Compare commits

..
Author SHA1 Message Date
Sonarly Claude CodeandClaude Opus 4.6 ebc224ae3b Fix multi-select filter crash when 'in' operator is used
The isMatchingMultiSelectFilter function did not handle the 'in' filter
operator, causing a runtime error when a multi-select field received an
'in' filter (e.g. after converting a SELECT field to MULTI_SELECT while
a view filter was active). Added 'in' support to both the
MultiSelectFilter type and the matching function.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 17:21:16 +00:00
Charles BochetandGitHub c3781e87cc Sync page Layout (#18034)
## Sync page layouts, tabs, and widgets

Adds the ability for SDK applications to synchronize `pageLayout`,
`pageLayoutTab`, and `pageLayoutWidget` entities, following the same
pattern established in #18003 for views and navigation menu items.

### Changes

**`twenty-shared`**
- New `PageLayoutManifest`, `PageLayoutTabManifest`, and
`PageLayoutWidgetManifest` types with a hierarchical structure (page
layout → tabs → widgets)
- Added `pageLayouts: PageLayoutManifest[]` to the `Manifest` type

**`twenty-sdk`**
- New `definePageLayout()` SDK function with validation for
universalIdentifier, name, and nested tabs/widgets
- Wired into the manifest extraction and build pipeline
(`DefinePageLayout` target function, `PageLayouts` entity key)
- Exported from the SDK entry point

**`twenty-server`**
- Added `pageLayout`, `pageLayoutTab`, `pageLayoutWidget` to
`APPLICATION_MANIFEST_METADATA_NAMES`
- New conversion utilities: manifest → universal flat entity for all
three entity types
- Updated `computeApplicationManifestAllUniversalFlatEntity
2026-02-18 17:55:04 +01:00
e9b5cb830c i18n - docs translations (#18040)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-02-18 17:54:40 +01:00
EtienneandGitHub e40c758aa6 Nav Menu Item Migration command fix (#18041) 2026-02-18 17:45:21 +01:00
martmullandGitHub 53c314d0fa 2094 extensibility define postinstall orand preinstall function to run in application (#18037)
- add a new optional key `postInstallLogicFunctionUniversalIdentifier`
in applicationConfig
- seed postInstall function in create-twenty-app
- update execute:function options
- update doc
2026-02-18 15:38:22 +00:00
618df704e6 [Chore] : Generate migration for DATE_TIME to DATE for DATE_TIME + IS operand filters (#17564)
migration command in response to the fix :
https://github.com/twentyhq/twenty/pull/17529 for the issue
https://github.com/twentyhq/core-team-issues/issues/2027

---------

Co-authored-by: Arun kumar <arunkumar@Aruns-MacBook-Air.local>
Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
2026-02-18 15:15:38 +00:00
EtienneandGitHub 058489b5cc Fixes - Workspace logo migration (#18035)
- Update migration command to handle case where workspace logo is
originated from workspace email and point to twenty-icons.com
- Update same logic for new workspaces
- Add feature-flag for all newly created workspaces
2026-02-18 16:35:48 +01:00
3bd431e95d Use proper PostgreSQL identifier/literal escaping in workspace DDL (#18024)
## Summary

- Replace the character-stripping approach (`removeSqlDDLInjection`)
with standard PostgreSQL `escapeIdentifier` and `escapeLiteral`
functions across all workspace schema manager services
- Add missing identifier escaping to `createForeignKey` (was the only
method in the FK manager without it)
- Add allowlist validation for index WHERE clauses and FK action types
- Harden tsvector expression builder with proper identifier quoting

## Context

The workspace schema managers build DDL dynamically from metadata (table
names, column names, enum values, etc.). The previous approach stripped
all non-alphanumeric characters — safe but lossy (silently corrupts
values with legitimate special characters). The new approach uses
PostgreSQL's standard escaping:

- **Identifiers**: double internal `"` and wrap → `"my""table"` (same
algorithm as `pg` driver's `escapeIdentifier`)
- **Literals**: double internal `'` and wrap → `'it''s a value'` (same
algorithm as `pg` driver's `escapeLiteral`)

`removeSqlDDLInjection` is kept only for name generation (e.g.,
`computePostgresEnumName`) where stripping to `[a-zA-Z0-9_]` is the
correct behavior.

## Files changed

| File | What |
|------|------|
| `remove-sql-injection.util.ts` | Added `escapeIdentifier` +
`escapeLiteral` |
| `validate-index-where-clause.util.ts` | New — allowlist for partial
index WHERE clauses |
| 5 schema manager services | Replaced strip+manual-quote with
`escapeIdentifier`/`escapeLiteral` |
| `build-sql-column-definition.util.ts` | `escapeIdentifier` for column
names, validated `generatedType` |
| `sanitize-default-value.util.ts` | `escapeLiteral` instead of
stripping |
| `serialize-default-value.util.ts` | `escapeLiteral` for values,
`escapeIdentifier` for enum casts |
| `get-ts-vector-column-expression.util.ts` | `escapeIdentifier` for
field names in expressions |
| `sanitize-default-value.util.spec.ts` | Updated tests for escape
behavior |

## Test plan

- [x] All 64 existing tests pass across 6 test suites
- [x] `lint:diff-with-main` passes
- [x] TypeScript typecheck — no new errors
- [ ] Verify workspace sync-metadata still works end-to-end
- [ ] Verify custom object/field creation works
- [ ] Verify enum field option changes work


Made with [Cursor](https://cursor.com)

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-18 14:24:10 +00:00
f4a61f26c0 Replace generic "Unknown error" messages with descriptive error details (#18019)
## Summary

- Replace all generic `"Unknown error"` fallback messages across the
server codebase with messages that include the actual error details
- The most impactful change is in `guard-redirect.service.ts`, which
handles OAuth redirect errors — non-`AuthException` errors (e.g.,
passport state verification failures) now show `"Authentication error:
<actual message>"` instead of the opaque `"Unknown error"`
- Gmail/Google error handler services now include the error message in
the thrown exception instead of discarding it
- Other catch blocks (workflow delay resume, migration runner rollback,
code interpreter, marketplace) now use `String(error)` for non-Error
objects instead of a static fallback

Fixes the class of issues reported in
https://github.com/twentyhq/twenty/issues/17812, where a user saw
"Unknown error" during Google OAuth and had no way to diagnose the root
cause (which turned out to be a session cookie / SSL configuration
issue).

## Test plan

- [ ] Verify OAuth error flows (e.g., Google Auth with misconfigured
callback URL) now display the actual error message on the `/verify` page
instead of "Unknown error"
- [ ] Verify Gmail sync error handling still correctly classifies and
re-throws errors with descriptive messages
- [ ] Verify workflow delay resume failures include the error details in
the workflow run status


Made with [Cursor](https://cursor.com)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-18 14:12:47 +00:00
8e6b267ff3 Allow DATE_TIME IS operand to filter on a whole day (#17529)
Fixes:  https://github.com/twentyhq/core-team-issues/issues/2027

We've replaced the DATE_TIME picker with DATE picker, and changed the
logic to filter for complete day period.



https://github.com/user-attachments/assets/ba7e1078-bab3-4c62-a803-d6a851f14b7d

---------

Co-authored-by: Arun kumar <arunkumar@Aruns-MacBook-Air.local>
Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
2026-02-18 13:44:05 +00:00
102 changed files with 2748 additions and 647 deletions
+4
View File
@@ -54,6 +54,9 @@ yarn twenty function:logs
# Execute a function with a JSON payload
yarn twenty function:execute -n my-function -p '{"key": "value"}'
# Execute the post-install function
yarn twenty function:execute --postInstall
# Uninstall the application from the current workspace
yarn twenty app:uninstall
```
@@ -63,6 +66,7 @@ yarn twenty app:uninstall
- `application-config.ts` - Application metadata configuration
- `roles/default-role.ts` - Default role for logic functions
- `logic-functions/hello-world.ts` - Example logic function with HTTP trigger
- `logic-functions/post-install.ts` - Post-install logic function (runs after app installation)
- `front-components/hello-world.tsx` - Example front component
- TypeScript configuration
- A prewired `twenty` script that delegates to the `twenty` CLI from twenty-sdk
@@ -49,6 +49,12 @@ export const copyBaseApplicationProject = async ({
fileName: 'hello-world.ts',
});
await createDefaultPostInstallFunction({
appDirectory: sourceFolderPath,
fileFolder: 'logic-functions',
fileName: 'post-install.ts',
});
await createApplicationConfig({
displayName: appDisplayName,
description: appDescription,
@@ -196,7 +202,6 @@ const handler = async (): Promise<{ message: string }> => {
return { message: 'Hello, World!' };
};
// Logic function handler - rename and implement your logic
export default defineLogicFunction({
universalIdentifier: '${universalIdentifier}',
name: 'hello-world-logic-function',
@@ -215,6 +220,38 @@ export default defineLogicFunction({
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
};
const createDefaultPostInstallFunction = async ({
appDirectory,
fileFolder,
fileName,
}: {
appDirectory: string;
fileFolder?: string;
fileName: string;
}) => {
const universalIdentifier = v4();
const content = `import { defineLogicFunction } from 'twenty-sdk';
export const POST_INSTALL_UNIVERSAL_IDENTIFIER = '${universalIdentifier}';
const handler = async (): Promise<void> => {
console.log('Post install logic function executed successfully!');
};
export default defineLogicFunction({
universalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
name: 'post-install',
description: 'Runs after installation to set up the application.',
timeoutSeconds: 300,
handler,
});
`;
await fs.ensureDir(join(appDirectory, fileFolder ?? ''));
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
};
const createApplicationConfig = async ({
displayName,
description,
@@ -230,12 +267,14 @@ const createApplicationConfig = async ({
}) => {
const content = `import { defineApplication } from 'twenty-sdk';
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role';
import { POST_INSTALL_UNIVERSAL_IDENTIFIER } from 'src/logic-functions/post-install';
export default defineApplication({
universalIdentifier: '${v4()}',
displayName: '${displayName}',
description: '${description ?? ''}',
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
postInstallLogicFunctionUniversalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
});
`;
@@ -53,6 +53,9 @@ yarn twenty function:logs
# Execute a function by name
yarn twenty function:execute -n my-function -p '{"name": "test"}'
# Execute the post-install function
yarn twenty function:execute --postInstall
# Uninstall the application from the current workspace
yarn twenty app:uninstall
@@ -69,7 +72,7 @@ When you run `npx create-twenty-app@latest my-twenty-app`, the scaffolder:
- Copies a minimal base application into `my-twenty-app/`
- Adds a local `twenty-sdk` dependency and Yarn 4 configuration
- Creates config files and scripts wired to the `twenty` CLI
- Generates a default application config and a default function role
- Generates a default application config, a default function role, and a post-install function
A freshly scaffolded app looks like this:
@@ -91,7 +94,8 @@ my-twenty-app/
├── roles/
│ └── default-role.ts # Default role for logic functions
├── logic-functions/
── hello-world.ts # Example logic function
── hello-world.ts # Example logic function
│ └── post-install.ts # Post-install logic function
└── front-components/
└── hello-world.tsx # Example front component
```
@@ -289,6 +293,7 @@ Every app has a single `application-config.ts` file that describes:
- **Who the app is**: identifiers, display name, and description.
- **How its functions run**: which role they use for permissions.
- **(Optional) variables**: keyvalue pairs exposed to your functions as environment variables.
- **(Optional) post-install function**: a logic function that runs after the app is installed.
Use `defineApplication()` to define your application configuration:
@@ -296,6 +301,7 @@ Use `defineApplication()` to define your application configuration:
// src/application-config.ts
import { defineApplication } from 'twenty-sdk';
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role';
import { POST_INSTALL_UNIVERSAL_IDENTIFIER } from 'src/logic-functions/post-install';
export default defineApplication({
universalIdentifier: '4ec0391d-18d5-411c-b2f3-266ddc1c3ef7',
@@ -311,6 +317,7 @@ export default defineApplication({
},
},
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
postInstallLogicFunctionUniversalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
});
```
@@ -318,6 +325,7 @@ Notes:
- `universalIdentifier` fields are deterministic IDs you own; generate them once and keep them stable across syncs.
- `applicationVariables` become environment variables for your functions (for example, `DEFAULT_RECIPIENT_NAME` is available as `process.env.DEFAULT_RECIPIENT_NAME`).
- `defaultRoleUniversalIdentifier` must match the role file (see below).
- `postInstallLogicFunctionUniversalIdentifier` (optional) points to a logic function that runs automatically after the app is installed. See [Post-install functions](#post-install-functions).
#### Roles and permissions
@@ -450,6 +458,54 @@ Notes:
- The `triggers` array is optional. Functions without triggers can be used as utility functions called by other functions.
- You can mix multiple trigger types in a single function.
### Post-install functions
A post-install function is a logic function that runs automatically after your app is installed on a workspace. This is useful for one-time setup tasks such as seeding default data, creating initial records, or configuring workspace settings.
When you scaffold a new app with `create-twenty-app`, a post-install function is generated for you at `src/logic-functions/post-install.ts`:
```typescript
// src/logic-functions/post-install.ts
import { defineLogicFunction } from 'twenty-sdk';
export const POST_INSTALL_UNIVERSAL_IDENTIFIER = '<generated-uuid>';
const handler = async (): Promise<void> => {
console.log('Post install logic function executed successfully!');
};
export default defineLogicFunction({
universalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
name: 'post-install',
description: 'Runs after installation to set up the application.',
timeoutSeconds: 300,
handler,
});
```
The function is wired into your app by referencing its universal identifier in `application-config.ts`:
```typescript
import { POST_INSTALL_UNIVERSAL_IDENTIFIER } from 'src/logic-functions/post-install';
export default defineApplication({
// ...
postInstallLogicFunctionUniversalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
});
```
You can also manually execute the post-install function at any time using the CLI:
```bash filename="Terminal"
yarn twenty function:execute --postInstall
```
Key points:
- Post-install functions are standard logic functions — they use `defineLogicFunction()` like any other function.
- The `postInstallLogicFunctionUniversalIdentifier` field in `defineApplication()` is optional. If omitted, no function runs after installation.
- The default timeout is set to 300 seconds (5 minutes) to allow for longer setup tasks like data seeding.
- Post-install functions do not need triggers — they are invoked by the platform during installation or manually via `function:execute --postInstall`.
### Route trigger payload
<Warning>
@@ -45,19 +45,22 @@ yarn twenty app:dev
من هنا يمكنك:
```bash filename="Terminal"
# أضف كيانًا جديدًا إلى تطبيقك (موجّه)
# Add a new entity to your application (guided)
yarn twenty entity:add
# راقب سجلات وظائف تطبيقك
# Watch your application's function logs
yarn twenty function:logs
# نفّذ وظيفة بالاسم
# Execute a function by name
yarn twenty function:execute -n my-function -p '{"name": "test"}'
# أزل تثبيت التطبيق من مساحة العمل الحالية
# Execute the post-install function
yarn twenty function:execute --postInstall
# Uninstall the application from the current workspace
yarn twenty app:uninstall
# اعرض مساعدة الأوامر
# Display commands' help
yarn twenty help
```
@@ -70,7 +73,7 @@ yarn twenty help
* ينسخ تطبيقًا أساسيًا مصغّرًا إلى `my-twenty-app/`
* يضيف اعتمادًا محليًا `twenty-sdk` وتهيئة Yarn 4
* ينشئ ملفات ضبط ونصوصًا مرتبطة بـ `twenty` CLI
* يُولّد ضبطًا افتراضيًا للتطبيق ودورًا افتراضيًا للوظيفة
* Generates a default application config, a default function role, and a post-install function
يبدو التطبيق المُنشأ حديثًا بالقالب كما يلي:
@@ -86,15 +89,16 @@ my-twenty-app/
eslint.config.mjs
tsconfig.json
README.md
public/ # مجلد الأصول العامة (صور، خطوط، إلخ)
public/ # Public assets folder (images, fonts, etc.)
src/
├── application-config.ts # مطلوب - التكوين الرئيسي للتطبيق
├── application-config.ts # Required - main application configuration
├── roles/
│ └── default-role.ts # الدور الافتراضي لوظائف المنطق
│ └── default-role.ts # Default role for logic functions
├── logic-functions/
── hello-world.ts # مثال لوظيفة منطقية
── hello-world.ts # Example logic function
│ └── post-install.ts # Post-install logic function
└── front-components/
└── hello-world.tsx # مثال لمكوّن الواجهة الأمامية
└── hello-world.tsx # Example front component
```
بشكل عام:
@@ -290,6 +294,7 @@ export default defineObject({
* **هوية التطبيق**: المعرفات، اسم العرض، والوصف.
* **كيفية تشغيل وظائفه**: الدور الذي تستخدمه للأذونات.
* **متغيرات (اختياري)**: أزواج مفتاح-قيمة تُعرض لوظائفك كمتغيرات بيئة.
* **(Optional) post-install function**: a logic function that runs after the app is installed.
Use `defineApplication()` to define your application configuration:
@@ -297,6 +302,7 @@ Use `defineApplication()` to define your application configuration:
// src/application-config.ts
import { defineApplication } from 'twenty-sdk';
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role';
import { POST_INSTALL_UNIVERSAL_IDENTIFIER } from 'src/logic-functions/post-install';
export default defineApplication({
universalIdentifier: '4ec0391d-18d5-411c-b2f3-266ddc1c3ef7',
@@ -312,6 +318,7 @@ export default defineApplication({
},
},
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
postInstallLogicFunctionUniversalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
});
```
@@ -320,6 +327,7 @@ export default defineApplication({
* حقول `universalIdentifier` هي معرّفات حتمية تخصك؛ أنشئها مرة واحدة واحتفظ بها ثابتة عبر عمليات المزامنة.
* `applicationVariables` تصبح متغيرات بيئة لوظائفك (على سبيل المثال، `DEFAULT_RECIPIENT_NAME` متاح كـ `process.env.DEFAULT_RECIPIENT_NAME`).
* `defaultRoleUniversalIdentifier` يجب أن يطابق ملف الدور (انظر أدناه).
* `postInstallLogicFunctionUniversalIdentifier` (optional) points to a logic function that runs automatically after the app is installed. See [Post-install functions](#post-install-functions).
#### الأدوار والصلاحيات
@@ -458,6 +466,55 @@ export default defineLogicFunction({
* المصفوفة `triggers` اختيارية. يمكن استخدام الوظائف بدون مشغلات كوظائف مساعدة تُستدعى بواسطة وظائف أخرى.
* يمكنك مزج أنواع متعددة من المشغلات في وظيفة واحدة.
### Post-install functions
A post-install function is a logic function that runs automatically after your app is installed on a workspace. This is useful for one-time setup tasks such as seeding default data, creating initial records, or configuring workspace settings.
When you scaffold a new app with `create-twenty-app`, a post-install function is generated for you at `src/logic-functions/post-install.ts`:
```typescript
// src/logic-functions/post-install.ts
import { defineLogicFunction } from 'twenty-sdk';
export const POST_INSTALL_UNIVERSAL_IDENTIFIER = '<generated-uuid>';
const handler = async (): Promise<void> => {
console.log('Post install logic function executed successfully!');
};
export default defineLogicFunction({
universalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
name: 'post-install',
description: 'Runs after installation to set up the application.',
timeoutSeconds: 300,
handler,
});
```
The function is wired into your app by referencing its universal identifier in `application-config.ts`:
```typescript
import { POST_INSTALL_UNIVERSAL_IDENTIFIER } from 'src/logic-functions/post-install';
export default defineApplication({
// ...
postInstallLogicFunctionUniversalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
});
```
You can also manually execute the post-install function at any time using the CLI:
```bash filename="Terminal"
yarn twenty function:execute --postInstall
```
النقاط الرئيسية:
* Post-install functions are standard logic functions — they use `defineLogicFunction()` like any other function.
* The `postInstallLogicFunctionUniversalIdentifier` field in `defineApplication()` is optional. If omitted, no function runs after installation.
* The default timeout is set to 300 seconds (5 minutes) to allow for longer setup tasks like data seeding.
* Post-install functions do not need triggers — they are invoked by the platform during installation or manually via `function:execute --postInstall`.
### حمولة مشغل المسار
<Warning>
@@ -45,19 +45,22 @@ yarn twenty app:dev
Odtud můžete:
```bash filename="Terminal"
# Přidejte do vaší aplikace novou entitu (s průvodcem)
# Add a new entity to your application (guided)
yarn twenty entity:add
# Sledujte logy funkcí vaší aplikace
# Watch your application's function logs
yarn twenty function:logs
# Spusťte funkci podle názvu
# Execute a function by name
yarn twenty function:execute -n my-function -p '{"name": "test"}'
# Odinstalujte aplikaci z aktuálního pracovního prostoru
# Execute the post-install function
yarn twenty function:execute --postInstall
# Uninstall the application from the current workspace
yarn twenty app:uninstall
# Zobrazte nápovědu k příkazům
# Display commands' help
yarn twenty help
```
@@ -70,7 +73,7 @@ Když spustíte `npx create-twenty-app@latest my-twenty-app`, scaffolder:
* Zkopíruje minimální základní aplikaci do `my-twenty-app/`
* Přidá lokální závislost `twenty-sdk` a konfiguraci pro Yarn 4
* Vytvoří konfigurační soubory a skripty napojené na `twenty` CLI
* Vygeneruje výchozí konfiguraci aplikace a výchozí roli funkcí
* Generates a default application config, a default function role, and a post-install function
Čerstvě vytvořená aplikace vypadá takto:
@@ -86,15 +89,16 @@ my-twenty-app/
eslint.config.mjs
tsconfig.json
README.md
public/ # Složka s veřejnými prostředky (obrázky, písma apod.)
public/ # Public assets folder (images, fonts, etc.)
src/
├── application-config.ts # Povinné hlavní konfigurace aplikace
├── application-config.ts # Required - main application configuration
├── roles/
│ └── default-role.ts # Výchozí role pro logic funkce
│ └── default-role.ts # Default role for logic functions
├── logic-functions/
── hello-world.ts # Ukázková logic funkce
── hello-world.ts # Example logic function
│ └── post-install.ts # Post-install logic function
└── front-components/
└── hello-world.tsx # Ukázková front-endová komponenta
└── hello-world.tsx # Example front component
```
V kostce:
@@ -290,6 +294,7 @@ Každá aplikace má jeden soubor `application-config.ts`, který popisuje:
* **Identitu aplikace**: identifikátory, zobrazovaný název a popis.
* **Jak běží její funkce**: kterou roli používají pro oprávnění.
* **(Volitelné) proměnné**: dvojice klíč–hodnota zpřístupněné vašim funkcím jako proměnné prostředí.
* **(Optional) post-install function**: a logic function that runs after the app is installed.
Use `defineApplication()` to define your application configuration:
@@ -297,6 +302,7 @@ Use `defineApplication()` to define your application configuration:
// src/application-config.ts
import { defineApplication } from 'twenty-sdk';
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role';
import { POST_INSTALL_UNIVERSAL_IDENTIFIER } from 'src/logic-functions/post-install';
export default defineApplication({
universalIdentifier: '4ec0391d-18d5-411c-b2f3-266ddc1c3ef7',
@@ -312,6 +318,7 @@ export default defineApplication({
},
},
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
postInstallLogicFunctionUniversalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
});
```
@@ -320,6 +327,7 @@ Poznámky:
* Pole `universalIdentifier` jsou deterministická ID, která vlastníte; vygenerujte je jednou a udržujte je stabilní napříč synchronizacemi.
* `applicationVariables` se stanou proměnnými prostředí pro vaše funkce (například `DEFAULT_RECIPIENT_NAME` je dostupné jako `process.env.DEFAULT_RECIPIENT_NAME`).
* `defaultRoleUniversalIdentifier` se musí shodovat se souborem role (viz níže).
* `postInstallLogicFunctionUniversalIdentifier` (optional) points to a logic function that runs automatically after the app is installed. See [Post-install functions](#post-install-functions).
#### Role a oprávnění
@@ -458,6 +466,55 @@ Poznámky:
* Pole `triggers` je volitelné. Funkce bez spouštěčů lze použít jako pomocné funkce volané jinými funkcemi.
* V jedné funkci můžete kombinovat více typů spouštěčů.
### Post-install functions
A post-install function is a logic function that runs automatically after your app is installed on a workspace. This is useful for one-time setup tasks such as seeding default data, creating initial records, or configuring workspace settings.
When you scaffold a new app with `create-twenty-app`, a post-install function is generated for you at `src/logic-functions/post-install.ts`:
```typescript
// src/logic-functions/post-install.ts
import { defineLogicFunction } from 'twenty-sdk';
export const POST_INSTALL_UNIVERSAL_IDENTIFIER = '<generated-uuid>';
const handler = async (): Promise<void> => {
console.log('Post install logic function executed successfully!');
};
export default defineLogicFunction({
universalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
name: 'post-install',
description: 'Runs after installation to set up the application.',
timeoutSeconds: 300,
handler,
});
```
The function is wired into your app by referencing its universal identifier in `application-config.ts`:
```typescript
import { POST_INSTALL_UNIVERSAL_IDENTIFIER } from 'src/logic-functions/post-install';
export default defineApplication({
// ...
postInstallLogicFunctionUniversalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
});
```
You can also manually execute the post-install function at any time using the CLI:
```bash filename="Terminal"
yarn twenty function:execute --postInstall
```
Hlavní body:
* Post-install functions are standard logic functions — they use `defineLogicFunction()` like any other function.
* The `postInstallLogicFunctionUniversalIdentifier` field in `defineApplication()` is optional. If omitted, no function runs after installation.
* The default timeout is set to 300 seconds (5 minutes) to allow for longer setup tasks like data seeding.
* Post-install functions do not need triggers — they are invoked by the platform during installation or manually via `function:execute --postInstall`.
### Payload spouštěče trasy
<Warning>
@@ -45,19 +45,22 @@ yarn twenty app:dev
Von hier aus können Sie:
```bash filename="Terminal"
# Eine neue Entität zu Ihrer Anwendung hinzufügen (geführt)
# Add a new entity to your application (guided)
yarn twenty entity:add
# Die Funktionsprotokolle Ihrer Anwendung überwachen
# Watch your application's function logs
yarn twenty function:logs
# Eine Funktion anhand ihres Namens ausführen
# Execute a function by name
yarn twenty function:execute -n my-function -p '{"name": "test"}'
# Die Anwendung aus dem aktuellen Arbeitsbereich deinstallieren
# Execute the post-install function
yarn twenty function:execute --postInstall
# Uninstall the application from the current workspace
yarn twenty app:uninstall
# Hilfe zu Befehlen anzeigen
# Display commands' help
yarn twenty help
```
@@ -70,7 +73,7 @@ Wenn Sie `npx create-twenty-app@latest my-twenty-app` ausführen, erledigt der S
* Kopiert eine minimale Basisanwendung nach `my-twenty-app/`
* Fügt eine lokale `twenty-sdk`-Abhängigkeit und die Yarn-4-Konfiguration hinzu
* Erstellt Konfigurationsdateien und Skripte, die an die `twenty`-CLI angebunden sind
* Generiert eine Standard-Anwendungskonfiguration und eine Standard-Funktionsrolle
* Generates a default application config, a default function role, and a post-install function
Eine frisch erzeugte App sieht so aus:
@@ -92,7 +95,8 @@ my-twenty-app/
├── roles/
│ └── default-role.ts # Default role for logic functions
├── logic-functions/
── hello-world.ts # Example logic function
── hello-world.ts # Example logic function
│ └── post-install.ts # Post-install logic function
└── front-components/
└── hello-world.tsx # Example front component
```
@@ -290,6 +294,7 @@ Jede App hat eine einzelne Datei `application-config.ts`, die Folgendes beschrei
* **Was die App ist**: Bezeichner, Anzeigename und Beschreibung.
* **Wie ihre Funktionen ausgeführt werden**: welche Rolle sie für Berechtigungen verwenden.
* **(Optional) Variablen**: SchlüsselWert-Paare, die Ihren Funktionen als Umgebungsvariablen zur Verfügung gestellt werden.
* **(Optional) post-install function**: a logic function that runs after the app is installed.
Verwenden Sie `defineApplication()`, um Ihre Anwendungskonfiguration zu definieren:
@@ -297,6 +302,7 @@ Verwenden Sie `defineApplication()`, um Ihre Anwendungskonfiguration zu definier
// src/application-config.ts
import { defineApplication } from 'twenty-sdk';
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role';
import { POST_INSTALL_UNIVERSAL_IDENTIFIER } from 'src/logic-functions/post-install';
export default defineApplication({
universalIdentifier: '4ec0391d-18d5-411c-b2f3-266ddc1c3ef7',
@@ -312,6 +318,7 @@ export default defineApplication({
},
},
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
postInstallLogicFunctionUniversalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
});
```
@@ -320,6 +327,7 @@ Notizen:
* `universalIdentifier`-Felder sind deterministische IDs, die Sie besitzen; generieren Sie sie einmal und halten Sie sie über Synchronisierungen hinweg stabil.
* `applicationVariables` werden zu Umgebungsvariablen für Ihre Funktionen (zum Beispiel ist `DEFAULT_RECIPIENT_NAME` als `process.env.DEFAULT_RECIPIENT_NAME` verfügbar).
* `defaultRoleUniversalIdentifier` muss mit der Rollendatei übereinstimmen (siehe unten).
* `postInstallLogicFunctionUniversalIdentifier` (optional) points to a logic function that runs automatically after the app is installed. See [Post-install functions](#post-install-functions).
#### Rollen und Berechtigungen
@@ -458,6 +466,55 @@ Notizen:
* Das Array `triggers` ist optional. Funktionen ohne Trigger können als von anderen Funktionen aufgerufene Utility-Funktionen verwendet werden.
* Sie können mehrere Trigger-Typen in einer Funktion kombinieren.
### Post-install functions
A post-install function is a logic function that runs automatically after your app is installed on a workspace. This is useful for one-time setup tasks such as seeding default data, creating initial records, or configuring workspace settings.
When you scaffold a new app with `create-twenty-app`, a post-install function is generated for you at `src/logic-functions/post-install.ts`:
```typescript
// src/logic-functions/post-install.ts
import { defineLogicFunction } from 'twenty-sdk';
export const POST_INSTALL_UNIVERSAL_IDENTIFIER = '<generated-uuid>';
const handler = async (): Promise<void> => {
console.log('Post install logic function executed successfully!');
};
export default defineLogicFunction({
universalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
name: 'post-install',
description: 'Runs after installation to set up the application.',
timeoutSeconds: 300,
handler,
});
```
The function is wired into your app by referencing its universal identifier in `application-config.ts`:
```typescript
import { POST_INSTALL_UNIVERSAL_IDENTIFIER } from 'src/logic-functions/post-install';
export default defineApplication({
// ...
postInstallLogicFunctionUniversalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
});
```
You can also manually execute the post-install function at any time using the CLI:
```bash filename="Terminal"
yarn twenty function:execute --postInstall
```
Hauptpunkte:
* Post-install functions are standard logic functions — they use `defineLogicFunction()` like any other function.
* The `postInstallLogicFunctionUniversalIdentifier` field in `defineApplication()` is optional. If omitted, no function runs after installation.
* The default timeout is set to 300 seconds (5 minutes) to allow for longer setup tasks like data seeding.
* Post-install functions do not need triggers — they are invoked by the platform during installation or manually via `function:execute --postInstall`.
### Routen-Trigger-Payload
<Warning>
@@ -45,19 +45,22 @@ yarn twenty app:dev
Da qui puoi:
```bash filename="Terminal"
# Aggiungi una nuova entità alla tua applicazione (guidata)
# Add a new entity to your application (guided)
yarn twenty entity:add
# Monitora i log delle funzioni della tua applicazione
# Watch your application's function logs
yarn twenty function:logs
# Esegui una funzione per nome
yarn twenty function:execute -n my-function -p '{\"name\": \"test\"}'
# Execute a function by name
yarn twenty function:execute -n my-function -p '{"name": "test"}'
# Disinstalla l'applicazione dallo spazio di lavoro corrente
# Execute the post-install function
yarn twenty function:execute --postInstall
# Uninstall the application from the current workspace
yarn twenty app:uninstall
# Mostra l'aiuto dei comandi
# Display commands' help
yarn twenty help
```
@@ -70,7 +73,7 @@ Quando esegui `npx create-twenty-app@latest my-twenty-app`, lo scaffolder:
* Copia un'applicazione base minimale in `my-twenty-app/`
* Aggiunge una dipendenza locale `twenty-sdk` e la configurazione di Yarn 4
* Crea file di configurazione e script collegati alla CLI `twenty`
* Genera una configurazione applicativa predefinita e un ruolo funzione predefinito
* Generates a default application config, a default function role, and a post-install function
Un'app appena generata dallo scaffolder si presenta così:
@@ -86,15 +89,16 @@ my-twenty-app/
eslint.config.mjs
tsconfig.json
README.md
public/ # Cartella delle risorse pubbliche (immagini, font, ecc.)
public/ # Public assets folder (images, fonts, etc.)
src/
├── application-config.ts # Obbligatorio - configurazione principale dell'applicazione
├── application-config.ts # Required - main application configuration
├── roles/
│ └── default-role.ts # Ruolo predefinito per le funzioni logiche
│ └── default-role.ts # Default role for logic functions
├── logic-functions/
── hello-world.ts # Funzione logica di esempio
── hello-world.ts # Example logic function
│ └── post-install.ts # Post-install logic function
└── front-components/
└── hello-world.tsx # Componente front-end di esempio
└── hello-world.tsx # Example front component
```
A livello generale:
@@ -290,6 +294,7 @@ Ogni app ha un singolo file `application-config.ts` che descrive:
* **Identità dell'app**: identificatori, nome visualizzato e descrizione.
* **Come vengono eseguite le sue funzioni**: quale ruolo usano per i permessi.
* **Variabili (opzionali)**: coppie chiavevalore esposte alle funzioni come variabili d'ambiente.
* **(Optional) post-install function**: a logic function that runs after the app is installed.
Usa `defineApplication()` per definire la configurazione della tua applicazione:
@@ -297,6 +302,7 @@ Usa `defineApplication()` per definire la configurazione della tua applicazione:
// src/application-config.ts
import { defineApplication } from 'twenty-sdk';
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role';
import { POST_INSTALL_UNIVERSAL_IDENTIFIER } from 'src/logic-functions/post-install';
export default defineApplication({
universalIdentifier: '4ec0391d-18d5-411c-b2f3-266ddc1c3ef7',
@@ -312,6 +318,7 @@ export default defineApplication({
},
},
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
postInstallLogicFunctionUniversalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
});
```
@@ -320,6 +327,7 @@ Note:
* I campi `universalIdentifier` sono ID deterministici sotto il tuo controllo; generali una volta e mantienili stabili tra le sincronizzazioni.
* `applicationVariables` diventano variabili d'ambiente per le tue funzioni (ad esempio, `DEFAULT_RECIPIENT_NAME` è disponibile come `process.env.DEFAULT_RECIPIENT_NAME`).
* `defaultRoleUniversalIdentifier` deve corrispondere al file del ruolo (vedi sotto).
* `postInstallLogicFunctionUniversalIdentifier` (optional) points to a logic function that runs automatically after the app is installed. See [Post-install functions](#post-install-functions).
#### Ruoli e permessi
@@ -458,6 +466,55 @@ Note:
* L'array `triggers` è facoltativo. Le funzioni senza trigger possono essere utilizzate come funzioni di utilità richiamate da altre funzioni.
* Puoi combinare più tipi di trigger in un'unica funzione.
### Post-install functions
A post-install function is a logic function that runs automatically after your app is installed on a workspace. This is useful for one-time setup tasks such as seeding default data, creating initial records, or configuring workspace settings.
When you scaffold a new app with `create-twenty-app`, a post-install function is generated for you at `src/logic-functions/post-install.ts`:
```typescript
// src/logic-functions/post-install.ts
import { defineLogicFunction } from 'twenty-sdk';
export const POST_INSTALL_UNIVERSAL_IDENTIFIER = '<generated-uuid>';
const handler = async (): Promise<void> => {
console.log('Post install logic function executed successfully!');
};
export default defineLogicFunction({
universalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
name: 'post-install',
description: 'Runs after installation to set up the application.',
timeoutSeconds: 300,
handler,
});
```
The function is wired into your app by referencing its universal identifier in `application-config.ts`:
```typescript
import { POST_INSTALL_UNIVERSAL_IDENTIFIER } from 'src/logic-functions/post-install';
export default defineApplication({
// ...
postInstallLogicFunctionUniversalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
});
```
You can also manually execute the post-install function at any time using the CLI:
```bash filename="Terminal"
yarn twenty function:execute --postInstall
```
Punti chiave:
* Post-install functions are standard logic functions — they use `defineLogicFunction()` like any other function.
* The `postInstallLogicFunctionUniversalIdentifier` field in `defineApplication()` is optional. If omitted, no function runs after installation.
* The default timeout is set to 300 seconds (5 minutes) to allow for longer setup tasks like data seeding.
* Post-install functions do not need triggers — they are invoked by the platform during installation or manually via `function:execute --postInstall`.
### Payload del trigger di route
<Warning>
@@ -45,19 +45,22 @@ yarn twenty app:dev
A partir daqui você pode:
```bash filename="Terminal"
# Adicionar uma nova entidade à sua aplicação (assistido)
# Add a new entity to your application (guided)
yarn twenty entity:add
# Acompanhar os logs das funções da sua aplicação
# Watch your application's function logs
yarn twenty function:logs
# Executar uma função pelo nome
# Execute a function by name
yarn twenty function:execute -n my-function -p '{"name": "test"}'
# Desinstalar a aplicação do espaço de trabalho atual
# Execute the post-install function
yarn twenty function:execute --postInstall
# Uninstall the application from the current workspace
yarn twenty app:uninstall
# Exibir a ajuda dos comandos
# Display commands' help
yarn twenty help
```
@@ -70,7 +73,7 @@ Ao executar `npx create-twenty-app@latest my-twenty-app`, o gerador:
* Copia um aplicativo base mínimo para `my-twenty-app/`
* Adiciona uma dependência local `twenty-sdk` e a configuração do Yarn 4
* Cria arquivos de configuração e scripts conectados à CLI `twenty`
* Gera uma configuração de aplicativo padrão e um papel padrão para as funções
* Generates a default application config, a default function role, and a post-install function
Um aplicativo recém-criado pelo scaffold fica assim:
@@ -86,15 +89,16 @@ my-twenty-app/
eslint.config.mjs
tsconfig.json
README.md
public/ # Pasta de recursos públicos (imagens, fontes, etc.)
public/ # Public assets folder (images, fonts, etc.)
src/
├── application-config.ts # Obrigatório - configuração principal da aplicação
├── application-config.ts # Required - main application configuration
├── roles/
│ └── default-role.ts # Papel padrão para funções de lógica
│ └── default-role.ts # Default role for logic functions
├── logic-functions/
── hello-world.ts # Exemplo de função de lógica
── hello-world.ts # Example logic function
│ └── post-install.ts # Post-install logic function
└── front-components/
└── hello-world.tsx # Exemplo de componente de front-end
└── hello-world.tsx # Example front component
```
Em alto nível:
@@ -290,6 +294,7 @@ Todo aplicativo tem um único arquivo `application-config.ts` que descreve:
* **O que é o aplicativo**: identificadores, nome de exibição e descrição.
* **Como suas funções são executadas**: qual papel usam para permissões.
* **Variáveis (opcional)**: pares chavevalor expostos às suas funções como variáveis de ambiente.
* **(Optional) post-install function**: a logic function that runs after the app is installed.
Use `defineApplication()` to define your application configuration:
@@ -297,6 +302,7 @@ Use `defineApplication()` to define your application configuration:
// src/application-config.ts
import { defineApplication } from 'twenty-sdk';
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role';
import { POST_INSTALL_UNIVERSAL_IDENTIFIER } from 'src/logic-functions/post-install';
export default defineApplication({
universalIdentifier: '4ec0391d-18d5-411c-b2f3-266ddc1c3ef7',
@@ -312,6 +318,7 @@ export default defineApplication({
},
},
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
postInstallLogicFunctionUniversalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
});
```
@@ -320,6 +327,7 @@ Notas:
* `universalIdentifier` são IDs determinísticos que você controla; gere-os uma vez e mantenha-os estáveis entre sincronizações.
* `applicationVariables` tornam-se variáveis de ambiente para suas funções (por exemplo, `DEFAULT_RECIPIENT_NAME` fica disponível como `process.env.DEFAULT_RECIPIENT_NAME`).
* `defaultRoleUniversalIdentifier` deve corresponder ao arquivo do papel (veja abaixo).
* `postInstallLogicFunctionUniversalIdentifier` (optional) points to a logic function that runs automatically after the app is installed. See [Post-install functions](#post-install-functions).
#### Papéis e permissões
@@ -458,6 +466,55 @@ Notas:
* O array `triggers` é opcional. Funções sem gatilhos podem ser usadas como funções utilitárias chamadas por outras funções.
* Você pode misturar vários tipos de gatilho em uma única função.
### Post-install functions
A post-install function is a logic function that runs automatically after your app is installed on a workspace. This is useful for one-time setup tasks such as seeding default data, creating initial records, or configuring workspace settings.
When you scaffold a new app with `create-twenty-app`, a post-install function is generated for you at `src/logic-functions/post-install.ts`:
```typescript
// src/logic-functions/post-install.ts
import { defineLogicFunction } from 'twenty-sdk';
export const POST_INSTALL_UNIVERSAL_IDENTIFIER = '<generated-uuid>';
const handler = async (): Promise<void> => {
console.log('Post install logic function executed successfully!');
};
export default defineLogicFunction({
universalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
name: 'post-install',
description: 'Runs after installation to set up the application.',
timeoutSeconds: 300,
handler,
});
```
The function is wired into your app by referencing its universal identifier in `application-config.ts`:
```typescript
import { POST_INSTALL_UNIVERSAL_IDENTIFIER } from 'src/logic-functions/post-install';
export default defineApplication({
// ...
postInstallLogicFunctionUniversalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
});
```
You can also manually execute the post-install function at any time using the CLI:
```bash filename="Terminal"
yarn twenty function:execute --postInstall
```
Pontos-chave:
* Post-install functions are standard logic functions — they use `defineLogicFunction()` like any other function.
* The `postInstallLogicFunctionUniversalIdentifier` field in `defineApplication()` is optional. If omitted, no function runs after installation.
* The default timeout is set to 300 seconds (5 minutes) to allow for longer setup tasks like data seeding.
* Post-install functions do not need triggers — they are invoked by the platform during installation or manually via `function:execute --postInstall`.
### Payload de gatilho de rota
<Warning>
@@ -54,6 +54,9 @@ yarn twenty function:logs
# Execută o funcție după nume
yarn twenty function:execute -n my-function -p '{"name": "test"}'
# Execută funcția post-instalare
yarn twenty function:execute --postInstall
# Dezinstalează aplicația din spațiul de lucru curent
yarn twenty app:uninstall
@@ -70,7 +73,7 @@ Când rulați `npx create-twenty-app@latest my-twenty-app`, generatorul:
* Copiază o aplicație de bază minimală în `my-twenty-app/`
* Adaugă o dependență locală `twenty-sdk` și configurația Yarn 4
* Creează fișiere de configurare și scripturi conectate la CLI-ul `twenty`
* Generează o configurație implicită a aplicației și un rol implicit pentru funcții
* Generează o configurație implicită a aplicației, un rol implicit pentru funcții și o funcție post-instalare
O aplicație nou generată arată astfel:
@@ -86,15 +89,16 @@ my-twenty-app/
eslint.config.mjs
tsconfig.json
README.md
public/ # Public assets folder (images, fonts, etc.)
public/ # Director pentru resurse publice (imagini, fonturi etc.)
src/
├── application-config.ts # Required - main application configuration
├── application-config.ts # Obligatoriu - configurația principală a aplicației
├── roles/
│ └── default-role.ts # Default role for logic functions
│ └── default-role.ts # Rol implicit pentru funcțiile logice
├── logic-functions/
── hello-world.ts # Example logic function
── hello-world.ts # Exemplu de funcție logică
│ └── post-install.ts # Funcție logică post-instalare
└── front-components/
└── hello-world.tsx # Example front component
└── hello-world.tsx # Exemplu de componentă de interfață
```
Pe scurt:
@@ -290,6 +294,7 @@ Fiecare aplicație are un singur fișier `application-config.ts` care descrie:
* **Cine este aplicația**: identificatori, nume de afișare și descriere.
* **Cum rulează funcțiile**: ce rol folosesc pentru permisiuni.
* **(Opțional) variabile**: perechi cheievaloare expuse funcțiilor ca variabile de mediu.
* **(Opțional) funcție post-instalare**: o funcție logică care rulează după instalarea aplicației.
Folosiți `defineApplication()` pentru a defini configurația aplicației:
@@ -297,6 +302,7 @@ Folosiți `defineApplication()` pentru a defini configurația aplicației:
// src/application-config.ts
import { defineApplication } from 'twenty-sdk';
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role';
import { POST_INSTALL_UNIVERSAL_IDENTIFIER } from 'src/logic-functions/post-install';
export default defineApplication({
universalIdentifier: '4ec0391d-18d5-411c-b2f3-266ddc1c3ef7',
@@ -312,6 +318,7 @@ export default defineApplication({
},
},
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
postInstallLogicFunctionUniversalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
});
```
@@ -320,6 +327,7 @@ Notițe:
* Câmpurile `universalIdentifier` sunt ID-uri deterministe pe care le dețineți; generați-le o singură dată și păstrați-le stabile între sincronizări.
* `applicationVariables` devin variabile de mediu pentru funcțiile dvs. (de exemplu, `DEFAULT_RECIPIENT_NAME` este disponibil ca `process.env.DEFAULT_RECIPIENT_NAME`).
* `defaultRoleUniversalIdentifier` trebuie să corespundă fișierului de rol (vedeți mai jos).
* `postInstallLogicFunctionUniversalIdentifier` (opțional) indică o funcție logică care rulează automat după instalarea aplicației. Vezi [Funcții post-instalare](#post-install-functions).
#### Roluri și permisiuni
@@ -458,6 +466,55 @@ Notițe:
* Matricea `triggers` este opțională. Funcțiile fără declanșatoare pot fi folosite ca funcții utilitare apelate de alte funcții.
* Puteți combina mai multe tipuri de declanșatoare într-o singură funcție.
### Funcții post-instalare
O funcție post-instalare este o funcție logică care rulează automat după instalarea aplicației într-un spațiu de lucru. Aceasta este utilă pentru sarcini de configurare unice, cum ar fi popularea cu date implicite, crearea înregistrărilor inițiale sau configurarea setărilor spațiului de lucru.
When you scaffold a new app with `create-twenty-app`, a post-install function is generated for you at `src/logic-functions/post-install.ts`:
```typescript
// src/logic-functions/post-install.ts
import { defineLogicFunction } from 'twenty-sdk';
export const POST_INSTALL_UNIVERSAL_IDENTIFIER = '<generated-uuid>';
const handler = async (): Promise<void> => {
console.log('Post install logic function executed successfully!');
};
export default defineLogicFunction({
universalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
name: 'post-install',
description: 'Runs after installation to set up the application.',
timeoutSeconds: 300,
handler,
});
```
The function is wired into your app by referencing its universal identifier in `application-config.ts`:
```typescript
import { POST_INSTALL_UNIVERSAL_IDENTIFIER } from 'src/logic-functions/post-install';
export default defineApplication({
// ...
postInstallLogicFunctionUniversalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
});
```
You can also manually execute the post-install function at any time using the CLI:
```bash filename="Terminal"
yarn twenty function:execute --postInstall
```
Puncte cheie:
* Post-install functions are standard logic functions — they use `defineLogicFunction()` like any other function.
* The `postInstallLogicFunctionUniversalIdentifier` field in `defineApplication()` is optional. If omitted, no function runs after installation.
* The default timeout is set to 300 seconds (5 minutes) to allow for longer setup tasks like data seeding.
* Post-install functions do not need triggers — they are invoked by the platform during installation or manually via `function:execute --postInstall`.
### Payload-ul declanșatorului de rută
<Warning>
@@ -45,19 +45,22 @@ yarn twenty app:dev
Отсюда вы можете:
```bash filename="Terminal"
# Добавить новую сущность в ваше приложение (с мастером)
# Add a new entity to your application (guided)
yarn twenty entity:add
# Просматривать логи функций вашего приложения
# Watch your application's function logs
yarn twenty function:logs
# Выполнить функцию по имени
yarn twenty function:execute -n my-function -p '{\"name\": \"test\"}'
# Execute a function by name
yarn twenty function:execute -n my-function -p '{"name": "test"}'
# Удалить приложение из текущего рабочего пространства
# Execute the post-install function
yarn twenty function:execute --postInstall
# Uninstall the application from the current workspace
yarn twenty app:uninstall
# Показать справку по командам
# Display commands' help
yarn twenty help
```
@@ -70,7 +73,7 @@ yarn twenty help
* Копирует минимальное базовое приложение в `my-twenty-app/`
* Добавляет локальную зависимость `twenty-sdk` и конфигурацию Yarn 4
* Создаёт файлы конфигурации и скрипты, подключённые к CLI `twenty`
* Генерирует конфигурацию приложения по умолчанию и роль функции по умолчанию
* Generates a default application config, a default function role, and a post-install function
Свежесгенерированное приложение выглядит так:
@@ -86,15 +89,16 @@ my-twenty-app/
eslint.config.mjs
tsconfig.json
README.md
public/ # Папка общедоступных ресурсов (изображения, шрифты и т. п.)
public/ # Public assets folder (images, fonts, etc.)
src/
├── application-config.ts # Обязательный — основная конфигурация приложения
├── application-config.ts # Required - main application configuration
├── roles/
│ └── default-role.ts # Роль по умолчанию для логических функций
│ └── default-role.ts # Default role for logic functions
├── logic-functions/
── hello-world.ts # Пример логической функции
── hello-world.ts # Example logic function
│ └── post-install.ts # Post-install logic function
└── front-components/
└── hello-world.tsx # Пример фронтенд-компонента
└── hello-world.tsx # Example front component
```
В общих чертах:
@@ -290,6 +294,7 @@ export default defineObject({
* **Что это за приложение**: идентификаторы, отображаемое имя и описание.
* **Как запускаются его функции**: какую роль они используют для прав доступа.
* **(Необязательно) переменные**: пары ключ-значение, предоставляемые вашим функциям как переменные окружения.
* **(Optional) post-install function**: a logic function that runs after the app is installed.
Используйте `defineApplication()` для определения конфигурации вашего приложения:
@@ -297,6 +302,7 @@ export default defineObject({
// src/application-config.ts
import { defineApplication } from 'twenty-sdk';
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role';
import { POST_INSTALL_UNIVERSAL_IDENTIFIER } from 'src/logic-functions/post-install';
export default defineApplication({
universalIdentifier: '4ec0391d-18d5-411c-b2f3-266ddc1c3ef7',
@@ -312,6 +318,7 @@ export default defineApplication({
},
},
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
postInstallLogicFunctionUniversalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
});
```
@@ -320,6 +327,7 @@ export default defineApplication({
* `universalIdentifier` — это детерминированные идентификаторы, которыми вы управляете; сгенерируйте их один раз и сохраняйте стабильными между синхронизациями.
* `applicationVariables` становятся переменными окружения для ваших функций (например, `DEFAULT_RECIPIENT_NAME` доступна как `process.env.DEFAULT_RECIPIENT_NAME`).
* `defaultRoleUniversalIdentifier` должен соответствовать файлу роли (см. ниже).
* `postInstallLogicFunctionUniversalIdentifier` (optional) points to a logic function that runs automatically after the app is installed. See [Post-install functions](#post-install-functions).
#### Роли и разрешения
@@ -458,6 +466,55 @@ export default defineLogicFunction({
* Массив `triggers` необязателен. Функции без триггеров можно использовать как вспомогательные, вызываемые другими функциями.
* Вы можете сочетать несколько типов триггеров в одной функции.
### Post-install functions
A post-install function is a logic function that runs automatically after your app is installed on a workspace. This is useful for one-time setup tasks such as seeding default data, creating initial records, or configuring workspace settings.
When you scaffold a new app with `create-twenty-app`, a post-install function is generated for you at `src/logic-functions/post-install.ts`:
```typescript
// src/logic-functions/post-install.ts
import { defineLogicFunction } from 'twenty-sdk';
export const POST_INSTALL_UNIVERSAL_IDENTIFIER = '<generated-uuid>';
const handler = async (): Promise<void> => {
console.log('Post install logic function executed successfully!');
};
export default defineLogicFunction({
universalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
name: 'post-install',
description: 'Runs after installation to set up the application.',
timeoutSeconds: 300,
handler,
});
```
The function is wired into your app by referencing its universal identifier in `application-config.ts`:
```typescript
import { POST_INSTALL_UNIVERSAL_IDENTIFIER } from 'src/logic-functions/post-install';
export default defineApplication({
// ...
postInstallLogicFunctionUniversalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
});
```
You can also manually execute the post-install function at any time using the CLI:
```bash filename="Terminal"
yarn twenty function:execute --postInstall
```
Основные моменты:
* Post-install functions are standard logic functions — they use `defineLogicFunction()` like any other function.
* The `postInstallLogicFunctionUniversalIdentifier` field in `defineApplication()` is optional. If omitted, no function runs after installation.
* The default timeout is set to 300 seconds (5 minutes) to allow for longer setup tasks like data seeding.
* Post-install functions do not need triggers — they are invoked by the platform during installation or manually via `function:execute --postInstall`.
### Полезная нагрузка триггера маршрута
<Warning>
@@ -54,6 +54,9 @@ yarn twenty function:logs
# Execute a function by name
yarn twenty function:execute -n my-function -p '{"name": "test"}'
# Execute the post-install function
yarn twenty function:execute --postInstall
# Uninstall the application from the current workspace
yarn twenty app:uninstall
@@ -70,7 +73,7 @@ Ayrıca bkz.: [create-twenty-app](https://www.npmjs.com/package/create-twenty-ap
* Minimal bir temel uygulamayı `my-twenty-app/` içine kopyalar
* Yerel bir `twenty-sdk` bağımlılığı ve Yarn 4 yapılandırması ekler
* `twenty` CLI ile bağlantılı yapılandırma dosyaları ve betikler oluşturur
* Varsayılan bir uygulama yapılandırması ve varsayılan bir fonksiyon rolü üretir
* Generates a default application config, a default function role, and a post-install function
Yeni şablondan oluşturulan bir uygulama şöyle görünür:
@@ -86,15 +89,16 @@ my-twenty-app/
eslint.config.mjs
tsconfig.json
README.md
public/ # Genel varlıklar klasörü (görseller, yazı tipleri vb.)
public/ # Public assets folder (images, fonts, etc.)
src/
├── application-config.ts # Gerekli - ana uygulama yapılandırması
├── application-config.ts # Required - main application configuration
├── roles/
│ └── default-role.ts # Mantık işlevleri için varsayılan rol
│ └── default-role.ts # Default role for logic functions
├── logic-functions/
── hello-world.ts # Örnek mantık işlevi
── hello-world.ts # Example logic function
│ └── post-install.ts # Post-install logic function
└── front-components/
└── hello-world.tsx # Örnek ön uç bileşeni
└── hello-world.tsx # Example front component
```
Genel hatlarıyla:
@@ -290,6 +294,7 @@ Her uygulamanın aşağıdakileri açıklayan tek bir `application-config.ts` do
* **Uygulamanın kim olduğu**: tanımlayıcılar, görünen ad ve açıklama.
* **Fonksiyonlarının nasıl çalıştığı**: izinler için hangi rolü kullandıkları.
* **(İsteğe bağlı) değişkenler**: fonksiyonlarınıza ortam değişkenleri olarak sunulan anahtardeğer çiftleri.
* **(Optional) post-install function**: a logic function that runs after the app is installed.
Uygulama yapılandırmanızı tanımlamak için `defineApplication()` kullanın:
@@ -297,6 +302,7 @@ Uygulama yapılandırmanızı tanımlamak için `defineApplication()` kullanın:
// src/application-config.ts
import { defineApplication } from 'twenty-sdk';
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role';
import { POST_INSTALL_UNIVERSAL_IDENTIFIER } from 'src/logic-functions/post-install';
export default defineApplication({
universalIdentifier: '4ec0391d-18d5-411c-b2f3-266ddc1c3ef7',
@@ -312,6 +318,7 @@ export default defineApplication({
},
},
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
postInstallLogicFunctionUniversalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
});
```
@@ -320,6 +327,7 @@ Notlar:
* `universalIdentifier` alanları size ait belirleyici kimliklerdir; bunları bir kez oluşturun ve eşitlemeler boyunca kararlı tutun.
* `applicationVariables`, fonksiyonlarınız için ortam değişkenlerine dönüşür (örneğin, `DEFAULT_RECIPIENT_NAME` değeri `process.env.DEFAULT_RECIPIENT_NAME` olarak kullanılabilir).
* `defaultRoleUniversalIdentifier`, rol dosyasıyla eşleşmelidir (aşağıya bakın).
* `postInstallLogicFunctionUniversalIdentifier` (optional) points to a logic function that runs automatically after the app is installed. See [Post-install functions](#post-install-functions).
#### Roller ve izinler
@@ -458,6 +466,55 @@ Notlar:
* `triggers` dizisi isteğe bağlıdır. Tetikleyicisi olmayan fonksiyonlar, diğer fonksiyonlar tarafından çağrılan yardımcı fonksiyonlar olarak kullanılabilir.
* Tek bir fonksiyonda birden çok tetikleyici türünü birleştirebilirsiniz.
### Post-install functions
A post-install function is a logic function that runs automatically after your app is installed on a workspace. This is useful for one-time setup tasks such as seeding default data, creating initial records, or configuring workspace settings.
When you scaffold a new app with `create-twenty-app`, a post-install function is generated for you at `src/logic-functions/post-install.ts`:
```typescript
// src/logic-functions/post-install.ts
import { defineLogicFunction } from 'twenty-sdk';
export const POST_INSTALL_UNIVERSAL_IDENTIFIER = '<generated-uuid>';
const handler = async (): Promise<void> => {
console.log('Post install logic function executed successfully!');
};
export default defineLogicFunction({
universalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
name: 'post-install',
description: 'Runs after installation to set up the application.',
timeoutSeconds: 300,
handler,
});
```
The function is wired into your app by referencing its universal identifier in `application-config.ts`:
```typescript
import { POST_INSTALL_UNIVERSAL_IDENTIFIER } from 'src/logic-functions/post-install';
export default defineApplication({
// ...
postInstallLogicFunctionUniversalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
});
```
You can also manually execute the post-install function at any time using the CLI:
```bash filename="Terminal"
yarn twenty function:execute --postInstall
```
Önemli noktalar:
* Post-install functions are standard logic functions — they use `defineLogicFunction()` like any other function.
* The `postInstallLogicFunctionUniversalIdentifier` field in `defineApplication()` is optional. If omitted, no function runs after installation.
* The default timeout is set to 300 seconds (5 minutes) to allow for longer setup tasks like data seeding.
* Post-install functions do not need triggers — they are invoked by the platform during installation or manually via `function:execute --postInstall`.
### Rota tetikleyicisi yükü
<Warning>
@@ -45,19 +45,22 @@ yarn twenty app:dev
从这里您可以:
```bash filename="Terminal"
# 向你的应用添加一个新实体(引导式)
# Add a new entity to your application (guided)
yarn twenty entity:add
# 监听你的应用函数日志
# Watch your application's function logs
yarn twenty function:logs
# 按名称执行一个函数
# Execute a function by name
yarn twenty function:execute -n my-function -p '{"name": "test"}'
# 从当前工作区卸载该应用
# Execute the post-install function
yarn twenty function:execute --postInstall
# Uninstall the application from the current workspace
yarn twenty app:uninstall
# 显示命令帮助
# Display commands' help
yarn twenty help
```
@@ -70,7 +73,7 @@ yarn twenty help
* 将一个最小的基础应用复制到 `my-twenty-app/` 中
* 添加本地 `twenty-sdk` 依赖和 Yarn 4 配置
* 创建与 `twenty` CLI 关联的配置文件和脚本
* 生成默认的应用配置和默认的函数角色
* Generates a default application config, a default function role, and a post-install function
一个新生成的脚手架应用如下所示:
@@ -86,15 +89,16 @@ my-twenty-app/
eslint.config.mjs
tsconfig.json
README.md
public/ # 公共资源文件夹(图像、字体等)
public/ # Public assets folder (images, fonts, etc.)
src/
├── application-config.ts # 必需 - 主应用程序配置
├── application-config.ts # Required - main application configuration
├── roles/
│ └── default-role.ts # 用于逻辑函数的默认角色
│ └── default-role.ts # Default role for logic functions
├── logic-functions/
── hello-world.ts # 示例逻辑函数
── hello-world.ts # Example logic function
│ └── post-install.ts # Post-install logic function
└── front-components/
└── hello-world.tsx # 示例前端组件
└── hello-world.tsx # Example front component
```
总体来说:
@@ -290,6 +294,7 @@ export default defineObject({
* **应用的身份**:标识符、显示名称和描述。
* **函数如何运行**:它们用于权限的角色。
* **(可选)变量**:以环境变量形式提供给函数的键值对。
* **(Optional) post-install function**: a logic function that runs after the app is installed.
使用 `defineApplication()` 定义你的应用配置:
@@ -297,6 +302,7 @@ export default defineObject({
// src/application-config.ts
import { defineApplication } from 'twenty-sdk';
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role';
import { POST_INSTALL_UNIVERSAL_IDENTIFIER } from 'src/logic-functions/post-install';
export default defineApplication({
universalIdentifier: '4ec0391d-18d5-411c-b2f3-266ddc1c3ef7',
@@ -312,6 +318,7 @@ export default defineApplication({
},
},
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
postInstallLogicFunctionUniversalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
});
```
@@ -320,6 +327,7 @@ export default defineApplication({
* `universalIdentifier` 字段是你拥有的确定性 ID;生成一次并在多次同步中保持稳定。
* `applicationVariables` 会变成函数可用的环境变量(例如,`DEFAULT_RECIPIENT_NAME` 可作为 `process.env.DEFAULT_RECIPIENT_NAME` 使用)。
* `defaultRoleUniversalIdentifier` 必须与角色文件一致(见下文)。
* `postInstallLogicFunctionUniversalIdentifier` (optional) points to a logic function that runs automatically after the app is installed. See [Post-install functions](#post-install-functions).
#### 角色和权限
@@ -458,6 +466,55 @@ export default defineLogicFunction({
* `triggers` 数组是可选的。 没有触发器的函数可作为实用函数,被其他函数调用。
* 你可以在单个函数中混用多种触发器类型。
### Post-install functions
A post-install function is a logic function that runs automatically after your app is installed on a workspace. This is useful for one-time setup tasks such as seeding default data, creating initial records, or configuring workspace settings.
When you scaffold a new app with `create-twenty-app`, a post-install function is generated for you at `src/logic-functions/post-install.ts`:
```typescript
// src/logic-functions/post-install.ts
import { defineLogicFunction } from 'twenty-sdk';
export const POST_INSTALL_UNIVERSAL_IDENTIFIER = '<generated-uuid>';
const handler = async (): Promise<void> => {
console.log('Post install logic function executed successfully!');
};
export default defineLogicFunction({
universalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
name: 'post-install',
description: 'Runs after installation to set up the application.',
timeoutSeconds: 300,
handler,
});
```
The function is wired into your app by referencing its universal identifier in `application-config.ts`:
```typescript
import { POST_INSTALL_UNIVERSAL_IDENTIFIER } from 'src/logic-functions/post-install';
export default defineApplication({
// ...
postInstallLogicFunctionUniversalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
});
```
You can also manually execute the post-install function at any time using the CLI:
```bash filename="Terminal"
yarn twenty function:execute --postInstall
```
关键点:
* Post-install functions are standard logic functions — they use `defineLogicFunction()` like any other function.
* The `postInstallLogicFunctionUniversalIdentifier` field in `defineApplication()` is optional. If omitted, no function runs after installation.
* The default timeout is set to 300 seconds (5 minutes) to allow for longer setup tasks like data seeding.
* Post-install functions do not need triggers — they are invoked by the platform during installation or manually via `function:execute --postInstall`.
### 路由触发器负载
<Warning>
@@ -1480,6 +1480,7 @@ export enum FeatureFlagKey {
IS_COMMAND_MENU_ITEM_ENABLED = 'IS_COMMAND_MENU_ITEM_ENABLED',
IS_CORE_PICTURE_MIGRATED = 'IS_CORE_PICTURE_MIGRATED',
IS_DASHBOARD_V2_ENABLED = 'IS_DASHBOARD_V2_ENABLED',
IS_DATE_TIME_WHOLE_DAY_FILTER_ENABLED = 'IS_DATE_TIME_WHOLE_DAY_FILTER_ENABLED',
IS_DRAFT_EMAIL_ENABLED = 'IS_DRAFT_EMAIL_ENABLED',
IS_EMAILING_DOMAIN_ENABLED = 'IS_EMAILING_DOMAIN_ENABLED',
IS_FILES_FIELD_MIGRATED = 'IS_FILES_FIELD_MIGRATED',
@@ -19,6 +19,7 @@ import {
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { RecordFilterOperand } from '@/object-record/record-filter/types/RecordFilterOperand';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useFeatureFlagsMap } from '@/workspace/hooks/useFeatureFlagsMap';
import { stringifyRelativeDateFilter } from '@/views/view-filter-value/utils/stringifyRelativeDateFilter';
import { WORKFLOW_TIMEZONE } from '@/workflow/constants/WorkflowTimeZone';
import { isObject, isString } from '@sniptt/guards';
@@ -62,6 +63,10 @@ export const AdvancedFilterCommandMenuValueFormInput = ({
const { applyObjectFilterDropdownFilterValue } =
useApplyObjectFilterDropdownFilterValue();
const featureFlags = useFeatureFlagsMap();
const isWholeDayFilterEnabled =
featureFlags.IS_DATE_TIME_WHOLE_DAY_FILTER_ENABLED ?? false;
const handleChange = (newValue: JsonValue) => {
if (isString(newValue)) {
applyObjectFilterDropdownFilterValue(newValue);
@@ -178,7 +183,12 @@ export const AdvancedFilterCommandMenuValueFormInput = ({
}
const field = {
type: recordFilter.type as FieldMetadataType,
type:
isWholeDayFilterEnabled === true &&
recordFilter.type === FieldMetadataType.DATE_TIME &&
recordFilter.operand === RecordFilterOperand.IS
? FieldMetadataType.DATE
: (recordFilter.type as FieldMetadataType),
label: '',
metadata: fieldDefinition?.metadata as FieldMetadata,
};
@@ -38,7 +38,12 @@ export const ObjectFilterDropdownDateInput = () => {
useApplyObjectFilterDropdownFilterValue();
const handleAbsoluteDateChange = (newPlainDate: string | null) => {
const newFilterValue = newPlainDate ?? '';
if (!isDefined(newPlainDate)) {
applyObjectFilterDropdownFilterValue('', '');
return;
}
const newFilterValue = newPlainDate;
// TODO: remove this and use getDisplayValue instead
const formattedDate = formatDateString({
@@ -91,6 +96,7 @@ export const ObjectFilterDropdownDateInput = () => {
? handleRelativeDateChange(null)
: handleAbsoluteDateChange(null);
};
const resolvedValue = objectFilterDropdownCurrentRecordFilter
? resolveDateFilter(objectFilterDropdownCurrentRecordFilter)
: null;
@@ -100,7 +106,7 @@ export const ObjectFilterDropdownDateInput = () => {
? resolvedValue
: undefined;
const plainDateValue =
const safePlainDateValue: string | undefined =
resolvedValue && typeof resolvedValue === 'string'
? resolvedValue
: undefined;
@@ -110,7 +116,7 @@ export const ObjectFilterDropdownDateInput = () => {
instanceId={`object-filter-dropdown-date-input`}
relativeDate={relativeDate}
isRelative={isRelativeOperand}
plainDateString={plainDateValue ?? null}
plainDateString={safePlainDateValue ?? null}
onChange={handleAbsoluteDateChange}
onRelativeDateChange={handleRelativeDateChange}
onClear={handleClear}
@@ -5,6 +5,7 @@ import { ObjectFilterDropdownRatingInput } from '@/object-record/object-filter-d
import { ObjectFilterDropdownRecordSelect } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownRecordSelect';
import { ObjectFilterDropdownSearchInput } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownSearchInput';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { useFeatureFlagsMap } from '@/workspace/hooks/useFeatureFlagsMap';
import { ViewFilterOperand } from 'twenty-shared/types';
@@ -28,6 +29,10 @@ export const ObjectFilterDropdownFilterInput = ({
filterDropdownId,
recordFilterId,
}: ObjectFilterDropdownFilterInputProps) => {
const featureFlags = useFeatureFlagsMap();
const isWholeDayFilterEnabled =
featureFlags.IS_DATE_TIME_WHOLE_DAY_FILTER_ENABLED ?? false;
const fieldMetadataItemUsedInDropdown = useRecoilComponentValue(
fieldMetadataItemUsedInDropdownComponentSelector,
);
@@ -76,6 +81,18 @@ export const ObjectFilterDropdownFilterInput = ({
</>
);
} else if (filterType === 'DATE_TIME') {
if (
isWholeDayFilterEnabled &&
selectedOperandInDropdown === ViewFilterOperand.IS
) {
return (
<>
<ObjectFilterDropdownInnerSelectOperandDropdown />
<DropdownMenuSeparator />
<ObjectFilterDropdownDateInput />
</>
);
}
return (
<>
<ObjectFilterDropdownInnerSelectOperandDropdown />
@@ -1,3 +1,6 @@
import { isNonEmptyString } from '@sniptt/guards';
import { Temporal } from 'temporal-polyfill';
import { useUpsertObjectFilterDropdownCurrentFilter } from '@/object-record/object-filter-dropdown/hooks/useUpsertObjectFilterDropdownCurrentFilter';
import { fieldMetadataItemUsedInDropdownComponentSelector } from '@/object-record/object-filter-dropdown/states/fieldMetadataItemUsedInDropdownComponentSelector';
import { objectFilterDropdownCurrentRecordFilterComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownCurrentRecordFilterComponentState';
@@ -7,14 +10,12 @@ import { useGetRelativeDateFilterWithUserTimezone } from '@/object-record/record
import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
import { RecordFilterOperand } from '@/object-record/record-filter/types/RecordFilterOperand';
import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import { stringifyRelativeDateFilter } from '@/views/view-filter-value/utils/stringifyRelativeDateFilter';
import { isNonEmptyString } from '@sniptt/guards';
import { Temporal } from 'temporal-polyfill';
import { DEFAULT_RELATIVE_DATE_FILTER_VALUE } from 'twenty-shared/constants';
import { useFeatureFlagsMap } from '@/workspace/hooks/useFeatureFlagsMap';
import { DEFAULT_RELATIVE_DATE_FILTER_VALUE } from 'twenty-shared/constants';
import {
isDefined,
relativeDateFilterStringifiedSchema,
@@ -47,6 +48,10 @@ export const useApplyObjectFilterDropdownOperand = () => {
const { getRelativeDateFilterWithUserTimezone } =
useGetRelativeDateFilterWithUserTimezone();
const featureFlags = useFeatureFlagsMap();
const isWholeDayFilterEnabled =
featureFlags.IS_DATE_TIME_WHOLE_DAY_FILTER_ENABLED ?? false;
const applyObjectFilterDropdownOperand = (
newOperand: RecordFilterOperand,
) => {
@@ -106,7 +111,24 @@ export const useApplyObjectFilterDropdownOperand = () => {
recordFilterToUpsert.value,
);
if (filterValueIsEmpty || isStillRelativeFilterValue.success) {
const previousOperand =
objectFilterDropdownCurrentRecordFilter?.operand;
const isDateTimeOperandFormatChange =
recordFilterToUpsert.type === 'DATE_TIME' &&
!filterValueIsEmpty &&
!isStillRelativeFilterValue.success &&
(previousOperand === RecordFilterOperand.IS ||
newOperand === RecordFilterOperand.IS);
if (isDateTimeOperandFormatChange) {
recordFilterToUpsert.value = convertDateTimeFilterValue(
recordFilterToUpsert.value,
newOperand,
userTimezone,
isWholeDayFilterEnabled,
);
} else if (filterValueIsEmpty || isStillRelativeFilterValue.success) {
const zonedDateToUse = Temporal.Now.zonedDateTimeISO(userTimezone);
if (recordFilterToUpsert.type === 'DATE') {
@@ -116,11 +138,18 @@ export const useApplyObjectFilterDropdownOperand = () => {
recordFilterToUpsert.value = initialNowDateFilterValue;
} else {
const initialNowDateTimeFilterValue = zonedDateToUse
.toInstant()
.toString();
recordFilterToUpsert.value = initialNowDateTimeFilterValue;
if (
newOperand === RecordFilterOperand.IS &&
isWholeDayFilterEnabled
) {
recordFilterToUpsert.value = zonedDateToUse
.toPlainDate()
.toString();
} else {
recordFilterToUpsert.value = zonedDateToUse
.toInstant()
.toString();
}
}
}
}
@@ -137,3 +166,40 @@ export const useApplyObjectFilterDropdownOperand = () => {
applyObjectFilterDropdownOperand,
};
};
const convertDateTimeFilterValue = (
currentValue: string,
targetOperand: RecordFilterOperand,
userTimezone: string,
isWholeDayFilterEnabled = false,
): string => {
const zonedDateToUse = Temporal.Now.zonedDateTimeISO(userTimezone);
if (targetOperand === RecordFilterOperand.IS) {
try {
const existingZoned = currentValue.includes('T')
? Temporal.Instant.from(currentValue).toZonedDateTimeISO(userTimezone)
: Temporal.PlainDate.from(currentValue).toZonedDateTime(userTimezone);
if (isWholeDayFilterEnabled) {
return existingZoned.toPlainDate().toString();
} else {
return existingZoned.toInstant().toString();
}
} catch {
return zonedDateToUse.toPlainDate().toString();
}
} else {
try {
const existingPlainDate = Temporal.PlainDate.from(currentValue);
const currentTime = zonedDateToUse.toPlainTime();
const zonedFromPlain = existingPlainDate.toZonedDateTime({
timeZone: userTimezone,
plainTime: currentTime,
});
return zonedFromPlain.toInstant().toString();
} catch {
return zonedDateToUse.toInstant().toString();
}
}
};
@@ -1,9 +1,12 @@
import { Temporal } from 'temporal-polyfill';
import { useGetDateFilterDisplayValue } from '@/object-record/object-filter-dropdown/hooks/useGetDateFilterDisplayValue';
import { useGetDateTimeFilterDisplayValue } from '@/object-record/object-filter-dropdown/hooks/useGetDateTimeFilterDisplayValue';
import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
import { RecordFilterOperand } from '@/object-record/record-filter/types/RecordFilterOperand';
import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
import { Temporal } from 'temporal-polyfill';
import { useFeatureFlagsMap } from '@/workspace/hooks/useFeatureFlagsMap';
import { type FilterableAndTSVectorFieldType } from 'twenty-shared/types';
const activeDatePickerOperands = [
@@ -16,6 +19,9 @@ export const useGetInitialFilterValue = () => {
const { userTimezone } = useUserTimezone();
const { getDateFilterDisplayValue } = useGetDateFilterDisplayValue();
const { getDateTimeFilterDisplayValue } = useGetDateTimeFilterDisplayValue();
const featureFlags = useFeatureFlagsMap();
const isWholeDayFilterEnabled =
featureFlags.IS_DATE_TIME_WHOLE_DAY_FILTER_ENABLED ?? false;
const getInitialFilterValue = (
newType: FilterableAndTSVectorFieldType,
@@ -44,6 +50,17 @@ export const useGetInitialFilterValue = () => {
alreadyExistingZonedDateTime ??
Temporal.Now.zonedDateTimeISO(userTimezone);
if (
isWholeDayFilterEnabled === true &&
newOperand === RecordFilterOperand.IS
) {
const value = referenceDate.toPlainDate().toString();
const { displayValue } = getDateFilterDisplayValue(referenceDate);
return { value, displayValue };
}
const value = referenceDate.toInstant().toString();
const { displayValue } = getDateTimeFilterDisplayValue(referenceDate);
@@ -1,3 +1,6 @@
import { isNonEmptyString } from '@sniptt/guards';
import { Temporal } from 'temporal-polyfill';
import { useGetFieldMetadataItemByIdOrThrow } from '@/object-metadata/hooks/useGetFieldMetadataItemById';
import { useGetDateFilterDisplayValue } from '@/object-record/object-filter-dropdown/hooks/useGetDateFilterDisplayValue';
import { useGetDateTimeFilterDisplayValue } from '@/object-record/object-filter-dropdown/hooks/useGetDateTimeFilterDisplayValue';
@@ -7,8 +10,7 @@ import { RecordFilterOperand } from '@/object-record/record-filter/types/RecordF
import { isRecordFilterConsideredEmpty } from '@/object-record/record-filter/utils/isRecordFilterConsideredEmpty';
import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
import { getTimezoneAbbreviationForZonedDateTime } from '@/ui/input/components/internal/date/utils/getTimeZoneAbbreviationForZonedDateTime';
import { isNonEmptyString } from '@sniptt/guards';
import { Temporal } from 'temporal-polyfill';
import { type Nullable } from 'twenty-shared/types';
import {
isDefined,
@@ -87,7 +89,23 @@ export const useGetRecordFilterDisplayValue = () => {
}
} else if (recordFilter.type === 'DATE_TIME') {
switch (recordFilter.operand) {
case RecordFilterOperand.IS:
case RecordFilterOperand.IS: {
if (!isNonEmptyString(recordFilter.value)) {
return '';
}
const zonedDateTime = recordFilter.value.includes('T')
? Temporal.Instant.from(recordFilter.value).toZonedDateTimeISO(
userTimezone,
)
: Temporal.PlainDate.from(recordFilter.value).toZonedDateTime(
userTimezone,
);
const { displayValue } = getDateFilterDisplayValue(zonedDateTime);
return `${displayValue}`;
}
case RecordFilterOperand.IS_AFTER:
case RecordFilterOperand.IS_BEFORE: {
if (!isNonEmptyString(recordFilter.value)) {
@@ -1024,9 +1024,9 @@ describe('should work as expected for the different field types', () => {
const dateFilterIs: RecordFilter = {
id: 'company-date-filter-is',
value: '2024-09-17T20:46:58.922Z',
value: '2024-09-17',
fieldMetadataId: companyMockDateFieldMetadataId?.id,
displayValue: '2024-09-17T20:46:58.922Z',
displayValue: '2024-09-17',
operand: ViewFilterOperand.IS,
label: 'Created At',
type: FieldMetadataType.DATE_TIME,
@@ -1081,12 +1081,12 @@ describe('should work as expected for the different field types', () => {
and: [
{
createdAt: {
lt: '2024-09-17T20:47:00Z',
gte: '2024-09-16T22:00:00Z',
},
},
{
createdAt: {
gte: '2024-09-17T20:46:00Z',
lt: '2024-09-17T22:00:00Z',
},
},
],
+6 -2
View File
@@ -148,8 +148,9 @@ Application development commands.
- `twenty function:execute [appPath]` — Execute a logic function with a JSON payload.
- Options:
- `-n, --functionName <name>`: Name of the function to execute (required if `-u` not provided).
- `-u, --functionUniversalIdentifier <id>`: Universal ID of the function to execute (required if `-n` not provided).
- `--postInstall`: Execute the post-install logic function defined in the application config (required if `-n` and `-u` not provided).
- `-n, --functionName <name>`: Name of the function to execute (required if `--postInstall` and `-u` not provided).
- `-u, --functionUniversalIdentifier <id>`: Universal ID of the function to execute (required if `--postInstall` and `-n` not provided).
- `-p, --payload <payload>`: JSON payload to send to the function (default: `{}`).
Examples:
@@ -187,6 +188,9 @@ twenty function:execute -n my-function -p '{"name": "test"}'
# Execute a function by universal identifier
twenty function:execute -u e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf -p '{"key": "value"}'
# Execute the post-install function
twenty function:execute --postInstall
```
## Configuration
@@ -412,6 +412,7 @@ export const EXPECTED_MANIFEST: Manifest = {
],
views: [],
navigationMenuItems: [],
pageLayouts: [],
logicFunctions: [
{
builtHandlerChecksum: '[checksum]',
@@ -142,6 +142,7 @@ export const EXPECTED_MANIFEST: Manifest = {
],
views: [],
navigationMenuItems: [],
pageLayouts: [],
roles: [
{
universalIdentifier: 'e1e2e3e4-e5e6-4000-8000-000000000040',
@@ -129,6 +129,7 @@ export const registerCommands = (program: Command): void => {
program
.command('function:execute [appPath]')
.option('--postInstall', 'Execute post-install logic function if defined')
.option(
'-p, --payload <payload>',
'JSON payload to send to the function',
@@ -147,15 +148,20 @@ export const registerCommands = (program: Command): void => {
async (
appPath?: string,
options?: {
postInstall?: boolean;
payload?: string;
functionUniversalIdentifier?: string;
functionName?: string;
},
) => {
if (!options?.functionUniversalIdentifier && !options?.functionName) {
if (
!options?.postInstall &&
!options?.functionUniversalIdentifier &&
!options?.functionName
) {
console.error(
chalk.red(
'Error: Either --functionName (-n) or --functionUniversalIdentifier (-u) is required.',
'Error: Either --postInstall or --functionName (-n) or --functionUniversalIdentifier (-u) is required.',
),
);
process.exit(1);
@@ -3,7 +3,7 @@ import { type ApiResponse } from '@/cli/utilities/api/api-response-type';
import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-execution-directory';
import chalk from 'chalk';
import inquirer from 'inquirer';
import { buildManifest } from '@/cli/utilities/build/manifest/manifest-build';
import { readManifestFromFile } from '@/cli/utilities/build/manifest/manifest-reader';
export class AppUninstallCommand {
private apiService = new ApiService();
@@ -25,7 +25,7 @@ export class AppUninstallCommand {
process.exit(1);
}
const { manifest } = await buildManifest(appPath);
const manifest = await readManifestFromFile(appPath);
if (!manifest) {
return { success: false, error: 'Build failed' };
@@ -3,6 +3,7 @@ import { getFrontComponentBaseFile } from '@/cli/utilities/entity/entity-front-c
import { getLogicFunctionBaseFile } from '@/cli/utilities/entity/entity-logic-function-template';
import { convertToLabel } from '@/cli/utilities/entity/entity-label';
import { getObjectBaseFile } from '@/cli/utilities/entity/entity-object-template';
import { getPageLayoutBaseFile } from '@/cli/utilities/entity/entity-page-layout-template';
import { getRoleBaseFile } from '@/cli/utilities/entity/entity-role-template';
import chalk from 'chalk';
import * as fs from 'fs-extra';
@@ -113,6 +114,16 @@ export class EntityAddCommand {
return { name, file };
}
case SyncableEntity.PageLayout: {
const name = await this.getEntityName(entity);
const file = getPageLayoutBaseFile({
name,
});
return { name, file };
}
default:
assertUnreachable(entity);
}
@@ -3,18 +3,20 @@ import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-exec
import chalk from 'chalk';
import { type Manifest } from 'twenty-shared/application';
import { isDefined } from 'twenty-shared/utils';
import { buildManifest } from '@/cli/utilities/build/manifest/manifest-build';
import { readManifestFromFile } from '@/cli/utilities/build/manifest/manifest-reader';
export class LogicFunctionExecuteCommand {
private apiService = new ApiService();
async execute({
appPath = CURRENT_EXECUTION_DIRECTORY,
postInstall = false,
functionUniversalIdentifier,
functionName,
payload = '{}',
}: {
appPath?: string;
postInstall?: boolean;
functionUniversalIdentifier?: string;
functionName?: string;
payload?: string;
@@ -30,7 +32,7 @@ export class LogicFunctionExecuteCommand {
process.exit(1);
}
const { manifest } = await buildManifest(appPath);
const manifest = await readManifestFromFile(appPath);
if (!manifest) {
console.error(chalk.red('Failed to build manifest.'));
@@ -54,6 +56,12 @@ export class LogicFunctionExecuteCommand {
);
const targetFunction = appFunctions.find((fn) => {
if (postInstall) {
return (
fn.universalIdentifier ===
manifest.application.postInstallLogicFunctionUniversalIdentifier
);
}
if (functionUniversalIdentifier) {
return fn.universalIdentifier === functionUniversalIdentifier;
}
@@ -64,7 +72,9 @@ export class LogicFunctionExecuteCommand {
});
if (!targetFunction) {
const identifier = functionUniversalIdentifier || functionName;
const identifier = postInstall
? 'post install'
: functionUniversalIdentifier || functionName;
console.error(
chalk.red(`Function "${identifier}" not found in application.`),
);
@@ -1,7 +1,7 @@
import { ApiService } from '@/cli/utilities/api/api-service';
import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-execution-directory';
import chalk from 'chalk';
import { buildManifest } from '@/cli/utilities/build/manifest/manifest-build';
import { readManifestFromFile } from '@/cli/utilities/build/manifest/manifest-reader';
export class LogicFunctionLogsCommand {
private apiService = new ApiService();
@@ -16,7 +16,7 @@ export class LogicFunctionLogsCommand {
functionName?: string;
}): Promise<void> {
try {
const { manifest } = await buildManifest(appPath);
const manifest = await readManifestFromFile(appPath);
if (!manifest) {
process.exit(1);
@@ -35,6 +35,7 @@ const validManifest: Manifest = {
publicAssets: [],
views: [],
navigationMenuItems: [],
pageLayouts: [],
};
describe('manifestValidate', () => {
@@ -11,6 +11,7 @@ import {
type LogicFunctionConfig,
} from '@/sdk';
import { type ObjectConfig } from '@/sdk/objects/object-config';
import { type PageLayoutConfig } from '@/sdk/page-layouts/page-layout-config';
import { type ViewConfig } from '@/sdk/views/view-config';
import { glob } from 'fast-glob';
import { readFile } from 'fs-extra';
@@ -25,6 +26,7 @@ import {
type Manifest,
type NavigationMenuItemManifest,
type ObjectManifest,
type PageLayoutManifest,
type RoleManifest,
type ViewManifest,
} from 'twenty-shared/application';
@@ -67,6 +69,7 @@ export const buildManifest = async (
const publicAssets: AssetManifest[] = [];
const views: ViewManifest[] = [];
const navigationMenuItems: NavigationMenuItemManifest[] = [];
const pageLayouts: PageLayoutManifest[] = [];
const applicationFilePaths: string[] = [];
const objectsFilePaths: string[] = [];
@@ -77,6 +80,7 @@ export const buildManifest = async (
const publicAssetsFilePaths: string[] = [];
const viewsFilePaths: string[] = [];
const navigationMenuItemsFilePaths: string[] = [];
const pageLayoutsFilePaths: string[] = [];
for (const filePath of filePaths) {
const fileContent = await readFile(filePath, 'utf-8');
@@ -240,6 +244,21 @@ export const buildManifest = async (
navigationMenuItemsFilePaths.push(relativePath);
break;
}
case ManifestEntityKey.PageLayouts: {
const extract = await extractManifestFromFile<PageLayoutConfig>({
appPath,
filePath,
});
const pageLayoutManifest: PageLayoutManifest = {
...extract.config,
};
pageLayouts.push(pageLayoutManifest);
errors.push(...extract.errors);
pageLayoutsFilePaths.push(relativePath);
break;
}
case ManifestEntityKey.PublicAssets: {
// Public assets are handled below
break;
@@ -280,6 +299,7 @@ export const buildManifest = async (
publicAssets,
views,
navigationMenuItems,
pageLayouts,
};
const entityFilePaths: EntityFilePaths = {
@@ -292,6 +312,7 @@ export const buildManifest = async (
publicAssets: publicAssetsFilePaths,
views: viewsFilePaths,
navigationMenuItems: navigationMenuItemsFilePaths,
pageLayouts: pageLayoutsFilePaths,
};
return { manifest, filePaths: entityFilePaths, errors };
@@ -9,6 +9,7 @@ export enum TargetFunction {
DefineFrontComponent = 'defineFrontComponent',
DefineView = 'defineView',
DefineNavigationMenuItem = 'defineNavigationMenuItem',
DefinePageLayout = 'definePageLayout',
}
export enum ManifestEntityKey {
@@ -21,6 +22,7 @@ export enum ManifestEntityKey {
PublicAssets = 'publicAssets',
Views = 'views',
NavigationMenuItems = 'navigationMenuItems',
PageLayouts = 'pageLayouts',
}
export type EntityFilePaths = Record<ManifestEntityKey, string[]>;
@@ -38,6 +40,7 @@ export const TARGET_FUNCTION_TO_ENTITY_KEY_MAPPING: Record<
[TargetFunction.DefineView]: ManifestEntityKey.Views,
[TargetFunction.DefineNavigationMenuItem]:
ManifestEntityKey.NavigationMenuItems,
[TargetFunction.DefinePageLayout]: ManifestEntityKey.PageLayouts,
};
const computeIsTargetFunctionCall = (node: ts.Node): string | undefined => {
@@ -0,0 +1,21 @@
import * as fs from 'fs-extra';
import path from 'path';
import { type Manifest, OUTPUT_DIR } from 'twenty-shared/application';
import { buildManifest } from '@/cli/utilities/build/manifest/manifest-build';
export const readManifestFromFile = async (
appPath: string,
): Promise<Manifest | null> => {
const outputDir = path.join(appPath, OUTPUT_DIR);
await fs.ensureDir(outputDir);
const manifestPath = path.join(outputDir, 'manifest.json');
if (!(await fs.pathExists(manifestPath))) {
const { manifest } = await buildManifest(appPath);
return manifest;
}
return await fs.readJson(manifestPath);
};
@@ -69,6 +69,7 @@ const ENTITY_TYPE_TO_SYNCABLE: Record<string, SyncableEntity | undefined> = {
logicFunctions: SyncableEntity.LogicFunction,
frontComponents: SyncableEntity.FrontComponent,
roles: SyncableEntity.Role,
pageLayouts: SyncableEntity.PageLayout,
};
const MAX_EVENT_COUNT = 200;
@@ -97,6 +97,7 @@ export const ENTITY_LABELS: Record<SyncableEntity, string> = {
[SyncableEntity.LogicFunction]: 'Logic functions',
[SyncableEntity.FrontComponent]: 'Front components',
[SyncableEntity.Role]: 'Roles',
[SyncableEntity.PageLayout]: 'Page layouts',
};
export const ENTITY_ORDER = Object.keys(ENTITY_LABELS) as SyncableEntity[];
@@ -46,7 +46,6 @@ export default defineLogicFunction({
// databaseEventTriggerSettings: {
// eventName: 'objectName.created',
// },
],
});
`;
};
@@ -0,0 +1,19 @@
import { v4 as uuidv4 } from 'uuid';
export const getPageLayoutBaseFile = ({ name }: { name: string }) => {
return `import { definePageLayout } from 'twenty-sdk';
export default definePageLayout({
universalIdentifier: '${uuidv4()}',
name: '${name}',
tabs: [
{
universalIdentifier: '${uuidv4()}',
title: 'Overview',
position: 0,
widgets: [],
},
],
});
`;
};
@@ -2,6 +2,7 @@ import { type ApplicationConfig } from '@/sdk/application/application-config';
import { type FrontComponentConfig } from '@/sdk/front-component-config';
import { type LogicFunctionConfig } from '@/sdk/logic-functions/logic-function-config';
import { type ObjectConfig } from '@/sdk/objects/object-config';
import { type PageLayoutConfig } from '@/sdk/page-layouts/page-layout-config';
import { type ViewConfig } from '@/sdk/views/view-config';
import {
type FieldManifest,
@@ -23,7 +24,8 @@ export type DefinableEntity =
| LogicFunctionConfig
| RoleManifest
| ViewConfig
| NavigationMenuItemManifest;
| NavigationMenuItemManifest
| PageLayoutConfig;
export type DefineEntity<T extends DefinableEntity = DefinableEntity> = (
config: T,
+2
View File
@@ -47,6 +47,8 @@ export type {
export type { RoutePayload } from './logic-functions/triggers/route-payload-type';
export { defineNavigationMenuItem } from './navigation-menu-items/define-navigation-menu-item';
export { defineObject } from './objects/define-object';
export { definePageLayout } from './page-layouts/define-page-layout';
export type { PageLayoutConfig } from './page-layouts/page-layout-config';
export { STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from './objects/standard-object-ids';
export { defineRole } from './roles/define-role';
export { PermissionFlag } from './roles/permission-flag-type';
@@ -0,0 +1,42 @@
import { type DefineEntity } from '@/sdk/common/types/define-entity.type';
import { createValidationResult } from '@/sdk/common/utils/create-validation-result';
import { type PageLayoutConfig } from '@/sdk/page-layouts/page-layout-config';
export const definePageLayout: DefineEntity<PageLayoutConfig> = (config) => {
const errors: string[] = [];
if (!config.universalIdentifier) {
errors.push('PageLayout must have a universalIdentifier');
}
if (!config.name) {
errors.push('PageLayout must have a name');
}
if (config.tabs) {
for (const tab of config.tabs) {
if (!tab.universalIdentifier) {
errors.push('PageLayoutTab must have a universalIdentifier');
}
if (!tab.title) {
errors.push('PageLayoutTab must have a title');
}
if (tab.widgets) {
for (const widget of tab.widgets) {
if (!widget.universalIdentifier) {
errors.push('PageLayoutWidget must have a universalIdentifier');
}
if (!widget.title) {
errors.push('PageLayoutWidget must have a title');
}
if (!widget.type) {
errors.push('PageLayoutWidget must have a type');
}
}
}
}
}
return createValidationResult({ config, errors });
};
@@ -0,0 +1,3 @@
import { type PageLayoutManifest } from 'twenty-shared/application';
export type PageLayoutConfig = PageLayoutManifest;
@@ -0,0 +1,154 @@
import { Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
import { FieldMetadataType, ViewFilterOperand } from 'twenty-shared/types';
import { Repository } from 'typeorm';
import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { ViewFilterEntity } from 'src/engine/metadata-modules/view-filter/entities/view-filter.entity';
import { WorkspaceMetadataVersionService } from 'src/engine/metadata-modules/workspace-metadata-version/services/workspace-metadata-version.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { WorkspaceCacheKeyName } from 'src/engine/workspace-cache/types/workspace-cache-key.type';
@Command({
name: 'upgrade:1-17:migrate-date-time-is-filter-values',
description: 'Migrate DATE_TIME IS operand values from Instant to Plain Date',
})
export class MigrateDateTimeIsFilterValuesCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
protected readonly logger = new Logger(
MigrateDateTimeIsFilterValuesCommand.name,
{},
);
constructor(
@InjectRepository(WorkspaceEntity)
protected readonly workspaceRepository: Repository<WorkspaceEntity>,
protected readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
protected readonly dataSourceService: DataSourceService,
@InjectRepository(ViewFilterEntity)
private readonly viewFilterRepository: Repository<ViewFilterEntity>,
private readonly featureFlagService: FeatureFlagService,
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly workspaceMetadataVersionService: WorkspaceMetadataVersionService,
private readonly workspaceCacheStorageService: WorkspaceCacheStorageService,
) {
super(workspaceRepository, globalWorkspaceOrmManager, dataSourceService);
}
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
const isDryRun = options.dryRun || false;
this.logger.log(`Processing workspace ${workspaceId}`);
const viewFilters = await this.viewFilterRepository.find({
where: {
workspaceId,
},
relations: ['fieldMetadata'],
withDeleted: true,
});
const filtersToUpdate = viewFilters.filter((filter) => {
if (
!filter.fieldMetadata ||
filter.fieldMetadata.type !== FieldMetadataType.DATE_TIME ||
filter.operand !== ViewFilterOperand.IS
) {
return false;
}
const value = filter.value;
if (typeof value !== 'string') {
return false;
}
return value.includes('T');
});
if (filtersToUpdate.length === 0) {
return;
}
this.logger.log(
`Found ${filtersToUpdate.length} filters to migrate in workspace ${workspaceId}`,
);
if (isDryRun) {
this.logger.log(
`Dry run: would update ${filtersToUpdate.length} filters`,
);
return;
}
let updatedCount = 0;
for (const filter of filtersToUpdate) {
try {
const newDate = (filter.value as string).split('T')[0];
if (!/^\d{4}-\d{2}-\d{2}$/.test(newDate)) {
this.logger.warn(
`Skipping invalid date extraction for filter ${filter.id}: ${filter.value} -> ${newDate}`,
);
continue;
}
await this.viewFilterRepository.update(filter.id, {
value: newDate,
});
updatedCount++;
} catch (err) {
this.logger.error(
`Failed to migrate filter ${filter.id}: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
this.logger.log(
`Migrated ${updatedCount} filters in workspace ${workspaceId}`,
);
await this.featureFlagService.enableFeatureFlags(
[FeatureFlagKey.IS_DATE_TIME_WHOLE_DAY_FILTER_ENABLED],
workspaceId,
);
this.logger.log(
`Enabled IS_DATE_TIME_WHOLE_DAY_FILTER_ENABLED for workspace ${workspaceId}`,
);
const cacheKeysToInvalidate: WorkspaceCacheKeyName[] = [
'flatViewFilterMaps',
];
this.logger.log(`Invalidating caches: ${cacheKeysToInvalidate.join(' ')}`);
await this.workspaceCacheService.invalidateAndRecompute(
workspaceId,
cacheKeysToInvalidate,
);
await this.workspaceMetadataVersionService.incrementMetadataVersion(
workspaceId,
);
this.logger.log(`Cache flushed`);
this.logger.log(`Flush cache for workspace ${workspaceId}`);
await this.workspaceCacheStorageService.flush(workspaceId);
}
}
@@ -7,6 +7,7 @@ import { FixMorphRelationFieldNamesCommand } from 'src/database/commands/upgrade
import { IdentifyWebhookMetadataCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-identify-webhook-metadata.command';
import { MakeWebhookUniversalIdentifierAndApplicationIdNotNullableMigrationCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-make-webhook-universal-identifier-and-application-id-not-nullable-migration.command';
import { MigrateAttachmentToMorphRelationsCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-migrate-attachment-to-morph-relations.command';
import { MigrateDateTimeIsFilterValuesCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-migrate-date-time-is-filter-values.command';
import { MigrateNoteTargetToMorphRelationsCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-migrate-note-target-to-morph-relations.command';
import { MigrateSendEmailRecipientsCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-migrate-send-email-recipients.command';
import { MigrateTaskTargetToMorphRelationsCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-migrate-task-target-to-morph-relations.command';
@@ -27,6 +28,7 @@ import { LogicFunctionEntity } from 'src/engine/metadata-modules/logic-function/
import { LogicFunctionModule } from 'src/engine/metadata-modules/logic-function/logic-function.module';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
import { ViewFilterEntity } from 'src/engine/metadata-modules/view-filter/entities/view-filter.entity';
import { WebhookEntity } from 'src/engine/metadata-modules/webhook/entities/webhook.entity';
import { WorkspaceMetadataVersionModule } from 'src/engine/metadata-modules/workspace-metadata-version/workspace-metadata-version.module';
import { GlobalWorkspaceDataSourceModule } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-datasource.module';
@@ -49,6 +51,7 @@ import { TaskTargetWorkspaceEntity } from 'src/modules/task/standard-objects/tas
NoteTargetWorkspaceEntity,
TaskTargetWorkspaceEntity,
FileEntity,
ViewFilterEntity,
LogicFunctionEntity,
]),
DataSourceModule,
@@ -75,6 +78,7 @@ import { TaskTargetWorkspaceEntity } from 'src/modules/task/standard-objects/tas
MakeWebhookUniversalIdentifierAndApplicationIdNotNullableMigrationCommand,
DeleteFileRecordsAndUpdateTableCommand,
MigrateSendEmailRecipientsCommand,
MigrateDateTimeIsFilterValuesCommand,
MigrateWorkflowCodeStepsCommand,
SeedWorkflowV1_16Command,
BackfillApplicationPackageFilesCommand,
@@ -87,6 +91,7 @@ import { TaskTargetWorkspaceEntity } from 'src/modules/task/standard-objects/tas
IdentifyWebhookMetadataCommand,
MakeWebhookUniversalIdentifierAndApplicationIdNotNullableMigrationCommand,
MigrateSendEmailRecipientsCommand,
MigrateDateTimeIsFilterValuesCommand,
DeleteFileRecordsAndUpdateTableCommand,
MigrateWorkflowCodeStepsCommand,
SeedWorkflowV1_16Command,
@@ -497,45 +497,53 @@ export class MigrateFavoritesToNavigationMenuItemsCommand extends ActiveOrSuspen
continue;
}
const {
applicationId,
applicationUniversalIdentifier,
universalIdentifier,
} = this.getApplicationIdAndUniversalIdentifierForViewFavorite({
viewId: favorite.viewId,
flatViewMaps,
twentyStandardApplicationId,
twentyStandardApplicationUniversalIdentifier,
workspaceCustomApplicationId,
workspaceCustomApplicationUniversalIdentifier,
});
try {
const {
applicationId,
applicationUniversalIdentifier,
universalIdentifier,
} = this.getApplicationIdAndUniversalIdentifierForViewFavorite({
viewId: favorite.viewId,
flatViewMaps,
twentyStandardApplicationId,
twentyStandardApplicationUniversalIdentifier,
workspaceCustomApplicationId,
workspaceCustomApplicationUniversalIdentifier,
});
const now = new Date().toISOString();
const now = new Date().toISOString();
flatNavigationMenuItemsToCreate.push({
id: favorite.id,
universalIdentifier,
userWorkspaceId,
targetRecordId: null,
targetObjectMetadataId: null,
targetObjectMetadataUniversalIdentifier: null,
viewId: favorite.viewId,
viewUniversalIdentifier:
flatViewMaps.universalIdentifierById[favorite.viewId] ?? null,
folderId,
folderUniversalIdentifier: folderId,
name: null,
link: null,
icon: null,
position: favorite.position,
workspaceId,
applicationId,
applicationUniversalIdentifier,
createdAt: now,
updatedAt: now,
});
flatNavigationMenuItemsToCreate.push({
id: favorite.id,
universalIdentifier,
userWorkspaceId,
targetRecordId: null,
targetObjectMetadataId: null,
targetObjectMetadataUniversalIdentifier: null,
viewId: favorite.viewId,
viewUniversalIdentifier:
flatViewMaps.universalIdentifierById[favorite.viewId] ?? null,
folderId,
folderUniversalIdentifier: folderId,
name: null,
link: null,
icon: null,
position: favorite.position,
workspaceId,
applicationId,
applicationUniversalIdentifier,
createdAt: now,
updatedAt: now,
});
migratedFavoriteIds.push(favorite.id);
migratedFavoriteIds.push(favorite.id);
} catch (error) {
this.logger.error(
`Failed to migrate favorite ${favorite.id} with view ${favorite.viewId} - ${error}`,
);
continue;
}
continue;
}
@@ -1,6 +1,7 @@
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { isNonEmptyString } from '@sniptt/guards';
import FileType from 'file-type';
import { Command } from 'nest-commander';
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
import { FileFolder } from 'twenty-shared/types';
@@ -20,6 +21,7 @@ import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/service
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util';
@@ -27,6 +29,7 @@ import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object
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 { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
import { getImageBufferFromUrl } from 'src/utils/image';
@Command({
name: 'upgrade:1-18:migrate-workspace-pictures',
@@ -44,6 +47,7 @@ export class MigrateWorkspacePicturesCommand extends ActiveOrSuspendedWorkspaces
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly applicationService: ApplicationService,
private readonly fileUrlService: FileUrlService,
private readonly secureHttpClientService: SecureHttpClientService,
@InjectDataSource()
private readonly coreDataSource: DataSource,
) {
@@ -135,14 +139,60 @@ export class MigrateWorkspacePicturesCommand extends ActiveOrSuspendedWorkspaces
return;
}
const isInWorkspaceLogo = workspace.logo.startsWith(
FileFolder.WorkspaceLogo,
);
const isTwentyIconLogo = workspace.logo.includes('twenty-icons');
if (!isTwentyIconLogo && !isInWorkspaceLogo) {
this.logger.log(
`Workspace logo is not a twenty icon or a workspace logo, skipping`,
);
return;
}
this.logger.log(
`Migrating workspace logo for workspace ${workspaceId}: ${workspace.logo}`,
);
if (isInWorkspaceLogo) {
await this.migrateWorkspaceLogoFromWorkspaceFolder({
workspaceId,
logoPath: workspace.logo,
isDryRun,
workspaceCustomFlatApplication,
fileRepository,
});
}
if (isTwentyIconLogo) {
await this.migrateWorkspaceLogoFromTwentyIcons({
workspaceId,
logoUrl: workspace.logo,
isDryRun,
workspaceCustomFlatApplication,
});
}
}
private async migrateWorkspaceLogoFromWorkspaceFolder({
workspaceId,
logoPath,
isDryRun,
workspaceCustomFlatApplication,
fileRepository,
}: {
workspaceId: string;
logoPath: string;
isDryRun: boolean;
workspaceCustomFlatApplication: FlatApplication;
fileRepository: Repository<FileEntity>;
}): Promise<void> {
try {
const { type: fileExtension } = extractFolderPathFilenameAndTypeOrThrow(
workspace.logo,
);
const { type: fileExtension } =
extractFolderPathFilenameAndTypeOrThrow(logoPath);
const fileId = v4();
const newFilename = `${fileId}${isNonEmptyString(fileExtension) ? `.${fileExtension}` : ''}`;
@@ -152,7 +202,7 @@ export class MigrateWorkspacePicturesCommand extends ActiveOrSuspendedWorkspaces
await this.fileStorageService.copyLegacy({
from: {
folderPath: `workspace-${workspaceId}`,
filename: workspace.logo,
filename: logoPath,
},
to: {
folderPath: `${workspaceId}/${workspaceCustomFlatApplication.universalIdentifier}`,
@@ -181,7 +231,7 @@ export class MigrateWorkspacePicturesCommand extends ActiveOrSuspendedWorkspaces
}
this.logger.log(
`Migrated workspace logo for workspace ${workspaceId} (${workspace.logo} -> ${newResourcePath})`,
`Migrated workspace logo for workspace ${workspaceId} (${logoPath} -> ${newResourcePath})`,
);
} catch (error) {
this.logger.error(
@@ -191,6 +241,67 @@ export class MigrateWorkspacePicturesCommand extends ActiveOrSuspendedWorkspaces
}
}
private async migrateWorkspaceLogoFromTwentyIcons({
workspaceId,
logoUrl,
isDryRun,
workspaceCustomFlatApplication,
}: {
workspaceId: string;
logoUrl: string;
isDryRun: boolean;
workspaceCustomFlatApplication: FlatApplication;
}): Promise<void> {
try {
const httpClient = this.secureHttpClientService.getHttpClient();
const buffer = await getImageBufferFromUrl(logoUrl, httpClient);
const type = await FileType.fromBuffer(buffer);
if (!isDefined(type) || !type.mime.startsWith('image/')) {
this.logger.warn(
`Unable to detect image type for workspace logo ${logoUrl}, skipping`,
);
return;
}
const fileId = v4();
const newFilename = `${fileId}.${type.ext}`;
const newResourcePath = `${newFilename}`;
if (!isDryRun) {
const fileEntity = await this.fileStorageService.writeFile({
workspaceId,
applicationUniversalIdentifier:
workspaceCustomFlatApplication.universalIdentifier,
fileFolder: FileFolder.CorePicture,
resourcePath: newResourcePath,
sourceFile: buffer,
mimeType: type.mime,
fileId,
settings: {
isTemporaryFile: false,
toDelete: false,
},
});
await this.workspaceRepository.update(
{ id: workspaceId },
{ logoFileId: fileEntity.id },
);
}
this.logger.log(
`Migrated workspace logo from twenty-icons for workspace ${workspaceId} (${logoUrl} -> ${FileFolder.CorePicture}/${newResourcePath})`,
);
} catch (error) {
this.logger.error(
`Failed to migrate workspace logo from twenty-icons for workspace ${workspaceId}: ${error.message}`,
);
}
}
private async migrateWorkspaceMemberAvatars({
workspaceId,
isDryRun,
@@ -15,6 +15,7 @@ import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import { FileModule } from 'src/engine/core-modules/file/file.module';
import { SecureHttpClientModule } from 'src/engine/core-modules/secure-http-client/secure-http-client.module';
import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
@@ -51,6 +52,7 @@ import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/sta
FileModule,
UserWorkspaceModule,
WorkspaceMigrationModule,
SecureHttpClientModule,
],
providers: [
MigratePersonAvatarFilesCommand,
@@ -13,6 +13,9 @@ export const APPLICATION_MANIFEST_METADATA_NAMES = [
'viewFilterGroup',
'viewGroup',
'navigationMenuItem',
'pageLayout',
'pageLayoutTab',
'pageLayoutWidget',
] as const satisfies AllMetadataName[];
export type ApplicationManifestMetadataName =
@@ -98,13 +98,13 @@ export class MarketplaceService {
}
} catch (error) {
this.logger.warn(
`Failed to load manifest from ${appDir}: ${error instanceof Error ? error.message : 'Unknown error'}`,
`Failed to load manifest from ${appDir}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
} catch (error) {
this.logger.error(
`Failed to fetch marketplace apps from GitHub: ${error instanceof Error ? error.message : 'Unknown error'}`,
`Failed to fetch marketplace apps from GitHub: ${error instanceof Error ? error.message : String(error)}`,
);
}
@@ -0,0 +1,51 @@
import { PageLayoutType } from 'src/engine/metadata-modules/page-layout/enums/page-layout-type.enum';
import { fromPageLayoutManifestToUniversalFlatPageLayout } from 'src/engine/core-modules/application/utils/from-page-layout-manifest-to-universal-flat-page-layout.util';
describe('fromPageLayoutManifestToUniversalFlatPageLayout', () => {
const now = '2026-01-01T00:00:00.000Z';
const applicationUniversalIdentifier = 'app-uuid-1';
it('should convert a minimal page layout manifest', () => {
const result = fromPageLayoutManifestToUniversalFlatPageLayout({
pageLayoutManifest: {
universalIdentifier: 'pl-uuid-1',
name: 'My Page Layout',
},
applicationUniversalIdentifier,
now,
});
expect(result.universalIdentifier).toBe('pl-uuid-1');
expect(result.applicationUniversalIdentifier).toBe(
applicationUniversalIdentifier,
);
expect(result.name).toBe('My Page Layout');
expect(result.type).toBe(PageLayoutType.RECORD_PAGE);
expect(result.objectMetadataUniversalIdentifier).toBeNull();
expect(
result.defaultTabToFocusOnMobileAndSidePanelUniversalIdentifier,
).toBeNull();
expect(result.tabUniversalIdentifiers).toEqual([]);
});
it('should convert a fully specified page layout manifest', () => {
const result = fromPageLayoutManifestToUniversalFlatPageLayout({
pageLayoutManifest: {
universalIdentifier: 'pl-uuid-2',
name: 'Dashboard Layout',
type: PageLayoutType.DASHBOARD,
objectUniversalIdentifier: 'obj-uuid-1',
defaultTabToFocusOnMobileAndSidePanelUniversalIdentifier: 'tab-uuid-1',
},
applicationUniversalIdentifier,
now,
});
expect(result.name).toBe('Dashboard Layout');
expect(result.type).toBe(PageLayoutType.DASHBOARD);
expect(result.objectMetadataUniversalIdentifier).toBe('obj-uuid-1');
expect(
result.defaultTabToFocusOnMobileAndSidePanelUniversalIdentifier,
).toBe('tab-uuid-1');
});
});
@@ -0,0 +1,55 @@
import { PageLayoutTabLayoutMode } from 'twenty-shared/types';
import { fromPageLayoutTabManifestToUniversalFlatPageLayoutTab } from 'src/engine/core-modules/application/utils/from-page-layout-tab-manifest-to-universal-flat-page-layout-tab.util';
describe('fromPageLayoutTabManifestToUniversalFlatPageLayoutTab', () => {
const now = '2026-01-01T00:00:00.000Z';
const applicationUniversalIdentifier = 'app-uuid-1';
const pageLayoutUniversalIdentifier = 'pl-uuid-1';
it('should convert a minimal page layout tab manifest', () => {
const result = fromPageLayoutTabManifestToUniversalFlatPageLayoutTab({
pageLayoutTabManifest: {
universalIdentifier: 'tab-uuid-1',
title: 'Overview',
position: 0,
},
pageLayoutUniversalIdentifier,
applicationUniversalIdentifier,
now,
});
expect(result.universalIdentifier).toBe('tab-uuid-1');
expect(result.applicationUniversalIdentifier).toBe(
applicationUniversalIdentifier,
);
expect(result.title).toBe('Overview');
expect(result.position).toBe(0);
expect(result.pageLayoutUniversalIdentifier).toBe(
pageLayoutUniversalIdentifier,
);
expect(result.icon).toBeNull();
expect(result.layoutMode).toBe(PageLayoutTabLayoutMode.GRID);
expect(result.widgetUniversalIdentifiers).toEqual([]);
});
it('should convert a fully specified page layout tab manifest', () => {
const result = fromPageLayoutTabManifestToUniversalFlatPageLayoutTab({
pageLayoutTabManifest: {
universalIdentifier: 'tab-uuid-2',
title: 'Details',
position: 1,
icon: 'IconLayout',
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
},
pageLayoutUniversalIdentifier,
applicationUniversalIdentifier,
now,
});
expect(result.title).toBe('Details');
expect(result.position).toBe(1);
expect(result.icon).toBe('IconLayout');
expect(result.layoutMode).toBe(PageLayoutTabLayoutMode.VERTICAL_LIST);
});
});
@@ -0,0 +1,76 @@
import { WidgetType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-type.enum';
import { fromPageLayoutWidgetManifestToUniversalFlatPageLayoutWidget } from 'src/engine/core-modules/application/utils/from-page-layout-widget-manifest-to-universal-flat-page-layout-widget.util';
describe('fromPageLayoutWidgetManifestToUniversalFlatPageLayoutWidget', () => {
const now = '2026-01-01T00:00:00.000Z';
const applicationUniversalIdentifier = 'app-uuid-1';
const pageLayoutTabUniversalIdentifier = 'tab-uuid-1';
it('should convert a minimal page layout widget manifest', () => {
const result = fromPageLayoutWidgetManifestToUniversalFlatPageLayoutWidget({
pageLayoutWidgetManifest: {
universalIdentifier: 'widget-uuid-1',
title: 'My Widget',
type: WidgetType.VIEW,
configuration: { configurationType: 'VIEW' },
},
pageLayoutTabUniversalIdentifier,
applicationUniversalIdentifier,
now,
});
expect(result.universalIdentifier).toBe('widget-uuid-1');
expect(result.applicationUniversalIdentifier).toBe(
applicationUniversalIdentifier,
);
expect(result.pageLayoutTabUniversalIdentifier).toBe(
pageLayoutTabUniversalIdentifier,
);
expect(result.title).toBe('My Widget');
expect(result.type).toBe(WidgetType.VIEW);
expect(result.objectMetadataUniversalIdentifier).toBeNull();
expect(result.conditionalDisplay).toBeNull();
expect(result.gridPosition).toEqual({
row: 0,
column: 0,
rowSpan: 1,
columnSpan: 1,
});
expect(result.position).toBeNull();
expect(result.universalConfiguration).toEqual({
configurationType: 'VIEW',
});
});
it('should convert a fully specified page layout widget manifest', () => {
const result = fromPageLayoutWidgetManifestToUniversalFlatPageLayoutWidget({
pageLayoutWidgetManifest: {
universalIdentifier: 'widget-uuid-2',
title: 'Iframe Widget',
type: 'IFRAME',
objectUniversalIdentifier: 'obj-uuid-1',
configuration: {
configurationType: 'IFRAME',
url: 'https://example.com',
},
},
pageLayoutTabUniversalIdentifier,
applicationUniversalIdentifier,
now,
});
expect(result.title).toBe('Iframe Widget');
expect(result.type).toBe('IFRAME');
expect(result.objectMetadataUniversalIdentifier).toBe('obj-uuid-1');
expect(result.gridPosition).toEqual({
row: 0,
column: 0,
rowSpan: 1,
columnSpan: 1,
});
expect(result.universalConfiguration).toEqual({
configurationType: 'IFRAME',
url: 'https://example.com',
});
});
});
@@ -6,6 +6,9 @@ import { fromFrontComponentManifestToUniversalFlatFrontComponent } from 'src/eng
import { fromLogicFunctionManifestToUniversalFlatLogicFunction } from 'src/engine/core-modules/application/utils/from-logic-function-manifest-to-universal-flat-logic-function.util';
import { fromNavigationMenuItemManifestToUniversalFlatNavigationMenuItem } from 'src/engine/core-modules/application/utils/from-navigation-menu-item-manifest-to-universal-flat-navigation-menu-item.util';
import { fromObjectManifestToUniversalFlatObjectMetadata } from 'src/engine/core-modules/application/utils/from-object-manifest-to-universal-flat-object-metadata.util';
import { fromPageLayoutManifestToUniversalFlatPageLayout } from 'src/engine/core-modules/application/utils/from-page-layout-manifest-to-universal-flat-page-layout.util';
import { fromPageLayoutTabManifestToUniversalFlatPageLayoutTab } from 'src/engine/core-modules/application/utils/from-page-layout-tab-manifest-to-universal-flat-page-layout-tab.util';
import { fromPageLayoutWidgetManifestToUniversalFlatPageLayoutWidget } from 'src/engine/core-modules/application/utils/from-page-layout-widget-manifest-to-universal-flat-page-layout-widget.util';
import { fromRoleManifestToUniversalFlatRole } from 'src/engine/core-modules/application/utils/from-role-manifest-to-universal-flat-role.util';
import { fromViewFieldGroupManifestToUniversalFlatViewFieldGroup } from 'src/engine/core-modules/application/utils/from-view-field-group-manifest-to-universal-flat-view-field-group.util';
import { fromViewFieldManifestToUniversalFlatViewField } from 'src/engine/core-modules/application/utils/from-view-field-manifest-to-universal-flat-view-field.util';
@@ -236,5 +239,55 @@ export const computeApplicationManifestAllUniversalFlatEntityMaps = ({
);
}
for (const pageLayoutManifest of manifest.pageLayouts ?? []) {
addUniversalFlatEntityToUniversalFlatEntityAndRelatedEntityMapsThroughMutationOrThrow(
{
metadataName: 'pageLayout',
universalFlatEntity: fromPageLayoutManifestToUniversalFlatPageLayout({
pageLayoutManifest,
applicationUniversalIdentifier,
now,
}),
universalFlatEntityAndRelatedMapsToMutate: allUniversalFlatEntityMaps,
},
);
for (const pageLayoutTabManifest of pageLayoutManifest.tabs ?? []) {
addUniversalFlatEntityToUniversalFlatEntityAndRelatedEntityMapsThroughMutationOrThrow(
{
metadataName: 'pageLayoutTab',
universalFlatEntity:
fromPageLayoutTabManifestToUniversalFlatPageLayoutTab({
pageLayoutTabManifest,
pageLayoutUniversalIdentifier:
pageLayoutManifest.universalIdentifier,
applicationUniversalIdentifier,
now,
}),
universalFlatEntityAndRelatedMapsToMutate: allUniversalFlatEntityMaps,
},
);
for (const pageLayoutWidgetManifest of pageLayoutTabManifest.widgets ??
[]) {
addUniversalFlatEntityToUniversalFlatEntityAndRelatedEntityMapsThroughMutationOrThrow(
{
metadataName: 'pageLayoutWidget',
universalFlatEntity:
fromPageLayoutWidgetManifestToUniversalFlatPageLayoutWidget({
pageLayoutWidgetManifest,
pageLayoutTabUniversalIdentifier:
pageLayoutTabManifest.universalIdentifier,
applicationUniversalIdentifier,
now,
}),
universalFlatEntityAndRelatedMapsToMutate:
allUniversalFlatEntityMaps,
},
);
}
}
}
return allUniversalFlatEntityMaps;
};
@@ -0,0 +1,31 @@
import { type PageLayoutManifest } from 'twenty-shared/application';
import { PageLayoutType } from 'src/engine/metadata-modules/page-layout/enums/page-layout-type.enum';
import { type UniversalFlatPageLayout } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-page-layout.type';
export const fromPageLayoutManifestToUniversalFlatPageLayout = ({
pageLayoutManifest,
applicationUniversalIdentifier,
now,
}: {
pageLayoutManifest: PageLayoutManifest;
applicationUniversalIdentifier: string;
now: string;
}): UniversalFlatPageLayout => {
return {
universalIdentifier: pageLayoutManifest.universalIdentifier,
applicationUniversalIdentifier,
name: pageLayoutManifest.name,
type:
(pageLayoutManifest.type as PageLayoutType) ?? PageLayoutType.RECORD_PAGE,
objectMetadataUniversalIdentifier:
pageLayoutManifest.objectUniversalIdentifier ?? null,
defaultTabToFocusOnMobileAndSidePanelUniversalIdentifier:
pageLayoutManifest.defaultTabToFocusOnMobileAndSidePanelUniversalIdentifier ??
null,
tabUniversalIdentifiers: [],
createdAt: now,
updatedAt: now,
deletedAt: null,
};
};
@@ -0,0 +1,31 @@
import { type PageLayoutTabManifest } from 'twenty-shared/application';
import { PageLayoutTabLayoutMode } from 'twenty-shared/types';
import { type UniversalFlatPageLayoutTab } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-page-layout-tab.type';
export const fromPageLayoutTabManifestToUniversalFlatPageLayoutTab = ({
pageLayoutTabManifest,
pageLayoutUniversalIdentifier,
applicationUniversalIdentifier,
now,
}: {
pageLayoutTabManifest: PageLayoutTabManifest;
pageLayoutUniversalIdentifier: string;
applicationUniversalIdentifier: string;
now: string;
}): UniversalFlatPageLayoutTab => {
return {
universalIdentifier: pageLayoutTabManifest.universalIdentifier,
applicationUniversalIdentifier,
title: pageLayoutTabManifest.title,
position: pageLayoutTabManifest.position,
pageLayoutUniversalIdentifier,
icon: pageLayoutTabManifest.icon ?? null,
layoutMode:
pageLayoutTabManifest.layoutMode ?? PageLayoutTabLayoutMode.GRID,
widgetUniversalIdentifiers: [],
createdAt: now,
updatedAt: now,
deletedAt: null,
};
};
@@ -0,0 +1,34 @@
import { type PageLayoutWidgetManifest } from 'twenty-shared/application';
import { type WidgetType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-type.enum';
import { type UniversalFlatPageLayoutWidget } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-page-layout-widget.type';
export const fromPageLayoutWidgetManifestToUniversalFlatPageLayoutWidget = ({
pageLayoutWidgetManifest,
pageLayoutTabUniversalIdentifier,
applicationUniversalIdentifier,
now,
}: {
pageLayoutWidgetManifest: PageLayoutWidgetManifest;
pageLayoutTabUniversalIdentifier: string;
applicationUniversalIdentifier: string;
now: string;
}): UniversalFlatPageLayoutWidget => {
return {
universalIdentifier: pageLayoutWidgetManifest.universalIdentifier,
applicationUniversalIdentifier,
pageLayoutTabUniversalIdentifier,
title: pageLayoutWidgetManifest.title,
type: pageLayoutWidgetManifest.type as WidgetType,
objectMetadataUniversalIdentifier:
pageLayoutWidgetManifest.objectUniversalIdentifier ?? null,
conditionalDisplay: pageLayoutWidgetManifest.conditionalDisplay ?? null,
gridPosition: { row: 0, column: 0, rowSpan: 1, columnSpan: 1 },
position: pageLayoutWidgetManifest.position ?? null,
universalConfiguration:
pageLayoutWidgetManifest.configuration as UniversalFlatPageLayoutWidget['universalConfiguration'],
createdAt: now,
updatedAt: now,
deletedAt: null,
};
};
@@ -38,13 +38,14 @@ import { EmailVerificationModule } from 'src/engine/core-modules/email-verificat
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { FileUploadModule } from 'src/engine/core-modules/file/file-upload/file-upload.module';
import { FileModule } from 'src/engine/core-modules/file/file.module';
import { GuardRedirectModule } from 'src/engine/core-modules/guard-redirect/guard-redirect.module';
import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
import { KeyValuePairEntity } from 'src/engine/core-modules/key-value-pair/key-value-pair.entity';
import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module';
import { OnboardingModule } from 'src/engine/core-modules/onboarding/onboarding.module';
import { WorkspaceSSOModule } from 'src/engine/core-modules/sso/sso.module';
import { SecureHttpClientModule } from 'src/engine/core-modules/secure-http-client/secure-http-client.module';
import { WorkspaceSSOModule } from 'src/engine/core-modules/sso/sso.module';
import { WorkspaceSSOIdentityProviderEntity } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity';
import { TwoFactorAuthenticationMethodEntity } from 'src/engine/core-modules/two-factor-authentication/entities/two-factor-authentication-method.entity';
import { TwoFactorAuthenticationModule } from 'src/engine/core-modules/two-factor-authentication/two-factor-authentication.module';
@@ -117,6 +118,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
ApplicationModule,
WorkspaceCacheModule,
SecureHttpClientModule,
FileModule,
],
controllers: [
GoogleAuthController,
@@ -97,6 +97,9 @@ const createSignInUpServiceForTests = () => {
{
createWorkspaceCustomApplication: jest.fn(),
} as any,
{
uploadWorkspaceLogoFromUrl: jest.fn(),
} as any,
{
createQueryRunner: jest.fn(() => queryRunnerMock),
} as any,
@@ -3,6 +3,7 @@ import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { msg } from '@lingui/core/macro';
import { TWENTY_ICONS_BASE_URL } from 'twenty-shared/constants';
import { isDefined } from 'twenty-shared/utils';
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
import { Repository, type DataSource, type QueryRunner } from 'typeorm';
import { v4 } from 'uuid';
@@ -27,6 +28,7 @@ import {
type SignInUpNewUserPayload,
} from 'src/engine/core-modules/auth/types/signInUp.type';
import { SubdomainManagerService } from 'src/engine/core-modules/domain/subdomain-manager/services/subdomain-manager.service';
import { FileCorePictureService } from 'src/engine/core-modules/file/file-core-picture/services/file-core-picture.service';
import { MetricsService } from 'src/engine/core-modules/metrics/metrics.service';
import { MetricsKeys } from 'src/engine/core-modules/metrics/types/metrics-keys.type';
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
@@ -62,6 +64,7 @@ export class SignInUpService {
private readonly metricsService: MetricsService,
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly applicationService: ApplicationService,
private readonly fileCorePictureService: FileCorePictureService,
@InjectDataSource()
private readonly dataSource: DataSource,
) {}
@@ -463,21 +466,7 @@ export class SignInUpService {
const shouldGrantServerAdmin = !(await this.hasServerAdmin());
const logoUrl = `${TWENTY_ICONS_BASE_URL}/${getDomainNameByEmail(email)}`;
const isLogoUrlValid = async () => {
try {
const httpClient = this.secureHttpClientService.getHttpClient();
const response = await httpClient.get(logoUrl, { timeout: 600 });
return response.status === 200;
} catch {
return false;
}
};
const isWorkEmailFound = isWorkEmail(email);
const logo =
isWorkEmailFound && (await isLogoUrlValid()) ? logoUrl : undefined;
const workspaceId = v4();
const workspaceCustomApplicationId = v4();
@@ -496,7 +485,6 @@ export class SignInUpService {
displayName: '',
inviteHash: v4(),
activationStatus: WorkspaceActivationStatus.PENDING_CREATION,
logo,
});
const workspace = await queryRunner.manager.save(
@@ -513,6 +501,26 @@ export class SignInUpService {
queryRunner,
);
if (isWorkEmailFound) {
const logoUrl = `${TWENTY_ICONS_BASE_URL}/${getDomainNameByEmail(email)}`;
const logoFile =
await this.fileCorePictureService.uploadWorkspaceLogoFromUrl({
imageUrl: logoUrl,
workspaceId,
applicationUniversalIdentifier:
customApplication.universalIdentifier,
queryRunner,
});
if (isDefined(logoFile)) {
await queryRunner.manager.update(
WorkspaceEntity,
{ id: workspaceId },
{ logoFileId: logoFile.id },
);
}
}
const isExistingUser = userData.type === 'existingUser';
const user = isExistingUser
? userData.existingUser
@@ -20,6 +20,7 @@ export enum FeatureFlagKey {
IS_SSE_DB_EVENTS_ENABLED = 'IS_SSE_DB_EVENTS_ENABLED',
IS_COMMAND_MENU_ITEM_ENABLED = 'IS_COMMAND_MENU_ITEM_ENABLED',
IS_NAVIGATION_MENU_ITEM_ENABLED = 'IS_NAVIGATION_MENU_ITEM_ENABLED',
IS_DATE_TIME_WHOLE_DAY_FILTER_ENABLED = 'IS_DATE_TIME_WHOLE_DAY_FILTER_ENABLED',
IS_NAVIGATION_MENU_ITEM_EDITING_ENABLED = 'IS_NAVIGATION_MENU_ITEM_EDITING_ENABLED',
IS_DRAFT_EMAIL_ENABLED = 'IS_DRAFT_EMAIL_ENABLED',
}
@@ -16,11 +16,15 @@ import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.ent
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter';
import { streamToBuffer } from 'src/utils/stream-to-buffer';
@UseGuards(WorkspaceAuthGuard)
@UsePipes(ResolverValidationPipe)
@UseFilters(PreventNestToAutoLogGraphqlErrorsFilter)
@UseFilters(
PermissionsGraphqlApiExceptionFilter,
PreventNestToAutoLogGraphqlErrorsFilter,
)
@MetadataResolver()
export class FileCorePictureResolver {
constructor(
@@ -64,7 +64,7 @@ export class FileCorePictureService {
const savedFile = await this.fileStorageService.writeFile({
sourceFile: sanitizedFile,
resourcePath: `${FileFolder.CorePicture}/${finalName}`,
resourcePath: finalName,
mimeType,
fileFolder: FileFolder.CorePicture,
applicationUniversalIdentifier: universalIdentifier,
@@ -182,6 +182,25 @@ export class FileCorePictureService {
});
}
private async fetchImageBufferFromUrl(
imageUrl: string,
): Promise<{ buffer: Buffer; extension: string } | undefined> {
try {
const httpClient = this.secureHttpClientService.getHttpClient();
const buffer = await getImageBufferFromUrl(imageUrl, httpClient);
const type = await FileType.fromBuffer(buffer);
if (!isDefined(type) || !type.mime.startsWith('image/')) {
return undefined;
}
return { buffer, extension: type.ext };
} catch {
return undefined;
}
}
async uploadWorkspaceMemberProfilePictureFromUrl({
imageUrl,
workspaceId,
@@ -193,18 +212,41 @@ export class FileCorePictureService {
applicationUniversalIdentifier?: string;
queryRunner?: QueryRunner;
}): Promise<FileWithSignedUrlDto | undefined> {
const httpClient = this.secureHttpClientService.getHttpClient();
const buffer = await getImageBufferFromUrl(imageUrl, httpClient);
const imageData = await this.fetchImageBufferFromUrl(imageUrl);
const type = await FileType.fromBuffer(buffer);
if (!isDefined(type) || !type.mime.startsWith('image/')) {
return;
if (!isDefined(imageData)) {
return undefined;
}
return this.uploadWorkspaceMemberProfilePicture({
file: buffer,
filename: `avatar.${type.ext}`,
file: imageData.buffer,
filename: `avatar.${imageData.extension}`,
workspaceId,
applicationUniversalIdentifier,
queryRunner,
});
}
async uploadWorkspaceLogoFromUrl({
imageUrl,
workspaceId,
applicationUniversalIdentifier,
queryRunner,
}: {
imageUrl: string;
workspaceId: string;
applicationUniversalIdentifier?: string;
queryRunner?: QueryRunner;
}): Promise<FileEntity | undefined> {
const imageData = await this.fetchImageBufferFromUrl(imageUrl);
if (!isDefined(imageData)) {
return undefined;
}
return this.uploadCorePicture({
file: imageData.buffer,
filename: `logo.${imageData.extension}`,
workspaceId,
applicationUniversalIdentifier,
queryRunner,
@@ -97,8 +97,13 @@ export class GuardRedirectService {
}) {
this.captureException(error, workspace.id);
const errorMessage =
error instanceof AuthException
? error.message
: `Authentication error: ${error instanceof Error ? error.message : String(error)}`;
return this.workspaceDomainsService.computeWorkspaceRedirectErrorUrl(
error instanceof AuthException ? error.message : 'Unknown error',
errorMessage,
{
subdomain: workspace.subdomain,
customDomain: workspace.customDomain,
@@ -226,7 +226,9 @@ export class CodeInterpreterTool implements Tool {
const executionTimeMs = Date.now() - startTime;
const errorMessage =
error instanceof Error ? error.message : 'Unknown error';
error instanceof Error
? error.message
: `Unexpected error: ${String(error)}`;
onCodeExecutionUpdate?.(
this.buildExecutionState(
@@ -0,0 +1 @@
export const DEFAULT_TIMEZONE = 'UTC';
@@ -12,6 +12,7 @@ import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadat
import { ViewSortDirection } from 'src/engine/metadata-modules/view-sort/enums/view-sort-direction';
import { ViewQueryParamsService } from 'src/engine/metadata-modules/view/services/view-query-params.service';
import { ViewService } from 'src/engine/metadata-modules/view/services/view.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
describe('ViewQueryParamsService', () => {
let viewQueryParamsService: ViewQueryParamsService;
@@ -73,6 +74,12 @@ describe('ViewQueryParamsService', () => {
getOrRecomputeManyOrAllFlatEntityMaps: jest.fn(),
},
},
{
provide: GlobalWorkspaceOrmManager,
useValue: {
getRepository: jest.fn(),
},
},
],
}).compile();
@@ -20,7 +20,10 @@ import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadat
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { ViewSortDirection } from 'src/engine/metadata-modules/view-sort/enums/view-sort-direction';
import { DEFAULT_TIMEZONE } from 'src/engine/metadata-modules/view/constants/default-timezone.constant';
import { ViewService } from 'src/engine/metadata-modules/view/services/view.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { type WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
export type ViewQueryParams = {
objectNameSingular: string;
@@ -35,6 +38,7 @@ export class ViewQueryParamsService {
constructor(
private readonly viewService: ViewService,
private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
) {}
async resolveViewToQueryParams(
@@ -61,6 +65,11 @@ export class ViewQueryParamsService {
flatEntityMaps: flatObjectMetadataMaps,
});
const timeZone = await this.getWorkspaceMemberTimezoneIfAvailable(
workspaceId,
currentWorkspaceMemberId,
);
const recordFilters: RecordFilter[] = (view.viewFilters ?? [])
.map((viewFilter) => {
const field = findFlatEntityByIdInFlatEntityMaps({
@@ -122,7 +131,7 @@ export class ViewQueryParamsService {
fields,
recordFilters,
recordFilterGroups,
filterValueDependencies: { currentWorkspaceMemberId, timeZone: 'UTC' }, // TODO: check if we need to put workspace member timezone here
filterValueDependencies: { currentWorkspaceMemberId, timeZone },
});
const orderBy: ObjectRecordOrderBy = (view.viewSorts ?? [])
@@ -151,4 +160,29 @@ export class ViewQueryParamsService {
viewType: view.type,
};
}
private async getWorkspaceMemberTimezoneIfAvailable(
workspaceId: string,
currentWorkspaceMemberId?: string,
): Promise<string> {
if (!isDefined(currentWorkspaceMemberId)) {
return DEFAULT_TIMEZONE;
}
try {
const workspaceMemberRepository =
await this.globalWorkspaceOrmManager.getRepository<WorkspaceMemberWorkspaceEntity>(
workspaceId,
'workspaceMember',
);
const workspaceMember = await workspaceMemberRepository.findOne({
where: { id: currentWorkspaceMemberId },
});
return workspaceMember?.timeZone ?? DEFAULT_TIMEZONE;
} catch {
return DEFAULT_TIMEZONE;
}
}
}
@@ -243,6 +243,7 @@ describe('WorkspaceEntityManager', () => {
IS_APPLICATION_INSTALLATION_FROM_TARBALL_ENABLED: false,
IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED: false,
IS_MARKETPLACE_ENABLED: false,
IS_DATE_TIME_WHOLE_DAY_FILTER_ENABLED: false,
IS_FILES_FIELD_MIGRATED: false,
IS_DRAFT_EMAIL_ENABLED: false,
IS_CORE_PICTURE_MIGRATED: false,
@@ -2,7 +2,7 @@ import { type ColumnType, type QueryRunner } from 'typeorm';
import { type WorkspaceSchemaColumnDefinition } from 'src/engine/twenty-orm/workspace-schema-manager/types/workspace-schema-column-definition.type';
import { buildSqlColumnDefinition } from 'src/engine/twenty-orm/workspace-schema-manager/utils/build-sql-column-definition.util';
import { removeSqlDDLInjection } from 'src/engine/workspace-manager/workspace-migration/utils/remove-sql-injection.util';
import { escapeIdentifier } from 'src/engine/workspace-manager/workspace-migration/utils/remove-sql-injection.util';
export class WorkspaceSchemaColumnManagerService {
async addColumns({
@@ -18,12 +18,10 @@ export class WorkspaceSchemaColumnManagerService {
}): Promise<void> {
if (columnDefinitions.length === 0) return;
const safeSchemaName = removeSqlDDLInjection(schemaName);
const safeTableName = removeSqlDDLInjection(tableName);
const addColumnClauses = columnDefinitions.map(
(column) => `ADD COLUMN ${buildSqlColumnDefinition(column)}`,
);
const sql = `ALTER TABLE "${safeSchemaName}"."${safeTableName}" ${addColumnClauses.join(', ')}`;
const sql = `ALTER TABLE ${escapeIdentifier(schemaName)}.${escapeIdentifier(tableName)} ${addColumnClauses.join(', ')}`;
await queryRunner.query(sql);
}
@@ -43,15 +41,12 @@ export class WorkspaceSchemaColumnManagerService {
}): Promise<void> {
if (columnNames.length === 0) return;
const safeSchemaName = removeSqlDDLInjection(schemaName);
const safeTableName = removeSqlDDLInjection(tableName);
const cascadeClause = cascade ? ' CASCADE' : '';
const dropClauses = columnNames.map((name) => {
const safeName = removeSqlDDLInjection(name);
return `DROP COLUMN IF EXISTS "${safeName}"${cascadeClause}`;
});
const sql = `ALTER TABLE "${safeSchemaName}"."${safeTableName}" ${dropClauses.join(', ')}`;
const dropClauses = columnNames.map(
(name) =>
`DROP COLUMN IF EXISTS ${escapeIdentifier(name)}${cascadeClause}`,
);
const sql = `ALTER TABLE ${escapeIdentifier(schemaName)}.${escapeIdentifier(tableName)} ${dropClauses.join(', ')}`;
await queryRunner.query(sql);
}
@@ -69,11 +64,7 @@ export class WorkspaceSchemaColumnManagerService {
oldColumnName: string;
newColumnName: string;
}): Promise<void> {
const safeSchemaName = removeSqlDDLInjection(schemaName);
const safeTableName = removeSqlDDLInjection(tableName);
const safeOldColumnName = removeSqlDDLInjection(oldColumnName);
const safeNewColumnName = removeSqlDDLInjection(newColumnName);
const sql = `ALTER TABLE "${safeSchemaName}"."${safeTableName}" RENAME COLUMN "${safeOldColumnName}" TO "${safeNewColumnName}"`;
const sql = `ALTER TABLE ${escapeIdentifier(schemaName)}.${escapeIdentifier(tableName)} RENAME COLUMN ${escapeIdentifier(oldColumnName)} TO ${escapeIdentifier(newColumnName)}`;
await queryRunner.query(sql);
}
@@ -92,20 +83,21 @@ export class WorkspaceSchemaColumnManagerService {
defaultValue?: string | number | boolean | null;
columnType?: ColumnType;
}): Promise<void> {
const safeSchemaName = removeSqlDDLInjection(schemaName);
const safeTableName = removeSqlDDLInjection(tableName);
const safeColumnName = removeSqlDDLInjection(columnName);
const tableRef = `${escapeIdentifier(schemaName)}.${escapeIdentifier(tableName)}`;
const columnRef = escapeIdentifier(columnName);
const computeDefaultValueSqlQuery = () => {
if (defaultValue === undefined) {
return `ALTER TABLE "${safeSchemaName}"."${safeTableName}" ALTER COLUMN "${safeColumnName}" DROP DEFAULT`;
return `ALTER TABLE ${tableRef} ALTER COLUMN ${columnRef} DROP DEFAULT`;
}
if (defaultValue === null) {
return `ALTER TABLE "${safeSchemaName}"."${safeTableName}" ALTER COLUMN "${safeColumnName}" SET DEFAULT NULL`;
return `ALTER TABLE ${tableRef} ALTER COLUMN ${columnRef} SET DEFAULT NULL`;
}
return `ALTER TABLE "${safeSchemaName}"."${safeTableName}" ALTER COLUMN "${safeColumnName}" SET DEFAULT ${defaultValue}`;
// defaultValue here is pre-serialized by serializeDefaultValue which
// already applies escaping/sanitization to the value.
return `ALTER TABLE ${tableRef} ALTER COLUMN ${columnRef} SET DEFAULT ${defaultValue}`;
};
const sql = computeDefaultValueSqlQuery();
@@ -7,9 +7,11 @@ import {
import { type WorkspaceSchemaColumnDefinition } from 'src/engine/twenty-orm/workspace-schema-manager/types/workspace-schema-column-definition.type';
import { buildSqlColumnDefinition } from 'src/engine/twenty-orm/workspace-schema-manager/utils/build-sql-column-definition.util';
import { computePostgresEnumName } from 'src/engine/workspace-manager/workspace-migration/utils/compute-postgres-enum-name.util';
import { removeSqlDDLInjection } from 'src/engine/workspace-manager/workspace-migration/utils/remove-sql-injection.util';
import {
escapeIdentifier,
escapeLiteral,
} from 'src/engine/workspace-manager/workspace-migration/utils/remove-sql-injection.util';
// TODO: upstream does not guarantee transactionality, implement IF EXISTS or equivalent for idempotency
export class WorkspaceSchemaEnumManagerService {
async createEnum({
queryRunner,
@@ -30,13 +32,10 @@ export class WorkspaceSchemaEnumManagerService {
}
const sanitizedValues = values
.map((value) => removeSqlDDLInjection(value.toString()))
.map((value) => `'${value}'`)
.map((value) => escapeLiteral(value.toString()))
.join(', ');
const safeSchemaName = removeSqlDDLInjection(schemaName);
const safeEnumName = removeSqlDDLInjection(enumName);
const sql = `CREATE TYPE "${safeSchemaName}"."${safeEnumName}" AS ENUM (${sanitizedValues})`;
const sql = `CREATE TYPE ${escapeIdentifier(schemaName)}.${escapeIdentifier(enumName)} AS ENUM (${sanitizedValues})`;
await queryRunner.query(sql);
}
@@ -50,9 +49,7 @@ export class WorkspaceSchemaEnumManagerService {
schemaName: string;
enumName: string;
}): Promise<void> {
const safeSchemaName = removeSqlDDLInjection(schemaName);
const safeEnumName = removeSqlDDLInjection(enumName);
const sql = `DROP TYPE IF EXISTS "${safeSchemaName}"."${safeEnumName}"`;
const sql = `DROP TYPE IF EXISTS ${escapeIdentifier(schemaName)}.${escapeIdentifier(enumName)}`;
await queryRunner.query(sql);
}
@@ -68,10 +65,7 @@ export class WorkspaceSchemaEnumManagerService {
oldEnumName: string;
newEnumName: string;
}): Promise<void> {
const safeSchemaName = removeSqlDDLInjection(schemaName);
const safeOldEnumName = removeSqlDDLInjection(oldEnumName);
const safeNewEnumName = removeSqlDDLInjection(newEnumName);
const sql = `ALTER TYPE "${safeSchemaName}"."${safeOldEnumName}" RENAME TO "${safeNewEnumName}"`;
const sql = `ALTER TYPE ${escapeIdentifier(schemaName)}.${escapeIdentifier(oldEnumName)} RENAME TO ${escapeIdentifier(newEnumName)}`;
await queryRunner.query(sql);
}
@@ -91,19 +85,12 @@ export class WorkspaceSchemaEnumManagerService {
beforeValue?: string;
afterValue?: string;
}): Promise<void> {
const sanitizedValue = removeSqlDDLInjection(value);
const safeSchemaName = removeSqlDDLInjection(schemaName);
const safeEnumName = removeSqlDDLInjection(enumName);
let sql = `ALTER TYPE "${safeSchemaName}"."${safeEnumName}" ADD VALUE '${sanitizedValue}'`;
let sql = `ALTER TYPE ${escapeIdentifier(schemaName)}.${escapeIdentifier(enumName)} ADD VALUE ${escapeLiteral(value)}`;
if (beforeValue) {
const sanitizedBeforeValue = removeSqlDDLInjection(beforeValue);
sql += ` BEFORE '${sanitizedBeforeValue}'`;
sql += ` BEFORE ${escapeLiteral(beforeValue)}`;
} else if (afterValue) {
const sanitizedAfterValue = removeSqlDDLInjection(afterValue);
sql += ` AFTER '${sanitizedAfterValue}'`;
sql += ` AFTER ${escapeLiteral(afterValue)}`;
}
await queryRunner.query(sql);
@@ -122,16 +109,11 @@ export class WorkspaceSchemaEnumManagerService {
oldValue: string;
newValue: string;
}): Promise<void> {
const sanitizedOldValue = removeSqlDDLInjection(oldValue);
const sanitizedNewValue = removeSqlDDLInjection(newValue);
const safeSchemaName = removeSqlDDLInjection(schemaName);
const safeEnumName = removeSqlDDLInjection(enumName);
const sql = `ALTER TYPE "${safeSchemaName}"."${safeEnumName}" RENAME VALUE '${sanitizedOldValue}' TO '${sanitizedNewValue}'`;
const sql = `ALTER TYPE ${escapeIdentifier(schemaName)}.${escapeIdentifier(enumName)} RENAME VALUE ${escapeLiteral(oldValue)} TO ${escapeLiteral(newValue)}`;
await queryRunner.query(sql);
}
// TODO: optimize this to not create a temp enum and column if not necessary (e.g. using ADD VALUE)
async alterEnumValues({
queryRunner,
schemaName,
@@ -255,11 +237,7 @@ export class WorkspaceSchemaEnumManagerService {
oldColumnName: string;
newColumnName: string;
}): Promise<void> {
const safeSchemaName = removeSqlDDLInjection(schemaName);
const safeTableName = removeSqlDDLInjection(tableName);
const safeOldColumnName = removeSqlDDLInjection(oldColumnName);
const safeNewColumnName = removeSqlDDLInjection(newColumnName);
const sql = `ALTER TABLE "${safeSchemaName}"."${safeTableName}" RENAME COLUMN "${safeOldColumnName}" TO "${safeNewColumnName}"`;
const sql = `ALTER TABLE ${escapeIdentifier(schemaName)}.${escapeIdentifier(tableName)} RENAME COLUMN ${escapeIdentifier(oldColumnName)} TO ${escapeIdentifier(newColumnName)}`;
await queryRunner.query(sql);
}
@@ -277,15 +255,12 @@ export class WorkspaceSchemaEnumManagerService {
columnDefinition: WorkspaceSchemaColumnDefinition;
enumTypeName: string;
}): Promise<void> {
const safeSchemaName = removeSqlDDLInjection(schemaName);
const safeTableName = removeSqlDDLInjection(tableName);
const columnDef = buildSqlColumnDefinition({
...columnDefinition,
type: `"${safeSchemaName}"."${enumTypeName}"`,
type: `${escapeIdentifier(schemaName)}.${escapeIdentifier(enumTypeName)}`,
});
const sql = `ALTER TABLE "${safeSchemaName}"."${safeTableName}" ADD COLUMN ${columnDef}`;
const sql = `ALTER TABLE ${escapeIdentifier(schemaName)}.${escapeIdentifier(tableName)} ADD COLUMN ${columnDef}`;
await queryRunner.query(sql);
}
@@ -301,15 +276,11 @@ export class WorkspaceSchemaEnumManagerService {
tableName: string;
columnName: string;
}): Promise<void> {
const safeSchemaName = removeSqlDDLInjection(schemaName);
const safeTableName = removeSqlDDLInjection(tableName);
const safeColumnName = removeSqlDDLInjection(columnName);
const sql = `ALTER TABLE "${safeSchemaName}"."${safeTableName}" DROP COLUMN "${safeColumnName}"`;
const sql = `ALTER TABLE ${escapeIdentifier(schemaName)}.${escapeIdentifier(tableName)} DROP COLUMN ${escapeIdentifier(columnName)}`;
await queryRunner.query(sql);
}
// TODO: explore USING clause to avoid the need for this function
private async migrateEnumData({
queryRunner,
schemaName,
@@ -334,36 +305,38 @@ export class WorkspaceSchemaEnumManagerService {
columnName: newColumnName,
});
const safeSchemaName = removeSqlDDLInjection(schemaName);
const safeTableName = removeSqlDDLInjection(tableName);
const safeOldColumnName = removeSqlDDLInjection(oldColumnName);
const safeNewColumnName = removeSqlDDLInjection(newColumnName);
const escapedSchema = escapeIdentifier(schemaName);
const escapedTable = escapeIdentifier(tableName);
const escapedOldColumn = escapeIdentifier(oldColumnName);
const escapedNewColumn = escapeIdentifier(newColumnName);
const escapedNewEnumType = `${escapedSchema}.${escapeIdentifier(newEnumTypeName)}`;
const caseStatements = Object.entries(oldToNewEnumOptionMap)
.map(
([oldEnumOption, newEnumOption]) =>
`WHEN '${removeSqlDDLInjection(oldEnumOption)}' THEN '${removeSqlDDLInjection(newEnumOption)}'::"${safeSchemaName}"."${newEnumTypeName}"`,
`WHEN ${escapeLiteral(oldEnumOption)} THEN ${escapeLiteral(newEnumOption)}::${escapedNewEnumType}`,
)
.join(' ');
const mappedValuesCondition = Object.keys(oldToNewEnumOptionMap)
.map((oldValue) => `'${removeSqlDDLInjection(oldValue)}'`)
.map((oldValue) => escapeLiteral(oldValue))
.join(', ');
const sqlQuery = columnDefinition.isArray
? this.updateArrayEnum({
safeSchemaName,
safeTableName,
safeOldColumnName,
safeNewColumnName,
newEnumTypeName,
oldEnumTypeName,
escapedSchema,
escapedTable,
escapedOldColumn,
escapedNewColumn,
escapedNewEnumType,
escapedOldEnumType: `${escapedSchema}.${escapeIdentifier(oldEnumTypeName)}`,
caseStatements,
mappedValuesCondition,
})
: this.updateAtomicEnum({
safeSchemaName,
safeTableName,
safeOldColumnName,
safeNewColumnName,
escapedSchema,
escapedTable,
escapedOldColumn,
escapedNewColumn,
caseStatements,
mappedValuesCondition,
});
@@ -372,61 +345,61 @@ export class WorkspaceSchemaEnumManagerService {
}
private updateArrayEnum({
safeNewColumnName,
safeOldColumnName,
safeSchemaName,
safeTableName,
newEnumTypeName,
oldEnumTypeName,
escapedNewColumn,
escapedOldColumn,
escapedSchema,
escapedTable,
escapedNewEnumType,
escapedOldEnumType,
caseStatements,
mappedValuesCondition,
}: {
safeSchemaName: string;
safeTableName: string;
safeOldColumnName: string;
safeNewColumnName: string;
newEnumTypeName: string;
oldEnumTypeName: string;
escapedSchema: string;
escapedTable: string;
escapedOldColumn: string;
escapedNewColumn: string;
escapedNewEnumType: string;
escapedOldEnumType: string;
caseStatements: string;
mappedValuesCondition: string;
}) {
return `
UPDATE "${safeSchemaName}"."${safeTableName}"
SET "${safeNewColumnName}" = (
UPDATE ${escapedSchema}.${escapedTable}
SET ${escapedNewColumn} = (
SELECT array_agg(
CASE unnest_value::text
${caseStatements}
ELSE unnest_value::text::"${safeSchemaName}"."${newEnumTypeName}"
ELSE unnest_value::text::${escapedNewEnumType}
END
)
FROM unnest("${safeOldColumnName}") AS unnest_value
FROM unnest(${escapedOldColumn}) AS unnest_value
)
WHERE "${safeOldColumnName}" IS NOT NULL
AND "${safeOldColumnName}" && ARRAY[${mappedValuesCondition}]::"${safeSchemaName}"."${oldEnumTypeName}"[]`;
WHERE ${escapedOldColumn} IS NOT NULL
AND ${escapedOldColumn} && ARRAY[${mappedValuesCondition}]::${escapedOldEnumType}[]`;
}
private updateAtomicEnum({
safeNewColumnName,
safeOldColumnName,
safeSchemaName,
safeTableName,
escapedNewColumn,
escapedOldColumn,
escapedSchema,
escapedTable,
caseStatements,
mappedValuesCondition,
}: {
caseStatements: string;
mappedValuesCondition: string;
safeSchemaName: string;
safeTableName: string;
safeOldColumnName: string;
safeNewColumnName: string;
escapedSchema: string;
escapedTable: string;
escapedOldColumn: string;
escapedNewColumn: string;
}) {
return `
UPDATE "${safeSchemaName}"."${safeTableName}"
SET "${safeNewColumnName}" =
CASE "${safeOldColumnName}"::text
UPDATE ${escapedSchema}.${escapedTable}
SET ${escapedNewColumn} =
CASE ${escapedOldColumn}::text
${caseStatements}
END
WHERE "${safeOldColumnName}" IS NOT NULL
AND "${safeOldColumnName}"::text IN (${mappedValuesCondition})`;
WHERE ${escapedOldColumn} IS NOT NULL
AND ${escapedOldColumn}::text IN (${mappedValuesCondition})`;
}
}
@@ -1,7 +1,15 @@
import { type QueryRunner } from 'typeorm';
import { type WorkspaceSchemaForeignKeyDefinition } from 'src/engine/twenty-orm/workspace-schema-manager/types/workspace-schema-foreign-key-definition.type';
import { removeSqlDDLInjection } from 'src/engine/workspace-manager/workspace-migration/utils/remove-sql-injection.util';
import { escapeIdentifier } from 'src/engine/workspace-manager/workspace-migration/utils/remove-sql-injection.util';
const ALLOWED_FK_ACTIONS = new Set([
'CASCADE',
'SET NULL',
'RESTRICT',
'NO ACTION',
'SET DEFAULT',
]);
export class WorkspaceSchemaForeignKeyManagerService {
async createForeignKey({
@@ -20,13 +28,19 @@ export class WorkspaceSchemaForeignKeyManagerService {
[foreignKey.referencedColumnName],
);
let sql = `ALTER TABLE "${schemaName}"."${foreignKey.tableName}" ADD CONSTRAINT "${foreignKeyName}" FOREIGN KEY ("${foreignKey.columnName}") REFERENCES "${schemaName}"."${foreignKey.referencedTableName}" ("${foreignKey.referencedColumnName}")`;
let sql = `ALTER TABLE ${escapeIdentifier(schemaName)}.${escapeIdentifier(foreignKey.tableName)} ADD CONSTRAINT ${escapeIdentifier(foreignKeyName)} FOREIGN KEY (${escapeIdentifier(foreignKey.columnName)}) REFERENCES ${escapeIdentifier(schemaName)}.${escapeIdentifier(foreignKey.referencedTableName)} (${escapeIdentifier(foreignKey.referencedColumnName)})`;
if (foreignKey.onDelete) {
if (!ALLOWED_FK_ACTIONS.has(foreignKey.onDelete)) {
throw new Error(`Unsupported ON DELETE action: ${foreignKey.onDelete}`);
}
sql += ` ON DELETE ${foreignKey.onDelete}`;
}
if (foreignKey.onUpdate) {
if (!ALLOWED_FK_ACTIONS.has(foreignKey.onUpdate)) {
throw new Error(`Unsupported ON UPDATE action: ${foreignKey.onUpdate}`);
}
sql += ` ON UPDATE ${foreignKey.onUpdate}`;
}
@@ -44,10 +58,7 @@ export class WorkspaceSchemaForeignKeyManagerService {
tableName: string;
foreignKeyName: string;
}): Promise<void> {
const safeSchemaName = removeSqlDDLInjection(schemaName);
const safeTableName = removeSqlDDLInjection(tableName);
const safeForeignKeyName = removeSqlDDLInjection(foreignKeyName);
const sql = `ALTER TABLE "${safeSchemaName}"."${safeTableName}" DROP CONSTRAINT IF EXISTS "${safeForeignKeyName}"`;
const sql = `ALTER TABLE ${escapeIdentifier(schemaName)}.${escapeIdentifier(tableName)} DROP CONSTRAINT IF EXISTS ${escapeIdentifier(foreignKeyName)}`;
await queryRunner.query(sql);
}
@@ -63,10 +74,7 @@ export class WorkspaceSchemaForeignKeyManagerService {
tableName: string;
foreignKeyName: string;
}): Promise<void> {
const safeSchemaName = removeSqlDDLInjection(schemaName);
const safeTableName = removeSqlDDLInjection(tableName);
const safeForeignKeyName = removeSqlDDLInjection(foreignKeyName);
const sql = `ALTER TABLE "${safeSchemaName}"."${safeTableName}" ALTER CONSTRAINT "${safeForeignKeyName}" NOT DEFERRABLE`;
const sql = `ALTER TABLE ${escapeIdentifier(schemaName)}.${escapeIdentifier(tableName)} ALTER CONSTRAINT ${escapeIdentifier(foreignKeyName)} NOT DEFERRABLE`;
await queryRunner.query(sql);
}
@@ -82,10 +90,7 @@ export class WorkspaceSchemaForeignKeyManagerService {
tableName: string;
foreignKeyName: string;
}): Promise<void> {
const safeSchemaName = removeSqlDDLInjection(schemaName);
const safeTableName = removeSqlDDLInjection(tableName);
const safeForeignKeyName = removeSqlDDLInjection(foreignKeyName);
const sql = `ALTER TABLE "${safeSchemaName}"."${safeTableName}" ALTER CONSTRAINT "${safeForeignKeyName}" DEFERRABLE`;
const sql = `ALTER TABLE ${escapeIdentifier(schemaName)}.${escapeIdentifier(tableName)} ALTER CONSTRAINT ${escapeIdentifier(foreignKeyName)} DEFERRABLE`;
await queryRunner.query(sql);
}
@@ -101,10 +106,7 @@ export class WorkspaceSchemaForeignKeyManagerService {
tableName: string;
columnName: string;
}): Promise<string | undefined> {
const safeSchemaName = removeSqlDDLInjection(schemaName);
const safeTableName = removeSqlDDLInjection(tableName);
const safeColumnName = removeSqlDDLInjection(columnName);
// Uses parameterized query ($1, $2, $3) — safe against injection
const foreignKeys = await queryRunner.query(
`
SELECT
@@ -121,7 +123,7 @@ export class WorkspaceSchemaForeignKeyManagerService {
AND tc.table_name = $2
AND kcu.column_name = $3
`,
[safeSchemaName, safeTableName, safeColumnName],
[schemaName, tableName, columnName],
);
return foreignKeys[0]?.constraint_name;
@@ -1,7 +1,17 @@
import { type QueryRunner } from 'typeorm';
import { type WorkspaceSchemaIndexDefinition } from 'src/engine/twenty-orm/workspace-schema-manager/types/workspace-schema-index-definition.type';
import { removeSqlDDLInjection } from 'src/engine/workspace-manager/workspace-migration/utils/remove-sql-injection.util';
import { escapeIdentifier } from 'src/engine/workspace-manager/workspace-migration/utils/remove-sql-injection.util';
import { validateAndReturnIndexWhereClause } from 'src/engine/workspace-manager/workspace-migration/utils/validate-index-where-clause.util';
const ALLOWED_INDEX_TYPES = new Set([
'BTREE',
'HASH',
'GIST',
'SPGIST',
'GIN',
'BRIN',
]);
export class WorkspaceSchemaIndexManagerService {
async createIndex({
@@ -15,25 +25,32 @@ export class WorkspaceSchemaIndexManagerService {
tableName: string;
index: WorkspaceSchemaIndexDefinition;
}): Promise<void> {
const safeSchemaName = removeSqlDDLInjection(schemaName);
const safeTableName = removeSqlDDLInjection(tableName);
const safeIndexName = removeSqlDDLInjection(index.name);
const quotedColumns = index.columns.map(
(column) => `"${removeSqlDDLInjection(column)}"`,
const quotedColumns = index.columns.map((column) =>
escapeIdentifier(column),
);
const isUnique = index.isUnique ? 'UNIQUE' : '';
const indexType =
index.type && index.type !== 'BTREE' ? `USING ${index.type}` : '';
const whereClause = index.where ? `WHERE ${index.where}` : ''; // TODO: to sanitize -> might search for a lib to sanitize sql queries
let indexType = '';
if (index.type && index.type !== 'BTREE') {
if (!ALLOWED_INDEX_TYPES.has(index.type)) {
throw new Error(`Unsupported index type: ${index.type}`);
}
indexType = `USING ${index.type}`;
}
const validatedWhereClause = validateAndReturnIndexWhereClause(index.where);
const whereClause = validatedWhereClause
? `WHERE ${validatedWhereClause}`
: '';
const sql = [
'CREATE',
isUnique && 'UNIQUE',
'INDEX IF NOT EXISTS',
`"${safeIndexName}"`,
escapeIdentifier(index.name),
'ON',
`"${safeSchemaName}"."${safeTableName}"`,
`${escapeIdentifier(schemaName)}.${escapeIdentifier(tableName)}`,
indexType,
`(${quotedColumns.join(', ')})`,
whereClause,
@@ -54,9 +71,7 @@ export class WorkspaceSchemaIndexManagerService {
schemaName: string;
indexName: string;
}): Promise<void> {
const safeSchemaName = removeSqlDDLInjection(schemaName);
const safeIndexName = removeSqlDDLInjection(indexName);
const sql = `DROP INDEX IF EXISTS "${safeSchemaName}"."${safeIndexName}"`;
const sql = `DROP INDEX IF EXISTS ${escapeIdentifier(schemaName)}.${escapeIdentifier(indexName)}`;
await queryRunner.query(sql);
}
@@ -2,7 +2,7 @@ import { type QueryRunner } from 'typeorm';
import { type WorkspaceSchemaColumnDefinition } from 'src/engine/twenty-orm/workspace-schema-manager/types/workspace-schema-column-definition.type';
import { buildSqlColumnDefinition } from 'src/engine/twenty-orm/workspace-schema-manager/utils/build-sql-column-definition.util';
import { removeSqlDDLInjection } from 'src/engine/workspace-manager/workspace-migration/utils/remove-sql-injection.util';
import { escapeIdentifier } from 'src/engine/workspace-manager/workspace-migration/utils/remove-sql-injection.util';
export class WorkspaceSchemaTableManagerService {
async createTable({
@@ -21,16 +21,13 @@ export class WorkspaceSchemaTableManagerService {
buildSqlColumnDefinition(columnDefinition),
) || [];
// Add default columns if no columns specified
if (sqlColumnDefinitions.length === 0) {
sqlColumnDefinitions.push(
'"id" uuid PRIMARY KEY DEFAULT gen_random_uuid()',
);
}
const safeSchemaName = removeSqlDDLInjection(schemaName);
const safeTableName = removeSqlDDLInjection(tableName);
const sql = `CREATE TABLE IF NOT EXISTS "${safeSchemaName}"."${safeTableName}" (${sqlColumnDefinitions.join(', ')})`;
const sql = `CREATE TABLE IF NOT EXISTS ${escapeIdentifier(schemaName)}.${escapeIdentifier(tableName)} (${sqlColumnDefinitions.join(', ')})`;
await queryRunner.query(sql);
}
@@ -46,10 +43,8 @@ export class WorkspaceSchemaTableManagerService {
tableName: string;
cascade?: boolean;
}): Promise<void> {
const safeSchemaName = removeSqlDDLInjection(schemaName);
const safeTableName = removeSqlDDLInjection(tableName);
const cascadeClause = cascade ? ' CASCADE' : '';
const sql = `DROP TABLE IF EXISTS "${safeSchemaName}"."${safeTableName}"${cascadeClause}`;
const sql = `DROP TABLE IF EXISTS ${escapeIdentifier(schemaName)}.${escapeIdentifier(tableName)}${cascadeClause}`;
await queryRunner.query(sql);
}
@@ -65,10 +60,7 @@ export class WorkspaceSchemaTableManagerService {
oldTableName: string;
newTableName: string;
}): Promise<void> {
const safeSchemaName = removeSqlDDLInjection(schemaName);
const safeOldTableName = removeSqlDDLInjection(oldTableName);
const safeNewTableName = removeSqlDDLInjection(newTableName);
const sql = `ALTER TABLE "${safeSchemaName}"."${safeOldTableName}" RENAME TO "${safeNewTableName}"`;
const sql = `ALTER TABLE ${escapeIdentifier(schemaName)}.${escapeIdentifier(oldTableName)} RENAME TO ${escapeIdentifier(newTableName)}`;
await queryRunner.query(sql);
}
@@ -3,243 +3,147 @@ import { sanitizeDefaultValue } from 'src/engine/twenty-orm/workspace-schema-man
describe('sanitizeDefaultValue', () => {
describe('allowed functions', () => {
it('should allow uuid_generate_v4() function', () => {
// Prepare
const input = 'public.uuid_generate_v4()';
// Act
const result = sanitizeDefaultValue(input);
// Assert
expect(result).toBe('public.uuid_generate_v4()');
});
it('should allow now() function', () => {
// Prepare
const input = 'now()';
// Act
const result = sanitizeDefaultValue(input);
// Assert
expect(result).toBe('now()');
});
it('should be case insensitive for allowed functions', () => {
// Act & Assert
expect(sanitizeDefaultValue('NOW()')).toBe('NOW()');
});
});
describe('SQL injection prevention', () => {
it('should sanitize potential SQL injection in string values', () => {
// Prepare
describe('SQL injection prevention via escapeLiteral', () => {
it('should escape single quotes to prevent SQL injection', () => {
const maliciousInput = "'; DROP TABLE users; --";
// Act
const result = sanitizeDefaultValue(maliciousInput);
// Assert
expect(result).not.toContain('DROP TABLE');
expect(result).not.toContain(';');
expect(result).not.toContain('--');
expect(result).toBe("'DROPTABLEusers'");
// Single quotes are doubled, making injection impossible
expect(result).toBe("'''; DROP TABLE users; --'");
});
it('should sanitize quotes in string values', () => {
// Prepare
it('should preserve double quotes inside string literals (safe in SQL strings)', () => {
const inputWithQuotes = 'test"value';
// Act
const result = sanitizeDefaultValue(inputWithQuotes);
// Assert
expect(result).not.toContain('"');
expect(result).toBe("'testvalue'");
expect(result).toBe("'test\"value'");
});
it('should sanitize parentheses in non-function values', () => {
// Prepare
it('should preserve parentheses inside string literals (safe in SQL strings)', () => {
const inputWithParens = 'test(value)';
// Act
const result = sanitizeDefaultValue(inputWithParens);
// Assert
expect(result).not.toContain('(');
expect(result).not.toContain(')');
expect(result).toBe("'testvalue'");
expect(result).toBe("'test(value)'");
});
it('should sanitize backslashes', () => {
// Prepare
it('should escape backslashes with E-string syntax', () => {
const inputWithBackslash = 'test\\value';
// Act
const result = sanitizeDefaultValue(inputWithBackslash);
// Assert
expect(result).not.toContain('\\');
expect(result).toBe("'testvalue'");
expect(result).toBe("E'test\\\\value'");
});
it('should sanitize SQL comment patterns', () => {
// Prepare
it('should preserve comment-like patterns inside string literals (safe in SQL strings)', () => {
const inputWithComments = 'value/*comment*/test';
// Act
const result = sanitizeDefaultValue(inputWithComments);
// Assert
expect(result).not.toContain('/*');
expect(result).not.toContain('*/');
expect(result).toBe("'valuecommenttest'");
});
it('should remove non-alphanumeric characters but preserve SQL keywords in alphanumeric form', () => {
// Prepare
const inputWithKeywords = 'SELECT * FROM users';
// Act
const result = sanitizeDefaultValue(inputWithKeywords);
// Assert
expect(result).toBe("'SELECTFROMusers'");
expect(result).not.toContain('*');
expect(result).not.toContain(' ');
expect(result).toBe("'value/*comment*/test'");
});
});
describe('regular values', () => {
it('should preserve underscores and alphanumeric characters in simple string values', () => {
// Prepare
it('should preserve simple string values', () => {
const input = 'simple_value';
// Act
const result = sanitizeDefaultValue(input);
// Assert
expect(result).toBe("'simple_value'");
});
it('should preserve numeric values', () => {
// Prepare
const input = 12345;
// Act
const result = sanitizeDefaultValue(input);
// Assert
expect(result).toBe(12345);
expect(sanitizeDefaultValue(12345)).toBe(12345);
});
it('should preserve boolean values', () => {
// Act & Assert
expect(sanitizeDefaultValue(true)).toBe(true);
expect(sanitizeDefaultValue(false)).toBe(false);
});
it('should handle empty string', () => {
// Prepare
const input = '';
// Act
const result = sanitizeDefaultValue(input);
// Assert
expect(result).toBe("''");
expect(sanitizeDefaultValue('')).toBe("''");
});
it('should remove whitespace but preserve alphanumeric and underscores', () => {
// Prepare
it('should preserve whitespace in string values', () => {
const input = ' test ';
// Act
const result = sanitizeDefaultValue(input);
// Assert
expect(result).toBe("'test'");
expect(result).toBe("' test '");
});
it('should preserve alphanumeric values with underscores', () => {
// Prepare
const input = 'test_value_123';
// Act
const result = sanitizeDefaultValue(input);
// Assert
expect(result).toBe("'test_value_123'");
});
});
describe('mixed cases', () => {
it('should distinguish between allowed functions and similar strings', () => {
// Act & Assert
expect(sanitizeDefaultValue('now()')).toBe('now()');
expect(sanitizeDefaultValue('now_test')).toBe("'now_test'");
expect(sanitizeDefaultValue('not_now()')).toBe("'not_now'");
expect(sanitizeDefaultValue('not_now()')).toBe("'not_now()'");
});
it('should handle functions with different casing but sanitize non-functions normally', () => {
// Act & Assert
it('should handle functions with different casing but escape non-functions', () => {
expect(sanitizeDefaultValue('NOW()')).toBe('NOW()');
expect(sanitizeDefaultValue('now_function')).toBe("'now_function'");
});
it('should handle complex mixed input', () => {
// Prepare
it('should properly escape complex mixed input', () => {
const complexInput = 'test"value; DROP TABLE users; /* comment */ now()';
// Act
const result = sanitizeDefaultValue(complexInput);
// Assert
expect(result).toBe("'testvalueDROPTABLEuserscommentnow'");
expect(result).not.toContain(';');
expect(result).not.toContain('"');
expect(result).not.toContain('/*');
expect(result).not.toContain('*/');
expect(result).not.toContain(' ');
expect(result).toBe(
"'test\"value; DROP TABLE users; /* comment */ now()'",
);
});
});
describe('edge cases', () => {
it('should handle null', () => {
// Act & Assert
expect(sanitizeDefaultValue(null)).toBe('NULL');
});
it('should handle strings that start with allowed function names', () => {
// Act & Assert
expect(sanitizeDefaultValue('now_extended')).toBe("'now_extended'");
expect(sanitizeDefaultValue('gen_random_uuid_custom')).toBe(
"'gen_random_uuid_custom'",
);
});
it('should handle strings with special characters', () => {
// Prepare
it('should escape all special characters properly', () => {
const specialChars = '!@#$%^&*()+=[]{}|\\:";\'<>?,.';
// Act
const result = sanitizeDefaultValue(specialChars);
// Assert
expect(result).toBe("''");
// Backslash triggers E-string, single quotes are doubled
expect(result).toContain('E');
expect(result).toContain("''");
});
it('should handle very long strings', () => {
// Prepare
const longString = 'a'.repeat(1000) + '; DROP TABLE users;';
// Act
it('should handle very long strings with injection attempts', () => {
const longString = 'a'.repeat(1000) + "'; DROP TABLE users;";
const result = sanitizeDefaultValue(longString);
// Assert
expect(result).toBe(`'${'a'.repeat(1000)}DROPTABLEusers'`);
expect(result).not.toContain(';');
expect(result).not.toContain(' ');
// The single quote in the injection attempt is properly escaped
expect(result).toBe(`'${'a'.repeat(1000)}''; DROP TABLE users;'`);
});
});
});
@@ -1,19 +1,27 @@
import { isDefined } from 'twenty-shared/utils';
import { type WorkspaceSchemaColumnDefinition } from 'src/engine/twenty-orm/workspace-schema-manager/types/workspace-schema-column-definition.type';
import { removeSqlDDLInjection } from 'src/engine/workspace-manager/workspace-migration/utils/remove-sql-injection.util';
import { escapeIdentifier } from 'src/engine/workspace-manager/workspace-migration/utils/remove-sql-injection.util';
const ALLOWED_GENERATED_TYPES = new Set(['STORED', 'VIRTUAL']);
export const buildSqlColumnDefinition = (
column: WorkspaceSchemaColumnDefinition,
): string => {
const safeName = removeSqlDDLInjection(column.name);
const parts = [`"${safeName}"`];
const parts = [escapeIdentifier(column.name)];
// column.type is either a PostgreSQL type name from fieldMetadataTypeToColumnType
// (safe enum-mapped), or a schema-qualified enum type pre-escaped by the caller.
parts.push(column.isArray ? `${column.type}[]` : column.type);
// asExpression is built internally by getTsVectorColumnExpressionFromFields
// (never user-provided). Field names within are escaped at the source.
if (column.asExpression && column.type === 'tsvector') {
parts.push(`GENERATED ALWAYS AS (${column.asExpression})`); // TODO: to sanitize
if (column.generatedType) {
parts.push(`GENERATED ALWAYS AS (${column.asExpression})`);
if (
column.generatedType &&
ALLOWED_GENERATED_TYPES.has(column.generatedType)
) {
parts.push(column.generatedType);
}
}
@@ -26,6 +34,8 @@ export const buildSqlColumnDefinition = (
parts.push('NOT NULL');
}
// column.default is pre-serialized by serializeDefaultValue which
// applies escapeLiteral/removeSqlDDLInjection to the value.
if (isDefined(column.default) && column.type !== 'tsvector') {
parts.push(`DEFAULT ${column.default}`);
}
@@ -1,4 +1,9 @@
import { removeSqlDDLInjection } from 'src/engine/workspace-manager/workspace-migration/utils/remove-sql-injection.util';
import { escapeLiteral } from 'src/engine/workspace-manager/workspace-migration/utils/remove-sql-injection.util';
const ALLOWED_DEFAULT_FUNCTIONS = new Set([
'public.uuid_generate_v4()',
'now()',
]);
export const sanitizeDefaultValue = (
defaultValue: string | number | boolean | null,
@@ -7,14 +12,12 @@ export const sanitizeDefaultValue = (
return 'NULL';
}
const allowedFunctions = ['public.uuid_generate_v4()', 'now()'];
if (typeof defaultValue === 'string') {
if (allowedFunctions.includes(defaultValue.toLowerCase())) {
if (ALLOWED_DEFAULT_FUNCTIONS.has(defaultValue.toLowerCase())) {
return defaultValue;
}
return `'${removeSqlDDLInjection(defaultValue)}'`;
return escapeLiteral(defaultValue);
}
return defaultValue;
@@ -101,11 +101,31 @@ export const seedFeatureFlags = async ({
workspaceId: workspaceId,
value: true,
},
{
key: FeatureFlagKey.IS_DATE_TIME_WHOLE_DAY_FILTER_ENABLED,
workspaceId: workspaceId,
value: true,
},
{
key: FeatureFlagKey.IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED,
workspaceId: workspaceId,
value: true,
},
{
key: FeatureFlagKey.IS_CORE_PICTURE_MIGRATED,
workspaceId: workspaceId,
value: true,
},
{
key: FeatureFlagKey.IS_FILES_FIELD_MIGRATED,
workspaceId: workspaceId,
value: true,
},
{
key: FeatureFlagKey.IS_OTHER_FILE_MIGRATED,
workspaceId: workspaceId,
value: true,
},
])
.execute();
};
@@ -8,6 +8,7 @@ import {
computeCompositeColumnName,
} from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util';
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
import { escapeIdentifier } from 'src/engine/workspace-manager/workspace-migration/utils/remove-sql-injection.util';
import { type SearchableFieldType } from 'src/engine/workspace-manager/utils/is-searchable-field.util';
import { isSearchableSubfield } from 'src/engine/workspace-manager/utils/is-searchable-subfield.util';
@@ -27,7 +28,6 @@ export const getTsVectorColumnExpressionFromFields = (
? columnExpressions.join(" || ' ' || ")
: 'NULL';
// Note: changing this expression requires reindexing/backfilling existing searchVector values.
return `to_tsvector('simple', ${concatenatedExpression})`;
};
@@ -59,9 +59,15 @@ const getColumnExpressionsFromField = (
});
if (fieldMetadataTypeAndName.type === FieldMetadataType.PHONES) {
const phoneNumberColumn = `"${fieldMetadataTypeAndName.name}PrimaryPhoneNumber"`;
const callingCodeColumn = `"${fieldMetadataTypeAndName.name}PrimaryPhoneCallingCode"`;
const additionalPhonesColumn = `"${fieldMetadataTypeAndName.name}AdditionalPhones"`;
const phoneNumberColumn = escapeIdentifier(
`${fieldMetadataTypeAndName.name}PrimaryPhoneNumber`,
);
const callingCodeColumn = escapeIdentifier(
`${fieldMetadataTypeAndName.name}PrimaryPhoneCallingCode`,
);
const additionalPhonesColumn = escapeIdentifier(
`${fieldMetadataTypeAndName.name}AdditionalPhones`,
);
const internationalFormats = [
`COALESCE(${callingCodeColumn} || ${phoneNumberColumn}, '')`,
@@ -79,7 +85,9 @@ const getColumnExpressionsFromField = (
}
if (fieldMetadataTypeAndName.type === FieldMetadataType.LINKS) {
const secondaryLinksColumn = `"${fieldMetadataTypeAndName.name}SecondaryLinks"`;
const secondaryLinksColumn = escapeIdentifier(
`${fieldMetadataTypeAndName.name}SecondaryLinks`,
);
const secondaryLinksExpression = `COALESCE(public.unaccent_immutable(TRANSLATE(regexp_replace(${secondaryLinksColumn}::text, '"(label|url)"\\s*:\\s*', '', 'g'), '[]{}",:', ' ')), '')`;
@@ -87,7 +95,9 @@ const getColumnExpressionsFromField = (
}
if (fieldMetadataTypeAndName.type === FieldMetadataType.EMAILS) {
const additionalEmailsColumn = `"${fieldMetadataTypeAndName.name}AdditionalEmails"`;
const additionalEmailsColumn = escapeIdentifier(
`${fieldMetadataTypeAndName.name}AdditionalEmails`,
);
const additionalEmailsExpression = `COALESCE(public.unaccent_immutable(TRANSLATE(${additionalEmailsColumn}::text, '[]",', ' ')), '') || ' ' || COALESCE(public.unaccent_immutable(TRANSLATE(REPLACE(${additionalEmailsColumn}::text, '@', ' '), '[]",', ' ')), '')`;
@@ -105,7 +115,7 @@ const getColumnExpression = (
columnName: string,
fieldType: FieldMetadataType,
): string => {
const quotedColumnName = `"${columnName}"`;
const quotedColumnName = escapeIdentifier(columnName);
switch (fieldType) {
case FieldMetadataType.EMAILS:
@@ -5,4 +5,7 @@ export const DEFAULT_FEATURE_FLAGS = [
FeatureFlagKey.IS_NOTE_TARGET_MIGRATED,
FeatureFlagKey.IS_TASK_TARGET_MIGRATED,
FeatureFlagKey.IS_NAVIGATION_MENU_ITEM_ENABLED,
FeatureFlagKey.IS_CORE_PICTURE_MIGRATED,
FeatureFlagKey.IS_FILES_FIELD_MIGRATED,
FeatureFlagKey.IS_OTHER_FILE_MIGRATED,
] as const satisfies FeatureFlagKey[];
@@ -0,0 +1,88 @@
import {
escapeIdentifier,
escapeLiteral,
removeSqlDDLInjection,
} from 'src/engine/workspace-manager/workspace-migration/utils/remove-sql-injection.util';
describe('removeSqlDDLInjection', () => {
it('should strip non-alphanumeric/underscore characters', () => {
expect(removeSqlDDLInjection('my_table')).toBe('my_table');
expect(removeSqlDDLInjection('table"name')).toBe('tablename');
expect(removeSqlDDLInjection('drop;--')).toBe('drop');
});
});
describe('escapeIdentifier', () => {
it('should wrap identifier in double quotes', () => {
expect(escapeIdentifier('myTable')).toBe('"myTable"');
});
it('should double internal double-quote characters', () => {
expect(escapeIdentifier('my"table')).toBe('"my""table"');
expect(escapeIdentifier('a""b')).toBe('"a""""b"');
});
it('should handle empty string', () => {
expect(escapeIdentifier('')).toBe('""');
});
it('should handle single-quote characters without modification', () => {
expect(escapeIdentifier("it's")).toBe('"it\'s"');
});
it('should reject null bytes', () => {
expect(() => escapeIdentifier('my\0table')).toThrow(
'Null bytes are not allowed in PostgreSQL identifiers',
);
});
it('should handle SQL injection attempts in identifiers', () => {
expect(escapeIdentifier('"; DROP TABLE users; --')).toBe(
'"""; DROP TABLE users; --"',
);
});
});
describe('escapeLiteral', () => {
it('should wrap value in single quotes', () => {
expect(escapeLiteral('hello')).toBe("'hello'");
});
it('should double internal single-quote characters', () => {
expect(escapeLiteral("it's")).toBe("'it''s'");
expect(escapeLiteral("a''b")).toBe("'a''''b'");
});
it('should handle empty string', () => {
expect(escapeLiteral('')).toBe("''");
});
it('should escape backslashes and add E prefix', () => {
expect(escapeLiteral('test\\value')).toBe("E'test\\\\value'");
});
it('should handle both single quotes and backslashes', () => {
expect(escapeLiteral("it's a \\path")).toBe("E'it''s a \\\\path'");
});
it('should not add E prefix when no backslashes present', () => {
expect(escapeLiteral('simple')).toBe("'simple'");
expect(escapeLiteral("it's")).toBe("'it''s'");
});
it('should reject null bytes', () => {
expect(() => escapeLiteral('my\0value')).toThrow(
'Null bytes are not allowed in PostgreSQL string literals',
);
});
it('should handle SQL injection attempts in literals', () => {
expect(escapeLiteral("'; DROP TABLE users; --")).toBe(
"'''; DROP TABLE users; --'",
);
});
it('should handle double quotes without modification', () => {
expect(escapeLiteral('test"value')).toBe("'test\"value'");
});
});
@@ -0,0 +1,27 @@
import { validateAndReturnIndexWhereClause } from 'src/engine/workspace-manager/workspace-migration/utils/validate-index-where-clause.util';
describe('validateAndReturnIndexWhereClause', () => {
it('should return undefined for null/undefined/empty input', () => {
expect(validateAndReturnIndexWhereClause(null)).toBeUndefined();
expect(validateAndReturnIndexWhereClause(undefined)).toBeUndefined();
expect(validateAndReturnIndexWhereClause('')).toBeUndefined();
});
it('should return the clause when it is in the allowlist', () => {
expect(validateAndReturnIndexWhereClause('"deletedAt" IS NULL')).toBe(
'"deletedAt" IS NULL',
);
});
it('should throw for clauses not in the allowlist', () => {
expect(() =>
validateAndReturnIndexWhereClause('1=1; DROP TABLE users;'),
).toThrow('Unsupported index WHERE clause');
});
it('should throw for subtle variants of allowed clauses', () => {
expect(() =>
validateAndReturnIndexWhereClause('"deletedAt" IS NOT NULL'),
).toThrow('Unsupported index WHERE clause');
});
});
@@ -1,3 +1,49 @@
// Strips all characters except [a-zA-Z0-9_].
// Use ONLY for generating safe identifier names (e.g. enum names from table+column).
// For SQL escaping, use escapeIdentifier or escapeLiteral instead.
export const removeSqlDDLInjection = (value: string): string => {
return value.replace(/[^a-zA-Z0-9_]/g, '');
};
// PostgreSQL standard identifier quoting: wraps in double quotes and
// doubles any internal double-quote characters.
// e.g. my"table → "my""table"
export const escapeIdentifier = (identifier: string): string => {
if (identifier.includes('\0')) {
throw new Error('Null bytes are not allowed in PostgreSQL identifiers');
}
return '"' + identifier.replace(/"/g, '""') + '"';
};
// PostgreSQL standard literal quoting: wraps in single quotes and
// doubles any internal single-quote characters. Prefixes with E when
// backslashes are present (standard_conforming_strings safety).
// e.g. it's → 'it''s'
export const escapeLiteral = (value: string): string => {
if (value.includes('\0')) {
throw new Error('Null bytes are not allowed in PostgreSQL string literals');
}
let hasBackslash = false;
let escaped = "'";
for (const char of value) {
if (char === "'") {
escaped += "''";
} else if (char === '\\') {
escaped += '\\\\';
hasBackslash = true;
} else {
escaped += char;
}
}
escaped += "'";
if (hasBackslash) {
escaped = 'E' + escaped;
}
return escaped;
};
@@ -0,0 +1,21 @@
// Allowlist of safe WHERE clause patterns for partial indexes.
// Any new pattern must be reviewed for SQL injection safety before being added.
const ALLOWED_INDEX_WHERE_CLAUSES = new Set(['"deletedAt" IS NULL']);
export const validateAndReturnIndexWhereClause = (
clause: string | null | undefined,
): string | undefined => {
if (!clause) {
return undefined;
}
if (ALLOWED_INDEX_WHERE_CLAUSES.has(clause)) {
return clause;
}
throw new Error(
`Unsupported index WHERE clause: "${clause}". ` +
'Only allowlisted patterns are permitted to prevent SQL injection. ' +
'Add the pattern to ALLOWED_INDEX_WHERE_CLAUSES after security review.',
);
};
@@ -1,5 +1,5 @@
import { type ColumnType } from 'typeorm';
import { type FieldMetadataDefaultValueForAnyType } from 'twenty-shared/types';
import { type ColumnType } from 'typeorm';
import {
FieldMetadataException,
@@ -7,7 +7,16 @@ import {
} from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
import { isFunctionDefaultValue } from 'src/engine/metadata-modules/field-metadata/utils/is-function-default-value.util';
import { serializeFunctionDefaultValue } from 'src/engine/metadata-modules/field-metadata/utils/serialize-function-default-value.util';
import { removeSqlDDLInjection } from 'src/engine/workspace-manager/workspace-migration/utils/remove-sql-injection.util';
import {
escapeIdentifier,
escapeLiteral,
removeSqlDDLInjection,
} from 'src/engine/workspace-manager/workspace-migration/utils/remove-sql-injection.util';
// Default values arrive pre-quoted with single quotes (e.g. "'OPTION_1'").
// Strip them so escapeLiteral can re-quote properly.
const stripSurroundingQuotes = (value: string): string =>
value.startsWith("'") && value.endsWith("'") ? value.slice(1, -1) : value;
type SerializeDefaultValueArgs = {
defaultValue?: FieldMetadataDefaultValueForAnyType;
@@ -23,15 +32,10 @@ export const serializeDefaultValue = ({
tableName,
columnName,
}: SerializeDefaultValueArgs) => {
const safeSchemaName = removeSqlDDLInjection(schemaName);
const safeTableName = removeSqlDDLInjection(tableName);
const safeColumnName = removeSqlDDLInjection(columnName);
if (defaultValue === undefined || defaultValue === null) {
return 'NULL';
}
// Function default values
if (isFunctionDefaultValue(defaultValue)) {
const serializedTypeDefaultValue =
serializeFunctionDefaultValue(defaultValue);
@@ -46,13 +50,16 @@ export const serializeDefaultValue = ({
return serializedTypeDefaultValue;
}
// Enum types need a schema-qualified cast; others use the column type directly.
// Enum name is built from sanitized table+column (removeSqlDDLInjection strips
// to [a-zA-Z0-9_]) to match computePostgresEnumName.
const castSuffix =
columnType === 'enum'
? `::${safeSchemaName}."${safeTableName}_${safeColumnName}_enum"`
? `::${escapeIdentifier(schemaName)}.${escapeIdentifier(`${removeSqlDDLInjection(tableName)}_${removeSqlDDLInjection(columnName)}_enum`)}`
: `::${columnType}`;
const sanitizeAndAddCastPrefix = (defaultValue: string) =>
`'${removeSqlDDLInjection(defaultValue)}'` + castSuffix;
const escapeAndCast = (rawValue: string) =>
escapeLiteral(rawValue) + castSuffix;
switch (typeof defaultValue) {
case 'string': {
@@ -63,27 +70,26 @@ export const serializeDefaultValue = ({
);
}
return sanitizeAndAddCastPrefix(defaultValue);
return escapeAndCast(stripSurroundingQuotes(defaultValue));
}
case 'boolean':
case 'number': {
return sanitizeAndAddCastPrefix(`${defaultValue}`);
return escapeAndCast(`${defaultValue}`);
}
case 'object': {
if (defaultValue instanceof Date) {
return sanitizeAndAddCastPrefix(`'${defaultValue.toISOString()}'`);
return escapeAndCast(defaultValue.toISOString());
}
if (Array.isArray(defaultValue)) {
const arrayValues = defaultValue
.map((val) => `'${removeSqlDDLInjection(val)}'`)
.map((val) => escapeLiteral(stripSurroundingQuotes(String(val))))
.join(',');
return `ARRAY[${arrayValues}]${castSuffix}[]`;
}
// Default value for objects won't work with sanitization here
return sanitizeAndAddCastPrefix(`'${JSON.stringify(defaultValue)}'`);
return escapeAndCast(JSON.stringify(defaultValue));
}
default: {
throw new FieldMetadataException(
@@ -300,7 +300,7 @@ export abstract class BaseWorkspaceMigrationRunnerActionHandlerService<
await this.rollbackForMetadata(context);
} catch (error) {
this.logger.error(
`Failed to rollback ${context.action.type} action for ${context.action.metadataName}: ${error instanceof Error ? error.message : 'Unknown error'}`,
`Failed to rollback ${context.action.type} action for ${context.action.metadataName}: ${error instanceof Error ? error.message : String(error)}`,
'BaseWorkspaceMigrationRunnerActionHandlerService',
);
}
@@ -30,7 +30,7 @@ export class GmailEmailAliasErrorHandlerService {
}
throw new MessageImportDriverException(
'Unknown error',
`Google email alias error: ${error instanceof Error ? error.message : String(error)}`,
MessageImportDriverExceptionCode.UNKNOWN,
);
}
@@ -30,7 +30,7 @@ export class GmailFoldersErrorHandlerService {
}
throw new MessageImportDriverException(
'Unknown error',
`Gmail folders fetch error: ${error instanceof Error ? error.message : String(error)}`,
MessageImportDriverExceptionCode.UNKNOWN,
);
}
@@ -30,7 +30,7 @@ export class GmailMessageListFetchErrorHandler {
}
throw new MessageImportDriverException(
'Unknown error',
`Gmail message list fetch error: ${error instanceof Error ? error.message : String(error)}`,
MessageImportDriverExceptionCode.UNKNOWN,
);
}
@@ -36,7 +36,7 @@ export class GmailMessagesImportErrorHandler {
}
throw new MessageImportDriverException(
'Unknown error',
`Gmail message import error: ${error instanceof Error ? error.message : String(error)}`,
MessageImportDriverExceptionCode.UNKNOWN,
);
}
@@ -101,7 +101,7 @@ export class ResumeDelayedWorkflowJob {
error:
error instanceof Error
? error.message
: 'Unknown error during delay resume',
: `Error during delay resume: ${String(error)}`,
});
}
}, authContext);
@@ -1,10 +1,20 @@
import { gql } from 'graphql-tag';
import request from 'supertest';
import { makeMetadataAPIRequestWithFileUpload } from 'test/integration/metadata/suites/utils/make-metadata-api-request-with-file-upload.util';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { PermissionsExceptionMessage } from 'src/engine/metadata-modules/permissions/permissions.exception';
const uploadWorkspaceLogoMutation = gql`
mutation UploadWorkspaceLogo($file: Upload!) {
uploadWorkspaceLogo(file: $file) {
id
url
}
}
`;
const client = request(`http://localhost:${APP_PORT}`);
describe('Security permissions', () => {
@@ -506,62 +516,85 @@ describe('Security permissions', () => {
});
describe('logo update', () => {
beforeAll(() => {
jest.useRealTimers();
});
afterAll(() => {
jest.useFakeTimers();
});
it('should update workspace logo when user has workspace settings permission', async () => {
const queryData = {
query: `
mutation updateWorkspace {
updateWorkspace(data: { logo: "new-logo" }) {
id
const testImageBuffer = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
'base64',
);
const uploadResponse = await makeMetadataAPIRequestWithFileUpload(
{
query: uploadWorkspaceLogoMutation,
variables: { file: null },
},
{
field: 'file',
buffer: testImageBuffer,
filename: 'test-logo.png',
contentType: 'image/png',
},
APPLE_JANE_ADMIN_ACCESS_TOKEN,
);
expect(uploadResponse.status).toBe(200);
expect(uploadResponse.body.errors).toBeUndefined();
expect(uploadResponse.body.data).toBeDefined();
expect(uploadResponse.body.data.uploadWorkspaceLogo).toBeDefined();
expect(uploadResponse.body.data.uploadWorkspaceLogo.id).toBeDefined();
expect(uploadResponse.body.data.uploadWorkspaceLogo.url).toBeDefined();
const getWorkspaceQuery = gql`
query GetWorkspace {
currentWorkspace {
logo
}
}
`,
};
`;
return client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send(queryData)
.expect(200)
.expect((res) => {
expect(res.body.data).toBeDefined();
expect(res.body.errors).toBeUndefined();
})
.expect((res) => {
const data = res.body.data.updateWorkspace;
const workspaceResponse = await makeMetadataAPIRequest({
query: getWorkspaceQuery,
});
expect(data).toBeDefined();
expect(data.logo).toContain('new-logo');
});
expect(workspaceResponse.body.data.currentWorkspace.logo).toBeDefined();
});
it('should throw a permission error when user does not have permission (member role)', async () => {
const queryData = {
query: `
mutation updateWorkspace {
updateWorkspace(data: { logo: "another-new-logo" }) {
id
logo
}
}
`,
};
const testImageBuffer = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
'base64',
);
await client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JONY_MEMBER_ACCESS_TOKEN}`)
.send(queryData)
.expect(200)
.expect((res) => {
expect(res.body.data).toBeNull();
expect(res.body.errors).toBeDefined();
expect(res.body.errors[0].message).toBe(
PermissionsExceptionMessage.PERMISSION_DENIED,
);
expect(res.body.errors[0].extensions.code).toBe(
ErrorCode.FORBIDDEN,
);
});
const response = await makeMetadataAPIRequestWithFileUpload(
{
query: uploadWorkspaceLogoMutation,
variables: { file: null },
},
{
field: 'file',
buffer: testImageBuffer,
filename: 'test-logo.png',
contentType: 'image/png',
},
APPLE_JONY_MEMBER_ACCESS_TOKEN,
);
expect(response.status).toBe(200);
expect(response.body.data).toBeNull();
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toBe(
PermissionsExceptionMessage.PERMISSION_DENIED,
);
expect(response.body.errors[0].extensions.code).toBe(
ErrorCode.FORBIDDEN,
);
});
});
});
@@ -1,5 +1,6 @@
import gql from 'graphql-tag';
import request from 'supertest';
import { makeMetadataAPIRequestWithFileUpload } from 'test/integration/metadata/suites/utils/make-metadata-api-request-with-file-upload.util';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum';
@@ -7,6 +8,15 @@ import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/featu
import { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { PermissionsExceptionMessage } from 'src/engine/metadata-modules/permissions/permissions.exception';
const uploadWorkspaceLogoMutation = gql`
mutation UploadWorkspaceLogo($file: Upload!) {
uploadWorkspaceLogo(file: $file) {
id
url
}
}
`;
const client = request(`http://localhost:${APP_PORT}`);
describe('workspace permissions', () => {
@@ -266,62 +276,85 @@ describe('workspace permissions', () => {
});
describe('logo update', () => {
beforeAll(() => {
jest.useRealTimers();
});
afterAll(() => {
jest.useFakeTimers();
});
it('should update workspace logo when user has workspace settings permission', async () => {
const queryData = {
query: `
mutation updateWorkspace {
updateWorkspace(data: { logo: "new-logo" }) {
id
logo
const testImageBuffer = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
'base64',
);
const uploadResponse = await makeMetadataAPIRequestWithFileUpload(
{
query: uploadWorkspaceLogoMutation,
variables: { file: null },
},
{
field: 'file',
buffer: testImageBuffer,
filename: 'test-logo.png',
contentType: 'image/png',
},
APPLE_JANE_ADMIN_ACCESS_TOKEN,
);
expect(uploadResponse.status).toBe(200);
expect(uploadResponse.body.errors).toBeUndefined();
expect(uploadResponse.body.data).toBeDefined();
expect(uploadResponse.body.data.uploadWorkspaceLogo).toBeDefined();
expect(uploadResponse.body.data.uploadWorkspaceLogo.id).toBeDefined();
expect(uploadResponse.body.data.uploadWorkspaceLogo.url).toBeDefined();
const getWorkspaceQuery = gql`
query GetWorkspace {
currentWorkspace {
logo
}
}
}
`,
};
`;
return client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send(queryData)
.expect(200)
.expect((res) => {
expect(res.body.data).toBeDefined();
expect(res.body.errors).toBeUndefined();
})
.expect((res) => {
const data = res.body.data.updateWorkspace;
const workspaceResponse = await makeMetadataAPIRequest({
query: getWorkspaceQuery,
});
expect(data).toBeDefined();
expect(data.logo).toContain('new-logo');
});
expect(workspaceResponse.body.data.currentWorkspace.logo).toBeDefined();
});
it('should throw a permission error when user does not have permission (member role)', async () => {
const queryData = {
query: `
mutation updateWorkspace {
updateWorkspace(data: { logo: "another-new-logo" }) {
id
logo
}
}
`,
};
const testImageBuffer = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
'base64',
);
await client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JONY_MEMBER_ACCESS_TOKEN}`)
.send(queryData)
.expect(200)
.expect((res) => {
expect(res.body.data).toBeNull();
expect(res.body.errors).toBeDefined();
expect(res.body.errors[0].message).toBe(
PermissionsExceptionMessage.PERMISSION_DENIED,
);
expect(res.body.errors[0].extensions.code).toBe(
ErrorCode.FORBIDDEN,
);
});
const response = await makeMetadataAPIRequestWithFileUpload(
{
query: uploadWorkspaceLogoMutation,
variables: { file: null },
},
{
field: 'file',
buffer: testImageBuffer,
filename: 'test-logo.png',
contentType: 'image/png',
},
APPLE_JONY_MEMBER_ACCESS_TOKEN,
);
expect(response.status).toBe(200);
expect(response.body.data).toBeNull();
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toBe(
PermissionsExceptionMessage.PERMISSION_DENIED,
);
expect(response.body.errors[0].extensions.code).toBe(
ErrorCode.FORBIDDEN,
);
});
});
});
@@ -108,6 +108,7 @@ describe('syncApplication', () => {
publicAssets: [],
views: [],
navigationMenuItems: [],
pageLayouts: [],
};
const { data: firstSyncData } = await syncApplication({
@@ -14,6 +14,7 @@ export type ApplicationMarketplaceData = {
export type ApplicationManifest = SyncableEntityOptions & {
defaultRoleUniversalIdentifier: string;
postInstallLogicFunctionUniversalIdentifier?: string;
displayName: string;
description: string;
icon?: string;
@@ -4,4 +4,5 @@ export enum SyncableEntity {
LogicFunction = 'logicFunction',
FrontComponent = 'frontComponent',
Role = 'role',
PageLayout = 'pageLayout',
}
@@ -36,6 +36,11 @@ export type { Manifest } from './manifestType';
export type { NavigationMenuItemManifest } from './navigationMenuItemManifestType';
export type { ObjectFieldManifest } from './objectFieldManifest.type';
export type { ObjectManifest } from './objectManifestType';
export type {
PageLayoutWidgetManifest,
PageLayoutTabManifest,
PageLayoutManifest,
} from './pageLayoutManifestType';
export type {
ObjectPermissionManifest,
FieldPermissionManifest,
@@ -5,6 +5,7 @@ import { type FrontComponentManifest } from './frontComponentManifestType';
import { type LogicFunctionManifest } from './logicFunctionManifestType';
import { type NavigationMenuItemManifest } from './navigationMenuItemManifestType';
import { type ObjectManifest } from './objectManifestType';
import { type PageLayoutManifest } from './pageLayoutManifestType';
import { type RoleManifest } from './roleManifestType';
import { type ViewManifest } from './viewManifestType';
@@ -18,4 +19,5 @@ export type Manifest = {
publicAssets: AssetManifest[];
views: ViewManifest[];
navigationMenuItems: NavigationMenuItemManifest[];
pageLayouts: PageLayoutManifest[];
};
@@ -0,0 +1,32 @@
import { type SyncableEntityOptions } from '@/application/syncableEntityOptionsType';
import {
type PageLayoutTabLayoutMode,
type PageLayoutWidgetConditionalDisplay,
type PageLayoutWidgetPosition,
type PageLayoutWidgetUniversalConfiguration,
} from '@/types';
export type PageLayoutWidgetManifest = SyncableEntityOptions & {
title: string;
type: string;
objectUniversalIdentifier?: string;
conditionalDisplay?: PageLayoutWidgetConditionalDisplay;
position?: PageLayoutWidgetPosition;
configuration: PageLayoutWidgetUniversalConfiguration;
};
export type PageLayoutTabManifest = SyncableEntityOptions & {
title: string;
position: number;
icon?: string;
layoutMode?: PageLayoutTabLayoutMode;
widgets?: PageLayoutWidgetManifest[];
};
export type PageLayoutManifest = SyncableEntityOptions & {
name: string;
type?: string;
objectUniversalIdentifier?: string;
defaultTabToFocusOnMobileAndSidePanelUniversalIdentifier?: string;
tabs?: PageLayoutTabManifest[];
};
@@ -125,6 +125,7 @@ export type SelectFilter = {
export type MultiSelectFilter = {
is?: IsFilter;
in?: string[];
isEmptyArray?: boolean;
containsAny?: string[];
};
@@ -143,6 +143,7 @@ export type {
PageLayoutWidgetCanvasPosition,
PageLayoutWidgetPosition,
} from './page-layout/page-layout-widget-position.type';
export type { PageLayoutWidgetUniversalConfiguration } from './page-layout/page-layout-widget-universal-configuration.type';
export { PageLayoutTabLayoutMode } from './page-layout/PageLayoutTabLayoutMode';
export type { PageLayoutWidgetConditionalDisplay } from './page-layout/PageLayoutWidgetConditionalDisplay';
export type { PartialFieldMetadataItem } from './PartialFieldMetadataItem';
@@ -0,0 +1,207 @@
import { type AggregateOperations } from '../AggregateOperations';
type ChartFilterRecordFilter = {
id: string;
fieldMetadataUniversalIdentifier: string;
operand: string;
value?: string | null;
type?: string;
recordFilterGroupId?: string | null;
subFieldName?: string | null;
};
type ChartFilterRecordFilterGroup = {
id: string;
logicalOperator: string;
parentRecordFilterGroupId?: string | null;
};
type UniversalChartFilter = {
recordFilters?: ChartFilterRecordFilter[];
recordFilterGroups?: ChartFilterRecordFilterGroup[];
};
type RatioAggregateConfig = {
fieldMetadataUniversalIdentifier: string | null;
optionValue: string;
};
type BaseChartFields = {
aggregateFieldMetadataUniversalIdentifier: string | null;
aggregateOperation: AggregateOperations;
displayDataLabel?: boolean;
description?: string;
color?: string;
filter?: UniversalChartFilter;
timezone?: string;
firstDayOfTheWeek?: number;
};
type AggregateChartUniversalConfiguration = BaseChartFields & {
configurationType: 'AGGREGATE_CHART';
label?: string;
format?: string;
prefix?: string;
suffix?: string;
ratioAggregateConfig?: RatioAggregateConfig;
};
type GaugeChartUniversalConfiguration = BaseChartFields & {
configurationType: 'GAUGE_CHART';
};
type PieChartUniversalConfiguration = BaseChartFields & {
configurationType: 'PIE_CHART';
groupByFieldMetadataUniversalIdentifier: string | null;
groupBySubFieldName?: string;
dateGranularity?: string;
orderBy?: string;
manualSortOrder?: string[];
showCenterMetric?: boolean;
displayLegend?: boolean;
hideEmptyCategory?: boolean;
splitMultiValueFields?: boolean;
};
type BarChartUniversalConfiguration = BaseChartFields & {
configurationType: 'BAR_CHART';
primaryAxisGroupByFieldMetadataUniversalIdentifier: string | null;
primaryAxisGroupBySubFieldName?: string;
primaryAxisDateGranularity?: string;
primaryAxisOrderBy?: string;
primaryAxisManualSortOrder?: string[];
secondaryAxisGroupByFieldMetadataUniversalIdentifier?: string | null;
secondaryAxisGroupBySubFieldName?: string;
secondaryAxisGroupByDateGranularity?: string;
secondaryAxisOrderBy?: string;
secondaryAxisManualSortOrder?: string[];
omitNullValues?: boolean;
splitMultiValueFields?: boolean;
axisNameDisplay?: string;
displayLegend?: boolean;
rangeMin?: number;
rangeMax?: number;
groupMode?: string;
layout?: string;
isCumulative?: boolean;
};
type LineChartUniversalConfiguration = BaseChartFields & {
configurationType: 'LINE_CHART';
primaryAxisGroupByFieldMetadataUniversalIdentifier: string | null;
primaryAxisGroupBySubFieldName?: string;
primaryAxisDateGranularity?: string;
primaryAxisOrderBy?: string;
primaryAxisManualSortOrder?: string[];
secondaryAxisGroupByFieldMetadataUniversalIdentifier?: string | null;
secondaryAxisGroupBySubFieldName?: string;
secondaryAxisGroupByDateGranularity?: string;
secondaryAxisOrderBy?: string;
secondaryAxisManualSortOrder?: string[];
omitNullValues?: boolean;
splitMultiValueFields?: boolean;
axisNameDisplay?: string;
displayLegend?: boolean;
rangeMin?: number;
rangeMax?: number;
isStacked?: boolean;
isCumulative?: boolean;
};
type ViewUniversalConfiguration = {
configurationType: 'VIEW';
};
type FieldUniversalConfiguration = {
configurationType: 'FIELD';
};
type FieldsUniversalConfiguration = {
configurationType: 'FIELDS';
viewId?: string | null;
newFieldDefaultConfiguration?: {
isVisible: boolean;
viewFieldGroupId: string | null;
} | null;
};
type FieldRichTextUniversalConfiguration = {
configurationType: 'FIELD_RICH_TEXT';
};
type StandaloneRichTextUniversalConfiguration = {
configurationType: 'STANDALONE_RICH_TEXT';
body: {
blocknote?: string | null;
markdown: string | null;
};
};
type IframeUniversalConfiguration = {
configurationType: 'IFRAME';
url?: string;
};
type FrontComponentUniversalConfiguration = {
configurationType: 'FRONT_COMPONENT';
frontComponentId: string;
};
type TimelineUniversalConfiguration = {
configurationType: 'TIMELINE';
};
type TasksUniversalConfiguration = {
configurationType: 'TASKS';
};
type NotesUniversalConfiguration = {
configurationType: 'NOTES';
};
type FilesUniversalConfiguration = {
configurationType: 'FILES';
};
type EmailsUniversalConfiguration = {
configurationType: 'EMAILS';
};
type CalendarUniversalConfiguration = {
configurationType: 'CALENDAR';
};
type WorkflowUniversalConfiguration = {
configurationType: 'WORKFLOW';
};
type WorkflowVersionUniversalConfiguration = {
configurationType: 'WORKFLOW_VERSION';
};
type WorkflowRunUniversalConfiguration = {
configurationType: 'WORKFLOW_RUN';
};
export type PageLayoutWidgetUniversalConfiguration =
| AggregateChartUniversalConfiguration
| GaugeChartUniversalConfiguration
| PieChartUniversalConfiguration
| BarChartUniversalConfiguration
| LineChartUniversalConfiguration
| ViewUniversalConfiguration
| FieldUniversalConfiguration
| FieldsUniversalConfiguration
| FieldRichTextUniversalConfiguration
| StandaloneRichTextUniversalConfiguration
| IframeUniversalConfiguration
| FrontComponentUniversalConfiguration
| TimelineUniversalConfiguration
| TasksUniversalConfiguration
| NotesUniversalConfiguration
| FilesUniversalConfiguration
| EmailsUniversalConfiguration
| CalendarUniversalConfiguration
| WorkflowUniversalConfiguration
| WorkflowVersionUniversalConfiguration
| WorkflowRunUniversalConfiguration;
@@ -1,4 +1,5 @@
import { isNonEmptyString } from '@sniptt/guards';
import { Temporal } from 'temporal-polyfill';
import {
FieldMetadataType,
@@ -52,7 +53,6 @@ import {
import { arrayOfStringsOrVariablesSchema } from '@/utils/filter/utils/validation-schemas/arrayOfStringsOrVariablesSchema';
import { arrayOfUuidOrVariableSchema } from '@/utils/filter/utils/validation-schemas/arrayOfUuidsOrVariablesSchema';
import { jsonRelationFilterValueSchema } from '@/utils/filter/utils/validation-schemas/jsonRelationFilterValueSchema';
import { Temporal } from 'temporal-polyfill';
type FieldShared = {
id: string;
@@ -390,6 +390,43 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
throw new Error(`Date filter is empty`);
}
if (recordFilter.operand === RecordFilterOperand.IS) {
const timeZone = filterValueDependencies.timeZone ?? 'UTC';
let parsedPlainDate = null;
try {
parsedPlainDate = recordFilter.value.includes('T')
? Temporal.Instant.from(recordFilter.value)
.toZonedDateTimeISO(timeZone)
.toPlainDate()
: Temporal.PlainDate.from(recordFilter.value);
} catch {
throw new Error(
`Cannot parse "${recordFilter.value}" for ${filterType} filter`,
);
}
const zonedDateTime = parsedPlainDate.toZonedDateTime(timeZone);
const start = zonedDateTime.toInstant();
const end = zonedDateTime.add({ days: 1 }).toInstant();
return {
and: [
{
[correspondingFieldMetadataItem.name]: {
gte: start.toString(),
} as DateTimeFilter,
},
{
[correspondingFieldMetadataItem.name]: {
lt: end.toString(),
} as DateTimeFilter,
},
],
};
}
const resolvedDateTime = Temporal.Instant.from(recordFilter.value);
switch (recordFilter.operand) {
@@ -407,34 +444,6 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
} as DateTimeFilter,
};
}
case RecordFilterOperand.IS: {
const start = resolvedDateTime
.toZonedDateTimeISO('UTC')
.with({
second: 0,
millisecond: 0,
microsecond: 0,
nanosecond: 0,
})
.toInstant();
const end = start.add({ minutes: 1 });
return {
and: [
{
[correspondingFieldMetadataItem.name]: {
lt: end.toString(),
} as DateTimeFilter,
},
{
[correspondingFieldMetadataItem.name]: {
gte: start.toString(),
} as DateTimeFilter,
},
],
};
}
}
}

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