Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01ffca0cef | ||
|
|
4bdd866a20 | ||
|
|
52cf3775b3 | ||
|
|
c36bb8f3a1 | ||
|
|
df456cfa2f | ||
|
|
ce7f0c03f7 | ||
|
|
52604e1c52 | ||
|
|
47a8a15598 | ||
|
|
db960cf509 | ||
|
|
daaa009fb4 | ||
|
|
34a1c64b7e | ||
|
|
3a1e2618e2 | ||
|
|
b4afbaefff | ||
|
|
12544da527 | ||
|
|
4fe8e3d3b6 | ||
|
|
6682d4eb02 | ||
|
|
4e15f7fe13 | ||
|
|
1e615f7102 | ||
|
|
75ae2b401b | ||
|
|
e5f655fcae | ||
|
|
82b0ee5995 | ||
|
|
1088f7bbab | ||
|
|
c13b955a36 | ||
|
|
b0e256221a | ||
|
|
a560e8ac83 | ||
|
|
100d4f7f42 | ||
|
|
1f1a1ea138 | ||
|
|
59d3a14922 | ||
|
|
0e6a8c04c4 | ||
|
|
ca976afa10 | ||
|
|
967298fe09 | ||
|
|
a60f750ed7 | ||
|
|
2e62bb133b | ||
|
|
6de424bc32 | ||
|
|
2717afce59 | ||
|
|
659ed6b080 | ||
|
|
30c2c22fc2 | ||
|
|
d43e21b695 | ||
|
|
0145920d7e | ||
|
|
1cbbd04761 | ||
|
|
58c85e7cca | ||
|
|
576019e465 | ||
|
|
5a5bf112cf | ||
|
|
9b00084fb0 | ||
|
|
38a0bba57b | ||
|
|
a6127c2128 | ||
|
|
04c596817a | ||
|
|
3ac72683da | ||
|
|
d998e3b92c | ||
|
|
75bba5a8ee | ||
|
|
553efe0cee | ||
|
|
48a7a24380 | ||
|
|
e8fd5fa3ed | ||
|
|
31467f2173 | ||
|
|
9c3cd959ba | ||
|
|
289e8bf1d4 | ||
|
|
0ecb60f50b | ||
|
|
cd0318ea65 | ||
|
|
a0b963ef86 | ||
|
|
66d242da24 | ||
|
|
e289f3056e | ||
|
|
e33f18bfa8 | ||
|
|
039a3d99a8 | ||
|
|
fc74a06f53 | ||
|
|
75f2c26d3f | ||
|
|
95e0793f81 | ||
|
|
2e104c8e76 | ||
|
|
4281a71f40 | ||
|
|
3e55e772d4 | ||
|
|
a83732d7a6 | ||
|
|
042972d7b2 | ||
|
|
d7b34fc636 | ||
|
|
8c79353bfb | ||
|
|
cf6fb419e0 | ||
|
|
9fd2853315 | ||
|
|
1119e3d77e | ||
|
|
d189e3f57e | ||
|
|
da9ade72c8 | ||
|
|
7f50486de5 | ||
|
|
d36d0d49d1 | ||
|
|
44f0cfdd9e | ||
|
|
afcca283c4 | ||
|
|
62408e7fd3 | ||
|
|
ee47a773bd | ||
|
|
f0af947331 | ||
|
|
0035fc1145 | ||
|
|
3e17140385 | ||
|
|
59663ec34e | ||
|
|
4b292ac780 | ||
|
|
d30453f38f | ||
|
|
6acdde72ef | ||
|
|
8dbcd506ed | ||
|
|
4f91b48470 | ||
|
|
ec243e9874 | ||
|
|
19f20cd37b | ||
|
|
c274adf4d8 | ||
|
|
bd8ed03990 | ||
|
|
4b2a604ef0 | ||
|
|
865265d0d9 | ||
|
|
5f4f4c0af8 | ||
|
|
70a78aafe9 | ||
|
|
4d90a8f6e3 | ||
|
|
14b2487d25 | ||
|
|
aad581a353 | ||
|
|
a13727335b | ||
|
|
999bc84b17 | ||
|
|
083a038f8a | ||
|
|
3dd2684254 | ||
|
|
2da56c1886 | ||
|
|
7f9e948e05 | ||
|
|
ec8773437e | ||
|
|
3cea19baf4 | ||
|
|
2943125410 | ||
|
|
f06a5ccacd | ||
|
|
9c1197c0b2 | ||
|
|
b83dc3aaff | ||
|
|
c13ef0c4a4 | ||
|
|
918b145fb5 | ||
|
|
310bf5fe9a | ||
|
|
5af2d37253 | ||
|
|
6c728fff78 | ||
|
|
5a697e914b | ||
|
|
bc57b8ee4e | ||
|
|
5daef28328 | ||
|
|
7021cd41ea | ||
|
|
1077cf1560 | ||
|
|
7a7f36d38c | ||
|
|
bed5270f3c | ||
|
|
2948880a0b | ||
|
|
b760e6df46 | ||
|
|
9bd8f94b3a | ||
|
|
4f13022774 | ||
|
|
3648fa064c | ||
|
|
b17e45f272 | ||
|
|
0a11add616 | ||
|
|
478c753a50 | ||
|
|
ac0ec88a87 | ||
|
|
5df8fd90c3 | ||
|
|
59b54f254e | ||
|
|
5e44781be1 | ||
|
|
89cc59d8ab | ||
|
|
f48adb5f07 | ||
|
|
1839f8e946 | ||
|
|
20f2ca6864 | ||
|
|
20879c8147 | ||
|
|
1e166ccb53 | ||
|
|
bc274b9937 | ||
|
|
d932ec28e3 | ||
|
|
30e3aa5961 | ||
|
|
915e0fd436 | ||
|
|
fd54a2af5c | ||
|
|
7b98804ec1 | ||
|
|
83fc434c5d | ||
|
|
3e9820fa8d | ||
|
|
adb38631b3 | ||
|
|
aa519e7d81 | ||
|
|
a18203934c | ||
|
|
608e5751a9 | ||
|
|
7af3da9393 | ||
|
|
bd49fd8d5f | ||
|
|
e758f78caf | ||
|
|
3ba7a070d9 | ||
|
|
e8d4f11d8e | ||
|
|
4996f3dd28 | ||
|
|
be91d870f9 | ||
|
|
2009112712 | ||
|
|
82ee097bdc | ||
|
|
e5fea85351 | ||
|
|
7e8151355d | ||
|
|
790a8d6f93 | ||
|
|
ec2b6ae691 | ||
|
|
ae2919e564 | ||
|
|
b578ea5951 | ||
|
|
95c69507aa | ||
|
|
0b8984116b | ||
|
|
77e592502c | ||
|
|
f80b2f2c38 | ||
|
|
07cfaa78ef | ||
|
|
950f452fef | ||
|
|
4fe9e57847 | ||
|
|
d7dbb87a8f | ||
|
|
9efeb180d6 | ||
|
|
1c14567bf1 | ||
|
|
ac89b5aff6 | ||
|
|
859004f4fc | ||
|
|
c2d3fbcd21 | ||
|
|
5fb7e76005 |
@@ -56,7 +56,9 @@ npx nx storybook:build twenty-front # Build Storybook
|
||||
npx nx storybook:serve-and-test:static # Run Storybook tests
|
||||
|
||||
# Development
|
||||
npx nx lint twenty-front # Run linter
|
||||
npx nx lint:changed twenty-front # Lint changed files only (fastest)
|
||||
npx nx lint:changed twenty-front --configuration=fix # Auto-fix changed files
|
||||
npx nx lint twenty-front # Lint all files (slower)
|
||||
npx nx typecheck twenty-front # Type checking
|
||||
npx nx run twenty-front:graphql:generate # Generate GraphQL types
|
||||
```
|
||||
@@ -70,7 +72,9 @@ npx nx run twenty-server:database:migrate:prod # Run migrations
|
||||
|
||||
# Development
|
||||
npx nx run twenty-server:start # Start the server
|
||||
npx nx run twenty-server:lint # Run linter (add --fix to auto-fix)
|
||||
npx nx lint:changed twenty-server # Lint changed files only (fastest)
|
||||
npx nx lint:changed twenty-server --configuration=fix # Auto-fix changed files
|
||||
npx nx run twenty-server:lint # Lint all files (slower)
|
||||
npx nx run twenty-server:typecheck # Type checking
|
||||
npx nx run twenty-server:test # Run unit tests
|
||||
npx nx run twenty-server:test:integration:with-db-reset # Run integration tests
|
||||
|
||||
@@ -12,7 +12,13 @@ alwaysApply: true
|
||||
npx nx run twenty-front:build
|
||||
npx nx run twenty-server:test
|
||||
|
||||
# Run target for all projects
|
||||
# Lint changed files only (recommended - much faster!)
|
||||
npx nx lint:changed twenty-front # Lint only files changed vs main
|
||||
npx nx lint:changed twenty-server
|
||||
npx nx lint:changed twenty-front --configuration=fix # Auto-fix changed files
|
||||
npx nx lint:changed twenty-front --configuration=staged # Lint staged files
|
||||
|
||||
# Run target for all projects (slower)
|
||||
npx nx run-many --target=build --all
|
||||
npx nx run-many --target=test --projects=twenty-front,twenty-server
|
||||
|
||||
@@ -43,12 +49,19 @@ npx nx g @nx/react:component my-component
|
||||
}
|
||||
```
|
||||
|
||||
## Linting Strategy
|
||||
For faster development, always prefer linting only changed files:
|
||||
- Use `npx nx lint:changed <project>` to lint only files changed vs main branch
|
||||
- Use `--configuration=fix` to auto-fix issues in changed files
|
||||
- Use `--configuration=staged` to lint only staged files (useful for pre-commit hooks)
|
||||
- Only use `npx nx lint <project>` when you need to lint the entire project
|
||||
|
||||
## Dependency Graph
|
||||
```bash
|
||||
# View project dependencies
|
||||
npx nx graph
|
||||
|
||||
# Check what's affected by changes
|
||||
# Check what's affected by changes (runs target on affected projects)
|
||||
npx nx affected --target=test
|
||||
npx nx affected --target=build --base=main
|
||||
```
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
task: [lint, typecheck, test, build]
|
||||
task: [lint, typecheck, test]
|
||||
steps:
|
||||
- name: Cancel Previous Runs
|
||||
uses: styfle/cancel-workflow-action@0.11.0
|
||||
@@ -39,6 +39,8 @@ jobs:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
- name: Build
|
||||
run: npx nx build create-twenty-app
|
||||
- name: Run ${{ matrix.task }} task
|
||||
uses: ./.github/workflows/actions/nx-affected
|
||||
with:
|
||||
|
||||
@@ -78,7 +78,7 @@ jobs:
|
||||
- name: Setup environment files
|
||||
run: |
|
||||
cp packages/twenty-front/.env.example packages/twenty-front/.env
|
||||
npx nx reset:env twenty-server
|
||||
npx nx reset:env:e2e-testing-server twenty-server
|
||||
|
||||
- name: Build frontend
|
||||
run: NODE_ENV=production NODE_OPTIONS="--max-old-space-size=10240" npx nx build twenty-front
|
||||
@@ -103,7 +103,7 @@ jobs:
|
||||
npm_config_yes=true npx serve -s packages/twenty-front/build -l 3001 &
|
||||
echo "Waiting for frontend to be ready..."
|
||||
timeout 60 bash -c 'until curl -s http://localhost:3001; do sleep 2; done'
|
||||
|
||||
|
||||
- name: Start worker
|
||||
run: |
|
||||
npx nx run twenty-server:worker &
|
||||
|
||||
@@ -185,6 +185,28 @@ jobs:
|
||||
uses: ./.github/workflows/actions/save-cache
|
||||
with:
|
||||
key: ${{ steps.restore-task-cache.outputs.cache-primary-key }}
|
||||
front-build:
|
||||
needs: changed-files-check
|
||||
if: needs.changed-files-check.outputs.any_changed == 'true'
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
NODE_OPTIONS: "--max-old-space-size=10240"
|
||||
steps:
|
||||
- name: Cancel Previous Runs
|
||||
uses: styfle/cancel-workflow-action@0.11.0
|
||||
with:
|
||||
access_token: ${{ github.token }}
|
||||
- name: Fetch custom Github Actions and base branch history
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
- name: Front / Write .env
|
||||
run: npx nx reset:env twenty-front
|
||||
- name: Build frontend
|
||||
run: npx nx build twenty-front
|
||||
ci-front-status-check:
|
||||
if: always() && !cancelled()
|
||||
timeout-minutes: 5
|
||||
@@ -193,6 +215,7 @@ jobs:
|
||||
[
|
||||
changed-files-check,
|
||||
front-task,
|
||||
front-build,
|
||||
front-chromatic-deployment,
|
||||
merge-reports-and-check-coverage,
|
||||
front-sb-test,
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
task: [lint, typecheck, test, build]
|
||||
task: [lint, typecheck, test]
|
||||
steps:
|
||||
- name: Cancel Previous Runs
|
||||
uses: styfle/cancel-workflow-action@0.11.0
|
||||
@@ -39,6 +39,8 @@ jobs:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
- name: Build
|
||||
run: npx nx build twenty-sdk
|
||||
- name: Run ${{ matrix.task }} task
|
||||
uses: ./.github/workflows/actions/nx-affected
|
||||
with:
|
||||
|
||||
@@ -171,7 +171,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4, 5]
|
||||
shard: [1, 2, 3, 4, 5, 6]
|
||||
services:
|
||||
postgres:
|
||||
image: twentycrm/twenty-postgres-spilo
|
||||
@@ -209,7 +209,7 @@ jobs:
|
||||
ANALYTICS_ENABLED: true
|
||||
CLICKHOUSE_URL: "http://default:clickhousePassword@localhost:8123/twenty"
|
||||
CLICKHOUSE_PASSWORD: clickhousePassword
|
||||
SHARD_COUNTER: 5
|
||||
SHARD_COUNTER: 6
|
||||
steps:
|
||||
- name: Fetch custom Github Actions and base branch history
|
||||
uses: actions/checkout@v4
|
||||
|
||||
Vendored
+1
-1
@@ -67,7 +67,7 @@
|
||||
"--config",
|
||||
"./jest-integration.config.ts",
|
||||
"${relativeFile}",
|
||||
"--silent=false"
|
||||
"--silent=false",
|
||||
],
|
||||
"cwd": "${workspaceFolder}/packages/twenty-server",
|
||||
"console": "integratedTerminal",
|
||||
|
||||
@@ -36,10 +36,16 @@ When testing the UI end to end, click on "Continue with Email" and use the prefi
|
||||
|
||||
### Code Quality
|
||||
```bash
|
||||
# Linting
|
||||
npx nx lint twenty-front # Frontend linting
|
||||
npx nx lint twenty-server # Backend linting
|
||||
npx nx lint twenty-front --fix # Auto-fix linting issues
|
||||
# Linting (changed files only - fastest)
|
||||
npx nx lint:changed twenty-front # Lint only changed files vs main
|
||||
npx nx lint:changed twenty-server # Lint only changed files vs main
|
||||
npx nx lint:changed twenty-front --configuration=fix # Auto-fix changed files
|
||||
npx nx lint:changed twenty-front --configuration=staged # Lint staged files only
|
||||
|
||||
# Linting (full project)
|
||||
npx nx lint twenty-front # Lint all files in frontend
|
||||
npx nx lint twenty-server # Lint all files in backend
|
||||
npx nx lint twenty-front --fix # Auto-fix all linting issues
|
||||
|
||||
# Type checking
|
||||
npx nx typecheck twenty-front
|
||||
|
||||
@@ -75,10 +75,6 @@ export default [
|
||||
sourceTag: 'scope:frontend',
|
||||
onlyDependOnLibsWithTags: ['scope:shared', 'scope:frontend'],
|
||||
},
|
||||
{
|
||||
sourceTag: 'scope:zapier',
|
||||
onlyDependOnLibsWithTags: ['scope:shared'],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
+489
-4
@@ -128,6 +128,375 @@ export default [
|
||||
additionalHooks: 'useRecoilCallback',
|
||||
},
|
||||
],
|
||||
// Lingui - detect untranslated strings
|
||||
'lingui/no-unlocalized-strings': [
|
||||
'error',
|
||||
{
|
||||
ignore: [
|
||||
// Ignore strings which are a single "word" (no spaces) and don't start with uppercase
|
||||
'^(?![A-Z])\\S+$',
|
||||
// Ignore UPPERCASE literals (constants, env vars)
|
||||
'^[A-Z0-9_-]+$',
|
||||
// Ignore strings that look like code/technical (contain special chars)
|
||||
'^[\\s]*$', // whitespace only
|
||||
'.*[{}/<>].*', // contains code-like characters
|
||||
'^\\d+(\\.\\d+)?(px|rem|em|%|vh|vw|s|ms)?$', // CSS units
|
||||
'^[\\d.]+(px|rem|em|%|vh|vw|fr|s|ms)?(\\s+[\\d.]+(px|rem|em|%|vh|vw|fr|s|ms)?)*$', // CSS values like "200px 1fr 20px"
|
||||
'^#[0-9a-fA-F]{3,8}$', // hex colors
|
||||
'^rgba?\\(.*\\)$', // rgb/rgba colors
|
||||
'^(auto|none|inherit|initial|unset|flex|grid|block|inline|inline-block|relative|absolute|fixed|sticky)$', // CSS keywords
|
||||
'^color:.*$', // CSS color declarations
|
||||
'^font-.*$', // CSS font declarations
|
||||
'^\\d+$', // numbers only
|
||||
'^https?:\\/\\/.*', // URLs
|
||||
'^@.*', // @ mentions or decorators
|
||||
'^\\/.*', // paths starting with /
|
||||
'^[HhMmSsYyDdAaPp:.,\\s-]+$', // date format patterns (HH:mm, yyyy-MM-dd, etc.)
|
||||
'^Arrow(Up|Down|Left|Right)$', // keyboard keys
|
||||
'^(Enter|Escape|Tab|Space|Backspace|Delete)$', // keyboard keys
|
||||
'^Text$', // clipboard data type
|
||||
'^(allow-|sandbox)', // iframe sandbox values
|
||||
'^Id$', // technical identifier suffix (e.g., fieldNameId)
|
||||
'^(string|number|boolean|void|any|unknown|never|object)$', // TypeScript type keywords
|
||||
'^(Dark|Light)$', // color schemes
|
||||
'^translate\\(.*\\)$', // CSS transform strings
|
||||
'^svg .*$', // CSS selectors
|
||||
'^Icon[A-Z]\\w*$', // Icon names like IconDefault, IconTable, IconSettings
|
||||
'^\\w*Icon$', // Icon names that end with Icon like FieldIcon
|
||||
'^%c.*$', // Console format strings
|
||||
// Common item IDs for selectable lists
|
||||
'^(Group|CalendarView|CalendarDateField|Compact view)$',
|
||||
'^(Layout|Visibility|Fields|Delete view|Copy link to view|Create custom view)$',
|
||||
'^(GroupBy|Sort|HideEmptyGroups|HiddenGroups)$',
|
||||
// HTTP headers and auth (technical, not user-facing)
|
||||
'^Authorization$',
|
||||
'^Bearer .*',
|
||||
// Allow object keys that are technical identifiers
|
||||
'^(topLeft|topRight|bottomLeft|bottomRight)$',
|
||||
// Color schemes and CSS media queries
|
||||
'^System$',
|
||||
'^\\(prefers-color-scheme:',
|
||||
// GraphQL query names (used in refetchQueries)
|
||||
'^Get[A-Z]\\w*$',
|
||||
// React Context names (technical identifiers)
|
||||
'.*Context$',
|
||||
// SVG paths (geometric coordinates, not translatable)
|
||||
'^M[0-9 LML]+$',
|
||||
'^[ML][0-9 ]+$',
|
||||
// Database ordering values (technical, backend API)
|
||||
'^(Asc|Desc)Nulls(First|Last)$',
|
||||
// Calendar response status values (backend enum values, not user-facing)
|
||||
'^(Yes|No|Maybe)$',
|
||||
// Email validation error prefixes (combined with dynamic content)
|
||||
'^Invalid email(s)?:',
|
||||
// GraphQL type construction patterns
|
||||
'.*FilterInput$',
|
||||
'.*OrderByInput.*',
|
||||
'^\\$filter.*',
|
||||
'^\\$orderBy.*',
|
||||
'^\\$after.*',
|
||||
'^\\$before.*',
|
||||
'^\\$first.*',
|
||||
'^\\$last.*',
|
||||
// Logger names (technical identifiers)
|
||||
'^Twenty(-\\w+)?$',
|
||||
// Cookie names and cookie string patterns
|
||||
'^twenty_session_id$',
|
||||
'^; domain=',
|
||||
// Context names for createRequiredContext
|
||||
'^[A-Z][a-zA-Z]+$',
|
||||
// JSON-like filter patterns
|
||||
'^%"type":',
|
||||
'^%"objectNameSingular":',
|
||||
],
|
||||
ignoreNames: [
|
||||
// HTML/React attributes that shouldn't be translated
|
||||
{ regex: { pattern: 'className', flags: 'i' } },
|
||||
{ regex: { pattern: 'styleName', flags: 'i' } },
|
||||
{ regex: { pattern: 'testId', flags: 'i' } },
|
||||
'data-testid',
|
||||
'dataTestId',
|
||||
'src',
|
||||
'srcSet',
|
||||
'href',
|
||||
'target',
|
||||
'rel',
|
||||
'type',
|
||||
'id',
|
||||
'key',
|
||||
'name',
|
||||
'htmlFor',
|
||||
'width',
|
||||
'height',
|
||||
'fill',
|
||||
'stroke',
|
||||
'viewBox',
|
||||
'clipPath',
|
||||
'd', // SVG path
|
||||
'transform',
|
||||
'displayName',
|
||||
'defaultValue',
|
||||
'to', // router links
|
||||
'path',
|
||||
'pathname',
|
||||
'hash',
|
||||
'componentInstanceId',
|
||||
'hotkeyScope',
|
||||
'dropdownId',
|
||||
'recoilScopeId',
|
||||
'modalId',
|
||||
'dialogId',
|
||||
'itemId',
|
||||
'selectableItemIdArray',
|
||||
'listenerId',
|
||||
'focusId',
|
||||
'color', // color prop values
|
||||
'variant', // component variants
|
||||
'size', // size prop values
|
||||
'position', // position values
|
||||
'align', // alignment values
|
||||
'justify', // justification values
|
||||
'direction', // direction values
|
||||
'orientation', // orientation values
|
||||
'status', // status values
|
||||
'state', // state values
|
||||
'mode', // mode values
|
||||
'accent', // accent values
|
||||
|
||||
// CSS-related props
|
||||
'gridAutoColumns',
|
||||
'gridAutoRows',
|
||||
'gridTemplateColumns',
|
||||
'gridTemplateRows',
|
||||
'gridColumn',
|
||||
'gridRow',
|
||||
'gap',
|
||||
'margin',
|
||||
'padding',
|
||||
'border',
|
||||
'borderRadius',
|
||||
'boxShadow',
|
||||
'flex',
|
||||
'flexDirection',
|
||||
'flexWrap',
|
||||
'justifyContent',
|
||||
'alignItems',
|
||||
'alignContent',
|
||||
'overflow',
|
||||
'display',
|
||||
'cursor',
|
||||
'zIndex',
|
||||
'opacity',
|
||||
'fontWeight',
|
||||
'fontSize',
|
||||
'lineHeight',
|
||||
'textAlign',
|
||||
'textDecoration',
|
||||
'whiteSpace',
|
||||
'wordBreak',
|
||||
'objectFit',
|
||||
'backgroundSize',
|
||||
'backgroundPosition',
|
||||
'minWidth',
|
||||
'maxWidth',
|
||||
'minHeight',
|
||||
'maxHeight',
|
||||
'mobileGridAutoColumns',
|
||||
'tabletGridAutoColumns',
|
||||
|
||||
// Styled components
|
||||
'css',
|
||||
'theme',
|
||||
'animation',
|
||||
'transition',
|
||||
|
||||
// GraphQL
|
||||
'query',
|
||||
'mutation',
|
||||
'subscription',
|
||||
'fragment',
|
||||
'operationName',
|
||||
'variables',
|
||||
'__typename',
|
||||
|
||||
// Technical identifiers
|
||||
'fieldName',
|
||||
'columnName',
|
||||
'objectNameSingular',
|
||||
'objectNamePlural',
|
||||
'metadataId',
|
||||
'nameSingular',
|
||||
'namePlural',
|
||||
|
||||
// Event types
|
||||
'eventName',
|
||||
'event',
|
||||
'action',
|
||||
'actionType',
|
||||
|
||||
// Icon names
|
||||
'iconName',
|
||||
{ regex: { pattern: '^Icon[A-Z]' } },
|
||||
|
||||
// UPPER_CASE names (constants)
|
||||
{ regex: { pattern: '^[A-Z][A-Z0-9_]*$' } },
|
||||
|
||||
// Sort direction values (backend API)
|
||||
'orderBy',
|
||||
{ regex: { pattern: '^(Asc|Desc)(NullsFirst|NullsLast)?$' } },
|
||||
|
||||
// HTTP headers (technical, not user-facing)
|
||||
'Authorization',
|
||||
],
|
||||
ignoreFunctions: [
|
||||
// Console and logging
|
||||
'console.*',
|
||||
'*.log',
|
||||
'*.warn',
|
||||
'*.error',
|
||||
'*.debug',
|
||||
'*.info',
|
||||
'*.trace',
|
||||
'logDebug',
|
||||
'formatTitle',
|
||||
|
||||
// Error handling (technical messages, not user-facing)
|
||||
'Error',
|
||||
'TypeError',
|
||||
'RangeError',
|
||||
'SyntaxError',
|
||||
'throw',
|
||||
'assertUnreachable',
|
||||
'CustomError',
|
||||
'parseInitialBlocknote',
|
||||
|
||||
// Testing
|
||||
'describe',
|
||||
'it',
|
||||
'test',
|
||||
'expect',
|
||||
'jest.*',
|
||||
'*.toBe',
|
||||
'*.toEqual',
|
||||
'*.toContain',
|
||||
'*.toMatch',
|
||||
'*.toThrow',
|
||||
|
||||
// React/Libraries internals
|
||||
'require',
|
||||
'import',
|
||||
'styled',
|
||||
'styled.*',
|
||||
'css',
|
||||
'keyframes',
|
||||
'createGlobalStyle',
|
||||
|
||||
// Router
|
||||
'useNavigate',
|
||||
'navigate',
|
||||
'useLocation',
|
||||
'useParams',
|
||||
|
||||
// Date formatting (patterns are not translatable)
|
||||
'format',
|
||||
'formatDate',
|
||||
'formatDateTime',
|
||||
'formatTime',
|
||||
'parseISO',
|
||||
'parse',
|
||||
|
||||
// Navigation
|
||||
'useNavigationSection',
|
||||
|
||||
// Recoil
|
||||
'atom',
|
||||
'atomFamily',
|
||||
'selector',
|
||||
'selectorFamily',
|
||||
'useSetRecoilState',
|
||||
'useRecoilState',
|
||||
'useRecoilValue',
|
||||
|
||||
// GraphQL operations
|
||||
'gql',
|
||||
'useQuery',
|
||||
'useMutation',
|
||||
'useLazyQuery',
|
||||
'useSubscription',
|
||||
|
||||
// Type checking and validation
|
||||
'*.includes',
|
||||
'*.indexOf',
|
||||
'*.startsWith',
|
||||
'*.endsWith',
|
||||
'*.split',
|
||||
'*.join',
|
||||
'*.match',
|
||||
'*.replace',
|
||||
'*.test',
|
||||
'Object.keys',
|
||||
'Object.values',
|
||||
'Object.entries',
|
||||
'Array.isArray',
|
||||
|
||||
// DOM operations
|
||||
'*.getElementById',
|
||||
'*.getElementsByClassName',
|
||||
'*.querySelector',
|
||||
'*.querySelectorAll',
|
||||
'*.getAttribute',
|
||||
'*.setAttribute',
|
||||
'*.addEventListener',
|
||||
'*.removeEventListener',
|
||||
'*.dispatchEvent',
|
||||
'*.createElement',
|
||||
|
||||
// Storage
|
||||
'localStorage.*',
|
||||
'sessionStorage.*',
|
||||
'searchParams.*',
|
||||
'*.get',
|
||||
'*.set',
|
||||
'*.has',
|
||||
'*.delete',
|
||||
|
||||
// Misc utilities
|
||||
'cva',
|
||||
'cn',
|
||||
'clsx',
|
||||
'classNames',
|
||||
'track',
|
||||
'*.postMessage',
|
||||
'*.dispatch',
|
||||
'*.commit',
|
||||
|
||||
// Event handlers (typically receive enum values, not user-facing text)
|
||||
'onChange',
|
||||
'onClick',
|
||||
'onSelect',
|
||||
'onSubmit',
|
||||
'onFocus',
|
||||
'onBlur',
|
||||
'onKeyDown',
|
||||
'onKeyUp',
|
||||
'onMouseEnter',
|
||||
'onMouseLeave',
|
||||
|
||||
// Logging functions (technical messages, not user-facing)
|
||||
'logError',
|
||||
'logDebug',
|
||||
'logInfo',
|
||||
'logWarn',
|
||||
'loggerLink',
|
||||
|
||||
// Context creation (technical names)
|
||||
'createRequiredContext',
|
||||
|
||||
// GraphQL refetch queries (technical identifiers)
|
||||
'refetchQueries',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -175,7 +544,7 @@ export default [
|
||||
'@typescript-eslint/ban-ts-comment': 'error',
|
||||
'@typescript-eslint/consistent-type-imports': [
|
||||
'error',
|
||||
{
|
||||
{
|
||||
prefer: 'type-imports',
|
||||
fixStyle: 'inline-type-imports'
|
||||
},
|
||||
@@ -208,11 +577,115 @@ export default [
|
||||
},
|
||||
},
|
||||
|
||||
// Storybook files
|
||||
// Storybook files and story-related files
|
||||
{
|
||||
files: ['*.stories.@(ts|tsx|js|jsx)'],
|
||||
files: [
|
||||
'**/*.stories.ts',
|
||||
'**/*.stories.tsx',
|
||||
'**/*.stories.js',
|
||||
'**/*.stories.jsx',
|
||||
'**/__stories__/**/*',
|
||||
],
|
||||
rules: {
|
||||
'@typescript-eslint/no-non-null-assertion': 'off',
|
||||
'lingui/no-unlocalized-strings': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
// Debug files - development only, not user-facing
|
||||
{
|
||||
files: [
|
||||
'**/Debug*.tsx',
|
||||
'**/*Debug*.tsx',
|
||||
'**/*DebugDisplay*.tsx',
|
||||
'**/*DebugHelper*.tsx',
|
||||
'**/*DebugObserver*.tsx',
|
||||
],
|
||||
rules: {
|
||||
'lingui/no-unlocalized-strings': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
// Testing utilities and mock data - not user-facing
|
||||
{
|
||||
files: [
|
||||
'**/testing/**/*.tsx',
|
||||
'**/testing/**/*.ts',
|
||||
'**/__mocks__/**/*',
|
||||
'**/*mock*.ts',
|
||||
'**/*Mock*.ts',
|
||||
'**/perf/**/*',
|
||||
],
|
||||
rules: {
|
||||
'lingui/no-unlocalized-strings': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
// Constants files - technical values, not user-facing
|
||||
{
|
||||
files: [
|
||||
'**/constants/**/*.ts',
|
||||
'**/*.constants.ts',
|
||||
'**/validation-schemas/**/*.ts',
|
||||
'**/*Schema.ts',
|
||||
'**/*-schema.ts',
|
||||
],
|
||||
rules: {
|
||||
'lingui/no-unlocalized-strings': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
// Layout configuration files - titles are translated at consumption time
|
||||
{
|
||||
files: ['**/layouts/**/*.ts'],
|
||||
rules: {
|
||||
'lingui/no-unlocalized-strings': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
// Service files - contain technical strings (logger names, HTTP headers, etc.)
|
||||
{
|
||||
files: ['**/services/**/*.ts'],
|
||||
rules: {
|
||||
'lingui/no-unlocalized-strings': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
// State files - contain technical default values
|
||||
{
|
||||
files: ['**/states/**/*.ts'],
|
||||
rules: {
|
||||
'lingui/no-unlocalized-strings': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
// Utility files - technical/developer-facing
|
||||
{
|
||||
files: [
|
||||
'**/utils/**/*.ts',
|
||||
'**/*Utils.ts',
|
||||
'**/*-utils.ts',
|
||||
'**/*Util.ts',
|
||||
'**/*-util.ts',
|
||||
'**/errors/**/*.ts',
|
||||
'**/*Error.ts',
|
||||
'**/*-error.ts',
|
||||
],
|
||||
rules: {
|
||||
'lingui/no-unlocalized-strings': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
// Config and setup files - not user-facing
|
||||
{
|
||||
files: [
|
||||
'**/*.config.ts',
|
||||
'**/*.config.js',
|
||||
'**/vite.config.ts',
|
||||
'**/.storybook/**/*',
|
||||
],
|
||||
rules: {
|
||||
'lingui/no-unlocalized-strings': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -250,7 +723,18 @@ export default [
|
||||
// Test files
|
||||
{
|
||||
files: [
|
||||
'*.test.@(ts|tsx|js|jsx)',
|
||||
'**/*.test.ts',
|
||||
'**/*.test.tsx',
|
||||
'**/*.test.js',
|
||||
'**/*.test.jsx',
|
||||
'**/*.spec.ts',
|
||||
'**/*.spec.tsx',
|
||||
'**/*.spec.js',
|
||||
'**/*.spec.jsx',
|
||||
'**/__tests__/**/*.ts',
|
||||
'**/__tests__/**/*.tsx',
|
||||
'**/__mocks__/**/*.ts',
|
||||
'**/__mocks__/**/*.tsx',
|
||||
],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
@@ -266,6 +750,7 @@ export default [
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/no-non-null-assertion': 'off',
|
||||
'lingui/no-unlocalized-strings': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-twenty-app",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.2",
|
||||
"description": "Command-line interface to create Twenty application",
|
||||
"main": "dist/cli.cjs",
|
||||
"bin": "dist/cli.cjs",
|
||||
|
||||
@@ -127,7 +127,7 @@ const createPackageJson = async ({
|
||||
auth: 'twenty auth login',
|
||||
},
|
||||
dependencies: {
|
||||
'twenty-sdk': '0.2.0',
|
||||
'twenty-sdk': '0.2.2',
|
||||
},
|
||||
devDependencies: {
|
||||
'@types/node': '^24.7.2',
|
||||
|
||||
@@ -17,7 +17,7 @@ FIREFLIES_API_KEY=your_fireflies_api_key_here
|
||||
# Fireflies plan level - affects which fields are available
|
||||
# Options: free, pro, business, enterprise
|
||||
# This controls which GraphQL fields are requested to avoid 403 errors
|
||||
FIREFLIES_PLAN_LEVEL=pro
|
||||
FIREFLIES_PLAN=free
|
||||
|
||||
# Twenty CRM API key for authentication
|
||||
# Generate this from your Twenty instance: Settings > Developers > API Keys
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
.yarn
|
||||
Binary file not shown.
@@ -1,5 +1,76 @@
|
||||
# Changelog
|
||||
|
||||
## [0.3.1] - 2025-12-08
|
||||
|
||||
Import all
|
||||
|
||||
### Added
|
||||
- Historical import CLI: `yarn meeting:all` to fetch and insert historical Fireflies meetings with filters (date range, organizers, participants, channel, mine) and dry-run support.
|
||||
- Fireflies transcripts listing with pagination and date filtering to support bulk imports.
|
||||
|
||||
### Changed
|
||||
- Deduplication now checks `firefliesMeetingId` before creating meetings (webhook + bulk).
|
||||
- Shared historical importer pipeline reusing existing note/meeting formatting.
|
||||
|
||||
## [0.3.0] - 2025-12-08
|
||||
|
||||
Subscription-based query / Full transcript and AI notes for Pro+ / More
|
||||
|
||||
### Added
|
||||
- **Full transcript capture**: Meeting object now stores the complete meeting transcript with speaker names and timestamps (`transcript` field)
|
||||
- **Rich AI meeting notes**: Captures detailed AI-generated meeting notes from Fireflies (`notes` field with 7,000+ char summaries)
|
||||
- **Expanded summary fields**: Now fetches all available Fireflies summary data:
|
||||
- `notes` - Detailed AI-generated meeting notes with timestamps and section headers
|
||||
- `bullet_gist` - Emoji-enhanced bullet point summaries
|
||||
- `outline` / `shorthand_bullet` - Timestamped meeting outline
|
||||
- `gist` - One-sentence meeting summary
|
||||
- `short_summary` - Single paragraph summary
|
||||
- `short_overview` - Brief overview
|
||||
- **New Meeting fields**:
|
||||
- `transcript` - Full meeting transcript with speaker attribution
|
||||
- `notes` - AI-generated detailed notes
|
||||
- `audioUrl` - Link to audio recording (Pro+)
|
||||
- `videoUrl` - Link to video recording (Business+)
|
||||
- `meetingLink` - Original meeting link
|
||||
- `neutralPercent` - Neutral sentiment percentage
|
||||
- **Meeting delete utility**: New `yarn meeting:delete <meetingId>` script for cleanup and re-import
|
||||
- **Debug meeting utility**: New `scripts/debug-meeting.ts` to inspect raw Fireflies API responses
|
||||
|
||||
### Changed
|
||||
- **Plan-based GraphQL queries**: Completely redesigned query system with three tiers:
|
||||
- **Free**: Basic fields only (title, date, duration, participants, transcript_url, meeting_link)
|
||||
- **Pro**: Adds full transcript (`sentences`), summary fields, speakers, audio_url
|
||||
- **Business+**: Adds analytics, video_url, speaker stats, meeting metrics
|
||||
- **Action items parsing**: Fixed parsing of `action_items` which Fireflies returns as newline-separated string, not array
|
||||
- **Note body format**: Enhanced with Meeting Notes, Outline, Key Points sections from rich Fireflies data
|
||||
- **Import status**: Added `PARTIAL` status for imports missing summary/analytics data
|
||||
|
||||
### Fixed
|
||||
- Missing `notes` and `bullet_gist` fields in data transform (were fetched but not passed through)
|
||||
- Proper fallback: Uses `shorthand_bullet` when `outline` is empty (Fireflies stores outline content there)
|
||||
- Summary readiness detection now checks `notes` field in addition to `overview` and `action_items`
|
||||
|
||||
### Documentation
|
||||
- Updated README with complete API access comparison table by subscription plan
|
||||
- Documented all available Fireflies summary fields and their plan requirements
|
||||
|
||||
## [0.2.3] - 2025-12-06
|
||||
|
||||
### Added
|
||||
- **Meeting ingest utility**: New `yarn meeting:ingest <meetingId>` script to manually fetch and import specific Fireflies meetings into Twenty
|
||||
- **Plan-based field selection**: Added `FIREFLIES_PLAN` configuration to control which GraphQL fields are requested based on subscription level (free, pro, business, enterprise)
|
||||
- **Main entry point**: New `src/index.ts` centralizing all exports for cleaner imports
|
||||
|
||||
### Changed
|
||||
- **Auth configuration**: Disabled authentication requirement for webhook route (`isAuthRequired: false`) to support serverless deployments
|
||||
- **Signature verification fallback**: Webhook handler now supports signature in payload body as fallback when HTTP headers aren't forwarded to serverless functions (production doesn't work for Fireflies webhook)
|
||||
- **Improved type safety**: Replaced `any` types with proper TypeScript types throughout codebase
|
||||
|
||||
### Enhanced
|
||||
- **Webhook debugging**: Added detailed debug output including param keys, header info, and signature comparison details
|
||||
- **Test webhook script**: Includes signature in both header and payload, with diagnostic output for header forwarding status
|
||||
- **Documentation**: Added README sections on current twenty headers forward limitations and utility scripts
|
||||
|
||||
## [0.2.2] - 2025-11-04
|
||||
|
||||
### Added
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
Automatically captures meeting notes with AI-generated summaries and insights from Fireflies.ai into your Twenty CRM.
|
||||
|
||||
### Current Status
|
||||
- Doesn't work with Fireflies webhook yet due to missing headers forwarding in twenty serverless func
|
||||
- Meeting ingestion utility scripts are available for individual meeting insertion and historical meetings with filters with yarn meeting:all
|
||||
|
||||
## Integration Overview
|
||||
|
||||
**Fireflies webhook → Fireflies API → Twenty CRM with summary-focused insights**
|
||||
@@ -19,19 +23,54 @@ Automatically captures meeting notes with AI-generated summaries and insights fr
|
||||
|
||||
## API Access by Subscription Plan
|
||||
|
||||
Fireflies API access varies significantly by subscription tier:
|
||||
Fireflies API access varies by subscription tier. This integration automatically adapts queries based on your plan and falls back gracefully if restrictions are encountered.
|
||||
|
||||
### Plan Comparison
|
||||
|
||||
| Feature | Free | Pro | Business | Enterprise |
|
||||
|---------|------|-----|----------|------------|
|
||||
| **API Rate Limit** | 50 requests/day | 50 requests/day | 60 requests/minute | 60 requests/minute |
|
||||
| **Storage** | 800 mins/seat | 8,000 mins/seat | Unlimited | Unlimited |
|
||||
| **AI Summaries** | Limited (20 credits) | Unlimited | Unlimited | Unlimited |
|
||||
| **Video Upload** | 100MB max | 1.5GB max | 1.5GB max | 1.5GB max |
|
||||
| **Advanced Features** | Basic transcription | AI apps, analytics | Team analytics, CI | Full API, SSO, compliance |
|
||||
|---------|:----:|:---:|:--------:|:----------:|
|
||||
| **API Rate Limit** | 50/day | 50/day | 60/min | 60/min |
|
||||
| **Basic Data** (title, date, duration) | ✅ | ✅ | ✅ | ✅ |
|
||||
| **Participants List** | ✅ | ✅ | ✅ | ✅ |
|
||||
| **Transcript URL** | ✅ | ✅ | ✅ | ✅ |
|
||||
| **Speakers** | ❌ | ✅ | ✅ | ✅ |
|
||||
| **Summary** (overview, keywords) | ❌ | ✅ | ✅ | ✅ |
|
||||
| **Audio URL** | ❌ | ✅ | ✅ | ✅ |
|
||||
| **Action Items** | ❌ | ❌ | ✅ | ✅ |
|
||||
| **Topics Discussed** | ❌ | ❌ | ✅ | ✅ |
|
||||
| **Video URL** | ❌ | ❌ | ✅ | ✅ |
|
||||
| **Sentiment Analytics** | ❌ | ❌ | ✅ | ✅ |
|
||||
| **Meeting Attendees (detailed)** | ❌ | ❌ | ✅ | ✅ |
|
||||
|
||||
**Key Design Pattern:** Subscription-based API access uses **tiered rate limiting** rather than feature gating. Lower tiers get severely restricted throughput (50/day vs 60/minute = 1,700x difference), making production integrations effectively require Business+ plans.
|
||||
### What You'll Get Per Plan
|
||||
|
||||
**Pro Plan Limitation:** Despite "unlimited" AI summaries, the 50 requests/day limit severely constrains production usage for meeting-heavy organizations.
|
||||
**Free Plan:**
|
||||
- Meeting title, date, duration
|
||||
- Participant names/emails (basic)
|
||||
- Link to transcript
|
||||
|
||||
**Pro Plan:**
|
||||
- Everything in Free, plus:
|
||||
- Speaker identification
|
||||
- AI summary (overview + keywords)
|
||||
- Audio recording URL
|
||||
|
||||
**Business Plan:**
|
||||
- Everything in Pro, plus:
|
||||
- Action items extraction
|
||||
- Topics discussed
|
||||
- Sentiment analysis (positive/negative/neutral %)
|
||||
- Video recording URL
|
||||
- Detailed meeting attendee info
|
||||
|
||||
### Configuration
|
||||
|
||||
Set your plan in `.env`:
|
||||
```bash
|
||||
FIREFLIES_PLAN=free # Options: free, pro, business, enterprise
|
||||
```
|
||||
|
||||
**Rate Limiting:** Free/Pro plans are limited to 50 API calls/day. The integration uses conservative retry settings by default to stay within limits.
|
||||
|
||||
## What Gets Captured
|
||||
|
||||
@@ -114,11 +153,6 @@ The `setup:fields` script adds 13 custom fields to store rich Fireflies data:
|
||||
| `firefliesMeetingId` | TEXT | Fireflies Meeting ID | Unique identifier from Fireflies |
|
||||
| `organizerEmail` | TEXT | Organizer Email | Email address of the meeting organizer |
|
||||
|
||||
Then re-sync:
|
||||
```bash
|
||||
npx twenty-cli app sync
|
||||
```
|
||||
|
||||
**Note:** Without custom fields, meetings will be created with just the title. The rich summary data will only be stored in Notes for 1-on-1 meetings.
|
||||
|
||||
## Configuration
|
||||
@@ -160,6 +194,29 @@ The integration uses **HMAC SHA-256 signature verification**:
|
||||
- Twenty verifies signature using your webhook secret
|
||||
- Invalid signatures are rejected immediately
|
||||
|
||||
### Current Platform Limitation (Headers)
|
||||
|
||||
- Twenty serverless route triggers currently do **not forward HTTP headers** to functions. Fireflies signatures sent in headers are stripped, so header-based verification does not work in production.
|
||||
- Workaround: the provided test script also includes the signature inside the payload; the handler falls back to that payload signature. Use this only for testing until header forwarding is supported.
|
||||
|
||||
## Utilities for meeting insertion (workarounds)
|
||||
|
||||
- Ingest a specific Fireflies meeting into Twenty:
|
||||
`yarn meeting:ingest <meetingId>` or `MEETING_ID=... yarn meeting:ingest`
|
||||
|
||||
- Fetch all/historical Fireflies meetings into Twenty:
|
||||
`yarn meeting:all [--from 2024-01-01] [--to 2024-02-01] [--organizer a@x.com] [--participant b@x.com] [--channel <channelId>] [--mine] [--dry-run]`
|
||||
|
||||
- Filters (combine as needed):
|
||||
- `--from` / `--to`: ISO or date string range filter
|
||||
- `--organizer` / `--participant`: comma-separated emails
|
||||
- `--channel`: Fireflies channel id
|
||||
- `--mine`: only meetings for the current Fireflies user
|
||||
- Controls:
|
||||
- `--dry-run`: list and transform without writing to Twenty
|
||||
- `--page-size`: pagination size (default 50)
|
||||
- `--max-records`: stop after N transcripts (default 500)
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
@@ -247,10 +304,7 @@ Client expressed strong interest in the enterprise plan.
|
||||
|
||||
## Future Implementation Opportunities
|
||||
|
||||
### Past Meetings Retrieval
|
||||
- **New trigger to retrieve past meetings from a contact** - Enable users to fetch historical meeting data from Fireflies for specific contacts, allowing retrospective capture and analysis of past interactions.
|
||||
|
||||
Next iteration would enhance the **intelligence layer** to:
|
||||
Next iterations would enhance the **intelligence layer** to:
|
||||
|
||||
### AI-Powered Insights
|
||||
- **Extract pain points, objections & buying signals** automatically from transcripts
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type ApplicationConfig } from 'twenty-sdk/application';
|
||||
import { type ApplicationConfig } from 'twenty-sdk';
|
||||
|
||||
const config: ApplicationConfig = {
|
||||
universalIdentifier: 'a4df0c0f-c65e-44e5-8436-24814182d4ac',
|
||||
@@ -9,17 +9,25 @@ const config: ApplicationConfig = {
|
||||
FIREFLIES_WEBHOOK_SECRET: {
|
||||
universalIdentifier: 'f51f7646-be9f-4ba9-9b75-160dd288cd0c',
|
||||
description: 'Secret key for verifying Fireflies webhook signatures',
|
||||
isSecret: true,
|
||||
//isSecret: true,
|
||||
value: '',
|
||||
},
|
||||
FIREFLIES_API_KEY: {
|
||||
universalIdentifier: 'faa41f07-b28e-4500-b1c0-ce4b3d27924c',
|
||||
description: 'Fireflies GraphQL API key used to fetch meeting summaries',
|
||||
isSecret: true,
|
||||
//isSecret: true,
|
||||
value: '',
|
||||
},
|
||||
FIREFLIES_PLAN: {
|
||||
universalIdentifier: '57dbb73c-aac5-4247-9fcc-a070bb669f16',
|
||||
description: 'Fireflies plan: free, pro, business, enterprise',
|
||||
value: 'free',
|
||||
},
|
||||
TWENTY_API_KEY: {
|
||||
universalIdentifier: '02756551-5bf7-4fb2-8e08-1f622008d305',
|
||||
description: 'Twenty API key used when running scripts locally',
|
||||
isSecret: true,
|
||||
//isSecret: true,
|
||||
value: '',
|
||||
},
|
||||
SERVER_URL: {
|
||||
universalIdentifier: '9b3a5e8e-5973-4e6b-a059-2966075652aa',
|
||||
@@ -36,6 +44,11 @@ const config: ApplicationConfig = {
|
||||
description: 'Log level: silent, error, warn, info, debug (default: error)',
|
||||
value: 'error',
|
||||
},
|
||||
CAPTURE_LOGS: {
|
||||
universalIdentifier: 'adbcc267-309d-49b2-af71-76f1299d863e',
|
||||
description: 'Capture logs in webhook response for debugging (true/false)',
|
||||
value: 'true',
|
||||
},
|
||||
FIREFLIES_SUMMARY_STRATEGY: {
|
||||
universalIdentifier: '562b43d9-cd47-4ec1-ae16-5cc7ebc9729b',
|
||||
description: 'Summary fetch strategy: immediate_only, immediate_with_retry, delayed_polling, or basic_only',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "fireflies",
|
||||
"version": "0.2.2",
|
||||
"version": "0.3.0",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^24.5.0",
|
||||
@@ -11,12 +11,15 @@
|
||||
"scripts": {
|
||||
"test": "jest",
|
||||
"setup:fields": "tsx scripts/add-meeting-fields.ts",
|
||||
"test:webhook": "tsx scripts/test-webhook.ts"
|
||||
"test:webhook": "tsx scripts/test-webhook.ts",
|
||||
"meeting:ingest": "tsx scripts/ingest-meeting.ts",
|
||||
"meeting:delete": "tsx scripts/delete-meeting.ts",
|
||||
"meeting:all": "tsx scripts/fetch-all-meetings.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.13.1",
|
||||
"dotenv": "^16.3.1",
|
||||
"twenty-sdk": "0.0.3"
|
||||
"dotenv": "^17.2.3",
|
||||
"twenty-sdk": "0.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.5",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "fireflies",
|
||||
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "packages/twenty-apps/fireflies/src",
|
||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "packages/twenty-apps/community/fireflies/src",
|
||||
"projectType": "application",
|
||||
"tags": [
|
||||
"scope:apps"
|
||||
@@ -13,7 +13,7 @@
|
||||
"{workspaceRoot}/coverage/{projectRoot}"
|
||||
],
|
||||
"options": {
|
||||
"jestConfig": "packages/twenty-apps/fireflies/jest.config.mjs",
|
||||
"jestConfig": "packages/twenty-apps/community/fireflies/jest.config.mjs",
|
||||
"passWithNoTests": true
|
||||
},
|
||||
"configurations": {
|
||||
@@ -36,7 +36,7 @@
|
||||
],
|
||||
"options": {
|
||||
"lintFilePatterns": [
|
||||
"packages/twenty-apps/fireflies/**/*.{ts,tsx,js,jsx}"
|
||||
"packages/twenty-apps/community/fireflies/**/*.{ts,tsx,js,jsx}"
|
||||
]
|
||||
},
|
||||
"configurations": {
|
||||
|
||||
@@ -50,7 +50,11 @@ interface FieldDefinition {
|
||||
options?: FieldOption[];
|
||||
}
|
||||
|
||||
// Meeting fields based on Fireflies GraphQL API transcript schema
|
||||
// See: https://docs.fireflies.ai/graphql-api/query/transcript
|
||||
// Note: Some fields require higher plans (Pro, Business, Enterprise)
|
||||
const MEETING_FIELDS: FieldDefinition[] = [
|
||||
// === Internal Twenty Relations ===
|
||||
{
|
||||
type: 'RELATION',
|
||||
name: 'note',
|
||||
@@ -59,11 +63,21 @@ const MEETING_FIELDS: FieldDefinition[] = [
|
||||
icon: 'IconNotes',
|
||||
isNullable: true,
|
||||
},
|
||||
|
||||
// === Basic Fields (All Plans) ===
|
||||
{
|
||||
type: 'TEXT',
|
||||
name: 'firefliesMeetingId',
|
||||
label: 'Fireflies ID',
|
||||
description: 'Unique transcript ID from Fireflies (maps to: id)',
|
||||
icon: 'IconKey',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
type: 'DATE_TIME',
|
||||
name: 'meetingDate',
|
||||
label: 'Meeting Date',
|
||||
description: 'Date and time when the meeting occurred',
|
||||
description: 'When the meeting occurred (maps to: date)',
|
||||
icon: 'IconCalendar',
|
||||
isNullable: true,
|
||||
},
|
||||
@@ -71,39 +85,107 @@ const MEETING_FIELDS: FieldDefinition[] = [
|
||||
type: 'NUMBER',
|
||||
name: 'duration',
|
||||
label: 'Duration (minutes)',
|
||||
description: 'Meeting duration in minutes',
|
||||
description: 'Meeting duration in minutes (maps to: duration)',
|
||||
icon: 'IconClock',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
type: 'TEXT',
|
||||
name: 'meetingType',
|
||||
label: 'Meeting Type',
|
||||
description: 'Type of meeting (e.g., Sales Call, Sprint Planning, 1:1)',
|
||||
icon: 'IconTag',
|
||||
name: 'organizerEmail',
|
||||
label: 'Organizer Email',
|
||||
description: 'Meeting organizer email (maps to: organizer_email)',
|
||||
icon: 'IconMail',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
type: 'LINKS',
|
||||
name: 'transcriptUrl',
|
||||
label: 'Transcript URL',
|
||||
description: 'Link to full transcript (maps to: transcript_url)',
|
||||
icon: 'IconFileText',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
type: 'LINKS',
|
||||
name: 'meetingLink',
|
||||
label: 'Meeting Link',
|
||||
description: 'Original meeting link (maps to: meeting_link)',
|
||||
icon: 'IconLink',
|
||||
isNullable: true,
|
||||
},
|
||||
|
||||
// === Pro+ Fields (summary, speakers, audio_url, transcript) ===
|
||||
{
|
||||
type: 'TEXT',
|
||||
name: 'transcript',
|
||||
label: 'Full Transcript',
|
||||
description: 'Full meeting transcript with speaker names and timestamps [Pro+]',
|
||||
icon: 'IconFileText',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
type: 'TEXT',
|
||||
name: 'overview',
|
||||
label: 'Overview',
|
||||
description: 'AI-generated meeting summary (maps to: summary.overview) [Pro+]',
|
||||
icon: 'IconFileDescription',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
type: 'TEXT',
|
||||
name: 'notes',
|
||||
label: 'AI Notes',
|
||||
description: 'Detailed AI-generated meeting notes (maps to: summary.notes) [Pro+]',
|
||||
icon: 'IconNotes',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
type: 'TEXT',
|
||||
name: 'keywords',
|
||||
label: 'Keywords',
|
||||
description: 'Key topics and themes discussed (comma-separated)',
|
||||
description: 'Key topics extracted (maps to: summary.keywords) [Pro+]',
|
||||
icon: 'IconTags',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
type: 'LINKS',
|
||||
name: 'audioUrl',
|
||||
label: 'Audio URL',
|
||||
description: 'Link to audio recording (maps to: audio_url) [Pro+]',
|
||||
icon: 'IconHeadphones',
|
||||
isNullable: true,
|
||||
},
|
||||
|
||||
// === Business+ Fields (analytics, video_url, full summary) ===
|
||||
{
|
||||
type: 'TEXT',
|
||||
name: 'meetingType',
|
||||
label: 'Meeting Type',
|
||||
description: 'AI-detected meeting type (maps to: summary.meeting_type) [Business+]',
|
||||
icon: 'IconTag',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
type: 'TEXT',
|
||||
name: 'topics',
|
||||
label: 'Topics Discussed',
|
||||
description: 'Topics covered in meeting (maps to: summary.topics_discussed) [Business+]',
|
||||
icon: 'IconListDetails',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
type: 'NUMBER',
|
||||
name: 'sentimentScore',
|
||||
label: 'Sentiment Score',
|
||||
description: 'Overall meeting sentiment (0-1 scale, where 1 is most positive)',
|
||||
icon: 'IconMoodSmile',
|
||||
name: 'actionItemsCount',
|
||||
label: 'Action Items',
|
||||
description: 'Number of action items (count of: summary.action_items) [Business+]',
|
||||
icon: 'IconCheckbox',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
type: 'NUMBER',
|
||||
name: 'positivePercent',
|
||||
label: 'Positive %',
|
||||
description: 'Percentage of positive sentiment in conversation',
|
||||
description: 'Positive sentiment % (maps to: analytics.sentiments.positive_pct) [Business+]',
|
||||
icon: 'IconThumbUp',
|
||||
isNullable: true,
|
||||
},
|
||||
@@ -111,69 +193,48 @@ const MEETING_FIELDS: FieldDefinition[] = [
|
||||
type: 'NUMBER',
|
||||
name: 'negativePercent',
|
||||
label: 'Negative %',
|
||||
description: 'Percentage of negative sentiment in conversation',
|
||||
description: 'Negative sentiment % (maps to: analytics.sentiments.negative_pct) [Business+]',
|
||||
icon: 'IconThumbDown',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
type: 'NUMBER',
|
||||
name: 'actionItemsCount',
|
||||
label: 'Action Items',
|
||||
description: 'Number of action items identified',
|
||||
icon: 'IconCheckbox',
|
||||
name: 'neutralPercent',
|
||||
label: 'Neutral %',
|
||||
description: 'Neutral sentiment % (maps to: analytics.sentiments.neutral_pct) [Business+]',
|
||||
icon: 'IconMoodNeutral',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
type: 'LINKS',
|
||||
name: 'transcriptUrl',
|
||||
label: 'Transcript URL',
|
||||
description: 'Link to full transcript in Fireflies',
|
||||
icon: 'IconFileText',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
type: 'LINKS',
|
||||
name: 'recordingUrl',
|
||||
label: 'Recording URL',
|
||||
description: 'Link to video/audio recording in Fireflies',
|
||||
name: 'videoUrl',
|
||||
label: 'Video URL',
|
||||
description: 'Link to video recording (maps to: video_url) [Business+]',
|
||||
icon: 'IconVideo',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
type: 'TEXT',
|
||||
name: 'firefliesMeetingId',
|
||||
label: 'Fireflies Meeting ID',
|
||||
description: 'Unique identifier from Fireflies',
|
||||
icon: 'IconKey',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
type: 'TEXT',
|
||||
name: 'organizerEmail',
|
||||
label: 'Organizer Email',
|
||||
description: 'Email address of the meeting organizer',
|
||||
icon: 'IconMail',
|
||||
isNullable: true,
|
||||
},
|
||||
|
||||
// === Import Tracking Fields (Internal) ===
|
||||
{
|
||||
type: 'SELECT',
|
||||
name: 'importStatus',
|
||||
label: 'Import Status',
|
||||
description: 'Status of the meeting import from Fireflies',
|
||||
description: 'Status of the Fireflies import',
|
||||
icon: 'IconCheck',
|
||||
isNullable: true,
|
||||
options: [
|
||||
{ value: 'SUCCESS', label: 'Success', position: 0, color: 'green' },
|
||||
{ value: 'FAILED', label: 'Failed', position: 1, color: 'red' },
|
||||
{ value: 'PENDING', label: 'Pending', position: 2, color: 'yellow' },
|
||||
{ value: 'RETRYING', label: 'Retrying', position: 3, color: 'orange' },
|
||||
{ value: 'PARTIAL', label: 'Partial', position: 1, color: 'blue' },
|
||||
{ value: 'FAILED', label: 'Failed', position: 2, color: 'red' },
|
||||
{ value: 'PENDING', label: 'Pending', position: 3, color: 'yellow' },
|
||||
{ value: 'RETRYING', label: 'Retrying', position: 4, color: 'orange' },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'TEXT',
|
||||
name: 'importError',
|
||||
label: 'Import Error',
|
||||
description: 'Error message if meeting import failed',
|
||||
description: 'Error message if import failed',
|
||||
icon: 'IconAlertTriangle',
|
||||
isNullable: true,
|
||||
},
|
||||
@@ -181,7 +242,7 @@ const MEETING_FIELDS: FieldDefinition[] = [
|
||||
type: 'DATE_TIME',
|
||||
name: 'lastImportAttempt',
|
||||
label: 'Last Import Attempt',
|
||||
description: 'Date and time of the last import attempt',
|
||||
description: 'When import was last attempted',
|
||||
icon: 'IconClock',
|
||||
isNullable: true,
|
||||
},
|
||||
@@ -189,7 +250,7 @@ const MEETING_FIELDS: FieldDefinition[] = [
|
||||
type: 'NUMBER',
|
||||
name: 'importAttempts',
|
||||
label: 'Import Attempts',
|
||||
description: 'Number of times import has been attempted',
|
||||
description: 'Number of import attempts',
|
||||
icon: 'IconRepeat',
|
||||
isNullable: true,
|
||||
},
|
||||
@@ -414,13 +475,9 @@ const main = async () => {
|
||||
}
|
||||
|
||||
if (createdCount === 0 && skippedCount === MEETING_FIELDS.length) {
|
||||
console.log('\n✨ All fields already exist. Nothing to do!');
|
||||
console.log('\n✨ All fields already exist. Nothing to do!\n');
|
||||
} else if (createdCount > 0) {
|
||||
console.log('\n✨ Custom fields added successfully!');
|
||||
console.log('\n📝 Next steps:');
|
||||
console.log(' 1. Re-sync your app: npx twenty-cli app sync');
|
||||
console.log(' 2. Update the createMeetingRecord function to use these fields');
|
||||
console.log(' 3. Test the integration with a real meeting');
|
||||
console.log('\n✨ Custom fields added successfully!\n');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/* eslint-disable no-console */
|
||||
import * as dotenv from 'dotenv';
|
||||
import * as path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
dotenv.config({ path: path.join(__dirname, '../.env') });
|
||||
|
||||
const FIREFLIES_API_KEY = process.env.FIREFLIES_API_KEY;
|
||||
const meetingId = process.argv[2] || '01KBMR1ZYQ34YP8D2KB4B16QPH';
|
||||
|
||||
const main = async (): Promise<void> => {
|
||||
if (!FIREFLIES_API_KEY) {
|
||||
console.error('❌ FIREFLIES_API_KEY is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const query = `
|
||||
query GetTranscript($transcriptId: String!) {
|
||||
transcript(id: $transcriptId) {
|
||||
id
|
||||
title
|
||||
summary {
|
||||
overview
|
||||
notes
|
||||
gist
|
||||
bullet_gist
|
||||
short_summary
|
||||
short_overview
|
||||
outline
|
||||
shorthand_bullet
|
||||
action_items
|
||||
keywords
|
||||
topics_discussed
|
||||
meeting_type
|
||||
transcript_chapters
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const response = await fetch('https://api.fireflies.ai/graphql', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${FIREFLIES_API_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({ query, variables: { transcriptId: meetingId } }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error(`❌ API request failed with status ${response.status}`);
|
||||
console.error(errorText);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
console.log('=== Fireflies API Response ===\n');
|
||||
console.log(JSON.stringify(json, null, 2));
|
||||
|
||||
if (json.data?.transcript?.summary) {
|
||||
const s = json.data.transcript.summary;
|
||||
console.log('\n=== Summary Fields Status ===');
|
||||
console.log('overview:', s.overview ? `✓ (${s.overview.length} chars)` : '✗ empty');
|
||||
console.log('notes:', s.notes ? `✓ (${s.notes.length} chars)` : '✗ empty');
|
||||
console.log('gist:', s.gist ? `✓ (${s.gist.length} chars)` : '✗ empty');
|
||||
console.log('bullet_gist:', s.bullet_gist ? `✓ (${s.bullet_gist.length} chars)` : '✗ empty');
|
||||
console.log('outline:', s.outline ? `✓ (${s.outline.length} chars)` : '✗ empty');
|
||||
console.log('action_items:', s.action_items?.length || 0, 'items');
|
||||
console.log('topics_discussed:', s.topics_discussed?.length || 0, 'topics');
|
||||
console.log('keywords:', s.keywords?.length || 0, 'keywords');
|
||||
}
|
||||
};
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('❌ Failed to fetch meeting');
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
/* eslint-disable no-console */
|
||||
import * as dotenv from 'dotenv';
|
||||
import * as path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
dotenv.config({ path: path.join(__dirname, '../.env') });
|
||||
|
||||
const SERVER_URL = process.env.SERVER_URL || 'http://localhost:3000';
|
||||
const API_KEY = process.env.TWENTY_API_KEY;
|
||||
const meetingId = process.argv[2];
|
||||
|
||||
const main = async (): Promise<void> => {
|
||||
if (!API_KEY) {
|
||||
console.error('❌ TWENTY_API_KEY is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!meetingId) {
|
||||
console.error('Usage: yarn delete:meeting <meetingId>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const response = await fetch(`${SERVER_URL}/graphql`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${API_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: `mutation DeleteMeeting($id: UUID!) { deleteMeeting(id: $id) { id } }`,
|
||||
variables: { id: meetingId },
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error(`❌ Delete failed (status ${response.status})`);
|
||||
console.error(errorText);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
const deletedId = result.data?.deleteMeeting?.id;
|
||||
if (result.errors || !deletedId) {
|
||||
const message = result.errors?.[0]?.message || 'deleteMeeting returned null';
|
||||
console.error('❌ Error:', message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('✅ Deleted meeting:', deletedId);
|
||||
};
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('❌ Failed to delete meeting');
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
/* eslint-disable no-console */
|
||||
/**
|
||||
* Fetch historical Fireflies meetings and insert into Twenty.
|
||||
*
|
||||
* Usage:
|
||||
* yarn meeting:all [--from 2024-01-01] [--to 2024-02-01] [--organizer alice@x.com] [--participant bob@x.com] [--channel <channelId>] [--mine] [--dry-run] [--page-size 50] [--max-records 200]
|
||||
*
|
||||
* Required env:
|
||||
* FIREFLIES_API_KEY
|
||||
* TWENTY_API_KEY
|
||||
*
|
||||
* Optional env:
|
||||
* SERVER_URL (defaults to http://localhost:3000)
|
||||
* FIREFLIES_PLAN (free|pro|business|enterprise)
|
||||
* AUTO_CREATE_CONTACTS (true|false)
|
||||
* FIREFLIES_* retry settings (see README)
|
||||
*/
|
||||
|
||||
import * as dotenv from 'dotenv';
|
||||
import { existsSync } from 'fs';
|
||||
import { dirname, join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
import { FirefliesApiClient } from '../src/fireflies-api-client';
|
||||
import { type HistoricalImportFilters, HistoricalImporter } from '../src/historical-importer';
|
||||
import { createLogger } from '../src/logger';
|
||||
import { TwentyCrmService } from '../src/twenty-crm-service';
|
||||
import {
|
||||
getApiUrl,
|
||||
getFirefliesPlan,
|
||||
getSummaryFetchConfig,
|
||||
shouldAutoCreateContacts,
|
||||
} from '../src/utils';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const envPath = join(__dirname, '..', '.env');
|
||||
if (existsSync(envPath)) {
|
||||
dotenv.config({ path: envPath });
|
||||
}
|
||||
|
||||
const logger = createLogger('cli:meeting:all');
|
||||
|
||||
type CliArgs = {
|
||||
from?: string;
|
||||
to?: string;
|
||||
organizer?: string[];
|
||||
participant?: string[];
|
||||
channel?: string;
|
||||
host?: string;
|
||||
mine?: boolean;
|
||||
dryRun?: boolean;
|
||||
pageSize?: number;
|
||||
maxRecords?: number;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
const parseArgs = (argv: string[]): CliArgs => {
|
||||
const args: CliArgs = {};
|
||||
|
||||
const parseNumberArg = (value?: string): number | undefined => {
|
||||
if (!value) return undefined;
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isNaN(parsed) ? undefined : parsed;
|
||||
};
|
||||
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const current = argv[i];
|
||||
const next = argv[i + 1];
|
||||
switch (current) {
|
||||
case '--from':
|
||||
args.from = next;
|
||||
i += 1;
|
||||
break;
|
||||
case '--to':
|
||||
args.to = next;
|
||||
i += 1;
|
||||
break;
|
||||
case '--organizer':
|
||||
args.organizer = next ? next.split(',') : [];
|
||||
i += 1;
|
||||
break;
|
||||
case '--participant':
|
||||
args.participant = next ? next.split(',') : [];
|
||||
i += 1;
|
||||
break;
|
||||
case '--channel':
|
||||
args.channel = next;
|
||||
i += 1;
|
||||
break;
|
||||
case '--host':
|
||||
args.host = next;
|
||||
i += 1;
|
||||
break;
|
||||
case '--mine':
|
||||
args.mine = true;
|
||||
break;
|
||||
case '--dry-run':
|
||||
args.dryRun = true;
|
||||
break;
|
||||
case '--page-size':
|
||||
args.pageSize = parseNumberArg(next);
|
||||
i += 1;
|
||||
break;
|
||||
case '--max-records':
|
||||
args.maxRecords = parseNumberArg(next);
|
||||
i += 1;
|
||||
break;
|
||||
case '--limit':
|
||||
args.limit = parseNumberArg(next);
|
||||
i += 1;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return args;
|
||||
};
|
||||
|
||||
const parseDate = (value?: string): number | undefined => {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = Date.parse(value);
|
||||
return Number.isNaN(parsed) ? undefined : parsed;
|
||||
};
|
||||
|
||||
const main = async (): Promise<void> => {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
const firefliesApiKey = process.env.FIREFLIES_API_KEY || '';
|
||||
const twentyApiKey = process.env.TWENTY_API_KEY || '';
|
||||
|
||||
if (!firefliesApiKey) {
|
||||
console.error('❌ FIREFLIES_API_KEY is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!twentyApiKey) {
|
||||
console.error('❌ TWENTY_API_KEY is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const fromDate = parseDate(args.from);
|
||||
const toDate = parseDate(args.to);
|
||||
|
||||
const filters: HistoricalImportFilters = {
|
||||
fromDate,
|
||||
toDate,
|
||||
organizers: args.organizer,
|
||||
participants: args.participant,
|
||||
channelId: args.channel,
|
||||
hostEmail: args.host,
|
||||
mine: args.mine,
|
||||
limit: args.limit,
|
||||
pageSize: args.pageSize,
|
||||
maxRecords: args.maxRecords,
|
||||
};
|
||||
|
||||
const summaryConfig = getSummaryFetchConfig();
|
||||
const plan = getFirefliesPlan();
|
||||
const autoCreateContacts = shouldAutoCreateContacts();
|
||||
|
||||
logger.info(
|
||||
`Starting historical import (dryRun=${Boolean(args.dryRun)}, plan=${plan}, pageSize=${filters.pageSize ?? 50})`,
|
||||
);
|
||||
|
||||
const firefliesClient = new FirefliesApiClient(firefliesApiKey);
|
||||
const twentyService = new TwentyCrmService(twentyApiKey, getApiUrl());
|
||||
const importer = new HistoricalImporter(firefliesClient, twentyService);
|
||||
|
||||
const result = await importer.run(filters, {
|
||||
dryRun: args.dryRun,
|
||||
autoCreateContacts,
|
||||
summaryConfig,
|
||||
plan,
|
||||
});
|
||||
|
||||
console.log('✅ Historical import summary:');
|
||||
const summary = {
|
||||
dryRun: result.dryRun,
|
||||
totalListed: result.totalListed,
|
||||
imported: result.imported,
|
||||
skippedExisting: result.skippedExisting,
|
||||
summaryPending: result.summaryPending,
|
||||
failed: result.failed,
|
||||
};
|
||||
console.log(JSON.stringify(summary, null, 2));
|
||||
|
||||
if (result.statuses.length > 0) {
|
||||
console.log('Status by meeting:');
|
||||
console.table(
|
||||
result.statuses.map((s) => ({
|
||||
meetingId: s.meetingId,
|
||||
title: s.title ?? '',
|
||||
status: s.status,
|
||||
reason: s.reason ?? '',
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
if (result.failed.length > 0) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
};
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('❌ Failed to import historical meetings');
|
||||
if (error instanceof Error) {
|
||||
console.error(error.message);
|
||||
if (error.stack) {
|
||||
console.error(error.stack);
|
||||
}
|
||||
} else {
|
||||
console.error(String(error));
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/* eslint-disable no-console */
|
||||
/**
|
||||
* Fetch a Fireflies meeting by ID and insert it into Twenty using the same path
|
||||
* as the webhook handler.
|
||||
*
|
||||
* Usage:
|
||||
* yarn meeting:ingest <meetingId>
|
||||
* Or
|
||||
* MEETING_ID=... yarn meeting:ingest
|
||||
*
|
||||
* Required env:
|
||||
* FIREFLIES_API_KEY
|
||||
* FIREFLIES_WEBHOOK_SECRET
|
||||
* TWENTY_API_KEY
|
||||
*
|
||||
* Optional env:
|
||||
* SERVER_URL (defaults to http://localhost:3000)
|
||||
* FIREFLIES_PLAN (free|pro|business|enterprise)
|
||||
*/
|
||||
|
||||
import { createHmac } from 'crypto';
|
||||
import * as dotenv from 'dotenv';
|
||||
import { existsSync } from 'fs';
|
||||
import { dirname, join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
import { WebhookHandler } from '../src/webhook-handler';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const envPath = join(__dirname, '..', '.env');
|
||||
if (existsSync(envPath)) {
|
||||
dotenv.config({ path: envPath });
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const meetingId = args[0] || process.env.MEETING_ID;
|
||||
|
||||
if (!meetingId) {
|
||||
console.error('❌ meetingId is required (arg or MEETING_ID env)');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const firefliesApiKey = process.env.FIREFLIES_API_KEY || '';
|
||||
const twentyApiKey = process.env.TWENTY_API_KEY || '';
|
||||
const webhookSecret = process.env.FIREFLIES_WEBHOOK_SECRET || '';
|
||||
|
||||
if (!firefliesApiKey) {
|
||||
console.error('❌ FIREFLIES_API_KEY is required');
|
||||
process.exit(1);
|
||||
}
|
||||
if (!twentyApiKey) {
|
||||
console.error('❌ TWENTY_API_KEY is required');
|
||||
process.exit(1);
|
||||
}
|
||||
if (!webhookSecret) {
|
||||
console.error('❌ FIREFLIES_WEBHOOK_SECRET is required to generate signature');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const payload = {
|
||||
meetingId,
|
||||
eventType: 'Transcription completed',
|
||||
};
|
||||
|
||||
const body = JSON.stringify(payload);
|
||||
const signature = `sha256=${createHmac('sha256', webhookSecret)
|
||||
.update(body, 'utf8')
|
||||
.digest('hex')}`;
|
||||
|
||||
const main = async (): Promise<void> => {
|
||||
console.log(`🚀 Ingesting meeting ${meetingId} via webhook handler`);
|
||||
const handler = new WebhookHandler();
|
||||
const result = await handler.handle(payload, {
|
||||
'x-hub-signature': signature,
|
||||
body,
|
||||
});
|
||||
|
||||
console.log('✅ Result:');
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
|
||||
if (result.errors && result.errors.length > 0) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
};
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('❌ Failed to ingest meeting');
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -40,11 +40,13 @@ const FIREFLIES_WEBHOOK_SECRET = process.env.FIREFLIES_WEBHOOK_SECRET || 'test_s
|
||||
const _FIREFLIES_API_KEY = process.env.FIREFLIES_API_KEY || 'test_api_key';
|
||||
|
||||
// Test meeting data (simulating Fireflies API response)
|
||||
const TEST_MEETING_ID = 'test-meeting-local-' + Date.now();
|
||||
const TEST_MEETING_ID = process.env.MEETING_ID || 'test-meeting-local-' + Date.now();
|
||||
const CLIENT_REFERENCE_ID = process.env.CLIENT_REFERENCE_ID;
|
||||
|
||||
const TEST_WEBHOOK_PAYLOAD = {
|
||||
meetingId: TEST_MEETING_ID,
|
||||
eventType: 'Transcription completed',
|
||||
clientReferenceId: 'test-client-ref',
|
||||
...(CLIENT_REFERENCE_ID ? { clientReferenceId: CLIENT_REFERENCE_ID } : {}),
|
||||
};
|
||||
|
||||
// Mock Fireflies GraphQL API response
|
||||
@@ -119,11 +121,17 @@ const main = async () => {
|
||||
}
|
||||
|
||||
// Prepare webhook payload
|
||||
const body = JSON.stringify(TEST_WEBHOOK_PAYLOAD);
|
||||
const signature = generateHMACSignature(body, FIREFLIES_WEBHOOK_SECRET);
|
||||
const unsignedBody = JSON.stringify(TEST_WEBHOOK_PAYLOAD);
|
||||
const signature = generateHMACSignature(unsignedBody, FIREFLIES_WEBHOOK_SECRET);
|
||||
const payloadWithSignature = {
|
||||
...TEST_WEBHOOK_PAYLOAD,
|
||||
'x-hub-signature': signature,
|
||||
};
|
||||
const body = JSON.stringify(payloadWithSignature);
|
||||
|
||||
console.log('📤 Sending webhook payload:');
|
||||
console.log(JSON.stringify(TEST_WEBHOOK_PAYLOAD, null, 2));
|
||||
console.log(JSON.stringify(payloadWithSignature, null, 2));
|
||||
console.log('\nℹ️ Signature is sent both as header (preferred) and in payload as fallback (headers are not passed to serverless functions)\n');
|
||||
console.log(`\n🔐 HMAC Signature: ${signature}\n`);
|
||||
|
||||
// Check if server is reachable
|
||||
@@ -178,6 +186,31 @@ const main = async () => {
|
||||
console.log('📥 Response Body:');
|
||||
console.log(JSON.stringify(responseData, null, 2));
|
||||
|
||||
// Report whether the server appears to have received the header signature
|
||||
const debugMessages = Array.isArray((responseData as any)?.debug)
|
||||
? ((responseData as any).debug as string[])
|
||||
: [];
|
||||
const headerMissing =
|
||||
debugMessages.some((msg) => msg.includes('headerKeys=none')) ||
|
||||
debugMessages.some((msg) => msg.includes('providedSignature=undefined'));
|
||||
const signatureErrors =
|
||||
Array.isArray((responseData as any)?.errors) &&
|
||||
((responseData as any).errors as unknown[]).some(
|
||||
(err) => typeof err === 'string' && err.toLowerCase().includes('signature'),
|
||||
);
|
||||
|
||||
if (headerMissing) {
|
||||
console.log(
|
||||
'\n⚠️ Server did not report any received headers; it may be using payload fallback for signature verification.',
|
||||
);
|
||||
} else {
|
||||
console.log('\n✅ Server reported headers present (header-based signature should be used).');
|
||||
}
|
||||
|
||||
if (signatureErrors) {
|
||||
console.log('⚠️ Signature was rejected by the server (check webhook secret / payload).');
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
console.log('\n✅ Webhook test completed successfully!');
|
||||
console.log('\n📋 Next steps:');
|
||||
|
||||
-426
@@ -1,426 +0,0 @@
|
||||
import { createLogger } from './logger';
|
||||
import type { FirefliesMeetingData, FirefliesParticipant, SummaryFetchConfig } from './types';
|
||||
|
||||
const logger = createLogger('fireflies-api');
|
||||
|
||||
export class FirefliesApiClient {
|
||||
private apiKey: string;
|
||||
|
||||
constructor(apiKey: string) {
|
||||
if (!apiKey) {
|
||||
logger.critical('FIREFLIES_API_KEY is required but not provided - this is a critical configuration error');
|
||||
throw new Error('FIREFLIES_API_KEY is required');
|
||||
}
|
||||
this.apiKey = apiKey;
|
||||
}
|
||||
|
||||
async fetchMeetingData(
|
||||
meetingId: string,
|
||||
options?: { timeout?: number }
|
||||
): Promise<FirefliesMeetingData> {
|
||||
const query = `
|
||||
query GetTranscript($transcriptId: String!) {
|
||||
transcript(id: $transcriptId) {
|
||||
id
|
||||
title
|
||||
date
|
||||
duration
|
||||
participants
|
||||
organizer_email
|
||||
meeting_attendees {
|
||||
displayName
|
||||
email
|
||||
phoneNumber
|
||||
name
|
||||
location
|
||||
}
|
||||
meeting_attendance {
|
||||
name
|
||||
join_time
|
||||
leave_time
|
||||
}
|
||||
speakers {
|
||||
name
|
||||
}
|
||||
summary {
|
||||
action_items
|
||||
overview
|
||||
}
|
||||
transcript_url
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = options?.timeout
|
||||
? setTimeout(() => controller.abort(), options.timeout)
|
||||
: null;
|
||||
|
||||
try {
|
||||
const response = await fetch('https://api.fireflies.ai/graphql', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${this.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query,
|
||||
variables: { transcriptId: meetingId },
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
let errorDetails = `Fireflies API request failed with status ${response.status}`;
|
||||
try {
|
||||
const errorBody = await response.text();
|
||||
if (errorBody) {
|
||||
errorDetails += `: ${errorBody}`;
|
||||
}
|
||||
} catch {
|
||||
// Ignore if we can't read the response body
|
||||
}
|
||||
throw new Error(errorDetails);
|
||||
}
|
||||
|
||||
const json = await response.json() as {
|
||||
data?: { transcript?: any };
|
||||
errors?: Array<{ message?: string }>;
|
||||
};
|
||||
|
||||
if (json.errors && json.errors.length > 0) {
|
||||
throw new Error(`Fireflies API error: ${json.errors[0]?.message || 'Unknown error'}`);
|
||||
}
|
||||
|
||||
const transcript = json.data?.transcript;
|
||||
if (!transcript) {
|
||||
throw new Error('Invalid response from Fireflies API: missing transcript data');
|
||||
}
|
||||
|
||||
return this.transformMeetingData(transcript, meetingId);
|
||||
} catch (error) {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async fetchMeetingDataWithRetry(
|
||||
meetingId: string,
|
||||
config: SummaryFetchConfig
|
||||
): Promise<{ data: FirefliesMeetingData; summaryReady: boolean }> {
|
||||
// immediate_only: single attempt, no retries
|
||||
if (config.strategy === 'immediate_only') {
|
||||
logger.debug(`fetching meeting ${meetingId} (strategy: immediate_only)`);
|
||||
const meetingData = await this.fetchMeetingData(meetingId, { timeout: 10000 });
|
||||
const ready = this.isSummaryReady(meetingData);
|
||||
logger.debug(`summary ready: ${ready}`);
|
||||
return { data: meetingData, summaryReady: ready };
|
||||
}
|
||||
|
||||
// immediate_with_retry: retry with exponential backoff
|
||||
logger.debug(`fetching meeting ${meetingId} (strategy: immediate_with_retry, maxAttempts: ${config.retryAttempts})`);
|
||||
|
||||
for (let attempt = 1; attempt <= config.retryAttempts; attempt++) {
|
||||
try {
|
||||
const meetingData = await this.fetchMeetingData(meetingId, { timeout: 10000 });
|
||||
const ready = this.isSummaryReady(meetingData);
|
||||
|
||||
logger.debug(`attempt ${attempt}/${config.retryAttempts}: summary ready=${ready}`);
|
||||
|
||||
if (ready) {
|
||||
return { data: meetingData, summaryReady: true };
|
||||
}
|
||||
|
||||
if (attempt < config.retryAttempts) {
|
||||
const delayMs = config.retryDelay * attempt;
|
||||
logger.debug(`summary not ready, waiting ${delayMs}ms before retry ${attempt + 1}`);
|
||||
await new Promise(resolve => setTimeout(resolve, delayMs));
|
||||
} else {
|
||||
logger.debug(`max retries reached, returning partial data`);
|
||||
return { data: meetingData, summaryReady: false };
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
logger.error(`attempt ${attempt}/${config.retryAttempts} failed: ${errorMsg}`);
|
||||
|
||||
if (attempt === config.retryAttempts) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const delayMs = config.retryDelay * attempt;
|
||||
logger.debug(`retrying in ${delayMs}ms...`);
|
||||
await new Promise(resolve => setTimeout(resolve, delayMs));
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Failed to fetch meeting data after retries');
|
||||
}
|
||||
|
||||
private isSummaryReady(meetingData: FirefliesMeetingData): boolean {
|
||||
return (
|
||||
(meetingData.summary?.action_items?.length > 0) ||
|
||||
(meetingData.summary?.overview?.length > 0) ||
|
||||
meetingData.summary_status === 'completed'
|
||||
);
|
||||
}
|
||||
|
||||
private extractAllParticipants(transcript: any): FirefliesParticipant[] {
|
||||
const participantsWithEmails: FirefliesParticipant[] = [];
|
||||
const participantsNameOnly: FirefliesParticipant[] = [];
|
||||
|
||||
logger.debug('=== PARTICIPANT EXTRACTION DEBUG ===');
|
||||
logger.debug('participants field:', JSON.stringify(transcript.participants));
|
||||
logger.debug('meeting_attendees field:', JSON.stringify(transcript.meeting_attendees));
|
||||
logger.debug('speakers field:', transcript.speakers?.map((s: any) => s.name));
|
||||
logger.debug('meeting_attendance field:', transcript.meeting_attendance?.map((a: any) => a.name));
|
||||
logger.debug('organizer_email:', transcript.organizer_email);
|
||||
|
||||
// Helper function to check if a string is an email
|
||||
const isEmail = (str: string): boolean => {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(str.trim());
|
||||
};
|
||||
|
||||
// Helper function to check if already exists
|
||||
const isDuplicate = (name: string, email: string): boolean => {
|
||||
const nameLower = name.toLowerCase().trim();
|
||||
const emailLower = email.toLowerCase().trim();
|
||||
|
||||
return participantsWithEmails.some(p =>
|
||||
p.name.toLowerCase().trim() === nameLower ||
|
||||
(email && p.email.toLowerCase() === emailLower)
|
||||
) || participantsNameOnly.some(p =>
|
||||
p.name.toLowerCase().trim() === nameLower
|
||||
);
|
||||
};
|
||||
|
||||
// 1. Extract from legacy participants field (with emails)
|
||||
if (transcript.participants && Array.isArray(transcript.participants)) {
|
||||
transcript.participants.forEach((participant: string) => {
|
||||
// Handle comma-separated emails or names
|
||||
const parts = participant.split(',').map(p => p.trim());
|
||||
|
||||
parts.forEach(part => {
|
||||
const emailMatch = part.match(/<([^>]+)>/);
|
||||
const email = emailMatch ? emailMatch[1] : '';
|
||||
// Extract name properly: if there's an email in angle brackets, get the part before it
|
||||
const name = emailMatch
|
||||
? part.substring(0, part.indexOf('<')).trim()
|
||||
: part.trim();
|
||||
|
||||
// Skip if the "name" is actually an email address
|
||||
if (isEmail(name)) {
|
||||
logger.debug(`Skipping participant with email as name: "${name}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if empty name
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip duplicates
|
||||
if (isDuplicate(name, email)) {
|
||||
logger.debug(`Skipping duplicate participant: "${name}" <${email}>`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (name && email) {
|
||||
participantsWithEmails.push({ name, email });
|
||||
} else if (name) {
|
||||
participantsNameOnly.push({ name, email: '' });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Extract from meeting_attendees field (structured)
|
||||
if (transcript.meeting_attendees && Array.isArray(transcript.meeting_attendees)) {
|
||||
transcript.meeting_attendees.forEach((attendee: any) => {
|
||||
const name = attendee.displayName || attendee.name || '';
|
||||
const email = attendee.email || '';
|
||||
|
||||
// Skip if name is actually an email
|
||||
if (isEmail(name)) {
|
||||
logger.debug(`Skipping attendee with email as name: "${name}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (name && !isDuplicate(name, email)) {
|
||||
if (email) {
|
||||
participantsWithEmails.push({ name, email });
|
||||
} else {
|
||||
participantsNameOnly.push({ name, email: '' });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Extract from speakers field (name only)
|
||||
if (transcript.speakers && Array.isArray(transcript.speakers)) {
|
||||
transcript.speakers.forEach((speaker: any) => {
|
||||
const name = speaker.name || '';
|
||||
|
||||
// Skip if name is actually an email
|
||||
if (isEmail(name)) {
|
||||
logger.debug(`Skipping speaker with email as name: "${name}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (name && !isDuplicate(name, '')) {
|
||||
participantsNameOnly.push({ name, email: '' });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Extract from meeting_attendance field (name only)
|
||||
if (transcript.meeting_attendance && Array.isArray(transcript.meeting_attendance)) {
|
||||
transcript.meeting_attendance.forEach((attendance: any) => {
|
||||
const name = attendance.name || '';
|
||||
|
||||
// Skip if name is actually an email or contains comma-separated emails
|
||||
if (isEmail(name) || name.includes(',')) {
|
||||
logger.debug(`Skipping attendance with email/list as name: "${name}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (name && !isDuplicate(name, '')) {
|
||||
participantsNameOnly.push({ name, email: '' });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 5. Add organizer email if available and not already included
|
||||
const organizerEmail = transcript.organizer_email;
|
||||
if (organizerEmail) {
|
||||
// Check if organizer email is already in the participants
|
||||
const existsWithEmail = participantsWithEmails.some(p =>
|
||||
p.email.toLowerCase() === organizerEmail.toLowerCase()
|
||||
);
|
||||
|
||||
if (!existsWithEmail) {
|
||||
// Try to find organizer name from speakers/attendance and match with email
|
||||
let organizerName = '';
|
||||
|
||||
// Extract username from organizer email for matching
|
||||
const emailUsername = organizerEmail.split('@')[0].toLowerCase();
|
||||
const emailNameVariations = [emailUsername];
|
||||
|
||||
// Add common name variations based on email username
|
||||
if (emailUsername === 'alex') {
|
||||
emailNameVariations.push('alexander', 'alexandre', 'alex');
|
||||
}
|
||||
|
||||
// Look for organizer in speakers by matching email username to speaker names
|
||||
if (transcript.speakers && Array.isArray(transcript.speakers)) {
|
||||
const potentialOrganizerSpeaker = transcript.speakers.find((speaker: any) => {
|
||||
const name = (speaker.name || '').toLowerCase();
|
||||
return emailNameVariations.some(variation =>
|
||||
name.includes(variation) || variation.includes(name)
|
||||
);
|
||||
});
|
||||
if (potentialOrganizerSpeaker) {
|
||||
organizerName = potentialOrganizerSpeaker.name;
|
||||
}
|
||||
}
|
||||
|
||||
// Look for organizer in attendance
|
||||
if (!organizerName && transcript.meeting_attendance && Array.isArray(transcript.meeting_attendance)) {
|
||||
const potentialOrganizerAttendance = transcript.meeting_attendance.find((attendance: any) => {
|
||||
const name = (attendance.name || '').toLowerCase();
|
||||
return emailNameVariations.some(variation =>
|
||||
name.includes(variation) || variation.includes(name)
|
||||
);
|
||||
});
|
||||
if (potentialOrganizerAttendance) {
|
||||
organizerName = potentialOrganizerAttendance.name;
|
||||
}
|
||||
}
|
||||
|
||||
// If we found a name match, add as participant with email
|
||||
if (organizerName) {
|
||||
participantsWithEmails.push({ name: organizerName, email: organizerEmail });
|
||||
|
||||
// Remove from name-only participants to avoid duplicates
|
||||
const nameIndex = participantsNameOnly.findIndex(p =>
|
||||
p.name.toLowerCase().includes(organizerName.toLowerCase()) ||
|
||||
organizerName.toLowerCase().includes(p.name.toLowerCase())
|
||||
);
|
||||
if (nameIndex !== -1) {
|
||||
participantsNameOnly.splice(nameIndex, 1);
|
||||
}
|
||||
} else {
|
||||
// If no name found, add with generic organizer name
|
||||
participantsWithEmails.push({ name: 'Meeting Organizer', email: organizerEmail });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return participants with emails first, then name-only participants
|
||||
const allParticipants = [...participantsWithEmails, ...participantsNameOnly];
|
||||
|
||||
logger.debug('=== EXTRACTED PARTICIPANTS ===');
|
||||
logger.debug('With emails:', participantsWithEmails.length, JSON.stringify(participantsWithEmails));
|
||||
logger.debug('Name only:', participantsNameOnly.length, JSON.stringify(participantsNameOnly));
|
||||
logger.debug('Total:', allParticipants.length);
|
||||
|
||||
return allParticipants;
|
||||
}
|
||||
|
||||
private transformMeetingData(transcript: any, meetingId: string): FirefliesMeetingData {
|
||||
// Convert date to ISO string - handle both timestamp and ISO string formats
|
||||
let dateString: string;
|
||||
if (transcript.date) {
|
||||
if (typeof transcript.date === 'number') {
|
||||
// Unix timestamp in milliseconds
|
||||
dateString = new Date(transcript.date).toISOString();
|
||||
} else if (typeof transcript.date === 'string') {
|
||||
// Could be ISO string or timestamp string
|
||||
const parsed = Number(transcript.date);
|
||||
if (!isNaN(parsed)) {
|
||||
// It's a numeric string (timestamp)
|
||||
dateString = new Date(parsed).toISOString();
|
||||
} else {
|
||||
// It's already an ISO string
|
||||
dateString = transcript.date;
|
||||
}
|
||||
} else {
|
||||
dateString = new Date().toISOString();
|
||||
}
|
||||
} else {
|
||||
dateString = new Date().toISOString();
|
||||
}
|
||||
|
||||
return {
|
||||
id: transcript.id || meetingId,
|
||||
title: transcript.title || 'Untitled Meeting',
|
||||
date: dateString,
|
||||
duration: transcript.duration || 0,
|
||||
participants: this.extractAllParticipants(transcript),
|
||||
organizer_email: transcript.organizer_email,
|
||||
summary: {
|
||||
action_items: Array.isArray(transcript.summary?.action_items)
|
||||
? transcript.summary.action_items
|
||||
: (typeof transcript.summary?.action_items === 'string'
|
||||
? [transcript.summary.action_items]
|
||||
: []),
|
||||
overview: transcript.summary?.overview || '',
|
||||
keywords: transcript.summary?.keywords,
|
||||
topics_discussed: transcript.summary?.topics_discussed,
|
||||
meeting_type: transcript.summary?.meeting_type,
|
||||
},
|
||||
analytics: transcript.sentiments ? {
|
||||
sentiments: {
|
||||
positive_pct: transcript.sentiments.positive_pct || 0,
|
||||
negative_pct: transcript.sentiments.negative_pct || 0,
|
||||
neutral_pct: transcript.sentiments.neutral_pct || 0,
|
||||
}
|
||||
} : undefined,
|
||||
transcript_url: transcript.transcript_url || `https://app.fireflies.ai/view/${meetingId}`,
|
||||
recording_url: transcript.video_url || undefined,
|
||||
summary_status: transcript.summary_status,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
-227
@@ -1,227 +0,0 @@
|
||||
import type { FirefliesMeetingData, MeetingCreateInput } from './types';
|
||||
|
||||
export class MeetingFormatter {
|
||||
static formatNoteBody(meetingData: FirefliesMeetingData): string {
|
||||
const meetingDate = new Date(meetingData.date);
|
||||
const formattedDate = meetingDate.toLocaleString('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
const durationMinutes = Math.round(meetingData.duration);
|
||||
|
||||
let noteBody = `**Date:** ${formattedDate}\n`;
|
||||
noteBody += `**Duration:** ${durationMinutes} minutes\n`;
|
||||
|
||||
if (meetingData.participants.length > 0) {
|
||||
const participantNames = meetingData.participants.map(p => p.name).join(', ');
|
||||
noteBody += `**Participants:** ${participantNames}\n`;
|
||||
}
|
||||
|
||||
// Overview section
|
||||
if (meetingData.summary?.overview) {
|
||||
noteBody += `\n## Overview\n${meetingData.summary.overview}\n`;
|
||||
}
|
||||
|
||||
// Key topics
|
||||
if (meetingData.summary?.topics_discussed && Array.isArray(meetingData.summary.topics_discussed) && meetingData.summary.topics_discussed.length > 0) {
|
||||
noteBody += `\n## Key Topics\n`;
|
||||
meetingData.summary.topics_discussed.forEach(topic => {
|
||||
noteBody += `- ${topic}\n`;
|
||||
});
|
||||
}
|
||||
|
||||
// Action items
|
||||
if (meetingData.summary?.action_items && Array.isArray(meetingData.summary.action_items) && meetingData.summary.action_items.length > 0) {
|
||||
noteBody += `\n## Action Items\n`;
|
||||
meetingData.summary.action_items.forEach(item => {
|
||||
noteBody += `- ${item}\n`;
|
||||
});
|
||||
}
|
||||
|
||||
// Insights section
|
||||
noteBody += `\n## Insights\n`;
|
||||
|
||||
if (meetingData.summary?.keywords && Array.isArray(meetingData.summary.keywords) && meetingData.summary.keywords.length > 0) {
|
||||
noteBody += `**Keywords:** ${meetingData.summary.keywords.join(', ')}\n`;
|
||||
}
|
||||
|
||||
if (meetingData.analytics?.sentiments) {
|
||||
const sentiments = meetingData.analytics.sentiments;
|
||||
noteBody += `**Sentiment:** ${sentiments.positive_pct}% positive, ${sentiments.negative_pct}% negative, ${sentiments.neutral_pct}% neutral\n`;
|
||||
}
|
||||
|
||||
if (meetingData.summary?.meeting_type) {
|
||||
noteBody += `**Meeting Type:** ${meetingData.summary.meeting_type}\n`;
|
||||
}
|
||||
|
||||
// Resources section
|
||||
noteBody += `\n## Resources\n`;
|
||||
noteBody += `[View Full Transcript](${meetingData.transcript_url})\n`;
|
||||
|
||||
if (meetingData.recording_url) {
|
||||
noteBody += `[Watch Recording](${meetingData.recording_url})\n`;
|
||||
}
|
||||
|
||||
return noteBody;
|
||||
}
|
||||
|
||||
static formatMeetingNotes(meetingData: FirefliesMeetingData): string {
|
||||
const meetingDate = new Date(meetingData.date);
|
||||
const formattedDate = meetingDate.toLocaleString('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
const durationMinutes = Math.round(meetingData.duration);
|
||||
|
||||
let meetingNotes = `**Date:** ${formattedDate}\n`;
|
||||
meetingNotes += `**Duration:** ${durationMinutes} minutes\n`;
|
||||
|
||||
if (meetingData.participants.length > 0) {
|
||||
const participantNames = meetingData.participants.map(p => p.name).join(', ');
|
||||
meetingNotes += `**Participants:** ${participantNames}\n`;
|
||||
}
|
||||
|
||||
// Overview section
|
||||
if (meetingData.summary?.overview) {
|
||||
meetingNotes += `\n## Overview\n${meetingData.summary.overview}\n`;
|
||||
}
|
||||
|
||||
// Key topics
|
||||
if (meetingData.summary?.topics_discussed && Array.isArray(meetingData.summary.topics_discussed) && meetingData.summary.topics_discussed.length > 0) {
|
||||
meetingNotes += `\n## Key Topics\n`;
|
||||
meetingData.summary.topics_discussed.forEach(topic => {
|
||||
meetingNotes += `- ${topic}\n`;
|
||||
});
|
||||
}
|
||||
|
||||
// Action items
|
||||
if (meetingData.summary?.action_items && Array.isArray(meetingData.summary.action_items) && meetingData.summary.action_items.length > 0) {
|
||||
meetingNotes += `\n## Action Items\n`;
|
||||
meetingData.summary.action_items.forEach(item => {
|
||||
meetingNotes += `- ${item}\n`;
|
||||
});
|
||||
}
|
||||
|
||||
// Insights section
|
||||
meetingNotes += `\n## Insights\n`;
|
||||
|
||||
if (meetingData.summary?.keywords && Array.isArray(meetingData.summary.keywords) && meetingData.summary.keywords.length > 0) {
|
||||
meetingNotes += `**Keywords:** ${meetingData.summary.keywords.join(', ')}\n`;
|
||||
}
|
||||
|
||||
if (meetingData.analytics?.sentiments) {
|
||||
const sentiments = meetingData.analytics.sentiments;
|
||||
meetingNotes += `**Sentiment:** ${sentiments.positive_pct}% positive, ${sentiments.negative_pct}% negative, ${sentiments.neutral_pct}% neutral\n`;
|
||||
}
|
||||
|
||||
if (meetingData.summary?.meeting_type) {
|
||||
meetingNotes += `**Meeting Type:** ${meetingData.summary.meeting_type}\n`;
|
||||
}
|
||||
|
||||
// Resources section
|
||||
meetingNotes += `\n## Resources\n`;
|
||||
meetingNotes += `[View Full Transcript](${meetingData.transcript_url})\n`;
|
||||
|
||||
if (meetingData.recording_url) {
|
||||
meetingNotes += `[Watch Recording](${meetingData.recording_url})\n`;
|
||||
}
|
||||
|
||||
return meetingNotes;
|
||||
}
|
||||
|
||||
|
||||
static toMeetingCreateInput(
|
||||
meetingData: FirefliesMeetingData,
|
||||
noteId?: string
|
||||
): MeetingCreateInput {
|
||||
const durationMinutes = Math.round(meetingData.duration);
|
||||
|
||||
// Build input object with only defined values (omit null fields)
|
||||
const input: MeetingCreateInput = {
|
||||
name: meetingData.title,
|
||||
meetingDate: meetingData.date,
|
||||
duration: durationMinutes,
|
||||
actionItemsCount: meetingData.summary?.action_items?.length || 0,
|
||||
firefliesMeetingId: meetingData.id,
|
||||
};
|
||||
|
||||
// Add direct relationship to note if noteId is provided
|
||||
if (noteId) {
|
||||
input.noteId = noteId;
|
||||
}
|
||||
|
||||
// Only add optional fields if they have values
|
||||
if (meetingData.summary?.meeting_type) {
|
||||
input.meetingType = meetingData.summary.meeting_type;
|
||||
}
|
||||
|
||||
if (meetingData.summary?.keywords && Array.isArray(meetingData.summary.keywords) && meetingData.summary.keywords.length > 0) {
|
||||
input.keywords = meetingData.summary.keywords.join(', ');
|
||||
}
|
||||
|
||||
if (meetingData.analytics?.sentiments?.positive_pct) {
|
||||
input.sentimentScore = meetingData.analytics.sentiments.positive_pct / 100;
|
||||
input.positivePercent = meetingData.analytics.sentiments.positive_pct;
|
||||
}
|
||||
|
||||
if (meetingData.analytics?.sentiments?.negative_pct) {
|
||||
input.negativePercent = meetingData.analytics.sentiments.negative_pct;
|
||||
}
|
||||
|
||||
// Only add URLs if they are valid (not empty strings)
|
||||
if (meetingData.transcript_url && meetingData.transcript_url.trim()) {
|
||||
input.transcriptUrl = {
|
||||
primaryLinkUrl: meetingData.transcript_url,
|
||||
primaryLinkLabel: 'View Transcript'
|
||||
};
|
||||
}
|
||||
|
||||
if (meetingData.recording_url && meetingData.recording_url.trim()) {
|
||||
input.recordingUrl = {
|
||||
primaryLinkUrl: meetingData.recording_url,
|
||||
primaryLinkLabel: 'Watch Recording'
|
||||
};
|
||||
}
|
||||
|
||||
if (meetingData.organizer_email) {
|
||||
input.organizerEmail = meetingData.organizer_email;
|
||||
}
|
||||
|
||||
// Set success status and timestamps
|
||||
input.importStatus = 'SUCCESS';
|
||||
input.lastImportAttempt = new Date().toISOString();
|
||||
input.importAttempts = 1;
|
||||
|
||||
return input;
|
||||
}
|
||||
|
||||
static toFailedMeetingCreateInput(
|
||||
meetingId: string,
|
||||
title: string,
|
||||
error: string,
|
||||
attempts: number = 1
|
||||
): MeetingCreateInput {
|
||||
const currentDate = new Date().toISOString();
|
||||
|
||||
return {
|
||||
name: title || `Failed Meeting Import - ${meetingId}`,
|
||||
meetingDate: currentDate,
|
||||
duration: 0,
|
||||
actionItemsCount: 0,
|
||||
firefliesMeetingId: meetingId,
|
||||
importStatus: 'FAILED',
|
||||
importError: error,
|
||||
lastImportAttempt: currentDate,
|
||||
importAttempts: attempts,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -1,4 +1,5 @@
|
||||
export { config, main } from './receive-fireflies-notes';
|
||||
// Serverless function entry point - re-exports from src/lib
|
||||
export { config, main } from '../../../src';
|
||||
export type {
|
||||
FirefliesMeetingData,
|
||||
FirefliesParticipant,
|
||||
@@ -6,5 +7,5 @@ export type {
|
||||
ProcessResult,
|
||||
SummaryFetchConfig,
|
||||
SummaryStrategy
|
||||
} from './types';
|
||||
} from '../../../src';
|
||||
|
||||
|
||||
-95
@@ -1,95 +0,0 @@
|
||||
export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'silent';
|
||||
|
||||
export interface LoggerConfig {
|
||||
logLevel: LogLevel;
|
||||
isTestEnvironment: boolean;
|
||||
}
|
||||
|
||||
const LOG_LEVELS: Record<LogLevel, number> = {
|
||||
debug: 0,
|
||||
info: 1,
|
||||
warn: 2,
|
||||
error: 3,
|
||||
silent: 4,
|
||||
};
|
||||
|
||||
/**
|
||||
* App-level Fireflies application logger with configurable log levels.
|
||||
*/
|
||||
export class AppLogger {
|
||||
private config: LoggerConfig;
|
||||
private context: string;
|
||||
|
||||
constructor(context: string) {
|
||||
this.context = context;
|
||||
this.config = {
|
||||
logLevel: this.parseLogLevel(process.env.LOG_LEVEL || 'error'),
|
||||
isTestEnvironment: process.env.NODE_ENV === 'test' || process.env.JEST_WORKER_ID !== undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private parseLogLevel(level: string): LogLevel {
|
||||
const normalizedLevel = level.toLowerCase() as LogLevel;
|
||||
return Object.keys(LOG_LEVELS).includes(normalizedLevel) ? normalizedLevel : 'error';
|
||||
}
|
||||
|
||||
private shouldLog(level: LogLevel): boolean {
|
||||
return LOG_LEVELS[level] >= LOG_LEVELS[this.config.logLevel];
|
||||
}
|
||||
|
||||
/**
|
||||
* Log debug information (LOG_LEVEL=debug)
|
||||
*/
|
||||
debug(message: string, ...args: any[]): void {
|
||||
if (this.shouldLog('debug')) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[${this.context}] ${message}`, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log informational messages (LOG_LEVEL=info or lower)
|
||||
*/
|
||||
info(message: string, ...args: any[]): void {
|
||||
if (this.shouldLog('info')) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[${this.context}] ${message}`, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log warnings (LOG_LEVEL=warn or lower)
|
||||
*/
|
||||
warn(message: string, ...args: any[]): void {
|
||||
if (this.shouldLog('warn')) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`[${this.context}] ${message}`, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log errors (LOG_LEVEL=error or lower)
|
||||
*/
|
||||
error(message: string, ...args: any[]): void {
|
||||
if (this.shouldLog('error')) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`[${this.context}] ${message}`, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log critical errors that should ALWAYS be visible regardless of log level
|
||||
* Use sparingly - only for fatal errors, security issues, or data corruption
|
||||
*/
|
||||
critical(message: string, ...args: any[]): void {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`[${this.context}] CRITICAL: ${message}`, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function to create loggers with automatic context detection
|
||||
*/
|
||||
export const createLogger = (context: string): AppLogger => {
|
||||
return new AppLogger(context);
|
||||
};
|
||||
-130
@@ -1,130 +0,0 @@
|
||||
// Fireflies API Types
|
||||
export type FirefliesParticipant = {
|
||||
email: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type FirefliesWebhookPayload = {
|
||||
meetingId: string;
|
||||
eventType: string;
|
||||
clientReferenceId?: string;
|
||||
};
|
||||
|
||||
export type FirefliesMeetingData = {
|
||||
id: string;
|
||||
title: string;
|
||||
date: string;
|
||||
duration: number;
|
||||
participants: FirefliesParticipant[];
|
||||
organizer_email?: string;
|
||||
summary: {
|
||||
action_items: string[];
|
||||
keywords?: string[];
|
||||
overview: string;
|
||||
gist?: string;
|
||||
topics_discussed?: string[];
|
||||
meeting_type?: string;
|
||||
bullet_gist?: string;
|
||||
};
|
||||
analytics?: {
|
||||
sentiments?: {
|
||||
positive_pct: number;
|
||||
negative_pct: number;
|
||||
neutral_pct: number;
|
||||
};
|
||||
};
|
||||
transcript_url: string;
|
||||
recording_url?: string;
|
||||
summary_status?: string;
|
||||
};
|
||||
|
||||
// Configuration Types
|
||||
export type SummaryStrategy = 'immediate_only' | 'immediate_with_retry' | 'delayed_polling' | 'basic_only';
|
||||
|
||||
export type SummaryFetchConfig = {
|
||||
strategy: SummaryStrategy;
|
||||
retryAttempts: number;
|
||||
retryDelay: number;
|
||||
pollInterval: number;
|
||||
maxPolls: number;
|
||||
};
|
||||
|
||||
export type WebhookConfig = {
|
||||
secret: string;
|
||||
apiUrl: string;
|
||||
};
|
||||
|
||||
// Processing Result Types
|
||||
export type ProcessResult = {
|
||||
success: boolean;
|
||||
meetingId?: string;
|
||||
noteIds?: string[];
|
||||
newContacts?: string[];
|
||||
errors?: string[];
|
||||
debug?: string[];
|
||||
summaryReady?: boolean;
|
||||
summaryPending?: boolean;
|
||||
enhancementScheduled?: boolean;
|
||||
actionItemsCount?: number;
|
||||
sentimentScore?: number;
|
||||
meetingType?: string;
|
||||
keyTopics?: string[];
|
||||
};
|
||||
|
||||
// Twenty CRM Types
|
||||
export type GraphQLResponse<T> = {
|
||||
data: T;
|
||||
errors?: Array<{
|
||||
message?: string;
|
||||
extensions?: { code?: string }
|
||||
}>;
|
||||
};
|
||||
|
||||
export type IdNode = { id: string };
|
||||
|
||||
export type FindMeetingResponse = {
|
||||
meetings: { edges: Array<{ node: IdNode }> };
|
||||
};
|
||||
|
||||
export type FindPeopleResponse = {
|
||||
people: { edges: Array<{ node: { id: string; emails: { primaryEmail: string } } }> };
|
||||
};
|
||||
|
||||
export type CreatePersonResponse = {
|
||||
createPerson: { id: string }
|
||||
};
|
||||
|
||||
export type CreateNoteResponse = {
|
||||
createNote: { id: string }
|
||||
};
|
||||
|
||||
export type CreateMeetingResponse = {
|
||||
createMeeting: { id: string }
|
||||
};
|
||||
|
||||
export type Contact = {
|
||||
id: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
export type MeetingCreateInput = {
|
||||
name: string;
|
||||
noteId?: string | null; // This is the relation field
|
||||
meetingDate: string;
|
||||
duration: number;
|
||||
meetingType?: string | null;
|
||||
keywords?: string | null;
|
||||
sentimentScore?: number | null;
|
||||
positivePercent?: number | null;
|
||||
negativePercent?: number | null;
|
||||
actionItemsCount: number;
|
||||
transcriptUrl?: { primaryLinkUrl: string; primaryLinkLabel: string } | null;
|
||||
recordingUrl?: { primaryLinkUrl: string; primaryLinkLabel: string } | null;
|
||||
firefliesMeetingId: string;
|
||||
organizerEmail?: string | null;
|
||||
importStatus?: 'SUCCESS' | 'FAILED' | 'PENDING' | 'RETRYING' | null;
|
||||
importError?: string | null;
|
||||
lastImportAttempt?: string | null;
|
||||
importAttempts?: number | null;
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
main,
|
||||
type FirefliesMeetingData,
|
||||
type FirefliesWebhookPayload,
|
||||
} from '../actions/receive-fireflies-notes';
|
||||
} from '../';
|
||||
|
||||
// Helper to generate HMAC signature
|
||||
const generateHMACSignature = (body: string, secret: string): string => {
|
||||
@@ -39,10 +39,12 @@ const mockFirefliesApiResponseWithSummary = {
|
||||
meeting_type: 'Sales Call',
|
||||
bullet_gist: '• Demonstrated core product features\n• Discussed enterprise pricing\n• Addressed integration questions',
|
||||
},
|
||||
sentiments: { // Note: Raw API has sentiments at top level, not in analytics
|
||||
positive_pct: 75,
|
||||
negative_pct: 10,
|
||||
neutral_pct: 15,
|
||||
analytics: {
|
||||
sentiments: {
|
||||
positive_pct: 75,
|
||||
negative_pct: 10,
|
||||
neutral_pct: 15,
|
||||
},
|
||||
},
|
||||
transcript_url: 'https://app.fireflies.ai/transcript/test-001',
|
||||
video_url: 'https://app.fireflies.ai/recording/test-001',
|
||||
@@ -81,7 +83,7 @@ const mockMeetingWithFullSummary: FirefliesMeetingData = {
|
||||
},
|
||||
},
|
||||
transcript_url: 'https://app.fireflies.ai/transcript/test-001',
|
||||
recording_url: 'https://app.fireflies.ai/recording/test-001',
|
||||
video_url: 'https://app.fireflies.ai/recording/test-001',
|
||||
summary_status: 'completed',
|
||||
};
|
||||
|
||||
@@ -404,7 +406,11 @@ describe('Fireflies Webhook Integration v2', () => {
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.summaryReady).toBe(true);
|
||||
expect(result.actionItemsCount).toBe(3);
|
||||
expect(result.sentimentScore).toBeCloseTo(0.75, 2); // Use toBeCloseTo for floating point comparison
|
||||
expect(result.sentimentAnalysis).toEqual({
|
||||
positive_pct: 75,
|
||||
negative_pct: 10,
|
||||
neutral_pct: 15,
|
||||
});
|
||||
expect(result.meetingType).toBe('Sales Call');
|
||||
expect(result.keyTopics).toEqual(['product features', 'pricing discussion', 'integration capabilities', 'support options']);
|
||||
});
|
||||
@@ -565,7 +571,7 @@ describe('Fireflies Webhook Integration v2', () => {
|
||||
|
||||
const createNoteMock = jest.fn();
|
||||
|
||||
global.fetch = jest.fn().mockImplementation((url: string, options?: any) => {
|
||||
global.fetch = jest.fn().mockImplementation((url: string, options?: RequestInit) => {
|
||||
if (url === 'https://api.fireflies.ai/graphql') {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
@@ -576,9 +582,9 @@ describe('Fireflies Webhook Integration v2', () => {
|
||||
}
|
||||
|
||||
// Twenty API
|
||||
const body = options?.body ? JSON.parse(options.body) : {};
|
||||
if (body.query?.includes('createNote')) {
|
||||
createNoteMock(body.variables);
|
||||
const requestBody = options?.body ? JSON.parse(options.body as string) : {};
|
||||
if (requestBody.query?.includes('createNote')) {
|
||||
createNoteMock(requestBody.variables);
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
@@ -610,7 +616,7 @@ describe('Fireflies Webhook Integration v2', () => {
|
||||
expect(noteData.data.bodyV2.markdown).toContain('## Overview'); // Markdown header, not bold
|
||||
expect(noteData.data.bodyV2.markdown).toContain('## Action Items'); // Markdown header, not bold
|
||||
expect(noteData.data.bodyV2.markdown).toContain('**Sentiment:**'); // This is bold
|
||||
expect(noteData.data.bodyV2.markdown).toContain('[View Full Transcript]');
|
||||
expect(noteData.data.bodyV2.markdown).toContain('View Full Transcript on Fireflies');
|
||||
});
|
||||
|
||||
it('should create meeting records for multi-party meetings', async () => {
|
||||
@@ -624,7 +630,7 @@ describe('Fireflies Webhook Integration v2', () => {
|
||||
|
||||
const createMeetingMock = jest.fn();
|
||||
|
||||
global.fetch = jest.fn().mockImplementation((url: string, options?: any) => {
|
||||
global.fetch = jest.fn().mockImplementation((url: string, options?: RequestInit) => {
|
||||
if (url === 'https://api.fireflies.ai/graphql') {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
@@ -635,9 +641,9 @@ describe('Fireflies Webhook Integration v2', () => {
|
||||
}
|
||||
|
||||
// Twenty API
|
||||
const body = options?.body ? JSON.parse(options.body) : {};
|
||||
if (body.query?.includes('createMeeting')) {
|
||||
createMeetingMock(body.variables);
|
||||
const requestBody = options?.body ? JSON.parse(options.body as string) : {};
|
||||
if (requestBody.query?.includes('createMeeting')) {
|
||||
createMeetingMock(requestBody.variables);
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
@@ -645,7 +651,7 @@ describe('Fireflies Webhook Integration v2', () => {
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (body.query?.includes('createNote')) {
|
||||
if (requestBody.query?.includes('createNote')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
@@ -694,7 +700,7 @@ describe('Fireflies Webhook Integration v2', () => {
|
||||
});
|
||||
|
||||
it('should handle missing payload gracefully', async () => {
|
||||
const result = await main(null as any, {});
|
||||
const result = await main(null as unknown, {});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toBeDefined();
|
||||
@@ -703,10 +709,10 @@ describe('Fireflies Webhook Integration v2', () => {
|
||||
it('should handle invalid payload structure', async () => {
|
||||
const invalidPayload = { invalid: 'data' };
|
||||
|
||||
const result = await main(invalidPayload as any, {});
|
||||
const result = await main(invalidPayload as unknown, {});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toBeDefined();
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { HistoricalImporter } from '../historical-importer';
|
||||
import type { FirefliesMeetingData, SummaryFetchConfig } from '../types';
|
||||
|
||||
const summaryConfig: SummaryFetchConfig = {
|
||||
strategy: 'immediate_with_retry',
|
||||
retryAttempts: 1,
|
||||
retryDelay: 0,
|
||||
pollInterval: 0,
|
||||
maxPolls: 0,
|
||||
};
|
||||
|
||||
const sampleMeeting: FirefliesMeetingData = {
|
||||
id: 'm-1',
|
||||
title: 'Sample',
|
||||
date: new Date().toISOString(),
|
||||
duration: 30,
|
||||
participants: [],
|
||||
summary: { action_items: [], overview: '' },
|
||||
transcript_url: 'https://example.com',
|
||||
};
|
||||
|
||||
describe('HistoricalImporter', () => {
|
||||
const buildImporter = () => {
|
||||
const firefliesClient = {
|
||||
listTranscripts: jest.fn(),
|
||||
fetchMeetingDataWithRetry: jest.fn(),
|
||||
} as unknown as jest.Mocked<any>;
|
||||
|
||||
const twentyService = {
|
||||
findMeetingByFirefliesId: jest.fn(),
|
||||
matchParticipantsToContacts: jest.fn(),
|
||||
createContactsForUnmatched: jest.fn(),
|
||||
createNoteOnly: jest.fn(),
|
||||
createMeeting: jest.fn(),
|
||||
createNoteTarget: jest.fn(),
|
||||
} as unknown as jest.Mocked<any>;
|
||||
|
||||
return { firefliesClient, twentyService };
|
||||
};
|
||||
|
||||
it('skips meetings that already exist by firefliesMeetingId', async () => {
|
||||
const { firefliesClient, twentyService } = buildImporter();
|
||||
|
||||
firefliesClient.listTranscripts.mockResolvedValue([{ id: 'existing' }]);
|
||||
twentyService.findMeetingByFirefliesId.mockResolvedValue({ id: 'twenty-id' });
|
||||
|
||||
const importer = new HistoricalImporter(firefliesClient, twentyService);
|
||||
const result = await importer.run(
|
||||
{},
|
||||
{ dryRun: false, autoCreateContacts: true, summaryConfig, plan: 'free' },
|
||||
);
|
||||
|
||||
expect(result.skippedExisting).toBe(1);
|
||||
expect(result.imported).toBe(0);
|
||||
expect(twentyService.createMeeting).not.toHaveBeenCalled();
|
||||
expect(result.statuses[0].status).toBe('skipped_existing');
|
||||
});
|
||||
|
||||
it('supports dry-run without writing to Twenty', async () => {
|
||||
const { firefliesClient, twentyService } = buildImporter();
|
||||
|
||||
firefliesClient.listTranscripts.mockResolvedValue([{ id: 'm-2' }]);
|
||||
firefliesClient.fetchMeetingDataWithRetry.mockResolvedValue({
|
||||
data: sampleMeeting,
|
||||
summaryReady: false,
|
||||
});
|
||||
twentyService.findMeetingByFirefliesId.mockResolvedValue(undefined);
|
||||
|
||||
const importer = new HistoricalImporter(firefliesClient, twentyService);
|
||||
const result = await importer.run(
|
||||
{},
|
||||
{ dryRun: true, autoCreateContacts: false, summaryConfig, plan: 'free' },
|
||||
);
|
||||
|
||||
expect(result.imported).toBe(1);
|
||||
expect(result.summaryPending).toBe(1);
|
||||
expect(twentyService.createMeeting).not.toHaveBeenCalled();
|
||||
expect(twentyService.createNoteOnly).not.toHaveBeenCalled();
|
||||
expect(result.statuses).toHaveLength(1);
|
||||
expect(result.statuses[0].status).toBe('pending_summary');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ process.env.AUTO_CREATE_CONTACTS = 'true';
|
||||
process.env.SERVER_URL = 'http://localhost:3000';
|
||||
process.env.TWENTY_API_KEY = 'test-api-key';
|
||||
process.env.LOG_LEVEL = 'silent';
|
||||
process.env.CAPTURE_LOGS = 'false';
|
||||
|
||||
// Reset mocks before each test
|
||||
beforeEach(() => {
|
||||
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
import { WebhookHandler } from '../webhook-handler';
|
||||
|
||||
describe('WebhookHandler log capture', () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = {
|
||||
...originalEnv,
|
||||
FIREFLIES_WEBHOOK_SECRET: 'testsecret',
|
||||
FIREFLIES_API_KEY: '',
|
||||
TWENTY_API_KEY: '',
|
||||
CAPTURE_LOGS: 'false',
|
||||
LOG_LEVEL: 'silent',
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it('includes debug logs in response when CAPTURE_LOGS is true', async () => {
|
||||
process.env.CAPTURE_LOGS = 'true';
|
||||
|
||||
const handler = new WebhookHandler();
|
||||
const result = await handler.handle(null);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(Array.isArray(result.debug)).toBe(true);
|
||||
});
|
||||
|
||||
it('omits debug logs when CAPTURE_LOGS is false', async () => {
|
||||
process.env.CAPTURE_LOGS = 'false';
|
||||
|
||||
const handler = new WebhookHandler();
|
||||
const result = await handler.handle(null);
|
||||
|
||||
expect(result.debug).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
export {
|
||||
config,
|
||||
main,
|
||||
type FirefliesMeetingData,
|
||||
type FirefliesParticipant,
|
||||
type FirefliesWebhookPayload,
|
||||
type ProcessResult,
|
||||
type SummaryFetchConfig,
|
||||
type SummaryStrategy
|
||||
} from '../../serverlessFunctions/receive-fireflies-notes/src/index';
|
||||
|
||||
@@ -0,0 +1,823 @@
|
||||
import { createLogger } from './logger';
|
||||
import {
|
||||
FIREFLIES_PLANS,
|
||||
type FirefliesMeetingData,
|
||||
type FirefliesParticipant,
|
||||
type FirefliesPlan,
|
||||
type FirefliesTranscriptListItem,
|
||||
type FirefliesTranscriptListOptions,
|
||||
type SummaryFetchConfig
|
||||
} from './types';
|
||||
|
||||
const logger = createLogger('fireflies-api');
|
||||
|
||||
export class FirefliesApiClient {
|
||||
private apiKey: string;
|
||||
|
||||
constructor(apiKey: string) {
|
||||
if (!apiKey) {
|
||||
logger.critical('FIREFLIES_API_KEY is required but not provided - this is a critical configuration error');
|
||||
throw new Error('FIREFLIES_API_KEY is required');
|
||||
}
|
||||
this.apiKey = apiKey;
|
||||
}
|
||||
|
||||
async listTranscripts(options: FirefliesTranscriptListOptions = {}): Promise<FirefliesTranscriptListItem[]> {
|
||||
const {
|
||||
organizers,
|
||||
participants,
|
||||
hostEmail,
|
||||
participantEmail,
|
||||
userId,
|
||||
channelId,
|
||||
mine,
|
||||
fromDate,
|
||||
toDate,
|
||||
pageSize = 50,
|
||||
maxRecords = 500,
|
||||
} = options;
|
||||
|
||||
const sanitizedOrganizers = organizers?.filter(Boolean);
|
||||
const sanitizedParticipants = participants?.filter(Boolean);
|
||||
|
||||
const transcripts: FirefliesTranscriptListItem[] = [];
|
||||
let skip = options.skip ?? 0;
|
||||
const limit = options.limit ?? pageSize;
|
||||
|
||||
const baseQuery = `
|
||||
query Transcripts(
|
||||
$limit: Int
|
||||
$skip: Int
|
||||
$hostEmail: String
|
||||
$participantEmail: String
|
||||
$organizers: [String!]
|
||||
$participants: [String!]
|
||||
$userId: String
|
||||
$channelId: String
|
||||
$mine: Boolean
|
||||
$date: Float
|
||||
) {
|
||||
transcripts(
|
||||
limit: $limit
|
||||
skip: $skip
|
||||
host_email: $hostEmail
|
||||
participant_email: $participantEmail
|
||||
organizers: $organizers
|
||||
participants: $participants
|
||||
user_id: $userId
|
||||
channel_id: $channelId
|
||||
mine: $mine
|
||||
date: $date
|
||||
) {
|
||||
id
|
||||
title
|
||||
date
|
||||
duration
|
||||
organizer_email
|
||||
participants
|
||||
transcript_url
|
||||
meeting_link
|
||||
meeting_info { summary_status }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
while (transcripts.length < maxRecords) {
|
||||
const pageVariables = {
|
||||
limit,
|
||||
skip,
|
||||
hostEmail,
|
||||
participantEmail,
|
||||
organizers: sanitizedOrganizers,
|
||||
participants: sanitizedParticipants,
|
||||
userId,
|
||||
channelId,
|
||||
mine,
|
||||
date: fromDate,
|
||||
};
|
||||
|
||||
const page = await this.executeTranscriptListQuery(baseQuery, pageVariables);
|
||||
const normalized = page
|
||||
.map((item) => {
|
||||
const normalizedDate = this.normalizeDate(item.date);
|
||||
return {
|
||||
id: (item.id as string) || '',
|
||||
title: (item.title as string) || 'Untitled Meeting',
|
||||
date: normalizedDate,
|
||||
duration: (item.duration as number) || 0,
|
||||
organizer_email: item.organizer_email as string | undefined,
|
||||
participants: Array.isArray(item.participants)
|
||||
? (item.participants as string[])
|
||||
: undefined,
|
||||
transcript_url: item.transcript_url as string | undefined,
|
||||
meeting_link: item.meeting_link as string | undefined,
|
||||
summary_status: (item.meeting_info as { summary_status?: string } | undefined)?.summary_status,
|
||||
};
|
||||
})
|
||||
.filter((item) => {
|
||||
if (toDate && item.date) {
|
||||
const itemTime = Date.parse(item.date);
|
||||
if (!Number.isNaN(itemTime) && itemTime > toDate) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
transcripts.push(...normalized);
|
||||
|
||||
if (page.length < limit) {
|
||||
break;
|
||||
}
|
||||
|
||||
skip += limit;
|
||||
}
|
||||
|
||||
if (transcripts.length > maxRecords) {
|
||||
return transcripts.slice(0, maxRecords);
|
||||
}
|
||||
|
||||
return transcripts;
|
||||
}
|
||||
|
||||
async fetchMeetingData(
|
||||
meetingId: string,
|
||||
options?: { timeout?: number; plan?: FirefliesPlan }
|
||||
): Promise<FirefliesMeetingData> {
|
||||
const plan = options?.plan ?? FIREFLIES_PLANS.FREE;
|
||||
const isPremiumPlan =
|
||||
plan === FIREFLIES_PLANS.BUSINESS || plan === FIREFLIES_PLANS.ENTERPRISE;
|
||||
|
||||
// Minimal query for free plans - only basic fields available on all plans
|
||||
// Note: audio_url requires Pro+, video_url requires Business+
|
||||
const freeQuery = `
|
||||
query GetTranscriptMinimal($transcriptId: String!) {
|
||||
transcript(id: $transcriptId) {
|
||||
id
|
||||
title
|
||||
date
|
||||
duration
|
||||
participants
|
||||
organizer_email
|
||||
transcript_url
|
||||
meeting_link
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// Standard query for pro plans - adds speakers, summary, sentences, and audio_url (Pro+)
|
||||
// Note: video_url requires Business+
|
||||
const proQuery = `
|
||||
query GetTranscriptBasic($transcriptId: String!) {
|
||||
transcript(id: $transcriptId) {
|
||||
id
|
||||
title
|
||||
date
|
||||
duration
|
||||
participants
|
||||
organizer_email
|
||||
speakers {
|
||||
name
|
||||
}
|
||||
sentences {
|
||||
index
|
||||
speaker_name
|
||||
text
|
||||
start_time
|
||||
end_time
|
||||
}
|
||||
summary {
|
||||
overview
|
||||
keywords
|
||||
action_items
|
||||
notes
|
||||
gist
|
||||
bullet_gist
|
||||
short_summary
|
||||
short_overview
|
||||
outline
|
||||
shorthand_bullet
|
||||
}
|
||||
meeting_info {
|
||||
summary_status
|
||||
}
|
||||
transcript_url
|
||||
audio_url
|
||||
meeting_link
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// Full query for business/enterprise - includes all fields
|
||||
const businessQuery = `
|
||||
query GetTranscriptFull($transcriptId: String!) {
|
||||
transcript(id: $transcriptId) {
|
||||
id
|
||||
title
|
||||
date
|
||||
duration
|
||||
participants
|
||||
organizer_email
|
||||
analytics {
|
||||
sentiments {
|
||||
positive_pct
|
||||
negative_pct
|
||||
neutral_pct
|
||||
}
|
||||
categories {
|
||||
questions
|
||||
tasks
|
||||
metrics
|
||||
date_times
|
||||
}
|
||||
speakers {
|
||||
speaker_id
|
||||
name
|
||||
duration
|
||||
word_count
|
||||
longest_monologue
|
||||
filler_words
|
||||
questions
|
||||
words_per_minute
|
||||
}
|
||||
}
|
||||
meeting_attendees {
|
||||
displayName
|
||||
email
|
||||
phoneNumber
|
||||
name
|
||||
location
|
||||
}
|
||||
meeting_attendance {
|
||||
name
|
||||
join_time
|
||||
leave_time
|
||||
}
|
||||
speakers {
|
||||
name
|
||||
}
|
||||
sentences {
|
||||
index
|
||||
speaker_name
|
||||
text
|
||||
start_time
|
||||
end_time
|
||||
ai_filters {
|
||||
task
|
||||
question
|
||||
sentiment
|
||||
}
|
||||
}
|
||||
summary {
|
||||
action_items
|
||||
overview
|
||||
keywords
|
||||
notes
|
||||
gist
|
||||
bullet_gist
|
||||
short_summary
|
||||
short_overview
|
||||
outline
|
||||
shorthand_bullet
|
||||
topics_discussed
|
||||
meeting_type
|
||||
transcript_chapters
|
||||
}
|
||||
meeting_info {
|
||||
summary_status
|
||||
}
|
||||
transcript_url
|
||||
audio_url
|
||||
video_url
|
||||
meeting_link
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// Select query based on plan
|
||||
const queryToUse = isPremiumPlan ? businessQuery :
|
||||
(plan === FIREFLIES_PLANS.PRO ? proQuery : freeQuery);
|
||||
|
||||
const planFeatures = {
|
||||
[FIREFLIES_PLANS.FREE]: 'basic fields only (no summary, no audio/video)',
|
||||
[FIREFLIES_PLANS.PRO]: 'summary, speakers, audio_url',
|
||||
[FIREFLIES_PLANS.BUSINESS]: 'full access including analytics, video_url',
|
||||
[FIREFLIES_PLANS.ENTERPRISE]: 'full access including analytics, video_url',
|
||||
};
|
||||
logger.debug(`using ${plan} plan query (${planFeatures[plan]})`);
|
||||
|
||||
try {
|
||||
return await this.executeTranscriptQuery({
|
||||
meetingId,
|
||||
query: queryToUse,
|
||||
timeout: options?.timeout,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
|
||||
// Detect plan-specific errors
|
||||
const requiresBusiness = message.toLowerCase().includes('business or higher');
|
||||
const requiresPro = message.toLowerCase().includes('pro or higher');
|
||||
const planError = requiresBusiness || requiresPro ||
|
||||
message.toLowerCase().includes('higher plan') ||
|
||||
message.includes('Cannot query field');
|
||||
|
||||
// Fallback cascade: business -> pro -> free
|
||||
if (planError) {
|
||||
if (isPremiumPlan) {
|
||||
logger.warn(`Plan limitation detected (configured: ${plan}), falling back to pro query`);
|
||||
try {
|
||||
return await this.executeTranscriptQuery({
|
||||
meetingId,
|
||||
query: proQuery,
|
||||
timeout: options?.timeout,
|
||||
});
|
||||
} catch (proError) {
|
||||
const proMessage = proError instanceof Error ? proError.message : String(proError);
|
||||
if (proMessage.toLowerCase().includes('plan') || proMessage.includes('Cannot query field')) {
|
||||
logger.warn('Pro query also failed, falling back to minimal free query');
|
||||
return this.executeTranscriptQuery({
|
||||
meetingId,
|
||||
query: freeQuery,
|
||||
timeout: options?.timeout,
|
||||
});
|
||||
}
|
||||
throw proError;
|
||||
}
|
||||
} else if (plan === FIREFLIES_PLANS.PRO) {
|
||||
logger.warn(`Pro plan query failed (${requiresBusiness ? 'requires Business+' : 'unknown restriction'}), falling back to free query`);
|
||||
return this.executeTranscriptQuery({
|
||||
meetingId,
|
||||
query: freeQuery,
|
||||
timeout: options?.timeout,
|
||||
});
|
||||
} else {
|
||||
// Already using free query - some field might still be restricted
|
||||
logger.error(
|
||||
'Fireflies API rejected the minimal free query. This may indicate: ' +
|
||||
'1) The transcript ID is invalid, or ' +
|
||||
'2) Your API key does not have access to this transcript, or ' +
|
||||
'3) An unexpected API restriction : open an issue'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async fetchMeetingDataWithRetry(
|
||||
meetingId: string,
|
||||
config: SummaryFetchConfig,
|
||||
plan: FirefliesPlan = FIREFLIES_PLANS.FREE
|
||||
): Promise<{ data: FirefliesMeetingData; summaryReady: boolean }> {
|
||||
// immediate_only: single attempt, no retries
|
||||
if (config.strategy === 'immediate_only') {
|
||||
logger.debug(`fetching meeting ${meetingId} (strategy: immediate_only)`);
|
||||
const meetingData = await this.fetchMeetingData(meetingId, { timeout: 10000, plan });
|
||||
const ready = this.isSummaryReady(meetingData);
|
||||
logger.debug(`summary ready: ${ready}`);
|
||||
return { data: meetingData, summaryReady: ready };
|
||||
}
|
||||
|
||||
// immediate_with_retry: retry with linear backoff
|
||||
logger.debug(`fetching meeting ${meetingId} (strategy: immediate_with_retry, maxAttempts: ${config.retryAttempts})`);
|
||||
|
||||
for (let attempt = 1; attempt <= config.retryAttempts; attempt++) {
|
||||
try {
|
||||
const meetingData = await this.fetchMeetingData(meetingId, { timeout: 10000, plan });
|
||||
const ready = this.isSummaryReady(meetingData);
|
||||
|
||||
logger.debug(`attempt ${attempt}/${config.retryAttempts}: summary ready=${ready}`);
|
||||
|
||||
if (ready) {
|
||||
return { data: meetingData, summaryReady: true };
|
||||
}
|
||||
|
||||
if (attempt < config.retryAttempts) {
|
||||
const delayMs = config.retryDelay * attempt;
|
||||
logger.debug(`summary not ready, waiting ${delayMs}ms before retry ${attempt + 1}`);
|
||||
await new Promise(resolve => setTimeout(resolve, delayMs));
|
||||
} else {
|
||||
logger.debug(`max retries reached, returning partial data`);
|
||||
return { data: meetingData, summaryReady: false };
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
logger.error(`attempt ${attempt}/${config.retryAttempts} failed: ${errorMsg}`);
|
||||
|
||||
if (attempt === config.retryAttempts) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const delayMs = config.retryDelay * attempt;
|
||||
logger.debug(`retrying in ${delayMs}ms...`);
|
||||
await new Promise(resolve => setTimeout(resolve, delayMs));
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Failed to fetch meeting data after retries');
|
||||
}
|
||||
|
||||
private async executeTranscriptQuery({
|
||||
meetingId,
|
||||
query,
|
||||
timeout,
|
||||
}: {
|
||||
meetingId: string;
|
||||
query: string;
|
||||
timeout?: number;
|
||||
}): Promise<FirefliesMeetingData> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = timeout ? setTimeout(() => controller.abort(), timeout) : null;
|
||||
|
||||
try {
|
||||
const response = await fetch('https://api.fireflies.ai/graphql', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${this.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query,
|
||||
variables: { transcriptId: meetingId },
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
let errorDetails = `Fireflies API request failed with status ${response.status}`;
|
||||
try {
|
||||
const errorBody = await response.text();
|
||||
if (errorBody) {
|
||||
errorDetails += `: ${errorBody}`;
|
||||
}
|
||||
} catch {
|
||||
// Ignore if we can't read the response body
|
||||
}
|
||||
throw new Error(errorDetails);
|
||||
}
|
||||
|
||||
const json = await response.json() as {
|
||||
data?: { transcript?: Record<string, unknown> };
|
||||
errors?: Array<{ message?: string }>;
|
||||
};
|
||||
|
||||
if (json.errors && json.errors.length > 0) {
|
||||
throw new Error(`Fireflies API error: ${json.errors[0]?.message || 'Unknown error'}`);
|
||||
}
|
||||
|
||||
const transcript = json.data?.transcript;
|
||||
if (!transcript) {
|
||||
throw new Error('Invalid response from Fireflies API: missing transcript data');
|
||||
}
|
||||
|
||||
return this.transformMeetingData(transcript, meetingId);
|
||||
} finally {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
private isSummaryReady(meetingData: FirefliesMeetingData): boolean {
|
||||
return (
|
||||
(meetingData.summary?.action_items?.length > 0) ||
|
||||
(meetingData.summary?.overview?.length > 0) ||
|
||||
meetingData.summary_status === 'completed'
|
||||
);
|
||||
}
|
||||
|
||||
private extractAllParticipants(transcript: Record<string, unknown>): FirefliesParticipant[] {
|
||||
const participantsWithEmails: FirefliesParticipant[] = [];
|
||||
const participantsNameOnly: FirefliesParticipant[] = [];
|
||||
|
||||
logger.debug('=== PARTICIPANT EXTRACTION DEBUG ===');
|
||||
logger.debug('participants field:', JSON.stringify(transcript.participants));
|
||||
logger.debug('meeting_attendees field:', JSON.stringify(transcript.meeting_attendees));
|
||||
logger.debug('speakers field:', (transcript.speakers as Array<{ name: string }>)?.map((s) => s.name));
|
||||
logger.debug('meeting_attendance field:', (transcript.meeting_attendance as Array<{ name: string }>)?.map((a) => a.name));
|
||||
logger.debug('organizer_email:', transcript.organizer_email);
|
||||
|
||||
const isEmail = (str: string): boolean => {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(str.trim());
|
||||
};
|
||||
|
||||
const isDuplicate = (name: string, email: string): boolean => {
|
||||
const nameLower = name.toLowerCase().trim();
|
||||
const emailLower = email.toLowerCase().trim();
|
||||
|
||||
return participantsWithEmails.some(p =>
|
||||
p.name.toLowerCase().trim() === nameLower ||
|
||||
(email && p.email.toLowerCase() === emailLower)
|
||||
) || participantsNameOnly.some(p =>
|
||||
p.name.toLowerCase().trim() === nameLower
|
||||
);
|
||||
};
|
||||
|
||||
// 1. Extract from legacy participants field (with emails)
|
||||
if (transcript.participants && Array.isArray(transcript.participants)) {
|
||||
transcript.participants.forEach((participant: string) => {
|
||||
const parts = participant.split(',').map(p => p.trim());
|
||||
|
||||
parts.forEach(part => {
|
||||
const emailMatch = part.match(/<([^>]+)>/);
|
||||
const email = emailMatch ? emailMatch[1] : '';
|
||||
const name = emailMatch
|
||||
? part.substring(0, part.indexOf('<')).trim()
|
||||
: part.trim();
|
||||
|
||||
if (isEmail(name)) {
|
||||
logger.debug(`Skipping participant with email as name: "${name}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDuplicate(name, email)) {
|
||||
logger.debug(`Skipping duplicate participant: "${name}" <${email}>`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (name && email) {
|
||||
participantsWithEmails.push({ name, email });
|
||||
} else if (name) {
|
||||
participantsNameOnly.push({ name, email: '' });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Extract from meeting_attendees field (structured)
|
||||
if (transcript.meeting_attendees && Array.isArray(transcript.meeting_attendees)) {
|
||||
transcript.meeting_attendees.forEach((attendee: Record<string, unknown>) => {
|
||||
const name = (attendee.displayName || attendee.name || '') as string;
|
||||
const email = (attendee.email || '') as string;
|
||||
|
||||
if (isEmail(name)) {
|
||||
logger.debug(`Skipping attendee with email as name: "${name}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (name && !isDuplicate(name, email)) {
|
||||
if (email) {
|
||||
participantsWithEmails.push({ name, email });
|
||||
} else {
|
||||
participantsNameOnly.push({ name, email: '' });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Extract from speakers field (name only)
|
||||
if (transcript.speakers && Array.isArray(transcript.speakers)) {
|
||||
transcript.speakers.forEach((speaker: Record<string, unknown>) => {
|
||||
const name = (speaker.name || '') as string;
|
||||
|
||||
if (isEmail(name)) {
|
||||
logger.debug(`Skipping speaker with email as name: "${name}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (name && !isDuplicate(name, '')) {
|
||||
participantsNameOnly.push({ name, email: '' });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Extract from meeting_attendance field (name only)
|
||||
if (transcript.meeting_attendance && Array.isArray(transcript.meeting_attendance)) {
|
||||
transcript.meeting_attendance.forEach((attendance: Record<string, unknown>) => {
|
||||
const name = (attendance.name || '') as string;
|
||||
|
||||
if (isEmail(name) || name.includes(',')) {
|
||||
logger.debug(`Skipping attendance with email/list as name: "${name}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (name && !isDuplicate(name, '')) {
|
||||
participantsNameOnly.push({ name, email: '' });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 5. Add organizer email if available and not already included
|
||||
const organizerEmail = transcript.organizer_email as string | undefined;
|
||||
if (organizerEmail) {
|
||||
const existsWithEmail = participantsWithEmails.some(p =>
|
||||
p.email.toLowerCase() === organizerEmail.toLowerCase()
|
||||
);
|
||||
|
||||
if (!existsWithEmail) {
|
||||
let organizerName = '';
|
||||
|
||||
const emailUsername = organizerEmail.split('@')[0].toLowerCase();
|
||||
const emailNameVariations = [emailUsername];
|
||||
|
||||
if (transcript.speakers && Array.isArray(transcript.speakers)) {
|
||||
const potentialOrganizerSpeaker = transcript.speakers.find((speaker: Record<string, unknown>) => {
|
||||
const name = ((speaker.name || '') as string).toLowerCase();
|
||||
return emailNameVariations.some(variation =>
|
||||
name.includes(variation) || variation.includes(name)
|
||||
);
|
||||
}) as Record<string, unknown> | undefined;
|
||||
if (potentialOrganizerSpeaker) {
|
||||
organizerName = potentialOrganizerSpeaker.name as string;
|
||||
}
|
||||
}
|
||||
|
||||
if (!organizerName && transcript.meeting_attendance && Array.isArray(transcript.meeting_attendance)) {
|
||||
const potentialOrganizerAttendance = transcript.meeting_attendance.find((attendance: Record<string, unknown>) => {
|
||||
const name = ((attendance.name || '') as string).toLowerCase();
|
||||
return emailNameVariations.some(variation =>
|
||||
name.includes(variation) || variation.includes(name)
|
||||
);
|
||||
}) as Record<string, unknown> | undefined;
|
||||
if (potentialOrganizerAttendance) {
|
||||
organizerName = potentialOrganizerAttendance.name as string;
|
||||
}
|
||||
}
|
||||
|
||||
if (organizerName) {
|
||||
participantsWithEmails.push({ name: organizerName, email: organizerEmail });
|
||||
|
||||
const nameIndex = participantsNameOnly.findIndex(p =>
|
||||
p.name.toLowerCase().includes(organizerName.toLowerCase()) ||
|
||||
organizerName.toLowerCase().includes(p.name.toLowerCase())
|
||||
);
|
||||
if (nameIndex !== -1) {
|
||||
participantsNameOnly.splice(nameIndex, 1);
|
||||
}
|
||||
} else {
|
||||
participantsWithEmails.push({ name: 'Meeting Organizer', email: organizerEmail });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const allParticipants = [...participantsWithEmails, ...participantsNameOnly];
|
||||
|
||||
logger.debug('=== EXTRACTED PARTICIPANTS ===');
|
||||
logger.debug('With emails:', participantsWithEmails.length, JSON.stringify(participantsWithEmails));
|
||||
logger.debug('Name only:', participantsNameOnly.length, JSON.stringify(participantsNameOnly));
|
||||
logger.debug('Total:', allParticipants.length);
|
||||
|
||||
return allParticipants;
|
||||
}
|
||||
|
||||
private transformMeetingData(transcript: Record<string, unknown>, meetingId: string): FirefliesMeetingData {
|
||||
let dateString: string;
|
||||
if (transcript.date) {
|
||||
if (typeof transcript.date === 'number') {
|
||||
dateString = new Date(transcript.date).toISOString();
|
||||
} else if (typeof transcript.date === 'string') {
|
||||
const parsed = Number(transcript.date);
|
||||
if (!isNaN(parsed)) {
|
||||
dateString = new Date(parsed).toISOString();
|
||||
} else {
|
||||
dateString = transcript.date;
|
||||
}
|
||||
} else {
|
||||
dateString = new Date().toISOString();
|
||||
}
|
||||
} else {
|
||||
dateString = new Date().toISOString();
|
||||
}
|
||||
|
||||
const summary = transcript.summary as Record<string, unknown> | undefined;
|
||||
const analytics = transcript.analytics as Record<string, unknown> | undefined;
|
||||
const sentiments = analytics?.sentiments as Record<string, number> | undefined;
|
||||
const categories = analytics?.categories as Record<string, number> | undefined;
|
||||
const speakersAnalytics = analytics?.speakers as Array<Record<string, unknown>> | undefined;
|
||||
const meetingInfo = transcript.meeting_info as Record<string, unknown> | undefined;
|
||||
|
||||
// Transform sentences array
|
||||
const rawSentences = transcript.sentences as Array<Record<string, unknown>> | undefined;
|
||||
const sentences = rawSentences?.map(s => ({
|
||||
index: (s.index as number) || 0,
|
||||
speaker_name: (s.speaker_name as string) || 'Unknown',
|
||||
text: (s.text as string) || '',
|
||||
start_time: (s.start_time as string) || '0',
|
||||
end_time: (s.end_time as string) || '0',
|
||||
ai_filters: s.ai_filters as { task?: boolean; question?: boolean; sentiment?: string } | undefined,
|
||||
}));
|
||||
|
||||
// Transform speaker analytics
|
||||
const speakers = speakersAnalytics?.map(sp => ({
|
||||
speaker_id: (sp.speaker_id as string) || '',
|
||||
name: (sp.name as string) || 'Unknown',
|
||||
duration: (sp.duration as number) || 0,
|
||||
word_count: (sp.word_count as number) || 0,
|
||||
longest_monologue: (sp.longest_monologue as number) || 0,
|
||||
filler_words: (sp.filler_words as number) || 0,
|
||||
questions: (sp.questions as number) || 0,
|
||||
words_per_minute: (sp.words_per_minute as number) || 0,
|
||||
}));
|
||||
|
||||
return {
|
||||
id: (transcript.id as string) || meetingId,
|
||||
title: (transcript.title as string) || 'Untitled Meeting',
|
||||
date: dateString,
|
||||
duration: (transcript.duration as number) || 0,
|
||||
participants: this.extractAllParticipants(transcript),
|
||||
organizer_email: transcript.organizer_email as string | undefined,
|
||||
sentences,
|
||||
summary: {
|
||||
// action_items can be string or array - normalize to array
|
||||
action_items: Array.isArray(summary?.action_items)
|
||||
? summary.action_items as string[]
|
||||
: (typeof summary?.action_items === 'string' && summary.action_items.trim()
|
||||
? summary.action_items.split('\n').filter((item: string) => item.trim())
|
||||
: []),
|
||||
overview: (summary?.overview as string) || '',
|
||||
notes: summary?.notes as string | undefined,
|
||||
gist: summary?.gist as string | undefined,
|
||||
bullet_gist: summary?.bullet_gist as string | undefined,
|
||||
short_summary: summary?.short_summary as string | undefined,
|
||||
short_overview: summary?.short_overview as string | undefined,
|
||||
outline: summary?.outline as string | undefined,
|
||||
shorthand_bullet: summary?.shorthand_bullet as string | undefined,
|
||||
keywords: summary?.keywords as string[] | undefined,
|
||||
topics_discussed: summary?.topics_discussed as string[] | undefined,
|
||||
meeting_type: summary?.meeting_type as string | undefined,
|
||||
transcript_chapters: summary?.transcript_chapters as string[] | undefined,
|
||||
},
|
||||
analytics: (sentiments || categories || speakers) ? {
|
||||
sentiments: sentiments ? {
|
||||
positive_pct: sentiments.positive_pct || 0,
|
||||
negative_pct: sentiments.negative_pct || 0,
|
||||
neutral_pct: sentiments.neutral_pct || 0,
|
||||
} : undefined,
|
||||
categories: categories ? {
|
||||
questions: categories.questions || 0,
|
||||
tasks: categories.tasks || 0,
|
||||
metrics: categories.metrics || 0,
|
||||
date_times: categories.date_times || 0,
|
||||
} : undefined,
|
||||
speakers,
|
||||
} : undefined,
|
||||
meeting_info: meetingInfo ? {
|
||||
summary_status: meetingInfo.summary_status as string | undefined,
|
||||
} : undefined,
|
||||
// URLs by plan availability:
|
||||
transcript_url: (transcript.transcript_url as string) || `https://app.fireflies.ai/view/${meetingId}`,
|
||||
audio_url: transcript.audio_url as string | undefined, // Pro+
|
||||
video_url: transcript.video_url as string | undefined, // Business+
|
||||
meeting_link: transcript.meeting_link as string | undefined, // All plans
|
||||
summary_status: (meetingInfo?.summary_status as string) || (transcript.summary_status as string) || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private async executeTranscriptListQuery(
|
||||
query: string,
|
||||
variables: Record<string, unknown>,
|
||||
): Promise<Array<Record<string, unknown>>> {
|
||||
const response = await fetch('https://api.fireflies.ai/graphql', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({ query, variables }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorBody = await response.text();
|
||||
throw new Error(`Fireflies transcripts request failed: ${response.status} ${errorBody}`);
|
||||
}
|
||||
|
||||
const json = await response.json() as {
|
||||
data?: { transcripts?: Array<Record<string, unknown>> };
|
||||
errors?: Array<{ message?: string }>;
|
||||
};
|
||||
|
||||
if (json.errors && json.errors.length > 0) {
|
||||
const message = json.errors[0]?.message || 'Unknown error';
|
||||
throw new Error(`Fireflies API error: ${message}`);
|
||||
}
|
||||
|
||||
return json.data?.transcripts ?? [];
|
||||
}
|
||||
|
||||
private normalizeDate(dateValue: unknown): string | undefined {
|
||||
if (!dateValue) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof dateValue === 'number') {
|
||||
return new Date(dateValue).toISOString();
|
||||
}
|
||||
|
||||
if (typeof dateValue === 'string') {
|
||||
const parsed = Number(dateValue);
|
||||
if (!Number.isNaN(parsed)) {
|
||||
return new Date(parsed).toISOString();
|
||||
}
|
||||
return dateValue;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
import type { FirefliesMeetingData, FirefliesSentence, MeetingCreateInput } from './types';
|
||||
|
||||
export class MeetingFormatter {
|
||||
// Format timestamp from seconds to MM:SS
|
||||
private static formatTimestamp(timeStr: string): string {
|
||||
const seconds = parseFloat(timeStr);
|
||||
if (isNaN(seconds)) return '00:00';
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// Format full transcript from sentences
|
||||
private static formatTranscript(sentences: FirefliesSentence[]): string {
|
||||
if (!sentences || sentences.length === 0) return '';
|
||||
|
||||
let transcript = '';
|
||||
let currentSpeaker = '';
|
||||
|
||||
for (const sentence of sentences) {
|
||||
const timestamp = this.formatTimestamp(sentence.start_time);
|
||||
const speaker = sentence.speaker_name || 'Unknown';
|
||||
|
||||
// Add speaker header when speaker changes
|
||||
if (speaker !== currentSpeaker) {
|
||||
currentSpeaker = speaker;
|
||||
transcript += `\n**${speaker}** [${timestamp}]\n`;
|
||||
}
|
||||
|
||||
transcript += `${sentence.text} `;
|
||||
}
|
||||
|
||||
return transcript.trim();
|
||||
}
|
||||
|
||||
static formatNoteBody(meetingData: FirefliesMeetingData): string {
|
||||
const meetingDate = meetingData.date ? new Date(meetingData.date) : null;
|
||||
const hasValidDate = meetingDate instanceof Date && !Number.isNaN(meetingDate.getTime());
|
||||
const formattedDate = hasValidDate
|
||||
? meetingDate.toLocaleString('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
: 'Unknown date';
|
||||
const durationMinutes = Math.round(meetingData.duration);
|
||||
|
||||
let noteBody = `**Date:** ${formattedDate}\n`;
|
||||
noteBody += `**Duration:** ${durationMinutes} minutes\n`;
|
||||
|
||||
if (meetingData.participants.length > 0) {
|
||||
const participantNames = meetingData.participants.map(p => p.name).join(', ');
|
||||
noteBody += `**Participants:** ${participantNames}\n`;
|
||||
}
|
||||
|
||||
// Overview section
|
||||
if (meetingData.summary?.overview) {
|
||||
noteBody += `\n## Overview\n${meetingData.summary.overview}\n`;
|
||||
}
|
||||
|
||||
// Detailed AI Notes (the rich content from Fireflies)
|
||||
if (meetingData.summary?.notes) {
|
||||
noteBody += `\n## Meeting Notes\n${meetingData.summary.notes}\n`;
|
||||
}
|
||||
|
||||
// Bullet gist with emojis (if available and different from notes)
|
||||
if (meetingData.summary?.bullet_gist && !meetingData.summary?.notes) {
|
||||
noteBody += `\n## Key Points\n${meetingData.summary.bullet_gist}\n`;
|
||||
}
|
||||
|
||||
// Meeting outline with timestamps (shorthand_bullet contains the timestamped outline)
|
||||
const outline = meetingData.summary?.outline || meetingData.summary?.shorthand_bullet;
|
||||
if (outline) {
|
||||
noteBody += `\n## Outline\n${outline}\n`;
|
||||
}
|
||||
|
||||
// Key topics
|
||||
if (meetingData.summary?.topics_discussed?.length) {
|
||||
noteBody += `\n## Key Topics\n`;
|
||||
meetingData.summary.topics_discussed.forEach(topic => {
|
||||
noteBody += `- ${topic}\n`;
|
||||
});
|
||||
}
|
||||
|
||||
// Action items
|
||||
if (meetingData.summary?.action_items?.length) {
|
||||
noteBody += `\n## Action Items\n`;
|
||||
meetingData.summary.action_items.forEach(item => {
|
||||
noteBody += `- [ ] ${item}\n`;
|
||||
});
|
||||
}
|
||||
|
||||
// Insights section
|
||||
noteBody += `\n## Insights\n`;
|
||||
|
||||
if (meetingData.summary?.keywords?.length) {
|
||||
noteBody += `**Keywords:** ${meetingData.summary.keywords.join(', ')}\n`;
|
||||
}
|
||||
|
||||
if (meetingData.analytics?.sentiments) {
|
||||
const sentiments = meetingData.analytics.sentiments;
|
||||
noteBody += `**Sentiment:** ${sentiments.positive_pct}% positive, ${sentiments.negative_pct}% negative, ${sentiments.neutral_pct}% neutral\n`;
|
||||
}
|
||||
|
||||
if (meetingData.summary?.meeting_type) {
|
||||
noteBody += `**Meeting Type:** ${meetingData.summary.meeting_type}\n`;
|
||||
}
|
||||
|
||||
// Speaker analytics (Business+)
|
||||
if (meetingData.analytics?.speakers?.length) {
|
||||
noteBody += `\n### Speaker Stats\n`;
|
||||
for (const speaker of meetingData.analytics.speakers) {
|
||||
const talkTime = Math.round(speaker.duration / 60);
|
||||
noteBody += `- **${speaker.name}**: ${talkTime} min talk time, ${speaker.word_count} words, ${speaker.questions} questions\n`;
|
||||
}
|
||||
}
|
||||
|
||||
// Meeting metrics (Business+)
|
||||
if (meetingData.analytics?.categories) {
|
||||
const cats = meetingData.analytics.categories;
|
||||
noteBody += `\n### Meeting Metrics\n`;
|
||||
noteBody += `- Questions asked: ${cats.questions}\n`;
|
||||
noteBody += `- Tasks identified: ${cats.tasks}\n`;
|
||||
if (cats.metrics > 0) noteBody += `- Metrics mentioned: ${cats.metrics}\n`;
|
||||
if (cats.date_times > 0) noteBody += `- Dates/times discussed: ${cats.date_times}\n`;
|
||||
}
|
||||
|
||||
// Resources section
|
||||
noteBody += `\n## Resources\n`;
|
||||
noteBody += `[View Full Transcript on Fireflies](${meetingData.transcript_url})\n`;
|
||||
|
||||
if (meetingData.video_url) {
|
||||
noteBody += `[Watch Video Recording](${meetingData.video_url})\n`;
|
||||
}
|
||||
|
||||
if (meetingData.audio_url) {
|
||||
noteBody += `[Listen to Audio](${meetingData.audio_url})\n`;
|
||||
}
|
||||
|
||||
if (meetingData.meeting_link) {
|
||||
noteBody += `[Original Meeting Link](${meetingData.meeting_link})\n`;
|
||||
}
|
||||
|
||||
return noteBody;
|
||||
}
|
||||
|
||||
static toMeetingCreateInput(
|
||||
meetingData: FirefliesMeetingData,
|
||||
noteId?: string
|
||||
): MeetingCreateInput {
|
||||
const durationMinutes = Math.round(meetingData.duration);
|
||||
const hasSummary = Boolean(meetingData.summary?.overview || meetingData.summary?.action_items?.length);
|
||||
const hasAnalytics = Boolean(meetingData.analytics?.sentiments);
|
||||
|
||||
// Build input object with only defined values (omit null fields)
|
||||
const input: MeetingCreateInput = {
|
||||
name: meetingData.title,
|
||||
meetingDate: meetingData.date,
|
||||
duration: durationMinutes,
|
||||
actionItemsCount: meetingData.summary?.action_items?.length || 0,
|
||||
firefliesMeetingId: meetingData.id,
|
||||
};
|
||||
|
||||
// Add direct relationship to note if noteId is provided
|
||||
if (noteId) {
|
||||
input.noteId = noteId;
|
||||
}
|
||||
|
||||
// Basic fields (All plans)
|
||||
if (meetingData.organizer_email) {
|
||||
input.organizerEmail = meetingData.organizer_email;
|
||||
}
|
||||
if (meetingData.transcript_url?.trim()) {
|
||||
input.transcriptUrl = {
|
||||
primaryLinkUrl: meetingData.transcript_url,
|
||||
primaryLinkLabel: 'View Transcript'
|
||||
};
|
||||
}
|
||||
if (meetingData.meeting_link?.trim()) {
|
||||
input.meetingLink = {
|
||||
primaryLinkUrl: meetingData.meeting_link,
|
||||
primaryLinkLabel: 'Join Meeting'
|
||||
};
|
||||
}
|
||||
|
||||
// Pro+ fields (transcript, summary, notes, keywords, audio)
|
||||
if (meetingData.sentences?.length) {
|
||||
input.transcript = this.formatTranscript(meetingData.sentences);
|
||||
}
|
||||
if (meetingData.summary?.overview) {
|
||||
input.overview = meetingData.summary.overview;
|
||||
}
|
||||
if (meetingData.summary?.notes) {
|
||||
input.notes = meetingData.summary.notes;
|
||||
}
|
||||
if (meetingData.summary?.keywords?.length) {
|
||||
input.keywords = meetingData.summary.keywords.join(', ');
|
||||
}
|
||||
if (meetingData.audio_url?.trim()) {
|
||||
input.audioUrl = {
|
||||
primaryLinkUrl: meetingData.audio_url,
|
||||
primaryLinkLabel: 'Listen to Audio'
|
||||
};
|
||||
}
|
||||
|
||||
// Business+ fields (analytics, video, detailed summary)
|
||||
if (meetingData.summary?.meeting_type) {
|
||||
input.meetingType = meetingData.summary.meeting_type;
|
||||
}
|
||||
if (meetingData.summary?.topics_discussed?.length) {
|
||||
input.topics = meetingData.summary.topics_discussed.join(', ');
|
||||
}
|
||||
if (meetingData.analytics?.sentiments) {
|
||||
const sentiments = meetingData.analytics.sentiments;
|
||||
input.positivePercent = sentiments.positive_pct;
|
||||
input.negativePercent = sentiments.negative_pct;
|
||||
input.neutralPercent = sentiments.neutral_pct;
|
||||
}
|
||||
if (meetingData.video_url?.trim()) {
|
||||
input.videoUrl = {
|
||||
primaryLinkUrl: meetingData.video_url,
|
||||
primaryLinkLabel: 'Watch Video'
|
||||
};
|
||||
}
|
||||
|
||||
// Import status based on data completeness
|
||||
const isPartial = !hasSummary && !hasAnalytics;
|
||||
input.importStatus = isPartial ? 'PARTIAL' : 'SUCCESS';
|
||||
input.lastImportAttempt = new Date().toISOString();
|
||||
input.importAttempts = 1;
|
||||
|
||||
return input;
|
||||
}
|
||||
|
||||
static toFailedMeetingCreateInput(
|
||||
meetingId: string,
|
||||
title: string,
|
||||
error: string,
|
||||
attempts: number = 1
|
||||
): MeetingCreateInput {
|
||||
const currentDate = new Date().toISOString();
|
||||
|
||||
return {
|
||||
name: title || `Failed Meeting Import - ${meetingId}`,
|
||||
meetingDate: currentDate,
|
||||
duration: 0,
|
||||
actionItemsCount: 0,
|
||||
firefliesMeetingId: meetingId,
|
||||
importStatus: 'FAILED',
|
||||
importError: error,
|
||||
lastImportAttempt: currentDate,
|
||||
importAttempts: attempts,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { type FirefliesApiClient } from './fireflies-api-client';
|
||||
import { MeetingFormatter } from './formatters';
|
||||
import { createLogger } from './logger';
|
||||
import { type TwentyCrmService } from './twenty-crm-service';
|
||||
import type {
|
||||
FirefliesPlan,
|
||||
FirefliesTranscriptListOptions,
|
||||
SummaryFetchConfig,
|
||||
} from './types';
|
||||
|
||||
const logger = createLogger('historical-importer');
|
||||
|
||||
export type HistoricalImportFilters = FirefliesTranscriptListOptions;
|
||||
|
||||
export type HistoricalImportOptions = {
|
||||
dryRun?: boolean;
|
||||
autoCreateContacts: boolean;
|
||||
summaryConfig: SummaryFetchConfig;
|
||||
plan: FirefliesPlan;
|
||||
};
|
||||
|
||||
export type HistoricalImportResult = {
|
||||
dryRun: boolean;
|
||||
totalListed: number;
|
||||
imported: number;
|
||||
skippedExisting: number;
|
||||
failed: Array<{ meetingId: string; reason: string }>;
|
||||
summaryPending: number;
|
||||
statuses: Array<{
|
||||
meetingId: string;
|
||||
title?: string;
|
||||
status: 'imported' | 'skipped_existing' | 'failed' | 'dry_run' | 'pending_summary';
|
||||
reason?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export class HistoricalImporter {
|
||||
private firefliesClient: FirefliesApiClient;
|
||||
private twentyService: TwentyCrmService;
|
||||
|
||||
constructor(firefliesClient: FirefliesApiClient, twentyService: TwentyCrmService) {
|
||||
this.firefliesClient = firefliesClient;
|
||||
this.twentyService = twentyService;
|
||||
}
|
||||
|
||||
async run(
|
||||
filters: HistoricalImportFilters,
|
||||
options: HistoricalImportOptions,
|
||||
): Promise<HistoricalImportResult> {
|
||||
const { dryRun = false, autoCreateContacts, summaryConfig, plan } = options;
|
||||
|
||||
logger.info('Listing Fireflies transcripts for historical import...');
|
||||
const transcripts = await this.firefliesClient.listTranscripts(filters);
|
||||
logger.info(`Found ${transcripts.length} transcript(s) to process`);
|
||||
|
||||
let imported = 0;
|
||||
let skippedExisting = 0;
|
||||
let summaryPending = 0;
|
||||
const failed: Array<{ meetingId: string; reason: string }> = [];
|
||||
const statuses: HistoricalImportResult['statuses'] = [];
|
||||
|
||||
for (const transcript of transcripts) {
|
||||
const meetingId = transcript.id;
|
||||
|
||||
try {
|
||||
const existing = await this.twentyService.findMeetingByFirefliesId(meetingId);
|
||||
if (existing) {
|
||||
logger.debug(`Skipping ${meetingId}: already exists in Twenty (${existing.id})`);
|
||||
skippedExisting += 1;
|
||||
statuses.push({
|
||||
meetingId,
|
||||
title: transcript.title,
|
||||
status: 'skipped_existing',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.info(`Fetching meeting ${meetingId} details`);
|
||||
const { data: meetingData, summaryReady } =
|
||||
await this.firefliesClient.fetchMeetingDataWithRetry(
|
||||
meetingId,
|
||||
summaryConfig,
|
||||
plan,
|
||||
);
|
||||
|
||||
const isPendingSummary = summaryReady === false;
|
||||
if (isPendingSummary) {
|
||||
summaryPending += 1;
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
logger.info(`[dry-run] Would import meeting "${meetingData.title}" (${meetingId})`);
|
||||
imported += 1;
|
||||
statuses.push({
|
||||
meetingId,
|
||||
title: meetingData.title,
|
||||
status: isPendingSummary ? 'pending_summary' : 'dry_run',
|
||||
reason: isPendingSummary ? 'summary not ready' : undefined,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const { matchedContacts, unmatchedParticipants } =
|
||||
await this.twentyService.matchParticipantsToContacts(
|
||||
meetingData.participants,
|
||||
);
|
||||
|
||||
const newContactIds = autoCreateContacts
|
||||
? await this.twentyService.createContactsForUnmatched(unmatchedParticipants)
|
||||
: [];
|
||||
const allContactIds = [...matchedContacts.map(({ id }) => id), ...newContactIds];
|
||||
|
||||
const noteBody = MeetingFormatter.formatNoteBody(meetingData);
|
||||
const noteId = await this.twentyService.createNoteOnly(
|
||||
`Meeting: ${meetingData.title}`,
|
||||
noteBody,
|
||||
);
|
||||
|
||||
const meetingInput = MeetingFormatter.toMeetingCreateInput(meetingData, noteId);
|
||||
const createdMeetingId = await this.twentyService.createMeeting(meetingInput);
|
||||
|
||||
for (const contactId of allContactIds) {
|
||||
await this.twentyService.createNoteTarget(noteId, contactId);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Imported meeting "${meetingData.title}" (${meetingId}) as ${createdMeetingId}`,
|
||||
);
|
||||
imported += 1;
|
||||
statuses.push({
|
||||
meetingId,
|
||||
title: meetingData.title,
|
||||
status: isPendingSummary ? 'pending_summary' : 'imported',
|
||||
reason: isPendingSummary ? 'summary not ready' : undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
logger.error(`Failed to import meeting ${meetingId}: ${reason}`);
|
||||
failed.push({ meetingId, reason });
|
||||
statuses.push({
|
||||
meetingId,
|
||||
title: transcript.title,
|
||||
status: 'failed',
|
||||
reason,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
dryRun,
|
||||
totalListed: transcripts.length,
|
||||
imported,
|
||||
skippedExisting,
|
||||
failed,
|
||||
summaryPending,
|
||||
statuses,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// Main exports for the Fireflies integration
|
||||
export { config, main } from './receive-fireflies-notes';
|
||||
|
||||
// Types
|
||||
export type {
|
||||
FirefliesMeetingData,
|
||||
FirefliesParticipant,
|
||||
FirefliesWebhookPayload,
|
||||
ProcessResult,
|
||||
SummaryFetchConfig,
|
||||
SummaryStrategy
|
||||
} from './types';
|
||||
|
||||
// Services (for advanced usage)
|
||||
export { FirefliesApiClient } from './fireflies-api-client';
|
||||
export { MeetingFormatter } from './formatters';
|
||||
export { TwentyCrmService } from './twenty-crm-service';
|
||||
export { WebhookHandler } from './webhook-handler';
|
||||
|
||||
// Objects
|
||||
export { Meeting } from './objects';
|
||||
|
||||
// Utilities
|
||||
export { createLogger } from './logger';
|
||||
export { getApiUrl, getSummaryFetchConfig, shouldAutoCreateContacts, toBoolean } from './utils';
|
||||
export {
|
||||
getWebhookSecretFingerprint,
|
||||
isValidFirefliesPayload,
|
||||
verifyWebhookSignature
|
||||
} from './webhook-validator';
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'silent';
|
||||
|
||||
export type LoggerConfig = {
|
||||
logLevel: LogLevel;
|
||||
isTestEnvironment: boolean;
|
||||
captureForResponse: boolean;
|
||||
};
|
||||
|
||||
const LOG_LEVELS: Record<LogLevel, number> = {
|
||||
debug: 0,
|
||||
info: 1,
|
||||
warn: 2,
|
||||
error: 3,
|
||||
silent: 4,
|
||||
};
|
||||
|
||||
const loggerRegistry = new Set<AppLogger>();
|
||||
|
||||
export class AppLogger {
|
||||
private config: LoggerConfig;
|
||||
private context: string;
|
||||
private capturedLogs: string[] = [];
|
||||
|
||||
constructor(context: string) {
|
||||
this.context = context;
|
||||
this.config = {
|
||||
logLevel: this.parseLogLevel(process.env.LOG_LEVEL || 'error'),
|
||||
isTestEnvironment: process.env.NODE_ENV === 'test' || process.env.JEST_WORKER_ID !== undefined,
|
||||
captureForResponse: process.env.CAPTURE_LOGS === 'true',
|
||||
};
|
||||
|
||||
// Silence logs in test environment unless explicitly overridden
|
||||
if (this.config.isTestEnvironment && process.env.LOG_LEVEL === undefined) {
|
||||
this.config.logLevel = 'silent';
|
||||
}
|
||||
}
|
||||
|
||||
private parseLogLevel(level: string): LogLevel {
|
||||
const normalizedLevel = level.toLowerCase() as LogLevel;
|
||||
return Object.keys(LOG_LEVELS).includes(normalizedLevel) ? normalizedLevel : 'error';
|
||||
}
|
||||
|
||||
private shouldLog(level: LogLevel): boolean {
|
||||
return LOG_LEVELS[level] >= LOG_LEVELS[this.config.logLevel];
|
||||
}
|
||||
|
||||
private safeStringify(value: unknown): string {
|
||||
try {
|
||||
if (typeof value === 'string') return value;
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return '[unserializable]';
|
||||
}
|
||||
}
|
||||
|
||||
private captureLog(level: LogLevel, message: string, ...args: unknown[]): void {
|
||||
if (!this.config.captureForResponse) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
const formattedMessage =
|
||||
args.length > 0
|
||||
? `${message} ${args.map((arg) => this.safeStringify(arg)).join(' ')}`
|
||||
: message;
|
||||
|
||||
this.capturedLogs.push(
|
||||
`[${timestamp}] [${level.toUpperCase()}] [${this.context}] ${formattedMessage}`,
|
||||
);
|
||||
}
|
||||
|
||||
debug(message: string, ...args: unknown[]): void {
|
||||
this.captureLog('debug', message, ...args);
|
||||
if (this.shouldLog('debug')) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[${this.context}] ${message}`, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
info(message: string, ...args: unknown[]): void {
|
||||
this.captureLog('info', message, ...args);
|
||||
if (this.shouldLog('info')) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[${this.context}] ${message}`, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
warn(message: string, ...args: unknown[]): void {
|
||||
this.captureLog('warn', message, ...args);
|
||||
if (this.shouldLog('warn')) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`[${this.context}] ${message}`, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
error(message: string, ...args: unknown[]): void {
|
||||
this.captureLog('error', message, ...args);
|
||||
if (this.shouldLog('error')) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`[${this.context}] ${message}`, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
// For fatal errors, security issues, or data corruption - always visible
|
||||
critical(message: string, ...args: unknown[]): void {
|
||||
this.captureLog('error', `CRITICAL: ${message}`, ...args);
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`[${this.context}] CRITICAL: ${message}`, ...args);
|
||||
}
|
||||
|
||||
getCapturedLogs(): string[] {
|
||||
return [...this.capturedLogs];
|
||||
}
|
||||
|
||||
clearCapturedLogs(): void {
|
||||
this.capturedLogs = [];
|
||||
}
|
||||
}
|
||||
|
||||
export const createLogger = (context: string): AppLogger => {
|
||||
const logger = new AppLogger(context);
|
||||
loggerRegistry.add(logger);
|
||||
return logger;
|
||||
};
|
||||
|
||||
export const removeLogger = (logger: AppLogger): void => {
|
||||
loggerRegistry.delete(logger);
|
||||
};
|
||||
|
||||
export const getAllCapturedLogs = (): string[] => {
|
||||
const allLogs: string[] = [];
|
||||
for (const logger of loggerRegistry) {
|
||||
allLogs.push(...logger.getCapturedLogs());
|
||||
}
|
||||
return allLogs.sort();
|
||||
};
|
||||
|
||||
export const clearAllCapturedLogs = (): void => {
|
||||
for (const logger of loggerRegistry) {
|
||||
logger.clearCapturedLogs();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export { Meeting } from './meeting';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ObjectMetadata } from 'twenty-sdk/application';
|
||||
import { Object } from 'twenty-sdk';
|
||||
|
||||
@ObjectMetadata({
|
||||
@Object({
|
||||
universalIdentifier: 'd1831348-b4a4-4426-9c0b-0af19e7a9c27',
|
||||
nameSingular: 'meeting',
|
||||
namePlural: 'meetings',
|
||||
|
||||
+4
-3
@@ -1,4 +1,4 @@
|
||||
import { type ServerlessFunctionConfig } from 'twenty-sdk/application';
|
||||
import { type FunctionConfig } from 'twenty-sdk';
|
||||
import type { ProcessResult } from './types';
|
||||
import { WebhookHandler } from './webhook-handler';
|
||||
|
||||
@@ -10,7 +10,7 @@ export const main = async (
|
||||
return handler.handle(params, headers);
|
||||
};
|
||||
|
||||
export const config: ServerlessFunctionConfig = {
|
||||
export const config: FunctionConfig = {
|
||||
universalIdentifier: '2d3ea303-667c-4bbe-9e3d-db6ffb9d6c74',
|
||||
name: 'receive-fireflies-notes',
|
||||
description:
|
||||
@@ -22,7 +22,8 @@ export const config: ServerlessFunctionConfig = {
|
||||
type: 'route',
|
||||
path: '/webhook/fireflies',
|
||||
httpMethod: 'POST',
|
||||
isAuthRequired: true,
|
||||
isAuthRequired: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
+75
-68
@@ -43,6 +43,20 @@ export class TwentyCrmService {
|
||||
return response.data?.meetings?.edges?.[0]?.node;
|
||||
}
|
||||
|
||||
async findMeetingByFirefliesId(meetingId: string): Promise<IdNode | undefined> {
|
||||
const query = `
|
||||
query FindMeetingByFirefliesId($meetingId: String!) {
|
||||
meetings(filter: { firefliesMeetingId: { eq: $meetingId } }) {
|
||||
edges { node { id } }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const variables = { meetingId };
|
||||
const response = await this.gqlRequest<FindMeetingResponse>(query, variables);
|
||||
return response.data?.meetings?.edges?.[0]?.node;
|
||||
}
|
||||
|
||||
async matchParticipantsToContacts(
|
||||
participants: FirefliesParticipant[],
|
||||
): Promise<{
|
||||
@@ -53,21 +67,18 @@ export class TwentyCrmService {
|
||||
return { matchedContacts: [], unmatchedParticipants: [] };
|
||||
}
|
||||
|
||||
// Split participants into those with emails and those with names only
|
||||
const participantsWithEmails = participants.filter(p => p.email && p.email.trim());
|
||||
const participantsNameOnly = participants.filter(p => !p.email || !p.email.trim());
|
||||
|
||||
let matchedContacts: Contact[] = [];
|
||||
let unmatchedParticipants: FirefliesParticipant[] = [];
|
||||
|
||||
// 1. Match by email first
|
||||
if (participantsWithEmails.length > 0) {
|
||||
const emailMatches = await this.matchByEmail(participantsWithEmails);
|
||||
matchedContacts.push(...emailMatches.matchedContacts);
|
||||
unmatchedParticipants.push(...emailMatches.unmatchedParticipants);
|
||||
}
|
||||
|
||||
// 2. For participants without emails, try name-based matching
|
||||
if (participantsNameOnly.length > 0) {
|
||||
const nameMatches = await this.matchByName(participantsNameOnly, matchedContacts);
|
||||
matchedContacts.push(...nameMatches.matchedContacts);
|
||||
@@ -126,7 +137,6 @@ export class TwentyCrmService {
|
||||
const matchedContacts: Contact[] = [];
|
||||
const unmatchedParticipants: FirefliesParticipant[] = [];
|
||||
|
||||
// Get set of already matched contact IDs to avoid duplicates
|
||||
const alreadyMatchedIds = new Set(alreadyMatchedContacts.map(c => c.id));
|
||||
|
||||
for (const participant of participants) {
|
||||
@@ -152,27 +162,37 @@ export class TwentyCrmService {
|
||||
const firstName = nameParts[0];
|
||||
const lastName = nameParts.slice(1).join(' ');
|
||||
|
||||
// Try exact name match first
|
||||
let query = `
|
||||
query FindPeopleByName($firstName: String!, $lastName: String) {
|
||||
people(filter: {
|
||||
and: [
|
||||
{ name: { firstName: { eq: $firstName } } }
|
||||
${lastName ? '{ name: { lastName: { eq: $lastName } } }' : ''}
|
||||
]
|
||||
}) {
|
||||
edges { node { id emails { primaryEmail } name { firstName lastName } } }
|
||||
}
|
||||
}
|
||||
`;
|
||||
const hasLastName = Boolean(lastName);
|
||||
|
||||
let variables: any = { firstName };
|
||||
if (lastName) {
|
||||
variables.lastName = lastName;
|
||||
}
|
||||
const query = hasLastName
|
||||
? `
|
||||
query FindPeopleByName($firstName: String!, $lastName: String!) {
|
||||
people(filter: {
|
||||
and: [
|
||||
{ name: { firstName: { eq: $firstName } } }
|
||||
{ name: { lastName: { eq: $lastName } } }
|
||||
]
|
||||
}) {
|
||||
edges { node { id emails { primaryEmail } name { firstName lastName } } }
|
||||
}
|
||||
}
|
||||
`
|
||||
: `
|
||||
query FindPeopleByName($firstName: String!) {
|
||||
people(filter: {
|
||||
name: { firstName: { eq: $firstName } }
|
||||
}) {
|
||||
edges { node { id emails { primaryEmail } name { firstName lastName } } }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const variables: Record<string, unknown> = hasLastName
|
||||
? { firstName, lastName }
|
||||
: { firstName };
|
||||
|
||||
try {
|
||||
const response = await this.gqlRequest<any>(query, variables);
|
||||
const response = await this.gqlRequest<{ people: { edges: Array<{ node: { id: string; emails?: { primaryEmail?: string }; name?: { firstName?: string; lastName?: string } } }> } }>(query, variables);
|
||||
const people = response.data?.people?.edges;
|
||||
|
||||
if (people && people.length > 0) {
|
||||
@@ -183,9 +203,8 @@ export class TwentyCrmService {
|
||||
};
|
||||
}
|
||||
|
||||
// If no exact match and we have a last name, try fuzzy matching
|
||||
if (lastName) {
|
||||
query = `
|
||||
if (hasLastName) {
|
||||
const fuzzyQuery = `
|
||||
query FindPeopleByNameFuzzy($firstName: String!) {
|
||||
people(filter: { name: { firstName: { ilike: $firstName } } }) {
|
||||
edges { node { id emails { primaryEmail } name { firstName lastName } } }
|
||||
@@ -193,12 +212,11 @@ export class TwentyCrmService {
|
||||
}
|
||||
`;
|
||||
|
||||
const fuzzyResponse = await this.gqlRequest<any>(query, { firstName: `%${firstName}%` });
|
||||
const fuzzyResponse = await this.gqlRequest<{ people: { edges: Array<{ node: { id: string; emails?: { primaryEmail?: string }; name?: { firstName?: string; lastName?: string } } }> } }>(fuzzyQuery, { firstName: `%${firstName}%` });
|
||||
const fuzzyPeople = fuzzyResponse.data?.people?.edges;
|
||||
|
||||
if (fuzzyPeople && fuzzyPeople.length > 0) {
|
||||
// Find best match by checking if last name contains our target
|
||||
const bestMatch = fuzzyPeople.find((edge: any) => {
|
||||
const bestMatch = fuzzyPeople.find((edge) => {
|
||||
const personLastName = edge.node.name?.lastName || '';
|
||||
return personLastName.toLowerCase().includes(lastName.toLowerCase());
|
||||
});
|
||||
@@ -215,7 +233,6 @@ export class TwentyCrmService {
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
// Silently fail - don't break the entire process for a single contact lookup
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -225,17 +242,14 @@ export class TwentyCrmService {
|
||||
): Promise<string[]> {
|
||||
const newContactIds: string[] = [];
|
||||
|
||||
// Split participants into those with emails and those with names only
|
||||
const participantsWithEmails = participants.filter(p => p.email && p.email.trim());
|
||||
const participantsNameOnly = participants.filter(p => !p.email || !p.email.trim());
|
||||
|
||||
// Process participants with emails
|
||||
if (participantsWithEmails.length > 0) {
|
||||
const emailContactIds = await this.createContactsWithEmails(participantsWithEmails);
|
||||
newContactIds.push(...emailContactIds);
|
||||
}
|
||||
|
||||
// Process participants with names only
|
||||
if (participantsNameOnly.length > 0) {
|
||||
const nameContactIds = await this.createContactsNameOnly(participantsNameOnly);
|
||||
newContactIds.push(...nameContactIds);
|
||||
@@ -247,7 +261,6 @@ export class TwentyCrmService {
|
||||
private async createContactsWithEmails(participants: FirefliesParticipant[]): Promise<string[]> {
|
||||
const newContactIds: string[] = [];
|
||||
|
||||
// Deduplicate participants by email to prevent duplicate contact creation
|
||||
const uniqueParticipants = participants.reduce<FirefliesParticipant[]>((unique, participant) => {
|
||||
const existing = unique.find(p => p.email === participant.email);
|
||||
if (!existing) {
|
||||
@@ -276,7 +289,10 @@ export class TwentyCrmService {
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await this.gqlRequest<CreatePersonResponse>(mutation, variables);
|
||||
const response = await this.gqlRequest<CreatePersonResponse>(mutation, variables, {
|
||||
suppressErrorCodes: ['BAD_USER_INPUT'],
|
||||
suppressErrorMessageIncludes: ['Duplicate Emails', 'duplicate entry'],
|
||||
});
|
||||
if (!response.data?.createPerson?.id) {
|
||||
throw new Error(`Failed to create contact for ${participant.email}`);
|
||||
}
|
||||
@@ -297,7 +313,6 @@ export class TwentyCrmService {
|
||||
private async createContactsNameOnly(participants: FirefliesParticipant[]): Promise<string[]> {
|
||||
const newContactIds: string[] = [];
|
||||
|
||||
// Deduplicate participants by name to prevent duplicate contact creation
|
||||
const uniqueParticipants = participants.reduce<FirefliesParticipant[]>((unique, participant) => {
|
||||
const existing = unique.find(p =>
|
||||
p.name.toLowerCase().trim() === participant.name.toLowerCase().trim()
|
||||
@@ -311,7 +326,6 @@ export class TwentyCrmService {
|
||||
}, []);
|
||||
|
||||
for (const participant of uniqueParticipants) {
|
||||
// Check if we already have a contact with this exact name to avoid duplicates
|
||||
const existingContact = await this.findContactByName(participant.name);
|
||||
if (existingContact) {
|
||||
logger.warn(`Contact with name "${participant.name}" already exists. Skipping creation.`);
|
||||
@@ -330,8 +344,6 @@ export class TwentyCrmService {
|
||||
const variables = {
|
||||
data: {
|
||||
name: { firstName, lastName },
|
||||
// Note: We don't set emails for name-only participants
|
||||
// This will create a contact without an email address
|
||||
},
|
||||
};
|
||||
|
||||
@@ -346,7 +358,6 @@ export class TwentyCrmService {
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.warn(`Failed to create contact for ${participant.name}: ${errorMessage}`);
|
||||
// Continue processing other participants instead of failing completely
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -409,28 +420,7 @@ export class TwentyCrmService {
|
||||
},
|
||||
};
|
||||
|
||||
await this.gqlRequest<any>(mutation, variables);
|
||||
}
|
||||
|
||||
async createMeetingTarget(meetingId: string, contactId: string): Promise<void> {
|
||||
const mutation = `
|
||||
mutation CreateMeetingTarget($data: NoteTargetCreateInput!) {
|
||||
createNoteTarget(data: $data) {
|
||||
id
|
||||
meetingId
|
||||
personId
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const variables = {
|
||||
data: {
|
||||
meetingId,
|
||||
personId: contactId,
|
||||
},
|
||||
};
|
||||
|
||||
await this.gqlRequest<any>(mutation, variables);
|
||||
await this.gqlRequest<{ createNoteTarget: { id: string; noteId: string; personId: string } }>(mutation, variables);
|
||||
}
|
||||
|
||||
async createMeeting(meetingData: MeetingCreateInput): Promise<string> {
|
||||
@@ -442,7 +432,6 @@ export class TwentyCrmService {
|
||||
|
||||
const variables = { data: meetingData };
|
||||
|
||||
// Debug: log the variables being sent
|
||||
if (!this.isTestEnvironment) {
|
||||
logger.debug('createMeeting variables:', JSON.stringify(variables, null, 2));
|
||||
}
|
||||
@@ -456,7 +445,8 @@ export class TwentyCrmService {
|
||||
|
||||
private async gqlRequest<T>(
|
||||
query: string,
|
||||
variables?: Record<string, unknown>
|
||||
variables?: Record<string, unknown>,
|
||||
options?: { suppressErrorCodes?: string[]; suppressErrorMessageIncludes?: string[] }
|
||||
): Promise<GraphQLResponse<T>> {
|
||||
const url = `${this.apiUrl}/graphql`;
|
||||
|
||||
@@ -500,7 +490,16 @@ export class TwentyCrmService {
|
||||
|
||||
return json;
|
||||
} catch (error) {
|
||||
logger.error('GraphQL request error:', error);
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const suppressByCode = options?.suppressErrorCodes?.some((code) =>
|
||||
message.includes(code),
|
||||
);
|
||||
const suppressByMessage = options?.suppressErrorMessageIncludes?.some((substring) =>
|
||||
message.includes(substring),
|
||||
);
|
||||
if (!suppressByCode && !suppressByMessage) {
|
||||
logger.error('GraphQL request error:', error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -525,7 +524,15 @@ export class TwentyCrmService {
|
||||
return response.data.createMeeting.id;
|
||||
}
|
||||
|
||||
async findFailedMeetings(): Promise<any[]> {
|
||||
async findFailedMeetings(): Promise<Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
firefliesMeetingId: string;
|
||||
importError: string;
|
||||
lastImportAttempt: string;
|
||||
importAttempts: number;
|
||||
createdAt: string;
|
||||
}>> {
|
||||
const query = `
|
||||
query FindFailedMeetings {
|
||||
meetings(filter: { importStatus: { eq: "FAILED" } }) {
|
||||
@@ -544,8 +551,8 @@ export class TwentyCrmService {
|
||||
}
|
||||
`;
|
||||
|
||||
const response = await this.gqlRequest<any>(query);
|
||||
return response.data?.meetings?.edges?.map((edge: any) => edge.node) || [];
|
||||
const response = await this.gqlRequest<{ meetings: { edges: Array<{ node: { id: string; name: string; firefliesMeetingId: string; importError: string; lastImportAttempt: string; importAttempts: number; createdAt: string } }> } }>(query);
|
||||
return response.data?.meetings?.edges?.map((edge) => edge.node) || [];
|
||||
}
|
||||
|
||||
async retryFailedMeeting(meetingId: string, updatedData: Partial<MeetingCreateInput>): Promise<void> {
|
||||
@@ -564,7 +571,7 @@ export class TwentyCrmService {
|
||||
}
|
||||
};
|
||||
|
||||
await this.gqlRequest<any>(mutation, variables);
|
||||
await this.gqlRequest<{ updateMeeting: { id: string } }>(mutation, variables);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
// Fireflies API Types
|
||||
export type FirefliesParticipant = {
|
||||
email: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type FirefliesWebhookPayload = {
|
||||
meetingId: string;
|
||||
eventType: string;
|
||||
clientReferenceId?: string;
|
||||
};
|
||||
|
||||
// Transcript sentence from Fireflies API
|
||||
export type FirefliesSentence = {
|
||||
index: number;
|
||||
speaker_name: string;
|
||||
text: string;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
ai_filters?: {
|
||||
task?: boolean;
|
||||
question?: boolean;
|
||||
sentiment?: string;
|
||||
};
|
||||
};
|
||||
|
||||
// Speaker analytics from Fireflies API (Business+)
|
||||
export type FirefliesSpeakerAnalytics = {
|
||||
speaker_id: string;
|
||||
name: string;
|
||||
duration: number;
|
||||
word_count: number;
|
||||
longest_monologue: number;
|
||||
filler_words: number;
|
||||
questions: number;
|
||||
words_per_minute: number;
|
||||
};
|
||||
|
||||
// Based on Fireflies GraphQL API transcript schema
|
||||
// See: https://docs.fireflies.ai/graphql-api/query/transcript
|
||||
export type FirefliesMeetingData = {
|
||||
id: string;
|
||||
title: string;
|
||||
date: string;
|
||||
duration: number;
|
||||
participants: FirefliesParticipant[];
|
||||
organizer_email?: string;
|
||||
// Full transcript (Pro+)
|
||||
sentences?: FirefliesSentence[];
|
||||
summary: {
|
||||
// Pro+ fields
|
||||
action_items: string[];
|
||||
keywords?: string[];
|
||||
overview: string;
|
||||
notes?: string; // Detailed AI-generated meeting notes
|
||||
gist?: string; // 1-sentence summary
|
||||
bullet_gist?: string; // Bullet point summary with emojis
|
||||
short_summary?: string; // Single paragraph summary
|
||||
short_overview?: string; // Brief overview
|
||||
outline?: string; // Meeting outline with timestamps
|
||||
shorthand_bullet?: string;
|
||||
// Business+ fields
|
||||
topics_discussed?: string[];
|
||||
meeting_type?: string;
|
||||
transcript_chapters?: string[];
|
||||
};
|
||||
// Business+ analytics
|
||||
analytics?: {
|
||||
sentiments?: {
|
||||
positive_pct: number;
|
||||
negative_pct: number;
|
||||
neutral_pct: number;
|
||||
};
|
||||
categories?: {
|
||||
questions: number;
|
||||
tasks: number;
|
||||
metrics: number;
|
||||
date_times: number;
|
||||
};
|
||||
speakers?: FirefliesSpeakerAnalytics[];
|
||||
};
|
||||
meeting_info?: {
|
||||
summary_status?: string;
|
||||
};
|
||||
// URLs
|
||||
transcript_url: string;
|
||||
audio_url?: string; // Pro+
|
||||
video_url?: string; // Business+
|
||||
meeting_link?: string; // All plans
|
||||
summary_status?: string;
|
||||
};
|
||||
|
||||
export type FirefliesTranscriptListItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
date?: string;
|
||||
duration?: number;
|
||||
organizer_email?: string;
|
||||
participants?: string[];
|
||||
transcript_url?: string;
|
||||
meeting_link?: string;
|
||||
summary_status?: string;
|
||||
};
|
||||
|
||||
export type FirefliesTranscriptListOptions = {
|
||||
limit?: number;
|
||||
skip?: number;
|
||||
organizers?: string[];
|
||||
participants?: string[];
|
||||
hostEmail?: string;
|
||||
participantEmail?: string;
|
||||
userId?: string;
|
||||
channelId?: string;
|
||||
mine?: boolean;
|
||||
fromDate?: number;
|
||||
toDate?: number;
|
||||
pageSize?: number;
|
||||
maxRecords?: number;
|
||||
};
|
||||
|
||||
// Configuration Types
|
||||
export type SummaryStrategy = 'immediate_only' | 'immediate_with_retry' | 'delayed_polling' | 'basic_only';
|
||||
|
||||
export type SummaryFetchConfig = {
|
||||
strategy: SummaryStrategy;
|
||||
retryAttempts: number;
|
||||
retryDelay: number;
|
||||
pollInterval: number;
|
||||
maxPolls: number;
|
||||
};
|
||||
|
||||
export const FIREFLIES_PLANS = {
|
||||
FREE: 'free',
|
||||
PRO: 'pro',
|
||||
BUSINESS: 'business',
|
||||
ENTERPRISE: 'enterprise',
|
||||
} as const;
|
||||
|
||||
export type FirefliesPlan = typeof FIREFLIES_PLANS[keyof typeof FIREFLIES_PLANS];
|
||||
|
||||
export type WebhookConfig = {
|
||||
secret: string;
|
||||
apiUrl: string;
|
||||
};
|
||||
|
||||
// Processing Result Types
|
||||
export type ProcessResult = {
|
||||
success: boolean;
|
||||
meetingId?: string;
|
||||
noteIds?: string[];
|
||||
newContacts?: string[];
|
||||
errors?: string[];
|
||||
debug?: string[];
|
||||
summaryReady?: boolean;
|
||||
summaryPending?: boolean;
|
||||
enhancementScheduled?: boolean;
|
||||
actionItemsCount?: number;
|
||||
sentimentAnalysis?: {
|
||||
positive_pct: number;
|
||||
negative_pct: number;
|
||||
neutral_pct: number;
|
||||
};
|
||||
meetingType?: string;
|
||||
keyTopics?: string[];
|
||||
};
|
||||
|
||||
// Twenty CRM Types
|
||||
export type GraphQLResponse<T> = {
|
||||
data: T;
|
||||
errors?: Array<{
|
||||
message?: string;
|
||||
extensions?: { code?: string }
|
||||
}>;
|
||||
};
|
||||
|
||||
export type IdNode = { id: string };
|
||||
|
||||
export type FindMeetingResponse = {
|
||||
meetings: { edges: Array<{ node: IdNode }> };
|
||||
};
|
||||
|
||||
export type FindPeopleResponse = {
|
||||
people: { edges: Array<{ node: { id: string; emails: { primaryEmail: string } } }> };
|
||||
};
|
||||
|
||||
export type CreatePersonResponse = {
|
||||
createPerson: { id: string }
|
||||
};
|
||||
|
||||
export type CreateNoteResponse = {
|
||||
createNote: { id: string }
|
||||
};
|
||||
|
||||
export type CreateMeetingResponse = {
|
||||
createMeeting: { id: string }
|
||||
};
|
||||
|
||||
export type Contact = {
|
||||
id: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
// Twenty CRM Meeting custom field input
|
||||
// Maps to fields defined in add-meeting-fields.ts
|
||||
export type MeetingCreateInput = {
|
||||
name: string;
|
||||
noteId?: string | null;
|
||||
// Basic fields (All plans)
|
||||
meetingDate: string;
|
||||
duration: number;
|
||||
firefliesMeetingId: string;
|
||||
organizerEmail?: string | null;
|
||||
transcriptUrl?: { primaryLinkUrl: string; primaryLinkLabel: string } | null;
|
||||
meetingLink?: { primaryLinkUrl: string; primaryLinkLabel: string } | null;
|
||||
// Pro+ fields
|
||||
transcript?: string | null;
|
||||
overview?: string | null;
|
||||
notes?: string | null;
|
||||
keywords?: string | null;
|
||||
audioUrl?: { primaryLinkUrl: string; primaryLinkLabel: string } | null;
|
||||
// Business+ fields
|
||||
meetingType?: string | null;
|
||||
topics?: string | null;
|
||||
actionItemsCount: number;
|
||||
positivePercent?: number | null;
|
||||
negativePercent?: number | null;
|
||||
neutralPercent?: number | null;
|
||||
videoUrl?: { primaryLinkUrl: string; primaryLinkLabel: string } | null;
|
||||
// Import tracking
|
||||
importStatus?: 'SUCCESS' | 'PARTIAL' | 'FAILED' | 'PENDING' | 'RETRYING' | null;
|
||||
importError?: string | null;
|
||||
lastImportAttempt?: string | null;
|
||||
importAttempts?: number | null;
|
||||
};
|
||||
|
||||
+13
-2
@@ -1,11 +1,11 @@
|
||||
import { FIREFLIES_PLANS, type FirefliesPlan, type SummaryFetchConfig, type SummaryStrategy } from './types';
|
||||
|
||||
export const toBoolean = (value: string | undefined, defaultValue: boolean): boolean => {
|
||||
if (value === undefined) return defaultValue;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return normalized === 'true' || normalized === '1' || normalized === 'yes';
|
||||
};
|
||||
|
||||
import type { SummaryFetchConfig, SummaryStrategy } from './types';
|
||||
|
||||
export const getApiUrl = (): string => {
|
||||
return process.env.SERVER_URL || 'http://localhost:3000';
|
||||
};
|
||||
@@ -28,3 +28,14 @@ export const shouldAutoCreateContacts = (): boolean => {
|
||||
return toBoolean(process.env.AUTO_CREATE_CONTACTS, true);
|
||||
};
|
||||
|
||||
export const getFirefliesPlan = (): FirefliesPlan => {
|
||||
const plan = (process.env.FIREFLIES_PLAN || FIREFLIES_PLANS.FREE).toLowerCase();
|
||||
if (plan === FIREFLIES_PLANS.BUSINESS || plan === FIREFLIES_PLANS.ENTERPRISE) {
|
||||
return plan as FirefliesPlan;
|
||||
}
|
||||
if (plan === FIREFLIES_PLANS.PRO) {
|
||||
return FIREFLIES_PLANS.PRO;
|
||||
}
|
||||
return FIREFLIES_PLANS.FREE;
|
||||
};
|
||||
|
||||
+109
-71
@@ -1,9 +1,9 @@
|
||||
import { FirefliesApiClient } from './fireflies-api-client';
|
||||
import { MeetingFormatter } from './formatters';
|
||||
import { createLogger } from './logger';
|
||||
import { clearAllCapturedLogs, createLogger, getAllCapturedLogs } from './logger';
|
||||
import { TwentyCrmService } from './twenty-crm-service';
|
||||
import type { FirefliesWebhookPayload, ProcessResult } from './types';
|
||||
import { getApiUrl, getSummaryFetchConfig, shouldAutoCreateContacts } from './utils';
|
||||
import { getApiUrl, getFirefliesPlan, getSummaryFetchConfig, shouldAutoCreateContacts } from './utils';
|
||||
import {
|
||||
getWebhookSecretFingerprint,
|
||||
isValidFirefliesPayload,
|
||||
@@ -12,17 +12,28 @@ import {
|
||||
|
||||
declare const process: { env: Record<string, string | undefined> };
|
||||
|
||||
const logger = createLogger('fireflies');
|
||||
|
||||
export class WebhookHandler {
|
||||
private debug: string[] = [];
|
||||
private isTestEnvironment: boolean;
|
||||
private logger: ReturnType<typeof createLogger>;
|
||||
|
||||
constructor() {
|
||||
this.isTestEnvironment = process.env.NODE_ENV === 'test' || process.env.JEST_WORKER_ID !== undefined;
|
||||
this.logger = createLogger('fireflies');
|
||||
}
|
||||
|
||||
private addDebugLogs(result: ProcessResult): ProcessResult {
|
||||
if (process.env.CAPTURE_LOGS === 'true') {
|
||||
const captured = getAllCapturedLogs();
|
||||
result.debug = [...this.debug, ...captured];
|
||||
} else {
|
||||
delete result.debug;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async handle(params: unknown, headers?: Record<string, string>): Promise<ProcessResult> {
|
||||
clearAllCapturedLogs();
|
||||
this.debug = [];
|
||||
|
||||
const result: ProcessResult = {
|
||||
success: false,
|
||||
noteIds: [],
|
||||
@@ -31,19 +42,27 @@ export class WebhookHandler {
|
||||
};
|
||||
|
||||
try {
|
||||
logger.debug('invoked');
|
||||
logger.debug(`apiUrl=${getApiUrl()}`);
|
||||
this.logger.debug('invoked');
|
||||
this.logger.debug(`apiUrl=${getApiUrl()}`);
|
||||
const paramKeys =
|
||||
params && typeof params === 'object'
|
||||
? Object.keys(params as Record<string, unknown>)
|
||||
: [];
|
||||
this.debug.push(
|
||||
`paramsType=${typeof params}`,
|
||||
`paramKeys=${paramKeys.join(',') || 'none'}`
|
||||
);
|
||||
|
||||
// 0) Validate environment configuration
|
||||
const firefliesApiKey = process.env.FIREFLIES_API_KEY || '';
|
||||
const twentyApiKey = process.env.TWENTY_API_KEY || '';
|
||||
|
||||
if (!firefliesApiKey) {
|
||||
logger.critical('FIREFLIES_API_KEY not configured - this is a critical configuration error');
|
||||
this.logger.critical('FIREFLIES_API_KEY not configured - this is a critical configuration error');
|
||||
throw new Error('FIREFLIES_API_KEY environment variable is required');
|
||||
}
|
||||
if (!twentyApiKey) {
|
||||
logger.critical('TWENTY_API_KEY not configured - this is a critical configuration error');
|
||||
this.logger.critical('TWENTY_API_KEY not configured - this is a critical configuration error');
|
||||
throw new Error('TWENTY_API_KEY environment variable is required');
|
||||
}
|
||||
|
||||
@@ -51,28 +70,30 @@ export class WebhookHandler {
|
||||
const { payload, extractedHeaders } = this.parsePayload(params);
|
||||
const finalHeaders = extractedHeaders || headers;
|
||||
|
||||
logger.debug(`payload meetingId=${payload.meetingId} eventType="${payload.eventType}"`);
|
||||
this.logger.debug(`payload meetingId=${payload.meetingId} eventType="${payload.eventType}"`);
|
||||
|
||||
// 2) Verify webhook signature
|
||||
const webhookSecret = process.env.FIREFLIES_WEBHOOK_SECRET || '';
|
||||
const secretFingerprint = getWebhookSecretFingerprint(webhookSecret);
|
||||
logger.debug(`webhook secret fingerprint=${secretFingerprint}`);
|
||||
this.logger.debug(`webhook secret fingerprint=${secretFingerprint}`);
|
||||
|
||||
this.verifySignature(payload, finalHeaders, webhookSecret);
|
||||
logger.debug('signature verification: ok');
|
||||
this.logger.debug('signature verification: ok');
|
||||
|
||||
// 3) Fetch meeting data from Fireflies
|
||||
const summaryConfig = getSummaryFetchConfig();
|
||||
logger.debug(`summary strategy: ${summaryConfig.strategy} (retryAttempts=${summaryConfig.retryAttempts}, retryDelay=${summaryConfig.retryDelay}ms)`);
|
||||
logger.debug(`fetching meeting data from Fireflies API`);
|
||||
const firefliesPlan = getFirefliesPlan();
|
||||
this.logger.debug(`summary strategy: ${summaryConfig.strategy} (retryAttempts=${summaryConfig.retryAttempts}, retryDelay=${summaryConfig.retryDelay}ms)`);
|
||||
this.logger.debug(`fetching meeting data from Fireflies API`);
|
||||
|
||||
const firefliesClient = new FirefliesApiClient(firefliesApiKey);
|
||||
const { data: meetingData, summaryReady } = await firefliesClient.fetchMeetingDataWithRetry(
|
||||
payload.meetingId,
|
||||
summaryConfig
|
||||
summaryConfig,
|
||||
firefliesPlan
|
||||
);
|
||||
|
||||
logger.debug(`meeting data fetched: title="${meetingData.title}" summaryReady=${summaryReady}`);
|
||||
this.logger.debug(`meeting data fetched: title="${meetingData.title}" summaryReady=${summaryReady}`);
|
||||
|
||||
result.summaryReady = summaryReady;
|
||||
result.summaryPending = !summaryReady;
|
||||
@@ -84,8 +105,7 @@ export class WebhookHandler {
|
||||
result.meetingType = meetingData.summary.meeting_type;
|
||||
|
||||
if (meetingData.analytics?.sentiments) {
|
||||
const sentiments = meetingData.analytics.sentiments;
|
||||
result.sentimentScore = sentiments.positive_pct / 100;
|
||||
result.sentimentAnalysis = meetingData.analytics.sentiments;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,29 +115,36 @@ export class WebhookHandler {
|
||||
getApiUrl()
|
||||
);
|
||||
|
||||
const existingMeeting = await twentyService.findExistingMeeting(meetingData.title);
|
||||
if (existingMeeting) {
|
||||
logger.debug(`meeting already exists id=${existingMeeting.id}`);
|
||||
const existingMeetingById = await twentyService.findMeetingByFirefliesId(meetingData.id);
|
||||
if (existingMeetingById) {
|
||||
this.logger.debug(`meeting already exists by firefliesMeetingId id=${existingMeetingById.id}`);
|
||||
result.success = true;
|
||||
result.meetingId = existingMeeting.id;
|
||||
result.debug = this.debug;
|
||||
return result;
|
||||
result.meetingId = existingMeetingById.id;
|
||||
return this.addDebugLogs(result);
|
||||
}
|
||||
logger.debug('no existing meeting found, proceeding');
|
||||
|
||||
const existingMeetingByTitle = await twentyService.findExistingMeeting(meetingData.title);
|
||||
if (existingMeetingByTitle) {
|
||||
this.logger.debug(`meeting already exists by title id=${existingMeetingByTitle.id}`);
|
||||
result.success = true;
|
||||
result.meetingId = existingMeetingByTitle.id;
|
||||
return this.addDebugLogs(result);
|
||||
}
|
||||
this.logger.debug('no existing meeting found, proceeding');
|
||||
|
||||
// 5) Match participants to existing contacts
|
||||
logger.debug(`total participants from API: ${meetingData.participants.length}`);
|
||||
this.logger.debug(`total participants from API: ${meetingData.participants.length}`);
|
||||
meetingData.participants.forEach((p, idx) => {
|
||||
logger.debug(`participant ${idx + 1}: name="${p.name}" email="${p.email || 'none'}"`);
|
||||
this.logger.debug(`participant ${idx + 1}: name="${p.name}" email="${p.email || 'none'}"`);
|
||||
});
|
||||
|
||||
const { matchedContacts, unmatchedParticipants } = await twentyService.matchParticipantsToContacts(
|
||||
meetingData.participants
|
||||
);
|
||||
logger.debug(`matched=${matchedContacts.length} unmatched=${unmatchedParticipants.length}`);
|
||||
this.logger.debug(`matched=${matchedContacts.length} unmatched=${unmatchedParticipants.length}`);
|
||||
|
||||
unmatchedParticipants.forEach((p, idx) => {
|
||||
logger.debug(`unmatched ${idx + 1}: name="${p.name}" email="${p.email || 'none'}"`);
|
||||
this.logger.debug(`unmatched ${idx + 1}: name="${p.name}" email="${p.email || 'none'}"`);
|
||||
});
|
||||
|
||||
// 6) Optionally create contacts
|
||||
@@ -126,7 +153,7 @@ export class WebhookHandler {
|
||||
? await twentyService.createContactsForUnmatched(unmatchedParticipants)
|
||||
: [];
|
||||
result.newContacts = newContactIds;
|
||||
logger.debug(`autoCreate=${autoCreate} createdContacts=${newContactIds.length}`);
|
||||
this.logger.debug(`autoCreate=${autoCreate} createdContacts=${newContactIds.length}`);
|
||||
|
||||
// 7) Create note first (so we can link to it from the meeting)
|
||||
const allContactIds = [...matchedContacts.map(({ id }) => id), ...newContactIds];
|
||||
@@ -136,13 +163,13 @@ export class WebhookHandler {
|
||||
noteBody
|
||||
);
|
||||
result.noteIds = [noteId];
|
||||
logger.debug(`created note id=${noteId}`);
|
||||
this.logger.debug(`created note id=${noteId}`);
|
||||
|
||||
// 8) Create meeting with direct relationship to the note
|
||||
const meetingInput = MeetingFormatter.toMeetingCreateInput(meetingData, noteId);
|
||||
logger.debug(`meeting duration: ${meetingData.duration} min (raw from API) → ${meetingInput.duration} min (rounded)`);
|
||||
this.logger.debug(`meeting duration: ${meetingData.duration} min (raw from API) → ${meetingInput.duration} min (rounded)`);
|
||||
result.meetingId = await twentyService.createMeeting(meetingInput);
|
||||
logger.debug(`created meeting id=${result.meetingId} with noteId=${noteId}`);
|
||||
this.logger.debug(`created meeting id=${result.meetingId} with noteId=${noteId}`);
|
||||
|
||||
// 9) Link note to participants (Meeting link is handled via the relation field)
|
||||
await this.linkNoteToParticipants(
|
||||
@@ -150,20 +177,19 @@ export class WebhookHandler {
|
||||
noteId,
|
||||
allContactIds
|
||||
);
|
||||
logger.debug(`linked note to ${allContactIds.length} participants`);
|
||||
this.logger.debug(`linked note to ${allContactIds.length} participants`);
|
||||
|
||||
result.success = true;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error(`error: ${message}`);
|
||||
this.logger.error(`error: ${message}`);
|
||||
result.errors?.push(message);
|
||||
|
||||
// Try to create a failed meeting record for tracking
|
||||
await this.createFailedMeetingRecord(params, message);
|
||||
}
|
||||
|
||||
result.debug = this.debug;
|
||||
return result;
|
||||
return this.addDebugLogs(result);
|
||||
}
|
||||
|
||||
private parsePayload(params: unknown): { payload: FirefliesWebhookPayload; extractedHeaders?: Record<string, string> } {
|
||||
@@ -172,16 +198,16 @@ export class WebhookHandler {
|
||||
|
||||
// Handle string-encoded params
|
||||
if (typeof normalizedParams === 'string') {
|
||||
logger.debug(`received params as string length=${normalizedParams.length}`);
|
||||
this.logger.debug(`received params as string length=${normalizedParams.length}`);
|
||||
try {
|
||||
const parsed = JSON.parse(normalizedParams);
|
||||
normalizedParams = parsed;
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
const parsedKeys = Object.keys(parsed as Record<string, unknown>);
|
||||
logger.debug(`parsed params keys: ${parsedKeys.join(',') || 'none'}`);
|
||||
this.logger.debug(`parsed params keys: ${parsedKeys.join(',') || 'none'}`);
|
||||
}
|
||||
} catch (parseError) {
|
||||
logger.error(`error parsing string params: ${String(parseError)}`);
|
||||
this.logger.error(`error parsing string params: ${String(parseError)}`);
|
||||
throw new Error('Invalid or missing webhook payload');
|
||||
}
|
||||
}
|
||||
@@ -197,14 +223,14 @@ export class WebhookHandler {
|
||||
if (wrapper.headers && typeof wrapper.headers === 'object' && !Array.isArray(wrapper.headers)) {
|
||||
extractedHeaders = wrapper.headers as Record<string, string>;
|
||||
const headerKeys = Object.keys(extractedHeaders);
|
||||
logger.debug(`extracted headers from wrapper: ${headerKeys.join(',')}`);
|
||||
this.logger.debug(`extracted headers from wrapper: ${headerKeys.join(',')}`);
|
||||
}
|
||||
|
||||
const wrapperKeys = ['params', 'payload', 'body', 'data', 'event'];
|
||||
for (const key of wrapperKeys) {
|
||||
const candidate = wrapper[key];
|
||||
if (isValidFirefliesPayload(candidate)) {
|
||||
logger.debug(`detected payload under wrapper key "${key}"`);
|
||||
this.logger.debug(`detected payload under wrapper key "${key}"`);
|
||||
payload = candidate as FirefliesWebhookPayload;
|
||||
break;
|
||||
}
|
||||
@@ -212,7 +238,7 @@ export class WebhookHandler {
|
||||
}
|
||||
|
||||
if (!payload) {
|
||||
logger.error('error: Invalid or missing webhook payload');
|
||||
this.logger.error('error: Invalid or missing webhook payload');
|
||||
throw new Error('Invalid or missing webhook payload');
|
||||
}
|
||||
|
||||
@@ -220,7 +246,7 @@ export class WebhookHandler {
|
||||
const payloadRecord = payload as Record<string, unknown>;
|
||||
const payloadKeys = Object.keys(payloadRecord);
|
||||
if (payloadKeys.length > 0) {
|
||||
logger.debug(`payload keys: ${payloadKeys.join(',')}`);
|
||||
this.logger.debug(`payload keys: ${payloadKeys.join(',')}`);
|
||||
}
|
||||
|
||||
return { payload, extractedHeaders };
|
||||
@@ -235,8 +261,9 @@ export class WebhookHandler {
|
||||
const normalizedHeaders = headers || {};
|
||||
const headerKeys = Object.keys(normalizedHeaders);
|
||||
if (headerKeys.length > 0) {
|
||||
logger.debug(`header keys: ${headerKeys.join(',')}`);
|
||||
this.logger.debug(`header keys: ${headerKeys.join(',')}`);
|
||||
}
|
||||
this.debug.push(`headerKeys=${headerKeys.join(',') || 'none'}`);
|
||||
|
||||
const headerSignature = Object.entries(normalizedHeaders).find(
|
||||
([key]) => key.toLowerCase() === 'x-hub-signature',
|
||||
@@ -249,32 +276,40 @@ export class WebhookHandler {
|
||||
: undefined;
|
||||
|
||||
if (payloadSignature) {
|
||||
logger.debug('found signature inside payload');
|
||||
this.logger.debug('found signature inside payload');
|
||||
}
|
||||
|
||||
const signature =
|
||||
(typeof headerSignature === 'string' ? headerSignature : undefined) || payloadSignature;
|
||||
|
||||
const body = typeof normalizedHeaders['body'] === 'string'
|
||||
? normalizedHeaders['body']
|
||||
: JSON.stringify(payloadRecord);
|
||||
const payloadForSignature =
|
||||
payloadSignature && 'x-hub-signature' in payloadRecord
|
||||
? Object.fromEntries(
|
||||
Object.entries(payloadRecord).filter(
|
||||
([key]) => key.toLowerCase() !== 'x-hub-signature',
|
||||
),
|
||||
)
|
||||
: payloadRecord;
|
||||
|
||||
const body =
|
||||
typeof normalizedHeaders['body'] === 'string'
|
||||
? normalizedHeaders['body']
|
||||
: JSON.stringify(payloadForSignature);
|
||||
|
||||
const signatureCheck = verifyWebhookSignature(body, signature, webhookSecret);
|
||||
if (!signatureCheck.isValid) {
|
||||
logger.debug(
|
||||
this.debug.push(
|
||||
`signatureProvided=${Boolean(signature)}`,
|
||||
`signatureMatched=${signatureCheck.isValid}`,
|
||||
`webhookSecretFingerprint=${getWebhookSecretFingerprint(webhookSecret)}`
|
||||
);
|
||||
this.logger.debug(
|
||||
`signature check failed. headerPresent=${Boolean(
|
||||
headerSignature,
|
||||
)} payloadSignaturePresent=${Boolean(payloadSignature)}`,
|
||||
);
|
||||
if (signature) {
|
||||
logger.debug(`provided signature=${signature}`);
|
||||
} else {
|
||||
logger.debug('provided signature=undefined');
|
||||
}
|
||||
logger.debug(
|
||||
`computed signature=${signatureCheck.computedSignature ?? 'unavailable'}`,
|
||||
);
|
||||
logger.critical('Invalid webhook signature - potential security threat detected in production');
|
||||
this.logger.debug(`provided signature present=${Boolean(signature)}`);
|
||||
this.logger.critical('Invalid webhook signature - potential security threat detected in production');
|
||||
throw new Error('Invalid webhook signature');
|
||||
}
|
||||
}
|
||||
@@ -284,15 +319,13 @@ export class WebhookHandler {
|
||||
noteId: string,
|
||||
contactIds: string[]
|
||||
): Promise<void> {
|
||||
// Create Note-Person links for each participant
|
||||
for (const contactId of contactIds) {
|
||||
try {
|
||||
await twentyService.createNoteTarget(noteId, contactId);
|
||||
logger.debug(`linked note ${noteId} to person ${contactId}`);
|
||||
this.logger.debug(`linked note ${noteId} to person ${contactId}`);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error(`failed to link note to person ${contactId}: ${message}`);
|
||||
// Continue with other participants
|
||||
this.logger.error(`failed to link note to person ${contactId}: ${message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -302,11 +335,10 @@ export class WebhookHandler {
|
||||
try {
|
||||
const twentyApiKey = process.env.TWENTY_API_KEY || '';
|
||||
if (!twentyApiKey) {
|
||||
logger.debug('Cannot create failed meeting record: TWENTY_API_KEY not configured');
|
||||
this.logger.debug('Cannot create failed meeting record: TWENTY_API_KEY not configured');
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to extract meeting ID and title from the params
|
||||
let meetingId = 'unknown';
|
||||
let meetingTitle = 'Unknown Meeting';
|
||||
|
||||
@@ -314,7 +346,6 @@ export class WebhookHandler {
|
||||
if (payload?.meetingId) {
|
||||
meetingId = payload.meetingId;
|
||||
|
||||
// Try to get meeting title from Fireflies API if possible
|
||||
const firefliesApiKey = process.env.FIREFLIES_API_KEY || '';
|
||||
if (firefliesApiKey) {
|
||||
try {
|
||||
@@ -322,7 +353,11 @@ export class WebhookHandler {
|
||||
const meetingData = await firefliesClient.fetchMeetingData(meetingId);
|
||||
meetingTitle = meetingData.title || meetingTitle;
|
||||
} catch (fetchError) {
|
||||
logger.debug(`Could not fetch meeting title: ${fetchError instanceof Error ? fetchError.message : 'Unknown error'}`);
|
||||
this.logger.debug(
|
||||
`Could not fetch meeting title: ${
|
||||
fetchError instanceof Error ? fetchError.message : 'Unknown error'
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -335,10 +370,13 @@ export class WebhookHandler {
|
||||
);
|
||||
|
||||
const failedMeetingId = await twentyService.createFailedMeeting(failedMeetingData);
|
||||
logger.debug(`Created failed meeting record: ${failedMeetingId}`);
|
||||
this.logger.debug(`Created failed meeting record: ${failedMeetingId}`);
|
||||
} catch (recordError) {
|
||||
// Don't throw here - we don't want to break the original error handling
|
||||
logger.error(`Failed to create failed meeting record: ${recordError instanceof Error ? recordError.message : 'Unknown error'}`);
|
||||
this.logger.error(
|
||||
`Failed to create failed meeting record: ${
|
||||
recordError instanceof Error ? recordError.message : 'Unknown error'
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+42
-29
@@ -12,19 +12,28 @@ function App() {
|
||||
useEffect(() => {
|
||||
browser.tabs.query({ active: true, currentWindow: true }, function(tabs) {
|
||||
const currentTab = tabs[0];
|
||||
if(currentTab.url?.includes('https://www.linkedin.com/in')) {
|
||||
sendMessage('getPersonviaRelay').then(data => {
|
||||
setValue({...data, type: 'person' })
|
||||
})
|
||||
}
|
||||
|
||||
if(currentTab.url?.includes('https://www.linkedin.com/company')) {
|
||||
sendMessage('getCompanyviaRelay').then(data => {
|
||||
setValue({...data, type: 'company'})
|
||||
})
|
||||
if (!currentTab.url) {
|
||||
return;
|
||||
}
|
||||
|
||||
let url = new URL(currentTab.url);
|
||||
|
||||
const isLinkedinHost =
|
||||
url.protocol === 'https:' && url.hostname === 'www.linkedin.com';
|
||||
|
||||
if (isLinkedinHost && url.pathname.startsWith('/in/')) {
|
||||
sendMessage('getPersonviaRelay').then((data) => {
|
||||
setValue({ ...data, type: 'person' });
|
||||
});
|
||||
}
|
||||
|
||||
if (isLinkedinHost && url.pathname.startsWith('/company/')) {
|
||||
sendMessage('getCompanyviaRelay').then((data) => {
|
||||
setValue({ ...data, type: 'company' });
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
}, []);
|
||||
|
||||
const isPersonValue = (val: Value): val is PersonValue => {
|
||||
@@ -38,25 +47,29 @@ function App() {
|
||||
return (
|
||||
<StyledMain>
|
||||
<h1>{JSON.stringify(value)}</h1>
|
||||
{
|
||||
isPersonValue(value) &&
|
||||
<button onClick={async () => {
|
||||
await sendMessage('createPerson', {
|
||||
firstName: value.firstName,
|
||||
lastName: value.lastName,
|
||||
});
|
||||
|
||||
}}>save person to twenty</button>
|
||||
}
|
||||
{
|
||||
isCompanyValue(value) &&
|
||||
<button onClick={async () => {
|
||||
await sendMessage('createCompany', {
|
||||
name: value.companyName
|
||||
});
|
||||
|
||||
}}>save company to twenty</button>
|
||||
}
|
||||
{isPersonValue(value) && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
await sendMessage('createPerson', {
|
||||
firstName: value.firstName,
|
||||
lastName: value.lastName,
|
||||
});
|
||||
}}
|
||||
>
|
||||
save person to twenty
|
||||
</button>
|
||||
)}
|
||||
{isCompanyValue(value) && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
await sendMessage('createCompany', {
|
||||
name: value.companyName,
|
||||
});
|
||||
}}
|
||||
>
|
||||
save company to twenty
|
||||
</button>
|
||||
)}
|
||||
</StyledMain>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hello-world",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.1",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^24.5.0",
|
||||
@@ -17,7 +17,7 @@
|
||||
"auth": "twenty auth login"
|
||||
},
|
||||
"dependencies": {
|
||||
"twenty-sdk": "0.1.2"
|
||||
"twenty-sdk": "0.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.7.2"
|
||||
|
||||
@@ -1,21 +1,30 @@
|
||||
import { type FunctionConfig } from 'twenty-sdk';
|
||||
import { createClient } from '../../generated';
|
||||
import type {
|
||||
FunctionConfig,
|
||||
DatabaseEventPayload,
|
||||
ObjectRecordCreateEvent,
|
||||
CronPayload,
|
||||
} from 'twenty-sdk';
|
||||
import Twenty, { type Person } from '../../generated';
|
||||
|
||||
export const main = async (params: { recipient?: string }) => {
|
||||
type CreateNewPostCardParams =
|
||||
| { name?: string }
|
||||
| DatabaseEventPayload<ObjectRecordCreateEvent<Person>>
|
||||
| CronPayload;
|
||||
|
||||
export const main = async (params: CreateNewPostCardParams) => {
|
||||
try {
|
||||
const client = createClient({
|
||||
url: `${process.env.TWENTY_API_URL}/graphql`,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${process.env.TWENTY_API_KEY}`,
|
||||
},
|
||||
});
|
||||
const client = new Twenty();
|
||||
|
||||
const name =
|
||||
'name' in params
|
||||
? params.name ?? process.env.DEFAULT_RECIPIENT_NAME ?? 'Hello world'
|
||||
: 'Hello world';
|
||||
|
||||
const createPostCard = await client.mutation({
|
||||
createPostCard: {
|
||||
__args: {
|
||||
data: {
|
||||
name: params.recipient ?? 'Hello-world',
|
||||
name,
|
||||
},
|
||||
},
|
||||
name: true,
|
||||
|
||||
+5
-8
@@ -6,17 +6,14 @@ const config: ApplicationConfig = {
|
||||
description: 'A simple hello world app',
|
||||
icon: 'IconWorld',
|
||||
applicationVariables: {
|
||||
TWENTY_API_KEY: {
|
||||
universalIdentifier: 'dedc53eb-9c12-4fe2-ba86-4a2add19d305',
|
||||
description: 'Twenty API Key',
|
||||
isSecret: true,
|
||||
},
|
||||
TWENTY_API_URL: {
|
||||
universalIdentifier: 'ef8ab489-e68a-4841-b402-261f440e6185',
|
||||
description: 'Twenty API Url',
|
||||
DEFAULT_RECIPIENT_NAME: {
|
||||
universalIdentifier: '19e94e59-d4fe-4251-8981-b96d0a9f74de',
|
||||
description: 'Default recipient name for postcards',
|
||||
value: 'Alex Karp',
|
||||
isSecret: false,
|
||||
},
|
||||
},
|
||||
functionRoleUniversalIdentifier: 'b648f87b-1d26-4961-b974-0908fd991061',
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,34 @@
|
||||
import { PermissionFlag, type RoleConfig } from 'twenty-sdk';
|
||||
|
||||
export const functionRole: RoleConfig = {
|
||||
universalIdentifier: 'b648f87b-1d26-4961-b974-0908fd991061',
|
||||
label: 'Default function role',
|
||||
description: 'Default role for function Twenty client',
|
||||
canReadAllObjectRecords: false,
|
||||
canUpdateAllObjectRecords: false,
|
||||
canSoftDeleteAllObjectRecords: false,
|
||||
canDestroyAllObjectRecords: false,
|
||||
canUpdateAllSettings: false,
|
||||
canBeAssignedToAgents: false,
|
||||
canBeAssignedToUsers: false,
|
||||
canBeAssignedToApiKeys: false,
|
||||
canBeAssignedToApplications: true,
|
||||
objectPermissions: [
|
||||
{
|
||||
objectNameSingular: 'postCard',
|
||||
canReadObjectRecords: true,
|
||||
canUpdateObjectRecords: true,
|
||||
canSoftDeleteObjectRecords: false,
|
||||
canDestroyObjectRecords: false,
|
||||
},
|
||||
],
|
||||
fieldPermissions: [
|
||||
{
|
||||
objectNameSingular: 'postCard',
|
||||
fieldName: 'content',
|
||||
canReadFieldValue: false,
|
||||
canUpdateFieldValue: false,
|
||||
},
|
||||
],
|
||||
permissionFlags: [PermissionFlag.APPLICATIONS],
|
||||
};
|
||||
@@ -1021,6 +1021,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"graphql-sse@npm:^2.5.4":
|
||||
version: 2.6.0
|
||||
resolution: "graphql-sse@npm:2.6.0"
|
||||
peerDependencies:
|
||||
graphql: ">=0.11 <=16"
|
||||
checksum: 10c0/e05f0b5c8539d61e5ce34af8e0bb418c02bf922d6a7f9232a9abd53c77df5654684ca14f674ac771645c8fba9c37ce666c5d06f46eef54ea07e63653468065b0
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"graphql@npm:^16.6.0, graphql@npm:^16.8.1":
|
||||
version: 16.12.0
|
||||
resolution: "graphql@npm:16.12.0"
|
||||
@@ -1074,7 +1083,7 @@ __metadata:
|
||||
resolution: "hello-world@workspace:."
|
||||
dependencies:
|
||||
"@types/node": "npm:^24.7.2"
|
||||
twenty-sdk: "npm:0.1.2"
|
||||
twenty-sdk: "npm:0.2.1"
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
@@ -1895,9 +1904,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"twenty-sdk@npm:0.1.2":
|
||||
version: 0.1.2
|
||||
resolution: "twenty-sdk@npm:0.1.2"
|
||||
"twenty-sdk@npm:0.2.1":
|
||||
version: 0.2.1
|
||||
resolution: "twenty-sdk@npm:0.2.1"
|
||||
dependencies:
|
||||
"@genql/cli": "npm:^3.0.3"
|
||||
axios: "npm:^1.6.0"
|
||||
@@ -1907,6 +1916,7 @@ __metadata:
|
||||
dotenv: "npm:^16.4.0"
|
||||
fs-extra: "npm:^11.2.0"
|
||||
graphql: "npm:^16.8.1"
|
||||
graphql-sse: "npm:^2.5.4"
|
||||
inquirer: "npm:^10.0.0"
|
||||
jsonc-parser: "npm:^3.2.0"
|
||||
lodash.camelcase: "npm:^4.3.0"
|
||||
@@ -1917,7 +1927,7 @@ __metadata:
|
||||
uuid: "npm:^13.0.0"
|
||||
bin:
|
||||
twenty: dist/cli.cjs
|
||||
checksum: 10c0/18cb8c589b6d6c6e59d58c28eae333718d62df485d827ba41e99421ef9fb359c3f71fa42d80b1e5463d2140b68de084aee81bf541e70c0d04a246c332e2978e7
|
||||
checksum: 10c0/5a4f4674129cc747e8e5091bf75025260db20087f76f1cee49896a073c8cf83b8c95e1dce3f2f2cbaaf9570aa1886c4c9a5585f0e61e0ee79552eab4d292fb4e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
@@ -118,6 +118,24 @@ Figma プラットフォームの学習に関するより包括的な詳細と
|
||||
これは推奨拡張機能の一部です。
|
||||
これは推奨拡張機能の一部です。
|
||||
これは推奨拡張機能の一部です。
|
||||
これは推奨拡張機能の一部です。
|
||||
これは推奨拡張機能の一部です。
|
||||
これは推奨拡張機能の一部です。
|
||||
これは推奨拡張機能の一部です。
|
||||
これは推奨拡張機能の一部です。
|
||||
これは推奨拡張機能の一部です。
|
||||
これは推奨拡張機能の一部です。
|
||||
これは推奨拡張機能の一部です。
|
||||
これは推奨拡張機能の一部です。
|
||||
これは推奨拡張機能の一部です。
|
||||
これは推奨拡張機能の一部です。
|
||||
これは推奨拡張機能の一部です。
|
||||
これは推奨拡張機能の一部です。
|
||||
これは推奨拡張機能の一部です。
|
||||
これは推奨拡張機能の一部です。
|
||||
これは推奨拡張機能の一部です。
|
||||
これは推奨拡張機能の一部です。
|
||||
これは推奨拡張機能の一部です。
|
||||
|
||||
## コラボレーション
|
||||
|
||||
|
||||
@@ -158,7 +158,7 @@ SSL(HTTPS)は、特定のブラウザ機能が正しく動作するために
|
||||
|
||||
2. **.env ファイルを更新**
|
||||
|
||||
.env`ファイルを開き、`SERVER_URL\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\`を更新します:
|
||||
.env`ファイルを開き、`SERVER_URL\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\`を更新します:
|
||||
|
||||
```ini
|
||||
SERVER_URL=http(s)://your-domain-or-ip:your-port
|
||||
|
||||
@@ -30,7 +30,7 @@ Twentyは、日常をサポートする最適なデータモデルを形成す
|
||||
既存のオブジェクトの特性に過ぎないもの(例:会社の「業種」や機会の「ステータス」)はフィールドにします。 カテゴリ、ラベル、属性にはフィールドが最適です。 カテゴリ、ラベル、属性にはフィールドが最適です。 カテゴリ、ラベル、属性にはフィールドが最適です。 カテゴリ、ラベル、属性にはフィールドが最適です。 カテゴリ、ラベル、属性にはフィールドが最適です。 カテゴリ、ラベル、属性にはフィールドが最適です。 カテゴリ、ラベル、属性にはフィールドが最適です。 カテゴリ、ラベル、属性にはフィールドが最適です。 カテゴリ、ラベル、属性にはフィールドが最適です。 カテゴリ、ラベル、属性にはフィールドが最適です。 カテゴリ、ラベル、属性にはフィールドが最適です。 カテゴリ、ラベル、属性にはフィールドが最適です。 カテゴリ、ラベル、属性にはフィールドが最適です。 カテゴリ、ラベル、属性にはフィールドが最適です。 カテゴリ、ラベル、属性にはフィールドが最適です。 カテゴリ、ラベル、属性にはフィールドが最適です。 カテゴリ、ラベル、属性にはフィールドが最適です。 カテゴリ、ラベル、属性にはフィールドが最適です。 カテゴリ、ラベル、属性にはフィールドが最適です。 カテゴリ、ラベル、属性にはフィールドが最適です。 カテゴリ、ラベル、属性にはフィールドが最適です。 カテゴリ、ラベル、属性にはフィールドが最適です。 カテゴリ、ラベル、属性にはフィールドが最適です。
|
||||
|
||||
**3. 単独で存在する場合には新しいオブジェクトを作成してください。**
|
||||
概念が独自のライフサイクル、プロパティ、または関係を持つ場合、それは通常オブジェクトに値します。 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば:
|
||||
概念が独自のライフサイクル、プロパティ、または関係を持つ場合、それは通常オブジェクトに値します。 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば: 例えば:
|
||||
|
||||
- **プロジェクト**:独自の期限、所有者、タスクを持つもの
|
||||
- **サブスクリプション**:会社、製品、請求書を結ぶもの
|
||||
|
||||
@@ -51,7 +51,7 @@ npx nx run twenty-front:storybook:coverage # (needs yarn storybook:serve:dev to
|
||||
|
||||
## Технологический стек
|
||||
|
||||
The project has a clean and simple stack, with minimal boilerplate code.
|
||||
Проект имеет чистый и простой стек с минимальным кодом.
|
||||
|
||||
**Приложение**
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ const PageListeningEnter = () => {
|
||||
|
||||
### Прослушивание горячих клавиш в компоненте типа модального окна
|
||||
|
||||
For this example we'll use a modal component that listens for the Escape key to tell its parent to close it.
|
||||
Для этого примера мы используем модальный компонент, который прослушивает ключ Escape, чтобы сообщить своему родителю закрыть его.
|
||||
|
||||
В этом случае взаимодействие пользователя меняет область операций.
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ sectionInfo: 配置您的Twenty工作空间设置和偏好
|
||||
刪除您的帳號將永久移除您對所有工作空間的訪問。
|
||||
刪除您的帳號將永久移除您對所有工作空間的訪問。 此操作無法撤銷,您將失去所有工作空間權限,如您只想退出特定的團隊,應考慮退出個別的工作空間。 刪除您的帳號將永久移除您對所有工作空間的訪問。
|
||||
刪除您的帳號將永久移除您對所有工作空間的訪問。 此操作無法撤銷,您將失去所有工作空間權限,如您只想退出特定的團隊,應考慮退出個別的工作空間。
|
||||
</Warning> 刪除您的帳號將永久移除您對所有工作空間的訪問。
|
||||
</Warning> 刪除您的帳號將永久移除您對所有工作空間的訪問。
|
||||
刪除您的帳號將永久移除您對所有工作空間的訪問。 此操作無法撤銷,您將失去所有工作空間權限,如您只想退出特定的團隊,應考慮退出個別的工作空間。
|
||||
|
||||
</Warning>
|
||||
|
||||
@@ -3,4 +3,4 @@ FRONTEND_BASE_URL=http://localhost:3001
|
||||
BACKEND_BASE_URL=http://localhost:3000
|
||||
DEFAULT_LOGIN=tim@apple.dev
|
||||
DEFAULT_PASSWORD=tim@apple.dev
|
||||
WEBSITE_URL=https://twenty.com
|
||||
WEBSITE_URL=https://twenty.com
|
||||
|
||||
@@ -27,6 +27,7 @@ export const test = base.extend<{ screenshotHook: void }>({
|
||||
},
|
||||
{ auto: true },
|
||||
],
|
||||
baseURL: process.env.LINK ? new URL(process.env.LINK).origin : 'http://localhost:3001',
|
||||
});
|
||||
|
||||
export { expect } from '@playwright/test';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Page } from '@playwright/test';
|
||||
import { getAuthToken } from '../utils/getAuthToken';
|
||||
import { type Page } from '@playwright/test';
|
||||
import { getAccessAuthToken } from '../utils/getAccessAuthToken';
|
||||
import { backendGraphQLUrl } from './backend';
|
||||
|
||||
export const createWorkflow = async ({
|
||||
@@ -11,7 +11,7 @@ export const createWorkflow = async ({
|
||||
workflowId: string;
|
||||
workflowName: string;
|
||||
}) => {
|
||||
const { authToken } = await getAuthToken(page);
|
||||
const { authToken } = await getAccessAuthToken(page);
|
||||
|
||||
return page.request.post(backendGraphQLUrl, {
|
||||
headers: {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Page } from '@playwright/test';
|
||||
import { getAuthToken } from '../utils/getAuthToken';
|
||||
import { type Page } from '@playwright/test';
|
||||
import { getAccessAuthToken } from '../utils/getAccessAuthToken';
|
||||
import { backendGraphQLUrl } from './backend';
|
||||
|
||||
export const deleteWorkflow = async ({
|
||||
@@ -9,7 +9,7 @@ export const deleteWorkflow = async ({
|
||||
page: Page;
|
||||
workflowId: string;
|
||||
}) => {
|
||||
const { authToken } = await getAuthToken(page);
|
||||
const { authToken } = await getAccessAuthToken(page);
|
||||
|
||||
return page.request.post(backendGraphQLUrl, {
|
||||
headers: {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Page } from '@playwright/test';
|
||||
import { getAuthToken } from '../utils/getAuthToken';
|
||||
import { type Page } from '@playwright/test';
|
||||
import { getAccessAuthToken } from '../utils/getAccessAuthToken';
|
||||
import { backendGraphQLUrl } from './backend';
|
||||
|
||||
export const destroyWorkflow = async ({
|
||||
@@ -9,7 +9,7 @@ export const destroyWorkflow = async ({
|
||||
page: Page;
|
||||
workflowId: string;
|
||||
}) => {
|
||||
const { authToken } = await getAuthToken(page);
|
||||
const { authToken } = await getAccessAuthToken(page);
|
||||
|
||||
return page.request.post(backendGraphQLUrl, {
|
||||
headers: {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { type Page } from '@playwright/test';
|
||||
|
||||
const decodeToken = (cookie: any) =>
|
||||
JSON.parse(decodeURIComponent(cookie.value)).accessOrWorkspaceAgnosticToken
|
||||
?.token;
|
||||
|
||||
const decodePayload = (jwt: string) =>
|
||||
JSON.parse(Buffer.from(jwt.split('.')[1], 'base64url').toString());
|
||||
|
||||
|
||||
export const getAccessAuthToken = async (page: Page) => {
|
||||
const storageState = await page.context().storageState();
|
||||
const tokenCookies = storageState.cookies.filter(
|
||||
(cookie) => cookie.name === 'tokenPair',
|
||||
);
|
||||
if (!tokenCookies) {
|
||||
throw new Error('No auth cookie found');
|
||||
}
|
||||
const accessTokenCookie = tokenCookies.find(
|
||||
(cookie) => {
|
||||
const payload = decodePayload(decodeToken(cookie) ?? '');
|
||||
return payload.type === 'ACCESS';
|
||||
}
|
||||
);
|
||||
|
||||
const token = JSON.parse(decodeURIComponent(accessTokenCookie?.value ?? '')).accessOrWorkspaceAgnosticToken
|
||||
.token;
|
||||
|
||||
return { authToken: token };
|
||||
};
|
||||
@@ -1,15 +0,0 @@
|
||||
import { Page } from '@playwright/test';
|
||||
|
||||
export const getAuthToken = async (page: Page) => {
|
||||
const storageState = await page.context().storageState();
|
||||
const authCookie = storageState.cookies.find(
|
||||
(cookie) => cookie.name === 'tokenPair',
|
||||
);
|
||||
if (!authCookie) {
|
||||
throw new Error('No auth cookie found');
|
||||
}
|
||||
const token = JSON.parse(decodeURIComponent(authCookie.value)).accessOrWorkspaceAgnosticToken
|
||||
.token;
|
||||
|
||||
return { authToken: token };
|
||||
};
|
||||
@@ -34,7 +34,10 @@ export default defineConfig({
|
||||
expect: {
|
||||
timeout: 5000,
|
||||
},
|
||||
reporter: process.env.CI ? 'github' : 'list',
|
||||
reporter: [
|
||||
[process.env.CI ? 'github' : 'list'],
|
||||
['./reporters/log-summary-reporter.ts'],
|
||||
],
|
||||
projects: [
|
||||
{
|
||||
name: 'setup',
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import type {
|
||||
Reporter,
|
||||
TestCase,
|
||||
TestResult,
|
||||
} from '@playwright/test/reporter';
|
||||
|
||||
class LogSummaryReporter implements Reporter {
|
||||
private passed: string[] = [];
|
||||
private failed: string[] = [];
|
||||
|
||||
onTestEnd(test: TestCase, result: TestResult): void {
|
||||
const name = test.titlePath().join(' › ');
|
||||
|
||||
if (result.status === 'passed') {
|
||||
this.passed.push(name);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.status === 'failed' || result.status === 'timedOut') {
|
||||
this.failed.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
onEnd(): void {
|
||||
const passedSet = new Set(this.passed);
|
||||
const failedSet = new Set(this.failed);
|
||||
const flaky: string[] = [];
|
||||
|
||||
for (const testName of passedSet) {
|
||||
if (failedSet.has(testName)) {
|
||||
flaky.push(testName);
|
||||
}
|
||||
}
|
||||
|
||||
const uniquePassed = this.passed.filter((testName) => !flaky.includes(testName));
|
||||
const uniqueFailed = this.failed.filter((testName) => !flaky.includes(testName));
|
||||
|
||||
console.log('\n=== Playwright summary ===');
|
||||
if (uniquePassed.length) {
|
||||
console.log('Passed:');
|
||||
uniquePassed.forEach((testName) => console.log(` ✅ ${testName}`));
|
||||
}
|
||||
if (uniqueFailed.length) {
|
||||
console.log('Failed:');
|
||||
uniqueFailed.forEach((testName) => console.log(` ❌ ${testName}`));
|
||||
}
|
||||
if (flaky.length) {
|
||||
console.log('Flaky:');
|
||||
flaky.forEach((testName) => console.log(` ⚠️ ${testName}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default LogSummaryReporter;
|
||||
@@ -49,12 +49,10 @@ test('Sign up with invite link via email', async ({
|
||||
await leftMenu.goToSettings();
|
||||
await settingsPage.goToProfileSection();
|
||||
await profileSection.deleteAccount();
|
||||
await expect(page.getByText('Account Deletion')).toBeVisible();
|
||||
await confirmationModal.typePlaceholderToInput();
|
||||
await confirmationModal.clickConfirmButton();
|
||||
|
||||
await Promise.all([
|
||||
page.waitForURL('/welcome'),
|
||||
|
||||
confirmationModal.clickConfirmButton(),
|
||||
]);
|
||||
await page.waitForURL('**/welcome');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { expect, test } from '../lib/fixtures/screenshot';
|
||||
test.describe.serial('Create Kanban View', () => {
|
||||
test('Create Industry Select Field', async ({ page }) => {
|
||||
await page.getByRole('link', { name: 'Settings' }).click();
|
||||
await page.getByRole('link', { name: 'Data model' }).click();
|
||||
await page.getByRole('link', { name: 'Opportunities' }).click();
|
||||
await page.getByRole('button', { name: 'Add Field' }).click();
|
||||
await page.getByRole('link', { name: 'Select', exact: true }).click();
|
||||
await page.getByRole('textbox', { name: 'Employees' }).click();
|
||||
await page.getByRole('textbox', { name: 'Employees' }).fill('Industry');
|
||||
await page.getByRole('textbox').nth(1).click();
|
||||
await page.getByRole('textbox').nth(1).press('ControlOrMeta+a');
|
||||
await page.getByRole('textbox').nth(1).fill('Food');
|
||||
await page.getByRole('button', { name: 'Add option' }).click();
|
||||
await page.getByRole('button', { name: 'Option 2' }).getByRole('textbox').fill('Tech');
|
||||
await page.getByRole('button', { name: 'Add option' }).click();
|
||||
await page.getByRole('button', { name: 'Option 3' }).getByRole('textbox').fill('Travel');
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
await page.waitForURL('**/objects/opportunities');
|
||||
await page.waitForSelector('text=Industry');
|
||||
await expect(page.getByText('Industry')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Create Kanban View from Industry Select Field', async ({ page }) => {
|
||||
await page.getByRole('link', { name: 'Opportunities' }).click();
|
||||
await page.getByRole('button', { name: 'All Opportunities ·' }).click();
|
||||
await page.getByText('Add view').click();
|
||||
await page.getByRole('textbox').press('ControlOrMeta+a');
|
||||
await page.getByRole('textbox').fill('By industry');
|
||||
await page.getByRole('button', { name: 'Table', exact: true }).click();
|
||||
await page.getByText('Kanban').click();
|
||||
await page.locator('[aria-controls="view-picker-kanban-field-options"]').click();
|
||||
await page.getByRole('option', { name: 'Industry' }).click();
|
||||
// Use exact: true to ensure we only click the button with the label "Create"
|
||||
await page.getByRole('button', { name: 'Create new view' }).click();
|
||||
await expect(page.getByText('Food')).toBeVisible();
|
||||
await expect(page.getByText('Tech')).toBeVisible();
|
||||
await expect(page.getByText('Travel')).toBeVisible();
|
||||
await expect(page.getByText('No value')).toBeVisible();
|
||||
const byIndustryElements = await page.locator('text=By industry').all();
|
||||
expect(byIndustryElements.length).toBeGreaterThanOrEqual(1);
|
||||
for (const element of byIndustryElements) {
|
||||
await expect(element).toBeVisible();
|
||||
}
|
||||
await page.getByText('Options').click();
|
||||
await page.getByText('Group', { exact: true }).click();
|
||||
await Promise.all([page.getByTestId('hide-group-').click(),
|
||||
page.waitForRequest((req) => {
|
||||
return req.url().includes('/metadata') &&
|
||||
req.method() === 'POST';
|
||||
})]);
|
||||
await expect(page.getByText('No value')).not.toBeVisible();
|
||||
});
|
||||
})
|
||||
@@ -0,0 +1,158 @@
|
||||
import { expect, test } from '../lib/fixtures/screenshot';
|
||||
import { backendGraphQLUrl } from '../lib/requests/backend';
|
||||
import { getAccessAuthToken } from '../lib/utils/getAccessAuthToken';
|
||||
|
||||
const query = `query FindOnePerson($objectRecordId: UUID!) {
|
||||
person(
|
||||
filter: {or: [{deletedAt: {is: NULL}}, {deletedAt: {is: NOT_NULL}}], id: {eq: $objectRecordId}}
|
||||
) {
|
||||
company {
|
||||
name
|
||||
}
|
||||
emails {
|
||||
primaryEmail
|
||||
additionalEmails
|
||||
__typename
|
||||
}
|
||||
id
|
||||
intro
|
||||
jobTitle
|
||||
linkedinLink {
|
||||
primaryLinkUrl
|
||||
primaryLinkLabel
|
||||
secondaryLinks
|
||||
__typename
|
||||
}
|
||||
name {
|
||||
firstName
|
||||
lastName
|
||||
__typename
|
||||
}
|
||||
performanceRating
|
||||
phones {
|
||||
primaryPhoneNumber
|
||||
primaryPhoneCountryCode
|
||||
primaryPhoneCallingCode
|
||||
additionalPhones
|
||||
__typename
|
||||
}
|
||||
position
|
||||
workPreference
|
||||
updatedAt
|
||||
}
|
||||
}`
|
||||
|
||||
test('Create and update record', async ({ page }) => {
|
||||
await page.getByRole('link', { name: 'People' }).click();
|
||||
await page.getByRole('button', { name: 'Create new record' }).click();
|
||||
|
||||
// Generate a random email for testing
|
||||
const randomEmail = `testuser_${Math.random().toString(36).substring(2, 10)}@example.com`;
|
||||
// Fill first name and last name
|
||||
const firstNameInput = page.getByRole('textbox', { name: 'First name' })
|
||||
await expect(firstNameInput).toBeFocused();
|
||||
await firstNameInput.fill('John');
|
||||
const lastNameInput = page.getByPlaceholder('Last name');
|
||||
await expect(lastNameInput).toBeVisible();
|
||||
await lastNameInput.fill('Doe');
|
||||
await lastNameInput.press('Enter');
|
||||
|
||||
// Focus on recordFieldList
|
||||
const recordFieldList = page.getByTestId('record-fields-list-container');
|
||||
await expect(recordFieldList).toBeVisible();
|
||||
await recordFieldList.getByText('Emails').first().click();
|
||||
|
||||
// Fill email
|
||||
const emailInput = recordFieldList.getByText('Emails').nth(1);
|
||||
await expect(emailInput).toBeVisible();
|
||||
await emailInput.click({ force: true });
|
||||
await page.getByPlaceholder('Email').fill(randomEmail);
|
||||
await page.keyboard.press('Enter');
|
||||
await page.keyboard.press('Escape');
|
||||
await recordFieldList.getByText('Emails').first().click();
|
||||
|
||||
|
||||
// Fill intro
|
||||
const introInput = recordFieldList.getByText('Intro').nth(1);
|
||||
await expect(introInput).toBeVisible();
|
||||
await introInput.click({ force: true });
|
||||
await introInput.click({ force: true });
|
||||
await page.getByPlaceholder('Intro').fill('This is an intro');
|
||||
await page.getByPlaceholder('Intro').press('Enter');
|
||||
|
||||
// Fill URL
|
||||
const urlInput = recordFieldList.getByText('Linkedin').nth(1);
|
||||
await expect(urlInput).toBeVisible();
|
||||
await urlInput.click({ force: true });
|
||||
await page.getByPlaceholder('URL').fill('linkedin.com/johndoe');
|
||||
await page.getByPlaceholder('URL').press('Enter');
|
||||
|
||||
// Click on 4th star to rate
|
||||
recordFieldList.getByText('Performance Rating').first().click({ force: true });
|
||||
const ratingContainer = recordFieldList.locator('div[aria-label="Rating"]');
|
||||
await ratingContainer.locator('svg').nth(3).click({force: true});
|
||||
|
||||
// Fill phone field
|
||||
const phoneInput = recordFieldList.getByText('Phones').nth(1);
|
||||
await expect(phoneInput).toBeVisible();
|
||||
await phoneInput.click({ force: true });
|
||||
await page.getByPlaceholder('Phone').fill('+336 1 122 3344');
|
||||
await page.getByPlaceholder('Phone').press('Enter');
|
||||
|
||||
// Fill work preference
|
||||
await recordFieldList.getByText('Work Preference').first().click({force: true});
|
||||
await recordFieldList.getByText('Work Preference').nth(1).click({force: true});
|
||||
const options = page.getByRole('listbox');
|
||||
await options.getByText('Hybrid').first().click({force: true});
|
||||
recordFieldList.getByText('Work Preference').first().click({force: true});
|
||||
|
||||
// Fill company relation
|
||||
const companyRelationHeader = page.getByTestId('company-relation');
|
||||
await expect(companyRelationHeader).toBeVisible();
|
||||
|
||||
await companyRelationHeader.locator('.tabler-icon-pencil').click();
|
||||
await page.getByRole('textbox', { name: 'Search' }).fill('Goog');
|
||||
await expect(page.getByRole('option', { name: 'Google' })).toBeVisible();
|
||||
const [updatePersonResponse] = await Promise.all([
|
||||
page.waitForResponse(async (response) => {
|
||||
if (!response.url().endsWith('/graphql')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const requestBody = response.request().postDataJSON();
|
||||
|
||||
return requestBody.operationName === 'UpdateOnePerson';
|
||||
}),
|
||||
await page.getByRole('option', { name: 'Google' }).click({force: true})
|
||||
]);
|
||||
|
||||
const body = await updatePersonResponse.json()
|
||||
const newPersonId = body.data.updatePerson.id;
|
||||
|
||||
// Check data was saved
|
||||
const { authToken } = await getAccessAuthToken(page);
|
||||
const findOnePersonResponse = await page.request.post(backendGraphQLUrl, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
},
|
||||
data: {
|
||||
operationName: 'FindOnePerson',
|
||||
query,
|
||||
variables: {
|
||||
objectRecordId: newPersonId,
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const findOnePersonReponseBody = await findOnePersonResponse.json();
|
||||
|
||||
expect(findOnePersonReponseBody.data.person.name.firstName).toBe('John');
|
||||
expect(findOnePersonReponseBody.data.person.name.lastName).toBe('Doe');
|
||||
expect(findOnePersonReponseBody.data.person.emails.primaryEmail).toBe(randomEmail);
|
||||
expect(findOnePersonReponseBody.data.person.intro).toBe('This is an intro');
|
||||
expect(findOnePersonReponseBody.data.person.linkedinLink.primaryLinkUrl).toBe('linkedin.com/johndoe');
|
||||
expect(findOnePersonReponseBody.data.person.phones.primaryPhoneNumber).toBe('611223344');
|
||||
expect(findOnePersonReponseBody.data.person.workPreference).toEqual(['HYBRID']);
|
||||
expect(findOnePersonReponseBody.data.person.company.name).toBe('Google');
|
||||
|
||||
});
|
||||
@@ -29,7 +29,13 @@ test('Login test', async ({ loginPage, page }) => {
|
||||
await loginPage.typePassword(process.env.DEFAULT_PASSWORD);
|
||||
await page.waitForLoadState('networkidle');
|
||||
await loginPage.clickSignInButton();
|
||||
await page.waitForLoadState('networkidle');
|
||||
await expect(page.getByText(/Welcome to .+/)).not.toBeVisible();
|
||||
await expect(page.getByText('Choose a workspace')).toBeVisible();
|
||||
await page.getByText('Apple', {exact: true}).click();
|
||||
await page.waitForFunction(() => window.location.href.includes('verify'));
|
||||
await page.waitForFunction(() => !window.location.href.includes('verify'));
|
||||
process.env.LINK = page.url();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -37,6 +43,5 @@ test('Login test', async ({ loginPage, page }) => {
|
||||
await page.context().storageState({
|
||||
path: path.resolve(__dirname, '..', '.auth', 'user.json'),
|
||||
});
|
||||
process.env.LINK = page.url();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { expect, test } from '../lib/fixtures/screenshot';
|
||||
import { deleteWorkflow } from '../lib/requests/delete-workflow';
|
||||
import { destroyWorkflow } from '../lib/requests/destroy-workflow';
|
||||
|
||||
test('Create workflow', async ({ page }) => {
|
||||
const NEW_WORKFLOW_NAME = 'Test Workflow';
|
||||
|
||||
await page.goto('/');
|
||||
await page.goto(process.env.LINK);
|
||||
|
||||
const workflowsLink = page.getByRole('link', { name: 'Workflows' });
|
||||
await workflowsLink.click();
|
||||
@@ -25,7 +25,7 @@ test('Create workflow', async ({ page }) => {
|
||||
return requestBody.operationName === 'CreateOneWorkflow';
|
||||
}),
|
||||
|
||||
createWorkflowButton.click(),
|
||||
createWorkflowButton.click()
|
||||
]);
|
||||
|
||||
const recordName = page.getByTestId('top-bar-title').getByText('Untitled');
|
||||
|
||||
@@ -1,150 +1,150 @@
|
||||
import { expect } from '@playwright/test';
|
||||
import { test } from '../lib/fixtures/blank-workflow';
|
||||
// import { expect } from '@playwright/test';
|
||||
// import { test } from '../lib/fixtures/blank-workflow';
|
||||
|
||||
test('The workflow run visualizer shows the executed draft version without the last draft changes', async ({
|
||||
workflowVisualizer,
|
||||
page,
|
||||
}) => {
|
||||
await workflowVisualizer.createInitialTrigger('manual');
|
||||
// test('The workflow run visualizer shows the executed draft version without the last draft changes', async ({
|
||||
// workflowVisualizer,
|
||||
// page,
|
||||
// }) => {
|
||||
// await workflowVisualizer.createInitialTrigger('manual');
|
||||
|
||||
const manualTriggerAvailabilitySelect = page.getByRole('button', {
|
||||
name: 'When record is selected',
|
||||
});
|
||||
// const manualTriggerAvailabilitySelect = page.getByRole('button', {
|
||||
// name: 'When record is selected',
|
||||
// });
|
||||
|
||||
await manualTriggerAvailabilitySelect.click();
|
||||
// await manualTriggerAvailabilitySelect.click();
|
||||
|
||||
const alwaysAvailableOption = page.getByText('When no record is selected');
|
||||
// const alwaysAvailableOption = page.getByText('When no record is selected');
|
||||
|
||||
await alwaysAvailableOption.click();
|
||||
// await alwaysAvailableOption.click();
|
||||
|
||||
await workflowVisualizer.closeSidePanel();
|
||||
// await workflowVisualizer.closeSidePanel();
|
||||
|
||||
const { createdStepId: firstStepId } =
|
||||
await workflowVisualizer.createStep('create-record');
|
||||
// const { createdStepId: firstStepId } =
|
||||
// await workflowVisualizer.createStep('create-record');
|
||||
|
||||
await workflowVisualizer.closeSidePanel();
|
||||
// await workflowVisualizer.closeSidePanel();
|
||||
|
||||
const launchTestButton = page.getByLabel(workflowVisualizer.workflowName);
|
||||
// const launchTestButton = page.getByLabel(workflowVisualizer.workflowName);
|
||||
|
||||
await launchTestButton.click();
|
||||
// await launchTestButton.click();
|
||||
|
||||
await workflowVisualizer.closeSidePanel();
|
||||
// await workflowVisualizer.closeSidePanel();
|
||||
|
||||
await workflowVisualizer.deleteStep(firstStepId);
|
||||
// await workflowVisualizer.deleteStep(firstStepId);
|
||||
|
||||
await page.goto('/objects/workflowRuns');
|
||||
// await page.goto('/objects/workflowRuns');
|
||||
|
||||
const recordTableRowForWorkflowRun = page
|
||||
.getByRole('row', {
|
||||
name: workflowVisualizer.workflowName,
|
||||
})
|
||||
.first();
|
||||
// const recordTableRowForWorkflowRun = page
|
||||
// .getByRole('row', {
|
||||
// name: workflowVisualizer.workflowName,
|
||||
// })
|
||||
// .first();
|
||||
|
||||
const linkToWorkflowRun = recordTableRowForWorkflowRun
|
||||
.getByRole('link', {
|
||||
name: workflowVisualizer.workflowName,
|
||||
})
|
||||
.first();
|
||||
// const linkToWorkflowRun = recordTableRowForWorkflowRun
|
||||
// .getByRole('link', {
|
||||
// name: workflowVisualizer.workflowName,
|
||||
// })
|
||||
// .first();
|
||||
|
||||
await linkToWorkflowRun.click({ force: true });
|
||||
// await linkToWorkflowRun.click({ force: true });
|
||||
|
||||
const workflowRunNameElement = page
|
||||
.getByText(`#1 - ${workflowVisualizer.workflowName}`)
|
||||
.nth(1);
|
||||
// const workflowRunNameElement = page
|
||||
// .getByText(`#1 - ${workflowVisualizer.workflowName}`)
|
||||
// .nth(1);
|
||||
|
||||
await expect(workflowRunNameElement).toBeVisible();
|
||||
// await expect(workflowRunNameElement).toBeVisible();
|
||||
|
||||
const executedFirstStepNode = workflowVisualizer.getStepNode(firstStepId);
|
||||
// const executedFirstStepNode = workflowVisualizer.getStepNode(firstStepId);
|
||||
|
||||
await expect(executedFirstStepNode).toBeVisible();
|
||||
// await expect(executedFirstStepNode).toBeVisible();
|
||||
|
||||
await executedFirstStepNode.click();
|
||||
// await executedFirstStepNode.click();
|
||||
|
||||
await expect(workflowVisualizer.stepHeaderInCommandMenu).toContainText(
|
||||
'Create Record',
|
||||
);
|
||||
});
|
||||
// await expect(workflowVisualizer.stepHeaderInCommandMenu).toContainText(
|
||||
// 'Create Record',
|
||||
// );
|
||||
// });
|
||||
|
||||
test('Workflow Runs with a pending form step can be opened in the side panel and then in full screen', async ({
|
||||
workflowVisualizer,
|
||||
page,
|
||||
}) => {
|
||||
await workflowVisualizer.createInitialTrigger('manual');
|
||||
// test('Workflow Runs with a pending form step can be opened in the side panel and then in full screen', async ({
|
||||
// workflowVisualizer,
|
||||
// page,
|
||||
// }) => {
|
||||
// await workflowVisualizer.createInitialTrigger('manual');
|
||||
|
||||
const manualTriggerAvailabilitySelect = page.getByRole('button', {
|
||||
name: 'When record is selected',
|
||||
});
|
||||
// const manualTriggerAvailabilitySelect = page.getByRole('button', {
|
||||
// name: 'When record is selected',
|
||||
// });
|
||||
|
||||
await manualTriggerAvailabilitySelect.click();
|
||||
// await manualTriggerAvailabilitySelect.click();
|
||||
|
||||
const alwaysAvailableOption = page.getByText('When no record is selected');
|
||||
// const alwaysAvailableOption = page.getByText('When no record is selected');
|
||||
|
||||
await alwaysAvailableOption.click();
|
||||
// await alwaysAvailableOption.click();
|
||||
|
||||
await workflowVisualizer.closeSidePanel();
|
||||
// await workflowVisualizer.closeSidePanel();
|
||||
|
||||
const { createdStepId: firstStepId } =
|
||||
await workflowVisualizer.createStep('form');
|
||||
// const { createdStepId: firstStepId } =
|
||||
// await workflowVisualizer.createStep('form');
|
||||
|
||||
const addFormFieldButton = page.getByText('Add Field', { exact: true });
|
||||
// const addFormFieldButton = page.getByText('Add Field', { exact: true });
|
||||
|
||||
await addFormFieldButton.click();
|
||||
// await addFormFieldButton.click();
|
||||
|
||||
await workflowVisualizer.closeSidePanel();
|
||||
// await workflowVisualizer.closeSidePanel();
|
||||
|
||||
const launchTestButton = page.getByLabel(workflowVisualizer.workflowName);
|
||||
// const launchTestButton = page.getByLabel(workflowVisualizer.workflowName);
|
||||
|
||||
await launchTestButton.click();
|
||||
// await launchTestButton.click();
|
||||
|
||||
const workflowRunName = `#1 - ${workflowVisualizer.workflowName}`;
|
||||
// const workflowRunName = `#1 - ${workflowVisualizer.workflowName}`;
|
||||
|
||||
await expect(workflowVisualizer.stepHeaderInCommandMenu).toContainText(
|
||||
'Form',
|
||||
{
|
||||
timeout: 30_000,
|
||||
},
|
||||
);
|
||||
// await expect(workflowVisualizer.stepHeaderInCommandMenu).toContainText(
|
||||
// 'Form',
|
||||
// {
|
||||
// timeout: 30_000,
|
||||
// },
|
||||
// );
|
||||
|
||||
await workflowVisualizer.goBackInCommandMenu.click();
|
||||
// await workflowVisualizer.goBackInCommandMenu.click();
|
||||
|
||||
const workflowRunNameInCommandMenu =
|
||||
workflowVisualizer.commandMenu.getByText(workflowRunName);
|
||||
// const workflowRunNameInCommandMenu =
|
||||
// workflowVisualizer.commandMenu.getByText(workflowRunName);
|
||||
|
||||
await expect(workflowRunNameInCommandMenu).toBeVisible();
|
||||
// await expect(workflowRunNameInCommandMenu).toBeVisible();
|
||||
|
||||
await workflowVisualizer.commandMenu
|
||||
.locator(workflowVisualizer.triggerNode)
|
||||
.click();
|
||||
// await workflowVisualizer.commandMenu
|
||||
// .locator(workflowVisualizer.triggerNode)
|
||||
// .click();
|
||||
|
||||
await expect(workflowVisualizer.stepHeaderInCommandMenu).toContainText(
|
||||
'Launch manually',
|
||||
);
|
||||
// await expect(workflowVisualizer.stepHeaderInCommandMenu).toContainText(
|
||||
// 'Launch manually',
|
||||
// );
|
||||
|
||||
await workflowVisualizer.goBackInCommandMenu.click();
|
||||
// await workflowVisualizer.goBackInCommandMenu.click();
|
||||
|
||||
const formStep = workflowVisualizer.commandMenu.locator(
|
||||
workflowVisualizer.getStepNode(firstStepId),
|
||||
);
|
||||
// const formStep = workflowVisualizer.commandMenu.locator(
|
||||
// workflowVisualizer.getStepNode(firstStepId),
|
||||
// );
|
||||
|
||||
await formStep.click();
|
||||
// await formStep.click();
|
||||
|
||||
await workflowVisualizer.goBackInCommandMenu.click();
|
||||
// await workflowVisualizer.goBackInCommandMenu.click();
|
||||
|
||||
const openInFullScreenButton = workflowVisualizer.commandMenu.getByRole(
|
||||
'button',
|
||||
{ name: 'Open' },
|
||||
);
|
||||
// const openInFullScreenButton = workflowVisualizer.commandMenu.getByRole(
|
||||
// 'button',
|
||||
// { name: 'Open' },
|
||||
// );
|
||||
|
||||
await openInFullScreenButton.click();
|
||||
// await openInFullScreenButton.click();
|
||||
|
||||
const workflowRunNameInShowPage = page
|
||||
.getByText(`#1 - ${workflowVisualizer.workflowName}`)
|
||||
.nth(1);
|
||||
// const workflowRunNameInShowPage = page
|
||||
// .getByText(`#1 - ${workflowVisualizer.workflowName}`)
|
||||
// .nth(1);
|
||||
|
||||
await expect(workflowRunNameInShowPage).toBeVisible();
|
||||
// await expect(workflowRunNameInShowPage).toBeVisible();
|
||||
|
||||
// Expect the side panel to be opened by default on the form.
|
||||
await expect(workflowVisualizer.stepHeaderInCommandMenu).toContainText(
|
||||
'Form',
|
||||
);
|
||||
});
|
||||
// // Expect the side panel to be opened by default on the form.
|
||||
// await expect(workflowVisualizer.stepHeaderInCommandMenu).toContainText(
|
||||
// 'Form',
|
||||
// );
|
||||
// });
|
||||
|
||||
@@ -1,163 +1,161 @@
|
||||
import { expect } from '@playwright/test';
|
||||
import { test } from '../lib/fixtures/blank-workflow';
|
||||
|
||||
test('Use an old version as draft', async ({ workflowVisualizer, page }) => {
|
||||
await workflowVisualizer.createInitialTrigger('record-created');
|
||||
// test('Use an old version as draft', async ({ workflowVisualizer, page }) => {
|
||||
// await workflowVisualizer.createInitialTrigger('record-created');
|
||||
|
||||
await workflowVisualizer.createStep('create-record');
|
||||
// await workflowVisualizer.createStep('create-record');
|
||||
|
||||
await workflowVisualizer.background.click();
|
||||
// await workflowVisualizer.background.click();
|
||||
|
||||
await workflowVisualizer.activateWorkflowButton.click();
|
||||
// await workflowVisualizer.activateWorkflowButton.click();
|
||||
|
||||
await expect(workflowVisualizer.workflowStatus).toHaveText('Active');
|
||||
// await expect(workflowVisualizer.workflowStatus).toHaveText('Active');
|
||||
|
||||
await workflowVisualizer.createStep('delete-record');
|
||||
// await workflowVisualizer.createStep('delete-record');
|
||||
|
||||
await expect(workflowVisualizer.workflowStatus).toHaveText('Draft');
|
||||
// await expect(workflowVisualizer.workflowStatus).toHaveText('Draft');
|
||||
|
||||
await workflowVisualizer.closeSidePanel();
|
||||
// await workflowVisualizer.closeSidePanel();
|
||||
|
||||
await workflowVisualizer.activateWorkflowButton.click();
|
||||
// await workflowVisualizer.activateWorkflowButton.click();
|
||||
|
||||
await expect(workflowVisualizer.workflowStatus).toHaveText('Active');
|
||||
// await expect(workflowVisualizer.workflowStatus).toHaveText('Active');
|
||||
|
||||
await expect(workflowVisualizer.triggerNode).toContainText(
|
||||
'Record is created',
|
||||
);
|
||||
await expect(workflowVisualizer.getAllStepNodes()).toContainText([
|
||||
'Create Record',
|
||||
'Delete Record',
|
||||
]);
|
||||
await expect(workflowVisualizer.getAllStepNodes()).toHaveCount(2);
|
||||
await expect(workflowVisualizer.useAsDraftButton).not.toBeVisible();
|
||||
// await expect(workflowVisualizer.triggerNode).toContainText(
|
||||
// 'Record is created',
|
||||
// );
|
||||
// await expect(workflowVisualizer.getAllStepNodes()).toContainText([
|
||||
// 'Create Record',
|
||||
// 'Delete Record',
|
||||
// ]);
|
||||
// await expect(workflowVisualizer.getAllStepNodes()).toHaveCount(2);
|
||||
// await expect(workflowVisualizer.useAsDraftButton).not.toBeVisible();
|
||||
|
||||
const workflowsLink = page.getByRole('link', { name: 'Workflows' });
|
||||
await workflowsLink.click();
|
||||
// const workflowsLink = page.getByRole('link', { name: 'Workflows' });
|
||||
// await workflowsLink.click();
|
||||
|
||||
await workflowVisualizer.setWorkflowsOpenInMode('record-page');
|
||||
// await workflowVisualizer.setWorkflowsOpenInMode('record-page');
|
||||
|
||||
const recordTableRowForWorkflow = page.getByRole('row', {
|
||||
name: workflowVisualizer.workflowName,
|
||||
});
|
||||
// const recordTableRowForWorkflow = page.getByRole('row', {
|
||||
// name: workflowVisualizer.workflowName,
|
||||
// });
|
||||
|
||||
const linkToWorkflow = recordTableRowForWorkflow.getByRole('link', {
|
||||
name: workflowVisualizer.workflowName,
|
||||
});
|
||||
await expect(linkToWorkflow).toBeVisible();
|
||||
// const linkToWorkflow = recordTableRowForWorkflow.getByRole('link', {
|
||||
// name: workflowVisualizer.workflowName,
|
||||
// });
|
||||
// await expect(linkToWorkflow).toBeVisible();
|
||||
|
||||
const linkToFirstWorkflowVersion = recordTableRowForWorkflow.getByRole(
|
||||
'link',
|
||||
{
|
||||
name: 'v1',
|
||||
},
|
||||
);
|
||||
// const linkToFirstWorkflowVersion = recordTableRowForWorkflow.getByRole(
|
||||
// 'link',
|
||||
// {
|
||||
// name: 'v1',
|
||||
// },
|
||||
// );
|
||||
|
||||
await linkToFirstWorkflowVersion.click({ force: true });
|
||||
// await linkToFirstWorkflowVersion.click({ force: true });
|
||||
|
||||
await expect(workflowVisualizer.workflowStatus).toHaveText('Archived');
|
||||
await expect(workflowVisualizer.useAsDraftButton).toBeVisible();
|
||||
await expect(workflowVisualizer.triggerNode).toContainText(
|
||||
'Record is created',
|
||||
);
|
||||
await expect(workflowVisualizer.getAllStepNodes()).toContainText([
|
||||
'Create Record',
|
||||
]);
|
||||
await expect(workflowVisualizer.getAllStepNodes()).toHaveCount(1);
|
||||
// await expect(workflowVisualizer.workflowStatus).toHaveText('Archived');
|
||||
// await expect(workflowVisualizer.useAsDraftButton).toBeVisible();
|
||||
// await expect(workflowVisualizer.triggerNode).toContainText(
|
||||
// 'Record is created',
|
||||
// );
|
||||
// await expect(workflowVisualizer.getAllStepNodes()).toContainText([
|
||||
// 'Create Record',
|
||||
// ]);
|
||||
// await expect(workflowVisualizer.getAllStepNodes()).toHaveCount(1);
|
||||
|
||||
await workflowVisualizer.useAsDraftButton.click();
|
||||
// await workflowVisualizer.useAsDraftButton.click();
|
||||
|
||||
await page.waitForURL(`/object/workflow/${workflowVisualizer.workflowId}`);
|
||||
// await page.waitForURL(`/object/workflow/${workflowVisualizer.workflowId}`);
|
||||
|
||||
await expect(workflowVisualizer.workflowStatus).toHaveText('Draft');
|
||||
await expect(workflowVisualizer.useAsDraftButton).not.toBeVisible();
|
||||
await expect(workflowVisualizer.triggerNode).toContainText(
|
||||
'Record is created',
|
||||
);
|
||||
await expect(workflowVisualizer.getAllStepNodes()).toContainText([
|
||||
'Create Record',
|
||||
]);
|
||||
await expect(workflowVisualizer.getAllStepNodes()).toHaveCount(1);
|
||||
});
|
||||
// await expect(workflowVisualizer.workflowStatus).toHaveText('Draft');
|
||||
// await expect(workflowVisualizer.useAsDraftButton).not.toBeVisible();
|
||||
// await expect(workflowVisualizer.triggerNode).toContainText(
|
||||
// 'Record is created',
|
||||
// );
|
||||
// await expect(workflowVisualizer.getAllStepNodes()).toContainText([
|
||||
// 'Create Record',
|
||||
// ]);
|
||||
// await expect(workflowVisualizer.getAllStepNodes()).toHaveCount(1);
|
||||
// });
|
||||
|
||||
test('Use an old version as draft while having a pending draft version', async ({
|
||||
workflowVisualizer,
|
||||
page,
|
||||
}) => {
|
||||
await workflowVisualizer.createInitialTrigger('record-created');
|
||||
// test('Use an old version as draft while having a pending draft version', async ({
|
||||
// workflowVisualizer,
|
||||
// page,
|
||||
// }) => {
|
||||
// await workflowVisualizer.createInitialTrigger('record-created');
|
||||
|
||||
await workflowVisualizer.createStep('create-record');
|
||||
// await workflowVisualizer.createStep('create-record');
|
||||
|
||||
await workflowVisualizer.background.click();
|
||||
// await workflowVisualizer.background.click();
|
||||
|
||||
await workflowVisualizer.activateWorkflowButton.click();
|
||||
// await workflowVisualizer.activateWorkflowButton.click();
|
||||
|
||||
await expect(workflowVisualizer.workflowStatus).toHaveText('Active');
|
||||
// await expect(workflowVisualizer.workflowStatus).toHaveText('Active');
|
||||
|
||||
await workflowVisualizer.createStep('delete-record');
|
||||
// await workflowVisualizer.createStep('delete-record');
|
||||
|
||||
await expect(workflowVisualizer.workflowStatus).toHaveText('Draft');
|
||||
// await expect(workflowVisualizer.workflowStatus).toHaveText('Draft');
|
||||
|
||||
await expect(workflowVisualizer.triggerNode).toContainText(
|
||||
'Record is created',
|
||||
);
|
||||
await expect(workflowVisualizer.getAllStepNodes()).toContainText([
|
||||
'Create Record',
|
||||
'Delete Record',
|
||||
]);
|
||||
await expect(workflowVisualizer.getAllStepNodes()).toHaveCount(2);
|
||||
await expect(workflowVisualizer.useAsDraftButton).not.toBeVisible();
|
||||
// await expect(workflowVisualizer.triggerNode).toContainText(
|
||||
// 'Record is created',
|
||||
// );
|
||||
// await expect(workflowVisualizer.getAllStepNodes()).toContainText([
|
||||
// 'Create Record',
|
||||
// 'Delete Record',
|
||||
// ]);
|
||||
// await expect(workflowVisualizer.getAllStepNodes()).toHaveCount(2);
|
||||
// await expect(workflowVisualizer.useAsDraftButton).not.toBeVisible();
|
||||
|
||||
await workflowVisualizer.closeSidePanel();
|
||||
// await workflowVisualizer.closeSidePanel();
|
||||
|
||||
const workflowsLink = page.getByRole('link', { name: 'Workflows' });
|
||||
await workflowsLink.click();
|
||||
// const workflowsLink = page.getByRole('link', { name: 'Workflows' });
|
||||
// await workflowsLink.click();
|
||||
|
||||
const recordTableRowForWorkflow = page.getByRole('row', {
|
||||
name: workflowVisualizer.workflowName,
|
||||
});
|
||||
// const recordTableRowForWorkflow = page.getByRole('row', {
|
||||
// name: workflowVisualizer.workflowName,
|
||||
// });
|
||||
|
||||
const linkToWorkflow = recordTableRowForWorkflow.getByRole('link', {
|
||||
name: workflowVisualizer.workflowName,
|
||||
});
|
||||
await expect(linkToWorkflow).toBeVisible();
|
||||
// const linkToWorkflow = recordTableRowForWorkflow.getByRole('link', {
|
||||
// name: workflowVisualizer.workflowName,
|
||||
// });
|
||||
// await expect(linkToWorkflow).toBeVisible();
|
||||
|
||||
const linkToFirstWorkflowVersion = recordTableRowForWorkflow.getByRole(
|
||||
'link',
|
||||
{
|
||||
name: 'v1',
|
||||
},
|
||||
);
|
||||
// const linkToFirstWorkflowVersion = recordTableRowForWorkflow.getByRole(
|
||||
// 'link',
|
||||
// {
|
||||
// name: 'v1',
|
||||
// },
|
||||
// );
|
||||
|
||||
await linkToFirstWorkflowVersion.click({ force: true });
|
||||
// await linkToFirstWorkflowVersion.click({ force: true });
|
||||
|
||||
await expect(workflowVisualizer.workflowStatus).toHaveText('Active');
|
||||
await expect(workflowVisualizer.useAsDraftButton).toBeVisible();
|
||||
await expect(workflowVisualizer.triggerNode).toContainText(
|
||||
'Record is created',
|
||||
);
|
||||
await expect(workflowVisualizer.getAllStepNodes()).toContainText([
|
||||
'Create Record',
|
||||
]);
|
||||
await expect(workflowVisualizer.getAllStepNodes()).toHaveCount(1);
|
||||
// await expect(workflowVisualizer.workflowStatus).toHaveText('Active');
|
||||
// await expect(workflowVisualizer.useAsDraftButton).toBeVisible();
|
||||
// await expect(workflowVisualizer.triggerNode).toContainText(
|
||||
// 'Record is created',
|
||||
// );
|
||||
// await expect(workflowVisualizer.getAllStepNodes()).toContainText([
|
||||
// 'Create Record',
|
||||
// ]);
|
||||
// await expect(workflowVisualizer.getAllStepNodes()).toHaveCount(1);
|
||||
|
||||
await workflowVisualizer.useAsDraftButton.click();
|
||||
// await workflowVisualizer.useAsDraftButton.click();
|
||||
|
||||
await expect(workflowVisualizer.overrideDraftButton).toBeVisible();
|
||||
// await expect(workflowVisualizer.overrideDraftButton).toBeVisible();
|
||||
|
||||
await workflowVisualizer.overrideDraftButton.click();
|
||||
// await workflowVisualizer.overrideDraftButton.click();
|
||||
|
||||
await page.waitForURL(`/object/workflow/${workflowVisualizer.workflowId}`);
|
||||
// await page.waitForURL(`/object/workflow/${workflowVisualizer.workflowId}`);
|
||||
|
||||
await expect(workflowVisualizer.workflowStatus).toHaveText('Draft');
|
||||
await expect(workflowVisualizer.useAsDraftButton).not.toBeVisible();
|
||||
await expect(workflowVisualizer.triggerNode).toContainText(
|
||||
'Record is created',
|
||||
);
|
||||
await expect(workflowVisualizer.getAllStepNodes()).toContainText([
|
||||
'Create Record',
|
||||
]);
|
||||
await expect(workflowVisualizer.getAllStepNodes()).toHaveCount(1);
|
||||
await expect(workflowVisualizer.activateWorkflowButton).toBeVisible();
|
||||
await expect(workflowVisualizer.discardDraftButton).toBeVisible();
|
||||
});
|
||||
// await expect(workflowVisualizer.workflowStatus).toHaveText('Draft');
|
||||
// await expect(workflowVisualizer.useAsDraftButton).not.toBeVisible();
|
||||
// await expect(workflowVisualizer.triggerNode).toContainText(
|
||||
// 'Record is created',
|
||||
// );
|
||||
// await expect(workflowVisualizer.getAllStepNodes()).toContainText([
|
||||
// 'Create Record',
|
||||
// ]);
|
||||
// await expect(workflowVisualizer.getAllStepNodes()).toHaveCount(1);
|
||||
// await expect(workflowVisualizer.activateWorkflowButton).toBeVisible();
|
||||
// await expect(workflowVisualizer.discardDraftButton).toBeVisible();
|
||||
// });
|
||||
|
||||
@@ -1,218 +1,218 @@
|
||||
import { expect } from '@playwright/test';
|
||||
import { test } from '../lib/fixtures/blank-workflow';
|
||||
// import { expect } from '@playwright/test';
|
||||
// import { test } from '../lib/fixtures/blank-workflow';
|
||||
|
||||
test('Create workflow with every possible step', async ({
|
||||
workflowVisualizer,
|
||||
page,
|
||||
}) => {
|
||||
await workflowVisualizer.createInitialTrigger('record-created');
|
||||
// test('Create workflow with every possible step', async ({
|
||||
// workflowVisualizer,
|
||||
// page,
|
||||
// }) => {
|
||||
// await workflowVisualizer.createInitialTrigger('record-created');
|
||||
|
||||
await workflowVisualizer.createStep('create-record');
|
||||
await workflowVisualizer.createStep('update-record');
|
||||
await workflowVisualizer.createStep('delete-record');
|
||||
await workflowVisualizer.createStep('code');
|
||||
await workflowVisualizer.createStep('send-email');
|
||||
// await workflowVisualizer.createStep('create-record');
|
||||
// await workflowVisualizer.createStep('update-record');
|
||||
// await workflowVisualizer.createStep('delete-record');
|
||||
// await workflowVisualizer.createStep('code');
|
||||
// await workflowVisualizer.createStep('send-email');
|
||||
|
||||
await workflowVisualizer.background.click();
|
||||
// await workflowVisualizer.background.click();
|
||||
|
||||
const draftWorkflowStatus =
|
||||
workflowVisualizer.workflowStatus.getByText('Draft');
|
||||
// const draftWorkflowStatus =
|
||||
// workflowVisualizer.workflowStatus.getByText('Draft');
|
||||
|
||||
await expect(draftWorkflowStatus).toBeVisible();
|
||||
// await expect(draftWorkflowStatus).toBeVisible();
|
||||
|
||||
await workflowVisualizer.activateWorkflowButton.click();
|
||||
// await workflowVisualizer.activateWorkflowButton.click();
|
||||
|
||||
const activeWorkflowStatus =
|
||||
workflowVisualizer.workflowStatus.getByText('Active');
|
||||
// const activeWorkflowStatus =
|
||||
// workflowVisualizer.workflowStatus.getByText('Active');
|
||||
|
||||
await expect(draftWorkflowStatus).not.toBeVisible();
|
||||
await expect(activeWorkflowStatus).toBeVisible();
|
||||
await expect(workflowVisualizer.activateWorkflowButton).not.toBeVisible();
|
||||
await expect(workflowVisualizer.deactivateWorkflowButton).toBeVisible();
|
||||
});
|
||||
// await expect(draftWorkflowStatus).not.toBeVisible();
|
||||
// await expect(activeWorkflowStatus).toBeVisible();
|
||||
// await expect(workflowVisualizer.activateWorkflowButton).not.toBeVisible();
|
||||
// await expect(workflowVisualizer.deactivateWorkflowButton).toBeVisible();
|
||||
// });
|
||||
|
||||
test('Delete steps from draft version', async ({
|
||||
workflowVisualizer,
|
||||
page,
|
||||
}) => {
|
||||
await workflowVisualizer.createInitialTrigger('record-created');
|
||||
// test('Delete steps from draft version', async ({
|
||||
// workflowVisualizer,
|
||||
// page,
|
||||
// }) => {
|
||||
// await workflowVisualizer.createInitialTrigger('record-created');
|
||||
|
||||
const { createdStepId: firstStepId } =
|
||||
await workflowVisualizer.createStep('create-record');
|
||||
const { createdStepId: secondStepId } =
|
||||
await workflowVisualizer.createStep('update-record');
|
||||
const { createdStepId: thirdStepId } =
|
||||
await workflowVisualizer.createStep('delete-record');
|
||||
const { createdStepId: fourthStepId } =
|
||||
await workflowVisualizer.createStep('code');
|
||||
const { createdStepId: fifthStepId } =
|
||||
await workflowVisualizer.createStep('send-email');
|
||||
// const { createdStepId: firstStepId } =
|
||||
// await workflowVisualizer.createStep('create-record');
|
||||
// const { createdStepId: secondStepId } =
|
||||
// await workflowVisualizer.createStep('update-record');
|
||||
// const { createdStepId: thirdStepId } =
|
||||
// await workflowVisualizer.createStep('delete-record');
|
||||
// const { createdStepId: fourthStepId } =
|
||||
// await workflowVisualizer.createStep('code');
|
||||
// const { createdStepId: fifthStepId } =
|
||||
// await workflowVisualizer.createStep('send-email');
|
||||
|
||||
await expect(workflowVisualizer.getAllStepNodes()).toContainText([
|
||||
'Create Record',
|
||||
'Update Record',
|
||||
'Delete Record',
|
||||
'Code - Serverless Function',
|
||||
'Send Email',
|
||||
]);
|
||||
await expect(workflowVisualizer.getAllStepNodes()).toHaveCount(5);
|
||||
// await expect(workflowVisualizer.getAllStepNodes()).toContainText([
|
||||
// 'Create Record',
|
||||
// 'Update Record',
|
||||
// 'Delete Record',
|
||||
// 'Code - Serverless Function',
|
||||
// 'Send Email',
|
||||
// ]);
|
||||
// await expect(workflowVisualizer.getAllStepNodes()).toHaveCount(5);
|
||||
|
||||
await workflowVisualizer.deleteStep(firstStepId);
|
||||
// await workflowVisualizer.deleteStep(firstStepId);
|
||||
|
||||
await expect(workflowVisualizer.getAllStepNodes()).toContainText([
|
||||
'Update Record',
|
||||
'Delete Record',
|
||||
'Code - Serverless Function',
|
||||
'Send Email',
|
||||
]);
|
||||
await expect(workflowVisualizer.getAllStepNodes()).toHaveCount(4);
|
||||
// await expect(workflowVisualizer.getAllStepNodes()).toContainText([
|
||||
// 'Update Record',
|
||||
// 'Delete Record',
|
||||
// 'Code - Serverless Function',
|
||||
// 'Send Email',
|
||||
// ]);
|
||||
// await expect(workflowVisualizer.getAllStepNodes()).toHaveCount(4);
|
||||
|
||||
await workflowVisualizer.deleteStep(fifthStepId);
|
||||
// await workflowVisualizer.deleteStep(fifthStepId);
|
||||
|
||||
await expect(workflowVisualizer.getAllStepNodes()).toContainText([
|
||||
'Update Record',
|
||||
'Delete Record',
|
||||
'Code - Serverless Function',
|
||||
]);
|
||||
await expect(workflowVisualizer.getAllStepNodes()).toHaveCount(3);
|
||||
// await expect(workflowVisualizer.getAllStepNodes()).toContainText([
|
||||
// 'Update Record',
|
||||
// 'Delete Record',
|
||||
// 'Code - Serverless Function',
|
||||
// ]);
|
||||
// await expect(workflowVisualizer.getAllStepNodes()).toHaveCount(3);
|
||||
|
||||
await workflowVisualizer.deleteStep(secondStepId);
|
||||
// await workflowVisualizer.deleteStep(secondStepId);
|
||||
|
||||
await expect(workflowVisualizer.getAllStepNodes()).toContainText([
|
||||
'Delete Record',
|
||||
'Code - Serverless Function',
|
||||
]);
|
||||
await expect(workflowVisualizer.getAllStepNodes()).toHaveCount(2);
|
||||
// await expect(workflowVisualizer.getAllStepNodes()).toContainText([
|
||||
// 'Delete Record',
|
||||
// 'Code - Serverless Function',
|
||||
// ]);
|
||||
// await expect(workflowVisualizer.getAllStepNodes()).toHaveCount(2);
|
||||
|
||||
await workflowVisualizer.deleteStep(fourthStepId);
|
||||
// await workflowVisualizer.deleteStep(fourthStepId);
|
||||
|
||||
await expect(workflowVisualizer.getAllStepNodes()).toContainText([
|
||||
'Delete Record',
|
||||
]);
|
||||
await expect(workflowVisualizer.getAllStepNodes()).toHaveCount(1);
|
||||
// await expect(workflowVisualizer.getAllStepNodes()).toContainText([
|
||||
// 'Delete Record',
|
||||
// ]);
|
||||
// await expect(workflowVisualizer.getAllStepNodes()).toHaveCount(1);
|
||||
|
||||
await workflowVisualizer.deleteStep(thirdStepId);
|
||||
// await workflowVisualizer.deleteStep(thirdStepId);
|
||||
|
||||
await expect(workflowVisualizer.getAllStepNodes()).toHaveCount(0);
|
||||
// await expect(workflowVisualizer.getAllStepNodes()).toHaveCount(0);
|
||||
|
||||
await Promise.all([
|
||||
page.reload(),
|
||||
// await Promise.all([
|
||||
// page.reload(),
|
||||
|
||||
expect(workflowVisualizer.triggerNode).toBeVisible(),
|
||||
]);
|
||||
// expect(workflowVisualizer.triggerNode).toBeVisible(),
|
||||
// ]);
|
||||
|
||||
await expect(workflowVisualizer.getAllStepNodes()).toHaveCount(0);
|
||||
});
|
||||
// await expect(workflowVisualizer.getAllStepNodes()).toHaveCount(0);
|
||||
// });
|
||||
|
||||
test('Add a step to an active version', async ({
|
||||
workflowVisualizer,
|
||||
page,
|
||||
}) => {
|
||||
await workflowVisualizer.createInitialTrigger('record-created');
|
||||
// test('Add a step to an active version', async ({
|
||||
// workflowVisualizer,
|
||||
// page,
|
||||
// }) => {
|
||||
// await workflowVisualizer.createInitialTrigger('record-created');
|
||||
|
||||
await workflowVisualizer.createStep('create-record');
|
||||
// await workflowVisualizer.createStep('create-record');
|
||||
|
||||
await expect(workflowVisualizer.workflowStatus).toHaveText('Draft');
|
||||
// await expect(workflowVisualizer.workflowStatus).toHaveText('Draft');
|
||||
|
||||
await workflowVisualizer.background.click();
|
||||
// await workflowVisualizer.background.click();
|
||||
|
||||
await Promise.all([
|
||||
expect(workflowVisualizer.workflowStatus).toHaveText('Active'),
|
||||
// await Promise.all([
|
||||
// expect(workflowVisualizer.workflowStatus).toHaveText('Active'),
|
||||
|
||||
workflowVisualizer.activateWorkflowButton.click(),
|
||||
]);
|
||||
// workflowVisualizer.activateWorkflowButton.click(),
|
||||
// ]);
|
||||
|
||||
await expect(workflowVisualizer.activateWorkflowButton).not.toBeVisible();
|
||||
// await expect(workflowVisualizer.activateWorkflowButton).not.toBeVisible();
|
||||
|
||||
const assertEndState = async () => {
|
||||
await expect(workflowVisualizer.workflowStatus).toHaveText('Active');
|
||||
await expect(workflowVisualizer.triggerNode).toContainText(
|
||||
'Record is created',
|
||||
);
|
||||
await expect(workflowVisualizer.getAllStepNodes()).toContainText([
|
||||
'Create Record',
|
||||
]);
|
||||
await expect(workflowVisualizer.getAllStepNodes()).toHaveCount(1);
|
||||
};
|
||||
// const assertEndState = async () => {
|
||||
// await expect(workflowVisualizer.workflowStatus).toHaveText('Active');
|
||||
// await expect(workflowVisualizer.triggerNode).toContainText(
|
||||
// 'Record is created',
|
||||
// );
|
||||
// await expect(workflowVisualizer.getAllStepNodes()).toContainText([
|
||||
// 'Create Record',
|
||||
// ]);
|
||||
// await expect(workflowVisualizer.getAllStepNodes()).toHaveCount(1);
|
||||
// };
|
||||
|
||||
await assertEndState();
|
||||
// await assertEndState();
|
||||
|
||||
await page.reload();
|
||||
// await page.reload();
|
||||
|
||||
await assertEndState();
|
||||
});
|
||||
// await assertEndState();
|
||||
// });
|
||||
|
||||
test('Replace the trigger of an active version', async ({
|
||||
workflowVisualizer,
|
||||
page,
|
||||
}) => {
|
||||
await workflowVisualizer.createInitialTrigger('record-created');
|
||||
// test('Replace the trigger of an active version', async ({
|
||||
// workflowVisualizer,
|
||||
// page,
|
||||
// }) => {
|
||||
// await workflowVisualizer.createInitialTrigger('record-created');
|
||||
|
||||
await workflowVisualizer.createStep('create-record');
|
||||
// await workflowVisualizer.createStep('create-record');
|
||||
|
||||
await workflowVisualizer.background.click();
|
||||
// await workflowVisualizer.background.click();
|
||||
|
||||
await Promise.all([
|
||||
expect(workflowVisualizer.workflowStatus).toHaveText('Active'),
|
||||
// await Promise.all([
|
||||
// expect(workflowVisualizer.workflowStatus).toHaveText('Active'),
|
||||
|
||||
workflowVisualizer.activateWorkflowButton.click(),
|
||||
]);
|
||||
// workflowVisualizer.activateWorkflowButton.click(),
|
||||
// ]);
|
||||
|
||||
await Promise.all([
|
||||
expect(workflowVisualizer.workflowStatus).toHaveText('Draft'),
|
||||
// await Promise.all([
|
||||
// expect(workflowVisualizer.workflowStatus).toHaveText('Draft'),
|
||||
|
||||
workflowVisualizer.deleteTrigger(),
|
||||
]);
|
||||
// workflowVisualizer.deleteTrigger(),
|
||||
// ]);
|
||||
|
||||
await workflowVisualizer.createInitialTrigger('record-deleted');
|
||||
// await workflowVisualizer.createInitialTrigger('record-deleted');
|
||||
|
||||
await workflowVisualizer.background.click();
|
||||
// await workflowVisualizer.background.click();
|
||||
|
||||
await Promise.all([
|
||||
expect(workflowVisualizer.workflowStatus).toHaveText('Active'),
|
||||
// await Promise.all([
|
||||
// expect(workflowVisualizer.workflowStatus).toHaveText('Active'),
|
||||
|
||||
workflowVisualizer.activateWorkflowButton.click(),
|
||||
]);
|
||||
// workflowVisualizer.activateWorkflowButton.click(),
|
||||
// ]);
|
||||
|
||||
await page.reload();
|
||||
// await page.reload();
|
||||
|
||||
await expect(workflowVisualizer.triggerNode).toContainText(
|
||||
'Record is deleted',
|
||||
);
|
||||
await expect(workflowVisualizer.getAllStepNodes()).toHaveCount(1);
|
||||
await expect(workflowVisualizer.getAllStepNodes()).toContainText([
|
||||
'Create Record',
|
||||
]);
|
||||
});
|
||||
// await expect(workflowVisualizer.triggerNode).toContainText(
|
||||
// 'Record is deleted',
|
||||
// );
|
||||
// await expect(workflowVisualizer.getAllStepNodes()).toHaveCount(1);
|
||||
// await expect(workflowVisualizer.getAllStepNodes()).toContainText([
|
||||
// 'Create Record',
|
||||
// ]);
|
||||
// });
|
||||
|
||||
test("Nodes can't be deleted by pressing Backspace or Delete keys", async ({
|
||||
workflowVisualizer,
|
||||
page,
|
||||
}) => {
|
||||
await workflowVisualizer.triggerNode.click();
|
||||
// test("Nodes can't be deleted by pressing Backspace or Delete keys", async ({
|
||||
// workflowVisualizer,
|
||||
// page,
|
||||
// }) => {
|
||||
// await workflowVisualizer.triggerNode.click();
|
||||
|
||||
await page.keyboard.press('Backspace');
|
||||
await page.keyboard.press('Delete');
|
||||
// await page.keyboard.press('Backspace');
|
||||
// await page.keyboard.press('Delete');
|
||||
|
||||
await expect(workflowVisualizer.triggerNode).toBeVisible();
|
||||
// await expect(workflowVisualizer.triggerNode).toBeVisible();
|
||||
|
||||
const { createdStepId: firstStepId } =
|
||||
await workflowVisualizer.createStep('create-record');
|
||||
const firstStep = workflowVisualizer.getStepNode(firstStepId);
|
||||
// const { createdStepId: firstStepId } =
|
||||
// await workflowVisualizer.createStep('create-record');
|
||||
// const firstStep = workflowVisualizer.getStepNode(firstStepId);
|
||||
|
||||
await firstStep.click();
|
||||
// await firstStep.click();
|
||||
|
||||
await expect(workflowVisualizer.getDeleteNodeButton(firstStep)).toBeVisible();
|
||||
// await expect(workflowVisualizer.getDeleteNodeButton(firstStep)).toBeVisible();
|
||||
|
||||
await page.keyboard.press('Backspace');
|
||||
await page.keyboard.press('Delete');
|
||||
// await page.keyboard.press('Backspace');
|
||||
// await page.keyboard.press('Delete');
|
||||
|
||||
await expect(firstStep).toBeVisible();
|
||||
// await expect(firstStep).toBeVisible();
|
||||
|
||||
await workflowVisualizer.addStepButton.click();
|
||||
// await workflowVisualizer.addStepButton.click();
|
||||
|
||||
await page.keyboard.press('Backspace');
|
||||
await page.keyboard.press('Delete');
|
||||
// await page.keyboard.press('Backspace');
|
||||
// await page.keyboard.press('Delete');
|
||||
|
||||
await expect(workflowVisualizer.addStepButton).toBeVisible();
|
||||
});
|
||||
// await expect(workflowVisualizer.addStepButton).toBeVisible();
|
||||
// });
|
||||
|
||||
@@ -1 +1 @@
|
||||
/*eslint-disable*/import type{Messages}from"@lingui/core";export const messages=JSON.parse("{\"Suspended Workspace\":[\"Espaço de Trabalho Suspenso\"],\"Dear {userName},\":[\"Caro \",[\"userName\"],\",\"],\"Hello,\":[\"Olá,\"],\"It appears that your workspace <0>{workspaceDisplayName}</0> has been suspended for {daysSinceInactive} days.\":[\"Parece que seu workspace <0>\",[\"workspaceDisplayName\"],\"</0> foi suspenso por \",[\"daysSinceInactive\"],\" dias.\"],\"The workspace will be deactivated in {remainingDays} {dayOrDays}, and all its data will be deleted.\":[\"O workspace será desativado em \",[\"remainingDays\"],\" \",[\"dayOrDays\"],\", e todos os seus dados serão excluídos.\"],\"If you wish to continue using Twenty, please update your subscription within the next {remainingDays} {dayOrDays}.\":[\"Se quiser continuar usando o Twenty, atualize sua assinatura nos próximos \",[\"remainingDays\"],\" \",[\"dayOrDays\"],\".\"],\"Update your subscription\":[\"Atualize sua assinatura\"],\"Validate domain\":[\"Validar domínio\"],\"{senderName} (<0>{senderEmail}</0>): Please validate this domain to allow users with <1>@{domain}</1> email addresses to join your workspace without requiring an invitation.\":[[\"senderName\"],\" (<0>\",[\"senderEmail\"],\"</0>): Por favor, valide este domínio para permitir que usuários com endereços de e-mail <1>@\",[\"domain\"],\"</1> entrem no seu espaço de trabalho sem precisar de um convite.\"],\"Test email\":[\"Email de teste\"],\"Join your team on Twenty\":[\"Junte-se à sua equipe no Twenty\"],\"{senderName} (<0>{senderEmail}</0>) has invited you to join a workspace called <1>{workspaceName}</1>.\":[[\"senderName\"],\" (<0>\",[\"senderEmail\"],\"</0>) convidou você para se juntar a um espaço de trabalho chamado <1>\",[\"workspaceName\"],\"</1>.\"],\"Accept invite\":[\"Aceitar convite\"],\"Confirm your new email address\":[\"Confirm your new email address\"],\"Confirm your email address\":[\"Confirme seu endereço de e-mail\"],\"Confirm new email\":[\"Confirm new email\"],\"Verify Email\":[\"Verificar e-mail\"],\"Password updated\":[\"Senha atualizada\"],\"This is a confirmation that password for your account ({email}) was successfully changed on {formattedDate}.\":[\"Esta é uma confirmação de que a senha da sua conta (\",[\"email\"],\") foi alterada com sucesso em \",[\"formattedDate\"],\".\"],\"If you did not initiate this change, please contact your workspace owner immediately.\":[\"Se você não iniciou essa alteração, entre em contato com o proprietário do workspace imediatamente.\"],\"Connect to Twenty\":[\"Conecte-se ao Twenty\"],\"Reset your password 🗝\":[\"Redefina sua senha 🗝\"],\"Set your password 🗝\":[\"Defina sua senha 🗝\"],\"Reset\":[\"Redefinir\"],\"Set\":[\"Definir\"],\"This link is only valid for the next {duration}. If the link does not work, you can use the login verification link directly:\":[\"Este link é válido apenas para os próximos \",[\"duration\"],\". Se o link não funcionar, você pode usar o link de verificação de login diretamente:\"],\"Deleted Workspace\":[\"Espaço de Trabalho Excluído\"],\"Your workspace <0>{workspaceDisplayName}</0> has been deleted as your subscription expired {daysSinceInactive} days ago.\":[\"Seu workspace <0>\",[\"workspaceDisplayName\"],\"</0> foi excluído porque sua assinatura expirou há \",[\"daysSinceInactive\"],\" dias.\"],\"All data in this workspace has been permanently deleted.\":[\"Todos os dados deste workspace foram excluídos permanentemente.\"],\"If you wish to use Twenty again, you can create a new workspace.\":[\"Se quiser usar o Twenty novamente, você pode criar um novo workspace.\"],\"Create a new workspace\":[\"Criar um novo espaço de trabalho\"],\"What is Twenty?\":[\"O que é Twenty?\"],\"It's a CRM, a software to help businesses manage their customer data and relationships efficiently.\":[\"É um CRM, um software para ajudar empresas a gerenciar seus dados de clientes e os relacionamentos de maneira eficiente.\"],\"Website\":[\"Site\"],\"Visit Twenty's website\":[\"Visite o website do Twenty\"],\"Github\":[\"Github\"],\"Visit Twenty's GitHub repository\":[\"Visite o repositório GitHub do Twenty\"],\"User guide\":[\"Guia do Usuário\"],\"Read Twenty's user guide\":[\"Leia o guia do usuário do Twenty\"],\"Developers\":[\"Desenvolvedores\"],\"Visit Twenty's developer documentation\":[\"Visite a documentação do desenvolvedor do Twenty\"],\"Twenty.com, Public Benefit Corporation\":[\"Twenty.com, Empresa de Benefício Público\"],\"San Francisco / Paris\":[\"São Francisco / Paris\"]}")as Messages;
|
||||
/*eslint-disable*/import type{Messages}from"@lingui/core";export const messages=JSON.parse("{\"Suspended Workspace\":[\"Espaço de Trabalho Suspenso\"],\"Dear {userName},\":[\"Caro \",[\"userName\"],\",\"],\"Hello,\":[\"Olá,\"],\"It appears that your workspace <0>{workspaceDisplayName}</0> has been suspended for {daysSinceInactive} days.\":[\"Parece que seu workspace <0>\",[\"workspaceDisplayName\"],\"</0> foi suspenso por \",[\"daysSinceInactive\"],\" dias.\"],\"The workspace will be deactivated in {remainingDays} {dayOrDays}, and all its data will be deleted.\":[\"O workspace será desativado em \",[\"remainingDays\"],\" \",[\"dayOrDays\"],\", e todos os seus dados serão excluídos.\"],\"If you wish to continue using Twenty, please update your subscription within the next {remainingDays} {dayOrDays}.\":[\"Se quiser continuar usando o Twenty, atualize sua assinatura nos próximos \",[\"remainingDays\"],\" \",[\"dayOrDays\"],\".\"],\"Update your subscription\":[\"Atualize sua assinatura\"],\"Validate domain\":[\"Validar domínio\"],\"{senderName} (<0>{senderEmail}</0>): Please validate this domain to allow users with <1>@{domain}</1> email addresses to join your workspace without requiring an invitation.\":[[\"senderName\"],\" (<0>\",[\"senderEmail\"],\"</0>): Por favor, valide este domínio para permitir que usuários com endereços de e-mail <1>@\",[\"domain\"],\"</1> entrem no seu espaço de trabalho sem precisar de um convite.\"],\"Test email\":[\"Email de teste\"],\"Join your team on Twenty\":[\"Junte-se à sua equipe no Twenty\"],\"{senderName} (<0>{senderEmail}</0>) has invited you to join a workspace called <1>{workspaceName}</1>.\":[[\"senderName\"],\" (<0>\",[\"senderEmail\"],\"</0>) convidou você para se juntar a um espaço de trabalho chamado <1>\",[\"workspaceName\"],\"</1>.\"],\"Accept invite\":[\"Aceitar convite\"],\"Confirm your new email address\":[\"Confirme seu novo endereço de e-mail\"],\"Confirm your email address\":[\"Confirme seu endereço de e-mail\"],\"Confirm new email\":[\"Confirme o novo email\"],\"Verify Email\":[\"Verificar e-mail\"],\"Password updated\":[\"Senha atualizada\"],\"This is a confirmation that password for your account ({email}) was successfully changed on {formattedDate}.\":[\"Esta é uma confirmação de que a senha da sua conta (\",[\"email\"],\") foi alterada com sucesso em \",[\"formattedDate\"],\".\"],\"If you did not initiate this change, please contact your workspace owner immediately.\":[\"Se você não iniciou essa alteração, entre em contato com o proprietário do workspace imediatamente.\"],\"Connect to Twenty\":[\"Conecte-se ao Twenty\"],\"Reset your password 🗝\":[\"Redefina sua senha 🗝\"],\"Set your password 🗝\":[\"Defina sua senha 🗝\"],\"Reset\":[\"Redefinir\"],\"Set\":[\"Definir\"],\"This link is only valid for the next {duration}. If the link does not work, you can use the login verification link directly:\":[\"Este link é válido apenas para os próximos \",[\"duration\"],\". Se o link não funcionar, você pode usar o link de verificação de login diretamente:\"],\"Deleted Workspace\":[\"Espaço de Trabalho Excluído\"],\"Your workspace <0>{workspaceDisplayName}</0> has been deleted as your subscription expired {daysSinceInactive} days ago.\":[\"Seu workspace <0>\",[\"workspaceDisplayName\"],\"</0> foi excluído porque sua assinatura expirou há \",[\"daysSinceInactive\"],\" dias.\"],\"All data in this workspace has been permanently deleted.\":[\"Todos os dados deste workspace foram excluídos permanentemente.\"],\"If you wish to use Twenty again, you can create a new workspace.\":[\"Se quiser usar o Twenty novamente, você pode criar um novo workspace.\"],\"Create a new workspace\":[\"Criar um novo espaço de trabalho\"],\"What is Twenty?\":[\"O que é Twenty?\"],\"It's a CRM, a software to help businesses manage their customer data and relationships efficiently.\":[\"É um CRM, um software para ajudar empresas a gerenciar seus dados de clientes e os relacionamentos de maneira eficiente.\"],\"Website\":[\"Site\"],\"Visit Twenty's website\":[\"Visite o website do Twenty\"],\"Github\":[\"Github\"],\"Visit Twenty's GitHub repository\":[\"Visite o repositório GitHub do Twenty\"],\"User guide\":[\"Guia do Usuário\"],\"Read Twenty's user guide\":[\"Leia o guia do usuário do Twenty\"],\"Developers\":[\"Desenvolvedores\"],\"Visit Twenty's developer documentation\":[\"Visite a documentação do desenvolvedor do Twenty\"],\"Twenty.com, Public Benefit Corporation\":[\"Twenty.com, Empresa de Benefício Público\"],\"San Francisco / Paris\":[\"São Francisco / Paris\"]}")as Messages;
|
||||
File diff suppressed because one or more lines are too long
@@ -91,7 +91,7 @@ msgstr "Aceitar convite"
|
||||
#. js-lingui-explicit-id
|
||||
#: src/emails/send-email-verification-link.email.tsx
|
||||
msgid "Confirm your new email address"
|
||||
msgstr ""
|
||||
msgstr "Confirme seu novo endereço de e-mail"
|
||||
|
||||
#. js-lingui-explicit-id
|
||||
#: src/emails/send-email-verification-link.email.tsx
|
||||
@@ -101,7 +101,7 @@ msgstr "Confirme seu endereço de e-mail"
|
||||
#. js-lingui-explicit-id
|
||||
#: src/emails/send-email-verification-link.email.tsx
|
||||
msgid "Confirm new email"
|
||||
msgstr ""
|
||||
msgstr "Confirme o novo email"
|
||||
|
||||
#. js-lingui-explicit-id
|
||||
#: src/emails/send-email-verification-link.email.tsx
|
||||
|
||||
@@ -91,7 +91,7 @@ msgstr "Принять приглашение"
|
||||
#. js-lingui-explicit-id
|
||||
#: src/emails/send-email-verification-link.email.tsx
|
||||
msgid "Confirm your new email address"
|
||||
msgstr ""
|
||||
msgstr "Подтвердите ваш новый адрес электронной почты"
|
||||
|
||||
#. js-lingui-explicit-id
|
||||
#: src/emails/send-email-verification-link.email.tsx
|
||||
@@ -101,7 +101,7 @@ msgstr "Подтвердите ваш адрес электронной почт
|
||||
#. js-lingui-explicit-id
|
||||
#: src/emails/send-email-verification-link.email.tsx
|
||||
msgid "Confirm new email"
|
||||
msgstr ""
|
||||
msgstr "Подтвердить новый адрес эл. почты"
|
||||
|
||||
#. js-lingui-explicit-id
|
||||
#: src/emails/send-email-verification-link.email.tsx
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { type JSONContent } from '@tiptap/core';
|
||||
import { type ReactNode } from 'react';
|
||||
|
||||
export const hardBreak = (_node: JSONContent): ReactNode => {
|
||||
return <br />;
|
||||
};
|
||||
@@ -1,14 +1,15 @@
|
||||
import { type JSONContent } from '@tiptap/core';
|
||||
import { Fragment, type ReactNode } from 'react';
|
||||
import { TIPTAP_NODE_TYPES, type TipTapNodeType } from 'twenty-shared/utils';
|
||||
import { bulletList } from '../nodes/bullet-list';
|
||||
import { hardBreak } from '../nodes/hard-break';
|
||||
import { heading } from '../nodes/heading';
|
||||
import { image } from '../nodes/image';
|
||||
import { listItem } from '../nodes/list-item';
|
||||
import { orderedList } from '../nodes/ordered-list';
|
||||
import { paragraph } from '../nodes/paragraph';
|
||||
import { text } from '../nodes/text';
|
||||
import { variableTag } from '../nodes/variable-tag';
|
||||
import { bulletList } from '../nodes/bullet-list';
|
||||
import { listItem } from '../nodes/list-item';
|
||||
import { orderedList } from '../nodes/ordered-list';
|
||||
|
||||
const NODE_RENDERERS = {
|
||||
[TIPTAP_NODE_TYPES.PARAGRAPH]: paragraph,
|
||||
@@ -19,6 +20,7 @@ const NODE_RENDERERS = {
|
||||
[TIPTAP_NODE_TYPES.BULLET_LIST]: bulletList,
|
||||
[TIPTAP_NODE_TYPES.ORDERED_LIST]: orderedList,
|
||||
[TIPTAP_NODE_TYPES.LIST_ITEM]: listItem,
|
||||
[TIPTAP_NODE_TYPES.HARD_BREAK]: hardBreak,
|
||||
};
|
||||
|
||||
const renderNode = (node: JSONContent): ReactNode => {
|
||||
|
||||
@@ -11,7 +11,7 @@ const modulesCoverage = {
|
||||
branches: 25,
|
||||
statements: 43,
|
||||
lines: 44,
|
||||
functions: 38,
|
||||
functions: 37,
|
||||
include: ['src/modules/**/*'],
|
||||
exclude: ['src/**/*.ts'],
|
||||
};
|
||||
|
||||
@@ -59,6 +59,22 @@
|
||||
"fix": {}
|
||||
}
|
||||
},
|
||||
"lint:changed": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": false,
|
||||
"options": {
|
||||
"command": "git diff --name-only --diff-filter=d {args.base} | grep -E '\\.(ts|tsx|js|jsx)$' | grep '^packages/twenty-front/' | xargs -r npx eslint --config packages/twenty-front/eslint.config.mjs",
|
||||
"base": "main"
|
||||
},
|
||||
"configurations": {
|
||||
"fix": {
|
||||
"command": "git diff --name-only --diff-filter=d {args.base} | grep -E '\\.(ts|tsx|js|jsx)$' | grep '^packages/twenty-front/' | xargs -r npx eslint --config packages/twenty-front/eslint.config.mjs --fix"
|
||||
},
|
||||
"staged": {
|
||||
"command": "git diff --cached --name-only --diff-filter=d | grep -E '\\.(ts|tsx|js|jsx)$' | grep '^packages/twenty-front/' | xargs -r npx eslint --config packages/twenty-front/eslint.config.mjs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"fmt": {
|
||||
"options": {
|
||||
"files": "src"
|
||||
|
||||
@@ -70,9 +70,14 @@ export type Agent = {
|
||||
|
||||
export type AgentChatThread = {
|
||||
__typename?: 'AgentChatThread';
|
||||
contextWindowTokens?: Maybe<Scalars['Int']>;
|
||||
createdAt: Scalars['DateTime'];
|
||||
id: Scalars['UUID'];
|
||||
title?: Maybe<Scalars['String']>;
|
||||
totalInputCredits: Scalars['Int'];
|
||||
totalInputTokens: Scalars['Int'];
|
||||
totalOutputCredits: Scalars['Int'];
|
||||
totalOutputTokens: Scalars['Int'];
|
||||
updatedAt: Scalars['DateTime'];
|
||||
};
|
||||
|
||||
@@ -152,6 +157,7 @@ export type AggregateChartConfiguration = {
|
||||
graphType: GraphType;
|
||||
label?: Maybe<Scalars['String']>;
|
||||
prefix?: Maybe<Scalars['String']>;
|
||||
ratioAggregateConfig?: Maybe<RatioAggregateConfig>;
|
||||
suffix?: Maybe<Scalars['String']>;
|
||||
timezone?: Maybe<Scalars['String']>;
|
||||
};
|
||||
@@ -235,6 +241,8 @@ export type Application = {
|
||||
agents: Array<Agent>;
|
||||
applicationVariables: Array<ApplicationVariable>;
|
||||
canBeUninstalled: Scalars['Boolean'];
|
||||
defaultServerlessFunctionRole?: Maybe<Role>;
|
||||
defaultServerlessFunctionRoleId?: Maybe<Scalars['String']>;
|
||||
description: Scalars['String'];
|
||||
id: Scalars['UUID'];
|
||||
name: Scalars['String'];
|
||||
@@ -570,6 +578,7 @@ export type CheckUserExistOutput = {
|
||||
|
||||
export type ClientAiModelConfig = {
|
||||
__typename?: 'ClientAIModelConfig';
|
||||
deprecated?: Maybe<Scalars['Boolean']>;
|
||||
inputCostPer1kTokensInCredits: Scalars['Float'];
|
||||
label: Scalars['String'];
|
||||
modelId: Scalars['String'];
|
||||
@@ -617,6 +626,7 @@ export enum ConfigVariablesGroup {
|
||||
BILLING_CONFIG = 'BILLING_CONFIG',
|
||||
CAPTCHA_CONFIG = 'CAPTCHA_CONFIG',
|
||||
CLOUDFLARE_CONFIG = 'CLOUDFLARE_CONFIG',
|
||||
CODE_INTERPRETER_CONFIG = 'CODE_INTERPRETER_CONFIG',
|
||||
EMAIL_SETTINGS = 'EMAIL_SETTINGS',
|
||||
EXCEPTION_HANDLER = 'EXCEPTION_HANDLER',
|
||||
GOOGLE_AUTH = 'GOOGLE_AUTH',
|
||||
@@ -694,6 +704,7 @@ export type CoreView = {
|
||||
objectMetadataId: Scalars['UUID'];
|
||||
openRecordIn: ViewOpenRecordIn;
|
||||
position: Scalars['Float'];
|
||||
shouldHideEmptyGroups: Scalars['Boolean'];
|
||||
type: ViewType;
|
||||
updatedAt: Scalars['DateTime'];
|
||||
viewFields: Array<CoreViewField>;
|
||||
@@ -985,6 +996,7 @@ export type CreateViewInput = {
|
||||
objectMetadataId: Scalars['UUID'];
|
||||
openRecordIn?: InputMaybe<ViewOpenRecordIn>;
|
||||
position?: InputMaybe<Scalars['Float']>;
|
||||
shouldHideEmptyGroups?: InputMaybe<Scalars['Boolean']>;
|
||||
type?: InputMaybe<ViewType>;
|
||||
visibility?: InputMaybe<ViewVisibility>;
|
||||
};
|
||||
@@ -1208,6 +1220,16 @@ export type DuplicateWorkflowVersionStepInput = {
|
||||
workflowVersionId: Scalars['String'];
|
||||
};
|
||||
|
||||
export type DuplicatedDashboard = {
|
||||
__typename?: 'DuplicatedDashboard';
|
||||
createdAt: Scalars['String'];
|
||||
id: Scalars['UUID'];
|
||||
pageLayoutId?: Maybe<Scalars['UUID']>;
|
||||
position: Scalars['Float'];
|
||||
title?: Maybe<Scalars['String']>;
|
||||
updatedAt: Scalars['String'];
|
||||
};
|
||||
|
||||
export type EditSsoInput = {
|
||||
id: Scalars['UUID'];
|
||||
status: SsoIdentityProviderStatus;
|
||||
@@ -1294,6 +1316,7 @@ export enum FeatureFlagKey {
|
||||
IS_PUBLIC_DOMAIN_ENABLED = 'IS_PUBLIC_DOMAIN_ENABLED',
|
||||
IS_RECORD_PAGE_LAYOUT_ENABLED = 'IS_RECORD_PAGE_LAYOUT_ENABLED',
|
||||
IS_STRIPE_INTEGRATION_ENABLED = 'IS_STRIPE_INTEGRATION_ENABLED',
|
||||
IS_TIMELINE_ACTIVITY_MIGRATED = 'IS_TIMELINE_ACTIVITY_MIGRATED',
|
||||
IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED',
|
||||
IS_WORKFLOW_RUN_STOPPAGE_ENABLED = 'IS_WORKFLOW_RUN_STOPPAGE_ENABLED'
|
||||
}
|
||||
@@ -1849,6 +1872,7 @@ export type Mutation = {
|
||||
destroyPageLayoutWidget: Scalars['Boolean'];
|
||||
disablePostgresProxy: PostgresCredentials;
|
||||
dismissReconnectAccountBanner: Scalars['Boolean'];
|
||||
duplicateDashboard: DuplicatedDashboard;
|
||||
duplicateWorkflow: WorkflowVersionDto;
|
||||
duplicateWorkflowVersionStep: WorkflowVersionStepChanges;
|
||||
editSSOIdentityProvider: EditSsoOutput;
|
||||
@@ -2387,6 +2411,11 @@ export type MutationDismissReconnectAccountBannerArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type MutationDuplicateDashboardArgs = {
|
||||
id: Scalars['UUID'];
|
||||
};
|
||||
|
||||
|
||||
export type MutationDuplicateWorkflowArgs = {
|
||||
input: DuplicateWorkflowInput;
|
||||
};
|
||||
@@ -3125,6 +3154,7 @@ export enum PermissionFlagType {
|
||||
API_KEYS_AND_WEBHOOKS = 'API_KEYS_AND_WEBHOOKS',
|
||||
APPLICATIONS = 'APPLICATIONS',
|
||||
BILLING = 'BILLING',
|
||||
CODE_INTERPRETER_TOOL = 'CODE_INTERPRETER_TOOL',
|
||||
CONNECTED_ACCOUNTS = 'CONNECTED_ACCOUNTS',
|
||||
DATA_MODEL = 'DATA_MODEL',
|
||||
DOWNLOAD_FILE = 'DOWNLOAD_FILE',
|
||||
@@ -3159,6 +3189,7 @@ export type PieChartConfiguration = {
|
||||
graphType: GraphType;
|
||||
groupByFieldMetadataId: Scalars['UUID'];
|
||||
groupBySubFieldName?: Maybe<Scalars['String']>;
|
||||
hideEmptyCategory?: Maybe<Scalars['Boolean']>;
|
||||
orderBy?: Maybe<GraphOrderBy>;
|
||||
showCenterMetric?: Maybe<Scalars['Boolean']>;
|
||||
timezone?: Maybe<Scalars['String']>;
|
||||
@@ -3695,6 +3726,17 @@ export type QueueRetentionConfig = {
|
||||
failedMaxCount: Scalars['Float'];
|
||||
};
|
||||
|
||||
export type RatioAggregateConfig = {
|
||||
__typename?: 'RatioAggregateConfig';
|
||||
fieldMetadataId: Scalars['UUID'];
|
||||
optionValue: Scalars['String'];
|
||||
};
|
||||
|
||||
export type RatioAggregateConfigInput = {
|
||||
fieldMetadataId: Scalars['UUID'];
|
||||
optionValue: Scalars['String'];
|
||||
};
|
||||
|
||||
export type Relation = {
|
||||
__typename?: 'Relation';
|
||||
sourceFieldMetadata: Field;
|
||||
@@ -4466,6 +4508,7 @@ export type UpdateViewInput = {
|
||||
name?: InputMaybe<Scalars['String']>;
|
||||
openRecordIn?: InputMaybe<ViewOpenRecordIn>;
|
||||
position?: InputMaybe<Scalars['Float']>;
|
||||
shouldHideEmptyGroups?: InputMaybe<Scalars['Boolean']>;
|
||||
type?: InputMaybe<ViewType>;
|
||||
visibility?: InputMaybe<ViewVisibility>;
|
||||
};
|
||||
@@ -5081,7 +5124,7 @@ export type GetChatMessagesQuery = { __typename?: 'Query', chatMessages: Array<{
|
||||
export type GetChatThreadsQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
export type GetChatThreadsQuery = { __typename?: 'Query', chatThreads: Array<{ __typename?: 'AgentChatThread', id: string, title?: string | null, createdAt: string, updatedAt: string }> };
|
||||
export type GetChatThreadsQuery = { __typename?: 'Query', chatThreads: Array<{ __typename?: 'AgentChatThread', id: string, title?: string | null, totalInputTokens: number, totalOutputTokens: number, contextWindowTokens?: number | null, totalInputCredits: number, totalOutputCredits: number, createdAt: string, updatedAt: string }> };
|
||||
|
||||
export type TrackAnalyticsMutationVariables = Exact<{
|
||||
type: AnalyticsType;
|
||||
@@ -6070,7 +6113,7 @@ export type ViewFilterFragmentFragment = { __typename?: 'CoreViewFilter', id: st
|
||||
|
||||
export type ViewFilterGroupFragmentFragment = { __typename?: 'CoreViewFilterGroup', id: string, parentViewFilterGroupId?: string | null, logicalOperator: ViewFilterGroupLogicalOperator, positionInViewFilterGroup?: number | null, viewId: string };
|
||||
|
||||
export type ViewFragmentFragment = { __typename?: 'CoreView', id: string, name: string, objectMetadataId: string, type: ViewType, key?: ViewKey | null, icon: string, position: number, isCompact: boolean, openRecordIn: ViewOpenRecordIn, kanbanAggregateOperation?: AggregateOperations | null, kanbanAggregateOperationFieldMetadataId?: string | null, mainGroupByFieldMetadataId?: string | null, anyFieldFilterValue?: string | null, calendarFieldMetadataId?: string | null, calendarLayout?: ViewCalendarLayout | null, visibility: ViewVisibility, createdByUserWorkspaceId?: string | null, viewFields: Array<{ __typename?: 'CoreViewField', id: string, fieldMetadataId: string, viewId: string, isVisible: boolean, position: number, size: number, aggregateOperation?: AggregateOperations | null, createdAt: string, updatedAt: string, deletedAt?: string | null }>, viewFilters: Array<{ __typename?: 'CoreViewFilter', id: string, fieldMetadataId: string, operand: ViewFilterOperand, value: any, viewFilterGroupId?: string | null, positionInViewFilterGroup?: number | null, subFieldName?: string | null, viewId: string, createdAt: string, updatedAt: string, deletedAt?: string | null }>, viewFilterGroups: Array<{ __typename?: 'CoreViewFilterGroup', id: string, parentViewFilterGroupId?: string | null, logicalOperator: ViewFilterGroupLogicalOperator, positionInViewFilterGroup?: number | null, viewId: string }>, viewSorts: Array<{ __typename?: 'CoreViewSort', id: string, fieldMetadataId: string, direction: ViewSortDirection, viewId: string }>, viewGroups: Array<{ __typename?: 'CoreViewGroup', id: string, isVisible: boolean, fieldValue: string, position: number, viewId: string, createdAt: string, updatedAt: string, deletedAt?: string | null }> };
|
||||
export type ViewFragmentFragment = { __typename?: 'CoreView', id: string, name: string, objectMetadataId: string, type: ViewType, key?: ViewKey | null, icon: string, position: number, isCompact: boolean, openRecordIn: ViewOpenRecordIn, kanbanAggregateOperation?: AggregateOperations | null, kanbanAggregateOperationFieldMetadataId?: string | null, mainGroupByFieldMetadataId?: string | null, shouldHideEmptyGroups: boolean, anyFieldFilterValue?: string | null, calendarFieldMetadataId?: string | null, calendarLayout?: ViewCalendarLayout | null, visibility: ViewVisibility, createdByUserWorkspaceId?: string | null, viewFields: Array<{ __typename?: 'CoreViewField', id: string, fieldMetadataId: string, viewId: string, isVisible: boolean, position: number, size: number, aggregateOperation?: AggregateOperations | null, createdAt: string, updatedAt: string, deletedAt?: string | null }>, viewFilters: Array<{ __typename?: 'CoreViewFilter', id: string, fieldMetadataId: string, operand: ViewFilterOperand, value: any, viewFilterGroupId?: string | null, positionInViewFilterGroup?: number | null, subFieldName?: string | null, viewId: string, createdAt: string, updatedAt: string, deletedAt?: string | null }>, viewFilterGroups: Array<{ __typename?: 'CoreViewFilterGroup', id: string, parentViewFilterGroupId?: string | null, logicalOperator: ViewFilterGroupLogicalOperator, positionInViewFilterGroup?: number | null, viewId: string }>, viewSorts: Array<{ __typename?: 'CoreViewSort', id: string, fieldMetadataId: string, direction: ViewSortDirection, viewId: string }>, viewGroups: Array<{ __typename?: 'CoreViewGroup', id: string, isVisible: boolean, fieldValue: string, position: number, viewId: string, createdAt: string, updatedAt: string, deletedAt?: string | null }> };
|
||||
|
||||
export type ViewGroupFragmentFragment = { __typename?: 'CoreViewGroup', id: string, isVisible: boolean, fieldValue: string, position: number, viewId: string, createdAt: string, updatedAt: string, deletedAt?: string | null };
|
||||
|
||||
@@ -6081,7 +6124,7 @@ export type CreateCoreViewMutationVariables = Exact<{
|
||||
}>;
|
||||
|
||||
|
||||
export type CreateCoreViewMutation = { __typename?: 'Mutation', createCoreView: { __typename?: 'CoreView', id: string, name: string, objectMetadataId: string, type: ViewType, key?: ViewKey | null, icon: string, position: number, isCompact: boolean, openRecordIn: ViewOpenRecordIn, kanbanAggregateOperation?: AggregateOperations | null, kanbanAggregateOperationFieldMetadataId?: string | null, mainGroupByFieldMetadataId?: string | null, anyFieldFilterValue?: string | null, calendarFieldMetadataId?: string | null, calendarLayout?: ViewCalendarLayout | null, visibility: ViewVisibility, createdByUserWorkspaceId?: string | null, viewFields: Array<{ __typename?: 'CoreViewField', id: string, fieldMetadataId: string, viewId: string, isVisible: boolean, position: number, size: number, aggregateOperation?: AggregateOperations | null, createdAt: string, updatedAt: string, deletedAt?: string | null }>, viewFilters: Array<{ __typename?: 'CoreViewFilter', id: string, fieldMetadataId: string, operand: ViewFilterOperand, value: any, viewFilterGroupId?: string | null, positionInViewFilterGroup?: number | null, subFieldName?: string | null, viewId: string, createdAt: string, updatedAt: string, deletedAt?: string | null }>, viewFilterGroups: Array<{ __typename?: 'CoreViewFilterGroup', id: string, parentViewFilterGroupId?: string | null, logicalOperator: ViewFilterGroupLogicalOperator, positionInViewFilterGroup?: number | null, viewId: string }>, viewSorts: Array<{ __typename?: 'CoreViewSort', id: string, fieldMetadataId: string, direction: ViewSortDirection, viewId: string }>, viewGroups: Array<{ __typename?: 'CoreViewGroup', id: string, isVisible: boolean, fieldValue: string, position: number, viewId: string, createdAt: string, updatedAt: string, deletedAt?: string | null }> } };
|
||||
export type CreateCoreViewMutation = { __typename?: 'Mutation', createCoreView: { __typename?: 'CoreView', id: string, name: string, objectMetadataId: string, type: ViewType, key?: ViewKey | null, icon: string, position: number, isCompact: boolean, openRecordIn: ViewOpenRecordIn, kanbanAggregateOperation?: AggregateOperations | null, kanbanAggregateOperationFieldMetadataId?: string | null, mainGroupByFieldMetadataId?: string | null, shouldHideEmptyGroups: boolean, anyFieldFilterValue?: string | null, calendarFieldMetadataId?: string | null, calendarLayout?: ViewCalendarLayout | null, visibility: ViewVisibility, createdByUserWorkspaceId?: string | null, viewFields: Array<{ __typename?: 'CoreViewField', id: string, fieldMetadataId: string, viewId: string, isVisible: boolean, position: number, size: number, aggregateOperation?: AggregateOperations | null, createdAt: string, updatedAt: string, deletedAt?: string | null }>, viewFilters: Array<{ __typename?: 'CoreViewFilter', id: string, fieldMetadataId: string, operand: ViewFilterOperand, value: any, viewFilterGroupId?: string | null, positionInViewFilterGroup?: number | null, subFieldName?: string | null, viewId: string, createdAt: string, updatedAt: string, deletedAt?: string | null }>, viewFilterGroups: Array<{ __typename?: 'CoreViewFilterGroup', id: string, parentViewFilterGroupId?: string | null, logicalOperator: ViewFilterGroupLogicalOperator, positionInViewFilterGroup?: number | null, viewId: string }>, viewSorts: Array<{ __typename?: 'CoreViewSort', id: string, fieldMetadataId: string, direction: ViewSortDirection, viewId: string }>, viewGroups: Array<{ __typename?: 'CoreViewGroup', id: string, isVisible: boolean, fieldValue: string, position: number, viewId: string, createdAt: string, updatedAt: string, deletedAt?: string | null }> } };
|
||||
|
||||
export type CreateCoreViewFieldMutationVariables = Exact<{
|
||||
input: CreateViewFieldInput;
|
||||
@@ -6222,7 +6265,7 @@ export type UpdateCoreViewMutationVariables = Exact<{
|
||||
}>;
|
||||
|
||||
|
||||
export type UpdateCoreViewMutation = { __typename?: 'Mutation', updateCoreView: { __typename?: 'CoreView', id: string, name: string, objectMetadataId: string, type: ViewType, key?: ViewKey | null, icon: string, position: number, isCompact: boolean, openRecordIn: ViewOpenRecordIn, kanbanAggregateOperation?: AggregateOperations | null, kanbanAggregateOperationFieldMetadataId?: string | null, mainGroupByFieldMetadataId?: string | null, anyFieldFilterValue?: string | null, calendarFieldMetadataId?: string | null, calendarLayout?: ViewCalendarLayout | null, visibility: ViewVisibility, createdByUserWorkspaceId?: string | null, viewFields: Array<{ __typename?: 'CoreViewField', id: string, fieldMetadataId: string, viewId: string, isVisible: boolean, position: number, size: number, aggregateOperation?: AggregateOperations | null, createdAt: string, updatedAt: string, deletedAt?: string | null }>, viewFilters: Array<{ __typename?: 'CoreViewFilter', id: string, fieldMetadataId: string, operand: ViewFilterOperand, value: any, viewFilterGroupId?: string | null, positionInViewFilterGroup?: number | null, subFieldName?: string | null, viewId: string, createdAt: string, updatedAt: string, deletedAt?: string | null }>, viewFilterGroups: Array<{ __typename?: 'CoreViewFilterGroup', id: string, parentViewFilterGroupId?: string | null, logicalOperator: ViewFilterGroupLogicalOperator, positionInViewFilterGroup?: number | null, viewId: string }>, viewSorts: Array<{ __typename?: 'CoreViewSort', id: string, fieldMetadataId: string, direction: ViewSortDirection, viewId: string }>, viewGroups: Array<{ __typename?: 'CoreViewGroup', id: string, isVisible: boolean, fieldValue: string, position: number, viewId: string, createdAt: string, updatedAt: string, deletedAt?: string | null }> } };
|
||||
export type UpdateCoreViewMutation = { __typename?: 'Mutation', updateCoreView: { __typename?: 'CoreView', id: string, name: string, objectMetadataId: string, type: ViewType, key?: ViewKey | null, icon: string, position: number, isCompact: boolean, openRecordIn: ViewOpenRecordIn, kanbanAggregateOperation?: AggregateOperations | null, kanbanAggregateOperationFieldMetadataId?: string | null, mainGroupByFieldMetadataId?: string | null, shouldHideEmptyGroups: boolean, anyFieldFilterValue?: string | null, calendarFieldMetadataId?: string | null, calendarLayout?: ViewCalendarLayout | null, visibility: ViewVisibility, createdByUserWorkspaceId?: string | null, viewFields: Array<{ __typename?: 'CoreViewField', id: string, fieldMetadataId: string, viewId: string, isVisible: boolean, position: number, size: number, aggregateOperation?: AggregateOperations | null, createdAt: string, updatedAt: string, deletedAt?: string | null }>, viewFilters: Array<{ __typename?: 'CoreViewFilter', id: string, fieldMetadataId: string, operand: ViewFilterOperand, value: any, viewFilterGroupId?: string | null, positionInViewFilterGroup?: number | null, subFieldName?: string | null, viewId: string, createdAt: string, updatedAt: string, deletedAt?: string | null }>, viewFilterGroups: Array<{ __typename?: 'CoreViewFilterGroup', id: string, parentViewFilterGroupId?: string | null, logicalOperator: ViewFilterGroupLogicalOperator, positionInViewFilterGroup?: number | null, viewId: string }>, viewSorts: Array<{ __typename?: 'CoreViewSort', id: string, fieldMetadataId: string, direction: ViewSortDirection, viewId: string }>, viewGroups: Array<{ __typename?: 'CoreViewGroup', id: string, isVisible: boolean, fieldValue: string, position: number, viewId: string, createdAt: string, updatedAt: string, deletedAt?: string | null }> } };
|
||||
|
||||
export type UpdateCoreViewFieldMutationVariables = Exact<{
|
||||
input: UpdateViewFieldInput;
|
||||
@@ -6264,7 +6307,7 @@ export type UpdateCoreViewSortMutation = { __typename?: 'Mutation', updateCoreVi
|
||||
export type FindAllCoreViewsQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
export type FindAllCoreViewsQuery = { __typename?: 'Query', getCoreViews: Array<{ __typename?: 'CoreView', id: string, name: string, objectMetadataId: string, type: ViewType, key?: ViewKey | null, icon: string, position: number, isCompact: boolean, openRecordIn: ViewOpenRecordIn, kanbanAggregateOperation?: AggregateOperations | null, kanbanAggregateOperationFieldMetadataId?: string | null, mainGroupByFieldMetadataId?: string | null, anyFieldFilterValue?: string | null, calendarFieldMetadataId?: string | null, calendarLayout?: ViewCalendarLayout | null, visibility: ViewVisibility, createdByUserWorkspaceId?: string | null, viewFields: Array<{ __typename?: 'CoreViewField', id: string, fieldMetadataId: string, viewId: string, isVisible: boolean, position: number, size: number, aggregateOperation?: AggregateOperations | null, createdAt: string, updatedAt: string, deletedAt?: string | null }>, viewFilters: Array<{ __typename?: 'CoreViewFilter', id: string, fieldMetadataId: string, operand: ViewFilterOperand, value: any, viewFilterGroupId?: string | null, positionInViewFilterGroup?: number | null, subFieldName?: string | null, viewId: string, createdAt: string, updatedAt: string, deletedAt?: string | null }>, viewFilterGroups: Array<{ __typename?: 'CoreViewFilterGroup', id: string, parentViewFilterGroupId?: string | null, logicalOperator: ViewFilterGroupLogicalOperator, positionInViewFilterGroup?: number | null, viewId: string }>, viewSorts: Array<{ __typename?: 'CoreViewSort', id: string, fieldMetadataId: string, direction: ViewSortDirection, viewId: string }>, viewGroups: Array<{ __typename?: 'CoreViewGroup', id: string, isVisible: boolean, fieldValue: string, position: number, viewId: string, createdAt: string, updatedAt: string, deletedAt?: string | null }> }> };
|
||||
export type FindAllCoreViewsQuery = { __typename?: 'Query', getCoreViews: Array<{ __typename?: 'CoreView', id: string, name: string, objectMetadataId: string, type: ViewType, key?: ViewKey | null, icon: string, position: number, isCompact: boolean, openRecordIn: ViewOpenRecordIn, kanbanAggregateOperation?: AggregateOperations | null, kanbanAggregateOperationFieldMetadataId?: string | null, mainGroupByFieldMetadataId?: string | null, shouldHideEmptyGroups: boolean, anyFieldFilterValue?: string | null, calendarFieldMetadataId?: string | null, calendarLayout?: ViewCalendarLayout | null, visibility: ViewVisibility, createdByUserWorkspaceId?: string | null, viewFields: Array<{ __typename?: 'CoreViewField', id: string, fieldMetadataId: string, viewId: string, isVisible: boolean, position: number, size: number, aggregateOperation?: AggregateOperations | null, createdAt: string, updatedAt: string, deletedAt?: string | null }>, viewFilters: Array<{ __typename?: 'CoreViewFilter', id: string, fieldMetadataId: string, operand: ViewFilterOperand, value: any, viewFilterGroupId?: string | null, positionInViewFilterGroup?: number | null, subFieldName?: string | null, viewId: string, createdAt: string, updatedAt: string, deletedAt?: string | null }>, viewFilterGroups: Array<{ __typename?: 'CoreViewFilterGroup', id: string, parentViewFilterGroupId?: string | null, logicalOperator: ViewFilterGroupLogicalOperator, positionInViewFilterGroup?: number | null, viewId: string }>, viewSorts: Array<{ __typename?: 'CoreViewSort', id: string, fieldMetadataId: string, direction: ViewSortDirection, viewId: string }>, viewGroups: Array<{ __typename?: 'CoreViewGroup', id: string, isVisible: boolean, fieldValue: string, position: number, viewId: string, createdAt: string, updatedAt: string, deletedAt?: string | null }> }> };
|
||||
|
||||
export type FindManyCoreViewFieldsQueryVariables = Exact<{
|
||||
viewId: Scalars['String'];
|
||||
@@ -6306,14 +6349,14 @@ export type FindManyCoreViewsQueryVariables = Exact<{
|
||||
}>;
|
||||
|
||||
|
||||
export type FindManyCoreViewsQuery = { __typename?: 'Query', getCoreViews: Array<{ __typename?: 'CoreView', id: string, name: string, objectMetadataId: string, type: ViewType, key?: ViewKey | null, icon: string, position: number, isCompact: boolean, openRecordIn: ViewOpenRecordIn, kanbanAggregateOperation?: AggregateOperations | null, kanbanAggregateOperationFieldMetadataId?: string | null, mainGroupByFieldMetadataId?: string | null, anyFieldFilterValue?: string | null, calendarFieldMetadataId?: string | null, calendarLayout?: ViewCalendarLayout | null, visibility: ViewVisibility, createdByUserWorkspaceId?: string | null, viewFields: Array<{ __typename?: 'CoreViewField', id: string, fieldMetadataId: string, viewId: string, isVisible: boolean, position: number, size: number, aggregateOperation?: AggregateOperations | null, createdAt: string, updatedAt: string, deletedAt?: string | null }>, viewFilters: Array<{ __typename?: 'CoreViewFilter', id: string, fieldMetadataId: string, operand: ViewFilterOperand, value: any, viewFilterGroupId?: string | null, positionInViewFilterGroup?: number | null, subFieldName?: string | null, viewId: string, createdAt: string, updatedAt: string, deletedAt?: string | null }>, viewFilterGroups: Array<{ __typename?: 'CoreViewFilterGroup', id: string, parentViewFilterGroupId?: string | null, logicalOperator: ViewFilterGroupLogicalOperator, positionInViewFilterGroup?: number | null, viewId: string }>, viewSorts: Array<{ __typename?: 'CoreViewSort', id: string, fieldMetadataId: string, direction: ViewSortDirection, viewId: string }>, viewGroups: Array<{ __typename?: 'CoreViewGroup', id: string, isVisible: boolean, fieldValue: string, position: number, viewId: string, createdAt: string, updatedAt: string, deletedAt?: string | null }> }> };
|
||||
export type FindManyCoreViewsQuery = { __typename?: 'Query', getCoreViews: Array<{ __typename?: 'CoreView', id: string, name: string, objectMetadataId: string, type: ViewType, key?: ViewKey | null, icon: string, position: number, isCompact: boolean, openRecordIn: ViewOpenRecordIn, kanbanAggregateOperation?: AggregateOperations | null, kanbanAggregateOperationFieldMetadataId?: string | null, mainGroupByFieldMetadataId?: string | null, shouldHideEmptyGroups: boolean, anyFieldFilterValue?: string | null, calendarFieldMetadataId?: string | null, calendarLayout?: ViewCalendarLayout | null, visibility: ViewVisibility, createdByUserWorkspaceId?: string | null, viewFields: Array<{ __typename?: 'CoreViewField', id: string, fieldMetadataId: string, viewId: string, isVisible: boolean, position: number, size: number, aggregateOperation?: AggregateOperations | null, createdAt: string, updatedAt: string, deletedAt?: string | null }>, viewFilters: Array<{ __typename?: 'CoreViewFilter', id: string, fieldMetadataId: string, operand: ViewFilterOperand, value: any, viewFilterGroupId?: string | null, positionInViewFilterGroup?: number | null, subFieldName?: string | null, viewId: string, createdAt: string, updatedAt: string, deletedAt?: string | null }>, viewFilterGroups: Array<{ __typename?: 'CoreViewFilterGroup', id: string, parentViewFilterGroupId?: string | null, logicalOperator: ViewFilterGroupLogicalOperator, positionInViewFilterGroup?: number | null, viewId: string }>, viewSorts: Array<{ __typename?: 'CoreViewSort', id: string, fieldMetadataId: string, direction: ViewSortDirection, viewId: string }>, viewGroups: Array<{ __typename?: 'CoreViewGroup', id: string, isVisible: boolean, fieldValue: string, position: number, viewId: string, createdAt: string, updatedAt: string, deletedAt?: string | null }> }> };
|
||||
|
||||
export type FindOneCoreViewQueryVariables = Exact<{
|
||||
id: Scalars['String'];
|
||||
}>;
|
||||
|
||||
|
||||
export type FindOneCoreViewQuery = { __typename?: 'Query', getCoreView?: { __typename?: 'CoreView', id: string, name: string, objectMetadataId: string, type: ViewType, key?: ViewKey | null, icon: string, position: number, isCompact: boolean, openRecordIn: ViewOpenRecordIn, kanbanAggregateOperation?: AggregateOperations | null, kanbanAggregateOperationFieldMetadataId?: string | null, mainGroupByFieldMetadataId?: string | null, anyFieldFilterValue?: string | null, calendarFieldMetadataId?: string | null, calendarLayout?: ViewCalendarLayout | null, visibility: ViewVisibility, createdByUserWorkspaceId?: string | null, viewFields: Array<{ __typename?: 'CoreViewField', id: string, fieldMetadataId: string, viewId: string, isVisible: boolean, position: number, size: number, aggregateOperation?: AggregateOperations | null, createdAt: string, updatedAt: string, deletedAt?: string | null }>, viewFilters: Array<{ __typename?: 'CoreViewFilter', id: string, fieldMetadataId: string, operand: ViewFilterOperand, value: any, viewFilterGroupId?: string | null, positionInViewFilterGroup?: number | null, subFieldName?: string | null, viewId: string, createdAt: string, updatedAt: string, deletedAt?: string | null }>, viewFilterGroups: Array<{ __typename?: 'CoreViewFilterGroup', id: string, parentViewFilterGroupId?: string | null, logicalOperator: ViewFilterGroupLogicalOperator, positionInViewFilterGroup?: number | null, viewId: string }>, viewSorts: Array<{ __typename?: 'CoreViewSort', id: string, fieldMetadataId: string, direction: ViewSortDirection, viewId: string }>, viewGroups: Array<{ __typename?: 'CoreViewGroup', id: string, isVisible: boolean, fieldValue: string, position: number, viewId: string, createdAt: string, updatedAt: string, deletedAt?: string | null }> } | null };
|
||||
export type FindOneCoreViewQuery = { __typename?: 'Query', getCoreView?: { __typename?: 'CoreView', id: string, name: string, objectMetadataId: string, type: ViewType, key?: ViewKey | null, icon: string, position: number, isCompact: boolean, openRecordIn: ViewOpenRecordIn, kanbanAggregateOperation?: AggregateOperations | null, kanbanAggregateOperationFieldMetadataId?: string | null, mainGroupByFieldMetadataId?: string | null, shouldHideEmptyGroups: boolean, anyFieldFilterValue?: string | null, calendarFieldMetadataId?: string | null, calendarLayout?: ViewCalendarLayout | null, visibility: ViewVisibility, createdByUserWorkspaceId?: string | null, viewFields: Array<{ __typename?: 'CoreViewField', id: string, fieldMetadataId: string, viewId: string, isVisible: boolean, position: number, size: number, aggregateOperation?: AggregateOperations | null, createdAt: string, updatedAt: string, deletedAt?: string | null }>, viewFilters: Array<{ __typename?: 'CoreViewFilter', id: string, fieldMetadataId: string, operand: ViewFilterOperand, value: any, viewFilterGroupId?: string | null, positionInViewFilterGroup?: number | null, subFieldName?: string | null, viewId: string, createdAt: string, updatedAt: string, deletedAt?: string | null }>, viewFilterGroups: Array<{ __typename?: 'CoreViewFilterGroup', id: string, parentViewFilterGroupId?: string | null, logicalOperator: ViewFilterGroupLogicalOperator, positionInViewFilterGroup?: number | null, viewId: string }>, viewSorts: Array<{ __typename?: 'CoreViewSort', id: string, fieldMetadataId: string, direction: ViewSortDirection, viewId: string }>, viewGroups: Array<{ __typename?: 'CoreViewGroup', id: string, isVisible: boolean, fieldValue: string, position: number, viewId: string, createdAt: string, updatedAt: string, deletedAt?: string | null }> } | null };
|
||||
|
||||
export type FindOneCoreViewFieldQueryVariables = Exact<{
|
||||
id: Scalars['String'];
|
||||
@@ -7166,6 +7209,7 @@ export const ViewFragmentFragmentDoc = gql`
|
||||
kanbanAggregateOperation
|
||||
kanbanAggregateOperationFieldMetadataId
|
||||
mainGroupByFieldMetadataId
|
||||
shouldHideEmptyGroups
|
||||
anyFieldFilterValue
|
||||
calendarFieldMetadataId
|
||||
calendarLayout
|
||||
@@ -7688,6 +7732,11 @@ export const GetChatThreadsDocument = gql`
|
||||
chatThreads {
|
||||
id
|
||||
title
|
||||
totalInputTokens
|
||||
totalOutputTokens
|
||||
contextWindowTokens
|
||||
totalInputCredits
|
||||
totalOutputCredits
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
|
||||
@@ -70,9 +70,14 @@ export type Agent = {
|
||||
|
||||
export type AgentChatThread = {
|
||||
__typename?: 'AgentChatThread';
|
||||
contextWindowTokens?: Maybe<Scalars['Int']>;
|
||||
createdAt: Scalars['DateTime'];
|
||||
id: Scalars['UUID'];
|
||||
title?: Maybe<Scalars['String']>;
|
||||
totalInputCredits: Scalars['Int'];
|
||||
totalInputTokens: Scalars['Int'];
|
||||
totalOutputCredits: Scalars['Int'];
|
||||
totalOutputTokens: Scalars['Int'];
|
||||
updatedAt: Scalars['DateTime'];
|
||||
};
|
||||
|
||||
@@ -152,6 +157,7 @@ export type AggregateChartConfiguration = {
|
||||
graphType: GraphType;
|
||||
label?: Maybe<Scalars['String']>;
|
||||
prefix?: Maybe<Scalars['String']>;
|
||||
ratioAggregateConfig?: Maybe<RatioAggregateConfig>;
|
||||
suffix?: Maybe<Scalars['String']>;
|
||||
timezone?: Maybe<Scalars['String']>;
|
||||
};
|
||||
@@ -235,6 +241,8 @@ export type Application = {
|
||||
agents: Array<Agent>;
|
||||
applicationVariables: Array<ApplicationVariable>;
|
||||
canBeUninstalled: Scalars['Boolean'];
|
||||
defaultServerlessFunctionRole?: Maybe<Role>;
|
||||
defaultServerlessFunctionRoleId?: Maybe<Scalars['String']>;
|
||||
description: Scalars['String'];
|
||||
id: Scalars['UUID'];
|
||||
name: Scalars['String'];
|
||||
@@ -570,6 +578,7 @@ export type CheckUserExistOutput = {
|
||||
|
||||
export type ClientAiModelConfig = {
|
||||
__typename?: 'ClientAIModelConfig';
|
||||
deprecated?: Maybe<Scalars['Boolean']>;
|
||||
inputCostPer1kTokensInCredits: Scalars['Float'];
|
||||
label: Scalars['String'];
|
||||
modelId: Scalars['String'];
|
||||
@@ -617,6 +626,7 @@ export enum ConfigVariablesGroup {
|
||||
BILLING_CONFIG = 'BILLING_CONFIG',
|
||||
CAPTCHA_CONFIG = 'CAPTCHA_CONFIG',
|
||||
CLOUDFLARE_CONFIG = 'CLOUDFLARE_CONFIG',
|
||||
CODE_INTERPRETER_CONFIG = 'CODE_INTERPRETER_CONFIG',
|
||||
EMAIL_SETTINGS = 'EMAIL_SETTINGS',
|
||||
EXCEPTION_HANDLER = 'EXCEPTION_HANDLER',
|
||||
GOOGLE_AUTH = 'GOOGLE_AUTH',
|
||||
@@ -694,6 +704,7 @@ export type CoreView = {
|
||||
objectMetadataId: Scalars['UUID'];
|
||||
openRecordIn: ViewOpenRecordIn;
|
||||
position: Scalars['Float'];
|
||||
shouldHideEmptyGroups: Scalars['Boolean'];
|
||||
type: ViewType;
|
||||
updatedAt: Scalars['DateTime'];
|
||||
viewFields: Array<CoreViewField>;
|
||||
@@ -968,6 +979,7 @@ export type CreateViewInput = {
|
||||
objectMetadataId: Scalars['UUID'];
|
||||
openRecordIn?: InputMaybe<ViewOpenRecordIn>;
|
||||
position?: InputMaybe<Scalars['Float']>;
|
||||
shouldHideEmptyGroups?: InputMaybe<Scalars['Boolean']>;
|
||||
type?: InputMaybe<ViewType>;
|
||||
visibility?: InputMaybe<ViewVisibility>;
|
||||
};
|
||||
@@ -1191,6 +1203,16 @@ export type DuplicateWorkflowVersionStepInput = {
|
||||
workflowVersionId: Scalars['String'];
|
||||
};
|
||||
|
||||
export type DuplicatedDashboard = {
|
||||
__typename?: 'DuplicatedDashboard';
|
||||
createdAt: Scalars['String'];
|
||||
id: Scalars['UUID'];
|
||||
pageLayoutId?: Maybe<Scalars['UUID']>;
|
||||
position: Scalars['Float'];
|
||||
title?: Maybe<Scalars['String']>;
|
||||
updatedAt: Scalars['String'];
|
||||
};
|
||||
|
||||
export type EditSsoInput = {
|
||||
id: Scalars['UUID'];
|
||||
status: SsoIdentityProviderStatus;
|
||||
@@ -1277,6 +1299,7 @@ export enum FeatureFlagKey {
|
||||
IS_PUBLIC_DOMAIN_ENABLED = 'IS_PUBLIC_DOMAIN_ENABLED',
|
||||
IS_RECORD_PAGE_LAYOUT_ENABLED = 'IS_RECORD_PAGE_LAYOUT_ENABLED',
|
||||
IS_STRIPE_INTEGRATION_ENABLED = 'IS_STRIPE_INTEGRATION_ENABLED',
|
||||
IS_TIMELINE_ACTIVITY_MIGRATED = 'IS_TIMELINE_ACTIVITY_MIGRATED',
|
||||
IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED',
|
||||
IS_WORKFLOW_RUN_STOPPAGE_ENABLED = 'IS_WORKFLOW_RUN_STOPPAGE_ENABLED'
|
||||
}
|
||||
@@ -1822,6 +1845,7 @@ export type Mutation = {
|
||||
destroyPageLayoutWidget: Scalars['Boolean'];
|
||||
disablePostgresProxy: PostgresCredentials;
|
||||
dismissReconnectAccountBanner: Scalars['Boolean'];
|
||||
duplicateDashboard: DuplicatedDashboard;
|
||||
duplicateWorkflow: WorkflowVersionDto;
|
||||
duplicateWorkflowVersionStep: WorkflowVersionStepChanges;
|
||||
editSSOIdentityProvider: EditSsoOutput;
|
||||
@@ -2339,6 +2363,11 @@ export type MutationDismissReconnectAccountBannerArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type MutationDuplicateDashboardArgs = {
|
||||
id: Scalars['UUID'];
|
||||
};
|
||||
|
||||
|
||||
export type MutationDuplicateWorkflowArgs = {
|
||||
input: DuplicateWorkflowInput;
|
||||
};
|
||||
@@ -3046,6 +3075,7 @@ export enum PermissionFlagType {
|
||||
API_KEYS_AND_WEBHOOKS = 'API_KEYS_AND_WEBHOOKS',
|
||||
APPLICATIONS = 'APPLICATIONS',
|
||||
BILLING = 'BILLING',
|
||||
CODE_INTERPRETER_TOOL = 'CODE_INTERPRETER_TOOL',
|
||||
CONNECTED_ACCOUNTS = 'CONNECTED_ACCOUNTS',
|
||||
DATA_MODEL = 'DATA_MODEL',
|
||||
DOWNLOAD_FILE = 'DOWNLOAD_FILE',
|
||||
@@ -3080,6 +3110,7 @@ export type PieChartConfiguration = {
|
||||
graphType: GraphType;
|
||||
groupByFieldMetadataId: Scalars['UUID'];
|
||||
groupBySubFieldName?: Maybe<Scalars['String']>;
|
||||
hideEmptyCategory?: Maybe<Scalars['Boolean']>;
|
||||
orderBy?: Maybe<GraphOrderBy>;
|
||||
showCenterMetric?: Maybe<Scalars['Boolean']>;
|
||||
timezone?: Maybe<Scalars['String']>;
|
||||
@@ -3546,6 +3577,17 @@ export type QueueRetentionConfig = {
|
||||
failedMaxCount: Scalars['Float'];
|
||||
};
|
||||
|
||||
export type RatioAggregateConfig = {
|
||||
__typename?: 'RatioAggregateConfig';
|
||||
fieldMetadataId: Scalars['UUID'];
|
||||
optionValue: Scalars['String'];
|
||||
};
|
||||
|
||||
export type RatioAggregateConfigInput = {
|
||||
fieldMetadataId: Scalars['UUID'];
|
||||
optionValue: Scalars['String'];
|
||||
};
|
||||
|
||||
export type Relation = {
|
||||
__typename?: 'Relation';
|
||||
sourceFieldMetadata: Field;
|
||||
@@ -4295,6 +4337,7 @@ export type UpdateViewInput = {
|
||||
name?: InputMaybe<Scalars['String']>;
|
||||
openRecordIn?: InputMaybe<ViewOpenRecordIn>;
|
||||
position?: InputMaybe<Scalars['Float']>;
|
||||
shouldHideEmptyGroups?: InputMaybe<Scalars['Boolean']>;
|
||||
type?: InputMaybe<ViewType>;
|
||||
visibility?: InputMaybe<ViewVisibility>;
|
||||
};
|
||||
@@ -4825,7 +4868,7 @@ export type SearchQueryVariables = Exact<{
|
||||
|
||||
export type SearchQuery = { __typename?: 'Query', search: { __typename?: 'SearchResultConnection', edges: Array<{ __typename?: 'SearchResultEdge', cursor: string, node: { __typename?: 'SearchRecord', recordId: any, objectNameSingular: string, label: string, imageUrl?: string | null, tsRankCD: number, tsRank: number } }>, pageInfo: { __typename?: 'SearchResultPageInfo', hasNextPage: boolean, endCursor?: string | null } } };
|
||||
|
||||
export type PageLayoutWidgetFragmentFragment = { __typename?: 'PageLayoutWidget', id: any, title: string, type: WidgetType, objectMetadataId?: any | null, createdAt: string, updatedAt: string, deletedAt?: string | null, pageLayoutTabId: any, gridPosition: { __typename?: 'GridPosition', column: number, columnSpan: number, row: number, rowSpan: number }, configuration?: { __typename?: 'AggregateChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, label?: string | null, displayDataLabel?: boolean | null, format?: string | null, description?: string | null, filter?: any | null, prefix?: string | null, suffix?: string | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'BarChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, primaryAxisGroupByFieldMetadataId: any, primaryAxisGroupBySubFieldName?: string | null, primaryAxisDateGranularity?: ObjectRecordGroupByDateGranularity | null, primaryAxisOrderBy?: GraphOrderBy | null, secondaryAxisGroupByFieldMetadataId?: any | null, secondaryAxisGroupBySubFieldName?: string | null, secondaryAxisGroupByDateGranularity?: ObjectRecordGroupByDateGranularity | null, secondaryAxisOrderBy?: GraphOrderBy | null, omitNullValues?: boolean | null, axisNameDisplay?: AxisNameDisplay | null, displayDataLabel?: boolean | null, displayLegend?: boolean | null, rangeMin?: number | null, rangeMax?: number | null, color?: string | null, description?: string | null, filter?: any | null, groupMode?: BarChartGroupMode | null, isCumulative?: boolean | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'GaugeChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, displayDataLabel?: boolean | null, color?: string | null, description?: string | null, filter?: any | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'IframeConfiguration', url?: string | null } | { __typename?: 'LineChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, primaryAxisGroupByFieldMetadataId: any, primaryAxisGroupBySubFieldName?: string | null, primaryAxisDateGranularity?: ObjectRecordGroupByDateGranularity | null, primaryAxisOrderBy?: GraphOrderBy | null, secondaryAxisGroupByFieldMetadataId?: any | null, secondaryAxisGroupBySubFieldName?: string | null, secondaryAxisGroupByDateGranularity?: ObjectRecordGroupByDateGranularity | null, secondaryAxisOrderBy?: GraphOrderBy | null, omitNullValues?: boolean | null, axisNameDisplay?: AxisNameDisplay | null, displayDataLabel?: boolean | null, displayLegend?: boolean | null, rangeMin?: number | null, rangeMax?: number | null, color?: string | null, description?: string | null, filter?: any | null, isStacked?: boolean | null, isCumulative?: boolean | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'PieChartConfiguration', graphType: GraphType, groupByFieldMetadataId: any, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, groupBySubFieldName?: string | null, dateGranularity?: ObjectRecordGroupByDateGranularity | null, orderBy?: GraphOrderBy | null, displayDataLabel?: boolean | null, showCenterMetric?: boolean | null, displayLegend?: boolean | null, color?: string | null, description?: string | null, filter?: any | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'StandaloneRichTextConfiguration', body: { __typename?: 'RichTextV2Body', blocknote?: string | null, markdown?: string | null } } | null };
|
||||
export type PageLayoutWidgetFragmentFragment = { __typename?: 'PageLayoutWidget', id: any, title: string, type: WidgetType, objectMetadataId?: any | null, createdAt: string, updatedAt: string, deletedAt?: string | null, pageLayoutTabId: any, gridPosition: { __typename?: 'GridPosition', column: number, columnSpan: number, row: number, rowSpan: number }, configuration?: { __typename?: 'AggregateChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, label?: string | null, displayDataLabel?: boolean | null, format?: string | null, description?: string | null, filter?: any | null, prefix?: string | null, suffix?: string | null, timezone?: string | null, firstDayOfTheWeek?: number | null, ratioAggregateConfig?: { __typename?: 'RatioAggregateConfig', fieldMetadataId: any, optionValue: string } | null } | { __typename?: 'BarChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, primaryAxisGroupByFieldMetadataId: any, primaryAxisGroupBySubFieldName?: string | null, primaryAxisDateGranularity?: ObjectRecordGroupByDateGranularity | null, primaryAxisOrderBy?: GraphOrderBy | null, secondaryAxisGroupByFieldMetadataId?: any | null, secondaryAxisGroupBySubFieldName?: string | null, secondaryAxisGroupByDateGranularity?: ObjectRecordGroupByDateGranularity | null, secondaryAxisOrderBy?: GraphOrderBy | null, omitNullValues?: boolean | null, axisNameDisplay?: AxisNameDisplay | null, displayDataLabel?: boolean | null, displayLegend?: boolean | null, rangeMin?: number | null, rangeMax?: number | null, color?: string | null, description?: string | null, filter?: any | null, groupMode?: BarChartGroupMode | null, isCumulative?: boolean | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'GaugeChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, displayDataLabel?: boolean | null, color?: string | null, description?: string | null, filter?: any | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'IframeConfiguration', url?: string | null } | { __typename?: 'LineChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, primaryAxisGroupByFieldMetadataId: any, primaryAxisGroupBySubFieldName?: string | null, primaryAxisDateGranularity?: ObjectRecordGroupByDateGranularity | null, primaryAxisOrderBy?: GraphOrderBy | null, secondaryAxisGroupByFieldMetadataId?: any | null, secondaryAxisGroupBySubFieldName?: string | null, secondaryAxisGroupByDateGranularity?: ObjectRecordGroupByDateGranularity | null, secondaryAxisOrderBy?: GraphOrderBy | null, omitNullValues?: boolean | null, axisNameDisplay?: AxisNameDisplay | null, displayDataLabel?: boolean | null, displayLegend?: boolean | null, rangeMin?: number | null, rangeMax?: number | null, color?: string | null, description?: string | null, filter?: any | null, isStacked?: boolean | null, isCumulative?: boolean | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'PieChartConfiguration', graphType: GraphType, groupByFieldMetadataId: any, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, groupBySubFieldName?: string | null, dateGranularity?: ObjectRecordGroupByDateGranularity | null, orderBy?: GraphOrderBy | null, displayDataLabel?: boolean | null, showCenterMetric?: boolean | null, displayLegend?: boolean | null, color?: string | null, description?: string | null, filter?: any | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'StandaloneRichTextConfiguration', body: { __typename?: 'RichTextV2Body', blocknote?: string | null, markdown?: string | null } } | null };
|
||||
|
||||
export type UpdatePageLayoutWithTabsAndWidgetsMutationVariables = Exact<{
|
||||
id: Scalars['String'];
|
||||
@@ -4833,7 +4876,7 @@ export type UpdatePageLayoutWithTabsAndWidgetsMutationVariables = Exact<{
|
||||
}>;
|
||||
|
||||
|
||||
export type UpdatePageLayoutWithTabsAndWidgetsMutation = { __typename?: 'Mutation', updatePageLayoutWithTabsAndWidgets: { __typename?: 'PageLayout', id: any, name: string, type: PageLayoutType, objectMetadataId?: any | null, createdAt: string, updatedAt: string, deletedAt?: string | null, tabs?: Array<{ __typename?: 'PageLayoutTab', id: any, title: string, position: number, pageLayoutId: any, createdAt: string, updatedAt: string, widgets?: Array<{ __typename?: 'PageLayoutWidget', id: any, title: string, type: WidgetType, objectMetadataId?: any | null, createdAt: string, updatedAt: string, deletedAt?: string | null, pageLayoutTabId: any, gridPosition: { __typename?: 'GridPosition', column: number, columnSpan: number, row: number, rowSpan: number }, configuration?: { __typename?: 'AggregateChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, label?: string | null, displayDataLabel?: boolean | null, format?: string | null, description?: string | null, filter?: any | null, prefix?: string | null, suffix?: string | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'BarChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, primaryAxisGroupByFieldMetadataId: any, primaryAxisGroupBySubFieldName?: string | null, primaryAxisDateGranularity?: ObjectRecordGroupByDateGranularity | null, primaryAxisOrderBy?: GraphOrderBy | null, secondaryAxisGroupByFieldMetadataId?: any | null, secondaryAxisGroupBySubFieldName?: string | null, secondaryAxisGroupByDateGranularity?: ObjectRecordGroupByDateGranularity | null, secondaryAxisOrderBy?: GraphOrderBy | null, omitNullValues?: boolean | null, axisNameDisplay?: AxisNameDisplay | null, displayDataLabel?: boolean | null, displayLegend?: boolean | null, rangeMin?: number | null, rangeMax?: number | null, color?: string | null, description?: string | null, filter?: any | null, groupMode?: BarChartGroupMode | null, isCumulative?: boolean | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'GaugeChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, displayDataLabel?: boolean | null, color?: string | null, description?: string | null, filter?: any | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'IframeConfiguration', url?: string | null } | { __typename?: 'LineChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, primaryAxisGroupByFieldMetadataId: any, primaryAxisGroupBySubFieldName?: string | null, primaryAxisDateGranularity?: ObjectRecordGroupByDateGranularity | null, primaryAxisOrderBy?: GraphOrderBy | null, secondaryAxisGroupByFieldMetadataId?: any | null, secondaryAxisGroupBySubFieldName?: string | null, secondaryAxisGroupByDateGranularity?: ObjectRecordGroupByDateGranularity | null, secondaryAxisOrderBy?: GraphOrderBy | null, omitNullValues?: boolean | null, axisNameDisplay?: AxisNameDisplay | null, displayDataLabel?: boolean | null, displayLegend?: boolean | null, rangeMin?: number | null, rangeMax?: number | null, color?: string | null, description?: string | null, filter?: any | null, isStacked?: boolean | null, isCumulative?: boolean | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'PieChartConfiguration', graphType: GraphType, groupByFieldMetadataId: any, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, groupBySubFieldName?: string | null, dateGranularity?: ObjectRecordGroupByDateGranularity | null, orderBy?: GraphOrderBy | null, displayDataLabel?: boolean | null, showCenterMetric?: boolean | null, displayLegend?: boolean | null, color?: string | null, description?: string | null, filter?: any | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'StandaloneRichTextConfiguration', body: { __typename?: 'RichTextV2Body', blocknote?: string | null, markdown?: string | null } } | null }> | null }> | null } };
|
||||
export type UpdatePageLayoutWithTabsAndWidgetsMutation = { __typename?: 'Mutation', updatePageLayoutWithTabsAndWidgets: { __typename?: 'PageLayout', id: any, name: string, type: PageLayoutType, objectMetadataId?: any | null, createdAt: string, updatedAt: string, deletedAt?: string | null, tabs?: Array<{ __typename?: 'PageLayoutTab', id: any, title: string, position: number, pageLayoutId: any, createdAt: string, updatedAt: string, widgets?: Array<{ __typename?: 'PageLayoutWidget', id: any, title: string, type: WidgetType, objectMetadataId?: any | null, createdAt: string, updatedAt: string, deletedAt?: string | null, pageLayoutTabId: any, gridPosition: { __typename?: 'GridPosition', column: number, columnSpan: number, row: number, rowSpan: number }, configuration?: { __typename?: 'AggregateChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, label?: string | null, displayDataLabel?: boolean | null, format?: string | null, description?: string | null, filter?: any | null, prefix?: string | null, suffix?: string | null, timezone?: string | null, firstDayOfTheWeek?: number | null, ratioAggregateConfig?: { __typename?: 'RatioAggregateConfig', fieldMetadataId: any, optionValue: string } | null } | { __typename?: 'BarChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, primaryAxisGroupByFieldMetadataId: any, primaryAxisGroupBySubFieldName?: string | null, primaryAxisDateGranularity?: ObjectRecordGroupByDateGranularity | null, primaryAxisOrderBy?: GraphOrderBy | null, secondaryAxisGroupByFieldMetadataId?: any | null, secondaryAxisGroupBySubFieldName?: string | null, secondaryAxisGroupByDateGranularity?: ObjectRecordGroupByDateGranularity | null, secondaryAxisOrderBy?: GraphOrderBy | null, omitNullValues?: boolean | null, axisNameDisplay?: AxisNameDisplay | null, displayDataLabel?: boolean | null, displayLegend?: boolean | null, rangeMin?: number | null, rangeMax?: number | null, color?: string | null, description?: string | null, filter?: any | null, groupMode?: BarChartGroupMode | null, isCumulative?: boolean | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'GaugeChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, displayDataLabel?: boolean | null, color?: string | null, description?: string | null, filter?: any | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'IframeConfiguration', url?: string | null } | { __typename?: 'LineChartConfiguration', graphType: GraphType, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, primaryAxisGroupByFieldMetadataId: any, primaryAxisGroupBySubFieldName?: string | null, primaryAxisDateGranularity?: ObjectRecordGroupByDateGranularity | null, primaryAxisOrderBy?: GraphOrderBy | null, secondaryAxisGroupByFieldMetadataId?: any | null, secondaryAxisGroupBySubFieldName?: string | null, secondaryAxisGroupByDateGranularity?: ObjectRecordGroupByDateGranularity | null, secondaryAxisOrderBy?: GraphOrderBy | null, omitNullValues?: boolean | null, axisNameDisplay?: AxisNameDisplay | null, displayDataLabel?: boolean | null, displayLegend?: boolean | null, rangeMin?: number | null, rangeMax?: number | null, color?: string | null, description?: string | null, filter?: any | null, isStacked?: boolean | null, isCumulative?: boolean | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'PieChartConfiguration', graphType: GraphType, groupByFieldMetadataId: any, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, groupBySubFieldName?: string | null, dateGranularity?: ObjectRecordGroupByDateGranularity | null, orderBy?: GraphOrderBy | null, displayDataLabel?: boolean | null, showCenterMetric?: boolean | null, displayLegend?: boolean | null, color?: string | null, description?: string | null, filter?: any | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'StandaloneRichTextConfiguration', body: { __typename?: 'RichTextV2Body', blocknote?: string | null, markdown?: string | null } } | null }> | null }> | null } };
|
||||
|
||||
export type OnDbEventSubscriptionVariables = Exact<{
|
||||
input: OnDbEventInput;
|
||||
@@ -4848,7 +4891,7 @@ export type ViewFilterFragmentFragment = { __typename?: 'CoreViewFilter', id: an
|
||||
|
||||
export type ViewFilterGroupFragmentFragment = { __typename?: 'CoreViewFilterGroup', id: any, parentViewFilterGroupId?: any | null, logicalOperator: ViewFilterGroupLogicalOperator, positionInViewFilterGroup?: number | null, viewId: any };
|
||||
|
||||
export type ViewFragmentFragment = { __typename?: 'CoreView', id: any, name: string, objectMetadataId: any, type: ViewType, key?: ViewKey | null, icon: string, position: number, isCompact: boolean, openRecordIn: ViewOpenRecordIn, kanbanAggregateOperation?: AggregateOperations | null, kanbanAggregateOperationFieldMetadataId?: any | null, mainGroupByFieldMetadataId?: any | null, anyFieldFilterValue?: string | null, calendarFieldMetadataId?: any | null, calendarLayout?: ViewCalendarLayout | null, visibility: ViewVisibility, createdByUserWorkspaceId?: any | null, viewFields: Array<{ __typename?: 'CoreViewField', id: any, fieldMetadataId: any, viewId: any, isVisible: boolean, position: number, size: number, aggregateOperation?: AggregateOperations | null, createdAt: string, updatedAt: string, deletedAt?: string | null }>, viewFilters: Array<{ __typename?: 'CoreViewFilter', id: any, fieldMetadataId: any, operand: ViewFilterOperand, value: any, viewFilterGroupId?: any | null, positionInViewFilterGroup?: number | null, subFieldName?: string | null, viewId: any, createdAt: string, updatedAt: string, deletedAt?: string | null }>, viewFilterGroups: Array<{ __typename?: 'CoreViewFilterGroup', id: any, parentViewFilterGroupId?: any | null, logicalOperator: ViewFilterGroupLogicalOperator, positionInViewFilterGroup?: number | null, viewId: any }>, viewSorts: Array<{ __typename?: 'CoreViewSort', id: any, fieldMetadataId: any, direction: ViewSortDirection, viewId: any }>, viewGroups: Array<{ __typename?: 'CoreViewGroup', id: any, isVisible: boolean, fieldValue: string, position: number, viewId: any, createdAt: string, updatedAt: string, deletedAt?: string | null }> };
|
||||
export type ViewFragmentFragment = { __typename?: 'CoreView', id: any, name: string, objectMetadataId: any, type: ViewType, key?: ViewKey | null, icon: string, position: number, isCompact: boolean, openRecordIn: ViewOpenRecordIn, kanbanAggregateOperation?: AggregateOperations | null, kanbanAggregateOperationFieldMetadataId?: any | null, mainGroupByFieldMetadataId?: any | null, shouldHideEmptyGroups: boolean, anyFieldFilterValue?: string | null, calendarFieldMetadataId?: any | null, calendarLayout?: ViewCalendarLayout | null, visibility: ViewVisibility, createdByUserWorkspaceId?: any | null, viewFields: Array<{ __typename?: 'CoreViewField', id: any, fieldMetadataId: any, viewId: any, isVisible: boolean, position: number, size: number, aggregateOperation?: AggregateOperations | null, createdAt: string, updatedAt: string, deletedAt?: string | null }>, viewFilters: Array<{ __typename?: 'CoreViewFilter', id: any, fieldMetadataId: any, operand: ViewFilterOperand, value: any, viewFilterGroupId?: any | null, positionInViewFilterGroup?: number | null, subFieldName?: string | null, viewId: any, createdAt: string, updatedAt: string, deletedAt?: string | null }>, viewFilterGroups: Array<{ __typename?: 'CoreViewFilterGroup', id: any, parentViewFilterGroupId?: any | null, logicalOperator: ViewFilterGroupLogicalOperator, positionInViewFilterGroup?: number | null, viewId: any }>, viewSorts: Array<{ __typename?: 'CoreViewSort', id: any, fieldMetadataId: any, direction: ViewSortDirection, viewId: any }>, viewGroups: Array<{ __typename?: 'CoreViewGroup', id: any, isVisible: boolean, fieldValue: string, position: number, viewId: any, createdAt: string, updatedAt: string, deletedAt?: string | null }> };
|
||||
|
||||
export type ViewGroupFragmentFragment = { __typename?: 'CoreViewGroup', id: any, isVisible: boolean, fieldValue: string, position: number, viewId: any, createdAt: string, updatedAt: string, deletedAt?: string | null };
|
||||
|
||||
@@ -4859,7 +4902,7 @@ export type CreateCoreViewMutationVariables = Exact<{
|
||||
}>;
|
||||
|
||||
|
||||
export type CreateCoreViewMutation = { __typename?: 'Mutation', createCoreView: { __typename?: 'CoreView', id: any, name: string, objectMetadataId: any, type: ViewType, key?: ViewKey | null, icon: string, position: number, isCompact: boolean, openRecordIn: ViewOpenRecordIn, kanbanAggregateOperation?: AggregateOperations | null, kanbanAggregateOperationFieldMetadataId?: any | null, mainGroupByFieldMetadataId?: any | null, anyFieldFilterValue?: string | null, calendarFieldMetadataId?: any | null, calendarLayout?: ViewCalendarLayout | null, visibility: ViewVisibility, createdByUserWorkspaceId?: any | null, viewFields: Array<{ __typename?: 'CoreViewField', id: any, fieldMetadataId: any, viewId: any, isVisible: boolean, position: number, size: number, aggregateOperation?: AggregateOperations | null, createdAt: string, updatedAt: string, deletedAt?: string | null }>, viewFilters: Array<{ __typename?: 'CoreViewFilter', id: any, fieldMetadataId: any, operand: ViewFilterOperand, value: any, viewFilterGroupId?: any | null, positionInViewFilterGroup?: number | null, subFieldName?: string | null, viewId: any, createdAt: string, updatedAt: string, deletedAt?: string | null }>, viewFilterGroups: Array<{ __typename?: 'CoreViewFilterGroup', id: any, parentViewFilterGroupId?: any | null, logicalOperator: ViewFilterGroupLogicalOperator, positionInViewFilterGroup?: number | null, viewId: any }>, viewSorts: Array<{ __typename?: 'CoreViewSort', id: any, fieldMetadataId: any, direction: ViewSortDirection, viewId: any }>, viewGroups: Array<{ __typename?: 'CoreViewGroup', id: any, isVisible: boolean, fieldValue: string, position: number, viewId: any, createdAt: string, updatedAt: string, deletedAt?: string | null }> } };
|
||||
export type CreateCoreViewMutation = { __typename?: 'Mutation', createCoreView: { __typename?: 'CoreView', id: any, name: string, objectMetadataId: any, type: ViewType, key?: ViewKey | null, icon: string, position: number, isCompact: boolean, openRecordIn: ViewOpenRecordIn, kanbanAggregateOperation?: AggregateOperations | null, kanbanAggregateOperationFieldMetadataId?: any | null, mainGroupByFieldMetadataId?: any | null, shouldHideEmptyGroups: boolean, anyFieldFilterValue?: string | null, calendarFieldMetadataId?: any | null, calendarLayout?: ViewCalendarLayout | null, visibility: ViewVisibility, createdByUserWorkspaceId?: any | null, viewFields: Array<{ __typename?: 'CoreViewField', id: any, fieldMetadataId: any, viewId: any, isVisible: boolean, position: number, size: number, aggregateOperation?: AggregateOperations | null, createdAt: string, updatedAt: string, deletedAt?: string | null }>, viewFilters: Array<{ __typename?: 'CoreViewFilter', id: any, fieldMetadataId: any, operand: ViewFilterOperand, value: any, viewFilterGroupId?: any | null, positionInViewFilterGroup?: number | null, subFieldName?: string | null, viewId: any, createdAt: string, updatedAt: string, deletedAt?: string | null }>, viewFilterGroups: Array<{ __typename?: 'CoreViewFilterGroup', id: any, parentViewFilterGroupId?: any | null, logicalOperator: ViewFilterGroupLogicalOperator, positionInViewFilterGroup?: number | null, viewId: any }>, viewSorts: Array<{ __typename?: 'CoreViewSort', id: any, fieldMetadataId: any, direction: ViewSortDirection, viewId: any }>, viewGroups: Array<{ __typename?: 'CoreViewGroup', id: any, isVisible: boolean, fieldValue: string, position: number, viewId: any, createdAt: string, updatedAt: string, deletedAt?: string | null }> } };
|
||||
|
||||
export type CreateCoreViewFieldMutationVariables = Exact<{
|
||||
input: CreateViewFieldInput;
|
||||
@@ -5000,7 +5043,7 @@ export type UpdateCoreViewMutationVariables = Exact<{
|
||||
}>;
|
||||
|
||||
|
||||
export type UpdateCoreViewMutation = { __typename?: 'Mutation', updateCoreView: { __typename?: 'CoreView', id: any, name: string, objectMetadataId: any, type: ViewType, key?: ViewKey | null, icon: string, position: number, isCompact: boolean, openRecordIn: ViewOpenRecordIn, kanbanAggregateOperation?: AggregateOperations | null, kanbanAggregateOperationFieldMetadataId?: any | null, mainGroupByFieldMetadataId?: any | null, anyFieldFilterValue?: string | null, calendarFieldMetadataId?: any | null, calendarLayout?: ViewCalendarLayout | null, visibility: ViewVisibility, createdByUserWorkspaceId?: any | null, viewFields: Array<{ __typename?: 'CoreViewField', id: any, fieldMetadataId: any, viewId: any, isVisible: boolean, position: number, size: number, aggregateOperation?: AggregateOperations | null, createdAt: string, updatedAt: string, deletedAt?: string | null }>, viewFilters: Array<{ __typename?: 'CoreViewFilter', id: any, fieldMetadataId: any, operand: ViewFilterOperand, value: any, viewFilterGroupId?: any | null, positionInViewFilterGroup?: number | null, subFieldName?: string | null, viewId: any, createdAt: string, updatedAt: string, deletedAt?: string | null }>, viewFilterGroups: Array<{ __typename?: 'CoreViewFilterGroup', id: any, parentViewFilterGroupId?: any | null, logicalOperator: ViewFilterGroupLogicalOperator, positionInViewFilterGroup?: number | null, viewId: any }>, viewSorts: Array<{ __typename?: 'CoreViewSort', id: any, fieldMetadataId: any, direction: ViewSortDirection, viewId: any }>, viewGroups: Array<{ __typename?: 'CoreViewGroup', id: any, isVisible: boolean, fieldValue: string, position: number, viewId: any, createdAt: string, updatedAt: string, deletedAt?: string | null }> } };
|
||||
export type UpdateCoreViewMutation = { __typename?: 'Mutation', updateCoreView: { __typename?: 'CoreView', id: any, name: string, objectMetadataId: any, type: ViewType, key?: ViewKey | null, icon: string, position: number, isCompact: boolean, openRecordIn: ViewOpenRecordIn, kanbanAggregateOperation?: AggregateOperations | null, kanbanAggregateOperationFieldMetadataId?: any | null, mainGroupByFieldMetadataId?: any | null, shouldHideEmptyGroups: boolean, anyFieldFilterValue?: string | null, calendarFieldMetadataId?: any | null, calendarLayout?: ViewCalendarLayout | null, visibility: ViewVisibility, createdByUserWorkspaceId?: any | null, viewFields: Array<{ __typename?: 'CoreViewField', id: any, fieldMetadataId: any, viewId: any, isVisible: boolean, position: number, size: number, aggregateOperation?: AggregateOperations | null, createdAt: string, updatedAt: string, deletedAt?: string | null }>, viewFilters: Array<{ __typename?: 'CoreViewFilter', id: any, fieldMetadataId: any, operand: ViewFilterOperand, value: any, viewFilterGroupId?: any | null, positionInViewFilterGroup?: number | null, subFieldName?: string | null, viewId: any, createdAt: string, updatedAt: string, deletedAt?: string | null }>, viewFilterGroups: Array<{ __typename?: 'CoreViewFilterGroup', id: any, parentViewFilterGroupId?: any | null, logicalOperator: ViewFilterGroupLogicalOperator, positionInViewFilterGroup?: number | null, viewId: any }>, viewSorts: Array<{ __typename?: 'CoreViewSort', id: any, fieldMetadataId: any, direction: ViewSortDirection, viewId: any }>, viewGroups: Array<{ __typename?: 'CoreViewGroup', id: any, isVisible: boolean, fieldValue: string, position: number, viewId: any, createdAt: string, updatedAt: string, deletedAt?: string | null }> } };
|
||||
|
||||
export type UpdateCoreViewFieldMutationVariables = Exact<{
|
||||
input: UpdateViewFieldInput;
|
||||
@@ -5042,7 +5085,7 @@ export type UpdateCoreViewSortMutation = { __typename?: 'Mutation', updateCoreVi
|
||||
export type FindAllCoreViewsQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
export type FindAllCoreViewsQuery = { __typename?: 'Query', getCoreViews: Array<{ __typename?: 'CoreView', id: any, name: string, objectMetadataId: any, type: ViewType, key?: ViewKey | null, icon: string, position: number, isCompact: boolean, openRecordIn: ViewOpenRecordIn, kanbanAggregateOperation?: AggregateOperations | null, kanbanAggregateOperationFieldMetadataId?: any | null, mainGroupByFieldMetadataId?: any | null, anyFieldFilterValue?: string | null, calendarFieldMetadataId?: any | null, calendarLayout?: ViewCalendarLayout | null, visibility: ViewVisibility, createdByUserWorkspaceId?: any | null, viewFields: Array<{ __typename?: 'CoreViewField', id: any, fieldMetadataId: any, viewId: any, isVisible: boolean, position: number, size: number, aggregateOperation?: AggregateOperations | null, createdAt: string, updatedAt: string, deletedAt?: string | null }>, viewFilters: Array<{ __typename?: 'CoreViewFilter', id: any, fieldMetadataId: any, operand: ViewFilterOperand, value: any, viewFilterGroupId?: any | null, positionInViewFilterGroup?: number | null, subFieldName?: string | null, viewId: any, createdAt: string, updatedAt: string, deletedAt?: string | null }>, viewFilterGroups: Array<{ __typename?: 'CoreViewFilterGroup', id: any, parentViewFilterGroupId?: any | null, logicalOperator: ViewFilterGroupLogicalOperator, positionInViewFilterGroup?: number | null, viewId: any }>, viewSorts: Array<{ __typename?: 'CoreViewSort', id: any, fieldMetadataId: any, direction: ViewSortDirection, viewId: any }>, viewGroups: Array<{ __typename?: 'CoreViewGroup', id: any, isVisible: boolean, fieldValue: string, position: number, viewId: any, createdAt: string, updatedAt: string, deletedAt?: string | null }> }> };
|
||||
export type FindAllCoreViewsQuery = { __typename?: 'Query', getCoreViews: Array<{ __typename?: 'CoreView', id: any, name: string, objectMetadataId: any, type: ViewType, key?: ViewKey | null, icon: string, position: number, isCompact: boolean, openRecordIn: ViewOpenRecordIn, kanbanAggregateOperation?: AggregateOperations | null, kanbanAggregateOperationFieldMetadataId?: any | null, mainGroupByFieldMetadataId?: any | null, shouldHideEmptyGroups: boolean, anyFieldFilterValue?: string | null, calendarFieldMetadataId?: any | null, calendarLayout?: ViewCalendarLayout | null, visibility: ViewVisibility, createdByUserWorkspaceId?: any | null, viewFields: Array<{ __typename?: 'CoreViewField', id: any, fieldMetadataId: any, viewId: any, isVisible: boolean, position: number, size: number, aggregateOperation?: AggregateOperations | null, createdAt: string, updatedAt: string, deletedAt?: string | null }>, viewFilters: Array<{ __typename?: 'CoreViewFilter', id: any, fieldMetadataId: any, operand: ViewFilterOperand, value: any, viewFilterGroupId?: any | null, positionInViewFilterGroup?: number | null, subFieldName?: string | null, viewId: any, createdAt: string, updatedAt: string, deletedAt?: string | null }>, viewFilterGroups: Array<{ __typename?: 'CoreViewFilterGroup', id: any, parentViewFilterGroupId?: any | null, logicalOperator: ViewFilterGroupLogicalOperator, positionInViewFilterGroup?: number | null, viewId: any }>, viewSorts: Array<{ __typename?: 'CoreViewSort', id: any, fieldMetadataId: any, direction: ViewSortDirection, viewId: any }>, viewGroups: Array<{ __typename?: 'CoreViewGroup', id: any, isVisible: boolean, fieldValue: string, position: number, viewId: any, createdAt: string, updatedAt: string, deletedAt?: string | null }> }> };
|
||||
|
||||
export type FindManyCoreViewFieldsQueryVariables = Exact<{
|
||||
viewId: Scalars['String'];
|
||||
@@ -5084,14 +5127,14 @@ export type FindManyCoreViewsQueryVariables = Exact<{
|
||||
}>;
|
||||
|
||||
|
||||
export type FindManyCoreViewsQuery = { __typename?: 'Query', getCoreViews: Array<{ __typename?: 'CoreView', id: any, name: string, objectMetadataId: any, type: ViewType, key?: ViewKey | null, icon: string, position: number, isCompact: boolean, openRecordIn: ViewOpenRecordIn, kanbanAggregateOperation?: AggregateOperations | null, kanbanAggregateOperationFieldMetadataId?: any | null, mainGroupByFieldMetadataId?: any | null, anyFieldFilterValue?: string | null, calendarFieldMetadataId?: any | null, calendarLayout?: ViewCalendarLayout | null, visibility: ViewVisibility, createdByUserWorkspaceId?: any | null, viewFields: Array<{ __typename?: 'CoreViewField', id: any, fieldMetadataId: any, viewId: any, isVisible: boolean, position: number, size: number, aggregateOperation?: AggregateOperations | null, createdAt: string, updatedAt: string, deletedAt?: string | null }>, viewFilters: Array<{ __typename?: 'CoreViewFilter', id: any, fieldMetadataId: any, operand: ViewFilterOperand, value: any, viewFilterGroupId?: any | null, positionInViewFilterGroup?: number | null, subFieldName?: string | null, viewId: any, createdAt: string, updatedAt: string, deletedAt?: string | null }>, viewFilterGroups: Array<{ __typename?: 'CoreViewFilterGroup', id: any, parentViewFilterGroupId?: any | null, logicalOperator: ViewFilterGroupLogicalOperator, positionInViewFilterGroup?: number | null, viewId: any }>, viewSorts: Array<{ __typename?: 'CoreViewSort', id: any, fieldMetadataId: any, direction: ViewSortDirection, viewId: any }>, viewGroups: Array<{ __typename?: 'CoreViewGroup', id: any, isVisible: boolean, fieldValue: string, position: number, viewId: any, createdAt: string, updatedAt: string, deletedAt?: string | null }> }> };
|
||||
export type FindManyCoreViewsQuery = { __typename?: 'Query', getCoreViews: Array<{ __typename?: 'CoreView', id: any, name: string, objectMetadataId: any, type: ViewType, key?: ViewKey | null, icon: string, position: number, isCompact: boolean, openRecordIn: ViewOpenRecordIn, kanbanAggregateOperation?: AggregateOperations | null, kanbanAggregateOperationFieldMetadataId?: any | null, mainGroupByFieldMetadataId?: any | null, shouldHideEmptyGroups: boolean, anyFieldFilterValue?: string | null, calendarFieldMetadataId?: any | null, calendarLayout?: ViewCalendarLayout | null, visibility: ViewVisibility, createdByUserWorkspaceId?: any | null, viewFields: Array<{ __typename?: 'CoreViewField', id: any, fieldMetadataId: any, viewId: any, isVisible: boolean, position: number, size: number, aggregateOperation?: AggregateOperations | null, createdAt: string, updatedAt: string, deletedAt?: string | null }>, viewFilters: Array<{ __typename?: 'CoreViewFilter', id: any, fieldMetadataId: any, operand: ViewFilterOperand, value: any, viewFilterGroupId?: any | null, positionInViewFilterGroup?: number | null, subFieldName?: string | null, viewId: any, createdAt: string, updatedAt: string, deletedAt?: string | null }>, viewFilterGroups: Array<{ __typename?: 'CoreViewFilterGroup', id: any, parentViewFilterGroupId?: any | null, logicalOperator: ViewFilterGroupLogicalOperator, positionInViewFilterGroup?: number | null, viewId: any }>, viewSorts: Array<{ __typename?: 'CoreViewSort', id: any, fieldMetadataId: any, direction: ViewSortDirection, viewId: any }>, viewGroups: Array<{ __typename?: 'CoreViewGroup', id: any, isVisible: boolean, fieldValue: string, position: number, viewId: any, createdAt: string, updatedAt: string, deletedAt?: string | null }> }> };
|
||||
|
||||
export type FindOneCoreViewQueryVariables = Exact<{
|
||||
id: Scalars['String'];
|
||||
}>;
|
||||
|
||||
|
||||
export type FindOneCoreViewQuery = { __typename?: 'Query', getCoreView?: { __typename?: 'CoreView', id: any, name: string, objectMetadataId: any, type: ViewType, key?: ViewKey | null, icon: string, position: number, isCompact: boolean, openRecordIn: ViewOpenRecordIn, kanbanAggregateOperation?: AggregateOperations | null, kanbanAggregateOperationFieldMetadataId?: any | null, mainGroupByFieldMetadataId?: any | null, anyFieldFilterValue?: string | null, calendarFieldMetadataId?: any | null, calendarLayout?: ViewCalendarLayout | null, visibility: ViewVisibility, createdByUserWorkspaceId?: any | null, viewFields: Array<{ __typename?: 'CoreViewField', id: any, fieldMetadataId: any, viewId: any, isVisible: boolean, position: number, size: number, aggregateOperation?: AggregateOperations | null, createdAt: string, updatedAt: string, deletedAt?: string | null }>, viewFilters: Array<{ __typename?: 'CoreViewFilter', id: any, fieldMetadataId: any, operand: ViewFilterOperand, value: any, viewFilterGroupId?: any | null, positionInViewFilterGroup?: number | null, subFieldName?: string | null, viewId: any, createdAt: string, updatedAt: string, deletedAt?: string | null }>, viewFilterGroups: Array<{ __typename?: 'CoreViewFilterGroup', id: any, parentViewFilterGroupId?: any | null, logicalOperator: ViewFilterGroupLogicalOperator, positionInViewFilterGroup?: number | null, viewId: any }>, viewSorts: Array<{ __typename?: 'CoreViewSort', id: any, fieldMetadataId: any, direction: ViewSortDirection, viewId: any }>, viewGroups: Array<{ __typename?: 'CoreViewGroup', id: any, isVisible: boolean, fieldValue: string, position: number, viewId: any, createdAt: string, updatedAt: string, deletedAt?: string | null }> } | null };
|
||||
export type FindOneCoreViewQuery = { __typename?: 'Query', getCoreView?: { __typename?: 'CoreView', id: any, name: string, objectMetadataId: any, type: ViewType, key?: ViewKey | null, icon: string, position: number, isCompact: boolean, openRecordIn: ViewOpenRecordIn, kanbanAggregateOperation?: AggregateOperations | null, kanbanAggregateOperationFieldMetadataId?: any | null, mainGroupByFieldMetadataId?: any | null, shouldHideEmptyGroups: boolean, anyFieldFilterValue?: string | null, calendarFieldMetadataId?: any | null, calendarLayout?: ViewCalendarLayout | null, visibility: ViewVisibility, createdByUserWorkspaceId?: any | null, viewFields: Array<{ __typename?: 'CoreViewField', id: any, fieldMetadataId: any, viewId: any, isVisible: boolean, position: number, size: number, aggregateOperation?: AggregateOperations | null, createdAt: string, updatedAt: string, deletedAt?: string | null }>, viewFilters: Array<{ __typename?: 'CoreViewFilter', id: any, fieldMetadataId: any, operand: ViewFilterOperand, value: any, viewFilterGroupId?: any | null, positionInViewFilterGroup?: number | null, subFieldName?: string | null, viewId: any, createdAt: string, updatedAt: string, deletedAt?: string | null }>, viewFilterGroups: Array<{ __typename?: 'CoreViewFilterGroup', id: any, parentViewFilterGroupId?: any | null, logicalOperator: ViewFilterGroupLogicalOperator, positionInViewFilterGroup?: number | null, viewId: any }>, viewSorts: Array<{ __typename?: 'CoreViewSort', id: any, fieldMetadataId: any, direction: ViewSortDirection, viewId: any }>, viewGroups: Array<{ __typename?: 'CoreViewGroup', id: any, isVisible: boolean, fieldValue: string, position: number, viewId: any, createdAt: string, updatedAt: string, deletedAt?: string | null }> } | null };
|
||||
|
||||
export type FindOneCoreViewFieldQueryVariables = Exact<{
|
||||
id: Scalars['String'];
|
||||
@@ -5226,6 +5269,10 @@ export const PageLayoutWidgetFragmentFragmentDoc = gql`
|
||||
suffix
|
||||
timezone
|
||||
firstDayOfTheWeek
|
||||
ratioAggregateConfig {
|
||||
fieldMetadataId
|
||||
optionValue
|
||||
}
|
||||
}
|
||||
... on GaugeChartConfiguration {
|
||||
graphType
|
||||
@@ -5323,6 +5370,7 @@ export const ViewFragmentFragmentDoc = gql`
|
||||
kanbanAggregateOperation
|
||||
kanbanAggregateOperationFieldMetadataId
|
||||
mainGroupByFieldMetadataId
|
||||
shouldHideEmptyGroups
|
||||
anyFieldFilterValue
|
||||
calendarFieldMetadataId
|
||||
calendarLayout
|
||||
|
||||
@@ -3,7 +3,7 @@ import { motion } from 'framer-motion';
|
||||
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
|
||||
|
||||
import { SKELETON_LOADER_HEIGHT_SIZES } from '@/activities/components/SkeletonLoader';
|
||||
import { NAV_DRAWER_WIDTHS } from '@/ui/navigation/navigation-drawer/constants/NavDrawerWidths';
|
||||
import { NAVIGATION_DRAWER_CONSTRAINTS } from '@/ui/layout/resizable-panel/constants/NavigationDrawerConstraints';
|
||||
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import { ANIMATION } from 'twenty-ui/theme';
|
||||
@@ -54,9 +54,7 @@ export const LeftPanelSkeletonLoader = () => {
|
||||
<StyledAnimatedContainer
|
||||
initial={false}
|
||||
animate={{
|
||||
width: isMobile
|
||||
? NAV_DRAWER_WIDTHS.menu.mobile.collapsed
|
||||
: NAV_DRAWER_WIDTHS.menu.desktop.expanded,
|
||||
width: isMobile ? 0 : NAVIGATION_DRAWER_CONSTRAINTS.default,
|
||||
opacity: isMobile ? 0 : 1,
|
||||
}}
|
||||
transition={{ duration: ANIMATION.duration.fast }}
|
||||
|
||||
@@ -2,7 +2,7 @@ import styled from '@emotion/styled';
|
||||
|
||||
import { useShowAuthModal } from '@/ui/layout/hooks/useShowAuthModal';
|
||||
import { Modal } from '@/ui/layout/modal/components/Modal';
|
||||
import { NAV_DRAWER_WIDTHS } from '@/ui/navigation/navigation-drawer/constants/NavDrawerWidths';
|
||||
import { NAVIGATION_DRAWER_CONSTRAINTS } from '@/ui/layout/resizable-panel/constants/NavigationDrawerConstraints';
|
||||
import { MOBILE_VIEWPORT } from 'twenty-ui/theme';
|
||||
import { LeftPanelSkeletonLoader } from '~/loading/components/LeftPanelSkeletonLoader';
|
||||
import { RightPanelSkeletonLoader } from '~/loading/components/RightPanelSkeletonLoader';
|
||||
@@ -14,7 +14,7 @@ const StyledContainer = styled.div`
|
||||
flex-direction: row;
|
||||
gap: 12px;
|
||||
height: 100dvh;
|
||||
min-width: ${NAV_DRAWER_WIDTHS.menu.desktop.expanded}px;
|
||||
min-width: ${NAVIGATION_DRAWER_CONSTRAINTS.default}px;
|
||||
width: 100%;
|
||||
padding: 12px 8px 12px 8px;
|
||||
overflow: hidden;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user