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 });
|
||||
}
|
||||
|
||||
@@ -2,21 +2,31 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Search, ChevronDown, ChevronUp, ExternalLink, FileText, User, Users, Calendar, Settings, ListChecks, Save, ShieldCheck, ShieldOff } from "lucide-react";
|
||||
import type { AdminUserDTO, ReportDTO, ReportsPageDTO } from "@/types/api";
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
export default function AdminDashboard() {
|
||||
const [reports, setReports] = useState<any[]>([]);
|
||||
const [reports, setReports] = useState<ReportDTO[]>([]);
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
const [tab, setTab] = useState<"REPORTS" | "USERS" | "SETTINGS">("REPORTS");
|
||||
const [folderId, setFolderId] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [users, setUsers] = useState<any[]>([]);
|
||||
const [users, setUsers] = useState<AdminUserDTO[]>([]);
|
||||
const [usersLoading, setUsersLoading] = useState(false);
|
||||
const [togglingId, setTogglingId] = useState<string | null>(null);
|
||||
|
||||
// Initial load + server-side search (debounced so we don't query per keystroke)
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => fetchReports(search), search ? 400 : 0);
|
||||
return () => clearTimeout(timer);
|
||||
}, [search]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchReports();
|
||||
fetchSettings();
|
||||
}, []);
|
||||
|
||||
@@ -35,11 +45,12 @@ export default function AdminDashboard() {
|
||||
const saveSetting = async (key: string, value: string) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await fetch("/api/admin/settings", {
|
||||
const res = await fetch("/api/admin/settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ key, value }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
alert("Setting saved successfully!");
|
||||
} catch (error) {
|
||||
alert("Failed to save setting");
|
||||
@@ -52,7 +63,8 @@ export default function AdminDashboard() {
|
||||
setUsersLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/admin/users");
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data: AdminUserDTO[] = await res.json();
|
||||
setUsers(data);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch users");
|
||||
@@ -83,23 +95,25 @@ export default function AdminDashboard() {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchReports = async () => {
|
||||
const fetchReports = async (q: string, cursor?: string) => {
|
||||
if (cursor) setLoadingMore(true);
|
||||
try {
|
||||
const res = await fetch("/api/reports");
|
||||
const data = await res.json();
|
||||
setReports(data);
|
||||
const params = new URLSearchParams({ take: String(PAGE_SIZE) });
|
||||
if (q) params.set("q", q);
|
||||
if (cursor) params.set("cursor", cursor);
|
||||
const res = await fetch(`/api/reports?${params}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data: ReportsPageDTO = await res.json();
|
||||
setReports(prev => (cursor ? [...prev, ...data.reports] : data.reports));
|
||||
setNextCursor(data.nextCursor);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch reports");
|
||||
console.error("Failed to fetch reports", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoadingMore(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredReports = reports.filter(r =>
|
||||
r.user?.name?.toLowerCase().includes(search.toLowerCase()) ||
|
||||
r.managerName?.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
if (loading) return <div className="text-center py-20 animate-pulse">Loading reports...</div>;
|
||||
|
||||
return (
|
||||
@@ -148,7 +162,7 @@ export default function AdminDashboard() {
|
||||
|
||||
{tab === "REPORTS" ? (
|
||||
<div className="grid gap-4">
|
||||
{filteredReports.map((report) => (
|
||||
{reports.map((report) => (
|
||||
<div key={report.id} className="glass-card overflow-hidden transition-all duration-300">
|
||||
<div
|
||||
className="p-4 flex items-center justify-between cursor-pointer hover:bg-white/5"
|
||||
@@ -180,7 +194,7 @@ export default function AdminDashboard() {
|
||||
<div className="grid md:grid-cols-2 gap-8">
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-xs font-bold uppercase text-accent-primary tracking-widest">Planned Tasks</h4>
|
||||
{report.tasks.filter((t: any) => t.type === 'PLANNED').map((task: any) => (
|
||||
{report.tasks.filter((t) => t.type === 'PLANNED').map((task) => (
|
||||
<div key={task.id} className="text-sm border-l-2 border-accent-primary/30 pl-3 py-1">
|
||||
<p className="font-medium">{task.description}</p>
|
||||
<p className="text-xs text-text-dim">Est: {task.timeEstimate} • {task.notes}</p>
|
||||
@@ -189,7 +203,7 @@ export default function AdminDashboard() {
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-xs font-bold uppercase text-accent-secondary tracking-widest">Completed Tasks</h4>
|
||||
{report.tasks.filter((t: any) => t.type === 'COMPLETED').map((task: any) => (
|
||||
{report.tasks.filter((t) => t.type === 'COMPLETED').map((task) => (
|
||||
<div key={task.id} className="text-sm border-l-2 border-accent-secondary/30 pl-3 py-1">
|
||||
<p className="font-medium">{task.description}</p>
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -208,7 +222,16 @@ export default function AdminDashboard() {
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{filteredReports.length === 0 && <p className="text-center py-10 text-text-dim">No reports found matching your criteria.</p>}
|
||||
{reports.length === 0 && <p className="text-center py-10 text-text-dim">No reports found matching your criteria.</p>}
|
||||
{nextCursor && (
|
||||
<button
|
||||
onClick={() => fetchReports(search, nextCursor)}
|
||||
disabled={loadingMore}
|
||||
className="btn-secondary py-2 px-4 text-sm mx-auto disabled:opacity-50"
|
||||
>
|
||||
{loadingMore ? "Loading…" : "Load more reports"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : tab === "USERS" ? (
|
||||
<div className="space-y-4 animate-fade-in">
|
||||
@@ -229,7 +252,7 @@ export default function AdminDashboard() {
|
||||
<div key={user.id} className="glass-card p-4 flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4 min-w-0">
|
||||
{user.image ? (
|
||||
<img src={user.image} alt={user.name} className="h-10 w-10 rounded-full flex-shrink-0" />
|
||||
<img src={user.image} alt={user.name ?? "User avatar"} className="h-10 w-10 rounded-full flex-shrink-0" />
|
||||
) : (
|
||||
<div className="h-10 w-10 rounded-full bg-accent-primary/20 flex items-center justify-center text-accent-primary flex-shrink-0">
|
||||
<User size={20} />
|
||||
|
||||
@@ -2,20 +2,29 @@
|
||||
|
||||
import { useSession, signIn, signOut } from "next-auth/react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Plus, Trash2, Send, Save, CheckCircle, Clock, Calendar, User as UserIcon, Link as LinkIcon, LogOut, ShieldCheck, ClipboardList } from "lucide-react";
|
||||
import { Plus, Trash2, Send, CheckCircle, Clock, Calendar, User as UserIcon, Link as LinkIcon, LogOut, ShieldCheck, ClipboardList, AlertTriangle } from "lucide-react";
|
||||
import AdminDashboard from "./AdminDashboard";
|
||||
import type { ReportDTO, ReportsPageDTO, TaskDTO, TaskStatus, TaskType } from "@/types/api";
|
||||
|
||||
type TaskUpdate = Partial<Pick<TaskDTO, "description" | "timeEstimate" | "notes" | "link" | "status">> & {
|
||||
type: TaskType;
|
||||
};
|
||||
|
||||
const RETRY_DELAY_MS = 5000;
|
||||
|
||||
export default function ReportForm() {
|
||||
const { data: session, status } = useSession();
|
||||
const [report, setReport] = useState<any>(null);
|
||||
const [report, setReport] = useState<ReportDTO | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [managerName, setManagerName] = useState("");
|
||||
const [plannedTasks, setPlannedTasks] = useState<any[]>([]);
|
||||
const [completedTasks, setCompletedTasks] = useState<any[]>([]);
|
||||
const [plannedTasks, setPlannedTasks] = useState<TaskDTO[]>([]);
|
||||
const [completedTasks, setCompletedTasks] = useState<TaskDTO[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveFailed, setSaveFailed] = useState(false);
|
||||
const [hasUnsaved, setHasUnsaved] = useState(false);
|
||||
const [view, setView] = useState<"REPORT" | "ADMIN">("REPORT");
|
||||
const debounceTimers = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
|
||||
const pendingUpdates = useRef<Record<string, any>>({});
|
||||
const pendingUpdates = useRef<Record<string, TaskUpdate>>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "authenticated") {
|
||||
@@ -31,16 +40,18 @@ export default function ReportForm() {
|
||||
|
||||
const fetchReport = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/reports");
|
||||
const data = await res.json();
|
||||
const today = getCentralToday();
|
||||
const todayReport = data.find((r: any) => r.date.split('T')[0] === today);
|
||||
|
||||
// Ask the server for just the caller's own report for today,
|
||||
// instead of the full history (mine=1 matters for admins)
|
||||
const res = await fetch(`/api/reports?date=${getCentralToday()}&mine=1`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data: ReportsPageDTO = await res.json();
|
||||
const todayReport = data.reports[0];
|
||||
|
||||
if (todayReport) {
|
||||
setReport(todayReport);
|
||||
setManagerName(todayReport.managerName);
|
||||
setPlannedTasks(todayReport.tasks.filter((t: any) => t.type === "PLANNED"));
|
||||
setCompletedTasks(todayReport.tasks.filter((t: any) => t.type === "COMPLETED"));
|
||||
setPlannedTasks(todayReport.tasks.filter((t) => t.type === "PLANNED"));
|
||||
setCompletedTasks(todayReport.tasks.filter((t) => t.type === "COMPLETED"));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch report", error);
|
||||
@@ -57,7 +68,8 @@ export default function ReportForm() {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ managerName, date: getCentralToday() }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data: ReportDTO = await res.json();
|
||||
setReport(data);
|
||||
} catch (error) {
|
||||
alert("Failed to start report");
|
||||
@@ -83,7 +95,8 @@ export default function ReportForm() {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(newTask),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data: TaskDTO = await res.json();
|
||||
if (type === "PLANNED") setPlannedTasks([...plannedTasks, data]);
|
||||
else setCompletedTasks([...completedTasks, data]);
|
||||
} catch (error) {
|
||||
@@ -91,7 +104,36 @@ export default function ReportForm() {
|
||||
}
|
||||
};
|
||||
|
||||
const updateTask = (id: string, updates: any) => {
|
||||
// Send a task's accumulated changes to the server. On failure the payload
|
||||
// is re-queued (newer keystrokes win) and retried, so edits are never
|
||||
// silently dropped.
|
||||
const flushTask = async (id: string) => {
|
||||
const payload = pendingUpdates.current[id];
|
||||
delete pendingUpdates.current[id];
|
||||
delete debounceTimers.current[id];
|
||||
if (!payload) return;
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/tasks", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id, ...payload }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
setSaveFailed(false);
|
||||
if (Object.keys(pendingUpdates.current).length === 0) setHasUnsaved(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to update task, will retry", error);
|
||||
pendingUpdates.current[id] = { ...payload, ...pendingUpdates.current[id] };
|
||||
setSaveFailed(true);
|
||||
// Retry unless a newer edit already rescheduled this task's timer
|
||||
if (!debounceTimers.current[id]) {
|
||||
debounceTimers.current[id] = setTimeout(() => flushTask(id), RETRY_DELAY_MS);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const updateTask = (id: string, updates: TaskUpdate) => {
|
||||
// Update local state immediately so the UI stays responsive
|
||||
if (updates.type === 'PLANNED') {
|
||||
setPlannedTasks(prev => prev.map(t => t.id === id ? { ...t, ...updates } : t));
|
||||
@@ -101,28 +143,17 @@ export default function ReportForm() {
|
||||
|
||||
// Accumulate all field changes for this task so a single request carries everything
|
||||
pendingUpdates.current[id] = { ...pendingUpdates.current[id], ...updates };
|
||||
setHasUnsaved(true);
|
||||
|
||||
// Reset the debounce timer — the API call fires 600 ms after the last keystroke
|
||||
clearTimeout(debounceTimers.current[id]);
|
||||
debounceTimers.current[id] = setTimeout(async () => {
|
||||
const payload = pendingUpdates.current[id];
|
||||
delete pendingUpdates.current[id];
|
||||
delete debounceTimers.current[id];
|
||||
try {
|
||||
await fetch("/api/tasks", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id, ...payload }),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to update task");
|
||||
}
|
||||
}, 600);
|
||||
debounceTimers.current[id] = setTimeout(() => flushTask(id), 600);
|
||||
};
|
||||
|
||||
const deleteTask = async (id: string, type: string) => {
|
||||
const deleteTask = async (id: string, type: TaskType) => {
|
||||
try {
|
||||
await fetch(`/api/tasks?id=${id}`, { method: "DELETE" });
|
||||
const res = await fetch(`/api/tasks?id=${id}`, { method: "DELETE" });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
if (type === "PLANNED") setPlannedTasks(plannedTasks.filter(t => t.id !== id));
|
||||
else setCompletedTasks(completedTasks.filter(t => t.id !== id));
|
||||
} catch (error) {
|
||||
@@ -303,7 +334,7 @@ export default function ReportForm() {
|
||||
<div className="flex gap-4">
|
||||
<select
|
||||
value={task.status || "DONE"}
|
||||
onChange={(e) => updateTask(task.id, { status: e.target.value, type: 'COMPLETED' })}
|
||||
onChange={(e) => updateTask(task.id, { status: e.target.value as TaskStatus, type: 'COMPLETED' })}
|
||||
className="bg-transparent text-sm text-text-dim border-none p-0 focus:ring-0 outline-none appearance-none cursor-pointer"
|
||||
>
|
||||
<option value="DONE" className="bg-slate-800">Completed</option>
|
||||
@@ -331,7 +362,14 @@ export default function ReportForm() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer className="pt-8 border-t border-white/10 flex justify-end gap-4">
|
||||
<footer className="pt-8 border-t border-white/10 flex items-center justify-end gap-4">
|
||||
{saveFailed ? (
|
||||
<span className="text-sm text-red-400 flex items-center gap-2">
|
||||
<AlertTriangle size={16} /> Some changes couldn't be saved — retrying…
|
||||
</span>
|
||||
) : hasUnsaved ? (
|
||||
<span className="text-sm text-text-dim animate-pulse">Saving…</span>
|
||||
) : null}
|
||||
<button
|
||||
onClick={exportToDrive}
|
||||
disabled={saving}
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ export const authOptions: NextAuthOptions = {
|
||||
async session({ session, user }) {
|
||||
if (session?.user && user) {
|
||||
session.user.id = user.id;
|
||||
session.user.role = (user as any).role || 'EMPLOYEE';
|
||||
session.user.role = user.role || 'EMPLOYEE';
|
||||
}
|
||||
return session;
|
||||
},
|
||||
|
||||
+41
-18
@@ -1,6 +1,11 @@
|
||||
import { google } from 'googleapis';
|
||||
import { google, Auth } from 'googleapis';
|
||||
import { Readable } from 'stream';
|
||||
import { prisma } from './prisma';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
|
||||
type ReportWithRelations = Prisma.ReportGetPayload<{
|
||||
include: { tasks: true; user: true };
|
||||
}>;
|
||||
|
||||
export async function getGoogleAuth(userId: string) {
|
||||
const account = await prisma.account.findFirst({
|
||||
@@ -50,10 +55,10 @@ export async function getGoogleAuth(userId: string) {
|
||||
return auth;
|
||||
}
|
||||
|
||||
export async function uploadToDrive(auth: any, fileName: string, content: string, folderId?: string) {
|
||||
export async function uploadToDrive(auth: Auth.OAuth2Client, fileName: string, content: string, folderId?: string) {
|
||||
const drive = google.drive({ version: 'v3', auth });
|
||||
|
||||
const fileMetadata: any = {
|
||||
const fileMetadata: { name: string; mimeType: string; parents?: string[] } = {
|
||||
name: fileName,
|
||||
mimeType: 'application/vnd.google-apps.document', // Automatically convert to Google Doc
|
||||
};
|
||||
@@ -80,7 +85,7 @@ export async function uploadToDrive(auth: any, fileName: string, content: string
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateDriveFile(auth: any, fileId: string, content: string) {
|
||||
export async function updateDriveFile(auth: Auth.OAuth2Client, fileId: string, content: string) {
|
||||
const drive = google.drive({ version: 'v3', auth });
|
||||
|
||||
const media = {
|
||||
@@ -101,11 +106,26 @@ export async function updateDriveFile(auth: any, fileId: string, content: string
|
||||
}
|
||||
}
|
||||
|
||||
export function generateReportHTML(report: any) {
|
||||
function escapeHtml(value: unknown): string {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// Only allow plain http(s) URLs into href attributes
|
||||
function safeLink(link: unknown): string | null {
|
||||
const url = String(link ?? '').trim();
|
||||
return /^https?:\/\//i.test(url) ? url : null;
|
||||
}
|
||||
|
||||
export function generateReportHTML(report: ReportWithRelations) {
|
||||
const dateObj = new Date(report.date);
|
||||
const dateStr = dateObj.toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
|
||||
const plannedTasks = report.tasks.filter((t: any) => t.type === 'PLANNED');
|
||||
const completedTasks = report.tasks.filter((t: any) => t.type === 'COMPLETED');
|
||||
const plannedTasks = report.tasks.filter((t) => t.type === 'PLANNED');
|
||||
const completedTasks = report.tasks.filter((t) => t.type === 'COMPLETED');
|
||||
|
||||
const cellStyle = "padding: 10px; border-bottom: 1px solid #e2e8f0; font-family: Arial, sans-serif; font-size: 11pt;";
|
||||
const headerStyle = "padding: 12px 10px; background-color: #f1f5f9; border-bottom: 2px solid #cbd5e1; font-family: Arial, sans-serif; font-size: 11pt; font-weight: bold; text-align: left; color: #334155;";
|
||||
@@ -117,8 +137,8 @@ export function generateReportHTML(report: any) {
|
||||
|
||||
<div style="background-color: #f8fafc; padding: 20px; border-left: 4px solid #3b82f6; border-radius: 4px; margin-bottom: 30px; font-family: Arial, sans-serif;">
|
||||
<p style="margin: 0 0 8px 0; font-size: 11pt;"><strong>Date:</strong> ${dateStr}</p>
|
||||
<p style="margin: 0 0 8px 0; font-size: 11pt;"><strong>Employee:</strong> ${report.user.name}</p>
|
||||
<p style="margin: 0; font-size: 11pt;"><strong>Manager:</strong> ${report.managerName || 'N/A'}</p>
|
||||
<p style="margin: 0 0 8px 0; font-size: 11pt;"><strong>Employee:</strong> ${escapeHtml(report.user.name)}</p>
|
||||
<p style="margin: 0; font-size: 11pt;"><strong>Manager:</strong> ${escapeHtml(report.managerName || 'N/A')}</p>
|
||||
</div>
|
||||
|
||||
<h2 style="color: #1e293b; margin-top: 30px; margin-bottom: 15px; font-family: Arial, sans-serif;">Planned Tasks</h2>
|
||||
@@ -129,11 +149,11 @@ export function generateReportHTML(report: any) {
|
||||
<th style="${headerStyle} width: 20%;">Estimate</th>
|
||||
<th style="${headerStyle} width: 35%;">Notes</th>
|
||||
</tr>
|
||||
${plannedTasks.map((t: any) => `
|
||||
${plannedTasks.map((t) => `
|
||||
<tr>
|
||||
<td style="${cellStyle}">${t.description}</td>
|
||||
<td style="${cellStyle} color: #64748b;">${t.timeEstimate || '-'}</td>
|
||||
<td style="${cellStyle} color: #64748b;">${t.notes || '-'}</td>
|
||||
<td style="${cellStyle}">${escapeHtml(t.description)}</td>
|
||||
<td style="${cellStyle} color: #64748b;">${escapeHtml(t.timeEstimate || '-')}</td>
|
||||
<td style="${cellStyle} color: #64748b;">${escapeHtml(t.notes || '-')}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</table>
|
||||
@@ -147,13 +167,16 @@ export function generateReportHTML(report: any) {
|
||||
<th style="${headerStyle} width: 20%;">Status</th>
|
||||
<th style="${headerStyle} width: 40%;">Work Link</th>
|
||||
</tr>
|
||||
${completedTasks.map((t: any) => `
|
||||
${completedTasks.map((t) => {
|
||||
const link = safeLink(t.link);
|
||||
return `
|
||||
<tr>
|
||||
<td style="${cellStyle}">${t.description}</td>
|
||||
<td style="${cellStyle} font-weight: bold; color: #059669;">${t.status || 'Done'}</td>
|
||||
<td style="${cellStyle}">${t.link ? `<a href="${t.link}" style="color: #2563eb; text-decoration: none;">${t.link}</a>` : '-'}</td>
|
||||
<td style="${cellStyle}">${escapeHtml(t.description)}</td>
|
||||
<td style="${cellStyle} font-weight: bold; color: #059669;">${escapeHtml(t.status || 'Done')}</td>
|
||||
<td style="${cellStyle}">${link ? `<a href="${escapeHtml(link)}" style="color: #2563eb; text-decoration: none;">${escapeHtml(link)}</a>` : '-'}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
`;
|
||||
}).join('')}
|
||||
</table>
|
||||
` : `<p style="font-style: italic; color: #94a3b8; font-family: Arial, sans-serif; margin-bottom: 30px;">No completed tasks reported today.</p>`}
|
||||
|
||||
|
||||
+1
-1
@@ -25,6 +25,6 @@ export const prisma = new Proxy({} as PrismaClient, {
|
||||
return Reflect.get(getPrismaClient(), prop)
|
||||
},
|
||||
apply(_target, thisArg, args) {
|
||||
return Reflect.apply(getPrismaClient() as unknown as Function, thisArg, args)
|
||||
return Reflect.apply(getPrismaClient() as unknown as (...a: unknown[]) => unknown, thisArg, args)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { z } from "zod";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export const TASK_STATUSES = ["PENDING", "DONE", "IN_PROGRESS", "CANCELLED"] as const;
|
||||
export const REPORT_STATUSES = ["IN_PROGRESS", "COMPLETED", "SUBMITTED"] as const;
|
||||
|
||||
const httpLink = z
|
||||
.string()
|
||||
.trim()
|
||||
.max(2000)
|
||||
.refine((v) => v === "" || /^https?:\/\//i.test(v), {
|
||||
message: "Link must start with http:// or https://",
|
||||
});
|
||||
|
||||
export const createReportSchema = z.object({
|
||||
managerName: z.string().trim().min(1).max(200),
|
||||
date: z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be YYYY-MM-DD")
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const updateReportSchema = z.object({
|
||||
managerName: z.string().trim().min(1).max(200).optional(),
|
||||
status: z.enum(REPORT_STATUSES).optional(),
|
||||
});
|
||||
|
||||
export const createTaskSchema = z.object({
|
||||
reportId: z.string().min(1),
|
||||
type: z.enum(["PLANNED", "COMPLETED"]),
|
||||
description: z.string().max(2000).default(""),
|
||||
timeEstimate: z.string().max(100).nullish(),
|
||||
notes: z.string().max(2000).nullish(),
|
||||
link: httpLink.nullish(),
|
||||
status: z.enum(TASK_STATUSES).nullish(),
|
||||
});
|
||||
|
||||
export const updateTaskSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
type: z.enum(["PLANNED", "COMPLETED"]).optional(),
|
||||
description: z.string().max(2000).optional(),
|
||||
timeEstimate: z.string().max(100).nullish(),
|
||||
notes: z.string().max(2000).nullish(),
|
||||
link: httpLink.nullish(),
|
||||
status: z.enum(TASK_STATUSES).nullish(),
|
||||
});
|
||||
|
||||
export const updateSettingSchema = z.object({
|
||||
key: z.enum(["GOOGLE_DRIVE_FOLDER_ID"]),
|
||||
value: z.string().trim().max(500),
|
||||
});
|
||||
|
||||
export const updateUserRoleSchema = z.object({
|
||||
userId: z.string().min(1),
|
||||
role: z.enum(["EMPLOYEE", "ADMIN"]),
|
||||
});
|
||||
|
||||
/**
|
||||
* Parse and validate a request body. Returns the parsed data, or a
|
||||
* NextResponse 400 that the route should return immediately.
|
||||
*/
|
||||
export async function parseBody<T extends z.ZodTypeAny>(
|
||||
req: Request,
|
||||
schema: T
|
||||
): Promise<{ data: z.infer<T>; error: null } | { data: null; error: NextResponse }> {
|
||||
let json: unknown;
|
||||
try {
|
||||
json = await req.json();
|
||||
} catch {
|
||||
return {
|
||||
data: null,
|
||||
error: NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }),
|
||||
};
|
||||
}
|
||||
|
||||
const result = schema.safeParse(json);
|
||||
if (!result.success) {
|
||||
return {
|
||||
data: null,
|
||||
error: NextResponse.json(
|
||||
{ error: "Validation failed", details: result.error.flatten().fieldErrors },
|
||||
{ status: 400 }
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return { data: result.data, error: null };
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// Serialized shapes of API responses (Dates arrive as ISO strings over JSON).
|
||||
|
||||
export type TaskType = "PLANNED" | "COMPLETED";
|
||||
export type TaskStatus = "PENDING" | "DONE" | "IN_PROGRESS" | "CANCELLED";
|
||||
export type ReportStatus = "IN_PROGRESS" | "COMPLETED" | "SUBMITTED";
|
||||
export type UserRole = "EMPLOYEE" | "ADMIN";
|
||||
|
||||
export interface TaskDTO {
|
||||
id: string;
|
||||
type: TaskType;
|
||||
description: string;
|
||||
timeEstimate: string | null;
|
||||
notes: string | null;
|
||||
status: TaskStatus | null;
|
||||
link: string | null;
|
||||
reportId: string;
|
||||
}
|
||||
|
||||
export interface ReportUserDTO {
|
||||
id: string;
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
image: string | null;
|
||||
}
|
||||
|
||||
export interface ReportDTO {
|
||||
id: string;
|
||||
date: string;
|
||||
managerName: string;
|
||||
status: ReportStatus;
|
||||
driveFileId: string | null;
|
||||
userId: string;
|
||||
tasks: TaskDTO[];
|
||||
user?: ReportUserDTO;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ReportsPageDTO {
|
||||
reports: ReportDTO[];
|
||||
nextCursor: string | null;
|
||||
}
|
||||
|
||||
export interface AdminUserDTO {
|
||||
id: string;
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
image: string | null;
|
||||
role: UserRole;
|
||||
reports: { date: string; status: ReportStatus }[];
|
||||
}
|
||||
Reference in New Issue
Block a user