Commit Graph

204 Commits

Author SHA1 Message Date
Arnas Donauskas
4aa329b527
feat(theme): add light/dark mode toggle (#243)
Introduce a light/dark MODE axis orthogonal to the existing accent
themes. Neutral surface tokens now live in html[data-mode="light|dark"]
blocks; accent tokens (--primary*, --ring, --chart-1, --sidebar-primary*)
stay in html[data-theme] blocks, so any accent works in either mode.

- globals.css: split neutrals (mode) from accent (theme); add light
  palette; dark values unchanged (dark stays the default).
- lib/themes.ts: Mode type, DEFAULT_MODE=dark, MODE_STORAGE_KEY, isMode.
- use-theme.tsx: provider owns mode/setMode/toggleMode + cross-tab sync.
- layout.tsx: boot script applies persisted mode before first paint
  (no flash); colorScheme "dark light".
- mode-toggle.tsx (header sun/moon) + themed-toaster.tsx (mode-aware).
- appearance-panel.tsx: light/dark control + updated copy.
- Tokenize hardcoded neutral colors (slate/white/gray) -> semantic
  tokens across the app so every surface flips with mode. Flow canvas
  and message-bubble timestamps made mode-aware too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 17:29:46 +03:00
Abhishek Sonwal
f39ca60629 refactor(contacts): improve code readability and formatting in import modal and CSV parsing
- Reformatted code in `import-modal.tsx` for better readability, including consistent indentation and line breaks.
- Enhanced `parse-contact-csv.ts` and `parse-contact-csv.test.ts` with consistent string delimiters and improved whitespace handling.
- Updated comments for clarity and consistency across the import functionality.
- Ensured consistent use of single quotes for strings throughout the files.
2026-06-16 12:59:03 +05:30
Abhishek Sonwal
a3ce8fcf9b
Merge branch 'ArnasDon:main' into main 2026-06-16 12:17:02 +05:30
Arnas Donauskas
3b924c85bc
fix(whatsapp): allow connecting Meta test numbers without a PIN
The WhatsApp config POST handler hard-failed with a 400 whenever a
number needed registration (first save or number change) and no
6-digit two-step-verification PIN was supplied. Meta test numbers
(Developer Console) are pre-registered by Meta and expose no PIN to
set, so test-number users could never complete setup (issue #242).

The PIN-based /{phone_number_id}/register call only governs inbound
webhook routing for production numbers under a shared WABA (issue
#136) — it has nothing to do with credential validity or outbound
sends. So treat it as best-effort: when no PIN is supplied, skip
/register, save the (already Meta-verified) credentials as connected,
and leave registered_at null. The response now reports `registered`
accurately and adds `registration_skipped` so the UI can react.

UI: the save handler no longer falsely claims the number is "Live"
on a skipped registration — it points at the existing "Not
registered" banner, which already offers a PIN-retry path. The PIN
field is relabelled (optional) with copy explaining it's only for
production inbound routing and that test numbers have no PIN.

Fixes #242

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 17:01:36 +03:00
Abhishek Sonwal
b780fddb3b feat(contacts): enhance import modal with company column support and improved CSV parsing
- Updated `parseContactCsv` to include a flag for the presence of a company column in the CSV.
- Modified the import modal state management to handle the new company column, improving user experience during contact import.
- Enhanced the `ImportPreviewTags` component to conditionally render based on the presence of company data.
- Adjusted the UI to better display tag and company information in the import preview.
2026-06-15 13:27:56 +05:30
Abhishek Sonwal
73062cf06d feat(contacts): enhance import modal with tag preview and CSV parsing improvements
- Added `ImportPreviewTags` component to display tags with color coding in the import modal.
- Updated `parseContactCsv` to return an object containing both parsed rows and a flag indicating the presence of a tags column.
- Modified state management in the import modal to handle tags and their colors, improving user experience during contact import.
- Adjusted the import modal to conditionally render the tags column based on the presence of tags in the preview.
2026-06-15 13:12:00 +05:30
Abhishek Sonwal
3a416832a7 feat(contacts): enhance CSV import functionality with tag assignment
- Introduced `parseContactCsv` for improved CSV parsing, replacing the previous inline parsing logic.
- Added support for importing tags alongside contacts, allowing users to assign tags during the import process.
- Implemented `assignImportedContactTags` to handle tag assignments after contact creation.
- Updated the import result state to include the number of tags assigned and added relevant success and info notifications for users.
- Enhanced the import modal description to clarify the format for tag input in CSV files.
2026-06-15 13:00:18 +05:30
Arnas Donauskas
599b21eed0
fix(settings): add Custom Fields tab to Settings (#238)
Custom-field definitions are account-wide config — a sibling of Tags,
Templates and Deals, all of which live under Settings. But the field
catalogue could only be managed via an admin-only button on the Contacts
page, so users looking under Settings found nothing (#238).

Surface it as a "Custom Fields" tab in Settings:

- Extract the manager's body into a reusable `CustomFieldsPanel`;
  `CustomFieldsManager` keeps its dialog signature and now wraps the
  panel, so the Contacts page is unchanged. Radix unmounts dialog
  content on close, so the panel still refetches on each open.
- New `CustomFieldsSettings` renders the panel inline with a
  settings-style header.
- Tab is admin+ gated (mirrors the Contacts gate); deep-linking to it
  as a non-admin falls back to Profile. `custom_fields` RLS still
  enforces writes server-side.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 15:48:41 +03:00
Arnas Donauskas
d477a7a429
feat(automations): support custom contact fields in Update Contact Field action
The "Update Contact Field" automation action could only write the built-in
name/email/company columns. This wires up the existing custom_fields /
contact_custom_values tables so automations can read and update arbitrary
custom fields, and adds the missing UI to define them.

- Add a "Custom fields" manager dialog on the Contacts page (admin+ gated)
  to create / rename / delete field definitions. Inserts carry account_id.
- Load account custom fields into the automation builder's ResourcesContext
  and offer them in the Field dropdown, encoded as `custom:<id>`. A saved
  field that's since been deleted is preserved as an "(unknown field)"
  option so editing doesn't silently drop it.
- Engine: interpolate the value ({{ vars.* }} / {{ message.text }}) for all
  fields, and for `custom:<id>` verify the field belongs to the automation's
  account (defense in depth) before upserting into contact_custom_values.
- Tests: cover the custom-value upsert, variable interpolation, and
  cross-account rejection.

Closes #235

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 15:23:49 +03:00
Arnas Donauskas
82caecec09
fix(flows): allow multiple comma-separated keywords in trigger (#234)
The list-view keyword trigger input parsed its value into the keywords
array on every keystroke (split/trim/filter(Boolean)) and re-rendered
from keywords.join(", "). Typing a comma produced a trailing empty
segment that filter(Boolean) dropped, so the comma was stripped the
instant it was typed — making it impossible to enter a second keyword.

Extract a KeywordsInput component that holds a local draft string and
only commits (parse + dedupe-empties + rejoin) on blur / Enter, mirroring
the automations builder's KeywordMatchConfig. Multi-keyword entry like
"support, help, hi" now works.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 11:16:16 +03:00
Arnas Donauskas
9277746161
fix(inbox): let the conversation list scroll instead of clipping
The conversation/contact list on the Inbox renders inside a flex
column with a fixed height, with the list in a `<ScrollArea
className="flex-1">`. A flex child defaults to `min-height: auto`, so
the ScrollArea grew to fit every conversation rather than shrinking to
the remaining space. The list then overflowed its container and was
clipped by the parent's `overflow-hidden`, with no vertical scrollbar —
so contacts past the fold were unreachable (issue #229).

Add `min-h-0` to the ScrollArea so it can shrink below its content and
the viewport scrolls.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 19:26:29 +03:00
Arnas Donauskas
3dce1b56cd
feat(contacts): prevent duplicate phone numbers on create/import (#212)
The same phone could become multiple contacts: only the WhatsApp
webhook de-duped, while manual create and CSV import inserted raw
un-normalized phones, and the DB had no uniqueness. That fragmented
conversations, deals, and tags across duplicates.

- Migration 022: generated `contacts.phone_normalized` (digits-only),
  a one-shot `merge_duplicate_contacts()` that collapses existing
  dupes into the oldest row (re-pointing conversations, deals, notes,
  tags, custom values, broadcast recipients — no data loss), then a
  UNIQUE (account_id, phone_normalized) index as the authoritative
  guarantee for every write path.
- Shared `src/lib/contacts/dedupe.ts`: findExistingContact (suffix
  prefilter + phonesMatch), isExactMatch, isUniqueViolation,
  dedupeByPhone. The webhook now reuses findExistingContact (and
  recovers from a unique-violation race instead of dropping the
  message).
- Contact form: on-blur duplicate check — hard-block + "view existing"
  link on an exact match, soft warning on a trunk-variant near-match;
  unique-violation backstop on submit.
- CSV import: de-dupes within the file and against existing contacts,
  reports "imported / skipped / failed", treats 23505 as skipped.
- Contact type gains account_id (was missing) + phone_normalized.
- Unit tests for the dedupe helpers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 15:52:38 +03:00
Arnas Donauskas
c076b3cfe1
fix(currency): make formatCurrency total + add unit tests
deals.currency has no DB CHECK (only accounts.default_currency does),
so legacy rows, imports, or hand-edited data can hold a structurally
invalid code (e.g. "United States"). Intl.NumberFormat throws a
RangeError on such codes, which would crash any deal-card / contact
render that passes deal.currency straight through.

Guard formatCurrency with a try/catch that falls back to "CODE 1,234"
so a bad value degrades gracefully instead of taking down the view.
Add currency.test.ts locking in the defaults, the no-throw contract,
and formatCurrencyShort's symbol/fallback behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 15:10:56 +03:00
Arnas Donauskas
3abecb433b
feat(deals): make default deal currency configurable per account (#218)
wacrm hardcoded USD across every deal-value formatter, the new-deal
form, and automation-created deals, making deal tracking unhelpful for
non-US self-hosted users. Add a per-account default currency.

- Migration 021: accounts.default_currency TEXT NOT NULL DEFAULT 'USD'
  with a 3-letter-code CHECK. The existing admin-only accounts_update
  RLS policy already gates writes.
- New src/lib/currency.ts: single source of truth (CURRENCIES list,
  formatCurrency, formatCurrencyShort), replacing ~6 duplicated
  USD-hardcoded helpers.
- useAuth exposes defaultCurrency (joined from accounts), falling back
  to USD while loading / on pre-021 schemas.
- Settings → Deals tab to pick the currency (admin-only, read-only for
  others to match RLS).
- Deal form defaults new deals to the account currency and sources its
  dropdown from CURRENCIES; existing deals keep their saved currency.
- Pipeline board/analytics, dashboard "Open Deals Value", and the
  pipeline donut format totals in the account currency. Individual
  deal cards still show each deal's own currency.
- Automation create_deal sets the new deal's currency from the
  account default instead of the static DB default.

One currency per account (no FX conversion) per the issue's
recommended first pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 13:59:48 +03:00
Arnas Donauskas
efd72b131e
fix(automations): pick tags, agents & templates by name in the builder
The automation builder exposed several developer-facing fields that
made automations hard to configure from the UI (issue #216):

- Keyword Match parsed/trimmed on every keystroke, so typing a
  comma-separated, multi-word list like "SEO, search engine
  optimization" was impossible — the comma and trailing space were
  stripped immediately.
- Tag Added / Add Tag / Remove Tag asked for a raw tag_id.
- Assign Conversation → Specific agent asked for a raw agent_id.
- Send Template asked for a manually typed template name + language.

Changes:
- Keyword input keeps a local draft and only parses into the keywords
  array on blur / Enter, then re-displays the cleaned form.
- New tag / agent / template pickers select existing resources by name
  while still storing ids / names internally, so the stored config
  shape (and the engine/validation) is unchanged. Resources load once
  at the builder root and are shared via context.
- Tags & approved templates load from Supabase (RLS-scoped, matching
  the broadcast picker); members load from /api/account/members.
- Every picker falls back to its original raw input when its list is
  empty (fresh account / older deployment), so automations stay
  authorable. Saved-but-missing ids are preserved when editing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 13:21:44 +03:00
Arnas Donauskas
165bdd3335
fix(pipelines): keep horizontal scrollbar visible on desktop boards
The pipeline board uses overflow-x-auto but hid the scrollbar on fine
pointers, assuming the full board always fits on lg+. That assumption
breaks once a pipeline has many stages: columns keep a 260px min-width,
so the row overflows the viewport and the rightmost stage gets clipped
with no scrollbar or affordance to reveal it (issue #214).

Scope the scrollbar-hiding to touch devices (where the snap/peek layout
already hints at more content) and show a thin, themed horizontal
scrollbar on desktop so the overflow is discoverable and usable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 13:08:27 +03:00
Arnas Donauskas
48fc10d084
fix(account-sharing): stamp account_id on flow and pipeline inserts (#210)
`flows.account_id` and `pipelines.account_id` are NOT NULL post-017 with
no DB default, so inserts omitting account_id fail with a not-null
violation (same class as #206 / #205).

- POST /api/flows: resolve the caller's profiles.account_id and stamp it
  on both insert paths (template clone + blank/manual). Closes #210.
- Pipelines page: stamp account_id (from useAuth) on the auto-seed and
  the manual "create pipeline" inserts — same bug, found while auditing
  other account-scoped insert flows.

Audited every insert into the 15 NOT-NULL-account_id tables; all other
sites already stamp account_id. typecheck + 370 tests pass; lint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 16:06:47 +03:00
Arnas Donauskas
7c373c8e0a
fix(theme): suppress hydration warning on root html data-theme (#207)
The `theme-boot` beforeInteractive script in layout.tsx rewrites
`data-theme` on <html> from localStorage before React hydrates, so for
any non-default theme the client DOM intentionally differs from the
server-rendered DEFAULT_THEME ("violet"). React then logged a hydration
mismatch on <html> on every load where the saved theme differed.

Add suppressHydrationWarning to the root <html>. It's scoped to that
element's own attributes (child mismatches still surface) and preserves
the documented localStorage-only/device-scoped theme design — unlike a
cookie-based SSR approach, which would change that behavior.

Verified in-browser (Next 16.2.6 / Turbopack): with a non-default theme
in localStorage, the reproduced "+ data-theme=violet / - data-theme=cobalt"
hydration error is gone; removing the prop brings it back (negative
control). typecheck, lint (0 errors), 370 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 13:46:23 +03:00
Arnas Donauskas
c5ee04ce9e
fix(account-sharing): stamp account_id on account-scoped inserts (#205)
Migration 017 made domain tables account-scoped: account_id is NOT NULL
and INSERT RLS is WITH CHECK (is_account_member(account_id, ...)). Several
client- and server-side create flows still inserted only user_id, so on a
fresh install they failed with 403 / "new row violates row-level security
policy" / not-null violations.

Resolve account_id from useAuth() (client) or the caller's profile (server)
and include it on every account-scoped insert:

- contacts: contact-form, import-modal, broadcast CSV upsert
- contact_notes: contact-sidebar, contact-detail-view
- tags: tag-manager
- broadcasts: broadcasts/new page, use-broadcast-sending
- deals: deal-form
- automations: POST /api/automations, POST /api/automations/[id]/duplicate

Each path also guards on a missing accountId with a clear message instead
of letting the insert fail opaquely.

pipelines/page.tsx and api/flows/route.ts are intentionally left to the
already-open PR #193, which fixes those same paths (and adds tests).

Verified: a full sweep of every account-scoped insert/upsert across src/
now includes account_id (except the two paths owned by #193). typecheck,
lint (0 errors), and 370 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 10:20:54 +03:00
Arnas Donauskas
5332f77831
feat(members): GA the Members tab — remove account_sharing beta gate
The multi-user account-sharing surface (Members tab + invite/role
management APIs + /join redeem flow) is complete, so drop the
`account_sharing` beta flag that hid it during development. Mirrors
the Flows soft-GA in 0.2.0.

- settings: Members tab is now always rendered (no beta_features
  check, no stale-link fallback). The `profiles.beta_features`
  column itself stays for future betas.
- sidebar: the active-account strip no longer keys off the flag.
  It now shows whenever the account name differs from the user's
  own name — i.e. once you've renamed the account or joined a
  shared one — preserving the original "don't duplicate a solo
  user's name" behaviour without the flag.
- CHANGELOG/README: document Members as a shipped, all-users
  feature.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 17:39:46 +03:00
Arnas Donauskas
47987acb06
Merge pull request #163 from dhruv465/codex/fix-cache-flow-canvas-drag
fix flow drag responsiveness and stale chunks
2026-06-02 13:39:03 +03:00
Arnas Donauskas
b4f18537bb
fix(automations): enforce tenant isolation on caller-supplied contactId
The automation engine executes steps via the service-role client, which
bypasses RLS. POST /api/automations/engine reads contact_id straight from
the request body, so an authenticated attacker could supply another
tenant's contact UUID and have the engine modify it (update_contact_field),
tag it, or read it back through a condition — bypassing the contacts RLS
policy. (GHSA-63cv-2c49-m5v3)

- Primary: verify the supplied contactId belongs to the dispatching
  account before any step runs; refuse silently otherwise.
- Defense in depth: scope the service-role contacts writes/reads
  (update_contact_field, contact_field condition) by account_id. Tag
  steps rely on the entry-point guard since contact_tags has no
  account_id column — documented inline.
- Add engine.test.ts covering the guard (PoC inverted), the happy path,
  and the account-scoped write.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 23:36:37 +03:00
Arnas Donauskas
d23a5a5017
Merge pull request #190 from ArnasDon/feat/rate-limit-admin-account-endpoints
feat(api): per-user rate limits on admin account endpoints
2026-06-01 15:12:49 +03:00
Arnas Donauskas
01715c7d6d
feat(api): per-user rate limits on admin account endpoints
Addresses the L1 finding from the final security review. The six
admin-class write endpoints under /api/account had no rate
limits, so a compromised admin session (or a script run in a
loop) could spam invite creation / role flips / member removes
fast enough to flood the DB or audit logs.

Adds a new `RATE_LIMITS.adminAction` bucket — 30/min/user — and
wires it into:

  - POST   /api/account/invitations          → admin:inviteCreate
  - DELETE /api/account/invitations/[id]     → admin:inviteRevoke
  - PATCH  /api/account/members/[userId]     → admin:memberRole
  - DELETE /api/account/members/[userId]     → admin:memberRemove
  - POST   /api/account/transfer-ownership   → admin:transferOwnership
  - PATCH  /api/account                      → admin:rename

Each endpoint keys its own bucket so spamming one doesn't starve
another. 30/min is comfortably above any realistic human pace
(the Members tab is a clicks-only UI; a legit admin would
struggle to hit ten of these per minute by hand) while still
bounding accidental abuse.

The bucket joins the existing send / broadcast / react /
invitationPeek / invitationRedeem buckets in `RATE_LIMITS` —
same in-memory fixed-window limiter, same `rateLimitResponse`
helper for the 429 shape.

Severity in the final review: LOW. The admin-trust model means
this isn't blocking — but the cost of adding it is six
near-identical 4-line additions and zero net risk, so worth
landing for hardening.

Verification
- `npm run lint`      → 0 errors (same 19 pre-existing warnings)
- `npm run typecheck` → clean
- `npm test`          → 366/366 pass

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 15:05:49 +03:00
Arnas Donauskas
44f5c550e1
harden(invite): optional ALLOWED_INVITE_HOSTS allow-list
Defense-in-depth for the request-derived branch of `getBaseUrl()`
in /api/account/invitations.

The threat model
- On a typical proxied deploy (Vercel / Hostinger / Cloudflare /
  nginx with a fixed server_name), the proxy overwrites the
  `Host` and `X-Forwarded-Host` headers with the deployment's
  canonical hostname, so the request-derived path produces the
  right URL. NEXT_PUBLIC_SITE_URL pinning is also unnecessary in
  that case.
- On a *bare* deployment exposed to the public internet (no proxy
  in front, no SITE_URL set), an attacker can POST directly to
  /api/account/invitations with `Host: phishing.example` and
  receive an invite URL pointing at their site. They'd still need
  admin role on *some* account to call the endpoint, and they'd
  need to set up `phishing.example/join/<token>` to actually
  phish anyone — but the resolution chain shouldn't trust
  un-validated client input by default.

The fix
- New env var `ALLOWED_INVITE_HOSTS` (comma-separated hostnames).
- When set, `getBaseUrl` validates each candidate host against
  the list and falls through to wacrm.tech if neither matches.
- When unset, behavior is unchanged — the request-derived host
  is trusted (preserves the "just works" experience for the
  typical proxied case).
- Distinct console.warn for the rejection path vs the
  no-Host-at-all path so operators can tell their allow-list is
  actually catching something.

Documented in .env.local.example with a clear explanation of
when operators should set it (bare deploys + multi-tenant
hosting) and when they probably don't need to (the typical case
where NEXT_PUBLIC_SITE_URL already pins the host).

Severity: MEDIUM in the final review, downgraded in practice
because (a) the attack window is narrow, (b) requires admin
role + a phishing site, and (c) most deployments are behind a
proxy that strips the spoofable headers. Still worth the small
amount of code to give security-conscious operators the knob.

Verification
- `npm run lint`      → 0 errors (same 19 pre-existing warnings)
- `npm run typecheck` → clean
- `npm test`          → 366/366 pass

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 14:26:01 +03:00
Arnas Donauskas
2e911d5e5a
fix(multi-user): share whatsapp_config + templates by account
Reported by @ArnasDon: a teammate accepting an invite landed in the
shared account but the WhatsApp Config tab still showed "not
connected." Inbox banner read "WhatsApp not connected" too.

Cause: PR 8 of the multi-user series converted the *webhook* and
the *automations/flows engines* to account-scoped lookups, but
left every user-facing API route + UI page still filtering by
`user_id`. Post-017 `whatsapp_config` and `message_templates` are
account-scoped (one row per account, with `account_id NOT NULL`),
so `.eq('user_id', user.id)` returned zero rows for any teammate
who didn't personally save the config — including everyone who
joined via invite.

This PR completes the conversion across the user-facing surface.

Surface (11 files)
- `src/components/settings/whatsapp-config.tsx` — read accountId
  from `useAuth()`; trigger fetch on profile-ready, not just
  user-ready; pass accountId through to the supabase query.
- `src/app/(dashboard)/inbox/page.tsx` — resolve account_id from
  profiles, query whatsapp_config by account_id for the
  connected-banner state.
- `src/app/api/whatsapp/config/route.ts` — GET/POST/DELETE all
  resolve account_id from profile. The phone_number_id collision
  check (service-role) now compares against `account_id`, not
  `user_id` — two accounts still can't share a Meta number, two
  teammates in one account share one row by design. INSERT
  populates `account_id` (tenancy) + `user_id` (audit).
- `src/app/api/whatsapp/config/verify-registration/route.ts` —
  same conversion.
- `src/app/api/whatsapp/send/route.ts` — conversation,
  whatsapp_config, message_templates lookups all switch to
  account_id; rate-limit key stays per-user.
- `src/app/api/whatsapp/react/route.ts` — conversation +
  whatsapp_config switch to account_id.
- `src/app/api/whatsapp/broadcast/route.ts` — whatsapp_config +
  message_templates + broadcasts switch to account_id.
- `src/app/api/whatsapp/media/[mediaId]/route.ts` — config
  lookup switches to account_id.
- `src/app/api/whatsapp/templates/submit/route.ts` — config
  lookup + every message_templates SELECT/upsert now uses
  account_id. The upsert ROW gains `account_id` so the
  NOT NULL constraint added by migration 017 stops rejecting
  new templates (this was an incidental bug — every new
  template post-017 would have been broken regardless of
  multi-user).
- `src/app/api/whatsapp/templates/sync/route.ts` — same.
- `src/app/api/whatsapp/templates/[id]/route.ts` PATCH +
  DELETE — same.

Pattern at every route:

  const { data: profile } = await supabase
    .from('profiles')
    .select('account_id')
    .eq('user_id', user.id)
    .maybeSingle()
  const accountId = profile?.account_id as string | undefined
  if (!accountId) return 403

then every downstream `.eq('user_id', user.id)` → `.eq('account_id', accountId)`.

Known follow-up not in this PR
- `message_templates` still has its UNIQUE index on
  `(user_id, name, language)` from migration 014; the submit
  upsert's `onConflict` targets that same triple. As a result,
  two teammates can still INSERT same-named templates without
  conflicting. The fix needs a small migration (drop legacy
  index, add `(account_id, name, language)` index, switch the
  `onConflict` target) — TODO comment left in the route file so
  it's not forgotten.

Verification
- `npm run lint`      → 0 errors (same 19 pre-existing warnings)
- `npm run typecheck` → clean
- `npm test`          → 366/366 pass
- `npm run build`     → succeeds

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 14:01:46 +03:00
Arnas Donauskas
7d466ca865
fix(invite): derive invite-URL origin from the request, not wacrm.tech
Pre-fix: `getBaseUrl()` in /api/account/invitations defaulted to
`https://wacrm.tech` whenever `NEXT_PUBLIC_SITE_URL` was unset.
wacrm.tech is the docs/marketing site (separate ArnasDon/wacrm-site
repo); it has no `/join/[token]` route. Any fork that didn't set the
env var got invite links pointing into the marketing site, which
404'd on click. Reported by @ArnasDon while testing the flow on
production.

Post-fix: `getBaseUrl(request)` resolves in this order:

  1. `NEXT_PUBLIC_SITE_URL` — explicit operator config, still wins.
  2. `X-Forwarded-Host` (+ `X-Forwarded-Proto`) — set by every
     reverse proxy in front of the app (Hostinger Managed Node.js,
     Vercel, Cloudflare). This is what makes invite links Just
     Work in production without any env-var fiddling.
  3. `Host` header + the request's protocol — bare deployments
     without a proxy in front.
  4. `https://wacrm.tech` last-resort fallback with a console.warn
     so operators spot the misconfig in logs. Effectively
     unreachable from a real browser; only triggers if there's no
     Host header at all.

Also tightened `.env.local.example` so the variable's purpose
matches the new behavior: it's now only needed when the request-
derived origin would be wrong (cron jobs, background workers).
Day-to-day forks don't have to remember it.

Verification
- `npm run lint`      → 0 errors (same 19 pre-existing warnings)
- `npm run typecheck` → clean (after `rm -rf .next` to clear
                        stale [locale]/* type stubs from the
                        i18n revert that landed on main)
- `npm test`          → 366/366 pass
- `npm run build`     → succeeds

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 09:16:14 +03:00
Arnas Donauskas
2fbeeee3ac
Revert "feat(i18n): re-attempt next-intl with hardened artifact tracing" 2026-05-31 21:05:34 +03:00
Arnas Donauskas
0fa06d8b14
feat(i18n): harden build artifact tracing for Hostinger deploy
Re-applies PR #182's next-intl scaffold with defensive measures aimed
at the most-likely-but-unconfirmed causes of the 503 that hit prod
after that PR merged. PR #183 reverted #182 to restore service; this
branch carries the same i18n payload plus the safeguards below.

Defensive changes vs PR #182:

1. **`outputFileTracingRoot: __dirname`** in next.config.ts.
   Builds were warning that Next.js inferred the workspace root as
   the parent GHWebhosting directory (because of a stray lockfile
   there). With the wrong root, file tracing for the production
   artifact can include the wrong files or skip ours. Pinning the
   root to the wacrm directory silences the warning and makes tracing
   deterministic across local / CI / Hostinger.

2. **`outputFileTracingIncludes: { "/**/*": ["./messages/**/*.json"] }`**.
   Belt-and-suspenders: explicitly force the i18n message dictionaries
   into the production artifact even if static-import tracing somehow
   misses them in Hostinger's build environment.

3. **Static locale import map in `src/i18n/request.ts`**.
   Replaces `import(\`../../messages/\${locale}.json\`)` with an
   explicit `{ en: () => import(...), es: ..., lt: ... }` map.
   Template-literal dynamic imports are well-supported by Turbopack's
   tracer but the static form leaves zero room for the tracer to
   skip a locale.

4. **Try/catch around the intl middleware call**.
   If `intlMiddleware(request)` ever throws at runtime, fall through
   to auth-only handling and log the error rather than 503-ing every
   page request behind a single bad call.

5. **Try/catch around `LOADERS[locale]()`** with English fallback.
   Same idea: a missing locale bundle at runtime returns English
   content instead of crashing the render path.

6. **`engines.node: ">=20.10.0"`** (was `>=20.0.0`). Next 16.2's
   actual minimum. Tells Hostinger's Node.js app picker not to land
   us on a too-old subversion.

Verified locally:
- npm run lint → 0 errors (same 18 pre-existing warnings)
- npm run typecheck → clean
- npm run test → 366 passing
- npm run build → 62 prerendered pages across en/es/lt; workspace
  root warning gone; messages bundled into `.next/server/chunks/ssr/
  messages_{en,es,lt}_json_[json]_cjs_*.js`
- npm start probes:
    /              → 307 → /en
    /en/login      → 200
    /es/login      → 200
    /lt/login      → 200
    /en/dashboard  → 307 → /en/login (signed out)
    /api/whatsapp/templates → 401

If 503 reappears in prod after this lands, the local repro is clean,
so the next step is hPanel build/runtime logs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 21:01:13 +03:00
Arnas Donauskas
8124640d80
Revert "Merge pull request #183 from ArnasDon/revert/i18n-pr-182"
This reverts commit 872f751c07, reversing
changes made to 5e8076cee9.
2026-05-31 20:56:54 +03:00
Arnas Donauskas
a30f04b5a4
Revert "Merge pull request #182 from ArnasDon/feat/i18n-next-intl"
This reverts commit 5e8076cee9, reversing
changes made to 962e4fb27d.
2026-05-31 20:42:14 +03:00
Arnas Donauskas
8a8758a487
feat(i18n): scaffold next-intl with [locale] routing + extract auth/layout
Introduces an internationalization layer so wacrm can ship in multiple
languages — initially en (canonical), es, lt (untranslated mirrors of
en, ready for translators).

Foundation
- next-intl ^4.13 (Next 16 + React 19 compatible)
- src/i18n/{routing,navigation,request}.ts — locale config, locale-aware
  Link/router/redirect, server-side message loader
- next.config.ts wired with `createNextIntlPlugin`
- src/app/* moved under src/app/[locale]/ (excluding /api and globals.css)
- [locale]/layout.tsx is now the root layout: <html lang={locale}>,
  hasLocale guard, setRequestLocale, NextIntlClientProvider,
  generateStaticParams for build-time prerendering of all locales
- middleware.ts composes next-intl's createMiddleware with the existing
  Supabase auth check: intl middleware runs first to handle locale
  prefix; auth redirects retain the active locale (e.g. signed-out user
  on /es/dashboard → /es/login); intl cookies (NEXT_LOCALE) are carried
  onto auth-issued responses so the sticky preference survives

Strings extracted (~80 keys across en/es/lt.json)
- common, nav (sidebar + header), roles (Owner/Admin/Agent/Viewer chips)
- auth.login, auth.signup, auth.forgotPassword (all forms + success states)
- join (all four UI states: loading, fail, accept, sign-up CTA + conflict modal)
- localeSwitcher

UI
- New `<LocaleSwitcher />` in the header — globe icon + locale dropdown
  using next-intl's useLocale + locale-aware useRouter

Out of scope for this PR (intentional — keeps diff reviewable)
- Dashboard feature pages (dashboard, inbox, contacts, pipelines,
  broadcasts, automations, flows, settings) and their components/
  namespaces still have hardcoded English; they continue to render
  fine because next/link redirects through the locale middleware
- Shared UI a11y strings (dialog close, etc.)
- Translating es.json / lt.json — they currently mirror en.json so the
  app runs in any locale without crashes (English fallback)
- Renaming middleware.ts → proxy.ts per Next 16 deprecation

Verification
- npm run build → 62 static pages prerendered across en/es/lt
- npm run typecheck → clean
- npm run test → 366 passing
- npm run lint → 0 errors (18 pre-existing warnings untouched)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 20:17:49 +03:00
Arnas Donauskas
962e4fb27d
Merge pull request #181 from ArnasDon/feat/account-sharing-visual-polish
feat(ui): visual polish from multi-user review (P1-P5, P7, P10)
2026-05-31 18:41:48 +02:00
Arnas Donauskas
03d4c26b89
feat(ui): visual polish from multi-user review (P1-P5, P7, P10)
PR D — the final pass of the post-review fix series. Bundles the
seven visual / UX polish items the design review flagged.

P1 + P5 — sidebar account strip (`src/components/layout/sidebar.tsx`)
- Owner used to be invisible in the role-chip slot (the previous
  check was `accountRole && accountRole !== "owner"`), so owners
  looked indistinguishable from admins. Now the chip always
  renders, with a per-role icon + colour pulled from a new
  `ROLE_CHIP` table.
- Added `title={account.name}` on the truncated name so the full
  account name is visible on hover when it's clipped (long names
  + narrow sidebars).

P2 + P3 + P10 — members tab (`members-tab.tsx`)
- **P2** — destructive Remove / Revoke buttons used a neutral
  default that only turned red on hover. The destructive intent
  was invisible until the user moused over. Both buttons now have
  a red default state (`border-red-500/40 bg-red-500/10
  text-red-300`) with a darker shade on hover.
- **P3** — role chips were all slate. Replaced with a per-role
  colour scale: owner amber (scarce, immutable), admin primary
  (significant), agent slate (operational default), viewer muted
  slate (read-only). Mirrors the sidebar's `ROLE_CHIP` table so
  the two surfaces don't drift; once the surface stabilises this
  should hoist into a shared module.
- **P10** — pending invitations section now opens with a one-line
  hint: "The plaintext invite URL is only shown once at creation
  for security — to re-share, revoke the invite below and create
  a new one." Pre-fix, admins kept looking for a "copy link"
  button on the pending rows; the design decision (don't store
  plaintext) is now self-explanatory.
- Cleanup: removed the now-unused `ROLE_LABELS` constant
  (subsumed by `ROLE_CHIP[role].label`).

P4 — amber "Save this link now" callout contrast
- Pre-fix: `bg-amber-500/10 + text-amber-200` on slate-900 was
  borderline for WCAG AAA body-text contrast.
- Post-fix: `bg-amber-500/15`, border bumped to `/50`, strong
  intro promoted to `amber-100` so the urgent copy reads with
  proper weight against the dark backdrop.

P7 — Try-again button on join page server_error
- Pre-fix: the only recourse for a server_error peek was to
  follow "Create a new account" / "Sign in" links — both lose
  the invite token.
- Post-fix: extracted the peek+auth fetch as `loadPeekAndAuth`,
  call it from a new "Try again" primary button on the
  server_error card. Other failure reasons (not_found / used /
  expired) are terminal for this token so they keep the
  signup/sign-in escape hatches; only server_error gets retry.

Verification
- `npm run lint`      → 0 errors (same 19 pre-existing warnings)
- `npm run typecheck` → clean
- `npm test`          → 366/366 pass
- `npm run build`     → succeeds

Closes out the post-review fix series. Combined with PRs A
(#178, backend perf), B (#179, UI blocking), and C (#180,
front-end correctness), every item from the consolidated review
report is addressed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 19:39:41 +03:00
Arnas Donauskas
4390b8b41f
Merge pull request #180 from ArnasDon/feat/account-sharing-fe-correctness
feat(ui): front-end correctness pass (S1,S2,S6-S8,P8,P9)
2026-05-31 18:36:39 +02:00
Arnas Donauskas
cbcad71fda
feat(ui): front-end correctness pass from multi-user review (S1,S2,S6-S8,P8,P9)
PR C of the post-review fix series. Bundles six related front-end
correctness / portability fixes. No behavior change for happy-path
users — these are belt-and-braces against edge cases.

S1 — useCan exhaustiveness (`src/hooks/use-can.ts`)
- Add a `default` branch with `const _: never = action` so adding
  a new `CanAction` without a case fails the typecheck instead of
  silently returning `undefined` at runtime.

S2 + P8 — GatedButton helper (`src/components/ui/gated-button.tsx`)
- `<Button disabled title="...">` doesn't render a tooltip in
  Safari or older Firefox: those browsers don't fire mouse events
  on disabled controls, so the title never surfaces. Viewers on
  those browsers saw silently-disabled CTAs with no explanation.
- New `<GatedButton canAct={...} gateReason="...">` wraps the
  Button in a non-disabled `<span>` that owns the `title`. The
  span receives mouseover everywhere; the tooltip works.
- Also subsumes the duplicated `READ_ONLY_TITLE = "..."` constants
  across 5 list pages. `gateReason` is the verb phrase that
  completes "Read-only — your role can't <gateReason>".
- Migrated call sites: broadcasts / automations / flows / contacts
  / pipelines list pages + inbox composer (Send + Templates
  buttons). The composer's textarea keeps its inline title since
  the GatedButton pattern only applies to buttons.

S6 — Revert optimistic role-change on error (`members-tab.tsx`)
- Pre-fix: PATCH failure showed a toast and left the local
  member-row state with the new role. Next interaction
  (re-toggling to the same role) was a no-op via the
  `member.role === nextRole` guard at the top, so the row
  appeared stuck. Now we capture `previousRole` before the
  optimistic flip and revert on both 4xx and network failure.

S7 — Sidebar flag check matches settings (`sidebar.tsx`)
- Pre-fix: sidebar's `accountSharingEnabled` didn't guard on
  `profileLoading`, so the account-name strip briefly was hidden
  during the initial profile fetch then popped in. Settings page
  did this guard correctly. Now they match.

S8 — Referrer-Policy: no-referrer on /join (`join/layout.tsx`)
- The plaintext invite token lives in the URL path. Belt-and-
  braces against any future external resource on the join page
  leaking the token via the Referer header. Set via Next 16's
  `metadata` export → `<meta name="referrer" content="no-referrer">`.
  Also marks the route `noindex, nofollow` so a join URL can't end
  up in search results.

P9 — Client-side label length validation (`invite-member-dialog.tsx`)
- Server caps `label` at 80 chars and returns 400 on overflow.
  Add a `MAX_LABEL_LEN` constant on the client, point both the
  `<Input maxLength>` and the pre-submit check at it. Catches the
  paste-then-submit path where a paste lands an over-limit string
  in state before the keystroke-level cap engages.

Verification
- `npm run lint`      → 0 errors (same 19 pre-existing warnings)
- `npm run typecheck` → clean (the new `never` cast catches typos)
- `npm test`          → 366/366 pass
- `npm run build`     → succeeds

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 19:33:28 +03:00
Arnas Donauskas
d56c4176b5
Merge pull request #179 from ArnasDon/feat/account-sharing-ui-blocking-fixes
feat(ui): user-facing fixes from multi-user review (M3, M4, S3)
2026-05-31 18:28:21 +02:00
Arnas Donauskas
bff8ec6765
feat(ui): user-facing fixes from multi-user review (M3, M4, S3)
PR B of the post-review fix series. Closes the three user-blocking
UI gaps the design / UX review flagged.

M3 — Charlie's 409 redeem dead-end (join page)
- Pre-fix: a user with existing data clicking an invite saw a
  3-second toast then the Accept button re-enabled. The same
  retry would 409 forever.
- Post-fix: 409 status is detected explicitly and surfaces a
  blocking dialog with an explanation + "Sign out & use a
  different email" action. The action calls supabase.signOut()
  then reloads the page; the page rebuilds on the signed-out
  branch with the "Create account & join" CTA. The invite token
  stays in the URL through the reload so the recovery is one
  click. Non-409 errors still take the regular toast path.
- Files: `src/app/join/[token]/page.tsx` — new state
  `conflictMessage`, new handler `handleSignOutAndRetry`, new
  Dialog component in the auth-in render branch.

M4 — WhatsApp share message includes the account name
- Pre-fix: "Join our wacrm account using this link…" — generic,
  no team context. In multi-team contexts the recipient
  couldn't tell which team they were being invited to.
- Post-fix: "Join <Account Name> on wacrm using this link…".
  The account name is snapshotted into the `CreatedInvite`
  result at creation time so a later rename can't retroactively
  rewrite the wa.me message.
- Files: `src/components/settings/invite-member-dialog.tsx` —
  read `account` from `useAuth()`, thread `accountName` through
  `CreatedInvite`, update `whatsappShareUrl` copy.

S3 — Members tab roster mobile reflow
- Pre-fix: the member row used a single flex-row with a fixed-
  width 128px role Select and a 36px Remove button. On a 375px
  viewport the name + email got crushed to ~50px.
- Post-fix: at base width the row stacks vertically — identity
  (avatar + name + email) on top, actions (role + Remove) below.
  At sm+ it returns to the original inline layout. The joined-
  date column stays desktop-only.
- Files: `src/components/settings/members-tab.tsx` — `flex-col`
  base, `sm:flex-row sm:items-center` for desktop. Actions
  wrapped in their own flex container so the role Select and
  Remove button cluster together on both breakpoints.

Verification
- `npm run lint`      → 0 errors (same 19 pre-existing warnings)
- `npm run typecheck` → clean
- `npm test`          → 366/366 pass
- `npm run build`     → succeeds

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 19:25:50 +03:00
dhruv465
242e2c2073 fix: keep auto-layout hydration clean 2026-05-31 21:49:14 +05:30
Arnas Donauskas
55da996e52
perf(db,webhook): backend follow-ups from the multi-user review
PR A of the post-review fix series. Addresses the two must-fix
backend findings (flow-media account scoping; webhook contact
lookup at scale) plus the two should-fix composite-index
additions, all in one migration + two TS files.

Migration 020 (`supabase/migrations/020_account_sharing_followups.sql`)
- Composite partial index `idx_automations_account_active_trigger`
  on `automations(account_id, trigger_type) WHERE is_active`.
  The 006-era partial index was account-blind so the planner had
  to post-filter by account_id on every webhook dispatch. With
  100+ admins in a shared account the post-filter step shows up
  in query plans; this index answers all three predicates from
  one lookup.
- Same shape for flows: `idx_flows_account_active` on
  `flows(account_id) WHERE status='active'` — paired to the
  `findEntryFlow` query in the runner.
- `flow-media` storage RLS rewritten to allow uploads / updates
  / deletes by any member of the account whose folder name
  matches `account-<account_id>/...`. Legacy `<auth.uid()>/...`
  paths remain writable by the original uploader so files
  uploaded pre-020 don't break. Reads stay public (the bucket
  was already public so Meta can fetch URLs without creds).

Webhook contact lookup (`src/app/api/whatsapp/webhook/route.ts`)
- The PR-8 conversion swapped `eq('user_id', userId)` for
  `eq('account_id', accountId)` but kept "pull every row, filter
  in JS" semantics. That was fine when account = one user but
  scales linearly with the team size × per-user contact count.
- Now pre-filters in SQL by the phone's last-8-digit suffix
  (mirroring `phonesMatch`'s trunk-prefix tolerance). The
  trailing-suffix `like` can't use a B-tree index, but the
  account_id filter on top of `idx_contacts_account` keeps
  the scan to a small account-scoped subset. Re-applies the
  strict `phonesMatch` check in JS on the candidate set for
  correctness — typically 0-2 rows, basically free.

Flow-media upload path (`src/components/flows/forms/node-config-form.tsx`)
- Resolves the caller's `profile.account_id` before upload and
  pathes the file under `account-<account_id>/...` to match the
  new 020 RLS policy. Legacy `<user_id>/...` paths keep working
  via the back-compat clause in the policy.

Verification
- `npm run lint`      → 0 errors (same 19 pre-existing warnings)
- `npm run typecheck` → clean
- `npm test`          → 366/366 pass
- `npm run build`     → succeeds

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 19:18:58 +03:00
Arnas Donauskas
14cdbf457b
feat(ui): role-aware gating sweep + account-name in sidebar
PR 9 of the multi-user accounts series — the final piece. Visual
polish so non-owners see consistent disabled states across the
app and so every user always knows which account they're acting in.

Sidebar
- New account-name strip above the user dropdown, gated behind
  the `account_sharing` beta flag (same flag the Members tab
  from PR 6 reads). For solo users (the default) the account is
  named after them, so showing it would just duplicate the user
  name below — flag-gating keeps the original layout untouched
  for single-user installs.
- When the caller's role is admin/agent/viewer (not owner), the
  role is surfaced as a small uppercase chip alongside the
  account name so they have an unambiguous signal of their
  permission level without opening Settings.
- Nav items stay always-visible per the planning doc's
  "show-but-disable" decision — hiding nav rows by role would
  make the app feel broken.

Inbox composer
- Send button + Templates button + textarea are disabled for
  viewers with a "Read-only — viewers can browse but not reply"
  placeholder and tooltip. The existing session-expired branch
  still wins when it applies (session-expired is the more
  specific condition and surfaces template-only sending).

List-page create buttons
- broadcasts/page.tsx: "New Broadcast" CTA (header + empty
  state).
- automations/page.tsx: "Create Automation" CTA.
- flows/page.tsx: "New flow" CTA (header + empty-state).
- contacts/page.tsx: "Add Contact" + "Import" CTAs.
- pipelines/page.tsx: "Add Pipeline" (gates on edit-settings —
  pipelines are settings-class under the 017 RLS) and "Add
  Deal" (gates on send-messages).

All use `useCan(...)` from PR 5. Disabled buttons get a tooltip
explaining why (`title="Read-only — your role can't ..."`).
Solo users see no visual change — single-owner accounts pass
every capability check.

Why show-but-disable, not hide
- A viewer scanning the app should still see what's possible
  in the product, both to understand what they can ask for an
  upgrade for and to avoid the UI feeling silently broken
  ("the New button just disappeared?"). The tooltip carries
  the explanation cheaply.

Verification
- `npm run lint`      → 0 errors (same 19 pre-existing warnings)
- `npm run typecheck` → clean
- `npm test`          → 366/366 pass
- `npm run build`     → succeeds

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 19:02:14 +03:00
Arnas Donauskas
0ed07ee6c9
feat(webhook,engines): account-scoped routing
PR 8 of the multi-user accounts series. Flips the WhatsApp webhook,
automations engine, and flows engine from per-user tenancy to
per-account so a shared inbox actually behaves like one.

Why this matters
- Pre-this-PR: an inbound message resolved the recipient by
  whatsapp_config.user_id and dispatched only that user's
  automations / flows. Teammates joining via an invite would see
  the message in the shared inbox but their automations / flows
  never fired.
- Also fixes incipient NOT NULL violations from migration 017.
  That migration added `account_id NOT NULL` to automation_logs,
  automation_pending_executions, flow_runs, and deals. Inserts in
  the engines hadn't been updated yet — any production run would
  500 with a column violation. This PR adds account_id to every
  such insert.

Surface
- `src/app/api/whatsapp/webhook/route.ts`
  - Resolves `config.account_id` AND `config.user_id` from the
    matched whatsapp_config row. Passes accountId through every
    downstream call. user_id is kept as `configOwnerUserId` and
    used only for NOT NULL FK audit columns on contact /
    conversation inserts.
  - `findOrCreateContact` / `findOrCreateConversation` look up by
    account_id and insert with both columns.
  - `flagBroadcastReplyIfAny` matches broadcasts by account_id so
    any teammate's broadcast is updated when a customer replies.
  - `dispatchInboundToFlows` and `runAutomationsForTrigger` now
    take `accountId` instead of `userId`.
- `src/lib/automations/engine.ts`
  - `DispatchInput.userId` → `accountId`.
  - `runAutomationsForTrigger` queries automations by account_id.
  - `executeAutomation` inserts into automation_logs with both
    account_id (tenancy) and user_id (audit, from automation row).
  - `wait` step inserts into automation_pending_executions with
    account_id.
  - `create_deal` step inserts into deals with account_id.
  - `assign_conversation` / `close_conversation` /
    `resolveConversationId` filter by account_id.
  - `resumePendingExecution` row shape now includes account_id;
    cron passes it through.
  - engineSendText / engineSendTemplate calls pass
    `accountId: args.automation.account_id`.
- `src/lib/flows/engine.ts`
  - `DispatchInboundInput.userId` is kept (audit) and
    `accountId` is added (tenancy).
  - `loadActiveRunForContact`, `isDuplicateInbound`,
    `findEntryFlow` all query by account_id.
  - `startNewRun` inserts into flow_runs with account_id.
  - All `engineSend*` calls pass `accountId: run.account_id`.
- `src/lib/automations/meta-send.ts` + `src/lib/flows/meta-send.ts`
  - All four engine senders (text, template, media, interactive
    buttons/list) accept `accountId` and look up the contact +
    whatsapp_config by account_id. user_id stays for inserts.
- `src/lib/flows/types.ts`
  - `FlowRow`, `FlowRunRow`, `DispatchInboundInput` gain
    `account_id` / `accountId`.
- `src/types/index.ts`
  - `Automation` gains `account_id`. Doc note on the user_id
    field clarifying it's audit-only post-multi-user.
- `src/app/api/automations/cron/route.ts`
  - Pass account_id from the pending row to resumePendingExecution.
- `src/app/api/automations/engine/route.ts`
  - Uses `getCurrentAccount()` from #169 to resolve the caller's
    account, then dispatches with accountId.
- `CHANGELOG.md` — Fixed entry explaining the inbox / NOT NULL fix.

Backwards compatibility
- All affected tables already have account_id from migration 017.
  This PR populates it on insert paths that hadn't been updated.
- The `user_id` columns are kept everywhere — they still identify
  the agent / author for audit display. They just no longer
  enforce tenancy.

Verification
- `npm run lint`      → 0 errors (same 19 pre-existing warnings)
- `npm run typecheck` → clean (had 4 errors mid-refactor; all
                        resolved by the call-site updates)
- `npm test`          → 366/366 pass (engine tests are pure unit
                        tests over helper functions; not affected)
- `npm run build`     → succeeds

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 18:45:48 +03:00
Arnas Donauskas
47104042fd
feat(join): /join/[token] page + signup/login invite redirects
PR 7 of the multi-user accounts series. Closes the invite loop —
PR 6 lets admins generate links, this PR is what the recipient
sees when they click one.

What lands
- `src/app/join/layout.tsx` — minimal centered dark shell. Sits
  outside both `(auth)` and `(dashboard)` route groups because
  the page is hybrid: it renders for anonymous AND signed-in
  visitors. Reusing either neighbouring layout would funnel one
  audience through the wrong middleware redirect.
- `src/app/join/[token]/page.tsx` — client component. Calls
  /api/invitations/[token]/peek + supabase.auth.getUser() in
  parallel on mount and routes through four UI states:

    peek          | auth         | render
    --------------|--------------|----------------------------------
    loading       | —            | spinner
    ok:false (any)| —            | friendly error + signup/signin CTAs
    ok:true       | signed out   | "Sign up & join" + "Sign in" links
    ok:true       | signed in    | "Accept invitation" button → redeem

  Redeem is intentionally manual — auto-accepting on mount would
  race with the signup → email-verification → return flow and
  hide what's being accepted from the visitor.

  AuthContext doesn't reach this route group (AuthProvider lives
  inside (dashboard)), so we call Supabase directly here the same
  way /login and /signup already do.

  After a successful accept, the page does a full reload to
  /dashboard (not router.push) so AuthProvider re-fetches the
  profile with the new account_id and account_role.

- `src/app/(auth)/signup/page.tsx`:
  - Reads `?invite=<token>` via useSearchParams.
  - Passes emailRedirectTo: `${origin}/join/<token>` to
    supabase.auth.signUp so the verification email returns the
    user to the redeem step.
  - Re-skins the card header for invite signups ("Create account
    & join", different icon).
  - Preserves the token through the "Sign in" link and the
    "Back to sign in" button on the success screen.
  - Default export now wraps the form in `<Suspense>` — Next 16
    refuses to prerender pages calling useSearchParams without
    it.

- `src/app/(auth)/login/page.tsx`:
  - Reads `?invite=<token>`.
  - On successful sign-in, routes to /join/<token> instead of
    /dashboard.
  - Re-skins the card header for invite logins.
  - Preserves the token through the "Create account" link.
  - Default export wrapped in `<Suspense>` for the same prerender
    reason.

- `src/middleware.ts`:
  - When an authenticated user hits /login?invite=<t> or
    /signup?invite=<t>, redirect to /join/<t> instead of the
    default /dashboard. Without this, a forwarded invite link
    landing on an already-signed-in browser would silently drop
    on /dashboard and the visitor would never see the accept
    prompt.
  - Both pre-existing redirect cases (no invite present, OR
    /forgot-password with an invite — which doesn't make sense)
    fall through to /dashboard as before. The search string is
    cleared on both paths so we don't leak the invite token
    into the dashboard URL bar.

Verification
- `npm run lint`      → 0 errors (same 19 pre-existing warnings)
- `npm run typecheck` → clean
- `npm test`          → 366/366 pass
- `npm run build`     → succeeds:
                          ƒ /join/[token]   — dynamic (peek + auth fetch)
                          ○ /login          — static, hydrates the form
                          ○ /signup         — static, hydrates the form

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 18:30:32 +03:00
Arnas Donauskas
7554ce7cd8
feat(settings): members tab + invite member dialog
PR 6 of the multi-user accounts series. First user-facing surface
for the feature — the Members tab in Settings.

What lands
- `src/components/settings/members-tab.tsx` — roster + pending
  invitations. Every member can see the roster (read-only).
  Admin+ gets an inline role-select dropdown (excluding the
  owner row and self), a Remove button (same restrictions), and
  the "Pending invitations" section with revoke.
- `src/components/settings/invite-member-dialog.tsx` — two-step
  modal. Form (role + expiry + optional label) → result
  (read-only URL with copy-to-clipboard + a "Send via WhatsApp"
  deep link). The result step is dismissed-once: the plaintext
  token is only ever shown here and never resurfaced, so the
  copy carries a warning ("Save this link now").
- `src/app/(dashboard)/settings/page.tsx` — adds `members` to
  the tab list, gated behind the existing
  `profiles.beta_features.includes('account_sharing')` flag
  (same column migration 011 added; flip it via Supabase Studio
  on a per-profile basis to dogfood). When the flag is off the
  tab trigger AND content render nothing — the page degrades
  to the original 5-tab layout. A user landing on
  `/settings?tab=members` without the flag is silently bounced
  to `?tab=profile` rather than seeing an empty pane.

Why a feature flag at all
- Locked decision from the planning phase: ship the schema and
  API unconditionally (they're structural, harmless to existing
  single-user installs) but keep the *user-visible* invite
  surface behind a flag until the full UI series ships (Join
  page, sidebar gating, webhook account routing). Mirrors how
  migration 011 / Flows beta worked.

Role-gating
- The tab is reachable by any member of an account. Inside the
  tab, mutation controls (Invite member button, role dropdown,
  Remove button, pending invitations section + revoke buttons)
  are wrapped in `<RequireRole min="admin">` or hidden via
  `useAuth().canManageMembers` from #172.
- Server-side RPCs (set_member_role, remove_account_member from
  #170; invitation create/revoke from #171) double-check role
  anyway — the client gate is UX, not security.

Owner row
- Non-editable. Inline role select and Remove button are hidden
  on the owner's row across the board. Owner role changes go
  through the (deferred) Transfer Ownership flow.

Self row
- "You" badge on the caller's own row. Role select and Remove
  are hidden on it — admins can't accidentally demote or remove
  themselves, even though the server RPC would also catch it.

Email visibility
- Admin+ sees teammate emails; agent/viewer sees name + avatar
  + role + joined date only. Implemented server-side by the
  `/api/account/members` route in #170; the component just
  renders `member.email` when present.

UI patterns
- Mirrors existing tag-manager / template-manager card +
  divide-y list layout and the slate-900/slate-700/primary
  color tokens from the dark-only theme.
- Dialogs follow the established `bg-slate-900 border-slate-700`
  pattern. Destructive actions get the `bg-red-600 hover:bg-red-700`
  treatment with `Loader2` spinner.
- "Send via WhatsApp" anchor uses `buttonVariants` directly
  rather than `<Button asChild>` — the wacrm Button is the
  Base UI ButtonPrimitive and has no Radix-style asChild.
- Base UI Select's `onValueChange` is
  `(value: string | null) => void`; every consumer guards on
  null. We don't expose a clear affordance, so null shouldn't
  fire at runtime — but typescript requires the guard.

Verification
- `npm run lint`      → 0 errors (same 19 pre-existing warnings)
- `npm run typecheck` → clean
- `npm test`          → 366/366 pass
- `npm run build`     → succeeds; /settings stays static (data
                        fetches happen client-side via useEffect)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 18:21:30 +03:00
Arnas Donauskas
7593253c85
feat(auth): extend useAuth with account context + RequireRole + useCan
PR 5 of the multi-user accounts series. Surfaces the account
+ role state from the profile row through useAuth so every UI
gate downstream can read it without its own fetch, and lands
two reusable patterns the upcoming Members tab / Join page /
sidebar gating PRs will all consume.

What's new in useAuth
- Profile SELECT now includes `account_id`, `account_role`, and
  the joined `accounts(id, name)` via the existing `account_id`
  FK. `!inner` mirrors the server-side `getCurrentAccount`
  helper from PR #169.
- Context value adds `accountId`, `accountRole`, `account`
  (`{id, name}` summary), and six derived booleans: `isOwner`,
  `isAdmin`, `isAgent`, `isViewer`, plus the capability-level
  `canManageMembers`, `canEditSettings`, `canSendMessages`.
- All derived booleans memoise off `profile.account_role`, so
  consumers can use them as React dep-array entries without
  re-renders cascading on unrelated profile fields.
- The no-provider fallback returns every new field at its
  least-privileged value (null / false) — UI gates fail closed
  if a component is somehow rendered outside the AuthProvider.

`<RequireRole min="admin" fallback={…}>{children}</RequireRole>`
- Renders `children` iff `accountRole >= min`, else `fallback`.
- Treats `profileLoading` as "below threshold" so under-
  privileged users never see a flash of gated content during
  the initial profile fetch.

`useCan(action)` hook
- Typed action union: `manage-members` | `edit-settings` |
  `send-messages` | `view-only` | `delete-account` |
  `transfer-ownership`. Closed set lets the compiler catch
  typos at every call site.
- Returns `false` while loading. The boolean alternative to
  `<RequireRole>` — useful for `disabled={!useCan('edit-settings')}`
  on buttons / inputs / tooltip copy without restructuring JSX.

Why no React-renderer tests
- The wacrm test convention is pure unit tests (`vitest` without
  any React testing library). The role predicates that back
  every new piece here are already covered by the test suite
  added in PR #169 — the new code is thin glue, and adding a
  React testing harness for it would be a much larger change
  than this PR warrants.

Verification
- `npm run lint`      → 0 errors. Warnings drop from 20 → 19
                        because the rewritten useEffect now
                        lists `fetchProfile` in its deps.
- `npm run typecheck` → clean
- `npm test`          → 366/366 pass
- `npm run build`     → succeeds

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 17:58:53 +03:00
Arnas Donauskas
b013861160
feat(api): invitation create + peek + redeem endpoints
PR 4 of the multi-user accounts series. Lands the link-only
invitation flow — admin generates a shareable URL, copies it,
sends it via any channel. No email service needed.

API surface
- GET    /api/account/invitations              — list pending  (admin+)
- POST   /api/account/invitations              — create        (admin+)
- DELETE /api/account/invitations/[id]         — revoke        (admin+)
- GET    /api/invitations/[token]/peek         — public, rate-limited per-IP
- POST   /api/invitations/[token]/redeem       — authenticated, rate-limited per-IP

Token lifecycle
- POST returns the plaintext token + share URL exactly ONCE in
  the response. We store only the SHA-256 hash. There is no
  endpoint that resurfaces it; if the admin dismisses the dialog
  without copying, the only recourse is to revoke and re-issue.
- Peek and redeem take the plaintext token in the URL path, hash
  it in TS via `hashInviteToken`, and look up by `token_hash`.

Two new SECURITY DEFINER RPCs back the cross-RLS work
(migration 019):
- `peek_invitation` — anonymous read. Returns a uniform JSON
  envelope: `{ ok, account_name, role, expires_at }` or
  `{ ok: false, reason: not_found|used|expired }`. The route
  forwards verbatim so the join page can branch cleanly.
- `redeem_invitation` — authenticated. Locks the invite row
  `FOR UPDATE`, validates expiry/usage, refuses if the caller is
  already in a shared account OR their personal account has any
  domain rows (checked across all 11 user-creatable parent
  tables), then atomically moves profile.account_id +
  account_role, stamps the invitation accepted, and deletes the
  orphan personal account. Single transaction.

Error contract
- 22023 (invalid_parameter_value)  → 400 — not_found / used / expired
- 42501 (insufficient_privilege)   → 401 — caller not authenticated
- 23505 (unique_violation, reused) → 409 — caller's account has data,
                                           or they're already a
                                           member of the inviter's
                                           account / another shared
                                           account

Rate limiting
- Two new buckets in `RATE_LIMITS`: invitationPeek (30/min/IP)
  and invitationRedeem (10/min/IP). Belt-and-braces against
  token enumeration even though 256-bit entropy makes it
  theoretical. Uses the existing fixed-window in-memory limiter.

Why no peek-then-redeem two-step on the server
- Peek is read-only and idempotent; redeem locks `FOR UPDATE` so
  two concurrent redemptions can't both win. The peek result is
  cosmetic ("show me what I'm joining"); the actual write
  happens entirely inside the redeem RPC's transaction, so a
  TOCTOU between peek and redeem just means the user sees the
  same `{ ok: false, reason }` on the next render.

Verification
- `npm run lint`      → 0 errors (same 20 pre-existing warnings)
- `npm run typecheck` → clean
- `npm test`          → 366/366 pass
- `npm run build`     → succeeds; the 4 new routes register as ƒ

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 17:32:46 +03:00
Arnas Donauskas
0a1cf97d6b
feat(api): account + members management endpoints
PR 3 of the multi-user accounts series. Lands the server-side
endpoints the upcoming Members tab UI will call. Built on the
helpers from #169 (`requireRole`, `getCurrentAccount`,
`toErrorResponse`).

API surface
- GET    /api/account                     — caller's account + role
- PATCH  /api/account                     — rename                  (admin+)
- GET    /api/account/members             — roster; email visible to admin+ only
- PATCH  /api/account/members/[userId]    — change role             (admin+)
- DELETE /api/account/members/[userId]    — remove                  (admin+)
- POST   /api/account/transfer-ownership  — atomic owner handoff    (owner)

Member-role writes can't ride on the regular `profiles_update` RLS
policy (which only allows self-edits — admins can't modify a
teammate's row that way). The three SECURITY DEFINER RPCs in the
new migration are the scoped escape hatches:

- `set_member_role(p_user_id, p_new_role)` — admin+ only. Blocks
  promotion to / demotion from owner (use transfer instead) and
  self-targeting.
- `remove_account_member(p_user_id)` — admin+ only. Creates a
  fresh personal account for the removed user and reassigns
  their profile as its 'owner' — mirror of the signup flow, so
  they keep their login and just "start over". Blocks removing
  the owner or self.
- `transfer_account_ownership(p_new_owner_user_id)` — owner only.
  Atomically demotes current owner → admin, promotes target →
  owner, and points accounts.owner_user_id at the new owner.
  Demote-first ordering means the temporary "zero owners" state
  is never visible.

Error contract
- Each RPC raises with SQLSTATE 42501 (forbidden) or 22023 (bad
  input) and a human-readable RAISE message. The route handlers
  map these onto 403 / 400 respectively; anything else collapses
  to a generic 500.
- The TS guards (e.g. `requireRole('owner')` on the transfer
  route, `isAccountRole(role)` on the PATCH route) are
  belt-and-braces — they fail fast before the Supabase round
  trip but the RPCs would catch the same violations regardless.

Field visibility
- `GET /api/account/members` returns `email` only when the
  caller is admin+. Agents and viewers see name + avatar + role
  + joined date — matches the planning decision so a viewer
  can't harvest the team's email list.

Verification
- `npm run lint`      → 0 errors (same 20 pre-existing warnings)
- `npm run typecheck` → clean
- `npm test`          → 366/366 pass
- `npm run build`     → succeeds; the 4 new routes register as ƒ
                        (dynamic — they all use `next/headers`
                        cookies via the SSR client)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 15:00:27 +03:00
Arnas Donauskas
2a46d7deca
feat(auth): account-sharing server helpers + types
PR 2 of the multi-user accounts series. Pure helpers and types
that the upcoming API routes (account, members, invitations,
redeem) all depend on. No new API surface yet — this commit is
intentionally a self-contained library + tests so the next PR
can focus on routes without re-litigating the foundations.

What landed
- `src/lib/auth/roles.ts` — `AccountRole` type mirroring the
  Postgres enum, `roleRank` / `hasMinRole`, plus the capability
  predicates (`canManageMembers`, `canEditSettings`,
  `canSendMessages`, `canViewOnly`, `canDeleteAccount`,
  `canTransferOwnership`). Pure functions; no I/O.
- `src/lib/auth/invitations.ts` — token generation
  (`crypto.randomBytes(32).toString('base64url')`), SHA-256
  hashing for at-rest storage, `inviteUrl` builder, and
  `inviteExpiresAt` / `clampExpiryDays` for the redeem deadline.
  Pure; no Supabase.
- `src/lib/auth/account.ts` — server-only. `getCurrentAccount()`
  resolves caller's user + profile + account in one Supabase
  round trip; `requireRole(min)` wraps it with a minimum-role
  check. Custom `UnauthorizedError` / `ForbiddenError` classes
  and a `toErrorResponse(err)` helper that maps them to
  `NextResponse` 401/403 with a generic 500 fallback (no
  uncategorised error leakage on the wire).
- `src/types/index.ts` — extends `Profile` with `account_id` +
  `account_role`; adds `Account`, `AccountMember`, and
  `AccountInvitation` interfaces matching migration 017's
  shapes. The legacy `profile.role TEXT` column is annotated
  for future cleanup.

Tests
- `roles.test.ts` — 16-row hierarchy matrix + every predicate's
  boundaries. Includes a "matches the SQL helper's numeric
  mapping" check so the JS rank and Postgres `is_account_member`
  CASE can't silently drift.
- `invitations.test.ts` — token length / charset / uniqueness,
  hash determinism, a known SHA-256 vector (regression net
  against the hash function ever changing and orphaning every
  stored token_hash), URL formation edge cases, and the
  `clampExpiryDays` envelope (default / non-finite / negative /
  oversize).

Verification
- `npm run lint`      → 0 errors (same 20 pre-existing warnings).
- `npm run typecheck` → clean.
- `npm test`          → 366/366 pass (319 prior + 47 new).
- `npm run build`     → succeeds.

What's NOT in this PR
- No API routes yet — `/api/account/*` and `/api/invitations/*`
  land in PR 3 + 4 of the series.
- No UI changes — useAuth extension, Members tab, and Join page
  come later (PRs 5-7).
- No WhatsApp webhook account routing (PR 8).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 14:51:21 +03:00
Arnas Donauskas
bf90c370fc
feat(dashboard): adopt Tremor for charts — first integration
Brings Tremor (Apache-2.0) in as the project's chart component
library and converts the response-time chart as the first
integration. Tremor is distributed copy-paste ("Tremor Raw") so
the components live under `src/components/tremor/`; the only
runtime peer dependency added is `recharts`. Two small local
adaptations to the vendored `BarChart`:

- `cx` (Tremor's `clsx`+`twMerge` helper) → our `cn` from
  `@/lib/utils`, aliased locally to keep diff vs upstream minimal.
- `@remixicon/react` legend-slider icons →
  `lucide-react`'s `ChevronLeft` / `ChevronRight`, since
  `lucide-react` is already a project dep.
- `ChartTooltipProps.label` widened from `string` to
  `string | number | undefined` to match recharts 3.x (Tremor's
  source still types it for recharts 2.x).

Why Tremor and not a wider switch (Mantine, HeroUI):
- The dashboard ships ~700 lines of hand-rolled SVG across three
  chart files — a focused charting library is the highest-ROI
  addition without touching the wider component surface.
- Tremor is Tailwind-native and built on the same Radix/Base UI
  layer as the existing `src/components/ui/*`, so it composes
  rather than displaces.
- Apache-2.0 + copy-paste distribution means no upgrade lock-in.

What this PR does
- Adds `src/components/tremor/{bar-chart.tsx,chart-colors.ts,
  get-y-axis-domain.ts,use-on-window-resize.ts}` (vendored from
  tremorlabs/tremor with the adaptations above).
- Installs `recharts@^3.8.1` (React 19 + Next.js 16 compatible).
- Rewrites `src/components/dashboard/response-time-chart.tsx`:
  drops 130 lines of bespoke SVG math (axis scaling, bar
  positioning, grid lines, tooltip rendering), reuses the same
  card/header/empty-state shell.

Deferred (own follow-up PRs)
- `pipeline-donut.tsx` — has user-customisable per-stage hex
  colours; Tremor's palette would override them. Needs either a
  small upstream patch or a `colors` override path.
- `conversations-chart.tsx` — area-chart conversion (largest of
  the three, 341 lines).
- The previous hand-rolled bar chart drew a horizontal dashed
  "target N minutes" reference line. Tremor BarChart doesn't
  expose Recharts primitives, so for now the target is promoted
  to a header pill (`target 5m`). On-chart reference line is a
  follow-up — either via a positioned overlay or an upstream
  `referenceLines` prop.

Verification
- `npm run lint`   → 0 errors (same 20 pre-existing warnings).
- `npm run typecheck` → clean.
- `npm test`       → 319/319 pass.
- `npm run build`  → succeeds, dashboard remains a static route.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 14:42:10 +03:00
Arnas Donauskas
13bafa5747
fix(inbox): reply quote no longer overflows or single-lines (#165)
A long reply quote was forcing the entire 3-panel inbox layout
wider, pushing the contact sidebar off-screen and making the page
horizontally scrollable. Two root causes (both correctly diagnosed
in the issue by @tekrexllp):

1. ReplyQuote applied `truncate` to the preview text, which sets
   `white-space: nowrap`. Long previews became one impossibly-wide
   line instead of wrapping.

2. The flex chain from MessageBubble outward (MessageActions →
   middle-panel wrapper → page) had no `min-w-0` on its flex
   children. Default `min-width: auto` means a flex child grows to
   its content's natural width, so the long quote propagated all the
   way up to the inbox-page flex container and pushed the contact
   sidebar past the viewport.

Fix:
  - ReplyQuote preview now uses `whitespace-pre-wrap break-words`
    instead of `truncate`. The author label stays single-line (it's
    a name, ellipsis is right for it). `overflow-hidden` on the
    inner text container as belt-and-braces.
  - MessageActions' .group/actions wrapper gets `min-w-0` so the
    `max-w-[75%]` cap actually holds even with wide content.
  - inbox/page.tsx middle-panel wrapper gets `min-w-0` so the
    MessageThread can shrink below its natural content width when
    the conversation list and contact sidebar are also flexing.

Audited the other 38 `truncate` usages across the codebase —
they're all on intentionally single-line content (contact names,
file paths, last-message previews in lists, headers, node
summaries) where ellipsis is the right behavior. ReplyQuote was
the only multi-line `truncate` misuse.

Reported and diagnosed by @tekrexllp in #165.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 12:28:49 +03:00