DEV Community

Cover image for Building a Jira-Like Task API with KickJS — Auth, WebSocket Chat, SSE, Queues & More
Orinda Felix Ochieng
Orinda Felix Ochieng

Posted on

Building a Jira-Like Task API with KickJS — Auth, WebSocket Chat, SSE, Queues & More

Update: This article documents the initial build and the problems encountered along the way. Many of the gotchas described here (ctx.set/get isolation, DI registration, controller HMR) have been resolved in KickJS v1.2.5–v1.2.7. The workarounds shown are still valid but no longer necessary on the latest version. See the complete project guide for the current patterns.

TL;DR

  • 🏗️ Built a full Jira-like task management API (65 endpoints, 17 modules) using KickJS — a decorator-driven TypeScript framework
  • 🔐 Auth with JWT + refresh tokens + role-based guards — and the debugging rabbit hole that came with it
  • 💬 Real-time WebSocket chat with rooms, typing indicators, and read receipts
  • 📡 Server-Sent Events for live dashboard updates on task changes
  • ⚡ Background job queues with BullMQ for emails, reminders, and audit logs
  • 🤕 10 gotchas that cost me hours so they don't have to cost you hours

Why This Exists

I wanted to stress-test KickJS beyond a "Hello World." So I built Vibed — a task management backend with categories, labels, sprints, comments, file attachments, real-time chat, SSE dashboards, background jobs, and cron-scheduled reminders.

Think Jira's API, but TypeScript-native, decorator-driven, and built in a weekend (okay, a long weekend).

Let's walk through the interesting parts.


The Stack

Concern Tool
Framework KickJS (Express under the hood)
Database MongoDB + Mongoose
Auth JWT access + refresh tokens
Email Resend SDK
Real-time WebSocket (native ws)
Live updates Server-Sent Events
Job queue BullMQ + Redis
Scheduled tasks Cron module
Docs Swagger / OpenAPI

Getting Started

KickJS has a CLI that scaffolds everything:

kick new vibed --pm pnpm
kick add auth ws mailer queue cron swagger devtools
kick g module tasks
Enter fullscreen mode Exit fullscreen mode

That last command generates a full DDD module — controller, service, repository, DTOs, the works. More on that structure in a sec.


The DDD Module Pattern

Every feature in KickJS lives in a module. Each module follows a layered architecture:

src/modules/tasks/
├── task.module.ts          # Wires everything together
├── task.controller.ts      # HTTP routes + decorators
├── task.service.ts         # Business logic
├── task.repository.ts      # Mongoose queries
├── dto/
│   ├── create-task.dto.ts  # Validation schemas
│   └── update-task.dto.ts
├── schemas/
│   └── task.schema.ts      # Mongoose model
└── interfaces/
    └── task.interface.ts   # TypeScript types
Enter fullscreen mode Exit fullscreen mode

Here's what a module registration looks like:

@Module({
  controllers: [TaskController],
  providers: [TaskService, TaskRepository],
  imports: [MongooseModule.forFeature([
    { name: 'Task', schema: TaskSchema }
  ])],
})
export class TaskModule {}
Enter fullscreen mode Exit fullscreen mode

And a typical controller:

@Controller('/tasks')
@UseGuards(AuthGuard)
export class TaskController {
  @Autowired() private taskService!: TaskService;

  @Get('/')
  async findAll(ctx: Context) {
    const user = getUser(ctx);
    const query = ctx.query as ApiQueryParamsConfig;
    const tasks = await this.taskService.findByWorkspace(
      user.workspaceId,
      query
    );
    return ctx.json({ data: tasks });
  }

  @Post('/')
  @Validate(CreateTaskDto)
  async create(ctx: Context) {
    const user = getUser(ctx);
    const body = ctx.body as CreateTaskDto;
    const task = await this.taskService.create({
      ...body,
      createdBy: user._id,
      workspaceId: user.workspaceId,
    });
    return ctx.json({ data: task }, 201);
  }
}
Enter fullscreen mode Exit fullscreen mode

Clean. Predictable. Every module follows the same shape.


Authentication — The Hard Way

This section is a debugging story. Buckle up.

Chapter 1: @public() Didn't Work

