DEV Community

Cover image for Building a Complete Jira-like Task Management Backend with KickJS — From Scaffold to Production
Orinda Felix Ochieng
Orinda Felix Ochieng

Posted on

Building a Complete Jira-like Task Management Backend with KickJS — From Scaffold to Production

TL;DR

This is the complete project guide for Vibed — a Jira-like task management backend built with KickJS v1.2.2. It covers everything from initial scaffold to production-ready features: 14 modules, 65+ endpoints, real-time WebSocket chat, SSE live dashboards, BullMQ background jobs, cron scheduling, and the framework issues we filed along the way.

If you've been following the article series, this brings it all together. If you're starting here, this is everything you need to build a non-trivial backend with KickJS.


1. Project Overview and Tech Stack

Vibed is a task management platform modeled after Jira's API surface. It supports workspaces, projects, task boards with drag-and-drop reordering, comments with @mentions, file attachments, real-time chat channels, live dashboard stats, email notifications, and activity feeds.

Concern Technology
Framework KickJS v1.2.2 (@forinda/kickjs-* packages)
Language TypeScript (ESM)
Database MongoDB via Mongoose 9
Cache / Queue broker Redis (ioredis)
Background jobs BullMQ
Auth JWT (access + refresh token rotation), bcryptjs
Email Resend (production) / ConsoleProvider (development)
Real-time WebSocket chat (Socket.IO via WsAdapter)
Live updates Server-Sent Events via ctx.sse()
Scheduled jobs CronAdapter (overdue reminders, daily digest, token cleanup, presence cleanup)
API docs Swagger via SwaggerAdapter
Dev tools DevToolsAdapter at /_debug/*
Build Vite + SWC
Package manager pnpm

KickJS is a decorator-driven Node.js framework built on Express 5. It uses TypeScript decorators for routing (@Get, @Post), dependency injection (@Service, @Inject, @Autowired), validation (@Validate with Zod), and documentation (@ApiTags, @ApiOperation). If you've used NestJS, the patterns will feel familiar — but KickJS is lighter, uses Express directly, and emphasizes convention over configuration.


2. Project Setup

Scaffolding

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

The first command scaffolds a KickJS project with the REST template. The second adds adapter packages for auth, WebSocket, email, queues, cron, Swagger, and dev tools.

Module Generation

kick g module tasks
kick g module workspaces
kick g module comments
# ... repeat for each module
Enter fullscreen mode Exit fullscreen mode

Each kick g module creates the full DDD directory structure:

module/
├── <name>.module.ts              # AppModule: register() + routes()
├── presentation/
│   └── <name>.controller.ts      # @Controller, @Get, @Post, etc.
├── application/
│   ├── dtos/                     # Zod validation schemas
│   └── use-cases/                # Single-purpose business logic classes
├── domain/
│   ├── entities/                 # TypeScript interfaces
│   └── repositories/            # Repository interfaces
└── infrastructure/
    ├── schemas/                  # Mongoose schemas + models
    └── repositories/            # Mongo repository implementations
Enter fullscreen mode Exit fullscreen mode

Environment Configuration with Zod

All environment variables are validated at startup via Zod:

// src/config/env.ts
import { z } from 'zod';
import { defineEnv, loadEnv } from '@forinda/kickjs-config';

const envSchema = defineEnv((base) =>
  base.extend({
    MONGODB_URI: z.string().url(),
    REDIS_URL: z.string().url(),
    JWT_SECRET: z.string().min(32),
    JWT_REFRESH_SECRET: z.string().min(32),
    JWT_ACCESS_EXPIRES_IN: z.string().default('15m'),
    JWT_REFRESH_EXPIRES_IN: z.string().default('7d'),
    RESEND_API_KEY: z.string().min(1),
    MAIL_FROM_NAME: z.string().default('Vibed'),
    MAIL_FROM_EMAIL: z.string().email(),
    APP_URL: z.string().url(),
    APP_NAME: z.string().default('Vibed'),
  }),
);

export const env = loadEnv(envSchema);
Enter fullscreen mode Exit fullscreen mode

If any required variable is missing or invalid, the app fails fast at startup with a clear Zod validation error. No more "undefined is not a function" errors 10 minutes into runtime because REDIS_URL was misspelled.

Entry Point

The entry point is deliberately minimal — all configuration is delegated to dedicated files:

// src/index.ts
import 'reflect-metadata';
import { bootstrap } from '@forinda/kickjs-http';
import { modules } from './modules';
import { adapters } from './config/adapters';
import { middleware } from './config/middleware';

bootstrap({
  modules,
  apiPrefix: '/api',
  defaultVersion: 1,
  middleware,
  adapters,
});
Enter fullscreen mode Exit fullscreen mode

Global middleware uses Express signatures (not KickJS RequestContext):

// src/config/middleware.ts
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import { requestIdMiddleware } from '@/shared/presentation/middlewares/request-id.middleware';

export const middleware = [
  requestIdMiddleware,
  cors(),
  helmet(),
  express.json({ limit: '5mb' }),
  express.urlencoded({ extended: true }),
];
Enter fullscreen mode Exit fullscreen mode

3. Authentication: JWT with Refresh Rotation

The Auth Controller

Auth endpoints are public — no authBridgeMiddleware:

@ApiTags('Auth')
@Controller()
export class AuthController {
  @Autowired() private registerUseCase!: RegisterUseCase;
  @Autowired() private loginUseCase!: LoginUseCase;
  @Autowired() private refreshTokenUseCase!: RefreshTokenUseCase;
  @Autowired() private logoutUseCase!: LogoutUseCase;

  @Post('/register', { body: registerSchema })
  @Public()
  @ApiOperation({ summary: 'Register a new user account' })
  async register(ctx: RequestContext) {
    const result = await this.registerUseCase.execute(ctx.body);
    ctx.created(successResponse(result, 'Registration successful'));
  }

  @Post('/login', { body: loginSchema })
  @Public()
  async login(ctx: RequestContext) {
    const result = await this.loginUseCase.execute(ctx.body);
    ctx.json(successResponse(result, 'Login successful'));
  }

  @Post('/refresh', { body: refreshTokenSchema })
  @Public()
  async refresh(ctx: RequestContext) {
    const result = await this.refreshTokenUseCase.execute(ctx.body);
    ctx.json(successResponse(result));
  }
}
Enter fullscreen mode Exit fullscreen mode

DTOs are Zod schemas:

// register.dto.ts
export const registerSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8).max(128),
  firstName: z.string().min(1).max(50),
  lastName: z.string().min(1).max(50),
});

// login.dto.ts
export const loginSchema = z.object({
  email: z.string().email(),
  password: z.string().min(1),
});
Enter fullscreen mode Exit fullscreen mode

The Auth Bridge Middleware

Every protected controller uses authBridgeMiddleware at the class level:

// src/shared/presentation/middlewares/auth-bridge.middleware.ts
export const authBridgeMiddleware: MiddlewareHandler = (ctx: RequestContext, next) => {
  const user = (ctx.req as any).user;
  if (user) {
    ctx.set('user', user);
  }
  next();
};
Enter fullscreen mode Exit fullscreen mode

The AuthAdapter with JwtStrategy validates the JWT and puts the user on req.user. The bridge middleware copies it to ctx metadata so handlers can use ctx.get('user') or the getUser() helper.

The getUser Helper

// src/shared/utils/auth.ts
export function getUser(ctx: RequestContext): AuthUser {
  const user = ctx.get<AuthUser>('user');
  if (!user) throw HttpException.unauthorized('Authentication required');
  return user;
}
Enter fullscreen mode Exit fullscreen mode

Every controller that needs the current user calls getUser(ctx) instead of accessing context directly. This abstraction saved us when the framework fixed the ctx.set/get sharing bug in v1.2.5 — one file changed, zero controllers touched.

Guards

Role-based access uses guard middleware that composes with authBridgeMiddleware:

// src/shared/guards/workspace-membership.guard.ts
export const workspaceMembershipGuard: MiddlewareHandler = async (ctx, next) => {
  const user = ctx.get('user');
  if (!user) throw HttpException.unauthorized('Authentication required');

  const workspaceId = ctx.params.workspaceId;
  if (!workspaceId) return next();

  const container = Container.getInstance();
  const memberRepo = container.resolve<IWorkspaceMemberRepository>(
    TOKENS.WORKSPACE_MEMBER_REPOSITORY,
  );
  const member = await memberRepo.findByUserAndWorkspace(user.id, workspaceId);

  if (!member) throw HttpException.forbidden(ErrorCode.NOT_WORKSPACE_MEMBER);

  ctx.set('workspaceMember', member);
  next();
};
Enter fullscreen mode Exit fullscreen mode

Guards resolve repositories from the DI container at request time via Container.getInstance(). This avoids circular dependency issues that arise when guards depend on repositories that depend on modules.


4. Core Modules: DDD Structure Walkthrough

The Module Registry

All modules are registered in src/modules/index.ts:

import type { AppModuleClass } from '@forinda/kickjs-core';
import { AuthModule } from './auth/auth.module';
import { UsersModule } from './users/users.module';
import { WorkspacesModule } from './workspaces/workspaces.module';
import { ProjectsModule } from './projects/projects.module';
import { TasksModule } from './tasks/tasks.module';
import { CommentsModule } from './comments/comments.module';
import { LabelsModule } from './labels/labels.module';
import { ChannelsModule } from './channels/channels.module';
import { MessagesModule } from './messages/messages.module';
import { NotificationsModule } from './notifications/notifications.module';
import { ActivityModule } from './activity/activity.module';
import { AttachmentsModule } from './attachments/attachments.module';
import { StatsModule } from './stats/stats.module';
import { QueueModule } from './queue/queue.module';

export const modules: AppModuleClass[] = [
  QueueModule,
  AuthModule,
  UsersModule,
  WorkspacesModule,
  ProjectsModule,
  LabelsModule,
  TasksModule,
  CommentsModule,
  AttachmentsModule,
  ActivityModule,
  NotificationsModule,
  ChannelsModule,
  MessagesModule,
  StatsModule,
];
Enter fullscreen mode Exit fullscreen mode

Tasks Module — A Complete Walkthrough

The tasks module is the most feature-rich. Let me walk through each layer.

Module registration maps the DI token to the concrete repository:

// src/modules/tasks/tasks.module.ts
export class TasksModule implements AppModule {
  register(container: Container): void {
    container.registerFactory(TOKENS.TASK_REPOSITORY, () =>
      container.resolve(MongoTaskRepository),
    );
  }

  routes(): ModuleRoutes {
    return {
      path: '/',
      router: buildRoutes(TasksController),
      controller: TasksController,
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

The path: '/' means routes are mounted at the API root. The controller defines the full paths: /projects/:projectId/tasks, /tasks/:taskId, etc.

Mongoose schema with HMR guard:

// src/modules/tasks/infrastructure/schemas/task.schema.ts
const taskSchema = new Schema<TaskDocument>(
  {
    projectId: { type: Schema.Types.ObjectId, ref: 'Project', required: true, index: true },
    workspaceId: { type: Schema.Types.ObjectId, ref: 'Workspace', required: true, index: true },
    key: { type: String, required: true, unique: true, index: true },
    title: { type: String, required: true, trim: true },
    description: { type: String },
    status: { type: String, default: 'todo' },
    priority: { type: String, enum: ['critical', 'high', 'medium', 'low', 'none'], default: 'none' },
    assigneeIds: [{ type: Schema.Types.ObjectId, ref: 'User' }],
    reporterId: { type: Schema.Types.ObjectId, ref: 'User', required: true },
    labelIds: [{ type: Schema.Types.ObjectId, ref: 'Label' }],
    parentTaskId: { type: Schema.Types.ObjectId, ref: 'Task' },
    dueDate: { type: Date },
    estimatePoints: { type: Number },
    orderIndex: { type: Number, default: 0 },
  },
  { timestamps: true },
);

// HMR guard — REQUIRED for Vite dev server
export const TaskModel =
  (mongoose.models.Task as mongoose.Model<TaskDocument>) ||
  mongoose.model<TaskDocument>('Task', taskSchema);
Enter fullscreen mode Exit fullscreen mode

Without the HMR guard, every file save that triggers a re-import crashes with OverwriteModelError.

Use case with constructor DI injection:

// src/modules/tasks/application/use-cases/create-task.use-case.ts
@Service()
export class CreateTaskUseCase {
  constructor(
    @Inject(TOKENS.TASK_REPOSITORY) private taskRepo: ITaskRepository,
    @Inject(TOKENS.PROJECT_REPOSITORY) private projectRepo: IProjectRepository,
  ) {}

  async execute(projectId: string, userId: string, dto: CreateTaskDto) {
    const project = await this.projectRepo.findById(projectId);
    if (!project) throw new Error('Project not found');

    const counter = await this.projectRepo.incrementTaskCounter(projectId);
    const key = `${project.key}-${counter}`;

    const maxOrderTask = await this.taskRepo.findByProject(projectId);
    const maxOrder = maxOrderTask.length > 0
      ? Math.max(...maxOrderTask.filter(t => t.status === dto.status).map(t => t.orderIndex))
      : -1;

    return this.taskRepo.create({
      ...dto,
      projectId: projectId as any,
      workspaceId: project.workspaceId,
      key,
      reporterId: userId as any,
      orderIndex: maxOrder + 1,
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

The task key pattern (PROJ-1, PROJ-2, etc.) mirrors Jira's issue keys. incrementTaskCounter atomically increments a counter on the project document to ensure uniqueness.

Controller with property injection via @Autowired():

@ApiTags('Tasks')
@ApiBearerAuth()
@Middleware(authBridgeMiddleware)
@Controller()
export class TasksController {
  @Autowired() private createTaskUseCase!: CreateTaskUseCase;
  @Autowired() private taskRepo!: MongoTaskRepository;

  @Post('/projects/:projectId/tasks', {
    params: z.object({ projectId: z.string() }),
    body: createTaskSchema,
  })
  @Middleware(projectAccessGuard)
  @ApiOperation({ summary: 'Create a new task in a project' })
  @ApiResponse({ status: 201, description: 'Task created successfully' })
  async create(ctx: RequestContext) {
    const user = ctx.get('user');
    const result = await this.createTaskUseCase.execute(ctx.params.projectId, user.id, ctx.body);
    ctx.created(successResponse(result, 'Task created'));
  }

  @Get('/projects/:projectId/tasks', {
    params: z.object({ projectId: z.string() }),
  })
  @Middleware(projectAccessGuard)
  @ApiQueryParams(TASK_QUERY_CONFIG)
  async list(ctx: RequestContext) {
    await ctx.paginate(
      async (parsed) => {
        parsed.filters.push({ field: 'projectId', operator: 'eq', value: ctx.params.projectId });
        return this.taskRepo.findPaginated(parsed);
      },
      TASK_QUERY_CONFIG,
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

DI Pattern Summary

Where Technique When to Use
Controller properties @Autowired() Concrete classes (use cases, repos)
Use case constructor @Inject(TOKENS.X) Interface-based repos via Symbol tokens
Processor constructor @Inject(MAILER) Framework service symbols
Module register() container.registerFactory() Mapping tokens to implementations

Critical rule: @Inject(TOKEN) only works on constructor parameters. @Autowired() only works on properties (resolves by class type). Mixing them up causes silent failures.


5. Supporting Modules

Labels

Workspace-scoped label CRUD. Labels can be attached to tasks via labelIds. Nothing complex — standard CRUD with workspaceMembershipGuard.

Comments

Comment CRUD with @mention parsing. Creating a comment triggers a notification job via BullMQ. The @mention pattern is parsed from the comment content and stored as a mentions array on the comment document.

Attachments

File upload using multipart form data. Files are converted to base64 and stored in MongoDB (not ideal for production, but keeps the stack simple). The controller increments task.attachmentCount for denormalized display.

Notifications

In-app notification system. Notifications are created by background jobs (not directly by controllers). Endpoints: list paginated, mark as read, mark all as read, unread count. The unread count endpoint is what the frontend polls for the notification badge.


6. Real-Time: WebSocket Chat and SSE Stats

WebSocket Chat

The ChatWsController handles real-time messaging via Socket.IO rooms:

@WsController('/chat')
export class ChatWsController {
  @Autowired() private messageRepo!: MongoMessageRepository;

  @OnConnect()
  handleConnect(ctx: WsContext) {
    const token = ctx.data?.token || '';
    const payload = jwt.verify(token, env.JWT_SECRET) as any;
    ctx.set('userId', payload.sub);
    onlineUsers.set(ctx.id, { userId: payload.sub, userName: payload.email });
    ctx.broadcastAll('presence:online', { userId: payload.sub, userName: payload.email });
  }

  @OnMessage('channel:join')
  handleJoin(ctx: WsContext) {
    const channelId = ctx.data?.channelId;
    ctx.join(`channel:${channelId}`);
    ctx.to(`channel:${channelId}`).send('channel:user_joined', {
      channelId,
      userId: ctx.get('userId'),
    });
  }

  @OnMessage('channel:typing')
  handleTyping(ctx: WsContext) {
    const { channelId } = ctx.data || {};
    const info = onlineUsers.get(ctx.id);
    ctx.to(`channel:${channelId}`).send('channel:typing', {
      channelId,
      userId: ctx.get('userId'),
      userName: info?.userName,
    });
  }

  @OnMessage('message:send')
  async handleSend(ctx: WsContext) {
    const userId = ctx.get('userId');
    const { channelId, content } = ctx.data || {};

    const message = await this.messageRepo.create({
      channelId, senderId: userId, content, mentions: [],
    });

    const payload = {
      messageId: message._id.toString(),
      channelId,
      senderId: userId,
      content: message.content,
      createdAt: message.createdAt,
    };

    ctx.to(`channel:${channelId}`).send('message:new', payload);
    ctx.send('message:new', payload); // Echo to sender
  }
}
Enter fullscreen mode Exit fullscreen mode

WebSocket events: channel:join, channel:leave, message:send, message:edit, message:delete, channel:typing, channel:stop_typing.

REST endpoints handle message history and editing: GET /channels/:channelId/messages, PATCH /messages/:messageId, DELETE /messages/:messageId.

SSE Live Stats

The stats module uses ctx.sse() for server-pushed updates:

@Controller()
@Middleware(authBridgeMiddleware)
export class StatsController {
  @Autowired() private taskRepo!: MongoTaskRepository;

  @Get('/projects/:projectId/stats/live')
  @Middleware(projectAccessGuard)
  async projectLive(ctx: RequestContext) {
    const sse = ctx.sse();
    const projectId = ctx.params.projectId;

    const sendStats = async () => {
      const tasksByStatus = await this.taskRepo.countByStatus(projectId);
      const totalTasks = Object.values(tasksByStatus).reduce((sum, c) => sum + c, 0);
      const doneTasks = tasksByStatus['done'] ?? 0;
      const completionRate = totalTasks > 0 ? Math.round((doneTasks / totalTasks) * 100) : 0;

      sse.send({ tasksByStatus, totalTasks, completionRate }, 'stats:update');
    };

    await sendStats();
    const interval = setInterval(sendStats, 10000);
    sse.onClose(() => clearInterval(interval));
  }
}
Enter fullscreen mode Exit fullscreen mode

SSE endpoints: workspace live stats, project live stats, workspace activity live.


7. Background Jobs: BullMQ Processors

Queue Configuration

// src/config/adapters.ts
const queueAdapter = new QueueAdapter({
  redis: {
    host: redisUrl.hostname,
    port: Number(redisUrl.port) || 6379,
    password: redisUrl.password || undefined,
  },
  queues: ['email', 'notifications', 'activity'],
  concurrency: 5,
});
Enter fullscreen mode Exit fullscreen mode

Email Processor

@Service()
@Job('email')
export class EmailProcessor {
  @Autowired(MAILER) private mailer!: MailerService;

  @Process('send-welcome-email')
  async sendWelcome(job: BullMQJob<{ email: string; firstName: string }>) {
    logger.info(`Sending welcome email to ${job.data.email}`);
    await this.mailer.send({
      to: job.data.email,
      subject: `Welcome to Vibed, ${job.data.firstName}!`,
      html: `<h1>Welcome to Vibed!</h1><p>Hi ${job.data.firstName}, your account is ready.</p>`,
    });
  }

  @Process('send-task-assigned')
  async sendTaskAssigned(job: BullMQJob<{ email: string; taskKey: string; taskTitle: string }>) {
    await this.mailer.send({
      to: job.data.email,
      subject: `You were assigned to ${job.data.taskKey}: ${job.data.taskTitle}`,
      html: `<p>You were assigned to <strong>${job.data.taskKey}</strong></p>`,
    });
  }

  @Process('send-overdue-reminder')
  async sendOverdueReminder(job: BullMQJob<{ email: string; taskKey: string; dueDate: string }>) {
    await this.mailer.send({
      to: job.data.email,
      subject: `Overdue: ${job.data.taskKey}`,
      html: `<p>Task ${job.data.taskKey} was due on ${job.data.dueDate}</p>`,
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

The QueueModule imports processors as side-effects so @Job() decorators register in the job registry:

// src/modules/queue/queue.module.ts
import './infrastructure/processors/email.processor';
import './infrastructure/processors/notification.processor';
import './infrastructure/processors/activity.processor';

export class QueueModule implements AppModule {
  register(_container: Container): void {
    // QueueAdapter v1.2.6+ auto-registers @Job classes
  }

  routes(): ModuleRoutes | null {
    return null; // No HTTP routes
  }
}
Enter fullscreen mode Exit fullscreen mode

Cron Jobs

@Service()
export class PresenceCronJobs {
  @Cron('*/5 * * * *', { description: 'Clean up stale presence entries' })
  async cleanupPresence() {
    logger.info('Running presence cleanup...');
  }
}
Enter fullscreen mode Exit fullscreen mode

