From 9716deead725c1f087aa3c06883ece39a0203835 Mon Sep 17 00:00:00 2001 From: user Date: Sun, 1 Mar 2026 18:22:49 +0200 Subject: [PATCH] proper asset creation/deletion, also raw file view in admin as well --- apps/main/src/lib/core/constants.ts | 6 + .../src/lib/domains/files/files.remote.ts | 48 +++++ .../src/lib/domains/files/files.vm.svelte.ts | 60 ++++++ .../main/src/routes/(main)/files/+page.svelte | 115 +++++++++++ apps/processor/src/domains/mobile/router.ts | 185 +++++++++++++++++- packages/logic/domains/mobile/controller.ts | 43 +++- packages/logic/domains/mobile/repository.ts | 89 +++++---- packages/logic/package.json | 1 + pnpm-lock.yaml | 3 + spec.mobile.md | 43 ++-- 10 files changed, 524 insertions(+), 69 deletions(-) create mode 100644 apps/main/src/lib/domains/files/files.remote.ts create mode 100644 apps/main/src/lib/domains/files/files.vm.svelte.ts create mode 100644 apps/main/src/routes/(main)/files/+page.svelte diff --git a/apps/main/src/lib/core/constants.ts b/apps/main/src/lib/core/constants.ts index d030983..d6e1579 100644 --- a/apps/main/src/lib/core/constants.ts +++ b/apps/main/src/lib/core/constants.ts @@ -1,4 +1,5 @@ import LayoutDashboard from "@lucide/svelte/icons/layout-dashboard"; +import FileArchive from "@lucide/svelte/icons/file-archive"; import Smartphone from "@lucide/svelte/icons/smartphone"; import { BellRingIcon, UsersIcon } from "@lucide/svelte"; import UserCircle from "~icons/lucide/user-circle"; @@ -31,6 +32,11 @@ export const mainNavTree = [ url: "/devices", icon: Smartphone, }, + { + title: "Files", + url: "/files", + icon: FileArchive, + }, ] as AppSidebarItem[]; export const secondaryNavTree = [ diff --git a/apps/main/src/lib/domains/files/files.remote.ts b/apps/main/src/lib/domains/files/files.remote.ts new file mode 100644 index 0000000..5cc1cc5 --- /dev/null +++ b/apps/main/src/lib/domains/files/files.remote.ts @@ -0,0 +1,48 @@ +import { getFileController } from "@pkg/logic/domains/files/controller"; +import { + getFlowExecCtxForRemoteFuncs, + unauthorized, +} from "$lib/core/server.utils"; +import { getRequestEvent, query } from "$app/server"; +import * as v from "valibot"; + +const fc = getFileController(); + +const filesPaginationSchema = v.object({ + page: v.pipe(v.number(), v.integer()), + pageSize: v.pipe(v.number(), v.integer()), + sortBy: v.optional(v.string()), + sortOrder: v.optional(v.picklist(["asc", "desc"])), +}); + +const getFilesInputSchema = v.object({ + search: v.optional(v.string()), + mimeType: v.optional(v.string()), + status: v.optional(v.string()), + visibility: v.optional(v.string()), + pagination: filesPaginationSchema, +}); + +export const getFilesSQ = query(getFilesInputSchema, async (input) => { + const event = getRequestEvent(); + const fctx = await getFlowExecCtxForRemoteFuncs(event.locals); + if (!fctx.userId) { + return unauthorized(fctx); + } + + const res = await fc.getFiles( + fctx, + { + userId: fctx.userId, + search: input.search, + mimeType: input.mimeType, + status: input.status, + visibility: input.visibility, + }, + input.pagination, + ); + + return res.isOk() + ? { data: res.value, error: null } + : { data: null, error: res.error }; +}); diff --git a/apps/main/src/lib/domains/files/files.vm.svelte.ts b/apps/main/src/lib/domains/files/files.vm.svelte.ts new file mode 100644 index 0000000..b453b2c --- /dev/null +++ b/apps/main/src/lib/domains/files/files.vm.svelte.ts @@ -0,0 +1,60 @@ +import type { File } from "@pkg/logic/domains/files/data"; +import { getFilesSQ } from "./files.remote"; +import { toast } from "svelte-sonner"; + +class FilesViewModel { + files = $state([] as File[]); + loading = $state(false); + + search = $state(""); + page = $state(1); + pageSize = $state(25); + total = $state(0); + totalPages = $state(0); + sortBy = $state("createdAt"); + sortOrder = $state("desc" as "asc" | "desc"); + + async fetchFiles() { + this.loading = true; + try { + const result = await getFilesSQ({ + search: this.search || undefined, + pagination: { + page: this.page, + pageSize: this.pageSize, + sortBy: this.sortBy, + sortOrder: this.sortOrder, + }, + }); + + if (result?.error || !result?.data) { + toast.error(result?.error?.message || "Failed to load files", { + description: result?.error?.description || "Please try again later", + }); + return; + } + + this.files = result.data.data as File[]; + this.total = result.data.total; + this.totalPages = result.data.totalPages; + } catch (error) { + toast.error("Failed to load files", { + description: + error instanceof Error ? error.message : "Please try again later", + }); + } finally { + this.loading = false; + } + } + + formatSize(size: number) { + if (size < 1024) return `${size} B`; + if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`; + if (size < 1024 * 1024 * 1024) { + return `${(size / (1024 * 1024)).toFixed(1)} MB`; + } + return `${(size / (1024 * 1024 * 1024)).toFixed(1)} GB`; + } +} + +export const filesVM = new FilesViewModel(); diff --git a/apps/main/src/routes/(main)/files/+page.svelte b/apps/main/src/routes/(main)/files/+page.svelte new file mode 100644 index 0000000..cb9dfa6 --- /dev/null +++ b/apps/main/src/routes/(main)/files/+page.svelte @@ -0,0 +1,115 @@ + + + + + +
+
+ + Files + + {filesVM.total} total + +
+ +
+ +
+ + { + filesVM.page = 1; + void filesVM.fetchFiles(); + }} + /> +
+
+ + + {#if !filesVM.loading && filesVM.files.length === 0} +
+ No files found. +
+ {:else} + + + + File + MIME + Size + Status + Uploaded + R2 URL + + + + {#each filesVM.files as item (item.id)} + + +
{item.originalName}
+
+ {item.id} +
+
+ {item.mimeType} + {filesVM.formatSize(item.size)} + {item.status} + + {new Date(item.uploadedAt).toLocaleString()} + + + + Open + + +
+ {/each} +
+
+ {/if} +
+
+
diff --git a/apps/processor/src/domains/mobile/router.ts b/apps/processor/src/domains/mobile/router.ts index aafffef..c553c66 100644 --- a/apps/processor/src/domains/mobile/router.ts +++ b/apps/processor/src/domains/mobile/router.ts @@ -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; + error?: string; +} { + if (!value) { + return {}; + } + try { + const parsed = JSON.parse(value); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return { value: parsed as Record }; + } + 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, + }); }); diff --git a/packages/logic/domains/mobile/controller.ts b/packages/logic/domains/mobile/controller.ts index 45aa87c..8acb9c0 100644 --- a/packages/logic/domains/mobile/controller.ts +++ b/packages/logic/domains/mobile/controller.ts @@ -13,12 +13,14 @@ import { traceResultAsync } from "@core/observability"; import { MobileRepository } from "./repository"; import { settings } from "@core/settings"; import { mobileErrors } from "./errors"; -import { errAsync } from "neverthrow"; +import { getFileController, type FileController } from "@domains/files/controller"; +import { errAsync, okAsync } from "neverthrow"; import { db } from "@pkg/db"; export class MobileController { constructor( private mobileRepo: MobileRepository, + private fileController: FileController, private defaultAdminEmail?: string, ) {} @@ -201,11 +203,13 @@ export class MobileController { "app.mobile.media_asset_id": mediaAssetId, }, fn: () => - this.mobileRepo.deleteMediaAsset( - fctx, - mediaAssetId, - ownerUserId, - ), + this.mobileRepo + .deleteMediaAsset(fctx, mediaAssetId, ownerUserId) + .andThen((fileId) => + this.fileController + .deleteFiles(fctx, [fileId], ownerUserId) + .map(() => true), + ), }); } @@ -217,7 +221,31 @@ export class MobileController { "app.user.id": ownerUserId, "app.mobile.device_id": deviceId, }, - fn: () => this.mobileRepo.deleteDevice(fctx, deviceId, ownerUserId), + fn: () => + this.mobileRepo + .deleteDevice(fctx, deviceId, ownerUserId) + .andThen((result) => { + const cleanup = result.fileIds.length + ? this.fileController.deleteFiles( + fctx, + result.fileIds, + ownerUserId, + ) + : okAsync(true); + + return cleanup.andThen(() => + this.mobileRepo + .finalizeDeleteDevice( + fctx, + deviceId, + ownerUserId, + ) + .map(() => ({ + deleted: true, + deletedFileCount: result.fileIds.length, + })), + ); + }), }); } @@ -235,6 +263,7 @@ export class MobileController { export function getMobileController(): MobileController { return new MobileController( new MobileRepository(db), + getFileController(), settings.defaultAdminEmail || undefined, ); } diff --git a/packages/logic/domains/mobile/repository.ts b/packages/logic/domains/mobile/repository.ts index 3099f6d..6972e2f 100644 --- a/packages/logic/domains/mobile/repository.ts +++ b/packages/logic/domains/mobile/repository.ts @@ -5,11 +5,10 @@ import { count, desc, eq, - inArray, like, or, } from "@pkg/db"; -import { file, mobileDevice, mobileMediaAsset, mobileSMS, user } from "@pkg/db/schema"; +import { mobileDevice, mobileMediaAsset, mobileSMS, user } from "@pkg/db/schema"; import { ResultAsync, errAsync, okAsync } from "neverthrow"; import { FlowExecCtx } from "@core/flow.execution.context"; import type { @@ -676,7 +675,7 @@ export class MobileRepository { fctx: FlowExecCtx, mediaAssetId: number, ownerUserId: string, - ): ResultAsync { + ): ResultAsync { const startedAt = Date.now(); return ResultAsync.fromPromise( this.db @@ -706,27 +705,32 @@ export class MobileRepository { } return ResultAsync.fromPromise( - this.db.transaction(async (tx) => { - await tx - .delete(mobileMediaAsset) - .where(eq(mobileMediaAsset.id, mediaAssetId)); - await tx.delete(file).where(eq(file.id, target.fileId)); - return true; - }), + this.db + .delete(mobileMediaAsset) + .where(eq(mobileMediaAsset.id, mediaAssetId)) + .returning({ fileId: mobileMediaAsset.fileId }), (error) => mobileErrors.deleteMediaFailed( fctx, error instanceof Error ? error.message : String(error), ), - ); - }).map((deleted) => { + ).andThen((deletedRows) => { + const deleted = deletedRows[0]; + if (!deleted) { + return errAsync( + mobileErrors.mediaAssetNotFound(fctx, mediaAssetId), + ); + } + return okAsync(deleted.fileId); + }); + }).map((fileId) => { logDomainEvent({ event: "mobile.media.delete.succeeded", fctx, durationMs: Date.now() - startedAt, - meta: { mediaAssetId, ownerUserId, deleted }, + meta: { mediaAssetId, ownerUserId, fileId }, }); - return deleted; + return fileId; }); } @@ -734,7 +738,7 @@ export class MobileRepository { fctx: FlowExecCtx, deviceId: number, ownerUserId: string, - ): ResultAsync<{ deleted: boolean; deletedFileCount: number }, Err> { + ): ResultAsync<{ fileIds: string[] }, Err> { const startedAt = Date.now(); return ResultAsync.fromPromise( this.db @@ -758,35 +762,54 @@ export class MobileRepository { } return ResultAsync.fromPromise( - this.db.transaction(async (tx) => { - const mediaFiles = await tx - .select({ fileId: mobileMediaAsset.fileId }) - .from(mobileMediaAsset) - .where(eq(mobileMediaAsset.deviceId, deviceId)); - const fileIds = mediaFiles.map((item) => item.fileId); - - await tx.delete(mobileDevice).where(eq(mobileDevice.id, deviceId)); - - if (fileIds.length > 0) { - await tx.delete(file).where(inArray(file.id, fileIds)); - } - - return { deleted: true, deletedFileCount: fileIds.length }; - }), + this.db + .select({ fileId: mobileMediaAsset.fileId }) + .from(mobileMediaAsset) + .where(eq(mobileMediaAsset.deviceId, deviceId)), (error) => mobileErrors.deleteDeviceFailed( fctx, error instanceof Error ? error.message : String(error), ), - ); + ).map((rows) => ({ + fileIds: [...new Set(rows.map((item) => item.fileId))], + })); }).map((result) => { logDomainEvent({ - event: "mobile.device.delete.succeeded", + event: "mobile.device.delete.prepared", fctx, durationMs: Date.now() - startedAt, - meta: { deviceId, deletedFileCount: result.deletedFileCount }, + meta: { deviceId, deletedFileCount: result.fileIds.length }, }); return result; }); } + + finalizeDeleteDevice( + fctx: FlowExecCtx, + deviceId: number, + ownerUserId: string, + ): ResultAsync { + return ResultAsync.fromPromise( + this.db + .delete(mobileDevice) + .where( + and( + eq(mobileDevice.id, deviceId), + eq(mobileDevice.ownerUserId, ownerUserId), + ), + ) + .returning({ id: mobileDevice.id }), + (error) => + mobileErrors.deleteDeviceFailed( + fctx, + error instanceof Error ? error.message : String(error), + ), + ).andThen((rows) => { + if (!rows[0]) { + return errAsync(mobileErrors.deviceNotFoundById(fctx, deviceId)); + } + return okAsync(true); + }); + } } diff --git a/packages/logic/package.json b/packages/logic/package.json index 2744d43..02417a5 100644 --- a/packages/logic/package.json +++ b/packages/logic/package.json @@ -13,6 +13,7 @@ "@pkg/keystore": "workspace:*", "@pkg/logger": "workspace:*", "@pkg/result": "workspace:*", + "@pkg/objectstorage ": "workspace:*", "@pkg/settings": "workspace:*", "@types/pdfkit": "^0.14.0", "argon2": "^0.43.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f5ed070..7dc0110 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -340,6 +340,9 @@ importers: '@pkg/logger': specifier: workspace:* version: link:../logger + '@pkg/objectstorage': + specifier: workspace:* + version: link:../objectstorage '@pkg/result': specifier: workspace:* version: link:../result diff --git a/spec.mobile.md b/spec.mobile.md index e5fd69c..ea9d2d4 100644 --- a/spec.mobile.md +++ b/spec.mobile.md @@ -74,28 +74,28 @@ Payload: - Method: `PUT` - Path: `/api/v1/mobile/media/sync` - Required header: `x-device-id` +- Content-Type: `multipart/form-data` -Payload: +Upload contract: -```json -{ - "assets": [ - { - "externalMediaId": "media-1", - "fileId": "01JNE3Q1S3KQX9Y7G2J8G7R0A8", - "mimeType": "image/jpeg", - "filename": "IMG_1234.jpg", - "capturedAt": "2026-03-01T10:05:00.000Z", - "sizeBytes": 1350021, - "hash": "sha256-...", - "metadata": { - "width": 3024, - "height": 4032 - } - } - ] -} -``` +- One request uploads one raw media file. +- Multipart field `file` is required (binary). +- Optional multipart metadata fields: + - `externalMediaId` + - `mimeType` + - `filename` + - `capturedAt` (ISO date string) + - `sizeBytes` (number) + - `hash` + - `metadata` (JSON object string) +- Optional metadata headers (alternative to multipart fields): + - `x-media-external-id` + - `x-media-mime-type` + - `x-media-filename` + - `x-media-captured-at` + - `x-media-size-bytes` + - `x-media-hash` + - `x-media-metadata` (JSON object string) ## Response Contract @@ -144,8 +144,9 @@ Failure: - Dedup key #1: `(deviceId, externalMessageId)` when provided. - Dedup key #2 fallback: `(deviceId, dedupHash)` where dedup hash is SHA-256 of `(deviceId + sentAt + sender + body)`. - Media: + - Raw file is uploaded first and persisted in `file`. + - Then one `mobile_media_asset` row is created referencing uploaded `fileId`. - Dedup key: `(deviceId, externalMediaId)` when provided. - - `fileId` in `mobile_media_asset` is unique. ## Operator Checklist