Skip to content

Epic 31 — Enhanced Flier Generation

Expands the existing flier stub (BE-FLIER-001 / FE-FLIER-001/002) into a full production-grade creator flow. Users configure three independent layers — Background, Logo, and Content — then fine-tune element placement in a drag-and-drop canvas editor across all five social media sizes. AI generation options are gated behind a CasTars spend (one-time per job, bypassed for subscribers).


BE-FLIER-002 — FlierConfig model + migration

  • [x] Done

Files:

  • Edit: castyou-backend/prisma/schema.prisma — add FlierConfig model
  • Run: pnpm db:migrate
  • Edit: castyou-backend/src/graphql/schema/index.ts — add FlierConfig type, SaveFlierConfigInput
  • Edit: castyou-backend/src/graphql/resolvers/job.ts — add saveFlierConfig mutation, extend Job with flierConfig
  • Edit: castyou-backend/src/graphql/resolvers/petJob.ts — same for PetJob
  • Create: castyou-backend/src/__tests__/resolvers/flierConfig.test.ts

Schema:

prisma
model FlierConfig {
  id            String   @id @default(cuid())

  // Polymorphic parent (one of these is set, the other null)
  jobId         String?  @unique
  job           Job?     @relation(fields: [jobId], references: [id], onDelete: Cascade)
  petJobId      String?  @unique
  petJob        PetJob?  @relation(fields: [petJobId], references: [id], onDelete: Cascade)

  // Background
  bgType        String   @default("gradient") // "gradient" | "solid" | "upload" | "ai"
  bgValue       String?  // hex, gradient spec, or R2 CDN URL
  bgPrompt      String?  // user's custom prompt for AI generation
  aiUsedBg      Boolean  @default(false)      // one-time gate per job

  // Logo
  logoType      String   @default("text")     // "text" | "upload" | "ai"
  logoValue     String?  // display text or R2 CDN URL (PNG with transparent BG)
  logoFont      String?
  logoColor     String?
  logoPrompt    String?  // user's custom prompt for AI generation
  aiUsedLogo    Boolean  @default(false)

  // Content
  contentType   String   @default("bullets")  // "bullets" | "textarea" | "ai"
  contentValue  Json?    // String[] (bullets) or String (free text)
  contentFont   String?
  contentColor  String?
  contentPrompt String?  // user's custom prompt for AI generation
  aiUsedContent Boolean  @default(false)

  // Canvas layout — per-format element positions saved by the editor
  canvasLayout  Json?    // { instagram: {...}, twitter: {...}, linkedin: {...}, facebook: {...}, story: {...} }

  createdAt     DateTime @default(now())
  updatedAt     DateTime @updatedAt

  @@map("flier_configs")
}

GraphQL:

graphql
type FlierConfig {
  id: ID!
  bgType: String!
  bgValue: String
  bgPrompt: String
  aiUsedBg: Boolean!
  logoType: String!
  logoValue: String
  logoFont: String
  logoColor: String
  logoPrompt: String
  aiUsedLogo: Boolean!
  contentType: String!
  contentValue: JSON
  contentFont: String
  contentColor: String
  contentPrompt: String
  aiUsedContent: Boolean!
  canvasLayout: JSON
  updatedAt: DateTime!
}

input SaveFlierConfigInput {
  bgType: String
  bgValue: String
  bgPrompt: String
  logoType: String
  logoValue: String
  logoFont: String
  logoColor: String
  logoPrompt: String
  contentType: String
  contentValue: JSON
  contentFont: String
  contentColor: String
  contentPrompt: String
  canvasLayout: JSON
}

extend type Mutation {
  saveJobFlierConfig(jobId: ID!, input: SaveFlierConfigInput!): Job!
  savePetJobFlierConfig(petJobId: ID!, input: SaveFlierConfigInput!): PetJob!
}

# Add to Job and PetJob types:
# flierConfig: FlierConfig

Notes:

  • saveJobFlierConfig upserts — creates on first call, updates on subsequent calls.
  • Authorization: requires PRODUCER profile + job ownership.
  • canvasLayout is treated as opaque JSON by the backend; validation is the frontend's responsibility.

