8 Commits
Author SHA1 Message Date
lucianoandClaude Opus 4.7 38f28e015b docs: documenta NEXT_PUBLIC_APP_URL e setup Google OAuth nel runbook
Due colpevoli scoperti durante il deploy live:
- `NEXT_PUBLIC_APP_URL` mancava nel runbook ed in .env.example. Il client di Better Auth (lib/auth-client.ts) la usa come baseURL — senza, fallback a localhost:3000 → CORS error sul click di "Continua con Google". Va inlined al build (è NEXT_PUBLIC_*), quindi modificarla richiede rebuild.
- Setup Google OAuth: il bottone è SSG (prerendered al build), quindi aggiungere le env GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET non basta — serve rebuild perché `enabledSocialProviders` è valutato a build-time.

Aggiunta una sezione 4.3.bis dedicata al setup Google con i punti critici evidenziati.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 14:52:53 +02:00
lucianoandClaude Opus 4.7 f455cbe499 feat(billing): Stripe + abbonamento Pro con trial 30gg (ADR 0004)
Chiude la decisione "payment provider" aperta in CLAUDE.md.

**Modello**: free 30gg post-signup (no carta richiesta) → Pro mensile €9,90 / annuale €89. Allinea il paywall al confine fra fase INTENSIVE e TRANSITION (PRD §5.1), quando l'utente ha già visto i primi risultati. Dopo la scadenza l'app non si "spegne": storico restano consultabili (sola lettura), ma generazione piani / nuove pesate / digiuni / foto / export richiedono abbonamento attivo.

**Schema**: nuova tabella `subscriptions` (1:1 con users) con stati TRIALING/ACTIVE/PAST_DUE/CANCEL_AT_PERIOD_END/CANCELED/EXPIRED + `billing_webhook_events` per idempotenza dei retry Stripe.

**Backend** (`apps/api/src/modules/billing/`):
- `GET /me/billing/status` — snapshot + derived (kind, isPro, trialDaysRemaining)
- `POST /me/billing/checkout` — crea Stripe Checkout Session (subscription mode + Stripe Tax + tax_id_collection)
- `POST /me/billing/portal` — Customer Portal Session
- `POST /webhooks/stripe` — raw body, firma HMAC, idempotenza per `event.id`, dispatch su `customer.subscription.*`, `checkout.session.completed`, `invoice.payment_failed`
- Plugin `requirePro()` (402 payment_required) applicato a 9 rotte: meal-plans CRUD, weight-entries POST, check-ins POST, fast-events POST/PATCH, fasting/pause POST, export.pdf (plan+tracking)

**Shared** (`@ketopath/shared/billing/pro-status`):
- `isProActive(snap)` — verifica live (gestisce anche TRIALING con `trialEndsAt` passato in caso di cron in ritardo)
- `deriveProStatus(snap)` — kind + isPro + trialDaysRemaining + accessEndsAt per l'UI
- `computeTrialEndsAt(signupAt, days=30)`
- 11 unit test

**Frontend** (`apps/web/src/app/[locale]/billing/`):
- Pagina `/billing` editoriale (capitolo VIII) con StatusBlock per ogni kind, BillingActionsBar client (transitions, redirect a Stripe), 3 benefits
- `<TrialBanner>` in SignedInDashboard (3 stati: trial in corso oro / scaduto pomodoro / past_due pomodoro)
- Nav item "Abbonamento" (chapter VI) nel grid asimmetrico
- i18n IT completo (`Billing` namespace)

