Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebc224ae3b | ||
|
|
c3781e87cc | ||
|
|
e9b5cb830c | ||
|
|
e40c758aa6 | ||
|
|
53c314d0fa | ||
|
|
618df704e6 | ||
|
|
058489b5cc | ||
|
|
3bd431e95d | ||
|
|
f4a61f26c0 | ||
|
|
8e6b267ff3 |
@@ -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**: key–value 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 logické funkce
|
||||
│ └── default-role.ts # Default role for logic functions
|
||||
├── logic-functions/
|
||||
│ └── hello-world.ts # Ukázková logická 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üssel–Wert-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 chiave–valore 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 chave–valor 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 cheie–valoare 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 anahtar–değ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',
|
||||
|
||||
+11
-1
@@ -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,
|
||||
};
|
||||
|
||||
+9
-3
@@ -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}
|
||||
|
||||
+17
@@ -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 />
|
||||
|
||||
+76
-10
@@ -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();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
+18
-1
@@ -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);
|
||||
|
||||
+21
-3
@@ -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)) {
|
||||
|
||||
+4
-4
@@ -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',
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
@@ -412,6 +412,7 @@ export const EXPECTED_MANIFEST: Manifest = {
|
||||
],
|
||||
views: [],
|
||||
navigationMenuItems: [],
|
||||
pageLayouts: [],
|
||||
logicFunctions: [
|
||||
{
|
||||
builtHandlerChecksum: '[checksum]',
|
||||
|
||||
+1
@@ -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);
|
||||
|
||||
+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,
|
||||
|
||||
@@ -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;
|
||||
+154
@@ -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);
|
||||
}
|
||||
}
|
||||
+5
@@ -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,
|
||||
|
||||
+44
-36
@@ -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;
|
||||
}
|
||||
|
||||
+116
-5
@@ -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,
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+3
@@ -13,6 +13,9 @@ export const APPLICATION_MANIFEST_METADATA_NAMES = [
|
||||
'viewFilterGroup',
|
||||
'viewGroup',
|
||||
'navigationMenuItem',
|
||||
'pageLayout',
|
||||
'pageLayoutTab',
|
||||
'pageLayoutWidget',
|
||||
] as const satisfies AllMetadataName[];
|
||||
|
||||
export type ApplicationManifestMetadataName =
|
||||
|
||||
+2
-2
@@ -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)}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+51
@@ -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');
|
||||
});
|
||||
});
|
||||
+55
@@ -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);
|
||||
});
|
||||
});
|
||||
+76
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
+53
@@ -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;
|
||||
};
|
||||
|
||||
+31
@@ -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,
|
||||
};
|
||||
};
|
||||
+31
@@ -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,
|
||||
};
|
||||
};
|
||||
+34
@@ -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,
|
||||
|
||||
+3
@@ -97,6 +97,9 @@ const createSignInUpServiceForTests = () => {
|
||||
{
|
||||
createWorkspaceCustomApplication: jest.fn(),
|
||||
} as any,
|
||||
{
|
||||
uploadWorkspaceLogoFromUrl: jest.fn(),
|
||||
} as any,
|
||||
{
|
||||
createQueryRunner: jest.fn(() => queryRunnerMock),
|
||||
} as any,
|
||||
|
||||
+23
-15
@@ -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
|
||||
|
||||
+1
@@ -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',
|
||||
}
|
||||
|
||||
+5
-1
@@ -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(
|
||||
|
||||
+51
-9
@@ -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,
|
||||
|
||||
+6
-1
@@ -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,
|
||||
|
||||
+3
-1
@@ -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(
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const DEFAULT_TIMEZONE = 'UTC';
|
||||
+7
@@ -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();
|
||||
|
||||
|
||||
+35
-1
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -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,
|
||||
|
||||
+15
-23
@@ -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();
|
||||
|
||||
+65
-92
@@ -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})`;
|
||||
}
|
||||
}
|
||||
|
||||
+21
-19
@@ -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;
|
||||
|
||||
+30
-15
@@ -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);
|
||||
}
|
||||
|
||||
+4
-12
@@ -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);
|
||||
}
|
||||
|
||||
+31
-127
@@ -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;'`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+15
-5
@@ -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}`);
|
||||
}
|
||||
|
||||
+8
-5
@@ -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;
|
||||
|
||||
+20
@@ -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();
|
||||
};
|
||||
|
||||
+17
-7
@@ -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:
|
||||
|
||||
+3
@@ -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[];
|
||||
|
||||
+88
@@ -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'");
|
||||
});
|
||||
});
|
||||
+27
@@ -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');
|
||||
});
|
||||
});
|
||||
+46
@@ -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;
|
||||
};
|
||||
|
||||
+21
@@ -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.',
|
||||
);
|
||||
};
|
||||
+22
-16
@@ -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(
|
||||
|
||||
+1
-1
@@ -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',
|
||||
);
|
||||
}
|
||||
|
||||
+1
-1
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
+1
-1
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
+1
-1
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
+1
-1
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
+1
-1
@@ -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);
|
||||
|
||||
+79
-46
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+81
-48
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+1
@@ -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';
|
||||
|
||||
+207
@@ -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
Reference in New Issue
Block a user