Initial commit: YouTube Studio Flow (backend, frontend, infrastructure, docs)

This commit is contained in:
2026-08-11 12:27:44 +02:00
commit d5af006443
304 changed files with 74604 additions and 0 deletions
@@ -0,0 +1,154 @@
# 01 - Visual Design
This document defines the visual language of YouTube Studio Flow. All UI work must follow these rules. For CSS implementation details, see [[02 - CSS Conventions]].
---
## Colors
The app uses a warm off-white background with a teal primary color. Two themes are supported: light and dark. Both use the same CSS variable names — the values swap via a `[data-theme="dark"]` selector in `globals.css`.
**Never use inline hex values.** Always reference the variables below. Test every new color usage in both themes before committing.
### Light Theme
| Variable | Value | Usage |
| --------------------------- | --------- | --------------------------------------------------------------------- |
| `--color-bg` | `#f7f6f2` | Page background |
| `--color-surface` | `#f9f8f5` | Card and panel backgrounds |
| `--color-surface-2` | `#fbfbf9` | Elevated surfaces (nested cards) |
| `--color-surface-offset` | `#f3f0ec` | Inset/recessed areas |
| `--color-border` | `#d4d1ca` | Borders |
| `--color-divider` | `#dcd9d5` | Subtle dividers between sections |
| `--color-text` | `#28251d` | Primary text |
| `--color-text-muted` | `#66645d` | Secondary/supporting text |
| `--color-text-faint` | `#9f9c94` | Placeholder and hint text |
| `--color-text-inverse` | `#f9f8f4` | Text placed on dark backgrounds |
| `--color-primary` | `#01696f` | Teal — primary actions, active nav, links |
| `--color-primary-hover` | `#0c4e54` | Primary button hover state |
| `--color-primary-highlight` | `#cedcd8` | Primary tint — active nav items, selection backgrounds |
| `--color-success` | `#437a22` | Success states |
| `--color-warning` | `#964219` | Warning states |
| `--color-error` | `#a12c7b` | Error states — magenta, not red |
| `--color-blue` | `#006494` | Info and link accent |
| `--color-purple` | `#7a39bb` | Secondary accent |
### Dark Theme
Dark theme uses the same variable names. Key differences:
- **Surfaces** shift to near-black: `#171614`, `#1c1b19`, `#201f1d`
- **Primary** lightens for contrast: `#4f98a3`
- **Error, success, and warning** all lighten to maintain legibility on dark backgrounds
When adding any new color usage, verify it works in both themes. The dark theme overrides are defined in `globals.css` under `[data-theme="dark"]`.
### Semantic mapping quick reference
| Situation | Variable |
|---|---|
| Page background | `--color-bg` |
| Card or panel | `--color-surface` |
| Input background | `--color-surface` |
| Sidebar / elevated container | `--color-surface-2` |
| Recessed input or inner area | `--color-surface-offset` |
| Standard border | `--color-border` |
| Divider line | `--color-divider` |
| Body copy | `--color-text` |
| Labels, captions | `--color-text-muted` |
| Placeholders | `--color-text-faint` |
| Primary button, active state | `--color-primary` |
| Hover on primary | `--color-primary-hover` |
| Active nav background | `--color-primary-highlight` |
---
## Typography
### Fonts
| Role | Family | Fallback | CSS Variable |
|---|---|---|---|
| Body / UI | General Sans | Inter, sans-serif | `--font-body` |
| Display / Headings | Cabinet Grotesk | Inter, sans-serif | `--font-display` |
Both fonts are loaded from the Fontshare CDN via `<link>` in `layout.tsx`. Do not use system fonts for headings.
### Type Scale
All sizes use fluid `clamp()` values so text scales smoothly between viewport breakpoints.
| Variable | Approximate range | Typical use |
|---|---|---|
| `--text-xs` | 0.75rem → 0.875rem | Badges, footnotes, timestamps |
| `--text-sm` | 0.875rem → 1rem | Labels, captions, secondary UI |
| `--text-base` | 1rem → 1.125rem | Body copy, inputs |
| `--text-lg` | 1.125rem → 1.5rem | Sub-headings, card titles |
| `--text-xl` | 1.5rem → 2.25rem | Section headings |
| `--text-2xl` | 2rem → 3.5rem | Page titles |
### Usage rules
- **Page titles** — `font-family: var(--font-display)`, `font-weight: 700`, `font-size: var(--text-xl)` or `--text-2xl`
- **Section labels** — `font-size: var(--text-sm)`, `text-transform: uppercase`, `letter-spacing: 0.05em`, `color: var(--color-text-muted)`
- **Body text** — `font-family: var(--font-body)`, `font-size: var(--text-base)`, `color: var(--color-text)`
- **Input text** — `font-size: var(--text-base)` or `--text-sm`
Never set font sizes in raw `px` or `rem` values — always use the scale variables.
---
## Spacing
Use the spacing scale for all margins, padding, and gaps. Never use raw pixel values in component CSS.
| Variable | Value | Rough equivalent |
|---|---|---|
| `--space-1` | `0.25rem` | 4px |
| `--space-2` | `0.5rem` | 8px |
| `--space-3` | `0.75rem` | 12px |
| `--space-4` | `1rem` | 16px |
| `--space-5` | `1.25rem` | 20px |
| `--space-6` | `1.5rem` | 24px |
| `--space-8` | `2rem` | 32px |
| `--space-10` | `2.5rem` | 40px |
| `--space-12` | `3rem` | 48px |
| `--space-16` | `4rem` | 64px |
---
## Borders and Radius
| Variable | Value | Typical use |
|---|---|---|
| `--radius-sm` | `0.375rem` | Small inputs, tight chips |
| `--radius-md` | `0.5rem` | Buttons, standard inputs |
| `--radius-lg` | `0.75rem` | Cards, panels |
| `--radius-xl` | `1rem` | Large cards, modals |
| `--radius-full` | `9999px` | Pills, badges, avatar circles, fully-round buttons |
All bordered elements use `--color-border` for their border color unless a semantic variant applies (e.g. `--color-error` for invalid inputs).
---
## Shadows
| Variable | Typical use |
|---|---|
| `--shadow-sm` | Subtle card lift — separates surface from background |
| `--shadow-md` | Popovers and dropdowns |
| `--shadow-lg` | Modals and full-screen overlays |
Use the lightest shadow that achieves the visual separation needed. Do not stack multiple shadows.
---
## Layout Constants
| Variable / Value | Definition |
|---|---|
| `--sidebar-width: 280px` | Expanded navigation sidebar |
| `--sidebar-width-collapsed: 64px` | Collapsed sidebar (icon-only) |
| `--header-height: 72px` | Top bar height |
The main content area is offset by the sidebar width. See [[03 - Component Patterns]] for the sidebar collapse pattern.
@@ -0,0 +1,188 @@
# 02 - CSS Conventions
Rules for writing CSS in YouTube Studio Flow. These apply to all frontend work. For the token values these rules reference, see [[01 - Visual Design]].
---
## Always use CSS Modules
Every component gets its own `ComponentName.module.css` file, co-located with the component. Class names are consumed as:
```tsx
import styles from './ComponentName.module.css';
<div className={styles.container}>...</div>
```
Global styles live exclusively in `globals.css`. Do not add component-specific rules to global files.
---
## Never use inline hex colors
All color values must come from CSS variables defined in `globals.css`. This is what makes the dark theme work — swapping variable values at the root switches the entire app.
```css
/* WRONG */
color: #01696f;
background: #f9f8f5;
border: 1px solid #d4d1ca;
/* CORRECT */
color: var(--color-primary);
background: var(--color-surface);
border: 1px solid var(--color-border);
```
This applies everywhere: component CSS, inline styles, and any dynamically constructed style objects.
---
## Never use raw px for spacing
Use `--space-*` variables for all margins, padding, and gaps. Raw pixel values make the spacing system incoherent and break visual rhythm.
```css
/* WRONG */
padding: 16px 24px;
gap: 8px;
margin-bottom: 12px;
/* CORRECT */
padding: var(--space-4) var(--space-6);
gap: var(--space-2);
margin-bottom: var(--space-3);
```
The spacing scale runs from `--space-1` (0.25rem) to `--space-16` (4rem). See [[01 - Visual Design]] for the full table.
---
## The `min-width: 0` rule
Flex and grid children default to `min-width: auto`, which means they cannot shrink below their content's natural size. In practice this causes horizontal overflow — the child pushes past its container instead of wrapping or truncating.
Add `min-width: 0` to any flex or grid child that contains text, a table, a wide image, or another flex/grid container:
```css
.wrapper {
display: flex;
}
.main {
flex: 1;
min-width: 0; /* required — prevents horizontal scrollbar */
}
```
This must be applied at every level of nesting. A `min-width: 0` on an outer element does not propagate to inner flex containers.
---
## The `overflow-x: hidden` backstop
The `.content` wrapper in `DashboardLayout.module.css` carries `overflow-x: hidden` as a definitive backstop against page-level horizontal overflow. Do not remove this rule. It is a last-resort containment boundary, not a substitute for fixing `min-width` at the source.
---
## Global utility classes
Prefer these over writing new styles for common UI elements.
### Buttons — from `globals.css`
```html
<button class="btn btn-primary">Save</button>
<button class="btn btn-secondary">Cancel</button>
```
Do not write custom button styles for standard primary/secondary actions.
### Pills and badges — from `globals.css`
```html
<span class="pill pill-primary">Active</span>
<span class="pill pill-warn">Warning</span>
<span class="pill pill-purple">Draft</span>
```
Pills use `--radius-full` and are intended for status chips, labels, and category tags.
### Form fields — from `FormField.module.css`
Import this file as `f` by convention:
```tsx
import f from '@/components/shared/FormField.module.css';
```
Available classes:
```tsx
<div className={f.field}>
<label className={f.label}>Title</label>
<input className={f.input} />
</div>
<div className={f.row}>
{/* two fields side by side */}
</div>
<div className={f.actions}>
{/* right-aligned action buttons */}
</div>
```
| Class | Purpose |
|---|---|
| `f.field` | Vertical label + input stack |
| `f.label` | Styled form label |
| `f.input` | Standard text input |
| `f.select` | Dropdown/select element |
| `f.row` | Horizontal pair of fields |
| `f.actions` | Right-aligned button row |
---
## Dropdowns and selects
Use `className={f.select}` from `FormField.module.css`. When a select needs to be inline or auto-sized, override width only:
```tsx
<select className={f.select} style={{ width: 'auto' }}>
```
Do not write custom select styles. The `f.select` class handles appearance, border, padding, color, and focus state consistently across themes.
---
## Transitions
Use these values for interactive elements:
| Situation | Value |
|---|---|
| Color, background, border on hover | `transition: color 0.15s, background 0.15s` |
| General interactive element | `transition: all 0.15s` |
| Layout change (sidebar width) | `transition: width 0.2s` |
Do not use durations longer than `0.2s` for micro-interactions. Reserve longer durations for full-screen transitions if they are ever introduced.
---
## Comments
Only comment CSS when the reason is not obvious from the code. Acceptable:
```css
.main {
flex: 1;
min-width: 0; /* prevents horizontal overflow in flex child */
}
.overlay {
pointer-events: none; /* must not intercept clicks on siblings */
}
```
Not acceptable: describing what the rule does, restating what is visually apparent, notes about which feature uses the class.
@@ -0,0 +1,178 @@
# 03 - Component Patterns
Established patterns for React components in YouTube Studio Flow. Follow these consistently. For CSS rules, see [[02 - CSS Conventions]]. For backend patterns, see [[04 - Backend Architecture Patterns]].
---
## File structure
Each component lives in two co-located files:
```
ComponentName.tsx
ComponentName.module.css
```
For complex components with internal sub-components, keep everything in one file unless a sub-component is genuinely reused elsewhere. Sub-components that exist only to decompose a large render function are not worth extracting.
---
## forwardRef pattern
Components that expose an imperative API (save, reset, isDirty) use `forwardRef` with a typed handle interface. The primary example is `VideoConfigEditor`.
```typescript
export interface MyComponentHandle {
save(): Promise<void>;
isDirty(): boolean;
}
const MyComponent = forwardRef<MyComponentHandle, Props>((props, ref) => {
useImperativeHandle(ref, () => ({
save: async () => {
// ...
},
isDirty: () => isDirty,
}));
// ...
});
```
The parent calls `configRef.current.save()` from a unified "Save Changes" button. There is no separate save button per sub-component — config and video metadata save together through a single user action.
---
## TanStack Query
### Rules
- Call `useQueryClient()` at the component top level, never inside a callback or effect.
- Query keys are always arrays: `['videos']`, `['video', id]`, `['blocks']`.
- Always invalidate related queries in `onSuccess`.
- Use `placeholderData: (prev) => prev` to keep stale data visible during page transitions and prevent content flash.
### Standard mutation pattern
```typescript
const qc = useQueryClient();
const mut = useMutation({
mutationFn: () => updateVideo(id, form),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['video', id] });
qc.invalidateQueries({ queryKey: ['videos'] });
},
});
```
### Query with stable placeholder
```typescript
const { data } = useQuery({
queryKey: ['videos', page, filters],
queryFn: () => fetchVideos({ page, ...filters }),
placeholderData: (prev) => prev,
});
```
---
## Modal pattern
Use the shared `<Modal>` component for all overlays. Do not implement custom dialog/overlay solutions.
```tsx
import Modal from '@/components/shared/Modal';
{isOpen && (
<Modal title="Edit Block" onClose={() => setIsOpen(false)} width={480}>
{/* modal content */}
</Modal>
)}
```
The `width` prop accepts a pixel number. Standard widths: `480` for forms, `600` for wider editors. The modal handles backdrop click, `Escape` key, and focus trap.
---
## Row-level navigation in tables
For table rows that must support both left-click (navigate via router) and middle-click (open in background tab), use an absolutely positioned anchor overlay inside the first cell:
```tsx
<tr className={styles.clickableRow}>
<td>
<a
href={`/videos/${id}`}
className={styles.rowOverlayLink}
onClick={(e) => {
e.preventDefault();
router.push(`/videos/${id}`);
}}
tabIndex={-1}
aria-hidden="true"
/>
{/* actual cell content */}
</td>
</tr>
```
```css
.clickableRow {
position: relative;
cursor: pointer;
}
.rowOverlayLink {
position: absolute;
inset: 0;
z-index: 1;
}
```
The anchor covers the entire row. Left-click is intercepted by `onClick` and delegates to `router.push` for client-side navigation. Middle-click bypasses the handler entirely, which gives native browser behavior — the link opens in a background tab on Windows. Interactive elements within the row (buttons, checkboxes) sit above the overlay via their own stacking context or `z-index`.
---
## Icon libraries
| Library | Import | Use for |
|---|---|---|
| `lucide-react` | `import { Save, X, AlertCircle } from 'lucide-react'` | General UI icons |
| `react-icons/fa` | `import { FaYoutube } from 'react-icons/fa'` | YouTube icon only |
| `react-icons/si` | `import { SiTwitch, SiInstagram, SiTiktok, SiX, SiBluesky, SiDiscord } from 'react-icons/si'` | Platform brand icons |
**`SiYoutube` does not exist in `react-icons/si` v5.** Always use `FaYoutube` from `react-icons/fa` for the YouTube icon.
---
## Zustand stores
Read specific slices to avoid unnecessary re-renders:
```typescript
// Preferred — subscribe only to what you need:
const toggleSidebar = useUIStore((s) => s.toggleSidebar);
const sidebarCollapsed = useUIStore((s) => s.sidebarCollapsed);
// Also acceptable when you need several values:
const { sidebarCollapsed, toggleSidebar } = useUIStore();
```
Auth state comes from `useAuthStore` (`src/store/useAuthStore.ts`). This store holds the current user, `teamId`, and JWT token. The Axios interceptor in `api-client.ts` reads the token from this store automatically — you do not need to attach it manually to requests.
---
## Sidebar collapse
`Sidebar.tsx` has two display states:
| State | Width | Content |
|---|---|---|
| Expanded | 280px (`--sidebar-width`) | Icon + label for each nav item |
| Collapsed | 64px (`--sidebar-width-collapsed`) | Icon only |
State is persisted to `localStorage` via Zustand's persist middleware under the key `ui-store`. The collapse toggle is a small circular button on the right border of the sidebar, visible only on hover.
When adding new navigation items, provide both the icon (always) and the label (hidden when collapsed via CSS, not conditional rendering).
@@ -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.
@@ -0,0 +1,160 @@
# 05 - Code Conventions
Language and style conventions for all TypeScript/TSX code in YouTube Studio Flow. These apply to both the frontend and backend unless noted otherwise. For CSS-specific rules, see [[02 - CSS Conventions]].
---
## Comments
Default to writing no comments.
Only add a comment when the **why** is non-obvious: a hidden constraint, a subtle invariant, a browser quirk, a workaround for a specific external bug, or behavior that would surprise a competent reader encountering it for the first time. If removing the comment wouldn't cause confusion, don't write it.
**Never write:**
- Comments that describe what the code does (the code already does that)
- Multi-paragraph docstrings on functions or classes
- Multi-line comment blocks
- Cross-reference notes ("added for issue #123", "used by the sync feature")
- Section dividers (`// --- helpers ---`)
**Acceptable:**
```typescript
// BullMQ silently drops jobs if Redis evicts keys — must use noeviction policy
const client = new Redis({ maxmemoryPolicy: 'noeviction' });
// Single-pass replacement avoids re-substituting inside already-replaced values
const result = template.replace(pattern, (match) => tokens[match] ?? match);
```
---
## No premature abstraction
Add abstractions exactly when they are needed, not before. Three similar lines of code is better than a helper function introduced speculatively for hypothetical future use.
If a pattern appears twice, note it. If it appears three times with meaningful variation, consider abstracting. If the abstraction would be more complex than the repetition, don't.
---
## Error handling scope
Only validate and handle errors at system boundaries:
- User input (request bodies, form submissions)
- External API calls (YouTube API, Google OAuth)
- Queue job payloads at the processor entry point
Trust internal code. Trust Prisma's type guarantees. Do not add defensive `try/catch` around internal service calls for errors that cannot happen under normal operation.
---
## No backwards-compat shims
When changing or removing behavior, change or remove it. Do not leave:
- Unused variables prefixed with `_` to signal "formerly used"
- Re-exports of deleted types for "compatibility"
- `// removed` comments where code used to be
- Feature flags gating old vs. new behavior
---
## TypeScript
- Use strict TypeScript throughout. `"strict": true` is set in both `tsconfig.json` files.
- Avoid `any`. The only acceptable uses are at Prisma enum boundaries where the type system cannot express a legitimate constraint, and when interfacing with genuinely untyped external data (e.g. raw OAuth token payloads).
- Use `as any` sparingly. If you reach for it, consider whether a type assertion (`as SpecificType`) or a type guard is more appropriate.
- Prefer explicit return types on exported functions and service methods. Inference is acceptable for small private helpers.
- Use `type` for object shapes and union types. Use `interface` for contracts intended to be extended or implemented.
```typescript
// Object shape — use type
type VideoFilters = {
search?: string;
status?: PrivacyStatus;
page: number;
};
// Extendable contract — use interface
interface RenderInput {
videoId: string;
blockOrder: string[];
blockOverrides: Record<string, BlockOverride>;
}
```
---
## Naming conventions
| Context | Convention | Examples |
|---|---|---|
| React components | PascalCase | `VideoTable`, `BlockEditor` |
| CSS module classes | camelCase | `styles.clickableRow`, `styles.headerCell` |
| TypeScript interfaces and types | PascalCase | `VideoFilters`, `RenderInput` |
| Frontend API functions | camelCase verb + noun | `fetchVideos`, `updateVideo`, `createBlock` |
| Backend service methods | camelCase verb + noun | `findAll`, `findOne`, `create`, `update`, `remove` |
| Queue job name strings | kebab-case | `'lint'`, `'youtube-sync'`, `'bulk-metadata'` |
| TanStack Query keys | array of strings | `['videos']`, `['video', id]`, `['blocks']` |
| Zustand store files | camelCase with `use` prefix | `useAuthStore.ts`, `useUIStore.ts` |
---
## Import order
Organize imports in this order within any TypeScript or TSX file. An empty line between each group:
1. React imports
2. Next.js imports (`next/navigation`, `next/image`, etc.)
3. Third-party libraries (`@tanstack/react-query`, `lucide-react`, etc.)
4. Internal aliases (`@/components/...`, `@/lib/...`, `@/store/...`)
5. Relative imports (`../utils`, `./helpers`)
6. Style imports (CSS modules, always last)
```typescript
import { useState, useRef, forwardRef } from 'react';
import { useRouter } from 'next/navigation';
import { useQuery, useMutation } from '@tanstack/react-query';
import { Save, X } from 'lucide-react';
import Modal from '@/components/shared/Modal';
import { updateVideo } from '@/lib/api';
import { useAuthStore } from '@/store/useAuthStore';
import { formatDate } from '../utils/date';
import styles from './VideoEditor.module.css';
import f from '@/components/shared/FormField.module.css';
```
---
## No dead code
Remove unused imports as you work. Remove unused variables. Remove unreachable branches. Delete unused files rather than leaving them in place.
Do not leave `console.log` statements in committed code. Use the logger (`nestjs/common` `Logger` on the backend) for intentional output.
---
## Validation
**Backend:** Use `class-validator` decorators on DTO classes. Every request body that reaches a controller must go through a validated DTO.
```typescript
export class CreateBlockDto {
@IsString()
@IsNotEmpty()
name: string;
@IsEnum(BlockType)
type: BlockType;
@IsString()
@IsOptional()
content?: string;
}
```
**Frontend:** Validate at the form submission boundary only. Inside a component, trust the types from `@/lib/api.ts`. Do not add runtime type checks on data returned by the API.