fix(files): actually delete physical files when removing event_file or hosted_file

event_file delete was broken since the module was first created (Oct 2024).
delete_ae_obj_id__event_file passed delete_hosted_file=true + rm_orphan=true to
the generic V3 CRUD endpoint, but delete_obj_template never handled those params —
it only did a raw sql_delete on the event_file row. The hosted_file_link record,
hosted_file DB record, and physical file on disk were never cleaned up.

Fix: call api.delete_hosted_file (the action endpoint) BEFORE deleting the
event_file record so the backend can still resolve link_to_id via Redis.
Pass link_to_type='event_file', link_to_id=event_file_id, rm_orphan=true,
method=delete. hosted_file_id is now an optional param on
delete_ae_obj_id__event_file; component passes event_file_obj.hosted_file_id.

Also fix hosted_file delete in /core/files/ admin page (same root cause):
load links first, then call delete_hosted_file for each link with correct
link_to_type/link_to_id_random and method=delete before removing the record.

Also: add clickable navigation links in the files admin link sub-row.
Direct types (event, journal, archive, post) resolve immediately; nested types
(event_session, event_location, event_presenter, journal_entry, etc.) fetch
their parent ID via the V3 CRUD endpoint and construct the correct route.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-06-18 17:47:10 -04:00
parent 015a38fd14
commit 94b3dd84af
3 changed files with 102 additions and 9 deletions

View File

