From 6b122a065e487211625ad6cde0a8baa6d5ee6d7c Mon Sep 17 00:00:00 2001 From: Scott Idem Date: Wed, 10 Jun 2026 14:19:25 -0400 Subject: [PATCH] feat(pres_mgmt): File Downloads report with clean filename presets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New report at Pres Mgmt > Reports > File Downloads. Groups all event files by session and presenter, with 10 filename format presets (original, session code/date/name combos, presenter variants). Per-file Download button and Copy Link button using hosted_file endpoint for ?key= auth support. Also fixes direct download links in element_manage_event_file_li and the hosted_files download button — event_file endpoint does not yet propagate ?key= auth internally, so all direct links now use hosted_file endpoint which supports it today. Co-Authored-By: Claude Sonnet 4.6 --- .../GUIDE__AE_API_V3_for_Frontend.md | 85 ++- ..._comp__hosted_files_download_button.svelte | 11 +- .../element_manage_event_file_li.svelte | 6 +- .../ae_events_stores__pres_mgmt_defaults.ts | 4 +- .../(pres_mgmt)/reports/+page.svelte | 34 +- .../reports/reports_file_downloads.svelte | 482 ++++++++++++++++++ 6 files changed, 597 insertions(+), 25 deletions(-) create mode 100644 src/routes/events/[event_id]/(pres_mgmt)/reports/reports_file_downloads.svelte diff --git a/documentation/GUIDE__AE_API_V3_for_Frontend.md b/documentation/GUIDE__AE_API_V3_for_Frontend.md index fece6e29..8ac942cd 100644 --- a/documentation/GUIDE__AE_API_V3_for_Frontend.md +++ b/documentation/GUIDE__AE_API_V3_for_Frontend.md @@ -96,6 +96,26 @@ The primary way to retrieve data. * **Endpoint:** `POST /v3/crud/{obj_type}/search` * **Security:** Automatically filters results to only show records belonging to your `x-account-id`. If no account context is provided, it will return **0 records** for private objects. +#### Sorting with `order_by_li` + +Pass a JSON object as the `order_by_li` query parameter to sort results: + +```ts +// ?order_by_li={"filename":"ASC","created_on":"DESC"} +const params = new URLSearchParams({ + order_by_li: JSON.stringify({ filename: 'ASC', created_on: 'DESC' }) +}); +``` + +> [!IMPORTANT] +> **`order_by_li` only accepts columns from the raw base table** — not view-only join columns. +> +> Some object types (e.g. `event_file`) have enriched views that JOIN other tables to expose convenience fields like `event_presenter_family_name`. These are available in search results when using `?view=alt`, but they **cannot** be used in `order_by_li`. Attempting to sort by them silently drops those sort keys (the query proceeds without them). +> +> If you need to sort by a joined field, sort client-side on the returned list. +> +> **Columns safe to sort on for `event_file`:** any field in the `event_file` table itself — `filename`, `title`, `extension`, `created_on`, `updated_on`, `sort`, `enable`, etc. + ### C. POST Create / PATCH Update Modify data in the system. * **Endpoints:** @@ -281,6 +301,47 @@ Every Event File (`event_file`) **must** have a linked Hosted File (`hosted_file * `hosted_file_size` (string - in bytes) 2. **Nested Hosted File Object:** A full `hosted_file` object will be nested under the `hosted_file` key. This object (`Hosted_File_Base` model) will contain all its standard fields, including `id` (random string ID), `hash_sha256`, `content_type`, `size`, etc. +### Direct Download Links (Shareable / External) + +Event files can be downloaded without standard auth headers using one of two bypass mechanisms. This is useful for generating shareable links for staff or external recipients. + +- **Method:** `GET` +- **Path:** `/v3/action/event_file/{event_file_id}/download` + +The endpoint also accepts `hosted_file_id` or `archive_content_id` directly — it resolves the chain automatically. + +#### Auth bypass options + +| Query param | Value | When to use | +|---|---|---| +| `?key=` | Any valid account random ID | Staff sharing within a known account context | +| `?site_key=` | The site's `access_key` value | Public or semi-public distribution tied to a specific site | + +Either param replaces the need for `x-aether-api-key` / `x-account-id` headers, so the URL is self-contained and works in a plain browser tab or `` link. + +#### Optional params + +| Query param | Description | +|---|---| +| `filename` | Override the download filename (min 4 chars). Useful for giving files clean display names. | + +#### Building a shareable link + +```ts +// Build a self-contained download URL for staff/external use +function makeDownloadUrl(eventFileId: string, accountId: string, displayName?: string): string { + const base = `https://dev-api.oneskyit.com/v3/action/event_file/${eventFileId}/download`; + const params = new URLSearchParams({ key: accountId }); + if (displayName) params.set('filename', displayName); + return `${base}?${params}`; +} +``` + +The endpoint supports byte-range requests (`Range` header), so it works correctly for in-browser media streaming as well as direct file downloads. + +> [!NOTE] +> The `?key=` bypass verifies only that the account ID exists — it does not confirm the file belongs to that account. It is appropriate for internal staff tools. For publicly distributed links, prefer `?site_key=` which ties access to a specific site's configured key. + --- ## 6. Hosted File Actions: Convert & Clip (Frontend Notes) @@ -301,18 +362,16 @@ These helper endpoints let the frontend request small server-side transformation - Required query params: `link_to_type`, `link_to_id`, `start_time`, `end_time` (format `HH:MM:SS`) - Optional query params: `filename_no_ext` (defaults to `automated_hosted_file_clip_video`), `reencode` (bool), `scale_down` (bool) - Auth: standard V3 headers - - Behavior: extracts a clip using `ffmpeg` and saves it as a new `hosted_file`. Defaults to stream-copying to be fast; set `reencode=true` to force H.264 or `scale_down=true` to resize. Returns 400 on failure. - - Behavior: extracts a clip using `ffmpeg` and saves it as a new `hosted_file`. - - Defaults to stream-copying to be fast; set `reencode=true` to force H.264 or `scale_down=true` to resize. - - For longer-running clips you can schedule the job in the background by adding `?background=true`. When scheduled the API returns `202 Accepted` and the clip runs asynchronously on the server; check the returned `hosted_file` record later via the standard V3 `hosted_file` endpoints. - - Returns 400 on synchronous failure; returns 202 when scheduled successfully. + - Behavior: extracts a clip using `ffmpeg` and saves it as a new `hosted_file`. + - Defaults to stream-copying (fast); set `reencode=true` to force H.264 or `scale_down=true` to resize. + - Add `?background=true` to schedule the clip asynchronously — returns `202 Accepted` immediately; poll the `hosted_file` record for completion. + - Returns 400 on synchronous failure; 202 when scheduled successfully. Frontend guidance: - Call these routes with the same `link_to_type` / `link_to_id` you plan to associate the resulting hosted_file with — the server resolves random IDs for you. - After a successful response, use the V3 `hosted_file` action endpoints (download/delete) to manage or retrieve the new file. -- These endpoints run synchronously and can take time for large inputs; for heavy or batch workloads use a queued job pattern instead. - - These endpoints may take time for large inputs. Prefer using `?background=true` to schedule work and receive a `202 Accepted` response for async processing. For heavy or batch workloads use a queued job pattern instead. +- Prefer `?background=true` for large inputs to avoid request timeouts. For heavy or batch workloads use a queued job pattern instead. --- @@ -673,19 +732,19 @@ Verifies a Novi AMS member UUID by proxying the Novi API call through the Aether |---|---|---| | `404` | UUID not found in Novi, or Novi returned 200 with no identity data (empty-member anti-pattern — member may have just joined) | Treat as denied / not a member | | `429` | Novi rate limit hit | Surface as `'rate_limited'`; advise retry | -| `503` | Novi unreachable or Novi 5xx error | Auto-retry once after 3s; if retry also fails, surface as `'api_error'` | +| `503` | Novi unreachable or Novi 5xx error | Surface as `'api_error'`; advise retry | -### Migration from direct Novi call — ✅ Complete (2026-05-19) +### Migration from direct Novi call -`+layout.svelte:verify_novi_uuid()` now calls this endpoint instead of Novi directly. Response code mapping (for reference): +The frontend's `+layout.svelte:verify_novi_uuid()` currently calls Novi directly from the browser. Replace that `fetch()` with this endpoint. Response code mapping: -| Direct Novi result | This endpoint returns | Frontend behavior | +| Direct Novi result | This endpoint returns | Frontend state | |---|---|---| | `200` with identity data | `200` | `verified` | | `200` with no identity data | `404` | `denied` | | `404` | `404` | `denied` | -| `429` | `429` | Auto-retry after 10s; `'rate_limited'` if retry fails | -| Network error / Novi 5xx | `503` | Auto-retry after 3s; `'api_error'` if retry fails | +| `429` | `429` | `'rate_limited'` | +| Network error / Novi 5xx | `503` | `'api_error'` | ### Caching diff --git a/src/lib/ae_core/ae_comp__hosted_files_download_button.svelte b/src/lib/ae_core/ae_comp__hosted_files_download_button.svelte index 06ad66fb..22cfb613 100644 --- a/src/lib/ae_core/ae_comp__hosted_files_download_button.svelte +++ b/src/lib/ae_core/ae_comp__hosted_files_download_button.svelte @@ -186,16 +186,13 @@ let is_url_file = $derived.by(() => { let direct_download_url = $derived.by(() => { if (!show_direct_download || !hosted_file_obj) return ''; - // IMPORTANT: For Direct Link Mode, we MUST use the V3 Action endpoint to support Random String IDs. - // Legacy endpoints often expect integer IDs and will return 404 for string IDs. + // Always use the hosted_file endpoint — it supports ?key= auth and resolves event_file + // IDs automatically via Redis. The event_file endpoint's ?key= support is not yet deployed. const file_id = - hosted_file_obj.event_file_id || hosted_file_obj.hosted_file_id || + hosted_file_obj.event_file_id || hosted_file_id; - const obj_type_path = hosted_file_obj.event_file_id - ? 'event_file' - : 'hosted_file'; - return `${$ae_api.base_url}/v3/action/${obj_type_path}/${file_id}/download?filename=${ae_util.clean_filename(final_filename)}&key=${$ae_api.account_id}`; + return `${$ae_api.base_url}/v3/action/hosted_file/${file_id}/download?filename=${ae_util.clean_filename(final_filename)}&key=${$ae_api.account_id}`; }); async function handle_click() { diff --git a/src/lib/elements/element_manage_event_file_li.svelte b/src/lib/elements/element_manage_event_file_li.svelte index de5bc059..ea0a3be1 100644 --- a/src/lib/elements/element_manage_event_file_li.svelte +++ b/src/lib/elements/element_manage_event_file_li.svelte @@ -317,7 +317,7 @@ async function handle_convert_pdf_to_image(event_file_obj: key_val) { diff --git a/src/lib/stores/ae_events_stores__pres_mgmt_defaults.ts b/src/lib/stores/ae_events_stores__pres_mgmt_defaults.ts index 0b323396..f86ce4ec 100644 --- a/src/lib/stores/ae_events_stores__pres_mgmt_defaults.ts +++ b/src/lib/stores/ae_events_stores__pres_mgmt_defaults.ts @@ -244,6 +244,7 @@ export interface PresMgmtSessState { recent_files: string | null; presenters_agree: string | null; presenters_biography: string | null; + file_downloads: string | null; }; rpt__session_no_files: boolean; rpt__session_poc_agree: boolean; @@ -414,7 +415,8 @@ export const pres_mgmt_sess_defaults: PresMgmtSessState = { status_rpt: { recent_files: null, presenters_agree: null, - presenters_biography: null + presenters_biography: null, + file_downloads: null }, rpt__session_no_files: true, rpt__session_poc_agree: false, diff --git a/src/routes/events/[event_id]/(pres_mgmt)/reports/+page.svelte b/src/routes/events/[event_id]/(pres_mgmt)/reports/+page.svelte index 2059d4f8..722e0e92 100644 --- a/src/routes/events/[event_id]/(pres_mgmt)/reports/+page.svelte +++ b/src/routes/events/[event_id]/(pres_mgmt)/reports/+page.svelte @@ -39,9 +39,11 @@ import Event_reports_page_menu from './event_reports_page_menu.svelte'; import Reports_sessions from './reports_sessions.svelte'; import Reports_presenters from './reports_presenters.svelte'; import Reports_files from './reports_files.svelte'; +import Reports_file_downloads from './reports_file_downloads.svelte'; import { Check, ClipboardList, + Download, File, IdCard, ListChecks, @@ -90,7 +92,8 @@ let lq__event_obj = $derived( let ae_triggers: key_val = $state({ rpt__event_files: true, rpt__event_sessions: true, - rpt__event_presenters: true + rpt__event_presenters: true, + rpt__file_downloads: true }); let url_hash: string = $state($page?.url?.hash); @@ -311,6 +314,25 @@ $effect(() => { Event Files + + @@ -362,4 +384,14 @@ $effect(() => { hide_session_code={pres_mgmt_loc.current.hide__session_code} {log_lvl} /> {/if} + + + {#if pres_mgmt_loc.current.show_report == 'file_downloads'} + + {/if} {/if} diff --git a/src/routes/events/[event_id]/(pres_mgmt)/reports/reports_file_downloads.svelte b/src/routes/events/[event_id]/(pres_mgmt)/reports/reports_file_downloads.svelte new file mode 100644 index 00000000..1460f7e2 --- /dev/null +++ b/src/routes/events/[event_id]/(pres_mgmt)/reports/reports_file_downloads.svelte @@ -0,0 +1,482 @@ + + + + + +
+
+

