add: delete actions for device + slightly better ui

This commit is contained in:
user
2026-03-01 18:59:43 +02:00
parent d000ccfeca
commit 48792692ff
11 changed files with 626 additions and 79 deletions

View File

@@ -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,

View File

@@ -381,6 +381,47 @@ export class FileRepository {
});
}
listReferencedObjectKeysForUser(
fctx: FlowExecCtx,
userId: string,
): ResultAsync<string[], Err> {
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<string>();
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<string, unknown>).thumbnailKey
: undefined;
if (typeof thumbnailKey === "string" && thumbnailKey.length > 0) {
keys.add(thumbnailKey);
}
}
return [...keys];
});
}
updateFileStatus(
fctx: FlowExecCtx,
fileId: string,

View File

@@ -284,4 +284,55 @@ export class StorageRepository {
return true;
});
}
listObjectKeys(
fctx: FlowExecCtx,
prefix?: string,
): ResultAsync<string[], Err> {
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);
});
}
}

View File

@@ -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<Result<string[]>> {
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,
),
};
}
}
}