Appearance
Build Your First Feature
This tutorial walks you through the complete development loop by adding a simple notes feature to TT Time Tracker. You will:
- Add a
Notemodel to the Prisma schema - Create and apply a migration
- Scaffold a NestJS module with a basic CRUD endpoint
- Regenerate the typed frontend SDK
- Display notes in a new Vue view
Prerequisites
- Completed the Local Development Setup tutorial
- All three services running (
dev:api,dev:worker,dev:client)
Step 1 — Add the Prisma model
Open packages/database/prisma/models/ and create a new file note.prisma:
prisma
model Note {
id String @id @default(cuid())
tenantId String
tenantUserId String
content String
createdAt DateTime @default(now())
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
tenantUser TenantUser @relation(fields: [tenantUserId], references: [id], onDelete: Cascade)
@@map("notes")
}Then add the reverse relation fields to the Tenant and TenantUser models in their respective .prisma files:
prisma
// In tenant.prisma, inside model Tenant { ... }
notes Note[]
// In tenant-user.prisma, inside model TenantUser { ... }
notes Note[]Step 2 — Create and apply the migration
bash
pnpm db:migratePrisma will detect the new model, prompt you to name the migration (e.g., add_notes), apply it to your local database, and regenerate the Prisma client.
Step 3 — Scaffold the NestJS module
Create the module directory:
bash
mkdir -p services/api/src/modules/noteCreate three files:
services/api/src/modules/note/note.module.ts
typescript
import { Module } from '@nestjs/common'
import { NoteController } from './note.controller'
import { NoteService } from './note.service'
@Module({
controllers: [NoteController],
providers: [NoteService],
})
export class NoteModule {}services/api/src/modules/note/note.service.ts
typescript
import { Injectable } from '@nestjs/common'
import { PrismaService } from '../../prisma/prisma.service'
@Injectable()
export class NoteService {
constructor(private readonly prisma: PrismaService) {}
findAll(tenantId: string, tenantUserId: string) {
return this.prisma.note.findMany({
where: { tenantId, tenantUserId },
orderBy: { createdAt: 'desc' },
})
}
create(tenantId: string, tenantUserId: string, content: string) {
return this.prisma.note.create({
data: { tenantId, tenantUserId, content },
})
}
}services/api/src/modules/note/note.controller.ts
typescript
import { Body, Controller, Get, Post } from '@nestjs/common'
import { ApiTags } from '@nestjs/swagger'
import { NoteService } from './note.service'
import { CheckAbility } from '../../casl/decorators'
import { TenantId, TenantUserId } from '../../auth/decorators'
@ApiTags('Notes')
@Controller('tenants/:tenantId/notes')
export class NoteController {
constructor(private readonly noteService: NoteService) {}
@Get()
@CheckAbility({ action: 'read', subject: 'Entry' })
findAll(@TenantId() tenantId: string, @TenantUserId() tenantUserId: string) {
return this.noteService.findAll(tenantId, tenantUserId)
}
@Post()
@CheckAbility({ action: 'create', subject: 'Entry' })
create(
@TenantId() tenantId: string,
@TenantUserId() tenantUserId: string,
@Body('content') content: string,
) {
return this.noteService.create(tenantId, tenantUserId, content)
}
}Register the module in services/api/src/app.module.ts by importing NoteModule and adding it to the imports array.
Step 4 — Regenerate the typed SDK
With the API running, export the updated OpenAPI spec and regenerate the frontend client:
bash
pnpm openapiThis reads openapi.json (auto-exported from the running API) and regenerates src/api/. Your new GET /tenants/:tenantId/notes and POST /tenants/:tenantId/notes endpoints are now available as type-safe functions.
Step 5 — Add a Vue view
Create src/views/Notes.vue:
vue
<script setup lang="ts">
import { Notes } from '@/api/sdk.gen'
import { useTenant } from '@/stores/tenant.store'
const tenant = useTenant()
const tenantId = computed(() => tenant.id!)
const { data: notes } = useQuery({
queryKey: ['notes', tenantId],
queryFn: () => Notes.noteControllerFindAll({ path: { tenantId: tenantId.value } }),
enabled: computed(() => !!tenantId.value),
})
</script>
<template>
<div class="p-4">
<h1 class="text-xl font-semibold mb-4">Notes</h1>
<ul v-if="notes?.data?.length">
<li v-for="note in notes.data" :key="note.id" class="mb-2 p-3 border rounded">
{{ note.content }}
</li>
</ul>
<p v-else class="text-gray-500">No notes yet.</p>
</div>
</template>Register the route in src/router/routes.ts inside the LayoutApp children:
typescript
{ path: 'notes', name: 'notes', component: () => import('@/views/Notes.vue') },Navigate to http://localhost:5173/notes to see it in action.
What you've learned
You've completed the full development loop:
- Schema change → Prisma model + migration
- API layer → NestJS module/controller/service with CASL guard
- Type safety → OpenAPI → generated SDK
- Frontend → Vue view consuming the typed SDK via TanStack Query
This is the same pattern used by every feature in the codebase. See the How-To Guides for more targeted instructions on each step.