Cron services are registered in the CronAdapter, not in the modules array:

new CronAdapter({
  services: [TaskCronJobs, DigestCronJobs, CleanupCronJobs, PresenceCronJobs, HealthCheckCronJobs],
  enabled: true,
});
Enter fullscreen mode Exit fullscreen mode

8. Pagination: ctx.paginate and Query Configs

Defining Query Configs

All query configurations are centralized in shared/constants/query-configs.ts:

import type { ApiQueryParamsConfig } from '@forinda/kickjs-core';

export const TASK_QUERY_CONFIG: ApiQueryParamsConfig = {
  filterable: ['status', 'priority', 'assigneeId', 'labelId', 'projectId'],
  sortable: ['createdAt', 'title', 'priority', 'dueDate', 'orderIndex'],
  searchable: ['title', 'description'],
};

export const WORKSPACE_QUERY_CONFIG: ApiQueryParamsConfig = {
  sortable: ['name', 'createdAt'],
  searchable: ['name', 'description'],
};
Enter fullscreen mode Exit fullscreen mode

Using ctx.paginate

The controller passes a fetcher function and the config to ctx.paginate():

@Get('/projects/:projectId/tasks')
@ApiQueryParams(TASK_QUERY_CONFIG)
async list(ctx: RequestContext) {
  await ctx.paginate(
    async (parsed) => {
      parsed.filters.push({ field: 'projectId', operator: 'eq', value: ctx.params.projectId });
      return this.taskRepo.findPaginated(parsed);
    },
    TASK_QUERY_CONFIG,
  );
}
Enter fullscreen mode Exit fullscreen mode

