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
16 changes: 13 additions & 3 deletions backend/db.js
Original file line number Diff line number Diff line change
Expand Up @@ -214,11 +214,21 @@ export const database = {
}));
},

getOrders({ spotId, userId }) {
// UPDATED: now supports from, to (date range on createdAt) and sort (asc/desc)
getOrders({ spotId, userId, from, to, sort = 'desc' }) {
const fromTime = from ? new Date(from).getTime() : null;
const toTime = to ? new Date(to).getTime() : null;

const orders = state.orders
.filter((order) => !spotId || order.spot_id === spotId)
.filter((order) => !userId || order.user_id === userId)
.sort((a, b) => b.created_at.localeCompare(a.created_at));
.filter((order) => !fromTime || new Date(order.created_at).getTime() >= fromTime)
.filter((order) => !toTime || new Date(order.created_at).getTime() <= toTime)
.sort((a, b) =>
sort === 'asc'
? a.created_at.localeCompare(b.created_at)
: b.created_at.localeCompare(a.created_at)
);

return orders.map((order) => {
const orderItems = state.order_items.filter((item) => item.order_id === order.id);
Expand Down Expand Up @@ -356,4 +366,4 @@ export const database = {
},
};

export { dbPath };
export { dbPath };
61 changes: 1 addition & 60 deletions backend/env.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,72 +40,13 @@ const loadDotEnv = () => {
}

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

dotenv.config();

const envSchema = z.object({
VITE_SUPABASE_URL: z.string().url(),
VITE_SUPABASE_ANON_KEY: z.string().min(10),
PORT: z.string().optional(),
AUTH_TOKEN_SECRET: z.string().min(16).optional(),
AUTH_TOKEN_TTL_SECONDS: z.string().regex(/^\d+$/).optional(),
CORS_ALLOW_ORIGIN: z.string().optional(),
LOGIN_RATE_LIMIT_MAX_ATTEMPTS: z.string().regex(/^\d+$/).optional(),
LOGIN_RATE_LIMIT_WINDOW_MS: z.string().regex(/^\d+$/).optional(),
LOGIN_RATE_LIMIT_BLOCK_MS: z.string().regex(/^\d+$/).optional(),
GLOBAL_RATE_LIMIT_MAX_REQUESTS: z.string().regex(/^\d+$/).optional(),
GLOBAL_RATE_LIMIT_WINDOW_MS: z.string().regex(/^\d+$/).optional(),
SECURITY_HEADERS_CSP: z.string().optional(),
DATABASE_URL: z.string().optional(),
REDIS_URL: z.string().optional(),
UPSTASH_REDIS_REST_URL: z.string().optional(),
UPSTASH_REDIS_REST_TOKEN: z.string().optional(),
STORAGE_DRIVER: z.enum(['s3', 'cloudinary', 'local']).optional(),
AWS_REGION: z.string().optional(),
AWS_S3_BUCKET: z.string().optional(),
AWS_ACCESS_KEY_ID: z.string().optional(),
AWS_SECRET_ACCESS_KEY: z.string().optional(),
CLOUDINARY_CLOUD_NAME: z.string().optional(),
CLOUDINARY_API_KEY: z.string().optional(),
CLOUDINARY_API_SECRET: z.string().optional(),
REDIS_URL: z.string().url().optional(),
REDIS_KEY_PREFIX: z.string().optional(),
CACHE_DEFAULT_TTL_SECONDS: z.string().regex(/^\d+$/).optional(),
PRESENCE_TTL_SECONDS: z.string().regex(/^\d+$/).optional(),
EVENT_STATE_DEFAULT_TTL_SECONDS: z.string().regex(/^\d+$/).optional(),
});

const result = envSchema.safeParse(process.env);

if (!result.success) {
console.error('\n❌ Invalid environment configuration:\n');

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',
Expand Down Expand Up @@ -136,4 +77,4 @@ const validateEnv = () => {
loadDotEnv();
validateEnv();

export default process.env;
export default process.env;
48 changes: 28 additions & 20 deletions backend/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,6 @@ const getRateLimitState = (key) => {
return existing;
};


const getGlobalRateLimitState = (key) => {
const now = Date.now();
const existing = globalRequests.get(key);
Expand Down Expand Up @@ -168,7 +167,6 @@ const recordFailedLoginAttempt = (key) => {
};

const sendJson = (res, statusCode, body, extraHeaders = {}) => {
const sendJson = (res, statusCode, body) => {
res.writeHead(statusCode, {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': CORS_ALLOW_ORIGIN,
Expand Down Expand Up @@ -287,19 +285,6 @@ const server = createServer(async (req, res) => {
}

const loginKey = getLoginKey(req, username);
const rateLimitState = getRateLimitState(loginKey);
const now = Date.now();
if (rateLimitState.blockedUntil > now) {
const retryAfterSeconds = Math.ceil((rateLimitState.blockedUntil - now) / 1000);
sendJson(
res,
429,
{
error: 'Too many failed login attempts. Please try again later.',
retryAfterSeconds,
},
{ 'Retry-After': String(Math.max(retryAfterSeconds, 1)) }
);
const retryAfterSeconds = await rateLimiter.getBlockedSeconds(loginKey);
if (retryAfterSeconds > 0) {
sendJson(res, 429, {
Expand Down Expand Up @@ -372,6 +357,7 @@ const server = createServer(async (req, res) => {
return;
}

// UPDATED: supports from, to (date range) and sort (asc/desc) query params
if (method === 'GET' && path === '/api/orders') {
const authedUser = await getUserFromAuthHeader(req.headers.authorization);
if (!authedUser) {
Expand All @@ -381,6 +367,24 @@ const server = createServer(async (req, res) => {

const spotId = parsedUrl.searchParams.get('spotId');
const userId = parsedUrl.searchParams.get('userId');
const from = parsedUrl.searchParams.get('from');
const to = parsedUrl.searchParams.get('to');
const sort = parsedUrl.searchParams.get('sort') ?? 'desc';

if (!['asc', 'desc'].includes(sort)) {
sendJson(res, 400, { error: "Invalid sort value. Must be 'asc' or 'desc'." });
return;
}

if (from && isNaN(Date.parse(from))) {
sendJson(res, 400, { error: "Invalid 'from' date. Use ISO 8601 format (e.g. 2025-01-01)." });
return;
}

if (to && isNaN(Date.parse(to))) {
sendJson(res, 400, { error: "Invalid 'to' date. Use ISO 8601 format (e.g. 2025-12-31)." });
return;
}

if (authedUser.role !== 'admin' && userId && userId !== authedUser.id) {
sendJson(res, 403, { error: 'Forbidden' });
Expand All @@ -389,10 +393,11 @@ const server = createServer(async (req, res) => {

const effectiveUserId = authedUser.role === 'admin' ? userId : authedUser.id;

const orders = database.getOrders({ spotId, userId: effectiveUserId });
const orders = database.getOrders({ spotId, userId: effectiveUserId, from, to, sort });
sendJson(res, 200, orders);
return;
}

if (method === 'GET' && path.startsWith('/api/orders/')) {
const orderId = path.replace('/api/orders/', '');

Expand Down Expand Up @@ -507,7 +512,7 @@ const server = createServer(async (req, res) => {
}

if (method === 'POST' && path === '/api/jobs/reminders/run') {
const authedUser = getUserFromAuthHeader(req.headers.authorization);
const authedUser = await getUserFromAuthHeader(req.headers.authorization);
if (!authedUser || authedUser.role !== 'admin') {
sendJson(res, 403, { error: 'Forbidden' });
return;
Expand All @@ -521,14 +526,17 @@ const server = createServer(async (req, res) => {
}

if (method === 'POST' && path === '/api/jobs/cleanup/run') {
const authedUser = getUserFromAuthHeader(req.headers.authorization);
const authedUser = await getUserFromAuthHeader(req.headers.authorization);
if (!authedUser || authedUser.role !== 'admin') {
sendJson(res, 403, { error: 'Forbidden' });
return;
}

await jobSystem.enqueueExpiredSpotCleanup();
sendJson(res, 202, { accepted: true, jobsEnabled: jobSystem.enabled });
return;
}

if (method === 'POST' && path === '/api/presence/heartbeat') {
const authedUser = await getUserFromAuthHeader(req.headers.authorization);
if (!authedUser) {
Expand Down Expand Up @@ -606,6 +614,7 @@ server.listen(port, () => {
console.log(`Backend API running on http://localhost:${port}`);
console.log(`Using local database at: ${dbPath}`);
console.log(`Swagger docs available at http://localhost:${port}/api/docs`);
console.log(`Cache mode: ${cache.mode}`);
});

process.on('SIGINT', async () => {
Expand All @@ -616,5 +625,4 @@ process.on('SIGINT', async () => {
process.on('SIGTERM', async () => {
await jobSystem.shutdown();
process.exit(0);
console.log(`Cache mode: ${cache.mode}`);
});
});