Loading theme
~/saverio

Costruire una piattaforma di Data API

21 mar 2026· 14 min
fastapiduckdbdataapistripe

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, billing
  • apps/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:

DatabaseCosa ci vivePerché
PostgreSQL (Prisma)Utenti, API key, saldo crediti, log d'usoRelazionale, transazionale, critico per l'auth
DuckDB (file)Dataset: aziende, ricetteOLAP, 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.

FileCosa aggiunge
001_initial.sqlTabella companies, etl_runs, indici
002_schema_versioning.sqlschema_version + raw_extracted_json su recipes, schema_versions_log
003_jobs.sqlTabella jobs (dataset futuro)
004_etl_resume.sqlrows_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é:

  1. Isolamento dei crash: uno scraper che fallisce non impatta il processo API
  2. 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.

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:

  1. Hero: full-width centrato, titolo responsive (3,75rem desktop → 2,5rem mobile), due CTA
  2. Stats bar: 10k+ ricette · 500k+ aziende · 1 credito/chiamata. Numeri immediati above the fold
  3. 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"
  4. Feature: 3 colonne (desktop) / 1 colonna (mobile): ⚡ Una riga per fare query · ⚖️ Sourced legalmente · 💳 Paghi solo quello che usi
  5. Code block: terminale stile macOS (sfondo scuro, dots), scrollabile su mobile
  6. 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):

TierMensileAnnualeLimite principale
Free0€n/a50 chiamate/giorno, dati di esempio
Starter19€15€500 chiamate/giorno, ricette
Pro49€39€2.000 chiamate/giorno, tutti i dataset
Business99€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:

ServizioPorta localeApp esistenti
Next.js3003tuberead=3002
FastAPI8002tuberead-api=8001
PostgreSQL5434tuberead=5433
MinIO9004/9005tuberead=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.com via Nixpacks: landing, pricing, docs, sign-up, dashboard
  • Build Nixpacks: Base Directory /, comandi pnpm --filter dataplatform, Node 22
  • Tutti gli errori di build risolti: lazy init OpenAI/Stripe, fix TypeScript never, boundary Suspense, sync del lockfile, middleware Clerk in proxy.ts
  • Auth Clerk: sign-up, sign-in, middleware proxy.ts che protegge /dashboard/*
  • Webhook Clerk: user.created / user.updated / user.deleted registrati
  • PostgreSQL in funzione su Coolify, tabelle create via prisma db push allo 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_KEY vuota → billing 503)
  • FastAPI non deployata (il proxy API ritorna 503)
  • ETL aziende non seedato
  • Bucket R2 non creato (env var S3_* vuote)
  • OPENAI_API_KEY non impostata in Coolify (endpoint di estrazione YouTube non funzionante)

Prossimi passi

Per far funzionare il billing:

  1. Stripe: crea account → copia le chiavi → aggiungile alle env var di Coolify (niente rebuild necessario, sono var runtime)
  2. Registra il webhook Stripe su https://datamazza.com/api/webhooks/stripe
  3. Testa con stripe listen --forward-to https://datamazza.com/api/webhooks/stripe

Per far funzionare l'API:

  1. Deploya FastAPI su Coolify (secondo servizio, stessa repo, Base Directory /apps/dataplatform-api)
  2. Aggiungi un volume persistente a /app/data
  3. Aggiorna DATAPLATFORM_API_URL nelle env del frontend con l'URL interno di FastAPI
  4. Seed delle aziende: uv run python -c "import asyncio; from app.services.etl.loader import seed; asyncio.run(seed(pages=50))"
  5. R2: crea bucket dataplatform-parquet → imposta le env var S3_*

Poi:

  • Aggiungi OPENAI_API_KEY in 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-etl quando il carico ETL impatta la latenza dell'API