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,4 +1,5 @@
import LayoutDashboard from "@lucide/svelte/icons/layout-dashboard"; import LayoutDashboard from "@lucide/svelte/icons/layout-dashboard";
import FileArchive from "@lucide/svelte/icons/file-archive";
import Smartphone from "@lucide/svelte/icons/smartphone"; import Smartphone from "@lucide/svelte/icons/smartphone";
import { BellRingIcon, UsersIcon } from "@lucide/svelte"; import { BellRingIcon, UsersIcon } from "@lucide/svelte";
import UserCircle from "~icons/lucide/user-circle"; import UserCircle from "~icons/lucide/user-circle";
@@ -31,6 +32,11 @@ export const mainNavTree = [
url: "/devices", url: "/devices",
icon: Smartphone, icon: Smartphone,
}, },
{
title: "Files",
url: "/files",
icon: FileArchive,
},
] as AppSidebarItem[]; ] as AppSidebarItem[];
export const secondaryNavTree = [ export const secondaryNavTree = [

View File

@@ -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 };
});

View File

@@ -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();

View File

@@ -0,0 +1,115 @@
<script lang="ts">
import Icon from "$lib/components/atoms/icon.svelte";
import MaxWidthWrapper from "$lib/components/molecules/max-width-wrapper.svelte";
import { Button } from "$lib/components/ui/button";
import * as Card from "$lib/components/ui/card";
import { Input } from "$lib/components/ui/input";
import * as Table from "$lib/components/ui/table";
import { mainNavTree } from "$lib/core/constants";
import { filesVM } from "$lib/domains/files/files.vm.svelte";
import { breadcrumbs } from "$lib/global.stores";
import FileArchive from "@lucide/svelte/icons/file-archive";
import RefreshCw from "@lucide/svelte/icons/refresh-cw";
import Search from "@lucide/svelte/icons/search";
import { onMount } from "svelte";
const filesNavItem = mainNavTree.find((item) => item.url === "/files");
breadcrumbs.set(filesNavItem ? [filesNavItem] : [{ title: "Files", url: "/files" }]);
onMount(async () => {
await filesVM.fetchFiles();
});
</script>
<MaxWidthWrapper cls="space-y-4">
<Card.Root>
<Card.Header>
<div class="flex items-center justify-between gap-4">
<div class="flex items-center gap-2">
<Icon icon={FileArchive} cls="h-5 w-5 text-primary" />
<Card.Title>Files</Card.Title>
<span class="text-muted-foreground text-xs">
{filesVM.total} total
</span>
</div>
<Button
variant="outline"
size="sm"
onclick={() => void filesVM.fetchFiles()}
disabled={filesVM.loading}
>
<Icon
icon={RefreshCw}
cls={`h-4 w-4 mr-2 ${filesVM.loading ? "animate-spin" : ""}`}
/>
Refresh
</Button>
</div>
<div class="relative mt-3 max-w-sm">
<Icon
icon={Search}
cls="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
/>
<Input
class="pl-10"
placeholder="Search by filename..."
bind:value={filesVM.search}
oninput={() => {
filesVM.page = 1;
void filesVM.fetchFiles();
}}
/>
</div>
</Card.Header>
<Card.Content>
{#if !filesVM.loading && filesVM.files.length === 0}
<div class="py-10 text-center text-sm text-muted-foreground">
No files found.
</div>
{:else}
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>File</Table.Head>
<Table.Head>MIME</Table.Head>
<Table.Head>Size</Table.Head>
<Table.Head>Status</Table.Head>
<Table.Head>Uploaded</Table.Head>
<Table.Head>R2 URL</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each filesVM.files as item (item.id)}
<Table.Row>
<Table.Cell>
<div class="font-medium">{item.originalName}</div>
<div class="font-mono text-xs text-muted-foreground">
{item.id}
</div>
</Table.Cell>
<Table.Cell>{item.mimeType}</Table.Cell>
<Table.Cell>{filesVM.formatSize(item.size)}</Table.Cell>
<Table.Cell>{item.status}</Table.Cell>
<Table.Cell>
{new Date(item.uploadedAt).toLocaleString()}
</Table.Cell>
<Table.Cell>
<a
href={item.r2Url}
target="_blank"
rel="noreferrer"
class="text-xs text-primary underline"
>
Open
</a>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
{/if}
</Card.Content>
</Card.Root>
</MaxWidthWrapper>

