diff --git a/.env.example b/.env.example index 08832f29c..64a14fee0 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,30 @@ # ============================================================================ + +# ============================================================================ +# NEXT.JS MIGRATION STACK (VERCEL + SUPABASE) +# ============================================================================ +# Use these for the `worklenz-next` app. +NEXT_PUBLIC_APP_URL=http://localhost:3000 +NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY= +CLERK_SECRET_KEY= +DATABASE_URL= +DIRECT_URL= +NEXT_PUBLIC_SUPABASE_URL= +NEXT_PUBLIC_SUPABASE_ANON_KEY= +SUPABASE_SERVICE_ROLE_KEY= +CLOUDFLARE_R2_ENDPOINT= +CLOUDFLARE_R2_BUCKET= +CLOUDFLARE_R2_ACCESS_KEY_ID= +CLOUDFLARE_R2_SECRET_ACCESS_KEY= +UPSTASH_REDIS_REST_URL= +UPSTASH_REDIS_REST_TOKEN= +RESEND_API_KEY= +RESEND_FROM_EMAIL= +AXIOM_TOKEN= +AXIOM_ORG_ID= +AXIOM_DATASET=worklenz +CRON_SECRET= + # Worklenz Self-Hosted Configuration # ============================================================================ # Copy this file to .env and configure according to your needs diff --git a/docs/NEXTJS_MIGRATION_IMPLEMENTATION_STATUS_2026-04-25.md b/docs/NEXTJS_MIGRATION_IMPLEMENTATION_STATUS_2026-04-25.md new file mode 100644 index 000000000..a3bda48bd --- /dev/null +++ b/docs/NEXTJS_MIGRATION_IMPLEMENTATION_STATUS_2026-04-25.md @@ -0,0 +1,51 @@ +# Next.js Migration Implementation Status + +Date: 2026-04-25 + +## Completed in this iteration + +1. Added a new Next.js App Router application at `worklenz-next/`. +2. Wired Clerk middleware and auth routes (`/sign-in`, `/sign-up`) with protected app routing. +3. Migrated core route structure from React Router to Next.js pages: + - `/worklenz/home` + - `/worklenz/projects` + - `/worklenz/projects/[projectId]` + - `/worklenz/schedule` + - `/worklenz/unauthorized` + - `/worklenz/gantt-demo` +4. Added Prisma schema and runtime client for Supabase Postgres. +5. Added Cloudflare R2 signed upload/download URL helpers. +6. Added Upstash Redis client helper. +7. Added Supabase SSR and realtime subscription utility. +8. Added Resend + React Email template setup. +9. Added Axiom structured logging utility. +10. Added secure Vercel cron endpoints and `vercel.json` schedule config. +11. Added Prisma + Upstash + Axiom-backed task API route handlers in Next.js (`GET/POST /api/projects/[projectId]/tasks`). +12. Added Supabase Realtime broadcast route (`POST /api/realtime/broadcast`) as Socket.IO replacement entrypoint. +13. Replaced Docker stack with Next.js-focused compose setup. +14. Removed database backup service from compose per requirement. +15. Removed nginx and certbot from compose per requirement. +16. Ported attendance module to Next.js (`GET/POST /api/attendance`) with auto-check-in confirmation model. +17. Ported office management module to Next.js (`GET/POST /api/offices`, `PATCH /api/offices/[id]`). +18. Ported task query log workflows (`GET/POST /api/tasks/[taskId]/query-logs`, `POST /api/tasks/query-logs/[id]/respond`). +19. Ported JCC review workflows: + - `GET /api/jcc/review-queue` + - `POST /api/jcc/tasks/[taskId]/submit` (mandatory time-log enforcement) + - `POST /api/jcc/tasks/[taskId]/review` (approve/reject/revision/on-hold with validations) +20. Added realtime publishing for review and submit actions using Supabase broadcast channels. +21. Added app routes for Attendance and Review Queue in the Next.js layout. +22. Expanded Prisma schema for office, attendance, query log, and review metadata fields. +23. Added additional Vercel cron endpoints for project digest and recurring tasks. + +## Remaining for production parity + +1. Port remaining Vite UI modules from `worklenz-frontend` into `worklenz-next` (Kanban, task drawer, dashboard widgets). +2. Replace remaining backend API surfaces with Next.js route handlers using Prisma. +3. Finalize Socket.IO removal by moving all frontend listeners/actions to Supabase Realtime channels. +4. Implement full cron business logic in `app/api/cron/*` (currently scaffolds with logging). +5. Complete SES to Resend migration for all email templates and notification events. +6. Replace legacy file-log and logger middleware usage across all migrated handlers with Axiom. +7. Apply Prisma baseline against existing production schema and run staged data validation. +8. Finalize Clerk role synchronization and authorization policies for MD/Senior QS/QS. +9. Add E2E and integration tests for auth, review flow, attendance, realtime, email, and cron. +10. Configure and validate production env on Vercel/Supabase/R2/Upstash/Clerk/Resend/Axiom. diff --git a/worklenz-next/.env.example b/worklenz-next/.env.example new file mode 100644 index 000000000..d308f2c1f --- /dev/null +++ b/worklenz-next/.env.example @@ -0,0 +1,35 @@ +NEXT_PUBLIC_APP_URL=http://localhost:3000 + +# Clerk +NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY= +CLERK_SECRET_KEY= +CLERK_WEBHOOK_SECRET= + +# Prisma + Supabase +DATABASE_URL= +DIRECT_URL= +NEXT_PUBLIC_SUPABASE_URL= +NEXT_PUBLIC_SUPABASE_ANON_KEY= +SUPABASE_SERVICE_ROLE_KEY= + +# Cloudflare R2 +CLOUDFLARE_R2_ENDPOINT= +CLOUDFLARE_R2_BUCKET= +CLOUDFLARE_R2_ACCESS_KEY_ID= +CLOUDFLARE_R2_SECRET_ACCESS_KEY= + +# Upstash Redis +UPSTASH_REDIS_REST_URL= +UPSTASH_REDIS_REST_TOKEN= + +# Resend + React Email +RESEND_API_KEY= +RESEND_FROM_EMAIL= + +# Axiom +AXIOM_TOKEN= +AXIOM_ORG_ID= +AXIOM_DATASET=worklenz + +# Vercel Cron +CRON_SECRET= diff --git a/worklenz-next/Dockerfile b/worklenz-next/Dockerfile new file mode 100644 index 000000000..9fdca4f9c --- /dev/null +++ b/worklenz-next/Dockerfile @@ -0,0 +1,20 @@ +FROM node:20-alpine AS deps +WORKDIR /app +COPY package*.json ./ +RUN npm ci + +FROM node:20-alpine AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN npm run build + +FROM node:20-alpine AS runner +WORKDIR /app +ENV NODE_ENV=production +COPY --from=builder /app/public ./public +COPY --from=builder /app/.next ./.next +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/package.json ./package.json +EXPOSE 3000 +CMD ["npm", "run", "start"] diff --git a/worklenz-next/README.md b/worklenz-next/README.md new file mode 100644 index 000000000..e6cf57cc9 --- /dev/null +++ b/worklenz-next/README.md @@ -0,0 +1,41 @@ +# Worklenz Next.js Migration App + +This app is the production migration target for: + +- Next.js App Router +- Clerk authentication +- Prisma on Supabase Postgres +- Supabase Realtime +- Cloudflare R2 object storage +- Upstash Redis cache +- Resend + React Email +- Vercel Cron +- Axiom logging + +## Local run + +1. Copy `.env.example` to `.env.local` and fill values. +2. Install dependencies: + +```bash +npm install +``` + +3. Generate Prisma client: + +```bash +npm run prisma:generate +``` + +4. Start the app: + +```bash +npm run dev +``` + +## Production notes + +- Deploy this folder as the Vercel project root. +- Add `CRON_SECRET` and all service credentials in Vercel env settings. +- Set Supabase pooled URL as `DATABASE_URL` and direct connection URL as `DIRECT_URL`. +- This migration intentionally removes local DB backup services, nginx, and certbot from the deployment path. diff --git a/worklenz-next/app/(auth)/sign-in/[[...sign-in]]/page.tsx b/worklenz-next/app/(auth)/sign-in/[[...sign-in]]/page.tsx new file mode 100644 index 000000000..a41743db6 --- /dev/null +++ b/worklenz-next/app/(auth)/sign-in/[[...sign-in]]/page.tsx @@ -0,0 +1,9 @@ +import { SignIn } from "@clerk/nextjs"; + +export default function SignInPage() { + return ( +
+ +
+ ); +} diff --git a/worklenz-next/app/(auth)/sign-up/[[...sign-up]]/page.tsx b/worklenz-next/app/(auth)/sign-up/[[...sign-up]]/page.tsx new file mode 100644 index 000000000..c95456c62 --- /dev/null +++ b/worklenz-next/app/(auth)/sign-up/[[...sign-up]]/page.tsx @@ -0,0 +1,9 @@ +import { SignUp } from "@clerk/nextjs"; + +export default function SignUpPage() { + return ( +
+ +
+ ); +} diff --git a/worklenz-next/app/api/attendance/route.ts b/worklenz-next/app/api/attendance/route.ts new file mode 100644 index 000000000..7f91ce02a --- /dev/null +++ b/worklenz-next/app/api/attendance/route.ts @@ -0,0 +1,94 @@ +import { NextRequest, NextResponse } from "next/server"; +import { isOneOfRoles } from "@/lib/auth/roles"; +import { prisma } from "@/lib/db/prisma"; +import { requireUserProfile } from "@/lib/users/profile"; + +const DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/; +const VALID_STATUSES = ["present", "absent", "half_day", "on_leave"] as const; + +function parseDate(value?: string | null) { + if (!value || !DATE_REGEX.test(value)) { + return null; + } + + return new Date(`${value}T00:00:00.000Z`); +} + +export async function GET(request: NextRequest) { + const profile = await requireUserProfile(); + if (!profile) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const canViewOthers = await isOneOfRoles(["owner", "admin", "managing_director", "senior_qs"]); + const query = request.nextUrl.searchParams; + const start = parseDate(query.get("start")) ?? new Date(new Date().toISOString().slice(0, 10)); + const end = parseDate(query.get("end")) ?? start; + const requestedUserId = query.get("user_id"); + const targetUserId = canViewOthers ? requestedUserId : profile.id; + + const rows = await prisma.attendance.findMany({ + where: { + workDate: { gte: start, lte: end }, + ...(targetUserId ? { userId: targetUserId } : {}) + }, + include: { + user: { select: { id: true, fullName: true, email: true } }, + office: { select: { id: true, code: true, name: true } } + }, + orderBy: [{ workDate: "desc" }, { createdAt: "desc" }] + }); + + return NextResponse.json({ data: rows }); +} + +export async function POST(request: NextRequest) { + const profile = await requireUserProfile(); + if (!profile) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = (await request.json()) as { + attendance_date?: string; + status?: string; + reason?: string; + check_in_at?: string; + }; + + const workDate = parseDate(body.attendance_date); + const status = (body.status ?? "").toLowerCase(); + if (!workDate || !VALID_STATUSES.includes(status as (typeof VALID_STATUSES)[number])) { + return NextResponse.json({ error: "attendance_date and valid status are required" }, { status: 400 }); + } + + const checkInTime = body.check_in_at ? new Date(body.check_in_at) : new Date(); + const record = await prisma.attendance.upsert({ + where: { + userId_workDate: { + userId: profile.id, + workDate + } + }, + update: { + status, + reason: body.reason?.trim() || null, + checkInTime, + confirmed: true + }, + create: { + userId: profile.id, + officeId: profile.officeId, + workDate, + status, + reason: body.reason?.trim() || null, + checkInTime, + confirmed: true + }, + include: { + user: { select: { id: true, fullName: true } }, + office: { select: { id: true, code: true, name: true } } + } + }); + + return NextResponse.json({ data: record }); +} diff --git a/worklenz-next/app/api/cron/daily-digest/route.ts b/worklenz-next/app/api/cron/daily-digest/route.ts new file mode 100644 index 000000000..d11c04ac7 --- /dev/null +++ b/worklenz-next/app/api/cron/daily-digest/route.ts @@ -0,0 +1,88 @@ +import { headers } from "next/headers"; +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/db/prisma"; +import { logError, logInfo } from "@/lib/logging/axiom"; +import { sendDailyDigestEmail } from "@/lib/email/resend"; + +function isAuthorized(authHeader: string | null) { + const secret = process.env.CRON_SECRET; + if (!secret || !authHeader) return false; + return authHeader === `Bearer ${secret}`; +} + +export async function GET() { + const h = await headers(); + if (!isAuthorized(h.get("authorization"))) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const now = new Date(); + const today = new Date(now.toISOString().slice(0, 10) + "T00:00:00.000Z"); + const tomorrow = new Date(today); + tomorrow.setDate(today.getDate() + 1); + const dateStr = today.toISOString().slice(0, 10); + + try { + const [allStaff, todayAttendance, pendingReviewCount, admins] = await Promise.all([ + prisma.userProfile.findMany({ + where: { role: { in: ["qs", "senior_qs"] } }, + select: { id: true, fullName: true, email: true } + }), + prisma.attendance.findMany({ + where: { workDate: { gte: today, lt: tomorrow } }, + select: { userId: true } + }), + prisma.task.count({ where: { status: "SUBMITTED" } }), + prisma.userProfile.findMany({ + where: { role: { in: ["managing_director", "admin", "owner"] } }, + select: { id: true, fullName: true, email: true } + }) + ]); + + const presentIds = new Set(todayAttendance.map((a) => a.userId)); + const absentStaff = allStaff + .filter((u) => !presentIds.has(u.id)) + .map((u) => ({ name: u.fullName ?? "", email: u.email })); + + let sent = 0; + for (const admin of admins) { + try { + await sendDailyDigestEmail({ + to: admin.email, + recipientName: admin.fullName ?? admin.email, + date: dateStr, + absentCount: absentStaff.length, + pendingReviewCount, + absentStaff + }); + sent++; + } catch (err) { + await logError("cron.dailyDigest.emailError", { + to: admin.email, + message: err instanceof Error ? err.message : "Unknown" + }); + } + } + + await logInfo("cron.dailyDigest.run", { + at: now.toISOString(), + date: dateStr, + absentCount: absentStaff.length, + pendingReviewCount, + emailsSent: sent + }); + + return NextResponse.json({ + ok: true, + job: "daily-digest", + absentCount: absentStaff.length, + pendingReviewCount, + emailsSent: sent + }); + } catch (error) { + await logError("cron.dailyDigest.error", { + message: error instanceof Error ? error.message : "Unknown error" + }); + return NextResponse.json({ error: "Cron failed" }, { status: 500 }); + } +} diff --git a/worklenz-next/app/api/cron/notifications/route.ts b/worklenz-next/app/api/cron/notifications/route.ts new file mode 100644 index 000000000..93cf74595 --- /dev/null +++ b/worklenz-next/app/api/cron/notifications/route.ts @@ -0,0 +1,96 @@ +import { headers } from "next/headers"; +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/db/prisma"; +import { logError, logInfo } from "@/lib/logging/axiom"; +import { sendReviewPendingEmail } from "@/lib/email/resend"; + +function isAuthorized(authHeader: string | null) { + const secret = process.env.CRON_SECRET; + if (!secret || !authHeader) return false; + return authHeader === `Bearer ${secret}`; +} + +const HOURS_THRESHOLD = 24; + +export async function GET() { + const h = await headers(); + if (!isAuthorized(h.get("authorization"))) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const cutoff = new Date(Date.now() - HOURS_THRESHOLD * 60 * 60 * 1000); + + try { + const [pendingTasks, reviewers] = await Promise.all([ + prisma.task.findMany({ + where: { + status: "SUBMITTED", + submittedAt: { lt: cutoff } + }, + include: { + project: { select: { id: true, name: true, code: true } }, + assignee: { select: { id: true, fullName: true, email: true } } + }, + orderBy: { submittedAt: "asc" } + }), + prisma.userProfile.findMany({ + where: { role: { in: ["managing_director", "senior_qs", "admin", "owner"] } }, + select: { id: true, fullName: true, email: true } + }) + ]); + + if (pendingTasks.length === 0) { + await logInfo("cron.notifications.run", { + at: new Date().toISOString(), + pendingCount: 0, + emailsSent: 0 + }); + return NextResponse.json({ ok: true, job: "notifications", pendingCount: 0, emailsSent: 0 }); + } + + const taskSummaries = pendingTasks.map((t) => ({ + title: t.title, + projectName: t.project.name, + assigneeName: t.assignee?.fullName ?? t.assignee?.email ?? "Unknown", + submittedHoursAgo: Math.floor( + (Date.now() - (t.submittedAt?.getTime() ?? Date.now())) / (1000 * 60 * 60) + ) + })); + + let sent = 0; + for (const reviewer of reviewers) { + try { + await sendReviewPendingEmail({ + to: reviewer.email, + recipientName: reviewer.fullName ?? reviewer.email, + pendingCount: pendingTasks.length, + tasks: taskSummaries + }); + sent++; + } catch (err) { + await logError("cron.notifications.emailError", { + to: reviewer.email, + message: err instanceof Error ? err.message : "Unknown" + }); + } + } + + await logInfo("cron.notifications.run", { + at: new Date().toISOString(), + pendingCount: pendingTasks.length, + emailsSent: sent + }); + + return NextResponse.json({ + ok: true, + job: "notifications", + pendingCount: pendingTasks.length, + emailsSent: sent + }); + } catch (error) { + await logError("cron.notifications.error", { + message: error instanceof Error ? error.message : "Unknown error" + }); + return NextResponse.json({ error: "Cron failed" }, { status: 500 }); + } +} diff --git a/worklenz-next/app/api/cron/project-digest/route.ts b/worklenz-next/app/api/cron/project-digest/route.ts new file mode 100644 index 000000000..1a23f8973 --- /dev/null +++ b/worklenz-next/app/api/cron/project-digest/route.ts @@ -0,0 +1,96 @@ +import { headers } from "next/headers"; +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/db/prisma"; +import { logError, logInfo } from "@/lib/logging/axiom"; +import { sendDailyDigestEmail } from "@/lib/email/resend"; + +function isAuthorized(authHeader: string | null) { + const secret = process.env.CRON_SECRET; + if (!secret || !authHeader) return false; + return authHeader === `Bearer ${secret}`; +} + +export async function GET() { + const h = await headers(); + if (!isAuthorized(h.get("authorization"))) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const dateStr = new Date().toISOString().slice(0, 10); + + try { + const [projects, admins] = await Promise.all([ + prisma.project.findMany({ + include: { + _count: { select: { tasks: true } }, + tasks: { + select: { status: true } + } + } + }), + prisma.userProfile.findMany({ + where: { role: { in: ["managing_director", "admin", "owner"] } }, + select: { id: true, fullName: true, email: true } + }) + ]); + + const summaries = projects.map((p) => { + const counts: Record = {}; + for (const t of p.tasks) { + counts[t.status] = (counts[t.status] ?? 0) + 1; + } + return { name: p.name, code: p.code, total: p._count.tasks, counts }; + }); + + const pendingReviewCount = summaries.reduce((sum, s) => sum + (s.counts["SUBMITTED"] ?? 0), 0); + const totalAbsent = 0; + + const digestLines = summaries + .filter((s) => s.total > 0) + .map( + (s) => + `${s.code}: ${s.total} tasks — ${Object.entries(s.counts) + .map(([k, v]) => `${k}: ${v}`) + .join(", ")}` + ); + + let sent = 0; + for (const admin of admins) { + try { + await sendDailyDigestEmail({ + to: admin.email, + recipientName: admin.fullName ?? admin.email, + date: `${dateStr} (project digest)`, + absentCount: totalAbsent, + pendingReviewCount, + absentStaff: digestLines.map((l) => ({ name: l, email: "" })) + }); + sent++; + } catch (err) { + await logError("cron.projectDigest.emailError", { + to: admin.email, + message: err instanceof Error ? err.message : "Unknown" + }); + } + } + + await logInfo("cron.projectDigest.run", { + at: new Date().toISOString(), + projectCount: projects.length, + emailsSent: sent + }); + + return NextResponse.json({ + ok: true, + job: "project-digest", + projectCount: projects.length, + summaries, + emailsSent: sent + }); + } catch (error) { + await logError("cron.projectDigest.error", { + message: error instanceof Error ? error.message : "Unknown error" + }); + return NextResponse.json({ error: "Cron failed" }, { status: 500 }); + } +} diff --git a/worklenz-next/app/api/cron/recurring-tasks/route.ts b/worklenz-next/app/api/cron/recurring-tasks/route.ts new file mode 100644 index 000000000..8c944f30a --- /dev/null +++ b/worklenz-next/app/api/cron/recurring-tasks/route.ts @@ -0,0 +1,79 @@ +import { headers } from "next/headers"; +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/db/prisma"; +import { logError, logInfo } from "@/lib/logging/axiom"; +import { sendRevisionReminderEmail } from "@/lib/email/resend"; + +function isAuthorized(authHeader: string | null) { + const secret = process.env.CRON_SECRET; + if (!secret || !authHeader) return false; + return authHeader === `Bearer ${secret}`; +} + +const STALE_DAYS = 7; + +export async function GET() { + const h = await headers(); + if (!isAuthorized(h.get("authorization"))) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const cutoff = new Date(Date.now() - STALE_DAYS * 24 * 60 * 60 * 1000); + + try { + const staleTasks = await prisma.task.findMany({ + where: { + status: "REVISION_REQUIRED", + updatedAt: { lt: cutoff } + }, + include: { + project: { select: { id: true, name: true } }, + assignee: { select: { id: true, fullName: true, email: true } } + } + }); + + let notified = 0; + for (const task of staleTasks) { + if (!task.assignee?.email) continue; + + const daysSince = Math.floor( + (Date.now() - task.updatedAt.getTime()) / (1000 * 60 * 60 * 24) + ); + + try { + await sendRevisionReminderEmail({ + to: task.assignee.email, + recipientName: task.assignee.fullName ?? task.assignee.email, + taskTitle: task.title, + projectName: task.project.name, + daysSinceRevision: daysSince, + reviewComment: task.reviewComment ?? undefined + }); + notified++; + } catch (err) { + await logError("cron.recurringTasks.emailError", { + taskId: task.id, + message: err instanceof Error ? err.message : "Unknown" + }); + } + } + + await logInfo("cron.recurringTasks.run", { + at: new Date().toISOString(), + staleCount: staleTasks.length, + notified + }); + + return NextResponse.json({ + ok: true, + job: "recurring-tasks", + staleCount: staleTasks.length, + notified + }); + } catch (error) { + await logError("cron.recurringTasks.error", { + message: error instanceof Error ? error.message : "Unknown error" + }); + return NextResponse.json({ error: "Cron failed" }, { status: 500 }); + } +} diff --git a/worklenz-next/app/api/files/sign/route.ts b/worklenz-next/app/api/files/sign/route.ts new file mode 100644 index 000000000..2d8829a0d --- /dev/null +++ b/worklenz-next/app/api/files/sign/route.ts @@ -0,0 +1,31 @@ +import { auth } from "@clerk/nextjs/server"; +import { NextRequest, NextResponse } from "next/server"; +import { createSignedDownloadUrl, createSignedUploadUrl } from "@/lib/storage/r2"; + +type SignBody = { + key: string; + mode: "upload" | "download"; + expiresInSeconds?: number; +}; + +export async function POST(request: NextRequest) { + const { userId } = await auth(); + + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = (await request.json()) as SignBody; + + if (!body.key || !body.mode) { + return NextResponse.json({ error: "Invalid payload" }, { status: 400 }); + } + + const expiresInSeconds = body.expiresInSeconds ?? 300; + const url = + body.mode === "upload" + ? await createSignedUploadUrl(body.key, expiresInSeconds) + : await createSignedDownloadUrl(body.key, expiresInSeconds); + + return NextResponse.json({ url, expiresInSeconds }); +} diff --git a/worklenz-next/app/api/health/route.ts b/worklenz-next/app/api/health/route.ts new file mode 100644 index 000000000..d168a3539 --- /dev/null +++ b/worklenz-next/app/api/health/route.ts @@ -0,0 +1,9 @@ +import { NextResponse } from "next/server"; + +export async function GET() { + return NextResponse.json({ + status: "ok", + service: "worklenz-next", + timestamp: new Date().toISOString() + }); +} diff --git a/worklenz-next/app/api/jcc/review-queue/route.ts b/worklenz-next/app/api/jcc/review-queue/route.ts new file mode 100644 index 000000000..cd800a9d1 --- /dev/null +++ b/worklenz-next/app/api/jcc/review-queue/route.ts @@ -0,0 +1,31 @@ +import { NextRequest, NextResponse } from "next/server"; +import { isOneOfRoles } from "@/lib/auth/roles"; +import { prisma } from "@/lib/db/prisma"; +import { requireUserProfile } from "@/lib/users/profile"; + +export async function GET(request: NextRequest) { + const profile = await requireUserProfile(); + if (!profile) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const allowed = await isOneOfRoles(["owner", "admin", "managing_director", "senior_qs"]); + if (!allowed) { + return NextResponse.json({ error: "Unauthorized" }, { status: 403 }); + } + + const projectId = request.nextUrl.searchParams.get("project_id"); + const tasks = await prisma.task.findMany({ + where: { + status: { in: ["SUBMITTED", "Submitted", "submitted"] }, + ...(projectId ? { projectId } : {}) + }, + include: { + project: { select: { id: true, name: true, code: true } }, + assignee: { select: { id: true, fullName: true, email: true } } + }, + orderBy: [{ submittedAt: "desc" }, { updatedAt: "desc" }] + }); + + return NextResponse.json({ data: tasks }); +} diff --git a/worklenz-next/app/api/jcc/tasks/[taskId]/review/route.ts b/worklenz-next/app/api/jcc/tasks/[taskId]/review/route.ts new file mode 100644 index 000000000..85224da8a --- /dev/null +++ b/worklenz-next/app/api/jcc/tasks/[taskId]/review/route.ts @@ -0,0 +1,72 @@ +import { NextRequest, NextResponse } from "next/server"; +import { isOneOfRoles } from "@/lib/auth/roles"; +import { prisma } from "@/lib/db/prisma"; +import { publishRealtimeEvent } from "@/lib/realtime/publish"; +import { requireUserProfile } from "@/lib/users/profile"; + +const OUTCOME_TO_STATUS: Record = { + approved: "APPROVED", + revision_required: "REVISION_REQUIRED", + rejected: "REJECTED", + on_hold: "ON_HOLD" +}; + +type RouteContext = { + params: Promise<{ taskId: string }>; +}; + +export async function POST(request: NextRequest, { params }: RouteContext) { + const profile = await requireUserProfile(); + if (!profile) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const allowed = await isOneOfRoles(["owner", "admin", "managing_director", "senior_qs"]); + if (!allowed) { + return NextResponse.json({ error: "Unauthorized" }, { status: 403 }); + } + + const { taskId } = await params; + const body = (await request.json()) as { outcome?: string; comment?: string }; + const outcome = (body.outcome ?? "").toLowerCase(); + + if (!OUTCOME_TO_STATUS[outcome]) { + return NextResponse.json({ error: "Invalid review outcome" }, { status: 400 }); + } + + if (outcome === "rejected" && !body.comment?.trim()) { + return NextResponse.json({ error: "Comment is required for rejected outcome" }, { status: 400 }); + } + + const task = await prisma.task.findUnique({ where: { id: taskId } }); + if (!task) { + return NextResponse.json({ error: "Task not found" }, { status: 404 }); + } + + const revisionIncrement = outcome === "revision_required" ? 1 : 0; + const updated = await prisma.task.update({ + where: { id: taskId }, + data: { + status: OUTCOME_TO_STATUS[outcome], + reviewOutcome: outcome, + reviewComment: body.comment?.trim() || task.reviewComment, + reviewedAt: new Date(), + revisionCount: { + increment: revisionIncrement + } + } + }); + + await publishRealtimeEvent({ + channel: `project:${updated.projectId}`, + event: "task.reviewed", + payload: { + taskId: updated.id, + projectId: updated.projectId, + outcome, + actorUserId: profile.id + } + }); + + return NextResponse.json({ data: updated }); +} diff --git a/worklenz-next/app/api/jcc/tasks/[taskId]/submit/route.ts b/worklenz-next/app/api/jcc/tasks/[taskId]/submit/route.ts new file mode 100644 index 000000000..1903a6452 --- /dev/null +++ b/worklenz-next/app/api/jcc/tasks/[taskId]/submit/route.ts @@ -0,0 +1,52 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/db/prisma"; +import { publishRealtimeEvent } from "@/lib/realtime/publish"; +import { requireUserProfile } from "@/lib/users/profile"; + +type RouteContext = { + params: Promise<{ taskId: string }>; +}; + +export async function POST(_: Request, { params }: RouteContext) { + const profile = await requireUserProfile(); + if (!profile) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { taskId } = await params; + const task = await prisma.task.findUnique({ + where: { id: taskId }, + include: { + logs: { select: { minutes: true } } + } + }); + + if (!task) { + return NextResponse.json({ error: "Task not found" }, { status: 404 }); + } + + const loggedMinutes = task.logs.reduce((sum, log) => sum + log.minutes, 0); + if (loggedMinutes <= 0 && task.timeSpentMinute <= 0) { + return NextResponse.json({ error: "Cannot submit without a time log" }, { status: 400 }); + } + + const updated = await prisma.task.update({ + where: { id: taskId }, + data: { + status: "SUBMITTED", + submittedAt: new Date() + } + }); + + await publishRealtimeEvent({ + channel: `project:${updated.projectId}`, + event: "task.submitted", + payload: { + taskId: updated.id, + projectId: updated.projectId, + actorUserId: profile.id + } + }); + + return NextResponse.json({ data: updated }); +} diff --git a/worklenz-next/app/api/offices/[id]/route.ts b/worklenz-next/app/api/offices/[id]/route.ts new file mode 100644 index 000000000..9ee818395 --- /dev/null +++ b/worklenz-next/app/api/offices/[id]/route.ts @@ -0,0 +1,44 @@ +import { NextRequest, NextResponse } from "next/server"; +import { isOneOfRoles } from "@/lib/auth/roles"; +import { prisma } from "@/lib/db/prisma"; +import { requireUserProfile } from "@/lib/users/profile"; + +type RouteContext = { + params: Promise<{ id: string }>; +}; + +export async function PATCH(request: NextRequest, { params }: RouteContext) { + const profile = await requireUserProfile(); + if (!profile) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const allowed = await isOneOfRoles(["owner", "admin", "managing_director"]); + if (!allowed) { + return NextResponse.json({ error: "Unauthorized" }, { status: 403 }); + } + + const { id } = await params; + const body = (await request.json()) as { + code?: string; + name?: string; + city?: string; + country?: string; + managing_user_id?: string; + active?: boolean; + }; + + const updated = await prisma.office.update({ + where: { id }, + data: { + ...(body.code ? { code: body.code.trim().toUpperCase() } : {}), + ...(body.name ? { name: body.name.trim() } : {}), + ...(body.city !== undefined ? { city: body.city?.trim() || null } : {}), + ...(body.country !== undefined ? { country: body.country?.trim() || null } : {}), + ...(body.managing_user_id !== undefined ? { managingUserId: body.managing_user_id || null } : {}), + ...(body.active !== undefined ? { active: body.active } : {}) + } + }); + + return NextResponse.json({ data: updated }); +} diff --git a/worklenz-next/app/api/offices/route.ts b/worklenz-next/app/api/offices/route.ts new file mode 100644 index 000000000..cd90be8f4 --- /dev/null +++ b/worklenz-next/app/api/offices/route.ts @@ -0,0 +1,55 @@ +import { NextRequest, NextResponse } from "next/server"; +import { isOneOfRoles } from "@/lib/auth/roles"; +import { prisma } from "@/lib/db/prisma"; +import { requireUserProfile } from "@/lib/users/profile"; + +export async function GET() { + const profile = await requireUserProfile(); + if (!profile) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const offices = await prisma.office.findMany({ + orderBy: { name: "asc" } + }); + + return NextResponse.json({ data: offices }); +} + +export async function POST(request: NextRequest) { + const profile = await requireUserProfile(); + if (!profile) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const allowed = await isOneOfRoles(["owner", "admin", "managing_director"]); + if (!allowed) { + return NextResponse.json({ error: "Unauthorized" }, { status: 403 }); + } + + const body = (await request.json()) as { + code?: string; + name?: string; + city?: string; + country?: string; + managing_user_id?: string; + active?: boolean; + }; + + if (!body.code || !body.name) { + return NextResponse.json({ error: "code and name are required" }, { status: 400 }); + } + + const created = await prisma.office.create({ + data: { + code: body.code.trim().toUpperCase(), + name: body.name.trim(), + city: body.city?.trim() || null, + country: body.country?.trim() || null, + managingUserId: body.managing_user_id || null, + active: body.active ?? true + } + }); + + return NextResponse.json({ data: created }); +} diff --git a/worklenz-next/app/api/projects/[projectId]/route.ts b/worklenz-next/app/api/projects/[projectId]/route.ts new file mode 100644 index 000000000..a5733a7c1 --- /dev/null +++ b/worklenz-next/app/api/projects/[projectId]/route.ts @@ -0,0 +1,62 @@ +import { NextRequest, NextResponse } from "next/server"; +import { isOneOfRoles } from "@/lib/auth/roles"; +import { prisma } from "@/lib/db/prisma"; +import { requireUserProfile } from "@/lib/users/profile"; +import { logInfo } from "@/lib/logging/axiom"; + +type RouteContext = { + params: Promise<{ projectId: string }>; +}; + +export async function GET(_: NextRequest, { params }: RouteContext) { + const profile = await requireUserProfile(); + if (!profile) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { projectId } = await params; + const project = await prisma.project.findUnique({ + where: { id: projectId }, + include: { + office: { select: { id: true, code: true, name: true } }, + _count: { select: { tasks: true } } + } + }); + + if (!project) { + return NextResponse.json({ error: "Project not found" }, { status: 404 }); + } + + return NextResponse.json({ data: project }); +} + +export async function PATCH(request: NextRequest, { params }: RouteContext) { + const profile = await requireUserProfile(); + if (!profile) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const allowed = await isOneOfRoles(["owner", "admin", "managing_director"]); + if (!allowed) { + return NextResponse.json({ error: "Unauthorized" }, { status: 403 }); + } + + const { projectId } = await params; + const body = (await request.json()) as { + name?: string; + code?: string; + office_id?: string | null; + }; + + const project = await prisma.project.update({ + where: { id: projectId }, + data: { + ...(body.name ? { name: body.name.trim() } : {}), + ...(body.code ? { code: body.code.trim().toUpperCase() } : {}), + ...(body.office_id !== undefined ? { officeId: body.office_id } : {}) + } + }); + + await logInfo("api.projects.update", { projectId, actor: profile.id }); + return NextResponse.json({ data: project }); +} diff --git a/worklenz-next/app/api/projects/[projectId]/tasks/route.ts b/worklenz-next/app/api/projects/[projectId]/tasks/route.ts new file mode 100644 index 000000000..2bd216964 --- /dev/null +++ b/worklenz-next/app/api/projects/[projectId]/tasks/route.ts @@ -0,0 +1,78 @@ +import { auth } from "@clerk/nextjs/server"; +import { NextResponse } from "next/server"; +import { redis } from "@/lib/cache/redis"; +import { prisma } from "@/lib/db/prisma"; +import { logError, logInfo } from "@/lib/logging/axiom"; + +type RouteContext = { + params: Promise<{ projectId: string }>; +}; + +export async function GET(_: Request, { params }: RouteContext) { + const { userId } = await auth(); + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { projectId } = await params; + const cacheKey = `project:${projectId}:tasks`; + + try { + const cached = await redis().get(cacheKey); + if (cached) { + return NextResponse.json({ source: "cache", data: cached }); + } + + const tasks = await prisma.task.findMany({ + where: { projectId }, + orderBy: { createdAt: "desc" }, + take: 250 + }); + + await redis().set(cacheKey, tasks, { ex: 60 }); + await logInfo("api.project.tasks.list", { projectId, count: tasks.length }); + + return NextResponse.json({ source: "db", data: tasks }); + } catch (error) { + await logError("api.project.tasks.list.error", { + projectId, + message: error instanceof Error ? error.message : "Unknown error" + }); + return NextResponse.json({ error: "Failed to fetch tasks" }, { status: 500 }); + } +} + +export async function POST(request: Request, { params }: RouteContext) { + const { userId } = await auth(); + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { projectId } = await params; + + try { + const payload = (await request.json()) as { title?: string; description?: string }; + if (!payload.title) { + return NextResponse.json({ error: "title is required" }, { status: 400 }); + } + + const task = await prisma.task.create({ + data: { + title: payload.title, + description: payload.description ?? null, + projectId + } + }); + + await redis().del(`project:${projectId}:tasks`); + await logInfo("api.project.tasks.create", { projectId, taskId: task.id, actor: userId }); + + return NextResponse.json({ data: task }, { status: 201 }); + } catch (error) { + await logError("api.project.tasks.create.error", { + projectId, + message: error instanceof Error ? error.message : "Unknown error" + }); + return NextResponse.json({ error: "Failed to create task" }, { status: 500 }); + } +} diff --git a/worklenz-next/app/api/projects/route.ts b/worklenz-next/app/api/projects/route.ts new file mode 100644 index 000000000..a11dcb26b --- /dev/null +++ b/worklenz-next/app/api/projects/route.ts @@ -0,0 +1,72 @@ +import { NextRequest, NextResponse } from "next/server"; +import { isOneOfRoles } from "@/lib/auth/roles"; +import { prisma } from "@/lib/db/prisma"; +import { requireUserProfile } from "@/lib/users/profile"; +import { logError, logInfo } from "@/lib/logging/axiom"; + +export async function GET(request: NextRequest) { + const profile = await requireUserProfile(); + if (!profile) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const officeId = request.nextUrl.searchParams.get("office_id"); + + try { + const projects = await prisma.project.findMany({ + where: officeId ? { officeId } : {}, + include: { + office: { select: { id: true, code: true, name: true } }, + _count: { select: { tasks: true } } + }, + orderBy: { createdAt: "desc" } + }); + + return NextResponse.json({ data: projects }); + } catch (error) { + await logError("api.projects.list.error", { + message: error instanceof Error ? error.message : "Unknown error" + }); + return NextResponse.json({ error: "Failed to fetch projects" }, { status: 500 }); + } +} + +export async function POST(request: NextRequest) { + const profile = await requireUserProfile(); + if (!profile) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const allowed = await isOneOfRoles(["owner", "admin", "managing_director"]); + if (!allowed) { + return NextResponse.json({ error: "Unauthorized" }, { status: 403 }); + } + + try { + const body = (await request.json()) as { + name?: string; + code?: string; + office_id?: string; + }; + + if (!body.name?.trim() || !body.code?.trim()) { + return NextResponse.json({ error: "name and code are required" }, { status: 400 }); + } + + const project = await prisma.project.create({ + data: { + name: body.name.trim(), + code: body.code.trim().toUpperCase(), + officeId: body.office_id ?? null + } + }); + + await logInfo("api.projects.create", { projectId: project.id, actor: profile.id }); + return NextResponse.json({ data: project }, { status: 201 }); + } catch (error) { + await logError("api.projects.create.error", { + message: error instanceof Error ? error.message : "Unknown error" + }); + return NextResponse.json({ error: "Failed to create project" }, { status: 500 }); + } +} diff --git a/worklenz-next/app/api/realtime/broadcast/route.ts b/worklenz-next/app/api/realtime/broadcast/route.ts new file mode 100644 index 000000000..188e5948c --- /dev/null +++ b/worklenz-next/app/api/realtime/broadcast/route.ts @@ -0,0 +1,32 @@ +import { auth } from "@clerk/nextjs/server"; +import { NextResponse } from "next/server"; +import { publishRealtimeEvent } from "@/lib/realtime/publish"; + +type BroadcastPayload = { + channel: string; + event: string; + payload: Record; +}; + +export async function POST(request: Request) { + const { userId } = await auth(); + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = (await request.json()) as BroadcastPayload; + if (!body.channel || !body.event || !body.payload) { + return NextResponse.json({ error: "Invalid payload" }, { status: 400 }); + } + + const status = await publishRealtimeEvent({ + channel: body.channel, + event: body.event, + payload: { + ...body.payload, + actor: userId + } + }); + + return NextResponse.json({ ok: true, status }); +} diff --git a/worklenz-next/app/api/tasks/[taskId]/query-logs/route.ts b/worklenz-next/app/api/tasks/[taskId]/query-logs/route.ts new file mode 100644 index 000000000..d467094de --- /dev/null +++ b/worklenz-next/app/api/tasks/[taskId]/query-logs/route.ts @@ -0,0 +1,56 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/db/prisma"; +import { requireUserProfile } from "@/lib/users/profile"; + +type RouteContext = { + params: Promise<{ taskId: string }>; +}; + +export async function GET(_: NextRequest, { params }: RouteContext) { + const profile = await requireUserProfile(); + if (!profile) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { taskId } = await params; + const rows = await prisma.taskQueryLog.findMany({ + where: { taskId }, + include: { + raisedBy: { select: { id: true, fullName: true } }, + respondedBy: { select: { id: true, fullName: true } } + }, + orderBy: { createdAt: "desc" } + }); + + return NextResponse.json({ data: rows }); +} + +export async function POST(request: NextRequest, { params }: RouteContext) { + const profile = await requireUserProfile(); + if (!profile) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { taskId } = await params; + const body = (await request.json()) as { + drawing_ref?: string; + description?: string; + impact?: string; + }; + + if (!body.description?.trim()) { + return NextResponse.json({ error: "description is required" }, { status: 400 }); + } + + const created = await prisma.taskQueryLog.create({ + data: { + taskId, + drawingRef: body.drawing_ref?.trim() || null, + description: body.description.trim(), + impact: body.impact?.trim() || null, + raisedById: profile.id + } + }); + + return NextResponse.json({ data: created }); +} diff --git a/worklenz-next/app/api/tasks/[taskId]/route.ts b/worklenz-next/app/api/tasks/[taskId]/route.ts new file mode 100644 index 000000000..39fd96577 --- /dev/null +++ b/worklenz-next/app/api/tasks/[taskId]/route.ts @@ -0,0 +1,59 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/db/prisma"; +import { requireUserProfile } from "@/lib/users/profile"; +import { redis } from "@/lib/cache/redis"; +import { logInfo } from "@/lib/logging/axiom"; + +type RouteContext = { + params: Promise<{ taskId: string }>; +}; + +const ALLOWED_STATUSES = ["ASSIGNED", "ON_HOLD", "REVISION_REQUIRED", "SUBMITTED", "APPROVED", "REJECTED"]; + +export async function PATCH(request: NextRequest, { params }: RouteContext) { + const profile = await requireUserProfile(); + if (!profile) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { taskId } = await params; + const task = await prisma.task.findUnique({ where: { id: taskId } }); + if (!task) { + return NextResponse.json({ error: "Task not found" }, { status: 404 }); + } + + const body = (await request.json()) as { + title?: string; + description?: string | null; + status?: string; + assignee_id?: string | null; + planned_rate?: number | null; + actual_rate?: number | null; + time_spent_minute?: number; + }; + + if (body.status && !ALLOWED_STATUSES.includes(body.status)) { + return NextResponse.json({ error: "Invalid status" }, { status: 400 }); + } + + const updated = await prisma.task.update({ + where: { id: taskId }, + data: { + ...(body.title ? { title: body.title.trim() } : {}), + ...(body.description !== undefined ? { description: body.description } : {}), + ...(body.status ? { status: body.status } : {}), + ...(body.assignee_id !== undefined ? { assigneeId: body.assignee_id } : {}), + ...(body.planned_rate !== undefined ? { plannedRate: body.planned_rate } : {}), + ...(body.actual_rate !== undefined ? { actualRate: body.actual_rate } : {}), + ...(body.time_spent_minute !== undefined ? { timeSpentMinute: body.time_spent_minute } : {}) + }, + include: { + assignee: { select: { id: true, fullName: true, email: true } } + } + }); + + await redis().del(`project:${task.projectId}:tasks`); + await logInfo("api.tasks.update", { taskId, actor: profile.id }); + + return NextResponse.json({ data: updated }); +} diff --git a/worklenz-next/app/api/tasks/query-logs/[id]/respond/route.ts b/worklenz-next/app/api/tasks/query-logs/[id]/respond/route.ts new file mode 100644 index 000000000..9d3a1b427 --- /dev/null +++ b/worklenz-next/app/api/tasks/query-logs/[id]/respond/route.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from "next/server"; +import { isOneOfRoles } from "@/lib/auth/roles"; +import { prisma } from "@/lib/db/prisma"; +import { requireUserProfile } from "@/lib/users/profile"; + +type RouteContext = { + params: Promise<{ id: string }>; +}; + +export async function POST(request: NextRequest, { params }: RouteContext) { + const profile = await requireUserProfile(); + if (!profile) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const allowed = await isOneOfRoles(["owner", "admin", "managing_director", "senior_qs"]); + if (!allowed) { + return NextResponse.json({ error: "Unauthorized" }, { status: 403 }); + } + + const { id } = await params; + const body = (await request.json()) as { response?: string; resolved?: boolean }; + + if (!body.response?.trim()) { + return NextResponse.json({ error: "response is required" }, { status: 400 }); + } + + const updated = await prisma.taskQueryLog.update({ + where: { id }, + data: { + response: body.response.trim(), + respondedById: profile.id, + respondedAt: new Date(), + ...(body.resolved !== undefined ? { resolved: body.resolved } : {}) + } + }); + + return NextResponse.json({ data: updated }); +} diff --git a/worklenz-next/app/api/users/me/route.ts b/worklenz-next/app/api/users/me/route.ts new file mode 100644 index 000000000..e4f7f7d13 --- /dev/null +++ b/worklenz-next/app/api/users/me/route.ts @@ -0,0 +1,11 @@ +import { NextResponse } from "next/server"; +import { requireUserProfile } from "@/lib/users/profile"; + +export async function GET() { + const profile = await requireUserProfile(); + if (!profile) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + return NextResponse.json({ data: profile }); +} diff --git a/worklenz-next/app/api/webhooks/clerk/route.ts b/worklenz-next/app/api/webhooks/clerk/route.ts new file mode 100644 index 000000000..72fcb925d --- /dev/null +++ b/worklenz-next/app/api/webhooks/clerk/route.ts @@ -0,0 +1,85 @@ +import { Webhook } from "svix"; +import { headers } from "next/headers"; +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/db/prisma"; +import { logError, logInfo } from "@/lib/logging/axiom"; + +type ClerkUserPayload = { + id: string; + email_addresses: { email_address: string }[]; + first_name: string | null; + last_name: string | null; + public_metadata: Record; +}; + +type ClerkWebhookEvent = { + type: string; + data: ClerkUserPayload; +}; + +export async function POST(request: Request) { + const h = await headers(); + const svixId = h.get("svix-id"); + const svixTimestamp = h.get("svix-timestamp"); + const svixSignature = h.get("svix-signature"); + + if (!svixId || !svixTimestamp || !svixSignature) { + return NextResponse.json({ error: "Missing svix headers" }, { status: 400 }); + } + + const secret = process.env.CLERK_WEBHOOK_SECRET; + if (!secret) { + return NextResponse.json({ error: "Webhook not configured" }, { status: 500 }); + } + + const body = await request.text(); + const wh = new Webhook(secret); + + let event: ClerkWebhookEvent; + try { + event = wh.verify(body, { + "svix-id": svixId, + "svix-timestamp": svixTimestamp, + "svix-signature": svixSignature + }) as ClerkWebhookEvent; + } catch { + return NextResponse.json({ error: "Invalid signature" }, { status: 400 }); + } + + if (event.type === "user.created" || event.type === "user.updated") { + const clerkUser = event.data; + const email = clerkUser.email_addresses[0]?.email_address; + + if (!email) { + return NextResponse.json({ ok: true }); + } + + const fullName = + [clerkUser.first_name, clerkUser.last_name].filter(Boolean).join(" ") || null; + const metaRole = clerkUser.public_metadata?.role; + const role = typeof metaRole === "string" ? metaRole : "qs"; + + try { + await prisma.userProfile.upsert({ + where: { clerkId: clerkUser.id }, + update: { email, fullName, role }, + create: { clerkId: clerkUser.id, email, fullName, role } + }); + + await logInfo("webhook.clerk.userSync", { + clerkId: clerkUser.id, + email, + role, + event: event.type + }); + } catch (error) { + await logError("webhook.clerk.userSync.error", { + clerkId: clerkUser.id, + message: error instanceof Error ? error.message : "Unknown error" + }); + return NextResponse.json({ error: "Sync failed" }, { status: 500 }); + } + } + + return NextResponse.json({ ok: true }); +} diff --git a/worklenz-next/app/globals.css b/worklenz-next/app/globals.css new file mode 100644 index 000000000..19ad31809 --- /dev/null +++ b/worklenz-next/app/globals.css @@ -0,0 +1,24 @@ +:root { + --bg: #f8fafc; + --surface: #ffffff; + --text: #0f172a; + --muted: #64748b; + --border: #e2e8f0; + --brand: #2563eb; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: "Inter", "Segoe UI", system-ui, -apple-system, sans-serif; + background: var(--bg); + color: var(--text); +} + +a { + color: inherit; + text-decoration: none; +} diff --git a/worklenz-next/app/layout.tsx b/worklenz-next/app/layout.tsx new file mode 100644 index 000000000..caf8305ab --- /dev/null +++ b/worklenz-next/app/layout.tsx @@ -0,0 +1,19 @@ +import type { Metadata } from "next"; +import { ClerkProvider } from "@clerk/nextjs"; +import { ReactNode } from "react"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "Prelim", + description: "Prelim — project management built on Next.js, Clerk, and Prisma" +}; + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + + {children} + + + ); +} diff --git a/worklenz-next/app/page.tsx b/worklenz-next/app/page.tsx new file mode 100644 index 000000000..0579c7479 --- /dev/null +++ b/worklenz-next/app/page.tsx @@ -0,0 +1,12 @@ +import { auth } from "@clerk/nextjs/server"; +import { redirect } from "next/navigation"; + +export default async function IndexPage() { + const { userId } = await auth(); + + if (!userId) { + redirect("/sign-in"); + } + + redirect("/worklenz/home"); +} diff --git a/worklenz-next/app/worklenz/attendance/page.tsx b/worklenz-next/app/worklenz/attendance/page.tsx new file mode 100644 index 000000000..16bb800ae --- /dev/null +++ b/worklenz-next/app/worklenz/attendance/page.tsx @@ -0,0 +1,111 @@ +import { requireUserProfile } from "@/lib/users/profile"; +import { prisma } from "@/lib/db/prisma"; +import { AttendanceForm } from "@/components/attendance/attendance-form"; + +const ATTENDANCE_BADGES: Record = { + present: { bg: "#f6ffed", color: "#52c41a", label: "Present" }, + absent: { bg: "#fff1f0", color: "#ff4d4f", label: "Absent" }, + half_day: { bg: "#fff7e6", color: "#fa8c16", label: "Half Day" }, + on_leave: { bg: "#f0f5ff", color: "#2f54eb", label: "On Leave" } +}; + +export default async function AttendancePage() { + const profile = await requireUserProfile(); + if (!profile) return null; + + const records = await prisma.attendance.findMany({ + where: { userId: profile.id }, + orderBy: { workDate: "desc" }, + take: 30, + include: { office: { select: { name: true, code: true } } } + }); + + return ( +
+

Attendance

+ + + +

Recent Records

+ {records.length === 0 ? ( +

No attendance records yet.

+ ) : ( +
+ + + + + + + + + + + + {records.map((rec) => { + const badge = ATTENDANCE_BADGES[rec.status] ?? { + bg: "#f5f5f5", + color: "#595959", + label: rec.status + }; + return ( + + + + + + + + ); + })} + +
DateStatusCheck-inOfficeReason
{rec.workDate.toISOString().slice(0, 10)} + + {badge.label} + + {rec.checkInTime.toISOString().slice(11, 16)}{rec.office?.name ?? "—"}{rec.reason ?? "—"}
+
+ )} +
+ ); +} + +function Th({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +function Td({ children }: { children: React.ReactNode }) { + return ( + {children} + ); +} diff --git a/worklenz-next/app/worklenz/gantt-demo/page.tsx b/worklenz-next/app/worklenz/gantt-demo/page.tsx new file mode 100644 index 000000000..9740ee114 --- /dev/null +++ b/worklenz-next/app/worklenz/gantt-demo/page.tsx @@ -0,0 +1,8 @@ +export default function GanttDemoPage() { + return ( +
+

Gantt Demo

+

Placeholder for migrated Gantt view.

+
+ ); +} diff --git a/worklenz-next/app/worklenz/home/page.tsx b/worklenz-next/app/worklenz/home/page.tsx new file mode 100644 index 000000000..46317f91f --- /dev/null +++ b/worklenz-next/app/worklenz/home/page.tsx @@ -0,0 +1,170 @@ +import { requireUserProfile } from "@/lib/users/profile"; +import { prisma } from "@/lib/db/prisma"; + +const STATUS_COLORS: Record = { + ASSIGNED: { bg: "#e8f4fd", color: "#1890ff" }, + SUBMITTED: { bg: "#f0f5ff", color: "#2f54eb" }, + REVISION_REQUIRED: { bg: "#fff7e6", color: "#fa8c16" }, + ON_HOLD: { bg: "#f5f5f5", color: "#8c8c8c" }, + APPROVED: { bg: "#f6ffed", color: "#52c41a" }, + REJECTED: { bg: "#fff1f0", color: "#ff4d4f" } +}; + +const STATUS_LABELS: Record = { + ASSIGNED: "Assigned", + SUBMITTED: "Submitted", + REVISION_REQUIRED: "Revision Required", + ON_HOLD: "On Hold", + APPROVED: "Approved", + REJECTED: "Rejected" +}; + +const ATTENDANCE_COLORS: Record = { + present: "#52c41a", + half_day: "#fa8c16", + on_leave: "#2f54eb", + absent: "#ff4d4f" +}; + +export default async function HomePage() { + const profile = await requireUserProfile(); + if (!profile) return null; + + const today = new Date(new Date().toISOString().slice(0, 10) + "T00:00:00.000Z"); + const tomorrow = new Date(today); + tomorrow.setDate(today.getDate() + 1); + + const [myTasksCount, submittedCount, projectsCount, attendanceToday, recentTasks] = + await Promise.all([ + prisma.task.count({ + where: { + assigneeId: profile.id, + status: { in: ["ASSIGNED", "REVISION_REQUIRED", "ON_HOLD"] } + } + }), + prisma.task.count({ where: { status: "SUBMITTED" } }), + prisma.project.count(), + prisma.attendance.findFirst({ + where: { userId: profile.id, workDate: { gte: today, lt: tomorrow } } + }), + prisma.task.findMany({ + where: { assigneeId: profile.id }, + orderBy: { updatedAt: "desc" }, + take: 5, + include: { project: { select: { id: true, name: true, code: true } } } + }) + ]); + + const attendanceColor = attendanceToday + ? (ATTENDANCE_COLORS[attendanceToday.status] ?? "#52c41a") + : "#ff4d4f"; + + return ( +
+

Home

+

+ Welcome back, {profile.fullName ?? profile.email} +

+ + {/* Stats */} +
+ + + + +
+ + {/* Recent Tasks */} +

My Recent Tasks

+ {recentTasks.length === 0 ? ( +

No tasks assigned yet.

+ ) : ( +
+ {recentTasks.map((task) => { + const c = STATUS_COLORS[task.status] ?? { bg: "#f5f5f5", color: "#595959" }; + return ( +
+
+

{task.title}

+

+ {task.project.name} · {task.project.code} +

+
+ + {STATUS_LABELS[task.status] ?? task.status} + +
+ ); + })} +
+ )} +
+ ); +} + +function StatCard({ + label, + value, + color +}: { + label: string; + value: string | number; + color: string; +}) { + return ( +
+

+ {label} +

+

{value}

+
+ ); +} diff --git a/worklenz-next/app/worklenz/layout.tsx b/worklenz-next/app/worklenz/layout.tsx new file mode 100644 index 000000000..2ec828616 --- /dev/null +++ b/worklenz-next/app/worklenz/layout.tsx @@ -0,0 +1,45 @@ +import Link from "next/link"; +import { UserButton } from "@clerk/nextjs"; +import { ReactNode } from "react"; + +const navItems = [ + { href: "/worklenz/home", label: "Home" }, + { href: "/worklenz/projects", label: "Projects" }, + { href: "/worklenz/attendance", label: "Attendance" }, + { href: "/worklenz/review-queue", label: "Review Queue" }, + { href: "/worklenz/schedule", label: "Schedule" }, + { href: "/worklenz/gantt-demo", label: "Gantt Demo" } +]; + +export default function WorklenzLayout({ children }: { children: ReactNode }) { + return ( +
+ +
+
+ +
+
{children}
+
+
+ ); +} diff --git a/worklenz-next/app/worklenz/page.tsx b/worklenz-next/app/worklenz/page.tsx new file mode 100644 index 000000000..3c6ae542b --- /dev/null +++ b/worklenz-next/app/worklenz/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function WorklenzIndexPage() { + redirect("/worklenz/home"); +} diff --git a/worklenz-next/app/worklenz/projects/[projectId]/page.tsx b/worklenz-next/app/worklenz/projects/[projectId]/page.tsx new file mode 100644 index 000000000..3b19833ba --- /dev/null +++ b/worklenz-next/app/worklenz/projects/[projectId]/page.tsx @@ -0,0 +1,55 @@ +import { notFound } from "next/navigation"; +import { prisma } from "@/lib/db/prisma"; +import { requireUserProfile } from "@/lib/users/profile"; +import { KanbanBoard } from "@/components/kanban/kanban-board"; +import type { SerializedTask } from "@/components/kanban/task-card"; + +type ProjectPageProps = { + params: Promise<{ projectId: string }>; +}; + +export default async function ProjectPage({ params }: ProjectPageProps) { + await requireUserProfile(); + const { projectId } = await params; + + const [project, rawTasks] = await Promise.all([ + prisma.project.findUnique({ where: { id: projectId } }), + prisma.task.findMany({ + where: { projectId }, + include: { assignee: { select: { id: true, fullName: true, email: true } } }, + orderBy: { createdAt: "desc" } + }) + ]); + + if (!project) notFound(); + + const tasks: SerializedTask[] = rawTasks.map((t) => ({ + id: t.id, + title: t.title, + description: t.description, + status: t.status, + projectId: t.projectId, + assigneeId: t.assigneeId, + reviewComment: t.reviewComment, + reviewOutcome: t.reviewOutcome, + submittedAt: t.submittedAt?.toISOString() ?? null, + reviewedAt: t.reviewedAt?.toISOString() ?? null, + revisionCount: t.revisionCount, + timeSpentMinute: t.timeSpentMinute, + plannedRate: t.plannedRate, + actualRate: t.actualRate, + efficiency: t.efficiency, + variance: t.variance, + createdAt: t.createdAt.toISOString(), + updatedAt: t.updatedAt.toISOString(), + assignee: t.assignee + })); + + return ( + + ); +} diff --git a/worklenz-next/app/worklenz/projects/page.tsx b/worklenz-next/app/worklenz/projects/page.tsx new file mode 100644 index 000000000..ea2d1ab7c --- /dev/null +++ b/worklenz-next/app/worklenz/projects/page.tsx @@ -0,0 +1,13 @@ +import Link from "next/link"; + +export default function ProjectsPage() { + return ( +
+

Projects

+

Project list route migrated from React Router to Next.js page routing.

+ + Open sample project route + +
+ ); +} diff --git a/worklenz-next/app/worklenz/review-queue/page.tsx b/worklenz-next/app/worklenz/review-queue/page.tsx new file mode 100644 index 000000000..35b2cd7aa --- /dev/null +++ b/worklenz-next/app/worklenz/review-queue/page.tsx @@ -0,0 +1,38 @@ +import { requireOneOfRoles } from "@/lib/auth/roles"; +import { prisma } from "@/lib/db/prisma"; +import { ReviewQueueClient, type ReviewTask } from "@/components/review/review-queue-client"; + +export default async function ReviewQueuePage() { + await requireOneOfRoles(["owner", "admin", "managing_director", "senior_qs"]); + + const rawTasks = await prisma.task.findMany({ + where: { status: { in: ["SUBMITTED", "Submitted", "submitted"] } }, + include: { + project: { select: { id: true, name: true, code: true } }, + assignee: { select: { id: true, fullName: true, email: true } } + }, + orderBy: [{ submittedAt: "desc" }, { updatedAt: "desc" }] + }); + + const tasks: ReviewTask[] = rawTasks.map((t) => ({ + id: t.id, + title: t.title, + description: t.description, + status: t.status, + revisionCount: t.revisionCount, + submittedAt: t.submittedAt?.toISOString() ?? null, + reviewComment: t.reviewComment, + project: t.project, + assignee: t.assignee + })); + + return ( +
+

Review Queue

+

+ {tasks.length} task{tasks.length !== 1 ? "s" : ""} awaiting review +

+ +
+ ); +} diff --git a/worklenz-next/app/worklenz/schedule/page.tsx b/worklenz-next/app/worklenz/schedule/page.tsx new file mode 100644 index 000000000..909bfce2b --- /dev/null +++ b/worklenz-next/app/worklenz/schedule/page.tsx @@ -0,0 +1,12 @@ +import { requireOneOfRoles } from "@/lib/auth/roles"; + +export default async function SchedulePage() { + await requireOneOfRoles(["owner", "admin"]); + + return ( +
+

Schedule

+

Role-protected route with Clerk role metadata validation.

+
+ ); +} diff --git a/worklenz-next/app/worklenz/unauthorized/page.tsx b/worklenz-next/app/worklenz/unauthorized/page.tsx new file mode 100644 index 000000000..8afd1950d --- /dev/null +++ b/worklenz-next/app/worklenz/unauthorized/page.tsx @@ -0,0 +1,8 @@ +export default function UnauthorizedPage() { + return ( +
+

Unauthorized

+

You do not have permission to access this route.

+
+ ); +} diff --git a/worklenz-next/components/attendance/attendance-form.tsx b/worklenz-next/components/attendance/attendance-form.tsx new file mode 100644 index 000000000..582be0032 --- /dev/null +++ b/worklenz-next/components/attendance/attendance-form.tsx @@ -0,0 +1,153 @@ +"use client"; + +import { useState } from "react"; + +const STATUSES = [ + { value: "present", label: "Present" }, + { value: "absent", label: "Absent" }, + { value: "half_day", label: "Half Day" }, + { value: "on_leave", label: "On Leave" } +]; + +export function AttendanceForm() { + const [date, setDate] = useState(() => new Date().toISOString().slice(0, 10)); + const [status, setStatus] = useState("present"); + const [reason, setReason] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [success, setSuccess] = useState(false); + const [error, setError] = useState(null); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setSubmitting(true); + setError(null); + setSuccess(false); + try { + const res = await fetch("/api/attendance", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + attendance_date: date, + status, + reason: reason.trim() || undefined + }) + }); + const json = await res.json() as { error?: string }; + if (!res.ok) { + setError(json.error ?? "Failed to log attendance"); + return; + } + setSuccess(true); + } catch { + setError("Network error"); + } finally { + setSubmitting(false); + } + }; + + const needsReason = status === "absent" || status === "on_leave"; + + return ( +
+

Log Attendance

+
+
+ + setDate(e.target.value)} + style={{ + display: "block", + padding: "7px 10px", + border: "1px solid #d9d9d9", + borderRadius: 6, + fontSize: 14, + width: "100%" + }} + /> +
+ +
+ + +
+ + {needsReason && ( +
+ +