Skip to content

Commit d64b1d9

Browse files
committed
Add BullMQ background job scaffolding and Swagger API docs
1 parent e5fa696 commit d64b1d9

7 files changed

Lines changed: 582 additions & 28 deletions

File tree

.env.example

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,18 @@ VITE_GEMINI_API_KEY=your_gemini_api_key_here
99
# These are only used when mockApi.ts is active
1010
VITE_ADMIN_PASSWORD=your_admin_password_here
1111
VITE_USER_PASSWORD=your_user_password_here
12+
13+
# Backend server
14+
PORT=4000
15+
CORS_ALLOW_ORIGIN=*
16+
AUTH_TOKEN_SECRET=replace_with_a_long_random_secret
17+
AUTH_TOKEN_TTL_SECONDS=43200
18+
LOGIN_RATE_LIMIT_MAX_ATTEMPTS=5
19+
LOGIN_RATE_LIMIT_WINDOW_MS=900000
20+
LOGIN_RATE_LIMIT_BLOCK_MS=900000
21+
22+
# BullMQ + Redis
23+
REDIS_HOST=127.0.0.1
24+
REDIS_PORT=6379
25+
REDIS_PASSWORD=
26+
EVENT_REMINDER_BEFORE_HOURS=2

backend/README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,11 +47,31 @@ Server starts at `http://localhost:4000` by default.
4747
- `AUTH_TOKEN_TTL_SECONDS`
4848
- `CORS_ALLOW_ORIGIN`
4949

50+
### Background jobs (BullMQ + Redis)
51+
52+
- The backend initializes BullMQ queues for:
53+
- email notification jobs (`email-notifications`) when a new order is created.
54+
- scheduled reminder jobs (`spot-reminders`) for upcoming spots/events.
55+
- recurring cleanup jobs (`expired-spot-cleanup`) for expired events.
56+
- Redis connection settings:
57+
- `REDIS_HOST` (default `127.0.0.1`)
58+
- `REDIS_PORT` (default `6379`)
59+
- `REDIS_PASSWORD` (optional)
60+
- Reminder timing:
61+
- `EVENT_REMINDER_BEFORE_HOURS` (default `2`)
62+
- If BullMQ or Redis dependencies are unavailable, backend continues to run with jobs disabled and logs a warning.
63+
64+
### API Documentation (Swagger/OpenAPI)
65+
66+
- OpenAPI JSON is available at `GET /api/docs/openapi.json`.
67+
- Swagger UI is available at `GET /api/docs`.
5068

5169
## Available endpoints
5270

5371
- `GET /api/health`
5472
- `POST /api/auth/login`
73+
- `GET /api/docs`
74+
- `GET /api/docs/openapi.json`
5575
- `GET /api/catalog`
5676
- `GET /api/catalog/:category` (`drinks`, `food`, `cigarettes`)
5777
- `GET /api/spots`
@@ -60,6 +80,8 @@ Server starts at `http://localhost:4000` by default.
6080
- `POST /api/orders` (auth required)
6181
- `GET /api/bills/:spotId` (admin only)
6282
- `DELETE /api/users/:userId` (admin only; removes the user and all related records)
83+
- `POST /api/jobs/reminders/run` (admin only; manually queue reminder jobs)
84+
- `POST /api/jobs/cleanup/run` (admin only; manually queue expired-event cleanup)
6385

6486
## Example login payload
6587

backend/db.js

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,23 @@ export const database = {
197197
.sort((a, b) => b.date.localeCompare(a.date));
198198
},
199199

200+
getSpotsBetween({ fromInclusive, toInclusive }) {
201+
const fromValue = fromInclusive ? new Date(fromInclusive).getTime() : Number.NEGATIVE_INFINITY;
202+
const toValue = toInclusive ? new Date(toInclusive).getTime() : Number.POSITIVE_INFINITY;
203+
204+
return state.spots
205+
.filter((spot) => {
206+
const timestamp = new Date(spot.date).getTime();
207+
return timestamp >= fromValue && timestamp <= toValue;
208+
})
209+
.map((spot) => ({
210+
id: spot.id,
211+
location: spot.location,
212+
date: spot.date,
213+
hostUserId: spot.host_user_id,
214+
}));
215+
},
216+
200217
getOrders({ spotId, userId }) {
201218
const orders = state.orders
202219
.filter((order) => !spotId || order.spot_id === spotId)
@@ -308,6 +325,35 @@ export const database = {
308325
orderCount: summaryRows.length,
309326
};
310327
},
328+
329+
cleanupExpiredSpots(referenceDate = new Date().toISOString()) {
330+
const referenceTimestamp = new Date(referenceDate).getTime();
331+
const expiredSpotIds = new Set(
332+
state.spots
333+
.filter((spot) => new Date(spot.date).getTime() < referenceTimestamp)
334+
.map((spot) => spot.id)
335+
);
336+
337+
if (expiredSpotIds.size === 0) {
338+
return { removedSpotCount: 0, removedOrderCount: 0, removedOrderItemCount: 0 };
339+
}
340+
341+
const previousOrderCount = state.orders.length;
342+
const previousOrderItemCount = state.order_items.length;
343+
344+
state.spots = state.spots.filter((spot) => !expiredSpotIds.has(spot.id));
345+
state.orders = state.orders.filter((order) => !expiredSpotIds.has(order.spot_id));
346+
const activeOrderIds = new Set(state.orders.map((order) => order.id));
347+
state.order_items = state.order_items.filter((item) => activeOrderIds.has(item.order_id));
348+
349+
persist();
350+
351+
return {
352+
removedSpotCount: expiredSpotIds.size,
353+
removedOrderCount: previousOrderCount - state.orders.length,
354+
removedOrderItemCount: previousOrderItemCount - state.order_items.length,
355+
};
356+
},
311357
};
312358

