Compare commits

...
Author SHA1 Message Date
Charles BochetandGitHub 732aea9121 fix: restore original migration timestamp for messaging infrastructure (#18810)
## Summary
- Restores the original migration timestamp (`1773945207801`) that was
accidentally changed to `1774005903909` during PR #18787
- Keeps the content improvements (default column values) from that PR
intact

## Test plan
- [ ] Verify migration runs correctly with `npx nx run
twenty-server:database:migrate:prod`


Made with [Cursor](https://cursor.com)
2026-03-20 18:14:51 +01:00
42269cc45b fix(links): preserve percent-encoded URLs during normalization (#18792)
## Summary

This preserves percent-encoded payloads when normalizing links fields.

`lowercaseUrlOriginAndRemoveTrailingSlash` was decoding the path and
query string while lowercasing the URL origin. That changes URLs where
encoded payloads are semantically significant, such as Google Maps links
containing `%2F` segments.

Closes #18698.

## Changes

- stop decoding the path/query payload in
`lowercaseUrlOriginAndRemoveTrailingSlash`
- preserve the raw path, query, and hash while still lowercasing the
origin and trimming a trailing slash
- update shared URL normalization tests to assert encoded payloads stay
encoded
- add a server-side regression test covering imported links field
normalization

## Validation

- `corepack yarn jest --config packages/twenty-shared/jest.config.mjs
packages/twenty-shared/src/utils/url/__tests__/lowercaseUrlOriginAndRemoveTrailingSlash.test.ts
--runInBand`
- `corepack yarn jest --config packages/twenty-server/jest.config.mjs
packages/twenty-server/src/engine/core-modules/record-transformer/utils/__tests__/transform-links-value.util.spec.ts
--runInBand`
- `corepack yarn nx test twenty-server --runInBand
--testFile=src/engine/core-modules/record-transformer/utils/__tests__/transform-links-value.util.spec.ts`

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-03-20 16:56:12 +00:00
1be0f1554f i18n - docs translations (#18807)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-20 17:46:46 +01:00
d149c2a041 i18n - translations (#18806)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-20 17:44:20 +01:00
EtienneandGitHub 9698996771 fix useless auth in sdk gql codegen command (#18805) 2026-03-20 17:22:41 +01:00
9cb21e71fa feat: secure and user-scope metadata resolvers for messaging infrastructure (#18787)
## Summary

Builds on the messaging infrastructure migration (#18784) by securing
and user-scoping all 4 metadata resolvers:

### DTOs secured
- **ConnectedAccountDTO**: `@HideField()` on `accessToken`,
`refreshToken`, `connectionParameters`, `oidcTokenClaims`
- **MessageChannelDTO / CalendarChannelDTO**: `@HideField()` on
`syncCursor`
- **MessageFolderDTO**: `@HideField()` on `syncCursor`, `externalId`
- **UpdateMessageFolderInputUpdates**: stripped to only `isSynced`
(removed `name`, `syncCursor`, `pendingSyncAction`)

### Resolvers user-scoped via `@AuthUserWorkspaceId()`
- `myConnectedAccounts` — returns only the calling user's accounts (no
permission guard)
- `myMessageChannels(connectedAccountId?)` — returns channels for the
user's connected accounts
- `myCalendarChannels(connectedAccountId?)` — same pattern
- `myMessageFolders(messageChannelId?)` — returns folders through the
ownership chain

### Admin-only listing with permission guard
- `connectedAccounts` query retained with
`SettingsPermissionGuard(CONNECTED_ACCOUNTS)` for admin listing of all
workspace accounts

### Unsafe mutations removed
- Removed `createConnectedAccount`, `updateConnectedAccount` (OAuth/IMAP
flows create/refresh tokens server-side)
- Removed `create*`/`delete*` mutations from MessageChannel,
CalendarChannel, MessageFolder (managed by sync engine)

### Update mutations restricted with ownership verification
- `deleteConnectedAccount(id)` — verifies `entity.userWorkspaceId ===
currentUserWorkspaceId`
- `updateMessageChannel` / `updateCalendarChannel` /
`updateMessageFolder` — verify ownership through connected account chain
- New `OWNERSHIP_VIOLATION` exception codes map to `ForbiddenError` in
GraphQL

### `@AuthUserWorkspaceId` decorator hardened
- Added `allowUndefined` option (default: `false`) — throws
`ForbiddenException` if `userWorkspaceId` is undefined (e.g. API key
auth)
- Existing callers updated to `@AuthUserWorkspaceId({ allowUndefined:
true })` where needed
- New user-scoped resolvers enforce non-undefined `userWorkspaceId` at
decorator level

### Exception handler chaining
- `MessageFolderGraphqlApiExceptionInterceptor`,
`MessageChannelGraphqlApiExceptionInterceptor`,
`CalendarChannelGraphqlApiExceptionInterceptor` chain upstream exception
handling (ConnectedAccountException, MessageChannelException) for
correct `ForbiddenError` propagation

### Metadata services enhanced
- `findByUserWorkspaceId()`, `getUserConnectedAccountIds()`,
`findByConnectedAccountIds()`, `findByMessageChannelIds()`
- `findBy*ForUser()` methods encapsulate ownership checks before
querying
- `verifyOwnership()` on all 4 services with proper chain validation
- Named parameters throughout for clarity

### Dev seeds for both schemas
- Added JANE to connected account, message channel, calendar channel
workspace seeds
- Created message folder workspace seeds (TIM, JONY, JANE)
- New `seed-metadata-entities.util.ts` seeds core schema tables
(connectedAccount, messageChannel, calendarChannel, messageFolder) with
same IDs as workspace seeds, mapping `accountOwnerId` →
`userWorkspaceId`

### Integration tests (using seeds, not raw SQL)
- 4 test suites (`connected-account`, `message-channel`,
`calendar-channel`, `message-folder`)
- Tests use seeded data IDs from seed constants — no raw SQL
inserts/deletes
- Tests read via GraphQL resolvers
- Tests cover: user scoping, admin permission checks, sensitive field
exclusion, ownership enforcement on mutations

### Frontend migration
- Feature-flag-gated hooks (`useMyConnectedAccounts`,
`useMyMessageChannels`, `useMyCalendarChannels`, `useMyMessageFolders`)
- When `IS_CONNECTED_ACCOUNT_MIGRATED` is on: hooks use metadata API
(`POST /metadata`)
- When flag is off: hooks use existing workspace API (`POST /graphql`,
current behavior)
- Settings account pages updated to use new hooks
- `useEffect` extracted to
`SettingsAccountsSelectedMessageChannelEffect` component per project
conventions
- Error messages translated with Lingui

## Test plan
- [x] Server typecheck passes
- [x] Server lint passes
- [x] Server unit tests pass (477 suites, 4269 tests)
- [x] Frontend typecheck passes
- [x] Frontend lint passes
- [x] Integration tests verify user-scoping, ownership enforcement,
hidden fields
- [ ] CI green

---------

Co-authored-by: neo773 <neo773@protonmail.com>
2026-03-20 17:22:22 +01:00
Thomas TrompetteandGitHub a8625d8bfb Clean workflow query invalid input errors from sentry (#18804)
As title
2026-03-20 16:12:25 +00:00
Charles BochetandGitHub 3d49c21b51 fix: decouple viewPicker favorite detection from sorted navigation menu items (#18803)
## Summary

- Reverts the `useSortedNavigationMenuItems` coupling in
`ViewPickerOptionDropdown` introduced by #18791
- The viewPicker now uses `useNavigationMenuItemsData` directly for the
`isFavorite` check, keeping view ordering and navigation menu item
ordering independent

## Why

PR #18791 replaced `useNavigationMenuItemsData` with
`useSortedNavigationMenuItems` in the viewPicker for favorite detection.
While the intent was to filter out stale/orphaned navigation items, this
created an unnecessary coupling: `useSortedNavigationMenuItems`
subscribes to `viewsSelector` and `objectMetadataItemsSelector`, making
the viewPicker transitively dependent on the navigation menu item
ordering system. View ordering in the picker (driven by `view.position`)
and navigation menu item ordering (driven by
`navigationMenuItem.position`) should remain decorrelated.

## Test plan

- [ ] Open the viewPicker dropdown and verify views are listed in
correct order
- [ ] Drag-and-drop to reorder views in the viewPicker — confirm it
works
- [ ] Verify the "Add to Favorite" / "Manage favorite" label still
correctly reflects favorite state
- [ ] Reorder navigation menu items in the sidebar — confirm viewPicker
order is unaffected


Made with [Cursor](https://cursor.com)
2026-03-20 16:08:26 +00:00
7c8f060b08 Fix orphan navigation menu items for deleted views (#18791)
## Summary

Fixes #18757

This fixes a set of Favorites / navigation-menu-item integrity problems
related to deleted views, stale hidden items, and upgraded workspaces
with orphaned navigation items.

## What changed

- delete `navigationMenuItem` entries when their favorited view is
deleted
- keep the client metadata store in sync immediately when a view is
deleted
- determine whether a view is already favorited from visible valid
navigation items instead of raw stale items
- add a `1.20.0` upgrade repair command that deletes orphan navigation
menu items and normalizes positions
- add regression coverage for deletion of both record-based and
view-based navigation menu items

## Details

Server:
- extend `NavigationMenuItemDeletionService` so cleanup applies to
deleted views as well as deleted records
- add regression tests covering record-based deletion, view-based
deletion, and no-op behavior
- add `DeleteOrphanNavigationMenuItemsCommand` to remove orphaned items
pointing to:
  - deleted views
  - deleted records
  - missing folders
- normalize positions per scope (`userWorkspaceId + folderId`) after
repair
- wire the new repair command into the `1.20.0` upgrade flow

Frontend:
- add `useRemoveNavigationMenuItemByViewId`
- remove the related navigation item from client metadata immediately
when deleting a view
- use sorted / visible navigation items for favorite detection so stale
hidden rows do not block re-adding a favorite

## Why

Issue `#18757` reports mismatches between Favorites shown in the UI and
rows users can still find in the database. We found that current
Favorites behavior is driven by `navigationMenuItem`, not the legacy
`favorite` table, and that stale / orphaned `navigationMenuItem` rows
could:
- remain after deleting a favorited view
- stay hidden from the UI if they point to invalid targets
- still cause the UI to think a view was already favorited
- persist in workspaces with migration damage from skipped sequential
upgrades

This patch addresses those cases directly and adds an upgrade-time
repair path for older corrupted workspaces.

## Validation

Passed:
- `./node_modules/.bin/jest --config
packages/twenty-server/jest.config.mjs --runInBand
packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/services/__tests__/navigation-menu-item-deletion.service.spec.ts`
- `./node_modules/.bin/tsc -p packages/twenty-front/tsconfig.json
--noEmit --pretty false`

Known unrelated existing failure:
- `./node_modules/.bin/tsc -p packages/twenty-server/tsconfig.json
--noEmit --pretty false`

The server typecheck failure is pre-existing and unrelated to this
branch. Current errors are around `@file-type/pdf` module resolution and
`is-psl-parsed-domain.type.ts`.

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
Co-authored-by: Charles Bochet <charlesBochet@users.noreply.github.com>
2026-03-20 15:54:33 +01:00
89300564ba Add a note to documentation about variable change (#18752)
Fixes https://github.com/twentyhq/twenty/issues/18646

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-03-20 15:54:11 +01:00
e7434bdc39 Direct graphql execution (#18759)
On top of [previous closed
PR](https://github.com/twentyhq/twenty/pull/18713) from @FelixMalfait :
- add a schema-creation-skipping optimization
- extract a handler-per-operation pattern, 
- add runtime input validation guards,
- integrate with the standard workspace cache
- add gql-style error handling


To do/optimize/check : 
- gql parsing and null backfilling


## Intro 

This PR introduces a **direct GraphQL execution path** that bypasses
per-workspace GraphQL schema generation for workspace data queries (CRUD
on user-defined objects like companies, people, tasks, etc.).

## Why

In the current architecture, every workspace gets its own
dynamically-generated GraphQL schema reflecting its custom objects and
fields. This costs **~20MB of RAM per workspace per pod** and takes time
to build. For a multi-tenant SaaS with thousands of workspaces, this is
a significant infrastructure cost and a latency bottleneck (especially
on cold starts or cache misses).

The insight is that most workspace queries (`findMany`, `createOne`,
`updateOne`, etc.) don't actually *need* the full schema — they can be
routed directly to the existing Common API query runners by parsing the
GraphQL AST and matching resolver names against object metadata. The
schema is only truly needed for introspection, subscriptions, or queries
that mix core and workspace resolvers.

## How It Works

1. A Yoga `onRequest` plugin intercepts incoming GraphQL requests
2. It parses the query AST and checks if all top-level fields map to
generated workspace resolvers (e.g. `findManyCompanies`,
`createOnePerson`)
3. If yes, it executes them directly against the query runners, skipping
schema generation entirely
4. If the query contains introspection, subscriptions, or core-only
resolvers, it falls through to the normal path
5. Even for mixed queries it can't fully handle, it sets
`skipWorkspaceSchemaCreation` to avoid building the schema when
unnecessary

The whole thing is gated behind the
`IS_DIRECT_GRAPHQL_EXECUTION_ENABLED` feature flag for safe incremental
rollout.

**Net effect**: dramatically lower memory footprint and faster response
times for the vast majority of workspace API calls.

---------

Co-authored-by: Félix Malfait <felix@twenty.com>
2026-03-20 14:33:56 +00:00
EtienneandGitHub 9a850e2241 fix - handle invoice.paid webhook to recover unpaid subscriptions (#18770)
Fixes https://github.com/twentyhq/private-issues/issues/432

## Problem

When a user's invoice goes unpaid, Stripe moves their subscription to
`unpaid` status, and Twenty suspends the workspace. But if the user pays
that invoice while a new billing period has started, Stripe has already
generated a new **draft** invoice for that period. Since the draft isn't
finalized or paid, Stripe doesn't reactivate the subscription — the
workspace stays suspended indefinitely.

## What was missing

- No handler for the `invoice.paid` Stripe webhook event
- No mechanism to finalize draft invoices that accumulated during the
`unpaid` period
- No way to reset the workspace deletion countdown (`suspendedAt`) when
a user shows payment intent

## What was added

### 1. `StripeInvoiceService` — new Stripe SDK wrapper

- `listDraftInvoices(stripeSubscriptionId)` — lists all draft invoices
for a subscription
- `finalizeInvoice(invoiceId)` — finalizes a draft with `auto_advance:
true` so Stripe auto-charges it

### 2. `INVOICE_PAID` enum value

Added to `BillingWebhookEvent` so the controller can route it.

### 3. `processInvoicePaid()` in `BillingWebhookInvoiceService`

New private handler that:

- Fetches all draft invoices for the subscription
- Filters to only those whose `period_end` is in the past (already
overdue)
- Finalizes each one (with error handling per invoice to avoid blocking
the webhook)
- If the workspace is suspended, resets `suspendedAt` to now (buys time
before deletion)

### 4. Controller routing

`INVOICE_FINALIZED` and `INVOICE_PAID` are now grouped in the same
switch case, both calling `processStripeEvent(data, eventType)`, which
forks internally in the service.

## Expected recovery flow

User pays overdue invoice
→ Stripe fires invoice.paid
→ Handler finalizes past-due draft invoices (auto_advance: true)
→ Stripe auto-charges them
→ Subscription becomes active
→ customer.subscription.updated fires
→ Existing logic unsuspends workspace
→ suspendedAt refreshed (resets deletion countdown while payments
cascade)
2026-03-20 14:19:53 +00:00
Charles BochetandGitHub 91793ef930 fix: update workspace members state on profile onboarding completion (#18799)
## Summary

Fixes #18610

After completing the CreateProfile onboarding step,
`currentWorkspaceMembersState` (plural, used by `useActorFieldDisplay`
to resolve "Created by" names) was never updated with the new name —
only `currentWorkspaceMemberState` (singular) was. This caused "Created
by" fields to display "Untitled" for the remainder of the session.

**Root cause analysis:**

The issue reporter attributed the bug to empty
`core.user.firstName/lastName`, but `createdBy` display doesn't use
`core.user` at all. The actual flow:

1. Sign-up creates workspace member with empty names (copied from empty
`core.user`)
2. `GetCurrentUserDocument` loads `currentWorkspaceMembersState` with
empty names
3. CreateProfile step updates workspace member in DB +
`currentWorkspaceMemberState` (singular) ✓
4. `currentWorkspaceMembersState` (plural) is **never refreshed** — the
query is skipped once `currentUser` exists
5. `useActorFieldDisplay` looks up from the stale plural state → empty
name → "Untitled"

**Fix:** Update `currentWorkspaceMembersState` alongside
`currentWorkspaceMemberState` in the CreateProfile submit handler.
2026-03-20 14:02:47 +00:00
Charles BochetandGitHub 217adc8d17 fix: add missing LEFT JOIN for relation fields in groupBy orderByForRecords (#18798)
## Summary

Fixes "missing FROM-clause entry for table" SQL error when using
`orderByForRecords` with a relation field (e.g. `{ company: { name:
"AscNullsFirst" } }`) in a `groupBy` query.

### Root cause

This is a regression from #18005 (Feb 17). That PR correctly removed a
duplicate `applyOrderToBuilder()` call on the groupBy subquery (which
was conflicting with the `ROW_NUMBER() OVER (... ORDER BY ...)` window
function), but in doing so it also removed the LEFT JOINs that
`applyOrderToBuilder` was adding for relation fields.

After that change, ordering relied solely on `getOrderByRawSQL()` inside
`applyPartitionByToBuilder()`, which builds raw SQL for the window
function but never added the required JOINs. Scalar field ordering (e.g.
`name`, `position`) kept working since those don't need JOINs, but
relation field ordering (e.g. `company.name`) broke.

### Fix

- `getOrderByRawSQL` now returns `relationJoins` alongside the SQL
string so callers get the join info they need
- `applyPartitionByToBuilder` in `GroupByWithRecordsService` adds the
required LEFT JOINs before building the `ROW_NUMBER()` window function,
mirroring the pattern used by `applyOrderToBuilder` in the `findMany`
path

## Test plan

- [x] Manually tested locally with a GraphQL query matching the
customer's failing pattern (`orderByForRecords: [{ company: { name:
"AscNullsFirst" } }]`)
- [x] Added integration tests for ascending and descending relation
field ordering in `group-by-with-records-resolver.integration-spec.ts`
- [x] Typecheck passes
- [x] Lint passes
- [x] CI green
2026-03-20 14:02:35 +00:00
Thomas TrompetteandGitHub 698c15b8cd Avoid side panel tab overriden by show page (#18800)
Missing prop so layout are considered as the same
2026-03-20 13:56:02 +00:00
Charles BochetandGitHub 34b3158308 fix: backfill missing ids on SELECT/MULTI_SELECT field metadata options (#18797)
## Summary
- Some production workspaces have `SELECT` or `MULTI_SELECT`
fieldMetadata options that are missing the `id` property (e.g.
`[{"color":"yellow","label":"Draft","value":"DRAFT","position":0},
...]`).
- Adds a new upgrade command
`upgrade:1-20:backfill-select-field-option-ids` that queries all
SELECT/MULTI_SELECT fieldMetadata per workspace, detects options missing
an `id`, and backfills them with a UUID v4.
- The command is idempotent (no-op when all options already have ids),
supports `--dry-run`, and invalidates workspace caches after patching.
2026-03-20 13:08:55 +00:00
Charles BochetandGitHub 66be7c3ef4 fix: autogrow input sizing + surface nested error details in upgrade command (#18796)
## Summary

### Fix 1: Autogrow input unclickable when value is empty string
- Fixes the last name input on the Person record show page being
unclickable on regular screens

### Fix 2: Surface nested migration errors in add-missing-system-fields
command
- `AddMissingSystemFieldsToStandardObjectsCommand` calls
`workspaceMigrationRunnerService.run()` directly and was re-throwing
`WorkspaceMigrationRunnerException` without reading its nested `errors`
- Extracted `getNestedErrorMessages()` helper (reused by both
`isUniqueViolationError` and `enrichErrorMessage`)
- Both catch sites now call `enrichErrorMessage()` before re-throwing,
appending nested error details to the message

**Before:**
```
ERROR [UpgradeCommand] Error in workspace ...: Migration action 'create' for 'fieldMetadata' failed
ERROR [UpgradeCommand] undefined
```

**After:**
```
ERROR [UpgradeCommand] Error in workspace ...: Migration action 'create' for 'fieldMetadata' failed (metadata: duplicate key value violates unique constraint "...")
```

## Test plan
- [x] Lint passes
- [x] Typecheck passes
- [x] Verify upgrade command errors now include nested error details
- [x] Verify autogrow input is clickable when value is empty string
2026-03-20 12:22:27 +00:00
Félix MalfaitandGitHub 0a6b514898 fix: remove redundant cookie write that made tokenPair a session cookie (#18795)
## Summary

- Removes a redundant direct `cookieStorage.setItem('tokenPair', ...)`
call in `handleSetAuthTokens` that was overwriting the Jotai-managed
cookie (which has a 180-day expiry) with a session cookie (no expiry)
- This caused users to be logged out whenever their browser fully
closed, instead of staying authenticated for 180 days

## Root cause

In April 2025 (`a7e6564017`), a direct `cookieStorage.setItem` call was
added alongside `setTokenPair()` as a workaround because Recoil's
`onSet` effect fired too late for the Apollo client to read the token
synchronously.

In February 2026 (`674f4353cd`), `tokenPairState` was migrated from
Recoil to Jotai's `atomWithStorage`, which writes the cookie
**synchronously** with a 180-day `expires`. The old direct write was
left in place and now runs *after* the Jotai write, overwriting the
cookie without an `expires` — making it a session cookie.

## Test plan

- [ ] Log in to the app
- [ ] Inspect the `tokenPair` cookie in DevTools → Application → Cookies
- [ ] Verify the cookie has an expiration date ~180 days from now (not
"Session")
- [ ] Close and reopen the browser — confirm you remain logged in


Made with [Cursor](https://cursor.com)
2026-03-20 12:03:30 +01:00
Thomas TrompetteandGitHub ffb02a6878 Improve workflow metrics with faillure reason (#18768)
- split workflow errors into system and user errors
- add workspace id to attributes for debugging
2026-03-20 10:04:40 +00:00
3cbd9f2b5d i18n - translations (#18786)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-03-20 00:39:40 +01:00
Charles BochetandGitHub cee4cf6452 feat: migrate ConnectedAccount infrastructure entities to metadata schema (#18784)
## Summary

- Migrates 4 entities (`connectedAccount`, `messageChannel`,
`calendarChannel`, `messageFolder`) from per-workspace schemas to the
shared `core` metadata schema
- Introduces a `IS_CONNECTED_ACCOUNT_MIGRATED` feature flag to control
the migration: when enabled, reads come from core metadata and all
writes are dual-written to both workspace and core
- Extracts 12 enums from workspace entity files to `twenty-shared` for
reuse across frontend and backend
- Creates new TypeORM entities, metadata services, GraphQL
resolvers/DTOs, and exception interceptors per entity
- Each entity owns its own data access module
(`ConnectedAccountDataAccessModule`, `MessageChannelDataAccessModule`,
`CalendarChannelDataAccessModule`, `MessageFolderDataAccessModule`) — no
umbrella infrastructure module
- Adds a 1.20 upgrade command that backfills data from workspace schemas
to core (preserving UUIDs) and enables the feature flag
- Replaces direct repository access with data access service calls
across ~50 files in messaging, calendar, and connected-account modules
- Adds `lastSignedInAt` and `oidcTokenClaims` fields to the new
`ConnectedAccountEntity`
- Drops unused `lastSyncHistoryId` field from the migrated connected
account entity

## Test plan

- [x] Lint passes (`npx nx lint:diff-with-main twenty-server`)
- [x] Typecheck passes (`npx nx typecheck twenty-server`)
- [x] All unit tests pass (477 suites, 4267 tests, 0 failures)
- [ ] Manual test: verify messaging sync works with feature flag
disabled (existing behavior)
- [ ] Manual test: run upgrade command on a workspace, verify data
backfilled to core tables
- [ ] Manual test: verify messaging/calendar sync works with feature
flag enabled (dual-write path)
- [ ] Manual test: verify GraphQL metadata resolvers return correct data
when flag enabled
2026-03-20 00:34:58 +01:00
Abdul RahmanandGitHub cd594ce8bd Fix: Hide object from Opened section when it already has a workspace nav item (#18766)
Previously, the Opened section was always hidden for all workflow
objects. We now base the “in nav” check on the full set of object
metadata IDs that have a workspace nav item, instead of only active
non-system objects. So: if an object has a nav item (e.g. workflow
runs), it no longer appears under Opened; if it doesn’t, it can appear
there. The hook was simplified to only expose
`objectMetadataIdsInWorkspaceNav`, and the unused return value was
removed.
2026-03-19 21:57:11 +00:00
26139ee463 fix: stop event propagation when removing file in AI chat preview (#18779)
## Summary

When clicking the ✕ button on an uploaded file in the AI chatbot context
preview, the click event bubbled up to the `StyledClickableContainer`
parent, which triggered `handleClick` (opening the file preview modal)
instead of — or in addition to — calling `onRemove`.

**Root cause:** `StyledClickableContainer` has `onClick={handleClick}`
at the div level. The `AvatarOrIcon` (X button) `onClick={onRemove}`
callback didn't stop propagation, so the event continued to bubble and
opened the preview.

**Fix:** Wrap `onRemove` in a `handleRemove` callback that calls
`e.stopPropagation()` before invoking the original handler.

Fixes #18298

---------

Co-authored-by: victorjzq <zhiqiangjia@users.noreply.github.com>
Co-authored-by: Charles Bochet <charles@twenty.com>
Co-authored-by: Charles Bochet <charlesBochet@users.noreply.github.com>
2026-03-19 19:07:19 +00:00
8005b35b56 Fix relation connect where failing on mixed-case email and URL (#18605)
Related to issue #17711 
Follow-up to PR #17774  which fixed the frontend link normalization only
## Summary
- Normalize `primaryEmail` (lowercase) and `primaryLinkUrl` in relation
`connect.where` composite values
- Applied in both frontend spreadsheet import and backend
`DataArgProcessorService` to cover all entry points (UI import, GraphQL
API)

---------

Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com>
2026-03-19 17:56:26 +00:00
Abdul RahmanandGitHub 02bfebccc2 Fix: cascade delete favorite folder children on backend (#18765) 2026-03-19 17:55:28 +00:00
Thomas TrompetteandGitHub b86e6189c0 Remove postition from Timeline Activities + fix workflow title placeholder (#18777)
- Position were not properly displayed because we never implemented a
display for this
- Untitled placeholder was not displayed anymore
<img width="359" height="117" alt="Capture d’écran 2026-03-19 à 17 11
25"
src="https://github.com/user-attachments/assets/64c90d81-8262-4176-ae25-804748e36b1e"
/>
2026-03-19 17:25:35 +00:00
a9f8a7e1fa Fix: prevent record navigation when clicking Remove from favorite in nav sidebar (#18760)
https://github.com/user-attachments/assets/96abf04d-726a-4225-846e-e5d701a583a2

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-03-19 14:29:12 +00:00
5526d2e5d0 Separate metadata and object record publisher (#18740)
- Separate both publishers
- Renaming
- Removal of the old SSE endpoint

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-03-19 13:24:38 +00:00
430 changed files with 16342 additions and 4436 deletions
@@ -157,7 +157,9 @@ plugins: [
Run `UPDATE core."user" SET "canAccessFullAdminPanel" = TRUE WHERE email = 'you@yourdomain.com';` in database container to get access to admin panel.
#### When running workflow, workflow run fails with "Logic function execution is disabled. Set LOGIC_FUNCTION_TYPE to LOCAL or LAMBDA to enable."
In production, logic functions are disabled by default. Set the `LOGIC_FUNCTION_TYPE` environment variable to `LOCAL` or `LAMBDA` to enable them. This can be configured via environment variables or through the admin panel database variables. See the [Logic Functions setup guide](/developers/self-host/capabilities/setup#logic-functions-available-drivers) for details.
### 1-click Docker compose
@@ -166,6 +166,10 @@ plugins: [
قم بتشغيل `UPDATE core."user" SET "canAccessFullAdminPanel" = TRUE WHERE email = 'you@yourdomain.com';` في حاوية قاعدة البيانات للحصول على الوصول إلى لوحة الإدارة.
#### عند تشغيل سير العمل، يفشل تشغيل سير العمل مع الرسالة "تم تعطيل تنفيذ دوال المنطق. عيّن LOGIC_FUNCTION_TYPE إلى LOCAL أو LAMBDA لتمكين ذلك."
في بيئة الإنتاج، يتم تعطيل دوال المنطق افتراضيًا. قم بتعيين متغير البيئة `LOGIC_FUNCTION_TYPE` إلى `LOCAL` أو `LAMBDA` لتمكينها. يمكن تكوين ذلك عبر متغيرات البيئة أو من خلال متغيرات قاعدة البيانات في لوحة الإدارة. راجع [دليل إعداد دوال المنطق](/l/ar/developers/self-host/capabilities/setup#logic-functions-available-drivers) للحصول على التفاصيل.
### Docker compose بنقرة واحدة
#### غير قادر على تسجيل الدخول
@@ -166,6 +166,10 @@ plugins: [
Führen Sie den folgenden Befehl im Datenbankcontainer aus, um Zugriff auf das Admin-Panel zu erhalten: `UPDATE core."user" SET "canAccessFullAdminPanel" = TRUE WHERE email = 'you@yourdomain.com';`
#### Beim Ausführen eines Workflows schlägt die Workflow-Ausführung fehl mit "Logic function execution is disabled. Set LOGIC_FUNCTION_TYPE to LOCAL or LAMBDA to enable."
In der Produktion sind Logikfunktionen standardmäßig deaktiviert. Setzen Sie die Umgebungsvariable `LOGIC_FUNCTION_TYPE` auf `LOCAL` oder `LAMBDA`, um sie zu aktivieren. Dies kann über Umgebungsvariablen oder über die Datenbankvariablen des Admin-Panels konfiguriert werden. Details finden Sie im [Leitfaden zur Einrichtung von Logikfunktionen](/l/de/developers/self-host/capabilities/setup#logic-functions-available-drivers).
### 1-Klick Docker Compose
#### Kann mich nicht einloggen
@@ -167,6 +167,10 @@ plugins: [
Esegui `UPDATE core."user" SET "canAccessFullAdminPanel" = TRUE WHERE email = 'you@yourdomain.com';` nel container del database per accedere al pannello di amministrazione.
#### Durante l'esecuzione di un workflow, l'esecuzione del workflow non riesce con "L'esecuzione delle funzioni logiche è disabilitata. Imposta LOGIC_FUNCTION_TYPE su LOCAL o LAMBDA per abilitarle."
In produzione, le funzioni logiche sono disabilitate per impostazione predefinita. Imposta la variabile d'ambiente `LOGIC_FUNCTION_TYPE` su `LOCAL` o `LAMBDA` per abilitarle. Questo può essere configurato tramite variabili d'ambiente o tramite le variabili del database del pannello di amministrazione. Consulta la [guida alla configurazione delle funzioni logiche](/l/it/developers/self-host/capabilities/setup#logic-functions-available-drivers) per dettagli.
### Composizione Docker a un clic
#### Impossibile connettersi
@@ -166,6 +166,10 @@ plugins: [
Execute `UPDATE core."user" SET "canAccessFullAdminPanel" = TRUE WHERE email = 'você@seudominio.com';` no contêiner de banco de dados para obter acesso ao painel administrativo.
#### Ao executar um fluxo de trabalho, a execução do fluxo de trabalho falha com "A execução da função de lógica está desativada. Defina LOGIC_FUNCTION_TYPE como LOCAL ou LAMBDA para ativar."
Em produção, as funções de lógica estão desativadas por padrão. Defina a variável de ambiente `LOGIC_FUNCTION_TYPE` como `LOCAL` ou `LAMBDA` para ativá-las. Isso pode ser configurado por meio de variáveis de ambiente ou pelas variáveis de banco de dados do painel de administração. Veja o [guia de configuração de Funções de Lógica](/l/pt/developers/self-host/capabilities/setup#logic-functions-available-drivers) para obter detalhes.
### Docker compose com um clique
#### Impossível efetuar login
@@ -167,6 +167,10 @@ plugins: [
Rulați `UPDATE core."user" SET "canAccessFullAdminPanel" = TRUE WHERE email = 'tine@domeniultău.com';` în containerul de baze de date pentru a obține acces la panoul de administrare.
#### Când rulați un flux de lucru, rularea fluxului de lucru eșuează cu "Execuția funcțiilor logice este dezactivată. Setați LOGIC_FUNCTION_TYPE la LOCAL sau LAMBDA pentru a le activa."
În producție, funcțiile logice sunt dezactivate în mod implicit. Setați variabila de mediu `LOGIC_FUNCTION_TYPE` la `LOCAL` sau `LAMBDA` pentru a le activa. Acest lucru poate fi configurat prin variabile de mediu sau prin variabilele bazei de date din panoul de administrare. Consultați [Ghidul de configurare a funcțiilor logice](/l/ro/developers/self-host/capabilities/setup#logic-functions-available-drivers) pentru detalii.
### Docker compose cu un singur click
#### Nu se poate conecta
@@ -167,6 +167,10 @@ plugins: [
Выполните `UPDATE core."user" SET "canAccessFullAdminPanel" = TRUE WHERE email = 'you@yourdomain.com';` в контейнере базы данных, чтобы получить доступ к административной панели.
#### При запуске рабочего процесса выполнение завершается ошибкой: "Выполнение логической функции отключено. Установите LOGIC_FUNCTION_TYPE в значение LOCAL или LAMBDA, чтобы их включить.
В производственной среде логические функции по умолчанию отключены. Установите переменную окружения `LOGIC_FUNCTION_TYPE` в `LOCAL` или `LAMBDA`, чтобы их включить. Это можно настроить через переменные окружения или через переменные базы данных в панели администратора. См. [руководство по настройке логических функций](/l/ru/developers/self-host/capabilities/setup#logic-functions-available-drivers) для получения подробной информации.
### Docker Compose в один клик
#### Не удается войти в систему
@@ -166,6 +166,10 @@ plugins: [
Veritabanı konteynerinde `UPDATE core."user" SET "canAccessFullAdminPanel" = TRUE WHERE email = 'you@yourdomain.com';` komutunu çalıştırarak yönetim paneline erişim sağlayın.
#### Bir iş akışı çalıştırılırken, iş akışının çalıştırılması şu hatayla başarısız oluyor: "Mantık işlevi yürütme devre dışı. Etkinleştirmek için LOGIC_FUNCTION_TYPE değerini LOCAL veya LAMBDA olarak ayarlayın."
Üretimde, mantık işlevleri varsayılan olarak devre dışıdır. Bunları etkinleştirmek için `LOGIC_FUNCTION_TYPE` ortam değişkenini `LOCAL` veya `LAMBDA` olarak ayarlayın. Bu, ortam değişkenleri aracılığıyla veya yönetici panelindeki veritabanı değişkenleri üzerinden yapılandırılabilir. Ayrıntılar için [Mantık İşlevleri kurulum kılavuzu](/l/tr/developers/self-host/capabilities/setup#logic-functions-available-drivers) bölümüne bakın.
### 1-tıklama ile Docker Compose
#### Giriş Yapılamıyor
@@ -166,6 +166,10 @@ plugins: [
在数据库容器中运行 `UPDATE core."user" SET "canAccessFullAdminPanel" = TRUE WHERE email = 'you@yourdomain.com';` 以访问管理面板。
#### 在运行工作流时,工作流运行失败,并提示 "Logic function execution is disabled." 将 LOGIC_FUNCTION_TYPE 设置为 LOCAL 或 LAMBDA 以启用。
在生产环境中,逻辑函数默认处于禁用状态。 将 `LOGIC_FUNCTION_TYPE` 环境变量设置为 `LOCAL` 或 `LAMBDA` 以启用它们。 这可以通过环境变量或管理面板的数据库变量进行配置。 详情请参见[逻辑函数设置指南](/l/zh/developers/self-host/capabilities/setup#logic-functions-available-drivers)。
### 一键使用 Docker Compose
#### 无法登录
@@ -761,6 +761,55 @@ export type BooleanFieldComparison = {
isNot?: InputMaybe<Scalars['Boolean']>;
};
export type CalendarChannel = {
__typename?: 'CalendarChannel';
connectedAccountId: Scalars['UUID'];
contactAutoCreationPolicy: CalendarChannelContactAutoCreationPolicy;
createdAt: Scalars['DateTime'];
handle: Scalars['String'];
id: Scalars['UUID'];
isContactAutoCreationEnabled: Scalars['Boolean'];
isSyncEnabled: Scalars['Boolean'];
syncStage: CalendarChannelSyncStage;
syncStageStartedAt?: Maybe<Scalars['DateTime']>;
syncStatus: CalendarChannelSyncStatus;
syncedAt?: Maybe<Scalars['DateTime']>;
throttleFailureCount: Scalars['Float'];
updatedAt: Scalars['DateTime'];
visibility: CalendarChannelVisibility;
};
export enum CalendarChannelContactAutoCreationPolicy {
AS_ORGANIZER = 'AS_ORGANIZER',
AS_PARTICIPANT = 'AS_PARTICIPANT',
AS_PARTICIPANT_AND_ORGANIZER = 'AS_PARTICIPANT_AND_ORGANIZER',
NONE = 'NONE'
}
export enum CalendarChannelSyncStage {
CALENDAR_EVENTS_IMPORT_ONGOING = 'CALENDAR_EVENTS_IMPORT_ONGOING',
CALENDAR_EVENTS_IMPORT_PENDING = 'CALENDAR_EVENTS_IMPORT_PENDING',
CALENDAR_EVENTS_IMPORT_SCHEDULED = 'CALENDAR_EVENTS_IMPORT_SCHEDULED',
CALENDAR_EVENT_LIST_FETCH_ONGOING = 'CALENDAR_EVENT_LIST_FETCH_ONGOING',
CALENDAR_EVENT_LIST_FETCH_PENDING = 'CALENDAR_EVENT_LIST_FETCH_PENDING',
CALENDAR_EVENT_LIST_FETCH_SCHEDULED = 'CALENDAR_EVENT_LIST_FETCH_SCHEDULED',
FAILED = 'FAILED',
PENDING_CONFIGURATION = 'PENDING_CONFIGURATION'
}
export enum CalendarChannelSyncStatus {
ACTIVE = 'ACTIVE',
FAILED_INSUFFICIENT_PERMISSIONS = 'FAILED_INSUFFICIENT_PERMISSIONS',
FAILED_UNKNOWN = 'FAILED_UNKNOWN',
NOT_SYNCED = 'NOT_SYNCED',
ONGOING = 'ONGOING'
}
export enum CalendarChannelVisibility {
METADATA = 'METADATA',
SHARE_EVERYTHING = 'SHARE_EVERYTHING'
}
export type CalendarConfiguration = {
__typename?: 'CalendarConfiguration';
configurationType: WidgetConfigurationType;
@@ -927,6 +976,21 @@ export type ConfigVariablesGroupData = {
variables: Array<ConfigVariable>;
};
export type ConnectedAccountDto = {
__typename?: 'ConnectedAccountDTO';
authFailedAt?: Maybe<Scalars['DateTime']>;
createdAt: Scalars['DateTime'];
handle: Scalars['String'];
handleAliases?: Maybe<Array<Scalars['String']>>;
id: Scalars['UUID'];
lastCredentialsRefreshedAt?: Maybe<Scalars['DateTime']>;
lastSignedInAt?: Maybe<Scalars['DateTime']>;
provider: Scalars['String'];
scopes?: Maybe<Array<Scalars['String']>>;
updatedAt: Scalars['DateTime'];
userWorkspaceId: Scalars['UUID'];
};
export type ConnectedImapSmtpCaldavAccount = {
__typename?: 'ConnectedImapSmtpCaldavAccount';
accountOwnerId: Scalars['UUID'];
@@ -1618,8 +1682,10 @@ export enum FeatureFlagKey {
IS_APPLICATION_ENABLED = 'IS_APPLICATION_ENABLED',
IS_ATTACHMENT_MIGRATED = 'IS_ATTACHMENT_MIGRATED',
IS_COMMAND_MENU_ITEM_ENABLED = 'IS_COMMAND_MENU_ITEM_ENABLED',
IS_CONNECTED_ACCOUNT_MIGRATED = 'IS_CONNECTED_ACCOUNT_MIGRATED',
IS_DASHBOARD_V2_ENABLED = 'IS_DASHBOARD_V2_ENABLED',
IS_DATE_TIME_WHOLE_DAY_FILTER_ENABLED = 'IS_DATE_TIME_WHOLE_DAY_FILTER_ENABLED',
IS_DIRECT_GRAPHQL_EXECUTION_ENABLED = 'IS_DIRECT_GRAPHQL_EXECUTION_ENABLED',
IS_DRAFT_EMAIL_ENABLED = 'IS_DRAFT_EMAIL_ENABLED',
IS_EMAILING_DOMAIN_ENABLED = 'IS_EMAILING_DOMAIN_ENABLED',
IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED',
@@ -2289,6 +2355,96 @@ export type MarketplaceAppRoleObjectPermission = {
objectUniversalIdentifier: Scalars['String'];
};
export type MessageChannel = {
__typename?: 'MessageChannel';
connectedAccountId: Scalars['UUID'];
contactAutoCreationPolicy: MessageChannelContactAutoCreationPolicy;
createdAt: Scalars['DateTime'];
excludeGroupEmails: Scalars['Boolean'];
excludeNonProfessionalEmails: Scalars['Boolean'];
handle: Scalars['String'];
id: Scalars['UUID'];
isContactAutoCreationEnabled: Scalars['Boolean'];
isSyncEnabled: Scalars['Boolean'];
messageFolderImportPolicy: MessageFolderImportPolicy;
pendingGroupEmailsAction: MessageChannelPendingGroupEmailsAction;
syncStage: MessageChannelSyncStage;
syncStageStartedAt?: Maybe<Scalars['DateTime']>;
syncStatus: MessageChannelSyncStatus;
syncedAt?: Maybe<Scalars['DateTime']>;
throttleFailureCount: Scalars['Float'];
throttleRetryAfter?: Maybe<Scalars['DateTime']>;
type: MessageChannelType;
updatedAt: Scalars['DateTime'];
visibility: MessageChannelVisibility;
};
export enum MessageChannelContactAutoCreationPolicy {
NONE = 'NONE',
SENT = 'SENT',
SENT_AND_RECEIVED = 'SENT_AND_RECEIVED'
}
export enum MessageChannelPendingGroupEmailsAction {
GROUP_EMAILS_DELETION = 'GROUP_EMAILS_DELETION',
GROUP_EMAILS_IMPORT = 'GROUP_EMAILS_IMPORT',
NONE = 'NONE'
}
export enum MessageChannelSyncStage {
FAILED = 'FAILED',
MESSAGES_IMPORT_ONGOING = 'MESSAGES_IMPORT_ONGOING',
MESSAGES_IMPORT_PENDING = 'MESSAGES_IMPORT_PENDING',
MESSAGES_IMPORT_SCHEDULED = 'MESSAGES_IMPORT_SCHEDULED',
MESSAGE_LIST_FETCH_ONGOING = 'MESSAGE_LIST_FETCH_ONGOING',
MESSAGE_LIST_FETCH_PENDING = 'MESSAGE_LIST_FETCH_PENDING',
MESSAGE_LIST_FETCH_SCHEDULED = 'MESSAGE_LIST_FETCH_SCHEDULED',
PENDING_CONFIGURATION = 'PENDING_CONFIGURATION'
}
export enum MessageChannelSyncStatus {
ACTIVE = 'ACTIVE',
FAILED_INSUFFICIENT_PERMISSIONS = 'FAILED_INSUFFICIENT_PERMISSIONS',
FAILED_UNKNOWN = 'FAILED_UNKNOWN',
NOT_SYNCED = 'NOT_SYNCED',
ONGOING = 'ONGOING'
}
export enum MessageChannelType {
EMAIL = 'EMAIL',
SMS = 'SMS'
}
export enum MessageChannelVisibility {
METADATA = 'METADATA',
SHARE_EVERYTHING = 'SHARE_EVERYTHING',
SUBJECT = 'SUBJECT'
}
export type MessageFolder = {
__typename?: 'MessageFolder';
createdAt: Scalars['DateTime'];
externalId?: Maybe<Scalars['String']>;
id: Scalars['UUID'];
isSentFolder: Scalars['Boolean'];
isSynced: Scalars['Boolean'];
messageChannelId: Scalars['UUID'];
name?: Maybe<Scalars['String']>;
parentFolderId?: Maybe<Scalars['UUID']>;
pendingSyncAction: MessageFolderPendingSyncAction;
updatedAt: Scalars['DateTime'];
};
export enum MessageFolderImportPolicy {
ALL_FOLDERS = 'ALL_FOLDERS',
SELECTED_FOLDERS = 'SELECTED_FOLDERS'
}
export enum MessageFolderPendingSyncAction {
FOLDER_DELETION = 'FOLDER_DELETION',
NONE = 'NONE'
}
export type MetadataEvent = {
__typename?: 'MetadataEvent';
metadataName: Scalars['String'];
@@ -2398,6 +2554,7 @@ export type Mutation = {
deleteApplicationRegistrationVariable: Scalars['Boolean'];
deleteApprovedAccessDomain: Scalars['Boolean'];
deleteCommandMenuItem: CommandMenuItem;
deleteConnectedAccount: ConnectedAccountDto;
deleteCurrentWorkspace: Workspace;
deleteDatabaseConfigVariable: Scalars['Boolean'];
deleteEmailingDomain: Scalars['Boolean'];
@@ -2487,10 +2644,14 @@ export type Mutation = {
updateApiKey?: Maybe<ApiKey>;
updateApplicationRegistration: ApplicationRegistration;
updateApplicationRegistrationVariable: ApplicationRegistrationVariable;
updateCalendarChannel: CalendarChannel;
updateCommandMenuItem: CommandMenuItem;
updateDatabaseConfigVariable: Scalars['Boolean'];
updateFrontComponent: FrontComponent;
updateLabPublicFeatureFlag: FeatureFlag;
updateMessageChannel: MessageChannel;
updateMessageFolder: MessageFolder;
updateMessageFolders: Array<MessageFolder>;
updateNavigationMenuItem: NavigationMenuItem;
updateOneAgent: Agent;
updateOneApplicationVariable: Scalars['Boolean'];
@@ -2794,6 +2955,11 @@ export type MutationDeleteCommandMenuItemArgs = {
};
export type MutationDeleteConnectedAccountArgs = {
id: Scalars['UUID'];
};
export type MutationDeleteDatabaseConfigVariableArgs = {
key: Scalars['String'];
};
@@ -3217,6 +3383,11 @@ export type MutationUpdateApplicationRegistrationVariableArgs = {
};
export type MutationUpdateCalendarChannelArgs = {
input: UpdateCalendarChannelInput;
};
export type MutationUpdateCommandMenuItemArgs = {
input: UpdateCommandMenuItemInput;
};
@@ -3238,6 +3409,21 @@ export type MutationUpdateLabPublicFeatureFlagArgs = {
};
export type MutationUpdateMessageChannelArgs = {
input: UpdateMessageChannelInput;
};
export type MutationUpdateMessageFolderArgs = {
input: UpdateMessageFolderInput;
};
export type MutationUpdateMessageFoldersArgs = {
input: UpdateMessageFoldersInput;
};
export type MutationUpdateNavigationMenuItemArgs = {
input: UpdateOneNavigationMenuItemInput;
};
@@ -3687,21 +3873,6 @@ export type ObjectStandardOverrides = {
translations?: Maybe<Scalars['JSON']>;
};
export type OnDbEvent = {
__typename?: 'OnDbEvent';
action: DatabaseEventAction;
eventDate: Scalars['DateTime'];
objectNameSingular: Scalars['String'];
record: Scalars['JSON'];
updatedFields?: Maybe<Array<Scalars['String']>>;
};
export type OnDbEventInput = {
action?: InputMaybe<DatabaseEventAction>;
objectNameSingular?: InputMaybe<Scalars['String']>;
recordId?: InputMaybe<Scalars['UUID']>;
};
/** Onboarding status */
export enum OnboardingStatus {
BOOK_ONBOARDING = 'BOOK_ONBOARDING',
@@ -3961,6 +4132,7 @@ export type Query = {
checkWorkspaceInviteHashIsValid: WorkspaceInviteHashValid;
commandMenuItem?: Maybe<CommandMenuItem>;
commandMenuItems: Array<CommandMenuItem>;
connectedAccounts: Array<ConnectedAccountDto>;
currentUser: User;
currentWorkspace: Workspace;
enterpriseCheckoutSession?: Maybe<Scalars['String']>;
@@ -4036,6 +4208,10 @@ export type Query = {
lineChartData: LineChartData;
listPlans: Array<BillingPlan>;
minimalMetadata: MinimalMetadata;
myCalendarChannels: Array<CalendarChannel>;
myConnectedAccounts: Array<ConnectedAccountDto>;
myMessageChannels: Array<MessageChannel>;
myMessageFolders: Array<MessageFolder>;
navigationMenuItem?: Maybe<NavigationMenuItem>;
navigationMenuItems: Array<NavigationMenuItem>;
object: Object;
@@ -4372,6 +4548,21 @@ export type QueryLineChartDataArgs = {
};
export type QueryMyCalendarChannelsArgs = {
connectedAccountId?: InputMaybe<Scalars['UUID']>;
};
export type QueryMyMessageChannelsArgs = {
connectedAccountId?: InputMaybe<Scalars['UUID']>;
};
export type QueryMyMessageFoldersArgs = {
messageChannelId?: InputMaybe<Scalars['UUID']>;
};
export type QueryNavigationMenuItemArgs = {
id: Scalars['UUID'];
};
@@ -4738,7 +4929,6 @@ export type StandardOverrides = {
export type Subscription = {
__typename?: 'Subscription';
logicFunctionLogs: LogicFunctionLogs;
onDbEvent: OnDbEvent;
onEventSubscription?: Maybe<EventSubscription>;
};
@@ -4748,11 +4938,6 @@ export type SubscriptionLogicFunctionLogsArgs = {
};
export type SubscriptionOnDbEventArgs = {
input: OnDbEventInput;
};
export type SubscriptionOnEventSubscriptionArgs = {
eventStreamId: Scalars['String'];
};
@@ -4892,6 +5077,18 @@ export type UpdateApplicationRegistrationVariablePayload = {
value?: InputMaybe<Scalars['String']>;
};
export type UpdateCalendarChannelInput = {
id: Scalars['UUID'];
update: UpdateCalendarChannelInputUpdates;
};
export type UpdateCalendarChannelInputUpdates = {
contactAutoCreationPolicy?: InputMaybe<CalendarChannelContactAutoCreationPolicy>;
isContactAutoCreationEnabled?: InputMaybe<Scalars['Boolean']>;
isSyncEnabled?: InputMaybe<Scalars['Boolean']>;
visibility?: InputMaybe<CalendarChannelVisibility>;
};
export type UpdateCommandMenuItemInput = {
availabilityObjectMetadataId?: InputMaybe<Scalars['UUID']>;
availabilityType?: InputMaybe<CommandMenuItemAvailabilityType>;
@@ -4961,6 +5158,35 @@ export type UpdateLogicFunctionFromSourceInputUpdates = {
toolInputSchema?: InputMaybe<Scalars['JSON']>;
};
export type UpdateMessageChannelInput = {
id: Scalars['UUID'];
update: UpdateMessageChannelInputUpdates;
};
export type UpdateMessageChannelInputUpdates = {
contactAutoCreationPolicy?: InputMaybe<MessageChannelContactAutoCreationPolicy>;
excludeGroupEmails?: InputMaybe<Scalars['Boolean']>;
excludeNonProfessionalEmails?: InputMaybe<Scalars['Boolean']>;
isContactAutoCreationEnabled?: InputMaybe<Scalars['Boolean']>;
isSyncEnabled?: InputMaybe<Scalars['Boolean']>;
messageFolderImportPolicy?: InputMaybe<MessageFolderImportPolicy>;
visibility?: InputMaybe<MessageChannelVisibility>;
};
export type UpdateMessageFolderInput = {
id: Scalars['UUID'];
update: UpdateMessageFolderInputUpdates;
};
export type UpdateMessageFolderInputUpdates = {
isSynced?: InputMaybe<Scalars['Boolean']>;
};
export type UpdateMessageFoldersInput = {
ids: Array<Scalars['UUID']>;
update: UpdateMessageFolderInputUpdates;
};
export type UpdateNavigationMenuItemInput = {
color?: InputMaybe<Scalars['String']>;
folderId?: InputMaybe<Scalars['UUID']>;
@@ -6598,6 +6824,13 @@ export type PieChartDataQueryVariables = Exact<{
export type PieChartDataQuery = { __typename?: 'Query', pieChartData: { __typename?: 'PieChartData', showLegend: boolean, showDataLabels: boolean, showCenterMetric: boolean, hasTooManyGroups: boolean, formattedToRawLookup: any, data: Array<{ __typename?: 'PieChartDataItem', id: string, value: number }> } };
export type DeleteConnectedAccountMutationVariables = Exact<{
id: Scalars['UUID'];
}>;
export type DeleteConnectedAccountMutation = { __typename?: 'Mutation', deleteConnectedAccount: { __typename?: 'ConnectedAccountDTO', id: string } };
export type SaveImapSmtpCaldavAccountMutationVariables = Exact<{
accountOwnerId: Scalars['UUID'];
handle: Scalars['String'];
@@ -6615,6 +6848,34 @@ export type StartChannelSyncMutationVariables = Exact<{
export type StartChannelSyncMutation = { __typename?: 'Mutation', startChannelSync: { __typename?: 'ChannelSyncSuccess', success: boolean } };
export type UpdateCalendarChannelMutationVariables = Exact<{
input: UpdateCalendarChannelInput;
}>;
export type UpdateCalendarChannelMutation = { __typename?: 'Mutation', updateCalendarChannel: { __typename?: 'CalendarChannel', id: string, visibility: CalendarChannelVisibility, isContactAutoCreationEnabled: boolean, contactAutoCreationPolicy: CalendarChannelContactAutoCreationPolicy } };
export type UpdateMessageChannelMutationVariables = Exact<{
input: UpdateMessageChannelInput;
}>;
export type UpdateMessageChannelMutation = { __typename?: 'Mutation', updateMessageChannel: { __typename?: 'MessageChannel', id: string, visibility: MessageChannelVisibility, contactAutoCreationPolicy: MessageChannelContactAutoCreationPolicy, excludeNonProfessionalEmails: boolean, excludeGroupEmails: boolean, messageFolderImportPolicy: MessageFolderImportPolicy } };
export type UpdateMessageFolderMutationVariables = Exact<{
input: UpdateMessageFolderInput;
}>;
export type UpdateMessageFolderMutation = { __typename?: 'Mutation', updateMessageFolder: { __typename?: 'MessageFolder', id: string, isSynced: boolean } };
export type UpdateMessageFoldersMutationVariables = Exact<{
input: UpdateMessageFoldersInput;
}>;
export type UpdateMessageFoldersMutation = { __typename?: 'Mutation', updateMessageFolders: Array<{ __typename?: 'MessageFolder', id: string, isSynced: boolean }> };
export type GetConnectedImapSmtpCaldavAccountQueryVariables = Exact<{
id: Scalars['UUID'];
}>;
@@ -6622,6 +6883,32 @@ export type GetConnectedImapSmtpCaldavAccountQueryVariables = Exact<{
export type GetConnectedImapSmtpCaldavAccountQuery = { __typename?: 'Query', getConnectedImapSmtpCaldavAccount: { __typename?: 'ConnectedImapSmtpCaldavAccount', id: string, handle: string, provider: string, accountOwnerId: string, connectionParameters?: { __typename?: 'ImapSmtpCaldavConnectionParameters', IMAP?: { __typename?: 'ConnectionParametersOutput', host: string, port: number, secure?: boolean | null, username?: string | null, password: string } | null, SMTP?: { __typename?: 'ConnectionParametersOutput', host: string, username?: string | null, port: number, secure?: boolean | null, password: string } | null, CALDAV?: { __typename?: 'ConnectionParametersOutput', host: string, username?: string | null, password: string } | null } | null } };
export type MyCalendarChannelsQueryVariables = Exact<{
connectedAccountId?: InputMaybe<Scalars['UUID']>;
}>;
export type MyCalendarChannelsQuery = { __typename?: 'Query', myCalendarChannels: Array<{ __typename?: 'CalendarChannel', id: string, handle: string, visibility: CalendarChannelVisibility, syncStatus: CalendarChannelSyncStatus, syncStage: CalendarChannelSyncStage, syncStageStartedAt?: string | null, isContactAutoCreationEnabled: boolean, contactAutoCreationPolicy: CalendarChannelContactAutoCreationPolicy, isSyncEnabled: boolean, connectedAccountId: string, createdAt: string, updatedAt: string }> };
export type MyConnectedAccountsQueryVariables = Exact<{ [key: string]: never; }>;
export type MyConnectedAccountsQuery = { __typename?: 'Query', myConnectedAccounts: Array<{ __typename?: 'ConnectedAccountDTO', id: string, handle: string, provider: string, authFailedAt?: string | null, scopes?: Array<string> | null, handleAliases?: Array<string> | null, lastSignedInAt?: string | null, userWorkspaceId: string, createdAt: string, updatedAt: string }> };
export type MyMessageChannelsQueryVariables = Exact<{
connectedAccountId?: InputMaybe<Scalars['UUID']>;
}>;
export type MyMessageChannelsQuery = { __typename?: 'Query', myMessageChannels: Array<{ __typename?: 'MessageChannel', id: string, handle: string, visibility: MessageChannelVisibility, type: MessageChannelType, isContactAutoCreationEnabled: boolean, contactAutoCreationPolicy: MessageChannelContactAutoCreationPolicy, messageFolderImportPolicy: MessageFolderImportPolicy, excludeNonProfessionalEmails: boolean, excludeGroupEmails: boolean, isSyncEnabled: boolean, syncStatus: MessageChannelSyncStatus, syncStage: MessageChannelSyncStage, syncStageStartedAt?: string | null, connectedAccountId: string, createdAt: string, updatedAt: string }> };
export type MyMessageFoldersQueryVariables = Exact<{
messageChannelId?: InputMaybe<Scalars['UUID']>;
}>;
export type MyMessageFoldersQuery = { __typename?: 'Query', myMessageFolders: Array<{ __typename?: 'MessageFolder', id: string, name?: string | null, isSynced: boolean, isSentFolder: boolean, parentFolderId?: string | null, externalId?: string | null, messageChannelId: string, createdAt: string, updatedAt: string }> };
export type SetAdminAiModelEnabledMutationVariables = Exact<{
modelId: Scalars['String'];
enabled: Scalars['Boolean'];
@@ -7723,9 +8010,18 @@ export const FindAllRecordPageLayoutsDocument = {"kind":"Document","definitions"
export const BarChartDataDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"BarChartData"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"BarChartDataInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"barChartData"},"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":"data"}},{"kind":"Field","name":{"kind":"Name","value":"indexBy"}},{"kind":"Field","name":{"kind":"Name","value":"keys"}},{"kind":"Field","name":{"kind":"Name","value":"series"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"label"}}]}},{"kind":"Field","name":{"kind":"Name","value":"xAxisLabel"}},{"kind":"Field","name":{"kind":"Name","value":"yAxisLabel"}},{"kind":"Field","name":{"kind":"Name","value":"showLegend"}},{"kind":"Field","name":{"kind":"Name","value":"showDataLabels"}},{"kind":"Field","name":{"kind":"Name","value":"layout"}},{"kind":"Field","name":{"kind":"Name","value":"groupMode"}},{"kind":"Field","name":{"kind":"Name","value":"hasTooManyGroups"}},{"kind":"Field","name":{"kind":"Name","value":"formattedToRawLookup"}}]}}]}}]} as unknown as DocumentNode<BarChartDataQuery, BarChartDataQueryVariables>;
export const LineChartDataDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"LineChartData"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"LineChartDataInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"lineChartData"},"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":"series"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"label"}},{"kind":"Field","name":{"kind":"Name","value":"data"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"x"}},{"kind":"Field","name":{"kind":"Name","value":"y"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"xAxisLabel"}},{"kind":"Field","name":{"kind":"Name","value":"yAxisLabel"}},{"kind":"Field","name":{"kind":"Name","value":"showLegend"}},{"kind":"Field","name":{"kind":"Name","value":"showDataLabels"}},{"kind":"Field","name":{"kind":"Name","value":"hasTooManyGroups"}},{"kind":"Field","name":{"kind":"Name","value":"formattedToRawLookup"}}]}}]}}]} as unknown as DocumentNode<LineChartDataQuery, LineChartDataQueryVariables>;
export const PieChartDataDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"PieChartData"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"PieChartDataInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"pieChartData"},"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":"data"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"value"}}]}},{"kind":"Field","name":{"kind":"Name","value":"showLegend"}},{"kind":"Field","name":{"kind":"Name","value":"showDataLabels"}},{"kind":"Field","name":{"kind":"Name","value":"showCenterMetric"}},{"kind":"Field","name":{"kind":"Name","value":"hasTooManyGroups"}},{"kind":"Field","name":{"kind":"Name","value":"formattedToRawLookup"}}]}}]}}]} as unknown as DocumentNode<PieChartDataQuery, PieChartDataQueryVariables>;
export const DeleteConnectedAccountDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteConnectedAccount"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteConnectedAccount"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]} as unknown as DocumentNode<DeleteConnectedAccountMutation, DeleteConnectedAccountMutationVariables>;
export const SaveImapSmtpCaldavAccountDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SaveImapSmtpCaldavAccount"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"accountOwnerId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"handle"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"connectionParameters"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"EmailAccountConnectionParameters"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"saveImapSmtpCaldavAccount"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"accountOwnerId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"accountOwnerId"}}},{"kind":"Argument","name":{"kind":"Name","value":"handle"},"value":{"kind":"Variable","name":{"kind":"Name","value":"handle"}}},{"kind":"Argument","name":{"kind":"Name","value":"connectionParameters"},"value":{"kind":"Variable","name":{"kind":"Name","value":"connectionParameters"}}},{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"success"}},{"kind":"Field","name":{"kind":"Name","value":"connectedAccountId"}}]}}]}}]} as unknown as DocumentNode<SaveImapSmtpCaldavAccountMutation, SaveImapSmtpCaldavAccountMutationVariables>;
export const StartChannelSyncDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"StartChannelSync"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"connectedAccountId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"startChannelSync"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"connectedAccountId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"connectedAccountId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"success"}}]}}]}}]} as unknown as DocumentNode<StartChannelSyncMutation, StartChannelSyncMutationVariables>;
export const UpdateCalendarChannelDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateCalendarChannel"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateCalendarChannelInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateCalendarChannel"},"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":"id"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"isContactAutoCreationEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"contactAutoCreationPolicy"}}]}}]}}]} as unknown as DocumentNode<UpdateCalendarChannelMutation, UpdateCalendarChannelMutationVariables>;
export const UpdateMessageChannelDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateMessageChannel"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateMessageChannelInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateMessageChannel"},"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":"id"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"contactAutoCreationPolicy"}},{"kind":"Field","name":{"kind":"Name","value":"excludeNonProfessionalEmails"}},{"kind":"Field","name":{"kind":"Name","value":"excludeGroupEmails"}},{"kind":"Field","name":{"kind":"Name","value":"messageFolderImportPolicy"}}]}}]}}]} as unknown as DocumentNode<UpdateMessageChannelMutation, UpdateMessageChannelMutationVariables>;
export const UpdateMessageFolderDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateMessageFolder"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateMessageFolderInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateMessageFolder"},"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":"id"}},{"kind":"Field","name":{"kind":"Name","value":"isSynced"}}]}}]}}]} as unknown as DocumentNode<UpdateMessageFolderMutation, UpdateMessageFolderMutationVariables>;
export const UpdateMessageFoldersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateMessageFolders"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateMessageFoldersInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateMessageFolders"},"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":"id"}},{"kind":"Field","name":{"kind":"Name","value":"isSynced"}}]}}]}}]} as unknown as DocumentNode<UpdateMessageFoldersMutation, UpdateMessageFoldersMutationVariables>;
export const GetConnectedImapSmtpCaldavAccountDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetConnectedImapSmtpCaldavAccount"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getConnectedImapSmtpCaldavAccount"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"handle"}},{"kind":"Field","name":{"kind":"Name","value":"provider"}},{"kind":"Field","name":{"kind":"Name","value":"accountOwnerId"}},{"kind":"Field","name":{"kind":"Name","value":"connectionParameters"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"IMAP"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"host"}},{"kind":"Field","name":{"kind":"Name","value":"port"}},{"kind":"Field","name":{"kind":"Name","value":"secure"}},{"kind":"Field","name":{"kind":"Name","value":"username"}},{"kind":"Field","name":{"kind":"Name","value":"password"}}]}},{"kind":"Field","name":{"kind":"Name","value":"SMTP"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"host"}},{"kind":"Field","name":{"kind":"Name","value":"username"}},{"kind":"Field","name":{"kind":"Name","value":"port"}},{"kind":"Field","name":{"kind":"Name","value":"secure"}},{"kind":"Field","name":{"kind":"Name","value":"password"}}]}},{"kind":"Field","name":{"kind":"Name","value":"CALDAV"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"host"}},{"kind":"Field","name":{"kind":"Name","value":"username"}},{"kind":"Field","name":{"kind":"Name","value":"password"}}]}}]}}]}}]}}]} as unknown as DocumentNode<GetConnectedImapSmtpCaldavAccountQuery, GetConnectedImapSmtpCaldavAccountQueryVariables>;
export const MyCalendarChannelsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"MyCalendarChannels"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"connectedAccountId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"myCalendarChannels"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"connectedAccountId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"connectedAccountId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"handle"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"syncStatus"}},{"kind":"Field","name":{"kind":"Name","value":"syncStage"}},{"kind":"Field","name":{"kind":"Name","value":"syncStageStartedAt"}},{"kind":"Field","name":{"kind":"Name","value":"isContactAutoCreationEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"contactAutoCreationPolicy"}},{"kind":"Field","name":{"kind":"Name","value":"isSyncEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"connectedAccountId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode<MyCalendarChannelsQuery, MyCalendarChannelsQueryVariables>;
export const MyConnectedAccountsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"MyConnectedAccounts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"myConnectedAccounts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"handle"}},{"kind":"Field","name":{"kind":"Name","value":"provider"}},{"kind":"Field","name":{"kind":"Name","value":"authFailedAt"}},{"kind":"Field","name":{"kind":"Name","value":"scopes"}},{"kind":"Field","name":{"kind":"Name","value":"handleAliases"}},{"kind":"Field","name":{"kind":"Name","value":"lastSignedInAt"}},{"kind":"Field","name":{"kind":"Name","value":"userWorkspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode<MyConnectedAccountsQuery, MyConnectedAccountsQueryVariables>;
export const MyMessageChannelsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"MyMessageChannels"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"connectedAccountId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"myMessageChannels"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"connectedAccountId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"connectedAccountId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"handle"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"isContactAutoCreationEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"contactAutoCreationPolicy"}},{"kind":"Field","name":{"kind":"Name","value":"messageFolderImportPolicy"}},{"kind":"Field","name":{"kind":"Name","value":"excludeNonProfessionalEmails"}},{"kind":"Field","name":{"kind":"Name","value":"excludeGroupEmails"}},{"kind":"Field","name":{"kind":"Name","value":"isSyncEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"syncStatus"}},{"kind":"Field","name":{"kind":"Name","value":"syncStage"}},{"kind":"Field","name":{"kind":"Name","value":"syncStageStartedAt"}},{"kind":"Field","name":{"kind":"Name","value":"connectedAccountId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode<MyMessageChannelsQuery, MyMessageChannelsQueryVariables>;
export const MyMessageFoldersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"MyMessageFolders"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"messageChannelId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"myMessageFolders"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"messageChannelId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"messageChannelId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"isSynced"}},{"kind":"Field","name":{"kind":"Name","value":"isSentFolder"}},{"kind":"Field","name":{"kind":"Name","value":"parentFolderId"}},{"kind":"Field","name":{"kind":"Name","value":"externalId"}},{"kind":"Field","name":{"kind":"Name","value":"messageChannelId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode<MyMessageFoldersQuery, MyMessageFoldersQueryVariables>;
export const SetAdminAiModelEnabledDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SetAdminAiModelEnabled"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"modelId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"enabled"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"setAdminAiModelEnabled"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"modelId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"modelId"}}},{"kind":"Argument","name":{"kind":"Name","value":"enabled"},"value":{"kind":"Variable","name":{"kind":"Name","value":"enabled"}}}]}]}}]} as unknown as DocumentNode<SetAdminAiModelEnabledMutation, SetAdminAiModelEnabledMutationVariables>;
export const GetAdminAiModelsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetAdminAiModels"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getAdminAiModels"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"autoEnableNewModels"}},{"kind":"Field","name":{"kind":"Name","value":"models"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"modelId"}},{"kind":"Field","name":{"kind":"Name","value":"label"}},{"kind":"Field","name":{"kind":"Name","value":"modelFamily"}},{"kind":"Field","name":{"kind":"Name","value":"inferenceProvider"}},{"kind":"Field","name":{"kind":"Name","value":"isAvailable"}},{"kind":"Field","name":{"kind":"Name","value":"isAdminEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"deprecated"}},{"kind":"Field","name":{"kind":"Name","value":"isRecommended"}}]}}]}}]}}]} as unknown as DocumentNode<GetAdminAiModelsQuery, GetAdminAiModelsQueryVariables>;
export const FindAllApplicationRegistrationsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"FindAllApplicationRegistrations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"findAllApplicationRegistrations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ApplicationRegistrationFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ApplicationRegistrationFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ApplicationRegistration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"universalIdentifier"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"logoUrl"}},{"kind":"Field","name":{"kind":"Name","value":"author"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthClientId"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthRedirectUris"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthScopes"}},{"kind":"Field","name":{"kind":"Name","value":"sourceType"}},{"kind":"Field","name":{"kind":"Name","value":"sourcePackage"}},{"kind":"Field","name":{"kind":"Name","value":"latestAvailableVersion"}},{"kind":"Field","name":{"kind":"Name","value":"websiteUrl"}},{"kind":"Field","name":{"kind":"Name","value":"termsUrl"}},{"kind":"Field","name":{"kind":"Name","value":"isListed"}},{"kind":"Field","name":{"kind":"Name","value":"isFeatured"}},{"kind":"Field","name":{"kind":"Name","value":"ownerWorkspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode<FindAllApplicationRegistrationsQuery, FindAllApplicationRegistrationsQueryVariables>;
@@ -72,11 +72,13 @@ export const AgentChatFilePreview = ({
);
const rightComponent = onRemove ? (
<AvatarOrIcon
Icon={IconX}
IconColor={theme.font.color.secondary}
onClick={onRemove}
/>
<div onClick={(e) => e.stopPropagation()}>
<AvatarOrIcon
Icon={IconX}
IconColor={theme.font.color.secondary}
onClick={onRemove}
/>
</div>
) : undefined;
const hasRightDivider = isDefined(onRemove);
@@ -63,7 +63,6 @@ import { i18n } from '@lingui/core';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { SOURCE_LOCALE } from 'twenty-shared/translations';
import { isDefined } from 'twenty-shared/utils';
import { cookieStorage } from '~/utils/cookie-storage';
import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl';
import { useStore } from 'jotai';
@@ -179,7 +178,6 @@ export const useAuth = () => {
const handleSetAuthTokens = useCallback(
(tokens: AuthTokenPair) => {
setTokenPair(tokens);
cookieStorage.setItem('tokenPair', JSON.stringify(tokens));
},
[setTokenPair],
);
@@ -1,8 +1,3 @@
import { useMemo } from 'react';
import { isNavigationMenuItemFolder } from '@/navigation-menu-item/common/utils/isNavigationMenuItemFolder';
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { viewsSelector } from '@/views/states/selectors/viewsSelector';
@@ -10,68 +5,28 @@ import { isDefined } from 'twenty-shared/utils';
import { useNavigationMenuItemsData } from './useNavigationMenuItemsData';
export const useWorkspaceNavigationMenuItems = (): {
workspaceNavigationMenuItemsObjectMetadataItems: ObjectMetadataItem[];
objectMetadataIdsInWorkspaceNav: Set<string>;
} => {
const { workspaceNavigationMenuItems: rawWorkspaceNavigationMenuItems } =
useNavigationMenuItemsData();
const views = useAtomStateValue(viewsSelector);
const workspaceFolderIds = useMemo(
() =>
new Set(
rawWorkspaceNavigationMenuItems
.filter(isNavigationMenuItemFolder)
.map((item) => item.id),
),
[rawWorkspaceNavigationMenuItems],
);
const workspaceNavigationMenuItemsIncludingFolderItems = useMemo(
() =>
rawWorkspaceNavigationMenuItems.filter(
(item) =>
!isDefined(item.folderId) ||
(isDefined(item.folderId) && workspaceFolderIds.has(item.folderId)),
),
[rawWorkspaceNavigationMenuItems, workspaceFolderIds],
);
const workspaceNavigationMenuItemViewIds = new Set(
workspaceNavigationMenuItemsIncludingFolderItems
const workspaceNavViewIds = new Set(
rawWorkspaceNavigationMenuItems
.map((item) => item.viewId)
.filter((viewId) => isDefined(viewId)),
);
const navigationMenuItemViewObjectMetadataIds = new Set(
views.reduce<string[]>((acc, view) => {
if (workspaceNavigationMenuItemViewIds.has(view.id)) {
acc.push(view.objectMetadataId);
}
return acc;
}, []),
);
const navigationMenuItemRecordObjectMetadataIds = new Set(
workspaceNavigationMenuItemsIncludingFolderItems
const objectMetadataIdsInWorkspaceNav = new Set([
...views
.filter((view) => workspaceNavViewIds.has(view.id))
.map((view) => view.objectMetadataId),
...rawWorkspaceNavigationMenuItems
.map((item) => item.targetObjectMetadataId)
.filter((objectMetadataId) => isDefined(objectMetadataId)),
);
const allNavigationMenuItemObjectMetadataIds = new Set([
...navigationMenuItemViewObjectMetadataIds,
...navigationMenuItemRecordObjectMetadataIds,
]);
const { activeNonSystemObjectMetadataItems } =
useFilteredObjectMetadataItems();
const activeNonSystemObjectMetadataItemsInWorkspaceNavigationMenuItems: ObjectMetadataItem[] =
activeNonSystemObjectMetadataItems.filter((item: ObjectMetadataItem) =>
allNavigationMenuItemObjectMetadataIds.has(item.id),
);
return {
workspaceNavigationMenuItemsObjectMetadataItems:
activeNonSystemObjectMetadataItemsInWorkspaceNavigationMenuItems,
objectMetadataIdsInWorkspaceNav,
};
};
@@ -1,14 +0,0 @@
import { normalizeUrl } from '@/navigation-menu-item/display/link/utils/normalizeUrl';
describe('normalizeUrl', () => {
it('should leave url unchanged when it has protocol, otherwise prepend https', () => {
expect(normalizeUrl('https://example.com')).toBe('https://example.com');
expect(normalizeUrl('example.com')).toBe('https://example.com');
expect(normalizeUrl(' example.com ')).toBe('https://example.com');
});
it('should return empty string for empty or whitespace input', () => {
expect(normalizeUrl('')).toBe('');
expect(normalizeUrl(' ')).toBe('');
});
});
@@ -1,11 +0,0 @@
export const normalizeUrl = (url: string) => {
const trimmedUrl = url.trim();
if (trimmedUrl === '') {
return trimmedUrl;
}
return trimmedUrl.startsWith('http://') || trimmedUrl.startsWith('https://')
? trimmedUrl
: `https://${trimmedUrl}`;
};
@@ -3,15 +3,8 @@ import { useParams } from 'react-router-dom';
import { useWorkspaceNavigationMenuItems } from '@/navigation-menu-item/display/hooks/useWorkspaceNavigationMenuItems';
import { NavigationDrawerSectionForObjectMetadataItems } from '@/object-metadata/components/NavigationDrawerSectionForObjectMetadataItems';
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { useLingui } from '@lingui/react/macro';
const WORKFLOW_OBJECTS_IN_SIDEBAR = [
CoreObjectNameSingular.Workflow,
CoreObjectNameSingular.WorkflowRun,
CoreObjectNameSingular.WorkflowVersion,
];
export const NavigationDrawerOpenedSection = () => {
const { t } = useLingui();
@@ -19,8 +12,7 @@ export const NavigationDrawerOpenedSection = () => {
const filteredActiveNonSystemObjectMetadataItems =
activeObjectMetadataItems.filter((item) => !item.isRemote);
const { workspaceNavigationMenuItemsObjectMetadataItems } =
useWorkspaceNavigationMenuItems();
const { objectMetadataIdsInWorkspaceNav } = useWorkspaceNavigationMenuItems();
const {
objectNamePlural: currentObjectNamePlural,
@@ -41,18 +33,12 @@ export const NavigationDrawerOpenedSection = () => {
return;
}
const isWorkflowObjectInSidebar = WORKFLOW_OBJECTS_IN_SIDEBAR.includes(
objectMetadataItem.nameSingular as CoreObjectNameSingular,
const isObjectAlreadyInNavbar = objectMetadataIdsInWorkspaceNav.has(
objectMetadataItem.id,
);
const shouldDisplayObjectInOpenedSection =
!isWorkflowObjectInSidebar &&
!workspaceNavigationMenuItemsObjectMetadataItems
.map((item) => item.id)
.includes(objectMetadataItem.id);
return (
shouldDisplayObjectInOpenedSection && (
!isObjectAlreadyInNavbar && (
<NavigationDrawerSectionForObjectMetadataItems
sectionTitle={t`Opened`}
objectMetadataItems={[objectMetadataItem]}
@@ -0,0 +1,35 @@
import { useCallback } from 'react';
import { useMetadataStore } from '@/metadata-store/hooks/useMetadataStore';
import { metadataStoreState } from '@/metadata-store/states/metadataStoreState';
import { type NavigationMenuItem } from '~/generated-metadata/graphql';
import { isDefined } from 'twenty-shared/utils';
import { useStore } from 'jotai';
export const useOptimisticRemoveNavigationMenuItemsByViewId = () => {
const store = useStore();
const { replaceDraft, applyChanges } = useMetadataStore();
const removeNavigationMenuItemsByViewIds = useCallback(
(viewIds: string[]) => {
const viewIdsSet = new Set(viewIds);
const entry = store.get(
metadataStoreState.atomFamily('navigationMenuItems'),
);
const currentNavigationMenuItems =
entry.current as unknown as NavigationMenuItem[];
const updatedNavigationMenuItems = currentNavigationMenuItems.filter(
(item) => !isDefined(item.viewId) || !viewIdsSet.has(item.viewId),
);
replaceDraft('navigationMenuItems', updatedNavigationMenuItems);
applyChanges();
},
[store, replaceDraft, applyChanges],
);
return {
removeNavigationMenuItemsByViewIds,
};
};
@@ -1,12 +1,11 @@
import { NavigationMenuItemType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { isDefined, normalizeUrl } from 'twenty-shared/utils';
import { v4 } from 'uuid';
import type { NavigationMenuItem } from '~/generated-metadata/graphql';
import { DEFAULT_NAVIGATION_MENU_ITEM_COLOR_LINK } from '@/navigation-menu-item/common/constants/NavigationMenuItemDefaultColorLink';
import { navigationMenuItemsDraftState } from '@/navigation-menu-item/common/states/navigationMenuItemsDraftState';
import { computeInsertIndexAndPosition } from '@/navigation-menu-item/common/utils/computeInsertIndexAndPosition';
import { normalizeUrl } from '@/navigation-menu-item/display/link/utils/normalizeUrl';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
export const useAddLinkToNavigationMenuDraft = () => {
@@ -1,7 +1,7 @@
import { useLingui } from '@lingui/react/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { useState } from 'react';
import { getAbsoluteUrl } from 'twenty-shared/utils';
import { ensureAbsoluteUrl } from 'twenty-shared/utils';
import { type NavigationMenuItem } from '~/generated-metadata/graphql';
import { extractDomainFromUrl } from '@/navigation-menu-item/display/link/utils/extractDomainFromUrl';
@@ -45,7 +45,7 @@ export const SidePanelEditLinkItemView = ({
const currentName = selectedItem.name ?? defaultLabel;
const currentDomain = selectedItem.link
? extractDomainFromUrl(getAbsoluteUrl(selectedItem.link))
? extractDomainFromUrl(ensureAbsoluteUrl(selectedItem.link))
: undefined;
const canAutoUpdateName =
currentName === defaultLabel ||
@@ -57,7 +57,7 @@ export const SidePanelEditLinkItemView = ({
if (!canAutoUpdateName) return;
const trimmed = value.trim();
if (!isNonEmptyString(trimmed)) return;
const domain = extractDomainFromUrl(getAbsoluteUrl(trimmed));
const domain = extractDomainFromUrl(ensureAbsoluteUrl(trimmed));
if (domain !== undefined) {
setLastAutoSetName(domain);
onUpdateLink(selectedItem.id, { name: domain });
@@ -67,7 +67,7 @@ export const SidePanelEditLinkItemView = ({
const handleUrlBlur = (event: React.FocusEvent<HTMLInputElement>) => {
const value = event.target.value.trim();
if (isNonEmptyString(value)) {
onUpdateLink(selectedItem.id, { link: getAbsoluteUrl(value) });
onUpdateLink(selectedItem.id, { link: ensureAbsoluteUrl(value) });
setUrlEditInput('');
}
};
@@ -11,7 +11,7 @@ import { useRecordFieldValue } from '@/object-record/record-store/hooks/useRecor
import { t } from '@lingui/core/macro';
import { useContext } from 'react';
import { FieldMetadataSettingsOnClickAction } from 'twenty-shared/types';
import { getAbsoluteUrl, isDefined } from 'twenty-shared/utils';
import { ensureAbsoluteUrl, isDefined } from 'twenty-shared/utils';
import { IconArrowUpRight, IconCopy } from 'twenty-ui/display';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
@@ -69,7 +69,7 @@ export const useGetSecondaryRecordTableCellButton = () => {
if (isFieldLinks(fieldDefinition)) {
const url = (fieldValue as FieldLinksValue).primaryLinkUrl ?? '';
openLinkOnClick = () => {
window.open(getAbsoluteUrl(url), '_blank');
window.open(ensureAbsoluteUrl(url), '_blank');
};
copyOnClick = () => {
copyToClipboard(url, t`Link copied to clipboard`);
@@ -7,6 +7,7 @@ import { getRecordFieldInputInstanceId } from '@/object-record/utils/getRecordFi
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { useContext } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { OverflowingTextWithTooltip } from 'twenty-ui/display';
import { themeCssVariables } from 'twenty-ui/theme-constants';
@@ -41,8 +42,8 @@ export const RecordTitleCellSingleTextDisplayMode = ({
const recordStore = useAtomFamilyStateValue(recordStoreFamilyState, recordId);
const isEmpty =
recordStore?.[fieldDefinition.metadata.fieldName]?.trim() === '';
const fieldValue = recordStore?.[fieldDefinition.metadata.fieldName];
const isEmpty = !isDefined(fieldValue) || fieldValue.trim() === '';
const { openRecordTitleCell } = useRecordTitleCell();
@@ -519,6 +519,94 @@ describe('buildRecordFromImportedStructuredRow', () => {
});
});
it('should lowercase relation email composite subfield', () => {
const importedStructuredRow: ImportedStructuredRow = {
'emailField (relationField)': 'John.Doe@Example.COM',
};
const spreadsheetImportFields = [
{
fieldMetadataItemId: '6',
isNestedField: false,
isRelationConnectField: true,
label: 'Relation Field / Email Field',
key: 'emailField (relationField)',
fieldMetadataType: FieldMetadataType.RELATION,
uniqueFieldMetadataItem: {
name: 'emailField',
type: FieldMetadataType.EMAILS,
},
compositeSubFieldKey: 'primaryEmail',
},
] as SpreadsheetImportField[];
const result = buildRecordFromImportedStructuredRow({
importedStructuredRow,
fieldMetadataItems: fields,
spreadsheetImportFields,
});
expect(result).toEqual({
relationField: {
connect: {
where: {
emailField: {
primaryEmail: 'john.doe@example.com',
},
},
},
},
createdBy: {
source: 'IMPORT',
context: {},
},
});
});
it('should normalize relation links composite subfield', () => {
const importedStructuredRow: ImportedStructuredRow = {
'domainNameField (relationField)': 'HTTPS://Example.COM/path/',
};
const spreadsheetImportFields = [
{
fieldMetadataItemId: '6',
isNestedField: false,
isRelationConnectField: true,
label: 'Relation Field / Domain Name Field',
key: 'domainNameField (relationField)',
fieldMetadataType: FieldMetadataType.RELATION,
uniqueFieldMetadataItem: {
name: 'linksField',
type: FieldMetadataType.LINKS,
},
compositeSubFieldKey: 'primaryLinkUrl',
},
] as SpreadsheetImportField[];
const result = buildRecordFromImportedStructuredRow({
importedStructuredRow,
fieldMetadataItems: fields,
spreadsheetImportFields,
});
expect(result).toEqual({
relationField: {
connect: {
where: {
linksField: {
primaryLinkUrl: 'https://example.com/path',
},
},
},
},
createdBy: {
source: 'IMPORT',
context: {},
},
});
});
it('should return empty record for empty imported row', () => {
const importedStructuredRow: ImportedStructuredRow = {};
@@ -12,7 +12,7 @@ import {
assertUnreachable,
isDefined,
isEmptyObject,
lowercaseUrlOriginAndRemoveTrailingSlash,
normalizeUrlOrigin,
} from 'twenty-shared/utils';
import { z } from 'zod';
import { FieldMetadataType, RelationType } from '~/generated-metadata/graphql';
@@ -185,7 +185,7 @@ export const buildRecordFromImportedStructuredRow = ({
},
[FieldMetadataType.LINKS]: {
primaryLinkLabel: castToString,
primaryLinkUrl: lowercaseUrlOriginAndRemoveTrailingSlash,
primaryLinkUrl: normalizeUrlOrigin,
secondaryLinks: linkArrayJSONSchema.parse,
},
@@ -202,7 +202,7 @@ export const buildRecordFromImportedStructuredRow = ({
},
[FieldMetadataType.EMAILS]: {
primaryEmail: castToString,
primaryEmail: (value: unknown) => castToString(value).toLowerCase(),
additionalEmails: stringArrayJSONSchema.parse,
},
[FieldMetadataType.FULL_NAME]: {
@@ -13,7 +13,7 @@ import { FieldMetadataType } from 'twenty-shared/types';
import {
getUniqueConstraintsFields,
isDefined,
lowercaseUrlOriginAndRemoveTrailingSlash,
normalizeUrlOrigin,
} from 'twenty-shared/utils';
type Column = {
@@ -104,9 +104,7 @@ const getUniqueValues = (
.primaryLinkUrl,
)
) {
return lowercaseUrlOriginAndRemoveTrailingSlash(
row?.[columnName]?.toString().trim() || '',
);
return normalizeUrlOrigin(row?.[columnName]?.toString().trim() || '');
}
return row?.[columnName]?.toString().trim().toLowerCase();
@@ -172,6 +172,7 @@ export const PageLayoutTabsRenderer = () => {
<PageLayoutTabList
tabs={sortedTabs}
behaveAsLinks={!isInSidePanel && !isPageLayoutInEditMode}
isInSidePanel={isInSidePanel}
componentInstanceId={tabListInstanceId}
onAddTab={handleAddTab}
isReorderEnabled={canEnableTabEditing}
@@ -1,6 +1,9 @@
import { type CalendarChannel } from '@/accounts/types/CalendarChannel';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { CoreObjectNameSingular, FeatureFlagKey } from 'twenty-shared/types';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { UPDATE_CALENDAR_CHANNEL } from '@/settings/accounts/graphql/mutations/updateCalendarChannel';
import { useFeatureFlagsMap } from '@/workspace/hooks/useFeatureFlagsMap';
import { useMutation } from '@apollo/client/react';
import { SettingsAccountsEventVisibilitySettingsCard } from '@/settings/accounts/components/SettingsAccountsCalendarVisibilitySettingsCard';
import { SettingsOptionCardContentToggle } from '@/settings/components/SettingsOptions/SettingsOptionCardContentToggle';
import { styled } from '@linaria/react';
@@ -27,26 +30,33 @@ type SettingsAccountsCalendarChannelDetailsProps = {
export const SettingsAccountsCalendarChannelDetails = ({
calendarChannel,
}: SettingsAccountsCalendarChannelDetailsProps) => {
const featureFlagsMap = useFeatureFlagsMap();
const isMigrated =
featureFlagsMap[FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED] ?? false;
const { updateOneRecord } = useUpdateOneRecord();
const [updateMetadataChannel] = useMutation(UPDATE_CALENDAR_CHANNEL);
const updateChannel = (update: Record<string, unknown>) => {
if (isMigrated) {
updateMetadataChannel({
variables: { input: { id: calendarChannel.id, update } },
});
} else {
updateOneRecord({
objectNameSingular: CoreObjectNameSingular.CalendarChannel,
idToUpdate: calendarChannel.id,
updateOneRecordInput: update,
});
}
};
const handleVisibilityChange = (value: CalendarChannelVisibility) => {
updateOneRecord({
objectNameSingular: CoreObjectNameSingular.CalendarChannel,
idToUpdate: calendarChannel.id,
updateOneRecordInput: {
visibility: value,
},
});
updateChannel({ visibility: value });
};
const handleContactAutoCreationToggle = (value: boolean) => {
updateOneRecord({
objectNameSingular: CoreObjectNameSingular.CalendarChannel,
idToUpdate: calendarChannel.id,
updateOneRecordInput: {
isContactAutoCreationEnabled: value,
},
});
updateChannel({ isContactAutoCreationEnabled: value });
};
return (
@@ -1,17 +1,9 @@
import { styled } from '@linaria/react';
import {
type CalendarChannel,
CalendarChannelSyncStage,
} from '@/accounts/types/CalendarChannel';
import { type ConnectedAccount } from '@/accounts/types/ConnectedAccount';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { SettingsAccountsCalendarChannelDetails } from '@/settings/accounts/components/SettingsAccountsCalendarChannelDetails';
import { SettingsNewAccountSection } from '@/settings/accounts/components/SettingsNewAccountSection';
import { SETTINGS_ACCOUNT_CALENDAR_CHANNELS_TAB_LIST_COMPONENT_ID } from '@/settings/accounts/constants/SettingsAccountCalendarChannelsTabListComponentId';
import { useMyCalendarChannels } from '@/settings/accounts/hooks/useMyCalendarChannels';
import { TabList } from '@/ui/layout/tab-list/components/TabList';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
@@ -27,33 +19,8 @@ export const SettingsAccountsCalendarChannelsContainer = () => {
activeTabIdComponentState,
SETTINGS_ACCOUNT_CALENDAR_CHANNELS_TAB_LIST_COMPONENT_ID,
);
const currentWorkspaceMember = useAtomStateValue(currentWorkspaceMemberState);
const { records: accounts } = useFindManyRecords<ConnectedAccount>({
objectNameSingular: CoreObjectNameSingular.ConnectedAccount,
filter: {
accountOwnerId: {
eq: currentWorkspaceMember?.id,
},
},
});
const { records: calendarChannels } = useFindManyRecords<
CalendarChannel & {
connectedAccount: ConnectedAccount;
}
>({
objectNameSingular: CoreObjectNameSingular.CalendarChannel,
filter: {
connectedAccountId: {
in: accounts.map((account) => account.id),
},
syncStage: {
neq: CalendarChannelSyncStage.PENDING_CONFIGURATION,
},
},
skip: !accounts.length,
});
const { channels: calendarChannels } = useMyCalendarChannels();
const tabs = [
...calendarChannels.map((calendarChannel) => ({
@@ -5,8 +5,11 @@ import {
type MessageChannelContactAutoCreationPolicy,
type MessageFolderImportPolicy,
} from '@/accounts/types/MessageChannel';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { CoreObjectNameSingular, FeatureFlagKey } from 'twenty-shared/types';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { UPDATE_MESSAGE_CHANNEL } from '@/settings/accounts/graphql/mutations/updateMessageChannel';
import { useFeatureFlagsMap } from '@/workspace/hooks/useFeatureFlagsMap';
import { useMutation } from '@apollo/client/react';
import { SettingsAccountsMessageAutoCreationCard } from '@/settings/accounts/components/SettingsAccountsMessageAutoCreationCard';
import { SettingsAccountsMessageFolderCard } from '@/settings/accounts/components/SettingsAccountsMessageFolderCard';
import { SettingsAccountsMessageVisibilityCard } from '@/settings/accounts/components/SettingsAccountsMessageVisibilityCard';
@@ -40,58 +43,49 @@ const StyledDetailsContainer = styled.div`
export const SettingsAccountsMessageChannelDetails = ({
messageChannel,
}: SettingsAccountsMessageChannelDetailsProps) => {
const featureFlagsMap = useFeatureFlagsMap();
const isMigrated =
featureFlagsMap[FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED] ?? false;
const { updateOneRecord } = useUpdateOneRecord();
const [updateMetadataChannel] = useMutation(UPDATE_MESSAGE_CHANNEL);
const updateChannel = (update: Record<string, unknown>) => {
if (isMigrated) {
updateMetadataChannel({
variables: { input: { id: messageChannel.id, update } },
});
} else {
updateOneRecord({
objectNameSingular: CoreObjectNameSingular.MessageChannel,
idToUpdate: messageChannel.id,
updateOneRecordInput: update,
});
}
};
const handleVisibilityChange = (value: MessageChannelVisibility) => {
updateOneRecord({
objectNameSingular: CoreObjectNameSingular.MessageChannel,
idToUpdate: messageChannel.id,
updateOneRecordInput: {
visibility: value,
},
});
updateChannel({ visibility: value });
};
const handleContactAutoCreationChange = (
value: MessageChannelContactAutoCreationPolicy,
) => {
updateOneRecord({
objectNameSingular: CoreObjectNameSingular.MessageChannel,
idToUpdate: messageChannel.id,
updateOneRecordInput: {
contactAutoCreationPolicy: value,
},
});
updateChannel({ contactAutoCreationPolicy: value });
};
const handleIsGroupEmailExcludedToggle = (value: boolean) => {
updateOneRecord({
objectNameSingular: CoreObjectNameSingular.MessageChannel,
idToUpdate: messageChannel.id,
updateOneRecordInput: {
excludeGroupEmails: value,
},
});
updateChannel({ excludeGroupEmails: value });
};
const handleIsNonProfessionalEmailExcludedToggle = (value: boolean) => {
updateOneRecord({
objectNameSingular: CoreObjectNameSingular.MessageChannel,
idToUpdate: messageChannel.id,
updateOneRecordInput: {
excludeNonProfessionalEmails: value,
},
});
updateChannel({ excludeNonProfessionalEmails: value });
};
const handleMessageFolderImportPolicyChange = (
value: MessageFolderImportPolicy,
) => {
updateOneRecord({
objectNameSingular: CoreObjectNameSingular.MessageChannel,
idToUpdate: messageChannel.id,
updateOneRecordInput: { messageFolderImportPolicy: value },
});
updateChannel({ messageFolderImportPolicy: value });
};
return (
@@ -1,23 +1,15 @@
import { styled } from '@linaria/react';
import { type ConnectedAccount } from '@/accounts/types/ConnectedAccount';
import {
type MessageChannel,
MessageChannelSyncStage,
} from '@/accounts/types/MessageChannel';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { useGenerateDepthRecordGqlFieldsFromObject } from '@/object-record/graphql/record-gql-fields/hooks/useGenerateDepthRecordGqlFieldsFromObject';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { SettingsAccountsMessageChannelDetails } from '@/settings/accounts/components/SettingsAccountsMessageChannelDetails';
import { SettingsAccountsSelectedMessageChannelEffect } from '@/settings/accounts/components/SettingsAccountsSelectedMessageChannelEffect';
import { SettingsNewAccountSection } from '@/settings/accounts/components/SettingsNewAccountSection';
import { SETTINGS_ACCOUNT_MESSAGE_CHANNELS_TAB_LIST_COMPONENT_ID } from '@/settings/accounts/constants/SettingsAccountMessageChannelsTabListComponentId';
import { useMyMessageChannels } from '@/settings/accounts/hooks/useMyMessageChannels';
import { settingsAccountsSelectedMessageChannelState } from '@/settings/accounts/states/settingsAccountsSelectedMessageChannelState';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { TabList } from '@/ui/layout/tab-list/components/TabList';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import React, { useCallback } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { themeCssVariables } from 'twenty-ui/theme-constants';
@@ -31,48 +23,11 @@ export const SettingsAccountsMessageChannelsContainer = () => {
activeTabIdComponentState,
SETTINGS_ACCOUNT_MESSAGE_CHANNELS_TAB_LIST_COMPONENT_ID,
);
const currentWorkspaceMember = useAtomStateValue(currentWorkspaceMemberState);
const setSettingsAccountsSelectedMessageChannel = useSetAtomState(
settingsAccountsSelectedMessageChannelState,
);
const { records: accounts } = useFindManyRecords<ConnectedAccount>({
objectNameSingular: CoreObjectNameSingular.ConnectedAccount,
filter: {
accountOwnerId: {
eq: currentWorkspaceMember?.id,
},
},
});
const { recordGqlFields } = useGenerateDepthRecordGqlFieldsFromObject({
objectNameSingular: CoreObjectNameSingular.MessageChannel,
depth: 1,
});
const { records: messageChannels } = useFindManyRecords<
MessageChannel & {
connectedAccount: ConnectedAccount;
}
>({
objectNameSingular: CoreObjectNameSingular.MessageChannel,
filter: {
connectedAccountId: {
in: accounts.map((account) => account.id),
},
isSyncEnabled: {
eq: true,
},
syncStage: {
neq: MessageChannelSyncStage.PENDING_CONFIGURATION,
},
},
recordGqlFields,
onCompleted: (data) => {
setSettingsAccountsSelectedMessageChannel(data[0]);
},
skip: !accounts.length,
});
const { channels: messageChannels } = useMyMessageChannels();
const tabs = messageChannels.map((messageChannel) => ({
id: messageChannel.id,
@@ -97,6 +52,9 @@ export const SettingsAccountsMessageChannelsContainer = () => {
return (
<>
<SettingsAccountsSelectedMessageChannelEffect
messageChannels={messageChannels}
/>
{tabs.length > 1 && (
<StyledMessageContainer>
<TabList
@@ -1,12 +1,16 @@
import { useApolloClient, useMutation } from '@apollo/client/react';
import { type ConnectedAccount } from '@/accounts/types/ConnectedAccount';
import { CalendarChannelSyncStage } from '@/accounts/types/CalendarChannel';
import { MessageChannelSyncStage } from '@/accounts/types/MessageChannel';
import {
CoreObjectNameSingular,
ConnectedAccountProvider,
FeatureFlagKey,
SettingsPath,
} from 'twenty-shared/types';
import { useDestroyOneRecord } from '@/object-record/hooks/useDestroyOneRecord';
import { useTriggerProviderReconnect } from '@/settings/accounts/hooks/useTriggerProviderReconnect';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
@@ -27,6 +31,8 @@ import {
import { LightIconButton } from 'twenty-ui/input';
import { MenuItem } from 'twenty-ui/navigation';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { useFeatureFlagsMap } from '@/workspace/hooks/useFeatureFlagsMap';
import { DELETE_CONNECTED_ACCOUNT } from '../graphql/mutations/deleteConnectedAccount';
type SettingsAccountsRowDropdownMenuProps = {
account: ConnectedAccount;
@@ -45,9 +51,17 @@ export const SettingsAccountsRowDropdownMenu = ({
const navigate = useNavigateSettings();
const { closeDropdown } = useCloseDropdown();
const featureFlagsMap = useFeatureFlagsMap();
const isMigrated =
featureFlagsMap[FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED] ?? false;
const apolloClient = useApolloClient();
const { destroyOneRecord } = useDestroyOneRecord({
objectNameSingular: CoreObjectNameSingular.ConnectedAccount,
});
const [deleteConnectedAccountMutation] = useMutation(
DELETE_CONNECTED_ACCOUNT,
);
const { triggerProviderReconnect } = useTriggerProviderReconnect();
const hasPendingConfiguration =
@@ -61,7 +75,14 @@ export const SettingsAccountsRowDropdownMenu = ({
);
const deleteAccount = async () => {
await destroyOneRecord(account.id);
if (isMigrated) {
await deleteConnectedAccountMutation({
variables: { id: account.id },
});
await apolloClient.refetchQueries({ include: 'active' });
} else {
await destroyOneRecord(account.id);
}
};
return (
@@ -0,0 +1,40 @@
import { type MessageChannel } from '@/accounts/types/MessageChannel';
import { SETTINGS_ACCOUNT_MESSAGE_CHANNELS_TAB_LIST_COMPONENT_ID } from '@/settings/accounts/constants/SettingsAccountMessageChannelsTabListComponentId';
import { settingsAccountsSelectedMessageChannelState } from '@/settings/accounts/states/settingsAccountsSelectedMessageChannelState';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { useEffect } from 'react';
type SettingsAccountsSelectedMessageChannelEffectProps = {
messageChannels: MessageChannel[];
};
export const SettingsAccountsSelectedMessageChannelEffect = ({
messageChannels,
}: SettingsAccountsSelectedMessageChannelEffectProps) => {
const activeTabId = useAtomComponentStateValue(
activeTabIdComponentState,
SETTINGS_ACCOUNT_MESSAGE_CHANNELS_TAB_LIST_COMPONENT_ID,
);
const setSettingsAccountsSelectedMessageChannel = useSetAtomState(
settingsAccountsSelectedMessageChannelState,
);
useEffect(() => {
if (messageChannels.length === 0) {
return;
}
const currentSelectionStillExists = activeTabId
? messageChannels.some((channel) => channel.id === activeTabId)
: false;
if (!currentSelectionStillExists) {
setSettingsAccountsSelectedMessageChannel(messageChannels[0]);
}
}, [messageChannels, activeTabId, setSettingsAccountsSelectedMessageChannel]);
return null;
};
@@ -1,13 +1,10 @@
import { type MessageChannel } from '@/accounts/types/MessageChannel';
import { type MessageFolder } from '@/accounts/types/MessageFolder';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { useGenerateDepthRecordGqlFieldsFromObject } from '@/object-record/graphql/record-gql-fields/hooks/useGenerateDepthRecordGqlFieldsFromObject';
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { SettingsMessageFoldersEmptyStateCard } from '@/settings/accounts/components/message-folders/SettingsMessageFoldersEmptyStateCard';
import { SettingsMessageFoldersSkeletonLoader } from '@/settings/accounts/components/message-folders/SettingsMessageFoldersSkeletonLoader';
import { SettingsMessageFoldersTreeItem } from '@/settings/accounts/components/message-folders/SettingsMessageFoldersTreeItem';
import { computeFolderIdsForSyncToggle } from '@/settings/accounts/components/message-folders/utils/computeFolderIdsForSyncToggle';
import { computeMessageFolderTree } from '@/settings/accounts/components/message-folders/utils/computeMessageFolderTree';
import { useMyMessageFolders } from '@/settings/accounts/hooks/useMyMessageFolders';
import { useUpdateMessageFoldersSyncStatus } from '@/settings/accounts/hooks/useUpdateMessageFoldersSyncStatus';
import { settingsAccountsSelectedMessageChannelState } from '@/settings/accounts/states/settingsAccountsSelectedMessageChannelState';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
@@ -75,19 +72,9 @@ export const SettingsAccountsMessageFoldersCard = () => {
const { updateMessageFoldersSyncStatus } =
useUpdateMessageFoldersSyncStatus();
const { recordGqlFields } = useGenerateDepthRecordGqlFieldsFromObject({
objectNameSingular: CoreObjectNameSingular.MessageChannel,
depth: 1,
shouldOnlyLoadRelationIdentifiers: false,
});
const { record: messageChannel, loading } = useFindOneRecord<MessageChannel>({
objectNameSingular: CoreObjectNameSingular.MessageChannel,
objectRecordId: settingsAccountsSelectedMessageChannel?.id,
recordGqlFields,
});
const { messageFolders = [] } = messageChannel ?? {};
const { messageFolders, loading } = useMyMessageFolders(
settingsAccountsSelectedMessageChannel?.id,
);
const filteredMessageFolders = useMemo(() => {
return messageFolders.filter((folder) =>
@@ -146,4 +146,25 @@ describe('computeMessageFolderTree', () => {
expect(result[0].children[0].folder.name).toBe('Clients');
expect(result[0].children[1].folder.name).toBe('Projects');
});
it('should resolve parent-child when parentFolderId references parent id instead of externalId', () => {
const parent = createFolder(
'20202020-aaaa-bbbb-cccc-000000000001',
'custom folder',
null,
'Label_5900090362003645629',
);
const child = createFolder(
'20202020-aaaa-bbbb-cccc-000000000002',
'child folder',
'20202020-aaaa-bbbb-cccc-000000000001',
'Label_7713410187110265162',
);
const result = computeMessageFolderTree([parent, child]);
expect(result).toHaveLength(1);
expect(result[0].folder.name).toBe('custom folder');
expect(result[0].children).toHaveLength(1);
expect(result[0].children[0].folder.name).toBe('child folder');
});
});
@@ -16,9 +16,11 @@ export const computeFolderIdsForSyncToggle = ({
const collectChildren = (id: string): string[] => {
const folder = folderById.get(id);
const children = folder?.externalId
const children = folder
? allFolders.filter(
(childFolder) => childFolder.parentFolderId === folder.externalId,
(childFolder) =>
childFolder.parentFolderId === folder.externalId ||
childFolder.parentFolderId === folder.id,
)
: [];
@@ -38,7 +40,9 @@ export const computeFolderIdsForSyncToggle = ({
break;
}
const parent = folderByExternalId.get(current.parentFolderId);
const parent =
folderByExternalId.get(current.parentFolderId) ??
folderById.get(current.parentFolderId);
if (!parent) {
break;
@@ -63,7 +67,9 @@ export const computeFolderIdsForSyncToggle = ({
for (const parent of collectParents(folderId)) {
const children = allFolders.filter(
(folder) => folder.parentFolderId === parent.externalId,
(folder) =>
folder.parentFolderId === parent.externalId ||
folder.parentFolderId === parent.id,
);
const hasOtherSyncedChild = children.some(
(child) => child.isSynced && !idsToUnsync.has(child.id),
@@ -11,17 +11,22 @@ export const computeMessageFolderTree = (
folders: MessageFolder[],
): MessageFolderTreeNode[] => {
const folderByExternalIdMap = new Map<string, MessageFolder>();
const folderByIdMap = new Map<string, MessageFolder>();
const childrenMap = new Map<string, MessageFolder[]>();
folders.forEach((folder) => {
if (isDefined(folder.externalId)) {
folderByExternalIdMap.set(folder.externalId, folder);
}
folderByIdMap.set(folder.id, folder);
});
folders.forEach((folder) => {
if (isDefined(folder.parentFolderId)) {
const parent = folderByExternalIdMap.get(folder.parentFolderId);
const parent =
folderByExternalIdMap.get(folder.parentFolderId) ??
folderByIdMap.get(folder.parentFolderId);
if (isDefined(parent)) {
const siblings = childrenMap.get(parent.id) || [];
siblings.push(folder);
@@ -46,7 +51,10 @@ export const computeMessageFolderTree = (
const rootFolders = folders.filter((folder) => {
if (!folder.parentFolderId) return true;
return !folderByExternalIdMap.has(folder.parentFolderId);
return (
!folderByExternalIdMap.has(folder.parentFolderId) &&
!folderByIdMap.has(folder.parentFolderId)
);
});
rootFolders.sort((a, b) => a.name.localeCompare(b.name));
@@ -0,0 +1,9 @@
import { gql } from '@apollo/client';
export const DELETE_CONNECTED_ACCOUNT = gql`
mutation DeleteConnectedAccount($id: UUID!) {
deleteConnectedAccount(id: $id) {
id
}
}
`;
@@ -0,0 +1,12 @@
import { gql } from '@apollo/client';
export const UPDATE_CALENDAR_CHANNEL = gql`
mutation UpdateCalendarChannel($input: UpdateCalendarChannelInput!) {
updateCalendarChannel(input: $input) {
id
visibility
isContactAutoCreationEnabled
contactAutoCreationPolicy
}
}
`;
@@ -0,0 +1,14 @@
import { gql } from '@apollo/client';
export const UPDATE_MESSAGE_CHANNEL = gql`
mutation UpdateMessageChannel($input: UpdateMessageChannelInput!) {
updateMessageChannel(input: $input) {
id
visibility
contactAutoCreationPolicy
excludeNonProfessionalEmails
excludeGroupEmails
messageFolderImportPolicy
}
}
`;
@@ -0,0 +1,10 @@
import { gql } from '@apollo/client';
export const UPDATE_MESSAGE_FOLDER = gql`
mutation UpdateMessageFolder($input: UpdateMessageFolderInput!) {
updateMessageFolder(input: $input) {
id
isSynced
}
}
`;
@@ -0,0 +1,10 @@
import { gql } from '@apollo/client';
export const UPDATE_MESSAGE_FOLDERS = gql`
mutation UpdateMessageFolders($input: UpdateMessageFoldersInput!) {
updateMessageFolders(input: $input) {
id
isSynced
}
}
`;
@@ -0,0 +1,20 @@
import { gql } from '@apollo/client';
export const GET_MY_CALENDAR_CHANNELS = gql`
query MyCalendarChannels($connectedAccountId: UUID) {
myCalendarChannels(connectedAccountId: $connectedAccountId) {
id
handle
visibility
syncStatus
syncStage
syncStageStartedAt
isContactAutoCreationEnabled
contactAutoCreationPolicy
isSyncEnabled
connectedAccountId
createdAt
updatedAt
}
}
`;
@@ -0,0 +1,18 @@
import { gql } from '@apollo/client';
export const GET_MY_CONNECTED_ACCOUNTS = gql`
query MyConnectedAccounts {
myConnectedAccounts {
id
handle
provider
authFailedAt
scopes
handleAliases
lastSignedInAt
userWorkspaceId
createdAt
updatedAt
}
}
`;
@@ -0,0 +1,24 @@
import { gql } from '@apollo/client';
export const GET_MY_MESSAGE_CHANNELS = gql`
query MyMessageChannels($connectedAccountId: UUID) {
myMessageChannels(connectedAccountId: $connectedAccountId) {
id
handle
visibility
type
isContactAutoCreationEnabled
contactAutoCreationPolicy
messageFolderImportPolicy
excludeNonProfessionalEmails
excludeGroupEmails
isSyncEnabled
syncStatus
syncStage
syncStageStartedAt
connectedAccountId
createdAt
updatedAt
}
}
`;
@@ -0,0 +1,17 @@
import { gql } from '@apollo/client';
export const GET_MY_MESSAGE_FOLDERS = gql`
query MyMessageFolders($messageChannelId: UUID) {
myMessageFolders(messageChannelId: $messageChannelId) {
id
name
isSynced
isSentFolder
parentFolderId
externalId
messageChannelId
createdAt
updatedAt
}
}
`;
@@ -0,0 +1,111 @@
import { type ConnectedAccount } from '@/accounts/types/ConnectedAccount';
import {
type CalendarChannel,
CalendarChannelSyncStage,
} from '@/accounts/types/CalendarChannel';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { GET_MY_CALENDAR_CHANNELS } from '@/settings/accounts/graphql/queries/getMyCalendarChannels';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useFeatureFlagsMap } from '@/workspace/hooks/useFeatureFlagsMap';
import { useApolloClient, useQuery } from '@apollo/client/react';
import { useMemo } from 'react';
import { CoreObjectNameSingular, FeatureFlagKey } from 'twenty-shared/types';
type MetadataCalendarChannel = {
id: string;
handle: string;
visibility: string;
syncStatus: string;
syncStage: string;
syncStageStartedAt: string | null;
isContactAutoCreationEnabled: boolean;
contactAutoCreationPolicy: string;
isSyncEnabled: boolean;
connectedAccountId: string;
createdAt: string;
updatedAt: string;
};
export const useMyCalendarChannels = () => {
const featureFlagsMap = useFeatureFlagsMap();
const isMigrated =
featureFlagsMap[FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED] ?? false;
const currentWorkspaceMember = useAtomStateValue(currentWorkspaceMemberState);
const apolloClient = useApolloClient();
const { records: workspaceAccounts } = useFindManyRecords<ConnectedAccount>({
objectNameSingular: CoreObjectNameSingular.ConnectedAccount,
filter: {
accountOwnerId: {
eq: currentWorkspaceMember?.id,
},
},
skip: isMigrated,
});
const { records: workspaceChannels, loading: workspaceLoading } =
useFindManyRecords<
CalendarChannel & { connectedAccount: ConnectedAccount }
>({
objectNameSingular: CoreObjectNameSingular.CalendarChannel,
filter: {
connectedAccountId: {
in: workspaceAccounts.map((account) => account.id),
},
syncStage: {
neq: CalendarChannelSyncStage.PENDING_CONFIGURATION,
},
},
skip: isMigrated || !workspaceAccounts.length,
});
const { data: metadataData, loading: metadataLoading } = useQuery<{
myCalendarChannels: MetadataCalendarChannel[];
}>(GET_MY_CALENDAR_CHANNELS, {
client: apolloClient,
skip: !isMigrated,
});
const channels = useMemo(() => {
if (!isMigrated) {
return workspaceChannels;
}
if (!metadataData?.myCalendarChannels) {
return [];
}
return metadataData.myCalendarChannels
.filter(
(channel: MetadataCalendarChannel) =>
channel.syncStage !== 'PENDING_CONFIGURATION',
)
.map(
(channel: MetadataCalendarChannel) =>
({
id: channel.id,
handle: channel.handle,
visibility: channel.visibility,
isContactAutoCreationEnabled: channel.isContactAutoCreationEnabled,
contactAutoCreationPolicy: channel.contactAutoCreationPolicy,
isSyncEnabled: channel.isSyncEnabled,
syncStatus: channel.syncStatus,
syncStage: channel.syncStage,
syncCursor: '',
syncStageStartedAt: channel.syncStageStartedAt
? new Date(channel.syncStageStartedAt)
: null,
throttleFailureCount: 0,
connectedAccountId: channel.connectedAccountId,
__typename: 'CalendarChannel',
}) as CalendarChannel,
);
}, [isMigrated, workspaceChannels, metadataData]);
return {
channels,
loading: isMigrated ? metadataLoading : workspaceLoading,
};
};
@@ -0,0 +1,116 @@
import { type ConnectedAccount } from '@/accounts/types/ConnectedAccount';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { useGenerateDepthRecordGqlFieldsFromObject } from '@/object-record/graphql/record-gql-fields/hooks/useGenerateDepthRecordGqlFieldsFromObject';
import { GET_MY_CONNECTED_ACCOUNTS } from '@/settings/accounts/graphql/queries/getMyConnectedAccounts';
import { useMyCalendarChannels } from '@/settings/accounts/hooks/useMyCalendarChannels';
import { useMyMessageChannels } from '@/settings/accounts/hooks/useMyMessageChannels';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useFeatureFlagsMap } from '@/workspace/hooks/useFeatureFlagsMap';
import { useApolloClient, useQuery } from '@apollo/client/react';
import { useMemo } from 'react';
import { CoreObjectNameSingular, FeatureFlagKey } from 'twenty-shared/types';
type MetadataConnectedAccount = {
id: string;
handle: string;
provider: string;
authFailedAt: string | null;
scopes: string[] | null;
handleAliases: string[] | null;
lastSignedInAt: string | null;
userWorkspaceId: string;
createdAt: string;
updatedAt: string;
};
export const useMyConnectedAccounts = () => {
const featureFlagsMap = useFeatureFlagsMap();
const isMigrated =
featureFlagsMap[FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED] ?? false;
const currentWorkspaceMember = useAtomStateValue(currentWorkspaceMemberState);
const apolloClient = useApolloClient();
const { recordGqlFields } = useGenerateDepthRecordGqlFieldsFromObject({
objectNameSingular: CoreObjectNameSingular.ConnectedAccount,
depth: 1,
shouldOnlyLoadRelationIdentifiers: false,
});
const { records: workspaceAccounts, loading: workspaceLoading } =
useFindManyRecords<ConnectedAccount>({
objectNameSingular: CoreObjectNameSingular.ConnectedAccount,
filter: {
accountOwnerId: {
eq: currentWorkspaceMember?.id,
},
},
recordGqlFields,
skip: isMigrated,
});
const { data: metadataData, loading: metadataLoading } = useQuery<{
myConnectedAccounts: MetadataConnectedAccount[];
}>(GET_MY_CONNECTED_ACCOUNTS, {
client: apolloClient,
skip: !isMigrated,
});
const { channels: messageChannels, loading: messageChannelsLoading } =
useMyMessageChannels();
const { channels: calendarChannels, loading: calendarChannelsLoading } =
useMyCalendarChannels();
const accounts = useMemo<ConnectedAccount[]>(() => {
if (!isMigrated) {
return workspaceAccounts;
}
if (!metadataData?.myConnectedAccounts) {
return [];
}
return metadataData.myConnectedAccounts.map(
(account: MetadataConnectedAccount) =>
({
id: account.id,
handle: account.handle,
provider: account.provider,
accessToken: '',
refreshToken: '',
accountOwnerId: account.userWorkspaceId,
lastSyncHistoryId: '',
authFailedAt: account.authFailedAt
? new Date(account.authFailedAt)
: null,
messageChannels: messageChannels.filter(
(channel) =>
(channel as unknown as { connectedAccountId: string })
.connectedAccountId === account.id,
),
calendarChannels: calendarChannels.filter(
(channel) =>
(channel as unknown as { connectedAccountId: string })
.connectedAccountId === account.id,
),
scopes: account.scopes,
__typename: 'ConnectedAccount',
}) as ConnectedAccount,
);
}, [
isMigrated,
workspaceAccounts,
metadataData,
messageChannels,
calendarChannels,
]);
return {
accounts,
loading: isMigrated
? metadataLoading || messageChannelsLoading || calendarChannelsLoading
: workspaceLoading,
};
};
@@ -0,0 +1,127 @@
import { type ConnectedAccount } from '@/accounts/types/ConnectedAccount';
import {
type MessageChannel,
MessageChannelSyncStage,
} from '@/accounts/types/MessageChannel';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { useGenerateDepthRecordGqlFieldsFromObject } from '@/object-record/graphql/record-gql-fields/hooks/useGenerateDepthRecordGqlFieldsFromObject';
import { GET_MY_MESSAGE_CHANNELS } from '@/settings/accounts/graphql/queries/getMyMessageChannels';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useFeatureFlagsMap } from '@/workspace/hooks/useFeatureFlagsMap';
import { useApolloClient, useQuery } from '@apollo/client/react';
import { useMemo } from 'react';
import { CoreObjectNameSingular, FeatureFlagKey } from 'twenty-shared/types';
type MetadataMessageChannel = {
id: string;
handle: string;
visibility: string;
type: string;
isContactAutoCreationEnabled: boolean;
contactAutoCreationPolicy: string;
messageFolderImportPolicy: string;
excludeNonProfessionalEmails: boolean;
excludeGroupEmails: boolean;
isSyncEnabled: boolean;
syncStatus: string;
syncStage: string;
syncStageStartedAt: string | null;
connectedAccountId: string;
createdAt: string;
updatedAt: string;
};
export const useMyMessageChannels = () => {
const featureFlagsMap = useFeatureFlagsMap();
const isMigrated =
featureFlagsMap[FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED] ?? false;
const currentWorkspaceMember = useAtomStateValue(currentWorkspaceMemberState);
const apolloClient = useApolloClient();
const { records: workspaceAccounts } = useFindManyRecords<ConnectedAccount>({
objectNameSingular: CoreObjectNameSingular.ConnectedAccount,
filter: {
accountOwnerId: {
eq: currentWorkspaceMember?.id,
},
},
skip: isMigrated,
});
const { recordGqlFields } = useGenerateDepthRecordGqlFieldsFromObject({
objectNameSingular: CoreObjectNameSingular.MessageChannel,
depth: 1,
});
const { records: workspaceChannels, loading: workspaceLoading } =
useFindManyRecords<MessageChannel & { connectedAccount: ConnectedAccount }>(
{
objectNameSingular: CoreObjectNameSingular.MessageChannel,
filter: {
connectedAccountId: {
in: workspaceAccounts.map((account) => account.id),
},
isSyncEnabled: { eq: true },
syncStage: {
neq: MessageChannelSyncStage.PENDING_CONFIGURATION,
},
},
recordGqlFields,
skip: isMigrated || !workspaceAccounts.length,
},
);
const { data: metadataData, loading: metadataLoading } = useQuery<{
myMessageChannels: MetadataMessageChannel[];
}>(GET_MY_MESSAGE_CHANNELS, {
client: apolloClient,
skip: !isMigrated,
});
const channels = useMemo(() => {
if (!isMigrated) {
return workspaceChannels;
}
if (!metadataData?.myMessageChannels) {
return [];
}
return metadataData.myMessageChannels
.filter(
(channel: MetadataMessageChannel) =>
channel.isSyncEnabled &&
channel.syncStage !== 'PENDING_CONFIGURATION',
)
.map(
(channel: MetadataMessageChannel) =>
({
id: channel.id,
handle: channel.handle,
visibility: channel.visibility,
contactAutoCreationPolicy: channel.contactAutoCreationPolicy,
excludeNonProfessionalEmails: channel.excludeNonProfessionalEmails,
excludeGroupEmails: channel.excludeGroupEmails,
isSyncEnabled: channel.isSyncEnabled,
messageFolders: [],
messageFolderImportPolicy: channel.messageFolderImportPolicy,
syncStatus: channel.syncStatus,
syncStage: channel.syncStage,
syncCursor: '',
syncStageStartedAt: channel.syncStageStartedAt
? new Date(channel.syncStageStartedAt)
: null,
throttleFailureCount: 0,
connectedAccountId: channel.connectedAccountId,
__typename: 'MessageChannel',
}) as MessageChannel,
);
}, [isMigrated, workspaceChannels, metadataData]);
return {
channels,
loading: isMigrated ? metadataLoading : workspaceLoading,
};
};
@@ -0,0 +1,80 @@
import { type MessageChannel } from '@/accounts/types/MessageChannel';
import { type MessageFolder } from '@/accounts/types/MessageFolder';
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { useGenerateDepthRecordGqlFieldsFromObject } from '@/object-record/graphql/record-gql-fields/hooks/useGenerateDepthRecordGqlFieldsFromObject';
import { GET_MY_MESSAGE_FOLDERS } from '@/settings/accounts/graphql/queries/getMyMessageFolders';
import { useFeatureFlagsMap } from '@/workspace/hooks/useFeatureFlagsMap';
import { useApolloClient, useQuery } from '@apollo/client/react';
import { useMemo } from 'react';
import { CoreObjectNameSingular, FeatureFlagKey } from 'twenty-shared/types';
type MetadataMessageFolder = {
id: string;
name: string | null;
isSynced: boolean;
isSentFolder: boolean;
parentFolderId: string | null;
externalId: string | null;
messageChannelId: string;
createdAt: string;
updatedAt: string;
};
export const useMyMessageFolders = (messageChannelId?: string) => {
const featureFlagsMap = useFeatureFlagsMap();
const isMigrated =
featureFlagsMap[FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED] ?? false;
const apolloClient = useApolloClient();
const { recordGqlFields } = useGenerateDepthRecordGqlFieldsFromObject({
objectNameSingular: CoreObjectNameSingular.MessageChannel,
depth: 1,
shouldOnlyLoadRelationIdentifiers: false,
});
const { record: messageChannel, loading: workspaceLoading } =
useFindOneRecord<MessageChannel>({
objectNameSingular: CoreObjectNameSingular.MessageChannel,
objectRecordId: messageChannelId,
recordGqlFields,
skip: isMigrated || !messageChannelId,
});
const { data: metadataData, loading: metadataLoading } = useQuery<{
myMessageFolders: MetadataMessageFolder[];
}>(GET_MY_MESSAGE_FOLDERS, {
client: apolloClient,
variables: messageChannelId ? { messageChannelId } : undefined,
skip: !isMigrated,
});
const messageFolders = useMemo<MessageFolder[]>(() => {
if (!isMigrated) {
return messageChannel?.messageFolders ?? [];
}
if (!metadataData?.myMessageFolders) {
return [];
}
return metadataData.myMessageFolders.map(
(folder: MetadataMessageFolder) => ({
id: folder.id,
name: folder.name ?? '',
syncCursor: '',
isSynced: folder.isSynced,
isSentFolder: folder.isSentFolder,
parentFolderId: folder.parentFolderId,
messageChannelId: folder.messageChannelId,
externalId: folder.externalId,
__typename: 'MessageFolder' as const,
}),
);
}, [isMigrated, messageChannel, metadataData]);
return {
messageFolders,
loading: isMigrated ? metadataLoading : workspaceLoading,
};
};
@@ -1,7 +1,10 @@
import { useCallback } from 'react';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { useUpdateManyRecords } from '@/object-record/hooks/useUpdateManyRecords';
import { UPDATE_MESSAGE_FOLDERS } from '@/settings/accounts/graphql/mutations/updateMessageFolders';
import { useFeatureFlagsMap } from '@/workspace/hooks/useFeatureFlagsMap';
import { useApolloClient } from '@apollo/client/react';
import { CoreObjectNameSingular, FeatureFlagKey } from 'twenty-shared/types';
type UpdateMessageFoldersSyncStatusArgs = {
messageFolderIds: string[];
@@ -9,6 +12,12 @@ type UpdateMessageFoldersSyncStatusArgs = {
};
export const useUpdateMessageFoldersSyncStatus = () => {
const featureFlagsMap = useFeatureFlagsMap();
const isMigrated =
featureFlagsMap[FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED] ?? false;
const apolloClient = useApolloClient();
const { updateManyRecords } = useUpdateManyRecords({
objectNameSingular: CoreObjectNameSingular.MessageFolder,
recordGqlFields: {
@@ -22,12 +31,37 @@ export const useUpdateMessageFoldersSyncStatus = () => {
messageFolderIds,
isSynced,
}: UpdateMessageFoldersSyncStatusArgs) => {
if (isMigrated) {
if (messageFolderIds.length === 0) {
return;
}
await apolloClient.mutate({
mutation: UPDATE_MESSAGE_FOLDERS,
variables: {
input: {
ids: messageFolderIds,
update: { isSynced },
},
},
optimisticResponse: {
updateMessageFolders: messageFolderIds.map((id) => ({
__typename: 'MessageFolder',
id,
isSynced,
})),
},
});
return;
}
await updateManyRecords({
recordIdsToUpdate: messageFolderIds,
updateOneRecordInput: { isSynced },
});
},
[updateManyRecords],
[isMigrated, apolloClient, updateManyRecords],
);
return { updateMessageFoldersSyncStatus };
@@ -40,8 +40,7 @@ export const SettingsNavigationDrawerItem = ({
to={href}
Icon={item.Icon}
active={isActive}
soon={item.soon}
isNew={item.isNew}
modifier={item.modifier}
onClick={item.onClick}
/>
</AdvancedSettingsWrapper>
@@ -56,8 +55,7 @@ export const SettingsNavigationDrawerItem = ({
to={href || undefined}
Icon={item.Icon}
active={isActive}
soon={item.soon}
isNew={item.isNew}
modifier={item.modifier}
onClick={item.onClick}
/>
);
@@ -7,7 +7,10 @@ import { billingState } from '@/client-config/states/billingState';
import { supportChatState } from '@/client-config/states/supportChatState';
import { usePermissionFlagMap } from '@/settings/roles/hooks/usePermissionFlagMap';
import { getDocumentationUrl } from '@/support/utils/getDocumentationUrl';
import { type NavigationDrawerItemIndentationLevel } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem';
import {
type NavigationDrawerItemIndentationLevel,
type NavigationDrawerItemModifier,
} from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { t } from '@lingui/core/macro';
@@ -57,8 +60,7 @@ export type SettingsNavigationItem = {
isHidden?: boolean;
subItems?: SettingsNavigationItem[];
isAdvanced?: boolean;
soon?: boolean;
isNew?: boolean;
modifier?: NavigationDrawerItemModifier;
};
const useSettingsNavigationItems = (): SettingsNavigationSection[] => {
@@ -179,7 +181,7 @@ const useSettingsNavigationItems = (): SettingsNavigationSection[] => {
isHidden:
!isApplicationEnabled ||
!permissionMap[PermissionFlagType.WORKSPACE],
isNew: true,
modifier: 'new',
},
{
label: t`AI`,
@@ -187,7 +189,7 @@ const useSettingsNavigationItems = (): SettingsNavigationSection[] => {
Icon: IconSparkles,
isHidden:
!isAIEnabled || !permissionMap[PermissionFlagType.WORKSPACE],
isNew: true,
modifier: 'new',
},
{
label: t`Security`,
@@ -45,7 +45,7 @@ export const SignInAppNavigationDrawerMock = ({
label={t`Search`}
Icon={IconSearch}
onClick={() => {}}
keyboard={[getOsControlSymbol(), 'K']}
modifier={{ keyboard: [getOsControlSymbol(), 'K'] }}
/>
<NavigationDrawerItem
label={t`Settings`}
@@ -1,4 +1,5 @@
import { InputErrorHelper } from '@/ui/input/components/InputErrorHelper';
import { isNonEmptyString } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
import { InputLabel } from '@/ui/input/components/InputLabel';
import { css } from '@linaria/core';
@@ -459,7 +460,7 @@ const TextInputWithAutoGrowWrapper = forwardRef<
{props.autoGrow ? (
<StyledAutogrowWrapper
sizeVariant={props.sizeVariant}
node={props.value ?? props.placeholder}
node={isNonEmptyString(props.value) ? props.value : props.placeholder}
>
<TextInputComponent
// oxlint-disable-next-line react/jsx-props-no-spreading
@@ -37,6 +37,11 @@ const DEFAULT_INDENTATION_LEVEL = 1;
export type NavigationDrawerItemIndentationLevel = 1 | 2;
export type NavigationDrawerItemModifier =
| 'soon'
| 'new'
| { keyboard: string[] };
export type NavigationDrawerItemProps = {
className?: string;
label: string;
@@ -48,17 +53,12 @@ export type NavigationDrawerItemProps = {
Icon?: IconComponent | ((props: TablerIconsProps) => JSX.Element);
iconColor?: string | null;
active?: boolean;
danger?: boolean;
soon?: boolean;
isNew?: boolean;
count?: number;
keyboard?: string[];
modifier?: NavigationDrawerItemModifier;
rightOptions?: ReactNode;
alwaysShowRightOptions?: boolean;
isDragging?: boolean;
isRightOptionsDropdownOpen?: boolean;
triggerEvent?: TriggerEventType;
mouseUpNavigation?: boolean;
preventCollapseOnMobile?: boolean;
isSelectedInEditMode?: boolean;
variant?: 'default' | 'tertiary';
@@ -67,14 +67,13 @@ export type NavigationDrawerItemProps = {
type StyledItemProps = Pick<
NavigationDrawerItemProps,
| 'active'
| 'danger'
| 'indentationLevel'
| 'soon'
| 'to'
| 'isDragging'
| 'isSelectedInEditMode'
| 'variant'
> & {
isSoon: boolean;
isNavigationDrawerExpanded: boolean;
hasRightOptions: boolean;
href?: string;
@@ -92,14 +91,11 @@ const StyledItem = styled.button<StyledItemProps>`
: '1px solid transparent'};
border-radius: ${themeCssVariables.border.radius.sm};
box-sizing: border-box;
color: ${({ active, danger, soon, variant }) => {
color: ${({ active, isSoon, variant }) => {
if (active === true) {
return themeCssVariables.font.color.primary;
}
if (danger === true) {
return themeCssVariables.color.red;
}
if (soon === true) {
if (isSoon) {
return themeCssVariables.font.color.light;
}
if (variant === 'tertiary') {
@@ -107,8 +103,8 @@ const StyledItem = styled.button<StyledItemProps>`
}
return themeCssVariables.font.color.secondary;
}};
cursor: ${({ soon, isDragging }) =>
isDragging ? 'grabbing' : soon ? 'default' : 'pointer'};
cursor: ${({ isSoon, isDragging }) =>
isDragging ? 'grabbing' : isSoon ? 'default' : 'pointer'};
display: flex;
font-family: ${themeCssVariables.font.family};
font-size: ${themeCssVariables.font.size.md};
@@ -122,7 +118,7 @@ const StyledItem = styled.button<StyledItemProps>`
? themeCssVariables.spacing['0.5']
: themeCssVariables.spacing[1]};
padding-top: ${themeCssVariables.spacing[1]};
pointer-events: ${({ soon }) => (soon ? 'none' : 'auto')};
pointer-events: ${({ isSoon }) => (isSoon ? 'none' : 'auto')};
text-decoration: none;
user-select: none;
width: ${({ isNavigationDrawerExpanded, hasRightOptions }) =>
@@ -132,10 +128,7 @@ const StyledItem = styled.button<StyledItemProps>`
&:hover {
background: ${themeCssVariables.background.transparent.light};
color: ${({ danger }) =>
danger
? themeCssVariables.color.red
: themeCssVariables.font.color.primary};
color: ${themeCssVariables.font.color.primary};
}
&:hover .keyboard-shortcuts {
@@ -172,20 +165,6 @@ const StyledItemSecondaryLabel = styled.span`
font-weight: ${themeCssVariables.font.weight.regular};
`;
const StyledItemCount = styled.span`
align-items: center;
background-color: ${themeCssVariables.color.blue};
border-radius: ${themeCssVariables.border.radius.rounded};
color: ${themeCssVariables.grayScale.gray1};
display: flex;
font-size: ${themeCssVariables.font.size.xs};
font-weight: ${themeCssVariables.font.weight.semiBold};
height: 16px;
justify-content: center;
margin-left: auto;
width: 16px;
`;
const StyledKeyBoardShortcut = styled.span`
align-items: center;
background: ${themeCssVariables.background.transparent.lighter};
@@ -277,18 +256,13 @@ export const NavigationDrawerItem = ({
to,
onClick,
active,
danger,
soon,
isNew,
count,
keyboard,
modifier,
subItemState,
rightOptions,
alwaysShowRightOptions = false,
isDragging,
isRightOptionsDropdownOpen,
triggerEvent,
mouseUpNavigation = false,
preventCollapseOnMobile = false,
isSelectedInEditMode = false,
variant = 'default',
@@ -301,10 +275,15 @@ export const NavigationDrawerItem = ({
const { navigationItemId } = useNavigationDrawerTooltip(label, to);
const isSoon = modifier === 'soon';
const isNew = modifier === 'new';
const keyboardKeys =
isDefined(modifier) && typeof modifier === 'object'
? modifier.keyboard
: undefined;
const showBreadcrumb = indentationLevel === 2;
const showStyledSpacer = Boolean(
soon || isNew || count || keyboard || rightOptions,
);
const showStyledSpacer = isDefined(modifier) || isDefined(rightOptions);
const handleMobileNavigation = () => {
if (isMobile && !preventCollapseOnMobile) {
@@ -314,6 +293,7 @@ export const NavigationDrawerItem = ({
const isExternalLink =
isDefined(to) && (to.startsWith('http://') || to.startsWith('https://'));
const isInternalLink = isDefined(to) && !isExternalLink;
const handleExternalLinkClick = () => {
handleMobileNavigation();
@@ -332,40 +312,37 @@ export const NavigationDrawerItem = ({
triggerEvent,
});
const elementType = isExternalLink
? 'a'
: isInternalLink
? Link
: isDefined(rightOptions)
? 'div'
: undefined;
return (
<StyledNavigationDrawerItemContainer>
<StyledItem
id={navigationItemId}
className={`navigation-drawer-item ${className || ''}`}
onClick={
mouseUpNavigation ? onClick : handleMouseDownNavigationClickClick
}
onMouseDown={mouseUpNavigation ? undefined : handleMouseDown}
onClick={handleMouseDownNavigationClickClick}
onMouseDown={handleMouseDown}
active={active}
aria-selected={active}
danger={danger}
soon={soon}
isSoon={isSoon}
variant={variant}
as={
to
? isExternalLink
? 'a'
: Link
: isDefined(rightOptions)
? 'div'
: undefined
}
role={to ? undefined : isDefined(rightOptions) ? 'button' : undefined}
to={isExternalLink ? undefined : to}
href={isExternalLink ? to : undefined}
target={isExternalLink ? '_blank' : undefined}
rel={isExternalLink ? 'noopener noreferrer' : undefined}
draggable={to && !isExternalLink ? false : undefined}
indentationLevel={indentationLevel}
isNavigationDrawerExpanded={isNavigationDrawerExpanded}
isDragging={isDragging}
hasRightOptions={isDefined(rightOptions)}
isSelectedInEditMode={isSelectedInEditMode}
as={elementType}
role={!to && isDefined(rightOptions) ? 'button' : undefined}
to={isInternalLink ? to : undefined}
href={isExternalLink ? to : undefined}
target={isExternalLink ? '_blank' : undefined}
rel={isExternalLink ? 'noopener noreferrer' : undefined}
draggable={isInternalLink ? false : undefined}
>
<StyledItemElementsContainer>
{showBreadcrumb && (
@@ -419,7 +396,7 @@ export const NavigationDrawerItem = ({
{showStyledSpacer && <StyledSpacer />}
{soon && (
{isSoon && (
<NavigationDrawerAnimatedCollapseWrapper>
<Pill label={t`Soon`} />
</NavigationDrawerAnimatedCollapseWrapper>
@@ -431,23 +408,25 @@ export const NavigationDrawerItem = ({
</NavigationDrawerAnimatedCollapseWrapper>
)}
{!!count && (
<NavigationDrawerAnimatedCollapseWrapper>
<StyledItemCount>{count}</StyledItemCount>
</NavigationDrawerAnimatedCollapseWrapper>
)}
{keyboard && (
{isDefined(keyboardKeys) && (
<NavigationDrawerAnimatedCollapseWrapper>
<StyledKeyBoardShortcut className="keyboard-shortcuts">
<Label>{keyboard}</Label>
<Label>{keyboardKeys}</Label>
</StyledKeyBoardShortcut>
</NavigationDrawerAnimatedCollapseWrapper>
)}
{isDefined(rightOptions) && (
<NavigationDrawerAnimatedCollapseWrapper>
<StyledRightOptionsContainer>
{/* When StyledItem renders as a Link, we need both handlers to
prevent navigation when interacting with rightOptions:
- onMouseDown: stops useMouseDownNavigation from calling navigate()
- onClickCapture: prevents the native <a> follow since the child's
stopPropagation blocks Link's own preventDefault */}
<StyledRightOptionsContainer
onMouseDown={(e) => e.stopPropagation()}
onClickCapture={(e) => e.preventDefault()}
>
<StyledRightOptionsVisbility
data-visible={
isMobile ||
@@ -14,10 +14,7 @@ export const NavigationDrawerSubItem = ({
to,
onClick,
active,
danger,
soon,
count,
keyboard,
modifier,
subItemState,
rightOptions,
isDragging,
@@ -37,10 +34,7 @@ export const NavigationDrawerSubItem = ({
to={to}
onClick={onClick}
active={active}
danger={danger}
soon={soon}
count={count}
keyboard={keyboard}
modifier={modifier}
rightOptions={rightOptions}
isDragging={isDragging}
isSelectedInEditMode={isSelectedInEditMode}
@@ -86,24 +86,19 @@ export const Default: Story = {
label="Notifications"
to="/inbox"
Icon={IconBell}
soon={true}
modifier="soon"
/>
<NavigationDrawerItem
label="Search"
Icon={IconSearch}
keyboard={[`${getOsControlSymbol()}`, 'K']}
modifier={{ keyboard: [`${getOsControlSymbol()}`, 'K'] }}
/>
<NavigationDrawerItem
label="Settings"
to="/settings/profile"
Icon={IconSettings}
/>
<NavigationDrawerItem
label="Tasks"
to="/tasks"
Icon={IconCheckbox}
count={2}
/>
<NavigationDrawerItem label="Tasks" to="/tasks" Icon={IconCheckbox} />
</NavigationDrawerSection>
<NavigationDrawerSection>
@@ -105,23 +105,14 @@ export const NewPill: Story = {
args={{
label: 'New Feature',
Icon: IconSearch,
isNew: true,
modifier: 'new',
}}
/>
<Story
args={{
label: 'New Feature with Count',
label: 'Feature with Keyboard Shortcut',
Icon: IconSearch,
isNew: true,
count: 5,
}}
/>
<Story
args={{
label: 'New Feature with Keyboard Shortcut',
Icon: IconSearch,
isNew: true,
keyboard: [getOsControlSymbol(), 'N'],
modifier: { keyboard: [getOsControlSymbol(), 'N'] },
}}
/>
</StyledContainer>
@@ -195,12 +186,6 @@ export const Catalog: CatalogStory<Story, typeof NavigationDrawerItem> = {
pseudo: { hover: ['.hover'] },
catalog: {
dimensions: [
{
name: 'danger',
values: [true, false],
props: (danger: boolean) => ({ danger }),
labels: (danger: boolean) => (danger ? 'Danger' : 'No Danger'),
},
{
name: 'active',
values: [true, false],
@@ -216,23 +201,19 @@ export const Catalog: CatalogStory<Story, typeof NavigationDrawerItem> = {
},
{
name: 'adornments',
values: [
'Without Adornments',
'Soon Pill',
'New Pill',
'Count',
'Keyboard Keys',
],
values: ['Without Modifier', 'Soon', 'New', 'Keyboard Keys'],
props: (adornmentName: string) =>
adornmentName === 'Soon Pill'
? { soon: true }
: adornmentName === 'New Pill'
? { isNew: true }
: adornmentName === 'Count'
? { count: 3 }
: adornmentName === 'Keyboard Keys'
? { keyboard: [getOsControlSymbol(), 'K'] }
: {},
adornmentName === 'Soon'
? { modifier: 'soon' }
: adornmentName === 'New'
? { modifier: 'new' }
: adornmentName === 'Keyboard Keys'
? {
modifier: {
keyboard: [getOsControlSymbol(), 'K'],
},
}
: {},
},
],
},
@@ -2,6 +2,7 @@ import { useCallback } from 'react';
import { useStore } from 'jotai';
import { useContextStoreObjectMetadataItemOrThrow } from '@/context-store/hooks/useContextStoreObjectMetadataItemOrThrow';
import { useOptimisticRemoveNavigationMenuItemsByViewId } from '@/navigation-menu-item/edit/hooks/useOptimisticRemoveNavigationMenuItemsByViewId';
import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
import { useAtomFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilySelectorValue';
import { usePerformViewAPIPersist } from '@/views/hooks/internal/usePerformViewAPIPersist';
@@ -45,6 +46,8 @@ export const useDestroyViewFromCurrentState = (viewBarInstanceId?: string) => {
const { changeView } = useChangeView();
const { performViewAPIDestroy } = usePerformViewAPIPersist();
const { removeNavigationMenuItemsByViewIds } =
useOptimisticRemoveNavigationMenuItemsByViewId();
const store = useStore();
@@ -72,11 +75,13 @@ export const useDestroyViewFromCurrentState = (viewBarInstanceId?: string) => {
}
await performViewAPIDestroy({ id: viewPickerReferenceViewId });
removeNavigationMenuItemsByViewIds([viewPickerReferenceViewId]);
}, [
currentView,
closeAndResetViewPicker,
changeView,
performViewAPIDestroy,
removeNavigationMenuItemsByViewIds,
store,
viewPickerIsDirtyCallbackState,
viewPickerIsPersistingCallbackState,
@@ -11,6 +11,7 @@ import { SubTitle } from '@/auth/components/SubTitle';
import { Title } from '@/auth/components/Title';
import { currentUserState } from '@/auth/states/currentUserState';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { currentWorkspaceMembersState } from '@/auth/states/currentWorkspaceMembersState';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { useSetNextOnboardingStatus } from '@/onboarding/hooks/useSetNextOnboardingStatus';
@@ -74,6 +75,9 @@ export const CreateProfile = () => {
currentWorkspaceMemberState,
);
const setCurrentUser = useSetAtomState(currentUserState);
const setCurrentWorkspaceMembers = useSetAtomState(
currentWorkspaceMembersState,
);
const { updateOneRecord } = useUpdateOneRecord();
// Form
@@ -128,6 +132,20 @@ export const CreateProfile = () => {
return current;
});
setCurrentWorkspaceMembers((members) =>
members.map((member) =>
member.id === currentWorkspaceMember?.id
? {
...member,
name: {
firstName: data.firstName,
lastName: data.lastName,
},
}
: member,
),
);
setCurrentUser((current) => {
if (isDefined(current)) {
return {
@@ -151,6 +169,7 @@ export const CreateProfile = () => {
setNextOnboardingStatus,
enqueueErrorSnackBar,
setCurrentWorkspaceMember,
setCurrentWorkspaceMembers,
setCurrentUser,
updateOneRecord,
],
@@ -1,40 +1,20 @@
import { type ConnectedAccount } from '@/accounts/types/ConnectedAccount';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { CoreObjectNameSingular, SettingsPath } from 'twenty-shared/types';
import { useGenerateDepthRecordGqlFieldsFromObject } from '@/object-record/graphql/record-gql-fields/hooks/useGenerateDepthRecordGqlFieldsFromObject';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { SettingsPath } from 'twenty-shared/types';
import { SettingsAccountLoader } from '@/settings/accounts/components/SettingsAccountLoader';
import { SettingsAccountsBlocklistSection } from '@/settings/accounts/components/SettingsAccountsBlocklistSection';
import { SettingsAccountsConnectedAccountsListCard } from '@/settings/accounts/components/SettingsAccountsConnectedAccountsListCard';
import { SettingsAccountsSettingsSection } from '@/settings/accounts/components/SettingsAccountsSettingsSection';
import { useMyConnectedAccounts } from '@/settings/accounts/hooks/useMyConnectedAccounts';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { useLingui } from '@lingui/react/macro';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { getSettingsPath } from 'twenty-shared/utils';
import { H2Title } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
export const SettingsAccounts = () => {
const { t } = useLingui();
const currentWorkspaceMember = useAtomStateValue(currentWorkspaceMemberState);
const { recordGqlFields } = useGenerateDepthRecordGqlFieldsFromObject({
objectNameSingular: CoreObjectNameSingular.ConnectedAccount,
depth: 1,
shouldOnlyLoadRelationIdentifiers: false,
});
const { records: allAccounts, loading } =
useFindManyRecords<ConnectedAccount>({
objectNameSingular: CoreObjectNameSingular.ConnectedAccount,
filter: {
accountOwnerId: {
eq: currentWorkspaceMember?.id,
},
},
recordGqlFields,
});
const { accounts: allAccounts, loading } = useMyConnectedAccounts();
return (
<SubMenuTopBarContainer
@@ -5,14 +5,22 @@ import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomStat
import { type CalendarChannel } from '@/accounts/types/CalendarChannel';
import { type MessageChannel } from '@/accounts/types/MessageChannel';
import { CoreObjectNameSingular, SettingsPath } from 'twenty-shared/types';
import {
CoreObjectNameSingular,
FeatureFlagKey,
SettingsPath,
} from 'twenty-shared/types';
import { useGenerateDepthRecordGqlFieldsFromObject } from '@/object-record/graphql/record-gql-fields/hooks/useGenerateDepthRecordGqlFieldsFromObject';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { GET_MY_CALENDAR_CHANNELS } from '@/settings/accounts/graphql/queries/getMyCalendarChannels';
import { GET_MY_MESSAGE_CHANNELS } from '@/settings/accounts/graphql/queries/getMyMessageChannels';
import { settingsAccountsSelectedMessageChannelState } from '@/settings/accounts/states/settingsAccountsSelectedMessageChannelState';
import { useFeatureFlagsMap } from '@/workspace/hooks/useFeatureFlagsMap';
import { useMutation, useQuery } from '@apollo/client/react';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { useMutation } from '@apollo/client/react';
import { StartChannelSyncDocument } from '~/generated-metadata/graphql';
import { SettingsAccountsConfigurationSelectedMessageChannelEffect } from '~/pages/settings/accounts/SettingsAccountsConfigurationSelectedMessageChannelEffect';
import { SettingsAccountsConfigurationStepCalendar } from '~/pages/settings/accounts/SettingsAccountsConfigurationStepCalendar';
import { SettingsAccountsConfigurationStepEmail } from '~/pages/settings/accounts/SettingsAccountsConfigurationStepEmail';
@@ -40,38 +48,66 @@ export const SettingsAccountsConfiguration = () => {
SettingsAccountsConfigurationStep.Email,
);
const featureFlagsMap = useFeatureFlagsMap();
const isMigrated =
featureFlagsMap[FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED] ?? false;
const { recordGqlFields } = useGenerateDepthRecordGqlFieldsFromObject({
objectNameSingular: CoreObjectNameSingular.MessageChannel,
depth: 1,
shouldOnlyLoadRelationIdentifiers: false,
});
const { records: messageChannels } = useFindManyRecords<MessageChannel>({
objectNameSingular: CoreObjectNameSingular.MessageChannel,
filter: {
connectedAccountId: {
eq: connectedAccountId,
const { records: workspaceMessageChannels } =
useFindManyRecords<MessageChannel>({
objectNameSingular: CoreObjectNameSingular.MessageChannel,
filter: {
connectedAccountId: {
eq: connectedAccountId,
},
},
},
recordGqlFields,
onCompleted: (data) => {
if (isDefined(data[0])) {
setSettingsAccountsSelectedMessageChannel(data[0]);
}
},
skip: !connectedAccountId,
recordGqlFields,
onCompleted: (data) => {
if (isDefined(data[0])) {
setSettingsAccountsSelectedMessageChannel(data[0]);
}
},
skip: !connectedAccountId || isMigrated,
});
const { records: workspaceCalendarChannels } =
useFindManyRecords<CalendarChannel>({
objectNameSingular: CoreObjectNameSingular.CalendarChannel,
filter: {
connectedAccountId: {
eq: connectedAccountId,
},
},
skip: !connectedAccountId || isMigrated,
});
const { data: metadataMessageChannelData } = useQuery<{
myMessageChannels: MessageChannel[];
}>(GET_MY_MESSAGE_CHANNELS, {
variables: { connectedAccountId },
skip: !isMigrated || !connectedAccountId,
});
const { records: calendarChannels } = useFindManyRecords<CalendarChannel>({
objectNameSingular: CoreObjectNameSingular.CalendarChannel,
filter: {
connectedAccountId: {
eq: connectedAccountId,
},
},
skip: !connectedAccountId,
const { data: metadataCalendarChannelData } = useQuery<{
myCalendarChannels: CalendarChannel[];
}>(GET_MY_CALENDAR_CHANNELS, {
variables: { connectedAccountId },
skip: !isMigrated || !connectedAccountId,
});
const messageChannels = isMigrated
? (metadataMessageChannelData?.myMessageChannels ?? [])
: workspaceMessageChannels;
const calendarChannels = isMigrated
? (metadataCalendarChannelData?.myCalendarChannels ?? [])
: workspaceCalendarChannels;
const messageChannel = messageChannels[0];
const calendarChannel = calendarChannels[0];
@@ -106,13 +142,20 @@ export const SettingsAccountsConfiguration = () => {
if (showEmailStep) {
return (
<SettingsAccountsConfigurationStepEmail
messageChannel={messageChannel}
hasNextStep={isDefined(calendarChannel)}
isSubmitting={isSubmitting}
onNext={handleNext}
onAddAccount={handleAddAccount}
/>
<>
{isMigrated && (
<SettingsAccountsConfigurationSelectedMessageChannelEffect
messageChannel={messageChannel as unknown as MessageChannel}
/>
)}
<SettingsAccountsConfigurationStepEmail
messageChannel={messageChannel}
hasNextStep={isDefined(calendarChannel)}
isSubmitting={isSubmitting}
onNext={handleNext}
onAddAccount={handleAddAccount}
/>
</>
);
}
@@ -0,0 +1,25 @@
import { type MessageChannel } from '@/accounts/types/MessageChannel';
import { settingsAccountsSelectedMessageChannelState } from '@/settings/accounts/states/settingsAccountsSelectedMessageChannelState';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { useEffect } from 'react';
import { isDefined } from 'twenty-shared/utils';
type SettingsAccountsConfigurationSelectedMessageChannelEffectProps = {
messageChannel: MessageChannel | undefined;
};
export const SettingsAccountsConfigurationSelectedMessageChannelEffect = ({
messageChannel,
}: SettingsAccountsConfigurationSelectedMessageChannelEffectProps) => {
const setSettingsAccountsSelectedMessageChannel = useSetAtomState(
settingsAccountsSelectedMessageChannelState,
);
useEffect(() => {
if (isDefined(messageChannel)) {
setSettingsAccountsSelectedMessageChannel(messageChannel);
}
}, [messageChannel, setSettingsAccountsSelectedMessageChannel]);
return null;
};
@@ -23,13 +23,12 @@ const main = async () => {
);
const serverUrl = process.env.TWENTY_API_URL ?? 'http://localhost:3000';
const token = process.env.TWENTY_API_KEY;
const clientWrapperTemplateSource = await readFile(TEMPLATE_PATH, 'utf-8');
const clientService = new ClientService({
clientWrapperTemplateSource,
serverUrl,
token,
skipAuth: true,
});
await clientService.generateMetadataClient({ outputPath });
@@ -12,8 +12,14 @@ export class ApiClient {
disableInterceptors?: boolean;
serverUrl?: string;
token?: string;
skipAuth?: boolean;
}) {
const { disableInterceptors = false, serverUrl, token } = options || {};
const {
disableInterceptors = false,
serverUrl,
token,
skipAuth = false,
} = options || {};
this.configService = new ConfigService();
this.tokenOverride = token;
this.serverUrlOverride = serverUrl;
@@ -24,7 +30,7 @@ export class ApiClient {
config.baseURL = this.serverUrlOverride ?? twentyConfig.apiUrl;
if (!config.headers.Authorization) {
if (!config.headers.Authorization && !skipAuth) {
const authToken = await this.resolveAuthToken();
if (authToken) {
@@ -10,6 +10,7 @@ type ApiServiceOptions = {
disableInterceptors?: boolean;
serverUrl?: string;
token?: string;
skipAuth?: boolean;
};
export class ApiService {
@@ -79,12 +79,14 @@ export class ClientService {
clientWrapperTemplateSource?: string;
serverUrl?: string;
token?: string;
skipAuth?: boolean;
}) {
this.clientWrapperTemplateSource =
options?.clientWrapperTemplateSource ?? twentyClientTemplateSource;
this.apiService = new ApiService({
disableInterceptors: true,
serverUrl: options?.serverUrl,
skipAuth: true,
token: options?.token,
});
}
@@ -1236,107 +1236,6 @@ enum PageLayoutType {
DASHBOARD
}
type FileWithSignedUrl {
id: UUID!
path: String!
size: Float!
createdAt: DateTime!
url: String!
}
type RecordIdentifier {
id: UUID!
labelIdentifier: String!
imageIdentifier: String
}
type NavigationMenuItem {
id: UUID!
userWorkspaceId: UUID
targetRecordId: UUID
targetObjectMetadataId: UUID
viewId: UUID
type: NavigationMenuItemType!
name: String
link: String
icon: String
color: String
folderId: UUID
position: Float!
applicationId: UUID
createdAt: DateTime!
updatedAt: DateTime!
targetRecordIdentifier: RecordIdentifier
}
enum NavigationMenuItemType {
VIEW
FOLDER
LINK
OBJECT
RECORD
}
type ObjectRecordEventProperties {
updatedFields: [String!]
before: JSON
after: JSON
diff: JSON
}
type MetadataEvent {
type: MetadataEventAction!
metadataName: String!
recordId: String!
properties: ObjectRecordEventProperties!
updatedCollectionHash: String
}
"""Metadata Event Action"""
enum MetadataEventAction {
CREATED
UPDATED
DELETED
}
type ObjectRecordEvent {
action: DatabaseEventAction!
objectNameSingular: String!
recordId: String!
userId: String
workspaceMemberId: String
properties: ObjectRecordEventProperties!
}
"""Database Event Action"""
enum DatabaseEventAction {
CREATED
UPDATED
DELETED
DESTROYED
RESTORED
UPSERTED
}
type ObjectRecordEventWithQueryIds {
queryIds: [String!]!
objectRecordEvent: ObjectRecordEvent!
}
type EventSubscription {
eventStreamId: String!
objectRecordEventsWithQueryIds: [ObjectRecordEventWithQueryIds!]!
metadataEvents: [MetadataEvent!]!
}
type OnDbEvent {
action: DatabaseEventAction!
objectNameSingular: String!
eventDate: DateTime!
record: JSON!
updatedFields: [String!]
}
type Analytics {
"""Boolean that confirms query was dispatched"""
success: Boolean!
@@ -1523,6 +1422,14 @@ type ApprovedAccessDomain {
createdAt: DateTime!
}
type FileWithSignedUrl {
id: UUID!
path: String!
size: Float!
createdAt: DateTime!
url: String!
}
type WorkspaceInvitation {
id: UUID!
email: String!
@@ -1641,7 +1548,9 @@ enum FeatureFlagKey {
IS_NAVIGATION_MENU_ITEM_EDITING_ENABLED
IS_DRAFT_EMAIL_ENABLED
IS_RICH_TEXT_V1_MIGRATED
IS_DIRECT_GRAPHQL_EXECUTION_ENABLED
IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED
IS_CONNECTED_ACCOUNT_MIGRATED
}
type SSOIdentityProvider {
@@ -1958,6 +1867,91 @@ type WorkspaceInviteHashValid {
isValid: Boolean!
}
type RecordIdentifier {
id: UUID!
labelIdentifier: String!
imageIdentifier: String
}
type NavigationMenuItem {
id: UUID!
userWorkspaceId: UUID
targetRecordId: UUID
targetObjectMetadataId: UUID
viewId: UUID
type: NavigationMenuItemType!
name: String
link: String
icon: String
color: String
folderId: UUID
position: Float!
applicationId: UUID
createdAt: DateTime!
updatedAt: DateTime!
targetRecordIdentifier: RecordIdentifier
}
enum NavigationMenuItemType {
VIEW
FOLDER
LINK
OBJECT
RECORD
}
type ObjectRecordEventProperties {
updatedFields: [String!]
before: JSON
after: JSON
diff: JSON
}
type MetadataEvent {
type: MetadataEventAction!
metadataName: String!
recordId: String!
properties: ObjectRecordEventProperties!
updatedCollectionHash: String
}
"""Metadata Event Action"""
enum MetadataEventAction {
CREATED
UPDATED
DELETED
}
type ObjectRecordEvent {
action: DatabaseEventAction!
objectNameSingular: String!
recordId: String!
userId: String
workspaceMemberId: String
properties: ObjectRecordEventProperties!
}
"""Database Event Action"""
enum DatabaseEventAction {
CREATED
UPDATED
DELETED
DESTROYED
RESTORED
UPSERTED
}
type ObjectRecordEventWithQueryIds {
queryIds: [String!]!
objectRecordEvent: ObjectRecordEvent!
}
type EventSubscription {
eventStreamId: String!
objectRecordEventsWithQueryIds: [ObjectRecordEventWithQueryIds!]!
metadataEvents: [MetadataEvent!]!
}
type LogicFunctionExecutionResult {
"""Execution result in JSON format"""
data: JSON
@@ -2212,6 +2206,156 @@ type AgentTurn {
createdAt: DateTime!
}
type CalendarChannel {
id: UUID!
handle: String!
syncStatus: CalendarChannelSyncStatus!
syncStage: CalendarChannelSyncStage!
visibility: CalendarChannelVisibility!
isContactAutoCreationEnabled: Boolean!
contactAutoCreationPolicy: CalendarChannelContactAutoCreationPolicy!
isSyncEnabled: Boolean!
syncedAt: DateTime
syncStageStartedAt: DateTime
throttleFailureCount: Float!
connectedAccountId: UUID!
createdAt: DateTime!
updatedAt: DateTime!
}
enum CalendarChannelSyncStatus {
NOT_SYNCED
ONGOING
ACTIVE
FAILED_INSUFFICIENT_PERMISSIONS
FAILED_UNKNOWN
}
enum CalendarChannelSyncStage {
PENDING_CONFIGURATION
CALENDAR_EVENT_LIST_FETCH_PENDING
CALENDAR_EVENT_LIST_FETCH_SCHEDULED
CALENDAR_EVENT_LIST_FETCH_ONGOING
CALENDAR_EVENTS_IMPORT_PENDING
CALENDAR_EVENTS_IMPORT_SCHEDULED
CALENDAR_EVENTS_IMPORT_ONGOING
FAILED
}
enum CalendarChannelVisibility {
METADATA
SHARE_EVERYTHING
}
enum CalendarChannelContactAutoCreationPolicy {
AS_PARTICIPANT_AND_ORGANIZER
AS_PARTICIPANT
AS_ORGANIZER
NONE
}
type ConnectedAccountDTO {
id: UUID!
handle: String!
provider: String!
lastCredentialsRefreshedAt: DateTime
authFailedAt: DateTime
handleAliases: [String!]
scopes: [String!]
lastSignedInAt: DateTime
userWorkspaceId: UUID!
createdAt: DateTime!
updatedAt: DateTime!
}
type MessageChannel {
id: UUID!
visibility: MessageChannelVisibility!
handle: String!
type: MessageChannelType!
isContactAutoCreationEnabled: Boolean!
contactAutoCreationPolicy: MessageChannelContactAutoCreationPolicy!
messageFolderImportPolicy: MessageFolderImportPolicy!
excludeNonProfessionalEmails: Boolean!
excludeGroupEmails: Boolean!
pendingGroupEmailsAction: MessageChannelPendingGroupEmailsAction!
isSyncEnabled: Boolean!
syncedAt: DateTime
syncStatus: MessageChannelSyncStatus!
syncStage: MessageChannelSyncStage!
syncStageStartedAt: DateTime
throttleFailureCount: Float!
throttleRetryAfter: DateTime
connectedAccountId: UUID!
createdAt: DateTime!
updatedAt: DateTime!
}
enum MessageChannelVisibility {
METADATA
SUBJECT
SHARE_EVERYTHING
}
enum MessageChannelType {
EMAIL
SMS
}
enum MessageChannelContactAutoCreationPolicy {
SENT_AND_RECEIVED
SENT
NONE
}
enum MessageFolderImportPolicy {
ALL_FOLDERS
SELECTED_FOLDERS
}
enum MessageChannelPendingGroupEmailsAction {
GROUP_EMAILS_DELETION
GROUP_EMAILS_IMPORT
NONE
}
enum MessageChannelSyncStatus {
NOT_SYNCED
ONGOING
ACTIVE
FAILED_INSUFFICIENT_PERMISSIONS
FAILED_UNKNOWN
}
enum MessageChannelSyncStage {
PENDING_CONFIGURATION
MESSAGE_LIST_FETCH_PENDING
MESSAGE_LIST_FETCH_SCHEDULED
MESSAGE_LIST_FETCH_ONGOING
MESSAGES_IMPORT_PENDING
MESSAGES_IMPORT_SCHEDULED
MESSAGES_IMPORT_ONGOING
FAILED
}
type MessageFolder {
id: UUID!
name: String
isSentFolder: Boolean!
isSynced: Boolean!
parentFolderId: UUID
externalId: String
pendingSyncAction: MessageFolderPendingSyncAction!
messageChannelId: UUID!
createdAt: DateTime!
updatedAt: DateTime!
}
enum MessageFolderPendingSyncAction {
FOLDER_DELETION
NONE
}
type CollectionHash {
collectionName: AllMetadataName!
hash: String!
@@ -3018,6 +3162,11 @@ type Query {
findWorkspaceFromInviteHash(inviteHash: String!): Workspace!
validatePasswordResetToken(passwordResetToken: String!): ValidatePasswordResetToken!
getSSOIdentityProviders: [FindAvailableSSOIDP!]!
myMessageFolders(messageChannelId: UUID): [MessageFolder!]!
myMessageChannels(connectedAccountId: UUID): [MessageChannel!]!
myConnectedAccounts: [ConnectedAccountDTO!]!
connectedAccounts: [ConnectedAccountDTO!]!
myCalendarChannels(connectedAccountId: UUID): [CalendarChannel!]!
webhooks: [Webhook!]!
webhook(id: UUID!): Webhook
minimalMetadata: MinimalMetadata!
@@ -3315,6 +3464,11 @@ type Mutation {
createSAMLIdentityProvider(input: SetupSAMLSsoInput!): SetupSso!
deleteSSOIdentityProvider(input: DeleteSsoInput!): DeleteSso!
editSSOIdentityProvider(input: EditSsoInput!): EditSso!
updateMessageFolder(input: UpdateMessageFolderInput!): MessageFolder!
updateMessageFolders(input: UpdateMessageFoldersInput!): [MessageFolder!]!
updateMessageChannel(input: UpdateMessageChannelInput!): MessageChannel!
deleteConnectedAccount(id: UUID!): ConnectedAccountDTO!
updateCalendarChannel(input: UpdateCalendarChannelInput!): CalendarChannel!
createWebhook(input: CreateWebhookInput!): Webhook!
updateWebhook(input: UpdateWebhookInput!): Webhook!
deleteWebhook(id: UUID!): Webhook!
@@ -4211,6 +4365,47 @@ input EditSsoInput {
status: SSOIdentityProviderStatus!
}
input UpdateMessageFolderInput {
id: UUID!
update: UpdateMessageFolderInputUpdates!
}
input UpdateMessageFolderInputUpdates {
isSynced: Boolean
}
input UpdateMessageFoldersInput {
ids: [UUID!]!
update: UpdateMessageFolderInputUpdates!
}
input UpdateMessageChannelInput {
id: UUID!
update: UpdateMessageChannelInputUpdates!
}
input UpdateMessageChannelInputUpdates {
visibility: MessageChannelVisibility
isContactAutoCreationEnabled: Boolean
contactAutoCreationPolicy: MessageChannelContactAutoCreationPolicy
messageFolderImportPolicy: MessageFolderImportPolicy
isSyncEnabled: Boolean
excludeNonProfessionalEmails: Boolean
excludeGroupEmails: Boolean
}
input UpdateCalendarChannelInput {
id: UUID!
update: UpdateCalendarChannelInputUpdates!
}
input UpdateCalendarChannelInputUpdates {
visibility: CalendarChannelVisibility
isContactAutoCreationEnabled: Boolean
contactAutoCreationPolicy: CalendarChannelContactAutoCreationPolicy
isSyncEnabled: Boolean
}
input CreateWebhookInput {
id: UUID
targetUrl: String!
@@ -4316,17 +4511,10 @@ enum FileFolder {
}
type Subscription {
onDbEvent(input: OnDbEventInput!): OnDbEvent!
onEventSubscription(eventStreamId: String!): EventSubscription
logicFunctionLogs(input: LogicFunctionLogsInput!): LogicFunctionLogs!
}
input OnDbEventInput {
action: DatabaseEventAction
objectNameSingular: String
recordId: UUID
}
input LogicFunctionLogsInput {
applicationId: UUID
applicationUniversalIdentifier: UUID
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1
View File
@@ -23,5 +23,6 @@ declare module 'express-serve-static-core' {
userWorkspaceId?: string;
authProvider?: AuthProviderEnum | null;
impersonationContext?: RawAuthContext['impersonationContext'];
skipWorkspaceSchemaCreation?: boolean;
}
}
@@ -55,27 +55,56 @@ const FIRST_NON_TS_VECTOR_UNIVERSAL_IDENTIFIER =
const DUPLICATE_KEY_MESSAGE = 'duplicate key value violates unique constraint';
const getNestedErrorMessages = (
error: WorkspaceMigrationRunnerException,
): string | undefined => {
const nestedErrors = error.errors;
if (!isDefined(nestedErrors)) {
return undefined;
}
const details = [
nestedErrors.metadata && `metadata: ${nestedErrors.metadata.message}`,
nestedErrors.workspaceSchema &&
`workspaceSchema: ${nestedErrors.workspaceSchema.message}`,
nestedErrors.actionTranspilation &&
`actionTranspilation: ${nestedErrors.actionTranspilation.message}`,
]
.filter(isDefined)
.join('; ');
return details || undefined;
};
const isUniqueViolationError = (error: Error): boolean => {
if (error.message.includes(DUPLICATE_KEY_MESSAGE)) {
return true;
}
if (error instanceof WorkspaceMigrationRunnerException) {
const nestedErrors = [
error.errors?.metadata,
error.errors?.workspaceSchema,
error.errors?.actionTranspilation,
];
const nestedMessages = getNestedErrorMessages(error);
return nestedErrors.some(
(nestedError) =>
nestedError?.message?.includes(DUPLICATE_KEY_MESSAGE) === true,
);
return nestedMessages?.includes(DUPLICATE_KEY_MESSAGE) === true;
}
return false;
};
const enrichErrorMessage = (error: Error): Error => {
if (!(error instanceof WorkspaceMigrationRunnerException)) {
return error;
}
const details = getNestedErrorMessages(error);
if (isDefined(details)) {
error.message = `${error.message} (${details})`;
}
return error;
};
@Command({
name: 'upgrade:1-19:add-missing-system-fields-to-standard-objects',
description:
@@ -123,7 +152,7 @@ export class AddMissingSystemFieldsToStandardObjectsCommand extends ActiveOrSusp
});
} catch (error) {
if (!isUniqueViolationError(error)) {
throw error;
throw enrichErrorMessage(error);
}
this.logger.warn(
@@ -182,7 +211,7 @@ export class AddMissingSystemFieldsToStandardObjectsCommand extends ActiveOrSusp
});
} catch (error) {
if (!isUniqueViolationError(error)) {
throw error;
throw enrichErrorMessage(error);
}
this.logger.warn(
@@ -0,0 +1,99 @@
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
import { isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid';
import { DataSource, Repository } from 'typeorm';
import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
@Command({
name: 'upgrade:1-20:backfill-select-field-option-ids',
description:
'Backfill missing ids on SELECT and MULTI_SELECT field metadata options',
})
export class BackfillSelectFieldOptionIdsCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
constructor(
@InjectRepository(WorkspaceEntity)
protected readonly workspaceRepository: Repository<WorkspaceEntity>,
@InjectDataSource()
private readonly coreDataSource: DataSource,
protected readonly twentyORMGlobalManager: GlobalWorkspaceOrmManager,
protected readonly dataSourceService: DataSourceService,
) {
super(workspaceRepository, twentyORMGlobalManager, dataSourceService);
}
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
const dryRun = options?.dryRun ?? false;
const selectFields: { id: string; options: Record<string, unknown>[] }[] =
await this.coreDataSource.query(
`SELECT "id", "options"
FROM core."fieldMetadata"
WHERE "workspaceId" = $1
AND "type" IN ('SELECT', 'MULTI_SELECT')
AND "options" IS NOT NULL`,
[workspaceId],
);
const fieldsToUpdate = selectFields.filter((field) =>
field.options.some((option) => !isDefined(option.id)),
);
if (fieldsToUpdate.length === 0) {
this.logger.log(
`No SELECT/MULTI_SELECT options missing ids in workspace ${workspaceId}`,
);
return;
}
this.logger.log(
`${dryRun ? '[DRY RUN] ' : ''}Found ${fieldsToUpdate.length} field(s) with options missing ids in workspace ${workspaceId}`,
);
if (dryRun) {
return;
}
const queryRunner = this.coreDataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
for (const field of fieldsToUpdate) {
const patchedOptions = field.options.map((option) => ({
...option,
id: isDefined(option.id) ? option.id : v4(),
}));
await queryRunner.query(
`UPDATE core."fieldMetadata"
SET "options" = $1::jsonb
WHERE "id" = $2`,
[JSON.stringify(patchedOptions), field.id],
);
}
await queryRunner.commitTransaction();
this.logger.log(
`Backfilled option ids for ${fieldsToUpdate.length} field(s) in workspace ${workspaceId}`,
);
} catch (error) {
await queryRunner.rollbackTransaction();
throw error;
} finally {
await queryRunner.release();
}
}
}
@@ -0,0 +1,91 @@
import { Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
import { In, Repository } from 'typeorm';
import { isDefined } from 'twenty-shared/utils';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { NavigationMenuItemEntity } from 'src/engine/metadata-modules/navigation-menu-item/entities/navigation-menu-item.entity';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
@Command({
name: 'upgrade:1-20:delete-orphan-navigation-menu-items',
description: 'Delete navigation menu items pointing to deleted views',
})
export class DeleteOrphanNavigationMenuItemsCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
protected readonly logger = new Logger(
DeleteOrphanNavigationMenuItemsCommand.name,
);
constructor(
@InjectRepository(WorkspaceEntity)
protected readonly workspaceRepository: Repository<WorkspaceEntity>,
protected readonly twentyORMGlobalManager: GlobalWorkspaceOrmManager,
protected readonly dataSourceService: DataSourceService,
@InjectRepository(NavigationMenuItemEntity)
private readonly navigationMenuItemRepository: Repository<NavigationMenuItemEntity>,
private readonly workspaceCacheService: WorkspaceCacheService,
) {
super(workspaceRepository, twentyORMGlobalManager, dataSourceService);
}
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
const { flatViewMaps, flatNavigationMenuItemMaps } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatViewMaps',
'flatNavigationMenuItemMaps',
]);
const activeViewIds = new Set(
Object.values(flatViewMaps.byUniversalIdentifier)
.filter((view): view is NonNullable<typeof view> => isDefined(view))
.filter((view) => view.deletedAt === null)
.map((view) => view.id),
);
const orphanViewNavigationMenuItemIds = Object.values(
flatNavigationMenuItemMaps.byUniversalIdentifier,
)
.filter(
(item): item is NonNullable<typeof item> =>
isDefined(item) &&
item.type === NavigationMenuItemType.VIEW &&
isDefined(item.viewId) &&
!activeViewIds.has(item.viewId),
)
.map((item) => item.id);
if (orphanViewNavigationMenuItemIds.length === 0) {
return;
}
if (options.dryRun) {
this.logger.log(
`[DRY RUN] Would delete ${orphanViewNavigationMenuItemIds.length} orphan navigation menu item(s) for workspace ${workspaceId}`,
);
return;
}
await this.navigationMenuItemRepository.delete({
workspaceId,
id: In(orphanViewNavigationMenuItemIds),
});
this.logger.log(
`Deleted ${orphanViewNavigationMenuItemIds.length} orphan navigation menu item(s) for workspace ${workspaceId}`,
);
await this.workspaceCacheService.flush(workspaceId, [
'flatNavigationMenuItemMaps',
]);
}
}
@@ -0,0 +1,400 @@
import { InjectRepository } from '@nestjs/typeorm';
import { isNonEmptyString } from '@sniptt/guards';
import { Command } from 'nest-commander';
import { FeatureFlagKey } from 'twenty-shared/types';
import { Repository } from 'typeorm';
import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
import { MessageFolderEntity } from 'src/engine/metadata-modules/message-folder/entities/message-folder.entity';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { type CalendarChannelWorkspaceEntity } from 'src/modules/calendar/common/standard-objects/calendar-channel.workspace-entity';
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
import { type MessageChannelWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
import { type MessageFolderWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-folder.workspace-entity';
import { type WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
@Command({
name: 'upgrade:1-20:migrate-messaging-infrastructure-to-metadata',
description:
'Backfill connectedAccount, messageChannel, calendarChannel, and messageFolder to core metadata schema',
})
export class MigrateMessagingInfrastructureToMetadataCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
constructor(
@InjectRepository(WorkspaceEntity)
protected readonly workspaceRepository: Repository<WorkspaceEntity>,
protected readonly twentyORMGlobalManager: GlobalWorkspaceOrmManager,
protected readonly dataSourceService: DataSourceService,
@InjectRepository(ConnectedAccountEntity)
private readonly connectedAccountRepository: Repository<ConnectedAccountEntity>,
@InjectRepository(MessageChannelEntity)
private readonly messageChannelRepository: Repository<MessageChannelEntity>,
@InjectRepository(CalendarChannelEntity)
private readonly calendarChannelRepository: Repository<CalendarChannelEntity>,
@InjectRepository(MessageFolderEntity)
private readonly messageFolderRepository: Repository<MessageFolderEntity>,
@InjectRepository(UserWorkspaceEntity)
private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>,
private readonly featureFlagService: FeatureFlagService,
) {
super(workspaceRepository, twentyORMGlobalManager, dataSourceService);
}
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
const isDryRun = options.dryRun ?? false;
const isAlreadyMigrated = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED,
workspaceId,
);
if (isAlreadyMigrated) {
this.logger.log(
`IS_CONNECTED_ACCOUNT_MIGRATED already enabled for workspace ${workspaceId}, skipping`,
);
return;
}
const connectedAccountWorkspaceRepository =
await this.twentyORMGlobalManager.getRepository<ConnectedAccountWorkspaceEntity>(
workspaceId,
'connectedAccount',
);
const messageChannelWorkspaceRepository =
await this.twentyORMGlobalManager.getRepository<MessageChannelWorkspaceEntity>(
workspaceId,
'messageChannel',
);
const calendarChannelWorkspaceRepository =
await this.twentyORMGlobalManager.getRepository<CalendarChannelWorkspaceEntity>(
workspaceId,
'calendarChannel',
);
const messageFolderWorkspaceRepository =
await this.twentyORMGlobalManager.getRepository<MessageFolderWorkspaceEntity>(
workspaceId,
'messageFolder',
);
const connectedAccounts = await connectedAccountWorkspaceRepository.find();
const messageChannels = await messageChannelWorkspaceRepository.find();
const calendarChannels = await calendarChannelWorkspaceRepository.find();
const messageFolders = await messageFolderWorkspaceRepository.find();
const workspaceMemberIdToUserWorkspaceIdMap =
await this.buildWorkspaceMemberIdToUserWorkspaceIdMap(workspaceId);
const connectedAccountsWithMissingHandle = connectedAccounts.filter(
(account) => !account.handle,
);
const connectedAccountsWithUnresolvedOwner = connectedAccounts.filter(
(account) =>
!workspaceMemberIdToUserWorkspaceIdMap.has(account.accountOwnerId),
);
const messageChannelsWithMissingHandle = messageChannels.filter(
(channel) => !channel.handle,
);
const calendarChannelsWithMissingHandle = calendarChannels.filter(
(channel) => !channel.handle,
);
if (isDryRun) {
this.logger.log(
`[DRY RUN] Workspace ${workspaceId}: ` +
`${connectedAccounts.length} connected accounts, ` +
`${messageChannels.length} message channels, ` +
`${calendarChannels.length} calendar channels, ` +
`${messageFolders.length} message folders`,
);
if (connectedAccountsWithMissingHandle.length > 0) {
this.logger.warn(
`[DRY RUN] ${connectedAccountsWithMissingHandle.length} connected accounts have empty handle`,
);
}
if (connectedAccountsWithUnresolvedOwner.length > 0) {
this.logger.warn(
`[DRY RUN] ${connectedAccountsWithUnresolvedOwner.length} connected accounts have unresolvable accountOwnerId (no matching userWorkspace)`,
);
}
if (messageChannelsWithMissingHandle.length > 0) {
this.logger.warn(
`[DRY RUN] ${messageChannelsWithMissingHandle.length} message channels have empty handle`,
);
}
if (calendarChannelsWithMissingHandle.length > 0) {
this.logger.warn(
`[DRY RUN] ${calendarChannelsWithMissingHandle.length} calendar channels have empty handle`,
);
}
return;
}
let migratedConnectedAccountIds = new Set(
connectedAccounts.map((account) => account.id),
);
let migratedMessageChannelIds = new Set(
messageChannels.map((channel) => channel.id),
);
if (connectedAccounts.length > 0) {
const coreConnectedAccounts = connectedAccounts
.filter((workspaceEntity) => {
const userWorkspaceId = workspaceMemberIdToUserWorkspaceIdMap.get(
workspaceEntity.accountOwnerId,
);
if (!userWorkspaceId) {
this.logger.warn(
`Skipping connected account ${workspaceEntity.id}: no userWorkspace found for workspaceMember ${workspaceEntity.accountOwnerId}`,
);
return false;
}
return true;
})
.map((workspaceEntity) => {
const handleAliases = isNonEmptyString(workspaceEntity.handleAliases)
? workspaceEntity.handleAliases
.split(',')
.map((alias) => alias.trim())
: null;
return {
id: workspaceEntity.id,
handle: workspaceEntity.handle ?? '',
provider: workspaceEntity.provider,
accessToken: workspaceEntity.accessToken,
refreshToken: workspaceEntity.refreshToken,
lastCredentialsRefreshedAt:
workspaceEntity.lastCredentialsRefreshedAt,
authFailedAt: workspaceEntity.authFailedAt,
handleAliases,
scopes: workspaceEntity.scopes,
connectionParameters:
workspaceEntity.connectionParameters as Record<
string,
unknown
> | null,
userWorkspaceId: workspaceMemberIdToUserWorkspaceIdMap.get(
workspaceEntity.accountOwnerId,
)!,
workspaceId,
createdAt: workspaceEntity.createdAt,
updatedAt: workspaceEntity.updatedAt,
};
});
if (coreConnectedAccounts.length > 0) {
await this.connectedAccountRepository.save(
coreConnectedAccounts as unknown as ConnectedAccountEntity[],
);
this.logger.log(
`Migrated ${coreConnectedAccounts.length} connected accounts for workspace ${workspaceId}`,
);
}
migratedConnectedAccountIds = new Set(
coreConnectedAccounts.map((account) => account.id),
);
}
if (messageChannels.length > 0) {
const coreMessageChannels = messageChannels
.filter((workspaceEntity) =>
migratedConnectedAccountIds.has(workspaceEntity.connectedAccountId),
)
.map((workspaceEntity) => ({
id: workspaceEntity.id,
visibility: workspaceEntity.visibility,
handle: workspaceEntity.handle ?? '',
type: workspaceEntity.type,
isContactAutoCreationEnabled:
workspaceEntity.isContactAutoCreationEnabled,
contactAutoCreationPolicy: workspaceEntity.contactAutoCreationPolicy,
messageFolderImportPolicy: workspaceEntity.messageFolderImportPolicy,
excludeNonProfessionalEmails:
workspaceEntity.excludeNonProfessionalEmails,
excludeGroupEmails: workspaceEntity.excludeGroupEmails,
pendingGroupEmailsAction: workspaceEntity.pendingGroupEmailsAction,
isSyncEnabled: workspaceEntity.isSyncEnabled,
syncCursor: workspaceEntity.syncCursor,
syncedAt: workspaceEntity.syncedAt
? new Date(workspaceEntity.syncedAt)
: null,
syncStatus: workspaceEntity.syncStatus ?? 'NOT_SYNCED',
syncStage: workspaceEntity.syncStage,
syncStageStartedAt: workspaceEntity.syncStageStartedAt
? new Date(workspaceEntity.syncStageStartedAt)
: null,
throttleFailureCount: workspaceEntity.throttleFailureCount,
throttleRetryAfter: workspaceEntity.throttleRetryAfter
? new Date(workspaceEntity.throttleRetryAfter)
: null,
connectedAccountId: workspaceEntity.connectedAccountId,
workspaceId,
createdAt: workspaceEntity.createdAt,
updatedAt: workspaceEntity.updatedAt,
}));
if (coreMessageChannels.length > 0) {
await this.messageChannelRepository.save(
coreMessageChannels as unknown as MessageChannelEntity[],
);
this.logger.log(
`Migrated ${coreMessageChannels.length} message channels for workspace ${workspaceId}`,
);
}
migratedMessageChannelIds = new Set(
coreMessageChannels.map((channel) => channel.id),
);
}
if (calendarChannels.length > 0) {
const coreCalendarChannels = calendarChannels
.filter((workspaceEntity) =>
migratedConnectedAccountIds.has(workspaceEntity.connectedAccountId),
)
.map((workspaceEntity) => ({
id: workspaceEntity.id,
handle: workspaceEntity.handle ?? '',
syncStatus: workspaceEntity.syncStatus ?? 'NOT_SYNCED',
syncStage: workspaceEntity.syncStage,
visibility: workspaceEntity.visibility,
isContactAutoCreationEnabled:
workspaceEntity.isContactAutoCreationEnabled,
contactAutoCreationPolicy: workspaceEntity.contactAutoCreationPolicy,
isSyncEnabled: workspaceEntity.isSyncEnabled,
syncCursor: workspaceEntity.syncCursor,
syncedAt: workspaceEntity.syncedAt
? new Date(workspaceEntity.syncedAt)
: null,
syncStageStartedAt: workspaceEntity.syncStageStartedAt
? new Date(workspaceEntity.syncStageStartedAt)
: null,
throttleFailureCount: workspaceEntity.throttleFailureCount,
connectedAccountId: workspaceEntity.connectedAccountId,
workspaceId,
createdAt: workspaceEntity.createdAt,
updatedAt: workspaceEntity.updatedAt,
}));
await this.calendarChannelRepository.save(
coreCalendarChannels as unknown as CalendarChannelEntity[],
);
this.logger.log(
`Migrated ${coreCalendarChannels.length} calendar channels for workspace ${workspaceId}`,
);
}
if (messageFolders.length > 0) {
const externalIdToFolderIdMap = new Map(
messageFolders.map((folder) => [folder.externalId, folder.id]),
);
const coreMessageFolders = messageFolders
.filter((workspaceEntity) =>
migratedMessageChannelIds.has(workspaceEntity.messageChannelId),
)
.map((workspaceEntity) => {
let resolvedParentFolderId: string | null = null;
if (isNonEmptyString(workspaceEntity.parentFolderId)) {
resolvedParentFolderId =
externalIdToFolderIdMap.get(workspaceEntity.parentFolderId) ??
null;
if (!resolvedParentFolderId) {
this.logger.warn(
`Message folder ${workspaceEntity.id}: could not resolve parentFolderId externalId "${workspaceEntity.parentFolderId}" to a UUID`,
);
}
}
return {
id: workspaceEntity.id,
name: workspaceEntity.name,
syncCursor: workspaceEntity.syncCursor,
isSentFolder: workspaceEntity.isSentFolder,
isSynced: workspaceEntity.isSynced,
parentFolderId: resolvedParentFolderId,
externalId: workspaceEntity.externalId,
pendingSyncAction: workspaceEntity.pendingSyncAction,
messageChannelId: workspaceEntity.messageChannelId,
workspaceId,
createdAt: workspaceEntity.createdAt,
updatedAt: workspaceEntity.updatedAt,
};
});
await this.messageFolderRepository.save(
coreMessageFolders as unknown as MessageFolderEntity[],
);
this.logger.log(
`Migrated ${coreMessageFolders.length} message folders for workspace ${workspaceId}`,
);
}
await this.featureFlagService.enableFeatureFlags(
[FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED],
workspaceId,
);
this.logger.log(
`Enabled IS_CONNECTED_ACCOUNT_MIGRATED for workspace ${workspaceId}`,
);
}
private async buildWorkspaceMemberIdToUserWorkspaceIdMap(
workspaceId: string,
): Promise<Map<string, string>> {
const workspaceMemberRepository =
await this.twentyORMGlobalManager.getRepository<WorkspaceMemberWorkspaceEntity>(
workspaceId,
'workspaceMember',
);
const workspaceMembers = await workspaceMemberRepository.find();
const userWorkspaces = await this.userWorkspaceRepository.find({
where: { workspaceId },
select: ['id', 'userId'],
});
const userWorkspaceIdByUserId = new Map(
userWorkspaces.map((userWorkspace) => [
userWorkspace.userId,
userWorkspace.id,
]),
);
return new Map(
workspaceMembers
.filter((member) => userWorkspaceIdByUserId.has(member.userId))
.map((member) => [
member.id,
userWorkspaceIdByUserId.get(member.userId)!,
]),
);
}
}
@@ -4,17 +4,26 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { BackfillCommandMenuItemsCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-backfill-command-menu-items.command';
import { BackfillNavigationMenuItemTypeCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-backfill-navigation-menu-item-type.command';
import { BackfillPageLayoutsCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-backfill-page-layouts.command';
import { BackfillSelectFieldOptionIdsCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-backfill-select-field-option-ids.command';
import { DeleteOrphanNavigationMenuItemsCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-delete-orphan-navigation-menu-items.command';
import { IdentifyObjectPermissionMetadataCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-identify-object-permission-metadata.command';
import { IdentifyPermissionFlagMetadataCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-identify-permission-flag-metadata.command';
import { MakeObjectPermissionUniversalIdentifierAndApplicationIdNotNullableMigrationCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-make-object-permission-universal-identifier-and-application-id-not-nullable-migration.command';
import { MakePermissionFlagUniversalIdentifierAndApplicationIdNotNullableMigrationCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-make-permission-flag-universal-identifier-and-application-id-not-nullable-migration.command';
import { MigrateMessagingInfrastructureToMetadataCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-migrate-messaging-infrastructure-to-metadata.command';
import { MigrateRichTextToTextCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-migrate-rich-text-to-text.command';
import { SeedCliApplicationRegistrationCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-seed-cli-application-registration.command';
import { ApplicationRegistrationModule } from 'src/engine/core-modules/application/application-registration/application-registration.module';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
import { MessageFolderEntity } from 'src/engine/metadata-modules/message-folder/entities/message-folder.entity';
import { NavigationMenuItemEntity } from 'src/engine/metadata-modules/navigation-menu-item/entities/navigation-menu-item.entity';
import { WorkspaceMetadataVersionModule } from 'src/engine/metadata-modules/workspace-metadata-version/workspace-metadata-version.module';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
@@ -23,7 +32,15 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
@Module({
imports: [
TypeOrmModule.forFeature([WorkspaceEntity]),
TypeOrmModule.forFeature([
WorkspaceEntity,
ConnectedAccountEntity,
MessageChannelEntity,
CalendarChannelEntity,
MessageFolderEntity,
UserWorkspaceEntity,
NavigationMenuItemEntity,
]),
DataSourceModule,
WorkspaceCacheModule,
WorkspaceCacheStorageModule,
@@ -42,8 +59,11 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
BackfillCommandMenuItemsCommand,
BackfillNavigationMenuItemTypeCommand,
BackfillPageLayoutsCommand,
BackfillSelectFieldOptionIdsCommand,
DeleteOrphanNavigationMenuItemsCommand,
SeedCliApplicationRegistrationCommand,
MigrateRichTextToTextCommand,
MigrateMessagingInfrastructureToMetadataCommand,
],
exports: [
IdentifyPermissionFlagMetadataCommand,
@@ -53,8 +73,11 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
BackfillCommandMenuItemsCommand,
BackfillNavigationMenuItemTypeCommand,
BackfillPageLayoutsCommand,
BackfillSelectFieldOptionIdsCommand,
DeleteOrphanNavigationMenuItemsCommand,
SeedCliApplicationRegistrationCommand,
MigrateRichTextToTextCommand,
MigrateMessagingInfrastructureToMetadataCommand,
],
})
export class V1_20_UpgradeVersionCommandModule {}
@@ -35,11 +35,14 @@ import { FixInvalidStandardUniversalIdentifiersCommand } from 'src/database/comm
import { SeedServerIdCommand } from 'src/database/commands/upgrade-version-command/1-19/1-19-seed-server-id.command';
import { BackfillCommandMenuItemsCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-backfill-command-menu-items.command';
import { BackfillNavigationMenuItemTypeCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-backfill-navigation-menu-item-type.command';
import { DeleteOrphanNavigationMenuItemsCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-delete-orphan-navigation-menu-items.command';
import { BackfillPageLayoutsCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-backfill-page-layouts.command';
import { BackfillSelectFieldOptionIdsCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-backfill-select-field-option-ids.command';
import { IdentifyObjectPermissionMetadataCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-identify-object-permission-metadata.command';
import { IdentifyPermissionFlagMetadataCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-identify-permission-flag-metadata.command';
import { MakeObjectPermissionUniversalIdentifierAndApplicationIdNotNullableMigrationCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-make-object-permission-universal-identifier-and-application-id-not-nullable-migration.command';
import { MakePermissionFlagUniversalIdentifierAndApplicationIdNotNullableMigrationCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-make-permission-flag-universal-identifier-and-application-id-not-nullable-migration.command';
import { MigrateMessagingInfrastructureToMetadataCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-migrate-messaging-infrastructure-to-metadata.command';
import { MigrateRichTextToTextCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-migrate-rich-text-to-text.command';
import { SeedCliApplicationRegistrationCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-seed-cli-application-registration.command';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@@ -98,9 +101,12 @@ export class UpgradeCommand extends UpgradeCommandRunner {
protected readonly makeObjectPermissionUniversalIdentifierAndApplicationIdNotNullableMigrationCommand: MakeObjectPermissionUniversalIdentifierAndApplicationIdNotNullableMigrationCommand,
protected readonly backfillNavigationMenuItemTypeCommand: BackfillNavigationMenuItemTypeCommand,
protected readonly backfillCommandMenuItemsCommand: BackfillCommandMenuItemsCommand,
protected readonly deleteOrphanNavigationMenuItemsCommand: DeleteOrphanNavigationMenuItemsCommand,
protected readonly backfillPageLayoutsCommand: BackfillPageLayoutsCommand,
protected readonly seedCliApplicationRegistrationCommand: SeedCliApplicationRegistrationCommand,
protected readonly migrateRichTextToTextCommand: MigrateRichTextToTextCommand,
protected readonly migrateMessagingInfrastructureToMetadataCommand: MigrateMessagingInfrastructureToMetadataCommand,
protected readonly backfillSelectFieldOptionIdsCommand: BackfillSelectFieldOptionIdsCommand,
) {
super(
workspaceRepository,
@@ -156,9 +162,12 @@ export class UpgradeCommand extends UpgradeCommandRunner {
.makeObjectPermissionUniversalIdentifierAndApplicationIdNotNullableMigrationCommand,
this.backfillNavigationMenuItemTypeCommand,
this.migrateRichTextToTextCommand,
this.deleteOrphanNavigationMenuItemsCommand,
this.backfillCommandMenuItemsCommand,
this.backfillPageLayoutsCommand,
this.seedCliApplicationRegistrationCommand,
this.migrateMessagingInfrastructureToMetadataCommand,
this.backfillSelectFieldOptionIdsCommand,
];
this.allCommands = {
@@ -0,0 +1,139 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddMessagingInfrastructureMetadataEntities1773945207801
implements MigrationInterface
{
name = 'AddMessagingInfrastructureMetadataEntities1773945207801';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "core"."connectedAccount" ("workspaceId" uuid NOT NULL, "id" uuid NOT NULL DEFAULT uuid_generate_v4(), "handle" character varying NOT NULL, "provider" character varying NOT NULL, "accessToken" character varying, "refreshToken" character varying, "lastCredentialsRefreshedAt" TIMESTAMP WITH TIME ZONE, "authFailedAt" TIMESTAMP WITH TIME ZONE, "handleAliases" character varying array, "scopes" character varying array, "connectionParameters" jsonb, "lastSignedInAt" TIMESTAMP WITH TIME ZONE, "oidcTokenClaims" jsonb, "userWorkspaceId" uuid NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_8e7a0a0bbc2e06ac2acf89b7f3a" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE TYPE "core"."messageChannel_visibility_enum" AS ENUM('METADATA', 'SUBJECT', 'SHARE_EVERYTHING')`,
);
await queryRunner.query(
`CREATE TYPE "core"."messageChannel_type_enum" AS ENUM('EMAIL', 'SMS')`,
);
await queryRunner.query(
`CREATE TYPE "core"."messageChannel_contactautocreationpolicy_enum" AS ENUM('SENT_AND_RECEIVED', 'SENT', 'NONE')`,
);
await queryRunner.query(
`CREATE TYPE "core"."messageChannel_messagefolderimportpolicy_enum" AS ENUM('ALL_FOLDERS', 'SELECTED_FOLDERS')`,
);
await queryRunner.query(
`CREATE TYPE "core"."messageChannel_pendinggroupemailsaction_enum" AS ENUM('GROUP_EMAILS_DELETION', 'GROUP_EMAILS_IMPORT', 'NONE')`,
);
await queryRunner.query(
`CREATE TYPE "core"."messageChannel_syncstatus_enum" AS ENUM('NOT_SYNCED', 'ONGOING', 'ACTIVE', 'FAILED_INSUFFICIENT_PERMISSIONS', 'FAILED_UNKNOWN')`,
);
await queryRunner.query(
`CREATE TYPE "core"."messageChannel_syncstage_enum" AS ENUM('PENDING_CONFIGURATION', 'MESSAGE_LIST_FETCH_PENDING', 'MESSAGE_LIST_FETCH_SCHEDULED', 'MESSAGE_LIST_FETCH_ONGOING', 'MESSAGES_IMPORT_PENDING', 'MESSAGES_IMPORT_SCHEDULED', 'MESSAGES_IMPORT_ONGOING', 'FAILED')`,
);
await queryRunner.query(
`CREATE TABLE "core"."messageChannel" ("workspaceId" uuid NOT NULL, "id" uuid NOT NULL DEFAULT uuid_generate_v4(), "visibility" "core"."messageChannel_visibility_enum" NOT NULL, "handle" character varying NOT NULL, "type" "core"."messageChannel_type_enum" NOT NULL, "isContactAutoCreationEnabled" boolean NOT NULL DEFAULT true, "contactAutoCreationPolicy" "core"."messageChannel_contactautocreationpolicy_enum" NOT NULL DEFAULT 'SENT', "messageFolderImportPolicy" "core"."messageChannel_messagefolderimportpolicy_enum" NOT NULL DEFAULT 'ALL_FOLDERS', "excludeNonProfessionalEmails" boolean NOT NULL DEFAULT true, "excludeGroupEmails" boolean NOT NULL DEFAULT true, "pendingGroupEmailsAction" "core"."messageChannel_pendinggroupemailsaction_enum" NOT NULL, "isSyncEnabled" boolean NOT NULL DEFAULT true, "syncCursor" character varying, "syncedAt" TIMESTAMP WITH TIME ZONE, "syncStatus" "core"."messageChannel_syncstatus_enum" NOT NULL DEFAULT 'NOT_SYNCED', "syncStage" "core"."messageChannel_syncstage_enum" NOT NULL, "syncStageStartedAt" TIMESTAMP WITH TIME ZONE, "throttleFailureCount" integer NOT NULL DEFAULT '0', "throttleRetryAfter" TIMESTAMP WITH TIME ZONE, "connectedAccountId" uuid NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_438b9412475f39712ed065f77af" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE TYPE "core"."messageFolder_pendingsyncaction_enum" AS ENUM('FOLDER_DELETION', 'NONE')`,
);
await queryRunner.query(
`CREATE TABLE "core"."messageFolder" ("workspaceId" uuid NOT NULL, "id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying, "syncCursor" character varying, "isSentFolder" boolean NOT NULL, "isSynced" boolean NOT NULL, "parentFolderId" uuid, "externalId" character varying, "pendingSyncAction" "core"."messageFolder_pendingsyncaction_enum" NOT NULL DEFAULT 'NONE', "messageChannelId" uuid NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_85cb5a339d9f7f1106dde9e4db8" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE TYPE "core"."calendarChannel_syncstatus_enum" AS ENUM('NOT_SYNCED', 'ONGOING', 'ACTIVE', 'FAILED_INSUFFICIENT_PERMISSIONS', 'FAILED_UNKNOWN')`,
);
await queryRunner.query(
`CREATE TYPE "core"."calendarChannel_syncstage_enum" AS ENUM('PENDING_CONFIGURATION', 'CALENDAR_EVENT_LIST_FETCH_PENDING', 'CALENDAR_EVENT_LIST_FETCH_SCHEDULED', 'CALENDAR_EVENT_LIST_FETCH_ONGOING', 'CALENDAR_EVENTS_IMPORT_PENDING', 'CALENDAR_EVENTS_IMPORT_SCHEDULED', 'CALENDAR_EVENTS_IMPORT_ONGOING', 'FAILED')`,
);
await queryRunner.query(
`CREATE TYPE "core"."calendarChannel_visibility_enum" AS ENUM('METADATA', 'SHARE_EVERYTHING')`,
);
await queryRunner.query(
`CREATE TYPE "core"."calendarChannel_contactautocreationpolicy_enum" AS ENUM('AS_PARTICIPANT_AND_ORGANIZER', 'AS_PARTICIPANT', 'AS_ORGANIZER', 'NONE')`,
);
await queryRunner.query(
`CREATE TABLE "core"."calendarChannel" ("workspaceId" uuid NOT NULL, "id" uuid NOT NULL DEFAULT uuid_generate_v4(), "handle" character varying NOT NULL, "syncStatus" "core"."calendarChannel_syncstatus_enum" NOT NULL DEFAULT 'NOT_SYNCED', "syncStage" "core"."calendarChannel_syncstage_enum" NOT NULL, "visibility" "core"."calendarChannel_visibility_enum" NOT NULL, "isContactAutoCreationEnabled" boolean NOT NULL DEFAULT true, "contactAutoCreationPolicy" "core"."calendarChannel_contactautocreationpolicy_enum" NOT NULL DEFAULT 'AS_PARTICIPANT_AND_ORGANIZER', "isSyncEnabled" boolean NOT NULL DEFAULT true, "syncCursor" character varying, "syncedAt" TIMESTAMP WITH TIME ZONE, "syncStageStartedAt" TIMESTAMP WITH TIME ZONE, "throttleFailureCount" integer NOT NULL DEFAULT '0', "connectedAccountId" uuid NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_796d701c0c35518517d0f3e0e0b" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`ALTER TABLE "core"."connectedAccount" ADD CONSTRAINT "FK_1c7af038a011e99c27044793c6a" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "core"."messageChannel" ADD CONSTRAINT "FK_22d9a21a23fdd99295dc0efc177" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "core"."messageChannel" ADD CONSTRAINT "FK_2e966cbb240771c67630d52895c" FOREIGN KEY ("connectedAccountId") REFERENCES "core"."connectedAccount"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "core"."messageFolder" ADD CONSTRAINT "FK_e7fb85af997d06d8f7cc7512801" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "core"."messageFolder" ADD CONSTRAINT "FK_4237a2fe8a6583354f807c2f8fe" FOREIGN KEY ("messageChannelId") REFERENCES "core"."messageChannel"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "core"."calendarChannel" ADD CONSTRAINT "FK_bb5ebadf91b73c8050fb0a092fa" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "core"."calendarChannel" ADD CONSTRAINT "FK_c7bc368c97a18a072413d67cf45" FOREIGN KEY ("connectedAccountId") REFERENCES "core"."connectedAccount"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."calendarChannel" DROP CONSTRAINT "FK_c7bc368c97a18a072413d67cf45"`,
);
await queryRunner.query(
`ALTER TABLE "core"."calendarChannel" DROP CONSTRAINT "FK_bb5ebadf91b73c8050fb0a092fa"`,
);
await queryRunner.query(
`ALTER TABLE "core"."messageFolder" DROP CONSTRAINT "FK_4237a2fe8a6583354f807c2f8fe"`,
);
await queryRunner.query(
`ALTER TABLE "core"."messageFolder" DROP CONSTRAINT "FK_e7fb85af997d06d8f7cc7512801"`,
);
await queryRunner.query(
`ALTER TABLE "core"."messageChannel" DROP CONSTRAINT "FK_2e966cbb240771c67630d52895c"`,
);
await queryRunner.query(
`ALTER TABLE "core"."messageChannel" DROP CONSTRAINT "FK_22d9a21a23fdd99295dc0efc177"`,
);
await queryRunner.query(
`ALTER TABLE "core"."connectedAccount" DROP CONSTRAINT "FK_1c7af038a011e99c27044793c6a"`,
);
await queryRunner.query(`DROP TABLE "core"."calendarChannel"`);
await queryRunner.query(
`DROP TYPE "core"."calendarChannel_contactautocreationpolicy_enum"`,
);
await queryRunner.query(
`DROP TYPE "core"."calendarChannel_visibility_enum"`,
);
await queryRunner.query(
`DROP TYPE "core"."calendarChannel_syncstage_enum"`,
);
await queryRunner.query(
`DROP TYPE "core"."calendarChannel_syncstatus_enum"`,
);
await queryRunner.query(`DROP TABLE "core"."messageFolder"`);
await queryRunner.query(
`DROP TYPE "core"."messageFolder_pendingsyncaction_enum"`,
);
await queryRunner.query(`DROP TABLE "core"."messageChannel"`);
await queryRunner.query(`DROP TYPE "core"."messageChannel_syncstage_enum"`);
await queryRunner.query(
`DROP TYPE "core"."messageChannel_syncstatus_enum"`,
);
await queryRunner.query(
`DROP TYPE "core"."messageChannel_pendinggroupemailsaction_enum"`,
);
await queryRunner.query(
`DROP TYPE "core"."messageChannel_messagefolderimportpolicy_enum"`,
);
await queryRunner.query(
`DROP TYPE "core"."messageChannel_contactautocreationpolicy_enum"`,
);
await queryRunner.query(`DROP TYPE "core"."messageChannel_type_enum"`);
await queryRunner.query(
`DROP TYPE "core"."messageChannel_visibility_enum"`,
);
await queryRunner.query(`DROP TABLE "core"."connectedAccount"`);
}
}
@@ -1,6 +1,6 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { FieldMetadataType } from 'twenty-shared/types';
import { FieldMetadataType, RelationType } from 'twenty-shared/types';
import { DataArgProcessorService } from 'src/engine/api/common/common-args-processors/data-arg-processor/data-arg-processor.service';
import { type SystemWorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
@@ -116,6 +116,118 @@ describe('DataArgProcessorService', () => {
expect(dataArgProcessorService).toBeDefined();
});
it('should normalize relation connect where composite values', async () => {
const flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata> = {
byUniversalIdentifier: {
'company-universal-id': {
id: 'company-id',
name: 'company',
type: FieldMetadataType.RELATION,
isNullable: true,
objectMetadataId: 'object-id',
universalIdentifier: 'company-universal-id',
relationTargetObjectMetadataId: 'target-company-object-id',
settings: {
relationType: RelationType.MANY_TO_ONE,
joinColumnName: 'companyId',
},
} as FlatFieldMetadata,
'emails-universal-id': {
id: 'emails-id',
name: 'emails',
type: FieldMetadataType.EMAILS,
isNullable: true,
objectMetadataId: 'target-company-object-id',
universalIdentifier: 'emails-universal-id',
} as FlatFieldMetadata,
'domainName-universal-id': {
id: 'domainName-id',
name: 'domainName',
type: FieldMetadataType.LINKS,
isNullable: true,
objectMetadataId: 'target-company-object-id',
universalIdentifier: 'domainName-universal-id',
} as FlatFieldMetadata,
},
universalIdentifierById: {
'company-id': 'company-universal-id',
'emails-id': 'emails-universal-id',
'domainName-id': 'domainName-universal-id',
},
universalIdentifiersByApplicationId: {},
};
const flatObjectMetadata = {
id: 'object-id',
nameSingular: 'testObject',
namePlural: 'testObjects',
isCustom: false,
fieldIds: ['company-id'],
universalIdentifier: 'test-object-universal-id',
labelIdentifierFieldMetadataUniversalIdentifier: null,
imageIdentifierFieldMetadataUniversalIdentifier: null,
} as FlatObjectMetadata;
const flatObjectMetadataMaps = {
byUniversalIdentifier: {
'target-company-universal-id': {
id: 'target-company-object-id',
nameSingular: 'company',
namePlural: 'companies',
isCustom: false,
fieldIds: ['emails-id', 'domainName-id'],
universalIdentifier: 'target-company-universal-id',
labelIdentifierFieldMetadataUniversalIdentifier: null,
imageIdentifierFieldMetadataUniversalIdentifier: null,
} as FlatObjectMetadata,
},
universalIdentifierById: {
'target-company-object-id': 'target-company-universal-id',
},
universalIdentifiersByApplicationId: {},
};
const result = await dataArgProcessorService.process({
partialRecordInputs: [
{
company: {
connect: {
where: {
emails: {
primaryEmail: 'User@Example.COM',
},
domainName: {
primaryLinkUrl: 'HTTPS://Example.COM/path/',
},
},
},
},
},
],
authContext: createMockAuthContext(),
flatObjectMetadata,
flatFieldMetadataMaps,
flatObjectMetadataMaps,
});
expect(result).toEqual([
{
company: {
connect: {
where: {
emails: {
primaryEmail: 'user@example.com',
},
domainName: {
primaryLinkUrl: 'https://example.com/path',
},
},
},
},
},
]);
});
describe('failing inputs validation', () => {
const fieldMetadataTypesToTest = Object.keys(
failingInputsByFieldMetadataType,
@@ -146,6 +258,11 @@ describe('DataArgProcessorService', () => {
authContext: createMockAuthContext(),
flatObjectMetadata,
flatFieldMetadataMaps,
flatObjectMetadataMaps: {
byUniversalIdentifier: {},
universalIdentifierById: {},
universalIdentifiersByApplicationId: {},
},
}),
).rejects.toThrowErrorMatchingSnapshot();
});
@@ -183,6 +300,11 @@ describe('DataArgProcessorService', () => {
authContext: createMockAuthContext(),
flatObjectMetadata,
flatFieldMetadataMaps,
flatObjectMetadataMaps: {
byUniversalIdentifier: {},
universalIdentifierById: {},
universalIdentifiersByApplicationId: {},
},
});
expect(result).toBeDefined();
@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { msg } from '@lingui/core/macro';
import { isNull, isUndefined } from '@sniptt/guards';
import { isNull, isObject, isUndefined } from '@sniptt/guards';
import {
FieldMetadataSettingsMapping,
FieldMetadataType,
@@ -15,7 +15,6 @@ import {
} from 'twenty-shared/utils';
import { transformActorField } from 'src/engine/api/common/common-args-processors/data-arg-processor/transformer-utils/transform-actor-field.util';
import { isRelationNestedOperation } from 'src/engine/api/common/common-args-processors/data-arg-processor/utils/is-relation-nested-operation.util';
import { transformAddressField } from 'src/engine/api/common/common-args-processors/data-arg-processor/transformer-utils/transform-address-field.util';
import { transformArrayField } from 'src/engine/api/common/common-args-processors/data-arg-processor/transformer-utils/transform-array-field.util';
import { transformCurrencyField } from 'src/engine/api/common/common-args-processors/data-arg-processor/transformer-utils/transform-currency-field.util';
@@ -23,6 +22,7 @@ import { transformFullNameField } from 'src/engine/api/common/common-args-proces
import { transformNumericField } from 'src/engine/api/common/common-args-processors/data-arg-processor/transformer-utils/transform-numeric-field.util';
import { transformRawJsonField } from 'src/engine/api/common/common-args-processors/data-arg-processor/transformer-utils/transform-raw-json-field.util';
import { transformTextField } from 'src/engine/api/common/common-args-processors/data-arg-processor/transformer-utils/transform-text-field.util';
import { isRelationNestedOperation } from 'src/engine/api/common/common-args-processors/data-arg-processor/utils/is-relation-nested-operation.util';
import { validateActorFieldOrThrow } from 'src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-actor-field-or-throw.util';
import { validateAddressFieldOrThrow } from 'src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-address-field-or-throw.util';
import { validateArrayFieldOrThrow } from 'src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-array-field-or-throw.util';
@@ -56,12 +56,12 @@ import { transformLinksValue } from 'src/engine/core-modules/record-transformer/
import { transformPhonesValue } from 'src/engine/core-modules/record-transformer/utils/transform-phones-value.util';
import { transformRichTextValue } from 'src/engine/core-modules/record-transformer/utils/transform-rich-text.util';
import { WorkspaceNotFoundDefaultError } from 'src/engine/core-modules/workspace/workspace.exception';
import { computeMorphOrRelationFieldJoinColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-morph-or-relation-field-join-column-name.util';
import { FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { computeMorphOrRelationFieldJoinColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-morph-or-relation-field-join-column-name.util';
import { FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { isFlatFieldMetadataOfType } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-flat-field-metadata-of-type.util';
import { buildFieldMapsFromFlatObjectMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/build-field-maps-from-flat-object-metadata.util';
import { isFlatFieldMetadataOfType } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-flat-field-metadata-of-type.util';
import { FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
@Injectable()
@@ -73,12 +73,14 @@ export class DataArgProcessorService {
authContext,
flatObjectMetadata,
flatFieldMetadataMaps,
flatObjectMetadataMaps,
shouldBackfillPositionIfUndefined = true,
}: {
partialRecordInputs: Partial<ObjectRecord>[] | undefined;
authContext: WorkspaceAuthContext;
flatObjectMetadata: FlatObjectMetadata;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>;
shouldBackfillPositionIfUndefined?: boolean;
}): Promise<Partial<ObjectRecord>[]> {
if (!isDefined(partialRecordInputs)) {
@@ -158,6 +160,8 @@ export class DataArgProcessorService {
fieldMetadata,
key,
value,
flatFieldMetadataMaps,
flatObjectMetadataMaps,
);
}
processedRecords.push(processedRecord);
@@ -170,6 +174,8 @@ export class DataArgProcessorService {
fieldMetadata: FlatFieldMetadata,
key: string,
value: unknown,
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>,
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>,
): Promise<unknown> {
switch (fieldMetadata.type) {
case FieldMetadataType.POSITION:
@@ -260,6 +266,29 @@ export class DataArgProcessorService {
);
}
const connectOperation = value as Record<
string,
Record<string, unknown>
>;
const connectWhere = connectOperation.connect?.where;
if (isObject(connectWhere)) {
const processedWhere = await this.processConnectWhere(
connectWhere as Record<string, unknown>,
fieldMetadata,
flatFieldMetadataMaps,
flatObjectMetadataMaps,
);
return {
...connectOperation,
connect: {
...connectOperation.connect,
where: processedWhere,
},
};
}
return value;
}
case FieldMetadataType.PHONES: {
@@ -325,4 +354,85 @@ export class DataArgProcessorService {
);
}
}
private async processConnectWhere(
connectWhere: Record<string, unknown>,
relationFieldMetadata: FlatFieldMetadata,
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>,
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>,
): Promise<Record<string, unknown>> {
if (!isDefined(relationFieldMetadata.relationTargetObjectMetadataId)) {
throw new CommonQueryRunnerException(
`Relation target object metadata id not found for field ${relationFieldMetadata.name}`,
CommonQueryRunnerExceptionCode.INVALID_ARGS_DATA,
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
);
}
const targetObjectMetadata =
findFlatEntityByIdInFlatEntityMaps<FlatObjectMetadata>({
flatEntityId: relationFieldMetadata.relationTargetObjectMetadataId,
flatEntityMaps: flatObjectMetadataMaps,
});
if (!isDefined(targetObjectMetadata)) {
throw new CommonQueryRunnerException(
`Relation target object metadata not found for field ${relationFieldMetadata.name}`,
CommonQueryRunnerExceptionCode.INVALID_ARGS_DATA,
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
);
}
const { fieldIdByName } = buildFieldMapsFromFlatObjectMetadata(
flatFieldMetadataMaps,
targetObjectMetadata,
);
const processedWhere: Record<string, unknown> = {};
for (const [whereKey, whereValue] of Object.entries(connectWhere)) {
const fieldId = fieldIdByName[whereKey];
if (!isDefined(fieldId)) {
processedWhere[whereKey] = whereValue;
continue;
}
const whereFieldMetadata =
findFlatEntityByIdInFlatEntityMaps<FlatFieldMetadata>({
flatEntityId: fieldId,
flatEntityMaps: flatFieldMetadataMaps,
});
if (!isDefined(whereFieldMetadata)) {
processedWhere[whereKey] = whereValue;
continue;
}
try {
const processedValue = await this.processField(
whereFieldMetadata,
whereKey,
whereValue,
flatFieldMetadataMaps,
flatObjectMetadataMaps,
);
// Only keep original keys — processField may add null subfields that alter WHERE semantics
if (isObject(whereValue) && isObject(processedValue)) {
const originalKeys = new Set(Object.keys(whereValue));
processedWhere[whereKey] = Object.fromEntries(
Object.entries(processedValue).filter(([k]) => originalKeys.has(k)),
);
} else {
processedWhere[whereKey] = processedValue;
}
} catch {
processedWhere[whereKey] = whereValue;
}
}
return processedWhere;
}
}
@@ -145,8 +145,12 @@ export class CommonCreateManyQueryRunnerService extends CommonBaseQueryRunnerSer
args: CommonInput<CreateManyQueryArgs>,
queryRunnerContext: CommonBaseQueryRunnerContext,
): Promise<CommonInput<CreateManyQueryArgs>> {
const { authContext, flatObjectMetadata, flatFieldMetadataMaps } =
queryRunnerContext;
const {
authContext,
flatObjectMetadata,
flatFieldMetadataMaps,
flatObjectMetadataMaps,
} = queryRunnerContext;
return {
...args,
@@ -155,6 +159,7 @@ export class CommonCreateManyQueryRunnerService extends CommonBaseQueryRunnerSer
authContext,
flatObjectMetadata,
flatFieldMetadataMaps,
flatObjectMetadataMaps,
}),
};
}
@@ -2,7 +2,6 @@ import { Injectable } from '@nestjs/common';
import { type ObjectRecord } from 'twenty-shared/types';
import { WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
import { CommonBaseQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-base-query-runner.service';
import { CommonCreateManyQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/common-create-many-query-runner.service';
import { CommonBaseQueryRunnerContext } from 'src/engine/api/common/types/common-base-query-runner-context.type';
@@ -15,6 +14,7 @@ import {
CreateOneQueryArgs,
} from 'src/engine/api/common/types/common-query-args.type';
import { assertIsValidUuid } from 'src/engine/api/graphql/workspace-query-runner/utils/assert-is-valid-uuid.util';
import { WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
import { FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
@@ -52,14 +52,19 @@ export class CommonCreateOneQueryRunnerService extends CommonBaseQueryRunnerServ
args: CommonInput<CreateOneQueryArgs>,
queryRunnerContext: CommonBaseQueryRunnerContext,
): Promise<CommonInput<CreateOneQueryArgs>> {
const { authContext, flatObjectMetadata, flatFieldMetadataMaps } =
queryRunnerContext;
const {
authContext,
flatObjectMetadata,
flatFieldMetadataMaps,
flatObjectMetadataMaps,
} = queryRunnerContext;
const coercedData = await this.dataArgProcessor.process({
partialRecordInputs: [args.data],
authContext,
flatObjectMetadata,
flatFieldMetadataMaps,
flatObjectMetadataMaps,
});
return {
@@ -176,8 +176,12 @@ export class CommonFindDuplicatesQueryRunnerService extends CommonBaseQueryRunne
args: CommonInput<FindDuplicatesQueryArgs>,
queryRunnerContext: CommonBaseQueryRunnerContext,
): Promise<CommonInput<FindDuplicatesQueryArgs>> {
const { authContext, flatObjectMetadata, flatFieldMetadataMaps } =
queryRunnerContext;
const {
authContext,
flatObjectMetadata,
flatFieldMetadataMaps,
flatObjectMetadataMaps,
} = queryRunnerContext;
const { fieldIdByName } = buildFieldMapsFromFlatObjectMetadata(
flatFieldMetadataMaps,
@@ -202,6 +206,7 @@ export class CommonFindDuplicatesQueryRunnerService extends CommonBaseQueryRunne
authContext,
flatObjectMetadata,
flatFieldMetadataMaps,
flatObjectMetadataMaps,
shouldBackfillPositionIfUndefined: false,
}),
};
@@ -100,8 +100,12 @@ export class CommonUpdateManyQueryRunnerService extends CommonBaseQueryRunnerSer
args: CommonInput<UpdateManyQueryArgs>,
queryRunnerContext: CommonBaseQueryRunnerContext,
): Promise<CommonInput<UpdateManyQueryArgs>> {
const { authContext, flatObjectMetadata, flatFieldMetadataMaps } =
queryRunnerContext;
const {
authContext,
flatObjectMetadata,
flatFieldMetadataMaps,
flatObjectMetadataMaps,
} = queryRunnerContext;
return {
...args,
@@ -116,6 +120,7 @@ export class CommonUpdateManyQueryRunnerService extends CommonBaseQueryRunnerSer
authContext,
flatObjectMetadata,
flatFieldMetadataMaps,
flatObjectMetadataMaps,
shouldBackfillPositionIfUndefined: false,
})
)[0],
@@ -65,8 +65,12 @@ export class CommonUpdateOneQueryRunnerService extends CommonBaseQueryRunnerServ
args: CommonInput<UpdateOneQueryArgs>,
queryRunnerContext: CommonBaseQueryRunnerContext,
): Promise<CommonInput<UpdateOneQueryArgs>> {
const { authContext, flatObjectMetadata, flatFieldMetadataMaps } =
queryRunnerContext;
const {
authContext,
flatObjectMetadata,
flatFieldMetadataMaps,
flatObjectMetadataMaps,
} = queryRunnerContext;
return {
...args,
@@ -76,6 +80,7 @@ export class CommonUpdateOneQueryRunnerService extends CommonBaseQueryRunnerServ
authContext,
flatObjectMetadata,
flatFieldMetadataMaps,
flatObjectMetadataMaps,
shouldBackfillPositionIfUndefined: false,
})
)[0],
@@ -0,0 +1,20 @@
import { Module } from '@nestjs/common';
import { CoreCommonApiModule } from 'src/engine/api/common/core-common-api.module';
import { DirectExecutionService } from 'src/engine/api/graphql/direct-execution/direct-execution.service';
import { WorkspaceResolverNameMapCacheService } from 'src/engine/api/graphql/direct-execution/services/workspace-resolver-name-map-cache.service';
import { WorkspaceResolverBuilderModule } from 'src/engine/api/graphql/workspace-resolver-builder/workspace-resolver-builder.module';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
@Module({
imports: [
CoreCommonApiModule,
WorkspaceManyOrAllFlatEntityMapsCacheModule,
WorkspaceCacheModule,
WorkspaceResolverBuilderModule,
],
providers: [DirectExecutionService, WorkspaceResolverNameMapCacheService],
exports: [DirectExecutionService],
})
export class DirectExecutionModule {}
@@ -0,0 +1,357 @@
import { Injectable } from '@nestjs/common';
import { type MessageDescriptor } from '@lingui/core';
import { type Request } from 'express';
import {
GraphQLError,
type DocumentNode,
type FieldNode,
type GraphQLFormattedError,
type GraphQLResolveInfo,
} from 'graphql';
import { SOURCE_LOCALE } from 'twenty-shared/translations';
import { isDefined } from 'twenty-shared/utils';
import graphqlFields from 'graphql-fields';
import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant';
import {
GraphqlDirectExecutionException,
GraphqlDirectExecutionExceptionCode,
} from 'src/engine/api/graphql/direct-execution/errors/graphql-direct-execution.exception';
import { assertCreateManyArgs } from 'src/engine/api/graphql/direct-execution/utils/assert-create-many-args.util';
import { assertCreateOneArgs } from 'src/engine/api/graphql/direct-execution/utils/assert-create-one-args.util';
import { assertDeleteManyArgs } from 'src/engine/api/graphql/direct-execution/utils/assert-delete-many-args.util';
import { assertDeleteOneArgs } from 'src/engine/api/graphql/direct-execution/utils/assert-delete-one-args.util';
import { assertDestroyManyArgs } from 'src/engine/api/graphql/direct-execution/utils/assert-destroy-many-args.util';
import { assertDestroyOneArgs } from 'src/engine/api/graphql/direct-execution/utils/assert-destroy-one-args.util';
import { assertFindDuplicatesArgs } from 'src/engine/api/graphql/direct-execution/utils/assert-find-duplicates-args.util';
import { assertFindManyArgs } from 'src/engine/api/graphql/direct-execution/utils/assert-find-many-args.util';
import { assertFindOneArgs } from 'src/engine/api/graphql/direct-execution/utils/assert-find-one-args.util';
import { assertGroupByArgs } from 'src/engine/api/graphql/direct-execution/utils/assert-group-by-args.util';
import { assertMergeManyArgs } from 'src/engine/api/graphql/direct-execution/utils/assert-merge-many-args.util';
import { assertRestoreManyArgs } from 'src/engine/api/graphql/direct-execution/utils/assert-restore-many-args.util';
import { assertRestoreOneArgs } from 'src/engine/api/graphql/direct-execution/utils/assert-restore-one-args.util';
import { assertUpdateManyArgs } from 'src/engine/api/graphql/direct-execution/utils/assert-update-many-args.util';
import { assertUpdateOneArgs } from 'src/engine/api/graphql/direct-execution/utils/assert-update-one-args.util';
import { type ResolverNameMapEntry } from 'src/engine/api/graphql/direct-execution/utils/build-resolver-name-map.util';
import { buildWorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/direct-execution/utils/build-workspace-schema-builder-context.util';
import { extractArgumentsFromAst } from 'src/engine/api/graphql/direct-execution/utils/extract-arguments-from-ast.util';
import { graphQLBackfillNullsFromSelectedFields } from 'src/engine/api/graphql/direct-execution/utils/graphql-backfill-nulls-from-selected-fields.util';
import { graphQLBuildFragmentMap } from 'src/engine/api/graphql/direct-execution/utils/graphql-build-fragment-map.util';
import { graphQLBuildPartialResolveInfo } from 'src/engine/api/graphql/direct-execution/utils/graphql-build-partial-resolve-info.util';
import { graphQLExtractTopLevelFields } from 'src/engine/api/graphql/direct-execution/utils/graphql-extract-top-level-fields.util';
import { workspaceQueryRunnerGraphqlApiExceptionHandler } from 'src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util';
import { RESOLVER_METHOD_NAMES } from 'src/engine/api/graphql/workspace-resolver-builder/constants/resolver-method-names';
import { CreateManyResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/create-many-resolver.factory';
import { CreateOneResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/create-one-resolver.factory';
import { DeleteManyResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/delete-many-resolver.factory';
import { DeleteOneResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/delete-one-resolver.factory';
import { DestroyManyResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/destroy-many-resolver.factory';
import { DestroyOneResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/destroy-one-resolver.factory';
import { FindDuplicatesResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/find-duplicates-resolver.factory';
import { FindManyResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/find-many-resolver.factory';
import { FindOneResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/find-one-resolver.factory';
import { GroupByResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/group-by-resolver.factory';
import { MergeManyResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/merge-many-resolver.factory';
import { RestoreManyResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/restore-many-resolver.factory';
import { RestoreOneResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/restore-one-resolver.factory';
import { UpdateManyResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/update-many-resolver.factory';
import { UpdateOneResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/update-one-resolver.factory';
import { type WorkspaceResolverBuilderFactoryInterface } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolver-builder-factory.interface';
import { type WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface';
import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { buildObjectIdByNameMaps } from 'src/engine/metadata-modules/flat-object-metadata/utils/build-object-id-by-name-maps.util';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
type DirectExecutionResult = {
data: Record<string, unknown> | null;
errors?: GraphQLFormattedError[];
};
@Injectable()
export class DirectExecutionService {
private readonly factoryMap: Map<
string,
WorkspaceResolverBuilderFactoryInterface
>;
private readonly argsAssertionMap: Map<string, (args: unknown) => void>;
constructor(
private readonly workspaceFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly twentyConfigService: TwentyConfigService,
private readonly i18nService: I18nService,
private readonly findManyResolverFactory: FindManyResolverFactory,
private readonly findOneResolverFactory: FindOneResolverFactory,
private readonly findDuplicatesResolverFactory: FindDuplicatesResolverFactory,
private readonly groupByResolverFactory: GroupByResolverFactory,
private readonly createOneResolverFactory: CreateOneResolverFactory,
private readonly createManyResolverFactory: CreateManyResolverFactory,
private readonly updateOneResolverFactory: UpdateOneResolverFactory,
private readonly updateManyResolverFactory: UpdateManyResolverFactory,
private readonly deleteOneResolverFactory: DeleteOneResolverFactory,
private readonly deleteManyResolverFactory: DeleteManyResolverFactory,
private readonly destroyOneResolverFactory: DestroyOneResolverFactory,
private readonly destroyManyResolverFactory: DestroyManyResolverFactory,
private readonly restoreOneResolverFactory: RestoreOneResolverFactory,
private readonly restoreManyResolverFactory: RestoreManyResolverFactory,
private readonly mergeManyResolverFactory: MergeManyResolverFactory,
) {
this.factoryMap = new Map<string, WorkspaceResolverBuilderFactoryInterface>(
[
[RESOLVER_METHOD_NAMES.FIND_MANY, this.findManyResolverFactory],
[RESOLVER_METHOD_NAMES.FIND_ONE, this.findOneResolverFactory],
[
RESOLVER_METHOD_NAMES.FIND_DUPLICATES,
this.findDuplicatesResolverFactory,
],
[RESOLVER_METHOD_NAMES.GROUP_BY, this.groupByResolverFactory],
[RESOLVER_METHOD_NAMES.CREATE_ONE, this.createOneResolverFactory],
[RESOLVER_METHOD_NAMES.CREATE_MANY, this.createManyResolverFactory],
[RESOLVER_METHOD_NAMES.UPDATE_ONE, this.updateOneResolverFactory],
[RESOLVER_METHOD_NAMES.UPDATE_MANY, this.updateManyResolverFactory],
[RESOLVER_METHOD_NAMES.DELETE_ONE, this.deleteOneResolverFactory],
[RESOLVER_METHOD_NAMES.DELETE_MANY, this.deleteManyResolverFactory],
[RESOLVER_METHOD_NAMES.DESTROY_ONE, this.destroyOneResolverFactory],
[RESOLVER_METHOD_NAMES.DESTROY_MANY, this.destroyManyResolverFactory],
[RESOLVER_METHOD_NAMES.RESTORE_ONE, this.restoreOneResolverFactory],
[RESOLVER_METHOD_NAMES.RESTORE_MANY, this.restoreManyResolverFactory],
[RESOLVER_METHOD_NAMES.MERGE_MANY, this.mergeManyResolverFactory],
],
);
this.argsAssertionMap = new Map<string, (args: unknown) => void>([
[RESOLVER_METHOD_NAMES.FIND_MANY, assertFindManyArgs],
[RESOLVER_METHOD_NAMES.FIND_ONE, assertFindOneArgs],
[RESOLVER_METHOD_NAMES.FIND_DUPLICATES, assertFindDuplicatesArgs],
[RESOLVER_METHOD_NAMES.GROUP_BY, assertGroupByArgs],
[RESOLVER_METHOD_NAMES.CREATE_ONE, assertCreateOneArgs],
[RESOLVER_METHOD_NAMES.CREATE_MANY, assertCreateManyArgs],
[RESOLVER_METHOD_NAMES.UPDATE_ONE, assertUpdateOneArgs],
[RESOLVER_METHOD_NAMES.UPDATE_MANY, assertUpdateManyArgs],
[RESOLVER_METHOD_NAMES.DELETE_ONE, assertDeleteOneArgs],
[RESOLVER_METHOD_NAMES.DELETE_MANY, assertDeleteManyArgs],
[RESOLVER_METHOD_NAMES.DESTROY_ONE, assertDestroyOneArgs],
[RESOLVER_METHOD_NAMES.DESTROY_MANY, assertDestroyManyArgs],
[RESOLVER_METHOD_NAMES.RESTORE_ONE, assertRestoreOneArgs],
[RESOLVER_METHOD_NAMES.RESTORE_MANY, assertRestoreManyArgs],
[RESOLVER_METHOD_NAMES.MERGE_MANY, assertMergeManyArgs],
]);
}
async getGeneratedWorkspaceResolverNames(
workspaceId: string,
): Promise<Set<string> | null> {
const { graphQLResolverNameMap } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
'graphQLResolverNameMap',
]);
return new Set(Object.keys(graphQLResolverNameMap));
}
async execute(
req: Request,
document: DocumentNode,
): Promise<DirectExecutionResult | null> {
try {
const workspaceId = req.workspace?.id;
if (!isDefined(workspaceId)) {
return null;
}
const topLevelFields = graphQLExtractTopLevelFields(
document,
req.body.operationName,
);
this.checkRootResolverLimitsOrThrow(topLevelFields);
const fragmentMap = graphQLBuildFragmentMap(document);
const variables = req.body.variables ?? {};
const data: Record<string, unknown> = {};
const { graphQLResolverNameMap } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
'graphQLResolverNameMap',
]);
const {
flatObjectMetadataMaps,
flatFieldMetadataMaps,
objectIdByNameSingular,
} = await this.loadWorkspaceMetadata(workspaceId);
const results = await Promise.allSettled(
topLevelFields.map(async (field) => {
const entry = graphQLResolverNameMap[field.name.value];
const responseKey = field.alias?.value ?? field.name.value;
const args = extractArgumentsFromAst(field.arguments, variables);
const graphqlPartialResolveInfo = graphQLBuildPartialResolveInfo(
field,
fragmentMap,
);
const workspaceSchemaBuilderContext =
buildWorkspaceSchemaBuilderContext(
entry,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
objectIdByNameSingular,
);
const result = await this.executeField({
entry,
args,
graphqlPartialResolveInfo,
workspaceSchemaBuilderContext,
});
graphQLBackfillNullsFromSelectedFields(
result,
graphqlFields(graphqlPartialResolveInfo as GraphQLResolveInfo),
);
return { responseKey, result };
}),
);
const errors: GraphQLFormattedError[] = [];
for (const settled of results) {
if (settled.status === 'fulfilled') {
data[settled.value.responseKey] = settled.value.result;
} else {
errors.push(this.formatError(settled.reason, req));
}
}
if (errors.length > 0) {
return { data, errors };
}
return { data };
} catch (error) {
return { data: null, errors: [this.formatError(error, req)] };
}
}
private async executeField({
entry,
args,
graphqlPartialResolveInfo,
workspaceSchemaBuilderContext,
}: {
entry: ResolverNameMapEntry;
args: Record<string, unknown>;
graphqlPartialResolveInfo: Pick<
GraphQLResolveInfo,
'fieldNodes' | 'fragments'
>;
workspaceSchemaBuilderContext: WorkspaceSchemaBuilderContext;
}): Promise<unknown> {
const factory = this.factoryMap.get(entry.method);
const assertFunction = this.argsAssertionMap.get(entry.method);
if (!isDefined(factory) || !isDefined(assertFunction)) {
throw new GraphqlDirectExecutionException(
`Unknown method: ${entry.method}`,
GraphqlDirectExecutionExceptionCode.UNKNOWN_METHOD,
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
);
}
assertFunction(args);
const resolver = factory.create(workspaceSchemaBuilderContext);
return resolver(
null,
args,
null,
graphqlPartialResolveInfo as GraphQLResolveInfo,
);
}
private formatError(error: any, req: Request): GraphQLFormattedError {
try {
workspaceQueryRunnerGraphqlApiExceptionHandler(error);
} catch (graphqlError) {
if (graphqlError instanceof GraphQLError) {
const json = graphqlError.toJSON();
if (json.extensions?.userFriendlyMessage) {
const userLocale = req.locale ?? SOURCE_LOCALE;
const i18n = this.i18nService.getI18nInstance(userLocale);
json.extensions.userFriendlyMessage = i18n._(
json.extensions.userFriendlyMessage as MessageDescriptor,
);
}
return json;
}
}
return {
message: isDefined(error.message)
? error.message
: 'Internal server error',
extensions: { code: 'INTERNAL_SERVER_ERROR' },
};
}
private checkRootResolverLimitsOrThrow(topLevelFields: FieldNode[]): void {
const maxRootResolvers = this.twentyConfigService.get(
'GRAPHQL_MAX_ROOT_RESOLVERS',
);
if (
isDefined(maxRootResolvers) &&
topLevelFields.length > maxRootResolvers
) {
throw new UserInputError(
`Query too complex - Too many root resolvers requested: ${topLevelFields.length} - Maximum allowed root resolvers: ${maxRootResolvers}`,
);
}
const seen = new Set<string>();
for (const field of topLevelFields) {
const name = field.name.value;
if (seen.has(name)) {
throw new UserInputError(`Duplicate root resolver: "${name}"`);
}
seen.add(name);
}
}
private async loadWorkspaceMetadata(workspaceId: string) {
const { flatObjectMetadataMaps, flatFieldMetadataMaps } =
await this.workspaceFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatObjectMetadataMaps', 'flatFieldMetadataMaps'],
},
);
const { idByNameSingular } = buildObjectIdByNameMaps(
flatObjectMetadataMaps,
);
return {
flatObjectMetadataMaps,
flatFieldMetadataMaps,
objectIdByNameSingular: idByNameSingular,
};
}
}
@@ -0,0 +1,20 @@
import { type MessageDescriptor } from '@lingui/core';
import { CustomException } from 'src/utils/custom-exception';
export enum GraphqlDirectExecutionExceptionCode {
INVALID_QUERY_INPUT = 'INVALID_QUERY_INPUT',
UNKNOWN_METHOD = 'UNKNOWN_METHOD',
}
export class GraphqlDirectExecutionException extends CustomException<GraphqlDirectExecutionExceptionCode> {
constructor(
message: string,
code: GraphqlDirectExecutionExceptionCode,
{ userFriendlyMessage }: { userFriendlyMessage: MessageDescriptor },
) {
super(message, code, {
userFriendlyMessage,
});
}
}
@@ -0,0 +1,95 @@
import { type Request } from 'express';
import { DocumentNode, parse } from 'graphql';
import { type Plugin } from 'graphql-yoga';
import { FeatureFlagKey } from 'twenty-shared/types';
import { isNull } from '@sniptt/guards';
import { type DirectExecutionService } from 'src/engine/api/graphql/direct-execution/direct-execution.service';
import { computeSkipWorkspaceSchemaCreation } from 'src/engine/api/graphql/direct-execution/utils/compute-skip-workspace-schema-creation.util';
import { findOperationDefinition } from 'src/engine/api/graphql/direct-execution/utils/find-operation-definition.util';
import { hasOnlyGeneratedWorkspaceResolvers } from 'src/engine/api/graphql/direct-execution/utils/has-only-generated-workspace-resolvers.util';
import { isSubscriptionOperation } from 'src/engine/api/graphql/direct-execution/utils/is-subscription-operation.util';
import { type FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
export type DirectExecutionPluginConfig = {
directExecutionService: DirectExecutionService;
featureFlagService: FeatureFlagService;
};
export function useDirectExecution(
config: DirectExecutionPluginConfig,
): Plugin {
return {
onRequest: async ({ endResponse, serverContext }) => {
const req = (serverContext as unknown as { req: Request }).req;
if (!req.workspace?.id || !req.body?.query) {
return;
}
const isEnabled = await config.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_DIRECT_GRAPHQL_EXECUTION_ENABLED,
req.workspace.id,
);
if (!isEnabled) {
return;
}
const generatedWorkspaceResolverNames =
await config.directExecutionService.getGeneratedWorkspaceResolverNames(
req.workspace.id,
);
if (!generatedWorkspaceResolverNames) {
return;
}
const queryString = req.body.query as string;
const operationName = req.body.operationName as string | undefined;
let document: DocumentNode;
try {
document = parse(queryString);
} catch {
return;
}
if (
!findOperationDefinition(document, operationName) ||
isSubscriptionOperation(document, operationName)
) {
return;
}
if (
computeSkipWorkspaceSchemaCreation(
queryString,
document,
operationName,
generatedWorkspaceResolverNames,
)
) {
req.skipWorkspaceSchemaCreation = true;
}
if (
!hasOnlyGeneratedWorkspaceResolvers(
document,
operationName,
generatedWorkspaceResolverNames,
)
) {
return;
}
const result = await config.directExecutionService.execute(req, document);
if (isNull(result)) {
return;
}
return endResponse(Response.json(result));
},
};
}
@@ -0,0 +1,31 @@
import { Injectable } from '@nestjs/common';
import { WorkspaceCacheProvider } from 'src/engine/workspace-cache/interfaces/workspace-cache-provider.service';
import {
type ResolverNameMapEntry,
buildResolverNameMap,
} from 'src/engine/api/graphql/direct-execution/utils/build-resolver-name-map.util';
import { WorkspaceCache } from 'src/engine/workspace-cache/decorators/workspace-cache.decorator';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
@Injectable()
@WorkspaceCache('graphQLResolverNameMap')
export class WorkspaceResolverNameMapCacheService extends WorkspaceCacheProvider<
Record<string, ResolverNameMapEntry>
> {
constructor(private readonly workspaceCacheService: WorkspaceCacheService) {
super();
}
async computeForCache(
workspaceId: string,
): Promise<Record<string, ResolverNameMapEntry>> {
const { flatObjectMetadataMaps } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatObjectMetadataMaps',
]);
return buildResolverNameMap(flatObjectMetadataMaps);
}
}
@@ -0,0 +1,178 @@
import { parse } from 'graphql';
import { computeSkipWorkspaceSchemaCreation } from 'src/engine/api/graphql/direct-execution/utils/compute-skip-workspace-schema-creation.util';
const GENERATED_RESOLVERS = new Set([
'findManyCompanies',
'findOneCompany',
'createOneCompany',
'findManyPeople',
'findOnePerson',
]);
describe('computeSkipWorkspaceSchemaCreation', () => {
it('should return true when all fields are core resolvers', () => {
const query = `
query {
currentWorkspace { id }
}
`;
expect(
computeSkipWorkspaceSchemaCreation(
query,
parse(query),
undefined,
GENERATED_RESOLVERS,
),
).toBe(true);
});
it('should return true for multiple core resolver fields', () => {
const query = `
query {
currentWorkspace { id }
currentUser { id }
}
`;
expect(
computeSkipWorkspaceSchemaCreation(
query,
parse(query),
undefined,
GENERATED_RESOLVERS,
),
).toBe(true);
});
it('should return true when all fields are generated workspace resolvers', () => {
const query = `
query {
findManyCompanies { id name }
}
`;
expect(
computeSkipWorkspaceSchemaCreation(
query,
parse(query),
undefined,
GENERATED_RESOLVERS,
),
).toBe(true);
});
it('should return false for mixed queries', () => {
const query = `
query {
findManyCompanies { id }
currentWorkspace { id }
}
`;
expect(
computeSkipWorkspaceSchemaCreation(
query,
parse(query),
undefined,
GENERATED_RESOLVERS,
),
).toBe(false);
});
it('should return false for __schema introspection', () => {
const query = `
query {
__schema { types { name } }
}
`;
expect(
computeSkipWorkspaceSchemaCreation(
query,
parse(query),
undefined,
GENERATED_RESOLVERS,
),
).toBe(false);
});
it('should return false for __type introspection', () => {
const query = `
query {
__type(name: "Company") { name fields { name } }
}
`;
expect(
computeSkipWorkspaceSchemaCreation(
query,
parse(query),
undefined,
GENERATED_RESOLVERS,
),
).toBe(false);
});
it('should not treat __typename as introspection', () => {
const query = `
query {
currentWorkspace { id __typename }
}
`;
expect(
computeSkipWorkspaceSchemaCreation(
query,
parse(query),
undefined,
GENERATED_RESOLVERS,
),
).toBe(true);
});
it('should return true when no operation matches (no fields to check)', () => {
const query = `
query GetCompanies { findManyCompanies { id } }
`;
expect(
computeSkipWorkspaceSchemaCreation(
query,
parse(query),
'NonExistent',
GENERATED_RESOLVERS,
),
).toBe(true);
});
it('should respect operationName', () => {
const query = `
query CoreQuery {
currentWorkspace { id }
}
query WorkspaceQuery {
findManyCompanies { id }
}
`;
expect(
computeSkipWorkspaceSchemaCreation(
query,
parse(query),
'CoreQuery',
GENERATED_RESOLVERS,
),
).toBe(true);
expect(
computeSkipWorkspaceSchemaCreation(
query,
parse(query),
'WorkspaceQuery',
GENERATED_RESOLVERS,
),
).toBe(true);
});
});
@@ -0,0 +1,64 @@
import { parse } from 'graphql';
import { findOperationDefinition } from 'src/engine/api/graphql/direct-execution/utils/find-operation-definition.util';
describe('findOperationDefinition', () => {
it('should return the single operation when no operationName is given', () => {
const document = parse('query { findManyCompanies { id } }');
const result = findOperationDefinition(document, undefined);
expect(result).toBeDefined();
expect(result?.name).toBeUndefined();
expect(result?.operation).toBe('query');
});
it('should throw when multiple operations exist and no operationName is given', () => {
const document = parse(`
query First { findManyCompanies { id } }
query Second { findManyPeople { id } }
`);
expect(() => findOperationDefinition(document, undefined)).toThrow(
'Must provide operation name when document contains multiple operations.',
);
});
it('should return the named operation when operationName matches', () => {
const document = parse(`
query First { findManyCompanies { id } }
query Second { findManyPeople { id } }
`);
const result = findOperationDefinition(document, 'Second');
expect(result?.name?.value).toBe('Second');
});
it('should return undefined when operationName does not match any operation', () => {
const document = parse('query MyQuery { findManyCompanies { id } }');
const result = findOperationDefinition(document, 'NonExistent');
expect(result).toBeUndefined();
});
it('should return a mutation operation', () => {
const document = parse(
'mutation CreateOne { createOnePerson(data: {}) { id } }',
);
const result = findOperationDefinition(document, 'CreateOne');
expect(result?.operation).toBe('mutation');
expect(result?.name?.value).toBe('CreateOne');
});
it('should return undefined for an empty document', () => {
const document = parse('type Query { dummy: String }');
const result = findOperationDefinition(document, undefined);
expect(result).toBeUndefined();
});
});
@@ -0,0 +1,76 @@
import { parse } from 'graphql';
import { graphQLExtractTopLevelFields } from 'src/engine/api/graphql/direct-execution/utils/graphql-extract-top-level-fields.util';
describe('graphQLExtractTopLevelFields', () => {
it('should return top-level fields from a query', () => {
const query = `
query {
findManyCompanies { id name }
findManyPeople { id email }
}
`;
const fields = graphQLExtractTopLevelFields(parse(query), undefined);
expect(fields).toHaveLength(2);
expect(fields[0].name.value).toBe('findManyCompanies');
expect(fields[1].name.value).toBe('findManyPeople');
});
it('should return top-level fields from a mutation', () => {
const query = `
mutation {
createOnePerson(data: { name: "Test" }) { id }
}
`;
const fields = graphQLExtractTopLevelFields(parse(query), undefined);
expect(fields).toHaveLength(1);
expect(fields[0].name.value).toBe('createOnePerson');
});
it('should select the operation matching operationName', () => {
const query = `
query GetCompanies {
findManyCompanies { id }
}
query GetPeople {
findManyPeople { id }
}
`;
const fields = graphQLExtractTopLevelFields(parse(query), 'GetPeople');
expect(fields).toHaveLength(1);
expect(fields[0].name.value).toBe('findManyPeople');
});
it('should throw when multiple operations exist and operationName is undefined', () => {
const query = `
query First {
findManyCompanies { id }
}
query Second {
findManyPeople { id }
}
`;
expect(() => graphQLExtractTopLevelFields(parse(query), undefined)).toThrow(
'Must provide operation name when document contains multiple operations.',
);
});
it('should return an empty array when no operation matches', () => {
const query = `
query GetCompanies {
findManyCompanies { id }
}
`;
const fields = graphQLExtractTopLevelFields(parse(query), 'NonExistent');
expect(fields).toEqual([]);
});
});
@@ -0,0 +1,121 @@
import { parse } from 'graphql';
import { hasOnlyGeneratedWorkspaceResolvers } from 'src/engine/api/graphql/direct-execution/utils/has-only-generated-workspace-resolvers.util';
const GENERATED_RESOLVERS = new Set([
'companies',
'company',
'createOneCompany',
'people',
'person',
]);
describe('hasOnlyGeneratedWorkspaceResolvers', () => {
it('should return true when all fields are generated workspace resolvers', () => {
const query = `
query {
companies { id name }
people { id email }
}
`;
expect(
hasOnlyGeneratedWorkspaceResolvers(
parse(query),
undefined,
GENERATED_RESOLVERS,
),
).toBe(true);
});
it('should return true for a single generated workspace resolver', () => {
const query = `
query {
company(filter: { id: { eq: "123" } }) { id }
}
`;
expect(
hasOnlyGeneratedWorkspaceResolvers(
parse(query),
undefined,
GENERATED_RESOLVERS,
),
).toBe(true);
});
it('should return false when all fields are core resolvers', () => {
const query = `
query {
search { id }
getTimelineCalendarEventsFromOpportunityId { id }
}
`;
expect(
hasOnlyGeneratedWorkspaceResolvers(
parse(query),
undefined,
GENERATED_RESOLVERS,
),
).toBe(false);
});
it('should return false for mixed queries', () => {
const query = `
query {
companies { id }
getTimelineCalendarEventsFromOpportunityId { id }
}
`;
expect(
hasOnlyGeneratedWorkspaceResolvers(
parse(query),
undefined,
GENERATED_RESOLVERS,
),
).toBe(false);
});
it('should return true when no operation matches (no fields to check)', () => {
const query = `
query GetCompanies { companies { id } }
`;
expect(
hasOnlyGeneratedWorkspaceResolvers(
parse(query),
'NonExistent',
GENERATED_RESOLVERS,
),
).toBe(true);
});
it('should respect operationName', () => {
const query = `
query WorkspaceQuery {
companies { id }
}
query CoreQuery {
getTimelineCalendarEventsFromOpportunityId { id }
}
`;
expect(
hasOnlyGeneratedWorkspaceResolvers(
parse(query),
'WorkspaceQuery',
GENERATED_RESOLVERS,
),
).toBe(true);
expect(
hasOnlyGeneratedWorkspaceResolvers(
parse(query),
'CoreQuery',
GENERATED_RESOLVERS,
),
).toBe(false);
});
});
@@ -0,0 +1,32 @@
import { parse } from 'graphql';
import { isSubscriptionOperation } from 'src/engine/api/graphql/direct-execution/utils/is-subscription-operation.util';
describe('isSubscriptionOperation', () => {
it('should return true for a subscription operation', () => {
const query = 'subscription { onCreateCompany { id } }';
expect(isSubscriptionOperation(parse(query), undefined)).toBe(true);
});
it('should return true for a named subscription matching operationName', () => {
const query = `
subscription OnCreate { onCreateCompany { id } }
query GetAll { findManyCompanies { id } }
`;
expect(isSubscriptionOperation(parse(query), 'OnCreate')).toBe(true);
});
it('should return false for a query operation', () => {
const query = 'query { findManyCompanies { id } }';
expect(isSubscriptionOperation(parse(query), undefined)).toBe(false);
});
it('should return false for a mutation operation', () => {
const query = 'mutation { createOnePerson(data: {}) { id } }';
expect(isSubscriptionOperation(parse(query), undefined)).toBe(false);
});
});
@@ -0,0 +1,51 @@
import { isBoolean, isObject } from 'class-validator';
import { isDefined } from 'twenty-shared/utils';
import {
GraphqlDirectExecutionException,
GraphqlDirectExecutionExceptionCode,
} from 'src/engine/api/graphql/direct-execution/errors/graphql-direct-execution.exception';
import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant';
import { type CreateManyResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
export function assertCreateManyArgs(
args: unknown,
): asserts args is CreateManyResolverArgs {
if (!isObject(args)) {
throw new GraphqlDirectExecutionException(
'Invalid argument: it must be an object',
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
);
}
const argKeys = Object.keys(args);
const allowedKeys = new Set(['data', 'upsert']);
for (const key of argKeys) {
if (!allowedKeys.has(key)) {
throw new GraphqlDirectExecutionException(
`Argument not allowed: ${key}`,
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
);
}
}
if (!('data' in args) || !Array.isArray(args.data)) {
throw new GraphqlDirectExecutionException(
'Missing required argument: "data" (array)',
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
);
}
if ('upsert' in args && isDefined(args.upsert) && !isBoolean(args.upsert)) {
throw new GraphqlDirectExecutionException(
'Invalid argument: "upsert" must be a boolean',
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
);
}
}
@@ -0,0 +1,52 @@
import { isBoolean, isObject } from 'class-validator';
import { isDefined } from 'twenty-shared/utils';
import {
GraphqlDirectExecutionException,
GraphqlDirectExecutionExceptionCode,
} from 'src/engine/api/graphql/direct-execution/errors/graphql-direct-execution.exception';
import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant';
import { type CreateOneResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
export function assertCreateOneArgs(
args: unknown,
): asserts args is CreateOneResolverArgs {
if (!isObject(args)) {
throw new GraphqlDirectExecutionException(
'Invalid argument: it must be an object',
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
);
}
const argKeys = Object.keys(args);
const allowedKeys = new Set(['data', 'upsert']);
for (const key of argKeys) {
if (!allowedKeys.has(key)) {
throw new GraphqlDirectExecutionException(
`Argument not allowed: ${key}`,
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
);
}
}
if (!('data' in args) || !isObject(args.data)) {
throw new GraphqlDirectExecutionException(
'Missing required argument: "data" (object)',
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
);
}
if ('upsert' in args && isDefined(args.upsert) && !isBoolean(args.upsert)) {
throw new GraphqlDirectExecutionException(
'Invalid argument: "upsert" must be a boolean',
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
);
}
}
@@ -0,0 +1,42 @@
import { isObject } from 'class-validator';
import {
GraphqlDirectExecutionException,
GraphqlDirectExecutionExceptionCode,
} from 'src/engine/api/graphql/direct-execution/errors/graphql-direct-execution.exception';
import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant';
import { type DeleteManyResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
export function assertDeleteManyArgs(
args: unknown,
): asserts args is DeleteManyResolverArgs {
if (!isObject(args)) {
throw new GraphqlDirectExecutionException(
'Invalid argument: it must be an object',
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
);
}
const argKeys = Object.keys(args);
const allowedKeys = new Set(['filter']);
for (const key of argKeys) {
if (!allowedKeys.has(key)) {
throw new GraphqlDirectExecutionException(
`Argument not allowed: ${key}`,
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
);
}
}
if (!('filter' in args) || !isObject(args.filter)) {
throw new GraphqlDirectExecutionException(
'Missing required argument: "filter" (object)',
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
);
}
}
@@ -0,0 +1,44 @@
import { isObject, isString } from 'class-validator';
import { isValidUuid } from 'twenty-shared/utils';
import {
GraphqlDirectExecutionException,
GraphqlDirectExecutionExceptionCode,
} from 'src/engine/api/graphql/direct-execution/errors/graphql-direct-execution.exception';
import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant';
import { type DeleteOneResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
export function assertDeleteOneArgs(
args: unknown,
): asserts args is DeleteOneResolverArgs {
if (!isObject(args)) {
throw new GraphqlDirectExecutionException(
'Invalid argument: it must be an object',
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
);
}
const argKeys = Object.keys(args);
const allowedKeys = new Set(['id']);
for (const key of argKeys) {
if (!allowedKeys.has(key)) {
throw new GraphqlDirectExecutionException(
`Argument not allowed: ${key}`,
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
);
}
}
if (!('id' in args) || !isString(args.id) || !isValidUuid(args.id)) {
throw new GraphqlDirectExecutionException(
'Missing required argument: "id" (UUID)',
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
);
}
}
@@ -0,0 +1,42 @@
import { isObject } from 'class-validator';
import {
GraphqlDirectExecutionException,
GraphqlDirectExecutionExceptionCode,
} from 'src/engine/api/graphql/direct-execution/errors/graphql-direct-execution.exception';
import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant';
import { type DestroyManyResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
export function assertDestroyManyArgs(
args: unknown,
): asserts args is DestroyManyResolverArgs {
if (!isObject(args)) {
throw new GraphqlDirectExecutionException(
'Invalid argument: it must be an object',
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
);
}
const argKeys = Object.keys(args);
const allowedKeys = new Set(['filter']);
for (const key of argKeys) {
if (!allowedKeys.has(key)) {
throw new GraphqlDirectExecutionException(
`Argument not allowed: ${key}`,
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
);
}
}
if (!('filter' in args) || !isObject(args.filter)) {
throw new GraphqlDirectExecutionException(
'Missing required argument: "filter" (object)',
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
);
}
}
@@ -0,0 +1,44 @@
import { isObject, isString } from 'class-validator';
import { isValidUuid } from 'twenty-shared/utils';
import {
GraphqlDirectExecutionException,
GraphqlDirectExecutionExceptionCode,
} from 'src/engine/api/graphql/direct-execution/errors/graphql-direct-execution.exception';
import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant';
import { type DestroyOneResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
export function assertDestroyOneArgs(
args: unknown,
): asserts args is DestroyOneResolverArgs {
if (!isObject(args)) {
throw new GraphqlDirectExecutionException(
'Invalid argument: it must be an object',
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
);
}
const argKeys = Object.keys(args);
const allowedKeys = new Set(['id']);
for (const key of argKeys) {
if (!allowedKeys.has(key)) {
throw new GraphqlDirectExecutionException(
`Argument not allowed: ${key}`,
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
);
}
}
if (!('id' in args) || !isString(args.id) || !isValidUuid(args.id)) {
throw new GraphqlDirectExecutionException(
'Missing required argument: "id" (UUID)',
GraphqlDirectExecutionExceptionCode.INVALID_QUERY_INPUT,
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
);
}
}

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