ctx.paginate() calls ctx.qs() internally to parse query string parameters, then calls the fetcher with the parsed result. The response includes pagination metadata:

{
  "data": [...],
  "meta": {
    "page": 1,
    "limit": 20,
    "total": 42,
    "totalPages": 3,
    "hasNext": true,
    "hasPrev": false
  }
}
Enter fullscreen mode Exit fullscreen mode

Query Helpers

Repositories use helper functions to convert parsed query params to Mongoose operations:

export function buildMongoFilter(
  filters: Array<{ field: string; operator: string; value: string }>,
): Record<string, any> {
  const mongoFilter: Record<string, any> = {};
  for (const { field, operator, value } of filters) {
    switch (operator) {
      case 'eq': mongoFilter[field] = value; break;
      case 'neq': mongoFilter[field] = { $ne: value }; break;
      case 'in': mongoFilter[field] = { $in: value.split(',') }; break;
      case 'contains': mongoFilter[field] = { $regex: value, $options: 'i' }; break;
      // ... gt, gte, lt, lte, between, starts, ends
    }
  }
  return mongoFilter;
}

export function buildMongoSort(
  sort: Array<{ field: string; direction: 'asc' | 'desc' }>,
): Record<string, 1 | -1> {
  const mongoSort: Record<string, 1 | -1> = {};
  for (const { field, direction } of sort) {
    mongoSort[field] = direction === 'asc' ? 1 : -1;
  }
  if (Object.keys(mongoSort).length === 0) mongoSort.createdAt = -1;
  return mongoSort;
}
Enter fullscreen mode Exit fullscreen mode

