feat(ever-works): send the Gauzy Inbox's own items to Ever Works, per person and kind
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 whenEVER_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/peoplerows carryinboxToEverWorks(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_WORKSnamespace.
Capture + push
- Seam (additive,
@gauzy/contracts+@gauzy/plugin-inbox):INBOX_ITEM_FEED(observers told after everyInboxServicewrite: create / re-delivery, reply, one recipient's state, mark-all-read; plusreadRecipient,recipientsChangedSince,openRecipientsOf) andINBOX_RECIPIENT_ACTIONS(=InboxService.reply/setState). Same pattern asINBOX_EMAIL_HANDOFFS; the Inbox still depends on no integration, and an observer can never fail or delay a write.toInboxItemmoved toinbox-item.mapper.ts(still exported frominbox.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
inboxdirection writes to EW. The domain now allowsboth | ew_to_gauzy | gauzy_to_ew | off; new mappings default toboth; existing mappings storedew_to_gauzyexplicitly at creation, so nothing changes for them until an admin switches; InboxItementity 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).
- not Ever Works' own (
- Queue: one coalesced
ever_works_outbound_changeper 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 settingINBOX_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(deterministicIdempotency-Key,externalIdreplay; a404= an EW without the route yet → deferred 6 h, no dead letter), thenPATCH /inbox/items/:idwithIf-Match; a412is retried once ondetails.current(else aGET);404on 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.notificationTypeisMention/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'ssourceUrl);sourceUrlneedsCLIENT_BASE_URL(elseAPP_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,
/meroutes,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_gauzypush nothing;gauzy_to_ewpushes; InboxItementity setting off → nothing;- task
syncScope: none(bytaskIdand by primary link) → nothing;ever-platformgoes withrelated.taskId; - replay idempotency: lost link + expired Idempotency-Key → same
externalIdanswers the copy filed before (1 EW item); - two recipients → two copies with their own
externalId; - PATCH with
If-Matchonly 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:
PATCHalways carriesIf-Match(Ever Works refuses one without); an unknown version defers.- A
412is the member's change:GETthe 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 statusansweredand choices asoptions: both read as such.- No Idempotency-Key of our own on
POST:externalIddedupes permanently and its replay answers the copy as it is now. integrationKeyIdvs the connection'swhoami.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 a404onPATCH) unlinks the copy and keeps the link as a tombstone, so it is never filed again. relatedids only with their read scope; achoiceIdonly among the copy's choices (an answer past the tenth option goes as text); escalations are filed asquestion.- Still assumed:
externalId = <inbox item id>:<recipient row id>(each recipient gets its own copy; it never repeats across organizations, so409 duplicate_external_refcannot happen); an emptybodyis accepted;sourceUrlis 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 tomention/assignmentexcept a notice that statesmetadata.notificationType. MirroringEmployeeNotifications 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
404toPOST /inbox/itemsdefers each copy (retried every 6 hours, no dead letter, nothing lost); existing mappings keepew_to_gauzyanyway, only new mappings start atboth. - 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