Skip to content

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 Post table, returned by the same feed query, 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 — make authorId nullable, add isSystem, ctaLabel, ctaUrl fields
  • 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 = trueauthorId IS NULL
  • isSystem = falseauthorId IS NOT NULL
  • ctaLabel and ctaUrl are either both set or both null. Allowed only when isSystem = true.
  • ctaUrl validated: must be https://… or start with / (in-app path). Reject javascript: / data: schemes.

Backward-compat with [[Epic 16 — Publication Feed]]: existing feed queries continue to work; system posts are just rows with authorId = NULL and isSystem = true.


BE-SYSPOST-002 — Admin-only system post mutations + feed integration

  • [x] Done

Files:

  • Edit: src/graphql/schema/index.ts — add createSystemPost, updateSystemPost, deleteSystemPost; extend Post type
  • Edit: src/graphql/resolvers/feed.ts — resolve author as nullable, expose isSystem, 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 under castyou-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):
    1. Media picker (MediaGallery from DS — image or video upload, single select).
    2. Caption textarea (optional, max 500 chars).
    3. "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.
    4. Live preview of the resulting SystemPostCard (see FE-SYSPOST-002) at the top of the form.
    5. "Publish" button calls uploadPostMediacreateSystemPost, navigates back to the list.
  • 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-system per [[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 on isSystem
  • 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 Badge variant accent).
  • 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 + ctaUrl present → DS Button (variant primary) showing ctaLabel. Click navigates to ctaUrl (in-app useNavigate for paths starting with /, target="_blank" rel="noopener noreferrer" for external https://).
    • If no CTA → no bottom button at all (replaces talent posts' "View full profile" — there is no profile to view).
  • 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> or Link. Cypress/RTL test asserts getByAltText("CasTyou").closest("a") is null.
  • CTA button is absent when ctaLabel/ctaUrl are 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.