**Soft-degradation**: env Stripe (`STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `STRIPE_PRICE_ID_*`, `BILLING_RETURN_URL`) sono tutte opzionali. Senza configurazione i route billing rispondono 503 e il banner trial mostra "pagamenti non ancora attivati" — utenti in trial continuano a usare l'app.

99/99 test verdi, lint pulito su tutto il monorepo.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 11:27:06 +02:00
lucianoandClaude Opus 4.7 bb48357179 feat: web push notifications, mobile-ready (PRD §5.6)
ADR 0003: VAPID self-hosted today, DeviceToken model agnostic to
platform so iOS/Android (Expo/APNs/FCM) plug in as new senders later.

Backend (apps/api/src/modules/notifications)
- sender.ts: NotificationSender interface, WebPushSender via VAPID
- notifications.routes.ts: GET /me/notifications/config, POST/DELETE
  /me/device-tokens, PATCH /me/notifications/settings, POST /me/notifications/test
- scheduler.ts: node-cron Mon 09:00 Europe/Rome for weekly weigh-in
  reminder; auto-cleanup of expired tokens on 404/410
- env: VAPID_PUBLIC_KEY/PRIVATE_KEY/SUBJECT (all optional → push gracefully off)

Frontend
- public/sw.js minimal (push + notificationclick)
- lib/notifications/push-client.ts: subscribe / unsubscribe / getCurrentSubscription
- profile/notifications-{actions,panel}.tsx: editorial panel with toggles,
  device list, "send test", per-device removal
- pushReady requires both permission AND active subscription (covers the
  case where the user revoked the SW but kept the browser permission)

Schema
- DeviceToken { userId, platform, endpoint, p256dh, auth, token, userAgent,
  createdAt, lastSeenAt } with unique(userId, endpoint)
- ExtendedPrismaClient type exported from @ketopath/db
- NotificationSettings zod schema in @ketopath/shared

Tooling
- lint-staged: split .js out of eslint glob so service worker is only
  formatted (it lives outside the TS project)

i18n
- Notifications namespace (it) with typed error keys

Smoke tested: POST /me/device-tokens 201, POST /me/notifications/test 200,
real push delivered to a macOS Chrome device.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 21:58:35 +02:00
lucianoandClaude Opus 4.7 9555022143 feat(db): encrypt Profile health fields at rest (ADR 0002)
Wire prisma-field-encryption AES-256-GCM extension on the shared Prisma
client and annotate the four sensitive columns on Profile with @encrypted:
- weightStartKg / weightCurrentKg / weightGoalKg (Decimal → String)
- targetDate (DateTime @db.Date → String, ISO YYYY-MM-DD)

Other Profile fields stay in clear text per ADR 0002 (age, gender,
heightCm, activityLevel) — they're needed for plan generation and
aggregate analytics, and are not strongly identifying on their own.

apps/api profile.routes.ts:
- serialize() now reads the columns as strings and parses them back to
  numbers for BMR/TDEE; targetDate is already an ISO string from the DB
- the upsert stringifies numeric inputs and slices the date to YYYY-MM-DD

Env wiring:
- packages/db, apps/api, apps/web .env.example all document
  PRISMA_FIELD_ENCRYPTION_KEY (k1.aesgcm256.<base64url>) — must match
  across every process that hits the DB
- key generation snippet documented inline

Migration is intentionally NOT in this commit: needs to be created against
a live Postgres instance and applied. The fields change Decimal/Date → text
so prisma migrate dev will require a USING cast — see the follow-up commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 15:03:24 +02:00
lucianoandClaude Opus 4.7 5b38ddc41d feat: wire Sentry, conditional on DSN env var
apps/web (@sentry/nextjs):
- sentry.client.config.ts / sentry.server.config.ts / sentry.edge.config.ts
  initialize Sentry only when NEXT_PUBLIC_SENTRY_DSN (or SENTRY_DSN on the
  server) is set; tracesSampleRate 0.1
- next.config.mjs wraps the existing config with withSentryConfig only when a
  DSN is present; source-map upload stays disabled until SENTRY_AUTH_TOKEN is
  provided
- .env.example documents NEXT_PUBLIC_SENTRY_DSN and the optional auth token

apps/api (@sentry/node):
- src/lib/sentry.ts initializes Sentry at module load when SENTRY_DSN is set
- server.ts imports sentry.ts as the very first side-effect so early-boot
  errors (env validation, plugin registration) reach Sentry
- env schema gains optional SENTRY_DSN (URL)
- app.ts registers an errorHandler that captures the exception with the
  authenticated user when SENTRY_DSN is set; logs and re-sends as before

Build-time housekeeping:
- profile route: targetDate ?? null on create to satisfy Prisma's input shape
  under exactOptionalPropertyTypes
- profile form: useForm receives defaultValues only when initial is provided
  (spread instead of `defaultValues: undefined`); Field error prop typed
  `string | undefined` for exactOptionalPropertyTypes

Without a DSN both apps run unchanged (Sentry is inert).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 14:25:08 +02:00
lucianoandClaude Opus 4.7 5f17f95d6d feat(api): add Better Auth plugin and protected GET /me endpoint
- authPlugin runs preHandler that turns Fastify request headers into a Headers
  object, calls auth.api.getSession, and decorates request.user / request.session
- requireAuth() preHandler short-circuits with 401 when not signed in
- New module modules/me with GET /me returning the authenticated user/session
- env validates BETTER_AUTH_SECRET (≥32) and BETTER_AUTH_URL — must match web
- Restored .js extensions in shared packages so NodeNext-resolution consumers
  (api) typecheck cleanly; Next webpack now uses extensionAlias to map .js → .ts
- Re-enabled NodeNext for packages/auth and packages/db tsconfigs

Verified end-to-end:
- POST /api/auth/sign-in/email on web returns session cookie
- GET /me on api with the cookie returns 200 + user/session
- GET /me without the cookie returns 401

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 13:50:14 +02:00
lucianoandClaude Opus 4.7 72d47da453 feat(api): integrate Prisma client with /db/health endpoint
- Add @ketopath/db and fastify-plugin to apps/api
- prismaPlugin decorates the Fastify instance with prisma and disconnects
  on app close, with FastifyInstance type augmented in src/types/fastify.d.ts
- DATABASE_URL is now required by env validation
- New module modules/db with GET /db/health running SELECT 1 via Prisma
- dev script switched to tsx --env-file=.env for env loading

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 12:38:04 +02:00
lucianoandClaude Opus 4.7 8625d85a87 feat(api): scaffold Fastify server with health route
- ESM Fastify 4 with Pino logger (pretty-print in dev only)
- Plugins: helmet, cors (allowlist via CORS_ORIGINS), rate-limit, sensible
- Zod-validated environment config in src/config/env.ts
- Modular structure under src/modules with /health route
- tsx watch for dev, tsc for build, dist/server.js as production entry

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 12:19:04 +02:00