first commit

This commit is contained in:
Rodrigo Bertoldo de Sousa Martins committed 2026-09-07 20:46:08 -03:00
commit 0e854110d3
49 files changed
+2292

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
import { PrismaClient } from "@prisma/client";
export const prisma = new PrismaClient();
+28
View File
@@ -0,0 +1,28 @@
// SM-2 spaced repetition algorithm (SuperMemo 2).
// `quality` is 0-5: 0-2 = failed recall (reset), 3-5 = successful recall (grows interval).
export interface SM2State {
easeFactor: number;
intervalDays: number;
repetitions: number;
}
export function sm2(state: SM2State, quality: number): SM2State {
const q = Math.max(0, Math.min(5, quality));
let { easeFactor, intervalDays, repetitions } = state;
if (q < 3) {
return { easeFactor, intervalDays: 1, repetitions: 0 };
}
repetitions += 1;
if (repetitions === 1) intervalDays = 1;
else if (repetitions === 2) intervalDays = 6;
else intervalDays = Math.round(intervalDays * easeFactor);
easeFactor = Math.max(
1.3,
easeFactor + (0.1 - (5 - q) * (0.08 + (5 - q) * 0.02)),
);
return { easeFactor, intervalDays, repetitions };
}
+74
View File
@@ -0,0 +1,74 @@
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { prisma } from "../lib/prisma.js";
import { sm2 } from "../lib/sm2.js";
const reviewBodySchema = z.object({
quality: z.number().int().min(0).max(5),
});
export async function quizRoutes(app: FastifyInstance) {
// Returns a batch of structures due for review (or new ones if nothing is due yet).
app.get<{ Querystring: { system?: string; limit?: string } }>(
"/api/quiz/next",
async (req) => {
const limit = Math.min(Number(req.query.limit ?? 10), 50);
const systemFilter = req.query.system
? { system: { slug: req.query.system } }
: {};
const due = await prisma.structure.findMany({
where: {
...systemFilter,
OR: [
{ quizCard: null },
{ quizCard: { dueAt: { lte: new Date() } } },
],
},
include: { system: true, quizCard: true },
take: limit,
});
return due;
},
);
app.post<{ Params: { structureId: string }; Body: unknown }>(
"/api/quiz/:structureId/review",
async (req, reply) => {
const { quality } = reviewBodySchema.parse(req.body);
const structureId = req.params.structureId;
const existing = await prisma.quizCard.findUnique({ where: { structureId } });
const current = existing ?? {
easeFactor: 2.5,
intervalDays: 0,
repetitions: 0,
};
const next = sm2(current, quality);
const dueAt = new Date(Date.now() + next.intervalDays * 24 * 60 * 60 * 1000);
const card = await prisma.quizCard.upsert({
where: { structureId },
create: {
structureId,
easeFactor: next.easeFactor,
intervalDays: next.intervalDays,
repetitions: next.repetitions,
dueAt,
lastReviewedAt: new Date(),
},
update: {
easeFactor: next.easeFactor,
intervalDays: next.intervalDays,
repetitions: next.repetitions,
dueAt,
lastReviewedAt: new Date(),
},
});
return reply.send(card);
},
);
}
+39
View File
@@ -0,0 +1,39 @@
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { prisma } from "../lib/prisma.js";
const listQuerySchema = z.object({
system: z.string().optional(),
q: z.string().optional(),
});
export async function structuresRoutes(app: FastifyInstance) {
app.get("/api/structures", async (req) => {
const { system, q } = listQuerySchema.parse(req.query);
return prisma.structure.findMany({
where: {
...(system ? { system: { slug: system } } : {}),
...(q
? {
OR: [
{ namePt: { contains: q, mode: "insensitive" } },
{ nameEn: { contains: q, mode: "insensitive" } },
{ synonyms: { has: q } },
],
}
: {}),
},
include: { system: true },
orderBy: { namePt: "asc" },
});
});
app.get<{ Params: { id: string } }>("/api/structures/:id", async (req, reply) => {
const structure = await prisma.structure.findUnique({
where: { id: req.params.id },
include: { system: true },
});
if (!structure) return reply.code(404).send({ error: "not_found" });
return structure;
});
}
+11
View File
@@ -0,0 +1,11 @@
import type { FastifyInstance } from "fastify";
import { prisma } from "../lib/prisma.js";
export async function systemsRoutes(app: FastifyInstance) {
app.get("/api/systems", async () => {
return prisma.anatomicalSystem.findMany({
orderBy: { order: "asc" },
include: { _count: { select: { structures: true } } },
});
});
}
+37
View File
@@ -0,0 +1,37 @@
import Fastify from "fastify";
import cors from "@fastify/cors";
import fastifyStatic from "@fastify/static";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { systemsRoutes } from "./routes/systems.js";
import { structuresRoutes } from "./routes/structures.js";
import { quizRoutes } from "./routes/quiz.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const MODELS_DIR = process.env.MODELS_DIR ?? path.resolve(__dirname, "../models");
const app = Fastify({ logger: true });
await app.register(cors, { origin: true });
await app.register(fastifyStatic, {
root: MODELS_DIR,
prefix: "/models/",
decorateReply: false,
immutable: true,
maxAge: "7d",
});
app.get("/api/health", async () => ({ status: "ok" }));
await app.register(systemsRoutes);
await app.register(structuresRoutes);
await app.register(quizRoutes);
const port = Number(process.env.PORT ?? 3333);
app
.listen({ port, host: "0.0.0.0" })
.catch((err) => {
app.log.error(err);
process.exit(1);
});