Acceptance criteria:

  • saveJobFlierConfig creates a new FlierConfig row linked to the job on first call
  • Re-calling with partial fields deep-merges only the provided fields (others unchanged)
  • Unauthorized access (non-owner, non-producer) throws FORBIDDEN
  • Tests cover: upsert creates, upsert updates, FORBIDDEN path

  • [x] Done

Files:

  • Edit: castyou-backend/src/graphql/schema/index.ts — add uploadFlierAsset mutation
  • Edit: castyou-backend/src/graphql/resolvers/job.ts — resolver
  • Edit: castyou-backend/src/services/media/index.ts — add uploadFlierAsset(jobId, assetType, file)
  • Create: castyou-backend/src/__tests__/resolvers/flierAsset.test.ts

GraphQL:

graphql
enum FlierAssetType {
  BACKGROUND
  LOGO
}

extend type Mutation {
  uploadFlierAsset(jobId: ID!, assetType: FlierAssetType!, file: Upload!): String!
  # Returns the R2 CDN URL of the uploaded asset
}

Upload paths in R2:

  • Background: flier-assets/{jobId}/bg.{ext}
  • Logo (must be PNG for transparent support): flier-assets/{jobId}/logo.png

Constraints:

  • Background: max 10 MB, accepted: jpg, png, webp
  • Logo: max 5 MB, accepted: png only (transparency requires PNG)
  • Replaces any prior asset at the same path (overwrite, no versioning needed)

Acceptance criteria:

  • Returned URL is publicly accessible via the R2 CDN
  • Non-PNG logo upload returns BAD_USER_INPUT with a clear message
  • Oversized uploads return BAD_USER_INPUT with the size limit

BE-FLIER-004 — AI background generation (DALL-E, CasTars-gated)

  • [x] Done

Files:

  • Edit: castyou-backend/src/graphql/schema/index.ts — add generateFlierBackground mutation
  • Edit: castyou-backend/src/graphql/resolvers/job.ts — resolver
  • Edit: castyou-backend/src/services/ai/flierImageGenerator.ts — expose generateBackground(job, userPrompt?) for app use (currently only used in seed)
  • Edit: castyou-backend/src/services/castars/features.ts — add FLIER_AI_BACKGROUND feature (150 stars)
  • Create: castyou-backend/src/__tests__/resolvers/flierAI.test.ts

GraphQL:

graphql
extend type Mutation {
  generateFlierBackground(jobId: ID!, userPrompt: String): Job!
  # Deducts CasTars, calls DALL-E, uploads result to R2,
  # saves URL to FlierConfig.bgValue, sets aiUsedBg = true.
  # Returns the updated Job (with flierConfig).
}

Prompt composition in flierImageGenerator.ts:

typescript
const basePrompt = `Cinematic poster background for "${job.title}", ${job.genre} ${job.productionType} production in ${job.location}. Suitable as a social media flier background.`
const finalPrompt = userPrompt
  ? `${basePrompt} Style: ${userPrompt}`
  : basePrompt
// DALL-E options: model gpt-image-1, size 1024x1024, format png

CasTars flow:

  1. Check FlierConfig.aiUsedBg — if true and user has no active subscription, throw ALREADY_USED_AI: "AI background already generated for this job. Purchase CasTars to regenerate."
  2. If user has active subscription (hasActiveSubscription(userId)true), skip deduction
  3. Otherwise call spendCasTarsForFeature(prisma, { userId, feature: 'FLIER_AI_BACKGROUND', refId: jobId })
  4. Call DALL-E, upload to R2, save URL, set aiUsedBg = true

Feature cost to add in features.ts:

typescript
FLIER_AI_BACKGROUND: { cost: 150, description: 'AI-generated flier background (one-time per job)', duration: null }
FLIER_AI_LOGO:       { cost: 100, description: 'AI-generated flier logo (one-time per job)',       duration: null }
FLIER_AI_CONTENT:    { cost: 75,  description: 'AI-generated flier copy (one-time per job)',        duration: null }

Acceptance criteria:

  • First call: deducts 150 stars, generates image, sets aiUsedBg = true, returns CDN URL in flierConfig.bgValue
  • Second call (no subscription): throws ALREADY_USED_AI without deducting
  • Subscriber: skips deduction on every call, always generates
  • Insufficient stars: spendCasTarsForFeature throws — propagate as INSUFFICIENT_STARS
  • OpenAI failure: return graceful error, do NOT deduct CasTars (wrap deduct + generate in try/catch, refund on failure via awardCasTars)
  • Tests mock openai and spendCasTarsForFeature; cover all paths above

