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_urlto 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_urlreliably 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_urlto 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 cardcontentJSON and canvas nodedata.background_audio_url— quest-level (single) background audio track stored onquests/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-urlthen clientPUTdirectly 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
Creator authoring —
CardAudioUpload(form editor),CanvasAudioSection(visual canvas, viaNodeToolbar), the Media section's quest-level audio + volume slider, and the Background Assets library.Upload & API —
POST /api/creator/quest-content-nodes/get-upload-urlmints a 60-second signed URL; the clientPUTs the file;PUT /api/creator/quest-content-cards/updatepersistsaudio_urland syncs the canvas;DELETE /api/creator/quest-content-nodes/delete-mediacleans up objects.Persistence (Postgres) —
quest_content_cards.contentJSON (audio_url),quests.canvas_metadatanodedata.audio_url,quests.background_audio_url, andasset_metadatarows withasset_type = 'AUDIO'.Object storage — Supabase Storage bucket
public-assets, pathquests/{node_id}/{name}.{ext}, MIME allow-listed.Learner delivery —
GET /api/learner/get-quest;QuestPlayerdrivesuseBackgroundAudio(per-card) anduseGlobalBackgroundAudio(quest-level) with theAudioIndicatortoolbar.
Technologies Used
Next.js (App Router) route handlers + React components
@xyflow/react(React Flow) for the canvasSupabase (Postgres + Storage + Auth;
getCreatorAuthContext)Zod (
getUploadUrlSchema) and the sharedvalidateFileTypeallow-listshadcn/ui (
Button,Input,Slider,Accordion),lucide-react,sonnertoastsPlaywright (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/updateand persisted immediately on audio change (form-editor/page.tsx:4261-4267).The table predates the repo migration folder;
contentis JSONB, soaudio_urlneeds no schema migration.
quests.canvas_metadata node data.audio_url
lib/schemas/nodes.schema.tsdeclaresaudio_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) andaudioNodeDataSchema(: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_QUESTinlib/quest/queries.ts:4, which also selects it.Added to
adventuresfor parity insupabase/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 in20260813_add_document_asset_type.sql:23.Bucket
public-assets; pathquests/{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). Includesaudio/mpeg,audio/mp3,audio/wav,audio/ogg,audio/mp4,audio/aac,audio/flac, aliases (audio/x-wav,audio/x-m4a,audio/wave), andapplication/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 tonode_id,file_ext, optionalfile_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-morepathvalues.Guards:
node_idviaisSafePathSegment; eachpathmust start withquests/{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.urlis replaced or removed, the old object is deleted frompublic-assets. This targetslink/url, notaudio_url(audio objects are intentionally retained).Canvas sync (linear quests): re-maps the card via
mapCardDataToNodeDataand writes backcanvas_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-levelbackground_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 setsaudio_url: undefined.Immediate persistence:
handleUpdateCardContent(:4240-4269) writes local state and, whenaudio_urlis in the updates, callsupdateContentCard(...)(thePUT .../updatecall) 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; propsnodeId, audioUrl, isChild?, isArchived?, onUpdate?, variant?.Surfaced through
NodeToolbarwhen the Music icon button (title="Background Audio") is toggled; it renders withvariant="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;
cachedAudioSrcis memoised throughcacheBustUrl; preview<audio controls loop>.Published-quest protection:
confirmPublishedEdit()is awaited before local upload, URL apply, asset select, and remove. Archived nodes rendernull.
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
localStoragekeyglobalBackgroundAudioVolume:{questID}(wrap inconsole.warnon failure). Slider label "Learner Volume (global)"; helper "This volume is applied globally in the Learner View only."Hook
useGlobalBackgroundAudioreads the same key for its initial volume (default0.5) and persists onsetVolume. NotoggleMuteis exposed.
Form ↔ canvas sync
lib/canvas-sync/formToCanvas.ts→mapCardDataToNodeDatacopiesaudio_urlfor 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→mapNodeDataToCardContentwritesaudio_urlback for text, image, quiz, link, code, essay, file, reflection, discussion, question;transformScenarioNodeToChildCardcovers text, image, file.
Learner playback
QuestPlayer.tsx: per-card URL from the active nodedata.audio_url;isVideoNodesuppression;useBackgroundAudio({ audioUrl, isVideoNode });useGlobalBackgroundAudio({ audioUrl: quest.background_audio_url, questId })(return currently discarded); bottom-rightAudioIndicatortoolbar.useBackgroundAudio: freshnew Audio(cacheBustUrl(audioUrl))withloop = true; autoplay when the user has interacted, otherwise queues a one-shotclick; retries on next click ifplay()is blocked;cleanup()resetspreviousUrlRef(React StrictMode fix); mute implemented as volume 0;setVolumeclamps to[0,1].AudioIndicator: returnsnullwhen!hasAudio; mute button aria-label"Mute background audio"/"Unmute background audio"; 0–100 slider mapped to0–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 bucketpublic-assets, Auth context.Next.js App Router route handlers under
app/api/creator/quest-content-nodes/*andapp/api/creator/quest-content-cards/*.Zod + shared
lib/uploadAssets/validate.tsandlib/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
No DB migration is required for
audio_url(JSONB).Ensure the
public-assetsMIME allow-list migration20260923_set_public_assets_allowed_mime_types.sqlis applied manually; missing audio MIME entries are the most common upload failure after a fresh environment.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 pinnedaudio/mpeg,.m4apinnedaudio/mp4, SVG rejected).tests/api/creator-upload-quest-media.test.ts— quest media (valid audioaudio/mpeg, SVG rejected).tests/unit/upload-validation.test.ts—validateFileType/validateMimeType, SVG hardening.tests/unit/upload-file-to-storage.test.ts— mp3 stored underbackground-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_typeenum,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
CardAudioUploadpersistence/round-trip,AssetListfallback, or the media-page localStorage paths.
Known Limitations
Learner has no quest-level volume/mute control.
QuestPlayercallsuseGlobalBackgroundAudiobut discards itsvolume/setVolume/isMuted; only the per-cardAudioIndicatoris 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 incomponents/learner/quest-detail/content-cards/*.tsxreadscard.content.audio_url, butrenderCardContenthas no call sites — the live path isQuestContentRenderer+useBackgroundAudio.Dead imports. Eight node files import
CanvasAudioSectionwithout using it in JSX; the only render site isNodeToolbar.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 |
|---|---|---|
| Browser reports an empty/odd MIME; client classified by MIME only | Extension fallback in |
Re-upload of the same filename fails with a storage conflict | Older | Signed URL uses |
Audio plays the old file after replacing it | Browser cache; same object URL | Unique object name per upload + |
|
| Remove |
| 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: |
Audio heard twice / overlapping | Per-card audio and quest-level | By design (two independent |
Per-card audio not playing on a Video card | Video-node suppression | Expected: |
Quest-level volume "resets" | Volume is per-quest and lives in browser | Key |
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 ( |
— | 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 | #741 ( |
— | 2026-08-28 | Patrick Babala | Upload rework: multipart | #790 ( |
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/listAPI 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.