feat: scaffold quiz API vertical slice
- Add GameRequestSchema and derived types to packages/shared - Add SupportedLanguageCode, SupportedPos, DifficultyLevel type exports - Add getGameTerms() model to packages/db with pos/language/difficulty/limit filters - Add prepareGameQuestions() service skeleton in apps/api - Add createGame controller with Zod safeParse validation - Wire POST /api/v1/game/start route - Add scripts/gametest/test-game.ts for manual end-to-end testing
This commit is contained in:
parent
13cc709b09
commit
9fc3ba375a
11 changed files with 99 additions and 94 deletions
|
|
@ -4,7 +4,7 @@ import { apiRouter } from "./routes/apiRouter.js";
|
||||||
|
|
||||||
export function createApp() {
|
export function createApp() {
|
||||||
const app: Express = express();
|
const app: Express = express();
|
||||||
|
app.use(express.json());
|
||||||
app.use("/api/v1", apiRouter);
|
app.use("/api/v1", apiRouter);
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,17 @@
|
||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
import { prepareQuestions } from "../services/gameService.js";
|
import { GameRequestSchema } from "@glossa/shared";
|
||||||
|
import { prepareGameQuestions } from "../services/gameService.js";
|
||||||
|
|
||||||
export const getGame = async (req: Request, res: Response) => {
|
export const createGame = async (req: Request, res: Response) => {
|
||||||
const query = gameRequestSchema.parse(req.query);
|
const gameSettings = GameRequestSchema.safeParse(req.body);
|
||||||
const questions = await prepareQuestions(query);
|
|
||||||
res.json({ success: true, data: questions });
|
// TODO: remove when global error handler is implemented
|
||||||
|
if (!gameSettings.success) {
|
||||||
|
res.status(400).json({ success: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const gameQuestions = await prepareGameQuestions(gameSettings.data);
|
||||||
|
|
||||||
|
res.json({ success: true, data: gameQuestions });
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -4,4 +4,4 @@ import { createGame } from "../controllers/gameController.js";
|
||||||
|
|
||||||
export const gameRouter: Router = express.Router();
|
export const gameRouter: Router = express.Router();
|
||||||
|
|
||||||
gameRouter.get("/", createGame);
|
gameRouter.post("/start", createGame);
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,16 @@
|
||||||
export const prepareQuestions = async (params: object) => {
|
import type { GameRequestType } from "@glossa/shared";
|
||||||
console.log(params);
|
import { getGameTerms } from "@glossa/db";
|
||||||
|
|
||||||
|
export const prepareGameQuestions = async (gameSettings: GameRequestType) => {
|
||||||
|
const { source_language, target_language, pos, difficulty, rounds } =
|
||||||
|
gameSettings;
|
||||||
|
|
||||||
|
const terms = await getGameTerms(
|
||||||
|
source_language,
|
||||||
|
target_language,
|
||||||
|
pos,
|
||||||
|
difficulty,
|
||||||
|
Number(rounds),
|
||||||
|
);
|
||||||
|
return terms;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
"extends": "../../tsconfig.base.json",
|
"extends": "../../tsconfig.base.json",
|
||||||
"references": [
|
"references": [
|
||||||
{ "path": "../../packages/shared" },
|
{ "path": "../../packages/shared" },
|
||||||
{ "path": "../../packages/db" }
|
{ "path": "../../packages/db" },
|
||||||
],
|
],
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"module": "NodeNext",
|
"module": "NodeNext",
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"rootDir": ".",
|
"rootDir": ".",
|
||||||
"types": ["vitest/globals"]
|
"types": ["vitest/globals"],
|
||||||
},
|
},
|
||||||
"include": ["src", "vitest.config.ts"]
|
"include": ["src", "vitest.config.ts", "../../packages/db/src/models"],
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -159,7 +159,7 @@ this would be the flow to start a single player game:
|
||||||
main menu => singleplayer, multiplayer, settings
|
main menu => singleplayer, multiplayer, settings
|
||||||
singleplayer => language selection
|
singleplayer => language selection
|
||||||
"i speak english" => "i want to learn italian" (both languages are dropdowns to select the fitting language)
|
"i speak english" => "i want to learn italian" (both languages are dropdowns to select the fitting language)
|
||||||
language selection => category selection => pure grammar, media (as disussed, practicing on song lyrics or breaking bad subtitles)
|
language selection => category selection => pure grammar, media (practicing on song lyrics or breaking bad subtitles)
|
||||||
pure grammar => pos selection => nouns or verbs (in mvp)
|
pure grammar => pos selection => nouns or verbs (in mvp)
|
||||||
nouns has 3 subcategories => singular (1-on-1 translation dog => cane), plural (plural practices cane => cani for example), gender/articles (il cane or la cane for example)
|
nouns has 3 subcategories => singular (1-on-1 translation dog => cane), plural (plural practices cane => cani for example), gender/articles (il cane or la cane for example)
|
||||||
verbs has 2 subcategories => infinitv (1-on-1 translation to talk => parlare) or conjugations (user gets shown the infinitiv and a table with all personal pronouns and has to fill in the gaps with the according conjugations)
|
verbs has 2 subcategories => infinitv (1-on-1 translation to talk => parlare) or conjugations (user gets shown the infinitiv and a table with all personal pronouns and has to fill in the gaps with the according conjugations)
|
||||||
|
|
|
||||||
|
|
@ -9,3 +9,5 @@ config({
|
||||||
});
|
});
|
||||||
|
|
||||||
export const db = drizzle(process.env["DATABASE_URL"]!);
|
export const db = drizzle(process.env["DATABASE_URL"]!);
|
||||||
|
|
||||||
|
export { getGameTerms } from "./models/termModel.js";
|
||||||
|
|
|
||||||
32
packages/db/src/models/termModel.ts
Normal file
32
packages/db/src/models/termModel.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { db } from "@glossa/db";
|
||||||
|
import { eq, and } from "drizzle-orm";
|
||||||
|
import { terms, translations } from "@glossa/db/schema";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
SupportedLanguageCode,
|
||||||
|
SupportedPos,
|
||||||
|
DifficultyLevel,
|
||||||
|
} from "@glossa/shared";
|
||||||
|
|
||||||
|
export const getGameTerms = async (
|
||||||
|
sourceLanguage: SupportedLanguageCode,
|
||||||
|
targetLanguage: SupportedLanguageCode,
|
||||||
|
pos: SupportedPos,
|
||||||
|
difficulty: DifficultyLevel,
|
||||||
|
count: number,
|
||||||
|
) => {
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(terms)
|
||||||
|
.innerJoin(translations, eq(translations.term_id, terms.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(terms.pos, pos),
|
||||||
|
eq(translations.language_code, targetLanguage),
|
||||||
|
eq(translations.difficulty, difficulty),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(count);
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
};
|
||||||
|
|
@ -1,11 +1,15 @@
|
||||||
export const SUPPORTED_LANGUAGE_CODES = ["en", "it"] as const;
|
export const SUPPORTED_LANGUAGE_CODES = ["en", "it"] as const;
|
||||||
|
export type SupportedLanguageCode = (typeof SUPPORTED_LANGUAGE_CODES)[number];
|
||||||
|
|
||||||
export const SUPPORTED_POS = ["noun", "verb"] as const;
|
export const SUPPORTED_POS = ["noun", "verb"] as const;
|
||||||
|
export type SupportedPos = (typeof SUPPORTED_POS)[number];
|
||||||
|
|
||||||
export const GAME_ROUNDS = ["3", "10"] as const;
|
export const GAME_ROUNDS = ["3", "10"] as const;
|
||||||
|
export type GameRounds = (typeof GAME_ROUNDS)[number];
|
||||||
|
|
||||||
export const CEFR_LEVELS = ["A1", "A2", "B1", "B2", "C1", "C2"] as const;
|
export const CEFR_LEVELS = ["A1", "A2", "B1", "B2", "C1", "C2"] as const;
|
||||||
|
|
||||||
export const SUPPORTED_DECK_TYPES = ["grammar", "media"] as const;
|
export const SUPPORTED_DECK_TYPES = ["grammar", "media"] as const;
|
||||||
|
|
||||||
export const DIFFICULTY_LEVELS = ["easy", "intermediate", "hard"] as const;
|
export const DIFFICULTY_LEVELS = ["easy", "intermediate", "hard"] as const;
|
||||||
|
export type DifficultyLevel = (typeof DIFFICULTY_LEVELS)[number];
|
||||||
|
|
|
||||||
|
|
@ -4,88 +4,15 @@ import {
|
||||||
SUPPORTED_LANGUAGE_CODES,
|
SUPPORTED_LANGUAGE_CODES,
|
||||||
SUPPORTED_POS,
|
SUPPORTED_POS,
|
||||||
GAME_ROUNDS,
|
GAME_ROUNDS,
|
||||||
|
DIFFICULTY_LEVELS,
|
||||||
} from "./constants.js";
|
} from "./constants.js";
|
||||||
|
|
||||||
export const Term = z.object({
|
export const GameRequestSchema = z.object({
|
||||||
id: z.uuid(),
|
source_language: z.enum(SUPPORTED_LANGUAGE_CODES),
|
||||||
synset_id: z.string(),
|
target_language: z.enum(SUPPORTED_LANGUAGE_CODES),
|
||||||
pos: z.string(),
|
pos: z.enum(SUPPORTED_POS),
|
||||||
created_at: z.iso.datetime(),
|
difficulty: z.enum(DIFFICULTY_LEVELS),
|
||||||
|
rounds: z.enum(GAME_ROUNDS),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type Term = z.infer<typeof Term>;
|
export type GameRequestType = z.infer<typeof GameRequestSchema>;
|
||||||
|
|
||||||
export const Translation = z.object({
|
|
||||||
id: z.uuid(),
|
|
||||||
term_id: z.string(),
|
|
||||||
language_code: z.string(),
|
|
||||||
text: z.string(),
|
|
||||||
created_at: z.iso.datetime(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type Translation = z.infer<typeof Translation>;
|
|
||||||
|
|
||||||
export const TermGloss = z.object({
|
|
||||||
id: z.uuid(),
|
|
||||||
term_id: z.uuid(),
|
|
||||||
language_code: z.string(),
|
|
||||||
text: z.string(),
|
|
||||||
created_at: z.iso.datetime(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type TermGloss = z.infer<typeof TermGloss>;
|
|
||||||
|
|
||||||
export const LanguagePair = z.object({
|
|
||||||
id: z.uuid(),
|
|
||||||
source_language: z.string(),
|
|
||||||
target_language: z.string(),
|
|
||||||
label: z.string(),
|
|
||||||
active: z.boolean(),
|
|
||||||
created_at: z.iso.datetime(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type LanguagePair = z.infer<typeof LanguagePair>;
|
|
||||||
|
|
||||||
export const User = z.object({
|
|
||||||
id: z.uuid(),
|
|
||||||
openauth_sub: z.string(),
|
|
||||||
email: z.email(),
|
|
||||||
display_name: z.string(),
|
|
||||||
created_at: z.iso.datetime(),
|
|
||||||
last_login_at: z.iso.datetime(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type User = z.infer<typeof User>;
|
|
||||||
|
|
||||||
export const Deck = z.object({
|
|
||||||
id: z.uuid(),
|
|
||||||
name: z.string(),
|
|
||||||
description: z.string(),
|
|
||||||
source_language: z.string(),
|
|
||||||
validated_languages: z.array(z.string()),
|
|
||||||
is_public: z.boolean(),
|
|
||||||
created_at: z.iso.datetime(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type Deck = z.infer<typeof Deck>;
|
|
||||||
|
|
||||||
export const DeckTerm = z.object({
|
|
||||||
deck_id: z.uuid(),
|
|
||||||
term_id: z.uuid(),
|
|
||||||
added: z.iso.datetime(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type DeckTerm = z.infer<typeof DeckTerm>;
|
|
||||||
|
|
||||||
export const GameRequestSchema = z
|
|
||||||
.object({
|
|
||||||
source_language: z.enum(SUPPORTED_LANGUAGE_CODES),
|
|
||||||
target_language: z.enum(SUPPORTED_LANGUAGE_CODES),
|
|
||||||
pos: z.enum(SUPPORTED_POS),
|
|
||||||
rounds: z.enum(GAME_ROUNDS).transform(Number),
|
|
||||||
})
|
|
||||||
.refine((game) => game.target_language !== game.source_language, {
|
|
||||||
error: "source and target language must be different",
|
|
||||||
});
|
|
||||||
|
|
||||||
export type GameRequestSchema = z.infer<typeof GameRequestSchema>;
|
|
||||||
|
|
|
||||||
18
scripts/gametest/test-game.ts
Normal file
18
scripts/gametest/test-game.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
async function main() {
|
||||||
|
const response = await fetch("http://localhost:3000/api/v1/game/start", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
source_language: "en",
|
||||||
|
target_language: "it",
|
||||||
|
pos: "noun",
|
||||||
|
difficulty: "easy",
|
||||||
|
rounds: "3",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
console.log(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
Loading…
Add table
Add a link
Reference in a new issue