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.