Creator Quest Card Background Audio (V2)

Feature Owner (original Hand-Off): Joshua Uriel Tribiana
Updated by / current owner: Patrick Babala
Reviewers: @clydetims (Clyde Ador), @cdnzn (Christian Denzon), @ybcole (Cole)
Module: Quest Editor / Quest Content Cards / Learner Quest Player
Priority: P1
Status: Internal Technical Guide — Draft
Date: 09/30/2026
Issue: GitHub Issue #4 — [1.7.2] (Creator) Add audio feature in each Quest Card
PRs: #667 (core, merged 2026-07-14), #790 (upload rework, merged 2026-08-28), #741 (audio asset fallback, merged 2026-08-06)
Related PRs: #13 (prior partial), #695 (mp3 upload fix)


INTRODUCTION & GOALS

Problem Summary

Creators could only attach audio to a quest through the dedicated Audio card (card.content.link) or the quest-level background media. There was no way to attach per-card background audio consistently across supported content card types, and quest-level background_audio_url was stored but never surfaced to logged-in learners. PR #13 (2025-11-25) added a partial, form-only audio integration; PR #667 completed the feature across the form editor, the visual canvas, and the learner player.

Goals

  • Attach an optional audio_url to eligible content cards (form editor) and canvas nodes (visual canvas).

  • Offer three ways to supply audio per card/node: upload a local file, paste a URL, or pick from the creator's Background Assets library.

  • Persist audio_url reliably and keep form data and canvas metadata in sync.

  • Play per-card audio automatically for learners, with a mute toggle and volume slider.

  • Wire the quest-level background_audio_url to the learner with a per-quest, locally persisted volume.

  • Protect published quests by requiring confirmation before audio is added/removed on the canvas.

Non-Goals

  • Video cards and dedicated Audio cards do not use per-card background audio (their own media is the content).

  • No audio waveform editing, trimming, fades, or transcoding/bitrate normalization.

  • No server-side audio duration probing in this scope.

  • Quiz/Activity and Reflection are not part of the audio scope in the form editor (canvas parity differs — see §3.3).

Glossary

  • audio_url — optional per-card / per-node background audio URL stored in card content JSON and canvas node data.

  • background_audio_url — quest-level (single) background audio track stored on quests / adventures.

  • Form editor — app/quest-editor/[questID]/(sections)/content/form-editor/page.tsx, the linear/list card editor.

  • Visual canvas — React Flow node editor; nodes carry data.audio_url.

  • Signed URL upload — two-step flow: POST /get-upload-url then client PUT directly to Supabase Storage.

  • Staged upload — an object written to storage for preview but only persisted to the DB on "Save Changes".


HIGH-LEVEL ARCHITECTURE

System Diagram

Layers

  1. Creator authoring — CardAudioUpload (form editor), CanvasAudioSection (visual canvas, via NodeToolbar), the Media section's quest-level audio + volume slider, and the Background Assets library.

  2. Upload & API — POST /api/creator/quest-content-nodes/get-upload-url mints a 60-second signed URL; the client PUTs the file; PUT /api/creator/quest-content-cards/update persists audio_url and syncs the canvas; DELETE /api/creator/quest-content-nodes/delete-media cleans up objects.

  3. Persistence (Postgres) — quest_content_cards.content JSON (audio_url), quests.canvas_metadata node data.audio_url, quests.background_audio_url, and asset_metadata rows with asset_type = 'AUDIO'.

  4. Object storage — Supabase Storage bucket public-assets, path quests/{node_id}/{name}.{ext}, MIME allow-listed.

  5. Learner delivery — GET /api/learner/get-quest; QuestPlayer drives useBackgroundAudio (per-card) and useGlobalBackgroundAudio (quest-level) with the AudioIndicator toolbar.

Technologies Used

  • Next.js (App Router) route handlers + React components

  • @xyflow/react (React Flow) for the canvas

  • Supabase (Postgres + Storage + Auth; getCreatorAuthContext)

  • Zod (getUploadUrlSchema) and the shared validateFileType allow-list

  • shadcn/ui (Button, Input, Slider, Accordion), lucide-react, sonner toasts

  • Playwright (Chromium, deviceScaleFactor: 2) for the diagram render


DETAILED DESIGN & IMPLEMENTATION

