Harden API, data layer, and autosave; add validation, pagination, migrations
Build and Push Docker Image / build (push) Successful in 2m26s

- Enforce task ownership on PATCH/DELETE /api/tasks (was: any signed-in
  user could edit or delete anyone's tasks)
- Validate all API request bodies with zod; escape user content and
  restrict links to http(s) in the Drive export; block reverting a
  SUBMITTED report
- Add @@unique([userId, date]) on Report with upsert to eliminate the
  duplicate-daily-report race; switch startup from
  `prisma db push --accept-data-loss` to `prisma migrate deploy` with
  automatic baselining of existing databases (dedup migration merges
  any pre-existing duplicates)
- Autosave: re-queue and retry failed task saves with a visible
  saving/error indicator instead of silently dropping edits
- Paginate and filter GET /api/reports (?date, ?mine, ?q, ?take,
  ?cursor); report form fetches only today's report, admin dashboard
  uses server-side search + Load more
- Type the frontend and lib layer (DTOs in src/types/api.ts); zero
  eslint errors
- Update README and Unraid guide for migrations, upgrade path, and API

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 19:43:36 -05:00
parent d8efa71992
commit a9df9c0cf4
22 changed files with 636 additions and 148 deletions
+5 -2
View File
@@ -6,6 +6,7 @@ export const runtime = "nodejs";
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { updateSettingSchema, parseBody } from "@/lib/validation";
// GET /api/admin/settings - Fetch global settings
export async function GET() {
@@ -16,7 +17,7 @@ export async function GET() {
}
const settings = await prisma.setting.findMany();
const settingsMap = settings.reduce((acc: any, curr: any) => ({ ...acc, [curr.key]: curr.value }), {});
const settingsMap = Object.fromEntries(settings.map((s) => [s.key, s.value]));
return NextResponse.json(settingsMap);
}
@@ -29,7 +30,9 @@ export async function POST(req: Request) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { key, value } = await req.json();
const { data, error } = await parseBody(req, updateSettingSchema);
if (error) return error;
const { key, value } = data;
const setting = await prisma.setting.upsert({
where: { key },
+4 -5
View File
@@ -5,6 +5,7 @@ export const runtime = "nodejs";
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { updateUserRoleSchema, parseBody } from "@/lib/validation";
// GET /api/admin/users - List all users
export async function GET() {
@@ -41,11 +42,9 @@ export async function PATCH(req: Request) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { userId, role } = await req.json();
if (!userId || !["EMPLOYEE", "ADMIN"].includes(role)) {
return NextResponse.json({ error: "Invalid request" }, { status: 400 });
}
const { data, error } = await parseBody(req, updateUserRoleSchema);
if (error) return error;
const { userId, role } = data;
// Prevent admins from demoting themselves
if (userId === session.user.id && role === "EMPLOYEE") {
+22 -3
View File
@@ -6,6 +6,7 @@ export const runtime = "nodejs";
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { updateReportSchema, parseBody } from "@/lib/validation";
// PATCH /api/reports/[id] - Update report status or manager
export async function PATCH(
@@ -19,11 +20,29 @@ export async function PATCH(
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { status, managerName } = await req.json();
const { data, error } = await parseBody(req, updateReportSchema);
if (error) return error;
const existing = await prisma.report.findFirst({
where: { id, userId: session.user.id },
select: { status: true },
});
if (!existing) {
return NextResponse.json({ error: "Not Found" }, { status: 404 });
}
// A submitted report is final: it can only be re-submitted, not walked back.
if (existing.status === "SUBMITTED" && data.status && data.status !== "SUBMITTED") {
return NextResponse.json(
{ error: "A submitted report cannot be reverted" },
{ status: 409 }
);
}
const report = await prisma.report.update({
where: { id, userId: session.user.id },
data: { status, managerName },
where: { id },
data: { status: data.status, managerName: data.managerName },
});
return NextResponse.json(report);
+68 -24
View File
@@ -2,28 +2,76 @@ import { NextResponse } from "next/server";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { createReportSchema, parseBody } from "@/lib/validation";
// GET /api/reports - Fetch reports for the logged-in user (or all for admin)
export async function GET() {
const MAX_PAGE_SIZE = 100;
const DEFAULT_PAGE_SIZE = 50;
// GET /api/reports - Fetch reports for the logged-in user (or all for admin).
// Query params:
// date - YYYY-MM-DD, return only that day's report(s)
// mine - "1" to return only the caller's own reports (even for admins)
// q - filter by employee or manager name (admin listing)
// take - page size (default 50, max 100)
// cursor - report id to continue after (from previous page's nextCursor)
// Returns { reports, nextCursor }.
export async function GET(req: Request) {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const where = session.user.role === "ADMIN" ? {} : { userId: session.user.id };
const { searchParams } = new URL(req.url);
const date = searchParams.get("date");
const q = searchParams.get("q")?.trim();
const cursor = searchParams.get("cursor");
const take = Math.min(
Math.max(parseInt(searchParams.get("take") || "", 10) || DEFAULT_PAGE_SIZE, 1),
MAX_PAGE_SIZE
);
const where: Record<string, unknown> = {};
if (session.user.role !== "ADMIN" || searchParams.get("mine") === "1") {
where.userId = session.user.id;
}
if (date) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
return NextResponse.json({ error: "date must be YYYY-MM-DD" }, { status: 400 });
}
where.date = new Date(`${date}T00:00:00.000Z`);
}
if (q) {
where.OR = [
{ user: { name: { contains: q } } },
{ managerName: { contains: q } },
];
}
const reports = await prisma.report.findMany({
where,
include: { tasks: true, user: true },
orderBy: { date: "desc" },
include: {
tasks: true,
user: { select: { id: true, name: true, email: true, image: true } },
},
orderBy: [{ date: "desc" }, { id: "desc" }],
take: take + 1, // fetch one extra to know if there is a next page
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
});
return NextResponse.json(reports);
const hasMore = reports.length > take;
const page = hasMore ? reports.slice(0, take) : reports;
return NextResponse.json({
reports: page,
nextCursor: hasMore ? page[page.length - 1].id : null,
});
}
// POST /api/reports - Create or resume a report
@@ -34,33 +82,29 @@ export async function POST(req: Request) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await req.json();
const { managerName, date } = body;
const { data, error } = await parseBody(req, createReportSchema);
if (error) return error;
// Check if a report already exists for this date and user.
// Client always sends a YYYY-MM-DD date string in Central US time;
// we store it as UTC midnight so the date string is stable across timezones.
const reportDate = date
? new Date(`${date}T00:00:00.000Z`)
const reportDate = data.date
? new Date(`${data.date}T00:00:00.000Z`)
: new Date(new Date().toLocaleDateString('en-CA', { timeZone: 'America/Chicago' }) + 'T00:00:00.000Z');
let report = await prisma.report.findFirst({
// Upsert against the (userId, date) unique constraint so concurrent
// requests can never create two reports for the same day.
const report = await prisma.report.upsert({
where: {
userId_date: { userId: session.user.id, date: reportDate },
},
update: {},
create: {
userId: session.user.id,
managerName: data.managerName,
date: reportDate,
},
include: { tasks: true },
});
if (!report) {
report = await prisma.report.create({
data: {
userId: session.user.id,
managerName,
date: reportDate,
},
include: { tasks: true },
});
}
return NextResponse.json(report);
}
+53 -30
View File
@@ -5,6 +5,7 @@ export const runtime = "nodejs";
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { createTaskSchema, updateTaskSchema, parseBody } from "@/lib/validation";
// POST /api/tasks - Add a task to a report
export async function POST(req: Request) {
@@ -14,11 +15,12 @@ export async function POST(req: Request) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { reportId, type, description, timeEstimate, notes, link, status } = await req.json();
const { data, error } = await parseBody(req, createTaskSchema);
if (error) return error;
// Verify ownership
const report = await prisma.report.findUnique({
where: { id: reportId, userId: session.user.id },
const report = await prisma.report.findFirst({
where: { id: data.reportId, userId: session.user.id },
});
if (!report) {
@@ -27,53 +29,74 @@ export async function POST(req: Request) {
const task = await prisma.task.create({
data: {
reportId,
type,
description,
timeEstimate,
notes,
link,
status: status || "PENDING",
reportId: data.reportId,
type: data.type,
description: data.description,
timeEstimate: data.timeEstimate,
notes: data.notes,
link: data.link,
status: data.status || "PENDING",
},
});
return NextResponse.json(task);
}
// PATCH /api/tasks/[id] - Update a task
// PATCH /api/tasks - Update a task (only on the caller's own report)
export async function PATCH(req: Request) {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id, type, description, timeEstimate, notes, link, status } = await req.json();
const { data, error } = await parseBody(req, updateTaskSchema);
if (error) return error;
const { id, ...updates } = data;
const existing = await prisma.task.findFirst({
where: { id, report: { userId: session.user.id } },
select: { id: true },
});
if (!existing) {
return NextResponse.json({ error: "Task not found" }, { status: 404 });
}
const task = await prisma.task.update({
where: { id },
data: { type, description, timeEstimate, notes, link, status },
data: updates,
});
return NextResponse.json(task);
}
// DELETE /api/tasks/[id] - Delete a task
// DELETE /api/tasks?id= - Delete a task (only on the caller's own report)
export async function DELETE(req: Request) {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { searchParams } = new URL(req.url);
const id = searchParams.get("id");
if (!id) return NextResponse.json({ error: "ID required" }, { status: 400 });
const session = await getServerSession(authOptions);
await prisma.task.delete({
where: { id },
});
return NextResponse.json({ success: true });
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { searchParams } = new URL(req.url);
const id = searchParams.get("id");
if (!id) return NextResponse.json({ error: "ID required" }, { status: 400 });
const existing = await prisma.task.findFirst({
where: { id, report: { userId: session.user.id } },
select: { id: true },
});
if (!existing) {
return NextResponse.json({ error: "Task not found" }, { status: 404 });
}
await prisma.task.delete({
where: { id },
});
return NextResponse.json({ success: true });
}