Compare commits

..
Author SHA1 Message Date
Félix Malfait 46b0c46beb fix(workflow): include relation target field metadata in find-records action
Workflow Search Records action built its fields list from
flatObjectMetadata.fieldIds — only the source object's own fields. The
shared dispatcher then couldn't resolve the relation target field by id
and silently dropped the filter, so a configured one-hop traversal
(e.g. People where Company → Name Contains "Airbnb") wasn't applied.

Collect relationTargetFieldMetadataId from the input record filters
and add them to the field id list before resolving against
flatFieldMetadataMaps.
2026-05-18 11:12:59 +02:00
Félix Malfait 52726ce943 fix(filters): plumb flattenedFieldMetadataItems through remaining computeContextStoreFilters callers 2026-05-15 15:03:56 +02:00
Félix Malfait 312bb37c40 fix(filters): include relation target field metadata in frontend gql filter compute
Every call site of computeRecordGqlOperationFilter was passing only the
source object's fields (objectMetadataItem.fields). The dispatcher needs
the relation target field by id to nest the inner filter under the
source field's GraphQL key — without it, the target lookup returns
undefined and the filter is silently dropped, producing { and: [] }.

The user-visible symptoms: relation-traversal chips show in the UI but
the GraphQL request carries an empty filter, and typing in the value
input doesn't trigger a refetch (variables unchanged because the filter
is being dropped at compute time).

