Appearance
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— addFlierConfigmodel - Run:
pnpm db:migrate - Edit:
castyou-backend/src/graphql/schema/index.ts— addFlierConfigtype,SaveFlierConfigInput - Edit:
castyou-backend/src/graphql/resolvers/job.ts— addsaveFlierConfigmutation, extendJobwithflierConfig - Edit:
castyou-backend/src/graphql/resolvers/petJob.ts— same forPetJob - 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: FlierConfigNotes:
saveJobFlierConfigupserts — creates on first call, updates on subsequent calls.- Authorization: requires PRODUCER profile + job ownership.
canvasLayoutis treated as opaque JSON by the backend; validation is the frontend's responsibility.
Acceptance criteria:
saveJobFlierConfigcreates a newFlierConfigrow 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
BE-FLIER-003 — Asset upload mutation (background & logo)
- [x] Done
Files:
- Edit:
castyou-backend/src/graphql/schema/index.ts— adduploadFlierAssetmutation - Edit:
castyou-backend/src/graphql/resolvers/job.ts— resolver - Edit:
castyou-backend/src/services/media/index.ts— adduploadFlierAsset(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_INPUTwith a clear message - Oversized uploads return
BAD_USER_INPUTwith the size limit
BE-FLIER-004 — AI background generation (DALL-E, CasTars-gated)
- [x] Done
Files:
- Edit:
castyou-backend/src/graphql/schema/index.ts— addgenerateFlierBackgroundmutation - Edit:
castyou-backend/src/graphql/resolvers/job.ts— resolver - Edit:
castyou-backend/src/services/ai/flierImageGenerator.ts— exposegenerateBackground(job, userPrompt?)for app use (currently only used in seed) - Edit:
castyou-backend/src/services/castars/features.ts— addFLIER_AI_BACKGROUNDfeature (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 pngCasTars flow:
- Check
FlierConfig.aiUsedBg— iftrueand user has no active subscription, throwALREADY_USED_AI: "AI background already generated for this job. Purchase CasTars to regenerate." - If user has active subscription (
hasActiveSubscription(userId)→true), skip deduction - Otherwise call
spendCasTarsForFeature(prisma, { userId, feature: 'FLIER_AI_BACKGROUND', refId: jobId }) - 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 inflierConfig.bgValue - Second call (no subscription): throws
ALREADY_USED_AIwithout deducting - Subscriber: skips deduction on every call, always generates
- Insufficient stars:
spendCasTarsForFeaturethrows — propagate asINSUFFICIENT_STARS - OpenAI failure: return graceful error, do NOT deduct CasTars (wrap deduct + generate in try/catch, refund on failure via
awardCasTars) - Tests mock
openaiandspendCasTarsForFeature; 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— addgenerateFlierLogomutation - Edit:
castyou-backend/src/graphql/resolvers/job.ts— resolver - Edit:
castyou-backend/src/services/ai/flierImageGenerator.ts— addgenerateLogo(job, userPrompt?) - (CasTars feature
FLIER_AI_LOGOadded 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 backgroundFlow: 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— addgenerateFlierContentmutation - Edit:
castyou-backend/src/graphql/resolvers/job.ts— resolver - Edit:
castyou-backend/src/services/ai/flierGenerator.ts— exposegenerateAIContent(job, userPrompt?)returningstring[](bullet array) - (CasTars feature
FLIER_AI_CONTENTadded 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— exportFlierAIPromptInput - 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:
| Option | UI |
|---|---|
| Gradient / Solid (default) | Color picker for primary color + gradient toggle; preview swatch |
| Upload Image | DS FileUpload (jpg/png/webp, max 10 MB); thumbnail preview after upload |
| AI Generate | DS 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
Toastpointing to the CasTars store
Step 2 — Logo
| Option | UI |
|---|---|
| Text (default) | Text input + font selector (DS Select — 5 curated display fonts) + color picker |
| Upload Image | DS FileUpload (PNG only); thumbnail preview; hint: "PNG with transparent background works best" |
| AI Generate | DS FlierAIPromptInput + "Generate — 100 ★"; placeholder: "e.g. retro neon sign, gold foil, minimal geometric lettermark" |
Step 3 — Content
| Option | UI |
|---|---|
| System bullets (default) | Read-only preview of bullets from existing FlierSpec (from generateJobFlier); font selector + color picker |
| Custom text | Textarea (free text) + font selector + color picker |
| AI Generate | DS 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/Contentand 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-konvaFiles:
- 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:
| Element | Source |
|---|---|
| Background | Image (upload/AI URL) or gradient rect |
| Logo | Image (upload/AI URL) or text node with selected font/color |
| Headline | Text node (from FlierSpec.headline) |
| Subheadline | Text node (from FlierSpec.subheadline, optional) |
| Content / Bullets | Text group (bullets as separate lines or free text) with selected font/color |
| Hashtag block | Text node |
| QR code | Image (pre-rendered from qrcode lib) |
| Footer bar | Rect + text node |
Social format selector:
Tabs at the top of the canvas page (matching existing renderFlier.ts formats):
| Tab | Size |
|---|---|
| 1080 × 1080 | |
| Twitter / X | 1600 × 900 |
| 1200 × 627 | |
| 1200 × 630 | |
| Story | 1080 × 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
FlierCanvascomponent 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 asJobFlierPage.tsxin 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.ts—saveJobFlierConfigupsert create, upsert update partial merge, FORBIDDEN on non-ownerflierAsset.test.ts— upload bg (jpg accepted), upload logo non-PNG rejected, oversize rejectedflierAI.test.tsfor 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
- Happy path: deducts stars, generates, saves URL/value, sets
Frontend (Vitest + RTL):
FlierBuilderPage.test.tsx— step navigation, default pre-selected, config saved on NextBackgroundStep.test.tsx— color picker renders, upload triggersuseUploadFlierAsset, AI button shows cost badge and lock stateLogoStep.test.tsx— text input, font selector, PNG-only upload hint, AI prompt inputContentStep.test.tsx— bullets preview, textarea mode, AI generate callFlierCanvasPage.test.tsx— format switch, drag saves layout, export callsstage.toDataURL()useGenerateFlierBackground.test.ts,useGenerateFlierLogo.test.ts,useGenerateFlierContent.test.ts— mutation call, loading state, error state