# Avatar System: Flag Builder + Photo Upload

## Context

Users and teams currently show initials in a colored box when no avatar is set. OAuth users get their Google/Discord photo in `users.imageUrl`, but there's no custom upload path and no procedurally generated identity. This plan adds:

1. **Flag avatars** — SVG flags (12 patterns, 2–3 colors) as the default identity for every user and team, editable via a builder UI.
2. **Photo upload** — Cloudinary-hosted images with Rekognition NSFW moderation before display.
3. **Hierarchy** — teams inherit their owner's avatar by default; users and teams can independently override.

---

## Architecture Decisions

- **Image hosting**: Cloudinary. Store at 256×256, serve at 45×45 or 18×18 via URL-based transformations (e.g. `w_45,h_45,c_fill`). Transformations are generated once per unique image+size then CDN-cached; cost is ~2 credits/1k new uploads. At ~2 KB average delivered size, the free tier (25 GB bandwidth) covers ~80,000 active users. NSFW moderation via the AWS Rekognition add-on. Images are never shown until the webhook approves them.
- **Flag library**: None exists. Build a custom `FlagSvg` component (~300 lines). Pure SVG, no dependencies, SSR-safe.
- **DB**: Two new JSONB/varchar columns each on `users` and `teams`; keep existing `imageUrl` / `logoUrl` untouched.
- **OAuth protection**: BetterAuth's `updateUser` hook will be patched to skip writing `imageUrl` once a user has set a custom avatar.

---

## DB Schema Changes

**`database/schema.ts`** — add to `users`:
```ts
flagConfig: jsonb("flag_config"),          // {pattern: string, colors: string[]}
customAvatarUrl: varchar("custom_avatar_url", { length: 512 }),  // Cloudinary URL (post-approval)
avatarType: varchar("avatar_type", { length: 20 }).notNull().default("flag"),
// avatarType: 'flag' | 'uploaded'
```

Add to `teams`:
```ts
flagConfig: jsonb("flag_config"),
avatarType: varchar("avatar_type", { length: 20 }).notNull().default("owner"),
// avatarType: 'owner' | 'flag' | 'uploaded'
// existing logoUrl reused for uploaded team images
```

Run `npm run db:generate` then `npm run db:migrate`.

---

## Avatar Display Hierarchy

```
getUserAvatarData(user):
  if avatarType === 'uploaded' && customAvatarUrl → {type: 'image', url: customAvatarUrl}
  if flagConfig → {type: 'flag', config: flagConfig}
  if imageUrl → {type: 'image', url: imageUrl}   // OAuth fallback
  → {type: 'initials'}

getTeamAvatarData(team, owner):
  if avatarType === 'uploaded' && logoUrl → {type: 'image', url: logoUrl}
  if avatarType === 'flag' && flagConfig → {type: 'flag', config: flagConfig}
  → getUserAvatarData(owner)   // 'owner' default + final fallback
```

---

## Phases

### Phase 1 — Flag SVG Engine

**New files:**
- `app/lib/flag-types.ts` — `FlagPattern` union type, `FlagConfig` interface
- `app/components/ui/FlagSvg.tsx` — renders `<svg>` from `{pattern, colors, size?}`
- `app/lib/flag-generator.ts` — `generateFlagConfig(seed: string): FlagConfig` (deterministic from hash)

**12 patterns** (pure SVG geometry, all square viewport):

| Pattern | SVG approach |
|---|---|
| `triband-h` | 3 stacked `<rect>` |
| `triband-v` | 3 side-by-side `<rect>` |
| `diagonal` | `<rect>` bg + `<polygon>` triangle |
| `nordic-cross` | bg + two crossing `<rect>` strips, cross offset left |
| `cross` | bg + two centered `<rect>` strips |
| `canton` | bg + `<rect>` overlay top-left quarter |
| `triangle` | bg + `<polygon>` left-pointing triangle |
| `chevron` | bg + `<polygon>` V pointing right |
| `quartered` | 4 `<rect>` in 2×2 |
| `bordered` | bg `<rect>` + inset `<rect>` with gap |
| `circle` | bg + `<circle>` center |
| `star` | bg + `<polygon>` 5-point star |

`generateFlagConfig` picks pattern + 2-3 colors deterministically using `hashString` from `app/lib/avatar-colors.ts` (reuse existing). Colors drawn from `AVATAR_COLORS` palette + white/black.

