diff --git a/.agent/CONTINUITY.md b/.agent/CONTINUITY.md new file mode 100644 index 0000000..24ab9bb --- /dev/null +++ b/.agent/CONTINUITY.md @@ -0,0 +1,38 @@ +# AutoReview implementation continuity + +## Snapshot + +- Branch: `codex/production-pilot`, based on `dev`. +- Goal: replace demonstration-only paths with a safe, usable manually approved pilot. +- No live Google, OpenRouter or cloud credentials supplied. Never claim production verification without them. + +## Progress + +- Persistent tenant-scoped PostgreSQL repository, CAS batches, forced RLS, append-only audit and expiry implemented. +- Google OAuth nonce, authorized location import/sync/disconnect and canonical review revalidation implemented. +- Recoverable publication intent and GET-only uncertain-outcome reconciliation implemented and tested. +- Identity Platform/TOTP, account grant/revocation checks, KMS vault and secure web/native sessions implemented. +- Versioned knowledge/document extraction and Vertex/pgvector hybrid retrieval implemented; no automatic learning. +- Web/mobile inboxes and review actions, sources, knowledge lifecycle, rules/simulation, preferences and audit wired to API. +- Content-free push outbox, Expo ticket checks, invalid-device removal and scheduled retries implemented. +- Terraform identity/web/worker/migrations/retention/initial monitoring validated; no cloud apply performed. +- English README and engineering/setup/API/release docs distinguish implemented pilot from unverified production gates. +- User confirmed no approved Google Cloud Business Profile project. Live connection remains external. + +## Verification + +- 70 automated tests pass; one optional TCP PostgreSQL test skipped without TEST_DATABASE_URL. +- Six Playwright desktop/mobile-web scenarios pass; all seven workspace typechecks/builds pass. +- Android/iOS Hermes exports pass; these are not signed APK/IPA or physical-device tests. +- Biome, diff whitespace, Terraform fmt/validate pass. CI expanded; inspect exact pushed head before reporting its result. +- PDF native worker-thread crash on Windows fixed with a bounded child process; real PDF/DOCX extraction tests pass. +- Test/dev server cleanup and Hermes compiler require appropriate Windows execution permissions, not source workarounds. + +## Decisions + +- Manual approval remains the default. Automatic publication must fail closed. +- Keep mock adapters explicit and prohibited in production. +- Use small commits. PR target must be `dev`; do not update `main` directly. +- Runtime SQL user must be non-owner, NOSUPERUSER/NOBYPASSRLS/NOCREATEROLE/NOCREATEDB. Fresh cloud bootstrap: targeted migration job provisioning, execute it, then full apply. +- No SaaS billing/team self-service, original document archive, correction dataset, full OTel or Expo receipt analytics shipped; these are documented expansion scope. +- Integrate remote dev audit: preserve location/limit filtering, parameterized SQL and generation failure audit; pre-PUT failures are recoverable while uncertain PUT outcomes require GET-only reconciliation. diff --git a/.dockerignore b/.dockerignore index 9b8dee5..ac4d51b 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,6 +5,9 @@ node_modules **/dist-ios **/.next .git +.agent/tools +**/test-results +**/playwright-report .env .env.* !.env.example diff --git a/.env.example b/.env.example index 03a0fd7..8492f0c 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,6 @@ NODE_ENV=development PORT=4100 +HOST=127.0.0.1 WORKER_PORT=4200 WORKER_AUTH_MODE=demo WORKER_PUBLIC_URL=http://localhost:4200 @@ -13,6 +14,17 @@ INTERNAL_WORKER_SECRET=reviewguard-local-worker-secret GOOGLE_WEBHOOK_TENANT_ID=11111111-1111-4111-8111-111111111111 GOOGLE_WEBHOOK_ACTOR_ID=22222222-2222-4222-8222-222222222222 AUTH_MODE=demo +STORAGE_MODE=memory +# Required only for local persistent development; production uses GOOGLE_KMS_KEY_NAME. +TOKEN_ENCRYPTION_KEY= +IDENTITY_PROJECT_ID= +IDENTITY_API_KEY= +WEB_AUTH_MODE=demo +AUTH_COOKIE_SECRET= +AUTOMATION_RELEASE_APPROVED=false +EMBEDDING_MODE=demo +EMBEDDING_MODEL=gemini-embedding-001 +EMBEDDING_LOCATION=europe-west4 DATABASE_URL=postgres://reviewguard:reviewguard@localhost:5432/reviewguard GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= @@ -26,5 +38,9 @@ OPENROUTER_MODEL=deepseek/deepseek-v4-pro-0813 OPENROUTER_PROVIDER_ALLOWLIST= AI_MODE=mock WEB_ORIGIN=http://localhost:3000 -NEXT_PUBLIC_API_URL=http://localhost:4100/v1 +EXPO_PUBLIC_AUTH_MODE=demo +EXPO_PUBLIC_IDENTITY_API_KEY= +EXPO_PUBLIC_EAS_PROJECT_ID= EXPO_PUBLIC_API_URL=http://localhost:4100/v1 +# Optional: required when Expo push access-token enforcement is enabled. +EXPO_ACCESS_TOKEN= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 28b1d36..89fa250 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,7 +3,7 @@ name: CI on: pull_request: push: - branches: [main] + branches: [main, dev, 'codex/**'] permissions: contents: read @@ -24,6 +24,16 @@ jobs: - run: pnpm typecheck - run: pnpm test - run: pnpm build + - run: pnpm --filter @reviewguard/web exec playwright install --with-deps chromium + - run: pnpm test:e2e + - run: pnpm --filter @reviewguard/mobile exec expo export --platform android --output-dir dist-android + - run: pnpm --filter @reviewguard/mobile exec expo export --platform ios --output-dir dist-ios + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: browser-test-diagnostics + path: apps/web/test-results/ + retention-days: 3 terraform: runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index e50e931..f3cca80 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,14 @@ node_modules/ .pnpm-store/ .turbo/ +.agent/tools/ .next/ dist/ dist-android/ dist-ios/ coverage/ +test-results/ +playwright-report/ .expo/ .env .env.local diff --git a/README.md b/README.md index b3ef797..bb664ff 100644 --- a/README.md +++ b/README.md @@ -2,276 +2,150 @@ # AutoReview -### Human-controlled AI responses for Google Business Profile reviews +### Thoughtful AI replies. Human accountability. -AutoReview turns incoming reviews into grounded, brand-consistent reply drafts and routes every sensitive decision through a deterministic approval workflow. +A review operations platform for Google Business Profile, with approved business knowledge, authenticated approval workflows, and conservative automation controls. -[![CI](https://github.com/DevvoLazza/AutoReview/actions/workflows/ci.yml/badge.svg)](https://github.com/DevvoLazza/AutoReview/actions/workflows/ci.yml) +[![CI](https://github.com/DevvoLazza/AutoReview/actions/workflows/ci.yml/badge.svg?branch=dev)](https://github.com/DevvoLazza/AutoReview/actions/workflows/ci.yml) [![Node.js](https://img.shields.io/badge/Node.js-24_LTS-339933?logo=nodedotjs&logoColor=white)](https://nodejs.org/) [![Next.js](https://img.shields.io/badge/Next.js-16-000000?logo=nextdotjs&logoColor=white)](https://nextjs.org/) [![Expo](https://img.shields.io/badge/Expo-57-000020?logo=expo&logoColor=white)](https://expo.dev/) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) -**Executable pilot · Multi-tenant by design · Automation disabled by default** +**Runnable local demo · Production-oriented pilot · Automation off by default** ---- - ## Overview -Responding to customer reviews well requires speed, context, a consistent voice, and careful handling of sensitive situations. AutoReview automates preparation—not accountability. - -The AI model can draft a response, identify risks, and cite the approved knowledge it used. It cannot access Google credentials, query the database directly, enable automation, or publish a response. Authorization and publication remain under application control. +AutoReview prepares context-aware replies and keeps publication under application control. An AI model receives the review and relevant approved knowledge; it never receives Google credentials, database access, tools, or permission to publish. ```text -Google review - → Pub/Sub event - → canonical review retrieval - → approved business knowledge + AI drafting - → independent validation and risk controls - → approval, revision, rejection, or scheduled delivery - → Google publication - → reconciliation and audit -``` - -## Product capabilities - -### Human approval workflow - -- Web and mobile inboxes for reviews requiring attention. -- Reply drafts generated in the language of the original review. -- Approve, edit, reject, or request a revised draft. -- Natural-language revision instructions such as “make it shorter and more empathetic.” -- Optimistic concurrency control prevents two users from publishing competing replies. -- The canonical Google review is retrieved again immediately before publication. - -### Controlled business knowledge - -- Tenant- and location-specific business profiles. -- Brand voice, supported languages, services, hours, contact details, and FAQs. -- Escalation rules for complaints, refunds, and sensitive topics. -- Versioned sources with `draft`, `approved`, and `retired` lifecycle states. -- Hybrid full-text and vector retrieval using PostgreSQL and `pgvector`. -- Only approved, currently valid sources may influence a reply. -- Human edits contribute to evaluation data; they never modify knowledge automatically. - -### Guarded automation - -- Rules scoped by location, rating, language, review text, category, and delay. -- A minimum of 20 manual reviews before a location becomes eligible for automation. -- A default 10-minute cancellation window before scheduled publication. -- Daily publication limits and a global kill switch. -- Non-bypassable hard stops for legal threats, health incidents, discrimination, fraud, refunds, chargebacks, personal data, employee allegations, and violent language. - -### Operations and accountability - -- Push notifications never contain review text. -- Notifications deep-link to an authenticated screen; approval never happens inside the notification. -- Idempotent event processing and publication attempts. -- Controlled retries, dead-letter handling, and final-state reconciliation. -- Append-only audit records for actor, decision, model, provider, prompt version, and knowledge version. -- Scheduled removal of temporary Google content within the required retention window. - -## Architecture - -```mermaid -flowchart LR - GBP[Google Business Profile] --> PS[Pub/Sub] - PS --> W[Cloud Run worker] - W --> API[NestJS API] - WEB[Next.js dashboard] --> API - APP[Expo mobile app] --> API - API --> DB[(Cloud SQL PostgreSQL + pgvector)] - API --> AI[OpenRouter] - API --> TASKS[Cloud Tasks] - TASKS --> W - API --> PUSH[Expo Push] - API --> GBP -``` - -| Layer | Technology | Responsibility | -| --- | --- | --- | -| Dashboard | Next.js 16, React 19 | Inbox, knowledge, rules, team, and audit | -| Mobile | Expo 57, React Native 0.86 | Push-driven review and approval workflow | -| API | NestJS 12, Fastify 5 | Authentication, authorization, workflow, and OpenAPI | -| Worker | Node.js, Fastify | Pub/Sub ingestion, Cloud Tasks, retries, and retention | -| Domain | TypeScript, Zod | State machine, hard stops, contracts, and validation | -| Data | PostgreSQL 17, Drizzle, pgvector | Tenant isolation, knowledge retrieval, and audit | -| AI | OpenRouter, pinned DeepSeek snapshot | Structured drafting without tools or data access | -| Infrastructure | Google Cloud, Terraform | Cloud Run, Cloud SQL, Pub/Sub, Tasks, KMS, and secrets | - -The configured model is the immutable `deepseek/deepseek-v4-pro-0813` snapshot. Requests use Structured Outputs, a provider allowlist, `data_collection: "deny"`, and Zero Data Retention routing. The model returns a structured `ReplyDraft`; the deterministic policy engine alone decides whether the workflow may proceed. - -## Review lifecycle - -```mermaid -stateDiagram-v2 - [*] --> received - received --> generating - generating --> pending_approval - generating --> scheduled_auto - generating --> needs_attention - pending_approval --> publishing: approve - pending_approval --> rejected: reject - scheduled_auto --> pending_approval: cancel automation - scheduled_auto --> publishing: cancellation window expires - publishing --> published - publishing --> needs_attention: terminal failure +Google review → authenticated event → canonical retrieval → approved knowledge + → AI draft + independent validation → human approval or eligible rule + → canonical recheck → publication → confirmation + audit ``` -Every mutation includes an `expectedVersion`. Stale commands return `409 version_conflict`. Before publication, the API retrieves the canonical review again. If the review changed or already has a reply, the draft is invalidated and returned for reassessment. - -## Project maturity - -This repository contains an **end-to-end pilot that runs with simulated data**, plus adapters and infrastructure boundaries for real services. - -| Capability | Status | -| --- | --- | -| Responsive operations dashboard | Implemented | -| iOS and Android companion app | Implemented; platform bundles verified | -| Approval workflow and hard stops | Implemented and tested | -| Real and simulated Google adapters | Implemented | -| Structured OpenRouter provider | Implemented | -| PostgreSQL schema, RLS, and migrations | Implemented | -| Google Cloud Terraform stack | Implemented and validated | -| Pilot against a real Google location | External Google approval and credentials required | -| PostgreSQL-backed API repository | Required before production | -| Physical-device push and store releases | Verification required | -| Commercial multi-tenant SaaS operation | Written Google confirmation required | - > [!IMPORTANT] -> AutoReview is not currently represented as production-ready. Local development uses simulated authentication, Google, AI, and scheduling by default. No real review is published in the development workflow. +> The application is locally runnable and has real-service adapters, a persistent PostgreSQL repository, and validated infrastructure configuration. A real Google pilot has **not** been verified: Google API approval, service credentials, cloud deployment, and physical-device testing are still required. Local demo mode never publishes to Google. -## Quick start +## Features -### Requirements +- **Review operations:** paginated web/mobile inboxes, manual editing, natural-language revisions, approval, rejection, schedule cancellation, and recovery from uncertain publication. +- **Business knowledge:** tenant/location scoping, source versions, approval and retirement, validity dates, PDF/DOCX/TXT/Markdown extraction, and PostgreSQL full-text plus vector retrieval. +- **Grounded drafting:** structured output, source identifiers visible to approvers, a separate validation request, unsupported-claim detection, and source-version revalidation before sending. +- **Conservative automation:** disabled rules, owner consent and MFA, 20 confirmed manual approvals per location, daily limits, cancellation delay, global kill switch, and an operator-controlled release gate. +- **Sensitive-case escalation:** deterministic risk checks and model validation prevent flagged cases from automatic delivery. Detection is not a guarantee that every sensitive phrase in every language will be recognized; review language/category evaluations remain release requirements. +- **Authenticated access:** Identity Platform email/password and TOTP, web HttpOnly encrypted sessions, native SecureStore sessions, role checks, and live account/revocation verification. +- **Reliable publication:** atomic version checks and persisted intent, canonical Google rereads, confirmation after PUT, and GET-only reconciliation after uncertain outcomes. +- **Private notifications:** content-free push payloads, authenticated deep links, persisted submission retries, invalid-device removal, and an inbox that works without push delivery. +- **Operational safeguards:** forced PostgreSQL RLS, non-owner runtime credentials, KMS-encrypted Google tokens, append-only metadata audit, temporary-content expiry, and Terraform monitoring alerts. -- Node.js 24 LTS -- pnpm 11 -- Docker Desktop, when running PostgreSQL locally +## Quick start -### Installation +Requires **Node.js 24 LTS** and **pnpm 11.19.0**. No Google project, AI key, Docker installation, or mobile account is needed for the local demo. -```powershell +```bash git clone https://github.com/DevvoLazza/AutoReview.git -Set-Location AutoReview -Copy-Item .env.example .env -pnpm install -pnpm dev +cd AutoReview +pnpm install --frozen-lockfile +pnpm build +pnpm dev:demo ``` -Local services: +Open **http://localhost:3000**. The launcher explicitly selects simulated Google/AI, demonstration authentication, and volatile in-memory storage. Stop it with `Ctrl+C`; demo data resets when the API restarts. -| Service | URL | +| Local service | Address | | --- | --- | | Dashboard | `http://localhost:3000` | -| API | `http://localhost:4100/v1` | +| REST API | `http://localhost:4100/v1` | | Swagger UI | `http://localhost:4100/docs` | -| OpenAPI document | `http://localhost:4100/openapi.json` | -| Worker | `http://localhost:4200` | +| OpenAPI | `http://localhost:4100/openapi.json` | -The dashboard and mobile app fall back to demonstration data when the API is unavailable. To simulate a new review while the API is running: +Simulate an incoming review from PowerShell: ```powershell -Invoke-RestMethod -Method Post ` - -Uri http://localhost:4100/v1/webhooks/google-business/demo +Invoke-RestMethod -Method Post -Uri http://localhost:4100/v1/webhooks/google-business/demo -ContentType application/json -Body '{}' ``` -### Local PostgreSQL +Open **Recensioni**, inspect its draft and sources, edit or request a revision, then approve. Publication is simulated. API failures show an error and retry action; clients do **not** silently substitute demo data. -```powershell -docker compose up -d postgres -pnpm db:migrate +For persistent development, authentication, mobile builds, and cloud deployment, follow the [setup guide](docs/setup.md). + +## Technology and layout + +| Component | Stack | +| --- | --- | +| Dashboard | Next.js 16, React 19, App Router, same-origin server-side API proxy | +| Mobile | Expo SDK 57, React Native 0.86, Expo Router, development builds | +| API / worker | NestJS, Fastify, TypeScript, shared Zod contracts | +| Storage / retrieval | PostgreSQL 17, Drizzle, `pgvector`, Vertex AI embeddings | +| Reply generation | OpenRouter, configured immutable DeepSeek snapshot, JSON Schema | +| Cloud | Cloud Run, Cloud SQL, Pub/Sub, Tasks, Scheduler, Identity Platform, KMS, Secret Manager | +| Delivery / tests | pnpm, Turborepo, Terraform, GitHub Actions, Vitest, PGlite, Playwright | + +```text +apps/api/ Authentication, Google onboarding, knowledge, review workflow +apps/worker/ OIDC-verified events, scheduled delivery, retention and push retry +apps/web/ Operations dashboard and encrypted web sessions +apps/mobile/ Authenticated iOS/Android companion app +packages/contracts/ Shared request and domain schemas +packages/core/ AI/Google interfaces, safety policy and automation engine +packages/database/ Runtime repository, retrieval, migrations and isolation tests +infra/terraform/ Dedicated pilot infrastructure +docs/ Setup, architecture, API, security and release guidance ``` +The pilot intentionally accepts **one configured workspace** in production. Storage isolation is multi-tenant by design, but commercial SaaS onboarding, billing, self-service team administration, and third-party integration APIs are not included. User access is provisioned with an administrator CLI. Original uploaded document binaries are not archived; approved extracted text is stored. + ## Configuration -Available environment variables are documented in [.env.example](.env.example). Safe local defaults are explicit: +See [.env.example](.env.example) and [setup](docs/setup.md). The default reply snapshot is `deepseek/deepseek-v4-pro-0813`; deployments can change it without rewriting clients or knowledge. Evaluate any replacement before release. -```dotenv -AUTH_MODE=demo -AI_MODE=mock -GOOGLE_MODE=mock -TASKS_MODE=mock -``` +Live AI requests require a reviewed provider allowlist and request `zdr: true`, `data_collection: "deny"`, and required-parameter support. Verify actual endpoint eligibility, disable optional OpenRouter prompt logging, and assess processing region and contractual terms. These request settings alone are not proof of EU-only processing or legal compliance. -Production credentials must be supplied through Secret Manager. Google refresh tokens must be encrypted with Cloud KMS before persistence. Never commit credentials, tokens, `.env` files, or real review data. +Production refuses demo adapters, volatile storage, missing secrets, insecure public origins, and privileged database roles. Automatic publication also requires `AUTOMATION_RELEASE_APPROVED=true`, a disabled kill switch, an enabled consented rule, and all safety checks. Leave the release flag **false** throughout the initial pilot. ## Verification -```powershell +```bash pnpm lint pnpm typecheck pnpm test pnpm build -pnpm audit --prod +pnpm test:e2e +pnpm --filter @reviewguard/mobile exec expo export --platform android --output-dir dist-android +pnpm --filter @reviewguard/mobile exec expo export --platform ios --output-dir dist-ios ``` -The automated suite covers: +Browser tests exercise desktop and mobile web workflows, dynamically imported reviews, knowledge lifecycle, saved settings, and visible failure/retry states. Install Chromium with `pnpm --filter @reviewguard/web exec playwright install chromium` if no supported local browser is available. -- state-machine transitions and version conflicts; -- hard stops and automation eligibility; -- `ReplyDraft` validation and OpenRouter request controls; -- Pub/Sub redelivery and event deduplication; -- draft generation, approval, and publication API behavior; -- web, Android, and iOS bundles. +Database tests run real PostgreSQL semantics in PGlite/WASM with `pgvector`, including cross-tenant RLS, pooled-context reset, concurrent writes, atomic publication intent, audit immutability, expiry, and hybrid source filtering. Set `TEST_DATABASE_URL` to run the optional TCP PostgreSQL integration check against an **isolated test database**. It is skipped when no test server is supplied. -GitHub Actions runs linting, type checking, tests, production builds, and Terraform formatting and validation with a required lockfile. +JavaScript platform exports are build checks—not signed APK/IPA files, physical-device evidence, or store approval. GitHub Actions checks application code, browser workflows, mobile exports, and Terraform formatting/validation; it does not deploy cloud resources. -## Repository structure +## Going live -```text -AutoReview/ -├── apps/ -│ ├── api/ REST API, OAuth, authorization, and workflow -│ ├── worker/ Pub/Sub, Cloud Tasks, retries, and retention -│ ├── web/ Next.js operations dashboard -│ └── mobile/ Expo iOS and Android app -├── packages/ -│ ├── contracts/ shared Zod contracts -│ ├── core/ domain, AI, Google, and policy engine -│ └── database/ Drizzle, PostgreSQL, RLS, and pgvector -├── infra/terraform/ Google Cloud infrastructure -├── docs/ architecture, API, security, and go-live guidance -└── .github/workflows/ continuous integration -``` +1. Obtain Google Business Profile API access and authorize a real business/location. +2. Provision the dedicated cloud project, migrate the database, configure Identity Platform, and grant users their scoped roles. +3. Verify Google OAuth, canonical reads, authenticated Pub/Sub, provider/ZDR routing, embeddings, and one manually approved publication. +4. Test account revocation, concurrent approval, lost publication responses, retention, alerts, and backup restoration. +5. Validate native authentication, deep links and push on physical iOS/Android devices; complete signing and release requirements. +6. Complete privacy/contractual review and obtain written Google confirmation before expanding to commercial SaaS or automatic client replies. + +The detailed [Google go-live checklist](docs/go-live-google.md) distinguishes implemented controls from external acceptance evidence. Google does not provide a full dedicated sandbox; simulated adapters do not replace a controlled real-location pilot. -## Documentation +## Documentation and contribution +- [Setup and deployment](docs/setup.md) - [Architecture and trust boundaries](docs/architecture.md) -- [API endpoints and contracts](docs/api.md) +- [API reference](docs/api.md) - [Engineering security model](docs/security.md) -- [Security policy and vulnerability reporting](SECURITY.md) -- [Google go-live checklist](docs/go-live-google.md) - -## Production readiness checklist - -1. Obtain access to the Google Business Profile APIs. -2. Configure OAuth, Identity Platform, and enforced MFA. -3. Replace the in-memory API store with the transactional PostgreSQL repository. -4. Enable KMS encryption for Google refresh tokens. -5. Configure OpenRouter and verify provider, ZDR, and processing-location requirements. -6. Apply migrations and Terraform in a dedicated Google Cloud project. -7. Exercise Pub/Sub, push delivery, OAuth revocation, and publication against one real location with automation disabled. -8. Complete physical-device testing before TestFlight or Play Internal Testing. -9. Complete privacy, DPA/SCC, incident-response, backup-restore, and store-listing work. - -Google Business Profile does not provide a complete sandbox. The simulated adapter makes local development and CI deterministic, but it does not replace the controlled real-location pilot. - -## Contributing - -Development happens on `dev`; `main` represents the synchronized release branch. Before proposing a change, run: - -```powershell -pnpm lint -pnpm typecheck -pnpm test -pnpm build -``` +- [Vulnerability reporting](SECURITY.md) +- [Google pilot acceptance](docs/go-live-google.md) -Keep commits focused, reviewable, and independently meaningful. Do not commit secrets, `.env` files, personal data, or production review content. +Branch from `dev`, keep commits focused, and target pull requests at `dev`. `main` is the synchronized release branch. Run the relevant checks before submitting changes. Never commit secrets, `.env` files, Terraform state, or real customer review data. ## License -Licensed under the [MIT License](LICENSE). Copyright © 2026 Lazzaro Davide. +AutoReview is licensed under the [MIT License](LICENSE). Copyright © 2026 Lazzaro Davide. diff --git a/apps/api/package.json b/apps/api/package.json index 955677a..4df0d53 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -8,12 +8,14 @@ "build": "tsc -p tsconfig.json", "start": "node dist/main.js", "typecheck": "tsc -p tsconfig.json --noEmit", + "identity:grant": "node scripts/grant-access.mjs", "test": "vitest run", "test:coverage": "vitest run --coverage" }, "dependencies": { "@fastify/cors": "11.3.0", "@fastify/static": "10.1.3", + "@google-cloud/kms": "6.1.0", "@google-cloud/tasks": "7.1.0", "@nestjs/common": "12.0.3", "@nestjs/core": "12.0.3", @@ -23,7 +25,10 @@ "@reviewguard/core": "workspace:*", "@reviewguard/database": "workspace:*", "fastify": "5.12.4", + "google-auth-library": "11.0.2", "jose": "6.2.12", + "mammoth": "1.12.3", + "pdf-parse": "2.4.5", "reflect-metadata": "0.2.2", "rxjs": "7.8.2", "zod": "4.6.5" diff --git a/apps/api/scripts/grant-access.mjs b/apps/api/scripts/grant-access.mjs new file mode 100644 index 0000000..b172dc0 --- /dev/null +++ b/apps/api/scripts/grant-access.mjs @@ -0,0 +1,86 @@ +import { parseArgs } from "node:util"; +import { GoogleAuth } from "google-auth-library"; + +const { values } = parseArgs({ + options: { + project: { type: "string" }, + uid: { type: "string" }, + tenant: { type: "string" }, + "user-id": { type: "string" }, + role: { type: "string" }, + apply: { type: "boolean", default: false }, + }, +}); +const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +if ( + !values.project || + !/^[a-z][a-z0-9-]{4,62}$/.test(values.project) || + !values.uid || + !uuid.test(values.tenant ?? "") || + !uuid.test(values["user-id"] ?? "") || + !["owner", "admin", "editor", "approver"].includes(values.role ?? "") +) { + console.error( + "Usage: pnpm identity:grant --project PROJECT --uid IDENTITY_UID --tenant UUID --user-id UUID --role owner|admin|editor|approver [--apply]", + ); + process.exit(1); +} +const claims = { tenant_id: values.tenant, app_user_id: values["user-id"], role: values.role }; +console.info( + JSON.stringify( + { + dryRun: !values.apply, + project: values.project, + uid: values.uid, + claims, + action: "assign access and revoke existing sessions", + }, + null, + 2, + ), +); +if (values.apply) { + const auth = new GoogleAuth({ scopes: ["https://www.googleapis.com/auth/cloud-platform"] }); + const base = `https://identitytoolkit.googleapis.com/v1/projects/${values.project}/accounts`; + try { + const lookup = await auth.request({ + url: `${base}:lookup`, + method: "POST", + data: { localId: [values.uid] }, + timeout: 10_000, + retry: false, + }); + const account = lookup.data.users?.[0]; + if (!account) throw new Error("missing account"); + const current = JSON.parse(account.customAttributes ?? "{}"); + await auth.request({ + url: `${base}:update`, + method: "POST", + data: { + localId: values.uid, + customAttributes: JSON.stringify({ ...current, ...claims }), + validSince: String(Math.floor(Date.now() / 1000)), + }, + timeout: 10_000, + retry: false, + }); + const verified = await auth.request({ + url: `${base}:lookup`, + method: "POST", + data: { localId: [values.uid] }, + timeout: 10_000, + retry: false, + }); + const actual = JSON.parse(verified.data.users?.[0]?.customAttributes ?? "{}"); + if (Object.entries(claims).some(([key, value]) => actual[key] !== value)) + throw new Error("readback mismatch"); + console.info( + "Access verified. The user must verify their email and sign in again; Owner/Approver must enroll TOTP.", + ); + } catch { + console.error( + "Provisioning failed or could not be verified. Check project, UID and operator IAM; no credentials were logged.", + ); + process.exitCode = 1; + } +} diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index a7b1cb3..c7fdcc2 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -12,11 +12,16 @@ import { KnowledgeController, ReviewsController, } from "./controllers.js"; +import { DocumentsController } from "./documents.controller.js"; +import { IdentityAccountVerifier } from "./identity-account.js"; +import { IntegrationService } from "./integration.service.js"; +import { KnowledgeService } from "./knowledge.service.js"; import { ReviewNotificationService } from "./notifications.js"; import { aiProvider, googleGateway } from "./providers.js"; import { ReviewService } from "./review.service.js"; import { MemoryStore } from "./store.js"; import { PublishTaskScheduler } from "./tasks.js"; +import { WorkspaceController } from "./workspace.controller.js"; @Module({ controllers: [ @@ -29,12 +34,17 @@ import { PublishTaskScheduler } from "./tasks.js"; DevicesController, IntegrationsController, GoogleWebhookController, + WorkspaceController, + DocumentsController, ], providers: [ MemoryStore, ReviewService, ReviewNotificationService, PublishTaskScheduler, + IntegrationService, + IdentityAccountVerifier, + KnowledgeService, aiProvider, googleGateway, { provide: APP_GUARD, useClass: AuthenticationGuard }, diff --git a/apps/api/src/auth.ts b/apps/api/src/auth.ts index 1bf18ac..a78323c 100644 --- a/apps/api/src/auth.ts +++ b/apps/api/src/auth.ts @@ -12,27 +12,39 @@ import type { RequestPrincipal, Role } from "@reviewguard/contracts"; import { createRemoteJWKSet, jwtVerify } from "jose"; import { z } from "zod"; import { DEMO_TENANT_ID, DEMO_USER_ID } from "./demo.js"; +import { IdentityAccountVerifier } from "./identity-account.js"; const principalKey = Symbol("requestPrincipal"); const rolesKey = "reviewguard.roles"; const publicKey = "reviewguard.public"; type RequestWithPrincipal = { + method: string; headers: Record; [principalKey]?: RequestPrincipal; }; const claimsSchema = z.object({ sub: z.string(), + auth_time: z.number(), tenant_id: z.string().uuid(), app_user_id: z.string().uuid(), role: z.enum(["owner", "admin", "editor", "approver"]), firebase: z.object({ sign_in_second_factor: z.string().optional() }).optional(), + email_verified: z.literal(true), }); +const identityKeys = createRemoteJWKSet( + new URL( + "https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com", + ), +); @Injectable() export class AuthenticationGuard implements CanActivate { - constructor(private readonly reflector: Reflector) {} + constructor( + private readonly reflector: Reflector, + private readonly accounts: IdentityAccountVerifier, + ) {} async canActivate(context: ExecutionContext): Promise { if ( @@ -49,7 +61,9 @@ export class AuthenticationGuard implements CanActivate { request[principalKey] = { userId: header(request, "x-user-id") ?? DEMO_USER_ID, tenantId: header(request, "x-tenant-id") ?? DEMO_TENANT_ID, - role: (header(request, "x-role") as Role | undefined) ?? "owner", + role: z + .enum(["owner", "admin", "editor", "approver"]) + .parse(header(request, "x-role") ?? "owner"), mfaVerified: header(request, "x-mfa-verified") !== "false", }; return true; @@ -61,16 +75,20 @@ export class AuthenticationGuard implements CanActivate { throw new UnauthorizedException("A valid Identity Platform token is required"); } const token = authorization.slice("Bearer ".length); - const jwks = createRemoteJWKSet( - new URL( - "https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com", - ), - ); - const result = await jwtVerify(token, jwks, { + const claims = await jwtVerify(token, identityKeys, { issuer: `https://securetoken.google.com/${projectId}`, audience: projectId, - }); - const claims = claimsSchema.parse(result.payload); + }) + .then((result) => claimsSchema.parse(result.payload)) + .catch(() => { + throw new UnauthorizedException("A valid verified account token is required"); + }); + await this.accounts.verify(claims.sub, claims); + if ( + process.env.NODE_ENV === "production" && + claims.tenant_id !== process.env.GOOGLE_WEBHOOK_TENANT_ID + ) + throw new ForbiddenException("This pilot is restricted to its configured workspace"); request[principalKey] = { userId: claims.app_user_id, tenantId: claims.tenant_id, @@ -96,6 +114,12 @@ export class RolesGuard implements CanActivate { if (!principal || !allowed.includes(principal.role)) { throw new ForbiddenException("Your role cannot perform this action"); } + if ( + ["owner", "approver"].includes(principal.role) && + request.method !== "GET" && + !principal.mfaVerified + ) + throw new ForbiddenException("Completa l'accesso con MFA prima di questa operazione"); return true; } } diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts new file mode 100644 index 0000000..a1f25e0 --- /dev/null +++ b/apps/api/src/config.ts @@ -0,0 +1,44 @@ +export function assertStartupConfiguration(env: NodeJS.ProcessEnv = process.env): void { + if (env.NODE_ENV !== "production") return; + for (const [name, expected] of Object.entries({ + AUTH_MODE: "identity", + STORAGE_MODE: "postgres", + GOOGLE_MODE: "live", + AI_MODE: "live", + TASKS_MODE: "live", + EMBEDDING_MODE: "vertex", + })) { + if (env[name] !== expected) throw new Error(`${name} must be ${expected} in production`); + } + for (const name of [ + "DATABASE_URL", + "GOOGLE_KMS_KEY_NAME", + "IDENTITY_PROJECT_ID", + "GOOGLE_CLIENT_ID", + "GOOGLE_CLIENT_SECRET", + "GOOGLE_REDIRECT_URI", + "GOOGLE_PUBSUB_TOPIC", + "OAUTH_STATE_SECRET", + "INTERNAL_WORKER_SECRET", + "OPENROUTER_API_KEY", + "OPENROUTER_MODEL", + "OPENROUTER_PROVIDER_ALLOWLIST", + "GOOGLE_WEBHOOK_TENANT_ID", + "GOOGLE_WEBHOOK_ACTOR_ID", + "WEB_ORIGIN", + "WORKER_PUBLIC_URL", + "GOOGLE_CLOUD_PROJECT", + "TASKS_QUEUE", + "PUSH_SERVICE_ACCOUNT_EMAIL", + ]) { + if (!env[name]?.trim()) throw new Error(`${name} is required in production`); + } + for (const name of ["GOOGLE_REDIRECT_URI", "WEB_ORIGIN", "WORKER_PUBLIC_URL"]) + if (!env[name]?.startsWith("https://")) throw new Error(`${name} requires HTTPS`); + if (env.OPENROUTER_BASE_URL && !env.OPENROUTER_BASE_URL.startsWith("https://")) + throw new Error("OPENROUTER_BASE_URL requires HTTPS"); + if (!env.OPENROUTER_PROVIDER_ALLOWLIST?.split(",").some((value) => value.trim())) + throw new Error("A non-empty provider allowlist is required"); + if ((env.OAUTH_STATE_SECRET?.length ?? 0) < 32 || (env.INTERNAL_WORKER_SECRET?.length ?? 0) < 32) + throw new Error("Worker/OAuth secrets must contain at least 32 characters"); +} diff --git a/apps/api/src/controllers.ts b/apps/api/src/controllers.ts index 718e178..fa2a954 100644 --- a/apps/api/src/controllers.ts +++ b/apps/api/src/controllers.ts @@ -23,14 +23,21 @@ import { googleReviewNotificationSchema, pubSubEnvelopeSchema, type RequestPrincipal, - type ReviewSnapshot, reviewListQuerySchema, + reviewSnapshotSchema, revisionRequestSchema, } from "@reviewguard/contracts"; -import type { GoogleBusinessGateway } from "@reviewguard/core"; +import { + decideAutomation, + FakeGoogleBusinessClient, + type GoogleBusinessGateway, +} from "@reviewguard/core"; import { z } from "zod"; import { Principal, Public, Roles } from "./auth.js"; import { DEMO_SNAPSHOTS, DEMO_TENANT_ID, DEMO_USER_ID } from "./demo.js"; +import { IntegrationService } from "./integration.service.js"; +import { KnowledgeService } from "./knowledge.service.js"; +import { ReviewNotificationService } from "./notifications.js"; import { GOOGLE_GATEWAY } from "./providers.js"; import { ReviewService } from "./review.service.js"; import { MemoryStore } from "./store.js"; @@ -52,9 +59,25 @@ export class ReviewsController { constructor(private readonly reviews: ReviewService) {} @Get() - list(@Principal() principal: RequestPrincipal, @Query() query: unknown) { + async list(@Principal() principal: RequestPrincipal, @Query() query: unknown) { const parsed = reviewListQuerySchema.parse(query); - return { data: this.reviews.list(principal, parsed), meta: { limit: parsed.limit } }; + const matches = (await this.reviews.list(principal, parsed.status)).filter( + (review) => !parsed.locationId || review.snapshot.locationId === parsed.locationId, + ); + const position = parsed.cursor + ? matches.findIndex((review) => review.id === parsed.cursor) + : -1; + if (parsed.cursor && position < 0) + throw new BadRequestException("Pagina scaduta: aggiorna l’inbox"); + const data = matches.slice(position + 1, position + 1 + parsed.limit); + return { + data, + meta: { + limit: parsed.limit, + total: matches.length, + nextCursor: position + 1 + data.length < matches.length ? data.at(-1)?.id : null, + }, + }; } @Get(":id") @@ -116,11 +139,14 @@ export class ReviewsController { @ApiTags("knowledge") @Controller("knowledge") export class KnowledgeController { - constructor(private readonly store: MemoryStore) {} + constructor( + private readonly store: MemoryStore, + private readonly knowledge: KnowledgeService, + ) {} @Get() - list(@Principal() principal: RequestPrincipal) { - return { data: this.store.listKnowledge(principal.tenantId) }; + async list(@Principal() principal: RequestPrincipal) { + return { data: await this.store.listKnowledge(principal.tenantId) }; } @Post() @@ -129,11 +155,64 @@ export class KnowledgeController { return this.store.createKnowledge(principal, createKnowledgeSourceSchema.parse(body)); } + @Get(":id") + async getSource(@Principal() principal: RequestPrincipal, @Param("id") id: string) { + const entry = (await this.store.listKnowledge(principal.tenantId)).find( + (source) => source.id === id, + ); + if (!entry) throw new BadRequestException("Fonte non disponibile"); + return entry; + } + + @Post(":id/edit") + @Roles("owner", "admin", "editor") + async editSource( + @Principal() principal: RequestPrincipal, + @Param("id") id: string, + @Body() body: unknown, + ) { + const input = createKnowledgeSourceSchema + .extend({ expectedVersion: z.number().int().positive() }) + .parse(body); + const current = (await this.store.listKnowledge(principal.tenantId)).find( + (source) => source.id === id, + ); + if (!current || current.version !== input.expectedVersion) + throw new BadRequestException("La fonte è cambiata: ricaricala prima di salvare"); + return this.store.changeKnowledge( + principal.tenantId, + id, + "draft", + createKnowledgeSourceSchema.parse(input), + input.expectedVersion, + ); + } + + @Post(":id/retire") + @Roles("owner", "admin") + retire(@Principal() principal: RequestPrincipal, @Param("id") id: string, @Body() body: unknown) { + return this.store.changeKnowledge( + principal.tenantId, + id, + "retired", + {}, + decisionRequestSchema.parse(body).expectedVersion, + ); + } + @Post(":id/approve") @Roles("owner", "admin") - approve(@Principal() principal: RequestPrincipal, @Param("id") id: string) { - const result = this.store.approveKnowledge(principal.tenantId, id); - this.store.appendAudit(principal, "knowledge.approved", "knowledge", id, { + async approve( + @Principal() principal: RequestPrincipal, + @Param("id") id: string, + @Body() body: unknown, + ) { + const result = await this.knowledge.approve( + principal.tenantId, + id, + decisionRequestSchema.parse(body).expectedVersion, + ); + await this.store.appendAudit(principal, "knowledge.approved", "knowledge", id, { version: result.version, }); return result; @@ -146,8 +225,8 @@ export class AutomationController { constructor(private readonly store: MemoryStore) {} @Get() - list(@Principal() principal: RequestPrincipal) { - return { data: this.store.listRules(principal.tenantId) }; + async list(@Principal() principal: RequestPrincipal) { + return { data: await this.store.listRules(principal.tenantId) }; } @Post() @@ -156,18 +235,58 @@ export class AutomationController { return this.store.createRule(principal.tenantId, createAutomationRuleSchema.parse(body)); } + @Post("simulate") + async simulate(@Principal() principal: RequestPrincipal, @Body() body: unknown) { + const { reviewId } = z.object({ reviewId: z.string().uuid() }).parse(body); + const review = await this.store.getReview(principal.tenantId, reviewId); + if (!review.activeDraft || !review.validation) + throw new BadRequestException("Genera e valida una bozza prima della simulazione"); + const rules = await this.store.listRules(principal.tenantId); + return decideAutomation({ + review, + draft: review.activeDraft, + validation: review.validation, + rules, + approvedManualCount: await this.store.manualApprovalCount( + principal.tenantId, + review.snapshot.locationId, + ), + sentTodayByRule: await this.store.sentTodayByRule( + principal.tenantId, + rules.map((rule) => rule.id), + ), + globalKillSwitch: + (await this.store.getSettings(principal.tenantId)).killSwitch || + process.env.AUTOMATION_RELEASE_APPROVED !== "true", + }); + } + @Post(":id/enable") @Roles("owner") - enable(@Principal() principal: RequestPrincipal, @Param("id") id: string, @Body() body: unknown) { + async enable( + @Principal() principal: RequestPrincipal, + @Param("id") id: string, + @Body() body: unknown, + ) { enableAutomationRuleSchema.parse(body); if (!principal.mfaVerified) throw new BadRequestException("MFA is required to enable automation"); - const rule = this.store.enableRule(principal, id); - this.store.appendAudit(principal, "rule.enabled", "automation_rule", id, { + if (process.env.AUTOMATION_RELEASE_APPROVED !== "true") + throw new BadRequestException("Automatic publication is not released; use manual approval"); + const rule = await this.store.enableRule(principal, id); + await this.store.appendAudit(principal, "rule.enabled", "automation_rule", id, { consentVersion: rule.consentVersion, }); return rule; } + + @Post(":id/disable") + @Roles("owner") + async disable(@Principal() principal: RequestPrincipal, @Param("id") id: string) { + const rule = await this.store.setRuleEnabled(principal, id, false); + await this.store.appendAudit(principal, "rule.disabled", "automation_rule", id); + return rule; + } } @ApiTags("audit") @@ -177,8 +296,8 @@ export class AuditController { @Get() @Roles("owner", "admin") - list(@Principal() principal: RequestPrincipal) { - return { data: this.store.listAudit(principal.tenantId) }; + async list(@Principal() principal: RequestPrincipal) { + return { data: await this.store.listAudit(principal.tenantId) }; } } @@ -199,16 +318,27 @@ export class IntegrationsController { constructor( @Inject(GOOGLE_GATEWAY) private readonly google: GoogleBusinessGateway, private readonly store: MemoryStore, + private readonly integrations: IntegrationService, ) {} @Get("start") @Roles("owner") @ApiOperation({ summary: "Start Google Business Profile OAuth" }) - start(@Principal() principal: RequestPrincipal) { + async start(@Principal() principal: RequestPrincipal) { + const nonce = crypto.randomUUID(); + await this.store.repository.put( + principal.tenantId, + "oauth", + nonce, + { userId: principal.userId }, + null, + new Date(Date.now() + 10 * 60_000).toISOString(), + ); const state = signState({ tenantId: principal.tenantId, userId: principal.userId, issuedAt: Date.now(), + nonce, }); return { authorizationUrl: this.google.buildAuthorizationUrl(state) }; } @@ -218,9 +348,28 @@ export class IntegrationsController { @Redirect(process.env.WEB_ORIGIN ?? "http://localhost:3000/settings", 302) async callback(@Query("code") code: string, @Query("state") state: string) { const payload = verifyState(state); + const record = await this.store.repository.get<{ userId: string; consumed?: boolean }>( + payload.tenantId, + "oauth", + payload.nonce, + ); + if ( + !record || + record.value.userId !== payload.userId || + record.value.consumed || + !(await this.store.repository.put( + payload.tenantId, + "oauth", + payload.nonce, + { userId: payload.userId, consumed: true }, + record.version, + )) + ) + throw new BadRequestException("OAuth session is expired or already used"); + if (!code) throw new BadRequestException("Google authorization was cancelled"); const tokens = await this.google.exchangeCode(code); - this.store.setGoogleTokens(payload.tenantId, tokens); - this.store.appendAudit( + await this.store.setGoogleTokens(payload.tenantId, tokens); + await this.store.appendAudit( { tenantId: payload.tenantId, userId: payload.userId }, "integration.connected", "google_connection", @@ -230,6 +379,36 @@ export class IntegrationsController { url: `${process.env.WEB_ORIGIN ?? "http://localhost:3000"}/settings?google=connected`, }; } + + @Get("discover") + @Roles("owner") + discover(@Principal() principal: RequestPrincipal) { + return this.integrations.discover(principal); + } + + @Post("import-location") + @Roles("owner") + importLocation(@Principal() principal: RequestPrincipal, @Body() body: unknown) { + const input = z + .object({ accountName: z.string(), locationName: z.string(), consent: z.literal(true) }) + .parse(body); + return this.integrations.importLocation(principal, input.accountName, input.locationName); + } + + @Post("sync") + @Roles("owner", "admin") + sync(@Principal() principal: RequestPrincipal, @Body() body: unknown) { + const input = z + .object({ locationId: z.string(), pageToken: z.string().max(4000).optional() }) + .parse(body); + return this.integrations.sync(principal, input.locationId, input.pageToken); + } + + @Post("disconnect") + @Roles("owner") + disconnect(@Principal() principal: RequestPrincipal) { + return this.integrations.disconnect(principal); + } } @ApiTags("webhooks") @@ -255,19 +434,55 @@ export class GoogleWebhookController { mfaVerified: true, }; const envelope = pubSubEnvelopeSchema.parse(body); - if (!this.store.claimEvent(envelope.message.messageId)) return { duplicate: true }; const notification = googleReviewNotificationSchema.parse( JSON.parse(Buffer.from(envelope.message.data, "base64").toString("utf8")), ); - const token = await currentAccessToken(this.store, this.google, principal.tenantId); - const snapshot = await this.google.getReview(token, notification.reviewName); - const review = await this.reviews.ingestAndGenerate(principal, snapshot); - return { accepted: true, reviewId: review.id }; + const location = (await this.store.listLocations(principal.tenantId)).find( + (entry) => + entry.active && + `${entry.googleAccountName}/${entry.googleLocationName}/reviews/` === + `${notification.reviewName.split("/reviews/")[0]}/reviews/`, + ); + if (process.env.GOOGLE_MODE === "live" && !location) + return { ignored: true, reason: "location_not_connected" }; + if (!(await this.store.claimEvent(principal.tenantId, envelope.message.messageId))) + return { duplicate: true }; + try { + const token = await currentAccessToken(this.store, this.google, principal.tenantId); + const snapshot = await this.google.getReview(token, notification.reviewName); + const review = await this.reviews.ingestAndGenerate( + principal, + { + ...snapshot, + locationId: location?.id ?? snapshot.locationId, + }, + notification.notificationType === "UPDATED_REVIEW", + ); + await this.store.completeEvent(principal.tenantId, envelope.message.messageId); + return { accepted: true, reviewId: review.id }; + } catch (error) { + await this.store.releaseEvent(principal.tenantId, envelope.message.messageId); + throw error; + } } @Post("demo") - async demo(@Principal() principal: RequestPrincipal) { - return this.reviews.ingestAndGenerate(principal, DEMO_SNAPSHOTS[1] as ReviewSnapshot); + async demo(@Principal() principal: RequestPrincipal, @Body() body: unknown) { + if (process.env.NODE_ENV === "production" || process.env.GOOGLE_MODE === "live") + throw new BadRequestException("Demo ingestion is disabled"); + const snapshot = + body && Object.keys(body).length + ? reviewSnapshotSchema.parse(body) + : { + ...DEMO_SNAPSHOTS[1], + googleReviewName: `accounts/demo/locations/demo/reviews/${crypto.randomUUID()}`, + createTime: new Date().toISOString(), + updateTime: new Date().toISOString(), + }; + if (!(this.google instanceof FakeGoogleBusinessClient)) + throw new BadRequestException("Mock adapter is required"); + this.google.putReview(snapshot); + return this.reviews.ingestAndGenerate(principal, snapshot); } } @@ -276,12 +491,16 @@ async function currentAccessToken( google: GoogleBusinessGateway, tenantId: string, ): Promise { - const current = store.getGoogleTokens(tenantId); - if (!current) return "demo-access-token"; + const current = await store.getGoogleTokens(tenantId); + if (!current) { + if (process.env.GOOGLE_MODE !== "live" && process.env.NODE_ENV !== "production") + return "demo-access-token"; + throw new UnauthorizedException("Connect Google before continuing"); + } if (current.expiresAt > Date.now()) return current.accessToken; if (!current.refreshToken) throw new UnauthorizedException("Google connection must be renewed"); const refreshed = await google.refreshAccessToken(current.refreshToken); - store.setGoogleTokens(tenantId, refreshed); + await store.setGoogleTokens(tenantId, refreshed); return refreshed.accessToken; } @@ -294,7 +513,26 @@ const internalPublishSchema = z.object({ @ApiTags("internal") @Controller("internal/reviews") export class InternalReviewsController { - constructor(private readonly reviews: ReviewService) {} + constructor( + private readonly reviews: ReviewService, + private readonly store: MemoryStore, + private readonly notifications: ReviewNotificationService, + ) {} + + @Post("retry-notifications") + @Public() + retryNotifications(@Headers("x-reviewguard-worker-secret") suppliedSecret: string | undefined) { + verifyWorkerSecret(suppliedSecret); + return this.notifications.retryPending(process.env.GOOGLE_WEBHOOK_TENANT_ID ?? DEMO_TENANT_ID); + } + + @Post("purge-expired-google-content") + @Public() + async purge(@Headers("x-reviewguard-worker-secret") suppliedSecret: string | undefined) { + verifyWorkerSecret(suppliedSecret); + const tenantId = process.env.GOOGLE_WEBHOOK_TENANT_ID ?? DEMO_TENANT_ID; + return { purged: await this.store.repository.purgeExpired(tenantId) }; + } @Post(":id/publish") @Public() @@ -319,13 +557,23 @@ export class InternalReviewsController { } } -function signState(payload: { tenantId: string; userId: string; issuedAt: number }): string { +function signState(payload: { + tenantId: string; + userId: string; + issuedAt: number; + nonce: string; +}): string { const encoded = Buffer.from(JSON.stringify(payload)).toString("base64url"); const signature = createHmac("sha256", oauthStateSecret()).update(encoded).digest("base64url"); return `${encoded}.${signature}`; } -function verifyState(state: string): { tenantId: string; userId: string; issuedAt: number } { +function verifyState(state: string): { + tenantId: string; + userId: string; + issuedAt: number; + nonce: string; +} { const [encoded, signature] = state.split("."); if (!encoded || !signature) throw new BadRequestException("Invalid OAuth state"); const expected = createHmac("sha256", oauthStateSecret()).update(encoded).digest(); @@ -333,12 +581,15 @@ function verifyState(state: string): { tenantId: string; userId: string; issuedA if (received.length !== expected.length || !timingSafeEqual(received, expected)) { throw new BadRequestException("Invalid OAuth state signature"); } - const payload = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8")) as { - tenantId: string; - userId: string; - issuedAt: number; - }; - if (Date.now() - payload.issuedAt > 10 * 60_000) + const payload = z + .object({ + tenantId: z.string().uuid(), + userId: z.string().uuid(), + nonce: z.string().uuid(), + issuedAt: z.number(), + }) + .parse(JSON.parse(Buffer.from(encoded, "base64url").toString("utf8"))); + if (Date.now() - payload.issuedAt > 10 * 60_000 || payload.issuedAt > Date.now() + 5000) throw new BadRequestException("Expired OAuth state"); return payload; } diff --git a/apps/api/src/document-worker.ts b/apps/api/src/document-worker.ts new file mode 100644 index 0000000..29175ce --- /dev/null +++ b/apps/api/src/document-worker.ts @@ -0,0 +1,20 @@ +import mammoth from "mammoth"; +import { PDFParse } from "pdf-parse"; + +async function extract(input: { base64: string; extension: string }) { + const data = Buffer.from(input.base64, "base64"); + if (input.extension === "docx") return (await mammoth.extractRawText({ buffer: data })).value; + const parser = new PDFParse({ data: new Uint8Array(data) }); + try { + const info = await parser.getInfo(); + if (info.total > 100) throw new Error("Too many pages"); + return (await parser.getText()).text; + } finally { + await parser.destroy(); + } +} +process.once("message", (input: { base64: string; extension: string }) => { + extract(input) + .then((text) => process.send?.({ text })) + .catch(() => process.send?.({ error: true })); +}); diff --git a/apps/api/src/documents.controller.ts b/apps/api/src/documents.controller.ts new file mode 100644 index 0000000..0e0c8c0 --- /dev/null +++ b/apps/api/src/documents.controller.ts @@ -0,0 +1,124 @@ +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { Body, Controller, Post } from "@nestjs/common"; +import type { RequestPrincipal } from "@reviewguard/contracts"; +import { DomainError } from "@reviewguard/core"; +import { z } from "zod"; +import { Principal, Roles } from "./auth.js"; +import { MemoryStore } from "./store.js"; + +export async function extractDocument(filename: string, base64: string): Promise { + const extension = filename.split(".").pop()?.toLowerCase(); + if (!extension || !["pdf", "docx", "txt", "md"].includes(extension)) + throw new DomainError( + "Formati ammessi: PDF, DOCX, TXT e Markdown", + "unsupported_document", + 400, + ); + const buffer = Buffer.from(base64, "base64"); + if (buffer.length > 4_000_000 || !buffer.length) + throw new DomainError("Il documento deve essere inferiore a 4 MB", "document_size", 400); + let text: string; + if (extension === "txt" || extension === "md") text = buffer.toString("utf8"); + else { + if ( + extension === "pdf" + ? !buffer.subarray(0, 5).equals(Buffer.from("%PDF-")) + : buffer[0] !== 0x50 || buffer[1] !== 0x4b + ) + throw new DomainError("Il contenuto non corrisponde al formato", "invalid_document", 400); + text = await new Promise((resolve, reject) => { + const path = import.meta.url.endsWith(".ts") + ? "./document-worker.ts" + : "./document-worker.js"; + const worker = spawn( + process.execPath, + ["--max-old-space-size=128", fileURLToPath(new URL(path, import.meta.url))], + { + stdio: ["ignore", "ignore", "ignore", "ipc"], + windowsHide: true, + env: { + NODE_ENV: process.env.NODE_ENV, + SystemRoot: process.env.SystemRoot, + PATH: process.env.PATH, + TEMP: process.env.TEMP, + TMP: process.env.TMP, + }, + }, + ); + let settled = false; + const timeout = setTimeout(() => { + finish(); + reject( + new DomainError( + "Estrazione scaduta. Usa un documento più semplice o incolla il testo.", + "document_timeout", + 400, + ), + ); + }, 15_000); + const finish = () => { + settled = true; + clearTimeout(timeout); + worker.kill(); + }; + worker.once("message", (value: { text?: string; error?: boolean }) => { + finish(); + if (value.error || !value.text) + reject( + new DomainError( + "Documento non leggibile. Sono richiesti PDF testuali e DOCX validi.", + "document_parse_failed", + 400, + ), + ); + else resolve(value.text); + }); + worker.once("error", () => { + finish(); + reject(new DomainError("Estrazione non riuscita", "document_parse_failed", 400)); + }); + worker.once("exit", () => { + if (!settled) { + finish(); + reject(new DomainError("Documento troppo complesso", "document_parse_failed", 400)); + } + }); + worker.send({ extension, base64 }); + }); + } + text = text.replaceAll("\u0000", "").trim(); + if (!text || text.length > 250_000) + throw new DomainError( + "Il testo deve contenere da 1 a 250.000 caratteri. Per scansioni usa prima OCR.", + "document_text_size", + 400, + ); + return text; +} +@Controller("knowledge/documents") +export class DocumentsController { + constructor(private readonly store: MemoryStore) {} + @Post() + @Roles("owner", "admin", "editor") + async upload(@Principal() principal: RequestPrincipal, @Body() body: unknown) { + const input = z + .object({ + filename: z.string().min(3).max(200), + base64: z.string().max(5_400_000), + language: z.string().min(2).max(16).default("it"), + locationId: z.string().nullable().default(null), + }) + .parse(body); + const content = await extractDocument(input.filename, input.base64); + return this.store.createKnowledge(principal, { + title: input.filename, + content, + language: input.language, + locationId: input.locationId, + kind: "document", + validFrom: null, + validUntil: null, + }); + } +} diff --git a/apps/api/src/http-exception.filter.ts b/apps/api/src/http-exception.filter.ts index d94c5dc..87d1952 100644 --- a/apps/api/src/http-exception.filter.ts +++ b/apps/api/src/http-exception.filter.ts @@ -32,10 +32,7 @@ export class HttpErrorFilter implements ExceptionFilter { response.status(exception.getStatus()).send(exception.getResponse()); return; } - console.error( - "unhandled_api_error", - exception instanceof Error ? exception.message : "unknown", - ); + console.error("unhandled_api_error", exception instanceof Error ? exception.name : "unknown"); response .status(HttpStatus.INTERNAL_SERVER_ERROR) .send({ error: "internal_error", message: "Unexpected server error" }); diff --git a/apps/api/src/identity-account.ts b/apps/api/src/identity-account.ts new file mode 100644 index 0000000..efb760f --- /dev/null +++ b/apps/api/src/identity-account.ts @@ -0,0 +1,54 @@ +import { Injectable, ServiceUnavailableException, UnauthorizedException } from "@nestjs/common"; +import { GoogleAuth } from "google-auth-library"; +import { z } from "zod"; + +const accountSchema = z.object({ + disabled: z.boolean().optional(), + emailVerified: z.boolean().optional(), + validSince: z.string().optional(), + customAttributes: z.string().optional(), +}); +export function assertCurrentIdentityAccount( + account: unknown, + claims: { auth_time?: unknown; tenant_id: string; app_user_id: string; role: string }, +) { + const value = accountSchema.parse(account); + const attributes = JSON.parse(value.customAttributes ?? "{}"); + if ( + value.disabled || + !value.emailVerified || + typeof claims.auth_time !== "number" || + claims.auth_time < Number(value.validSince ?? 0) || + attributes.tenant_id !== claims.tenant_id || + attributes.app_user_id !== claims.app_user_id || + attributes.role !== claims.role + ) + throw new UnauthorizedException("Account or permissions changed: sign in again"); +} +@Injectable() +export class IdentityAccountVerifier { + private readonly auth = new GoogleAuth({ + scopes: ["https://www.googleapis.com/auth/cloud-platform"], + }); + async verify(uid: string, claims: Parameters[1]) { + let account: unknown; + try { + const result = await this.auth.request<{ users?: unknown[] }>({ + url: `https://identitytoolkit.googleapis.com/v1/projects/${process.env.IDENTITY_PROJECT_ID}/accounts:lookup`, + method: "POST", + data: { localId: [uid] }, + timeout: 5_000, + retry: false, + }); + account = result.data.users?.[0]; + } catch { + throw new ServiceUnavailableException("Account verification is temporarily unavailable"); + } + if (!account) throw new UnauthorizedException("Account is no longer authorized"); + try { + assertCurrentIdentityAccount(account, claims); + } catch { + throw new UnauthorizedException("Account or permissions changed: sign in again"); + } + } +} diff --git a/apps/api/src/integration.service.ts b/apps/api/src/integration.service.ts new file mode 100644 index 0000000..d70dc17 --- /dev/null +++ b/apps/api/src/integration.service.ts @@ -0,0 +1,116 @@ +import { Inject, Injectable } from "@nestjs/common"; +import type { RequestPrincipal } from "@reviewguard/contracts"; +import { DomainError, type GoogleBusinessGateway } from "@reviewguard/core"; +import { GOOGLE_GATEWAY } from "./providers.js"; +import { ReviewService } from "./review.service.js"; +import { MemoryStore } from "./store.js"; + +@Injectable() +export class IntegrationService { + constructor( + private readonly store: MemoryStore, + private readonly reviews: ReviewService, + @Inject(GOOGLE_GATEWAY) private readonly google: GoogleBusinessGateway, + ) {} + async discover(principal: RequestPrincipal) { + const token = await this.reviews.currentAccessToken(principal.tenantId); + const accounts = await this.google.listAccounts(token); + return { + data: await Promise.all( + accounts.map(async (account) => ({ + ...account, + locations: await this.google.listLocations(token, account.name), + })), + ), + }; + } + async importLocation(principal: RequestPrincipal, accountName: string, locationName: string) { + const token = await this.reviews.currentAccessToken(principal.tenantId); + const accounts = await this.google.listAccounts(token); + if (!accounts.some((account) => account.name === accountName)) + throw new DomainError("Google account is not authorized", "google_account_forbidden", 403); + const allowed = (await this.google.listLocations(token, accountName)).find( + (location) => location.name === locationName, + ); + if (!allowed) + throw new DomainError("Google location is not authorized", "google_location_forbidden", 403); + const existing = (await this.store.listLocations(principal.tenantId)).find( + (location) => location.googleLocationName === locationName, + ); + const topic = process.env.GOOGLE_PUBSUB_TOPIC; + if (process.env.GOOGLE_MODE === "live" && !topic) + throw new DomainError( + "Configure Google Pub/Sub before importing", + "notifications_not_configured", + 503, + ); + await this.google.configureNotifications(token, accountName, topic ?? "demo-topic"); + const location = await this.store.upsertLocation(principal.tenantId, { + id: existing?.id ?? crypto.randomUUID(), + googleAccountName: accountName, + googleLocationName: locationName, + displayName: allowed.title, + active: true, + defaultLanguage: existing?.defaultLanguage ?? "it", + tone: existing?.tone ?? "professionale, umano e conciso", + }); + await this.store.appendAudit(principal, "integration.connected", "location", location.id, { + consentVersion: "google-location-consent-v1", + accountName, + locationName, + }); + return location; + } + async sync(principal: RequestPrincipal, locationId: string, pageToken?: string) { + const location = (await this.store.listLocations(principal.tenantId)).find( + (entry) => entry.id === locationId && entry.active, + ); + if (!location) throw new DomainError("Active location not found", "not_found", 404); + const parent = `${location.googleAccountName}/${location.googleLocationName}`; + const page = await this.google.listReviews( + await this.reviews.currentAccessToken(principal.tenantId), + parent, + pageToken, + ); + for (const snapshot of page.reviews) + await this.store.createReview(principal.tenantId, { ...snapshot, locationId }); + return { imported: page.reviews.length, nextPageToken: page.nextPageToken ?? null }; + } + async disconnect(principal: RequestPrincipal) { + await this.store.saveSettings(principal.tenantId, { + ...(await this.store.getSettings(principal.tenantId)), + killSwitch: true, + }); + const tokens = await this.store.getGoogleTokens(principal.tenantId); + const locations = await this.store.listLocations(principal.tenantId); + for (const location of locations) + await this.store.upsertLocation(principal.tenantId, { ...location, active: false }); + let remoteCleanupPending = false; + if (tokens) { + try { + const token = await this.reviews.currentAccessToken(principal.tenantId); + for (const accountName of new Set(locations.map((location) => location.googleAccountName))) + await this.google.configureNotifications( + token, + accountName, + "", + process.env.GOOGLE_PUBSUB_TOPIC, + ); + await this.google.revoke(tokens.refreshToken ?? tokens.accessToken); + } catch { + remoteCleanupPending = true; + } + } + await this.store.clearGoogleTokens(principal.tenantId); + for (const kind of ["review", "publish", "device", "oauth", "event"]) + await this.store.repository.removeKind(principal.tenantId, kind); + await this.store.appendAudit( + principal, + "integration.disconnected", + "google_connection", + principal.tenantId, + { remoteCleanupPending }, + ); + return { disconnected: true, remoteCleanupPending }; + } +} diff --git a/apps/api/src/knowledge.service.ts b/apps/api/src/knowledge.service.ts new file mode 100644 index 0000000..76b2798 --- /dev/null +++ b/apps/api/src/knowledge.service.ts @@ -0,0 +1,149 @@ +import { Injectable } from "@nestjs/common"; +import type { KnowledgeSource, ReviewSnapshot } from "@reviewguard/contracts"; +import { DomainError, InMemoryKnowledgeRetriever, VersionConflictError } from "@reviewguard/core"; +import { GoogleAuth } from "google-auth-library"; +import { z } from "zod"; +import { MemoryStore } from "./store.js"; + +const embeddingResponse = z.object({ + predictions: z + .array( + z.object({ + embeddings: z.object({ + values: z.array(z.number().finite()).length(768), + statistics: z.object({ truncated: z.boolean().optional() }).optional(), + }), + }), + ) + .min(1), +}); +export function chunkKnowledge(content: string): string[] { + const chunks: string[] = []; + let current = ""; + for (const section of content.split(/\n\s*\n/)) { + for (const piece of section.match(/[\s\S]{1,1500}/g) ?? []) { + if (`${current}\n\n${piece}`.length > 1500 && current) { + chunks.push(current); + current = ""; + } + current += (current ? "\n\n" : "") + piece; + } + } + if (current.trim()) chunks.push(current); + return chunks; +} +@Injectable() +export class KnowledgeService { + private readonly auth = new GoogleAuth({ + scopes: ["https://www.googleapis.com/auth/cloud-platform"], + }); + private get model() { + return process.env.EMBEDDING_MODEL ?? "gemini-embedding-001"; + } + constructor(private readonly store: MemoryStore) {} + private async embed(content: string, task: "RETRIEVAL_DOCUMENT" | "RETRIEVAL_QUERY") { + const region = process.env.EMBEDDING_LOCATION ?? "europe-west4"; + if (!/^[a-z0-9-]+$/.test(region) || !/^[a-z0-9-]+$/.test(this.model)) + throw new Error("Invalid embedding configuration"); + try { + const response = await this.auth.request({ + url: `https://${region}-aiplatform.googleapis.com/v1/projects/${process.env.GOOGLE_CLOUD_PROJECT}/locations/${region}/publishers/google/models/${this.model}:predict`, + method: "POST", + data: { + instances: [{ content, task_type: task }], + parameters: { outputDimensionality: 768, autoTruncate: false }, + }, + timeout: 10_000, + retry: false, + }); + const value = embeddingResponse.parse(response.data).predictions[0]?.embeddings; + if (!value || value.statistics?.truncated) throw new Error("Embedding was truncated"); + return value.values; + } catch { + throw new DomainError( + "Ricerca semantica non disponibile: verifica Vertex AI, modello e quota, poi riprova", + "embedding_failed", + 503, + ); + } + } + async approve(tenantId: string, id: string, expectedVersion: number) { + const source = (await this.store.listKnowledge(tenantId)).find((entry) => entry.id === id); + if (!source || source.version !== expectedVersion) + throw new VersionConflictError(expectedVersion, source?.version ?? 0); + if (process.env.EMBEDDING_MODE === "vertex") { + const chunks = chunkKnowledge(source.content); + if (chunks.length > 48) + throw new DomainError( + "Dividi questa fonte in documenti più piccoli (massimo 48 sezioni da 1.500 caratteri) prima di approvarla", + "knowledge_too_large", + 400, + ); + const indexed: Array<{ content: string; embedding: number[]; model: string }> = new Array( + chunks.length, + ); + let cursor = 0; + let failed = false; + await Promise.all( + Array.from({ length: Math.min(8, chunks.length) }, async () => { + while (!failed && cursor < chunks.length) { + const index = cursor++; + const content = chunks[index]; + if (content) { + try { + indexed[index] = { + content, + embedding: await this.embed(content, "RETRIEVAL_DOCUMENT"), + model: this.model, + }; + } catch (error) { + failed = true; + throw error; + } + } + } + }), + ); + // Index the future version first. The query joins the live approved version, so stale edits never become visible. + await this.store.repository.replaceKnowledgeChunks( + tenantId, + id, + expectedVersion + 1, + indexed, + ); + } + return this.store.approveKnowledge(tenantId, id, expectedVersion); + } + async retrieve(tenantId: string, review: ReviewSnapshot, sources: KnowledgeSource[]) { + if (process.env.EMBEDDING_MODE === "vertex") + return this.store.repository.searchKnowledge( + tenantId, + review.locationId, + review.comment.slice(0, 1500) || `${review.starRating} star customer review`, + await this.embed( + review.comment.slice(0, 1500) || `${review.starRating} star customer review`, + "RETRIEVAL_QUERY", + ), + this.model, + ); + const now = Date.now(); + const entries = sources.filter( + (entry) => + entry.status === "approved" && + (!entry.locationId || entry.locationId === review.locationId) && + (!entry.validFrom || Date.parse(entry.validFrom) <= now) && + (!entry.validUntil || Date.parse(entry.validUntil) > now), + ); + return new InMemoryKnowledgeRetriever( + entries.flatMap((entry) => + chunkKnowledge(entry.content).map((content) => ({ + sourceId: entry.id, + title: entry.title, + content, + score: entry.kind === "policy" || entry.kind === "forbidden_claim" ? 1 : 0.15, + version: entry.version, + })), + ), + ).retrieve({ tenantId, locationId: review.locationId, review, limit: 12 }); + } +} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 40beb9e..c2b019b 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -3,12 +3,33 @@ import { NestFactory } from "@nestjs/core"; import { FastifyAdapter, type NestFastifyApplication } from "@nestjs/platform-fastify"; import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger"; import { AppModule } from "./app.module.js"; +import { assertStartupConfiguration } from "./config.js"; import { HttpErrorFilter } from "./http-exception.filter.js"; export async function createApp() { + assertStartupConfiguration(); const app = await NestFactory.create( AppModule, - new FastifyAdapter({ logger: process.env.NODE_ENV !== "test" }), + new FastifyAdapter({ + logger: + process.env.NODE_ENV !== "test" + ? { + redact: [ + "req.headers.authorization", + "req.headers.cookie", + "req.headers.x-reviewguard-worker-secret", + ], + serializers: { + req: (request: { method: string; url: string; id: string }) => ({ + method: request.method, + path: request.url.split("?")[0], + id: request.id, + }), + }, + } + : false, + bodyLimit: 8_000_000, + }), ); app.setGlobalPrefix("v1"); app.enableCors({ @@ -16,10 +37,11 @@ export async function createApp() { credentials: true, }); app.useGlobalFilters(new HttpErrorFilter()); + app.enableShutdownHooks(); const document = SwaggerModule.createDocument( app, new DocumentBuilder() - .setTitle("ReviewGuard API") + .setTitle("AutoReview API") .setDescription("Human-controlled Google Business review reply workflow") .setVersion("1.0") .addBearerAuth() @@ -33,6 +55,9 @@ export async function createApp() { if (process.env.NODE_ENV !== "test") { const app = await createApp(); const port = Number(process.env.PORT ?? 4100); - await app.listen(port, "0.0.0.0"); - console.info(`ReviewGuard API listening on http://localhost:${port}/v1`); + await app.listen( + port, + process.env.HOST ?? (process.env.NODE_ENV === "production" ? "0.0.0.0" : "127.0.0.1"), + ); + console.info(`AutoReview API listening on port ${port}`); } diff --git a/apps/api/src/notifications.ts b/apps/api/src/notifications.ts index 8251db7..7311173 100644 --- a/apps/api/src/notifications.ts +++ b/apps/api/src/notifications.ts @@ -3,29 +3,144 @@ import type { RequestPrincipal, ReviewCase } from "@reviewguard/contracts"; import { ExpoNotificationGateway } from "@reviewguard/core"; import { MemoryStore } from "./store.js"; +type PendingPush = { + type: "push"; + reviewId: string; + reviewVersion: number; + actorId: string; + attempts: number; + nextAttemptAt: number; + leaseUntil: number; +}; + @Injectable() export class ReviewNotificationService { constructor(private readonly store: MemoryStore) {} async reviewReady(principal: RequestPrincipal, review: ReviewCase): Promise { - const registrations = this.store.listDeviceRegistrations(principal.tenantId); + if (!(await this.store.listDeviceRegistrations(principal.tenantId)).length) return false; + const id = `push/${review.id}/${review.version}`; + const value: PendingPush = { + type: "push", + reviewId: review.id, + reviewVersion: review.version, + actorId: principal.userId, + attempts: 1, + nextAttemptAt: Date.now() + 60_000, + leaseUntil: Date.now() + 30_000, + }; + const expiresAt = new Date( + Math.min( + Date.now() + 48 * 3_600_000, + Date.parse(review.contentExpiresAt ?? new Date(Date.now() + 48 * 3_600_000).toISOString()), + ), + ).toISOString(); + if (!(await this.store.repository.put(principal.tenantId, "event", id, value, null, expiresAt))) + return false; + try { + await this.send(principal.tenantId, review); + await this.store.repository.remove(principal.tenantId, "event", id); + return true; // Expo ticket acceptance, not proof of physical delivery. + } catch (error) { + await this.store.repository.put( + principal.tenantId, + "event", + id, + { ...value, leaseUntil: 0 }, + 1, + ); + throw error; + } + } + + async retryPending(tenantId: string) { + const pending = (await this.store.repository.list(tenantId, "event")) + .filter( + (entry) => + entry.value.type === "push" && + entry.value.nextAttemptAt <= Date.now() && + entry.value.leaseUntil <= Date.now(), + ) + .slice(0, 12); + let submitted = 0; + for (let offset = 0; offset < pending.length; offset += 3) { + await Promise.all( + pending.slice(offset, offset + 3).map(async (entry) => { + const value = { + ...entry.value, + attempts: entry.value.attempts + 1, + leaseUntil: Date.now() + 30_000, + }; + if (!(await this.store.repository.put(tenantId, "event", entry.id, value, entry.version))) + return; + try { + const review = await this.store.getReview(tenantId, value.reviewId); + if ( + review.version === value.reviewVersion && + ["pending_approval", "scheduled_auto"].includes(review.status) + ) { + await this.send(tenantId, review); + await this.store.appendAudit( + { tenantId, userId: value.actorId }, + "notification.sent", + "review", + review.id, + { submitted: true, retry: value.attempts }, + ); + submitted++; + } + await this.store.repository.remove(tenantId, "event", entry.id); + } catch { + if (value.attempts >= 8) { + await this.store.appendAudit( + { tenantId, userId: value.actorId }, + "notification.failed", + "review", + value.reviewId, + { exhausted: true }, + ); + await this.store.repository.remove(tenantId, "event", entry.id); + } else + await this.store.repository.put( + tenantId, + "event", + entry.id, + { + ...value, + leaseUntil: 0, + nextAttemptAt: Date.now() + Math.min(3_600_000, 60_000 * 2 ** value.attempts), + }, + entry.version + 1, + ); + } + }), + ); + } + return { processed: pending.length, submitted }; + } + + private async send(tenantId: string, review: ReviewCase) { + const registrations = await this.store.listDeviceRegistrations(tenantId); const userIds = [...new Set(registrations.map((registration) => registration.userId))]; - if (userIds.length === 0) return false; - const gateway = new ExpoNotificationGateway(async (recipients) => - registrations - .filter((registration) => recipients.includes(registration.userId)) - .map((registration) => registration.token), + if (userIds.length === 0) return; + const gateway = new ExpoNotificationGateway( + async (recipients) => + registrations + .filter((registration) => recipients.includes(registration.userId)) + .map((registration) => registration.token), + fetch, + (token) => this.store.removeDeviceToken(tenantId, token), + process.env.EXPO_ACCESS_TOKEN, ); const scheduled = review.status === "scheduled_auto"; await gateway.send({ userIds, title: scheduled ? "Risposta programmata" : "Risposta da approvare", body: scheduled - ? "Apri ReviewGuard per controllare o annullare l'invio." - : "Apri ReviewGuard per verificare la nuova bozza.", + ? "Apri AutoReview per controllare o annullare l'invio." + : "Apri AutoReview per verificare la nuova bozza.", route: `/reviews/${review.id}`, category: scheduled ? "auto_scheduled" : "approval_required", }); - return true; } } diff --git a/apps/api/src/providers.ts b/apps/api/src/providers.ts index 69e545c..35b7607 100644 --- a/apps/api/src/providers.ts +++ b/apps/api/src/providers.ts @@ -12,7 +12,8 @@ export const GOOGLE_GATEWAY = Symbol("GOOGLE_GATEWAY"); export const aiProvider = { provide: AI_PROVIDER, useFactory: () => { - if ((process.env.AI_MODE ?? "mock") !== "live") return new MockReplyProvider(); + if (!["live", "openrouter"].includes(process.env.AI_MODE ?? "mock")) + return new MockReplyProvider(); return new OpenRouterReplyProvider({ apiKey: process.env.OPENROUTER_API_KEY ?? "", baseUrl: process.env.OPENROUTER_BASE_URL, diff --git a/apps/api/src/review.service.ts b/apps/api/src/review.service.ts index 74e4796..d6d7a84 100644 --- a/apps/api/src/review.service.ts +++ b/apps/api/src/review.service.ts @@ -1,22 +1,21 @@ import { Inject, Injectable } from "@nestjs/common"; -import type { - RequestPrincipal, - ReviewCase, - ReviewListQuery, - ReviewSnapshot, -} from "@reviewguard/contracts"; +import type { RequestPrincipal, ReviewCase, ReviewSnapshot } from "@reviewguard/contracts"; import { assertExpectedVersion, + DomainError, decideAutomation, detectHardStops, type GoogleBusinessGateway, type ReplyModelProvider, } from "@reviewguard/core"; +import { KnowledgeService } from "./knowledge.service.js"; import { ReviewNotificationService } from "./notifications.js"; import { AI_PROVIDER, GOOGLE_GATEWAY } from "./providers.js"; import { MemoryStore } from "./store.js"; import { PublishTaskScheduler } from "./tasks.js"; +type PublishIntent = { text: string; baseVersion: number; manual: boolean; startedAt: number }; + @Injectable() export class ReviewService { constructor( @@ -25,293 +24,458 @@ export class ReviewService { @Inject(GOOGLE_GATEWAY) private readonly google: GoogleBusinessGateway, private readonly notifications: ReviewNotificationService, private readonly tasks: PublishTaskScheduler, + private readonly knowledgeService: KnowledgeService, ) {} - - list(principal: RequestPrincipal, filters: ReviewListQuery): ReviewCase[] { - return this.store.listReviews(principal.tenantId, filters); + list(principal: RequestPrincipal, status?: ReviewCase["status"]) { + return this.store.listReviews(principal.tenantId, status); } - - get(principal: RequestPrincipal, id: string): ReviewCase { + get(principal: RequestPrincipal, id: string) { return this.store.getReview(principal.tenantId, id); } - async ingestAndGenerate( principal: RequestPrincipal, snapshot: ReviewSnapshot, + updatedEvent = false, ): Promise { - const review = this.store.createReview(principal.tenantId, snapshot); - this.store.appendAudit(principal, "review.received", "review", review.id, { - googleReviewNameHash: await sha256(snapshot.googleReviewName), - }); + let review = await this.store.createReview(principal.tenantId, snapshot, updatedEvent); + if ( + snapshot.existingReply || + ["pending_approval", "scheduled_auto", "published", "publishing", "rejected"].includes( + review.status, + ) + ) + return review; + if (review.status === "generating") { + if (Date.now() - Date.parse(review.updatedAt) < 120_000) + throw new DomainError("Generation is still in progress", "generation_busy", 503); + review = await this.store.transition( + principal.tenantId, + review.id, + "needs_attention", + review.version, + ); + } + await this.store.appendAudit(principal, "review.received", "review", review.id); return this.generate(principal, review.id, review.version); } - async generate( principal: RequestPrincipal, id: string, expectedVersion: number, instruction?: string, ): Promise { - const current = this.store.getReview(principal.tenantId, id); - const generating = this.store.transition(principal.tenantId, id, "generating", expectedVersion); - const knowledge = this.store - .listKnowledge(principal.tenantId) - .filter( - (entry) => - entry.status === "approved" && - (!entry.locationId || entry.locationId === current.snapshot.locationId), + let current = await this.store.getReview(principal.tenantId, id); + assertExpectedVersion(current, expectedVersion); + if (current.status === "scheduled_auto") + current = await this.cancelSchedule(principal, id, current.version); + if (current.status === "generating" && Date.now() - Date.parse(current.updatedAt) >= 120_000) + current = await this.store.transition( + principal.tenantId, + id, + "needs_attention", + current.version, + ); + const generating = await this.store.transition( + principal.tenantId, + id, + "generating", + current.version, + ); + try { + const [sources, settings, locations, rules] = await Promise.all([ + this.store.listKnowledge(principal.tenantId), + this.store.getSettings(principal.tenantId), + this.store.listLocations(principal.tenantId), + this.store.listRules(principal.tenantId), + ]); + const knowledge = await this.knowledgeService.retrieve( + principal.tenantId, + current.snapshot, + sources, + ); + const location = locations.find((entry) => entry.id === current.snapshot.locationId); + const input = { + review: current.snapshot, + knowledge, + defaultLanguage: location?.defaultLanguage ?? settings.defaultLanguage, + tone: location?.tone ?? settings.tone, + instruction, + previousDraft: current.activeDraft?.text, + }; + const generated = await this.ai.generateDraft(input); + const checked = await this.ai.validateDraft({ ...input, draft: generated.value }); + const flags = detectHardStops(current.snapshot); + if (current.wasUpdated) flags.push("review_updated"); + if (!knowledge.length) flags.push("insufficient_knowledge"); + if ( + (knowledge.length > 0 && generated.value.knowledgeSourceIds.length === 0) || + generated.value.knowledgeSourceIds.some( + (sourceId) => !knowledge.some((entry) => entry.sourceId === sourceId), + ) || + generated.value.unsupportedClaims.length || + checked.value.unsupportedClaims.length ) - .slice(0, 8) - .map((entry, index) => ({ - sourceId: entry.id, - title: entry.title, - content: entry.content.slice(0, 4_000), - score: Math.max(0.1, 1 - index * 0.1), - version: entry.version, - })); - const input = { - review: current.snapshot, - knowledge, - defaultLanguage: current.snapshot.languageHint ?? "it", - tone: "professionale, umano e conciso", - instruction, - previousDraft: current.activeDraft?.text, - }; - const { generated, checked } = await (async () => { - try { - const generatedDraft = await this.ai.generateDraft(input); - const checkedDraft = await this.ai.validateDraft({ - ...input, - draft: generatedDraft.value, - }); - return { generated: generatedDraft, checked: checkedDraft }; - } catch (error) { - const latest = this.store.getReview(principal.tenantId, id); - if (latest.status === "generating" && latest.version === generating.version) { - this.store.transition(principal.tenantId, id, "needs_attention", generating.version); + flags.push("unsupported_claim"); + if (generated.value.language.toLowerCase() !== checked.value.detectedLanguage.toLowerCase()) + flags.push("validator_disagreement"); + const validation = { + ...checked.value, + valid: checked.value.valid && flags.length === 0, + riskFlags: [...new Set([...checked.value.riskFlags, ...flags])], + }; + const draft = { + ...generated.value, + riskFlags: [...new Set([...generated.value.riskFlags, ...flags])], + requiresHumanReview: + generated.value.requiresHumanReview || + !validation.valid || + generated.value.riskFlags.length > 0, + }; + const decision = decideAutomation({ + review: generating, + draft, + validation, + rules, + approvedManualCount: await this.store.manualApprovalCount( + principal.tenantId, + current.snapshot.locationId, + ), + sentTodayByRule: await this.store.sentTodayByRule( + principal.tenantId, + rules.map((rule) => rule.id), + ), + globalKillSwitch: + settings.killSwitch || + Boolean(instruction) || + process.env.AUTOMATION_RELEASE_APPROVED !== "true", + }); + let result = await this.store.transition( + principal.tenantId, + id, + decision.action === "schedule_auto" ? "scheduled_auto" : "pending_approval", + generating.version, + { + activeDraft: draft, + validation, + scheduledAt: decision.scheduledAt, + matchedRuleId: decision.matchedRuleId, + knowledgeVersions: Object.fromEntries( + knowledge.map((entry) => [entry.sourceId, entry.version]), + ), + }, + ); + await this.store.appendAudit( + principal, + instruction ? "draft.revised" : "draft.generated", + "review", + id, + { + model: generated.model, + provider: generated.provider, + requestId: generated.requestId, + validationModel: checked.model, + promptVersion: "reply-draft-v1", + knowledgeVersions: knowledge.map((entry) => ({ + id: entry.sourceId, + version: entry.version, + })), + automationDecision: decision.action, + riskFlags: draft.riskFlags, + }, + ); + if (result.status === "scheduled_auto") { + try { + const task = await this.tasks.schedule(result, principal.userId); + await this.store.appendAudit(principal, "review.scheduled", "review", id, { + scheduledAt: result.scheduledAt, + taskName: task.taskName, + }); + } catch { + result = await this.store.transition( + principal.tenantId, + id, + "needs_attention", + result.version, + { scheduledAt: null, matchedRuleId: null }, + ); + await this.store.appendAudit(principal, "review.schedule_failed", "review", id); } - this.store.appendAudit(principal, "draft.generation_failed", "review", id, { - errorCode: error instanceof Error ? error.name : "unknown", - }); - throw error; } - })(); - const deterministicFlags = detectHardStops(current.snapshot); - const validation = { - ...checked.value, - valid: checked.value.valid && deterministicFlags.length === 0, - riskFlags: [...new Set([...checked.value.riskFlags, ...deterministicFlags])], - }; - const draft = { - ...generated.value, - riskFlags: [...new Set([...generated.value.riskFlags, ...deterministicFlags])], - requiresHumanReview: - generated.value.requiresHumanReview || !validation.valid || deterministicFlags.length > 0, - }; - - const rules = this.store.listRules(principal.tenantId); - const decision = instruction - ? { - action: "require_approval" as const, - matchedRuleId: null, - hardStops: [], - scheduledAt: null, - reason: "Human-requested revisions require approval", - } - : decideAutomation({ - review: generating, - draft, - validation, - rules, - approvedManualCount: this.store.manualApprovalCount(current.snapshot.locationId), - sentTodayByRule: this.store.sentTodayByRule(rules.map((rule) => rule.id)), - }); - const targetStatus = - decision.action === "schedule_auto" ? "scheduled_auto" : "pending_approval"; - let result = this.store.transition(principal.tenantId, id, targetStatus, generating.version, { - activeDraft: draft, - validation, - scheduledAt: decision.scheduledAt, - matchedRuleId: decision.matchedRuleId, - }); - this.store.appendAudit( - principal, - instruction ? "draft.revised" : "draft.generated", - "review", - id, - { - model: generated.model, - provider: generated.provider, - validationModel: checked.model, - promptVersion: "reply-draft-v1", - automationDecision: decision.action, - riskFlags: draft.riskFlags, - }, - ); - if (result.status === "scheduled_auto") { try { - const task = await this.tasks.schedule(result, principal.userId); - this.store.appendAudit(principal, "review.scheduled", "review", id, { - scheduledAt: result.scheduledAt, - matchedRuleId: result.matchedRuleId, - taskName: task.taskName, - }); - } catch (error) { - result = this.store.transition(principal.tenantId, id, "needs_attention", result.version, { - scheduledAt: null, - matchedRuleId: null, - }); - this.store.appendAudit(principal, "review.schedule_failed", "review", id, { - errorCode: error instanceof Error ? error.name : "unknown", - }); + if (await this.notifications.reviewReady(principal, result)) + await this.store.appendAudit(principal, "notification.sent", "review", id); + } catch { + await this.store.appendAudit(principal, "notification.failed", "review", id); } - } - try { - const sent = await this.notifications.reviewReady(principal, result); - if (sent) - this.store.appendAudit(principal, "notification.sent", "review", id, { - category: result.status, - }); + return result; } catch (error) { - this.store.appendAudit(principal, "notification.failed", "review", id, { - errorCode: error instanceof Error ? error.name : "unknown", + const latest = await this.store.getReview(principal.tenantId, id); + if (latest.status === "generating" && latest.version === generating.version) + await this.store.transition(principal.tenantId, id, "needs_attention", latest.version); + await this.store.appendAudit(principal, "draft.generation_failed", "review", id, { + errorCode: error instanceof DomainError ? error.code : "provider_error", }); + throw error; } - return result; } - - editDraft( + async editDraft( principal: RequestPrincipal, id: string, expectedVersion: number, text: string, - ): ReviewCase { - const review = this.store.getReview(principal.tenantId, id); - if (!review.activeDraft) throw new Error("Review has no draft to edit"); + ): Promise { + const review = await this.store.getReview(principal.tenantId, id); assertExpectedVersion(review, expectedVersion); - return this.store.saveReview({ + if ( + !review.activeDraft || + !["pending_approval", "scheduled_auto", "needs_attention"].includes(review.status) + ) + throw new DomainError("Draft cannot be edited in this state", "invalid_transition", 409); + const result = await this.store.saveReview({ ...review, activeDraft: { ...review.activeDraft, text, requiresHumanReview: true }, + validation: null, status: "pending_approval", scheduledAt: null, matchedRuleId: null, version: review.version + 1, updatedAt: new Date().toISOString(), }); + await this.store.appendAudit(principal, "draft.revised", "review", id, { manualEdit: true }); + return result; } - async approve( principal: RequestPrincipal, id: string, expectedVersion: number, manual = true, ): Promise { - const review = this.store.getReview(principal.tenantId, id); - if (!review.activeDraft) throw new Error("Review has no active draft"); - const publishing = this.store.transition(principal.tenantId, id, "publishing", expectedVersion); - this.store.appendAudit(principal, "review.approved", "review", id); - this.store.appendAudit(principal, "reply.publish_started", "review", id); - try { - const accessToken = await this.currentAccessToken(principal.tenantId); - const canonical = await this.google.getReview(accessToken, review.snapshot.googleReviewName); - if (canonical.updateTime !== review.snapshot.updateTime || canonical.existingReply) { - return this.store.transition( - principal.tenantId, - id, - "needs_attention", - publishing.version, - { - snapshot: canonical, - scheduledAt: null, - matchedRuleId: null, - validation: review.validation - ? { - ...review.validation, - valid: false, - riskFlags: [ - ...review.validation.riskFlags, - canonical.existingReply ? "existing_reply" : "review_updated", - ], - } - : null, - }, - ); - } - const published = await this.google.updateReply( - accessToken, - review.snapshot.googleReviewName, - review.activeDraft.text, - ); - const result = this.store.transition( - principal.tenantId, - id, - "published", - publishing.version, - { - publishedAt: published.updateTime, - publishedReply: published.comment, - scheduledAt: null, - }, + let review = await this.store.getReview(principal.tenantId, id); + const intent = await this.store.repository.get( + principal.tenantId, + "publish", + id, + ); + if (review.status === "published" && intent?.value.baseVersion === expectedVersion) + return review; + if (review.status === "publishing") { + if ( + !intent || + (expectedVersion !== intent.value.baseVersion && expectedVersion !== review.version) + ) + throw new DomainError("Publication cannot be reconciled", "publish_conflict", 409); + if (Date.now() - intent.value.startedAt < 120_000) + throw new DomainError("Publication is still in progress", "publication_busy", 503); + return this.reconcile(principal, review, intent.value); + } + if (!manual && (review.status !== "scheduled_auto" || review.version !== expectedVersion)) + return review; // A cancelled/stale task is acknowledged without publishing. + assertExpectedVersion(review, expectedVersion); + if (!review.activeDraft) throw new DomainError("Review has no draft", "missing_draft", 409); + if (manual && (!principal.mfaVerified || !["owner", "approver"].includes(principal.role))) + throw new DomainError("MFA and an approver role are required", "mfa_required", 403); + if ( + process.env.GOOGLE_MODE === "live" && + !(await this.store.listLocations(principal.tenantId)).some( + (location) => + location.active && + location.id === review.snapshot.locationId && + review.snapshot.googleReviewName.startsWith( + `${location.googleAccountName}/${location.googleLocationName}/reviews/`, + ), + ) + ) + throw new DomainError( + "Collega nuovamente la sede prima di pubblicare", + "google_location_disconnected", + 409, ); - this.store.appendAudit(principal, "reply.published", "review", id, { - googleUpdateTime: published.updateTime, + const sources = await this.store.listKnowledge(principal.tenantId); + if ( + review.activeDraft.knowledgeSourceIds.some((sourceId) => { + const source = sources.find((entry) => entry.id === sourceId); + return ( + !source || + source.status !== "approved" || + (review.knowledgeVersions?.[sourceId] !== undefined && + review.knowledgeVersions[sourceId] !== source.version) || + (source.locationId && source.locationId !== review.snapshot.locationId) || + (source.validFrom && Date.parse(source.validFrom) > Date.now()) || + (source.validUntil && Date.parse(source.validUntil) <= Date.now()) + ); + }) + ) + return this.store.transition(principal.tenantId, id, "needs_attention", review.version, { + activeDraft: null, + validation: null, + scheduledAt: null, + matchedRuleId: null, }); - this.store.recordPublished(result, manual); - return result; + if (!manual) { + if (!review.scheduledAt || Date.parse(review.scheduledAt) > Date.now()) + throw new DomainError("Task arrived before scheduled delivery", "task_early", 503); + const [rules, settings] = await Promise.all([ + this.store.listRules(principal.tenantId), + this.store.getSettings(principal.tenantId), + ]); + const rule = rules.find((entry) => entry.id === review.matchedRuleId); + const decision = review.validation + ? decideAutomation({ + review, + draft: review.activeDraft, + validation: review.validation, + rules: rule ? [rule] : [], + approvedManualCount: await this.store.manualApprovalCount( + principal.tenantId, + review.snapshot.locationId, + ), + sentTodayByRule: await this.store.sentTodayByRule( + principal.tenantId, + rule ? [rule.id] : [], + ), + globalKillSwitch: + settings.killSwitch || process.env.AUTOMATION_RELEASE_APPROVED !== "true", + }) + : null; + if ( + decision?.action !== "schedule_auto" || + !rule || + !(await this.store.reserveRuleSlot(principal.tenantId, rule.id, rule.dailyLimit)) + ) + return this.cancelSchedule(principal, id, review.version); + } + const value: PublishIntent = { + text: review.activeDraft.text, + baseVersion: expectedVersion, + manual, + startedAt: Date.now(), + }; + review = await this.store.beginPublication(review, value, intent?.version ?? null); + let writeAttempted = false; + try { + await this.store.appendAudit(principal, "review.approved", "review", id, { manual }); + await this.store.appendAudit(principal, "reply.publish_started", "review", id); + const token = await this.currentAccessToken(principal.tenantId); + const canonical = await this.google.getReview(token, review.snapshot.googleReviewName); + if (canonical.updateTime !== review.snapshot.updateTime || canonical.existingReply) + return this.invalidate(principal, review, canonical); + writeAttempted = true; + await this.google.updateReply(token, review.snapshot.googleReviewName, value.text); + const confirmed = await this.google.getReview(token, review.snapshot.googleReviewName); + if (confirmed.existingReply !== value.text) + throw new DomainError( + "Google publication is not confirmed", + "publication_unconfirmed", + 503, + ); + return this.confirmPublished(principal, review, value, confirmed.updateTime); } catch (error) { - const latest = this.store.getReview(principal.tenantId, id); - if (latest.status === "publishing" && latest.version === publishing.version) { - this.store.transition(principal.tenantId, id, "needs_attention", publishing.version, { - scheduledAt: null, - matchedRuleId: null, - }); + if (!writeAttempted) { + const latest = await this.store.getReview(principal.tenantId, id); + if (latest.status === "publishing" && latest.version === review.version) + await this.store.transition(principal.tenantId, id, "needs_attention", latest.version, { + scheduledAt: null, + matchedRuleId: null, + }); } - this.store.appendAudit(principal, "reply.publish_failed", "review", id, { - errorCode: error instanceof Error ? error.name : "unknown", + // Do not retry PUT after an uncertain response. Re-read Google before any subsequent action. + await this.store.appendAudit(principal, "reply.publish_failed", "review", id, { + errorCode: error instanceof DomainError ? error.code : "transport_error", }); throw error; } } - - reject( + private async reconcile(principal: RequestPrincipal, review: ReviewCase, intent: PublishIntent) { + const canonical = await this.google.getReview( + await this.currentAccessToken(principal.tenantId), + review.snapshot.googleReviewName, + ); + if (canonical.existingReply === intent.text) + return this.confirmPublished(principal, review, intent, canonical.updateTime); + if (canonical.existingReply || canonical.updateTime !== review.snapshot.updateTime) + return this.invalidate(principal, review, canonical); + return this.store.transition( + principal.tenantId, + review.id, + "pending_approval", + review.version, + { scheduledAt: null, matchedRuleId: null }, + ); + } + private async confirmPublished( principal: RequestPrincipal, - id: string, - expectedVersion: number, - reason?: string, - ): ReviewCase { - const result = this.store.transition(principal.tenantId, id, "rejected", expectedVersion, { + review: ReviewCase, + intent: PublishIntent, + time: string, + ) { + const result = await this.store.transition( + principal.tenantId, + review.id, + "published", + review.version, + { publishedAt: time, publishedReply: intent.text, scheduledAt: null }, + ); + await this.store.appendAudit(principal, "reply.published", "review", review.id, { + googleUpdateTime: time, + confirmed: true, + }); + await this.store.recordPublished(result, intent.manual); + return result; + } + private async invalidate( + principal: RequestPrincipal, + review: ReviewCase, + snapshot: ReviewSnapshot, + ) { + return this.store.transition(principal.tenantId, review.id, "needs_attention", review.version, { + snapshot: { ...snapshot, locationId: review.snapshot.locationId }, + wasUpdated: true, + activeDraft: null, + validation: null, scheduledAt: null, matchedRuleId: null, }); - this.store.appendAudit(principal, "review.rejected", "review", id, { reason: reason ?? null }); + } + async reject(principal: RequestPrincipal, id: string, expectedVersion: number, reason?: string) { + const result = await this.store.transition( + principal.tenantId, + id, + "rejected", + expectedVersion, + { scheduledAt: null, matchedRuleId: null }, + ); + await this.store.appendAudit(principal, "review.rejected", "review", id, { + reasonProvided: Boolean(reason), + }); return result; } - - cancelSchedule(principal: RequestPrincipal, id: string, expectedVersion: number): ReviewCase { - const result = this.store.transition( + async cancelSchedule(principal: RequestPrincipal, id: string, expectedVersion: number) { + const result = await this.store.transition( principal.tenantId, id, "pending_approval", expectedVersion, { scheduledAt: null, matchedRuleId: null }, ); - this.store.appendAudit(principal, "review.schedule_cancelled", "review", id); + await this.store.appendAudit(principal, "review.schedule_cancelled", "review", id); return result; } - - private async currentAccessToken(tenantId: string): Promise { - const current = this.store.getGoogleTokens(tenantId); - if (!current) return "demo-access-token"; + async currentAccessToken(tenantId: string): Promise { + const current = await this.store.getGoogleTokens(tenantId); + if (!current) { + if (process.env.GOOGLE_MODE !== "live" && process.env.NODE_ENV !== "production") + return "demo-access-token"; + throw new DomainError("Connect Google before continuing", "google_disconnected", 401); + } if (current.expiresAt > Date.now()) return current.accessToken; - if (!current.refreshToken) throw new Error("Google connection must be renewed"); + if (!current.refreshToken) + throw new DomainError( + "Google connection must be renewed", + "google_reauthorization_required", + 401, + ); const refreshed = await this.google.refreshAccessToken(current.refreshToken); - this.store.setGoogleTokens(tenantId, refreshed); + await this.store.setGoogleTokens(tenantId, refreshed); return refreshed.accessToken; } } - -async function sha256(value: string): Promise { - const bytes = new TextEncoder().encode(value); - const digest = await crypto.subtle.digest("SHA-256", bytes); - return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); -} diff --git a/apps/api/src/store.ts b/apps/api/src/store.ts index ea4df28..1064488 100644 --- a/apps/api/src/store.ts +++ b/apps/api/src/store.ts @@ -1,73 +1,164 @@ import { createHash } from "node:crypto"; -import { Injectable } from "@nestjs/common"; +import { Injectable, type OnModuleDestroy, type OnModuleInit } from "@nestjs/common"; import type { AuditEvent, AutomationRule, KnowledgeSource, RequestPrincipal, ReviewCase, - ReviewListQuery, ReviewSnapshot, } from "@reviewguard/contracts"; -import type { GoogleTokens } from "@reviewguard/core"; -import { NotFoundError, transitionReview } from "@reviewguard/core"; -import { DEMO_KNOWLEDGE, DEMO_REVIEWS, DEMO_RULES } from "./demo.js"; +import { + DomainError, + type GoogleTokens, + NotFoundError, + transitionReview, + VersionConflictError, +} from "@reviewguard/core"; +import { + MemoryRecordRepository, + PostgresRecordRepository, + type RecordRepository, +} from "@reviewguard/database"; +import { DEMO_KNOWLEDGE, DEMO_REVIEWS, DEMO_RULES, DEMO_TENANT_ID } from "./demo.js"; +import { KmsTokenVault, TokenVault } from "./token-vault.js"; -@Injectable() -export class MemoryStore { - private readonly reviews = new Map( - DEMO_REVIEWS.map((review) => [review.id, structuredClone(review)]), - ); - private readonly knowledge = new Map( - DEMO_KNOWLEDGE.map((entry) => [entry.id, structuredClone(entry)]), - ); - private readonly rules = new Map(DEMO_RULES.map((rule) => [rule.id, structuredClone(rule)])); - private readonly audit: AuditEvent[] = []; - private readonly processedEvents = new Set(); - private readonly googleTokens = new Map(); - private readonly deviceTokens = new Map< - string, - { tenantId: string; userId: string; platform: string; provider: string } - >(); - private readonly manualApprovalsByLocation = new Map(); - private readonly publishedTodayByRule = new Map(); +export type Location = { + id: string; + googleAccountName: string; + googleLocationName: string; + displayName: string; + active: boolean; + defaultLanguage: string; + tone: string; +}; +export type Settings = { killSwitch: boolean; defaultLanguage: string; tone: string }; +export type StoredGoogleTokens = GoogleTokens & { expiresAt: number }; - listReviews(tenantId: string, filters: ReviewListQuery = { limit: 50 }): ReviewCase[] { - return [...this.reviews.values()] - .filter( - (review) => - review.tenantId === tenantId && - (!filters.status || review.status === filters.status) && - (!filters.locationId || review.snapshot.locationId === filters.locationId), - ) - .sort((left, right) => right.createdAt.localeCompare(left.createdAt)) - .slice(0, filters.limit) - .map((review) => structuredClone(review)); +// The injection name is retained for compatibility; STORAGE_MODE selects the repository. +@Injectable() +export class MemoryStore implements OnModuleInit, OnModuleDestroy { + readonly repository: RecordRepository; + private readonly vault: TokenVault | KmsTokenVault; + constructor() { + const persistent = process.env.STORAGE_MODE === "postgres"; + if (process.env.NODE_ENV === "production" && (!persistent || !process.env.GOOGLE_KMS_KEY_NAME)) + throw new Error("Production requires PostgreSQL and GOOGLE_KMS_KEY_NAME"); + if ( + persistent && + (!process.env.DATABASE_URL || + (!process.env.TOKEN_ENCRYPTION_KEY && !process.env.GOOGLE_KMS_KEY_NAME)) + ) + throw new Error("Persistent storage requires DATABASE_URL and an encryption key"); + this.repository = persistent + ? new PostgresRecordRepository(process.env.DATABASE_URL as string) + : new MemoryRecordRepository(); + this.vault = process.env.GOOGLE_KMS_KEY_NAME + ? new KmsTokenVault(process.env.GOOGLE_KMS_KEY_NAME) + : new TokenVault(process.env.TOKEN_ENCRYPTION_KEY); } - - getReview(tenantId: string, id: string): ReviewCase { - const review = this.reviews.get(id); - if (!review || review.tenantId !== tenantId) throw new NotFoundError("Review", id); - return structuredClone(review); + async onModuleInit() { + if ( + process.env.NODE_ENV === "production" && + this.repository instanceof PostgresRecordRepository + ) + await this.repository.assertSafeRuntimeRole(process.env.GOOGLE_WEBHOOK_TENANT_ID as string); + if ((process.env.AUTH_MODE ?? "demo") === "demo" && process.env.NODE_ENV !== "production") { + for (const [kind, entries] of [ + ["review", DEMO_REVIEWS], + ["knowledge", DEMO_KNOWLEDGE], + ["rule", DEMO_RULES], + ] as const) { + for (const entry of entries) + await this.repository.put(entry.tenantId, kind, entry.id, entry, null); + } + const first = DEMO_REVIEWS[0]; + if (!first) return; + await this.upsertLocation(DEMO_TENANT_ID, { + id: first.snapshot.locationId, + googleAccountName: "accounts/demo", + googleLocationName: `locations/${first.snapshot.locationId}`, + displayName: "Sede dimostrativa", + active: true, + defaultLanguage: "it", + tone: "professionale, umano e conciso", + }); + } } - - findReviewByGoogleName(tenantId: string, name: string): ReviewCase | null { - const review = [...this.reviews.values()].find( - (candidate) => - candidate.tenantId === tenantId && candidate.snapshot.googleReviewName === name, + async onModuleDestroy() { + await this.repository.close(); + } + private async values(tenantId: string, kind: string): Promise { + return (await this.repository.list(tenantId, kind)).map((entry) => entry.value); + } + async listReviews(tenantId: string, status?: ReviewCase["status"]): Promise { + return (await this.values(tenantId, "review")) + .filter((review) => !status || review.status === status) + .sort((a, b) => b.createdAt.localeCompare(a.createdAt) || b.id.localeCompare(a.id)); + } + async getReview(tenantId: string, id: string): Promise { + const entry = await this.repository.get(tenantId, "review", id); + if (!entry) throw new NotFoundError("Review", id); + return entry.value; + } + async findReviewByGoogleName(tenantId: string, name: string) { + return ( + (await this.listReviews(tenantId)).find( + (entry) => entry.snapshot.googleReviewName === name, + ) ?? null ); - return review ? structuredClone(review) : null; } - - createReview(tenantId: string, snapshot: ReviewSnapshot): ReviewCase { - const existing = this.findReviewByGoogleName(tenantId, snapshot.googleReviewName); - if (existing) return existing; + async createReview( + tenantId: string, + snapshot: ReviewSnapshot, + updatedEvent = false, + ): Promise { + const id = deterministicUuid(snapshot.googleReviewName); + const existing = + (await this.repository.get(tenantId, "review", id)) ?? + ((process.env.AUTH_MODE ?? "demo") === "demo" + ? await this.repository.list(tenantId, "review") + : [] + ).find((entry) => entry.value.snapshot.googleReviewName === snapshot.googleReviewName); + if (existing) { + if (existing.value.snapshot.updateTime === snapshot.updateTime) return existing.value; + const ownPublishedReply = + existing.value.status === "published" && + existing.value.publishedReply === snapshot.existingReply && + existing.value.snapshot.comment === snapshot.comment && + existing.value.snapshot.starRating === snapshot.starRating; + const updated = { + ...existing.value, + snapshot, + status: ownPublishedReply ? ("published" as const) : ("needs_attention" as const), + activeDraft: null, + validation: null, + scheduledAt: null, + matchedRuleId: null, + version: existing.value.version + 1, + updatedAt: new Date().toISOString(), + contentExpiresAt: expiry(), + wasUpdated: ownPublishedReply ? existing.value.wasUpdated : true, + }; + if ( + !(await this.repository.put( + tenantId, + "review", + existing.id, + updated, + existing.version, + expiry(), + )) + ) + throw new VersionConflictError(existing.version, existing.version + 1); + return updated; + } const now = new Date().toISOString(); const review: ReviewCase = { - id: crypto.randomUUID(), + id, tenantId, snapshot, - status: "received", + status: snapshot.existingReply ? "needs_attention" : "received", version: 1, activeDraft: null, validation: null, @@ -77,41 +168,68 @@ export class MemoryStore { publishedReply: null, createdAt: now, updatedAt: now, + contentExpiresAt: expiry(), + wasUpdated: updatedEvent || snapshot.createTime !== snapshot.updateTime, }; - this.reviews.set(review.id, review); - return structuredClone(review); + if (!(await this.repository.put(tenantId, "review", id, review, null, expiry()))) + return this.getReview(tenantId, id); + return review; } - - saveReview(review: ReviewCase): ReviewCase { - this.reviews.set(review.id, structuredClone(review)); - return structuredClone(review); + async saveReview(review: ReviewCase, expectedVersion = review.version - 1): Promise { + const record = await this.repository.get(review.tenantId, "review", review.id); + if (!record) throw new NotFoundError("Review", review.id); + if ( + record.value.version !== expectedVersion || + !(await this.repository.put(review.tenantId, "review", review.id, review, record.version)) + ) + throw new VersionConflictError(expectedVersion, record.value.version); + return review; } - - transition( + async transition( tenantId: string, id: string, status: ReviewCase["status"], expectedVersion: number, patch: Partial = {}, - ): ReviewCase { + ) { return this.saveReview( - transitionReview(this.getReview(tenantId, id), status, expectedVersion, patch), + transitionReview(await this.getReview(tenantId, id), status, expectedVersion, patch), + expectedVersion, ); } - - listKnowledge(tenantId: string): KnowledgeSource[] { - return [...this.knowledge.values()] - .filter((entry) => entry.tenantId === tenantId) - .map((entry) => structuredClone(entry)); + async beginPublication(review: ReviewCase, intent: unknown, intentVersion: number | null) { + const record = await this.repository.get(review.tenantId, "review", review.id); + if (!record || record.value.version !== review.version) + throw new VersionConflictError(review.version, record?.value.version ?? 0); + const next = transitionReview(review, "publishing", review.version); + const expiresAt = + review.contentExpiresAt ?? + new Date(Date.parse(review.createdAt) + 21 * 86_400_000).toISOString(); + if ( + !(await this.repository.putMany(review.tenantId, [ + { kind: "review", id: review.id, value: next, expectedVersion: record.version, expiresAt }, + { + kind: "publish", + id: review.id, + value: intent, + expectedVersion: intentVersion, + expiresAt, + }, + ])) + ) + throw new VersionConflictError(review.version, review.version + 1); + return next; } - - createKnowledge( + async listKnowledge(tenantId: string) { + return this.values(tenantId, "knowledge"); + } + async createKnowledge( principal: RequestPrincipal, input: Omit< KnowledgeSource, "id" | "tenantId" | "status" | "version" | "authorId" | "sha256" | "createdAt" | "updatedAt" >, - ): KnowledgeSource { + ): Promise { const now = new Date().toISOString(); const entry: KnowledgeSource = { ...input, @@ -124,30 +242,43 @@ export class MemoryStore { createdAt: now, updatedAt: now, }; - this.knowledge.set(entry.id, entry); - return structuredClone(entry); + await this.repository.put(principal.tenantId, "knowledge", entry.id, entry, null); + return entry; } - - approveKnowledge(tenantId: string, id: string): KnowledgeSource { - const entry = this.knowledge.get(id); - if (!entry || entry.tenantId !== tenantId) throw new NotFoundError("Knowledge source", id); - const approved = { - ...entry, - status: "approved" as const, - version: entry.version + 1, + async approveKnowledge(tenantId: string, id: string, expectedVersion: number) { + return this.changeKnowledge(tenantId, id, "approved", {}, expectedVersion); + } + async changeKnowledge( + tenantId: string, + id: string, + status: KnowledgeSource["status"], + patch: Partial = {}, + expectedVersion?: number, + ) { + const entry = await this.repository.get(tenantId, "knowledge", id); + if (!entry) throw new NotFoundError("Knowledge source", id); + if (expectedVersion !== undefined && expectedVersion !== entry.value.version) + throw new VersionConflictError(expectedVersion, entry.value.version); + const value = { + ...entry.value, + ...patch, + id, + tenantId, + status, + version: entry.value.version + 1, + sha256: createHash("sha256") + .update(patch.content ?? entry.value.content) + .digest("hex"), updatedAt: new Date().toISOString(), }; - this.knowledge.set(id, approved); - return structuredClone(approved); + if (!(await this.repository.put(tenantId, "knowledge", id, value, entry.version))) + throw new VersionConflictError(entry.version, entry.version + 1); + return value; } - - listRules(tenantId: string): AutomationRule[] { - return [...this.rules.values()] - .filter((rule) => rule.tenantId === tenantId) - .map((rule) => structuredClone(rule)); + async listRules(tenantId: string) { + return this.values(tenantId, "rule"); } - - createRule( + async createRule( tenantId: string, input: Omit< AutomationRule, @@ -159,7 +290,7 @@ export class MemoryStore { | "createdAt" | "updatedAt" >, - ): AutomationRule { + ) { const now = new Date().toISOString(); const rule: AutomationRule = { ...input, @@ -172,34 +303,35 @@ export class MemoryStore { createdAt: now, updatedAt: now, }; - this.rules.set(rule.id, rule); - return structuredClone(rule); + await this.repository.put(tenantId, "rule", rule.id, rule, null); + return rule; } - - enableRule(principal: RequestPrincipal, id: string): AutomationRule { - const rule = this.rules.get(id); - if (!rule || rule.tenantId !== principal.tenantId) - throw new NotFoundError("Automation rule", id); + async enableRule(principal: RequestPrincipal, id: string) { + return this.setRuleEnabled(principal, id, true); + } + async setRuleEnabled(principal: RequestPrincipal, id: string, enabled: boolean) { + const entry = await this.repository.get(principal.tenantId, "rule", id); + if (!entry) throw new NotFoundError("Automation rule", id); const now = new Date().toISOString(); - const enabled = { - ...rule, - enabled: true, - consentVersion: "automation-consent-v1", - consentedBy: principal.userId, - consentedAt: now, + const value = { + ...entry.value, + enabled, + consentVersion: enabled ? "automation-consent-v1" : null, + consentedBy: enabled ? principal.userId : null, + consentedAt: enabled ? now : null, updatedAt: now, }; - this.rules.set(id, enabled); - return structuredClone(enabled); + if (!(await this.repository.put(principal.tenantId, "rule", id, value, entry.version))) + throw new VersionConflictError(entry.version, entry.version + 1); + return value; } - - appendAudit( + async appendAudit( principal: Pick, action: AuditEvent["action"], entityType: string, entityId: string, metadata: Record = {}, - ): AuditEvent { + ) { const event: AuditEvent = { id: crypto.randomUUID(), tenantId: principal.tenantId, @@ -210,81 +342,210 @@ export class MemoryStore { metadata, createdAt: new Date().toISOString(), }; - this.audit.push(event); - return structuredClone(event); + await this.repository.put(principal.tenantId, "audit", event.id, event, null); + return event; } - - listAudit(tenantId: string): AuditEvent[] { - return this.audit - .filter((event) => event.tenantId === tenantId) - .map((event) => structuredClone(event)) - .reverse(); + async listAudit(tenantId: string) { + return (await this.values(tenantId, "audit")).sort((a, b) => + b.createdAt.localeCompare(a.createdAt), + ); } - - claimEvent(messageId: string): boolean { - if (this.processedEvents.has(messageId)) return false; - this.processedEvents.add(messageId); + async claimEvent(tenantId: string, id: string): Promise { + const entry = await this.repository.get<{ completed: boolean; leaseUntil: number }>( + tenantId, + "event", + id, + ); + if (entry?.value.completed) return false; + if (entry && entry.value.leaseUntil > Date.now()) + throw new DomainError("Event processing is in progress", "event_busy", 503); + const claimed = await this.repository.put( + tenantId, + "event", + id, + { completed: false, leaseUntil: Date.now() + 120_000 }, + entry?.version ?? null, + expiry(2), + ); + if (!claimed) throw new DomainError("Event lease conflict", "event_busy", 503); return true; } - - setGoogleTokens(tenantId: string, tokens: GoogleTokens): void { - this.googleTokens.set(tenantId, { + async completeEvent(tenantId: string, id: string) { + const entry = await this.repository.get(tenantId, "event", id); + if (entry) + await this.repository.put( + tenantId, + "event", + id, + { completed: true, leaseUntil: 0 }, + entry.version, + ); + } + async releaseEvent(tenantId: string, id: string) { + await this.repository.remove(tenantId, "event", id); + } + async setGoogleTokens(tenantId: string, tokens: GoogleTokens) { + const current = await this.getGoogleTokens(tenantId); + const value = { ...tokens, + refreshToken: tokens.refreshToken ?? current?.refreshToken ?? null, expiresAt: Date.now() + Math.max(60, tokens.expiresIn - 60) * 1_000, - }); + }; + const record = await this.repository.get(tenantId, "google_tokens", "connection"); + if ( + !(await this.repository.put( + tenantId, + "google_tokens", + "connection", + { encrypted: await this.vault.seal(value, tenantId) }, + record?.version ?? null, + )) + ) + throw new VersionConflictError(record?.version ?? 0, (record?.version ?? 0) + 1); } - - getGoogleTokens(tenantId: string): (GoogleTokens & { expiresAt: number }) | null { - return this.googleTokens.get(tenantId) ?? null; + async getGoogleTokens(tenantId: string): Promise { + const record = await this.repository.get<{ encrypted: string }>( + tenantId, + "google_tokens", + "connection", + ); + return record ? this.vault.open(record.value.encrypted, tenantId) : null; } - - listDeviceRegistrations(tenantId: string): Array<{ token: string; userId: string }> { - return [...this.deviceTokens.entries()] - .filter(([, registration]) => registration.tenantId === tenantId) - .map(([token, registration]) => ({ token, userId: registration.userId })); + async clearGoogleTokens(tenantId: string) { + await this.repository.remove(tenantId, "google_tokens", "connection"); } - - registerDevice( + async listDeviceRegistrations(tenantId: string) { + return this.values<{ token: string; userId: string }>(tenantId, "device"); + } + async registerDevice( principal: RequestPrincipal, input: { token: string; platform: string; provider: string }, - ): { registered: true } { - this.deviceTokens.set(input.token, { - tenantId: principal.tenantId, - userId: principal.userId, - platform: input.platform, - provider: input.provider, - }); - return { registered: true }; + ) { + const id = createHash("sha256").update(input.token).digest("hex"); + const entry = await this.repository.get(principal.tenantId, "device", id); + await this.repository.put( + principal.tenantId, + "device", + id, + { ...input, userId: principal.userId }, + entry?.version ?? null, + ); + return { registered: true as const }; } - - manualApprovalCount(locationId: string): number { - return this.manualApprovalsByLocation.get(locationId) ?? 0; + async removeDeviceToken(tenantId: string, token: string) { + await this.repository.remove( + tenantId, + "device", + createHash("sha256").update(token).digest("hex"), + ); } - - sentTodayByRule(ruleIds: readonly string[]): Record { - const today = new Date().toISOString().slice(0, 10); + async manualApprovalCount(tenantId: string, locationId: string) { + return ( + (await this.repository.get<{ count: number }>(tenantId, "counter", `manual/${locationId}`)) + ?.value.count ?? 0 + ); + } + async sentTodayByRule(tenantId: string, ruleIds: readonly string[]) { return Object.fromEntries( - ruleIds.map((ruleId) => { - const current = this.publishedTodayByRule.get(ruleId); - return [ruleId, current?.date === today ? current.count : 0]; - }), + await Promise.all( + ruleIds.map(async (id) => [ + id, + ( + await this.repository.get<{ count: number }>( + tenantId, + "counter", + `rule/${id}/${new Date().toISOString().slice(0, 10)}`, + ) + )?.value.count ?? 0, + ]), + ), ); } - - recordPublished(review: ReviewCase, manual: boolean): void { - if (manual) { - this.manualApprovalsByLocation.set( - review.snapshot.locationId, - this.manualApprovalCount(review.snapshot.locationId) + 1, - ); + async increment(tenantId: string, id: string) { + for (let attempt = 0; attempt < 10; attempt++) { + const entry = await this.repository.get<{ count: number }>(tenantId, "counter", id); + if ( + await this.repository.put( + tenantId, + "counter", + id, + { count: (entry?.value.count ?? 0) + 1 }, + entry?.version ?? null, + ) + ) + return; } - if (review.matchedRuleId) { - const today = new Date().toISOString().slice(0, 10); - const current = this.publishedTodayByRule.get(review.matchedRuleId); - this.publishedTodayByRule.set(review.matchedRuleId, { - date: today, - count: current?.date === today ? current.count + 1 : 1, - }); + throw new Error("Counter contention"); + } + async recordPublished(review: ReviewCase, manual: boolean) { + if (manual) await this.increment(review.tenantId, `manual/${review.snapshot.locationId}`); + } + async reserveRuleSlot(tenantId: string, ruleId: string, limit: number): Promise { + const id = `rule/${ruleId}/${new Date().toISOString().slice(0, 10)}`; + for (let attempt = 0; attempt < 10; attempt++) { + const entry = await this.repository.get<{ count: number }>(tenantId, "counter", id); + const count = entry?.value.count ?? 0; + if (count >= limit) return false; + if ( + await this.repository.put( + tenantId, + "counter", + id, + { count: count + 1 }, + entry?.version ?? null, + ) + ) + return true; } + return false; + } + async getSettings(tenantId: string): Promise { + return ( + (await this.repository.get(tenantId, "settings", "business"))?.value ?? { + killSwitch: true, + defaultLanguage: "it", + tone: "professionale, umano e conciso", + } + ); + } + async saveSettings(tenantId: string, settings: Settings) { + const record = await this.repository.get(tenantId, "settings", "business"); + if ( + !(await this.repository.put( + tenantId, + "settings", + "business", + settings, + record?.version ?? null, + )) + ) + throw new VersionConflictError(record?.version ?? 0, (record?.version ?? 0) + 1); + return settings; } + async listLocations(tenantId: string) { + return this.values(tenantId, "location"); + } + async upsertLocation(tenantId: string, location: Location) { + const record = await this.repository.get(tenantId, "location", location.id); + const value = { ...record?.value, ...location }; + if ( + !(await this.repository.put( + tenantId, + "location", + location.id, + value, + record?.version ?? null, + )) + ) + throw new VersionConflictError(record?.version ?? 0, (record?.version ?? 0) + 1); + return value; + } +} +// Leave room for hourly cleanup and seven-day encrypted backup/PITR retention. +function expiry(days = 21) { + return new Date(Date.now() + days * 86_400_000).toISOString(); +} +function deterministicUuid(value: string) { + const hash = createHash("sha256").update(value).digest("hex"); + return `${hash.slice(0, 8)}-${hash.slice(8, 12)}-4${hash.slice(13, 16)}-8${hash.slice(17, 20)}-${hash.slice(20, 32)}`; } diff --git a/apps/api/src/token-vault.ts b/apps/api/src/token-vault.ts new file mode 100644 index 0000000..053f2d5 --- /dev/null +++ b/apps/api/src/token-vault.ts @@ -0,0 +1,60 @@ +import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"; +import { KeyManagementServiceClient } from "@google-cloud/kms"; + +/** Production encryption key is supplied through Secret Manager, never through the database. */ +export class TokenVault { + private readonly key: Buffer; + constructor(key?: string) { + this.key = key ? Buffer.from(key, "base64") : randomBytes(32); + if (this.key.length !== 32) + throw new Error("TOKEN_ENCRYPTION_KEY must be 32 bytes encoded as base64"); + } + seal(value: unknown, tenantId: string): string { + const iv = randomBytes(12); + const cipher = createCipheriv("aes-256-gcm", this.key, iv); + cipher.setAAD(Buffer.from(tenantId)); + const data = Buffer.concat([cipher.update(JSON.stringify(value)), cipher.final()]); + return Buffer.concat([iv, cipher.getAuthTag(), data]).toString("base64"); + } + open(value: string, tenantId: string): T { + const buffer = Buffer.from(value, "base64"); + const cipher = createDecipheriv("aes-256-gcm", this.key, buffer.subarray(0, 12)); + cipher.setAAD(Buffer.from(tenantId)); + cipher.setAuthTag(buffer.subarray(12, 28)); + return JSON.parse( + Buffer.concat([cipher.update(buffer.subarray(28)), cipher.final()]).toString(), + ) as T; + } +} + +/** Cloud KMS binds ciphertext to both the configured key and the tenant's authenticated context. */ +export class KmsTokenVault { + constructor( + private readonly keyName: string, + private readonly client = new KeyManagementServiceClient(), + ) {} + async seal(value: unknown, tenantId: string): Promise { + const [result] = await this.client.encrypt({ + name: this.keyName, + plaintext: Buffer.from(JSON.stringify(value)), + additionalAuthenticatedData: Buffer.from(tenantId), + }); + if (!result.ciphertext) throw new Error("KMS encryption did not return ciphertext"); + return `kms:${typeof result.ciphertext === "string" ? result.ciphertext : Buffer.from(result.ciphertext).toString("base64")}`; + } + async open(value: string, tenantId: string): Promise { + if (!value.startsWith("kms:")) + throw new Error("Token encryption mode differs; reconnect Google"); + const [result] = await this.client.decrypt({ + name: this.keyName, + ciphertext: Buffer.from(value.slice(4), "base64"), + additionalAuthenticatedData: Buffer.from(tenantId), + }); + if (!result.plaintext) throw new Error("KMS decryption did not return plaintext"); + return JSON.parse( + typeof result.plaintext === "string" + ? Buffer.from(result.plaintext, "base64").toString("utf8") + : Buffer.from(result.plaintext).toString("utf8"), + ) as T; + } +} diff --git a/apps/api/src/workspace.controller.ts b/apps/api/src/workspace.controller.ts new file mode 100644 index 0000000..e275732 --- /dev/null +++ b/apps/api/src/workspace.controller.ts @@ -0,0 +1,82 @@ +import { Body, Controller, Get, Param, Post } from "@nestjs/common"; +import type { RequestPrincipal } from "@reviewguard/contracts"; +import { DomainError } from "@reviewguard/core"; +import { z } from "zod"; +import { Principal, Roles } from "./auth.js"; +import { MemoryStore } from "./store.js"; + +const settingsSchema = z.object({ + killSwitch: z.boolean(), + defaultLanguage: z.string().min(2).max(16), + tone: z.string().min(3).max(1000), +}); + +@Controller() +export class WorkspaceController { + constructor(private readonly store: MemoryStore) {} + @Get("session") + session(@Principal() principal: RequestPrincipal) { + return { + principal, + demo: (process.env.AUTH_MODE ?? "demo") === "demo" && process.env.NODE_ENV !== "production", + }; + } + @Get("workspace") + async workspace(@Principal() principal: RequestPrincipal) { + const [locations, settings, reviews, knowledge, tokens] = await Promise.all([ + this.store.listLocations(principal.tenantId), + this.store.getSettings(principal.tenantId), + this.store.listReviews(principal.tenantId), + this.store.listKnowledge(principal.tenantId), + this.store.getGoogleTokens(principal.tenantId), + ]); + return { + principal, + locations: await Promise.all( + locations.map(async (location) => ({ + ...location, + manualApprovalCount: await this.store.manualApprovalCount( + principal.tenantId, + location.id, + ), + })), + ), + settings, + metrics: { + pending: reviews.filter((review) => review.status === "pending_approval").length, + attention: reviews.filter((review) => review.status === "needs_attention").length, + published: reviews.filter((review) => review.status === "published").length, + approvedSources: knowledge.filter((entry) => entry.status === "approved").length, + }, + integration: { + googleMode: process.env.GOOGLE_MODE ?? "mock", + googleConnected: Boolean(tokens), + aiMode: process.env.AI_MODE ?? "mock", + model: process.env.OPENROUTER_MODEL ?? "mock-review-model-v1", + storageMode: process.env.STORAGE_MODE ?? "memory", + automationReleased: process.env.AUTOMATION_RELEASE_APPROVED === "true", + }, + }; + } + @Post("workspace/settings") + @Roles("owner") + saveSettings(@Principal() principal: RequestPrincipal, @Body() body: unknown) { + return this.store.saveSettings(principal.tenantId, settingsSchema.parse(body)); + } + @Post("locations/:id/settings") + @Roles("owner", "admin") + async locationSettings( + @Principal() principal: RequestPrincipal, + @Param("id") id: string, + @Body() body: unknown, + ) { + const input = z + .object({ defaultLanguage: z.string().min(2).max(16), tone: z.string().min(3).max(1000) }) + .parse(body); + const location = (await this.store.listLocations(principal.tenantId)).find( + (entry) => entry.id === id, + ); + if (!location) throw new DomainError("Location not found", "not_found", 404); + return this.store.upsertLocation(principal.tenantId, { ...location, ...input }); + } +} diff --git a/apps/api/test/api.test.ts b/apps/api/test/api.test.ts index 1e18f44..a7d134e 100644 --- a/apps/api/test/api.test.ts +++ b/apps/api/test/api.test.ts @@ -48,6 +48,20 @@ describe("ReviewGuard API", () => { expect(response.statusCode).toBe(409); expect(response.json().error).toBe("version_conflict"); }); + it("paginates the inbox without dropping or repeating reviews", async () => { + const first = (await app.inject({ method: "GET", url: "/v1/reviews?limit=2" })).json(); + expect(first.data).toHaveLength(2); + expect(first.meta.total).toBe(3); + const second = ( + await app.inject({ + method: "GET", + url: `/v1/reviews?limit=2&cursor=${first.meta.nextCursor}`, + }) + ).json(); + expect(second.data).toHaveLength(1); + expect(second.meta.nextCursor).toBeNull(); + expect(new Set([...first.data, ...second.data].map((entry) => entry.id)).size).toBe(3); + }); it("publishes an approved reply after the canonical Google re-read", async () => { const response = await app.inject({ diff --git a/apps/api/test/documents.test.ts b/apps/api/test/documents.test.ts new file mode 100644 index 0000000..f210879 --- /dev/null +++ b/apps/api/test/documents.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; +import { extractDocument } from "../src/documents.controller.js"; + +function pdf(text: string) { + const stream = `BT /F1 12 Tf 40 200 Td (${text}) Tj ET`; + const objects = [ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + `<< /Length ${stream.length} >>\nstream\n${stream}\nendstream`, + ]; + let result = "%PDF-1.4\n"; + const offsets = objects.map((object, index) => { + const offset = Buffer.byteLength(result); + result += `${index + 1} 0 obj\n${object}\nendobj\n`; + return offset; + }); + const start = Buffer.byteLength(result); + result += `xref\n0 6\n0000000000 65535 f \n${offsets.map((offset) => `${String(offset).padStart(10, "0")} 00000 n \n`).join("")}trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n${start}\n%%EOF`; + return Buffer.from(result).toString("base64"); +} + +// Minimal, uncompressed OOXML archive generated in memory: no files or personal data. +function docx(text: string) { + const files = { + "[Content_Types].xml": + '', + "_rels/.rels": + '', + "word/document.xml": `${text}`, + }; + const local: Buffer[] = []; + const central: Buffer[] = []; + let offset = 0; + for (const [filename, value] of Object.entries(files)) { + const name = Buffer.from(filename); + const data = Buffer.from(value); + let crc = 0xffffffff; + for (const byte of data) { + crc ^= byte; + for (let bit = 0; bit < 8; bit++) crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0); + } + const checksum = (crc ^ 0xffffffff) >>> 0; + const header = Buffer.alloc(30); + header.writeUInt32LE(0x04034b50); + header.writeUInt16LE(20, 4); + header.writeUInt32LE(checksum, 14); + header.writeUInt32LE(data.length, 18); + header.writeUInt32LE(data.length, 22); + header.writeUInt16LE(name.length, 26); + const directory = Buffer.alloc(46); + directory.writeUInt32LE(0x02014b50); + directory.writeUInt16LE(20, 4); + directory.writeUInt16LE(20, 6); + directory.writeUInt32LE(checksum, 16); + directory.writeUInt32LE(data.length, 20); + directory.writeUInt32LE(data.length, 24); + directory.writeUInt16LE(name.length, 28); + directory.writeUInt32LE(offset, 42); + local.push(header, name, data); + central.push(directory, name); + offset += header.length + name.length + data.length; + } + const end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50); + end.writeUInt16LE(3, 8); + end.writeUInt16LE(3, 10); + end.writeUInt32LE(Buffer.concat(central).length, 12); + end.writeUInt32LE(offset, 16); + return Buffer.concat([...local, ...central, end]).toString("base64"); +} + +describe("Isolated document extraction", () => { + it("extracts textual PDF with the real parser", async () => { + expect(await extractDocument("services.pdf", pdf("Approved business information"))).toContain( + "Approved business information", + ); + }); + it("extracts valid DOCX with the real parser", async () => { + expect(await extractDocument("services.docx", docx("Opening hours are verified"))).toContain( + "Opening hours are verified", + ); + }); + it("reads UTF-8 Markdown and removes null characters", async () => { + expect( + await extractDocument( + "faq.md", + Buffer.from("# Orari\nInformazioni verificate\u0000").toString("base64"), + ), + ).toBe("# Orari\nInformazioni verificate"); + }); + it("rejects mismatched file signatures", async () => { + await expect( + extractDocument("fake.pdf", Buffer.from("not a PDF").toString("base64")), + ).rejects.toMatchObject({ code: "invalid_document" }); + }); + it("rejects unsupported file types", async () => { + await expect(extractDocument("script.html", "eA==")).rejects.toMatchObject({ + code: "unsupported_document", + }); + }); +}); diff --git a/apps/api/test/identity-account.test.ts b/apps/api/test/identity-account.test.ts new file mode 100644 index 0000000..0d3cbf5 --- /dev/null +++ b/apps/api/test/identity-account.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { assertCurrentIdentityAccount } from "../src/identity-account.js"; + +const claims = { auth_time: 200, tenant_id: "tenant", app_user_id: "user", role: "owner" }; +const account = { + emailVerified: true, + validSince: "100", + customAttributes: JSON.stringify(claims), +}; +describe("Current Identity Platform account authorization", () => { + it("accepts a verified account with matching current grants", () => + expect(() => assertCurrentIdentityAccount(account, claims)).not.toThrow()); + it("rejects revoked or disabled sessions", () => { + expect(() => assertCurrentIdentityAccount({ ...account, validSince: "201" }, claims)).toThrow(); + expect(() => assertCurrentIdentityAccount({ ...account, disabled: true }, claims)).toThrow(); + }); + it("rejects stale role and tenant claims", () => { + expect(() => assertCurrentIdentityAccount(account, { ...claims, role: "approver" })).toThrow(); + expect(() => + assertCurrentIdentityAccount(account, { ...claims, tenant_id: "other" }), + ).toThrow(); + }); +}); diff --git a/apps/api/test/knowledge.test.ts b/apps/api/test/knowledge.test.ts new file mode 100644 index 0000000..a228cd7 --- /dev/null +++ b/apps/api/test/knowledge.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { chunkKnowledge } from "../src/knowledge.service.js"; + +describe("Section-aware bounded knowledge indexing", () => { + it("keeps paragraph boundaries and limits embedding input size", () => { + const content = `first section\n\n${"x".repeat(4000)}\n\nlast section`; + const chunks = chunkKnowledge(content); + expect(chunks.every((entry) => entry.length <= 1500)).toBe(true); + expect(chunks.join("\n\n")).toContain("first section"); + expect(chunks.join("\n\n")).toContain("last section"); + expect(chunks.map((entry) => entry.match(/x/g)?.length ?? 0).reduce((a, b) => a + b, 0)).toBe( + 4000, + ); + }); +}); diff --git a/apps/api/test/notification-outbox.test.ts b/apps/api/test/notification-outbox.test.ts new file mode 100644 index 0000000..845a0a6 --- /dev/null +++ b/apps/api/test/notification-outbox.test.ts @@ -0,0 +1,54 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { DEMO_TENANT_ID, DEMO_USER_ID } from "../src/demo.js"; +import { createApp } from "../src/main.js"; +import { ReviewNotificationService } from "../src/notifications.js"; +import { MemoryStore } from "../src/store.js"; + +describe("Durable push retries", () => { + let app: Awaited>; + let store: MemoryStore; + let notifications: ReviewNotificationService; + beforeAll(async () => { + process.env.NODE_ENV = "test"; + process.env.AUTH_MODE = "demo"; + app = await createApp(); + store = app.get(MemoryStore); + notifications = app.get(ReviewNotificationService); + await store.registerDevice( + { tenantId: DEMO_TENANT_ID, userId: DEMO_USER_ID, role: "owner", mfaVerified: true }, + { token: "ExpoPushToken[local-test]", platform: "android", provider: "expo" }, + ); + }); + afterAll(async () => { + vi.unstubAllGlobals(); + await app.close(); + }); + it("keeps a failed submission and retries it once due", async () => { + const transport = vi + .fn() + .mockRejectedValueOnce(new Error("Temporary Expo failure")) + .mockResolvedValue(Response.json({ data: [{ status: "ok", id: "ticket" }] })); + vi.stubGlobal("fetch", transport); + const review = await store.getReview(DEMO_TENANT_ID, "55555555-5555-4555-8555-555555555551"); + await expect( + notifications.reviewReady( + { tenantId: DEMO_TENANT_ID, userId: DEMO_USER_ID, role: "owner", mfaVerified: true }, + review, + ), + ).rejects.toThrow(); + const id = `push/${review.id}/${review.version}`; + const record = await store.repository.get>(DEMO_TENANT_ID, "event", id); + if (!record) throw new Error("Expected pending push"); + await store.repository.put( + DEMO_TENANT_ID, + "event", + id, + { ...record.value, nextAttemptAt: 0 }, + record.version, + ); + expect((await notifications.retryPending(DEMO_TENANT_ID)).submitted).toBe(1); + expect(await store.repository.get(DEMO_TENANT_ID, "event", id)).toBeNull(); + expect(transport).toHaveBeenCalledTimes(2); + expect((await notifications.retryPending(DEMO_TENANT_ID)).processed).toBe(0); + }); +}); diff --git a/apps/api/test/publication.test.ts b/apps/api/test/publication.test.ts new file mode 100644 index 0000000..beb1c32 --- /dev/null +++ b/apps/api/test/publication.test.ts @@ -0,0 +1,160 @@ +import { FakeGoogleBusinessClient } from "@reviewguard/core"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { DEMO_TENANT_ID } from "../src/demo.js"; +import { createApp } from "../src/main.js"; +import { GOOGLE_GATEWAY } from "../src/providers.js"; +import { MemoryStore } from "../src/store.js"; + +describe("Publication recovery and canonical review checks", () => { + let app: Awaited>; + let google: FakeGoogleBusinessClient; + let store: MemoryStore; + beforeAll(async () => { + process.env.NODE_ENV = "test"; + process.env.AUTH_MODE = "demo"; + app = await createApp(); + google = app.get(GOOGLE_GATEWAY); + store = app.get(MemoryStore); + }); + afterAll(async () => { + vi.restoreAllMocks(); + await app.close(); + }); + const id = "55555555-5555-4555-8555-555555555551"; + const approve = (expectedVersion: number) => + app.inject({ + method: "POST", + url: `/v1/reviews/${id}/approve`, + payload: { expectedVersion }, + }); + + it("reconciles a successful PUT with a lost response without publishing twice", async () => { + const original = google.updateReply.bind(google); + const put = vi.spyOn(google, "updateReply").mockImplementationOnce(async (...args) => { + await original(...args); + throw new Error("Connection lost after Google accepted the reply"); + }); + expect((await approve(3)).statusCode).toBe(500); + expect((await store.getReview(DEMO_TENANT_ID, id)).status).toBe("publishing"); + expect((await approve(3)).statusCode).toBe(503); + const intent = await store.repository.get<{ startedAt: number }>(DEMO_TENANT_ID, "publish", id); + expect(intent).not.toBeNull(); + if (!intent) throw new Error("Missing publish intent"); + await store.repository.put( + DEMO_TENANT_ID, + "publish", + id, + { ...intent.value, startedAt: Date.now() - 121_000 }, + intent.version, + ); + const reconciled = await approve(3); + expect(reconciled.statusCode).toBe(201); + expect(reconciled.json().status).toBe("published"); + expect((await approve(3)).json().status).toBe("published"); + expect(put).toHaveBeenCalledTimes(1); + put.mockRestore(); + }); + + it("keeps the confirmed publication on its own Google update event", async () => { + const current = await store.getReview(DEMO_TENANT_ID, id); + const snapshot = await google.getReview("demo", current.snapshot.googleReviewName); + const updated = await store.createReview( + DEMO_TENANT_ID, + { + ...snapshot, + locationId: current.snapshot.locationId, + updateTime: new Date().toISOString(), + }, + true, + ); + expect(updated.status).toBe("published"); + }); + + it("invalidates a draft when Google changes the review before publication", async () => { + const response = await app.inject({ + method: "POST", + url: "/v1/webhooks/google-business/demo", + payload: {}, + }); + const review = response.json(); + google.putReview({ + ...review.snapshot, + comment: "Changed since generation", + updateTime: new Date(Date.now() + 1000).toISOString(), + }); + const put = vi.spyOn(google, "updateReply"); + const result = await app.inject({ + method: "POST", + url: `/v1/reviews/${review.id}/approve`, + payload: { expectedVersion: review.version }, + }); + expect(result.json().status).toBe("needs_attention"); + expect(result.json().activeDraft).toBeNull(); + expect(put).not.toHaveBeenCalled(); + put.mockRestore(); + }); + + it("only one concurrent approver reaches Google PUT", async () => { + const response = await app.inject({ + method: "POST", + url: "/v1/webhooks/google-business/demo", + payload: {}, + }); + const review = response.json(); + const put = vi.spyOn(google, "updateReply"); + const results = await Promise.all( + [1, 2].map(() => + app.inject({ + method: "POST", + url: `/v1/reviews/${review.id}/approve`, + payload: { expectedVersion: review.version }, + }), + ), + ); + expect(results.filter((r) => r.json().status === "published").length).toBeGreaterThan(0); + expect(put).toHaveBeenCalledTimes(1); + expect((await store.getReview(DEMO_TENANT_ID, review.id)).status).toBe("published"); + put.mockRestore(); + }); + + it("marks previously edited reviews as a non-disableable hard stop", async () => { + const created = new Date(Date.now() - 60_000).toISOString(); + const response = await app.inject({ + method: "POST", + url: "/v1/webhooks/google-business/demo", + payload: { + googleReviewName: `accounts/demo/locations/demo/reviews/${crypto.randomUUID()}`, + locationId: "demo-location", + reviewerDisplayName: "Test", + starRating: 5, + comment: "Great visit", + createTime: created, + updateTime: new Date().toISOString(), + existingReply: null, + }, + }); + expect(response.json().activeDraft.riskFlags).toContain("review_updated"); + expect(response.json().status).toBe("pending_approval"); + }); + it("does not regenerate a rejected unchanged review on a later duplicate event", async () => { + const response = await app.inject({ + method: "POST", + url: "/v1/webhooks/google-business/demo", + payload: {}, + }); + const review = response.json(); + const rejected = await app.inject({ + method: "POST", + url: `/v1/reviews/${review.id}/reject`, + payload: { expectedVersion: review.version }, + }); + expect(rejected.json().status).toBe("rejected"); + const duplicate = await app.inject({ + method: "POST", + url: "/v1/webhooks/google-business/demo", + payload: review.snapshot, + }); + expect(duplicate.json().status).toBe("rejected"); + expect(duplicate.json().version).toBe(rejected.json().version); + }); +}); diff --git a/apps/api/test/review.service.test.ts b/apps/api/test/review.service.test.ts index bbe8306..c9c07ab 100644 --- a/apps/api/test/review.service.test.ts +++ b/apps/api/test/review.service.test.ts @@ -4,8 +4,9 @@ import { MockReplyProvider, type ReplyModelProvider, } from "@reviewguard/core"; -import { describe, expect, it } from "vitest"; -import { DEMO_TENANT_ID, DEMO_USER_ID } from "../src/demo.js"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { DEMO_SNAPSHOTS, DEMO_TENANT_ID, DEMO_USER_ID } from "../src/demo.js"; +import { KnowledgeService } from "../src/knowledge.service.js"; import { ReviewNotificationService } from "../src/notifications.js"; import { ReviewService } from "../src/review.service.js"; import { MemoryStore } from "../src/store.js"; @@ -23,18 +24,31 @@ function createService( ai: ReplyModelProvider, google: FakeGoogleBusinessClient, ): ReviewService { + for (const snapshot of DEMO_SNAPSHOTS) google.putReview(snapshot); return new ReviewService( store, ai, google, new ReviewNotificationService(store), new PublishTaskScheduler(), + new KnowledgeService(store), ); } +async function createStore() { + vi.stubEnv("NODE_ENV", "test"); + vi.stubEnv("AUTH_MODE", "demo"); + vi.stubEnv("STORAGE_MODE", "memory"); + vi.stubEnv("EMBEDDING_MODE", "demo"); + const store = new MemoryStore(); + await store.onModuleInit(); + return store; +} + describe("ReviewService failure recovery", () => { + afterEach(() => vi.unstubAllEnvs()); it("moves failed draft generation to needs_attention", async () => { - const store = new MemoryStore(); + const store = await createStore(); const failingAi: ReplyModelProvider = { generateDraft: async () => { throw new Error("provider unavailable"); @@ -49,29 +63,47 @@ describe("ReviewService failure recovery", () => { service.generate(principal, "55555555-5555-4555-8555-555555555552", 1), ).rejects.toThrow("provider unavailable"); - expect(store.getReview(principal.tenantId, "55555555-5555-4555-8555-555555555552").status).toBe( - "needs_attention", + expect( + (await store.getReview(principal.tenantId, "55555555-5555-4555-8555-555555555552")).status, + ).toBe("needs_attention"); + expect(await store.listAudit(principal.tenantId)).toEqual( + expect.arrayContaining([expect.objectContaining({ action: "draft.generation_failed" })]), ); - expect(store.listAudit(principal.tenantId)[0]?.action).toBe("draft.generation_failed"); + const recovered = await createService( + store, + new MockReplyProvider(), + new FakeGoogleBusinessClient(), + ).generate(principal, "55555555-5555-4555-8555-555555555552", 3); + expect(recovered.status).toBe("pending_approval"); + await store.onModuleDestroy(); }); - it("moves failed publication to needs_attention", async () => { + it("recovers a failed preflight without an uncertain Google write", async () => { class FailingGoogleClient extends FakeGoogleBusinessClient { override async getReview(_accessToken: string, _reviewName: string): Promise { throw new Error("google unavailable"); } } - const store = new MemoryStore(); + const store = await createStore(); const service = createService(store, new MockReplyProvider(), new FailingGoogleClient()); await expect( service.approve(principal, "55555555-5555-4555-8555-555555555551", 3), ).rejects.toThrow("google unavailable"); - expect(store.getReview(principal.tenantId, "55555555-5555-4555-8555-555555555551").status).toBe( - "needs_attention", + expect( + (await store.getReview(principal.tenantId, "55555555-5555-4555-8555-555555555551")).status, + ).toBe("needs_attention"); + expect(await store.listAudit(principal.tenantId)).toEqual( + expect.arrayContaining([expect.objectContaining({ action: "reply.publish_failed" })]), ); - expect(store.listAudit(principal.tenantId)[0]?.action).toBe("reply.publish_failed"); + const recovered = await createService( + store, + new MockReplyProvider(), + new FakeGoogleBusinessClient(), + ).approve(principal, "55555555-5555-4555-8555-555555555551", 5); + expect(recovered.status).toBe("published"); + await store.onModuleDestroy(); }); }); diff --git a/apps/api/test/safety.test.ts b/apps/api/test/safety.test.ts new file mode 100644 index 0000000..f7dd49d --- /dev/null +++ b/apps/api/test/safety.test.ts @@ -0,0 +1,113 @@ +import { FakeGoogleBusinessClient, type ReplyModelProvider } from "@reviewguard/core"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { assertStartupConfiguration } from "../src/config.js"; +import { createApp } from "../src/main.js"; +import { AI_PROVIDER, GOOGLE_GATEWAY } from "../src/providers.js"; +import { TokenVault } from "../src/token-vault.js"; + +describe("Production configuration and token vault", () => { + it("refuses demo configuration in production", () => { + expect(() => assertStartupConfiguration({ NODE_ENV: "production" })).toThrow("AUTH_MODE"); + }); + it("authenticates encrypted token data and its tenant", () => { + const vault = new TokenVault(Buffer.alloc(32, 1).toString("base64")); + const encrypted = vault.seal({ refreshToken: "never-plain" }, "tenant-a"); + expect(encrypted).not.toContain("never-plain"); + expect(vault.open(encrypted, "tenant-a")).toEqual({ refreshToken: "never-plain" }); + expect(() => vault.open(encrypted, "tenant-b")).toThrow(); + }); +}); +describe("API workflow safety", () => { + let app: Awaited>; + beforeAll(async () => { + process.env.NODE_ENV = "test"; + process.env.AUTH_MODE = "demo"; + app = await createApp(); + }); + afterAll(async () => { + await app.close(); + }); + const id = "55555555-5555-4555-8555-555555555551"; + it("does not expose another tenant's review", async () => { + const response = await app.inject({ + method: "GET", + url: `/v1/reviews/${id}`, + headers: { "x-tenant-id": "99999999-9999-4999-8999-999999999999" }, + }); + expect(response.statusCode).toBe(404); + }); + it("requires MFA before publishing", async () => { + const response = await app.inject({ + method: "POST", + url: `/v1/reviews/${id}/approve`, + headers: { "x-mfa-verified": "false" }, + payload: { expectedVersion: 3 }, + }); + expect(response.statusCode).toBe(403); + }); + it("rejects stale edits atomically", async () => { + const first = await app.inject({ + method: "POST", + url: `/v1/reviews/${id}/edit`, + payload: { expectedVersion: 3, text: "An approved manual response" }, + }); + expect(first.statusCode).toBe(201); + const stale = await app.inject({ + method: "POST", + url: `/v1/reviews/${id}/edit`, + payload: { expectedVersion: 3, text: "Must not overwrite" }, + }); + expect(stale.statusCode).toBe(409); + }); + it("releases failed Pub/Sub events and retries generation safely", async () => { + const google = app.get(GOOGLE_GATEWAY); + const name = "accounts/demo/locations/demo-location/reviews/retry-test"; + google.putReview({ + googleReviewName: name, + locationId: "demo-location", + reviewerDisplayName: "Test", + starRating: 5, + comment: "A positive visit", + createTime: new Date().toISOString(), + updateTime: new Date().toISOString(), + existingReply: null, + }); + const envelope = { + message: { + messageId: "retry-test", + publishTime: new Date().toISOString(), + data: Buffer.from( + JSON.stringify({ + notificationType: "NEW_REVIEW", + reviewName: name, + locationName: "locations/demo-location", + }), + ).toString("base64"), + }, + }; + const ai = app.get(AI_PROVIDER); + const spy = vi.spyOn(ai, "generateDraft").mockRejectedValueOnce(new Error("Transport failed")); + const first = await app.inject({ + method: "POST", + url: "/v1/webhooks/google-business", + headers: { "x-reviewguard-worker-secret": "reviewguard-local-worker-secret" }, + payload: envelope, + }); + expect(first.statusCode).toBe(500); + const second = await app.inject({ + method: "POST", + url: "/v1/webhooks/google-business", + headers: { "x-reviewguard-worker-secret": "reviewguard-local-worker-secret" }, + payload: envelope, + }); + expect(second.statusCode).toBe(201); + const duplicate = await app.inject({ + method: "POST", + url: "/v1/webhooks/google-business", + headers: { "x-reviewguard-worker-secret": "reviewguard-local-worker-secret" }, + payload: envelope, + }); + expect(duplicate.json().duplicate).toBe(true); + spy.mockRestore(); + }); +}); diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts new file mode 100644 index 0000000..2fc79f5 --- /dev/null +++ b/apps/mobile/app.config.ts @@ -0,0 +1,21 @@ +import type { ExpoConfig } from "expo/config"; +import base from "./app.json"; + +const projectId = process.env.EXPO_PUBLIC_EAS_PROJECT_ID; +if (process.env.EAS_BUILD_PROFILE === "production") { + if (!projectId || !/^[0-9a-f-]{36}$/i.test(projectId) || projectId.startsWith("00000000")) + throw new Error("Set the real EXPO_PUBLIC_EAS_PROJECT_ID before a production build"); + if ( + !process.env.EXPO_PUBLIC_API_URL?.startsWith("https://") || + !process.env.EXPO_PUBLIC_IDENTITY_API_KEY || + process.env.EXPO_PUBLIC_AUTH_MODE === "demo" + ) + throw new Error( + "Production mobile builds require HTTPS API and Identity Platform; demo auth is forbidden", + ); +} +export default { + ...base.expo, + name: "AutoReview", + extra: { ...base.expo.extra, ...(projectId ? { eas: { projectId } } : {}) }, +} as ExpoConfig; diff --git a/apps/mobile/app.json b/apps/mobile/app.json index 287c191..a0ab935 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -34,10 +34,6 @@ "experiments": { "typedRoutes": true }, - "extra": { - "eas": { - "projectId": "00000000-0000-0000-0000-000000000000" - } - } + "extra": {} } } diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 6baa409..4245070 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -14,8 +14,10 @@ }, "dependencies": { "@reviewguard/contracts": "workspace:*", + "@reviewguard/core": "workspace:*", "expo": "57.0.23", "expo-constants": "~57.0.0", + "expo-dev-client": "57.0.19", "expo-device": "~57.0.0", "expo-linking": "~57.0.0", "expo-notifications": "57.0.19", @@ -28,8 +30,8 @@ "react-native-reanimated": "4.5.1", "react-native-safe-area-context": "~5.7.0", "react-native-screens": "~4.26.0", - "react-native-worklets": "0.10.1", - "react-native-web": "^0.21.2" + "react-native-web": "^0.21.2", + "react-native-worklets": "0.10.1" }, "devDependencies": { "@react-native/metro-config": "0.86.3", diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index f2cd5e7..df98406 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -1,36 +1,93 @@ import * as Notifications from "expo-notifications"; -import { Stack, useRouter } from "expo-router"; +import { Stack, useRouter, useSegments } from "expo-router"; import { StatusBar } from "expo-status-bar"; -import { useEffect } from "react"; -import { Platform } from "react-native"; -import { registerDeviceToken } from "@/lib/api"; -import { registerForPushNotifications } from "@/lib/notifications"; +import { useEffect, useState } from "react"; +import { ActivityIndicator, Text, View } from "react-native"; +import { accessToken, demoMode, restoreSession, subscribeSession } from "@/lib/session"; import { colors } from "@/lib/theme"; export default function RootLayout() { const router = useRouter(); + const segments: readonly string[] = useSegments(); + const [ready, setReady] = useState(false); + const [authenticated, setAuthenticated] = useState(demoMode); + const [pendingRoute, setPendingRoute] = useState(null); + const [notificationError, setNotificationError] = useState(false); useEffect(() => { - registerForPushNotifications() - .then((token) => { - if (token && (Platform.OS === "ios" || Platform.OS === "android")) { - return registerDeviceToken(token, Platform.OS); - } - }) - .catch(() => undefined); + const refresh = async () => { + try { + await restoreSession(); + setAuthenticated(demoMode || Boolean(await accessToken())); + } catch { + setAuthenticated(false); + } finally { + setReady(true); + } + }; + const unsubscribe = subscribeSession(() => { + void restoreSession() + .then((value) => setAuthenticated(demoMode || Boolean(value))) + .catch(() => setAuthenticated(false)); + }); + void refresh(); + return unsubscribe; + }, []); + useEffect(() => { + if (!ready) return; + if (!authenticated && segments[0] !== "login") { + if (segments[0] === "reviews" && /^[0-9a-f-]{36}$/i.test(segments[1] ?? "")) + setPendingRoute(`/reviews/${segments[1]}`); + router.replace("/login"); + } else if (authenticated && pendingRoute) { + router.replace(pendingRoute as never); + setPendingRoute(null); + void Notifications.clearLastNotificationResponseAsync().catch(() => + setNotificationError(true), + ); + } else if (authenticated && segments[0] === "login") router.replace("/"); + }, [ready, authenticated, segments, pendingRoute, router]); + useEffect(() => { + const accept = (route: unknown) => { + if (typeof route === "string" && /^\/reviews\/[0-9a-f-]{36}$/i.test(route)) + setPendingRoute(route); + }; const subscription = Notifications.addNotificationResponseReceivedListener((response) => { const route = response.notification.request.content.data?.route; - if (typeof route === "string" && route.startsWith("/reviews/")) router.push(route as never); - }); - Notifications.getLastNotificationResponseAsync().then((response) => { - const route = response?.notification.request.content.data?.route; - if (typeof route === "string" && route.startsWith("/reviews/")) router.push(route as never); + accept(route); }); + Notifications.getLastNotificationResponseAsync() + .then((response) => { + const route = response?.notification.request.content.data?.route; + accept(route); + }) + .catch(() => setNotificationError(true)); return () => subscription.remove(); - }, [router]); + }, []); + if (!ready) + return ( + + + + ); return ( <> + {notificationError && ( + + Notifiche non disponibili: consulta l’inbox per le nuove recensioni. + + )} + ([]); const [refreshing, setRefreshing] = useState(false); const [live, setLive] = useState(false); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + const [count, setCount] = useState(0); + const [nextCursor, setNextCursor] = useState(null); + const [moreLoading, setMoreLoading] = useState(false); const load = useCallback(async () => { - const result = await listReviews(); - setReviews(result.data); - setLive(result.live); - setRefreshing(false); + setError(null); + try { + const [result, workspace] = await Promise.all([ + listReviews(), + request<{ locations: Array<{ manualApprovalCount: number }> }>("/workspace"), + ]); + setReviews(result.data); + setNextCursor(result.nextCursor); + setLive(result.live); + setCount(workspace.locations[0]?.manualApprovalCount ?? 0); + } catch (reason) { + setError(reason instanceof Error ? reason.message : "Caricamento non riuscito"); + } finally { + setRefreshing(false); + setLoading(false); + } }, []); - useEffect(() => { - load(); - }, [load]); + useFocusEffect( + useCallback(() => { + void load(); + }, [load]), + ); return ( CENTRO APPROVAZIONI - Buongiorno, Demo + Le tue recensioni Le risposte restano sotto il tuo controllo. @@ -69,7 +101,8 @@ export default function InboxScreen() { - 14/20 + {count} + /20 CALIBRAZIONE @@ -85,9 +118,80 @@ export default function InboxScreen() { - {reviews.map((review) => ( - - ))} + {loading && ( + + Caricamento… + + )} + {error && ( + + + {error} + + + Riprova + + + )} + {!loading && !error && !reviews.length && ( + + Nessuna recensione. Collega Google e importa una sede dalla dashboard web. + + )} + {!error && reviews.map((review) => )} + {!error && nextCursor && ( + { + setMoreLoading(true); + try { + const result = await listReviews(nextCursor); + setReviews((previous) => [ + ...new Map( + [...previous, ...result.data].map((entry) => [entry.id, entry]), + ).values(), + ]); + setNextCursor(result.nextCursor); + } catch (reason) { + setError(reason instanceof Error ? reason.message : "Caricamento non riuscito"); + } finally { + setMoreLoading(false); + } + }} + > + + {moreLoading ? "Caricamento…" : "Carica altre recensioni"} + + + )} + { + try { + const token = await registerForPushNotifications(); + if (!token || (Platform.OS !== "ios" && Platform.OS !== "android")) { + Alert.alert( + "Notifiche non disponibili", + "Servono un dispositivo fisico, un progetto EAS configurato e il permesso alle notifiche. L’inbox resta utilizzabile.", + ); + return; + } + await registerDeviceToken(token, Platform.OS); + Alert.alert("Notifiche abilitate", "Riceverai avvisi senza testo delle recensioni."); + } catch { + Alert.alert( + "Notifiche non abilitate", + "Verifica la connessione e la configurazione EAS. Puoi continuare a usare l’inbox.", + ); + } + }} + > + Abilita notifiche di approvazione + + + Esci dall’account + Le notifiche non contengono mai il testo della recensione. diff --git a/apps/mobile/src/app/login.tsx b/apps/mobile/src/app/login.tsx new file mode 100644 index 0000000..0fb8624 --- /dev/null +++ b/apps/mobile/src/app/login.tsx @@ -0,0 +1,173 @@ +import type { MfaChallenge } from "@reviewguard/core"; +import { useRouter } from "expo-router"; +import { useState } from "react"; +import { + ActivityIndicator, + Alert, + Linking, + Pressable, + SafeAreaView, + ScrollView, + StyleSheet, + Text, + TextInput, +} from "react-native"; +import { identity, saveSession } from "@/lib/session"; +import { colors } from "@/lib/theme"; + +export default function LoginScreen() { + const router = useRouter(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [code, setCode] = useState(""); + const [challenge, setChallenge] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + async function login() { + setBusy(true); + setError(""); + try { + const result = challenge + ? { session: await identity().verifyMfa(challenge, code) } + : await identity().signIn(email, password); + setPassword(""); + if ("challenge" in result) setChallenge(result.challenge); + else { + await saveSession(result.session); + router.replace("/"); + } + } catch (reason) { + setError(reason instanceof Error ? reason.message : "Accesso non riuscito"); + } finally { + setBusy(false); + } + } + async function reset() { + setBusy(true); + try { + await identity().resetPassword(email); + Alert.alert("Recupero password", "Se l’account esiste riceverai un’email di recupero."); + } catch { + Alert.alert("Recupero password", "Verifica l’email o riprova più tardi."); + } finally { + setBusy(false); + } + } + return ( + + + AUTOREVIEW + + {challenge ? "Verifica l’accesso" : "Recensioni sotto controllo"} + + + {challenge + ? "Inserisci il codice della tua app authenticator." + : "Accedi con l’account autorizzato per la tua attività."} + + {challenge ? ( + + ) : ( + <> + + + + )} + {error && ( + + {error} + + )} + + {busy ? ( + + ) : ( + Accedi + )} + + {challenge ? ( + { + setChallenge(null); + setCode(""); + }} + > + Cambia account + + ) : ( + + Password dimenticata? + + )} + { + const url = process.env.EXPO_PUBLIC_WEB_URL; + if (url) void Linking.openURL(`${url.replace(/\/$/, "")}/mfa`); + else + Alert.alert( + "Configurazione MFA", + "Apri la dashboard web della tua attività per configurare il secondo fattore.", + ); + }} + > + Configura MFA dalla dashboard web + + + + ); +} +const styles = StyleSheet.create({ + safe: { flex: 1, backgroundColor: colors.background }, + content: { padding: 28, paddingTop: 60, gap: 18 }, + brand: { color: colors.green, fontWeight: "800", letterSpacing: 2 }, + title: { fontSize: 32, color: colors.ink, fontWeight: "700" }, + copy: { fontSize: 16, color: colors.muted, lineHeight: 23 }, + input: { + padding: 16, + borderRadius: 12, + borderWidth: 1, + borderColor: colors.line, + backgroundColor: colors.surface, + fontSize: 16, + color: colors.ink, + }, + button: { padding: 18, borderRadius: 12, backgroundColor: colors.green, alignItems: "center" }, + buttonText: { color: colors.surface, fontSize: 16, fontWeight: "700" }, + link: { color: colors.green, fontSize: 15, paddingVertical: 10 }, + error: { color: colors.red, fontSize: 15 }, + disabled: { opacity: 0.5 }, +}); diff --git a/apps/mobile/src/app/reviews/[id].tsx b/apps/mobile/src/app/reviews/[id].tsx index 407e82b..c37f022 100644 --- a/apps/mobile/src/app/reviews/[id].tsx +++ b/apps/mobile/src/app/reviews/[id].tsx @@ -1,6 +1,6 @@ -import type { ReviewCase } from "@reviewguard/contracts"; +import type { KnowledgeSource, RequestPrincipal, ReviewCase } from "@reviewguard/contracts"; import { useLocalSearchParams, useRouter } from "expo-router"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { ActivityIndicator, Alert, @@ -13,7 +13,7 @@ import { TextInput, View, } from "react-native"; -import { decide, getReview, revise } from "@/lib/api"; +import { decide, edit, getReview, request, revise } from "@/lib/api"; import { colors } from "@/lib/theme"; export default function ReviewDetailScreen() { @@ -22,16 +22,41 @@ export default function ReviewDetailScreen() { const [review, setReview] = useState(null); const [instruction, setInstruction] = useState(""); const [busy, setBusy] = useState(false); - useEffect(() => { - if (id) - getReview(id) - .then(setReview) - .catch((e) => Alert.alert("Errore", e.message)); + const [draft, setDraft] = useState(""); + const [error, setError] = useState(null); + const [principal, setPrincipal] = useState(null); + const [source, setSource] = useState(null); + const load = useCallback(async () => { + setError(null); + try { + const [value, session] = await Promise.all([ + getReview(id), + request<{ principal: RequestPrincipal }>("/session"), + ]); + setReview(value); + setDraft(value.activeDraft?.text ?? ""); + setPrincipal(session.principal); + } catch (reason) { + setError(reason instanceof Error ? reason.message : "Caricamento non riuscito"); + } }, [id]); + useEffect(() => { + if (id) void load(); + }, [id, load]); if (!review) return ( - + {!error && } + {error && ( + <> + + {error} + + + Riprova + + + )} ); const run = async (operation: () => Promise, success: string) => { @@ -39,8 +64,9 @@ export default function ReviewDetailScreen() { try { const next = await operation(); setReview(next); + setDraft(next.activeDraft?.text ?? ""); Alert.alert( - success, + next.status === "needs_attention" ? "Nuova verifica necessaria" : success, next.status === "published" ? "La risposta è stata confermata da Google." : undefined, ); if (next.status === "published" || next.status === "rejected") router.back(); @@ -51,6 +77,9 @@ export default function ReviewDetailScreen() { } }; const risk = Boolean(review.activeDraft?.riskFlags.length) || review.status === "needs_attention"; + const canApprove = Boolean( + principal?.mfaVerified && ["owner", "approver"].includes(principal.role), + ); return ( {review.snapshot.reviewerDisplayName} - {review.snapshot.languageHint?.toUpperCase()} · Demo Location + {review.snapshot.languageHint?.toUpperCase() ?? "AUTO"} · {review.status} @@ -90,23 +119,92 @@ export default function ReviewDetailScreen() { PROPOSTA AI Risposta pubblica - V4 PRO + DA VERIFICARE {review.activeDraft ? ( ) : ( AI - Genera una proposta dal pannello web oppure chiedi una nuova versione. + Genera una proposta, controlla le fonti e approva soltanto quando è corretta. )} + {!review.activeDraft && + !review.snapshot.existingReply && + ["received", "rejected", "needs_attention"].includes(review.status) && ( + run(() => decide(review, "generate"), "Bozza generata")} + > + Genera risposta + + )} + {review.activeDraft && draft !== review.activeDraft.text && ( + run(() => edit(review, draft), "Modifica salvata")} + > + Salva modifica prima di approvare + + )} + {review.activeDraft?.knowledgeSourceIds.map((sourceId, index) => ( + { + try { + setSource(await request(`/knowledge/${sourceId}`)); + } catch { + Alert.alert("Fonte non disponibile", "Riprova o consulta la dashboard web."); + } + }} + > + Leggi fonte {index + 1} + + ))} + {source && ( + + + {source.title} · v{source.version} · {source.status} + + {source.content} + setSource(null)}> + Chiudi fonte + + + )} + {review.status === "scheduled_auto" && ( + run(() => decide(review, "cancel-schedule"), "Invio annullato")} + > + Annulla invio programmato + + )} + {review.status === "publishing" && ( + run(() => decide(review, "approve"), "Esito verificato")} + > + Attendi due minuti, poi verifica esito Google + + )} COME DEVE ESSERE MODIFICATA? [ styles.reviseButton, (busy || instruction.length < 2) && styles.disabled, @@ -130,17 +233,34 @@ export default function ReviewDetailScreen() { ✓ Il modello non possiede credenziali di pubblicazione + {!canApprove && ( + + Per pubblicare servono ruolo Owner/Approver e accesso con MFA. + + )} [styles.reject, pressed && styles.pressed]} onPress={() => run(() => decide(review, "reject"), "Risposta rifiutata")} > Rifiuta [ styles.approve, (busy || !review.activeDraft || review.status !== "pending_approval") && diff --git a/apps/mobile/src/components/ReviewCard.tsx b/apps/mobile/src/components/ReviewCard.tsx index 95fd65f..82e3a48 100644 --- a/apps/mobile/src/components/ReviewCard.tsx +++ b/apps/mobile/src/components/ReviewCard.tsx @@ -28,7 +28,9 @@ export function ReviewCard({ review }: { review: ReviewCase }) { {review.snapshot.reviewerDisplayName} - oggi + + {new Date(review.snapshot.createTime).toLocaleDateString("it-IT")} + {review.snapshot.comment || "Recensione senza testo"} diff --git a/apps/mobile/src/lib/api.ts b/apps/mobile/src/lib/api.ts index 31b731b..843c0d8 100644 --- a/apps/mobile/src/lib/api.ts +++ b/apps/mobile/src/lib/api.ts @@ -1,50 +1,60 @@ import type { ReviewCase } from "@reviewguard/contracts"; -import { mobileDemoReviews } from "./demo"; +import { accessToken, demoMode, signOut } from "./session"; const baseUrl = process.env.EXPO_PUBLIC_API_URL ?? "http://localhost:4100/v1"; -async function request(path: string, init?: RequestInit): Promise { +export async function request(path: string, init?: RequestInit): Promise { + const token = demoMode ? null : await accessToken(); + if (!__DEV__ && !baseUrl.startsWith("https://")) + throw new Error("Configura un endpoint HTTPS prima del rilascio"); + if (!demoMode && !token) throw new Error("Accedi prima di continuare"); const response = await fetch(`${baseUrl}${path}`, { + signal: AbortSignal.timeout(95_000), ...init, headers: { "Content-Type": "application/json", - "x-role": "owner", - "x-mfa-verified": "true", + ...(token + ? { Authorization: `Bearer ${token}` } + : { "x-role": "owner", "x-mfa-verified": "true" }), ...init?.headers, }, }); if (!response.ok) { - const body = (await response.json().catch(() => ({}))) as { message?: string }; + const body = (await response.json().catch(() => ({}))) as { message?: string; error?: string }; + if (response.status === 401 && !demoMode && !body.error?.startsWith("google_")) await signOut(); throw new Error(body.message ?? `Errore ${response.status}`); } return response.json() as Promise; } -export async function listReviews(): Promise<{ data: ReviewCase[]; live: boolean }> { - try { - const result = await request<{ data: ReviewCase[] }>("/reviews"); - return { data: result.data, live: true }; - } catch { - return { data: mobileDemoReviews, live: false }; - } +export async function listReviews( + cursor?: string, +): Promise<{ data: ReviewCase[]; live: boolean; nextCursor: string | null }> { + const result = await request<{ data: ReviewCase[]; meta: { nextCursor: string | null } }>( + `/reviews?limit=50${cursor ? `&cursor=${cursor}` : ""}`, + ); + return { data: result.data, live: !demoMode, nextCursor: result.meta.nextCursor }; } export async function getReview(id: string): Promise { - try { - return await request(`/reviews/${id}`); - } catch { - const review = mobileDemoReviews.find((item) => item.id === id); - if (!review) throw new Error("Recensione non trovata"); - return review; - } + return request(`/reviews/${encodeURIComponent(id)}`); } -export function decide(review: ReviewCase, action: "approve" | "reject") { +export function decide( + review: ReviewCase, + action: "approve" | "reject" | "cancel-schedule" | "generate", +) { return request(`/reviews/${review.id}/${action}`, { method: "POST", body: JSON.stringify({ expectedVersion: review.version }), }); } +export function edit(review: ReviewCase, text: string) { + return request(`/reviews/${review.id}/edit`, { + method: "POST", + body: JSON.stringify({ expectedVersion: review.version, text }), + }); +} export function revise(review: ReviewCase, instruction: string) { return request(`/reviews/${review.id}/revise`, { diff --git a/apps/mobile/src/lib/session.ts b/apps/mobile/src/lib/session.ts new file mode 100644 index 0000000..6475473 --- /dev/null +++ b/apps/mobile/src/lib/session.ts @@ -0,0 +1,107 @@ +import { IdentityClient, type IdentitySession } from "@reviewguard/core"; +import * as SecureStore from "expo-secure-store"; +import { Platform } from "react-native"; + +const key = "autoreview_session"; +export const demoMode = process.env.EXPO_PUBLIC_AUTH_MODE === "demo" && __DEV__; +export const identity = () => new IdentityClient(process.env.EXPO_PUBLIC_IDENTITY_API_KEY ?? ""); +let session: IdentitySession | null = null; +let loaded = false; +let refreshing: Promise | null = null; +let generation = 0; +let writes: Promise = Promise.resolve(); +const listeners = new Set<() => void>(); +export function subscribeSession(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} +function notify() { + for (const listener of listeners) listener(); +} +async function read() { + return Platform.OS === "web" + ? (globalThis.sessionStorage?.getItem(key) ?? null) + : SecureStore.getItemAsync(key); +} +async function write(value: string | null) { + if (Platform.OS === "web") { + if (value) globalThis.sessionStorage?.setItem(key, value); + else globalThis.sessionStorage?.removeItem(key); + return; + } + if (value) + await SecureStore.setItemAsync(key, value, { + keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY, + }); + else await SecureStore.deleteItemAsync(key); +} +function persist(value: string | null) { + const operation = writes.then(() => write(value)); + // Keep the queue usable after a failure; the original operation still rejects to its caller. + writes = operation.catch(() => undefined); + return operation; +} +export async function restoreSession() { + if (!loaded) { + const current = generation; + try { + await writes; + const value = await read(); + if (current !== generation) return session; + session = value ? JSON.parse(value) : null; + } catch { + if (current === generation) session = null; + } + loaded = true; + notify(); + } + return session; +} +export async function saveSession(value: IdentitySession) { + const current = ++generation; + refreshing = null; + await persist(JSON.stringify(value)); + if (current !== generation) return; + session = value; + loaded = true; + notify(); +} +export async function signOut() { + generation++; + refreshing = null; + session = null; + loaded = true; + notify(); + await persist(null); +} +export async function accessToken() { + await restoreSession(); + if (!session) return null; + if (session.expiresAt > Date.now() + 60_000) return session.idToken; + if (!refreshing) { + const current = generation; + const refreshToken = session.refreshToken; + refreshing = (async () => { + try { + const value = await identity().refresh(refreshToken); + if (current !== generation) return null; + await persist(JSON.stringify(value)); + if (current !== generation) return null; + session = value; + notify(); + return value.idToken; + } catch (error) { + if (current === generation) await signOut(); + throw error; + } + })(); + } + const pending = refreshing; + try { + return await pending; + } finally { + if (refreshing === pending) refreshing = null; + } +} diff --git a/apps/mobile/test/session.test.ts b/apps/mobile/test/session.test.ts new file mode 100644 index 0000000..4506c65 --- /dev/null +++ b/apps/mobile/test/session.test.ts @@ -0,0 +1,93 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const storage = vi.hoisted(() => ({ value: null as string | null })); +vi.mock("react-native", () => ({ Platform: { OS: "android" } })); +vi.mock("expo-secure-store", () => ({ + WHEN_UNLOCKED_THIS_DEVICE_ONLY: "device-only", + getItemAsync: async () => storage.value, + setItemAsync: async (_key: string, value: string) => { + storage.value = value; + }, + deleteItemAsync: async () => { + storage.value = null; + }, +})); + +describe("Native session lifecycle", () => { + beforeEach(() => { + vi.resetModules(); + storage.value = null; + vi.stubGlobal("__DEV__", false); + vi.stubEnv("EXPO_PUBLIC_IDENTITY_API_KEY", "test-project-key"); + vi.stubEnv("EXPO_PUBLIC_AUTH_MODE", "identity"); + }); + afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + }); + const expired = { idToken: "expired", refreshToken: "refresh", expiresAt: 0 }; + const response = () => + Response.json({ id_token: "fresh", refresh_token: "new-refresh", expires_in: "3600" }); + it("restores a valid session without a network request", async () => { + const session = await import("../src/lib/session"); + storage.value = JSON.stringify({ + ...expired, + idToken: "current", + expiresAt: Date.now() + 3_600_000, + }); + const transport = vi.fn(); + vi.stubGlobal("fetch", transport); + expect(await session.accessToken()).toBe("current"); + expect(transport).not.toHaveBeenCalled(); + }); + it("deduplicates concurrent refresh requests", async () => { + const session = await import("../src/lib/session"); + await session.saveSession(expired); + const transport = vi.fn().mockImplementation(async () => response()); + vi.stubGlobal("fetch", transport); + expect(await Promise.all([session.accessToken(), session.accessToken()])).toEqual([ + "fresh", + "fresh", + ]); + expect(transport).toHaveBeenCalledTimes(1); + expect(JSON.parse(storage.value ?? "{}").refreshToken).toBe("new-refresh"); + }); + it("cannot restore a session after logout while refresh is pending", async () => { + const session = await import("../src/lib/session"); + await session.saveSession(expired); + let finish: ((value: Response) => void) | undefined; + const started = Promise.withResolvers(); + vi.stubGlobal( + "fetch", + vi.fn().mockImplementation(() => { + started.resolve(); + return new Promise((resolve) => { + finish = resolve; + }); + }), + ); + const pending = session.accessToken(); + await started.promise; + await session.signOut(); + if (!finish) throw new Error("Refresh was not started"); + finish(response()); + expect(await pending).toBeNull(); + expect(await session.accessToken()).toBeNull(); + expect(storage.value).toBeNull(); + }); + it("clears a revoked or failed refresh instead of retaining stale credentials", async () => { + const session = await import("../src/lib/session"); + await session.saveSession(expired); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(Response.json({}, { status: 401 })), + ); + await expect(session.accessToken()).rejects.toThrow("Sessione scaduta"); + expect(storage.value).toBeNull(); + expect(await session.accessToken()).toBeNull(); + }); + it("refuses demo authentication outside development builds", async () => { + vi.stubEnv("EXPO_PUBLIC_AUTH_MODE", "demo"); + expect((await import("../src/lib/session")).demoMode).toBe(false); + }); +}); diff --git a/apps/mobile/tsconfig.json b/apps/mobile/tsconfig.json index 27658fb..31b1536 100644 --- a/apps/mobile/tsconfig.json +++ b/apps/mobile/tsconfig.json @@ -6,5 +6,5 @@ "@/*": ["./src/*"] } }, - "include": ["src/**/*.ts", "src/**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"] + "include": ["src/**/*.ts", "src/**/*.tsx", "test/**/*.ts", ".expo/types/**/*.ts", "expo-env.d.ts"] } diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md new file mode 100644 index 0000000..643577d --- /dev/null +++ b/apps/web/AGENTS.md @@ -0,0 +1,9 @@ + + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. + +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. + + diff --git a/apps/web/CLAUDE.md b/apps/web/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/apps/web/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/apps/web/app/api/backend/[...path]/route.ts b/apps/web/app/api/backend/[...path]/route.ts new file mode 100644 index 0000000..db34a83 --- /dev/null +++ b/apps/web/app/api/backend/[...path]/route.ts @@ -0,0 +1,54 @@ +import { checkOrigin, currentToken, demoMode } from "@/lib/session"; + +async function proxy(request: Request, context: { params: Promise<{ path: string[] }> }) { + try { + checkOrigin(request); + const { path } = await context.params; + const allowed = [ + "reviews", + "knowledge", + "automation-rules", + "workspace", + "locations", + "audit", + "devices", + "session", + "integrations", + ]; + if ( + !path.length || + !allowed.includes(path[0] ?? "") || + path.some((part) => part === "." || part === ".." || /[\\/]/.test(part)) + ) + return Response.json({ message: "Endpoint non disponibile" }, { status: 404 }); + if (path[0] === "integrations" && ["callback"].includes(path[2] ?? "")) + return Response.json({ message: "Endpoint non disponibile" }, { status: 404 }); + const token = demoMode() ? null : await currentToken(); + if (!demoMode() && !token) + return Response.json({ message: "Sessione scaduta: accedi nuovamente" }, { status: 401 }); + const base = (process.env.API_INTERNAL_URL ?? "http://localhost:4100/v1").replace(/\/$/, ""); + const url = `${base}/${path.map(encodeURIComponent).join("/")}${new URL(request.url).search}`; + const response = await fetch(url, { + method: request.method, + headers: { + "Content-Type": "application/json", + ...(token + ? { Authorization: `Bearer ${token}` } + : { "x-role": "owner", "x-mfa-verified": "true" }), + }, + body: request.method === "GET" ? undefined : await request.text(), + cache: "no-store", + signal: AbortSignal.timeout(95_000), + }); + return new Response(await response.text(), { + status: response.status, + headers: { "Content-Type": "application/json", "Cache-Control": "no-store" }, + }); + } catch { + return Response.json( + { message: "Servizio non disponibile o sessione scaduta. Riprova o accedi nuovamente." }, + { status: 503 }, + ); + } +} +export { proxy as GET, proxy as POST }; diff --git a/apps/web/app/api/session/route.ts b/apps/web/app/api/session/route.ts new file mode 100644 index 0000000..3d0a65c --- /dev/null +++ b/apps/web/app/api/session/route.ts @@ -0,0 +1,104 @@ +import { z } from "zod"; +import { + checkOrigin, + clearSession, + currentToken, + demoMode, + identity, + readSession, + writeSession, +} from "@/lib/session"; + +export async function GET() { + try { + return Response.json( + { authenticated: demoMode() || Boolean(await currentToken()), demo: demoMode() }, + { headers: { "Cache-Control": "no-store" } }, + ); + } catch { + await clearSession(); + return Response.json({ authenticated: false, demo: false }); + } +} +export async function POST(request: Request) { + try { + checkOrigin(request); + const input = z + .discriminatedUnion("action", [ + z.object({ + action: z.literal("signin"), + email: z.email(), + password: z.string().min(1).max(512), + }), + z.object({ + action: z.literal("mfa"), + mfaPendingCredential: z.string().max(8000), + mfaEnrollmentId: z.string().max(500), + code: z.string().regex(/^\d{6}$/), + }), + z.object({ action: z.literal("reset"), email: z.email() }), + z.object({ action: z.literal("enroll-start") }), + z.object({ + action: z.literal("enroll-finish"), + sessionInfo: z.string().max(8000), + code: z.string().regex(/^\d{6}$/), + }), + z.object({ action: z.literal("verify-email") }), + ]) + .parse(await request.json()); + const client = identity(); + if (input.action === "signin") { + const result = await client.signIn(input.email, input.password); + if ("challenge" in result) return Response.json({ challenge: result.challenge }); + await writeSession(result.session); + return Response.json({ authenticated: true }); + } + if (input.action === "mfa") { + await writeSession(await client.verifyMfa(input, input.code)); + return Response.json({ authenticated: true }); + } + if (input.action === "reset") { + try { + await client.resetPassword(input.email); + } catch { + // Return the same message on unknown accounts to prevent account enumeration. + return Response.json({ message: "Se l'account esiste, riceverai un'email di recupero." }); + } + return Response.json({ message: "Se l'account esiste, riceverai un'email di recupero." }); + } + const token = await currentToken(); + if (!token || !(await readSession())) + return Response.json({ message: "Accedi prima di continuare" }, { status: 401 }); + if (input.action === "enroll-start") return Response.json(await client.startEnrollment(token)); + if (input.action === "enroll-finish") { + await client.finishEnrollment(token, input.sessionInfo, input.code); + await clearSession(); + return Response.json({ + message: "MFA attivata. Accedi nuovamente con il codice del tuo authenticator.", + }); + } + await client.sendVerification(token); + return Response.json({ message: "Email di verifica inviata." }); + } catch (error) { + return Response.json( + { + message: + error instanceof z.ZodError + ? "Controlla i campi inseriti" + : error instanceof Error + ? error.message + : "Accesso non riuscito", + }, + { status: 400 }, + ); + } +} +export async function DELETE(request: Request) { + try { + checkOrigin(request); + await clearSession(); + return Response.json({ signedOut: true }); + } catch { + return Response.json({ message: "Operazione non autorizzata" }, { status: 403 }); + } +} diff --git a/apps/web/app/audit/page.tsx b/apps/web/app/audit/page.tsx new file mode 100644 index 0000000..a6ba971 --- /dev/null +++ b/apps/web/app/audit/page.tsx @@ -0,0 +1,4 @@ +import { AuditLog } from "@/components/audit-log"; +export default function AuditPage() { + return ; +} diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 505d29b..d76705c 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -15,6 +15,67 @@ --blue-bg: #e6f1f7; --shadow: 0 12px 36px rgba(34, 48, 40, 0.07); } + +/* Shared form layout; colors and controls reuse the existing theme tokens. */ +.auth-page { + min-height: 100dvh; + display: grid; + place-items: center; + padding: 24px; +} +.auth-card { + width: min(100%, 480px); + padding: 32px; + display: grid; + gap: 16px; +} +.auth-card h1 { + margin: 0; +} +.auth-card label, +.operational-form label { + display: grid; + gap: 8px; + font-size: 14px; +} +.auth-card input, +.operational-form input, +.operational-form select, +.operational-form textarea { + width: 100%; + padding: 12px; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--paper); + color: var(--ink); + font: inherit; +} +.operational-form { + display: grid; + gap: 16px; +} +.operational-panel { + padding: 24px; + margin-bottom: 20px; +} +.operational-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; +} +.privacy-note { + font-size: 12px; + color: var(--muted); +} +.source-content { + white-space: pre-wrap; + max-height: 360px; + overflow: auto; +} +.operational-form input[type="checkbox"] { + width: auto; +} * { box-sizing: border-box; } diff --git a/apps/web/app/inbox/[id]/page.tsx b/apps/web/app/inbox/[id]/page.tsx index 451787a..4bd4f89 100644 --- a/apps/web/app/inbox/[id]/page.tsx +++ b/apps/web/app/inbox/[id]/page.tsx @@ -1,13 +1,9 @@ import Link from "next/link"; -import { notFound } from "next/navigation"; import { Icon } from "@/components/icons"; -import { ReviewWorkbench } from "@/components/review-workbench"; -import { demoReviews } from "@/lib/demo-data"; +import { ReviewDetail } from "@/components/review-detail"; export default async function ReviewPage({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; - const review = demoReviews.find((item) => item.id === id); - if (!review) notFound(); return (
@@ -25,7 +21,7 @@ export default async function ReviewPage({ params }: { params: Promise<{ id: str
- + ); } diff --git a/apps/web/app/inbox/page.tsx b/apps/web/app/inbox/page.tsx new file mode 100644 index 0000000..49c0a2c --- /dev/null +++ b/apps/web/app/inbox/page.tsx @@ -0,0 +1,15 @@ +import { Inbox } from "@/components/inbox"; +export default function InboxPage() { + return ( +
+
+
+ Centro approvazioni +

Recensioni

+

Apri una recensione per generare, modificare e approvare la risposta.

+
+
+ +
+ ); +} diff --git a/apps/web/app/knowledge/page.tsx b/apps/web/app/knowledge/page.tsx index 393ff48..d2288e7 100644 --- a/apps/web/app/knowledge/page.tsx +++ b/apps/web/app/knowledge/page.tsx @@ -1,60 +1,4 @@ -import { Icon } from "@/components/icons"; -import { demoKnowledge } from "@/lib/demo-data"; - +import { KnowledgeManager } from "@/components/knowledge-manager"; export default function KnowledgePage() { - return ( -
-
-
- Memoria controllata -

Conoscenza aziendale

-

Solo le fonti approvate possono guidare le risposte pubbliche.

-
- -
-
-
- - 94% - Copertura stimata -
-
-

Una memoria verificabile, non una chat infinita

-

- Ogni informazione ha versione, autore, validità e stato. Le correzioni suggeriscono - miglioramenti, ma non modificano mai le regole senza approvazione. -

-
-
-
-
-
- Fonti -

Contenuti approvati

-
- -
-
- {demoKnowledge.map((entry) => ( -
-
- {entry.kind.replace("_", " ")} - Approvata -
-

{entry.title}

-

{entry.content}

-
- Versione {entry.version} - -
-
- ))} -
-
-
- ); + return ; } diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index f029cc1..d92a798 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -1,10 +1,10 @@ import type { Metadata } from "next"; import type { ReactNode } from "react"; -import { AppShell } from "@/components/app-shell"; +import { SiteFrame } from "@/components/auth-gate"; import "./globals.css"; export const metadata: Metadata = { - title: "ReviewGuard · AI review operations", + title: "AutoReview · AI review operations", description: "Risposte AI controllate alle recensioni Google Business Profile.", }; @@ -12,7 +12,7 @@ export default function RootLayout({ children }: { children: ReactNode }) { return ( - {children} + {children} ); diff --git a/apps/web/app/login/page.tsx b/apps/web/app/login/page.tsx new file mode 100644 index 0000000..900b177 --- /dev/null +++ b/apps/web/app/login/page.tsx @@ -0,0 +1,135 @@ +"use client"; +import type { MfaChallenge } from "@reviewguard/core"; +import { useRouter } from "next/navigation"; +import { type FormEvent, useState } from "react"; + +export default function LoginPage() { + const router = useRouter(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [code, setCode] = useState(""); + const [challenge, setChallenge] = useState(null); + const [busy, setBusy] = useState(false); + const [notice, setNotice] = useState(""); + async function submit(event: FormEvent) { + event.preventDefault(); + setBusy(true); + setNotice(""); + try { + const response = await fetch("/api/session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify( + challenge ? { action: "mfa", ...challenge, code } : { action: "signin", email, password }, + ), + }); + const result = await response.json(); + if (!response.ok) throw new Error(result.message); + setPassword(""); + if (result.challenge) { + setChallenge(result.challenge); + return; + } + router.replace("/"); + router.refresh(); + } catch (error) { + setNotice(error instanceof Error ? error.message : "Accesso non riuscito"); + } finally { + setBusy(false); + } + } + async function reset() { + setBusy(true); + try { + const response = await fetch("/api/session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "reset", email }), + }); + const result = await response.json(); + setNotice(result.message); + } catch { + setNotice("Servizio non disponibile: riprova"); + } finally { + setBusy(false); + } + } + return ( +
+
+ AutoReview · Accesso protetto +

{challenge ? "Verifica il tuo accesso" : "Le recensioni, sotto controllo"}

+

+ {challenge + ? "Inserisci il codice della tua app authenticator." + : "Accedi con l’account assegnato alla tua attività."} +

+ {challenge ? ( + + ) : ( + <> + + + + )} + {notice && ( +

+ {notice} +

+ )} + + {challenge ? ( + + ) : ( + + )} +

+ Nessuna registrazione pubblica: gli account vengono autorizzati dall’amministratore del + pilot. +

+
+
+ ); +} diff --git a/apps/web/app/mfa/page.tsx b/apps/web/app/mfa/page.tsx new file mode 100644 index 0000000..f4fc2d3 --- /dev/null +++ b/apps/web/app/mfa/page.tsx @@ -0,0 +1,94 @@ +"use client"; +import type { TotpEnrollment } from "@reviewguard/core"; +import Link from "next/link"; +import { useState } from "react"; + +export default function MfaPage() { + const [enrollment, setEnrollment] = useState(null); + const [code, setCode] = useState(""); + const [notice, setNotice] = useState(""); + const [busy, setBusy] = useState(false); + const [done, setDone] = useState(false); + async function execute(action: "enroll-start" | "enroll-finish") { + setBusy(true); + setNotice(""); + try { + const response = await fetch("/api/session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action, sessionInfo: enrollment?.sessionInfo, code }), + }); + const value = await response.json(); + if (!response.ok) throw new Error(value.message); + if (action === "enroll-start") setEnrollment(value); + else { + setEnrollment(null); + setDone(true); + setNotice(value.message); + } + } catch (error) { + setNotice(error instanceof Error ? error.message : "Operazione non riuscita"); + } finally { + setBusy(false); + } + } + return ( +
+
+ Sicurezza +

Attiva il secondo fattore

+

+ Usa un’app authenticator compatibile TOTP. Non condividere la chiave di configurazione. +

+ {!enrollment && !done && ( + + )} + {enrollment && ( + <> +

Aggiungi un account manualmente nell’app authenticator:

+ +

+ {enrollment.verificationCodeLength} cifre · {enrollment.periodSec} secondi ·{" "} + {enrollment.hashingAlgorithm} +

+ + + + )} + {notice && ( +

+ {notice} +

+ )} + + {done ? "Accedi nuovamente" : "Torna alle impostazioni"} + +
+
+ ); +} diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 4d5d7c0..fed0a2b 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -1,129 +1,4 @@ -import { Icon } from "@/components/icons"; -import { Inbox } from "@/components/inbox"; - +import { Dashboard } from "@/components/dashboard"; export default function DashboardPage() { - return ( -
-
-
- Mercoledì, 16 settembre -

Buongiorno, Demo

-

Hai 3 recensioni che richiedono attenzione.

-
-
- - -
-
-
-
-
- -
-
- Da approvare - 3 - - +2 da ieri - -
-
-
-
- -
-
- Pubblicate - 42 - ultimi 30 giorni -
-
-
-
- -
-
- Tempo medio - - 12 min - - - −18% questo mese - -
-
-
-
- -
-
- Copertura memoria - - 94% - - 2 fonti da rivedere -
-
-
-
- - -
-
- ); + return ; } diff --git a/apps/web/app/rules/page.tsx b/apps/web/app/rules/page.tsx index f5c831f..56d625b 100644 --- a/apps/web/app/rules/page.tsx +++ b/apps/web/app/rules/page.tsx @@ -1,66 +1,4 @@ -import { Icon } from "@/components/icons"; -import { demoRules } from "@/lib/demo-data"; - +import { RulesManager } from "@/components/rules-manager"; export default function RulesPage() { - return ( -
-
-
- Governance -

Regole di automazione

-

Il motore applica condizioni deterministiche; l’AI non decide mai di pubblicare.

-
- -
-
- -
- Kill switch globale attivo in modalità sicura -

- Tutte le nuove regole nascono disattivate e richiedono MFA, consenso versionato e 20 - approvazioni manuali. -

-
- -
-
-
-
- Configurazione -

Regole della sede

-
- {demoRules.length} regola -
- {demoRules.map((rule) => ( -
-
- -
-
-
-

{rule.name}

- - {rule.enabled ? "Attiva" : "Disattivata"} - -
-

- {rule.starRatings.map((rating) => `${rating}★`).join(", ")} ·{" "} - {rule.languages.join(", ").toUpperCase()} · attesa {rule.delayMinutes} minuti -

-
- ✓ Hard stop - ✓ Limite {rule.dailyLimit}/giorno - ✓ MFA richiesta -
-
- -
- ))} -
-
- ); + return ; } diff --git a/apps/web/app/settings/page.tsx b/apps/web/app/settings/page.tsx index 8e1ed97..3027914 100644 --- a/apps/web/app/settings/page.tsx +++ b/apps/web/app/settings/page.tsx @@ -1,72 +1,4 @@ -import { Icon } from "@/components/icons"; - +import { SettingsManager } from "@/components/settings-manager"; export default function SettingsPage() { - return ( -
-
-
- Configurazione -

Impostazioni

-

Integrazioni, sicurezza e preferenze della sede.

-
-
-
-
-
G
-
- Integrazione -

Google Business Profile

-

- Collega un account autorizzato per ricevere recensioni e pubblicare risposte - approvate. -

-
- - Ambiente demo connesso -
-
- -
-
-
- -
-
- Sicurezza -

Accesso e MFA

-

- Owner e Approver devono completare il secondo fattore prima delle azioni sensibili. -

-
- - MFA attiva -
-
- -
-
-
AI
-
- Modello -

DeepSeek V4 Pro 0813

-

- Snapshot bloccato tramite OpenRouter, ZDR richiesto e fallback limitato ai provider - approvati. -

-
- - Prompt logging disattivato -
-
- -
-
-
- ); + return ; } diff --git a/apps/web/components/app-shell.tsx b/apps/web/components/app-shell.tsx index 2b1272d..18e065d 100644 --- a/apps/web/components/app-shell.tsx +++ b/apps/web/components/app-shell.tsx @@ -1,45 +1,53 @@ +"use client"; import Link from "next/link"; +import { usePathname, useRouter } from "next/navigation"; import type { ReactNode } from "react"; +import { useSession } from "./auth-gate"; import { Icon } from "./icons"; const navigation = [ { href: "/", label: "Panoramica", icon: "home" }, - { href: "/#inbox", label: "Recensioni", icon: "inbox", badge: "3" }, + { href: "/inbox", label: "Recensioni", icon: "inbox" }, { href: "/knowledge", label: "Memoria AI", icon: "brain" }, { href: "/rules", label: "Automazioni", icon: "bolt" }, { href: "/settings", label: "Impostazioni", icon: "settings" }, + { href: "/audit", label: "Registro attività", icon: "shield" }, ]; export function AppShell({ children }: { children: ReactNode }) { + const session = useSession(); + const pathname = usePathname(); + const router = useRouter(); return (
diff --git a/apps/web/components/audit-log.tsx b/apps/web/components/audit-log.tsx new file mode 100644 index 0000000..8172866 --- /dev/null +++ b/apps/web/components/audit-log.tsx @@ -0,0 +1,39 @@ +"use client"; +import type { AuditEvent } from "@reviewguard/contracts"; +import { useResource } from "@/lib/use-resource"; +import { ResourceState } from "./resource-state"; +export function AuditLog() { + const { data, error, loading, refresh } = useResource<{ data: AuditEvent[] }>("/audit"); + return ( +
+
+
+ Tracciabilità +

Registro attività

+

+ Decisioni, modello, provider e versioni delle fonti. Nessun testo di recensione nei + metadati. +

+
+ +
+
+ + {!loading && !error && !data?.data.length &&

Nessun evento registrato.

} + {data?.data.slice(0, 200).map((event) => ( +
+ + {new Date(event.createdAt).toLocaleString("it-IT")} · {event.action} + +

+ Attore: {event.actorId} · {event.entityType}: {event.entityId} +

+
{JSON.stringify(event.metadata, null, 2)}
+
+ ))} +
+
+ ); +} diff --git a/apps/web/components/auth-gate.tsx b/apps/web/components/auth-gate.tsx new file mode 100644 index 0000000..3bdbf5f --- /dev/null +++ b/apps/web/components/auth-gate.tsx @@ -0,0 +1,91 @@ +"use client"; +import type { RequestPrincipal } from "@reviewguard/contracts"; +import { usePathname, useRouter } from "next/navigation"; +import { createContext, type ReactNode, useContext, useEffect, useState } from "react"; +import { apiRequest } from "@/lib/api"; +import { AppShell } from "./app-shell"; + +const SessionContext = createContext<{ principal: RequestPrincipal; demo: boolean } | null>(null); +export function useSession() { + return useContext(SessionContext); +} +export function SiteFrame({ children }: { children: ReactNode }) { + const pathname = usePathname(); + if (pathname === "/login" || pathname === "/mfa") return children; + return ( + + {children} + + ); +} +function AuthGate({ children }: { children: ReactNode }) { + const [session, setSession] = useState<{ principal: RequestPrincipal; demo: boolean } | null>( + null, + ); + const [error, setError] = useState(null); + const router = useRouter(); + useEffect(() => { + let active = true; + fetch("/api/session", { cache: "no-store" }) + .then((response) => response.json()) + .then(async (result) => { + if (!result.authenticated) { + router.replace("/login"); + return; + } + const value = await apiRequest<{ principal: RequestPrincipal; demo: boolean }>("/session"); + if (active) setSession(value); + }) + .catch((reason) => { + if (active) setError(reason instanceof Error ? reason.message : "Accesso non disponibile"); + }); + return () => { + active = false; + }; + }, [router]); + if (error) + return ( +
+
+

Accesso da verificare

+

{error}

+

+ Verifica l’email e assicurati che l’amministratore ti abbia assegnato un’azienda e un + ruolo. +

+ + +
+
+ ); + if (!session) + return ( +
+

Verifica della sessione…

+
+ ); + return {children}; +} diff --git a/apps/web/components/dashboard.tsx b/apps/web/components/dashboard.tsx new file mode 100644 index 0000000..ac65b56 --- /dev/null +++ b/apps/web/components/dashboard.tsx @@ -0,0 +1,80 @@ +"use client"; +import Link from "next/link"; +import { useResource } from "@/lib/use-resource"; +import type { Workspace } from "@/lib/workspace"; +import { Inbox } from "./inbox"; +import { ResourceState } from "./resource-state"; +export function Dashboard() { + const { data, error, loading, refresh } = useResource("/workspace"); + return ( +
+
+
+ Operazioni recensioni +

La tua attività, sotto controllo

+

Genera proposte e verifica ogni risposta prima della pubblicazione.

+
+ + Collega o gestisci una sede + +
+ + {data && ( + <> +
+ {[ + ["Da approvare", data.metrics.pending], + ["Da verificare", data.metrics.attention], + ["Pubblicate in archivio", data.metrics.published], + ["Fonti approvate", data.metrics.approvedSources], + ].map(([label, value]) => ( +
+
+ {label} + {value} + Dati dell’attività corrente +
+
+ ))} +
+
+

Stato reale dei servizi

+
+ + Google:{" "} + {data.integration.googleMode !== "live" + ? "simulato — nessun invio reale" + : data.integration.googleConnected + ? "collegato" + : "da collegare"} + + + AI: {data.integration.aiMode === "mock" ? "simulata" : data.integration.model} + + + Dati:{" "} + {data.integration.storageMode === "postgres" ? "PostgreSQL" : "temporanei — demo"} + + + Automazione:{" "} + {data.settings.killSwitch || !data.integration.automationReleased + ? "bloccata" + : "controllata dalle regole"} + +
+
+ {data.locations.map((location) => ( +
+

{location.displayName}

+

+ {location.active ? "Sede attiva" : "Sede disconnessa"} ·{" "} + {location.manualApprovalCount}/20 approvazioni manuali di calibrazione +

+
+ ))} + + )} + +
+ ); +} diff --git a/apps/web/components/inbox.tsx b/apps/web/components/inbox.tsx index 9a51790..118e802 100644 --- a/apps/web/components/inbox.tsx +++ b/apps/web/components/inbox.tsx @@ -2,24 +2,21 @@ import type { ReviewCase } from "@reviewguard/contracts"; import Link from "next/link"; -import { useEffect, useState } from "react"; -import { apiRequest } from "@/lib/api"; -import { demoReviews } from "@/lib/demo-data"; +import { useState } from "react"; +import { useResource } from "@/lib/use-resource"; import { Icon } from "./icons"; +import { ResourceState } from "./resource-state"; import { StatusBadge } from "./status-badge"; export function Inbox() { - const [reviews, setReviews] = useState(demoReviews); - const [live, setLive] = useState(false); - - useEffect(() => { - apiRequest<{ data: ReviewCase[] }>("/reviews") - .then((result) => { - setReviews(result.data); - setLive(true); - }) - .catch(() => setLive(false)); - }, []); + const [status, setStatus] = useState(""); + const [cursors, setCursors] = useState([]); + const cursor = cursors.at(-1); + const { data, error, loading, refresh } = useResource<{ + data: ReviewCase[]; + meta: { total: number; nextCursor: string | null }; + }>(`/reviews?limit=50${status ? `&status=${status}` : ""}${cursor ? `&cursor=${cursor}` : ""}`); + const reviews = data?.data ?? []; return (
@@ -29,46 +26,92 @@ export function Inbox() {

Recensioni da gestire

- - {live ? "API connessa" : "Dati dimostrativi"} -
- {reviews.map((review) => ( - -
- {review.snapshot.starRating} - -
-
-
- {review.snapshot.reviewerDisplayName} - · - {relativeTime(review.snapshot.createTime)} + + {!loading && !error && reviews.length === 0 && ( +

+ Nessuna recensione. Collega Google dalle impostazioni e importa una sede, oppure cambia + filtro. +

+ )} + {!loading && + !error && + reviews.map((review) => ( + +
+ {review.snapshot.starRating} + +
+
+
+ {review.snapshot.reviewerDisplayName} + · + {relativeTime(review.snapshot.createTime)} +
+

{review.snapshot.comment || "Recensione senza testo"}

+ {review.activeDraft ? ( + + AI + {review.activeDraft.text} + + ) : null}
-

{review.snapshot.comment || "Recensione senza testo"}

- {review.activeDraft ? ( - - AI - {review.activeDraft.text} - - ) : null} -
-
- - -
- - ))} +
+ + +
+ + ))}
- Mostrate {reviews.length} recensioni operative - + + Mostrate {reviews.length} di {data?.meta.total ?? 0} recensioni · pagina{" "} + {cursors.length + 1} + +
+ + +
+ Le nuove recensioni restano nell’inbox anche senza notifica push.
); diff --git a/apps/web/components/knowledge-manager.tsx b/apps/web/components/knowledge-manager.tsx new file mode 100644 index 0000000..1ea204e --- /dev/null +++ b/apps/web/components/knowledge-manager.tsx @@ -0,0 +1,331 @@ +"use client"; +import type { KnowledgeSource } from "@reviewguard/contracts"; +import { type FormEvent, useState } from "react"; +import { apiRequest } from "@/lib/api"; +import { useResource } from "@/lib/use-resource"; +import type { Workspace } from "@/lib/workspace"; +import { useSession } from "./auth-gate"; +import { ResourceState } from "./resource-state"; + +const initial = { + title: "", + content: "", + kind: "faq", + language: "it", + locationId: "", + validFrom: "", + validUntil: "", +}; +export function KnowledgeManager() { + const { data, error, loading, refresh } = useResource<{ data: KnowledgeSource[] }>("/knowledge"); + const workspace = useResource("/workspace"); + const [form, setForm] = useState(initial); + const [editing, setEditing] = useState(null); + const [busy, setBusy] = useState(false); + const [notice, setNotice] = useState(""); + const [selected, setSelected] = useState(null); + const session = useSession(); + const canEdit = Boolean(session && ["owner", "admin", "editor"].includes(session.principal.role)); + const canApprove = Boolean(session && ["owner", "admin"].includes(session.principal.role)); + async function execute(operation: () => Promise, message: string) { + setBusy(true); + setNotice(""); + try { + await operation(); + setNotice(message); + await refresh(); + } catch (reason) { + setNotice(reason instanceof Error ? reason.message : "Operazione non riuscita"); + } finally { + setBusy(false); + } + } + async function submit(event: FormEvent) { + event.preventDefault(); + await execute(async () => { + const body = { + ...form, + locationId: form.locationId || null, + validFrom: form.validFrom ? new Date(form.validFrom).toISOString() : null, + validUntil: form.validUntil ? new Date(form.validUntil).toISOString() : null, + ...(editing ? { expectedVersion: editing.version } : {}), + }; + await apiRequest(editing ? `/knowledge/${editing.id}/edit` : "/knowledge", { + method: "POST", + body: JSON.stringify(body), + }); + setEditing(null); + setForm(initial); + }, "Fonte salvata come bozza: richiede approvazione prima dell’utilizzo"); + } + async function upload(file: File) { + await execute(async () => { + if (file.size > 4_000_000) throw new Error("Il documento deve essere inferiore a 4 MB"); + const bytes = new Uint8Array(await file.arrayBuffer()); + let binary = ""; + for (let i = 0; i < bytes.length; i += 32768) + binary += String.fromCharCode(...bytes.subarray(i, i + 32768)); + await apiRequest("/knowledge/documents", { + method: "POST", + body: JSON.stringify({ + filename: file.name, + base64: btoa(binary), + language: form.language, + locationId: form.locationId || null, + }), + }); + }, "Documento estratto come bozza. Controlla il testo e approvalo."); + } + return ( +
+
+
+ Memoria controllata +

Conoscenza aziendale

+

Solo fonti approvate, valide e pertinenti possono guidare le risposte.

+
+
+ {notice && ( +

+ {notice} +

+ )} + {canEdit && ( +
+

{editing ? "Modifica fonte" : "Nuova fonte"}

+
+ +
+ + + +
+