View File

@@ -1,9 +1,10 @@
import { import {
mobileMediaAssetInputSchema,
pingMobileDeviceSchema, pingMobileDeviceSchema,
registerMobileDeviceSchema, registerMobileDeviceSchema,
syncMobileMediaSchema,
syncMobileSMSSchema, syncMobileSMSSchema,
} from "@pkg/logic/domains/mobile/data"; } from "@pkg/logic/domains/mobile/data";
import { getFileController } from "@pkg/logic/domains/files/controller";
import { getMobileController } from "@pkg/logic/domains/mobile/controller"; import { getMobileController } from "@pkg/logic/domains/mobile/controller";
import type { FlowExecCtx } from "@pkg/logic/core/flow.execution.context"; import type { FlowExecCtx } from "@pkg/logic/core/flow.execution.context";
import { errorStatusMap, type Err } from "@pkg/result"; import { errorStatusMap, type Err } from "@pkg/result";
@@ -13,8 +14,16 @@ import * as v from "valibot";
import { Hono } from "hono"; import { Hono } from "hono";
const mobileController = getMobileController(); const mobileController = getMobileController();
const fileController = getFileController();
const DEVICE_ID_HEADER = "x-device-id"; const DEVICE_ID_HEADER = "x-device-id";
const FLOW_ID_HEADER = "x-flow-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 { function buildFlowExecCtx(c: Context): FlowExecCtx {
return { 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() export const mobileRouter = new Hono()
.post("/register", async (c) => { .post("/register", async (c) => {
const fctx = buildFlowExecCtx(c); const fctx = buildFlowExecCtx(c);
@@ -220,25 +257,150 @@ export const mobileRouter = new Hono()
return respondError(c, fctx, error); return respondError(c, fctx, error);
} }
const payload = await parseJson(c); const form = await c.req.formData().catch(() => null);
const parsed = v.safeParse(syncMobileMediaSchema, payload); 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) { if (!parsed.success) {
const error = { const error = {
flowId: fctx.flowId, flowId: fctx.flowId,
code: "VALIDATION_ERROR", code: "VALIDATION_ERROR",
message: "Invalid media sync payload", message: "Invalid media upload metadata",
description: "Please validate the payload and retry", description: "Please validate multipart metadata and retry",
detail: parsed.issues.map((issue) => issue.message).join(", "), detail: parsed.issues.map((issue) => issue.message).join(", "),
} as Err; } as Err;
return respondError(c, fctx, error); 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( const result = await mobileController.syncMediaByExternalDeviceId(
fctx, fctx,
externalDeviceId, externalDeviceId,
parsed.output.assets, [{ ...parsed.output, fileId: uploadedFileId }],
); );
if (result.isErr()) { if (result.isErr()) {
await fileController.deleteFiles(
fctx,
[uploadedFileId],
deviceResult.value.ownerUserId,
);
logDomainEvent({ logDomainEvent({
level: "error", level: "error",
event: "processor.mobile.media_sync.failed", event: "processor.mobile.media_sync.failed",
@@ -247,7 +409,7 @@ export const mobileRouter = new Hono()
error: result.error, error: result.error,
meta: { meta: {
externalDeviceId, externalDeviceId,
received: parsed.output.assets.length, received: 1,
}, },
}); });
return respondError(c, fctx, result.error); return respondError(c, fctx, result.error);
@@ -259,8 +421,15 @@ export const mobileRouter = new Hono()
durationMs: Date.now() - startedAt, durationMs: Date.now() - startedAt,
meta: { meta: {
externalDeviceId, externalDeviceId,
fileId: uploadedFileId,
...result.value, ...result.value,
}, },
}); });
return c.json({ data: result.value, error: null }); return c.json({
data: {
...result.value,
fileId: uploadedFileId,
},
error: null,
});
}); });

View File