KickJS ships with an auth module that has a defaultPolicy: 'RESTRICTED' setting. Every route is locked down by default — which is great, until you need public routes.

The @Public() decorator is supposed to mark routes as open. Except... it wasn't working. Every public route still returned 401 Unauthorized.

After digging through the source, I found the issue: the resolveHandler in the auth config was throwing before the @Public() metadata could be checked. The auth middleware runs globally, and when resolveHandler fails, it short-circuits.

Chapter 2: The Bridge Middleware

Solution? I built authBridgeMiddleware — a global middleware that sits before the auth guard:

export function authBridgeMiddleware(
  ctx: Context,
  next: NextFunction
) {
  const token = ctx.headers.authorization?.split(' ')[1];

  if (!token) {
    ctx.set('user', null);
    return next();
  }

  try {
    const payload = verifyAccessToken(token);
    ctx.set('user', payload);
  } catch {
    ctx.set('user', null);
  }

  return next();
}
Enter fullscreen mode Exit fullscreen mode

This way, the auth guard always has a user (or null) to work with, and @Public() routes can proceed even without a token.

Chapter 3: The ctx.set()/ctx.get() Fix

This used to be a major gotcha — ctx.set() in middleware was invisible to ctx.get() in the handler because each got a separate RequestContext instance with its own metadata Map.

Good news: fixed in KickJS v1.2.5. The metadata Map is now stored on req and shared across all RequestContext instances for the same request. ctx.set()/ctx.get() works exactly as you'd expect:

// In middleware:
ctx.set('user', payload);  // ✅ Sets on shared metadata

// In route handler:
const user = ctx.get<AuthUser>('user');  // ✅ Works!
Enter fullscreen mode Exit fullscreen mode

A clean helper keeps it centralized:

export function getUser(ctx: Context): AuthUser {
  const user = ctx.get<AuthUser>('user');

  if (!user) {
    throw new UnauthorizedException('Not authenticated');
  }

  return user;
}
Enter fullscreen mode Exit fullscreen mode

Real-Time Chat with WebSocket

KickJS has first-class WebSocket support via @WsController:

@WsController('/chat')
export class ChatGateway {
  @Autowired() private chatService!: ChatService;

  @OnConnect()
  async handleConnect(socket: WsSocket) {
    const user = await this.authenticateSocket(socket);
    socket.data = { userId: user._id };
    console.log(`🟢 ${user.name} connected`);
  }

  @OnMessage('join-room')
  async handleJoinRoom(
    socket: WsSocket,
    payload: { roomId: string }
  ) {
    socket.join(payload.roomId);
    socket.to(payload.roomId).emit('user-joined', {
      userId: socket.data.userId,
      timestamp: new Date(),
    });
  }

  @OnMessage('send-message')
  async handleMessage(
    socket: WsSocket,
    payload: { roomId: string; content: string }
  ) {
    const message = await this.chatService.create({
      roomId: payload.roomId,
      senderId: socket.data.userId,
      content: payload.content,
    });

    socket.to(payload.roomId).emit('new-message', message);
  }

