diff --git a/.gitignore b/.gitignore index dc22134..e80ee70 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ coverage/ .vscode/ .idea/ package-lock.json +apps/backend/data/ +*.sqlite diff --git a/apps/backend/package.json b/apps/backend/package.json index bd8dd77..a12f6dc 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.9", "@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..b29ee3a 100644 --- a/apps/backend/src/index.test.ts +++ b/apps/backend/src/index.test.ts @@ -1,7 +1,46 @@ -import { describe, it, expect } from "vitest"; +import { describe, expect, it } from "vitest"; -describe("Backend", () => { - it("should pass placeholder test", () => { - expect(true).toBe(true); +import { calculateAverage, validateFeedback } from "./roti-service.js"; + +describe("validateFeedback", () => { + it("rejects ratings below 1", () => { + expect(() => validateFeedback({ rating: 0, comment: "ok" })).toThrow( + "Rating must be an integer between 1 and 5" + ); + }); + + it("rejects ratings above 5", () => { + expect(() => validateFeedback({ rating: 6, comment: "ok" })).toThrow( + "Rating must be an integer between 1 and 5" + ); + }); + + it("rejects non-integer ratings", () => { + expect(() => validateFeedback({ rating: 2.5, comment: "ok" })).toThrow( + "Rating must be an integer between 1 and 5" + ); + }); + + it("requires a comment for ratings at or below 3", () => { + expect(() => validateFeedback({ rating: 3, comment: " " })).toThrow( + "Comment is required when rating is 3 or below" + ); + }); + + it("allows empty comment for ratings above 3", () => { + expect(validateFeedback({ rating: 4, comment: " " })).toEqual({ + rating: 4, + comment: "", + }); + }); +}); + +describe("calculateAverage", () => { + it("returns zero for empty input", () => { + expect(calculateAverage([])).toBe(0); + }); + + it("calculates average to one decimal", () => { + expect(calculateAverage([5, 4, 4])).toBe(4.3); }); }); diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 678bf6d..8a498e6 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -1,7 +1,11 @@ -import express, { Request, Response } from "express"; import path from "path"; import { fileURLToPath } from "url"; +import express, { Request, Response } from "express"; + +import { insertFeedback, listFeedbacks } from "./roti-db.js"; +import { calculateAverage, validateFeedback } from "./roti-service.js"; + const __dirname = path.dirname(fileURLToPath(import.meta.url)); const app = express(); const PORT = process.env.PORT || 3001; @@ -17,6 +21,28 @@ app.get("/api/hello", (_req: Request, res: Response) => { res.json({ message: "Hello from backend!", env: process.env.NODE_ENV || "development" }); }); +app.get("/api/roti", (_req: Request, res: Response) => { + const feedbacks = listFeedbacks(); + const average = calculateAverage(feedbacks.map((feedback) => feedback.rating)); + + res.json({ + average, + count: feedbacks.length, + feedbacks, + }); +}); + +app.post("/api/roti", (req: Request, res: Response) => { + try { + const feedback = validateFeedback(req.body); + const created = insertFeedback(feedback); + res.status(201).json(created); + } catch (error) { + const message = error instanceof Error ? error.message : "Invalid request"; + res.status(400).json({ error: message }); + } +}); + // Serve frontend static files const frontendPath = path.join(__dirname, "../../frontend/dist"); app.use(express.static(frontendPath)); diff --git a/apps/backend/src/roti-db.ts b/apps/backend/src/roti-db.ts new file mode 100644 index 0000000..85df337 --- /dev/null +++ b/apps/backend/src/roti-db.ts @@ -0,0 +1,66 @@ +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 defaultDbPath = path.join(__dirname, "../data/roti.sqlite"); +const dbPath = process.env.ROTI_DB_PATH || defaultDbPath; + +fs.mkdirSync(path.dirname(dbPath), { recursive: true }); + +const db = new Database(dbPath); +db.pragma("journal_mode = WAL"); +db.exec(` + CREATE TABLE IF NOT EXISTS roti_feedback ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + rating INTEGER NOT NULL, + comment TEXT NOT NULL, + created_at TEXT NOT NULL + ) +`); + +export interface RotiFeedbackRecord { + id: number; + rating: number; + comment: string; + createdAt: string; +} + +interface RotiFeedbackRow { + id: number; + rating: number; + comment: string; + createdAt: string; +} + +export function insertFeedback(input: { rating: number; comment: string }): RotiFeedbackRecord { + const createdAt = new Date().toISOString(); + const statement = db.prepare( + "INSERT INTO roti_feedback (rating, comment, created_at) VALUES (?, ?, ?)" + ); + const result = statement.run(input.rating, input.comment, createdAt); + + return { + id: Number(result.lastInsertRowid), + rating: input.rating, + comment: input.comment, + createdAt, + }; +} + +export function listFeedbacks(): RotiFeedbackRecord[] { + const rows = db + .prepare( + "SELECT id, rating, comment, created_at as createdAt FROM roti_feedback ORDER BY datetime(created_at) DESC" + ) + .all() as RotiFeedbackRow[]; + + return rows.map((row) => ({ + id: Number(row.id), + rating: Number(row.rating), + comment: String(row.comment), + createdAt: String(row.createdAt), + })); +} diff --git a/apps/backend/src/roti-service.ts b/apps/backend/src/roti-service.ts new file mode 100644 index 0000000..faf5aad --- /dev/null +++ b/apps/backend/src/roti-service.ts @@ -0,0 +1,34 @@ +export interface FeedbackInput { + rating: unknown; + comment: unknown; +} + +export interface ValidatedFeedback { + rating: number; + comment: string; +} + +export function validateFeedback(input: FeedbackInput): ValidatedFeedback { + if (typeof input.rating !== "number" || !Number.isInteger(input.rating)) { + throw new Error("Rating must be an integer between 1 and 5"); + } + + if (input.rating < 1 || input.rating > 5) { + throw new Error("Rating must be an integer between 1 and 5"); + } + + const comment = typeof input.comment === "string" ? input.comment.trim() : ""; + + if (input.rating <= 3 && comment.length === 0) { + throw new Error("Comment is required when rating is 3 or below"); + } + + return { rating: input.rating, comment }; +} + +export function calculateAverage(values: number[]): number { + if (values.length === 0) return 0; + const total = values.reduce((sum, value) => sum + value, 0); + const average = total / values.length; + return Math.round(average * 10) / 10; +} diff --git a/apps/frontend/index.html b/apps/frontend/index.html index 48e2a8e..2ab7e68 100644 --- a/apps/frontend/index.html +++ b/apps/frontend/index.html @@ -3,7 +3,13 @@
-React + Express + Railway
+ROTI Live
+ + Live voting + ++ Drop a quick ROTI score and help us capture the room energy. Comments are + required when the rating is 3 or below. +
+