9. DI Tokens

All DI Symbol tokens are centralized in one file:

// src/shared/constants/tokens.ts
export const TOKENS = {
  USER_REPOSITORY: Symbol('UserRepository'),
  REFRESH_TOKEN_REPOSITORY: Symbol('RefreshTokenRepository'),
  WORKSPACE_REPOSITORY: Symbol('WorkspaceRepository'),
  WORKSPACE_MEMBER_REPOSITORY: Symbol('WorkspaceMemberRepository'),
  PROJECT_REPOSITORY: Symbol('ProjectRepository'),
  TASK_REPOSITORY: Symbol('TaskRepository'),
  COMMENT_REPOSITORY: Symbol('CommentRepository'),
  LABEL_REPOSITORY: Symbol('LabelRepository'),
  CHANNEL_REPOSITORY: Symbol('ChannelRepository'),
  MESSAGE_REPOSITORY: Symbol('MessageRepository'),
  NOTIFICATION_REPOSITORY: Symbol('NotificationRepository'),
  ACTIVITY_REPOSITORY: Symbol('ActivityRepository'),
  ATTACHMENT_REPOSITORY: Symbol('AttachmentRepository'),
  PRESENCE_SERVICE: Symbol('PresenceService'),
  QUEUE_SERVICE: Symbol('QueueService'),
} as const;
Enter fullscreen mode Exit fullscreen mode

