Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a35ea3b61 |
@@ -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);
|
||||
|
||||
+2
-3
@@ -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();
|
||||
|
||||
|
||||
-88
@@ -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 = {};
|
||||
|
||||
|
||||
+1
-1
@@ -202,7 +202,7 @@ export const buildRecordFromImportedStructuredRow = ({
|
||||
},
|
||||
|
||||
[FieldMetadataType.EMAILS]: {
|
||||
primaryEmail: (value: unknown) => castToString(value).toLowerCase(),
|
||||
primaryEmail: castToString,
|
||||
additionalEmails: stringArrayJSONSchema.parse,
|
||||
},
|
||||
[FieldMetadataType.FULL_NAME]: {
|
||||
|
||||
+4
-2
@@ -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`,
|
||||
|
||||
+1
-1
@@ -45,7 +45,7 @@ export const SignInAppNavigationDrawerMock = ({
|
||||
label={t`Search`}
|
||||
Icon={IconSearch}
|
||||
onClick={() => {}}
|
||||
modifier={{ keyboard: [getOsControlSymbol(), 'K'] }}
|
||||
keyboard={[getOsControlSymbol(), 'K']}
|
||||
/>
|
||||
<NavigationDrawerItem
|
||||
label={t`Settings`}
|
||||
|
||||
+74
-53
@@ -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 ||
|
||||
|
||||
+8
-2
@@ -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}
|
||||
|
||||
+8
-3
@@ -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>
|
||||
|
||||
+34
-15
@@ -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
-123
@@ -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();
|
||||
|
||||
+4
-114
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+2
-7
@@ -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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
+3
-8
@@ -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 {
|
||||
|
||||
+2
-7
@@ -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,
|
||||
}),
|
||||
};
|
||||
|
||||
+2
-7
@@ -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],
|
||||
|
||||
+2
-7
@@ -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],
|
||||
|
||||
-7
@@ -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',
|
||||
});
|
||||
|
||||
+3
-3
@@ -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,
|
||||
|
||||
-51
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
+1
-2
@@ -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;
|
||||
}
|
||||
|
||||
+23
-3
@@ -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();
|
||||
}
|
||||
|
||||
+4
-4
@@ -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,
|
||||
});
|
||||
+14
@@ -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 {}
|
||||
+2
-2
@@ -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,
|
||||
+3
-5
@@ -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,
|
||||
};
|
||||
|
||||
+1
-14
@@ -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: [],
|
||||
},
|
||||
},
|
||||
|
||||
+1
-8
@@ -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,
|
||||
|
||||
+1
-1
@@ -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',
|
||||
}
|
||||
|
||||
-250
@@ -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 {}
|
||||
|
||||
+15
-6
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
+512
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+198
@@ -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),
|
||||
);
|
||||
};
|
||||
+3
-1
@@ -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:
|
||||
+24
-2
@@ -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 {}
|
||||
|
||||
+45
-5
@@ -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: (
|
||||
+278
-31
@@ -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,
|
||||
+1
-1
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user