@aglyn/plugins-commerce 1.0.0-beta.292 → 1.0.0-beta.293

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/quote-proofs.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { claimAttempt, type PluginApiHandler, type PluginApiRequest } from '@aglyn/aglyn/server'\nimport { readClientIp } from '@aglyn/aglyn/app-utils/request-ip'\nimport { VISITOR_UPLOAD_HONEYPOT_FIELD, visitorUploadFileView } from '@aglyn/aglyn/app-utils/visitor-upload'\nimport { notifyHostManagers } from '@aglyn/tenant-data-admin'\nimport * as CommerceModel from '../model'\nimport { orderStatusUrl } from './order-status-token'\nimport {\n defaultQuotePayDeps,\n limitedQuoteLink,\n loadLinkedQuote,\n quoteLinkParams,\n sendQuoteLinkOutcome,\n type QuoteLinkOutcome,\n type QuotePayDeps,\n} from './quote-pay'\nimport { quoteUploadCalls, type QuoteUploadCalls } from './quote-uploads'\n\n/*==========================================\n * THE CUSTOMER'S SIDE OF A PROOF (AGL-4096), on the business's site, behind\n * the quote's signed pay link like the page itself:\n *\n * GET /api/commerce/quote-proof?hostId=&q=&t=&file=<proofId>\n * → the current revision's proof (a picture inline, anything else as a\n * download) | 404 | 410 replaced | 429\n * POST /api/commerce/quote-upload { hostId, q, t, action: 'ticket', file: { name, contentType, size } }\n * → { ticket, upload: { url, fields }, file }\n * POST /api/commerce/quote-upload { hostId, q, t, action: 'finalize', ticket }\n * → { file: { id, name, contentType, size, uploadedAtMs } }\n * POST /api/commerce/quote-respond { hostId, q, t, v, action: 'approve', name }\n * { hostId, q, t, v, action: 'changes', comment, fileIds? }\n * + Idempotency-Key → 200 { view } | 409 { error, reason } | 410 { replaced } | 404 | 400 | 429\n *\n * Every route spends a per-address rate limit on the site BEFORE the token is\n * checked, answers a wrong token exactly as a missing quote, and answers a\n * link to an older version as replaced. The uploads are the shared visitor\n * upload (`@aglyn/tenant-data-admin/server/visitor-upload`), scoped to this\n * quote's artwork; the answer names the files by id and the server reads\n * them back from that scope, so nothing else can be attached.\n *\n * `v` is the edit of the quote the page showed, as Pay sends it: an answer to\n * proofs the customer has not seen is refused and the page is shown again.\n *=========================================*/\n\n/** Proofs one address may load per site, per ten minutes: every thumbnail of a few visits. */\nexport const QUOTE_PROOF_LOADS_PER_WINDOW = 120\n/** Upload calls (tickets and finalizes) one address may make per site, per ten minutes, before the upload's own limits. */\nexport const QUOTE_UPLOAD_CALLS_PER_WINDOW = 80\n/** Answers one address may send per site, per ten minutes. */\nexport const QUOTE_RESPONSES_PER_WINDOW = 20\n\nexport interface QuoteProofDeps extends QuotePayDeps {\n uploads(): Promise<QuoteUploadCalls>\n notify(...args: Parameters<typeof notifyHostManagers>): Promise<void>\n}\n\nexport function defaultQuoteProofDeps(): QuoteProofDeps {\n return {\n ...defaultQuotePayDeps(),\n uploads: async () => quoteUploadCalls(),\n notify: (...args) => notifyHostManagers(...args),\n }\n}\n\nfunction bodyOf(req: PluginApiRequest): Record<string, unknown> {\n const raw = req.body\n if (typeof raw === 'string') {\n try {\n const parsed = JSON.parse(raw)\n return parsed && typeof parsed === 'object' ? parsed : {}\n } catch {\n return {}\n }\n }\n return raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {}\n}\n\nfunction headerOf(req: PluginApiRequest, name: string): string {\n const headers = (req.headers ?? {}) as Record<string, unknown>\n const value = headers[name.toLowerCase()] ?? headers[name]\n return String(Array.isArray(value) ? (value[0] ?? '') : (value ?? '')).trim()\n}\n\nconst clientIp = (req: PluginApiRequest): string | null =>\n readClientIp(req.headers as never, { remoteAddress: req.socket?.remoteAddress })\n\nconst reasonRefusal = (status: number, reason: string, error: string): QuoteLinkOutcome => ({\n status,\n body: { error, reason },\n})\n\n/** What a proof route answers: a JSON outcome, or a file to stream. */\nexport type QuoteProofOutcome = QuoteLinkOutcome & { file?: { path: string; name: string; contentType: string } }\n\n/** `GET /api/commerce/quote-proof`: one of the current revision's proofs. */\nexport async function handleQuoteProof(deps: QuoteProofDeps, req: PluginApiRequest): Promise<QuoteProofOutcome> {\n if (req.method !== 'GET') return { status: 405, body: { error: 'Method not allowed' } }\n const query = (req.query ?? {}) as Record<string, unknown>\n const { hostId, quoteId, token, complete } = quoteLinkParams(query)\n const fileId = String(query['file'] ?? '').slice(0, 200)\n if (!complete || !fileId) return { status: 400, body: { error: 'This link is incomplete.' } }\n const limited = await limitedQuoteLink(deps, req, `commerce-quote-proof:${hostId}`, QUOTE_PROOF_LOADS_PER_WINDOW)\n if (limited) return limited\n const linked = await loadLinkedQuote(deps, hostId, quoteId, token)\n if ('outcome' in linked) return linked.outcome\n const proof = CommerceModel.currentQuoteProofs(linked.loaded.order.quote).find((entry) => entry.id === fileId)\n if (!proof?.path) return { status: 404, body: { error: 'That proof is no longer on this quote.' } }\n return { status: 200, body: {}, file: { path: proof.path, name: proof.name, contentType: proof.contentType } }\n}\n\n/** `POST /api/commerce/quote-upload`: a ticket for one of the customer's files, or its finalize. */\nexport async function handleQuoteUpload(deps: QuoteProofDeps, req: PluginApiRequest): Promise<QuoteLinkOutcome> {\n if (req.method !== 'POST') return { status: 405, body: { error: 'Method not allowed' } }\n const body = bodyOf(req)\n const { hostId, quoteId, token, complete } = quoteLinkParams(body)\n if (!complete) return { status: 400, body: { error: 'This link is incomplete.' } }\n // A form a person fills never fills this; a bot that did learns nothing.\n const honeypot = body[VISITOR_UPLOAD_HONEYPOT_FIELD]\n if (typeof honeypot === 'string' && honeypot.trim()) return { status: 200, body: { received: true } }\n const limited = await limitedQuoteLink(deps, req, `commerce-quote-upload:${hostId}`, QUOTE_UPLOAD_CALLS_PER_WINDOW)\n if (limited) return limited\n const linked = await loadLinkedQuote(deps, hostId, quoteId, token)\n if ('outcome' in linked) return linked.outcome\n const { order, storeName } = linked.loaded\n // Artwork rides an answer; a quote that cannot be answered takes none.\n const refused = CommerceModel.quoteRespondRefusal(order)\n if (refused) return reasonRefusal(409, refused, CommerceModel.quoteRespondRefusalMessage(refused, storeName))\n\n const scope = { hostId, purpose: CommerceModel.QUOTE_ARTWORK_PURPOSE, purposeId: quoteId }\n const caller = { ip: clientIp(req) ?? 'no-address' }\n const uploads = await deps.uploads()\n if (body['action'] === 'ticket') {\n const minted = await uploads.mint({ scope, request: body['file'], caller })\n if ('error' in minted) {\n return {\n status: minted.status,\n body: { error: minted.error, code: minted.code },\n ...(minted.retryAfterSeconds ? { headers: { 'Retry-After': String(minted.retryAfterSeconds) } } : {}),\n }\n }\n return { status: 200, body: { ticket: minted.ticket, upload: minted.upload, file: minted.file } }\n }\n if (body['action'] === 'finalize') {\n const kept = await uploads.finalize({ scope, ticket: body['ticket'], caller })\n if ('error' in kept) return { status: kept.status, body: { error: kept.error, code: kept.code } }\n return { status: 200, body: { file: visitorUploadFileView(kept.file) } }\n }\n return { status: 400, body: { error: 'Unknown action' } }\n}\n\nconst UPDATED_MESSAGE = 'This quote was updated since you opened it. Look it over again before you answer.'\n\n/** The managers' notice of an answer: who, which quote and revision, what they said, and what to do next. */\nfunction noticeOf(input: {\n hostId: string\n quoteId: string\n order: CommerceModel.HostOrder\n response: CommerceModel.QuoteCustomerResponse\n}) {\n const { hostId, quoteId, order, response } = input\n const quote = order.quote as CommerceModel.OrderQuote\n const number = CommerceModel.formatQuoteNumber(order, quoteId)\n const customer = quote.customer?.name || quote.customer?.company || quote.customer?.email || 'The customer'\n const revision = response.revision\n const files = response.files ?? []\n const approving = response.action === 'approve'\n const payNote = quote.requireProofApproval === true ? ' They can pay the quote now.' : ''\n const details = [\n { label: 'Quote', value: number },\n { label: 'Customer', value: [quote.customer?.name, quote.customer?.company, quote.customer?.email].filter(Boolean).join(', ') },\n { label: 'Revision', value: String(revision) },\n ...(approving\n ? [{ label: 'Approved as', value: response.name ?? '' }]\n : [\n { label: 'What to change', value: response.comment ?? '' },\n ...(files.length ? [{ label: 'Files sent', value: files.map((file) => file.name).join(', ') }] : []),\n ]),\n ]\n const email = String(quote.customer?.email ?? '').trim()\n return {\n payload: {\n type: 'content.order' as const,\n title: approving ? `Proof approved — ${number} on {site}` : `Changes requested — ${number} on {site}`,\n body: approving\n ? `${customer} approved the proof for revision ${revision} of ${number}.${payNote} The approval, with the name, time and address it was given from, is on the quote.`\n : `${customer} asked for changes to revision ${revision} of ${number}` +\n (files.length ? ` and sent ${files.length} file${files.length === 1 ? '' : 's'}` : '') +\n `. Read what they asked for on the quote, then start revision ${revision + 1} with new proofs.`,\n link: `/${hostId}/products/orders?quote=${encodeURIComponent(quoteId)}`,\n },\n options: {\n emailDetails: details,\n ...(/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(email) ? { replyTo: email } : {}),\n },\n }\n}\n\n/** `POST /api/commerce/quote-respond`: the customer approves the proof or asks for changes. */\nexport async function handleQuoteRespond(deps: QuoteProofDeps, req: PluginApiRequest): Promise<QuoteLinkOutcome> {\n if (req.method !== 'POST') return { status: 405, body: { error: 'Method not allowed' } }\n const body = bodyOf(req)\n const { hostId, quoteId, token, complete } = quoteLinkParams(body)\n if (!complete) return { status: 400, body: { error: 'This link is incomplete.' } }\n const honeypot = body[VISITOR_UPLOAD_HONEYPOT_FIELD]\n if (typeof honeypot === 'string' && honeypot.trim()) return { status: 200, body: { received: true } }\n const idempotencyKey = headerOf(req, 'idempotency-key').slice(0, 200)\n if (!idempotencyKey) return { status: 400, body: { error: 'An answer needs an Idempotency-Key header.' } }\n const limited = await limitedQuoteLink(deps, req, `commerce-quote-respond:${hostId}`, QUOTE_RESPONSES_PER_WINDOW)\n if (limited) return limited\n const linked = await loadLinkedQuote(deps, hostId, quoteId, token)\n if ('outcome' in linked) return linked.outcome\n const { hostRef, host, order, storeName, store } = linked.loaded\n\n // Everything the customer can be refused is decided before the claim, so\n // none of it spends the attempt.\n const seen = Number(body['v'])\n const version = Number(order.quote?.updatedAtMs) || 0\n if (!Number.isFinite(seen) || seen !== version) return reasonRefusal(409, 'updated', UPDATED_MESSAGE)\n const parsed = CommerceModel.parseQuoteResponseInput(body)\n if ('error' in parsed) return { status: 400, body: { error: parsed.error } }\n const refused = CommerceModel.quoteRespondRefusal(order)\n if (refused) return reasonRefusal(409, refused, CommerceModel.quoteRespondRefusalMessage(refused, storeName))\n\n // The files are read back from this quote's artwork, by id: nothing the\n // browser says about a file is believed, and no other record's file can\n // be named.\n let files: CommerceModel.QuoteResponseFile[] = []\n if (parsed.input.action === 'changes' && parsed.input.fileIds.length) {\n const uploads = await deps.uploads()\n const scope = { hostId, purpose: CommerceModel.QUOTE_ARTWORK_PURPOSE, purposeId: quoteId }\n const read = await Promise.all(parsed.input.fileIds.map((id) => uploads.read(scope, id)))\n if (read.some((file) => !file)) {\n return { status: 400, body: { error: 'One of your files did not finish sending. Send it again.' } }\n }\n files = read.map((file) => ({\n id: file!.id,\n name: file!.name,\n contentType: file!.contentType,\n size: file!.size,\n path: file!.path,\n }))\n }\n\n const claimed = await claimAttempt(deps.firestore(), {\n kind: 'commerce-quote-respond',\n scopeId: `${hostId}:${quoteId}`,\n orgId: '',\n key: idempotencyKey,\n busyMessage: 'Your answer is already being sent',\n })\n if ('replay' in claimed) {\n return { status: claimed.replay.status, body: claimed.replay.body as Record<string, unknown> }\n }\n const claim = claimed.claim\n const now = deps.now()\n try {\n const ref = hostRef.collection('orders').doc(quoteId)\n const settled = await deps.firestore().runTransaction(\n async (transaction): Promise<{ outcome: QuoteLinkOutcome; order?: CommerceModel.HostOrder; response?: CommerceModel.QuoteCustomerResponse }> => {\n const snapshot = await transaction.get(ref)\n const current = snapshot.exists ? (snapshot.data() as CommerceModel.HostOrder) : null\n if (!current || !CommerceModel.isQuote(current) || !current.quote) {\n return { outcome: { status: 404, body: { error: 'We could not find that quote. Check the link you were sent.' } } }\n }\n if (CommerceModel.quoteLinkVersion(current.quote) !== CommerceModel.quoteLinkVersion(order.quote)) {\n return {\n outcome: {\n status: 410,\n body: { replaced: true, error: `This quote was updated, and ${storeName || 'the business'} sent you a newer link.` },\n },\n }\n }\n if ((Number(current.quote.updatedAtMs) || 0) !== version) {\n return { outcome: reasonRefusal(409, 'updated', UPDATED_MESSAGE) }\n }\n const answered = CommerceModel.applyQuoteResponse(\n current,\n { ...parsed.input, ...(files.length ? { files } : {}) },\n { atMs: now, ip: clientIp(req), userAgent: headerOf(req, 'user-agent') || null },\n )\n if ('reason' in answered) {\n return {\n outcome: reasonRefusal(409, answered.reason, CommerceModel.quoteRespondRefusalMessage(answered.reason, storeName)),\n }\n }\n transaction.set(ref, answered.order)\n return { outcome: { status: 200, body: {} }, order: answered.order, response: answered.response }\n },\n )\n if (!settled.order || !settled.response) {\n await claim.release()\n return settled.outcome\n }\n const view = CommerceModel.buildQuotePageView({\n order: settled.order,\n orderId: quoteId,\n storeName,\n currency: String(store.get('currency') ?? 'USD'),\n receiptUrl: CommerceModel.isUnpaidQuote(settled.order) ? null : orderStatusUrl(host, hostId, quoteId),\n })\n const outcome = { status: 200, body: { view } as Record<string, unknown> }\n await claim.record(200, outcome.body)\n const notice = noticeOf({ hostId, quoteId, order: settled.order, response: settled.response })\n void deps.notify(hostId, notice.payload, notice.options)\n return outcome\n } catch (error) {\n console.error('quote respond failed', error)\n await claim.release()\n return { status: 500, body: { error: 'Your answer could not be sent. Try again.' } }\n }\n}\n\n/** `GET /api/commerce/quote-proof`. */\nexport const quoteProofHandler: PluginApiHandler = async (req, res) => {\n const deps = defaultQuoteProofDeps()\n try {\n const outcome = await handleQuoteProof(deps, req)\n if (!outcome.file) return sendQuoteLinkOutcome(res, outcome)\n const uploads = await deps.uploads()\n return await uploads.send(res as never, outcome.file)\n } catch (error) {\n console.error('quote proof failed', error)\n return sendQuoteLinkOutcome(res, { status: 500, body: { error: 'This proof could not be loaded. Try again.' } })\n }\n}\n\n/** `POST /api/commerce/quote-upload`. */\nexport const quoteUploadHandler: PluginApiHandler = async (req, res) => {\n try {\n return sendQuoteLinkOutcome(res, await handleQuoteUpload(defaultQuoteProofDeps(), req))\n } catch (error) {\n console.error('quote upload failed', error)\n return sendQuoteLinkOutcome(res, { status: 500, body: { error: 'The file could not be sent. Try again.' } })\n }\n}\n\n/** `POST /api/commerce/quote-respond`. */\nexport const quoteRespondHandler: PluginApiHandler = async (req, res) =>\n sendQuoteLinkOutcome(res, await handleQuoteRespond(defaultQuoteProofDeps(), req))\n"],"names":["claimAttempt","readClientIp","VISITOR_UPLOAD_HONEYPOT_FIELD","visitorUploadFileView","notifyHostManagers","CommerceModel","orderStatusUrl","defaultQuotePayDeps","limitedQuoteLink","loadLinkedQuote","quoteLinkParams","sendQuoteLinkOutcome","quoteUploadCalls","QUOTE_PROOF_LOADS_PER_WINDOW","QUOTE_UPLOAD_CALLS_PER_WINDOW","QUOTE_RESPONSES_PER_WINDOW","defaultQuoteProofDeps","uploads","notify","args","bodyOf","req","raw","body","parsed","JSON","parse","headerOf","name","headers","value","toLowerCase","String","Array","isArray","trim","clientIp","remoteAddress","socket","reasonRefusal","status","reason","error","handleQuoteProof","deps","query","method","hostId","quoteId","token","complete","fileId","slice","limited","linked","outcome","proof","currentQuoteProofs","loaded","order","quote","find","entry","id","path","file","contentType","handleQuoteUpload","honeypot","received","storeName","refused","quoteRespondRefusal","quoteRespondRefusalMessage","scope","purpose","QUOTE_ARTWORK_PURPOSE","purposeId","caller","ip","minted","mint","request","code","retryAfterSeconds","ticket","upload","kept","finalize","UPDATED_MESSAGE","noticeOf","input","response","number","formatQuoteNumber","customer","company","email","revision","files","approving","action","payNote","requireProofApproval","details","label","filter","Boolean","join","comment","length","map","payload","type","title","link","encodeURIComponent","options","emailDetails","test","replyTo","handleQuoteRespond","idempotencyKey","hostRef","host","store","seen","Number","version","updatedAtMs","isFinite","parseQuoteResponseInput","fileIds","read","Promise","all","some","size","claimed","firestore","kind","scopeId","orgId","key","busyMessage","replay","claim","now","ref","collection","doc","settled","runTransaction","transaction","snapshot","get","current","exists","data","isQuote","quoteLinkVersion","replaced","answered","applyQuoteResponse","atMs","userAgent","set","release","view","buildQuotePageView","orderId","currency","receiptUrl","isUnpaidQuote","record","notice","console","quoteProofHandler","res","send","quoteUploadHandler","quoteRespondHandler"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/quote-proofs.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { claimAttempt, type PluginApiHandler, type PluginApiRequest } from '@aglyn/aglyn/server'\nimport { readClientIp } from '@aglyn/aglyn/app-utils/request-ip'\nimport { VISITOR_UPLOAD_HONEYPOT_FIELD, visitorUploadFileView, visitorUploadFolder } from '@aglyn/aglyn/app-utils/visitor-upload'\nimport { notifyHostManagers } from '@aglyn/tenant-data-admin'\nimport * as CommerceModel from '../model'\nimport { orderStatusUrl } from './order-status-token'\nimport {\n defaultQuotePayDeps,\n limitedQuoteLink,\n loadLinkedQuote,\n quoteLinkParams,\n sendQuoteLinkOutcome,\n type QuoteLinkOutcome,\n type QuotePayDeps,\n} from './quote-pay'\nimport { keepSentQuoteUploads, quoteUploadKeyRef, recordPendingQuoteUpload } from './quote-upload-keys'\nimport { quoteUploadCalls, type QuoteUploadCalls } from './quote-uploads'\n\n/*==========================================\n * THE CUSTOMER'S SIDE OF A PROOF (AGL-4096), on the business's site, behind\n * the quote's signed pay link like the page itself:\n *\n * GET /api/commerce/quote-proof?hostId=&q=&t=&file=<proofId>\n * → a proof from any revision, for a link to any version of this quote\n * (a picture inline, anything else as a download) | 404 | 429\n * POST /api/commerce/quote-upload { hostId, q, t, action: 'ticket', file: { name, contentType, size } }\n * → { ticket, upload: { url, fields }, file }\n * POST /api/commerce/quote-upload { hostId, q, t, action: 'finalize', ticket }\n * → { file: { id, name, contentType, size, uploadedAtMs } }\n * POST /api/commerce/quote-respond { hostId, q, t, v, action: 'approve', name }\n * { hostId, q, t, v, action: 'changes', comment, fileIds? }\n * + Idempotency-Key → 200 { view } | 409 { error, reason } | 410 { replaced } | 404 | 400 | 429\n *\n * Every route spends a per-address rate limit on the site BEFORE the token is\n * checked, answers a wrong token exactly as a missing quote, and (but for a\n * proof, which an earlier email still draws) answers a link to an older\n * version as replaced. The uploads are the shared visitor\n * upload (`@aglyn/tenant-data-admin/server/visitor-upload`), scoped to this\n * quote's artwork; the answer names the files by id and the server reads\n * them back from that scope, so nothing else can be attached.\n *\n * `v` is the edit of the quote the page showed, as Pay sends it: an answer to\n * proofs the customer has not seen is refused and the page is shown again.\n *\n * A file goes up before the answer does, so each one is recorded as pending\n * under its quote from its ticket on, and the answer that sends it takes it\n * off; one never sent is deleted a day later (`quote-upload-keys.ts`).\n *=========================================*/\n\n/** Proofs one address may load per site, per ten minutes: every thumbnail of a few visits. */\nexport const QUOTE_PROOF_LOADS_PER_WINDOW = 120\n/** Upload calls (tickets and finalizes) one address may make per site, per ten minutes, before the upload's own limits. */\nexport const QUOTE_UPLOAD_CALLS_PER_WINDOW = 80\n/** Answers one address may send per site, per ten minutes. */\nexport const QUOTE_RESPONSES_PER_WINDOW = 20\n\nexport interface QuoteProofDeps extends QuotePayDeps {\n uploads(): Promise<QuoteUploadCalls>\n notify(...args: Parameters<typeof notifyHostManagers>): Promise<void>\n}\n\nexport function defaultQuoteProofDeps(): QuoteProofDeps {\n return {\n ...defaultQuotePayDeps(),\n uploads: async () => quoteUploadCalls(),\n notify: (...args) => notifyHostManagers(...args),\n }\n}\n\nfunction bodyOf(req: PluginApiRequest): Record<string, unknown> {\n const raw = req.body\n if (typeof raw === 'string') {\n try {\n const parsed = JSON.parse(raw)\n return parsed && typeof parsed === 'object' ? parsed : {}\n } catch {\n return {}\n }\n }\n return raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {}\n}\n\nfunction headerOf(req: PluginApiRequest, name: string): string {\n const headers = (req.headers ?? {}) as Record<string, unknown>\n const value = headers[name.toLowerCase()] ?? headers[name]\n return String(Array.isArray(value) ? (value[0] ?? '') : (value ?? '')).trim()\n}\n\nconst clientIp = (req: PluginApiRequest): string | null =>\n readClientIp(req.headers as never, { remoteAddress: req.socket?.remoteAddress })\n\nconst reasonRefusal = (status: number, reason: string, error: string): QuoteLinkOutcome => ({\n status,\n body: { error, reason },\n})\n\n/** What a proof route answers: a JSON outcome, or a file to stream. */\nexport type QuoteProofOutcome = QuoteLinkOutcome & { file?: { path: string; name: string; contentType: string } }\n\nconst PROOF_GONE: QuoteProofOutcome = { status: 404, body: { error: 'That proof is no longer on this quote.' } }\n\n/**\n * `GET /api/commerce/quote-proof`: one of the proofs in the quote's history\n * (AGL-4097). An email is a record the customer keeps, so the proof it draws\n * keeps showing after the business starts the next revision or replaces the\n * link: any link signed for this quote, an older version's too, is served the\n * current revision's proofs and every earlier revision's as its snapshot kept\n * them. A proof taken off its revision, another quote's file and a wrong token\n * are not found.\n */\nexport async function handleQuoteProof(deps: QuoteProofDeps, req: PluginApiRequest): Promise<QuoteProofOutcome> {\n if (req.method !== 'GET') return { status: 405, body: { error: 'Method not allowed' } }\n const query = (req.query ?? {}) as Record<string, unknown>\n const { hostId, quoteId, token, complete } = quoteLinkParams(query)\n const fileId = String(query['file'] ?? '').slice(0, 200)\n if (!complete || !fileId) return { status: 400, body: { error: 'This link is incomplete.' } }\n const limited = await limitedQuoteLink(deps, req, `commerce-quote-proof:${hostId}`, QUOTE_PROOF_LOADS_PER_WINDOW)\n if (limited) return limited\n const linked = await loadLinkedQuote(deps, hostId, quoteId, token, { history: true })\n if ('outcome' in linked) return linked.outcome\n const quote = linked.loaded.order.quote\n // The earlier revisions are read only when the current one lacks the proof.\n let proof = CommerceModel.quoteProofInHistory(quote, [], fileId)\n if (!proof) {\n const listed = await linked.loaded.hostRef.collection('orders').doc(quoteId).collection('revisions').get()\n const snapshots = listed.docs.map((entry) => entry.data() as Partial<CommerceModel.QuoteRevisionSnapshot>)\n proof = CommerceModel.quoteProofInHistory(quote, snapshots, fileId)\n }\n // A proof is kept under this quote's own folder; a path anywhere else is never served.\n const folder = visitorUploadFolder({ hostId, purpose: CommerceModel.QUOTE_PROOF_PURPOSE, purposeId: quoteId })\n if (!proof?.path || !proof.path.startsWith(folder)) return PROOF_GONE\n return { status: 200, body: {}, file: { path: proof.path, name: proof.name, contentType: proof.contentType } }\n}\n\n/** `POST /api/commerce/quote-upload`: a ticket for one of the customer's files, or its finalize. */\nexport async function handleQuoteUpload(deps: QuoteProofDeps, req: PluginApiRequest): Promise<QuoteLinkOutcome> {\n if (req.method !== 'POST') return { status: 405, body: { error: 'Method not allowed' } }\n const body = bodyOf(req)\n const { hostId, quoteId, token, complete } = quoteLinkParams(body)\n if (!complete) return { status: 400, body: { error: 'This link is incomplete.' } }\n // A form a person fills never fills this; a bot that did learns nothing.\n const honeypot = body[VISITOR_UPLOAD_HONEYPOT_FIELD]\n if (typeof honeypot === 'string' && honeypot.trim()) return { status: 200, body: { received: true } }\n const limited = await limitedQuoteLink(deps, req, `commerce-quote-upload:${hostId}`, QUOTE_UPLOAD_CALLS_PER_WINDOW)\n if (limited) return limited\n const linked = await loadLinkedQuote(deps, hostId, quoteId, token)\n if ('outcome' in linked) return linked.outcome\n const { order, storeName } = linked.loaded\n // Artwork rides an answer; a quote that cannot be answered takes none.\n const refused = CommerceModel.quoteRespondRefusal(order)\n if (refused) return reasonRefusal(409, refused, CommerceModel.quoteRespondRefusalMessage(refused, storeName))\n\n const scope = { hostId, purpose: CommerceModel.QUOTE_ARTWORK_PURPOSE, purposeId: quoteId }\n const caller = { ip: clientIp(req) ?? 'no-address' }\n const uploads = await deps.uploads()\n if (body['action'] === 'ticket') {\n const minted = await uploads.mint({ scope, request: body['file'], caller })\n if ('error' in minted) {\n return {\n status: minted.status,\n body: { error: minted.error, code: minted.code },\n ...(minted.retryAfterSeconds ? { headers: { 'Retry-After': String(minted.retryAfterSeconds) } } : {}),\n }\n }\n // Pending before a byte can arrive, so a file kept and never sent is always swept.\n await recordPendingQuoteUpload(deps.firestore(), { hostId, quoteId, fileId: minted.file.id, nowMs: deps.now() })\n return { status: 200, body: { ticket: minted.ticket, upload: minted.upload, file: minted.file } }\n }\n if (body['action'] === 'finalize') {\n const kept = await uploads.finalize({ scope, ticket: body['ticket'], caller })\n if ('error' in kept) return { status: kept.status, body: { error: kept.error, code: kept.code } }\n // Its day starts again from the latest file.\n await recordPendingQuoteUpload(deps.firestore(), { hostId, quoteId, fileId: kept.file.id, nowMs: deps.now() })\n return { status: 200, body: { file: visitorUploadFileView(kept.file) } }\n }\n return { status: 400, body: { error: 'Unknown action' } }\n}\n\nconst UPDATED_MESSAGE = 'This quote was updated since you opened it. Look it over again before you answer.'\n\n/**\n * The managers' notice of an answer (AGL-4096): who, which quote and\n * revision, what they said, and what to do next.\n *\n * It rides `content.order`, so whoever is told about orders (in the feed, by\n * email, by push) is told about the quotes that become them, and it names\n * itself as what it is rather than as that type: \"Proof approved\" or\n * \"Changes requested\" where the feed and the email's eyebrow would say \"New\n * order\", the customer and the quote as its heading, and \"View quote\" on the\n * email's button, which opens the quote's dialog on the Orders page.\n */\nfunction noticeOf(input: {\n hostId: string\n quoteId: string\n order: CommerceModel.HostOrder\n response: CommerceModel.QuoteCustomerResponse\n currency: string\n}) {\n const { hostId, quoteId, order, response } = input\n const quote = order.quote as CommerceModel.OrderQuote\n const number = CommerceModel.formatQuoteNumber(order, quoteId)\n const customer = quote.customer?.name || quote.customer?.company || quote.customer?.email || 'The customer'\n const revision = response.revision\n const files = response.files ?? []\n const approving = response.action === 'approve'\n const money = CommerceModel.formatOrderMoney(Number(order.totals?.totalCents ?? 0), input.currency)\n const total = quote.tax?.kind === 'at-payment' ? `${money} before tax` : money\n const filesSent = `${files.length} file${files.length === 1 ? '' : 's'}`\n const details = [\n { label: 'Quote', value: number },\n { label: 'Customer', value: [quote.customer?.name, quote.customer?.company, quote.customer?.email].filter(Boolean).join(', ') },\n { label: 'Revision', value: String(revision) },\n { label: 'Total', value: total },\n ...(approving\n ? [{ label: 'Approved as', value: response.name ?? '' }]\n : [\n { label: 'What to change', value: response.comment ?? '' },\n ...(files.length ? [{ label: 'Files sent', value: files.map((file) => file.name).join(', ') }] : []),\n ]),\n ]\n const email = String(quote.customer?.email ?? '').trim()\n return {\n payload: {\n type: 'content.order' as const,\n // Good news for an approval; a change request is work to read.\n level: approving ? ('success' as const) : ('info' as const),\n label: approving ? 'Proof approved' : 'Changes requested',\n actionLabel: 'View quote',\n title: approving\n ? `${customer} approved the proof for ${number} on {site}`\n : `${customer} asked for changes to ${number} on {site}`,\n body: approving\n ? (quote.requireProofApproval === true\n ? `Revision ${revision} is approved, so they can pay the quote now. It becomes an order when they do.`\n : `Revision ${revision} is approved. The quote becomes an order when they pay.`) +\n ' The approval, with the name, time and address it was given from, is on the quote.'\n : files.length\n ? `What they want changed on revision ${revision}, and the ${filesSent} they sent, are on the quote. ` +\n `Read them, then start revision ${revision + 1} with new proofs and send it.`\n : `What they want changed on revision ${revision} is on the quote. ` +\n `Read it, then start revision ${revision + 1} with new proofs and send it.`,\n link: `/${hostId}/products/orders?quote=${encodeURIComponent(quoteId)}`,\n },\n options: {\n emailDetails: details,\n ...(/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(email) ? { replyTo: email } : {}),\n },\n }\n}\n\n/** `POST /api/commerce/quote-respond`: the customer approves the proof or asks for changes. */\nexport async function handleQuoteRespond(deps: QuoteProofDeps, req: PluginApiRequest): Promise<QuoteLinkOutcome> {\n if (req.method !== 'POST') return { status: 405, body: { error: 'Method not allowed' } }\n const body = bodyOf(req)\n const { hostId, quoteId, token, complete } = quoteLinkParams(body)\n if (!complete) return { status: 400, body: { error: 'This link is incomplete.' } }\n const honeypot = body[VISITOR_UPLOAD_HONEYPOT_FIELD]\n if (typeof honeypot === 'string' && honeypot.trim()) return { status: 200, body: { received: true } }\n const idempotencyKey = headerOf(req, 'idempotency-key').slice(0, 200)\n if (!idempotencyKey) return { status: 400, body: { error: 'An answer needs an Idempotency-Key header.' } }\n const limited = await limitedQuoteLink(deps, req, `commerce-quote-respond:${hostId}`, QUOTE_RESPONSES_PER_WINDOW)\n if (limited) return limited\n const linked = await loadLinkedQuote(deps, hostId, quoteId, token)\n if ('outcome' in linked) return linked.outcome\n const { hostRef, host, order, storeName, store } = linked.loaded\n\n // Everything the customer can be refused is decided before the claim, so\n // none of it spends the attempt.\n const seen = Number(body['v'])\n const version = Number(order.quote?.updatedAtMs) || 0\n if (!Number.isFinite(seen) || seen !== version) return reasonRefusal(409, 'updated', UPDATED_MESSAGE)\n const parsed = CommerceModel.parseQuoteResponseInput(body)\n if ('error' in parsed) return { status: 400, body: { error: parsed.error } }\n const refused = CommerceModel.quoteRespondRefusal(order)\n if (refused) return reasonRefusal(409, refused, CommerceModel.quoteRespondRefusalMessage(refused, storeName))\n\n // The files are read back from this quote's artwork, by id: nothing the\n // browser says about a file is believed, and no other record's file can\n // be named.\n let files: CommerceModel.QuoteResponseFile[] = []\n if (parsed.input.action === 'changes' && parsed.input.fileIds.length) {\n const uploads = await deps.uploads()\n const scope = { hostId, purpose: CommerceModel.QUOTE_ARTWORK_PURPOSE, purposeId: quoteId }\n const read = await Promise.all(parsed.input.fileIds.map((id) => uploads.read(scope, id)))\n if (read.some((file) => !file)) {\n return { status: 400, body: { error: 'One of your files did not finish sending. Send it again.' } }\n }\n files = read.map((file) => ({\n id: file!.id,\n name: file!.name,\n contentType: file!.contentType,\n size: file!.size,\n path: file!.path,\n }))\n }\n\n const claimed = await claimAttempt(deps.firestore(), {\n kind: 'commerce-quote-respond',\n scopeId: `${hostId}:${quoteId}`,\n orgId: '',\n key: idempotencyKey,\n busyMessage: 'Your answer is already being sent',\n })\n if ('replay' in claimed) {\n return { status: claimed.replay.status, body: claimed.replay.body as Record<string, unknown> }\n }\n const claim = claimed.claim\n const now = deps.now()\n try {\n const ref = hostRef.collection('orders').doc(quoteId)\n const pendingRef = quoteUploadKeyRef(deps.firestore(), hostId, quoteId)\n const settled = await deps.firestore().runTransaction(\n async (transaction): Promise<{ outcome: QuoteLinkOutcome; order?: CommerceModel.HostOrder; response?: CommerceModel.QuoteCustomerResponse }> => {\n const snapshot = await transaction.get(ref)\n // The files this answer sends leave the pending record with it.\n const pending = files.length ? await transaction.get(pendingRef) : null\n const current = snapshot.exists ? (snapshot.data() as CommerceModel.HostOrder) : null\n if (!current || !CommerceModel.isQuote(current) || !current.quote) {\n return { outcome: { status: 404, body: { error: 'We could not find that quote. Check the link you were sent.' } } }\n }\n if (CommerceModel.quoteLinkVersion(current.quote) !== CommerceModel.quoteLinkVersion(order.quote)) {\n return {\n outcome: {\n status: 410,\n body: { replaced: true, error: `This quote was updated, and ${storeName || 'the business'} sent you a newer link.` },\n },\n }\n }\n if ((Number(current.quote.updatedAtMs) || 0) !== version) {\n return { outcome: reasonRefusal(409, 'updated', UPDATED_MESSAGE) }\n }\n const answered = CommerceModel.applyQuoteResponse(\n current,\n { ...parsed.input, ...(files.length ? { files } : {}) },\n { atMs: now, ip: clientIp(req), userAgent: headerOf(req, 'user-agent') || null },\n )\n if ('reason' in answered) {\n return {\n outcome: reasonRefusal(409, answered.reason, CommerceModel.quoteRespondRefusalMessage(answered.reason, storeName)),\n }\n }\n transaction.set(ref, answered.order)\n if (pending) keepSentQuoteUploads(transaction, pending, pendingRef, files.map((file) => file.id))\n return { outcome: { status: 200, body: {} }, order: answered.order, response: answered.response }\n },\n )\n if (!settled.order || !settled.response) {\n await claim.release()\n return settled.outcome\n }\n const view = CommerceModel.buildQuotePageView({\n order: settled.order,\n orderId: quoteId,\n storeName,\n currency: String(store.get('currency') ?? 'USD'),\n receiptUrl: CommerceModel.isUnpaidQuote(settled.order) ? null : orderStatusUrl(host, hostId, quoteId),\n })\n const outcome = { status: 200, body: { view } as Record<string, unknown> }\n await claim.record(200, outcome.body)\n const notice = noticeOf({\n hostId,\n quoteId,\n order: settled.order,\n response: settled.response,\n currency: String(store.get('currency') ?? 'USD'),\n })\n void deps.notify(hostId, notice.payload, notice.options)\n return outcome\n } catch (error) {\n console.error('quote respond failed', error)\n await claim.release()\n return { status: 500, body: { error: 'Your answer could not be sent. Try again.' } }\n }\n}\n\n/** `GET /api/commerce/quote-proof`. */\nexport const quoteProofHandler: PluginApiHandler = async (req, res) => {\n const deps = defaultQuoteProofDeps()\n try {\n const outcome = await handleQuoteProof(deps, req)\n if (!outcome.file) return sendQuoteLinkOutcome(res, outcome)\n const uploads = await deps.uploads()\n return await uploads.send(res as never, outcome.file)\n } catch (error) {\n console.error('quote proof failed', error)\n return sendQuoteLinkOutcome(res, { status: 500, body: { error: 'This proof could not be loaded. Try again.' } })\n }\n}\n\n/** `POST /api/commerce/quote-upload`. */\nexport const quoteUploadHandler: PluginApiHandler = async (req, res) => {\n try {\n return sendQuoteLinkOutcome(res, await handleQuoteUpload(defaultQuoteProofDeps(), req))\n } catch (error) {\n console.error('quote upload failed', error)\n return sendQuoteLinkOutcome(res, { status: 500, body: { error: 'The file could not be sent. Try again.' } })\n }\n}\n\n/** `POST /api/commerce/quote-respond`. */\nexport const quoteRespondHandler: PluginApiHandler = async (req, res) =>\n sendQuoteLinkOutcome(res, await handleQuoteRespond(defaultQuoteProofDeps(), req))\n"],"names":["claimAttempt","readClientIp","VISITOR_UPLOAD_HONEYPOT_FIELD","visitorUploadFileView","visitorUploadFolder","notifyHostManagers","CommerceModel","orderStatusUrl","defaultQuotePayDeps","limitedQuoteLink","loadLinkedQuote","quoteLinkParams","sendQuoteLinkOutcome","keepSentQuoteUploads","quoteUploadKeyRef","recordPendingQuoteUpload","quoteUploadCalls","QUOTE_PROOF_LOADS_PER_WINDOW","QUOTE_UPLOAD_CALLS_PER_WINDOW","QUOTE_RESPONSES_PER_WINDOW","defaultQuoteProofDeps","uploads","notify","args","bodyOf","req","raw","body","parsed","JSON","parse","headerOf","name","headers","value","toLowerCase","String","Array","isArray","trim","clientIp","remoteAddress","socket","reasonRefusal","status","reason","error","PROOF_GONE","handleQuoteProof","deps","query","method","hostId","quoteId","token","complete","fileId","slice","limited","linked","history","outcome","quote","loaded","order","proof","quoteProofInHistory","listed","hostRef","collection","doc","get","snapshots","docs","map","entry","data","folder","purpose","QUOTE_PROOF_PURPOSE","purposeId","path","startsWith","file","contentType","handleQuoteUpload","honeypot","received","storeName","refused","quoteRespondRefusal","quoteRespondRefusalMessage","scope","QUOTE_ARTWORK_PURPOSE","caller","ip","minted","mint","request","code","retryAfterSeconds","firestore","id","nowMs","now","ticket","upload","kept","finalize","UPDATED_MESSAGE","noticeOf","input","response","number","formatQuoteNumber","customer","company","email","revision","files","approving","action","money","formatOrderMoney","Number","totals","totalCents","currency","total","tax","kind","filesSent","length","details","label","filter","Boolean","join","comment","payload","type","level","actionLabel","title","requireProofApproval","link","encodeURIComponent","options","emailDetails","test","replyTo","handleQuoteRespond","idempotencyKey","host","store","seen","version","updatedAtMs","isFinite","parseQuoteResponseInput","fileIds","read","Promise","all","some","size","claimed","scopeId","orgId","key","busyMessage","replay","claim","ref","pendingRef","settled","runTransaction","transaction","snapshot","pending","current","exists","isQuote","quoteLinkVersion","replaced","answered","applyQuoteResponse","atMs","userAgent","set","release","view","buildQuotePageView","orderId","receiptUrl","isUnpaidQuote","record","notice","console","quoteProofHandler","res","send","quoteUploadHandler","quoteRespondHandler"],"mappings":"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"}
@@ -0,0 +1,50 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ /** Where a quote's pending artwork is recorded, under the quote. */
18
+ export declare const QUOTE_UPLOAD_KEYS_COLLECTION = "quoteUploadKeys";
19
+ /** How long a file the customer attached may wait for their answer. */
20
+ export declare const QUOTE_UPLOAD_KEY_TTL_MS: number;
21
+ /** What the upload route knows of a quote's unsent artwork. */
22
+ export interface QuoteUploadKeyRecord {
23
+ hostId: string;
24
+ quoteId: string;
25
+ /** Each pending file's id, and when it was last recorded. */
26
+ files: Record<string, number>;
27
+ /** When the console's sweep may delete the files still pending. */
28
+ sweepAtMs: number;
29
+ }
30
+ /** The pending record of one quote's artwork. */
31
+ export declare function quoteUploadKeyRef(firestore: FirebaseFirestore.Firestore, hostId: string, quoteId: string): FirebaseFirestore.DocumentReference;
32
+ /** The file ids a record holds, read defensively. */
33
+ export declare function quoteUploadKeyFiles(data: Record<string, unknown> | undefined): Record<string, number>;
34
+ /**
35
+ * Records one of the customer's files as pending, and moves the record's
36
+ * sweep to a day from now. A merge, so files recorded at once each keep
37
+ * their own entry.
38
+ */
39
+ export declare function recordPendingQuoteUpload(firestore: FirebaseFirestore.Firestore, input: {
40
+ hostId: string;
41
+ quoteId: string;
42
+ fileId: string;
43
+ nowMs: number;
44
+ }): Promise<void>;
45
+ /**
46
+ * In the answer's transaction, after every read: the files the answer sends
47
+ * are kept, so they leave the pending record, and a record left with none
48
+ * is deleted. `pending` is the record as the transaction read it.
49
+ */
50
+ export declare function keepSentQuoteUploads(transaction: FirebaseFirestore.Transaction, pending: FirebaseFirestore.DocumentSnapshot, ref: FirebaseFirestore.DocumentReference, sentIds: readonly string[]): void;
@@ -0,0 +1,86 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Aglyn LLC
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */ import { isVisitorUploadSegment } from "@aglyn/aglyn/app-utils/visitor-upload";
18
+ /*==========================================
19
+ * A CUSTOMER'S FILES THAT WERE NEVER SENT (AGL-4096).
20
+ *
21
+ * The customer's artwork goes up before their answer does: `quote-upload`
22
+ * keeps each file the moment it is finalized, and `quote-respond` names the
23
+ * ones the answer carries. A customer who attaches a file and never sends
24
+ * the answer leaves a file nobody will see, kept until the quote is deleted.
25
+ *
26
+ * So, as a form's files are (`forms/{formId}/formUploadKeys/{key}`), each one
27
+ * is recorded as pending under its quote,
28
+ * `hosts/{hostId}/orders/{quoteId}/quoteUploadKeys/{quoteId}`: the ticket
29
+ * records its id before anything can be uploaded, the finalize records it
30
+ * again, and `sweepAtMs` is a day after the latest of them. The answer takes
31
+ * the files it sends off the record in the transaction that records the
32
+ * answer, which is what keeps them. The console's sweep
33
+ * (`quote-upload-sweep.ts`) deletes every file still on a record whose day
34
+ * has passed, and the record with it.
35
+ *
36
+ * The record's id is the quote's, because the artwork's scope
37
+ * (`quote-artwork/{quoteId}`) is the quote, as a form's files' scope is the
38
+ * form's key. Only the Admin SDK reads or writes it: everything under an
39
+ * order is closed to clients.
40
+ *=========================================*/ /** Where a quote's pending artwork is recorded, under the quote. */ export const QUOTE_UPLOAD_KEYS_COLLECTION = 'quoteUploadKeys';
41
+ /** How long a file the customer attached may wait for their answer. */ export const QUOTE_UPLOAD_KEY_TTL_MS = 24 * 60 * 60 * 1000;
42
+ /** The pending record of one quote's artwork. */ export function quoteUploadKeyRef(firestore, hostId, quoteId) {
43
+ return firestore.collection('hosts').doc(hostId).collection('orders').doc(quoteId).collection(QUOTE_UPLOAD_KEYS_COLLECTION).doc(quoteId);
44
+ }
45
+ /** The file ids a record holds, read defensively. */ export function quoteUploadKeyFiles(data) {
46
+ var _ref;
47
+ const files = (_ref = data == null ? void 0 : data['files']) != null ? _ref : {};
48
+ return Object.fromEntries(Object.entries(files).filter(([id])=>isVisitorUploadSegment(id)).map(([id, atMs])=>[
49
+ id,
50
+ Number(atMs) || 0
51
+ ]));
52
+ }
53
+ /**
54
+ * Records one of the customer's files as pending, and moves the record's
55
+ * sweep to a day from now. A merge, so files recorded at once each keep
56
+ * their own entry.
57
+ */ export async function recordPendingQuoteUpload(firestore, input) {
58
+ const { hostId, quoteId, fileId, nowMs } = input;
59
+ if (!isVisitorUploadSegment(fileId)) return;
60
+ await quoteUploadKeyRef(firestore, hostId, quoteId).set({
61
+ hostId,
62
+ quoteId,
63
+ files: {
64
+ [fileId]: nowMs
65
+ },
66
+ sweepAtMs: nowMs + QUOTE_UPLOAD_KEY_TTL_MS
67
+ }, {
68
+ merge: true
69
+ });
70
+ }
71
+ /**
72
+ * In the answer's transaction, after every read: the files the answer sends
73
+ * are kept, so they leave the pending record, and a record left with none
74
+ * is deleted. `pending` is the record as the transaction read it.
75
+ */ export function keepSentQuoteUploads(transaction, pending, ref, sentIds) {
76
+ if (!pending.exists || !sentIds.length) return;
77
+ const files = quoteUploadKeyFiles(pending.data());
78
+ const rest = Object.fromEntries(Object.entries(files).filter(([id])=>!sentIds.includes(id)));
79
+ if (Object.keys(rest).length === Object.keys(files).length) return;
80
+ if (!Object.keys(rest).length) transaction.delete(ref);
81
+ else transaction.set(ref, _extends({}, pending.data(), {
82
+ files: rest
83
+ }));
84
+ }
85
+
86
+ //# sourceMappingURL=quote-upload-keys.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/quote-upload-keys.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { isVisitorUploadSegment } from '@aglyn/aglyn/app-utils/visitor-upload'\n\n/*==========================================\n * A CUSTOMER'S FILES THAT WERE NEVER SENT (AGL-4096).\n *\n * The customer's artwork goes up before their answer does: `quote-upload`\n * keeps each file the moment it is finalized, and `quote-respond` names the\n * ones the answer carries. A customer who attaches a file and never sends\n * the answer leaves a file nobody will see, kept until the quote is deleted.\n *\n * So, as a form's files are (`forms/{formId}/formUploadKeys/{key}`), each one\n * is recorded as pending under its quote,\n * `hosts/{hostId}/orders/{quoteId}/quoteUploadKeys/{quoteId}`: the ticket\n * records its id before anything can be uploaded, the finalize records it\n * again, and `sweepAtMs` is a day after the latest of them. The answer takes\n * the files it sends off the record in the transaction that records the\n * answer, which is what keeps them. The console's sweep\n * (`quote-upload-sweep.ts`) deletes every file still on a record whose day\n * has passed, and the record with it.\n *\n * The record's id is the quote's, because the artwork's scope\n * (`quote-artwork/{quoteId}`) is the quote, as a form's files' scope is the\n * form's key. Only the Admin SDK reads or writes it: everything under an\n * order is closed to clients.\n *=========================================*/\n\n/** Where a quote's pending artwork is recorded, under the quote. */\nexport const QUOTE_UPLOAD_KEYS_COLLECTION = 'quoteUploadKeys'\n\n/** How long a file the customer attached may wait for their answer. */\nexport const QUOTE_UPLOAD_KEY_TTL_MS = 24 * 60 * 60 * 1000\n\n/** What the upload route knows of a quote's unsent artwork. */\nexport interface QuoteUploadKeyRecord {\n hostId: string\n quoteId: string\n /** Each pending file's id, and when it was last recorded. */\n files: Record<string, number>\n /** When the console's sweep may delete the files still pending. */\n sweepAtMs: number\n}\n\n/** The pending record of one quote's artwork. */\nexport function quoteUploadKeyRef(\n firestore: FirebaseFirestore.Firestore,\n hostId: string,\n quoteId: string,\n): FirebaseFirestore.DocumentReference {\n return firestore\n .collection('hosts')\n .doc(hostId)\n .collection('orders')\n .doc(quoteId)\n .collection(QUOTE_UPLOAD_KEYS_COLLECTION)\n .doc(quoteId)\n}\n\n/** The file ids a record holds, read defensively. */\nexport function quoteUploadKeyFiles(data: Record<string, unknown> | undefined): Record<string, number> {\n const files = (data?.['files'] ?? {}) as Record<string, unknown>\n return Object.fromEntries(\n Object.entries(files)\n .filter(([id]) => isVisitorUploadSegment(id))\n .map(([id, atMs]) => [id, Number(atMs) || 0]),\n )\n}\n\n/**\n * Records one of the customer's files as pending, and moves the record's\n * sweep to a day from now. A merge, so files recorded at once each keep\n * their own entry.\n */\nexport async function recordPendingQuoteUpload(\n firestore: FirebaseFirestore.Firestore,\n input: { hostId: string; quoteId: string; fileId: string; nowMs: number },\n): Promise<void> {\n const { hostId, quoteId, fileId, nowMs } = input\n if (!isVisitorUploadSegment(fileId)) return\n await quoteUploadKeyRef(firestore, hostId, quoteId).set(\n { hostId, quoteId, files: { [fileId]: nowMs }, sweepAtMs: nowMs + QUOTE_UPLOAD_KEY_TTL_MS },\n { merge: true },\n )\n}\n\n/**\n * In the answer's transaction, after every read: the files the answer sends\n * are kept, so they leave the pending record, and a record left with none\n * is deleted. `pending` is the record as the transaction read it.\n */\nexport function keepSentQuoteUploads(\n transaction: FirebaseFirestore.Transaction,\n pending: FirebaseFirestore.DocumentSnapshot,\n ref: FirebaseFirestore.DocumentReference,\n sentIds: readonly string[],\n): void {\n if (!pending.exists || !sentIds.length) return\n const files = quoteUploadKeyFiles(pending.data())\n const rest = Object.fromEntries(Object.entries(files).filter(([id]) => !sentIds.includes(id)))\n if (Object.keys(rest).length === Object.keys(files).length) return\n if (!Object.keys(rest).length) transaction.delete(ref)\n // The whole record, so the sent files leave the map rather than merge into it.\n else transaction.set(ref, { ...pending.data(), files: rest })\n}\n"],"names":["isVisitorUploadSegment","QUOTE_UPLOAD_KEYS_COLLECTION","QUOTE_UPLOAD_KEY_TTL_MS","quoteUploadKeyRef","firestore","hostId","quoteId","collection","doc","quoteUploadKeyFiles","data","files","Object","fromEntries","entries","filter","id","map","atMs","Number","recordPendingQuoteUpload","input","fileId","nowMs","set","sweepAtMs","merge","keepSentQuoteUploads","transaction","pending","ref","sentIds","exists","length","rest","includes","keys","delete"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,sBAAsB,QAAQ,wCAAuC;AAE9E;;;;;;;;;;;;;;;;;;;;;;2CAsB2C,GAE3C,kEAAkE,GAClE,OAAO,MAAMC,+BAA+B,kBAAiB;AAE7D,qEAAqE,GACrE,OAAO,MAAMC,0BAA0B,KAAK,KAAK,KAAK,KAAI;AAY1D,+CAA+C,GAC/C,OAAO,SAASC,kBACdC,SAAsC,EACtCC,MAAc,EACdC,OAAe;IAEf,OAAOF,UACJG,UAAU,CAAC,SACXC,GAAG,CAACH,QACJE,UAAU,CAAC,UACXC,GAAG,CAACF,SACJC,UAAU,CAACN,8BACXO,GAAG,CAACF;AACT;AAEA,mDAAmD,GACnD,OAAO,SAASG,oBAAoBC,IAAyC;;IAC3E,MAAMC,gBAASD,wBAAAA,IAAM,CAAC,QAAQ,mBAAI,CAAC;IACnC,OAAOE,OAAOC,WAAW,CACvBD,OAAOE,OAAO,CAACH,OACZI,MAAM,CAAC,CAAC,CAACC,GAAG,GAAKhB,uBAAuBgB,KACxCC,GAAG,CAAC,CAAC,CAACD,IAAIE,KAAK,GAAK;YAACF;YAAIG,OAAOD,SAAS;SAAE;AAElD;AAEA;;;;CAIC,GACD,OAAO,eAAeE,yBACpBhB,SAAsC,EACtCiB,KAAyE;IAEzE,MAAM,EAAEhB,MAAM,EAAEC,OAAO,EAAEgB,MAAM,EAAEC,KAAK,EAAE,GAAGF;IAC3C,IAAI,CAACrB,uBAAuBsB,SAAS;IACrC,MAAMnB,kBAAkBC,WAAWC,QAAQC,SAASkB,GAAG,CACrD;QAAEnB;QAAQC;QAASK,OAAO;YAAE,CAACW,OAAO,EAAEC;QAAM;QAAGE,WAAWF,QAAQrB;IAAwB,GAC1F;QAAEwB,OAAO;IAAK;AAElB;AAEA;;;;CAIC,GACD,OAAO,SAASC,qBACdC,WAA0C,EAC1CC,OAA2C,EAC3CC,GAAwC,EACxCC,OAA0B;IAE1B,IAAI,CAACF,QAAQG,MAAM,IAAI,CAACD,QAAQE,MAAM,EAAE;IACxC,MAAMtB,QAAQF,oBAAoBoB,QAAQnB,IAAI;IAC9C,MAAMwB,OAAOtB,OAAOC,WAAW,CAACD,OAAOE,OAAO,CAACH,OAAOI,MAAM,CAAC,CAAC,CAACC,GAAG,GAAK,CAACe,QAAQI,QAAQ,CAACnB;IACzF,IAAIJ,OAAOwB,IAAI,CAACF,MAAMD,MAAM,KAAKrB,OAAOwB,IAAI,CAACzB,OAAOsB,MAAM,EAAE;IAC5D,IAAI,CAACrB,OAAOwB,IAAI,CAACF,MAAMD,MAAM,EAAEL,YAAYS,MAAM,CAACP;SAE7CF,YAAYJ,GAAG,CAACM,KAAK,aAAKD,QAAQnB,IAAI;QAAIC,OAAOuB;;AACxD"}
@@ -0,0 +1,30 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import type { PluginConsoleCronReport } from '@aglyn/aglyn/plugin-manager/plugin-console-crons';
18
+ import type { QuoteUploadCalls } from './quote-uploads';
19
+ export declare const QUOTE_UPLOAD_SWEEP_JOB_ID = "commerce-quote-upload-sweep";
20
+ /** Records one tick sweeps. */
21
+ export declare const QUOTE_UPLOAD_SWEEP_BATCH = 100;
22
+ export interface QuoteUploadSweepDeps {
23
+ firestore(): FirebaseFirestore.Firestore;
24
+ uploads(): Promise<Pick<QuoteUploadCalls, 'remove'>>;
25
+ }
26
+ export declare function defaultQuoteUploadSweepDeps(): QuoteUploadSweepDeps;
27
+ export declare function sweepQuoteUploads(deps: QuoteUploadSweepDeps, context: {
28
+ nowMs: number;
29
+ deadlineMs: number;
30
+ }): Promise<PluginConsoleCronReport>;
@@ -0,0 +1,117 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ import { _ as _extends } from "@swc/helpers/_/_extends";
17
+ import { isVisitorUploadSegment, visitorUploadFinalPath } from "@aglyn/aglyn/app-utils/visitor-upload";
18
+ import { firebaseAdmin } from "@aglyn/tenant-data-admin/server/firebase-admin";
19
+ import { QUOTE_ARTWORK_PURPOSE } from "../model/commerce-quote-proofs.js";
20
+ import { QUOTE_UPLOAD_KEYS_COLLECTION, quoteUploadKeyFiles } from "./quote-upload-keys.js";
21
+ /*==========================================
22
+ * THE SWEEP OF A CUSTOMER'S UNSENT FILES (AGL-4096).
23
+ *
24
+ * Every pending record (`quote-upload-keys.ts`) whose day has passed: each
25
+ * file still on it is deleted, unless an answer on the quote holds it (its
26
+ * record outlived the answer, or a retried finalize put it back), and then
27
+ * the record goes. A file recorded on it while the sweep ran stays on it,
28
+ * with the sweep time that file set.
29
+ *
30
+ * Run every fifteen minutes by the console's plugin job runner, on the same
31
+ * tick as the forms plugin's sweep of forms never sent; each tick takes at
32
+ * most {@link QUOTE_UPLOAD_SWEEP_BATCH} records and starts none after its
33
+ * deadline. The query is a collection-group read on `sweepAtMs`, whose index
34
+ * is declared in `cloud/firebase-firestore.indexes.json`.
35
+ *=========================================*/ export const QUOTE_UPLOAD_SWEEP_JOB_ID = 'commerce-quote-upload-sweep';
36
+ /** Records one tick sweeps. */ export const QUOTE_UPLOAD_SWEEP_BATCH = 100;
37
+ const DOC_ID = /^(?!__.*__$)[^/]{1,200}$/;
38
+ export function defaultQuoteUploadSweepDeps() {
39
+ return {
40
+ firestore: ()=>firebaseAdmin.app().firestore(),
41
+ uploads: async ()=>(await import("./quote-uploads.js")).quoteUploadCalls()
42
+ };
43
+ }
44
+ /** The file ids the answers in a list send. */ function sentFileIds(responses) {
45
+ if (!Array.isArray(responses)) return [];
46
+ return responses.flatMap((response)=>Array.isArray(response == null ? void 0 : response.files) ? response.files.map((file)=>{
47
+ var _ref;
48
+ return String((_ref = file == null ? void 0 : file.id) != null ? _ref : '');
49
+ }) : []);
50
+ }
51
+ /** Every file an answer on the quote holds: the current revision's, and every earlier one's. */ async function heldQuoteUploads(quoteRef) {
52
+ const [quote, revisions] = await Promise.all([
53
+ quoteRef.get(),
54
+ quoteRef.collection('revisions').get()
55
+ ]);
56
+ return new Set([
57
+ ...sentFileIds(quote.get('quote.responses')),
58
+ ...revisions.docs.flatMap((revision)=>sentFileIds(revision.get('responses')))
59
+ ]);
60
+ }
61
+ export async function sweepQuoteUploads(deps, context) {
62
+ const firestore = deps.firestore();
63
+ const due = await firestore.collectionGroup(QUOTE_UPLOAD_KEYS_COLLECTION).where('sweepAtMs', '<=', context.nowMs).orderBy('sweepAtMs').limit(QUOTE_UPLOAD_SWEEP_BATCH).get();
64
+ let swept = 0;
65
+ let kept = 0;
66
+ let failed = 0;
67
+ const uploads = due.empty ? null : await deps.uploads();
68
+ for (const record of due.docs){
69
+ var _record_get;
70
+ if (Date.now() > context.deadlineMs) break;
71
+ const hostId = String((_record_get = record.get('hostId')) != null ? _record_get : '');
72
+ const quoteId = record.id;
73
+ try {
74
+ const pending = quoteUploadKeyFiles(record.data());
75
+ if (uploads && DOC_ID.test(hostId) && DOC_ID.test(quoteId) && Object.keys(pending).length) {
76
+ const held = await heldQuoteUploads(firestore.collection('hosts').doc(hostId).collection('orders').doc(quoteId));
77
+ const scope = {
78
+ hostId,
79
+ purpose: QUOTE_ARTWORK_PURPOSE,
80
+ purposeId: quoteId
81
+ };
82
+ for (const id of Object.keys(pending)){
83
+ if (held.has(id)) kept += 1;
84
+ else if (isVisitorUploadSegment(id)) {
85
+ await uploads.remove(visitorUploadFinalPath(scope, id));
86
+ swept += 1;
87
+ }
88
+ }
89
+ }
90
+ // The record goes, but a file recorded on it since it was read stays.
91
+ await firestore.runTransaction(async (transaction)=>{
92
+ const fresh = await transaction.get(record.ref);
93
+ if (!fresh.exists) return;
94
+ const left = Object.entries(quoteUploadKeyFiles(fresh.data())).filter(([id, atMs])=>pending[id] !== atMs);
95
+ if (!left.length) transaction.delete(record.ref);
96
+ else transaction.set(record.ref, _extends({}, fresh.data(), {
97
+ files: Object.fromEntries(left)
98
+ }));
99
+ });
100
+ } catch (error) {
101
+ failed += 1;
102
+ console.error('[commerce] an unsent quote file was not swept', {
103
+ hostId,
104
+ quoteId,
105
+ error
106
+ });
107
+ }
108
+ }
109
+ return {
110
+ due: due.size,
111
+ swept,
112
+ kept,
113
+ failed
114
+ };
115
+ }
116
+
117
+ //# sourceMappingURL=quote-upload-sweep.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/quote-upload-sweep.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { PluginConsoleCronReport } from '@aglyn/aglyn/plugin-manager/plugin-console-crons'\nimport { isVisitorUploadSegment, visitorUploadFinalPath } from '@aglyn/aglyn/app-utils/visitor-upload'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { QUOTE_ARTWORK_PURPOSE } from '../model/commerce-quote-proofs'\nimport type { QuoteCustomerResponse } from '../model/commerce-quotes'\nimport { QUOTE_UPLOAD_KEYS_COLLECTION, quoteUploadKeyFiles } from './quote-upload-keys'\nimport type { QuoteUploadCalls } from './quote-uploads'\n\n/*==========================================\n * THE SWEEP OF A CUSTOMER'S UNSENT FILES (AGL-4096).\n *\n * Every pending record (`quote-upload-keys.ts`) whose day has passed: each\n * file still on it is deleted, unless an answer on the quote holds it (its\n * record outlived the answer, or a retried finalize put it back), and then\n * the record goes. A file recorded on it while the sweep ran stays on it,\n * with the sweep time that file set.\n *\n * Run every fifteen minutes by the console's plugin job runner, on the same\n * tick as the forms plugin's sweep of forms never sent; each tick takes at\n * most {@link QUOTE_UPLOAD_SWEEP_BATCH} records and starts none after its\n * deadline. The query is a collection-group read on `sweepAtMs`, whose index\n * is declared in `cloud/firebase-firestore.indexes.json`.\n *=========================================*/\n\nexport const QUOTE_UPLOAD_SWEEP_JOB_ID = 'commerce-quote-upload-sweep'\n\n/** Records one tick sweeps. */\nexport const QUOTE_UPLOAD_SWEEP_BATCH = 100\n\nconst DOC_ID = /^(?!__.*__$)[^/]{1,200}$/\n\nexport interface QuoteUploadSweepDeps {\n firestore(): FirebaseFirestore.Firestore\n uploads(): Promise<Pick<QuoteUploadCalls, 'remove'>>\n}\n\nexport function defaultQuoteUploadSweepDeps(): QuoteUploadSweepDeps {\n return {\n firestore: () => firebaseAdmin.app().firestore(),\n uploads: async () => (await import('./quote-uploads')).quoteUploadCalls(),\n }\n}\n\n/** The file ids the answers in a list send. */\nfunction sentFileIds(responses: unknown): string[] {\n if (!Array.isArray(responses)) return []\n return (responses as QuoteCustomerResponse[]).flatMap((response) =>\n Array.isArray(response?.files) ? response.files.map((file) => String(file?.id ?? '')) : [],\n )\n}\n\n/** Every file an answer on the quote holds: the current revision's, and every earlier one's. */\nasync function heldQuoteUploads(quoteRef: FirebaseFirestore.DocumentReference): Promise<Set<string>> {\n const [quote, revisions] = await Promise.all([quoteRef.get(), quoteRef.collection('revisions').get()])\n return new Set([\n ...sentFileIds(quote.get('quote.responses')),\n ...revisions.docs.flatMap((revision) => sentFileIds(revision.get('responses'))),\n ])\n}\n\nexport async function sweepQuoteUploads(\n deps: QuoteUploadSweepDeps,\n context: { nowMs: number; deadlineMs: number },\n): Promise<PluginConsoleCronReport> {\n const firestore = deps.firestore()\n const due = await firestore\n .collectionGroup(QUOTE_UPLOAD_KEYS_COLLECTION)\n .where('sweepAtMs', '<=', context.nowMs)\n .orderBy('sweepAtMs')\n .limit(QUOTE_UPLOAD_SWEEP_BATCH)\n .get()\n let swept = 0\n let kept = 0\n let failed = 0\n const uploads = due.empty ? null : await deps.uploads()\n for (const record of due.docs) {\n if (Date.now() > context.deadlineMs) break\n const hostId = String(record.get('hostId') ?? '')\n const quoteId = record.id\n try {\n const pending = quoteUploadKeyFiles(record.data())\n if (uploads && DOC_ID.test(hostId) && DOC_ID.test(quoteId) && Object.keys(pending).length) {\n const held = await heldQuoteUploads(firestore.collection('hosts').doc(hostId).collection('orders').doc(quoteId))\n const scope = { hostId, purpose: QUOTE_ARTWORK_PURPOSE, purposeId: quoteId }\n for (const id of Object.keys(pending)) {\n if (held.has(id)) kept += 1\n else if (isVisitorUploadSegment(id)) {\n await uploads.remove(visitorUploadFinalPath(scope, id))\n swept += 1\n }\n }\n }\n // The record goes, but a file recorded on it since it was read stays.\n await firestore.runTransaction(async (transaction) => {\n const fresh = await transaction.get(record.ref)\n if (!fresh.exists) return\n const left = Object.entries(quoteUploadKeyFiles(fresh.data())).filter(([id, atMs]) => pending[id] !== atMs)\n if (!left.length) transaction.delete(record.ref)\n else transaction.set(record.ref, { ...fresh.data(), files: Object.fromEntries(left) })\n })\n } catch (error) {\n failed += 1\n console.error('[commerce] an unsent quote file was not swept', { hostId, quoteId, error })\n }\n }\n return { due: due.size, swept, kept, failed }\n}\n"],"names":["isVisitorUploadSegment","visitorUploadFinalPath","firebaseAdmin","QUOTE_ARTWORK_PURPOSE","QUOTE_UPLOAD_KEYS_COLLECTION","quoteUploadKeyFiles","QUOTE_UPLOAD_SWEEP_JOB_ID","QUOTE_UPLOAD_SWEEP_BATCH","DOC_ID","defaultQuoteUploadSweepDeps","firestore","app","uploads","quoteUploadCalls","sentFileIds","responses","Array","isArray","flatMap","response","files","map","file","String","id","heldQuoteUploads","quoteRef","quote","revisions","Promise","all","get","collection","Set","docs","revision","sweepQuoteUploads","deps","context","due","collectionGroup","where","nowMs","orderBy","limit","swept","kept","failed","empty","record","Date","now","deadlineMs","hostId","quoteId","pending","data","test","Object","keys","length","held","doc","scope","purpose","purposeId","has","remove","runTransaction","transaction","fresh","ref","exists","left","entries","filter","atMs","delete","set","fromEntries","error","console","size"],"mappings":"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"}