10. Full Module Structure

src/
├── index.ts                          # Entry point
├── config/
│   ├── env.ts                        # Zod env validation
│   ├── adapters.ts                   # All adapter configurations
│   └── middleware.ts                  # Global Express middleware
├── shared/
│   ├── constants/
│   │   ├── tokens.ts                 # DI Symbol tokens
│   │   ├── error-codes.ts            # Error code enum
│   │   └── query-configs.ts          # Pagination configs
│   ├── guards/
│   │   ├── workspace-membership.guard.ts
│   │   ├── project-access.guard.ts
│   │   └── channel-membership.guard.ts
│   ├── presentation/middlewares/
│   │   ├── auth-bridge.middleware.ts
│   │   └── request-id.middleware.ts
│   ├── utils/
│   │   └── auth.ts                   # getUser(ctx) helper
│   └── infrastructure/
│       ├── database/
│       │   ├── mongoose.adapter.ts
│       │   └── query-helpers.ts
│       ├── redis/
│       │   └── redis.config.ts
│       └── mail/
│           └── resend.provider.ts
└── modules/
    ├── index.ts                      # Module registry
    ├── auth/                         # Register, login, refresh, logout
    ├── users/                        # Profile CRUD
    ├── workspaces/                   # CRUD + membership + invite
    ├── projects/                     # CRUD + board view
    ├── tasks/                        # CRUD + status/priority/assignees/reorder
    ├── comments/                     # CRUD + @mention parsing
    ├── labels/                       # Workspace-scoped label CRUD
    ├── channels/                     # Chat channel CRUD + membership
    ├── messages/                     # REST history + WebSocket chat
    ├── notifications/                # In-app notifications + unread count
    ├── activity/                     # Activity feed
    ├── attachments/                  # File upload
    ├── stats/                        # SSE live dashboards
    ├── queue/                        # BullMQ processors
    └── cron/                         # Scheduled jobs
