# Generazione PDF DVR — Piano di implementazione

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Permettere il download del PDF di un DVR sempre allineato ai dati, generato con Chromium (design approvato) fuori da Vercel, con cache su R2 per hash dei dati.

**Architecture:** I PDF stanno su R2, uno per agenzia, etichettati con un hash del contenuto del DVR. Al click l'app confronta l'hash corrente con quello salvato: se uguale scarica da R2, se diverso innesca una GitHub Action (Chromium/Puppeteer) che rigenera, carica su R2 e aggiorna lo stato. UX asincrona con polling.

**Tech Stack:** SvelteKit 2 / Svelte 5, Drizzle ORM, Turso (libsql), Cloudflare R2 (@aws-sdk/client-s3), Puppeteer (solo lato GitHub Actions / locale), vitest (nuovo, per la logica pura).

## Global Constraints

- **Vercel Hobby**: niente Chromium su Vercel (limite 10s). La generazione PDF gira SOLO su GitHub Actions o in locale. Il codice Puppeteer NON deve finire nel bundle delle funzioni Vercel.
- **Fedeltà**: si riusa l'HTML/CSS approvato (`pdf-prototype/template.js`). Nessun cambio al design.
- **Motore remoto**: GitHub Actions, repo `paoloalby/alleanza-dvr`, trigger `repository_dispatch`.
- **Chiave R2 PDF**: `pdf/{codiceFisico}_DVR.pdf`. **Nome file scaricato**: `{codiceFisico}_DVR.pdf`.
- **Gemelle**: il PDF usa il contenuto del DVR risolto via `resolveDvrAgenziaId()` (compagna) ma anagrafica/codice/nome-file propri della gemella.
- **Fingerprint = basata sul contenuto** (le tabelle figlie non hanno `updated_at`): hash deterministico dei valori delle righe rilevanti + `TEMPLATE_VERSION`. Nessuna modifica agli endpoint autosave.
- **Copy UI in italiano.**
- **Commit frequenti**, uno per task. Migrazioni SQL in `app/migrations/` applicate a mano (sqlite3 locale + `turso db shell` prod), come da prassi del progetto.

---

## File Structure

**Nuovi:**
- `app/src/lib/server/db/schema.ts` (MODIFICA) — tabella `dvrPdf`.
- `app/migrations/2026-06-24-phase3-dvr-pdf.sql` — `CREATE TABLE dvr_pdf`.
- `app/src/lib/server/pdf/fingerprint.ts` — caricamento input + hash contenuto DVR (framework-agnostico, riusabile dallo script di generazione).
- `app/src/lib/server/pdf/state.ts` — derivazione dello stato PDF (pura) + lettura/scrittura `dvr_pdf` lato app.
- `app/src/lib/server/pdf/dispatch.ts` — trigger della GitHub Action via API.
- `app/src/routes/api/dvr/[id]/pdf/+server.ts` — endpoint: stato (polling), download, trigger.
- `app/src/lib/components/PdfButton.svelte` — controllo UI (stati + polling).
- `app/scripts/pdf/generate.ts` — script di generazione (Turso + R2 + Puppeteer), per Action e seed.
- `app/scripts/pdf/template.ts` — porting di `pdf-prototype/template.js` (immutato nel layout; cambia solo la fonte immagini).
- `.github/workflows/generate-pdf.yml` — workflow Chromium.
- `app/vitest.config.ts`, `app/src/lib/server/pdf/*.test.ts` — test della logica pura.

**Modificati:**
- `app/src/routes/agenzie/[id]/+page.svelte` e `+page.server.ts` — usano `PdfButton` + stato PDF.
- `app/src/routes/agenzie/+page.svelte` e `+page.server.ts` — icona PDF in elenco.
- `app/package.json` — devDeps: `vitest`, `puppeteer`, `tsx`; script `test`, `pdf:gen`.

---

## Task 1: Tabella `dvr_pdf` (schema + migrazione)

**Files:**
- Modify: `app/src/lib/server/db/schema.ts`
- Create: `app/migrations/2026-06-24-phase3-dvr-pdf.sql`

**Interfaces:**
- Produces: tabella/Drizzle `dvrPdf` con colonne `agenziaId` (PK, FK agenzie.id), `r2Key`, `dataHash`, `stato` (`'ready'|'generating'|'error'`), `generatoIl`, `errorMsg`, `updatedAt`.

- [ ] **Step 1: Aggiungi la tabella allo schema Drizzle**

In `app/src/lib/server/db/schema.ts`, dopo la definizione di `dvr` (e relative figlie), aggiungi:

```ts
export const dvrPdf = sqliteTable('dvr_pdf', {
	agenziaId: integer('agenzia_id')
		.primaryKey()
		.references(() => agenzie.id, { onDelete: 'cascade' }),
	r2Key: text('r2_key'),
	dataHash: text('data_hash'),
	stato: text('stato', { enum: ['ready', 'generating', 'error'] }),
	generatoIl: text('generato_il'),
	errorMsg: text('error_msg'),
	updatedAt: text('updated_at')
		.notNull()
		.default(sql`CURRENT_TIMESTAMP`)
});
```

- [ ] **Step 2: Scrivi la migrazione SQL**

Crea `app/migrations/2026-06-24-phase3-dvr-pdf.sql`:

```sql
-- 2026-06-24 Fase 3 — tabella stato PDF per agenzia (cache PDF su R2)
CREATE TABLE IF NOT EXISTS dvr_pdf (
  agenzia_id  INTEGER PRIMARY KEY REFERENCES agenzie(id) ON DELETE CASCADE,
  r2_key      TEXT,
  data_hash   TEXT,
  stato       TEXT CHECK (stato IN ('ready','generating','error')),
  generato_il TEXT,
  error_msg   TEXT,
  updated_at  TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
```

- [ ] **Step 3: Applica a locale e verifica**

```bash
cd "/Users/paolo/Server/Siti Web/Lavoro/Inlumia/Alleanza"
sqlite3 import-tools/db/dev.db < app/migrations/2026-06-24-phase3-dvr-pdf.sql
sqlite3 import-tools/db/dev.db ".schema dvr_pdf"
```
Atteso: lo schema della tabella stampato senza errori.

