252 lines
6.9 KiB
Markdown
252 lines
6.9 KiB
Markdown
# 04 - Backend Architecture Patterns
|
|
|
|
Established patterns for the NestJS backend of YouTube Studio Flow. These rules define how modules, services, and controllers are structured. For frontend patterns, see [[03 - Component Patterns]].
|
|
|
|
---
|
|
|
|
## Module structure
|
|
|
|
Every feature module follows this layout:
|
|
|
|
```
|
|
modules/feature-name/
|
|
feature-name.module.ts
|
|
feature-name.controller.ts
|
|
feature-name.service.ts
|
|
dto/
|
|
create-feature.dto.ts
|
|
update-feature.dto.ts
|
|
```
|
|
|
|
The `dto/` folder is optional for read-only modules, but required for any module that accepts request bodies.
|
|
|
|
---
|
|
|
|
## Controller responsibilities
|
|
|
|
Controllers handle routing and guards only. They must not contain business logic.
|
|
|
|
**What belongs in a controller:**
|
|
- Route decorators (`@Get`, `@Post`, `@Patch`, `@Delete`)
|
|
- Guard declarations (`@UseGuards`)
|
|
- Role decorators (`@Roles`)
|
|
- Extracting `req.user.id` and `req.user.teamId`
|
|
- Calling one service method and returning the result
|
|
|
|
**What does not belong in a controller:**
|
|
- Database queries
|
|
- Conditional logic
|
|
- Transformations beyond passing arguments
|
|
|
|
### Auth pattern
|
|
|
|
```typescript
|
|
@UseGuards(JwtAuthGuard)
|
|
@Controller('blocks')
|
|
export class BlocksController {
|
|
// Read operations — JwtAuthGuard on class is sufficient
|
|
|
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
|
@Roles(TeamRole.EDITOR)
|
|
@Post()
|
|
create(@Request() req, @Body() dto: CreateBlockDto) {
|
|
return this.blocksService.create(req.user.teamId, req.user.id, dto);
|
|
}
|
|
}
|
|
```
|
|
|
|
- `JwtAuthGuard` on the class: all routes require authentication
|
|
- `RolesGuard` + `@Roles(TeamRole.EDITOR)` on write methods only
|
|
|
|
---
|
|
|
|
## Service responsibilities
|
|
|
|
All business logic lives in services.
|
|
|
|
**What belongs in a service:**
|
|
- All database access via `PrismaService`
|
|
- Team scoping on every query
|
|
- Audit logging for every mutation
|
|
- Queue enqueue calls
|
|
- Validation that requires database state
|
|
|
|
Services receive `teamId` and `actorId` as arguments from the controller — they never extract these from a request object.
|
|
|
|
---
|
|
|
|
## Team scoping — the cardinal rule
|
|
|
|
Every database query must be scoped to the current team. The ownership chain is:
|
|
|
|
```
|
|
Team → Channel → Video
|
|
```
|
|
|
|
For video queries, the scope travels through the channel:
|
|
|
|
```typescript
|
|
// CORRECT
|
|
await this.prisma.video.findMany({
|
|
where: { channel: { teamId } },
|
|
});
|
|
|
|
// WRONG — missing team scope
|
|
await this.prisma.video.findMany({
|
|
where: { id: videoId },
|
|
});
|
|
```
|
|
|
|
For resources owned directly by a team (blocks, templates, collaborators, variables):
|
|
|
|
```typescript
|
|
await this.prisma.descriptionBlock.findMany({
|
|
where: { teamId },
|
|
});
|
|
```
|
|
|
|
Violating team scoping is a data leak between tenants. There are no exceptions.
|
|
|
|
---
|
|
|
|
## Audit logging
|
|
|
|
Required for every user-facing mutation: create, update, and delete. Background/system operations (queue processors, scheduled jobs) do not get audit logs.
|
|
|
|
**Setup — in the module:**
|
|
|
|
```typescript
|
|
@Module({
|
|
imports: [AuditModule, PrismaModule],
|
|
controllers: [BlocksController],
|
|
providers: [BlocksService],
|
|
})
|
|
export class BlocksModule {}
|
|
```
|
|
|
|
**Usage — in the service:**
|
|
|
|
```typescript
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly audit: AuditService,
|
|
) {}
|
|
|
|
async update(teamId: string, actorId: string, id: string, dto: UpdateBlockDto) {
|
|
const before = await this.prisma.descriptionBlock.findUnique({ where: { id } });
|
|
|
|
const after = await this.prisma.descriptionBlock.update({
|
|
where: { id },
|
|
data: dto,
|
|
});
|
|
|
|
await this.audit.log(actorId, 'DescriptionBlock', id, 'update', before, after);
|
|
return after;
|
|
}
|
|
```
|
|
|
|
Tracked entity types: `Video`, `VideoConfig`, `DescriptionBlock`, `Template`, `Collaborator`, `TeamVariable`, `SavedView`, `TeamMember`.
|
|
|
|
---
|
|
|
|
## Render engine usage
|
|
|
|
Never fetch blocks, variables, or collaborators manually to construct a render. Always go through `VideoRenderService`:
|
|
|
|
```typescript
|
|
// In module imports:
|
|
// VideoRenderModule (re-exports VideoRenderService)
|
|
|
|
constructor(private readonly videoRenderService: VideoRenderService) {}
|
|
|
|
const { rendered, hash } = await this.videoRenderService.render(videoId);
|
|
```
|
|
|
|
`VideoRenderService` is the single source of truth for fetching render data and delegating to `RenderEngineService`. The only exceptions are `video-configs.service.ts` and `templates.service.ts`, which may call `RenderEngineService` directly for preview rendering because they already hold all the data.
|
|
|
|
See the CLAUDE.md render engine section for the full block type behavior reference.
|
|
|
|
---
|
|
|
|
## YouTube API quota
|
|
|
|
All YouTube write operations cost quota. `videos.update` costs 50 units. The daily budget is 10,000 units, resetting at midnight Pacific Time.
|
|
|
|
Required pattern before every YouTube write:
|
|
|
|
```typescript
|
|
const ok = await this.quotaService.canSpend(50);
|
|
if (!ok) throw new Error('Quota exceeded');
|
|
|
|
await this.quotaService.spend(50, 'videos.update', { videoId, channelId });
|
|
|
|
// ... make the YouTube API call
|
|
```
|
|
|
|
Never make a YouTube write call without checking quota first.
|
|
|
|
---
|
|
|
|
## Queue enqueue pattern
|
|
|
|
Jobs are enqueued with a deterministic `jobId` to prevent duplicate queuing:
|
|
|
|
```typescript
|
|
// Standard enqueue — deduplicates by videoId:
|
|
await this.lintQueue.add('lint', { videoId }, {
|
|
jobId: `lint-${videoId}`,
|
|
});
|
|
|
|
// Forced rerun — bypass deduplication:
|
|
await this.lintQueue.add('lint', { videoId }, {
|
|
jobId: `lint-${videoId}-${Date.now()}`,
|
|
});
|
|
```
|
|
|
|
Queue names are defined in a `QUEUES` constant. Job name strings use kebab-case: `'lint'`, `'youtube-sync'`, `'render'`.
|
|
|
|
---
|
|
|
|
## Prisma enum rule
|
|
|
|
Never remove a value from a Prisma enum. Removing an enum value in PostgreSQL requires a raw SQL migration and risks data loss if existing rows reference the removed value.
|
|
|
|
When a concept is retired from the UI, mark it as deprecated in comments and hide it from the frontend. Leave the enum value in the schema.
|
|
|
|
Current example: `BlockType.GLOBAL` and `BlockType.REPEATABLE` are removed from the UI but remain in the DB enum.
|
|
|
|
---
|
|
|
|
## Schema change workflow
|
|
|
|
After any change to `backend/prisma/schema.prisma`:
|
|
|
|
```bash
|
|
# 1. Stop the running backend process
|
|
npx prisma generate # regenerates the Prisma client
|
|
npx prisma migrate deploy # applies pending migrations
|
|
# 2. Restart the backend
|
|
```
|
|
|
|
Run these from the `backend/` directory. Both the API process (`src/main.ts`) and the worker process (`src/worker.ts`) must be restarted.
|
|
|
|
---
|
|
|
|
## Error handling
|
|
|
|
Use NestJS built-in HTTP exceptions at service boundaries. Do not add generic `try/catch` blocks for errors that cannot occur.
|
|
|
|
| Situation | Exception |
|
|
|---|---|
|
|
| Entity not found | `NotFoundException` |
|
|
| Team scoping violation | `ForbiddenException` |
|
|
| Invalid input state | `BadRequestException` |
|
|
|
|
```typescript
|
|
const block = await this.prisma.descriptionBlock.findUnique({ where: { id } });
|
|
if (!block) throw new NotFoundException(`Block ${id} not found`);
|
|
if (block.teamId !== teamId) throw new ForbiddenException();
|
|
```
|
|
|
|
Let NestJS handle unhandled exceptions. The default exception filter returns structured error responses with the correct HTTP status codes.
|