feat(ever-works): send the Gauzy Inbox's own items to Ever Works, per person and kind

Merged
EV
13d ago
+7118-104

What

Gauzy's own Inbox items now reach Ever Works (owner decision 2026-10-29: mirror everything by default, emails included; each person controls what goes). Until now only the other way existed (Ever Works inbox → Gauzy Inbox, source = EVER_WORKS).

Gauzy task: Ever Gauzy #664 (Ever Works side: Ever Works #458, lane IE, POST /api/integrations/v1/inbox/items).

Per-user preference (the owner's control)

  • Storage: new plugin table ever_works_inbox_preference (per user and tenant; the kinds a person excludes, JSON). No row = every kind on, so a kind added later starts on too. The Inbox plugin has no per-user settings store (only an organization one), so this lives in the EW plugin.
  • Migration: 1791000003510-CreateEverWorksInboxPreference, in the EW plugin's migration set (only registered when EVER_WORKS_ENABLED=true), Postgres / SQLite / MySQL, idempotent (CREATE TABLE IF NOT EXISTS, probed indexes/FKs), serialised on Postgres, reversible, additive (no existing table changes, no data written). Unique (tenantId, userId); tenant/org FKs cascade.
  • API: GET / PUT /api/integration/ever-works/me/inbox-preferences (INBOX_VIEW; the person is always the caller, never a parameter; no-store). The answer also lists, per mapped organization, whether the person's items are sent and why not (not_paired, direction, sync_setting_off, mapping_disabled, connection_inactive).
  • Admin view: GET …/mappings/:id/people rows carry inboxToEverWorks (the paired person's effective kinds, read-only); the People tab shows "all kinds" / "5 of 7 kinds (no emails, …)".
  • UI: Settings → Ever Works inbox ("Send to Ever Works", one toggle per kind), shown only once the API serves the route to the signed-in user. i18n in the plugin's EVER_WORKS namespace.

Capture + push

  • Seam (additive, @gauzy/contracts + @gauzy/plugin-inbox): INBOX_ITEM_FEED (observers told after every InboxService write: create / re-delivery, reply, one recipient's state, mark-all-read; plus readRecipient, recipientsChangedSince, openRecipientsOf) and INBOX_RECIPIENT_ACTIONS (= InboxService.reply / setState). Same pattern as INBOX_EMAIL_HANDOFFS; the Inbox still depends on no integration, and an observer can never fail or delay a write. toInboxItem moved to inbox-item.mapper.ts (still exported from inbox.service).
  • Guards (capture drops early, the push re-checks everything):
    • not Ever Works' own (source != EVER_WORKS, loop guard) and not one of the integration's own notices (ever_works_notice, docs conflicts: a dead-letter notice pushed through the sync that stopped would loop);
    • recipient paired with an EW member;
    • mapping's inbox direction writes to EW. The domain now allows both | ew_to_gauzy | gauzy_to_ew | off; new mappings default to both; existing mappings stored ew_to_gauzy explicitly at creation, so nothing changes for them until an admin switches;
    • InboxItem entity setting on;
    • the person's preference sends the item's kind;
    • task-related items: isTaskSyncAllowed(task, 'ever-platform').
    • also: what the sync itself writes (EverWorksSyncContext, loop guard 1); read/snooze-only changes are not queued (no such state on a filed EW item).
  • Queue: one coalesced ever_works_outbound_change per recipient row (GAUZY_INBOX_ITEM): retries, dead-letter, lease fence as every push. A 10-minute recovery sweep (cursor kept across sweeps) re-queues a change whose notice was missed in the last hour — a never-sent item only when it changed after the inbox started going to EW (new mapping setting INBOX_PUSH_SINCE, set on create / when the direction starts writing), so it never backfills quietly — and archives copies whose recipient row is gone (missingRecipients).
  • Link: GAUZY_INBOX_ITEM = recipient row id ↔ EW item id + version + snapshot (g: title, body, choices, state, resolution as Gauzy last sent them; e: EW's own state/unread as last seen). Only fields Gauzy moved since are patched, so an EW user's answer/archive is never undone (loop guard 2); only what EW moved since last seen is applied inbound, so an event never undoes a Gauzy change still waiting. Every EW answer (event, PATCH response after a 412, POST replay) is reconciled into Gauzy before its version is recorded.
  • Calls: POST /inbox/items (deterministic Idempotency-Key, externalId replay; a 404 = an EW without the route yet → deferred 6 h, no dead letter), then PATCH /inbox/items/:id with If-Match; a 412 is retried once on details.current (else a GET); 404 on PATCH unlinks. An item archived before it went out is not filed; answered/archived at filing → patched right after.
  • Kind mapping: QUESTION → question, APPROVAL → approval (no options → Approve/Reject, the ids Gauzy accepts), ESCALATION → question (options kept as choices), NOTICE → notice (mention/assignment when metadata.notificationType is Mention/Assignment), EMAIL → email, anything else → other. Recorded in the README.
  • Emails: email: { from, to, subject, receivedAt } (the stored message where the Inbox email features run, else the item's metadata) + the body as plain text escaped for Markdown.

Review rounds (Greptile, Copilot, CodeRabbit)

All 31 inline findings fixed and answered in their threads (55a0019022, 20c151c0f3): observers never awaited; class-typed optional deps without | null (it emitted Object metadata → nothing injected; spec pins the metadata); reconciliation of EW answers before recording versions; per-field snapshot moves; archive moves only from EW's archive flag; removed choices cleared; corrected answers re-state resolved; re-paired recipients skipped; exact backfill/preview filtering; inbox phase scopes split; preference saves compare-and-set; removed recipients announced and swept.

Inbound for Gauzy-origin items

inbox.item.* of a filed copy → EverWorksInboxService.apply routes it to the push service (never a second, EVER_WORKS item in Gauzy): reply/decision → InboxService.reply, archive / unarchive / read / unread → setState, all signed in as the recipient (the reply reaches the item's source like a Gauzy reply). Versions only go up; an item answered in Gauzy first keeps Gauzy's answer; a copy whose link is not recorded yet (externalId names an existing Gauzy recipient row) waits (deferred); deleted in EW → unlinked, Gauzy item stays.

Initial sync

The inbox phase gains an export (G → EW) that queues the open Gauzy-origin items of paired people (preferences applied) only with the new mapping flag backfillGauzyInbox (default off), settable on PUT …/mappings/:id and the Entities and directions tab. GET …/sync/preview shows the Gauzy side of the inbox phase (gauzy: { total, linked }) either way, and creates.inEverWorks once the backfill is on.

Also

  • Inbox page deep link #/pages/inbox?item=<id> (the EW copy's sourceUrl); sourceUrl needs CLIENT_BASE_URL (else APP_LINK), HTTPS.
  • Mock EW server: MockEverWorksInboxWrite (POST/PATCH /inbox/items, replay, If-Match / 412, EW-user helpers).
  • READMEs: EW plugin (Inbox both ways, per-user control, kind table, /me routes, CLIENT_BASE_URL, backfill), Inbox plugin (observer seam), EW UI plugin.

Specs

packages/plugins/integration-ever-works/src/lib/inbox/ever-works-inbox-push.service.spec.ts — each fails without its guard:

  • EW-origin items not pushed back (capture + push); integration notices / sync writes not captured;
  • unpaired recipient not pushed;
  • a preference that excludes emails: email skipped, other kinds still go;
  • direction off / ew_to_gauzy push nothing; gauzy_to_ew pushes;
  • InboxItem entity setting off → nothing;
  • task syncScope: none (by taskId and by primary link) → nothing; ever-platform goes with related.taskId;
  • replay idempotency: lost link + expired Idempotency-Key → same externalId answers the copy filed before (1 EW item);
  • two recipients → two copies with their own externalId;
  • PATCH with If-Match only for changed fields; nothing changed → nothing sent; 412 → retried on the current version without undoing the EW user's read;
  • answered in Gauzy → resolved + resolution; archived → archived; archived before filing → not filed;
  • inbound answer applied once as the recipient (same event again → version skip; later event → no second reply; the next push sends nothing back); Gauzy-first answer kept; EW archive applied and not echoed; pending link → deferred; deleted → unlinked;
  • backfill page (open, Gauzy-origin, paired, preference-filtered) and counts; preferences defaults / partial update / reasons.

Also: database/inbox-preference-migration.db.spec.ts (SQLite up/up/down/up, unique, tenant cascade), ever-works-config.spec.ts, Inbox inbox.db.spec.ts observers block (SQLite: notified on create / re-delivery / reply / state, a throwing observer never fails a write, feed reads), UI specs (preferences page, directions backfill, People summary, plugin, availability service, inbox page deep link).

Contract (Ever Works side merged: ever-works/ever-works#3010, v1-draft.17 / CR-15)

Adapted in 9ab2e85cd9:

  • PATCH always carries If-Match (Ever Works refuses one without); an unknown version defers.
  • A 412 is the member's change: GET the copy, apply the member's answer / read / archive to Gauzy, then send Gauzy's change once on the current version. Gauzy never sends its own answer over the member's (the link records who answered there).
  • state: 'resolved' comes back as status answered and choices as options: both read as such.
  • No Idempotency-Key of our own on POST: externalId dedupes permanently and its replay answers the copy as it is now.
  • integrationKeyId vs the connection's whoami.keyId: an item this key filed is never imported back as an Ever Works item (extra loop guard); another integration's item is imported as before.
  • Hard deletes: inbox.item.deleted (or a 404 on PATCH) unlinks the copy and keeps the link as a tombstone, so it is never filed again.
  • related ids only with their read scope; a choiceId only among the copy's choices (an answer past the tenth option goes as text); escalations are filed as question.
  • Still assumed: externalId = <inbox item id>:<recipient row id> (each recipient gets its own copy; it never repeats across organizations, so 409 duplicate_external_ref cannot happen); an empty body is accepted; sourceUrl is optional.

Questions for the owner (recorded, not blocking)

  • Mentions / assignments: Gauzy's mention and assignment notifications are EmployeeNotifications (the Inbox's Notifications tab), not Inbox items, so today nothing maps to mention / assignment except a notice that states metadata.notificationType. Mirroring EmployeeNotifications too is a follow-up if wanted.
  • An item a person excludes after it was filed: its EW copy is left as it is (no archive). Say if it should be archived instead.

Prod release notes (Gauzy release process, not this lane)

  • One new table (plugin migration, only with EVER_WORKS_ENABLED=true): backups first, as for every migration.
  • Safe before the EW side (lane IE) is deployed: an EW that answers 404 to POST /inbox/items defers each copy (retried every 6 hours, no dead letter, nothing lost); existing mappings keep ew_to_gauzy anyway, only new mappings start at both.
  • Set CLIENT_BASE_URL (already set on hosted deployments) for the link back.
  • Key scope: inbox:write (in the "Ever Gauzy" preset already).

🤖 Generated with Claude Code