Skip to content
Closed
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
9 changes: 7 additions & 2 deletions .github/workflows/opencode-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 10 additions & 5 deletions .github/workflows/opencode.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Binary file added apps/backend/data/roti.db
Binary file not shown.
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": "^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",
Expand Down
51 changes: 51 additions & 0 deletions apps/backend/src/db.ts
Original file line number Diff line number Diff line change
@@ -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;
6 changes: 3 additions & 3 deletions apps/backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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");
Expand Down
44 changes: 44 additions & 0 deletions apps/backend/src/routes/feedbacks.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
25 changes: 25 additions & 0 deletions apps/backend/src/routes/feedbacks.ts
Original file line number Diff line number Diff line change
@@ -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;
2 changes: 2 additions & 0 deletions apps/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
50 changes: 1 addition & 49 deletions apps/frontend/src/App.css
Original file line number Diff line number Diff line change
@@ -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";
95 changes: 65 additions & 30 deletions apps/frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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<ApiResponse | null>(null);
const [hello, setHello] = useState<ApiResponse | null>(null);
const [feedbacks, setFeedbacks] = useState<Feedback[]>([]);
const [averageRating, setAverageRating] = useState<number | null>(null);
const [error, setError] = useState<string | null>(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 (
<div className="app">
<h1>Live Code</h1>
<p>React + Express + Railway</p>
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-indigo-50">
<div className="max-w-md mx-auto px-4 py-8">
<header className="text-center mb-8">
<h1 className="text-3xl font-bold bg-gradient-to-r from-indigo-600 to-violet-600 bg-clip-text text-transparent">
ROTI Feedback
</h1>
<p className="text-gray-500 mt-2">Return On Time Invested</p>
</header>

{error && <p className="error">Error: {error}</p>}
{error && (
<div className="mb-6 text-red-500 text-sm bg-red-50 px-4 py-2 rounded-lg">
{error}
</div>
)}

<div className="bg-white rounded-2xl shadow-xl p-6 mb-6">
<AverageRating average={averageRating} count={feedbacks.length} />
</div>

{health && (
<div className="card">
<h2>Health</h2>
<p>Status: {health.status}</p>
<p>Time: {health.timestamp}</p>
<div className="bg-white rounded-2xl shadow-xl p-6 mb-6">
<FeedbackForm onSubmit={handleSubmit} />
</div>
)}

{hello && (
<div className="card">
<h2>Hello</h2>
<p>{hello.message}</p>
<p>Env: {hello.env}</p>
<div className="bg-white rounded-2xl shadow-xl p-6">
<FeedbackList feedbacks={feedbacks} />
</div>
)}
</div>
</div>
);
}
Expand Down
Loading