Compare commits
45
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd04b27a57 | ||
|
|
38ad0820c0 | ||
|
|
647c32ff3e | ||
|
|
7293d4c1f8 | ||
|
|
1acbf28316 | ||
|
|
1decd40eea | ||
|
|
1b9d188e4a | ||
|
|
57499342f1 | ||
|
|
ecbc0ac013 | ||
|
|
c571473d67 | ||
|
|
2a82df7073 | ||
|
|
a2f80d882b | ||
|
|
abd9709291 | ||
|
|
9d4ff7820d | ||
|
|
4cfd738312 | ||
|
|
0e89c96170 | ||
|
|
bfa50f566e | ||
|
|
338a38682d | ||
|
|
6a2e0182ab | ||
|
|
72086fe111 | ||
|
|
228865bd94 | ||
|
|
2493adbb87 | ||
|
|
26f0a416a1 | ||
|
|
c41a8e2b23 | ||
|
|
4c001778c2 | ||
|
|
911a46aa45 | ||
|
|
c53d281960 | ||
|
|
7f6b270a76 | ||
|
|
c94657dc0a | ||
|
|
aeedcf3353 | ||
|
|
eda905f271 | ||
|
|
be01a85d67 | ||
|
|
999dcd4468 | ||
|
|
b11f77df2a | ||
|
|
f09a9cc25a | ||
|
|
d59d2efb8b | ||
|
|
07803f232f | ||
|
|
aaa483c020 | ||
|
|
845a1934d3 | ||
|
|
c97d872b9f | ||
|
|
906a0aed38 | ||
|
|
80d054563e | ||
|
|
5b544809f7 | ||
|
|
3b2bf39565 | ||
|
|
995793c0ac |
Vendored
+1
@@ -48,6 +48,7 @@
|
||||
"search.exclude": {
|
||||
"**/.yarn": true
|
||||
},
|
||||
"eslint.useFlatConfig": true,
|
||||
"eslint.debug": true,
|
||||
"files.associations": {
|
||||
".cursorrules": "markdown"
|
||||
|
||||
+14
-14
@@ -46,7 +46,7 @@
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-responsive": "^9.0.2",
|
||||
"react-router-dom": "^6.4.4",
|
||||
"react-router-dom": "^6.30.3",
|
||||
"react-tooltip": "^5.13.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"rxjs": "^7.2.0",
|
||||
@@ -72,14 +72,14 @@
|
||||
"@graphql-codegen/typescript": "^3.0.4",
|
||||
"@graphql-codegen/typescript-operations": "^3.0.4",
|
||||
"@graphql-codegen/typescript-react-apollo": "^3.3.7",
|
||||
"@nx/eslint": "22.3.3",
|
||||
"@nx/eslint-plugin": "22.3.3",
|
||||
"@nx/jest": "22.3.3",
|
||||
"@nx/js": "22.3.3",
|
||||
"@nx/react": "22.3.3",
|
||||
"@nx/storybook": "22.3.3",
|
||||
"@nx/vite": "22.3.3",
|
||||
"@nx/web": "22.3.3",
|
||||
"@nx/eslint": "22.5.4",
|
||||
"@nx/eslint-plugin": "22.5.4",
|
||||
"@nx/jest": "22.5.4",
|
||||
"@nx/js": "22.5.4",
|
||||
"@nx/react": "22.5.4",
|
||||
"@nx/storybook": "22.5.4",
|
||||
"@nx/vite": "22.5.4",
|
||||
"@nx/web": "22.5.4",
|
||||
"@sentry/types": "^8",
|
||||
"@storybook-community/storybook-addon-cookie": "^5.0.0",
|
||||
"@storybook/addon-coverage": "^3.0.0",
|
||||
@@ -90,10 +90,10 @@
|
||||
"@storybook/react-vite": "^10.2.13",
|
||||
"@storybook/test-runner": "^0.24.2",
|
||||
"@stylistic/eslint-plugin": "^1.5.0",
|
||||
"@swc-node/register": "1.11.1",
|
||||
"@swc/cli": "^0.3.12",
|
||||
"@swc/core": "1.15.11",
|
||||
"@swc/helpers": "~0.5.18",
|
||||
"@swc-node/register": "^1.11.1",
|
||||
"@swc/cli": "^0.7.10",
|
||||
"@swc/core": "^1.15.11",
|
||||
"@swc/helpers": "~0.5.19",
|
||||
"@swc/jest": "^0.2.39",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
@@ -170,7 +170,7 @@
|
||||
"jsdom": "~22.1.0",
|
||||
"msw": "^2.12.7",
|
||||
"msw-storybook-addon": "^2.0.6",
|
||||
"nx": "22.3.3",
|
||||
"nx": "22.5.4",
|
||||
"prettier": "^3.1.1",
|
||||
"raw-loader": "^4.0.2",
|
||||
"rimraf": "^5.0.5",
|
||||
|
||||
@@ -89,6 +89,7 @@ In interactive mode, you can pick from:
|
||||
- **Example view** — a saved view for the example object (`views/example-view.ts`)
|
||||
- **Example navigation menu item** — a sidebar link (`navigation-menu-items/example-navigation-menu-item.ts`)
|
||||
- **Example skill** — an AI agent skill definition (`skills/example-skill.ts`)
|
||||
- **Integration test** — a vitest integration test verifying app installation (`__tests__/app-install.integration-test.ts`)
|
||||
|
||||
## What gets scaffolded
|
||||
|
||||
@@ -108,6 +109,7 @@ In interactive mode, you can pick from:
|
||||
- `views/example-view.ts` — Example saved view for the example object
|
||||
- `navigation-menu-items/example-navigation-menu-item.ts` — Example sidebar navigation link
|
||||
- `skills/example-skill.ts` — Example AI agent skill definition
|
||||
- `__tests__/app-install.integration-test.ts` — Integration test that builds, installs, and verifies the app (includes `vitest.config.ts`, `tsconfig.spec.json`, and a setup file)
|
||||
|
||||
## Next steps
|
||||
- Run `yarn twenty help` to see all available commands.
|
||||
|
||||
@@ -36,6 +36,17 @@ yarn twenty function:execute # Execute a function with JSON payload
|
||||
yarn twenty app:uninstall # Uninstall app from workspace
|
||||
```
|
||||
|
||||
## Integration Tests
|
||||
|
||||
If your project includes the example integration test (`src/__tests__/app-install.integration-test.ts`), you can run it with:
|
||||
|
||||
```bash
|
||||
# Make sure a Twenty server is running at http://localhost:3000
|
||||
yarn test
|
||||
```
|
||||
|
||||
The test builds and installs the app, then verifies it appears in the applications list. Test configuration (API URL and API key) is defined in `vitest.config.ts`.
|
||||
|
||||
## LLMs instructions
|
||||
|
||||
Main docs and pitfalls are available in LLMS.md file.
|
||||
|
||||
@@ -27,5 +27,5 @@
|
||||
"~/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts", "**/*.integration-test.ts"]
|
||||
}
|
||||
|
||||
@@ -115,6 +115,7 @@ export class CreateAppCommand {
|
||||
includeExampleView: false,
|
||||
includeExampleNavigationMenuItem: false,
|
||||
includeExampleSkill: false,
|
||||
includeExampleIntegrationTest: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -127,6 +128,7 @@ export class CreateAppCommand {
|
||||
includeExampleView: true,
|
||||
includeExampleNavigationMenuItem: true,
|
||||
includeExampleSkill: true,
|
||||
includeExampleIntegrationTest: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -171,12 +173,19 @@ export class CreateAppCommand {
|
||||
value: 'skill',
|
||||
checked: true,
|
||||
},
|
||||
{
|
||||
name: 'Integration test (vitest test verifying app installation)',
|
||||
value: 'integrationTest',
|
||||
checked: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const includeField = selectedExamples.includes('field');
|
||||
const includeView = selectedExamples.includes('view');
|
||||
const includeExampleIntegrationTest =
|
||||
selectedExamples.includes('integrationTest');
|
||||
const includeObject =
|
||||
selectedExamples.includes('object') || includeField || includeView;
|
||||
|
||||
@@ -197,6 +206,7 @@ export class CreateAppCommand {
|
||||
includeExampleNavigationMenuItem:
|
||||
selectedExamples.includes('navigationMenuItem'),
|
||||
includeExampleSkill: selectedExamples.includes('skill'),
|
||||
includeExampleIntegrationTest,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -8,4 +8,5 @@ export type ExampleOptions = {
|
||||
includeExampleView: boolean;
|
||||
includeExampleNavigationMenuItem: boolean;
|
||||
includeExampleSkill: boolean;
|
||||
includeExampleIntegrationTest: boolean;
|
||||
};
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { type ExampleOptions } from '@/types/scaffolding-options';
|
||||
import { GENERATED_DIR } from 'twenty-shared/application';
|
||||
import { copyBaseApplicationProject } from '@/utils/app-template';
|
||||
import * as fs from 'fs-extra';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import createTwentyAppPackageJson from 'package.json';
|
||||
import { join } from 'path';
|
||||
import { GENERATED_DIR } from 'twenty-shared/application';
|
||||
|
||||
jest.mock('fs-extra', () => {
|
||||
const actual = jest.requireActual('fs-extra');
|
||||
@@ -25,6 +25,16 @@ const ALL_EXAMPLES: ExampleOptions = {
|
||||
includeExampleView: true,
|
||||
includeExampleNavigationMenuItem: true,
|
||||
includeExampleSkill: true,
|
||||
includeExampleIntegrationTest: true,
|
||||
};
|
||||
|
||||
const SEED_TSCONFIG = {
|
||||
compilerOptions: { paths: { 'src/*': ['./src/*'] } },
|
||||
exclude: ['node_modules', 'dist', '**/*.integration-test.ts'],
|
||||
};
|
||||
|
||||
const seedTsconfig = async (directory: string) => {
|
||||
await fs.writeJson(join(directory, 'tsconfig.json'), SEED_TSCONFIG);
|
||||
};
|
||||
|
||||
const NO_EXAMPLES: ExampleOptions = {
|
||||
@@ -35,6 +45,7 @@ const NO_EXAMPLES: ExampleOptions = {
|
||||
includeExampleFrontComponent: false,
|
||||
includeExampleView: false,
|
||||
includeExampleNavigationMenuItem: false,
|
||||
includeExampleIntegrationTest: false,
|
||||
};
|
||||
|
||||
describe('copyBaseApplicationProject', () => {
|
||||
@@ -46,6 +57,7 @@ describe('copyBaseApplicationProject', () => {
|
||||
`test-twenty-app-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
);
|
||||
await fs.ensureDir(testAppDirectory);
|
||||
await seedTsconfig(testAppDirectory);
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
@@ -149,11 +161,18 @@ describe('copyBaseApplicationProject', () => {
|
||||
"import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role'",
|
||||
);
|
||||
|
||||
expect(appConfigContent).toContain(
|
||||
'export const APPLICATION_UNIVERSAL_IDENTIFIER',
|
||||
);
|
||||
expect(appConfigContent).toContain(
|
||||
'universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER',
|
||||
);
|
||||
|
||||
expect(appConfigContent).toContain("displayName: 'My Test App'");
|
||||
expect(appConfigContent).toContain("description: 'A test application'");
|
||||
|
||||
expect(appConfigContent).toMatch(
|
||||
/universalIdentifier: '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'/,
|
||||
/APPLICATION_UNIVERSAL_IDENTIFIER =\s*'[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'/,
|
||||
);
|
||||
|
||||
expect(appConfigContent).toContain(
|
||||
@@ -235,6 +254,7 @@ describe('copyBaseApplicationProject', () => {
|
||||
it('should generate unique UUIDs for each application', async () => {
|
||||
const firstAppDir = join(testAppDirectory, 'app1');
|
||||
await fs.ensureDir(firstAppDir);
|
||||
await seedTsconfig(firstAppDir);
|
||||
await copyBaseApplicationProject({
|
||||
appName: 'app-one',
|
||||
appDisplayName: 'App One',
|
||||
@@ -245,6 +265,7 @@ describe('copyBaseApplicationProject', () => {
|
||||
|
||||
const secondAppDir = join(testAppDirectory, 'app2');
|
||||
await fs.ensureDir(secondAppDir);
|
||||
await seedTsconfig(secondAppDir);
|
||||
await copyBaseApplicationProject({
|
||||
appName: 'app-two',
|
||||
appDisplayName: 'App Two',
|
||||
@@ -263,7 +284,7 @@ describe('copyBaseApplicationProject', () => {
|
||||
);
|
||||
|
||||
const uuidRegex =
|
||||
/universalIdentifier: '([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})'/;
|
||||
/APPLICATION_UNIVERSAL_IDENTIFIER =\s*'([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})'/;
|
||||
const firstUuid = firstAppConfig.match(uuidRegex)?.[1];
|
||||
const secondUuid = secondAppConfig.match(uuidRegex)?.[1];
|
||||
|
||||
@@ -275,6 +296,7 @@ describe('copyBaseApplicationProject', () => {
|
||||
it('should generate unique role UUIDs for each application', async () => {
|
||||
const firstAppDir = join(testAppDirectory, 'app1');
|
||||
await fs.ensureDir(firstAppDir);
|
||||
await seedTsconfig(firstAppDir);
|
||||
await copyBaseApplicationProject({
|
||||
appName: 'app-one',
|
||||
appDisplayName: 'App One',
|
||||
@@ -285,6 +307,7 @@ describe('copyBaseApplicationProject', () => {
|
||||
|
||||
const secondAppDir = join(testAppDirectory, 'app2');
|
||||
await fs.ensureDir(secondAppDir);
|
||||
await seedTsconfig(secondAppDir);
|
||||
await copyBaseApplicationProject({
|
||||
appName: 'app-two',
|
||||
appDisplayName: 'App Two',
|
||||
@@ -355,6 +378,12 @@ describe('copyBaseApplicationProject', () => {
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
await fs.pathExists(
|
||||
join(srcPath, '__tests__', 'app-install.integration-test.ts'),
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
// Install functions should always exist
|
||||
expect(
|
||||
await fs.pathExists(
|
||||
@@ -428,6 +457,11 @@ describe('copyBaseApplicationProject', () => {
|
||||
),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
await fs.pathExists(
|
||||
join(srcPath, '__tests__', 'app-install.integration-test.ts'),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -446,6 +480,7 @@ describe('copyBaseApplicationProject', () => {
|
||||
includeExampleFrontComponent: true,
|
||||
includeExampleView: false,
|
||||
includeExampleNavigationMenuItem: false,
|
||||
includeExampleIntegrationTest: false,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -483,6 +518,7 @@ describe('copyBaseApplicationProject', () => {
|
||||
includeExampleFrontComponent: false,
|
||||
includeExampleView: false,
|
||||
includeExampleNavigationMenuItem: false,
|
||||
includeExampleIntegrationTest: false,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -540,6 +576,7 @@ describe('copyBaseApplicationProject', () => {
|
||||
it('should generate unique UUIDs for example objects across apps', async () => {
|
||||
const firstAppDir = join(testAppDirectory, 'app1');
|
||||
await fs.ensureDir(firstAppDir);
|
||||
await seedTsconfig(firstAppDir);
|
||||
await copyBaseApplicationProject({
|
||||
appName: 'app-one',
|
||||
appDisplayName: 'App One',
|
||||
@@ -550,6 +587,7 @@ describe('copyBaseApplicationProject', () => {
|
||||
|
||||
const secondAppDir = join(testAppDirectory, 'app2');
|
||||
await fs.ensureDir(secondAppDir);
|
||||
await seedTsconfig(secondAppDir);
|
||||
await copyBaseApplicationProject({
|
||||
appName: 'app-two',
|
||||
appDisplayName: 'App Two',
|
||||
@@ -738,6 +776,44 @@ describe('copyBaseApplicationProject', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('integration test', () => {
|
||||
it('should include vitest and test scripts in package.json when enabled', async () => {
|
||||
await copyBaseApplicationProject({
|
||||
appName: 'my-test-app',
|
||||
appDisplayName: 'My Test App',
|
||||
appDescription: 'A test application',
|
||||
appDirectory: testAppDirectory,
|
||||
exampleOptions: ALL_EXAMPLES,
|
||||
});
|
||||
|
||||
const packageJson = await fs.readJson(
|
||||
join(testAppDirectory, 'package.json'),
|
||||
);
|
||||
|
||||
expect(packageJson.scripts.test).toBe('vitest run');
|
||||
expect(packageJson.scripts['test:watch']).toBe('vitest');
|
||||
expect(packageJson.devDependencies.vitest).toBeDefined();
|
||||
});
|
||||
|
||||
it('should not include vitest or test scripts when disabled', async () => {
|
||||
await copyBaseApplicationProject({
|
||||
appName: 'my-test-app',
|
||||
appDisplayName: 'My Test App',
|
||||
appDescription: 'A test application',
|
||||
appDirectory: testAppDirectory,
|
||||
exampleOptions: NO_EXAMPLES,
|
||||
});
|
||||
|
||||
const packageJson = await fs.readJson(
|
||||
join(testAppDirectory, 'package.json'),
|
||||
);
|
||||
|
||||
expect(packageJson.scripts.test).toBeUndefined();
|
||||
expect(packageJson.scripts['test:watch']).toBeUndefined();
|
||||
expect(packageJson.devDependencies.vitest).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('post-install logic function', () => {
|
||||
it('should create post-install.ts with definePostInstallLogicFunction and typed payload', async () => {
|
||||
await copyBaseApplicationProject({
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { scaffoldIntegrationTest } from '@/utils/test-template';
|
||||
import * as fs from 'fs-extra';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
describe('scaffoldIntegrationTest', () => {
|
||||
let testAppDirectory: string;
|
||||
let sourceFolderPath: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
testAppDirectory = join(
|
||||
tmpdir(),
|
||||
`test-twenty-app-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
);
|
||||
sourceFolderPath = join(testAppDirectory, 'src');
|
||||
await fs.ensureDir(sourceFolderPath);
|
||||
|
||||
await fs.writeJson(join(testAppDirectory, 'tsconfig.json'), {
|
||||
compilerOptions: {
|
||||
paths: { 'src/*': ['./src/*'] },
|
||||
},
|
||||
exclude: ['node_modules', 'dist', '**/*.integration-test.ts'],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (testAppDirectory && (await fs.pathExists(testAppDirectory))) {
|
||||
await fs.remove(testAppDirectory);
|
||||
}
|
||||
});
|
||||
|
||||
describe('integration test file', () => {
|
||||
it('should create app-install.integration-test.ts with correct structure', async () => {
|
||||
await scaffoldIntegrationTest({
|
||||
appDirectory: testAppDirectory,
|
||||
sourceFolderPath,
|
||||
});
|
||||
|
||||
const testPath = join(
|
||||
sourceFolderPath,
|
||||
'__tests__',
|
||||
'app-install.integration-test.ts',
|
||||
);
|
||||
|
||||
expect(await fs.pathExists(testPath)).toBe(true);
|
||||
|
||||
const content = await fs.readFile(testPath, 'utf8');
|
||||
|
||||
expect(content).toContain(
|
||||
"import { appBuild, appUninstall } from 'twenty-sdk/cli'",
|
||||
);
|
||||
expect(content).toContain(
|
||||
"import { MetadataApiClient } from 'twenty-sdk/generated'",
|
||||
);
|
||||
expect(content).toContain(
|
||||
"import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/application-config'",
|
||||
);
|
||||
expect(content).toContain('TWENTY_TEST_API_KEY');
|
||||
expect(content).toContain('assertServerIsReachable');
|
||||
expect(content).toContain('appBuild');
|
||||
expect(content).toContain('appUninstall');
|
||||
expect(content).toContain('findManyApplications');
|
||||
expect(content).toContain('APPLICATION_UNIVERSAL_IDENTIFIER');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setup-test file', () => {
|
||||
it('should create setup-test.ts with SDK config bootstrap', async () => {
|
||||
await scaffoldIntegrationTest({
|
||||
appDirectory: testAppDirectory,
|
||||
sourceFolderPath,
|
||||
});
|
||||
|
||||
const setupTestPath = join(
|
||||
sourceFolderPath,
|
||||
'__tests__',
|
||||
'setup-test.ts',
|
||||
);
|
||||
|
||||
expect(await fs.pathExists(setupTestPath)).toBe(true);
|
||||
|
||||
const content = await fs.readFile(setupTestPath, 'utf8');
|
||||
|
||||
expect(content).toContain('.twenty-sdk-test');
|
||||
expect(content).toContain('config.json');
|
||||
expect(content).toContain('process.env.TWENTY_API_URL');
|
||||
expect(content).toContain('process.env.TWENTY_TEST_API_KEY');
|
||||
});
|
||||
});
|
||||
|
||||
describe('vitest config', () => {
|
||||
it('should create vitest.config.ts with env vars and setup file', async () => {
|
||||
await scaffoldIntegrationTest({
|
||||
appDirectory: testAppDirectory,
|
||||
sourceFolderPath,
|
||||
});
|
||||
|
||||
const vitestConfigPath = join(testAppDirectory, 'vitest.config.ts');
|
||||
|
||||
expect(await fs.pathExists(vitestConfigPath)).toBe(true);
|
||||
|
||||
const content = await fs.readFile(vitestConfigPath, 'utf8');
|
||||
|
||||
expect(content).toContain('TWENTY_TEST_API_KEY');
|
||||
expect(content).toContain('TWENTY_API_URL');
|
||||
expect(content).toContain('setup-test.ts');
|
||||
expect(content).toContain('tsconfig.spec.json');
|
||||
expect(content).toContain('integration-test.ts');
|
||||
});
|
||||
});
|
||||
|
||||
describe('tsconfig.spec.json', () => {
|
||||
it('should create tsconfig.spec.json extending the base tsconfig', async () => {
|
||||
await scaffoldIntegrationTest({
|
||||
appDirectory: testAppDirectory,
|
||||
sourceFolderPath,
|
||||
});
|
||||
|
||||
const tsconfigSpecPath = join(testAppDirectory, 'tsconfig.spec.json');
|
||||
|
||||
expect(await fs.pathExists(tsconfigSpecPath)).toBe(true);
|
||||
|
||||
const tsconfigSpec = await fs.readJson(tsconfigSpecPath);
|
||||
|
||||
expect(tsconfigSpec.extends).toBe('./tsconfig.json');
|
||||
expect(tsconfigSpec.compilerOptions.composite).toBe(true);
|
||||
expect(tsconfigSpec.include).toContain('src/**/*.ts');
|
||||
expect(tsconfigSpec.exclude).not.toContain('**/*.integration-test.ts');
|
||||
});
|
||||
|
||||
it('should add a reference to tsconfig.spec.json in tsconfig.json', async () => {
|
||||
await scaffoldIntegrationTest({
|
||||
appDirectory: testAppDirectory,
|
||||
sourceFolderPath,
|
||||
});
|
||||
|
||||
const tsconfig = await fs.readJson(
|
||||
join(testAppDirectory, 'tsconfig.json'),
|
||||
);
|
||||
|
||||
expect(tsconfig.references).toEqual([{ path: './tsconfig.spec.json' }]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,10 @@
|
||||
import * as fs from 'fs-extra';
|
||||
import { join } from 'path';
|
||||
import { v4 } from 'uuid';
|
||||
import { ASSETS_DIR } from 'twenty-shared/application';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { type ExampleOptions } from '@/types/scaffolding-options';
|
||||
import { scaffoldIntegrationTest } from '@/utils/test-template';
|
||||
import createTwentyAppPackageJson from 'package.json';
|
||||
|
||||
const SRC_FOLDER = 'src';
|
||||
@@ -23,7 +24,11 @@ export const copyBaseApplicationProject = async ({
|
||||
}) => {
|
||||
await fs.copy(join(__dirname, './constants/base-application'), appDirectory);
|
||||
|
||||
await createPackageJson({ appName, appDirectory });
|
||||
await createPackageJson({
|
||||
appName,
|
||||
appDirectory,
|
||||
includeExampleIntegrationTest: exampleOptions.includeExampleIntegrationTest,
|
||||
});
|
||||
|
||||
await createGitignore(appDirectory);
|
||||
|
||||
@@ -98,6 +103,13 @@ export const copyBaseApplicationProject = async ({
|
||||
});
|
||||
}
|
||||
|
||||
if (exampleOptions.includeExampleIntegrationTest) {
|
||||
await scaffoldIntegrationTest({
|
||||
appDirectory,
|
||||
sourceFolderPath,
|
||||
});
|
||||
}
|
||||
|
||||
await createDefaultPreInstallFunction({
|
||||
appDirectory: sourceFolderPath,
|
||||
fileFolder: 'logic-functions',
|
||||
@@ -167,6 +179,7 @@ yarn-error.log*
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
*.d.ts
|
||||
`;
|
||||
|
||||
await fs.writeFile(join(appDirectory, '.gitignore'), gitignoreContent);
|
||||
@@ -509,11 +522,16 @@ const createApplicationConfig = async ({
|
||||
fileFolder?: string;
|
||||
fileName: string;
|
||||
}) => {
|
||||
const universalIdentifier = v4();
|
||||
|
||||
const content = `import { defineApplication } from 'twenty-sdk';
|
||||
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role';
|
||||
|
||||
export const APPLICATION_UNIVERSAL_IDENTIFIER =
|
||||
'${universalIdentifier}';
|
||||
|
||||
export default defineApplication({
|
||||
universalIdentifier: '${v4()}',
|
||||
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
|
||||
displayName: '${displayName}',
|
||||
description: '${description ?? ''}',
|
||||
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
|
||||
@@ -527,10 +545,35 @@ export default defineApplication({
|
||||
const createPackageJson = async ({
|
||||
appName,
|
||||
appDirectory,
|
||||
includeExampleIntegrationTest,
|
||||
}: {
|
||||
appName: string;
|
||||
appDirectory: string;
|
||||
includeExampleIntegrationTest: boolean;
|
||||
}) => {
|
||||
const scripts: Record<string, string> = {
|
||||
twenty: 'twenty',
|
||||
lint: 'eslint',
|
||||
'lint:fix': 'eslint --fix',
|
||||
};
|
||||
|
||||
const devDependencies: Record<string, string> = {
|
||||
typescript: '^5.9.3',
|
||||
'@types/node': '^24.7.2',
|
||||
'@types/react': '^18.2.0',
|
||||
react: '^18.2.0',
|
||||
eslint: '^9.32.0',
|
||||
'typescript-eslint': '^8.50.0',
|
||||
'twenty-sdk': createTwentyAppPackageJson.version,
|
||||
};
|
||||
|
||||
if (includeExampleIntegrationTest) {
|
||||
scripts.test = 'vitest run';
|
||||
scripts['test:watch'] = 'vitest';
|
||||
devDependencies.vitest = '^3.1.1';
|
||||
devDependencies['vite-tsconfig-paths'] = '^4.2.1';
|
||||
}
|
||||
|
||||
const packageJson = {
|
||||
name: appName,
|
||||
version: '0.1.0',
|
||||
@@ -541,21 +584,8 @@ const createPackageJson = async ({
|
||||
yarn: '>=4.0.2',
|
||||
},
|
||||
packageManager: '[email protected]',
|
||||
scripts: {
|
||||
twenty: 'twenty',
|
||||
lint: 'eslint',
|
||||
'lint:fix': 'eslint --fix',
|
||||
},
|
||||
dependencies: {},
|
||||
devDependencies: {
|
||||
'twenty-sdk': createTwentyAppPackageJson.version,
|
||||
typescript: '^5.9.3',
|
||||
'@types/node': '^24.7.2',
|
||||
'@types/react': '^18.2.0',
|
||||
react: '^18.2.0',
|
||||
eslint: '^9.32.0',
|
||||
'typescript-eslint': '^8.50.0',
|
||||
},
|
||||
scripts,
|
||||
devDependencies,
|
||||
};
|
||||
|
||||
await fs.writeFile(
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import * as fs from 'fs-extra';
|
||||
import { join } from 'path';
|
||||
|
||||
const SEED_API_KEY =
|
||||
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik';
|
||||
|
||||
export const scaffoldIntegrationTest = async ({
|
||||
appDirectory,
|
||||
sourceFolderPath,
|
||||
}: {
|
||||
appDirectory: string;
|
||||
sourceFolderPath: string;
|
||||
}) => {
|
||||
await createIntegrationTest({
|
||||
appDirectory: sourceFolderPath,
|
||||
fileFolder: '__tests__',
|
||||
fileName: 'app-install.integration-test.ts',
|
||||
});
|
||||
|
||||
await createSetupTest({
|
||||
appDirectory: sourceFolderPath,
|
||||
fileFolder: '__tests__',
|
||||
fileName: 'setup-test.ts',
|
||||
});
|
||||
|
||||
await createVitestConfig(appDirectory);
|
||||
await createTsconfigSpec(appDirectory);
|
||||
};
|
||||
|
||||
const createVitestConfig = async (appDirectory: string) => {
|
||||
const content = `import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
tsconfigPaths({
|
||||
projects: ['tsconfig.spec.json'],
|
||||
ignoreConfigErrors: true,
|
||||
}),
|
||||
],
|
||||
test: {
|
||||
testTimeout: 120_000,
|
||||
hookTimeout: 120_000,
|
||||
include: ['src/**/*.integration-test.ts'],
|
||||
setupFiles: ['src/__tests__/setup-test.ts'],
|
||||
env: {
|
||||
TWENTY_API_URL: 'http://localhost:3000',
|
||||
TWENTY_TEST_API_KEY:
|
||||
'${SEED_API_KEY}',
|
||||
},
|
||||
},
|
||||
});
|
||||
`;
|
||||
|
||||
await fs.writeFile(join(appDirectory, 'vitest.config.ts'), content);
|
||||
};
|
||||
|
||||
const createTsconfigSpec = async (appDirectory: string) => {
|
||||
const tsconfigSpec = {
|
||||
extends: './tsconfig.json',
|
||||
compilerOptions: {
|
||||
composite: true,
|
||||
types: ['vitest/globals'],
|
||||
},
|
||||
include: ['src/**/*.ts', 'src/**/*.tsx'],
|
||||
exclude: ['node_modules', 'dist'],
|
||||
};
|
||||
|
||||
await fs.writeFile(
|
||||
join(appDirectory, 'tsconfig.spec.json'),
|
||||
JSON.stringify(tsconfigSpec, null, 2),
|
||||
);
|
||||
|
||||
const tsconfigPath = join(appDirectory, 'tsconfig.json');
|
||||
const tsconfig = await fs.readJson(tsconfigPath);
|
||||
|
||||
tsconfig.references = [{ path: './tsconfig.spec.json' }];
|
||||
|
||||
await fs.writeFile(tsconfigPath, JSON.stringify(tsconfig, null, 2));
|
||||
};
|
||||
|
||||
const createSetupTest = async ({
|
||||
appDirectory,
|
||||
fileFolder,
|
||||
fileName,
|
||||
}: {
|
||||
appDirectory: string;
|
||||
fileFolder?: string;
|
||||
fileName: string;
|
||||
}) => {
|
||||
const content = `import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { beforeAll } from 'vitest';
|
||||
|
||||
const TEST_CONFIG_DIR = path.join(os.tmpdir(), '.twenty-sdk-test');
|
||||
|
||||
beforeAll(() => {
|
||||
fs.mkdirSync(TEST_CONFIG_DIR, { recursive: true });
|
||||
|
||||
const configFile = {
|
||||
profiles: {
|
||||
default: {
|
||||
apiUrl: process.env.TWENTY_API_URL,
|
||||
apiKey: process.env.TWENTY_TEST_API_KEY,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(TEST_CONFIG_DIR, 'config.json'),
|
||||
JSON.stringify(configFile, null, 2),
|
||||
);
|
||||
});
|
||||
`;
|
||||
|
||||
await fs.ensureDir(join(appDirectory, fileFolder ?? ''));
|
||||
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
|
||||
};
|
||||
|
||||
const createIntegrationTest = async ({
|
||||
appDirectory,
|
||||
fileFolder,
|
||||
fileName,
|
||||
}: {
|
||||
appDirectory: string;
|
||||
fileFolder?: string;
|
||||
fileName: string;
|
||||
}) => {
|
||||
const content = `import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/application-config';
|
||||
import { appBuild, appUninstall } from 'twenty-sdk/cli';
|
||||
import { MetadataApiClient } from 'twenty-sdk/generated';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
const APP_PATH = process.cwd();
|
||||
const TWENTY_API_URL = process.env.TWENTY_API_URL ?? 'http://localhost:3000';
|
||||
|
||||
const assertServerIsReachable = async () => {
|
||||
let response: Response;
|
||||
|
||||
try {
|
||||
response = await fetch(\`\${TWENTY_API_URL}/healthz\`);
|
||||
} catch {
|
||||
throw new Error(
|
||||
\`Twenty server is not reachable at \${TWENTY_API_URL}. \` +
|
||||
'Make sure the server is running before executing integration tests.',
|
||||
);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(\`Server at \${TWENTY_API_URL} returned \${response.status}\`);
|
||||
}
|
||||
};
|
||||
|
||||
describe('App installation', () => {
|
||||
let appInstalled = false;
|
||||
|
||||
beforeAll(async () => {
|
||||
await assertServerIsReachable();
|
||||
|
||||
const buildResult = await appBuild({
|
||||
appPath: APP_PATH,
|
||||
onProgress: (message: string) => console.log(\`[build] \${message}\`),
|
||||
});
|
||||
|
||||
if (!buildResult.success) {
|
||||
throw new Error(
|
||||
\`App build failed: \${buildResult.error?.message ?? 'Unknown error'}\`,
|
||||
);
|
||||
}
|
||||
|
||||
appInstalled = true;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (!appInstalled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const uninstallResult = await appUninstall({ appPath: APP_PATH });
|
||||
|
||||
if (!uninstallResult.success) {
|
||||
console.warn(
|
||||
\`App uninstall failed: \${uninstallResult.error?.message ?? 'Unknown error'}\`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('should find the installed app in the applications list', async () => {
|
||||
const apiKey = process.env.TWENTY_TEST_API_KEY;
|
||||
|
||||
if (!apiKey) {
|
||||
throw new Error(
|
||||
'No API key found. Set TWENTY_TEST_API_KEY in your vitest config env.',
|
||||
);
|
||||
}
|
||||
|
||||
const metadataClient = new MetadataApiClient({
|
||||
url: \`\${TWENTY_API_URL}/metadata\`,
|
||||
headers: {
|
||||
Authorization: \`Bearer \${apiKey}\`,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await metadataClient.query({
|
||||
findManyApplications: {
|
||||
id: true,
|
||||
name: true,
|
||||
universalIdentifier: true,
|
||||
},
|
||||
});
|
||||
|
||||
const installedApp = result.findManyApplications.find(
|
||||
(application: { universalIdentifier: string }) =>
|
||||
application.universalIdentifier ===
|
||||
APPLICATION_UNIVERSAL_IDENTIFIER,
|
||||
);
|
||||
|
||||
expect(installedApp).toBeDefined();
|
||||
});
|
||||
});
|
||||
`;
|
||||
|
||||
await fs.ensureDir(join(appDirectory, fileFolder ?? ''));
|
||||
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
|
||||
};
|
||||
@@ -23,5 +23,8 @@
|
||||
"**/__mocks__/**/*",
|
||||
"vite.config.ts",
|
||||
"jest.config.mjs"
|
||||
],
|
||||
"exclude": [
|
||||
"src/constants/base-application/vitest.config.ts"
|
||||
]
|
||||
}
|
||||
|
||||
-9
@@ -1,7 +1,6 @@
|
||||
import { defineLogicFunction, RoutePayload } from "twenty-sdk";
|
||||
import { MetadataApiClient } from 'twenty-sdk/generated';
|
||||
|
||||
|
||||
export const OAUTH_TOKEN_PAIRS_PATH = '/oauth/token-pairs';
|
||||
|
||||
type ApolloTokenResponse = {
|
||||
@@ -53,16 +52,8 @@ const handler = async (event: RoutePayload): Promise<any> => {
|
||||
const apolloClientSecret = process.env.APOLLO_CLIENT_SECRET ?? '';
|
||||
const applicationId = process.env.APPLICATION_ID ?? '';
|
||||
|
||||
|
||||
const metadataClient = new MetadataApiClient({});
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
const tokenPairs = await getAuthenticationTokenPairs(
|
||||
code,
|
||||
apolloClientId,
|
||||
|
||||
@@ -5,8 +5,6 @@ import {
|
||||
} from 'twenty-sdk';
|
||||
import { CoreApiClient } from 'twenty-sdk/generated';
|
||||
|
||||
|
||||
|
||||
type CompanyRecord = {
|
||||
id: string;
|
||||
name?: string;
|
||||
@@ -38,8 +36,6 @@ type ApolloEnrichResponse = {
|
||||
organization?: ApolloOrganization;
|
||||
};
|
||||
|
||||
|
||||
|
||||
const extractDomain = (
|
||||
domainName?: CompanyRecord['domainName'],
|
||||
): string | undefined => {
|
||||
@@ -172,7 +168,6 @@ const updateCompanyInTwenty = async (
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
type CompanyUpdateEvent = DatabaseEventPayload<
|
||||
ObjectRecordUpdateEvent<CompanyRecord>
|
||||
>;
|
||||
@@ -181,7 +176,6 @@ const handler = async (
|
||||
event: CompanyUpdateEvent,
|
||||
): Promise<object | undefined> => {
|
||||
|
||||
|
||||
const { recordId, properties } = event;
|
||||
const { after: companyAfter } = properties;
|
||||
|
||||
@@ -206,7 +200,6 @@ const handler = async (
|
||||
return { skipped: true, reason: 'no enrichment data to apply' };
|
||||
}
|
||||
|
||||
|
||||
await updateCompanyInTwenty(recordId, updateData);
|
||||
|
||||
const result = {
|
||||
|
||||
@@ -219,4 +219,3 @@ main().catch((error) => {
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -81,4 +81,3 @@ describe('HistoricalImporter', () => {
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -158,4 +158,3 @@ export class HistoricalImporter {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
export { Meeting } from './meeting';
|
||||
|
||||
|
||||
|
||||
@@ -330,7 +330,6 @@ export class WebhookHandler {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private async createFailedMeetingRecord(params: unknown, error: string): Promise<void> {
|
||||
try {
|
||||
const twentyApiKey = process.env.TWENTY_API_KEY || '';
|
||||
|
||||
-1
@@ -43,7 +43,6 @@ export default defineContentScript({
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
ctx.addEventListener(window, 'wxt:locationchange', ({newUrl, }) => {
|
||||
const injectedBtn = document.querySelector('[data-id="twenty-btn"]');
|
||||
if(personPattern.includes(newUrl) && !injectedBtn) ui.mount();
|
||||
|
||||
-2
@@ -8,7 +8,6 @@ const StyledButton = styled.button`
|
||||
--text-color: #0a66c2;
|
||||
--bg-color: #00000000;
|
||||
|
||||
|
||||
font-size: ${({theme}) => theme.spacing(3.5)};
|
||||
font-weight: ${({theme}) => theme.font.weight.semiBold};
|
||||
font-family: ${({theme}) => theme.font.family};
|
||||
@@ -26,7 +25,6 @@ const StyledButton = styled.button`
|
||||
transition-timing-function: cubic-bezier(.4, 0, .2, 1);
|
||||
transition-duration: 167ms;
|
||||
|
||||
|
||||
&:hover {
|
||||
background-color: var(--hover-bg-color);
|
||||
color: var(--hover-color);
|
||||
|
||||
+2
-7
@@ -1,6 +1,5 @@
|
||||
import { ThemeProvider } from '@emotion/react';
|
||||
import type React from 'react';
|
||||
import { THEME_DARK, ThemeContextProvider } from 'twenty-ui/theme';
|
||||
import { ColorSchemeProvider } from 'twenty-ui/theme-constants';
|
||||
|
||||
type ThemeContextProps = {
|
||||
children: React.ReactNode;
|
||||
@@ -8,10 +7,6 @@ type ThemeContextProps = {
|
||||
|
||||
export const ThemeContext = ({ children }: ThemeContextProps) => {
|
||||
return (
|
||||
<ThemeProvider theme={THEME_DARK}>
|
||||
<ThemeContextProvider theme={THEME_DARK}>
|
||||
{children}
|
||||
</ThemeContextProvider>
|
||||
</ThemeProvider>
|
||||
<ColorSchemeProvider colorScheme="dark">{children}</ColorSchemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
import type { ThemeType } from 'twenty-ui/theme';
|
||||
|
||||
declare module '@emotion/react' {
|
||||
export interface Theme extends ThemeType {}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
TWENTY_API_KEY=<SET_YOUR_TWENTY_API_KEY>
|
||||
TWENTY_API_URL=<SET_YOUR_TWENTY_API_URL>
|
||||
@@ -1,3 +1,38 @@
|
||||
.yarn/install-state.gz
|
||||
.env
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn
|
||||
|
||||
# codegen
|
||||
generated
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# dev
|
||||
/dist/
|
||||
|
||||
.twenty
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
*.d.ts
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
24.5.0
|
||||
@@ -0,0 +1,12 @@
|
||||
## Base documentation
|
||||
|
||||
- Documentation: https://docs.twenty.com/developers/extend/capabilities/apps
|
||||
- Rich app example: https://github.com/twentyhq/twenty/tree/main/packages/twenty-sdk/src/cli/__tests__/apps/rich-app
|
||||
|
||||
## UUID requirement
|
||||
- All generated UUIDs must be valid UUID v4.
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- Creating an object without an index view associated. Unless this is a technical object, user will need to visualize it.
|
||||
- Creating a view without a navigationMenuItem associated. This will make the view available on the left sidebar.
|
||||
@@ -1,41 +1,62 @@
|
||||
# Hello world
|
||||
This is a [Twenty](https://twenty.com) application project bootstrapped with [`create-twenty-app`](https://www.npmjs.com/package/create-twenty-app).
|
||||
|
||||
A minimal hello-world application built with an alpha version of the twenty-cli.
|
||||
## Getting Started
|
||||
|
||||
⚠️ Since this project uses an early alpha release, expect breaking changes and evolving features.
|
||||
|
||||
This example will gradually gain complexity and capabilities as the twenty-cli matures with future updates.
|
||||
|
||||
|
||||
## Requirements
|
||||
- twenty-cli `npm install -g twenty-cli`
|
||||
- an `apiKey`. Go to `/settings/api-webhooks` to generate one
|
||||
|
||||
|
||||
## Install to your Twenty workspace
|
||||
First, authenticate to your workspace:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
yarn twenty auth:login
|
||||
```
|
||||
|
||||
- replace `<SET_YOUR_TWENTY_API_KEY>` and `<SET_YOUR_TWENTY_API_URL>` accordingly
|
||||
Then, start development mode to sync your app and watch for changes:
|
||||
|
||||
```bash
|
||||
twenty auth login
|
||||
twenty app sync
|
||||
yarn twenty app:dev
|
||||
```
|
||||
|
||||
## What it does
|
||||
- creates a new object `postCard` with a `name` field
|
||||
- creates a serverless function `create-new-post-card`
|
||||
- two triggers on `create-new-post-card`:
|
||||
- one route trigger on `GET` `/post-card/create?recipient=RecipientName`
|
||||
- one databaseEvent trigger on `people.created` events
|
||||
Open your Twenty instance and go to `/settings/applications` section to see the result.
|
||||
|
||||
## Development
|
||||
## Available Commands
|
||||
|
||||
Run dev mode and see application updates on your workspace instantly
|
||||
Run `yarn twenty help` to list all available commands. Common commands:
|
||||
|
||||
```bash
|
||||
twenty app dev
|
||||
# Authentication
|
||||
yarn twenty auth:login # Authenticate with Twenty
|
||||
yarn twenty auth:logout # Remove credentials
|
||||
yarn twenty auth:status # Check auth status
|
||||
yarn twenty auth:switch # Switch default workspace
|
||||
yarn twenty auth:list # List all configured workspaces
|
||||
|
||||
# Application
|
||||
yarn twenty app:dev # Start dev mode (watch, build, sync, and auto-generate typed client)
|
||||
yarn twenty entity:add # Add a new entity (object, field, function, front-component, role, view, navigation-menu-item)
|
||||
yarn twenty function:logs # Stream function logs
|
||||
yarn twenty function:execute # Execute a function with JSON payload
|
||||
yarn twenty app:uninstall # Uninstall app from workspace
|
||||
```
|
||||
|
||||
## Integration Tests
|
||||
|
||||
If your project includes the example integration test (`src/__tests__/app-install.integration-test.ts`), you can run it with:
|
||||
|
||||
```bash
|
||||
# Make sure a Twenty server is running at http://localhost:3000
|
||||
yarn test
|
||||
```
|
||||
|
||||
The test builds and installs the app, then verifies it appears in the applications list. Test configuration (API URL and API key) is defined in `vitest.config.ts`.
|
||||
|
||||
## LLMs instructions
|
||||
|
||||
Main docs and pitfalls are available in LLMS.md file.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Twenty applications, take a look at the following resources:
|
||||
|
||||
- [twenty-sdk](https://www.npmjs.com/package/twenty-sdk) - learn about `twenty-sdk` tool.
|
||||
- [Twenty doc](https://docs.twenty.com/) - Twenty's documentation.
|
||||
- Join our [Discord](https://discord.gg/cx5n4Jzs57)
|
||||
|
||||
You can check out [the Twenty GitHub repository](https://github.com/twentyhq/twenty) - your feedback and contributions are welcome!
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import js from '@eslint/js';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default [
|
||||
// Base JS recommended rules
|
||||
js.configs.recommended,
|
||||
|
||||
// TypeScript recommended rules
|
||||
...tseslint.configs.recommended,
|
||||
|
||||
{
|
||||
files: ['**/*.ts', '**/*.tsx'],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
// Common TypeScript-friendly tweaks
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'warn',
|
||||
{ argsIgnorePattern: '^_' },
|
||||
],
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'no-unused-vars': 'off', // handled by TS rule
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "hello-world",
|
||||
"name": "@twentyhq/hello-world",
|
||||
"version": "0.2.2",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -10,17 +10,20 @@
|
||||
"packageManager": "[email protected]",
|
||||
"scripts": {
|
||||
"twenty": "twenty",
|
||||
"auth": "twenty auth:login",
|
||||
"dev": "twenty app:dev",
|
||||
"build": "twenty app:build",
|
||||
"typecheck": "twenty app:typecheck",
|
||||
"uninstall": "twenty app:uninstall",
|
||||
"entity:add": "twenty entity:add"
|
||||
},
|
||||
"dependencies": {
|
||||
"twenty-sdk": "0.6.3"
|
||||
"lint": "eslint",
|
||||
"lint:fix": "eslint --fix",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.7.2"
|
||||
"@types/node": "^24.7.2",
|
||||
"@types/react": "^18.2.0",
|
||||
"eslint": "^9.32.0",
|
||||
"react": "^18.2.0",
|
||||
"twenty-sdk": "0.6.3",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.50.0",
|
||||
"vite-tsconfig-paths": "^4.2.1",
|
||||
"vitest": "^3.1.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/application-config';
|
||||
import { appBuild, appUninstall } from 'twenty-sdk/cli';
|
||||
import { MetadataApiClient } from 'twenty-sdk/generated';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
const APP_PATH = process.cwd();
|
||||
const TWENTY_API_URL = process.env.TWENTY_API_URL ?? 'http://localhost:3000';
|
||||
|
||||
const assertServerIsReachable = async () => {
|
||||
let response: Response;
|
||||
|
||||
try {
|
||||
response = await fetch(`${TWENTY_API_URL}/healthz`);
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Twenty server is not reachable at ${TWENTY_API_URL}. ` +
|
||||
'Make sure the server is running before executing integration tests.',
|
||||
);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Server at ${TWENTY_API_URL} returned ${response.status}`);
|
||||
}
|
||||
};
|
||||
|
||||
describe('App installation', () => {
|
||||
let appInstalled = false;
|
||||
|
||||
beforeAll(async () => {
|
||||
await assertServerIsReachable();
|
||||
|
||||
const buildResult = await appBuild({
|
||||
appPath: APP_PATH,
|
||||
onProgress: (message: string) => console.log(`[build] ${message}`),
|
||||
});
|
||||
|
||||
if (!buildResult.success) {
|
||||
throw new Error(
|
||||
`App build failed: ${buildResult.error?.message ?? 'Unknown error'}`,
|
||||
);
|
||||
}
|
||||
|
||||
appInstalled = true;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (!appInstalled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const uninstallResult = await appUninstall({ appPath: APP_PATH });
|
||||
|
||||
if (!uninstallResult.success) {
|
||||
console.warn(
|
||||
`App uninstall failed: ${uninstallResult.error?.message ?? 'Unknown error'}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('should find the installed app in the applications list', async () => {
|
||||
const apiKey = process.env.TWENTY_TEST_API_KEY;
|
||||
|
||||
if (!apiKey) {
|
||||
throw new Error(
|
||||
'No API key found. Set TWENTY_TEST_API_KEY in your vitest config env.',
|
||||
);
|
||||
}
|
||||
|
||||
const metadataClient = new MetadataApiClient({
|
||||
url: `${TWENTY_API_URL}/metadata`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await metadataClient.query({
|
||||
findManyApplications: {
|
||||
id: true,
|
||||
name: true,
|
||||
universalIdentifier: true,
|
||||
},
|
||||
});
|
||||
|
||||
const installedApp = result.findManyApplications.find(
|
||||
(application: { universalIdentifier: string }) =>
|
||||
application.universalIdentifier ===
|
||||
APPLICATION_UNIVERSAL_IDENTIFIER,
|
||||
);
|
||||
|
||||
expect(installedApp).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { beforeAll } from 'vitest';
|
||||
|
||||
const TEST_CONFIG_DIR = path.join(os.tmpdir(), '.twenty-sdk-test');
|
||||
|
||||
beforeAll(() => {
|
||||
fs.mkdirSync(TEST_CONFIG_DIR, { recursive: true });
|
||||
|
||||
const configFile = {
|
||||
profiles: {
|
||||
default: {
|
||||
apiUrl: process.env.TWENTY_API_URL,
|
||||
apiKey: process.env.TWENTY_TEST_API_KEY,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(TEST_CONFIG_DIR, 'config.json'),
|
||||
JSON.stringify(configFile, null, 2),
|
||||
);
|
||||
});
|
||||
@@ -1,55 +0,0 @@
|
||||
import {
|
||||
defineLogicFunction,
|
||||
type CronPayload,
|
||||
type DatabaseEventPayload,
|
||||
type ObjectRecordCreateEvent,
|
||||
} from 'twenty-sdk';
|
||||
import { CoreApiClient as Twenty, type CoreSchema } from 'twenty-sdk/generated';
|
||||
|
||||
type CreateNewPostCardParams =
|
||||
| { name?: string }
|
||||
| DatabaseEventPayload<ObjectRecordCreateEvent<CoreSchema.Person>>
|
||||
| CronPayload;
|
||||
|
||||
const handler = async (params: CreateNewPostCardParams) => {
|
||||
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,
|
||||
},
|
||||
},
|
||||
name: true,
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
console.log('createPostCard result', createPostCard);
|
||||
|
||||
return createPostCard;
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
|
||||
name: 'create-new-post-card',
|
||||
timeoutSeconds: 2,
|
||||
handler,
|
||||
httpRouteTriggerSettings: {
|
||||
path: '/post-card/create',
|
||||
httpMethod: 'GET',
|
||||
isAuthRequired: false,
|
||||
},
|
||||
cronTriggerSettings: {
|
||||
pattern: '0 0 1 1 *',
|
||||
},
|
||||
databaseEventTriggerSettings: {
|
||||
eventName: 'person.created',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineApplication } from 'twenty-sdk';
|
||||
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role';
|
||||
|
||||
export const APPLICATION_UNIVERSAL_IDENTIFIER =
|
||||
'1badae7c-8a42-4dea-b4b8-3c56e77c2f9a';
|
||||
|
||||
export default defineApplication({
|
||||
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
|
||||
displayName: 'Hello world',
|
||||
description: '',
|
||||
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
import { defineApplication } from 'twenty-sdk';
|
||||
|
||||
export default defineApplication({
|
||||
universalIdentifier: '4ec0391d-18d5-411c-b2f3-266ddc1c3ef7',
|
||||
displayName: 'Hello World',
|
||||
description: 'A simple hello world app',
|
||||
icon: 'IconWorld',
|
||||
applicationVariables: {
|
||||
DEFAULT_RECIPIENT_NAME: {
|
||||
universalIdentifier: '19e94e59-d4fe-4251-8981-b96d0a9f74de',
|
||||
description: 'Default recipient name for postcards',
|
||||
value: 'Alex Karp',
|
||||
isSecret: false,
|
||||
},
|
||||
},
|
||||
defaultRoleUniversalIdentifier: 'b648f87b-1d26-4961-b974-0908fd991061',
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { defineField, FieldType } from 'twenty-sdk';
|
||||
import { EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/objects/example-object';
|
||||
|
||||
export default defineField({
|
||||
objectUniversalIdentifier: EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
universalIdentifier: '2c503a0d-36c9-49ec-b82f-4fafe0eb6f47',
|
||||
type: FieldType.NUMBER,
|
||||
name: 'priority',
|
||||
label: 'Priority',
|
||||
description: 'Priority level for the example item (1-10)',
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { defineFrontComponent } from 'twenty-sdk';
|
||||
|
||||
export const HelloWorld = () => {
|
||||
return (
|
||||
<div style={{ padding: '20px', fontFamily: 'sans-serif' }}>
|
||||
<h1>Hello, World!</h1>
|
||||
<p>This is your first front component.</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier: '26c17445-fbfb-4b34-99d6-f461e734ca97',
|
||||
name: 'hello-world-front-component',
|
||||
description: 'A sample front component',
|
||||
component: HelloWorld,
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineLogicFunction } from 'twenty-sdk';
|
||||
|
||||
const handler = async (): Promise<{ message: string }> => {
|
||||
return { message: 'Hello, World!' };
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: '4f0b7137-1399-4e50-ac00-3c3bb2555c38',
|
||||
name: 'hello-world-logic-function',
|
||||
description: 'A simple logic function',
|
||||
timeoutSeconds: 5,
|
||||
handler,
|
||||
httpRouteTriggerSettings: {
|
||||
path: '/hello-world-logic-function',
|
||||
httpMethod: 'GET',
|
||||
isAuthRequired: false,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { definePostInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk';
|
||||
|
||||
const handler = async (payload: InstallLogicFunctionPayload): Promise<void> => {
|
||||
console.log('Post install logic function executed successfully!', payload.previousVersion);
|
||||
};
|
||||
|
||||
export default definePostInstallLogicFunction({
|
||||
universalIdentifier: 'c1410017-8536-42aa-a188-4bfc5a1c3dae',
|
||||
name: 'post-install',
|
||||
description: 'Runs after installation to set up the application.',
|
||||
timeoutSeconds: 300,
|
||||
handler,
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { definePreInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk';
|
||||
|
||||
const handler = async (payload: InstallLogicFunctionPayload): Promise<void> => {
|
||||
console.log('Pre install logic function executed successfully!', payload.previousVersion);
|
||||
};
|
||||
|
||||
export default definePreInstallLogicFunction({
|
||||
universalIdentifier: '68d005d4-1110-4fa0-8227-71e06d6b9f30',
|
||||
name: 'pre-install',
|
||||
description: 'Runs before installation to prepare the application.',
|
||||
timeoutSeconds: 300,
|
||||
handler,
|
||||
});
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import { defineNavigationMenuItem } from 'twenty-sdk';
|
||||
|
||||
export default defineNavigationMenuItem({
|
||||
universalIdentifier: '574a895f-1511-4b38-9d28-d6b8436738ff',
|
||||
name: 'example-navigation-menu-item',
|
||||
icon: 'IconList',
|
||||
position: 0,
|
||||
// Link to a view:
|
||||
// viewUniversalIdentifier: '...',
|
||||
// Or link to an object:
|
||||
// targetObjectUniversalIdentifier: '...',
|
||||
// Or link to an external URL:
|
||||
// link: 'https://example.com',
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { defineObject, FieldType } from 'twenty-sdk';
|
||||
|
||||
export const EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER =
|
||||
'b75cfe84-18ce-47da-812a-53e25ee094af';
|
||||
|
||||
export const NAME_FIELD_UNIVERSAL_IDENTIFIER =
|
||||
'6ab9c690-06ce-455e-a2c9-8067a9747f96';
|
||||
|
||||
export default defineObject({
|
||||
universalIdentifier: EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
nameSingular: 'exampleItem',
|
||||
namePlural: 'exampleItems',
|
||||
labelSingular: 'Example item',
|
||||
labelPlural: 'Example items',
|
||||
description: 'A sample custom object',
|
||||
icon: 'IconBox',
|
||||
labelIdentifierFieldMetadataUniversalIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fields: [
|
||||
{
|
||||
universalIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.TEXT,
|
||||
name: 'name',
|
||||
label: 'Name',
|
||||
description: 'Name of the example item',
|
||||
icon: 'IconAbc',
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -1,89 +0,0 @@
|
||||
import { defineObject, FieldType } from 'twenty-sdk';
|
||||
|
||||
const POST_CARD_STATUS = {
|
||||
DRAFT: 'DRAFT',
|
||||
SENT: 'SENT',
|
||||
DELIVERED: 'DELIVERED',
|
||||
RETURNED: 'RETURNED',
|
||||
} as const;
|
||||
|
||||
export default defineObject({
|
||||
universalIdentifier: '54b589ca-eeed-4950-a176-358418b85c05',
|
||||
nameSingular: 'postCard',
|
||||
namePlural: 'postCards',
|
||||
labelSingular: 'Post card',
|
||||
labelPlural: 'Post cards',
|
||||
description: 'A post card object',
|
||||
icon: 'IconMail',
|
||||
fields: [
|
||||
{
|
||||
universalIdentifier: '58a0a314-d7ea-4865-9850-7fb84e72f30b',
|
||||
type: FieldType.TEXT,
|
||||
name: 'content',
|
||||
label: 'Content',
|
||||
description: "Postcard's content",
|
||||
icon: 'IconAbc',
|
||||
},
|
||||
{
|
||||
universalIdentifier: 'c6aa31f3-da76-4ac6-889f-475e226009ac',
|
||||
type: FieldType.FULL_NAME,
|
||||
name: 'recipientName',
|
||||
label: 'Recipient name',
|
||||
icon: 'IconUser',
|
||||
},
|
||||
{
|
||||
universalIdentifier: '95045777-a0ad-49ec-98f9-22f9fc0c8266',
|
||||
type: FieldType.ADDRESS,
|
||||
name: 'recipientAddress',
|
||||
label: 'Recipient address',
|
||||
icon: 'IconHome',
|
||||
},
|
||||
{
|
||||
universalIdentifier: '87b675b8-dd8c-4448-b4ca-20e5a2234a1e',
|
||||
type: FieldType.SELECT,
|
||||
name: 'status',
|
||||
label: 'Status',
|
||||
icon: 'IconSend',
|
||||
defaultValue: `'${POST_CARD_STATUS.DRAFT}'`,
|
||||
options: [
|
||||
{
|
||||
id: 'a1b2c3d4-0001-4000-8000-000000000001',
|
||||
value: POST_CARD_STATUS.DRAFT,
|
||||
label: 'Draft',
|
||||
position: 0,
|
||||
color: 'gray',
|
||||
},
|
||||
{
|
||||
id: 'a1b2c3d4-0002-4000-8000-000000000002',
|
||||
value: POST_CARD_STATUS.SENT,
|
||||
label: 'Sent',
|
||||
position: 1,
|
||||
color: 'orange',
|
||||
},
|
||||
{
|
||||
id: 'a1b2c3d4-0003-4000-8000-000000000003',
|
||||
value: POST_CARD_STATUS.DELIVERED,
|
||||
label: 'Delivered',
|
||||
position: 2,
|
||||
color: 'green',
|
||||
},
|
||||
{
|
||||
id: 'a1b2c3d4-0004-4000-8000-000000000004',
|
||||
value: POST_CARD_STATUS.RETURNED,
|
||||
label: 'Returned',
|
||||
position: 3,
|
||||
color: 'orange',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
universalIdentifier: 'e06abe72-5b44-4e7f-93be-afc185a3c433',
|
||||
type: FieldType.DATE_TIME,
|
||||
name: 'deliveredAt',
|
||||
label: 'Delivered at',
|
||||
icon: 'IconCheck',
|
||||
isNullable: true,
|
||||
defaultValue: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { defineRole } from 'twenty-sdk';
|
||||
|
||||
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER =
|
||||
'f14afc30-f2fa-4f70-9b12-903c5f852225';
|
||||
|
||||
export default defineRole({
|
||||
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
|
||||
label: 'Hello world default function role',
|
||||
description: 'Hello world default function role',
|
||||
canReadAllObjectRecords: true,
|
||||
canUpdateAllObjectRecords: true,
|
||||
canSoftDeleteAllObjectRecords: true,
|
||||
canDestroyAllObjectRecords: false,
|
||||
});
|
||||
@@ -1,33 +0,0 @@
|
||||
import { defineRole, PermissionFlag } from 'twenty-sdk';
|
||||
|
||||
export default defineRole({
|
||||
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,
|
||||
objectPermissions: [
|
||||
{
|
||||
objectUniversalIdentifier: '54b589ca-eeed-4950-a176-358418b85c05',
|
||||
canReadObjectRecords: true,
|
||||
canUpdateObjectRecords: true,
|
||||
canSoftDeleteObjectRecords: false,
|
||||
canDestroyObjectRecords: false,
|
||||
},
|
||||
],
|
||||
fieldPermissions: [
|
||||
{
|
||||
objectUniversalIdentifier: '54b589ca-eeed-4950-a176-358418b85c05',
|
||||
fieldUniversalIdentifier: '58a0a314-d7ea-4865-9850-7fb84e72f30b',
|
||||
canReadFieldValue: false,
|
||||
canUpdateFieldValue: false,
|
||||
},
|
||||
],
|
||||
permissionFlags: [PermissionFlag.APPLICATIONS],
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { defineSkill } from 'twenty-sdk';
|
||||
|
||||
export const EXAMPLE_SKILL_UNIVERSAL_IDENTIFIER =
|
||||
'4f00dd76-c07b-4d55-a43a-7f17e7f6440a';
|
||||
|
||||
export default defineSkill({
|
||||
universalIdentifier: EXAMPLE_SKILL_UNIVERSAL_IDENTIFIER,
|
||||
name: 'example-skill',
|
||||
label: 'Example Skill',
|
||||
description: 'A sample skill for your application',
|
||||
icon: 'IconBrain',
|
||||
content: 'Add your skill instructions here. Skills provide context and capabilities to AI agents.',
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineView } from 'twenty-sdk';
|
||||
import { EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/objects/example-object';
|
||||
|
||||
export default defineView({
|
||||
universalIdentifier: 'e574b32c-c058-492a-8a5c-780b844a8735',
|
||||
name: 'example-view',
|
||||
objectUniversalIdentifier: EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
icon: 'IconList',
|
||||
position: 0,
|
||||
});
|
||||
@@ -5,21 +5,45 @@
|
||||
"declaration": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": ".",
|
||||
"jsx": "react-jsx",
|
||||
"moduleResolution": "node",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"importHelpers": true,
|
||||
"allowUnreachableCode": false,
|
||||
"strictNullChecks": true,
|
||||
"strict": true,
|
||||
"alwaysStrict": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": false,
|
||||
"target": "es2018",
|
||||
"module": "esnext",
|
||||
"lib": ["es2020", "dom"],
|
||||
"lib": [
|
||||
"es2020",
|
||||
"dom"
|
||||
],
|
||||
"skipLibCheck": true,
|
||||
"skipDefaultLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"paths": {
|
||||
"src/*": [
|
||||
"./src/*"
|
||||
],
|
||||
"~/*": [
|
||||
"./*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
|
||||
}
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"**/*.test.ts",
|
||||
"**/*.spec.ts",
|
||||
"**/*.integration-test.ts"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"types": [
|
||||
"vitest/globals"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.tsx"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
tsconfigPaths({
|
||||
projects: ['tsconfig.spec.json'],
|
||||
ignoreConfigErrors: true,
|
||||
}),
|
||||
],
|
||||
test: {
|
||||
testTimeout: 120_000,
|
||||
hookTimeout: 120_000,
|
||||
include: ['src/**/*.integration-test.ts'],
|
||||
setupFiles: ['src/__tests__/setup-test.ts'],
|
||||
env: {
|
||||
TWENTY_API_URL: 'http://localhost:3000',
|
||||
TWENTY_TEST_API_KEY:
|
||||
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik',
|
||||
},
|
||||
},
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn
|
||||
|
||||
# codegen
|
||||
generated
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# dev
|
||||
/dist/
|
||||
|
||||
.twenty/*
|
||||
!.twenty/output/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
|
||||
# Remove once prod ready
|
||||
.twenty
|
||||
@@ -0,0 +1 @@
|
||||
24.5.0
|
||||
@@ -0,0 +1 @@
|
||||
nodeLinker: node-modules
|
||||
@@ -0,0 +1,9 @@
|
||||
## Base documentation
|
||||
|
||||
- Documentation: https://docs.twenty.com/developers/extend/capabilities/apps
|
||||
- Rich app example: https://github.com/twentyhq/twenty/tree/main/packages/twenty-sdk/src/cli/__tests__/apps/rich-app
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- Creating an object without an index view associated. Unless this is a technical object, user will need to visualize it.
|
||||
- Creating a view without a navigationMenuItem associated. This will make the view available on the left sidebar.
|
||||
@@ -0,0 +1,51 @@
|
||||
This is a [Twenty](https://twenty.com) application project bootstrapped with [`create-twenty-app`](https://www.npmjs.com/package/create-twenty-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, authenticate to your workspace:
|
||||
|
||||
```bash
|
||||
yarn twenty auth:login
|
||||
```
|
||||
|
||||
Then, start development mode to sync your app and watch for changes:
|
||||
|
||||
```bash
|
||||
yarn twenty app:dev
|
||||
```
|
||||
|
||||
Open your Twenty instance and go to `/settings/applications` section to see the result.
|
||||
|
||||
## Available Commands
|
||||
|
||||
Run `yarn twenty help` to list all available commands. Common commands:
|
||||
|
||||
```bash
|
||||
# Authentication
|
||||
yarn twenty auth:login # Authenticate with Twenty
|
||||
yarn twenty auth:logout # Remove credentials
|
||||
yarn twenty auth:status # Check auth status
|
||||
yarn twenty auth:switch # Switch default workspace
|
||||
yarn twenty auth:list # List all configured workspaces
|
||||
|
||||
# Application
|
||||
yarn twenty app:dev # Start dev mode (watch, build, sync, and auto-generate typed client)
|
||||
yarn twenty entity:add # Add a new entity (object, field, function, front-component, role, view, navigation-menu-item)
|
||||
yarn twenty function:logs # Stream function logs
|
||||
yarn twenty function:execute # Execute a function with JSON payload
|
||||
yarn twenty app:uninstall # Uninstall app from workspace
|
||||
```
|
||||
|
||||
## LLMs instructions
|
||||
|
||||
Main docs and pitfalls are available in LLMS.md file.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Twenty applications, take a look at the following resources:
|
||||
|
||||
- [twenty-sdk](https://www.npmjs.com/package/twenty-sdk) - learn about `twenty-sdk` tool.
|
||||
- [Twenty doc](https://docs.twenty.com/) - Twenty's documentation.
|
||||
- Join our [Discord](https://discord.gg/cx5n4Jzs57)
|
||||
|
||||
You can check out [the Twenty GitHub repository](https://github.com/twentyhq/twenty) - your feedback and contributions are welcome!
|
||||
@@ -0,0 +1,29 @@
|
||||
import js from '@eslint/js';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default [
|
||||
// Base JS recommended rules
|
||||
js.configs.recommended,
|
||||
|
||||
// TypeScript recommended rules
|
||||
...tseslint.configs.recommended,
|
||||
|
||||
{
|
||||
files: ['**/*.ts', '**/*.tsx'],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
// Common TypeScript-friendly tweaks
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'warn',
|
||||
{ argsIgnorePattern: '^_' },
|
||||
],
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'no-unused-vars': 'off', // handled by TS rule
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "call-recording",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^24.5.0",
|
||||
"npm": "please-use-yarn",
|
||||
"yarn": ">=4.0.2"
|
||||
},
|
||||
"packageManager": "[email protected]",
|
||||
"scripts": {
|
||||
"twenty": "twenty",
|
||||
"lint": "eslint",
|
||||
"lint:fix": "eslint --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.11.1",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"react-loading-skeleton": "^3.5.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"twenty-sdk": "0.6.3-alpha"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.7.2",
|
||||
"@types/react": "^18.2.0",
|
||||
"eslint": "^9.32.0",
|
||||
"react": "^18.2.0",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.50.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role';
|
||||
import { defineApplication } from 'twenty-sdk';
|
||||
|
||||
export default defineApplication({
|
||||
universalIdentifier: '4daa5147-7e70-4e43-b091-c27e1e8a32e3',
|
||||
displayName: 'Call recording',
|
||||
description: 'Allows to record calls',
|
||||
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { SerializedEventData } from 'twenty-sdk/dist/sdk/front-component-api';
|
||||
|
||||
const StyledAudioWrapper = styled.div`
|
||||
background: linear-gradient(135deg, #f8f9fb 0%, #eef0f4 100%);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||
`;
|
||||
|
||||
const StyledAudio = styled.audio`
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
outline: none;
|
||||
|
||||
&::-webkit-media-controls-panel {
|
||||
background: transparent;
|
||||
}
|
||||
`;
|
||||
|
||||
type AudioPlayerProps = {
|
||||
src: string;
|
||||
extension: string;
|
||||
onTimeUpdate?: (currentTimeSeconds: number) => void;
|
||||
};
|
||||
|
||||
export const AudioPlayer = ({
|
||||
src,
|
||||
extension,
|
||||
onTimeUpdate,
|
||||
}: AudioPlayerProps) => {
|
||||
return (
|
||||
<StyledAudioWrapper>
|
||||
<StyledAudio
|
||||
controls
|
||||
onTimeUpdate={(event: unknown) => {
|
||||
const currentTime = (event as CustomEvent<SerializedEventData>)
|
||||
.detail.currentTime;
|
||||
|
||||
if (typeof currentTime === 'number') {
|
||||
onTimeUpdate?.(currentTime);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<source src={src} type={`audio/${extension}`} />
|
||||
</StyledAudio>
|
||||
</StyledAudioWrapper>
|
||||
);
|
||||
};
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
import styled from '@emotion/styled';
|
||||
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
|
||||
|
||||
import {
|
||||
SKELETON_BASE_COLOR,
|
||||
SKELETON_HIGHLIGHT_COLOR,
|
||||
StyledSummarySkeletonContainer,
|
||||
StyledViewerSkeletonContainer,
|
||||
} from 'src/constants/skeleton-constants';
|
||||
|
||||
const StyledMediaSkeletonCard = styled.div`
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||
`;
|
||||
|
||||
const StyledTranscriptSkeletonCard = styled.div`
|
||||
background: #ffffff;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
export const CallRecordingViewerSkeleton = () => {
|
||||
return (
|
||||
<SkeletonTheme
|
||||
baseColor={SKELETON_BASE_COLOR}
|
||||
highlightColor={SKELETON_HIGHLIGHT_COLOR}
|
||||
borderRadius={4}
|
||||
>
|
||||
<StyledViewerSkeletonContainer>
|
||||
<StyledMediaSkeletonCard>
|
||||
<Skeleton height={54} width="100%" borderRadius={0} />
|
||||
</StyledMediaSkeletonCard>
|
||||
<StyledTranscriptSkeletonCard>
|
||||
<StyledSummarySkeletonContainer>
|
||||
<Skeleton height={14} count={4} style={{ marginBottom: 6 }} />
|
||||
<Skeleton height={14} width="60%" />
|
||||
</StyledSummarySkeletonContainer>
|
||||
</StyledTranscriptSkeletonCard>
|
||||
</StyledViewerSkeletonContainer>
|
||||
</SkeletonTheme>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import { AudioPlayer } from 'src/components/AudioPlayer';
|
||||
import { VideoPlayer } from 'src/components/VideoPlayer';
|
||||
import { isAudioExtension } from 'src/utils/is-audio-extension';
|
||||
import { isVideoExtension } from 'src/utils/is-video-extension';
|
||||
|
||||
type MediaPlayerProps = {
|
||||
url: string;
|
||||
extension: string;
|
||||
onTimeUpdate?: (currentTimeSeconds: number) => void;
|
||||
};
|
||||
|
||||
export const MediaPlayer = ({
|
||||
url,
|
||||
extension,
|
||||
onTimeUpdate,
|
||||
}: MediaPlayerProps) => {
|
||||
const normalizedExtension = extension.toLowerCase().replace(/^\./, '');
|
||||
|
||||
if (isAudioExtension(normalizedExtension)) {
|
||||
return (
|
||||
<AudioPlayer
|
||||
src={url}
|
||||
extension={normalizedExtension}
|
||||
onTimeUpdate={onTimeUpdate}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isVideoExtension(normalizedExtension)) {
|
||||
return (
|
||||
<VideoPlayer
|
||||
src={url}
|
||||
extension={normalizedExtension}
|
||||
onTimeUpdate={onTimeUpdate}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error('Unsupported file extension');
|
||||
};
|
||||
@@ -0,0 +1,107 @@
|
||||
import styled from '@emotion/styled';
|
||||
import Markdown from 'react-markdown';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
type SummaryViewerProps = {
|
||||
markdown: string | null | undefined;
|
||||
};
|
||||
|
||||
const StyledSummaryCard = styled.div`
|
||||
background: #ffffff;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const StyledSummaryContent = styled.div`
|
||||
line-height: 1.7;
|
||||
padding: 20px 24px;
|
||||
font-size: 13.5px;
|
||||
color: #333;
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.12);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
margin-top: 1.2em;
|
||||
margin-bottom: 0.5em;
|
||||
color: #1a1a1a;
|
||||
font-weight: 600;
|
||||
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0.6em 0;
|
||||
}
|
||||
|
||||
strong {
|
||||
color: #1a1a1a;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
padding-left: 1.5em;
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
|
||||
code {
|
||||
background-color: rgba(0, 0, 0, 0.04);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.88em;
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
}
|
||||
|
||||
pre {
|
||||
background-color: #f6f8fa;
|
||||
padding: 14px 16px;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
blockquote {
|
||||
border-left: 3px solid #d0d7de;
|
||||
margin: 0.6em 0;
|
||||
padding-left: 1em;
|
||||
color: #57606a;
|
||||
}
|
||||
`;
|
||||
|
||||
export const SummaryViewer = ({ markdown }: SummaryViewerProps) => {
|
||||
if (!isDefined(markdown) || markdown.trim().length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledSummaryCard>
|
||||
<StyledSummaryContent>
|
||||
<Markdown>{markdown}</Markdown>
|
||||
</StyledSummaryContent>
|
||||
</StyledSummaryCard>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import styled from '@emotion/styled';
|
||||
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
|
||||
|
||||
import {
|
||||
SKELETON_BASE_COLOR,
|
||||
SKELETON_HIGHLIGHT_COLOR,
|
||||
StyledSummarySkeletonContainer,
|
||||
} from 'src/constants/skeleton-constants';
|
||||
|
||||
const StyledSkeletonCard = styled.div`
|
||||
background: #ffffff;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
export const SummaryViewerSkeleton = () => {
|
||||
return (
|
||||
<SkeletonTheme
|
||||
baseColor={SKELETON_BASE_COLOR}
|
||||
highlightColor={SKELETON_HIGHLIGHT_COLOR}
|
||||
borderRadius={4}
|
||||
>
|
||||
<StyledSkeletonCard>
|
||||
<StyledSummarySkeletonContainer>
|
||||
<Skeleton height={16} width="40%" />
|
||||
<Skeleton height={14} count={3} style={{ marginBottom: 6 }} />
|
||||
<Skeleton height={16} width="55%" />
|
||||
<Skeleton height={14} count={2} style={{ marginBottom: 6 }} />
|
||||
<Skeleton height={14} width="75%" />
|
||||
</StyledSummarySkeletonContainer>
|
||||
</StyledSkeletonCard>
|
||||
</SkeletonTheme>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,136 @@
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import {
|
||||
type TranscriptEntry,
|
||||
type TranscriptWord,
|
||||
} from 'src/hooks/useTranscript';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
type TranscriptViewerProps = {
|
||||
entries: TranscriptEntry[];
|
||||
currentTimeSeconds: number;
|
||||
};
|
||||
|
||||
const StyledTranscriptCard = styled.div`
|
||||
background: #ffffff;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const StyledTranscriptContent = styled.div`
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
`;
|
||||
|
||||
const StyledEntry = styled.div<{ isActive: boolean }>`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s ease;
|
||||
background-color: ${({ isActive }) =>
|
||||
isActive ? '#f1f1f1' : 'transparent'};
|
||||
|
||||
& + & {
|
||||
margin-top: 2px;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledSpeaker = styled.span`
|
||||
font-weight: 600;
|
||||
color: #333333;
|
||||
font-size: 0.92rem;
|
||||
`;
|
||||
|
||||
const StyledTextContent = styled.div`
|
||||
line-height: 1.5;
|
||||
`;
|
||||
|
||||
const StyledWord = styled.span<{ isSpoken: boolean }>`
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.5;
|
||||
transition: color 0.15s ease;
|
||||
color: ${({ isSpoken }) => (isSpoken ? '#333333' : '#b3b3b3')};
|
||||
`;
|
||||
|
||||
const getEntryTimeRange = (entry: TranscriptEntry) => {
|
||||
const firstWord = entry.words[0];
|
||||
const lastWord = entry.words[entry.words.length - 1];
|
||||
|
||||
const start = firstWord?.start_timestamp?.relative;
|
||||
const end = lastWord?.end_timestamp?.relative;
|
||||
|
||||
return { start, end };
|
||||
};
|
||||
|
||||
const findActiveEntryIndex = (
|
||||
entries: TranscriptEntry[],
|
||||
currentTimeSeconds: number,
|
||||
): number => {
|
||||
for (let index = entries.length - 1; index >= 0; index--) {
|
||||
const { start, end } = getEntryTimeRange(entries[index]);
|
||||
|
||||
if (!isDefined(start) || !isDefined(end)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentTimeSeconds >= start && currentTimeSeconds <= end) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
};
|
||||
|
||||
const isWordSpoken = (
|
||||
word: TranscriptWord,
|
||||
currentTimeSeconds: number,
|
||||
): boolean => {
|
||||
const start = word.start_timestamp?.relative;
|
||||
|
||||
if (!isDefined(start)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return currentTimeSeconds >= start;
|
||||
};
|
||||
|
||||
export const TranscriptViewer = ({
|
||||
entries,
|
||||
currentTimeSeconds,
|
||||
}: TranscriptViewerProps) => {
|
||||
if (entries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeEntryIndex = findActiveEntryIndex(entries, currentTimeSeconds);
|
||||
|
||||
return (
|
||||
<StyledTranscriptCard>
|
||||
<StyledTranscriptContent>
|
||||
{entries.map((entry, index) => {
|
||||
const speaker = entry.participant?.name ?? 'Unknown';
|
||||
const isActive = index === activeEntryIndex;
|
||||
|
||||
return (
|
||||
<StyledEntry key={index} isActive={isActive}>
|
||||
<StyledSpeaker>{speaker}</StyledSpeaker>
|
||||
<StyledTextContent>
|
||||
{entry.words.map((word, wordIndex) => (
|
||||
<StyledWord
|
||||
key={wordIndex}
|
||||
isSpoken={isWordSpoken(word, currentTimeSeconds)}
|
||||
>
|
||||
{wordIndex > 0 ? ' ' : ''}
|
||||
{word.text}
|
||||
</StyledWord>
|
||||
))}
|
||||
</StyledTextContent>
|
||||
</StyledEntry>
|
||||
);
|
||||
})}
|
||||
</StyledTranscriptContent>
|
||||
</StyledTranscriptCard>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { SerializedEventData } from 'twenty-sdk/dist/sdk/front-component-api';
|
||||
|
||||
const StyledVideoWrapper = styled.div`
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||
background: #000;
|
||||
`;
|
||||
|
||||
const StyledVideo = styled.video`
|
||||
width: 100%;
|
||||
display: block;
|
||||
`;
|
||||
|
||||
type VideoPlayerProps = {
|
||||
src: string;
|
||||
extension: string;
|
||||
onTimeUpdate?: (currentTimeSeconds: number) => void;
|
||||
};
|
||||
|
||||
export const VideoPlayer = ({
|
||||
src,
|
||||
extension,
|
||||
onTimeUpdate,
|
||||
}: VideoPlayerProps) => {
|
||||
return (
|
||||
<StyledVideoWrapper>
|
||||
<StyledVideo
|
||||
controls
|
||||
onTimeUpdate={(event: unknown) => {
|
||||
const currentTime = (event as CustomEvent<SerializedEventData>)
|
||||
.detail.currentTime;
|
||||
|
||||
if (typeof currentTime === 'number') {
|
||||
onTimeUpdate?.(currentTime);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<source src={src} type={`video/${extension}`} />
|
||||
</StyledVideo>
|
||||
</StyledVideoWrapper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
export const AUDIO_EXTENSIONS = [
|
||||
'mp3',
|
||||
'wav',
|
||||
'ogg',
|
||||
'aac',
|
||||
'flac',
|
||||
'webm',
|
||||
] as const;
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export const CALL_RECORDING_SUMMARY_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
|
||||
'43aae2d4-396a-4c5e-9f45-0162a2904825';
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export const CALL_RECORDING_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
|
||||
'9f3bbb39-042d-4216-b8fc-bedfc3487208';
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
export const SEED_CALL_RECORDINGS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
|
||||
'0894353c-86d2-484c-84f2-802cf5c4d22b';
|
||||
|
||||
export const SEED_CALL_RECORDINGS_COMMAND_UNIVERSAL_IDENTIFIER =
|
||||
'e0e5b7a1-d472-4897-88d0-ce5f1bf6a5ea';
|
||||
@@ -0,0 +1,24 @@
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
export const SKELETON_BASE_COLOR = '#f0f1f3';
|
||||
export const SKELETON_HIGHLIGHT_COLOR = '#f8f9fb';
|
||||
|
||||
export const StyledSummarySkeletonContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 20px 24px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
`;
|
||||
|
||||
export const StyledViewerSkeletonContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
padding: 20px;
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
`;
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
export const SUMMARIZE_PERSON_RECORDINGS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
|
||||
'958c796e-baf0-472c-838f-8d0a7f572774';
|
||||
|
||||
export const SUMMARIZE_PERSON_RECORDINGS_COMMAND_UNIVERSAL_IDENTIFIER =
|
||||
'30e73886-18e7-476f-9c55-b19c59397a81';
|
||||
@@ -0,0 +1,8 @@
|
||||
export const VIDEO_EXTENSIONS = [
|
||||
'mp4',
|
||||
'webm',
|
||||
'ogv',
|
||||
'avi',
|
||||
'mov',
|
||||
'mkv',
|
||||
] as const;
|
||||
@@ -0,0 +1,574 @@
|
||||
export type MockCallRecording = {
|
||||
name: string;
|
||||
createdAt: string;
|
||||
endedAt: string;
|
||||
status: 'ENDED';
|
||||
transcript: { blocknote: null; markdown: string };
|
||||
summary: { blocknote: null; markdown: string };
|
||||
};
|
||||
|
||||
export const MOCK_CALL_RECORDINGS: MockCallRecording[] = [
|
||||
{
|
||||
name: 'Call Sarah Chen / Mike Johnson',
|
||||
createdAt: '2025-01-08T10:00:00.000Z',
|
||||
endedAt: '2025-01-08T10:32:00.000Z',
|
||||
status: 'ENDED',
|
||||
transcript: {
|
||||
blocknote: null,
|
||||
markdown: [
|
||||
'**Mike Johnson:** Hi Sarah, thanks for taking the time today. I wanted to walk you through how we handle pipeline management and see if there might be a fit for your team.',
|
||||
'**Sarah Chen:** Of course, Mike. We\'ve been looking at several CRM options. Right now we\'re using spreadsheets and it\'s becoming unmanageable with the team growing.',
|
||||
'**Mike Johnson:** That\'s a common pain point. How many reps are on your team currently?',
|
||||
'**Sarah Chen:** We have twelve sales reps and three managers. The biggest issue is visibility. Managers can\'t see deal progress in real time, and reps are spending too much time on data entry.',
|
||||
'**Mike Johnson:** I hear that a lot. One thing that sets us apart is our approach to reducing manual entry. We automatically capture emails, meeting notes, and even call data. Would that address your main concern?',
|
||||
'**Sarah Chen:** That would be huge. Our reps probably spend an hour a day just logging activities. What about reporting? We need weekly pipeline reviews with accurate forecasting.',
|
||||
'**Mike Johnson:** Absolutely. I can set up a demo environment for your team. We have built-in forecasting that uses historical win rates and deal velocity. Should we schedule a more in-depth demo with your managers next week?',
|
||||
'**Sarah Chen:** Yes, let\'s do that. Tuesday or Wednesday afternoon would work best for us.',
|
||||
].join('\n\n'),
|
||||
},
|
||||
summary: {
|
||||
blocknote: null,
|
||||
markdown: [
|
||||
'## Overview',
|
||||
'Initial discovery call with Sarah Chen (VP Sales at prospect company) to assess CRM needs and pipeline management requirements.',
|
||||
'',
|
||||
'## Key Discussion Points',
|
||||
'- Current setup: spreadsheets, 12 reps + 3 managers',
|
||||
'- Main pain points: lack of real-time visibility, excessive manual data entry (~1 hour/day per rep)',
|
||||
'- Interest in automated activity capture (emails, meetings, calls)',
|
||||
'- Need for weekly pipeline reporting and accurate forecasting',
|
||||
'',
|
||||
'## Action Items',
|
||||
'- [ ] Schedule in-depth demo with Sarah\'s managers for Tuesday or Wednesday afternoon',
|
||||
'- [ ] Prepare demo environment with pipeline forecasting features',
|
||||
'- [ ] Send follow-up email with product overview deck',
|
||||
].join('\n'),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Call David Park / Emily Rivera',
|
||||
createdAt: '2025-01-22T14:30:00.000Z',
|
||||
endedAt: '2025-01-22T15:05:00.000Z',
|
||||
status: 'ENDED',
|
||||
transcript: {
|
||||
blocknote: null,
|
||||
markdown: [
|
||||
'**Emily Rivera:** David, good to connect again. I wanted to follow up on the demo we did last week. How did the team feel about it?',
|
||||
'**David Park:** The feedback was really positive overall. The interface is clean and the automation features impressed everyone. A couple of questions came up though.',
|
||||
'**Emily Rivera:** Great to hear! What questions did the team have?',
|
||||
'**David Park:** First, our legal team wants to know about data residency. We need our data hosted in the EU. Second, we\'re wondering about the integration with Salesforce — we still have some legacy data there.',
|
||||
'**Emily Rivera:** Both great questions. We offer EU data residency with our Business plan. For Salesforce, we have a native migration tool that can import your historical data including contacts, deals, and activities. It typically takes about a day for a dataset your size.',
|
||||
'**David Park:** That\'s reassuring. And what about pricing for our team size? We\'d be looking at about forty users.',
|
||||
'**Emily Rivera:** For forty users on the Business plan with EU residency, I can put together a custom proposal. We also offer annual billing discounts. Let me send that over by Friday.',
|
||||
'**David Park:** Perfect. If the pricing works, I think we\'re ready to move forward. We\'d want to start onboarding in March.',
|
||||
].join('\n\n'),
|
||||
},
|
||||
summary: {
|
||||
blocknote: null,
|
||||
markdown: [
|
||||
'## Overview',
|
||||
'Follow-up call with David Park post-demo to address team feedback and move toward closing.',
|
||||
'',
|
||||
'## Key Discussion Points',
|
||||
'- Demo feedback was positive across the team',
|
||||
'- EU data residency requirement — available on Business plan',
|
||||
'- Salesforce migration needed for legacy data (contacts, deals, activities)',
|
||||
'- Team size: ~40 users',
|
||||
'- Target onboarding start: March',
|
||||
'',
|
||||
'## Action Items',
|
||||
'- [ ] Send custom pricing proposal for 40 users on Business plan by Friday',
|
||||
'- [ ] Include EU data residency details and Salesforce migration timeline',
|
||||
'- [ ] Prepare onboarding plan for March start',
|
||||
].join('\n'),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Call Lisa Wong / James Martinez',
|
||||
createdAt: '2025-02-05T09:00:00.000Z',
|
||||
endedAt: '2025-02-05T09:28:00.000Z',
|
||||
status: 'ENDED',
|
||||
transcript: {
|
||||
blocknote: null,
|
||||
markdown: [
|
||||
'**James Martinez:** Lisa, thanks for joining. This is your first quarterly review since onboarding. How has the first month been going?',
|
||||
'**Lisa Wong:** It\'s been great honestly. The team adopted it much faster than I expected. We\'re already seeing about a thirty percent reduction in time spent on data entry.',
|
||||
'**James Martinez:** That\'s fantastic. Are there any areas where you feel we could improve the experience?',
|
||||
'**Lisa Wong:** One thing that came up is custom fields. We have some industry-specific data points we track — like compliance status and license numbers — and we\'d like to add those as fields on our contact records.',
|
||||
'**James Martinez:** You can absolutely do that. I\'ll send you a guide on creating custom fields. You can add text, dropdown, date, or number fields to any object. Any other requests?',
|
||||
'**Lisa Wong:** Our marketing team is asking about the API. They want to push lead data from our website forms directly into the CRM.',
|
||||
'**James Martinez:** Our REST API and GraphQL API both support that. I\'ll connect you with our developer relations team for a quick walkthrough. They can have your marketing team set up in about an hour.',
|
||||
].join('\n\n'),
|
||||
},
|
||||
summary: {
|
||||
blocknote: null,
|
||||
markdown: [
|
||||
'## Overview',
|
||||
'First quarterly review with Lisa Wong, one month post-onboarding. Team adoption is strong with measurable productivity gains.',
|
||||
'',
|
||||
'## Key Discussion Points',
|
||||
'- 30% reduction in data entry time reported',
|
||||
'- Need for custom fields: compliance status, license numbers',
|
||||
'- Marketing team interest in API integration for website lead forms',
|
||||
'',
|
||||
'## Action Items',
|
||||
'- [ ] Send custom fields documentation to Lisa',
|
||||
'- [ ] Connect Lisa\'s marketing team with developer relations for API walkthrough',
|
||||
'- [ ] Schedule next quarterly review for May',
|
||||
].join('\n'),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Call Robert Taylor / Anna Schmidt',
|
||||
createdAt: '2025-02-19T16:00:00.000Z',
|
||||
endedAt: '2025-02-19T16:45:00.000Z',
|
||||
status: 'ENDED',
|
||||
transcript: {
|
||||
blocknote: null,
|
||||
markdown: [
|
||||
'**Anna Schmidt:** Robert, I appreciate you making time. I wanted to understand your current sales process before we put together a proposal.',
|
||||
'**Robert Taylor:** Sure. We\'re a B2B SaaS company, about two hundred employees. Our sales cycle is typically three to six months. We have SDRs doing outbound, AEs closing, and a small customer success team.',
|
||||
'**Anna Schmidt:** What tools are you using today across those teams?',
|
||||
'**Robert Taylor:** It\'s a mess honestly. SDRs use one tool for outreach, AEs use a different CRM, and customer success has their own platform. Nothing talks to each other.',
|
||||
'**Anna Schmidt:** That fragmentation is really common. How does it impact your day-to-day?',
|
||||
'**Robert Taylor:** The biggest issue is handoffs. When an SDR qualifies a lead and passes it to an AE, context gets lost. Same thing when a deal closes and moves to customer success. We lose notes, meeting history, everything.',
|
||||
'**Anna Schmidt:** That\'s exactly what we solve. A single platform for the entire customer lifecycle — from first touch through renewal. All the context travels with the record. Let me show you a quick overview of how that handoff looks in practice.',
|
||||
'**Robert Taylor:** That would be great. And can you also show me how your workflow automation works? We want to automate some of our internal notifications and task assignments.',
|
||||
].join('\n\n'),
|
||||
},
|
||||
summary: {
|
||||
blocknote: null,
|
||||
markdown: [
|
||||
'## Overview',
|
||||
'Discovery call with Robert Taylor (B2B SaaS, ~200 employees) to map current sales process and identify pain points.',
|
||||
'',
|
||||
'## Key Discussion Points',
|
||||
'- Sales cycle: 3-6 months with SDR → AE → CS handoffs',
|
||||
'- Tool fragmentation: separate tools for outreach, CRM, and customer success',
|
||||
'- Critical pain: context loss during handoffs (notes, meeting history)',
|
||||
'- Interest in workflow automation for notifications and task assignments',
|
||||
'',
|
||||
'## Action Items',
|
||||
'- [ ] Prepare demo focused on lifecycle handoff workflows',
|
||||
'- [ ] Include workflow automation examples for internal notifications',
|
||||
'- [ ] Schedule demo for next week',
|
||||
].join('\n'),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Call Priya Patel / Tom Wilson',
|
||||
createdAt: '2025-03-12T11:00:00.000Z',
|
||||
endedAt: '2025-03-12T11:38:00.000Z',
|
||||
status: 'ENDED',
|
||||
transcript: {
|
||||
blocknote: null,
|
||||
markdown: [
|
||||
'**Tom Wilson:** Hi Priya, I wanted to touch base about the pricing proposal we sent over. Have you had a chance to review it with your CFO?',
|
||||
'**Priya Patel:** Yes, we went through it yesterday. The per-seat pricing is within our budget, but we\'re concerned about the implementation cost. Fifty thousand for onboarding feels steep.',
|
||||
'**Tom Wilson:** I understand. That fee covers dedicated onboarding support, data migration from your three existing systems, custom training sessions, and sixty days of post-launch support. What would make it work for your budget?',
|
||||
'**Priya Patel:** If we could break the implementation into two phases, that would help. Phase one would be the core sales team migration, and phase two would be marketing and customer success. That way we spread the cost over two quarters.',
|
||||
'**Tom Wilson:** We can absolutely structure it that way. In fact, phased rollouts often lead to better adoption. We could do phase one for thirty thousand and phase two for twenty-five, with phase two starting three months later.',
|
||||
'**Priya Patel:** That works much better. I think we can get sign-off on that. Can you send a revised proposal with those terms?',
|
||||
'**Tom Wilson:** I\'ll have it in your inbox by end of day. If everything looks good, we could target April first for kickoff.',
|
||||
'**Priya Patel:** Let\'s plan on that. I\'ll schedule an internal alignment meeting for Friday to finalize.',
|
||||
].join('\n\n'),
|
||||
},
|
||||
summary: {
|
||||
blocknote: null,
|
||||
markdown: [
|
||||
'## Overview',
|
||||
'Pricing negotiation call with Priya Patel. Agreed on a phased implementation to fit budget constraints.',
|
||||
'',
|
||||
'## Key Discussion Points',
|
||||
'- Per-seat pricing approved, implementation cost ($50K) was a concern',
|
||||
'- Agreed on phased approach: Phase 1 ($30K, core sales) + Phase 2 ($25K, marketing & CS)',
|
||||
'- Phase 2 starts 3 months after Phase 1',
|
||||
'- Target kickoff: April 1st',
|
||||
'',
|
||||
'## Action Items',
|
||||
'- [ ] Send revised proposal with phased implementation terms by end of day',
|
||||
'- [ ] Prepare Phase 1 kickoff plan targeting April 1st',
|
||||
'- [ ] Priya to schedule internal alignment meeting for Friday',
|
||||
].join('\n'),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Call Marcus Lee / Sophie Dubois',
|
||||
createdAt: '2025-04-03T13:00:00.000Z',
|
||||
endedAt: '2025-04-03T13:22:00.000Z',
|
||||
status: 'ENDED',
|
||||
transcript: {
|
||||
blocknote: null,
|
||||
markdown: [
|
||||
'**Sophie Dubois:** Marcus, welcome to your onboarding kickoff. I\'m your dedicated customer success manager and I\'ll be guiding you through the setup process over the next four weeks.',
|
||||
'**Marcus Lee:** Great, the team is excited to get started. We have twenty users ready to go on day one.',
|
||||
'**Sophie Dubois:** Perfect. Let me walk you through the onboarding timeline. Week one is data migration and system configuration. Week two is user training. Week three is a guided pilot where your team uses it in parallel with your old system. Week four is full cutover and go-live.',
|
||||
'**Marcus Lee:** That sounds structured. For the data migration, we have about fifty thousand contacts and ten thousand deals in our current system. Is that going to be an issue?',
|
||||
'**Sophie Dubois:** Not at all. That\'s well within our standard migration capacity. I\'ll need your team to export the data in CSV format and I\'ll handle the mapping and import. We typically complete migrations of that size in two to three days.',
|
||||
'**Marcus Lee:** And what about our custom deal stages? We have a pretty specific pipeline with eight stages.',
|
||||
'**Sophie Dubois:** We\'ll configure that during week one. You can have as many stages as you need, and we can set up automation rules for each transition. I\'ll send you a configuration worksheet to fill out before our next session.',
|
||||
].join('\n\n'),
|
||||
},
|
||||
summary: {
|
||||
blocknote: null,
|
||||
markdown: [
|
||||
'## Overview',
|
||||
'Onboarding kickoff with Marcus Lee. Covered the 4-week timeline and initial setup requirements.',
|
||||
'',
|
||||
'## Key Discussion Points',
|
||||
'- 20 users ready for day one',
|
||||
'- Data migration: ~50K contacts, ~10K deals (CSV export needed)',
|
||||
'- Custom pipeline: 8 deal stages with automation rules',
|
||||
'- 4-week onboarding plan: migration → training → pilot → go-live',
|
||||
'',
|
||||
'## Action Items',
|
||||
'- [ ] Send configuration worksheet for custom pipeline stages',
|
||||
'- [ ] Marcus to prepare CSV exports of contacts and deals',
|
||||
'- [ ] Schedule week 1 check-in for data migration review',
|
||||
].join('\n'),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Call Jennifer Brooks / Carlos Mendez',
|
||||
createdAt: '2025-05-14T15:30:00.000Z',
|
||||
endedAt: '2025-05-14T16:02:00.000Z',
|
||||
status: 'ENDED',
|
||||
transcript: {
|
||||
blocknote: null,
|
||||
markdown: [
|
||||
'**Carlos Mendez:** Jennifer, thanks for the demo request. I see you\'re interested in our analytics and reporting capabilities. Can you tell me what you\'re looking for specifically?',
|
||||
'**Jennifer Brooks:** We need better visibility into rep performance. Right now I\'m pulling data from three different sources to build my weekly report. It takes me half a day every Monday.',
|
||||
'**Carlos Mendez:** That\'s painful. What metrics do you track in those reports?',
|
||||
'**Jennifer Brooks:** Calls made, emails sent, meetings booked, pipeline generated, deals closed, and average deal size. I also need to compare rep-to-rep and track trends over time.',
|
||||
'**Carlos Mendez:** All of those are available out of the box in our analytics dashboard. Let me share my screen and show you. Here you can see a real-time dashboard with all those metrics. You can filter by rep, team, date range, and even deal stage.',
|
||||
'**Jennifer Brooks:** Oh wow, that\'s exactly what I need. Can I schedule these reports to be sent automatically?',
|
||||
'**Carlos Mendez:** Yes, you can set up scheduled email reports — daily, weekly, or monthly. You can also create custom dashboards and share them with your team or leadership. Each person only sees data they have access to.',
|
||||
'**Jennifer Brooks:** This would save me so much time. What does pricing look like for a team of twenty-five?',
|
||||
].join('\n\n'),
|
||||
},
|
||||
summary: {
|
||||
blocknote: null,
|
||||
markdown: [
|
||||
'## Overview',
|
||||
'Demo call with Jennifer Brooks focused on analytics and reporting capabilities. Strong interest in automated reporting.',
|
||||
'',
|
||||
'## Key Discussion Points',
|
||||
'- Current report building takes half a day weekly across 3 data sources',
|
||||
'- Key metrics: calls, emails, meetings, pipeline, deals closed, avg deal size',
|
||||
'- Rep-to-rep comparison and trend tracking required',
|
||||
'- Demonstrated real-time dashboard, scheduled reports, and custom dashboards',
|
||||
'',
|
||||
'## Action Items',
|
||||
'- [ ] Send pricing proposal for 25-user team',
|
||||
'- [ ] Share sample analytics dashboard templates',
|
||||
'- [ ] Schedule follow-up to review proposal with Jennifer\'s leadership',
|
||||
].join('\n'),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Call Alex Nguyen / Rachel Foster',
|
||||
createdAt: '2025-06-20T10:00:00.000Z',
|
||||
endedAt: '2025-06-20T10:41:00.000Z',
|
||||
status: 'ENDED',
|
||||
transcript: {
|
||||
blocknote: null,
|
||||
markdown: [
|
||||
'**Rachel Foster:** Alex, this is your six-month review. Let\'s go over the numbers. How are things going since you fully migrated in January?',
|
||||
'**Alex Nguyen:** Really well. Our sales cycle has shortened by about twenty percent. Reps are closing deals faster because they have all the context in one place.',
|
||||
'**Rachel Foster:** That\'s a significant improvement. What about adoption? Are all teams using the platform regularly?',
|
||||
'**Alex Nguyen:** The sales team is fully on board, probably ninety-five percent daily usage. Marketing is at about eighty percent. The one area where we\'re struggling is getting our field sales team to use the mobile app consistently.',
|
||||
'**Rachel Foster:** Mobile adoption is often the trickiest. We recently launched offline mode which helps a lot for field reps with spotty connectivity. I can set up a quick training session specifically for your field team.',
|
||||
'**Alex Nguyen:** That would be great. Also, we\'re looking at expanding to our APAC team next quarter. That would add about thirty more users. What does that look like from a licensing perspective?',
|
||||
'**Rachel Foster:** I\'ll work with your account executive to prepare an expansion quote. We can usually offer volume discounts when scaling beyond fifty users. I\'ll have that ready for your budget planning meeting.',
|
||||
'**Alex Nguyen:** Perfect timing. Our planning cycle starts in August so if we can have numbers by mid-July that would be ideal.',
|
||||
].join('\n\n'),
|
||||
},
|
||||
summary: {
|
||||
blocknote: null,
|
||||
markdown: [
|
||||
'## Overview',
|
||||
'Six-month review with Alex Nguyen. Strong results with 20% shorter sales cycles. Planning APAC expansion.',
|
||||
'',
|
||||
'## Key Discussion Points',
|
||||
'- Sales cycle reduced by 20% since migration',
|
||||
'- Adoption: 95% daily (sales), 80% (marketing), field team needs mobile training',
|
||||
'- New offline mode could help field sales adoption',
|
||||
'- APAC expansion planned: +30 users next quarter',
|
||||
'',
|
||||
'## Action Items',
|
||||
'- [ ] Schedule mobile app training for field sales team',
|
||||
'- [ ] Prepare APAC expansion quote with volume discounts by mid-July',
|
||||
'- [ ] Connect with account executive on expansion pricing',
|
||||
].join('\n'),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Call Kevin O\'Brien / Maria Santos',
|
||||
createdAt: '2025-07-09T14:00:00.000Z',
|
||||
endedAt: '2025-07-09T14:35:00.000Z',
|
||||
status: 'ENDED',
|
||||
transcript: {
|
||||
blocknote: null,
|
||||
markdown: [
|
||||
'**Maria Santos:** Kevin, I understand your team is evaluating us alongside two other CRM vendors. I\'d love to understand what criteria are most important to you.',
|
||||
'**Kevin O\'Brien:** Right, we\'re in the final stages of evaluation. The three biggest factors for us are ease of use, integration ecosystem, and total cost of ownership over three years.',
|
||||
'**Maria Santos:** Makes sense. On ease of use, our average onboarding time is two weeks and we consistently score highest in user satisfaction surveys. What integrations are most critical for you?',
|
||||
'**Kevin O\'Brien:** We need Slack, Google Workspace, Zoom, and our billing system which runs on Stripe. We also use Notion for internal documentation.',
|
||||
'**Maria Santos:** All of those have native integrations except Notion, which we support through our Zapier and Make connectors. For Stripe, our integration syncs billing data bidirectionally so your sales team can see payment status directly on the deal record.',
|
||||
'**Kevin O\'Brien:** The Stripe integration is a big differentiator actually. The other vendors we\'re looking at don\'t offer that natively. What about the three-year cost comparison?',
|
||||
'**Maria Santos:** I\'ll put together a total cost of ownership analysis that includes licensing, implementation, training, and ongoing support. We\'re typically fifteen to twenty percent lower than our main competitors when you factor in everything.',
|
||||
'**Kevin O\'Brien:** Send that over and I\'ll present it to our executive team next Thursday. We\'re planning to make a decision by end of month.',
|
||||
].join('\n\n'),
|
||||
},
|
||||
summary: {
|
||||
blocknote: null,
|
||||
markdown: [
|
||||
'## Overview',
|
||||
'Competitive evaluation call with Kevin O\'Brien. In final stages, comparing against two other vendors.',
|
||||
'',
|
||||
'## Key Discussion Points',
|
||||
'- Evaluation criteria: ease of use, integrations, 3-year TCO',
|
||||
'- Required integrations: Slack, Google Workspace, Zoom, Stripe (native), Notion (via Zapier/Make)',
|
||||
'- Stripe bidirectional sync is a key differentiator vs competitors',
|
||||
'- Decision timeline: end of month, executive presentation next Thursday',
|
||||
'',
|
||||
'## Action Items',
|
||||
'- [ ] Prepare 3-year TCO analysis vs competitors',
|
||||
'- [ ] Send integration ecosystem overview document',
|
||||
'- [ ] Follow up before Thursday executive presentation',
|
||||
].join('\n'),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Call Diana Hughes / Ryan Cooper',
|
||||
createdAt: '2025-08-18T09:30:00.000Z',
|
||||
endedAt: '2025-08-18T10:05:00.000Z',
|
||||
status: 'ENDED',
|
||||
transcript: {
|
||||
blocknote: null,
|
||||
markdown: [
|
||||
'**Ryan Cooper:** Diana, I noticed some of your team\'s usage metrics dropped last month. Everything okay?',
|
||||
'**Diana Hughes:** Honestly, we\'ve been struggling with a few things. The email sync stopped working for about half our team two weeks ago and we haven\'t been able to figure out why.',
|
||||
'**Ryan Cooper:** I\'m sorry to hear that. Let me look into this right now. Can you tell me which email provider you\'re using?',
|
||||
'**Diana Hughes:** We\'re on Microsoft 365. It was working fine and then suddenly half the team\'s emails stopped syncing. The other half is still fine.',
|
||||
'**Ryan Cooper:** I think I see the issue. Microsoft recently changed their OAuth token refresh policy. The affected users likely need to re-authenticate. I\'ll send you a step-by-step guide. It should take each user about two minutes.',
|
||||
'**Diana Hughes:** Okay that\'s a relief, I was worried it was something bigger. The other thing is we need better support response times. We submitted a ticket about this five days ago and didn\'t hear back until yesterday.',
|
||||
'**Ryan Cooper:** That\'s not acceptable and I apologize. I\'m escalating this with our support team. For your account, I\'m also going to set up a dedicated Slack channel so you can reach me or someone on my team directly for urgent issues.',
|
||||
'**Diana Hughes:** That would make a huge difference. We need to be able to get help quickly when something breaks.',
|
||||
].join('\n\n'),
|
||||
},
|
||||
summary: {
|
||||
blocknote: null,
|
||||
markdown: [
|
||||
'## Overview',
|
||||
'Issue resolution call with Diana Hughes regarding email sync failures and support response times.',
|
||||
'',
|
||||
'## Key Discussion Points',
|
||||
'- Email sync broken for ~50% of team (Microsoft 365, OAuth token refresh issue)',
|
||||
'- Support ticket response took 5 days — unacceptable',
|
||||
'- Setting up dedicated Slack channel for urgent issues',
|
||||
'',
|
||||
'## Risks',
|
||||
'- Customer satisfaction at risk due to slow support response',
|
||||
'- Usage metrics declining — need to restore confidence quickly',
|
||||
'',
|
||||
'## Action Items',
|
||||
'- [ ] Send OAuth re-authentication guide to Diana immediately',
|
||||
'- [ ] Escalate support response time issue internally',
|
||||
'- [ ] Set up dedicated Slack channel for Diana\'s team',
|
||||
'- [ ] Follow up in 48 hours to confirm email sync is restored',
|
||||
].join('\n'),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Call Nathan Kim / Laura Chen / Steve Morris',
|
||||
createdAt: '2025-09-25T16:00:00.000Z',
|
||||
endedAt: '2025-09-25T16:42:00.000Z',
|
||||
status: 'ENDED',
|
||||
transcript: {
|
||||
blocknote: null,
|
||||
markdown: [
|
||||
'**Steve Morris:** Alright team, let\'s do our monthly pipeline review. Nathan, kick us off with the numbers.',
|
||||
'**Nathan Kim:** Sure. Total pipeline is at two point four million, up twelve percent from last month. We have eight deals in the negotiation stage totaling about nine hundred K. Three of those should close this month.',
|
||||
'**Laura Chen:** Which three are you most confident about?',
|
||||
'**Nathan Kim:** Meridian Corp at three hundred and twenty K — contract is out for signature. TechFlow at two hundred and ten K — verbal agreement, just working through procurement. And BrightPath at one hundred and fifty K — they want to start before their fiscal year end on October fifteenth.',
|
||||
'**Steve Morris:** Good. What about the other five? Any at risk?',
|
||||
'**Nathan Kim:** Two are solid but won\'t close until November. The other three I\'m worried about. DataSync keeps pushing their timeline back and I think they might be evaluating a competitor.',
|
||||
'**Laura Chen:** Let me jump on a call with the DataSync champion this week. I have a relationship there from a previous company. Maybe I can help move things forward.',
|
||||
'**Steve Morris:** Great idea. Nathan, can you also do a deep dive on our Q4 pipeline generation? We need to make sure we\'re building enough top-of-funnel to hit our annual target.',
|
||||
].join('\n\n'),
|
||||
},
|
||||
summary: {
|
||||
blocknote: null,
|
||||
markdown: [
|
||||
'## Overview',
|
||||
'Internal monthly pipeline review. Pipeline at $2.4M (+12% MoM), 8 deals in negotiation stage.',
|
||||
'',
|
||||
'## Key Discussion Points',
|
||||
'- 3 deals expected to close this month: Meridian ($320K), TechFlow ($210K), BrightPath ($150K)',
|
||||
'- 2 deals solid for November close',
|
||||
'- 3 deals at risk, especially DataSync (may be evaluating competitor)',
|
||||
'- Need to build Q4 top-of-funnel pipeline',
|
||||
'',
|
||||
'## Action Items',
|
||||
'- [ ] Laura to contact DataSync champion this week',
|
||||
'- [ ] Nathan to prepare Q4 pipeline generation analysis',
|
||||
'- [ ] Follow up on Meridian contract signature',
|
||||
'- [ ] Track BrightPath against Oct 15 fiscal year deadline',
|
||||
].join('\n'),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Call Amanda Price / Ben Watson',
|
||||
createdAt: '2025-10-30T11:00:00.000Z',
|
||||
endedAt: '2025-10-30T11:25:00.000Z',
|
||||
status: 'ENDED',
|
||||
transcript: {
|
||||
blocknote: null,
|
||||
markdown: [
|
||||
'**Ben Watson:** Amanda, thanks for your interest. I understand you\'re a startup looking for your first CRM. Tell me about your team.',
|
||||
'**Amanda Price:** We\'re a Series A startup, fifteen people total. Five of us are in go-to-market roles. We\'ve been tracking everything in Airtable and Google Sheets but we need something purpose-built as we scale.',
|
||||
'**Ben Watson:** What\'s your growth plan? Understanding your trajectory helps me recommend the right package.',
|
||||
'**Amanda Price:** We plan to triple the sales team by end of next year. So we need something that can grow with us without breaking the bank in the early days.',
|
||||
'**Ben Watson:** Our startup program is designed exactly for that. You\'d get our full platform at a seventy percent discount for the first year, then fifty percent off the second year, with a gradual step-up to standard pricing.',
|
||||
'**Amanda Price:** That\'s compelling. What\'s the catch? Do we lose any features on the startup plan?',
|
||||
'**Ben Watson:** No catch — full feature parity. The only difference is the per-seat price. You also get priority onboarding support included. We want to grow with you.',
|
||||
'**Amanda Price:** I love that approach. Can you send me the startup program details? I\'ll review it with my co-founder this week.',
|
||||
].join('\n\n'),
|
||||
},
|
||||
summary: {
|
||||
blocknote: null,
|
||||
markdown: [
|
||||
'## Overview',
|
||||
'Discovery call with Amanda Price, Series A startup looking for first CRM. Good fit for startup program.',
|
||||
'',
|
||||
'## Key Discussion Points',
|
||||
'- Series A, 15 people, 5 in GTM roles',
|
||||
'- Currently using Airtable + Google Sheets',
|
||||
'- Plan to 3x sales team by end of next year',
|
||||
'- Startup program: 70% off Y1, 50% off Y2, full features, priority onboarding',
|
||||
'',
|
||||
'## Action Items',
|
||||
'- [ ] Send startup program details and application form',
|
||||
'- [ ] Amanda to review with co-founder this week',
|
||||
'- [ ] Schedule follow-up call for next week',
|
||||
].join('\n'),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Call Chris Yamamoto / Olivia Barrett',
|
||||
createdAt: '2025-12-03T10:30:00.000Z',
|
||||
endedAt: '2025-12-03T11:08:00.000Z',
|
||||
status: 'ENDED',
|
||||
transcript: {
|
||||
blocknote: null,
|
||||
markdown: [
|
||||
'**Olivia Barrett:** Chris, you\'re coming up on your annual renewal. I wanted to check in and see how things are going before we discuss the renewal terms.',
|
||||
'**Chris Yamamoto:** Overall we\'re happy. The platform has become essential for our team. There are a few enhancements I\'d love to see though.',
|
||||
'**Olivia Barrett:** I\'d love to hear them. What\'s on your wish list?',
|
||||
'**Chris Yamamoto:** First, we really need better territory management. We operate in eight regions and right now we\'re managing territories manually with filters. Second, we\'d like more advanced workflow branching — if-then-else logic in automations.',
|
||||
'**Olivia Barrett:** Good news on both fronts. Territory management is in our Q1 roadmap — we\'re targeting a February launch. Advanced workflow logic is already in beta. I can get your team access to the beta next week if you\'re interested.',
|
||||
'**Chris Yamamoto:** Definitely, sign us up for the beta. For the renewal, we want to add ten more seats. What does that look like pricing-wise?',
|
||||
'**Olivia Barrett:** Adding ten seats at your current rate would bring you to sixty users. Given your expansion and commitment, I can offer a five percent loyalty discount on the full renewal. I\'ll draft the renewal proposal and have it ready by next week.',
|
||||
'**Chris Yamamoto:** That sounds fair. Let\'s aim to have the renewal signed before the holiday break.',
|
||||
].join('\n\n'),
|
||||
},
|
||||
summary: {
|
||||
blocknote: null,
|
||||
markdown: [
|
||||
'## Overview',
|
||||
'Annual renewal discussion with Chris Yamamoto. Expanding from 50 to 60 seats with loyalty discount.',
|
||||
'',
|
||||
'## Key Discussion Points',
|
||||
'- Customer is satisfied, platform is essential to operations',
|
||||
'- Feature requests: territory management (in Q1 roadmap), advanced workflow logic (beta available)',
|
||||
'- Expansion: +10 seats (50 → 60 users)',
|
||||
'- 5% loyalty discount offered on full renewal',
|
||||
'- Target: sign renewal before holiday break',
|
||||
'',
|
||||
'## Action Items',
|
||||
'- [ ] Enroll Chris\'s team in workflow logic beta next week',
|
||||
'- [ ] Draft renewal proposal for 60 seats with 5% loyalty discount',
|
||||
'- [ ] Share Q1 territory management roadmap details',
|
||||
'- [ ] Get renewal signed before December holidays',
|
||||
].join('\n'),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Call Samantha Reed / Derek Chang',
|
||||
createdAt: '2026-01-15T14:00:00.000Z',
|
||||
endedAt: '2026-01-15T14:33:00.000Z',
|
||||
status: 'ENDED',
|
||||
transcript: {
|
||||
blocknote: null,
|
||||
markdown: [
|
||||
'**Derek Chang:** Samantha, your team has been on the platform for about two months now. I wanted to do a health check and see how the implementation is going.',
|
||||
'**Samantha Reed:** The core sales team is doing well. We\'re tracking about ninety percent of our deals in the system now. But I have some concerns about data quality.',
|
||||
'**Derek Chang:** What kind of data quality issues are you seeing?',
|
||||
'**Samantha Reed:** Duplicate contacts mostly. When we imported our data we ended up with a lot of duplicates. And our reps sometimes create new contacts instead of linking to existing ones.',
|
||||
'**Derek Chang:** That\'s a common post-migration issue. We have a built-in deduplication tool that can merge duplicates. I can run an audit on your database this week and present the results. For preventing new duplicates, we can enable duplicate detection rules that alert reps when they\'re about to create a potential duplicate.',
|
||||
'**Samantha Reed:** Yes, please run that audit. The other thing is we\'re not using the email automation features yet. Can we schedule a training session specifically on email sequences?',
|
||||
'**Derek Chang:** Absolutely. I\'ll set up a one-hour training session for your team next week. We\'ll cover sequence creation, personalization tokens, A/B testing, and analytics.',
|
||||
].join('\n\n'),
|
||||
},
|
||||
summary: {
|
||||
blocknote: null,
|
||||
markdown: [
|
||||
'## Overview',
|
||||
'Two-month health check with Samantha Reed. Good deal tracking (90%) but data quality concerns with duplicates.',
|
||||
'',
|
||||
'## Key Discussion Points',
|
||||
'- 90% of deals tracked in system — good adoption',
|
||||
'- Duplicate contact issue from data migration and manual creation',
|
||||
'- Built-in deduplication tool and detection rules available',
|
||||
'- Email automation features not yet adopted — training needed',
|
||||
'',
|
||||
'## Action Items',
|
||||
'- [ ] Run duplicate contact audit this week and present results',
|
||||
'- [ ] Enable duplicate detection rules for new contact creation',
|
||||
'- [ ] Schedule 1-hour email sequence training for next week',
|
||||
].join('\n'),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Call Michelle Torres / Greg Anderson',
|
||||
createdAt: '2026-02-10T09:00:00.000Z',
|
||||
endedAt: '2026-02-10T09:40:00.000Z',
|
||||
status: 'ENDED',
|
||||
transcript: {
|
||||
blocknote: null,
|
||||
markdown: [
|
||||
'**Greg Anderson:** Michelle, I\'m reaching out because we noticed your contract is expiring in thirty days and we haven\'t heard from you about renewal. Is everything alright?',
|
||||
'**Michelle Torres:** To be honest Greg, we\'ve been having some internal discussions about whether to continue. Our new VP of Sales wants to consolidate vendors and he\'s pushing for an all-in-one suite from one of the larger providers.',
|
||||
'**Greg Anderson:** I appreciate the transparency. Can you help me understand what the all-in-one suite offers that we don\'t currently provide?',
|
||||
'**Michelle Torres:** They bundle marketing automation, CRM, and customer service into one platform. The appeal is a single vendor relationship and unified data.',
|
||||
'**Greg Anderson:** I understand the appeal of consolidation. A few things to consider though. You\'d be replacing a tool your team has adopted and loves with something new. Migration costs and ramp-up time are real. And our open API means we integrate deeply with best-of-breed tools for each function. Would it be possible to get fifteen minutes with your VP of Sales? I\'d like to address his concerns directly.',
|
||||
'**Michelle Torres:** I think that\'s fair. Let me check his calendar. If you can make a compelling case for the best-of-breed approach, I think we have a shot at keeping this.',
|
||||
'**Greg Anderson:** I\'ll prepare a comparison analysis showing total cost, migration risk, and feature-by-feature breakdown. I want to make sure your VP has all the data he needs to make the right decision for the team.',
|
||||
].join('\n\n'),
|
||||
},
|
||||
summary: {
|
||||
blocknote: null,
|
||||
markdown: [
|
||||
'## Overview',
|
||||
'At-risk renewal call with Michelle Torres. New VP of Sales pushing for vendor consolidation with all-in-one suite.',
|
||||
'',
|
||||
'## Key Discussion Points',
|
||||
'- Contract expires in 30 days, internal debate about renewal',
|
||||
'- New VP wants all-in-one suite (marketing + CRM + service from single vendor)',
|
||||
'- Counter-argument: team adoption, migration costs, best-of-breed approach via API',
|
||||
'- Requested meeting with VP of Sales to present case',
|
||||
'',
|
||||
'## Risks',
|
||||
'- **High churn risk** — decision-maker change driving vendor review',
|
||||
'- Timeline is tight (30 days to contract expiry)',
|
||||
'',
|
||||
'## Action Items',
|
||||
'- [ ] Prepare best-of-breed vs all-in-one comparison analysis',
|
||||
'- [ ] Schedule meeting with Michelle\'s VP of Sales ASAP',
|
||||
'- [ ] Include total cost, migration risk, and feature comparison',
|
||||
'- [ ] Escalate internally as at-risk account',
|
||||
].join('\n'),
|
||||
},
|
||||
},
|
||||
];
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import {
|
||||
PEOPLE_ON_CALL_RECORDING_ID,
|
||||
} from 'src/fields/people-on-call-recording.field';
|
||||
import { CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/objects/call-recording';
|
||||
import { defineField, FieldType, RelationType } from 'twenty-sdk';
|
||||
|
||||
export const CALL_RECORDING_ON_PERSON_ID =
|
||||
'c62ae064-88aa-48a7-84b3-c9940e3a5db9';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier: CALL_RECORDING_ON_PERSON_ID,
|
||||
objectUniversalIdentifier: '20202020-e674-48e5-a542-72570eee7213',
|
||||
type: FieldType.RELATION,
|
||||
name: 'callRecordings',
|
||||
label: 'Call Recordings',
|
||||
relationTargetObjectMetadataUniversalIdentifier:
|
||||
CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
relationTargetFieldMetadataUniversalIdentifier:
|
||||
PEOPLE_ON_CALL_RECORDING_ID,
|
||||
universalSettings: {
|
||||
relationType: RelationType.ONE_TO_MANY,
|
||||
},
|
||||
});
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import { WORKSPACE_MEMBERS_ON_CALL_RECORDING_ID } from 'src/fields/workspace-members-on-call-recording.field';
|
||||
import { CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/objects/call-recording';
|
||||
import { defineField, FieldType, RelationType } from 'twenty-sdk';
|
||||
|
||||
export const CALL_RECORDING_ON_WORKSPACE_MEMBER_ID =
|
||||
'ae57f5bc-b9f1-4867-887a-834c14737bae';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier: CALL_RECORDING_ON_WORKSPACE_MEMBER_ID,
|
||||
objectUniversalIdentifier: '20202020-3319-4234-a34c-82d5c0e881a6',
|
||||
type: FieldType.RELATION,
|
||||
name: 'callRecordings',
|
||||
label: 'Call Recordings',
|
||||
relationTargetObjectMetadataUniversalIdentifier:
|
||||
CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
relationTargetFieldMetadataUniversalIdentifier:
|
||||
WORKSPACE_MEMBERS_ON_CALL_RECORDING_ID,
|
||||
universalSettings: {
|
||||
relationType: RelationType.ONE_TO_MANY,
|
||||
},
|
||||
});
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { CALL_RECORDING_ON_PERSON_ID } from 'src/fields/call-recording-on-person.field';
|
||||
import { CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/objects/call-recording';
|
||||
import { defineField, FieldType, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk';
|
||||
|
||||
export const PEOPLE_ON_CALL_RECORDING_ID =
|
||||
'0066e1d2-59f6-4ca7-8073-ca9bd964bfe0';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier: PEOPLE_ON_CALL_RECORDING_ID,
|
||||
objectUniversalIdentifier: CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.RELATION,
|
||||
name: 'person',
|
||||
label: 'Person',
|
||||
relationTargetObjectMetadataUniversalIdentifier:
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
|
||||
relationTargetFieldMetadataUniversalIdentifier:
|
||||
CALL_RECORDING_ON_PERSON_ID,
|
||||
universalSettings: {
|
||||
relationType: RelationType.MANY_TO_ONE,
|
||||
joinColumnName: 'personId',
|
||||
},
|
||||
icon: 'IconUser',
|
||||
});
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { CALL_RECORDING_ON_WORKSPACE_MEMBER_ID } from 'src/fields/call-recording-on-workspace-member.field';
|
||||
import { CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/objects/call-recording';
|
||||
import { defineField, FieldType, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk';
|
||||
|
||||
export const WORKSPACE_MEMBERS_ON_CALL_RECORDING_ID =
|
||||
'5550e26a-4354-434b-b32e-3f7b04585113';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier: WORKSPACE_MEMBERS_ON_CALL_RECORDING_ID,
|
||||
objectUniversalIdentifier: CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.RELATION,
|
||||
name: 'workspaceMember',
|
||||
label: 'Workspace Member',
|
||||
relationTargetObjectMetadataUniversalIdentifier:
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.workspaceMember.universalIdentifier,
|
||||
relationTargetFieldMetadataUniversalIdentifier:
|
||||
CALL_RECORDING_ON_WORKSPACE_MEMBER_ID,
|
||||
universalSettings: {
|
||||
relationType: RelationType.MANY_TO_ONE,
|
||||
joinColumnName: 'workspaceMemberId',
|
||||
},
|
||||
icon: 'IconUser',
|
||||
});
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
import { SummaryViewer } from 'src/components/SummaryViewer';
|
||||
import { SummaryViewerSkeleton } from 'src/components/SummaryViewerSkeleton';
|
||||
import { CALL_RECORDING_SUMMARY_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/constants/call-recording-summary-viewer-front-component-universal-identifier';
|
||||
import { useCallRecording } from 'src/hooks/useCallRecording';
|
||||
import { defineFrontComponent } from 'twenty-sdk';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
const CallRecordingSummaryViewer = () => {
|
||||
const { callRecording, loading, error } = useCallRecording();
|
||||
|
||||
if (loading) {
|
||||
return <SummaryViewerSkeleton />;
|
||||
}
|
||||
|
||||
if (isDefined(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return <SummaryViewer markdown={callRecording?.summary?.markdown} />;
|
||||
};
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier:
|
||||
CALL_RECORDING_SUMMARY_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
name: 'Call Recording Summary Viewer',
|
||||
description: 'Displays the AI-generated summary of a call recording',
|
||||
component: CallRecordingSummaryViewer,
|
||||
});
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { CallRecordingViewerSkeleton } from 'src/components/CallRecordingViewerSkeleton';
|
||||
import { MediaPlayer } from 'src/components/MediaPlayer';
|
||||
import { SummaryViewerSkeleton } from 'src/components/SummaryViewerSkeleton';
|
||||
import { TranscriptViewer } from 'src/components/TranscriptViewer';
|
||||
import { CALL_RECORDING_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/constants/call-recording-viewer-front-component-universal-identifier';
|
||||
import { useCallRecording } from 'src/hooks/useCallRecording';
|
||||
import { useTranscript } from 'src/hooks/useTranscript';
|
||||
import { defineFrontComponent } from 'twenty-sdk';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
padding: 20px;
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
`;
|
||||
|
||||
export const CallRecordingViewer = () => {
|
||||
const [currentTimeSeconds, setCurrentTimeSeconds] = useState(0);
|
||||
|
||||
const { callRecording, loading, error } = useCallRecording();
|
||||
|
||||
const transcriptFileUrl = callRecording?.transcriptFile[0]?.url;
|
||||
|
||||
const { entries: transcriptEntries, loading: transcriptLoading } =
|
||||
useTranscript(transcriptFileUrl);
|
||||
|
||||
if (loading) {
|
||||
return <CallRecordingViewerSkeleton />;
|
||||
}
|
||||
|
||||
if (isDefined(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const recordingFile = callRecording?.recordingFile[0];
|
||||
|
||||
const recordingFileUrl = recordingFile?.url;
|
||||
const recordingFileExtension = recordingFile?.extension;
|
||||
|
||||
const hasRecording =
|
||||
isDefined(recordingFileUrl) && isDefined(recordingFileExtension);
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
{hasRecording && (
|
||||
<MediaPlayer
|
||||
url={recordingFileUrl}
|
||||
extension={recordingFileExtension}
|
||||
onTimeUpdate={setCurrentTimeSeconds}
|
||||
/>
|
||||
)}
|
||||
{transcriptLoading ? (
|
||||
<SummaryViewerSkeleton />
|
||||
) : (
|
||||
<TranscriptViewer
|
||||
entries={transcriptEntries}
|
||||
currentTimeSeconds={currentTimeSeconds}
|
||||
/>
|
||||
)}
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier:
|
||||
CALL_RECORDING_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
name: 'Call Recording Viewer',
|
||||
description: 'A viewer for call recordings',
|
||||
component: CallRecordingViewer,
|
||||
});
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
SEED_CALL_RECORDINGS_COMMAND_UNIVERSAL_IDENTIFIER,
|
||||
SEED_CALL_RECORDINGS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
} from 'src/constants/seed-call-recordings-universal-identifiers';
|
||||
import { MOCK_CALL_RECORDINGS } from 'src/data/mock-call-recordings';
|
||||
import { defineFrontComponent } from 'twenty-sdk';
|
||||
import { CoreApiClient } from 'twenty-sdk/generated';
|
||||
|
||||
type SeedStatus = 'seeding' | 'done' | 'error';
|
||||
|
||||
const fetchPeopleIds = async (
|
||||
client: InstanceType<typeof CoreApiClient>,
|
||||
): Promise<string[]> => {
|
||||
const result: any = await client.query({
|
||||
people: {
|
||||
__args: { first: 50 },
|
||||
edges: { node: { id: true } },
|
||||
},
|
||||
} as any);
|
||||
|
||||
return (
|
||||
result?.people?.edges?.map(
|
||||
(edge: { node: { id: string } }) => edge.node.id,
|
||||
) ?? []
|
||||
);
|
||||
};
|
||||
|
||||
const fetchWorkspaceMemberIds = async (
|
||||
client: InstanceType<typeof CoreApiClient>,
|
||||
): Promise<string[]> => {
|
||||
const result: any = await client.query({
|
||||
workspaceMembers: {
|
||||
__args: { first: 50 },
|
||||
edges: { node: { id: true } },
|
||||
},
|
||||
} as any);
|
||||
|
||||
return (
|
||||
result?.workspaceMembers?.edges?.map(
|
||||
(edge: { node: { id: string } }) => edge.node.id,
|
||||
) ?? []
|
||||
);
|
||||
};
|
||||
|
||||
const pickRandom = <T,>(items: T[]): T | undefined =>
|
||||
items.length > 0 ? items[Math.floor(Math.random() * items.length)] : undefined;
|
||||
|
||||
const SeedCallRecordings = () => {
|
||||
const [status, setStatus] = useState<SeedStatus>('seeding');
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const seed = async () => {
|
||||
try {
|
||||
const client = new CoreApiClient();
|
||||
|
||||
const [personIds, workspaceMemberIds] = await Promise.all([
|
||||
fetchPeopleIds(client),
|
||||
fetchWorkspaceMemberIds(client),
|
||||
]);
|
||||
|
||||
const recordsToCreate = MOCK_CALL_RECORDINGS.map((recording) => ({
|
||||
...recording,
|
||||
personId: pickRandom(personIds),
|
||||
workspaceMemberId: pickRandom(workspaceMemberIds),
|
||||
}));
|
||||
|
||||
await client.mutation({
|
||||
createCallRecordings: {
|
||||
__args: { data: recordsToCreate as any },
|
||||
id: true,
|
||||
},
|
||||
} as any);
|
||||
|
||||
setCount(recordsToCreate.length);
|
||||
setStatus('done');
|
||||
} catch {
|
||||
setStatus('error');
|
||||
}
|
||||
};
|
||||
|
||||
seed();
|
||||
}, []);
|
||||
|
||||
if (status === 'seeding') {
|
||||
return <div>Seeding call recordings...</div>;
|
||||
}
|
||||
|
||||
if (status === 'error') {
|
||||
return <div>Failed to seed call recordings.</div>;
|
||||
}
|
||||
|
||||
return <div>Seeded {count} call recordings.</div>;
|
||||
};
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier:
|
||||
SEED_CALL_RECORDINGS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
name: 'Seed Call Recordings',
|
||||
description: 'Seeds the workspace with mock call recordings for testing',
|
||||
isHeadless: true,
|
||||
component: SeedCallRecordings,
|
||||
command: {
|
||||
universalIdentifier: SEED_CALL_RECORDINGS_COMMAND_UNIVERSAL_IDENTIFIER,
|
||||
label: 'Seed call recordings',
|
||||
icon: 'IconDatabase',
|
||||
isPinned: false,
|
||||
availabilityType: 'GLOBAL',
|
||||
},
|
||||
});
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { SummaryViewer } from 'src/components/SummaryViewer';
|
||||
import { SummaryViewerSkeleton } from 'src/components/SummaryViewerSkeleton';
|
||||
import {
|
||||
SUMMARIZE_PERSON_RECORDINGS_COMMAND_UNIVERSAL_IDENTIFIER,
|
||||
SUMMARIZE_PERSON_RECORDINGS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
} from 'src/constants/summarize-person-recordings-universal-identifiers';
|
||||
import { defineFrontComponent, useRecordId } from 'twenty-sdk';
|
||||
import { CoreApiClient } from 'twenty-sdk/generated';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
const SUMMARIZATION_SYSTEM_PROMPT = [
|
||||
'You are a helpful assistant that summarizes call transcripts.',
|
||||
'Provide a concise summary with:',
|
||||
'1) A brief overview of the calls',
|
||||
'2) Key themes across all calls',
|
||||
'3) Action items (if any)',
|
||||
'4) Risks or opportunities identified',
|
||||
'Use markdown formatting.',
|
||||
].join(' ');
|
||||
|
||||
type Recording = {
|
||||
id: string;
|
||||
name: string | null;
|
||||
createdAt: string;
|
||||
summary: { markdown: string | null } | null;
|
||||
};
|
||||
|
||||
const summarizeAllRecordings = async (
|
||||
recordings: Recording[],
|
||||
): Promise<string | undefined> => {
|
||||
const apiBaseUrl = process.env.TWENTY_API_URL;
|
||||
const token =
|
||||
process.env.TWENTY_APP_ACCESS_TOKEN ?? process.env.TWENTY_API_KEY;
|
||||
|
||||
if (!apiBaseUrl || !token) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const summariesText = recordings
|
||||
.map(
|
||||
(recording, index) =>
|
||||
`### ${index + 1}. ${recording.name ?? 'Untitled'} (${recording.createdAt})\n${recording.summary?.markdown ?? 'No summary available'}`,
|
||||
)
|
||||
.join('\n\n---\n\n');
|
||||
|
||||
const userPrompt = [
|
||||
`Here are the summaries of ${recordings.length} call recording(s) linked to this person:`,
|
||||
'',
|
||||
summariesText,
|
||||
'',
|
||||
'Generate a detailed summary of these calls.',
|
||||
'Highlight key themes, action items, and any risks or opportunities.',
|
||||
].join('\n');
|
||||
|
||||
const url = `${apiBaseUrl}/rest/ai/generate-text`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
systemPrompt: SUMMARIZATION_SYSTEM_PROMPT,
|
||||
userPrompt,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorBody = await response.text();
|
||||
|
||||
throw new Error(
|
||||
`AI summarization request failed with status ${response.status}: ${errorBody}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { text?: string };
|
||||
|
||||
return data.text ?? undefined;
|
||||
};
|
||||
|
||||
const SummarizePersonRecordings = () => {
|
||||
const personRecordId = useRecordId();
|
||||
|
||||
const [summary, setSummary] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDefined(personRecordId)) {
|
||||
setError(new Error('No person record selected'));
|
||||
setLoading(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchAndSummarize = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const client = new CoreApiClient();
|
||||
|
||||
const result: Record<string, unknown> = await client.query({
|
||||
callRecordings: {
|
||||
__args: {
|
||||
filter: { personId: { eq: personRecordId } },
|
||||
},
|
||||
edges: {
|
||||
node: {
|
||||
id: true,
|
||||
name: true,
|
||||
summary: { markdown: true },
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const recordings: Recording[] =
|
||||
(
|
||||
result?.callRecordings as {
|
||||
edges?: { node: Recording }[];
|
||||
}
|
||||
)?.edges?.map((edge) => edge.node) ?? [];
|
||||
|
||||
if (recordings.length === 0) {
|
||||
setError(new Error('No call recordings linked to this person'));
|
||||
setLoading(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const generatedSummary = await summarizeAllRecordings(recordings);
|
||||
|
||||
setSummary(generatedSummary ?? null);
|
||||
} catch (fetchError) {
|
||||
setError(
|
||||
fetchError instanceof Error
|
||||
? fetchError
|
||||
: new Error('Failed to summarize recordings'),
|
||||
);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
fetchAndSummarize();
|
||||
|
||||
return () => {
|
||||
setSummary(null);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
};
|
||||
}, [personRecordId]);
|
||||
|
||||
if (loading) {
|
||||
return <SummaryViewerSkeleton />;
|
||||
}
|
||||
|
||||
if (isDefined(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return <SummaryViewer markdown={summary} />;
|
||||
};
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier:
|
||||
SUMMARIZE_PERSON_RECORDINGS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
name: 'Summarize Person Call Recordings',
|
||||
description:
|
||||
'Generates and displays a summary of recent call recordings for a person',
|
||||
component: SummarizePersonRecordings,
|
||||
command: {
|
||||
universalIdentifier:
|
||||
SUMMARIZE_PERSON_RECORDINGS_COMMAND_UNIVERSAL_IDENTIFIER,
|
||||
label: 'Summarize call recordings',
|
||||
icon: 'IconSparkles',
|
||||
isPinned: false,
|
||||
availabilityType: 'SINGLE_RECORD',
|
||||
availabilityObjectUniversalIdentifier:
|
||||
'20202020-e674-48e5-a542-72570eee7213',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRecordId } from 'twenty-sdk';
|
||||
import { CoreApiClient } from 'twenty-sdk/generated';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
type CallRecording = {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
endedAt: string | null;
|
||||
recordingFile: Array<{ fileId: string; label: string; url: string | null; extension: string | null }>;
|
||||
transcriptFile: Array<{ fileId: string; label: string; url: string | null; extension: string | null }>;
|
||||
transcript: { markdown: string | null } | null;
|
||||
summary: { markdown: string | null } | null;
|
||||
};
|
||||
|
||||
export const useCallRecording = () => {
|
||||
const recordId = useRecordId();
|
||||
|
||||
const [callRecording, setCallRecording] = useState<CallRecording | null>(
|
||||
null,
|
||||
);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDefined(recordId)) {
|
||||
setError(new Error('Record ID is not defined'));
|
||||
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchRecord = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const client = new CoreApiClient();
|
||||
|
||||
const { callRecording } = await client.query({
|
||||
callRecording: {
|
||||
__args: {
|
||||
filter: { id: { eq: recordId } },
|
||||
},
|
||||
id: true,
|
||||
name: true,
|
||||
createdAt: true,
|
||||
endedAt: true,
|
||||
recordingFile: {
|
||||
fileId: true,
|
||||
label: true,
|
||||
url: true,
|
||||
extension: true,
|
||||
},
|
||||
transcriptFile: {
|
||||
fileId: true,
|
||||
label: true,
|
||||
url: true,
|
||||
extension: true,
|
||||
},
|
||||
transcript: {
|
||||
markdown: true,
|
||||
},
|
||||
summary: {
|
||||
markdown: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
setCallRecording({
|
||||
id: callRecording?.id ?? '',
|
||||
name: callRecording?.name ?? '',
|
||||
createdAt: callRecording?.createdAt ?? '',
|
||||
endedAt: callRecording?.endedAt ?? null,
|
||||
recordingFile: callRecording?.recordingFile?.map((file) => ({
|
||||
fileId: file.fileId,
|
||||
label: file.label,
|
||||
url: file.url ?? null,
|
||||
extension: file.extension ?? null,
|
||||
})) ?? [],
|
||||
transcriptFile: callRecording?.transcriptFile?.map((file) => ({
|
||||
fileId: file.fileId,
|
||||
label: file.label,
|
||||
url: file.url ?? null,
|
||||
extension: file.extension ?? null,
|
||||
})) ?? [],
|
||||
transcript: callRecording?.transcript
|
||||
? { markdown: callRecording.transcript.markdown ?? null }
|
||||
: null,
|
||||
summary: callRecording?.summary
|
||||
? { markdown: callRecording.summary.markdown ?? null }
|
||||
: null,
|
||||
});
|
||||
} catch (fetchError) {
|
||||
if (fetchError instanceof Error) {
|
||||
setError(fetchError);
|
||||
} else {
|
||||
setError(new Error('Failed to fetch call recording'));
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
fetchRecord();
|
||||
|
||||
return () => {
|
||||
setCallRecording(null);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
};
|
||||
}, [recordId]);
|
||||
|
||||
return { callRecording, loading, error };
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export type TranscriptTimestamp = {
|
||||
relative: number;
|
||||
absolute: string;
|
||||
};
|
||||
|
||||
export type TranscriptWord = {
|
||||
text: string;
|
||||
start_timestamp?: TranscriptTimestamp;
|
||||
end_timestamp?: TranscriptTimestamp;
|
||||
};
|
||||
|
||||
export type TranscriptEntry = {
|
||||
participant: {
|
||||
name: string | null;
|
||||
};
|
||||
words: TranscriptWord[];
|
||||
};
|
||||
|
||||
export const useTranscript = (
|
||||
transcriptFileUrl: string | null | undefined,
|
||||
) => {
|
||||
const [entries, setEntries] = useState<TranscriptEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDefined(transcriptFileUrl)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchTranscript = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await fetch(transcriptFileUrl);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch transcript: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
setEntries(data);
|
||||
} catch (fetchError) {
|
||||
if (fetchError instanceof Error) {
|
||||
setError(fetchError);
|
||||
} else {
|
||||
setError(new Error('Failed to fetch transcript'));
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
fetchTranscript();
|
||||
|
||||
return () => {
|
||||
setEntries([]);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
};
|
||||
}, [transcriptFileUrl]);
|
||||
|
||||
return { entries, loading, error };
|
||||
};
|
||||
@@ -0,0 +1,306 @@
|
||||
import {
|
||||
RECORDING_FILE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
TRANSCRIPT_FILE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
} from 'src/objects/call-recording';
|
||||
import {
|
||||
matchParticipants,
|
||||
type Participant,
|
||||
} from 'src/utils/match-participants';
|
||||
import { summarizeTranscript } from 'src/utils/summarize-transcript';
|
||||
import { defineLogicFunction } from 'twenty-sdk';
|
||||
import { CoreApiClient, MetadataApiClient } from 'twenty-sdk/generated';
|
||||
import { z } from 'zod';
|
||||
|
||||
interface LocalTranscriptWord {
|
||||
text: string;
|
||||
start_timestamp?: { relative: number; absolute: string };
|
||||
end_timestamp?: { relative: number; absolute: string };
|
||||
}
|
||||
|
||||
interface LocalTranscriptEntry {
|
||||
participant: { name: string };
|
||||
words: LocalTranscriptWord[];
|
||||
}
|
||||
|
||||
interface EndRecordingBody {
|
||||
callRecordingId: string;
|
||||
audioUrl: string;
|
||||
transcriptUrl?: string;
|
||||
participants?: Participant[];
|
||||
localTranscript?: LocalTranscriptEntry[];
|
||||
}
|
||||
|
||||
type UploadedFileRef = { fileId: string; label: string };
|
||||
|
||||
const timestampSchema = z.object({
|
||||
relative: z.number(),
|
||||
absolute: z.string(),
|
||||
});
|
||||
|
||||
const transcriptEntrySchema = z.object({
|
||||
participant: z.object({
|
||||
name: z.string().nullable(),
|
||||
}),
|
||||
words: z.array(
|
||||
z.object({
|
||||
text: z.string(),
|
||||
start_timestamp: timestampSchema.optional(),
|
||||
end_timestamp: timestampSchema.optional(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const transcriptSchema = z.array(transcriptEntrySchema);
|
||||
|
||||
const transcriptToMarkdown = (
|
||||
entries: z.infer<typeof transcriptSchema>,
|
||||
): string =>
|
||||
entries
|
||||
.map((entry) => {
|
||||
const speaker = entry.participant?.name ?? 'Unknown';
|
||||
const text = entry.words.map((word) => word.text).join(' ');
|
||||
|
||||
return `**${speaker}:** ${text}`;
|
||||
})
|
||||
.join('\n\n');
|
||||
|
||||
const localTranscriptToMarkdown = (
|
||||
entries: LocalTranscriptEntry[],
|
||||
): string =>
|
||||
entries
|
||||
.map((entry) => {
|
||||
const speaker = entry.participant?.name ?? 'Unknown';
|
||||
const text = entry.words.map((word) => word.text).join(' ');
|
||||
|
||||
return `**${speaker}:** ${text}`;
|
||||
})
|
||||
.join('\n\n');
|
||||
|
||||
const downloadFile = async (
|
||||
url: string,
|
||||
): Promise<{ buffer: Buffer; contentType: string; fileName: string }> => {
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download file from ${url}: ${response.status}`);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') ?? 'audio/mpeg';
|
||||
|
||||
const urlPath = new URL(url).pathname;
|
||||
const fileName = urlPath.split('/').pop() ?? 'recording.mp4';
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
|
||||
return {
|
||||
buffer: Buffer.from(arrayBuffer),
|
||||
contentType,
|
||||
fileName,
|
||||
};
|
||||
};
|
||||
|
||||
const processTranscript = async (
|
||||
metadataClient: InstanceType<typeof MetadataApiClient>,
|
||||
transcriptUrl: string | undefined,
|
||||
localTranscript?: LocalTranscriptEntry[],
|
||||
): Promise<
|
||||
| {
|
||||
transcriptFile?: UploadedFileRef[];
|
||||
transcript?: { blocknote: null; markdown: string };
|
||||
}
|
||||
| undefined
|
||||
> => {
|
||||
// The local transcript already has correct speakers (from isActiveSpeaker
|
||||
// tracking) and word-level timestamps (from the SDK events). Use it
|
||||
// directly instead of the Recall file which misattributes speakers.
|
||||
if (localTranscript?.length) {
|
||||
const transcriptBuffer = Buffer.from(
|
||||
JSON.stringify(localTranscript),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const uploadedTranscript = await metadataClient.uploadFile(
|
||||
transcriptBuffer,
|
||||
'transcript.json',
|
||||
'application/json',
|
||||
TRANSCRIPT_FILE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
);
|
||||
|
||||
return {
|
||||
transcriptFile: [
|
||||
{ fileId: uploadedTranscript.id, label: 'transcript.json' },
|
||||
],
|
||||
transcript: {
|
||||
blocknote: null,
|
||||
markdown: localTranscriptToMarkdown(localTranscript),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback: use the Recall-provided transcript file when no local data
|
||||
if (!transcriptUrl) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { buffer, fileName } = await downloadFile(transcriptUrl);
|
||||
|
||||
const uploadedTranscript = await metadataClient.uploadFile(
|
||||
buffer,
|
||||
fileName,
|
||||
'application/json',
|
||||
TRANSCRIPT_FILE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
);
|
||||
|
||||
const parsedEntries = transcriptSchema.parse(
|
||||
JSON.parse(buffer.toString('utf-8')),
|
||||
);
|
||||
|
||||
return {
|
||||
transcriptFile: [{ fileId: uploadedTranscript.id, label: fileName }],
|
||||
transcript: { blocknote: null, markdown: transcriptToMarkdown(parsedEntries) },
|
||||
};
|
||||
};
|
||||
|
||||
const handler = async (event: any) => {
|
||||
const body = event.body as EndRecordingBody | null;
|
||||
|
||||
if (!body?.callRecordingId) {
|
||||
throw new Error('Missing callRecordingId in request body');
|
||||
}
|
||||
|
||||
if (!body?.audioUrl) {
|
||||
throw new Error('Missing audioUrl in request body');
|
||||
}
|
||||
|
||||
const client = new CoreApiClient();
|
||||
const metadataClient = new MetadataApiClient();
|
||||
|
||||
const { callRecording } = await client.query({
|
||||
callRecording: {
|
||||
__args: {
|
||||
filter: { id: { eq: body.callRecordingId } },
|
||||
},
|
||||
id: true,
|
||||
name: true,
|
||||
status: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!callRecording) {
|
||||
throw new Error(`Call recording not found: ${body.callRecordingId}`);
|
||||
}
|
||||
|
||||
if (callRecording.status === 'ENDED') {
|
||||
throw new Error(`Call recording already ended: ${body.callRecordingId}`);
|
||||
}
|
||||
|
||||
const { buffer, contentType, fileName } = await downloadFile(body.audioUrl);
|
||||
|
||||
const uploadedRecording = await metadataClient.uploadFile(
|
||||
buffer,
|
||||
fileName,
|
||||
contentType,
|
||||
RECORDING_FILE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
);
|
||||
|
||||
const transcriptData = await processTranscript(
|
||||
metadataClient,
|
||||
body.transcriptUrl,
|
||||
body.localTranscript,
|
||||
);
|
||||
|
||||
const callName = body.participants?.length
|
||||
? `Call ${body.participants
|
||||
.map((participant) => participant.name)
|
||||
.join(' / ')}`
|
||||
: undefined;
|
||||
const updateData: Record<string, unknown> = {
|
||||
status: 'ENDED',
|
||||
endedAt: new Date().toISOString(),
|
||||
recordingFile: [{ fileId: uploadedRecording.id, label: fileName }],
|
||||
...transcriptData,
|
||||
...(callName ? { name: callName } : {}),
|
||||
};
|
||||
|
||||
delete updateData.createdAt;
|
||||
|
||||
await client.mutation({
|
||||
updateCallRecording: {
|
||||
__args: {
|
||||
id: callRecording.id,
|
||||
data: updateData,
|
||||
},
|
||||
id: true,
|
||||
endedAt: true,
|
||||
status: true,
|
||||
},
|
||||
});
|
||||
|
||||
// TODO: remove `as any` after running `yarn twenty app:dev` to regenerate the typed client
|
||||
const updateSummary = async (markdown: string) => {
|
||||
await client.mutation({
|
||||
updateCallRecording: {
|
||||
__args: {
|
||||
id: callRecording.id,
|
||||
data: {
|
||||
summary: { blocknote: null, markdown },
|
||||
} as any,
|
||||
},
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (transcriptData?.transcript?.markdown) {
|
||||
console.log(
|
||||
'[end-recording] Transcript available, attempting summarization...',
|
||||
);
|
||||
|
||||
await updateSummary('*Generating summary...*');
|
||||
|
||||
try {
|
||||
const summaryMarkdown = await summarizeTranscript(
|
||||
transcriptData.transcript.markdown,
|
||||
);
|
||||
|
||||
console.log(
|
||||
'[end-recording] Summarization result:',
|
||||
summaryMarkdown ? `${summaryMarkdown.length} chars` : 'undefined',
|
||||
);
|
||||
|
||||
if (summaryMarkdown) {
|
||||
await updateSummary(summaryMarkdown);
|
||||
console.log('[end-recording] Summary saved to record');
|
||||
} else {
|
||||
await updateSummary('*Failed to generate summary: NO_RESPONSE*');
|
||||
}
|
||||
} catch (error) {
|
||||
const errorCode =
|
||||
error instanceof Error ? error.message : 'UNKNOWN_ERROR';
|
||||
|
||||
console.error('[end-recording] AI summarization failed:', error);
|
||||
await updateSummary(`*Failed to generate summary: ${errorCode}*`);
|
||||
}
|
||||
} else {
|
||||
console.log(
|
||||
'[end-recording] No transcript markdown, skipping summarization',
|
||||
);
|
||||
}
|
||||
|
||||
if (body.participants?.length) {
|
||||
await matchParticipants(callRecording.id, body.participants);
|
||||
}
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: '471353f6-5933-417b-8062-9ad0fc44cd7f',
|
||||
name: 'end-recording',
|
||||
description: 'Endpoint to end a call recording',
|
||||
timeoutSeconds: 60,
|
||||
handler,
|
||||
httpRouteTriggerSettings: {
|
||||
path: '/end-recording',
|
||||
httpMethod: 'POST',
|
||||
isAuthRequired: false,
|
||||
},
|
||||
});
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { CALL_RECORDING_VIEW_UNIVERSAL_IDENTIFIER } from 'src/views/call-recording-view';
|
||||
import { defineNavigationMenuItem } from 'twenty-sdk';
|
||||
|
||||
export default defineNavigationMenuItem({
|
||||
universalIdentifier: '5248a62d-7d2e-43a7-ba45-6e8f61876a71',
|
||||
name: 'Call recordings',
|
||||
icon: 'IconPhone',
|
||||
position: 0,
|
||||
viewUniversalIdentifier: CALL_RECORDING_VIEW_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import { defineObject, FieldType } from 'twenty-sdk';
|
||||
|
||||
export const CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER =
|
||||
'af251b70-85c6-49bd-bf4a-2631f34c8f1a';
|
||||
|
||||
export const NAME_FIELD_UNIVERSAL_IDENTIFIER =
|
||||
'272dca6f-b3aa-49f5-b7ed-39780052f1fe';
|
||||
|
||||
export const CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER =
|
||||
'c581a044-f646-464b-aa4b-56b8ea9bf05a';
|
||||
|
||||
export const ENDED_AT_FIELD_UNIVERSAL_IDENTIFIER =
|
||||
'56185e64-6591-41c1-a3e0-af8de20a5471';
|
||||
|
||||
export const RECORDING_FILE_FIELD_UNIVERSAL_IDENTIFIER =
|
||||
'e78d41fd-a493-4d06-b036-0dd7b7617dbe';
|
||||
|
||||
export const TRANSCRIPT_FILE_FIELD_UNIVERSAL_IDENTIFIER =
|
||||
'b2a3c8e1-7f94-4d5b-a6e2-9c1d0f3e8b47';
|
||||
|
||||
export const TRANSCRIPT_FIELD_UNIVERSAL_IDENTIFIER =
|
||||
'a1d4e7c3-5b28-4f96-8e3a-0c7d9f2b6a15';
|
||||
|
||||
export const SUMMARY_FIELD_UNIVERSAL_IDENTIFIER =
|
||||
'55eb083f-0b68-4f5c-bcd7-c853ad77ba11';
|
||||
|
||||
export const STATUS_FIELD_UNIVERSAL_IDENTIFIER =
|
||||
'24c92ad0-4559-4bf9-a9fa-09168914a142';
|
||||
|
||||
export default defineObject({
|
||||
universalIdentifier: CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
nameSingular: 'callRecording',
|
||||
namePlural: 'callRecordings',
|
||||
labelSingular: 'Call recording',
|
||||
labelPlural: 'Call recordings',
|
||||
description: 'A recorded call',
|
||||
icon: 'IconPhone',
|
||||
labelIdentifierFieldMetadataUniversalIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fields: [
|
||||
{
|
||||
universalIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.TEXT,
|
||||
name: 'name',
|
||||
label: 'Name',
|
||||
description: 'Name of the call recording',
|
||||
icon: 'IconAbc',
|
||||
},
|
||||
{
|
||||
universalIdentifier: CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.DATE_TIME,
|
||||
name: 'createdAt',
|
||||
label: 'Created at',
|
||||
description: 'When the call recording was created',
|
||||
icon: 'IconCalendar',
|
||||
},
|
||||
{
|
||||
universalIdentifier: ENDED_AT_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.DATE_TIME,
|
||||
name: 'endedAt',
|
||||
label: 'Ended at',
|
||||
description: 'When the call ended',
|
||||
icon: 'IconCalendar',
|
||||
},
|
||||
{
|
||||
universalIdentifier: RECORDING_FILE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.FILES,
|
||||
name: 'recordingFile',
|
||||
label: 'Recording file',
|
||||
description: 'The recording file of the call recording',
|
||||
icon: 'IconFile',
|
||||
universalSettings: { maxNumberOfValues: 1 },
|
||||
},
|
||||
{
|
||||
universalIdentifier: TRANSCRIPT_FILE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.FILES,
|
||||
name: 'transcriptFile',
|
||||
label: 'Transcript file',
|
||||
description: 'The transcript file of the call recording',
|
||||
icon: 'IconFileText',
|
||||
universalSettings: { maxNumberOfValues: 1 },
|
||||
},
|
||||
{
|
||||
universalIdentifier: TRANSCRIPT_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.RICH_TEXT_V2,
|
||||
name: 'transcript',
|
||||
label: 'Transcript',
|
||||
description: 'Human-readable transcript of the call',
|
||||
icon: 'IconMessage',
|
||||
},
|
||||
{
|
||||
universalIdentifier: STATUS_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.SELECT,
|
||||
name: 'status',
|
||||
label: 'Status',
|
||||
description: 'Status of the call recording',
|
||||
icon: 'IconStatusChange',
|
||||
defaultValue: "'ONGOING'",
|
||||
options: [
|
||||
{
|
||||
id: '8b275a4d-98ba-4718-912d-b1d97e713f5d',
|
||||
value: 'ONGOING',
|
||||
label: 'Ongoing',
|
||||
position: 0,
|
||||
color: 'blue',
|
||||
},
|
||||
{
|
||||
id: 'a515ac77-44f8-4744-9c50-0a29352a800d',
|
||||
value: 'ENDED',
|
||||
label: 'Ended',
|
||||
position: 1,
|
||||
color: 'green',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
universalIdentifier: SUMMARY_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.RICH_TEXT_V2,
|
||||
name: 'summary',
|
||||
label: 'Summary',
|
||||
description: 'AI-generated summary of the call',
|
||||
icon: 'IconSparkles',
|
||||
},
|
||||
],
|
||||
});
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
import { PEOPLE_ON_CALL_RECORDING_ID } from 'src/fields/people-on-call-recording.field';
|
||||
import { WORKSPACE_MEMBERS_ON_CALL_RECORDING_ID } from 'src/fields/workspace-members-on-call-recording.field';
|
||||
import {
|
||||
CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
} from 'src/objects/call-recording';
|
||||
import { AggregateOperations, definePageLayout, ObjectRecordGroupByDateGranularity, PageLayoutTabLayoutMode } from 'twenty-sdk';
|
||||
|
||||
export const CALL_RECORDING_DASHBOARD_LAYOUT_UNIVERSAL_IDENTIFIER =
|
||||
'17ff2924-00c9-4105-ac4e-64c28cba781f';
|
||||
|
||||
export default definePageLayout({
|
||||
universalIdentifier: CALL_RECORDING_DASHBOARD_LAYOUT_UNIVERSAL_IDENTIFIER,
|
||||
name: 'Call Recording Insights',
|
||||
type: 'DASHBOARD',
|
||||
tabs: [
|
||||
{
|
||||
universalIdentifier: 'aa3398e8-b7e8-402f-9681-4cdc44fdd6d8',
|
||||
title: 'Overview',
|
||||
position: 0,
|
||||
icon: 'IconChartBar',
|
||||
layoutMode: PageLayoutTabLayoutMode.CANVAS,
|
||||
widgets: [
|
||||
{
|
||||
universalIdentifier: '1a04a308-5318-4f75-9b3a-4ee414750508',
|
||||
title: 'Total Calls',
|
||||
type: 'GRAPH',
|
||||
objectUniversalIdentifier:
|
||||
CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
gridPosition: { row: 0, column: 0, rowSpan: 2, columnSpan: 3 },
|
||||
configuration: {
|
||||
configurationType: 'AGGREGATE_CHART',
|
||||
aggregateFieldMetadataUniversalIdentifier:
|
||||
NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
aggregateOperation: AggregateOperations.COUNT,
|
||||
label: 'Total Calls',
|
||||
},
|
||||
},
|
||||
{
|
||||
universalIdentifier: '26c8190f-ff63-45f8-9cd9-d8bfd1380cca',
|
||||
title: 'Calls per Person',
|
||||
type: 'GRAPH',
|
||||
objectUniversalIdentifier:
|
||||
CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
gridPosition: { row: 0, column: 3, rowSpan: 6, columnSpan: 4 },
|
||||
configuration: {
|
||||
configurationType: 'PIE_CHART',
|
||||
aggregateFieldMetadataUniversalIdentifier:
|
||||
NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
aggregateOperation: AggregateOperations.COUNT,
|
||||
groupByFieldMetadataUniversalIdentifier:
|
||||
PEOPLE_ON_CALL_RECORDING_ID,
|
||||
groupBySubFieldName: 'name.firstName',
|
||||
showCenterMetric: true,
|
||||
displayLegend: true,
|
||||
displayDataLabel: false,
|
||||
color: 'blue',
|
||||
},
|
||||
},
|
||||
{
|
||||
universalIdentifier: 'd3741561-e9ca-4dd2-8510-4f49d25911e5',
|
||||
title: 'Calls per Workspace Member',
|
||||
type: 'GRAPH',
|
||||
objectUniversalIdentifier:
|
||||
CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
gridPosition: { row: 0, column: 7, rowSpan: 6, columnSpan: 5 },
|
||||
configuration: {
|
||||
configurationType: 'BAR_CHART',
|
||||
aggregateFieldMetadataUniversalIdentifier:
|
||||
NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
aggregateOperation: AggregateOperations.COUNT,
|
||||
primaryAxisGroupByFieldMetadataUniversalIdentifier:
|
||||
WORKSPACE_MEMBERS_ON_CALL_RECORDING_ID,
|
||||
primaryAxisGroupBySubFieldName: 'name.firstName',
|
||||
displayDataLabel: true,
|
||||
displayLegend: false,
|
||||
color: 'turquoise',
|
||||
layout: 'VERTICAL',
|
||||
},
|
||||
},
|
||||
{
|
||||
universalIdentifier: '7916f127-8f60-4226-a0b8-45632cf3cfe7',
|
||||
title: 'Calls Over Time',
|
||||
type: 'GRAPH',
|
||||
objectUniversalIdentifier:
|
||||
CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
gridPosition: { row: 6, column: 0, rowSpan: 6, columnSpan: 12 },
|
||||
configuration: {
|
||||
configurationType: 'LINE_CHART',
|
||||
aggregateFieldMetadataUniversalIdentifier:
|
||||
NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
aggregateOperation: AggregateOperations.COUNT,
|
||||
primaryAxisGroupByFieldMetadataUniversalIdentifier:
|
||||
CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
primaryAxisDateGranularity: ObjectRecordGroupByDateGranularity.MONTH,
|
||||
displayDataLabel: false,
|
||||
displayLegend: false,
|
||||
color: 'purple',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
import { CALL_RECORDING_SUMMARY_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/constants/call-recording-summary-viewer-front-component-universal-identifier';
|
||||
import { CALL_RECORDING_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/constants/call-recording-viewer-front-component-universal-identifier';
|
||||
import { CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/objects/call-recording';
|
||||
import { definePageLayout, PageLayoutTabLayoutMode } from 'twenty-sdk';
|
||||
|
||||
export default definePageLayout({
|
||||
universalIdentifier: 'b7e3a1d4-5c92-4f68-9a0b-3e8d7c6f1a25',
|
||||
name: 'Call Recording Record Page',
|
||||
type: 'RECORD_PAGE',
|
||||
objectUniversalIdentifier: CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
tabs: [
|
||||
{
|
||||
universalIdentifier: 'e6b2d8f4-7a13-4c59-b2e1-9d4f0c8a3b67',
|
||||
title: 'Summary',
|
||||
position: 50,
|
||||
icon: 'IconSparkles',
|
||||
layoutMode: PageLayoutTabLayoutMode.CANVAS,
|
||||
widgets: [
|
||||
{
|
||||
universalIdentifier: 'e5c93fce-76b4-41e9-9c5d-9b17e034366c',
|
||||
title: 'Summary',
|
||||
type: 'FRONT_COMPONENT',
|
||||
configuration: {
|
||||
configurationType: 'FRONT_COMPONENT',
|
||||
frontComponentUniversalIdentifier:
|
||||
CALL_RECORDING_SUMMARY_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
universalIdentifier: 'c4f8e2a6-3d71-4b95-8e0c-1a9f6d5b7c34',
|
||||
title: 'Transcript',
|
||||
position: 100,
|
||||
icon: 'IconVideo',
|
||||
layoutMode: PageLayoutTabLayoutMode.CANVAS,
|
||||
widgets: [
|
||||
{
|
||||
universalIdentifier: 'd5a9f3b7-4e82-4c06-9f1d-2b0a7e6c8d45',
|
||||
title: 'Media Player',
|
||||
type: 'FRONT_COMPONENT',
|
||||
configuration: {
|
||||
configurationType: 'FRONT_COMPONENT',
|
||||
frontComponentUniversalIdentifier:
|
||||
CALL_RECORDING_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
universalIdentifier: 'f7c1b5d9-6a04-4e28-b13f-4d2c9a8e0f67',
|
||||
title: 'Timeline',
|
||||
position: 200,
|
||||
icon: 'IconTimelineEvent',
|
||||
layoutMode: PageLayoutTabLayoutMode.CANVAS,
|
||||
widgets: [
|
||||
{
|
||||
universalIdentifier: '23c87a9c-25e3-4e83-84d9-02fb1a6fde76',
|
||||
title: 'Timeline',
|
||||
type: 'TIMELINE',
|
||||
configuration: {
|
||||
configurationType: 'TIMELINE',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
universalIdentifier: '498e47e5-bed1-4492-a08d-b12b7ff40ed9',
|
||||
title: 'Tasks',
|
||||
position: 300,
|
||||
icon: 'IconCheckbox',
|
||||
layoutMode: PageLayoutTabLayoutMode.CANVAS,
|
||||
widgets: [
|
||||
{
|
||||
universalIdentifier: 'ae93482f-384f-42a8-9c06-6bc14b10da6a',
|
||||
title: 'Tasks',
|
||||
type: 'TASKS',
|
||||
configuration: {
|
||||
configurationType: 'TASKS',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
universalIdentifier: 'c111ccf0-b95b-4333-b2bc-a7a9da40f913',
|
||||
title: 'Notes',
|
||||
position: 400,
|
||||
icon: 'IconNotes',
|
||||
layoutMode: PageLayoutTabLayoutMode.CANVAS,
|
||||
widgets: [
|
||||
{
|
||||
universalIdentifier: 'e2b6a0c4-1f59-4d73-a684-9c7b4f3d5e12',
|
||||
title: 'Notes',
|
||||
type: 'NOTES',
|
||||
configuration: {
|
||||
configurationType: 'NOTES',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
universalIdentifier: 'f3c7b1d5-2a60-4e84-b795-0d8c5a4e6f23',
|
||||
title: 'Files',
|
||||
position: 500,
|
||||
icon: 'IconPaperclip',
|
||||
layoutMode: PageLayoutTabLayoutMode.CANVAS,
|
||||
widgets: [
|
||||
{
|
||||
universalIdentifier: 'a17bf74a-a7ff-48a0-8628-3fd905539c8d',
|
||||
title: 'Files',
|
||||
type: 'FILES',
|
||||
configuration: {
|
||||
configurationType: 'FILES',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defineRole } from 'twenty-sdk';
|
||||
import { PermissionFlagType } from 'twenty-shared/constants';
|
||||
|
||||
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER =
|
||||
'f9cfb3ce-cb1e-4f55-af85-be45f6059054';
|
||||
|
||||
export default defineRole({
|
||||
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
|
||||
label: 'Call recording default function role',
|
||||
description: 'Call recording default function role',
|
||||
canReadAllObjectRecords: true,
|
||||
canUpdateAllObjectRecords: true,
|
||||
canSoftDeleteAllObjectRecords: true,
|
||||
canDestroyAllObjectRecords: false,
|
||||
permissionFlags: [PermissionFlagType.UPLOAD_FILE, PermissionFlagType.AI],
|
||||
});
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
import { defineSkill } from 'twenty-sdk';
|
||||
|
||||
export const CALL_TRANSCRIPT_SUMMARIZATION_SKILL_UNIVERSAL_IDENTIFIER =
|
||||
'11fb51a7-4d5a-4168-91d7-9fbe5eb7d609';
|
||||
|
||||
export default defineSkill({
|
||||
universalIdentifier: CALL_TRANSCRIPT_SUMMARIZATION_SKILL_UNIVERSAL_IDENTIFIER,
|
||||
name: 'call-transcript-summarization',
|
||||
label: 'Call Transcript Summarization',
|
||||
description:
|
||||
'Instructions for summarizing and analyzing call recording transcripts',
|
||||
content: `# Call Transcript Summarization
|
||||
|
||||
## When to Use
|
||||
Use this skill when a user asks you to summarize, analyze, or extract insights from a call recording.
|
||||
|
||||
## How to Access the Data
|
||||
1. Use \`find_one_callRecording\` to fetch the call recording by its ID.
|
||||
2. Read the \`transcript\` field (RICH_TEXT_V2, markdown format) which contains the full conversation.
|
||||
3. The transcript uses the format: **Speaker Name:** spoken text
|
||||
|
||||
## What to Produce
|
||||
Generate a structured summary with these sections:
|
||||
|
||||
### Overview
|
||||
A 2-3 sentence high-level description of what the call was about, who participated, and the general outcome.
|
||||
|
||||
### Key Discussion Points
|
||||
Bullet points covering the main topics discussed, organized chronologically or by theme.
|
||||
|
||||
### Decisions Made
|
||||
Any explicit decisions or agreements reached during the call.
|
||||
|
||||
### Action Items
|
||||
Concrete next steps mentioned during the call, including who is responsible (if stated).
|
||||
|
||||
### Sentiment & Tone
|
||||
A brief note on the overall tone of the conversation (collaborative, tense, exploratory, etc.).
|
||||
|
||||
## Output Format
|
||||
- Use markdown formatting.
|
||||
- Keep the summary concise — aim for roughly 20% of the transcript length.
|
||||
- If the transcript is very short (under 200 words), provide a brief 2-3 sentence summary instead of the full structure.
|
||||
|
||||
## Saving the Summary
|
||||
After generating the summary, use \`update_callRecording\` to save it in the \`summary\` field with the format:
|
||||
\`\`\`json
|
||||
{ "summary": { "blocknote": null, "markdown": "<your summary>" } }
|
||||
\`\`\`
|
||||
`,
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
import { AUDIO_EXTENSIONS } from 'src/constants/audio-extensions';
|
||||
|
||||
export const isAudioExtension = (extension: string): boolean =>
|
||||
AUDIO_EXTENSIONS.includes(extension.toLowerCase() as (typeof AUDIO_EXTENSIONS)[number]);
|
||||
@@ -0,0 +1,4 @@
|
||||
import { VIDEO_EXTENSIONS } from 'src/constants/video-extensions';
|
||||
|
||||
export const isVideoExtension = (extension: string): boolean =>
|
||||
VIDEO_EXTENSIONS.includes(extension.toLowerCase() as (typeof VIDEO_EXTENSIONS)[number]);
|
||||
@@ -0,0 +1,148 @@
|
||||
import { CoreApiClient } from 'twenty-sdk/generated';
|
||||
|
||||
export interface Participant {
|
||||
id: string;
|
||||
name: string;
|
||||
isHost: boolean;
|
||||
platform: string;
|
||||
}
|
||||
|
||||
const parseName = (
|
||||
fullName: string,
|
||||
): { firstName: string; lastName: string } => {
|
||||
const parts = fullName.trim().split(/\s+/);
|
||||
const firstName = parts[0] ?? '';
|
||||
const lastName = parts.slice(1).join(' ');
|
||||
|
||||
return { firstName, lastName };
|
||||
};
|
||||
|
||||
const findWorkspaceMember = async (
|
||||
client: InstanceType<typeof CoreApiClient>,
|
||||
firstName: string,
|
||||
lastName: string,
|
||||
): Promise<string | null> => {
|
||||
const result: any = await client.query({
|
||||
workspaceMembers: {
|
||||
__args: {
|
||||
filter: {
|
||||
name: {
|
||||
firstName: { ilike: `%${firstName}%` },
|
||||
lastName: { ilike: `%${lastName}%` },
|
||||
},
|
||||
},
|
||||
},
|
||||
edges: {
|
||||
node: {
|
||||
id: true,
|
||||
name: { firstName: true, lastName: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
const firstMatch = result.workspaceMembers?.edges?.[0]?.node;
|
||||
|
||||
return firstMatch?.id ?? null;
|
||||
};
|
||||
|
||||
const findPerson = async (
|
||||
client: InstanceType<typeof CoreApiClient>,
|
||||
firstName: string,
|
||||
lastName: string,
|
||||
): Promise<string | null> => {
|
||||
const result: any = await client.query({
|
||||
people: {
|
||||
__args: {
|
||||
filter: {
|
||||
name: {
|
||||
firstName: { ilike: `%${firstName}%` },
|
||||
lastName: { ilike: `%${lastName}%` },
|
||||
},
|
||||
},
|
||||
},
|
||||
edges: {
|
||||
node: {
|
||||
id: true,
|
||||
name: { firstName: true, lastName: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
const firstMatch = result.people?.edges?.[0]?.node;
|
||||
|
||||
return firstMatch?.id ?? null;
|
||||
};
|
||||
|
||||
export const matchParticipants = async (
|
||||
callRecordingId: string,
|
||||
participants: Participant[],
|
||||
): Promise<void> => {
|
||||
if (!participants.length) {
|
||||
console.log('No participants to match, skipping');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const client = new CoreApiClient();
|
||||
|
||||
for (const participant of participants) {
|
||||
const { firstName, lastName } = parseName(participant.name);
|
||||
|
||||
if (!firstName) {
|
||||
console.log(
|
||||
`Skipping participant with empty name: ${participant.id}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const workspaceMemberId = await findWorkspaceMember(
|
||||
client,
|
||||
firstName,
|
||||
lastName,
|
||||
);
|
||||
|
||||
if (workspaceMemberId) {
|
||||
console.log(
|
||||
`Matched "${participant.name}" to workspace member ${workspaceMemberId}`,
|
||||
);
|
||||
|
||||
await client.mutation({
|
||||
updateCallRecording: {
|
||||
__args: {
|
||||
id: callRecordingId,
|
||||
data: { workspaceMemberId },
|
||||
},
|
||||
id: true,
|
||||
},
|
||||
} as any);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
const personId = await findPerson(client, firstName, lastName);
|
||||
|
||||
if (personId) {
|
||||
console.log(
|
||||
`Matched "${participant.name}" to person ${personId}`,
|
||||
);
|
||||
|
||||
await client.mutation({
|
||||
updateCallRecording: {
|
||||
__args: {
|
||||
id: callRecordingId,
|
||||
data: { personId },
|
||||
},
|
||||
id: true,
|
||||
},
|
||||
} as any);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`No match found for participant "${participant.name}"`,
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
const SUMMARIZATION_SYSTEM_PROMPT = [
|
||||
'You are a helpful assistant that summarizes call transcripts.',
|
||||
'Provide a concise summary with:',
|
||||
'1) A brief overview of the call',
|
||||
'2) Key discussion points',
|
||||
'3) Action items (if any)',
|
||||
'Use markdown formatting.',
|
||||
].join(' ');
|
||||
|
||||
export const summarizeTranscript = async (
|
||||
transcriptMarkdown: string,
|
||||
): Promise<string | undefined> => {
|
||||
const apiBaseUrl = process.env.TWENTY_API_URL;
|
||||
const token =
|
||||
process.env.TWENTY_APP_ACCESS_TOKEN ?? process.env.TWENTY_API_KEY;
|
||||
|
||||
console.log(
|
||||
'[summarizeTranscript] Starting summarization',
|
||||
JSON.stringify({
|
||||
hasApiBaseUrl: !!apiBaseUrl,
|
||||
hasToken: !!token,
|
||||
transcriptLength: transcriptMarkdown.length,
|
||||
}),
|
||||
);
|
||||
|
||||
if (!apiBaseUrl || !token) {
|
||||
console.log(
|
||||
'[summarizeTranscript] Skipping: missing apiBaseUrl or token',
|
||||
);
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const url = `${apiBaseUrl}/rest/ai/generate-text`;
|
||||
|
||||
console.log('[summarizeTranscript] Calling', url);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
systemPrompt: SUMMARIZATION_SYSTEM_PROMPT,
|
||||
userPrompt: `Summarize this call transcript:\n\n${transcriptMarkdown}`,
|
||||
}),
|
||||
});
|
||||
|
||||
console.log(
|
||||
'[summarizeTranscript] Response status:',
|
||||
response.status,
|
||||
response.statusText,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorBody = await response.text();
|
||||
|
||||
console.error('[summarizeTranscript] Error response body:', errorBody);
|
||||
throw new Error(
|
||||
`AI summarization request failed with status ${response.status}: ${errorBody}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { text?: string };
|
||||
|
||||
console.log(
|
||||
'[summarizeTranscript] Result text length:',
|
||||
data.text?.length ?? 0,
|
||||
);
|
||||
|
||||
return data.text ?? undefined;
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
import { WORKSPACE_MEMBERS_ON_CALL_RECORDING_ID } from 'src/fields/workspace-members-on-call-recording.field';
|
||||
import { PEOPLE_ON_CALL_RECORDING_ID } from 'src/fields/people-on-call-recording.field';
|
||||
import { CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER, CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER, ENDED_AT_FIELD_UNIVERSAL_IDENTIFIER, NAME_FIELD_UNIVERSAL_IDENTIFIER, RECORDING_FILE_FIELD_UNIVERSAL_IDENTIFIER, STATUS_FIELD_UNIVERSAL_IDENTIFIER, SUMMARY_FIELD_UNIVERSAL_IDENTIFIER, TRANSCRIPT_FIELD_UNIVERSAL_IDENTIFIER, TRANSCRIPT_FILE_FIELD_UNIVERSAL_IDENTIFIER } from 'src/objects/call-recording';
|
||||
import { defineView } from 'twenty-sdk';
|
||||
|
||||
export const CALL_RECORDING_VIEW_UNIVERSAL_IDENTIFIER =
|
||||
'9c9c09bb-de9f-4248-89f2-e7d91f29c3ed';
|
||||
|
||||
export default defineView({
|
||||
universalIdentifier: CALL_RECORDING_VIEW_UNIVERSAL_IDENTIFIER,
|
||||
name: 'Call recordings',
|
||||
objectUniversalIdentifier: CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
icon: 'IconPhone',
|
||||
position: 0,
|
||||
fields: [
|
||||
{
|
||||
universalIdentifier: 'b5609679-8451-45ec-ad52-5a4e3720af45',
|
||||
fieldMetadataUniversalIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
isVisible: true,
|
||||
size: 12,
|
||||
position: 0,
|
||||
},
|
||||
{
|
||||
universalIdentifier: 'd791bea6-c49e-4d6f-8864-737ed00276f8',
|
||||
fieldMetadataUniversalIdentifier: CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
isVisible: true,
|
||||
size: 12,
|
||||
position: 1,
|
||||
},
|
||||
{
|
||||
universalIdentifier: 'db96d9cf-cbd8-407b-b748-7b12913f018b',
|
||||
fieldMetadataUniversalIdentifier: ENDED_AT_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
isVisible: true,
|
||||
size: 12,
|
||||
position: 2,
|
||||
},
|
||||
{
|
||||
universalIdentifier: '17c4e68a-5b62-4509-b8ed-19f82dfb8e2f',
|
||||
fieldMetadataUniversalIdentifier: STATUS_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
isVisible: true,
|
||||
size: 12,
|
||||
position: 3,
|
||||
},
|
||||
{
|
||||
universalIdentifier: 'a7bce6c7-39ce-406a-bc6f-00b495951b4a',
|
||||
fieldMetadataUniversalIdentifier: RECORDING_FILE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
isVisible: true,
|
||||
size: 12,
|
||||
position: 4,
|
||||
},
|
||||
{
|
||||
universalIdentifier: 'f3e2d1c0-a9b8-47c6-85d4-3e2f1a0b9c8d',
|
||||
fieldMetadataUniversalIdentifier: TRANSCRIPT_FILE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
isVisible: true,
|
||||
size: 12,
|
||||
position: 5,
|
||||
},
|
||||
{
|
||||
universalIdentifier: 'e4c3b2a1-0d9e-48f7-a6b5-1c2d3e4f5a6b',
|
||||
fieldMetadataUniversalIdentifier: TRANSCRIPT_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
isVisible: true,
|
||||
size: 12,
|
||||
position: 6,
|
||||
},
|
||||
{
|
||||
universalIdentifier: '56925631-0077-4b65-a25a-24afec4d8bff',
|
||||
fieldMetadataUniversalIdentifier: WORKSPACE_MEMBERS_ON_CALL_RECORDING_ID,
|
||||
isVisible: true,
|
||||
size: 12,
|
||||
position: 7,
|
||||
},
|
||||
{
|
||||
universalIdentifier: '1ba8538d-9c8e-47bf-b485-a8ba07b3d9a3',
|
||||
fieldMetadataUniversalIdentifier: PEOPLE_ON_CALL_RECORDING_ID,
|
||||
isVisible: true,
|
||||
size: 12,
|
||||
position: 8,
|
||||
},
|
||||
{
|
||||
universalIdentifier: '58124ded-56ff-40a0-8858-eae1b5ae75d7',
|
||||
fieldMetadataUniversalIdentifier: SUMMARY_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
isVisible: true,
|
||||
size: 12,
|
||||
position: 6,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"compileOnSave": false,
|
||||
"compilerOptions": {
|
||||
"sourceMap": true,
|
||||
"declaration": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": ".",
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "@emotion/react",
|
||||
"moduleResolution": "node",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"importHelpers": true,
|
||||
"allowUnreachableCode": false,
|
||||
"strict": true,
|
||||
"alwaysStrict": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": false,
|
||||
"target": "es2018",
|
||||
"module": "esnext",
|
||||
"lib": ["es2020", "dom"],
|
||||
"skipLibCheck": true,
|
||||
"skipDefaultLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"paths": {
|
||||
"src/*": ["./src/*"],
|
||||
"~/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,9 @@
|
||||
RECALLAI_API_URL=https://us-west-2.recall.ai
|
||||
RECALLAI_API_KEY=recall_api_key
|
||||
|
||||
# Twenty CRM integration (optional)
|
||||
# Generate an API key at <your-twenty-instance>/settings/api-webhooks
|
||||
TWENTY_API_URL=http://localhost:3000
|
||||
# Workspace subdomain, required for logic function routes when multiworkspace is enabled
|
||||
TWENTY_WORKSPACE_SUBDOMAIN=
|
||||
TWENTY_API_KEY=replace_me_with_your_twenty_api_key
|
||||
@@ -0,0 +1,2 @@
|
||||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user