← Projects

What’s Next
Please

Supervisors and agents went from coordinating work across disconnected tools to a single platform where every task has an owner, a status, a time log, a comment thread, a file trail, and real-time notifications — whether the tab is open or not.

Client Hill Country Coders
Role Lead Engineer
Date June 2024
Task supervisor dashboard showing all tasks with status, priority, and assignee columns
Client dashboard task detail view
Agent task open view with comment thread and file attachments

The Problem

No one knew
who owned what.

A single organization was running task delegation, client requests, and agent assignments through a combination of informal tools — no unified system for tracking who owns what, no visibility into task status across roles, and no audit trail for time spent or work completed.

Supervisors had no structured way to match tasks to agents by skill. Clients had no self-service portal to submit or track requests. There was no notification layer to alert anyone when state changed. The result: coordination overhead, missed deadlines, and no data to review performance.

Requirements I Gathered

Ask the right
questions first.

Before architecture was decided, these questions shaped the design:

  1. Who are the actors and what can each do?

    Surfaced 6 distinct roles: Super User, Task Supervisor, Task Agent, Client, District/Territory Manager, Account Executive — each needing a separate dashboard and permission set.

  2. Who creates tasks, and who resolves them?

    Tasks can originate from both internal users and clients. This distinction had to be captured in the schema: creatorType, createdByUserId vs createdByClientId.

  3. How do we identify tasks externally?

    Clients and agents need a human-readable reference, not a UUID. Led to a serial number system with per-category prefixes — e.g. WD-00042.

  4. What does “real-time” mean here?

    Notifications needed to update the UI without polling. EventSource incompatibility with httpOnly cookies had to be resolved — which drove the single-use SSE token design.

  5. What’s the auth migration path?

    Existing users on legacy JWT needed transparent migration to Keycloak/Cognito without forced re-registration.

  6. What are the scale expectations?

    Single-organization internal tool, not multi-tenant at the data layer. Not discussed explicitly — noted as a future constraint if the product scope expands.

Architecture Decisions

Five calls that
shaped the system.

arch-01 monorepo-with-incremental-backend-extraction
Decision Keep Next.js routes running alongside a standalone Express backend; migrate module by module rather than big-bang rewrite.
Why Reduced risk — old routes stay functional while new ones are built properly in Express; team ships features during migration.
Rejected Full rewrite upfront — too risky with active users and no feature freeze.
arch-02 bff-proxy-pattern-for-frontend-backend-communication
Decision All frontend API calls route through a Next.js proxy route (/api/proxy/[...path]) that reads the httpOnly cookie server-side and forwards requests with an Authorization header.
Why Keeps the token out of client-side JS while allowing the Express backend to remain stateless and token-based.
Rejected Direct client-to-backend calls with localStorage tokens — exposes tokens to XSS. getServerSideProps per-route pattern — too much boilerplate per module.
arch-03 single-use-sse-token-instead-of-proxying-the-stream
Decision A sseTokenService issues a short-lived (60s, single-use) token fetched via the proxy; the EventSource connects directly to the Express backend using that token.
Why EventSource API does not support custom headers — httpOnly cookies are invisible to it, and proxying a long-lived stream through Next.js risks Vercel timeout limits.
Rejected Proxying SSE through Next.js — functionally broken on Vercel due to streaming timeouts. Using localStorage for the main token — security downgrade.
arch-04 idp-sync-at-the-controller-layer-only
Decision Keycloak/Cognito user creation happens in the auth controller, not the service layer.
Why The controller is the only layer that holds the plaintext password before it’s hashed; the service layer never sees it.
Rejected IDP sync inside the service — would require passing plaintext password deeper into the stack, violating separation of concerns.
arch-05 dual-cdk-stacks-for-different-deployment-lifecycles
Decision One CDK stack for ECS/Fargate backend deployment; a separate stack in apps/web/cdk for S3 + CloudFront image serving.
Why Different lifecycles — image infrastructure changes rarely; backend deploys on every push. Separating them avoids unnecessary CloudFront invalidations on code deploys.
Rejected Single CDK stack — tight coupling between infra that has no reason to change together.

Problems Hit

Five bugs worth
writing down.

cognito-admin-api-called-with-sub-instead-of-username

Symptom Profile updates silently failed in production; Cognito returned user-not-found errors.
Root cause The Cognito Admin API requires the login username string, not the sub UUID — but the code was passing sub.
Fix Updated the Cognito service to pass the username string; added targeted logging at the IDP call site to catch this class of error earlier.

cursor-in-useCallback-deps-causing-infinite-fetch-loop

Symptom Paginated data fetch triggered on every render, hammering the backend.
Root cause cursor was included in the useCallback deps array; each fetch updated cursor, which re-triggered the callback.
Fix Removed cursor from the dependency array; fetch is now triggered explicitly, not reactively off cursor state.

