Skip to content

Epic 34 — Unified Talent Portfolio (replaces Reel/ReelItem)

Audit (2026-06-08) found two disconnected "reel" systems: the public profile "Demo Reel" renders the first video from MediaItem, while the "My Reels" builder manages a completely separate Reel/ReelItem table (no FK between them — see schema.prisma MediaItem 281-293 vs Reel/ReelItem 675-717). A video can show on the profile but be invisible in "My Reels", and there is no UI at all to view/edit/delete a MediaItem (uploadMedia has no resolver; TalentEditPage.tsx:82 discards mediaItems).

Decision (user, 2026-06-08): MediaItem becomes the single source of truth — the portfolio. The separate Reel/ReelItem system is removed. Each portfolio item is an Image, Video, or Link, has a title, and a public/private switch:

  • public → shown on the public talent profile
  • private → visible only to the owning talent
  • both public and private items are selectable in the job-application flow.

A standalone reel builder may return later as a separate tool that, on save, exports its output and adds it back as a single MediaItem — it will no longer be its own storage model. That is out of scope for this epic (future epic).

Why: Applications already attach media from MediaItem (Application.submittedMaterials stores { material, mediaItemId? }), but talents have no way to build or manage that library, items are unnamed, and a parallel reel system creates confusion with no link between the two. This epic consolidates everything onto MediaItem and gives it a real management screen.


  • [x] Done

Files:

  • Edit: castyou-backend/prisma/schema.prisma — extend MediaItem
  • Run: pnpm db:migrate (Postgres schema change — prisma generate alone is not enough)
  • Edit: castyou-backend/src/graphql/schema/index.ts — extend MediaItem type

Schema change:

prisma
model MediaItem {
  id              String        @id @default(cuid())
  talentProfileId String
  talentProfile   TalentProfile @relation(fields: [talentProfileId], references: [id], onDelete: Cascade)
  url             String        // R2 CDN URL (image/video) OR external URL (link)
  type            String        // "image" | "video" | "link"  (audio dropped; was unused on profile)
  title           String?       // user-facing label (shown in profile + application picker)
  description     String?       // optional caption / context for producers
  visibility      String        @default("PUBLIC") // "PUBLIC" | "PRIVATE"
  duration        Int?          // video only
  thumbnailUrl    String?
  order           Int           @default(0)
  createdAt       DateTime      @default(now())

  @@map("media_items")
}

GraphQL:

graphql
type MediaItem {
  id: ID!
  talentProfileId: ID!
  url: String!
  type: String!          # "image" | "video" | "link"
  title: String
  description: String
  visibility: String!    # "PUBLIC" | "PRIVATE"
  duration: Int
  thumbnailUrl: String
  order: Int!
  createdAt: DateTime!
}

Notes:

  • Migration default visibility = "PUBLIC" so existing/seeded items keep showing on profiles (preserves current behavior). New uploads default to PUBLIC in the API but the UI lets the talent choose at creation.
  • type gains "link" (e.g. external Vimeo/YouTube/portfolio URL); url holds the external link. Drop "audio" from the documented set (not rendered anywhere today) — no data migration needed since none exist.

Acceptance criteria:

  • Migration adds nullable title/description + non-null visibility (default PUBLIC) to media_items; existing rows become PUBLIC
  • MediaItem GraphQL type exposes the new fields

BE-PORTFOLIO-002 — Remove the Reel / ReelItem system

  • [x] Done

Files:

  • Edit: castyou-backend/prisma/schema.prisma — remove Reel and ReelItem models (+ reels/items relations on TalentProfile)
  • Run: pnpm db:migrate (drops reels / reel_items tables)
  • Edit: castyou-backend/src/graphql/schema/index.ts — remove Reel/ReelItem types and all reel queries/mutations (myReels, reel, createReel, addReelItem, reorderReelItems, generateAIReelDraft, legacy uploadMedia/confirmMediaUpload if reel-only)
  • Delete: castyou-backend/src/graphql/resolvers/reel.ts (fold the still-needed getMediaUploadUrl into a shared media resolver — see BE-PORTFOLIO-003)
  • Delete: castyou-backend/src/services/reel/ (reel builder + AI draft service)
  • Delete: castyou-backend/src/__tests__/resolvers/reel.test.ts
  • Edit: castyou-backend/src/services/featureFlags/seed.tskeep the reel-editor feature flag (rename DEMO_REEL_BUILDERREEL_EDITOR for clarity); do NOT delete it

