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

+22
View File
@@ -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"]
+8
View File
@@ -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
+28
View File
@@ -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"
+54
View File
@@ -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")
}
+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);
});
+18
View File
@@ -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"]
}