Files

309 lines
12 KiB
Markdown
Raw Permalink Normal View History

2026-03-09 11:18:22 +01:00
# Media Manager PRD
## Purpose
Design and deliver an organization-level **Media Manager** so uploaded files become reusable, governable assets instead of content-local JSON blobs.
## Problem Statement
Today, uploads are attached directly inside content payloads (for example `lesson.videos`) and managed only from that local context.
This creates product and technical gaps:
- No organization-wide asset library.
- No reliable reuse across courses/lessons/exercises.
- No global usage map (where each asset is used).
- No global edit propagation (title/thumbnail/metadata updates).
- No accurate organization storage accounting.
- No one-click media export per organization.
## Decision Update
We will use a **separate canonical assets model**:
- `assets` table for organization-scoped asset identity and storage metadata.
- `asset_usages` table for where each asset is used.
`lesson.videos` remains temporarily for compatibility during migration.
## Current-State Audit
Legend: ✅ available now, ⚠️ partial/manual, ❌ missing
| Capability | Availability | Current State | Effort | Should Implement |
| --- | --- | --- | --- | --- |
| Upload media to content | ✅ | Added directly inside content JSON | Low | [x] |
| Reuse existing uploaded media | ⚠️ | Mostly manual copy/re-add flows | Medium | [x] |
| Org-wide asset library | ❌ | No canonical assets table/page | High | [x] |
| Cross-target usage visibility | ❌ | No usage graph | High | [x] |
| Global metadata updates | ❌ | Metadata is duplicated per content item | High | [x] |
| Accurate org storage usage | ❌ | No canonical `byte_size` aggregation | Medium | [x] |
| Safe delete/archive with usage guard | ❌ | Local removal only | Medium | [x] |
## Goals
1. Create canonical org-level assets in `assets`.
2. Support asset kinds beyond video (`document`, `image`, extensible).
3. Allow attaching assets to lessons, exercises, and nested content slots.
4. Provide organization-level browsing/search/filtering and usage visibility.
5. Support global metadata updates with consistent rendering across usages.
6. Enable accurate organization storage metrics from canonical asset rows.
7. Preserve existing lesson UX while migrating incrementally.
## Non-Goals (Initial Release)
- Replacing transcoding pipeline.
- Cross-organization sharing.
- Full billing implementation (we will provide usage metrics for billing).
## Proposed Architecture
### Data Model (New)
#### `assets`
Canonical asset record at organization scope.
Suggested fields:
- `id` (uuid, pk)
- `organization_id` (uuid, fk -> `organization.id`, indexed)
- `kind` (`video` | `document` | `image` | `audio` | `other`)
- `provider` (`upload` | `youtube` | `generic` | `external_url`)
- `storage_provider` (`s3` initially)
- `storage_key` (text, nullable, indexed)
- `source_url` (text, nullable)
- `mime_type` (text, nullable)
- `byte_size` (bigint/int, nullable for external assets, required for uploaded assets)
- `checksum` (text, nullable)
- `title` (text, nullable)
- `description` (text, nullable)
- `thumbnail_url` (text, nullable)
- `duration_seconds` (int, nullable)
- `aspect_ratio` (text, nullable)
- `is_external` (boolean, default false)
- `status` (`active` | `archived`)
- `created_by_profile_id` (uuid, fk -> `profile.id`)
- `created_at`, `updated_at`
Indexes/constraints:
- `(organization_id, created_at desc)`
- `(organization_id, kind, status)`
- `(organization_id, title)`
- `(organization_id, byte_size)`
- unique `(organization_id, provider, storage_key)` where `storage_key` is not null
#### `asset_usages`
Usage graph from asset to content targets.
Suggested fields:
- `id` (uuid, pk)
- `organization_id` (uuid, indexed)
- `asset_id` (uuid, fk -> `assets.id`, indexed)
- `target_type` (`lesson` | `exercise` | `question`)
- `target_id` (uuid)
- `slot_type` (`lesson_video` | `lesson_document` | `exercise_attachment` | `question_prompt_media` | `question_explanation_media` ...)
- `slot_key` (text, nullable; path-like key for nested content, e.g. `questions[2].prompt.media`)
- `position` (int, nullable)
- `created_by_profile_id` (uuid)
- `created_at`
Constraints:
- unique `(asset_id, target_type, target_id, slot_type, slot_key, position)`
- indexes `(target_type, target_id)` and `(asset_id)`
- service-layer scope validation: asset org must equal usage org.
### Storage Accounting
Storage metrics come from `assets`, not `asset_usages`.
Rules:
- Organization total: `sum(byte_size)` for `organization_id`.
- Reuse does not multiply storage.
- External assets (`is_external=true`) are excluded from internal storage totals.
Recommended outputs:
- `totalBytes`
- `bytesByKind`
- `internalBytes`
- `externalAssetCount`
### Backward Compatibility Strategy
- Keep `lesson.videos` during transition.
- Extend lesson media payload with `assetId?: string`.
- For new lesson attachments, write-through to both:
- canonical `assets` + `asset_usages`
- compatibility `lesson.videos`
- Read path should prefer canonical metadata when `assetId` exists.
### API Surface (Proposed)
#### Validation (`packages/utils/src/validation/assets/`)
- `ZAssetListQuery`
- `ZAssetCreateUpload`
- `ZAssetAttach`
- `ZAssetDetach`
- `ZAssetUpdate`
- `ZAssetDelete`
- `ZAssetUsageParams`
- `ZAssetExportQuery`
- `ZAssetStorageQuery`
#### DB Queries (`packages/db/src/queries/assets/`)
- `createAsset`
- `getAssetById`
- `listAssetsByOrg`
- `updateAsset`
- `archiveAsset`
- `deleteAsset`
- `createAssetUsage`
- `deleteAssetUsage`
- `listAssetUsagesByAsset`
- `countAssetUsagesByAsset`
- `listAssetsForExport`
- `getAssetStorageSummaryByOrg`
#### Services (`apps/api/src/services/assets/`)
- `createAssetFromUploadService`
- `attachAssetService`
- `detachAssetService`
- `updateAssetService`
- `deleteAssetService`
- `getAssetUsageGraphService`
- `exportOrganizationAssetsService`
- `getOrganizationAssetStorageService`
#### Routes (`apps/api/src/routes/assets/`)
All routes require `authMiddleware` + `orgTeamMemberMiddleware`, except delete/export where admin policy applies.
Proposed endpoints:
- `GET /organization/assets`
- `POST /organization/assets`
- `GET /organization/assets/:assetId`
- `PUT /organization/assets/:assetId`
- `DELETE /organization/assets/:assetId`
- `GET /organization/assets/:assetId/usage`
- `POST /organization/assets/:assetId/attach`
- `POST /organization/assets/:assetId/detach`
- `GET /organization/assets/export`
- `GET /organization/assets/storage`
## Frontend Plan
### Routes
- `apps/dashboard/src/routes/org/[slug]/media/+page.server.ts`
- `apps/dashboard/src/routes/org/[slug]/media/+page.svelte`
### Feature Module
- `apps/dashboard/src/lib/features/media-manager/`
- `api/media-manager.svelte.ts`
- `utils/types.ts`
- `components/media-library.svelte`
- `components/media-card.svelte`
- `components/media-usage-drawer.svelte`
- `components/edit-media-modal.svelte`
- `components/attach-media-modal.svelte`
- `components/storage-usage-card.svelte`
### Navigation
- Add item in `apps/dashboard/src/lib/features/ui/navigation/org-navigation.ts`:
- title key: `org_navigation.media`
- path: `/media`
### Lesson and Exercise Integration
- Lesson upload completion:
1. upload file to storage via presign
2. create/get canonical asset with `kind='video'`
3. attach usage with `targetType='lesson'`, `targetId=lessonId`, `slotType='lesson_video'`
4. persist compatibility shape with `assetId` in `lesson.videos`
- Add “Choose from library” tab in lesson add-video modal.
- Add exercise-side attach flow using `targetType='exercise'` and slot metadata.
- Replace delete-by-index with detach behavior when asset has multiple usages.
## Export Design
- v1: JSON manifest + signed download URLs.
- Include:
- asset metadata (id, kind, provider, key, mimeType, byteSize, title, timestamps)
- usage entries (`targetType`, `targetId`, `slotType`, `slotKey`, `position`)
## Migration Plan
### Phase 0: Foundation (No UI changes)
1. Add `assets` and `asset_usages` tables + indexes.
2. Add assets queries/services/routes.
3. Add compatibility support for `assetId` in lesson video schema.
4. Add migration script to backfill existing lesson media payloads (videos + documents, internal + external).
### Phase 1: Org Media Manager + Lesson Reuse
1. Add org `/media` page.
2. Add list/search/filter/sort.
3. Add usage drawer.
4. Add attach-from-library flow in lesson editor.
### Phase 2: Exercise Reuse + Global Editing + Export + Storage
1. Add exercise usage attachments via `targetType='exercise'`.
2. Add global metadata editing.
3. Add export endpoint + UI action.
4. Add storage summary endpoint + UI card.
### Phase 3: Hardening
1. Deletion/archival policies.
2. Usage-safe deletion confirmations.
3. Pagination/performance tuning.
4. Reconciliation job for JSON/table drift.
## Migration Script Requirement
A dedicated script is required to migrate existing lesson media payloads into `assets` and `asset_usages`.
Script plan:
- File: `packages/db/src/scripts/backfill-lesson-assets.ts`
- Command: `pnpm --filter @cio/db db:assets-backfill`
- Reference spec: `prd/media-manager/backfill-lesson-videos-to-assets.md`
## Risks and Mitigations
- Drift between content JSON and canonical tables.
- Mitigation: write-through plus reconciliation report.
- Expired presigned URLs in existing `link` fields.
- Mitigation: key-based URL regeneration; key is source of truth.
- Shared asset update surprises.
- Mitigation: explicit UI copy that updates are global.
- Migration edge cases (missing keys, duplicate keys).
- Mitigation: idempotent script + dry-run + skip report.
- Missing historical file sizes.
- Mitigation: nullable `byte_size` + follow-up hydration job for unknown sizes.
## Success Metrics
- `% of upload videos with assetId >= 95%` within 2 weeks.
- Median attach-from-library time < 20s.
- Export success rate > 99%.
- Duplicate upload reduction >= 40%.
- `% of internal assets with non-null byte_size >= 98%`.
## Open Questions
1. Should we add size hydration for existing external assets where `byte_size` is unknown?
2. Should delete be admin-only in v1?
3. Should v1 use soft-delete only (`archived`) before hard-delete?
4. Should storage totals exclude archived assets?
5. What is the final controlled vocabulary for `slot_type` values across exercises/questions?
## Kanban Board (Ideation Phase)
| Ticket | Title | Todo | In Progress | Verification | Done |
| --- | --- | --- | --- | --- | --- |
| MM-1 | DB: create `assets` and `asset_usages` tables | [ ] | [ ] | [ ] | [ ] |
| MM-2 | Validation schemas for assets APIs | [ ] | [ ] | [ ] | [ ] |
| MM-3 | DB queries for assets CRUD + usage graph | [ ] | [ ] | [ ] | [ ] |
| MM-4 | Service layer for attach/detach/update/delete/export | [ ] | [ ] | [ ] | [ ] |
| MM-5 | API routes under organization scope (`/organization/assets`) | [ ] | [ ] | [ ] | [ ] |
| MM-6 | Backfill script: lesson media (videos + documents) -> assets + usages | [ ] | [ ] | [ ] | [ ] |
| MM-7 | Dashboard feature module (`media-manager`) | [ ] | [ ] | [ ] | [ ] |
| MM-8 | Org `/media` page with list/search/filter | [ ] | [ ] | [ ] | [ ] |
| MM-9 | Usage drawer with target/slot visibility | [ ] | [ ] | [ ] | [ ] |
| MM-10 | Lesson “Choose from library” attach flow | [ ] | [ ] | [ ] | [ ] |
| MM-11 | Exercise slot attach flow | [ ] | [ ] | [ ] | [ ] |
| MM-12 | Global media edit modal | [ ] | [ ] | [ ] | [ ] |
| MM-13 | Export all assets (manifest + signed URLs) | [ ] | [ ] | [ ] | [ ] |
| MM-14 | Storage summary endpoint + UI | [ ] | [ ] | [ ] | [ ] |
| MM-15 | Permission hardening + audit logs | [ ] | [ ] | [ ] | [ ] |
## Verification Checklist
- [ ] `pnpm --filter @cio/db build`
- [ ] `pnpm --filter @cio/utils build`
- [ ] `pnpm --filter @cio/api build`
- [ ] `pnpm --filter @cio/dashboard build`
- [ ] Backfill dry-run on staging snapshot
- [ ] Manual smoke: upload -> attach elsewhere -> edit metadata -> verify propagation
- [ ] Manual smoke: same asset used in multiple slots of one exercise
- [ ] Manual smoke: storage summary correctness against sample dataset
- [ ] Manual smoke: export output integrity