Skip to content

UPDATE - #503

Open
hernandezservicios wants to merge 94 commits into
ArnasDon:mainfrom
hernandezservicios:main
Open

UPDATE#503
hernandezservicios wants to merge 94 commits into
ArnasDon:mainfrom
hernandezservicios:main

Conversation

@hernandezservicios

Copy link
Copy Markdown

Summary

What changed

Test plan

  • npm run typecheck clean.
  • npm run lint — no new errors beyond the pre-existing backlog.
  • npm run build succeeds.
  • Feature / fix manually exercised in the browser (or the reason it can't be).

Related

andycruz47 and others added 21 commits June 29, 2026 10:31
# 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
hernandezservicios and others added 6 commits August 19, 2026 17:12
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>
hernandezservicios and others added 30 commits August 31, 2026 15:41
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants