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
+43 -20
View File
@@ -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} />
+71 -33
View File
@@ -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&apos;t be saved retrying
</span>
) : hasUnsaved ? (
<span className="text-sm text-text-dim animate-pulse">Saving</span>
) : null}
<button
onClick={exportToDrive}
disabled={saving}