> ## Documentation Index
> Fetch the complete documentation index at: https://docs.avyratech.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Adding a Module

> Step-by-step guide to implementing a new business module in Tonnex.

# 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`:

```typescript theme={null}
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`:

```typescript theme={null}
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:

```bash theme={null}
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`:

```typescript theme={null}
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

```typescript theme={null}
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

```typescript theme={null}
@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

```typescript theme={null}
@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`:

```typescript theme={null}
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

<Warning>
  Every table **MUST** include `org_id` for RLS. Tables without `org_id` will not be protected by Row Level Security.
</Warning>

<Warning>
  Every controller **MUST** use `@UseGuards(SupabaseGuard, RbacGuard)` and every method **MUST** have `@RequirePermission`.
</Warning>

<Warning>
  NEVER create per-module junction tables for scoping. ALWAYS use the `resource_scopes` table via `ScopeService`.
</Warning>
