From 48792692ff0ade9baa703620b97e267a4ff74aab Mon Sep 17 00:00:00 2001 From: user Date: Sun, 1 Mar 2026 18:59:43 +0200 Subject: [PATCH] add: delete actions for device + slightly better ui --- .../src/lib/domains/files/files.remote.ts | 32 ++- .../src/lib/domains/files/files.vm.svelte.ts | 51 +++- .../src/lib/domains/mobile/mobile.remote.ts | 20 +- .../lib/domains/mobile/mobile.vm.svelte.ts | 34 +++ .../src/routes/(main)/dashboard/+page.svelte | 88 +++++- .../(main)/devices/[deviceId]/+page.svelte | 266 +++++++++++++----- .../main/src/routes/(main)/files/+page.svelte | 13 + packages/logic/domains/files/controller.ts | 61 +++- packages/logic/domains/files/repository.ts | 41 +++ .../logic/domains/files/storage.repository.ts | 51 ++++ packages/objectstorage/src/client.ts | 48 ++++ 11 files changed, 626 insertions(+), 79 deletions(-) diff --git a/apps/main/src/lib/domains/files/files.remote.ts b/apps/main/src/lib/domains/files/files.remote.ts index 5cc1cc5..77f4074 100644 --- a/apps/main/src/lib/domains/files/files.remote.ts +++ b/apps/main/src/lib/domains/files/files.remote.ts @@ -3,7 +3,7 @@ import { getFlowExecCtxForRemoteFuncs, unauthorized, } from "$lib/core/server.utils"; -import { getRequestEvent, query } from "$app/server"; +import { command, getRequestEvent, query } from "$app/server"; import * as v from "valibot"; const fc = getFileController(); @@ -46,3 +46,33 @@ export const getFilesSQ = query(getFilesInputSchema, async (input) => { ? { data: res.value, error: null } : { data: null, error: res.error }; }); + +const deleteFileInputSchema = v.object({ + fileId: v.string(), +}); + +export const deleteFileSC = command(deleteFileInputSchema, async (input) => { + const event = getRequestEvent(); + const fctx = await getFlowExecCtxForRemoteFuncs(event.locals); + if (!fctx.userId) { + return unauthorized(fctx); + } + + const res = await fc.deleteFile(fctx, input.fileId, fctx.userId); + return res.isOk() + ? { data: res.value, error: null } + : { data: null, error: res.error }; +}); + +export const cleanupDanglingFilesSC = command(v.object({}), async () => { + const event = getRequestEvent(); + const fctx = await getFlowExecCtxForRemoteFuncs(event.locals); + if (!fctx.userId) { + return unauthorized(fctx); + } + + const res = await fc.cleanupDanglingStorageFiles(fctx, fctx.userId); + 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 index b453b2c..3f880ac 100644 --- a/apps/main/src/lib/domains/files/files.vm.svelte.ts +++ b/apps/main/src/lib/domains/files/files.vm.svelte.ts @@ -1,10 +1,12 @@ import type { File } from "@pkg/logic/domains/files/data"; -import { getFilesSQ } from "./files.remote"; +import { cleanupDanglingFilesSC, deleteFileSC, getFilesSQ } from "./files.remote"; import { toast } from "svelte-sonner"; class FilesViewModel { files = $state([] as File[]); loading = $state(false); + deletingFileId = $state(null as string | null); + cleanupLoading = $state(false); search = $state(""); page = $state(1); @@ -55,6 +57,53 @@ class FilesViewModel { } return `${(size / (1024 * 1024 * 1024)).toFixed(1)} GB`; } + + async deleteFile(fileId: string) { + this.deletingFileId = fileId; + try { + const result = await deleteFileSC({ fileId }); + if (result?.error) { + toast.error(result.error.message || "Failed to delete file", { + description: result.error.description || "Please try again later", + }); + return; + } + + toast.success("File deleted"); + await this.fetchFiles(); + } catch (error) { + toast.error("Failed to delete file", { + description: + error instanceof Error ? error.message : "Please try again later", + }); + } finally { + this.deletingFileId = null; + } + } + + async cleanupDanglingFiles() { + this.cleanupLoading = true; + try { + const result = await cleanupDanglingFilesSC({}); + if (result?.error || !result?.data) { + toast.error(result?.error?.message || "Cleanup failed", { + description: result?.error?.description || "Please try again later", + }); + return; + } + + toast.success("Storage cleanup completed", { + description: `Deleted ${result.data.deleted} dangling files (scanned ${result.data.scanned})`, + }); + } catch (error) { + toast.error("Cleanup failed", { + description: + error instanceof Error ? error.message : "Please try again later", + }); + } finally { + this.cleanupLoading = false; + } + } } export const filesVM = new FilesViewModel(); diff --git a/apps/main/src/lib/domains/mobile/mobile.remote.ts b/apps/main/src/lib/domains/mobile/mobile.remote.ts index 92e258a..4d27c02 100644 --- a/apps/main/src/lib/domains/mobile/mobile.remote.ts +++ b/apps/main/src/lib/domains/mobile/mobile.remote.ts @@ -8,7 +8,7 @@ import { getFlowExecCtxForRemoteFuncs, unauthorized, } from "$lib/core/server.utils"; -import { getRequestEvent, query } from "$app/server"; +import { command, getRequestEvent, query } from "$app/server"; import * as v from "valibot"; const mc = getMobileController(); @@ -109,3 +109,21 @@ export const getDeviceMediaSQ = query( : { data: null, error: res.error }; }, ); + +export const deleteDeviceSC = command( + v.object({ + deviceId: v.pipe(v.number(), v.integer()), + }), + async (payload) => { + const event = getRequestEvent(); + const fctx = await getFlowExecCtxForRemoteFuncs(event.locals); + if (!fctx.userId) { + return unauthorized(fctx); + } + + const res = await mc.deleteDevice(fctx, payload.deviceId, fctx.userId); + return res.isOk() + ? { data: res.value, error: null } + : { data: null, error: res.error }; + }, +); diff --git a/apps/main/src/lib/domains/mobile/mobile.vm.svelte.ts b/apps/main/src/lib/domains/mobile/mobile.vm.svelte.ts index 85c45d5..131a25a 100644 --- a/apps/main/src/lib/domains/mobile/mobile.vm.svelte.ts +++ b/apps/main/src/lib/domains/mobile/mobile.vm.svelte.ts @@ -5,6 +5,7 @@ import type { MobileSMS, } from "@pkg/logic/domains/mobile/data"; import { + deleteDeviceSC, getDeviceDetailSQ, getDeviceMediaSQ, getDeviceSmsSQ, @@ -34,6 +35,7 @@ class MobileViewModel { mediaPage = $state(1); mediaPageSize = $state(25); mediaTotal = $state(0); + deletingDeviceId = $state(null as number | null); private devicesPollTimer: ReturnType | null = null; private smsPollTimer: ReturnType | null = null; @@ -177,6 +179,38 @@ class MobileViewModel { } } + async deleteDevice(deviceId: number) { + this.deletingDeviceId = deviceId; + try { + const result = await deleteDeviceSC({ deviceId }); + if (result?.error || !result?.data) { + toast.error(result?.error?.message || "Failed to delete device", { + description: result?.error?.description || "Please try again later", + }); + return false; + } + + this.devices = this.devices.filter((d) => d.id !== deviceId); + this.devicesTotal = Math.max(0, this.devicesTotal - 1); + if (this.selectedDeviceDetail?.device.id === deviceId) { + this.selectedDeviceDetail = null; + } + + toast.success("Device deleted", { + description: `Deleted ${result.data.deletedFileCount} related file(s)`, + }); + return true; + } catch (error) { + toast.error("Failed to delete device", { + description: + error instanceof Error ? error.message : "Please try again later", + }); + return false; + } finally { + this.deletingDeviceId = null; + } + } + startDevicesPolling(intervalMs = 5000) { this.stopDevicesPolling(); this.devicesPollTimer = setInterval(() => { diff --git a/apps/main/src/routes/(main)/dashboard/+page.svelte b/apps/main/src/routes/(main)/dashboard/+page.svelte index c30ab5a..6d13b72 100644 --- a/apps/main/src/routes/(main)/dashboard/+page.svelte +++ b/apps/main/src/routes/(main)/dashboard/+page.svelte @@ -1,17 +1,21 @@
-
- +
+
+ +
+ {#if mobileVM.selectedDeviceDetail} + + + + Delete Device + + + + Delete device? + + This permanently removes the device with all SMS/media entries and + related files in storage. + + + + Cancel + { + if (!mobileVM.selectedDeviceDetail) return; + const deleted = await mobileVM.deleteDevice( + mobileVM.selectedDeviceDetail.device.id, + ); + if (deleted) { + await goto("/dashboard"); + } + }} + > + Delete Device + + + + + {/if}
@@ -83,38 +129,75 @@ {#if mobileVM.selectedDeviceDetail} -
-
- External ID: -
- {mobileVM.selectedDeviceDetail.device.externalDeviceId} +
+
+
+
+

+ {mobileVM.selectedDeviceDetail.device.name} +

+

+ {mobileVM.selectedDeviceDetail.device.externalDeviceId} +

+
+
+

Created

+

+ {new Date( + mobileVM.selectedDeviceDetail.device.createdAt, + ).toLocaleString()} +

+
+
+ +
+
+

Manufacturer

+

+ {mobileVM.selectedDeviceDetail.device.manufacturer} +

+
+
+

Model

+

+ {mobileVM.selectedDeviceDetail.device.model} +

+
+
+

Android Version

+

+ {mobileVM.selectedDeviceDetail.device.androidVersion} +

+
+
+

Last Ping

+

+ {mobileVM.formatLastPing( + mobileVM.selectedDeviceDetail.device.lastPingAt, + )} +

+
-
- Last Ping: -
- {mobileVM.formatLastPing( - mobileVM.selectedDeviceDetail.device.lastPingAt, - )} + +
+
+
+ +

Total SMS

+
+

+ {mobileVM.selectedDeviceDetail.smsCount} +

-
-
- Manufacturer: -
{mobileVM.selectedDeviceDetail.device.manufacturer}
-
-
- Model: -
{mobileVM.selectedDeviceDetail.device.model}
-
-
- Android: -
{mobileVM.selectedDeviceDetail.device.androidVersion}
-
-
- Counts: -
- SMS: {mobileVM.selectedDeviceDetail.smsCount}, Media: - {mobileVM.selectedDeviceDetail.mediaCount} +
+
+ +

Total Media

+
+

+ {mobileVM.selectedDeviceDetail.mediaCount} +

@@ -141,11 +224,15 @@ {#if !mobileVM.smsLoading && mobileVM.sms.length === 0} -
+
No SMS records yet.
{:else} -
+
@@ -158,15 +245,21 @@ {#each mobileVM.sms as message (message.id)} - {message.sender} + {message.sender} {message.recipient || "-"} - + {message.body} - {new Date(message.sentAt).toLocaleString()} + {new Date( + message.sentAt, + ).toLocaleString()} {/each} @@ -185,11 +278,15 @@ {#if !mobileVM.mediaLoading && mobileVM.media.length === 0} -
+
No media assets yet.
{:else} -
+
{#each mobileVM.media as asset (asset.id)} diff --git a/apps/main/src/routes/(main)/files/+page.svelte b/apps/main/src/routes/(main)/files/+page.svelte index cb9dfa6..e7db948 100644 --- a/apps/main/src/routes/(main)/files/+page.svelte +++ b/apps/main/src/routes/(main)/files/+page.svelte @@ -11,6 +11,7 @@ import FileArchive from "@lucide/svelte/icons/file-archive"; import RefreshCw from "@lucide/svelte/icons/refresh-cw"; import Search from "@lucide/svelte/icons/search"; + import Trash2 from "@lucide/svelte/icons/trash-2"; import { onMount } from "svelte"; const filesNavItem = mainNavTree.find((item) => item.url === "/files"); @@ -78,6 +79,7 @@ Status Uploaded R2 URL + Actions @@ -105,6 +107,17 @@ Open + + + {/each} diff --git a/packages/logic/domains/files/controller.ts b/packages/logic/domains/files/controller.ts index a41934c..ff8b0ff 100644 --- a/packages/logic/domains/files/controller.ts +++ b/packages/logic/domains/files/controller.ts @@ -10,7 +10,7 @@ import { FlowExecCtx } from "@core/flow.execution.context"; import { StorageRepository } from "./storage.repository"; import { FileRepository } from "./repository"; import { settings } from "@core/settings"; -import { ResultAsync } from "neverthrow"; +import { okAsync, ResultAsync } from "neverthrow"; import { traceResultAsync } from "@core/observability"; import { db } from "@pkg/db"; @@ -198,6 +198,65 @@ export class FileController { }); } + deleteFile(fctx: FlowExecCtx, fileId: string, userId: string) { + return traceResultAsync({ + name: "logic.files.controller.deleteFile", + fctx, + attributes: { "app.user.id": userId, "app.file.id": fileId }, + fn: () => this.deleteFiles(fctx, [fileId], userId), + }); + } + + cleanupDanglingStorageFiles(fctx: FlowExecCtx, userId: string) { + return traceResultAsync({ + name: "logic.files.controller.cleanupDanglingStorageFiles", + fctx, + attributes: { "app.user.id": userId }, + fn: () => + this.fileRepo + .listReferencedObjectKeysForUser(fctx, userId) + .andThen((referencedKeys) => { + const referencedSet = new Set(referencedKeys); + return ResultAsync.combine([ + this.storageRepo.listObjectKeys( + fctx, + `uploads/${userId}/`, + ), + this.storageRepo.listObjectKeys( + fctx, + `thumbnails/${userId}/`, + ), + ]).andThen(([uploadKeys, thumbnailKeys]) => { + const existingStorageKeys = [ + ...new Set([...uploadKeys, ...thumbnailKeys]), + ]; + + const danglingKeys = existingStorageKeys.filter( + (key) => !referencedSet.has(key), + ); + + if (danglingKeys.length === 0) { + return okAsync({ + scanned: existingStorageKeys.length, + referenced: referencedKeys.length, + dangling: 0, + deleted: 0, + }); + } + + return this.storageRepo + .deleteFiles(fctx, danglingKeys) + .map(() => ({ + scanned: existingStorageKeys.length, + referenced: referencedKeys.length, + dangling: danglingKeys.length, + deleted: danglingKeys.length, + })); + }); + }), + }); + } + shareFile( fctx: FlowExecCtx, fileId: string, diff --git a/packages/logic/domains/files/repository.ts b/packages/logic/domains/files/repository.ts index 416fded..1a8a37c 100644 --- a/packages/logic/domains/files/repository.ts +++ b/packages/logic/domains/files/repository.ts @@ -381,6 +381,47 @@ export class FileRepository { }); } + listReferencedObjectKeysForUser( + fctx: FlowExecCtx, + userId: string, + ): ResultAsync { + return ResultAsync.fromPromise( + this.db + .select({ + objectKey: file.objectKey, + metadata: file.metadata, + }) + .from(file) + .where(eq(file.userId, userId)), + (error) => + fileErrors.getFilesFailed( + fctx, + error instanceof Error ? error.message : String(error), + ), + ).map((rows) => { + const keys = new Set(); + + for (const row of rows) { + if (row.objectKey) { + keys.add(row.objectKey); + } + + const thumbnailKey = + row.metadata && + typeof row.metadata === "object" && + "thumbnailKey" in row.metadata + ? (row.metadata as Record).thumbnailKey + : undefined; + + if (typeof thumbnailKey === "string" && thumbnailKey.length > 0) { + keys.add(thumbnailKey); + } + } + + return [...keys]; + }); + } + updateFileStatus( fctx: FlowExecCtx, fileId: string, diff --git a/packages/logic/domains/files/storage.repository.ts b/packages/logic/domains/files/storage.repository.ts index ba5526a..0c38a8a 100644 --- a/packages/logic/domains/files/storage.repository.ts +++ b/packages/logic/domains/files/storage.repository.ts @@ -284,4 +284,55 @@ export class StorageRepository { return true; }); } + + listObjectKeys( + fctx: FlowExecCtx, + prefix?: string, + ): ResultAsync { + const startedAt = Date.now(); + logDomainEvent({ + event: "files.storage.list.started", + fctx, + meta: { prefix: prefix || null }, + }); + + return ResultAsync.fromPromise( + this.storageClient.listObjectKeys(prefix), + (error) => { + logDomainEvent({ + level: "error", + event: "files.storage.list.failed", + fctx, + durationMs: Date.now() - startedAt, + error, + meta: { prefix: prefix || null }, + }); + return fileErrors.storageError( + fctx, + error instanceof Error ? error.message : String(error), + ); + }, + ).andThen((result) => { + if (result.error) { + logDomainEvent({ + level: "error", + event: "files.storage.list.failed", + fctx, + durationMs: Date.now() - startedAt, + error: result.error, + meta: { prefix: prefix || null, stage: "storage_response" }, + }); + return errAsync(fileErrors.storageError(fctx, String(result.error))); + } + + const keys = result.data || []; + logDomainEvent({ + event: "files.storage.list.succeeded", + fctx, + durationMs: Date.now() - startedAt, + meta: { prefix: prefix || null, count: keys.length }, + }); + return okAsync(keys); + }); + } } diff --git a/packages/objectstorage/src/client.ts b/packages/objectstorage/src/client.ts index 8a55039..d3af434 100644 --- a/packages/objectstorage/src/client.ts +++ b/packages/objectstorage/src/client.ts @@ -3,6 +3,7 @@ import { DeleteObjectCommand, GetObjectCommand, HeadObjectCommand, + ListObjectsV2Command, PutObjectCommand, S3Client, } from "@aws-sdk/client-s3"; @@ -481,4 +482,51 @@ export class R2StorageClient { }; } } + + /** + * List object keys in R2 bucket, optionally filtered by prefix + */ + async listObjectKeys(prefix?: string): Promise> { + try { + const keys: string[] = []; + let continuationToken: string | undefined = undefined; + + do { + const command = new ListObjectsV2Command({ + Bucket: this.config.bucketName, + Prefix: prefix, + ContinuationToken: continuationToken, + }); + + const response = await this.s3Client.send(command); + const pageKeys = + response.Contents?.map((item) => item.Key).filter( + (key): key is string => Boolean(key), + ) || []; + keys.push(...pageKeys); + + continuationToken = response.IsTruncated + ? response.NextContinuationToken + : undefined; + } while (continuationToken); + + logger.info( + `Listed ${keys.length} objects${prefix ? ` with prefix ${prefix}` : ""}`, + ); + return { data: keys }; + } catch (error) { + logger.error("Failed to list object keys:", error); + return { + error: getError( + { + code: ERROR_CODES.STORAGE_ERROR, + message: "Failed to list objects", + description: "Could not list objects in storage", + detail: "S3 list operation failed", + }, + error, + ), + }; + } + } }