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
+3
View File
@@ -0,0 +1,3 @@
# Shell scripts must stay LF or /bin/sh in the container fails on \r
*.sh text eol=lf
prisma/migrations/**/*.sql text eol=lf
+31 -2
View File
@@ -9,9 +9,10 @@ A sleek, modern, and dockerized web application for employees to track and submi
- **Multi-Step Reporting**: - **Multi-Step Reporting**:
- **Morning**: Log planned tasks, time estimates, and initial notes. - **Morning**: Log planned tasks, time estimates, and initial notes.
- **Evening**: Review achievements, update statuses, and submit links to completed work. - **Evening**: Review achievements, update statuses, and submit links to completed work.
- **Reliable Autosave**: Task edits are debounced and saved automatically; failed saves are retried and surfaced in the UI so no edit is ever silently lost.
- **Smart Admin Logic**: - **Smart Admin Logic**:
- The first user to log in is automatically granted the **ADMIN** role. - The first user to log in is automatically granted the **ADMIN** role.
- Exclusive **Admin Panel** to search and review all employee reports. - Exclusive **Admin Panel** with server-side search and paginated report browsing, user role management, and settings.
- **Google Drive Integration**: - **Google Drive Integration**:
- Automatically exports completed reports as Google Docs. - Automatically exports completed reports as Google Docs.
- Admins can designate a specific folder for all exports. - Admins can designate a specific folder for all exports.
@@ -41,12 +42,40 @@ docker run -p 3000:3000 \
wfh-report wfh-report
``` ```
## 🗄️ Database & Migrations
The schema is managed with **Prisma Migrate**. On every container start, `entrypoint.sh` runs `prisma migrate deploy`, which applies any pending migrations from `prisma/migrations/` — additive and safe, unlike the old `db push --accept-data-loss` approach.
- **Fresh installs**: all migrations are applied automatically to the new database.
- **Upgrading an existing deployment** (created before migrations were introduced): the first boot logs a one-time `P3005` error, then automatically *baselines* the database (marks `0_init` as applied) and applies the remaining migrations. This is expected — no manual action needed.
- Reports are unique per user per day (`@@unique([userId, date])`); the migration merges any pre-existing duplicate reports, keeping the earliest and moving its tasks.
To add a schema change during development:
```bash
npx prisma migrate dev --name describe_your_change
```
## 🔌 API Overview
All endpoints require a session; admin endpoints require the `ADMIN` role. Request bodies are validated with zod (invalid input returns `400` with field details).
| Endpoint | Notes |
| :--- | :--- |
| `GET /api/reports` | Returns `{ reports, nextCursor }`. Query params: `date` (YYYY-MM-DD), `mine=1` (own reports only, matters for admins), `q` (search by employee/manager, admin), `take` (page size, max 100), `cursor` (pagination). |
| `POST /api/reports` | Creates or resumes the day's report (upsert — safe against double-submits). |
| `GET/PATCH /api/reports/[id]` | Owner (or admin for GET) only. A `SUBMITTED` report cannot be reverted. |
| `POST /api/reports/[id]/export` | Exports/re-exports the report to Google Drive. User content is HTML-escaped; only `http(s)` links are rendered. |
| `POST/PATCH/DELETE /api/tasks` | Scoped to tasks on the caller's own reports. |
| `GET/POST /api/admin/settings` | Admin only. Allowed keys: `GOOGLE_DRIVE_FOLDER_ID`. |
| `GET/PATCH /api/admin/users` | Admin only. Role management; self-demotion is blocked. |
## 🏡 Unraid Installation ## 🏡 Unraid Installation
For specific instructions on installing this on Unraid (including volume mapping and Unraid UI configuration), please refer to our [Unraid Installation Guide](https://git.alwisp.com/jason/wfh/src/branch/master/unraid_install.md). For specific instructions on installing this on Unraid (including volume mapping and Unraid UI configuration), please refer to our [Unraid Installation Guide](https://git.alwisp.com/jason/wfh/src/branch/master/unraid_install.md).
## 🛠️ Tech Stack ## 🛠️ Tech Stack
- **Framework**: [Next.js](https://nextjs.org/) (App Router) - **Framework**: [Next.js](https://nextjs.org/) (App Router)
- **Database**: [SQLite](https://sqlite.org/) via [Prisma ORM](https://www.prisma.io/) - **Database**: [SQLite](https://sqlite.org/) via [Prisma ORM](https://www.prisma.io/) (Prisma Migrate for schema management)
- **Auth**: [NextAuth.js](https://next-auth.js.org/) - **Auth**: [NextAuth.js](https://next-auth.js.org/)
- **Validation**: [Zod](https://zod.dev/) on all API request bodies
- **Styles**: Vanilla CSS & TailwindCSS (for utility) - **Styles**: Vanilla CSS & TailwindCSS (for utility)
- **Icons**: [Lucide React](https://lucide.dev/) - **Icons**: [Lucide React](https://lucide.dev/)
+11 -2
View File
@@ -1,6 +1,15 @@
#!/bin/sh #!/bin/sh
set -e set -e
echo "Running prisma db push..."
npx prisma db push --accept-data-loss echo "Applying database migrations..."
if ! npx prisma migrate deploy; then
# An existing database from the old `db push` days has tables but no
# migration history (P3005). Baseline it: mark the init migration as
# already applied, then deploy the rest.
echo "Migrate deploy failed - attempting to baseline existing database..."
npx prisma migrate resolve --applied 0_init
npx prisma migrate deploy
fi
echo "Starting server..." echo "Starting server..."
node server.js node server.js
+5 -5
View File
@@ -17,7 +17,8 @@
"next-auth": "^4.24.13", "next-auth": "^4.24.13",
"prisma": "^7.5.0", "prisma": "^7.5.0",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3" "react-dom": "19.2.3",
"zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
@@ -8563,10 +8564,9 @@
} }
}, },
"node_modules/zod": { "node_modules/zod": {
"version": "4.3.6", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
+2 -1
View File
@@ -18,7 +18,8 @@
"next-auth": "^4.24.13", "next-auth": "^4.24.13",
"prisma": "^7.5.0", "prisma": "^7.5.0",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3" "react-dom": "19.2.3",
"zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
+94
View File
@@ -0,0 +1,94 @@
-- CreateTable
CREATE TABLE "Account" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"providerAccountId" TEXT NOT NULL,
"refresh_token" TEXT,
"access_token" TEXT,
"expires_at" INTEGER,
"token_type" TEXT,
"scope" TEXT,
"id_token" TEXT,
"session_state" TEXT,
CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "Session" (
"id" TEXT NOT NULL PRIMARY KEY,
"sessionToken" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"expires" DATETIME NOT NULL,
CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT,
"email" TEXT,
"emailVerified" DATETIME,
"image" TEXT,
"role" TEXT NOT NULL DEFAULT 'EMPLOYEE'
);
-- CreateTable
CREATE TABLE "VerificationToken" (
"identifier" TEXT NOT NULL,
"token" TEXT NOT NULL,
"expires" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "Report" (
"id" TEXT NOT NULL PRIMARY KEY,
"date" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"managerName" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'IN_PROGRESS',
"driveFileId" TEXT,
"userId" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Report_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "Task" (
"id" TEXT NOT NULL PRIMARY KEY,
"type" TEXT NOT NULL,
"description" TEXT NOT NULL,
"timeEstimate" TEXT,
"notes" TEXT,
"status" TEXT DEFAULT 'PENDING',
"link" TEXT,
"reportId" TEXT NOT NULL,
CONSTRAINT "Task_reportId_fkey" FOREIGN KEY ("reportId") REFERENCES "Report" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "Setting" (
"id" TEXT NOT NULL PRIMARY KEY,
"key" TEXT NOT NULL,
"value" TEXT NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId");
-- CreateIndex
CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken");
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "VerificationToken_token_key" ON "VerificationToken"("token");
-- CreateIndex
CREATE UNIQUE INDEX "VerificationToken_identifier_token_key" ON "VerificationToken"("identifier", "token");
-- CreateIndex
CREATE UNIQUE INDEX "Setting_key_key" ON "Setting"("key");
@@ -0,0 +1,30 @@
-- Deduplicate reports before adding the unique constraint:
-- keep the earliest report per (userId, date), move tasks from the
-- duplicates onto it, then delete the duplicates.
UPDATE "Task"
SET "reportId" = (
SELECT keep."id"
FROM "Report" keep
JOIN "Report" cur ON cur."id" = "Task"."reportId"
WHERE keep."userId" = cur."userId"
AND keep."date" = cur."date"
ORDER BY keep."createdAt" ASC, keep."id" ASC
LIMIT 1
);
DELETE FROM "Report"
WHERE "id" IN (
SELECT "id" FROM (
SELECT "id",
ROW_NUMBER() OVER (
PARTITION BY "userId", "date"
ORDER BY "createdAt" ASC, "id" ASC
) AS rn
FROM "Report"
)
WHERE rn > 1
);
-- CreateIndex
CREATE UNIQUE INDEX "Report_userId_date_key" ON "Report"("userId", "date");
+3
View File
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "sqlite"
+2
View File
@@ -72,6 +72,8 @@ model Report {
tasks Task[] tasks Task[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@unique([userId, date])
} }
enum ReportStatus { enum ReportStatus {
+5 -2
View File
@@ -6,6 +6,7 @@ export const runtime = "nodejs";
import { getServerSession } from "next-auth/next"; import { getServerSession } from "next-auth/next";
import { authOptions } from "@/lib/auth"; import { authOptions } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { updateSettingSchema, parseBody } from "@/lib/validation";
// GET /api/admin/settings - Fetch global settings // GET /api/admin/settings - Fetch global settings
export async function GET() { export async function GET() {
@@ -16,7 +17,7 @@ export async function GET() {
} }
const settings = await prisma.setting.findMany(); 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); return NextResponse.json(settingsMap);
} }
@@ -29,7 +30,9 @@ export async function POST(req: Request) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); 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({ const setting = await prisma.setting.upsert({
where: { key }, where: { key },
+4 -5
View File
@@ -5,6 +5,7 @@ export const runtime = "nodejs";
import { getServerSession } from "next-auth/next"; import { getServerSession } from "next-auth/next";
import { authOptions } from "@/lib/auth"; import { authOptions } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { updateUserRoleSchema, parseBody } from "@/lib/validation";
// GET /api/admin/users - List all users // GET /api/admin/users - List all users
export async function GET() { export async function GET() {
@@ -41,11 +42,9 @@ export async function PATCH(req: Request) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
} }
const { userId, role } = await req.json(); const { data, error } = await parseBody(req, updateUserRoleSchema);
if (error) return error;
if (!userId || !["EMPLOYEE", "ADMIN"].includes(role)) { const { userId, role } = data;
return NextResponse.json({ error: "Invalid request" }, { status: 400 });
}
// Prevent admins from demoting themselves // Prevent admins from demoting themselves
if (userId === session.user.id && role === "EMPLOYEE") { 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 { getServerSession } from "next-auth/next";
import { authOptions } from "@/lib/auth"; import { authOptions } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { updateReportSchema, parseBody } from "@/lib/validation";
// PATCH /api/reports/[id] - Update report status or manager // PATCH /api/reports/[id] - Update report status or manager
export async function PATCH( export async function PATCH(
@@ -19,11 +20,29 @@ export async function PATCH(
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); 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({ const report = await prisma.report.update({
where: { id, userId: session.user.id }, where: { id },
data: { status, managerName }, data: { status: data.status, managerName: data.managerName },
}); });
return NextResponse.json(report); return NextResponse.json(report);
+68 -24
View File
@@ -2,28 +2,76 @@ import { NextResponse } from "next/server";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export const runtime = "nodejs"; export const runtime = "nodejs";
import { getServerSession } from "next-auth/next"; import { getServerSession } from "next-auth/next";
import { authOptions } from "@/lib/auth"; import { authOptions } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; 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) const MAX_PAGE_SIZE = 100;
export async function GET() { 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); const session = await getServerSession(authOptions);
if (!session) { if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); 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({ const reports = await prisma.report.findMany({
where, where,
include: { tasks: true, user: true }, include: {
orderBy: { date: "desc" }, 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 // 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 }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
} }
const body = await req.json(); const { data, error } = await parseBody(req, createReportSchema);
const { managerName, date } = body; 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; // 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. // we store it as UTC midnight so the date string is stable across timezones.
const reportDate = date const reportDate = data.date
? new Date(`${date}T00:00:00.000Z`) ? new Date(`${data.date}T00:00:00.000Z`)
: new Date(new Date().toLocaleDateString('en-CA', { timeZone: 'America/Chicago' }) + '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: { where: {
userId_date: { userId: session.user.id, date: reportDate },
},
update: {},
create: {
userId: session.user.id, userId: session.user.id,
managerName: data.managerName,
date: reportDate, 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); return NextResponse.json(report);
} }
+52 -29
View File
@@ -5,6 +5,7 @@ export const runtime = "nodejs";
import { getServerSession } from "next-auth/next"; import { getServerSession } from "next-auth/next";
import { authOptions } from "@/lib/auth"; import { authOptions } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { createTaskSchema, updateTaskSchema, parseBody } from "@/lib/validation";
// POST /api/tasks - Add a task to a report // POST /api/tasks - Add a task to a report
export async function POST(req: Request) { export async function POST(req: Request) {
@@ -14,11 +15,12 @@ export async function POST(req: Request) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); 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 // Verify ownership
const report = await prisma.report.findUnique({ const report = await prisma.report.findFirst({
where: { id: reportId, userId: session.user.id }, where: { id: data.reportId, userId: session.user.id },
}); });
if (!report) { if (!report) {
@@ -27,20 +29,20 @@ export async function POST(req: Request) {
const task = await prisma.task.create({ const task = await prisma.task.create({
data: { data: {
reportId, reportId: data.reportId,
type, type: data.type,
description, description: data.description,
timeEstimate, timeEstimate: data.timeEstimate,
notes, notes: data.notes,
link, link: data.link,
status: status || "PENDING", status: data.status || "PENDING",
}, },
}); });
return NextResponse.json(task); 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) { export async function PATCH(req: Request) {
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
@@ -48,32 +50,53 @@ export async function PATCH(req: Request) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); 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({ const task = await prisma.task.update({
where: { id }, where: { id },
data: { type, description, timeEstimate, notes, link, status }, data: updates,
}); });
return NextResponse.json(task); 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) { export async function DELETE(req: Request) {
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
if (!session) { if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); 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 });
await prisma.task.delete({
where: { id },
});
return NextResponse.json({ success: true });
} }
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 });
}
+43 -20
View File
@@ -2,21 +2,31 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { Search, ChevronDown, ChevronUp, ExternalLink, FileText, User, Users, Calendar, Settings, ListChecks, Save, ShieldCheck, ShieldOff } from "lucide-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() { 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 [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [expandedId, setExpandedId] = useState<string | null>(null); const [expandedId, setExpandedId] = useState<string | null>(null);
const [tab, setTab] = useState<"REPORTS" | "USERS" | "SETTINGS">("REPORTS"); const [tab, setTab] = useState<"REPORTS" | "USERS" | "SETTINGS">("REPORTS");
const [folderId, setFolderId] = useState(""); const [folderId, setFolderId] = useState("");
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [users, setUsers] = useState<any[]>([]); const [users, setUsers] = useState<AdminUserDTO[]>([]);
const [usersLoading, setUsersLoading] = useState(false); const [usersLoading, setUsersLoading] = useState(false);
const [togglingId, setTogglingId] = useState<string | null>(null); 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(() => { useEffect(() => {
fetchReports();
fetchSettings(); fetchSettings();
}, []); }, []);
@@ -35,11 +45,12 @@ export default function AdminDashboard() {
const saveSetting = async (key: string, value: string) => { const saveSetting = async (key: string, value: string) => {
setSaving(true); setSaving(true);
try { try {
await fetch("/api/admin/settings", { const res = await fetch("/api/admin/settings", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key, value }), body: JSON.stringify({ key, value }),
}); });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
alert("Setting saved successfully!"); alert("Setting saved successfully!");
} catch (error) { } catch (error) {
alert("Failed to save setting"); alert("Failed to save setting");
@@ -52,7 +63,8 @@ export default function AdminDashboard() {
setUsersLoading(true); setUsersLoading(true);
try { try {
const res = await fetch("/api/admin/users"); 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); setUsers(data);
} catch (error) { } catch (error) {
console.error("Failed to fetch users"); 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 { try {
const res = await fetch("/api/reports"); const params = new URLSearchParams({ take: String(PAGE_SIZE) });
const data = await res.json(); if (q) params.set("q", q);
setReports(data); 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) { } catch (error) {
console.error("Failed to fetch reports"); console.error("Failed to fetch reports", error);
} finally { } finally {
setLoading(false); 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>; if (loading) return <div className="text-center py-20 animate-pulse">Loading reports...</div>;
return ( return (
@@ -148,7 +162,7 @@ export default function AdminDashboard() {
{tab === "REPORTS" ? ( {tab === "REPORTS" ? (
<div className="grid gap-4"> <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 key={report.id} className="glass-card overflow-hidden transition-all duration-300">
<div <div
className="p-4 flex items-center justify-between cursor-pointer hover:bg-white/5" 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="grid md:grid-cols-2 gap-8">
<div className="space-y-3"> <div className="space-y-3">
<h4 className="text-xs font-bold uppercase text-accent-primary tracking-widest">Planned Tasks</h4> <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"> <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="font-medium">{task.description}</p>
<p className="text-xs text-text-dim">Est: {task.timeEstimate} {task.notes}</p> <p className="text-xs text-text-dim">Est: {task.timeEstimate} {task.notes}</p>
@@ -189,7 +203,7 @@ export default function AdminDashboard() {
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
<h4 className="text-xs font-bold uppercase text-accent-secondary tracking-widest">Completed Tasks</h4> <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"> <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> <p className="font-medium">{task.description}</p>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -208,7 +222,16 @@ export default function AdminDashboard() {
)} )}
</div> </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> </div>
) : tab === "USERS" ? ( ) : tab === "USERS" ? (
<div className="space-y-4 animate-fade-in"> <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 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"> <div className="flex items-center gap-4 min-w-0">
{user.image ? ( {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"> <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} /> <User size={20} />
+70 -32
View File
@@ -2,20 +2,29 @@
import { useSession, signIn, signOut } from "next-auth/react"; import { useSession, signIn, signOut } from "next-auth/react";
import { useState, useEffect, useRef } from "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 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() { export default function ReportForm() {
const { data: session, status } = useSession(); const { data: session, status } = useSession();
const [report, setReport] = useState<any>(null); const [report, setReport] = useState<ReportDTO | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [managerName, setManagerName] = useState(""); const [managerName, setManagerName] = useState("");
const [plannedTasks, setPlannedTasks] = useState<any[]>([]); const [plannedTasks, setPlannedTasks] = useState<TaskDTO[]>([]);
const [completedTasks, setCompletedTasks] = useState<any[]>([]); const [completedTasks, setCompletedTasks] = useState<TaskDTO[]>([]);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [saveFailed, setSaveFailed] = useState(false);
const [hasUnsaved, setHasUnsaved] = useState(false);
const [view, setView] = useState<"REPORT" | "ADMIN">("REPORT"); const [view, setView] = useState<"REPORT" | "ADMIN">("REPORT");
const debounceTimers = useRef<Record<string, ReturnType<typeof setTimeout>>>({}); const debounceTimers = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
const pendingUpdates = useRef<Record<string, any>>({}); const pendingUpdates = useRef<Record<string, TaskUpdate>>({});
useEffect(() => { useEffect(() => {
if (status === "authenticated") { if (status === "authenticated") {
@@ -31,16 +40,18 @@ export default function ReportForm() {
const fetchReport = async () => { const fetchReport = async () => {
try { try {
const res = await fetch("/api/reports"); // Ask the server for just the caller's own report for today,
const data = await res.json(); // instead of the full history (mine=1 matters for admins)
const today = getCentralToday(); const res = await fetch(`/api/reports?date=${getCentralToday()}&mine=1`);
const todayReport = data.find((r: any) => r.date.split('T')[0] === today); if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data: ReportsPageDTO = await res.json();
const todayReport = data.reports[0];
if (todayReport) { if (todayReport) {
setReport(todayReport); setReport(todayReport);
setManagerName(todayReport.managerName); setManagerName(todayReport.managerName);
setPlannedTasks(todayReport.tasks.filter((t: any) => t.type === "PLANNED")); setPlannedTasks(todayReport.tasks.filter((t) => t.type === "PLANNED"));
setCompletedTasks(todayReport.tasks.filter((t: any) => t.type === "COMPLETED")); setCompletedTasks(todayReport.tasks.filter((t) => t.type === "COMPLETED"));
} }
} catch (error) { } catch (error) {
console.error("Failed to fetch report", error); console.error("Failed to fetch report", error);
@@ -57,7 +68,8 @@ export default function ReportForm() {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ managerName, date: getCentralToday() }), 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); setReport(data);
} catch (error) { } catch (error) {
alert("Failed to start report"); alert("Failed to start report");
@@ -83,7 +95,8 @@ export default function ReportForm() {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(newTask), 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]); if (type === "PLANNED") setPlannedTasks([...plannedTasks, data]);
else setCompletedTasks([...completedTasks, data]); else setCompletedTasks([...completedTasks, data]);
} catch (error) { } 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 // Update local state immediately so the UI stays responsive
if (updates.type === 'PLANNED') { if (updates.type === 'PLANNED') {
setPlannedTasks(prev => prev.map(t => t.id === id ? { ...t, ...updates } : t)); 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 // Accumulate all field changes for this task so a single request carries everything
pendingUpdates.current[id] = { ...pendingUpdates.current[id], ...updates }; pendingUpdates.current[id] = { ...pendingUpdates.current[id], ...updates };
setHasUnsaved(true);
// Reset the debounce timer — the API call fires 600 ms after the last keystroke // Reset the debounce timer — the API call fires 600 ms after the last keystroke
clearTimeout(debounceTimers.current[id]); clearTimeout(debounceTimers.current[id]);
debounceTimers.current[id] = setTimeout(async () => { debounceTimers.current[id] = setTimeout(() => flushTask(id), 600);
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);
}; };
const deleteTask = async (id: string, type: string) => { const deleteTask = async (id: string, type: TaskType) => {
try { 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)); if (type === "PLANNED") setPlannedTasks(plannedTasks.filter(t => t.id !== id));
else setCompletedTasks(completedTasks.filter(t => t.id !== id)); else setCompletedTasks(completedTasks.filter(t => t.id !== id));
} catch (error) { } catch (error) {
@@ -303,7 +334,7 @@ export default function ReportForm() {
<div className="flex gap-4"> <div className="flex gap-4">
<select <select
value={task.status || "DONE"} 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" 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> <option value="DONE" className="bg-slate-800">Completed</option>
@@ -331,7 +362,14 @@ export default function ReportForm() {
</div> </div>
</section> </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 <button
onClick={exportToDrive} onClick={exportToDrive}
disabled={saving} disabled={saving}
+1 -1
View File
@@ -26,7 +26,7 @@ export const authOptions: NextAuthOptions = {
async session({ session, user }) { async session({ session, user }) {
if (session?.user && user) { if (session?.user && user) {
session.user.id = user.id; session.user.id = user.id;
session.user.role = (user as any).role || 'EMPLOYEE'; session.user.role = user.role || 'EMPLOYEE';
} }
return session; return session;
}, },
+41 -18
View File
@@ -1,6 +1,11 @@
import { google } from 'googleapis'; import { google, Auth } from 'googleapis';
import { Readable } from 'stream'; import { Readable } from 'stream';
import { prisma } from './prisma'; 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) { export async function getGoogleAuth(userId: string) {
const account = await prisma.account.findFirst({ const account = await prisma.account.findFirst({
@@ -50,10 +55,10 @@ export async function getGoogleAuth(userId: string) {
return auth; 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 drive = google.drive({ version: 'v3', auth });
const fileMetadata: any = { const fileMetadata: { name: string; mimeType: string; parents?: string[] } = {
name: fileName, name: fileName,
mimeType: 'application/vnd.google-apps.document', // Automatically convert to Google Doc 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 drive = google.drive({ version: 'v3', auth });
const media = { 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// 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 dateObj = new Date(report.date);
const dateStr = dateObj.toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }); 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 plannedTasks = report.tasks.filter((t) => t.type === 'PLANNED');
const completedTasks = report.tasks.filter((t: any) => t.type === 'COMPLETED'); 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 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;"; 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;"> <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>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 0 8px 0; font-size: 11pt;"><strong>Employee:</strong> ${escapeHtml(report.user.name)}</p>
<p style="margin: 0; font-size: 11pt;"><strong>Manager:</strong> ${report.managerName || 'N/A'}</p> <p style="margin: 0; font-size: 11pt;"><strong>Manager:</strong> ${escapeHtml(report.managerName || 'N/A')}</p>
</div> </div>
<h2 style="color: #1e293b; margin-top: 30px; margin-bottom: 15px; font-family: Arial, sans-serif;">Planned Tasks</h2> <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: 20%;">Estimate</th>
<th style="${headerStyle} width: 35%;">Notes</th> <th style="${headerStyle} width: 35%;">Notes</th>
</tr> </tr>
${plannedTasks.map((t: any) => ` ${plannedTasks.map((t) => `
<tr> <tr>
<td style="${cellStyle}">${t.description}</td> <td style="${cellStyle}">${escapeHtml(t.description)}</td>
<td style="${cellStyle} color: #64748b;">${t.timeEstimate || '-'}</td> <td style="${cellStyle} color: #64748b;">${escapeHtml(t.timeEstimate || '-')}</td>
<td style="${cellStyle} color: #64748b;">${t.notes || '-'}</td> <td style="${cellStyle} color: #64748b;">${escapeHtml(t.notes || '-')}</td>
</tr> </tr>
`).join('')} `).join('')}
</table> </table>
@@ -147,13 +167,16 @@ export function generateReportHTML(report: any) {
<th style="${headerStyle} width: 20%;">Status</th> <th style="${headerStyle} width: 20%;">Status</th>
<th style="${headerStyle} width: 40%;">Work Link</th> <th style="${headerStyle} width: 40%;">Work Link</th>
</tr> </tr>
${completedTasks.map((t: any) => ` ${completedTasks.map((t) => {
const link = safeLink(t.link);
return `
<tr> <tr>
<td style="${cellStyle}">${t.description}</td> <td style="${cellStyle}">${escapeHtml(t.description)}</td>
<td style="${cellStyle} font-weight: bold; color: #059669;">${t.status || 'Done'}</td> <td style="${cellStyle} font-weight: bold; color: #059669;">${escapeHtml(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}">${link ? `<a href="${escapeHtml(link)}" style="color: #2563eb; text-decoration: none;">${escapeHtml(link)}</a>` : '-'}</td>
</tr> </tr>
`).join('')} `;
}).join('')}
</table> </table>
` : `<p style="font-style: italic; color: #94a3b8; font-family: Arial, sans-serif; margin-bottom: 30px;">No completed tasks reported today.</p>`} ` : `<p style="font-style: italic; color: #94a3b8; font-family: Arial, sans-serif; margin-bottom: 30px;">No completed tasks reported today.</p>`}
+1 -1
View File
@@ -25,6 +25,6 @@ export const prisma = new Proxy({} as PrismaClient, {
return Reflect.get(getPrismaClient(), prop) return Reflect.get(getPrismaClient(), prop)
}, },
apply(_target, thisArg, args) { 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)
}, },
}) })
+88
View File
@@ -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 };
}
+51
View File
@@ -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 }[];
}
+7 -1
View File
@@ -70,5 +70,11 @@ For the application to work, you must configure your Google Cloud Project:
1. **Authorized Redirect URIs**: `http://[your-unraid-ip]:3000/api/auth/callback/google` 1. **Authorized Redirect URIs**: `http://[your-unraid-ip]:3000/api/auth/callback/google`
2. **Scopes**: Enable `Google Drive API` and ensure `auth/drive.file` scope is requested. 2. **Scopes**: Enable `Google Drive API` and ensure `auth/drive.file` scope is requested.
## Database & Upgrades
The database schema is managed by Prisma Migrate: every container start runs `prisma migrate deploy`, which applies any pending schema migrations automatically before the server boots.
**Upgrading from a version prior to migrations** (i.e. a database created by the old `db push` startup): the first boot after the upgrade will log a one-time `P3005` error followed by `Migration 0_init marked as applied.` — this is the automatic baseline step and is expected. No manual action is required, and your data is preserved. Any duplicate reports for the same user and day (a bug in older versions) are merged automatically during this migration.
## Support ## Support
The database is a single SQLite file located at `/mnt/user/appdata/wfh-daily-report/data/dev.db`. You can back this file up directly. The database is a single SQLite file located at `/mnt/user/appdata/wfh-daily-report/data/dev.db`. You can back this file up directly (ideally while the container is stopped, or copy the file atomically).