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