Appearance
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
videofromMediaItem, while the "My Reels" builder manages a completely separateReel/ReelItemtable (no FK between them — seeschema.prismaMediaItem 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 aMediaItem(uploadMediahas no resolver;TalentEditPage.tsx:82discardsmediaItems).Decision (user, 2026-06-08):
MediaItembecomes the single source of truth — the portfolio. The separateReel/ReelItemsystem 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.
BE-PORTFOLIO-001 — MediaItem becomes the unified model (title, visibility, link type) + migration
- [x] Done
Files:
- Edit:
castyou-backend/prisma/schema.prisma— extendMediaItem - Run:
pnpm db:migrate(Postgres schema change —prisma generatealone is not enough) - Edit:
castyou-backend/src/graphql/schema/index.ts— extendMediaItemtype
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. typegains"link"(e.g. external Vimeo/YouTube/portfolio URL);urlholds 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-nullvisibility(default PUBLIC) tomedia_items; existing rows become PUBLIC MediaItemGraphQL type exposes the new fields
BE-PORTFOLIO-002 — Remove the Reel / ReelItem system
- [x] Done
Files:
- Edit:
castyou-backend/prisma/schema.prisma— removeReelandReelItemmodels (+reels/itemsrelations onTalentProfile) - Run:
pnpm db:migrate(dropsreels/reel_itemstables) - Edit:
castyou-backend/src/graphql/schema/index.ts— removeReel/ReelItemtypes and all reel queries/mutations (myReels,reel,createReel,addReelItem,reorderReelItems,generateAIReelDraft, legacyuploadMedia/confirmMediaUploadif reel-only) - Delete:
castyou-backend/src/graphql/resolvers/reel.ts(fold the still-neededgetMediaUploadUrlinto 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.ts— keep the reel-editor feature flag (renameDEMO_REEL_BUILDER→REEL_EDITORfor 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/reelbefore 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 singleMediaItem) can be gated without affecting portfolio behavior.
Acceptance criteria:
reels/reel_itemstables dropped; schema has noReel/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) getMediaUploadUrlstill 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 keptgetMediaUploadUrlhere - Edit:
castyou-backend/src/graphql/resolvers/talent.ts—mediaItemsfield 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. addPortfolioItemsetsorderto the current item count. For image/video the client first callsgetMediaUploadUrl→ uploads to R2 → passes the CDNurl; forlinkit passes the external URL directly (no upload).- Visibility-aware
mediaItemsfield 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.submittedMaterialsholds a softmediaItemId(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)
addPortfolioItemworks for image, video, and link types; sets correctorderupdatePortfolioItemcan flipvisibilityand 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 anApplication's materials, hydrate eachmediaItemIdto its currentMediaItem(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 withitem.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). Prefertitle; only fall back to the numbered default. - For producer-facing display of
submittedMaterials, resolvemediaItemId→MediaItem; if the item was deleted (dangling soft reference), fall back to the storedmaterial/uploadedUrlso 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 + extendmediaItemsselections withtitle,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_EDITORflag 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
myReelsin schema)
Frontend (Vitest/RTL):
PortfolioPage.test.tsx— add link, upload→create, toggle public/private, edit title, delete, reorder calls mutation- Extend
JobApplyPagetests — picker preferstitle, falls back to numbered label, lists both public + private