From 3e4f0b6c75c59422675637bf658b6682c22f4a89 Mon Sep 17 00:00:00 2001 From: Citali Date: Thu, 12 Feb 2026 18:41:01 +0100 Subject: [PATCH] refactor(media): use asset-centric storage key layout --- .env.example | 1 + TODO.md | 1 + apps/admin/src/app/api/media/upload/route.ts | 31 +++++-- apps/admin/src/app/media/page.tsx | 21 ++++- .../components/media/flash-query-cleanup.tsx | 19 +++++ .../components/media/media-upload-form.tsx | 10 ++- apps/admin/src/lib/media/local-storage.ts | 13 ++- apps/admin/src/lib/media/s3-storage.ts | 13 ++- apps/admin/src/lib/media/storage-key.test.ts | 19 +++++ apps/admin/src/lib/media/storage-key.ts | 32 +++++-- apps/admin/src/lib/media/storage.ts | 84 +++++++++++++++++-- packages/content/src/media.ts | 1 + 12 files changed, 218 insertions(+), 27 deletions(-) create mode 100644 apps/admin/src/components/media/flash-query-cleanup.tsx create mode 100644 apps/admin/src/lib/media/storage-key.test.ts diff --git a/.env.example b/.env.example index feb5f34..15f8e91 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,7 @@ CMS_SUPPORT_PASSWORD="change-me-support-password" CMS_SUPPORT_NAME="Technical Support" CMS_SUPPORT_LOGIN_KEY="support-access-change-me" CMS_MEDIA_STORAGE_PROVIDER="s3" +CMS_MEDIA_STORAGE_TENANT_ID="default" CMS_MEDIA_UPLOAD_MAX_BYTES="26214400" # Optional: override local media storage directory for admin upload adapter. # CMS_MEDIA_LOCAL_STORAGE_DIR="/absolute/path/to/media-storage" diff --git a/TODO.md b/TODO.md index 57e6408..ed9c34a 100644 --- a/TODO.md +++ b/TODO.md @@ -272,6 +272,7 @@ This file is the single source of truth for roadmap and delivery progress. - [2026-02-11] MVP1 media foundation now includes baseline create/link workflows in admin (`/media`, `/portfolio`), seeded sample portfolio entities, and schema/service test coverage. - [2026-02-12] MVP1 media upload pipeline started: admin `/api/media/upload` accepts metadata + file upload with permission checks, stores files via local adapter (`.data/media`), and persists upload metadata to `MediaAsset`. - [2026-02-12] Upload storage is now provider-based (`local` + `s3`) via `CMS_MEDIA_STORAGE_PROVIDER`; admin-side GUI toggle remains a later MVP item. +- [2026-02-12] Media storage keys now use asset-centric layout (`tenant//asset///__.`) with DB-managed media taxonomy. ## How We Use This File diff --git a/apps/admin/src/app/api/media/upload/route.ts b/apps/admin/src/app/api/media/upload/route.ts index 6ee2fd6..7537e54 100644 --- a/apps/admin/src/app/api/media/upload/route.ts +++ b/apps/admin/src/app/api/media/upload/route.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto" import { hasPermission } from "@cms/content/rbac" import { createMediaAsset } from "@cms/db" @@ -57,6 +58,20 @@ function parseTags(formData: FormData): string[] { .filter((item) => item.length > 0) } +function deriveTitleFromFilename(fileName: string): string { + const trimmed = fileName.trim() + + if (!trimmed) { + return "Untitled media" + } + + const dotIndex = trimmed.lastIndexOf(".") + const base = dotIndex > 0 ? trimmed.slice(0, dotIndex) : trimmed + const normalized = base.trim() + + return normalized.length > 0 ? normalized : "Untitled media" +} + function isMimeAllowed(mediaType: string, mimeType: string): boolean { const rule = ALLOWED_MIME_BY_TYPE[mediaType] @@ -116,14 +131,9 @@ export async function POST(request: Request): Promise { return badRequest("Invalid form payload.") } - const title = parseTextField(formData, "title") const type = parseTextField(formData, "type") const fileEntry = formData.get("file") - if (!title) { - return badRequest("Title is required.") - } - if (!type) { return badRequest("Type is required.") } @@ -146,13 +156,21 @@ export async function POST(request: Request): Promise { return badRequest(`File type ${fileEntry.type || "unknown"} is not allowed for ${type}.`) } + const title = parseTextField(formData, "title") || deriveTitleFromFilename(fileEntry.name) + const mediaAssetId = randomUUID() + const variant = "original" + const fileRole = "original" + try { const stored = await storeUpload({ file: fileEntry, - mediaType: type, + assetId: mediaAssetId, + variant, + fileRole, }) const created = await createMediaAsset({ + id: mediaAssetId, title, type, description: parseOptionalField(formData, "description"), @@ -171,6 +189,7 @@ export async function POST(request: Request): Promise { { id: created.id, provider: stored.provider, + warning: stored.fallbackReason, notice: "Media uploaded successfully.", }, { status: 201 }, diff --git a/apps/admin/src/app/media/page.tsx b/apps/admin/src/app/media/page.tsx index b0cabc4..f4692a8 100644 --- a/apps/admin/src/app/media/page.tsx +++ b/apps/admin/src/app/media/page.tsx @@ -1,6 +1,7 @@ import { getMediaFoundationSummary, listMediaAssets } from "@cms/db" import { AdminShell } from "@/components/admin-shell" +import { FlashQueryCleanup } from "@/components/media/flash-query-cleanup" import { MediaUploadForm } from "@/components/media/media-upload-form" import { resolveMediaStorageProvider } from "@/lib/media/storage" import { requirePermissionForRoute } from "@/lib/route-guards" @@ -34,7 +35,10 @@ export default async function MediaManagementPage({ ]) const notice = readFirstValue(resolvedSearchParams.notice) const error = readFirstValue(resolvedSearchParams.error) + const uploadedVia = readFirstValue(resolvedSearchParams.uploadedVia) + const warning = readFirstValue(resolvedSearchParams.warning) const activeStorageProvider = resolveMediaStorageProvider(process.env.CMS_MEDIA_STORAGE_PROVIDER) + const hasFlashQuery = Boolean(notice || error || warning || uploadedVia) return ( + + {notice ? (
- {notice} +
+ {notice} + {uploadedVia ? ( + + Stored via: {uploadedVia} + + ) : null} +
) : null} @@ -56,6 +69,12 @@ export default async function MediaManagementPage({ ) : null} + {warning ? ( +
+ {warning} +
+ ) : null} +

