Commit Graph

1295 Commits

Author SHA1 Message Date
Thomas Trompette cbcfba0de2 feat(workflow): dedicated updateWorkflowVersionTrigger mutation + close version CRUD holes (#23207)
Prerequisite for the workflow-core soft-ref migration: every
`workflowVersion` content write must go through a dedicated,
draft-guarded server mutation so it can later be wrapped in a
transactional core mirror. This closes the generic-CRUD holes that let
writes bypass that path.

## Part A - dedicated `updateWorkflowVersionTrigger` mutation
The builder saved a version's trigger through generic
`updateOneWorkflowVersion` - the only content write not going through a
dedicated mutation. Added:
- Server: `updateWorkflowVersionTrigger(input: { workflowVersionId,
trigger })` resolver +
`WorkflowVersionStepWorkspaceService.updateWorkflowVersionTrigger`,
draft-guarded via `getValidatedDraftWorkflowVersion` then
`updateWorkflowVersionStepsAndTrigger` (reuses existing write logic).
- Front: `useUpdateWorkflowVersionTrigger` now calls the dedicated
mutation instead of `useUpdateOneRecord`.

## Part B - restrict generic `updateOneWorkflowVersion`
`validateWorkflowVersionForUpdateOne` previously allowed writing
`trigger`, `position`, `workflowId` (re-parenting),
`coreWorkflowVersionId`, and let `steps: null` slip through on a draft.
It now rejects any update that sets `steps`, `trigger`, `status`,
`workflowId`, or `coreWorkflowVersionId`, or that clears the `name`,
while still allowing a plain rename. (A name-only allowlist was tried
first but blocked legitimate renames - at the pre-hook the generic
update payload is not single-key - so it was replaced by this denylist,
verified live.)

## Part C - close the destroy/restore hole
`workflowVersion` had no `destroyOne/destroyMany/restoreOne/restoreMany`
query hooks, so a caller with object permission could hard-destroy any
version (including active) or resurrect one with no validation. Added
pre-hooks that forbid all four via the API ("Method not allowed"),
matching the existing forbidden generic mutations (`createOne`,
`deleteMany`, ...). Rationale: there is no legitimate API use for
standalone version destroy/restore - retention purging happens through
the trash-cleanup cron (internal, not hook-gated) and restore happens
through the workflow-restore cascade or create-draft-from-version.

## Tests
Integration specs that set a trigger through the generic mutation were
migrated to the new `updateWorkflowVersionTrigger` mutation (new
`update-workflow-version-trigger.util.ts`). Unit test for the front hook
updated.

## Verification
- `twenty-server` + `twenty-front` typecheck: clean.
- oxlint + oxfmt on all changed files: clean.
- `graphql.ts` regenerated for the new mutation; its types match the
server DTOs exactly. Local `graphql:generate` introspects a running
server, so it only succeeds against a server built from this branch - CI
regenerates against the PR server and verifies.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23207?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->

---------

Co-authored-by: prastoin <paul@twenty.com>
2026-07-24 14:32:09 +00:00
Raphaël Bosi d1c6b8ee72 Show relation record labels instead of UUIDs in dashboard charts (#23163)
https://github.com/user-attachments/assets/d012a013-2c90-49a1-a27e-b8e4b684a84f



Charts grouped by a relation without a sub-field rendered raw FK UUIDs
on axis ticks, legends and tooltips. The server now batch-resolves the
grouped record ids to their label identifier through a permission-scoped
query and formats every bucket with the record's display name.
Unresolvable records (deleted or not readable) render as Unknown and
their ids are stripped from the response payload. Same-named records get
an ordinal suffix so their buckets don't merge. Covers bar, line and
pie, plain and morph relations.

```mermaid
flowchart TD
    A["Dashboard widget load"] --> B["Chart data service<br/>(bar / line / pie)"]
    B --> C["executeGroupByQuery:<br/>group by relation FK id,<br/>ORDER BY target label identifier,<br/>scoped to source object permissions"]
    C --> D["filterOutEmptyChartBuckets"]
    D --> E{"Bare relation axis?<br/>(no sub-field)"}

    subgraph RL["ChartRelationLabelService.resolveRelationLabels"]
        direction TB
        G1["Collect distinct record ids<br/>per target object"] --> G2["Batch SELECT label identifier columns,<br/>scoped to TARGET object permissions"]
        G2 --> G3["buildRawLabelByRecordId:<br/>display name per record"]
        G3 --> G4["buildUniqueRelationLabels:<br/>suffix duplicates, Unknown for unresolved"]
    end

    E -- No --> H["formatDimensionValue per bucket"]
    E -- Yes --> G1
    G4 --> H
    H --> I["Strip unresolved ids from<br/>formattedToRawLookup"]
    I --> J["Chart DTO to frontend"]
```

The chart settings sub-field dropdown gains a Record option to group by
the related record itself, and now only offers sub-fields the backend
accepts (system fields like a workspace member's updatedBy were
selectable but rejected at query time). Chart-data errors are now logged
server-side.

Also fixes two latent bugs on this path: sorting a bare-relation chart
by field threw `Cannot orderBy unknown field: agentId`, and the pie
chart truncated slices before sorting. The AI dashboard tool guidance
and the seeded dashboards no longer force the sub-field workaround.

The group-by query orders buckets by the related record's label
identifier at the database level (the engine now accepts ordering by a
target field when grouping by its id), so with more than 100 distinct
related records the surviving buckets match the label order.
2026-07-24 14:12:22 +00:00
Thomas Trompette 96a2456367 feat(workflow): periodic core-consistency check for workflows, versions and triggers (#23103)
Monitoring for the soft-ref migration. The `workflow` /
`workflowVersion` dual-write into core is **best-effort** (async, not
transactional; failures only go to Sentry), so `core.workflow` /
`core.workflowVersion` can silently drift from the workspace source of
truth. This adds a periodic job that detects that drift across **all
three workflow entities** and emits it as metrics.

Supersedes the earlier inline shadow-parity approach that lived on this
branch — that only covered trigger dispatch and added a cache read +
diff to every cron tick and every DB-event batch (too much hot-path
overhead). This is broader and fully off the dispatch path.

## What
A cron (`cron:workflow:core-consistency-check`, every 3 hours, wired
into `cron:register:all`). Per run:
- **Bounded**: `SELECT DISTINCT "workspaceId" FROM core."workflow"` —
only workspaces that actually use workflows (skips the large majority).
- Per such workspace, emit a drift metric per `(entity, driftType)` —
detect-only, plus a triage log:
- **workflow** and **workflowVersion** — `unlinked` / `missingCore` /
`orphanCore` / `fieldMismatch`, via cross-schema `COUNT` aggregates
(core + workspace are the same DB, so indexed joins — no rows pulled
into JS).
- **automated triggers** — the `workflowAutomatedTrigger` table vs the
`workflowAutomatedTriggerMaps` cache: `inTableNotCache` /
`inCacheNotTable` / settings `mismatch`.
- Per-workspace failures are isolated (caught → Sentry) so one bad
workspace does not stop the sweep.

## Why it is efficient
Two central `core.*` queries + a few `COUNT` queries per
*workflow-using* workspace, on a relaxed cadence, off the dispatch path.
Shardable across ticks later if needed.

## Metrics
`workflow-core-consistency/{workflow,version,automated-trigger}/drift`
counters, attribute `driftType`. Dashboards: twentyhq/twenty-infra#800.

## Not in scope
Detect-only — no auto-heal (the existing backfill/rebuild command can
heal). No dispatch or flag changes.

## Test
- The consistency SQL (workflow/version sync counts, orphan counts,
trigger read) validated against a live workspace (it surfaced real drift
there — unlinked versions + an orphan core version). The whole
cron→service→SQL→metric pipeline is proven live: the cron is already
emitting real drift counters on a running server.
- Unit specs for the service (clean → no metric; per-entity drift per
dimension; per-workspace error isolation).
- Command boots and registers via `cron:register:all` (verified).
Typecheck + lint clean.
2026-07-23 12:30:12 +00:00
neo773 0489d39f2b fix(messaging): stop group and bulk emails from creating contacts (#23137)
Group addresses were only checked against the message sender, so
anything arriving as reply-to or cc slipped through and became a
contact. Now checked per participant at contact creation.

The group word list can't keep up with real senders (posts-recap@,
showinfo@, follow-suggestions@), so bulk-mail headers back it up:
`List-Unsubscribe, List-Id, Precedence, Auto-Submitted` (Industry
standard headers)

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23137?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-22 17:17:45 +05:30
Raphaël Bosi 578d69e4fb Fix update events reporting untouched columns as changed (#22797)
## Before


https://github.com/user-attachments/assets/b02dbd50-87c9-4699-a6a9-254a4c8b9182

The image uploaded during the onboarding is deleted and doesn't appear
in the animation

## After


https://github.com/user-attachments/assets/9443b837-044e-47c6-b2df-c392c238e935

The Image appears in the animation

## Description

TwentyORM's `.save()` emits UPDATE events whose `after` record carries
default (empty) values for columns that weren't written: TypeORM
null-injects untouched nullable columns on the entity in place, and
`formatResult` turns those into empty strings. So a partial update (e.g.
renaming a workspace member) reports untouched fields like `avatarUrl`
and `userEmail` as changed, which is what made the avatar-file-deletion
listener delete the picture uploaded during onboarding. The same stale
data also reaches webhooks, workflow/logic-function triggers and record
subscriptions.

Fix: `save()` was the only write path building its event `after` from
the in-memory payload instead of re-reading the row. `update()`,
`upsert()` and `softDelete()` all re-SELECT after the write, so `save()`
now does the same.

`withDeleted` is on both the before and the after find, since `save({
id, deletedAt })` and `save({ id, deletedAt: null })` are valid
soft-delete and restore, and an asymmetric find would leave a restored
row with no matching before-record.

Worth noting: a genuinely no-op save now emits no update event, where it
previously emitted one with a bogus diff.
2026-07-21 17:44:18 +02:00
Félix Malfait 3ad3e8bd1a feat: kanban, calendar and group-by table layouts for dashboard view widgets (#22963)
## Context

Dashboard view widgets previously only rendered flat tables. This PR
ships the full feature: **Table with group-by**, **Kanban**, and
**Calendar** layouts for dashboard view widgets — server API + frontend,
end-to-end. (Originally staged as a 4-PR stack — #22966, #22967, #22968
— consolidated here per review.)

## Server / API

- **View typing.** Adds `KANBAN_WIDGET` and `CALENDAR_WIDGET` to
`ViewType` (following the `TABLE_WIDGET` precedent) so widget-backing
views keep their layout in `view.type` while staying excluded from
record-index pickers. Shared `getViewLayoutFromViewType()` maps widget
types to their base layout; `isWidgetViewType()` centralizes the
exclusions that were previously hardcoded per-site.
- **Migrations.** Two fast instance commands (**2.23**): `ALTER TYPE
core.view_type_enum ADD VALUE` for both values, and a widened
`CHK_VIEW_CALENDAR_INTEGRITY` constraint covering `CALENDAR_WIDGET`
(entity `@Check` updated for fresh installs).
- **Validation.** `FlatViewValidatorService` keys kanban/calendar
validation on the mapped layout, so widget views get the same invariants
as index views (kanban needs a groupable group-by field; calendar needs
a date field + layout). Calendar widget views default to month; a
non-month (DAY/WEEK) layout is rejected at the API level **unless** the
`IS_CALENDAR_WEEK_VIEW_ENABLED` feature flag is enabled for the
workspace — the same flag that gates day/week on index calendars.
- **API.** `upsertViewWidget` (LAYOUTS permission) accepts a nested
`view` settings input (`type`, `mainGroupByFieldMetadataId`,
`shouldHideEmptyGroups`, kanban aggregate/column-width, calendar
layout/fields). Routes through the standard update path, so `viewGroups`
auto-generate from SELECT options exactly like index views. Only widget
view types accepted; only `RECORD_TABLE` widgets can change view
settings.
- **AI tools.** `create-complete-dashboard` + `create_view` now
use/allow the `*_WIDGET` types (previously they created plain `TABLE`
views that leak into index pickers).

## Frontend

**Settings panel.** The **Source** (object) row comes first, since which
layouts are available depends on it. The **Layout** row below is a
working dropdown (Table / Kanban / Calendar); layouts the source object
can't support are **disabled with a hint** ("Needs a Select field" /
"Needs a Date field") rather than hidden. Group-by row (select fields;
searchable) with a **Hide empty groups** toggle while grouped; **Date
field** row replaces Group by while Calendar is active, and — when the
`IS_CALENDAR_WEEK_VIEW_ENABLED` flag is on — a **Calendar view** row
(Day / Week / Month) appears beside it; **Limit** row hidden while
grouped (only the flat virtualized loader enforces it). Kanban keeps its
group-by locked (no `None` option).

**Instant edit-mode preview.** Draft snapshots carry `viewGroups`;
picking a group-by synthesizes them client-side
(`buildDraftViewGroupsForFieldMetadataItem`, mirroring the server&#39;s
generation), so grouped tables/boards preview immediately before
dashboard save. On save, `upsertViewWidget` responses hand back the
server-generated groups, which replace the client-generated ones in the
persisted snapshot.

**Renderers.** `RecordTableWidgetRendererContent` branches on the
backing view&#39;s layout: `RecordBoardWidget` (wraps the standard
`RecordBoardContainer`) and `RecordCalendarWidget` (mounts the existing
`RecordCalendar`, which renders month / day / week) inside the same
per-widget provider sandbox the table uses.

**Read-only semantics.** Two flags with distinct scopes, each documented
on its state:
- `isRecordBoardViewSettingsReadOnlyComponentState` — locks the board
chrome that edits view settings (add group, column reorder/resize/menu,
aggregates); **card drag still updates records** under object
permissions.
- `isRecordCalendarReadOnlyComponentState` — widget calendars are
read-only by default (no drag, no add-new, no in-calendar layout
switch); cards open the side panel. The one exception, behind
`IS_CALENDAR_WEEK_VIEW_ENABLED`: a **live (non edit-mode) day/week**
widget calendar allows drag-to-reschedule and record creation under
object permissions. Month calendars and edit-mode previews stay
read-only.

**Calendar state componentization.** The calendar module&#39;s three
settings move from global atoms to component states keyed on
`RecordCalendarComponentInstanceContext` (same pattern as record-board),
so several calendar widgets and an index-page calendar can coexist
without leaking state. All readers resolve the ambient instance;
calendar unit tests updated.

**Multi-instance fixes that also fix index pages:** record drag states
were written against a different instance than every reader resolves
(now use the ambient instance); the board sticky-header DOM id is
namespaced per board; dragged board cards portal to `document.body`
while dragging so react-grid-layout&#39;s transforms can&#39;t offset
the clone from the pointer.

## Scope (v1)

- Widget calendars are month-only and read-only by default. With
`IS_CALENDAR_WEEK_VIEW_ENABLED` enabled, day/week layouts become
selectable (UI + API) and live day/week widget calendars support
drag-to-reschedule and record creation under object permissions.
- Widget group-by offers SELECT fields only (server auto-generates
groups from options; widgets have no per-record add-group flow).

## Tests

- Integration: `upsert-view-widget-view-settings.integration-spec.ts` (9
tests — group auto-creation, invalid type/field rejections, non-month
calendar widget rejected while the week/day flag is off and accepted
once it&#39;s enabled, combined settings+fields call); pre-existing
`upsert-view-widget` suite (20) green.
- Front: new suites for draft view-group generation and snapshot
clone/build utils; calendar suites componentized; full `twenty-front`
jest, typecheck, oxlint green; `twenty-server` typecheck + lint green.
- Browser-verified end-to-end (real dev server + seeded workspace):
configure → live edit-mode preview → save → reload for all three
layouts; measured drag with pointer inside the card; index-page calendar
re-verified (with the week/day flag enabled).

https://claude.ai/code/session_01E5N87kwwZWhDtEQaP72cMf
2026-07-21 15:41:08 +02:00
Thomas Trompette 98c71d7b3d fix(server): recover workflow runs whose queue job was lost - monitoring only (#22995)
## Context

A workflow run can get permanently stuck in RUNNING when the queue job
executing a step dies without failing (worker crash/restart, lost BullMQ
job). The step stays `RUNNING` in the persisted state, nothing ever
recomputes the run status, and the run never terminates. If a user
clicks Stop, it wedges in STOPPING instead (the stuck-STOPPING sweeper
from #22900 then catches it after 1h, but only because of the manual
stop). Self-hosters also have no way to tell that a job was lost, or
when.

## What this PR does

### Detect runs stuck in RUNNING (monitoring only, no finalization yet)

New `handleStuckRunningRunsForWorkspace` in the staled-runs sweeper
(same cron/job/CLI wiring as the stuck-STOPPING recovery):

- Targets RUNNING runs with `updatedAt` older than 1h (`updatedAt`
refreshes on every step-info write, so staleness means zero progress).
- Skips any run that still has a job in the queue: new `getInFlightJobs`
on the message queue driver
(active/waiting/waiting-children/paused/prioritized/delayed), matched by
run-id-prefixed job id with a `job.data.workflowRunId` fallback for jobs
enqueued before this deploys.
- A truly orphaned run (orphaned RUNNING step, lost between two steps,
failed branch, or finished-but-never-finalized) is **flagged, not
finalized**: warn log + `WorkflowRunStuckRunningDetected` metric + entry
in a per-workspace cache.
- On every subsequent sweep, flagged runs are re-checked. One that ended
or got a new queue job on its own is recorded as
`WorkflowRunStuckRunningFalsePositive` (warn log with the status it
reached) and unflagged. The cron keeps sweeping a workspace as long as
it has flagged runs.

This validates the detection before it is allowed to act: if flagged
runs never resolve on their own (no false positives) while `Detected`
counts real incidents, a follow-up PR can turn the flag into an actual
finalization (fail with a clear "job lost" error so Retry works). Runs
waiting on PENDING steps (delay, form) are never flagged.

### Make queue jobs traceable to their run

All RunWorkflowJob dispatches now set the job id prefix to the workflow
run id, so BullMQ job ids become `<workflowRunId>-<uuid>`. Worker logs
(`Processing job <id>` / `processed`) and Redis job keys are now
greppable by run id. A new opt-in `allowDuplicatedPrefixes` queue option
bypasses the one-waiting-job-per-id dedup (which would otherwise drop
parallel-branch continuations); existing `id` users keep dedup by
default.

### Observability

- `stalled` worker event listener: warn log + new `JobStalled` metric —
emitted when BullMQ detects a job whose worker stopped renewing its lock
(i.e. died mid-job).
- `WorkflowRunStuckRunningDetected` /
`WorkflowRunStuckRunningFalsePositive` metrics as described above.

## Out of scope (follow-ups)

- Actually finalizing flagged runs once monitoring shows no false
positives.
- Recovering lost *delayed* resume jobs (PENDING delay step whose
scheduled job vanished).
- Persisting job ids on the run entity — unnecessary given derived ids.

## Testing

- 11 unit tests for the monitoring sweeper (flagging, id-prefix + data
fallback in-flight guards, pending skip, failed-branch precedence,
false-positive tracking, still-stuck retention, error isolation,
never-finalizes) plus find-options specs; 613 tests pass across workflow
and message-queue modules.
- Not covered: end-to-end kill-the-worker scenario against a real queue.
2026-07-20 14:21:33 +02:00
Thomas Trompette 87729a2822 feat(workflow): backfill + dual-write for core workflow entity (#22776)
Workflow-side soft-ref sync — the `coreWorkflowId` mirror of the merged
version side (#22821 / #22940 / #22944 / #22961). Rebased onto current
main; supersedes the original shared-UUID version of this PR.

## What
Gives `core.workflow` a per-workspace copy of each workflow (`name`,
`lastPublishedVersionId`), soft-reffed from the workspace record via
`coreWorkflowId`, so app-shipped workflows have a core home. Does not
touch reads/dispatch (that's Phase B).

- **Sync service** (`WorkflowCoreSyncService`): core rows get their own
id (`uuidv5(workspaceId:recordId)`), the workspace record links via
`coreWorkflowId` (written back after the upsert), and the write-back is
**guarded** on the `coreWorkflowId` field being present (skips with a
warning otherwise — mirrors #22940). Injected repo renamed
`coreWorkflowRepository`.
- **Dual-write listener** on the `workflow` object:
CREATED/UPDATED/RESTORED upsert, DELETED/DESTROYED delete by
`coreWorkflowId`. Always-on; failures routed to Sentry so they never
break the user write.
- **2-20 backfill** (`backfill-workflow-to-core`): reads via the
provided `RunOnWorkspaceArgs.dataSource`, upserts each workspace
workflow into core.
- **2-22 provisioning** (mirrors #22944/#22961):
- `add-workflow-core-soft-ref-field`: adds the `coreWorkflowId` system
field on existing workspaces (flat-entity legacy migration).
- `backfill-workflow-core-links`: full rebuild — per workspace, in one
raw-SQL transaction, wipes all `core.workflow` rows, inserts a fresh
own-id row per workflow, and re-links every record. (No trigger-map
cache to invalidate on `core.workflow`.)

Simpler than the version side: `core.workflow` has no
one-active-per-workflow index and no trigger-map cache, and it was never
backfilled in prod, so there are no legacy shared-id rows.

## Test
Fresh `database:reset` + full sequence (2-20 backfill → 2-22 add-field →
2-22 rebuild link): 4/4 workspace records linked via `coreWorkflowId`
(id != coreWorkflowId), links resolve, **0 dangling**, no duplicate core
rows, names populated. Typecheck + lint + oxfmt clean.
2026-07-20 14:04:32 +02:00
neo773 c04714b9e0 fix(messaging): register and harden webhook subscription renewal cron (#23006)
The renewal cron was never wired into cron:register:all, so
Gmail/Calendar/Graph watches were never renewed and went dark ~7 days
after connect (the max watch lifetime all three providers allow).
Register it, fan renewals out as per-channel queue jobs scoped to active
workspaces, retry FAILED channels, and recreate Google Calendar watches
before stopping the old one.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23006?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-20 17:26:47 +05:30
Félix Malfait 7e133a4930 Converge email recipient fields on existing patterns: shared parser/formatter, search-index members, one display-name rule (#22997)
# Why

Follow-up to #22668, addressing @charlesBochet's five post-merge review
comments. They all point the same direction: the recipient fields
rebuilt things the codebase already had. This PR converges on the
existing patterns where that holds up, and answers on the threads where
it deliberately does not.

# What changed, per comment

**Parser duplication
([comment](https://github.com/twentyhq/twenty/pull/22668#discussion_r3553064242))**:
`parseEmailAddressList` now lives in twenty-shared (addressparser, group
flattening, try/catch). The server's `safeParseEmailAddresses` delegates
to it, the front wrapper keeps only paste normalization (newlines to
commas) and invalid-token preservation for red chips. The
`addressparser` dependency moves from twenty-front to twenty-shared.
Side effect worth knowing: RFC 5322 group members in inbound To/Cc
headers were previously dropped entirely (group entries have no
top-level address, so the filter removed them); flattening now imports
those participants. Covered by a new regression test.

**Formatter duplication
([comment](https://github.com/twentyhq/twenty/pull/22668#discussion_r3553064243))**:
`formatEmailAddress` (quote only when specials require it) lives in
twenty-shared. The composer chips and the server's
`formatMessageFromHeader` both delegate to it. The Gmail From header
output is byte-identical: the name is mime-encoded first and encoded
words never contain characters that trigger quoting. CodeQL then caught
that the quoting (ported from the original front util) escaped quotes
but not backslashes, letting a crafted name close the quoted string
early; escaping now covers both as RFC 5322 quoted-pairs, with a
containment test proving a hostile name cannot split into extra
recipients on reparse.

**Member search divergence
([comment](https://github.com/twentyhq/twenty/pull/22668#discussion_r3553064231))**:
suggestions now search WorkspaceMember through the search index in the
same `useObjectRecordSearchRecords` call as Person (one ranked query),
and enrich hits from `currentWorkspaceMembersState`, exactly like
`SettingsRoleAssignmentWorkspaceMemberPickerDropdown`. The client-side
`filterBySearchQuery` pass is gone. The hook is now what the comment
described: the merge of context people, searched people, and members
into one ranked list, rendered with the same
`SelectableList`/`MenuItemAvatar` primitives the pickers use. Also fixed
while in there: searched person ids are sliced to the suggestion limit
before hydration, so top-ranked people can no longer be crowded out of
the hydration page.

**Chip resolution duplication
([comment](https://github.com/twentyhq/twenty/pull/22668#discussion_r3553064236))**:
the display-name preference is now one rule,
`getEmailIdentityDisplayName`, used by both
`getDisplayNameFromParticipant` (threads) and the composer chip/menu, so
the same address renders identically everywhere. The order is workspace
member, then person, then display name, then handle: when an address
belongs to both a teammate and a Person record, the internal identity
wins (product call from Felix). `BaseChip.maxLabelWidth` is renamed
`maxWidth` to match the twenty-ui `Chip` API. `ParticipantChip` itself
is not used inside the field: it renders a navigating `RecordChip` when
a person is linked, and navigation from the composer destroys the draft
(no draft persistence yet), plus the field chips need
remove/selected/danger/edit affordances it does not have.

**Rebuilding on MultiItemFieldInput
([comment](https://github.com/twentyhq/twenty/pull/22668#discussion_r3553064221))**:
answered on the thread rather than in code, deliberately.
`MultiItemFieldInput` is a dropdown-panel list editor (vertical rows,
one input at a time, bound to record-field contexts and
`FieldMetadataType`), and its own TODO says the API should be refactored
into a hook before growing. The inline wrapping chip row commits batches
(paste), dedupes with a flash, and keeps a persistent inline input with
suggestions; layering that through `renderItem`/`renderInput` would
strain both components. On the menu overlap: after comparing side by
side, the shared surface between `MultiItemFieldMenuItem`'s dropdown and
the chip menu is three `MenuItem` rows with different copy, order, and
neighbors; `MenuItem` is already the shared primitive, and a
config-driven fragment would be indirection without deduplication. If
deeper convergence is wanted, the honest path is the existing TODO
(extract the multi-item state machine into a hook, rebase both editors
on it); that touches the Links/Phones/Emails/Array/Files cell editors
and deserves its own PR.

# Verification

- New twenty-shared suites for the parser and formatter (16 tests),
including parse/format round-trips, the encoded-word case, and the
backslash-escaping containment case.
- Server messaging util specs all pass (70 tests), including new
group-flattening regression tests; From-header spec output unchanged.
- Front email module suites all pass (59 tests) with the slimmed
wrappers.
- Typecheck and lint green on twenty-shared, twenty-front,
twenty-server; oxfmt clean on all three.
- Playwright smoke against the seeded dev stack passes end to end:
context suggestions on the Google company, typed search showing people
and the workspace member row (now served by the search index), Enter
picking the top suggestion, duplicate merge, keyboard delete, chip menu
with clipboard copy, Ctrl+Enter committing the buffer then triggering
send.
2026-07-17 21:20:14 +02:00
neo773 f6612e5a85 fix(server): stop treating Microsoft Graph 401 as a permanent failure (#22989)
In production we are seeing some accounts being occasionally marked as
permanent failure even though when you check with their refresh token it
never actually failed this PR stops treating 401 as permanent failure
and treats them as Transient error.

We already have token refresh stage that runs before the actual import
stage, so if it's an actual revoke token error, it should catch it.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22989?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-17 19:13:50 +05:30
neo773 52b7aebddf fix(server): key connected-account lookup on handle and provider (#22964)
Connect flows (Google, Microsoft, IMAP/SMTP/CalDAV) looked up an
existing connectedAccount by `handle` alone, so connecting a second
account with the same handle but a different provider overwrote the
first instead of inserting a new row (e.g. IMAP inbox clobbering a
calendar-only Google account).

Fix: add the `provider` discriminator to the lookup. Same
provider+handle still updates; a different provider gets its own row.
Integration test covers the Google-then-IMAP case.
2026-07-17 00:28:23 +05:30
Thomas Trompette f4b2968a74 fix(server): finalize workflow runs stuck in STOPPING (#22900)
## Context

A workflow run only reaches STOPPED via the in-flight worker execution:
`stopWorkflowRun` just flips a RUNNING run to `STOPPING` (records
intent), and the `STOPPING -> STOPPED` transition is done later inside
`computeWorkflowRunStatus`, which only runs while a worker is executing
the run's steps.

If no worker is executing the run at that point, nothing ever finalizes
it:
- the worker that owned the run crashed / was killed mid-step (e.g.
under heavy load), or
- a step legitimately sits in RUNNING awaiting an external event that
never arrives (the user stopped it).

Only `ENQUEUED` runs had a staleness sweep, so `STOPPING` (and
`RUNNING`) had no recovery path and would stay stuck indefinitely. This
has been observed in production (~150 runs stuck in `STOPPING` after
manual stops during a migration).

## Change

Extend the existing staled-runs machinery to also finalize runs left in
`STOPPING`:
- New `stuck-stopping-runs-threshold` (1h) +
`getStuckStoppingRunsFindOptions` matching `status = STOPPING AND
updatedAt < now - 1h`. `updatedAt` is a TypeORM update-date column, so
it reliably marks when the run entered `STOPPING`, and 1h stays above
any legitimate in-flight step.
- `handleStuckStoppingRunsForWorkspace` finalizes each match to
`STOPPED` via `endWorkflowRun`, so `endedAt`, step infos and the
`WorkflowRunStopped` metric stay consistent. It pages the backlog with
keyset pagination on `(createdAt, id)`, so a page whose finalizations
all fail can't stay at the front of the query and starve later runs
(failed ones are retried on the next sweep).
- Wired into the same cron (`WorkflowHandleStaledRunsCronJob`, every 10
min), per-workspace job, and the manual `workflow:handle-staled-runs`
command — so ops can also clear an existing backlog immediately. The
staled-ENQUEUED and stuck-STOPPING handlers run independently
(`Promise.allSettled` in the job, separate try/catch in the command), so
a failure in one doesn't block the other.

Stop remains manual and unchanged; this only guarantees a stopped run
eventually reaches `STOPPED`.

## Notes / scope

- No schema change (reuses `updatedAt`), so no migration.
- `RUNNING` runs orphaned by a worker crash have the same missing-net
problem; left out of scope here (this covers the user-triggered STOPPING
case).
- The new detection query scans `status`/`updatedAt` like the existing
ENQUEUED sweep; at very high `workflowRun` volumes an index on `(status,
updatedAt)` would help — same pre-existing consideration as the ENQUEUED
path.

## Tests

Unit tests for `handleStuckStoppingRunsForWorkspace`: no-op when none,
finalizes each match to STOPPED, pages through a multi-page backlog, and
advances past a fully-failed page instead of starving later runs. Plus a
unit test for the `(createdAt, id)` keyset condition in
`getStuckStoppingRunsFindOptions`. Full suite green, lint + typecheck
clean on changed files.

Manually verified on a real instance (Postgres): seeded a `STOPPING` run
aged 2h and ran `workflow:handle-staled-runs` -> transitioned to
`STOPPED` with `endedAt` set; a freshly-`STOPPING` run (updatedAt now)
was correctly left untouched by the 1h threshold.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22900?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-15 16:13:26 +00:00
Thomas Trompette f67eb60c57 feat(workflow): soft-ref core workflow/version (backfill + dual-write) (#22821)
Replaces the shared-UUID model (core row reuses the workspace record id)
with a **soft-ref**: the workspace `workflow`/`workflowVersion` records
carry a nullable `coreWorkflowId`/`coreWorkflowVersionId` pointing to
their **own-id** core rows. This removes the assumption that workspace
record ids are globally unique - which is false, since prefilled/seeded
workflows share ids across workspaces. Supersedes #22776.

## In this PR
**Soft-ref columns (foundation):**
- **twenty-shared** `STANDARD_OBJECTS`:
`workflowVersion.coreWorkflowVersionId` + `workflow.coreWorkflowId` (+
snapshot test).
- **compute utils**: both as system, nullable UUID fields.
- **entity classes**: the bare fields.

**Version soft-ref sync:**
- Core `workflowVersion` rows get their own id, derived
deterministically from `workspaceId + record id` (uuidv5). Deterministic
so the upsert is idempotent: a failed write-back re-derives the same id
and self-heals instead of orphaning rows or colliding on the
one-active-per-workflow index.
- Sync = find-or-create keyed on the workspace record's
`coreWorkflowVersionId`, then write the core id back onto the workspace
record.
- Migrating over pre-soft-ref data: purges any core row whose id equals
the workspace record id before recreating, so old shared-UUID rows
aren't orphaned.
- Version dual-write listener reworked: delete is keyed by the core id
read off `before.coreWorkflowVersionId`.

Verified on a fresh `database:reset` (columns materialize, backfill
produces deterministic own-id rows linked back, idempotent re-run), a
simulated old shared-UUID state (stale rows purged, records re-linked),
and a simulated write-back failure (retry re-links to the same id, no
orphan, active-version index intact).

## Next steps (follow-up work, not in this PR)
1. Workflow-side soft-ref sync mirroring the version side (service,
module, dual-write listener, backfill command).
2. Workspace command to add the two columns to existing workspaces.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22821?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-15 17:50:00 +02:00
Charles Bochet 8e03921372 Add CREATED workspace activation status (read path + enum migration) (#22904)
## Context

Since v2 onboarding (#22303), workspaces are activated **before** the
billing plan step (now the last onboarding step). Users abandoning at
the plan step leave ACTIVE workspaces with a Stripe customer but no
subscription (~60–110/day on cloud, 935+ so far), and no cleanup
mechanism ever touches them: billing webhooks never fire (no
subscription), the suspended-workspaces cron only handles SUSPENDED, the
onboarding cron only handles PENDING_CREATION/ONGOING_CREATION.

Target lifecycle (across two PRs): `PENDING_CREATION → ONGOING_CREATION
→ CREATED → ACTIVE → SUSPENDED → deleted`.

**`CREATED`** = the workspace schema is provisioned but onboarding is
not complete — no billing subscription yet. It is **not** considered
active:

| Concern | CREATED behavior |
|---|---|
| Sign-in / invited teammates joining | allowed (invite-team step
precedes the plan step) |
| Member + metadata loading (app shell) | allowed (user must finish
onboarding) |
| Permissions | real permission checks (no PENDING-style bypass) |
| Version upgrades / workspace migrations | **included** (schema must
not drift) |
| Messaging/calendar/workflow/etc. crons | **excluded** — no background
processing until a plan is chosen |
| PLAN_REQUIRED onboarding lock | unchanged (still derived from
subscription existence) |

## What this PR does (read path only)

The enum addition ships as a **slow** instance command, which can run
after deploy — so nothing in this PR ever **writes** `CREATED`. The
write path (setting it at activation, the cleanup sweep, the backfill of
the existing zombie cohort) is a follow-up PR that ships once this
migration has run everywhere.

- **twenty-shared**: `CREATED` enum value;
`PROVISIONED_WORKSPACE_ACTIVATION_STATUSES` + `isWorkspaceProvisioned`
("schema exists": CREATED | ACTIVE | SUSPENDED), replacing
`isWorkspaceActiveOrSuspended` — all call sites (server member loading,
access-token workspace-member lookup, front metadata-store gates) meant
"has schema/members".
- **Slow instance command** (2.22.0): swaps
`core.workspace_activationStatus_enum` using the
rename→recreate→alter-column idiom. The CHECK constraints on
`core.workspace` embed casts to the enum type and would break the swap —
the command captures them from `pg_constraint`, drops them, swaps the
type, and restores them.
- **Pre-migration-safe queries**: Postgres rejects `IN ('CREATED', ...)`
when the enum value does not exist yet — even for reads, and the
instance-command runner itself queries provisioned workspaces before
migrating (a fresh database could never initialize). All
provisioned-status filters go through a new `activationStatusIn` util
comparing on `"activationStatus"::text`, valid before and after the
migration.
- **Upgrade path**: workspace iterator, command runner, upgrade-status
and workspace-version services iterate CREATED workspaces. Since they
now cover more than ACTIVE/SUSPENDED, the stale names were renamed to
`ProvisionedWorkspaceCommandRunner`, `hasProvisionedWorkspaces`,
`getProvisionedWorkspaceIds`, `loadProvisionedWorkspaces` (the
mechanical import rename in old version-command dirs is why this PR
carries the `ci:allow-previous-version-upgrade-mutation` label).
- **Sign-in**: `throwIfWorkspaceIsNotReadyForSignInUp` accepts CREATED
so invited members can join during onboarding (join authorization itself
is unchanged — enforced upstream in `checkAccessForSignIn`);
`activateWorkspace` idempotent-retry accepts CREATED as a terminal
state.
- **Transitions out of CREATED** (only write ACTIVE — safe to ship now,
dead until the write path lands): the Stripe webhook reactivation branch
also promotes CREATED, and `syncSubscriptionToDatabase` promotes
synchronously; both gated on
`WORKSPACE_ACTIVATING_SUBSCRIPTION_STATUSES` (Active/Trialing —
extracted from `shouldReactivateWorkspace`, behavior-preserving) so an
`incomplete` subscription created by the payment-intent flow before
payment never promotes the workspace.
- Deliberately untouched: all background crons, permission guards, JWT
strategy, PLAN_REQUIRED logic, admin panel (renders the raw status
string).

## Follow-up PR (after this migration has run)
1. `activateWorkspace` sets `hasWorkspaceAnySubscription ? ACTIVE :
CREATED` (billing disabled → always ACTIVE, self-hosted unchanged).
2. Cleanup: suspend CREATED workspaces older than N days (config var),
handing them to the existing suspended pipeline (warn → soft-delete →
destroy).
3. Backfill: cloud-only slow command moving ACTIVE workspaces with no
billingSubscription row (created since Jul 1) to CREATED.

## Verification
- Migration exercised against a real database via the command class: up
→ down → up; `enum_range` and `pg_get_constraintdef` checked after each
step (constraints restored against the new type, `DEFAULT 'INACTIVE'`
preserved).
- Pre-migration safety exercised for real: with the migration rolled
back (enum without CREATED), `run-instance-commands` — the exact
fresh-database CI path that failed before the `::text` fix — completes
cleanly.
- End-to-end with a workspace manually set to CREATED and the branch
server+front running: sign-in issues tokens, `currentUser` loads
workspaceMember(s), the full app loads with no console errors; GraphQL
returns `activationStatus: CREATED`.
- Workspace creation ran end-to-end locally in **both billing modes** on
this branch:
- billing disabled: signup → workspace creation → ACTIVE immediately →
onboarding completes with no plan step → app loads (unchanged behavior);
- billing enabled (Stripe test mode): signup creates the Stripe customer
eagerly → activation ends ACTIVE → subscription-less workspace is pinned
to the plan-required page → no-card trial checkout creates a `trialing`
subscription via `createDirectSubscription`/`syncSubscriptionToDatabase`
→ app loads.
- `twenty-shared` unit tests, server specs on touched services,
`lint:diff-with-main` and `typecheck` for shared/server/front all green;
full CI green.
2026-07-15 17:03:17 +02:00
Joshua Freedman 9f75506896 feat(workflow-tools): add get_logic_function_source tool (#22835)
## What / why

The workflow agent tools let an AI **write** a CODE step's logic
function (`update_logic_function_source`) and **list** logic functions
(`list_logic_function_tools`), but there is no tool to **read** an
existing function's source.

That's fine for greenfield generation — the agent already has in context
whatever code it just wrote. But it's a real gap when editing a function
the agent did **not** author: to safely modify an existing CODE step it
has to see the current source first, and today the only ways to get it
are the frontend (`getLogicFunctionSourceCode` query) or the DB. So the
agent is forced to either guess or ask a human to paste the code.

This adds a small read tool that closes the loop, mirroring the existing
`update_logic_function_source` tool.

## How

- New `get_logic_function_source` tool that calls the existing
`LogicFunctionFromSourceService.getSourceCode({ id, workspaceId })` —
the same service method backing the `getLogicFunctionSourceCode` GraphQL
resolver the frontend already uses. No new service logic.
- Registered in `workflow-tool.workspace-service.ts` alongside
`update_logic_function_source` (the dependency
`logicFunctionFromSourceService` is already injected).
- Unit test covering the success and error paths, matching the `get-*`
tool test convention.

## Notes

- Read-only, additive; no schema or API changes.
- Naturally pairs with `update_logic_function_source`: read → edit →
write.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22835?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-13 17:41:00 +02:00
neo773 9f5d17d1f0 Add receipt metrics and logs to connected account sync webhooks (#22853)
Webhook deliveries from Google and Microsoft were invisible at the app
level: successful notifications produced no logs and no metrics, so
webhook-triggered syncs could not be told apart from cron polling.

Add two counters, connected-account-sync-webhook/received/messaging and
/received/calendar, mirroring the sync-job metric umbrellas, and log a
line whenever a notification triggers a sync. Unmatched subscriptions
keep their existing warn logs.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22853?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-13 14:28:21 +02:00
neo773 f547da4ee9 Gate connected-account webhook subscriptions behind config (#22761)
Now gated behind IS_CONNECTED_ACCOUNT_WEBHOOK_SUBSCRIPTION_ENABLED
(default false).

Merge + deploy twentyhq/twenty-infra#780 first.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22761?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->

Co-authored-by: Charles Bochet <charlesBochet@users.noreply.github.com>
2026-07-10 15:03:53 +05:30
Abdul Rahman a360f9bdec migrate 10 modules off NestjsQueryTypeOrmModule wiring (#22763)
## Summary
Continues the incremental removal of `@ptc-org/nestjs-query`. Migrates
ten modules from `NestjsQueryTypeOrmModule.forFeature` to the standard
`TypeOrmModule.forFeature`. These modules only used `nestjs-query` for
repository registration — none register `NestjsQueryGraphQLModule` /
auto-generated
resolvers — so this is a pure module-wiring swap with no behavior or
schema change.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22763?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->

---------

Co-authored-by: Weiko <corentin@twenty.com>
2026-07-10 09:21:52 +00:00
neo773 3a5545c753 chore: remove IS_MESSAGING_CALENDAR_WEBHOOK_ENABLED flag (#22680)
Messaging/calendar webhook subscriptions are now always on; drop the
feature flag gate and its enum/public-flag registration.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22680?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-08 23:35:35 +02:00
Thomas Trompette ca90a9358f feat(workflow): backfill workspace workflowVersion into core (phase A) (#22663)
## workflowVersion -> core, Phase A

Follows #21674 (Phase 0, merged). Base: `main`.

Populates core `workflowVersion` and keeps it in sync with the workspace
object, so a later phase can switch reads to core. Reads stay on the
workspace object in this PR.

### 1. Backfill (upgrade command)
`BackfillWorkflowVersionToCoreCommand`, a
`@RegisteredWorkspaceCommand('2.20.0', ...)`. Per workspace, reads all
workspace `workflowVersion` records and upserts them into core,
preserving ids (idempotent), dry-run aware.

### 2. Dual-write (always on, not flag-gated)
`WorkflowVersionCoreDualWriteListener` hooks
`@OnDatabaseBatchEvent('workflowVersion', CREATED/UPDATED/DELETED)`
(same mechanism as the existing workflow-version status listener) and
mirrors every mutation into core. Sync failures are logged, never break
the user's write; drift is repaired by re-running the backfill command.

Dual-write is deliberately not behind a flag: reading from core (next
phase) is only safe if core has been continuously in sync since the
backfill. An always-on mirror makes "core is fresh" an invariant, so the
read switch becomes a plain flag flip. The cost is one extra upsert on
infrequent workflowVersion writes.

`IS_WORKFLOW_VERSION_IN_CORE_ENABLED` is reserved for the read switch
(Phase B): dispatch from the `workflowAutomatedTriggerMaps` cache,
runner and builder reading trigger/steps from core. Until then it gates
nothing.

Both the backfill and the listener go through a single
`WorkflowVersionCoreSyncService` (`upsertToCore`/`deleteFromCore`): the
workspace-to-core mapping (`trigger` -> `triggers[]`, plus `steps`,
`status`, `workflowId`) and `workflowAutomatedTriggerMaps` invalidation
live in one place.

### Rollout plan (following phases)
- **B, read switch (flag per workspace):** reads move to core; writes
keep flowing workspace -> listener -> core. Rollback = flip the flag
back, workspace never stopped being source of truth.
- **C, contract (code change):** write paths write trigger/steps to core
directly; workspace `workflowVersion` stays as a thin shell
(nav/relations/search) but drops the trigger/steps columns; listener and
flag removed.

### Not in this PR
- Reconciliation tooling beyond re-running the backfill.
- The read switch (Phase B).
2026-07-08 18:45:57 +02:00
neo773 b5a73ad86a Chore/remove messaging mock specs (#22665)
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22665?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-08 18:37:06 +02:00
neo773 674de0056b feat(messaging): message campaign delivery stats + views (#22661)
Re-land of #22452 (reverted in #22627). Rebuilt on fresh main with
upgrade commands isolated to 2-20 only; no other version's commands
touched.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22661?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-08 15:55:20 +00:00
Pratik Mahajan 72d728ea8e fix(messaging): add sender name to IMAP/SMTP From headers (#22603)
Manual IMAP/SMTP outbound emails were being composed with a
bare email address in the From header, so recipients did not
see the sender's display name.
Gmail already built a proper sender header from Google profile
data, but manually configured IMAP/SMTP accounts had no
equivalent path and fell back to the raw address only.

Fix this by storing the optional sender display name in the
IMAP/SMTP/CALDAV connection parameters, exposing it through
the settings flow and metadata API, and reusing a shared
From-header formatter when composing outbound messages.
The formatter now builds a properly encoded sender header when
a name is available and falls back to the bare email address
when it is not, keeping the behavior safe for blank or missing names.
Gmail keeps using its existing Google-derived display name source;
this change only brings manual accounts up to the same header
formatting standard and removes duplicated formatting logic between
outbound drivers.

After this change, manual SMTP sends and IMAP draft creation include
the configured sender name in the From header, while blank names are
normalized away instead of producing malformed headers.
Existing manual account names are preserved when updates omit the
field, and edited accounts can still explicitly clear it through the
settings flow.

Add focused utility coverage for the shared From-header formatter.

Fixes: #22608

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22603?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->

---------

Co-authored-by: neo773 <neo773@protonmail.com>
2026-07-08 20:29:25 +05:30
Thomas Trompette 6bc4d8efac fix(workflow): batch staled run reset to avoid Postgres param limit (#22654)
## Problem

Self-hosters with a large backlog of workflow runs stuck in `ENQUEUED`
see the recovery job (`WorkflowHandleStaledRunsJob`) fail with:

```
Error: Data validation error.
    at computeTwentyORMException ...
    at WorkspaceSelectQueryBuilder.getMany ...
    at WorkspaceUpdateQueryBuilder.execute ...
    at WorkflowHandleStaledRunsWorkspaceService.handleStaledRunsForWorkspace ...
```

So the very job meant to unblock enqueued runs can never complete, and
runs stay stuck.

## Root cause

`handleStaledRunsForWorkspace` fetched **every** staled run unbounded,
then called `repository.update(allIds, ...)`. That builds a `WHERE id IN
($1, $2, ... $N)`. Inside `WorkspaceUpdateQueryBuilder.execute`, a
"before" `SELECT` runs with that same huge `IN` list; with a big enough
backlog the bind-parameter count exceeds Postgres' limit, the `getMany`
throws a `QueryFailedError`, and `computeTwentyORMException` maps the
resulting PG error code to the generic `PostgresException('Data
validation error.')`.

There's also a secondary `before.length > QUERY_MAX_RECORDS` (200) guard
in the update path that would reject anything over 200 rows even if the
param limit weren't hit.

## Fix

Process staled runs in batches of `QUERY_MAX_RECORDS` (200), looping
until a pass finds none left — the same batching pattern the sibling
clean-runs job already uses. Each update flips the batch from `ENQUEUED`
to `NOT_STARTED`, so the find criteria stops matching them and the loop
terminates. The throttling recompute now runs once at the end, and only
if at least one batch was reset.

## Tests

New unit spec covering:
- no staled runs -> no update, no recompute
- single batch -> correct ids/payload, recompute once
- exactly 200 -> `take: 200`, 200 ids per update
- 450 backlog -> 3 update calls (200/200/50), loops until empty,
recompute exactly once

All 4 pass locally.

## Note

This fixes the recovery job. If runs keep re-accumulating as `ENQUEUED`,
there may be a separate producer-side issue worth investigating.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22654?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-08 13:53:26 +02:00
Thomas Trompette bfeaaa56a3 fix(workflow): handle IS/IS_NOT operand in text and array filters (#22640)
## Problem

Sentry `TWENTY-SERVER-G4F` — `Error: Operand IS not supported for this
filter type` (30k+ occurrences, 15 workspaces, ongoing).

A workflow **Filter** step throws when a step filter carries an
`IS`/`IS_NOT` operand on a text/array field type (`TEXT`,
`MULTI_SELECT`, `EMAILS`, `PHONES`, `ADDRESS`, `LINKS`, `FULL_NAME`,
`ARRAY`, `RAW_JSON`). `evaluateTextAndArrayFilter` only handled
`CONTAINS`/`DOES_NOT_CONTAIN`/`IS_EMPTY`/`IS_NOT_EMPTY` and hit
`default:` → `throw`. The throw propagates out of `FilterWorkflowAction`
and **fails the entire workflow run**.

The current frontend no longer offers `IS`/`IS_NOT` for these types, so
these are **legacy persisted step filters** in older (immutable)
workflow versions that keep executing.

## Fix

Handle `IS`/`IS_NOT` in `evaluateTextAndArrayFilter` as
`contains`/`!contains`, consistent with `evaluateSelectFilter` (chosen
over strict equality because the routed types include arrays/composites
where `==` would silently never match). No existing operand behavior
changes.

## Tests

Added coverage for legacy `IS`/`IS_NOT` on `TEXT` and `MULTI_SELECT`.
Note: the pre-existing `date operands` test failures are
timezone-dependent and unrelated to this change (they fail on `main`
too).

Fixes TWENTY-SERVER-G4F

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22640?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-08 13:12:08 +02:00
Paul Rastoin bd8bf89653 Revert "feat(messaging): message campaign delivery stats + views" (#22452) (#22627)
Revert "feat(messaging): message campaign delivery stats + views
(#22452)"

This reverts commit 2e1117d442.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22627?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-07 14:17:13 +02:00
neo773 2e1117d442 feat(messaging): message campaign delivery stats + views (#22452)
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22452?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-07 04:47:52 +02:00
Paul Rastoin 6c40c7b91a Deterministic system field universal identifier (#22565)
# Introduction

Close twentyhq/core-team-issues#2641

Auto-provisioned field metadata used to get its `universalIdentifier`
from three unrelated sources: random `v4()` on the server when creating
custom objects, hardcoded values in `STANDARD_OBJECTS`, and an ad-hoc
`v5` derivation in the SDK manifest build. This PR unifies all of them
behind the shared `getFieldUniversalIdentifier` derivation:

```
universalIdentifier = f(applicationUniversalIdentifier, objectUniversalIdentifier, fieldName)
```

## Ownership model

The rollout is built on an explicit split of who owns a field's
universal identifier:

- **The 8 system fields** (`id`, `createdAt`, `updatedAt`, `deletedAt`,
`createdBy`, `updatedBy`, `position`, `searchVector`) are
**server-owned**. Their universal identifiers are always the
deterministic derivation, on **every** application (standard,
workspace-custom, installed). Clients cannot provide custom values: a
temporary check in `validateObjectMetadataSystemFieldsIntegrity` rejects
any non-derived system field identifier at migration build time. This
check stands in until system fields are generated exclusively server
side by the metadata side-effect engine and stripped from client inputs
— at which point it becomes structurally impossible to send one.
- **`name` is a default field, not a system field**: it is
auto-provisioned when absent (server side for custom objects, SDK side
for application objects) but authors can define their own. It is only
derived where it is guaranteed to be auto-provisioned. In particular,
standard objects keep their **historical hardcoded** `name` identifiers:
the standard app authors its `name` fields like any installed app would,
and moving those identifiers would break every installed application
referencing them (e.g. views on `opportunity.name`).
- **User-created and author-provided fields** keep random / explicit
identifiers, untouched.

## Server

- `validateObjectMetadataSystemFieldsIntegrity` now validates, on top of
the existing type/`isSystem` checks, that each system field's
`universalIdentifier` equals the deterministic derivation. Runs for
every object creation going through the migration orchestrator: app
sync, custom object creation, standard provisioning
- `build-default-flat-field-metadatas-for-custom-object.util.ts` derives
the system field identifiers (and the auto-provisioned `name`) with
`getFieldUniversalIdentifier` instead of `v4()`
-
`build-default-relation-flat-field-metadatas-for-custom-object.util.ts`
derives both the forward and the reverse default relation field
identifiers deterministically
- `generateMorphOrRelationFlatFieldMetadataPair` accepts optional
`sourceFieldUniversalIdentifier` / `targetFieldUniversalIdentifier` so
callers can inject deterministic values; user-created relations still
default to `v4()`

## twenty-shared

- `STANDARD_OBJECTS` system field identifiers (the 8) are now computed
at module load via `buildStandardObjectSystemFields`; `name` and every
other identifier keep their hardcoded values
- New snapshot test pinning **every** universal identifier of
`STANDARD_OBJECTS`: any identifier change now requires an explicit
snapshot update and should ship with a coordinated backfill

## SDK (breaking, pre-GA)

- `generateDefaultFieldUniversalIdentifier` delegates to
`getFieldUniversalIdentifier` and now requires
`applicationUniversalIdentifier`
- Reverse default relation field identifiers are derived from the
field's real coordinates (standard object UID + actual field name, e.g.
`targetRocket` on `attachment`) instead of the legacy custom-object UID
+ synthetic `${fieldName}Inverse` hash input. Field *names* are
unchanged
- The manifest build threads the application universal identifier
through default field injection (two-pass over object configs)
- `twenty dev:add` now resolves the application universal identifier
upfront and refuses to scaffold anything until `defineApplication`
declares one — no more `fill-later` placeholder for the app UID in
generated files

## Upgrade

A 2.19 **workspace command** backfills existing
`fieldMetadata.universalIdentifier` rows to the deterministic
derivation. Coverage follows the ownership model:

- **The 8 system fields**: taken over for **every application**,
whatever value they currently hold. This is both safe and required now
that sync rejects non-derived values — leaving a row unconverged would
make its application unsyncable
- **`name`**: workspace-custom app → always taken over
(server-generated, no author to clobber); installed applications → only
rows still carrying the legacy SDK derivation are recomputed,
author-provided identifiers are never touched; standard app → never
touched (hardcoded in `STANDARD_OBJECTS`)
- **Default relation fields**: workspace-custom app → forward fields on
custom objects and reverse fields on the standard relation objects;
installed applications → legacy-derivation probe only

All identifiers of a workspace are updated inside a single transaction,
then the command flushes the field-metadata-related workspace caches and
bumps the metadata version.

Stored `applicationRegistration.manifest` snapshots are intentionally
**not** rewritten: installs and upgrades always sync from the
`manifest.json` inside the resolved package (npm/tarball), the stored
column is only used for display/marketplace purposes.

## Breaking behavior for old packages (fail closed)

Packages built with an older SDK carry legacy system field identifiers
in their tarball `manifest.json`. Installing or upgrading such a package
now fails with an explicit `INVALID_SYSTEM_FIELD` validation error
("universal identifier is not deterministic") instead of silently
mismatching against the backfilled rows and triggering a destructive
delete+create. The remediation is to rebuild the package with the new
SDK; the backfill has already converged the installed rows, so the
rebuilt manifest syncs cleanly.

## Test plan

- [x] `twenty-sdk` unit tests (526 tests) and typecheck
- [x] `twenty-shared` unit tests (1635 tests) including the
`STANDARD_OBJECTS` snapshot; `name` identifiers verified byte-for-byte
identical to `main`
- [x] Lint and typecheck clean on all touched packages
- [x] Integration: create a custom object and verify system + default
relation field identifiers match the deterministic derivation
(`create-one-object-metadata-deterministic-field-universal-identifiers`,
13 assertions passing)
- [x] Integration: `failing-sync-application-object-system-fields`
extended with a non-derived system field identifier case; all
identifiers in the spec pinned deterministically so snapshots embedding
expected/actual values are stable across runs (verified with a double
run)
- [x] Integration: all application sync suites pass with the derived
system field identifiers now required by the
`buildDefaultObjectManifest` test helper (9 suites, 20 tests)
- [x] Full test-database reset: standard app provisioning and seeded
workspaces pass the new validation
- [x] SDK manifest build verified on the postcard example app: all
auto-generated default field identifiers match the derivation
- [ ] Run
`upgrade:2-19:backfill-deterministic-field-universal-identifiers`
(dry-run then real) on a seeded workspace and verify identifier
convergence with a rebuilt app manifest
2026-07-06 13:34:33 +00:00
Matt Van Horn 3cd4498bb2 fix: convert Microsoft calendar event HTML body to plain text description (#22540)
## Summary
Calendar events synced from Microsoft accounts now show a readable
plain-text description instead of raw HTML. Microsoft Graph returns
event bodies as HTML by default; the importer stored
`event.body.content` verbatim, so descriptions rendered as markup soup
in the record page and the event drawer.

## Why this matters
Issue #22537 reports Microsoft-synced calendar events displaying full
`<html><body>...` content in the description field. Google Calendar
events don't have this problem because Google returns plain text. The
fix converts HTML bodies with the `html-to-text` package that's already
a `twenty-server` dependency (used the same way in
`packages/twenty-server/src/modules/messaging/message-import-manager/drivers/microsoft/utils/format-text-body.utils.ts`
for email bodies), then normalizes line endings, non-breaking spaces,
and blank-line runs. Non-HTML bodies pass through unchanged, and the
existing `sanitizeCalendarEvent` step still runs afterwards.

## Testing
Added specs to `format-microsoft-calendar-event.util.spec.ts` covering
HTML-to-text conversion with `<br>` and block elements, HTML entity
decoding and `&nbsp;` handling, empty/null bodies, and text-body
passthrough.

Fixes #22537


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22540?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: neo773 <neo773@protonmail.com>
2026-07-05 19:23:05 +05:30
Félix Malfait 9f4efa57ff Expose sent message identifiers in workflow send-email step output (#22520)
## Context

First step toward thread-continuity / follow-up email steps in workflows
(email sequences). The outbound send pipeline already knows the sent
email's RFC-822 Message-ID, the provider thread id, and the persisted
message/thread records — but none of it was surfaced in the send-email
step output, so a later step had no way to reference the email that was
sent.

## What changed

- `saveMessagesWithinTransaction` also returns a `messageExternalId →
messageThreadId` map, and `saveMessagesAndEnqueueContactCreation`
returns the message/thread id maps (both other call sites ignore the
return value)
- `SentMessagePersistenceService.persistSentMessage` and
`SendEmailService.persistSentMessage` return the persisted `{ messageId,
messageThreadId }` (`undefined` when persistence is skipped or fails —
sending still succeeds)
- `SendEmailTool` result now includes `headerMessageId`,
`threadExternalId`, `messageId` and `messageThreadId`
- SEND_EMAIL step output schema (server + frontend) declares
`headerMessageId`/`messageId`/`messageThreadId` so they show up in the
variable picker; DRAFT_EMAIL keeps its success-only schema since draft
creation returns no identifiers yet

This already enables manual thread continuity today: wire
`{{sendEmailStep.headerMessageId}}` into a later email step's
In-Reply-To advanced field — the composer resolves the References chain
and provider thread from it.

## Tests

- New `send-email-tool.spec.ts` covering identifiers in the result,
persistence disabled, and persistence failure
- Extended save-messages spec with the new map, updated frontend
`computeStepOutputSchema` tests

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22520?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-03 18:01:40 +02:00
Félix Malfait 3cf04bea28 Support sender variable on workflow send-email action (#22512)
## Context

Draft-email workflow steps already accept a workspace member variable as
the sender: the step input's `connectedAccountId` can hold a workflow
variable that resolves to a workspace member id, which the action then
maps to that member's first connected account. Send-email steps were
gated out of this and only accepted a static connected account pick.

This enables the same dynamic sender resolution on send-email, e.g.
sending from the assignee/owner of the record that triggered the
workflow.

## What changed

- Removed the draft-only gate in
`EmailWorkflowActionBase.postprocessInput` so send-email resolves a
workspace member id to a connected account the same way draft-email does
- Exposed the variable picker and hint on the Account field for both
email actions in the workflow step editor
- Updated the send-email action spec to cover sender resolution (mirrors
the draft-email spec) and replaced the `SendEmailHasNoVariablePicker`
story with a variable-sender story for `SEND_EMAIL`

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22512?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-03 17:53:07 +02:00
Etienne 1270054d35 feat(ai): dashboard & view building (#22411)
## Why

Building a dashboard through AI chat used to cost ~9 sequential LLM
round-trips
(~160K input tokens for a single request): the agent had to resolve
object/field UUIDs and assemble views through many granular tool calls,
each
step replaying the full cached context.

## What changed

### 1. Reference objects & fields by name (fewer round-trips)
The agent no longer needs to resolve UUIDs before acting.
- `get_object_metadata`: filter by `objectName` (singular/plural) and a
new
`includeFields` flag returning each object's fields (`{id, name, type,
label}`)
  inline — object + field IDs in one call.
- `get_field_metadata`: accepts `objectName` as an alternative to
  `objectMetadataId`.
- All three dashboard write tools (`create_complete_dashboard`,
`add_dashboard_widget`, `update_dashboard_widget`): accept `objectName`
and
`*FieldName` variants (`aggregateFieldName`,
`primaryAxisGroupByFieldName`,
`secondaryAxisGroupByFieldName`, `groupByFieldName`, ratio `fieldName`),
resolved to UUIDs server-side by `resolveWidgetFieldNamesToIds`. UUID
variants
  still win when both are given.

### 2. `upsert_complete_view` — one atomic call to build/reconfigure a
view
- New `upsert_complete_view` tool + `ViewService.upsertCompleteView`:
create or
  update a view together with its fields, filters, and sorts.
- Children are **declarative**: a provided array replaces all existing
entries of
that kind, `[]` clears them, omitting leaves them untouched. Fields are
  referenced by name or UUID; no child-row IDs needed.
- Runs as a **single workspace migration** (`view` + `viewField` +
`viewFilter` +
`viewSort` in one `validateBuildAndRunWorkspaceMigration` matrice)
instead of
chained per-entity service calls. New
`buildCompleteViewChildrenFlatOperations`
  util assembles the child create/delete operations.
- Granular tools (`create_view_filter`, `update_view_sort`, …) are
retained for
  surgical single-entry edits.

### 3. Chart filters on dashboard widgets (end-to-end)
- Added `chartFilterSchema` (`recordFilters` + optional
`recordFilterGroups` for
AND/OR logic) to the four chart configs, with field-by-name or -UUID
references
  and documented operands/value formats.
- **Relative dates supported** — e.g. `PAST_7_DAY`, `THIS_1_MONTH`,
`NEXT_3_WEEK`,
plus open-ended `IS_IN_PAST` / `IS_IN_FUTURE` / `IS_TODAY`. Filters
route
through the same read pipeline (`computeRecordGqlOperationFilter`) as
view
  filters, so they resolve and apply correctly.
- `resolveChartFilterFieldNamesToIds` resolves filter `fieldName` → id
against the
  widget object.

### 4. Re-enable AI-assisted dashboards
- Removed the "coming soon" gating (`isActive: false` on the dashboard
skill and
the "not available yet" copy in the MCP server + chat prompts) and
registered
  `DashboardToolProvider`.
- Rewrote the dashboard skill prompt: confirmation gate (present a plan,
wait for
confirmation), completion guard (once confirmed, emit the create tool
in-turn —
no "now let me…" preambles), default-and-proceed (pick sensible defaults
for
missing fields instead of stalling), and an intent gate so informational
  dashboard questions are answered directly without loading skills.

### 5. Frontend: clearer advanced-filter labels
- `useRecordFilterField` now derives the filter label from field
metadata and
appends the relation target field (e.g. `Company → Name`), so
relation/target
filters — including those set by the AI — display correctly instead of
showing
  a stale/blank stored label.

## Fixes
- **`get_object_metadata({ objectName })` crash.**
`ObjectMetadataService.findManyWithinWorkspace`
  spread an array-form (`OR`) `where` into a plain object, producing
`{ "0": {...}, "1": {...}, workspaceId }` → `Property "0" was not found
in
"ObjectMetadataEntity"`. Now injects `workspaceId` into each OR clause,
so name
  lookups work.
- **Invalid SELECT/MULTI_SELECT filter options silently produced broken
charts/views.**
Chart-configuration validation and the migration-layer
`FlatViewFilterValidator`
now reject filters that reference options that don't exist, with a clear
  `Allowed values: …` message at creation time (shared
  `getInvalidSelectFilterOptionValues` util + tests).
- **Non-atomic view assembly.** The previous multi-call view build could
leave a
half-built view on failure; `upsert_complete_view` now runs as a single
transaction (one validation pass, one cache recompute, rollback on
error).
- **Blank RECORD_TABLE widgets from UNLISTED views.** Guidance + the
upsert
ownership check steer widget-backing views to `WORKSPACE` visibility; an
  UNLISTED view created without an owner renders a blank widget.
- **Extra discovery round-trip removed.** Deleted the skill→tool bundle
mechanism
(`SKILL_TOOL_BUNDLES`, `getBundledToolNamesForSkills`, and the
`load_skills`
  schema-loading path) that forced a second `learn_tools` call.
- **Type-safety of widget resolution.** Reworked the widget resolver to
build a
properly typed `WidgetWithMetadataIds` (dedicated input/output types)
instead of
  returning an untyped, cast-heavy object.

## Notes
- Backend changes are in `twenty-server`; one small `twenty-front`
change to the
  advanced-filter label hook. No entity/schema changes, so no migration.
- Tests added: `getInvalidSelectFilterOptionValues`,
`resolveWidgetFieldNamesToIds`
(incl. filter/relative-date resolution), `update_dashboard_widget`, and
expanded
  view-tools factory specs.
- Design decisions: dedicated composite tool over code-interpreter
orchestration
(atomicity + validation + consistency with `create_complete_dashboard` /
  `create_complete_workflow`); name-or-UUID but no child-row IDs on
`upsert_complete_view`; name→id resolution kept as stateless utils, not
services.

## Test plan
- [ ] `npx nx run twenty-server:typecheck`
- [ ] `npx nx lint:diff-with-main twenty-server` and `twenty-front`
- [ ] `npx nx test twenty-server` (view tools factory,
`getInvalidSelectFilterOptionValues`,
      `resolveWidgetFieldNamesToIds`, `update_dashboard_widget`)
- [ ] AI chat: "Create a dashboard with a chart of deal value by
pipeline stage
      and a table of the top 10 open opportunities" → plans, waits for
      confirmation, then builds with fewer round-trips
- [ ] AI chat: add a chart widget filtered by a relative date (e.g.
deals created
      in `PAST_7_DAY`) and confirm the chart is actually filtered
- [ ] Filter on a non-existent SELECT option is rejected with a clear
error

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22411?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-03 14:15:27 +00:00
neo773 3c3a8078fe fix email alias guard with message channel availibility (#22521)
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22521?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-03 18:23:01 +05:30
Etienne 90f35658c5 fix(ai) - sync AI agent step output schema when agent response format changes (#22466)
## Summary

When an AI agent workflow step is built via the AI chat tools, its
persisted
`settings.outputSchema` was left empty (or stale as a text `{ response
}` schema)
even after the agent was given a structured JSON `responseFormat`. The
workflow
still executed correctly (runtime uses actual step results), but the
builder UI
resolves downstream variables (`{{stepId.fieldName}}`) exclusively from
the
persisted `outputSchema`, so those variables showed as **"Not Found"**.

Root cause: the `update_agent` tool only mutated the agent entity and
never
re-derived the linked step's `outputSchema`, and `enrichOutputSchema`
did not
handle `AI_AGENT` steps at all.

## What changed

- **Enrich AI_AGENT output schema on the backend**: added `AI_AGENT` to
`BACKEND_ENRICHED_TYPES` in
`WorkflowSchemaWorkspaceService.enrichOutputSchema`,
so a step's `outputSchema` is computed from the agent's `responseFormat`
on
every create/update (text → `{ response }`, JSON → one field per
property).
- **Re-sync the step when the agent's response format changes**: after
`update_agent` sets a `responseFormat`, the tool now finds the draft
workflow
version(s) whose `AI_AGENT` step references that agent and re-runs the
step
  update so the persisted `outputSchema` is regenerated.
- **Fix stale-cache read**: `updateOneAgent` reads `flatAgentMaps`
before its
migration, which can leave a memoized/local stale copy for a few
seconds. The
resync now invalidates `flatAgentMaps` before re-enriching, so the fresh
  `responseFormat` is used.
- **Surface failures**: resync errors are logged (`UpdateAgentTool`)
instead of
  failing silently; the agent update itself still succeeds.
- Added unit tests for the `update_agent` resync behavior (fires on
`responseFormat` change, invalidates the cache, skips unrelated agents,
and
  reports success when the resync fails).


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22466?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-03 08:47:12 +00:00
Abdul Rahman f00b6ae185 use Temporal for date filter evaluation and fix IS operand (#22408)
## Summary
- Refactored `evaluateDateFilter` in the workflow filter action from
native `Date` to the Temporal API, using the shared
`parseToInstantOrThrow` and `isSamePlainDate` utilities from
`twenty-shared` (resolving the long-standing `// TODO: refactor this
with Temporal`).
- Fixed a bug in the `IS` operand: it previously compared only
`getDate()` (day-of-month 1–31), so e.g. `2023-01-15` incorrectly
matched `2023-02-15`. It now compares the full calendar day in UTC.
- Removed server-local-timezone leakage: `IS_TODAY` and day comparisons
now run in UTC (consistent with the neighbouring relative-date filter
util), instead of relying on `toDateString()`/`getDate()`.

## Behavior changes (intended)
- `IS` now matches on the full UTC calendar day, not day-of-month.
- `DATE_TIME` `IS` matches on the same UTC day (not exact-instant
equality).
- Date comparisons are UTC-based, so evaluations near midnight in a
non-UTC server locale may differ from the old local-timezone behavior.
- Parsing is stricter (ISO + known formats via `parseToInstantOrThrow`);
malformed operands resolve to "no match" instead of being loosely
guessed by `new Date()`.



<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22408?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-02 22:27:46 +02:00
neo773 6f64be5751 Gate and meter email group: enterprise license (self-host) + credits (cloud) (#22390)
Email group (marketing email) was gated only by the
`IS_EMAIL_GROUP_ENABLED` feature flag with no server-side enforcement.
This adds real gating, split by deployment:

- **Self-hosted** (`IS_BILLING_ENABLED=false`): requires a valid
Enterprise plan.
- **Cloud** (billing enabled): metered by credits, mirroring the
existing AI credit system. Priced on AWS SES cost ($0.10/1,000 outbound)
× 3 margin = $0.30/1,000 (300 micro-credits/email). Pre-flight blocks
sends when out of credits; each email is charged after SES accepts it,
in the async send job — matching how AWS bills us (no refund on bounce).

Enforcement is applied at every email group resolver, and denials
surface as proper client errors through a dedicated GraphQL exception
filter.
2026-07-02 15:14:49 +00:00
neo773 7cf8b58f1b feat(emailing): local unsubscribe URL + full-content log driver (#22412)
In LOG mode, emit a working
http://unsubscribe.<subdomain>.localhost/emailing/unsubscribe link (from
SERVER_URL + workspace subdomain) instead of empty content, and log the
full text/html body + List-Unsubscribe header so the flow is inspectable
locally. buildUnsubscribeUrls now takes a full base URL (field renamed
httpsUrl -> webUrl).

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22412?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-02 13:36:57 +00:00
Etienne 7a5896ff5d feat(ai) - add delete_workflow tool (#22432)
## Summary

- Add a new `delete_workflow` agent tool that soft-deletes a workflow
and cleans up its sub-entities (versions, runs, triggers) via
`WorkflowCommonWorkspaceService.handleWorkflowSubEntities`
- Update the workflow skill system prompt to document the new capability
and instruct the agent to always confirm with the user before deleting
- Wire `WorkflowCommonModule` / `WorkflowCommonWorkspaceService` into
the workflow-tools dependency graph

## Test plan

- [x] Unit tests added (`delete-workflow.tool.spec.ts`) covering
successful deletion and error handling
- [ ] Verify the agent can resolve a workflow by name via
`list_workflows` then delete it with `delete_workflow`
- [ ] Confirm the agent asks for user confirmation before executing the
deletion
- [ ] Confirm sub-entities (versions, runs, triggers) are removed after
deletion

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22432?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-02 13:05:46 +02:00
neo773 4fef02394f Backfill webhook subscriptions for existing connected accounts (#22314)
Add command iterating workspaces, enqueuing staggered per-channel jobs
for Google/Microsoft channels still on polling

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22314?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-01 11:32:18 +02:00
Abdul Rahman 59d708e324 fix(workflow): stop relative date filter from crashing on empty/invalid date values (#22384)
## Problem

A workflow **Filter** step on a `DATE`/`DATE_TIME` field using
`IS_RELATIVE` crashes with a `RangeError` when the referenced step
output is empty or invalid. The empty value is coerced into an `Invalid
Date` (`new Date("undefined")`),
whose `.getTime()` is `NaN`, and
`Temporal.Instant.fromEpochMilliseconds(NaN)` throws, failing the
affected workflow runs.

This is a latent regression from the Date → Temporal migration (#16544):
the previous `date-fns` implementation silently returned `false` on an
invalid date, but Temporal is strict and throws. The guard was never
carried over.

## Fix

Validate the coerced date once at the boundary in `evaluateDateFilter` —
the single place arbitrary/empty step output is turned into a `Date`. An
unparseable date now resolves to "does not match" for every comparison
operand (`IS`, `IS_IN_PAST`, `IS_IN_FUTURE`, `IS_TODAY`, `IS_BEFORE`,
`IS_AFTER`, `IS_RELATIVE`), restoring the pre-migration contract.
`IS_EMPTY` / `IS_NOT_EMPTY` are intentionally excluded so emptiness is
still evaluated on the raw operand.

## Tests
Added a parameterized regression test covering every date comparison
operand with empty and missing step output, asserting no throw and a
`false` result.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22384?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-01 13:01:25 +05:30
neo773 101b85db7b messaging remove dead workspace entities (#22366)
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22366?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->

---------

Co-authored-by: prastoin <paul@twenty.com>
2026-06-30 14:36:01 +00:00
neo773 d55ef3063b Fix draft send: read messageChannel from core, not workspace ORM (#22365)
messageChannel moved to a core-schema entity, so resolving it via the
workspace ORM by name throws 'object metadata missing'. Query the core
MessageChannelEntity repository scoped by workspaceId instead.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22365?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-06-30 14:08:05 +00:00
neo773 9f3ebaaf22 feat(messaging): sync draft emails and edit them in the thread composer (#22178)
Stop excluding drafts from sync across all three providers (Gmail DRAFT
label, Microsoft/IMAP Drafts folder) and add an isDraft boolean field on
Message so drafts are queryable by the API and AI agents.

Drafts render in the thread with a Draft tag; clicking one opens the
existing reply composer pre-filled with the draft's recipients, subject
and body, and Send reuses the existing send-email flow.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22178?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
2026-06-30 13:07:53 +02:00
neo773 66edd96213 microsoft webhook ttl fix (#22300)
In dev testing it worked fine but on production the TTL is failing we
add a 1 hour buffer for safety

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22300?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-06-29 18:09:07 +05:30
Félix Malfait 0e22ae0521 feat: create calendar events on Google and Microsoft accounts (#22231)
## Context

Twenty can import calendar events and send emails, but cannot create
calendar events. This adds calendar event creation on connected
**Google** and **Microsoft** accounts, mirroring the existing email-send
architecture (`message-outbound-manager`).

## What it adds

The capability is exposed three ways, all backed by the same composer →
driver → persist pipeline:

- **GraphQL mutation** `createCalendarEvent` (metadata API)
- **AI agent tool** `create_calendar_event` (flows to MCP
automatically), gated by a new `CREATE_CALENDAR_EVENT_TOOL` permission
flag
- **Workflow builder node** "Create Calendar Event" in the **Core**
section, with a full settings form (variable interpolation supported)

CalDAV/IMAP is intentionally out of scope for now (different long pole).

## Design notes

- **Reuse over reinvention** — the created event is run through the
existing inbound formatters (`formatGoogleCalendarEvents` /
`formatMicrosoftCalendarEvents`) and persisted immediately via the
existing `CalendarSaveEventsService`, so it appears in Twenty right away
and is reconciled by the next provider sync (dedup on external id).
Persistence is best-effort.
- **OAuth scopes** — Google already requests `calendar.events`
(read+write), so no change there. Microsoft moves `Calendars.Read` →
`Calendars.ReadWrite`; existing Microsoft accounts must re-consent
(surfaced as a clear "reconnect" error via a missing-scope check).
- **Deliberate invitation semantics** — `sendInvitations` is off by
default. When off, the event is created with **no attendees** on either
provider, so creating an event never silently emails external people.
When on, attendees are attached and notified (Google `sendUpdates: all`,
Microsoft's default). This sidesteps Microsoft Graph having no
per-request suppression.
- **Timezone correctness** — Microsoft Graph interprets `dateTime` as
wall-clock in the supplied `timeZone` and ignores the offset, so the
absolute instant is converted to its wall-clock form before sending
(Google honors the offset directly). Both providers end up scheduling
the same instant.
- **Conferencing** — optional Google Meet
(`conferenceData.createRequest`, with a follow-up `events.get` to
resolve the async link) / Microsoft Teams (`isOnlineMeeting`).
- Attendees are a comma-separated string everywhere (tool input, GraphQL
DTO, workflow input), consistent with `send_email` recipients; the
composer parses to its internal list.

## Test plan

- **Unit**: 45 tests covering the composer (validation, all-day
boundaries, offset enforcement, timezone, scope checks, default-account
resolution), both provider drivers, the dispatcher, and the workflow
step-log builder.
- **Integration**: `createCalendarEvent` on the `/metadata` API fails
closed with a structured error for a non-existent account (the
auth/ownership/validation path that doesn't require provider mocking).
- **Manual**: verified the workflow node appears in the Core section,
the settings form renders and round-trips (edit → autosave → reload),
and the live mutation returns a structured failure for a bogus account.

## Open question for reviewers

The metadata mutation `createCalendarEvent` shares a name with the core
schema's auto-generated `createCalendarEvent(data:)` CRUD mutation for
the CalendarEvent object — they live on different endpoints (`/metadata`
vs `/graphql`) so there's no runtime conflict, but it's a potential
point of confusion for API consumers. Happy to rename (e.g.
`createCalendarEventOnConnectedAccount`) if preferred.

## Out of scope / follow-ups

- CalDAV/IMAP support
- Event update/delete and recurrence
- Existing Microsoft accounts need re-consent for the widened scope


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22231?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->

---------

Co-authored-by: neo773 <neo773@protonmail.com>
2026-06-27 14:05:58 +02:00
Raphaël Bosi c891258f34 Add v2 onboarding create profile page (#22221)
<img width="3024" height="1498" alt="CleanShot 2026-06-26 at 15 30
23@2x"
src="https://github.com/user-attachments/assets/8b4863a9-66ed-4da1-851b-473cedf71511"
/>

<img width="3022" height="1500" alt="CleanShot 2026-06-26 at 15 29
43@2x"
src="https://github.com/user-attachments/assets/22fc0e94-f670-4638-975c-f06b2b2e25e8"
/>

Adds the v2 onboarding **Create profile** page, shown right after the
import-contacts step (`PROFILE_CREATION`) for the onboarding-v2 cohort.
It renders full-screen under `BlankLayout` via the shared
`OnboardingV2Layout`, matching the Figma (340px column, inline round
avatar uploader + First/Last row, Job Title, dark Continue). The v1
modal flow is untouched and still used for non-v2 users.

Job Title is wired end-to-end: it adds a real `jobTitle` field to the
`WorkspaceMember` standard object (shared metadata constant + flat field
metadata + entity property) and a `2-17` workspace upgrade command to
backfill the field on existing workspaces. Continue persists name +
jobTitle through the existing `updateWorkspaceMemberSettings` mutation,
whose allow-list picks up the new standard field automatically.

Routing mirrors `SyncEmailsV2`: new `AppPath.CreateProfileV2`, lazy
route, and an `isOnboardingV2`-gated branch in
`usePageChangeEffectNavigateLocation` (+ tests and a Storybook story).

Reviewer notes:
- `jobTitle` is **write-only** for now (no read-back path: core
DTO/transpiler/fragment unchanged), and the field is
`isSystem`/non-UI-editable to match its siblings. Easy to surface later
if wanted.
- New `OnboardingProfilePictureUploader` is a compact round avatar
uploader reusing the same upload mutation flow as
`WorkspaceMemberPictureUploader`.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22221?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-06-26 17:34:44 +02:00
neo773 9747e3a7a3 feat(messaging): link emails by Reply-To as a REPLY_TO participant (#22216)
Relay senders (e.g. a website form sending as a shared address with the
real contact in Reply-To) never linked to the contact because matching
only used From/To/Cc/Bcc. Record Reply-To addresses under a new REPLY_TO
participant role across the Gmail, Microsoft and IMAP drivers, excluding
any that just repeat the sender.

Adds the REPLY_TO option to the messageParticipant role field and a 2.17
workspace command to backfill it for existing workspaces.

QAed with real test run

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22216?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-06-26 17:58:51 +05:30
Parship Chowdhury b3e39e2198 fix: relative date picker calendar display (#21895)
Part of
https://github.com/twentyhq/twenty/issues/19739#issuecomment-4652034526
(Bug 1-3). Maybe it feels like theses bugs are not actually bugs, but we
can maybe say it as UX improvements: specially needed in case when an
user will choose any past options.

### Bug 1: calendar open on wrong month
With Is Relative (e.g. Past 1 Quarter), the calendar opened on today’s
month instead of the range start. After the fix, it now opens on the
first month of the filtered range.

**Testing:**
View filter → Date field → Is Relative → Past 1 Quarter. Calendar opens
on January (range start), not today’s month


https://github.com/user-attachments/assets/8849d00a-4d5c-4f8a-8d31-3a62535eb311


### Bug 2: Dates not highlighted
Ranges older than ~2 months (e.g. Q1 when today is June) showed no
highlighted days. Highlighting now covers the full resolved range.

**Testing:**
Same setup: past 1 Quarter on a date when Q1 is outside the old 2‑month
window. Jan 1 - Mar 31 will highlight.


https://github.com/user-attachments/assets/d21e2272-c923-4493-80ff-bdf4228842b1


### Bug 3: No month navigation
Relative mode only showed Past - 1 - Quarter controls with no way to
browse months. Now see the new arrows move through months without
changing the filter.

<img width="377" height="455" alt="Screenshot 2026-06-20 181107"
src="https://github.com/user-attachments/assets/eb51feb9-af10-489a-b166-8b8d6c642e05"
/>


> [!NOTE]
> 1. We can't do the fixes by one by one, i have to fix them within one
PR because all the fixes are inter-related, like we can't test the bug 1
fix alone without implementing bug 3.
> 2. Bug 4 will be done in a separate PR which is actually the issue
#19739. See
https://github.com/twentyhq/twenty/issues/19739#issuecomment-4652034526
for better understanding.
> 3. If you see the screen recordings, they are actually done with the
alignment fixes from #21881 . So without that changes you will see the
alignmemt issues in the calendar grid in your local.

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
2026-06-26 10:03:21 +02:00
Marie 6e2df0654b [Workflows] Allow iterator to take whole item as variable (#22031)
**Select the whole item in iterator loops, and iterate over a step's
array output**
## Summary
Two related improvements to working with lists in workflows:
- Pick the current item as a whole inside an iterator loop. Previously,
in a node inside the loop, you could only reference individual fields of
the Iterator's current item. Now you can select the whole item (e.g. a
full record) — useful for passing it straight into a downstream step.
<img width="1270" height="744" alt="Screenshot 2026-06-23 at 17 02 47"
src="https://github.com/user-attachments/assets/6b92e72e-ec25-4c1a-9841-3a438210e753"
/>
- Iterate over a step's array output. A Code / Logic Function step that
returns a top-level array couldn't be fed to the Iterator: its output
was flattened into indexed entries (0, 1, …) with no way to select the
array as a whole. A new "Whole list" option selects the step's entire
output, and the Iterator infers the per-iteration item shape from it.
<img width="1026" height="728" alt="Screenshot 2026-06-23 at 17 17 53"
src="https://github.com/user-attachments/assets/db07dcd8-4fb8-4db9-8b45-aa56051d9f3b"
/>


Together these complete the loop ergonomics: select a list → iterate →
reference the current item (whole or by field) downstream — matching the
model used by tools like Windmill.

## What changed
- The variable picker offers a "Use the whole item" option when viewing
an iterator's current item, and a "Whole list" option when a step
returns a top-level array.
- The Iterator's current-item schema can now be inferred from a variable
pointing at a step's whole output.

## Risks for existing workflows
None expected. The change is purely additive:
- No DB migration and no change to how output schemas are stored or read
— existing schemas, variables, and iterators behave identically.
- No change to runtime variable resolution; existing {{step.field}} and
current-item references are untouched.
- The new options only apply to new selections (whole item / whole
list); all existing paths take the unchanged code path.
- The only edge case: array detection is heuristic (an output whose keys
are exactly 0…n-1), so an object that happens to have those keys would
also show "Whole list". This is rare for real outputs, affects nothing
unless a user selects it, and fails safe — the Iterator validates its
input and throws a clear "items must be an array" error if a non-array
is passed.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22031?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-06-26 09:26:11 +02:00