- [ ] **Step 4: Applica a prod e verifica**

```bash
turso db shell alleanza-dvr-prod < app/migrations/2026-06-24-phase3-dvr-pdf.sql
printf "SELECT name FROM sqlite_master WHERE type='table' AND name='dvr_pdf';\n" | turso db shell alleanza-dvr-prod
```
Atteso: stampa `dvr_pdf`.

- [ ] **Step 5: Type-check**

Run: `cd app && npm run check`
Expected: 0 errori (warning preesistenti ammessi).

- [ ] **Step 6: Commit**

```bash
git add app/src/lib/server/db/schema.ts app/migrations/2026-06-24-phase3-dvr-pdf.sql
git commit -m "PDF: tabella dvr_pdf (stato/cache PDF per agenzia)"
```

---

## Task 2: Fingerprint del contenuto DVR (+ setup vitest)

**Files:**
- Create: `app/src/lib/server/pdf/fingerprint.ts`
- Create: `app/vitest.config.ts`
- Create: `app/src/lib/server/pdf/fingerprint.test.ts`
- Modify: `app/package.json` (devDep `vitest`, script `test`)

**Interfaces:**
- Produces:
  - `export const TEMPLATE_VERSION = 'v11'`
  - `export type FingerprintInput = { templateVersion: string; agenzia: Record<string, unknown>; dvr: Record<string, unknown>; pericoli: unknown[]; valutazioni: unknown[]; misure: unknown[]; incendio: unknown[]; piano: unknown[]; attivita: unknown[]; allegati: unknown[]; settings: Record<string, unknown> }`
  - `export function hashFingerprintInput(input: FingerprintInput): string` (sha256 hex, deterministico, indipendente dall'ordine delle chiavi)
  - `export async function loadFingerprintInput(client: Client, agenziaId: number): Promise<FingerprintInput>` (`client` = `@libsql/client` Client; risolve la gemella via la stessa logica di `resolveDvrAgenziaId`)
  - `export async function computeDvrFingerprint(client: Client, agenziaId: number): Promise<string>`
- Consumes: `@libsql/client` `Client`, modulo `node:crypto`.

- [ ] **Step 1: Installa vitest e aggiungi lo script**

```bash
cd app && npm install -D vitest
```
In `app/package.json` aggiungi a `scripts`: `"test": "vitest run"`.

- [ ] **Step 2: Config vitest**

Crea `app/vitest.config.ts`:

```ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
	test: {
		include: ['src/**/*.test.ts'],
		environment: 'node'
	}
});
```

- [ ] **Step 3: Scrivi il test della funzione pura `hashFingerprintInput`**

Crea `app/src/lib/server/pdf/fingerprint.test.ts`:

```ts
import { describe, it, expect } from 'vitest';
import { hashFingerprintInput, type FingerprintInput } from './fingerprint';

const base: FingerprintInput = {
	templateVersion: 'v11',
	agenzia: { id: 1, codiceFisico: '22399', nomeAgenzia: 'ABANO TERME' },
	dvr: { revCorrente: 2 },
	pericoli: [{ id: 1, colD: 'S' }],
	valutazioni: [], misure: [], incendio: [], piano: [], attivita: [],
	allegati: [{ id: 9, tipo: 'foto_immobile' }],
	settings: { rspp_nome: 'X' }
};

describe('hashFingerprintInput', () => {
	it('è deterministico per lo stesso input', () => {
		expect(hashFingerprintInput(base)).toBe(hashFingerprintInput(structuredClone(base)));
	});
	it('non dipende dall’ordine delle chiavi', () => {
		const reordered = { ...base, settings: { rspp_nome: 'X' }, agenzia: { nomeAgenzia: 'ABANO TERME', codiceFisico: '22399', id: 1 } };
		expect(hashFingerprintInput(reordered as FingerprintInput)).toBe(hashFingerprintInput(base));
	});
	it('cambia se cambia un valore del DVR', () => {
		const changed = structuredClone(base); changed.dvr.revCorrente = 3;
		expect(hashFingerprintInput(changed)).not.toBe(hashFingerprintInput(base));
	});
	it('cambia se cambia il template version', () => {
		const changed = structuredClone(base); changed.templateVersion = 'v12';
		expect(hashFingerprintInput(changed)).not.toBe(hashFingerprintInput(base));
	});
});
```

- [ ] **Step 4: Esegui il test e verifica che fallisca**

Run: `cd app && npx vitest run src/lib/server/pdf/fingerprint.test.ts`
Expected: FAIL (modulo `./fingerprint` inesistente).

- [ ] **Step 5: Implementa `fingerprint.ts`**

Crea `app/src/lib/server/pdf/fingerprint.ts`:

```ts
import { createHash } from 'node:crypto';
import type { Client } from '@libsql/client';

export const TEMPLATE_VERSION = 'v11';

export type FingerprintInput = {
	templateVersion: string;
	agenzia: Record<string, unknown>;
	dvr: Record<string, unknown>;
	pericoli: unknown[];
	valutazioni: unknown[];
	misure: unknown[];
	incendio: unknown[];
	piano: unknown[];
	attivita: unknown[];
	allegati: unknown[];
	settings: Record<string, unknown>;
};

// Serializzazione stabile (chiavi ordinate) → hash riproducibile a prescindere dall'ordine.
function stableStringify(value: unknown): string {
	if (Array.isArray(value)) return '[' + value.map(stableStringify).join(',') + ']';
	if (value && typeof value === 'object') {
		const keys = Object.keys(value as Record<string, unknown>).sort();
		return '{' + keys.map((k) => JSON.stringify(k) + ':' + stableStringify((value as Record<string, unknown>)[k])).join(',') + '}';
	}
	return JSON.stringify(value ?? null);
}

export function hashFingerprintInput(input: FingerprintInput): string {
	return createHash('sha256').update(stableStringify(input)).digest('hex');
}

async function rows(client: Client, sql: string, args: unknown[]): Promise<Record<string, unknown>[]> {
	const r = await client.execute({ sql, args: args as never });
	return r.rows as unknown as Record<string, unknown>[];
}

// Stessa regola di $lib/server/dvr.resolveDvrAgenziaId, ma via client libsql diretto.
async function resolveDvrAgenziaId(client: Client, agenziaId: number): Promise<number> {
	const [a] = await rows(client, 'SELECT dvr_condiviso_da_id AS shared FROM agenzie WHERE id = ?', [agenziaId]);
	return (a?.shared as number) ?? agenziaId;
}

export async function loadFingerprintInput(client: Client, agenziaId: number): Promise<FingerprintInput> {
	const dvrAgenziaId = await resolveDvrAgenziaId(client, agenziaId);
	const [agenzia] = await rows(client,
		`SELECT codice_fisico, codice_immobile, codice_agenzia, tipologia, nome_agenzia, nome_sede,
		        indirizzo, cap, citta, provincia, latitudine, longitudine, piano, mq,
		        datore_lavoro, rspp, zona_sismica_id, rischio_frane_id, rischio_alluvioni_id, dvr_condiviso_da_id
		 FROM agenzie WHERE id = ?`, [agenziaId]);
	const [dvr] = await rows(client, `SELECT * FROM dvr WHERE agenzia_id = ?`, [dvrAgenziaId]);
	const dvrId = (dvr?.id as number) ?? -1;
	const pericoli = await rows(client, `SELECT id, punto_id, col_c_lookup_id, col_c_override, col_d, col_e, col_f_lookup_id, col_f_override FROM dvr_pericoli WHERE dvr_id = ? ORDER BY id`, [dvrId]);
	const valutazioni = await rows(client, `SELECT id, elemento_id, figure_esposte, rischi_descr, misure_prevenzione_lookup_id, misure_prevenzione_override, p, g FROM dvr_valutazione WHERE dvr_id = ? ORDER BY id`, [dvrId]);
	const misure = await rows(client, `SELECT id, valutazione_id, lookup_id, testo_override, p_var, g_var, r_var, ordine FROM dvr_valutazione_misure WHERE valutazione_id IN (SELECT id FROM dvr_valutazione WHERE dvr_id = ?) ORDER BY id`, [dvrId]);
	const incendio = await rows(client, `SELECT id, documento_id, stato_lookup_id, stato_override, note FROM dvr_incendio_doc WHERE dvr_id = ? ORDER BY id`, [dvrId]);
	const piano = await rows(client, `SELECT id, attivita_lookup_id, attivita_override, responsabile, data_avvio, data_conclusione, ordine FROM dvr_piano_miglioramento WHERE dvr_id = ? ORDER BY id`, [dvrId]);
	const attivita = await rows(client, `SELECT id, source_type, source_id, famiglia_id, variante_scelta_id, stato FROM dvr_attivita_miglioramento WHERE dvr_id = ? ORDER BY id`, [dvrId]);
	const allegati = await rows(client, `SELECT id, tipo, storage_path, excluded FROM agenzie_allegati WHERE agenzia_id = ? ORDER BY id`, [agenziaId]);
	const [settings] = await rows(client, `SELECT datore_lavoro_nome, datore_lavoro_qualifica, rspp_nome, rspp_qualifica FROM app_settings WHERE id = 1`, []);
	return {
		templateVersion: TEMPLATE_VERSION,
		agenzia: agenzia ?? {}, dvr: dvr ?? {}, pericoli, valutazioni, misure,
		incendio, piano, attivita, allegati, settings: settings ?? {}
	};
}

export async function computeDvrFingerprint(client: Client, agenziaId: number): Promise<string> {
	return hashFingerprintInput(await loadFingerprintInput(client, agenziaId));
}
```

> Nota: gli allegati entrano nell'hash via `id`+`tipo`+`storage_path`+`excluded`. La sostituzione di un'immagine cambia `storage_path` (vedi `buildKey`), quindi l'hash cambia. Se in futuro una sostituzione riusasse la stessa chiave, aggiungere alla SELECT una colonna di versione/sha dell'allegato.

- [ ] **Step 6: Esegui i test e verifica che passino**

Run: `cd app && npx vitest run src/lib/server/pdf/fingerprint.test.ts`
Expected: 4 test PASS.

- [ ] **Step 7: Commit**

```bash
git add app/src/lib/server/pdf/fingerprint.ts app/src/lib/server/pdf/fingerprint.test.ts app/vitest.config.ts app/package.json app/package-lock.json
git commit -m "PDF: fingerprint del contenuto DVR + setup vitest"
```

---

## Task 3: Derivazione dello stato PDF

**Files:**
- Create: `app/src/lib/server/pdf/state.ts`
- Create: `app/src/lib/server/pdf/state.test.ts`

**Interfaces:**
- Consumes: `computeDvrFingerprint` (Task 2), `db` da `$lib/server/db`, `dvrPdf` schema (Task 1).
- Produces:
  - `export type PdfState = 'ready' | 'stale' | 'generating' | 'error' | 'missing'`
  - `export function derivePdfState(row: { stato: string|null; dataHash: string|null } | null, currentHash: string): PdfState`
  - `export type PdfStatus = { state: PdfState; currentHash: string; generatoIl: string|null; r2Key: string|null; errorMsg: string|null }`
  - `export async function getPdfStatus(agenziaId: number): Promise<PdfStatus>`
  - `export async function markGenerating(agenziaId: number, currentHash: string): Promise<void>`

- [ ] **Step 1: Scrivi il test della funzione pura `derivePdfState`**

Crea `app/src/lib/server/pdf/state.test.ts`:

```ts
import { describe, it, expect } from 'vitest';
import { derivePdfState } from './state';

describe('derivePdfState', () => {
	const H = 'abc';
	it('missing se non c’è riga', () => expect(derivePdfState(null, H)).toBe('missing'));
	it('generating se in corso', () => expect(derivePdfState({ stato: 'generating', dataHash: 'old' }, H)).toBe('generating'));
	it('error se errore', () => expect(derivePdfState({ stato: 'error', dataHash: 'old' }, H)).toBe('error'));
	it('ready se hash combacia', () => expect(derivePdfState({ stato: 'ready', dataHash: H }, H)).toBe('ready'));
	it('stale se ready ma hash diverso', () => expect(derivePdfState({ stato: 'ready', dataHash: 'old' }, H)).toBe('stale'));
	it('missing se ready ma senza hash', () => expect(derivePdfState({ stato: 'ready', dataHash: null }, H)).toBe('missing'));
});
```

- [ ] **Step 2: Esegui e verifica fallimento**

Run: `cd app && npx vitest run src/lib/server/pdf/state.test.ts`
Expected: FAIL (modulo inesistente).

- [ ] **Step 3: Implementa `state.ts`**

Crea `app/src/lib/server/pdf/state.ts`:

```ts
import { createClient } from '@libsql/client';
import { env } from '$env/dynamic/private';
import { db } from '$lib/server/db';
import { dvrPdf } from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';
import { computeDvrFingerprint } from './fingerprint';

export type PdfState = 'ready' | 'stale' | 'generating' | 'error' | 'missing';

export function derivePdfState(
	row: { stato: string | null; dataHash: string | null } | null,
	currentHash: string
): PdfState {
	if (!row || !row.stato) return 'missing';
	if (row.stato === 'generating') return 'generating';
	if (row.stato === 'error') return 'error';
	if (row.stato === 'ready') {
		if (!row.dataHash) return 'missing';
		return row.dataHash === currentHash ? 'ready' : 'stale';
	}
	return 'missing';
}

export type PdfStatus = {
	state: PdfState;
	currentHash: string;
	generatoIl: string | null;
	r2Key: string | null;
	errorMsg: string | null;
};

function fpClient() {
	return createClient({
		url: env.DATABASE_URL ?? 'file:../import-tools/db/dev.db',
		authToken: env.DATABASE_AUTH_TOKEN
	});
}

export async function getPdfStatus(agenziaId: number): Promise<PdfStatus> {
	const client = fpClient();
	const currentHash = await computeDvrFingerprint(client, agenziaId);
	const [row] = await db.select().from(dvrPdf).where(eq(dvrPdf.agenziaId, agenziaId));
	return {
		state: derivePdfState(row ?? null, currentHash),
		currentHash,
		generatoIl: row?.generatoIl ?? null,
		r2Key: row?.r2Key ?? null,
		errorMsg: row?.errorMsg ?? null
	};
}

export async function markGenerating(agenziaId: number, currentHash: string): Promise<void> {
	const now = new Date().toISOString();
	await db
		.insert(dvrPdf)
		.values({ agenziaId, stato: 'generating', updatedAt: now })
		.onConflictDoUpdate({
			target: dvrPdf.agenziaId,
			set: { stato: 'generating', updatedAt: now, errorMsg: null }
		});
}
```

- [ ] **Step 4: Esegui e verifica passaggio**

Run: `cd app && npx vitest run src/lib/server/pdf/state.test.ts`
Expected: 6 test PASS.

- [ ] **Step 5: Type-check**

Run: `cd app && npm run check`
Expected: 0 errori.

- [ ] **Step 6: Commit**

```bash
git add app/src/lib/server/pdf/state.ts app/src/lib/server/pdf/state.test.ts
git commit -m "PDF: derivazione stato (ready/stale/generating/error/missing)"
```

---

## Task 4: Script di generazione (Turso + R2 + Puppeteer)

**Files:**
- Create: `app/scripts/pdf/template.ts` (porting di `pdf-prototype/template.js`)
- Create: `app/scripts/pdf/generate.ts`
- Modify: `app/package.json` (devDeps `puppeteer`, `tsx`; script `pdf:gen`)

**Interfaces:**
- Consumes: `loadFingerprintInput`/`computeDvrFingerprint` (Task 2), `dvrPdf` (Task 1).
- Produces: eseguibile `npx tsx scripts/pdf/generate.ts <agenziaId|all>` che genera il/i PDF, li carica su R2 (`pdf/{codice}_DVR.pdf`) e aggiorna `dvr_pdf` (`stato='ready'`, `data_hash`, `generato_il`, `r2_key`) o `stato='error'`.
- **Variabili d'ambiente richieste:** `DATABASE_URL`, `DATABASE_AUTH_TOKEN`, `R2_ACCOUNT_ID`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_BUCKET`.

- [ ] **Step 1: Installa le dipendenze (solo dev: non finiranno nel bundle Vercel)**

```bash
cd app && npm install -D puppeteer tsx
```
In `app/package.json` aggiungi a `scripts`: `"pdf:gen": "tsx scripts/pdf/generate.ts"`.

- [ ] **Step 2: Evita il download di Chromium sul build Vercel**

Su Vercel (pannello del progetto → Environment Variables) aggiungi `PUPPETEER_SKIP_DOWNLOAD=true` per tutti gli ambienti, così `npm install` in fase di build Vercel non scarica Chromium (non serve: la generazione non gira su Vercel). Annotalo anche in `infrastruttura_alleanza` (memoria). In CI (GitHub Action) NON impostarla, così Chromium viene scaricato.

- [ ] **Step 3: Porta il template**

Copia `pdf-prototype/template.js` in `app/scripts/pdf/template.ts` esportando `renderHtml(data)` come prima. **Unica modifica funzionale**: le immagini non si leggono più dal filesystem locale. Sostituisci `fileToDataUri(path.join(ROOT, allegato.storage_path))` con una funzione che riceve già i byte dell'immagine (base64) precaricati: cambia la firma in modo che `renderHtml(data)` usi `allegato.dataUri` già valorizzato a monte (lo riempie `generate.ts` scaricando da R2). Mantieni identici layout, CSS, sezioni, matrice. Adatta i `require` in `import` TypeScript.

- [ ] **Step 4: Implementa `generate.ts`**

Crea `app/scripts/pdf/generate.ts`:

```ts
import { createClient, type Client } from '@libsql/client';
import { S3Client, GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
import puppeteer from 'puppeteer';
import { renderHtml } from './template';
import { loadFingerprintInput, hashFingerprintInput } from '../../src/lib/server/pdf/fingerprint';

const DB_URL = process.env.DATABASE_URL!;
const DB_TOKEN = process.env.DATABASE_AUTH_TOKEN!;
const r2 = new S3Client({
	region: 'auto',
	endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
	credentials: { accessKeyId: process.env.R2_ACCESS_KEY_ID!, secretAccessKey: process.env.R2_SECRET_ACCESS_KEY! }
});
const BUCKET = process.env.R2_BUCKET!;

async function r2GetBase64(key: string): Promise<string | null> {
	try {
		const o = await r2.send(new GetObjectCommand({ Bucket: BUCKET, Key: key }));
		const buf = Buffer.from(await o.Body!.transformToByteArray());
		const mime = key.endsWith('.png') ? 'image/png' : 'image/jpeg';
		return `data:${mime};base64,${buf.toString('base64')}`;
	} catch { return null; }
}

// Carica i dati per il render (riusa le stesse query di load.js, ma da Turso e con
// resolveDvrAgenziaId per le gemelle). Per brevità, fattorizza una loadDvrData(client, agenziaId)
// che ritorna { agenzia, dvr, pericoli, valutazioni, valutazioniMisure, incendioDoc, attivita, allegati, settings }
// con la STESSA forma usata da pdf-prototype/load.js (l'origine immagini diventa R2).
import { loadDvrData } from './data'; // vedi Step 5

async function generateOne(client: Client, agenziaId: number): Promise<void> {
	const data = await loadDvrData(client, agenziaId);
	// precarica immagini da R2 → dataUri
	for (const a of data.allegati) {
		const key = a.storage_path.replace(/^storage\//, '');
		a.dataUri = (await r2GetBase64(key)) ?? '';
	}
	const html = renderHtml(data);
	const browser = await puppeteer.launch({ headless: 'new', args: ['--no-sandbox'] });
	try {
		const page = await browser.newPage();
		await page.emulateMediaType('print');
		await page.setContent(html, { waitUntil: 'load' });
		const pdf = await page.pdf({ format: 'A4', printBackground: true, preferCSSPageSize: true });
		const codice = data.agenzia.codice_fisico as string;
		const key = `pdf/${codice}_DVR.pdf`;
		await r2.send(new PutObjectCommand({ Bucket: BUCKET, Key: key, Body: pdf, ContentType: 'application/pdf' }));
		const hash = hashFingerprintInput(await loadFingerprintInput(client, agenziaId));
		await client.execute({
			sql: `INSERT INTO dvr_pdf(agenzia_id, r2_key, data_hash, stato, generato_il, error_msg, updated_at)
			      VALUES (?,?,?,?,?,NULL,?)
			      ON CONFLICT(agenzia_id) DO UPDATE SET r2_key=excluded.r2_key, data_hash=excluded.data_hash,
			        stato='ready', generato_il=excluded.generato_il, error_msg=NULL, updated_at=excluded.updated_at`,
			args: [agenziaId, key, hash, 'ready', new Date().toISOString(), new Date().toISOString()]
		});
		console.log(`OK ${agenziaId} → ${key}`);
	} finally {
		await browser.close();
	}
}

async function main() {
	const arg = process.argv[2];
	const client = createClient({ url: DB_URL, authToken: DB_TOKEN });
	if (arg === 'all') {
		const r = await client.execute(`SELECT id FROM agenzie WHERE deleted_at IS NULL ORDER BY id`);
		let n = 0;
		for (const row of r.rows) {
			const id = row.id as number;
			try { await generateOne(client, id); }
			catch (e) {
				await client.execute({ sql: `INSERT INTO dvr_pdf(agenzia_id,stato,error_msg,updated_at) VALUES (?,?,?,?)
					ON CONFLICT(agenzia_id) DO UPDATE SET stato='error', error_msg=excluded.error_msg, updated_at=excluded.updated_at`,
					args: [id, 'error', String(e), new Date().toISOString()] });
				console.error(`ERR ${id}: ${e}`);
			}
			console.log(`-- ${++n}/${r.rows.length}`);
		}
	} else {
		const id = Number(arg);
		try { await generateOne(client, id); }
		catch (e) {
			await client.execute({ sql: `INSERT INTO dvr_pdf(agenzia_id,stato,error_msg,updated_at) VALUES (?,?,?,?)
				ON CONFLICT(agenzia_id) DO UPDATE SET stato='error', error_msg=excluded.error_msg, updated_at=excluded.updated_at`,
				args: [id, 'error', String(e), new Date().toISOString()] });
			console.error(`ERR ${id}: ${e}`); process.exit(1);
		}
	}
}
main();
```

- [ ] **Step 5: Fattorizza `loadDvrData` da `pdf-prototype/load.js`**

Crea `app/scripts/pdf/data.ts` portando le query di `pdf-prototype/load.js` su `@libsql/client` (async), aggiungendo all'inizio `const dvrAgenziaId = await resolveDvrAgenziaId(client, agenziaId)` e usando `dvrAgenziaId` per le query che partono da `dvr.agenzia_id`, mentre `agenzia` e `allegati` restano sull'`agenziaId` proprio (anagrafica/codice/nome-file della gemella; allegati per-sede). Tipizza il ritorno con un campo `dataUri?: string` su ogni allegato.

- [ ] **Step 6: Genera 1 PDF in locale puntando a prod (verifica end-to-end)**

```bash
cd app
DATABASE_URL='libsql://alleanza-dvr-prod-paoloalby.aws-eu-west-1.turso.io' \
DATABASE_AUTH_TOKEN='<token>' R2_ACCOUNT_ID='6164844dd7d12a5c2b03a6b5d2ba26c3' \
R2_ACCESS_KEY_ID='<id>' R2_SECRET_ACCESS_KEY='<secret>' R2_BUCKET='alleanza-dvr-allegati' \
npx tsx scripts/pdf/generate.ts 467
```
Atteso: `OK 467 → pdf/10899_DVR.pdf`. Verifica su R2 che l'oggetto esista e che `dvr_pdf` per 467 sia `ready` con `data_hash` valorizzato. Scarica il PDF e confrontalo col riferimento Chromium approvato (deve combaciare).

- [ ] **Step 7: Verifica che il bundle Vercel non includa Puppeteer**

Run: `cd app && npm run build`
Expected: build OK; nessun aumento anomalo; Puppeteer non referenziato dalle route (è importato solo da `scripts/`, non bundlato).

- [ ] **Step 8: Commit**

```bash
git add app/scripts/pdf/ app/package.json app/package-lock.json
git commit -m "PDF: script di generazione Chromium (Turso+R2) per Action e seed"
```

---

## Task 5: Trigger della GitHub Action

**Files:**
- Create: `app/src/lib/server/pdf/dispatch.ts`
- Create: `app/src/lib/server/pdf/dispatch.test.ts`

**Interfaces:**
- Produces: `export async function triggerPdfGeneration(agenziaId: number): Promise<void>` — chiama `POST https://api.github.com/repos/paoloalby/alleanza-dvr/dispatches` con `event_type: 'generate-dvr-pdf'`, `client_payload: { agenzia_id }`, header `Authorization: Bearer ${GITHUB_DISPATCH_TOKEN}`, `Accept: application/vnd.github+json`.
- **Env richiesta su Vercel:** `GITHUB_DISPATCH_TOKEN` (PAT fine-grained con permesso "Contents: read & write" o "Actions: read & write" sul repo).

- [ ] **Step 1: Test che costruisca la richiesta corretta (mock fetch)**

Crea `app/src/lib/server/pdf/dispatch.test.ts`:

```ts
import { describe, it, expect, vi, beforeEach } from 'vitest';

vi.mock('$env/dynamic/private', () => ({ env: { GITHUB_DISPATCH_TOKEN: 'tok' } }));

describe('triggerPdfGeneration', () => {
	beforeEach(() => vi.restoreAllMocks());
	it('POSTa il repository_dispatch con il payload giusto', async () => {
		const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 204 });
		vi.stubGlobal('fetch', fetchMock);
		const { triggerPdfGeneration } = await import('./dispatch');
		await triggerPdfGeneration(467);
		const [url, opts] = fetchMock.mock.calls[0];
		expect(url).toBe('https://api.github.com/repos/paoloalby/alleanza-dvr/dispatches');
		expect(opts.method).toBe('POST');
		expect(JSON.parse(opts.body)).toEqual({ event_type: 'generate-dvr-pdf', client_payload: { agenzia_id: 467 } });
		expect(opts.headers.Authorization).toBe('Bearer tok');
	});
});
```

- [ ] **Step 2: Esegui e verifica fallimento**

Run: `cd app && npx vitest run src/lib/server/pdf/dispatch.test.ts`
Expected: FAIL (modulo inesistente).

- [ ] **Step 3: Implementa `dispatch.ts`**

```ts
import { env } from '$env/dynamic/private';

const REPO = 'paoloalby/alleanza-dvr';

export async function triggerPdfGeneration(agenziaId: number): Promise<void> {
	const token = env.GITHUB_DISPATCH_TOKEN;
	if (!token) throw new Error('GITHUB_DISPATCH_TOKEN mancante');
	const res = await fetch(`https://api.github.com/repos/${REPO}/dispatches`, {
		method: 'POST',
		headers: {
			Authorization: `Bearer ${token}`,
			Accept: 'application/vnd.github+json',
			'Content-Type': 'application/json',
			'User-Agent': 'alleanza-dvr'
		},
		body: JSON.stringify({ event_type: 'generate-dvr-pdf', client_payload: { agenzia_id: agenziaId } })
	});
	if (!res.ok) throw new Error(`Dispatch fallito: ${res.status}`);
}
```

- [ ] **Step 4: Esegui e verifica passaggio**

Run: `cd app && npx vitest run src/lib/server/pdf/dispatch.test.ts`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add app/src/lib/server/pdf/dispatch.ts app/src/lib/server/pdf/dispatch.test.ts
git commit -m "PDF: trigger GitHub Action via repository_dispatch"
```

