diff --git a/src/routes/fingerprint.ts b/src/routes/fingerprint.ts index 6f837791..34655901 100644 --- a/src/routes/fingerprint.ts +++ b/src/routes/fingerprint.ts @@ -32,6 +32,31 @@ import { getCorrelationId } from "../middleware/correlation"; import { logger } from "../config/logger"; import { getRequestId } from "../lib/requestContext"; +// In-flight request tracking for graceful shutdown drain +let inFlightFingerprintRequests = 0; + +export async function drainFingerprintRequests(timeoutMs = 10000): Promise { + const start = Date.now(); + if (inFlightFingerprintRequests === 0) { + logger.info("No in-flight /api/fingerprint requests to drain"); + return; + } + + logger.info({ inFlight: inFlightFingerprintRequests }, "Draining in-flight /api/fingerprint requests..."); + + while (inFlightFingerprintRequests > 0) { + if (Date.now() - start > timeoutMs) { + logger.warn({ inFlight: inFlightFingerprintRequests }, "Timeout waiting for /api/fingerprint requests to drain"); + break; + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + + if (inFlightFingerprintRequests === 0) { + logger.info("Successfully drained all /api/fingerprint requests"); + } +} + export const fingerprintRouter = Router(); /** @@ -45,6 +70,7 @@ fingerprintRouter.get( "/", fingerprintRateLimiter, (req: Request, res: Response, next: NextFunction): void => { + inFlightFingerprintRequests++; const correlationId = getCorrelationId() ?? "unknown"; const reqId = getRequestId() ?? "unknown"; @@ -76,6 +102,8 @@ fingerprintRouter.get( "fingerprint_route_error", ); next(err); + } finally { + inFlightFingerprintRequests--; } }, ); diff --git a/src/routes/users.ts b/src/routes/users.ts index 1bab9a57..c26b714b 100644 --- a/src/routes/users.ts +++ b/src/routes/users.ts @@ -238,23 +238,69 @@ usersRouter.get( (res.locals.correlationId as string | undefined) ?? getRequestId(); try { - stellarAddressSchema.parse(address); - } catch { - return res.status(400).json({ error: { code: "invalid_address" } }); + const userId = req.user!.id; + const result = await getCurrentUserProfile(userId); + + if (!result.ok) { + throw result.error; + } + + const profile = result.value; + logger.info( + { + correlationId, + userId, + stellarAddress: profile.stellarAddress, + ...profile.totals, + }, + "user_me_profile_loaded", + ); + + // Strong ETag on the profile payload; 304 if client already has it. + const responsePayload = { data: profile }; + if (conditionalGet(responsePayload, req, res)) return; + return res.json(responsePayload); + } catch (e) { + return next(e); } }, ); - const querySchema = z.object({ - status: z.enum(["pending", "confirmed", "won", "lost", "claimed"]).optional(), - cursor: z - .string() - .regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\|[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i) - .optional(), - limit: z.coerce.number().int().min(1).max(100), - }); +// --------------------------------------------------------------------------- +// GET /api/users/:address/predictions +// --------------------------------------------------------------------------- - const query = querySchema.parse({ status, cursor, limit }); +/** + * Returns a cursor-paginated list of predictions for the given Stellar address. + * + * Path parameters: + * - :address — a valid 56-char Stellar G-address + * + * Query parameters: + * - status (optional) — filter by prediction status enum + * - cursor (optional) — opaque base64url token from the previous page's `nextCursor` + * - limit (optional, default 20, max 100) — page size + * + * Response: + * { data: UserPredictionRow[], nextCursor: string | null } + * + * Caching: + * Strong ETag on the page payload; clients may revalidate with If-None-Match + * and receive 304 Not Modified when the page is unchanged. + * + * Errors: + * 400 invalid_address — path param is not a valid G… Stellar address + * 400 validation_error — query params fail the zod schema + * 404 not_found — no user row for that address + */ +usersRouter.get( + "/:address/predictions", + usersRateLimit, + async (req: Request, res: Response, next: NextFunction) => { + // Prefer the access-log correlation ID; fall back to ALS for non-route callers. + const correlationId = + (res.locals.correlationId as string | undefined) ?? getRequestId(); + const reqId = correlationId; try { // Validate the path parameter :address at the route boundary before touching the DB. diff --git a/src/server.ts b/src/server.ts index b2941e11..af5f8bee 100644 --- a/src/server.ts +++ b/src/server.ts @@ -12,6 +12,7 @@ import { startSlowQueryAlerter } from "./workers/slowQueryAlerter"; import { startPredictionsConfirmer } from "./workers/predictionsConfirmer"; import { drainSearchRequests } from "./routes/search"; import { drainExportsRequests } from "./routes/exports"; +import { drainFingerprintRequests } from "./routes/fingerprint"; const app = createApp(); let webhookWorker: WebhookWorker | null = null; @@ -47,6 +48,8 @@ connectWithRetry() await drainSearchRequests(4000); // Ensure in-flight /api/exports requests finish await drainExportsRequests(4000); + // Ensure in-flight /api/fingerprint requests finish + await drainFingerprintRequests(4000); stopScheduler(); await closeDb(); diff --git a/tests/fingerprint.test.ts b/tests/fingerprint.test.ts index 694687ad..d0961eec 100644 --- a/tests/fingerprint.test.ts +++ b/tests/fingerprint.test.ts @@ -48,6 +48,7 @@ import request from "supertest"; import { createApp } from "../src/index"; +import { drainFingerprintRequests } from "../src/routes/fingerprint"; import { normalizeContentType, normalizeAccept, @@ -451,7 +452,17 @@ describe("GET /api/fingerprint", () => { }); }); -// ── 35: fingerprintMiddleware error handling ──────────────────────────── +// ── 35: fingerprintRoute drain ─────────────────────────────────────── + +describe("drainFingerprintRequests()", () => { + it("returns immediately when no in-flight requests", async () => { + const start = Date.now(); + await drainFingerprintRequests(1000); + expect(Date.now() - start).toBeLessThan(100); + }); +}); + +// ── 36: fingerprintMiddleware error handling ──────────────────────────── describe("fingerprintMiddleware error handling", () => { it("calls next() and does not throw when an internal error occurs", () => {