diff --git a/.github/workflows/opencode-review.yml b/.github/workflows/opencode-review.yml index 134d43c..7243c4e 100644 --- a/.github/workflows/opencode-review.yml +++ b/.github/workflows/opencode-review.yml @@ -8,16 +8,21 @@ jobs: review: runs-on: ubuntu-latest permissions: + id-token: write contents: read pull-requests: write issues: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 + with: + persist-credentials: false - uses: anomalyco/opencode/github@latest env: OPENROUTER_API_KEY: ${{ secrets.OPENCODE_API_KEY }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - model: openrouter/mistralai/devstral-2512:free + model: openrouter/google/gemini-3-flash-preview + use_github_token: true prompt: | Review this pull request: - Check for code quality issues diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml index c57c0eb..4837053 100644 --- a/.github/workflows/opencode.yml +++ b/.github/workflows/opencode.yml @@ -13,13 +13,18 @@ jobs: contains(github.event.comment.body, '/opencode') runs-on: ubuntu-latest permissions: - contents: read - pull-requests: read - issues: read + id-token: write + contents: write + pull-requests: write + issues: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 + with: + persist-credentials: false - uses: anomalyco/opencode/github@latest env: OPENROUTER_API_KEY: ${{ secrets.OPENCODE_API_KEY }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - model: openrouter/mistralai/devstral-2512:free + model: openrouter/google/gemini-3-flash-preview + use_github_token: true diff --git a/apps/backend/data/roti.db b/apps/backend/data/roti.db new file mode 100644 index 0000000..c794a5b Binary files /dev/null and b/apps/backend/data/roti.db differ diff --git a/apps/backend/package.json b/apps/backend/package.json index bd8dd77..fdafd07 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -11,10 +11,12 @@ "test": "vitest run" }, "dependencies": { + "better-sqlite3": "^12.6.2", "express": "^4.18.2" }, "devDependencies": { "@eslint/js": "^8.56.0", + "@types/better-sqlite3": "^7.6.13", "@types/express": "^4.17.21", "@types/node": "^20.10.6", "eslint": "^8.56.0", diff --git a/apps/backend/src/db.ts b/apps/backend/src/db.ts new file mode 100644 index 0000000..9321c55 --- /dev/null +++ b/apps/backend/src/db.ts @@ -0,0 +1,51 @@ +import Database from "better-sqlite3"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const dbPath = path.join(__dirname, "../data/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 DEFAULT CURRENT_TIMESTAMP + ) +`); + +export interface Feedback { + id: number; + rating: number; + comment: string | null; + created_at: string; +} + +export function getAllFeedbacks(): Feedback[] { + return db.prepare("SELECT * FROM feedbacks ORDER BY created_at DESC").all() as Feedback[]; +} + +export function getAverageRating(): number | null { + const result = db.prepare("SELECT AVG(rating) as avg FROM feedbacks").get() as { avg: number | null }; + return result.avg ? Math.round(result.avg * 10) / 10 : null; +} + +export function createFeedback(rating: number, comment: string | null): Feedback { + const stmt = db.prepare("INSERT INTO feedbacks (rating, comment) VALUES (?, ?)"); + const result = stmt.run(rating, comment); + return db.prepare("SELECT * FROM feedbacks WHERE id = ?").get(result.lastInsertRowid) as Feedback; +} + +export function validateFeedback(rating: number, comment: string | null): string | null { + if (rating < 1 || rating > 5) { + return "Rating must be between 1 and 5"; + } + if (rating <= 3 && (!comment || comment.trim() === "")) { + return "Comment is required for ratings of 3 or below"; + } + return null; +} + +export default db; diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 678bf6d..f129268 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -2,6 +2,8 @@ import express, { Request, Response } from "express"; import path from "path"; import { fileURLToPath } from "url"; +import feedbacksRouter from "./routes/feedbacks.js"; + const __dirname = path.dirname(fileURLToPath(import.meta.url)); const app = express(); const PORT = process.env.PORT || 3001; @@ -13,9 +15,7 @@ app.get("/api/health", (_req: Request, res: Response) => { res.json({ status: "ok", timestamp: new Date().toISOString() }); }); -app.get("/api/hello", (_req: Request, res: Response) => { - res.json({ message: "Hello from backend!", env: process.env.NODE_ENV || "development" }); -}); +app.use("/api/feedbacks", feedbacksRouter); // Serve frontend static files const frontendPath = path.join(__dirname, "../../frontend/dist"); diff --git a/apps/backend/src/routes/feedbacks.test.ts b/apps/backend/src/routes/feedbacks.test.ts new file mode 100644 index 0000000..d48582f --- /dev/null +++ b/apps/backend/src/routes/feedbacks.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from "vitest"; +import { validateFeedback } from "../db.js"; + +describe("validateFeedback", () => { + it("should return null for valid rating 5 without comment", () => { + expect(validateFeedback(5, null)).toBeNull(); + }); + + it("should return null for valid rating 4 without comment", () => { + expect(validateFeedback(4, "")).toBeNull(); + }); + + it("should return null for rating 3 with comment", () => { + expect(validateFeedback(3, "Could be better")).toBeNull(); + }); + + it("should return null for rating 1 with comment", () => { + expect(validateFeedback(1, "Not good")).toBeNull(); + }); + + it("should return error for rating 3 without comment", () => { + expect(validateFeedback(3, null)).toBe("Comment is required for ratings of 3 or below"); + }); + + it("should return error for rating 2 with empty comment", () => { + expect(validateFeedback(2, "")).toBe("Comment is required for ratings of 3 or below"); + }); + + it("should return error for rating 1 with whitespace-only comment", () => { + expect(validateFeedback(1, " ")).toBe("Comment is required for ratings of 3 or below"); + }); + + it("should return error for rating below 1", () => { + expect(validateFeedback(0, "Comment")).toBe("Rating must be between 1 and 5"); + }); + + it("should return error for rating above 5", () => { + expect(validateFeedback(6, "Comment")).toBe("Rating must be between 1 and 5"); + }); + + it("should return error for negative rating", () => { + expect(validateFeedback(-1, "Comment")).toBe("Rating must be between 1 and 5"); + }); +}); diff --git a/apps/backend/src/routes/feedbacks.ts b/apps/backend/src/routes/feedbacks.ts new file mode 100644 index 0000000..1d6bfd3 --- /dev/null +++ b/apps/backend/src/routes/feedbacks.ts @@ -0,0 +1,25 @@ +import { Router, Request, Response } from "express"; +import { getAllFeedbacks, getAverageRating, createFeedback, validateFeedback } from "../db.js"; + +const router = Router(); + +router.get("/", (_req: Request, res: Response) => { + const feedbacks = getAllFeedbacks(); + const averageRating = getAverageRating(); + res.json({ feedbacks, averageRating }); +}); + +router.post("/", (req: Request, res: Response) => { + const { rating, comment } = req.body; + + const error = validateFeedback(rating, comment); + if (error) { + res.status(400).json({ error }); + return; + } + + const feedback = createFeedback(rating, comment || null); + res.status(201).json(feedback); +}); + +export default router; diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 7441660..9c5ff4a 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -15,12 +15,14 @@ }, "devDependencies": { "@eslint/js": "^8.56.0", + "@tailwindcss/vite": "^4.1.18", "@types/react": "^18.2.43", "@types/react-dom": "^18.2.17", "@vitejs/plugin-react": "^4.2.1", "eslint": "^8.56.0", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.5", + "tailwindcss": "^4.1.18", "typescript": "^5.2.2", "typescript-eslint": "^7.0.0", "vite": "^5.0.8" diff --git a/apps/frontend/src/App.css b/apps/frontend/src/App.css index 5700eae..f1d8c73 100644 --- a/apps/frontend/src/App.css +++ b/apps/frontend/src/App.css @@ -1,49 +1 @@ -* { - 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; -} +@import "tailwindcss"; diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index 3af0e05..f809660 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -1,50 +1,85 @@ import { useEffect, useState } from "react"; import "./App.css"; +import { FeedbackForm } from "./components/FeedbackForm"; +import { FeedbackList } from "./components/FeedbackList"; +import { AverageRating } from "./components/AverageRating"; -interface ApiResponse { - status?: string; - message?: string; - timestamp?: string; - env?: string; +interface Feedback { + id: number; + rating: number; + comment: string | null; + created_at: string; +} + +interface FeedbacksResponse { + feedbacks: Feedback[]; + averageRating: number | null; } function App() { - const [health, setHealth] = useState(null); - const [hello, setHello] = useState(null); + const [feedbacks, setFeedbacks] = useState([]); + const [averageRating, setAverageRating] = useState(null); const [error, setError] = useState(null); + const fetchFeedbacks = async () => { + try { + const res = await fetch("/api/feedbacks"); + if (!res.ok) throw new Error("Failed to fetch feedbacks"); + const data: FeedbacksResponse = await res.json(); + setFeedbacks(data.feedbacks); + setAverageRating(data.averageRating); + } catch (err) { + setError(err instanceof Error ? err.message : "An error occurred"); + } + }; + 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)); + fetchFeedbacks(); }, []); + const handleSubmit = async (rating: number, comment: string) => { + const res = await fetch("/api/feedbacks", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rating, comment: comment || null }), + }); + + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || "Failed to submit feedback"); + } + + await fetchFeedbacks(); + }; + return ( -
-

Live Code

-

React + Express + Railway

+
+
+
+

+ ROTI Feedback +

+

Return On Time Invested

+
- {error &&

Error: {error}

} + {error && ( +
+ {error} +
+ )} + +
+ +
- {health && ( -
-

Health

-

Status: {health.status}

-

Time: {health.timestamp}

+
+
- )} - {hello && ( -
-

Hello

-

{hello.message}

-

Env: {hello.env}

+
+
- )} +
); } diff --git a/apps/frontend/src/components/AverageRating.tsx b/apps/frontend/src/components/AverageRating.tsx new file mode 100644 index 0000000..fb9c15f --- /dev/null +++ b/apps/frontend/src/components/AverageRating.tsx @@ -0,0 +1,38 @@ +interface AverageRatingProps { + average: number | null; + count: number; +} + +export function AverageRating({ average, count }: AverageRatingProps) { + if (average === null || count === 0) { + return ( +
+

No ratings yet. Be the first!

+
+ ); + } + + const percentage = (average / 5) * 100; + + return ( +
+
+ {average.toFixed(1)} +
+
out of 5
+ +
+
+
+
+
+ +
+ Based on {count} {count === 1 ? "response" : "responses"} +
+
+ ); +} diff --git a/apps/frontend/src/components/FeedbackForm.tsx b/apps/frontend/src/components/FeedbackForm.tsx new file mode 100644 index 0000000..720ec89 --- /dev/null +++ b/apps/frontend/src/components/FeedbackForm.tsx @@ -0,0 +1,107 @@ +import { useState } from "react"; + +interface FeedbackFormProps { + onSubmit: (rating: number, comment: string) => Promise; +} + +export function FeedbackForm({ onSubmit }: FeedbackFormProps) { + const [rating, setRating] = useState(null); + const [comment, setComment] = useState(""); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + + const requiresComment = rating !== null && rating <= 3; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setSuccess(false); + + if (rating === null) { + setError("Please select a rating"); + return; + } + + if (requiresComment && !comment.trim()) { + setError("Comment is required for ratings of 3 or below"); + return; + } + + setIsSubmitting(true); + try { + await onSubmit(rating, comment); + setRating(null); + setComment(""); + setSuccess(true); + setTimeout(() => setSuccess(false), 3000); + } catch (err) { + setError(err instanceof Error ? err.message : "An error occurred"); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+
+ +
+ {[1, 2, 3, 4, 5].map((value) => ( + + ))} +
+
+ Waste of time + Excellent! +
+
+ +
+ +