Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,5 @@ coverage/
.vscode/
.idea/
package-lock.json
apps/backend/data/
*.sqlite
2 changes: 2 additions & 0 deletions apps/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
47 changes: 43 additions & 4 deletions apps/backend/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
28 changes: 27 additions & 1 deletion apps/backend/src/index.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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));
Expand Down
66 changes: 66 additions & 0 deletions apps/backend/src/roti-db.ts
Original file line number Diff line number Diff line change
@@ -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),
}));
}
34 changes: 34 additions & 0 deletions apps/backend/src/roti-service.ts
Original file line number Diff line number Diff line change
@@ -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;
}
8 changes: 7 additions & 1 deletion apps/frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Live Code</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<title>ROTI Live</title>
</head>
<body>
<div id="root"></div>
Expand Down
3 changes: 3 additions & 0 deletions apps/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,12 @@
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.19",
"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.1",
"typescript": "^5.2.2",
"typescript-eslint": "^7.0.0",
"vite": "^5.0.8"
Expand Down
6 changes: 6 additions & 0 deletions apps/frontend/postcss.config.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
49 changes: 0 additions & 49 deletions apps/frontend/src/App.css

This file was deleted.

Loading
Loading