-
Notifications
You must be signed in to change notification settings - Fork 0
Add TeamExpense application code #5
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,29 @@ | ||||||||
| import Database from "better-sqlite3"; | ||||||||
| import path from "path"; | ||||||||
|
|
||||||||
| const db = new Database(path.join(__dirname, "..", "teamexpense.db")); | ||||||||
|
|
||||||||
| db.pragma("journal_mode = WAL"); | ||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Foreign key enforcement is disabled — the SQLite disables FK enforcement by default. Without 🔧 Proposed fix db.pragma("journal_mode = WAL");
+db.pragma("foreign_keys = ON");Note: this pragma must be set on every connection opening (it is not persisted). Since 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||
|
|
||||||||
| db.exec(` | ||||||||
| CREATE TABLE IF NOT EXISTS users ( | ||||||||
| id INTEGER PRIMARY KEY AUTOINCREMENT, | ||||||||
| email TEXT UNIQUE NOT NULL, | ||||||||
| password TEXT NOT NULL, | ||||||||
| name TEXT NOT NULL, | ||||||||
| role TEXT NOT NULL DEFAULT 'member' | ||||||||
| ); | ||||||||
|
|
||||||||
| CREATE TABLE IF NOT EXISTS expenses ( | ||||||||
| id INTEGER PRIMARY KEY AUTOINCREMENT, | ||||||||
| user_id INTEGER NOT NULL, | ||||||||
| amount REAL NOT NULL, | ||||||||
| description TEXT NOT NULL, | ||||||||
| category TEXT NOT NULL, | ||||||||
| status TEXT NOT NULL DEFAULT 'pending', | ||||||||
| created_at TEXT NOT NULL DEFAULT (datetime('now')), | ||||||||
| FOREIGN KEY (user_id) REFERENCES users(id) | ||||||||
| ); | ||||||||
| `); | ||||||||
|
|
||||||||
| export default db; | ||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,23 @@ | ||||||||||||
| import express from "express"; | ||||||||||||
| import cors from "cors"; | ||||||||||||
| import authRoutes from "./routes/auth"; | ||||||||||||
| import expenseRoutes from "./routes/expenses"; | ||||||||||||
| import reportRoutes from "./routes/reports"; | ||||||||||||
|
|
||||||||||||
| const app = express(); | ||||||||||||
| const PORT = 3001; | ||||||||||||
|
|
||||||||||||
| app.use(cors()); | ||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. CORS allows all origins - attackers can make requests from malicious sites There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Restrict CORS to known origins.
🛡️ Proposed fix-app.use(cors());
+app.use(cors({
+ origin: process.env.CORS_ORIGIN || "http://localhost:3000",
+ methods: ["GET", "POST", "PUT", "PATCH", "DELETE"],
+}));📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||
| app.use(express.json()); | ||||||||||||
|
|
||||||||||||
| app.use("/api/auth", authRoutes); | ||||||||||||
| app.use("/api/expenses", expenseRoutes); | ||||||||||||
| app.use("/api/reports", reportRoutes); | ||||||||||||
|
|
||||||||||||
| app.get("/api/health", (_, res) => { | ||||||||||||
| res.json({ status: "ok" }); | ||||||||||||
| }); | ||||||||||||
|
|
||||||||||||
| app.listen(PORT, () => { | ||||||||||||
| console.log(`Server running on http://localhost:${PORT}`); | ||||||||||||
| }); | ||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,32 @@ | ||||||||||||
| import { Request, Response, NextFunction } from "express"; | ||||||||||||
| import jwt from "jsonwebtoken"; | ||||||||||||
|
|
||||||||||||
| const JWT_SECRET = "secret123"; | ||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hardcoded JWT secret exposes all tokens to compromise - must use environment variable There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 6. Hardcoded jwt secret JWT signing/verification uses a hardcoded secret (secret123). If the code leaks or is guessable, attackers can forge tokens and become any user/admin. Agent Prompt
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hardcoded
Load it from an environment variable and fail fast at startup if it is absent: 🛡️ Proposed fix-const JWT_SECRET = "secret123";
+const JWT_SECRET = process.env.JWT_SECRET;
+if (!JWT_SECRET) {
+ throw new Error("JWT_SECRET environment variable is not set");
+}Update the export type accordingly ( 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||
|
|
||||||||||||
| export interface AuthRequest extends Request { | ||||||||||||
| user?: { id: number; email: string; role: string }; | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| export function authenticate(req: AuthRequest, res: Response, next: NextFunction) { | ||||||||||||
| const header = req.headers.authorization; | ||||||||||||
|
|
||||||||||||
| if (!header || !header.startsWith("Bearer ")) { | ||||||||||||
| return res.status(401).json({ error: "No token provided" }); | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| const token = header.split(" ")[1]; | ||||||||||||
|
|
||||||||||||
| try { | ||||||||||||
| const decoded = jwt.verify(token, JWT_SECRET) as { | ||||||||||||
| id: number; | ||||||||||||
| email: string; | ||||||||||||
| role: string; | ||||||||||||
| }; | ||||||||||||
| req.user = decoded; | ||||||||||||
| next(); | ||||||||||||
| } catch { | ||||||||||||
| return res.status(401).json({ error: "Invalid token" }); | ||||||||||||
| } | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| export { JWT_SECRET }; | ||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,66 @@ | ||
| import { Router, Request, Response } from "express"; | ||
| import bcrypt from "bcryptjs"; | ||
| import jwt from "jsonwebtoken"; | ||
| import db from "../db"; | ||
| import { JWT_SECRET } from "../middleware/auth"; | ||
| import { isValidEmail } from "../utils/validate"; | ||
|
|
||
| const router = Router(); | ||
|
|
||
| router.post("/register", async (req: Request, res: Response) => { | ||
| const { email, password, name } = req.body; | ||
|
|
||
| if (!email || !password || !name) { | ||
| return res.status(400).json({ error: "All fields are required" }); | ||
| } | ||
|
|
||
| if (!isValidEmail(email)) { | ||
| return res.status(400).json({ error: "Invalid email format" }); | ||
| } | ||
|
|
||
| const existing = db.prepare("SELECT id FROM users WHERE email = ?").get(email); | ||
| if (existing) { | ||
| return res.status(409).json({ error: "Email already registered" }); | ||
| } | ||
|
|
||
| const hash = await bcrypt.hash(password, 10); | ||
| const result = db.prepare( | ||
| "INSERT INTO users (email, password, name) VALUES (?, ?, ?)" | ||
| ).run(email, hash, name); | ||
|
|
||
| const token = jwt.sign( | ||
| { id: result.lastInsertRowid, email, role: "member" }, | ||
| JWT_SECRET, | ||
| { expiresIn: "24h" } | ||
| ); | ||
|
|
||
| res.status(201).json({ token, user: { id: result.lastInsertRowid, email, name, role: "member" } }); | ||
|
Comment on lines
+27
to
+37
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# First, let's find and examine the auth.ts file to see the code context
if [ -f "backend/src/routes/auth.ts" ]; then
echo "=== Auth.ts file (lines 20-40) ==="
sed -n '20,40p' backend/src/routes/auth.ts
fi
# Check what version of better-sqlite3 is installed
echo -e "\n=== Package.json dependencies ==="
if [ -f "package.json" ]; then
grep -A 10 '"dependencies"' package.json | grep -E '(better-sqlite3|@types/better-sqlite3)'
fi
if [ -f "backend/package.json" ]; then
grep -A 10 '"dependencies"' backend/package.json | grep -E '(better-sqlite3|@types/better-sqlite3)'
fi
# Search for lastInsertRowid usage in the codebase
echo -e "\n=== lastInsertRowid usage ==="
rg -n "lastInsertRowid" --type ts --type jsRepository: reeder32/teamexpense Length of output: 1249 🏁 Script executed: #!/bin/bash
# Check if node_modules has the type definitions
echo "=== Checking for better-sqlite3 types in node_modules ==="
if [ -d "node_modules/@types/better-sqlite3" ]; then
find node_modules/@types/better-sqlite3 -name "*.d.ts" -type f | head -5
echo ""
echo "=== Type definition for RunResult ==="
grep -A 5 "RunResult" node_modules/@types/better-sqlite3/index.d.ts 2>/dev/null || echo "File not found in expected location"
fi
# Also try to find the declaration file another way
echo -e "\n=== Searching for RunResult type definition ==="
find . -path ./node_modules -prune -o -name "*.d.ts" -type f -print 2>/dev/null | xargs grep -l "RunResult" 2>/dev/null || echo "No RunResult definitions found"
# Check if the type definitions are available
echo -e "\n=== Checking available package.json in node_modules ==="
if [ -f "node_modules/better-sqlite3/package.json" ]; then
grep -E '"(types|typings)"' node_modules/better-sqlite3/package.json
fiRepository: reeder32/teamexpense Length of output: 258 🌐 Web query:
💡 Result: In better-sqlite3, interface RunResult {
changes: number;
lastInsertRowid: number | bigint;
}That Note: the API docs describe the same field (spelled Sources: [1] [2] 🏁 Script executed: #!/bin/bash
# Check TypeScript configuration
echo "=== TypeScript Config ==="
if [ -f "tsconfig.json" ]; then
jq '.' tsconfig.json 2>/dev/null || cat tsconfig.json
fi
if [ -f "backend/tsconfig.json" ]; then
jq '.' backend/tsconfig.json 2>/dev/null || cat backend/tsconfig.json
fi
# Check if there's any type casting or narrowing around lastInsertRowid
echo -e "\n=== Context around lastInsertRowid usage in auth.ts ==="
sed -n '25,40p' backend/src/routes/auth.ts
# Check if jwt.sign has any special handling or type requirements
echo -e "\n=== Checking jwt import and type ==="
grep -n "import.*jwt" backend/src/routes/auth.ts
# Verify the actual behavior of JSON.stringify with bigint
echo -e "\n=== Testing JSON.stringify with bigint behavior ==="
node <<'EOF'
try {
const result = JSON.stringify({ id: BigInt(1), email: "test" });
console.log("Result:", result);
} catch (e) {
console.log("Error:", e.message);
}
EOFRepository: reeder32/teamexpense Length of output: 1140 Handle the
🤖 Prompt for AI Agents |
||
| }); | ||
|
|
||
| router.post("/login", async (req: Request, res: Response) => { | ||
| const { email, password } = req.body; | ||
|
|
||
| if (!email || !password) { | ||
| return res.status(400).json({ error: "Email and password are required" }); | ||
| } | ||
|
|
||
| const user = db.prepare("SELECT * FROM users WHERE email = ?").get(email) as any; | ||
| if (!user) { | ||
| return res.status(401).json({ error: "Invalid credentials" }); | ||
| } | ||
|
|
||
| const valid = await bcrypt.compare(password, user.password); | ||
| if (!valid) { | ||
| return res.status(401).json({ error: "Invalid credentials" }); | ||
| } | ||
|
|
||
| const token = jwt.sign( | ||
| { id: user.id, email: user.email, role: user.role }, | ||
| JWT_SECRET, | ||
| { expiresIn: "24h" } | ||
| ); | ||
|
|
||
| res.json({ token, user: { id: user.id, email: user.email, name: user.name, role: user.role } }); | ||
| }); | ||
|
Comment on lines
+40
to
+64
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Without request rate limiting, an attacker can attempt unlimited password guesses against any known email address. Add per-IP or per-email rate limiting (e.g., 🤖 Prompt for AI Agents |
||
|
|
||
| export default router; | ||
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,152 @@ | ||||||||||||
| import { Router, Response } from "express"; | ||||||||||||
| import db from "../db"; | ||||||||||||
| import { authenticate, AuthRequest } from "../middleware/auth"; | ||||||||||||
| import { isPositiveNumber, sanitizeString, CATEGORIES, BUDGET_LIMIT } from "../utils/validate"; | ||||||||||||
|
|
||||||||||||
| const router = Router(); | ||||||||||||
|
|
||||||||||||
| // Create expense | ||||||||||||
| router.post("/", authenticate, (req: AuthRequest, res: Response) => { | ||||||||||||
| const { amount, description, category } = req.body; | ||||||||||||
| const userId = req.user!.id; | ||||||||||||
|
|
||||||||||||
| if (!isPositiveNumber(amount)) { | ||||||||||||
| return res.status(400).json({ error: "Amount must be a positive number" }); | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| if (!description || !category) { | ||||||||||||
| return res.status(400).json({ error: "Description and category are required" }); | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| if (!CATEGORIES.includes(category)) { | ||||||||||||
| return res.status(400).json({ error: "Invalid category" }); | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| const existing = db.prepare( | ||||||||||||
| "SELECT COALESCE(SUM(amount), 0) as total FROM expenses WHERE user_id = ? AND status != 'rejected'" | ||||||||||||
| ).get(userId) as any; | ||||||||||||
|
|
||||||||||||
| if (existing.total + amount > BUDGET_LIMIT) { | ||||||||||||
| return res.status(400).json({ error: `Would exceed budget limit of $${BUDGET_LIMIT}` }); | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| const result = db.prepare( | ||||||||||||
| "INSERT INTO expenses (user_id, amount, description, category) VALUES (?, ?, ?, ?)" | ||||||||||||
| ).run(userId, amount, sanitizeString(description), category); | ||||||||||||
|
|
||||||||||||
| res.status(201).json({ id: result.lastInsertRowid, amount, description, category, status: "pending" }); | ||||||||||||
| }); | ||||||||||||
|
|
||||||||||||
| // Get single expense | ||||||||||||
| router.get("/:id", authenticate, (req: AuthRequest, res: Response) => { | ||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: # First, check if the file exists and its size
wc -l backend/src/routes/expenses.tsRepository: reeder32/teamexpense Length of output: 98 🏁 Script executed: # Read the file to see all route handlers
cat -n backend/src/routes/expenses.tsRepository: reeder32/teamexpense Length of output: 5742 Constrain The Suggested fix-router.get("/:id", authenticate, (req: AuthRequest, res: Response) => {
+router.get("/:id(\\d+)", authenticate, (req: AuthRequest, res: Response) => {📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||
| const expense = db.prepare("SELECT * FROM expenses WHERE id = ?").get(req.params.id); | ||||||||||||
|
|
||||||||||||
| if (!expense) { | ||||||||||||
| return res.status(404).json({ error: "Expense not found" }); | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| res.json(expense); | ||||||||||||
|
Comment on lines
+41
to
+48
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Users can view any expense by ID without checking ownership - should verify
Comment on lines
+41
to
+48
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 3. Expense idor read GET /api/expenses/:id returns an expense solely by ID without checking ownership, allowing any authenticated user to fetch other users’ expenses by guessing IDs. Agent Prompt
Comment on lines
+42
to
+48
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Scope Any authenticated user can fetch any expense by ID right now. This is an authorization bypass. ✅ Suggested fix-router.get("/:id", authenticate, (req: AuthRequest, res: Response) => {
- const expense = db.prepare("SELECT * FROM expenses WHERE id = ?").get(req.params.id);
+router.get("/:id", authenticate, (req: AuthRequest, res: Response) => {
+ const expense = db
+ .prepare("SELECT * FROM expenses WHERE id = ? AND user_id = ?")
+ .get(req.params.id, req.user!.id);🤖 Prompt for AI Agents |
||||||||||||
| }); | ||||||||||||
|
|
||||||||||||
| // List expenses with pagination | ||||||||||||
| router.get("/", authenticate, (req: AuthRequest, res: Response) => { | ||||||||||||
| const page = parseInt(req.query.page as string) || 1; | ||||||||||||
| const limit = parseInt(req.query.limit as string) || 20; | ||||||||||||
| const userId = req.user!.id; | ||||||||||||
|
|
||||||||||||
| const expenses = db.prepare( | ||||||||||||
| "SELECT * FROM expenses WHERE user_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?" | ||||||||||||
| ).all(userId, limit, page * limit); | ||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Pagination offset calculation is wrong - should be
Suggested change
|
||||||||||||
|
|
||||||||||||
|
Comment on lines
+52
to
+60
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 4. Pagination offset wrong The list endpoint uses OFFSET page * limit which skips the first page (page=1 starts at offset=20). Users will see missing/empty results on early pages. Agent Prompt
Comment on lines
+52
to
+60
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fix pagination offset and guard against negative values. Offset uses ✅ Suggested fix- const page = parseInt(req.query.page as string) || 1;
- const limit = parseInt(req.query.limit as string) || 20;
+ const page = Math.max(parseInt(req.query.page as string) || 1, 1);
+ const limit = Math.max(parseInt(req.query.limit as string) || 20, 1);
@@
- ).all(userId, limit, page * limit);
+ const offset = (page - 1) * limit;
+ ).all(userId, limit, offset);🤖 Prompt for AI Agents |
||||||||||||
| const count = db.prepare( | ||||||||||||
| "SELECT COUNT(*) as total FROM expenses WHERE user_id = ?" | ||||||||||||
| ).get(userId) as any; | ||||||||||||
|
|
||||||||||||
| res.json({ data: expenses, total: count.total, page, limit }); | ||||||||||||
| }); | ||||||||||||
|
|
||||||||||||
| // Search expenses | ||||||||||||
| router.get("/search", authenticate, (req: AuthRequest, res: Response) => { | ||||||||||||
| const query = req.query.q as string; | ||||||||||||
|
|
||||||||||||
| if (!query) { | ||||||||||||
| return res.status(400).json({ error: "Search query is required" }); | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| const results = db.prepare( | ||||||||||||
| `SELECT * FROM expenses WHERE user_id = ${req.user!.id} AND description LIKE '%${query}%' ORDER BY created_at DESC` | ||||||||||||
| ).all(); | ||||||||||||
|
Comment on lines
+77
to
+78
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Critical SQL injection vulnerability - user input
Suggested change
Comment on lines
+69
to
+78
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 1. Sql injection search The expenses search endpoint builds SQL with unescaped user input, enabling SQL injection and potential cross-user data access/modification. This is exploitable via the q query parameter. Agent Prompt
Comment on lines
+68
to
+78
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Parameterize search query to prevent SQL injection. String interpolation in SQL allows injection via ✅ Suggested fix- const query = req.query.q as string;
+ const query = req.query.q;
- if (!query) {
+ if (typeof query !== "string" || !query.trim()) {
return res.status(400).json({ error: "Search query is required" });
}
- const results = db.prepare(
- `SELECT * FROM expenses WHERE user_id = ${req.user!.id} AND description LIKE '%${query}%' ORDER BY created_at DESC`
- ).all();
+ const like = `%${sanitizeString(query)}%`;
+ const results = db
+ .prepare(
+ "SELECT * FROM expenses WHERE user_id = ? AND description LIKE ? ORDER BY created_at DESC"
+ )
+ .all(req.user!.id, like);🤖 Prompt for AI Agents |
||||||||||||
|
|
||||||||||||
| res.json(results); | ||||||||||||
| }); | ||||||||||||
|
|
||||||||||||
| // Filter expenses by date range | ||||||||||||
| router.get("/filter", authenticate, (req: AuthRequest, res: Response) => { | ||||||||||||
| const { startDate, endDate, category } = req.query; | ||||||||||||
|
Comment on lines
+40
to
+85
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 2. Routes shadowed by :id GET /:id is registered before /search and /filter, so requests to /search and /filter will match the /:id handler first and never reach the intended endpoints. This breaks search and filtering. Agent Prompt
|
||||||||||||
| const userId = req.user!.id; | ||||||||||||
|
|
||||||||||||
| let sql = "SELECT * FROM expenses WHERE user_id = ?"; | ||||||||||||
| const params: any[] = [userId]; | ||||||||||||
|
|
||||||||||||
| if (startDate) { | ||||||||||||
| sql += " AND created_at >= ?"; | ||||||||||||
| params.push(startDate); | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| if (endDate) { | ||||||||||||
| sql += " AND created_at < ?"; | ||||||||||||
| params.push(endDate); | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| if (category) { | ||||||||||||
| sql += " AND category = ?"; | ||||||||||||
| params.push(category); | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| sql += " ORDER BY created_at DESC"; | ||||||||||||
| const results = db.prepare(sql).all(...params); | ||||||||||||
| res.json(results); | ||||||||||||
| }); | ||||||||||||
|
|
||||||||||||
| // Approve / reject expense (managers only) | ||||||||||||
| router.patch("/:id/status", authenticate, (req: AuthRequest, res: Response) => { | ||||||||||||
| if (req.user!.role !== "admin") { | ||||||||||||
| return res.status(403).json({ error: "Only managers can approve/reject expenses" }); | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| const { status } = req.body; | ||||||||||||
| if (!["approved", "rejected"].includes(status)) { | ||||||||||||
| return res.status(400).json({ error: "Status must be 'approved' or 'rejected'" }); | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| const result = db.prepare( | ||||||||||||
| "UPDATE expenses SET status = ? WHERE id = ?" | ||||||||||||
| ).run(status, req.params.id); | ||||||||||||
|
|
||||||||||||
| if (result.changes === 0) { | ||||||||||||
| return res.status(404).json({ error: "Expense not found" }); | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| res.json({ message: `Expense ${status}` }); | ||||||||||||
| }); | ||||||||||||
|
|
||||||||||||
| // Delete expense | ||||||||||||
| router.delete("/:id", authenticate, (req: AuthRequest, res: Response) => { | ||||||||||||
| const expense = db.prepare("SELECT * FROM expenses WHERE id = ? AND user_id = ?").get( | ||||||||||||
| req.params.id, | ||||||||||||
| req.user!.id | ||||||||||||
| ) as any; | ||||||||||||
|
|
||||||||||||
| if (!expense) { | ||||||||||||
| return res.status(404).json({ error: "Expense not found" }); | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| if (expense.status !== "pending") { | ||||||||||||
| return res.status(400).json({ error: "Can only delete pending expenses" }); | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| db.prepare("DELETE FROM expenses WHERE id = ?").run(req.params.id); | ||||||||||||
| res.json({ message: "Expense deleted" }); | ||||||||||||
| }); | ||||||||||||
|
|
||||||||||||
| export default router; | ||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,56 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { Router, Request, Response } from "express"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import db from "../db"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { authenticate, AuthRequest } from "../middleware/auth"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const router = Router(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // User spending summary | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| router.get("/my-summary", authenticate, (req: AuthRequest, res: Response) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const userId = req.user!.id; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const expenses = db.prepare( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "SELECT amount, category FROM expenses WHERE user_id = ? AND status = 'approved'" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ).all(userId) as any[]; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const byCategory: Record<string, number> = {}; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let total = 0; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| for (const exp of expenses) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| byCategory[exp.category] = (byCategory[exp.category] || 0) + exp.amount; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| total += exp.amount; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| res.json({ total, byCategory, count: expenses.length }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Team summary — admin only | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| router.get("/team-summary", (req: Request, res: Response) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const expenses = db.prepare( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "SELECT * FROM expenses WHERE status = 'approved'" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ).all() as any[]; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const summary: Record<string, { name: string; total: number; count: number }> = {}; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+26
to
+33
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 5. Public team summary GET /api/reports/team-summary is labeled “admin only” but has no authentication/authorization middleware, leaking organization-wide spending totals to unauthenticated callers. Agent Prompt
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| for (const exp of expenses) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!summary[exp.user_id]) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const user = db.prepare("SELECT name FROM users WHERE id = ?").get(exp.user_id) as any; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| summary[exp.user_id] = { name: user.name, total: 0, count: 0 }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| summary[exp.user_id].total += exp.amount; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| summary[exp.user_id].count += 1; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+34
to
+41
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. N+1 query per unique user plus a missing null guard on the user lookup. For every distinct Replace with a single JOIN query: ♻️ Proposed fix- const expenses = db.prepare(
- "SELECT * FROM expenses WHERE status = 'approved'"
- ).all() as any[];
-
- const summary: Record<string, { name: string; total: number; count: number }> = {};
-
- for (const exp of expenses) {
- if (!summary[exp.user_id]) {
- const user = db.prepare("SELECT name FROM users WHERE id = ?").get(exp.user_id) as any;
- summary[exp.user_id] = { name: user.name, total: 0, count: 0 };
- }
- summary[exp.user_id].total += exp.amount;
- summary[exp.user_id].count += 1;
- }
-
- res.json(Object.values(summary));
+ const rows = db.prepare(`
+ SELECT u.name, SUM(e.amount) AS total, COUNT(*) AS count
+ FROM expenses e
+ JOIN users u ON u.id = e.user_id
+ WHERE e.status = 'approved'
+ GROUP BY e.user_id, u.name
+ `).all() as { name: string; total: number; count: number }[];
+
+ res.json(rows);🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| res.json(Object.values(summary)); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+27
to
+43
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Missing authentication - anyone can access team summary without being logged in or having admin privileges |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+27
to
+44
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The comment says "admin only" but Compare with 🛡️ Proposed fix-router.get("/team-summary", (req: Request, res: Response) => {
+router.get("/team-summary", authenticate, (req: AuthRequest, res: Response) => {
+ if (req.user!.role !== "admin") {
+ return res.status(403).json({ error: "Admin only" });
+ }
+
const expenses = db.prepare(📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Export all expenses | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| router.get("/export", authenticate, (req: AuthRequest, res: Response) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (req.user!.role !== "admin") { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return res.status(403).json({ error: "Admin only" }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const allExpenses = db.prepare("SELECT * FROM expenses").all(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| res.json(allExpenses); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export default router; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| export function isValidEmail(email: string): boolean { | ||
| return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); | ||
| } | ||
|
|
||
| export function isPositiveNumber(value: unknown): value is number { | ||
| return typeof value === "number" && value > 0; | ||
| } | ||
|
|
||
| export function sanitizeString(input: string): string { | ||
| return input.trim().slice(0, 500); | ||
| } | ||
|
|
||
| export const CATEGORIES = [ | ||
| "travel", | ||
| "meals", | ||
| "supplies", | ||
| "software", | ||
| "equipment", | ||
| "other", | ||
| ] as const; | ||
|
|
||
| export const BUDGET_LIMIT = 5000; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: reeder32/teamexpense
Length of output: 271
🏁 Script executed:
Repository: reeder32/teamexpense
Length of output: 336
🏁 Script executed:
Repository: reeder32/teamexpense
Length of output: 629
Add
.db-shmand.db-walpatterns to.gitignore.The root
.gitignorealready excludes*.db, but SQLite's WAL (Write-Ahead Logging) support—enabled on line 6 (db.pragma("journal_mode = WAL"))—creates.db-shmand.db-walfiles that are currently tracked in git (backend/teamexpense.db-shmandbackend/teamexpense.db-wal). These are environment-specific database state files and should be excluded. Update.gitignoreto also exclude*.db-shmand*.db-wal.🤖 Prompt for AI Agents