Enter fullscreen mode Exit fullscreen mode

11. API Endpoint Summary

Base prefix: /api/v1

Auth (/api/v1/auth) — No auth required

Method Path Description
POST /register Register new user
POST /login Login with credentials
POST /refresh Refresh JWT token pair
POST /logout Logout (invalidate refresh)

Users (/api/v1/users)

Method Path Description
GET /me Current user profile
PATCH /me Update profile
GET /:id Get user by ID
GET / List users (paginated)

Workspaces (/api/v1/workspaces)

Method Path Description
POST / Create workspace
GET / List user's workspaces
GET /:workspaceId Get workspace
PATCH /:workspaceId Update workspace
DELETE /:workspaceId Delete workspace
POST /:workspaceId/invite Invite member
GET /:workspaceId/members List members
PATCH /:workspaceId/members/:userId Update member role
DELETE /:workspaceId/members/:userId Remove member
POST /:workspaceId/leave Leave workspace

Projects (/api/v1)

Method Path Description
POST /workspaces/:workspaceId/projects Create project
GET /workspaces/:workspaceId/projects List projects
GET /projects/:projectId Get project
PATCH /projects/:projectId Update project
DELETE /projects/:projectId Delete project
GET /projects/:projectId/board Board view

Tasks (/api/v1)

