Skip to content

Commit 4c8b52c

Browse files
Revamping eatery routes and cache
1 parent 332c463 commit 4c8b52c

6 files changed

Lines changed: 102 additions & 136 deletions

File tree

src/auth/authService.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,9 @@ const generateRefreshToken = (): string => {
99
};
1010

1111
const generateAccessToken = (userId: number): string => {
12-
return jwt.sign(
13-
{ userId },
14-
process.env.ACCESS_TOKEN_SECRET!,
15-
{ expiresIn: '15m' },
16-
);
12+
return jwt.sign({ userId }, process.env.ACCESS_TOKEN_SECRET!, {
13+
expiresIn: '15m',
14+
});
1715
};
1816

1917
export const verifyDeviceUuid = async (deviceUuid: string) => {

src/eateries/eateries.schema.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,3 @@ export const getAllEateriesSchema = z.object({
55
days: z.coerce.number().int().min(0).default(0),
66
}),
77
});
8-
9-
// Schema for validating cached eatery data
10-
// This will be validated by the scraper that sends the data, max 500 eateries for safety
11-
export const EaterySchema = z.array(z.any()).max(500);

src/eateries/eateryRouter.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import { validateRequest } from '../middleware/validateRequest.js';
44
import { getAllEateriesSchema } from './eateries.schema.js';
55
import { getAllEateries, getEateryById } from './eateryController.js';
66

7-
export const eateryRouter = Router();
7+
const router = Router();
88

9-
eateryRouter.get('/', validateRequest(getAllEateriesSchema), getAllEateries);
10-
eateryRouter.get('/:eateryId', getEateryById);
9+
router.get('/', validateRequest(getAllEateriesSchema), getAllEateries);
10+
router.get('/:eateryId', getEateryById);
11+
12+
export default router;

src/eateries/eateryService.ts

Lines changed: 26 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,22 @@
11
import type { Event } from '@prisma/client';
22

3-
import { prisma } from '../prisma.js';
43
import { NotFoundError } from '../utils/AppError.js';
54
import { getAllEateriesData, refreshCacheFromDB } from '../utils/cache.js';
65
import type { EateryWithEvents } from '../utils/cache.js';
76

