Compare commits

..
Author SHA1 Message Date
Sonarly Claude Code 6a35ea3b61 fix: handle ResourceConflictException in LambdaDriver.build() for concurrent executions
https://sonarly.com/issue/16137?type=bug

The `LambdaDriver.build()` method has a TOCTOU race condition: it checks if an AWS Lambda function exists (GET → 404), then creates it (POST → 409 conflict), without handling the case where another concurrent worker creates the same function between the check and create.

Fix: **What changed:** Added `ResourceConflictException` handling to all three `CreateFunctionCommand` call sites in `LambdaDriver`:

1. **`build()`** (line 738) — the main logic function executor Lambda creation, which is the exact call site in the Sentry stack trace
2. **`ensureYarnInstallLambdaExists()`** (line 358) — the yarn-install helper Lambda creation
3. **`ensureBuilderLambdaExists()`** (line 454) — the esbuild transpiler helper Lambda creation

All three methods had the same TOCTOU race condition: check if function exists → get 404 → try to create → 409 because another concurrent worker already created it.

**Pattern:** The fix uses the exact same error-handling pattern already established in the file for `ResourceNotFoundException` — catch the specific AWS SDK exception, re-throw anything else. When `ResourceConflictException` is caught, execution continues normally because the function now exists (created by the other concurrent worker), and `waitFunctionActive` (called after `build()`) will wait for it to be ready.