Method Path Description
POST /projects/:projectId/tasks Create task
GET /projects/:projectId/tasks List tasks (paginated)
GET /tasks/:taskId Get task
PATCH /tasks/:taskId Update task
DELETE /tasks/:taskId Delete task
PATCH /tasks/:taskId/status Change status
PATCH /tasks/:taskId/assignees Update assignees
POST /tasks/:taskId/reorder Reorder in column
GET /tasks/:taskId/subtasks List subtasks

Comments (/api/v1)

Method Path Description
POST /tasks/:taskId/comments Create comment
GET /tasks/:taskId/comments List comments
PATCH /comments/:commentId Update comment
DELETE /comments/:commentId Delete comment

Labels (/api/v1)

Method Path Description
POST /workspaces/:workspaceId/labels Create label
GET /workspaces/:workspaceId/labels List labels
PATCH /labels/:labelId Update label
DELETE /labels/:labelId Delete label

Attachments (/api/v1)

Method Path Description
POST /tasks/:taskId/attachments Upload
GET /tasks/:taskId/attachments List
GET /attachments/:attachmentId Metadata
GET /attachments/:attachmentId/download Download
DELETE /attachments/:attachmentId Delete

Channels (/api/v1)

Method Path Description
POST /workspaces/:workspaceId/channels Create channel
GET /workspaces/:workspaceId/channels List channels
GET /channels/:channelId Get channel
DELETE /channels/:channelId Delete channel
POST /channels/:channelId/members Add member
DELETE /channels/:channelId/members/:userId Remove member

