From 98c28770e8e5ca08f6d9c6fe9d3c563526680db3 Mon Sep 17 00:00:00 2001 From: "Runneth (for Josh)" Date: Mon, 22 Jun 2026 07:49:51 -0400 Subject: [PATCH] Brain submissions: label zip downloads with the customer name Single and bulk zip downloads were anonymous: the file was named brain-submission-.zip and the folders inside were just section keys, so a downloaded zip had nothing tying it to the customer. - zip filename now leads with a customer-name slug - every file inside the zip sits under a top-level folder named after the customer it came from (bulk selections can span customers, so each file is foldered by its own submission's customer) - add getSubmissionNamesByIds / getSubmissionNameById db helpers - tighten the archiver stream so the response is attached before finalize() runs --- .../server/src/brain-submissions-db.ts | 22 ++++++++ use-case-library-site/server/src/index.ts | 56 +++++++++++++++++-- 2 files changed, 72 insertions(+), 6 deletions(-) diff --git a/use-case-library-site/server/src/brain-submissions-db.ts b/use-case-library-site/server/src/brain-submissions-db.ts index 5838ebb..ccb541e 100755 --- a/use-case-library-site/server/src/brain-submissions-db.ts +++ b/use-case-library-site/server/src/brain-submissions-db.ts @@ -261,6 +261,28 @@ export const getFilesByIds = ( }> } +// Map submission ids to their customer (workspace) name. Used by the zip +// download routes so every export is labelled with the customer it came from +// instead of an anonymous submission id. +const submissionNamesByIdsStmt = db.prepare(` + SELECT id, workspace_name + FROM brain_submissions + WHERE id IN (SELECT value FROM json_each(?)) +`) + +export const getSubmissionNamesByIds = (ids: number[]): Record => { + if (ids.length === 0) return {} + const rows = submissionNamesByIdsStmt.all(JSON.stringify(ids)) as Array<{ id: number; workspace_name: string }> + const out: Record = {} + for (const r of rows) out[r.id] = r.workspace_name + return out +} + +export const getSubmissionNameById = (id: number): string | null => { + const names = getSubmissionNamesByIds([id]) + return names[id] ?? null +} + // Wipe everything. Returns counts. Used by the admin wipe endpoint only. export const wipeAllSubmissions = (): { submissions: number; files: number } => { diff --git a/use-case-library-site/server/src/index.ts b/use-case-library-site/server/src/index.ts index 20b367d..56864ef 100755 --- a/use-case-library-site/server/src/index.ts +++ b/use-case-library-site/server/src/index.ts @@ -53,6 +53,8 @@ import { listFilesForSubmission, getFileBytes, getFilesByIds, + getSubmissionNamesByIds, + getSubmissionNameById, countSubmissionsByCsm, wipeAllSubmissions, dbPath as brainSubmissionsDbPath, @@ -62,6 +64,28 @@ import { resolveCsmFromHubSpot } from './brain-csm-resolver.js' import { SLUG_RE, hashIp, validateFlag, validateReview } from './reviews.js' const __dirname = dirname(fileURLToPath(import.meta.url)) + +// Turn a customer / workspace name into a safe, readable label for download +// filenames and zip folder entries. Keeps letters, numbers, spaces, dot, dash +// and underscore; collapses everything else so the name can never break a +// content-disposition header or a zip path. +const customerLabel = (name: string | null | undefined): string => { + const cleaned = (name || '') + .replace(/[\\/]+/g, ' ') + .replace(/[^\w.\- ]+/g, '') + .replace(/\s+/g, ' ') + .trim() + return cleaned || 'customer' +} + +// ASCII-safe slug for the downloaded .zip filename itself. +const customerFileSlug = (name: string | null | undefined): string => { + const slug = customerLabel(name) + .replace(/[^\w.\-]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') + return slug || 'customer' +} const PUBLIC_DIR = resolve(__dirname, '..', 'public') // Standalone marketing pages bundled with the server (copied from src/ on build). @@ -584,17 +608,21 @@ server.get<{ Params: { id: string } }>('/api/brain-submissions/:id/zip', async ( return { error: 'no_files' } } const fullFiles = getFilesByIds(files.map((f) => f.id)) + const customerName = getSubmissionNameById(id) + const folder = customerLabel(customerName) + const fileSlug = customerFileSlug(customerName) reply.header('content-type', 'application/zip') - reply.header('content-disposition', `attachment; filename="brain-submission-${id}.zip"`) + reply.header('content-disposition', `attachment; filename="${fileSlug}-brain-submission-${id}.zip"`) reply.header('cache-control', 'no-store') const archive = archiver('zip', { zlib: { level: 6 } }) archive.on('warning', (err) => server.log.warn({ err }, 'zip warning')) archive.on('error', (err) => server.log.error({ err }, 'zip error')) for (const f of fullFiles) { - archive.append(f.data, { name: `${f.section_key}/${f.filename}` }) + archive.append(f.data, { name: `${folder}/${f.section_key}/${f.filename}` }) } + reply.send(archive) archive.finalize() - return reply.send(archive) + return reply }) // POST form variant for the dashboard "Download selected (.zip)" button. @@ -615,17 +643,33 @@ server.post('/api/brain-submissions/zip-form', async (req, reply) => { reply.code(404) return { error: 'not_found' } } + // Label every file's folder with the customer it came from. Selected files + // can span multiple customers, so build a submissionId -> name map up front. + const names = getSubmissionNamesByIds( + Array.from(new Set(fullFiles.map((f) => f.submission_id))), + ) + const uniqueCustomers = Array.from( + new Set(fullFiles.map((f) => customerLabel(names[f.submission_id]))), + ) + // If everything came from one customer, name the zip after them; otherwise + // keep a dated multi-customer name. + const zipName = + uniqueCustomers.length === 1 + ? `${customerFileSlug(names[fullFiles[0].submission_id])}-brain-files.zip` + : `brain-files-${Date.now()}.zip` reply.header('content-type', 'application/zip') - reply.header('content-disposition', `attachment; filename="brain-files-${Date.now()}.zip"`) + reply.header('content-disposition', `attachment; filename="${zipName}"`) reply.header('cache-control', 'no-store') const archive = archiver('zip', { zlib: { level: 6 } }) archive.on('warning', (err) => server.log.warn({ err }, 'zip warning')) archive.on('error', (err) => server.log.error({ err }, 'zip error')) for (const f of fullFiles) { - archive.append(f.data, { name: `sub-${f.submission_id}/${f.section_key}/${f.filename}` }) + const folder = customerLabel(names[f.submission_id]) + archive.append(f.data, { name: `${folder}/${f.section_key}/${f.filename}` }) } + reply.send(archive) archive.finalize() - return reply.send(archive) + return reply }) // Static frontend.