first commit
This commit is contained in:
commit
0e854110d3
49 files changed
+2292
No files matched your search
@@ -0,0 +1,22 @@
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
RUN npm install
|
||||
COPY prisma ./prisma
|
||||
RUN npx prisma generate
|
||||
COPY src ./src
|
||||
COPY tsconfig.json ./
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
COPY package.json ./
|
||||
RUN npm install --omit=dev
|
||||
COPY --from=build /app/node_modules/.prisma ./node_modules/.prisma
|
||||
COPY --from=build /app/dist ./dist
|
||||
COPY prisma ./prisma
|
||||
COPY docker-entrypoint.sh ./
|
||||
RUN chmod +x docker-entrypoint.sh
|
||||
EXPOSE 3333
|
||||
ENTRYPOINT ["./docker-entrypoint.sh"]
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
echo "Aplicando migrations do Prisma..."
|
||||
npx prisma migrate deploy
|
||||
|
||||
echo "Iniciando API..."
|
||||
exec node dist/server.js
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "anatomia-backend",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/server.ts",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"start": "node dist/server.js",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate:deploy": "prisma migrate deploy",
|
||||
"prisma:migrate:dev": "prisma migrate dev",
|
||||
"seed": "tsx src/lib/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^9.0.1",
|
||||
"@fastify/static": "^7.0.4",
|
||||
"@prisma/client": "^5.20.0",
|
||||
"fastify": "^4.28.1",
|
||||
"prisma": "^5.20.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.14.15",
|
||||
"tsx": "^4.19.1",
|
||||
"typescript": "^5.6.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "anatomical_systems" (
|
||||
"id" TEXT NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"namePt" TEXT NOT NULL,
|
||||
"nameEn" TEXT NOT NULL,
|
||||
"colorHex" TEXT NOT NULL DEFAULT '#4f7cff',
|
||||
"order" INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
CONSTRAINT "anatomical_systems_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "structures" (
|
||||
"id" TEXT NOT NULL,
|
||||
"fmaId" TEXT,
|
||||
"slug" TEXT NOT NULL,
|
||||
"namePt" TEXT NOT NULL,
|
||||
"nameEn" TEXT NOT NULL,
|
||||
"synonyms" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
|
||||
"description" TEXT,
|
||||
"regionPath" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
|
||||
"modelFile" TEXT NOT NULL,
|
||||
"meshName" TEXT NOT NULL,
|
||||
"systemId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "structures_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "quiz_cards" (
|
||||
"id" TEXT NOT NULL,
|
||||
"structureId" TEXT NOT NULL,
|
||||
"easeFactor" DOUBLE PRECISION NOT NULL DEFAULT 2.5,
|
||||
"intervalDays" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
"repetitions" INTEGER NOT NULL DEFAULT 0,
|
||||
"dueAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"lastReviewedAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "quiz_cards_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "anatomical_systems_slug_key" ON "anatomical_systems"("slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "structures_fmaId_key" ON "structures"("fmaId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "structures_slug_key" ON "structures"("slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "structures_systemId_idx" ON "structures"("systemId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "quiz_cards_structureId_key" ON "quiz_cards"("structureId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "structures" ADD CONSTRAINT "structures_systemId_fkey" FOREIGN KEY ("systemId") REFERENCES "anatomical_systems"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "quiz_cards" ADD CONSTRAINT "quiz_cards_structureId_fkey" FOREIGN KEY ("structureId") REFERENCES "structures"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -0,0 +1 @@
|
||||
provider = "postgresql"
|
||||
@@ -0,0 +1,54 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model AnatomicalSystem {
|
||||
id String @id @default(cuid())
|
||||
slug String @unique
|
||||
namePt String
|
||||
nameEn String
|
||||
colorHex String @default("#4f7cff")
|
||||
order Int @default(0)
|
||||
structures Structure[]
|
||||
|
||||
@@map("anatomical_systems")
|
||||
}
|
||||
|
||||
model Structure {
|
||||
id String @id @default(cuid())
|
||||
fmaId String? @unique
|
||||
slug String @unique
|
||||
namePt String
|
||||
nameEn String
|
||||
synonyms String[] @default([])
|
||||
description String?
|
||||
regionPath String[] @default([])
|
||||
modelFile String
|
||||
meshName String
|
||||
systemId String
|
||||
system AnatomicalSystem @relation(fields: [systemId], references: [id])
|
||||
quizCard QuizCard?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([systemId])
|
||||
@@map("structures")
|
||||
}
|
||||
|
||||
model QuizCard {
|
||||
id String @id @default(cuid())
|
||||
structureId String @unique
|
||||
structure Structure @relation(fields: [structureId], references: [id])
|
||||
easeFactor Float @default(2.5)
|
||||
intervalDays Float @default(0)
|
||||
repetitions Int @default(0)
|
||||
dueAt DateTime @default(now())
|
||||
lastReviewedAt DateTime?
|
||||
|
||||
@@map("quiz_cards")
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
export const prisma = new PrismaClient();
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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 } } },
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": false,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in new issue
Block a user