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:
+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 };
|
||||
}
|
||||
Reference in New Issue
Block a user