Compare commits

...
21 Commits
Author SHA1 Message Date
ishan-karmakar 12bd695c59 Change host port in docker-compose.yml
CI Create App / changed-files-check (push) Has been cancelled
CD deploy main / deploy-main (push) Has been cancelled
CI Codex Plugin / changed-files-check (push) Has been cancelled
CI Codex Plugin / codex-plugin-validate (push) Has been cancelled
CI Create App E2E minimal / changed-files-check (push) Has been cancelled
CI Create App E2E minimal / create-app-e2e-minimal (push) Has been cancelled
CI Create App E2E minimal / ci-create-app-e2e-minimal-status-check (push) Has been cancelled
CI Create App / create-app-test (lint) (push) Has been cancelled
CI Create App / create-app-test (test) (push) Has been cancelled
CI Create App / create-app-test (typecheck) (push) Has been cancelled
CI Create App / ci-create-app-status-check (push) Has been cancelled
CI Docs / changed-files-check (push) Has been cancelled
CI Docs / docs-lint (push) Has been cancelled
CI Emails / changed-files-check (push) Has been cancelled
CI Emails / emails-test (push) Has been cancelled
CI Emails / ci-emails-status-check (push) Has been cancelled
CI Example App Hello World / changed-files-check (push) Has been cancelled
CI Example App Hello World / example-app-hello-world (push) Has been cancelled
CI Example App Hello World / ci-example-app-hello-world-status-check (push) Has been cancelled
CI Example App Postcard / changed-files-check (push) Has been cancelled
CI Example App Postcard / example-app-postcard (push) Has been cancelled
CI Example App Postcard / ci-example-app-postcard-status-check (push) Has been cancelled
CI UI / changed-files-check (push) Has been cancelled
CI UI / ui-task (lint) (push) Has been cancelled
CI UI / ui-task (test) (push) Has been cancelled
CI UI / ui-task (typecheck) (push) Has been cancelled
CI UI / ui-sb-build (push) Has been cancelled
CI UI / ui-sb-test (push) Has been cancelled
CI UI / ci-ui-status-check (push) Has been cancelled
Push translations to Crowdin / Extract and upload translations (push) Has been cancelled
2026-06-07 17:38:49 +00:00
ishan-karmakar 17347736be Update docker-compose for our purposes
CI Codex Plugin / changed-files-check (push) Has been cancelled
CI Codex Plugin / codex-plugin-validate (push) Has been cancelled
CI Create App E2E minimal / changed-files-check (push) Has been cancelled
CI Create App E2E minimal / create-app-e2e-minimal (push) Has been cancelled
CI Create App E2E minimal / ci-create-app-e2e-minimal-status-check (push) Has been cancelled
CI Create App / changed-files-check (push) Has been cancelled
CI Create App / create-app-test (lint) (push) Has been cancelled
CI Create App / create-app-test (test) (push) Has been cancelled
CI Create App / create-app-test (typecheck) (push) Has been cancelled
CI Create App / ci-create-app-status-check (push) Has been cancelled
CI Docs / changed-files-check (push) Has been cancelled
CI Docs / docs-lint (push) Has been cancelled
CI Emails / changed-files-check (push) Has been cancelled
CI Emails / emails-test (push) Has been cancelled
CI Emails / ci-emails-status-check (push) Has been cancelled
CI Example App Hello World / changed-files-check (push) Has been cancelled
CI Example App Hello World / example-app-hello-world (push) Has been cancelled
CI Example App Hello World / ci-example-app-hello-world-status-check (push) Has been cancelled
CI Example App Postcard / changed-files-check (push) Has been cancelled
CI Example App Postcard / example-app-postcard (push) Has been cancelled
CI Example App Postcard / ci-example-app-postcard-status-check (push) Has been cancelled
CI UI / changed-files-check (push) Has been cancelled
CI UI / ui-task (lint) (push) Has been cancelled
CI UI / ui-task (test) (push) Has been cancelled
CI UI / ui-task (typecheck) (push) Has been cancelled
CI UI / ui-sb-build (push) Has been cancelled
CI UI / ui-sb-test (push) Has been cancelled
CI UI / ci-ui-status-check (push) Has been cancelled
CD deploy main / deploy-main (push) Has been cancelled
Push translations to Crowdin / Extract and upload translations (push) Has been cancelled
2026-06-07 17:34:40 +00:00
claude[bot]GitHubclaude[bot] <41898282+claude[bot]@users.noreply.github.com>Félix Malfait
dfe0b5bfd4 chore(server): rename TypeORM migrations dir to legacy-typeorm-migrations-do-not-add (#21295)
## What

Renames the historical TypeORM migrations directory so it's obvious at a
glance the path is frozen.

- `packages/twenty-server/src/database/typeorm/core/migrations/common/`
→ `…/typeorm/core/legacy-typeorm-migrations-do-not-add/common/`
- `packages/twenty-server/src/database/typeorm/core/migrations/billing/`
→ `…/typeorm/core/legacy-typeorm-migrations-do-not-add/billing/`
- `packages/twenty-server/src/database/typeorm/core/migrations/utils/` —
**left in place** (those SQL helpers are still imported by current
instance/workspace commands, see e.g.
`1-21-workspace-command-1775500002000-add-global-key-value-pair-unique-index.command.ts`)
- Updates `core.datasource.ts` globs to the new path and adds an inline
comment explaining the dir is frozen
- Adds a `README.md` at the new dir pointing readers at
`UPGRADE_COMMANDS.md` and the active `upgrade-version-command/` tree

## Why

The TypeORM migration system was replaced by fast/slow instance commands
+ workspace commands (PR #19356), but `typeorm/core/migrations/common/`
still looked structurally identical to an active migrations folder, with
new files merging in as recently as last week. New contributors — and AI
agents — kept inferring it was the active path and adding TypeORM
`MigrationInterface` files. See #21286 for the most recent instance.

This is recommendation **1b** from #21286
(`https://github.com/twentyhq/twenty/pull/21286#discussion_r3369356792`).
A renamed folder is the single strongest signal — no one instinctively
adds to a `do-not-add` directory.

## Notes

- Imports from `src/database/typeorm/core/migrations/utils/…` keep
working because `utils/` did not move.
- No behavior change at runtime: TypeORM loads the same files via
`_typeorm_migrations`, just from the new path.

## Test plan

- [ ] CI green
- [ ] `nx build twenty-server` succeeds
- [ ] Fresh `nx database:reset twenty-server` from a clean DB still
replays the legacy TypeORM migrations (i.e. `_typeorm_migrations` rows
get inserted at boot/init)
- [ ] Spot-check that an instance command that imports from
`migrations/utils/` still resolves at build time (e.g.
`1-21-workspace-command-1775500002000-add-global-key-value-pair-unique-index.command.ts`)

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

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Félix Malfait <FelixMalfait@users.noreply.github.com>
2026-06-07 14:54:36 +02:00
neo773andGitHub 186d5b8faa revert #21177 (#21284) 2026-06-06 14:49:55 +02:00
011afa6011 Allow kanban cross-column drag when sorting is enabled (#21025)
## Summary

This PR allows kanban cards to be dragged across columns while sorting
is enabled.

Previously, any board drag while a sort was active opened the “Remove
sorting?” modal. That makes sense for same-column reordering, because
manual reorder conflicts with the active sort. But for cross-column
moves, the user is changing the grouped field, not trying to manually
reorder the destination column.

With this change:

- Same-column drag with sorting enabled still opens the existing
remove-sorting modal.
- Cross-column drag with sorting enabled updates only the group field.
- The destination column keeps using the active sort to determine where
the card appears.
- Unsorted board drag behavior continues to update `position` as before.

## Why

On sorted kanban boards, moving a card to another column is a valid
workflow even though manual reordering is not. The previous guard
blocked both cases because it only checked whether sorting was active,
not whether the card stayed inside the same column.

## Implementation

The drop behavior now distinguishes between:

- sorted same-column drops, which remain blocked
- sorted cross-column drops, which are allowed without a position update
- unsorted drops, which keep the existing position-update behavior

A small helper captures that decision and has focused unit coverage.

## Validation

- Manually verified sorted cross-column drag persists after refresh.
- Manually verified sorted same-column drag still opens the
remove-sorting modal.
- Manually verified unsorted same-column drag still reorders cards.
- Manually verified unsorted cross-column drag still moves cards.
- Ran focused Jest coverage for the sorted board drop decision.
- Ran formatting and oxlint checks on touched frontend files.
- Ran `twenty-front` typecheck.
- Ran `twenty-front` production build.

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Charles Bochet <charles@twenty.com>
2026-06-06 14:22:57 +02:00
Félix MalfaitandGitHub a2fa941ce3 chore(twenty-front): remove dead SettingsAiMCP component and covers (#21281)
## What

`SettingsAiMCP` is dead code — nothing imports or renders it. The
redesign moved MCP setup to the **APIs & Webhooks** page
(`SettingsMcpSetup`, on the MCP tab), and the AI settings page now
**deep-links** there (`ApiWebhooks#mcp`) instead of rendering this
component.

Removes the orphaned component and its two cover SVGs:
- `src/pages/settings/ai/components/SettingsAiMCP.tsx`
- `public/images/ai/ai-mcp-cover-{light,dark}.svg`

## Notes

- Verified nothing references the component or the SVGs in source (only
stale `.po` source-reference comments remain, which `lingui` extraction
reconciles separately — not hand-edited here).
- The hero on the APIs & Webhooks page (incl. its MCP tab) is
unaffected; that's the `playground/cover` image.
2026-06-06 12:23:42 +02:00
Félix MalfaitandGitHub 898713bd49 fix(server): finalize dangling tool calls when persisting agent chat messages (#21276)
## Problem

Interrupting an AI chat turn mid tool-call batch permanently bricks the
thread. Every subsequent message fails with:

> Tool results are missing for tool calls toolu_…, toolu_…

## Root cause

When the model fires a parallel batch of tool calls, it streams all the
calls first, then results come back one by one. If the stream is aborted
(user hits stop, credit cutoff, etc.) after only some have resolved, the
AI SDK's `onFinish` still fires with the partial assistant message —
including tool parts left in `input-available` state (a tool call with
no result).

`addMessage` persists that message verbatim. On the next turn the
history is rebuilt and `streamText` validates it: every `tool-call` must
be cleared by a `tool-result` before the next user message, or it throws
`MissingToolResultsError` (`ai/dist`, the `MissingToolResultsError`
check). The orphaned calls are now in the DB, so the thread fails on
every turn from then on.

## Fix

Enforce the invariant at the single write chokepoint. Every chat message
is persisted through `AgentChatService.addMessage`, so
`finalizeDanglingToolParts` runs there once: any tool part still in
`input-available` is rewritten to `output-error` ("Tool execution was
interrupted.") before mapping to DB rows.

`output-error` converts to a real `tool-result`, so the persisted turn
is always self-consistent and the next request is valid. Interrupted
calls are kept (not dropped) and surfaced as errored rather than
perpetually "running" — honest, since a partially-executed call may have
committed side effects the model should be able to reconcile.

One guard at one point covers every abort source — no read-side
patching, no migration, no schema change.

## Caching impact

None on the happy path. A completed turn has no `input-available` parts,
so the helper is a no-op and the persisted bytes (and therefore the
cached prefix) are identical to before. For an interrupted turn, the
finalized content is deterministic and written once, so it caches
cleanly on the following request and stays stable across later turns —
there is no scenario where this invalidates an existing cache entry. Net
effect: turns a hard failure into a normally-cached continuation.

## Testing

- New unit test covering finalize / no-op cases (7 cases, passing)
- `oxlint --type-aware` + `oxfmt` clean on changed files
2026-06-06 11:22:45 +02:00
Félix MalfaitandGitHub 4658d44d8b fix(settings): ship borderless hero cover images (#21277)
## What

The settings discovery hero images (AI, Applications, Page Layouts,
Members, Data Model, APIs & Webhooks) baked the rounded border into the
pixels — transparent rounded corners plus a 1px edge stroke. Rendered
inside `Card rounded` — which already draws a 1px border + border-radius
and clips children with `overflow: hidden` — this produced a doubled,
slightly misaligned border.

This replaces all 12 files (light + dark per section) with clean
full-bleed exports (opaque square corners), so the border and rounding
come entirely from CSS.

## Notes

- Pure asset swap, no component changes.
- The MCP section's `.svg` cover is untouched (no new export provided).
Billing's unused cover is left as-is.

## Verification

- Each new image confirmed 1388×300, opaque square corners (no baked
border), correct light/dark variant.
- `Card` (twenty-ui) provides `border` + `border-radius` + `overflow:
hidden`, so the square images are clipped to the rounded card.
2026-06-06 11:21:06 +02:00
a596e7d904 fix: add missing space in README heading (#21271)
The h2 heading in README.md was missing a space between the `>` closing
angle bracket and "The", causing minor formatting inconsistency.

Changed:
`<h2 align="center" >The #1 Open-Source CRM</h2>`

To:
`<h2 align="center"> The #1 Open-Source CRM</h2>`

---------

Co-authored-by: Félix Malfait <felix@twenty.com>
2026-06-06 10:41:16 +02:00
Félix MalfaitandGitHub 91f2f08995 feat(server): unify workspace-event ingestion behind one EventSink pipeline (#21197)
## Why

The five event-log streams (`workspaceEvent`, `pageview`, `objectEvent`,
`usageEvent`, `applicationLog`) each wrote to ClickHouse through their
own fire-and-forget writer (`AuditService`, `UsageEventWriterService`,
and the `application-logs` driver), with the per-type knowledge (table
names, normalization, access rules) spread across several modules. Three
of them reimplemented the same ClickHouse insert, and the read side, the
live stream, and the producers lived in different modules under two
different names.

This consolidates them into one `core-modules/event-logs/` subsystem
(emit, write, live, read), with the per-type config in a single registry
so adding an event type is roughly one file.

The base Logs settings tab and free application logs shipped separately
in #21180 (merged). This PR adds the unified backend, the registry, and
the viewer's live mode and entitlement gating.

## Pipeline

```mermaid
flowchart TB
    subgraph PROD["Producers"]
      A["auth, billing, impersonation,<br/>webhook, custom-domain"]
      U["usage listener"]
      F["logic-function executor (app logs)"]
      R["record CRUD (entity events)"]
    end
    EM["EventLogEmitterService<br/>createContext().insert* / dispatch()"]
    EQ(["entityEventsToDbQueue<br/>(existing, shared with timeline)"])
    CIE["CreateEventLogFromInternalEvent"]
    SINK["WorkspaceEventSinkService.ingest()"]
    C1["ClickHouseEventSink"]
    C2["ConsoleEventSink"]
    LIVE["EventLogLiveService.publishWatched()<br/>(presence-gated)"]
    CH[("ClickHouse, 5 tables, async_insert")]
    CHAN(["WORKSPACE_EVENTS_CHANNEL"])
    RS["EventLogsService (registry-driven read)"]
    LR["EventLogsLiveResolver"]
    UI["Settings > Logs"]

    A --> EM
    U --> EM
    F --> EM
    EM -->|direct| SINK
    R --> EQ --> CIE -->|ingest| SINK
    SINK --> C1 --> CH
    SINK --> C2
    SINK --> LIVE -.->|if a viewer is watching| CHAN --> LR --> UI
    CH --> RS --> UI
```

## What it does

- Producers call `EventLogEmitterService.createContext().insert*()`,
which builds a typed `WorkspaceEventEnvelope` and writes it through
`WorkspaceEventSinkService` to the configured sinks (ClickHouse,
Console) plus a presence-gated live fan-out. Record/CRUD events reach
the same sink through the existing `entityEventsToDbQueue`. There is no
dedicated queue; ClickHouse `async_insert` batches server-side. Writes
are best-effort, as on main today.
- `EVENT_LOG_TYPES[table]` is the per-type source of truth: the
ClickHouse table, the required entitlement, the free-text filter column,
and the row-to-GraphQL mapping. Read row shapes derive from the write
rows.
- Four modules along their dependency boundaries:
`EventLogEmitterModule` (producer API), `EventLogIngestionModule` (sink
layer), `EventLogLiveModule` (fan-out), and `EventLogsViewerModule` (the
entitlement-gated GraphQL read, which is where
billing/enterprise/permissions stay so producers stay light).
- Logs viewer: per-table columns, filters (text, date, record), live
mode, and an upgrade card that points to Billing on Cloud or the Admin
Panel on self-hosted. Application logs are free on every plan; the other
four require the `AUDIT_LOGS` entitlement (with a `NO_ENTITLEMENT`
fallback to the upgrade card).
- Renames `AuditService` to `EventLogEmitterService`, and the generic
`Monitoring` event to a typed `Impersonation` event (`level` +
`action`).
- Removes `UsageEventWriterService`, the `application-logs`
driver/module, and `AuditService`'s direct inserts.

## Durability

Writes are best-effort, the same as main today (the old writers were
fire-and-forget). A dedicated queue was tried mid-PR and removed:
`async_insert` already batches server-side, so the queue only added
durability, which isn't a requirement right now. The `EventSink` seam
keeps a durable transport (e.g. a Redis-Streams buffer) easy to add
later without touching producers.

## Out of scope

S3 peer sink (seam only), Postgres or any second read path,
`ReplicatedMergeTree`, ClickHouse table-schema changes, and the
record-data `EVENT_STREAM_CHANNEL` (unchanged, separate concern).

## Testing

Unit tests cover the registry definitions and row normalization, the
entitlement gating, the envelope builders, and the producers.
Integration tests cover the write paths (record create produces an
`objectEvent`; the track mutation produces a `workspaceEvent`) and the
read/query path across all five tables. Verified with typecheck, lint, a
server boot, and GraphQL/SDK codegen.
2026-06-06 10:32:56 +02:00
martmullandGitHub 6c65d26ced feat(app-dev): add dry-run preview to dev sync (#21251)
Split out of #21240. Stacked on #21250 (review/merge that first).

`yarn twenty dev --once --dry-run` computes the migration plan and
prints the diff **without applying anything** (no migration, no
app-record update, no SDK generation). Also renders the diff on a normal
`dev --once` sync.

<img width="646" height="179" alt="image"
src="https://github.com/user-attachments/assets/59f3ddcd-2a5b-4b8a-b21a-c659abe16af0"
/>
2026-06-05 17:49:02 +00:00
bfb83e93b2 fix(metadata): resolve junction targets order-independently during mgration (#21193)
A junction relation points at a target field on the join object that
another action may create later (two junctions into the same join
reference each other). The builder validator now also looks up the
target in the to be created set, and the runner mints every field id up
front so the target resolves regardless of action order, the same way
relation pairs are already handled.

---------

Co-authored-by: Félix Malfait <felix@twenty.com>
2026-06-05 17:32:25 +00:00
20d9244639 fix(server): gate viewFilter.relationTargetFieldMetadataId behind its 2.6 upgrade command (#21267)
## Problem

Self-hosted upgrades crossing 2.6 (e.g. `2.4 → 2.6/2.9`) can abort with:

```
column ViewFilterEntity.relationTargetFieldMetadataId does not exist
  at WorkspaceFlatViewFilterMapCacheService.computeForCache
  at WorkspaceCacheService.recomputeDataFromProvider
[UpgradeSequenceRunnerService] Workspace steps ended with 1 failure(s). Aborting
```

This is **Failure #1** from #20841 — the counterpart to the
role-permission cache crash fixed in #21257 (Failure #2). Same shape: a
workspace **cache recompute runs mid-upgrade and reads schema that the
target version's migration hasn't applied yet**.

## Root cause

`ViewFilterEntity.relationTargetFieldMetadataId` is added to
`core.viewFilter` only at the **2.6.0** cursor
(`AddRelationTargetFieldMetadataIdToViewFilterFastInstanceCommand`, ts
`1798000005000`). But the workspace cache recompute SELECTs every column
of the entity, and it runs during *earlier* (2.5) workspace steps.
Unlike `RolePermissionFlagEntity.permissionFlag`, this column has **no
`@WasIntroducedInUpgrade` gate**, so the proxy can't hide it — and the
SELECT fails when the column isn't there yet.

There are three `IF NOT EXISTS` backport commands (2.3/2.4/2.5) meant to
add the column sooner, but they use **low timestamps** that sort to the
front of their version bundles. An instance whose cursor has already
advanced past those positions (e.g. it reached 2.4, or a prior failed
attempt advanced it through 2.5 instance commands) treats them as
already-applied and **skips them** — so the column is never created, yet
the entity keeps selecting it.

## Fix

Gate the column with `@WasIntroducedInUpgrade` pointing at the **2.6.0**
command that adds it:

```ts
@WasIntroducedInUpgrade({
  upgradeCommandName:
    '2.6.0_AddRelationTargetFieldMetadataIdToViewFilterFastInstanceCommand_1798000005000',
})
@Column({ nullable: true, type: 'uuid', default: null })
relationTargetFieldMetadataId: string | null;
```

`UpgradeAwareRepositoryProxy` then hides the column from reads while the
cursor is < 2.6, so the cache recompute simply omits it — no crash — and
it becomes visible once the 2.6.0 command has run (where it's guaranteed
to exist). Gating to **2.6.0** specifically (not the earlier backports)
is what fixes the cursor-skip case: 2.6.0 is the first point where the
column is reliably present regardless of whether the backports ran.

Validator-safe: the referenced command resolves to a real step
(`computeCommandName` = `${version}_${className}_${timestamp}`), so
`validate-upgrade-aware-entity-decorators` accepts it. The existing
backport commands are left untouched (committed instance commands).

## Recovery for already-stuck instances

This prevents *new* failures. An instance already aborted mid-upgrade
needs the column added manually before retrying:

```sql
ALTER TABLE core."viewFilter" ADD COLUMN IF NOT EXISTS "relationTargetFieldMetadataId" uuid;
DELETE FROM core."upgradeMigration" WHERE status='failed';
```
then re-run the upgrade on a build that includes this fix.

Refs #20841

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 18:56:02 +02:00
Thomas TrompetteandGitHub 51e3eddb29 Add job queue latency histogram metric (#21260)
## Summary
- Records the time each job spends waiting in the queue (enqueue →
processing start) as an OpenTelemetry histogram
- Metric is broken down by `queue` and `job_name` attributes, enabling
per-queue p50/p95/p99 latency analysis
- Uses BullMQ's native `job.timestamp` for accurate measurement

## Test plan
- [x] Deploy to staging and verify `job/latency-ms` metric appears in
ClickHouse `otel_metrics_histogram` table
- [x] Confirm Grafana dashboard can query the histogram data
2026-06-05 16:04:54 +00:00
EtienneandGitHub 27db8bbae4 fixes - remove billing cancellation at trial end + disable ai chat if suspended (#21261)
- Remove billing cancelation if trial is ended (subscription activated)
- Disable AI Chat use if workspace suspended
2026-06-05 15:34:12 +00:00
84ba7eb8dc fix(app-dev): serialize dev sync per workspace with a cache lock (#21250)
Split out of #21240. Stacked on #21249 (review/merge that first).

Concurrent `syncApplication` calls on the same workspace could
interleave their metadata migrations and leave metadata partially
applied. Wrap the manifest sync in a per-workspace cache lock
(`app-sync:<workspaceId>`), mirroring the install path. The rate-limit
throttle stays outside the lock.

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-06-05 17:30:55 +02:00
a3d73740a1 fix(server): guard role-permission cache against stripped permissionFlag relation during upgrade (#21257)
## Problem

Self-hosted upgrades that jump versions (e.g. `2.4 → 2.7/2.9`) abort
with:

```
TypeError: Cannot read properties of undefined (reading 'universalIdentifier')
  at WorkspaceRolesPermissionsCacheService.hasSettingsGatedObjectPermissions
  at WorkspaceRolesPermissionsCacheService.computeForCache
  at WorkspaceCacheService.recomputeDataFromProvider
```

Reported in #20841 (Failure #2). The sequence aborts mid-upgrade and
leaves the DB in a half-migrated state.

## Root cause

The per-workspace **cache recompute runs at a `2.5.0` workspace step —
before the `2.6` schema migrations apply**. At that cursor:

- `RolePermissionFlagEntity.permissionFlag` is
`@WasIntroducedInUpgrade('2.6.0_LinkRolePermissionFlagToPermissionFlag…')`,
so `UpgradeAwareRepositoryProxy` **strips the relation**
(`[upgrade-proxy] strip relation
RolePermissionFlagEntity.permissionFlag` in the logs) → `permissionFlag`
is `undefined`.
- `hasSettingsGatedObjectPermissions()` then does an **unguarded**
`rolePermissionFlag.permissionFlag.universalIdentifier` → throws.

The crash only manifests when a workspace has **≥1 `rolePermissionFlag`
row** (custom roles with gated settings perms / SDK `defineRole`). A
vanilla seed has an empty table, so `.find()` over `[]` never
dereferences anything — which is why it didn't reproduce on a clean
instance.

A null-safe fallback to the legacy `flag` column used to exist here; it
was dropped in #20730.

## Fix

Resolve the flag's universal identifier through a small helper that
falls back to the legacy `flag` column (only removed in `2.7.0`) when
the relation is unavailable:

```ts
private getRolePermissionFlagUniversalIdentifier(
  rolePermissionFlag: RolePermissionFlagEntity,
): string {
  // The `permissionFlag` relation is stripped during upgrades until the 2.6.0
  // cursor (@WasIntroducedInUpgrade), so fall back to the legacy `flag` column.
  return (
    rolePermissionFlag.permissionFlag?.universalIdentifier ??
    SystemPermissionFlag[rolePermissionFlag.flag]
  );
}
```

`SystemPermissionFlag[flag]` yields the same UUID the relation would, so
the comparison stays in a single space and the computed permission is
exact (not an over-grant). Correct at every transitional cursor:
pre-`2.6` (relation stripped → use `flag`), `2.6` (both present →
relation wins), post-`2.7` (`flag` removed → relation wins).

## Reproduction & validation

Locally jumped a real `2.4.0` DB → `v2.9.0` build via `yarn command:prod
upgrade`:

| Scenario | Result |
| --- | --- |
| Empty `permissionFlag` (vanilla seed) | passes (no crash) |
| **+1 flag row**, current code | `TypeError … universalIdentifier` →
**3 succeeded, 1 failed** |
| Same fixture, **this fix** | **16 succeeded, 0 failed**, DB fully
migrated to 2.9.0 |

`nx typecheck twenty-server` clean; existing cache-service unit tests
pass; app boots on the upgraded DB.

## Scope / follow-up

This fixes **Failure #2**. **Failure #1** in the same issue
(`viewFilter.relationTargetFieldMetadataId` selected before its column
exists) is a separate instance of the same theme — cache recompute
reading "future" schema before migrations run — and is worth a
follow-up. A more durable systemic fix would defer the workspace cache
recompute until after all schema-adding migrations; this PR is the
low-risk, backport-friendly fix for the immediate breakage.

> Note: an earlier bot branch
(`sonarly-39738-fixupgrade-guard-role-permission-flag-relation`)
proposed the same fallback inline. This PR supersedes it with a named
helper + a focused comment.

Fixes #20841

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 14:45:17 +00:00
Thomas TrompetteandGitHub 0d3c7a47af fix: guard against undefined logicFunctionId when destroying workflow CODE steps (#21256)
## Summary
- Adds `isDefined` guard in `handleLogicFunctionSubEntities` to skip
CODE steps with undefined `logicFunctionId` instead of crashing
- Adds same guard in `runWorkflowVersionStepDeletionSideEffects` for
consistency
- Rejects CODE steps in `create_complete_workflow` AI tool at runtime to
prevent creating workflows with missing logic functions in the first
place

Fixes `"Logic function with id undefined not found"`
INTERNAL_SERVER_ERROR when destroying workflows whose CODE steps were
created via `create_complete_workflow` without a proper logic function.

## Test plan
- [x] Destroy a workflow that has a CODE step with undefined
logicFunctionId → should succeed silently
- [x] Try creating a workflow with a CODE step via
`create_complete_workflow` tool → should return error message
- [x] Normal workflow destroy with valid CODE steps still deletes the
logic function
2026-06-05 14:30:19 +00:00
2dbdd72e65 chore: bump version to 2.11.0 (#21259)
## Summary

- Moves current version to previous versions array
- Sets TWENTY_CURRENT_VERSION to the new version
- Updates TWENTY_NEXT_VERSIONS with the next minor version
- Bumps twenty-client-sdk, twenty-sdk, and create-twenty-app to the same
version

## Checklist

- [ ] Verify version constants are correct
- [ ] Verify npm package versions match

Co-authored-by: Github Action Deploy <github-action-deploy@twenty.com>
2026-06-05 16:47:40 +02:00
f20d04eb6e feat(app-dev): surface metadata diff in dev sync and name failing migration actions (#21249)
Split out of #21240.

- Render the applied metadata changes (created/updated/deleted +
identifiers) in the dev sync output instead of a bare `✓ Synced`.
- Include the failing entity's `universalIdentifier` in
`WorkspaceMigrationRunnerException` messages so conflicts are
diagnosable.

<img width="637" height="114" alt="image"
src="https://github.com/user-attachments/assets/61422a16-370c-4e9b-a2f6-c29ce17f3b1b"
/>

<img width="497" height="104" alt="image"
src="https://github.com/user-attachments/assets/d493c398-da29-49c9-ac5e-aa0f26cd7389"
/>

<img width="593" height="127" alt="image"
src="https://github.com/user-attachments/assets/15e26edc-c0e4-4427-bd34-909040e970c9"
/>

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-06-05 16:35:50 +02:00
nitinandGitHub f72898063a upgrade command patch - warn instead of throw when no calendarEvent object found in the workspace (#21258) 2026-06-05 16:32:16 +02:00
419 changed files with 4435 additions and 3206 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
</a>
</p>
<h2 align="center" >The #1 Open-Source CRM</h2>
<h2 align="center">The #1 Open-Source CRM</h2>
<p align="center"><a href="https://twenty.com"><img src="./packages/twenty-website/public/images/readme/globe-icon.svg" width="12" height="12"/> Website</a> · <a href="https://docs.twenty.com"><img src="./packages/twenty-website/public/images/readme/book-icon.svg" width="12" height="12"/> Documentation</a> · <a href="https://github.com/orgs/twentyhq/projects/1"><img src="./packages/twenty-website/public/images/readme/map-icon.svg" width="12" height="12"/> Roadmap </a> · <a href="https://discord.gg/cx5n4Jzs57"><img src="./packages/twenty-website/public/images/readme/discord-icon.svg" width="12" height="12"/> Discord</a> · <a href="https://www.figma.com/file/xt8O9mFeLl46C5InWwoMrN/Twenty"><img src="./packages/twenty-website/public/images/readme/figma-icon.webp" width="12" height="12"/> Figma</a></p>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "create-twenty-app",
"version": "2.10.0",
"version": "2.11.0",
"description": "Command-line interface to create Twenty application",
"main": "dist/cli.cjs",
"bin": "dist/cli.cjs",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "twenty-client-sdk",
"version": "2.10.0",
"version": "2.11.0",
"sideEffects": false,
"license": "AGPL-3.0",
"scripts": {
@@ -1434,11 +1434,6 @@ type EnterpriseSubscriptionStatusDTO {
isCancellationScheduled: Boolean!
}
type Analytics {
"""Boolean that confirms query was dispatched"""
success: Boolean!
}
type VerificationRecord {
type: String!
key: String!
@@ -2621,6 +2616,11 @@ type SendEmailOutput {
error: String
}
type Analytics {
"""Boolean that confirms query was dispatched"""
success: Boolean!
}
type EventLogRecord {
event: String!
timestamp: DateTime!
@@ -3007,7 +3007,6 @@ type Query {
): IndexConnection!
findManyAgents: [Agent!]!
findOneAgent(input: AgentIdInput!): Agent!
myConnectedAccounts: [ConnectedAccountPublicDTO!]!
getRoles: [Role!]!
getToolIndex: [ToolIndexEntry!]!
getToolInputSchema(toolName: String!): JSON
@@ -3028,6 +3027,7 @@ type Query {
getViewGroup(id: String!): ViewGroup
myMessageFolders(messageChannelId: UUID): [MessageFolder!]!
myMessageChannels(connectedAccountId: UUID): [MessageChannel!]!
myConnectedAccounts: [ConnectedAccountPublicDTO!]!
myCalendarChannels(connectedAccountId: UUID): [CalendarChannel!]!
minimalMetadata: MinimalMetadata!
appConnections(filter: ListAppConnectionsInput): [AppConnection!]!
@@ -3200,8 +3200,6 @@ type Mutation {
updateApiKey(input: UpdateApiKeyInput!): ApiKey
revokeApiKey(input: RevokeApiKeyInput!): ApiKey
assignRoleToApiKey(apiKeyId: UUID!, roleId: UUID!): Boolean!
createObjectEvent(event: String!, recordId: UUID!, objectMetadataId: UUID!, properties: JSON): Analytics!
trackAnalytics(type: AnalyticsType!, name: String, event: String, properties: JSON): Analytics!
skipSyncEmailOnboardingStep: OnboardingStepSuccess!
skipBookOnboardingStep: OnboardingStepSuccess!
checkoutSession(recurringInterval: SubscriptionInterval!, plan: BillingPlanKey! = PRO, requirePaymentMethod: Boolean! = true, successUrlPath: String): BillingSession!
@@ -3254,7 +3252,6 @@ type Mutation {
createOneAgent(input: CreateAgentInput!): Agent!
updateOneAgent(input: UpdateAgentInput!): Agent!
deleteOneAgent(input: AgentIdInput!): Agent!
deleteConnectedAccount(id: UUID!): ConnectedAccountPublicDTO!
updateWorkspaceMemberRole(workspaceMemberId: UUID!, roleId: UUID!): WorkspaceMember!
createOneRole(createRoleInput: CreateRoleInput!): Role!
updateOneRole(updateRoleInput: UpdateRoleInput!): Role!
@@ -3283,6 +3280,7 @@ type Mutation {
updateMessageChannel(input: UpdateMessageChannelInput!): MessageChannel!
createEmailGroupChannel(input: CreateEmailGroupChannelInput!): CreateEmailGroupChannelOutput!
deleteEmailGroupChannel(id: UUID!): MessageChannel!
deleteConnectedAccount(id: UUID!): ConnectedAccountPublicDTO!
updateCalendarChannel(input: UpdateCalendarChannelInput!): CalendarChannel!
createChatThread: AgentChatThread!
sendChatMessage(threadId: UUID!, text: String!, messageId: UUID!, browsingContext: JSON, modelId: String, fileAttachments: [FileAttachmentInput!]): SendChatMessageResult!
@@ -3344,6 +3342,8 @@ type Mutation {
createSAMLIdentityProvider(input: SetupSAMLSsoInput!): SetupSso!
deleteSSOIdentityProvider(input: DeleteSsoInput!): DeleteSso!
editSSOIdentityProvider(input: EditSsoInput!): EditSso!
createObjectEvent(event: String!, recordId: UUID!, objectMetadataId: UUID!, properties: JSON): Analytics!
trackAnalytics(type: AnalyticsType!, name: String, event: String, properties: JSON): Analytics!
duplicateDashboard(id: UUID!): DuplicatedDashboard!
impersonate(userId: UUID!, workspaceId: UUID!): Impersonate!
sendEmail(input: SendEmailInput!): SendEmailOutput!
@@ -3360,7 +3360,7 @@ type Mutation {
syncMarketplaceCatalog: Boolean!
createDevelopmentApplication(universalIdentifier: String!, name: String!): DevelopmentApplication!
generateApplicationToken(applicationId: UUID!): ApplicationTokenPair!
syncApplication(manifest: JSON!): WorkspaceMigration!
syncApplication(manifest: JSON!, dryRun: Boolean): WorkspaceMigration!
uploadApplicationFile(file: Upload!, applicationUniversalIdentifier: String!, fileFolder: FileFolder!, filePath: String!): File!
upgradeApplication(appRegistrationId: String!, targetVersion: String!): Boolean!
renewApplicationToken(applicationRefreshToken: String!): ApplicationTokenPair!
@@ -3716,11 +3716,6 @@ input RevokeApiKeyInput {
id: UUID!
}
enum AnalyticsType {
PAGEVIEW
TRACK
}
input CreateApprovedAccessDomainInput {
domain: String!
email: String!
@@ -4430,6 +4425,11 @@ input EditSsoInput {
status: SSOIdentityProviderStatus!
}
enum AnalyticsType {
PAGEVIEW
TRACK
}
input SendEmailInput {
connectedAccountId: String!
to: String!
@@ -4498,6 +4498,7 @@ type Subscription {
onEventSubscription(eventStreamId: String!): EventSubscription
logicFunctionLogs(input: LogicFunctionLogsInput!): LogicFunctionLogs!
onAgentChatEvent(threadId: UUID!): AgentChatEvent!
eventLogsLive(table: EventLogTable!): [EventLogRecord!]
}
input LogicFunctionLogsInput {
@@ -1091,12 +1091,6 @@ export interface EnterpriseSubscriptionStatusDTO {
__typename: 'EnterpriseSubscriptionStatusDTO'
}
export interface Analytics {
/** Boolean that confirms query was dispatched */
success: Scalars['Boolean']
__typename: 'Analytics'
}
export interface VerificationRecord {
type: Scalars['String']
key: Scalars['String']
@@ -2314,6 +2308,12 @@ export interface SendEmailOutput {
__typename: 'SendEmailOutput'
}
export interface Analytics {
/** Boolean that confirms query was dispatched */
success: Scalars['Boolean']
__typename: 'Analytics'
}
export interface EventLogRecord {
event: Scalars['String']
timestamp: Scalars['DateTime']
@@ -2613,7 +2613,6 @@ export interface Query {
indexMetadatas: IndexConnection
findManyAgents: Agent[]
findOneAgent: Agent
myConnectedAccounts: ConnectedAccountPublicDTO[]
getRoles: Role[]
getToolIndex: ToolIndexEntry[]
getToolInputSchema?: Scalars['JSON']
@@ -2625,6 +2624,7 @@ export interface Query {
getViewGroup?: ViewGroup
myMessageFolders: MessageFolder[]
myMessageChannels: MessageChannel[]
myConnectedAccounts: ConnectedAccountPublicDTO[]
myCalendarChannels: CalendarChannel[]
minimalMetadata: MinimalMetadata
appConnections: AppConnection[]
@@ -2724,8 +2724,6 @@ export interface Mutation {
updateApiKey?: ApiKey
revokeApiKey?: ApiKey
assignRoleToApiKey: Scalars['Boolean']
createObjectEvent: Analytics
trackAnalytics: Analytics
skipSyncEmailOnboardingStep: OnboardingStepSuccess
skipBookOnboardingStep: OnboardingStepSuccess
checkoutSession: BillingSession
@@ -2778,7 +2776,6 @@ export interface Mutation {
createOneAgent: Agent
updateOneAgent: Agent
deleteOneAgent: Agent
deleteConnectedAccount: ConnectedAccountPublicDTO
updateWorkspaceMemberRole: WorkspaceMember
createOneRole: Role
updateOneRole: Role
@@ -2807,6 +2804,7 @@ export interface Mutation {
updateMessageChannel: MessageChannel
createEmailGroupChannel: CreateEmailGroupChannelOutput
deleteEmailGroupChannel: MessageChannel
deleteConnectedAccount: ConnectedAccountPublicDTO
updateCalendarChannel: CalendarChannel
createChatThread: AgentChatThread
sendChatMessage: SendChatMessageResult
@@ -2868,6 +2866,8 @@ export interface Mutation {
createSAMLIdentityProvider: SetupSso
deleteSSOIdentityProvider: DeleteSso
editSSOIdentityProvider: EditSso
createObjectEvent: Analytics
trackAnalytics: Analytics
duplicateDashboard: DuplicatedDashboard
impersonate: Impersonate
sendEmail: SendEmailOutput
@@ -2892,16 +2892,17 @@ export interface Mutation {
__typename: 'Mutation'
}
export type AnalyticsType = 'PAGEVIEW' | 'TRACK'
export type WorkspaceMigrationActionType = 'delete' | 'create' | 'update'
export type AnalyticsType = 'PAGEVIEW' | 'TRACK'
export type FileFolder = 'ProfilePicture' | 'WorkspaceLogo' | 'Attachment' | 'PersonPicture' | 'CorePicture' | 'File' | 'AgentChat' | 'BuiltLogicFunction' | 'BuiltFrontComponent' | 'PublicAsset' | 'Source' | 'FilesField' | 'Dependencies' | 'Workflow' | 'EmailAttachment' | 'AppTarball' | 'GeneratedSdkClient'
export interface Subscription {
onEventSubscription?: EventSubscription
logicFunctionLogs: LogicFunctionLogs
onAgentChatEvent: AgentChatEvent
eventLogsLive?: EventLogRecord[]
__typename: 'Subscription'
}
@@ -4049,13 +4050,6 @@ export interface EnterpriseSubscriptionStatusDTOGenqlSelection{
__scalar?: boolean | number
}
export interface AnalyticsGenqlSelection{
/** Boolean that confirms query was dispatched */
success?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface VerificationRecordGenqlSelection{
type?: boolean | number
key?: boolean | number
@@ -5364,6 +5358,13 @@ export interface SendEmailOutputGenqlSelection{
__scalar?: boolean | number
}
export interface AnalyticsGenqlSelection{
/** Boolean that confirms query was dispatched */
success?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface EventLogRecordGenqlSelection{
event?: boolean | number
timestamp?: boolean | number
@@ -5671,7 +5672,6 @@ export interface QueryGenqlSelection{
filter: IndexFilter} })
findManyAgents?: AgentGenqlSelection
findOneAgent?: (AgentGenqlSelection & { __args: {input: AgentIdInput} })
myConnectedAccounts?: ConnectedAccountPublicDTOGenqlSelection
getRoles?: RoleGenqlSelection
getToolIndex?: ToolIndexEntryGenqlSelection
getToolInputSchema?: { __args: {toolName: Scalars['String']} }
@@ -5689,6 +5689,7 @@ export interface QueryGenqlSelection{
getViewGroup?: (ViewGroupGenqlSelection & { __args: {id: Scalars['String']} })
myMessageFolders?: (MessageFolderGenqlSelection & { __args?: {messageChannelId?: (Scalars['UUID'] | null)} })
myMessageChannels?: (MessageChannelGenqlSelection & { __args?: {connectedAccountId?: (Scalars['UUID'] | null)} })
myConnectedAccounts?: ConnectedAccountPublicDTOGenqlSelection
myCalendarChannels?: (CalendarChannelGenqlSelection & { __args?: {connectedAccountId?: (Scalars['UUID'] | null)} })
minimalMetadata?: MinimalMetadataGenqlSelection
appConnections?: (AppConnectionGenqlSelection & { __args?: {filter?: (ListAppConnectionsInput | null)} })
@@ -5811,8 +5812,6 @@ export interface MutationGenqlSelection{
updateApiKey?: (ApiKeyGenqlSelection & { __args: {input: UpdateApiKeyInput} })
revokeApiKey?: (ApiKeyGenqlSelection & { __args: {input: RevokeApiKeyInput} })
assignRoleToApiKey?: { __args: {apiKeyId: Scalars['UUID'], roleId: Scalars['UUID']} }
createObjectEvent?: (AnalyticsGenqlSelection & { __args: {event: Scalars['String'], recordId: Scalars['UUID'], objectMetadataId: Scalars['UUID'], properties?: (Scalars['JSON'] | null)} })
trackAnalytics?: (AnalyticsGenqlSelection & { __args: {type: AnalyticsType, name?: (Scalars['String'] | null), event?: (Scalars['String'] | null), properties?: (Scalars['JSON'] | null)} })
skipSyncEmailOnboardingStep?: OnboardingStepSuccessGenqlSelection
skipBookOnboardingStep?: OnboardingStepSuccessGenqlSelection
checkoutSession?: (BillingSessionGenqlSelection & { __args: {recurringInterval: SubscriptionInterval, plan: BillingPlanKey, requirePaymentMethod: Scalars['Boolean'], successUrlPath?: (Scalars['String'] | null)} })
@@ -5865,7 +5864,6 @@ export interface MutationGenqlSelection{
createOneAgent?: (AgentGenqlSelection & { __args: {input: CreateAgentInput} })
updateOneAgent?: (AgentGenqlSelection & { __args: {input: UpdateAgentInput} })
deleteOneAgent?: (AgentGenqlSelection & { __args: {input: AgentIdInput} })
deleteConnectedAccount?: (ConnectedAccountPublicDTOGenqlSelection & { __args: {id: Scalars['UUID']} })
updateWorkspaceMemberRole?: (WorkspaceMemberGenqlSelection & { __args: {workspaceMemberId: Scalars['UUID'], roleId: Scalars['UUID']} })
createOneRole?: (RoleGenqlSelection & { __args: {createRoleInput: CreateRoleInput} })
updateOneRole?: (RoleGenqlSelection & { __args: {updateRoleInput: UpdateRoleInput} })
@@ -5894,6 +5892,7 @@ export interface MutationGenqlSelection{
updateMessageChannel?: (MessageChannelGenqlSelection & { __args: {input: UpdateMessageChannelInput} })
createEmailGroupChannel?: (CreateEmailGroupChannelOutputGenqlSelection & { __args: {input: CreateEmailGroupChannelInput} })
deleteEmailGroupChannel?: (MessageChannelGenqlSelection & { __args: {id: Scalars['UUID']} })
deleteConnectedAccount?: (ConnectedAccountPublicDTOGenqlSelection & { __args: {id: Scalars['UUID']} })
updateCalendarChannel?: (CalendarChannelGenqlSelection & { __args: {input: UpdateCalendarChannelInput} })
createChatThread?: AgentChatThreadGenqlSelection
sendChatMessage?: (SendChatMessageResultGenqlSelection & { __args: {threadId: Scalars['UUID'], text: Scalars['String'], messageId: Scalars['UUID'], browsingContext?: (Scalars['JSON'] | null), modelId?: (Scalars['String'] | null), fileAttachments?: (FileAttachmentInput[] | null)} })
@@ -5955,6 +5954,8 @@ export interface MutationGenqlSelection{
createSAMLIdentityProvider?: (SetupSsoGenqlSelection & { __args: {input: SetupSAMLSsoInput} })
deleteSSOIdentityProvider?: (DeleteSsoGenqlSelection & { __args: {input: DeleteSsoInput} })
editSSOIdentityProvider?: (EditSsoGenqlSelection & { __args: {input: EditSsoInput} })
createObjectEvent?: (AnalyticsGenqlSelection & { __args: {event: Scalars['String'], recordId: Scalars['UUID'], objectMetadataId: Scalars['UUID'], properties?: (Scalars['JSON'] | null)} })
trackAnalytics?: (AnalyticsGenqlSelection & { __args: {type: AnalyticsType, name?: (Scalars['String'] | null), event?: (Scalars['String'] | null), properties?: (Scalars['JSON'] | null)} })
duplicateDashboard?: (DuplicatedDashboardGenqlSelection & { __args: {id: Scalars['UUID']} })
impersonate?: (ImpersonateGenqlSelection & { __args: {userId: Scalars['UUID'], workspaceId: Scalars['UUID']} })
sendEmail?: (SendEmailOutputGenqlSelection & { __args: {input: SendEmailInput} })
@@ -5972,7 +5973,7 @@ export interface MutationGenqlSelection{
syncMarketplaceCatalog?: boolean | number
createDevelopmentApplication?: (DevelopmentApplicationGenqlSelection & { __args: {universalIdentifier: Scalars['String'], name: Scalars['String']} })
generateApplicationToken?: (ApplicationTokenPairGenqlSelection & { __args: {applicationId: Scalars['UUID']} })
syncApplication?: (WorkspaceMigrationGenqlSelection & { __args: {manifest: Scalars['JSON']} })
syncApplication?: (WorkspaceMigrationGenqlSelection & { __args: {manifest: Scalars['JSON'], dryRun?: (Scalars['Boolean'] | null)} })
uploadApplicationFile?: (FileGenqlSelection & { __args: {file: Scalars['Upload'], applicationUniversalIdentifier: Scalars['String'], fileFolder: FileFolder, filePath: Scalars['String']} })
upgradeApplication?: { __args: {appRegistrationId: Scalars['String'], targetVersion: Scalars['String']} }
renewApplicationToken?: (ApplicationTokenPairGenqlSelection & { __args: {applicationRefreshToken: Scalars['String']} })
@@ -6356,6 +6357,7 @@ export interface SubscriptionGenqlSelection{
onEventSubscription?: (EventSubscriptionGenqlSelection & { __args: {eventStreamId: Scalars['String']} })
logicFunctionLogs?: (LogicFunctionLogsGenqlSelection & { __args: {input: LogicFunctionLogsInput} })
onAgentChatEvent?: (AgentChatEventGenqlSelection & { __args: {threadId: Scalars['UUID']} })
eventLogsLive?: (EventLogRecordGenqlSelection & { __args: {table: EventLogTable} })
__typename?: boolean | number
__scalar?: boolean | number
}
@@ -7003,14 +7005,6 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const Analytics_possibleTypes: string[] = ['Analytics']
export const isAnalytics = (obj?: { __typename?: any } | null): obj is Analytics => {
if (!obj?.__typename) throw new Error('__typename is missing in "isAnalytics"')
return Analytics_possibleTypes.includes(obj.__typename)
}
const VerificationRecord_possibleTypes: string[] = ['VerificationRecord']
export const isVerificationRecord = (obj?: { __typename?: any } | null): obj is VerificationRecord => {
if (!obj?.__typename) throw new Error('__typename is missing in "isVerificationRecord"')
@@ -8139,6 +8133,14 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const Analytics_possibleTypes: string[] = ['Analytics']
export const isAnalytics = (obj?: { __typename?: any } | null): obj is Analytics => {
if (!obj?.__typename) throw new Error('__typename is missing in "isAnalytics"')
return Analytics_possibleTypes.includes(obj.__typename)
}
const EventLogRecord_possibleTypes: string[] = ['EventLogRecord']
export const isEventLogRecord = (obj?: { __typename?: any } | null): obj is EventLogRecord => {
if (!obj?.__typename) throw new Error('__typename is missing in "isEventLogRecord"')
@@ -8990,17 +8992,17 @@ export const enumUsageOperationType = {
WEB_SEARCH: 'WEB_SEARCH' as const
}
export const enumAnalyticsType = {
PAGEVIEW: 'PAGEVIEW' as const,
TRACK: 'TRACK' as const
}
export const enumWorkspaceMigrationActionType = {
delete: 'delete' as const,
create: 'create' as const,
update: 'update' as const
}
export const enumAnalyticsType = {
PAGEVIEW: 'PAGEVIEW' as const,
TRACK: 'TRACK' as const
}
export const enumFileFolder = {
ProfilePicture: 'ProfilePicture' as const,
WorkspaceLogo: 'WorkspaceLogo' as const,
File diff suppressed because it is too large Load Diff
+3 -39
View File
@@ -6,10 +6,10 @@ services:
volumes:
- server-local-data:/app/packages/twenty-server/.local-storage
ports:
- "3000:3000"
- "3010:3000"
environment:
NODE_PORT: 3000
PG_DATABASE_URL: postgres://${PG_DATABASE_USER:-postgres}:${PG_DATABASE_PASSWORD:-postgres}@${PG_DATABASE_HOST:-db}:${PG_DATABASE_PORT:-5432}/default
PG_DATABASE_URL: postgres://${PG_DATABASE_USER:-postgres}:${PG_DATABASE_PASSWORD:-postgres}@${PG_DATABASE_HOST:-db}:${PG_DATABASE_PORT:-5432}/${PG_DATABASE_NAME}
SERVER_URL: ${SERVER_URL}
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
DISABLE_DB_MIGRATIONS: ${DISABLE_DB_MIGRATIONS}
@@ -46,11 +46,6 @@ services:
# EMAIL_SMTP_USER: ${EMAIL_SMTP_USER:-}
# EMAIL_SMTP_PASSWORD: ${EMAIL_SMTP_PASSWORD:-}
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: curl --fail http://localhost:3000/healthz
interval: 5s
@@ -64,7 +59,7 @@ services:
- server-local-data:/app/packages/twenty-server/.local-storage
command: ["yarn", "worker:prod"]
environment:
PG_DATABASE_URL: postgres://${PG_DATABASE_USER:-postgres}:${PG_DATABASE_PASSWORD:-postgres}@${PG_DATABASE_HOST:-db}:${PG_DATABASE_PORT:-5432}/default
PG_DATABASE_URL: postgres://${PG_DATABASE_USER:-postgres}:${PG_DATABASE_PASSWORD:-postgres}@${PG_DATABASE_HOST:-db}:${PG_DATABASE_PORT:-5432}/${PG_DATABASE_NAME}
SERVER_URL: ${SERVER_URL}
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
DISABLE_DB_MIGRATIONS: "true" # it already runs on the server
@@ -101,38 +96,7 @@ services:
# EMAIL_SMTP_USER: ${EMAIL_SMTP_USER:-}
# EMAIL_SMTP_PASSWORD: ${EMAIL_SMTP_PASSWORD:-}
depends_on:
db:
condition: service_healthy
server:
condition: service_healthy
restart: always
db:
image: postgres:16
volumes:
- db-data:/var/lib/postgresql/data
environment:
POSTGRES_DB: ${PG_DATABASE_NAME:-default}
POSTGRES_PASSWORD: ${PG_DATABASE_PASSWORD:-postgres}
POSTGRES_USER: ${PG_DATABASE_USER:-postgres}
healthcheck:
test: pg_isready -U ${PG_DATABASE_USER:-postgres} -h localhost -d postgres
interval: 5s
timeout: 5s
retries: 10
restart: always
redis:
image: redis
restart: always
command: ["--maxmemory-policy", "noeviction"]
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 10
volumes:
db-data:
server-local-data:
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 124 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 69 KiB

@@ -3315,6 +3315,7 @@ export type MutationStopAgentChatStreamArgs = {
export type MutationSyncApplicationArgs = {
dryRun?: InputMaybe<Scalars['Boolean']>;
manifest: Scalars['JSON'];
};
@@ -4932,12 +4933,18 @@ export type StandardOverrides = {
export type Subscription = {
__typename?: 'Subscription';
eventLogsLive?: Maybe<Array<EventLogRecord>>;
logicFunctionLogs: LogicFunctionLogs;
onAgentChatEvent: AgentChatEvent;
onEventSubscription?: Maybe<EventSubscription>;
};
export type SubscriptionEventLogsLiveArgs = {
table: EventLogTable;
};
export type SubscriptionLogicFunctionLogsArgs = {
input: LogicFunctionLogsInput;
};
@@ -7403,7 +7410,14 @@ export type EventLogsQueryVariables = Exact<{
}>;
export type EventLogsQuery = { __typename?: 'Query', eventLogs: { __typename?: 'EventLogQueryResult', totalCount: number, records: Array<{ __typename?: 'EventLogRecord', event: string, timestamp: string, userId?: string | null, properties?: any | null, recordId?: string | null, objectMetadataId?: string | null, isCustom?: boolean | null }>, pageInfo: { __typename?: 'EventLogPageInfo', endCursor?: string | null, hasNextPage: boolean } } };
export type EventLogsQuery = { __typename?: 'Query', eventLogs: { __typename?: 'EventLogQueryResult', totalCount: number, records: Array<{ __typename?: 'EventLogRecord', event: string, timestamp: string, userId?: string | null, properties?: any | null, recordId?: string | null, objectMetadataId?: string | null }>, pageInfo: { __typename?: 'EventLogPageInfo', endCursor?: string | null, hasNextPage: boolean } } };
export type EventLogsLiveSubscriptionVariables = Exact<{
table: EventLogTable;
}>;
export type EventLogsLiveSubscription = { __typename?: 'Subscription', eventLogsLive?: Array<{ __typename?: 'EventLogRecord', event: string, timestamp: string, userId?: string | null, properties?: any | null, recordId?: string | null, objectMetadataId?: string | null }> | null };
export type UpdateLabPublicFeatureFlagMutationVariables = Exact<{
input: UpdateLabPublicFeatureFlagInput;
@@ -8254,7 +8268,8 @@ export const SetEnterpriseKeyDocument = {"kind":"Document","definitions":[{"kind
export const EnterpriseCheckoutSessionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"EnterpriseCheckoutSession"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"billingInterval"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"enterpriseCheckoutSession"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"billingInterval"},"value":{"kind":"Variable","name":{"kind":"Name","value":"billingInterval"}}}]}]}}]} as unknown as DocumentNode<EnterpriseCheckoutSessionQuery, EnterpriseCheckoutSessionQueryVariables>;
export const EnterprisePortalSessionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"EnterprisePortalSession"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"returnUrlPath"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"enterprisePortalSession"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"returnUrlPath"},"value":{"kind":"Variable","name":{"kind":"Name","value":"returnUrlPath"}}}]}]}}]} as unknown as DocumentNode<EnterprisePortalSessionQuery, EnterprisePortalSessionQueryVariables>;
export const EnterpriseSubscriptionStatusDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"EnterpriseSubscriptionStatus"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"enterpriseSubscriptionStatus"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"licensee"}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}},{"kind":"Field","name":{"kind":"Name","value":"cancelAt"}},{"kind":"Field","name":{"kind":"Name","value":"currentPeriodEnd"}},{"kind":"Field","name":{"kind":"Name","value":"isCancellationScheduled"}}]}}]}}]} as unknown as DocumentNode<EnterpriseSubscriptionStatusQuery, EnterpriseSubscriptionStatusQueryVariables>;
export const EventLogsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"EventLogs"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"EventLogQueryInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"eventLogs"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"records"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"event"}},{"kind":"Field","name":{"kind":"Name","value":"timestamp"}},{"kind":"Field","name":{"kind":"Name","value":"userId"}},{"kind":"Field","name":{"kind":"Name","value":"properties"}},{"kind":"Field","name":{"kind":"Name","value":"recordId"}},{"kind":"Field","name":{"kind":"Name","value":"objectMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"isCustom"}}]}},{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"pageInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"endCursor"}},{"kind":"Field","name":{"kind":"Name","value":"hasNextPage"}}]}}]}}]}}]} as unknown as DocumentNode<EventLogsQuery, EventLogsQueryVariables>;
export const EventLogsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"EventLogs"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"EventLogQueryInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"eventLogs"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"records"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"event"}},{"kind":"Field","name":{"kind":"Name","value":"timestamp"}},{"kind":"Field","name":{"kind":"Name","value":"userId"}},{"kind":"Field","name":{"kind":"Name","value":"properties"}},{"kind":"Field","name":{"kind":"Name","value":"recordId"}},{"kind":"Field","name":{"kind":"Name","value":"objectMetadataId"}}]}},{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"pageInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"endCursor"}},{"kind":"Field","name":{"kind":"Name","value":"hasNextPage"}}]}}]}}]}}]} as unknown as DocumentNode<EventLogsQuery, EventLogsQueryVariables>;
export const EventLogsLiveDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"subscription","name":{"kind":"Name","value":"EventLogsLive"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"table"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"EventLogTable"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"eventLogsLive"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"table"},"value":{"kind":"Variable","name":{"kind":"Name","value":"table"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"event"}},{"kind":"Field","name":{"kind":"Name","value":"timestamp"}},{"kind":"Field","name":{"kind":"Name","value":"userId"}},{"kind":"Field","name":{"kind":"Name","value":"properties"}},{"kind":"Field","name":{"kind":"Name","value":"recordId"}},{"kind":"Field","name":{"kind":"Name","value":"objectMetadataId"}}]}}]}}]} as unknown as DocumentNode<EventLogsLiveSubscription, EventLogsLiveSubscriptionVariables>;
export const UpdateLabPublicFeatureFlagDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateLabPublicFeatureFlag"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateLabPublicFeatureFlagInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateLabPublicFeatureFlag"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"value"}}]}}]}}]} as unknown as DocumentNode<UpdateLabPublicFeatureFlagMutation, UpdateLabPublicFeatureFlagMutationVariables>;
export const UploadWorkspaceMemberProfilePictureDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UploadWorkspaceMemberProfilePicture"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"file"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Upload"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"uploadWorkspaceMemberProfilePicture"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"file"},"value":{"kind":"Variable","name":{"kind":"Name","value":"file"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}}]}}]} as unknown as DocumentNode<UploadWorkspaceMemberProfilePictureMutation, UploadWorkspaceMemberProfilePictureMutationVariables>;
export const UpdateUserEmailDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateUserEmail"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"newEmail"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"verifyEmailRedirectPath"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateUserEmail"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"newEmail"},"value":{"kind":"Variable","name":{"kind":"Name","value":"newEmail"}}},{"kind":"Argument","name":{"kind":"Name","value":"verifyEmailRedirectPath"},"value":{"kind":"Variable","name":{"kind":"Name","value":"verifyEmailRedirectPath"}}}]}]}}]} as unknown as DocumentNode<UpdateUserEmailMutation, UpdateUserEmailMutationVariables>;
@@ -8,6 +8,7 @@ import { originalDragSelectionComponentState } from '@/object-record/record-drag
import { RECORD_INDEX_REMOVE_SORTING_MODAL_ID } from '@/object-record/record-index/constants/RecordIndexRemoveSortingModalId';
import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';
import { getBoardCardDropBehavior } from '@/object-record/record-board/utils/getBoardCardDropBehavior';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { useAtomComponentSelectorCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorCallbackState';
import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
@@ -82,8 +83,13 @@ export const RecordBoardDragDropContext = ({
}
const existingRecordSorts = store.get(currentRecordSorts);
const boardCardDropBehavior = getBoardCardDropBehavior({
hasRecordSorts: existingRecordSorts.length > 0,
sourceDroppableId: result.source.droppableId,
destinationDroppableId: result.destination.droppableId,
});
if (existingRecordSorts.length > 0) {
if (boardCardDropBehavior.shouldBlockDrop) {
store.set(isRecordBoardDropProcessingCallbackState, false);
endRecordDrag();
openModal(RECORD_INDEX_REMOVE_SORTING_MODAL_ID);
@@ -91,7 +97,9 @@ export const RecordBoardDragDropContext = ({
}
try {
processBoardCardDrop(result, originalDragSelection);
processBoardCardDrop(result, originalDragSelection, {
shouldUpdatePosition: boardCardDropBehavior.shouldUpdatePosition,
});
} catch (error) {
store.set(isRecordBoardDropProcessingCallbackState, false);
endRecordDrag();
@@ -0,0 +1,42 @@
import { getBoardCardDropBehavior } from '@/object-record/record-board/utils/getBoardCardDropBehavior';
describe('getBoardCardDropBehavior', () => {
it('should block same-column drops when record sorting is active', () => {
expect(
getBoardCardDropBehavior({
hasRecordSorts: true,
sourceDroppableId: 'new',
destinationDroppableId: 'new',
}),
).toEqual({
shouldBlockDrop: true,
shouldUpdatePosition: false,
});
});
it('should allow cross-column drops without position updates when record sorting is active', () => {
expect(
getBoardCardDropBehavior({
hasRecordSorts: true,
sourceDroppableId: 'new',
destinationDroppableId: 'won',
}),
).toEqual({
shouldBlockDrop: false,
shouldUpdatePosition: false,
});
});
it('should allow drops with position updates when record sorting is inactive', () => {
expect(
getBoardCardDropBehavior({
hasRecordSorts: false,
sourceDroppableId: 'new',
destinationDroppableId: 'won',
}),
).toEqual({
shouldBlockDrop: false,
shouldUpdatePosition: true,
});
});
});
@@ -0,0 +1,17 @@
export const getBoardCardDropBehavior = ({
hasRecordSorts,
sourceDroppableId,
destinationDroppableId,
}: {
hasRecordSorts: boolean;
sourceDroppableId: string;
destinationDroppableId: string;
}) => {
const isMovingInsideSameRecordGroup =
sourceDroppableId === destinationDroppableId;
return {
shouldBlockDrop: hasRecordSorts && isMovingInsideSameRecordGroup,
shouldUpdatePosition: !hasRecordSorts,
};
};
@@ -40,9 +40,15 @@ export const useProcessBoardCardDrop = () => {
);
const processBoardCardDrop = useCallback(
(boardCardDropResult: DropResult, selectedRecordIds: string[]) => {
(
boardCardDropResult: DropResult,
selectedRecordIds: string[],
options?: { shouldUpdatePosition?: boolean },
) => {
if (!isDefined(selectFieldMetadataItem)) return;
const shouldUpdatePosition = options?.shouldUpdatePosition ?? true;
processGroupDrop({
groupDropResult: boardCardDropResult,
store,
@@ -51,7 +57,10 @@ export const useProcessBoardCardDrop = () => {
recordIndexRecordIdsByGroupCallbackFamilyState,
onUpdateRecord: ({ recordId, position }, targetRecordGroupValue) => {
updateDroppedRecordOnBoard(
{ recordId, position },
{
recordId,
position: shouldUpdatePosition ? position : undefined,
},
targetRecordGroupValue,
);
},
@@ -45,10 +45,6 @@ export const useUpdateDroppedRecordOnBoard = () => {
recordStoreFamilyState.atomFamily(recordId),
) as Record<string, unknown> | null | undefined;
if (!isDefined(newPosition)) {
return;
}
if (!isDefined(initialRecord)) {
return;
}
@@ -94,7 +90,10 @@ export const useUpdateDroppedRecordOnBoard = () => {
const isSamePosition = initialRecord.position === newPosition;
if (movingInsideSameRecordGroup && isSamePosition) {
if (
movingInsideSameRecordGroup &&
(!isDefined(newPosition) || isSamePosition)
) {
return;
}
@@ -126,25 +125,32 @@ export const useUpdateDroppedRecordOnBoard = () => {
);
}
const targetGroupRecordsWithIds = extractRecordPositions(
currentRecordIdsInTargetRecordGroup,
store,
);
if (isDefined(newPosition)) {
const targetGroupRecordsWithIds = extractRecordPositions(
currentRecordIdsInTargetRecordGroup,
store,
);
const newTargetRecordGroupWithIds = [
...targetGroupRecordsWithIds,
{
id: recordId,
position: newPosition,
},
];
const newTargetRecordGroupWithIds = [
...targetGroupRecordsWithIds,
{
id: recordId,
position: newPosition,
},
];
newTargetRecordGroupWithIds.sort(sortByProperty('position', 'asc'));
newTargetRecordGroupWithIds.sort(sortByProperty('position', 'asc'));
store.set(
recordIndexRecordIdsByGroupCallbackFamilyState(targetRecordGroupId),
newTargetRecordGroupWithIds.map((record) => record.id),
);
store.set(
recordIndexRecordIdsByGroupCallbackFamilyState(targetRecordGroupId),
newTargetRecordGroupWithIds.map((record) => record.id),
);
} else {
store.set(
recordIndexRecordIdsByGroupCallbackFamilyState(targetRecordGroupId),
[...currentRecordIdsInTargetRecordGroup, recordId],
);
}
upsertRecordsInStore({
partialRecords: [
@@ -155,7 +161,7 @@ export const useUpdateDroppedRecordOnBoard = () => {
(initialRecord as { __typename?: string })?.__typename ??
'Record',
[selectFieldMetadataItem.name]: targetRecordGroupValue,
position: newPosition,
...(isDefined(newPosition) && { position: newPosition }),
} as ObjectRecord,
],
});
@@ -164,7 +170,7 @@ export const useUpdateDroppedRecordOnBoard = () => {
idToUpdate: recordId,
updateOneRecordInput: {
[selectFieldMetadataItem.name]: targetRecordGroupValue,
position: newPosition,
...(isDefined(newPosition) && { position: newPosition }),
},
});
},
Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 40 KiB

@@ -1,4 +1 @@
export {
SettingsDatePickerInput as EventLogDatePickerInput,
type SettingsDatePickerInputProps as EventLogDatePickerInputProps,
} from '@/settings/components/SettingsDatePickerInput';
export { SettingsDatePickerInput as EventLogDatePickerInput } from '@/settings/components/SettingsDatePickerInput';
@@ -12,18 +12,16 @@ import { TableRow } from '@/ui/layout/table/components/TableRow';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { useScrollWrapperHTMLElement } from '@/ui/utilities/scroll/hooks/useScrollWrapperHTMLElement';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { beautifyPastDateRelativeToNow } from '~/utils/date-utils';
import {
type EventLogRecord,
EventLogTable,
type EventLogTable,
} from '~/generated-metadata/graphql';
import {
type ColumnConfig,
getColumnsForEventLogTable,
} from '@/settings/event-logs/utils/getColumnsForEventLogTable';
import { EventLogJsonCell } from '@/settings/event-logs/components/EventLogJsonCell';
type EventLogResultsTableProps = {
records: EventLogRecord[];
@@ -106,9 +104,6 @@ export const EventLogResultsTable = ({
const { theme } = useContext(ThemeContext);
const { t } = useLingui();
const showObjectEventColumns = selectedTable === EventLogTable.OBJECT_EVENT;
const showApplicationLogColumns =
selectedTable === EventLogTable.APPLICATION_LOG;
const baseColumns = getColumnsForEventLogTable(selectedTable);
const [columnWidths, setColumnWidths] = useState<Record<string, number>>(() =>
@@ -117,7 +112,6 @@ export const EventLogResultsTable = ({
const [resizingColumn, setResizingColumn] = useState<string | null>(null);
// Reset column widths when switching tables to avoid undefined widths for new columns
useEffect(() => {
setColumnWidths(
Object.fromEntries(baseColumns.map((col) => [col.id, col.defaultWidth])),
@@ -242,85 +236,21 @@ export const EventLogResultsTable = ({
</StyledResizableHeaderContainer>
))}
</TableRow>
{records.map((record, index) => (
{records.map((record) => (
<TableRow
key={`${record.timestamp}-${record.event}-${index}`}
key={`${record.timestamp}-${record.event}`}
gridTemplateColumns={gridTemplateColumns}
>
<TableCell
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
{record.event}
</TableCell>
<TableCell
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
{beautifyPastDateRelativeToNow(record.timestamp)}
</TableCell>
{showApplicationLogColumns ? (
<>
<TableCell
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
{record.properties?.level ?? '-'}
</TableCell>
<TableCell
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
{record.properties?.message ?? '-'}
</TableCell>
<TableCell
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
{record.properties?.executionId ?? '-'}
</TableCell>
</>
) : (
<>
<TableCell
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
{record.userId ?? '-'}
</TableCell>
{showObjectEventColumns && (
<>
<TableCell
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
{record.recordId ?? '-'}
</TableCell>
<TableCell
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
{record.objectMetadataId ?? '-'}
</TableCell>
</>
)}
<TableCell
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
<EventLogJsonCell value={record.properties} />
</TableCell>
</>
)}
{baseColumns.map((column) => (
<TableCell
key={column.id}
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
{column.renderCell(record)}
</TableCell>
))}
</TableRow>
))}
</Table>
@@ -1,3 +1,5 @@
import { type MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { Select } from '@/ui/input/components/Select';
@@ -8,34 +10,26 @@ type EventLogTableSelectorProps = {
onChange: (value: EventLogTable) => void;
};
const TABLE_LABELS: Record<EventLogTable, MessageDescriptor> = {
[EventLogTable.PAGEVIEW]: msg`Page Views`,
[EventLogTable.WORKSPACE_EVENT]: msg`Workspace Events`,
[EventLogTable.OBJECT_EVENT]: msg`Object Events`,
[EventLogTable.USAGE_EVENT]: msg`Usage Events`,
[EventLogTable.APPLICATION_LOG]: msg`Application Logs`,
};
export const EventLogTableSelector = ({
value,
onChange,
}: EventLogTableSelectorProps) => {
const { t } = useLingui();
const options = [
{
value: EventLogTable.PAGEVIEW,
label: t`Page Views`,
},
{
value: EventLogTable.WORKSPACE_EVENT,
label: t`Workspace Events`,
},
{
value: EventLogTable.OBJECT_EVENT,
label: t`Object Events`,
},
{
value: EventLogTable.USAGE_EVENT,
label: t`Usage Events`,
},
{
value: EventLogTable.APPLICATION_LOG,
label: t`Application Logs`,
},
];
const options = (
Object.entries(TABLE_LABELS) as [EventLogTable, MessageDescriptor][]
).map(([table, label]) => ({
value: table,
label: t(label),
}));
return (
<Select
@@ -1,24 +1,51 @@
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { useState } from 'react';
import { useMemo, useState } from 'react';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { billingState } from '@/client-config/states/billingState';
import { isClickHouseConfiguredState } from '@/client-config/states/isClickHouseConfiguredState';
import { SettingsEmptyPlaceholder } from '@/settings/components/SettingsEmptyPlaceholder';
import { SettingsEnterpriseFeatureGateCard } from '@/settings/components/SettingsEnterpriseFeatureGateCard';
import { SettingsOptionCardContentButton } from '@/settings/components/SettingsOptions/SettingsOptionCardContentButton';
import { EventLogFilters } from '@/settings/event-logs/components/EventLogFilters';
import { EventLogResultsTable } from '@/settings/event-logs/components/EventLogResultsTable';
import { EventLogTableSelector } from '@/settings/event-logs/components/EventLogTableSelector';
import { useEventLogsLiveStream } from '@/settings/event-logs/hooks/useEventLogsLiveStream';
import { useEventLogs } from '@/settings/event-logs/hooks/useQueryEventLogs';
import { type EventLogFiltersState } from '@/settings/event-logs/types/EventLogFiltersState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { SettingsPath } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { IconRefresh } from 'twenty-ui/display';
import { IconButton } from 'twenty-ui/input';
import {
IconArrowUp,
IconLock,
IconPlayerPause,
IconPlayerPlay,
} from 'twenty-ui/display';
import { Button, IconButton } from 'twenty-ui/input';
import { Card } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { EventLogTable } from '~/generated-metadata/graphql';
import {
BillingEntitlementKey,
EventLogTable,
} from '~/generated-metadata/graphql';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { isGraphqlErrorOfType } from '~/utils/is-graphql-error-of-type.util';
const StyledRoot = styled.div`
box-sizing: border-box;
display: flex;
flex: 1;
flex-direction: column;
gap: ${themeCssVariables.spacing[6]};
margin: 0 auto;
max-width: 760px;
min-height: 0;
padding: ${themeCssVariables.spacing[6]} ${themeCssVariables.spacing[8]}
${themeCssVariables.spacing[8]};
width: 100%;
`;
const StyledCardContent = styled.div`
display: flex;
@@ -40,8 +67,10 @@ const StyledSelectorGrow = styled.div`
const StyledResults = styled.div`
display: flex;
flex: 1;
flex-direction: column;
gap: ${themeCssVariables.spacing[2]};
min-height: 0;
`;
const StyledRecordCount = styled.span`
@@ -50,10 +79,9 @@ const StyledRecordCount = styled.span`
font-size: ${themeCssVariables.font.size.sm};
`;
// The results table scrolls internally and loads more as you reach the bottom,
// so it needs a bounded height.
const StyledTableWrapper = styled.div`
height: 480px;
flex: 1;
min-height: 0;
overflow: hidden;
`;
@@ -64,45 +92,64 @@ export const SettingsLogs = () => {
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const isClickHouseConfigured = useAtomStateValue(isClickHouseConfiguredState);
const billing = useAtomStateValue(billingState);
const navigateSettings = useNavigateSettings();
const hasEnterpriseAccess =
currentWorkspace?.hasValidSignedEnterpriseKey === true;
const isBillingEnabled = billing?.isBillingEnabled ?? false;
const hasAuditLogsEntitlement =
currentWorkspace?.billingEntitlements?.some(
(entitlement) =>
entitlement.key === BillingEntitlementKey.AUDIT_LOGS &&
entitlement.value,
) === true;
const [selectedTable, setSelectedTable] = useState<EventLogTable>(
EventLogTable.PAGEVIEW,
);
const [filters, setFilters] = useState<EventLogFiltersState>({});
const [isPaused, setIsPaused] = useState(false);
const isApplicationLog = selectedTable === EventLogTable.APPLICATION_LOG;
const canQuery =
isClickHouseConfigured && (isApplicationLog || hasEnterpriseAccess);
isClickHouseConfigured && (isApplicationLog || hasAuditLogsEntitlement);
const {
records,
totalCount,
hasNextPage,
loading,
error,
refetch,
loadMore,
} = useEventLogs(
{
table: selectedTable,
filters: {
eventType: filters.eventType,
userWorkspaceId: filters.userWorkspaceId,
dateRange: filters.dateRange
? {
start: filters.dateRange.start?.toISOString(),
end: filters.dateRange.end?.toISOString(),
}
: undefined,
recordId: filters.recordId,
objectMetadataId: filters.objectMetadataId,
const { records, totalCount, hasNextPage, loading, error, loadMore } =
useEventLogs(
{
table: selectedTable,
filters: {
eventType: filters.eventType,
userWorkspaceId: filters.userWorkspaceId,
dateRange: filters.dateRange
? {
start: filters.dateRange.start?.toISOString(),
end: filters.dateRange.end?.toISOString(),
}
: undefined,
recordId: filters.recordId,
objectMetadataId: filters.objectMetadataId,
},
first: RECORDS_PER_PAGE,
},
first: RECORDS_PER_PAGE,
},
{ skip: !canQuery },
{ skip: !canQuery },
);
const hasActiveFilters =
isDefined(filters.eventType) ||
isDefined(filters.userWorkspaceId) ||
isDefined(filters.recordId) ||
isDefined(filters.objectMetadataId) ||
isDefined(filters.dateRange?.start) ||
isDefined(filters.dateRange?.end);
const liveRecords = useEventLogsLiveStream({
table: selectedTable,
enabled: !isPaused && !hasActiveFilters && canQuery,
});
const displayedRecords = useMemo(
() => [...liveRecords, ...records],
[liveRecords, records],
);
const handleTableChange = (table: EventLogTable) => {
@@ -114,39 +161,63 @@ export const SettingsLogs = () => {
setFilters(newFilters);
};
const renderUpgradeCard = () => (
<Card rounded>
<SettingsOptionCardContentButton
Icon={IconLock}
title={t`Upgrade to access audit logs`}
description={t`Only application logs are available on your current plan. Other log types require an Enterprise subscription.`}
Button={
<Button
title={t`Upgrade`}
variant="primary"
accent="blue"
size="small"
Icon={IconArrowUp}
onClick={() =>
navigateSettings(
isBillingEnabled
? SettingsPath.Billing
: SettingsPath.AdminPanelEnterprise,
)
}
/>
}
/>
</Card>
);
const renderResults = () => {
if (!isApplicationLog && !hasEnterpriseAccess) {
return (
<SettingsEnterpriseFeatureGateCard
title={t`Enterprise feature`}
description={t`Upgrade to Enterprise to access this log type.`}
buttonTitle={t`Activate`}
/>
);
if (!isApplicationLog && !hasAuditLogsEntitlement) {
return renderUpgradeCard();
}
if (!isClickHouseConfigured) {
return (
<SettingsEmptyPlaceholder>
{t`Audit logs require ClickHouse to be configured. Please contact your administrator.`}
{t`Logs require ClickHouse to be configured. Please contact your administrator.`}
</SettingsEmptyPlaceholder>
);
}
if (isDefined(error)) {
if (isGraphqlErrorOfType(error, 'NO_ENTITLEMENT')) {
return renderUpgradeCard();
}
return (
<SettingsEmptyPlaceholder>
{t`Something went wrong while loading audit logs. Please try again.`}
{t`Something went wrong while loading logs. Please try again.`}
</SettingsEmptyPlaceholder>
);
}
return (
<StyledResults>
<StyledRecordCount>{t`${records.length} of ${totalCount}`}</StyledRecordCount>
<StyledRecordCount>{t`${displayedRecords.length} of ${totalCount + liveRecords.length}`}</StyledRecordCount>
<StyledTableWrapper>
<EventLogResultsTable
records={records}
records={displayedRecords}
loading={loading}
hasNextPage={hasNextPage}
onLoadMore={loadMore}
@@ -158,7 +229,7 @@ export const SettingsLogs = () => {
};
return (
<>
<StyledRoot>
<Card rounded fullWidth>
<StyledCardContent>
<StyledSelectorRow>
@@ -168,17 +239,15 @@ export const SettingsLogs = () => {
onChange={handleTableChange}
/>
</StyledSelectorGrow>
<IconButton
Icon={IconRefresh}
variant="secondary"
size="medium"
ariaLabel={t`Refresh`}
onClick={() => {
if (canQuery) {
void refetch();
}
}}
/>
{canQuery && (
<IconButton
Icon={isPaused ? IconPlayerPlay : IconPlayerPause}
variant="secondary"
size="medium"
ariaLabel={isPaused ? t`Resume` : t`Pause`}
onClick={() => setIsPaused((previous) => !previous)}
/>
)}
</StyledSelectorRow>
<EventLogFilters
table={selectedTable}
@@ -189,6 +258,6 @@ export const SettingsLogs = () => {
</Card>
{renderResults()}
</>
</StyledRoot>
);
};
@@ -10,7 +10,6 @@ export const GET_EVENT_LOGS = gql`
properties
recordId
objectMetadataId
isCustom
}
totalCount
pageInfo {
@@ -0,0 +1,14 @@
import { gql } from '@apollo/client';
export const EVENT_LOGS_LIVE_SUBSCRIPTION = gql`
subscription EventLogsLive($table: EventLogTable!) {
eventLogsLive(table: $table) {
event
timestamp
userId
properties
recordId
objectMetadataId
}
}
`;
@@ -0,0 +1,68 @@
import { print, type ExecutionResult } from 'graphql';
import { useEffect, useState } from 'react';
import { EVENT_LOGS_LIVE_SUBSCRIPTION } from '@/settings/event-logs/graphql/subscriptions/EventLogsLiveSubscription';
import { sseClientState } from '@/sse-db-event/states/sseClientState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { captureException } from '@sentry/react';
import { isDefined } from 'twenty-shared/utils';
import {
type EventLogRecord,
type EventLogTable,
} from '~/generated-metadata/graphql';
type EventLogsLivePayload = {
eventLogsLive: EventLogRecord[] | null;
};
const EVENT_LOGS_LIVE_SUBSCRIPTION_QUERY = print(EVENT_LOGS_LIVE_SUBSCRIPTION);
export const useEventLogsLiveStream = ({
table,
enabled,
}: {
table: EventLogTable;
enabled: boolean;
}): EventLogRecord[] => {
const sseClient = useAtomStateValue(sseClientState);
const [liveRecords, setLiveRecords] = useState<EventLogRecord[]>([]);
useEffect(() => {
setLiveRecords([]);
}, [table]);
useEffect(() => {
if (!enabled) {
setLiveRecords([]);
}
}, [enabled]);
useEffect(() => {
if (!enabled || !isDefined(sseClient)) {
return;
}
const dispose = sseClient.subscribe<EventLogsLivePayload>(
{
query: EVENT_LOGS_LIVE_SUBSCRIPTION_QUERY,
variables: { table },
},
{
next: (value: ExecutionResult<EventLogsLivePayload>) => {
const incoming = value.data?.eventLogsLive;
if (isDefined(incoming) && incoming.length > 0) {
setLiveRecords((previous) => [...incoming, ...previous]);
}
},
error: (error) => captureException(error),
complete: () => {},
},
);
return () => dispose();
}, [enabled, sseClient, table]);
return liveRecords;
};
@@ -20,7 +20,7 @@ export const useEventLogs = (
input: EventLogQueryInput,
options?: { skip?: boolean },
) => {
const { data, loading, error, refetch, fetchMore } = useQuery<
const { data, loading, error, fetchMore } = useQuery<
EventLogsData,
EventLogsVariables
>(GET_EVENT_LOGS, {
@@ -70,7 +70,6 @@ export const useEventLogs = (
hasNextPage,
loading,
error,
refetch,
loadMore,
};
};
@@ -1,121 +0,0 @@
import { msg } from '@lingui/core/macro';
import { type MessageDescriptor } from '@lingui/core';
import { EventLogTable } from '~/generated-metadata/graphql';
export type ColumnConfig = {
id: string;
label: MessageDescriptor;
minWidth: number;
defaultWidth: number;
};
const DEFAULT_COLUMNS: ColumnConfig[] = [
{ id: 'event', label: msg`Event`, minWidth: 100, defaultWidth: 200 },
{
id: 'timestamp',
label: msg`Timestamp`,
minWidth: 100,
defaultWidth: 150,
},
{ id: 'userId', label: msg`User`, minWidth: 100, defaultWidth: 150 },
{
id: 'properties',
label: msg`Properties`,
minWidth: 200,
defaultWidth: 400,
},
];
const OBJECT_EVENT_COLUMNS: ColumnConfig[] = [
{ id: 'event', label: msg`Event`, minWidth: 100, defaultWidth: 180 },
{
id: 'timestamp',
label: msg`Timestamp`,
minWidth: 100,
defaultWidth: 130,
},
{ id: 'userId', label: msg`User`, minWidth: 100, defaultWidth: 130 },
{
id: 'recordId',
label: msg`Record ID`,
minWidth: 100,
defaultWidth: 130,
},
{
id: 'objectMetadataId',
label: msg`Object ID`,
minWidth: 100,
defaultWidth: 130,
},
{
id: 'properties',
label: msg`Properties`,
minWidth: 150,
defaultWidth: 300,
},
];
const USAGE_EVENT_COLUMNS: ColumnConfig[] = [
{
id: 'event',
label: msg`Resource Type`,
minWidth: 100,
defaultWidth: 130,
},
{
id: 'timestamp',
label: msg`Timestamp`,
minWidth: 100,
defaultWidth: 140,
},
{ id: 'userId', label: msg`User`, minWidth: 100, defaultWidth: 130 },
{
id: 'properties',
label: msg`Details`,
minWidth: 200,
defaultWidth: 400,
},
];
const APPLICATION_LOG_COLUMNS: ColumnConfig[] = [
{
id: 'event',
label: msg`Function`,
minWidth: 100,
defaultWidth: 160,
},
{
id: 'timestamp',
label: msg`Timestamp`,
minWidth: 100,
defaultWidth: 140,
},
{ id: 'level', label: msg`Level`, minWidth: 60, defaultWidth: 80 },
{
id: 'message',
label: msg`Message`,
minWidth: 200,
defaultWidth: 400,
},
{
id: 'executionId',
label: msg`Execution ID`,
minWidth: 100,
defaultWidth: 140,
},
];
const COLUMNS_BY_TABLE: Record<EventLogTable, ColumnConfig[]> = {
[EventLogTable.OBJECT_EVENT]: OBJECT_EVENT_COLUMNS,
[EventLogTable.USAGE_EVENT]: USAGE_EVENT_COLUMNS,
[EventLogTable.APPLICATION_LOG]: APPLICATION_LOG_COLUMNS,
[EventLogTable.WORKSPACE_EVENT]: DEFAULT_COLUMNS,
[EventLogTable.PAGEVIEW]: DEFAULT_COLUMNS,
};
export const getColumnsForEventLogTable = (
table: EventLogTable,
): ColumnConfig[] => {
return COLUMNS_BY_TABLE[table] ?? DEFAULT_COLUMNS;
};
@@ -0,0 +1,125 @@
import { type ReactNode } from 'react';
import { msg } from '@lingui/core/macro';
import { type MessageDescriptor } from '@lingui/core';
import { EventLogJsonCell } from '@/settings/event-logs/components/EventLogJsonCell';
import {
type EventLogRecord,
EventLogTable,
} from '~/generated-metadata/graphql';
import { beautifyPastDateRelativeToNow } from '~/utils/date-utils';
export type ColumnConfig = {
id: string;
label: MessageDescriptor;
minWidth: number;
defaultWidth: number;
renderCell: (record: EventLogRecord) => ReactNode;
};
const EVENT_COLUMN: ColumnConfig = {
id: 'event',
label: msg`Event`,
minWidth: 100,
defaultWidth: 200,
renderCell: (record) => record.event,
};
const TIMESTAMP_COLUMN: ColumnConfig = {
id: 'timestamp',
label: msg`Timestamp`,
minWidth: 100,
defaultWidth: 150,
renderCell: (record) => beautifyPastDateRelativeToNow(record.timestamp),
};
const USER_COLUMN: ColumnConfig = {
id: 'userId',
label: msg`User`,
minWidth: 100,
defaultWidth: 150,
renderCell: (record) => record.userId ?? '-',
};
const PROPERTIES_COLUMN: ColumnConfig = {
id: 'properties',
label: msg`Properties`,
minWidth: 200,
defaultWidth: 400,
renderCell: (record) => <EventLogJsonCell value={record.properties} />,
};
const DEFAULT_COLUMNS: ColumnConfig[] = [
EVENT_COLUMN,
TIMESTAMP_COLUMN,
USER_COLUMN,
PROPERTIES_COLUMN,
];
const OBJECT_EVENT_COLUMNS: ColumnConfig[] = [
{ ...EVENT_COLUMN, defaultWidth: 180 },
{ ...TIMESTAMP_COLUMN, defaultWidth: 130 },
{ ...USER_COLUMN, defaultWidth: 130 },
{
id: 'recordId',
label: msg`Record ID`,
minWidth: 100,
defaultWidth: 130,
renderCell: (record) => record.recordId ?? '-',
},
{
id: 'objectMetadataId',
label: msg`Object ID`,
minWidth: 100,
defaultWidth: 130,
renderCell: (record) => record.objectMetadataId ?? '-',
},
{ ...PROPERTIES_COLUMN, minWidth: 150, defaultWidth: 300 },
];
const USAGE_EVENT_COLUMNS: ColumnConfig[] = [
{ ...EVENT_COLUMN, label: msg`Resource Type`, defaultWidth: 130 },
{ ...TIMESTAMP_COLUMN, defaultWidth: 140 },
{ ...USER_COLUMN, defaultWidth: 130 },
{ ...PROPERTIES_COLUMN, label: msg`Details` },
];
const APPLICATION_LOG_COLUMNS: ColumnConfig[] = [
{ ...EVENT_COLUMN, label: msg`Function`, defaultWidth: 160 },
{ ...TIMESTAMP_COLUMN, defaultWidth: 140 },
{
id: 'level',
label: msg`Level`,
minWidth: 60,
defaultWidth: 80,
renderCell: (record) => record.properties?.level ?? '-',
},
{
id: 'message',
label: msg`Message`,
minWidth: 200,
defaultWidth: 400,
renderCell: (record) => record.properties?.message ?? '-',
},
{
id: 'executionId',
label: msg`Execution ID`,
minWidth: 100,
defaultWidth: 140,
renderCell: (record) => record.properties?.executionId ?? '-',
},
];
const COLUMNS_BY_TABLE: Record<EventLogTable, ColumnConfig[]> = {
[EventLogTable.OBJECT_EVENT]: OBJECT_EVENT_COLUMNS,
[EventLogTable.USAGE_EVENT]: USAGE_EVENT_COLUMNS,
[EventLogTable.APPLICATION_LOG]: APPLICATION_LOG_COLUMNS,
[EventLogTable.WORKSPACE_EVENT]: DEFAULT_COLUMNS,
[EventLogTable.PAGEVIEW]: DEFAULT_COLUMNS,
};
export const getColumnsForEventLogTable = (
table: EventLogTable,
): ColumnConfig[] => {
return COLUMNS_BY_TABLE[table] ?? DEFAULT_COLUMNS;
};
Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 110 KiB

@@ -1,169 +0,0 @@
import { useContext, useState } from 'react';
import { Select } from '@/ui/input/components/Select';
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { H2Title, IconCopy } from 'twenty-ui/display';
import { CodeEditor, IconButton } from 'twenty-ui/input';
import { Card, CardContent, Section } from 'twenty-ui/layout';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { REACT_APP_SERVER_BASE_URL } from '~/config';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
const StyledCoverImage = styled.div`
background-position: center;
background-size: cover;
height: 160px;
overflow: hidden;
`;
const StyledConfigButtonsContainer = styled.div`
display: flex;
flex-direction: row;
gap: ${themeCssVariables.spacing[2]};
position: absolute;
right: ${themeCssVariables.spacing[3]};
top: ${themeCssVariables.spacing[3]};
z-index: 1;
`;
const StyledCoverCardContent = styled(CardContent)`
padding: 0;
`;
const StyledCopyButton = styled(IconButton)`
background-color: ${themeCssVariables.background.transparent.lighter};
border: 1px solid ${themeCssVariables.border.color.medium};
`;
const StyledEditorContainer = styled.div`
.monaco-editor,
.monaco-editor .overflow-guard {
background-color: transparent !important;
border: none !important;
}
.monaco-editor .line-hover {
background-color: transparent !important;
}
`;
type McpAuthMethod = 'oauth' | 'api-key';
export const SettingsAiMCP = () => {
const { t } = useLingui();
const { copyToClipboard } = useCopyToClipboard();
const [authMethod, setAuthMethod] = useState<McpAuthMethod>('oauth');
const { colorScheme } = useContext(ThemeContext);
const coverImage =
colorScheme === 'light'
? '/images/ai/ai-mcp-cover-light.svg'
: '/images/ai/ai-mcp-cover-dark.svg';
const oauthConfig = JSON.stringify(
{
mcpServers: {
twenty: {
type: 'streamable-http',
url: `${REACT_APP_SERVER_BASE_URL}/mcp`,
},
},
},
null,
2,
);
const apiKeyConfig = JSON.stringify(
{
mcpServers: {
twenty: {
type: 'streamable-http',
url: `${REACT_APP_SERVER_BASE_URL}/mcp`,
headers: {
Authorization: 'Bearer [API_KEY]',
},
},
},
},
null,
2,
);
const isOAuth = authMethod === 'oauth';
const activeConfig = isOAuth ? oauthConfig : apiKeyConfig;
const editorHeight = isOAuth ? 170 : 230;
const codeEditorOptions = {
readOnly: true,
domReadOnly: true,
renderLineHighlight: 'none' as const,
renderLineHighlightOnlyWhenFocus: false,
lineNumbers: 'off' as const,
folding: false,
selectionHighlight: false,
occurrencesHighlight: 'off' as const,
scrollBeyondLastLine: false,
hover: {
enabled: false,
},
guides: {
indentation: false,
bracketPairs: false,
bracketPairsHorizontal: false,
},
padding: {
top: 12,
},
};
return (
<Section>
<H2Title
title={t`MCP Server`}
description={t`Access your workspace data from your favorite MCP client like Claude Desktop, Windsurf or Cursor.`}
/>
<Card rounded>
<StyledCoverCardContent divider>
<StyledCoverImage
style={{ backgroundImage: `url('${coverImage}')` }}
/>
</StyledCoverCardContent>
<StyledCoverCardContent>
<StyledEditorContainer style={{ position: 'relative' }}>
<StyledConfigButtonsContainer>
<Select
dropdownId="mcp-auth-method-select"
value={authMethod}
onChange={(value) => setAuthMethod(value as McpAuthMethod)}
options={[
{ label: t`OAuth`, value: 'oauth' },
{ label: t`API Key`, value: 'api-key' },
]}
selectSizeVariant="small"
dropdownWidth={GenericDropdownContentWidth.Medium}
dropdownOffset={{ x: 0, y: 4 }}
/>
<StyledCopyButton
Icon={IconCopy}
onClick={() => {
copyToClipboard(
activeConfig,
t`MCP Configuration copied to clipboard`,
);
}}
size="small"
/>
</StyledConfigButtonsContainer>
<CodeEditor
value={activeConfig}
language="json"
options={codeEditorOptions}
height={editorHeight}
/>
</StyledEditorContainer>
</StyledCoverCardContent>
</Card>
</Section>
);
};
Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 92 KiB

@@ -50,10 +50,6 @@ export const SettingsGeneral = () => {
);
const renderActiveTabContent = () => {
if (activeTabId === GENERAL_TAB_LOGS) {
return <SettingsLogs />;
}
if (activeTabId === GENERAL_TAB_SECURITY) {
return <SettingsSecuritySettings />;
}
@@ -97,7 +93,13 @@ export const SettingsGeneral = () => {
}
links={[{ children: t`Workspace` }, { children: t`General` }]}
>
<SettingsPageContainer>{renderActiveTabContent()}</SettingsPageContainer>
{activeTabId === GENERAL_TAB_LOGS ? (
<SettingsLogs />
) : (
<SettingsPageContainer>
{renderActiveTabContent()}
</SettingsPageContainer>
)}
</SettingsPageLayout>
);
};
Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 88 KiB

+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "twenty-sdk",
"version": "2.10.0",
"version": "2.11.0",
"sideEffects": false,
"bin": {
"twenty": "dist/cli.cjs"
@@ -7,6 +7,7 @@ import chalk from 'chalk';
export type AppDevOnceCommandOptions = {
appPath?: string;
verbose?: boolean;
dryRun?: boolean;
};
export class AppDevOnceCommand {
@@ -17,12 +18,17 @@ export class AppDevOnceCommand {
const remoteName = ConfigService.getActiveRemote();
console.log(chalk.blue(`Syncing application on ${remoteName}...`));
console.log(
chalk.blue(
`${options.dryRun ? 'Previewing application diff' : 'Syncing application'} on ${remoteName}...`,
),
);
console.log(chalk.gray(`App path: ${appPath}\n`));
const result = await appDevOnce({
appPath,
verbose: options.verbose,
dryRun: options.dryRun,
onProgress: (message) => console.log(chalk.gray(message)),
});
@@ -31,6 +37,16 @@ export class AppDevOnceCommand {
process.exit(1);
}
if (options.dryRun) {
console.log(
chalk.green(
`\n✓ Dry run complete for ${result.data.applicationDisplayName} — no changes were applied`,
),
);
return;
}
console.log(
chalk.green(
`\n✓ Synced ${result.data.applicationDisplayName} (${result.data.fileCount} file${result.data.fileCount === 1 ? '' : 's'})`,
@@ -22,6 +22,7 @@ export const registerDevCommands = (program: Command): void => {
verbose?: boolean;
debug?: boolean;
debounceMs?: string;
dryRun?: boolean;
},
) => {
const commonOptions = {
@@ -33,7 +34,10 @@ export const registerDevCommands = (program: Command): void => {
};
if (options.once) {
await devOnceCommand.execute(commonOptions);
await devOnceCommand.execute({
...commonOptions,
dryRun: options.dryRun,
});
return;
}
@@ -48,6 +52,10 @@ export const registerDevCommands = (program: Command): void => {
'-o, --once',
'Build and sync once, then exit (useful for CI, scripts, and pre-commit hooks)',
)
.option(
'--dry-run',
'Preview the metadata changes without applying them (requires --once)',
)
.option('--debounceMs <ms>', 'Debounce in ms (default: 2 000)')
.option('-v, --verbose', 'Show detailed logs')
.option('-d, --debug', 'Show detailed logs (alias for --verbose)')
@@ -1,5 +1,6 @@
import path from 'path';
import { OUTPUT_DIR, type Manifest } from 'twenty-shared/application';
import { type SyncAction } from 'twenty-shared/metadata';
import { ApiService } from '@/cli/utilities/api/api-service';
import {
@@ -13,6 +14,7 @@ import { manifestUpdateChecksums } from '@/cli/utilities/build/manifest/manifest
import { writeManifestToOutput } from '@/cli/utilities/build/manifest/manifest-writer';
import { ClientService } from '@/cli/utilities/client/client-service';
import { ConfigService } from '@/cli/utilities/config/config-service';
import { formatSyncActionsSummary } from '@/cli/utilities/dev/orchestrator/steps/format-sync-actions-summary';
import { formatManifestValidationErrors } from '@/cli/utilities/error/format-manifest-validation-errors';
import { serializeError } from '@/cli/utilities/error/serialize-error';
import { FileUploader } from '@/cli/utilities/file/file-uploader';
@@ -22,6 +24,7 @@ import { APP_ERROR_CODES, type CommandResult } from '@/cli/types';
export type AppDevOnceOptions = {
appPath: string;
verbose?: boolean;
dryRun?: boolean;
onProgress?: (message: string) => void;
};
@@ -32,10 +35,19 @@ export type AppDevOnceResult = {
applicationUniversalIdentifier: string;
};
const reportMetadataChanges = (
data: { actions: SyncAction[] },
onProgress?: (message: string) => void,
): void => {
for (const event of formatSyncActionsSummary(data.actions)) {
onProgress?.(event.message);
}
};
const innerAppDevOnce = async (
options: AppDevOnceOptions,
): Promise<CommandResult<AppDevOnceResult>> => {
const { appPath, onProgress, verbose } = options;
const { appPath, onProgress, verbose, dryRun } = options;
onProgress?.('Checking server...');
@@ -120,6 +132,47 @@ const innerAppDevOnce = async (
await writeManifestToOutput(appPath, manifest);
if (dryRun) {
onProgress?.(
'Computing metadata diff (dry run, nothing will be applied)...',
);
const dryRunResult = await apiService.syncApplication(manifest, {
dryRun: true,
});
if (!dryRunResult.success) {
const errorEvents = verbose
? null
: formatManifestValidationErrors(dryRunResult.error);
const message = errorEvents
? errorEvents.map((event) => event.message).join('\n')
: `Dry run failed with error: ${serializeError(dryRunResult.error)}`;
return {
success: false,
error: {
code: APP_ERROR_CODES.SYNC_FAILED,
message,
},
};
}
reportMetadataChanges(dryRunResult.data, onProgress);
return {
success: true,
data: {
outputDir: path.join(appPath, OUTPUT_DIR),
fileCount: buildResult.builtFileInfos.size,
applicationDisplayName: manifest.application.displayName,
applicationUniversalIdentifier:
manifest.application.universalIdentifier,
},
};
}
onProgress?.('Registering application...');
const configService = new ConfigService();
@@ -212,6 +265,8 @@ const innerAppDevOnce = async (
};
}
reportMetadataChanges(syncResult.data, onProgress);
onProgress?.('Generating API client...');
try {
@@ -5,6 +5,7 @@ import { FileApi } from '@/cli/utilities/api/file-api';
import { LogicFunctionApi } from '@/cli/utilities/api/logic-function-api';
import { SchemaApi } from '@/cli/utilities/api/schema-api';
import { type Manifest } from 'twenty-shared/application';
import { type SyncAction } from 'twenty-shared/metadata';
type ApiServiceOptions = {
disableInterceptors?: boolean;
@@ -72,8 +73,16 @@ export class ApiService {
return this.applicationApi.createDevelopmentApplication(...args);
}
syncApplication(manifest: Manifest): Promise<ApiResponse> {
return this.applicationApi.syncApplication(manifest);
syncApplication(
manifest: Manifest,
options?: { dryRun?: boolean },
): Promise<
ApiResponse<{
applicationUniversalIdentifier: string;
actions: SyncAction[];
}>
> {
return this.applicationApi.syncApplication(manifest, options);
}
uninstallApplication(universalIdentifier: string): Promise<ApiResponse> {
@@ -1,6 +1,7 @@
import { type ApiResponse } from '@/cli/utilities/api/api-response-type';
import axios, { type AxiosInstance, type AxiosResponse } from 'axios';
import { type Manifest } from 'twenty-shared/application';
import { type SyncAction } from 'twenty-shared/metadata';
export class ApplicationApi {
constructor(private readonly client: AxiosInstance) {}
@@ -251,18 +252,26 @@ export class ApplicationApi {
}
}
async syncApplication(manifest: Manifest): Promise<ApiResponse> {
async syncApplication(
manifest: Manifest,
options?: { dryRun?: boolean },
): Promise<
ApiResponse<{
applicationUniversalIdentifier: string;
actions: SyncAction[];
}>
> {
try {
const mutation = `
mutation SyncApplication($manifest: JSON!) {
syncApplication(manifest: $manifest) {
mutation SyncApplication($manifest: JSON!, $dryRun: Boolean) {
syncApplication(manifest: $manifest, dryRun: $dryRun) {
applicationUniversalIdentifier
actions
}
}
`;
const variables = { manifest };
const variables = { manifest, dryRun: options?.dryRun ?? false };
const response: AxiosResponse = await this.client.post(
'/metadata',
@@ -0,0 +1,98 @@
import { type SyncAction } from 'twenty-shared/metadata';
import { describe, expect, it } from 'vitest';
import { formatSyncActionsSummary } from '@/cli/utilities/dev/orchestrator/steps/format-sync-actions-summary';
describe('formatSyncActionsSummary', () => {
it('reports no changes when the actions list is empty', () => {
expect(formatSyncActionsSummary([])).toEqual([
{ message: 'No metadata changes', status: 'info' },
]);
});
it('reports no changes when actions are missing from the response', () => {
expect(formatSyncActionsSummary(undefined)).toEqual([
{ message: 'No metadata changes', status: 'info' },
]);
});
it('summarizes created, updated and deleted actions with their identifiers', () => {
const events = formatSyncActionsSummary([
{
type: 'create',
metadataName: 'objectMetadata',
flatEntity: {
universalIdentifier: 'uid-object',
nameSingular: 'rocket',
},
},
{
type: 'create',
metadataName: 'fieldMetadata',
flatEntity: {
universalIdentifier: 'uid-field',
name: 'timelineActivities',
},
},
{
type: 'update',
metadataName: 'fieldMetadata',
universalIdentifier: 'uid-updated-field',
},
{
type: 'delete',
metadataName: 'pageLayout',
universalIdentifier: 'uid-page-layout',
},
]);
expect(events).toEqual([
{
message: 'Metadata changes: 2 created, 1 updated, 1 deleted',
status: 'info',
},
{ message: ' created objectMetadata rocket', status: 'info' },
{ message: ' created fieldMetadata timelineActivities', status: 'info' },
{ message: ' updated fieldMetadata uid-updated-field', status: 'info' },
{ message: ' deleted pageLayout uid-page-layout', status: 'info' },
]);
});
it('falls back to the universal identifier when a created entity has no name', () => {
const events = formatSyncActionsSummary([
{
type: 'create',
metadataName: 'fieldMetadata',
flatEntity: { universalIdentifier: 'uid-nameless' },
},
]);
expect(events).toEqual([
{ message: 'Metadata changes: 1 created', status: 'info' },
{ message: ' created fieldMetadata uid-nameless', status: 'info' },
]);
});
it('truncates the detail lines when there are more changes than the display limit', () => {
const actions: SyncAction[] = Array.from({ length: 55 }, (_, index) => ({
type: 'create' as const,
metadataName: 'fieldMetadata' as const,
flatEntity: {
universalIdentifier: `uid-${index}`,
name: `field${index}`,
},
}));
const events = formatSyncActionsSummary(actions);
expect(events[0]).toEqual({
message: 'Metadata changes: 55 created',
status: 'info',
});
expect(events).toHaveLength(52);
expect(events[51]).toEqual({
message: ' …and 5 more change(s)',
status: 'info',
});
});
});
@@ -0,0 +1,85 @@
import { describe, expect, it, vi } from 'vitest';
import { type ApiService } from '@/cli/utilities/api/api-service';
import { OrchestratorState } from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state';
import { SyncApplicationOrchestratorStep } from '@/cli/utilities/dev/orchestrator/steps/sync-application-orchestrator-step';
import { type Manifest } from 'twenty-shared/application';
vi.mock('@/cli/utilities/build/manifest/manifest-update-checksums', () => ({
manifestUpdateChecksums: ({ manifest }: { manifest: unknown }) => manifest,
}));
vi.mock('@/cli/utilities/build/manifest/manifest-writer', () => ({
writeManifestToOutput: vi.fn(),
}));
const buildStep = (
syncApplication: ApiService['syncApplication'],
): { state: OrchestratorState; step: SyncApplicationOrchestratorStep } => {
const state = new OrchestratorState({ appPath: '/tmp/app' });
const apiService = { syncApplication } as unknown as ApiService;
const step = new SyncApplicationOrchestratorStep({
apiService,
state,
notify: () => {},
});
return { state, step };
};
const executeInput = {
manifest: {
application: { displayName: 'Demo' },
} as unknown as Manifest,
builtFileInfos: new Map(),
appPath: '/tmp/app',
};
describe('SyncApplicationOrchestratorStep', () => {
it('renders the applied metadata changes on a successful sync', async () => {
const syncApplication = vi.fn().mockResolvedValue({
success: true,
data: {
applicationUniversalIdentifier: 'app-uid',
actions: [
{
type: 'create',
metadataName: 'fieldMetadata',
flatEntity: {
universalIdentifier: 'uid-field',
name: 'timelineActivities',
},
},
],
},
});
const { state, step } = buildStep(syncApplication);
await step.execute(executeInput);
const messages = state.events.map((event) => event.message);
expect(messages).toContain('Metadata changes: 1 created');
expect(messages).toContain(' created fieldMetadata timelineActivities');
expect(messages).toContain('✓ Synced');
});
it('reports no metadata changes when the sync applies nothing', async () => {
const syncApplication = vi.fn().mockResolvedValue({
success: true,
data: { applicationUniversalIdentifier: 'app-uid', actions: [] },
});
const { state, step } = buildStep(syncApplication);
await step.execute(executeInput);
const messages = state.events.map((event) => event.message);
expect(messages).toContain('No metadata changes');
expect(messages).toContain('✓ Synced');
});
});
@@ -0,0 +1,70 @@
import { type OrchestratorStateStepEvent } from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state';
import { type SyncAction } from 'twenty-shared/metadata';
import { isDefined } from 'twenty-shared/utils';
const MAX_DETAIL_LINES = 50;
const VERB_BY_TYPE = {
create: 'created',
update: 'updated',
delete: 'deleted',
} as const;
const getActionLabel = (action: SyncAction): string => {
if (action.type === 'create') {
return (
action.flatEntity?.name ??
action.flatEntity?.nameSingular ??
action.flatEntity?.universalIdentifier ??
'unknown'
);
}
return action.universalIdentifier;
};
export const formatSyncActionsSummary = (
actions: SyncAction[] | undefined,
): OrchestratorStateStepEvent[] => {
const definedActions = actions ?? [];
if (definedActions.length === 0) {
return [{ message: 'No metadata changes', status: 'info' }];
}
const counts = { create: 0, update: 0, delete: 0 };
for (const action of definedActions) {
counts[action.type] += 1;
}
const summaryParts = [
counts.create > 0 ? `${counts.create} created` : null,
counts.update > 0 ? `${counts.update} updated` : null,
counts.delete > 0 ? `${counts.delete} deleted` : null,
].filter(isDefined);
const events: OrchestratorStateStepEvent[] = [
{ message: `Metadata changes: ${summaryParts.join(', ')}`, status: 'info' },
];
const visibleActions = definedActions.slice(0, MAX_DETAIL_LINES);
for (const action of visibleActions) {
events.push({
message: ` ${VERB_BY_TYPE[action.type]} ${action.metadataName} ${getActionLabel(action)}`,
status: 'info',
});
}
const hiddenCount = definedActions.length - visibleActions.length;
if (hiddenCount > 0) {
events.push({
message: ` …and ${hiddenCount} more change(s)`,
status: 'info',
});
}
return events;
};
@@ -7,6 +7,7 @@ import {
type OrchestratorStateStepEvent,
type OrchestratorStateSyncStatus,
} from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state';
import { formatSyncActionsSummary } from '@/cli/utilities/dev/orchestrator/steps/format-sync-actions-summary';
import { formatManifestValidationErrors } from '@/cli/utilities/error/format-manifest-validation-errors';
import { serializeError } from '@/cli/utilities/error/serialize-error';
import { type Manifest } from 'twenty-shared/application';
@@ -69,6 +70,9 @@ export class SyncApplicationOrchestratorStep {
const syncResult = await this.apiService.syncApplication(manifest);
if (syncResult.success) {
const syncData = syncResult.data;
events.push(...formatSyncActionsSummary(syncData.actions));
events.push({ message: '✓ Synced', status: 'success' });
step.output = { syncStatus: 'synced', error: null };
step.status = 'done';
-1
View File
@@ -35,7 +35,6 @@ FRONTEND_URL=http://localhost:3001
# AUTH_GOOGLE_APIS_CALLBACK_URL=http://localhost:3000/auth/google-apis/get-access-token
# CODE_INTERPRETER_TYPE=LOCAL
# LOGIC_FUNCTION_TYPE=LOCAL
# LOGIC_FUNCTION_LOGS_ENABLED=true
# STORAGE_TYPE=local
# STORAGE_LOCAL_PATH=.local-storage
# SUPPORT_DRIVER=front
@@ -105,6 +105,10 @@ describe('ClickHouseService', () => {
table: 'test_table',
values: testData,
format: 'JSONEachRow',
clickhouse_settings: {
async_insert: 1,
wait_for_async_insert: 1,
},
});
});
@@ -28,10 +28,6 @@ export class ClickHouseService implements OnModuleInit, OnModuleDestroy {
response: true,
request: true,
},
clickhouse_settings: {
async_insert: 1,
wait_for_async_insert: 1,
},
application: 'twenty',
log: { level: ClickHouseLogLevel.OFF },
});
@@ -88,10 +84,6 @@ export class ClickHouseService implements OnModuleInit, OnModuleDestroy {
response: true,
request: true,
},
clickhouse_settings: {
async_insert: 1,
wait_for_async_insert: 1,
},
application: 'twenty',
log: { level: ClickHouseLogLevel.OFF },
});
@@ -282,6 +274,10 @@ export class ClickHouseService implements OnModuleInit, OnModuleDestroy {
table,
values: chunk,
format: 'JSONEachRow',
clickhouse_settings: {
async_insert: 1,
wait_for_async_insert: 1,
},
});
chunk = [];
currentSizeBytes = 0;
@@ -12,3 +12,8 @@ export const formatDateTimeForClickHouse = (date: Date | string): string => {
export const formatDateForClickHouse = (date: Date): string =>
date.toISOString().slice(0, 10);
// ClickHouse returns DateTime64 values as naive strings (YYYY-MM-DD HH:mm:ss.SSS) in UTC.
// Parse them as UTC explicitly, otherwise `new Date` assumes the server's local timezone.
export const parseClickHouseDateTime = (value: string): Date =>
new Date(`${value.replace(' ', 'T')}Z`);
@@ -1,9 +1,9 @@
import { OBJECT_RECORD_CREATED_EVENT } from 'src/engine/core-modules/audit/utils/events/object-event/object-record-created';
import { OBJECT_RECORD_DELETED_EVENT } from 'src/engine/core-modules/audit/utils/events/object-event/object-record-delete';
import { OBJECT_RECORD_UPDATED_EVENT } from 'src/engine/core-modules/audit/utils/events/object-event/object-record-updated';
import { CUSTOM_DOMAIN_ACTIVATED_EVENT } from 'src/engine/core-modules/audit/utils/events/workspace-event/custom-domain/custom-domain-activated';
import { CUSTOM_DOMAIN_DEACTIVATED_EVENT } from 'src/engine/core-modules/audit/utils/events/workspace-event/custom-domain/custom-domain-deactivated';
import { type GenericTrackEvent } from 'src/engine/core-modules/audit/utils/events/workspace-event/track';
import { OBJECT_RECORD_CREATED_EVENT } from 'src/engine/core-modules/event-logs/emit/events/object-event/object-record-created';
import { OBJECT_RECORD_DELETED_EVENT } from 'src/engine/core-modules/event-logs/emit/events/object-event/object-record-delete';
import { OBJECT_RECORD_UPDATED_EVENT } from 'src/engine/core-modules/event-logs/emit/events/object-event/object-record-updated';
import { CUSTOM_DOMAIN_ACTIVATED_EVENT } from 'src/engine/core-modules/event-logs/emit/events/workspace-event/custom-domain/custom-domain-activated';
import { CUSTOM_DOMAIN_DEACTIVATED_EVENT } from 'src/engine/core-modules/event-logs/emit/events/workspace-event/custom-domain/custom-domain-deactivated';
import { type GenericTrackEvent } from 'src/engine/core-modules/event-logs/emit/events/workspace-event/track';
import { formatDateTimeForClickHouse } from 'src/database/clickHouse/clickHouse.util';
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
import { USER_WORKSPACE_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-user-workspaces.util';
@@ -144,9 +144,11 @@ export class SyncCallRecordingStandardObjectsCommand extends ActiveOrSuspendedWo
];
if (!isDefined(calendarEventObjectMetadata)) {
throw new Error(
`calendarEvent object not found for workspace ${workspaceId}`,
this.logger.warn(
`calendarEvent object not found for workspace ${workspaceId}, skipping CallRecording standard metadata sync`,
);
return;
}
const { twentyStandardFlatApplication } =
@@ -56,14 +56,19 @@ export const typeORMCoreModuleOptions: TypeOrmModuleOptions = {
migrationsRun: false,
migrationsTableName: '_typeorm_migrations',
metadataTableName: '_typeorm_generated_columns_and_materialized_views',
// The TypeORM migration system is frozen — historical migrations live in
// `legacy-typeorm-migrations-do-not-add/` and are loaded here only so the
// `_typeorm_migrations` table stays consistent for older deployments.
// Do NOT add new files there: write a fast/slow instance command instead.
// See `packages/twenty-server/docs/UPGRADE_COMMANDS.md`.
migrations:
process.env.IS_BILLING_ENABLED === 'true'
? [
`${isJest ? 'src/' : 'dist/'}database/typeorm/core/migrations/common/*{.ts,.js}`,
`${isJest ? 'src/' : 'dist/'}database/typeorm/core/migrations/billing/*{.ts,.js}`,
`${isJest ? 'src/' : 'dist/'}database/typeorm/core/legacy-typeorm-migrations-do-not-add/common/*{.ts,.js}`,
`${isJest ? 'src/' : 'dist/'}database/typeorm/core/legacy-typeorm-migrations-do-not-add/billing/*{.ts,.js}`,
]
: [
`${isJest ? 'src/' : 'dist/'}database/typeorm/core/migrations/common/*{.ts,.js}`,
`${isJest ? 'src/' : 'dist/'}database/typeorm/core/legacy-typeorm-migrations-do-not-add/common/*{.ts,.js}`,
],
ssl:
process.env.PG_SSL_ALLOW_SELF_SIGNED === 'true'
@@ -0,0 +1,23 @@
# Legacy TypeORM migrations — do not add new files here
This directory contains historical TypeORM migrations (`common/` and `billing/`).
They are kept so that older deployments can still replay them against the
`_typeorm_migrations` table.
**The TypeORM migration system is frozen.** Do not add new files here.
The active upgrade system is **instance commands** (fast / slow) and
**workspace commands**, registered with `@RegisteredInstanceCommand` and
`@RegisteredWorkspaceCommand`. Generate one with:
```bash
npx nx run twenty-server:database:migrate:generate --name <name> --type <fast|slow>
```
See `packages/twenty-server/docs/UPGRADE_COMMANDS.md` for the full guide and
`packages/twenty-server/src/database/commands/upgrade-version-command/` for
existing examples.
Note: `../migrations/utils/` is **not** legacy — those SQL helpers are still
imported by current instance/workspace commands and live outside this folder
on purpose.

Some files were not shown because too many files have changed in this diff Show More