Skip to content
117 changes: 117 additions & 0 deletions backend/src/routes/game.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { Hono } from "hono";
import { db } from "../db/index";
import { games } from "../db/schema";
import { eq, like, gte, lte, lt, and } from "drizzle-orm";
import { z } from "zod";
import { requireSession } from "../lib/middleware";

export const gamesRoute = new Hono();

// zod validation
const gamesQuerySchema = z.object({
limit: z.coerce.number().min(1).max(50).default(10),
cursor: z.coerce.number().optional(),
search: z.string().min(1).optional(),
maxLikes: z.coerce.number().min(0).optional(),
createdAfter: z.string().date().optional(),
createdBefore: z.string().date().optional(),
Comment thread
zia0307 marked this conversation as resolved.
});

/**
* GET /games
*
* Returns all active games with cursor-based (scroll) pagination.
*
* Requirements:
* - User must be authenticated
*
* Query parameters:
* - limit Number of results per page (default: 10, max: 50)
* - cursor createdAt timestamp of the last received item; omit for first page
* - search Filter by title
* - minLikes Filter by minimum like count
* - maxLikes Filter by maximum like count
* - createdAfter Return games created after this date (YYYY-MM-DD)
* - createdBefore Return games created before this date (YYYY-MM-DD)
Comment thread
zia0307 marked this conversation as resolved.
*
* Response:
* - games[] Array of game objects
* - nextCursor Pass as `cursor` in the next request; null means no more results
*/
gamesRoute.get("/", requireSession, async (c) => {
try {
const queryParse = gamesQuerySchema.safeParse(c.req.query());

if (!queryParse.success) {
return c.json({ error: "Validation failed", details: queryParse.error.format() }, 400);
}

const { limit, cursor, search, maxLikes, createdAfter, createdBefore } = queryParse.data;

/* Filter conditions */
const conditions = [eq(games.isActive, true)];

// cursor pagination - only fetch records older than the last seen item
if (cursor !== undefined) {
conditions.push(lt(games.createdAt, cursor));
}
Comment thread
zia0307 marked this conversation as resolved.

if (search) {
conditions.push(like(games.title, `%${search}%`));
}

if (maxLikes !== undefined) {
conditions.push(lte(games.countLikes, maxLikes));
}

if (createdAfter) {
const afterTimestamp = Math.floor(new Date(createdAfter).getTime() / 1000);
conditions.push(gte(games.createdAt, afterTimestamp));
}

if (createdBefore) {
const beforeTimestamp = Math.floor(new Date(createdBefore).getTime() / 1000);
conditions.push(lte(games.createdAt, beforeTimestamp));
Comment thread
zia0307 marked this conversation as resolved.
}
Comment on lines +67 to +75
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

createdAt is defined as an integer timestamp column with mode: "timestamp" in the schema (used elsewhere with new Date() values). For consistency and to avoid type/serialization issues, compare games.createdAt against Date objects instead of manually converting to epoch seconds. Also, with createdBefore as YYYY-MM-DD, using midnight with lte will exclude most of that day; consider filtering by < startOfNextDay(createdBefore) (or otherwise making the end date inclusive).

Copilot uses AI. Check for mistakes.

const whereClause = and(...conditions);

/* Data retrieval - fetch one extra so we can tell if there's a next page */
const gamesList = await db.query.games.findMany({
where: whereClause,
orderBy: (g, { desc }) => [desc(g.createdAt)],
limit: limit + 1,
Comment thread
zia0307 marked this conversation as resolved.
columns: {
id: true,
title: true,
description: true,
gameUrl: true,
coverMediaId: true,
createdBy: true,
countLikes: true,
countDislikes: true,
countSuperlikes: true,
score: true,
viewCount: true,
createdAt: true,
updatedAt: true,
},
});

const hasNextPage = gamesList.length > limit;
const results = hasNextPage ? gamesList.slice(0, limit) : gamesList;
const nextCursor = hasNextPage ? results[results.length - 1].createdAt : null;

return c.json({
success: true,
limit,
nextCursor,
games: results,
Comment on lines +101 to +109
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nextCursor is returned as results[...].createdAt. If createdAt is a Date, the JSON response will contain an ISO string cursor, but the request schema currently expects a numeric cursor, so the next request will fail validation. Make the response and request cursor formats consistent.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can try returning the timestamps as numbers instead of ISO strings.

});
} catch (error) {
console.error("Get games error:", error);
return c.json({ error: "Failed to fetch games", message: "An error occurred while retrieving games." }, 500);
}
});

export default gamesRoute;
Comment on lines +116 to +117
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This route is exported but is not currently mounted anywhere in src/index.ts (only /auth and /profile are routed), so GET /games will never be reachable. Add it to the main app router (e.g., app.route("/games", gamesRoute)) to actually expose the endpoint.

Copilot uses AI. Check for mistakes.
Loading