@@ -13,12 +13,14 @@ import { traceResultAsync } from "@core/observability";
import { MobileRepository } from "./repository"; import { MobileRepository } from "./repository";
import { settings } from "@core/settings"; import { settings } from "@core/settings";
import { mobileErrors } from "./errors"; 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"; import { db } from "@pkg/db";
export class MobileController { export class MobileController {
constructor( constructor(
private mobileRepo: MobileRepository, private mobileRepo: MobileRepository,
private fileController: FileController,
private defaultAdminEmail?: string, private defaultAdminEmail?: string,
) {} ) {}
@@ -201,11 +203,13 @@ export class MobileController {
"app.mobile.media_asset_id": mediaAssetId, "app.mobile.media_asset_id": mediaAssetId,
}, },
fn: () => fn: () =>
this.mobileRepo.deleteMediaAsset( this.mobileRepo
fctx, .deleteMediaAsset(fctx, mediaAssetId, ownerUserId)
mediaAssetId, .andThen((fileId) =>
ownerUserId, this.fileController
), .deleteFiles(fctx, [fileId], ownerUserId)
.map(() => true),
),
}); });
} }
@@ -217,7 +221,31 @@ export class MobileController {
"app.user.id": ownerUserId, "app.user.id": ownerUserId,
"app.mobile.device_id": deviceId, "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 { export function getMobileController(): MobileController {
return new MobileController( return new MobileController(
new MobileRepository(db), new MobileRepository(db),
getFileController(),
settings.defaultAdminEmail || undefined, settings.defaultAdminEmail || undefined,
); );
} }

View File

@@ -5,11 +5,10 @@ import {
count, count,
desc, desc,
eq, eq,
inArray,
like, like,
or, or,
} from "@pkg/db"; } 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 { ResultAsync, errAsync, okAsync } from "neverthrow";
import { FlowExecCtx } from "@core/flow.execution.context"; import { FlowExecCtx } from "@core/flow.execution.context";
import type { import type {
@@ -676,7 +675,7 @@ export class MobileRepository {
fctx: FlowExecCtx, fctx: FlowExecCtx,
mediaAssetId: number, mediaAssetId: number,
ownerUserId: string, ownerUserId: string,
): ResultAsync<boolean, Err> { ): ResultAsync<string, Err> {
const startedAt = Date.now(); const startedAt = Date.now();
return ResultAsync.fromPromise( return ResultAsync.fromPromise(
this.db this.db
@@ -706,27 +705,32 @@ export class MobileRepository {
} }
return ResultAsync.fromPromise( return ResultAsync.fromPromise(
this.db.transaction(async (tx) => { this.db
await tx .delete(mobileMediaAsset)
.delete(mobileMediaAsset) .where(eq(mobileMediaAsset.id, mediaAssetId))
.where(eq(mobileMediaAsset.id, mediaAssetId)); .returning({ fileId: mobileMediaAsset.fileId }),
await tx.delete(file).where(eq(file.id, target.fileId));
return true;
}),
(error) => (error) =>
mobileErrors.deleteMediaFailed( mobileErrors.deleteMediaFailed(
fctx, fctx,
error instanceof Error ? error.message : String(error), error instanceof Error ? error.message : String(error),
), ),
); ).andThen((deletedRows) => {
}).map((deleted) => { const deleted = deletedRows[0];
if (!deleted) {
return errAsync(
mobileErrors.mediaAssetNotFound(fctx, mediaAssetId),
);
}
return okAsync(deleted.fileId);
});
}).map((fileId) => {
logDomainEvent({ logDomainEvent({
event: "mobile.media.delete.succeeded", event: "mobile.media.delete.succeeded",
fctx, fctx,
durationMs: Date.now() - startedAt, 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, fctx: FlowExecCtx,
deviceId: number, deviceId: number,
ownerUserId: string, ownerUserId: string,
): ResultAsync<{ deleted: boolean; deletedFileCount: number }, Err> { ): ResultAsync<{ fileIds: string[] }, Err> {
const startedAt = Date.now(); const startedAt = Date.now();
return ResultAsync.fromPromise( return ResultAsync.fromPromise(
this.db this.db
@@ -758,35 +762,54 @@ export class MobileRepository {
} }
return ResultAsync.fromPromise( return ResultAsync.fromPromise(
this.db.transaction(async (tx) => { this.db
const mediaFiles = await tx .select({ fileId: mobileMediaAsset.fileId })
.select({ fileId: mobileMediaAsset.fileId }) .from(mobileMediaAsset)
.from(mobileMediaAsset) .where(eq(mobileMediaAsset.deviceId, deviceId)),
.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 };
}),
(error) => (error) =>
mobileErrors.deleteDeviceFailed( mobileErrors.deleteDeviceFailed(
fctx, fctx,
error instanceof Error ? error.message : String(error), error instanceof Error ? error.message : String(error),
), ),
); ).map((rows) => ({
fileIds: [...new Set(rows.map((item) => item.fileId))],
}));
}).map((result) => { }).map((result) => {
logDomainEvent({ logDomainEvent({
event: "mobile.device.delete.succeeded", event: "mobile.device.delete.prepared",
fctx, fctx,
durationMs: Date.now() - startedAt, durationMs: Date.now() - startedAt,
meta: { deviceId, deletedFileCount: result.deletedFileCount }, meta: { deviceId, deletedFileCount: result.fileIds.length },
}); });
return result; return result;
}); });
} }
finalizeDeleteDevice(
fctx: FlowExecCtx,
deviceId: number,
ownerUserId: string,
): ResultAsync<boolean, Err> {
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);
});
}
} }

