Skip to content
Balwan Bamniya, home
Menu
Back to work

Case study

AI Kanban Board

A collaborative Kanban app with realtime presence and AI task planning, where every AI call runs through a durable, retry-safe job queue and every suggestion is validated before it touches the board.

#NestJS#TypeScript#PostgreSQL#Prisma#Redis#Socket.IO#TanStack Start#OpenAI
AI Kanban Board landing page with the headline "Big ideas. Clear next steps." above a sample board.

The problem

Most “AI-powered” task tools bolt a chat box onto a board and write whatever the model returns. For a team board that’s two risks at once: an LLM call that times out halfway through a request, and generated content landing in shared work without review.

I built this demo to show the opposite approach — AI as a reviewed, auditable background job inside a properly multi-user product: roles (owner, admin, member, viewer), invitations, live cursors and conflict-safe drag-and-drop.

Architecture

Request, job and realtime paths

  1. Client

    • TanStack StartSSR on Nitro · React Query · dnd-kit
    • ClerkSign-in, JWT

    REST /api/v1 + Socket.IO

  2. NestJS API

    • ModulesBoards, members, tasks, AI, activity
    • Access controlPer-board role permissions
    • Socket.IO gatewayBoard rooms, presence, cursors

    One transaction: write + Activity + OutboxEvent

  3. Data & jobs

    • PostgreSQLTasks, outbox, AI runs
    • Background workerSKIP LOCKED leases, retries
    • RedisSocket.IO adapter + presence
    • OpenAIResponses API, structured output

Hard problems

1. Retry-safe job claiming

Workers claim one job at a time with FOR UPDATE SKIP LOCKED and a 120-second lease. A crashed worker’s job becomes claimable again when its lease expires, and a lease token “fences” late writes so a stale worker can never overwrite a newer result.

WITH candidate AS (
  SELECT id FROM ${table}
  WHERE (status = ${pending} AND next_attempt_at <= NOW())
     OR (status = ${running} AND (lease_expires_at IS NULL OR lease_expires_at < NOW()))
  ORDER BY next_attempt_at, id FOR UPDATE SKIP LOCKED LIMIT 1
)
UPDATE ${table} AS job SET status = ${running},
  attempts = job.attempts + 1, lease_token = ${token},
  lease_expires_at = NOW() + INTERVAL '120 seconds'
FROM candidate WHERE job.id = candidate.id
RETURNING job.id, job.attempts, job.lease_token AS "leaseToken"

apps/api/src/modules/background/background.worker.ts (enum casts trimmed)

2. AI output you can trust

AI run lifecycle

  1. RequestIdempotency key + daily quota
  2. QueuedAPI returns 202
  3. ClaimedWorker lease
  4. GeneratedZod-schema structured output
  5. ValidatedRefusal, completeness, count
  6. ReviewedUser picks suggestions
  7. AppliedOne transaction, once only

The prompt treats board content as untrusted data, never instructions. Responses must match a strict Zod schema, refusals and incomplete outputs are rejected, and the number of tasks must equal what was requested. Errors are classified as retryable (408, 429, 5xx) or permanent, so the queue backs off only when it should. Rolling 24-hour quotas (100 runs per board, 25 per user) cap spend.

Applying suggestions is idempotent twice over: a replayed key returns the stored result, and each suggestion index is permanently mapped to the task it created, so nothing can be applied twice.

AI assistant panel over a board, offering task suggestions or a board summary, with a suggestion count and previous runs.

3. Drag-and-drop without lost updates

Ordering uses fractional-index string keys, so a move rewrites one row instead of renumbering a column. Each task carries a version; the move is a compare-and-swap inside a transaction, so two people dragging the same card can’t silently clobber each other.

const sortKey = generateKeyBetween(
  last?.sortKey ?? null,
  next?.sortKey ?? null,
);
const update = await transaction.task.updateMany({
  where: { boardId: context.boardId, id: taskId, version: dto.version },
  data: { columnId: destination.id, sortKey, version: { increment: 1 } },
});
if (update.count !== 1) {
  throw new ConflictException("Task has changed. Refresh and try again.");
}

apps/api/src/modules/tasks/application/tasks.service.ts

A shared board with Todo, In Progress, Review and Done columns, search and filters, and the AI assistant button.

Outcome

  • 187 automated tests passing: 121 API unit/HTTP, 39 frontend, and 27 integration tests against real Postgres and Redis, including the Socket.IO gateway (per the project’s completion report).
  • CI on every pull request and push to main runs schema validation, migrations, the seed twice to prove it’s idempotent, then lint, type checks, tests and build.
  • Date logic is tested in America/New_York across both 23- and 25-hour daylight-saving days.
  • Deployed as two Render services with a 384 MB heap cap and a 3-connection pool to fit the free tier.

What I’d change