DELETE-requests-silently-dropped-request-body

Symptom Bulk delete operations arrived at the backend with no payload.
Root cause The proxy middleware excluded DELETE from body-forwarding logic, assuming REST convention (DELETE has no body).
Fix Updated proxy to forward body on DELETE as well; documented as a non-standard but valid HTTP pattern.

FormData-being-JSON.stringify-d-in-the-API-client

Symptom File uploads failed; multipart boundary was not set correctly.
Root cause The apiClient serialized all bodies with JSON.stringify; FormData requires the browser to set the Content-Type with the correct boundary automatically.
Fix Added FormData instance detection to skip serialization and omit the Content-Type header on those requests.

pnpm-dlx-prisma-version-mismatch-in-Docker-CI

Symptom Migrations ran with a different Prisma CLI version than what was installed in the project, causing subtle schema drift.
Root cause pnpm dlx bypasses the lockfile and fetches the latest CLI version.
Fix Switched all migration commands to use the locally installed pnpm prisma binary.

What Shipped

The full scope
of what went live.

Auth & Access

  • 6 role-based dashboards

    Super User, Task Supervisor, Task Agent, Client, District Manager, Account Executive — distinct permission sets and UI layouts per role.

  • Hybrid IDP auth

    Legacy JWT, Keycloak (dev), and Cognito (staging/prod) with transparent auto-migration on login.

  • BFF proxy route

    /api/proxy/[...path] handling GET/POST/PUT/PATCH/DELETE with auth forwarding. Token never touches client JS.

Task Management

  • Status workflow

    NEW → IN_PROGRESS → REVIEW → COMPLETED/BLOCKED/OVERDUE with priority levels and skill tagging.

  • Serial number system

    Per-category prefix sequences (e.g. WD-00042), atomic DB increments, format validation, and backfill migration for existing tasks.

  • Work logging

    Time entries in Jira format (1w 2d 3h 30m), transactional aggregation into totalTimeSpent and latestTimeRemaining, progress bar, soft delete audit trail.

  • Batch operations

    Multi-select tasks, batch status/priority/assignee/category/due date update, batch delete, and CSV export.

Views & Filtering

  • Three task views

    Kanban (dnd-kit with per-column infinite scroll), Gantt (frappe-gantt with synchronized sidebar), and tabular list — switchable via query param.

  • Advanced filter builder

    Dynamic condition builder with per-field operator validation (eq, contains, in, between, isNull, etc.), AND/OR logic, and saved filter system (TaskViewFilter).

  • Global fuzzy search

    Fuse.js across navigation items and tasks from the top bar, cursor-based pagination, scroll-to-load-more.

Real-time & Notifications

  • SSE notification system

    Multi-tab support, tab-specific connection IDs, ping/keepalive, reconnect logic, and a connection status indicator component.

  • Push notifications

    Web Push API with VAPID, persistent subscription storage, and sw.js service worker — delivers notifications when the tab is inactive or closed.

  • Overdue task scheduler

    Node.js worker thread (overdueTasksWorker.ts) on a daily cron; transitions past-due tasks and notifies assignee and all Task Supervisors via SSE + push.

Content & Files

  • Rich text editor

    TipTap with full formatting, syntax-highlighted code blocks, @mention support with user search, link auto-extraction, and file attachments per comment.

  • Task comments system

    Threaded replies, edit/delete permissions, author-type polymorphism (USER/CLIENT), and mention notifications via SSE.

  • File handling

    Presigned S3 upload/download via AWS SDK, CloudFront serving, per-context ownership (task, profile, comment), 5 MB validation, file preview modal with zoom and PDF support.

  • In-app real-time chat

    Embedded via iframe, JWT-signed init token, cross-origin postMessage for auth refresh and notification sync, floating chat button with unread badge.

Infrastructure

  • Dual CDK stacks

    ECS/Fargate for backend, S3 + CloudFront for image serving — independent deployment lifecycles.

  • CI/CD pipelines

    GitHub Actions for staging and production; Vercel for frontend. Separate staging and production environments.

  • Shared packages

    @wnp/types (Zod schemas + TS types), @wnp/logger, typescript-config — single source of truth across the monorepo.

What I’d Do Differently

Two decisions
I’d revisit.

The two Prisma schemas (apps/web/prisma and apps/backend/prisma) being maintained separately — kept in sync manually with a note in the README — is the highest-risk part of this codebase. A single schema drift can cause silent type mismatches that only surface at runtime. The right call would have been a shared packages/database workspace from the start.

The chat integration via iframe and postMessage is the most fragile coupling in the system — any change to the external chat service’s origin, auth contract, or message protocol silently breaks the integration with no type safety. A versioned message schema shared between both apps, or building the chat on the same backend with WebSocket support, would have eliminated the cross-origin dependency entirely.

Have a system
worth building?