  @OnMessage('typing')
  async handleTyping(
    socket: WsSocket,
    payload: { roomId: string; isTyping: boolean }
  ) {
    socket.to(payload.roomId).emit('user-typing', {
      userId: socket.data.userId,
      isTyping: payload.isTyping,
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Rooms, typing indicators, message persistence — all decorator-driven. The socket.join() / socket.to() API mirrors Socket.IO, which makes it easy to reason about.


Live Dashboard with SSE

For the dashboard, I didn't want WebSocket overhead. SSE is perfect for one-way server-to-client updates:

@Get('/stream')
@UseGuards(AuthGuard)
async streamUpdates(ctx: Context) {
  const user = getUser(ctx);

  ctx.sse((send, close) => {
    const interval = setInterval(async () => {
      const stats = await this.dashboardService.getStats(
        user.workspaceId
      );
      send({ event: 'dashboard-update', data: stats });
    }, 5000);

    ctx.req.on('close', () => {
      clearInterval(interval);
      close();
    });
  });
}
Enter fullscreen mode Exit fullscreen mode

ctx.sse() handles the headers (text/event-stream, Cache-Control, etc.) and gives you a clean send/close interface. The client just uses EventSource:

const es = new EventSource('/api/dashboard/stream', {
  headers: { Authorization: `Bearer ${token}` }
});

es.addEventListener('dashboard-update', (e) => {
  updateDashboard(JSON.parse(e.data));
});
Enter fullscreen mode Exit fullscreen mode

Background Jobs That Actually Work

Email notifications, reminder scheduling, audit logging — all of this needs to happen off the request cycle. KickJS wraps BullMQ:

@Job('email-queue')
export class EmailJob {
  @Autowired() private mailer!: MailerService;

  @Process('send-welcome')
  async handleWelcome(job: JobData<WelcomePayload>) {
    await this.mailer.send({
      to: job.data.email,
      subject: 'Welcome to Vibed!',
      template: 'welcome',
      context: { name: job.data.name },
    });
  }

  @Process('send-reminder')
  async handleReminder(job: JobData<ReminderPayload>) {
    await this.mailer.send({
      to: job.data.email,
      subject: `Reminder: ${job.data.taskTitle}`,
      template: 'task-reminder',
      context: job.data,
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Dispatching a job from anywhere:

@Autowired() private queueService!: QueueService;

async createTask(data: CreateTaskDto) {
  const task = await this.taskRepo.create(data);

  // Fire-and-forget background job
  await this.queueService.add('email-queue', 'send-reminder', {
    email: data.assigneeEmail,
    taskTitle: task.title,
    dueDate: task.dueDate,
  });

  return task;
}
Enter fullscreen mode Exit fullscreen mode

Pro tip: In development, skip Redis entirely with the ConsoleProvider:

QueueModule.register({
  provider: process.env.NODE_ENV === 'development'
    ? 'console'    // Logs jobs to terminal
    : 'bullmq',   // Real Redis-backed queue
  connection: { host: 'localhost', port: 6379 },
})
Enter fullscreen mode Exit fullscreen mode

File Uploads → Base64 → MongoDB

No S3, no Cloudinary. For a task management MVP, storing files as base64 in MongoDB is fine:

@Post('/attachments')
@FileUpload({ maxSize: 5 * 1024 * 1024 }) // 5MB
async uploadAttachment(ctx: Context) {
  const file = ctx.file;
  const user = getUser(ctx);

  const attachment = await this.attachmentService.create({
    taskId: ctx.params.taskId,
    fileName: file.originalname,
    mimeType: file.mimetype,
    data: file.buffer.toString('base64'),
    uploadedBy: user._id,
  });

  return ctx.json({ data: attachment }, 201);
}
Enter fullscreen mode Exit fullscreen mode

Is this production-ready for large files? No. Is it perfect for an MVP with < 5MB attachments? Absolutely.


10 Gotchas That'll Save You Hours

These are the things I wish someone had told me before I started. Each one cost me at least 30 minutes of head-scratching.

1. Mongoose HMR Guard

Hot module reload re-runs your schema definitions. Mongoose hates that.

// ❌ Crashes on HMR
export const TaskModel = model('Task', TaskSchema);

// ✅ Guard it
export const TaskModel =
  models.Task || model('Task', TaskSchema);
Enter fullscreen mode Exit fullscreen mode

2. Double-Slash Routes

If your module prefix is /api and your controller has @Controller('/tasks'), you get /api//tasks. Both start with /.

// ❌ Results in /api//tasks
@Module({ prefix: '/api' })
@Controller('/tasks')

// ✅ Drop the leading slash on one
@Module({ prefix: '/api' })
@Controller('tasks')
Enter fullscreen mode Exit fullscreen mode

3. Global vs Route Middleware Signatures

Global middleware gets (ctx, next). Route middleware gets (ctx, next) too — but the ctx object is different. Global middleware runs on the raw Express context; route middleware runs on the KickJS-wrapped context.

// Global middleware — req/res under the hood
app.use((ctx, next) => {
  ctx.req.headers; // ✅ works
  ctx.body;        // ❌ might not be parsed yet
  next();
});

// Route middleware — full KickJS context
@UseMiddleware(myMiddleware)
// ctx.body ✅, ctx.query ✅, ctx.params ✅
Enter fullscreen mode Exit fullscreen mode

4. ctx.set/get Shared Across Middleware/Handler (Fixed in v1.2.5)

This was a pain point in earlier versions but is now fixed. Use ctx.set()/ctx.get() directly — no res.locals workaround needed.

5. @Inject for Constructors, @Autowired for Properties

// Constructor injection
class TaskService {
  constructor(@Inject(TaskRepository) private repo: TaskRepository) {}
}

// Property injection (no constructor needed)
class TaskController {
  @Autowired() private taskService!: TaskService;
}
Enter fullscreen mode Exit fullscreen mode

Mix them up and you get silent undefined values. Pick one pattern per class.

6. QueueAdapter Wants String Names, Not Classes

// ❌ Nope
this.queueService.add(EmailJob, 'send-welcome', data);

// ✅ String name matching @Job('email-queue')
this.queueService.add('email-queue', 'send-welcome', data);
Enter fullscreen mode Exit fullscreen mode

7. Route-less Modules Crash Express

If a module has no controllers (e.g., a pure service module), KickJS still tries to register it as a router. Empty router = Express crash.

// ✅ Add a health controller or use forRoot() pattern
@Module({
  controllers: [],  // This can cause issues
  providers: [MyService],
})

// ✅ Better: export the service from a module that HAS routes
Enter fullscreen mode Exit fullscreen mode

8. Auth defaultPolicy Blocks When resolveHandler Fails

If your resolveHandler throws, every route gets a 401 — even @Public() ones. Always wrap it:

authConfig({
  defaultPolicy: 'RESTRICTED',
  resolveHandler: async (ctx) => {
    try {
      return await verifyToken(ctx);
    } catch {
      return null;  // Let @Public() routes through
    }
  },
})
Enter fullscreen mode Exit fullscreen mode

9. ApiQueryParamsConfig Type Name

The type for pagination/filter/sort query params is called ApiQueryParamsConfig, not QueryParams, not ApiQuery, not PaginationOptions. Ask me how many times I searched for the wrong name.

import { ApiQueryParamsConfig } from '@kickjs/core';

@Get('/')
async findAll(ctx: Context) {
  const query = ctx.query as ApiQueryParamsConfig;
  // query.page, query.limit, query.sort, query.filter
}
Enter fullscreen mode Exit fullscreen mode

10. loadEnv() Type Erasure

loadEnv() returns Record<string, string>. Everything is a string. Your "boolean" ENABLE_SWAGGER=true is the string "true".

// ❌ This is always truthy (it's a non-empty string)
if (env.ENABLE_SWAGGER) { ... }

// ✅ Compare strings explicitly
if (env.ENABLE_SWAGGER === 'true') { ... }

// ✅ Or parse once at startup
const config = {
  enableSwagger: env.ENABLE_SWAGGER === 'true',
  port: parseInt(env.PORT, 10) || 3000,
};
Enter fullscreen mode Exit fullscreen mode

The Numbers

After a long weekend of building:

Metric Count
Modules 17
API Endpoints 65
MongoDB Collections 13
TypeScript Files 159
Type Errors 0

Modules include: auth, users, workspaces, projects, sprints, tasks, task-comments, task-labels, task-categories, task-attachments, task-reminders, chat, notifications, dashboard, audit-log, file-uploads, and health.


What I'd Do Differently

  1. Start with the auth bridge middleware — don't fight @Public() for two hours first
  2. Use property injection everywhere@Autowired() is simpler than constructor @Inject()
  3. String-based queue names from day one — saves a refactor later
  4. Build the getUser(ctx) helper immediately — uses ctx.get<AuthUser>('user') under the hood, called in every guarded route

What's Next

In the next article in this series, I'll cover:

  • Role-based access control — workspace admins vs members vs viewers
  • Advanced query filtering — how to build a flexible filter/sort/paginate layer on top of Mongoose
  • Deployment — containerizing the whole stack with Docker Compose

If you've been looking for a NestJS alternative that's lighter, faster to scaffold, and doesn't require a PhD in dependency injection — give KickJS a shot.

Star KickJS on GitHub


Got questions? Drop them in the comments — I'll answer everything. If you hit any of these gotchas yourself, I'd love to hear your war stories too.

Top comments (0)