Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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
Expand Down
51 changes: 51 additions & 0 deletions docs/NEXTJS_MIGRATION_IMPLEMENTATION_STATUS_2026-04-25.md
Original file line number Diff line number Diff line change
@@ -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.
35 changes: 35 additions & 0 deletions worklenz-next/.env.example
Original file line number Diff line number Diff line change
@@ -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=
20 changes: 20 additions & 0 deletions worklenz-next/Dockerfile
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +9 to +10

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify .dockerignore exists and check Prisma generate hooks.
fd -H -t f '^\.dockerignore$' worklenz-next || echo "MISSING: worklenz-next/.dockerignore"
fd -H -t f '^package\.json$' worklenz-next --exec cat {}

Repository: Worklenz/worklenz

Length of output: 1113


🏁 Script executed:

# Check if .dockerignore exists and show its contents
find . -name ".dockerignore" -type f 2>/dev/null | head -20
# If found, show contents
if [ -f worklenz-next/.dockerignore ]; then
  echo "=== .dockerignore exists ===" 
  cat worklenz-next/.dockerignore
else
  echo "=== .dockerignore NOT found ==="
fi

Repository: Worklenz/worklenz

Length of output: 160


🏁 Script executed:

# Show full Dockerfile to understand build context
cat -n worklenz-next/Dockerfile 2>/dev/null || echo "Dockerfile not found"

Repository: Worklenz/worklenz

Length of output: 691


🏁 Script executed:

# Check for prisma schema or auto-generation configuration
find worklenz-next -name "prisma.schema" -o -name ".prismarc*" -o -name "prisma.config.*" 2>/dev/null | xargs ls -la
# Also check if prisma has any postinstall/prebuild configuration
cat worklenz-next/prisma/schema.prisma 2>/dev/null | head -30 || echo "Schema not found"

Repository: Worklenz/worklenz

Length of output: 2699


🌐 Web query:

Next.js 16 prisma generate automatic build hooks postinstall

💡 Result:

For Next.js 16 + Prisma on Vercel (serverless), you generally need to force Prisma Client generation during deployment because Vercel caches dependencies and may skip Prisma’s usual auto-generation/postinstall flow. What to do (recommended): 1) Add prisma generate to your package.json postinstall (or build) so it runs on every deployment. Example: { "scripts": { "postinstall": "prisma generate", "build": "next build" } } This matches Prisma’s recommended fix: Prisma notes that Vercel dependency caching can prevent the postinstall hook from running on subsequent deployments, leading to an outdated Prisma Client; their documented solutions include adding prisma generate to the postinstall script and/or prepending it to the build command. Alternative (build-time): { "scripts": { "build": "prisma generate && next build" } } Notes/edge cases: - Make sure you run prisma generate (as above) on the deployment/build environment where dependencies are installed, not only locally. - If prisma is a devDependency, deployments may fail with “prisma: command not found”; Prisma’s Vercel deployment guide mentions moving prisma from devDependencies to dependencies if needed. If you tell me your deployment target (Vercel vs another host) and package manager (pnpm/yarn/npm), I can tailor the exact scripts/commands.

Citations:


Add .dockerignore and ensure Prisma Client generation during build

Two critical issues:

  1. .dockerignore is missing from worklenz-next/. The COPY . . on line 9 will copy .env* files, .git/, node_modules/, and other local artifacts into the image, leaking secrets and increasing image size significantly. Create a .dockerignore file excluding at minimum: .env*, .git, node_modules, .next, dist, .turbo.

  2. The build script (npm run buildnext build) does not invoke prisma generate. Your package.json has prisma generate as a separate manual script with no postinstall or prebuild hook. Next.js 16 does not auto-trigger Prisma Client generation, so the build will fail or use stale/missing Prisma types. Add prisma generate to the build pipeline by either updating the build script to "build": "prisma generate && next build" or adding a postinstall hook "postinstall": "prisma generate".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@worklenz-next/Dockerfile` around lines 9 - 10, The Dockerfile uses COPY . .
and RUN npm run build but there's no .dockerignore and Prisma Client isn't
generated during image build; add a .dockerignore at worklenz-next to exclude
.env*, .git, node_modules, .next, dist, .turbo to avoid leaking secrets and
bloating the image, and modify package.json build pipeline to ensure Prisma
Client is generated during build by either changing the "build" script to run
"prisma generate && next build" or adding a "postinstall": "prisma generate"
hook so Prisma Client is available during 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"]
41 changes: 41 additions & 0 deletions worklenz-next/README.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions worklenz-next/app/(auth)/sign-in/[[...sign-in]]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { SignIn } from "@clerk/nextjs";

export default function SignInPage() {
return (
<main style={{ display: "grid", placeItems: "center", minHeight: "100vh" }}>
<SignIn />
</main>
);
}
9 changes: 9 additions & 0 deletions worklenz-next/app/(auth)/sign-up/[[...sign-up]]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { SignUp } from "@clerk/nextjs";

export default function SignUpPage() {
return (
<main style={{ display: "grid", placeItems: "center", minHeight: "100vh" }}>
<SignUp />
</main>
);
}
94 changes: 94 additions & 0 deletions worklenz-next/app/api/attendance/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
88 changes: 88 additions & 0 deletions worklenz-next/app/api/cron/daily-digest/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
Loading