View File

@@ -13,6 +13,7 @@
"@pkg/keystore": "workspace:*", "@pkg/keystore": "workspace:*",
"@pkg/logger": "workspace:*", "@pkg/logger": "workspace:*",
"@pkg/result": "workspace:*", "@pkg/result": "workspace:*",
"@pkg/objectstorage ": "workspace:*",
"@pkg/settings": "workspace:*", "@pkg/settings": "workspace:*",
"@types/pdfkit": "^0.14.0", "@types/pdfkit": "^0.14.0",
"argon2": "^0.43.0", "argon2": "^0.43.0",

3
pnpm-lock.yaml generated
View File

@@ -340,6 +340,9 @@ importers:
'@pkg/logger': '@pkg/logger':
specifier: workspace:* specifier: workspace:*
version: link:../logger version: link:../logger
'@pkg/objectstorage':
specifier: workspace:*
version: link:../objectstorage
'@pkg/result': '@pkg/result':
specifier: workspace:* specifier: workspace:*
version: link:../result version: link:../result

View File

@@ -74,28 +74,28 @@ Payload:
- Method: `PUT` - Method: `PUT`
- Path: `/api/v1/mobile/media/sync` - Path: `/api/v1/mobile/media/sync`
- Required header: `x-device-id` - Required header: `x-device-id`
- Content-Type: `multipart/form-data`
Payload: Upload contract:
```json - One request uploads one raw media file.
{ - Multipart field `file` is required (binary).
"assets": [ - Optional multipart metadata fields:
{ - `externalMediaId`
"externalMediaId": "media-1", - `mimeType`
"fileId": "01JNE3Q1S3KQX9Y7G2J8G7R0A8", - `filename`
"mimeType": "image/jpeg", - `capturedAt` (ISO date string)
"filename": "IMG_1234.jpg", - `sizeBytes` (number)
"capturedAt": "2026-03-01T10:05:00.000Z", - `hash`
"sizeBytes": 1350021, - `metadata` (JSON object string)
"hash": "sha256-...", - Optional metadata headers (alternative to multipart fields):
"metadata": { - `x-media-external-id`
"width": 3024, - `x-media-mime-type`
"height": 4032 - `x-media-filename`
} - `x-media-captured-at`
} - `x-media-size-bytes`
] - `x-media-hash`
} - `x-media-metadata` (JSON object string)
```
## Response Contract ## Response Contract
@@ -144,8 +144,9 @@ Failure:
- Dedup key #1: `(deviceId, externalMessageId)` when provided. - Dedup key #1: `(deviceId, externalMessageId)` when provided.
- Dedup key #2 fallback: `(deviceId, dedupHash)` where dedup hash is SHA-256 of `(deviceId + sentAt + sender + body)`. - Dedup key #2 fallback: `(deviceId, dedupHash)` where dedup hash is SHA-256 of `(deviceId + sentAt + sender + body)`.
- Media: - 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. - Dedup key: `(deviceId, externalMediaId)` when provided.
- `fileId` in `mobile_media_asset` is unique.
## Operator Checklist ## Operator Checklist