refactor(media): use asset-centric storage key layout
This commit is contained in:
@@ -11,6 +11,7 @@ CMS_SUPPORT_PASSWORD="change-me-support-password"
|
|||||||
CMS_SUPPORT_NAME="Technical Support"
|
CMS_SUPPORT_NAME="Technical Support"
|
||||||
CMS_SUPPORT_LOGIN_KEY="support-access-change-me"
|
CMS_SUPPORT_LOGIN_KEY="support-access-change-me"
|
||||||
CMS_MEDIA_STORAGE_PROVIDER="s3"
|
CMS_MEDIA_STORAGE_PROVIDER="s3"
|
||||||
|
CMS_MEDIA_STORAGE_TENANT_ID="default"
|
||||||
CMS_MEDIA_UPLOAD_MAX_BYTES="26214400"
|
CMS_MEDIA_UPLOAD_MAX_BYTES="26214400"
|
||||||
# Optional: override local media storage directory for admin upload adapter.
|
# Optional: override local media storage directory for admin upload adapter.
|
||||||
# CMS_MEDIA_LOCAL_STORAGE_DIR="/absolute/path/to/media-storage"
|
# CMS_MEDIA_LOCAL_STORAGE_DIR="/absolute/path/to/media-storage"
|
||||||
|
|||||||
1
TODO.md
1
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-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] 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] 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/<id>/asset/<assetId>/<fileRole>/<assetId>__<variant>.<ext>`) with DB-managed media taxonomy.
|
||||||
|
|
||||||
## How We Use This File
|
## How We Use This File
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { randomUUID } from "node:crypto"
|
||||||
import { hasPermission } from "@cms/content/rbac"
|
import { hasPermission } from "@cms/content/rbac"
|
||||||
import { createMediaAsset } from "@cms/db"
|
import { createMediaAsset } from "@cms/db"
|
||||||
|
|
||||||
@@ -57,6 +58,20 @@ function parseTags(formData: FormData): string[] {
|
|||||||
.filter((item) => item.length > 0)
|
.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 {
|
function isMimeAllowed(mediaType: string, mimeType: string): boolean {
|
||||||
const rule = ALLOWED_MIME_BY_TYPE[mediaType]
|
const rule = ALLOWED_MIME_BY_TYPE[mediaType]
|
||||||
|
|
||||||
@@ -116,14 +131,9 @@ export async function POST(request: Request): Promise<Response> {
|
|||||||
return badRequest("Invalid form payload.")
|
return badRequest("Invalid form payload.")
|
||||||
}
|
}
|
||||||
|
|
||||||
const title = parseTextField(formData, "title")
|
|
||||||
const type = parseTextField(formData, "type")
|
const type = parseTextField(formData, "type")
|
||||||
const fileEntry = formData.get("file")
|
const fileEntry = formData.get("file")
|
||||||
|
|
||||||
if (!title) {
|
|
||||||
return badRequest("Title is required.")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!type) {
|
if (!type) {
|
||||||
return badRequest("Type is required.")
|
return badRequest("Type is required.")
|
||||||
}
|
}
|
||||||
@@ -146,13 +156,21 @@ export async function POST(request: Request): Promise<Response> {
|
|||||||
return badRequest(`File type ${fileEntry.type || "unknown"} is not allowed for ${type}.`)
|
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 {
|
try {
|
||||||
const stored = await storeUpload({
|
const stored = await storeUpload({
|
||||||
file: fileEntry,
|
file: fileEntry,
|
||||||
mediaType: type,
|
assetId: mediaAssetId,
|
||||||
|
variant,
|
||||||
|
fileRole,
|
||||||
})
|
})
|
||||||
|
|
||||||
const created = await createMediaAsset({
|
const created = await createMediaAsset({
|
||||||
|
id: mediaAssetId,
|
||||||
title,
|
title,
|
||||||
type,
|
type,
|
||||||
description: parseOptionalField(formData, "description"),
|
description: parseOptionalField(formData, "description"),
|
||||||
@@ -171,6 +189,7 @@ export async function POST(request: Request): Promise<Response> {
|
|||||||
{
|
{
|
||||||
id: created.id,
|
id: created.id,
|
||||||
provider: stored.provider,
|
provider: stored.provider,
|
||||||
|
warning: stored.fallbackReason,
|
||||||
notice: "Media uploaded successfully.",
|
notice: "Media uploaded successfully.",
|
||||||
},
|
},
|
||||||
{ status: 201 },
|
{ status: 201 },
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { getMediaFoundationSummary, listMediaAssets } from "@cms/db"
|
import { getMediaFoundationSummary, listMediaAssets } from "@cms/db"
|
||||||
|
|
||||||
import { AdminShell } from "@/components/admin-shell"
|
import { AdminShell } from "@/components/admin-shell"
|
||||||
|
import { FlashQueryCleanup } from "@/components/media/flash-query-cleanup"
|
||||||
import { MediaUploadForm } from "@/components/media/media-upload-form"
|
import { MediaUploadForm } from "@/components/media/media-upload-form"
|
||||||
import { resolveMediaStorageProvider } from "@/lib/media/storage"
|
import { resolveMediaStorageProvider } from "@/lib/media/storage"
|
||||||
import { requirePermissionForRoute } from "@/lib/route-guards"
|
import { requirePermissionForRoute } from "@/lib/route-guards"
|
||||||
@@ -34,7 +35,10 @@ export default async function MediaManagementPage({
|
|||||||
])
|
])
|
||||||
const notice = readFirstValue(resolvedSearchParams.notice)
|
const notice = readFirstValue(resolvedSearchParams.notice)
|
||||||
const error = readFirstValue(resolvedSearchParams.error)
|
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 activeStorageProvider = resolveMediaStorageProvider(process.env.CMS_MEDIA_STORAGE_PROVIDER)
|
||||||
|
const hasFlashQuery = Boolean(notice || error || warning || uploadedVia)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminShell
|
<AdminShell
|
||||||
@@ -44,9 +48,18 @@ export default async function MediaManagementPage({
|
|||||||
title="Media"
|
title="Media"
|
||||||
description="Media foundation baseline for assets, artwork renditions, and grouping metadata."
|
description="Media foundation baseline for assets, artwork renditions, and grouping metadata."
|
||||||
>
|
>
|
||||||
|
<FlashQueryCleanup enabled={hasFlashQuery} />
|
||||||
|
|
||||||
{notice ? (
|
{notice ? (
|
||||||
<section className="rounded-xl border border-emerald-300 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
|
<section className="rounded-xl border border-emerald-300 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
|
||||||
{notice}
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span>{notice}</span>
|
||||||
|
{uploadedVia ? (
|
||||||
|
<span className="rounded border border-emerald-300 bg-white px-2 py-0.5 text-xs font-medium uppercase tracking-wide text-emerald-700">
|
||||||
|
Stored via: {uploadedVia}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -56,6 +69,12 @@ export default async function MediaManagementPage({
|
|||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{warning ? (
|
||||||
|
<section className="rounded-xl border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-900">
|
||||||
|
{warning}
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<section className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
<section className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
<article className="rounded-xl border border-neutral-200 p-4">
|
<article className="rounded-xl border border-neutral-200 p-4">
|
||||||
<p className="text-xs uppercase tracking-[0.2em] text-neutral-500">Media Assets</p>
|
<p className="text-xs uppercase tracking-[0.2em] text-neutral-500">Media Assets</p>
|
||||||
|
|||||||
19
apps/admin/src/components/media/flash-query-cleanup.tsx
Normal file
19
apps/admin/src/components/media/flash-query-cleanup.tsx
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -44,10 +44,15 @@ export function MediaUploadForm() {
|
|||||||
|
|
||||||
const payload = (await response.json().catch(() => null)) as {
|
const payload = (await response.json().catch(() => null)) as {
|
||||||
notice?: string
|
notice?: string
|
||||||
|
provider?: "s3" | "local"
|
||||||
|
warning?: string
|
||||||
} | null
|
} | null
|
||||||
|
|
||||||
const notice = payload?.notice ?? "Media uploaded."
|
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 {
|
} catch {
|
||||||
setError("Upload request failed. Please retry.")
|
setError("Upload request failed. Please retry.")
|
||||||
} finally {
|
} finally {
|
||||||
@@ -68,8 +73,7 @@ export function MediaUploadForm() {
|
|||||||
<span className="text-xs text-neutral-600">Title</span>
|
<span className="text-xs text-neutral-600">Title</span>
|
||||||
<input
|
<input
|
||||||
name="title"
|
name="title"
|
||||||
required
|
placeholder="Optional (defaults to file name)"
|
||||||
minLength={1}
|
|
||||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ import { buildMediaStorageKey } from "@/lib/media/storage-key"
|
|||||||
|
|
||||||
type StoreLocalUploadParams = {
|
type StoreLocalUploadParams = {
|
||||||
file: File
|
file: File
|
||||||
mediaType: string
|
tenantId: string
|
||||||
|
assetId: string
|
||||||
|
fileRole: string
|
||||||
|
variant: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type StoredUpload = {
|
type StoredUpload = {
|
||||||
@@ -23,7 +26,13 @@ function resolveBaseDirectory(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function storeUploadLocally(params: StoreLocalUploadParams): Promise<StoredUpload> {
|
export async function storeUploadLocally(params: StoreLocalUploadParams): Promise<StoredUpload> {
|
||||||
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 baseDirectory = resolveBaseDirectory()
|
||||||
const outputPath = path.join(baseDirectory, storageKey)
|
const outputPath = path.join(baseDirectory, storageKey)
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import { buildMediaStorageKey } from "@/lib/media/storage-key"
|
|||||||
|
|
||||||
type StoreS3UploadParams = {
|
type StoreS3UploadParams = {
|
||||||
file: File
|
file: File
|
||||||
mediaType: string
|
tenantId: string
|
||||||
|
assetId: string
|
||||||
|
fileRole: string
|
||||||
|
variant: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type StoredUpload = {
|
type StoredUpload = {
|
||||||
@@ -62,7 +65,13 @@ function createS3Client(config: S3Config): S3Client {
|
|||||||
export async function storeUploadToS3(params: StoreS3UploadParams): Promise<StoredUpload> {
|
export async function storeUploadToS3(params: StoreS3UploadParams): Promise<StoredUpload> {
|
||||||
const config = resolveS3Config()
|
const config = resolveS3Config()
|
||||||
const client = createS3Client(config)
|
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())
|
const payload = new Uint8Array(await params.file.arrayBuffer())
|
||||||
|
|
||||||
await client.send(
|
await client.send(
|
||||||
|
|||||||
19
apps/admin/src/lib/media/storage-key.test.ts
Normal file
19
apps/admin/src/lib/media/storage-key.test.ts
Normal file
@@ -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",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,7 +1,15 @@
|
|||||||
import { randomUUID } from "node:crypto"
|
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
|
|
||||||
const FALLBACK_EXTENSION = "bin"
|
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 {
|
function normalizeSegment(value: string): string {
|
||||||
return value
|
return value
|
||||||
@@ -22,12 +30,20 @@ function extensionFromFilename(fileName: string): string {
|
|||||||
return normalized.length > 0 ? normalized : FALLBACK_EXTENSION
|
return normalized.length > 0 ? normalized : FALLBACK_EXTENSION
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildMediaStorageKey(mediaType: string, fileName: string): string {
|
export function buildMediaStorageKey(params: BuildMediaStorageKeyParams): string {
|
||||||
const now = new Date()
|
const normalizedTenantId = normalizeSegment(params.tenantId) || "default"
|
||||||
const year = String(now.getUTCFullYear())
|
const normalizedAssetId = normalizeSegment(params.assetId)
|
||||||
const month = String(now.getUTCMonth() + 1).padStart(2, "0")
|
const normalizedFileRole = normalizeSegment(params.fileRole) || "original"
|
||||||
const normalizedType = normalizeSegment(mediaType) || "generic"
|
const normalizedVariant = normalizeSegment(params.variant ?? DEFAULT_VARIANT) || DEFAULT_VARIANT
|
||||||
const extension = extensionFromFilename(fileName)
|
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("/")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,65 @@ export type MediaStorageProvider = "local" | "s3"
|
|||||||
|
|
||||||
type StoreUploadParams = {
|
type StoreUploadParams = {
|
||||||
file: File
|
file: File
|
||||||
mediaType: string
|
assetId: string
|
||||||
|
variant: string
|
||||||
|
fileRole: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type StoredUpload = {
|
type StoredUpload = {
|
||||||
storageKey: string
|
storageKey: string
|
||||||
provider: MediaStorageProvider
|
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 {
|
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<StoredUpload> {
|
export async function storeUpload(params: StoreUploadParams): Promise<StoredUpload> {
|
||||||
const provider = resolveMediaStorageProvider(process.env.CMS_MEDIA_STORAGE_PROVIDER)
|
const provider = resolveMediaStorageProvider(process.env.CMS_MEDIA_STORAGE_PROVIDER)
|
||||||
|
const tenantId = resolveTenantId()
|
||||||
|
|
||||||
if (provider === "s3") {
|
if (provider === "s3") {
|
||||||
try {
|
try {
|
||||||
const stored = await storeUploadToS3(params)
|
const stored = await storeUploadToS3({
|
||||||
|
file: params.file,
|
||||||
|
tenantId,
|
||||||
|
assetId: params.assetId,
|
||||||
|
fileRole: params.fileRole,
|
||||||
|
variant: params.variant,
|
||||||
|
})
|
||||||
return {
|
return {
|
||||||
...stored,
|
...stored,
|
||||||
provider,
|
provider,
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (error) {
|
||||||
const fallbackStored = await storeUploadLocally(params)
|
const detail = describeS3Error(error)
|
||||||
|
const fallbackStored = await storeUploadLocally({
|
||||||
|
file: params.file,
|
||||||
|
tenantId,
|
||||||
|
assetId: params.assetId,
|
||||||
|
fileRole: params.fileRole,
|
||||||
|
variant: params.variant,
|
||||||
|
})
|
||||||
return {
|
return {
|
||||||
...fallbackStored,
|
...fallbackStored,
|
||||||
provider: "local",
|
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 {
|
return {
|
||||||
...stored,
|
...stored,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export const mediaAssetTypeSchema = z.enum([
|
|||||||
export const artworkRenditionSlotSchema = z.enum(["thumbnail", "card", "full", "custom"])
|
export const artworkRenditionSlotSchema = z.enum(["thumbnail", "card", "full", "custom"])
|
||||||
|
|
||||||
export const createMediaAssetInputSchema = z.object({
|
export const createMediaAssetInputSchema = z.object({
|
||||||
|
id: z.string().uuid().optional(),
|
||||||
type: mediaAssetTypeSchema,
|
type: mediaAssetTypeSchema,
|
||||||
title: z.string().min(1).max(180),
|
title: z.string().min(1).max(180),
|
||||||
description: z.string().max(5000).optional(),
|
description: z.string().max(5000).optional(),
|
||||||
|
|||||||
Reference in New Issue
Block a user