Aller au contenu principal

Adding a New Module — HeartCo

Complete guide to scaffold a new feature module in 9 steps.

Overview

Each module in HeartCo follows a standardized structure: Prisma model → tRPC router → App Router page → navigation config → tests → deployment. The src/modules/_template/ folder provides a boilerplate to copy.


Step 1: Copy the Template Module

cp -r src/modules/_template src/modules/tasks

This creates the directory structure:

src/modules/tasks/
├── README.md                  # Your module documentation
├── router.ts                  # tRPC procedures
├── page.tsx                   # Next.js page
├── components/
│   ├── DataTable.tsx          # List view with sorting/filtering
│   ├── ItemForm.tsx           # Form component
│   └── ItemDialog.tsx         # Create/edit dialog
└── schema.prisma.example      # Prisma model template

Step 2: Define the Prisma Model

Open prisma/schema.prisma and add your model. Example for Tasks:

model Task {
  id            String   @id @default(cuid())
 
  // Multi-tenant scope (MANDATORY)
  organizationId String
  organization  Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
 
  // Core fields
  title         String   @db.VarChar(255)
  description   String?  @db.Text
  status        String   @default("DRAFT") // DRAFT | ACTIVE | COMPLETED
 
  // Assignment
  assigneeId    String?
  assignee      User?    @relation("taskAssignee", fields: [assigneeId], references: [id], onDelete: SetNull)
 
  // Metadata
  createdById   String
  createdBy     User     @relation("taskCreatedBy", fields: [createdById], references: [id], onDelete: Cascade)
  createdAt     DateTime @default(now())
  updatedAt     DateTime @updatedAt
 
  // Indexes (MANDATORY for multi-tenant queries)
  @@index([organizationId])
  @@index([assigneeId])
}

Key Rules:

  • Every multi-tenant model must have organizationId + @@index([organizationId])
  • Use findFirst() to query by ID + organizationId (never findUnique())
  • Store createdById for ownership tracking and audit logs

Step 3: Add Model to Prisma Org-Scope

Edit src/lib/prisma-org-scope.ts — add your model to the set of org-scoped models:

const ORG_SCOPED_MODELS = new Set([
  'Task',           // ← Add here
  'Quote',
  'Invoice',
  // ... other models
]);

This enables automatic organizationId filtering in ctx.orgDb for reads.


Step 4: Create the tRPC Router

Rename src/modules/tasks/router.ts from the template and customize:

import { z } from 'zod';
import { TRPCError } from '@trpc/server';
import { staffProcedure, createTRPCRouter, requirePermission } from '~/server/api/trpc';
 
// ============================================
// Schemas
// ============================================
 
const createTaskSchema = z.object({
  title: z.string().min(1).max(255),
  description: z.string().max(2000).optional(),
  assigneeId: z.string().cuid().optional(),
});
 
const updateTaskSchema = z.object({
  id: z.string().cuid(),
  title: z.string().min(1).max(255).optional(),
  status: z.enum(['DRAFT', 'ACTIVE', 'COMPLETED']).optional(),
  assigneeId: z.string().cuid().optional(),
});
 
const listTasksSchema = z.object({
  cursor: z.string().cuid().optional(),
  limit: z.number().min(1).max(100).default(20),
  search: z.string().max(100).optional(),
  status: z.enum(['DRAFT', 'ACTIVE', 'COMPLETED']).optional(),
  sortBy: z.enum(['createdAt', 'title', 'updatedAt']).default('createdAt'),
  sortOrder: z.enum(['asc', 'desc']).default('desc'),
});
 
// ============================================
// Router
// ============================================
 