7+
async function getCachedEateries(): Promise<EateryWithEvents[]> {
8+
try {
9+
return getAllEateriesData();
10+
} catch {
11+
// If cache is cold (should never happen), populate it from the database
12+
await refreshCacheFromDB();
13+
return getAllEateriesData();
14+
}
15+
}
16+
817
export const getAllEateries = async (days: number = 0) => {
18+
const cachedEateries = await getCachedEateries();
19+
920
// Calculate date range for filtering events
1021
// days=0 means today, days=1 means tomorrow, etc.
1122
const now = new Date();
@@ -18,96 +29,7 @@ export const getAllEateries = async (days: number = 0) => {
1829
const endOfDay = new Date(targetDay);
1930
endOfDay.setHours(23, 59, 59, 999);
2031

21-
// Try to get from cache first
22-
try {
23-
const cachedEateries = getAllEateriesData();
24-
25-
// Filter events to only include those on the specified day
26-
const filteredEateries = filterEateries(
27-
cachedEateries,
28-
startOfDay,
29-
endOfDay,
30-
);
31-
32-
return filteredEateries;
33-
} catch {
34-
// If cache is cold (should never happen), fall back to database
35-
refreshCacheFromDB();
36-
const cachedEateries = getAllEateriesData();
37-
38-
// Filter events to only include those on the specified day
39-
const filteredEateries = filterEateries(
40-
cachedEateries,
41-
startOfDay,
42-
endOfDay,
43-
);
44-
45-
return filteredEateries;
46-
}
47-
};
48-
49-
export const getEateryById = async (eateryId: number) => {
50-
// Try to get eatery from cache first
51-
try {
52-
const cachedEateries = getAllEateriesData();
53-
const eatery = cachedEateries.find((e) => e.id === eateryId);
54-
55-
if (!eatery) {
56-
throw new NotFoundError('Eatery not found');
57-
}
58-
59-
return eatery;
60-
} catch (error) {
61-
// If cache is cold or eatery not found in cache, fall back to database
62-
if (error instanceof NotFoundError) {
63-
throw error;
64-
}
65-
refreshCacheFromDB();
66-
const cachedEateries = getAllEateriesData();
67-
const eatery = cachedEateries.find((e) => e.id === eateryId);
68-
69-
if (eatery) {
70-
return eatery;
71-
}
72-
73-
// As a last resort, fetch directly from the database
74-
const fromDB = await prisma.eatery.findUnique({
75-
where: { id: eateryId },
76-
include: {
77-
events: {
78-
include: {
79-
menu: {
80-
include: {
81-
items: {
82-
include: {
83-
dietaryPreferences: true,
84-
allergens: true,
85-
},
86-
},
87-
},
88-
},
89-
userEventVotes: true,
90-
},
91-
orderBy: {
92-
startTimestamp: 'asc',
93-
},
94-
},
95-
},
96-
});
97-
98-
if (!fromDB) {
99-
throw new NotFoundError('Eatery not in DB');
100-
}
101-
102-
return fromDB;
103-
}
104-
};
105-
106-
function filterEateries(
107-
cachedEateries: EateryWithEvents[],
108-
startOfDay: Date,
109-
endOfDay: Date,
110-
) {
32+
// Filter events to only include those on the specified day
11133
const filteredEateries = cachedEateries.map((eatery) => ({
11234
...eatery,
11335
events: eatery.events.filter((event: Event) => {
@@ -118,5 +40,17 @@ function filterEateries(
11840
return eventStart <= endOfDay && eventEnd >= startOfDay;
11941
}),
12042
}));
43+
12144
return filteredEateries;
122-
}
45+
};
46+
47+
export const getEateryById = async (eateryId: number) => {
48+
const cachedEateries = await getCachedEateries();
49+
const eatery = cachedEateries.find((e) => e.id === eateryId);
50+
51+
if (!eatery) {
52+
throw new NotFoundError(`Eatery with ID ${eateryId} not found`);
53+
}
54+
55+
return eatery;
56+
};

src/server.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,14 @@ import express from 'express';
44
import type { Request, Response } from 'express';
55

66
import authRouter from './auth/authRouter.js';
7-
import { eateryRouter } from './eateries/eateryRouter.js';
7+
import eateryRouter from './eateries/eateryRouter.js';
88
import { requireAuth } from './middleware/authentication.js';
99
import { globalErrorHandler } from './middleware/errorHandler.js';
1010
import { requestLogger } from './middleware/logger.js';
1111
import { ipRateLimiter } from './middleware/rateLimit.js';
1212
import { prisma } from './prisma.js';
1313
import userRouter from './users/userRouter.js';
14+
import { cacheRouter } from './utils/cache.js';
1415
import { refreshCacheFromDB } from './utils/cache.js';
1516

1617
const app = express();
@@ -49,6 +50,7 @@ router.get('/health', async (_: Request, res: Response) => {
4950

5051
// Public routes
5152
router.use('/auth', authRouter);
53+
router.use('/internal/cache', cacheRouter);
5254
router.use('/eateries', eateryRouter);
5355

5456
// Protected routes

src/utils/cache.ts

Lines changed: 65 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import NodeCache from 'node-cache';
2+
import { z } from 'zod';
23

34
import type { Eatery, Event } from '@prisma/client';
45

@@ -9,55 +10,64 @@ import {
910
Router,
1011
} from 'express';
1112

12-
import { EaterySchema } from '../eateries/eateries.schema.js';
13+
import { validateRequest } from '../middleware/validateRequest.js';
1314
import { prisma } from '../prisma.js';
1415
import { UnauthorizedError } from './AppError.js';
1516

1617
export const cacheRouter = Router();
17-
export const appCache = new NodeCache({ stdTTL: 0 }); // never expire
18+
const appCache = new NodeCache({ stdTTL: 0 }); // never expire
1819

1920
export type EateryWithEvents = Eatery & { events: Event[] };
2021

21-
function requireSecret(req: Request, _res: Response, next: NextFunction): void {
22+
/**
23+
* Cache keys used throughout the application
24+
*/
25+
const CACHE_KEYS = {
26+
ALL_EATERIES_DATA: 'allEateriesData',
27+
ALL_EATERIES_ETAG: 'allEateriesEtag',
28+
} as const;
29+
30+
/**
31+
* Schema for validating cached eatery data
32+
* This will be validated by the scraper that sends the data
33+
* Max 100 eateries for safety to prevent memory issues
34+
*/
35+
const allEateriesSchema = z.object({
36+
body: z.object({
37+
eateries: z.array(z.any()).max(100),
38+
}),
39+
});
40+
41+
function requireCacheRefreshSecret(
42+
req: Request,
43+
_res: Response,
44+
next: NextFunction,
45+
): void {
2246
const provided = req.header(process.env.CACHE_REFRESH_HEADER!);
2347
if (!provided || provided !== process.env.CACHE_REFRESH_SECRET) {
24-
throw new UnauthorizedError();
48+
throw new UnauthorizedError('Invalid cache refresh secret provided');
2549
}
2650
next();
2751
}
2852

2953
export function getAllEateriesData(): EateryWithEvents[] {
30-
const data = appCache.get<EateryWithEvents[]>('allEateriesData');
54+
const data = appCache.get<EateryWithEvents[]>(CACHE_KEYS.ALL_EATERIES_DATA);
3155
if (!data) {
32-
const err = new Error(
33-
'Cache is cold. allEateriesData is not set.',
34-
) as Error & { status?: number };
35-
err.status = 503;
36-
throw err;
56+
throw new Error('Cache miss: eateries data not found in cache');
3757
}
3858
return data;
3959
}
4060

41-
// Private cache refresh route for scraper
42-
cacheRouter.post('/internal/refresh-cache', requireSecret, (req, res) => {
43-
const parse = EaterySchema.safeParse(req.body);
44-
if (!parse.success) {
45-
res
46-
.status(400)
47-
.json({ error: 'Invalid payload', issues: parse.error.issues });
48-
return;
61+
export function getEateriesEtag(): string {
62+
const etag = appCache.get<string>(CACHE_KEYS.ALL_EATERIES_ETAG);
63+
if (!etag) {
64+
throw new Error('Cache miss: eateries ETag not found in cache');
4965
}
50-
51-
appCache.set('allEateriesData', parse.data);
52-
const etag = `"eateries-${Date.now()}"`;
53-
appCache.set('allEateriesEtag', etag);
54-
55-
console.log('Cache updated, allEateriesData refreshed.');
56-
res.status(200).json({ ok: true });
57-
});
66+
return etag;
67+
}
5868

5969
export async function refreshCacheFromDB() {
60-
appCache.del('allEateriesData');
70+
clearAppCache();
6171
const eateries = await prisma.eatery.findMany({
6272
include: {
6373
events: {
@@ -80,12 +90,36 @@ export async function refreshCacheFromDB() {
8090
},
8191
},
8292
});
83-
appCache.set('allEateriesData', eateries);
84-
const etag = `"eateries-${Date.now()}"`;
85-
appCache.set('allEateriesEtag', etag);
86-
console.log('Cache updated from DB, allEateriesData refreshed.');
93+
populateCache(eateries);
8794
}
8895

8996
export function clearAppCache(): void {
9097
appCache.flushAll();
9198
}
99+
100+
function populateCache(eateries: EateryWithEvents[]): void {
101+
appCache.set(CACHE_KEYS.ALL_EATERIES_DATA, eateries);
102+
const etag = `"eateries-${Date.now()}"`;
103+
appCache.set(CACHE_KEYS.ALL_EATERIES_ETAG, etag);
104+
console.log(
105+
`Cache updated at ${new Date().toISOString()}, ${eateries.length} eateries cached.`,
106+
);
107+
}
108+
109+
/**
110+
* Private cache refresh route for scraper
111+
* Allows the scraper to update the cache with new eatery data
112+
*/
113+
cacheRouter.post(
114+
'/',
115+
requireCacheRefreshSecret,
116+
validateRequest(allEateriesSchema),
117+
(req, res) => {
118+
const { eateries } = req.body;
119+
populateCache(eateries);
120+
return res.status(200).json({
121+
message: 'Cache refreshed successfully',
122+
count: eateries.length,
123+
});
124+
},
125+
);

0 commit comments

Comments
 (0)