BE-FLIER-005 — AI logo generation (DALL-E transparent PNG, CasTars-gated)

  • [x] Done

Files:

  • Edit: castyou-backend/src/graphql/schema/index.ts — add generateFlierLogo mutation
  • Edit: castyou-backend/src/graphql/resolvers/job.ts — resolver
  • Edit: castyou-backend/src/services/ai/flierImageGenerator.ts — add generateLogo(job, userPrompt?)
  • (CasTars feature FLIER_AI_LOGO added in BE-FLIER-004)
  • Edit: castyou-backend/src/__tests__/resolvers/flierAI.test.ts — add logo tests

GraphQL:

graphql
extend type Mutation {
  generateFlierLogo(jobId: ID!, userPrompt: String): Job!
}

Prompt composition:

typescript
const basePrompt = `Logo or wordmark for "${job.title}" with a transparent background. Clean, professional, suitable for overlay on a dark background. PNG format.`
const finalPrompt = userPrompt
  ? `${basePrompt} Style: ${userPrompt}`
  : basePrompt
// DALL-E options: model gpt-image-1, size 1024x1024, transparent background

Flow: identical to BE-FLIER-004 but uses FLIER_AI_LOGO (100 stars) and sets aiUsedLogo = true. Uploaded to flier-assets/{jobId}/logo.png.

Acceptance criteria: same pattern as BE-FLIER-004.


BE-FLIER-006 — AI content generation (GPT, CasTars-gated)

  • [x] Done

Files:

  • Edit: castyou-backend/src/graphql/schema/index.ts — add generateFlierContent mutation
  • Edit: castyou-backend/src/graphql/resolvers/job.ts — resolver
  • Edit: castyou-backend/src/services/ai/flierGenerator.ts — expose generateAIContent(job, userPrompt?) returning string[] (bullet array)
  • (CasTars feature FLIER_AI_CONTENT added in BE-FLIER-004)
  • Edit: castyou-backend/src/__tests__/resolvers/flierAI.test.ts

GraphQL:

graphql
extend type Mutation {
  generateFlierContent(jobId: ID!, userPrompt: String): Job!
}

Prompt composition (GPT-4o):

typescript
const systemPrompt = `You are a casting director writing punchy, energetic copy for a talent casting flier. Always respond with valid JSON: an array of 3–5 short bullet strings. Each bullet is under 12 words. No hashtags. No filler.`
const userMessage = `Job: "${job.title}" — ${job.genre} ${job.productionType}. Location: ${job.location}. Pay: ${job.paymentType} ${job.paymentAmount ?? ''}.${userPrompt ? ` Tone/style: ${userPrompt}` : ''}`

Flow: FLIER_AI_CONTENT (75 stars), sets aiUsedContent = true, saves bullet array to FlierConfig.contentValue.

Acceptance criteria: same CasTars flow pattern; OpenAI JSON parse error → retry once, then throw AI_PARSE_ERROR.


FE-FLIER-003 — Flier builder wizard (3-step config)

  • [x] Done

Files:

  • Edit: castyou-frontend/apps/app/src/pages/jobs/JobFlierPage.tsx — replace current single-step page with the wizard below; keep existing canvas preview and download buttons for the final canvas-editor phase
  • Create: castyou-frontend/apps/app/src/pages/jobs/flier/FlierBuilderPage.tsx — 3-step wizard wrapper
  • Create: castyou-frontend/apps/app/src/pages/jobs/flier/steps/BackgroundStep.tsx
  • Create: castyou-frontend/apps/app/src/pages/jobs/flier/steps/LogoStep.tsx
  • Create: castyou-frontend/apps/app/src/pages/jobs/flier/steps/ContentStep.tsx
  • Create: castyou-frontend/apps/app/src/hooks/useGenerateFlierBackground.ts
  • Create: castyou-frontend/apps/app/src/hooks/useGenerateFlierLogo.ts
  • Create: castyou-frontend/apps/app/src/hooks/useGenerateFlierContent.ts
  • Create: castyou-frontend/apps/app/src/hooks/useSaveFlierConfig.ts
  • Create: castyou-frontend/apps/app/src/hooks/useUploadFlierAsset.ts
  • Edit: castyou-frontend/packages/design-system/src/components/ui/FlierAIPromptInput.tsx — reusable textarea + generate button component (DS)
  • Edit: castyou-frontend/packages/design-system/src/index.ts — export FlierAIPromptInput
  • Edit: castyou-frontend/apps/app/src/lib/queries/flier.ts — add new mutations
  • Create: castyou-frontend/apps/app/src/__tests__/pages/jobs/FlierBuilderPage.test.tsx
  • Create: castyou-frontend/apps/app/src/__tests__/hooks/useGenerateFlierBackground.test.ts