Notes:

  • Keep getMediaUploadUrl (the R2 presign step) — it's reused by the new portfolio upload flow. Only the reel-specific create/list/AI-draft paths are removed.
  • Confirm no other module imports from services/reel before deleting (casting assembly uses its own intro/assembly path, not reel).
  • Feature-flag scope (per user, 2026-06-08): the reel-editor flag (REEL_EDITOR) gates only the reel editor tool, never the portfolio. The entire portfolio — uploading/managing items, public/private display on the profile, and the application picker — must work identically regardless of the flag's state. During this epic the editor is removed, so the flag is dormant; it exists so the future standalone reel-editor tool (which exports its output back into a single MediaItem) can be gated without affecting portfolio behavior.

Acceptance criteria:

  • reels / reel_items tables dropped; schema has no Reel/ReelItem
  • No GraphQL reel queries/mutations remain; server boots clean
  • Reel-editor flag retained as REEL_EDITOR (gates only the future editor, not the portfolio)
  • getMediaUploadUrl still available for portfolio uploads

BE-PORTFOLIO-003 — Portfolio CRUD mutations (create / update / delete / reorder / visibility)

  • [x] Done

Files:

  • Edit: castyou-backend/src/graphql/schema/index.ts — add mutations below
  • Create: castyou-backend/src/graphql/resolvers/portfolio.ts — resolvers (own-profile authorization); host the kept getMediaUploadUrl here
  • Edit: castyou-backend/src/graphql/resolvers/talent.tsmediaItems field resolver becomes visibility-aware (see Notes)
  • Create: castyou-backend/src/__tests__/resolvers/portfolio.test.ts

GraphQL:

graphql
input AddPortfolioItemInput {
  type: String!          # "image" | "video" | "link"
  url: String!           # R2 CDN URL (image/video) or external URL (link)
  title: String
  description: String
  visibility: String     # default "PUBLIC"
  duration: Float
  thumbnailUrl: String
}

input UpdatePortfolioItemInput {
  title: String
  description: String
  visibility: String     # "PUBLIC" | "PRIVATE"
  thumbnailUrl: String
}

extend type Mutation {
  addPortfolioItem(input: AddPortfolioItemInput!): MediaItem!
  updatePortfolioItem(id: ID!, input: UpdatePortfolioItemInput!): MediaItem!
  deletePortfolioItem(id: ID!): Boolean!
  reorderPortfolioItems(itemIds: [ID!]!): [MediaItem!]!
}

Notes:

  • All mutations require the caller to own the target TalentProfile; non-owner → FORBIDDEN.
  • addPortfolioItem sets order to the current item count. For image/video the client first calls getMediaUploadUrl → uploads to R2 → passes the CDN url; for link it passes the external URL directly (no upload).
  • Visibility-aware mediaItems field resolver: when the requesting user owns the profile, return all items (public + private — needed by the apply picker). Otherwise return PUBLIC only (public profile view). This is what makes the public/private switch actually gate the profile.
  • deletePortfolioItem: Application.submittedMaterials holds a soft mediaItemId (JSON, no FK), so deletion does not cascade — resolve gracefully on read (see BE-PORTFOLIO-004).

Acceptance criteria:

  • Each mutation enforces own-profile ownership (FORBIDDEN on non-owner)
  • addPortfolioItem works for image, video, and link types; sets correct order
  • updatePortfolioItem can flip visibility and edit title/description
  • Owner sees all items via mediaItems; a non-owner viewer sees PUBLIC only
  • Tests cover: create (each type), update incl. visibility flip, delete, reorder, FORBIDDEN, visibility filtering by viewer

BE-PORTFOLIO-004 — Application materials use real titles (resilient to deleted items)

  • [x] Done

Files:

  • Edit: castyou-backend/src/graphql/resolvers/job.ts — when resolving an Application's materials, hydrate each mediaItemId to its current MediaItem (title/url/type) so the producer view shows real names, not numbered placeholders
  • Edit: castyou-frontend/apps/app/src/pages/jobs/JobApplyPage.tsx — label each library item with item.title ?? 'Media item ' (current behavior always numbers — JobApplyPage.tsx:113-116)

