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 @@ - + - Live Code + ROTI Live Feedback + + +
diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 7441660..0dfe5b4 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -18,9 +18,12 @@ "@types/react": "^18.2.43", "@types/react-dom": "^18.2.17", "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.17", "eslint": "^8.56.0", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.5", + "postcss": "^8.4.35", + "tailwindcss": "^3.4.3", "typescript": "^5.2.2", "typescript-eslint": "^7.0.0", "vite": "^5.0.8" diff --git a/apps/frontend/postcss.config.cjs b/apps/frontend/postcss.config.cjs new file mode 100644 index 0000000..12a703d --- /dev/null +++ b/apps/frontend/postcss.config.cjs @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/apps/frontend/src/App.css b/apps/frontend/src/App.css deleted file mode 100644 index 5700eae..0000000 --- a/apps/frontend/src/App.css +++ /dev/null @@ -1,49 +0,0 @@ -* { - box-sizing: border-box; - margin: 0; - padding: 0; -} - -body { - font-family: system-ui, sans-serif; - background: #f5f5f5; - min-height: 100vh; -} - -.app { - max-width: 600px; - margin: 0 auto; - padding: 2rem; - text-align: center; -} - -h1 { - color: #333; - margin-bottom: 0.5rem; -} - -p { - color: #666; -} - -.card { - background: white; - padding: 1.5rem; - border-radius: 8px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); - margin-top: 1rem; - text-align: left; -} - -.card h2 { - font-size: 1rem; - color: #333; - border-bottom: 2px solid #007bff; - padding-bottom: 0.5rem; - margin-bottom: 0.5rem; -} - -.error { - color: #dc3545; - margin-top: 1rem; -} diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index 3af0e05..624940a 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -1,50 +1,219 @@ -import { useEffect, useState } from "react"; -import "./App.css"; - -interface ApiResponse { - status?: string; - message?: string; - timestamp?: string; - env?: string; +import { useEffect, useMemo, useState, type FormEvent } from "react"; + +interface Feedback { + id: number; + rating: number; + comment: string | null; + createdAt: string; +} + +interface RotiResponse { + average: number; + feedbacks: Feedback[]; } +const ratingOptions = [1, 2, 3, 4, 5]; + function App() { - const [health, setHealth] = useState(null); - const [hello, setHello] = useState(null); + const [rating, setRating] = useState(null); + const [comment, setComment] = useState(""); + const [average, setAverage] = useState(0); + const [feedbacks, setFeedbacks] = useState([]); + const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [submitError, setSubmitError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + const isCommentRequired = rating !== null && rating <= 3; + const trimmedComment = comment.trim(); + + const averageDisplay = useMemo(() => { + if (feedbacks.length === 0) return "—"; + return average.toFixed(1); + }, [average, feedbacks.length]); + + const canSubmit = + rating !== null && (!isCommentRequired || trimmedComment.length > 0) && !isSubmitting; + + const loadFeedbacks = async () => { + setLoading(true); + try { + const response = await fetch("/api/roti"); + if (!response.ok) throw new Error("Erreur API"); + const data = (await response.json()) as RotiResponse; + setAverage(data.average); + setFeedbacks(data.feedbacks); + setError(null); + } catch (err) { + setError((err as Error).message); + } finally { + setLoading(false); + } + }; useEffect(() => { - Promise.all([fetch("/api/health"), fetch("/api/hello")]) - .then(async ([healthRes, helloRes]) => { - if (!healthRes.ok || !helloRes.ok) throw new Error("API error"); - setHealth(await healthRes.json()); - setHello(await helloRes.json()); - }) - .catch((err) => setError(err.message)); + loadFeedbacks(); }, []); + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + if (rating === null) { + setSubmitError("Merci de choisir une note."); + return; + } + if (isCommentRequired && trimmedComment.length === 0) { + setSubmitError("Merci d'ajouter un commentaire."); + return; + } + + setIsSubmitting(true); + try { + const response = await fetch("/api/roti", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + rating, + comment: trimmedComment.length > 0 ? trimmedComment : null, + }), + }); + + if (!response.ok) { + const payload = (await response.json()) as { error?: string }; + throw new Error(payload.error || "Impossible d'envoyer le feedback."); + } + + setRating(null); + setComment(""); + setSubmitError(null); + await loadFeedbacks(); + } catch (err) { + setSubmitError((err as Error).message); + } finally { + setIsSubmitting(false); + } + }; + return ( -
-

Live Code

-

React + Express + Railway

+
+
+
+
+
+

+ Live Vibe Coding +

+

+ ROTI du talk +

+

+ Laissez votre note en direct, c'est rapide et anonyme. +

+
+
+

Moyenne

+

{averageDisplay}

+

{feedbacks.length} retours

+
+
+
- {error &&

Error: {error}

} +
+
+

Votre feedback

+

+ Note de 1 (pas utile) a 5 (indispensable). +

- {health && ( -
-

Health

-

Status: {health.status}

-

Time: {health.timestamp}

-
- )} +
+
+ +
+ {ratingOptions.map((value) => { + const isActive = rating === value; + return ( + + ); + })} +
+
+ +
+
+ + {isCommentRequired && ( + Obligatoire + )} +
+