Build a task board application where authenticated users create, organise, and track tasks across customisable columns.
You could start the project manually or use Makefile
The repo ships a Makefile that wraps every common workflow. Run make (no args) to print the full list of targets with descriptions.
# 1. one-shot onboarding: installs deps and copies env templates
make setup
# 2. edit backend/.env and frontend/.env (DATABASE_URL, AUTH0_*, etc.)
# 3. start Postgres + both dev servers (Ctrl-C kills both)
make devmake dev brings up Postgres via docker-compose and runs the backend (:3000) and frontend (:5173) dev servers in parallel. The first run won't have a schema yet — run make migrate in another terminal, then optionally make seed for demo data.
# 1. clone
git clone https://github.com/floraSimpleDev/Task-Board.git
cd Task-Board
# 2. install workspace deps (frontend + backend)
npm install
# 3. copy env templates
cp backend/.env.example backend/.env # fill in DATABASE_URL + AUTH0_*
cp frontend/.env.example frontend/.env # fill in VITE_AUTH0_* if present
npm install at the repo root installs both workspaces in one pass — there is no need to cd backend && npm install separately.
Run Postgres in Docker, run the dev servers on the host. Vite + Fastify hot-reload locally; only the database is containerized for dev.
# 1. start Postgres (matches DATABASE_URL in backend/.env.example)
docker compose up -d postgres
# 2. apply schema
npm run db:migrate -w backend
# 3. (optional) seed demo data
npm run db:seed -w backend
# 4. run dev servers in two terminals
npm run dev:backend # http://localhost:3000
npm run dev:frontend # http://localhost:5173npm run db:generate -w backend # generate SQL migration from schema changes
npm run db:migrate -w backend # apply pending migrations
npm run db:push -w backend # push schema directly (dev only — skips migration files)
npm run db:seed -w backend # idempotent demo data; only touches the seed user
cd backend
npm run db:studio # Drizzle Studio UI at https://local.drizzle.studioThe Fastify server exposes interactive API documentation while running in dev. With npm run dev:backend up, open:
http://localhost:3000/docs
The page lists every endpoint, the TypeBox-derived request/response schemas, and lets you fire authenticated requests against the running API (paste a Bearer JWT into the "Authorize" dialog at the top — get one from your browser's dev tools after signing in via Auth0 in the SPA, or via Auth0's "Test" tab).
Everything lives under k8s/. The k8s/manage.sh script wraps every operation a reviewer needs.
In addition to the top-level Prerequisites, you need a running local cluster — kind is the primary target, minikube also works:
# kind (recommended)
kind create cluster --name task-board
# OR minikube
minikube start./k8s/manage.sh deployWhat this does:
- Builds three local Docker images:
task-board-backend,task-board-backend-migrate(builder stage of the same Dockerfile, used by the migration Job),task-board-frontend. - Loads them into the running cluster (auto-detects kind vs minikube).
- Applies the namespace, generates Secrets from
k8s/.env, applies Postgres → migration Job → backend → frontend in dependency order. - Waits for each rollout and the migration Job before returning.
The deploy is idempotent — re-running reconciles via kubectl apply and recreates Jobs (which are immutable in Kubernetes).
The frontend Service is NodePort 30080.
# kind: requires extraPortMappings on the cluster, OR use port-forward:
kubectl port-forward -n taskboard service/frontend 8080:80
# → http://localhost:8080
# minikube:
minikube service -n taskboard frontend./k8s/manage.sh status # pods, services, PVCs, jobs
./k8s/manage.sh logs backend # tail backend logs
./k8s/manage.sh logs frontend # tail nginx logs
./k8s/manage.sh logs postgres # tail postgres logs
./k8s/manage.sh logs migrate # last migration Job logs
./k8s/manage.sh logs seed # last seed Job logs
./k8s/manage.sh migrate # re-run migrations
./k8s/manage.sh seed # load demo data (opt-in)
./k8s/manage.sh port-forward-db # localhost:5432 → cluster postgres
./k8s/manage.sh teardown # delete the namespace (drops all data)| Layer | Choice |
|---|---|
| Framework | React 19 |
| Build / dev | Vite 8 |
| Language | TypeScript |
| Styling | Tailwind CSS 4 (@tailwindcss/vite) |
| UI primitives | shadcn + Radix UI, class-variance-authority, clsx, tailwind-merge |
| Routing | React Router 7 |
| Data fetching | SWR |
| Validation | Zod |
| Drag and drop | @dnd-kit/core, @dnd-kit/sortable |
| Auth | @auth0/auth0-react (SPA SDK) |
| Icons / fonts | lucide-react, @fontsource-variable/geist |
| Lint / format | ESLint, Prettier (+ prettier-plugin-tailwindcss) |
| Layer | Choice |
|---|---|
| Runtime | Node.js 24 |
| Framework | Fastify 5 |
| Language | TypeScript |
| Schema / types | TypeBox (@sinclair/typebox) |
| ORM / migrations | Drizzle ORM + drizzle-kit |
| Database driver | pg (node-postgres) |
| Database | PostgreSQL 16 |
| Auth | @auth0/auth0-fastify-api (JWT verification via JWKS) |
| CORS | @fastify/cors |
| API docs | @fastify/swagger, @fastify/swagger-ui |
| Dev runner | tsx (tsx watch --env-file-if-exists=.env) |
| Lint / format | ESLint, Prettier |
| Layer | Choice |
|---|---|
| Containers | Multi-stage Docker (Alpine bases) |
| Local DB | docker-compose (postgres:16-alpine) |
| Orchestration | Kubernetes (kind primary, minikube supported) |
| Static serving | nginx (with envsubst-templated reverse proxy) |
| Workspaces | npm workspaces (frontend + backend) |
| Git hooks | husky + lint-staged + commitlint (Conventional Commits) |
The repo runs lint / type-check / format, you can run these in either frontend or backend:
npm run format # prettier --write .
npm run lint:fix # eslint . --fix
npm run type-check # tsc -b --noEmitOr run them in the root path of the monorepo like:
# from the repo root — runs against both workspaces
npm run lint:backend
npm run lint:frontend
npm run type-check:backend
npm run type-check:frontend
npm run format:backend
npm run format:frontend- Frontend is the only public entry point (NodePort)
- Backend and Postgres are internal-only (ClusterIP)
- Frontend nginx proxies API requests to backend
- Database schema is managed via Kubernetes Jobs
- Secrets are generated dynamically at deploy time
Browser
↓
http://localhost:30080
↓
frontend Service NodePort
↓
frontend Nginx pod
↓
/api/* proxy
↓
backend Service ClusterIP
↓
backend pod
↓
Postgres Service
↓
postgres StatefulSet podcp k8s/.env.example k8s/.env
# edit k8s/.env: set POSTGRES_PASSWORD, AUTH0_DOMAIN, AUTH0_AUDIENCE,
# AUTH0_CLIENT_ID, and AUTH0_REDIRECT_URIk8s/.env is gitignored. The script generates the postgres-credentials and backend-secrets Secrets from this file at deploy time, so no credentials are ever committed.
Secrets are generated at deploy time from a local .env file and never stored in Git.
This ensures sensitive data (DB password, Auth0 config) is not exposed in version control.
users (1) ──< boards (1) ──< board_columns (1) ──< tasks (1) ──< task_activities
│
users (1) ──────┘ (actor, nullable)
| Relationship | FK | onDelete |
|---|---|---|
boards.user_id |
→ users.id |
CASCADE |
board_columns.board_id |
→ boards.id |
CASCADE |
tasks.board_column_id |
→ board_columns.id |
CASCADE |
task_activities.task_id |
→ tasks.id |
CASCADE |
task_activities.actor_id |
→ users.id |
SET NULL |
Deleting a column also deletes every task in that column. Postgres enforces this at the database level via ON DELETE CASCADE on tasks.board_column_id — there is no application-layer cleanup, no soft-delete, and no orphaned-task possibility. The same rule chains upward: deleting a board cascade-deletes its columns, which in turn cascade-delete their tasks; deleting a user cascade-deletes everything they own.
Why cascade and not restrict / soft-delete:
- Simpler invariants. No code path can leave orphaned tasks — the schema makes the bad state unrepresentable.
- Matches user intent. When a user clicks "delete column", they expect the tasks to go with it; surfacing a "column has tasks, refuse" error would be poor UX for a personal kanban.
- Tradeoff: destructive and irreversible. A real product would want soft-delete + an "Undo" toast or a 30-day recovery window. For this scope, hard cascade is the honest choice.
boards_user_id_index— every per-user board lookup hits an index.board_columns_board_id_position_key—UNIQUE(board_id, position)prevents two columns at the same position.tasks_board_column_id_position_index— composite index on(board_column_id, position)powers the ordered-task query in getMyBoardWithColumns.task_activities_task_id_created_at_index— composite index on(task_id, created_at)powers the timeline query (filter by task, order by time).- Task
positionisnumeric(20, 10)— fractional indexing, so inserting between two tasks isO(1)(compute midpoint) rather than rewriting every position after the insertion point.
Two queries that go beyond simple CRUD:
1. Nested ordered fetch — getMyBoardWithColumns
Loads a board, all its columns ordered by position, and all tasks in each column ordered by position, scoped to the requesting userId — in a single round-trip via Drizzle's relational with: API:
database.query.boards.findFirst({
where: and(eq(boards.id, boardId), eq(boards.userId, userId)),
with: {
columns: {
orderBy: asc(boardColumns.position),
with: { tasks: { orderBy: asc(tasks.position) } },
},
},
})Why it matters: the naive implementation is N+1 (one query per column to fetch its tasks). The relational form lets Drizzle emit a single SQL statement that joins + aggregates, and the tasks_board_column_id_position_index keeps the ordered fetch cheap.
2. Deferred-constraint position swap — reorderColumns
Reordering columns has to update multiple position values at once. With a naive UPDATE, two rows would briefly share the same position, violating the UNIQUE(board_id, position) constraint. The repository uses Postgres's deferred-constraint feature inside a transaction:
await transaction.execute(sql`SET CONSTRAINTS "board_columns_board_id_position_key" DEFERRED`)
// ...UPDATEs that produce transiently-duplicate positions...
// constraint is re-checked at COMMIT — by then positions are unique againWhy it matters: avoids the alternatives (deleting + reinserting, or moving rows through a "parking" position), preserves the unique constraint, and keeps the operation atomic. Demonstrates a Postgres-specific feature (SET CONSTRAINTS ... DEFERRED) that most ORMs don't expose without escape hatches.
The backend verifies JWTs via Auth0's JWKS; the frontend uses the Auth0 SPA SDK. You need a free-tier Auth0 tenant with one Application and one API.
- Type: Single Page Application
- Allowed Callback URLs:
http://localhost:30080, http://localhost:5173 - Allowed Logout URLs:
http://localhost:30080, http://localhost:5173 - Allowed Web Origins:
http://localhost:30080, http://localhost:5173 - Copy
Domain→AUTH0_DOMAIN(frontend.envandk8s/.env) - Copy
Client ID→VITE_AUTH0_CLIENT_ID(frontend.envonly — used at build time)
- Identifier (audience): any URL-shaped string, e.g.
https://task-board.local/api - Copy that value →
AUTH0_AUDIENCE(backend/.envandk8s/.env)
Vite embeds env vars at build time. For local dev, put them in frontend/.env. For the K8s deploy, k8s/manage.sh reads AUTH0_DOMAIN, AUTH0_AUDIENCE, AUTH0_CLIENT_ID, and AUTH0_REDIRECT_URI from k8s/.env and passes them through as --build-arg VITE_AUTH0_*=... when building the frontend image — same source of truth for both paths.
The brief asks for "at least one route with role or permission-based access." This grew into a full admin surface — stats overview plus drill-down tables for users, boards, tasks, and activities — all gated by a single Auth0 permission.
| Route | Page | Backend endpoint | Pagination | Notes |
|---|---|---|---|---|
/admin |
AdminStatsPage | GET /admin/stats |
n/a | Four count cards (users, boards, tasks, activities); cards link to drill-downs |
/admin/users |
AdminUsersPage | GET /admin/users |
none | Flat list: name, email, joined |
/admin/boards |
AdminBoardsPage | GET /admin/boards |
none | Flat list with denormalized owner (name + email) |
/admin/tasks |
AdminTasksPage | GET /admin/tasks |
cursor | Title, board, owner, priority, created |
/admin/activities |
AdminActivitiesPage | GET /admin/activities |
cursor | Action, task + board, actor, summary, when |
All five endpoints share the same gate: requirePermission('read:admin-stats') — a Fastify preHandler that reads the permissions array from the verified access-token claims and returns 403 Forbidden if missing.
Auth0 RBAC normalizes roles into a flat permissions array on the token. Checking permissions.includes('read:admin-stats') is one line and works whether the user got the permission via a role assignment or a direct grant — the API doesn't care which.
The two high-cardinality lists (tasks, activities) use opaque cursor pagination instead of offset/page numbers:
- Cursor format: base64-encoded
{ createdAt, id }. Generated and validated at the API boundary via cursorPagination.ts using a TypeBox schema for runtime validation. - Stable ordering: rows are ordered by
(createdAt DESC, id DESC). Theidtie-breaker matters when two rows share acreatedAt(e.g. a batch insert). - Cursor predicate:
WHERE (createdAt < c.createdAt) OR (createdAt = c.createdAt AND id < c.id)— keyset pagination, index-friendly, noOFFSETscan. - "Has next" detection: the route fetches
limit + 1rows; if the slice is over-full, the last item is dropped from the response and its(createdAt, id)becomes the next cursor. - Query params:
?cursor=<opaque>&limit=<n>validated by the shared adminCursorQuerySchema (limit 1–100, default 25). - Frontend:
useSWRInfinitewraps each page; the page exposestasks/activities,hasMore,loadMore, andisLoadingMore. A page-level "Load more" button below the table appends results in place — no full-page refetch on next-page. - Why opaque base64 over
?createdAtBefore=&idBefore=: the client never constructs cursors; the API alone defines what a cursor means and can change the encoding (add fields, switch ordering) without breaking clients. Internally documented as{ createdAt, id }for debugging.
To keep the four list pages consistent and avoid drift:
DataTable— wrapper + table + thead shell shared by all four admin lists. Each page passesheadersand renders its own<tr>rows, which keeps cell formatting (multi-line owner, plain priority, relative timestamps) decoupled from the table chrome.renderActivitySummary— formatscreated/updated/movedactivity events into human-readable strings. Shared between the per-task timeline (TaskActivityList) and the admin activities table. The column-titles map is optional: per-task view passes one (somovedreads "from To Do to In Progress"); admin view omits it (falls back to "moved this task between columns") to avoid fetching every column.formatRelativeTime—Xm ago/Xh ago/Xd agoformatter, also shared between the two surfaces.adminCursorQuerySchema— single TypeBox query schema reused by/admin/tasksand/admin/activities.
- All admin pages render inside the same
AuthenticatedGuardas the rest of the app — auth gate first, permission gate via 403. - Header shows a conditional
Adminlink that renders only ifuseAdminStatssucceeds — see Header.tsx. SWR dedupes the request by key. - Each page distinguishes 403 ("you don't have permission to view X") from generic load failures via
error instanceof ApiError && error.status === 403, so non-admins hitting a deep link get a clear message instead of a blank screen. useAdminStats,useAdminUsers,useAdminBoardsuseuseSWRwithshouldRetryOnError: false(single 403, not SWR's default 5-retry storm).useAdminTasksanduseAdminActivitiesuseuseSWRInfinitewith the same option.
The permissions claim lives on the access token, which the Auth0 React SDK doesn't expose by default. We deliberately don't decode it client-side:
- No drift. The backend already validates the token and reads
permissions— the frontend just calls the endpoint and treats 403 as "hide this." One source of truth, no parallel client-side check that could disagree. - No token parsing in the browser. Decoding JWTs in the SPA invites bugs (signature verification skipped, alg confusion, etc.). The backend's JWKS-validated check is the only one that matters for security; the frontend's hide-on-403 is a UX optimization, not an authorization decision.
- Trade-off: every authenticated user makes one extra request per session to discover whether they're admin. Acceptable: the response is tiny, SWR caches it, and the alternative (decoded token) is worse on every dimension.
To actually grant a user read:admin-stats:
- APIs → your API → Settings: enable both Enable RBAC and Add Permissions in the Access Token.
- APIs → your API → Permissions: add
read:admin-statswith any description. - User Management → Users → pick the user → Permissions tab → Assign Permissions → select your API → check
read:admin-stats. - Log out and back in — Auth0 caches tokens, and the new permission won't appear until a fresh token is issued.
After step 4, the Admin link will appear in the header and /admin will load the stats card with drill-down links.
Picked the task activity log from the optional list. Each task has a per-task timeline visible in the edit dialog.
| Action | When | Payload (changes jsonb) |
|---|---|---|
created |
A task is created | { title, boardColumnId } |
updated |
title, description, or priority changes (same column) |
Per-field { from, to } deltas |
moved |
boardColumnId changes |
{ fromBoardColumnId, toBoardColumnId } |
Pure position-only changes within the same column are not logged — they're noisy reorders, not user-meaningful events.
Activity inserts run in the same transaction as the underlying task mutation (tasksRoute.ts). The activity row can never disagree with task state.
task_activities.task_id is ON DELETE CASCADE. Logging a 'deleted' row would vanish in the same transaction the task does. Keeping deletion history would require denormalizing task title onto the activity row and severing the FK, which is more than this scope warrants. The honest trade-off is: the timeline exists for the lifetime of the task it describes.
When a user is deleted (cascading away their boards / columns / tasks), the tasks vanish and their activities go with them — actor_id cascade-from-user wouldn't see those rows anyway. But for the case where a future feature lets two users collaborate on the same board, SET NULL preserves the timeline entries authored by a removed collaborator (rendered as "Someone" on the frontend).
GET /tasks/:id/activities— per-task timeline; gated by the same ownership check as the rest of the task routes; joinsusersfor the actor's display name.GET /admin/activities— cross-task feed for admins (cursor-paginated, gated byread:admin-stats); same data joined further to surface task title + board context. See Admin dashboard.- Frontend per-task timeline lives inside EditTaskDialog, fetched lazily when the dialog opens. Frontend admin feed lives at /admin/activities — both consume the shared renderActivitySummary helper.
A single contract: every failure produces a { error, message } body with a meaningful HTTP status, and the frontend reads both as typed data — no string parsing.
HttpErrorhierarchy — base class plusBadRequestError,UnauthorizedError,ForbiddenError,NotFoundError,ConflictError. Each setsstatusCodeso any error caught by Fastify's error hook can be mapped uniformly.- Routes throw, never
reply.code(N).send(...)— the route layer expresses intent (throw new NotFoundError('Task not found')) and the handler does the HTTP work.requirePermissionmiddleware does the same withForbiddenError. resolveErrorStatus— pure function decoupled from the Fastify hook. Returns{ statusCode, trustMessage }based on three checks, in order:- Does the error carry an explicit
statusCode? (our typed errors + Fastify validation errors) → use it, message is safe to surface. - Is it a Postgres error? → look up
codein the table below, message is not safe (raw constraint detail leaks internals). - Otherwise → 500, message is not safe.
- Does the error carry an explicit
- Central handler — logs at warn (4xx) or error (5xx), then renders
{ error: <statusName>, message: <safe message> }. ThetrustMessageflag controls whether the client seeserror.messageor a sanitisedSTATUS_CODES[statusCode]fallback.
| PG code | Meaning | HTTP |
|---|---|---|
23505 |
unique_violation | 409 |
23503 |
foreign_key_violation | 409 |
23502 |
not_null_violation | 400 |
23514 |
check_violation | 400 |
22P02 |
invalid_text_representation | 400 |
Adding a new code is one line in PG_CODE_TO_STATUS.
ApiError— extendsError, carriesstatus,statusText(the backend'serrorfield), andmessage.createBaseFetcher— on non-OK response, parses the JSON body and throwsApiError(status, body.error, body.message). Falls back toresponse.statusTextif the body isn't JSON.- Consumers — anywhere we display
error.messageautomatically renders the backend's user-friendly text (e.g. "Task not found", "Missing required permission: read:admin-stats"). Pages that branch on status doerror instanceof ApiError && error.status === 403— typed and correct, no string parsing.
- One source of truth per concern. Status decisions live in one Fastify hook, not scattered across routes. PG → HTTP mapping lives in one table. Client-side error semantics live in one class.
- Information leak is opt-in, not accidental. A typed error declares its message safe; everything else gets a sanitised status name. The full error still hits the server log via Pino.
- No fabricated strings. The previous frontend threw
new Error("403 Forbidden")and pages parsed that withstartsWith('403'). That's gone —error.status === 403works against typed data. - PG concurrency edge cases stop being 500s. A unique-constraint race on
board_columns_board_id_position_keynow surfaces as409 Conflictinstead of an opaque server error.
The browser only talks to the frontend NodePort. The frontend's nginx proxies /api/* → http://backend.taskboard.svc.cluster.local:3000 via the standard envsubst template mechanism (BACKEND_URL env var).
Why: avoids installing an ingress controller in kind, avoids CORS preflight in production, and avoids baking a backend hostname into the Vite bundle at build time. The same image works in docker-compose (BACKEND_URL=http://backend:3000) and Kubernetes.
Tradeoff: introduces an extra hop. For a take-home this is fine; in production you'd use an ingress controller with two rules.
The seed job only operates on a dedicated demo user (auth_sub = seed|demo-user) and does not affect real Auth0 users.
The runtime image is stripped to prod deps + compiled JS. To run drizzle-kit migrate, we need dev deps and the migration SQL files — both already present in the existing builder stage. Building with --target builder produces the migration image without a duplicate Dockerfile.
Why: one Dockerfile, two purposes. No drift between what runs migrations and what runs the app.
Tradeoff: the migration image is larger than necessary (full source tree + dev deps). Acceptable since it never runs in prod traffic — only as a Job. This ensures the database schema is always up-to-date before the backend starts serving traffic.
Per the brief: in-cluster Postgres is fine. Single replica with a 1Gi PVC. Headless Service for stable DNS. StatefulSet is used instead of Deployment because Postgres requires stable storage and identity.
Why: simplest possible setup for a take-home. No operators, no replication.
Tradeoff: no HA, no automated backups, no point-in-time recovery. Fine for review; production would use a managed DB or a real operator (Zalando, CloudNativePG).
manage.sh runs kubectl create secret --dry-run=client -o yaml | kubectl apply -f -. The --dry-run=client flag turns the imperative create secret into a declarative manifest that apply reconciles — first run creates, subsequent runs patch.
Why: no committed secrets, no plaintext in git, idempotent.
Tradeoff: secrets aren't versioned alongside manifests. For a real cluster you'd use SealedSecrets, External Secrets Operator, or SOPS — but the brief says no operators.
Frontend is exposed via NodePort 30080. Backend stays ClusterIP.
Why: ingress controllers are extra moving parts and per-cluster setup (kind needs extraPortMappings; minikube has an addon). Nodeport works on both with zero install.
Tradeoff: NodePort is unrealistic for production. With more time: an Ingress resource + ingress-nginx install step in manage.sh deploy.
We intentionally avoid an Ingress controller to keep the local setup minimal and reproducible:
- kind requires extraPortMappings for ingress
- minikube requires enabling an addon
- both add non-trivial setup overhead
Instead, the frontend nginx acts as a reverse proxy for /api/*, making the frontend the single public entry point.
- Backend —
GET /healthfor both liveness and readiness. Single endpoint is fine for a small service; in production you'd split (/health/livefor "process responsive",/health/readyfor "DB reachable"). - Postgres —
pg_isreadyexec probe. Standard. - Frontend —
GET /(nginx). - Probes ensure Kubernetes only routes traffic to healthy pods and automatically restarts crashed containers.
- frontend → exposed via NodePort (public entry)
- backend → ClusterIP (internal only)
- postgres → ClusterIP (internal only)
- Split health endpoints. Backend
/health/readyshould check the DB connection so the readiness probe drains pods that have lost their DB connection. Liveness should stay process-only. - Ingress over NodePort. Add
ingress-nginxinstall tomanage.sh deploy(or document a pre-step), expose the app at a single hostname with TLS via cert-manager. - External secrets. Replace the
kubectl create secretshell pattern with SealedSecrets or the External Secrets Operator backed by Vault / Doppler / 1Password. - HA for the API. Bump backend
replicasto 2+, add aPodDisruptionBudget, settopologySpreadConstraintsto spread across nodes. - Observability. OpenTelemetry SDK in the backend, Prometheus + Grafana via Helm, structured-log shipping. Right now we have
kubectl logsand that's it. - Backups. WAL archiving for Postgres + a CronJob doing logical dumps to object storage.
- CI. A GitHub Actions pipeline running
lint,type-check,build, pluskind-in-CI doing a smoke deploy on every PR. The brief excluded CI from scope, but it's the obvious next step. - Auth0 callback URLs. Currently the K8s deploy assumes
http://localhost:8080/(port-forward target) is added to the Auth0 Application's Allowed Callback URLs. A more polished setup would either auto-detect or document a Helm-style values file per environment.
The admin dashboard is intentionally read-only, single-permission, no filters. Concrete next steps when needs grow:
- Paginate users and boards. Currently unpaginated — fine while volume is small (Auth0-provisioned users grow slowly, boards scale with users). Infrastructure is already in place: when boards cross ~500 rows or page render exceeds ~200ms, retrofit by adding a cursor variant of the repository, swapping the route to
Querystring: AdminCursorQuery, and replacinguseSWRwithuseSWRInfiniteplus a "Load more" button. ~30 minutes per table. - Search and filter UI. Action-type filter on activities (created / updated / moved), priority filter on tasks, owner filter on boards/tasks, date-range filter across all lists. Backend would extend
adminCursorQuerySchemawith optional filter fields; cursor stays opaque so it implicitly carries the active filter set. - Granular admin permissions. Currently a single
read:admin-statsgates everything. Real product would split:read:admin-users,read:admin-activities, etc., and keepread:admin-statsonly for the overview cards. TherequirePermissionmiddleware already supports this — just thread the right string per route. - Admin mutations. Deactivate user, force-delete board, redact activity row. Each would need a new permission (
write:admin-*) plus an audit log of admin actions themselves (admin who did it, timestamp, target, reason). Currently the surface is read-only by design — adding writes is non-trivial because cascade rules mean "delete board" is destructive. - CSV export. Stream a paginated query into a CSV response for offline analysis. Trivial extension to the existing repos; would benefit from a
?format=csvquery param sharing the cursor schema. - Richer cell rendering. Priority as coloured badges instead of
P0/P1/P2text. Status indicators for users (last active). Formovedactivities in the admin view, fetch column titles on demand (per-board or via a cached endpoint) so the summary reads "from To Do to In Progress" instead of the generic fallback. - Rate limiting on admin endpoints. A misbehaving admin client (or a compromised token) could fetch all activities by paginating end-to-end. A token-bucket limiter at the Fastify hook level would cap throughput without breaking legitimate use.
- Server-side aggregation views. "Top 10 most active users this week," "boards with no recent activity" — these are aggregate questions, not filtered list views. They want their own purpose-built endpoints + dashboard widgets, not generic table filters.