Messages (/api/v1)

Method Path Description
GET /channels/:channelId/messages Message history
PATCH /messages/:messageId Edit message
DELETE /messages/:messageId Delete message

Notifications (/api/v1/notifications)

Method Path Description
GET / List (paginated)
PATCH /:id/read Mark as read
POST /read-all Mark all as read
GET /unread-count Unread count

Activity (/api/v1)

Method Path Description
GET /workspaces/:workspaceId/activity Workspace activity
GET /projects/:projectId/activity Project activity
GET /tasks/:taskId/activity Task activity

Stats — SSE (/api/v1)

Method Path Description
GET /workspaces/:workspaceId/stats/live SSE workspace stats
GET /projects/:projectId/stats/live SSE project stats
GET /workspaces/:workspaceId/activity/live SSE activity stream

WebSocket (/ws/chat)

Event Direction Description
channel:join Client -> Server Join room
channel:leave Client -> Server Leave room
message:send Client -> Server Send message
message:edit Client -> Server Edit message
message:delete Client -> Server Delete message
channel:typing Client -> Server Typing indicator
channel:stop_typing Client -> Server Stop typing

12. Framework Issues and Contributing Back

We tracked 18 framework issues in framework-filed-issues/ — 13 bug reports, 1 documentation issue, and 4 feature requests. Seven issues were fixed across four KickJS releases (v1.2.3, v1.2.5, v1.2.6, v1.2.7).

Key issues and their resolutions:

Issue Problem Resolution
KICK-003 Modules without routes crash Express v1.2.3: routes() can return null
KICK-009 ctx.set/get not shared across middleware/handler v1.2.5: Metadata Map stored on req
KICK-016 @Service + @Job not auto-registered in DI v1.2.6: QueueAdapter auto-registers
KICK-017 @Service() should mean auto-DI-registration v1.2.7: Container.bootstrap() scans

The feedback loop that made this work: discover the bug during development, build a workaround, file a detailed issue with a suggested fix, validate the upstream fix, remove the workaround, update the docs.


13. What's Next

Features planned but not yet implemented:

Typed API Client (KICK-018)

A kick generate:client command that produces a tRPC-like typed client from the existing route decorators and Zod DTOs. All the metadata exists — it just needs to be surfaced to a client generator.

Subtask CRUD

The task schema already has parentTaskId. Full subtask CRUD (create, list, reparent, delete) is the next module addition.

Forgot Password Flow

The email processor already has send-password-reset. The missing piece is the auth controller endpoint (POST /auth/forgot-password) and a time-limited reset token stored in Redis.

Redis-Backed Presence

The current in-memory onlineUsers Map works for single-instance deployments. For horizontal scaling, presence needs to move to Redis with the @socket.io/redis-adapter.

Full-Text Search Upgrade

The current $text search works but is limited. Moving to MongoDB Atlas Search or Elasticsearch would enable fuzzy matching, typo tolerance, and field-weighted ranking.


Quick Reference

Commands

kick dev              # Dev server with Vite HMR
kick build            # Production build
kick start            # Run production build
kick g module <name>  # Generate DDD module scaffold
kick g controller <n> # Generate controller
kick g dto <name>     # Generate Zod DTO
Enter fullscreen mode Exit fullscreen mode

What Survives HMR

  • Controller logic, use cases, DTOs, guards, middleware, Mongoose schemas (with guard)

What Needs Full Restart

  • Adapter config changes, new modules in array, new adapters, queue processor class changes

Key File Paths

File Purpose
src/index.ts Entry point
src/config/adapters.ts All adapter configurations
src/config/env.ts Zod env validation
src/modules/index.ts Module registry
src/shared/constants/tokens.ts DI Symbol tokens
src/shared/constants/query-configs.ts Pagination configs
src/shared/utils/auth.ts getUser(ctx) helper
src/shared/guards/ Access control guards
framework-filed-issues/ Framework issue tracker

This is the final article in the "Building with KickJS" series. The full project source is available on GitHub. If you build something with KickJS, I'd genuinely like to hear about it.

Top comments (0)