---

## Task 6: Endpoint stato/download/trigger

**Files:**
- Create: `app/src/routes/api/dvr/[id]/pdf/+server.ts`

**Interfaces:**
- Consumes: `getPdfStatus`, `markGenerating` (Task 3), `triggerPdfGeneration` (Task 5), `getObject` da `$lib/server/r2`.
- Produces:
  - `GET ?mode=status` → `200 { state, generatoIl }`
  - `GET` (default, download) → se `ready`: stream del PDF da R2 con `Content-Disposition: attachment; filename="{codice}_DVR.pdf"`; altrimenti `409 { state }`. Se `state==='stale'` ma esiste un `r2_key` precedente, consente comunque il download della versione precedente con `?allowStale=1`.
  - `POST` → se non già `generating`: `markGenerating` + `triggerPdfGeneration`; ritorna `202 { state: 'generating' }`.

- [ ] **Step 1: Implementa l'endpoint**

```ts
import { error, json } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { agenzie, dvrPdf } from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';
import { getPdfStatus, markGenerating } from '$lib/server/pdf/state';
import { triggerPdfGeneration } from '$lib/server/pdf/dispatch';
import { getObject } from '$lib/server/r2';

export const GET = async ({ params, url }) => {
	const id = parseInt(params.id, 10);
	if (Number.isNaN(id)) throw error(400, 'ID non valido');
	const status = await getPdfStatus(id);
	if (url.searchParams.get('mode') === 'status') {
		return json({ state: status.state, generatoIl: status.generatoIl });
	}
	// download
	const canServe = status.state === 'ready' || (url.searchParams.get('allowStale') === '1' && status.r2Key);
	if (!canServe || !status.r2Key) return json({ state: status.state }, { status: 409 });
	const [a] = await db.select({ codice: agenzie.codiceFisico }).from(agenzie).where(eq(agenzie.id, id));
	const obj = await getObject(status.r2Key);
	if (!obj) return json({ state: 'missing' }, { status: 409 });
	return new Response(obj.body, {
		headers: {
			'Content-Type': 'application/pdf',
			'Content-Disposition': `attachment; filename="${a?.codice ?? 'dvr'}_DVR.pdf"`
		}
	});
};

export const POST = async ({ params }) => {
	const id = parseInt(params.id, 10);
	if (Number.isNaN(id)) throw error(400, 'ID non valido');
	const status = await getPdfStatus(id);
	if (status.state === 'generating') return json({ state: 'generating' }, { status: 202 });
	await markGenerating(id, status.currentHash);
	await triggerPdfGeneration(id);
	return json({ state: 'generating' }, { status: 202 });
};
```

