Skip to main content

Adding a New Module

Follow the V-Model workflow for implementing any feature in Tonnex.

Step 1: Define Validators

Create Zod schemas in packages/utils/src/validators/<module>.ts:
import { z } from "zod";

export const createVehicleSchema = z.object({
  orgId: z.string().uuid(),
  regNumber: z.string().min(1, "Registration number is required"),
  type: z.enum(["truck", "trailer", "tanker"]),
  make: z.string().min(1),
  model: z.string().min(1),
  year: z.number().min(1900).max(2100),
});

export type CreateVehicleInput = z.infer<typeof createVehicleSchema>;
Export from packages/utils/src/validators/index.ts and packages/utils/src/index.ts.

Step 2: Define Database Schema

Create the Drizzle schema in packages/db/src/schema/<module>.ts:
import { pgTable, uuid, varchar, integer, timestamp, pgPolicy } from "drizzle-orm/pg-core";
import { organizations } from "./auth";
import { authenticatedRole, isOrgMember, isOrgAdmin } from "./rls";

export const vehicles = pgTable("vehicles", {
  id: uuid("id").defaultRandom().primaryKey(),
  orgId: uuid("org_id").references(() => organizations.id, { onDelete: "cascade" }).notNull(),
  regNumber: varchar("reg_number", { length: 20 }).notNull(),
  type: varchar("type", { length: 50 }).notNull(),
  deletedAt: timestamp("deleted_at", { withTimezone: true }),
  createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
}, (table) => [
  pgPolicy("members_can_view", { for: "select", to: authenticatedRole, using: isOrgMember(table.orgId) }),
  pgPolicy("admins_can_manage", { for: "all", to: authenticatedRole, using: isOrgAdmin(table.orgId), withCheck: isOrgAdmin(table.orgId) }),
]);

vehicles.enableRLS();
Then export from packages/db/src/schema/index.ts and run:
pnpm db:generate
pnpm db:migrate

Step 3: Add Resource Type for Scoping (if applicable)

If this module’s data should be filterable by location/warehouse, add the resource type to resourceTypeEnum in packages/db/src/schema/rbac.ts:
export const resourceTypeEnum = pgEnum("resource_type", [
    "bank_account", "client", "vehicle", /* add here */
]);
Then regenerate and migrate.

Step 4: Create API Module

Create the NestJS module in apps/api/src/<module>/:
apps/api/src/vehicles/
├── vehicles.module.ts
├── vehicles.controller.ts
├── vehicles.service.ts
└── dto/vehicle.dto.ts

Module — Import RbacModule

import { Module } from "@nestjs/common";
import { VehiclesController } from "./vehicles.controller";
import { VehiclesService } from "./vehicles.service";
import { RbacModule } from "../rbac/rbac.module";

@Module({
    imports: [RbacModule],        // ← REQUIRED for scope filtering
    controllers: [VehiclesController],
    providers: [VehiclesService],
    exports: [VehiclesService],
})
export class VehiclesModule {}

Controller — Add RBAC Guards

@ApiTags("Vehicles")
@UseGuards(SupabaseGuard, RbacGuard)
@Controller("vehicles")
export class VehiclesController {
    constructor(private readonly vehiclesService: VehiclesService) {}

    @Post()
    @RequirePermission("fleet", "create")   // ← ALWAYS add
    create(@Req() req: any, @Body() dto: CreateVehicleDto) {
        return this.vehiclesService.create(req.token, dto);
    }

    @Get("org/:orgId")
    @RequirePermission("fleet", "view")
    listByOrg(@Req() req: any, @Param("orgId") orgId: string) {
        return this.vehiclesService.listByOrg(req.token, orgId);
    }
}

Service — Add Scope Filtering

@Injectable()
export class VehiclesService {
    constructor(
        private readonly scopeResolver: ScopeResolverService,
        private readonly scopeService: ScopeService,
    ) {}

    async listByOrg(token: string, orgId: string) {
        const db = createDrizzleSupabaseClient(token);
        const jwtPayload = JSON.parse(Buffer.from(token.split(".")[1]!, "base64").toString());
        const userScope = await this.scopeResolver.resolve(token, jwtPayload.sub, orgId);
        const visibleIds = await this.scopeService.getVisibleResourceIds(token, orgId, "vehicle", userScope);

        return await db.rls(async (tx) => {
            const base = and(eq(schema.vehicles.orgId, orgId), isNull(schema.vehicles.deletedAt));
            const condition = visibleIds === null ? base : and(base, visibleIds.length > 0 ? inArray(schema.vehicles.id, visibleIds) : sql`false`);

            const result = await tx.query.vehicles.findMany({ where: condition });
            return { data: result, reasoning: `Retrieved ${result.length} vehicles.` };
        });
    }
}

Step 5: Register in App Module

Add to apps/api/src/app.module.ts:
import { VehiclesModule } from "./vehicles/vehicles.module";

@Module({
    imports: [
        // ... existing modules
        VehiclesModule,
    ],
})
export class AppModule {}

Step 6: Create Frontend Pages

In apps/web/app/(dashboard)/vehicles/page.tsx, use DiceUI Data Table for the list view.

Step 7: Update Documentation

Create a guide at apps/docs/guides/<module>.mdx with:
  • Architecture diagram
  • Schema reference
  • API endpoint table
  • Usage examples
Every table MUST include org_id for RLS. Tables without org_id will not be protected by Row Level Security.
Every controller MUST use @UseGuards(SupabaseGuard, RbacGuard) and every method MUST have @RequirePermission.
NEVER create per-module junction tables for scoping. ALWAYS use the resource_scopes table via ScopeService.