Harden API, data layer, and autosave; add validation, pagination, migrations
Build and Push Docker Image / build (push) Successful in 2m26s
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:
@@ -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 },
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user