proper asset creation/deletion, also raw file view in admin as well
This commit is contained in:
@@ -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 = [
|
||||||
|
|||||||
48
apps/main/src/lib/domains/files/files.remote.ts
Normal file
48
apps/main/src/lib/domains/files/files.remote.ts
Normal 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 };
|
||||||
|
});
|
||||||
60
apps/main/src/lib/domains/files/files.vm.svelte.ts
Normal file
60
apps/main/src/lib/domains/files/files.vm.svelte.ts
Normal 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();
|
||||||
115
apps/main/src/routes/(main)/files/+page.svelte
Normal file
115
apps/main/src/routes/(main)/files/+page.svelte
Normal 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>
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
3
pnpm-lock.yaml
generated
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user