export const tasksRouter = createTRPCRouter({
  /**
   * List with cursor pagination + filtering
   */
  list: requirePermission('task:read')
    .input(listTasksSchema)
    .query(async ({ ctx, input }) => {
      const { cursor, limit, search, status, sortBy, sortOrder } = input;
 
      const where = {
        organizationId: ctx.session.user.organizationId,
        ...(status && { status }),
        ...(search && {
          title: { contains: search, mode: 'insensitive' as const },
        }),
      };
 
      const items = await ctx.db.task.findMany({
        where,
        take: limit + 1,
        ...(cursor && { cursor: { id: cursor }, skip: 1 }),
        orderBy: { [sortBy]: sortOrder },
        select: {
          id: true,
          title: true,
          status: true,
          createdAt: true,
          assignee: { select: { id: true, name: true } },
        },
      });
 
      let nextCursor: string | undefined;
      if (items.length > limit) {
        const nextItem = items.pop();
        nextCursor = nextItem?.id;
      }
 
      return { items, nextCursor };
    }),
 
  /**
   * Get single task
   */
  getById: requirePermission('task:read')
    .input(z.object({ id: z.string().cuid() }))
    .query(async ({ ctx, input }) => {
      const task = await ctx.db.task.findFirst({
        where: {
          id: input.id,
          organizationId: ctx.session.user.organizationId,
        },
      });
 
      if (!task) {
        throw new TRPCError({ code: 'NOT_FOUND', message: 'Task not found' });
      }
 
      return task;
    }),
 
  /**
   * Create task
   */
  create: requirePermission('task:create')
    .input(createTaskSchema)
    .mutation(async ({ ctx, input }) => {
      return ctx.db.task.create({
        data: {
          ...input,
          organizationId: ctx.session.user.organizationId,
          createdById: ctx.session.user.id,
        },
        select: { id: true, title: true },
      });
    }),
 
  /**
   * Update task
   */
  update: requirePermission('task:update')
    .input(updateTaskSchema)
    .mutation(async ({ ctx, input }) => {
      const { id, ...data } = input;
 
      // Verify ownership
      const existing = await ctx.db.task.findFirst({
        where: { id, organizationId: ctx.session.user.organizationId },
        select: { id: true },
      });
 
      if (!existing) {
        throw new TRPCError({ code: 'NOT_FOUND', message: 'Task not found' });
      }
 
      return ctx.db.task.update({ where: { id }, data });
    }),
 
  /**
   * Delete task
   */
  delete: requirePermission('task:delete')
    .input(z.object({ id: z.string().cuid() }))
    .mutation(async ({ ctx, input }) => {
      const existing = await ctx.db.task.findFirst({
        where: {
          id: input.id,
          organizationId: ctx.session.user.organizationId,
        },
        select: { id: true },
      });
 
      if (!existing) {
        throw new TRPCError({ code: 'NOT_FOUND', message: 'Task not found' });
      }
 
      await ctx.db.task.delete({ where: { id: input.id } });
      return { success: true };
    }),
});

Security Rules:

  • Always use findFirst() with organizationId filter
  • Return NOT_FOUND (not FORBIDDEN) for IDOR violations
  • Use requirePermission() instead of staffProcedure for granular RBAC
  • Throw TRPCError (never Error)

Step 5: Register Router in root.ts

Edit src/server/api/root.ts:

import { tasksRouter } from "~/server/api/routers/tasks";
 
export const appRouter = createTRPCRouter({
  tasks: tasksRouter,    // ← Add here
  facturation: facturationRouter,
  clients: clientsRouter,
  // ... other routers
});

Step 6: Create the App Router Page

Create src/app/dashboard/tasks/page.tsx:

'use client';
 
import { api } from '~/trpc/react';
import { TaskDataTable } from '~/components/tasks/DataTable';
import { TaskDialog } from '~/components/tasks/ItemDialog';
import { Button } from '~/components/ui/button';
import { useState } from 'react';
 
export default function TasksPage() {
  const [isOpen, setIsOpen] = useState(false);
  const { data, isLoading } = api.tasks.list.useQuery({ limit: 20 });
 
  return (
    <div className="space-y-4 p-6">
      <div className="flex justify-between items-center">
        <h1 className="text-3xl font-bold">Tasks</h1>
        <Button onClick={() => setIsOpen(true)}>New Task</Button>
      </div>
 
      <TaskDataTable data={data?.items || []} isLoading={isLoading} />
      <TaskDialog open={isOpen} onOpenChange={setIsOpen} />
    </div>
  );
}

Step 7: Add to Navigation Config

Edit src/components/layout/sidebar.tsx or your nav config:

{
  title: 'Tasks',
  href: '/dashboard/tasks',
  icon: CheckCircle2,
  requiredRole: 'STAFF',
}

Step 8: Add to Tier Gating (Optional)

If your feature is plan-restricted, edit src/lib/config/tiers.ts:

export const FEATURE_TIERS = {
  task_management: {
    free: false,
    starter: true,
    pro: true,
    business: true,
    enterprise: true,
  },
};

Step 9: Write Tests & Run Pipeline

Create src/server/api/routers/__tests__/tasks.test.ts:

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { tasksRouter } from '../tasks';
 
describe('tasksRouter', () => {
  let ctx: any;
 
  beforeEach(() => {
    ctx = {
      session: { user: { id: 'user1', organizationId: 'org1' } },
      db: { task: {} },
    };
  });
 
  it('should list tasks by organization', async () => {
    const router = tasksRouter.createCaller(ctx);
    // Mock ctx.db.task.findMany
    expect(true).toBe(true);
  });
});

Run the full pipeline:

# 1. Generate Prisma client
npx prisma generate
 
# 2. Create migration (don't run yet — wait for review)
npx prisma migrate dev --name add_tasks
 
# 3. Type check
pnpm typecheck
 
# 4. Tests
pnpm test
 
# 5. Lint & format
pnpm lint:fix
pnpm format:write
 
# 6. Commit
git add -A
git commit -m "feat: add tasks module with CRUD and pagination"

Final Checklist

  • Prisma model created with organizationId + index
  • Model added to ORG_SCOPED_MODELS
  • Router exported from root.ts
  • Page created at /dashboard/tasks
  • Navigation updated
  • Tests written
  • Pipeline passed (typecheck, tests, lint)
  • Committed with descriptive message

You're ready to deploy!