**Tests:** `app/lib/__tests__/flag-generator.test.ts`

---

### Phase 2 — Schema Migration + Backfill

1. Add columns to schema (`database/schema.ts`)
2. `npm run db:generate` → `npm run db:migrate`
3. Update model functions:
   - `app/models/user.ts` — add `flagConfig`, `customAvatarUrl`, `avatarType` to select/update types
   - `app/models/team.ts` — add `flagConfig`, `avatarType`
4. Backfill script: `scripts/backfill-flag-configs.ts`
   - Queries all users/teams where `flagConfig IS NULL`
   - Calls `generateFlagConfig(id)` for each
   - Batch-updates. Run once manually: `npx tsx scripts/backfill-flag-configs.ts`

---

### Phase 3 — Avatar Display Components

**Modified files:**
- `app/components/TeamAvatar.tsx` — accept `flagConfig?` prop; render `<FlagSvg>` when present instead of initials
- `app/components/UserMenu.tsx` — use `customAvatarUrl ?? flagConfig ?? imageUrl` priority
- **New:** `app/components/ui/UserAvatar.tsx` — mirrors `TeamAvatar` for user contexts

`TeamAvatar` new props:
```ts
interface TeamAvatarProps {
  teamId: string;
  teamName: string;
  logoUrl?: string | null;
  flagConfig?: FlagConfig | null;
  avatarType?: 'owner' | 'flag' | 'uploaded';
  ownerAvatarData?: AvatarData;   // resolved by parent from owner user
  size?: 'sm' | 'md' | 'lg';
}
```

Add `xl` size (`h-16 w-16`) to both components for the editor preview.

---

### Phase 4 — Flag Builder UI

**New files:**
- `app/components/ui/FlagBuilder.tsx` — interactive editor
  - Grid of 12 pattern thumbnails (4-col, using `<FlagSvg>` at ~60×60px)
  - Selected pattern highlighted with brackt green border
  - Color slot pickers: each slot shows the 6 brackt palette swatches + a custom hex `<input>`
  - Live preview (large `<FlagSvg>` at ~180×180px)
  - `onChange(config: FlagConfig)` callback
- `app/components/ui/AvatarEditor.tsx` — tab shell: "Flag" | "Upload Photo"
  - Flag tab: `<FlagBuilder>`
  - Upload tab: `<AvatarUploader>` (built in Phase 5)
  - Save button calls appropriate action

**Modified:** `app/routes/user-profile.tsx`
- Add `AvatarEditor` section above existing profile fields
- New action intent `"update-avatar-flag"` — saves `flagConfig` + `avatarType: 'flag'`
- Update `app/models/user.ts` `updateUser` to accept these new fields

---

### Phase 5 — Cloudinary Photo Upload

**Setup:**
- `npm install cloudinary react-image-crop`
- Env vars: `CLOUDINARY_CLOUD_NAME`, `CLOUDINARY_API_KEY`, `CLOUDINARY_API_SECRET`, `CLOUDINARY_WEBHOOK_SECRET`
- One-time: enable AWS Rekognition add-on in Cloudinary dashboard; enable "block pending" mode via Cloudinary support ticket

**New server files:**
- `app/lib/cloudinary.server.ts` — Cloudinary SDK init + `uploadAvatarImage(buffer, userId)` helper
  ```ts
  cloudinary.uploader.upload(dataUri, {
    folder: 'avatars',
    moderation: 'aws_rek',
    context: `user_id=${userId}`,
    // Store at 256×256; serve via URL transforms: w_45,h_45,c_fill or w_18,h_18,c_fill
    transformation: [{ width: 256, height: 256, crop: 'fill', gravity: 'face' }],
  })
  ```
- `app/routes/api.upload-avatar.ts` — POST multipart handler
  - Auth required; max 5 MB; image types only
  - Returns `{ status: 'pending' }`
  - Registers in `app/routes.ts`
- `app/routes/api.upload-team-logo.ts` — same pattern but for teams, checks team ownership
- `app/routes/api.webhooks.cloudinary.ts` — POST webhook
  - Validates `X-Cld-Signature` header
  - On `approved`: reads `context.user_id` or `context.team_id`, updates `customAvatarUrl`/`logoUrl` + `avatarType: 'uploaded'`
  - On `rejected`: no-op (log only; user keeps prior avatar)
  - Register in `app/routes.ts`

