Costruire una piattaforma di Data API
L'idea è semplice: scrapare dati pubblicamente disponibili da fonti legali, strutturarli e vendere l'accesso tramite una API REST. Si paga per chiamata. Niente abbonamenti per iniziare: compri un pack di crediti e cominci a fare query.
La piattaforma sta nello stesso monorepo, sullo stesso server Hetzner. Due nuove app:
apps/dataplatform: frontend Next.js: landing page, dashboard, docs, pricing, billingapps/dataplatform-api: backend FastAPI: la data API vera, i job ETL, le query DuckDB
Perché questo stack
FastAPI + DuckDB per il backend, non Next.js API route e Prisma. Il motivo: il livello dati è analitico. Cercare decine di migliaia di record con filtri, aggregazioni e ricerca testuale è dove DuckDB brilla: è un motore OLAP embedded che gira dentro al processo Python e interroga storage colonnare velocemente. Niente round-trip di rete, niente overhead di ORM.
Due database, due ruoli:
| Database | Cosa ci vive | Perché |
|---|---|---|
| PostgreSQL (Prisma) | Utenti, API key, saldo crediti, log d'uso | Relazionale, transazionale, critico per l'auth |
| DuckDB (file) | Dataset: aziende, ricette | OLAP, append-heavy, ricerca filtrata veloce |
PostgreSQL gestisce tutto ciò che riguarda i soldi e l'auth. DuckDB gestisce il prodotto: i dati che vendiamo davvero.
Cloudflare R2 memorizza i transcript grezzi, il contenuto scrapato e gli snapshot Parquet giornalieri di ogni dataset. Economico, durabile, costo di egress zero. Permette di rielaborare i record vecchi se la logica di estrazione cambia, senza riscaricare la fonte.
Clerk per l'auth, già usato in altre app di questo monorepo. Pattern coerente: webhook su user.created fa upsert in PostgreSQL, la dashboard è protetta dal middleware Clerk.
Stripe per i pagamenti. Acquisti one-time di pack di crediti via Checkout Session: niente Stripe.js, niente abbonamenti per ora. Tre pack: 9€ per 1.000 chiamate, 29€ per 5.000, 99€ per 25.000.
Architettura
Browser
└── apps/dataplatform (Next.js, porta 3003)
├── /dashboard → pagine protette da Clerk
├── /docs → riferimento API
├── /pricing → pagina pricing
├── /api/keys → genera/revoca API key
├── /api/credits → saldo, storico, Stripe checkout
├── /api/webhooks/clerk → upsert utente al signup
├── /api/webhooks/stripe → aggiunge crediti al pagamento
├── /api/companies → proxy → FastAPI
└── /api/recipes → proxy → FastAPI
└── apps/dataplatform-api (FastAPI, porta 8002)
├── GET /api/v1/companies/search (auth API key, scala 1 credito)
├── GET /api/v1/companies/{id} (auth API key, scala 1 credito)
├── GET /api/v1/recipes/search (auth API key, scala 1 credito)
├── GET /api/v1/recipes/{id} (auth API key, scala 1 credito)
├── GET /api/v1/datasets (pubblico)
├── GET /api/v1/health (pubblico)
└── POST /api/v1/internal/* (solo header internal secret)
PostgreSQL ← porta 5434 in locale, gestito da Coolify in prod
DuckDB ← /app/data/companies.duckdb (volume Docker)
MinIO/R2 ← contenuto grezzo + snapshot Parquet
Il check dell'API key e la deduzione dei crediti vivono interamente in FastAPI: niente round-trip a Next.js al momento della query.
Flusso API key
Le chiavi hanno prefisso dp_live_ seguito da 32 caratteri hex. La chiave grezza viene mostrata una sola volta alla creazione. In PostgreSQL si salva solo l'hash SHA-256.
POST /api/keys
→ genera: dp_live_abc123...
→ salva: { keyHash: sha256(key), keyPrefix: "dp_live_abc1" }
→ ritorna la chiave grezza una volta sola
GET /api/v1/recipes/search?q=carbonara
X-API-Key: dp_live_abc123...
→ FastAPI: sha256(key) → lookup api_keys JOIN users
→ controlla credit_balance >= 1
→ esegue la query DuckDB
→ UPDATE users SET credit_balance = credit_balance - 1
WHERE id = $1 AND credit_balance >= 1 ← atomico, race-safe
→ INSERT INTO usage_logs
→ ritorna i risultati
Il WHERE credit_balance >= 1 sull'UPDATE è atomico: non serve un lock di transazione per evitare double-spend. La deduzione avviene dopo una query riuscita: niente addebito su risposte 404 o 500.
Stripe
Il flusso di billing usa Stripe Checkout Session: un redirect server-side alla pagina di pagamento ospitata da Stripe. Niente Stripe.js, niente iframe, niente form di pagamento frontend da mantenere.
L'utente clicca "Buy" sulla pagina di billing
→ POST /api/credits/checkout { pack: "5k" }
→ stripe.checkout.sessions.create({
mode: "payment",
line_items: [{ price_data: { unit_amount: 2900, currency: "eur" } }],
metadata: { clerk_user_id, pack },
success_url: "/dashboard/billing?success=1&pack=5k",
cancel_url: "/dashboard/billing?canceled=1",
})
→ window.location.href = session.url (redirect a Stripe)
L'utente paga → Stripe spara il webhook checkout.session.completed
→ POST /api/webhooks/stripe
→ constructEvent() verifica la firma
→ if payment_status === "paid":
prisma.$transaction([
user.update({ creditBalance: { increment: 5000 } }),
creditTransaction.create({ type: "purchase", ... })
])
L'utente atterra su /dashboard/billing?success=1&pack=5k
→ banner verde, saldo ricaricato
Perché Checkout Session e non PaymentIntent + Stripe.js: nessuna dipendenza JS frontend, Stripe gestisce 3DS e Apple/Google Pay in automatico, e passare agli abbonamenti dopo richiede solo di cambiare mode: "payment" in "subscription".
Dataset
Ricette
Gli utenti incollano un URL YouTube nella dashboard. L'app prende il transcript, lo manda a OpenAI gpt-4o-mini con uno schema di output strutturato e salva la ricetta parsata in DuckDB. Il transcript grezzo viene anche caricato su R2.
Il campo source è generico: youtube, web o qualsiasi fonte futura. L'URL è il riferimento al contenuto originale. Questo rende il dataset agnostico alla fonte: YouTube è solo il primo canale di ingestion.
Ogni record porta uno schema_version e un raw_extracted_json. Quando il prompt di estrazione o lo schema di output cambia, i vecchi record si possono rielaborare a partire dal transcript salvato su R2 senza dover riscaricare YouTube. Un endpoint POST /internal/reextract gestisce la ri-estrazione batch.
Schema ricetta: titolo, source, source_url, cucina, ingredienti (con quantità/unità), passaggi, porzioni, tempi prep/cottura, tag, thumbnail.
Aziende
Fonte: API pubblica OpenCorporates, alimentata dal registro CCIAA italiano. Il campo country è generico (IT, DE, FR...); l'Italia è solo la prima giurisdizione. L'ETL pagina a 100 aziende per richiesta, rate-limited a 200ms tra le richieste.
Nota: il free tier non restituisce i codici ATECO/settore. Città e regione sono disponibili. I record di ditte individuali possono contenere dati personali; l'uso deve rispettare il GDPR.
Seed iniziale: ~5.000 aziende (50 pagine). Refresh giornaliero via APScheduler alle 02:00 UTC.
Lavoro (futuro)
Lo schema DuckDB esiste (migrations/003_jobs.sql). L'endpoint interno di ingest è pronto. Ancora nessuno scraper attivo: il modello dati è fatto, la fonte non è decisa.
Migrazioni DuckDB
DuckDB non ha un equivalente di Prisma. Il migration runner è una piccola classe che legge file SQL numerati e tiene traccia delle versioni applicate in una tabella schema_versions:
class MigrationRunner:
def run(self):
self.conn.execute("""
CREATE TABLE IF NOT EXISTS schema_versions (
version INTEGER PRIMARY KEY,
name VARCHAR NOT NULL,
applied_at TIMESTAMP DEFAULT now()
)
""")
applied = {r[0] for r in self.conn.execute(
"SELECT version FROM schema_versions"
).fetchall()}
for sql_file in sorted(Path(__file__).parent.glob("*.sql")):
version = int(sql_file.stem.split("_")[0])
if version not in applied:
self.conn.execute(sql_file.read_text())
self.conn.execute(
"INSERT INTO schema_versions(version, name) VALUES (?, ?)",
[version, sql_file.stem]
)
Parte all'avvio di FastAPI, prima dello scheduler. Idempotente, sicuro a ogni restart.
| File | Cosa aggiunge |
|---|---|
001_initial.sql | Tabella companies, etl_runs, indici |
002_schema_versioning.sql | schema_version + raw_extracted_json su recipes, schema_versions_log |
003_jobs.sql | Tabella jobs (dataset futuro) |
004_etl_resume.sql | rows_failed, last_page su etl_runs per scraping ripristinabile |
Affidabilità ETL
Il primo istinto è di costruire da zero retry logic, tracciamento dei fallimenti e monitoring. Non farlo. Due opzioni migliori:
Opzione A, Prefect (consigliata quando hai 2+ scraper attivi e vuoi visibilità):
Prefect è l'alternativa leggera ad Airflow. Python-native, decoratori @flow e @task, retry integrato con backoff esponenziale, una UI self-hosted che mostra storico delle run e log per task.
from prefect import flow, task
from prefect.tasks import exponential_backoff
@task(retries=3, retry_delay_seconds=exponential_backoff(backoff_factor=2))
async def fetch_page(client, page: int) -> list[dict]:
... # solleva su 429/timeout → Prefect ritenta in automatico
@flow(name="companies-etl", log_prints=True)
async def companies_etl(pages: int = 50):
async with httpx.AsyncClient() as client:
for page in range(1, pages + 1):
rows = await fetch_page(client, page)
await upsert_to_duckdb(rows)
Self-hosted su Coolify: prefect server start (UI) + prefect worker start (executor). ~300MB di RAM in totale, niente Redis, niente database extra.
Opzione B, cron + tenacity + Uptime Kuma (per ora, con uno scraper):
tenacity gestisce il retry con un decoratore. La tabella etl_runs in DuckDB tiene lo storico delle run. Dopo ogni run riuscita, parte un ping di heartbeat verso Uptime Kuma (già in funzione su questa piattaforma). Se l'heartbeat non arriva entro 25 ore, Uptime Kuma avvisa.
from tenacity import retry, stop_after_attempt, wait_exponential
@retry(stop=stop_after_attempt(4), wait=wait_exponential(min=5, max=120))
async def fetch_page(client, page):
resp = await client.get(url, params=...)
if resp.status_code == 429:
await asyncio.sleep(int(resp.headers.get("Retry-After", 60)))
resp.raise_for_status()
resp.raise_for_status()
return resp.json()
Dead man's switch in Uptime Kuma: crea un Push monitor con intervallo di 25h. Il job ETL lo pinga al successo. Se il job crasha, si pianta o il server si riavvia, niente ping, alert automatico.
Scelta attuale: cron + tenacity per ora. Si passa a Prefect quando ci saranno più dataset che girano su schedule e vorrò vedere cosa è fallito senza fare SSH al server.
Architettura ETL: embedded o progetto separato
L'ETL al momento vive dentro apps/dataplatform-api: scraper in app/services/etl/, schedulato via APScheduler nello stesso processo FastAPI. Va bene per il giorno uno.
Un apps/dataplatform-etl separato avrà senso più avanti perché:
- Isolamento dei crash: uno scraper che fallisce non impatta il processo API
- Separazione delle scritture DuckDB: l'API legge solo DuckDB; solo l'ETL ci scrive. Processi separati: ETL apre in read-write, API apre in read-only. Niente lock di threading necessario.
apps/dataplatform-etl/
pyproject.toml
etl/
companies.py # OpenCorporates → DuckDB
recipes.py # YouTube → OpenAI → DuckDB
run.py # python run.py companies --pages 50
Stesso volume Docker dell'API (/app/data), nessuna porta HTTP, gira su schedule. Estrai quando hai 2+ scraper attivi o lo scheduler interferisce con i tempi di risposta dell'API.
Schema Prisma (PostgreSQL)
model User {
id String @id // ID utente Clerk
creditBalance Int @default(0)
apiKeys ApiKey[]
transactions CreditTransaction[]
usageLogs UsageLog[]
}
model ApiKey {
id String @id @default(cuid())
userId String
keyHash String @unique // SHA-256, mai salvare la grezza
keyPrefix String // "dp_live_abc1" per la UI
name String
usageCount Int @default(0)
lastUsedAt DateTime?
revokedAt DateTime? // soft delete
}
model CreditTransaction {
id String @id @default(cuid())
userId String
amount Int // positivo=acquisto, negativo=uso
type String // "purchase" | "usage"
stripePaymentId String?
packLabel String? // "1k" | "5k" | "25k"
}
model UsageLog {
id String @id @default(cuid())
userId String
apiKeyId String
endpoint String
query String?
statusCode Int
createdAt DateTime @default(now())
}
Frontend
Il sito marketing ha tre pagine pubbliche che condividono un componente SiteNav: landing, docs, pricing. La dashboard ha un suo layout con una nav separata.
SiteNav
Componente client sticky. Logo a sinistra, due link centrati (Docs · Pricing), Sign In + Sign Up (bottone outlined) a destra. Su mobile i link centrati collassano in linea accanto al logo. Stato attivo da usePathname.
Landing page
Sezioni in ordine:
- Hero: full-width centrato, titolo responsive (3,75rem desktop → 2,5rem mobile), due CTA
- Stats bar: 10k+ ricette · 500k+ aziende · 1 credito/chiamata. Numeri immediati above the fold
- Dataset: griglia 2 colonne (desktop) / 1 colonna (mobile). Ogni card: etichetta categoria, icona, nome, conteggio record, descrizione, tag dei campi, badge "Free sample" o "Coming soon"
- Feature: 3 colonne (desktop) / 1 colonna (mobile): ⚡ Una riga per fare query · ⚖️ Sourced legalmente · 💳 Paghi solo quello che usi
- Code block: terminale stile macOS (sfondo scuro, dots), scrollabile su mobile
- CTA pricing: headline + due bottoni (Vai al pricing · Inizia gratis)
Layout responsive via <style> con media query: nessuna dipendenza dalla config Tailwind.
Pagina pricing (/pricing)
Tier ad abbonamento con toggle mensile/annuale (annuale = 20% di sconto):
| Tier | Mensile | Annuale | Limite principale |
|---|---|---|---|
| Free | 0€ | n/a | 50 chiamate/giorno, dati di esempio |
| Starter | 19€ | 15€ | 500 chiamate/giorno, ricette |
| Pro | 49€ | 39€ | 2.000 chiamate/giorno, tutti i dataset |
| Business | 99€ | 79€ | Illimitato, SLA |
Il toggle è solo UI per ora. Il click su "Get started" porta a /sign-up. Gli abbonamenti Stripe arrivano dopo che il sistema a crediti è validato con utenti veri.
Pagina docs (/docs)
Ispirata al riferimento API di FMP:
- Sidebar sticky: sezioni collassabili, badge Popular, input di ricerca
- Endpoint bar: box blu, badge colorato del metodo HTTP, URL completo, bottone copia
- Tabella parametri: Parametro · Tipo · Descrizione · Esempio · Required (*)
- Response viewer: righe numerate, colorazione sintassi JSON, bottone copia
- Sezioni: Quick Start, Authentication, Recipes ↳ Search / Get by ID, Companies ↳ Search / Get by ID, Datasets, codici di errore
Sviluppo locale
L'obiettivo: frontend in locale in pochi minuti senza il backend FastAPI.
# 1. Avvia solo Postgres
cd apps/dataplatform
docker compose up postgres -d
# 2. Crea le tabelle
pnpm --filter dataplatform db:push
# 3. Riempi il .env (chiavi Clerk obbligatorie, Stripe opzionale per ora)
# DATAPLATFORM_API_URL=http://localhost:8002 ← fuori da Docker
# 4. Dev server con Turbopack (HMR quasi istantaneo)
pnpm --filter dataplatform dev
localhost:3003: landing, auth, dashboard, docs, pricing caricano tutte. Le route API proxy danno errore finché FastAPI non è su.
# Stack completo quando serve
docker compose up postgres minio dataplatform-api -d
Deploy
Dominio
La piattaforma è online su datamazza.com, registrato su Namecheap, DNS gestito da Cloudflare (stesso pattern del resto della serie). Il nome combina il cognome del founder col prodotto: facile da pronunciare in qualsiasi lingua, impossibile da copiare.
Setup DNS: Namecheap → Custom DNS → nameserver Cloudflare. Record A che punta all'IP del server Hetzner. Cloudflare Proxied (nuvoletta arancione) per protezione DDoS e CDN. SSL/TLS impostato a Full (Strict). Traefik gestisce il certificato Let's Encrypt in automatico via Coolify.
Coolify: Nixpacks (come per il blog)
Il blog e mazzaimmobiliare usano Nixpacks con Base Directory / (root del monorepo) e pnpm --filter <app> build. DataMazza usa lo stesso pattern: nessun Dockerfile custom necessario.
Il Dockerfile è stato provato per primo e abbandonato. La CLI prisma nello stage del runner standalone richiede una lista crescente di dipendenze transitive (effect, fast-check e altre) che non sono incluse nell'output standalone di Next.js. Copiarle una a una è una rincorsa infinita. Nixpacks con un db:push come start command è più semplice e funziona.
Con Nixpacks, i comandi di build e start gestiscono Prisma:
# Build Command
pnpm --filter dataplatform db:generate && pnpm --filter dataplatform build
# Start Command
pnpm --filter dataplatform db:push && pnpm --filter dataplatform start
db:push allo startup è sicuro qui: è idempotente e non ci sono file di migrazione da tracciare. Se lo schema è già in sync, è un no-op.
Configurazione Coolify
- Build Pack: Nixpacks
- Base Directory:
/← root del monorepo, non/apps/dataplatform - Build Command:
pnpm --filter dataplatform db:generate && pnpm --filter dataplatform build - Start Command:
pnpm --filter dataplatform db:push && pnpm --filter dataplatform start - Port:
3000 - Domain:
https://datamazza.com
La Base Directory deve essere /. Impostarla a /apps/dataplatform fa sì che Nixpacks rilevi package-lock.json (npm) invece di pnpm-workspace.yaml alla root, quindi pnpm non viene mai installato e il build fallisce subito con pnpm: command not found.
Serve una env var extra per evitare di deployare con Node 18 (che dà warning di EOL):
NIXPACKS_NODE_VERSION=22
PostgreSQL creato come database gestito da Coolify (postgres-datamazza). L'URL di connessione interno dalla pagina del database in Coolify viene usato direttamente come DATABASE_URL.
Env var del frontend in Coolify:
NIXPACKS_NODE_VERSION=22
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY= # dalla pagina API Keys della TUA dashboard Clerk
CLERK_SECRET_KEY=
CLERK_WEBHOOK_SECRET=
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
DATABASE_URL= # dal "Postgres URL (internal)" di Coolify
DATAPLATFORM_API_URL=http://localhost # placeholder finché FastAPI non è deployato
INTERNAL_API_SECRET=
OPENAI_API_KEY= # richiesto: anche un placeholder al build time
Env var FastAPI (quando deployato dopo):
DATABASE_URL=
DUCKDB_PATH=/app/data/companies.duckdb
S3_ENDPOINT_URL=
S3_ACCESS_KEY=
S3_SECRET_KEY=
S3_BUCKET_NAME=dataplatform-parquet
OPENCORPORATES_API_KEY=
OPENAI_API_KEY=
INTERNAL_API_SECRET=
CORS_ORIGINS=["https://datamazza.com"]
Critico per FastAPI: aggiungi un volume persistente in Coolify a /app/data, altrimenti il file DuckDB si resetta a ogni deploy.
Le porte locali del docker-compose evitano conflitti con le app esistenti:
| Servizio | Porta locale | App esistenti |
|---|---|---|
| Next.js | 3003 | tuberead=3002 |
| FastAPI | 8002 | tuberead-api=8001 |
| PostgreSQL | 5434 | tuberead=5433 |
| MinIO | 9004/9005 | tuberead=9002/9003 |
Fix di build
Lockfile non aggiornato. Dopo aver aggiunto nuovi pacchetti a apps/dataplatform/package.json, il pnpm-lock.yaml alla root del monorepo non era stato aggiornato. Nixpacks esegue pnpm install --frozen-lockfile. Se il lockfile non corrisponde a package.json, il build fallisce subito. Fix: esegui pnpm install in locale e committa il lockfile aggiornato.
OpenAI client lazy. Il primo deploy è fallito con:
Error: The OPENAI_API_KEY environment variable is missing or empty
Il client OpenAI era istanziato a livello di modulo in lib/extract-recipe.ts. Next.js valuta i moduli delle route al build time, quindi il costruttore partiva prima che qualsiasi env var fosse disponibile.
Stesso problema con Stripe: throw new Error("STRIPE_SECRET_KEY is not set") al top level fa crashare anche il build.
Fix: inizializzazione lazy dentro a una funzione getter per entrambi:
// prima — crasha al build time
const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
// dopo — gira solo a request time
let client: OpenAI | null = null;
function getClient() {
if (!client) client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
return client;
}
Questo pattern vale per qualunque client che legga env var a costruzione. Se è istanziato a livello di modulo, gira al build time. La lazy init lo rimanda alla prima richiesta vera.
TypeScript never su array discriminato. L'array SECTIONS della sidebar docs aveva forme miste (alcuni elementi avevano badge, altri children). TypeScript ha inferito il tipo come union e l'ha narrowato a never nel branch else. Fix: aggiungi una annotazione di tipo esplicita:
type Section = {
id: string;
label: string;
badge?: { label: string; variant: "popular" | "hot" | "default" };
children?: { id: string; label: string }[];
};
const SECTIONS: Section[] = [...];
useSearchParams() senza Suspense. La pagina di billing usava useSearchParams() a livello di componente. Next.js 14+ richiede che sia dentro un boundary <Suspense>, altrimenti throw al build time. Fix: avvolgi il componente:
export default function BillingPage() {
return (
<Suspense>
<BillingContent />
</Suspense>
);
}
Next.js 16: proxy.ts invece di middleware.ts
Next.js 16 ha rinominato Middleware in Proxy. Il file di convenzione adesso è proxy.ts (o src/proxy.ts con la directory src). @clerk/nextjs v6 lo supporta: rileva la major version di Next.js e cerca il filename giusto.
Se il file è ancora chiamato middleware.ts in un progetto Next.js 16, le chiamate auth() di Clerk nei Server Component falliranno:
Clerk: auth() was called but Clerk can't detect usage of clerkMiddleware().
Il fix è solo rinominare il file. Il contenuto resta identico.
Clerk Publishable Key: usa le chiavi della tua dashboard, non quelle dei docs
I docs "Getting Started" di Clerk mostrano chiavi di esempio tipo pk_test_dGFsZW50ZW... (decodificata in talented-puma-73.clerk.accounts.dev). Se finiscono nelle env var di Coolify, ogni richiesta ritorna:
{"errors":[{"message":"Invalid host","code":"host_invalid"}]}
Le chiavi devono arrivare dalla pagina API Keys della tua applicazione Clerk, non da un esempio della documentazione.
Firewall Hetzner: servizio real-time di Coolify
La dashboard di Coolify usa WebSocket sulle porte 6001 e 6002 per gli aggiornamenti real-time. Queste porte devono essere aperte nel firewall Hetzner per permettere al browser di connettersi, altrimenti la dashboard mostra "Cannot connect to real-time service" e tutti i servizi appaiono come "unreachable" anche se i container girano benissimo.
Aggiungi gli IP sorgente del browser (o Any IPv4) alle regole inbound per TCP 6001 e 6002 nel Hetzner Cloud Firewall.
Webhook Clerk
Tre eventi registrati: user.created, user.updated, user.deleted. L'URL del webhook è https://datamazza.com/api/webhooks/clerk. Il Signing Secret dalla dashboard webhook di Clerk è CLERK_WEBHOOK_SECRET.
Stato attuale
Cosa funziona:
- Frontend deployato su
datamazza.comvia Nixpacks: landing, pricing, docs, sign-up, dashboard - Build Nixpacks: Base Directory
/, comandipnpm --filter dataplatform, Node 22 - Tutti gli errori di build risolti: lazy init OpenAI/Stripe, fix TypeScript
never, boundary Suspense, sync del lockfile, middleware Clerk inproxy.ts - Auth Clerk: sign-up, sign-in, middleware
proxy.tsche protegge/dashboard/* - Webhook Clerk:
user.created/user.updated/user.deletedregistrati - PostgreSQL in funzione su Coolify, tabelle create via
prisma db pushallo startup - Stripe collegato: Checkout Session → webhook →
addCredits(). Le chiavi vanno configurate in Coolify - FastAPI: endpoint companies + recipes, auth con API key, deduzione atomica dei crediti. Non ancora deployata
- Migrazioni DuckDB (schema companies, recipes, jobs)
- ETL per le aziende italiane (scraper OpenCorporates + loader): scraper costruito, non ancora seedato
- Estrazione ricette YouTube (transcript → OpenAI → DuckDB + R2)
- Infrastruttura di schema versioning e ri-estrazione
Cosa è ancora aperto:
- Chiavi Stripe non impostate (
STRIPE_SECRET_KEYvuota → billing 503) - FastAPI non deployata (il proxy API ritorna 503)
- ETL aziende non seedato
- Bucket R2 non creato (env var
S3_*vuote) OPENAI_API_KEYnon impostata in Coolify (endpoint di estrazione YouTube non funzionante)
Prossimi passi
Per far funzionare il billing:
- Stripe: crea account → copia le chiavi → aggiungile alle env var di Coolify (niente rebuild necessario, sono var runtime)
- Registra il webhook Stripe su
https://datamazza.com/api/webhooks/stripe - Testa con
stripe listen --forward-to https://datamazza.com/api/webhooks/stripe
Per far funzionare l'API:
- Deploya FastAPI su Coolify (secondo servizio, stessa repo, Base Directory
/apps/dataplatform-api) - Aggiungi un volume persistente a
/app/data - Aggiorna
DATAPLATFORM_API_URLnelle env del frontend con l'URL interno di FastAPI - Seed delle aziende:
uv run python -c "import asyncio; from app.services.etl.loader import seed; asyncio.run(seed(pages=50))" - R2: crea bucket
dataplatform-parquet→ imposta le env varS3_*
Poi:
- Aggiungi
OPENAI_API_KEYin Coolify → estrazione YouTube live - Aggiungi un cron giornaliero ETL aziende ad APScheduler
- Decidi la fonte del dataset jobs e costruisci lo scraper
- Considera Prefect quando 2+ scraper sono attivi
- Considera l'estrazione di
apps/dataplatform-etlquando il carico ETL impatta la latenza dell'API