Skip to content

Commit cd1a79f

Browse files
committed
feat: add ROTI feedback flow
1 parent cea1d50 commit cd1a79f

14 files changed

Lines changed: 397 additions & 88 deletions

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,5 @@ coverage/
88
.vscode/
99
.idea/
1010
package-lock.json
11+
apps/backend/data/
12+
*.sqlite

apps/backend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"test": "vitest run"
1212
},
1313
"dependencies": {
14+
"better-sqlite3": "^9.4.4",
1415
"express": "^4.18.2"
1516
},
1617
"devDependencies": {

apps/backend/src/index.test.ts

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,46 @@
1-
import { describe, it, expect } from "vitest";
1+
import { describe, expect, it } from "vitest";
22

3-
describe("Backend", () => {
4-
it("should pass placeholder test", () => {
5-
expect(true).toBe(true);
3+
import { calculateAverage, validateFeedback } from "./roti-service.js";
4+
5+
describe("validateFeedback", () => {
6+
it("rejects ratings below 1", () => {
7+
expect(() => validateFeedback({ rating: 0, comment: "ok" })).toThrow(
8+
"Rating must be an integer between 1 and 5"
9+
);
10+
});
11+
12+
it("rejects ratings above 5", () => {
13+
expect(() => validateFeedback({ rating: 6, comment: "ok" })).toThrow(
14+
"Rating must be an integer between 1 and 5"
15+
);
16+
});
17+
18+
it("rejects non-integer ratings", () => {
19+
expect(() => validateFeedback({ rating: 2.5, comment: "ok" })).toThrow(
20+
"Rating must be an integer between 1 and 5"
21+
);
22+
});
23+
24+
it("requires a comment for ratings at or below 3", () => {
25+
expect(() => validateFeedback({ rating: 3, comment: " " })).toThrow(
26+
"Comment is required when rating is 3 or below"
27+
);
28+
});
29+
30+
it("allows empty comment for ratings above 3", () => {
31+
expect(validateFeedback({ rating: 4, comment: " " })).toEqual({
32+
rating: 4,
33+
comment: "",
34+
});
35+
});
36+
});
37+
38+
describe("calculateAverage", () => {
39+
it("returns zero for empty input", () => {
40+
expect(calculateAverage([])).toBe(0);
41+
});
42+
43+
it("calculates average to one decimal", () => {
44+
expect(calculateAverage([5, 4, 4])).toBe(4.3);
645
});
746
});

apps/backend/src/index.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
1-
import express, { Request, Response } from "express";
21
import path from "path";
32
import { fileURLToPath } from "url";
43

4+
import express, { Request, Response } from "express";
5+
6+
import { insertFeedback, listFeedbacks } from "./roti-db.js";
7+
import { calculateAverage, validateFeedback } from "./roti-service.js";
8+
59
const __dirname = path.dirname(fileURLToPath(import.meta.url));
610
const app = express();
711
const PORT = process.env.PORT || 3001;
@@ -17,6 +21,28 @@ app.get("/api/hello", (_req: Request, res: Response) => {
1721
res.json({ message: "Hello from backend!", env: process.env.NODE_ENV || "development" });
1822
});
1923

24+
app.get("/api/roti", (_req: Request, res: Response) => {
25+
const feedbacks = listFeedbacks();
26+
const average = calculateAverage(feedbacks.map((feedback) => feedback.rating));
27+
28+
res.json({
29+
average,
30+
count: feedbacks.length,
31+
feedbacks,
32+
});
33+
});
34+
35+
app.post("/api/roti", (req: Request, res: Response) => {
36+
try {
37+
const feedback = validateFeedback(req.body);
38+
const created = insertFeedback(feedback);
39+
res.status(201).json(created);
40+
} catch (error) {
41+
const message = error instanceof Error ? error.message : "Invalid request";
42+
res.status(400).json({ error: message });
43+
}
44+
});
45+
2046
// Serve frontend static files
2147
const frontendPath = path.join(__dirname, "../../frontend/dist");
2248
app.use(express.static(frontendPath));

apps/backend/src/roti-db.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import fs from "fs";
2+
import path from "path";
3+
import { fileURLToPath } from "url";
4+
5+
import Database from "better-sqlite3";
6+
7+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
8+
const defaultDbPath = path.join(__dirname, "../data/roti.sqlite");
9+
const dbPath = process.env.ROTI_DB_PATH || defaultDbPath;
10+
11+
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
12+
13+
const db = new Database(dbPath);
14+
db.pragma("journal_mode = WAL");
15+
db.exec(`
16+
CREATE TABLE IF NOT EXISTS roti_feedback (
17+
id INTEGER PRIMARY KEY AUTOINCREMENT,
18+
rating INTEGER NOT NULL,
19+
comment TEXT NOT NULL,
20+
created_at TEXT NOT NULL
21+
)
22+
`);
23+
24+
export interface RotiFeedbackRecord {
25+
id: number;
26+
rating: number;
27+
comment: string;
28+
createdAt: string;
29+
}
30+
31+
export function insertFeedback(input: { rating: number; comment: string }): RotiFeedbackRecord {
32+
const createdAt = new Date().toISOString();
33+
const statement = db.prepare(
34+
"INSERT INTO roti_feedback (rating, comment, created_at) VALUES (?, ?, ?)"
35+
);
36+
const result = statement.run(input.rating, input.comment, createdAt);
37+
38+
return {
39+
id: Number(result.lastInsertRowid),
40+
rating: input.rating,
41+
comment: input.comment,
42+
createdAt,
43+
};
44+
}
45+
46+
export function listFeedbacks(): RotiFeedbackRecord[] {
47+
const rows = db
48+
.prepare(
49+
"SELECT id, rating, comment, created_at as createdAt FROM roti_feedback ORDER BY datetime(created_at) DESC"
50+
)
51+
.all();
52+
53+
return rows.map((row) => ({
54+
id: Number(row.id),
55+
rating: Number(row.rating),
56+
comment: String(row.comment),
57+
createdAt: String(row.createdAt),
58+
}));
59+
}

apps/backend/src/roti-service.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
export interface FeedbackInput {
2+
rating: unknown;
3+
comment: unknown;
4+
}
5+
6+
export interface ValidatedFeedback {
7+
rating: number;
8+
comment: string;
9+
}
10+
11+
export function validateFeedback(input: FeedbackInput): ValidatedFeedback {
12+
if (typeof input.rating !== "number" || !Number.isInteger(input.rating)) {
13+
throw new Error("Rating must be an integer between 1 and 5");
14+
}
15+
16+
if (input.rating < 1 || input.rating > 5) {
17+
throw new Error("Rating must be an integer between 1 and 5");
18+
}
19+
20+
const comment = typeof input.comment === "string" ? input.comment.trim() : "";
21+
22+
if (input.rating <= 3 && comment.length === 0) {
23+
throw new Error("Comment is required when rating is 3 or below");
24+
}
25+
26+
return { rating: input.rating, comment };
27+
}
28+
29+
export function calculateAverage(values: number[]): number {
30+
if (values.length === 0) return 0;
31+
const total = values.reduce((sum, value) => sum + value, 0);
32+
const average = total / values.length;
33+
return Math.round(average * 10) / 10;
34+
}

apps/frontend/index.html

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,13 @@
33
<head>
44
<meta charset="UTF-8" />
55
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6-
<title>Live Code</title>
6+
<link rel="preconnect" href="https://fonts.googleapis.com" />
7+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
8+
<link
9+
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap"
10+
rel="stylesheet"
11+
/>
12+
<title>ROTI Live</title>
713
</head>
814
<body>
915
<div id="root"></div>

apps/frontend/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,12 @@
1818
"@types/react": "^18.2.43",
1919
"@types/react-dom": "^18.2.17",
2020
"@vitejs/plugin-react": "^4.2.1",
21+
"autoprefixer": "^10.4.19",
2122
"eslint": "^8.56.0",
2223
"eslint-plugin-react-hooks": "^4.6.0",
2324
"eslint-plugin-react-refresh": "^0.4.5",
25+
"postcss": "^8.4.35",
26+
"tailwindcss": "^3.4.1",
2427
"typescript": "^5.2.2",
2528
"typescript-eslint": "^7.0.0",
2629
"vite": "^5.0.8"

apps/frontend/postcss.config.cjs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
module.exports = {
2+
plugins: {
3+
tailwindcss: {},
4+
autoprefixer: {},
5+
},
6+
};

apps/frontend/src/App.css

Lines changed: 0 additions & 49 deletions
This file was deleted.

0 commit comments

Comments
 (0)