> Adatta la lettura del corpo da `getObject` alla forma effettiva del suo ritorno (vedi `app/src/lib/server/r2.ts` e l'uso in `routes/api/allegati/[id]/+server.ts`): riusa lo stesso pattern di streaming già presente lì.

- [ ] **Step 2: Type-check**

Run: `cd app && npm run check`
Expected: 0 errori.

- [ ] **Step 3: Verifica manuale (dev server, dato locale)**

```bash
cd app && npm run dev
```
Con un'agenzia che ha già un PDF in R2 (dopo Task 4 Step 6): `GET /api/dvr/467/pdf?mode=status` → `{ state: 'ready' | 'stale' }`. `GET /api/dvr/467/pdf` → scarica il PDF. Per una gemella (es. 806) lo stato deve riflettere il DVR della compagna.

- [ ] **Step 4: Commit**

```bash
git add "app/src/routes/api/dvr/[id]/pdf/+server.ts"
git commit -m "PDF: endpoint stato/download/trigger"
```

---

## Task 7: UI — pulsante PDF (dettaglio + elenco)

**Files:**
- Create: `app/src/lib/components/PdfButton.svelte`
- Modify: `app/src/routes/agenzie/[id]/+page.server.ts` (passa lo stato PDF)
- Modify: `app/src/routes/agenzie/[id]/+page.svelte` (usa `PdfButton` nella sezione DVR)
- Modify: `app/src/routes/agenzie/+page.server.ts` e `+page.svelte` (icona nell'elenco)

**Interfaces:**
- Consumes: endpoint Task 6, `getPdfStatus` (Task 3) nel load del dettaglio.

- [ ] **Step 1: Componente `PdfButton.svelte`**

Componente Svelte 5 (runes) che riceve `agenziaId: number`, `state: PdfState`, `generatoIl: string|null`. Comportamento:
- `ready` → link/bottone "Scarica PDF" (+ data) → `GET /api/dvr/{id}/pdf`.
- `stale` → bottone "Aggiorna PDF" → `POST /api/dvr/{id}/pdf`, poi passa a `generating` + avvia polling; mostra anche "scarica versione precedente" (`?allowStale=1`).
- `generating` → spinner "PDF in aggiornamento, pronto tra ~1 min"; polling `GET ?mode=status` ogni 12s; a `ready` abilita il download.
- `error` → "Errore nella generazione — riprova" (POST).
- `missing` → "Genera PDF" (POST).

```svelte
<script lang="ts">
	let { agenziaId, state: initialState, generatoIl } = $props<{ agenziaId: number; state: string; generatoIl: string | null }>();
	let state = $state(initialState);
	let timer: ReturnType<typeof setInterval> | null = null;

	async function trigger() {
		const r = await fetch(`/api/dvr/${agenziaId}/pdf`, { method: 'POST' });
		if (r.ok || r.status === 202) { state = 'generating'; startPolling(); }
	}
	function startPolling() {
		if (timer) return;
		timer = setInterval(async () => {
			const r = await fetch(`/api/dvr/${agenziaId}/pdf?mode=status`);
			const j = await r.json();
			if (j.state !== 'generating') { state = j.state; if (timer) { clearInterval(timer); timer = null; } }
		}, 12000);
	}
	$effect(() => () => { if (timer) clearInterval(timer); });
</script>

{#if state === 'ready'}
	<a href={`/api/dvr/${agenziaId}/pdf`} class="inline-flex items-center gap-1.5 rounded-md bg-brand-500 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-600">Scarica PDF</a>
	{#if generatoIl}<span class="ml-2 text-xs text-slate-500">generato il {generatoIl.slice(0,10)}</span>{/if}
{:else if state === 'generating'}
	<span class="inline-flex items-center gap-2 text-sm text-slate-600"><span class="animate-spin">↻</span> PDF in aggiornamento, pronto tra ~1 min…</span>
	<a href={`/api/dvr/${agenziaId}/pdf?allowStale=1`} class="ml-2 text-xs text-slate-500 underline">scarica versione precedente</a>
{:else if state === 'stale'}
	<button onclick={trigger} class="inline-flex items-center gap-1.5 rounded-md bg-amber-500 px-4 py-2 text-sm font-semibold text-white">Aggiorna PDF</button>
	<a href={`/api/dvr/${agenziaId}/pdf?allowStale=1`} class="ml-2 text-xs text-slate-500 underline">scarica versione precedente</a>
{:else if state === 'error'}
	<button onclick={trigger} class="rounded-md bg-red-500 px-4 py-2 text-sm font-semibold text-white">Errore — riprova</button>
{:else}
	<button onclick={trigger} class="rounded-md bg-brand-500 px-4 py-2 text-sm font-semibold text-white">Genera PDF</button>
{/if}
```

- [ ] **Step 2: Passa lo stato PDF nel load del dettaglio**

In `app/src/routes/agenzie/[id]/+page.server.ts`, importa `getPdfStatus` e aggiungi al return: `pdfStatus: await getPdfStatus(id)` (oggetto `{ state, generatoIl }` minimo).

- [ ] **Step 3: Usa `PdfButton` nel dettaglio**

In `app/src/routes/agenzie/[id]/+page.svelte`, dentro la sezione DVR, sostituisci/affianca il vecchio link "Apri DVR" con `<PdfButton agenziaId={a.id} state={data.pdfStatus.state} generatoIl={data.pdfStatus.generatoIl} />` (import del componente in testa).

- [ ] **Step 4: Icona nell'elenco**

In `app/src/routes/agenzie/+page.server.ts` la colonna `hasDvr` resta; aggiungi (query leggera) lo stato PDF se vuoi un'icona attiva, oppure nell'elenco mostra solo un link diretto `/api/dvr/{id}/pdf` con icona PDF (il click scarica se ready, altrimenti l'endpoint risponde 409 → in elenco basta l'icona che apre il dettaglio). Scelta MVP: in elenco mostra un'icona PDF che linka al **dettaglio** (dove c'è il `PdfButton` completo), per non moltiplicare le query di stato sull'elenco.

- [ ] **Step 5: Type-check + verifica visiva (Playwright su dev o prod dopo deploy)**

Run: `cd app && npm run check` → 0 errori.
Verifica: dettaglio di un'agenzia `ready` mostra "Scarica PDF"; di una `stale` mostra "Aggiorna PDF"; il click su Aggiorna passa a "in aggiornamento" e fa polling.

- [ ] **Step 6: Commit**

```bash
git add app/src/lib/components/PdfButton.svelte "app/src/routes/agenzie/[id]/+page.server.ts" "app/src/routes/agenzie/[id]/+page.svelte" app/src/routes/agenzie/+page.server.ts app/src/routes/agenzie/+page.svelte
git commit -m "PDF: UI pulsante con stati e polling (dettaglio + elenco)"
```

---

## Task 8: GitHub Action di generazione

**Files:**
- Create: `.github/workflows/generate-pdf.yml`

**Interfaces:**
- Consumes: `npx tsx scripts/pdf/generate.ts` (Task 4); secrets repo `DATABASE_URL`, `DATABASE_AUTH_TOKEN`, `R2_ACCOUNT_ID`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_BUCKET`.

- [ ] **Step 1: Workflow**

```yaml
name: Genera PDF DVR
on:
  repository_dispatch:
    types: [generate-dvr-pdf]
  workflow_dispatch:
    inputs:
      agenzia_id:
        description: 'ID agenzia oppure "all"'
        required: true
        default: 'all'
jobs:
  generate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20', cache: 'npm', cache-dependency-path: app/package-lock.json }
      - name: Install deps
        working-directory: app
        run: npm ci
      - name: Genera PDF
        working-directory: app
        env:
          DATABASE_URL: ${{ secrets.DATABASE_URL }}
          DATABASE_AUTH_TOKEN: ${{ secrets.DATABASE_AUTH_TOKEN }}
          R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
          R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
          R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
          R2_BUCKET: ${{ secrets.R2_BUCKET }}
        run: npx tsx scripts/pdf/generate.ts "${{ github.event.client_payload.agenzia_id || github.event.inputs.agenzia_id }}"
```

- [ ] **Step 2: Configura i secrets del repo**

Su GitHub (repo → Settings → Secrets and variables → Actions) aggiungi i 6 secrets sopra con i valori da `infrastruttura_alleanza` (memoria).

- [ ] **Step 3: Test manuale del workflow**

Su GitHub → Actions → "Genera PDF DVR" → Run workflow → `agenzia_id = 467`. Verifica run verde, oggetto R2 aggiornato, `dvr_pdf` 467 `ready`. Misura la durata (atteso ~1-2 min incluso setup).

- [ ] **Step 4: Commit**

```bash
git add .github/workflows/generate-pdf.yml
git commit -m "PDF: workflow GitHub Actions (Chromium) su repository_dispatch"
```

---

## Task 9: Token dispatch, deploy, seed iniziale, verifica end-to-end

**Files:** nessuno (configurazione + esecuzione).

- [ ] **Step 1: PAT GitHub + env Vercel**

Crea un PAT fine-grained (repo `alleanza-dvr`, permesso "Contents: read & write"). Su Vercel aggiungi `GITHUB_DISPATCH_TOKEN=<pat>` (+ `PUPPETEER_SKIP_DOWNLOAD=true` da Task 4). Redeploy.

- [ ] **Step 2: Push & deploy del codice app**

```bash
git push origin main
```
Attendi il deploy Vercel; verifica che `/login` risponda 200 (build ok).

- [ ] **Step 3: Seed iniziale di tutti i PDF**

Lancia il workflow con `agenzia_id = all` (o in locale `npx tsx scripts/pdf/generate.ts all` con le env). Attendi il completamento; verifica conteggio:
```bash
printf "SELECT stato, COUNT(*) FROM dvr_pdf GROUP BY stato;\n" | turso db shell alleanza-dvr-prod
```
Atteso: ~806 `ready`, 0 (o pochi tracciati) `error`.

- [ ] **Step 4: Verifica end-to-end su Vercel (Playwright)**

- Dettaglio di un'agenzia normale → "Scarica PDF" → scarica `{codice}_DVR.pdf` corretto.
- Modifica un campo del DVR (autosave) → torna al dettaglio → stato `stale` → "Aggiorna PDF" → `generating` → dopo ~1-2 min → `ready` con PDF aggiornato.
- Gemella (es. ASCOLI PICENO EST `39902`) → il PDF riflette il DVR della compagna, con nome file `39902_DVR.pdf`.

- [ ] **Step 5: Aggiorna la memoria infrastruttura**

Annota in `infrastruttura_alleanza` (memoria): `GITHUB_DISPATCH_TOKEN` su Vercel, `PUPPETEER_SKIP_DOWNLOAD=true`, i 6 secrets Actions, il workflow `generate-pdf.yml`, la convenzione chiave R2 `pdf/{codice}_DVR.pdf`.

- [ ] **Step 6: Commit finale (eventuali aggiustamenti)**

```bash
git add -A && git commit -m "PDF: configurazione finale, seed e verifica end-to-end"
git push origin main
```

---

## Note di esecuzione

- **Ordine consigliato**: 1→2→3 (logica app), 4 (generazione, verificabile in locale subito), 5→6→7 (trigger/endpoint/UI), 8→9 (Action + seed).
- **TDD**: test unitari sulle funzioni pure (`hashFingerprintInput`, `derivePdfState`, costruzione richiesta dispatch). Generazione/Action/endpoint/UI verificate con esecuzione reale (niente unit test su Puppeteer/R2/rete).
- **Sicurezza credenziali**: token e secret NON vanno committati; stanno solo su Vercel/GitHub Secrets.