**Import:** Added `ResourceConflictException` to the existing `@aws-sdk/client-lambda` import block, alphabetically next to the already-imported `ResourceNotFoundException`.
2026-03-18 22:08:28 +00:00
63 changed files with 2562 additions and 2251 deletions
+2 -2
View File
@@ -10,8 +10,8 @@ permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
group: ${{ github.workflow }}-${{ github.event_name == 'merge_group' && github.event.merge_group.base_ref || github.ref }}
cancel-in-progress: ${{ github.event_name != 'merge_group' }}
jobs:
e2e-test:
@@ -21,51 +21,19 @@ description: أنشئ وأدِر تخصيصات Twenty على هيئة كود.
## المتطلبات الأساسية
* Node.js 24+ وYarn 4
* Docker (لخادم تطوير Twenty المحلي)
* مساحة عمل Twenty ومفتاح واجهة برمجة التطبيقات (أنشئ واحدًا على https://app.twenty.com/settings/api-webhooks)
## البدء
أنشئ تطبيقًا جديدًا باستخدام المولّد الرسمي. يمكنه بدء مثيل محلي من Twenty تلقائيًا لك:
أنشئ تطبيقًا جديدًا باستخدام المُهيئ الرسمي، ثم قم بالمصادقة وابدأ التطوير:
```bash filename="Terminal"
# إنشاء تطبيق جديد — ستعرض واجهة سطر الأوامر خيار بدء خادم Twenty محلي
# إنشاء تطبيق جديد (يتضمن جميع الأمثلة افتراضيًا)
npx create-twenty-app@latest my-twenty-app
cd my-twenty-app
# ابدأ وضع التطوير: يُزامن التغييرات المحلية تلقائيًا مع مساحة العمل الخاصة بك
yarn twenty dev
```
### إدارة الخادم المحلي
يتضمن SDK أوامر لإدارة خادم تطوير Twenty محلي (صورة Docker متكاملة تتضمن PostgreSQL وRedis والخادم والعامل):
```bash filename="Terminal"
# ابدأ الخادم المحلي (يسحب الصورة إذا لزم الأمر)
yarn twenty server start
# تحقّق من حالة الخادم
yarn twenty server status
# بثّ سجلات الخادم
yarn twenty server logs
# أوقف الخادم
yarn twenty server stop
# أعد ضبط جميع البيانات وابدأ من جديد
yarn twenty server reset
```
يأتي الخادم المحلي مهيأً مسبقًا بمساحة عمل ومستخدم (`tim@apple.dev` / `tim@apple.dev`)، بحيث يمكنك البدء في التطوير فورًا دون أي إعداد يدوي.
### المصادقة
وصّل تطبيقك بالخادم المحلي باستخدام OAuth:
```bash filename="Terminal"
# المصادقة عبر OAuth (يفتح المتصفح)
yarn twenty remote add --local
yarn twenty app:dev
```
يدعم المُنشئ وضعين للتحكم في ملفات الأمثلة التي سيتم تضمينها:
@@ -21,51 +21,19 @@ Mit Apps können Sie Twenty-Anpassungen **als Code** erstellen und verwalten. An
## Voraussetzungen
* Node.js 24+ und Yarn 4
* Docker (für den lokalen Twenty-Dev-Server)
* Ein Twenty-Workspace und ein API-Schlüssel (unter https://app.twenty.com/settings/api-webhooks erstellen)
## Erste Schritte
Erstelle eine neue App mit dem offiziellen Scaffolder. Der Scaffolder kann für dich automatisch eine lokale Twenty-Instanz starten:
Erstellen Sie mit dem offiziellen Scaffolder eine neue App, authentifizieren Sie sich und beginnen Sie mit der Entwicklung:
```bash filename="Terminal"
# Eine neue App erstellen — die CLI bietet an, einen lokalen Twenty-Server zu starten
# Eine neue App erstellen (enthält standardmäßig alle Beispiele)
npx create-twenty-app@latest my-twenty-app
cd my-twenty-app
# Dev-Modus starten: synchronisiert lokale Änderungen automatisch mit deinem Arbeitsbereich
yarn twenty dev
```
### Lokale Serververwaltung
Das SDK enthält Befehle zur Verwaltung eines lokalen Twenty-Dev-Servers (All-in-One-Docker-Image mit PostgreSQL, Redis, Server und Worker):
```bash filename="Terminal"
# Den lokalen Server starten (lädt das Image bei Bedarf herunter)
yarn twenty server start
# Serverstatus prüfen
yarn twenty server status
# Serverprotokolle streamen
yarn twenty server logs
# Server stoppen
yarn twenty server stop
# Alle Daten zurücksetzen und neu starten
yarn twenty server reset
```
Der lokale Server ist bereits mit einem Arbeitsbereich und einem Benutzer (`tim@apple.dev` / `tim@apple.dev`) vorbefüllt, sodass Sie ohne manuelle Einrichtung sofort mit der Entwicklung beginnen können.
### Authentifizierung
Verbinden Sie Ihre App mithilfe von OAuth mit dem lokalen Server:
```bash filename="Terminal"
# Authenticate via OAuth (opens browser)
yarn twenty remote add --local
yarn twenty app:dev
```
Das Scaffolding-Tool unterstützt zwei Modi, um zu steuern, welche Beispieldateien enthalten sind:
@@ -21,51 +21,19 @@ Le app ti consentono di creare e gestire le personalizzazioni di Twenty **come c
## Prerequisiti
* Node.js 24+ e Yarn 4
* Docker (per il server di sviluppo locale di Twenty)
* Uno spazio di lavoro Twenty e una chiave API (creane una su https://app.twenty.com/settings/api-webhooks)
## Per iniziare
Crea una nuova app utilizzando lo scaffolder ufficiale. Può avviare automaticamente un'istanza locale di Twenty per te:
Crea una nuova app utilizzando lo scaffolder ufficiale, quindi autenticati e inizia a sviluppare:
```bash filename="Terminal"
# Crea lo scaffold di una nuova app — la CLI offrirà di avviare un server locale di Twenty
# Crea lo scaffold di una nuova app (include tutti gli esempi per impostazione predefinita)
npx create-twenty-app@latest my-twenty-app
cd my-twenty-app
# Avvia la modalità di sviluppo: sincronizza automaticamente le modifiche locali con il tuo workspace
yarn twenty dev
```
### Gestione del server locale
L'SDK include comandi per gestire un server di sviluppo locale di Twenty (immagine Docker all-in-one con PostgreSQL, Redis, server e worker):
```bash filename="Terminal"
# Avvia il server locale (scarica l'immagine se necessario)
yarn twenty server start
# Verifica lo stato del server
yarn twenty server status
# Segui i log del server
yarn twenty server logs
# Arresta il server
yarn twenty server stop
# Reimposta tutti i dati e riparti da zero
yarn twenty server reset
```
Il server locale è preconfigurato con uno spazio di lavoro e un utente (`tim@apple.dev` / `tim@apple.dev`), così puoi iniziare a sviluppare immediatamente senza alcuna configurazione manuale.
### Autenticazione
Collega la tua app al server locale tramite OAuth:
```bash filename="Terminal"
# Autenticati tramite OAuth (apre il browser)
yarn twenty remote add --local
yarn twenty app:dev
```
Lo strumento di scaffolding supporta due modalità per controllare quali file di esempio vengono inclusi:
@@ -21,51 +21,19 @@ Os aplicativos permitem criar e gerenciar personalizações do Twenty **como có
## Pré-requisitos
* Node.js 24+ e Yarn 4
* Docker (para o servidor de desenvolvimento local do Twenty)
* Um espaço de trabalho do Twenty e uma chave de API (crie uma em https://app.twenty.com/settings/api-webhooks)
## Primeiros passos
Crie um novo app usando o gerador oficial de estrutura. Ele pode iniciar automaticamente uma instância local do Twenty para você:
Crie um novo aplicativo usando o gerador oficial, depois autentique-se e comece a desenvolver:
```bash filename="Terminal"
# Criar a estrutura de um novo app — a CLI oferecerá iniciar um servidor local do Twenty
# Criar a estrutura de um novo app (inclui todos os exemplos por padrão)
npx create-twenty-app@latest my-twenty-app
cd my-twenty-app
# Iniciar modo de desenvolvimento: sincroniza automaticamente as alterações locais com seu workspace
yarn twenty dev
```
### Gerenciamento do Servidor Local
O SDK inclui comandos para gerenciar um servidor de desenvolvimento local do Twenty (imagem Docker all-in-one com PostgreSQL, Redis, servidor e worker):
```bash filename="Terminal"
# Iniciar o servidor local (faz pull da imagem se necessário)
yarn twenty server start
# Verificar o status do servidor
yarn twenty server status
# Transmitir os logs do servidor
yarn twenty server logs
# Parar o servidor
yarn twenty server stop
# Redefinir todos os dados e começar do zero
yarn twenty server reset
```
O servidor local já vem pré-configurado com um espaço de trabalho e um usuário (`tim@apple.dev` / `tim@apple.dev`), para que você possa começar a desenvolver imediatamente, sem qualquer configuração manual.
### Autenticação
Conecte seu aplicativo ao servidor local usando OAuth:
```bash filename="Terminal"
# Authenticate via OAuth (opens browser)
yarn twenty remote add --local
yarn twenty app:dev
```
O gerador de estrutura oferece suporte a dois modos para controlar quais arquivos de exemplo são incluídos:
@@ -21,51 +21,19 @@ Aplicațiile vă permit să construiți și să gestionați personalizările Twe
## Cerințe
* Node.js 24+ și Yarn 4
* Docker (pentru serverul local de dezvoltare Twenty)
* Un spațiu de lucru Twenty și o cheie API (creați una la https://app.twenty.com/settings/api-webhooks)
## Începeți
Creează o aplicație nouă folosind generatorul oficial. Poate porni automat o instanță Twenty locală pentru tine:
Creați o aplicație nouă folosind generatorul oficial, apoi autentificați-vă și începeți să dezvoltați:
```bash filename="Terminal"
# Creează scheletul unei aplicații noi — CLI-ul îți va oferi opțiunea de a porni un server Twenty local
# Creează scheletul unei aplicații noi (include toate exemplele în mod implicit)
npx create-twenty-app@latest my-twenty-app
cd my-twenty-app
# Pornește modul de dezvoltare: sincronizează automat modificările locale cu spațiul tău de lucru
yarn twenty dev
```
### Gestionarea serverului local
SDK-ul include comenzi pentru a gestiona un server local de dezvoltare Twenty (imagine Docker all-in-one cu PostgreSQL, Redis, server și worker):
```bash filename="Terminal"
# Pornește serverul local (descarcă imaginea dacă este necesar)
yarn twenty server start
# Verifică starea serverului
yarn twenty server status
# Afișează în timp real jurnalele serverului
yarn twenty server logs
# Oprește serverul
yarn twenty server stop
# Resetează toate datele și pornește de la zero
yarn twenty server reset
```
Serverul local vine preconfigurat cu un spațiu de lucru și un utilizator (`tim@apple.dev` / `tim@apple.dev`), astfel încât să poți începe să dezvolți imediat, fără nicio configurare manuală.
### Autentificare
Conectează-ți aplicația la serverul local folosind OAuth:
```bash filename="Terminal"
# Autentifică-te prin OAuth (se deschide browserul)
yarn twenty remote add --local
yarn twenty app:dev
```
Generatorul de schelet acceptă două moduri pentru a controla ce fișiere de exemplu sunt incluse:
@@ -21,51 +21,19 @@ description: Создавайте и управляйте настройками
## Требования
* Node.js 24+ и Yarn 4
* Docker (для локального сервера разработки Twenty)
* Рабочее пространство Twenty и ключ API (создайте его на https://app.twenty.com/settings/api-webhooks)
## Начало работы
Создайте новое приложение с помощью официального генератора каркаса. Он может автоматически запустить локальный экземпляр Twenty:
Создайте новое приложение с помощью официального генератора, затем выполните аутентификацию и начните разработку:
```bash filename="Terminal"
# Создать каркас нового приложения — CLI предложит запустить локальный сервер Twenty
# Создать каркас нового приложения (по умолчанию включает все примеры)
npx create-twenty-app@latest my-twenty-app
cd my-twenty-app
# Запустить режим разработки: автоматически синхронизирует локальные изменения с вашим рабочим пространством
yarn twenty dev
```
### Управление локальным сервером
SDK включает команды для управления локальным сервером разработки Twenty (универсальный образ Docker с PostgreSQL, Redis, сервером и воркером):
```bash filename="Terminal"
# Запустить локальный сервер (при необходимости будет загружен образ)
yarn twenty server start
# Проверить статус сервера
yarn twenty server status
# Просмотр логов сервера в реальном времени
yarn twenty server logs
# Остановить сервер
yarn twenty server stop
# Сбросить все данные и начать с нуля
yarn twenty server reset
```
Локальный сервер уже содержит рабочее пространство и пользователя (`tim@apple.dev` / `tim@apple.dev`), так что вы можете сразу начать разработку без какой-либо ручной настройки.
### Аутентификация
Подключите своё приложение к локальному серверу с помощью OAuth:
```bash filename="Terminal"
# Authenticate via OAuth (opens browser)
yarn twenty remote add --local
yarn twenty app:dev
```
Генератор каркаса поддерживает два режима для управления тем, какие файлы-примеры включаются:
@@ -21,51 +21,19 @@ Uygulamalar, Twenty özelleştirmelerini **kod olarak** oluşturup yönetmenizi
## Ön Gereksinimler
* Node.js 24+ ve Yarn 4
* Docker (yerel Twenty geliştirme sunucusu için)
* Bir Twenty çalışma alanı ve bir API anahtarı (https://app.twenty.com/settings/api-webhooks adresinde oluşturun)
## Başlarken
Resmi iskelet oluşturucusunu kullanarak yeni bir uygulama oluşturun. Sizin için otomatik olarak yerel bir Twenty örneğini başlatabilir:
Resmi scaffolder aracını kullanarak yeni bir uygulama oluşturun, ardından kimlik doğrulaması yapıp geliştirmeye başlayın:
```bash filename="Terminal"
# Yeni bir uygulamanın iskeletini oluşturun — CLI yerel bir Twenty sunucusunu başlatmayı önerecektir
# Yeni bir uygulamanın iskeletini oluşturun (varsayılan olarak tüm örnekleri içerir)
npx create-twenty-app@latest my-twenty-app
cd my-twenty-app
# Geliştirme modunu başlatın: yerel değişiklikleri çalışma alanınızla otomatik olarak senkronize eder
yarn twenty dev
```
### Yerel Sunucu Yönetimi
SDK, yerel bir Twenty geliştirme sunucusunu yönetmek için komutlar içerir (PostgreSQL, Redis, sunucu ve worker içeren hepsi bir arada Docker imajı):
```bash filename="Terminal"
# Yerel sunucuyu başlatın (gerekirse imajı indirir)
yarn twenty server start
# Sunucu durumunu kontrol edin
yarn twenty server status
# Sunucu günlüklerini akış olarak görüntüleyin
yarn twenty server logs
# Sunucuyu durdurun
yarn twenty server stop
# Tüm verileri sıfırlayın ve temiz bir başlangıç yapın
yarn twenty server reset
```
Yerel sunucu, bir çalışma alanı ve kullanıcıyla (`tim@apple.dev` / `tim@apple.dev`) önceden yapılandırılmış olarak gelir; böylece herhangi bir manuel kurulum gerektirmeden hemen geliştirmeye başlayabilirsiniz.
### Kimlik Doğrulama
Uygulamanızı OAuth kullanarak yerel sunucuya bağlayın:
```bash filename="Terminal"
# Authenticate via OAuth (opens browser)
yarn twenty remote add --local
# Geliştirme modunu başlatın: yerel değişiklikleri otomatik olarak çalışma alanınızla senkronize eder
yarn twenty app:dev
```
İskelet oluşturucu, hangi örnek dosyaların dahil edileceğini kontrol etmek için iki modu destekler:
@@ -21,51 +21,19 @@ description: 以代码的形式构建并管理 Twenty 自定义项。
## 先决条件
* Node.js 24+ 和 Yarn 4
* Docker (用于本地 Twenty 开发服务器)
* 一个 Twenty 工作空间和一个 API 密钥(在 https://app.twenty.com/settings/api-webhooks 创建)
## 开始使用
使用官方脚手架创建一个新应用。 它可以为你自动启动一个本地 Twenty 实例
使用官方脚手架创建一个新应用,然后进行身份验证并开始开发
```bash filename="Terminal"
# Scaffold a new app — the CLI will offer to start a local Twenty server
# 创建一个新应用脚手架(默认包含所有示例)
npx create-twenty-app@latest my-twenty-app
cd my-twenty-app
# Start dev mode: automatically syncs local changes to your workspace
yarn twenty dev
```
### 本地服务器管理
该 SDK 包含用于管理本地 Twenty 开发服务器的命令(该服务器是一体化 Docker 镜像,内含 PostgreSQL、Redis、服务器和工作进程):
```bash filename="Terminal"
# Start the local server (pulls the image if needed)
yarn twenty server start
# Check server status
yarn twenty server status
# Stream server logs
yarn twenty server logs
# Stop the server
yarn twenty server stop
# Reset all data and start fresh
yarn twenty server reset
```
本地服务器预置了一个工作区和用户 (`tim@apple.dev` / `tim@apple.dev`),因此你可以无需任何手动设置即可立即开始开发。
### 身份验证
使用 OAuth 将你的应用连接到本地服务器:
```bash filename="Terminal"
# 通过 OAuth 进行身份验证 (将打开浏览器)
yarn twenty remote add --local
# 启动开发模式:会将本地更改自动同步到你的工作区
yarn twenty app:dev
```
脚手架工具支持两种模式,用于控制包含哪些示例文件:
@@ -3687,6 +3687,21 @@ 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',
@@ -4723,6 +4738,7 @@ export type StandardOverrides = {
export type Subscription = {
__typename?: 'Subscription';
logicFunctionLogs: LogicFunctionLogs;
onDbEvent: OnDbEvent;
onEventSubscription?: Maybe<EventSubscription>;
};
@@ -4732,6 +4748,11 @@ export type SubscriptionLogicFunctionLogsArgs = {
};
export type SubscriptionOnDbEventArgs = {
input: OnDbEventInput;
};
export type SubscriptionOnEventSubscriptionArgs = {
eventStreamId: Scalars['String'];
};
@@ -72,13 +72,11 @@ export const AgentChatFilePreview = ({
);
const rightComponent = onRemove ? (
<div onClick={(e) => e.stopPropagation()}>
<AvatarOrIcon
Icon={IconX}
IconColor={theme.font.color.secondary}
onClick={onRemove}
/>
</div>
<AvatarOrIcon
Icon={IconX}
IconColor={theme.font.color.secondary}
onClick={onRemove}
/>
) : undefined;
const hasRightDivider = isDefined(onRemove);
@@ -7,7 +7,6 @@ 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';
@@ -42,8 +41,8 @@ export const RecordTitleCellSingleTextDisplayMode = ({
const recordStore = useAtomFamilyStateValue(recordStoreFamilyState, recordId);
const fieldValue = recordStore?.[fieldDefinition.metadata.fieldName];
const isEmpty = !isDefined(fieldValue) || fieldValue.trim() === '';
const isEmpty =
recordStore?.[fieldDefinition.metadata.fieldName]?.trim() === '';
const { openRecordTitleCell } = useRecordTitleCell();
@@ -519,94 +519,6 @@ 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 = {};
@@ -202,7 +202,7 @@ export const buildRecordFromImportedStructuredRow = ({
},
[FieldMetadataType.EMAILS]: {
primaryEmail: (value: unknown) => castToString(value).toLowerCase(),
primaryEmail: castToString,
additionalEmails: stringArrayJSONSchema.parse,
},
[FieldMetadataType.FULL_NAME]: {
@@ -40,7 +40,8 @@ export const SettingsNavigationDrawerItem = ({
to={href}
Icon={item.Icon}
active={isActive}
modifier={item.modifier}
soon={item.soon}
isNew={item.isNew}
onClick={item.onClick}
/>
</AdvancedSettingsWrapper>
@@ -55,7 +56,8 @@ export const SettingsNavigationDrawerItem = ({
to={href || undefined}
Icon={item.Icon}
active={isActive}
modifier={item.modifier}
soon={item.soon}
isNew={item.isNew}
onClick={item.onClick}
/>
);
@@ -7,10 +7,7 @@ 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,
type NavigationDrawerItemModifier,
} from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem';
import { type NavigationDrawerItemIndentationLevel } 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';
@@ -60,7 +57,8 @@ export type SettingsNavigationItem = {
isHidden?: boolean;
subItems?: SettingsNavigationItem[];
isAdvanced?: boolean;
modifier?: NavigationDrawerItemModifier;
soon?: boolean;
isNew?: boolean;
};
const useSettingsNavigationItems = (): SettingsNavigationSection[] => {
@@ -181,7 +179,7 @@ const useSettingsNavigationItems = (): SettingsNavigationSection[] => {
isHidden:
!isApplicationEnabled ||
!permissionMap[PermissionFlagType.WORKSPACE],
modifier: 'new',
isNew: true,
},
{
label: t`AI`,
@@ -189,7 +187,7 @@ const useSettingsNavigationItems = (): SettingsNavigationSection[] => {
Icon: IconSparkles,
isHidden:
!isAIEnabled || !permissionMap[PermissionFlagType.WORKSPACE],
modifier: 'new',
isNew: true,
},
{
label: t`Security`,
@@ -45,7 +45,7 @@ export const SignInAppNavigationDrawerMock = ({
label={t`Search`}
Icon={IconSearch}
onClick={() => {}}
modifier={{ keyboard: [getOsControlSymbol(), 'K'] }}
keyboard={[getOsControlSymbol(), 'K']}
/>
<NavigationDrawerItem
label={t`Settings`}
@@ -37,11 +37,6 @@ const DEFAULT_INDENTATION_LEVEL = 1;
export type NavigationDrawerItemIndentationLevel = 1 | 2;
export type NavigationDrawerItemModifier =
| 'soon'
| 'new'
| { keyboard: string[] };
export type NavigationDrawerItemProps = {
className?: string;
label: string;
@@ -53,12 +48,17 @@ export type NavigationDrawerItemProps = {
Icon?: IconComponent | ((props: TablerIconsProps) => JSX.Element);
iconColor?: string | null;
active?: boolean;
modifier?: NavigationDrawerItemModifier;
danger?: boolean;
soon?: boolean;
isNew?: boolean;
count?: number;
keyboard?: string[];
rightOptions?: ReactNode;
alwaysShowRightOptions?: boolean;
isDragging?: boolean;
isRightOptionsDropdownOpen?: boolean;
triggerEvent?: TriggerEventType;
mouseUpNavigation?: boolean;
preventCollapseOnMobile?: boolean;
isSelectedInEditMode?: boolean;
variant?: 'default' | 'tertiary';
@@ -67,13 +67,14 @@ export type NavigationDrawerItemProps = {
type StyledItemProps = Pick<
NavigationDrawerItemProps,
| 'active'
| 'danger'
| 'indentationLevel'
| 'soon'
| 'to'
| 'isDragging'
| 'isSelectedInEditMode'
| 'variant'
> & {
isSoon: boolean;
isNavigationDrawerExpanded: boolean;
hasRightOptions: boolean;
href?: string;
@@ -91,11 +92,14 @@ const StyledItem = styled.button<StyledItemProps>`
: '1px solid transparent'};
border-radius: ${themeCssVariables.border.radius.sm};
box-sizing: border-box;
color: ${({ active, isSoon, variant }) => {
color: ${({ active, danger, soon, variant }) => {
if (active === true) {
return themeCssVariables.font.color.primary;
}
if (isSoon) {
if (danger === true) {
return themeCssVariables.color.red;
}
if (soon === true) {
return themeCssVariables.font.color.light;
}
if (variant === 'tertiary') {
@@ -103,8 +107,8 @@ const StyledItem = styled.button<StyledItemProps>`
}
return themeCssVariables.font.color.secondary;
}};
cursor: ${({ isSoon, isDragging }) =>
isDragging ? 'grabbing' : isSoon ? 'default' : 'pointer'};
cursor: ${({ soon, isDragging }) =>
isDragging ? 'grabbing' : soon ? 'default' : 'pointer'};
display: flex;
font-family: ${themeCssVariables.font.family};
font-size: ${themeCssVariables.font.size.md};
@@ -118,7 +122,7 @@ const StyledItem = styled.button<StyledItemProps>`
? themeCssVariables.spacing['0.5']
: themeCssVariables.spacing[1]};
padding-top: ${themeCssVariables.spacing[1]};
pointer-events: ${({ isSoon }) => (isSoon ? 'none' : 'auto')};
pointer-events: ${({ soon }) => (soon ? 'none' : 'auto')};
text-decoration: none;
user-select: none;
width: ${({ isNavigationDrawerExpanded, hasRightOptions }) =>
@@ -128,7 +132,10 @@ const StyledItem = styled.button<StyledItemProps>`
&:hover {
background: ${themeCssVariables.background.transparent.light};
color: ${themeCssVariables.font.color.primary};
color: ${({ danger }) =>
danger
? themeCssVariables.color.red
: themeCssVariables.font.color.primary};
}
&:hover .keyboard-shortcuts {
@@ -165,6 +172,20 @@ 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};
@@ -256,13 +277,18 @@ export const NavigationDrawerItem = ({
to,
onClick,
active,
modifier,
danger,
soon,
isNew,
count,
keyboard,
subItemState,
rightOptions,
alwaysShowRightOptions = false,
isDragging,
isRightOptionsDropdownOpen,
triggerEvent,
mouseUpNavigation = false,
preventCollapseOnMobile = false,
isSelectedInEditMode = false,
variant = 'default',
@@ -275,15 +301,10 @@ 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 = isDefined(modifier) || isDefined(rightOptions);
const showStyledSpacer = Boolean(
soon || isNew || count || keyboard || rightOptions,
);
const handleMobileNavigation = () => {
if (isMobile && !preventCollapseOnMobile) {
@@ -293,7 +314,6 @@ export const NavigationDrawerItem = ({
const isExternalLink =
isDefined(to) && (to.startsWith('http://') || to.startsWith('https://'));
const isInternalLink = isDefined(to) && !isExternalLink;
const handleExternalLinkClick = () => {
handleMobileNavigation();
@@ -312,37 +332,40 @@ 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={handleMouseDownNavigationClickClick}
onMouseDown={handleMouseDown}
onClick={
mouseUpNavigation ? onClick : handleMouseDownNavigationClickClick
}
onMouseDown={mouseUpNavigation ? undefined : handleMouseDown}
active={active}
aria-selected={active}
isSoon={isSoon}
danger={danger}
soon={soon}
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 && (
@@ -396,7 +419,7 @@ export const NavigationDrawerItem = ({
{showStyledSpacer && <StyledSpacer />}
{isSoon && (
{soon && (
<NavigationDrawerAnimatedCollapseWrapper>
<Pill label={t`Soon`} />
</NavigationDrawerAnimatedCollapseWrapper>
@@ -408,25 +431,23 @@ export const NavigationDrawerItem = ({
</NavigationDrawerAnimatedCollapseWrapper>
)}
{isDefined(keyboardKeys) && (
{!!count && (
<NavigationDrawerAnimatedCollapseWrapper>
<StyledItemCount>{count}</StyledItemCount>
</NavigationDrawerAnimatedCollapseWrapper>
)}
{keyboard && (
<NavigationDrawerAnimatedCollapseWrapper>
<StyledKeyBoardShortcut className="keyboard-shortcuts">
<Label>{keyboardKeys}</Label>
<Label>{keyboard}</Label>
</StyledKeyBoardShortcut>
</NavigationDrawerAnimatedCollapseWrapper>
)}
{isDefined(rightOptions) && (
<NavigationDrawerAnimatedCollapseWrapper>
{/* 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()}
>
<StyledRightOptionsContainer>
<StyledRightOptionsVisbility
data-visible={
isMobile ||
@@ -14,7 +14,10 @@ export const NavigationDrawerSubItem = ({
to,
onClick,
active,
modifier,
danger,
soon,
count,
keyboard,
subItemState,
rightOptions,
isDragging,
@@ -34,7 +37,10 @@ export const NavigationDrawerSubItem = ({
to={to}
onClick={onClick}
active={active}
modifier={modifier}
danger={danger}
soon={soon}
count={count}
keyboard={keyboard}
rightOptions={rightOptions}
isDragging={isDragging}
isSelectedInEditMode={isSelectedInEditMode}
@@ -86,19 +86,24 @@ export const Default: Story = {
label="Notifications"
to="/inbox"
Icon={IconBell}
modifier="soon"
soon={true}
/>
<NavigationDrawerItem
label="Search"
Icon={IconSearch}
modifier={{ keyboard: [`${getOsControlSymbol()}`, 'K'] }}
keyboard={[`${getOsControlSymbol()}`, 'K']}
/>
<NavigationDrawerItem
label="Settings"
to="/settings/profile"
Icon={IconSettings}
/>
<NavigationDrawerItem label="Tasks" to="/tasks" Icon={IconCheckbox} />
<NavigationDrawerItem
label="Tasks"
to="/tasks"
Icon={IconCheckbox}
count={2}
/>
</NavigationDrawerSection>
<NavigationDrawerSection>
@@ -105,14 +105,23 @@ export const NewPill: Story = {
args={{
label: 'New Feature',
Icon: IconSearch,
modifier: 'new',
isNew: true,
}}
/>
<Story
args={{
label: 'Feature with Keyboard Shortcut',
label: 'New Feature with Count',
Icon: IconSearch,
modifier: { keyboard: [getOsControlSymbol(), 'N'] },
isNew: true,
count: 5,
}}
/>
<Story
args={{
label: 'New Feature with Keyboard Shortcut',
Icon: IconSearch,
isNew: true,
keyboard: [getOsControlSymbol(), 'N'],
}}
/>
</StyledContainer>
@@ -186,6 +195,12 @@ 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],
@@ -201,19 +216,23 @@ export const Catalog: CatalogStory<Story, typeof NavigationDrawerItem> = {
},
{
name: 'adornments',
values: ['Without Modifier', 'Soon', 'New', 'Keyboard Keys'],
values: [
'Without Adornments',
'Soon Pill',
'New Pill',
'Count',
'Keyboard Keys',
],
props: (adornmentName: string) =>
adornmentName === 'Soon'
? { modifier: 'soon' }
: adornmentName === 'New'
? { modifier: 'new' }
: adornmentName === 'Keyboard Keys'
? {
modifier: {
keyboard: [getOsControlSymbol(), 'K'],
},
}
: {},
adornmentName === 'Soon Pill'
? { soon: true }
: adornmentName === 'New Pill'
? { isNew: true }
: adornmentName === 'Count'
? { count: 3 }
: adornmentName === 'Keyboard Keys'
? { keyboard: [getOsControlSymbol(), 'K'] }
: {},
},
],
},
@@ -1236,6 +1236,107 @@ 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!
@@ -1422,14 +1523,6 @@ type ApprovedAccessDomain {
createdAt: DateTime!
}
type FileWithSignedUrl {
id: UUID!
path: String!
size: Float!
createdAt: DateTime!
url: String!
}
type WorkspaceInvitation {
id: UUID!
email: String!
@@ -1865,91 +1958,6 @@ 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
@@ -4308,10 +4316,17 @@ 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
@@ -963,6 +963,101 @@ export interface PageLayout {
export type PageLayoutType = 'RECORD_INDEX' | 'RECORD_PAGE' | 'DASHBOARD'
export interface FileWithSignedUrl {
id: Scalars['UUID']
path: Scalars['String']
size: Scalars['Float']
createdAt: Scalars['DateTime']
url: Scalars['String']
__typename: 'FileWithSignedUrl'
}
export interface RecordIdentifier {
id: Scalars['UUID']
labelIdentifier: Scalars['String']
imageIdentifier?: Scalars['String']
__typename: 'RecordIdentifier'
}
export interface NavigationMenuItem {
id: Scalars['UUID']
userWorkspaceId?: Scalars['UUID']
targetRecordId?: Scalars['UUID']
targetObjectMetadataId?: Scalars['UUID']
viewId?: Scalars['UUID']
type: NavigationMenuItemType
name?: Scalars['String']
link?: Scalars['String']
icon?: Scalars['String']
color?: Scalars['String']
folderId?: Scalars['UUID']
position: Scalars['Float']
applicationId?: Scalars['UUID']
createdAt: Scalars['DateTime']
updatedAt: Scalars['DateTime']
targetRecordIdentifier?: RecordIdentifier
__typename: 'NavigationMenuItem'
}
export type NavigationMenuItemType = 'VIEW' | 'FOLDER' | 'LINK' | 'OBJECT' | 'RECORD'
export interface ObjectRecordEventProperties {
updatedFields?: Scalars['String'][]
before?: Scalars['JSON']
after?: Scalars['JSON']
diff?: Scalars['JSON']
__typename: 'ObjectRecordEventProperties'
}
export interface MetadataEvent {
type: MetadataEventAction
metadataName: Scalars['String']
recordId: Scalars['String']
properties: ObjectRecordEventProperties
updatedCollectionHash?: Scalars['String']
__typename: 'MetadataEvent'
}
/** Metadata Event Action */
export type MetadataEventAction = 'CREATED' | 'UPDATED' | 'DELETED'
export interface ObjectRecordEvent {
action: DatabaseEventAction
objectNameSingular: Scalars['String']
recordId: Scalars['String']
userId?: Scalars['String']
workspaceMemberId?: Scalars['String']
properties: ObjectRecordEventProperties
__typename: 'ObjectRecordEvent'
}
/** Database Event Action */
export type DatabaseEventAction = 'CREATED' | 'UPDATED' | 'DELETED' | 'DESTROYED' | 'RESTORED' | 'UPSERTED'
export interface ObjectRecordEventWithQueryIds {
queryIds: Scalars['String'][]
objectRecordEvent: ObjectRecordEvent
__typename: 'ObjectRecordEventWithQueryIds'
}
export interface EventSubscription {
eventStreamId: Scalars['String']
objectRecordEventsWithQueryIds: ObjectRecordEventWithQueryIds[]
metadataEvents: MetadataEvent[]
__typename: 'EventSubscription'
}
export interface OnDbEvent {
action: DatabaseEventAction
objectNameSingular: Scalars['String']
eventDate: Scalars['DateTime']
record: Scalars['JSON']
updatedFields?: Scalars['String'][]
__typename: 'OnDbEvent'
}
export interface Analytics {
/** Boolean that confirms query was dispatched */
success: Scalars['Boolean']
@@ -1146,15 +1241,6 @@ export interface ApprovedAccessDomain {
__typename: 'ApprovedAccessDomain'
}
export interface FileWithSignedUrl {
id: Scalars['UUID']
path: Scalars['String']
size: Scalars['Float']
createdAt: Scalars['DateTime']
url: Scalars['String']
__typename: 'FileWithSignedUrl'
}
export interface WorkspaceInvitation {
id: Scalars['UUID']
email: Scalars['String']
@@ -1601,83 +1687,6 @@ export interface WorkspaceInviteHashValid {
__typename: 'WorkspaceInviteHashValid'
}
export interface RecordIdentifier {
id: Scalars['UUID']
labelIdentifier: Scalars['String']
imageIdentifier?: Scalars['String']
__typename: 'RecordIdentifier'
}
export interface NavigationMenuItem {
id: Scalars['UUID']
userWorkspaceId?: Scalars['UUID']
targetRecordId?: Scalars['UUID']
targetObjectMetadataId?: Scalars['UUID']
viewId?: Scalars['UUID']
type: NavigationMenuItemType
name?: Scalars['String']
link?: Scalars['String']
icon?: Scalars['String']
color?: Scalars['String']
folderId?: Scalars['UUID']
position: Scalars['Float']
applicationId?: Scalars['UUID']
createdAt: Scalars['DateTime']
updatedAt: Scalars['DateTime']
targetRecordIdentifier?: RecordIdentifier
__typename: 'NavigationMenuItem'
}
export type NavigationMenuItemType = 'VIEW' | 'FOLDER' | 'LINK' | 'OBJECT' | 'RECORD'
export interface ObjectRecordEventProperties {
updatedFields?: Scalars['String'][]
before?: Scalars['JSON']
after?: Scalars['JSON']
diff?: Scalars['JSON']
__typename: 'ObjectRecordEventProperties'
}
export interface MetadataEvent {
type: MetadataEventAction
metadataName: Scalars['String']
recordId: Scalars['String']
properties: ObjectRecordEventProperties
updatedCollectionHash?: Scalars['String']
__typename: 'MetadataEvent'
}
/** Metadata Event Action */
export type MetadataEventAction = 'CREATED' | 'UPDATED' | 'DELETED'
export interface ObjectRecordEvent {
action: DatabaseEventAction
objectNameSingular: Scalars['String']
recordId: Scalars['String']
userId?: Scalars['String']
workspaceMemberId?: Scalars['String']
properties: ObjectRecordEventProperties
__typename: 'ObjectRecordEvent'
}
/** Database Event Action */
export type DatabaseEventAction = 'CREATED' | 'UPDATED' | 'DELETED' | 'DESTROYED' | 'RESTORED' | 'UPSERTED'
export interface ObjectRecordEventWithQueryIds {
queryIds: Scalars['String'][]
objectRecordEvent: ObjectRecordEvent
__typename: 'ObjectRecordEventWithQueryIds'
}
export interface EventSubscription {
eventStreamId: Scalars['String']
objectRecordEventsWithQueryIds: ObjectRecordEventWithQueryIds[]
metadataEvents: MetadataEvent[]
__typename: 'EventSubscription'
}
export interface LogicFunctionExecutionResult {
/** Execution result in JSON format */
data?: Scalars['JSON']
@@ -2872,6 +2881,7 @@ export type WorkspaceMigrationActionType = 'delete' | 'create' | 'update'
export type FileFolder = 'ProfilePicture' | 'WorkspaceLogo' | 'Attachment' | 'PersonPicture' | 'CorePicture' | 'File' | 'AgentChat' | 'BuiltLogicFunction' | 'BuiltFrontComponent' | 'PublicAsset' | 'Source' | 'FilesField' | 'Dependencies' | 'Workflow' | 'AppTarball'
export interface Subscription {
onDbEvent: OnDbEvent
onEventSubscription?: EventSubscription
logicFunctionLogs: LogicFunctionLogs
__typename: 'Subscription'
@@ -3887,6 +3897,100 @@ export interface PageLayoutGenqlSelection{
__scalar?: boolean | number
}
export interface FileWithSignedUrlGenqlSelection{
id?: boolean | number
path?: boolean | number
size?: boolean | number
createdAt?: boolean | number
url?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface RecordIdentifierGenqlSelection{
id?: boolean | number
labelIdentifier?: boolean | number
imageIdentifier?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface NavigationMenuItemGenqlSelection{
id?: boolean | number
userWorkspaceId?: boolean | number
targetRecordId?: boolean | number
targetObjectMetadataId?: boolean | number
viewId?: boolean | number
type?: boolean | number
name?: boolean | number
link?: boolean | number
icon?: boolean | number
color?: boolean | number
folderId?: boolean | number
position?: boolean | number
applicationId?: boolean | number
createdAt?: boolean | number
updatedAt?: boolean | number
targetRecordIdentifier?: RecordIdentifierGenqlSelection
__typename?: boolean | number
__scalar?: boolean | number
}
export interface ObjectRecordEventPropertiesGenqlSelection{
updatedFields?: boolean | number
before?: boolean | number
after?: boolean | number
diff?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface MetadataEventGenqlSelection{
type?: boolean | number
metadataName?: boolean | number
recordId?: boolean | number
properties?: ObjectRecordEventPropertiesGenqlSelection
updatedCollectionHash?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface ObjectRecordEventGenqlSelection{
action?: boolean | number
objectNameSingular?: boolean | number
recordId?: boolean | number
userId?: boolean | number
workspaceMemberId?: boolean | number
properties?: ObjectRecordEventPropertiesGenqlSelection
__typename?: boolean | number
__scalar?: boolean | number
}
export interface ObjectRecordEventWithQueryIdsGenqlSelection{
queryIds?: boolean | number
objectRecordEvent?: ObjectRecordEventGenqlSelection
__typename?: boolean | number
__scalar?: boolean | number
}
export interface EventSubscriptionGenqlSelection{
eventStreamId?: boolean | number
objectRecordEventsWithQueryIds?: ObjectRecordEventWithQueryIdsGenqlSelection
metadataEvents?: MetadataEventGenqlSelection
__typename?: boolean | number
__scalar?: boolean | number
}
export interface OnDbEventGenqlSelection{
action?: boolean | number
objectNameSingular?: boolean | number
eventDate?: boolean | number
record?: boolean | number
updatedFields?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface AnalyticsGenqlSelection{
/** Boolean that confirms query was dispatched */
success?: boolean | number
@@ -4077,16 +4181,6 @@ export interface ApprovedAccessDomainGenqlSelection{
__scalar?: boolean | number
}
export interface FileWithSignedUrlGenqlSelection{
id?: boolean | number
path?: boolean | number
size?: boolean | number
createdAt?: boolean | number
url?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface WorkspaceInvitationGenqlSelection{
id?: boolean | number
email?: boolean | number
@@ -4582,80 +4676,6 @@ export interface WorkspaceInviteHashValidGenqlSelection{
__scalar?: boolean | number
}
export interface RecordIdentifierGenqlSelection{
id?: boolean | number
labelIdentifier?: boolean | number
imageIdentifier?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface NavigationMenuItemGenqlSelection{
id?: boolean | number
userWorkspaceId?: boolean | number
targetRecordId?: boolean | number
targetObjectMetadataId?: boolean | number
viewId?: boolean | number
type?: boolean | number
name?: boolean | number
link?: boolean | number
icon?: boolean | number
color?: boolean | number
folderId?: boolean | number
position?: boolean | number
applicationId?: boolean | number
createdAt?: boolean | number
updatedAt?: boolean | number
targetRecordIdentifier?: RecordIdentifierGenqlSelection
__typename?: boolean | number
__scalar?: boolean | number
}
export interface ObjectRecordEventPropertiesGenqlSelection{
updatedFields?: boolean | number
before?: boolean | number
after?: boolean | number
diff?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface MetadataEventGenqlSelection{
type?: boolean | number
metadataName?: boolean | number
recordId?: boolean | number
properties?: ObjectRecordEventPropertiesGenqlSelection
updatedCollectionHash?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface ObjectRecordEventGenqlSelection{
action?: boolean | number
objectNameSingular?: boolean | number
recordId?: boolean | number
userId?: boolean | number
workspaceMemberId?: boolean | number
properties?: ObjectRecordEventPropertiesGenqlSelection
__typename?: boolean | number
__scalar?: boolean | number
}
export interface ObjectRecordEventWithQueryIdsGenqlSelection{
queryIds?: boolean | number
objectRecordEvent?: ObjectRecordEventGenqlSelection
__typename?: boolean | number
__scalar?: boolean | number
}
export interface EventSubscriptionGenqlSelection{
eventStreamId?: boolean | number
objectRecordEventsWithQueryIds?: ObjectRecordEventWithQueryIdsGenqlSelection
metadataEvents?: MetadataEventGenqlSelection
__typename?: boolean | number
__scalar?: boolean | number
}
export interface LogicFunctionExecutionResultGenqlSelection{
/** Execution result in JSON format */
data?: boolean | number
@@ -6246,12 +6266,15 @@ export interface WorkspaceMigrationInput {actions: WorkspaceMigrationDeleteActio
export interface WorkspaceMigrationDeleteActionInput {type: WorkspaceMigrationActionType,metadataName: AllMetadataName,universalIdentifier: Scalars['String']}
export interface SubscriptionGenqlSelection{
onDbEvent?: (OnDbEventGenqlSelection & { __args: {input: OnDbEventInput} })
onEventSubscription?: (EventSubscriptionGenqlSelection & { __args: {eventStreamId: Scalars['String']} })
logicFunctionLogs?: (LogicFunctionLogsGenqlSelection & { __args: {input: LogicFunctionLogsInput} })
__typename?: boolean | number
__scalar?: boolean | number
}
export interface OnDbEventInput {action?: (DatabaseEventAction | null),objectNameSingular?: (Scalars['String'] | null),recordId?: (Scalars['UUID'] | null)}
export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null),applicationUniversalIdentifier?: (Scalars['UUID'] | null),name?: (Scalars['String'] | null),id?: (Scalars['UUID'] | null),universalIdentifier?: (Scalars['UUID'] | null)}
@@ -6799,6 +6822,78 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const FileWithSignedUrl_possibleTypes: string[] = ['FileWithSignedUrl']
export const isFileWithSignedUrl = (obj?: { __typename?: any } | null): obj is FileWithSignedUrl => {
if (!obj?.__typename) throw new Error('__typename is missing in "isFileWithSignedUrl"')
return FileWithSignedUrl_possibleTypes.includes(obj.__typename)
}
const RecordIdentifier_possibleTypes: string[] = ['RecordIdentifier']
export const isRecordIdentifier = (obj?: { __typename?: any } | null): obj is RecordIdentifier => {
if (!obj?.__typename) throw new Error('__typename is missing in "isRecordIdentifier"')
return RecordIdentifier_possibleTypes.includes(obj.__typename)
}
const NavigationMenuItem_possibleTypes: string[] = ['NavigationMenuItem']
export const isNavigationMenuItem = (obj?: { __typename?: any } | null): obj is NavigationMenuItem => {
if (!obj?.__typename) throw new Error('__typename is missing in "isNavigationMenuItem"')
return NavigationMenuItem_possibleTypes.includes(obj.__typename)
}
const ObjectRecordEventProperties_possibleTypes: string[] = ['ObjectRecordEventProperties']
export const isObjectRecordEventProperties = (obj?: { __typename?: any } | null): obj is ObjectRecordEventProperties => {
if (!obj?.__typename) throw new Error('__typename is missing in "isObjectRecordEventProperties"')
return ObjectRecordEventProperties_possibleTypes.includes(obj.__typename)
}
const MetadataEvent_possibleTypes: string[] = ['MetadataEvent']
export const isMetadataEvent = (obj?: { __typename?: any } | null): obj is MetadataEvent => {
if (!obj?.__typename) throw new Error('__typename is missing in "isMetadataEvent"')
return MetadataEvent_possibleTypes.includes(obj.__typename)
}
const ObjectRecordEvent_possibleTypes: string[] = ['ObjectRecordEvent']
export const isObjectRecordEvent = (obj?: { __typename?: any } | null): obj is ObjectRecordEvent => {
if (!obj?.__typename) throw new Error('__typename is missing in "isObjectRecordEvent"')
return ObjectRecordEvent_possibleTypes.includes(obj.__typename)
}
const ObjectRecordEventWithQueryIds_possibleTypes: string[] = ['ObjectRecordEventWithQueryIds']
export const isObjectRecordEventWithQueryIds = (obj?: { __typename?: any } | null): obj is ObjectRecordEventWithQueryIds => {
if (!obj?.__typename) throw new Error('__typename is missing in "isObjectRecordEventWithQueryIds"')
return ObjectRecordEventWithQueryIds_possibleTypes.includes(obj.__typename)
}
const EventSubscription_possibleTypes: string[] = ['EventSubscription']
export const isEventSubscription = (obj?: { __typename?: any } | null): obj is EventSubscription => {
if (!obj?.__typename) throw new Error('__typename is missing in "isEventSubscription"')
return EventSubscription_possibleTypes.includes(obj.__typename)
}
const OnDbEvent_possibleTypes: string[] = ['OnDbEvent']
export const isOnDbEvent = (obj?: { __typename?: any } | null): obj is OnDbEvent => {
if (!obj?.__typename) throw new Error('__typename is missing in "isOnDbEvent"')
return OnDbEvent_possibleTypes.includes(obj.__typename)
}
const Analytics_possibleTypes: string[] = ['Analytics']
export const isAnalytics = (obj?: { __typename?: any } | null): obj is Analytics => {
if (!obj?.__typename) throw new Error('__typename is missing in "isAnalytics"')
@@ -6967,14 +7062,6 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const FileWithSignedUrl_possibleTypes: string[] = ['FileWithSignedUrl']
export const isFileWithSignedUrl = (obj?: { __typename?: any } | null): obj is FileWithSignedUrl => {
if (!obj?.__typename) throw new Error('__typename is missing in "isFileWithSignedUrl"')
return FileWithSignedUrl_possibleTypes.includes(obj.__typename)
}
const WorkspaceInvitation_possibleTypes: string[] = ['WorkspaceInvitation']
export const isWorkspaceInvitation = (obj?: { __typename?: any } | null): obj is WorkspaceInvitation => {
if (!obj?.__typename) throw new Error('__typename is missing in "isWorkspaceInvitation"')
@@ -7463,62 +7550,6 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const RecordIdentifier_possibleTypes: string[] = ['RecordIdentifier']
export const isRecordIdentifier = (obj?: { __typename?: any } | null): obj is RecordIdentifier => {
if (!obj?.__typename) throw new Error('__typename is missing in "isRecordIdentifier"')
return RecordIdentifier_possibleTypes.includes(obj.__typename)
}
const NavigationMenuItem_possibleTypes: string[] = ['NavigationMenuItem']
export const isNavigationMenuItem = (obj?: { __typename?: any } | null): obj is NavigationMenuItem => {
if (!obj?.__typename) throw new Error('__typename is missing in "isNavigationMenuItem"')
return NavigationMenuItem_possibleTypes.includes(obj.__typename)
}
const ObjectRecordEventProperties_possibleTypes: string[] = ['ObjectRecordEventProperties']
export const isObjectRecordEventProperties = (obj?: { __typename?: any } | null): obj is ObjectRecordEventProperties => {
if (!obj?.__typename) throw new Error('__typename is missing in "isObjectRecordEventProperties"')
return ObjectRecordEventProperties_possibleTypes.includes(obj.__typename)
}
const MetadataEvent_possibleTypes: string[] = ['MetadataEvent']
export const isMetadataEvent = (obj?: { __typename?: any } | null): obj is MetadataEvent => {
if (!obj?.__typename) throw new Error('__typename is missing in "isMetadataEvent"')
return MetadataEvent_possibleTypes.includes(obj.__typename)
}
const ObjectRecordEvent_possibleTypes: string[] = ['ObjectRecordEvent']
export const isObjectRecordEvent = (obj?: { __typename?: any } | null): obj is ObjectRecordEvent => {
if (!obj?.__typename) throw new Error('__typename is missing in "isObjectRecordEvent"')
return ObjectRecordEvent_possibleTypes.includes(obj.__typename)
}
const ObjectRecordEventWithQueryIds_possibleTypes: string[] = ['ObjectRecordEventWithQueryIds']
export const isObjectRecordEventWithQueryIds = (obj?: { __typename?: any } | null): obj is ObjectRecordEventWithQueryIds => {
if (!obj?.__typename) throw new Error('__typename is missing in "isObjectRecordEventWithQueryIds"')
return ObjectRecordEventWithQueryIds_possibleTypes.includes(obj.__typename)
}
const EventSubscription_possibleTypes: string[] = ['EventSubscription']
export const isEventSubscription = (obj?: { __typename?: any } | null): obj is EventSubscription => {
if (!obj?.__typename) throw new Error('__typename is missing in "isEventSubscription"')
return EventSubscription_possibleTypes.includes(obj.__typename)
}
const LogicFunctionExecutionResult_possibleTypes: string[] = ['LogicFunctionExecutionResult']
export const isLogicFunctionExecutionResult = (obj?: { __typename?: any } | null): obj is LogicFunctionExecutionResult => {
if (!obj?.__typename) throw new Error('__typename is missing in "isLogicFunctionExecutionResult"')
@@ -8560,6 +8591,29 @@ export const enumPageLayoutType = {
DASHBOARD: 'DASHBOARD' as const
}
export const enumNavigationMenuItemType = {
VIEW: 'VIEW' as const,
FOLDER: 'FOLDER' as const,
LINK: 'LINK' as const,
OBJECT: 'OBJECT' as const,
RECORD: 'RECORD' as const
}
export const enumMetadataEventAction = {
CREATED: 'CREATED' as const,
UPDATED: 'UPDATED' as const,
DELETED: 'DELETED' as const
}
export const enumDatabaseEventAction = {
CREATED: 'CREATED' as const,
UPDATED: 'UPDATED' as const,
DELETED: 'DELETED' as const,
DESTROYED: 'DESTROYED' as const,
RESTORED: 'RESTORED' as const,
UPSERTED: 'UPSERTED' as const
}
export const enumBillingPlanKey = {
PRO: 'PRO' as const,
ENTERPRISE: 'ENTERPRISE' as const
@@ -8638,29 +8692,6 @@ export const enumRelationType = {
MANY_TO_ONE: 'MANY_TO_ONE' as const
}
export const enumNavigationMenuItemType = {
VIEW: 'VIEW' as const,
FOLDER: 'FOLDER' as const,
LINK: 'LINK' as const,
OBJECT: 'OBJECT' as const,
RECORD: 'RECORD' as const
}
export const enumMetadataEventAction = {
CREATED: 'CREATED' as const,
UPDATED: 'UPDATED' as const,
DELETED: 'DELETED' as const
}
export const enumDatabaseEventAction = {
CREATED: 'CREATED' as const,
UPDATED: 'UPDATED' as const,
DELETED: 'DELETED' as const,
DESTROYED: 'DESTROYED' as const,
RESTORED: 'RESTORED' as const,
UPSERTED: 'UPSERTED' as const
}
export const enumLogicFunctionExecutionStatus = {
IDLE: 'IDLE' as const,
SUCCESS: 'SUCCESS' as const,
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,6 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { FieldMetadataType, RelationType } from 'twenty-shared/types';
import { FieldMetadataType } 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,118 +116,6 @@ 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,
@@ -258,11 +146,6 @@ describe('DataArgProcessorService', () => {
authContext: createMockAuthContext(),
flatObjectMetadata,
flatFieldMetadataMaps,
flatObjectMetadataMaps: {
byUniversalIdentifier: {},
universalIdentifierById: {},
universalIdentifiersByApplicationId: {},
},
}),
).rejects.toThrowErrorMatchingSnapshot();
});
@@ -300,11 +183,6 @@ 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, isObject, isUndefined } from '@sniptt/guards';
import { isNull, isUndefined } from '@sniptt/guards';
import {
FieldMetadataSettingsMapping,
FieldMetadataType,
@@ -15,6 +15,7 @@ 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';
@@ -22,7 +23,6 @@ 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 { 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 { buildFieldMapsFromFlatObjectMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/build-field-maps-from-flat-object-metadata.util';
import { FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
@Injectable()
@@ -73,14 +73,12 @@ 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)) {
@@ -160,8 +158,6 @@ export class DataArgProcessorService {
fieldMetadata,
key,
value,
flatFieldMetadataMaps,
flatObjectMetadataMaps,
);
}
processedRecords.push(processedRecord);
@@ -174,8 +170,6 @@ export class DataArgProcessorService {
fieldMetadata: FlatFieldMetadata,
key: string,
value: unknown,
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>,
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>,
): Promise<unknown> {
switch (fieldMetadata.type) {
case FieldMetadataType.POSITION:
@@ -266,29 +260,6 @@ 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: {
@@ -354,85 +325,4 @@ 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,12 +145,8 @@ export class CommonCreateManyQueryRunnerService extends CommonBaseQueryRunnerSer
args: CommonInput<CreateManyQueryArgs>,
queryRunnerContext: CommonBaseQueryRunnerContext,
): Promise<CommonInput<CreateManyQueryArgs>> {
const {
authContext,
flatObjectMetadata,
flatFieldMetadataMaps,
flatObjectMetadataMaps,
} = queryRunnerContext;
const { authContext, flatObjectMetadata, flatFieldMetadataMaps } =
queryRunnerContext;
return {
...args,
@@ -159,7 +155,6 @@ export class CommonCreateManyQueryRunnerService extends CommonBaseQueryRunnerSer
authContext,
flatObjectMetadata,
flatFieldMetadataMaps,
flatObjectMetadataMaps,
}),
};
}
@@ -2,6 +2,7 @@ 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';
@@ -14,7 +15,6 @@ 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,19 +52,14 @@ export class CommonCreateOneQueryRunnerService extends CommonBaseQueryRunnerServ
args: CommonInput<CreateOneQueryArgs>,
queryRunnerContext: CommonBaseQueryRunnerContext,
): Promise<CommonInput<CreateOneQueryArgs>> {
const {
authContext,
flatObjectMetadata,
flatFieldMetadataMaps,
flatObjectMetadataMaps,
} = queryRunnerContext;
const { authContext, flatObjectMetadata, flatFieldMetadataMaps } =
queryRunnerContext;
const coercedData = await this.dataArgProcessor.process({
partialRecordInputs: [args.data],
authContext,
flatObjectMetadata,
flatFieldMetadataMaps,
flatObjectMetadataMaps,
});
return {
@@ -176,12 +176,8 @@ export class CommonFindDuplicatesQueryRunnerService extends CommonBaseQueryRunne
args: CommonInput<FindDuplicatesQueryArgs>,
queryRunnerContext: CommonBaseQueryRunnerContext,
): Promise<CommonInput<FindDuplicatesQueryArgs>> {
const {
authContext,
flatObjectMetadata,
flatFieldMetadataMaps,
flatObjectMetadataMaps,
} = queryRunnerContext;
const { authContext, flatObjectMetadata, flatFieldMetadataMaps } =
queryRunnerContext;
const { fieldIdByName } = buildFieldMapsFromFlatObjectMetadata(
flatFieldMetadataMaps,
@@ -206,7 +202,6 @@ export class CommonFindDuplicatesQueryRunnerService extends CommonBaseQueryRunne
authContext,
flatObjectMetadata,
flatFieldMetadataMaps,
flatObjectMetadataMaps,
shouldBackfillPositionIfUndefined: false,
}),
};
@@ -100,12 +100,8 @@ export class CommonUpdateManyQueryRunnerService extends CommonBaseQueryRunnerSer
args: CommonInput<UpdateManyQueryArgs>,
queryRunnerContext: CommonBaseQueryRunnerContext,
): Promise<CommonInput<UpdateManyQueryArgs>> {
const {
authContext,
flatObjectMetadata,
flatFieldMetadataMaps,
flatObjectMetadataMaps,
} = queryRunnerContext;
const { authContext, flatObjectMetadata, flatFieldMetadataMaps } =
queryRunnerContext;
return {
...args,
@@ -120,7 +116,6 @@ export class CommonUpdateManyQueryRunnerService extends CommonBaseQueryRunnerSer
authContext,
flatObjectMetadata,
flatFieldMetadataMaps,
flatObjectMetadataMaps,
shouldBackfillPositionIfUndefined: false,
})
)[0],
@@ -65,12 +65,8 @@ export class CommonUpdateOneQueryRunnerService extends CommonBaseQueryRunnerServ
args: CommonInput<UpdateOneQueryArgs>,
queryRunnerContext: CommonBaseQueryRunnerContext,
): Promise<CommonInput<UpdateOneQueryArgs>> {
const {
authContext,
flatObjectMetadata,
flatFieldMetadataMaps,
flatObjectMetadataMaps,
} = queryRunnerContext;
const { authContext, flatObjectMetadata, flatFieldMetadataMaps } =
queryRunnerContext;
return {
...args,
@@ -80,7 +76,6 @@ export class CommonUpdateOneQueryRunnerService extends CommonBaseQueryRunnerServ
authContext,
flatObjectMetadata,
flatFieldMetadataMaps,
flatObjectMetadataMaps,
shouldBackfillPositionIfUndefined: false,
})
)[0],
@@ -1,5 +1,3 @@
import { registerEnumType } from '@nestjs/graphql';
export enum DatabaseEventAction {
CREATED = 'created',
UPDATED = 'updated',
@@ -8,8 +6,3 @@ export enum DatabaseEventAction {
RESTORED = 'restored',
UPSERTED = 'upserted',
}
registerEnumType(DatabaseEventAction, {
name: 'DatabaseEventAction',
description: 'Database Event Action',
});
@@ -20,7 +20,7 @@ import { CallWebhookJobsJob } from 'src/engine/metadata-modules/webhook/jobs/cal
import { WorkspaceEventBatchForWebhook } from 'src/engine/metadata-modules/webhook/types/workspace-event-batch-for-webhook.type';
import { CallDatabaseEventTriggerJobsJob } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/database-event/call-database-event-trigger-jobs.job';
import { WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event-batch.type';
import { ObjectRecordEventPublisher } from 'src/engine/subscriptions/object-record-event/object-record-event-publisher';
import { WorkspaceEventEmitterService } from 'src/engine/workspace-event-emitter/workspace-event-emitter.service';
import { UpsertTimelineActivityFromInternalEvent } from 'src/modules/timeline/jobs/upsert-timeline-activity-from-internal-event.job';
@Injectable()
@@ -32,7 +32,7 @@ export class EntityEventsToDbListener {
private readonly webhookQueueService: MessageQueueService,
@InjectMessageQueue(MessageQueue.triggerQueue)
private readonly triggerQueueService: MessageQueueService,
private readonly objectRecordEventPublisher: ObjectRecordEventPublisher,
private readonly workspaceEventEmitterService: WorkspaceEventEmitterService,
) {}
@OnDatabaseBatchEvent('*', DatabaseEventAction.CREATED)
@@ -79,7 +79,7 @@ export class EntityEventsToDbListener {
};
const promises = [
this.objectRecordEventPublisher.publish(batchEvent),
this.workspaceEventEmitterService.publish(batchEvent),
this.webhookQueueService.add<WorkspaceEventBatchForWebhook<T>>(
CallWebhookJobsJob.name,
batchEventForWebhook,
@@ -65,6 +65,7 @@ import { RoleModule } from 'src/engine/metadata-modules/role/role.module';
import { RowLevelPermissionModule } from 'src/engine/metadata-modules/row-level-permission-predicate/row-level-permission.module';
import { SubscriptionsModule } from 'src/engine/subscriptions/subscriptions.module';
import { TrashCleanupModule } from 'src/engine/trash-cleanup/trash-cleanup.module';
import { MetadataEventEmitterModule } from 'src/engine/metadata-event-emitter/metadata-event-emitter.module';
import { WorkspaceEventEmitterModule } from 'src/engine/workspace-event-emitter/workspace-event-emitter.module';
import { ChannelSyncModule } from 'src/modules/connected-account/channel-sync/channel-sync.module';
import { DashboardModule } from 'src/modules/dashboard/dashboard.module';
@@ -109,6 +110,7 @@ import { FileModule } from './file/file.module';
PostgresCredentialsModule,
WorkflowApiModule,
WorkspaceEventEmitterModule,
MetadataEventEmitterModule,
ActorModule,
TelemetryModule,
AdminPanelModule,
@@ -1,5 +1,3 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { objectRecordChangedValues } from 'src/engine/core-modules/event-emitter/utils/object-record-changed-values';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
@@ -148,53 +146,4 @@ describe('objectRecordChangedValues', () => {
expect(result).toEqual(expectedChanges);
});
it('ignores changes to POSITION fields', () => {
const positionFieldId = 'position-field-id';
const positionUniversalId = 'position-universal-id';
const objectMetadataWithPosition: FlatObjectMetadata = {
...mockObjectMetadata,
fieldIds: [positionFieldId],
};
const flatFieldMetadataMapsWithPosition: FlatEntityMaps<FlatFieldMetadata> =
{
byUniversalIdentifier: {
[positionUniversalId]: {
id: positionFieldId,
name: 'position',
type: FieldMetadataType.POSITION,
universalIdentifier: positionUniversalId,
} as FlatFieldMetadata,
},
universalIdentifierById: {
[positionFieldId]: positionUniversalId,
},
universalIdentifiersByApplicationId: {},
};
const oldRecord = {
id: '74316f58-29b0-4a6a-b8fa-d2b506d5516n',
position: 1,
name: 'Original',
};
const newRecord = {
id: '74316f58-29b0-4a6a-b8fa-d2b506d5516n',
position: 5,
name: 'Updated',
};
const result = objectRecordChangedValues(
oldRecord,
newRecord,
objectMetadataWithPosition,
flatFieldMetadataMapsWithPosition,
);
expect(result).toEqual({
name: { before: 'Original', after: 'Updated' },
});
expect(result).not.toHaveProperty('position');
});
});
@@ -56,8 +56,7 @@ export const objectRecordChangedValues = (
if (
key === 'updatedAt' ||
key === 'searchVector' ||
field?.type === FieldMetadataType.RELATION ||
field?.type === FieldMetadataType.POSITION
field?.type === FieldMetadataType.RELATION
) {
return acc;
}
@@ -15,6 +15,7 @@ import {
type ListLayerVersionsCommandInput,
LogType,
PublishLayerVersionCommand,
ResourceConflictException,
ResourceNotFoundException,
waitUntilFunctionActiveV2,
} from '@aws-sdk/client-lambda';
@@ -355,7 +356,13 @@ export class LambdaDriver implements LogicFunctionDriver {
MemorySize: YARN_INSTALL_LAMBDA_MEMORY_MB,
};
await lambdaClient.send(new CreateFunctionCommand(params));
try {
await lambdaClient.send(new CreateFunctionCommand(params));
} catch (error) {
if (!(error instanceof ResourceConflictException)) {
throw error;
}
}
} finally {
await temporaryDirManager.clean();
}
@@ -444,7 +451,13 @@ export class LambdaDriver implements LogicFunctionDriver {
MemorySize: BUILDER_LAMBDA_MEMORY_MB,
};
await lambdaClient.send(new CreateFunctionCommand(params));
try {
await lambdaClient.send(new CreateFunctionCommand(params));
} catch (error) {
if (!(error instanceof ResourceConflictException)) {
throw error;
}
}
} finally {
await temporaryDirManager.clean();
}
@@ -725,7 +738,14 @@ export class LambdaDriver implements LogicFunctionDriver {
const command = new CreateFunctionCommand(params);
await (await this.getLambdaClient()).send(command);
try {
await (await this.getLambdaClient()).send(command);
} catch (error) {
if (!(error instanceof ResourceConflictException)) {
throw error;
}
// Function was concurrently created by another worker — safe to proceed
}
await temporaryDirManager.clean();
}
@@ -6,12 +6,12 @@ import { AllMetadataName } from 'twenty-shared/metadata';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
import { type MetadataEventBatch } from 'src/engine/subscriptions/metadata-event/types/metadata-event-batch.type';
import { type MetadataEventBatch } from 'src/engine/metadata-event-emitter/types/metadata-event-batch.type';
import { getMetadataFlatEntityMapsKey } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-flat-entity-maps-key.util';
import { CallWebhookJobsForMetadataJob } from 'src/engine/metadata-modules/webhook/jobs/call-webhook-jobs-for-metadata.job';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { type WorkspaceCacheKeyName } from 'src/engine/workspace-cache/types/workspace-cache-key.type';
import { MetadataEventPublisher } from 'src/engine/subscriptions/metadata-event/metadata-event-publisher';
import { WorkspaceEventEmitterService } from 'src/engine/workspace-event-emitter/workspace-event-emitter.service';
import { type AllMetadataEventType } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/types/metadata-event';
@Injectable()
@@ -19,7 +19,7 @@ export class MetadataEventsToDbListener {
constructor(
@InjectMessageQueue(MessageQueue.webhookQueue)
private readonly webhookQueueService: MessageQueueService,
private readonly metadataEventPublisher: MetadataEventPublisher,
private readonly workspaceEventEmitterService: WorkspaceEventEmitterService,
private readonly workspaceCacheService: WorkspaceCacheService,
) {}
@@ -68,7 +68,7 @@ export class MetadataEventsToDbListener {
const updatedCollectionHash = cacheHashes[cacheKeyName];
await this.metadataEventPublisher.publish({
await this.workspaceEventEmitterService.publish({
...metadataEventBatch,
updatedCollectionHash,
});
@@ -0,0 +1,14 @@
import { Global, Module } from '@nestjs/common';
import { MetadataEventsToDbListener } from 'src/engine/metadata-event-emitter/listeners/metadata-events-to-db.listener';
import { MetadataEventEmitter } from 'src/engine/metadata-event-emitter/metadata-event-emitter';
import { SubscriptionsModule } from 'src/engine/subscriptions/subscriptions.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
@Global()
@Module({
imports: [SubscriptionsModule, WorkspaceCacheModule],
providers: [MetadataEventEmitter, MetadataEventsToDbListener],
exports: [MetadataEventEmitter],
})
export class MetadataEventEmitterModule {}
@@ -6,8 +6,8 @@ import { isDefined } from 'twenty-shared/utils';
import { getWorkspaceAuthContext } from 'src/engine/core-modules/auth/storage/workspace-auth-context.storage';
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
import { MetadataEventBatch } from 'src/engine/subscriptions/metadata-event/types/metadata-event-batch.type';
import { computeMetadataEventName } from 'src/engine/subscriptions/metadata-event/utils/compute-metadata-event-name.util';
import { MetadataEventBatch } from 'src/engine/metadata-event-emitter/types/metadata-event-batch.type';
import { computeMetadataEventName } from 'src/engine/metadata-event-emitter/utils/compute-metadata-event-name.util';
import {
AllMetadataEventName,
AllMetadataEventType,
@@ -104,7 +104,6 @@ export const fromCreateObjectInputToFlatObjectMetadataAndFlatFieldMetadatasToCre
const {
standardSourceFlatFieldMetadatas,
standardTargetFlatFieldMetadatas,
standardRelationIndexMetadatas,
} = buildDefaultRelationFlatFieldMetadatasForCustomObject({
existingFlatObjectMetadataMaps,
sourceFlatObjectMetadata: universalFlatObjectMetadataToCreate,
@@ -125,10 +124,9 @@ export const fromCreateObjectInputToFlatObjectMetadataAndFlatFieldMetadatasToCre
return {
flatObjectMetadataToCreate: universalFlatObjectMetadataToCreate,
flatIndexMetadataToCreate: [
...Object.values(defaultIndexesForCustomObject.indexes),
...standardRelationIndexMetadatas,
],
flatIndexMetadataToCreate: Object.values(
defaultIndexesForCustomObject.indexes,
),
relationTargetFlatFieldMetadataToCreate: standardTargetFlatFieldMetadatas,
flatFieldMetadataToCreateOnObject: objectFlatFieldMetadatas,
};
@@ -15,7 +15,6 @@ import { type CreateNavigationMenuItemInput } from 'src/engine/metadata-modules/
import { type NavigationMenuItemDTO } from 'src/engine/metadata-modules/navigation-menu-item/dtos/navigation-menu-item.dto';
import { RecordIdentifierDTO } from 'src/engine/metadata-modules/navigation-menu-item/dtos/record-identifier.dto';
import { type UpdateNavigationMenuItemInput } from 'src/engine/metadata-modules/navigation-menu-item/dtos/update-navigation-menu-item.input';
import { NavigationMenuItemType } from 'src/engine/metadata-modules/navigation-menu-item/enums/navigation-menu-item-type.enum';
import {
NavigationMenuItemException,
NavigationMenuItemExceptionCode,
@@ -337,25 +336,13 @@ export class NavigationMenuItemService {
existingUserWorkspaceId: flatNavigationMenuItemToDelete.userWorkspaceId,
});
const flatEntitiesToDelete = [flatNavigationMenuItemToDelete];
if (flatNavigationMenuItemToDelete.type === NavigationMenuItemType.FOLDER) {
const userWorkspaceIdKey =
flatNavigationMenuItemToDelete.userWorkspaceId ?? 'null';
const folderChildren =
existingFlatNavigationMenuItemMaps.byUserWorkspaceIdAndFolderId[
userWorkspaceIdKey
]?.[id] ?? [];
flatEntitiesToDelete.unshift(...folderChildren);
}
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
navigationMenuItem: {
flatEntityToCreate: [],
flatEntityToDelete: flatEntitiesToDelete,
flatEntityToDelete: [flatNavigationMenuItemToDelete],
flatEntityToUpdate: [],
},
},
@@ -20,7 +20,6 @@ import {
} from 'src/engine/metadata-modules/object-metadata/object-metadata.exception';
import { STANDARD_OBJECT_ICONS } from 'src/engine/workspace-manager/workspace-migration/constant/standard-object-icons';
import { type UniversalFlatFieldMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-field-metadata.type';
import { type UniversalFlatIndexMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-index-metadata.type';
import { type UniversalFlatObjectMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-object-metadata.type';
const morphIdByRelationObjectNameSingular = {
@@ -51,13 +50,11 @@ export type BuildDefaultRelationFieldsForCustomObjectArgs = {
type SourceAndTargetFlatFieldMetadatasRecord = {
standardSourceFlatFieldMetadatas: UniversalFlatFieldMetadata[];
standardTargetFlatFieldMetadatas: UniversalFlatFieldMetadata[];
standardRelationIndexMetadatas: UniversalFlatIndexMetadata[];
};
const EMPTY_SOURCE_AND_TARGET_FLAT_FIELD_METADATAS_RECORD: SourceAndTargetFlatFieldMetadatasRecord =
{
standardSourceFlatFieldMetadatas: [],
standardTargetFlatFieldMetadatas: [],
standardRelationIndexMetadatas: [],
};
export const buildDefaultRelationFlatFieldMetadatasForCustomObject = ({
@@ -130,7 +127,7 @@ export const buildDefaultRelationFlatFieldMetadatasForCustomObject = ({
const morphId =
morphIdByRelationObjectNameSingular[objectMetadataNameSingular];
const { flatFieldMetadatas, indexMetadatas } =
const { flatFieldMetadatas } =
generateMorphOrRelationFlatFieldMetadataPair({
sourceFlatObjectMetadata,
targetFlatObjectMetadata,
@@ -167,10 +164,6 @@ export const buildDefaultRelationFlatFieldMetadatasForCustomObject = ({
...sourceAndTargetFlatFieldMetadatasRecord.standardTargetFlatFieldMetadatas,
flatFieldMetadatas[1],
],
standardRelationIndexMetadatas: [
...sourceAndTargetFlatFieldMetadatasRecord.standardRelationIndexMetadatas,
...indexMetadatas,
],
};
},
EMPTY_SOURCE_AND_TARGET_FLAT_FIELD_METADATAS_RECORD,
@@ -6,7 +6,7 @@ import { Process } from 'src/engine/core-modules/message-queue/decorators/proces
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
import { type MetadataEventBatch } from 'src/engine/subscriptions/metadata-event/types/metadata-event-batch.type';
import { type MetadataEventBatch } from 'src/engine/metadata-event-emitter/types/metadata-event-batch.type';
import { type FlatWebhook } from 'src/engine/metadata-modules/flat-webhook/types/flat-webhook.type';
import { CallWebhookJob } from 'src/engine/metadata-modules/webhook/jobs/call-webhook.job';
import { type CallMetadataWebhookJobData } from 'src/engine/metadata-modules/webhook/types/webhook-job-data.type';
@@ -0,0 +1,29 @@
import { Field, ObjectType, registerEnumType } from '@nestjs/graphql';
import GraphQLJSON from 'graphql-type-json';
import { ObjectRecord } from 'twenty-shared/types';
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
registerEnumType(DatabaseEventAction, {
name: 'DatabaseEventAction',
description: 'Database Event Action',
});
@ObjectType('OnDbEvent')
export class OnDbEventDTO {
@Field(() => DatabaseEventAction)
action: DatabaseEventAction;
@Field(() => String)
objectNameSingular: string;
@Field()
eventDate: Date;
@Field(() => GraphQLJSON)
record: ObjectRecord;
@Field(() => [String], { nullable: true })
updatedFields?: string[];
}
@@ -0,0 +1,16 @@
import { Field, InputType } from '@nestjs/graphql';
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@InputType()
export class OnDbEventInput {
@Field(() => DatabaseEventAction, { nullable: true })
action?: DatabaseEventAction;
@Field(() => String, { nullable: true })
objectNameSingular?: string;
@Field(() => UUIDScalarType, { nullable: true })
recordId?: string;
}
@@ -1,4 +1,5 @@
export enum SubscriptionChannel {
DATABASE_EVENT_CHANNEL = 'DATABASE_EVENT_CHANNEL',
LOGIC_FUNCTION_LOGS_CHANNEL = 'LOGIC_FUNCTION_LOGS_CHANNEL',
EVENT_STREAM_CHANNEL = 'EVENT_STREAM_CHANNEL',
}
@@ -1,250 +0,0 @@
import { Injectable } from '@nestjs/common';
import { isDefined, isNonEmptyArray } from 'twenty-shared/utils';
import { OBJECT_METADATA_STANDARD_OVERRIDES_PROPERTIES } from 'src/engine/metadata-modules/object-metadata/constants/object-metadata-standard-overrides-properties.constant';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { NavigationMenuItemRecordIdentifierService } from 'src/engine/metadata-modules/navigation-menu-item/services/navigation-menu-item-record-identifier.service';
import { type MetadataEventBatch } from 'src/engine/subscriptions/metadata-event/types/metadata-event-batch.type';
import { type EventStreamPayload } from 'src/engine/subscriptions/types/event-stream-payload.type';
import { EventStreamService } from 'src/engine/subscriptions/event-stream.service';
import { SubscriptionService } from 'src/engine/subscriptions/subscription.service';
import { enrichFieldMetadataEventWithRelations } from 'src/engine/subscriptions/metadata-event/utils/enrich-field-metadata-event-with-relations.util';
@Injectable()
export class MetadataEventPublisher {
constructor(
private readonly subscriptionService: SubscriptionService,
private readonly eventStreamService: EventStreamService,
private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
private readonly navigationMenuItemRecordIdentifierService: NavigationMenuItemRecordIdentifierService,
) {}
async publish(metadataEventBatch: MetadataEventBatch): Promise<void> {
const workspaceId = metadataEventBatch.workspaceId;
const activeStreamIds =
await this.eventStreamService.getActiveStreamIds(workspaceId);
if (activeStreamIds.length === 0) {
return;
}
const streamsData = await this.eventStreamService.getStreamsData(
workspaceId,
activeStreamIds,
);
const enrichedBatch =
await this.enrichMetadataEventBatch(metadataEventBatch);
const streamIdsToRemove: string[] = [];
for (const [streamChannelId, streamData] of streamsData) {
if (!isDefined(streamData)) {
streamIdsToRemove.push(streamChannelId);
continue;
}
await this.publishToStream({
streamChannelId,
metadataEventBatch: enrichedBatch,
});
}
await this.eventStreamService.removeFromActiveStreams(
workspaceId,
streamIdsToRemove,
);
}
private async enrichMetadataEventBatch(
metadataEventBatch: MetadataEventBatch,
): Promise<MetadataEventBatch> {
switch (metadataEventBatch.metadataName) {
case 'fieldMetadata':
return this.enrichFieldMetadataEventsWithRelations(
metadataEventBatch as MetadataEventBatch<'fieldMetadata'>,
);
case 'navigationMenuItem':
return this.enrichNavigationMenuItemEventsWithTargetRecordIdentifier(
metadataEventBatch as MetadataEventBatch<'navigationMenuItem'>,
);
case 'objectMetadata':
return this.resolveObjectMetadataStandardOverrides(
metadataEventBatch as MetadataEventBatch<'objectMetadata'>,
);
default:
return metadataEventBatch;
}
}
private async publishToStream({
streamChannelId,
metadataEventBatch,
}: {
streamChannelId: string;
metadataEventBatch: MetadataEventBatch;
}): Promise<void> {
if (!isNonEmptyArray(metadataEventBatch.events)) {
return;
}
const metadataEvents = metadataEventBatch.events.map((metadataEvent) => ({
...metadataEvent,
updatedCollectionHash: metadataEventBatch.updatedCollectionHash,
}));
const payload: EventStreamPayload = {
objectRecordEventsWithQueryIds: [],
metadataEvents,
};
await this.subscriptionService.publishToEventStream({
workspaceId: metadataEventBatch.workspaceId,
eventStreamChannelId: streamChannelId,
payload,
});
}
private async enrichFieldMetadataEventsWithRelations(
metadataEventBatch: MetadataEventBatch<'fieldMetadata'>,
): Promise<MetadataEventBatch<'fieldMetadata'>> {
const { flatFieldMetadataMaps, flatObjectMetadataMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId: metadataEventBatch.workspaceId,
flatMapsKeys: ['flatFieldMetadataMaps', 'flatObjectMetadataMaps'],
},
);
const enrichedEvents = metadataEventBatch.events.map((event) => {
if (
!('after' in event.properties) ||
!isDefined(event.properties.after)
) {
return event;
}
const enrichedAfter = enrichFieldMetadataEventWithRelations({
record: event.properties.after as Record<string, unknown>,
flatFieldMetadataMaps,
flatObjectMetadataMaps,
});
return {
...event,
properties: {
...event.properties,
after: enrichedAfter,
},
} as typeof event;
});
return { ...metadataEventBatch, events: enrichedEvents };
}
private async enrichNavigationMenuItemEventsWithTargetRecordIdentifier(
metadataEventBatch: MetadataEventBatch<'navigationMenuItem'>,
): Promise<MetadataEventBatch<'navigationMenuItem'>> {
const enrichedEvents = await Promise.all(
metadataEventBatch.events.map(async (event) => {
if (
!('after' in event.properties) ||
!isDefined(event.properties.after)
) {
return event;
}
const after = event.properties.after as Record<string, unknown>;
const targetRecordId = after.targetRecordId as string | undefined;
const targetObjectMetadataId = after.targetObjectMetadataId as
| string
| undefined;
if (!isDefined(targetRecordId) || !isDefined(targetObjectMetadataId)) {
return event;
}
const targetRecordIdentifier =
await this.navigationMenuItemRecordIdentifierService.resolveRecordIdentifier(
{
targetRecordId,
targetObjectMetadataId,
workspaceId: metadataEventBatch.workspaceId,
},
);
const enrichedAfter: Record<string, unknown> = {
...after,
targetRecordIdentifier,
};
return {
...event,
properties: {
...event.properties,
after: enrichedAfter,
},
} as typeof event;
}),
);
return { ...metadataEventBatch, events: enrichedEvents };
}
private resolveObjectMetadataStandardOverrides(
metadataEventBatch: MetadataEventBatch<'objectMetadata'>,
): MetadataEventBatch<'objectMetadata'> {
const enrichedEvents = metadataEventBatch.events.map((event) => {
const enrichedProperties = { ...event.properties };
if (
'before' in enrichedProperties &&
isDefined(enrichedProperties.before)
) {
enrichedProperties.before =
this.applyStandardOverridesToObjectMetadataRecord(
enrichedProperties.before as Record<string, unknown>,
) as typeof enrichedProperties.before;
}
if (
'after' in enrichedProperties &&
isDefined(enrichedProperties.after)
) {
enrichedProperties.after =
this.applyStandardOverridesToObjectMetadataRecord(
enrichedProperties.after as Record<string, unknown>,
) as typeof enrichedProperties.after;
}
return { ...event, properties: enrichedProperties } as typeof event;
});
return { ...metadataEventBatch, events: enrichedEvents };
}
private applyStandardOverridesToObjectMetadataRecord(
record: Record<string, unknown>,
): Record<string, unknown> {
const standardOverrides = record.standardOverrides as
| Record<string, unknown>
| null
| undefined;
if (!isDefined(standardOverrides)) {
return record;
}
const resolved = { ...record };
for (const key of OBJECT_METADATA_STANDARD_OVERRIDES_PROPERTIES) {
if (isDefined(standardOverrides[key])) {
resolved[key] = standardOverrides[key];
}
}
return resolved;
}
}
@@ -1,26 +1,14 @@
import { Global, Module } from '@nestjs/common';
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ProcessNestedRelationsV2Helper } from 'src/engine/api/common/common-nested-relations-processor/process-nested-relations-v2.helper';
import { ProcessNestedRelationsHelper } from 'src/engine/api/common/common-nested-relations-processor/process-nested-relations.helper';
import { CommonSelectFieldsHelper } from 'src/engine/api/common/common-select-fields/common-select-fields-helper';
import { CacheLockModule } from 'src/engine/core-modules/cache-lock/cache-lock.module';
import { CacheStorageModule } from 'src/engine/core-modules/cache-storage/cache-storage.module';
import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module';
import { RedisClientModule } from 'src/engine/core-modules/redis-client/redis-client.module';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
import { NavigationMenuItemModule } from 'src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.module';
import { EventStreamResolver } from 'src/engine/subscriptions/event-stream.resolver';
import { EventStreamService } from 'src/engine/subscriptions/event-stream.service';
import { MetadataEventEmitter } from 'src/engine/subscriptions/metadata-event/metadata-event-emitter';
import { MetadataEventPublisher } from 'src/engine/subscriptions/metadata-event/metadata-event-publisher';
import { MetadataEventsToDbListener } from 'src/engine/subscriptions/metadata-event/metadata-events-to-db.listener';
import { ObjectRecordEventPublisher } from 'src/engine/subscriptions/object-record-event/object-record-event-publisher';
import { SubscriptionService } from 'src/engine/subscriptions/subscription.service';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
@Global()
@Module({
imports: [
RedisClientModule,
@@ -28,26 +16,8 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
CacheLockModule,
MetricsModule,
TypeOrmModule.forFeature([WorkspaceEntity]),
WorkspaceCacheModule,
WorkspaceManyOrAllFlatEntityMapsCacheModule,
NavigationMenuItemModule,
],
providers: [
SubscriptionService,
EventStreamService,
EventStreamResolver,
ObjectRecordEventPublisher,
MetadataEventPublisher,
MetadataEventEmitter,
MetadataEventsToDbListener,
ProcessNestedRelationsHelper,
ProcessNestedRelationsV2Helper,
CommonSelectFieldsHelper,
],
exports: [
SubscriptionService,
ObjectRecordEventPublisher,
MetadataEventEmitter,
],
providers: [SubscriptionService, EventStreamService],
exports: [SubscriptionService, EventStreamService],
})
export class SubscriptionsModule {}
@@ -22,7 +22,8 @@ import { type EventStreamData } from 'src/engine/subscriptions/types/event-strea
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';
import { type WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event-batch.type';
import { ObjectRecordEventPublisher } from 'src/engine/subscriptions/object-record-event/object-record-event-publisher';
import { NavigationMenuItemRecordIdentifierService } from 'src/engine/metadata-modules/navigation-menu-item/services/navigation-menu-item-record-identifier.service';
import { WorkspaceEventEmitterService } from 'src/engine/workspace-event-emitter/workspace-event-emitter.service';
jest.mock(
'src/engine/twenty-orm/utils/build-row-level-permission-record-filter.util',
@@ -69,8 +70,8 @@ const buildFlatFieldMetadataMaps = (
createEmptyFlatEntityMaps() as FlatEntityMaps<FlatFieldMetadata>,
);
describe('ObjectRecordEventPublisher', () => {
let service: ObjectRecordEventPublisher;
describe('WorkspaceEventEmitterService', () => {
let service: WorkspaceEventEmitterService;
let mockSubscriptionService: jest.Mocked<
Pick<SubscriptionService, 'publish' | 'publishToEventStream'>
>;
@@ -272,7 +273,7 @@ describe('ObjectRecordEventPublisher', () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
ObjectRecordEventPublisher,
WorkspaceEventEmitterService,
{
provide: SubscriptionService,
useValue: mockSubscriptionService,
@@ -301,11 +302,19 @@ describe('ObjectRecordEventPublisher', () => {
provide: CommonSelectFieldsHelper,
useValue: new CommonSelectFieldsHelper(),
},
{
provide: NavigationMenuItemRecordIdentifierService,
useValue: {
enrichNavigationMenuItemEventsWithRecordIdentifiers: jest
.fn()
.mockResolvedValue(undefined),
},
},
],
}).compile();
service = module.get<ObjectRecordEventPublisher>(
ObjectRecordEventPublisher,
service = module.get<WorkspaceEventEmitterService>(
WorkspaceEventEmitterService,
);
});
@@ -0,0 +1,512 @@
import { isMetadataRecordMatchingFilter } from 'src/engine/workspace-event-emitter/utils/is-metadata-record-matching-filter.util';
const record = {
id: '1',
name: 'Test Object',
label: 'testObject',
isActive: true,
count: 42,
};
describe('isMetadataRecordMatchingFilter', () => {
describe('empty filters', () => {
it('should match any record when filter is empty', () => {
expect(isMetadataRecordMatchingFilter({ record, filter: {} })).toBe(true);
});
});
describe('and filter', () => {
it('should match when all sub-filters match', () => {
const filter = {
and: [{ name: { eq: 'Test Object' } }, { isActive: { eq: true } }],
};
expect(isMetadataRecordMatchingFilter({ record, filter })).toBe(true);
});
it('should not match when any sub-filter fails', () => {
const filter = {
and: [{ name: { eq: 'Test Object' } }, { isActive: { eq: false } }],
};
expect(isMetadataRecordMatchingFilter({ record, filter })).toBe(false);
});
it('should match when and array is empty', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { and: [] },
}),
).toBe(true);
});
it('should throw when and value is not an array', () => {
expect(() =>
isMetadataRecordMatchingFilter({
record,
filter: { and: 'invalid' } as any,
}),
).toThrow('Unexpected value for "and" filter');
});
});
describe('or filter', () => {
it('should match when any sub-filter matches', () => {
const filter = {
or: [{ name: { eq: 'Wrong Name' } }, { name: { eq: 'Test Object' } }],
};
expect(isMetadataRecordMatchingFilter({ record, filter })).toBe(true);
});
it('should not match when no sub-filter matches', () => {
const filter = {
or: [{ name: { eq: 'Wrong' } }, { name: { eq: 'Also Wrong' } }],
};
expect(isMetadataRecordMatchingFilter({ record, filter })).toBe(false);
});
it('should match when or array is empty', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { or: [] },
}),
).toBe(true);
});
it('should treat or with an object as an and', () => {
const filter = {
or: { name: { eq: 'Test Object' } },
};
expect(isMetadataRecordMatchingFilter({ record, filter })).toBe(true);
});
it('should throw when or value is neither array nor object', () => {
expect(() =>
isMetadataRecordMatchingFilter({
record,
filter: { or: 'invalid' } as any,
}),
).toThrow('Unexpected value for "or" filter');
});
});
describe('not filter', () => {
it('should negate a matching filter', () => {
const filter = { not: { name: { eq: 'Test Object' } } };
expect(isMetadataRecordMatchingFilter({ record, filter })).toBe(false);
});
it('should negate a non-matching filter', () => {
const filter = { not: { name: { eq: 'Wrong' } } };
expect(isMetadataRecordMatchingFilter({ record, filter })).toBe(true);
});
it('should match when not contains an empty object', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { not: {} },
}),
).toBe(true);
});
});
describe('implicit and (multi-key filter)', () => {
it('should treat multiple keys as an implicit AND', () => {
const filter = {
name: { eq: 'Test Object' },
isActive: { eq: true },
};
expect(isMetadataRecordMatchingFilter({ record, filter })).toBe(true);
});
it('should fail if any key in implicit AND does not match', () => {
const filter = {
name: { eq: 'Test Object' },
isActive: { eq: false },
};
expect(isMetadataRecordMatchingFilter({ record, filter })).toBe(false);
});
});
describe('eq operator', () => {
it('should match equal string values', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { name: { eq: 'Test Object' } },
}),
).toBe(true);
});
it('should not match different string values', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { name: { eq: 'Other' } },
}),
).toBe(false);
});
it('should match equal boolean values', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { isActive: { eq: true } },
}),
).toBe(true);
});
});
describe('neq operator', () => {
it('should match when values are different', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { name: { neq: 'Other' } },
}),
).toBe(true);
});
it('should not match when values are equal', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { name: { neq: 'Test Object' } },
}),
).toBe(false);
});
});
describe('in operator', () => {
it('should match when value is in the array', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { name: { in: ['Test Object', 'Other'] } },
}),
).toBe(true);
});
it('should not match when value is not in the array', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { name: { in: ['A', 'B'] } },
}),
).toBe(false);
});
it('should return false when in value is not an array', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { name: { in: 'not-array' } } as any,
}),
).toBe(false);
});
});
describe('is operator', () => {
it('should match NULL for undefined values', () => {
expect(
isMetadataRecordMatchingFilter({
record: { ...record, optional: undefined },
filter: { optional: { is: 'NULL' } },
}),
).toBe(true);
});
it('should not match NULL for defined values', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { name: { is: 'NULL' } },
}),
).toBe(false);
});
it('should match NOT_NULL for defined values', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { name: { is: 'NOT_NULL' } },
}),
).toBe(true);
});
it('should not match NOT_NULL for undefined values', () => {
expect(
isMetadataRecordMatchingFilter({
record: { ...record, optional: undefined },
filter: { optional: { is: 'NOT_NULL' } },
}),
).toBe(false);
});
});
describe('like operator', () => {
it('should match with wildcard prefix', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { name: { like: '%Object' } },
}),
).toBe(true);
});
it('should match with wildcard suffix', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { name: { like: 'Test%' } },
}),
).toBe(true);
});
it('should match with wildcards on both sides', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { name: { like: '%est Obj%' } },
}),
).toBe(true);
});
it('should not match when pattern does not match', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { name: { like: 'wrong%' } },
}),
).toBe(false);
});
it('should escape regex special characters in pattern', () => {
const specialRecord = { ...record, name: 'foo.bar' };
expect(
isMetadataRecordMatchingFilter({
record: specialRecord,
filter: { name: { like: 'foo.bar' } },
}),
).toBe(true);
// A dot in the pattern should NOT match any character
expect(
isMetadataRecordMatchingFilter({
record: { ...record, name: 'fooXbar' },
filter: { name: { like: 'foo.bar' } },
}),
).toBe(false);
});
it('should return false for non-string values', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { count: { like: '42' } },
}),
).toBe(false);
});
});
describe('ilike operator', () => {
it('should match case-insensitively', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { name: { ilike: '%test object%' } },
}),
).toBe(true);
});
it('should escape regex special characters', () => {
const specialRecord = { ...record, name: 'foo(bar)' };
expect(
isMetadataRecordMatchingFilter({
record: specialRecord,
filter: { name: { ilike: 'FOO(BAR)' } },
}),
).toBe(true);
});
it('should return false for non-string values', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { count: { ilike: '42' } },
}),
).toBe(false);
});
});
describe('gt operator', () => {
it('should match when value is greater', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { count: { gt: 40 } },
}),
).toBe(true);
});
it('should not match when value is equal', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { count: { gt: 42 } },
}),
).toBe(false);
});
it('should not match when value is less', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { count: { gt: 50 } },
}),
).toBe(false);
});
});
describe('gte operator', () => {
it('should match when value is greater or equal', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { count: { gte: 42 } },
}),
).toBe(true);
});
it('should not match when value is less', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { count: { gte: 43 } },
}),
).toBe(false);
});
});
describe('lt operator', () => {
it('should match when value is less', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { count: { lt: 50 } },
}),
).toBe(true);
});
it('should not match when value is equal', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { count: { lt: 42 } },
}),
).toBe(false);
});
});
describe('lte operator', () => {
it('should match when value is less or equal', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { count: { lte: 42 } },
}),
).toBe(true);
});
it('should not match when value is greater', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { count: { lte: 41 } },
}),
).toBe(false);
});
});
describe('unknown operator', () => {
it('should throw for unsupported operators', () => {
expect(() =>
isMetadataRecordMatchingFilter({
record,
filter: { name: { unknownOp: 'value' } } as any,
}),
).toThrow('Unsupported filter operator');
});
});
describe('nested logical operators', () => {
it('should handle deeply nested and/or/not', () => {
const filter = {
and: [
{
or: [
{ name: { eq: 'Wrong' } },
{ not: { isActive: { eq: false } } },
],
},
{ count: { gte: 40 } },
],
};
expect(isMetadataRecordMatchingFilter({ record, filter })).toBe(true);
});
it('should handle not with nested and', () => {
const filter = {
not: {
and: [{ name: { eq: 'Test Object' } }, { count: { gt: 100 } }],
},
};
// name matches but count > 100 fails, so AND = false, NOT = true
expect(isMetadataRecordMatchingFilter({ record, filter })).toBe(true);
});
});
describe('edge cases', () => {
it('should skip non-object field filters', () => {
// When fieldFilter is a primitive, it is skipped (returns true)
expect(
isMetadataRecordMatchingFilter({
record,
filter: { name: 'Test Object' },
}),
).toBe(true);
});
it('should skip undefined field filters', () => {
expect(
isMetadataRecordMatchingFilter({
record,
filter: { name: undefined },
}),
).toBe(true);
});
it('should handle records with missing fields', () => {
expect(
isMetadataRecordMatchingFilter({
record: { id: '1' },
filter: { name: { eq: 'Test' } },
}),
).toBe(false);
});
});
});
@@ -0,0 +1,198 @@
import { isObject } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
type MetadataFilter = Record<string, unknown>;
type MetadataScalarFilter = {
is?: 'NULL' | 'NOT_NULL';
eq?: unknown;
neq?: unknown;
in?: unknown[];
like?: string;
ilike?: string;
gt?: number;
gte?: number;
lt?: number;
lte?: number;
};
const isEmptyFilter = (filter: MetadataFilter): boolean =>
Object.keys(filter).length === 0;
const isAndFilter = (filter: MetadataFilter): boolean =>
'and' in filter && isDefined(filter.and);
const isOrFilter = (filter: MetadataFilter): boolean =>
'or' in filter && isDefined(filter.or);
const isNotFilter = (filter: MetadataFilter): boolean =>
'not' in filter && isDefined(filter.not);
const isImplicitAndFilter = (filter: MetadataFilter): boolean =>
Object.keys(filter).length > 1;
const escapeRegExp = (str: string): string =>
str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
export const isMetadataRecordMatchingFilter = ({
record,
filter,
}: {
record: Record<string, unknown>;
filter: MetadataFilter;
}): boolean => {
if (isEmptyFilter(filter)) {
return true;
}
if (isImplicitAndFilter(filter)) {
return Object.entries(filter).every(([key, value]) =>
isMetadataRecordMatchingFilter({
record,
filter: { [key]: value },
}),
);
}
if (isAndFilter(filter)) {
const andValue = filter.and;
if (!Array.isArray(andValue)) {
throw new Error(
'Unexpected value for "and" filter: ' + JSON.stringify(andValue),
);
}
return (
andValue.length === 0 ||
andValue.every((subFilter: MetadataFilter) =>
isMetadataRecordMatchingFilter({ record, filter: subFilter }),
)
);
}
if (isOrFilter(filter)) {
const orValue = filter.or;
if (Array.isArray(orValue)) {
return (
orValue.length === 0 ||
orValue.some((subFilter: MetadataFilter) =>
isMetadataRecordMatchingFilter({ record, filter: subFilter }),
)
);
}
if (isObject(orValue)) {
return isMetadataRecordMatchingFilter({
record,
filter: orValue as MetadataFilter,
});
}
throw new Error(
'Unexpected value for "or" filter: ' + JSON.stringify(orValue),
);
}
if (isNotFilter(filter)) {
const notValue = filter.not;
if (isObject(notValue) && isEmptyFilter(notValue as MetadataFilter)) {
return true;
}
return !isMetadataRecordMatchingFilter({
record,
filter: notValue as MetadataFilter,
});
}
return Object.entries(filter).every(([fieldName, fieldFilter]) => {
if (!isDefined(fieldFilter) || !isObject(fieldFilter)) {
return true;
}
const recordValue = record[fieldName];
return isScalarValueMatchingFilter(
recordValue,
fieldFilter as MetadataScalarFilter,
);
});
};
const isScalarValueMatchingFilter = (
value: unknown,
fieldFilter: MetadataScalarFilter,
): boolean => {
if ('is' in fieldFilter) {
if (fieldFilter.is === 'NULL') {
return !isDefined(value);
}
return isDefined(value);
}
if ('eq' in fieldFilter) {
return value === fieldFilter.eq;
}
if ('neq' in fieldFilter) {
return value !== fieldFilter.neq;
}
if ('in' in fieldFilter) {
if (!Array.isArray(fieldFilter.in)) {
return false;
}
return fieldFilter.in.includes(value);
}
if ('like' in fieldFilter) {
if (typeof value !== 'string') {
return false;
}
const pattern = String(fieldFilter.like)
.split('%')
.map(escapeRegExp)
.join('.*');
return new RegExp(`^${pattern}$`).test(value);
}
if ('ilike' in fieldFilter) {
if (typeof value !== 'string') {
return false;
}
const pattern = String(fieldFilter.ilike)
.split('%')
.map(escapeRegExp)
.join('.*');
return new RegExp(`^${pattern}$`, 'i').test(value);
}
if ('gt' in fieldFilter) {
return isDefined(value) && (value as number) > (fieldFilter.gt as number);
}
if ('gte' in fieldFilter) {
return isDefined(value) && (value as number) >= (fieldFilter.gte as number);
}
if ('lt' in fieldFilter) {
return isDefined(value) && (value as number) < (fieldFilter.lt as number);
}
if ('lte' in fieldFilter) {
return isDefined(value) && (value as number) <= (fieldFilter.lte as number);
}
throw new Error(
'Unsupported filter operator: ' + JSON.stringify(fieldFilter),
);
};
@@ -10,7 +10,9 @@ import {
} from 'src/engine/subscriptions/event-stream.exception';
@Catch(EventStreamException)
export class EventStreamExceptionFilter implements GqlExceptionFilter {
export class WorkspaceEventEmitterExceptionFilter
implements GqlExceptionFilter
{
catch(exception: EventStreamException) {
switch (exception.code) {
case EventStreamExceptionCode.EVENT_STREAM_ALREADY_EXISTS:
@@ -1,10 +1,32 @@
import { Global, Module } from '@nestjs/common';
import { ProcessNestedRelationsV2Helper } from 'src/engine/api/common/common-nested-relations-processor/process-nested-relations-v2.helper';
import { ProcessNestedRelationsHelper } from 'src/engine/api/common/common-nested-relations-processor/process-nested-relations.helper';
import { CommonSelectFieldsHelper } from 'src/engine/api/common/common-select-fields/common-select-fields-helper';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
import { NavigationMenuItemModule } from 'src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.module';
import { SubscriptionsModule } from 'src/engine/subscriptions/subscriptions.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
import { WorkspaceEventEmitterResolver } from 'src/engine/workspace-event-emitter/workspace-event-emitter.resolver';
import { WorkspaceEventEmitterService } from 'src/engine/workspace-event-emitter/workspace-event-emitter.service';
@Global()
@Module({
providers: [WorkspaceEventEmitter],
exports: [WorkspaceEventEmitter],
imports: [
SubscriptionsModule,
WorkspaceCacheModule,
WorkspaceManyOrAllFlatEntityMapsCacheModule,
NavigationMenuItemModule,
],
providers: [
WorkspaceEventEmitter,
WorkspaceEventEmitterService,
WorkspaceEventEmitterResolver,
ProcessNestedRelationsHelper,
ProcessNestedRelationsV2Helper,
CommonSelectFieldsHelper,
],
exports: [WorkspaceEventEmitter, WorkspaceEventEmitterService],
})
export class WorkspaceEventEmitterModule {}
@@ -19,8 +19,10 @@ import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { EVENT_STREAM_TTL_MS } from 'src/engine/subscriptions/constants/event-stream-ttl.constant';
import { AddQuerySubscriptionInput } from 'src/engine/subscriptions/dtos/add-query-subscription.input';
import { EventSubscriptionDTO } from 'src/engine/subscriptions/dtos/event-subscription.dto';
import { OnDbEventDTO } from 'src/engine/subscriptions/dtos/on-db-event.dto';
import { OnDbEventInput } from 'src/engine/subscriptions/dtos/on-db-event.input';
import { RemoveQueryFromEventStreamInput } from 'src/engine/subscriptions/dtos/remove-query-subscription.input';
import { EventStreamExceptionFilter } from 'src/engine/subscriptions/event-stream-exception.filter';
import { SubscriptionChannel } from 'src/engine/subscriptions/enums/subscription-channel.enum';
import {
EventStreamException,
EventStreamExceptionCode,
@@ -28,19 +30,57 @@ import {
import { EventStreamService } from 'src/engine/subscriptions/event-stream.service';
import { SubscriptionService } from 'src/engine/subscriptions/subscription.service';
import { type EventStreamPayload } from 'src/engine/subscriptions/types/event-stream-payload.type';
import { eventStreamIdToChannelId } from 'src/engine/subscriptions/utils/get-channel-id-from-event-stream-id';
import { wrapAsyncIteratorWithLifecycle } from 'src/engine/subscriptions/utils/wrap-async-iterator-with-lifecycle';
import { wrapAsyncIteratorWithLifecycle } from 'src/engine/workspace-event-emitter/utils/wrap-async-iterator-with-lifecycle';
import { WorkspaceEventEmitterExceptionFilter } from 'src/engine/workspace-event-emitter/workspace-event-emitter-exception.filter';
import { eventStreamIdToChannelId } from './utils/get-channel-id-from-event-stream-id';
@MetadataResolver()
@UseGuards(WorkspaceAuthGuard, UserAuthGuard, NoPermissionGuard)
@UsePipes(ResolverValidationPipe)
@UseFilters(EventStreamExceptionFilter, PreventNestToAutoLogGraphqlErrorsFilter)
export class EventStreamResolver {
@UseFilters(
WorkspaceEventEmitterExceptionFilter,
PreventNestToAutoLogGraphqlErrorsFilter,
)
export class WorkspaceEventEmitterResolver {
constructor(
private readonly subscriptionService: SubscriptionService,
private readonly eventStreamService: EventStreamService,
) {}
@Subscription(() => OnDbEventDTO, {
filter: (
payload: { onDbEvent: OnDbEventDTO },
variables: { input: OnDbEventInput },
) => {
const isActionMatching =
!isDefined(variables.input.action) ||
payload.onDbEvent.action === variables.input.action;
const isObjectNameSingularMatching =
!isDefined(variables.input.objectNameSingular) ||
payload.onDbEvent.objectNameSingular ===
variables.input.objectNameSingular;
const isRecordIdMatching =
!isDefined(variables.input.recordId) ||
payload.onDbEvent.record.id === variables.input.recordId;
return (
isActionMatching && isObjectNameSingularMatching && isRecordIdMatching
);
},
})
onDbEvent(
@Args('input') _: OnDbEventInput,
@AuthWorkspace() workspace: WorkspaceEntity,
) {
return this.subscriptionService.subscribe({
channel: SubscriptionChannel.DATABASE_EVENT_CHANNEL,
workspaceId: workspace.id,
});
}
@Subscription(() => EventSubscriptionDTO, {
nullable: true,
resolve: (
@@ -13,6 +13,7 @@ import {
import {
combineFilters,
isDefined,
isNonEmptyArray,
isRecordGqlOperationSignature,
} from 'twenty-shared/utils';
import { FindOptionsRelations, ObjectLiteral } from 'typeorm';
@@ -24,14 +25,20 @@ import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/
import { type SerializableAuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
import { type FlatWorkspaceMemberMaps } from 'src/engine/core-modules/user/types/flat-workspace-member-maps.type';
import { type MetadataEventBatch } from 'src/engine/metadata-event-emitter/types/metadata-event-batch.type';
import { OBJECT_METADATA_STANDARD_OVERRIDES_PROPERTIES } from 'src/engine/metadata-modules/object-metadata/constants/object-metadata-standard-overrides-properties.constant';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { type 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 { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { NavigationMenuItemRecordIdentifierService } from 'src/engine/metadata-modules/navigation-menu-item/services/navigation-menu-item-record-identifier.service';
import { enrichFieldMetadataEventWithRelations } from 'src/engine/workspace-event-emitter/utils/enrich-field-metadata-event-with-relations.util';
import { UserWorkspaceRoleMap } from 'src/engine/metadata-modules/role-target/types/user-workspace-role-map';
import { type FlatRowLevelPermissionPredicateGroupMaps } from 'src/engine/metadata-modules/row-level-permission-predicate/types/flat-row-level-permission-predicate-group-maps.type';
import { type FlatRowLevelPermissionPredicateMaps } from 'src/engine/metadata-modules/row-level-permission-predicate/types/flat-row-level-permission-predicate-maps.type';
import { transformEventToWebhookEvent } from 'src/engine/metadata-modules/webhook/utils/transform-event-to-webhook-event';
import { SubscriptionChannel } from 'src/engine/subscriptions/enums/subscription-channel.enum';
import { EventStreamService } from 'src/engine/subscriptions/event-stream.service';
import { SubscriptionService } from 'src/engine/subscriptions/subscription.service';
import {
@@ -47,9 +54,8 @@ import { isRecordMatchingRLSRowLevelPermissionPredicate } from 'src/engine/twent
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event-batch.type';
import { parseEventNameOrThrow } from 'src/engine/workspace-event-emitter/utils/parse-event-name';
@Injectable()
export class ObjectRecordEventPublisher {
export class WorkspaceEventEmitterService {
constructor(
private readonly subscriptionService: SubscriptionService,
private readonly eventStreamService: EventStreamService,
@@ -58,12 +64,57 @@ export class ObjectRecordEventPublisher {
private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly commonSelectFieldsHelper: CommonSelectFieldsHelper,
private readonly navigationMenuItemRecordIdentifierService: NavigationMenuItemRecordIdentifierService,
) {}
async publish(
eventBatch: WorkspaceEventBatch<ObjectRecordEvent> | MetadataEventBatch,
): Promise<void> {
if (!this.isMetadataEventBatch(eventBatch)) {
await this.publishToLegacyChannel(eventBatch);
}
await this.publishToEventStreams(eventBatch);
}
private isMetadataEventBatch(
eventBatch: WorkspaceEventBatch<ObjectRecordEvent> | MetadataEventBatch,
): eventBatch is MetadataEventBatch {
return 'metadataName' in eventBatch;
}
private async publishToLegacyChannel(
eventBatch: WorkspaceEventBatch<ObjectRecordEvent>,
): Promise<void> {
const [nameSingular, operation] = eventBatch.name.split('.');
for (const eventData of eventBatch.events) {
const { record, updatedFields } = transformEventToWebhookEvent({
eventName: eventBatch.name,
event: eventData,
});
const event = {
action: operation,
objectNameSingular: nameSingular,
eventDate: new Date(),
record,
...(updatedFields && { updatedFields }),
};
await this.subscriptionService.publish({
channel: SubscriptionChannel.DATABASE_EVENT_CHANNEL,
workspaceId: eventBatch.workspaceId,
payload: { onDbEvent: event },
});
}
}
private async publishToEventStreams(
eventBatch: WorkspaceEventBatch<ObjectRecordEvent> | MetadataEventBatch,
): Promise<void> {
const workspaceId = eventBatch.workspaceId;
const isMetadata = this.isMetadataEventBatch(eventBatch);
const activeStreamIds =
await this.eventStreamService.getActiveStreamIds(workspaceId);
@@ -77,28 +128,53 @@ export class ObjectRecordEventPublisher {
activeStreamIds,
);
const { permissionsContext, flatWorkspaceMemberMaps } =
await this.fetchObjectRecordStreamContext(workspaceId);
const streamIdsToRemove: string[] = [];
const objectRecordStreamContext = !isMetadata
? await this.fetchObjectRecordStreamContext(workspaceId)
: undefined;
const enrichedMetadataEventBatch = isMetadata
? await this.enrichFieldMetadataEventsWithRelations(
eventBatch as MetadataEventBatch,
)
.then((batch) =>
this.enrichNavigationMenuItemEventsWithTargetRecordIdentifier(
batch,
),
)
.then((batch) => this.resolveObjectMetadataStandardOverrides(batch))
: undefined;
for (const [streamChannelId, streamData] of streamsData) {
if (!isDefined(streamData)) {
streamIdsToRemove.push(streamChannelId);
continue;
}
if (Object.keys(streamData.queries).length === 0) {
continue;
}
if (isMetadata && isDefined(enrichedMetadataEventBatch)) {
await this.processMetadataStreamEvents(
streamChannelId,
streamData,
enrichedMetadataEventBatch,
);
} else if (!isMetadata) {
if (Object.keys(streamData.queries).length === 0) {
continue;
}
await this.processObjectRecordStreamEvents({
streamChannelId,
streamData,
workspaceEventBatch: eventBatch,
permissionsContext,
flatWorkspaceMemberMaps,
});
if (!isDefined(objectRecordStreamContext)) {
continue;
}
await this.processObjectRecordStreamEvents(
streamChannelId,
streamData,
eventBatch as WorkspaceEventBatch<ObjectRecordEvent>,
objectRecordStreamContext.permissionsContext,
objectRecordStreamContext.flatWorkspaceMemberMaps,
);
}
}
await this.eventStreamService.removeFromActiveStreams(
@@ -117,25 +193,198 @@ export class ObjectRecordEventPublisher {
return { permissionsContext, flatWorkspaceMemberMaps };
}
private async processObjectRecordStreamEvents({
streamChannelId,
streamData,
workspaceEventBatch,
permissionsContext,
flatWorkspaceMemberMaps,
}: {
streamChannelId: string;
streamData: EventStreamData;
workspaceEventBatch: WorkspaceEventBatch<ObjectRecordEvent>;
private async processMetadataStreamEvents(
streamChannelId: string,
_streamData: EventStreamData,
metadataEventBatch: MetadataEventBatch,
): Promise<void> {
if (!isNonEmptyArray(metadataEventBatch.events)) {
return;
}
const metadataEvents = metadataEventBatch.events.map((metadataEvent) => ({
...metadataEvent,
updatedCollectionHash: metadataEventBatch.updatedCollectionHash,
}));
const payload: EventStreamPayload = {
objectRecordEventsWithQueryIds: [],
metadataEvents,
};
await this.subscriptionService.publishToEventStream({
workspaceId: metadataEventBatch.workspaceId,
eventStreamChannelId: streamChannelId,
payload,
});
}
private async enrichFieldMetadataEventsWithRelations(
metadataEventBatch: MetadataEventBatch,
): Promise<MetadataEventBatch> {
if (metadataEventBatch.metadataName !== 'fieldMetadata') {
return metadataEventBatch;
}
const { flatFieldMetadataMaps, flatObjectMetadataMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId: metadataEventBatch.workspaceId,
flatMapsKeys: ['flatFieldMetadataMaps', 'flatObjectMetadataMaps'],
},
);
const enrichedEvents = metadataEventBatch.events.map((event) => {
if (
!('after' in event.properties) ||
!isDefined(event.properties.after)
) {
return event;
}
const enrichedAfter = enrichFieldMetadataEventWithRelations({
record: event.properties.after as Record<string, unknown>,
flatFieldMetadataMaps,
flatObjectMetadataMaps,
});
return {
...event,
properties: {
...event.properties,
after: enrichedAfter,
},
} as typeof event;
});
return { ...metadataEventBatch, events: enrichedEvents };
}
private async enrichNavigationMenuItemEventsWithTargetRecordIdentifier(
metadataEventBatch: MetadataEventBatch,
): Promise<MetadataEventBatch> {
if (metadataEventBatch.metadataName !== 'navigationMenuItem') {
return metadataEventBatch;
}
const enrichedEvents = await Promise.all(
metadataEventBatch.events.map(async (event) => {
if (
!('after' in event.properties) ||
!isDefined(event.properties.after)
) {
return event;
}
const after = event.properties.after as Record<string, unknown>;
const targetRecordId = after.targetRecordId as string | undefined;
const targetObjectMetadataId = after.targetObjectMetadataId as
| string
| undefined;
if (!isDefined(targetRecordId) || !isDefined(targetObjectMetadataId)) {
return event;
}
const targetRecordIdentifier =
await this.navigationMenuItemRecordIdentifierService.resolveRecordIdentifier(
{
targetRecordId,
targetObjectMetadataId,
workspaceId: metadataEventBatch.workspaceId,
},
);
const enrichedAfter: Record<string, unknown> = {
...after,
targetRecordIdentifier,
};
return {
...event,
properties: {
...event.properties,
after: enrichedAfter,
},
} as typeof event;
}),
);
return { ...metadataEventBatch, events: enrichedEvents };
}
private resolveObjectMetadataStandardOverrides(
metadataEventBatch: MetadataEventBatch,
): MetadataEventBatch {
if (metadataEventBatch.metadataName !== 'objectMetadata') {
return metadataEventBatch;
}
const enrichedEvents = metadataEventBatch.events.map((event) => {
const enrichedProperties = { ...event.properties };
if (
'before' in enrichedProperties &&
isDefined(enrichedProperties.before)
) {
enrichedProperties.before =
this.applyStandardOverridesToObjectMetadataRecord(
enrichedProperties.before as Record<string, unknown>,
) as typeof enrichedProperties.before;
}
if (
'after' in enrichedProperties &&
isDefined(enrichedProperties.after)
) {
enrichedProperties.after =
this.applyStandardOverridesToObjectMetadataRecord(
enrichedProperties.after as Record<string, unknown>,
) as typeof enrichedProperties.after;
}
return { ...event, properties: enrichedProperties } as typeof event;
});
return { ...metadataEventBatch, events: enrichedEvents };
}
private applyStandardOverridesToObjectMetadataRecord(
record: Record<string, unknown>,
): Record<string, unknown> {
const standardOverrides = record.standardOverrides as
| Record<string, unknown>
| null
| undefined;
if (!isDefined(standardOverrides)) {
return record;
}
const resolved = { ...record };
for (const key of OBJECT_METADATA_STANDARD_OVERRIDES_PROPERTIES) {
if (isDefined(standardOverrides[key])) {
resolved[key] = standardOverrides[key];
}
}
return resolved;
}
private async processObjectRecordStreamEvents(
streamChannelId: string,
streamData: EventStreamData,
workspaceEventBatch: WorkspaceEventBatch<ObjectRecordEvent>,
permissionsContext: {
flatRowLevelPermissionPredicateMaps: FlatRowLevelPermissionPredicateMaps;
flatRowLevelPermissionPredicateGroupMaps: FlatRowLevelPermissionPredicateGroupMaps;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
userWorkspaceRoleMap: Record<string, string>;
rolesPermissions: ObjectsPermissionsByRoleId;
};
flatWorkspaceMemberMaps: FlatWorkspaceMemberMaps;
}): Promise<void> {
},
flatWorkspaceMemberMaps: FlatWorkspaceMemberMaps,
): Promise<void> {
const { userWorkspaceId } = streamData.authContext;
if (!isDefined(userWorkspaceId)) {
@@ -221,9 +470,7 @@ export class ObjectRecordEventPublisher {
if (matchedEvents.length > 0) {
await this.enrichEventBatchWithNestedRelations({
objectMetadata: workspaceEventBatch.objectMetadata,
events: matchedEvents.map(
(matchedEvent) => matchedEvent.objectRecordEvent,
),
events: matchedEvents.map((e) => e.objectRecordEvent),
streamData,
permissionsContext,
workspaceId: workspaceEventBatch.workspaceId,
@@ -8,7 +8,7 @@ import { isDefined } from 'twenty-shared/utils';
import { FlatApplicationCacheMaps } from 'src/engine/core-modules/application/types/flat-application-cache-maps.type';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { MetadataEventEmitter } from 'src/engine/subscriptions/metadata-event/metadata-event-emitter';
import { MetadataEventEmitter } from 'src/engine/metadata-event-emitter/metadata-event-emitter';
import { ALL_MANY_TO_ONE_METADATA_RELATIONS } from 'src/engine/metadata-modules/flat-entity/constant/all-many-to-one-metadata-relations.constant';
import { createEmptyFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-flat-entity-maps.constant';
import {
+23 -20
View File
@@ -102,14 +102,14 @@ __metadata:
linkType: hard
"@ai-sdk/mistral@npm:^3.0.20":
version: 3.0.25
resolution: "@ai-sdk/mistral@npm:3.0.25"
version: 3.0.20
resolution: "@ai-sdk/mistral@npm:3.0.20"
dependencies:
"@ai-sdk/provider": "npm:3.0.8"
"@ai-sdk/provider-utils": "npm:4.0.20"
"@ai-sdk/provider-utils": "npm:4.0.15"
peerDependencies:
zod: ^3.25.76 || ^4.1.8
checksum: 10c0/4ea732fc807e9e8f143ecbc3168e103dbb0c43f611ab9d9fdfe532510059c18aa8b44a61c7eafda4d9a91977e7d83fa94022c4aa39be21ad035b186f8471384f
checksum: 10c0/32caf651dae8a0fb7b7faceb8706e711404a32bc21aea94fe6bcbbe6adb09c31fc6563efd28547bdcbbfe99165145ec73fe83f4b54bd75f290b6020cc9834bc7
languageName: node
linkType: hard
@@ -163,19 +163,6 @@ __metadata:
languageName: node
linkType: hard
"@ai-sdk/provider-utils@npm:4.0.20":
version: 4.0.20
resolution: "@ai-sdk/provider-utils@npm:4.0.20"
dependencies:
"@ai-sdk/provider": "npm:3.0.8"
"@standard-schema/spec": "npm:^1.1.0"
eventsource-parser: "npm:^3.0.6"
peerDependencies:
zod: ^3.25.76 || ^4.1.8
checksum: 10c0/749d9f91b2b098f716246bbb92a801d0f8a952fed4481e48b7d36a1c026cfe96b9d956f34b95270bc4628048ad57edd7b19026c167e8ded288e741aa289d4cdd
languageName: node
linkType: hard
"@ai-sdk/provider@npm:2.0.1":
version: 2.0.1
resolution: "@ai-sdk/provider@npm:2.0.1"
@@ -4130,7 +4117,16 @@ __metadata:
languageName: node
linkType: hard
"@dagrejs/dagre@npm:^1.1.2, @dagrejs/dagre@npm:^1.1.8":
"@dagrejs/dagre@npm:^1.1.2":
version: 1.1.3
resolution: "@dagrejs/dagre@npm:1.1.3"
dependencies:
"@dagrejs/graphlib": "npm:2.2.2"
checksum: 10c0/c81e142ed55d23bda02968624bd96987eeb545dbc860b0dc22aafd606f8bd8b1b18914e2b986c46e3250a6cfcc2bc16a944bb8df301da9adffd738936679db44
languageName: node
linkType: hard
"@dagrejs/dagre@npm:^1.1.8":
version: 1.1.8
resolution: "@dagrejs/dagre@npm:1.1.8"
dependencies:
@@ -4139,6 +4135,13 @@ __metadata:
languageName: node
linkType: hard
"@dagrejs/graphlib@npm:2.2.2":
version: 2.2.2
resolution: "@dagrejs/graphlib@npm:2.2.2"
checksum: 10c0/2e79a4f5c6c402054b7ef42e786459645495934476170999f13867a55a00072636a23914772cce6bc03ce51eef70de589058860b8f034c1d70804fb61e01fcfc
languageName: node
linkType: hard
"@dagrejs/graphlib@npm:2.2.4":
version: 2.2.4
resolution: "@dagrejs/graphlib@npm:2.2.4"
@@ -46872,14 +46875,14 @@ __metadata:
languageName: node
linkType: hard
"nodemailer@npm:7.0.11":
"nodemailer@npm:7.0.11, nodemailer@npm:^7.0.11":
version: 7.0.11
resolution: "nodemailer@npm:7.0.11"
checksum: 10c0/208f108fdb4c5dd0e3a2f013578d53dad505cf1b9c7a084f6d22fc9d6f3912daafb4a23793ca568ff848afc35f15f4eb24382d3f6f9fb8ede4a8410d4ca63618
languageName: node
linkType: hard
"nodemailer@npm:7.0.13, nodemailer@npm:^7.0.11":
"nodemailer@npm:7.0.13":
version: 7.0.13
resolution: "nodemailer@npm:7.0.13"
checksum: 10c0/b26aa5b9fa4a033bbc1e1c16ef75ee2a9c8641fd290c00a8361d6a251b3c1b8bad545a23efa627f59cb266340a448891ea8aa49d8a9307c767b8505219d95079