Initial commit: YouTube Studio Flow (backend, frontend, infrastructure, docs)
This commit is contained in:
+251
@@ -0,0 +1,251 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user