UPDATE - #503
Open
hernandezservicios wants to merge 94 commits into
Open
Conversation
# Conflicts: # next.config.ts
Adds OpenRouter alongside OpenAI and Anthropic in Settings -> AI Agents. It is a gateway rather than a first-party lab: one sk-or-v1-... key reaches its whole catalogue, and the existing free-text Model field selects which one by id (vendor/model-id). OpenRouter speaks OpenAI's Chat Completions wire format, so the request builder is extracted to providers/openai-compatible.ts and both adapters are now thin configuration over it. One behaviour is specific to the gateway: it reports upstream model failures as a 200 carrying an `error` body, which without special-casing would surface as our "empty response" error instead of the provider's own message. The provider list moves to a single AI_PROVIDERS const plus an isAiProvider guard, so the two API routes, the settings form and the DB CHECK constraint can no longer drift apart as providers are added. The Model field stays free text — a model works the day it ships — but now offers the live catalogue as type-to-search suggestions via a new admin-gated /api/ai/openrouter/models route (1h in-process cache, degrades to plain free text on any upstream failure). Embeddings still go to OpenAI directly, since no other provider serves that endpoint; on OpenRouter the optional embeddings key must therefore be a separate OpenAI key. Noted in the docs and the form hint. Migration 037 widens the provider CHECK on both ai_configs (029) and ai_usage_log (033). The second matters: without it every OpenRouter reply would fail its best-effort usage insert and token spend would silently stop being recorded. Verified by applying 037 to a throwaway Postgres 17 instance seeded with the pre-migration schema: openrouter is accepted, openai/anthropic still are, invalid values are still rejected, existing rows survive, and the migration is idempotent. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds the Supabase CLI as a devDependency (the supported install path on Windows — npm global is not) plus supabase/config.toml from `init`, so migrations can be applied with the CLI instead of pasting SQL into the dashboard. New scripts: db:status, db:push, db:push:dry. The CLI accepts this repo's NNN_name.sql migration names and does not require its own timestamp convention — confirmed with a real `db push --dry-run`, not assumed. Note on the lockfile: regenerating it drops the nested optional peer entry next-intl/node_modules/@swc/helpers. That is npm re-resolving an optional peer, not a side effect of adding the CLI — reproduced from a clean checkout with this change absent. next-intl stays at 4.13.2 and the root @swc/helpers at 0.5.15. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The previous commit regenerated package-lock.json with npm 11.13.0
(local, Node 26). npm 11 omits the nested optional peer entry
next-intl/node_modules/@swc/helpers@0.5.23, but the Docker build runs
node:22-alpine with npm 10.9.8, which still resolves that dependency and
so rejects the lock:
npm error `npm ci` can only install packages when your package.json
and package-lock.json ... are in sync.
npm error Missing: @swc/helpers@0.5.23 from lock file
The earlier note claiming this was ambient npm behaviour and therefore
harmless was wrong. It is ambient, but it is not harmless: npm 11 and
npm 10 disagree about this lock, and the build uses npm 10.
Regenerated inside node:22-alpine (the exact image and digest the build
uses) so the lock matches the consumer. The diff against the last
known-good lock is now purely additive — the ten supabase CLI entries,
zero removals — and `npm ci` was verified to succeed in that image
(705 packages, exit 0).
Note for future dependency changes: this lockfile is npm 10-shaped.
Running `npm install` with npm 11+ locally will silently reshape it and
break the Docker build again. Either run installs through
`docker run --rm -v "$PWD":/w -w /w node:22-alpine npm install`, or bump
the Dockerfile's NODE_VERSION so build and local npm agree.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Open Router desde Andy a ArnasDon
… Vercel (Linux) doesn't try to install Windows binaries
…ail renders 'Language' instead of the raw key
Audits WACRM's current architecture (AI agent pipeline, secret storage, WhatsApp media send, settings/integrations UI, tenant isolation, API client patterns) against the Budun ERP Inventory API integration kit added under docs/integrations/budun-erp/, and records findings + the proposed implementation plan in WACRM_IMPLEMENTATION_ANALYSIS.md. Audit-only: no models, migrations, endpoints, tools, UI, secrets, or agent/WhatsApp behavior were added or changed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…plementation plan
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…urce refreshes Punto 5 audit findings H-4 and H-5: - H-4: inventory-parser.ts's parsePrice() rounded every price to a whole number before it reached ai_catalog_products.price (numeric), silently turning e.g. 19.99 into 20. Decimals are now preserved exactly; whole-number prices and Budun pricing are unaffected. - H-5: two concurrent refreshes of the same data source could race their insert-then-delete-previous sequence and leave duplicate or missing catalog rows. refreshDataSource() now atomically claims the source (ai_data_sources.refresh_started_at) via a single UPDATE...WHERE...RETURNING before doing any work, rejecting a second concurrent refresh of the same source with HTTP 409 instead of racing it. A stale (crashed/timed-out) claim self-expires after 5 minutes. Refreshes of different sources never block each other. Adds UNIQUE(data_source_id, source_product_id) on ai_catalog_products as defense in depth. Migration: 059_data_source_refresh_lock.sql. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Punto 6 audit finding H-6: Two inbound messages from the same contact, delivered as two separate webhook calls processed concurrently (each in its own after() invocation on serverless — no shared lock between them), could each independently call dispatchInboundToAiReply() for the same conversation. Message insertion was already race-free (insert_inbound_customer_message, migration 053), but nothing downstream of it was: two dispatches could build context, call the model, and send a reply at the same time, risking duplicate replies or a message never getting a dedicated answer. Fix: an atomic claim on conversations.ai_processing_started_at (single UPDATE...WHERE...RETURNING, same shape as this engagement's H-5 fix) acquired before building context. A second, concurrent dispatch for the same conversation fails the claim and returns without generating a competing reply. A drain loop (coalescing, no in-memory queue, no setTimeout) lets the SAME dispatch that holds the claim notice and answer a message that arrived while it was busy, via a new atomic RPC (release_or_continue_ai_processing) — this is what guarantees the 'losing' dispatch never causes a message to go unanswered. A stale claim (crashed/killed instance) self-expires after 10 minutes. H-7: added src/lib/ai/auto-reply-concurrency.test.ts (9 deterministic tests, same discipline as first-inbound-race.test.ts) covering same-conversation races, cross-conversation independence, provider failure release, stale-claim recovery, handoff, and — the critical case — a message arriving mid-generation being drained by the same dispatch rather than lost or double-answered. Migration: 060_ai_processing_claim.sql. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Punto 9, H9-1 — closes the cross-tenant path from account_business_contacts.linked_user_id / ai_configs.handoff_agent_id into conversations.assigned_agent_id and, via the on_conversation_assigned trigger, into notifications. - New composite FKs (migration 062): conversations(account_id, assigned_agent_id) and account_business_contacts(account_id, linked_user_id) both reference profiles(account_id, user_id), backed by a new UNIQUE(account_id, user_id) on profiles (implied by the existing UNIQUE(user_id), so it cannot fail against existing data). Guarded by pre-check DO blocks that RAISE EXCEPTION with a diagnostic (never a silent delete/reassignment) if historical cross-tenant data would violate either FK. - notifications_select/notifications_update now additionally require is_account_member(account_id), on top of the existing auth.uid() = user_id check. - New business-profile/service.ts::isAccountMember() helper, reused by the contacts create/update routes, ai_configs' handoff_agent_id save (minimal refactor of its pre-existing inline check), and handOffToHuman()'s own last-line-of-defense re-check before it ever writes assigned_agent_id. - Tests: unit coverage for isAccountMember and both contacts routes (new test files), the config route's refactored check, handoff's fallback chain (invalid contact -> valid config agent -> unassigned), and real RLS tests for both new FKs plus a dedicated notifications.rls.test.ts. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The RLS suite's local-only privilege bootstrap (tests/rls/ bootstrap-privileges.sql) never covered public.notifications, so service_role's fixture-style inserts in the new tests/rls/notifications.rls.test.ts (Punto 9, H9-1) failed on a freshly-provisioned local Supabase stack, which does not replicate the hosted platform's automatic baseline table privileges the way the other 12 tables in this file already account for. Grants SELECT/INSERT/DELETE to both service_role and authenticated, matching the existing pattern. UPDATE is granted to service_role only, deliberately NOT to authenticated: migration 027 already narrows authenticated's UPDATE on this table to the read_at column alone (REVOKE UPDATE ... GRANT UPDATE (read_at) ...), applied during the migration replay that runs before this bootstrap step — a blanket table-level UPDATE grant here would silently re-widen that restriction for the rest of the local CI run. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
supabase db query --local --file sends the whole file as ONE prepared statement (documented in supabase/ci/verify-schema.sql's own header, after an earlier incident with the identical mechanism) — a second top-level statement fails with 'cannot insert multiple commands into a prepared statement'. The previous commit (21e1044) added the notifications GRANTs as two additional top-level statements, breaking that invariant and failing the bootstrap step itself before the RLS suite ever ran. Consolidates all three GRANTs (the original 12-table baseline plus the two notifications-specific ones) into a single DO $$ ... $$ block, issuing each GRANT via EXECUTE (PL/pgSQL cannot call GRANT directly). Privileges are unchanged from the previous commit's intent: service_role gets SELECT/INSERT/UPDATE/DELETE on notifications; authenticated gets SELECT/INSERT/DELETE only, deliberately never a blanket UPDATE, preserving migration 027's column-level restriction (GRANT UPDATE (read_at) ON notifications TO authenticated) intact. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Punto 10, F-P10-1 + F-P10-2.
F-P10-1: every fetch() to Meta's Graph API in meta-api.ts now goes
through a new internal fetchWithTimeout() (AbortController-based) —
10s default, 20s for the two calls that move actual file bytes
(downloadMedia, uploadResumableMedia's byte-upload step). A hung Meta
response previously had no bound short of the webhook route's own
maxDuration=60 killing the whole function mid-flight — before
insert_inbound_customer_message ever ran for a message with an
attachment, since media resolution happens first. A timeout now
surfaces as a plain Error, handled by the exact same try/catch every
other media failure already falls back through (proxy URL, message
still persists). mirror-inbound-media.ts needed no changes — it has no
fetch() of its own, it delegates to downloadMedia.
F-P10-2: audited every real caller of send-message.ts,
automations/meta-send.ts, and flows/meta-send.ts before assuming any
of them represent a human taking over a conversation. Only one path
actually does: the dashboard's manual /api/whatsapp/send route, which
has a real, currently-authenticated agent (requireRole('agent')). The
public /api/v1/messages endpoint authenticates via an API key with no
live human identity and is deliberately left unchanged. Automations,
Flows, and the AI auto-reply bot itself (which shares flows/meta-send.ts
to deliver its own replies) never pause AI — doing so there would mean
the bot disabling itself the instant it sent a message.
send-message.ts gains an optional humanAgentUserId param: when
provided and verified (via the existing isAccountMember helper) as a
member of the conversation's own account, and the conversation isn't
already assigned, the send now sets assigned_agent_id +
ai_autoreply_disabled — mirroring exactly what the inbox's existing
Take-over action already does. Never overwrites an existing assignment.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Punto 10 — F-P10-3, F-P10-4, F-P10-5. F-P10-3: the media proxy (/api/whatsapp/media/[mediaId]) now verifies, via the RLS-scoped client, that the requested mediaId actually belongs to a message inside a conversation of the caller's own account before ever spending a call on Meta. A miss (mediaId belongs to no message, or to a different account) returns the same 404 either way. F-P10-4: SendMessageError gains externalEffectOccurred + waMessageId, set only when Meta already accepted a send before the local messages.insert() failed. withIdempotency() now completes (never fails) the idempotency claim for such an error, so a retry under the same Idempotency-Key replays the cached failure instead of sending to Meta a second time — the actual duplicate-send bug this closes. /api/v1/messages/route.ts lets that specific error propagate uncaught so withIdempotency's catch can see it. The dashboard route (/api/whatsapp/send) surfaces whatsapp_message_id in its error body as reconciliation evidence; message-thread.tsx already displays the full error text via its existing toast, so no UI change was needed. The AI auto-reply path (auto-reply.ts) now hands off to a human when engineSendText fails after the reply slot was already claimed, instead of silently stranding the conversation with no record and no handoff. F-P10-5: new route.signature.test.ts exercises the webhook's real signature check end to end (not mocked, unlike every other webhook test file) — valid/invalid/missing signature, confirming processWebhook() is never even scheduled on a rejection. F-P10-6 remains INFO/documented — no retries implemented. A pre-existing null byte in idempotency.ts's computeRequestHash (unrelated to this fix, confirmed present in HEAD before this change) was found and deliberately left untouched — out of scope for P10. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
What changed
Test plan
npm run typecheckclean.npm run lint— no new errors beyond the pre-existing backlog.npm run buildsucceeds.Related