brackt/plans/avatar-system.md

271 lines
11 KiB
Markdown
Raw Normal View History

# 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, 23 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.