diff --git a/.gitignore b/.gitignore index dc22134..ed1d01c 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ coverage/ .vscode/ .idea/ package-lock.json +apps/backend/data/ diff --git a/Dockerfile b/Dockerfile index df855ab..a9078f5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,6 +21,7 @@ COPY apps/backend/package*.json ./apps/backend/ RUN cd apps/backend && npm install --omit=dev COPY --from=backend /app/backend/dist ./apps/backend/dist COPY --from=frontend /app/frontend/dist ./apps/frontend/dist +RUN mkdir -p /app/apps/backend/data && chown -R node:node /app/apps/backend/data ENV NODE_ENV=production ENV PORT=3001 EXPOSE 3001 diff --git a/apps/backend/package.json b/apps/backend/package.json index bd8dd77..a89ae4c 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -11,10 +11,12 @@ "test": "vitest run" }, "dependencies": { + "better-sqlite3": "^9.4.4", "express": "^4.18.2" }, "devDependencies": { "@eslint/js": "^8.56.0", + "@types/better-sqlite3": "^7.6.6", "@types/express": "^4.17.21", "@types/node": "^20.10.6", "eslint": "^8.56.0", diff --git a/apps/backend/src/index.test.ts b/apps/backend/src/index.test.ts index ef38eb4..00a9165 100644 --- a/apps/backend/src/index.test.ts +++ b/apps/backend/src/index.test.ts @@ -1,7 +1,35 @@ -import { describe, it, expect } from "vitest"; +import { describe, expect, it } from "vitest"; -describe("Backend", () => { - it("should pass placeholder test", () => { - expect(true).toBe(true); +import { computeAverage, normalizeComment, validateFeedback } from "./roti-service.js"; + +describe("roti-service", () => { + it("rejects rating below 1", () => { + const result = validateFeedback({ rating: 0, comment: "ok" }); + expect(result.ok).toBe(false); + }); + + it("rejects rating above 5", () => { + const result = validateFeedback({ rating: 6, comment: "ok" }); + expect(result.ok).toBe(false); + }); + + it("requires comment for rating 3", () => { + const result = validateFeedback({ rating: 3, comment: "" }); + expect(result.ok).toBe(false); + }); + + it("accepts rating 4 without comment", () => { + const result = validateFeedback({ rating: 4, comment: "" }); + expect(result.ok).toBe(true); + }); + + it("normalizes blank comment to null", () => { + const normalized = normalizeComment(" "); + expect(normalized).toBe(null); + }); + + it("computes average to one decimal", () => { + const average = computeAverage([5, 4, 4]); + expect(average).toBe(4.3); }); }); diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 678bf6d..d3c22ee 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -1,20 +1,73 @@ -import express, { Request, Response } from "express"; import path from "path"; import { fileURLToPath } from "url"; +import express, { Request, Response } from "express"; + +import { getDb } from "./roti-db.js"; +import { computeAverage, normalizeComment, validateFeedback } from "./roti-service.js"; + const __dirname = path.dirname(fileURLToPath(import.meta.url)); const app = express(); const PORT = process.env.PORT || 3001; +const db = getDb(); + +interface FeedbackRow { + id: number; + rating: number; + comment: string | null; + created_at: string; +} app.use(express.json()); -// API routes -app.get("/api/health", (_req: Request, res: Response) => { - res.json({ status: "ok", timestamp: new Date().toISOString() }); +app.get("/api/roti", (_req: Request, res: Response) => { + try { + const rows = db + .prepare("SELECT id, rating, comment, created_at FROM feedbacks ORDER BY created_at DESC") + .all() as FeedbackRow[]; + + const feedbacks = rows.map((row) => ({ + id: row.id, + rating: row.rating, + comment: row.comment ?? null, + createdAt: row.created_at, + })); + + const average = computeAverage(feedbacks.map((feedback) => feedback.rating)); + + res.json({ average, feedbacks }); + } catch (error) { + console.error("Failed to fetch feedbacks:", error); + res.status(500).json({ error: "Internal server error" }); + } }); -app.get("/api/hello", (_req: Request, res: Response) => { - res.json({ message: "Hello from backend!", env: process.env.NODE_ENV || "development" }); +app.post("/api/roti", (req: Request, res: Response) => { + const rating = Number(req.body?.rating); + const comment = normalizeComment(req.body?.comment); + const validation = validateFeedback({ rating, comment }); + + if (!validation.ok) { + res.status(400).json({ error: validation.error }); + return; + } + + try { + const createdAt = new Date().toISOString(); + const result = db + .prepare("INSERT INTO feedbacks (rating, comment, created_at) VALUES (?, ?, ?)") + .run(rating, comment, createdAt); + + res.status(201).json({ + id: Number(result.lastInsertRowid), + rating, + comment, + createdAt, + }); + } catch (error) { + console.error("Failed to save feedback:", error); + res.status(500).json({ error: "Internal server error" }); + } }); // Serve frontend static files diff --git a/apps/backend/src/roti-db.ts b/apps/backend/src/roti-db.ts new file mode 100644 index 0000000..8186101 --- /dev/null +++ b/apps/backend/src/roti-db.ts @@ -0,0 +1,26 @@ +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; + +import Database from "better-sqlite3"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const dataDir = path.join(__dirname, "..", "data"); + +fs.mkdirSync(dataDir, { recursive: true }); + +const dbPath = path.join(dataDir, "roti.db"); +const db = new Database(dbPath); + +db.exec(` + CREATE TABLE IF NOT EXISTS feedbacks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + rating INTEGER NOT NULL CHECK (rating >= 1 AND rating <= 5), + comment TEXT, + created_at TEXT NOT NULL + ) +`); + +export function getDb() { + return db; +} diff --git a/apps/backend/src/roti-service.ts b/apps/backend/src/roti-service.ts new file mode 100644 index 0000000..a450db5 --- /dev/null +++ b/apps/backend/src/roti-service.ts @@ -0,0 +1,41 @@ +export interface FeedbackInput { + rating: number; + comment?: string | null; +} + +export interface Feedback { + id: number; + rating: number; + comment: string | null; + createdAt: string; +} + +export interface ValidationResult { + ok: boolean; + error?: string; +} + +export function validateFeedback(input: FeedbackInput): ValidationResult { + if (!Number.isInteger(input.rating) || input.rating < 1 || input.rating > 5) { + return { ok: false, error: "La note doit etre entre 1 et 5." }; + } + + const trimmedComment = input.comment?.trim() ?? ""; + if (input.rating <= 3 && trimmedComment.length === 0) { + return { ok: false, error: "Le commentaire est obligatoire pour une note de 1 a 3." }; + } + + return { ok: true }; +} + +export function normalizeComment(comment?: string | null) { + const trimmedComment = comment?.trim(); + return trimmedComment && trimmedComment.length > 0 ? trimmedComment : null; +} + +export function computeAverage(ratings: number[]) { + if (ratings.length === 0) return 0; + const total = ratings.reduce((sum, rating) => sum + rating, 0); + const average = total / ratings.length; + return Math.round(average * 10) / 10; +} diff --git a/apps/frontend/index.html b/apps/frontend/index.html index 48e2a8e..f759a51 100644 --- a/apps/frontend/index.html +++ b/apps/frontend/index.html @@ -1,9 +1,15 @@ - +
-React + Express + Railway
++ Live Vibe Coding +
++ Laissez votre note en direct, c'est rapide et anonyme. +
+Moyenne
+{averageDisplay}
+{feedbacks.length} retours
+Error: {error}
} ++ Note de 1 (pas utile) a 5 (indispensable). +
- {health && ( -Status: {health.status}
-Time: {health.timestamp}
-{error}
} + +{hello.message}
-Env: {hello.env}
+ {feedbacks.map((feedback) => ( ++ {feedback.comment ? feedback.comment : "(Pas de commentaire)"} +
+