Skip to content

Commit f5a9e41

Browse files
authored
Merge pull request #54 from fuzziecoder/codex/implement-api-documentation-with-swagger
Add BullMQ background jobs and Swagger OpenAPI docs for backend
2 parents 3eea30a + 6ef0261 commit f5a9e41

7 files changed

Lines changed: 581 additions & 4 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: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,11 +68,32 @@ Server starts at `http://localhost:4000` by default.
6868
- `AUTH_TOKEN_TTL_SECONDS`
6969
- `CORS_ALLOW_ORIGIN`
7070

71+
### Background jobs (BullMQ + Redis)
72+
73+
- The backend initializes BullMQ queues for:
74+
- email notification jobs (`email-notifications`) when a new order is created.
75+
- scheduled reminder jobs (`spot-reminders`) for upcoming spots/events.
76+
- recurring cleanup jobs (`expired-spot-cleanup`) for expired events.
77+
- Redis connection settings:
78+
- `REDIS_HOST` (default `127.0.0.1`)
79+
- `REDIS_PORT` (default `6379`)
80+
- `REDIS_PASSWORD` (optional)
81+
- Reminder timing:
82+
- `EVENT_REMINDER_BEFORE_HOURS` (default `2`)
83+
- If BullMQ or Redis dependencies are unavailable, backend continues to run with jobs disabled and logs a warning.
84+
85+
### API Documentation (Swagger/OpenAPI)
86+
87+
- OpenAPI JSON is available at `GET /api/docs/openapi.json`.
88+
- Swagger UI is available at `GET /api/docs`.
7189

7290
## Available endpoints
7391

7492
- `GET /api/health`
7593
- `POST /api/auth/login`
94+
- `GET /api/docs`
95+
- `GET /api/docs/openapi.json`
96+
- `GET /api/catalog`
7697
- `POST /api/auth/logout`
7798
- `GET /api/catalog` (cached)
7899
- `GET /api/catalog/:category` (`drinks`, `food`, `cigarettes`)
@@ -82,6 +103,8 @@ Server starts at `http://localhost:4000` by default.
82103
- `POST /api/orders` (auth required)
83104
- `GET /api/bills/:spotId` (admin only)
84105
- `DELETE /api/users/:userId` (admin only; removes the user and all related records)
106+
- `POST /api/jobs/reminders/run` (admin only; manually queue reminder jobs)
107+
- `POST /api/jobs/cleanup/run` (admin only; manually queue expired-event cleanup)
85108
- `POST /api/presence/heartbeat` (auth required)
86109
- `GET /api/presence/active?spotId=...` (auth required)
87110
- `PUT|POST /api/events/state/:eventKey` (auth required)

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 & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,45 @@
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;
143
import dotenv from 'dotenv';
244
import { z } from 'zod';
345

@@ -28,8 +70,55 @@ if (!result.success) {
2870
result.error.errors.forEach((err) => {
2971
console.error(`- ${err.path.join('.')}: ${err.message}`);
3072
});
73+
};
74+
75+
const isIntegerString = (value) => typeof value === 'string' && /^\d+$/.test(value);
76+
77+
const validateEnv = () => {
78+
const errors = [];
79+
const { VITE_SUPABASE_URL, VITE_SUPABASE_ANON_KEY } = process.env;
80+
81+
if (!VITE_SUPABASE_URL) {
82+
errors.push('VITE_SUPABASE_URL is required');
83+
} else {
84+
try {
85+
new URL(VITE_SUPABASE_URL);
86+
} catch {
87+
errors.push('VITE_SUPABASE_URL must be a valid URL');
88+
}
89+
}
90+
91+
if (!VITE_SUPABASE_ANON_KEY || VITE_SUPABASE_ANON_KEY.length < 10) {
92+
errors.push('VITE_SUPABASE_ANON_KEY is required and must be at least 10 characters');
93+
}
94+
95+
const numericKeys = [
96+
'AUTH_TOKEN_TTL_SECONDS',
97+
'LOGIN_RATE_LIMIT_MAX_ATTEMPTS',
98+
'LOGIN_RATE_LIMIT_WINDOW_MS',
99+
'LOGIN_RATE_LIMIT_BLOCK_MS',
100+
'REDIS_PORT',
101+
];
102+
103+
numericKeys.forEach((key) => {
104+
const value = process.env[key];
105+
if (value !== undefined && !isIntegerString(value)) {
106+
errors.push(`${key} must be an integer string`);
107+
}
108+
});
109+
110+
if (process.env.AUTH_TOKEN_SECRET && process.env.AUTH_TOKEN_SECRET.length < 16) {
111+
errors.push('AUTH_TOKEN_SECRET must be at least 16 characters when provided');
112+
}
113+
114+
if (errors.length > 0) {
115+
console.error('\n❌ Invalid environment configuration:\n');
116+
errors.forEach((error) => console.error(`- ${error}`));
117+
process.exit(1);
118+
}
119+
};
31120

32-
process.exit(1);
33-
}
121+
loadDotEnv();
122+
validateEnv();
34123

35-
export default result.data;
124+
export default process.env;

0 commit comments

Comments
 (0)