feat(badges): add Cvent Splash XLSX import mode; fix server-side upload timeout

- Add 'Cvent Splash XLSX (registrant export)' upload mode hitting the new
  /event/{id}/badge/import/splash_xlsx endpoint
- Admin controls: begin_at/end_at/return_detail (shared with Zoom mode) +
  import_status_filter (splash only, default 'Attending')
- File picker accept attribute switches between .csv and .xlsx per mode
- Set timeout=300000 and retry_count=1 on both server-side upload paths to
  prevent false 'no response' failures on slow imports; upsert-by-email on
  the backend makes retries safe but a single attempt is sufficient
- Replace misleading 0/0 progress bar with an indeterminate progress bar
  during server-side processing; real counter kept for client-side CSV mode
- Show 'Processing on server…' message once upload completes and server work begins

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-06-02 11:37:48 -04:00
parent 33d48e7e78
commit a74effa6ff

View File

@@ -23,10 +23,11 @@ let upload_status: string = $state('idle'); // idle, loading, processing, succes
let upload_message: string = $state('');
let processed_badges_count: number = $state(0);
let total_badges_in_file: number = $state(0);
let upload_mode: 'client_csv' | 'axonius_zoom' = $state('axonius_zoom');
let upload_mode: 'client_csv' | 'axonius_zoom' | 'axonius_splash_xlsx' = $state('axonius_zoom');
let begin_at: number | null = $state(null);
let end_at: number | null = $state(null);
let return_detail: boolean = $state(false);
let import_status_filter: string = $state('Attending'); // splash only; set to '' to import all statuses
// A very basic CSV parser that assumes the first row is headers
function parse_csv(text: string): key_val[] {
@@ -64,6 +65,72 @@ async function handle_upload(event: Event) {
return;
}
// Server-side import for Cvent Splash XLSX
if (upload_mode === 'axonius_splash_xlsx') {
upload_status = 'loading';
upload_message = 'Uploading to server...';
const formData = new FormData();
formData.append('file', selected_file, selected_file.name);
const params: any = {};
if (begin_at !== null && begin_at !== undefined) params.begin_at = String(begin_at);
if (end_at !== null && end_at !== undefined) params.end_at = String(end_at);
if (import_status_filter !== 'Attending') params.import_status_filter = import_status_filter;
if (return_detail) params.return_detail = String(true);
const task_id = (crypto && (crypto as any).randomUUID)
? (crypto as any).randomUUID()
: String(Date.now());
try {
upload_status = 'processing';
upload_message = 'Processing on server… this may take a minute or two.';
const endpoint = `/event/${event_id}/badge/import/splash_xlsx`;
const result = await api.post_object({
api_cfg: $ae_api,
endpoint,
form_data: formData,
params,
return_meta: true,
task_id,
timeout: 300000, // 5 min — server-side import can be slow; no retry to avoid duplicate imports
retry_count: 1,
log_lvl: 0
});
if (!result) {
upload_status = 'error';
upload_message = 'Server import failed (no response).';
if (onerror) onerror(result);
return;
}
const meta = result.meta ?? result;
if (meta && meta.success === false) {
upload_status = 'error';
upload_message = meta?.details?.message || 'Server import failed';
if (onerror) onerror(result);
return;
}
const processed = meta?.processed ?? meta?.data_list_count ?? (Array.isArray(result?.data) ? result.data.length : 0);
processed_badges_count = processed || 0;
total_badges_in_file = meta?.total ?? processed_badges_count;
upload_status = 'success';
upload_message = `Server processed ${processed_badges_count} badges.`;
if (onsuccess) onsuccess();
} catch (err: any) {
upload_status = 'error';
upload_message = `Upload failed: ${err?.message || 'Unknown error'}`;
console.error('Upload error:', err);
if (onerror) onerror(err);
}
return;
}
// Server-side import for Axonius Zoom CSV
if (upload_mode === 'axonius_zoom') {
upload_status = 'loading';
@@ -83,6 +150,7 @@ async function handle_upload(event: Event) {
try {
upload_status = 'processing';
upload_message = 'Processing on server… this may take a minute or two.';
const endpoint = `/event/${event_id}/badge/import/zoom_csv`;
const result = await api.post_object({
api_cfg: $ae_api,
@@ -91,6 +159,8 @@ async function handle_upload(event: Event) {
params,
return_meta: true,
task_id,
timeout: 300000, // 5 min — server-side import can be slow; no retry to avoid duplicate imports
retry_count: 1,
log_lvl: 0
});
@@ -208,23 +278,27 @@ function handle_cancel() {
<code>badge_type_code</code>.
</p> -->
<p>
Upload the standard Axonius Zoom event tickets CSV export file here. This will trigger server-side processing to import the new and changed badges. This can take a few seconds to a few minutes.
{#if upload_mode === 'axonius_splash_xlsx'}
Upload the Cvent Splash XLSX registrant export file here. Only registrants with status "Attending" are imported by default. Server-side processing can take a few seconds to a few minutes.
{:else}
Upload the standard Axonius Zoom event tickets CSV export file here. This will trigger server-side processing to import the new and changed badges. This can take a few seconds to a few minutes.
{/if}
</p>
<fieldset class="space-y-2">
<legend class="label"><span>Upload format</span></legend>
<div class="flex gap-4 items-center">
<!-- <label class="label flex items-center gap-2">
<input type="radio" name="upload_mode" value="client_csv" bind:group={upload_mode} />
<span>Standard CSV (client-side parse)</span>
</label> -->
<label class="label flex items-center gap-2">
<input type="radio" name="upload_mode" value="axonius_zoom" bind:group={upload_mode} />
<span>Axonius Zoom CSV (event tickets)</span>
</label>
<label class="label flex items-center gap-2">
<input type="radio" name="upload_mode" value="axonius_splash_xlsx" bind:group={upload_mode} />
<span>Cvent Splash XLSX (registrant export)</span>
</label>
</div>
{#if $ae_loc.administrator_access && upload_mode === 'axonius_zoom'}
{#if $ae_loc.administrator_access && (upload_mode === 'axonius_zoom' || upload_mode === 'axonius_splash_xlsx')}
<div class="grid grid-cols-3 gap-2 items-center">
<input type="number" min="0" placeholder="begin_at (0)" bind:value={begin_at} class="input" />
<input type="number" min="0" placeholder="end_at (20000)" bind:value={end_at} class="input" />
@@ -233,14 +307,25 @@ function handle_cancel() {
<span>Return detail</span>
</label>
</div>
{#if upload_mode === 'axonius_splash_xlsx'}
<div class="flex items-center gap-2">
<label class="label" for="import_status_filter">Status filter</label>
<input
id="import_status_filter"
type="text"
placeholder="Attending (leave blank for all)"
bind:value={import_status_filter}
class="input flex-1" />
</div>
{/if}
{/if}
</fieldset>
<label class="label">
<span>Select CSV File</span>
<span>Select {upload_mode === 'axonius_splash_xlsx' ? 'XLSX' : 'CSV'} File</span>
<input
type="file"
accept=".csv"
accept={upload_mode === 'axonius_splash_xlsx' ? '.xlsx' : '.csv'}
bind:this={file_input}
onchange={handle_file_change}
class="input" />
@@ -257,13 +342,15 @@ function handle_cancel() {
class:preset-tonal-surface={upload_status !== 'error'}>
<p>{upload_message}</p>
{#if upload_status === 'processing' || upload_status === 'loading'}
<progress
class="progress"
value={processed_badges_count}
max={total_badges_in_file}></progress>
<p>
Processed: {processed_badges_count} / {total_badges_in_file}
</p>
{#if upload_mode === 'client_csv'}
<progress
class="progress"
value={processed_badges_count}
max={total_badges_in_file}></progress>
<p>Processed: {processed_badges_count} / {total_badges_in_file}</p>
{:else}
<progress class="progress"></progress>
{/if}
{/if}
</div>
{/if}