Data Model / Schema

quest_content_cards.content (JSONB) — per-card audio

  • Shape: content.audio_url?: string (types/quest-content-cards.d.ts:7; "Common field for non-video, non-audio cards").

  • Scenario child cards with audio_url: text :97, image :154, file :189, link :201, code :211.

  • Written by PUT /api/creator/quest-content-cards/update and persisted immediately on audio change (form-editor/page.tsx:4261-4267).

  • The table predates the repo migration folder; content is JSONB, so audio_url needs no schema migration.

quests.canvas_metadata node data.audio_url

  • lib/schemas/nodes.schema.ts declares audio_url: z.string().optional() on 10 node data types: text :9, image :23, question :53, discussion :66, code :97, file :122, link :135, reflection :160, quiz :230, scenario :291.

  • Excluded node types: videoNodeDataSchema (:79-90) and audioNodeDataSchema (:143-154).

quests.background_audio_url (text) — quest-level audio

  • Pre-existing column (not created by this feature); selected for the logged-in learner in app/api/learner/get-quest/route.ts:17.

  • Shared/SCORM learner path uses PUBLIC_SHARE_QUEST in lib/quest/queries.ts:4, which also selects it.

  • Added to adventures for parity in supabase/migrations/20260819_add_missing_adventures_columns.sql:20-21.

asset_metadata = AUDIO + storage bucket

  • asset_metadata.asset_type = 'AUDIO' constraint: 20260424053322_create_asset_metadata_table.sql:24, re-declared in 20260813_add_document_asset_type.sql:23.

  • Bucket public-assets; path quests/{node_id}/{safeName}.{file_ext} (get-upload-url/route.ts:116-117).

  • MIME allow-list: 20260923_set_public_assets_allowed_mime_types.sql (audio entries :70-81), applied manually (:47-48). Includes audio/mpeg, audio/mp3, audio/wav, audio/ogg, audio/mp4, audio/aac, audio/flac, aliases (audio/x-wav, audio/x-m4a, audio/wave), and application/octet-stream.

ERD (tables this feature reads/writes)

