-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathairtable-extension-anexo2.js
More file actions
189 lines (174 loc) · 8.82 KB
/
Copy pathairtable-extension-anexo2.js
File metadata and controls
189 lines (174 loc) · 8.82 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
/**
* ═══════════════════════════════════════════════════════════════════════════
* AIRTABLE SCRIPTING EXTENSION — Generar Informe de Vulnerabilitat
* POST /anexo2
* ═══════════════════════════════════════════════════════════════════════════
*
* Aquest script viu a una Scripting Extension d'una Dashboard d'Airtable
* (el que es veu com "Dashboard 1 → generar"). El voluntari selecciona una
* fila de la taula `Informes de Vulnerabilitat` i el script crida el Worker
* per generar el PDF.
*
* Diferent de `airtable-automation.js`:
* - Aquell és una Automation (button → run script).
* - Aquest és una Scripting Extension (UI interactiva al dashboard).
*
* SETUP:
* 1. Dashboard → Add an extension → Scripting
* 2. Edit code → enganxa aquest codi
* 3. Constants WORKER_URL i SHARED_SECRET (valors de wrangler)
* 4. Run → seleccionar fila → genera el PDF
*
* IMPORTANT: si modifiques aquest fitxer al repo, recorda copiar-lo
* manualment a l'extensió — no hi ha sync automàtic.
*
* ─────────────────────────────────────────────────────────────────────────
*/
// ⚠️ Canvia aquests dos valors abans de desar ⚠️
const WORKER_URL = "https://reus-refugi-pdf-worker.YOUR-SUBDOMAIN.workers.dev";
const SHARED_SECRET = "PASTE-THE-SAME-SECRET-YOU-SET-IN-WRANGLER";
const TABLE_NAME = "Informes de Vulnerabilitat";
const PDF_FIELD = "Informe generat"; // multipleAttachments — INFORMES_VULN_PDF_FIELD
const TIMESTAMP_FIELD = "Generat el"; // dateTime — INFORMES_VULN_GENERATED_AT_FIELD
const table = base.getTable(TABLE_NAME);
const record = await input.recordAsync("Selecciona fila", table);
if (!record) {
output.text("Cap fila seleccionada.");
return;
}
output.markdown(`⏳ Generant informe per **${record.name}**...`);
// Marquem el moment de la sol·licitud per a la verificació post-fail.
// Si el fetch falla però el worker va completar la generació mentre la
// resposta es perdia (Cloudflare timeout, browser tancant la connexió,
// CORS bloqueja el body...), Airtable tindrà el PDF i `Generat el` >
// requestStartedAt — el detectem i tractem com a èxit.
const requestStartedAt = Date.now() - 5000; // marge de 5s pel clock skew
let result;
try {
result = await callWorkerWithRetry(`${WORKER_URL}/anexo2`, SHARED_SECRET, {
recordId: record.id,
});
} catch (err) {
output.markdown(`⚠️ Cap resposta del worker — verifico Airtable...`);
const verified = await verifyGeneratedAfter(table, record.id, requestStartedAt);
if (verified) {
result = { ok: true, ...verified };
} else {
throw err;
}
}
output.markdown(`✅ **${result.filename}** (${result.sizeBytes} bytes)`);
// ─────────────────────────────────────────────────────────────────────────
// verifyGeneratedAfter — comprova si el PDF ja està a Airtable.
//
// El worker fa la feina (Airtable upload) ABANS de retornar la resposta
// HTTP. Si la resposta es perd pel camí (browser tanca, Cloudflare 524,
// CORS bloqueja body, etc.) el PDF està igualment a Airtable. Aquesta
// funció rellegeix la fila i, si "Generat el" és posterior a `sinceMs`
// i l'attachment hi és, retorna les dades — així el script reporta èxit
// enlloc de fail enganyós.
// ─────────────────────────────────────────────────────────────────────────
async function verifyGeneratedAfter(table, recordId, sinceMs) {
try {
const fresh = await table.selectRecordAsync(recordId);
if (!fresh) return null;
const ts = fresh.getCellValue(TIMESTAMP_FIELD);
if (!ts) return null;
const tsMs = new Date(ts).getTime();
if (!isFinite(tsMs) || tsMs < sinceMs) return null;
const att = fresh.getCellValue(PDF_FIELD);
if (!Array.isArray(att) || att.length === 0) return null;
return { filename: att[0].filename, sizeBytes: att[0].size };
} catch {
return null;
}
}
// ─────────────────────────────────────────────────────────────────────────
// callWorkerWithRetry — retry transparent en 429 / 5xx / non-JSON.
//
// Backoff: 5s, 15s, 35s (3 retries màxim, ~55s totals). Pensat per
// outlast el lockout de 30s d'Airtable per burst (5 req/sec per base) i
// els Cloudflare 524 intermitents. Bumpejat des de 2s/4s/8s perquè el
// lockout d'Airtable és 30s i amb retries curts no s'aconseguia mai.
// ─────────────────────────────────────────────────────────────────────────
async function callWorkerWithRetry(url, secret, body) {
const delays = [5000, 15000, 35000];
let lastErr;
for (let attempt = 0; attempt <= delays.length; attempt++) {
try {
return await callWorker(url, secret, body);
} catch (err) {
lastErr = err;
const msg = String(err.message || err);
const retryable =
/ HTTP 429\b/.test(msg) ||
/ HTTP 5\d\d\b/.test(msg) ||
/non-JSON/.test(msg) ||
/empty body/.test(msg) ||
/Network error/.test(msg);
if (!retryable || attempt === delays.length) throw err;
output.markdown(`⏳ Reintent ${attempt + 1}/${delays.length} en ${delays[attempt]/1000}s...`);
await new Promise((r) => setTimeout(r, delays[attempt]));
}
}
throw lastErr;
}
// ─────────────────────────────────────────────────────────────────────────
// callWorker — POST JSON i parseja la resposta amb missatges d'error útils.
//
// Per què cal: feien `await response.json()` directament i si el cos no
// era JSON (Cloudflare 1xxx HTML error page, timeout 524, body buit per
// fetch avortat...) saltava "SyntaxError: JSON.parse: unexpected character
// at line 1 column 1" sense pista de la causa real. Aquí llegim text →
// parsegem amb try/catch → re-llencem amb el contingut real perquè es
// vegi al panell d'error de l'extensió.
//
// Possibles causes intermitents per /anexo2 (per record):
// - Bursts paral·lels de voluntaris → 429 d'Airtable + 30s retry > timeout
// del fetch del browser de l'extensió.
// - Worker excedeix CPU/wall-time per renders pesats.
// - Cloudflare Worker cold start + 524.
// ─────────────────────────────────────────────────────────────────────────
async function callWorker(url, secret, body) {
// POST "simple" — no dispara CORS preflight perquè:
// - Content-Type: text/plain (els 3 valors "simples" són text/plain,
// application/x-www-form-urlencoded i multipart/form-data)
// - cap Authorization header (Authorization sempre dispara preflight)
//
// El secret va dins el body JSON. Necessari per a Scripting Extensions
// de Dashboards d'Airtable, on el sandbox de l'iframe rebutja preflights
// cap a *.workers.dev silenciosament → fetch() rejecta amb NetworkError
// indistingible d'un DNS fail.
let response;
try {
response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "text/plain" },
body: JSON.stringify({ ...body, secret }),
});
} catch (err) {
throw new Error(`Network error calling worker: ${err.message || err}`);
}
const text = await response.text();
if (!text) {
throw new Error(
`Worker returned empty body (HTTP ${response.status} ${response.statusText}). ` +
`Likely Cloudflare timeout or worker crash — check Workers logs.`,
);
}
let parsed;
try {
parsed = JSON.parse(text);
} catch {
const snippet = text.slice(0, 300).replace(/\s+/g, " ").trim();
throw new Error(
`Worker returned non-JSON (HTTP ${response.status}). ` +
`First 300 chars: ${snippet}`,
);
}
if (!response.ok || !parsed.ok) {
const detail = parsed.error || parsed.message || response.statusText;
throw new Error(`Worker error (HTTP ${response.status}): ${detail}`);
}
return parsed;
}