Notes:

  • The apply picker lists the owner's mediaItems (now both public and private, per the visibility-aware resolver). Prefer title; only fall back to the numbered default.
  • For producer-facing display of submittedMaterials, resolve mediaItemIdMediaItem; if the item was deleted (dangling soft reference), fall back to the stored material/uploadedUrl so the application still renders.

Acceptance criteria:

  • Apply picker shows titles where set, numbered fallback otherwise; both public and private items are selectable
  • Producer view resolves titles for live items and degrades gracefully for deleted ones

FE-PORTFOLIO-001 — Portfolio management screen + remove reel builder UI

  • [x] Done

Files:

  • Create: castyou-frontend/apps/app/src/pages/talent/PortfolioPage.tsx — manage portfolio (grid: upload image/video, add link, edit, delete, drag-reorder, per-item public/private toggle)
  • Create: castyou-frontend/apps/app/src/hooks/useAddPortfolioItem.ts
  • Create: castyou-frontend/apps/app/src/hooks/useUpdatePortfolioItem.ts
  • Create: castyou-frontend/apps/app/src/hooks/useDeletePortfolioItem.ts
  • Create: castyou-frontend/apps/app/src/hooks/useReorderPortfolioItems.ts
  • Edit: castyou-frontend/apps/app/src/lib/queries/talent.ts — add the four mutations + extend mediaItems selections with title, description, visibility
  • Edit: castyou-frontend/apps/app/src/App.tsx — add route /portfolio; remove reel routes (/reels, /reel/new, etc.)
  • Edit: castyou-frontend/apps/app/src/pages/ProfilePage.tsx — replace the flag-gated "Demo Reels" management card with a "Manage Portfolio" entry point that is always shown (NOT behind the reel-editor flag)
  • Edit: castyou-frontend/apps/app/src/pages/talent/TalentProfilePage.tsx — the public "Demo Reel"/media sections render PUBLIC items only (backend already filters; keep client resilient)
  • Delete: castyou-frontend/apps/app/src/pages/reel/ (MyReelsPage, ReelBuilderPage), hooks/useReelBuilder.ts, hooks/useMyReels.ts, lib/queries/reel.ts, and reel tests
  • Create: castyou-frontend/apps/app/src/__tests__/pages/talent/PortfolioPage.test.tsx

UI:

  • Grid of portfolio items; each card shows preview (image thumb / video player / link card), editable title + description, type badge, public/private toggle, and delete.
  • Image/video upload uses getMediaUploadUrl → R2 → addPortfolioItem. "Add link" is a simple URL + title form (no upload).
  • Drag-and-drop reorder → debounced reorderPortfolioItems.
  • All components from @castyou/design-system (see [[Design System Rule]]); paginate if the list can grow unbounded (see [[Pagination Rule]]).

Acceptance criteria:

  • A talent can add an image, a video, or a link; it persists as a reusable MediaItem
  • Per-item public/private toggle works: public items appear on the public profile, private ones do not, but both appear in the apply picker
  • Titles/descriptions editable; items deletable and reorderable (order persists)
  • The whole portfolio works regardless of the REEL_EDITOR flag state (the flag only ever gates the reel-editor tool, which this epic removes)
  • All reel-builder UI and routes are removed; no dead links
  • Tests cover: add each type, toggle visibility, edit title, delete, reorder

TEST-PORTFOLIO-001 — Tests for Epic 34

  • [x] Done

Backend (Vitest):

  • portfolio.test.ts — add/update/delete/reorder for image/video/link; visibility flip; owner-sees-all vs viewer-sees-public-only field resolver; FORBIDDEN on non-owner; application material hydration resolves titles and degrades on deleted items
  • Confirm reel resolvers/types are gone (no myReels in schema)

Frontend (Vitest/RTL):

  • PortfolioPage.test.tsx — add link, upload→create, toggle public/private, edit title, delete, reorder calls mutation
  • Extend JobApplyPage tests — picker prefers title, falls back to numbered label, lists both public + private