+ {#if qry__status === 'loading'} + + {:else} + + {/if} + File Downloads + {#if qry__count} + {qry__count} files + {/if} + +  ({total_session_files} session  / {total_presenter_files} presenter) + +

+ + +
+ + +
+ + + Applied to all download buttons below. "Copy Link" copies the same URL. + +
+
+ + + + + +{#if qry__status === 'loading'} +
+ + Loading files… +
+{:else if qry__status === 'error'} +
+ Failed to load files. Check your connection and try refreshing. +
+{:else if session_groups.length === 0 && qry__status === 'done'} +

+ No session or presenter files found for this event. +

+{:else} + {#each session_groups as sg (sg.session_id)} + {@const has_any = sg.session_files.length > 0 || sg.presenter_groups.length > 0} + {#if has_any} + +
+ +
+ {#if sg.session_code} + {sg.session_code} + {/if} + {sg.session_name || '— session name not set —'} + {#if sg.session_start_datetime} + + {ae_util.iso_datetime_formatter(sg.session_start_datetime, 'date_iso')} +  {ae_util.iso_datetime_formatter(sg.session_start_datetime, 'time_12_short_no_leading')} + + {/if} + + {sg.session_files.length} session file{sg.session_files.length !== 1 ? 's' : ''}, +  {sg.presenter_groups.reduce((n, pg) => n + pg.files.length, 0)} presenter file{sg.presenter_groups.reduce((n, pg) => n + pg.files.length, 0) !== 1 ? 's' : ''} + +
+ +
+ + + {#if sg.session_files.length > 0} +
+

+ + Session Files ({sg.session_files.length}) +

+ + + {#each sg.session_files as file (file.event_file_id)} + {@const ExtIcon = ae_util.file_extension_icon_lucide(file.extension)} + {@const computed_name = build_filename(file, selected_format)} + {@const dl_url = build_download_url(file, selected_format)} + + + + + + + {/each} + +
+ + + {ae_util.shorten_filename({ filename: file.filename, max_length: 30 })} + + + + {computed_name} + + + {ae_util.format_bytes(file.file_size)} + + +
+
+ {/if} + + + {#each sg.presenter_groups as pg (pg.presenter_id)} +
+

+ + {pg.presenter_full_name || '— presenter name not set —'} + {#if pg.presentation_name} + — {pg.presentation_name} + {/if} + ({pg.files.length} file{pg.files.length !== 1 ? 's' : ''}) +

+ + + {#each pg.files as file (file.event_file_id)} + {@const ExtIcon = ae_util.file_extension_icon_lucide(file.extension)} + {@const computed_name = build_filename(file, selected_format)} + {@const dl_url = build_download_url(file, selected_format)} + + + + + + + {/each} + +
+ + + {ae_util.shorten_filename({ filename: file.filename, max_length: 30 })} + + + + {computed_name} + + + {ae_util.format_bytes(file.file_size)} + + +
+
+ {/each} + +
+
+ {/if} + {/each} +{/if}