Media Assets

diff --git a/apps/admin/src/components/media/flash-query-cleanup.tsx b/apps/admin/src/components/media/flash-query-cleanup.tsx new file mode 100644 index 0000000..28b5109 --- /dev/null +++ b/apps/admin/src/components/media/flash-query-cleanup.tsx @@ -0,0 +1,19 @@ +"use client" + +import { useEffect } from "react" + +type FlashQueryCleanupProps = { + enabled: boolean +} + +export function FlashQueryCleanup({ enabled }: FlashQueryCleanupProps) { + useEffect(() => { + if (!enabled) { + return + } + + window.history.replaceState(window.history.state, "", "/media") + }, [enabled]) + + return null +} diff --git a/apps/admin/src/components/media/media-upload-form.tsx b/apps/admin/src/components/media/media-upload-form.tsx index 0cc0c17..b65f74d 100644 --- a/apps/admin/src/components/media/media-upload-form.tsx +++ b/apps/admin/src/components/media/media-upload-form.tsx @@ -44,10 +44,15 @@ export function MediaUploadForm() { const payload = (await response.json().catch(() => null)) as { notice?: string + provider?: "s3" | "local" + warning?: string } | null const notice = payload?.notice ?? "Media uploaded." - window.location.href = `/media?notice=${encodeURIComponent(notice)}` + const provider = payload?.provider ?? "local" + const warning = payload?.warning + const warningQuery = warning ? `&warning=${encodeURIComponent(warning)}` : "" + window.location.href = `/media?notice=${encodeURIComponent(notice)}&uploadedVia=${encodeURIComponent(provider)}${warningQuery}` } catch { setError("Upload request failed. Please retry.") } finally { @@ -68,8 +73,7 @@ export function MediaUploadForm() { Title diff --git a/apps/admin/src/lib/media/local-storage.ts b/apps/admin/src/lib/media/local-storage.ts index 57b6018..aa96845 100644 --- a/apps/admin/src/lib/media/local-storage.ts +++ b/apps/admin/src/lib/media/local-storage.ts @@ -5,7 +5,10 @@ import { buildMediaStorageKey } from "@/lib/media/storage-key" type StoreLocalUploadParams = { file: File - mediaType: string + tenantId: string + assetId: string + fileRole: string + variant: string } type StoredUpload = { @@ -23,7 +26,13 @@ function resolveBaseDirectory(): string { } export async function storeUploadLocally(params: StoreLocalUploadParams): Promise { - const storageKey = buildMediaStorageKey(params.mediaType, params.file.name) + const storageKey = buildMediaStorageKey({ + tenantId: params.tenantId, + assetId: params.assetId, + fileRole: params.fileRole, + variant: params.variant, + fileName: params.file.name, + }) const baseDirectory = resolveBaseDirectory() const outputPath = path.join(baseDirectory, storageKey) diff --git a/apps/admin/src/lib/media/s3-storage.ts b/apps/admin/src/lib/media/s3-storage.ts index cc88166..114f60f 100644 --- a/apps/admin/src/lib/media/s3-storage.ts +++ b/apps/admin/src/lib/media/s3-storage.ts @@ -4,7 +4,10 @@ import { buildMediaStorageKey } from "@/lib/media/storage-key" type StoreS3UploadParams = { file: File - mediaType: string + tenantId: string + assetId: string + fileRole: string + variant: string } type StoredUpload = { @@ -62,7 +65,13 @@ function createS3Client(config: S3Config): S3Client { export async function storeUploadToS3(params: StoreS3UploadParams): Promise { const config = resolveS3Config() const client = createS3Client(config) - const storageKey = buildMediaStorageKey(params.mediaType, params.file.name) + const storageKey = buildMediaStorageKey({ + tenantId: params.tenantId, + assetId: params.assetId, + fileRole: params.fileRole, + variant: params.variant, + fileName: params.file.name, + }) const payload = new Uint8Array(await params.file.arrayBuffer()) await client.send( diff --git a/apps/admin/src/lib/media/storage-key.test.ts b/apps/admin/src/lib/media/storage-key.test.ts new file mode 100644 index 0000000..32a8db1 --- /dev/null +++ b/apps/admin/src/lib/media/storage-key.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest" + +import { buildMediaStorageKey } from "@/lib/media/storage-key" + +describe("buildMediaStorageKey", () => { + it("builds asset-centric key with fileRole and variant", () => { + const key = buildMediaStorageKey({ + tenantId: "default", + assetId: "550e8400-e29b-41d4-a716-446655440000", + fileRole: "original", + variant: "thumb", + fileName: "My File.PNG", + }) + + expect(key).toBe( + "tenant/default/asset/550e8400-e29b-41d4-a716-446655440000/original/550e8400-e29b-41d4-a716-446655440000__thumb.png", + ) + }) +}) diff --git a/apps/admin/src/lib/media/storage-key.ts b/apps/admin/src/lib/media/storage-key.ts index e035c1f..9b1707d 100644 --- a/apps/admin/src/lib/media/storage-key.ts +++ b/apps/admin/src/lib/media/storage-key.ts @@ -1,7 +1,15 @@ -import { randomUUID } from "node:crypto" import path from "node:path" const FALLBACK_EXTENSION = "bin" +const DEFAULT_VARIANT = "original" + +type BuildMediaStorageKeyParams = { + tenantId: string + assetId: string + fileRole: string + variant?: string + fileName: string +} function normalizeSegment(value: string): string { return value @@ -22,12 +30,20 @@ function extensionFromFilename(fileName: string): string { return normalized.length > 0 ? normalized : FALLBACK_EXTENSION } -export function buildMediaStorageKey(mediaType: string, fileName: string): string { - const now = new Date() - const year = String(now.getUTCFullYear()) - const month = String(now.getUTCMonth() + 1).padStart(2, "0") - const normalizedType = normalizeSegment(mediaType) || "generic" - const extension = extensionFromFilename(fileName) +export function buildMediaStorageKey(params: BuildMediaStorageKeyParams): string { + const normalizedTenantId = normalizeSegment(params.tenantId) || "default" + const normalizedAssetId = normalizeSegment(params.assetId) + const normalizedFileRole = normalizeSegment(params.fileRole) || "original" + const normalizedVariant = normalizeSegment(params.variant ?? DEFAULT_VARIANT) || DEFAULT_VARIANT + const extension = extensionFromFilename(params.fileName) + const fileName = `${normalizedAssetId}__${normalizedVariant}.${extension}` - return [normalizedType, year, month, `${randomUUID()}.${extension}`].join("/") + return [ + "tenant", + normalizedTenantId, + "asset", + normalizedAssetId, + normalizedFileRole, + fileName, + ].join("/") } diff --git a/apps/admin/src/lib/media/storage.ts b/apps/admin/src/lib/media/storage.ts index bac8764..2eb8d16 100644 --- a/apps/admin/src/lib/media/storage.ts +++ b/apps/admin/src/lib/media/storage.ts @@ -5,12 +5,65 @@ export type MediaStorageProvider = "local" | "s3" type StoreUploadParams = { file: File - mediaType: string + assetId: string + variant: string + fileRole: string } type StoredUpload = { storageKey: string provider: MediaStorageProvider + fallbackReason?: string +} + +type S3LikeError = { + name?: unknown + message?: unknown + Code?: unknown + code?: unknown + $metadata?: { + httpStatusCode?: unknown + requestId?: unknown + } +} + +function resolveTenantId(): string { + return process.env.CMS_MEDIA_STORAGE_TENANT_ID?.trim() || "default" +} + +function describeS3Error(error: unknown): string { + if (!error || typeof error !== "object") { + return "Unknown S3 error" + } + + const err = error as S3LikeError + const details: string[] = [] + + if (typeof err.name === "string" && err.name.length > 0) { + details.push(`name=${err.name}`) + } + + if (typeof err.message === "string" && err.message.length > 0) { + details.push(`message=${err.message}`) + } + + if (typeof err.Code === "string" && err.Code.length > 0) { + details.push(`code=${err.Code}`) + } else if (typeof err.code === "string" && err.code.length > 0) { + details.push(`code=${err.code}`) + } + + const status = err.$metadata?.httpStatusCode + if (typeof status === "number") { + details.push(`status=${status}`) + } + + const requestId = err.$metadata?.requestId + if (typeof requestId === "string" && requestId.length > 0) { + details.push(`requestId=${requestId}`) + } + + return details.length > 0 ? details.join(", ") : "Unknown S3 error" } export function resolveMediaStorageProvider(raw: string | undefined): MediaStorageProvider { @@ -23,24 +76,45 @@ export function resolveMediaStorageProvider(raw: string | undefined): MediaStora export async function storeUpload(params: StoreUploadParams): Promise { const provider = resolveMediaStorageProvider(process.env.CMS_MEDIA_STORAGE_PROVIDER) + const tenantId = resolveTenantId() if (provider === "s3") { try { - const stored = await storeUploadToS3(params) + const stored = await storeUploadToS3({ + file: params.file, + tenantId, + assetId: params.assetId, + fileRole: params.fileRole, + variant: params.variant, + }) return { ...stored, provider, } - } catch { - const fallbackStored = await storeUploadLocally(params) + } catch (error) { + const detail = describeS3Error(error) + const fallbackStored = await storeUploadLocally({ + file: params.file, + tenantId, + assetId: params.assetId, + fileRole: params.fileRole, + variant: params.variant, + }) return { ...fallbackStored, provider: "local", + fallbackReason: `S3 upload failed; file stored locally instead. ${detail}`, } } } - const stored = await storeUploadLocally(params) + const stored = await storeUploadLocally({ + file: params.file, + tenantId, + assetId: params.assetId, + fileRole: params.fileRole, + variant: params.variant, + }) return { ...stored, diff --git a/packages/content/src/media.ts b/packages/content/src/media.ts index a020416..adbacc4 100644 --- a/packages/content/src/media.ts +++ b/packages/content/src/media.ts @@ -12,6 +12,7 @@ export const mediaAssetTypeSchema = z.enum([ export const artworkRenditionSlotSchema = z.enum(["thumbnail", "card", "full", "custom"]) export const createMediaAssetInputSchema = z.object({ + id: z.string().uuid().optional(), type: mediaAssetTypeSchema, title: z.string().min(1).max(180), description: z.string().max(5000).optional(),