Compare commits
No commits in common. "0118798e36d101066f09769ccaa50fc59fdb3aca" and "caa2f7d395d17d6ff272566710093a89960543b0" have entirely different histories.
0118798e36
...
caa2f7d395
15 changed files with 42 additions and 495 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -19,4 +19,3 @@ data-pipeline/stage-4-merge/output/
|
||||||
data-pipeline/db/pipeline.db
|
data-pipeline/db/pipeline.db
|
||||||
data-pipeline/reports/
|
data-pipeline/reports/
|
||||||
data-pipeline/.env
|
data-pipeline/.env
|
||||||
.aider*
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import request from "supertest";
|
import request from "supertest";
|
||||||
import type { GameSession, AnswerResult } from "@lila/shared";
|
import type { GameSession, AnswerResult } from "@lila/shared";
|
||||||
import { auth } from "../lib/auth.js";
|
|
||||||
|
|
||||||
type SuccessResponse<T> = { success: true; data: T };
|
type SuccessResponse<T> = { success: true; data: T };
|
||||||
type ErrorResponse = { success: false; error: string };
|
type ErrorResponse = { success: false; error: string };
|
||||||
|
|
@ -49,36 +48,6 @@ vi.mock("better-auth/node", () => ({
|
||||||
toNodeHandler: vi.fn().mockReturnValue(vi.fn()),
|
toNodeHandler: vi.fn().mockReturnValue(vi.fn()),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const mockGetSession = vi.mocked(auth.api.getSession);
|
|
||||||
|
|
||||||
function setAuthenticatedUser(userId: string) {
|
|
||||||
mockGetSession.mockResolvedValue({
|
|
||||||
session: {
|
|
||||||
id: "session-1",
|
|
||||||
userId,
|
|
||||||
token: "fake-token",
|
|
||||||
expiresAt: new Date(Date.now() + 1000 * 60 * 60),
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
ipAddress: null,
|
|
||||||
userAgent: null,
|
|
||||||
},
|
|
||||||
user: {
|
|
||||||
id: userId,
|
|
||||||
name: "Test User",
|
|
||||||
email: "test@test.com",
|
|
||||||
emailVerified: false,
|
|
||||||
image: null,
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function setGuestUser() {
|
|
||||||
mockGetSession.mockResolvedValue(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
import { getGameTerms, getDistractors } from "@lila/db";
|
import { getGameTerms, getDistractors } from "@lila/db";
|
||||||
import { createApp } from "../app.js";
|
import { createApp } from "../app.js";
|
||||||
|
|
||||||
|
|
@ -107,7 +76,6 @@ const fakeTerms = [
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
setAuthenticatedUser("user-1"); // default: authenticated
|
|
||||||
mockGetGameTerms.mockResolvedValue(fakeTerms);
|
mockGetGameTerms.mockResolvedValue(fakeTerms);
|
||||||
mockGetDistractors.mockResolvedValue(["wrong1", "wrong2", "wrong3"]);
|
mockGetDistractors.mockResolvedValue(["wrong1", "wrong2", "wrong3"]);
|
||||||
});
|
});
|
||||||
|
|
@ -253,87 +221,3 @@ describe("POST /api/v1/game/answer", () => {
|
||||||
expect(body.success).toBe(false);
|
expect(body.success).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("POST /api/v1/game/start — guest", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
setGuestUser();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns 200 for a guest with valid game settings", async () => {
|
|
||||||
const res = await request(app).post("/api/v1/game/start").send(validBody);
|
|
||||||
const body = res.body as GameStartResponse;
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(body.success).toBe(true);
|
|
||||||
expect(body.data.sessionId).toBeDefined();
|
|
||||||
expect(body.data.questions).toHaveLength(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("creates a session without userId for guests", async () => {
|
|
||||||
const startRes = await request(app)
|
|
||||||
.post("/api/v1/game/start")
|
|
||||||
.send(validBody);
|
|
||||||
const startBody = startRes.body as GameStartResponse;
|
|
||||||
const { sessionId, questions } = startBody.data;
|
|
||||||
|
|
||||||
// Guest can answer — no 404 from userId mismatch
|
|
||||||
const res = await request(app)
|
|
||||||
.post("/api/v1/game/answer")
|
|
||||||
.send({
|
|
||||||
sessionId,
|
|
||||||
questionId: questions[0]!.questionId,
|
|
||||||
selectedOptionId: 0,
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("POST /api/v1/game/answer — guest", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
setGuestUser();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("allows a guest to submit an answer", async () => {
|
|
||||||
const startRes = await request(app)
|
|
||||||
.post("/api/v1/game/start")
|
|
||||||
.send(validBody);
|
|
||||||
const startBody = startRes.body as GameStartResponse;
|
|
||||||
const { sessionId, questions } = startBody.data;
|
|
||||||
const question = questions[0]!;
|
|
||||||
|
|
||||||
const res = await request(app)
|
|
||||||
.post("/api/v1/game/answer")
|
|
||||||
.send({
|
|
||||||
sessionId,
|
|
||||||
questionId: question.questionId,
|
|
||||||
selectedOptionId: 0,
|
|
||||||
});
|
|
||||||
const body = res.body as GameAnswerResponse;
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(body.success).toBe(true);
|
|
||||||
expect(body.data.questionId).toBe(question.questionId);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns 404 when a guest tries to answer an authenticated user's session", async () => {
|
|
||||||
// First: create session as authenticated user
|
|
||||||
setAuthenticatedUser("user-1");
|
|
||||||
const startRes = await request(app)
|
|
||||||
.post("/api/v1/game/start")
|
|
||||||
.send(validBody);
|
|
||||||
const startBody = startRes.body as GameStartResponse;
|
|
||||||
const { sessionId, questions } = startBody.data;
|
|
||||||
|
|
||||||
// Then: try to answer as guest
|
|
||||||
setGuestUser();
|
|
||||||
const res = await request(app)
|
|
||||||
.post("/api/v1/game/answer")
|
|
||||||
.send({
|
|
||||||
sessionId,
|
|
||||||
questionId: questions[0]!.questionId,
|
|
||||||
selectedOptionId: 0,
|
|
||||||
});
|
|
||||||
const body = res.body as ErrorResponse;
|
|
||||||
expect(res.status).toBe(404);
|
|
||||||
expect(body.success).toBe(false);
|
|
||||||
expect(body.error).toContain("Game session not found");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -16,11 +16,10 @@ export const createGameController = (store: GameSessionStore) => ({
|
||||||
if (!gameSettings.success) {
|
if (!gameSettings.success) {
|
||||||
throw new ValidationError("Invalid game settings");
|
throw new ValidationError("Invalid game settings");
|
||||||
}
|
}
|
||||||
const userId = req.session?.user.id ?? null;
|
|
||||||
const gameQuestions = await createGameSession(
|
const gameQuestions = await createGameSession(
|
||||||
gameSettings.data,
|
gameSettings.data,
|
||||||
store,
|
store,
|
||||||
userId,
|
req.session.user.id,
|
||||||
);
|
);
|
||||||
res.json({ success: true, data: gameQuestions });
|
res.json({ success: true, data: gameQuestions });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -38,8 +37,11 @@ export const createGameController = (store: GameSessionStore) => ({
|
||||||
if (!submission.success) {
|
if (!submission.success) {
|
||||||
throw new ValidationError("Invalid answer submission");
|
throw new ValidationError("Invalid answer submission");
|
||||||
}
|
}
|
||||||
const userId = req.session?.user.id ?? null;
|
const result = await evaluateAnswer(
|
||||||
const result = await evaluateAnswer(submission.data, store, userId);
|
submission.data,
|
||||||
|
store,
|
||||||
|
req.session.user.id,
|
||||||
|
);
|
||||||
res.json({ success: true, data: result });
|
res.json({ success: true, data: result });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
export type GameSessionData = {
|
export type GameSessionData = {
|
||||||
answers: Map<string, { correctOptionId: number }>;
|
answers: Map<string, { correctOptionId: number }>;
|
||||||
userId: string | null;
|
userId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface GameSessionStore {
|
export interface GameSessionStore {
|
||||||
|
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
import express from "express";
|
|
||||||
import request from "supertest";
|
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
import type { Session, User } from "better-auth";
|
|
||||||
|
|
||||||
vi.mock("../lib/auth.js", () => ({ auth: { api: { getSession: vi.fn() } } }));
|
|
||||||
|
|
||||||
vi.mock("better-auth/node", () => ({
|
|
||||||
fromNodeHeaders: vi.fn().mockReturnValue({}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { auth } from "../lib/auth.js";
|
|
||||||
import { requireAuth, optionalAuth } from "./authMiddleware.js";
|
|
||||||
|
|
||||||
const mockGetSession = vi.mocked(auth.api.getSession);
|
|
||||||
|
|
||||||
function createOptionalAuthApp() {
|
|
||||||
const app = express();
|
|
||||||
app.use(optionalAuth);
|
|
||||||
app.get("/test", (req, res) => {
|
|
||||||
res
|
|
||||||
.status(200)
|
|
||||||
.json({
|
|
||||||
hasSession: !!req.session,
|
|
||||||
userId: req.session?.user?.id ?? null,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return app;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("optionalAuth", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("allows the request through when no session exists (guest)", async () => {
|
|
||||||
mockGetSession.mockResolvedValue(null);
|
|
||||||
|
|
||||||
const app = createOptionalAuthApp();
|
|
||||||
const res = await request(app).get("/test");
|
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.body).toEqual({ hasSession: false, userId: null });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("attaches session to req when user is authenticated", async () => {
|
|
||||||
mockGetSession.mockResolvedValue({
|
|
||||||
session: { id: "session-1" } as Session,
|
|
||||||
user: { id: "user-1" } as User,
|
|
||||||
});
|
|
||||||
|
|
||||||
const app = createOptionalAuthApp();
|
|
||||||
const res = await request(app).get("/test");
|
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.body).toEqual({ hasSession: true, userId: "user-1" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("allows the request through even when getSession throws", async () => {
|
|
||||||
mockGetSession.mockRejectedValue(new Error("auth service down"));
|
|
||||||
|
|
||||||
const app = createOptionalAuthApp();
|
|
||||||
const res = await request(app).get("/test");
|
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.body).toEqual({ hasSession: false, userId: null });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("requireAuth", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns 401 when no session exists", async () => {
|
|
||||||
mockGetSession.mockResolvedValue(null);
|
|
||||||
|
|
||||||
const app = express();
|
|
||||||
app.use(requireAuth);
|
|
||||||
app.get("/test", (_req, res) => res.status(200).json({ ok: true }));
|
|
||||||
|
|
||||||
const res = await request(app).get("/test");
|
|
||||||
expect(res.status).toBe(401);
|
|
||||||
expect(res.body).toEqual({ success: false, error: "Unauthorized" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("allows the request through when session exists", async () => {
|
|
||||||
mockGetSession.mockResolvedValue({
|
|
||||||
session: { id: "session-1" } as Session,
|
|
||||||
user: { id: "user-1" } as User,
|
|
||||||
});
|
|
||||||
|
|
||||||
const app = express();
|
|
||||||
app.use(requireAuth);
|
|
||||||
app.get("/test", (req, res) => {
|
|
||||||
res.status(200).json({ userId: req.session?.user?.id });
|
|
||||||
});
|
|
||||||
|
|
||||||
const res = await request(app).get("/test");
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.body).toEqual({ userId: "user-1" });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -20,23 +20,3 @@ export const requireAuth = async (
|
||||||
|
|
||||||
next();
|
next();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const optionalAuth = async (
|
|
||||||
req: Request,
|
|
||||||
_res: Response,
|
|
||||||
next: NextFunction,
|
|
||||||
) => {
|
|
||||||
try {
|
|
||||||
const session = await auth.api.getSession({
|
|
||||||
headers: fromNodeHeaders(req.headers),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (session) {
|
|
||||||
req.session = session;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn("Auth check failed, continuing as guest:", err);
|
|
||||||
}
|
|
||||||
|
|
||||||
next();
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import rateLimit, { ipKeyGenerator } from "express-rate-limit";
|
import rateLimit from "express-rate-limit";
|
||||||
import type { Request } from "express";
|
import type { Request } from "express";
|
||||||
|
|
||||||
// TODO: When Valkey is wired up, swap the default in-memory store for
|
// TODO: When Valkey is wired up, swap the default in-memory store for
|
||||||
|
|
@ -33,8 +33,7 @@ export const gameLimiter = rateLimit({
|
||||||
limit: 150,
|
limit: 150,
|
||||||
standardHeaders: "draft-8",
|
standardHeaders: "draft-8",
|
||||||
legacyHeaders: false,
|
legacyHeaders: false,
|
||||||
keyGenerator: (req: Request) =>
|
keyGenerator: (req: Request) => req.session!.user.id,
|
||||||
req.session?.user.id ?? ipKeyGenerator(req.ip ?? "unknown"),
|
|
||||||
message: {
|
message: {
|
||||||
success: false,
|
success: false,
|
||||||
error: "Too many requests, please try again later.",
|
error: "Too many requests, please try again later.",
|
||||||
|
|
@ -46,8 +45,7 @@ export const lobbyLimiter = rateLimit({
|
||||||
limit: 20,
|
limit: 20,
|
||||||
standardHeaders: "draft-8",
|
standardHeaders: "draft-8",
|
||||||
legacyHeaders: false,
|
legacyHeaders: false,
|
||||||
keyGenerator: (req: Request) =>
|
keyGenerator: (req: Request) => req.session!.user.id,
|
||||||
req.session?.user.id ?? ipKeyGenerator(req.ip ?? "unknown"),
|
|
||||||
message: {
|
message: {
|
||||||
success: false,
|
success: false,
|
||||||
error: "Too many requests, please try again later.",
|
error: "Too many requests, please try again later.",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import type { Router } from "express";
|
import type { Router } from "express";
|
||||||
import { createGameController } from "../controllers/gameController.js";
|
import { createGameController } from "../controllers/gameController.js";
|
||||||
import { optionalAuth } from "../middleware/authMiddleware.js";
|
import { requireAuth } from "../middleware/authMiddleware.js";
|
||||||
import { gameLimiter } from "../middleware/rateLimiters.js";
|
import { gameLimiter } from "../middleware/rateLimiters.js";
|
||||||
import type { GameSessionStore } from "../gameSessionStore/index.js";
|
import type { GameSessionStore } from "../gameSessionStore/index.js";
|
||||||
|
|
||||||
|
|
@ -9,7 +9,7 @@ export const createGameRouter = (store: GameSessionStore): Router => {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const controller = createGameController(store);
|
const controller = createGameController(store);
|
||||||
|
|
||||||
router.use(optionalAuth);
|
router.use(requireAuth);
|
||||||
router.use(gameLimiter);
|
router.use(gameLimiter);
|
||||||
|
|
||||||
router.post("/start", controller.createGame as express.RequestHandler);
|
router.post("/start", controller.createGame as express.RequestHandler);
|
||||||
|
|
|
||||||
|
|
@ -332,89 +332,3 @@ describe("evaluateAnswer", () => {
|
||||||
).rejects.toMatchObject({ statusCode: 422 });
|
).rejects.toMatchObject({ statusCode: 422 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add to existing gameService.test.ts
|
|
||||||
|
|
||||||
describe("createGameSession — guest", () => {
|
|
||||||
let store: InMemoryGameSessionStore;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
store = new InMemoryGameSessionStore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("creates a session with userId null for guests", async () => {
|
|
||||||
const session = await createGameSession(validRequest, store, null);
|
|
||||||
|
|
||||||
expect(session.sessionId).toBeDefined();
|
|
||||||
expect(session.questions).toHaveLength(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("stores userId as null in the session store", async () => {
|
|
||||||
const session = await createGameSession(validRequest, store, null);
|
|
||||||
const stored = await store.get(session.sessionId);
|
|
||||||
|
|
||||||
expect(stored).not.toBeNull();
|
|
||||||
expect(stored!.userId).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("evaluateAnswer — guest", () => {
|
|
||||||
let store: InMemoryGameSessionStore;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
store = new InMemoryGameSessionStore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("allows a guest to answer their own session", async () => {
|
|
||||||
const session = await createGameSession(validRequest, store, null);
|
|
||||||
const question = session.questions[0]!;
|
|
||||||
const correctText = fakeTerms[0]!.targetText;
|
|
||||||
const correctOption = question.options.find((o) => o.text === correctText)!;
|
|
||||||
|
|
||||||
const result = await evaluateAnswer(
|
|
||||||
{
|
|
||||||
sessionId: session.sessionId,
|
|
||||||
questionId: question.questionId,
|
|
||||||
selectedOptionId: correctOption.optionId,
|
|
||||||
},
|
|
||||||
store,
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.isCorrect).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws NotFoundError when guest tries to answer an authenticated session", async () => {
|
|
||||||
const authSession = await createGameSession(validRequest, store, "user-1");
|
|
||||||
const question = authSession.questions[0]!;
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
evaluateAnswer(
|
|
||||||
{
|
|
||||||
sessionId: authSession.sessionId,
|
|
||||||
questionId: question.questionId,
|
|
||||||
selectedOptionId: 0,
|
|
||||||
},
|
|
||||||
store,
|
|
||||||
null,
|
|
||||||
),
|
|
||||||
).rejects.toThrow("Game session not found");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws NotFoundError when authenticated user tries to answer a guest session", async () => {
|
|
||||||
const guestSession = await createGameSession(validRequest, store, null);
|
|
||||||
const question = guestSession.questions[0]!;
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
evaluateAnswer(
|
|
||||||
{
|
|
||||||
sessionId: guestSession.sessionId,
|
|
||||||
questionId: question.questionId,
|
|
||||||
selectedOptionId: 0,
|
|
||||||
},
|
|
||||||
store,
|
|
||||||
"user-1",
|
|
||||||
),
|
|
||||||
).rejects.toThrow("Game session not found");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ import { shuffleArray } from "../lib/utils.js";
|
||||||
export const createGameSession = async (
|
export const createGameSession = async (
|
||||||
request: GameRequest,
|
request: GameRequest,
|
||||||
store: GameSessionStore,
|
store: GameSessionStore,
|
||||||
userId: string | null,
|
userId: string,
|
||||||
): Promise<GameSession> => {
|
): Promise<GameSession> => {
|
||||||
const terms = await getGameTerms(
|
const terms = await getGameTerms(
|
||||||
request.source_language,
|
request.source_language,
|
||||||
|
|
@ -87,15 +87,11 @@ export const createGameSession = async (
|
||||||
export const evaluateAnswer = async (
|
export const evaluateAnswer = async (
|
||||||
submission: AnswerSubmission,
|
submission: AnswerSubmission,
|
||||||
store: GameSessionStore,
|
store: GameSessionStore,
|
||||||
userId: string | null,
|
userId: string,
|
||||||
): Promise<AnswerResult> => {
|
): Promise<AnswerResult> => {
|
||||||
const session = await store.get(submission.sessionId);
|
const session = await store.get(submission.sessionId);
|
||||||
|
|
||||||
if (!session) {
|
if (!session || session.userId !== userId) {
|
||||||
throw new NotFoundError(`Game session not found: ${submission.sessionId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (session.userId !== userId) {
|
|
||||||
throw new NotFoundError(`Game session not found: ${submission.sessionId}`);
|
throw new NotFoundError(`Game session not found: ${submission.sessionId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
4
apps/api/src/types/express.d.ts
vendored
4
apps/api/src/types/express.d.ts
vendored
|
|
@ -18,7 +18,3 @@ declare module "ws" {
|
||||||
export type AuthenticatedRequest = Request & {
|
export type AuthenticatedRequest = Request & {
|
||||||
session: { session: Session; user: User };
|
session: { session: Session; user: User };
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GuestRequest = Request & {
|
|
||||||
session?: { session: Session; user: User };
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,9 @@
|
||||||
import type { AnswerResult } from "@lila/shared";
|
import type { AnswerResult } from "@lila/shared";
|
||||||
import { ConfettiBurst } from "../ui/ConfettiBurst";
|
import { ConfettiBurst } from "../ui/ConfettiBurst";
|
||||||
|
|
||||||
type ScoreScreenProps = {
|
type ScoreScreenProps = { results: AnswerResult[]; onPlayAgain: () => void };
|
||||||
results: AnswerResult[];
|
|
||||||
onPlayAgain: () => void;
|
|
||||||
showAuthPrompt?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ScoreScreen = ({
|
export const ScoreScreen = ({ results, onPlayAgain }: ScoreScreenProps) => {
|
||||||
results,
|
|
||||||
onPlayAgain,
|
|
||||||
showAuthPrompt = false,
|
|
||||||
}: ScoreScreenProps) => {
|
|
||||||
const score = results.filter((r) => r.isCorrect).length;
|
const score = results.filter((r) => r.isCorrect).length;
|
||||||
const total = results.length;
|
const total = results.length;
|
||||||
const percentage = Math.round((score / total) * 100);
|
const percentage = Math.round((score / total) * 100);
|
||||||
|
|
@ -66,34 +58,12 @@ export const ScoreScreen = ({
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showAuthPrompt && (
|
|
||||||
<div className="w-full rounded-2xl border border-(--color-primary-light) bg-white/60 dark:bg-black/10 backdrop-blur p-6 text-center">
|
|
||||||
<p className="text-sm text-(--color-text-muted) mb-3">
|
|
||||||
Want to save your progress and compete with friends?
|
|
||||||
</p>
|
|
||||||
<a
|
|
||||||
href="/?modal=auth&redirect=/play"
|
|
||||||
className="inline-block w-full py-3 rounded-xl text-base font-bold bg-linear-to-r from-pink-400 to-purple-500 text-white shadow-sm hover:shadow-md hover:-translate-y-0.5 active:translate-y-0 transition-all duration-200"
|
|
||||||
>
|
|
||||||
Create an account
|
|
||||||
</a>
|
|
||||||
<button
|
|
||||||
onClick={onPlayAgain}
|
|
||||||
className="mt-3 text-sm text-(--color-text-muted) hover:text-(--color-primary) transition-colors cursor-pointer"
|
|
||||||
>
|
|
||||||
Continue as guest →
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!showAuthPrompt && (
|
|
||||||
<button
|
<button
|
||||||
onClick={onPlayAgain}
|
onClick={onPlayAgain}
|
||||||
className="w-full py-3 px-10 rounded-2xl text-lg font-black bg-(--color-primary) text-white shadow-sm hover:shadow-md hover:bg-(--color-primary-dark) hover:-translate-y-0.5 active:translate-y-0 transition-all duration-200 cursor-pointer"
|
className="w-full py-3 px-10 rounded-2xl text-lg font-black bg-(--color-primary) text-white shadow-sm hover:shadow-md hover:bg-(--color-primary-dark) hover:-translate-y-0.5 active:translate-y-0 transition-all duration-200 cursor-pointer"
|
||||||
>
|
>
|
||||||
Play again
|
Play again
|
||||||
</button>
|
</button>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||||
import { useState, useCallback, useEffect } from "react";
|
import { useState, useCallback } from "react";
|
||||||
import type { GameSession, GameRequest, AnswerResult } from "@lila/shared";
|
import type { GameSession, GameRequest, AnswerResult } from "@lila/shared";
|
||||||
import { QuestionCard } from "../components/game/QuestionCard";
|
import { QuestionCard } from "../components/game/QuestionCard";
|
||||||
import { ScoreScreen } from "../components/game/ScoreScreen";
|
import { ScoreScreen } from "../components/game/ScoreScreen";
|
||||||
|
|
@ -18,13 +18,6 @@ function Play() {
|
||||||
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
|
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
|
||||||
const [results, setResults] = useState<AnswerResult[]>([]);
|
const [results, setResults] = useState<AnswerResult[]>([]);
|
||||||
const [currentResult, setCurrentResult] = useState<AnswerResult | null>(null);
|
const [currentResult, setCurrentResult] = useState<AnswerResult | null>(null);
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void authClient.getSession().then(({ data }) => {
|
|
||||||
setIsAuthenticated(!!data);
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const startGame = useCallback(async (settings: GameRequest) => {
|
const startGame = useCallback(async (settings: GameRequest) => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
@ -107,11 +100,7 @@ function Play() {
|
||||||
if (currentQuestionIndex >= gameSession.questions.length) {
|
if (currentQuestionIndex >= gameSession.questions.length) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-linear-to-b from-purple-100 to-pink-50 flex items-center justify-center p-6">
|
<div className="min-h-screen bg-linear-to-b from-purple-100 to-pink-50 flex items-center justify-center p-6">
|
||||||
<ScoreScreen
|
<ScoreScreen results={results} onPlayAgain={resetToSetup} />
|
||||||
results={results}
|
|
||||||
onPlayAgain={resetToSetup}
|
|
||||||
showAuthPrompt={isAuthenticated === false}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -140,4 +129,10 @@ function Play() {
|
||||||
export const Route = createFileRoute("/play")({
|
export const Route = createFileRoute("/play")({
|
||||||
component: Play,
|
component: Play,
|
||||||
errorComponent: RouteError,
|
errorComponent: RouteError,
|
||||||
|
beforeLoad: async () => {
|
||||||
|
const { data: session } = await authClient.getSession();
|
||||||
|
if (!session) {
|
||||||
|
throw redirect({ to: "/", search: { modal: "auth", redirect: "/play" } });
|
||||||
|
}
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,78 +0,0 @@
|
||||||
# 99 — Current Task
|
|
||||||
|
|
||||||
## Task Description
|
|
||||||
|
|
||||||
Implement guest play flow so users can try a 3-round singleplayer quiz without creating an account. After completing the quiz, optionally prompt them to sign up to save progress.
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
**Which parts of the codebase does this touch?**
|
|
||||||
|
|
||||||
- [x] Frontend (`apps/web/`)
|
|
||||||
- [x] Backend API (`apps/api/`)
|
|
||||||
- [ ] Database schema (`packages/db/`) — no schema changes needed
|
|
||||||
- [x] Shared schemas (`packages/shared/`) — may need GuestGameRequestSchema
|
|
||||||
- [ ] WebSocket protocol (`apps/api/src/ws/`) — not touched (guest play is singleplayer only)
|
|
||||||
- [ ] Data pipeline (`data-pipeline/`)
|
|
||||||
- [ ] Infrastructure / deployment (`docker-compose.yml`, Caddyfile, etc.)
|
|
||||||
- [x] Documentation
|
|
||||||
|
|
||||||
**Relevant files I already know about:**
|
|
||||||
|
|
||||||
- `apps/api/src/middleware/authMiddleware.ts` — needs optional auth path
|
|
||||||
- `apps/api/src/controllers/gameController.ts` — needs guest variant of start/answer
|
|
||||||
- `apps/api/src/services/gameService.ts` — may need guest session logic
|
|
||||||
- `apps/api/src/routes/gameRouter.ts` — route definitions
|
|
||||||
- `apps/web/src/routes/play.tsx` — singleplayer route
|
|
||||||
- `apps/web/src/components/game/GameSetup.tsx` — start quiz UI
|
|
||||||
- `packages/shared/src/schemas/game.ts` — request/response schemas
|
|
||||||
- `apps/web/src/components/auth/AuthModal.tsx` — post-game auth prompt
|
|
||||||
|
|
||||||
## Constraints & Requirements
|
|
||||||
|
|
||||||
**Must have:**
|
|
||||||
|
|
||||||
- [ ] Guest users can start and complete a singleplayer quiz (3 or 10 rounds)
|
|
||||||
- [ ] No login required to reach `/play` or call `POST /api/v1/game/start`
|
|
||||||
- [ ] Server-side answer evaluation still works (correct answer never sent to frontend)
|
|
||||||
- [ ] Guest sessions are ephemeral (no database storage of guest progress)
|
|
||||||
- [ ] After quiz completion, show a friendly "Save your progress?" prompt with auth options
|
|
||||||
|
|
||||||
**Nice to have:**
|
|
||||||
|
|
||||||
- [ ] Guest sessions stored in-memory with a TTL (e.g., 24h) so refreshing the page doesn't lose the current quiz
|
|
||||||
- [ ] Post-game prompt includes a "Continue as guest" option to play again without signing up
|
|
||||||
|
|
||||||
**Must NOT break:**
|
|
||||||
|
|
||||||
- [x] Existing auth flow (logged-in users still work normally)
|
|
||||||
- [x] WebSocket protocol (if applicable)
|
|
||||||
- [x] Database schema (additive changes only unless migration planned)
|
|
||||||
- [x] Zod schemas in `packages/shared` (no silent drift)
|
|
||||||
|
|
||||||
**Known blockers or open questions:**
|
|
||||||
|
|
||||||
- [ ] Should guest sessions use the same `GameSessionStore` interface with a guest flag, or a separate store?
|
|
||||||
- [ ] Should the post-game auth prompt be a modal or a redirect to a dedicated page?
|
|
||||||
|
|
||||||
## Definition of Done
|
|
||||||
|
|
||||||
- [ ] Code implemented and tested
|
|
||||||
- [ ] No TypeScript errors (`pnpm typecheck` passes)
|
|
||||||
- [ ] Tests pass (`pnpm test`)
|
|
||||||
- [ ] Manual verification in dev environment (both logged-in and guest flows)
|
|
||||||
- [ ] Commit message follows convention
|
|
||||||
- [ ] Feature branch merged to main
|
|
||||||
|
|
||||||
## Post-Work Checklist
|
|
||||||
|
|
||||||
After the task is complete, ask the LLM:
|
|
||||||
|
|
||||||
> "Review the post-work checklist in prompts/meta.md. Which documentation files need updates based on what we just changed?"
|
|
||||||
|
|
||||||
Expected doc updates:
|
|
||||||
|
|
||||||
- `documentation/STATUS.md` — Guest play is now live
|
|
||||||
- `documentation/ai-context/03-api-contract.md` — New guest endpoint or schema changes
|
|
||||||
- `packages/shared/src/schemas/game.ts` — If GuestGameRequestSchema added
|
|
||||||
- `README.md` — Quickstart may mention guest play
|
|
||||||
|
|
@ -34,7 +34,6 @@ Example: "Implement guest play flow so users can try a 3-round quiz without crea
|
||||||
[List files you've identified. The LLM may ask for additional ones.]
|
[List files you've identified. The LLM may ask for additional ones.]
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
- `apps/api/src/controllers/gameController.ts` — needs guest variant
|
- `apps/api/src/controllers/gameController.ts` — needs guest variant
|
||||||
- `apps/api/src/middleware/authMiddleware.ts` — needs optional auth path
|
- `apps/api/src/middleware/authMiddleware.ts` — needs optional auth path
|
||||||
- `packages/shared/src/schemas/game.ts` — needs GuestGameRequestSchema
|
- `packages/shared/src/schemas/game.ts` — needs GuestGameRequestSchema
|
||||||
|
|
@ -44,24 +43,20 @@ Example:
|
||||||
## Constraints & Requirements
|
## Constraints & Requirements
|
||||||
|
|
||||||
**Must have:**
|
**Must have:**
|
||||||
|
|
||||||
- [ ]
|
- [ ]
|
||||||
- [ ]
|
- [ ]
|
||||||
|
|
||||||
**Nice to have:**
|
**Nice to have:**
|
||||||
|
|
||||||
- [ ]
|
- [ ]
|
||||||
- [ ]
|
- [ ]
|
||||||
|
|
||||||
**Must NOT break:**
|
**Must NOT break:**
|
||||||
|
|
||||||
- [ ] Existing auth flow (logged-in users still work normally)
|
- [ ] Existing auth flow (logged-in users still work normally)
|
||||||
- [ ] WebSocket protocol (if applicable)
|
- [ ] WebSocket protocol (if applicable)
|
||||||
- [ ] Database schema (additive changes only unless migration planned)
|
- [ ] Database schema (additive changes only unless migration planned)
|
||||||
- [ ] Zod schemas in `packages/shared` (no silent drift)
|
- [ ] Zod schemas in `packages/shared` (no silent drift)
|
||||||
|
|
||||||
**Known blockers or open questions:**
|
**Known blockers or open questions:**
|
||||||
|
|
||||||
- [ ]
|
- [ ]
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -86,7 +81,7 @@ After the task is complete, ask the LLM:
|
||||||
The LLM should check:
|
The LLM should check:
|
||||||
|
|
||||||
| File | Check if... |
|
| File | Check if... |
|
||||||
| ---------------------------------- | ----------------------------------------------------------------------- |
|
|------|-------------|
|
||||||
| `documentation/STATUS.md` | Task changes what's working or what's blocked |
|
| `documentation/STATUS.md` | Task changes what's working or what's blocked |
|
||||||
| `documentation/BACKLOG.md` | Task completes a backlog item or creates a new one |
|
| `documentation/BACKLOG.md` | Task completes a backlog item or creates a new one |
|
||||||
| `documentation/DECISIONS.md` | Task involved choosing between alternatives with long-term consequences |
|
| `documentation/DECISIONS.md` | Task involved choosing between alternatives with long-term consequences |
|
||||||
|
|
@ -96,7 +91,6 @@ The LLM should check:
|
||||||
| `README.md` | Task changes quickstart steps, stack, or current status |
|
| `README.md` | Task changes quickstart steps, stack, or current status |
|
||||||
|
|
||||||
**Expected output format:**
|
**Expected output format:**
|
||||||
|
|
||||||
```
|
```
|
||||||
- FILE: [filename] — REASON: [what changed and why the doc needs updating]
|
- FILE: [filename] — REASON: [what changed and why the doc needs updating]
|
||||||
```
|
```
|
||||||
Loading…
Add table
Add a link
Reference in a new issue