Compare commits

..
Author SHA1 Message Date
Weiko f6b5bf8bd6 fix(sdk): restore nested defineFrontComponent (create command menu items) 2026-05-27 14:33:33 +02:00
34db7ac8b4 chore: sync AI model catalog from models.dev (#20948)
Automated daily sync of `ai-providers.json` from
[models.dev](https://models.dev).

This PR updates pricing, context windows, and model availability based
on the latest data.
New models meeting inclusion criteria (tool calling, pricing data,
context limits) are added automatically.
Deprecated models are detected based on cost-efficiency within the same
model family.

**Please review before merging** — verify no critical models were
incorrectly deprecated.

Co-authored-by: FelixMalfait <6399865+FelixMalfait@users.noreply.github.com>
2026-05-27 09:19:32 +02:00
EtienneandGitHub 4a82cddad6 remove ai-model-preferences var env and config (#20859)
Split the single AI_MODEL_PREFERENCES JSON config into 4 array configs
and migrates existing workspace data.
2026-05-27 06:47:47 +00:00
Abdullah.andGitHub 9d6c5b7d58 [Website] Restore shared build dependency for typecheck (#20936)
Should fix the CI failure on `typecheck` by building twenty-shared
first.
2026-05-26 19:33:04 +02:00
Paul RastoinandGitHub a2db0b6932 Fix index cache computation (#20933)
## Introduction
Should rely on custom typeorm entity loader layer that inspects the
upgradeMigration that has bene run to dynamically request existing col
only
2026-05-26 18:55:24 +02:00
EtienneandGitHub 5eb79e7797 fix(billing) - fix orphaned stripe subs 2/2 (#20916)
Fixes https://sonarly.com/issue/40688

Should have been included in
https://github.com/twentyhq/twenty/commit/0edd8d400c646cd6a40ff0fea5342a0f61645d5e
2026-05-26 16:28:21 +00:00
Paul RastoinandGitHub f19647617a Fix cross version upgrade for 2.8 (#20927) 2026-05-26 15:46:06 +00:00
Thomas TrompetteandGitHub 424c6737a1 Fix workflow step output schema not reflecting user-renamed step name (#20922)
## Summary
- Fixes https://github.com/twentyhq/twenty/issues/20906
- Moves `markStepForRecomputation` from `useUpdateStep` into the
lower-level `useUpdateWorkflowVersionStep` hook, so **every** caller
that updates a step also triggers output schema recomputation.
- Previously, renaming a step via the side panel title input called
`useUpdateWorkflowVersionStep` directly (bypassing `useUpdateStep`), so
the variable picker kept showing the initial default name (e.g. "Create
Record") instead of the user's custom name.

## Test plan
- [x] Rename a workflow step via the side panel title input
- [x] Verify the variable picker dropdown shows the updated name
- [x] Verify variable tags/chips in subsequent steps reflect the updated
name
- [x] Verify that updating step settings (e.g. changing object type)
still refreshes the output schema correctly
2026-05-26 15:38:36 +00:00
be8c25dfc2 i18n - translations (#20930)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-05-26 17:46:04 +02:00
Paul RastoinandGitHub b8de469f37 Refactor and centralize file mimeType integrity check and sanitization (#20889)
# Introduction
closes https://github.com/twentyhq/private-issues/issues/484

This PR refactors the writeFile API to never expect to be passed a
mimetype, its extract is done programmatically low level so any callers
will pass through
Same for the file sanitization

## IANA override
Disclaimer for consistency we existing behavior we wanted to always have
`application/typescript`
- should we rather consider fallbacking to octect-steam instead ?
- Any pulbic assets that has .ts will now also fallback to
`application/typescript` instead of the official IANA

## Integration
Added coverage
2026-05-26 15:10:52 +00:00
c74337978b i18n - docs translations (#20926)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-05-26 17:25:43 +02:00
059e75e532 chore: bump version to 2.9.0 (#20925)
## Summary

- Moves current version to previous versions array
- Sets TWENTY_CURRENT_VERSION to the new version
- Updates TWENTY_NEXT_VERSIONS with the next minor version
- Bumps twenty-client-sdk, twenty-sdk, and create-twenty-app to the same
version

## Checklist

- [ ] Verify version constants are correct
- [ ] Verify npm package versions match

Co-authored-by: Github Action Deploy <github-action-deploy@twenty.com>
2026-05-26 17:14:22 +02:00
bb5c2bd00c Fix/restore channel association scalar field metadata (#20920)
[#20836](https://github.com/twentyhq/twenty/pull/20836) dropped the
channel objects but even though
calendarChannelId/messageChannelId/messageFolderId already existed in
compute standard flat field, there was never an upgrade command to readd
them on the surviving association objects

so existing workspaces lost the field metadata (columns survived) and
import workers throw
```Error: Unknown error importing calendar events for calendar channel <REDACTED> in workspace <REDACTED>: Query runner already released. Cannot run queries anymore.```

This PR adds that command

---------

Co-authored-by: prastoin <paul@twenty.com>
2026-05-26 14:58:33 +00:00
a2673da164 i18n - translations (#20924)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-05-26 17:03:31 +02:00
WeikoandGitHub 08682fb3f5 Various fixes - some AI findings (#20921)
App permissions tab:
- The fallback uuidv4() for a marketplace field was generated twice, so
id and universalIdentifier could diverge; it's now computed once and
reused as it seemed to be the intention (even though I don't really
think it's a good idea)
- Renamed buildobjectMetadataItemsFromMarketplaceApp →
buildObjectMetadataItemsFromMarketplaceApp to follow camelCase.

Morph relation validation:
- Fixed the user-facing message "At least one relation is require" →
"...is required"
- Typos in the related test descriptions (Morh → Morph, samefield → same
field) and their snapshots.

Docs
- The UUID field-type row in views.mdx only listed IS; updated to the
full set supported by FILTER_OPERANDS_MAP (IS, IS_NOT, IS_EMPTY,
IS_NOT_EMPTY).
2026-05-26 14:43:50 +00:00
53d4e92dda [Website] Generate release notes manifest at build time (#20913)
Removed the releases page’s runtime dependency on `fs` and
`process.cwd()` by introducing a build-time manifest generator: release
notes still live as markdown under `src/content/releases`, but a new
script now parses their frontmatter/content, validates that each note
has a release, title, and preview image (and that the image actually
exists), sorts the notes, and emits a typed `generated-release-notes.ts`
file that the app imports at runtime.

Updated the releases loader to return that generated data, changed the
menu releases preview and release JSON-LD to use explicit typed fields
(`title`, `previewImage`) instead of scraping markdown with regex at
runtime, wired the generator into Nx so it runs automatically before
`dev`, `build`, and `typecheck`, and fixed two stale image references in
the release MDX files that the new validation exposed.

---------

Co-authored-by: prastoin <paul@twenty.com>
2026-05-26 16:46:11 +02:00
Rashad KaranouhandGitHub 53392f9a16 feat(twenty-partners): partnerContent catalog + TFT import improvements (#20904)
## Summary

Two related threads for the internal `twenty-partners` app:

1. **Redesign `partnerQuote` → `partnerContent`.** The object was
mis-modeled as a sales/pre-invoice doc (`amount`, opportunity link). In
TFT it's actually a marketing-content catalog — customer quotes, case
studies, partner quotes, logos — moving through a production lifecycle.
This renames it in place and reshapes it to mirror TFT's
`CustomerContent`.
2. **Import tooling improvements** to the TFT importer + multi-env
workflow.

## Changes

**Schema (`partnerContent`)**
- Rename `partnerQuote` → `partnerContent` (object, view, nav, relation
fields, identifiers).
- Add `contentType` MULTI_SELECT `[CUSTOMER_QUOTE, CASE_STUDY,
PARTNER_QUOTE, LOGO]` and `interview` LINKS.
- Add `customerCompany` / `customerPerson` relations; keep `partner`;
drop the `opportunity` link (TFT has none).
- Drop `amount`; rename the FILES field `quoteFile` → `documents`
(`attachments` is a reserved morph-relation name).

**Importer (`import-from-tft.ts`)**
- Import the full content catalog (all types), not just `PARTNER_QUOTE`.
- Map TFT `partnerTimezone` → `region`, default
`languagesSpoken=[ENGLISH]`, and set `deploymentExpertise=[SELF_HOST]`
when scope includes `HOSTING_ENVIRONMENT`.
- Filter to partner-relevant records only: opportunities linked to a
partner (20 of 164), content linked to a partner (10 of 22). Drops
general sales-pipeline / customer-only noise.
- Dedupe companies by **normalized domain** (Twenty's unique key), not
just name — fixes duplicate-entry crashes when the same company arrives
under different names.
- Progress logging throughout.

**Tooling**
- `purge-soft-deleted` script (soft-deleted rows block re-imports via
unique constraints).
- Multi-env script variants (`*:prod`) selected via `ENV_FILE`.

## Testing

Verified on a local Twenty instance and on `partner.twenty.com`:
- 122 partners, 20 partner-linked opportunities, 10 partner-linked
content (all types), 229 domain-deduped companies.
- Schema confirmed via metadata introspection; `yarn twenty typecheck`
clean.

## Notes

- Renaming an installed object isn't a pure in-place migration on a
server that already had `partnerQuote` — the working path is `uninstall
→ deploy → install` (safe here: prod had no data).

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-05-26 14:09:57 +00:00
213 changed files with 3413 additions and 1474 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "create-twenty-app",
"version": "2.8.0",
"version": "2.9.0",
"description": "Command-line interface to create Twenty application",
"main": "dist/cli.cjs",
"bin": "dist/cli.cjs",
@@ -1,6 +1,6 @@
{
"name": "twenty-partners",
"version": "0.1.0",
"version": "0.3.1",
"license": "MIT",
"engines": {
"node": "^24.5.0",
@@ -16,8 +16,13 @@
"test": "vitest run",
"test:watch": "vitest",
"seed": "tsx src/scripts/seed.ts",
"seed:prod": "ENV_FILE=.env.prod tsx src/scripts/seed.ts",
"purge": "tsx src/scripts/purge-soft-deleted.ts",
"purge:prod": "ENV_FILE=.env.prod tsx src/scripts/purge-soft-deleted.ts",
"import:dryrun": "tsx src/scripts/import-from-tft.ts",
"import:apply": "IMPORT_APPLY=1 tsx src/scripts/import-from-tft.ts"
"import:dryrun:prod": "ENV_FILE=.env.prod tsx src/scripts/import-from-tft.ts",
"import:apply": "IMPORT_APPLY=1 tsx src/scripts/import-from-tft.ts",
"import:apply:prod": "ENV_FILE=.env.prod IMPORT_APPLY=1 tsx src/scripts/import-from-tft.ts"
},
"dependencies": {
"twenty-client-sdk": "2.4.0",
@@ -3,7 +3,7 @@ export const APP_DESCRIPTION = '';
export const APPLICATION_UNIVERSAL_IDENTIFIER = 'e662fc1f-02c1-41ff-b8ba-c95a447b3965';
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER = 'ee18c3f3-ebe7-4c56-ad6d-aad555cc32db';
export const PARTNER_OBJECT_UNIVERSAL_IDENTIFIER = '39101b39-1c16-4148-9e82-45dc271bb90d';
export const PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER = '65172140-d377-41c1-a2ae-190e96fb79dd';
export const PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER = '65172140-d377-41c1-a2ae-190e96fb79dd';
export const ALL_PARTNERS_VIEW_UNIVERSAL_IDENTIFIER = '379b11d5-44d5-476b-ba7d-31d5f515c9b4';
export const ALL_MATCHED_DEALS_VIEW_UNIVERSAL_IDENTIFIER = '7a34da39-a8e1-44c7-88b4-91ceaa064920';
export const PARTNERS_NAV_UNIVERSAL_IDENTIFIER = '3fe15ab5-e38b-4914-af17-2270b210aeb2';
@@ -35,7 +35,7 @@ export const ALL_OPPORTUNITIES_NAV_UNIVERSAL_IDENTIFIER = '37944f52-cbe5-4814-a1
// Partner views + nav (harmonization)
export const PARTNER_APPLICATIONS_VIEW_UNIVERSAL_IDENTIFIER = 'b57a84ed-d7c1-420d-b0eb-348db0dac612';
export const VALIDATED_PARTNERS_VIEW_UNIVERSAL_IDENTIFIER = '13cca6a7-b9f1-4103-b011-ea2e39430899';
export const PARTNER_QUOTES_VIEW_UNIVERSAL_IDENTIFIER = 'd9db705c-795a-4a14-b891-6201149510b3';
export const PARTNER_CONTENT_VIEW_UNIVERSAL_IDENTIFIER = 'd9db705c-795a-4a14-b891-6201149510b3';
export const PARTNER_APPLICATIONS_NAV_UNIVERSAL_IDENTIFIER = '13e2334a-6b1e-4080-8c74-d11109990cc1';
export const VALIDATED_PARTNERS_NAV_UNIVERSAL_IDENTIFIER = '6aed30c6-d80f-4ac6-aab0-db5bc59e5c4b';
export const PARTNER_QUOTES_NAV_UNIVERSAL_IDENTIFIER = '3543723d-80c1-466a-ac35-86f7b284917b';
export const PARTNER_CONTENT_NAV_UNIVERSAL_IDENTIFIER = '3543723d-80c1-466a-ac35-86f7b284917b';
@@ -0,0 +1,22 @@
import { FieldType, OnDeleteAction, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
import { PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export const PARTNER_CONTENT_CUSTOMER_COMPANY_FIELD_ID = 'd448f6be-533f-4c43-a70f-55dc361dcdd7';
export const PARTNER_CONTENTS_ON_COMPANY_FIELD_ID = '941e7707-1b3d-47d4-b13a-45c6628c1b3d';
export default defineField({
universalIdentifier: PARTNER_CONTENT_CUSTOMER_COMPANY_FIELD_ID,
objectUniversalIdentifier: PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'customerCompany',
label: 'Customer Company',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier: PARTNER_CONTENTS_ON_COMPANY_FIELD_ID,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'customerCompanyId',
},
});
@@ -0,0 +1,22 @@
import { FieldType, OnDeleteAction, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
import { PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export const PARTNER_CONTENT_CUSTOMER_PERSON_FIELD_ID = 'f17547be-76aa-4231-bd1a-3f57bb1ae323';
export const PARTNER_CONTENTS_ON_PERSON_FIELD_ID = 'fae42f4e-b054-4267-a761-81a6792f7c12';
export default defineField({
universalIdentifier: PARTNER_CONTENT_CUSTOMER_PERSON_FIELD_ID,
objectUniversalIdentifier: PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'customerPerson',
label: 'Customer Person',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier: PARTNER_CONTENTS_ON_PERSON_FIELD_ID,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'customerPersonId',
},
});
@@ -0,0 +1,22 @@
import { FieldType, OnDeleteAction, RelationType, defineField } from 'twenty-sdk/define';
import { PARTNER_OBJECT_UNIVERSAL_IDENTIFIER, PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export const PARTNER_CONTENT_PARTNER_FIELD_ID = '1774738e-96a1-43e2-aca4-d3c7cc794c50';
export const PARTNER_CONTENTS_ON_PARTNER_FIELD_ID = 'ac7995f1-7ccf-4607-827b-0788eeacaee0';
export default defineField({
universalIdentifier: PARTNER_CONTENT_PARTNER_FIELD_ID,
objectUniversalIdentifier: PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'partner',
label: 'Partner',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier: PARTNER_CONTENTS_ON_PARTNER_FIELD_ID,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'partnerId',
},
});
@@ -0,0 +1,18 @@
import { FieldType, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
import { PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { PARTNER_CONTENTS_ON_COMPANY_FIELD_ID, PARTNER_CONTENT_CUSTOMER_COMPANY_FIELD_ID } from './partner-content-customer-company.field';
export default defineField({
universalIdentifier: PARTNER_CONTENTS_ON_COMPANY_FIELD_ID,
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
type: FieldType.RELATION,
name: 'partnerContents',
label: 'Partner Content',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier: PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier: PARTNER_CONTENT_CUSTOMER_COMPANY_FIELD_ID,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -0,0 +1,18 @@
import { FieldType, RelationType, defineField } from 'twenty-sdk/define';
import { PARTNER_OBJECT_UNIVERSAL_IDENTIFIER, PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { PARTNER_CONTENTS_ON_PARTNER_FIELD_ID, PARTNER_CONTENT_PARTNER_FIELD_ID } from './partner-content-partner.field';
export default defineField({
universalIdentifier: PARTNER_CONTENTS_ON_PARTNER_FIELD_ID,
objectUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'partnerContents',
label: 'Partner Content',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier: PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier: PARTNER_CONTENT_PARTNER_FIELD_ID,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -0,0 +1,18 @@
import { FieldType, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
import { PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { PARTNER_CONTENTS_ON_PERSON_FIELD_ID, PARTNER_CONTENT_CUSTOMER_PERSON_FIELD_ID } from './partner-content-customer-person.field';
export default defineField({
universalIdentifier: PARTNER_CONTENTS_ON_PERSON_FIELD_ID,
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
type: FieldType.RELATION,
name: 'partnerContents',
label: 'Partner Content',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier: PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier: PARTNER_CONTENT_CUSTOMER_PERSON_FIELD_ID,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -1,22 +0,0 @@
import { FieldType, OnDeleteAction, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
import { PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export const PARTNER_QUOTE_OPPORTUNITY_FIELD_ID = '36684f3e-f01a-4270-8f34-78966747dd64';
export const PARTNER_QUOTES_ON_OPPORTUNITY_FIELD_ID = '4857f339-a051-4cdb-bd8a-6219b933d1ce';
export default defineField({
universalIdentifier: PARTNER_QUOTE_OPPORTUNITY_FIELD_ID,
objectUniversalIdentifier: PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'opportunity',
label: 'Opportunity',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier: PARTNER_QUOTES_ON_OPPORTUNITY_FIELD_ID,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'opportunityId',
},
});
@@ -1,22 +0,0 @@
import { FieldType, OnDeleteAction, RelationType, defineField } from 'twenty-sdk/define';
import { PARTNER_OBJECT_UNIVERSAL_IDENTIFIER, PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export const PARTNER_QUOTE_PARTNER_FIELD_ID = '1774738e-96a1-43e2-aca4-d3c7cc794c50';
export const PARTNER_QUOTES_ON_PARTNER_FIELD_ID = 'ac7995f1-7ccf-4607-827b-0788eeacaee0';
export default defineField({
universalIdentifier: PARTNER_QUOTE_PARTNER_FIELD_ID,
objectUniversalIdentifier: PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'partner',
label: 'Partner',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier: PARTNER_QUOTES_ON_PARTNER_FIELD_ID,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'partnerId',
},
});
@@ -1,18 +0,0 @@
import { FieldType, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
import { PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { PARTNER_QUOTES_ON_OPPORTUNITY_FIELD_ID, PARTNER_QUOTE_OPPORTUNITY_FIELD_ID } from './partner-quote-opportunity.field';
export default defineField({
universalIdentifier: PARTNER_QUOTES_ON_OPPORTUNITY_FIELD_ID,
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
type: FieldType.RELATION,
name: 'partnerQuotes',
label: 'Partner Quotes',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier: PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier: PARTNER_QUOTE_OPPORTUNITY_FIELD_ID,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -1,18 +0,0 @@
import { FieldType, RelationType, defineField } from 'twenty-sdk/define';
import { PARTNER_OBJECT_UNIVERSAL_IDENTIFIER, PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { PARTNER_QUOTES_ON_PARTNER_FIELD_ID, PARTNER_QUOTE_PARTNER_FIELD_ID } from './partner-quote-partner.field';
export default defineField({
universalIdentifier: PARTNER_QUOTES_ON_PARTNER_FIELD_ID,
objectUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'partnerQuotes',
label: 'Partner Quotes',
isNullable: true,
relationTargetObjectMetadataUniversalIdentifier: PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier: PARTNER_QUOTE_PARTNER_FIELD_ID,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -1,15 +1,15 @@
import { NavigationMenuItemType, defineNavigationMenuItem } from 'twenty-sdk/define';
import {
PARTNER_QUOTES_NAV_UNIVERSAL_IDENTIFIER,
PARTNER_QUOTES_VIEW_UNIVERSAL_IDENTIFIER,
PARTNER_CONTENT_NAV_UNIVERSAL_IDENTIFIER,
PARTNER_CONTENT_VIEW_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineNavigationMenuItem({
universalIdentifier: PARTNER_QUOTES_NAV_UNIVERSAL_IDENTIFIER,
universalIdentifier: PARTNER_CONTENT_NAV_UNIVERSAL_IDENTIFIER,
type: NavigationMenuItemType.VIEW,
icon: 'IconFileDollar',
icon: 'IconQuote',
position: 3,
folderUniversalIdentifier: '857be3b5-82c6-45f7-b546-e20a8a97be8d',
viewUniversalIdentifier: PARTNER_QUOTES_VIEW_UNIVERSAL_IDENTIFIER,
viewUniversalIdentifier: PARTNER_CONTENT_VIEW_UNIVERSAL_IDENTIFIER,
});
@@ -1,15 +1,15 @@
import { FieldType, defineObject } from 'twenty-sdk/define';
import { PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export default defineObject({
universalIdentifier: PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER,
nameSingular: 'partnerQuote',
namePlural: 'partnerQuotes',
labelSingular: 'Partner Quote',
labelPlural: 'Partner Quotes',
description: 'A quote a partner submitted for a customer deal',
icon: 'IconFileDollar',
universalIdentifier: PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER,
nameSingular: 'partnerContent',
namePlural: 'partnerContents',
labelSingular: 'Partner Content',
labelPlural: 'Partner Content',
description: 'Marketing content involving a partner: quotes, case studies, logos',
icon: 'IconQuote',
isSearchable: true,
labelIdentifierFieldMetadataUniversalIdentifier: '9e688624-83d2-4715-8b18-80492a6de2b6',
fields: [
@@ -21,6 +21,20 @@ export default defineObject({
icon: 'IconTag',
defaultValue: "''",
},
{
universalIdentifier: '1d926e6e-6ac1-4d60-ab3d-a73114005692',
type: FieldType.MULTI_SELECT,
name: 'contentType',
label: 'Content Type',
icon: 'IconCategory',
isNullable: true,
options: [
{ id: '07f85e5b-0d70-416b-9884-256e469ed532', value: 'CUSTOMER_QUOTE', label: 'Customer quote', position: 0, color: 'blue' },
{ id: '108b2358-d04d-4fdc-83df-a5978d39f66f', value: 'CASE_STUDY', label: 'Case study', position: 1, color: 'green' },
{ id: 'eb45f371-f93c-4c45-9c8a-f29e1a58b7e4', value: 'PARTNER_QUOTE', label: 'Partner quote', position: 2, color: 'orange' },
{ id: '3356c8a0-41cd-47c3-a293-5862138abc1a', value: 'LOGO', label: 'Logo', position: 3, color: 'purple' },
],
},
{
universalIdentifier: 'a0fe09c4-c1f4-4b96-93c6-d7ec38f1166a',
type: FieldType.SELECT,
@@ -45,21 +59,21 @@ export default defineObject({
isNullable: true,
},
{
universalIdentifier: '2cbe67e3-24ec-421b-bab5-50f3306c2391',
type: FieldType.CURRENCY,
name: 'amount',
label: 'Amount',
icon: 'IconCoin',
universalIdentifier: 'da7e9094-e2c3-47d3-924f-a1d4d3c717ed',
type: FieldType.LINKS,
name: 'interview',
label: 'Interview',
icon: 'IconMicrophone',
isNullable: true,
},
{
universalIdentifier: 'f303369e-288c-4a48-9920-c1de0ad9a159',
type: FieldType.FILES,
name: 'quoteFile',
label: 'Quote File',
name: 'documents',
label: 'Documents',
icon: 'IconPaperclip',
isNullable: true,
universalSettings: { maxNumberOfValues: 1 },
universalSettings: { maxNumberOfValues: 10 },
},
],
});
@@ -23,7 +23,7 @@
// tsx src/scripts/import-from-tft.ts
import { config } from 'dotenv';
config({ path: '.env.local' });
config({ path: process.env.ENV_FILE ?? '.env.local' });
import { CoreApiClient } from 'twenty-client-sdk/core';
@@ -76,6 +76,17 @@ const PARTNER_STAGE_TO_VALIDATION: Record<string, string> = {
REJECTED: 'REJECTED',
};
// TFT person.partnerTimezone -> our Partner.region (MULTI_SELECT). TFT's value is a
// coarse timezone band, not a geography, so each maps to every region it plausibly
// covers. OTHER carries no signal -> no region. Region stays empty if unmapped.
const TIMEZONE_TO_REGION: Record<string, string[]> = {
AMERICAS: ['US', 'LATAM'],
EMEA: ['EUROPE', 'MENA', 'AFRICA'],
WEST_ASIA: ['MENA', 'APAC'],
EAST_ASIA_OCEANIA: ['APAC'],
OTHER: [],
};
// Local SELECT option sets, for preflight coverage checks (a TFT value not in
// these would fail a real write). Keep in sync with src/objects + src/fields.
const LOCAL_OPTIONS: Record<string, Set<string>> = {
@@ -101,6 +112,30 @@ const edges = (result: any, key: string): any[] =>
const uniq = (values: (string | undefined | null)[]): string[] =>
[...new Set(values.filter((v): v is string => !!v))];
// Normalize a domain for dedup. Twenty's company domain is a unique key but is
// stored with an https:// prefix, while TFT values vary, so compare on a canonical
// form (no protocol, no www, no trailing slash, lowercased).
const normDomain = (d?: string | null): string | undefined =>
d
? d.trim().toLowerCase().replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/+$/, '') || undefined
: undefined;
// Distinct non-empty values across rows, for the preflight report. Flattens array
// fields (e.g. partnerScope, typeCustom) and stringifies, so scalar and
// multi-select fields share one path. Derived from the already-fetched source
// rows — no per-loop bookkeeping needed.
const distinct = <TRow>(rows: TRow[], pick: (row: TRow) => unknown): string[] =>
[
...new Set(
rows.flatMap((row) => {
const value = pick(row);
return Array.isArray(value) ? value : value != null ? [value] : [];
}),
),
]
.map(String)
.sort();
async function main() {
console.log(`[import] mode: ${APPLY ? 'APPLY (writing to local)' : 'DRY-RUN (no writes)'}`);
const local = new CoreApiClient({
@@ -114,26 +149,10 @@ async function main() {
if (APPLY && writes++ > 0) await new Promise((r) => setTimeout(r, 750));
};
// Distinct TFT values seen, for the preflight coverage report.
const seen: Record<string, Set<string>> = {
partnerStage: new Set(),
partnerTier: new Set(),
partnerScope: new Set(),
typeOfTeam: new Set(),
oppStage: new Set(),
hostingType: new Set(),
subscriptionType: new Set(),
subscriptionFrequency: new Set(),
quoteStatus: new Set(),
typeCustom: new Set(),
};
const note = (bucket: string, value?: string | null) => {
if (value) seen[bucket].add(value);
};
// ---------------------------------------------------------------------
// 1. Read all three TFT datasets (raw fetch; not rate-limited by local).
// ---------------------------------------------------------------------
console.log('[import] fetching TFT people...');
const tftPeople = edges(
await tftQuery(`query {
people(first: 500, filter: { partnerStage: { in: ["APPLICATION","POTENTIAL_PARTNER","PARTNER","FORMER_PARTNER","REJECTED"] } }) {
@@ -143,7 +162,7 @@ async function main() {
emails { primaryEmail }
city jobTitle
linkedinLink { primaryLinkUrl }
partnerStage partnerTier partnerScope partnerTypeOfTeam partnerIsAvailable partnerSkills
partnerStage partnerTier partnerScope partnerTypeOfTeam partnerTimezone partnerIsAvailable partnerSkills
partnerBudgetMinimum { amountMicros currencyCode }
partnerBudgetAverage { amountMicros currencyCode }
company { id name domainName { primaryLinkUrl } }
@@ -152,7 +171,8 @@ async function main() {
}`),
'people',
);
const tftOpps = edges(
console.log('[import] fetching TFT opportunities...');
const tftOppsAll = edges(
await tftQuery(`query {
opportunities(first: 500) {
edges { node {
@@ -166,16 +186,32 @@ async function main() {
}`),
'opportunities',
);
// Only import opportunities linked to a partner. The rest is TFT's general sales
// pipeline (mostly LOST/IDENTIFIED) — noise for a partners app. Every partner
// stage (INTRODUCED/WORKING) only ever appears on partner-linked opps anyway.
const tftOpps = tftOppsAll.filter((o: any) => o.partner?.id);
console.log(`[import] opportunities: ${tftOpps.length} partner-linked of ${tftOppsAll.length} total (skipping ${tftOppsAll.length - tftOpps.length} unlinked)`);
console.log('[import] fetching TFT partner content...');
const tftContent = edges(
await tftQuery(`query {
customerContents(first: 500) {
edges { node { id name status approvalDate typeCustom partnerPerson { id } } }
edges { node {
id name status approvalDate typeCustom
interview { primaryLinkUrl }
partnerPerson { id }
customerCompany { id name domainName { primaryLinkUrl } }
customerPerson { id }
} }
}
}`),
'customerContents',
);
const quotes = tftContent.filter((c: any) => Array.isArray(c.typeCustom) && c.typeCustom.includes('PARTNER_QUOTE'));
console.log(`[import] TFT: ${tftPeople.length} partner-people, ${tftOpps.length} opportunities, ${quotes.length} partner quotes`);
// Import all content TYPES (quotes, case studies, logos) but only records that
// involve a partner. Customer-only content (no partnerPerson) is noise for the
// partners app; a partner-linked case study/quote should show on the partner.
const contentRecords = tftContent.filter((c: any) => c.partnerPerson?.id);
console.log(`[import] TFT: ${tftPeople.length} partner-people, ${tftOpps.length} opportunities, ${tftContent.length} content records`);
console.log(`[import] partner content: ${contentRecords.length} partner-linked of ${tftContent.length} total (skipping ${tftContent.length - contentRecords.length} customer-only)`);
const personSlug = (p: any): string =>
slugify([p.name?.firstName, p.name?.lastName].filter(Boolean).join(' ').trim() || 'Unknown partner') || p.id;
@@ -183,6 +219,7 @@ async function main() {
// ---------------------------------------------------------------------
// 2. Batched existence lookups against local (one `in` query per object).
// ---------------------------------------------------------------------
console.log('[import] checking existing records in target workspace...');
const partnerSlugs = uniq(tftPeople.map(personSlug));
const partnerIdBySlug = new Map<string, string>(
partnerSlugs.length
@@ -195,17 +232,33 @@ async function main() {
: [],
);
const companyNames = uniq([...tftPeople.map((p: any) => p.company?.name), ...tftOpps.map((o: any) => o.company?.name)]);
const companyIdByName = new Map<string, string>(
companyNames.length
? edges(
await local.query({
companies: { __args: { filter: { name: { in: companyNames } }, first: 500 }, edges: { node: { id: true, name: true } } },
} as any),
'companies',
).map((n: any) => [n.name, n.id])
: [],
);
// Fetch existing companies and index by BOTH name and normalized domain. Twenty
// enforces uniqueness on domain, so dedup must be domain-aware: the same company
// can arrive under different names (e.g. "Acme" vs "acme.com") across TFT people,
// opps and content, and creating a second one collides on the domain constraint.
// Page through ALL companies (not just the first 500): these dedupe maps must
// be complete, or upsertCompany would create domain-colliding duplicates for
// companies that live beyond the first page in a larger workspace.
const existingCompanies: any[] = [];
let companiesCursor: string | undefined;
for (;;) {
const page: any = await local.query({
companies: {
__args: { filter: {}, first: 200, ...(companiesCursor ? { after: companiesCursor } : {}) },
edges: { node: { id: true, name: true, domainName: { primaryLinkUrl: true } } },
pageInfo: { hasNextPage: true, endCursor: true },
},
} as any);
existingCompanies.push(...edges(page, 'companies'));
if (!page?.companies?.pageInfo?.hasNextPage) break;
companiesCursor = page.companies.pageInfo.endCursor;
}
const companyIdByName = new Map<string, string>(existingCompanies.map((n: any) => [n.name, n.id]));
const companyIdByDomain = new Map<string, string>();
for (const c of existingCompanies) {
const nd = normDomain(c.domainName?.primaryLinkUrl);
if (nd && !companyIdByDomain.has(nd)) companyIdByDomain.set(nd, c.id);
}
const oppTftIds = uniq(tftOpps.filter((o: any) => o.name).map((o: any) => o.id));
const oppIdByTftId = new Map<string, string>(
@@ -219,14 +272,18 @@ async function main() {
: [],
);
const quoteNames = uniq(quotes.map((q: any) => q.name || 'Partner quote'));
const quoteIdByName = new Map<string, string>(
quoteNames.length
// Unnamed content upserts by name, so a constant fallback would make every
// unnamed record collide on one key (collapsing them on re-run). Key the
// fallback on the TFT id so each unnamed record stays distinct.
const contentName = (c: any): string => c.name || `Partner content ${c.id}`;
const contentNames = uniq(contentRecords.map(contentName));
const contentIdByName = new Map<string, string>(
contentNames.length
? edges(
await local.query({
partnerQuotes: { __args: { filter: { name: { in: quoteNames } }, first: 500 }, edges: { node: { id: true, name: true } } },
partnerContents: { __args: { filter: { name: { in: contentNames } }, first: 500 }, edges: { node: { id: true, name: true } } },
} as any),
'partnerQuotes',
'partnerContents',
).map((n: any) => [n.name, n.id])
: [],
);
@@ -238,43 +295,102 @@ async function main() {
const upsertCompany = async (name?: string, domain?: string): Promise<string | undefined> => {
if (!name) return undefined;
if (companyIdByName.has(name)) return companyIdByName.get(name);
const nd = normDomain(domain);
// Same company under a different name but same domain — reuse it.
if (nd && companyIdByDomain.has(nd)) {
const existingId = companyIdByDomain.get(nd) as string;
companyIdByName.set(name, existingId);
return existingId;
}
let id = `dry:company:${name}`;
if (APPLY) {
await pace();
const created: any = await local.mutation({
createCompany: {
__args: { data: { name, ...(domain ? { domainName: { primaryLinkUrl: domain } } : {}) } },
id: true,
},
} as any);
id = created.createCompany.id;
try {
const created: any = await local.mutation({
createCompany: {
__args: { data: { name, ...(domain ? { domainName: { primaryLinkUrl: domain } } : {}) } },
id: true,
},
} as any);
id = created.createCompany.id;
} catch (err) {
// Fallback: createCompany failed, almost certainly because the domain
// already exists (Twenty enforces a unique domain) on a company we
// didn't index. Re-find it and reuse instead of failing the import.
// `ilike` is a substring match — and the stored value carries an
// https:// prefix so we can't `eq` the normalized form — so it can
// return the wrong company ("acme.com" also matches "notacme.com" or
// "acme.com.br"). Confirm an exact normalized-domain match before reuse.
if (!nd) throw err;
await pace();
const candidates = edges(
await local.query({
companies: { __args: { filter: { domainName: { primaryLinkUrl: { ilike: `%${nd}%` } } }, first: 20 }, edges: { node: { id: true, domainName: { primaryLinkUrl: true } } } },
} as any),
'companies',
);
const match = candidates.find((c: any) => normDomain(c.domainName?.primaryLinkUrl) === nd);
if (!match?.id) throw err;
id = match.id;
console.log(`[import] company "${name}" reused existing by domain ${nd}`);
}
}
companyIdByName.set(name, id);
if (nd) companyIdByDomain.set(nd, id);
return id;
};
const budgetCurrency = (amount: any) =>
amount?.amountMicros != null ? { amountMicros: amount.amountMicros, currencyCode: amount.currencyCode ?? 'USD' } : undefined;
// Generic create/update dispatch shared by partners, opportunities and content.
// Owns pacing + APPLY-gating + the create-vs-update branch, so each loop below
// only builds its `data`. genql keys a mutation by the object name, so the
// create<Object>/update<Object> names are derived from one argument. Companies
// keep their own upsert (above) because of the domain-collision fallback.
// Returns the row id: the real id on APPLY, a synthetic dry id otherwise so the
// relation mapping in dry-run still resolves to a stable placeholder.
const upsert = async (
object: 'Partner' | 'Opportunity' | 'PartnerContent',
existingId: string | undefined,
data: Record<string, unknown>,
dryKey: string,
): Promise<string> => {
if (!APPLY) return existingId ?? `dry:${object}:${dryKey}`;
await pace();
if (existingId) {
await local.mutation({ [`update${object}`]: { __args: { id: existingId, data }, id: true } } as any);
return existingId;
}
const created: any = await local.mutation({ [`create${object}`]: { __args: { data }, id: true } } as any);
return created[`create${object}`].id;
};
// -- Partners (upsert by slug) --
console.log(`[import] upserting ${tftPeople.length} partners...`);
const localPartnerIdByTftPersonId = new Map<string, string>();
let partnersCreated = 0;
let partnersUpdated = 0;
let partnersDone = 0;
for (const p of tftPeople) {
note('partnerStage', p.partnerStage);
note('partnerTier', p.partnerTier);
(Array.isArray(p.partnerScope) ? p.partnerScope : []).forEach((s: string) => note('partnerScope', s));
note('typeOfTeam', p.partnerTypeOfTeam);
const slug = personSlug(p);
const companyId = await upsertCompany(p.company?.name, p.company?.domainName?.primaryLinkUrl);
// Timezone band -> geographic region(s). Unmapped/OTHER -> no region.
const region = TIMEZONE_TO_REGION[p.partnerTimezone] ?? [];
// A partner scoped for hosting is, by definition, a self-host expert.
const scope = Array.isArray(p.partnerScope) ? p.partnerScope : [];
const deploymentExpertise = scope.includes('HOSTING_ENVIRONMENT') ? ['SELF_HOST'] : [];
const data: Record<string, unknown> = {
name: [p.name?.firstName, p.name?.lastName].filter(Boolean).join(' ').trim() || 'Unknown partner',
slug,
validationStage: PARTNER_STAGE_TO_VALIDATION[p.partnerStage] ?? 'APPLICATION',
availability: p.partnerIsAvailable ? 'AVAILABLE' : 'UNAVAILABLE',
// TFT has no language data; default everyone to English.
languagesSpoken: ['ENGLISH'],
...(p.partnerTier ? { partnerTier: p.partnerTier } : {}),
...(Array.isArray(p.partnerScope) && p.partnerScope.length ? { partnerScope: p.partnerScope } : {}),
...(scope.length ? { partnerScope: scope } : {}),
...(region.length ? { region } : {}),
...(deploymentExpertise.length ? { deploymentExpertise } : {}),
...(p.partnerTypeOfTeam ? { typeOfTeam: p.partnerTypeOfTeam } : {}),
...(Array.isArray(p.partnerSkills) && p.partnerSkills.length ? { skills: p.partnerSkills } : {}),
...(p.city ? { city: p.city } : {}),
@@ -284,39 +400,29 @@ async function main() {
...(companyId && APPLY ? { companyId } : {}),
};
let partnerId = partnerIdBySlug.get(slug);
if (partnerId) {
if (APPLY) {
await pace();
await local.mutation({ updatePartner: { __args: { id: partnerId, data }, id: true } } as any);
}
const existingId = partnerIdBySlug.get(slug);
const partnerId = await upsert('Partner', existingId, data, slug);
if (existingId) {
partnersUpdated++;
} else {
if (APPLY) {
await pace();
const created: any = await local.mutation({ createPartner: { __args: { data }, id: true } } as any);
partnerId = created.createPartner.id;
} else {
partnerId = `dry:partner:${slug}`;
}
partnerIdBySlug.set(slug, partnerId as string);
partnerIdBySlug.set(slug, partnerId);
partnersCreated++;
}
localPartnerIdByTftPersonId.set(p.id, partnerId as string);
localPartnerIdByTftPersonId.set(p.id, partnerId);
partnersDone++;
if (partnersDone % 10 === 0 || partnersDone === tftPeople.length)
console.log(`[import] partners ${partnersDone}/${tftPeople.length} (created=${partnersCreated} updated=${partnersUpdated})`);
}
console.log(`[import] partners created=${partnersCreated} updated=${partnersUpdated}`);
console.log(`[import] partners done: created=${partnersCreated} updated=${partnersUpdated}`);
// -- Opportunities (upsert by tftOpportunityId) --
console.log(`[import] upserting ${tftOpps.length} opportunities...`);
let oppsCreated = 0;
let oppsUpdated = 0;
let oppsPartnerLinked = 0;
let oppsDone = 0;
for (const o of tftOpps) {
if (!o.name) continue;
note('oppStage', o.stage);
note('hostingType', o.hostingType);
note('subscriptionType', o.subscriptionType);
note('subscriptionFrequency', o.subscriptionFrequence);
const companyId = await upsertCompany(o.company?.name, o.company?.domainName?.primaryLinkUrl);
const partnerId = o.partner?.id ? localPartnerIdByTftPersonId.get(o.partner.id) : undefined;
if (partnerId) oppsPartnerLinked++;
@@ -338,58 +444,44 @@ async function main() {
};
const existingId = oppIdByTftId.get(o.id);
if (existingId) {
if (APPLY) {
await pace();
await local.mutation({ updateOpportunity: { __args: { id: existingId, data }, id: true } } as any);
}
oppsUpdated++;
} else {
if (APPLY) {
await pace();
await local.mutation({ createOpportunity: { __args: { data }, id: true } } as any);
}
oppsCreated++;
}
await upsert('Opportunity', existingId, data, o.id);
if (existingId) oppsUpdated++;
else oppsCreated++;
oppsDone++;
if (oppsDone % 20 === 0 || oppsDone === tftOpps.length)
console.log(`[import] opportunities ${oppsDone}/${tftOpps.length} (created=${oppsCreated} updated=${oppsUpdated})`);
}
console.log(`[import] opportunities created=${oppsCreated} updated=${oppsUpdated} (partner-linked=${oppsPartnerLinked})`);
console.log(`[import] opportunities done: created=${oppsCreated} updated=${oppsUpdated} (partner-linked=${oppsPartnerLinked})`);
// -- Partner quotes (upsert by name) --
let quotesCreated = 0;
let quotesUpdated = 0;
tftContent.forEach((c: any) => (Array.isArray(c.typeCustom) ? c.typeCustom : []).forEach((t: string) => note('typeCustom', t)));
for (const q of quotes) {
note('quoteStatus', q.status);
const name = q.name || 'Partner quote';
const partnerId = q.partnerPerson?.id ? localPartnerIdByTftPersonId.get(q.partnerPerson.id) : undefined;
// -- Partner content (upsert by name) --
console.log(`[import] upserting ${contentRecords.length} content records...`);
let contentCreated = 0;
let contentUpdated = 0;
for (const c of contentRecords) {
const name = contentName(c);
const partnerId = c.partnerPerson?.id ? localPartnerIdByTftPersonId.get(c.partnerPerson.id) : undefined;
const customerCompanyId = await upsertCompany(c.customerCompany?.name, c.customerCompany?.domainName?.primaryLinkUrl);
const data: Record<string, unknown> = {
name,
...(q.status ? { status: q.status } : {}),
...(q.approvalDate ? { approvalDate: q.approvalDate } : {}),
...(Array.isArray(c.typeCustom) && c.typeCustom.length ? { contentType: c.typeCustom } : {}),
...(c.status ? { status: c.status } : {}),
...(c.approvalDate ? { approvalDate: c.approvalDate } : {}),
...(c.interview?.primaryLinkUrl ? { interview: { primaryLinkUrl: c.interview.primaryLinkUrl } } : {}),
...(partnerId && APPLY ? { partnerId } : {}),
...(customerCompanyId && APPLY ? { customerCompanyId } : {}),
};
const existingId = quoteIdByName.get(name);
if (existingId) {
if (APPLY) {
await pace();
await local.mutation({ updatePartnerQuote: { __args: { id: existingId, data }, id: true } } as any);
}
quotesUpdated++;
} else {
if (APPLY) {
await pace();
await local.mutation({ createPartnerQuote: { __args: { data }, id: true } } as any);
}
quotesCreated++;
}
const existingId = contentIdByName.get(name);
await upsert('PartnerContent', existingId, data, name);
if (existingId) contentUpdated++;
else contentCreated++;
}
console.log(`[import] partner quotes created=${quotesCreated} updated=${quotesUpdated}`);
console.log(`[import] partner content created=${contentCreated} updated=${contentUpdated}`);
// ---------------------------------------------------------------------
// 4. Preflight: distinct TFT values vs local option coverage.
// 4. Preflight: distinct TFT values vs local option coverage. Derived
// directly from the fetched source rows (no per-loop bookkeeping).
// ---------------------------------------------------------------------
const report = (label: string, bucket: string, optionKey?: string) => {
const values = [...seen[bucket]].sort();
const report = (label: string, values: string[], optionKey?: string) => {
const allowed = optionKey ? LOCAL_OPTIONS[optionKey] : undefined;
const uncovered = allowed ? values.filter((v) => !allowed.has(v)) : [];
console.log(
@@ -397,21 +489,27 @@ async function main() {
(uncovered.length ? ` ⚠️ NOT IN LOCAL OPTIONS: ${uncovered.join(', ')}` : ''),
);
};
const partnerStages = distinct(tftPeople, (p: any) => p.partnerStage);
const oppStages = distinct(tftOpps, (o: any) => o.stage);
const timezones = distinct(tftPeople, (p: any) => p.partnerTimezone);
console.log('--- preflight: distinct TFT values seen ---');
report('partnerStage (-> validationStage map)', 'partnerStage');
report('partnerTier', 'partnerTier', 'partnerTier');
report('partnerScope', 'partnerScope', 'partnerScope');
report('typeOfTeam', 'typeOfTeam', 'typeOfTeam');
report('opp stage (-> matchStatus map)', 'oppStage');
report('hostingType', 'hostingType', 'hostingType');
report('subscriptionType', 'subscriptionType', 'subscriptionType');
report('subscriptionFrequency', 'subscriptionFrequency', 'subscriptionFrequency');
report('quote status', 'quoteStatus', 'quoteStatus');
report('customerContent typeCustom', 'typeCustom');
const unmappedStages = [...seen.partnerStage].filter((s) => !(s in PARTNER_STAGE_TO_VALIDATION));
const unmappedOpps = [...seen.oppStage].filter((s) => !(s in STAGE_TO_MATCH_STATUS));
report('partnerStage (-> validationStage map)', partnerStages);
report('partnerTier', distinct(tftPeople, (p: any) => p.partnerTier), 'partnerTier');
report('partnerScope', distinct(tftPeople, (p: any) => p.partnerScope), 'partnerScope');
report('typeOfTeam', distinct(tftPeople, (p: any) => p.partnerTypeOfTeam), 'typeOfTeam');
report('partnerTimezone (-> region map)', timezones);
report('opp stage (-> matchStatus map)', oppStages);
report('hostingType', distinct(tftOpps, (o: any) => o.hostingType), 'hostingType');
report('subscriptionType', distinct(tftOpps, (o: any) => o.subscriptionType), 'subscriptionType');
report('subscriptionFrequency', distinct(tftOpps, (o: any) => o.subscriptionFrequence), 'subscriptionFrequency');
report('quote status', distinct(contentRecords, (c: any) => c.status), 'quoteStatus');
report('customerContent typeCustom', distinct(contentRecords, (c: any) => c.typeCustom));
const unmappedStages = partnerStages.filter((s) => !(s in PARTNER_STAGE_TO_VALIDATION));
const unmappedOpps = oppStages.filter((s) => !(s in STAGE_TO_MATCH_STATUS));
const unmappedTz = timezones.filter((t) => !(t in TIMEZONE_TO_REGION));
if (unmappedStages.length) console.log(`[preflight] ⚠️ partnerStage not mapped: ${unmappedStages.join(', ')}`);
if (unmappedOpps.length) console.log(`[preflight] ⚠️ opp stage not mapped: ${unmappedOpps.join(', ')}`);
if (unmappedTz.length) console.log(`[preflight] ⚠️ partnerTimezone not mapped: ${unmappedTz.join(', ')}`);
}
main().catch((err) => {
@@ -0,0 +1,59 @@
// Hard-destroy soft-deleted records that block re-imports.
//
// Twenty SOFT-deletes (sets deletedAt); the row stays in the DB and keeps holding
// unique constraints (e.g. company domain, partner slug). But normal queries —
// including the import's existence checks — exclude soft-deleted rows. So after a
// UI "delete" or a partial import that got rolled back, re-running the import hits
// "A duplicate entry was detected" on records it cannot see. This purges those
// ghosts permanently so idempotent upserts work again.
//
// Only touches soft-deleted rows (deletedAt IS NOT NULL); active/default data is
// left untouched. One bulk destroy per object, so it is not rate-limited.
//
// yarn purge # against .env.local
// yarn purge:prod # against .env.prod
//
import { config } from 'dotenv';
config({ path: process.env.ENV_FILE ?? '.env.local' });
const requireEnv = (name: string): string => {
const value = process.env[name];
if (!value) throw new Error(`Missing ${name} env var`);
return value;
};
// Objects the import writes to. partners + partnerContents are app custom objects;
// companies + opportunities are standard but populated by the import.
const OBJECTS = ['companies', 'partners', 'opportunities', 'partnerContents'] as const;
const gql = async (url: string, key: string, query: string): Promise<any> => {
const response = await fetch(`${url.replace(/\/$/, '')}/graphql`, {
method: 'POST',
headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ query }),
});
const json: any = await response.json();
if (json.errors?.length) throw new Error(JSON.stringify(json.errors));
return json.data;
};
async function main() {
const url = requireEnv('TWENTY_PARTNERS_API_URL');
const key = requireEnv('TWENTY_PARTNERS_API_KEY');
console.log(`[purge] target: ${url} — destroying soft-deleted rows only`);
for (const obj of OBJECTS) {
// destroy<Object>s is the bulk variant; capitalise + already-plural names.
const mutationName = `destroy${obj.charAt(0).toUpperCase()}${obj.slice(1)}`;
const data = await gql(url, key, `mutation { ${mutationName}(filter: { deletedAt: { is: NOT_NULL } }) { id } }`);
const destroyed = data[mutationName]?.length ?? 0;
console.log(`[purge] ${obj}: destroyed ${destroyed} soft-deleted`);
}
console.log('[purge] done');
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
@@ -11,7 +11,7 @@
// tsx src/scripts/seed.ts
import { config } from 'dotenv';
config({ path: '.env.local' });
config({ path: process.env.ENV_FILE ?? '.env.local' });
import { CoreApiClient } from 'twenty-client-sdk/core';
@@ -89,13 +89,13 @@ const OPPORTUNITIES: Opp[] = [
{ name: 'Sunrise — vendor onboarding', companyName: 'Sunrise Logistics', matchStatus: 'LOST', numberOfSeats: 10, hostingType: 'CLOUD', subscriptionType: 'PRO', subscriptionFrequency: 'MONTHLY' },
];
type Quote = { name: string; status: string; partnerSlug: string; oppName: string };
type Quote = { name: string; status: string; partnerSlug: string; contentType: string[] };
const QUOTES: Quote[] = [
{ name: 'Sunrise APAC fleet — Nine Dots quote', status: 'WIP', partnerSlug: 'nine-dots-ventures', oppName: 'Sunrise — APAC fleet CRM' },
{ name: 'Helix clinical — NetZero quote', status: 'INTERVIEW_SCHEDULED', partnerSlug: 'netzero-systems', oppName: 'Helix Bio — clinical trials CRM' },
{ name: 'Acme rollout — Elevate quote', status: 'UNDER_CUSTOMER_PARTNER_REVIEW', partnerSlug: 'elevate-consulting', oppName: 'Acme RE — CRM rollout' },
{ name: 'Sunrise LATAM — Nine Dots quote', status: 'APPROVED', partnerSlug: 'nine-dots-ventures', oppName: 'Sunrise — LATAM expansion' },
{ name: 'Helix self-host — Meridian quote', status: 'REJECTED', partnerSlug: 'meridian-craft', oppName: 'Helix Bio — self-host evaluation' },
{ name: 'Sunrise APAC fleet — Nine Dots quote', status: 'WIP', partnerSlug: 'nine-dots-ventures', contentType: ['PARTNER_QUOTE'] },
{ name: 'Helix clinical — NetZero quote', status: 'INTERVIEW_SCHEDULED', partnerSlug: 'netzero-systems', contentType: ['PARTNER_QUOTE'] },
{ name: 'Acme rollout — Elevate quote', status: 'UNDER_CUSTOMER_PARTNER_REVIEW', partnerSlug: 'elevate-consulting', contentType: ['PARTNER_QUOTE'] },
{ name: 'Sunrise LATAM — Nine Dots quote', status: 'APPROVED', partnerSlug: 'nine-dots-ventures', contentType: ['PARTNER_QUOTE'] },
{ name: 'Helix self-host — Meridian quote', status: 'REJECTED', partnerSlug: 'meridian-craft', contentType: ['CASE_STUDY'] },
];
const nodes = (r: any, key: string): any[] => (r?.[key]?.edges ?? []).map((e: any) => e.node);
@@ -177,12 +177,12 @@ async function main() {
// -- Partner quotes (upsert by name) --
let quoteCount = 0;
for (const q of QUOTES) {
const data = { name: q.name, status: q.status, partnerId: partnerIdBySlug.get(q.partnerSlug), opportunityId: oppIdByName.get(q.oppName) };
const existing = nodes(await client.query({ partnerQuotes: { __args: { filter: { name: { eq: q.name } }, first: 1 }, edges: { node: { id: true } } } } as any), 'partnerQuotes');
const data = { name: q.name, status: q.status, contentType: q.contentType, partnerId: partnerIdBySlug.get(q.partnerSlug) };
const existing = nodes(await client.query({ partnerContents: { __args: { filter: { name: { eq: q.name } }, first: 1 }, edges: { node: { id: true } } } } as any), 'partnerContents');
if (existing[0]?.id) {
await client.mutation({ updatePartnerQuote: { __args: { id: existing[0].id, data }, id: true } } as any);
await client.mutation({ updatePartnerContent: { __args: { id: existing[0].id, data }, id: true } } as any);
} else {
await client.mutation({ createPartnerQuote: { __args: { data }, id: true } } as any);
await client.mutation({ createPartnerContent: { __args: { data }, id: true } } as any);
}
quoteCount++;
}
@@ -1,21 +1,21 @@
import { ViewType, defineView } from 'twenty-sdk/define';
import {
PARTNER_QUOTES_VIEW_UNIVERSAL_IDENTIFIER,
PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER,
PARTNER_CONTENT_VIEW_UNIVERSAL_IDENTIFIER,
PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
// Index view for partner quotes.
// Index view for partner content.
export default defineView({
universalIdentifier: PARTNER_QUOTES_VIEW_UNIVERSAL_IDENTIFIER,
name: 'Partner quotes',
icon: 'IconFileDollar',
objectUniversalIdentifier: PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER,
universalIdentifier: PARTNER_CONTENT_VIEW_UNIVERSAL_IDENTIFIER,
name: 'Partner content',
icon: 'IconQuote',
objectUniversalIdentifier: PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER,
type: ViewType.TABLE,
fields: [
{ universalIdentifier: 'ad7b3702-b552-4355-afec-1e1e96d9f3df', fieldMetadataUniversalIdentifier: '9e688624-83d2-4715-8b18-80492a6de2b6', position: 0, isVisible: true },
{ universalIdentifier: '426e0c2d-449d-4a06-860b-0cfe0ed501e6', fieldMetadataUniversalIdentifier: 'a0fe09c4-c1f4-4b96-93c6-d7ec38f1166a', position: 1, isVisible: true },
{ universalIdentifier: '62bb1536-539c-4fb0-95bf-440c5c5da89f', fieldMetadataUniversalIdentifier: '2cbe67e3-24ec-421b-bab5-50f3306c2391', position: 2, isVisible: true },
{ universalIdentifier: 'a9bf3eaa-ec27-4a0a-8df2-e18c8f4239a7', fieldMetadataUniversalIdentifier: '1d926e6e-6ac1-4d60-ab3d-a73114005692', position: 1, isVisible: true },
{ universalIdentifier: '426e0c2d-449d-4a06-860b-0cfe0ed501e6', fieldMetadataUniversalIdentifier: 'a0fe09c4-c1f4-4b96-93c6-d7ec38f1166a', position: 2, isVisible: true },
{ universalIdentifier: 'fbd1f953-1dd2-4d0f-a239-148a0688fbff', fieldMetadataUniversalIdentifier: 'b52d263e-423e-40b0-b82c-29214597c005', position: 3, isVisible: true },
],
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "twenty-client-sdk",
"version": "2.8.0",
"version": "2.9.0",
"sideEffects": false,
"license": "AGPL-3.0",
"scripts": {
@@ -48,7 +48,7 @@ type ApplicationRegistration {
isListed: Boolean!
isFeatured: Boolean!
isPreInstalled: Boolean!
logo: String
logoUrl: String
createdAt: DateTime!
updatedAt: DateTime!
isConfigured: Boolean!
@@ -287,7 +287,7 @@ type ApplicationRegistrationSummary {
id: UUID!
latestAvailableVersion: String
sourceType: ApplicationRegistrationSourceType!
logo: String
logoUrl: String
}
type ApplicationVariable {
@@ -1972,7 +1972,7 @@ type CreateApplicationRegistration {
type PublicApplicationRegistration {
id: UUID!
name: String!
logo: String
logoUrl: String
websiteUrl: String
oAuthScopes: [String!]!
}
@@ -2358,7 +2358,6 @@ type MarketplaceAppDetail {
sourceType: ApplicationRegistrationSourceType!
sourcePackage: String
latestAvailableVersion: String
logo: String
isListed: Boolean!
isFeatured: Boolean!
manifest: JSON
@@ -52,7 +52,7 @@ export interface ApplicationRegistration {
isListed: Scalars['Boolean']
isFeatured: Scalars['Boolean']
isPreInstalled: Scalars['Boolean']
logo?: Scalars['String']
logoUrl?: Scalars['String']
createdAt: Scalars['DateTime']
updatedAt: Scalars['DateTime']
isConfigured: Scalars['Boolean']
@@ -236,7 +236,7 @@ export interface ApplicationRegistrationSummary {
id: Scalars['UUID']
latestAvailableVersion?: Scalars['String']
sourceType: ApplicationRegistrationSourceType
logo?: Scalars['String']
logoUrl?: Scalars['String']
__typename: 'ApplicationRegistrationSummary'
}
@@ -1612,7 +1612,7 @@ export interface CreateApplicationRegistration {
export interface PublicApplicationRegistration {
id: Scalars['UUID']
name: Scalars['String']
logo?: Scalars['String']
logoUrl?: Scalars['String']
websiteUrl?: Scalars['String']
oAuthScopes: Scalars['String'][]
__typename: 'PublicApplicationRegistration'
@@ -2035,7 +2035,6 @@ export interface MarketplaceAppDetail {
sourceType: ApplicationRegistrationSourceType
sourcePackage?: Scalars['String']
latestAvailableVersion?: Scalars['String']
logo?: Scalars['String']
isListed: Scalars['Boolean']
isFeatured: Scalars['Boolean']
manifest?: Scalars['JSON']
@@ -2927,7 +2926,7 @@ export interface ApplicationRegistrationGenqlSelection{
isListed?: boolean | number
isFeatured?: boolean | number
isPreInstalled?: boolean | number
logo?: boolean | number
logoUrl?: boolean | number
createdAt?: boolean | number
updatedAt?: boolean | number
isConfigured?: boolean | number
@@ -3104,7 +3103,7 @@ export interface ApplicationRegistrationSummaryGenqlSelection{
id?: boolean | number
latestAvailableVersion?: boolean | number
sourceType?: boolean | number
logo?: boolean | number
logoUrl?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
@@ -4560,7 +4559,7 @@ export interface CreateApplicationRegistrationGenqlSelection{
export interface PublicApplicationRegistrationGenqlSelection{
id?: boolean | number
name?: boolean | number
logo?: boolean | number
logoUrl?: boolean | number
websiteUrl?: boolean | number
oAuthScopes?: boolean | number
__typename?: boolean | number
@@ -5035,7 +5034,6 @@ export interface MarketplaceAppDetailGenqlSelection{
sourceType?: boolean | number
sourcePackage?: boolean | number
latestAvailableVersion?: boolean | number
logo?: boolean | number
isListed?: boolean | number
isFeatured?: boolean | number
manifest?: boolean | number
@@ -204,7 +204,7 @@ export default {
"isPreInstalled": [
6
],
"logo": [
"logoUrl": [
1
],
"createdAt": [
@@ -615,7 +615,7 @@ export default {
"sourceType": [
8
],
"logo": [
"logoUrl": [
1
],
"__typename": [
@@ -3886,7 +3886,7 @@ export default {
"name": [
1
],
"logo": [
"logoUrl": [
1
],
"websiteUrl": [
@@ -4643,9 +4643,6 @@ export default {
"latestAvailableVersion": [
1
],
"logo": [
1
],
"isListed": [
6
],
@@ -70,7 +70,7 @@ filters: [
| `CURRENCY.currencyCode` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `DATE`, `DATE_TIME` | `IS`, `IS_RELATIVE`, `IS_IN_PAST`, `IS_IN_FUTURE`, `IS_TODAY`, `IS_BEFORE`, `IS_AFTER`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `BOOLEAN` | `IS` |
| `UUID` | `IS` |
| `UUID` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `TS_VECTOR` | `VECTOR_SEARCH` |
> Field types with similar names can use entirely different operands — `SELECT` and `MULTI_SELECT` being a common case.
@@ -70,7 +70,7 @@ filters: [
| `CURRENCY.currencyCode` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `DATE`, `DATE_TIME` | `IS`, `IS_RELATIVE`, `IS_IN_PAST`, `IS_IN_FUTURE`, `IS_TODAY`, `IS_BEFORE`, `IS_AFTER`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `BOOLEAN` | `IS` |
| `UUID` | `IS` |
| `UUID` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `TS_VECTOR` | `VECTOR_SEARCH` |
> Feldtypen mit ähnlichen Namen können völlig unterschiedliche Operanden verwenden `SELECT` und `MULTI_SELECT` sind ein häufiges Beispiel.
@@ -70,7 +70,7 @@ filters: [
| `CURRENCY.currencyCode` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `DATE`, `DATE_TIME` | `IS`, `IS_RELATIVE`, `IS_IN_PAST`, `IS_IN_FUTURE`, `IS_TODAY`, `IS_BEFORE`, `IS_AFTER`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `BOOLEAN` | `IS` |
| `UUID` | `IS` |
| `UUID` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `TS_VECTOR` | `VECTOR_SEARCH` |
> Tipos de campos com nomes semelhantes podem usar operandos completamente diferentes — `SELECT` e `MULTI_SELECT` sendo um caso comum.
@@ -70,7 +70,7 @@ filters: [
| `CURRENCY.currencyCode` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `DATE`, `DATE_TIME` | `IS`, `IS_RELATIVE`, `IS_IN_PAST`, `IS_IN_FUTURE`, `IS_TODAY`, `IS_BEFORE`, `IS_AFTER`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `BOOLEAN` | `IS` |
| `UUID` | `IS` |
| `UUID` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `TS_VECTOR` | `VECTOR_SEARCH` |
> Tipurile de câmp cu nume similare pot folosi operanzi complet diferiți — `SELECT` și `MULTI_SELECT` fiind un caz comun.
@@ -70,7 +70,7 @@ filters: [
| `CURRENCY.currencyCode` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `DATE`, `DATE_TIME` | `IS`, `IS_RELATIVE`, `IS_IN_PAST`, `IS_IN_FUTURE`, `IS_TODAY`, `IS_BEFORE`, `IS_AFTER`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `BOOLEAN` | `IS` |
| `UUID` | `IS` |
| `UUID` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `TS_VECTOR` | `VECTOR_SEARCH` |
> Типы полей с похожими названиями могут использовать совершенно разные операнды — типичный пример: `SELECT` и `MULTI_SELECT`.
@@ -70,7 +70,7 @@ filters: [
| `CURRENCY.currencyCode` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `DATE`, `DATE_TIME` | `IS`, `IS_RELATIVE`, `IS_IN_PAST`, `IS_IN_FUTURE`, `IS_TODAY`, `IS_BEFORE`, `IS_AFTER`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `BOOLEAN` | `IS` |
| `UUID` | `IS` |
| `UUID` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `TS_VECTOR` | `VECTOR_SEARCH` |
> Benzer adlara sahip alan türleri tamamen farklı işleçler kullanabilir — buna `SELECT` ve `MULTI_SELECT` yaygın bir örnektir.
@@ -70,7 +70,7 @@ filters: [
| `CURRENCY.currencyCode` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `DATE`, `DATE_TIME` | `IS`, `IS_RELATIVE`, `IS_IN_PAST`, `IS_IN_FUTURE`, `IS_TODAY`, `IS_BEFORE`, `IS_AFTER`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `BOOLEAN` | `IS` |
| `UUID` | `IS` |
| `UUID` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
| `TS_VECTOR` | `VECTOR_SEARCH` |
> 名称相似的字段类型可以使用完全不同的运算符 —— `SELECT` 和 `MULTI_SELECT` 就是常见情况。
@@ -176,7 +176,7 @@ export type ApplicationRegistration = {
isListed: Scalars['Boolean'];
isPreInstalled: Scalars['Boolean'];
latestAvailableVersion?: Maybe<Scalars['String']>;
logo?: Maybe<Scalars['String']>;
logoUrl?: Maybe<Scalars['String']>;
name: Scalars['String'];
oAuthClientId: Scalars['String'];
oAuthRedirectUris: Array<Scalars['String']>;
@@ -933,7 +933,7 @@ export type FindAdminApplicationRegistrationVariablesQuery = { __typename?: 'Que
export type FindAllApplicationRegistrationsQueryVariables = Exact<{ [key: string]: never; }>;
export type FindAllApplicationRegistrationsQuery = { __typename?: 'Query', findAllApplicationRegistrations: Array<{ __typename?: 'ApplicationRegistration', id: string, universalIdentifier: string, name: string, logo?: string | null, oAuthClientId: string, oAuthRedirectUris: Array<string>, oAuthScopes: Array<string>, sourceType: ApplicationRegistrationSourceType, sourcePackage?: string | null, latestAvailableVersion?: string | null, isListed: boolean, isFeatured: boolean, isPreInstalled: boolean, isConfigured: boolean, ownerWorkspaceId?: string | null, createdAt: string, updatedAt: string }> };
export type FindAllApplicationRegistrationsQuery = { __typename?: 'Query', findAllApplicationRegistrations: Array<{ __typename?: 'ApplicationRegistration', id: string, universalIdentifier: string, name: string, logoUrl?: string | null, oAuthClientId: string, oAuthRedirectUris: Array<string>, oAuthScopes: Array<string>, sourceType: ApplicationRegistrationSourceType, sourcePackage?: string | null, latestAvailableVersion?: string | null, isListed: boolean, isFeatured: boolean, isPreInstalled: boolean, isConfigured: boolean, ownerWorkspaceId?: string | null, createdAt: string, updatedAt: string }> };
export type CreateDatabaseConfigVariableMutationVariables = Exact<{
key: Scalars['String'];
@@ -1000,7 +1000,7 @@ export type FindOneAdminApplicationRegistrationQueryVariables = Exact<{
}>;
export type FindOneAdminApplicationRegistrationQuery = { __typename?: 'Query', findOneAdminApplicationRegistration: { __typename?: 'ApplicationRegistration', id: string, universalIdentifier: string, name: string, logo?: string | null, oAuthClientId: string, oAuthRedirectUris: Array<string>, oAuthScopes: Array<string>, sourceType: ApplicationRegistrationSourceType, sourcePackage?: string | null, latestAvailableVersion?: string | null, isListed: boolean, isFeatured: boolean, isPreInstalled: boolean, isConfigured: boolean, ownerWorkspaceId?: string | null, createdAt: string, updatedAt: string } };
export type FindOneAdminApplicationRegistrationQuery = { __typename?: 'Query', findOneAdminApplicationRegistration: { __typename?: 'ApplicationRegistration', id: string, universalIdentifier: string, name: string, logoUrl?: string | null, oAuthClientId: string, oAuthRedirectUris: Array<string>, oAuthScopes: Array<string>, sourceType: ApplicationRegistrationSourceType, sourcePackage?: string | null, latestAvailableVersion?: string | null, isListed: boolean, isFeatured: boolean, isPreInstalled: boolean, isConfigured: boolean, ownerWorkspaceId?: string | null, createdAt: string, updatedAt: string } };
export type GetAdminChatThreadMessagesQueryVariables = Exact<{
threadId: Scalars['UUID'];
@@ -1136,10 +1136,10 @@ export type GetSigningKeysQueryVariables = Exact<{ [key: string]: never; }>;
export type GetSigningKeysQuery = { __typename?: 'Query', getSigningKeys: { __typename?: 'SigningKeysAdminPanelDTO', legacyVerifyCountInWindow: number, verifyWindowDays: number, signingKeys: Array<{ __typename?: 'SigningKeyDTO', id: string, publicKey: string, isCurrent: boolean, createdAt: string, revokedAt?: string | null, verifyCountInWindow: number }> } };
export type ApplicationRegistrationFragmentFragment = { __typename?: 'ApplicationRegistration', id: string, universalIdentifier: string, name: string, logo?: string | null, oAuthClientId: string, oAuthRedirectUris: Array<string>, oAuthScopes: Array<string>, sourceType: ApplicationRegistrationSourceType, sourcePackage?: string | null, latestAvailableVersion?: string | null, isListed: boolean, isFeatured: boolean, isPreInstalled: boolean, isConfigured: boolean, ownerWorkspaceId?: string | null, createdAt: string, updatedAt: string };
export type ApplicationRegistrationFragmentFragment = { __typename?: 'ApplicationRegistration', id: string, universalIdentifier: string, name: string, logoUrl?: string | null, oAuthClientId: string, oAuthRedirectUris: Array<string>, oAuthScopes: Array<string>, sourceType: ApplicationRegistrationSourceType, sourcePackage?: string | null, latestAvailableVersion?: string | null, isListed: boolean, isFeatured: boolean, isPreInstalled: boolean, isConfigured: boolean, ownerWorkspaceId?: string | null, createdAt: string, updatedAt: string };
export const UserInfoFragmentFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"UserInfoFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"UserInfo"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"firstName"}},{"kind":"Field","name":{"kind":"Name","value":"lastName"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]} as unknown as DocumentNode<UserInfoFragmentFragment, unknown>;
export const ApplicationRegistrationFragmentFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ApplicationRegistrationFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ApplicationRegistration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"universalIdentifier"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"logo"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthClientId"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthRedirectUris"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthScopes"}},{"kind":"Field","name":{"kind":"Name","value":"sourceType"}},{"kind":"Field","name":{"kind":"Name","value":"sourcePackage"}},{"kind":"Field","name":{"kind":"Name","value":"latestAvailableVersion"}},{"kind":"Field","name":{"kind":"Name","value":"isListed"}},{"kind":"Field","name":{"kind":"Name","value":"isFeatured"}},{"kind":"Field","name":{"kind":"Name","value":"isPreInstalled"}},{"kind":"Field","name":{"kind":"Name","value":"isConfigured"}},{"kind":"Field","name":{"kind":"Name","value":"ownerWorkspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode<ApplicationRegistrationFragmentFragment, unknown>;
export const ApplicationRegistrationFragmentFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ApplicationRegistrationFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ApplicationRegistration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"universalIdentifier"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"logoUrl"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthClientId"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthRedirectUris"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthScopes"}},{"kind":"Field","name":{"kind":"Name","value":"sourceType"}},{"kind":"Field","name":{"kind":"Name","value":"sourcePackage"}},{"kind":"Field","name":{"kind":"Name","value":"latestAvailableVersion"}},{"kind":"Field","name":{"kind":"Name","value":"isListed"}},{"kind":"Field","name":{"kind":"Name","value":"isFeatured"}},{"kind":"Field","name":{"kind":"Name","value":"isPreInstalled"}},{"kind":"Field","name":{"kind":"Name","value":"isConfigured"}},{"kind":"Field","name":{"kind":"Name","value":"ownerWorkspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode<ApplicationRegistrationFragmentFragment, unknown>;
export const AddAiProviderDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"AddAiProvider"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"providerName"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"providerConfig"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"JSON"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"addAiProvider"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"providerName"},"value":{"kind":"Variable","name":{"kind":"Name","value":"providerName"}}},{"kind":"Argument","name":{"kind":"Name","value":"providerConfig"},"value":{"kind":"Variable","name":{"kind":"Name","value":"providerConfig"}}}]}]}}]} as unknown as DocumentNode<AddAiProviderMutation, AddAiProviderMutationVariables>;
export const AddModelToProviderDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"AddModelToProvider"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"providerName"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"modelConfig"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"JSON"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"addModelToProvider"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"providerName"},"value":{"kind":"Variable","name":{"kind":"Name","value":"providerName"}}},{"kind":"Argument","name":{"kind":"Name","value":"modelConfig"},"value":{"kind":"Variable","name":{"kind":"Name","value":"modelConfig"}}}]}]}}]} as unknown as DocumentNode<AddModelToProviderMutation, AddModelToProviderMutationVariables>;
export const RemoveAiProviderDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RemoveAiProvider"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"providerName"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"removeAiProvider"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"providerName"},"value":{"kind":"Variable","name":{"kind":"Name","value":"providerName"}}}]}]}}]} as unknown as DocumentNode<RemoveAiProviderMutation, RemoveAiProviderMutationVariables>;
@@ -1155,7 +1155,7 @@ export const GetAiProvidersDocument = {"kind":"Document","definitions":[{"kind":
export const GetModelsDevProvidersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetModelsDevProviders"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getModelsDevProviders"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"modelCount"}},{"kind":"Field","name":{"kind":"Name","value":"npm"}}]}}]}}]} as unknown as DocumentNode<GetModelsDevProvidersQuery, GetModelsDevProvidersQueryVariables>;
export const GetModelsDevSuggestionsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetModelsDevSuggestions"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"providerType"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getModelsDevSuggestions"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"providerType"},"value":{"kind":"Variable","name":{"kind":"Name","value":"providerType"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"modelId"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"inputCostPerMillionTokens"}},{"kind":"Field","name":{"kind":"Name","value":"outputCostPerMillionTokens"}},{"kind":"Field","name":{"kind":"Name","value":"cachedInputCostPerMillionTokens"}},{"kind":"Field","name":{"kind":"Name","value":"cacheCreationCostPerMillionTokens"}},{"kind":"Field","name":{"kind":"Name","value":"contextWindowTokens"}},{"kind":"Field","name":{"kind":"Name","value":"maxOutputTokens"}},{"kind":"Field","name":{"kind":"Name","value":"modalities"}},{"kind":"Field","name":{"kind":"Name","value":"supportsReasoning"}}]}}]}}]} as unknown as DocumentNode<GetModelsDevSuggestionsQuery, GetModelsDevSuggestionsQueryVariables>;
export const FindAdminApplicationRegistrationVariablesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"FindAdminApplicationRegistrationVariables"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"applicationRegistrationId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"findAdminApplicationRegistrationVariables"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"applicationRegistrationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"applicationRegistrationId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"value"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"isSecret"}},{"kind":"Field","name":{"kind":"Name","value":"isRequired"}},{"kind":"Field","name":{"kind":"Name","value":"isFilled"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode<FindAdminApplicationRegistrationVariablesQuery, FindAdminApplicationRegistrationVariablesQueryVariables>;
export const FindAllApplicationRegistrationsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"FindAllApplicationRegistrations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"findAllApplicationRegistrations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ApplicationRegistrationFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ApplicationRegistrationFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ApplicationRegistration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"universalIdentifier"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"logo"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthClientId"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthRedirectUris"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthScopes"}},{"kind":"Field","name":{"kind":"Name","value":"sourceType"}},{"kind":"Field","name":{"kind":"Name","value":"sourcePackage"}},{"kind":"Field","name":{"kind":"Name","value":"latestAvailableVersion"}},{"kind":"Field","name":{"kind":"Name","value":"isListed"}},{"kind":"Field","name":{"kind":"Name","value":"isFeatured"}},{"kind":"Field","name":{"kind":"Name","value":"isPreInstalled"}},{"kind":"Field","name":{"kind":"Name","value":"isConfigured"}},{"kind":"Field","name":{"kind":"Name","value":"ownerWorkspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode<FindAllApplicationRegistrationsQuery, FindAllApplicationRegistrationsQueryVariables>;
export const FindAllApplicationRegistrationsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"FindAllApplicationRegistrations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"findAllApplicationRegistrations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ApplicationRegistrationFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ApplicationRegistrationFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ApplicationRegistration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"universalIdentifier"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"logoUrl"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthClientId"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthRedirectUris"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthScopes"}},{"kind":"Field","name":{"kind":"Name","value":"sourceType"}},{"kind":"Field","name":{"kind":"Name","value":"sourcePackage"}},{"kind":"Field","name":{"kind":"Name","value":"latestAvailableVersion"}},{"kind":"Field","name":{"kind":"Name","value":"isListed"}},{"kind":"Field","name":{"kind":"Name","value":"isFeatured"}},{"kind":"Field","name":{"kind":"Name","value":"isPreInstalled"}},{"kind":"Field","name":{"kind":"Name","value":"isConfigured"}},{"kind":"Field","name":{"kind":"Name","value":"ownerWorkspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode<FindAllApplicationRegistrationsQuery, FindAllApplicationRegistrationsQueryVariables>;
export const CreateDatabaseConfigVariableDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateDatabaseConfigVariable"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"key"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"value"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"JSON"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createDatabaseConfigVariable"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"key"},"value":{"kind":"Variable","name":{"kind":"Name","value":"key"}}},{"kind":"Argument","name":{"kind":"Name","value":"value"},"value":{"kind":"Variable","name":{"kind":"Name","value":"value"}}}]}]}}]} as unknown as DocumentNode<CreateDatabaseConfigVariableMutation, CreateDatabaseConfigVariableMutationVariables>;
export const DeleteDatabaseConfigVariableDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteDatabaseConfigVariable"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"key"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteDatabaseConfigVariable"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"key"},"value":{"kind":"Variable","name":{"kind":"Name","value":"key"}}}]}]}}]} as unknown as DocumentNode<DeleteDatabaseConfigVariableMutation, DeleteDatabaseConfigVariableMutationVariables>;
export const UpdateDatabaseConfigVariableDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateDatabaseConfigVariable"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"key"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"value"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"JSON"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateDatabaseConfigVariable"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"key"},"value":{"kind":"Variable","name":{"kind":"Name","value":"key"}}},{"kind":"Argument","name":{"kind":"Name","value":"value"},"value":{"kind":"Variable","name":{"kind":"Name","value":"value"}}}]}]}}]} as unknown as DocumentNode<UpdateDatabaseConfigVariableMutation, UpdateDatabaseConfigVariableMutationVariables>;
@@ -1164,7 +1164,7 @@ export const GetDatabaseConfigVariableDocument = {"kind":"Document","definitions
export const UpdateWorkspaceFeatureFlagDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateWorkspaceFeatureFlag"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"featureFlag"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"value"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateWorkspaceFeatureFlag"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"workspaceId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}},{"kind":"Argument","name":{"kind":"Name","value":"featureFlag"},"value":{"kind":"Variable","name":{"kind":"Name","value":"featureFlag"}}},{"kind":"Argument","name":{"kind":"Name","value":"value"},"value":{"kind":"Variable","name":{"kind":"Name","value":"value"}}}]}]}}]} as unknown as DocumentNode<UpdateWorkspaceFeatureFlagMutation, UpdateWorkspaceFeatureFlagMutationVariables>;
export const AdminPanelRecentUsersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"AdminPanelRecentUsers"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"searchTerm"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"adminPanelRecentUsers"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"searchTerm"},"value":{"kind":"Variable","name":{"kind":"Name","value":"searchTerm"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"firstName"}},{"kind":"Field","name":{"kind":"Name","value":"lastName"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceName"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceLogo"}}]}}]}}]} as unknown as DocumentNode<AdminPanelRecentUsersQuery, AdminPanelRecentUsersQueryVariables>;
export const AdminPanelTopWorkspacesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"AdminPanelTopWorkspaces"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"searchTerm"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"adminPanelTopWorkspaces"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"searchTerm"},"value":{"kind":"Variable","name":{"kind":"Name","value":"searchTerm"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"logoUrl"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"totalUsers"}},{"kind":"Field","name":{"kind":"Name","value":"subdomain"}}]}}]}}]} as unknown as DocumentNode<AdminPanelTopWorkspacesQuery, AdminPanelTopWorkspacesQueryVariables>;
export const FindOneAdminApplicationRegistrationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"FindOneAdminApplicationRegistration"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"findOneAdminApplicationRegistration"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ApplicationRegistrationFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ApplicationRegistrationFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ApplicationRegistration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"universalIdentifier"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"logo"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthClientId"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthRedirectUris"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthScopes"}},{"kind":"Field","name":{"kind":"Name","value":"sourceType"}},{"kind":"Field","name":{"kind":"Name","value":"sourcePackage"}},{"kind":"Field","name":{"kind":"Name","value":"latestAvailableVersion"}},{"kind":"Field","name":{"kind":"Name","value":"isListed"}},{"kind":"Field","name":{"kind":"Name","value":"isFeatured"}},{"kind":"Field","name":{"kind":"Name","value":"isPreInstalled"}},{"kind":"Field","name":{"kind":"Name","value":"isConfigured"}},{"kind":"Field","name":{"kind":"Name","value":"ownerWorkspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode<FindOneAdminApplicationRegistrationQuery, FindOneAdminApplicationRegistrationQueryVariables>;
export const FindOneAdminApplicationRegistrationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"FindOneAdminApplicationRegistration"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"findOneAdminApplicationRegistration"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ApplicationRegistrationFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ApplicationRegistrationFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ApplicationRegistration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"universalIdentifier"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"logoUrl"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthClientId"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthRedirectUris"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthScopes"}},{"kind":"Field","name":{"kind":"Name","value":"sourceType"}},{"kind":"Field","name":{"kind":"Name","value":"sourcePackage"}},{"kind":"Field","name":{"kind":"Name","value":"latestAvailableVersion"}},{"kind":"Field","name":{"kind":"Name","value":"isListed"}},{"kind":"Field","name":{"kind":"Name","value":"isFeatured"}},{"kind":"Field","name":{"kind":"Name","value":"isPreInstalled"}},{"kind":"Field","name":{"kind":"Name","value":"isConfigured"}},{"kind":"Field","name":{"kind":"Name","value":"ownerWorkspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode<FindOneAdminApplicationRegistrationQuery, FindOneAdminApplicationRegistrationQueryVariables>;
export const GetAdminChatThreadMessagesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetAdminChatThreadMessages"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"threadId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getAdminChatThreadMessages"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"threadId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"threadId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"thread"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"totalInputTokens"}},{"kind":"Field","name":{"kind":"Name","value":"totalOutputTokens"}},{"kind":"Field","name":{"kind":"Name","value":"conversationSize"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}},{"kind":"Field","name":{"kind":"Name","value":"messages"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"parts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"textContent"}},{"kind":"Field","name":{"kind":"Name","value":"toolName"}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]}}]}}]} as unknown as DocumentNode<GetAdminChatThreadMessagesQuery, GetAdminChatThreadMessagesQueryVariables>;
export const GetAdminWorkspaceChatThreadsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetAdminWorkspaceChatThreads"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getAdminWorkspaceChatThreads"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"workspaceId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"totalInputTokens"}},{"kind":"Field","name":{"kind":"Name","value":"totalOutputTokens"}},{"kind":"Field","name":{"kind":"Name","value":"conversationSize"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode<GetAdminWorkspaceChatThreadsQuery, GetAdminWorkspaceChatThreadsQueryVariables>;
export const GetUpgradeStatusDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetUpgradeStatus"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceIds"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getUpgradeStatus"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"workspaceIds"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceIds"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}},{"kind":"Field","name":{"kind":"Name","value":"inferredVersion"}},{"kind":"Field","name":{"kind":"Name","value":"health"}},{"kind":"Field","name":{"kind":"Name","value":"latestCommand"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"executedByVersion"}},{"kind":"Field","name":{"kind":"Name","value":"errorMessage"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]}}]}}]} as unknown as DocumentNode<GetUpgradeStatusQuery, GetUpgradeStatusQueryVariables>;
File diff suppressed because one or more lines are too long
@@ -19,7 +19,7 @@ export const APPLICATION_FRAGMENT = gql`
id
latestAvailableVersion
sourceType
logo
logoUrl
}
canBeUninstalled
defaultRoleId
@@ -8,6 +8,7 @@ import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { t } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
import { buildApplicationLogoUrl } from '@/applications/utils/buildApplicationLogoUrl';
import CustomLogo from '~/pages/settings/applications/assets/custom-illustrations/custom-logo.webp';
import StandardLogo from '~/pages/settings/applications/assets/standard-illustrations/standard-logo.webp';
@@ -66,7 +67,11 @@ export const useApplicationChipData = ({
? new URL(StandardLogo, window.location.href).toString()
: isCustom
? new URL(CustomLogo, window.location.href).toString()
: (application.logo ?? undefined);
: buildApplicationLogoUrl({
applicationId: application.id,
logo: application.logo,
workspaceId: currentWorkspace?.id,
});
return {
applicationChipData: {
@@ -0,0 +1,26 @@
import { REACT_APP_SERVER_BASE_URL } from '~/config';
import { isDefined } from 'twenty-shared/utils';
export const buildApplicationLogoUrl = ({
workspaceId,
applicationId,
logo,
}: {
workspaceId?: string | null;
applicationId?: string | null;
logo?: string | null;
}): string | undefined => {
if (
!isDefined(logo) ||
logo.startsWith('http://') ||
logo.startsWith('https://')
) {
return logo ?? undefined;
}
if (!isDefined(workspaceId) || !isDefined(applicationId)) {
return undefined;
}
return `${REACT_APP_SERVER_BASE_URL}/public-assets/${workspaceId}/${applicationId}/${logo}`;
};
@@ -4,7 +4,7 @@ export const FIND_APPLICATION_REGISTRATION_BY_CLIENT_ID = gql`
query FindApplicationRegistrationByClientId($clientId: String!) {
findApplicationRegistrationByClientId(clientId: $clientId) {
id
logo
logoUrl
name
oAuthScopes
websiteUrl
@@ -8,7 +8,6 @@ export const MARKETPLACE_APP_DETAIL_FRAGMENT = gql`
sourceType
sourcePackage
latestAvailableVersion
logo
isListed
isFeatured
manifest
@@ -179,7 +179,7 @@ const SettingsAdminAppsTableRow = ({
<AppChip
size="md"
fallbackApplicationData={{
logo: registration.logo,
logo: registration.logoUrl,
name: registration.name,
}}
/>
@@ -1,21 +0,0 @@
import { gql } from '@apollo/client';
export const FIND_ADMIN_APPLICATION_REGISTRATION_VARIABLES = gql`
query FindAdminApplicationRegistrationVariables(
$applicationRegistrationId: String!
) {
findAdminApplicationRegistrationVariables(
applicationRegistrationId: $applicationRegistrationId
) {
id
key
value
description
isSecret
isRequired
isFilled
createdAt
updatedAt
}
}
`;
@@ -5,7 +5,7 @@ export const APPLICATION_REGISTRATION_FRAGMENT = gql`
id
universalIdentifier
name
logo
logoUrl
oAuthClientId
oAuthRedirectUris
oAuthScopes
@@ -1,10 +1,8 @@
import { useUpdateStep } from '@/workflow/workflow-steps/hooks/useUpdateStep';
import { act, renderHook } from '@testing-library/react';
import { type WorkflowAction } from '~/generated/graphql';
const mockUpdateWorkflowVersionStep = jest.fn();
const mockGetUpdatableWorkflowVersion = jest.fn();
const mockMarkStepForRecomputation = jest.fn();
jest.mock(
'@/workflow/workflow-steps/hooks/useUpdateWorkflowVersionStep',
@@ -21,12 +19,6 @@ jest.mock('@/workflow/hooks/useGetUpdatableWorkflowVersionOrThrow', () => ({
}),
}));
jest.mock('@/workflow/workflow-variables/hooks/useStepsOutputSchema', () => ({
useStepsOutputSchema: jest.fn(() => ({
markStepForRecomputation: mockMarkStepForRecomputation,
})),
}));
describe('useUpdateStep', () => {
beforeEach(() => {
jest.clearAllMocks();
@@ -69,69 +61,4 @@ describe('useUpdateStep', () => {
step: mockStep,
});
});
it('should mark step for recomputation after update', async () => {
const mockWorkflowVersionId = 'version-123';
const mockStep = {
id: 'step-1',
name: 'Create Record',
valid: true,
type: 'CREATE_RECORD' as const,
settings: {
input: {
objectName: 'company',
},
errorHandlingOptions: {
retryOnFailure: { value: false },
continueOnFailure: { value: false },
},
},
};
mockGetUpdatableWorkflowVersion.mockResolvedValue(mockWorkflowVersionId);
const { result } = renderHook(() => useUpdateStep());
await act(async () => {
await result.current.updateStep(mockStep as WorkflowAction);
});
expect(mockMarkStepForRecomputation).toHaveBeenCalledWith({
stepId: 'step-1',
workflowVersionId: mockWorkflowVersionId,
});
});
it('should mark step for recomputation for all step types', async () => {
const mockWorkflowVersionId = 'version-123';
const stepTypes = ['CODE', 'HTTP_REQUEST', 'CREATE_RECORD', 'SEND_EMAIL'];
for (const stepType of stepTypes) {
mockMarkStepForRecomputation.mockClear();
mockGetUpdatableWorkflowVersion.mockResolvedValue(mockWorkflowVersionId);
const mockStep = {
id: `step-${stepType}`,
name: `${stepType} Step`,
valid: true,
type: stepType as any,
settings: {
input: {},
errorHandlingOptions: {
retryOnFailure: { value: false },
continueOnFailure: { value: false },
},
},
};
const { result } = renderHook(() => useUpdateStep());
await act(async () => {
await result.current.updateStep(mockStep as WorkflowAction);
});
expect(mockMarkStepForRecomputation).toHaveBeenCalledWith({
stepId: `step-${stepType}`,
workflowVersionId: mockWorkflowVersionId,
});
}
});
});
@@ -0,0 +1,125 @@
import { useUpdateWorkflowVersionStep } from '@/workflow/workflow-steps/hooks/useUpdateWorkflowVersionStep';
import { act, renderHook } from '@testing-library/react';
const mockMutate = jest.fn();
const mockGetRecordFromCache = jest.fn();
const mockMarkStepForRecomputation = jest.fn();
const mockEnqueueErrorSnackBar = jest.fn();
jest.mock('@/object-metadata/hooks/useApolloCoreClient', () => ({
useApolloCoreClient: () => ({ cache: {} }),
}));
jest.mock('@/object-metadata/hooks/useObjectMetadataItems', () => ({
useObjectMetadataItems: () => ({ objectMetadataItems: [] }),
}));
jest.mock('@/object-metadata/hooks/useObjectMetadataItem', () => ({
useObjectMetadataItem: () => ({ objectMetadataItem: {} }),
}));
jest.mock('@/object-record/hooks/useObjectPermissions', () => ({
useObjectPermissions: () => ({ objectPermissionsByObjectMetadataId: {} }),
}));
jest.mock('@/ui/feedback/snack-bar-manager/hooks/useSnackBar', () => ({
useSnackBar: () => ({ enqueueErrorSnackBar: mockEnqueueErrorSnackBar }),
}));
jest.mock('@/object-record/cache/hooks/useGetRecordFromCache', () => ({
useGetRecordFromCache: () => mockGetRecordFromCache,
}));
jest.mock('@/object-record/cache/utils/updateRecordFromCache', () => ({
updateRecordFromCache: jest.fn(),
}));
jest.mock('@/workflow/workflow-variables/hooks/useStepsOutputSchema', () => ({
useStepsOutputSchema: () => ({
markStepForRecomputation: mockMarkStepForRecomputation,
}),
}));
jest.mock('@apollo/client/react', () => ({
useMutation: () => [mockMutate],
}));
describe('useUpdateWorkflowVersionStep', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should mark step for recomputation after successful update', async () => {
const updatedStep = {
id: 'step-1',
name: 'My Custom Name',
type: 'CREATE_RECORD',
};
mockMutate.mockResolvedValue({
data: { updateWorkflowVersionStep: updatedStep },
});
mockGetRecordFromCache.mockReturnValue({
id: 'version-1',
steps: [{ id: 'step-1', name: 'Create Record', type: 'CREATE_RECORD' }],
});
const { result } = renderHook(() => useUpdateWorkflowVersionStep());
await act(async () => {
await result.current.updateWorkflowVersionStep({
workflowVersionId: 'version-1',
step: updatedStep,
});
});
expect(mockMarkStepForRecomputation).toHaveBeenCalledWith({
stepId: 'step-1',
workflowVersionId: 'version-1',
});
});
it('should not mark step for recomputation when mutation returns no data', async () => {
mockMutate.mockResolvedValue({ data: null });
const { result } = renderHook(() => useUpdateWorkflowVersionStep());
await act(async () => {
await result.current.updateWorkflowVersionStep({
workflowVersionId: 'version-1',
step: { id: 'step-1', name: 'Step', type: 'CODE' },
});
});
expect(mockMarkStepForRecomputation).not.toHaveBeenCalled();
});
it('should still mark step for recomputation when cached record is missing', async () => {
mockMutate.mockResolvedValue({
data: {
updateWorkflowVersionStep: {
id: 'step-1',
name: 'Step',
type: 'CODE',
},
},
});
mockGetRecordFromCache.mockReturnValue(undefined);
const { result } = renderHook(() => useUpdateWorkflowVersionStep());
await act(async () => {
await result.current.updateWorkflowVersionStep({
workflowVersionId: 'version-1',
step: { id: 'step-1', name: 'Step', type: 'CODE' },
});
});
expect(mockMarkStepForRecomputation).toHaveBeenCalledWith({
stepId: 'step-1',
workflowVersionId: 'version-1',
});
});
});
@@ -1,13 +1,11 @@
import { useGetUpdatableWorkflowVersionOrThrow } from '@/workflow/hooks/useGetUpdatableWorkflowVersionOrThrow';
import { type WorkflowAction } from '@/workflow/types/Workflow';
import { useUpdateWorkflowVersionStep } from '@/workflow/workflow-steps/hooks/useUpdateWorkflowVersionStep';
import { useStepsOutputSchema } from '@/workflow/workflow-variables/hooks/useStepsOutputSchema';
export const useUpdateStep = () => {
const { getUpdatableWorkflowVersion } =
useGetUpdatableWorkflowVersionOrThrow();
const { updateWorkflowVersionStep } = useUpdateWorkflowVersionStep();
const { markStepForRecomputation } = useStepsOutputSchema();
const updateStep = async (updatedStep: WorkflowAction) => {
const workflowVersionId = await getUpdatableWorkflowVersion();
@@ -17,11 +15,6 @@ export const useUpdateStep = () => {
step: updatedStep,
});
markStepForRecomputation({
stepId: updatedStep.id,
workflowVersionId,
});
return {
updatedStep: result?.data?.updateWorkflowVersionStep,
};
@@ -10,6 +10,7 @@ import {
type WorkflowVersion,
type WorkflowStep,
} from '@/workflow/types/Workflow';
import { useStepsOutputSchema } from '@/workflow/workflow-variables/hooks/useStepsOutputSchema';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useMutation } from '@apollo/client/react';
import { isDefined } from 'twenty-shared/utils';
@@ -24,6 +25,7 @@ export const useUpdateWorkflowVersionStep = () => {
const { objectMetadataItems } = useObjectMetadataItems();
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
const { enqueueErrorSnackBar } = useSnackBar();
const { markStepForRecomputation } = useStepsOutputSchema();
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular: CoreObjectNameSingular.WorkflowVersion,
@@ -52,11 +54,16 @@ export const useUpdateWorkflowVersionStep = () => {
return;
}
markStepForRecomputation({
stepId: updatedStep.id,
workflowVersionId: input.workflowVersionId,
});
const cachedRecord = getRecordFromCache<WorkflowVersion>(
input.workflowVersionId,
);
if (!isDefined(cachedRecord)) {
return;
return result;
}
const newCachedRecord = {
@@ -80,6 +87,7 @@ export const useUpdateWorkflowVersionStep = () => {
recordGqlFields,
objectPermissionsByObjectMetadataId,
});
return result;
};
@@ -223,7 +223,7 @@ export const Authorize = () => {
}
const appName = applicationRegistration.name;
const appLogoUrl = applicationRegistration.logo;
const appLogoUrl = applicationRegistration.logoUrl;
const requestedScopes: string[] = applicationRegistration.oAuthScopes ?? [];
return (
@@ -20,7 +20,7 @@ const buildHandlers = (
application: {
id: string;
name: string;
logo: string | null;
logoUrl: string | null;
oAuthScopes: string[];
websiteUrl: string | null;
} | null,
@@ -90,7 +90,7 @@ export const Default: Story = {
handlers: buildHandlers({
id: 'application-id-default',
name: 'ChatGPT',
logo: null,
logoUrl: null,
oAuthScopes: ['api', 'profile'],
websiteUrl: null,
}),
@@ -116,7 +116,7 @@ export const WithApiScopeOnly: Story = {
handlers: buildHandlers({
id: 'application-id-api-only',
name: 'Internal Tool',
logo: null,
logoUrl: null,
oAuthScopes: ['api'],
websiteUrl: null,
}),
@@ -144,7 +144,7 @@ export const WithLongAppName: Story = {
handlers: buildHandlers({
id: 'application-id-long-name',
name: 'Custom Workspace Automation Suite',
logo: null,
logoUrl: null,
oAuthScopes: ['api', 'profile'],
websiteUrl: null,
}),
@@ -8,15 +8,12 @@ import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBa
import { useApolloAdminClient } from '@/settings/admin-panel/apollo/hooks/useApolloAdminClient';
import { APPLICATION_REGISTRATION_ADMIN_PATH } from '@/settings/admin-panel/apps/constants/ApplicationRegistrationAdminPath';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { styled } from '@linaria/react';
import {
Avatar,
IconInfoCircle,
IconKey,
IconSettings,
IconWorld,
} from 'twenty-ui/display';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { SettingsApplicationRegistrationConfigTab } from '~/pages/settings/applications/tabs/SettingsApplicationRegistrationConfigTab';
import { SettingsApplicationRegistrationOAuthTab } from '~/pages/settings/applications/tabs/SettingsApplicationRegistrationOAuthTab';
import { SettingsApplicationRegistrationDistributionTab } from '~/pages/settings/applications/tabs/SettingsApplicationRegistrationDistributionTab';
@@ -25,12 +22,6 @@ import { TabList } from '@/ui/layout/tab-list/components/TabList';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
const StyledTitleContainer = styled.div`
align-items: center;
display: flex;
gap: ${themeCssVariables.spacing[2]};
`;
const REGISTRATION_DETAIL_TAB_LIST_ID =
'admin-application-registration-detail-tab-list';
@@ -103,18 +94,7 @@ export const SettingsAdminApplicationRegistrationDetail = () => {
return (
<SubMenuTopBarContainer
title={
<StyledTitleContainer>
<Avatar
type="app"
size="md"
avatarUrl={registration.logo ?? undefined}
placeholder={registration.name}
placeholderColorSeed={registration.name}
/>
{registration.name}
</StyledTitleContainer>
}
title={registration.name}
links={[
{
children: t`Other`,
@@ -8,15 +8,12 @@ import { useLingui } from '@lingui/react/macro';
import { Tag } from 'twenty-ui/components';
import { TabList } from '@/ui/layout/tab-list/components/TabList';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { styled } from '@linaria/react';
import {
Avatar,
IconInfoCircle,
IconKey,
IconSettings,
IconWorld,
} from 'twenty-ui/display';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { SettingsApplicationRegistrationConfigTab } from '~/pages/settings/applications/tabs/SettingsApplicationRegistrationConfigTab';
import { SettingsApplicationRegistrationOAuthTab } from '~/pages/settings/applications/tabs/SettingsApplicationRegistrationOAuthTab';
import { SettingsApplicationRegistrationDistributionTab } from '~/pages/settings/applications/tabs/SettingsApplicationRegistrationDistributionTab';
@@ -24,12 +21,6 @@ import { SettingsApplicationRegistrationGeneralTab } from '~/pages/settings/appl
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
const StyledTitleContainer = styled.div`
align-items: center;
display: flex;
gap: ${themeCssVariables.spacing[2]};
`;
const REGISTRATION_DETAIL_TAB_LIST_ID =
'application-registration-detail-tab-list';
@@ -95,18 +86,7 @@ export const SettingsApplicationRegistrationDetails = () => {
return (
<SubMenuTopBarContainer
title={
<StyledTitleContainer>
<Avatar
type="app"
size="md"
avatarUrl={registration.logo ?? undefined}
placeholder={registration.name}
placeholderColorSeed={registration.name}
/>
{registration.name}
</StyledTitleContainer>
}
title={registration.name}
tag={<Tag text={t`Owner`} color={'gray'} />}
links={[
{
@@ -247,7 +247,7 @@ export const SettingsAvailableApplicationDetails = () => {
manifestContent={manifest}
applicationInfo={{
name: displayName,
logo: detail?.logo,
logo: app?.logoUrl,
universalIdentifier: detail.universalIdentifier,
}}
/>
@@ -288,10 +288,6 @@ export const SettingsAvailableApplicationDetails = () => {
displayName={displayName}
description={description}
applicationId={application?.id}
fallbackApplicationData={{
logo: detail?.logo,
name: displayName,
}}
isUnlisted={isUnlisted}
/>
}
@@ -9,11 +9,10 @@ import { useApplicationChipData } from '@/applications/hooks/useApplicationChipD
type SettingsApplicationDetailTitleProps = {
displayName: string;
description?: string;
logoUrl?: string;
applicationId?: string;
fallbackApplicationData?: {
logo?: string | null;
name?: string | null;
};
applicationName?: string;
universalIdentifier?: string;
isUnlisted?: boolean;
};
@@ -74,14 +73,12 @@ export const SettingsApplicationDetailTitle = ({
displayName,
description,
applicationId,
fallbackApplicationData,
isUnlisted = false,
}: SettingsApplicationDetailTitleProps) => {
const descriptionSummary = getApplicationDescriptionSummary(description);
const { applicationChipData } = useApplicationChipData({
applicationId,
fallbackApplicationData,
});
return (
@@ -148,10 +148,11 @@ const buildFieldMetadataItemFromMarketplaceField = (
field: ObjectFieldManifest,
): FieldMetadataItem => {
const now = new Date().toISOString();
const universalIdentifier = field.universalIdentifier ?? uuidv4();
return {
id: field.universalIdentifier ?? uuidv4(),
universalIdentifier: field.universalIdentifier ?? uuidv4(),
id: universalIdentifier,
universalIdentifier,
name: field.name,
label: field.label,
type: (field.type as FieldMetadataType) ?? FieldMetadataType.TEXT,
@@ -172,7 +173,7 @@ const buildFieldMetadataItemFromMarketplaceField = (
};
};
const buildobjectMetadataItemsFromMarketplaceApp = (
const buildObjectMetadataItemsFromMarketplaceApp = (
defaultRole: RoleManifest,
objectUniversalIdToIdMap: Record<string, string>,
marketplaceAppObjects: ObjectManifest[],
@@ -292,7 +293,7 @@ const MarketplaceRoleEffect = ({
fieldUniversalIdToIdMap,
),
objectMetadataItemsFromMarketplaceApp:
buildobjectMetadataItemsFromMarketplaceApp(
buildObjectMetadataItemsFromMarketplaceApp(
defaultRole,
objectUniversalIdToIdMap,
marketplaceAppObjects,
@@ -177,8 +177,8 @@ export const SettingsApplicationsDeveloperTab = () => {
<TableRow
gridTemplateColumns={APPLICATION_TABLE_ROW_GRID_TEMPLATE_COLUMNS}
>
<TableHeader>{t`Name`}</TableHeader>
<TableHeader>{t`Type`}</TableHeader>
<TableHeader> {t`Name`}</TableHeader>
<TableHeader>{''}</TableHeader>
<TableHeader>{''}</TableHeader>
<TableHeader />
</TableRow>
@@ -188,7 +188,6 @@ export const SettingsApplicationsDeveloperTab = () => {
<SettingsApplicationTableRow
key={registration.id}
application={registration}
sourceType={registration.sourceType}
action={
<IconChevronRight
size={theme.icon.size.md}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "twenty-sdk",
"version": "2.8.0",
"version": "2.9.0",
"sideEffects": false,
"bin": {
"twenty": "dist/cli.cjs"
@@ -51,7 +51,7 @@ import {
getInputSchemaFromSourceCode,
jsonSchemaToInputSchema,
} from 'twenty-shared/logic-function';
import { assertUnreachable } from 'twenty-shared/utils';
import { assertUnreachable, isDefined } from 'twenty-shared/utils';
const loadSources = async (appPath: string): Promise<string[]> => {
return await glob(['**/*.ts', '**/*.tsx'], {
@@ -358,7 +358,7 @@ export const buildManifest = async (
errors.push(...extract.errors);
warnings.push(...(extract.warnings ?? []));
const { component, ...rest } = extract.config;
const { component, command, ...rest } = extract.config;
const relativeFilePath = relative(appPath, filePath);
@@ -374,6 +374,14 @@ export const buildManifest = async (
frontComponents.push(config);
frontComponentsFilePaths.push(relativePath);
if (isDefined(command)) {
commandMenuItems.push({
...command,
frontComponentUniversalIdentifier: config.universalIdentifier,
} as unknown as CommandMenuItemManifest);
commandMenuItemsFilePaths.push(relativePath);
}
break;
}
case ManifestEntityKey.Views: {
@@ -19,6 +19,16 @@ export const defineFrontComponent: DefineEntity<FrontComponentConfig> = (
errors.push('Front component component must be a React component');
}
if (config.command) {
if (!config.command.universalIdentifier) {
errors.push('Command must have a universalIdentifier');
}
if (!config.command.label) {
errors.push('Command must have a label');
}
}
return createValidationResult({
config,
errors,
@@ -1,7 +1,17 @@
import { type FrontComponentManifest } from 'twenty-shared/application';
import {
type FrontComponentCommandManifest,
type FrontComponentManifest,
} from 'twenty-shared/application';
export type FrontComponentType = React.ComponentType<any>;
export type FrontComponentCommandConfig = Omit<
FrontComponentCommandManifest,
'conditionalAvailabilityExpression'
> & {
conditionalAvailabilityExpression?: boolean | string;
};
export type FrontComponentConfig = Omit<
FrontComponentManifest,
| 'sourceComponentPath'
@@ -11,4 +21,5 @@ export type FrontComponentConfig = Omit<
| 'usesSdkClient'
> & {
component: FrontComponentType;
command?: FrontComponentCommandConfig;
};
@@ -2,7 +2,6 @@ import {
DEFAULT_API_URL_NAME,
DEFAULT_APP_ACCESS_TOKEN_NAME,
} from 'twenty-shared/application';
import { buildPublicAssetUrl } from 'twenty-shared/utils';
const decodeTokenPayload = (
token: string,
@@ -34,24 +33,10 @@ export const getPublicAssetUrl = (path: string): string => {
? withoutLeadingSlash
: `public/${withoutLeadingSlash}`;
const isAbsolute =
withPublicPrefix.startsWith('http://') ||
withPublicPrefix.startsWith('https://');
const encodedPath = withPublicPrefix
.split('/')
.map(encodeURIComponent)
.join('/');
const encodedPath = isAbsolute
? withPublicPrefix
: withPublicPrefix.split('/').map(encodeURIComponent).join('/');
const url = buildPublicAssetUrl({
path: encodedPath,
serverUrl: apiUrl,
workspaceId,
applicationId,
});
if (!url) {
throw new Error('Failed to build public asset URL');
}
return url;
return `${apiUrl}/public-assets/${workspaceId}/${applicationId}/${encodedPath}`;
};
+5 -2
View File
@@ -96,5 +96,8 @@ FRONTEND_URL=http://localhost:3001
# Add custom providers (private gateway, extra regions, etc.):
# AI_PROVIDERS='{"my-gateway":{"type":"openai-compatible","baseUrl":"...","apiKey":"..."}}'
#
# Override model preferences (disabled, recommended, defaults):
# AI_MODEL_PREFERENCES='{"recommendedModels":["openai/gpt-5.2"],"defaultFastModels":["openai/gpt-5-mini"]}'
# Comma-separated model IDs (JSON array syntax also accepted, e.g. '["a","b"]'):
# AI_MODELS_DEFAULT_FAST=openai/gpt-5-mini,anthropic/claude-haiku-4-5-20251001
# AI_MODELS_DEFAULT_SMART=openai/gpt-5.2,anthropic/claude-sonnet-4-6
# AI_MODELS_DEFAULT_RECOMMENDED=openai/gpt-5.2,openai/gpt-4.1,anthropic/claude-sonnet-4-6
# AI_MODELS_DEFAULT_DISABLED=
@@ -29,7 +29,9 @@ export class AddWorkspaceIdToTotoFastInstanceCommand
implements FastInstanceCommand
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "core"."toto" ADD "workspaceId" uuid`);
await queryRunner.query(
`ALTER TABLE "core"."toto" ADD "workspaceId" uuid`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
@@ -79,9 +81,12 @@ Workspace commands run per-workspace logic across all active or suspended worksp
@RegisteredWorkspaceCommand('1.22.0', 1780000002000)
@Command({
name: 'upgrade:1-22:backfill-standard-skills',
description: 'Backfill standard skills for existing workspaces',
description:
'Backfill standard skills for existing workspaces',
})
export class BackfillStandardSkillsCommand extends ActiveOrSuspendedWorkspaceCommandRunner {
export class BackfillStandardSkillsCommand
extends ActiveOrSuspendedWorkspaceCommandRunner
{
constructor(
protected readonly workspaceIteratorService: WorkspaceIteratorService,
// inject any services you need
@@ -3,14 +3,12 @@
This Bash script helps generate self-signed SSL certificates for local development. It uses OpenSSL to create a root certificate authority, a domain certificate, and configures them for local usage.
## Features
- Generates a private key and root certificate.
- Creates a signed certificate for a specified domain.
- Adds the root certificate to the macOS keychain for trusted usage (macOS only).
- Customizable with default values for easier use.
## Requirements
- OpenSSL
## Usage
@@ -32,27 +30,24 @@ To generate certificates using the default values:
#### Examples:
1. **Using Default Values**:
```sh
./script.sh
```
```sh
./script.sh
```
2. **Custom Domain Name**:
```sh
./script.sh example.com
```
```sh
./script.sh example.com
```
3. **Custom Domain Name and Root Certificate Name**:
```sh
./script.sh example.com customRootCertificate
```
```sh
./script.sh example.com customRootCertificate
```
4. **Custom Domain Name, Root Certificate Name, and Validity Days**:
```sh
./script.sh example.com customRootCertificate 398
```
```sh
./script.sh example.com customRootCertificate 398
```
## Script Details
@@ -76,4 +71,4 @@ The generated files are stored in `~/certs/{domain}`:
## Notes
- If running on non-macOS systems, you'll need to manually add the root certificate to your trusted certificate store.
- Ensure that OpenSSL is installed and available in your PATH.
- Ensure that OpenSSL is installed and available in your PATH.
@@ -1,39 +0,0 @@
import { QueryRunner } from 'typeorm';
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
import { FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface';
@RegisteredInstanceCommand('2.8.0', 1779387505162)
export class AddLogoToApplicationRegistrationFastInstanceCommand
implements FastInstanceCommand
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
'ALTER TABLE "core"."applicationRegistration" ADD "logo" text',
);
await queryRunner.query(
'ALTER TABLE "core"."applicationRegistration" ADD "logoFileId" uuid',
);
await queryRunner.query(
'ALTER TABLE "core"."applicationRegistration" ADD CONSTRAINT "UQ_796819fb23559c233e6ebd49f34" UNIQUE ("logoFileId")',
);
await queryRunner.query(
'ALTER TABLE "core"."applicationRegistration" ADD CONSTRAINT "FK_796819fb23559c233e6ebd49f34" FOREIGN KEY ("logoFileId") REFERENCES "core"."file"("id") ON DELETE SET NULL ON UPDATE NO ACTION',
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
'ALTER TABLE "core"."applicationRegistration" DROP CONSTRAINT "FK_796819fb23559c233e6ebd49f34"',
);
await queryRunner.query(
'ALTER TABLE "core"."applicationRegistration" DROP CONSTRAINT "UQ_796819fb23559c233e6ebd49f34"',
);
await queryRunner.query(
'ALTER TABLE "core"."applicationRegistration" DROP COLUMN "logoFileId"',
);
await queryRunner.query(
'ALTER TABLE "core"."applicationRegistration" DROP COLUMN "logo"',
);
}
}
@@ -1,23 +0,0 @@
import { DataSource, QueryRunner } from 'typeorm';
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
import { SlowInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/slow-instance-command.interface';
@RegisteredInstanceCommand('2.8.0', 1779387601428, { type: 'slow' })
export class BackfillApplicationRegistrationLogoSlowInstanceCommand
implements SlowInstanceCommand
{
async runDataMigration(dataSource: DataSource): Promise<void> {
await dataSource.query(`
UPDATE "core"."applicationRegistration"
SET "logo" = manifest->'application'->>'logoUrl'
WHERE manifest IS NOT NULL
AND manifest->'application'->>'logoUrl' IS NOT NULL
AND "logo" IS NULL
`);
}
public async up(_queryRunner: QueryRunner): Promise<void> {}
public async down(_queryRunner: QueryRunner): Promise<void> {}
}
@@ -1,10 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module';
import { DropChannelStandardObjectsCommand } from 'src/database/commands/upgrade-version-command/2-8/2-8-workspace-command-1798000050000-drop-channel-standard-objects.command';
import { BackfillRelationJoinColumnIndexesCommand } from 'src/database/commands/upgrade-version-command/2-8/2-8-workspace-command-1798100000000-backfill-relation-join-column-indexes.command';
import { GateDefaultCommandMenuItemsByPermissionFlagCommand } from 'src/database/commands/upgrade-version-command/2-8/2-8-workspace-command-1798100010000-gate-default-command-menu-items-by-permission-flag.command';
import { RestoreChannelAssociationScalarFieldMetadataCommand } from 'src/database/commands/upgrade-version-command/2-8/2-8-workspace-command-1798100020000-restore-channel-association-scalar-field-metadata.command';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { WorkspaceMetadataVersionModule } from 'src/engine/metadata-modules/workspace-metadata-version/workspace-metadata-version.module';
import { WorkspaceSchemaManagerModule } from 'src/engine/twenty-orm/workspace-schema-manager/workspace-schema-manager.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration.module';
@@ -12,8 +16,10 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
@Module({
imports: [
ApplicationModule,
TypeOrmModule.forFeature([FieldMetadataEntity]),
WorkspaceCacheModule,
WorkspaceIteratorModule,
WorkspaceMetadataVersionModule,
WorkspaceMigrationModule,
WorkspaceSchemaManagerModule,
],
@@ -21,6 +27,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
DropChannelStandardObjectsCommand,
BackfillRelationJoinColumnIndexesCommand,
GateDefaultCommandMenuItemsByPermissionFlagCommand,
RestoreChannelAssociationScalarFieldMetadataCommand,
],
})
export class V2_8_UpgradeVersionCommandModule {}
@@ -0,0 +1,290 @@
import { Command } from 'nest-commander';
import { InjectRepository } from '@nestjs/typeorm';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { type QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
import { v4 as uuidv4 } from 'uuid';
import { ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner';
import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service';
import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util';
import { getMetadataFlatEntityMapsKey } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-flat-entity-maps-key.util';
import { getMetadataRelatedMetadataNames } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-related-metadata-names.util';
import { getMetadataSerializedRelationNames } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-serialized-relation-names.util';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { WorkspaceMetadataVersionService } from 'src/engine/metadata-modules/workspace-metadata-version/services/workspace-metadata-version.service';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { getWorkspaceSchemaContextForMigration } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/get-workspace-schema-context-for-migration.util';
// 2-8 dropped calendarChannel/messageChannel/messageFolder standard objects, which
// cascade-deleted the relation field metadata that owned the join columns on the
// surviving association objects. The physical columns + their data survived, but the
// replacement standard scalar field metadata was never created for existing workspaces,
// so TwentyORM builds the association entity without the join field and every calendar /
// message import crashes with `Property "<channel>Id" was not found`.
// This re-registers the scalar field metadata over the existing physical columns.
type ScalarFieldToRestore = {
objectUniversalIdentifier: string;
fieldUniversalIdentifier: string;
fieldName: string;
label: string;
description: string;
icon: string;
isNullable: boolean;
};
const SCALAR_FIELDS_TO_RESTORE: ScalarFieldToRestore[] = [
{
objectUniversalIdentifier: '20202020-491b-4aaa-9825-afd1bae6ae00',
fieldUniversalIdentifier: '20202020-93ee-4da4-8d58-0282c4a9cb7d',
fieldName: 'calendarChannelId',
label: 'Channel ID',
description: 'Channel ID',
icon: 'IconCalendar',
isNullable: false,
},
{
objectUniversalIdentifier: '20202020-ad1e-4127-bccb-d83ae04d2ccb',
fieldUniversalIdentifier: '20202020-b658-408f-bd46-3bd2d15d7e52',
fieldName: 'messageChannelId',
label: 'Message Channel Id',
description: 'Message Channel Id',
icon: 'IconHash',
isNullable: true,
},
{
objectUniversalIdentifier: '20202020-a1b0-40b0-8ab0-5b6c7d8e9f0a',
fieldUniversalIdentifier: 'b3369d31-3856-4a7a-b007-ee353918127c',
fieldName: 'messageFolderId',
label: 'Message Folder',
description: 'Message Folder',
icon: 'IconFolder',
isNullable: false,
},
];
@RegisteredWorkspaceCommand('2.8.0', 1798100020000)
@Command({
name: 'upgrade:2-8:restore-channel-association-scalar-field-metadata',
description:
'Re-register the calendarChannelId/messageChannelId/messageFolderId scalar field metadata on the surviving association objects, over their already-existing physical columns, for workspaces where 2-8 drop-channel-standard-objects cascade-removed them',
})
export class RestoreChannelAssociationScalarFieldMetadataCommand extends ActiveOrSuspendedWorkspaceCommandRunner {
constructor(
protected readonly workspaceIteratorService: WorkspaceIteratorService,
private readonly applicationService: ApplicationService,
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly workspaceMetadataVersionService: WorkspaceMetadataVersionService,
@InjectRepository(FieldMetadataEntity)
private readonly fieldMetadataRepository: Repository<FieldMetadataEntity>,
) {
super(workspaceIteratorService);
}
override async runOnWorkspace({
workspaceId,
dataSource,
options,
}: RunOnWorkspaceArgs): Promise<void> {
const isDryRun = options.dryRun ?? false;
if (!isDefined(dataSource)) {
this.logger.log(`No data source for workspace ${workspaceId}, skipping`);
return;
}
const { flatObjectMetadataMaps, flatFieldMetadataMaps } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatObjectMetadataMaps',
'flatFieldMetadataMaps',
]);
// Only fields whose object still exists and whose scalar metadata is absent.
// For already-healthy workspaces this is empty, so no schema query runs below.
const candidates = SCALAR_FIELDS_TO_RESTORE.flatMap((fieldToRestore) => {
const flatObjectMetadata =
findFlatEntityByUniversalIdentifier<FlatObjectMetadata>({
flatEntityMaps: flatObjectMetadataMaps,
universalIdentifier: fieldToRestore.objectUniversalIdentifier,
});
if (!isDefined(flatObjectMetadata)) {
return [];
}
const existingFlatFieldMetadata =
findFlatEntityByUniversalIdentifier<FlatFieldMetadata>({
flatEntityMaps: flatFieldMetadataMaps,
universalIdentifier: fieldToRestore.fieldUniversalIdentifier,
});
if (isDefined(existingFlatFieldMetadata)) {
return [];
}
const { schemaName, tableName } = getWorkspaceSchemaContextForMigration({
workspaceId,
objectMetadata: flatObjectMetadata,
});
return [{ fieldToRestore, flatObjectMetadata, schemaName, tableName }];
});
if (candidates.length === 0) {
this.logger.log(
`No channel association scalar field metadata to restore for workspace ${workspaceId}`,
);
return;
}
const existingColumnKeys = await this.getExistingColumnKeys({
dataSource,
tableColumnPairs: candidates.map(({ tableName, fieldToRestore }) => ({
tableName,
columnName: fieldToRestore.fieldName,
})),
schemaName: candidates[0].schemaName,
});
const fieldMetadataRowsToInsert: QueryDeepPartialEntity<FieldMetadataEntity>[] =
[];
for (const {
fieldToRestore,
flatObjectMetadata,
tableName,
} of candidates) {
if (!existingColumnKeys.has(`${tableName}.${fieldToRestore.fieldName}`)) {
this.logger.warn(
`Physical column ${tableName}.${fieldToRestore.fieldName} missing for workspace ${workspaceId} - skipping metadata-only restore (needs schema repair)`,
);
continue;
}
fieldMetadataRowsToInsert.push({
id: uuidv4(),
universalIdentifier: fieldToRestore.fieldUniversalIdentifier,
objectMetadataId: flatObjectMetadata.id,
workspaceId,
type: FieldMetadataType.UUID,
name: fieldToRestore.fieldName,
label: fieldToRestore.label,
description: fieldToRestore.description,
icon: fieldToRestore.icon,
isCustom: false,
isActive: true,
isSystem: false,
isNullable: fieldToRestore.isNullable,
isUIReadOnly: true,
isLabelSyncedWithName: false,
});
}
if (fieldMetadataRowsToInsert.length === 0) {
this.logger.log(
`No channel association scalar field metadata to restore for workspace ${workspaceId}`,
);
return;
}
this.logger.log(
`${isDryRun ? '[DRY RUN] ' : ''}Restoring ${fieldMetadataRowsToInsert.length} scalar field metadata row(s) for workspace ${workspaceId}: ${fieldMetadataRowsToInsert.map((row) => row.name).join(', ')}`,
);
if (isDryRun) {
return;
}
const { twentyStandardFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
);
const fieldMetadataRowsWithApplication = fieldMetadataRowsToInsert.map(
(row) => ({
...row,
applicationId: twentyStandardFlatApplication.id,
}),
);
await this.fieldMetadataRepository.insert(fieldMetadataRowsWithApplication);
// Derive the affected cache keys from the fieldMetadata metadata name using
// the same utility set the migration runner uses, so the flush scope stays
// in sync with the canonical dependency graph automatically.
const fieldMetadataRelatedNames = [
'fieldMetadata',
...getMetadataRelatedMetadataNames('fieldMetadata'),
...getMetadataSerializedRelationNames('fieldMetadata'),
] as const;
const cacheKeysToFlush = [
...new Set(fieldMetadataRelatedNames.map(getMetadataFlatEntityMapsKey)),
];
await this.workspaceCacheService.flush(workspaceId, [
...cacheKeysToFlush,
'ORMEntityMetadatas',
'graphQLResolverNameMap',
]);
await this.workspaceMetadataVersionService.incrementMetadataVersion(
workspaceId,
);
this.logger.log(
`Restored ${fieldMetadataRowsWithApplication.length} scalar field metadata row(s) for workspace ${workspaceId}`,
);
}
// Single information_schema lookup for all candidate columns in the workspace
// schema. Returns a set of `tableName.columnName` keys that physically exist.
private async getExistingColumnKeys({
dataSource,
schemaName,
tableColumnPairs,
}: {
dataSource: NonNullable<RunOnWorkspaceArgs['dataSource']>;
schemaName: string;
tableColumnPairs: { tableName: string; columnName: string }[];
}): Promise<Set<string>> {
const tableNames = [
...new Set(tableColumnPairs.map(({ tableName }) => tableName)),
];
const columnNames = [
...new Set(tableColumnPairs.map(({ columnName }) => columnName)),
];
// The workspace data source blocks raw query(); use a query runner like sibling
// upgrade commands do for direct schema access.
const queryRunner = dataSource.createQueryRunner();
try {
await queryRunner.connect();
const rows: { table_name: string; column_name: string }[] =
await queryRunner.query(
`SELECT table_name, column_name
FROM information_schema.columns
WHERE table_schema = $1
AND table_name = ANY($2)
AND column_name = ANY($3)`,
[schemaName, tableNames, columnNames],
);
return new Set(rows.map((row) => `${row.table_name}.${row.column_name}`));
} finally {
await queryRunner.release();
}
}
}
@@ -0,0 +1,118 @@
import { Logger } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { DataSource, IsNull, QueryRunner } from 'typeorm';
import {
KeyValuePairEntity,
KeyValuePairType,
} from 'src/engine/core-modules/key-value-pair/key-value-pair.entity';
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
import { SlowInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/slow-instance-command.interface';
import { aiModelPreferencesSchema } from 'src/engine/metadata-modules/ai/ai-models/types/ai-model-preferences.schema';
const NEW_KEYS = [
'AI_MODELS_DEFAULT_FAST',
'AI_MODELS_DEFAULT_SMART',
'AI_MODELS_DEFAULT_RECOMMENDED',
'AI_MODELS_DEFAULT_DISABLED',
] as const;
const PREFERENCE_KEY_MAP = {
AI_MODELS_DEFAULT_FAST: 'defaultFastModels',
AI_MODELS_DEFAULT_SMART: 'defaultSmartModels',
AI_MODELS_DEFAULT_RECOMMENDED: 'recommendedModels',
AI_MODELS_DEFAULT_DISABLED: 'disabledModels',
} as const;
@RegisteredInstanceCommand('2.9.0', 1799000010000, { type: 'slow' })
export class MigrateAiModelPreferencesSlowInstanceCommand
implements SlowInstanceCommand
{
private readonly logger = new Logger(
MigrateAiModelPreferencesSlowInstanceCommand.name,
);
async runDataMigration(dataSource: DataSource): Promise<void> {
const keyValuePairRepository = dataSource.getRepository(KeyValuePairEntity);
const existingRow = await keyValuePairRepository.findOne({
where: {
type: KeyValuePairType.CONFIG_VARIABLE,
key: 'AI_MODEL_PREFERENCES',
userId: IsNull(),
workspaceId: IsNull(),
},
});
if (!isDefined(existingRow)) {
this.logger.log(
'No server-level AI_MODEL_PREFERENCES row found, skipping',
);
return;
}
const parseResult = aiModelPreferencesSchema.safeParse(existingRow.value);
if (!parseResult.success) {
this.logger.error(
`Failed to parse server-level AI_MODEL_PREFERENCES: ${parseResult.error.message}`,
);
return;
}
const prefs = parseResult.data;
this.logger.log('Migrating server-level AI_MODEL_PREFERENCES');
await dataSource.transaction(async (manager) => {
const transactionalRepository = manager.getRepository(KeyValuePairEntity);
for (const newKey of NEW_KEYS) {
const prefField = PREFERENCE_KEY_MAP[newKey];
const value = prefs[prefField];
if (!isDefined(value) || value.length === 0) {
continue;
}
const existingNewKeyCount = await transactionalRepository.count({
where: {
type: KeyValuePairType.CONFIG_VARIABLE,
key: newKey,
userId: IsNull(),
workspaceId: IsNull(),
},
});
if (existingNewKeyCount > 0) {
continue;
}
await transactionalRepository.insert({
key: newKey,
value: value as unknown as JSON,
type: KeyValuePairType.CONFIG_VARIABLE,
userId: null,
workspaceId: null,
});
}
await transactionalRepository.delete({ id: existingRow.id });
});
this.logger.log(
'Migrated server-level AI_MODEL_PREFERENCES to 4 individual vars',
);
}
public async up(_queryRunner: QueryRunner): Promise<void> {
return;
}
public async down(_queryRunner: QueryRunner): Promise<void> {
return;
}
}
@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module';
import { MigrateAiModelPreferencesCommand } from 'src/database/commands/upgrade-version-command/2-9/2-9-workspace-command-1799000000000-migrate-ai-model-preferences.command';
import { KeyValuePairEntity } from 'src/engine/core-modules/key-value-pair/key-value-pair.entity';
@Module({
imports: [
TypeOrmModule.forFeature([KeyValuePairEntity]),
WorkspaceIteratorModule,
],
providers: [MigrateAiModelPreferencesCommand],
})
export class V2_9_UpgradeVersionCommandModule {}
@@ -0,0 +1,136 @@
import { InjectRepository } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
import { IsNull, Repository } from 'typeorm';
import { isArray, isDefined } from 'class-validator';
import { ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner';
import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service';
import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner';
import {
KeyValuePairEntity,
KeyValuePairType,
} from 'src/engine/core-modules/key-value-pair/key-value-pair.entity';
import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator';
import { aiModelPreferencesSchema } from 'src/engine/metadata-modules/ai/ai-models/types/ai-model-preferences.schema';
const NEW_KEYS = [
'AI_MODELS_DEFAULT_FAST',
'AI_MODELS_DEFAULT_SMART',
'AI_MODELS_DEFAULT_RECOMMENDED',
'AI_MODELS_DEFAULT_DISABLED',
] as const;
const PREFERENCE_KEY_MAP = {
AI_MODELS_DEFAULT_FAST: 'defaultFastModels',
AI_MODELS_DEFAULT_SMART: 'defaultSmartModels',
AI_MODELS_DEFAULT_RECOMMENDED: 'recommendedModels',
AI_MODELS_DEFAULT_DISABLED: 'disabledModels',
} as const;
@RegisteredWorkspaceCommand('2.9.0', 1799000000000)
@Command({
name: 'upgrade:2-9:migrate-ai-model-preferences',
description:
'Migrate AI_MODEL_PREFERENCES config var to the four individual AI_MODELS_DEFAULT_* vars, per workspace',
})
export class MigrateAiModelPreferencesCommand extends ActiveOrSuspendedWorkspaceCommandRunner {
constructor(
protected readonly workspaceIteratorService: WorkspaceIteratorService,
@InjectRepository(KeyValuePairEntity)
private readonly keyValuePairRepository: Repository<KeyValuePairEntity>,
) {
super(workspaceIteratorService);
}
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
const isDryRun = options.dryRun ?? false;
const existingPreferencesRow = await this.keyValuePairRepository.findOne({
where: {
key: 'AI_MODEL_PREFERENCES',
type: KeyValuePairType.CONFIG_VARIABLE,
workspaceId,
userId: IsNull(),
},
});
if (existingPreferencesRow === null) {
this.logger.log(
`No AI_MODEL_PREFERENCES row found for workspace ${workspaceId}, skipping`,
);
return;
}
const parseResult = aiModelPreferencesSchema.safeParse(
existingPreferencesRow.value,
);
if (!parseResult.success) {
this.logger.error(
`Failed to parse AI_MODEL_PREFERENCES for workspace ${workspaceId}: ${parseResult.error.message}`,
);
return;
}
const prefs = parseResult.data;
this.logger.log(
`${isDryRun ? '[DRY RUN] ' : ''}Migrating AI_MODEL_PREFERENCES for workspace ${workspaceId}`,
);
for (const newKey of NEW_KEYS) {
const prefField = PREFERENCE_KEY_MAP[newKey];
if (
!isDefined(prefField) ||
(isArray(prefs[prefField]) && prefs[prefField].length === 0)
) {
continue;
}
const value = prefs[prefField];
if (isDryRun) {
this.logger.log(
`[DRY RUN] Would insert ${newKey} = ${JSON.stringify(value)} for workspace ${workspaceId}`,
);
continue;
}
const existingNewKeyRow = await this.keyValuePairRepository.findOne({
where: {
key: newKey,
type: KeyValuePairType.CONFIG_VARIABLE,
workspaceId,
userId: IsNull(),
},
});
if (existingNewKeyRow !== null) {
continue;
}
await this.keyValuePairRepository.insert({
key: newKey,
value: value as unknown as JSON,
type: KeyValuePairType.CONFIG_VARIABLE,
workspaceId,
userId: null,
});
}
if (!isDryRun) {
await this.keyValuePairRepository.delete({
id: existingPreferencesRow.id,
});
this.logger.log(
`Migrated AI_MODEL_PREFERENCES to 4 individual vars for workspace ${workspaceId}`,
);
}
}
}
@@ -53,10 +53,9 @@ import { DropPostgresCredentialsTableFastInstanceCommand } from 'src/database/co
import { AddRelationTargetFieldMetadataIdToViewFilterFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-6/2-6-instance-command-fast-1798000005000-add-relation-target-field-metadata-id-to-view-filter';
import { AddChannelSyncStageIndexesFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-6/2-6-instance-command-fast-1798000010000-add-channel-sync-stage-indexes';
import { FinalizeRolePermissionFlagCutoverFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-7/2-7-instance-command-fast-1779600000000-finalize-role-permission-flag-cutover';
import { AddLogoToApplicationRegistrationFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-8/2-8-instance-command-fast-1779387505162-add-logo-to-application-registration';
import { BackfillApplicationRegistrationLogoSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-8/2-8-instance-command-slow-1779387601428-backfill-application-registration-logo';
import { AddSubFieldNameToIndexFieldMetadataFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-8/2-8-instance-command-fast-1798200000000-add-sub-field-name-to-index-field-metadata';
import { DropFieldMetadataIsUniqueColumnFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-8/2-8-instance-command-fast-1798300000000-drop-field-metadata-is-unique-column';
import { MigrateAiModelPreferencesSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-9/2-9-instance-command-slow-1799000010000-migrate-ai-model-preferences';
export const INSTANCE_COMMANDS = [
AddViewFieldGroupIdIndexOnViewFieldFastInstanceCommand,
@@ -112,8 +111,7 @@ export const INSTANCE_COMMANDS = [
AddRelationTargetFieldMetadataIdToViewFilterFastInstanceCommand,
AddChannelSyncStageIndexesFastInstanceCommand,
FinalizeRolePermissionFlagCutoverFastInstanceCommand,
AddLogoToApplicationRegistrationFastInstanceCommand,
BackfillApplicationRegistrationLogoSlowInstanceCommand,
AddSubFieldNameToIndexFieldMetadataFastInstanceCommand,
DropFieldMetadataIsUniqueColumnFastInstanceCommand,
MigrateAiModelPreferencesSlowInstanceCommand,
];
@@ -11,6 +11,7 @@ import { V2_4_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-
import { V2_5_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-5/2-5-upgrade-version-command.module';
import { V2_7_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-7/2-7-upgrade-version-command.module';
import { V2_8_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-8/2-8-upgrade-version-command.module';
import { V2_9_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-9/2-9-upgrade-version-command.module';
@Module({
imports: [
@@ -25,6 +26,7 @@ import { V2_8_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-
V2_5_UpgradeVersionCommandModule,
V2_7_UpgradeVersionCommandModule,
V2_8_UpgradeVersionCommandModule,
V2_9_UpgradeVersionCommandModule,
],
})
export class WorkspaceCommandProviderModule {}
@@ -2,22 +2,10 @@ import { Context, Parent, ResolveField } from '@nestjs/graphql';
import { AdminResolver } from 'src/engine/api/graphql/graphql-config/decorators/admin-resolver.decorator';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
import { ApplicationRegistrationLogoService } from 'src/engine/core-modules/application/application-registration/application-registration-logo.service';
import { type IDataloaders } from 'src/engine/dataloaders/dataloader.interface';
@AdminResolver(() => ApplicationRegistrationEntity)
export class AdminPanelApplicationRegistrationResolver {
constructor(
private readonly applicationRegistrationLogoService: ApplicationRegistrationLogoService,
) {}
@ResolveField(() => String, { nullable: true })
async logo(
@Parent() registration: ApplicationRegistrationEntity,
): Promise<string | null> {
return this.applicationRegistrationLogoService.resolveLogoUrl(registration);
}
@ResolveField(() => Boolean)
async isConfigured(
@Parent() registration: ApplicationRegistrationEntity,
@@ -20,9 +20,9 @@ import { DevelopmentApplicationDTO } from 'src/engine/core-modules/application/a
import { GenerateApplicationTokenInput } from 'src/engine/core-modules/application/application-development/dtos/generate-application-token.input';
import { UploadApplicationFileInput } from 'src/engine/core-modules/application/application-development/dtos/upload-application-file.input';
import { WorkspaceMigrationDTO } from 'src/engine/core-modules/application/application-development/dtos/workspace-migration.dto';
import { validateFilePath } from 'src/engine/core-modules/file-storage/utils/validate-file-path.util';
import { ApplicationExceptionFilter } from 'src/engine/core-modules/application/application-exception-filter';
import { ApplicationSyncService } from 'src/engine/core-modules/application/application-manifest/application-sync.service';
import { resolveManifestAssetUrls } from 'src/engine/core-modules/application/application-marketplace/utils/resolve-manifest-asset-urls.util';
import { ApplicationTokenPairDTO } from 'src/engine/core-modules/application/application-oauth/dtos/application-token-pair.dto';
import { ApplicationRegistrationVariableService } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.service';
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
@@ -34,10 +34,12 @@ import {
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import { validateFilePath } from 'src/engine/core-modules/file-storage/utils/validate-file-path.util';
import { FileDTO } from 'src/engine/core-modules/file/dtos/file.dto';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import { SdkClientGenerationService } from 'src/engine/core-modules/sdk-client/sdk-client-generation.service';
import { ThrottlerService } from 'src/engine/core-modules/throttler/throttler.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
@@ -67,6 +69,7 @@ export class ApplicationDevelopmentResolver {
private readonly applicationRegistrationVariableService: ApplicationRegistrationVariableService,
private readonly fileStorageService: FileStorageService,
private readonly sdkClientGenerationService: SdkClientGenerationService,
private readonly twentyConfigService: TwentyConfigService,
private readonly throttlerService: ThrottlerService,
) {}
@@ -170,7 +173,12 @@ export class ApplicationDevelopmentResolver {
});
}
await this.syncRegistrationMetadata(applicationRegistrationId, manifest);
await this.syncRegistrationMetadata(
applicationRegistrationId,
manifest,
workspaceId,
application.id,
);
return {
applicationUniversalIdentifier:
@@ -184,7 +192,7 @@ export class ApplicationDevelopmentResolver {
async uploadApplicationFile(
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
@Args({ name: 'file', type: () => GraphQLUpload })
{ createReadStream, mimetype }: FileUpload,
{ createReadStream }: FileUpload,
@Args()
{
applicationUniversalIdentifier,
@@ -242,7 +250,6 @@ export class ApplicationDevelopmentResolver {
return await this.fileStorageService.writeFile({
sourceFile: buffer,
mimeType: mimetype,
fileFolder,
applicationUniversalIdentifier,
workspaceId,
@@ -284,11 +291,20 @@ export class ApplicationDevelopmentResolver {
private async syncRegistrationMetadata(
applicationRegistrationId: string,
manifest: ApplicationInput['manifest'],
workspaceId: string,
applicationId: string,
): Promise<void> {
const serverUrl = this.twentyConfigService.get('SERVER_URL');
const manifestWithResolvedUrls = resolveManifestAssetUrls(
manifest,
(filePath) =>
`${serverUrl}/public-assets/${workspaceId}/${applicationId}/${filePath}`,
);
await this.applicationRegistrationService.updateFromManifest(
applicationRegistrationId,
manifest,
ApplicationRegistrationSourceType.LOCAL,
manifestWithResolvedUrls,
);
if (manifest.application.serverVariables) {
@@ -9,7 +9,6 @@ import { ApplicationManifestModule } from 'src/engine/core-modules/application/a
import { ApplicationPackageModule } from 'src/engine/core-modules/application/application-package/application-package.module';
import { ApplicationInstallResolver } from 'src/engine/core-modules/application/application-install/application-install.resolver';
import { ApplicationInstallService } from 'src/engine/core-modules/application/application-install/application-install.service';
import { ApplicationLogoResolver } from 'src/engine/core-modules/application/application-install/application-logo.resolver';
import { FileStorageModule } from 'src/engine/core-modules/file-storage/file-storage.module';
import { LogicFunctionModule } from 'src/engine/core-modules/logic-function/logic-function.module';
import { SdkClientModule } from 'src/engine/core-modules/sdk-client/sdk-client.module';
@@ -30,11 +29,7 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
FileStorageModule,
WorkspaceCacheModule,
],
providers: [
ApplicationInstallResolver,
ApplicationInstallService,
ApplicationLogoResolver,
],
providers: [ApplicationInstallResolver, ApplicationInstallService],
exports: [ApplicationInstallService],
})
export class ApplicationInstallModule {}
@@ -5,8 +5,6 @@ import { promises as fs } from 'fs';
import { resolve } from 'path';
import semver from 'semver';
import { extractFileInfo } from 'src/engine/core-modules/file/utils/extract-file-info.utils';
import { sanitizeFile } from 'src/engine/core-modules/file/utils/sanitize-file.utils';
import { Manifest } from 'twenty-shared/application';
import { FileFolder } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
@@ -493,15 +491,8 @@ export class ApplicationInstallService {
);
}
const { mimeType, ext } = await extractFileInfo({
file: content,
filename: relativePath,
});
const sanitizedContent = sanitizeFile({ file: content, ext, mimeType });
await this.fileStorageService.writeFile({
sourceFile: sanitizedContent,
mimeType,
sourceFile: content,
fileFolder,
applicationUniversalIdentifier,
workspaceId,
@@ -1,29 +0,0 @@
import { Parent, ResolveField } from '@nestjs/graphql';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { ApplicationDTO } from 'src/engine/core-modules/application/dtos/application.dto';
import { resolveApplicationLogoUrl } from 'src/engine/core-modules/application/utils/resolve-application-logo-url.util';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@MetadataResolver(() => ApplicationDTO)
export class ApplicationLogoResolver {
constructor(private readonly twentyConfigService: TwentyConfigService) {}
@ResolveField(() => String, { nullable: true })
logo(
@Parent() application: ApplicationDTO & { workspaceId?: string },
): string | null {
if (!application.workspaceId) {
return application.logo ?? null;
}
const serverUrl = this.twentyConfigService.get('SERVER_URL');
return resolveApplicationLogoUrl({
logo: application.logo,
serverUrl,
workspaceId: application.workspaceId,
applicationId: application.id,
});
}
}
@@ -36,11 +36,6 @@ export class MarketplaceAppDetailDTO {
@Field({ nullable: true })
latestAvailableVersion?: string;
@IsOptional()
@IsString()
@Field({ nullable: true })
logo?: string;
@IsBoolean()
@Field(() => Boolean)
isListed: boolean;
@@ -3,6 +3,9 @@ import { Injectable, Logger } from '@nestjs/common';
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum';
import { MarketplaceService } from 'src/engine/core-modules/application/application-marketplace/marketplace.service';
import { buildRegistryCdnUrl } from 'src/engine/core-modules/application/application-marketplace/utils/build-registry-cdn-url.util';
import { resolveManifestAssetUrls } from 'src/engine/core-modules/application/application-marketplace/utils/resolve-manifest-asset-urls.util';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@Injectable()
export class MarketplaceCatalogSyncService {
@@ -11,6 +14,7 @@ export class MarketplaceCatalogSyncService {
constructor(
private readonly applicationRegistrationService: ApplicationRegistrationService,
private readonly marketplaceService: MarketplaceService,
private readonly twentyConfigService: TwentyConfigService,
) {}
async syncCatalog(): Promise<void> {
@@ -55,13 +59,26 @@ export class MarketplaceCatalogSyncService {
}
: fetchedManifest;
const cdnBaseUrl = this.twentyConfigService.get('APP_REGISTRY_CDN_URL');
const manifestWithResolvedUrls = resolveManifestAssetUrls(
manifest,
(filePath) =>
buildRegistryCdnUrl({
cdnBaseUrl,
packageName: pkg.name,
version: pkg.version,
filePath,
}),
);
await this.applicationRegistrationService.upsertFromCatalog({
universalIdentifier,
name: manifest.application.displayName ?? pkg.name,
sourceType: ApplicationRegistrationSourceType.NPM,
sourcePackage: pkg.name,
latestAvailableVersion: pkg.version ?? null,
manifest,
manifest: manifestWithResolvedUrls,
});
} catch (error) {
this.logger.error(
@@ -12,11 +12,9 @@ import { ApplicationRegistrationService } from 'src/engine/core-modules/applicat
import { MarketplaceCatalogSyncCronJob } from 'src/engine/core-modules/application/application-marketplace/crons/marketplace-catalog-sync.cron.job';
import { MarketplaceAppDTO } from 'src/engine/core-modules/application/application-marketplace/dtos/marketplace-app.dto';
import { MarketplaceAppDetailDTO } from 'src/engine/core-modules/application/application-marketplace/dtos/marketplace-app-detail.dto';
import { resolveApplicationRegistrationLogoUrl } from 'src/engine/core-modules/application/utils/resolve-application-registration-logo-url.util';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@Injectable()
export class MarketplaceQueryService {
@@ -26,7 +24,6 @@ export class MarketplaceQueryService {
constructor(
private readonly applicationRegistrationService: ApplicationRegistrationService,
private readonly applicationRegistrationVariableService: ApplicationRegistrationVariableService,
private readonly twentyConfigService: TwentyConfigService,
@InjectMessageQueue(MessageQueue.cronQueue)
private readonly messageQueueService: MessageQueueService,
) {}
@@ -99,17 +96,7 @@ export class MarketplaceQueryService {
description: app?.description ?? '',
author: `${app?.author ?? 'Unknown'}`,
category: app?.category ?? '',
logo:
resolveApplicationRegistrationLogoUrl({
logo:
registration.logo ??
registration.manifest?.application?.logoUrl ??
null,
sourceType: registration.sourceType,
sourcePackage: registration.sourcePackage,
latestAvailableVersion: registration.latestAvailableVersion,
cdnBaseUrl: this.twentyConfigService.get('APP_REGISTRY_CDN_URL'),
}) ?? undefined,
logo: app?.logoUrl ?? undefined,
sourcePackage: registration.sourcePackage ?? undefined,
isFeatured: registration.isFeatured,
};
@@ -125,17 +112,6 @@ export class MarketplaceQueryService {
sourceType: registration.sourceType,
sourcePackage: registration.sourcePackage ?? undefined,
latestAvailableVersion: registration.latestAvailableVersion ?? undefined,
logo:
resolveApplicationRegistrationLogoUrl({
logo:
registration.logo ??
registration.manifest?.application?.logoUrl ??
null,
sourceType: registration.sourceType,
sourcePackage: registration.sourcePackage,
latestAvailableVersion: registration.latestAvailableVersion,
cdnBaseUrl: this.twentyConfigService.get('APP_REGISTRY_CDN_URL'),
}) ?? undefined,
isListed: registration.isListed,
isFeatured: registration.isFeatured,
manifest: registration.manifest ?? undefined,
@@ -1,63 +0,0 @@
import { Injectable } from '@nestjs/common';
import { FileFolder } from 'twenty-shared/types';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { resolveApplicationLogoUrl } from 'src/engine/core-modules/application/utils/resolve-application-logo-url.util';
import { resolveApplicationRegistrationLogoUrl } from 'src/engine/core-modules/application/utils/resolve-application-registration-logo-url.util';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@Injectable()
export class ApplicationRegistrationLogoService {
constructor(
private readonly twentyConfigService: TwentyConfigService,
private readonly applicationService: ApplicationService,
) {}
async resolveLogoUrl(
registration: ApplicationRegistrationEntity,
): Promise<string | null> {
if (
registration.sourceType === ApplicationRegistrationSourceType.TARBALL &&
registration.logoFileId
) {
const serverUrl = this.twentyConfigService.get('SERVER_URL');
return `${serverUrl}/file/${FileFolder.AppTarball}/${registration.logoFileId}`;
}
const logo =
registration.logo ?? registration.manifest?.application?.logoUrl ?? null;
if (
registration.sourceType === ApplicationRegistrationSourceType.LOCAL &&
logo &&
registration.ownerWorkspaceId
) {
const application =
await this.applicationService.findByUniversalIdentifier({
universalIdentifier: registration.universalIdentifier,
workspaceId: registration.ownerWorkspaceId,
});
if (application) {
return resolveApplicationLogoUrl({
logo,
serverUrl: this.twentyConfigService.get('SERVER_URL'),
workspaceId: registration.ownerWorkspaceId,
applicationId: application.id,
});
}
}
return resolveApplicationRegistrationLogoUrl({
logo,
sourceType: registration.sourceType,
sourcePackage: registration.sourcePackage,
latestAvailableVersion: registration.latestAvailableVersion,
cdnBaseUrl: this.twentyConfigService.get('APP_REGISTRY_CDN_URL'),
});
}
}
@@ -21,7 +21,6 @@ import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/
import { type Manifest } from 'twenty-shared/application';
import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.entity';
import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum';
import { WasIntroducedInUpgrade } from 'src/engine/core-modules/upgrade/decorators/was-introduced-in-upgrade.decorator';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
@@ -135,23 +134,9 @@ export class ApplicationRegistrationEntity {
manifest: Manifest | null;
@Field(() => String, { nullable: true })
@Column({ nullable: true, type: 'text' })
@WasIntroducedInUpgrade({
upgradeCommandName:
'2.8.0_AddLogoToApplicationRegistrationFastInstanceCommand_1779387505162',
})
logo: string | null;
@Column({ nullable: true, type: 'uuid' })
@WasIntroducedInUpgrade({
upgradeCommandName:
'2.8.0_AddLogoToApplicationRegistrationFastInstanceCommand_1779387505162',
})
logoFileId: string | null;
@OneToOne(() => FileEntity, { onDelete: 'SET NULL', nullable: true })
@JoinColumn({ name: 'logoFileId' })
logoFile: Relation<FileEntity> | null;
get logoUrl(): string | null {
return this.manifest?.application?.logoUrl ?? null;
}
@OneToMany(
() => ApplicationRegistrationVariableEntity,
@@ -2,7 +2,6 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
import { ApplicationRegistrationLogoService } from 'src/engine/core-modules/application/application-registration/application-registration-logo.service';
import { ApplicationRegistrationResolver } from 'src/engine/core-modules/application/application-registration/application-registration.resolver';
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
import { ApplicationRegistrationVariableModule } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.module';
@@ -37,13 +36,11 @@ import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/
],
providers: [
ApplicationRegistrationService,
ApplicationRegistrationLogoService,
ApplicationRegistrationResolver,
ApplicationTarballService,
],
exports: [
ApplicationRegistrationService,
ApplicationRegistrationLogoService,
ApplicationRegistrationVariableModule,
],
})
@@ -24,7 +24,6 @@ import { CreateApplicationRegistrationVariableInput } from 'src/engine/core-modu
import { UpdateApplicationRegistrationVariableInput } from 'src/engine/core-modules/application/application-registration-variable/dtos/update-application-registration-variable.input';
import { ApplicationRegistrationExceptionFilter } from 'src/engine/core-modules/application/application-registration/application-registration-exception-filter';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
import { ApplicationRegistrationLogoService } from 'src/engine/core-modules/application/application-registration/application-registration-logo.service';
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
import { ApplicationTarballService } from 'src/engine/core-modules/application/application-registration/application-tarball.service';
import { ApplicationRegistrationStatsDTO } from 'src/engine/core-modules/application/application-registration/dtos/application-registration-stats.dto';
@@ -65,7 +64,6 @@ import {
export class ApplicationRegistrationResolver {
constructor(
private readonly applicationRegistrationService: ApplicationRegistrationService,
private readonly applicationRegistrationLogoService: ApplicationRegistrationLogoService,
private readonly applicationRegistrationVariableService: ApplicationRegistrationVariableService,
private readonly applicationTarballService: ApplicationTarballService,
private readonly fileUrlService: FileUrlService,
@@ -332,13 +330,6 @@ export class ApplicationRegistrationResolver {
});
}
@ResolveField(() => String, { nullable: true })
async logo(
@Parent() registration: ApplicationRegistrationEntity,
): Promise<string | null> {
return this.applicationRegistrationLogoService.resolveLogoUrl(registration);
}
@ResolveField(() => Boolean)
async isConfigured(
@Parent() registration: ApplicationRegistrationEntity,
@@ -16,8 +16,6 @@ import {
ApplicationRegistrationException,
ApplicationRegistrationExceptionCode,
} from 'src/engine/core-modules/application/application-registration/application-registration.exception';
import { resolveApplicationRegistrationLogoUrl } from 'src/engine/core-modules/application/utils/resolve-application-registration-logo-url.util';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { type ApplicationRegistrationStatsDTO } from 'src/engine/core-modules/application/application-registration/dtos/application-registration-stats.dto';
import { type CreateApplicationRegistrationInput } from 'src/engine/core-modules/application/application-registration/dtos/create-application-registration.input';
import { type PublicApplicationRegistrationDTO } from 'src/engine/core-modules/application/application-registration/dtos/public-application-registration.dto';
@@ -41,7 +39,6 @@ export class ApplicationRegistrationService {
@InjectRepository(WorkspaceEntity)
private readonly workspaceRepository: Repository<WorkspaceEntity>,
private readonly applicationRegistrationVariableService: ApplicationRegistrationVariableService,
private readonly twentyConfigService: TwentyConfigService,
) {}
async findMany(
@@ -107,6 +104,7 @@ export class ApplicationRegistrationService {
): Promise<PublicApplicationRegistrationDTO | null> {
const registration = await this.applicationRegistrationRepository.findOne({
where: { oAuthClientId: clientId },
select: ['id', 'name', 'manifest', 'oAuthScopes'],
});
if (!registration) {
@@ -116,13 +114,7 @@ export class ApplicationRegistrationService {
return {
id: registration.id,
name: registration.name,
logo: resolveApplicationRegistrationLogoUrl({
logo: registration.logo,
sourceType: registration.sourceType,
sourcePackage: registration.sourcePackage,
latestAvailableVersion: registration.latestAvailableVersion,
cdnBaseUrl: this.twentyConfigService.get('APP_REGISTRY_CDN_URL'),
}),
logoUrl: registration.manifest?.application?.logoUrl ?? null,
websiteUrl: registration.manifest?.application?.websiteUrl ?? null,
oAuthScopes: registration.oAuthScopes,
};
@@ -222,7 +214,6 @@ export class ApplicationRegistrationService {
async updateFromManifest(
applicationRegistrationId: string,
manifest: Manifest,
sourceType?: ApplicationRegistrationSourceType,
): Promise<void> {
const existing = await this.applicationRegistrationRepository.findOneOrFail(
{ where: { id: applicationRegistrationId } },
@@ -231,9 +222,7 @@ export class ApplicationRegistrationService {
await this.applicationRegistrationRepository.save({
...existing,
name: manifest.application.displayName,
logo: manifest.application.logoUrl ?? null,
manifest,
...(sourceType !== undefined && { sourceType }),
});
}
@@ -301,7 +290,6 @@ export class ApplicationRegistrationService {
sourceType: params.sourceType,
sourcePackage: params.sourcePackage,
latestAvailableVersion: params.latestAvailableVersion,
logo: params.manifest?.application?.logoUrl ?? null,
manifest: params.manifest,
isFeatured,
});
@@ -314,7 +302,6 @@ export class ApplicationRegistrationService {
latestAvailableVersion: params.latestAvailableVersion,
isListed: true,
isFeatured,
logo: params.manifest?.application?.logoUrl ?? null,
manifest: params.manifest,
oAuthClientId: v4(),
oAuthRedirectUris: [],
@@ -3,7 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { promises as fs } from 'fs';
import { tmpdir } from 'os';
import { join, resolve, sep } from 'path';
import { join } from 'path';
import semver from 'semver';
import { FileFolder } from 'twenty-shared/types';
@@ -28,8 +28,6 @@ import { ApplicationService } from 'src/engine/core-modules/application/applicat
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import type { ApplicationManifest } from 'twenty-shared/application';
import { ApplicationRegistrationVariableService } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.service';
import { extractFileInfo } from 'src/engine/core-modules/file/utils/extract-file-info.utils';
import { sanitizeFile } from 'src/engine/core-modules/file/utils/sanitize-file.utils';
@Injectable()
export class ApplicationTarballService {
@@ -192,7 +190,6 @@ export class ApplicationTarballService {
const savedFile = await this.fileStorageService.writeFile({
sourceFile: params.tarballBuffer,
resourcePath: `${appRegistration.id}/app.tar.gz`,
mimeType: 'application/gzip',
fileFolder: FileFolder.AppTarball,
applicationUniversalIdentifier:
workspaceCustomFlatApplication.universalIdentifier,
@@ -204,22 +201,10 @@ export class ApplicationTarballService {
},
});
const logoFileId = await this.uploadLogoFromTarball({
contentDir,
logoPath: manifest.application?.logoUrl ?? null,
registrationId: appRegistration.id,
existingLogoFileId: appRegistration.logoFileId ?? undefined,
applicationUniversalIdentifier:
workspaceCustomFlatApplication.universalIdentifier,
workspaceId: params.ownerWorkspaceId,
});
await this.appRegistrationRepository.update(appRegistration.id, {
sourceType: ApplicationRegistrationSourceType.TARBALL,
tarballFileId: savedFile.id,
name: manifest.application?.displayName ?? 'Unknown App',
logo: manifest.application?.logoUrl ?? null,
logoFileId,
manifest,
latestAvailableVersion: packageJson?.version ?? null,
isListed: false,
@@ -245,59 +230,4 @@ export class ApplicationTarballService {
await fs.rm(tempDir, { recursive: true, force: true });
}
}
private async uploadLogoFromTarball(params: {
contentDir: string;
logoPath: string | null;
registrationId: string;
existingLogoFileId?: string;
applicationUniversalIdentifier: string;
workspaceId: string;
}): Promise<string | null> {
if (
!params.logoPath ||
params.logoPath.startsWith('http://') ||
params.logoPath.startsWith('https://')
) {
return null;
}
const absolutePath = resolve(params.contentDir, params.logoPath);
const safePrefx = params.contentDir.endsWith(sep)
? params.contentDir
: params.contentDir + sep;
if (!absolutePath.startsWith(safePrefx)) {
return null;
}
try {
const content = await fs.readFile(absolutePath);
const { mimeType, ext } = await extractFileInfo({
file: content,
filename: params.logoPath,
});
const sanitizedContent = sanitizeFile({ file: content, ext, mimeType });
const savedLogoFile = await this.fileStorageService.writeFile({
sourceFile: sanitizedContent,
mimeType,
fileFolder: FileFolder.AppTarball,
applicationUniversalIdentifier: params.applicationUniversalIdentifier,
workspaceId: params.workspaceId,
resourcePath: `${params.registrationId}/logo${ext}`,
fileId: params.existingLogoFileId ?? v4(),
settings: { isTemporaryFile: false, toDelete: false },
});
return savedLogoFile.id;
} catch {
this.logger.warn(
`Failed to process logo from tarball: ${params.logoPath}`,
);
return null;
}
}
}
@@ -21,5 +21,5 @@ export class ApplicationRegistrationSummaryDTO {
@IsOptional()
@IsString()
@Field(() => String, { nullable: true })
logo?: string | null;
logoUrl?: string | null;
}
@@ -11,7 +11,7 @@ export class PublicApplicationRegistrationDTO {
name: string;
@Field(() => String, { nullable: true })
logo: string | null;
logoUrl: string | null;
@Field(() => String, { nullable: true })
websiteUrl: string | null;
@@ -411,7 +411,6 @@ export class ApplicationService {
const packageJsonFile = await this.fileStorageService.writeFile({
sourceFile: defaultPackageFields.packageJsonContent,
mimeType: undefined,
fileFolder: FileFolder.Dependencies,
applicationUniversalIdentifier: application.universalIdentifier,
workspaceId: application.workspaceId,
@@ -422,7 +421,6 @@ export class ApplicationService {
const yarnLockFile = await this.fileStorageService.writeFile({
sourceFile: defaultPackageFields.yarnLockContent,
mimeType: undefined,
fileFolder: FileFolder.Dependencies,
applicationUniversalIdentifier: application.universalIdentifier,
workspaceId: application.workspaceId,
@@ -14,9 +14,8 @@ export const fromFlatApplicationToApplicationDto = ({
availablePackages,
universalIdentifier,
version,
workspaceId,
settingsCustomTabFrontComponentId,
}: FlatApplication): ApplicationDTO & { workspaceId: string } => {
}: FlatApplication): ApplicationDTO => {
return {
canBeUninstalled,
description: description ?? undefined,
@@ -31,7 +30,6 @@ export const fromFlatApplicationToApplicationDto = ({
availablePackages: availablePackages ?? {},
universalIdentifier,
version: version ?? undefined,
workspaceId,
settingsCustomTabFrontComponentId:
settingsCustomTabFrontComponentId ?? undefined,
};
@@ -1,20 +0,0 @@
import { buildPublicAssetUrl } from 'twenty-shared/utils';
export const resolveApplicationLogoUrl = ({
logo,
serverUrl,
workspaceId,
applicationId,
}: {
logo: string | null | undefined;
serverUrl: string;
workspaceId: string;
applicationId: string;
}): string | null => {
return buildPublicAssetUrl({
path: logo,
serverUrl,
workspaceId,
applicationId,
});
};
@@ -1,42 +0,0 @@
import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum';
import { buildRegistryCdnUrl } from 'src/engine/core-modules/application/application-marketplace/utils/build-registry-cdn-url.util';
const isAbsoluteUrl = (url: string): boolean =>
url.startsWith('http://') || url.startsWith('https://');
export const resolveApplicationRegistrationLogoUrl = ({
logo,
sourceType,
sourcePackage,
latestAvailableVersion,
cdnBaseUrl,
}: {
logo: string | null | undefined;
sourceType: ApplicationRegistrationSourceType;
sourcePackage: string | null;
latestAvailableVersion: string | null;
cdnBaseUrl: string;
}): string | null => {
if (!logo) {
return null;
}
if (isAbsoluteUrl(logo)) {
return logo;
}
if (
sourceType === ApplicationRegistrationSourceType.NPM &&
sourcePackage &&
latestAvailableVersion
) {
return buildRegistryCdnUrl({
cdnBaseUrl,
packageName: sourcePackage,
version: latestAvailableVersion,
filePath: logo,
});
}
return null;
};
@@ -0,0 +1,36 @@
import { FileStorageExceptionFilter } from 'src/engine/core-modules/file-storage/file-storage-exception-filter';
import {
FileStorageException,
FileStorageExceptionCode,
} from 'src/engine/core-modules/file-storage/interfaces/file-storage-exception';
import {
ForbiddenError,
NotFoundError,
UserInputError,
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
describe('FileStorageExceptionFilter', () => {
const filter = new FileStorageExceptionFilter();
it.each([
{
code: FileStorageExceptionCode.INVALID_EXTENSION,
expectedError: UserInputError,
},
{
code: FileStorageExceptionCode.ACCESS_DENIED,
expectedError: ForbiddenError,
},
{
code: FileStorageExceptionCode.FILE_NOT_FOUND,
expectedError: NotFoundError,
},
])(
'should map $code to the expected GraphQL error',
({ code, expectedError }) => {
const exception = new FileStorageException('test message', code);
expect(() => filter.catch(exception)).toThrow(expectedError);
},
);
});
@@ -381,7 +381,6 @@ describe('FileStorageService', () => {
...validResourceIdentifier,
resourcePath: context.resourcePath,
sourceFile: Buffer.from('malicious'),
mimeType: 'application/javascript',
settings: { isTemporaryFile: false, toDelete: false },
}),
).rejects.toMatchObject({
@@ -396,7 +395,6 @@ describe('FileStorageService', () => {
await service.writeFile({
...validResourceIdentifier,
sourceFile: Buffer.from('valid content'),
mimeType: 'application/javascript',
settings: { isTemporaryFile: false, toDelete: false },
});
@@ -406,6 +404,141 @@ describe('FileStorageService', () => {
}),
);
});
describe('magic-byte backstop', () => {
const pngBuffer = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00,
0x0d, 0x49, 0x48, 0x44, 0x52,
]);
const textBuffer = Buffer.from('Hello, world!', 'utf-8');
it('should reject buffer whose magic bytes do not match the path extension', async () => {
await expect(
service.writeFile({
workspaceId: 'workspace-123',
applicationUniversalIdentifier: 'app-456',
fileFolder: FileFolder.PublicAsset,
resourcePath: 'assets/fake-image.png',
sourceFile: textBuffer,
settings: { isTemporaryFile: false, toDelete: false },
}),
).rejects.toMatchObject({
code: FileStorageExceptionCode.INVALID_EXTENSION,
});
expect(mockDriver.writeFile).not.toHaveBeenCalled();
});
it('should accept buffer whose magic bytes match the path extension and persist the bytes-derived mime', async () => {
await service.writeFile({
workspaceId: 'workspace-123',
applicationUniversalIdentifier: 'app-456',
fileFolder: FileFolder.PublicAsset,
resourcePath: 'assets/photo.png',
sourceFile: pngBuffer,
settings: { isTemporaryFile: false, toDelete: false },
});
expect(mockDriver.writeFile).toHaveBeenCalledWith(
expect.objectContaining({ mimeType: 'image/png' }),
);
expect(mockFileRepository.upsert).toHaveBeenCalledWith(
expect.objectContaining({ mimeType: 'image/png' }),
expect.anything(),
);
});
it('should persist application/typescript for a TypeScript source string (TWENTY_MIME_POLICY)', async () => {
await service.writeFile({
workspaceId: 'workspace-123',
applicationUniversalIdentifier: 'app-456',
fileFolder: FileFolder.Source,
resourcePath: 'src/index.tsx',
sourceFile: 'export const App = () => null;',
settings: { isTemporaryFile: false, toDelete: false },
});
expect(mockDriver.writeFile).toHaveBeenCalledWith(
expect.objectContaining({ mimeType: 'application/typescript' }),
);
});
it('should persist application/typescript for a TypeScript source buffer (policy beats mrmime collision)', async () => {
await service.writeFile({
workspaceId: 'workspace-123',
applicationUniversalIdentifier: 'app-456',
fileFolder: FileFolder.Source,
resourcePath: 'src/handler.ts',
sourceFile: Buffer.from(
'export const handler = () => null;',
'utf-8',
),
settings: { isTemporaryFile: false, toDelete: false },
});
expect(mockDriver.writeFile).toHaveBeenCalledWith(
expect.objectContaining({ mimeType: 'application/typescript' }),
);
});
});
describe('SVG sanitization (centralized invariant)', () => {
const maliciousSvg =
'<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)"><script>alert(2)</script><circle r="10" /></svg>';
it('should strip scripts and event handlers from an SVG string upload', async () => {
await service.writeFile({
workspaceId: 'workspace-123',
applicationUniversalIdentifier: 'app-456',
fileFolder: FileFolder.PublicAsset,
resourcePath: 'assets/icon.svg',
sourceFile: maliciousSvg,
settings: { isTemporaryFile: false, toDelete: false },
});
const driverCall = mockDriver.writeFile.mock.calls[0][0];
expect(driverCall.mimeType).toBe('image/svg+xml');
expect(typeof driverCall.sourceFile).toBe('string');
expect(driverCall.sourceFile).not.toContain('<script>');
expect(driverCall.sourceFile).not.toContain('onload');
expect(driverCall.sourceFile).toContain('<circle');
});
it('should strip scripts and event handlers from an SVG buffer upload', async () => {
await service.writeFile({
workspaceId: 'workspace-123',
applicationUniversalIdentifier: 'app-456',
fileFolder: FileFolder.PublicAsset,
resourcePath: 'assets/icon.svg',
sourceFile: Buffer.from(maliciousSvg, 'utf-8'),
settings: { isTemporaryFile: false, toDelete: false },
});
const driverCall = mockDriver.writeFile.mock.calls[0][0];
expect(driverCall.mimeType).toBe('image/svg+xml');
expect(driverCall.sourceFile).not.toContain('<script>');
expect(driverCall.sourceFile).not.toContain('onload');
});
it('should leave non-SVG content untouched (sanitize is no-op)', async () => {
const original = '{"foo": "bar"}';
await service.writeFile({
workspaceId: 'workspace-123',
applicationUniversalIdentifier: 'app-456',
fileFolder: FileFolder.Source,
resourcePath: 'package.json',
sourceFile: original,
settings: { isTemporaryFile: false, toDelete: false },
});
const driverCall = mockDriver.writeFile.mock.calls[0][0];
expect(driverCall.sourceFile).toBe(original);
});
});
});
describe('deleteFile', () => {
@@ -0,0 +1,29 @@
import { Catch, ExceptionFilter } from '@nestjs/common';
import { assertUnreachable } from 'twenty-shared/utils';
import {
FileStorageException,
FileStorageExceptionCode,
} from 'src/engine/core-modules/file-storage/interfaces/file-storage-exception';
import {
ForbiddenError,
NotFoundError,
UserInputError,
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
@Catch(FileStorageException)
export class FileStorageExceptionFilter implements ExceptionFilter {
catch(exception: FileStorageException) {
switch (exception.code) {
case FileStorageExceptionCode.INVALID_EXTENSION:
throw new UserInputError(exception);
case FileStorageExceptionCode.ACCESS_DENIED:
throw new ForbiddenError(exception);
case FileStorageExceptionCode.FILE_NOT_FOUND:
throw new NotFoundError(exception);
default:
assertUnreachable(exception.code);
}
}
}
@@ -1,7 +1,9 @@
import { type DynamicModule, Global } from '@nestjs/common';
import { APP_FILTER } from '@nestjs/core';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { FileStorageExceptionFilter } from 'src/engine/core-modules/file-storage/file-storage-exception-filter';
import { FileStorageDriverFactory } from 'src/engine/core-modules/file-storage/file-storage-driver.factory';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
@@ -16,7 +18,14 @@ export class FileStorageModule {
TwentyConfigModule,
TypeOrmModule.forFeature([FileEntity, ApplicationEntity]),
],
providers: [FileStorageDriverFactory, FileStorageService],
providers: [
FileStorageDriverFactory,
FileStorageService,
{
provide: APP_FILTER,
useClass: FileStorageExceptionFilter,
},
],
exports: [FileStorageDriverFactory, FileStorageService],
};
}
@@ -13,6 +13,7 @@ import {
FileStorageException,
FileStorageExceptionCode,
} from 'src/engine/core-modules/file-storage/interfaces/file-storage-exception';
import { prepareFileForStorageOrThrow } from 'src/engine/core-modules/file-storage/utils/prepare-file-for-storage-or-throw.util';
import { validateFilePath } from 'src/engine/core-modules/file-storage/utils/validate-file-path.util';
import { validateFolderPath } from 'src/engine/core-modules/file-storage/utils/validate-folder-path.util';
import { validateStoragePathIsWithinWorkspaceOrThrow } from 'src/engine/core-modules/file-storage/utils/validate-storage-path-is-within-workspace-or-throw.util';
@@ -66,7 +67,7 @@ export class FileStorageService {
return { onStoragePath, resourcePath };
}
private validateAndBuildFileStoragePath(params: ResourceIdentifier): {
private validateAndBuildFileStoragePathOrThrow(params: ResourceIdentifier): {
onStorageFilePath: string;
filePath: string;
} {
@@ -91,7 +92,7 @@ export class FileStorageService {
return { onStorageFilePath: onStoragePath, filePath: resourcePath };
}
private validateAndBuildFolderStoragePath(
private validateAndBuildFolderStoragePathOrThrow(
params: Omit<ResourceIdentifier, 'resourcePath'> & { folderPath: string },
): { onStorageFolderPath: string; folderPath: string } {
const validationResult = validateFolderPath({
@@ -119,7 +120,6 @@ export class FileStorageService {
async writeFile({
sourceFile,
mimeType,
fileFolder,
applicationUniversalIdentifier,
workspaceId,
@@ -129,7 +129,6 @@ export class FileStorageService {
queryRunner,
}: ResourceIdentifier & {
sourceFile: string | Buffer | Uint8Array;
mimeType: string | undefined;
fileId?: string;
settings: FileSettings;
queryRunner?: QueryRunner;
@@ -151,17 +150,23 @@ export class FileStorageService {
});
const { onStorageFilePath, filePath } =
this.validateAndBuildFileStoragePath({
this.validateAndBuildFileStoragePathOrThrow({
workspaceId,
applicationUniversalIdentifier,
fileFolder,
resourcePath,
});
const { sourceFile: persistedSourceFile, mimeType } =
await prepareFileForStorageOrThrow({
sourceFile,
resourcePath,
});
await driver.writeFile({
filePath: onStorageFilePath,
mimeType,
sourceFile,
sourceFile: persistedSourceFile,
});
await fileRepository.upsert(
@@ -172,9 +177,9 @@ export class FileStorageService {
id: fileId,
mimeType,
size:
typeof sourceFile === 'string'
? Buffer.byteLength(sourceFile)
: sourceFile.length,
typeof persistedSourceFile === 'string'
? Buffer.byteLength(persistedSourceFile)
: persistedSourceFile.length,
settings,
},
['path', 'workspaceId', 'applicationId'],
@@ -197,7 +202,8 @@ export class FileStorageService {
},
): Promise<string | null> {
const driver = this.fileStorageDriverFactory.getCurrentDriver();
const { onStorageFilePath } = this.validateAndBuildFileStoragePath(params);
const { onStorageFilePath } =
this.validateAndBuildFileStoragePathOrThrow(params);
return driver.getPresignedUrl({
filePath: onStorageFilePath,
@@ -210,7 +216,8 @@ export class FileStorageService {
readFile(params: ResourceIdentifier): Promise<Readable> {
const driver = this.fileStorageDriverFactory.getCurrentDriver();
const { onStorageFilePath } = this.validateAndBuildFileStoragePath(params);
const { onStorageFilePath } =
this.validateAndBuildFileStoragePathOrThrow(params);
return driver.readFile({ filePath: onStorageFilePath });
}
@@ -219,7 +226,8 @@ export class FileStorageService {
params: ResourceIdentifier & { localPath: string },
): Promise<void> {
const driver = this.fileStorageDriverFactory.getCurrentDriver();
const { onStorageFilePath } = this.validateAndBuildFileStoragePath(params);
const { onStorageFilePath } =
this.validateAndBuildFileStoragePathOrThrow(params);
return driver.downloadFile({
onStoragePath: onStorageFilePath,
@@ -256,7 +264,7 @@ export class FileStorageService {
async deleteFile(params: ResourceIdentifier): Promise<void> {
const driver = this.fileStorageDriverFactory.getCurrentDriver();
const { onStorageFilePath, filePath } =
this.validateAndBuildFileStoragePath(params);
this.validateAndBuildFileStoragePathOrThrow(params);
await driver.delete({
folderPath: dirname(onStorageFilePath),
@@ -288,7 +296,7 @@ export class FileStorageService {
} = params;
const { onStorageFolderPath, folderPath: validatedFolderPath } =
this.validateAndBuildFolderStoragePath({
this.validateAndBuildFolderStoragePathOrThrow({
workspaceId,
applicationUniversalIdentifier,
fileFolder,
@@ -373,9 +381,9 @@ export class FileStorageService {
const driver = this.fileStorageDriverFactory.getCurrentDriver();
const { onStorageFilePath: fromPath } =
this.validateAndBuildFileStoragePath(from);
this.validateAndBuildFileStoragePathOrThrow(from);
const { onStorageFilePath: toPath } =
this.validateAndBuildFileStoragePath(to);
this.validateAndBuildFileStoragePathOrThrow(to);
const isFile = await driver.checkFileExists({ filePath: fromPath });
@@ -394,7 +402,8 @@ export class FileStorageService {
checkFileExists(params: ResourceIdentifier): Promise<boolean> {
const driver = this.fileStorageDriverFactory.getCurrentDriver();
const { onStorageFilePath } = this.validateAndBuildFileStoragePath(params);
const { onStorageFilePath } =
this.validateAndBuildFileStoragePathOrThrow(params);
return driver.checkFileExists({ filePath: onStorageFilePath });
}
@@ -0,0 +1,207 @@
import { FileStorageExceptionCode } from 'src/engine/core-modules/file-storage/interfaces/file-storage-exception';
import { prepareFileForStorageOrThrow } from 'src/engine/core-modules/file-storage/utils/prepare-file-for-storage-or-throw.util';
const pngBuffer = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49,
0x48, 0x44, 0x52,
]);
const pdfBuffer = Buffer.from('%PDF-1.4\n', 'utf-8');
const zipBuffer = Buffer.from([0x50, 0x4b, 0x03, 0x04]);
const textBuffer = Buffer.from('Hello, world!', 'utf-8');
const benignSvg =
'<svg xmlns="http://www.w3.org/2000/svg"><circle r="10" /></svg>';
const maliciousSvg = `<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)"><script>alert(2)</script><circle r="10" /></svg>`;
describe('prepareFileForStorageOrThrow', () => {
describe('mime resolution', () => {
it.each([
{
name: 'TypeScript .ts string (policy)',
sourceFile: 'export const x = 1;',
resourcePath: 'src/index.ts',
expectedMime: 'application/typescript',
},
{
name: 'TypeScript .tsx string (policy)',
sourceFile: 'export const App = () => null;',
resourcePath: 'src/App.tsx',
expectedMime: 'application/typescript',
},
{
name: '.mjs string (mrmime)',
sourceFile: 'export default 1;',
resourcePath: 'src/handler.mjs',
expectedMime: 'text/javascript',
},
{
name: 'JSON string (mrmime)',
sourceFile: '{"foo": "bar"}',
resourcePath: 'package.json',
expectedMime: 'application/json',
},
{
name: 'unknown-extension string (octet-stream)',
sourceFile: 'lock content',
resourcePath: 'yarn.lock',
expectedMime: 'application/octet-stream',
},
{
name: 'no-extension string (octet-stream)',
sourceFile: 'content',
resourcePath: 'somefile',
expectedMime: 'application/octet-stream',
},
{
name: 'dot-file string (octet-stream — extname semantics)',
sourceFile: 'content',
resourcePath: '.gitignore',
expectedMime: 'application/octet-stream',
},
])(
'returns $expectedMime for $name',
async ({ sourceFile, resourcePath, expectedMime }) => {
const { mimeType } = await prepareFileForStorageOrThrow({
sourceFile,
resourcePath,
});
expect(mimeType).toBe(expectedMime);
},
);
it.each([
{
name: 'PNG buffer',
sourceFile: pngBuffer,
resourcePath: 'images/photo.png',
expectedMime: 'image/png',
},
{
name: 'PDF buffer',
sourceFile: pdfBuffer,
resourcePath: 'docs/contract.pdf',
expectedMime: 'application/pdf',
},
{
name: 'ZIP buffer',
sourceFile: zipBuffer,
resourcePath: 'archives/data.zip',
expectedMime: 'application/zip',
},
])(
'returns the bytes-derived mime for $name',
async ({ sourceFile, resourcePath, expectedMime }) => {
const { mimeType } = await prepareFileForStorageOrThrow({
sourceFile,
resourcePath,
});
expect(mimeType).toBe(expectedMime);
},
);
it('returns the bytes-derived mime for a Uint8Array source', async () => {
const { mimeType } = await prepareFileForStorageOrThrow({
sourceFile: new Uint8Array(pngBuffer),
resourcePath: 'images/photo.png',
});
expect(mimeType).toBe('image/png');
});
});
describe('binary-extension backstop', () => {
it('throws when a buffer with a binary extension does not match the magic bytes', async () => {
await expect(
prepareFileForStorageOrThrow({
sourceFile: textBuffer,
resourcePath: 'fake-image.png',
}),
).rejects.toMatchObject({
code: FileStorageExceptionCode.INVALID_EXTENSION,
});
});
it('throws when a string with a binary extension does not match the magic bytes', async () => {
await expect(
prepareFileForStorageOrThrow({
sourceFile: 'this is not a png',
resourcePath: 'fake-image.png',
}),
).rejects.toMatchObject({
code: FileStorageExceptionCode.INVALID_EXTENSION,
});
});
it('throws on Uint8Array byte/extension mismatch', async () => {
await expect(
prepareFileForStorageOrThrow({
sourceFile: new Uint8Array(textBuffer),
resourcePath: 'fake-image.pdf',
}),
).rejects.toMatchObject({
code: FileStorageExceptionCode.INVALID_EXTENSION,
});
});
});
describe('SVG sanitization', () => {
it('sanitizes an SVG string by removing scripts and event handlers', async () => {
const { sourceFile, mimeType } = await prepareFileForStorageOrThrow({
sourceFile: maliciousSvg,
resourcePath: 'assets/icon.svg',
});
expect(mimeType).toBe('image/svg+xml');
expect(typeof sourceFile).toBe('string');
expect(sourceFile).not.toContain('<script>');
expect(sourceFile).not.toContain('onload');
expect(sourceFile).toContain('<circle');
});
it('sanitizes an SVG buffer (DOMPurify returns a string)', async () => {
const { sourceFile, mimeType } = await prepareFileForStorageOrThrow({
sourceFile: Buffer.from(maliciousSvg, 'utf-8'),
resourcePath: 'assets/icon.svg',
});
expect(mimeType).toBe('image/svg+xml');
expect(typeof sourceFile).toBe('string');
expect(sourceFile).not.toContain('<script>');
expect(sourceFile).not.toContain('onload');
});
it('preserves a benign SVG (no destructive sanitization)', async () => {
const { sourceFile } = await prepareFileForStorageOrThrow({
sourceFile: benignSvg,
resourcePath: 'assets/icon.svg',
});
expect(sourceFile).toContain('<circle');
expect(sourceFile).toContain('xmlns');
});
});
describe('non-SVG content is returned unchanged', () => {
it('returns the original buffer reference for a non-SVG buffer', async () => {
const { sourceFile } = await prepareFileForStorageOrThrow({
sourceFile: pngBuffer,
resourcePath: 'images/photo.png',
});
expect(sourceFile).toBe(pngBuffer);
});
it('returns the original string for a non-SVG string', async () => {
const original = '{"foo": "bar"}';
const { sourceFile } = await prepareFileForStorageOrThrow({
sourceFile: original,
resourcePath: 'package.json',
});
expect(sourceFile).toBe(original);
});
});
});
@@ -0,0 +1,33 @@
import { extractFileInfoOrThrow } from 'src/engine/core-modules/file/utils/extract-file-info-or-throw.utils';
import { sanitizeFile } from 'src/engine/core-modules/file/utils/sanitize-file.utils';
export const prepareFileForStorageOrThrow = async ({
sourceFile,
resourcePath,
}: {
sourceFile: Buffer | Uint8Array | string;
resourcePath: string;
}): Promise<{
sourceFile: Buffer | Uint8Array | string;
mimeType: string;
}> => {
const bufferForExtract =
typeof sourceFile === 'string'
? Buffer.from(sourceFile, 'utf8')
: Buffer.isBuffer(sourceFile)
? sourceFile
: Buffer.from(sourceFile);
const { mimeType, ext } = await extractFileInfoOrThrow({
file: bufferForExtract,
filename: resourcePath,
});
const sanitizedSourceFile = sanitizeFile({
file: sourceFile,
ext,
mimeType,
});
return { sourceFile: sanitizedSourceFile, mimeType };
};
@@ -0,0 +1,19 @@
// Extensions where Twenty intentionally deviates from the IANA-standard mime
// mapping returned by mrmime. Twenty's storage layer persists these values
// instead of mrmime's output (or instead of throwing in the case of `.ts`).
//
// Why each entry exists:
// - ts/tsx: IANA registers `.ts` as `video/mp2t` (MPEG-2 Transport Stream),
// which predates TypeScript. `.tsx` is unregistered. Twenty uses
// the developer-tooling convention `application/typescript`.
//
// Only add an entry when (1) mrmime returns the wrong mime (collision) or no
// mime AND (2) Twenty actually writes that extension via FileStorageService AND
// (3) there is a clear developer-tooling convention for the right mime. For
// everything else — media, archives, documents, `.js`, `.mjs`, `.cjs`, etc. —
// trust mrmime. This policy is reactive: extend it the day we observe a real
// wrong-mime persisted by an exercised code path, not in anticipation.
export const TWENTY_MIME_POLICY: Record<string, string> = {
ts: 'application/typescript',
tsx: 'application/typescript',
};
@@ -8,8 +8,8 @@ import { ApplicationService } from 'src/engine/core-modules/application/applicat
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import { FileWithSignedUrlDTO } from 'src/engine/core-modules/file/dtos/file-with-sign-url.dto';
import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
import { extractFileInfo } from 'src/engine/core-modules/file/utils/extract-file-info.utils';
import { sanitizeFile } from 'src/engine/core-modules/file/utils/sanitize-file.utils';
import { extractFileInfoOrThrow } from 'src/engine/core-modules/file/utils/extract-file-info-or-throw.utils';
@Injectable()
export class FileAiChatService {
constructor(
@@ -27,13 +27,11 @@ export class FileAiChatService {
filename: string;
workspaceId: string;
}): Promise<FileWithSignedUrlDTO> {
const { mimeType, ext } = await extractFileInfo({
const { ext } = await extractFileInfoOrThrow({
file,
filename,
});
const sanitizedFile = sanitizeFile({ file, ext, mimeType });
const fileId = v4();
const name = `${fileId}${isNonEmptyString(ext) ? `.${ext}` : ''}`;
@@ -45,9 +43,8 @@ export class FileAiChatService {
);
const savedFile = await this.fileStorageService.writeFile({
sourceFile: sanitizedFile,
sourceFile: file,
resourcePath: name,
mimeType,
fileFolder: FileFolder.AgentChat,
applicationUniversalIdentifier:
workspaceCustomFlatApplication.universalIdentifier,
@@ -19,9 +19,8 @@ import { FileStorageService } from 'src/engine/core-modules/file-storage/file-st
import { FileWithSignedUrlDTO } from 'src/engine/core-modules/file/dtos/file-with-sign-url.dto';
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 { extractFileInfo } from 'src/engine/core-modules/file/utils/extract-file-info.utils';
import { extractFileInfoOrThrow } from 'src/engine/core-modules/file/utils/extract-file-info-or-throw.utils';
import { removeFileFolderFromFileEntityPath } from 'src/engine/core-modules/file/utils/remove-file-folder-from-file-entity-path.utils';
import { sanitizeFile } from 'src/engine/core-modules/file/utils/sanitize-file.utils';
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 { getImageBufferFromUrl } from 'src/utils/image';
@@ -72,8 +71,7 @@ export class FileCorePictureService {
applicationUniversalIdentifier?: string;
queryRunner?: QueryRunner;
}): Promise<FileEntity> {
const { mimeType, ext } = await extractFileInfo({ file, filename });
const sanitizedFile = sanitizeFile({ file, ext, mimeType });
const { ext } = await extractFileInfoOrThrow({ file, filename });
const fileId = v4();
const finalName = `${fileId}${isNonEmptyString(ext) ? `.${ext}` : ''}`;
@@ -83,9 +81,8 @@ export class FileCorePictureService {
(await this.findCustomApplicationUniversalIdentifier(workspaceId));
const savedFile = await this.fileStorageService.writeFile({
sourceFile: sanitizedFile,
sourceFile: file,
resourcePath: finalName,
mimeType,
fileFolder: FileFolder.CorePicture,
applicationUniversalIdentifier: universalIdentifier,
workspaceId,
@@ -8,8 +8,7 @@ import { ApplicationService } from 'src/engine/core-modules/application/applicat
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import { FileWithSignedUrlDTO } from 'src/engine/core-modules/file/dtos/file-with-sign-url.dto';
import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
import { extractFileInfo } from 'src/engine/core-modules/file/utils/extract-file-info.utils';
import { sanitizeFile } from 'src/engine/core-modules/file/utils/sanitize-file.utils';
import { extractFileInfoOrThrow } from 'src/engine/core-modules/file/utils/extract-file-info-or-throw.utils';
@Injectable()
export class FileEmailAttachmentService {
@@ -30,13 +29,11 @@ export class FileEmailAttachmentService {
filename: string;
workspaceId: string;
}): Promise<FileWithSignedUrlDTO> {
const { mimeType, ext } = await extractFileInfo({
const { ext } = await extractFileInfoOrThrow({
file,
filename,
});
const sanitizedFile = sanitizeFile({ file, ext, mimeType });
const fileId = v4();
const name = `${fileId}${isNonEmptyString(ext) ? `.${ext}` : ''}`;
@@ -48,9 +45,8 @@ export class FileEmailAttachmentService {
);
const savedFile = await this.fileStorageService.writeFile({
sourceFile: sanitizedFile,
sourceFile: file,
resourcePath: name,
mimeType,
fileFolder: FileFolder.EmailAttachment,
applicationUniversalIdentifier:
workspaceCustomFlatApplication.universalIdentifier,

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