**New client file:**
- `app/components/ui/AvatarUploader.tsx`
  - Uses `react-image-crop` for square crop
  - Previews crop before submit
  - POST to `/api/upload-avatar` or `/api/upload-team-logo`
  - Shows "Photo submitted for review — it'll appear within a few seconds" after submit

**OAuth protection** — `app/lib/auth.server.ts`:
```ts
// In BetterAuth user hooks, add:
databaseHooks: {
  user: {
    update: {
      before: async (data) => {
        if (data.image) {
          const existing = await db.query.users.findFirst({ where: eq(users.id, data.id) });
          if (existing?.avatarType === 'uploaded' || existing?.avatarType === 'flag') {
            const { image, ...rest } = data;   // drop the OAuth image update
            return { data: rest };
          }
        }
        return { data };
      }
    }
  }
}
```

---

### Phase 6 — Team Avatar in League Settings

- Locate league settings route (recently refactored into per-section components — likely `app/routes/leagues/$leagueId.settings.tsx` or similar)
- Add a "Team Avatar" section (card) for each team the logged-in user owns in the league
- Reuse `<AvatarEditor>` with `teamId` prop instead of `userId`
- New action intent: `"update-team-avatar-flag"` and `"update-team-avatar-type"`
- `app/models/team.ts`: `updateTeam` accepts `flagConfig`, `avatarType`

---

## New Files Summary

| File | Purpose |
|---|---|
| `app/lib/flag-types.ts` | FlagPattern type, FlagConfig interface |
| `app/lib/flag-generator.ts` | Deterministic flag config from seed |
| `app/components/ui/FlagSvg.tsx` | SVG renderer |
| `app/components/ui/FlagBuilder.tsx` | Interactive editor |
| `app/components/ui/AvatarEditor.tsx` | Tab shell (Flag / Upload) |
| `app/components/ui/AvatarUploader.tsx` | Crop + upload UI |
| `app/components/ui/UserAvatar.tsx` | User avatar display component |
| `app/lib/cloudinary.server.ts` | Cloudinary SDK wrapper |
| `app/routes/api.upload-avatar.ts` | Upload endpoint (users) |
| `app/routes/api.upload-team-logo.ts` | Upload endpoint (teams) |
| `app/routes/api.webhooks.cloudinary.ts` | Cloudinary moderation webhook |
| `scripts/backfill-flag-configs.ts` | One-time backfill script |
| `app/lib/__tests__/flag-generator.test.ts` | Unit tests |

## Modified Files Summary

| File | Change |
|---|---|
| `database/schema.ts` | Add flagConfig/avatarType/customAvatarUrl to users; flagConfig/avatarType to teams |
| `app/models/user.ts` | New fields in types + updateUser |
| `app/models/team.ts` | New fields in types + updateTeam |
| `app/components/TeamAvatar.tsx` | Support flagConfig + ownerAvatarData |
| `app/components/UserMenu.tsx` | Use new avatar priority logic |
| `app/routes/user-profile.tsx` | Add AvatarEditor + new action intents |
| `app/lib/auth.server.ts` | BetterAuth databaseHook to protect imageUrl |
| `app/routes.ts` | Register 3 new API routes |
| League settings route | Add team avatar editing section |

---

## Verification

- **Phase 1**: `npm run test:run` passes flag-generator tests; `FlagSvg` renders all 12 patterns in browser
- **Phase 2**: `npm run typecheck` passes; backfill script runs without error; DB rows have flagConfig populated
- **Phase 3**: All avatar displays in the app (UserMenu, TeamAvatar in standings, draft room) show flags
- **Phase 4**: Flag builder is interactive; saving updates the DB; flag appears in UserMenu immediately
- **Phase 5**: Upload a test image → see "pending" message → Cloudinary webhook fires → avatar updates. Upload an explicit image → it should be rejected and not appear.
- **Phase 6**: League member can change their team's avatar from league settings

---

## Open Questions for Implementation

1. **Cloudinary account**: Need `CLOUD_NAME`, `API_KEY`, `API_SECRET`, and to enable the AWS Rekognition add-on + "block-pending" mode.
2. **League settings route path**: Confirm the exact file before Phase 6.
3. **Flag aspect ratio in builder UI**: `FlagSvg` will use `viewBox="0 0 100 100"` for square output. The builder preview will also be square.