313359
export { dbPath };

backend/env.js

Lines changed: 92 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,95 @@
1-
import dotenv from 'dotenv';
2-
import { z } from 'zod';
3-
4-
dotenv.config();
5-
6-
const envSchema = z.object({
7-
VITE_SUPABASE_URL: z.string().url(),
8-
VITE_SUPABASE_ANON_KEY: z.string().min(10),
9-
PORT: z.string().optional(),
10-
AUTH_TOKEN_SECRET: z.string().min(16).optional(),
11-
AUTH_TOKEN_TTL_SECONDS: z.string().regex(/^\d+$/).optional(),
12-
CORS_ALLOW_ORIGIN: z.string().optional(),
13-
LOGIN_RATE_LIMIT_MAX_ATTEMPTS: z.string().regex(/^\d+$/).optional(),
14-
LOGIN_RATE_LIMIT_WINDOW_MS: z.string().regex(/^\d+$/).optional(),
15-
LOGIN_RATE_LIMIT_BLOCK_MS: z.string().regex(/^\d+$/).optional(),
16-
});
17-
18-
const result = envSchema.safeParse(process.env);
19-
20-
if (!result.success) {
21-
console.error('\n❌ Invalid environment configuration:\n');
22-
23-
result.error.errors.forEach((err) => {
24-
console.error(`- ${err.path.join('.')}: ${err.message}`);
1+
import { existsSync, readFileSync } from 'node:fs';
2+
import { resolve } from 'node:path';
3+
4+
const ENV_PATH = resolve(process.cwd(), '.env');
5+
6+
const parseEnvLine = (line) => {
7+
const trimmed = line.trim();
8+
if (!trimmed || trimmed.startsWith('#')) {
9+
return null;
10+
}
11+
12+
const separatorIndex = trimmed.indexOf('=');
13+
if (separatorIndex < 0) {
14+
return null;
15+
}
16+
17+
const key = trimmed.slice(0, separatorIndex).trim();
18+
let value = trimmed.slice(separatorIndex + 1).trim();
19+
20+
if (
21+
(value.startsWith('"') && value.endsWith('"')) ||
22+
(value.startsWith("'") && value.endsWith("'"))
23+
) {
24+
value = value.slice(1, -1);
25+
}
26+
27+
return { key, value };
28+
};
29+
30+
const loadDotEnv = () => {
31+
if (!existsSync(ENV_PATH)) {
32+
return;
33+
}
34+
35+
const contents = readFileSync(ENV_PATH, 'utf-8');
36+
contents.split(/\r?\n/).forEach((line) => {
37+
const parsed = parseEnvLine(line);
38+
if (!parsed || process.env[parsed.key] !== undefined) {
39+
return;
40+
}
41+
42+
process.env[parsed.key] = parsed.value;
2543
});
44+
};
45+
46+
const isIntegerString = (value) => typeof value === 'string' && /^\d+$/.test(value);
47+
48+
const validateEnv = () => {
49+
const errors = [];
50+
const { VITE_SUPABASE_URL, VITE_SUPABASE_ANON_KEY } = process.env;
51+
52+
if (!VITE_SUPABASE_URL) {
53+
errors.push('VITE_SUPABASE_URL is required');
54+
} else {
55+
try {
56+
new URL(VITE_SUPABASE_URL);
57+
} catch {
58+
errors.push('VITE_SUPABASE_URL must be a valid URL');
59+
}
60+
}
61+
62+
if (!VITE_SUPABASE_ANON_KEY || VITE_SUPABASE_ANON_KEY.length < 10) {
63+
errors.push('VITE_SUPABASE_ANON_KEY is required and must be at least 10 characters');
64+
}
65+
66+
const numericKeys = [
67+
'AUTH_TOKEN_TTL_SECONDS',
68+
'LOGIN_RATE_LIMIT_MAX_ATTEMPTS',
69+
'LOGIN_RATE_LIMIT_WINDOW_MS',
70+
'LOGIN_RATE_LIMIT_BLOCK_MS',
71+
'REDIS_PORT',
72+
];
73+
74+
numericKeys.forEach((key) => {
75+
const value = process.env[key];
76+
if (value !== undefined && !isIntegerString(value)) {
77+
errors.push(`${key} must be an integer string`);
78+
}
79+
});
80+
81+
if (process.env.AUTH_TOKEN_SECRET && process.env.AUTH_TOKEN_SECRET.length < 16) {
82+
errors.push('AUTH_TOKEN_SECRET must be at least 16 characters when provided');
83+
}
84+
85+
if (errors.length > 0) {
86+
console.error('\n❌ Invalid environment configuration:\n');
87+
errors.forEach((error) => console.error(`- ${error}`));
88+
process.exit(1);
89+
}
90+
};
2691

27-
process.exit(1);
28-
}
92+
loadDotEnv();
93+
validateEnv();
2994

30-
export default result.data;
95+
export default process.env;

0 commit comments

Comments
 (0)