Appearance
Epic 25 — System Posts (CasTyou Official)
Posts authored by CasTyou itself (platform-wide announcements, tips, feature highlights, community spotlights). They are first-class posts in the same
Posttable, returned by the samefeedquery, and interleaved with talent posts chronologically in every user's feed — likes, comments, shares, view-tracking, and pagination all work exactly the same. The only differences are: no human author (the post card renders with the CasTyou logo as avatar, "CasTyou" as display name, no profile link), an optional CTA button (text + link) in place of the usual "View full profile", and a stripped-down admin-only composer (media + caption only — no zoom/pan/filters/edits).
BE-SYSPOST-001 — Extend Post model for system authorship + CTA
- [x] Done
Files:
- Edit:
prisma/schema.prisma— makeauthorIdnullable, addisSystem,ctaLabel,ctaUrlfields - Run:
pnpm db:migrate
Schema delta:
prisma
model Post {
// ...existing fields...
authorId String? // nullable — null for system posts
author TalentProfile? @relation(fields: [authorId], references: [id], onDelete: Cascade)
isSystem Boolean @default(false)
ctaLabel String? // e.g. "Read the announcement"
ctaUrl String? // absolute or in-app path (validated)
// ...
@@index([isSystem, createdAt])
}Invariants (enforced in service layer + DB check):
isSystem = true⇒authorId IS NULLisSystem = false⇒authorId IS NOT NULLctaLabelandctaUrlare either both set or both null. Allowed only whenisSystem = true.ctaUrlvalidated: must behttps://…or start with/(in-app path). Rejectjavascript:/data:schemes.
Backward-compat with [[Epic 16 — Publication Feed]]: existing feed queries continue to work; system posts are just rows with
authorId = NULLandisSystem = true.
BE-SYSPOST-002 — Admin-only system post mutations + feed integration
- [x] Done
Files:
- Edit:
src/graphql/schema/index.ts— addcreateSystemPost,updateSystemPost,deleteSystemPost; extendPosttype - Edit:
src/graphql/resolvers/feed.ts— resolveauthoras nullable, exposeisSystem,ctaLabel,ctaUrl - Create:
src/services/feed/systemPosts.ts - Create:
src/__tests__/resolvers/systemPosts.test.ts
GraphQL:
graphql
extend type Post {
isSystem: Boolean!
ctaLabel: String
ctaUrl: String
author: TalentProfile # now nullable
}
input CreateSystemPostInput {
mediaUrl: String!
mediaType: String! # PHOTO | VIDEO
thumbnailUrl: String
caption: String
ctaLabel: String
ctaUrl: String
}
extend type Mutation {
createSystemPost(input: CreateSystemPostInput!): Post!
updateSystemPost(id: ID!, input: CreateSystemPostInput!): Post!
deleteSystemPost(id: ID!): Boolean!
}Authorization: all three mutations require role = ADMIN. Non-admin callers get FORBIDDEN.
Feed integration: no separate query, no separate channel. System posts are returned by the existing feed(first, after) query alongside talent posts, ordered by createdAt DESC. Every user (talent, producer, pet owner, logged-out) sees them in their normal feed. Likes, comments, shares, and viewCount all increment on system posts the same way they do on talent posts. No pinning in MVP — admins control timing by post time.
Tests cover: admin-only gating, CTA pair-validation, URL scheme validation, that author resolves null, that isSystem flag round-trips, and that feed returns a mix.
BE-SYSPOST-003 — Reuse media pipeline (no transforms)
- [x] Done
Files:
- Edit:
src/services/media/index.ts— accept system-post uploads undercastyou-media/system/{postId}.{ext}
Description: System posts go through the same uploadPostMedia pipeline as talent posts (thumbnail + HLS for video) but skip filter/edit param storage — editParams and filterId are always null. No new worker, just a path namespace and the absence of post-processing fields.
FE-SYSPOST-001 — Admin "System Posts" tab in Admin Panel
- [x] Done
Files:
- Edit:
apps/app/src/pages/admin/AdminPage.tsx— add "System Posts" tab - Create:
apps/app/src/pages/admin/AdminSystemPostsPage.tsx - Create:
apps/app/src/pages/admin/AdminSystemPostComposer.tsx - Create:
apps/app/src/hooks/useSystemPosts.ts
Description:
- Tab lists all existing system posts in a paginated table (per [[feedback_pagination]] — page/pageSize controls below the table). Columns: thumbnail, caption (truncated), CTA label, created date, actions (Edit / Delete).
- "New system post" button → composer.
- Composer is a single-page form (no Text / Filters / Edit tabs — system posts skip those):
- Media picker (
MediaGalleryfrom DS — image or video upload, single select). - Caption textarea (optional, max 500 chars).
- "Add CTA" toggle. When enabled, two fields appear:
- Button text (required, max 40 chars) — e.g. "Learn more"
- Link URL (required) —
https://…or in-app path; validated client-side too.
- Live preview of the resulting
SystemPostCard(see FE-SYSPOST-002) at the top of the form. - "Publish" button calls
uploadPostMedia→createSystemPost, navigates back to the list.
- Media picker (
- Edit reuses the same composer pre-filled. Delete asks for confirmation.
- Admin-only route (guarded by
role = ADMIN); non-admins get the standard 403. - All UI from
@castyou/design-systemper [[feedback_design_system]].
FE-SYSPOST-002 — Feed renders system posts with CasTyou identity
- [x] Done
Files:
- Edit:
apps/app/src/components/feed/PostCard.tsx— branch onisSystem - Create:
packages/design-system/src/components/ui/SystemPostCard.tsx - Edit:
packages/design-system/src/index.ts - Edit:
apps/app/src/hooks/useFeed.ts— pull through new fields
Description: When post.isSystem === true, render SystemPostCard instead of the standard PostCard:
- Avatar: CasTyou logo (DS asset
LogoMark). Renders as a plain<img>— no link, no hover affordance pointing anywhere. - Display name: "CasTyou" (localized via DS). Also non-clickable.
- Role label: small "Official" badge (DS
Badgevariantaccent). - Media area: identical layout to talent post media (full-bleed photo or looping muted video).
- Caption: rendered below media, same typography as talent posts.
- Bottom action row:
- If
ctaLabel+ctaUrlpresent → DSButton(variantprimary) showingctaLabel. Click navigates toctaUrl(in-appuseNavigatefor paths starting with/,target="_blank" rel="noopener noreferrer"for externalhttps://). - If no CTA → no bottom button at all (replaces talent posts' "View full profile" — there is no profile to view).
- If
- Likes / comments / shares behave identically to talent posts (they are real posts in the DB). Logged-out users see the same auth-gated affordances.
Acceptance criteria:
- Avatar and display name are not wrapped in any
<a>orLink. Cypress/RTL test assertsgetByAltText("CasTyou").closest("a")isnull. - CTA button is absent when
ctaLabel/ctaUrlare null; present and correctly linked when both set. - External CTAs open in a new tab with
rel="noopener noreferrer"; internal CTAs use the router. - Tests cover: system-post card render, CTA present/absent, internal vs external CTA navigation, mixed feed (talent + system) ordering by
createdAt.