From b91260aae89937cf6c00ef9e85c0ad71cb4e7ef7 Mon Sep 17 00:00:00 2001 From: Esdragones Date: Wed, 16 Sep 2026 16:20:10 +0200 Subject: [PATCH 01/14] feat(database): add tenant-scoped versioned pilot repository --- .agent/CONTINUITY.md | 25 +++ .../migrations/0003_runtime_records.sql | 24 +++ .../database/migrations/meta/_journal.json | 7 + packages/database/src/index.ts | 1 + packages/database/src/records.ts | 154 ++++++++++++++++++ packages/database/test/records.test.ts | 56 +++++++ 6 files changed, 267 insertions(+) create mode 100644 .agent/CONTINUITY.md create mode 100644 packages/database/migrations/0003_runtime_records.sql create mode 100644 packages/database/src/records.ts create mode 100644 packages/database/test/records.test.ts diff --git a/.agent/CONTINUITY.md b/.agent/CONTINUITY.md new file mode 100644 index 0000000..d2d2d33 --- /dev/null +++ b/.agent/CONTINUITY.md @@ -0,0 +1,25 @@ +# 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. + +## Current plan + +1. Durable tenant-scoped storage, transactional version checks and recoverable workflow. +2. Real authenticated sessions, onboarding and functional web/mobile actions. +3. Google import, notification setup, AI grounding and knowledge management. +4. Deployment configuration, integration/e2e tests and honest English documentation. + +## Verification + +- Baseline: 16 tests; typechecks pass in seven workspaces. +- Baseline has no database, web or mobile tests. Google/cloud/physical push verification remains external. + +## 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. diff --git a/packages/database/migrations/0003_runtime_records.sql b/packages/database/migrations/0003_runtime_records.sql new file mode 100644 index 0000000..0eb89dc --- /dev/null +++ b/packages/database/migrations/0003_runtime_records.sql @@ -0,0 +1,24 @@ +-- Authoritative versioned pilot aggregates; normalized tables are reserved for reporting. +CREATE TABLE runtime_records ( + tenant_id uuid NOT NULL, + kind text NOT NULL CHECK (kind IN ('review','knowledge','rule','audit','google_tokens','device','event','settings','location','counter','oauth','publish')), + id text NOT NULL, + version integer NOT NULL DEFAULT 1 CHECK (version>0), + payload jsonb NOT NULL, + expires_at timestamptz, + updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (tenant_id,kind,id) +);--> statement-breakpoint +ALTER TABLE runtime_records ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +ALTER TABLE runtime_records FORCE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE POLICY tenant_isolation ON runtime_records USING (tenant_id=app_tenant_id()) WITH CHECK (tenant_id=app_tenant_id());--> statement-breakpoint +CREATE INDEX runtime_records_expiry_idx ON runtime_records(expires_at) WHERE expires_at IS NOT NULL;--> statement-breakpoint +CREATE UNIQUE INDEX runtime_review_google_idx ON runtime_records(tenant_id,(payload->'snapshot'->>'googleReviewName')) WHERE kind='review';--> statement-breakpoint +CREATE INDEX runtime_knowledge_search_idx ON runtime_records USING gin(to_tsvector('simple',payload->>'content')) WHERE kind='knowledge';--> statement-breakpoint +CREATE FUNCTION protect_runtime_audit() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF OLD.kind='audit' THEN RAISE EXCEPTION 'runtime audit is append-only'; END IF; + IF TG_OP='DELETE' THEN RETURN OLD; END IF; + RETURN NEW; +END $$;--> statement-breakpoint +CREATE TRIGGER runtime_audit_append_only BEFORE UPDATE OR DELETE ON runtime_records FOR EACH ROW EXECUTE FUNCTION protect_runtime_audit(); diff --git a/packages/database/migrations/meta/_journal.json b/packages/database/migrations/meta/_journal.json index e54cb95..825d6df 100644 --- a/packages/database/migrations/meta/_journal.json +++ b/packages/database/migrations/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1789513334088, "tag": "0002_strong_roughhouse", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1789569000000, + "tag": "0003_runtime_records", + "breakpoints": true } ] } diff --git a/packages/database/src/index.ts b/packages/database/src/index.ts index d71e580..7f284bb 100644 --- a/packages/database/src/index.ts +++ b/packages/database/src/index.ts @@ -1,3 +1,4 @@ export * from "./client.js"; export * from "./maintenance.js"; +export * from "./records.js"; export * from "./schema.js"; diff --git a/packages/database/src/records.ts b/packages/database/src/records.ts new file mode 100644 index 0000000..ef65315 --- /dev/null +++ b/packages/database/src/records.ts @@ -0,0 +1,154 @@ +import { Pool, type PoolClient } from "pg"; + +export type StoredRecord = { id: string; version: number; value: T }; +export interface RecordRepository { + list(tenantId: string, kind: string): Promise[]>; + get(tenantId: string, kind: string, id: string): Promise | null>; + put( + tenantId: string, + kind: string, + id: string, + value: T, + expectedVersion: number | null, + expiresAt?: string, + ): Promise | null>; + remove(tenantId: string, kind: string, id: string): Promise; + close(): Promise; +} + +/** Versioned aggregates: each transaction resets tenant scope before releasing its connection. */ +export class PostgresRecordRepository implements RecordRepository { + private readonly pool: Pool; + constructor(connectionString: string) { + this.pool = new Pool({ + connectionString, + max: 10, + connectionTimeoutMillis: 5_000, + idleTimeoutMillis: 30_000, + }); + } + private async scoped( + tenantId: string, + operation: (client: PoolClient) => Promise, + ): Promise { + const client = await this.pool.connect(); + try { + await client.query("BEGIN"); + await client.query("SELECT set_config('app.tenant_id', $1, true)", [tenantId]); + await client.query("SET LOCAL statement_timeout = '5s'"); + const result = await operation(client); + await client.query("COMMIT"); + return result; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } + } + async list(tenantId: string, kind: string): Promise[]> { + return this.scoped( + tenantId, + async (client) => + ( + await client.query( + "SELECT id,version,payload AS value FROM runtime_records WHERE tenant_id=$1 AND kind=$2 AND (expires_at IS NULL OR expires_at>now())", + [tenantId, kind], + ) + ).rows, + ); + } + async get(tenantId: string, kind: string, id: string): Promise | null> { + return this.scoped( + tenantId, + async (client) => + ( + await client.query( + "SELECT id,version,payload AS value FROM runtime_records WHERE tenant_id=$1 AND kind=$2 AND id=$3 AND (expires_at IS NULL OR expires_at>now())", + [tenantId, kind, id], + ) + ).rows[0] ?? null, + ); + } + async put( + tenantId: string, + kind: string, + id: string, + value: T, + expectedVersion: number | null, + expiresAt?: string, + ): Promise | null> { + return this.scoped(tenantId, async (client) => { + const result = + expectedVersion === null + ? await client.query( + "INSERT INTO runtime_records(tenant_id,kind,id,payload,expires_at) VALUES($1,$2,$3,$4,$5) ON CONFLICT DO NOTHING RETURNING id,version,payload AS value", + [tenantId, kind, id, JSON.stringify(value), expiresAt ?? null], + ) + : await client.query( + "UPDATE runtime_records SET payload=$4,version=version+1,updated_at=now(),expires_at=COALESCE($6,expires_at) WHERE tenant_id=$1 AND kind=$2 AND id=$3 AND version=$5 RETURNING id,version,payload AS value", + [tenantId, kind, id, JSON.stringify(value), expectedVersion, expiresAt ?? null], + ); + return result.rows[0] ?? null; + }); + } + async remove(tenantId: string, kind: string, id: string): Promise { + await this.scoped(tenantId, async (client) => { + await client.query("DELETE FROM runtime_records WHERE tenant_id=$1 AND kind=$2 AND id=$3", [ + tenantId, + kind, + id, + ]); + }); + } + async close(): Promise { + await this.pool.end(); + } +} + +export class MemoryRecordRepository implements RecordRepository { + private readonly entries = new Map(); + private key(tenantId: string, kind: string, id: string) { + return `${tenantId}/${kind}/${id}`; + } + async list(tenantId: string, kind: string): Promise[]> { + return [...this.entries.entries()] + .filter( + ([key, entry]) => + key.startsWith(`${tenantId}/${kind}/`) && + (!entry.expiresAt || Date.parse(entry.expiresAt) > Date.now()), + ) + .map(([, entry]) => structuredClone(entry) as StoredRecord); + } + async get(tenantId: string, kind: string, id: string): Promise | null> { + const entry = this.entries.get(this.key(tenantId, kind, id)); + return entry && (!entry.expiresAt || Date.parse(entry.expiresAt) > Date.now()) + ? (structuredClone(entry) as StoredRecord) + : null; + } + async put( + tenantId: string, + kind: string, + id: string, + value: T, + expectedVersion: number | null, + expiresAt?: string, + ): Promise | null> { + const key = this.key(tenantId, kind, id); + const current = this.entries.get(key); + if (expectedVersion === null ? Boolean(current) : current?.version !== expectedVersion) + return null; + const result = { + id, + version: (current?.version ?? 0) + 1, + value: structuredClone(value), + expiresAt: expiresAt ?? current?.expiresAt, + }; + this.entries.set(key, result); + return structuredClone(result); + } + async remove(tenantId: string, kind: string, id: string): Promise { + this.entries.delete(this.key(tenantId, kind, id)); + } + async close(): Promise {} +} diff --git a/packages/database/test/records.test.ts b/packages/database/test/records.test.ts new file mode 100644 index 0000000..8be525b --- /dev/null +++ b/packages/database/test/records.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { MemoryRecordRepository, PostgresRecordRepository } from "../src/records.js"; + +const tenantA = "11111111-1111-4111-8111-111111111111"; +const tenantB = "99999999-9999-4999-8999-999999999999"; +describe("Versioned record repository", () => { + it("isolates tenants with identical record ids", async () => { + const store = new MemoryRecordRepository(); + await store.put(tenantA, "settings", "business", { name: "A" }, null); + expect(await store.get(tenantB, "settings", "business")).toBeNull(); + expect(await store.list(tenantB, "settings")).toEqual([]); + }); + it("only one concurrent writer can replace a version", async () => { + const store = new MemoryRecordRepository(); + await store.put(tenantA, "review", "id", { text: "original" }, null); + const results = await Promise.all([ + store.put(tenantA, "review", "id", { text: "one" }, 1), + store.put(tenantA, "review", "id", { text: "two" }, 1), + ]); + expect(results.filter(Boolean)).toHaveLength(1); + expect(await store.put(tenantA, "review", "id", {}, null)).toBeNull(); + }); + it("hides expired Google payloads", async () => { + const store = new MemoryRecordRepository(); + await store.put(tenantA, "review", "id", { text: "expired" }, null, "2020-01-01T00:00:00.000Z"); + expect(await store.get(tenantA, "review", "id")).toBeNull(); + expect(await store.list(tenantA, "review")).toEqual([]); + }); +}); + +describe.skipIf(!process.env.TEST_DATABASE_URL)( + "PostgreSQL integration (migrations and non-superuser runtime role required)", + () => { + it("persists across connections and uses transactional compare-and-swap", async () => { + const one = new PostgresRecordRepository(process.env.TEST_DATABASE_URL!); + const two = new PostgresRecordRepository(process.env.TEST_DATABASE_URL!); + const id = crypto.randomUUID(); + try { + await one.put(tenantA, "settings", id, { name: "persisted" }, null); + expect((await two.get<{ name: string }>(tenantA, "settings", id))?.value.name).toBe( + "persisted", + ); + expect(await two.get(tenantB, "settings", id)).toBeNull(); + const results = await Promise.all([ + one.put(tenantA, "settings", id, {}, 1), + two.put(tenantA, "settings", id, {}, 1), + ]); + expect(results.filter(Boolean)).toHaveLength(1); + } finally { + await one.remove(tenantA, "settings", id); + await one.close(); + await two.close(); + } + }); + }, +); From 2ca87ce8925ce02cc038dd86d152ffe4b2fc4eea Mon Sep 17 00:00:00 2001 From: Esdragones Date: Wed, 16 Sep 2026 16:20:14 +0200 Subject: [PATCH 02/14] fix(api): persist workflow decisions and reconcile uncertain publication --- apps/api/src/config.ts | 39 ++ apps/api/src/controllers.ts | 68 ++-- apps/api/src/http-exception.filter.ts | 5 +- apps/api/src/main.ts | 17 +- apps/api/src/notifications.ts | 2 +- apps/api/src/providers.ts | 3 +- apps/api/src/review.service.ts | 543 +++++++++++++++++--------- apps/api/src/store.ts | 523 +++++++++++++++++-------- apps/api/src/token-vault.ts | 27 ++ apps/api/test/safety.test.ts | 113 ++++++ packages/core/src/ai/openrouter.ts | 5 +- packages/core/test/openrouter.test.ts | 2 +- 12 files changed, 972 insertions(+), 375 deletions(-) create mode 100644 apps/api/src/config.ts create mode 100644 apps/api/src/token-vault.ts create mode 100644 apps/api/test/safety.test.ts diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts new file mode 100644 index 0000000..c40351e --- /dev/null +++ b/apps/api/src/config.ts @@ -0,0 +1,39 @@ +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", + })) { + if (env[name] !== expected) throw new Error(`${name} must be ${expected} in production`); + } + for (const name of [ + "DATABASE_URL", + "TOKEN_ENCRYPTION_KEY", + "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.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 c0aaa28..6114407 100644 --- a/apps/api/src/controllers.ts +++ b/apps/api/src/controllers.ts @@ -52,9 +52,12 @@ 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.status), meta: { limit: parsed.limit } }; + const data = (await this.reviews.list(principal, parsed.status)) + .filter((review) => !parsed.locationId || review.snapshot.locationId === parsed.locationId) + .slice(0, parsed.limit); + return { data, meta: { limit: parsed.limit } }; } @Get(":id") @@ -119,8 +122,8 @@ export class KnowledgeController { constructor(private readonly store: MemoryStore) {} @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() @@ -131,9 +134,9 @@ export class KnowledgeController { @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) { + const result = await this.store.approveKnowledge(principal.tenantId, id); + await this.store.appendAudit(principal, "knowledge.approved", "knowledge", id, { version: result.version, }); return result; @@ -146,8 +149,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() @@ -158,12 +161,18 @@ export class AutomationController { @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; @@ -177,8 +186,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) }; } } @@ -219,8 +228,8 @@ export class IntegrationsController { async callback(@Query("code") code: string, @Query("state") state: string) { const payload = verifyState(state); 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", @@ -255,14 +264,21 @@ 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 }; + 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); + 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") @@ -276,12 +292,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; } 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/main.ts b/apps/api/src/main.ts index 40beb9e..afcd671 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -3,12 +3,26 @@ 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", + ], + } + : false, + bodyLimit: 8_000_000, + }), ); app.setGlobalPrefix("v1"); app.enableCors({ @@ -16,6 +30,7 @@ export async function createApp() { credentials: true, }); app.useGlobalFilters(new HttpErrorFilter()); + app.enableShutdownHooks(); const document = SwaggerModule.createDocument( app, new DocumentBuilder() diff --git a/apps/api/src/notifications.ts b/apps/api/src/notifications.ts index 8251db7..085d55f 100644 --- a/apps/api/src/notifications.ts +++ b/apps/api/src/notifications.ts @@ -8,7 +8,7 @@ export class ReviewNotificationService { constructor(private readonly store: MemoryStore) {} async reviewReady(principal: RequestPrincipal, review: ReviewCase): Promise { - const registrations = this.store.listDeviceRegistrations(principal.tenantId); + const registrations = await this.store.listDeviceRegistrations(principal.tenantId); const userIds = [...new Set(registrations.map((registration) => registration.userId))]; if (userIds.length === 0) return false; const gateway = new ExpoNotificationGateway(async (recipients) => 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 0100f3f..652bb55 100644 --- a/apps/api/src/review.service.ts +++ b/apps/api/src/review.service.ts @@ -2,9 +2,11 @@ import { Inject, Injectable } from "@nestjs/common"; import type { RequestPrincipal, ReviewCase, ReviewSnapshot } from "@reviewguard/contracts"; import { assertExpectedVersion, + DomainError, decideAutomation, detectHardStops, type GoogleBusinessGateway, + InMemoryKnowledgeRetriever, type ReplyModelProvider, } from "@reviewguard/core"; import { ReviewNotificationService } from "./notifications.js"; @@ -12,6 +14,8 @@ 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( @@ -21,256 +25,433 @@ export class ReviewService { private readonly notifications: ReviewNotificationService, private readonly tasks: PublishTaskScheduler, ) {} - - list(principal: RequestPrincipal, status?: ReviewCase["status"]): ReviewCase[] { + 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, ): 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); + if ( + snapshot.existingReply || + ["pending_approval", "scheduled_auto", "published", "publishing"].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( + 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 now = Date.now(); + const eligible = sources.filter( (entry) => entry.status === "approved" && - (!entry.locationId || entry.locationId === current.snapshot.locationId), + (!entry.locationId || entry.locationId === current.snapshot.locationId) && + (!entry.validFrom || Date.parse(entry.validFrom) <= now) && + (!entry.validUntil || Date.parse(entry.validUntil) > now), + ); + const retriever = new InMemoryKnowledgeRetriever( + eligible.flatMap( + (entry) => + entry.content + .match(/[\s\S]{1,3500}/g) + ?.map((content) => ({ + sourceId: entry.id, + title: entry.title, + content, + score: entry.kind === "policy" || entry.kind === "forbidden_claim" ? 1 : 0.15, + version: entry.version, + })) ?? [], + ), + ); + const knowledge = await retriever.retrieve({ + tenantId: principal.tenantId, + locationId: current.snapshot.locationId, + review: current.snapshot, + limit: 12, + }); + 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 (!knowledge.length) flags.push("insufficient_knowledge"); + if ( + 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 = await this.ai.generateDraft(input); - const checked = await this.ai.validateDraft({ ...input, draft: generated.value }); - 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, + 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, - 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") { + scheduledAt: decision.scheduledAt, + matchedRuleId: decision.matchedRuleId, + }, + ); + 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); + } + } 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); + 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); - 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) { - const attention = this.store.transition( + 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 (!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); + } + review = await this.store.transition(principal.tenantId, id, "publishing", review.version); + const value: PublishIntent = { + text: review.activeDraft!.text, + baseVersion: expectedVersion, + manual, + startedAt: Date.now(), + }; + if ( + !(await this.store.repository.put( principal.tenantId, + "publish", 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, - }, - ); - return attention; + value, + intent?.version ?? null, + new Date(Date.now() + 30 * 86_400_000).toISOString(), + )) + ) + throw new DomainError("Publication intent conflict", "publish_conflict", 409); + 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 (intent && canonical.existingReply === intent.value.text) + return this.confirmPublished(principal, review, intent.value, canonical.updateTime); + if (canonical.updateTime !== review.snapshot.updateTime || canonical.existingReply) + return this.invalidate(principal, review, canonical); + 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) { + // 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; } - const published = await this.google.updateReply( - accessToken, + } + private async reconcile(principal: RequestPrincipal, review: ReviewCase, intent: PublishIntent) { + const canonical = await this.google.getReview( + await this.currentAccessToken(principal.tenantId), 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, - }); - this.store.appendAudit(principal, "reply.published", "review", id, { - googleUpdateTime: published.updateTime, + 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, + 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, }); - this.store.recordPublished(result, manual); + await this.store.recordPublished(result, intent.manual); return result; } - - reject( + private async invalidate( principal: RequestPrincipal, - id: string, - expectedVersion: number, - reason?: string, - ): ReviewCase { - const result = this.store.transition(principal.tenantId, id, "rejected", expectedVersion, { + review: ReviewCase, + snapshot: ReviewSnapshot, + ) { + return this.store.transition(principal.tenantId, review.id, "needs_attention", review.version, { + snapshot, + 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 1ae5f4a..de5f639 100644 --- a/apps/api/src/store.ts +++ b/apps/api/src/store.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { Injectable } from "@nestjs/common"; +import { Injectable, type OnModuleDestroy, type OnModuleInit } from "@nestjs/common"; import type { AuditEvent, AutomationRule, @@ -8,59 +8,130 @@ import type { ReviewCase, 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 { + 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 { 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, status?: ReviewCase["status"]): ReviewCase[] { - return [...this.reviews.values()] - .filter((review) => review.tenantId === tenantId && (!status || review.status === status)) - .sort((left, right) => right.createdAt.localeCompare(left.createdAt)) - .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; + constructor() { + const persistent = process.env.STORAGE_MODE === "postgres"; + if (process.env.NODE_ENV === "production" && (!persistent || !process.env.TOKEN_ENCRYPTION_KEY)) + throw new Error("Production requires PostgreSQL and TOKEN_ENCRYPTION_KEY"); + if (persistent && !process.env.DATABASE_URL) throw new Error("DATABASE_URL is required"); + this.repository = persistent + ? new PostgresRecordRepository(process.env.DATABASE_URL as string) + : new MemoryRecordRepository(); + this.vault = 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.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); + } + await this.upsertLocation(DEMO_TENANT_ID, { + id: DEMO_REVIEWS[0]!.snapshot.locationId, + googleAccountName: "accounts/demo", + googleLocationName: `locations/${DEMO_REVIEWS[0]!.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)); + } + 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): Promise { + const id = deterministicUuid(snapshot.googleReviewName); + const existing = + (await this.repository.get(tenantId, "review", id)) ?? + (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 updated = { + ...existing.value, + snapshot, + status: "needs_attention" as const, + activeDraft: null, + validation: null, + scheduledAt: null, + matchedRuleId: null, + version: existing.value.version + 1, + updatedAt: new Date().toISOString(), + }; + 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, @@ -71,40 +142,42 @@ export class MemoryStore { createdAt: now, updatedAt: now, }; - 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 listKnowledge(tenantId: string) { + return this.values(tenantId, "knowledge"); } - - createKnowledge( + 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, @@ -117,30 +190,40 @@ 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) { + return this.changeKnowledge(tenantId, id, "approved"); + } + async changeKnowledge( + tenantId: string, + id: string, + status: KnowledgeSource["status"], + patch: Partial = {}, + ) { + const entry = await this.repository.get(tenantId, "knowledge", id); + if (!entry) throw new NotFoundError("Knowledge source", id); + 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, @@ -152,7 +235,7 @@ export class MemoryStore { | "createdAt" | "updatedAt" >, - ): AutomationRule { + ) { const now = new Date().toISOString(); const rule: AutomationRule = { ...input, @@ -165,34 +248,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, @@ -203,81 +287,202 @@ 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); - return true; + 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 VersionConflictError(0, entry.version); + return Boolean( + await this.repository.put( + tenantId, + "event", + id, + { completed: false, leaseUntil: Date.now() + 120_000 }, + entry?.version ?? null, + expiry(2), + ), + ); } - - 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: 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 manualApprovalCount(tenantId: string, locationId: string) { + return ( + (await this.repository.get<{ count: number }>(tenantId, "counter", `manual/${locationId}`)) + ?.value.count ?? 0 + ); } - - sentTodayByRule(ruleIds: readonly string[]): Record { - const today = new Date().toISOString().slice(0, 10); + 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; + } +} +function expiry(days = 30) { + 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..7d67c42 --- /dev/null +++ b/apps/api/src/token-vault.ts @@ -0,0 +1,27 @@ +import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"; + +/** 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; + } +} diff --git a/apps/api/test/safety.test.ts b/apps/api/test/safety.test.ts new file mode 100644 index 0000000..fb34d99 --- /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/packages/core/src/ai/openrouter.ts b/packages/core/src/ai/openrouter.ts index 242f599..0d38778 100644 --- a/packages/core/src/ai/openrouter.ts +++ b/packages/core/src/ai/openrouter.ts @@ -65,7 +65,7 @@ export class OpenRouterReplyProvider implements ReplyModelProvider { allow_fallbacks: true, }; if (this.options.providerAllowlist?.length) { - provider.order = this.options.providerAllowlist; + provider.only = this.options.providerAllowlist; } const response = await this.request(`${this.baseUrl}/chat/completions`, { @@ -99,9 +99,8 @@ export class OpenRouterReplyProvider implements ReplyModelProvider { }); if (!response.ok) { - const body = await response.text(); throw new DomainError( - `AI provider returned ${response.status}: ${body.slice(0, 300)}`, + `AI provider returned HTTP ${response.status}`, "ai_provider_error", 502, ); diff --git a/packages/core/test/openrouter.test.ts b/packages/core/test/openrouter.test.ts index f9523c1..f5679d0 100644 --- a/packages/core/test/openrouter.test.ts +++ b/packages/core/test/openrouter.test.ts @@ -64,7 +64,7 @@ describe("OpenRouter safety envelope", () => { zdr: true, data_collection: "deny", require_parameters: true, - order: ["verified-provider"], + only: ["verified-provider"], }); }); }); From 88bac6c04c5329db857b8d2fb45962ea950970c7 Mon Sep 17 00:00:00 2001 From: Esdragones Date: Wed, 16 Sep 2026 16:48:02 +0200 Subject: [PATCH 03/14] feat(api): complete Google onboarding, identity verification and indexed knowledge --- apps/api/package.json | 5 + apps/api/scripts/grant-access.mjs | 86 + apps/api/src/app.module.ts | 10 + apps/api/src/auth.ts | 44 +- apps/api/src/config.ts | 5 +- apps/api/src/controllers.ts | 241 +- apps/api/src/document-worker.ts | 20 + apps/api/src/documents.controller.ts | 110 + apps/api/src/identity-account.ts | 54 + apps/api/src/integration.service.ts | 116 + apps/api/src/knowledge.service.ts | 149 ++ apps/api/src/main.ts | 7 + apps/api/src/review.service.ts | 95 +- apps/api/src/store.ts | 97 +- apps/api/src/token-vault.ts | 33 + apps/api/src/workspace.controller.ts | 82 + apps/api/test/identity-account.test.ts | 23 + apps/api/test/knowledge.test.ts | 15 + apps/api/test/safety.test.ts | 2 +- apps/worker/src/main.ts | 40 +- apps/worker/test/worker.test.ts | 6 +- packages/contracts/src/reviews.ts | 3 + packages/core/src/ai/openrouter.ts | 1 + packages/core/src/auth/identity.ts | 157 ++ packages/core/src/google/client.ts | 149 ++ packages/core/src/index.ts | 1 + packages/core/test/identity.test.ts | 49 + .../0004_runtime_knowledge_chunks.sql | 18 + .../migrations/meta/0004_snapshot.json | 2007 +++++++++++++++++ .../database/migrations/meta/_journal.json | 7 + packages/database/package.json | 2 + packages/database/src/client.ts | 5 +- packages/database/src/index.ts | 1 + packages/database/src/knowledge-index.ts | 10 + packages/database/src/maintenance.ts | 8 +- packages/database/src/records.ts | 226 +- packages/database/src/schema.ts | 58 + packages/database/test/postgres.test.ts | 184 ++ packages/database/test/records.test.ts | 6 +- pnpm-lock.yaml | 442 +++- 40 files changed, 4415 insertions(+), 159 deletions(-) create mode 100644 apps/api/scripts/grant-access.mjs create mode 100644 apps/api/src/document-worker.ts create mode 100644 apps/api/src/documents.controller.ts create mode 100644 apps/api/src/identity-account.ts create mode 100644 apps/api/src/integration.service.ts create mode 100644 apps/api/src/knowledge.service.ts create mode 100644 apps/api/src/workspace.controller.ts create mode 100644 apps/api/test/identity-account.test.ts create mode 100644 apps/api/test/knowledge.test.ts create mode 100644 packages/core/src/auth/identity.ts create mode 100644 packages/core/test/identity.test.ts create mode 100644 packages/database/migrations/0004_runtime_knowledge_chunks.sql create mode 100644 packages/database/migrations/meta/0004_snapshot.json create mode 100644 packages/database/src/knowledge-index.ts create mode 100644 packages/database/test/postgres.test.ts 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 index c40351e..65416cb 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -6,12 +6,13 @@ export function assertStartupConfiguration(env: NodeJS.ProcessEnv = process.env) 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", - "TOKEN_ENCRYPTION_KEY", + "GOOGLE_KMS_KEY_NAME", "IDENTITY_PROJECT_ID", "GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET", @@ -34,6 +35,8 @@ export function assertStartupConfiguration(env: NodeJS.ProcessEnv = process.env) } 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 6114407..3294f0b 100644 --- a/apps/api/src/controllers.ts +++ b/apps/api/src/controllers.ts @@ -23,14 +23,20 @@ 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 { GOOGLE_GATEWAY } from "./providers.js"; import { ReviewService } from "./review.service.js"; import { MemoryStore } from "./store.js"; @@ -119,7 +125,10 @@ 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() async list(@Principal() principal: RequestPrincipal) { @@ -132,10 +141,63 @@ 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") - async approve(@Principal() principal: RequestPrincipal, @Param("id") id: string) { - const result = await this.store.approveKnowledge(principal.tenantId, 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, }); @@ -159,6 +221,32 @@ 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") async enable( @@ -177,6 +265,14 @@ export class AutomationController { }); 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") @@ -208,16 +304,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) }; } @@ -227,6 +334,25 @@ 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); await this.store.setGoogleTokens(payload.tenantId, tokens); await this.store.appendAudit( @@ -239,6 +365,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") @@ -267,12 +423,23 @@ export class GoogleWebhookController { const notification = googleReviewNotificationSchema.parse( JSON.parse(Buffer.from(envelope.message.data, "base64").toString("utf8")), ); + 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); + const review = await this.reviews.ingestAndGenerate(principal, { + ...snapshot, + locationId: location?.id ?? snapshot.locationId, + }); await this.store.completeEvent(principal.tenantId, envelope.message.messageId); return { accepted: true, reviewId: review.id }; } catch (error) { @@ -282,8 +449,22 @@ export class GoogleWebhookController { } @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); } } @@ -314,7 +495,18 @@ 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, + ) {} + + @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() @@ -339,13 +531,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(); @@ -353,12 +555,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..4235ea1 --- /dev/null +++ b/apps/api/src/document-worker.ts @@ -0,0 +1,20 @@ +import { parentPort, workerData } from "node:worker_threads"; +import mammoth from "mammoth"; +import { PDFParse } from "pdf-parse"; + +async function extract() { + const data = Buffer.from(workerData.base64, "base64"); + if (workerData.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(); + } +} +extract() + .then((text) => parentPort?.postMessage({ text })) + .catch(() => parentPort?.postMessage({ error: true })); diff --git a/apps/api/src/documents.controller.ts b/apps/api/src/documents.controller.ts new file mode 100644 index 0000000..a05683a --- /dev/null +++ b/apps/api/src/documents.controller.ts @@ -0,0 +1,110 @@ +import { Worker } from "node:worker_threads"; +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 = new Worker(new URL(path, import.meta.url), { + workerData: { extension, base64 }, + execArgv: [], + resourceLimits: { maxOldGenerationSizeMb: 128 }, + }); + const timeout = setTimeout(() => { + void worker.terminate(); + reject( + new DomainError( + "Estrazione scaduta. Usa un documento più semplice o incolla il testo.", + "document_timeout", + 400, + ), + ); + }, 15_000); + const finish = () => { + clearTimeout(timeout); + void worker.terminate(); + }; + 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", (code) => { + if (code !== 0) { + finish(); + reject(new DomainError("Documento troppo complesso", "document_parse_failed", 400)); + } + }); + }); + } + 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/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 afcd671..80970b5 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -19,6 +19,13 @@ export async function createApp() { "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, diff --git a/apps/api/src/review.service.ts b/apps/api/src/review.service.ts index 652bb55..947648f 100644 --- a/apps/api/src/review.service.ts +++ b/apps/api/src/review.service.ts @@ -6,9 +6,9 @@ import { decideAutomation, detectHardStops, type GoogleBusinessGateway, - InMemoryKnowledgeRetriever, 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"; @@ -24,6 +24,7 @@ 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, status?: ReviewCase["status"]) { return this.store.listReviews(principal.tenantId, status); @@ -84,34 +85,11 @@ export class ReviewService { this.store.listLocations(principal.tenantId), this.store.listRules(principal.tenantId), ]); - const now = Date.now(); - const eligible = sources.filter( - (entry) => - entry.status === "approved" && - (!entry.locationId || entry.locationId === current.snapshot.locationId) && - (!entry.validFrom || Date.parse(entry.validFrom) <= now) && - (!entry.validUntil || Date.parse(entry.validUntil) > now), - ); - const retriever = new InMemoryKnowledgeRetriever( - eligible.flatMap( - (entry) => - entry.content - .match(/[\s\S]{1,3500}/g) - ?.map((content) => ({ - sourceId: entry.id, - title: entry.title, - content, - score: entry.kind === "policy" || entry.kind === "forbidden_claim" ? 1 : 0.15, - version: entry.version, - })) ?? [], - ), + const knowledge = await this.knowledgeService.retrieve( + principal.tenantId, + current.snapshot, + sources, ); - const knowledge = await retriever.retrieve({ - tenantId: principal.tenantId, - locationId: current.snapshot.locationId, - review: current.snapshot, - limit: 12, - }); const location = locations.find((entry) => entry.id === current.snapshot.locationId); const input = { review: current.snapshot, @@ -124,6 +102,7 @@ export class ReviewService { 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 ( generated.value.knowledgeSourceIds.some( @@ -176,6 +155,9 @@ export class ReviewService { validation, scheduledAt: decision.scheduledAt, matchedRuleId: decision.matchedRuleId, + knowledgeVersions: Object.fromEntries( + knowledge.map((entry) => [entry.sourceId, entry.version]), + ), }, ); await this.store.appendAudit( @@ -285,6 +267,43 @@ export class ReviewService { 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, + ); + 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, + }); if (!manual) { if (!review.scheduledAt || Date.parse(review.scheduledAt) > Date.now()) throw new DomainError("Task arrived before scheduled delivery", "task_early", 503); @@ -318,31 +337,18 @@ export class ReviewService { ) return this.cancelSchedule(principal, id, review.version); } - review = await this.store.transition(principal.tenantId, id, "publishing", review.version); const value: PublishIntent = { - text: review.activeDraft!.text, + text: review.activeDraft.text, baseVersion: expectedVersion, manual, startedAt: Date.now(), }; - if ( - !(await this.store.repository.put( - principal.tenantId, - "publish", - id, - value, - intent?.version ?? null, - new Date(Date.now() + 30 * 86_400_000).toISOString(), - )) - ) - throw new DomainError("Publication intent conflict", "publish_conflict", 409); + review = await this.store.beginPublication(review, value, intent?.version ?? null); 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 (intent && canonical.existingReply === intent.value.text) - return this.confirmPublished(principal, review, intent.value, canonical.updateTime); if (canonical.updateTime !== review.snapshot.updateTime || canonical.existingReply) return this.invalidate(principal, review, canonical); await this.google.updateReply(token, review.snapshot.googleReviewName, value.text); @@ -405,7 +411,8 @@ export class ReviewService { snapshot: ReviewSnapshot, ) { return this.store.transition(principal.tenantId, review.id, "needs_attention", review.version, { - snapshot, + snapshot: { ...snapshot, locationId: review.snapshot.locationId }, + wasUpdated: true, activeDraft: null, validation: null, scheduledAt: null, diff --git a/apps/api/src/store.ts b/apps/api/src/store.ts index de5f639..8a9ce99 100644 --- a/apps/api/src/store.ts +++ b/apps/api/src/store.ts @@ -9,6 +9,7 @@ import type { ReviewSnapshot, } from "@reviewguard/contracts"; import { + DomainError, type GoogleTokens, NotFoundError, transitionReview, @@ -20,7 +21,7 @@ import { type RecordRepository, } from "@reviewguard/database"; import { DEMO_KNOWLEDGE, DEMO_REVIEWS, DEMO_RULES, DEMO_TENANT_ID } from "./demo.js"; -import { TokenVault } from "./token-vault.js"; +import { KmsTokenVault, TokenVault } from "./token-vault.js"; export type Location = { id: string; @@ -38,18 +39,30 @@ export type StoredGoogleTokens = GoogleTokens & { expiresAt: number }; @Injectable() export class MemoryStore implements OnModuleInit, OnModuleDestroy { readonly repository: RecordRepository; - private readonly vault: TokenVault; + private readonly vault: TokenVault | KmsTokenVault; constructor() { const persistent = process.env.STORAGE_MODE === "postgres"; - if (process.env.NODE_ENV === "production" && (!persistent || !process.env.TOKEN_ENCRYPTION_KEY)) - throw new Error("Production requires PostgreSQL and TOKEN_ENCRYPTION_KEY"); - if (persistent && !process.env.DATABASE_URL) throw new Error("DATABASE_URL is required"); + 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 = new TokenVault(process.env.TOKEN_ENCRYPTION_KEY); + this.vault = process.env.GOOGLE_KMS_KEY_NAME + ? new KmsTokenVault(process.env.GOOGLE_KMS_KEY_NAME) + : new TokenVault(process.env.TOKEN_ENCRYPTION_KEY); } 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], @@ -59,10 +72,12 @@ export class MemoryStore implements OnModuleInit, OnModuleDestroy { 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: DEMO_REVIEWS[0]!.snapshot.locationId, + id: first.snapshot.locationId, googleAccountName: "accounts/demo", - googleLocationName: `locations/${DEMO_REVIEWS[0]!.snapshot.locationId}`, + googleLocationName: `locations/${first.snapshot.locationId}`, displayName: "Sede dimostrativa", active: true, defaultLanguage: "it", @@ -97,9 +112,10 @@ export class MemoryStore implements OnModuleInit, OnModuleDestroy { const id = deterministicUuid(snapshot.googleReviewName); const existing = (await this.repository.get(tenantId, "review", id)) ?? - (await this.repository.list(tenantId, "review")).find( - (entry) => entry.value.snapshot.googleReviewName === snapshot.googleReviewName, - ); + ((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 updated = { @@ -112,6 +128,8 @@ export class MemoryStore implements OnModuleInit, OnModuleDestroy { matchedRuleId: null, version: existing.value.version + 1, updatedAt: new Date().toISOString(), + contentExpiresAt: expiry(), + wasUpdated: true, }; if ( !(await this.repository.put( @@ -141,6 +159,8 @@ export class MemoryStore implements OnModuleInit, OnModuleDestroy { publishedReply: null, createdAt: now, updatedAt: now, + contentExpiresAt: expiry(), + wasUpdated: false, }; if (!(await this.repository.put(tenantId, "review", id, review, null, expiry()))) return this.getReview(tenantId, id); @@ -168,6 +188,29 @@ export class MemoryStore implements OnModuleInit, OnModuleDestroy { expectedVersion, ); } + 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; + } async listKnowledge(tenantId: string) { return this.values(tenantId, "knowledge"); } @@ -193,17 +236,20 @@ export class MemoryStore implements OnModuleInit, OnModuleDestroy { await this.repository.put(principal.tenantId, "knowledge", entry.id, entry, null); return entry; } - async approveKnowledge(tenantId: string, id: string) { - return this.changeKnowledge(tenantId, id, "approved"); + 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, @@ -303,17 +349,17 @@ export class MemoryStore implements OnModuleInit, OnModuleDestroy { ); if (entry?.value.completed) return false; if (entry && entry.value.leaseUntil > Date.now()) - throw new VersionConflictError(0, entry.version); - return Boolean( - await this.repository.put( - tenantId, - "event", - id, - { completed: false, leaseUntil: Date.now() + 120_000 }, - entry?.version ?? null, - expiry(2), - ), + 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; } async completeEvent(tenantId: string, id: string) { const entry = await this.repository.get(tenantId, "event", id); @@ -342,7 +388,7 @@ export class MemoryStore implements OnModuleInit, OnModuleDestroy { tenantId, "google_tokens", "connection", - { encrypted: this.vault.seal(value, tenantId) }, + { encrypted: await this.vault.seal(value, tenantId) }, record?.version ?? null, )) ) @@ -479,7 +525,8 @@ export class MemoryStore implements OnModuleInit, OnModuleDestroy { return value; } } -function expiry(days = 30) { +// 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) { diff --git a/apps/api/src/token-vault.ts b/apps/api/src/token-vault.ts index 7d67c42..053f2d5 100644 --- a/apps/api/src/token-vault.ts +++ b/apps/api/src/token-vault.ts @@ -1,4 +1,5 @@ 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 { @@ -25,3 +26,35 @@ export class TokenVault { ) 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/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/safety.test.ts b/apps/api/test/safety.test.ts index fb34d99..f7dd49d 100644 --- a/apps/api/test/safety.test.ts +++ b/apps/api/test/safety.test.ts @@ -78,7 +78,7 @@ describe("API workflow safety", () => { publishTime: new Date().toISOString(), data: Buffer.from( JSON.stringify({ - notificationType: "NEW_REVIEW", + notificationType: "NEW_REVIEW", reviewName: name, locationName: "locations/demo-location", }), diff --git a/apps/worker/src/main.ts b/apps/worker/src/main.ts index b048f1e..0d47884 100644 --- a/apps/worker/src/main.ts +++ b/apps/worker/src/main.ts @@ -3,7 +3,6 @@ import { googleReviewNotificationSchema, pubSubEnvelopeSchema, } from "@reviewguard/contracts"; -import { createDatabase, purgeExpiredGoogleContent } from "@reviewguard/database"; import Fastify, { type FastifyInstance } from "fastify"; import { z } from "zod"; import { verifyGoogleOidc } from "./auth.js"; @@ -23,8 +22,6 @@ export type WorkerOptions = { export function createWorker(options: WorkerOptions = {}): FastifyInstance { const worker = Fastify({ logger: process.env.NODE_ENV !== "test" }); const fetchImpl = options.fetchImpl ?? fetch; - const now = options.now ?? Date.now; - const claimed = new Map(); worker.get("/health", async () => ({ status: "ok", @@ -35,17 +32,10 @@ export function createWorker(options: WorkerOptions = {}): FastifyInstance { worker.post("/events/google-business", async (request, reply) => { await verifyGoogleOidc(request); const envelope = pubSubEnvelopeSchema.parse(request.body); - pruneClaims(claimed, now()); - if (claimed.has(envelope.message.messageId)) return { duplicate: true }; const _notification = parseNotification(envelope.message.data); - claimed.set(envelope.message.messageId, now()); - try { - await callApi(fetchImpl, "/webhooks/google-business", envelope); - return reply.code(204).send(); - } catch (error) { - claimed.delete(envelope.message.messageId); - throw error; - } + // Only the durable API lease may deduplicate. In-flight redelivery must not be acknowledged early. + await callApi(fetchImpl, "/webhooks/google-business", envelope); + return reply.code(204).send(); }); worker.post("/tasks/publish", async (request) => { @@ -60,15 +50,7 @@ export function createWorker(options: WorkerOptions = {}): FastifyInstance { worker.post("/tasks/purge-expired-google-content", async (request) => { await verifyGoogleOidc(request); - const connectionString = process.env.DATABASE_URL; - if (!connectionString) - throw Object.assign(new Error("DATABASE_URL is required"), { statusCode: 503 }); - const { db, pool } = createDatabase(connectionString); - try { - return { purged: await purgeExpiredGoogleContent(db) }; - } finally { - await pool.end(); - } + return callApi(fetchImpl, "/internal/reviews/purge-expired-google-content", {}); }); worker.setErrorHandler((error, _request, reply) => { @@ -77,7 +59,10 @@ export function createWorker(options: WorkerOptions = {}): FastifyInstance { const status = typeof statusCode === "number" ? statusCode : 500; reply.code(status).send({ error: status >= 500 ? "worker_error" : "invalid_request", - message: normalized.message, + message: + status >= 500 + ? "Worker request failed; retry or inspect service metrics" + : normalized.message, }); }); return worker; @@ -103,7 +88,7 @@ async function callApi(fetchImpl: typeof fetch, path: string, body: unknown): Pr process.env.INTERNAL_WORKER_SECRET ?? "reviewguard-local-worker-secret", }, body: JSON.stringify(body), - signal: AbortSignal.timeout(20_000), + signal: AbortSignal.timeout(95_000), }); if (!response.ok) { throw Object.assign(new Error(`API returned ${response.status}`), { @@ -114,13 +99,6 @@ async function callApi(fetchImpl: typeof fetch, path: string, body: unknown): Pr return response.json(); } -function pruneClaims(claimed: Map, time: number): void { - const retentionMs = 24 * 60 * 60 * 1_000; - for (const [messageId, claimedAt] of claimed) { - if (time - claimedAt > retentionMs) claimed.delete(messageId); - } -} - if (process.env.NODE_ENV !== "test") { const worker = createWorker(); await worker.listen({ port: Number(process.env.WORKER_PORT ?? 4200), host: "0.0.0.0" }); diff --git a/apps/worker/test/worker.test.ts b/apps/worker/test/worker.test.ts index 16a84ef..7533b9f 100644 --- a/apps/worker/test/worker.test.ts +++ b/apps/worker/test/worker.test.ts @@ -10,7 +10,7 @@ const notification = { describe("Google event worker", () => { afterEach(() => vi.restoreAllMocks()); - it("validates and deduplicates Pub/Sub delivery", async () => { + it("delegates every valid redelivery to the durable API lease", async () => { const fetchImpl = vi.fn( async () => new Response(JSON.stringify({ accepted: true }), { status: 200 }), ); @@ -28,8 +28,8 @@ describe("Google event worker", () => { payload, }); expect(first.statusCode).toBe(204); - expect(duplicate.json()).toEqual({ duplicate: true }); - expect(fetchImpl).toHaveBeenCalledTimes(1); + expect(duplicate.statusCode).toBe(204); + expect(fetchImpl).toHaveBeenCalledTimes(2); await worker.close(); }); diff --git a/packages/contracts/src/reviews.ts b/packages/contracts/src/reviews.ts index 462c40f..e24185b 100644 --- a/packages/contracts/src/reviews.ts +++ b/packages/contracts/src/reviews.ts @@ -48,6 +48,9 @@ export const reviewCaseSchema = z.object({ publishedReply: z.string().max(4_000).nullable(), createdAt: z.iso.datetime(), updatedAt: z.iso.datetime(), + contentExpiresAt: z.iso.datetime().optional(), + wasUpdated: z.boolean().optional(), + knowledgeVersions: z.record(z.string(), z.number().int().positive()).optional(), }); export type ReviewCase = z.infer; diff --git a/packages/core/src/ai/openrouter.ts b/packages/core/src/ai/openrouter.ts index 0d38778..7fd84be 100644 --- a/packages/core/src/ai/openrouter.ts +++ b/packages/core/src/ai/openrouter.ts @@ -37,6 +37,7 @@ export class OpenRouterReplyProvider implements ReplyModelProvider { if (!options.apiKey) { throw new DomainError("OPENROUTER_API_KEY is required", "ai_not_configured", 503); } + if (!options.providerAllowlist?.length) throw new DomainError("A verified provider allowlist is required", "ai_not_configured", 503); this.baseUrl = (options.baseUrl ?? "https://openrouter.ai/api/v1").replace(/\/$/, ""); this.model = options.model ?? "deepseek/deepseek-v4-pro-0813"; this.request = options.fetchImpl ?? fetch; diff --git a/packages/core/src/auth/identity.ts b/packages/core/src/auth/identity.ts new file mode 100644 index 0000000..554cd9f --- /dev/null +++ b/packages/core/src/auth/identity.ts @@ -0,0 +1,157 @@ +/** Identity Platform REST client shared by the server-side web session and native app. */ +export type IdentitySession = { idToken: string; refreshToken: string; expiresAt: number }; +export type MfaChallenge = { mfaPendingCredential: string; mfaEnrollmentId: string }; +export type TotpEnrollment = { + sharedSecretKey: string; + sessionInfo: string; + verificationCodeLength: number; + periodSec: number; + hashingAlgorithm: string; +}; + +export class IdentityClient { + constructor( + private readonly apiKey: string, + private readonly request: typeof fetch = fetch, + ) { + if (!apiKey) throw new Error("Autenticazione non configurata"); + } + private async call(method: string, body: unknown, version = "v1"): Promise { + const response = await this.request( + `https://identitytoolkit.googleapis.com/${version}/${method}?key=${encodeURIComponent(this.apiKey)}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(15_000), + }, + ); + const payload = (await response.json()) as T & { error?: { message?: string } }; + if (!response.ok) { + const code = payload.error?.message?.split(" : ")[0]; + const messages: Record = { + INVALID_LOGIN_CREDENTIALS: "Email o password non corretti", + EMAIL_NOT_FOUND: "Email o password non corretti", + INVALID_PASSWORD: "Email o password non corretti", + INVALID_MFA_CODE: "Codice MFA non corretto", + TOO_MANY_ATTEMPTS_TRY_LATER: "Troppi tentativi: riprova più tardi", + TOKEN_EXPIRED: "Sessione scaduta: accedi nuovamente", + INVALID_ID_TOKEN: "Sessione scaduta: accedi nuovamente", + USER_DISABLED: "Account disabilitato", + UNVERIFIED_EMAIL: "Verifica prima il tuo indirizzo email", + }; + throw new Error( + messages[code ?? ""] ?? + "Autenticazione non riuscita. Verifica la configurazione o riprova.", + ); + } + return payload; + } + private session(payload: { + idToken: string; + refreshToken: string; + expiresIn?: string; + }): IdentitySession { + return { + idToken: payload.idToken, + refreshToken: payload.refreshToken, + expiresAt: Date.now() + Number(payload.expiresIn ?? 3600) * 1000, + }; + } + async signIn( + email: string, + password: string, + ): Promise<{ session: IdentitySession } | { challenge: MfaChallenge }> { + const payload = await this.call<{ + idToken?: string; + refreshToken?: string; + expiresIn?: string; + mfaPendingCredential?: string; + mfaInfo?: Array<{ mfaEnrollmentId: string; totpInfo?: unknown }>; + }>("accounts:signInWithPassword", { email, password, returnSecureToken: true }); + if (payload.mfaPendingCredential) { + const factor = payload.mfaInfo?.find((info) => info.totpInfo); + if (!factor) + throw new Error( + "Questo account richiede un fattore non supportato. Configura TOTP dalla console Identity Platform.", + ); + return { + challenge: { + mfaPendingCredential: payload.mfaPendingCredential, + mfaEnrollmentId: factor.mfaEnrollmentId, + }, + }; + } + if (!payload.idToken || !payload.refreshToken) throw new Error("Sessione non valida"); + return { + session: this.session({ + ...payload, + idToken: payload.idToken, + refreshToken: payload.refreshToken, + }), + }; + } + async verifyMfa(challenge: MfaChallenge, code: string): Promise { + return this.session( + await this.call( + "accounts/mfaSignIn:finalize", + { ...challenge, totpVerificationInfo: { verificationCode: code } }, + "v2", + ), + ); + } + async refresh(refreshToken: string): Promise { + const response = await this.request( + `https://securetoken.googleapis.com/v1/token?key=${encodeURIComponent(this.apiKey)}`, + { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken }), + signal: AbortSignal.timeout(15_000), + }, + ); + if (!response.ok) throw new Error("Sessione scaduta: accedi nuovamente"); + const payload = (await response.json()) as { + id_token: string; + refresh_token: string; + expires_in: string; + }; + return this.session({ + idToken: payload.id_token, + refreshToken: payload.refresh_token, + expiresIn: payload.expires_in, + }); + } + async startEnrollment(idToken: string): Promise { + return ( + await this.call<{ totpSessionInfo: TotpEnrollment }>( + "accounts/mfaEnrollment:start", + { idToken, totpEnrollmentInfo: {} }, + "v2", + ) + ).totpSessionInfo; + } + async finishEnrollment( + idToken: string, + sessionInfo: string, + verificationCode: string, + ): Promise { + return this.session( + await this.call( + "accounts/mfaEnrollment:finalize", + { + idToken, + displayName: "AutoReview authenticator", + totpVerificationInfo: { sessionInfo, verificationCode }, + }, + "v2", + ), + ); + } + async resetPassword(email: string): Promise { + await this.call("accounts:sendOobCode", { requestType: "PASSWORD_RESET", email }); + } + async sendVerification(idToken: string): Promise { + await this.call("accounts:sendOobCode", { requestType: "VERIFY_EMAIL", idToken }); + } +} diff --git a/packages/core/src/google/client.ts b/packages/core/src/google/client.ts index ddc5cba..99d5a46 100644 --- a/packages/core/src/google/client.ts +++ b/packages/core/src/google/client.ts @@ -11,6 +11,23 @@ export interface GoogleBusinessGateway { buildAuthorizationUrl(state: string): string; exchangeCode(code: string): Promise; refreshAccessToken(refreshToken: string): Promise; + listAccounts(accessToken: string): Promise>; + listLocations( + accessToken: string, + accountName: string, + ): Promise>; + listReviews( + accessToken: string, + parent: string, + pageToken?: string, + ): Promise<{ reviews: ReviewSnapshot[]; nextPageToken?: string }>; + configureNotifications( + accessToken: string, + accountName: string, + topic: string, + expectedTopic?: string, + ): Promise; + revoke(token: string): Promise; getReview(accessToken: string, reviewName: string): Promise; updateReply( accessToken: string, @@ -107,6 +124,121 @@ export class GoogleBusinessClient implements GoogleBusinessGateway { return mapGoogleReview(payload, reviewName); } + async listAccounts(accessToken: string): Promise> { + const result: Array<{ name: string; accountName: string }> = []; + let pageToken = ""; + do { + const url = new URL("https://mybusinessaccountmanagement.googleapis.com/v1/accounts"); + if (pageToken) url.searchParams.set("pageToken", pageToken); + const payload = await this.json<{ + accounts?: Array<{ name: string; accountName: string }>; + nextPageToken?: string; + }>(accessToken, url.toString()); + result.push(...(payload.accounts ?? [])); + pageToken = payload.nextPageToken ?? ""; + } while (pageToken); + return result; + } + async listLocations( + accessToken: string, + accountName: string, + ): Promise> { + this.assertAccountName(accountName); + const result: Array<{ name: string; title: string }> = []; + let pageToken = ""; + do { + const url = new URL( + `https://mybusinessbusinessinformation.googleapis.com/v1/${accountName}/locations`, + ); + url.searchParams.set("readMask", "name,title"); + url.searchParams.set("pageSize", "100"); + if (pageToken) url.searchParams.set("pageToken", pageToken); + const payload = await this.json<{ + locations?: Array<{ name: string; title: string }>; + nextPageToken?: string; + }>(accessToken, url.toString()); + result.push(...(payload.locations ?? [])); + pageToken = payload.nextPageToken ?? ""; + } while (pageToken); + return result; + } + async listReviews(accessToken: string, parent: string, pageToken?: string) { + if (!/^accounts\/[^/]+\/locations\/[^/]+$/.test(parent)) + throw new DomainError("Invalid Google location", "invalid_google_resource", 400); + const url = new URL(`https://mybusiness.googleapis.com/v4/${parent}/reviews`); + url.searchParams.set("pageSize", "50"); + url.searchParams.set("orderBy", "updateTime desc"); + if (pageToken) url.searchParams.set("pageToken", pageToken); + const payload = await this.json<{ + reviews?: Array>; + nextPageToken?: string; + }>(accessToken, url.toString()); + return { + reviews: (payload.reviews ?? []).map((review) => + mapGoogleReview(review, `${parent}/reviews/${review.reviewId}`), + ), + nextPageToken: payload.nextPageToken, + }; + } + async configureNotifications( + accessToken: string, + accountName: string, + topic: string, + expectedTopic?: string, + ): Promise { + this.assertAccountName(accountName); + const url = `https://mybusinessnotifications.googleapis.com/v1/${accountName}/notificationSetting`; + const current = await this.json<{ pubsubTopic?: string }>(accessToken, url); + if (!topic && current.pubsubTopic && current.pubsubTopic !== expectedTopic) + throw new DomainError( + "Notification integration changed; disconnect it in Google", + "notification_conflict", + 409, + ); + if (topic && current.pubsubTopic && current.pubsubTopic !== topic) + throw new DomainError( + "This Google account already has a different notification integration; resolve it before connecting", + "notification_conflict", + 409, + ); + await this.json(accessToken, `${url}?updateMask=pubsubTopic,notificationTypes`, { + method: "PATCH", + body: JSON.stringify({ + name: `${accountName}/notificationSetting`, + pubsubTopic: topic, + notificationTypes: topic ? ["NEW_REVIEW", "UPDATED_REVIEW"] : [], + }), + }); + } + async revoke(token: string): Promise { + const response = await this.request("https://oauth2.googleapis.com/revoke", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ token }), + signal: AbortSignal.timeout(15_000), + }); + if (!response.ok && response.status !== 400) + throw new DomainError("Google revocation failed", "google_api_failed", 502); + } + private assertAccountName(name: string) { + if (!/^accounts\/[^/?#]+$/.test(name)) + throw new DomainError("Invalid Google account", "invalid_google_resource", 400); + } + private async json(accessToken: string, url: string, init: RequestInit = {}): Promise { + const response = await this.request(url, { + ...init, + headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" }, + signal: AbortSignal.timeout(15_000), + }); + if (!response.ok) + throw new DomainError( + `Google API returned HTTP ${response.status}`, + "google_api_failed", + response.status === 401 ? 401 : 502, + ); + return response.json() as Promise; + } + async updateReply( accessToken: string, reviewName: string, @@ -174,6 +306,23 @@ export class FakeGoogleBusinessClient implements GoogleBusinessGateway { async refreshAccessToken(refreshToken: string): Promise { return { accessToken: "demo-access-token", refreshToken, expiresIn: 3_600 }; } + async listAccounts() { + return [{ name: "accounts/demo", accountName: "Account dimostrativo" }]; + } + async listLocations() { + return [...new Set([...this.reviews.values()].map((review) => review.locationId))].map( + (id) => ({ name: `locations/${id}`, title: "Sede dimostrativa" }), + ); + } + async listReviews(_token: string, parent: string) { + return { + reviews: [...this.reviews.values()] + .filter((review) => review.googleReviewName.startsWith(`${parent}/reviews/`)) + .map((review) => structuredClone(review)), + }; + } + async configureNotifications() {} + async revoke() {} async getReview(_accessToken: string, reviewName: string): Promise { const review = this.reviews.get(reviewName); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e4cd9c0..f1dd340 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,6 +1,7 @@ export * from "./ai/openrouter.js"; export * from "./ai/prompts.js"; export * from "./ai/types.js"; +export * from "./auth/identity.js"; export * from "./automation/decision-engine.js"; export * from "./errors.js"; export * from "./google/client.js"; diff --git a/packages/core/test/identity.test.ts b/packages/core/test/identity.test.ts new file mode 100644 index 0000000..f9c7f8b --- /dev/null +++ b/packages/core/test/identity.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it, vi } from "vitest"; +import { IdentityClient } from "../src/auth/identity.js"; + +describe("Identity Platform session client", () => { + it("does not treat a pending MFA challenge as a session", async () => { + const request = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + mfaPendingCredential: "pending", + mfaInfo: [{ mfaEnrollmentId: "factor", totpInfo: {} }], + }), + ), + ); + const result = await new IdentityClient("public-key", request).signIn( + "owner@example.com", + "password", + ); + expect(result).toEqual({ + challenge: { mfaPendingCredential: "pending", mfaEnrollmentId: "factor" }, + }); + }); + it("finalizes TOTP using the official v2 contract", async () => { + const request = vi + .fn() + .mockResolvedValue(new Response(JSON.stringify({ idToken: "id", refreshToken: "refresh" }))); + const result = await new IdentityClient("key", request).verifyMfa( + { mfaPendingCredential: "pending", mfaEnrollmentId: "factor" }, + "123456", + ); + expect(result.idToken).toBe("id"); + expect(request.mock.calls[0]?.[0]).toContain("v2/accounts/mfaSignIn:finalize"); + expect(JSON.parse(request.mock.calls[0]?.[1].body)).toMatchObject({ + totpVerificationInfo: { verificationCode: "123456" }, + }); + }); + it("redacts provider error payloads", async () => { + const request = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + error: { message: "INVALID_LOGIN_CREDENTIALS : sensitive@example.com" }, + }), + { status: 400 }, + ), + ); + await expect(new IdentityClient("key", request).signIn("a@b.it", "password")).rejects.toThrow( + "Email o password non corretti", + ); + }); +}); diff --git a/packages/database/migrations/0004_runtime_knowledge_chunks.sql b/packages/database/migrations/0004_runtime_knowledge_chunks.sql new file mode 100644 index 0000000..d7f35d1 --- /dev/null +++ b/packages/database/migrations/0004_runtime_knowledge_chunks.sql @@ -0,0 +1,18 @@ +CREATE TABLE "runtime_knowledge_chunks" ( + "tenant_id" uuid NOT NULL, + "source_id" uuid NOT NULL, + "source_version" integer NOT NULL, + "ordinal" integer NOT NULL, + "content" text NOT NULL, + "embedding" vector(768) NOT NULL, + "embedding_model" text NOT NULL, + CONSTRAINT "runtime_knowledge_chunks_tenant_id_source_id_source_version_ordinal_pk" PRIMARY KEY("tenant_id","source_id","source_version","ordinal") +); +--> statement-breakpoint +-- runtime_records is already created by 0003; the generated snapshot now includes it. +ALTER TABLE runtime_knowledge_chunks ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +ALTER TABLE runtime_knowledge_chunks FORCE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE POLICY tenant_isolation ON runtime_knowledge_chunks USING(tenant_id=app_tenant_id()) WITH CHECK(tenant_id=app_tenant_id());--> statement-breakpoint +CREATE INDEX "runtime_knowledge_chunks_lookup_idx" ON "runtime_knowledge_chunks" USING btree ("tenant_id","source_id","source_version");--> statement-breakpoint +CREATE INDEX "runtime_knowledge_chunks_search_idx" ON "runtime_knowledge_chunks" USING gin (to_tsvector('simple',"content"));--> statement-breakpoint +CREATE INDEX "runtime_knowledge_chunks_vector_idx" ON "runtime_knowledge_chunks" USING hnsw ("embedding" vector_cosine_ops);--> statement-breakpoint diff --git a/packages/database/migrations/meta/0004_snapshot.json b/packages/database/migrations/meta/0004_snapshot.json new file mode 100644 index 0000000..97765cf --- /dev/null +++ b/packages/database/migrations/meta/0004_snapshot.json @@ -0,0 +1,2007 @@ +{ + "id": "fd902876-011b-4faa-91da-79a7fedcf9ed", + "prevId": "d8b42dc4-5607-4dd1-b410-3e0f650b2f47", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.audit_events": { + "name": "audit_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_events_tenant_time_idx": { + "name": "audit_events_tenant_time_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_events_tenant_id_tenants_id_fk": { + "name": "audit_events_tenant_id_tenants_id_fk", + "tableFrom": "audit_events", + "tableTo": "tenants", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audit_events_actor_id_users_id_fk": { + "name": "audit_events_actor_id_users_id_fk", + "tableFrom": "audit_events", + "tableTo": "users", + "columnsFrom": ["actor_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.automation_rules": { + "name": "automation_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "location_ids": { + "name": "location_ids", + "type": "uuid[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY[]::uuid[]" + }, + "star_ratings": { + "name": "star_ratings", + "type": "integer[]", + "primaryKey": false, + "notNull": true + }, + "languages": { + "name": "languages", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY[]::text[]" + }, + "comment_mode": { + "name": "comment_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'any'" + }, + "categories": { + "name": "categories", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY[]::text[]" + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "delay_minutes": { + "name": "delay_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "daily_limit": { + "name": "daily_limit", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 20 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "consent_version": { + "name": "consent_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "consented_by": { + "name": "consented_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "consented_at": { + "name": "consented_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "automation_rules_tenant_idx": { + "name": "automation_rules_tenant_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "automation_rules_tenant_id_tenants_id_fk": { + "name": "automation_rules_tenant_id_tenants_id_fk", + "tableFrom": "automation_rules", + "tableTo": "tenants", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "automation_rules_consented_by_users_id_fk": { + "name": "automation_rules_consented_by_users_id_fk", + "tableFrom": "automation_rules", + "tableTo": "users", + "columnsFrom": ["consented_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_tokens": { + "name": "device_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "device_tokens_token_uidx": { + "name": "device_tokens_token_uidx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "device_tokens_tenant_id_tenants_id_fk": { + "name": "device_tokens_tenant_id_tenants_id_fk", + "tableFrom": "device_tokens", + "tableTo": "tenants", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "device_tokens_user_id_users_id_fk": { + "name": "device_tokens_user_id_users_id_fk", + "tableFrom": "device_tokens", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.google_connections": { + "name": "google_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "google_account_name": { + "name": "google_account_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_refresh_token": { + "name": "encrypted_refresh_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_key_version": { + "name": "token_key_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_error_code": { + "name": "last_error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "connected_by": { + "name": "connected_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "google_connections_tenant_idx": { + "name": "google_connections_tenant_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "google_connections_tenant_id_tenants_id_fk": { + "name": "google_connections_tenant_id_tenants_id_fk", + "tableFrom": "google_connections", + "tableTo": "tenants", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "google_connections_connected_by_users_id_fk": { + "name": "google_connections_connected_by_users_id_fk", + "tableFrom": "google_connections", + "tableTo": "users", + "columnsFrom": ["connected_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_chunks": { + "name": "knowledge_chunks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "ordinal": { + "name": "ordinal", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "search_text": { + "name": "search_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(768)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "knowledge_chunks_source_ordinal_uidx": { + "name": "knowledge_chunks_source_ordinal_uidx", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ordinal", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "knowledge_chunks_tenant_idx": { + "name": "knowledge_chunks_tenant_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_chunks_tenant_id_tenants_id_fk": { + "name": "knowledge_chunks_tenant_id_tenants_id_fk", + "tableFrom": "knowledge_chunks", + "tableTo": "tenants", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_chunks_source_id_knowledge_sources_id_fk": { + "name": "knowledge_chunks_source_id_knowledge_sources_id_fk", + "tableFrom": "knowledge_chunks", + "tableTo": "knowledge_sources", + "columnsFrom": ["source_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_sources": { + "name": "knowledge_sources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "location_id": { + "name": "location_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "knowledge_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "knowledge_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "valid_from": { + "name": "valid_from", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "valid_until": { + "name": "valid_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "knowledge_sources_lookup_idx": { + "name": "knowledge_sources_lookup_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "location_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_sources_tenant_id_tenants_id_fk": { + "name": "knowledge_sources_tenant_id_tenants_id_fk", + "tableFrom": "knowledge_sources", + "tableTo": "tenants", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_sources_location_id_locations_id_fk": { + "name": "knowledge_sources_location_id_locations_id_fk", + "tableFrom": "knowledge_sources", + "tableTo": "locations", + "columnsFrom": ["location_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_sources_author_id_users_id_fk": { + "name": "knowledge_sources_author_id_users_id_fk", + "tableFrom": "knowledge_sources", + "tableTo": "users", + "columnsFrom": ["author_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.locations": { + "name": "locations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "google_account_name": { + "name": "google_account_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "google_location_name": { + "name": "google_location_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "default_language": { + "name": "default_language", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'it'" + }, + "tone": { + "name": "tone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'professionale, umano e conciso'" + }, + "manual_approval_count": { + "name": "manual_approval_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "locations_tenant_google_uidx": { + "name": "locations_tenant_google_uidx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "google_location_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "locations_tenant_idx": { + "name": "locations_tenant_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "locations_tenant_id_tenants_id_fk": { + "name": "locations_tenant_id_tenants_id_fk", + "tableFrom": "locations", + "tableTo": "tenants", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memberships": { + "name": "memberships", + "schema": "", + "columns": { + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "memberships_tenant_id_tenants_id_fk": { + "name": "memberships_tenant_id_tenants_id_fk", + "tableFrom": "memberships", + "tableTo": "tenants", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_user_id_users_id_fk": { + "name": "memberships_user_id_users_id_fk", + "tableFrom": "memberships", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "memberships_tenant_id_user_id_pk": { + "name": "memberships_tenant_id_user_id_pk", + "columns": ["tenant_id", "user_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.processed_events": { + "name": "processed_events", + "schema": "", + "columns": { + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.publish_attempts": { + "name": "publish_attempts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "review_case_id": { + "name": "review_case_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_status": { + "name": "provider_status", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "publish_attempts_idempotency_uidx": { + "name": "publish_attempts_idempotency_uidx", + "columns": [ + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "attempt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "publish_attempts_review_idx": { + "name": "publish_attempts_review_idx", + "columns": [ + { + "expression": "review_case_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "publish_attempts_tenant_id_tenants_id_fk": { + "name": "publish_attempts_tenant_id_tenants_id_fk", + "tableFrom": "publish_attempts", + "tableTo": "tenants", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "publish_attempts_review_case_id_review_cases_id_fk": { + "name": "publish_attempts_review_case_id_review_cases_id_fk", + "tableFrom": "publish_attempts", + "tableTo": "review_cases", + "columnsFrom": ["review_case_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reply_drafts": { + "name": "reply_drafts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "review_case_id": { + "name": "review_case_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "risk_flags": { + "name": "risk_flags", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY[]::text[]" + }, + "knowledge_source_ids": { + "name": "knowledge_source_ids", + "type": "uuid[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY[]::uuid[]" + }, + "unsupported_claims": { + "name": "unsupported_claims", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY[]::text[]" + }, + "requires_human_review": { + "name": "requires_human_review", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "validation": { + "name": "validation", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prompt_version": { + "name": "prompt_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "human_instruction": { + "name": "human_instruction", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "reply_drafts_revision_uidx": { + "name": "reply_drafts_revision_uidx", + "columns": [ + { + "expression": "review_case_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "revision", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reply_drafts_tenant_idx": { + "name": "reply_drafts_tenant_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reply_drafts_tenant_id_tenants_id_fk": { + "name": "reply_drafts_tenant_id_tenants_id_fk", + "tableFrom": "reply_drafts", + "tableTo": "tenants", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reply_drafts_review_case_id_review_cases_id_fk": { + "name": "reply_drafts_review_case_id_review_cases_id_fk", + "tableFrom": "reply_drafts", + "tableTo": "review_cases", + "columnsFrom": ["review_case_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reply_drafts_created_by_users_id_fk": { + "name": "reply_drafts_created_by_users_id_fk", + "tableFrom": "reply_drafts", + "tableTo": "users", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.review_cases": { + "name": "review_cases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "location_id": { + "name": "location_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "google_review_name": { + "name": "google_review_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "google_create_time": { + "name": "google_create_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "google_update_time": { + "name": "google_update_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "reviewer_display_name": { + "name": "reviewer_display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "star_rating": { + "name": "star_rating", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "content_expires_at": { + "name": "content_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "existing_reply": { + "name": "existing_reply", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "review_workflow_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'received'" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "risk_flags": { + "name": "risk_flags", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY[]::text[]" + }, + "scheduled_at": { + "name": "scheduled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "matched_rule_id": { + "name": "matched_rule_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "published_reply": { + "name": "published_reply", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active_draft_id": { + "name": "active_draft_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "review_cases_tenant_google_uidx": { + "name": "review_cases_tenant_google_uidx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "google_review_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "review_cases_inbox_idx": { + "name": "review_cases_inbox_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "review_cases_expiry_idx": { + "name": "review_cases_expiry_idx", + "columns": [ + { + "expression": "content_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "review_cases_tenant_id_tenants_id_fk": { + "name": "review_cases_tenant_id_tenants_id_fk", + "tableFrom": "review_cases", + "tableTo": "tenants", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "review_cases_location_id_locations_id_fk": { + "name": "review_cases_location_id_locations_id_fk", + "tableFrom": "review_cases", + "tableTo": "locations", + "columnsFrom": ["location_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.runtime_knowledge_chunks": { + "name": "runtime_knowledge_chunks", + "schema": "", + "columns": { + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_version": { + "name": "source_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "ordinal": { + "name": "ordinal", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(768)", + "primaryKey": false, + "notNull": true + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "runtime_knowledge_chunks_lookup_idx": { + "name": "runtime_knowledge_chunks_lookup_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "runtime_knowledge_chunks_search_idx": { + "name": "runtime_knowledge_chunks_search_idx", + "columns": [ + { + "expression": "to_tsvector('simple',\"content\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "runtime_knowledge_chunks_vector_idx": { + "name": "runtime_knowledge_chunks_vector_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "runtime_knowledge_chunks_tenant_id_source_id_source_version_ordinal_pk": { + "name": "runtime_knowledge_chunks_tenant_id_source_id_source_version_ordinal_pk", + "columns": ["tenant_id", "source_id", "source_version", "ordinal"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.runtime_records": { + "name": "runtime_records", + "schema": "", + "columns": { + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "runtime_records_expiry_idx": { + "name": "runtime_records_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"runtime_records\".\"expires_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "runtime_review_google_idx": { + "name": "runtime_review_google_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "(\"payload\"->'snapshot'->>'googleReviewName')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"runtime_records\".\"kind\"='review'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "runtime_knowledge_search_idx": { + "name": "runtime_knowledge_search_idx", + "columns": [ + { + "expression": "to_tsvector('simple',\"payload\"->>'content')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"runtime_records\".\"kind\"='knowledge'", + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "runtime_records_tenant_id_kind_id_pk": { + "name": "runtime_records_tenant_id_kind_id_pk", + "columns": ["tenant_id", "kind", "id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "runtime_records_version_check": { + "name": "runtime_records_version_check", + "value": "\"runtime_records\".\"version\">0" + }, + "runtime_records_kind_check": { + "name": "runtime_records_kind_check", + "value": "\"runtime_records\".\"kind\" IN ('review','knowledge','rule','audit','google_tokens','device','event','settings','location','counter','oauth','publish')" + } + }, + "isRLSEnabled": false + }, + "public.tenants": { + "name": "tenants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "default_language": { + "name": "default_language", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'it'" + }, + "automation_kill_switch": { + "name": "automation_kill_switch", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "identity_uid": { + "name": "identity_uid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mfa_enrolled": { + "name": "mfa_enrolled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_identity_uid_unique": { + "name": "users_identity_uid_unique", + "nullsNotDistinct": false, + "columns": ["identity_uid"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.knowledge_kind": { + "name": "knowledge_kind", + "schema": "public", + "values": [ + "business_profile", + "service", + "opening_hours", + "contact", + "tone", + "faq", + "policy", + "forbidden_claim", + "document" + ] + }, + "public.knowledge_status": { + "name": "knowledge_status", + "schema": "public", + "values": ["draft", "approved", "retired"] + }, + "public.review_workflow_status": { + "name": "review_workflow_status", + "schema": "public", + "values": [ + "received", + "generating", + "pending_approval", + "scheduled_auto", + "publishing", + "published", + "rejected", + "needs_attention" + ] + }, + "public.member_role": { + "name": "member_role", + "schema": "public", + "values": ["owner", "admin", "editor", "approver"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/database/migrations/meta/_journal.json b/packages/database/migrations/meta/_journal.json index 825d6df..1898a55 100644 --- a/packages/database/migrations/meta/_journal.json +++ b/packages/database/migrations/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1789569000000, "tag": "0003_runtime_records", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1789569872750, + "tag": "0004_runtime_knowledge_chunks", + "breakpoints": true } ] } diff --git a/packages/database/package.json b/packages/database/package.json index 8c064f1..abc3775 100644 --- a/packages/database/package.json +++ b/packages/database/package.json @@ -23,6 +23,8 @@ "pg": "8.23.0" }, "devDependencies": { + "@electric-sql/pglite": "0.5.8", + "@electric-sql/pglite-pgvector": "0.0.9", "@types/pg": "8.16.0", "drizzle-kit": "0.31.10", "typescript": "5.9.3", diff --git a/packages/database/src/client.ts b/packages/database/src/client.ts index 905c717..9479210 100644 --- a/packages/database/src/client.ts +++ b/packages/database/src/client.ts @@ -1,3 +1,4 @@ +import { sql } from "drizzle-orm"; import { drizzle } from "drizzle-orm/node-postgres"; import { Pool, type PoolConfig } from "pg"; import * as schema from "./schema.js"; @@ -23,9 +24,7 @@ export async function withTenant( ) => Promise, ): Promise { return database.transaction(async (transaction) => { - await transaction.execute( - `select set_config('app.tenant_id', '${tenantId.replaceAll("'", "")}', true)`, - ); + await transaction.execute(sql`select set_config('app.tenant_id', ${tenantId}, true)`); return operation(transaction); }); } diff --git a/packages/database/src/index.ts b/packages/database/src/index.ts index 7f284bb..9c9b04a 100644 --- a/packages/database/src/index.ts +++ b/packages/database/src/index.ts @@ -1,4 +1,5 @@ export * from "./client.js"; +export * from "./knowledge-index.js"; export * from "./maintenance.js"; export * from "./records.js"; export * from "./schema.js"; diff --git a/packages/database/src/knowledge-index.ts b/packages/database/src/knowledge-index.ts new file mode 100644 index 0000000..72dd67b --- /dev/null +++ b/packages/database/src/knowledge-index.ts @@ -0,0 +1,10 @@ +export type IndexedKnowledgeChunk = { content: string; embedding: number[]; model: string }; +export function vectorParameter(value: readonly number[]) { + if ( + value.length !== 768 || + value.some((entry) => !Number.isFinite(entry)) || + value.every((entry) => entry === 0) + ) + throw new Error("Embeddings require 768 finite, non-zero dimensions"); + return `[${value.join(",")}]`; +} diff --git a/packages/database/src/maintenance.ts b/packages/database/src/maintenance.ts index 763b75f..0dd3f2e 100644 --- a/packages/database/src/maintenance.ts +++ b/packages/database/src/maintenance.ts @@ -7,13 +7,7 @@ export async function purgeExpiredGoogleContent( now = new Date(), ): Promise { const expired = await db - .update(reviewCases) - .set({ - reviewerDisplayName: "Expired Google user", - comment: "", - existingReply: null, - updatedAt: now, - }) + .delete(reviewCases) .where(lt(reviewCases.contentExpiresAt, now)) .returning({ id: reviewCases.id }); return expired.length; diff --git a/packages/database/src/records.ts b/packages/database/src/records.ts index ef65315..78328cd 100644 --- a/packages/database/src/records.ts +++ b/packages/database/src/records.ts @@ -1,6 +1,15 @@ +import type { KnowledgeExcerpt } from "@reviewguard/contracts"; import { Pool, type PoolClient } from "pg"; +import { type IndexedKnowledgeChunk, vectorParameter } from "./knowledge-index.js"; export type StoredRecord = { id: string; version: number; value: T }; +export type RecordWrite = { + kind: string; + id: string; + value: unknown; + expectedVersion: number | null; + expiresAt?: string; +}; export interface RecordRepository { list(tenantId: string, kind: string): Promise[]>; get(tenantId: string, kind: string, id: string): Promise | null>; @@ -13,19 +22,37 @@ export interface RecordRepository { expiresAt?: string, ): Promise | null>; remove(tenantId: string, kind: string, id: string): Promise; + putMany(tenantId: string, writes: RecordWrite[]): Promise; + removeKind(tenantId: string, kind: string): Promise; + purgeExpired(tenantId: string): Promise; + replaceKnowledgeChunks( + tenantId: string, + sourceId: string, + version: number, + chunks: IndexedKnowledgeChunk[], + ): Promise; + searchKnowledge( + tenantId: string, + locationId: string, + query: string, + embedding: number[], + model: string, + ): Promise; close(): Promise; } /** Versioned aggregates: each transaction resets tenant scope before releasing its connection. */ export class PostgresRecordRepository implements RecordRepository { private readonly pool: Pool; - constructor(connectionString: string) { - this.pool = new Pool({ - connectionString, - max: 10, - connectionTimeoutMillis: 5_000, - idleTimeoutMillis: 30_000, - }); + constructor(connectionString: string, pool?: Pool) { + this.pool = + pool ?? + new Pool({ + connectionString, + max: 10, + connectionTimeoutMillis: 5_000, + idleTimeoutMillis: 30_000, + }); } private async scoped( tenantId: string, @@ -82,11 +109,11 @@ export class PostgresRecordRepository implements RecordRepository { const result = expectedVersion === null ? await client.query( - "INSERT INTO runtime_records(tenant_id,kind,id,payload,expires_at) VALUES($1,$2,$3,$4,$5) ON CONFLICT DO NOTHING RETURNING id,version,payload AS value", + "INSERT INTO runtime_records(tenant_id,kind,id,payload,expires_at) VALUES($1,$2,$3,$4,$5) ON CONFLICT (tenant_id,kind,id) DO UPDATE SET payload=EXCLUDED.payload,version=1,expires_at=EXCLUDED.expires_at,updated_at=now() WHERE runtime_records.expires_at<=now() AND runtime_records.kind<>'audit' RETURNING id,version,payload AS value", [tenantId, kind, id, JSON.stringify(value), expiresAt ?? null], ) : await client.query( - "UPDATE runtime_records SET payload=$4,version=version+1,updated_at=now(),expires_at=COALESCE($6,expires_at) WHERE tenant_id=$1 AND kind=$2 AND id=$3 AND version=$5 RETURNING id,version,payload AS value", + "UPDATE runtime_records SET payload=$4,version=version+1,updated_at=now(),expires_at=COALESCE($6,expires_at) WHERE tenant_id=$1 AND kind=$2 AND id=$3 AND version=$5 AND (expires_at IS NULL OR expires_at>now()) RETURNING id,version,payload AS value", [tenantId, kind, id, JSON.stringify(value), expectedVersion, expiresAt ?? null], ); return result.rows[0] ?? null; @@ -101,9 +128,137 @@ export class PostgresRecordRepository implements RecordRepository { ]); }); } + async putMany(tenantId: string, writes: RecordWrite[]): Promise { + try { + return await this.scoped(tenantId, async (client) => { + for (const write of writes) { + const values = [ + tenantId, + write.kind, + write.id, + JSON.stringify(write.value), + write.expectedVersion, + write.expiresAt ?? null, + ]; + const result = + write.expectedVersion === null + ? await client.query( + "INSERT INTO runtime_records(tenant_id,kind,id,payload,expires_at) VALUES($1,$2,$3,$4,$5) ON CONFLICT (tenant_id,kind,id) DO UPDATE SET payload=EXCLUDED.payload,version=1,expires_at=EXCLUDED.expires_at,updated_at=now() WHERE runtime_records.expires_at<=now() AND runtime_records.kind<>'audit' RETURNING id", + [ + tenantId, + write.kind, + write.id, + JSON.stringify(write.value), + write.expiresAt ?? null, + ], + ) + : await client.query( + "UPDATE runtime_records SET payload=$4,version=version+1,updated_at=now(),expires_at=COALESCE($6,expires_at) WHERE tenant_id=$1 AND kind=$2 AND id=$3 AND version=$5 AND (expires_at IS NULL OR expires_at>now()) RETURNING id", + values, + ); + if (!result.rows.length) throw new BatchConflict(); + } + return true; + }); + } catch (error) { + if (error instanceof BatchConflict) return false; + throw error; + } + } + async removeKind(tenantId: string, kind: string): Promise { + return this.scoped( + tenantId, + async (client) => + ( + await client.query("DELETE FROM runtime_records WHERE tenant_id=$1 AND kind=$2", [ + tenantId, + kind, + ]) + ).rowCount ?? 0, + ); + } + async purgeExpired(tenantId: string): Promise { + return this.scoped( + tenantId, + async (client) => + ( + await client.query( + "DELETE FROM runtime_records WHERE tenant_id=$1 AND expires_at<=now() AND kind<>'audit'", + [tenantId], + ) + ).rowCount ?? 0, + ); + } async close(): Promise { await this.pool.end(); } + async assertSafeRuntimeRole(tenantId: string) { + await this.scoped(tenantId, async (client) => { + const result = await client.query( + "SELECT rolsuper,rolbypassrls,(SELECT count(*)::integer FROM pg_class WHERE relname IN ('runtime_records','runtime_knowledge_chunks') AND relrowsecurity AND relforcerowsecurity) AS protected_tables FROM pg_roles WHERE rolname=current_user", + ); + const role = result.rows[0]; + if (!role || role.rolsuper || role.rolbypassrls || role.protected_tables !== 2) + throw new Error( + "Runtime requires a non-superuser, NOBYPASSRLS role and both forced tenant policies", + ); + }); + } + async replaceKnowledgeChunks( + tenantId: string, + sourceId: string, + version: number, + chunks: IndexedKnowledgeChunk[], + ) { + await this.scoped(tenantId, async (client) => { + await client.query( + "DELETE FROM runtime_knowledge_chunks WHERE tenant_id=$1 AND source_id=$2 AND source_version=$3", + [tenantId, sourceId, version], + ); + for (const [ordinal, chunk] of chunks.entries()) + await client.query( + "INSERT INTO runtime_knowledge_chunks(tenant_id,source_id,source_version,ordinal,content,embedding,embedding_model) VALUES($1,$2,$3,$4,$5,$6::vector,$7)", + [ + tenantId, + sourceId, + version, + ordinal, + chunk.content, + vectorParameter(chunk.embedding), + chunk.model, + ], + ); + }); + } + async searchKnowledge( + tenantId: string, + locationId: string, + query: string, + embedding: number[], + model: string, + ): Promise { + return this.scoped( + tenantId, + async (client) => + ( + await client.query( + ` + SELECT c.source_id AS "sourceId",r.payload->>'title' AS title,c.content,c.source_version AS version, + GREATEST(0,LEAST(1,1-(c.embedding <=> $4::vector)))*0.65 + + LEAST(1,ts_rank_cd(to_tsvector('simple',c.content),plainto_tsquery('simple',$3)))*0.25 + + CASE WHEN r.payload->>'kind' IN ('policy','forbidden_claim') THEN 0.1 ELSE 0 END AS score + FROM runtime_knowledge_chunks c JOIN runtime_records r ON r.tenant_id=c.tenant_id AND r.id=c.source_id::text AND r.kind='knowledge' + WHERE c.tenant_id=$1 AND c.embedding_model=$5 AND r.payload->>'status'='approved' + AND (r.payload->>'version')::integer=c.source_version + AND (r.payload->>'locationId' IS NULL OR r.payload->>'locationId'=$2) + AND (r.payload->>'validFrom' IS NULL OR (r.payload->>'validFrom')::timestamptz<=now()) + AND (r.payload->>'validUntil' IS NULL OR (r.payload->>'validUntil')::timestamptz>now()) + ORDER BY CASE WHEN r.payload->>'kind' IN ('policy','forbidden_claim') THEN 0 ELSE 1 END,score DESC,c.source_id,c.ordinal LIMIT 12`, + [tenantId, locationId, query, vectorParameter(embedding), model], + ) + ).rows, + ); + } } export class MemoryRecordRepository implements RecordRepository { @@ -135,7 +290,9 @@ export class MemoryRecordRepository implements RecordRepository { expiresAt?: string, ): Promise | null> { const key = this.key(tenantId, kind, id); - const current = this.entries.get(key); + const stored = this.entries.get(key); + const current = + stored?.expiresAt && Date.parse(stored.expiresAt) <= Date.now() ? undefined : stored; if (expectedVersion === null ? Boolean(current) : current?.version !== expectedVersion) return null; const result = { @@ -150,5 +307,54 @@ export class MemoryRecordRepository implements RecordRepository { async remove(tenantId: string, kind: string, id: string): Promise { this.entries.delete(this.key(tenantId, kind, id)); } + async putMany(tenantId: string, writes: RecordWrite[]): Promise { + for (const write of writes) { + const stored = this.entries.get(this.key(tenantId, write.kind, write.id)); + const entry = + stored?.expiresAt && Date.parse(stored.expiresAt) <= Date.now() ? undefined : stored; + if ( + write.expectedVersion === null ? Boolean(entry) : entry?.version !== write.expectedVersion + ) + return false; + } + // No await in the commit loop: the in-memory adapter commits all writes in one turn. + for (const write of writes) { + const key = this.key(tenantId, write.kind, write.id); + this.entries.set(key, { + id: write.id, + version: (write.expectedVersion ?? 0) + 1, + value: structuredClone(write.value), + expiresAt: write.expiresAt ?? this.entries.get(key)?.expiresAt, + }); + } + return true; + } + async removeKind(tenantId: string, kind: string): Promise { + let count = 0; + for (const key of this.entries.keys()) + if (key.startsWith(`${tenantId}/${kind}/`)) { + this.entries.delete(key); + count++; + } + return count; + } + async purgeExpired(tenantId: string): Promise { + let count = 0; + for (const [key, entry] of this.entries) + if ( + key.startsWith(`${tenantId}/`) && + entry.expiresAt && + Date.parse(entry.expiresAt) <= Date.now() + ) { + this.entries.delete(key); + count++; + } + return count; + } async close(): Promise {} + async replaceKnowledgeChunks(): Promise {} + async searchKnowledge(): Promise { + throw new Error("Hybrid retrieval requires PostgreSQL; use the explicit demo retriever"); + } } +class BatchConflict extends Error {} diff --git a/packages/database/src/schema.ts b/packages/database/src/schema.ts index 8f73c49..7bee23c 100644 --- a/packages/database/src/schema.ts +++ b/packages/database/src/schema.ts @@ -1,6 +1,7 @@ import { sql } from "drizzle-orm"; import { boolean, + check, index, integer, jsonb, @@ -15,6 +16,63 @@ import { } from "drizzle-orm/pg-core"; export const roleEnum = pgEnum("member_role", ["owner", "admin", "editor", "approver"]); +export const runtimeRecords = pgTable( + "runtime_records", + { + tenantId: uuid("tenant_id").notNull(), + kind: text("kind").notNull(), + id: text("id").notNull(), + version: integer("version").notNull().default(1), + payload: jsonb("payload").notNull(), + expiresAt: timestamp("expires_at", { withTimezone: true }), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + primaryKey({ columns: [table.tenantId, table.kind, table.id] }), + check("runtime_records_version_check", sql`${table.version}>0`), + check( + "runtime_records_kind_check", + sql`${table.kind} IN ('review','knowledge','rule','audit','google_tokens','device','event','settings','location','counter','oauth','publish')`, + ), + index("runtime_records_expiry_idx") + .on(table.expiresAt) + .where(sql`${table.expiresAt} IS NOT NULL`), + uniqueIndex("runtime_review_google_idx") + .on(table.tenantId, sql`(${table.payload}->'snapshot'->>'googleReviewName')`) + .where(sql`${table.kind}='review'`), + index("runtime_knowledge_search_idx") + .using("gin", sql`to_tsvector('simple',${table.payload}->>'content')`) + .where(sql`${table.kind}='knowledge'`), + ], +); +export const runtimeKnowledgeChunks = pgTable( + "runtime_knowledge_chunks", + { + tenantId: uuid("tenant_id").notNull(), + sourceId: uuid("source_id").notNull(), + sourceVersion: integer("source_version").notNull(), + ordinal: integer("ordinal").notNull(), + content: text("content").notNull(), + embedding: vector("embedding", { dimensions: 768 }).notNull(), + embeddingModel: text("embedding_model").notNull(), + }, + (table) => [ + primaryKey({ columns: [table.tenantId, table.sourceId, table.sourceVersion, table.ordinal] }), + index("runtime_knowledge_chunks_lookup_idx").on( + table.tenantId, + table.sourceId, + table.sourceVersion, + ), + index("runtime_knowledge_chunks_search_idx").using( + "gin", + sql`to_tsvector('simple',${table.content})`, + ), + index("runtime_knowledge_chunks_vector_idx").using( + "hnsw", + table.embedding.op("vector_cosine_ops"), + ), + ], +); export const reviewStatusEnum = pgEnum("review_workflow_status", [ "received", "generating", diff --git a/packages/database/test/postgres.test.ts b/packages/database/test/postgres.test.ts new file mode 100644 index 0000000..eb8d0f9 --- /dev/null +++ b/packages/database/test/postgres.test.ts @@ -0,0 +1,184 @@ +import { readFile } from "node:fs/promises"; +import { PGlite } from "@electric-sql/pglite"; +import { vector } from "@electric-sql/pglite-pgvector"; +import type { Pool } from "pg"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { PostgresRecordRepository } from "../src/records.js"; + +describe("Embedded PostgreSQL runtime migration and repository", () => { + let database: PGlite; + let repository: PostgresRecordRepository; + const tenant = "11111111-1111-4111-8111-111111111111"; + beforeAll(async () => { + database = new PGlite({ extensions: { vector } }); + await database.exec("CREATE EXTENSION vector"); + await database.exec( + "CREATE FUNCTION app_tenant_id() RETURNS uuid LANGUAGE sql STABLE AS $$ SELECT NULLIF(current_setting('app.tenant_id',true),'')::uuid $$;", + ); + await database.exec( + await readFile(new URL("../migrations/0003_runtime_records.sql", import.meta.url), "utf8"), + ); + await database.exec( + await readFile( + new URL("../migrations/0004_runtime_knowledge_chunks.sql", import.meta.url), + "utf8", + ), + ); + await database.exec( + "CREATE ROLE runtime_user NOSUPERUSER NOBYPASSRLS; GRANT USAGE ON SCHEMA public TO runtime_user; GRANT SELECT,INSERT,UPDATE,DELETE ON runtime_records,runtime_knowledge_chunks TO runtime_user; SET ROLE runtime_user;", + ); + let queue = Promise.resolve(); + const pool = { + connect: async () => { + const previous = queue; + let unlock = () => {}; + queue = new Promise((resolve) => { + unlock = resolve; + }); + await previous; + return { + query: (sql: string, values?: unknown[]) => database.query(sql, values), + release: unlock, + }; + }, + end: async () => {}, + }; + repository = new PostgresRecordRepository("embedded", pool as unknown as Pool); + }, 30_000); + afterAll(async () => { + await database.close(); + }); + it("persists and compares versions using the production SQL", async () => { + expect( + (await repository.put(tenant, "settings", "one", { name: "original" }, null))?.version, + ).toBe(1); + const results = await Promise.all([ + repository.put(tenant, "settings", "one", { name: "A" }, 1), + repository.put(tenant, "settings", "one", { name: "B" }, 1), + ]); + expect(results.filter(Boolean)).toHaveLength(1); + expect((await repository.get(tenant, "settings", "one"))?.version).toBe(2); + }); + it("resets tenant scope between pooled requests", async () => { + const other = "99999999-9999-4999-8999-999999999999"; + expect(await repository.get(other, "settings", "one")).toBeNull(); + expect(await repository.list(other, "settings")).toEqual([]); + expect((await repository.get(tenant, "settings", "one"))?.version).toBe(2); + }); + it("enforces RLS even if a query omits the tenant predicate", async () => { + await database.exec("BEGIN"); + await database.query("SELECT set_config('app.tenant_id',$1,true)", [ + "99999999-9999-4999-8999-999999999999", + ]); + expect((await database.query("SELECT * FROM runtime_records")).rows).toEqual([]); + await database.exec("ROLLBACK"); + }); + it("keeps audit append-only", async () => { + await repository.put(tenant, "audit", "audit-one", { action: "test" }, null); + await expect(repository.put(tenant, "audit", "audit-one", {}, 1)).rejects.toThrow( + "append-only", + ); + await expect(repository.remove(tenant, "audit", "audit-one")).rejects.toThrow("append-only"); + expect((await repository.get(tenant, "audit", "audit-one"))?.value).toEqual({ action: "test" }); + }); + it("hides expired snapshots", async () => { + await repository.put( + tenant, + "review", + "expired", + { snapshot: { googleReviewName: "accounts/demo/locations/one/reviews/expired" } }, + null, + "2020-01-01T00:00:00.000Z", + ); + expect(await repository.get(tenant, "review", "expired")).toBeNull(); + }); + it("atomically commits the workflow and publication intent, rolling both back on conflict", async () => { + await repository.put(tenant, "review", "atomic", { status: "pending" }, null); + expect( + await repository.putMany(tenant, [ + { kind: "review", id: "atomic", value: { status: "publishing" }, expectedVersion: 1 }, + { kind: "publish", id: "atomic", value: { text: "approved" }, expectedVersion: 99 }, + ]), + ).toBe(false); + expect((await repository.get(tenant, "review", "atomic"))?.value).toEqual({ + status: "pending", + }); + expect( + await repository.putMany(tenant, [ + { kind: "review", id: "atomic", value: { status: "publishing" }, expectedVersion: 1 }, + { kind: "publish", id: "atomic", value: { text: "approved" }, expectedVersion: null }, + ]), + ).toBe(true); + expect((await repository.get(tenant, "publish", "atomic"))?.value).toEqual({ + text: "approved", + }); + }); + it("recreates expired records and physically purges expired content", async () => { + expect( + await repository.put( + tenant, + "review", + "expired", + { snapshot: { googleReviewName: "newly-fetched" } }, + null, + ), + ).not.toBeNull(); + await repository.put( + tenant, + "publish", + "expired-intent", + { text: "expired" }, + null, + "2020-01-01T00:00:00.000Z", + ); + expect(await repository.purgeExpired(tenant)).toBe(1); + expect(await repository.removeKind(tenant, "publish")).toBe(1); + }); + it("runs hybrid pgvector/full-text retrieval and excludes stale, retired and foreign sources", async () => { + const sourceId = "44444444-4444-4444-8444-444444444441"; + const embedding = Array.from({ length: 768 }, (_, index) => (index === 0 ? 1 : 0)); + const source = { + title: "Opening hours", + version: 2, + status: "approved", + kind: "opening_hours", + locationId: null, + validFrom: null, + validUntil: null, + }; + await repository.put(tenant, "knowledge", sourceId, source, null); + await repository.replaceKnowledgeChunks(tenant, sourceId, 2, [ + { content: "Opening hours: Monday to Friday", embedding, model: "test-model" }, + ]); + expect( + (await repository.searchKnowledge(tenant, "one", "opening hours", embedding, "test-model"))[0] + ?.sourceId, + ).toBe(sourceId); + expect( + await repository.searchKnowledge( + "99999999-9999-4999-8999-999999999999", + "one", + "opening hours", + embedding, + "test-model", + ), + ).toEqual([]); + await repository.put(tenant, "knowledge", sourceId, { ...source, version: 3 }, 1); + expect( + await repository.searchKnowledge(tenant, "one", "opening hours", embedding, "test-model"), + ).toEqual([]); + await repository.replaceKnowledgeChunks(tenant, sourceId, 3, [ + { content: "Opening hours", embedding, model: "test-model" }, + ]); + await repository.put( + tenant, + "knowledge", + sourceId, + { ...source, version: 3, status: "retired" }, + 2, + ); + expect( + await repository.searchKnowledge(tenant, "one", "opening hours", embedding, "test-model"), + ).toEqual([]); + }); +}); diff --git a/packages/database/test/records.test.ts b/packages/database/test/records.test.ts index 8be525b..8949f34 100644 --- a/packages/database/test/records.test.ts +++ b/packages/database/test/records.test.ts @@ -32,8 +32,10 @@ describe.skipIf(!process.env.TEST_DATABASE_URL)( "PostgreSQL integration (migrations and non-superuser runtime role required)", () => { it("persists across connections and uses transactional compare-and-swap", async () => { - const one = new PostgresRecordRepository(process.env.TEST_DATABASE_URL!); - const two = new PostgresRecordRepository(process.env.TEST_DATABASE_URL!); + const url = process.env.TEST_DATABASE_URL; + if (!url) throw new Error("TEST_DATABASE_URL is required"); + const one = new PostgresRecordRepository(url); + const two = new PostgresRecordRepository(url); const id = crypto.randomUUID(); try { await one.put(tenantA, "settings", id, { name: "persisted" }, null); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 98bff90..ba5ec3c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,6 +39,9 @@ importers: '@fastify/static': specifier: 10.1.3 version: 10.1.3 + '@google-cloud/kms': + specifier: 6.1.0 + version: 6.1.0(supports-color@8.1.1) '@google-cloud/tasks': specifier: 7.1.0 version: 7.1.0(supports-color@8.1.1) @@ -66,9 +69,18 @@ importers: fastify: specifier: 5.12.4 version: 5.12.4 + google-auth-library: + specifier: 11.0.2 + version: 11.0.2(supports-color@8.1.1) jose: specifier: 6.2.12 version: 6.2.12 + mammoth: + specifier: 1.12.3 + version: 1.12.3 + pdf-parse: + specifier: 2.4.5 + version: 2.4.5 reflect-metadata: specifier: 0.2.2 version: 0.2.2 @@ -100,12 +112,18 @@ importers: '@reviewguard/contracts': specifier: workspace:* version: link:../../packages/contracts + '@reviewguard/core': + specifier: workspace:* + version: link:../../packages/core expo: specifier: 57.0.23 version: 57.0.23(@babel/core@7.29.7(supports-color@8.1.1))(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.15)(expo-router@57.0.21)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3)(supports-color@8.1.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3)(supports-color@8.1.1)(typescript@6.0.3) expo-constants: specifier: ~57.0.0 version: 57.0.18(expo@57.0.23)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.3)(supports-color@8.1.1))(supports-color@8.1.1) + expo-dev-client: + specifier: 57.0.19 + version: 57.0.19(expo@57.0.23)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.3)(supports-color@8.1.1)) expo-device: specifier: ~57.0.0 version: 57.0.2(expo@57.0.23) @@ -167,16 +185,25 @@ importers: '@reviewguard/contracts': specifier: workspace:* version: link:../../packages/contracts + '@reviewguard/core': + specifier: workspace:* + version: link:../../packages/core next: specifier: 16.3.5 - version: 16.3.5(@babel/core@7.29.7(supports-color@8.1.1))(@opentelemetry/api@1.9.1)(@types/node@24.10.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: 16.3.5(@babel/core@7.29.7(supports-color@8.1.1))(@opentelemetry/api@1.9.1)(@playwright/test@1.63.0)(@types/node@24.10.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) react: specifier: 19.2.3 version: 19.2.3 react-dom: specifier: 19.2.3 version: 19.2.3(react@19.2.3) + zod: + specifier: 4.6.5 + version: 4.6.5 devDependencies: + '@playwright/test': + specifier: 1.63.0 + version: 1.63.0 '@types/react': specifier: 19.2.14 version: 19.2.14 @@ -254,11 +281,17 @@ importers: version: link:../contracts drizzle-orm: specifier: 0.45.2 - version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.16.0)(pg@8.23.0) + version: 0.45.2(@electric-sql/pglite@0.5.8)(@opentelemetry/api@1.9.1)(@types/pg@8.16.0)(pg@8.23.0) pg: specifier: 8.23.0 version: 8.23.0 devDependencies: + '@electric-sql/pglite': + specifier: 0.5.8 + version: 0.5.8 + '@electric-sql/pglite-pgvector': + specifier: 0.0.9 + version: 0.0.9(@electric-sql/pglite@0.5.8) '@types/pg': specifier: 8.16.0 version: 8.16.0 @@ -726,6 +759,14 @@ packages: '@drizzle-team/brocli@0.10.2': resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} + '@electric-sql/pglite-pgvector@0.0.9': + resolution: {integrity: sha512-ue4iBW651gDQwBwn97Ekv1lYGPvXa1ymHbRbTCSL0Ib286PRDD1VDOwzwEoekZuO/wctMbTzKzlwdgDwYrqZ8A==} + peerDependencies: + '@electric-sql/pglite': 0.5.8 + + '@electric-sql/pglite@0.5.8': + resolution: {integrity: sha512-n9tsbUOhwx2epK1V0ZG9Ar4SHWUju04dhmzZXiSBXwBoleOvIfals33NAaWgagQVAL4Rbvx/Ptsu3P+pA09f6Q==} + '@emnapi/runtime@1.11.3': resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} @@ -1405,6 +1446,10 @@ packages: '@fastify/static@10.1.3': resolution: {integrity: sha512-W6jqajYS974XjPjB5hQWoxPM8NKM4+p8YmQT6G5IbCa4uhdWSVadZUv75siy1wEA/3ty8RYdpBydfWeu9AqAqQ==} + '@google-cloud/kms@6.1.0': + resolution: {integrity: sha512-Rl3dVdfjElLKG7GgiS71JYN2q8IaOu2Ea3BwxxLNxSA5F9MSC/4g08xuM62Asfj+pEkIVOlNOrR6K9YTM9Wt/A==} + engines: {node: '>=22'} + '@google-cloud/tasks@7.1.0': resolution: {integrity: sha512-BKrY8ULUok0QQzfg/jOl5ZfHJq1nsU+KSFmYSoLmxUZPdkps8YHWWVXXUrRHMX/rBVudJ0ow2UIqHMZlmX9bvg==} engines: {node: '>=22'} @@ -1625,6 +1670,75 @@ packages: '@microsoft/tsdoc@0.16.0': resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==} + '@napi-rs/canvas-android-arm64@0.1.80': + resolution: {integrity: sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@napi-rs/canvas-darwin-arm64@0.1.80': + resolution: {integrity: sha512-O64APRTXRUiAz0P8gErkfEr3lipLJgM6pjATwavZ22ebhjYl/SUbpgM0xcWPQBNMP1n29afAC/Us5PX1vg+JNQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/canvas-darwin-x64@0.1.80': + resolution: {integrity: sha512-FqqSU7qFce0Cp3pwnTjVkKjjOtxMqRe6lmINxpIZYaZNnVI0H5FtsaraZJ36SiTHNjZlUB69/HhxNDT1Aaa9vA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.80': + resolution: {integrity: sha512-eyWz0ddBDQc7/JbAtY4OtZ5SpK8tR4JsCYEZjCE3dI8pqoWUC8oMwYSBGCYfsx2w47cQgQCgMVRVTFiiO38hHQ==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/canvas-linux-arm64-gnu@0.1.80': + resolution: {integrity: sha512-qwA63t8A86bnxhuA/GwOkK3jvb+XTQaTiVML0vAWoHyoZYTjNs7BzoOONDgTnNtr8/yHrq64XXzUoLqDzU+Uuw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@napi-rs/canvas-linux-arm64-musl@0.1.80': + resolution: {integrity: sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@napi-rs/canvas-linux-riscv64-gnu@0.1.80': + resolution: {integrity: sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@napi-rs/canvas-linux-x64-gnu@0.1.80': + resolution: {integrity: sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@napi-rs/canvas-linux-x64-musl@0.1.80': + resolution: {integrity: sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@napi-rs/canvas-win32-x64-msvc@0.1.80': + resolution: {integrity: sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/canvas@0.1.80': + resolution: {integrity: sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww==} + engines: {node: '>= 10'} + '@nestjs/common@12.0.3': resolution: {integrity: sha512-Fosz6lHc9OagZNcF0LTrVHyJS8W7mtuNFBOo9RRccGwyeICBd0hiutjAi9zxnzBcP45RkYXeNB+idA/52jZhBQ==} peerDependencies: @@ -1773,6 +1887,11 @@ packages: '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@playwright/test@1.63.0': + resolution: {integrity: sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==} + engines: {node: '>=20'} + hasBin: true + '@protobufjs/aspromise@1.1.2': resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} @@ -2417,6 +2536,9 @@ packages: arg@5.0.2: resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -2511,6 +2633,9 @@ packages: bignumber.js@9.3.1: resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} + bluebird@3.4.7: + resolution: {integrity: sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==} + bplist-creator@0.1.0: resolution: {integrity: sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg==} @@ -2685,6 +2810,9 @@ packages: resolution: {integrity: sha512-XGpFGbMLHwSt74YLTKho7Ib242qi6O8MSX+sRokV4oz7iKXvQWGYZthjIhjRGMxjzVkAubBO512dKGYcefmX3Q==} engines: {node: '>=6.4.0'} + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + cross-fetch@3.2.0: resolution: {integrity: sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==} @@ -2764,6 +2892,9 @@ packages: dezalgo@1.0.4: resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} + dingbat-to-unicode@1.0.1: + resolution: {integrity: sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==} + dnssd-advertise@1.1.6: resolution: {integrity: sha512-Ndrrf6BMPalkQPd/zubL+4YghH2J9NspapQ09uDXwYbvOPkP0oaqf5CkcwJ0b50kS2O3ul6yVu+jz+RY62Cejg==} @@ -2863,6 +2994,9 @@ packages: sqlite3: optional: true + duck@0.1.12: + resolution: {integrity: sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -2981,6 +3115,28 @@ packages: expo: '*' react-native: '*' + expo-dev-client@57.0.19: + resolution: {integrity: sha512-7giMK6BLL685GECHem2UAQvS473ImZLi5l+Q5tq+XSyv6I9UNVPs0p07mKwgBzq8z7DR3d4nTl702ziFxTjO3Q==} + peerDependencies: + expo: '*' + + expo-dev-launcher@57.0.20: + resolution: {integrity: sha512-F6oAHaDn9j3L/gyMGE+OalriX6PHZWnZLxpN5jc6crTrp8f6hSwGhmsHpAvdOzijfMjyi3R2UkcISFsCIIQYFA==} + peerDependencies: + expo: '*' + react-native: '*' + + expo-dev-menu-interface@57.0.0: + resolution: {integrity: sha512-F47VdzOHYc19FhI/jBgctpO8a5UskTIxG6a1E5t3W5gF8VImuvBQffdXXfLHhsuCl7dS3v3U0R45cleeVXO1Zg==} + peerDependencies: + expo: '*' + + expo-dev-menu@57.0.18: + resolution: {integrity: sha512-H7m9ENtVYz4FpJktQj+z/njIN1t7kOEaKCv8Qgv2SdAlyFTW8s2EnufxPoSlg2zUTanaauJ6wrR6XESHwW7kpw==} + peerDependencies: + expo: '*' + react-native: '*' + expo-device@57.0.2: resolution: {integrity: sha512-1A0/FICR3CgrH43FeiDQP3l5eHDmiMoh4EjXfhfoHHkMgdBQdyD0kwDWskiGZlQ3E0Nd0LHkCFH6vJ1ofm/jow==} peerDependencies: @@ -3006,6 +3162,9 @@ packages: react: '*' react-native: '*' + expo-json-utils@57.0.2: + resolution: {integrity: sha512-9kYZECLKE3pYZQs+kt3+ggY83Lfryog9/UEo3HJKGcHUZeKKtlFBIPFjI4LzREubsvjP7AsvG0FdEX9EquIoAQ==} + expo-keep-awake@57.0.2: resolution: {integrity: sha512-GqgH746wtJImmsbyHmCQoi7cOslKuWQBbJHXr53S35Bpf5UvxCAztvnitUEd7D0QvFN2rvkOhylbZpgZZfBLlQ==} peerDependencies: @@ -3018,6 +3177,11 @@ packages: react: '*' react-native: '*' + expo-manifests@57.0.2: + resolution: {integrity: sha512-pPIIcKR73OShwlWMg8H90WRzKUd2krDdKlKAwuQV1FtUwE7H3hQEFw0ePFQY9yFoGMF/77ryYwNKfI5umMcCog==} + peerDependencies: + expo: '*' + expo-modules-autolinking@57.0.13: resolution: {integrity: sha512-Hj5NjZRlfccazhKab+wK19leRVUr1Y/PUERaRvegIbUSkq4GVxKq6lU9QHlKQyC16MdD/VS4iwsiLivB4o7lvQ==} hasBin: true @@ -3100,6 +3264,11 @@ packages: react: '*' react-native: '*' + expo-updates-interface@57.0.2: + resolution: {integrity: sha512-x3SA5toiHZEkW+5oeNdUM1Rvt6M3ExegQsSwS3Z1+S9gn9utGYTGBkb8MBQivhjIZiRlQYDIhauxA8kySQEWtQ==} + peerDependencies: + expo: '*' + expo@57.0.23: resolution: {integrity: sha512-/aBLZFTD7+BNHWV/q5XbNRFqMhW/CrHsYeq8CcS/iGz6lJHYwEMm4XFcdoaeliuMdo6ZjNrsTHkBIdGB8VMHTg==} hasBin: true @@ -3364,6 +3533,9 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} + immediate@3.0.6: + resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -3401,6 +3573,9 @@ packages: resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} engines: {node: '>=8'} + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -3466,6 +3641,9 @@ packages: engines: {node: '>=6'} hasBin: true + jszip@3.10.2: + resolution: {integrity: sha512-3l+rb15IOWtUhU0H5MFqES/T6Kh7abYwjosBey/vD6hDt8zoEffkSC5Ws5SGtgVw3gBx2NEbhTeSW1+kWkpyTQ==} + jwa@2.0.1: resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} @@ -3484,6 +3662,9 @@ packages: resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} engines: {node: '>=6'} + lie@3.3.0: + resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + light-my-request@6.6.0: resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} @@ -3588,6 +3769,9 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + lop@0.4.2: + resolution: {integrity: sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==} + lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} @@ -3607,6 +3791,11 @@ packages: makeerror@1.0.12: resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} + mammoth@1.12.3: + resolution: {integrity: sha512-kkv2MrSFk3f/w3uLsz4FG/91LdWp2j+qmp7AjG2v7w2xgX5YDxiaFlaWourXrXtyUR6335+9guyIlPBnhHLvKw==} + engines: {node: '>=12.0.0'} + hasBin: true + marky@1.3.0: resolution: {integrity: sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==} @@ -3929,10 +4118,16 @@ packages: resolution: {integrity: sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==} engines: {node: '>=8'} + option@0.2.4: + resolution: {integrity: sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==} + ora@3.4.0: resolution: {integrity: sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg==} engines: {node: '>=6'} + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + parse-png@2.1.0: resolution: {integrity: sha512-Nt/a5SfCLiTnQAjx3fHlqp8hRgTL3z7kTQZzvIMS9uCAepnCyjpdEc6M/sz69WqMBdaDBw9sF1F1UaHROYzGkQ==} engines: {node: '>=10'} @@ -3941,6 +4136,10 @@ packages: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -3955,6 +4154,15 @@ packages: path-to-regexp@8.4.2: resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + pdf-parse@2.4.5: + resolution: {integrity: sha512-mHU89HGh7v+4u2ubfnevJ03lmPgQ5WU4CxAVmTSh/sxVTEDYd1er/dKS/A6vg77NX47KTEoihq8jZBLr8Cxuwg==} + engines: {node: '>=20.16.0 <21 || >=22.3.0'} + hasBin: true + + pdfjs-dist@5.4.296: + resolution: {integrity: sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==} + engines: {node: '>=20.16.0 || >=22.3.0'} + pg-cloudflare@1.4.0: resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==} @@ -4010,6 +4218,16 @@ packages: resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} hasBin: true + playwright-core@1.63.0: + resolution: {integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.63.0: + resolution: {integrity: sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==} + engines: {node: '>=20'} + hasBin: true + plist@3.1.1: resolution: {integrity: sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA==} engines: {node: '>=10.4.0'} @@ -4053,6 +4271,9 @@ packages: resolution: {integrity: sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + process-warning@4.0.1: resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==} @@ -4224,6 +4445,9 @@ packages: resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==} engines: {node: '>=0.10.0'} + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + readable-stream@3.6.2: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} @@ -4309,6 +4533,9 @@ packages: rxjs@7.8.2: resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} @@ -4457,6 +4684,9 @@ packages: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -4499,6 +4729,9 @@ packages: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} @@ -4674,6 +4907,9 @@ packages: resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} engines: {node: '>=18'} + underscore@1.13.8: + resolution: {integrity: sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==} + undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} @@ -4914,6 +5150,10 @@ packages: resolution: {integrity: sha512-eLTh0kA8uHceqesPqSE+VvO1CDDJWMwlQfB6LuN6T8w6MaDJ8Txm8P7s5cHD0miF0V+GGTZrDQfxPZQVsur33w==} engines: {node: '>=4.0.0'} + xmlbuilder@10.1.1: + resolution: {integrity: sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==} + engines: {node: '>=4.0'} + xmlbuilder@11.0.1: resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} engines: {node: '>=4.0'} @@ -5479,6 +5719,12 @@ snapshots: '@drizzle-team/brocli@0.10.2': {} + '@electric-sql/pglite-pgvector@0.0.9(@electric-sql/pglite@0.5.8)': + dependencies: + '@electric-sql/pglite': 0.5.8 + + '@electric-sql/pglite@0.5.8': {} + '@emnapi/runtime@1.11.3': dependencies: tslib: 2.8.1 @@ -6159,6 +6405,12 @@ snapshots: fastq: 1.20.3 glob: 13.0.6 + '@google-cloud/kms@6.1.0(supports-color@8.1.1)': + dependencies: + google-gax: 6.3.0(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + '@google-cloud/tasks@7.1.0(supports-color@8.1.1)': dependencies: google-gax: 6.3.0(supports-color@8.1.1) @@ -6331,6 +6583,49 @@ snapshots: '@microsoft/tsdoc@0.16.0': {} + '@napi-rs/canvas-android-arm64@0.1.80': + optional: true + + '@napi-rs/canvas-darwin-arm64@0.1.80': + optional: true + + '@napi-rs/canvas-darwin-x64@0.1.80': + optional: true + + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.80': + optional: true + + '@napi-rs/canvas-linux-arm64-gnu@0.1.80': + optional: true + + '@napi-rs/canvas-linux-arm64-musl@0.1.80': + optional: true + + '@napi-rs/canvas-linux-riscv64-gnu@0.1.80': + optional: true + + '@napi-rs/canvas-linux-x64-gnu@0.1.80': + optional: true + + '@napi-rs/canvas-linux-x64-musl@0.1.80': + optional: true + + '@napi-rs/canvas-win32-x64-msvc@0.1.80': + optional: true + + '@napi-rs/canvas@0.1.80': + optionalDependencies: + '@napi-rs/canvas-android-arm64': 0.1.80 + '@napi-rs/canvas-darwin-arm64': 0.1.80 + '@napi-rs/canvas-darwin-x64': 0.1.80 + '@napi-rs/canvas-linux-arm-gnueabihf': 0.1.80 + '@napi-rs/canvas-linux-arm64-gnu': 0.1.80 + '@napi-rs/canvas-linux-arm64-musl': 0.1.80 + '@napi-rs/canvas-linux-riscv64-gnu': 0.1.80 + '@napi-rs/canvas-linux-x64-gnu': 0.1.80 + '@napi-rs/canvas-linux-x64-musl': 0.1.80 + '@napi-rs/canvas-win32-x64-msvc': 0.1.80 + '@nestjs/common@12.0.3(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1)': dependencies: '@standard-schema/spec': 1.1.0 @@ -6429,6 +6724,10 @@ snapshots: '@pinojs/redact@0.4.0': {} + '@playwright/test@1.63.0': + dependencies: + playwright: 1.63.0 + '@protobufjs/aspromise@1.1.2': {} '@protobufjs/base64@1.1.2': {} @@ -7028,6 +7327,10 @@ snapshots: arg@5.0.2: {} + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + argparse@2.0.1: {} aria-hidden@1.2.6: @@ -7161,6 +7464,8 @@ snapshots: bignumber.js@9.3.1: {} + bluebird@3.4.7: {} + bplist-creator@0.1.0: dependencies: stream-buffers: 2.2.0 @@ -7341,6 +7646,8 @@ snapshots: dependencies: browserslist: 4.28.9 + core-util-is@1.0.3: {} + cross-fetch@3.2.0: dependencies: node-fetch: 2.7.0 @@ -7404,6 +7711,8 @@ snapshots: asap: 2.0.6 wrappy: 1.0.2 + dingbat-to-unicode@1.0.1: {} + dnssd-advertise@1.1.6: {} drizzle-kit@0.31.10: @@ -7413,12 +7722,17 @@ snapshots: esbuild: 0.25.12 tsx: 4.23.13 - drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.16.0)(pg@8.23.0): + drizzle-orm@0.45.2(@electric-sql/pglite@0.5.8)(@opentelemetry/api@1.9.1)(@types/pg@8.16.0)(pg@8.23.0): optionalDependencies: + '@electric-sql/pglite': 0.5.8 '@opentelemetry/api': 1.9.1 '@types/pg': 8.16.0 pg: 8.23.0 + duck@0.1.12: + dependencies: + underscore: 1.13.8 + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -7597,6 +7911,35 @@ snapshots: transitivePeerDependencies: - supports-color + expo-dev-client@57.0.19(expo@57.0.23)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.3)(supports-color@8.1.1)): + dependencies: + expo: 57.0.23(@babel/core@7.29.7(supports-color@8.1.1))(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.15)(expo-router@57.0.21)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3)(supports-color@8.1.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3)(supports-color@8.1.1)(typescript@6.0.3) + expo-dev-launcher: 57.0.20(expo@57.0.23)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.3)(supports-color@8.1.1)) + expo-dev-menu: 57.0.18(expo@57.0.23)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.3)(supports-color@8.1.1)) + expo-dev-menu-interface: 57.0.0(expo@57.0.23) + expo-manifests: 57.0.2(expo@57.0.23) + expo-updates-interface: 57.0.2(expo@57.0.23) + transitivePeerDependencies: + - react-native + + expo-dev-launcher@57.0.20(expo@57.0.23)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.3)(supports-color@8.1.1)): + dependencies: + '@expo/schema-utils': 57.0.2 + expo: 57.0.23(@babel/core@7.29.7(supports-color@8.1.1))(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.15)(expo-router@57.0.21)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3)(supports-color@8.1.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3)(supports-color@8.1.1)(typescript@6.0.3) + expo-dev-menu: 57.0.18(expo@57.0.23)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.3)(supports-color@8.1.1)) + expo-manifests: 57.0.2(expo@57.0.23) + react-native: 0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.3)(supports-color@8.1.1) + + expo-dev-menu-interface@57.0.0(expo@57.0.23): + dependencies: + expo: 57.0.23(@babel/core@7.29.7(supports-color@8.1.1))(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.15)(expo-router@57.0.21)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3)(supports-color@8.1.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3)(supports-color@8.1.1)(typescript@6.0.3) + + expo-dev-menu@57.0.18(expo@57.0.23)(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.3)(supports-color@8.1.1)): + dependencies: + expo: 57.0.23(@babel/core@7.29.7(supports-color@8.1.1))(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.15)(expo-router@57.0.21)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3)(supports-color@8.1.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3)(supports-color@8.1.1)(typescript@6.0.3) + expo-dev-menu-interface: 57.0.0(expo@57.0.23) + react-native: 0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.3)(supports-color@8.1.1) + expo-device@57.0.2(expo@57.0.23): dependencies: expo: 57.0.23(@babel/core@7.29.7(supports-color@8.1.1))(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.15)(expo-router@57.0.21)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3)(supports-color@8.1.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3)(supports-color@8.1.1)(typescript@6.0.3) @@ -7620,6 +7963,8 @@ snapshots: react: 19.2.3 react-native: 0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.3)(supports-color@8.1.1) + expo-json-utils@57.0.2: {} + expo-keep-awake@57.0.2(expo@57.0.23)(react@19.2.3): dependencies: expo: 57.0.23(@babel/core@7.29.7(supports-color@8.1.1))(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.15)(expo-router@57.0.21)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3)(supports-color@8.1.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3)(supports-color@8.1.1)(typescript@6.0.3) @@ -7635,6 +7980,11 @@ snapshots: - expo - supports-color + expo-manifests@57.0.2(expo@57.0.23): + dependencies: + expo: 57.0.23(@babel/core@7.29.7(supports-color@8.1.1))(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.15)(expo-router@57.0.21)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3)(supports-color@8.1.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3)(supports-color@8.1.1)(typescript@6.0.3) + expo-json-utils: 57.0.2 + expo-modules-autolinking@57.0.13(supports-color@8.1.1)(typescript@6.0.3): dependencies: '@expo/require-utils': 57.0.5(supports-color@8.1.1)(typescript@6.0.3) @@ -7742,6 +8092,10 @@ snapshots: react-native: 0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.3)(supports-color@8.1.1) sf-symbols-typescript: 2.2.0 + expo-updates-interface@57.0.2(expo@57.0.23): + dependencies: + expo: 57.0.23(@babel/core@7.29.7(supports-color@8.1.1))(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.15)(expo-router@57.0.21)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3)(supports-color@8.1.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3)(supports-color@8.1.1)(typescript@6.0.3) + expo@57.0.23(@babel/core@7.29.7(supports-color@8.1.1))(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.15)(expo-router@57.0.21)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3)(supports-color@8.1.1))(react-native@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.86.3(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.14)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3)(supports-color@8.1.1)(typescript@6.0.3): dependencies: '@babel/runtime': 7.29.7 @@ -8079,6 +8433,8 @@ snapshots: ignore@5.3.2: {} + immediate@3.0.6: {} + inherits@2.0.4: {} inline-style-prefixer@7.0.1: @@ -8107,6 +8463,8 @@ snapshots: dependencies: is-docker: 2.2.1 + isarray@1.0.0: {} + isexe@2.0.0: {} iterare@1.2.1: {} @@ -8170,6 +8528,13 @@ snapshots: json5@2.2.3: {} + jszip@3.10.2: + dependencies: + lie: 3.3.0 + pako: 1.0.11 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + jwa@2.0.1: dependencies: buffer-equal-constant-time: 1.0.1 @@ -8187,6 +8552,10 @@ snapshots: leven@3.1.0: {} + lie@3.3.0: + dependencies: + immediate: 3.0.6 + light-my-request@6.6.0: dependencies: cookie: 1.1.1 @@ -8267,6 +8636,12 @@ snapshots: dependencies: js-tokens: 4.0.0 + lop@0.4.2: + dependencies: + duck: 0.1.12 + option: 0.2.4 + underscore: 1.13.8 + lru-cache@10.4.3: {} lru-cache@11.5.2: {} @@ -8289,6 +8664,19 @@ snapshots: dependencies: tmpl: 1.0.5 + mammoth@1.12.3: + dependencies: + '@xmldom/xmldom': 0.8.15 + argparse: 1.0.10 + base64-js: 1.5.1 + bluebird: 3.4.7 + dingbat-to-unicode: 1.0.1 + jszip: 3.10.2 + lop: 0.4.2 + path-is-absolute: 1.0.1 + underscore: 1.13.8 + xmlbuilder: 10.1.1 + marky@1.3.0: {} math-intrinsics@1.1.0: {} @@ -8696,7 +9084,7 @@ snapshots: dependencies: content-type: 2.1.0 - next@16.3.5(@babel/core@7.29.7(supports-color@8.1.1))(@opentelemetry/api@1.9.1)(@types/node@24.10.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + next@16.3.5(@babel/core@7.29.7(supports-color@8.1.1))(@opentelemetry/api@1.9.1)(@playwright/test@1.63.0)(@types/node@24.10.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: '@next/env': 16.3.5 '@swc/helpers': 0.5.23 @@ -8716,6 +9104,7 @@ snapshots: '@next/swc-win32-arm64-msvc': 16.3.5 '@next/swc-win32-x64-msvc': 16.3.5 '@opentelemetry/api': 1.9.1 + '@playwright/test': 1.63.0 babel-plugin-react-compiler: 1.0.0 sharp: 0.35.4(@types/node@24.10.1) transitivePeerDependencies: @@ -8791,6 +9180,8 @@ snapshots: is-docker: 2.2.1 is-wsl: 2.2.0 + option@0.2.4: {} + ora@3.4.0: dependencies: chalk: 2.4.2 @@ -8800,12 +9191,16 @@ snapshots: strip-ansi: 5.2.0 wcwidth: 1.0.1 + pako@1.0.11: {} + parse-png@2.1.0: dependencies: pngjs: 3.4.0 parseurl@1.3.3: {} + path-is-absolute@1.0.1: {} + path-key@3.1.1: {} path-parse@1.0.7: {} @@ -8817,6 +9212,15 @@ snapshots: path-to-regexp@8.4.2: {} + pdf-parse@2.4.5: + dependencies: + '@napi-rs/canvas': 0.1.80 + pdfjs-dist: 5.4.296 + + pdfjs-dist@5.4.296: + optionalDependencies: + '@napi-rs/canvas': 0.1.80 + pg-cloudflare@1.4.0: optional: true @@ -8878,6 +9282,12 @@ snapshots: sonic-boom: 4.2.1 thread-stream: 4.2.0 + playwright-core@1.63.0: {} + + playwright@1.63.0: + dependencies: + playwright-core: 1.63.0 + plist@3.1.1: dependencies: '@xmldom/xmldom': 0.9.12 @@ -8918,6 +9328,8 @@ snapshots: proc-log@4.2.0: {} + process-nextick-args@2.0.1: {} + process-warning@4.0.1: {} process-warning@5.1.0: {} @@ -9147,6 +9559,16 @@ snapshots: react@19.2.3: {} + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + readable-stream@3.6.2: dependencies: inherits: 2.0.4 @@ -9242,6 +9664,8 @@ snapshots: dependencies: tslib: 2.8.1 + safe-buffer@5.1.2: {} + safe-buffer@5.2.1: {} safe-regex2@5.1.1: @@ -9410,6 +9834,8 @@ snapshots: split2@4.2.0: {} + sprintf-js@1.0.3: {} + stackback@0.0.2: {} stackframe@1.3.4: {} @@ -9442,6 +9868,10 @@ snapshots: is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + string_decoder@1.3.0: dependencies: safe-buffer: 5.2.1 @@ -9609,6 +10039,8 @@ snapshots: uint8array-extras@1.5.0: {} + underscore@1.13.8: {} + undici-types@7.16.0: {} unicode-canonical-property-names-ecmascript@2.0.1: {} @@ -9762,6 +10194,8 @@ snapshots: sax: 1.6.1 xmlbuilder: 11.0.1 + xmlbuilder@10.1.1: {} + xmlbuilder@11.0.1: {} xmlbuilder@15.1.1: {} From f79af42e5e6b80130ba4603cf2a71352a48e2d68 Mon Sep 17 00:00:00 2001 From: Esdragones Date: Wed, 16 Sep 2026 16:50:08 +0200 Subject: [PATCH 04/14] feat(web): ship authenticated review, knowledge and operations workflows --- apps/web/AGENTS.md | 9 + apps/web/CLAUDE.md | 1 + apps/web/app/api/backend/[...path]/route.ts | 54 +++ apps/web/app/api/session/route.ts | 104 ++++++ apps/web/app/audit/page.tsx | 4 + apps/web/app/globals.css | 61 ++++ apps/web/app/inbox/[id]/page.tsx | 8 +- apps/web/app/inbox/page.tsx | 15 + apps/web/app/knowledge/page.tsx | 60 +--- apps/web/app/layout.tsx | 4 +- apps/web/app/login/page.tsx | 135 ++++++++ apps/web/app/mfa/page.tsx | 94 ++++++ apps/web/app/page.tsx | 129 +------- apps/web/app/rules/page.tsx | 66 +--- apps/web/app/settings/page.tsx | 72 +--- apps/web/components/app-shell.tsx | 42 ++- apps/web/components/audit-log.tsx | 39 +++ apps/web/components/auth-gate.tsx | 91 +++++ apps/web/components/dashboard.tsx | 80 +++++ apps/web/components/inbox.tsx | 105 +++--- apps/web/components/knowledge-manager.tsx | 331 +++++++++++++++++++ apps/web/components/location-preferences.tsx | 11 + apps/web/components/resource-state.tsx | 21 ++ apps/web/components/review-detail.tsx | 18 + apps/web/components/review-workbench.tsx | 149 ++++++++- apps/web/components/rules-manager.tsx | 313 ++++++++++++++++++ apps/web/components/settings-manager.tsx | 318 ++++++++++++++++++ apps/web/e2e/pilot.spec.ts | 86 +++++ apps/web/lib/api.ts | 5 +- apps/web/lib/session.ts | 62 ++++ apps/web/lib/use-resource.ts | 23 ++ apps/web/lib/workspace.ts | 24 ++ apps/web/next-env.d.ts | 4 +- apps/web/package.json | 6 +- apps/web/playwright.config.ts | 43 +++ apps/web/vitest.config.ts | 2 + 36 files changed, 2192 insertions(+), 397 deletions(-) create mode 100644 apps/web/AGENTS.md create mode 100644 apps/web/CLAUDE.md create mode 100644 apps/web/app/api/backend/[...path]/route.ts create mode 100644 apps/web/app/api/session/route.ts create mode 100644 apps/web/app/audit/page.tsx create mode 100644 apps/web/app/inbox/page.tsx create mode 100644 apps/web/app/login/page.tsx create mode 100644 apps/web/app/mfa/page.tsx create mode 100644 apps/web/components/audit-log.tsx create mode 100644 apps/web/components/auth-gate.tsx create mode 100644 apps/web/components/dashboard.tsx create mode 100644 apps/web/components/knowledge-manager.tsx create mode 100644 apps/web/components/location-preferences.tsx create mode 100644 apps/web/components/resource-state.tsx create mode 100644 apps/web/components/review-detail.tsx create mode 100644 apps/web/components/rules-manager.tsx create mode 100644 apps/web/components/settings-manager.tsx create mode 100644 apps/web/e2e/pilot.spec.ts create mode 100644 apps/web/lib/session.ts create mode 100644 apps/web/lib/use-resource.ts create mode 100644 apps/web/lib/workspace.ts create mode 100644 apps/web/playwright.config.ts create mode 100644 apps/web/vitest.config.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..25bca63 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -1,6 +1,6 @@ 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 = { @@ -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..5d8c04d 100644 --- a/apps/web/components/app-shell.tsx +++ b/apps/web/components/app-shell.tsx @@ -1,16 +1,23 @@ +"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..98607cd 100644 --- a/apps/web/components/inbox.tsx +++ b/apps/web/components/inbox.tsx @@ -2,24 +2,18 @@ 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 { data, error, loading, refresh } = useResource<{ data: ReviewCase[] }>( + `/reviews?limit=100${status ? `&status=${status}` : ""}`, + ); + const reviews = data?.data ?? []; return (
@@ -29,46 +23,65 @@ 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 - + 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..97e6d29 --- /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"}

+
+ +
+ + + +
+