Skip to content

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:

  1. Add a Note model to the Prisma schema
  2. Create and apply a migration
  3. Scaffold a NestJS module with a basic CRUD endpoint
  4. Regenerate the typed frontend SDK
  5. Display notes in a new Vue view

Prerequisites

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:migrate

Prisma 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/note

Create 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 openapi

This 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.

TT Time Tracker — Internal Documentation