Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,18 @@ VITE_GEMINI_API_KEY=your_gemini_api_key_here
# These are only used when mockApi.ts is active
VITE_ADMIN_PASSWORD=your_admin_password_here
VITE_USER_PASSWORD=your_user_password_here

# Backend server
PORT=4000
CORS_ALLOW_ORIGIN=*
AUTH_TOKEN_SECRET=replace_with_a_long_random_secret
AUTH_TOKEN_TTL_SECONDS=43200
LOGIN_RATE_LIMIT_MAX_ATTEMPTS=5
LOGIN_RATE_LIMIT_WINDOW_MS=900000
LOGIN_RATE_LIMIT_BLOCK_MS=900000

# BullMQ + Redis
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD=
EVENT_REMINDER_BEFORE_HOURS=2
23 changes: 23 additions & 0 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,32 @@ Server starts at `http://localhost:4000` by default.
- `AUTH_TOKEN_TTL_SECONDS`
- `CORS_ALLOW_ORIGIN`

### Background jobs (BullMQ + Redis)

- The backend initializes BullMQ queues for:
- email notification jobs (`email-notifications`) when a new order is created.
- scheduled reminder jobs (`spot-reminders`) for upcoming spots/events.
- recurring cleanup jobs (`expired-spot-cleanup`) for expired events.
- Redis connection settings:
- `REDIS_HOST` (default `127.0.0.1`)
- `REDIS_PORT` (default `6379`)
- `REDIS_PASSWORD` (optional)
- Reminder timing:
- `EVENT_REMINDER_BEFORE_HOURS` (default `2`)
- If BullMQ or Redis dependencies are unavailable, backend continues to run with jobs disabled and logs a warning.

### API Documentation (Swagger/OpenAPI)

- OpenAPI JSON is available at `GET /api/docs/openapi.json`.
- Swagger UI is available at `GET /api/docs`.

## Available endpoints

- `GET /api/health`
- `POST /api/auth/login`
- `GET /api/docs`
- `GET /api/docs/openapi.json`
- `GET /api/catalog`
- `POST /api/auth/logout`
- `GET /api/catalog` (cached)
- `GET /api/catalog/:category` (`drinks`, `food`, `cigarettes`)
Expand All @@ -82,6 +103,8 @@ Server starts at `http://localhost:4000` by default.
- `POST /api/orders` (auth required)
- `GET /api/bills/:spotId` (admin only)
- `DELETE /api/users/:userId` (admin only; removes the user and all related records)
- `POST /api/jobs/reminders/run` (admin only; manually queue reminder jobs)
- `POST /api/jobs/cleanup/run` (admin only; manually queue expired-event cleanup)
- `POST /api/presence/heartbeat` (auth required)
- `GET /api/presence/active?spotId=...` (auth required)
- `PUT|POST /api/events/state/:eventKey` (auth required)
Expand Down
46 changes: 46 additions & 0 deletions backend/db.js
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,23 @@ export const database = {
.sort((a, b) => b.date.localeCompare(a.date));
},

getSpotsBetween({ fromInclusive, toInclusive }) {
const fromValue = fromInclusive ? new Date(fromInclusive).getTime() : Number.NEGATIVE_INFINITY;
const toValue = toInclusive ? new Date(toInclusive).getTime() : Number.POSITIVE_INFINITY;

return state.spots
.filter((spot) => {
const timestamp = new Date(spot.date).getTime();
return timestamp >= fromValue && timestamp <= toValue;
})
.map((spot) => ({
id: spot.id,
location: spot.location,
date: spot.date,
hostUserId: spot.host_user_id,
}));
},

getOrders({ spotId, userId }) {
const orders = state.orders
.filter((order) => !spotId || order.spot_id === spotId)
Expand Down Expand Up @@ -308,6 +325,35 @@ export const database = {
orderCount: summaryRows.length,
};
},