erDiagram
quests ||--o{ quest_content_cards : "quest_id"
quests ||--o{ asset_metadata : "creator-scoped assets"
quests {
uuid id PK
jsonb canvas_metadata "node.data.audio_url"
text background_audio_url
}
quest_content_cards {
text id PK
uuid quest_id FK
text type
jsonb content "audio_url?: string"
timestamptz updated_at
}
asset_metadata {
uuid id PK
text asset_type "'AUDIO'"
text url
}

API Specification

POST /api/creator/quest-content-nodes/get-upload-url

Source: app/api/creator/quest-content-nodes/get-upload-url/route.ts (method POST at :34). Auth: getCreatorAuthContext(); quest must belong to the creator unless agency member; verifyAgencyResourceOwnership.

Request body (Zod getUploadUrlSchema, :24-32):

{
"quest_id": "uuid",
"node_id": "card-or-node-id",
"node_type": "image | video | audio | file",
"file_ext": "mp3",
"file_size": 1234567,
"file_type": "audio/mpeg",
"file_name": "optional-original-name"
}
  • Size limits (:57-62): image 5 MB, audio 10 MB, video 25 MB, file 50 MB → 400 { "error": "File is too large" }.

  • Type validation: validateFileType(node_type, file_type, file_ext) (:73) — allow-listed MIME first, then extension fallback.

  • Path-safety: isSafePathSegment (:10-19) rejects /, \, .., ., empty; applied to node_id, file_ext, optional file_name (:82-91) → 400 { "error": "Invalid file path" }.

  • Storage: createSignedUploadUrl(filePath, { upsert: true }) (:120-122), valid 60 s.

  • Success response (:137-142): { signed_url, token, path, public_url }.

  • Errors: 404 "Quest not found or access denied"; 500 "Failed to create upload URL" + details; catch → "Failed to generate upload URL".

DELETE /api/creator/quest-content-nodes/delete-media

Source: app/api/creator/quest-content-nodes/delete-media/route.ts (method DELETE at :20).

  • Query params: quest_id, node_id, zero-or-more path values.

  • Guards: node_id via isSafePathSegment; each path must start with quests/{node_id}/ and every following segment must be safe → 400 "Invalid file path".

  • Behaviour: removes supplied object paths; with no paths, lists the folder then removes all files.

  • Errors: 400 "Missing required fields: quest_id, node_id"; 404 "Quest not found or access denied."; 500 "Failed to delete files" / "Failed to list files".

PUT /api/creator/quest-content-cards/update

Source: app/api/creator/quest-content-cards/update/route.ts (method PUT at :7).

  • Auth/ownership: card → quest → creator, plus agency ownership.

  • Body: { id, content?, motivation?, order_index? }.

  • Media cleanup: when an existing content.link/content.url is replaced or removed, the old object is deleted from public-assets. This targets link/url, not audio_url (audio objects are intentionally retained).

  • Canvas sync (linear quests): re-maps the card via mapCardDataToNodeData and writes back canvas_metadata.nodes (:149-188), logging "[Form Canvas Sync] Failed to update canvas:".

GET /api/creator/quest-content-cards/list

Fetches the quest's content cards including persisted content metadata (used on editor load/reload).

GET /api/learner/get-quest

Selects ..., canvas_metadata, background_img_url, background_audio_url (route.ts:17) and returns the whole quest row. Both per-card data.audio_url (inside canvas_metadata) and quest-level background_audio_url are delivered here.

Deprecated: POST /api/creator/quest-content-cards/upload-media

The multipart route documented in the original Hand-Off no longer exists. It was removed in PR #790 (commit ef29ee2d) in favour of the signed-URL flow, because reverse-proxy body-size limits and storage RLS rejected large/valid multipart uploads. Any doc or test referencing it is stale.

Related (unchanged) upload routes

  • POST /api/creator/upload-background-media — quest-level background_audio_url / background_img_url.

  • POST /api/creator/(content)/upload-quest-media — quest media variants.

  • POST /api/creator/(background-assets)/upload-assets — Asset Library uploads (asset_type = 'AUDIO').

Logic & Workflows

Form editor — CardAudioUpload (per card)

  • Defined inline in form-editor/page.tsx:1595-1793; props { card, onUpdate, questId }.

  • Three source tabs: Upload Local, Via Link, From Assets. Label "Background Audio (Optional)".

  • Local upload: 10 MB guard toast.error("Audio file size must be less than 10MB"); uploadFile(file, "audio", card.id, "audio_url") → onUpdate({ audio_url }) + toast.success("Audio uploaded successfully").

  • URL apply validates with new URL(); asset select applies the asset URL; remove sets audio_url: undefined.

  • Immediate persistence: handleUpdateCardContent (:4240-4269) writes local state and, when audio_url is in the updates, calls updateContentCard(...) (the PUT .../update call) immediately.

  • Supported card editors: Discussion, Text, Image, File, Link, Codeblock, Scenario, Question. Not in form mode: Video, Audio, Activity, and Reflection.

Visual canvas — CanvasAudioSection (per node)

  • Component: components/quest-editor/visual-canvas/CanvasAudioSection.tsx; props nodeId, audioUrl, isChild?, isArchived?, onUpdate?, variant?.

  • Surfaced through NodeToolbar when the Music icon button (title="Background Audio") is toggled; it renders with variant="inline".

  • Three tabs: Local, URL, Assets. 10 MB guard "Audio file must be under 10MB."; MIME guard "Please select an audio file (MP3, WAV, etc.).".

  • Upload uses the signed-URL flow; cachedAudioSrc is memoised through cacheBustUrl; preview <audio controls loop>.

  • Published-quest protection: confirmPublishedEdit() is awaited before local upload, URL apply, asset select, and remove. Archived nodes render null.

Scenario child cards

scenario-child-cards.tsx — shared ScenarioChildAudioUpload. Supported child types: text, image, file, link, reflection, question. Not supported: child video, child audio (uses musicContent), child discussion, activity. Upload guards: MIME "Please upload a valid audio file (MP3, WAV, etc.)." and 10 MB "Audio file must be under 10MB.".

Quest-level global audio + per-quest volume

  • Media section reads/writes localStorage key globalBackgroundAudioVolume:{questID} (wrap in console.warn on failure). Slider label "Learner Volume (global)"; helper "This volume is applied globally in the Learner View only."

  • Hook useGlobalBackgroundAudio reads the same key for its initial volume (default 0.5) and persists on setVolume. No toggleMute is exposed.

Form ↔ canvas sync

  • lib/canvas-sync/formToCanvas.ts → mapCardDataToNodeData copies audio_url for text, image, file, link, codeblock, reflection, discussion, question. Video and audio omit it; activity spreads ...card.content; scenario maps children.

  • lib/canvas-sync/canvasToForm.ts → mapNodeDataToCardContent writes audio_url back for text, image, quiz, link, code, essay, file, reflection, discussion, question; transformScenarioNodeToChildCard covers text, image, file.

Learner playback

  • QuestPlayer.tsx: per-card URL from the active node data.audio_url; isVideoNode suppression; useBackgroundAudio({ audioUrl, isVideoNode }); useGlobalBackgroundAudio({ audioUrl: quest.background_audio_url, questId }) (return currently discarded); bottom-right AudioIndicator toolbar.

  • useBackgroundAudio: fresh new Audio(cacheBustUrl(audioUrl)) with loop = true; autoplay when the user has interacted, otherwise queues a one-shot click; retries on next click if play() is blocked; cleanup() resets previousUrlRef (React StrictMode fix); mute implemented as volume 0; setVolume clamps to [0,1].

  • AudioIndicator: returns null when !hasAudio; mute button aria-label "Mute background audio" / "Unmute background audio"; 0–100 slider mapped to 0–1.

Persistence semantics: audio_url is immediate, link is staged

Because handleUpdateCardContent persists audio_url immediately, audio is deliberately excluded from the staged-upload registry (which targets link uploads and deletes them if an edit is cancelled). PR #790 first staged audio, then reverted it in round 3 — staging audio caused a cancelled edit to delete a DB-referenced object and break the card. Immediate-persist is the intended design.


INFRASTRUCTURE & OPERATIONS

Dependencies

  • Supabase — Postgres (JSONB audio_url, canvas_metadata), Storage bucket public-assets, Auth context.

  • Next.js App Router route handlers under app/api/creator/quest-content-nodes/* and app/api/creator/quest-content-cards/*.

  • Zod + shared lib/uploadAssets/validate.ts and lib/constants.ts (ALLOWED_AUDIO_TYPES, ALLOWED_EXTENSIONS_BY_TYPE.audio, MAX_AUDIO_SIZE).

  • React Flow (@xyflow/react) for canvas node state. No new environment variables.

Monitoring & Alerting

No dedicated telemetry. Key server log strings for triage: get-upload-url → "Quest validation error:", "Signed URL error:", "POST /get-upload-url error:"; delete-media → "Error deleting files:", "Error listing files:"; update → "[Form Canvas Sync] Failed to update canvas:". Client storage key: globalBackgroundAudioVolume:{questId}.

Deployment Plan

  1. No DB migration is required for audio_url (JSONB).

  2. Ensure the public-assets MIME allow-list migration 20260923_set_public_assets_allowed_mime_types.sql is applied manually; missing audio MIME entries are the most common upload failure after a fresh environment.

  3. Standard app deploy; no feature flag.


TESTING & QUALITY ASSURANCE

Test Strategy

Existing automated coverage focuses on upload routes and validation; the audio hooks and the signed-URL/path APIs are currently covered by manual QA only.

  • tests/api/creator-upload-background-media.test.ts — quest-level background media (mp3 pinned audio/mpeg, .m4a pinned audio/mp4, SVG rejected).

  • tests/api/creator-upload-quest-media.test.ts — quest media (valid audio audio/mpeg, SVG rejected).

  • tests/unit/upload-validation.test.ts — validateFileType / validateMimeType, SVG hardening.

  • tests/unit/upload-file-to-storage.test.ts — mp3 stored under background-assets/{creator}/audio/.

  • tests/unit/resolve-upload-content-type.test.ts — m4a → audio/mp4.

Coverage gaps (recommended follow-ups)

  • No tests for get-upload-url (Zod schema, size limits, node_type enum, isSafePathSegment, response shape).

  • No tests for delete-media (path-traversal guard, per-path deletion).

  • No tests for useBackgroundAudio / useGlobalBackgroundAudio (loop, autoplay fallback, StrictMode reset, volume persistence).

  • No tests for CardAudioUpload persistence/round-trip, AssetList fallback, or the media-page localStorage paths.

Known Limitations

  • Learner has no quest-level volume/mute control. QuestPlayer calls useGlobalBackgroundAudio but discards its volume/setVolume/isMuted; only the per-card AudioIndicator is rendered.

  • Form-editor audio is not available for Reflection, Quiz/Activity, Video, or Audio cards, even though canvas Reflection/Quiz schemas support audio_url.

  • Legacy/unused audio players. The per-card <audio> code in components/learner/quest-detail/content-cards/*.tsx reads card.content.audio_url, but renderCardContent has no call sites — the live path is QuestContentRenderer + useBackgroundAudio.

  • Dead imports. Eight node files import CanvasAudioSection without using it in JSX; the only render site is NodeToolbar.tsx:78.

  • Stale JSDoc. The comment above cacheBustUrl (lib/utils.ts:325-329) describes UUID generation, not cache-busting.


MAINTENANCE & SUPPORT

Troubleshooting Cheat Sheet

Symptom

Likely cause

Fix / check

.mp3 rejected as an image / generic "Failed to upload file"

Browser reports an empty/odd MIME; client classified by MIME only

Extension fallback in lib/backgroundAssets/uploadHandlers.ts:83; server falls back to extension in validateFileType. Confirm the public-assets MIME migration is applied.

Re-upload of the same filename fails with a storage conflict

Older upsert: false

Signed URL uses upsert: true; client also generates a unique object name.

Audio plays the old file after replacing it

Browser cache; same object URL

Unique object name per upload + cacheBustUrl (?t=<Date.now()>) on preview/playback.

400 "Invalid file path"

node_id / file_ext / file_name failed isSafePathSegment

Remove /, \, .., . from those values.

400 "File is too large" on audio

File > 10 MB

Enforce the 10 MB audio limit client-side and server-side.

Adding audio on canvas moves the quest to Draft

Published-quest protection

Expected: confirmPublishedEdit() prompts and reverts to draft via PATCH /api/creator/update-quest.

Audio heard twice / overlapping

Per-card audio and quest-level background_audio_url both play

By design (two independent Audio instances). Remove one source.

Per-card audio not playing on a Video card

Video-node suppression

Expected: isVideoNode short-circuits the hook.

Quest-level volume "resets"

Volume is per-quest and lives in browser localStorage

Key globalBackgroundAudioVolume:{questId}; not synced to the DB.

Changelog

Version

Date

Author

Summary

PR / commits

1.0

2026-09-30

Patrick Babala

Initial Internal Technical Guide created (none existed). Documents the shipped creator + learner feature, the signed-URL upload pipeline, persistence semantics, and known limitations.

#667 (dead3398), #790 (258e1cc9), #741 (641184c2)

—

2026-07-14

Patrick Babala

Core feature: per-card audio (form + canvas), learner playback, three source tabs, quest-level learner audio + per-quest volume, published-quest protection, form↔canvas sync.

#667

—

2026-08-06

Patrick Babala

Audio-asset thumbnail/music placeholder + image onError fallback; background-audio volume localStorage logging.

#741 (641184c2)

—

2026-08-28

Patrick Babala

Upload rework: multipart upload-media → signed URL (get-upload-url + PUT); staged-upload cleanup; audio_url fixed to immediate-persist.

#790 (258e1cc9, ef29ee2d)


REFERENCES

  • Issue: wyzlab/WyzQuests #4 — [1.7.2] (Creator) Add audio feature in each Quest Card (closed 2026-07-16).

  • Primary PR: #667 — feature/1.7.2-creator-quest-card-background-audio.

  • Follow-up PRs: #790 — file upload via signed URLs; #741 — empty catch blocks + audio asset fallback.

  • Related, not authored by Patrick: #13 — Quest content card audio integration (prior partial); #695 — resolve mp3 background asset upload failure.

  • Published Hand-Off (superseded on API details): Hand-Off v1.0 by Joshua Uriel Tribiana (July 2, 2026). Its upload-media / update / list API section is stale (see §3.2).


VERSION HISTORY

  • 1.0 — 2026-09-30 — Patrick Babala. Initial Internal Technical Guide. Created because no ITG previously existed for this feature; companion Hand-Off documentation is updated in a later pass.


Was this article helpful?