Wizard structure:

The wizard has 3 steps (progress indicator at top using DS ProgressRing or step dots):

Step 1 — Background

Three radio-style option cards:

OptionUI
Gradient / Solid (default)Color picker for primary color + gradient toggle; preview swatch
Upload ImageDS FileUpload (jpg/png/webp, max 10 MB); thumbnail preview after upload
AI GenerateDS FlierAIPromptInput (textarea + generate button) + CasTars cost badge; disabled if aiUsedBg && !subscriber with a lock icon

On AI option:

  • Show placeholder text: "e.g. dark moody cinematic, fog, deep blues and purples, lens flare"
  • "Generate — 150 ★" button (or "Regenerate — 150 ★" if already used and subscriber)
  • Spinner while generating; result shown as a preview card
  • If insufficient stars, show inline Toast pointing to the CasTars store

Step 2 — Logo

OptionUI
Text (default)Text input + font selector (DS Select — 5 curated display fonts) + color picker
Upload ImageDS FileUpload (PNG only); thumbnail preview; hint: "PNG with transparent background works best"
AI GenerateDS FlierAIPromptInput + "Generate — 100 ★"; placeholder: "e.g. retro neon sign, gold foil, minimal geometric lettermark"

Step 3 — Content

OptionUI
System bullets (default)Read-only preview of bullets from existing FlierSpec (from generateJobFlier); font selector + color picker
Custom textTextarea (free text) + font selector + color picker
AI GenerateDS FlierAIPromptInput + "Generate — 75 ★"; placeholder: "e.g. urgent, exciting, 3 punchy bullets, no corporate language"

Navigation:

  • "Back" / "Next" buttons between steps; "Continue to Canvas" on step 3
  • Config auto-saved to backend (saveJobFlierConfig) on every step transition — no explicit save needed
  • All DS components only (see [[Design System Rule]])

Acceptance criteria:

  • Each step selection is persisted immediately (no data loss on Back navigation)
  • AI options show correct cost badge and lock state based on aiUsedBg/Logo/Content and subscription status
  • File upload previews render before upload completes (use URL.createObjectURL)
  • "Continue to Canvas" is enabled only after at least the default option is chosen for each step (always satisfiable — default is pre-selected)
  • Tests cover: step transitions, AI generate call, lock state rendering, insufficient-stars toast

FE-FLIER-004 — Canvas editor (drag-and-drop, multi-size)

  • [x] Done

Dependencies to add:

bash
pnpm --filter @castyou/app add konva react-konva

Files:

  • Create: castyou-frontend/apps/app/src/pages/jobs/flier/FlierCanvasPage.tsx — canvas editor page
  • Create: castyou-frontend/apps/app/src/components/flier/FlierCanvas.tsx — Konva stage wrapper (local; owns SDK boundary — exception to DS rule per DS-016 policy)
  • Create: castyou-frontend/apps/app/src/components/flier/FlierCanvasLayer.tsx — per-element draggable layer
  • Create: castyou-frontend/apps/app/src/lib/flierCanvasDefaults.ts — default element positions per social format
  • Edit: castyou-frontend/apps/app/src/App.tsx — add route /jobs/:id/flier/canvas
  • Edit: castyou-frontend/apps/app/src/pages/jobs/flier/FlierBuilderPage.tsx — "Continue to Canvas" navigates here
  • Create: castyou-frontend/apps/app/src/__tests__/pages/jobs/FlierCanvasPage.test.tsx

Canvas element inventory:

Each of the following is an independently draggable Konva node:

ElementSource
BackgroundImage (upload/AI URL) or gradient rect
LogoImage (upload/AI URL) or text node with selected font/color
HeadlineText node (from FlierSpec.headline)
SubheadlineText node (from FlierSpec.subheadline, optional)
Content / BulletsText group (bullets as separate lines or free text) with selected font/color
Hashtag blockText node
QR codeImage (pre-rendered from qrcode lib)
Footer barRect + text node

Social format selector:

Tabs at the top of the canvas page (matching existing renderFlier.ts formats):

TabSize
Instagram1080 × 1080
Twitter / X1600 × 900
LinkedIn1200 × 627
Facebook1200 × 630
Story1080 × 1920

Switching format loads the saved layout for that format from canvasLayout. If no layout saved for a format, apply defaults from flierCanvasDefaults.ts.

Layout persistence:

On every drag end (onDragEnd), merge the updated element positions for the current format into local state. Auto-save debounced 800 ms → calls saveJobFlierConfig({ canvasLayout: { ...currentLayout, [format]: updatedPositions } }).

Export:

Existing download buttons (PNG/JPG/PDF) remain. On click, Konva's stage.toDataURL() produces the export from the current canvas state (replacing the old renderFlierToCanvas path for user-customised fliers; the old renderer is still used for auto-thumbnails in FlierThumbnail.tsx).

Acceptance criteria:

  • Dragging an element updates its position immediately; positions persist across page reloads per format
  • Switching social format loads that format's saved layout (or defaults)
  • Export (PNG/JPG/PDF) captures the current canvas state including all user-positioned elements
  • Background image, AI logo PNG, and upload images render correctly with transparency preserved
  • FlierCanvas component is the only place Konva is imported; all surrounding UI (tabs, buttons, header) uses DS components
  • Tests cover: format switch loads correct layout, save is called after drag, export triggers download

FE-FLIER-005 — PetJob flier builder (mirror of FE-FLIER-003/004)

  • [x] Done

Files:

  • Edit: castyou-frontend/apps/app/src/pages/jobs/PetJobFlierPage.tsx — same refactor as JobFlierPage.tsx in FE-FLIER-003
  • Create: castyou-frontend/apps/app/src/pages/jobs/flier/PetJobFlierBuilderPage.tsx
  • Create: castyou-frontend/apps/app/src/pages/jobs/flier/PetJobFlierCanvasPage.tsx
  • Create: castyou-frontend/apps/app/src/hooks/useSavePetJobFlierConfig.ts
  • Edit: castyou-frontend/apps/app/src/App.tsx — add routes /pet-jobs/:id/flier, /pet-jobs/:id/flier/canvas

Description: Identical wizard and canvas editor as FE-FLIER-003/004, wired to PetJob GraphQL mutations (savePetJobFlierConfig, generateFlierBackground/Logo/Content using petJobId). Re-use all step components and hooks — pass entityType: 'job' | 'petJob' and entityId as props.

Acceptance criteria: identical to FE-FLIER-003/004.


TEST-FLIER-001 — Tests for Epic 31

  • [x] Done

Backend (Vitest):

  • flierConfig.test.tssaveJobFlierConfig upsert create, upsert update partial merge, FORBIDDEN on non-owner
  • flierAsset.test.ts — upload bg (jpg accepted), upload logo non-PNG rejected, oversize rejected
  • flierAI.test.ts for each of background / logo / content:
    • Happy path: deducts stars, generates, saves URL/value, sets aiUsed* = true
    • Already-used + no subscription: throws ALREADY_USED_AI, no deduction
    • Already-used + subscriber: skips deduction, generates again
    • Insufficient stars: propagates INSUFFICIENT_STARS
    • OpenAI failure: refunds stars, throws error

Frontend (Vitest + RTL):

  • FlierBuilderPage.test.tsx — step navigation, default pre-selected, config saved on Next
  • BackgroundStep.test.tsx — color picker renders, upload triggers useUploadFlierAsset, AI button shows cost badge and lock state
  • LogoStep.test.tsx — text input, font selector, PNG-only upload hint, AI prompt input
  • ContentStep.test.tsx — bullets preview, textarea mode, AI generate call
  • FlierCanvasPage.test.tsx — format switch, drag saves layout, export calls stage.toDataURL()
  • useGenerateFlierBackground.test.ts, useGenerateFlierLogo.test.ts, useGenerateFlierContent.test.ts — mutation call, loading state, error state