proper asset creation/deletion, also raw file view in admin as well

This commit is contained in:
user
2026-03-01 18:22:49 +02:00
parent 6c2b917088
commit 9716deead7
10 changed files with 524 additions and 69 deletions

View File

@@ -1,9 +1,10 @@
import {
mobileMediaAssetInputSchema,
pingMobileDeviceSchema,
registerMobileDeviceSchema,
syncMobileMediaSchema,
syncMobileSMSSchema,
} from "@pkg/logic/domains/mobile/data";
import { getFileController } from "@pkg/logic/domains/files/controller";
import { getMobileController } from "@pkg/logic/domains/mobile/controller";
import type { FlowExecCtx } from "@pkg/logic/core/flow.execution.context";
import { errorStatusMap, type Err } from "@pkg/result";
@@ -13,8 +14,16 @@ import * as v from "valibot";
import { Hono } from "hono";
const mobileController = getMobileController();
const fileController = getFileController();
const DEVICE_ID_HEADER = "x-device-id";
const FLOW_ID_HEADER = "x-flow-id";
const MEDIA_EXTERNAL_ID_HEADER = "x-media-external-id";
const MEDIA_MIME_TYPE_HEADER = "x-media-mime-type";
const MEDIA_FILENAME_HEADER = "x-media-filename";
const MEDIA_CAPTURED_AT_HEADER = "x-media-captured-at";
const MEDIA_SIZE_BYTES_HEADER = "x-media-size-bytes";
const MEDIA_HASH_HEADER = "x-media-hash";
const MEDIA_METADATA_HEADER = "x-media-metadata";
function buildFlowExecCtx(c: Context): FlowExecCtx {
return {
@@ -44,6 +53,34 @@ async function parseJson(c: Context) {
}
}
function toOptionalString(
value: FormDataEntryValue | string | null | undefined,
): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function parseOptionalJsonRecord(value: string | undefined): {
value?: Record<string, unknown>;
error?: string;
} {
if (!value) {
return {};
}
try {
const parsed = JSON.parse(value);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return { value: parsed as Record<string, unknown> };
}
return { error: "metadata must be a JSON object" };
} catch {
return { error: "metadata must be valid JSON" };
}
}
export const mobileRouter = new Hono()
.post("/register", async (c) => {
const fctx = buildFlowExecCtx(c);
@@ -220,25 +257,150 @@ export const mobileRouter = new Hono()
return respondError(c, fctx, error);
}
const payload = await parseJson(c);
const parsed = v.safeParse(syncMobileMediaSchema, payload);
const form = await c.req.formData().catch(() => null);
if (!form) {
const error = {
flowId: fctx.flowId,
code: "VALIDATION_ERROR",
message: "Invalid media upload request",
description: "Expected multipart/form-data with a file field",
detail: "Unable to parse multipart form data",
} as Err;
return respondError(c, fctx, error);
}
const fileEntry = form.get("file");
if (!(fileEntry instanceof File)) {
const error = {
flowId: fctx.flowId,
code: "VALIDATION_ERROR",
message: "Missing media file",
description: "Expected a file part named 'file'",
detail: "multipart/form-data requires file field",
} as Err;
return respondError(c, fctx, error);
}
const sizeBytesRaw =
toOptionalString(form.get("sizeBytes")) ||
toOptionalString(c.req.header(MEDIA_SIZE_BYTES_HEADER));
const sizeBytesParsed =
sizeBytesRaw !== undefined ? Number(sizeBytesRaw) : undefined;
const metadataRaw =
toOptionalString(form.get("metadata")) ||
toOptionalString(c.req.header(MEDIA_METADATA_HEADER));
const metadataResult = parseOptionalJsonRecord(metadataRaw);
if (metadataResult.error) {
const error = {
flowId: fctx.flowId,
code: "VALIDATION_ERROR",
message: "Invalid media metadata",
description: "Please provide metadata as a JSON object",
detail: metadataResult.error,
} as Err;
return respondError(c, fctx, error);
}
const mediaPayload = {
externalMediaId:
toOptionalString(form.get("externalMediaId")) ||
toOptionalString(c.req.header(MEDIA_EXTERNAL_ID_HEADER)),
fileId: crypto.randomUUID(),
mimeType:
toOptionalString(form.get("mimeType")) ||
toOptionalString(c.req.header(MEDIA_MIME_TYPE_HEADER)) ||
fileEntry.type ||
"application/octet-stream",
filename:
toOptionalString(form.get("filename")) ||
toOptionalString(c.req.header(MEDIA_FILENAME_HEADER)) ||
fileEntry.name,
capturedAt:
toOptionalString(form.get("capturedAt")) ||
toOptionalString(c.req.header(MEDIA_CAPTURED_AT_HEADER)),
sizeBytes: Number.isFinite(sizeBytesParsed)
? sizeBytesParsed
: fileEntry.size,
hash:
toOptionalString(form.get("hash")) ||
toOptionalString(c.req.header(MEDIA_HASH_HEADER)),
metadata: metadataResult.value,
};
const parsed = v.safeParse(mobileMediaAssetInputSchema, mediaPayload);
if (!parsed.success) {
const error = {
flowId: fctx.flowId,
code: "VALIDATION_ERROR",
message: "Invalid media sync payload",
description: "Please validate the payload and retry",
message: "Invalid media upload metadata",
description: "Please validate multipart metadata and retry",
detail: parsed.issues.map((issue) => issue.message).join(", "),
} as Err;
return respondError(c, fctx, error);
}
const deviceResult = await mobileController.resolveDeviceByExternalId(
fctx,
externalDeviceId,
);
if (deviceResult.isErr()) {
return respondError(c, fctx, deviceResult.error);
}
const uploadResult = await fileController.uploadFile(
fctx,
deviceResult.value.ownerUserId,
fileEntry,
{
visibility: "private",
metadata: {
source: "mobile.media.sync",
externalDeviceId,
externalMediaId: parsed.output.externalMediaId || null,
...(parsed.output.metadata || {}),
},
tags: ["mobile", "media-sync"],
processImage: parsed.output.mimeType.startsWith("image/"),
processVideo: parsed.output.mimeType.startsWith("video/"),
},
);
if (uploadResult.isErr()) {
logDomainEvent({
level: "error",
event: "processor.mobile.media_upload.failed",
fctx,
durationMs: Date.now() - startedAt,
error: uploadResult.error,
meta: { externalDeviceId, filename: fileEntry.name },
});
return respondError(c, fctx, uploadResult.error);
}
const uploadedFileId =
uploadResult.value.file?.id || uploadResult.value.uploadId || null;
if (!uploadedFileId) {
const error = {
flowId: fctx.flowId,
code: "INTERNAL_ERROR",
message: "Failed to resolve uploaded file id",
description: "Please retry media upload",
detail: "File upload succeeded but returned no file id",
} as Err;
return respondError(c, fctx, error);
}
const result = await mobileController.syncMediaByExternalDeviceId(
fctx,
externalDeviceId,
parsed.output.assets,
[{ ...parsed.output, fileId: uploadedFileId }],
);
if (result.isErr()) {
await fileController.deleteFiles(
fctx,
[uploadedFileId],
deviceResult.value.ownerUserId,
);
logDomainEvent({
level: "error",
event: "processor.mobile.media_sync.failed",
@@ -247,7 +409,7 @@ export const mobileRouter = new Hono()
error: result.error,
meta: {
externalDeviceId,
received: parsed.output.assets.length,
received: 1,
},
});
return respondError(c, fctx, result.error);
@@ -259,8 +421,15 @@ export const mobileRouter = new Hono()
durationMs: Date.now() - startedAt,
meta: {
externalDeviceId,
fileId: uploadedFileId,
...result.value,
},
});
return c.json({ data: result.value, error: null });
return c.json({
data: {
...result.value,
fileId: uploadedFileId,
},
error: null,
});
});