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/tasksThis 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 (neverfindUnique()) - Store
createdByIdfor 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()withorganizationIdfilter - Return
NOT_FOUND(notFORBIDDEN) for IDOR violations - Use
requirePermission()instead ofstaffProcedurefor granular RBAC - Throw
TRPCError(neverError)
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!