cleanupExpiredSpots(referenceDate = new Date().toISOString()) {
const referenceTimestamp = new Date(referenceDate).getTime();
const expiredSpotIds = new Set(
state.spots
.filter((spot) => new Date(spot.date).getTime() < referenceTimestamp)
.map((spot) => spot.id)
);

if (expiredSpotIds.size === 0) {
return { removedSpotCount: 0, removedOrderCount: 0, removedOrderItemCount: 0 };
}

const previousOrderCount = state.orders.length;
const previousOrderItemCount = state.order_items.length;

state.spots = state.spots.filter((spot) => !expiredSpotIds.has(spot.id));
state.orders = state.orders.filter((order) => !expiredSpotIds.has(order.spot_id));
const activeOrderIds = new Set(state.orders.map((order) => order.id));
state.order_items = state.order_items.filter((item) => activeOrderIds.has(item.order_id));

persist();

return {
removedSpotCount: expiredSpotIds.size,
removedOrderCount: previousOrderCount - state.orders.length,
removedOrderItemCount: previousOrderItemCount - state.order_items.length,
};
},
};

export { dbPath };
95 changes: 92 additions & 3 deletions backend/env.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,45 @@
import { existsSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path';

const ENV_PATH = resolve(process.cwd(), '.env');

const parseEnvLine = (line) => {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) {
return null;
}

const separatorIndex = trimmed.indexOf('=');
if (separatorIndex < 0) {
return null;
}

const key = trimmed.slice(0, separatorIndex).trim();
let value = trimmed.slice(separatorIndex + 1).trim();

if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}

return { key, value };
};

const loadDotEnv = () => {
if (!existsSync(ENV_PATH)) {
return;
}

const contents = readFileSync(ENV_PATH, 'utf-8');
contents.split(/\r?\n/).forEach((line) => {
const parsed = parseEnvLine(line);
if (!parsed || process.env[parsed.key] !== undefined) {
return;
}

process.env[parsed.key] = parsed.value;
import dotenv from 'dotenv';
import { z } from 'zod';

Expand Down Expand Up @@ -28,8 +70,55 @@ if (!result.success) {
result.error.errors.forEach((err) => {
console.error(`- ${err.path.join('.')}: ${err.message}`);
});
};

const isIntegerString = (value) => typeof value === 'string' && /^\d+$/.test(value);

const validateEnv = () => {
const errors = [];
const { VITE_SUPABASE_URL, VITE_SUPABASE_ANON_KEY } = process.env;

if (!VITE_SUPABASE_URL) {
errors.push('VITE_SUPABASE_URL is required');
} else {
try {
new URL(VITE_SUPABASE_URL);
} catch {
errors.push('VITE_SUPABASE_URL must be a valid URL');
}
}

if (!VITE_SUPABASE_ANON_KEY || VITE_SUPABASE_ANON_KEY.length < 10) {
errors.push('VITE_SUPABASE_ANON_KEY is required and must be at least 10 characters');
}

const numericKeys = [
'AUTH_TOKEN_TTL_SECONDS',
'LOGIN_RATE_LIMIT_MAX_ATTEMPTS',
'LOGIN_RATE_LIMIT_WINDOW_MS',
'LOGIN_RATE_LIMIT_BLOCK_MS',
'REDIS_PORT',
];

numericKeys.forEach((key) => {
const value = process.env[key];
if (value !== undefined && !isIntegerString(value)) {
errors.push(`${key} must be an integer string`);
}
});

if (process.env.AUTH_TOKEN_SECRET && process.env.AUTH_TOKEN_SECRET.length < 16) {
errors.push('AUTH_TOKEN_SECRET must be at least 16 characters when provided');
}

if (errors.length > 0) {
console.error('\n❌ Invalid environment configuration:\n');
errors.forEach((error) => console.error(`- ${error}`));
process.exit(1);
}
};

process.exit(1);
}
loadDotEnv();
validateEnv();

export default result.data;
export default process.env;
Loading
Loading