feat: add prisma

This commit is contained in:
BeauTroll
2026-01-19 12:22:08 +01:00
parent 448721f0a0
commit 2b78d4e332
8 changed files with 1002 additions and 68 deletions

2
.gitignore vendored
View File

@@ -11,3 +11,5 @@ count.txt
.output .output
.vinxi .vinxi
todos.json todos.json
/generated/prisma

View File

@@ -22,10 +22,10 @@ src/server/
```typescript ```typescript
// src/server/functions/characters.ts // src/server/functions/characters.ts
import { createServerFn } from '@tanstack/react-start/server'; import { createServerFn } from "@tanstack/react-start/server";
import { z } from 'zod'; import { z } from "zod";
import { db } from '@/lib/server/db'; import { db } from "@/lib/server/db";
import { requireAuth } from '@/server/middleware/auth'; import { requireAuth } from "@/server/middleware/auth";
// Schemas // Schemas
const createCharacterSchema = z.object({ const createCharacterSchema = z.object({
@@ -48,7 +48,7 @@ const characterFiltersSchema = z.object({
}); });
// Functions // Functions
export const getCharacters = createServerFn({ method: 'GET' }) export const getCharacters = createServerFn({ method: "GET" })
.validator((data: unknown) => characterFiltersSchema.parse(data)) .validator((data: unknown) => characterFiltersSchema.parse(data))
.handler(async ({ data }) => { .handler(async ({ data }) => {
const session = await requireAuth(); const session = await requireAuth();
@@ -57,7 +57,7 @@ export const getCharacters = createServerFn({ method: 'GET' })
const where = { const where = {
account: { userId: session.userId }, account: { userId: session.userId },
...(search && { ...(search && {
name: { contains: search, mode: 'insensitive' as const }, name: { contains: search, mode: "insensitive" as const },
}), }),
...(classIds?.length && { classId: { in: classIds } }), ...(classIds?.length && { classId: { in: classIds } }),
...(serverIds?.length && { serverId: { in: serverIds } }), ...(serverIds?.length && { serverId: { in: serverIds } }),
@@ -70,7 +70,7 @@ export const getCharacters = createServerFn({ method: 'GET' })
include: { account: { select: { id: true, name: true } } }, include: { account: { select: { id: true, name: true } } },
skip: (page - 1) * limit, skip: (page - 1) * limit,
take: limit, take: limit,
orderBy: { name: 'asc' }, orderBy: { name: "asc" },
}), }),
db.character.count({ where }), db.character.count({ where }),
]); ]);
@@ -86,7 +86,7 @@ export const getCharacters = createServerFn({ method: 'GET' })
}; };
}); });
export const createCharacter = createServerFn({ method: 'POST' }) export const createCharacter = createServerFn({ method: "POST" })
.validator((data: unknown) => createCharacterSchema.parse(data)) .validator((data: unknown) => createCharacterSchema.parse(data))
.handler(async ({ data }) => { .handler(async ({ data }) => {
const session = await requireAuth(); const session = await requireAuth();
@@ -97,14 +97,16 @@ export const createCharacter = createServerFn({ method: 'POST' })
}); });
if (!account) { if (!account) {
throw new Error('Account not found'); throw new Error("Account not found");
} }
return db.character.create({ data }); return db.character.create({ data });
}); });
export const bulkDeleteCharacters = createServerFn({ method: 'POST' }) export const bulkDeleteCharacters = createServerFn({ method: "POST" })
.validator((data: unknown) => z.object({ ids: z.array(z.string().cuid()) }).parse(data)) .validator((data: unknown) =>
z.object({ ids: z.array(z.string().cuid()) }).parse(data),
)
.handler(async ({ data }) => { .handler(async ({ data }) => {
const session = await requireAuth(); const session = await requireAuth();
@@ -123,8 +125,8 @@ export const bulkDeleteCharacters = createServerFn({ method: 'POST' })
```typescript ```typescript
// src/server/middleware/auth.ts // src/server/middleware/auth.ts
import { getWebRequest } from '@tanstack/react-start/server'; import { getWebRequest } from "@tanstack/react-start/server";
import { db } from '@/lib/server/db'; import { db } from "@/lib/server/db";
interface Session { interface Session {
userId: string; userId: string;
@@ -133,13 +135,14 @@ interface Session {
export async function requireAuth(): Promise<Session> { export async function requireAuth(): Promise<Session> {
const request = getWebRequest(); const request = getWebRequest();
const sessionId = request.headers.get('cookie') const sessionId = request.headers
?.split(';') .get("cookie")
.find(c => c.trim().startsWith('session=')) ?.split(";")
?.split('=')[1]; .find((c) => c.trim().startsWith("session="))
?.split("=")[1];
if (!sessionId) { if (!sessionId) {
throw new Error('Unauthorized'); throw new Error("Unauthorized");
} }
const session = await db.session.findUnique({ const session = await db.session.findUnique({
@@ -148,7 +151,7 @@ export async function requireAuth(): Promise<Session> {
}); });
if (!session || session.expiresAt < new Date()) { if (!session || session.expiresAt < new Date()) {
throw new Error('Session expired'); throw new Error("Session expired");
} }
return { return {
@@ -170,7 +173,7 @@ export async function getOptionalAuth(): Promise<Session | null> {
```typescript ```typescript
// src/lib/server/cache.ts // src/lib/server/cache.ts
import NodeCache from 'node-cache'; import NodeCache from "node-cache";
// Different TTLs for different data types // Different TTLs for different data types
const caches = { const caches = {
@@ -196,7 +199,9 @@ export const userCache = {
// Helper to invalidate all cache for a user // Helper to invalidate all cache for a user
invalidateUser: (userId: string): void => { invalidateUser: (userId: string): void => {
const keys = caches.user.keys().filter(k => k.startsWith(`user:${userId}:`)); const keys = caches.user
.keys()
.filter((k) => k.startsWith(`user:${userId}:`));
caches.user.del(keys); caches.user.del(keys);
}, },
}; };

View File

@@ -16,32 +16,33 @@ Draft
2. Docker Compose configuration with app service and PostgreSQL 16 2. Docker Compose configuration with app service and PostgreSQL 16
3. Prisma configured and connected to PostgreSQL 3. Prisma configured and connected to PostgreSQL
4. shadcn/ui installed with base components (Button, Input, Card, Table) 4. shadcn/ui installed with base components (Button, Input, Card, Table)
5. ESLint + Prettier configured with recommended rules 5. Biome configured for linting and formatting
6. GitLab CI pipeline: build, lint, test stages 6. Gitea Actions workflow: build, lint, test stages
7. Dockerfile multi-stage pour production build 7. Dockerfile multi-stage pour production build
8. README avec instructions de setup local 8. README avec instructions de setup local
9. Application démarre et affiche une page d'accueil "Dofus Manager" 9. Application démarre et affiche une page d'accueil "Dofus Manager"
10. Health check endpoint `/api/health` pour Docker healthcheck
## Tasks / Subtasks ## Tasks / Subtasks
- [ ] Task 1: Initialize TanStack Start project (AC: 1) - [x] Task 1: Initialize TanStack Start project (AC: 1)
- [x] Create new TanStack Start project with `pnpm create @tanstack/start` - [x] Create new TanStack Start project with `pnpm create @tanstack/start`
- [ ] Configure `tsconfig.json` with strict mode enabled - [x] Configure `tsconfig.json` with strict mode enabled
- [ ] Configure path aliases (`@/` pointing to `src/`) - [x] Configure path aliases (`@/` pointing to `src/`)
- [ ] Verify TypeScript strict compilation works - [x] Verify TypeScript strict compilation works
- [ ] Task 2: Setup Docker environment (AC: 2, 7) - [x] Task 2: Setup Docker environment (AC: 2, 7)
- [ ] Create `docker/` directory - [x] Create `docker/` directory
- [ ] Create `docker/Dockerfile` with multi-stage build (builder + runner) - [x] Create `docker/Dockerfile` with multi-stage build (builder + runner)
- [ ] Create `docker/docker-compose.yml` with app and postgres services - [x] Create `docker/docker-compose.yml` with app and postgres services
- [ ] Create `docker/docker-compose.dev.yml` for local development (postgres only) - [x] Create `docker/docker-compose.dev.yml` for local development (postgres only)
- [ ] Configure PostgreSQL 16-alpine with healthcheck - [x] Configure PostgreSQL 16-alpine with healthcheck
- [ ] Test database connectivity - [x] Test database connectivity
- [ ] Task 3: Configure Prisma ORM (AC: 3) - [ ] Task 3: Configure Prisma ORM (AC: 3)
- [ ] Install Prisma dependencies (`prisma`, `@prisma/client`) - [x] Install Prisma dependencies (`prisma`, `@prisma/client`)
- [ ] Initialize Prisma with `pnpm prisma init` - [x] Initialize Prisma with `pnpm prisma init`
- [ ] Configure `prisma/schema.prisma` with PostgreSQL provider - [x] Configure `prisma/schema.prisma` with PostgreSQL provider
- [ ] Create `src/lib/server/db.ts` for Prisma client singleton - [ ] Create `src/lib/server/db.ts` for Prisma client singleton
- [ ] Create `.env.example` with DATABASE_URL template - [ ] Create `.env.example` with DATABASE_URL template
- [ ] Verify Prisma connects to database - [ ] Verify Prisma connects to database
@@ -60,12 +61,12 @@ Draft
- [ ] Add lint and format scripts to `package.json` - [ ] Add lint and format scripts to `package.json`
- [ ] Verify linting works on project files - [ ] Verify linting works on project files
- [ ] Task 6: Setup GitLab CI/CD pipeline (AC: 6) - [ ] Task 6: Setup Gitea Actions workflow (AC: 6)
- [ ] Create `.gitlab-ci.yml` with stages: test, build, deploy - [ ] Create `.gitea/workflows/ci.yml`
- [ ] Configure test stage: lint, typecheck, test - [ ] Configure test job: lint, typecheck, test
- [ ] Configure build stage: Docker image build and push - [ ] Configure build job: Docker image build and push
- [ ] Configure deploy stages (staging/production) with manual triggers - [ ] Configure deploy jobs (staging/production) with manual triggers
- [ ] Add caching for node_modules - [ ] Add caching for pnpm store
- [ ] Task 7: Create README documentation (AC: 8) - [ ] Task 7: Create README documentation (AC: 8)
- [ ] Document project overview - [ ] Document project overview
@@ -81,7 +82,13 @@ Draft
- [ ] Create `src/styles/globals.css` with Tailwind imports - [ ] Create `src/styles/globals.css` with Tailwind imports
- [ ] Verify application starts and renders correctly - [ ] Verify application starts and renders correctly
- [ ] Task 9: Final verification - [ ] Task 9: Create health check endpoint (AC: 10)
- [ ] Create `src/routes/api/health.ts` server function
- [ ] Return JSON `{ status: "ok", timestamp: Date }`
- [ ] Optionally check database connectivity
- [ ] Verify endpoint responds at `GET /api/health`
- [ ] Task 10: Final verification
- [ ] Run `pnpm dev` and verify app starts - [ ] Run `pnpm dev` and verify app starts
- [ ] Run `pnpm lint` and verify no errors - [ ] Run `pnpm lint` and verify no errors
- [ ] Run `pnpm typecheck` and verify no errors - [ ] Run `pnpm typecheck` and verify no errors
@@ -118,7 +125,7 @@ Draft
- Docker - Docker
- Docker Compose - Docker Compose
- GitLab CI - Gitea Actions
**Dev Tools:** **Dev Tools:**
@@ -131,9 +138,13 @@ Draft
``` ```
dofus-manager/ dofus-manager/
├── .gitea/
│ └── workflows/
│ └── ci.yml
├── docker/ ├── docker/
│ ├── Dockerfile │ ├── Dockerfile
── docker-compose.yml ── docker-compose.yml
│ └── docker-compose.dev.yml
├── prisma/ ├── prisma/
│ ├── schema.prisma │ ├── schema.prisma
│ └── migrations/ │ └── migrations/
@@ -156,7 +167,9 @@ dofus-manager/
│ │ └── logger.ts │ │ └── logger.ts
│ ├── routes/ │ ├── routes/
│ │ ├── __root.tsx │ │ ├── __root.tsx
│ │ ── index.tsx │ │ ── index.tsx
│ │ └── api/
│ │ └── health.ts
│ ├── styles/ │ ├── styles/
│ │ └── globals.css │ │ └── globals.css
│ └── app.tsx │ └── app.tsx
@@ -187,22 +200,66 @@ dofus-manager/
- PostgreSQL 16-alpine with healthcheck - PostgreSQL 16-alpine with healthcheck
- Traefik labels for reverse proxy (production) - Traefik labels for reverse proxy (production)
### GitLab CI/CD [Source: architecture/14-deployment-architecture.md#gitlab-cicd-pipeline] ### Gitea Actions [Adapted from architecture]
**Stages:** test, build, deploy **Workflow file:** `.gitea/workflows/ci.yml`
**Test stage:** **Jobs:** test, build, deploy
- image: node:20-alpine **Test job:**
- Commands: pnpm lint, pnpm typecheck, pnpm test
- Cache node_modules
**Build stage:** - runs-on: ubuntu-latest
- Steps: checkout, setup pnpm, setup node, install, lint, typecheck, test
- Cache pnpm store
- image: docker:24 **Build job:**
- Build and push Docker image to registry
- needs: test
- Build and push Docker image
- Only on main/develop branches - Only on main/develop branches
**Exemple de workflow:**
```yaml
name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v2
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm typecheck
- run: pnpm test
build:
needs: test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop'
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v5
with:
context: .
file: docker/Dockerfile
push: true
tags: registry.example.com/dofus-manager:${{ github.sha }}
```
### Environment Variables [Source: architecture/13-development-workflow.md#environment-variables] ### Environment Variables [Source: architecture/13-development-workflow.md#environment-variables]
```bash ```bash
@@ -234,9 +291,45 @@ SESSION_SECRET="your-secret-key-min-32-chars"
- Types/Interfaces: PascalCase - Types/Interfaces: PascalCase
- Constants: SCREAMING_SNAKE_CASE - Constants: SCREAMING_SNAKE_CASE
### Important Discrepancy Note ### Health Check Endpoint [Source: architecture/19-monitoring-observability.md]
AC #5 specifies "ESLint + Prettier" but the architecture documents (3-technology-stack.md) specify **Biome** for linting and formatting. Recommend following the architecture document and using Biome instead, as it's the project standard. **Endpoint:** `GET /api/health`
**Response:**
```json
{
"status": "ok",
"timestamp": "2026-01-19T10:00:00.000Z",
"database": "connected"
}
```
**Implementation avec TanStack Start:**
```typescript
// src/routes/api/health.ts
import { createAPIFileRoute } from "@tanstack/start/api";
import { prisma } from "@/lib/server/db";
export const Route = createAPIFileRoute("/api/health")({
GET: async () => {
let dbStatus = "disconnected";
try {
await prisma.$queryRaw`SELECT 1`;
dbStatus = "connected";
} catch {
dbStatus = "error";
}
return Response.json({
status: "ok",
timestamp: new Date().toISOString(),
database: dbStatus,
});
},
});
```
## Testing ## Testing
@@ -265,8 +358,9 @@ AC #5 specifies "ESLint + Prettier" but the architecture documents (3-technology
## Change Log ## Change Log
| Date | Version | Description | Author | | Date | Version | Description | Author |
| ---------- | ------- | ---------------------- | -------- | | ---------- | ------- | --------------------------------------------- | -------- |
| 2026-01-19 | 1.0 | Initial story creation | SM Agent | | 2026-01-19 | 1.0 | Initial story creation | SM Agent |
| 2026-01-19 | 1.1 | Gitea Actions, Biome, Health endpoint ajoutés | SM Agent |
--- ---

View File

@@ -9,6 +9,7 @@
"test": "vitest run" "test": "vitest run"
}, },
"dependencies": { "dependencies": {
"@prisma/client": "^7.2.0",
"@tanstack/react-devtools": "^0.7.0", "@tanstack/react-devtools": "^0.7.0",
"@tanstack/react-router": "^1.132.0", "@tanstack/react-router": "^1.132.0",
"@tanstack/react-router-devtools": "^1.132.0", "@tanstack/react-router-devtools": "^1.132.0",
@@ -30,6 +31,7 @@
"@types/react-dom": "^19.2.0", "@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.0.4", "@vitejs/plugin-react": "^5.0.4",
"jsdom": "^27.0.0", "jsdom": "^27.0.0",
"prisma": "^7.2.0",
"typescript": "^5.7.2", "typescript": "^5.7.2",
"vite": "^7.1.7", "vite": "^7.1.7",
"vitest": "^3.0.5", "vitest": "^3.0.5",

666
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

14
prisma.config.ts Normal file
View File

@@ -0,0 +1,14 @@
// This file was generated by Prisma, and assumes you have installed the following:
// npm install --save-dev prisma dotenv
import "dotenv/config";
import { defineConfig } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
url: process.env["DATABASE_URL"],
},
});

165
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,165 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ============================================
// USER & AUTHENTICATION
// ============================================
model User {
id String @id @default(cuid())
email String @unique
passwordHash String @map("password_hash")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
accounts Account[]
teams Team[]
sessions Session[]
@@map("users")
}
model Session {
id String @id @default(cuid())
userId String @map("user_id")
expiresAt DateTime @map("expires_at")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([expiresAt])
@@map("sessions")
}
// ============================================
// CORE ENTITIES
// ============================================
model Account {
id String @id @default(cuid())
name String
userId String @map("user_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
characters Character[]
@@unique([userId, name])
@@index([userId])
@@map("accounts")
}
model Character {
id String @id @default(cuid())
name String
level Int @default(1)
classId Int @map("class_id")
className String @map("class_name")
serverId Int @map("server_id")
serverName String @map("server_name")
accountId String @map("account_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
teamMembers TeamMember[]
progressions CharacterProgression[]
@@unique([accountId, name])
@@index([accountId])
@@index([classId])
@@index([serverId])
@@index([level])
@@map("characters")
}
// ============================================
// TEAMS
// ============================================
enum TeamType {
MAIN
SECONDARY
CUSTOM
}
model Team {
id String @id @default(cuid())
name String
type TeamType @default(CUSTOM)
userId String @map("user_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
members TeamMember[]
@@unique([userId, name])
@@index([userId])
@@map("teams")
}
model TeamMember {
id String @id @default(cuid())
teamId String @map("team_id")
characterId String @map("character_id")
joinedAt DateTime @default(now()) @map("joined_at")
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
@@unique([teamId, characterId])
@@index([teamId])
@@index([characterId])
@@map("team_members")
}
// ============================================
// PROGRESSIONS
// ============================================
enum ProgressionType {
QUEST
DUNGEON
ACHIEVEMENT
DOFUS
}
model Progression {
id String @id @default(cuid())
name String
type ProgressionType
category String
dofusDbId Int? @unique @map("dofusdb_id")
characterProgressions CharacterProgression[]
@@index([type])
@@index([category])
@@map("progressions")
}
model CharacterProgression {
id String @id @default(cuid())
characterId String @map("character_id")
progressionId String @map("progression_id")
completed Boolean @default(false)
completedAt DateTime? @map("completed_at")
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
progression Progression @relation(fields: [progressionId], references: [id], onDelete: Cascade)
@@unique([characterId, progressionId])
@@index([characterId])
@@index([progressionId])
@@index([completed])
@@map("character_progressions")
}

0
src/lib/server/db.ts Normal file
View File