Introduce augmentFieldsWithRelationTargets which extends the base field
list with the target fields referenced by the record filters, resolved
from flattenedFieldMetadataItemsSelector (workspace-wide flat field
list). Wire it through every computeRecordGqlOperationFilter caller in
the frontend (record table table/aggregate/empty/SSE paths, record
index group + total count, graph widget, calendar date range, context
store filters via three callers, parent view query variables).
2026-05-15 14:59:00 +02:00
Félix MalfaitandGitHub 4f707bb9f1 refactor(twenty-shared): split turnRecordFilterIntoGqlOperationFilter into dispatcher + direct builder (#20600)
Stacked on top of #20533 — merge that one first.

## Summary

Splits `turnRecordFilterIntoRecordGqlOperationFilter` into a thin
dispatcher and a private `buildDirectFieldGqlOperationFilter`. Same
observable behaviour, but:

- The two ordering-constraint comments that #20533 introduced go away
(\"must run before the emptiness shortcut\", \"drop rather than fall
through to legacy relation-by-record\"). They were both flagging a real
code smell: dispatch and direct-filter logic were interleaved in the
same function body and depended on a flag inside the input.
- Removes the self-recursion + \"inject target into fieldMetadataItems\"
hack.
- Removes three duplicated `fieldMetadataItems.find(...)` lookups that
only existed to compose error-message labels — the resolved
`fieldMetadataItem` was already in scope.
- Renames `correspondingFieldMetadataItem` → `fieldMetadataItem` inside
the extracted function (the variable IS the field; the longer prefix
predated the split).

## Why

Discussed in #20533: the conditional ordering between the
relation-traversal branch and the per-type switch was easy to get wrong,
and the legacy `case 'RELATION':` foot-gun (parsing the filter value as
a UUID list when it's actually a text value) was guarded by a defensive
`return` rather than by structure.

After this PR the dispatcher picks exactly one branch up front and
`buildDirectFieldGqlOperationFilter` only ever runs against the field
the filter operates on — no \"is this the source field or the target
field?\" ambiguity inside the switch.

## Test plan
- [x] `npx nx build twenty-shared` succeeds (no type regressions).
- [x] `jest packages/twenty-shared` — 1212 tests pass.
- [x] `jest view-query-params.service.spec.ts` — 7 tests pass, including
the relation-traversal round-trip case.
- [ ] CI green.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-05-15 14:04:46 +02:00
Félix Malfait e0e36a8f00 chore: prettier format 2026-05-15 13:46:34 +02:00
Félix Malfait 4a78b092a2 refactor(advanced-filter): drop dead composite fallback + extract focus-push helper
- useApplyAdvancedFilterSourceField is only reached on leaf source fields
  (callers branch to the composite sub-menu hook or relation target hook
  before calling it). Removed the now-unreachable composite "any
  subfield" fallback and its three imports.
- The three callers that push the focus stack for a leaf RELATION/SELECT
  field (AdvancedFilterFieldSelectMenu, AdvancedFilterRelationTargetFieldSelectMenu,
  SettingsRolePermissions...FieldSelectFieldMenu) shared the same 8-line
  block — extracted into usePushFocusForLeafFieldValuePicker.
2026-05-15 13:42:20 +02:00
Félix MalfaitandGitHub 0a18423149 Merge branch 'main' into feat/graphql-relation-traversal-filters-frontend 2026-05-15 13:24:21 +02:00
Félix Malfait e3638925a2 fix(filters): drop relation-traversal view filter when target field cannot be resolved
mapViewFiltersToFilters used to fall back to the source field's filterType
and label when the relation target couldn't be found in
fieldMetadataItems. The resulting RecordFilter still carried the
unresolved relationTargetFieldMetadataId, and the dispatcher would later
drop the GraphQL filter anyway — leaving a chip in the UI that didn't
match the query.

Treat an unresolvable target the same as an unresolvable source: drop
the filter at mapping time. Race conditions during view change (the same
race the source-field handling documents) are the expected reason for a
transient miss and don't warrant a thrown error.
2026-05-15 13:14:49 +02:00
Félix Malfait 88d0476da0 fix(advanced-filter): reset relation-target-selecting state on dropdown dismiss + typecheck
- useResetFilterDropdown now clears
  objectFilterDropdownIsSelectingRelationTargetField, mirroring the
  composite-field reset. Without this, dismissing the dropdown mid-pick
  (Escape, click-away) left the flag true and the next open jumped
  straight to the relation target sub-menu.
- useApplyAdvancedFilterSourceField: tighten the defaultSubFieldName
  type. getDefaultSubFieldNameForCompositeFilterableFieldType can
  return undefined when the composite type isn't filterable by any
  subfield path, so coalesce to null to satisfy the
  CompositeFieldSubFieldName | null type expected by RecordFilter.
2026-05-15 13:10:20 +02:00
Félix Malfait 203f72116a fix(advanced-filter): exclude many-to-one relations from relation target picker
The backend supports a single-hop relation traversal. If the user picked
a many-to-one relation field as the target, the dispatcher would
substitute the target id into fieldMetadataId and recurse — the inner
call has relationTargetFieldMetadataId set to null, so it falls through
to the per-type RELATION case which expects a UUID list, not a primitive
value. The UI offered a traversal that the backend silently demoted to
filter-by-id.

Filter many-to-one fields out of the target list so only one-hop
traversals can be composed.
2026-05-15 13:05:21 +02:00
Félix Malfait a23ab663bc chore: prettier format 2026-05-15 12:59:40 +02:00
Félix Malfait 2bd03cab91 fix(view-widget-upsert): resolve relationTargetFieldMetadataUniversalIdentifier on filter create
The CREATE path hardcoded relationTargetFieldMetadataUniversalIdentifier
to null while passing the FK id through. The migration system's
resolveUniversalRelationIdentifiersToIds then renormalises by treating
the null universal identifier as authoritative and nullifying the FK id,
silently dropping the relation traversal on save.

Mirror the UPDATE path: include relationTargetFieldMetadataId in the
foreignKeyValues passed to resolveEntityRelationUniversalIdentifiers and
read the resolved universal identifier from its result.
2026-05-15 12:55:04 +02:00
Félix Malfait 50bd2a78a7 fix(advanced-filter): defer record filter upsert until sub-menu choice is final
When the user picked a relation or composite source field and then backed
out of the sub-menu, the immediate upsertRecordFilter call left an
orphaned partial filter in the chip list (a relation-by-id filter with no
UUIDs, or a composite filter with the "any subfield" fallback). The chip
appeared functional but couldn't actually filter anything.

The source-field handler now only stages the source choice in dropdown
state for the sub-menu paths and lets the sub-menu's hook do the upsert
on the final pick. The leaf path keeps the immediate upsert.
2026-05-15 12:52:17 +02:00
Félix Malfait 54894044b2 refactor(advanced-filter): split useSelectFieldUsedInAdvancedFilterDropdown into 3 specialized hooks
- useApplyAdvancedFilterSourceField for the source field choice
  (handles composite "any subfield" fallback for the initial pick)
- useApplyAdvancedFilterCompositeSubField for the composite subfield
  selection (no relation-target branching)
- useApplyAdvancedFilterRelationTargetField for the relation traversal
  target selection (derives filterType + label from target)

Each menu component calls its own dedicated hook — no shared
isRelationTraversal branching, no shared subFieldName fallback path.
The old useSelectFieldUsedInAdvancedFilterDropdown hook is removed.
2026-05-15 12:47:29 +02:00
Félix Malfait 7158fab9fa fix(filters): include relation target fields in fields list, fix deleted-target crash
- view-query-params and common-group-by include relation target field metadata
  in the fields list passed to the dispatcher so the traversal branch can
  resolve the target field by id
- ObjectFilterDropdownInnerSelectOperandDropdown swaps the throwing lookup
  for the non-throwing getFieldMetadataItemById so a deleted target field
  renders nothing instead of crashing
- lint: rename setIsSelectingRelationTargetField and isSelectingCompositeField
  to match their underlying component state names
2026-05-15 12:42:04 +02:00
Félix Malfait 858ae62b67 refactor(filters): split advanced-filter sub-menu, derive relation target on demand
- RecordFilter only carries relationTargetFieldMetadataId; derive label/type at render
- Split AdvancedFilterSubFieldSelectMenu into Composite + RelationTargetField components
- Dedicated objectFilterDropdownIsSelectingRelationTargetField signal (no RELATION sentinel)
- mapViewFiltersToFilters takes a single fieldMetadataItems list
- Server view-query-params and common-group-by stop embedding resolved relation target
- Lift focus-stack push out of useSelectFieldUsedInAdvancedFilterDropdown
2026-05-15 11:09:10 +02:00
Félix Malfait 41239c9f0e refactor(twenty-front): move sub-menu type / constant out of state, extract relation sub-menu
Two review nits:

1. `RELATION_SUB_MENU_FIELD_TYPE` and `ObjectFilterDropdownSubMenuFieldType`
   lived inside the state file. They aren't state — moved to
   `object-filter-dropdown/constants/` and `record-filter/types/`
   respectively, alongside the rest of the constants and types.

2. The relation sub-menu branch in `AdvancedFilterSubFieldSelectMenu`
   computed `targetObjectMetadataId` with an empty-string sentinel so
   the always-running `useFilterableFieldMetadataItems` hook could
   accept it. Fragile (the `''` sentinel is unnamed, untyped, and
   relies on the selector silently returning `[]`). Extracted the
   relation branch into its own `AdvancedFilterRelationSubMenu`
   component that only mounts when actually in relation mode — the
   hook now always receives a real object id, no sentinel.

Also: turned `isManyToOneRelationField` into a generic type guard so
callers can read `field.relation.targetObjectMetadata.id` after the
check without a non-null assertion.
2026-05-15 10:32:20 +02:00
Félix Malfait 49633515d2 fix(twenty-front): treat composite and relation sub-menus symmetrically
Per review feedback: the relation sub-menu case wasn't actually different
from the composite sub-menu case — they're both "another dropdown that
manages its own focus, source-field push would shadow it." The previous
implementation skipped the push only for relation traversal because that
was the only case where the smell was visible (composite source fields
aren't RELATION/SELECT so the original conditional push didn't fire for
them). Generalising:

- AdvancedFilterFieldSelectMenu: skipFocusPush = subMenuType !== null
- AdvancedFilterSubFieldSelectMenu: skipFocusPush = true (always —
  the next dropdown is for a sub-field or target field, never the source).
2026-05-15 10:19:28 +02:00
Félix Malfait 90a1381c86 fix(twenty-front): skip focus-stack push when relation traversal opens sub-menu
useSelectFieldUsedInAdvancedFilterDropdown was pushing fieldMetadataItem.id
onto the focus stack whenever the source field was RELATION or SELECT —
originally for the entity / option picker that opens next. With relation
traversal, a RELATION source now opens the sub-menu instead, and the
sub-menu's SelectableList uses advancedFilterFieldSelectDropdownId as its
focusId. The mismatched push at the top of the stack shadowed the
sub-menu's hotkey scope and disabled keyboard navigation.

Add a skipFocusPush opt-out, set by:
- AdvancedFilterFieldSelectMenu when the next step is the relation
  sub-menu (case 1, entering)
- AdvancedFilterSubFieldSelectMenu when committing with a resolved
  relationTargetFieldMetadataItem (case 2, leaving — the target's value
  picker will set up its own focus when opened)

Role-permission callers keep the original push behaviour (they go
directly to a value picker, no sub-menu).
2026-05-15 07:31:39 +02:00
Félix Malfait d947fed809 fix(twenty-server): align relationTargetFieldMetadata ON DELETE to CASCADE
The other foreign keys on viewFilter referencing fieldMetadata / view /
viewFilterGroup all use ON DELETE CASCADE. The new column was using
SET NULL, which required a runtime "drop filter when target gone" path
and left a half-broken row in the table. Align to CASCADE so a deleted
field cleans up its filters atomically, matching the pre-existing
convention on this entity.
2026-05-15 07:11:38 +02:00
Félix Malfait d39a2d00c1 fix(twenty-server): apply relationTargetFieldMetadataId on widget filter update
In ViewWidgetUpsertService.computeViewFilterOperations the hasChanged
branch correctly detected a change to relationTargetFieldMetadataId but
the entry pushed to filtersToUpdate only spread existingFilter and never
applied the new value (or recomputed its universal identifier). The user's
change was silently ignored. Update path now mirrors the create branch:
includes relationTargetFieldMetadataId and resolves the matching
universal identifier alongside the source field and view-filter-group.
2026-05-14 22:49:10 +02:00
Félix Malfait 68d513662c test(twenty-server): integration test for relation-traversal view filter round-trip
Asserts that the metadata GraphQL Create + Update mutations correctly
persist and surface `relationTargetFieldMetadataId` on view filters:

- create with relationTargetFieldMetadataId set → returns it on the DTO
- create without it → defaults to null
- update relationTargetFieldMetadataId → new value is reflected on the DTO
- update an unrelated property (value) → relationTargetFieldMetadataId
  is preserved (regression test for the universal-identifier-recompute
  fix in fromUpdateViewFilterInputToFlatViewFilterToUpdateOrThrow)

Uses the standard Person.company MANY_TO_ONE relation pointing at
Company.name / Company.domainName so the test runs against seed data.
2026-05-14 22:44:23 +02:00
Félix Malfait 24884e9a27 fix: handle relation traversal in update flow, group-by runner, value reopen
- from-update-view-filter-input-to-flat-view-filter-to-update-or-throw:
  recompute relationTargetFieldMetadataUniversalIdentifier when the
  editable property changes. Without this, workspace migration sync
  diffs would miss or mishandle relation-target changes on existing
  filters (Create already handled this; Update did not).

- common-group-by-query-runner.service: when converting saved view
  filters to record filters for dashboard widget aggregates, look up
  the relation target field and embed it on the RecordFilter (same
  pattern as view-query-params.service). Without this, saved relation
  traversal filters are silently dropped from group-by queries.

- AdvancedFilterValueInput: when reopening a filter chip's value
  dropdown, restore relationTargetFieldMetadataIdUsedInDropdown from
  the filter so the operand picker reflects the target's type (not
  the relation field's RELATION type).

- useGetRecordFilterDisplayValue: read SELECT / MULTI_SELECT options
  from the target field (when relation traversal is in play) so chip
  labels show the target field's option labels, not the source's.
2026-05-14 22:33:05 +02:00
Félix Malfait 3040e75e97 refactor: embed resolved relation target on RecordFilter
Replace the cross-object field lookup that bled into ~9 callers with a
self-contained filter shape: RecordFilter now carries an optional
relationTargetField: { id, name, type, label } resolved at construction
time. The shared turnRecordFilterIntoRecordGqlOperationFilter reads the
target directly off the filter and injects it into the recursive lookup
array, so downstream callers no longer need to pass the flattened
cross-object metadata.

Effect on the diff:
- RecordFilter shape gains relationTargetField (shared + frontend types).
- turnRecordFilterIntoRecordGqlOperationFilter uses recordFilter.
  relationTargetField directly; no caller-supplied lookup needed.
- Filter construction sites embed the resolved target:
    mapViewFiltersToFilters (frontend),
    useSelectFieldUsedInAdvancedFilterDropdown (frontend),
    view-query-params.service.ts (server).
- Revert 9 consumer callers to objectMetadataItem.fields:
    useFindManyRecordIndexTableParams, useGraphWidgetQueryCommon,
    useRecordIndexGroupCommonQueryVariables,
    useRecordIndexGroupsAggregatesGroupBy,
    RecordTableEmptyHasNewRecordEffect,
    useAggregateRecordsForRecordTableColumnFooter,
    RecordTableVirtualizedSSESubscribeEffect,
    useGetRecordIndexTotalCount, useRecordCalendarQueryDateRangeFilter.
- computeContextStoreFilters drops its optional flattened-fields param;
  reverts 5 callers (useUpdateMultipleRecordsActions,
  useRecordIndexLazyFetchRecords, useFindManyRecordsSelectedInContextStore,
  RecordIndexContainerContextStoreNumberOfSelectedRecordsEffect,
  buildHeadlessCommandContextApi).
- getQueryVariablesFromFiltersAndSorts reverts to single-object fields.
- mapRecordFilterToViewFilter derives the persisted id from
  recordFilter.relationTargetField?.id.

Net −79 LOC and the filter is now self-contained.
2026-05-14 22:27:27 +02:00
Félix Malfait 4a37f654f8 fix: prevent silent fallback when relation-traversal filter target missing
- turnRecordFilterIntoRecordGqlOperationFilter now drops the filter instead
  of falling through to the legacy relation-by-record path when
  relationTargetFieldMetadataId is set but the target field isn't in the
  provided fieldMetadataItems. The legacy path parses the value as a UUID
  list, which would silently mishandle target-field values like "Acme" and
  could broaden destructive operations to every record.
- Update remaining callers of computeRecordGqlOperationFilter /
  computeContextStoreFilters to pass the flattened cross-object field list
  so the target field is resolvable: useGetRecordIndexTotalCount,
  getQueryVariablesFromFiltersAndSorts, useUpdateMultipleRecordsActions,
  useRecordIndexLazyFetchRecords, useFindManyRecordsSelectedInContextStore,
  RecordIndexContainerContextStoreNumberOfSelectedRecordsEffect,
  buildHeadlessCommandContextApi, useRecordCalendarQueryDateRangeFilter.
- computeContextStoreFilters accepts an optional flattenedFieldMetadataItems
  param that falls back to objectMetadataItem.fields.
2026-05-14 21:54:59 +02:00
Félix Malfait 2dc23464e7 fix: propagate relationTargetFieldMetadataId server-side, fix prettier
- ViewQueryParamsService now carries relationTargetFieldMetadataId onto
  RecordFilter and includes the target field in the fields array passed to
  computeRecordGqlOperationFilter so server-side flows (dashboard widgets,
  workflows, etc.) execute the relation traversal branch instead of falling
  back to legacy relation-by-record matching. Add an integration-style spec
  asserting the nested filter shape.
- Prettier fix in filterSortableFieldMetadataItems.
2026-05-14 21:41:45 +02:00
Félix Malfait 06f8c8b5a7 refactor: address review feedback and simplify
Address PR review comments:
- Move isManyToOneRelationField to @/object-metadata/utils (global helper).
  Refactor prefillRecord, sanitizeRecordInput, filterSortableFieldMetadataItems
  and spreadsheetImportHasNestedFields to reuse it.
- Drop narrative/redundant comments on RecordFilter, ViewFilter shape,
  RELATION_SUB_MENU_FIELD_TYPE sentinel and relationTargetFieldMetadataIdUsed
  state — names are self-describing.

Simplifications:
- Unify the two select-filter handlers in AdvancedFilterSubFieldSelectMenu
  behind a single keyword-args entry point.
- Collapse isRelationDrillDown/isCompositeDrillDown flags in
  AdvancedFilterFieldSelectMenu into a single computed sub-menu type.
- Drop the redundant `'relationTargetFieldMetadataId' in viewFilter` check
  in mapViewFiltersToFilters now that ViewFilter shape carries the field.
- Drop the over-engineered useMemo around effectiveFieldMetadataItem in
  ObjectFilterDropdownInnerSelectOperandDropdown — the underlying lookup
  is a cheap dictionary read.
- Reuse the same relationTargetFieldMetadataId for both the filter object
  and the dropdown state setter in useSelectFieldUsedInAdvancedFilterDropdown.
2026-05-14 21:28:13 +02:00
Félix Malfait fd1b51ac03 fix(twenty-server): relocate relation-target-field migration to 2-6 directory
Main bumped current version to 2.6.0; the upgrade-mutation guard requires
new commands to live in the current version's directory. Move the file,
rename the timestamp prefix, and bump the @RegisteredInstanceCommand version.
2026-05-14 20:43:04 +02:00
Félix MalfaitandGitHub b805b962f1 Merge branch 'main' into feat/graphql-relation-traversal-filters-frontend 2026-05-14 20:37:53 +02:00
Félix Malfait 96a6a08a51 fix(twenty-server): bump viewFilter in children-first sort snapshot
Adding the relationTargetFieldMetadata many-to-one relation gives viewFilter
one more parent-pointing edge, which shifts it ahead of commandMenuItem/
fieldPermission/viewField in the children-first ordering.
2026-05-14 17:11:07 +02:00
Félix Malfait 0153ea3887 fix: align generated graphql DocumentNodes, view-filter test, and properties snapshot
- Add relationTargetFieldMetadataId to 17 ViewFilter DocumentNode constants
  (codegen was missing these AST entries even though the TS shapes had the
  field).
- Update useApplyCurrentViewFiltersToCurrentRecordFilters expectation to
  include relationTargetFieldMetadataId: null in the produced RecordFilter.
- Add relationTargetFieldMetadataUniversalIdentifier to viewFilter
  propertiesToCompare snapshot.
2026-05-14 16:53:15 +02:00
Félix Malfait f3211f1d52 fix(twenty-front): remove relationTargetFieldMetadataId from ViewSort generated shapes
Earlier patch used an overly broad sed that also matched ViewSort inline
shapes (since ViewSort also has subFieldName, viewId). ViewSort doesn't
have this column - only ViewFilter does.
2026-05-14 16:26:08 +02:00
Félix MalfaitandGitHub e97f466fe6 Merge branch 'main' into feat/graphql-relation-traversal-filters-frontend 2026-05-14 16:11:47 +02:00
Félix Malfait 6076a79bda fix: regen generated graphql + SDK metadata client for relationTargetFieldMetadataId
The ViewFilter DTO / inputs gained `relationTargetFieldMetadataId` in
this PR but I had forgotten to re-run the schema generators that mirror
the GraphQL surface into typed clients:

- twenty-front/src/generated-metadata/graphql.ts — add the column to
  CreateViewFilterInput, UpdateViewFilterInputUpdates,
  UpsertViewWidgetViewFilterInput, the ViewFilter type, and the 22+
  inline `Array<{ __typename: 'ViewFilter', ... }>` shapes used by
  query/mutation result types and fragments.
- twenty-client-sdk/src/metadata/generated/{schema.graphql,schema.ts,
  types.ts} — same field on each of the four shapes (type +
  Create / UpdateUpdates / UpsertViewWidget inputs) plus the
  ViewFilterGenqlSelection helper and the four introspection-data
  entries in types.ts.

Diff matches what the codegen+sdk generators would emit (validated
field-by-field against the diff CI's server-validation step
produced).
2026-05-14 16:06:44 +02:00
Félix Malfait 1f4225fb83 fix(twenty-server): align relation-target-field migration with TypeORM's expected SQL
The server-validation step (`database:migrate:generate`) emits a pending
migration whenever the entity-derived schema differs from what existing
migrations produced. The original migration drifted in three ways that
TypeORM's generator flagged:

- FK constraint name was a human-readable
  `FK_VIEW_FILTER_RELATION_TARGET_FIELD_METADATA_ID` instead of the
  hash that `DefaultNamingStrategy.foreignKeyName` produces (sha1 of
  `<tableName>_<columns>` truncated to 27 chars) →
  `FK_dbe259395cbd9a54c1c17d12b0b`.
- The FK lacked `ON UPDATE NO ACTION`, which TypeORM emits by default.
- Operation order was column → FK → index; TypeORM emits
  column → index → FK.

Also dropped the `IF NOT EXISTS` guards on column / index / constraint
since TypeORM-generated migrations don't use them.
2026-05-14 15:43:49 +02:00
Félix MalfaitandGitHub 36aadfa649 Merge branch 'main' into feat/graphql-relation-traversal-filters-frontend 2026-05-14 15:33:33 +02:00
Félix Malfait 9529764c94 fix(twenty-front): typecheck — read selector via .atom and coerce undefined to null
- store.get on the custom `flattenedFieldMetadataItemsSelector` needs
  `.atom` to match Jotai's Atom<T> shape (callback context can't use
  useAtomStateValue).
- recordFilter.relationTargetFieldMetadataId is string | null | undefined;
  the component state expects string | null, so coerce with ?? null.
2026-05-14 15:23:47 +02:00
Félix Malfait a90a6e3bc6 fix(views): make relation traversal filters survive serialize + reload
Four bugs caught in PR review of the one-hop relation filter feature:

- turnRecordFilterIntoRecordGqlOperationFilter (twenty-shared) looks up
  the target field in the same `fieldMetadataItems` list it received,
  but every frontend caller was passing only the current object's
  fields. The target field never resolved, so the serializer fell
  through to the legacy RELATION case and threw a ZodError trying to
  parse the text value as record IDs. Fix: feed every call site the
  `flattenedFieldMetadataItemsSelector` value (a memoized flat-map of
  every object's fields). `turnAnyFieldFilterIntoRecordGqlFilter`
  iterates and is left on the current-object list.

- mapViewFiltersToFilters derived `type` and `label` from the source
  RELATION field on load, so a saved `Company → Name` filter came back
  as type=RELATION / label=Company and the advanced-filter UI reopened
  it with record-picker semantics. Add an optional
  `allFieldMetadataItems` param, resolve the target field through it,
  and use the target's filterType / build a `source → target` label.
  Callers updated to pass the flattened list.

- view-filter.entity.ts comment promised "graceful fallback to
  relation-by-record semantics" on ON DELETE SET NULL, but nothing
  rewrites the persisted operand/value to fit the new shape — load
  path was the implicit dead-letter office. Rewrite the comment to
  describe the actual contract (row survives, caller drops the
  filter); no behavior change.

- relationTargetFieldMetadataIdUsedInDropdownComponentState had a
  writer in the advanced-filter field-select hook and a reader in the
  simple operand dropdown (chip-edit), but the contexts never met:
  chip-edit init didn't restore the state, so editing a saved
  traversal filter via its chip showed the source field's operand
  list. Wire setEditableFilterChipDropdownStates to also restore
  relationTargetFieldMetadataId from the loaded recordFilter.

Also unfreezes the viewMapFunctions test that was already failing on
the branch (the persisted relation-target field wasn't covered in the
expected shape).
2026-05-14 15:23:41 +02:00
Félix MalfaitandClaude Opus 4.7 8461ddbadd feat(views): persist one-hop relation filters via relationTargetFieldMetadataId
Introduces a dedicated, stable identifier for the relation-traversal filter
target on `ViewFilterEntity` so saved views survive renames of the field
they reference, and removes the shortcut that previously cast a target
field's NAME into the closed `CompositeFieldSubFieldName` slot.

Schema
 - New column `core.viewFilter.relationTargetFieldMetadataId` (uuid,
   nullable, FK to `core.fieldMetadata.id` with ON DELETE SET NULL so
   filters degrade gracefully if their target field is deleted, rather
   than vanishing).
 - Partial index on the column where not-null.
 - Instance-fast migration (2.5.0, ts 1798000005000) adds the column,
   the FK, and the index; registered in `INSTANCE_COMMANDS`.
 - Field metadata configuration wired through:
   `FLAT_VIEW_FILTER_EDITABLE_PROPERTIES`,
   `all-entity-properties-configuration-by-metadata-name`,
   `all-many-to-one-metadata-foreign-key`,
   `all-many-to-one-metadata-relations` (universal foreign key
   `relationTargetFieldMetadataUniversalIdentifier`).

API
 - `ViewFilterDTO`, `CreateViewFilterInput`, `UpdateViewFilterInputUpdates`
   and `UpsertViewWidgetViewFilterInput` all carry the new optional UUID.
 - View → flat converters (`fromViewFilterEntityToFlatViewFilter`,
   `fromCreateViewFilterInputToFlatViewFilterToCreate`,
   `fromViewFilterManifestToUniversalFlatViewFilter`,
   `createStandardViewFilterFlatMetadata`,
   widget upsert, create-action handler) thread the new column
   through both the foreign-key and universal-identifier sides.

Shared
 - `RecordFilter.relationTargetFieldMetadataId` added on both the
   frontend and twenty-shared types.
 - `turnRecordFilterIntoRecordGqlOperationFilter` now branches on
   `relationTargetFieldMetadataId` BEFORE the emptiness shortcut and
   synthesizes the inner filter against the target field, wrapping it
   under the relation field's name (`{ company: { name: { ... } } }`).
   Falls through to the existing relation-by-record path if the target
   field isn't present in the provided `fieldMetadataItems`.

Frontend
 - New dedicated state
   `relationTargetFieldMetadataIdUsedInDropdownComponentState` (the
   existing `subFieldNameUsedInDropdownComponentState` stays narrowly
   typed for composite sub-fields).
 - `useSelectFieldUsedInAdvancedFilterDropdown` accepts an optional
   `relationTargetFieldMetadataItem`. When present it stores the target
   field's id on `RecordFilter.relationTargetFieldMetadataId` and uses
   the TARGET field's type (so operand picker / value input behave as if
   filtering it directly) and a "Relation → Target" label.
 - `ObjectFilterDropdownInnerSelectOperandDropdown` resolves the
   effective field via `useGetFieldMetadataItemByIdOrThrow` when the
   relation-target state is set, so operand options match the target's
   filterable type.
 - `AdvancedFilterFieldSelectMenu` routes MANY_TO_ONE clicks into the
   same sub-menu flow as composite fields, with a `'RELATION'` sentinel
   on `objectFilterDropdownSubMenuFieldType`.
 - `AdvancedFilterSubFieldSelectMenu` renders the target object's
   filterable fields via `useFilterableFieldMetadataItems`; the ghost
   `-1` selectable id that the composite branch used for the "Any X
   field" item is no longer included in the relation list (fixes the
   keyboard-navigation issue cubic-dev-ai flagged).
 - `ObjectFilterDropdownFilterSelectMenuItem` shows the sub-menu
   chevron for MANY_TO_ONE relations via a new
   `isManyToOneRelationField` util.
 - `mapRecordFilterToViewFilter` / `mapViewFiltersToFilters`,
   `areViewFiltersEqual`, the save mutation inputs, and the GraphQL
   fragment all thread the new field through the round-trip so saved
   views actually persist relation traversal.
 - Role-permissions sub-field menu narrows the `'RELATION'` sentinel
   back out (it never deals with relations).

Out of scope
 - Normal filter dropdown (`ViewBarFilterDropdownFieldSelectMenu`) —
   composite drill-down doesn't exist there today, so adding relation
   drill-down would mean introducing the entire sub-menu pattern on the
   normal surface. Tracking as follow-up; advanced filter is the only
   surface that currently builds nested filter shapes.
 - REST DSL, ONE_TO_MANY reverse traversal, aggregates — unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 15:23:34 +02:00
4540 changed files with 67609 additions and 146442 deletions
+21 -21
View File
@@ -106,24 +106,24 @@ Replace `{VERSION}` with the actual version number (e.g., `1.9.0`)
### 2. Create File Structure
**Create changelog file:**
- Path: `packages/twenty-website/src/content/releases/{VERSION}.mdx`
- Example: `packages/twenty-website/src/content/releases/1.9.0.mdx`
- Path: `packages/twenty-website-new/src/content/releases/{VERSION}.mdx`
- Example: `packages/twenty-website-new/src/content/releases/1.9.0.mdx`
**Create image folder:**
- Path: `packages/twenty-website/public/images/releases/{MINOR_VERSION}/`
- Example for version 1.9.0: `packages/twenty-website/public/images/releases/1.9/`
- Example for version 2.0.0: `packages/twenty-website/public/images/releases/2.0/`
- Path: `packages/twenty-website-new/public/images/releases/{MINOR_VERSION}/`
- Example for version 1.9.0: `packages/twenty-website-new/public/images/releases/1.9/`
- Example for version 2.0.0: `packages/twenty-website-new/public/images/releases/2.0/`
```bash
# Create the image folder
mkdir -p packages/twenty-website/public/images/releases/{MINOR_VERSION}
mkdir -p packages/twenty-website-new/public/images/releases/{MINOR_VERSION}
```
### 3. Move Illustration Files
**Source:** `/Users/thomascolasdesfrancs/Downloads/🆕`
**Destination:** `packages/twenty-website/public/images/releases/{MINOR_VERSION}/`
**Destination:** `packages/twenty-website-new/public/images/releases/{MINOR_VERSION}/`
**Naming Convention:** `{VERSION}-descriptive-name.webp`
@@ -133,8 +133,8 @@ Examples:
```bash
# Move and rename source files, then convert to webp if needed
cp ~/Downloads/🆕/source-file.png packages/twenty-website/public/images/releases/{MINOR_VERSION}/{VERSION}-feature-name.png
cd packages/twenty-website && node scripts/convert-png-to-webp.mjs
cp ~/Downloads/🆕/source-file.png packages/twenty-website-new/public/images/releases/{MINOR_VERSION}/{VERSION}-feature-name.png
cd packages/twenty-website-new && node scripts/convert-png-to-webp.mjs
```
### 4. Research Features (if needed)
@@ -183,7 +183,7 @@ Description of the third feature.
- **NEVER mention the brand name "Twenty"** in changelog text - use "your workspace", "the platform", or similar neutral references instead
**Reference Previous Changelogs:**
- Check `packages/twenty-website/src/content/releases/` for examples
- Check `packages/twenty-website-new/src/content/releases/` for examples
- Recent releases: 1.7.0.mdx, 1.6.0.mdx, 1.5.0.mdx
### 6. Review
@@ -191,10 +191,10 @@ Description of the third feature.
Open the changelog file for review:
```bash
# Open in Cursor
cursor packages/twenty-website/src/content/releases/{VERSION}.mdx
cursor packages/twenty-website-new/src/content/releases/{VERSION}.mdx
# Open image folder to verify illustrations
open packages/twenty-website/public/images/releases/{MINOR_VERSION}
open packages/twenty-website-new/public/images/releases/{MINOR_VERSION}
```
Review checklist:
@@ -222,8 +222,8 @@ I've created the changelog for version {VERSION}. Here's the content for your re
[Show full MDX content]
Images moved to:
- packages/twenty-website/public/images/releases/{MINOR_VERSION}/{VERSION}-feature-1.webp
- packages/twenty-website/public/images/releases/{MINOR_VERSION}/{VERSION}-feature-2.webp
- packages/twenty-website-new/public/images/releases/{MINOR_VERSION}/{VERSION}-feature-1.webp
- packages/twenty-website-new/public/images/releases/{MINOR_VERSION}/{VERSION}-feature-2.webp
Please review the content. Once you approve, I'll commit the changes and create the pull request.
```
@@ -242,8 +242,8 @@ Possible user responses:
git status
# Add files
git add packages/twenty-website/src/content/releases/{VERSION}.mdx
git add packages/twenty-website/public/images/releases/{MINOR_VERSION}/
git add packages/twenty-website-new/src/content/releases/{VERSION}.mdx
git add packages/twenty-website-new/public/images/releases/{MINOR_VERSION}/
# Commit
git commit -m "Add {VERSION} release changelog"
@@ -266,7 +266,7 @@ This release includes:
- Feature 2
- Feature 3
Changelog file: \`packages/twenty-website/src/content/releases/{VERSION}.mdx\`
Changelog file: \`packages/twenty-website-new/src/content/releases/{VERSION}.mdx\`
Release date: {DATE}" \
--base main \
--head {VERSION}
@@ -280,13 +280,13 @@ Or visit: `https://github.com/twentyhq/twenty/pull/new/{VERSION}`
- **Format**: `{MAJOR}.{MINOR}.{PATCH}.mdx`
- **Convention**: One file per complete version
- **Examples**: `1.6.0.mdx`, `1.7.0.mdx`, `2.0.0.mdx`
- **Location**: `packages/twenty-website/src/content/releases/`
- **Location**: `packages/twenty-website-new/src/content/releases/`
### Image Folders
- **Format**: `{MAJOR}.{MINOR}/`
- **Convention**: One folder per minor version (shared across patches)
- **Examples**: `1.6/`, `1.7/`, `2.0/`
- **Location**: `packages/twenty-website/public/images/releases/`
- **Location**: `packages/twenty-website-new/public/images/releases/`
### Image Files
- **Format**: `{VERSION}-descriptive-name.webp`
@@ -311,8 +311,8 @@ Features to document:
3. ___________________________
Branch name: {VERSION}
Changelog path: packages/twenty-website/src/content/releases/{VERSION}.mdx
Images path: packages/twenty-website/public/images/releases/{MINOR_VERSION}/
Changelog path: packages/twenty-website-new/src/content/releases/{VERSION}.mdx
Images path: packages/twenty-website-new/public/images/releases/{MINOR_VERSION}/
```
## Tips
+1 -1
View File
@@ -50,4 +50,4 @@ runs:
- name: Deploy
shell: bash
working-directory: ${{ inputs.app-path }}
run: yarn twenty app:publish --private --remote target
run: yarn twenty deploy --remote target
@@ -50,4 +50,4 @@ runs:
- name: Install
shell: bash
working-directory: ${{ inputs.app-path }}
run: yarn twenty app:install --remote target
run: yarn twenty install --remote target
+2 -2
View File
@@ -1,5 +1,5 @@
#
# Crowdin CLI configuration for Website translations (twenty-website)
# Crowdin CLI configuration for Website translations (twenty-website-new)
# Project ID: 4
# See https://crowdin.github.io/crowdin-cli/configuration for more information
#
@@ -16,7 +16,7 @@ files:
#
# Source file - PO file for Lingui
#
- source: packages/twenty-website/src/locales/en.po
- source: packages/twenty-website-new/src/locales/en.po
#
# Translation files path
#
+5 -91
View File
@@ -144,9 +144,6 @@ jobs:
npx nx run twenty-server:database:init:prod
- name: Flush cache before seeding current branch
run: npx nx command-no-deps twenty-server -- cache:flush
- name: Seed current branch database with test data
run: |
npx nx command-no-deps twenty-server -- workspace:seed:dev
@@ -163,7 +160,7 @@ jobs:
- name: Wait for current branch server to be ready
run: |
echo "Waiting for current branch server to start..."
timeout=60
timeout=300
interval=5
elapsed=0
@@ -188,10 +185,9 @@ jobs:
done
if [ $elapsed -ge $timeout ]; then
echo "Timed out waiting for current branch server to serve a valid schema."
echo "::warning::Timed out waiting for current branch server to serve a valid schema. Validation will skip the API diff."
echo "Current server log:"
cat /tmp/current-server.log || echo "No current server log found"
exit 1
fi
- name: Download GraphQL and REST responses from current branch
@@ -315,9 +311,6 @@ jobs:
npx nx run twenty-server:database:init:prod
- name: Flush cache before seeding main branch
run: npx nx command-no-deps twenty-server -- cache:flush
- name: Seed main branch database with test data
run: |
npx nx command-no-deps twenty-server -- workspace:seed:dev
@@ -359,10 +352,9 @@ jobs:
done
if [ $elapsed -ge $timeout ]; then
echo "Timed out waiting for main branch server to serve a valid schema."
echo "::warning::Timed out waiting for main branch server to serve a valid schema. Validation will skip the API diff."
echo "Main server log:"
cat /tmp/main-server.log || echo "No main server log found"
exit 1
fi
- name: Download GraphQL and REST responses from main branch
@@ -458,7 +450,6 @@ jobs:
echo "Using OpenAPITools/openapi-diff via Docker"
- name: Generate GraphQL Schema Diff Reports
id: graphql-diff
if: steps.validate-schemas.outputs.valid == 'true'
run: |
echo "=== INSTALLING GRAPHQL INSPECTOR CLI ==="
@@ -472,7 +463,6 @@ jobs:
echo "✅ No changes in GraphQL schema"
else
echo "⚠️ Changes detected in GraphQL schema, generating report..."
echo "core_breaking=true" >> $GITHUB_OUTPUT
echo "# GraphQL Schema Changes" > graphql-schema-diff.md
echo "" >> graphql-schema-diff.md
graphql-inspector diff main-schema-introspection.json current-schema-introspection.json >> graphql-schema-diff.md 2>&1 || {
@@ -490,7 +480,6 @@ jobs:
echo "✅ No changes in GraphQL metadata schema"
else
echo "⚠️ Changes detected in GraphQL metadata schema, generating report..."
echo "metadata_breaking=true" >> $GITHUB_OUTPUT
echo "# GraphQL Metadata Schema Changes" > graphql-metadata-diff.md
echo "" >> graphql-metadata-diff.md
graphql-inspector diff main-metadata-schema-introspection.json current-metadata-schema-introspection.json >> graphql-metadata-diff.md 2>&1 || {
@@ -507,7 +496,6 @@ jobs:
ls -la *-diff.md 2>/dev/null || echo "No diff files generated (no changes detected)"
- name: Check REST API Breaking Changes
id: rest-diff
if: steps.validate-schemas.outputs.valid == 'true'
run: |
echo "=== CHECKING REST API FOR BREAKING CHANGES ==="
@@ -530,7 +518,6 @@ jobs:
if [ "$incompatible" = "true" ]; then
echo "❌ Breaking changes detected in REST API"
echo "breaking=true" >> $GITHUB_OUTPUT
# Generate breaking changes report
echo "# REST API Breaking Changes" > rest-api-diff.md
@@ -578,7 +565,6 @@ jobs:
fi
- name: Check REST Metadata API Breaking Changes
id: rest-metadata-diff
if: steps.validate-schemas.outputs.valid == 'true'
run: |
echo "=== CHECKING REST METADATA API FOR BREAKING CHANGES ==="
@@ -601,7 +587,6 @@ jobs:
if [ "$incompatible" = "true" ]; then
echo "❌ Breaking changes detected in REST Metadata API"
echo "breaking=true" >> $GITHUB_OUTPUT
# Generate breaking changes report (only for breaking changes)
echo "# REST Metadata API Breaking Changes" > rest-metadata-api-diff.md
@@ -647,79 +632,6 @@ jobs:
echo "::warning::REST Metadata API analysis tool error - continuing workflow"
fi
- name: Fail on breaking changes
if: steps.validate-schemas.outputs.valid == 'true'
run: |
breaking=false
if [ "${{ steps.graphql-diff.outputs.core_breaking }}" = "true" ]; then
echo "❌ GraphQL core schema has breaking changes"
breaking=true
if [ -f graphql-schema-diff.md ]; then
echo ""
cat graphql-schema-diff.md
echo ""
fi
fi
if [ "${{ steps.graphql-diff.outputs.metadata_breaking }}" = "true" ]; then
echo "❌ GraphQL metadata schema has breaking changes"
breaking=true
if [ -f graphql-metadata-diff.md ]; then
echo ""
cat graphql-metadata-diff.md
echo ""
fi
fi
if [ "${{ steps.rest-diff.outputs.breaking }}" = "true" ]; then
echo "❌ REST core API has breaking changes"
breaking=true
if [ -f rest-api-diff.json ]; then
echo ""
jq -r '
(if (.missingEndpoints | length) > 0 then
" Removed endpoints:\n" +
(.missingEndpoints | map(" - " + (.method // "?") + " " + (.pathUrl // "?")) | join("\n"))
else "" end),
(if (.changedOperations | length) > 0 then
" Changed operations:\n" +
(.changedOperations | map(" - " + (.method // "?") + " " + (.pathUrl // "?")) | join("\n"))
else "" end)
' rest-api-diff.json | sed '/^$/d'
echo ""
fi
fi
if [ "${{ steps.rest-metadata-diff.outputs.breaking }}" = "true" ]; then
echo "❌ REST metadata API has breaking changes"
breaking=true
if [ -f rest-metadata-api-diff.json ]; then
echo ""
jq -r '
(if (.missingEndpoints | length) > 0 then
" Removed endpoints:\n" +
(.missingEndpoints | map(" - " + (.method // "?") + " " + (.pathUrl // "?")) | join("\n"))
else "" end),
(if (.changedOperations | length) > 0 then
" Changed operations:\n" +
(.changedOperations | map(" - " + (.method // "?") + " " + (.pathUrl // "?")) | join("\n"))
else "" end)
' rest-metadata-api-diff.json | sed '/^$/d'
echo ""
fi
fi
if [ "$breaking" = "true" ]; then
echo ""
echo "This PR introduces breaking changes to the public API."
echo "If intentional, deprecate the old endpoint and introduce a new one."
echo "See the breaking changes report artifact and PR comment for details."
exit 1
fi
echo "✅ No breaking API changes detected"
- name: Upload breaking changes report
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
@@ -740,3 +652,5 @@ jobs:
if [ -f /tmp/main-server.pid ]; then
kill $(cat /tmp/main-server.pid) || true
fi
@@ -0,0 +1,201 @@
name: CI Hello world App E2E
on:
# Temporarily disabled — will be re-enabled when example apps are published.
# push:
# branches:
# - main
#
# pull_request:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
changed-files-check:
uses: ./.github/workflows/changed-files.yaml
with:
files: |
packages/create-twenty-app/**
packages/twenty-sdk/**
packages/twenty-client-sdk/**
packages/twenty-shared/**
!packages/create-twenty-app/package.json
!packages/twenty-sdk/package.json
!packages/twenty-client-sdk/package.json
!packages/twenty-shared/package.json
create-app-e2e-hello-world:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest-4-cores
services:
postgres:
image: postgres:18
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis
ports:
- 6379:6379
env:
PUBLISHABLE_PACKAGES: twenty-client-sdk twenty-sdk create-twenty-app
TWENTY_API_URL: http://localhost:3000
TWENTY_API_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
fetch-depth: 10
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Set CI version and prepare packages for publish
run: |
CI_VERSION="0.0.0-ci.$(date +%s)"
echo "CI_VERSION=$CI_VERSION" >> $GITHUB_ENV
for pkg in $PUBLISHABLE_PACKAGES; do
npx nx run $pkg:set-local-version --releaseVersion=$CI_VERSION
done
- name: Build packages
run: |
for pkg in $PUBLISHABLE_PACKAGES; do
npx nx build $pkg
done
- name: Install and start Verdaccio
run: |
npx verdaccio --config .github/verdaccio-config.yaml &
for i in $(seq 1 30); do
if curl -s http://localhost:4873 > /dev/null 2>&1; then
echo "Verdaccio is ready"
break
fi
echo "Waiting for Verdaccio... ($i/30)"
sleep 1
done
- name: Publish packages to local registry
run: |
yarn config set npmRegistryServer http://localhost:4873
yarn config set unsafeHttpWhitelist --json '["localhost"]'
yarn config set npmAuthToken ci-auth-token
for pkg in $PUBLISHABLE_PACKAGES; do
cd packages/$pkg
yarn npm publish --tag ci
cd ../..
done
- name: Scaffold app using published create-twenty-app
run: |
npm install -g create-twenty-app@$CI_VERSION --registry http://localhost:4873
create-twenty-app --version
mkdir -p /tmp/e2e-test-workspace
cd /tmp/e2e-test-workspace
create-twenty-app test-app --example hello-world --display-name "Test hello-world app" --description "E2E test hello-world app" --api-url http://localhost:3000
- name: Install scaffolded app dependencies
run: |
cd /tmp/e2e-test-workspace/test-app
echo 'npmRegistryServer: "http://localhost:4873"' >> .yarnrc.yml
echo 'unsafeHttpWhitelist: ["localhost"]' >> .yarnrc.yml
YARN_ENABLE_IMMUTABLE_INSTALLS=false yarn install --no-immutable
echo "--- Installing last SDK versions ---"
YARN_ENABLE_IMMUTABLE_INSTALLS=false yarn add twenty-sdk twenty-client-sdk
- name: Verify installed app versions
run: |
cd /tmp/e2e-test-workspace/test-app
echo "--- Checking package.json references correct SDK version ---"
node -e "
const pkg = require('./package.json');
const sdkVersion = pkg.dependencies['twenty-sdk'];
if (!sdkVersion.startsWith('0.0.0-ci.')) {
console.error('Expected twenty-sdk version to start with 0.0.0-ci., got:', sdkVersion);
process.exit(1);
}
console.log('SDK version in scaffolded app:', sdkVersion);
"
- name: Verify SDK CLI is available
run: |
cd /tmp/e2e-test-workspace/test-app
npx --no-install twenty --version
- name: Setup server environment
run: npx nx reset:env:e2e-testing-server twenty-server
- name: Create databases
run: |
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "default";'
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "test";'
- name: Setup database
run: npx nx run twenty-server:database:reset
- name: Start server
run: nohup npx nx start:ci twenty-server &
- name: Wait for server to be ready
run: npx wait-on http://localhost:3000/healthz --timeout 120000 --interval 1000
- name: Authenticate with twenty-server
run: |
cd /tmp/e2e-test-workspace/test-app
npx --no-install twenty remote add --api-key ${{ env.TWENTY_API_KEY }} --api-url ${{ env.TWENTY_API_URL }}
- name: Deploy scaffolded app
run: |
cd /tmp/e2e-test-workspace/test-app
npx --no-install twenty deploy
- name: Install scaffolded app
run: |
cd /tmp/e2e-test-workspace/test-app
npx --no-install twenty install
- name: Execute hello-world logic function
run: |
cd /tmp/e2e-test-workspace/test-app
EXEC_OUTPUT=$(npx --no-install twenty exec --functionName hello-world-logic-function)
echo "$EXEC_OUTPUT"
echo "$EXEC_OUTPUT" | grep -q "Hello, World!"
- name: Execute create-hello-world-company logic function
run: |
cd /tmp/e2e-test-workspace/test-app
EXEC_OUTPUT=$(npx --no-install twenty exec --functionName create-hello-world-company)
echo "$EXEC_OUTPUT"
echo "$EXEC_OUTPUT" | grep -q 'Created company.*Hello World.*with id'
- name: Run scaffolded app integration test
run: |
cd /tmp/e2e-test-workspace/test-app
yarn test
ci-create-app-e2e-hello-world-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: ubuntu-latest
needs: [changed-files-check, create-app-e2e-hello-world]
steps:
- name: Fail job if any needs failed
if: contains(needs.*.result, 'failure')
run: exit 1
@@ -105,7 +105,7 @@ jobs:
create-twenty-app --version
mkdir -p /tmp/e2e-test-workspace
cd /tmp/e2e-test-workspace
create-twenty-app test-app --display-name "Test scaffolded app" --description "E2E test scaffolded app" --url http://localhost:3000
create-twenty-app test-app --display-name "Test scaffolded app" --description "E2E test scaffolded app" --api-url http://localhost:3000
- name: Install scaffolded app dependencies
run: |
@@ -153,7 +153,7 @@ jobs:
- name: Authenticate with twenty-server
run: |
cd /tmp/e2e-test-workspace/test-app
npx --no-install twenty remote:add --api-key ${{ env.TWENTY_API_KEY }} --url ${{ env.TWENTY_API_URL }}
npx --no-install twenty remote add --api-key ${{ env.TWENTY_API_KEY }} --api-url ${{ env.TWENTY_API_URL }}
- name: Run scaffolded app integration test (deploys, installs, and verifies the app)
run: |
@@ -0,0 +1,199 @@
name: CI Postcard App E2E
on:
# Temporarily disabled — will be re-enabled when example apps are published.
# push:
# branches:
# - main
#
# pull_request:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
changed-files-check:
uses: ./.github/workflows/changed-files.yaml
with:
files: |
packages/create-twenty-app/**
packages/twenty-sdk/**
packages/twenty-client-sdk/**
packages/twenty-shared/**
!packages/create-twenty-app/package.json
!packages/twenty-sdk/package.json
!packages/twenty-client-sdk/package.json
!packages/twenty-shared/package.json
create-app-e2e-postcard:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest-4-cores
services:
postgres:
image: postgres:18
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis
ports:
- 6379:6379
env:
PUBLISHABLE_PACKAGES: twenty-client-sdk twenty-sdk create-twenty-app
TWENTY_API_URL: http://localhost:3000
TWENTY_API_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
fetch-depth: 10
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Set CI version and prepare packages for publish
run: |
CI_VERSION="0.0.0-ci.$(date +%s)"
echo "CI_VERSION=$CI_VERSION" >> $GITHUB_ENV
npx nx run-many -t set-local-version -p $PUBLISHABLE_PACKAGES --releaseVersion=$CI_VERSION
- name: Build packages
run: |
for pkg in $PUBLISHABLE_PACKAGES; do
npx nx build $pkg
done
- name: Install and start Verdaccio
run: |
npx verdaccio --config .github/verdaccio-config.yaml &
for i in $(seq 1 30); do
if curl -s http://localhost:4873 > /dev/null 2>&1; then
echo "Verdaccio is ready"
break
fi
echo "Waiting for Verdaccio... ($i/30)"
sleep 1
done
- name: Publish packages to local registry
run: |
yarn config set npmRegistryServer http://localhost:4873
yarn config set unsafeHttpWhitelist --json '["localhost"]'
yarn config set npmAuthToken ci-auth-token
for pkg in $PUBLISHABLE_PACKAGES; do
cd packages/$pkg
yarn npm publish --tag ci
cd ../..
done
- name: Scaffold app using published create-twenty-app
run: |
npm install -g create-twenty-app@$CI_VERSION --registry http://localhost:4873
create-twenty-app --version
mkdir -p /tmp/e2e-test-workspace
cd /tmp/e2e-test-workspace
create-twenty-app test-app --example postcard --display-name "Test postcard app" --description "E2E test postcard app" --api-url http://localhost:3000
- name: Install scaffolded app dependencies
run: |
cd /tmp/e2e-test-workspace/test-app
echo 'npmRegistryServer: "http://localhost:4873"' >> .yarnrc.yml
echo 'unsafeHttpWhitelist: ["localhost"]' >> .yarnrc.yml
YARN_ENABLE_IMMUTABLE_INSTALLS=false yarn install --no-immutable
echo "--- Installing last SDK versions ---"
YARN_ENABLE_IMMUTABLE_INSTALLS=false yarn add twenty-sdk twenty-client-sdk
- name: Verify installed app versions
run: |
cd /tmp/e2e-test-workspace/test-app
echo "--- Checking package.json references correct SDK version ---"
node -e "
const pkg = require('./package.json');
const sdkVersion = pkg.dependencies['twenty-sdk'];
if (!sdkVersion.startsWith('0.0.0-ci.')) {
console.error('Expected twenty-sdk version to start with 0.0.0-ci., got:', sdkVersion);
process.exit(1);
}
console.log('SDK version in scaffolded app:', sdkVersion);
"
- name: Verify SDK CLI is available
run: |
cd /tmp/e2e-test-workspace/test-app
npx --no-install twenty --version
- name: Setup server environment
run: npx nx reset:env:e2e-testing-server twenty-server
- name: Create databases
run: |
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "default";'
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "test";'
- name: Setup database
run: npx nx run twenty-server:database:reset
- name: Start server
run: nohup npx nx start:ci twenty-server &
- name: Wait for server to be ready
run: npx wait-on http://localhost:3000/healthz --timeout 120000 --interval 1000
- name: Authenticate with twenty-server
run: |
cd /tmp/e2e-test-workspace/test-app
npx --no-install twenty remote add --api-key ${{ env.TWENTY_API_KEY }} --api-url ${{ env.TWENTY_API_URL }}
- name: Deploy scaffolded app
run: |
cd /tmp/e2e-test-workspace/test-app
npx --no-install twenty deploy
- name: Install scaffolded app
run: |
cd /tmp/e2e-test-workspace/test-app
npx --no-install twenty install
- name: Execute postcard logic function
run: |
cd /tmp/e2e-test-workspace/test-app
EXEC_OUTPUT=$(npx --no-install twenty exec --functionName postcard-logic-function)
echo "$EXEC_OUTPUT"
echo "$EXEC_OUTPUT" | grep -q "Hello, World!"
- name: Execute create-postcard-company logic function
run: |
cd /tmp/e2e-test-workspace/test-app
EXEC_OUTPUT=$(npx --no-install twenty exec --functionName create-postcard-company)
echo "$EXEC_OUTPUT"
echo "$EXEC_OUTPUT" | grep -q 'Created company.*Hello World.*with id'
- name: Run scaffolded app integration test
run: |
cd /tmp/e2e-test-workspace/test-app
yarn test
ci-create-app-e2e-postcard-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: ubuntu-latest
needs: [changed-files-check, create-app-e2e-postcard]
steps:
- name: Fail job if any needs failed
if: contains(needs.*.result, 'failure')
run: exit 1
@@ -23,11 +23,9 @@ jobs:
packages/twenty-sdk/**
packages/twenty-client-sdk/**
packages/twenty-shared/**
packages/twenty-server/**
!packages/twenty-sdk/package.json
!packages/twenty-client-sdk/package.json
!packages/twenty-shared/package.json
!packages/twenty-server/package.json
example-app-postcard:
needs: changed-files-check
@@ -85,31 +83,6 @@ jobs:
working-directory: packages/twenty-apps/examples/postcard
run: npx vitest run
- name: Configure remote for SDK CLI
run: |
mkdir -p ~/.twenty
cat > ~/.twenty/config.json <<EOF
{
"version": 1,
"remotes": {
"target": {
"apiUrl": "${TWENTY_API_URL}",
"apiKey": "${TWENTY_API_KEY}",
"accessToken": "${TWENTY_API_KEY}"
}
},
"defaultRemote": "target"
}
EOF
- name: Deploy postcard app (registry install path)
working-directory: packages/twenty-apps/examples/postcard
run: node ${{ github.workspace }}/packages/twenty-sdk/dist/cli.cjs deploy --remote target
- name: Install postcard app (registry install path)
working-directory: packages/twenty-apps/examples/postcard
run: node ${{ github.workspace }}/packages/twenty-sdk/dist/cli.cjs install --remote target
ci-example-app-postcard-status-check:
if: always() && !cancelled()
timeout-minutes: 5
@@ -80,20 +80,10 @@ jobs:
with:
name: storybook-twenty-front-component-renderer
path: packages/twenty-front-component-renderer/storybook-static
- name: Resolve Playwright version
id: playwright-version
run: echo "version=$(node -p "require('@playwright/test/package.json').version")" >> "$GITHUB_OUTPUT"
- name: Cache Playwright browsers
id: playwright-cache
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ~/.cache/ms-playwright
key: v4-playwright-browsers-${{ runner.os }}-${{ steps.playwright-version.outputs.version }}
- name: Install Playwright
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: |
cd packages/twenty-front-component-renderer
npx playwright install chromium
npx playwright install
- name: Serve storybook & run tests
run: |
npx http-server packages/twenty-front-component-renderer/storybook-static --port 6008 --silent &
+1 -11
View File
@@ -96,20 +96,10 @@ jobs:
with:
name: storybook-static
path: packages/twenty-front/storybook-static
- name: Resolve Playwright version
id: playwright-version
run: echo "version=$(node -p "require('@playwright/test/package.json').version")" >> "$GITHUB_OUTPUT"
- name: Cache Playwright browsers
id: playwright-cache
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ~/.cache/ms-playwright
key: v4-playwright-browsers-${{ runner.os }}-${{ steps.playwright-version.outputs.version }}
- name: Install Playwright
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: |
cd packages/twenty-front
npx playwright install chromium
npx playwright install
- name: Front / Write .env
run: npx nx reset:env twenty-front
- name: Serve storybook & run tests
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
files: |
package.json
yarn.lock
packages/twenty-website/**
packages/twenty-website-new/**
packages/twenty-shared/**
website-task:
needs: changed-files-check
+19 -4
View File
@@ -1,6 +1,7 @@
name: 'Preview Environment Dispatch'
permissions: {}
permissions:
contents: write
on:
pull_request_target:
@@ -10,6 +11,7 @@ on:
- packages/twenty-server/**
- packages/twenty-front/**
- .github/workflows/preview-env-dispatch.yaml
- .github/workflows/preview-env-keepalive.yaml
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -33,15 +35,28 @@ jobs:
timeout-minutes: 5
runs-on: ubuntu-latest
steps:
- name: Dispatch preview-env to ci-privileged
- name: Trigger preview environment workflow
env:
GH_TOKEN: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
REPOSITORY: ${{ github.repository }}
run: |
gh api repos/twentyhq/ci-privileged/dispatches \
gh api repos/"$REPOSITORY"/dispatches \
-f event_type=preview-environment \
-f "client_payload[pr_number]=$PR_NUMBER" \
-f "client_payload[pr_head_sha]=$PR_HEAD_SHA" \
-f "client_payload[repo_full_name]=$REPOSITORY"
- name: Dispatch to ci-privileged for PR comment
env:
GH_TOKEN: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
KEEPALIVE_DISPATCH_TIME: ${{ github.event.pull_request.updated_at }}
REPOSITORY: ${{ github.repository }}
run: |
gh api repos/twentyhq/ci-privileged/dispatches \
-f event_type=preview-env-url \
-f "client_payload[pr_number]=$PR_NUMBER" \
-f "client_payload[keepalive_dispatch_time]=$KEEPALIVE_DISPATCH_TIME" \
-f "client_payload[repo]=$REPOSITORY"
@@ -0,0 +1,186 @@
name: 'Preview Environment Keep Alive'
permissions:
contents: read
on:
repository_dispatch:
types: [preview-environment]
jobs:
preview-environment:
timeout-minutes: 310
runs-on: ubuntu-latest
steps:
- name: Checkout PR
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
ref: ${{ github.event.client_payload.pr_head_sha }}
- name: Login to Docker Hub
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
- name: Run compose setup
run: |
echo "Patching docker-compose.yml..."
# change image to localbuild using yq
yq eval 'del(.services.server.image)' -i packages/twenty-docker/docker-compose.yml
yq eval '.services.server.build.context = "../../"' -i packages/twenty-docker/docker-compose.yml
yq eval '.services.server.build.dockerfile = "./packages/twenty-docker/twenty/Dockerfile"' -i packages/twenty-docker/docker-compose.yml
yq eval '.services.server.build.target = "twenty"' -i packages/twenty-docker/docker-compose.yml
yq eval 'del(.services.worker.image)' -i packages/twenty-docker/docker-compose.yml
yq eval '.services.worker.build.context = "../../"' -i packages/twenty-docker/docker-compose.yml
yq eval '.services.worker.build.dockerfile = "./packages/twenty-docker/twenty/Dockerfile"' -i packages/twenty-docker/docker-compose.yml
yq eval '.services.worker.build.target = "twenty"' -i packages/twenty-docker/docker-compose.yml
echo "Adding SIGN_IN_PREFILLED environment variable to server service..."
yq eval '.services.server.environment.SIGN_IN_PREFILLED = "${SIGN_IN_PREFILLED}"' -i packages/twenty-docker/docker-compose.yml
echo "Setting up .env file..."
cp packages/twenty-docker/.env.example packages/twenty-docker/.env
echo "Generating secrets..."
echo "" >> packages/twenty-docker/.env
echo "# === Randomly generated secrets ===" >> packages/twenty-docker/.env
echo "APP_SECRET=$(openssl rand -base64 32)" >> packages/twenty-docker/.env
echo "PG_DATABASE_PASSWORD=$(openssl rand -hex 16)" >> packages/twenty-docker/.env
echo "SIGN_IN_PREFILLED=true" >> packages/twenty-docker/.env
echo "Docker compose build..."
cd packages/twenty-docker/
docker compose build
working-directory: ./
- name: Create Tunnel
id: expose-tunnel
env:
CLOUDFLARED_VERSION: '2026.3.0'
run: |
set -euo pipefail
# Install cloudflared (pinned for reproducibility)
sudo curl -fsSL -o /usr/local/bin/cloudflared \
"https://github.com/cloudflare/cloudflared/releases/download/${CLOUDFLARED_VERSION}/cloudflared-linux-amd64"
sudo chmod +x /usr/local/bin/cloudflared
cloudflared --version
# Start an account-less "quick tunnel" pointing at the server container.
# Cloudflare prints the assigned https://*.trycloudflare.com URL into the log.
log_file="$RUNNER_TEMP/cloudflared.log"
: > "$log_file"
cloudflared tunnel \
--url http://localhost:3000 \
--no-autoupdate \
--logfile "$log_file" \
--loglevel info \
> "$RUNNER_TEMP/cloudflared.stdout" 2>&1 &
pid=$!
echo "$pid" > "$RUNNER_TEMP/cloudflared.pid"
echo "cloudflared PID: $pid"
# Wait up to 2 minutes for the URL to appear; fail fast if cloudflared exits.
url=''
for _ in $(seq 1 60); do
url=$(grep -oE 'https://[a-zA-Z0-9-]+\.trycloudflare\.com' "$log_file" 2>/dev/null | head -n1 || true)
[ -n "$url" ] && break
if ! kill -0 "$pid" 2>/dev/null; then
echo "cloudflared exited before producing a URL"
cat "$log_file" || true
exit 1
fi
sleep 2
done
if [ -z "$url" ]; then
echo "Timed out waiting for tunnel URL"
cat "$log_file" || true
exit 1
fi
echo "Tunnel URL: $url"
echo "tunnel-url=$url" >> "$GITHUB_OUTPUT"
- name: Start services with correct SERVER_URL
env:
TUNNEL_URL: ${{ steps.expose-tunnel.outputs.tunnel-url }}
run: |
cd packages/twenty-docker/
echo "Setting SERVER_URL to $TUNNEL_URL"
sed -i '/SERVER_URL=/d' .env
echo "" >> .env
echo "SERVER_URL=$TUNNEL_URL" >> .env
# Start the services
echo "Docker compose up..."
docker compose up -d || {
echo "Docker compose failed to start"
docker compose logs
exit 1
}
echo "Waiting for services to be ready..."
count=0
while [ ! $(docker inspect --format='{{.State.Health.Status}}' twenty-db-1) = "healthy" ] || [ ! $(docker inspect --format='{{.State.Health.Status}}' twenty-server-1) = "healthy" ]; do
sleep 5
count=$((count+1))
if [ $count -gt 60 ]; then
echo "Timeout waiting for services to be ready"
docker compose logs
exit 1
fi
echo "Still waiting for services... ($count/60)"
done
echo "All services are up and running!"
working-directory: ./
- name: Seed Dev Workspace
run: |
cd packages/twenty-docker/
echo "Seeding light dev workspace (Apple only)..."
if ! docker compose exec -T server yarn command:prod workspace:seed:dev --light; then
echo "❌ Seeding light dev workspace failed. Dumping server logs..."
docker compose logs server
exit 1
fi
working-directory: ./
- name: Output tunnel URL
env:
TUNNEL_URL: ${{ steps.expose-tunnel.outputs.tunnel-url }}
run: |
echo "✅ Preview Environment Ready!"
echo "🔗 Preview URL: $TUNNEL_URL"
echo "⏱️ This environment will be available for 5 hours"
echo "## 🚀 Preview Environment Ready!" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "Preview URL: $TUNNEL_URL" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "This environment will automatically shut down after 5 hours." >> "$GITHUB_STEP_SUMMARY"
echo "$TUNNEL_URL" > tunnel-url.txt
- name: Upload tunnel URL artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: tunnel-url
path: tunnel-url.txt
retention-days: 1
- name: Keep tunnel alive for 5 hours
run: timeout 300m sleep 18000 # Stop on whichever we reach first (300m or 5hour sleep)
- name: Cleanup
if: always()
run: |
if [ -f "$RUNNER_TEMP/cloudflared.pid" ]; then
kill "$(cat "$RUNNER_TEMP/cloudflared.pid")" 2>/dev/null || true
fi
cd packages/twenty-docker/
docker compose down -v
working-directory: ./
+5 -5
View File
@@ -54,7 +54,7 @@ jobs:
# Strict mode fails if there are missing website translations.
- name: Compile website translations
id: compile_translations_strict
run: npx nx run twenty-website:lingui:compile --strict
run: npx nx run twenty-website-new:lingui:compile --strict
continue-on-error: true
- name: Stash any changes before pulling translations
@@ -71,8 +71,8 @@ jobs:
upload_sources: false
upload_translations: false
download_translations: true
source: 'packages/twenty-website/src/locales/en.po'
translation: 'packages/twenty-website/src/locales/%locale%.po'
source: 'packages/twenty-website-new/src/locales/en.po'
translation: 'packages/twenty-website-new/src/locales/%locale%.po'
export_only_approved: false
localization_branch_name: i18n-website
base_url: 'https://twenty.api.crowdin.com'
@@ -101,9 +101,9 @@ jobs:
- name: Compile website translations
id: compile_translations
run: |
npx nx run twenty-website:lingui:compile
npx nx run twenty-website-new:lingui:compile
git status
git add packages/twenty-website/src/locales
git add packages/twenty-website-new/src/locales
if ! git diff --staged --quiet --exit-code; then
git commit -m "chore: compile website translations"
echo "changes_detected=true" >> $GITHUB_OUTPUT
+5 -5
View File
@@ -10,7 +10,7 @@ on:
push:
branches: ['main']
paths:
- 'packages/twenty-website/**'
- 'packages/twenty-website-new/**'
- '.github/crowdin-website.yml'
- '.github/workflows/website-i18n-push.yaml'
@@ -41,14 +41,14 @@ jobs:
run: npx nx build twenty-shared
- name: Extract website translations
run: npx nx run twenty-website:lingui:extract
run: npx nx run twenty-website-new:lingui:extract
- name: Check and commit extracted files
id: check_extract_changes
run: |
git config --global user.name 'github-actions'
git config --global user.email 'github-actions@twenty.com'
git add packages/twenty-website/src/locales
git add packages/twenty-website-new/src/locales
if ! git diff --staged --quiet --exit-code; then
git commit -m "chore: extract website translations"
echo "changes_detected=true" >> $GITHUB_OUTPUT
@@ -57,14 +57,14 @@ jobs:
fi
- name: Compile website translations
run: npx nx run twenty-website:lingui:compile
run: npx nx run twenty-website-new:lingui:compile
- name: Check and commit compiled files
id: check_compile_changes
run: |
git config --global user.name 'github-actions'
git config --global user.email 'github-actions@twenty.com'
git add packages/twenty-website/src/locales/generated
git add packages/twenty-website-new/src/locales/generated
if ! git diff --staged --quiet --exit-code; then
git commit -m "chore: compile website translations"
echo "changes_detected=true" >> $GITHUB_OUTPUT
@@ -1,69 +0,0 @@
name: 'Website Preview Dispatch'
permissions:
contents: read
on:
pull_request:
types: [opened, synchronize, reopened, closed, labeled]
paths:
- packages/twenty-website/**
- .github/workflows/website-preview-dispatch.yaml
concurrency:
# Keyed on PR number so independent PRs don't cancel each other. `github.ref`
# would resolve to the base branch under pull_request and collide.
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
trigger-build:
# Same fork PRs from outside the org don't have `secrets.*` so the dispatch
# call would fail anyway — skip explicitly to avoid noise.
if: |
github.event.pull_request.head.repo.full_name == github.repository &&
github.event.action != 'closed' && (
(github.event.action == 'labeled' && github.event.label.name == 'preview-website') ||
(
(
github.event.pull_request.author_association == 'MEMBER' ||
github.event.pull_request.author_association == 'OWNER' ||
github.event.pull_request.author_association == 'COLLABORATOR'
) && contains(fromJSON('["opened","synchronize","reopened"]'), github.event.action)
)
)
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Dispatch website-preview-build to ci-privileged
env:
GH_TOKEN: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PR_HEAD_REF: ${{ github.event.pull_request.head.ref }}
run: |
gh api repos/twentyhq/ci-privileged/dispatches \
-f event_type=website-preview-build \
-f "client_payload[pr_number]=$PR_NUMBER" \
-f "client_payload[pr_head_sha]=$PR_HEAD_SHA" \
-f "client_payload[pr_head_ref]=$PR_HEAD_REF"
trigger-cleanup:
# Covers both merge and close-without-merge — pull_request `closed` fires
# for both. PRs left open forever are covered by OpenNext's
# `maxVersionAgeDays: 14` + `maxNumberOfVersions: 50` auto-pruning in
# open-next.config.ts, so nothing leaks even if cleanup never runs.
if: |
github.event.pull_request.head.repo.full_name == github.repository &&
github.event.action == 'closed'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Dispatch website-preview-cleanup to ci-privileged
env:
GH_TOKEN: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
gh api repos/twentyhq/ci-privileged/dispatches \
-f event_type=website-preview-cleanup \
-f "client_payload[pr_number]=$PR_NUMBER"
-27
View File
@@ -1,27 +0,0 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"singleQuote": true,
"trailingComma": "all",
"endOfLine": "lf",
"printWidth": 80,
"sortPackageJson": false,
"ignorePatterns": [
"**/dist/**",
"**/build/**",
"**/lib/**",
"**/.next/**",
"**/coverage/**",
"**/generated/**",
"**/generated-admin/**",
"**/generated-metadata/**",
"**/.cache/**",
"**/node_modules/**",
"**/*.min.js",
"**/*.snap",
"**/*.md",
"**/*.mdx",
"**/seed-project/**/*.mjs",
"packages/twenty-zapier/build/**",
"**/upgrade-version-command/**"
]
}
+2 -2
View File
@@ -81,8 +81,8 @@
"path": "../packages/twenty-sdk"
},
{
"name": "packages/twenty-website",
"path": "../packages/twenty-website"
"name": "packages/twenty-website-new",
"path": "../packages/twenty-website-new"
}
],
"settings": {
-4
View File
@@ -10,8 +10,4 @@ nodeLinker: node-modules
npmMinimalAgeGate: 3d
npmPreapprovedPackages:
- twenty-sdk
- twenty-client-sdk
yarnPath: .yarn/releases/yarn-4.13.0.cjs
+1 -1
View File
@@ -110,7 +110,7 @@ packages/
├── twenty-ui/ # Shared UI components library
├── twenty-shared/ # Common types and utilities
├── twenty-emails/ # Email templates with React Email
├── twenty-website/ # Next.js marketing website
├── twenty-website-new/ # Next.js marketing website
├── twenty-docs/ # Documentation website
├── twenty-zapier/ # Zapier integration
└── twenty-e2e-testing/ # Playwright E2E tests
+44 -44
View File
@@ -1,19 +1,19 @@
<p align="center">
<a href="https://www.twenty.com">
<img src="./packages/twenty-website/public/images/core/logo.svg" width="100px" alt="Twenty logo" />
<img src="./packages/twenty-website-new/public/images/core/logo.svg" width="100px" alt="Twenty logo" />
</a>
</p>
<h2 align="center" >The #1 Open-Source CRM</h2>
<p align="center"><a href="https://twenty.com"><img src="./packages/twenty-website/public/images/readme/globe-icon.svg" width="12" height="12"/> Website</a> · <a href="https://docs.twenty.com"><img src="./packages/twenty-website/public/images/readme/book-icon.svg" width="12" height="12"/> Documentation</a> · <a href="https://github.com/orgs/twentyhq/projects/1"><img src="./packages/twenty-website/public/images/readme/map-icon.svg" width="12" height="12"/> Roadmap </a> · <a href="https://discord.gg/cx5n4Jzs57"><img src="./packages/twenty-website/public/images/readme/discord-icon.svg" width="12" height="12"/> Discord</a> · <a href="https://www.figma.com/file/xt8O9mFeLl46C5InWwoMrN/Twenty"><img src="./packages/twenty-website/public/images/readme/figma-icon.webp" width="12" height="12"/> Figma</a></p>
<p align="center"><a href="https://twenty.com"><img src="./packages/twenty-website-new/public/images/readme/globe-icon.svg" width="12" height="12"/> Website</a> · <a href="https://docs.twenty.com"><img src="./packages/twenty-website-new/public/images/readme/book-icon.svg" width="12" height="12"/> Documentation</a> · <a href="https://github.com/orgs/twentyhq/projects/1"><img src="./packages/twenty-website-new/public/images/readme/map-icon.svg" width="12" height="12"/> Roadmap </a> · <a href="https://discord.gg/cx5n4Jzs57"><img src="./packages/twenty-website-new/public/images/readme/discord-icon.svg" width="12" height="12"/> Discord</a> · <a href="https://www.figma.com/file/xt8O9mFeLl46C5InWwoMrN/Twenty"><img src="./packages/twenty-website-new/public/images/readme/figma-icon.webp" width="12" height="12"/> Figma</a></p>
<p align="center">
<a href="https://www.twenty.com">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/github-cover-dark.webp" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/github-cover-light.webp" />
<img src="./packages/twenty-website/public/images/readme/github-cover-light.webp" alt="Twenty banner" />
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website-new/public/images/readme/github-cover-dark.webp" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website-new/public/images/readme/github-cover-light.webp" />
<img src="./packages/twenty-website-new/public/images/readme/github-cover-light.webp" alt="Twenty banner" />
</picture>
</a>
</p>
@@ -24,17 +24,17 @@
Twenty gives technical teams the building blocks for a custom CRM that meets complex business needs and quickly adapts as the business evolves. Twenty is the CRM you build, ship, and version like the rest of your stack.
<a href="https://twenty.com/resources/why-twenty"><img src="./packages/twenty-website/public/images/readme/star-icon.svg" width="14" height="14"/> Learn more about why we built Twenty</a>
<a href="https://twenty.com/resources/why-twenty"><img src="./packages/twenty-website-new/public/images/readme/star-icon.svg" width="14" height="14"/> Learn more about why we built Twenty</a>
<br />
# Installation
### <img src="./packages/twenty-website/public/images/readme/globe-icon.svg" width="14" height="14"/> Cloud
### <img src="./packages/twenty-website-new/public/images/readme/globe-icon.svg" width="14" height="14"/> Cloud
The fastest way to get started. Sign up at [twenty.com](https://twenty.com) and spin up a workspace in under a minute, with no infrastructure to manage and always up to date.
### <img src="./packages/twenty-website/public/images/readme/book-icon.svg" width="14" height="14"/> Build an app
### <img src="./packages/twenty-website-new/public/images/readme/book-icon.svg" width="14" height="14"/> Build an app
Scaffold a new app with the Twenty CLI:
@@ -63,12 +63,12 @@ export default defineObject({
Then ship it to your workspace:
```bash
npx twenty app:publish --private
npx twenty deploy
```
See the [app development guide](https://docs.twenty.com/developers/extend/apps/getting-started) for objects, views, agents, and logic functions.
### <img src="./packages/twenty-website/public/images/readme/rocket-icon.svg" width="14" height="14"/> Self-hosting
### <img src="./packages/twenty-website-new/public/images/readme/rocket-icon.svg" width="14" height="14"/> Self-hosting
Run Twenty on your own infrastructure with [Docker Compose](https://docs.twenty.com/developers/self-host/capabilities/docker-compose), or contribute locally via the [local setup guide](https://docs.twenty.com/developers/contribute/capabilities/local-setup).
@@ -79,61 +79,61 @@ Run Twenty on your own infrastructure with [Docker Compose](https://docs.twenty.
Twenty gives you the building blocks of a modern CRM (objects, views, workflows, and agents) and lets you extend them as code. Here's a tour of what's in the box.
Want to go deeper? Read the <a href="https://docs.twenty.com/user-guide/introduction"><img src="./packages/twenty-website/public/images/readme/planner-icon.svg" width="14" height="14"/> User Guide</a> for product walkthroughs, or the <a href="https://docs.twenty.com"><img src="./packages/twenty-website/public/images/readme/book-icon.svg" width="14" height="14"/> Documentation</a> for developer reference.
Want to go deeper? Read the <a href="https://docs.twenty.com/user-guide/introduction"><img src="./packages/twenty-website-new/public/images/readme/planner-icon.svg" width="14" height="14"/> User Guide</a> for product walkthroughs, or the <a href="https://docs.twenty.com"><img src="./packages/twenty-website-new/public/images/readme/book-icon.svg" width="14" height="14"/> Documentation</a> for developer reference.
<table align="center">
<tr>
<td width="50%">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/v2-build-apps-dark.webp" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/v2-build-apps-light.webp" />
<img src="./packages/twenty-website/public/images/readme/v2-build-apps-light.webp" alt="Create your apps" />
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website-new/public/images/readme/v2-build-apps-dark.webp" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website-new/public/images/readme/v2-build-apps-light.webp" />
<img src="./packages/twenty-website-new/public/images/readme/v2-build-apps-light.webp" alt="Create your apps" />
</picture>
<p align="center"><a href="https://docs.twenty.com/developers/extend/apps/getting-started"><img src="./packages/twenty-website/public/images/readme/code-icon.svg" width="16" height="16"/> Learn more about apps in doc</a></p>
<p align="center"><a href="https://docs.twenty.com/developers/extend/apps/getting-started"><img src="./packages/twenty-website-new/public/images/readme/code-icon.svg" width="16" height="16"/> Learn more about apps in doc</a></p>
</td>
<td width="50%">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/v2-version-control-dark.webp" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/v2-version-control-light.webp" />
<img src="./packages/twenty-website/public/images/readme/v2-version-control-light.webp" alt="Stay on top with version control" />
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website-new/public/images/readme/v2-version-control-dark.webp" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website-new/public/images/readme/v2-version-control-light.webp" />
<img src="./packages/twenty-website-new/public/images/readme/v2-version-control-light.webp" alt="Stay on top with version control" />
</picture>
<p align="center"><a href="https://docs.twenty.com/developers/extend/apps/publishing"><img src="./packages/twenty-website/public/images/readme/monitor-icon.svg" width="16" height="16"/> Learn more about version control in doc</a></p>
<p align="center"><a href="https://docs.twenty.com/developers/extend/apps/publishing"><img src="./packages/twenty-website-new/public/images/readme/monitor-icon.svg" width="16" height="16"/> Learn more about version control in doc</a></p>
</td>
</tr>
<tr>
<td width="50%">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/v2-all-tools-dark.webp" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/v2-all-tools-light.webp" />
<img src="./packages/twenty-website/public/images/readme/v2-all-tools-light.webp" alt="All the tools you need to build anything" />
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website-new/public/images/readme/v2-all-tools-dark.webp" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website-new/public/images/readme/v2-all-tools-light.webp" />
<img src="./packages/twenty-website-new/public/images/readme/v2-all-tools-light.webp" alt="All the tools you need to build anything" />
</picture>
<p align="center"><a href="https://docs.twenty.com/developers/extend/apps/building"><img src="./packages/twenty-website/public/images/readme/rocket-icon.svg" width="16" height="16"/> Learn more about primitives in doc</a></p>
<p align="center"><a href="https://docs.twenty.com/developers/extend/apps/building"><img src="./packages/twenty-website-new/public/images/readme/rocket-icon.svg" width="16" height="16"/> Learn more about primitives in doc</a></p>
</td>
<td width="50%">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/v2-tools-dark.webp" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/v2-tools-light.webp" />
<img src="./packages/twenty-website/public/images/readme/v2-tools-light.webp" alt="Customize your layouts" />
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website-new/public/images/readme/v2-tools-dark.webp" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website-new/public/images/readme/v2-tools-light.webp" />
<img src="./packages/twenty-website-new/public/images/readme/v2-tools-light.webp" alt="Customize your layouts" />
</picture>
<p align="center"><a href="https://docs.twenty.com/user-guide/layout/overview"><img src="./packages/twenty-website/public/images/readme/planner-icon.svg" width="16" height="16"/> Learn more about layouts in doc</a></p>
<p align="center"><a href="https://docs.twenty.com/user-guide/layout/overview"><img src="./packages/twenty-website-new/public/images/readme/planner-icon.svg" width="16" height="16"/> Learn more about layouts in doc</a></p>
</td>
</tr>
<tr>
<td width="50%">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/v2-ai-agents-dark.webp" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/v2-ai-agents-light.webp" />
<img src="./packages/twenty-website/public/images/readme/v2-ai-agents-light.webp" alt="AI agents and chats" />
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website-new/public/images/readme/v2-ai-agents-dark.webp" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website-new/public/images/readme/v2-ai-agents-light.webp" />
<img src="./packages/twenty-website-new/public/images/readme/v2-ai-agents-light.webp" alt="AI agents and chats" />
</picture>
<p align="center"><a href="https://docs.twenty.com/user-guide/ai/overview"><img src="./packages/twenty-website/public/images/readme/message-icon.svg" width="16" height="16"/> Learn more about AI in doc</a></p>
<p align="center"><a href="https://docs.twenty.com/user-guide/ai/overview"><img src="./packages/twenty-website-new/public/images/readme/message-icon.svg" width="16" height="16"/> Learn more about AI in doc</a></p>
</td>
<td width="50%">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/v2-crm-tools-dark.webp" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/v2-crm-tools-light.webp" />
<img src="./packages/twenty-website/public/images/readme/v2-crm-tools-light.webp" alt="Plus all the tools of a good CRM" />
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website-new/public/images/readme/v2-crm-tools-dark.webp" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website-new/public/images/readme/v2-crm-tools-light.webp" />
<img src="./packages/twenty-website-new/public/images/readme/v2-crm-tools-light.webp" alt="Plus all the tools of a good CRM" />
</picture>
<p align="center"><a href="https://docs.twenty.com/user-guide/introduction"><img src="./packages/twenty-website/public/images/readme/star-icon.svg" width="16" height="16"/> Learn more about CRM features in doc</a></p>
<p align="center"><a href="https://docs.twenty.com/user-guide/introduction"><img src="./packages/twenty-website-new/public/images/readme/star-icon.svg" width="16" height="16"/> Learn more about CRM features in doc</a></p>
</td>
</tr>
</table>
@@ -142,23 +142,23 @@ Want to go deeper? Read the <a href="https://docs.twenty.com/user-guide/introduc
# Stack
- <a href="https://www.typescriptlang.org/"><img src="./packages/twenty-website/public/images/readme/stack-typescript.svg" width="14" height="14"/> TypeScript</a>
- <a href="https://nx.dev/"><img src="./packages/twenty-website/public/images/readme/stack-nx.svg" width="14" height="14"/> Nx</a>
- <a href="https://nestjs.com/"><img src="./packages/twenty-website/public/images/readme/stack-nestjs.svg" width="14" height="14"/> NestJS</a>, with <a href="https://bullmq.io/">BullMQ</a>, <a href="https://www.postgresql.org/"><img src="./packages/twenty-website/public/images/readme/stack-postgresql.svg" width="14" height="14"/> PostgreSQL</a>, <a href="https://redis.io/"><img src="./packages/twenty-website/public/images/readme/stack-redis.svg" width="14" height="14"/> Redis</a>
- <a href="https://reactjs.org/"><img src="./packages/twenty-website/public/images/readme/stack-react.svg" width="14" height="14"/> React</a>, with <a href="https://jotai.org/">Jotai</a>, <a href="https://linaria.dev/">Linaria</a> and <a href="https://lingui.dev/">Lingui</a>
- <a href="https://www.typescriptlang.org/"><img src="./packages/twenty-website-new/public/images/readme/stack-typescript.svg" width="14" height="14"/> TypeScript</a>
- <a href="https://nx.dev/"><img src="./packages/twenty-website-new/public/images/readme/stack-nx.svg" width="14" height="14"/> Nx</a>
- <a href="https://nestjs.com/"><img src="./packages/twenty-website-new/public/images/readme/stack-nestjs.svg" width="14" height="14"/> NestJS</a>, with <a href="https://bullmq.io/">BullMQ</a>, <a href="https://www.postgresql.org/"><img src="./packages/twenty-website-new/public/images/readme/stack-postgresql.svg" width="14" height="14"/> PostgreSQL</a>, <a href="https://redis.io/"><img src="./packages/twenty-website-new/public/images/readme/stack-redis.svg" width="14" height="14"/> Redis</a>
- <a href="https://reactjs.org/"><img src="./packages/twenty-website-new/public/images/readme/stack-react.svg" width="14" height="14"/> React</a>, with <a href="https://jotai.org/">Jotai</a>, <a href="https://linaria.dev/">Linaria</a> and <a href="https://lingui.dev/">Lingui</a>
# Thanks
<p align="center">
<a href="https://www.chromatic.com/"><img src="./packages/twenty-website/public/images/readme/chromatic.webp" height="28" alt="Chromatic" /></a>
<a href="https://www.chromatic.com/"><img src="./packages/twenty-website-new/public/images/readme/chromatic.webp" height="28" alt="Chromatic" /></a>
&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://greptile.com"><img src="./packages/twenty-website/public/images/readme/greptile.webp" height="28" alt="Greptile" /></a>
<a href="https://greptile.com"><img src="./packages/twenty-website-new/public/images/readme/greptile.webp" height="28" alt="Greptile" /></a>
&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://sentry.io/"><img src="./packages/twenty-website/public/images/readme/sentry.webp" height="28" alt="Sentry" /></a>
<a href="https://sentry.io/"><img src="./packages/twenty-website-new/public/images/readme/sentry.webp" height="28" alt="Sentry" /></a>
&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://crowdin.com/"><img src="./packages/twenty-website/public/images/readme/crowdin.webp" height="28" alt="Crowdin" /></a>
<a href="https://crowdin.com/"><img src="./packages/twenty-website-new/public/images/readme/crowdin.webp" height="28" alt="Crowdin" /></a>
</p>
Thanks to these amazing services that we use and recommend for UI testing (Chromatic), code review (Greptile), catching bugs (Sentry) and translating (Crowdin).
@@ -166,4 +166,4 @@ Want to go deeper? Read the <a href="https://docs.twenty.com/user-guide/introduc
# Join the Community
<p><a href="https://github.com/twentyhq/twenty"><img src="./packages/twenty-website/public/images/readme/star-icon.svg" width="12" height="12"/> Star the repo</a> · <a href="https://discord.gg/cx5n4Jzs57"><img src="./packages/twenty-website/public/images/readme/discord-icon.svg" width="12" height="12"/> Discord</a> · <a href="https://github.com/twentyhq/twenty/discussions"><img src="./packages/twenty-website/public/images/readme/message-icon.svg" width="12" height="12"/> Feature requests</a> · <a href="https://github.com/orgs/twentyhq/projects/1/views/35"><img src="./packages/twenty-website/public/images/readme/rocket-icon.svg" width="12" height="12"/> Releases</a> · <a href="https://twitter.com/twentycrm"><img src="./packages/twenty-website/public/images/readme/x-icon.svg" width="12" height="12"/> X</a> · <a href="https://www.linkedin.com/company/twenty/"><img src="./packages/twenty-website/public/images/readme/linkedin-icon.svg" width="12" height="12"/> LinkedIn</a> · <a href="https://twenty.crowdin.com/twenty"><img src="./packages/twenty-website/public/images/readme/language-icon.svg" width="12" height="12"/> Crowdin</a> · <a href="https://github.com/twentyhq/twenty/contribute"><img src="./packages/twenty-website/public/images/readme/code-icon.svg" width="12" height="12"/> Contribute</a></p>
<p><a href="https://github.com/twentyhq/twenty"><img src="./packages/twenty-website-new/public/images/readme/star-icon.svg" width="12" height="12"/> Star the repo</a> · <a href="https://discord.gg/cx5n4Jzs57"><img src="./packages/twenty-website-new/public/images/readme/discord-icon.svg" width="12" height="12"/> Discord</a> · <a href="https://github.com/twentyhq/twenty/discussions"><img src="./packages/twenty-website-new/public/images/readme/message-icon.svg" width="12" height="12"/> Feature requests</a> · <a href="https://github.com/orgs/twentyhq/projects/1/views/35"><img src="./packages/twenty-website-new/public/images/readme/rocket-icon.svg" width="12" height="12"/> Releases</a> · <a href="https://twitter.com/twentycrm"><img src="./packages/twenty-website-new/public/images/readme/x-icon.svg" width="12" height="12"/> X</a> · <a href="https://www.linkedin.com/company/twenty/"><img src="./packages/twenty-website-new/public/images/readme/linkedin-icon.svg" width="12" height="12"/> LinkedIn</a> · <a href="https://twenty.crowdin.com/twenty"><img src="./packages/twenty-website-new/public/images/readme/language-icon.svg" width="12" height="12"/> Crowdin</a> · <a href="https://github.com/twentyhq/twenty/contribute"><img src="./packages/twenty-website-new/public/images/readme/code-icon.svg" width="12" height="12"/> Contribute</a></p>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 173 KiB

+13 -9
View File
@@ -44,12 +44,12 @@
"cache": true,
"options": {
"cwd": "{projectRoot}",
"command": "npx oxlint -c .oxlintrc.json . && (npx oxfmt --check . || (echo 'ERROR: oxfmt formatting check failed! Fix with: npx nx lint --configuration=fix' && false))"
"command": "npx oxlint -c .oxlintrc.json . && (prettier . --check --cache --cache-location ../../.cache/prettier/{projectRoot} --cache-strategy metadata || (echo 'ERROR: Prettier formatting check failed! Fix with: npx nx lint --configuration=fix' && false))"
},
"configurations": {
"ci": {},
"fix": {
"command": "npx oxlint --fix -c .oxlintrc.json . && npx oxfmt ."
"command": "npx oxlint --fix -c .oxlintrc.json . && prettier . --write --cache --cache-location ../../.cache/prettier/{projectRoot} --cache-strategy metadata"
}
},
"dependsOn": ["^build", "twenty-oxlint-rules:build"]
@@ -59,12 +59,12 @@
"cache": false,
"dependsOn": ["twenty-oxlint-rules:build"],
"options": {
"command": "FILES=$(git diff --name-only --diff-filter=d main -- {projectRoot}/ | grep -E '{args.pattern}'); [ -z \"$FILES\" ] && echo 'No changed files.' || (npx oxlint -c {projectRoot}/.oxlintrc.json $FILES && (npx oxfmt --check $FILES || (echo 'ERROR: oxfmt formatting check failed! Fix with: npx nx lint:diff-with-main --configuration=fix' && false)))",
"command": "FILES=$(git diff --name-only --diff-filter=d main -- {projectRoot}/ | grep -E '{args.pattern}'); [ -z \"$FILES\" ] && echo 'No changed files.' || (npx oxlint -c {projectRoot}/.oxlintrc.json $FILES && (prettier --check $FILES || (echo 'ERROR: Prettier formatting check failed! Fix with: npx nx lint:diff-with-main --configuration=fix' && false)))",
"pattern": "\\.(ts|tsx|js|jsx)$"
},
"configurations": {
"fix": {
"command": "FILES=$(git diff --name-only --diff-filter=d main -- {projectRoot}/ | grep -E '{args.pattern}'); [ -z \"$FILES\" ] && echo 'No changed files.' || (npx oxlint --fix -c {projectRoot}/.oxlintrc.json $FILES && npx oxfmt $FILES)"
"command": "FILES=$(git diff --name-only --diff-filter=d main -- {projectRoot}/ | grep -E '{args.pattern}'); [ -z \"$FILES\" ] && echo 'No changed files.' || (npx oxlint --fix -c {projectRoot}/.oxlintrc.json $FILES && prettier --write $FILES)"
}
}
},
@@ -73,14 +73,18 @@
"cache": true,
"options": {
"cwd": "{projectRoot}",
"command": "npx oxfmt --check {args.files} {args.write}",
"files": ".",
"write": ""
"command": "prettier {args.files} --check --cache {args.cache} --cache-location {args.cacheLocation} --write {args.write} --cache-strategy {args.cacheStrategy}",
"cache": true,
"cacheLocation": "../../.cache/prettier/{projectRoot}",
"cacheStrategy": "metadata",
"write": false
},
"configurations": {
"ci": {},
"ci": {
"cacheStrategy": "content"
},
"fix": {
"command": "npx oxfmt {args.files}"
"write": true
}
},
"dependsOn": ["^build"]
+2 -3
View File
@@ -13,7 +13,6 @@
"concurrently": "^8.2.2",
"http-server": "^14.1.1",
"nx": "22.5.4",
"oxfmt": "0.50.0",
"tsx": "^4.17.0",
"verdaccio": "^6.3.1"
},
@@ -28,7 +27,7 @@
"resolutions": {
"graphql": "16.8.1",
"type-fest": "4.10.1",
"typescript": "5.9.3",
"typescript": "5.9.2",
"nodemailer": "8.0.4",
"graphql-redis-subscriptions/ioredis": "^5.6.0",
"@lingui/core": "5.1.2",
@@ -54,7 +53,7 @@
"packages/twenty-ui",
"packages/twenty-utils",
"packages/twenty-zapier",
"packages/twenty-website",
"packages/twenty-website-new",
"packages/twenty-docs",
"packages/twenty-e2e-testing",
"packages/twenty-shared",
+13 -4
View File
@@ -1,7 +1,7 @@
<div align="center">
<a href="https://twenty.com">
<picture>
<img alt="Twenty logo" src="https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-website/public/images/core/logo.svg" height="128">
<img alt="Twenty logo" src="https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-website-new/public/images/core/logo.svg" height="128">
</picture>
</a>
<h1>Create Twenty App</h1>
@@ -32,12 +32,21 @@ The scaffolder will:
| Flag | Description |
| ---------------------------------- | --------------------------------------------------------------------- |
| `--example <name>` | Initialize from an example |
| `--name <name>` | Set the app name |
| `--display-name <displayName>` | Set the display name |
| `--description <description>` | Set the description |
| `--url <url>` | Twenty workspace URL (default: `http://localhost:2020`) |
| `--api-url <url>` | Twenty instance URL (default: `http://localhost:2020`) |
| `--authentication-method <method>` | `oauth` or `apiKey` (default: `apiKey` for local, `oauth` for remote) |
By default (no flags), a minimal app is generated with core files and an integration test. Use `--example` to start from a richer example:
```bash
npx create-twenty-app@latest my-twenty-app --example hello-world
```
Examples are sourced from [twentyhq/twenty/packages/twenty-apps/examples](https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples).
## Documentation
Full documentation is available at **[docs.twenty.com/developers/extend/apps](https://docs.twenty.com/developers/extend/apps/getting-started/quick-start)**:
@@ -48,8 +57,8 @@ Full documentation is available at **[docs.twenty.com/developers/extend/apps](ht
## Troubleshooting
- Server not starting: check Docker is running (`docker info`), then try `yarn twenty docker:logs`.
- Auth not working: run `yarn twenty remote:add --local` to re-authenticate.
- Server not starting: check Docker is running (`docker info`), then try `yarn twenty server logs`.
- Auth not working: run `yarn twenty remote add --local` to re-authenticate.
- Types not generated: ensure `yarn twenty dev` is running — it auto-generates the typed client.
## Contributing
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "create-twenty-app",
"version": "2.9.0",
"version": "2.5.0",
"description": "Command-line interface to create Twenty application",
"main": "dist/cli.cjs",
"bin": "dist/cli.cjs",
@@ -50,7 +50,7 @@
"jest": "29.7.0",
"jest-environment-node": "^29.4.1",
"twenty-shared": "workspace:*",
"typescript": "^5.9.3",
"typescript": "^5.9.2",
"vite": "^7.0.0",
"vite-plugin-dts": "^4.5.4",
"vite-tsconfig-paths": "^4.2.1"
+8 -12
View File
@@ -15,11 +15,14 @@ const program = new Command(packageJson.name)
'Output the current version of create-twenty-app.',
)
.argument('[directory]')
.option('--example <name>', 'Initialize from an example')
.option('-n, --name <name>', 'Application name')
.option('-d, --display-name <displayName>', 'Application display name')
.option('--description <description>', 'Application description')
.option('--url <url>', 'Twenty server URL (default: http://localhost:2020)')
.option('--api-url <apiUrl>', '[deprecated: use --url]')
.option(
'--api-url <apiUrl>',
'Twenty instance URL (default: http://localhost:2020)',
)
.option(
'--authentication-method <method>',
'Authentication method: oauth or apiKey (default: apiKey for local, oauth for remote)',
@@ -29,10 +32,10 @@ const program = new Command(packageJson.name)
async (
directory?: string,
options?: {
example?: string;
name?: string;
displayName?: string;
description?: string;
url?: string;
apiUrl?: string;
authenticationMethod?: AuthenticationMethod;
},
@@ -63,20 +66,13 @@ const program = new Command(packageJson.name)
process.exit(1);
}
if (options?.apiUrl) {
console.warn(
chalk.yellow('Warning: --api-url is deprecated. Use --url instead.'),
);
}
const serverUrl = (options?.url ?? options?.apiUrl)?.replace(/\/+$/, '');
await new CreateAppCommand().execute({
directory,
example: options?.example,
name: options?.name,
displayName: options?.displayName,
description: options?.description,
serverUrl,
apiUrl: options?.apiUrl,
authenticationMethod: options?.authenticationMethod,
});
},
@@ -49,19 +49,19 @@
## Best practice
It's highly recommended to create new app entities using `yarn twenty dev:add`. These are the options:
It's highly recommended to create new app entities using `yarn twenty add`. These are the options:
| Entity type | Command | Generated file |
| -------------------- | ---------------------------------------- | ------------------------------------- |
| Object | `yarn twenty dev:add object` | `src/objects/<name>.ts` |
| Field | `yarn twenty dev:add field` | `src/fields/<name>.ts` |
| Logic function | `yarn twenty dev:add logicFunction` | `src/logic-functions/<name>.ts` |
| Front component | `yarn twenty dev:add frontComponent` | `src/front-components/<name>.tsx` |
| Role | `yarn twenty dev:add role` | `src/roles/<name>.ts` |
| Skill | `yarn twenty dev:add skill` | `src/skills/<name>.ts` |
| Agent | `yarn twenty dev:add agent` | `src/agents/<name>.ts` |
| View | `yarn twenty dev:add view` | `src/views/<name>.ts` |
| Navigation menu item | `yarn twenty dev:add navigationMenuItem` | `src/navigation-menu-items/<name>.ts` |
| Page layout | `yarn twenty dev:add pageLayout` | `src/page-layouts/<name>.ts` |
| Entity type | Command | Generated file |
| -------------------- | ------------------------------------ | ------------------------------------- |
| Object | `yarn twenty add object` | `src/objects/<name>.ts` |
| Field | `yarn twenty add field` | `src/fields/<name>.ts` |
| Logic function | `yarn twenty add logicFunction` | `src/logic-functions/<name>.ts` |
| Front component | `yarn twenty add frontComponent` | `src/front-components/<name>.tsx` |
| Role | `yarn twenty add role` | `src/roles/<name>.ts` |
| Skill | `yarn twenty add skill` | `src/skills/<name>.ts` |
| Agent | `yarn twenty add agent` | `src/agents/<name>.ts` |
| View | `yarn twenty add view` | `src/views/<name>.ts` |
| Navigation menu item | `yarn twenty add navigationMenuItem` | `src/navigation-menu-items/<name>.ts` |
| Page layout | `yarn twenty add pageLayout` | `src/page-layouts/<name>.ts` |
This helps automatically generate required IDs etc.
@@ -11,8 +11,8 @@ Run `yarn twenty help` to list all available commands.
## Useful Commands
- `yarn twenty dev` - Start the development server and sync your app
- `yarn twenty docker:status` - Check the local Twenty server status
- `yarn twenty docker:start` - Start the local Twenty server
- `yarn twenty server status` - Check the local Twenty server status
- `yarn twenty server start` - Start the local Twenty server
- `yarn test` - Run integration tests
## Learn More
@@ -1,7 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 80" fill="none">
<rect width="80" height="80" rx="16" fill="#141414"/>
<rect x="20" y="20" width="16" height="16" rx="4" fill="#FAFAFA"/>
<rect x="44" y="20" width="16" height="16" rx="4" fill="#FAFAFA" opacity="0.6"/>
<rect x="20" y="44" width="16" height="16" rx="4" fill="#FAFAFA" opacity="0.6"/>
<rect x="44" y="44" width="16" height="16" rx="4" fill="#FAFAFA" opacity="0.3"/>
</svg>

Before

Width:  |  Height:  |  Size: 454 B

@@ -14,7 +14,7 @@ function validateEnv(): { apiUrl: string; apiKey: string } {
if (!apiUrl || !apiKey) {
throw new Error(
'TWENTY_API_URL and TWENTY_API_KEY must be set.\n' +
'Start a local server: yarn twenty docker:start\n' +
'Start a local server: yarn twenty server start\n' +
'Or set them in vitest env config.',
);
}
@@ -2,10 +2,3 @@ export const APP_DISPLAY_NAME = 'DISPLAY-NAME-TO-BE-GENERATED';
export const APP_DESCRIPTION = 'DESCRIPTION-TO-BE-GENERATED';
export const APPLICATION_UNIVERSAL_IDENTIFIER = 'UUID-TO-BE-GENERATED';
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER = 'UUID-TO-BE-GENERATED';
export const MAIN_PAGE_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
'UUID-TO-BE-GENERATED';
export const MAIN_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER = 'UUID-TO-BE-GENERATED';
export const MAIN_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER = 'UUID-TO-BE-GENERATED';
export const MAIN_PAGE_WIDGET_UNIVERSAL_IDENTIFIER = 'UUID-TO-BE-GENERATED';
export const MAIN_PAGE_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER =
'UUID-TO-BE-GENERATED';
@@ -1,291 +0,0 @@
import { defineFrontComponent } from 'twenty-sdk/define';
import {
Avatar,
IconBox,
IconHierarchy,
IconLayout,
IconSettingsAutomation,
} from 'twenty-sdk/ui';
import { useState } from 'react';
import {
APP_DISPLAY_NAME,
MAIN_PAGE_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
const DOCS_BASE_URL = 'https://docs.twenty.com/developers/extend/apps';
const CATEGORIES = [
{
title: 'Data model',
color: '#73D08D',
items: [
{ label: 'CUSTOM OBJECT', href: `${DOCS_BASE_URL}/data/objects` },
{
label: 'CUSTOM FIELDS',
href: `${DOCS_BASE_URL}/data/extending-objects`,
},
],
rotation: '2.4deg',
},
{
title: 'Logic',
color: '#F4D345',
items: [
{
label: 'TOOLS',
href: `${DOCS_BASE_URL}/logic/logic-functions`,
},
{
label: 'LOGIC FUNCTION',
href: `${DOCS_BASE_URL}/logic/logic-functions`,
},
{
label: 'SKILLS',
href: `${DOCS_BASE_URL}/logic/skills-and-agents`,
},
],
rotation: '0deg',
},
{
title: 'Layout',
color: '#C4A2E0',
items: [
{ label: 'VIEWS', href: `${DOCS_BASE_URL}/layout/views` },
{ label: 'WIDGETS', href: `${DOCS_BASE_URL}/layout/page-layouts` },
{
label: 'LAYOUT PAGES',
href: `${DOCS_BASE_URL}/layout/page-layouts`,
},
{
label: 'COMMANDS',
href: `${DOCS_BASE_URL}/layout/command-menu-items`,
},
],
rotation: '-2.8deg',
},
] as const;
const ArrowUpRight = ({ color = '#999' }: { color?: string }) => (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path
d="M4.5 3.5H10.5V9.5M10.5 3.5L3.5 10.5"
stroke={color}
strokeWidth="1.2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
const CategoryCard = ({
title,
color,
items,
rotation,
}: {
title: string;
color: string;
items: ReadonlyArray<{ label: string; href: string }>;
rotation: string;
}) => {
const [hoveredItem, setHoveredItem] = useState<string | null>(null);
const CategoryIcon = () => {
if (title === 'Data model') {
return <IconHierarchy color={color} size={'20px'} />;
}
if (title === 'Logic') {
return <IconSettingsAutomation color={color} size={'20px'} />;
}
if (title === 'Layout') {
return <IconLayout color={color} size={'20px'} />;
}
};
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
border: `1px solid ${color}80`,
borderRadius: '12px',
overflow: 'hidden',
width: '240px',
background: '#FFFFFF',
transform: `rotate(${rotation})`,
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.04)',
}}
>
<div
style={{
padding: '16px 20px',
background: `${color}22`,
display: 'flex',
alignItems: 'center',
gap: '12px',
}}
>
<CategoryIcon />
<span
style={{
fontSize: '16px',
fontWeight: 600,
color: color,
}}
>
{title}
</span>
</div>
<div
style={{
display: 'flex',
flexDirection: 'column',
padding: '8px',
gap: '4px',
}}
>
{items.map((item) => {
const isHovered = hoveredItem === item.label;
return (
<a
key={item.label}
href={item.href}
target="_blank"
rel="noopener noreferrer"
onMouseEnter={() => setHoveredItem(item.label)}
onMouseLeave={() => setHoveredItem(null)}
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
textDecoration: 'none',
cursor: 'pointer',
padding: '10px 12px',
borderRadius: '8px',
background: isHovered ? '#0000000A' : 'transparent',
transition: 'background 0.15s',
}}
>
<IconBox color={color} size={'20px'} />
<span
style={{
fontSize: '13px',
fontWeight: 300,
color: '#333',
letterSpacing: '0.5px',
flex: 1,
}}
>
{item.label}
</span>
{isHovered && <ArrowUpRight />}
</a>
);
})}
</div>
</div>
);
};
const MainPage = () => {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
fontFamily:
'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
gap: '8px',
padding: '40px',
}}
>
<Avatar
placeholder={APP_DISPLAY_NAME}
placeholderColorSeed={APP_DISPLAY_NAME}
size="xl"
/>
<span
style={{
fontSize: '24px',
fontWeight: 600,
color: '#333',
marginTop: '8px',
}}
>
{APP_DISPLAY_NAME}
</span>
<span
style={{
fontSize: '13px',
color: '#888',
textAlign: 'center',
lineHeight: '1.5',
}}
>
Was installed successfully.
<br />
You can now add content to your app.
</span>
<a
href="/settings/applications#installed"
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
marginTop: '16px',
fontSize: '13px',
color: '#333',
textDecoration: 'none',
padding: '8px 16px',
borderRadius: '8px',
border: '1px solid #e0e0e0',
background: '#fafafa',
transition: 'background 0.15s, border-color 0.15s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#f0f0f0';
e.currentTarget.style.borderColor = '#ccc';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#fafafa';
e.currentTarget.style.borderColor = '#e0e0e0';
}}
>
Open app settings
<ArrowUpRight color="#333" />
</a>
<div
style={{
display: 'flex',
gap: '16px',
marginTop: '32px',
flexWrap: 'wrap',
justifyContent: 'center',
alignItems: 'flex-start',
}}
>
{CATEGORIES.map((category) => (
<CategoryCard
key={category.title}
title={category.title}
color={category.color}
items={category.items}
rotation={category.rotation}
/>
))}
</div>
</div>
);
};
export default defineFrontComponent({
universalIdentifier: MAIN_PAGE_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
name: APP_DISPLAY_NAME,
description: `${APP_DISPLAY_NAME} front component displaying the app logo and name`,
component: MainPage,
});
@@ -1,19 +0,0 @@
import {
defineNavigationMenuItem,
NavigationMenuItemType,
} from 'twenty-sdk/define';
import {
APP_DISPLAY_NAME,
MAIN_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
MAIN_PAGE_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineNavigationMenuItem({
universalIdentifier: MAIN_PAGE_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER,
name: APP_DISPLAY_NAME,
icon: 'IconFile',
position: -1,
type: NavigationMenuItemType.PAGE_LAYOUT,
pageLayoutUniversalIdentifier: MAIN_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
});
@@ -1,37 +0,0 @@
import { definePageLayout, PageLayoutTabLayoutMode } from 'twenty-sdk/define';
import {
APP_DISPLAY_NAME,
MAIN_PAGE_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
MAIN_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER,
MAIN_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
MAIN_PAGE_WIDGET_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default definePageLayout({
universalIdentifier: MAIN_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
name: APP_DISPLAY_NAME,
type: 'STANDALONE_PAGE',
tabs: [
{
universalIdentifier: MAIN_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER,
title: 'Overview',
position: 0,
icon: 'IconApps',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
widgets: [
{
universalIdentifier: MAIN_PAGE_WIDGET_UNIVERSAL_IDENTIFIER,
title: ' ',
type: 'FRONT_COMPONENT',
gridPosition: { row: 0, column: 0, rowSpan: 12, columnSpan: 12 },
configuration: {
configurationType: 'FRONT_COMPONENT',
frontComponentUniversalIdentifier:
MAIN_PAGE_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
},
},
],
},
],
});
@@ -1 +0,0 @@
nodeLinker: node-modules
@@ -1,4 +1,5 @@
import { copyBaseApplicationProject } from '@/utils/app-template';
import { downloadExample } from '@/utils/download-example';
import { convertToLabel } from '@/utils/convert-to-label';
import { install } from '@/utils/install';
import { tryGitInit } from '@/utils/try-git-init';
@@ -17,7 +18,7 @@ import {
DEV_API_URL,
serverStart,
} from 'twenty-sdk/cli';
import { isDefined, normalizeUrl } from 'twenty-shared/utils';
import { isDefined } from 'twenty-shared/utils';
import {
getDockerInstallInstructions,
isDockerInstalled,
@@ -30,10 +31,11 @@ export type AuthenticationMethod = 'oauth' | 'apiKey';
type CreateAppOptions = {
directory?: string;
example?: string;
name?: string;
displayName?: string;
description?: string;
serverUrl?: string;
apiUrl?: string;
authenticationMethod?: AuthenticationMethod;
};
@@ -45,9 +47,9 @@ export class CreateAppCommand {
const { appName, appDisplayName, appDirectory, appDescription } =
this.getAppInfos(options);
const serverUrl = options.serverUrl ?? DEV_API_URL;
const apiUrl = options.apiUrl ?? DEV_API_URL;
const skipLocalInstance = serverUrl !== DEV_API_URL;
const skipLocalInstance = apiUrl !== DEV_API_URL;
if (!skipLocalInstance && !isDockerInstalled()) {
console.log(chalk.yellow('\n' + getDockerInstallInstructions() + '\n'));
@@ -90,13 +92,30 @@ export class CreateAppCommand {
this.logNextStep('Scaffolding project files');
await copyBaseApplicationProject({
appName,
appDisplayName,
appDescription,
appDirectory,
onProgress: (message) => this.logDetail(message),
});
if (options.example) {
const exampleSucceeded = await this.tryDownloadExample(
options.example,
appDirectory,
);
if (!exampleSucceeded) {
await copyBaseApplicationProject({
appName,
appDisplayName,
appDescription,
appDirectory,
onProgress: (message) => this.logDetail(message),
});
}
} else {
await copyBaseApplicationProject({
appName,
appDisplayName,
appDescription,
appDirectory,
onProgress: (message) => this.logDetail(message),
});
}
this.logNextStep('Installing dependencies');
@@ -118,7 +137,7 @@ export class CreateAppCommand {
console.log('');
let authSucceeded = false;
let resolvedServerUrl = serverUrl;
let resolvedApiUrl = apiUrl;
let serverReady = skipLocalInstance;
if (!skipLocalInstance) {
@@ -126,47 +145,20 @@ export class CreateAppCommand {
const serverResult = await this.ensureDockerServer(dockerPullPromise);
if (isDefined(serverResult.url)) {
resolvedServerUrl = serverResult.url;
resolvedApiUrl = serverResult.url;
serverReady = true;
}
}
if (serverReady) {
this.logNextStep('Authenticating');
authSucceeded = await this.tryExistingAuth(resolvedServerUrl);
if (authSucceeded) {
this.logDetail('Reusing existing credentials');
} else if (authenticationMethod === 'oauth') {
this.logDetail('Starting OAuth flow');
authSucceeded = await this.authenticateWithOAuth(resolvedServerUrl);
} else {
this.logDetail('Using development API key');
authSucceeded = await this.authenticateWithDevKey(resolvedServerUrl);
}
if (serverReady && authenticationMethod === 'oauth') {
this.logNextStep('Authenticating via OAuth');
authSucceeded = await this.authenticateWithOAuth(resolvedApiUrl);
} else if (serverReady && authenticationMethod === 'apiKey') {
this.logNextStep('Authenticating via API key');
authSucceeded = await this.authenticateWithDevKey(resolvedApiUrl);
}
this.logNextStep('Installing application');
let syncSucceeded = false;
if (serverReady && authSucceeded) {
syncSucceeded = await this.syncApplication(appDirectory);
if (!syncSucceeded) {
this.logDetail('Sync failed. Run `yarn twenty dev --once` manually.');
return;
}
} else {
this.logDetail('Skipped (server or authentication not available)');
}
if (syncSucceeded) {
await this.openMainPage(appDirectory, resolvedServerUrl);
}
this.logSuccess(appDirectory, resolvedServerUrl, authSucceeded);
this.logSuccess(appDirectory, resolvedApiUrl, authSucceeded);
} catch (error) {
console.error(
chalk.red('\nCreate application failed:'),
@@ -188,7 +180,6 @@ export class CreateAppCommand {
}
steps += 1; // authenticate (oauth or apiKey)
steps += 1; // sync application
return steps;
}
@@ -202,6 +193,7 @@ export class CreateAppCommand {
const appName = (
options.name ??
options.directory ??
options.example ??
'my-twenty-app'
).trim();
@@ -230,6 +222,28 @@ export class CreateAppCommand {
}
}
private async tryDownloadExample(
example: string,
appDirectory: string,
): Promise<boolean> {
try {
await downloadExample(example, appDirectory);
return true;
} catch (error) {
console.log(
chalk.yellow(
`\n${error instanceof Error ? error.message : 'Failed to download example.'}`,
),
);
this.logDetail('Falling back to default template...');
await fs.emptyDir(appDirectory);
return false;
}
}
private logPlan({
appName,
appDisplayName,
@@ -311,253 +325,11 @@ export class CreateAppCommand {
return {};
}
private async openMainPage(
appDirectory: string,
serverUrl: string,
): Promise<void> {
try {
const configService = new ConfigService();
const config = await configService.getConfig();
const token = config.twentyCLIAccessToken ?? config.apiKey;
if (!token) {
return;
}
const [universalIdentifier, frontUrl] = await Promise.all([
this.readMainPageLayoutUniversalIdentifier(appDirectory),
this.resolveWorkspaceFrontUrl(serverUrl, token),
]);
if (!universalIdentifier || !frontUrl) {
return;
}
const pageLayoutId = await this.resolvePageLayoutId(
serverUrl,
universalIdentifier,
token,
);
if (!pageLayoutId) {
return;
}
const url = `${frontUrl}/page/${pageLayoutId}`;
this.logDetail(`Opening app welcome page: ${url}`);
this.openInBrowser(url);
} catch {
// Best-effort — don't fail the scaffold if browser open fails
}
}
private async resolveWorkspaceFrontUrl(
serverUrl: string,
token: string,
): Promise<string | null> {
const query = `{ currentWorkspace { workspaceUrls { subdomainUrl customUrl } } }`;
const response = await fetch(`${serverUrl}/metadata`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ query }),
});
if (!response.ok) {
return null;
}
const body = (await response.json()) as {
data?: {
currentWorkspace?: {
workspaceUrls?: { subdomainUrl?: string; customUrl?: string };
};
};
};
const urls = body.data?.currentWorkspace?.workspaceUrls;
if (!urls) {
return null;
}
const frontUrl = urls.customUrl ?? urls.subdomainUrl;
return frontUrl ? normalizeUrl(frontUrl) : null;
}
private async readMainPageLayoutUniversalIdentifier(
appDirectory: string,
): Promise<string | null> {
const filePath = path.join(
appDirectory,
'src',
'constants',
'universal-identifiers.ts',
);
const content = await fs.readFile(filePath, 'utf-8');
const match = content.match(
/MAIN_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER\s*=\s*'([^']+)'/,
);
return match?.[1] ?? null;
}
private async resolvePageLayoutId(
serverUrl: string,
universalIdentifier: string,
token: string,
): Promise<string | null> {
const query = `{ getPageLayouts { id universalIdentifier } }`;
const response = await fetch(`${serverUrl}/metadata`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ query }),
});
if (!response.ok) {
return null;
}
const body = (await response.json()) as {
data?: {
getPageLayouts?: { id: string; universalIdentifier: string }[];
};
};
const matching = body.data?.getPageLayouts?.find(
(layout) => layout.universalIdentifier === universalIdentifier,
);
return matching?.id ?? null;
}
private sanitizeBrowserUrl(url: string): string | null {
if (/[^\u0020-\u007E]/.test(url)) {
return null;
}
try {
const parsed = new URL(url);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return null;
}
return parsed.toString();
} catch {
return null;
}
}
private openInBrowser(url: string): void {
const safeUrl = this.sanitizeBrowserUrl(url);
if (!safeUrl) {
return;
}
const isWindows = process.platform === 'win32';
const command = isWindows
? 'rundll32'
: process.platform === 'darwin'
? 'open'
: 'xdg-open';
const args = isWindows
? ['url.dll,FileProtocolHandler', safeUrl]
: [safeUrl];
const child = spawn(command, args, {
stdio: 'ignore',
detached: !isWindows,
});
child.on('error', () => undefined);
if (!isWindows) {
child.unref();
}
}
private async syncApplication(appDirectory: string): Promise<boolean> {
this.logDetail('Running `yarn twenty dev --once`...');
return new Promise((resolve) => {
const child = spawn('yarn', ['twenty', 'dev', '--once'], {
cwd: appDirectory,
stdio: ['inherit', 'pipe', 'pipe'],
});
child.stdout?.resume();
child.stderr?.resume();
child.on('close', (code) => resolve(code === 0));
child.on('error', () => resolve(false));
});
}
private async tryExistingAuth(serverUrl: string): Promise<boolean> {
try {
const configService = new ConfigService();
const remoteNames = await configService.getRemotes();
for (const remoteName of remoteNames) {
const remoteConfig = await configService.getConfigForRemote(remoteName);
if (remoteConfig.apiUrl !== serverUrl) {
continue;
}
const token = remoteConfig.twentyCLIAccessToken ?? remoteConfig.apiKey;
if (!token) {
continue;
}
const response = await fetch(`${serverUrl}/metadata`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
query: '{ currentWorkspace { id } }',
}),
});
if (!response.ok) {
continue;
}
const body = (await response.json()) as {
data?: { currentWorkspace?: { id: string } };
errors?: unknown[];
};
if (isDefined(body.data?.currentWorkspace) && !body.errors) {
ConfigService.setActiveRemote(remoteName);
await configService.setDefaultRemote(remoteName);
return true;
}
}
return false;
} catch {
return false;
}
}
private async authenticateWithDevKey(serverUrl: string): Promise<boolean> {
private async authenticateWithDevKey(apiUrl: string): Promise<boolean> {
try {
const result = await authLogin({
apiKey: DEV_API_KEY,
apiUrl: serverUrl,
apiUrl,
remote: 'local',
});
@@ -572,7 +344,7 @@ export class CreateAppCommand {
console.log(
chalk.yellow(
' Authentication failed. Run `yarn twenty remote:add --local` manually.',
' Authentication failed. Run `yarn twenty remote add --local` manually.',
),
);
@@ -580,7 +352,7 @@ export class CreateAppCommand {
} catch {
console.log(
chalk.yellow(
' Authentication failed. Run `yarn twenty remote:add --local` manually.',
' Authentication failed. Run `yarn twenty remote add --local` manually.',
),
);
@@ -596,21 +368,21 @@ export class CreateAppCommand {
}
}
private async authenticateWithOAuth(serverUrl: string): Promise<boolean> {
private async authenticateWithOAuth(apiUrl: string): Promise<boolean> {
try {
const remoteName = this.deriveRemoteName(serverUrl);
const remoteName = this.deriveRemoteName(apiUrl);
ConfigService.setActiveRemote(remoteName);
this.logDetail('Opening browser for OAuth...');
const result = await authLoginOAuth({ apiUrl: serverUrl });
const result = await authLoginOAuth({ apiUrl });
if (result.success) {
const configService = new ConfigService();
await configService.setDefaultRemote(remoteName);
this.logDetail(`Authenticated via OAuth to ${serverUrl}`);
this.logDetail(`Authenticated via OAuth to ${apiUrl}`);
return true;
}
@@ -618,7 +390,7 @@ export class CreateAppCommand {
console.log(
chalk.yellow(
` OAuth failed: ${result.error.message}\n` +
` Run \`yarn twenty remote:add --url ${serverUrl}\` manually.`,
` Run \`yarn twenty remote add --api-url ${apiUrl}\` manually.`,
),
);
@@ -626,7 +398,7 @@ export class CreateAppCommand {
} catch {
console.log(
chalk.yellow(
` Authentication failed. Run \`yarn twenty remote:add --url ${serverUrl}\` manually.`,
` Authentication failed. Run \`yarn twenty remote add --api-url ${apiUrl}\` manually.`,
),
);
@@ -636,7 +408,7 @@ export class CreateAppCommand {
private logSuccess(
appDirectory: string,
serverUrl: string,
apiUrl: string,
authSucceeded: boolean,
): void {
const dirName = basename(appDirectory);
@@ -654,7 +426,9 @@ export class CreateAppCommand {
if (!authSucceeded) {
console.log(chalk.white(` ${stepNumber}. Connect to a Twenty instance`));
console.log(
chalk.cyan(' yarn twenty remote:add --url <your-instance-url>\n'),
chalk.cyan(
' yarn twenty remote add --api-url <your-instance-url>\n',
),
);
stepNumber++;
}
@@ -664,7 +438,7 @@ export class CreateAppCommand {
stepNumber++;
console.log(chalk.white(` ${stepNumber}. Open your twenty instance`));
console.log(chalk.cyan(` ${serverUrl}\n`));
console.log(chalk.cyan(` ${apiUrl}\n`));
console.log(
chalk.gray(
@@ -17,7 +17,6 @@ const UNIVERSAL_IDENTIFIERS_PATH = join(
'constants',
'universal-identifiers.ts',
);
const YARNRC_PATH = 'yarnrc.yml';
// Template content matching template/src/constants/universal-identifiers.ts
const TEMPLATE_UNIVERSAL_IDENTIFIERS = `export const APP_DISPLAY_NAME = 'DISPLAY-NAME-TO-BE-GENERATED';
@@ -174,27 +173,6 @@ describe('copyBaseApplicationProject', () => {
expect(publicDirectoryContents).toHaveLength(0);
});
it('should rename yarnrc.yml to .yarnrc.yml in the scaffolded project', async () => {
await fs.writeFile(
join(testAppDirectory, YARNRC_PATH),
'nodeLinker: node-modules',
);
await copyBaseApplicationProject({
appName: 'my-test-app',
appDisplayName: 'My Test App',
appDescription: 'A test application',
appDirectory: testAppDirectory,
});
expect(await fs.pathExists(join(testAppDirectory, YARNRC_PATH))).toBe(
false,
);
expect(await fs.pathExists(join(testAppDirectory, '.yarnrc.yml'))).toBe(
true,
);
});
it('should handle empty description', async () => {
await copyBaseApplicationProject({
appName: 'my-test-app',
@@ -22,7 +22,7 @@ export const copyBaseApplicationProject = async ({
onProgress?.('Copying base template');
await fs.copy(join(__dirname, './constants/template'), appDirectory);
onProgress?.('Configuring dotfiles (.gitignore, .github, .yarnrc.yml)');
onProgress?.('Configuring dotfiles (.gitignore, .github)');
await renameDotfiles({ appDirectory });
onProgress?.('Mirroring AGENTS.md to CLAUDE.md');
@@ -47,7 +47,6 @@ const renameDotfiles = async ({ appDirectory }: { appDirectory: string }) => {
const renames = [
{ from: 'gitignore', to: '.gitignore' },
{ from: 'github', to: '.github' },
{ from: 'yarnrc.yml', to: '.yarnrc.yml' },
];
for (const { from, to } of renames) {
@@ -28,6 +28,6 @@ export const getDockerInstallInstructions = (): string => {
' Then run this command again.',
'',
' Alternatively, connect to an existing Twenty instance:',
' npx create-twenty-app@latest my-twenty-app --url <your-instance-url>',
' npx create-twenty-app@latest my-twenty-app --api-url <your-instance-url>',
].join('\n');
};
@@ -0,0 +1,179 @@
import { execSync } from 'node:child_process';
import * as fs from 'fs-extra';
import { join } from 'path';
import { tmpdir } from 'node:os';
import chalk from 'chalk';
const TWENTY_REPO_OWNER = 'twentyhq';
const TWENTY_REPO_NAME = 'twenty';
const TWENTY_FALLBACK_REF = 'main';
const TWENTY_EXAMPLES_PATH = 'packages/twenty-apps/examples';
const TWENTY_EXAMPLES_URL = `https://github.com/${TWENTY_REPO_OWNER}/${TWENTY_REPO_NAME}/tree/${TWENTY_FALLBACK_REF}/${TWENTY_EXAMPLES_PATH}`;
// Fetches the latest release tag from the repo, or falls back to main
const resolveRef = async (): Promise<string> => {
const response = await fetch(
`https://api.github.com/repos/${TWENTY_REPO_OWNER}/${TWENTY_REPO_NAME}/releases/latest`,
{ headers: { Accept: 'application/vnd.github.v3+json' } },
);
if (response.ok) {
const release = (await response.json()) as { tag_name: string };
return release.tag_name;
}
return TWENTY_FALLBACK_REF;
};
// Uses the GitHub Contents API to list directories — fast and doesn't download the repo
const fetchGitHubDirectoryContents = async (
path: string,
ref: string,
): Promise<{ name: string; type: string }[] | null> => {
const apiUrl = `https://api.github.com/repos/${TWENTY_REPO_OWNER}/${TWENTY_REPO_NAME}/contents/${path}?ref=${ref}`;
const response = await fetch(apiUrl, {
headers: { Accept: 'application/vnd.github.v3+json' },
});
if (!response.ok) {
return null;
}
const data = await response.json();
if (!Array.isArray(data)) {
return null;
}
return data as { name: string; type: string }[];
};
const listAvailableExamples = async (ref: string): Promise<string[]> => {
const contents = await fetchGitHubDirectoryContents(
TWENTY_EXAMPLES_PATH,
ref,
);
if (!contents) {
return [];
}
return contents
.filter((entry) => entry.type === 'dir')
.map((entry) => entry.name);
};
const validateExampleExists = async (
exampleName: string,
ref: string,
): Promise<void> => {
const examplePath = `${TWENTY_EXAMPLES_PATH}/${exampleName}`;
const contents = await fetchGitHubDirectoryContents(examplePath, ref);
if (contents !== null) {
return;
}
const availableExamples = await listAvailableExamples(ref);
throw new Error(
`Example "${exampleName}" not found.\n\n` +
(availableExamples.length > 0
? `Available examples:\n${availableExamples.map((name) => ` - ${name}`).join('\n')}\n\n`
: '') +
`Browse all examples: ${TWENTY_EXAMPLES_URL}`,
);
};
export const downloadExample = async (
exampleName: string,
targetDirectory: string,
): Promise<void> => {
if (
exampleName.includes('/') ||
exampleName.includes('\\') ||
exampleName.includes('..')
) {
throw new Error(
`Invalid example name: "${exampleName}". Example names must be simple directory names (e.g., "hello-world").`,
);
}
const ref = await resolveRef();
const examplePath = `${TWENTY_EXAMPLES_PATH}/${exampleName}`;
console.log(chalk.gray(`Resolving examples from ref '${ref}'...`));
await validateExampleExists(exampleName, ref);
console.log(chalk.gray(`Example '${examplePath}' validated successfully.`));
const tarballUrl = `https://codeload.github.com/${TWENTY_REPO_OWNER}/${TWENTY_REPO_NAME}/tar.gz/${ref}`;
const tempDir = join(
tmpdir(),
`create-twenty-app-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
try {
await fs.ensureDir(tempDir);
console.log(chalk.gray(`Downloading tarball from ${tarballUrl}...`));
const response = await fetch(tarballUrl);
if (!response.ok) {
if (response.status === 404) {
throw new Error(
`Could not find repository: ${TWENTY_REPO_OWNER}/${TWENTY_REPO_NAME} (ref: ${ref})`,
);
}
throw new Error(
`Failed to download from GitHub: ${response.status} ${response.statusText}`,
);
}
console.log(chalk.gray('Tarball downloaded. Writing to disk...'));
const tarballPath = join(tempDir, 'archive.tar.gz');
const buffer = Buffer.from(await response.arrayBuffer());
await fs.writeFile(tarballPath, buffer);
console.log(
chalk.gray(
`Tarball saved (${(buffer.length / 1024 / 1024).toFixed(1)} MB). Extracting...`,
),
);
execSync(`tar xzf "${tarballPath}" -C "${tempDir}"`, {
stdio: 'pipe',
});
// GitHub tarballs extract to a directory named {repo}-{ref}/
const extractedEntries = await fs.readdir(tempDir);
const extractedDir = extractedEntries.find(
(entry) => entry !== 'archive.tar.gz',
);
if (!extractedDir) {
throw new Error('Failed to extract archive: no directory found');
}
const sourcePath = join(tempDir, extractedDir, examplePath);
if (!(await fs.pathExists(sourcePath))) {
throw new Error(
`Example directory not found in archive: "${examplePath}"`,
);
}
await fs.copy(sourcePath, targetDirectory);
} finally {
await fs.remove(tempDir);
}
};
@@ -93,7 +93,7 @@ yarn install
# Register your local Twenty server as a remote (interactive prompt).
# When asked for the URL use http://localhost:2021 and paste an API key
# from Settings -> Developers in the Twenty UI.
yarn twenty remote:add
yarn twenty remote add
# Build, install, and watch for changes.
yarn twenty dev
@@ -107,8 +107,8 @@ watching `src/`. Edit any file and the change is re-synced within seconds.
```bash
cd packages/twenty-apps/community/github-connector
yarn install
yarn twenty remote:add # same prompts as above
yarn twenty app:install # builds and installs once
yarn twenty remote add # same prompts as above
yarn twenty install # builds and installs once
```
## Configure authentication
@@ -5,7 +5,7 @@ This is a [Twenty](https://twenty.com) application project bootstrapped with [`c
First, authenticate to your workspace:
```bash
yarn twenty remote:add --api-url http://localhost:2020 --as local
yarn twenty remote add --api-url http://localhost:2020 --as local
```
Then, start development mode to sync your app and watch for changes:
@@ -22,18 +22,18 @@ Run `yarn twenty help` to list all available commands. Common commands:
```bash
# Remotes & Authentication
yarn twenty remote:add --api-url http://localhost:2020 --as local # Authenticate with Twenty
yarn twenty remote:status # Check auth status
yarn twenty remote:use # Set default remote
yarn twenty remote:list # List all configured remotes
yarn twenty remote:remove <name> # Remove a remote
yarn twenty remote add --api-url http://localhost:2020 --as local # Authenticate with Twenty
yarn twenty remote status # Check auth status
yarn twenty remote switch # Switch default remote
yarn twenty remote list # List all configured remotes
yarn twenty remote remove <name> # Remove a remote
# Application
yarn twenty dev # Start dev mode (watch, build, sync, and auto-generate typed client)
yarn twenty dev:add # Scaffold a new entity (object, field, function, front-component, role, view, navigation-menu-item)
yarn twenty dev:function:logs # Stream function logs
yarn twenty dev:function:exec # Execute a function with JSON payload
yarn twenty app:uninstall # Uninstall app from workspace
yarn twenty dev # Start dev mode (watch, build, sync, and auto-generate typed client)
yarn twenty add # Add a new entity (object, field, function, front-component, role, view, navigation-menu-item)
yarn twenty logs # Stream function logs
yarn twenty exec # Execute a function with JSON payload
yarn twenty uninstall # Uninstall app from workspace
```
## Integration Tests
@@ -13,7 +13,7 @@ beforeAll(async () => {
if (!apiUrl || !token) {
throw new Error(
'TWENTY_API_URL and TWENTY_API_KEY must be set.\n' +
'Start a local server: yarn twenty docker:start\n' +
'Start a local server: yarn twenty server start\n' +
'Or set them in vitest env config.',
);
}
@@ -28,6 +28,12 @@ yarn install
yarn twenty dev
```
Or use it as a template via create-twenty-app:
```bash
npx create-twenty-app@latest my-app --example postcard
```
## Learn more
- [Getting Started](https://docs.twenty.com/developers/extend/apps/getting-started)
@@ -14,7 +14,7 @@ function validateEnv(): { apiUrl: string; apiKey: string } {
if (!apiUrl || !apiKey) {
throw new Error(
'TWENTY_API_URL and TWENTY_API_KEY must be set.\n' +
'Start a local server: yarn twenty docker:start\n' +
'Start a local server: yarn twenty server start\n' +
'Or set them in vitest env config.',
);
}
@@ -80,8 +80,8 @@ export async function teardown() {
const uninstallResult = await appUninstall({ appPath: APP_PATH });
if (!uninstallResult.success) {
throw new Error(
`App uninstall failed during teardown: ${JSON.stringify(uninstallResult.error, null, 2)}`,
console.warn(
`App uninstall failed: ${uninstallResult.error?.message ?? 'Unknown error'}`,
);
}
}
@@ -1,4 +1,4 @@
import { SystemPermissionFlag, defineRole } from 'twenty-sdk/define';
import { PermissionFlag, defineRole } from 'twenty-sdk/define';
import {
CONTENT_FIELD_UNIVERSAL_IDENTIFIER,
POST_CARD_UNIVERSAL_IDENTIFIER,
@@ -36,5 +36,5 @@ export default defineRole({
canUpdateFieldValue: true,
},
],
permissionFlagUniversalIdentifiers: [SystemPermissionFlag.APPLICATIONS],
permissionFlags: [PermissionFlag.APPLICATIONS],
});
@@ -10,8 +10,8 @@
"packageManager": "yarn@4.9.2",
"scripts": {
"dev": "twenty dev",
"exec": "twenty dev:fn-exec",
"uninstall": "twenty app:uninstall",
"exec": "twenty exec",
"uninstall": "twenty uninstall",
"lint": "oxlint -c .oxlintrc.json .",
"lint:fix": "oxlint --fix -c .oxlintrc.json ."
},
@@ -1,17 +0,0 @@
import { defineIndex } from 'twenty-sdk/define';
import {
POST_CARD_UNIVERSAL_IDENTIFIER,
STATUS_FIELD_UNIVERSAL_IDENTIFIER,
} from '../objects/post-card.object';
export default defineIndex({
universalIdentifier: 'b6e9d2a1-5a4c-46ca-9d52-42c8f02d1ff0',
objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
fields: [
{
universalIdentifier: 'b6e9d2a1-5a4c-46ca-9d52-42c8f02d1ff1',
fieldUniversalIdentifier: STATUS_FIELD_UNIVERSAL_IDENTIFIER,
},
],
});
@@ -1,4 +1,4 @@
import { SystemPermissionFlag, defineRole } from 'twenty-sdk/define';
import { PermissionFlag, defineRole } from 'twenty-sdk/define';
import {
CONTENT_FIELD_UNIVERSAL_IDENTIFIER,
POST_CARD_UNIVERSAL_IDENTIFIER,
@@ -36,5 +36,5 @@ export default defineRole({
canUpdateFieldValue: false,
},
],
permissionFlagUniversalIdentifiers: [SystemPermissionFlag.APPLICATIONS],
permissionFlags: [PermissionFlag.APPLICATIONS],
});
@@ -5,7 +5,7 @@ This is a [Twenty](https://twenty.com) application project bootstrapped with [`c
First, authenticate to your workspace:
```bash
yarn twenty remote:add --api-url http://localhost:2020 --as local
yarn twenty remote add --api-url http://localhost:2020 --as local
```
Then, start development mode to sync your app and watch for changes:
@@ -22,18 +22,18 @@ Run `yarn twenty help` to list all available commands. Common commands:
```bash
# Remotes & Authentication
yarn twenty remote:add --api-url http://localhost:2020 --as local # Authenticate with Twenty
yarn twenty remote:status # Check auth status
yarn twenty remote:use # Set default remote
yarn twenty remote:list # List all configured remotes
yarn twenty remote:remove <name> # Remove a remote
yarn twenty remote add --api-url http://localhost:2020 --as local # Authenticate with Twenty
yarn twenty remote status # Check auth status
yarn twenty remote switch # Switch default remote
yarn twenty remote list # List all configured remotes
yarn twenty remote remove <name> # Remove a remote
# Application
yarn twenty dev # Start dev mode (watch, build, sync, and auto-generate typed client)
yarn twenty dev:add # Scaffold a new entity (object, field, function, front-component, role, view, navigation-menu-item)
yarn twenty dev:function:logs # Stream function logs
yarn twenty dev:function:exec # Execute a function with JSON payload
yarn twenty app:uninstall # Uninstall app from workspace
yarn twenty dev # Start dev mode (watch, build, sync, and auto-generate typed client)
yarn twenty add # Add a new entity (object, field, function, front-component, role, view, navigation-menu-item)
yarn twenty logs # Stream function logs
yarn twenty exec # Execute a function with JSON payload
yarn twenty uninstall # Uninstall app from workspace
```
## LLMs instructions
@@ -13,7 +13,7 @@ beforeAll(async () => {
if (!apiUrl || !token) {
throw new Error(
'TWENTY_API_URL and TWENTY_API_KEY must be set.\n' +
'Start a local server: yarn twenty docker:start\n' +
'Start a local server: yarn twenty server start\n' +
'Or set them in vitest env config.',
);
}
@@ -1,18 +0,0 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript"],
"categories": {
"correctness": "off"
},
"ignorePatterns": ["node_modules", "dist"],
"rules": {
"no-unused-vars": "off",
"typescript/no-unused-vars": [
"warn",
{
"argsIgnorePattern": "^_"
}
],
"typescript/no-explicit-any": "off"
}
}
@@ -1,267 +0,0 @@
# Fireflies for Twenty
Sync [Fireflies](https://fireflies.ai) call transcripts and AI summaries onto
the matching `CalendarEvent` in your Twenty CRM — searchable, in context, and
ready for AI agents and workflows to act on. Plus on-demand workflow tools to
sync, list, and search Fireflies calls from the AI chat or workflow builder.
## What this app does
1. Fireflies records and transcribes your Zoom / Meet / Teams / phone call.
2. When the transcript is ready, Fireflies fires a `meeting.transcribed`
webhook; once Fireflies finishes its AI summary, it fires a separate
`meeting.summarized` webhook.
3. For each event, this app fetches the relevant data via the Fireflies
GraphQL API.
4. It finds the matching `CalendarEvent` in Twenty and writes the content
into either the **Transcript** or **Summary** field on that event.
Alongside the webhook, three [Workflow tools](#workflow-tools) let you
trigger Fireflies actions from the AI chat or as steps inside a workflow,
without waiting for Fireflies to push.
### How a transcript is matched to a CalendarEvent
The matcher tries two provider-ID strategies in priority order and stops at
the first hit:
1. **Provider-native event ID** — Fireflies' `calendar_id` / `cal_id` is
matched against `CalendarChannelEventAssociation.eventExternalId`. Covers
events synced into Twenty from Google Calendar (including individual
instances of recurring events, where Fireflies returns the per-instance
id with timestamp on `cal_id`).
2. **iCalUID** — Fireflies' `calendar_id` is matched against
`CalendarEvent.iCalUid`. Covers events synced from Outlook / CalDAV,
where Fireflies returns the RFC 5545 iCalUID directly.
Both identifiers are populated by Twenty's calendar drivers on every synced
CalendarEvent, so any meeting that's been pulled in via Google / Outlook /
CalDAV calendar sync will match exactly. The matcher does **not** fall back
to fuzzy URL matching — if the transcript can't be tied to a synced calendar
event, the call is treated as an orphan and skipped (see
[Limitations](#limitations) below). This avoids silently writing transcripts
to the wrong event.
## What gets added to your Twenty workspace
Two new fields on the standard **CalendarEvent** object:
- **Transcript** — rich-text field, speaker-attributed (e.g. *"**Sarah:**
Hi there"*, then *"**John:** Doing well, thanks."*).
- **Summary** — rich-text field with the Fireflies AI summary: a bullet-list
overview, action items grouped by speaker, topics discussed, and keywords.
Plus three workflow tools — see [Workflow tools](#workflow-tools) below.
## Workflow tools
Once the API key is configured, three tools become available in the workflow
builder and the AI chat — covering the cases the webhook can't:
- **Sync Fireflies Call** — *"sync the Fireflies call `01HXYZ...` onto its
CalendarEvent now"*. As a workflow step: provide `transcriptId`. Runs the
same pipeline as the webhook (fetch transcript + AI summary, find matching
CalendarEvent, write Transcript + Summary fields) on demand. Use cases:
**backfilling** historical calls that happened before the app was
installed; **recovering** from a missed webhook (e.g. the calendar event
hadn't synced yet when Fireflies pushed); or triggering a sync from a
workflow instead of waiting for Fireflies. Output includes
`calendarEventId`, `updatedFields`, and a per-field outcome breakdown so
partial successes are visible.
- **List Fireflies Calls By Participant** — *"show me my last 5 calls with
john@acme.com"*. As a workflow step: provide `participantEmail` (and
optional `limit`, max 50). Returns recent Fireflies calls — newest first —
where that email was an attendee, with title, date, duration, host, and
transcript URL. The natural first step in workflows triggered on
`Person.created`*"find what we've talked about with this contact"*.
- **Search Fireflies Calls** — *"find any call where we discussed pricing"*.
As a workflow step: provide `keyword` (and optional `limit`, max 50).
Matches the keyword against both meeting titles and the words actually
spoken in meetings. Returns the same call-summary shape as the
participant tool. Best for AI-chat-driven research.
The list-by-participant and search tools return the same compact call shape:
`id`, `title`, `date`, `durationMinutes`, `participants`, `hostEmail`,
`transcriptUrl`, `meetingLink`. To then sync any of those calls onto its
CalendarEvent, pass the `id` from a list result into **Sync Fireflies Call**.
## Installing
1. Open **Settings → Applications** in your Twenty workspace.
2. Find **Fireflies** in the available apps and click **Install**.
3. Follow [Self-hosting setup](#self-hosting-setup-admin-only) below to wire
up the API key and webhook (admin-only, one-time).
> **Heads up:** if you see *"Fireflies is not configured"* on the first
> webhook, your Twenty admin needs to follow the
> [Self-hosting setup](#self-hosting-setup-admin-only) section.
## Limitations
What this connector intentionally does **not** support in v1:
- **Calls without a matching CalendarEvent (orphan calls).** Ad-hoc calls
that were never on anyone's synced calendar are skipped. The webhook logs
the skip reason; the transcript still lives in Fireflies. Synthetic event
creation for orphans is planned for v2.
- **Fireflies sentiment, speaker analytics, transcript chapters.** Only
the raw transcript and the AI summary (overview, action items, topics,
keywords) are synced today.
- **Per-user Fireflies accounts.** All transcripts come through one
workspace-shared API key (set by the admin). Per-user OAuth-style
connections require extending Twenty's connection provider system and are
planned once we have evidence that workspace-shared is too coarse.
- **Editing transcripts or summaries in Twenty.** The fields are writable
but the next Fireflies sync overwrites any manual edits — treat them as
read-only.
## Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Webhook returns `Fireflies is not configured` | `FIREFLIES_API_KEY` not set | Admin: paste the API key in **Settings → Applications → Fireflies → Settings** |
| Webhook returns `Invalid webhook signature` | `FIREFLIES_WEBHOOK_SECRET` mismatch between Fireflies and Twenty | Re-copy the signing secret from the Fireflies webhook configuration and paste it into the Twenty app settings |
| Webhook returns `skipped: No CalendarEvent matched the transcript by external ID or iCalUid` | The meeting was never on a synced calendar in Twenty, or the workspace has no Google/Outlook/CalDAV calendar connection set up | Connect the relevant calendar provider in **Settings → Accounts** so the calendar event lands in Twenty with `eventExternalId` and `iCalUid` populated. Manually-created CalendarEvents are intentionally not matched in v1 |
| Transcript appears empty | Fireflies returned no sentences (call too short, audio failed) | Check the call in the Fireflies dashboard; nothing this app can do |
| Summary appears empty | Fireflies hasn't summarized the call yet, or the call was too short to summarize | Fireflies sends `meeting.summarized` separately from `meeting.transcribed` (typically a minute or two later); ensure that event is subscribed to in your Webhooks V2 config |
| Summary is populated but Transcript isn't (or vice versa) | Only one of the two Fireflies events is subscribed to | Subscribe to both `meeting.transcribed` and `meeting.summarized` in your Fireflies Webhooks V2 configuration |
| Fireflies API errors with `401` | API key wrong, rotated, or revoked | Generate a new key in Fireflies → Integrations → Fireflies API → Regenerate, then update `FIREFLIES_API_KEY` |
| **Sync Fireflies Call** reports `No fields were updated` | The Fireflies call's `calendar_id` / `cal_id` doesn't match any CalendarEvent's `iCalUid` or `eventExternalId` (orphan call), or the per-field outcomes show transient Fireflies API failures | Check the `fieldOutcomes` array in the result — `skipped` means orphan call (same limitation as the webhook); `error` means Fireflies-side failure (retry, or inspect the error message) |
| **List / Search** tools return `count: 0` for a contact you've definitely talked to | Email mismatch — Fireflies stores the address as the participant joined the meeting with, which may differ from the contact's primary address in Twenty (aliases, plus-addressing, work vs. personal) | Try the contact's other known email addresses; cross-check the `participants` list on a known matching call |
---
## Self-hosting setup (admin-only)
This section is for Twenty server admins. If you're on Twenty Cloud, skip
this — the credentials may already be configured.
### 1. Generate a Fireflies API key
1. Visit https://app.fireflies.ai and sign in.
2. Go to **Integrations → Fireflies API**.
3. Click **Generate API key** and copy the value (it's only shown once).
### 2. Configure a Webhooks V2 endpoint in Fireflies
This integration targets [Fireflies Webhooks V2](https://docs.fireflies.ai/graphql-api/webhooks-v2)
(snake_case payload, granular event subscriptions). The legacy V1 webhook
format (`meetingId` / `eventType: "Transcription completed"`) is **not**
supported.
1. Open the Webhooks V2 page: https://app.fireflies.ai/integrations/api/webhook
2. Set the **Webhook URL** to your Twenty deployment's webhook endpoint:
`https://<your-twenty-domain>/webhook/fireflies`. Twenty resolves the
target workspace from the request's `Host` header, so the URL must match
the workspace's public domain — `localhost` is not valid in the
Fireflies UI. For local development, expose your dev server with a
tunnel like `ngrok http 3000` and paste the HTTPS forwarding URL here,
or skip the Fireflies UI entirely and POST a signed payload directly to
your local endpoint (see [Local webhook testing](#local-webhook-testing)
in the developer section below).
3. Set a **Signing Secret** (a long random string — generate one with
`openssl rand -hex 32`). Save it; you'll paste it into Twenty next.
4. Under **Events**, subscribe to **both**:
- **`meeting.transcribed`** — fires when the transcript is ready and
writes it to the **Transcript** field.
- **`meeting.summarized`** — fires once Fireflies finishes its AI summary
and writes it to the **Summary** field.
Subscribing to only one is fine if you don't want the other field
populated; the app dispatches per event.
5. **Save** the configuration.
### 3. Wire the credentials into Twenty
1. In Twenty: **Settings → Applications → Fireflies → Settings tab**.
2. Paste the Fireflies API key into the `FIREFLIES_API_KEY` row.
3. Paste the signing secret into the `FIREFLIES_WEBHOOK_SECRET` row.
After saving, the next time Fireflies finishes processing a recording, the
transcript will land on the matching CalendarEvent within a few seconds;
the summary follows once Fireflies finishes the AI summarization step
(typically a minute or two later — Fireflies sends two separate webhooks).
---
## Why `transcript` / `summary` fields on `CalendarEvent` instead of a new object?
Storing the transcript and AI summary as rich-text fields directly on the
existing `CalendarEvent`:
- Keeps everything about a meeting in one place (no joins)
- Avoids inventing a new object that other call-recording apps would each
need to coordinate on
- Works today without lookup fields
If later integrations (Gong, Otter, Zoom AI, etc.) make one pair of fields
too restrictive — for example, needing to distinguish *which* tool produced
the transcript — we'll promote the fields to a platform-level concept rather
than keep extending this app.
---
## Developers only
If you're working on this app rather than installing the published version:
```bash
cd packages/twenty-apps/internal/twenty-fireflies
# Day-to-day development (publish + install + watch in one):
yarn twenty dev
# Run unit tests:
yarn test
# Lint:
yarn lint
```
`twenty dev` is recommended for iteration — it publishes to your local Twenty
server, installs the app, and watches for changes in one command.
The Fireflies GraphQL API is called directly via `fetch` — no `fireflies` SDK
dependency. See `src/logic-functions/utils/fireflies-api-request.ts` for the
auth + error-handling wrapper that all queries go through.
### Local webhook testing
Fireflies' Webhooks V2 UI only accepts a publicly reachable HTTPS URL, so
pointing it at `http://localhost:*` directly is not possible. Two paths:
**End-to-end via tunnel.** Run a tunnel that fronts your local server with
a public HTTPS URL (`ngrok http 3000`, `cloudflared tunnel`, etc.), paste
the HTTPS forwarding URL into the Fireflies webhook UI as the **Webhook
URL**, and exercise the integration by ending a real Fireflies meeting.
**Backend-only via signed `curl`.** Skip the Fireflies UI entirely and POST
a signed payload straight to the local endpoint. The signature must be
HMAC-SHA256 over the **raw** request body, keyed by your
`FIREFLIES_WEBHOOK_SECRET`, prefixed with `sha256=`:
```bash
export FIREFLIES_WEBHOOK_SECRET='<the secret you set in Twenty app settings>'
BODY='{"event":"meeting.transcribed","meeting_id":"<a-real-fireflies-transcript-id>"}'
SIG=$(printf '%s' "$BODY" \
| openssl dgst -sha256 -hmac "$FIREFLIES_WEBHOOK_SECRET" \
| awk '{print $NF}')
curl -X POST http://localhost:3000/webhook/fireflies \
-H 'Content-Type: application/json' \
-H "x-hub-signature: sha256=$SIG" \
--data-binary "$BODY"
```
`--data-binary` (not `--data`) is important: it preserves the bytes
verbatim so the HMAC the server computes matches the one `openssl`
computed above. Twenty resolves the workspace from the `Host` header, so
the default dev workspace (mapped to `localhost:3000` in a standard
`yarn start` setup) receives the request.
To match a real `CalendarEvent`, the transcript ID you pass must belong to
a Fireflies call whose `calendar_id` / `cal_id` matches an existing
`CalendarEvent.iCalUid` or `CalendarChannelEventAssociation.eventExternalId`
in your local Twenty workspace. The easiest local seed is to manually
insert a row with one of those identifiers and use a Fireflies transcript
whose calendar fields point at it.
@@ -1,32 +0,0 @@
{
"name": "twenty-fireflies",
"version": "0.1.0",
"description": "Fireflies call-transcript connector for Twenty",
"license": "MIT",
"engines": {
"node": "^24.5.0",
"npm": "please-use-yarn",
"yarn": ">=4.0.2"
},
"keywords": [
"twenty-app"
],
"packageManager": "yarn@4.9.2",
"scripts": {
"twenty": "twenty",
"lint": "oxlint -c .oxlintrc.json .",
"lint:fix": "oxlint --fix -c .oxlintrc.json .",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"twenty-client-sdk": "2.4.0",
"twenty-sdk": "2.4.0"
},
"devDependencies": {
"@types/node": "^24.7.2",
"oxlint": "^0.16.0",
"typescript": "^5.9.3",
"vitest": "^3.1.1"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 303 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

@@ -1,44 +0,0 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="64"
height="64"
viewBox="0 0 64 64"
fill="none"
role="img"
>
<title>Fireflies</title>
<defs>
<linearGradient id="fireflies-tl" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#8E2A8B" />
<stop offset="100%" stop-color="#D02D7E" />
</linearGradient>
<linearGradient id="fireflies-tr" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#C2287D" />
<stop offset="100%" stop-color="#E83C8E" />
</linearGradient>
<linearGradient id="fireflies-mid" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#B0297B" />
<stop offset="100%" stop-color="#D9357F" />
</linearGradient>
<linearGradient id="fireflies-bl" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#7C1F6E" />
<stop offset="100%" stop-color="#C8327F" />
</linearGradient>
</defs>
<path
fill="url(#fireflies-tl)"
d="M4 12a8 8 0 0 1 8-8h14v22H4V12z"
/>
<path
fill="url(#fireflies-tr)"
d="M30 4h22a8 8 0 0 1 8 8v14H30V4z"
/>
<path
fill="url(#fireflies-mid)"
d="M30 30h18v18H30V30z"
/>
<path
fill="url(#fireflies-bl)"
d="M4 30h22v22a8 8 0 0 1-8 8H4V30z"
/>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

@@ -1,43 +0,0 @@
import { defineApplication } from 'twenty-sdk/define';
import { ABOUT_DESCRIPTION } from 'src/constants/ABOUT_DESCRIPTION.md';
import {
APPLICATION_UNIVERSAL_IDENTIFIER,
FIREFLIES_API_KEY_VARIABLE_UNIVERSAL_IDENTIFIER,
FIREFLIES_WEBHOOK_SECRET_VARIABLE_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineApplication({
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
displayName: 'Twenty Fireflies',
description:
'Sync Fireflies call transcripts and AI summaries onto matching CalendarEvent records in Twenty, and trigger sync / list / search of Fireflies calls from workflows and the AI chat.',
logoUrl: 'public/twenty-fireflies.svg',
author: 'Twenty',
category: 'Productivity',
aboutDescription: ABOUT_DESCRIPTION,
screenshots: [
'public/gallery/transcript-on-calendar-event.png',
'public/gallery/summary-on-calendar-event.png',
'public/gallery/workflow-builder-actions.png',
'public/gallery/app-settings.png',
],
websiteUrl: 'https://docs.twenty.com/developers/extend/apps/getting-started',
termsUrl: 'https://www.twenty.com/terms',
emailSupport: 'contact@twenty.com',
issueReportUrl: 'https://github.com/twentyhq/twenty/issues',
applicationVariables: {
FIREFLIES_API_KEY: {
universalIdentifier: FIREFLIES_API_KEY_VARIABLE_UNIVERSAL_IDENTIFIER,
description:
'API key from Fireflies (Integrations → Fireflies API → Generate). Used as a Bearer token against https://api.fireflies.ai/graphql to fetch full transcript content after a webhook fires.',
isSecret: true,
},
FIREFLIES_WEBHOOK_SECRET: {
universalIdentifier: FIREFLIES_WEBHOOK_SECRET_VARIABLE_UNIVERSAL_IDENTIFIER,
description:
'Signing secret for verifying Fireflies Webhooks V2 payloads (sent in the X-Hub-Signature header as sha256=<hex-hmac-sha256-of-body>). Configure the same value on the Fireflies V2 webhook setup page.',
isSecret: true,
},
},
});
@@ -1,64 +0,0 @@
export const ABOUT_DESCRIPTION = `Bring your Fireflies meeting recordings into Twenty. When Fireflies finishes processing a call, the transcript and AI summary land on the matching CalendarEvent automatically — no copy-pasting, no extra tabs.
## What gets added to your workspace
Two new fields appear on the standard **CalendarEvent** object:
- **Transcript** — speaker-attributed rich text, e.g. *"**Sarah:** Hi there"* followed by *"**John:** Doing well, thanks"*.
- **Summary** — Fireflies' AI-generated overview of the meeting (key points, action items, decisions).
Both update in real time through a Fireflies Webhooks V2 subscription.
## How it syncs
The connector subscribes to two Fireflies V2 events:
- \`meeting.transcribed\` writes the **Transcript** field.
- \`meeting.summarized\` writes the **Summary** field.
Each webhook delivery is HMAC-SHA256 verified against your signing secret before anything touches your data.
## How calls are matched to CalendarEvents
The matcher uses provider-native identifiers — never fuzzy URL matching — so transcripts always land on the right event:
1. **Provider event ID** — Fireflies' \`calendar_id\` / \`calendar_event_uid\` against \`CalendarChannelEventAssociation.eventExternalId\`. Covers events synced from Google Calendar, including individual instances of recurring meetings.
2. **iCalUID** — Fireflies' \`calendar_id\` against \`CalendarEvent.iCalUid\`. Covers events synced from Outlook / CalDAV.
Both identifiers are populated automatically when calendars are synced into Twenty. If a recording can't be matched (orphan recording, no calendar sync configured), the webhook reports a clear skip reason and writes nothing.
## Tools for workflows and the AI chat
Beyond the automatic sync, three Fireflies tools become available in **workflows** and the **AI chat**:
- **Sync Fireflies Call** — Pull a single Fireflies call onto its CalendarEvent on demand. Useful for backfilling history or recovering from a missed webhook. Same matching rules as the webhook.
- **Search Fireflies Calls** — Keyword search across **both** meeting titles and the words spoken during meetings. Ask the AI chat *"find any call where we discussed pricing"* and it returns matching calls with titles, dates, participants, and transcript links.
- **List Fireflies Calls By Participant** — List every call a given email address attended. Great as the first step of a workflow triggered when a Person record is created, or to answer *"what calls have we had with this contact?"* from the AI chat.
## Installing
1. Open **Settings → Applications** in your Twenty workspace.
2. Find **Twenty Fireflies** in the available apps and click **Install**.
Then your admin completes the one-time wiring (see below).
## One-time setup (admin)
1. Generate an API key at [Fireflies → Integrations → Fireflies API](https://app.fireflies.ai/settings/developer-settings) and paste it into the **FIREFLIES_API_KEY** application variable.
2. Generate a long random string (\`openssl rand -hex 32\`). Paste it into the **FIREFLIES_WEBHOOK_SECRET** application variable.
3. Configure a Webhooks V2 endpoint at [Fireflies → Integrations → Webhooks V2](https://app.fireflies.ai/integrations/api/webhook):
- **Webhook URL**: \`https://<your-twenty-domain>/webhook/fireflies\`
- **Signing Secret**: the same value as \`FIREFLIES_WEBHOOK_SECRET\`
- **Events**: subscribe to \`meeting.transcribed\` (required) and \`meeting.summarized\` (optional, for AI summaries)
That's it — the next call Fireflies processes will start syncing automatically.
## Limitations
What this connector intentionally does **not** support in v1:
- **Orphan calls** (recordings with no matching CalendarEvent in Twenty) are skipped — fuzzy URL matching is avoided so transcripts never land on the wrong event.
- **Per-user Fireflies accounts** — all sync goes through one workspace-shared API key set by the admin.
- **Editing transcripts in Twenty** — the field is writable in principle, but future Fireflies syncs will overwrite manual edits.
- **Speaker analytics, sentiment, action items as structured fields** — only raw transcript and summary text are synced; structured insights stay in the Fireflies dashboard.
`;
@@ -1,29 +0,0 @@
export const APPLICATION_UNIVERSAL_IDENTIFIER =
'97d24431-ebc7-4156-9705-b6900e73edc8';
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER =
'ed0a61a4-7638-4fd8-a2c1-982b50aca4ff';
export const FIREFLIES_API_KEY_VARIABLE_UNIVERSAL_IDENTIFIER =
'876ebb9f-0fb1-48b3-bd6f-f4138f999ba7';
export const FIREFLIES_WEBHOOK_SECRET_VARIABLE_UNIVERSAL_IDENTIFIER =
'a1ef34d9-f0e7-483e-9909-fce2757bdd23';
export const TRANSCRIPT_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER =
'e444c83b-0d28-41e2-8c3f-4ede9eb88a75';
export const SUMMARY_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER =
'c5f8a3e2-7b91-4d2e-b6a4-9f3e1d5c8a02';
export const FIREFLIES_WEBHOOK_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'e0c2d033-0ef4-4b92-9e64-2d8f2e4c497e';
export const FIREFLIES_SYNC_CALL_UNIVERSAL_IDENTIFIER =
'c715a476-929e-4e5b-b692-6350fc0adb73';
export const FIREFLIES_LIST_CALLS_BY_PARTICIPANT_UNIVERSAL_IDENTIFIER =
'3148f5c7-8bcb-48d3-a7b9-aa811608e256';
export const FIREFLIES_SEARCH_CALLS_UNIVERSAL_IDENTIFIER =
'cb2dd01d-8dca-4222-acce-1d1dbdef9146';
@@ -1,53 +0,0 @@
import { defineLogicFunction } from 'twenty-sdk/define';
import { jsonSchemaToInputSchema } from 'twenty-shared/logic-function';
import { FIREFLIES_LIST_CALLS_BY_PARTICIPANT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { firefliesListCallsByParticipantHandler } from 'src/logic-functions/handlers/fireflies-list-calls-by-participant-handler';
import { firefliesListCallsByParticipantInputSchema } from 'src/logic-functions/schemas/fireflies-list-calls-by-participant-input.schema';
const callSummaryProperties = {
id: { type: 'string' },
title: { type: 'string' },
date: { type: 'string' },
durationMinutes: { type: 'number' },
participants: { type: 'array', items: { type: 'string' } },
hostEmail: { type: 'string' },
transcriptUrl: { type: 'string' },
meetingLink: { type: 'string' },
} as const;
export default defineLogicFunction({
universalIdentifier: FIREFLIES_LIST_CALLS_BY_PARTICIPANT_UNIVERSAL_IDENTIFIER,
name: 'fireflies-list-calls-by-participant',
description:
'List Fireflies calls that include a given participant email. Returns each call\'s ID, title, date, duration, participants, host, Fireflies transcript URL, and original meeting link. Use this to answer "what calls have we had with this contact?" — for example as the first step of a workflow triggered when a Person record is created.',
timeoutSeconds: 30,
toolTriggerSettings: {
inputSchema: firefliesListCallsByParticipantInputSchema,
},
workflowActionTriggerSettings: {
label: 'List Fireflies Calls By Participant',
inputSchema: jsonSchemaToInputSchema(
firefliesListCallsByParticipantInputSchema,
),
outputSchema: [
{
type: 'object',
properties: {
success: { type: 'boolean' },
message: { type: 'string' },
error: { type: 'string' },
count: { type: 'number' },
calls: {
type: 'array',
items: {
type: 'object',
properties: callSummaryProperties,
},
},
},
},
],
},
handler: firefliesListCallsByParticipantHandler,
});
@@ -1,51 +0,0 @@
import { defineLogicFunction } from 'twenty-sdk/define';
import { jsonSchemaToInputSchema } from 'twenty-shared/logic-function';
import { FIREFLIES_SEARCH_CALLS_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { firefliesSearchCallsHandler } from 'src/logic-functions/handlers/fireflies-search-calls-handler';
import { firefliesSearchCallsInputSchema } from 'src/logic-functions/schemas/fireflies-search-calls-input.schema';
const callSummaryProperties = {
id: { type: 'string' },
title: { type: 'string' },
date: { type: 'string' },
durationMinutes: { type: 'number' },
participants: { type: 'array', items: { type: 'string' } },
hostEmail: { type: 'string' },
transcriptUrl: { type: 'string' },
meetingLink: { type: 'string' },
} as const;
export default defineLogicFunction({
universalIdentifier: FIREFLIES_SEARCH_CALLS_UNIVERSAL_IDENTIFIER,
name: 'fireflies-search-calls',
description:
'Search Fireflies calls by keyword. Matches the keyword against both meeting titles and the words spoken during meetings (full transcript content). Returns each match\'s ID, title, date, duration, participants, host, Fireflies transcript URL, and meeting link. Use this for AI-chat questions like "find any call where we discussed pricing".',
timeoutSeconds: 30,
toolTriggerSettings: {
inputSchema: firefliesSearchCallsInputSchema,
},
workflowActionTriggerSettings: {
label: 'Search Fireflies Calls',
inputSchema: jsonSchemaToInputSchema(firefliesSearchCallsInputSchema),
outputSchema: [
{
type: 'object',
properties: {
success: { type: 'boolean' },
message: { type: 'string' },
error: { type: 'string' },
count: { type: 'number' },
calls: {
type: 'array',
items: {
type: 'object',
properties: callSummaryProperties,
},
},
},
},
],
},
handler: firefliesSearchCallsHandler,
});
@@ -1,47 +0,0 @@
import { defineLogicFunction } from 'twenty-sdk/define';
import { jsonSchemaToInputSchema } from 'twenty-shared/logic-function';
import { FIREFLIES_SYNC_CALL_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { firefliesSyncCallHandler } from 'src/logic-functions/handlers/fireflies-sync-call-handler';
import { firefliesSyncCallInputSchema } from 'src/logic-functions/schemas/fireflies-sync-call-input.schema';
export default defineLogicFunction({
universalIdentifier: FIREFLIES_SYNC_CALL_UNIVERSAL_IDENTIFIER,
name: 'fireflies-sync-call',
description:
'Sync a single Fireflies call onto its matching CalendarEvent on demand: fetches both transcript and AI summary from Fireflies and writes them to the Transcript and Summary fields. Same matching rules as the webhook (Fireflies calendar_id / cal_id ↔ Twenty eventExternalId or iCalUid). Useful for backfilling history, recovering from a missed webhook, or syncing on a workflow trigger instead of waiting for Fireflies to push.',
timeoutSeconds: 60,
toolTriggerSettings: {
inputSchema: firefliesSyncCallInputSchema,
},
workflowActionTriggerSettings: {
label: 'Sync Fireflies Call',
inputSchema: jsonSchemaToInputSchema(firefliesSyncCallInputSchema),
outputSchema: [
{
type: 'object',
properties: {
success: { type: 'boolean' },
message: { type: 'string' },
error: { type: 'string' },
transcriptId: { type: 'string' },
calendarEventId: { type: 'string' },
updatedFields: { type: 'array', items: { type: 'string' } },
fieldOutcomes: {
type: 'array',
items: {
type: 'object',
properties: {
field: { type: 'string' },
status: { type: 'string' },
reason: { type: 'string' },
error: { type: 'string' },
},
},
},
},
},
],
},
handler: firefliesSyncCallHandler,
});
@@ -1,68 +0,0 @@
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import { isDefined } from 'twenty-shared/utils';
import { FIREFLIES_WEBHOOK_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { firefliesWebhookHandler } from 'src/logic-functions/handlers/fireflies-webhook-handler';
import {
type FirefliesWebhookPayload,
type FirefliesWebhookResult,
} from 'src/logic-functions/types/fireflies-webhook-payload.type';
import { getFirefliesWebhookSecret } from 'src/logic-functions/utils/get-fireflies-webhook-secret';
import { verifyFirefliesWebhookSignature } from 'src/logic-functions/utils/verify-fireflies-webhook-signature';
const firefliesWebhookRouteHandler = async (
routePayload: RoutePayload<FirefliesWebhookPayload>,
): Promise<FirefliesWebhookResult> => {
const secretResult = getFirefliesWebhookSecret();
if (!secretResult.success) {
return { error: secretResult.error };
}
const { rawBody } = routePayload;
if (!isDefined(rawBody)) {
return {
error:
'Invalid webhook signature: raw request body was not forwarded by the server, cannot verify HMAC',
};
}
const signatureHeader = routePayload.headers['x-hub-signature'];
const signatureCheck = verifyFirefliesWebhookSignature({
rawBody,
signatureHeader,
secret: secretResult.secret,
});
if (!signatureCheck.valid) {
return { error: `Invalid webhook signature: ${signatureCheck.error}` };
}
const body = routePayload.body;
if (!isDefined(body)) {
return { error: 'Webhook payload was empty' };
}
return firefliesWebhookHandler({
meetingId: body.meeting_id,
eventType: body.event,
});
};
export default defineLogicFunction({
universalIdentifier: FIREFLIES_WEBHOOK_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
name: 'fireflies-webhook',
description:
'Receives Fireflies webhook events when a transcript is ready, then writes the transcript onto the matching CalendarEvent.',
timeoutSeconds: 60,
handler: firefliesWebhookRouteHandler,
httpRouteTriggerSettings: {
path: '/webhook/fireflies',
httpMethod: 'POST',
isAuthRequired: false,
forwardedRequestHeaders: ['x-hub-signature'],
},
});
@@ -1,62 +0,0 @@
import { isNonEmptyString } from '@sniptt/guards';
import { type FirefliesCallListResult } from 'src/logic-functions/types/fireflies-call-list-result.type';
import { type FirefliesListCallsByParticipantInput } from 'src/logic-functions/types/fireflies-list-calls-by-participant-input.type';
import { getFirefliesApiKey } from 'src/logic-functions/utils/get-fireflies-api-key';
import { listFirefliesTranscripts } from 'src/logic-functions/utils/list-fireflies-transcripts';
const DEFAULT_LIMIT = 20;
const MAX_LIMIT = 50;
const clampLimit = (limit: number | undefined): number => {
if (!Number.isFinite(limit) || limit === undefined) {
return DEFAULT_LIMIT;
}
return Math.max(1, Math.min(MAX_LIMIT, Math.trunc(limit)));
};
export const firefliesListCallsByParticipantHandler = async (
parameters: FirefliesListCallsByParticipantInput,
): Promise<FirefliesCallListResult> => {
const participantEmail = parameters.participantEmail?.trim();
if (!isNonEmptyString(participantEmail)) {
return {
success: false,
message: 'Failed to list Fireflies calls',
error: '`participantEmail` is required.',
};
}
const apiKeyResult = getFirefliesApiKey();
if (!apiKeyResult.success) {
return {
success: false,
message: 'Fireflies is not configured',
error: apiKeyResult.error,
};
}
const result = await listFirefliesTranscripts({
apiKey: apiKeyResult.apiKey,
participants: [participantEmail],
limit: clampLimit(parameters.limit),
});
if (!result.ok) {
return {
success: false,
message: 'Failed to list Fireflies calls',
error: result.errorMessage,
};
}
return {
success: true,
message: `Found ${result.data.length} Fireflies call(s) with ${participantEmail}.`,
calls: result.data,
count: result.data.length,
};
};
@@ -1,63 +0,0 @@
import { isNonEmptyString } from '@sniptt/guards';
import { type FirefliesCallListResult } from 'src/logic-functions/types/fireflies-call-list-result.type';
import { type FirefliesSearchCallsInput } from 'src/logic-functions/types/fireflies-search-calls-input.type';
import { getFirefliesApiKey } from 'src/logic-functions/utils/get-fireflies-api-key';
import { listFirefliesTranscripts } from 'src/logic-functions/utils/list-fireflies-transcripts';
const DEFAULT_LIMIT = 20;
const MAX_LIMIT = 50;
const clampLimit = (limit: number | undefined): number => {
if (!Number.isFinite(limit) || limit === undefined) {
return DEFAULT_LIMIT;
}
return Math.max(1, Math.min(MAX_LIMIT, Math.trunc(limit)));
};
export const firefliesSearchCallsHandler = async (
parameters: FirefliesSearchCallsInput,
): Promise<FirefliesCallListResult> => {
const keyword = parameters.keyword?.trim();
if (!isNonEmptyString(keyword)) {
return {
success: false,
message: 'Failed to search Fireflies calls',
error: '`keyword` is required.',
};
}
const apiKeyResult = getFirefliesApiKey();
if (!apiKeyResult.success) {
return {
success: false,
message: 'Fireflies is not configured',
error: apiKeyResult.error,
};
}
const result = await listFirefliesTranscripts({
apiKey: apiKeyResult.apiKey,
keyword,
keywordScope: 'all',
limit: clampLimit(parameters.limit),
});
if (!result.ok) {
return {
success: false,
message: 'Failed to search Fireflies calls',
error: result.errorMessage,
};
}
return {
success: true,
message: `Found ${result.data.length} Fireflies call(s) matching "${keyword}".`,
calls: result.data,
count: result.data.length,
};
};
@@ -1,125 +0,0 @@
import { isNonEmptyString } from '@sniptt/guards';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { type FirefliesSyncCallInput } from 'src/logic-functions/types/fireflies-sync-call-input.type';
import {
type FirefliesSyncCallFieldOutcome,
type FirefliesSyncCallResult,
} from 'src/logic-functions/types/fireflies-sync-call-result.type';
import { getFirefliesApiKey } from 'src/logic-functions/utils/get-fireflies-api-key';
import {
type FirefliesSyncableField,
syncFirefliesFieldToCalendarEvent,
} from 'src/logic-functions/utils/sync-fireflies-field-to-calendar-event';
const ALL_FIELDS: FirefliesSyncableField[] = ['transcript', 'summary'];
const buildFailure = (
message: string,
error: string,
transcriptId?: string,
): FirefliesSyncCallResult => ({
success: false,
message,
error,
transcriptId,
});
export const firefliesSyncCallHandler = async (
parameters: FirefliesSyncCallInput,
): Promise<FirefliesSyncCallResult> => {
const transcriptId = parameters.transcriptId?.trim();
if (!isNonEmptyString(transcriptId)) {
return buildFailure(
'Failed to sync Fireflies call',
'`transcriptId` is required.',
);
}
const apiKeyResult = getFirefliesApiKey();
if (!apiKeyResult.success) {
return buildFailure(
'Fireflies is not configured',
apiKeyResult.error,
transcriptId,
);
}
const client = new CoreApiClient();
const results = await Promise.all(
ALL_FIELDS.map((field) =>
syncFirefliesFieldToCalendarEvent({
apiKey: apiKeyResult.apiKey,
client,
transcriptId,
field,
}),
),
);
const fieldOutcomes: FirefliesSyncCallFieldOutcome[] = results.map(
(result) => {
if (result.status === 'skipped') {
return {
field: result.field,
status: 'skipped',
reason: result.reason,
};
}
if (result.status === 'error') {
return { field: result.field, status: 'error', error: result.error };
}
return { field: result.field, status: 'updated' };
},
);
const updatedFields = fieldOutcomes
.filter((outcome) => outcome.status === 'updated')
.map((outcome) => outcome.field);
const calendarEventId = results.find(
(result) => result.status === 'updated',
)?.calendarEventId;
if (updatedFields.length === 0) {
const skipReasons = fieldOutcomes
.filter((outcome) => outcome.status === 'skipped')
.map((outcome) => `${outcome.field}: ${outcome.reason}`);
const errors = fieldOutcomes
.filter((outcome) => outcome.status === 'error')
.map((outcome) => `${outcome.field}: ${outcome.error}`);
return {
success: false,
message: `No fields were updated on the matching CalendarEvent for Fireflies transcript ${transcriptId}.`,
error: [...errors, ...skipReasons].join(' | ') || 'No fields updated.',
transcriptId,
fieldOutcomes,
};
}
const partialFailures = fieldOutcomes.filter(
(outcome) => outcome.status === 'error',
);
return {
success: true,
message:
partialFailures.length > 0
? `Synced ${updatedFields.join(
' + ',
)} for Fireflies transcript ${transcriptId} (with errors on ${partialFailures
.map((outcome) => outcome.field)
.join(', ')}).`
: `Synced ${updatedFields.join(
' + ',
)} for Fireflies transcript ${transcriptId}.`,
transcriptId,
calendarEventId,
updatedFields,
fieldOutcomes,
};
};
@@ -1,71 +0,0 @@
import { isNonEmptyString } from '@sniptt/guards';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { type FirefliesWebhookResult } from 'src/logic-functions/types/fireflies-webhook-payload.type';
import { getFirefliesApiKey } from 'src/logic-functions/utils/get-fireflies-api-key';
import {
type FirefliesSyncableField,
syncFirefliesFieldToCalendarEvent,
} from 'src/logic-functions/utils/sync-fireflies-field-to-calendar-event';
const TRANSCRIPT_READY_EVENT = 'meeting.transcribed';
const SUMMARY_READY_EVENT = 'meeting.summarized';
const FIELD_BY_EVENT: Record<string, FirefliesSyncableField> = {
[TRANSCRIPT_READY_EVENT]: 'transcript',
[SUMMARY_READY_EVENT]: 'summary',
};
export const firefliesWebhookHandler = async ({
meetingId,
eventType,
}: {
meetingId?: string | null;
eventType?: string | null;
}): Promise<FirefliesWebhookResult> => {
if (!isNonEmptyString(meetingId)) {
return { error: 'Webhook payload is missing meetingId' };
}
const field = isNonEmptyString(eventType)
? FIELD_BY_EVENT[eventType]
: undefined;
if (field === undefined) {
return {
skipped: true,
reason: `Unsupported Fireflies Webhooks V2 event "${
eventType ?? '<missing>'
}"; expected "${TRANSCRIPT_READY_EVENT}" or "${SUMMARY_READY_EVENT}"`,
meetingId,
};
}
const apiKeyResult = getFirefliesApiKey();
if (!apiKeyResult.success) {
return { error: apiKeyResult.error, meetingId };
}
const syncResult = await syncFirefliesFieldToCalendarEvent({
apiKey: apiKeyResult.apiKey,
client: new CoreApiClient(),
transcriptId: meetingId,
field,
});
if (syncResult.status === 'error') {
return { error: syncResult.error, meetingId };
}
if (syncResult.status === 'skipped') {
return { skipped: true, reason: syncResult.reason, meetingId };
}
return {
action: 'updated',
field: syncResult.field,
calendarEventId: syncResult.calendarEventId,
meetingId,
};
};
@@ -1,23 +0,0 @@
import { type InputJsonSchema } from 'twenty-sdk/logic-function';
export const firefliesListCallsByParticipantInputSchema: InputJsonSchema = {
type: 'object',
properties: {
participantEmail: {
type: 'string',
label: 'Participant email',
description:
'Email address of a meeting attendee. Returns Fireflies calls where this email appears in the participants list (case-insensitive match performed by Fireflies). Useful to answer "what calls have we had with this contact?" before reaching out to them.',
},
limit: {
type: 'integer',
label: 'Maximum number of calls',
description:
'Optional. Maximum number of calls to return. Defaults to 20. Fireflies caps the limit at 50 per query.',
minimum: 1,
maximum: 50,
},
},
required: ['participantEmail'],
additionalProperties: false,
};
@@ -1,23 +0,0 @@
import { type InputJsonSchema } from 'twenty-sdk/logic-function';
export const firefliesSearchCallsInputSchema: InputJsonSchema = {
type: 'object',
properties: {
keyword: {
type: 'string',
label: 'Keyword to search for',
description:
'Keyword or phrase to search across Fireflies meetings. Matches against both meeting titles and the words spoken during meetings. Useful for finding "the call where we discussed pricing" or "any meeting that mentioned the new integration".',
},
limit: {
type: 'integer',
label: 'Maximum number of calls',
description:
'Optional. Maximum number of calls to return. Defaults to 20. Fireflies caps the limit at 50 per query.',
minimum: 1,
maximum: 50,
},
},
required: ['keyword'],
additionalProperties: false,
};
@@ -1,15 +0,0 @@
import { type InputJsonSchema } from 'twenty-sdk/logic-function';
export const firefliesSyncCallInputSchema: InputJsonSchema = {
type: 'object',
properties: {
transcriptId: {
type: 'string',
label: 'Fireflies call ID',
description:
'The ID of the Fireflies call to sync (also referred to as the "transcript ID" in Fireflies\' API and docs). Found at the end of the Fireflies meeting URL (`https://app.fireflies.ai/view/<id>`) or in the `meeting_id` field of a Fireflies webhook payload. Runs the same pipeline as the webhook: fetches transcript + AI summary from Fireflies and writes both onto the matching CalendarEvent.',
},
},
required: ['transcriptId'],
additionalProperties: false,
};
@@ -1,18 +0,0 @@
export type FirefliesCallSummary = {
id: string;
title: string | null;
date: string | null;
durationMinutes: number | null;
participants: string[];
hostEmail: string | null;
transcriptUrl: string | null;
meetingLink: string | null;
};
export type FirefliesCallListResult = {
success: boolean;
message: string;
error?: string;
calls?: FirefliesCallSummary[];
count?: number;
};
@@ -1,4 +0,0 @@
export type FirefliesListCallsByParticipantInput = {
participantEmail: string;
limit?: number;
};
@@ -1,4 +0,0 @@
export type FirefliesSearchCallsInput = {
keyword: string;
limit?: number;
};
@@ -1,3 +0,0 @@
export type FirefliesSyncCallInput = {
transcriptId: string;
};
@@ -1,16 +0,0 @@
import { type FirefliesSyncableField } from 'src/logic-functions/utils/sync-fireflies-field-to-calendar-event';
export type FirefliesSyncCallFieldOutcome =
| { field: FirefliesSyncableField; status: 'updated' }
| { field: FirefliesSyncableField; status: 'skipped'; reason: string }
| { field: FirefliesSyncableField; status: 'error'; error: string };
export type FirefliesSyncCallResult = {
success: boolean;
message: string;
error?: string;
transcriptId?: string;
calendarEventId?: string;
updatedFields?: FirefliesSyncableField[];
fieldOutcomes?: FirefliesSyncCallFieldOutcome[];
};
@@ -1,30 +0,0 @@
export type FirefliesTranscriptSentence = {
speaker_name: string | null;
text: string;
start_time: number | null;
};
export type FirefliesSummary = {
overview: string | null;
action_items: string | null;
keywords: string[] | null;
topics_discussed: string[] | null;
short_summary: string | null;
};
export type FirefliesTranscript = {
id: string;
title: string | null;
duration: number | null;
meeting_link: string | null;
participants: string[];
organizer_email: string | null;
host_email?: string | null;
date?: number | null;
transcript_url?: string | null;
sentences?: FirefliesTranscriptSentence[] | null;
summary?: FirefliesSummary | null;
calendar_id?: string | null;
cal_id?: string | null;
calendar_type?: string | null;
};
@@ -1,18 +0,0 @@
export type FirefliesWebhookPayload = {
event: string;
meeting_id: string;
timestamp?: number;
client_reference_id?: string | null;
};
export type FirefliesSyncedField = 'transcript' | 'summary';
export type FirefliesWebhookResult =
| {
action: 'updated';
field: FirefliesSyncedField;
calendarEventId: string;
meetingId: string;
}
| { skipped: true; reason: string; meetingId?: string }
| { error: string; meetingId?: string };
@@ -1,109 +0,0 @@
import { describe, expect, it } from 'vitest';
import {
type FirefliesSummary,
type FirefliesTranscript,
} from 'src/logic-functions/types/fireflies-transcript.type';
import { formatSummaryAsMarkdown } from 'src/logic-functions/utils/format-summary-as-markdown';
const buildTranscript = (
summary: FirefliesSummary | null | undefined,
): FirefliesTranscript => ({
id: 'abc',
title: 'Test call',
duration: 30,
meeting_link: 'https://zoom.us/j/1234',
participants: ['a@example.com'],
organizer_email: 'a@example.com',
summary,
});
describe('formatSummaryAsMarkdown', () => {
it('returns a placeholder when summary is undefined', () => {
const result = formatSummaryAsMarkdown(buildTranscript(undefined));
expect(result).toContain('Fireflies returned no summary content');
});
it('returns a placeholder when summary is null', () => {
const result = formatSummaryAsMarkdown(buildTranscript(null));
expect(result).toContain('Fireflies returned no summary content');
});
it('returns a placeholder when every summary section is empty', () => {
const result = formatSummaryAsMarkdown(
buildTranscript({
overview: '',
action_items: ' ',
keywords: null,
topics_discussed: null,
short_summary: null,
}),
);
expect(result).toContain('Fireflies returned no summary content');
});
it('renders overview, action items, topics and keywords in order with headers', () => {
const result = formatSummaryAsMarkdown(
buildTranscript({
overview: '- **Item 1:** First point',
action_items: '**Abdul**\nDo something (00:10)',
keywords: ['Twenty', 'Fireflies'],
topics_discussed: ['Integration', 'Roadmap'],
short_summary: 'irrelevant',
}),
);
expect(result).toBe(
[
'## Overview',
'',
'- **Item 1:** First point',
'',
'## Action items',
'',
'**Abdul**\nDo something (00:10)',
'',
'## Topics discussed',
'',
'Integration, Roadmap',
'',
'## Keywords',
'',
'Twenty, Fireflies',
].join('\n'),
);
});
it('omits sections that are empty', () => {
const result = formatSummaryAsMarkdown(
buildTranscript({
overview: 'Some overview',
action_items: null,
keywords: [],
topics_discussed: null,
short_summary: null,
}),
);
expect(result).toBe('## Overview\n\nSome overview');
});
it('trims overview and action_items whitespace', () => {
const result = formatSummaryAsMarkdown(
buildTranscript({
overview: ' \n Overview body \n ',
action_items: '\n**A**\nDo it\n',
keywords: null,
topics_discussed: null,
short_summary: null,
}),
);
expect(result).toBe(
'## Overview\n\nOverview body\n\n## Action items\n\n**A**\nDo it',
);
});
});
@@ -1,87 +0,0 @@
import { describe, expect, it } from 'vitest';
import { type FirefliesTranscript } from 'src/logic-functions/types/fireflies-transcript.type';
import { formatTranscriptAsMarkdown } from 'src/logic-functions/utils/format-transcript-as-markdown';
const buildTranscript = (
sentences: FirefliesTranscript['sentences'],
): FirefliesTranscript => ({
id: 'abc',
title: 'Test call',
date: 1700000000000,
duration: 30,
meeting_link: 'https://zoom.us/j/1234',
participants: ['a@example.com'],
organizer_email: 'a@example.com',
sentences,
});
describe('formatTranscriptAsMarkdown', () => {
it('returns a placeholder when there are no sentences', () => {
const result = formatTranscriptAsMarkdown(buildTranscript([]));
expect(result).toContain('Fireflies returned no transcript content');
});
it('returns a placeholder when sentences is null', () => {
const result = formatTranscriptAsMarkdown(buildTranscript(null));
expect(result).toContain('Fireflies returned no transcript content');
});
it('groups consecutive sentences from the same speaker into one paragraph', () => {
const result = formatTranscriptAsMarkdown(
buildTranscript([
{ speaker_name: 'Sarah', text: 'Hi there', start_time: 0 },
{ speaker_name: 'Sarah', text: 'How are you?', start_time: 1 },
{ speaker_name: 'John', text: 'Doing well, thanks.', start_time: 2 },
]),
);
expect(result).toBe(
'**Sarah:** Hi there How are you?\n\n**John:** Doing well, thanks.',
);
});
it('falls back to "Speaker" when the speaker name is missing', () => {
const result = formatTranscriptAsMarkdown(
buildTranscript([{ speaker_name: null, text: 'Hello', start_time: 0 }]),
);
expect(result).toBe('**Speaker:** Hello');
});
it('falls back to "Speaker" when the speaker name is whitespace-only', () => {
const result = formatTranscriptAsMarkdown(
buildTranscript([
{ speaker_name: ' ', text: 'Hello', start_time: 0 },
{ speaker_name: '\n\t', text: 'World', start_time: 1 },
]),
);
expect(result).toBe('**Speaker:** Hello World');
});
it('skips empty sentence text', () => {
const result = formatTranscriptAsMarkdown(
buildTranscript([
{ speaker_name: 'Sarah', text: ' ', start_time: 0 },
{ speaker_name: 'Sarah', text: 'Hello', start_time: 1 },
]),
);
expect(result).toBe('**Sarah:** Hello');
});
it('returns the placeholder when every sentence is whitespace-only', () => {
const result = formatTranscriptAsMarkdown(
buildTranscript([
{ speaker_name: 'Sarah', text: ' ', start_time: 0 },
{ speaker_name: 'John', text: '\n\t', start_time: 1 },
{ speaker_name: null, text: '', start_time: 2 },
]),
);
expect(result).toContain('Fireflies returned no transcript content');
});
});
@@ -1,101 +0,0 @@
import { createHmac } from 'crypto';
import { describe, expect, it } from 'vitest';
import { verifyFirefliesWebhookSignature } from 'src/logic-functions/utils/verify-fireflies-webhook-signature';
const SECRET = 'test-secret-abc123';
const sign = (body: string): string =>
createHmac('sha256', SECRET).update(body, 'utf8').digest('hex');
describe('verifyFirefliesWebhookSignature', () => {
it('accepts a valid bare-hex signature', () => {
const body = JSON.stringify({ meetingId: 'm1' });
const signature = sign(body);
const result = verifyFirefliesWebhookSignature({
rawBody: body,
signatureHeader: signature,
secret: SECRET,
});
expect(result).toEqual({ valid: true });
});
it('accepts a valid signature with sha256= prefix', () => {
const body = JSON.stringify({ meetingId: 'm1' });
const signature = `sha256=${sign(body)}`;
const result = verifyFirefliesWebhookSignature({
rawBody: body,
signatureHeader: signature,
secret: SECRET,
});
expect(result).toEqual({ valid: true });
});
it('rejects when signature header is missing', () => {
const result = verifyFirefliesWebhookSignature({
rawBody: '{}',
signatureHeader: undefined,
secret: SECRET,
});
expect(result).toEqual({
valid: false,
error: 'Missing x-hub-signature header',
});
});
it('rejects when signature header is empty', () => {
const result = verifyFirefliesWebhookSignature({
rawBody: '{}',
signatureHeader: '',
secret: SECRET,
});
expect(result).toEqual({
valid: false,
error: 'Missing x-hub-signature header',
});
});
it('rejects when the signature was computed from a different body', () => {
const signature = sign(JSON.stringify({ meetingId: 'm1' }));
const result = verifyFirefliesWebhookSignature({
rawBody: JSON.stringify({ meetingId: 'tampered' }),
signatureHeader: signature,
secret: SECRET,
});
expect(result.valid).toBe(false);
});
it('rejects when the signature was computed with a different secret', () => {
const body = JSON.stringify({ meetingId: 'm1' });
const signature = createHmac('sha256', 'other-secret')
.update(body, 'utf8')
.digest('hex');
const result = verifyFirefliesWebhookSignature({
rawBody: body,
signatureHeader: signature,
secret: SECRET,
});
expect(result.valid).toBe(false);
});
it('rejects malformed signature strings', () => {
const result = verifyFirefliesWebhookSignature({
rawBody: '{}',
signatureHeader: 'not-a-real-signature',
secret: SECRET,
});
expect(result.valid).toBe(false);
});
});
@@ -1,60 +0,0 @@
import { type FirefliesTranscript } from 'src/logic-functions/types/fireflies-transcript.type';
import {
firefliesApiRequest,
type FirefliesApiResult,
} from 'src/logic-functions/utils/fireflies-api-request';
const SUMMARY_QUERY = `
query Transcript($transcriptId: String!) {
transcript(id: $transcriptId) {
id
title
duration
meeting_link
participants
organizer_email
calendar_id
cal_id
calendar_type
summary {
overview
action_items
keywords
topics_discussed
short_summary
}
}
}
`;
type FirefliesSummaryResponse = {
transcript: FirefliesTranscript | null;
};
export const fetchFirefliesSummary = async ({
apiKey,
transcriptId,
}: {
apiKey: string;
transcriptId: string;
}): Promise<FirefliesApiResult<FirefliesTranscript>> => {
const result = await firefliesApiRequest<FirefliesSummaryResponse>({
apiKey,
query: SUMMARY_QUERY,
variables: { transcriptId },
});
if (!result.ok) {
return result;
}
if (result.data.transcript === null) {
return {
ok: false,
status: 404,
errorMessage: `Fireflies transcript ${transcriptId} not found (may have been deleted or access was revoked)`,
};
}
return { ok: true, status: result.status, data: result.data.transcript };
};
@@ -1,58 +0,0 @@
import { type FirefliesTranscript } from 'src/logic-functions/types/fireflies-transcript.type';
import {
firefliesApiRequest,
type FirefliesApiResult,
} from 'src/logic-functions/utils/fireflies-api-request';
const TRANSCRIPT_QUERY = `
query Transcript($transcriptId: String!) {
transcript(id: $transcriptId) {
id
title
duration
meeting_link
participants
organizer_email
calendar_id
cal_id
calendar_type
sentences {
speaker_name
text
start_time
}
}
}
`;
type FirefliesTranscriptResponse = {
transcript: FirefliesTranscript | null;
};
export const fetchFirefliesTranscript = async ({
apiKey,
transcriptId,
}: {
apiKey: string;
transcriptId: string;
}): Promise<FirefliesApiResult<FirefliesTranscript>> => {
const result = await firefliesApiRequest<FirefliesTranscriptResponse>({
apiKey,
query: TRANSCRIPT_QUERY,
variables: { transcriptId },
});
if (!result.ok) {
return result;
}
if (result.data.transcript === null) {
return {
ok: false,
status: 404,
errorMessage: `Fireflies transcript ${transcriptId} not found (may have been deleted or access was revoked)`,
};
}
return { ok: true, status: result.status, data: result.data.transcript };
};
@@ -1,117 +0,0 @@
import { isNonEmptyString } from '@sniptt/guards';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { isDefined } from 'twenty-shared/utils';
import { type FirefliesTranscript } from 'src/logic-functions/types/fireflies-transcript.type';
type CalendarChannelEventAssociationsConnection = {
edges: Array<{ node: { calendarEventId: string } }>;
};
type CalendarEventsConnection = {
edges: Array<{ node: { id: string } }>;
};
const findByEventExternalId = async (
client: CoreApiClient,
externalIds: string[],
): Promise<string | null> => {
if (externalIds.length === 0) {
return null;
}
const { calendarChannelEventAssociations } = await client.query({
calendarChannelEventAssociations: {
__args: {
filter: {
eventExternalId: { in: externalIds },
},
first: 1,
},
edges: {
node: {
calendarEventId: true,
},
},
},
});
const node = (
calendarChannelEventAssociations as
| CalendarChannelEventAssociationsConnection
| undefined
)?.edges?.[0]?.node;
return node?.calendarEventId ?? null;
};
const findByICalUid = async (
client: CoreApiClient,
iCalUid: string,
): Promise<string | null> => {
const { calendarEvents } = await client.query({
calendarEvents: {
__args: {
filter: {
iCalUid: { eq: iCalUid },
},
first: 1,
},
edges: {
node: {
id: true,
},
},
},
});
const node = (calendarEvents as CalendarEventsConnection | undefined)
?.edges?.[0]?.node;
return node?.id ?? null;
};
export const findMatchingCalendarEvent = async ({
client,
transcript,
}: {
client: CoreApiClient;
transcript: FirefliesTranscript;
}): Promise<
| {
matched: true;
calendarEventId: string;
matchedBy: 'externalId' | 'iCalUid';
}
| { matched: false; reason: string }
> => {
const externalIdCandidates = [
transcript.calendar_id,
transcript.cal_id,
].filter(isNonEmptyString);
if (externalIdCandidates.length > 0) {
const calendarEventId = await findByEventExternalId(
client,
externalIdCandidates,
);
if (isDefined(calendarEventId)) {
return { matched: true, calendarEventId, matchedBy: 'externalId' };
}
}
if (isNonEmptyString(transcript.calendar_id)) {
const calendarEventId = await findByICalUid(client, transcript.calendar_id);
if (isDefined(calendarEventId)) {
return { matched: true, calendarEventId, matchedBy: 'iCalUid' };
}
}
return {
matched: false,
reason:
'No CalendarEvent matched the transcript by external ID or iCalUid. Either the meeting was never on a synced calendar, or its calendar sync (Google/Outlook/CalDAV) is not configured in Twenty. Orphan calls are skipped in v1.',
};
};
@@ -1,122 +0,0 @@
import { isDefined } from 'twenty-shared/utils';
const FIREFLIES_API_URL = 'https://api.fireflies.ai/graphql';
type FirefliesApiSuccess<TData> = {
ok: true;
status: number;
data: TData;
};
type FirefliesApiFailure = {
ok: false;
status: number;
errorMessage: string;
};
export type FirefliesApiResult<TData> =
| FirefliesApiSuccess<TData>
| FirefliesApiFailure;
type FirefliesGraphqlError = {
message?: string;
extensions?: {
code?: string;
};
};
type FirefliesGraphqlEnvelope<TData> = {
data?: TData;
errors?: FirefliesGraphqlError[];
};
type FirefliesApiRequestParams = {
apiKey: string;
query: string;
variables?: Record<string, unknown>;
};
export const firefliesApiRequest = async <TData = unknown>({
apiKey,
query,
variables,
}: FirefliesApiRequestParams): Promise<FirefliesApiResult<TData>> => {
let response: Response;
try {
response = await fetch(FIREFLIES_API_URL, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ query, variables }),
});
} catch (error) {
return {
ok: false,
status: 0,
errorMessage: `Fireflies API request failed: ${(error as Error).message}`,
};
}
let envelope: FirefliesGraphqlEnvelope<TData> | null = null;
let parseError: Error | null = null;
try {
envelope = (await response.json()) as FirefliesGraphqlEnvelope<TData>;
} catch (error) {
parseError = error as Error;
}
if (
envelope !== null &&
isDefined(envelope.errors) &&
envelope.errors.length > 0
) {
return {
ok: false,
status: response.status,
errorMessage: `Fireflies GraphQL error: ${formatFirefliesGraphqlError(
envelope.errors[0],
)}`,
};
}
if (!response.ok) {
return {
ok: false,
status: response.status,
errorMessage: `Fireflies API responded with HTTP ${response.status}`,
};
}
if (parseError !== null) {
return {
ok: false,
status: response.status,
errorMessage: `Fireflies API returned a non-JSON response: ${parseError.message}`,
};
}
if (envelope === null || !isDefined(envelope.data)) {
return {
ok: false,
status: response.status,
errorMessage: 'Fireflies GraphQL response was missing a `data` field',
};
}
return { ok: true, status: response.status, data: envelope.data };
};
const formatFirefliesGraphqlError = (
error: FirefliesGraphqlError | undefined,
): string => {
const message = error?.message ?? 'Unknown Fireflies GraphQL error';
const code = error?.extensions?.code;
return isDefined(code)
? `${message} (Fireflies error code ${code})`
: message;
};
@@ -1,43 +0,0 @@
import { isNonEmptyArray, isNonEmptyString } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
import { type FirefliesTranscript } from 'src/logic-functions/types/fireflies-transcript.type';
const EMPTY_SUMMARY_MESSAGE =
'_Fireflies returned no summary content for this meeting._';
export const formatSummaryAsMarkdown = (
transcript: FirefliesTranscript,
): string => {
const summary = transcript.summary;
if (!isDefined(summary)) {
return EMPTY_SUMMARY_MESSAGE;
}
const sections: string[] = [];
if (isNonEmptyString(summary.overview?.trim())) {
sections.push(`## Overview\n\n${summary.overview.trim()}`);
}
if (isNonEmptyString(summary.action_items?.trim())) {
sections.push(`## Action items\n\n${summary.action_items.trim()}`);
}
if (isNonEmptyArray(summary.topics_discussed)) {
sections.push(
`## Topics discussed\n\n${summary.topics_discussed.join(', ')}`,
);
}
if (isNonEmptyArray(summary.keywords)) {
sections.push(`## Keywords\n\n${summary.keywords.join(', ')}`);
}
if (sections.length === 0) {
return EMPTY_SUMMARY_MESSAGE;
}
return sections.join('\n\n');
};
@@ -1,57 +0,0 @@
import { isNonEmptyArray, isNonEmptyString } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
import { type FirefliesTranscript } from 'src/logic-functions/types/fireflies-transcript.type';
const UNKNOWN_SPEAKER_LABEL = 'Speaker';
const EMPTY_TRANSCRIPT_MESSAGE =
'_Fireflies returned no transcript content for this meeting._';
export const formatTranscriptAsMarkdown = (
transcript: FirefliesTranscript,
): string => {
const sentences = transcript.sentences ?? [];
const lines: string[] = [];
let currentSpeaker: string | null = null;
let currentLines: string[] = [];
const flush = () => {
if (!isNonEmptyArray(currentLines)) {
return;
}
const speakerLabel = isDefined(currentSpeaker)
? currentSpeaker
: UNKNOWN_SPEAKER_LABEL;
lines.push(`**${speakerLabel}:** ${currentLines.join(' ')}`);
currentLines = [];
};
for (const sentence of sentences) {
const text = sentence.text.trim();
if (!isNonEmptyString(text)) {
continue;
}
const trimmedSpeaker = sentence.speaker_name?.trim();
const speaker = isNonEmptyString(trimmedSpeaker) ? trimmedSpeaker : null;
if (speaker !== currentSpeaker) {
flush();
currentSpeaker = speaker;
}
currentLines.push(text);
}
flush();
if (!isNonEmptyArray(lines)) {
return EMPTY_TRANSCRIPT_MESSAGE;
}
return lines.join('\n\n');
};
@@ -1,19 +0,0 @@
import { isNonEmptyString } from '@sniptt/guards';
export const FIREFLIES_API_KEY_ENV_VAR = 'FIREFLIES_API_KEY';
export const getFirefliesApiKey = ():
| { success: true; apiKey: string }
| { success: false; error: string } => {
const apiKey = process.env[FIREFLIES_API_KEY_ENV_VAR];
if (!isNonEmptyString(apiKey)) {
return {
success: false,
error:
'Fireflies is not configured. Open the Twenty Fireflies app settings and set the FIREFLIES_API_KEY application variable (Fireflies → Integrations → Fireflies API → Generate API key).',
};
}
return { success: true, apiKey };
};
@@ -1,19 +0,0 @@
import { isNonEmptyString } from '@sniptt/guards';
export const FIREFLIES_WEBHOOK_SECRET_ENV_VAR = 'FIREFLIES_WEBHOOK_SECRET';
export const getFirefliesWebhookSecret = ():
| { success: true; secret: string }
| { success: false; error: string } => {
const secret = process.env[FIREFLIES_WEBHOOK_SECRET_ENV_VAR];
if (!isNonEmptyString(secret)) {
return {
success: false,
error:
'FIREFLIES_WEBHOOK_SECRET application variable is not set. Set it in Twenty Fireflies app settings, then configure the same value on the Fireflies side when registering the webhook URL.',
};
}
return { success: true, secret };
};
@@ -1,101 +0,0 @@
import { isDefined } from 'twenty-shared/utils';
import { type FirefliesCallSummary } from 'src/logic-functions/types/fireflies-call-list-result.type';
import { type FirefliesTranscript } from 'src/logic-functions/types/fireflies-transcript.type';
import {
firefliesApiRequest,
type FirefliesApiResult,
} from 'src/logic-functions/utils/fireflies-api-request';
const TRANSCRIPTS_QUERY = `
query Transcripts(
$keyword: String
$scope: String
$participants: [String!]
$fromDate: DateTime
$toDate: DateTime
$limit: Int
) {
transcripts(
keyword: $keyword
scope: $scope
participants: $participants
fromDate: $fromDate
toDate: $toDate
limit: $limit
) {
id
title
date
duration
participants
host_email
transcript_url
meeting_link
}
}
`;
type TranscriptsResponse = {
transcripts: FirefliesTranscript[] | null;
};
export type FirefliesKeywordScope = 'title' | 'sentences' | 'all';
export type ListFirefliesTranscriptsArgs = {
apiKey: string;
keyword?: string;
keywordScope?: FirefliesKeywordScope;
participants?: string[];
fromDate?: string;
toDate?: string;
limit?: number;
};
const toCallSummary = (transcript: FirefliesTranscript): FirefliesCallSummary => ({
id: transcript.id,
title: transcript.title,
date: isDefined(transcript.date) ? new Date(transcript.date).toISOString() : null,
durationMinutes: transcript.duration,
participants: transcript.participants,
hostEmail: transcript.host_email ?? transcript.organizer_email ?? null,
transcriptUrl: transcript.transcript_url ?? null,
meetingLink: transcript.meeting_link,
});
export const listFirefliesTranscripts = async ({
apiKey,
keyword,
keywordScope,
participants,
fromDate,
toDate,
limit,
}: ListFirefliesTranscriptsArgs): Promise<
FirefliesApiResult<FirefliesCallSummary[]>
> => {
const result = await firefliesApiRequest<TranscriptsResponse>({
apiKey,
query: TRANSCRIPTS_QUERY,
variables: {
keyword,
scope: keywordScope,
participants,
fromDate,
toDate,
limit,
},
});
if (!result.ok) {
return result;
}
const transcripts = result.data.transcripts ?? [];
return {
ok: true,
status: result.status,
data: transcripts.map(toCallSummary),
};
};
@@ -1,102 +0,0 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { type FirefliesTranscript } from 'src/logic-functions/types/fireflies-transcript.type';
import { fetchFirefliesSummary } from 'src/logic-functions/utils/fetch-fireflies-summary';
import { fetchFirefliesTranscript } from 'src/logic-functions/utils/fetch-fireflies-transcript';
import { type FirefliesApiResult } from 'src/logic-functions/utils/fireflies-api-request';
import { findMatchingCalendarEvent } from 'src/logic-functions/utils/find-matching-calendar-event';
import { formatSummaryAsMarkdown } from 'src/logic-functions/utils/format-summary-as-markdown';
import { formatTranscriptAsMarkdown } from 'src/logic-functions/utils/format-transcript-as-markdown';
import { updateCalendarEventSummary } from 'src/logic-functions/utils/update-calendar-event-summary';
import { updateCalendarEventTranscript } from 'src/logic-functions/utils/update-calendar-event-transcript';
export type FirefliesSyncableField = 'transcript' | 'summary';
export type SyncFirefliesFieldResult =
| {
status: 'updated';
field: FirefliesSyncableField;
calendarEventId: string;
}
| { status: 'skipped'; field: FirefliesSyncableField; reason: string }
| { status: 'error'; field: FirefliesSyncableField; error: string };
type FieldSyncStrategy = {
fetch: (args: {
apiKey: string;
transcriptId: string;
}) => Promise<FirefliesApiResult<FirefliesTranscript>>;
format: (transcript: FirefliesTranscript) => string;
update: (args: {
client: CoreApiClient;
calendarEventId: string;
markdown: string;
}) => Promise<void>;
};
const FIELD_SYNC_STRATEGIES: Record<FirefliesSyncableField, FieldSyncStrategy> =
{
transcript: {
fetch: fetchFirefliesTranscript,
format: formatTranscriptAsMarkdown,
update: updateCalendarEventTranscript,
},
summary: {
fetch: fetchFirefliesSummary,
format: formatSummaryAsMarkdown,
update: updateCalendarEventSummary,
},
};
export const syncFirefliesFieldToCalendarEvent = async ({
apiKey,
client,
transcriptId,
field,
}: {
apiKey: string;
client: CoreApiClient;
transcriptId: string;
field: FirefliesSyncableField;
}): Promise<SyncFirefliesFieldResult> => {
const strategy = FIELD_SYNC_STRATEGIES[field];
const fetchResult = await strategy.fetch({ apiKey, transcriptId });
if (!fetchResult.ok) {
return { status: 'error', field, error: fetchResult.errorMessage };
}
const match = await findMatchingCalendarEvent({
client,
transcript: fetchResult.data,
});
if (!match.matched) {
return { status: 'skipped', field, reason: match.reason };
}
const markdown = strategy.format(fetchResult.data);
try {
await strategy.update({
client,
calendarEventId: match.calendarEventId,
markdown,
});
} catch (error) {
return {
status: 'error',
field,
error: `Failed to update CalendarEvent ${match.calendarEventId} ${field}: ${
(error as Error).message
}`,
};
}
return {
status: 'updated',
field,
calendarEventId: match.calendarEventId,
};
};
@@ -1,26 +0,0 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
export const updateCalendarEventSummary = async ({
client,
calendarEventId,
markdown,
}: {
client: CoreApiClient;
calendarEventId: string;
markdown: string;
}): Promise<void> => {
await client.mutation({
updateCalendarEvent: {
__args: {
id: calendarEventId,
data: {
summary: {
markdown,
blocknote: null,
},
},
},
id: true,
},
});
};
@@ -1,26 +0,0 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
export const updateCalendarEventTranscript = async ({
client,
calendarEventId,
markdown,
}: {
client: CoreApiClient;
calendarEventId: string;
markdown: string;
}): Promise<void> => {
await client.mutation({
updateCalendarEvent: {
__args: {
id: calendarEventId,
data: {
transcript: {
markdown,
blocknote: null,
},
},
},
id: true,
},
});
};
@@ -1,49 +0,0 @@
import { createHmac, timingSafeEqual } from 'crypto';
import { isNonEmptyString } from '@sniptt/guards';
const SIGNATURE_PREFIX = 'sha256=';
const stripPrefix = (signature: string): string =>
signature.startsWith(SIGNATURE_PREFIX)
? signature.slice(SIGNATURE_PREFIX.length)
: signature;
export const verifyFirefliesWebhookSignature = ({
rawBody,
signatureHeader,
secret,
}: {
rawBody: string;
signatureHeader: string | undefined;
secret: string;
}): { valid: true } | { valid: false; error: string } => {
if (!isNonEmptyString(signatureHeader)) {
return { valid: false, error: 'Missing x-hub-signature header' };
}
const provided = stripPrefix(signatureHeader.trim()).toLowerCase();
const expected = createHmac('sha256', secret)
.update(rawBody, 'utf8')
.digest('hex');
if (provided.length !== expected.length) {
return { valid: false, error: 'Signature length mismatch' };
}
const providedBuffer = Buffer.from(provided, 'hex');
const expectedBuffer = Buffer.from(expected, 'hex');
if (
providedBuffer.length === 0 ||
providedBuffer.length !== expectedBuffer.length
) {
return { valid: false, error: 'Malformed signature' };
}
if (!timingSafeEqual(providedBuffer, expectedBuffer)) {
return { valid: false, error: 'Signature verification failed' };
}
return { valid: true };
};
@@ -1,42 +0,0 @@
import {
defineApplicationRole,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
export default defineApplicationRole({
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
label: 'Twenty Fireflies sync role',
description:
'Reads CalendarEvent and CalendarChannelEventAssociation to locate the meeting matching a Fireflies call (from an incoming webhook or from the on-demand Sync Fireflies Call workflow tool), and updates that CalendarEvent to write the synced transcript and summary fields. The list / search workflow tools only call the Fireflies API and do not require any Twenty object permissions.',
canReadAllObjectRecords: false,
canUpdateAllObjectRecords: false,
canSoftDeleteAllObjectRecords: false,
canDestroyAllObjectRecords: false,
canUpdateAllSettings: false,
canBeAssignedToAgents: false,
canBeAssignedToUsers: false,
canBeAssignedToApiKeys: false,
objectPermissions: [
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.calendarEvent.universalIdentifier,
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
},
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.calendarChannelEventAssociation
.universalIdentifier,
canReadObjectRecords: true,
canUpdateObjectRecords: false,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
},
],
fieldPermissions: [],
permissionFlags: [],
});

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