@@ -365,21 +365,39 @@ export async function create_event_file_obj_from_hosted_file_async({
export async function delete_ae_obj_id__event_file({
api_cfg,
event_file_id,
hosted_file_id,
params = {},
try_cache = true,
log_lvl = 0
}: {
api_cfg: any;
event_file_id: string;
// Providing hosted_file_id enables full cleanup: removes the hosted_file_link,
// then deletes the hosted_file record and physical file if no other links remain.
// Must be done BEFORE deleting the event_file record so the backend can still
// resolve the link_to_id via Redis.
hosted_file_id?: string;
params?: key_val;
try_cache?: boolean;
log_lvl?: number;
}) {
if (hosted_file_id) {
await api.delete_hosted_file({
api_cfg,
hosted_file_id,
link_to_type: 'event_file',
link_to_id: event_file_id,
rm_orphan: true,
params: { method: 'delete' },
log_lvl
});
}
const result = await api.delete_ae_obj({
api_cfg,
obj_type: 'event_file',
obj_id: event_file_id,
params: { ...params, delete_hosted_file: true, rm_orphan: true },
params,
log_lvl
});
if (try_cache) await db_events.file.delete(event_file_id);

View File

@@ -570,6 +570,8 @@ async function handle_convert_pdf_to_image(event_file_obj: key_val) {
api_cfg: $ae_api,
event_file_id:
event_file_obj.event_file_id,
hosted_file_id:
event_file_obj.hosted_file_id,
log_lvl: 1
}
);

View File

@@ -235,6 +235,9 @@ async function copy_hash(hash: string) {
// Keyed by hosted_file_id. Value: null = not loaded, [] = loaded/empty, [...] = loaded with links.
let links_map = new SvelteMap<string, { link_to_type: string; link_to_id: number; link_to_id_random: string | null }[] | null>();
let links_loading = new SvelteMap<string, boolean>();
// Resolved navigation URLs per link — keyed as `${link_to_type}:${link_to_id_random}`.
// Null means no routable page exists for that type.
let link_url_map = new SvelteMap<string, string | null>();
async function toggle_links(file: ae_HostedFile) {
const id = file.hosted_file_id;
@@ -249,7 +252,10 @@ async function toggle_links(file: ae_HostedFile) {
endpoint: `/v3/action/hosted_file/${id}/links`,
log_lvl: 0
});
links_map.set(id, result?.data ?? result ?? []);
const file_links = result?.data ?? result ?? [];
links_map.set(id, file_links);
// Resolve navigation URLs for each link in parallel.
await Promise.all(file_links.map((lnk: any) => resolve_link_url(lnk.link_to_type, lnk.link_to_id_random)));
} catch (e) {
console.error('[hosted_file links]', e);
links_map.set(id, []);
@@ -258,6 +264,58 @@ async function toggle_links(file: ae_HostedFile) {
}
}
// Resolves a link record to a navigable app URL. Caches results to avoid repeat calls.
// For nested types (session, presenter, location, journal_entry, etc.) we fetch the
// object to get its parent ID, then construct the correct route.
async function resolve_link_url(link_to_type: string, id_random: string | null): Promise<void> {
if (!id_random) return;
const cache_key = `${link_to_type}:${id_random}`;
if (link_url_map.has(cache_key)) return;
// Direct types — no parent lookup needed.
const direct: Record<string, string> = {
event: `/events/${id_random}`,
journal: `/journals/${id_random}`,
archive: `/idaa/archives/${id_random}`,
post: `/idaa/bb/${id_random}`
};
if (direct[link_to_type]) {
link_url_map.set(cache_key, direct[link_to_type]);
return;
}
// Nested types — look up parent ID from the object, then build URL.
try {
const result = await api.get_object({
api_cfg: $ae_api,
endpoint: `/v3/crud/${link_to_type}/${id_random}`,
log_lvl: 0
});
const obj = result?.data ?? result;
let url: string | null = null;
if (link_to_type === 'event_session' && obj?.event_id) {
url = `/events/${obj.event_id}/session/${id_random}`;
} else if (link_to_type === 'event_location' && obj?.event_id) {
url = `/events/${obj.event_id}/location/${id_random}`;
} else if (link_to_type === 'event_presenter' && obj?.event_id) {
url = `/events/${obj.event_id}/presenter/${id_random}`;
} else if (link_to_type === 'journal_entry' && obj?.journal_id) {
url = `/journals/${obj.journal_id}/entry/${id_random}`;
} else if (link_to_type === 'archive_content' && obj?.archive_id) {
// No dedicated content detail page — link to the parent archive.
url = `/idaa/archives/${obj.archive_id}`;
} else if (link_to_type === 'post_comment' && obj?.post_id) {
// No dedicated comment detail page — link to the parent post.
url = `/idaa/bb/${obj.post_id}`;
}
link_url_map.set(cache_key, url);
} catch {
link_url_map.set(cache_key, null);
}
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function fmt_size(bytes: number | undefined): string {
if (!bytes) return '—';
@@ -587,14 +645,29 @@ let total_size = $derived(results.reduce((sum, f) => sum + (f.size ?? 0), 0));
<div class="flex flex-wrap items-center gap-1.5">
<span class="text-[10px] font-bold uppercase tracking-wider opacity-40">Linked to:</span>
{#each file_links as lnk}
<span
class="badge preset-tonal-secondary font-mono text-[10px]"
title={lnk.link_to_id_random ?? String(lnk.link_to_id)}>
{lnk.link_to_type}
<span class="opacity-60 ml-0.5">
/ {lnk.link_to_id_random ? lnk.link_to_id_random.slice(0, 8) + '…' : lnk.link_to_id}
{@const link_key = `${lnk.link_to_type}:${lnk.link_to_id_random}`}
{@const link_url = link_url_map.get(link_key)}
{@const id_display = lnk.link_to_id_random ? lnk.link_to_id_random.slice(0, 8) + '…' : String(lnk.link_to_id)}
{@const full_id = lnk.link_to_id_random ?? String(lnk.link_to_id)}
{#if link_url}
<a
href={link_url}
target="_blank"
rel="noopener noreferrer"
class="badge preset-tonal-tertiary font-mono text-[10px] hover:preset-filled-tertiary transition-all"
title={`Open ${lnk.link_to_type} ${full_id}`}>
{lnk.link_to_type}
<span class="opacity-60 ml-0.5">/ {id_display}</span>
<span class="ml-0.5 opacity-40"></span>
</a>
{:else}
<span
class="badge preset-tonal-secondary font-mono text-[10px]"
title={full_id}>
{lnk.link_to_type}
<span class="opacity-60 ml-0.5">/ {id_display}</span>
</span>
</span>
{/if}
{/each}
</div>
{/if}