snapshot of https://github.com/twentyhq/twenty.git (main, 2026-09-17; blobs > 131072B omitted)
Some checks failed
App Prod-Parity E2E Dispatch / dispatch (push) Failing after 10s
CD Storybook UI / build (push) Failing after 3m25s
CD Storybook UI / dispatch (push) Has been skipped
CD deploy main / website-changed-files (push) Successful in 18m8s
CI Agent Skills / changed-files-check (push) Successful in 18m9s
CI Create App E2E minimal / changed-files-check (push) Successful in 24s
CI Create App / changed-files-check (push) Successful in 26s
CI Docs / changed-files-check (push) Successful in 26s
CI Emails / changed-files-check (push) Successful in 28s
CI Example App Hello World / changed-files-check (push) Successful in 23s
CI Front / changed-files-check (push) Has been skipped
CI Example App Postcard / changed-files-check (push) Successful in 27s
CI UI / changed-files-check (push) Has been skipped
CI Utils Test / changed-files-check (push) Successful in 45s
CI Twenty Apps / discover (push) Successful in 1m3s
CD deploy main / deploy-main (push) Failing after 3s
CI Agent Skills / agent-skills-validate (push) Has been skipped
CI Create App / create-app-test (lint) (push) Has been skipped
CI Emails / emails-test (push) Has been skipped
CI Front / front-task (lingui:compile) (push) Has been skipped
CI Front / front-task (lint) (push) Has been skipped
CI Example App Postcard / example-app-postcard (push) Has been skipped
CI UI / ui-task (lint) (push) Has been skipped
CI UI / ui-task (pack-size) (push) Has been skipped
CI UI / ui-task (size) (push) Has been skipped
CI UI / ui-task (test) (push) Has been skipped
CI UI / ui-task (typecheck) (push) Has been skipped
CI Utils Test / utils-test (push) Has been skipped
CI Twenty Apps / ${{ matrix.app.name }} (push) Has been skipped
CI Emails / ci-emails-status-check (push) Successful in 2s
CI Example App Hello World / ci-example-app-hello-world-status-check (push) Successful in 2s
CI Example App Postcard / ci-example-app-postcard-status-check (push) Successful in 3s
CI UI / ui-sb-test (push) Has been skipped
CI Utils Test / ci-utils-test-status-check (push) Successful in 2s
CI Twenty Apps / ci-twenty-apps-status-check (push) Successful in 2s
CI UI / ci-ui-status-check (push) Successful in 2s
CI Desktop / desktop (push) Failing after 1m8s
Push docs to Crowdin / Push documentation to Crowdin (push) Failing after 55s
Push translations to Crowdin / Extract and upload translations (push) Failing after 5s
Push website translations to Crowdin / Extract and upload website translations (push) Failing after 5s
CI Create App / create-app-test (test) (push) Has been skipped
CI Create App / create-app-test (typecheck) (push) Has been skipped
CI Docs / docs-lint (push) Has been skipped
CI Example App Hello World / example-app-hello-world (push) Has been skipped
CI Front / front-task (lingui:extract) (push) Has been skipped
CI Front / front-task (test) (push) Has been skipped
CI Front / front-task (typecheck) (push) Has been skipped
CI UI / ui-sb-build (push) Failing after 1m32s
CI Twenty Apps / ${{ matrix.app.name }} (${{ matrix.server-source }}) (push) Has been skipped
CI Agent Skills / agent-skills-publish (push) Has been skipped
CI Create App / ci-create-app-status-check (push) Successful in 2s
CI Agent Skills / agent-skills-remote-source (push) Has been skipped
CI Create App E2E minimal / create-app-e2e-minimal (push) Has been cancelled
CI E2E Main / e2e-test (push) Has been cancelled
CI Front / front-sb-build (push) Has been cancelled
CI Front / front-build (push) Has been cancelled
CI Create App E2E minimal / ci-create-app-e2e-minimal-status-check (push) Has been cancelled
CI E2E Main / ci-e2e-main-status-check (push) Has been cancelled
CI E2E Main / notify-main-ci-failure (push) Has been cancelled
CI E2E Main / qa-scout-comment (push) Has been cancelled
CI Front / front-sb-test (push) Has been cancelled
CI Front / front-sb-screenshots (push) Has been cancelled
CI Front / ci-front-status-check (push) Has been cancelled
DPA Sub-Processor Sync / sync-subprocessors (push) Failing after 3s
AI Catalog Sync / sync-catalog (push) Failing after 3s

This commit is contained in:
upstream-mirror
2026-09-17 23:28:13 +05:00
commit e7da97194a
32688 changed files with 4208681 additions and 0 deletions

View File

@@ -0,0 +1,33 @@
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
generated
/coverage
/dist/
.twenty
/build
.DS_Store
*.pem
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
.env*
*.tsbuildinfo
*.d.ts

View File

@@ -0,0 +1 @@
24.5.0

View File

@@ -0,0 +1,38 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"extends": ["../../.oxlintrc.base.json"],
"plugins": ["typescript"],
"categories": {
"correctness": "off"
},
"ignorePatterns": ["node_modules", "dist"],
"rules": {
"no-unused-vars": "off",
"typescript/no-unused-vars": [
"warn",
{
"argsIgnorePattern": "^_"
}
],
"typescript/no-explicit-any": "off"
},
"overrides": [
{
"files": ["**/*.logic-function.ts", "**/logic-functions/**/*.ts"],
"rules": {
"no-restricted-imports": [
"error",
{
"patterns": [
{
"group": ["twenty-shared", "twenty-shared/*"],
"message": "Logic functions must not import from twenty-shared directly. Import runtime types and helpers from `twenty-sdk/logic-function` instead so the logic-function bundle stays minimal."
}
]
}
]
}
}
]
}

View File

@@ -0,0 +1 @@
nodeLinker: node-modules

View File

@@ -0,0 +1,70 @@
## Base documentation
- Getting started:
- https://docs.twenty.com/developers/extend/apps/getting-started/quick-start.md
- https://docs.twenty.com/developers/extend/apps/getting-started/concepts.md
- https://docs.twenty.com/developers/extend/apps/getting-started/project-structure.md
- https://docs.twenty.com/developers/extend/apps/getting-started/local-server.md
- https://docs.twenty.com/developers/extend/apps/getting-started/scaffolding.md
- https://docs.twenty.com/developers/extend/apps/getting-started/troubleshooting.md
- Config:
- https://docs.twenty.com/developers/extend/apps/config/overview.md
- https://docs.twenty.com/developers/extend/apps/config/application.md
- https://docs.twenty.com/developers/extend/apps/config/roles.md
- https://docs.twenty.com/developers/extend/apps/config/install-hooks.md
- https://docs.twenty.com/developers/extend/apps/config/public-assets.md
- Data:
- https://docs.twenty.com/developers/extend/apps/data/overview.md
- https://docs.twenty.com/developers/extend/apps/data/objects.md
- https://docs.twenty.com/developers/extend/apps/data/extending-objects.md
- https://docs.twenty.com/developers/extend/apps/data/relations.md
- Logic:
- https://docs.twenty.com/developers/extend/apps/logic/overview.md
- https://docs.twenty.com/developers/extend/apps/logic/logic-functions.md
- https://docs.twenty.com/developers/extend/apps/logic/skills-and-agents.md
- https://docs.twenty.com/developers/extend/apps/logic/connections.md
- Layout:
- https://docs.twenty.com/developers/extend/apps/layout/overview.md
- https://docs.twenty.com/developers/extend/apps/layout/views.md
- https://docs.twenty.com/developers/extend/apps/layout/navigation-menu-items.md
- https://docs.twenty.com/developers/extend/apps/layout/page-layouts.md
- https://docs.twenty.com/developers/extend/apps/layout/front-components.md
- https://docs.twenty.com/developers/extend/apps/layout/command-menu-items.md
- Operations:
- https://docs.twenty.com/developers/extend/apps/operations/overview.md
- https://docs.twenty.com/developers/extend/apps/operations/cli.md
- https://docs.twenty.com/developers/extend/apps/operations/testing.md
- https://docs.twenty.com/developers/extend/apps/operations/publishing.md
- Rich app example: https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples/postcard
## UUID requirement
- All generated UUIDs must be valid UUID v4.
## Common Pitfalls
- Creating a view without a navigationMenuItem associated. This will make the view unavailable on the left sidebar.
- Creating a front-end component that has a scroll instead of being responsive to its fixed widget height and width, unless it is specifically meant to be used in a canvas tab.
## Best practice
It's highly recommended to create new app entities using `yarn twenty dev:add`. These are the options:
| Entity type | Command | Generated file |
| -------------------- | ---------------------------------------- | ------------------------------------- |
| Object | `yarn twenty dev:add object` | `src/objects/<name>.ts` |
| Field | `yarn twenty dev:add field` | `src/fields/<name>.ts` |
| Logic function | `yarn twenty dev:add logicFunction` | `src/logic-functions/<name>.ts` |
| Front component | `yarn twenty dev:add frontComponent` | `src/front-components/<name>.tsx` |
| Role | `yarn twenty dev:add role` | `src/roles/<name>.ts` |
| Skill | `yarn twenty dev:add skill` | `src/skills/<name>.ts` |
| Agent | `yarn twenty dev:add agent` | `src/agents/<name>.ts` |
| View | `yarn twenty dev:add view` | `src/views/<name>.ts` |
| Navigation menu item | `yarn twenty dev:add navigationMenuItem` | `src/navigation-menu-items/<name>.ts` |
| Page layout | `yarn twenty dev:add pageLayout` | `src/page-layouts/<name>.ts` |
| Page layout tab | `yarn twenty dev:add pageLayoutTab` | `src/page-layout-tabs/<name>.ts` |
| Command menu item | `yarn twenty dev:add commandMenuItem` | `src/command-menu-items/<name>.ts` |
| View field | `yarn twenty dev:add viewField` | `src/view-fields/<name>.ts` |
| Connection provider | `yarn twenty dev:add connectionProvider` | `src/connection-providers/<name>.ts` |
This helps automatically generate required IDs etc.

View File

@@ -0,0 +1,5 @@
# Changelog
## 0.1.0
- Initial Granola application scaffold.

View File

@@ -0,0 +1,70 @@
## Base documentation
- Getting started:
- https://docs.twenty.com/developers/extend/apps/getting-started/quick-start.md
- https://docs.twenty.com/developers/extend/apps/getting-started/concepts.md
- https://docs.twenty.com/developers/extend/apps/getting-started/project-structure.md
- https://docs.twenty.com/developers/extend/apps/getting-started/local-server.md
- https://docs.twenty.com/developers/extend/apps/getting-started/scaffolding.md
- https://docs.twenty.com/developers/extend/apps/getting-started/troubleshooting.md
- Config:
- https://docs.twenty.com/developers/extend/apps/config/overview.md
- https://docs.twenty.com/developers/extend/apps/config/application.md
- https://docs.twenty.com/developers/extend/apps/config/roles.md
- https://docs.twenty.com/developers/extend/apps/config/install-hooks.md
- https://docs.twenty.com/developers/extend/apps/config/public-assets.md
- Data:
- https://docs.twenty.com/developers/extend/apps/data/overview.md
- https://docs.twenty.com/developers/extend/apps/data/objects.md
- https://docs.twenty.com/developers/extend/apps/data/extending-objects.md
- https://docs.twenty.com/developers/extend/apps/data/relations.md
- Logic:
- https://docs.twenty.com/developers/extend/apps/logic/overview.md
- https://docs.twenty.com/developers/extend/apps/logic/logic-functions.md
- https://docs.twenty.com/developers/extend/apps/logic/skills-and-agents.md
- https://docs.twenty.com/developers/extend/apps/logic/connections.md
- Layout:
- https://docs.twenty.com/developers/extend/apps/layout/overview.md
- https://docs.twenty.com/developers/extend/apps/layout/views.md
- https://docs.twenty.com/developers/extend/apps/layout/navigation-menu-items.md
- https://docs.twenty.com/developers/extend/apps/layout/page-layouts.md
- https://docs.twenty.com/developers/extend/apps/layout/front-components.md
- https://docs.twenty.com/developers/extend/apps/layout/command-menu-items.md
- Operations:
- https://docs.twenty.com/developers/extend/apps/operations/overview.md
- https://docs.twenty.com/developers/extend/apps/operations/cli.md
- https://docs.twenty.com/developers/extend/apps/operations/testing.md
- https://docs.twenty.com/developers/extend/apps/operations/publishing.md
- Rich app example: https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples/postcard
## UUID requirement
- All generated UUIDs must be valid UUID v4.
## Common Pitfalls
- Creating a view without a navigationMenuItem associated. This will make the view unavailable on the left sidebar.
- Creating a front-end component that has a scroll instead of being responsive to its fixed widget height and width, unless it is specifically meant to be used in a canvas tab.
## Best practice
It's highly recommended to create new app entities using `yarn twenty dev:add`. These are the options:
| Entity type | Command | Generated file |
| -------------------- | ---------------------------------------- | ------------------------------------- |
| Object | `yarn twenty dev:add object` | `src/objects/<name>.ts` |
| Field | `yarn twenty dev:add field` | `src/fields/<name>.ts` |
| Logic function | `yarn twenty dev:add logicFunction` | `src/logic-functions/<name>.ts` |
| Front component | `yarn twenty dev:add frontComponent` | `src/front-components/<name>.tsx` |
| Role | `yarn twenty dev:add role` | `src/roles/<name>.ts` |
| Skill | `yarn twenty dev:add skill` | `src/skills/<name>.ts` |
| Agent | `yarn twenty dev:add agent` | `src/agents/<name>.ts` |
| View | `yarn twenty dev:add view` | `src/views/<name>.ts` |
| Navigation menu item | `yarn twenty dev:add navigationMenuItem` | `src/navigation-menu-items/<name>.ts` |
| Page layout | `yarn twenty dev:add pageLayout` | `src/page-layouts/<name>.ts` |
| Page layout tab | `yarn twenty dev:add pageLayoutTab` | `src/page-layout-tabs/<name>.ts` |
| Command menu item | `yarn twenty dev:add commandMenuItem` | `src/command-menu-items/<name>.ts` |
| View field | `yarn twenty dev:add viewField` | `src/view-fields/<name>.ts` |
| Connection provider | `yarn twenty dev:add connectionProvider` | `src/connection-providers/<name>.ts` |
This helps automatically generate required IDs etc.

View File

@@ -0,0 +1,9 @@
# Granola for Twenty
Implementation in progress. Sync meeting transcripts and shared summaries into Call Recordings, import history, and use notes in workflows and AI. Requires Granola Business or Enterprise and an API key. Private notes are excluded.
## Limitations
- One Granola API key serves the whole Twenty workspace. Members cannot connect their own Granola accounts, so every recording comes from the notes that key can see. Use a workspace key to cover a team.
When a Granola note matches multiple calendar event copies, the recording is imported without a calendar link because the app cannot determine which copy belongs to the note's owner.

View File

@@ -0,0 +1,3 @@
# Setup
Implementation in progress. Create a workspace API key in Granola Settings → Connectors → Workspace API keys. Install the app and save the key in its settings. Registration, history import, and folder selection follow in the stack.

View File

@@ -0,0 +1,45 @@
{
"name": "@twentyhq/granola",
"version": "0.1.0",
"description": "Sync Granola meeting notes into Twenty Call Recordings",
"license": "MIT",
"engines": {
"node": "^24.5.0",
"npm": "please-use-yarn",
"yarn": ">=4.0.2",
"twenty": ">=2.38.0"
},
"keywords": [
"twenty-app"
],
"packageManager": "yarn@4.13.0",
"scripts": {
"twenty": "twenty",
"lint": "oxlint -c .oxlintrc.json .",
"lint:fix": "oxlint --fix -c .oxlintrc.json .",
"typecheck": "tsgo --noEmit -p tsconfig.spec.json",
"test": "vitest run",
"test:watch": "vitest",
"test:unit": "vitest run --config vitest.unit.config.ts"
},
"dependencies": {
"@sniptt/guards": "^0.2.0",
"zod": "^4.1.11"
},
"devDependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@types/node": "^24.7.2",
"@types/react": "^19.2.0",
"@typescript/native-preview": "^7.0.0-dev.20260116.1",
"oxlint": "^0.16.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"twenty-client-sdk": "2.38.0",
"twenty-sdk": "2.38.0",
"twenty-ui": "^1.0.0-alpha.1",
"typescript": "^5.9.3",
"vite-tsconfig-paths": "^4.2.1",
"vitest": "^4.0.0"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest';
import * as identifiers from 'src/constants/universal-identifiers';
describe('application identifiers', () => {
it('uses distinct UUID v4 identifiers for every manifest entity', () => {
const values = Object.entries(identifiers)
.filter(([name]) => name.endsWith('_UNIVERSAL_IDENTIFIER'))
.map(([, value]) => value);
expect(new Set(values).size).toBe(values.length);
for (const value of values) {
expect(value).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
);
}
});
});

View File

@@ -0,0 +1,147 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
import { functionExecute } from 'twenty-sdk/cli';
import { describe, expect, it } from 'vitest';
import { z } from 'zod';
import { GRANOLA_BACKFILL_ROUTE_PATH } from 'src/constants/granola-backfill-route-path';
const APP_PATH = process.cwd();
const MANIFEST_SCHEMA = z.object({
logicFunctions: z.array(z.object({ name: z.string() })),
});
const executeDeployedFunction = async ({
functionName,
payload,
}: {
functionName: string;
payload: Record<string, unknown>;
}) => {
const result = await functionExecute({
appPath: APP_PATH,
functionName,
payload,
});
if (!result.success) {
throw new Error(
`Could not execute ${functionName}: ${result.error.message}`,
);
}
return result.data;
};
const buildBackfillRoutePayload = (body: Record<string, unknown>) => ({
headers: {},
queryStringParameters: {},
pathParameters: {},
body,
isBase64Encoded: false,
requestContext: {
http: { method: 'POST', path: GRANOLA_BACKFILL_ROUTE_PATH },
},
});
describe('Granola deployed functions', () => {
it('deploys every function declared by the built application', async () => {
const manifest = MANIFEST_SCHEMA.parse(
JSON.parse(
readFileSync(
join(APP_PATH, '.twenty', 'output', 'manifest.json'),
'utf8',
),
),
);
const result = await new MetadataApiClient().query({
findManyLogicFunctions: { name: true },
});
const deployedNames = result.findManyLogicFunctions.map(
(logicFunction) => logicFunction.name,
);
for (const { name } of manifest.logicFunctions) {
expect(deployedNames).toContain(name);
}
});
it('reports the missing API key from the deployed connection status route', async () => {
const execution = await executeDeployedFunction({
functionName: 'granola-connection-status',
payload: {},
});
expect(execution.status).toBe('SUCCESS');
expect(execution.data).toEqual({ isConnected: false, isApiKeySet: false });
});
it.each([0, -1, 3651, 1.5, '31'])(
'rejects the invalid backfill window %s before enqueueing',
async (days) => {
const execution = await executeDeployedFunction({
functionName: 'granola-backfill',
payload: buildBackfillRoutePayload({ days }),
});
expect(execution.status).toBe('SUCCESS');
expect(execution.data).toEqual({
success: false,
error: expect.stringContaining('between 1 and 3650'),
});
},
);
it.each([
{
functionName: 'granola-sync-note',
payload: { noteId: 'not_invalid' },
error: 'A valid Granola note ID is required.',
},
{
functionName: 'granola-list-notes',
payload: { limit: 31 },
error:
'Use valid date filters, a Granola folder ID, and a limit between 1 and 30.',
},
{
functionName: 'granola-list-notes',
payload: { createdAfter: 'not-a-date' },
error:
'Use valid date filters, a Granola folder ID, and a limit between 1 and 30.',
},
{
functionName: 'granola-list-folders',
payload: { limit: 0 },
error: 'Use a limit between 1 and 30 and a valid pagination cursor.',
},
])(
'validates inputs in deployed $functionName',
async ({ functionName, payload, error }) => {
const execution = await executeDeployedFunction({
functionName,
payload,
});
expect(execution.status).toBe('SUCCESS');
expect(execution.data).toEqual({ success: false, error });
},
);
it.each([
{
functionName: 'granola-sync-note',
payload: { noteId: 'not_12345678901234' },
},
{ functionName: 'granola-list-notes', payload: { limit: 1 } },
{ functionName: 'granola-list-folders', payload: { limit: 1 } },
])(
'reports the missing key from deployed $functionName',
async ({ functionName, payload }) => {
const execution = await executeDeployedFunction({
functionName,
payload,
});
expect(execution.status).toBe('SUCCESS');
expect(execution.data).toEqual({
success: false,
error: expect.stringContaining('Connect Granola'),
});
},
);
});

View File

@@ -0,0 +1,120 @@
import { isNonEmptyString } from '@sniptt/guards';
import { isDefined } from 'twenty-sdk/utils';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { appDevOnce, appUninstall } from 'twenty-sdk/cli';
const APP_PATH = process.cwd();
const CONFIG_DIR = path.join(os.homedir(), '.twenty');
const TEST_CONFIG_PATH = path.join(CONFIG_DIR, 'config.test.json');
let previousTestConfig: Buffer | undefined;
let isTestConfigWritten = false;
const restoreTestConfig = () => {
if (!isTestConfigWritten) {
return;
}
if (isDefined(previousTestConfig)) {
fs.writeFileSync(TEST_CONFIG_PATH, previousTestConfig);
} else {
fs.rmSync(TEST_CONFIG_PATH, { force: true });
}
isTestConfigWritten = false;
};
function validateEnvironmentOrThrow(): { apiUrl: string; apiKey: string } {
const apiUrl = process.env.TWENTY_API_URL;
const apiKey = process.env.TWENTY_API_KEY;
if (!isNonEmptyString(apiUrl) || !isNonEmptyString(apiKey)) {
throw new Error(
'TWENTY_API_URL and TWENTY_API_KEY must be set.\n' +
'Start a local server: yarn twenty docker:start\n' +
'Or set them in vitest env config.',
);
}
return { apiUrl, apiKey };
}
async function checkServerOrThrow(apiUrl: string) {
let response: Response;
try {
response = await fetch(`${apiUrl}/healthz`);
} catch {
throw new Error(
`Twenty server is not reachable at ${apiUrl}. ` +
'Make sure the server is running before executing integration tests.',
);
}
if (!response.ok) {
throw new Error(`Server at ${apiUrl} returned ${response.status}`);
}
}
function writeConfig({ apiUrl, apiKey }: { apiUrl: string; apiKey: string }) {
const payload = JSON.stringify(
{
remotes: {
local: { apiUrl, apiKey },
},
defaultRemote: 'local',
},
null,
2,
);
fs.mkdirSync(CONFIG_DIR, { recursive: true });
previousTestConfig = fs.existsSync(TEST_CONFIG_PATH)
? fs.readFileSync(TEST_CONFIG_PATH)
: undefined;
fs.writeFileSync(TEST_CONFIG_PATH, payload);
isTestConfigWritten = true;
}
export async function setup() {
const { apiUrl, apiKey } = validateEnvironmentOrThrow();
await checkServerOrThrow(apiUrl);
writeConfig({ apiUrl, apiKey });
try {
await appUninstall({ appPath: APP_PATH }).catch(() => {});
const result = await appDevOnce({
appPath: APP_PATH,
onProgress: (message: string) => console.log(`[dev] ${message}`),
});
if (!result.success) {
throw new Error(
`Dev sync failed: ${result.error?.message ?? 'Unknown error'}`,
);
}
} catch (error) {
restoreTestConfig();
throw error;
}
}
export async function teardown() {
if (!isTestConfigWritten) {
return;
}
try {
const uninstallResult = await appUninstall({ appPath: APP_PATH });
if (!uninstallResult.success) {
console.warn(
`App uninstall failed: ${uninstallResult.error?.message ?? 'Unknown error'}`,
);
}
} finally {
restoreTestConfig();
}
}

View File

@@ -0,0 +1,17 @@
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { describe, expect, it } from 'vitest';
describe('Granola installation', () => {
it('installs the application with its reserved identity', async () => {
const result = await new MetadataApiClient().query({
findManyApplications: { id: true, universalIdentifier: true },
});
expect(
result.findManyApplications.some(
(application) =>
application.universalIdentifier === APPLICATION_UNIVERSAL_IDENTIFIER,
),
).toBe(true);
});
});

View File

@@ -0,0 +1,20 @@
import { type GranolaNote } from 'src/logic-functions/types/granola-api.type';
export const buildGranolaNote = (
overrides: Partial<GranolaNote> = {},
): GranolaNote => ({
id: 'not_1d3tmYTlCICgjy',
object: 'note',
title: 'Customer meeting',
owner: { name: 'Alice', email: 'alice@example.com' },
created_at: '2026-09-05T10:00:00Z',
updated_at: '2026-09-05T11:00:00Z',
web_url: 'https://notes.granola.ai/d/meeting',
calendar_event: null,
attendees: [],
folder_membership: [],
summary_text: 'Shared summary',
summary_markdown: '## Shared summary',
transcript: null,
...overrides,
});

View File

@@ -0,0 +1,11 @@
import { type GranolaTranscriptItem } from 'src/logic-functions/types/granola-api.type';
export const buildGranolaTranscriptItem = (
overrides: Partial<GranolaTranscriptItem> = {},
): GranolaTranscriptItem => ({
speaker: { source: 'microphone', attribution: 'me' },
text: 'Hello',
start_time: '2026-09-05T10:00:05Z',
end_time: '2026-09-05T10:00:07Z',
...overrides,
});

View File

@@ -0,0 +1,26 @@
import { defineApplication } from 'twenty-sdk/define';
import { GRANOLA_API_KEY_ENV_VAR_NAME } from 'src/logic-functions/constants/granola-api-key-env-var-name';
import {
APP_DESCRIPTION,
APP_DISPLAY_NAME,
APPLICATION_UNIVERSAL_IDENTIFIER,
GRANOLA_API_KEY_VARIABLE_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineApplication({
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
displayName: APP_DISPLAY_NAME,
description: APP_DESCRIPTION,
logo: 'public/logo.png',
author: 'Twenty',
category: 'Productivity',
applicationVariables: {
[GRANOLA_API_KEY_ENV_VAR_NAME]: {
universalIdentifier: GRANOLA_API_KEY_VARIABLE_UNIVERSAL_IDENTIFIER,
label: 'Granola API key',
description:
'Workspace or personal API key from Granola Settings → Connectors → API keys.',
isSecret: true,
},
},
});

View File

@@ -0,0 +1,5 @@
export const GRANOLA_NOTE_ID_PATTERN = /^not_[a-zA-Z0-9]{14}$/;
export const GRANOLA_FOLDER_ID_PATTERN = /^fol_[a-zA-Z0-9]{14}$/;
export const GRANOLA_MAX_PAGE_SIZE = 30;
export const GRANOLA_PAGE_INTERVAL_MILLISECONDS = 250;
export const GRANOLA_FOLDER_SELECTION_LIMIT = 100;

View File

@@ -0,0 +1 @@
export const GRANOLA_BACKFILL_ROUTE_PATH = '/granola/backfill';

View File

@@ -0,0 +1,2 @@
export const GRANOLA_CONNECTION_STATUS_ROUTE_PATH =
'/granola/connection-status';

View File

@@ -0,0 +1,2 @@
export const GRANOLA_FOLDER_SELECTION_ROUTE_PATH =
'/granola/webhook-registration/folders';

View File

@@ -0,0 +1 @@
export const GRANOLA_FOLDERS_ROUTE_PATH = '/granola/folders';

View File

@@ -0,0 +1,7 @@
export const GRANOLA_HISTORY_IMPORT_INTERVAL_MILLISECONDS = 4_000;
export const GRANOLA_HISTORY_MAX_PAGES = 1000;
export const GRANOLA_HISTORY_MAX_IMPORT_DAYS = 3_650;
export const GRANOLA_INITIAL_IMPORT_DAYS = 31;
export const GRANOLA_CATCH_UP_WINDOW_DAYS = 2;
export const GRANOLA_JOB_RETRY_LIMIT = 3;
export const GRANOLA_MILLISECONDS_PER_DAY = 86_400_000;

View File

@@ -0,0 +1,21 @@
import { type InputJsonSchema } from 'twenty-sdk/logic-function';
import { GRANOLA_MAX_PAGE_SIZE } from 'src/constants/granola-api.constant';
export const GRANOLA_PAGE_PARAMETERS_INPUT_SCHEMA: Record<
'cursor' | 'limit',
InputJsonSchema
> = {
cursor: {
type: 'string',
label: 'Cursor',
description: 'The next cursor returned by the previous page.',
},
limit: {
type: 'integer',
label: 'Limit',
description: `Maximum items in this page, between 1 and ${GRANOLA_MAX_PAGE_SIZE}. Defaults to ${GRANOLA_MAX_PAGE_SIZE}.`,
minimum: 1,
maximum: GRANOLA_MAX_PAGE_SIZE,
},
};

View File

@@ -0,0 +1,4 @@
export const GRANOLA_TRANSCRIPT_MAX_PAGES = 1_000;
export const GRANOLA_TRANSCRIPT_PAGE_SIZE = 100;
export const GRANOLA_TRANSCRIPT_TIMEOUT_MILLISECONDS = 800_000;
export const GRANOLA_UNKNOWN_SPEAKER_LABEL = 'Participant';

View File

@@ -0,0 +1,2 @@
export const GRANOLA_WEBHOOK_REGISTRATION_ROUTE_PATH =
'/granola/webhook-registration';

View File

@@ -0,0 +1 @@
export const GRANOLA_WEBHOOK_REMOVAL_ROUTE_PATH = '/granola/webhook-removal';

View File

@@ -0,0 +1,5 @@
export const GRANOLA_WEBHOOK_REGISTRATION_KEY = 'granola-webhook-registration';
export const GRANOLA_PENDING_REGISTRATION_KEY = 'granola-pending-registration';
export const GRANOLA_HISTORY_SCHEDULE_KEY = 'granola-history-schedule';
export const GRANOLA_PENDING_FOLDER_SELECTION_KEY =
'granola-pending-folder-selection';

View File

@@ -0,0 +1,41 @@
export const APP_DISPLAY_NAME = 'Granola';
export const APP_DESCRIPTION =
'Sync Granola meeting transcripts and summaries into Twenty Call Recordings.';
export const APPLICATION_UNIVERSAL_IDENTIFIER =
'ffcc3979-0ee6-4d7d-912b-c12997d9f0f4';
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER =
'1805440a-233a-4cce-b40e-e94a020b6529';
export const GRANOLA_CONNECTION_STATUS_UNIVERSAL_IDENTIFIER =
'2493ae6c-d3be-4ce0-b303-1ff6472ac907';
export const GRANOLA_REGISTER_WEBHOOK_UNIVERSAL_IDENTIFIER =
'1feab85a-f7f0-41ae-a9ce-cf4c1d807fee';
export const GRANOLA_WEBHOOK_RESOLVER_UNIVERSAL_IDENTIFIER =
'91dd18b2-ddfd-4516-bda6-db0e3a2d3e33';
export const GRANOLA_WEBHOOK_UNIVERSAL_IDENTIFIER =
'1c7fb7ac-89b4-4d01-a4bf-d331b694a0ac';
export const GRANOLA_REMOVE_WEBHOOK_UNIVERSAL_IDENTIFIER =
'4b297591-6fff-42d0-8ebf-d6f614de6555';
export const GRANOLA_UNINSTALL_UNIVERSAL_IDENTIFIER =
'c97f76e2-78b9-4221-9053-c353ea6522b0';
export const GRANOLA_BACKFILL_UNIVERSAL_IDENTIFIER =
'ace20bc6-95c7-4e5b-8c7a-eacd5a85315c';
export const GRANOLA_BACKFILL_WORKER_UNIVERSAL_IDENTIFIER =
'0a66fe57-1fda-47ed-b759-a5f2f55e70d9';
export const GRANOLA_BACKFILL_NOTE_UNIVERSAL_IDENTIFIER =
'87da64cb-14a5-482b-99de-4b12ff2d3557';
export const GRANOLA_DAILY_CATCH_UP_UNIVERSAL_IDENTIFIER =
'a054a421-50cc-4cb5-819d-7ce5f8e22595';
export const GRANOLA_SYNC_NOTE_UNIVERSAL_IDENTIFIER =
'29cd6cdb-ec18-4efb-b012-c8cac315ba9e';
export const GRANOLA_LIST_NOTES_UNIVERSAL_IDENTIFIER =
'26080635-5896-417d-99b5-3227256d1690';
export const GRANOLA_LIST_FOLDERS_UNIVERSAL_IDENTIFIER =
'ad261086-f111-488f-83e9-4b66aa784e38';
export const GRANOLA_SETTINGS_UNIVERSAL_IDENTIFIER =
'8fa70622-f7b2-459d-bea3-dc38bff3680a';
export const GRANOLA_FOLDERS_UNIVERSAL_IDENTIFIER =
'e054fa37-78c9-4f6a-a064-f887b87f1fc5';
export const GRANOLA_UPDATE_WEBHOOK_FOLDERS_UNIVERSAL_IDENTIFIER =
'5092d8b4-b10d-4fcb-b029-c4e93d5234c5';
export const GRANOLA_API_KEY_VARIABLE_UNIVERSAL_IDENTIFIER =
'cbc0f310-d0e9-4033-ae07-0271405195e7';

View File

@@ -0,0 +1,63 @@
import {
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
defineApplicationRole,
SystemPermissionFlag,
} from 'twenty-sdk/define';
import {
APP_DISPLAY_NAME,
DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
export default defineApplicationRole({
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
label: `${APP_DISPLAY_NAME} default role`,
description:
'Reads calendar events to match Granola meetings and writes transcripts, summaries, and metadata to CallRecording records.',
canReadAllObjectRecords: false,
canUpdateAllObjectRecords: false,
canSoftDeleteAllObjectRecords: false,
canDestroyAllObjectRecords: false,
canUpdateAllSettings: false,
canBeAssignedToAgents: false,
canBeAssignedToUsers: false,
canBeAssignedToApiKeys: false,
objectPermissions: [
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.calendarEventParticipant
.universalIdentifier,
canReadObjectRecords: true,
canUpdateObjectRecords: false,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
},
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.calendarEvent.universalIdentifier,
canReadObjectRecords: true,
canUpdateObjectRecords: false,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
},
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.calendarChannelEventAssociation
.universalIdentifier,
canReadObjectRecords: true,
canUpdateObjectRecords: false,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
},
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.callRecording.universalIdentifier,
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
},
],
fieldPermissions: [],
permissionFlagUniversalIdentifiers: [SystemPermissionFlag.APPLICATIONS],
});

View File

@@ -0,0 +1,101 @@
import styled from '@emotion/styled';
import { IconCheck, IconMinus } from 'twenty-ui/icon';
import { themeCssVariables } from 'twenty-ui/theme-constants';
// twenty-ui's Checkbox dispatches a PointerEvent the front-component sandbox does not provide.
const CHECKBOX_BOX_SIZE_PIXELS = 14;
const CHECKBOX_ICON_SIZE_PIXELS = 12;
const StyledCheckbox = styled.button`
align-items: center;
appearance: none;
background: transparent;
border: none;
border-radius: ${() => themeCssVariables.border.radius.md};
cursor: pointer;
display: flex;
flex-shrink: 0;
justify-content: center;
padding: 5px;
&:hover {
background-color: ${() => themeCssVariables.background.transparent.light};
}
&:disabled {
cursor: not-allowed;
}
&:disabled:hover {
background-color: transparent;
}
`;
const StyledBox = styled.span`
align-items: center;
background: transparent;
border: 1px solid ${() => themeCssVariables.border.color.inverted};
border-radius: ${() => themeCssVariables.border.radius.sm};
box-sizing: border-box;
color: ${() => themeCssVariables.font.color.inverted};
display: flex;
height: ${CHECKBOX_BOX_SIZE_PIXELS}px;
justify-content: center;
width: ${CHECKBOX_BOX_SIZE_PIXELS}px;
&[data-checked='true'] {
background: ${() => themeCssVariables.color.blue};
border-color: ${() => themeCssVariables.color.blue};
}
&[data-disabled='true'] {
border-color: ${() => themeCssVariables.border.color.strong};
}
&[data-disabled='true'][data-checked='true'] {
background: ${() => themeCssVariables.color.blue7};
border-color: ${() => themeCssVariables.color.blue7};
}
`;
type CheckboxProps = {
id?: string;
checked: boolean;
indeterminate?: boolean;
disabled?: boolean;
'aria-label'?: string;
onChange: (checked: boolean) => void;
};
export const Checkbox = ({
id,
checked,
indeterminate = false,
disabled = false,
'aria-label': ariaLabel,
onChange,
}: CheckboxProps) => {
const isFilled = checked || indeterminate;
return (
<StyledCheckbox
type="button"
role="checkbox"
id={id}
aria-checked={indeterminate ? 'mixed' : checked ? 'true' : 'false'}
aria-label={ariaLabel}
disabled={disabled}
onClick={() => onChange(!checked)}
>
<StyledBox
data-checked={isFilled ? 'true' : 'false'}
data-disabled={disabled ? 'true' : 'false'}
>
{checked && <IconCheck size={CHECKBOX_ICON_SIZE_PIXELS} stroke={3} />}
{!checked && indeterminate && (
<IconMinus size={CHECKBOX_ICON_SIZE_PIXELS} stroke={3} />
)}
</StyledBox>
</StyledCheckbox>
);
};

View File

@@ -0,0 +1,198 @@
import styled from '@emotion/styled';
import { isNonEmptyString } from '@sniptt/guards';
import { useId, useState } from 'react';
import { t } from 'twenty-sdk/front-component';
import { isDefined } from 'twenty-sdk/utils';
import { Status } from 'twenty-ui/data-display';
import { Loader } from 'twenty-ui/feedback';
import { IconKey, IconTrash } from 'twenty-ui/icon';
import { Button, IconButton, LightButton } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { H2Title } from 'twenty-ui/typography';
import { LabelledSettingsField } from 'src/front-components/components/LabelledSettingsField';
import { SettingsOptionCardContent } from 'src/front-components/components/SettingsOptionCardContent';
import { StyledSettingsCard } from 'src/front-components/components/StyledSettingsCard';
import { StyledSettingsError } from 'src/front-components/components/StyledSettingsError';
import { StyledSettingsSectionStack } from 'src/front-components/components/StyledSettingsSectionStack';
import { StyledSettingsTextInput } from 'src/front-components/components/StyledSettingsTextInput';
import { type GranolaConnectionStatus } from 'src/front-components/types/granola-connection-status.type';
import { isGranolaConnectionReady } from 'src/front-components/utils/is-granola-connection-ready.util';
const StyledKeyForm = styled.form`
display: flex;
flex-direction: column;
gap: ${() => themeCssVariables.spacing[3]};
`;
const StyledActions = styled.div`
display: flex;
gap: ${() => themeCssVariables.spacing[2]};
justify-content: flex-start;
`;
const StyledConnectButtonContainer = styled.div`
button {
gap: ${() => themeCssVariables.spacing[2]};
min-width: ${() => themeCssVariables.spacing[32]};
padding-inline: ${() => themeCssVariables.spacing[4]};
}
`;
const StyledConnectionHint = styled.div`
color: ${() => themeCssVariables.font.color.secondary};
font-family: ${() => themeCssVariables.font.family};
font-size: ${() => themeCssVariables.font.size.sm};
`;
// Button's isLoading animation clips the title; keep the loader in its icon slot.
const ConnectionLoader = () => <Loader />;
type GranolaConnectionSectionProps = {
status: GranolaConnectionStatus;
isConnecting: boolean;
isRemoving: boolean;
connectError: string | undefined;
removeError: string | undefined;
onConnect: (apiKey: string) => Promise<boolean>;
onRemove: () => Promise<void>;
onRetry: () => void;
};
export const GranolaConnectionSection = ({
status,
isConnecting,
isRemoving,
connectError,
removeError,
onConnect,
onRemove,
onRetry,
}: GranolaConnectionSectionProps) => {
const inputId = useId();
const [apiKeyDraft, setApiKeyDraft] = useState('');
const trimmedApiKeyDraft = apiKeyDraft.trim();
const isConnected =
!isConnecting &&
isGranolaConnectionReady(status) &&
!isDefined(connectError);
const connectionError = isConnecting
? undefined
: (connectError ??
(status.isApiKeySet && !isConnected
? t('Could not connect to Granola. Try again.')
: undefined));
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (isConnecting || isRemoving) {
return;
}
if (status.isApiKeySet) {
onRetry();
return;
}
if (!isNonEmptyString(trimmedApiKeyDraft)) {
return;
}
const isSaved = await onConnect(trimmedApiKeyDraft);
if (isSaved) {
setApiKeyDraft('');
}
};
return (
<Section>
<H2Title
title={t('Granola account')}
description={t(
'Requires Granola Business or Enterprise. Create a key in Granola under Settings → Connectors → API keys.',
)}
/>
<StyledSettingsSectionStack>
{isConnected && (
<StyledSettingsCard>
<SettingsOptionCardContent
Icon={IconKey}
title={t('API key')}
description={t(
'New and updated notes appear in Call Recordings automatically.',
)}
>
<Status color="green" text={t('Connected')} />
<IconButton
Icon={IconTrash}
variant="tertiary"
accent="danger"
ariaLabel={
isRemoving ? t('Removing API key') : t('Remove API key')
}
disabled={isRemoving}
onClick={() => onRemove()}
/>
</SettingsOptionCardContent>
</StyledSettingsCard>
)}
{isDefined(removeError) && (
<StyledSettingsError>{removeError}</StyledSettingsError>
)}
{!isConnected && (
<StyledKeyForm onSubmit={handleSubmit}>
<LabelledSettingsField
label={t('API key')}
inputId={inputId}
errorMessage={connectionError}
>
<StyledSettingsTextInput
id={inputId}
type="password"
autoComplete="off"
placeholder={status.isApiKeySet ? '••••••••' : 'grn_…'}
value={apiKeyDraft}
disabled={isConnecting || isRemoving || status.isApiKeySet}
onChange={(event) => setApiKeyDraft(event.target.value)}
/>
</LabelledSettingsField>
<StyledActions>
{status.isApiKeySet && isDefined(connectionError) && (
<LightButton
title={t('Cancel')}
disabled={isRemoving}
onClick={() => onRemove()}
/>
)}
<StyledConnectButtonContainer>
<Button
type="submit"
title={isConnecting ? t('Connecting') : t('Connect')}
accent="blue"
justify="center"
Icon={isConnecting ? ConnectionLoader : undefined}
disabled={
isConnecting ||
isRemoving ||
(!status.isApiKeySet &&
!isNonEmptyString(trimmedApiKeyDraft))
}
/>
</StyledConnectButtonContainer>
</StyledActions>
{isConnecting && (
<StyledConnectionHint role="status">
{t('Configuring your connection. This may take a few seconds.')}
</StyledConnectionHint>
)}
</StyledKeyForm>
)}
</StyledSettingsSectionStack>
</Section>
);
};

View File

@@ -0,0 +1,23 @@
import styled from '@emotion/styled';
import { t } from 'twenty-sdk/front-component';
import { IconFolder } from 'twenty-ui/icon';
import { ICON } from 'twenty-ui/theme';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledEmptyState = styled.div`
align-items: center;
color: ${() => themeCssVariables.font.color.tertiary};
display: flex;
flex-direction: column;
font-size: ${() => themeCssVariables.font.size.sm};
gap: ${() => themeCssVariables.spacing[2]};
padding: ${() => themeCssVariables.spacing[8]};
text-align: center;
`;
export const GranolaFolderEmptyState = () => (
<StyledEmptyState>
<IconFolder size={ICON.size.md} />
<div>{t('No folders found for this key')}</div>
</StyledEmptyState>
);

View File

@@ -0,0 +1,92 @@
import styled from '@emotion/styled';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { type GranolaFolderPolicy } from 'src/front-components/types/granola-folder-policy.type';
// Mirrors twenty-front's SettingsAccountsMessageFolderIcon, which a front component cannot import.
const StyledCardMedia = styled.div`
align-items: center;
background-color: ${() => themeCssVariables.border.color.medium};
border-radius: ${() => themeCssVariables.border.radius.sm};
box-sizing: border-box;
display: flex;
flex-shrink: 0;
height: 40px;
justify-content: center;
padding: 2px;
width: 32px;
`;
const StyledCardMediaContent = styled.div`
align-items: stretch;
background-color: ${() => themeCssVariables.background.secondary};
border-radius: ${() => themeCssVariables.border.radius.xs};
box-sizing: border-box;
display: flex;
flex: 1;
flex-direction: column;
gap: 2px;
height: 100%;
justify-content: center;
min-width: 0;
padding: 2px;
`;
const StyledFolderRow = styled.div`
align-items: center;
display: flex;
flex: 1 0 0;
gap: 2px;
min-height: 0;
`;
const StyledFolderIcon = styled.div<{ $isDisabled: boolean }>`
background-color: ${({ $isDisabled }) =>
$isDisabled
? themeCssVariables.border.color.medium
: themeCssVariables.accent.accent7};
border-radius: 1px;
height: 100%;
width: 4.8px;
`;
const StyledFolderLabel = styled.div<{ $isDisabled: boolean }>`
background-color: ${({ $isDisabled }) =>
$isDisabled
? themeCssVariables.border.color.medium
: themeCssVariables.accent.accent7};
border-radius: 1px;
flex: 1;
height: 100%;
`;
const ROW_IS_DISABLED_WHEN_SELECTING = [true, true, false, true, false];
type GranolaFolderPolicyIconProps = {
policy: GranolaFolderPolicy;
};
export const GranolaFolderPolicyIcon = ({
policy,
}: GranolaFolderPolicyIconProps) => {
const isSelectedFolders = policy === 'SELECTED_FOLDERS';
return (
<StyledCardMedia>
<StyledCardMediaContent>
{ROW_IS_DISABLED_WHEN_SELECTING.map(
(isDisabledWhenSelecting, index) => {
const isDisabled = isSelectedFolders && isDisabledWhenSelecting;
return (
<StyledFolderRow key={index}>
<StyledFolderIcon $isDisabled={isDisabled} />
<StyledFolderLabel $isDisabled={isDisabled} />
</StyledFolderRow>
);
},
)}
</StyledCardMediaContent>
</StyledCardMedia>
);
};

View File

@@ -0,0 +1,202 @@
import { useState } from 'react';
import { t } from 'twenty-sdk/front-component';
import { isDefined } from 'twenty-sdk/utils';
import { Section } from 'twenty-ui/layout';
import { H2Title } from 'twenty-ui/typography';
import { GRANOLA_FOLDER_SELECTION_LIMIT } from 'src/constants/granola-api.constant';
import { GranolaFolderEmptyState } from 'src/front-components/components/GranolaFolderEmptyState';
import { GranolaFolderPolicyIcon } from 'src/front-components/components/GranolaFolderPolicyIcon';
import { GranolaFolderTree } from 'src/front-components/components/GranolaFolderTree';
import { OnMountEffect } from 'src/front-components/components/OnMountEffect';
import { SettingsRadioCard } from 'src/front-components/components/SettingsRadioCard';
import { StyledSettingsError } from 'src/front-components/components/StyledSettingsError';
import { StyledSettingsHint } from 'src/front-components/components/StyledSettingsHint';
import { StyledSettingsSectionStack } from 'src/front-components/components/StyledSettingsSectionStack';
import { useAutosaveGranolaFolderSelection } from 'src/front-components/hooks/use-autosave-granola-folder-selection';
import { type GranolaFolderPolicy } from 'src/front-components/types/granola-folder-policy.type';
import { type GranolaSettingsFolder } from 'src/front-components/types/granola-settings-folder.type';
import { fetchGranolaFoldersOrThrow } from 'src/front-components/utils/fetch-granola-folders-or-throw.util';
type FolderSaveState =
| { kind: 'idle' }
| { kind: 'saving' }
| { kind: 'saved' }
| { kind: 'pending' }
| { kind: 'error'; message: string };
export const GranolaFolderSection = () => {
const [folders, setFolders] = useState<GranolaSettingsFolder[] | undefined>(
undefined,
);
const [loadError, setLoadError] = useState<string | undefined>(undefined);
const [selectedFolderIds, setSelectedFolderIds] = useState<string[]>([]);
const [policy, setPolicy] = useState<GranolaFolderPolicy>('ALL_FOLDERS');
const [hasInaccessibleSelection, setHasInaccessibleSelection] =
useState(false);
const [saveState, setSaveState] = useState<FolderSaveState>({ kind: 'idle' });
const { save } = useAutosaveGranolaFolderSelection({
onSaveStart: () => setSaveState({ kind: 'saving' }),
onSaveSuccess: (folderIds) => {
setSaveState({ kind: 'saved' });
setSelectedFolderIds(folderIds);
},
onSaveError: (message) => setSaveState({ kind: 'error', message }),
});
const loadFolders = async () => {
try {
const result = await fetchGranolaFoldersOrThrow();
const accessibleIds = new Set(result.folders.map((folder) => folder.id));
const storedFolderIds =
result.pendingFolderIds ?? result.selectedFolderIds;
const accessibleSelectedIds = storedFolderIds.filter((id) =>
accessibleIds.has(id),
);
setFolders(result.folders);
setSelectedFolderIds(accessibleSelectedIds);
setPolicy(
storedFolderIds.length > 0 ? 'SELECTED_FOLDERS' : 'ALL_FOLDERS',
);
setHasInaccessibleSelection(
storedFolderIds.length > 0 && accessibleSelectedIds.length === 0,
);
if (isDefined(result.pendingFolderIds)) {
setSaveState({ kind: 'pending' });
}
} catch {
setLoadError(t('Could not load your Granola folders. Reload to retry.'));
}
};
const handlePolicyChange = (nextPolicy: GranolaFolderPolicy) => {
setPolicy(nextPolicy);
if (
nextPolicy === 'ALL_FOLDERS' &&
(selectedFolderIds.length > 0 || hasInaccessibleSelection)
) {
setHasInaccessibleSelection(false);
setSelectedFolderIds([]);
save([]);
}
};
const handleReplaceSelection = (nextSelectedFolderIds: string[]) => {
setHasInaccessibleSelection(false);
setSelectedFolderIds(nextSelectedFolderIds);
save(nextSelectedFolderIds);
};
const handleToggleFolder = (folderId: string, checked: boolean) => {
handleReplaceSelection(
checked
? [...selectedFolderIds, folderId]
: selectedFolderIds.filter((id) => id !== folderId),
);
};
const isSelectionFull =
selectedFolderIds.length >= GRANOLA_FOLDER_SELECTION_LIMIT;
const hasFolders = isDefined(folders) && folders.length > 0;
return (
<Section>
<OnMountEffect onMount={loadFolders} />
<H2Title
title={t('Folders')}
description={t(
'Choose which Granola folders feed live sync and history imports.',
)}
/>
<StyledSettingsSectionStack>
<SettingsRadioCard
value={policy}
disabled={!isDefined(folders)}
onChange={handlePolicyChange}
options={[
{
value: 'ALL_FOLDERS',
cardMedia: <GranolaFolderPolicyIcon policy="ALL_FOLDERS" />,
title: t('Everything'),
description: t('Sync notes from every folder you can access'),
},
{
value: 'SELECTED_FOLDERS',
cardMedia: <GranolaFolderPolicyIcon policy="SELECTED_FOLDERS" />,
title: t('Some folders'),
description: t(
'Sync only the folders you pick, including their subfolders',
),
expandedContent: hasFolders ? (
<GranolaFolderTree
folders={folders}
selectedFolderIds={selectedFolderIds}
selectionLimit={GRANOLA_FOLDER_SELECTION_LIMIT}
onToggleFolder={handleToggleFolder}
onReplaceSelection={handleReplaceSelection}
/>
) : (
<GranolaFolderEmptyState />
),
},
]}
/>
{!isDefined(folders) && !isDefined(loadError) && (
<StyledSettingsHint>{t('Loading folders…')}</StyledSettingsHint>
)}
{isDefined(loadError) && (
<StyledSettingsError>{loadError}</StyledSettingsError>
)}
{hasInaccessibleSelection && (
<StyledSettingsError>
{t(
'The folders you picked are no longer available. Pick folders again, or switch to Everything.',
)}
</StyledSettingsError>
)}
{policy === 'SELECTED_FOLDERS' &&
!hasInaccessibleSelection &&
hasFolders &&
selectedFolderIds.length === 0 && (
<StyledSettingsHint>
{t('Nothing is picked yet, so every folder still syncs.')}
</StyledSettingsHint>
)}
{policy === 'SELECTED_FOLDERS' && selectedFolderIds.length === 1 && (
<StyledSettingsHint>
{t('Switch to Everything to stop filtering by folder.')}
</StyledSettingsHint>
)}
{isSelectionFull && (
<StyledSettingsHint>
{t('You can pick up to {limit} folders.', {
limit: GRANOLA_FOLDER_SELECTION_LIMIT,
})}
</StyledSettingsHint>
)}
{saveState.kind === 'saving' && (
<StyledSettingsHint>{t('Saving…')}</StyledSettingsHint>
)}
{saveState.kind === 'saved' && (
<StyledSettingsHint>
{t('Saved. Live sync and imports now follow this selection.')}
</StyledSettingsHint>
)}
{saveState.kind === 'pending' && (
<StyledSettingsHint>
{t(
'This selection is not applied in Granola yet. It is retried at the next daily catch-up, or change it again to retry now.',
)}
</StyledSettingsHint>
)}
{saveState.kind === 'error' && (
<StyledSettingsError>{saveState.message}</StyledSettingsError>
)}
</StyledSettingsSectionStack>
</Section>
);
};

View File

@@ -0,0 +1,141 @@
import styled from '@emotion/styled';
import { useState } from 'react';
import { t } from 'twenty-sdk/front-component';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { Label } from 'twenty-ui/typography';
import { Checkbox } from 'src/front-components/components/Checkbox';
import { GranolaFolderTreeItem } from 'src/front-components/components/GranolaFolderTreeItem';
import { StyledSettingsHint } from 'src/front-components/components/StyledSettingsHint';
import { StyledSettingsTextInput } from 'src/front-components/components/StyledSettingsTextInput';
import { type GranolaSettingsFolder } from 'src/front-components/types/granola-settings-folder.type';
import { computeGranolaFolderAncestorIds } from 'src/front-components/utils/compute-granola-folder-ancestor-ids.util';
import { computeGranolaFolderTree } from 'src/front-components/utils/compute-granola-folder-tree.util';
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
`;
const StyledSearchInputContainer = styled.div`
margin-bottom: ${() => themeCssVariables.spacing[2]};
`;
const StyledLabelContainer = styled.span`
align-items: center;
color: ${() => themeCssVariables.font.color.tertiary};
display: flex;
margin-bottom: ${() => themeCssVariables.spacing[2]};
margin-top: ${() => themeCssVariables.spacing[2]};
`;
const StyledSectionHeader = styled.div`
align-items: center;
background-color: ${() => themeCssVariables.background.transparent.lighter};
border-bottom: 1px solid ${() => themeCssVariables.border.color.light};
display: flex;
height: ${() => themeCssVariables.spacing[6]};
justify-content: space-between;
padding: 0 ${() => themeCssVariables.spacing[1]};
`;
const StyledTreeList = styled.ul`
list-style: none;
margin: 0;
max-height: 400px;
overflow-y: auto;
padding: ${() => themeCssVariables.spacing[2]} 0;
`;
type GranolaFolderTreeProps = {
folders: GranolaSettingsFolder[];
selectedFolderIds: string[];
selectionLimit: number;
onToggleFolder: (folderId: string, checked: boolean) => void;
onReplaceSelection: (folderIds: string[]) => void;
};
export const GranolaFolderTree = ({
folders,
selectedFolderIds,
selectionLimit,
onToggleFolder,
onReplaceSelection,
}: GranolaFolderTreeProps) => {
const [search, setSearch] = useState('');
const selectedFolderIdSet = new Set(selectedFolderIds);
const ancestorIdsByFolderId = computeGranolaFolderAncestorIds(folders);
const coveredFolderIds = new Set(
folders
.filter((folder) =>
(ancestorIdsByFolderId.get(folder.id) ?? []).some((ancestorId) =>
selectedFolderIdSet.has(ancestorId),
),
)
.map((folder) => folder.id),
);
const rootFolderIds = computeGranolaFolderTree(folders).map(
(node) => node.folder.id,
);
const isEveryFolderSelected = rootFolderIds.every((folderId) =>
selectedFolderIdSet.has(folderId),
);
const canSelectEveryFolder = rootFolderIds.length <= selectionLimit;
const normalizedSearch = search.trim().toLowerCase();
const tree = computeGranolaFolderTree(
normalizedSearch === ''
? folders
: folders.filter((folder) =>
folder.name.toLowerCase().includes(normalizedSearch),
),
);
const handleToggleAllFolders = () => {
onReplaceSelection(isEveryFolderSelected ? [] : rootFolderIds);
};
return (
<StyledContainer>
<StyledSearchInputContainer>
<StyledSettingsTextInput
type="text"
placeholder={t('Search folders...')}
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
</StyledSearchInputContainer>
<StyledLabelContainer>
<Label>{t('Folders')}</Label>
</StyledLabelContainer>
<StyledSectionHeader>
<Label>{t('Toggle all folders')}</Label>
<Checkbox
checked={isEveryFolderSelected}
indeterminate={!isEveryFolderSelected && selectedFolderIds.length > 0}
disabled={!canSelectEveryFolder}
aria-label={t('Toggle all folders')}
onChange={handleToggleAllFolders}
/>
</StyledSectionHeader>
{tree.length === 0 && (
<StyledSettingsHint>
{t('No folder matches your search.')}
</StyledSettingsHint>
)}
<StyledTreeList>
{tree.map((node, index) => (
<GranolaFolderTreeItem
key={node.folder.id}
node={node}
isLast={index === tree.length - 1}
selectedFolderIds={selectedFolderIdSet}
coveredFolderIds={coveredFolderIds}
isSelectionFull={selectedFolderIds.length >= selectionLimit}
onToggleFolder={onToggleFolder}
/>
))}
</StyledTreeList>
</StyledContainer>
);
};

View File

@@ -0,0 +1,92 @@
import styled from '@emotion/styled';
import { themeCssVariables } from 'twenty-ui/theme-constants';
export const GRANOLA_FOLDER_TREE_INDENT_PIXELS = 24;
const ROW_HEIGHT_PIXELS = 28;
const ICON_CENTER_OFFSET_PIXELS = 8;
const StyledOverlay = styled.div<{ $depth: number }>`
height: ${ROW_HEIGHT_PIXELS}px;
left: 0;
pointer-events: none;
position: absolute;
top: 0;
width: ${({ $depth }) => $depth * GRANOLA_FOLDER_TREE_INDENT_PIXELS}px;
`;
const StyledAncestorLine = styled.div<{ $index: number; $isVisible: boolean }>`
background: ${({ $isVisible }) =>
$isVisible ? themeCssVariables.border.color.strong : 'transparent'};
height: ${ROW_HEIGHT_PIXELS}px;
left: ${({ $index }) =>
$index * GRANOLA_FOLDER_TREE_INDENT_PIXELS + ICON_CENTER_OFFSET_PIXELS}px;
position: absolute;
top: 0;
width: 1px;
`;
const StyledConnector = styled.div<{ $depth: number }>`
height: ${ROW_HEIGHT_PIXELS}px;
left: ${({ $depth }) =>
($depth - 1) * GRANOLA_FOLDER_TREE_INDENT_PIXELS +
ICON_CENTER_OFFSET_PIXELS}px;
position: absolute;
top: 0;
width: ${GRANOLA_FOLDER_TREE_INDENT_PIXELS - ICON_CENTER_OFFSET_PIXELS}px;
`;
const StyledVerticalLineTop = styled.div`
background: ${() => themeCssVariables.border.color.strong};
height: 12px;
left: 0;
position: absolute;
top: 0;
width: 1px;
`;
const StyledRoundedCorner = styled.div`
border-bottom: 1px solid ${() => themeCssVariables.border.color.strong};
border-bottom-left-radius: 4px;
border-left: 1px solid ${() => themeCssVariables.border.color.strong};
height: 8px;
left: 0;
position: absolute;
top: 6px;
width: 8px;
`;
const StyledVerticalLineBottom = styled.div`
background: ${() => themeCssVariables.border.color.strong};
height: 16px;
left: 0;
position: absolute;
top: 12px;
width: 1px;
`;
type GranolaFolderTreeBreadcrumbProps = {
depth: number;
isLast: boolean;
parentsIsLastList: boolean[];
};
export const GranolaFolderTreeBreadcrumb = ({
depth,
isLast,
parentsIsLastList,
}: GranolaFolderTreeBreadcrumbProps) => (
<StyledOverlay $depth={depth}>
{parentsIsLastList.map((parentIsLast, index) => (
<StyledAncestorLine
key={index}
$index={index}
$isVisible={!parentIsLast}
/>
))}
<StyledConnector $depth={depth}>
<StyledVerticalLineTop />
<StyledRoundedCorner />
{!isLast && <StyledVerticalLineBottom />}
</StyledConnector>
</StyledOverlay>
);

View File

@@ -0,0 +1,224 @@
import styled from '@emotion/styled';
import { useState } from 'react';
import { t } from 'twenty-sdk/front-component';
import {
IconChevronDown,
IconChevronUp,
IconFolder,
IconFolderRoot,
} from 'twenty-ui/icon';
import { ICON } from 'twenty-ui/theme';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { Checkbox } from 'src/front-components/components/Checkbox';
import {
GRANOLA_FOLDER_TREE_INDENT_PIXELS,
GranolaFolderTreeBreadcrumb,
} from 'src/front-components/components/GranolaFolderTreeBreadcrumb';
import { type GranolaFolderTreeNode } from 'src/front-components/utils/compute-granola-folder-tree.util';
import { countNestedGranolaFolders } from 'src/front-components/utils/count-nested-granola-folders.util';
import { hasSelectedGranolaDescendant } from 'src/front-components/utils/has-selected-granola-descendant.util';
const StyledItem = styled.li`
position: relative;
`;
const StyledNestedList = styled.ul`
list-style: none;
margin: 0;
padding: 0;
`;
const StyledCollapsibleWrapper = styled.div<{ $isExpanded: boolean }>`
display: grid;
grid-template-rows: ${({ $isExpanded }) => ($isExpanded ? '1fr' : '0fr')};
transition: grid-template-rows
calc(${() => themeCssVariables.animation.duration.fast} * 1s) ease-out;
`;
const StyledCollapsibleContent = styled.div`
overflow: hidden;
`;
const StyledRow = styled.div<{ $depth: number }>`
align-items: center;
border-radius: ${() => themeCssVariables.border.radius.sm};
cursor: pointer;
display: flex;
height: 28px;
padding-left: ${({ $depth }) => $depth * GRANOLA_FOLDER_TREE_INDENT_PIXELS}px;
user-select: none;
&:hover {
background-color: ${() => themeCssVariables.background.transparent.lighter};
}
`;
const StyledFolderContent = styled.div`
align-items: center;
color: ${() => themeCssVariables.font.color.tertiary};
display: flex;
flex: 1;
gap: ${() => themeCssVariables.spacing[2]};
min-width: 0;
padding-left: ${() => themeCssVariables.spacing[1]};
padding-right: ${() => themeCssVariables.spacing[1]};
`;
const StyledFolderName = styled.span<{ $disabled: boolean }>`
color: ${({ $disabled }) =>
$disabled
? themeCssVariables.font.color.tertiary
: themeCssVariables.font.color.primary};
font-size: ${() => themeCssVariables.font.size.md};
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const StyledRightSection = styled.div`
align-items: center;
display: flex;
flex-shrink: 0;
gap: ${() => themeCssVariables.spacing[1]};
margin-left: auto;
`;
const StyledChildCount = styled.span`
color: ${() => themeCssVariables.font.color.tertiary};
font-size: ${() => themeCssVariables.font.size.sm};
min-width: ${() => themeCssVariables.spacing[3]};
text-align: right;
`;
const StyledExpandButton = styled.button`
align-items: center;
background: none;
border: none;
color: ${() => themeCssVariables.font.color.tertiary};
cursor: pointer;
display: flex;
height: ${() => themeCssVariables.spacing[4]};
justify-content: center;
padding: 0;
width: ${() => themeCssVariables.spacing[4]};
&:hover {
color: ${() => themeCssVariables.font.color.primary};
}
`;
type GranolaFolderTreeItemProps = {
node: GranolaFolderTreeNode;
depth?: number;
isLast?: boolean;
parentsIsLastList?: boolean[];
selectedFolderIds: Set<string>;
coveredFolderIds: Set<string>;
isSelectionFull: boolean;
onToggleFolder: (folderId: string, checked: boolean) => void;
};
export const GranolaFolderTreeItem = ({
node,
depth = 0,
isLast = false,
parentsIsLastList = [],
selectedFolderIds,
coveredFolderIds,
isSelectionFull,
onToggleFolder,
}: GranolaFolderTreeItemProps) => {
const [isExpanded, setIsExpanded] = useState(true);
const { folder, children } = node;
const hasChildren = children.length > 0;
const isSelected = selectedFolderIds.has(folder.id);
const isCovered = coveredFolderIds.has(folder.id);
const isChecked = isSelected || isCovered;
const isIndeterminate =
!isChecked && hasSelectedGranolaDescendant(node, selectedFolderIds);
const isOnlySelectedFolder = isSelected && selectedFolderIds.size === 1;
const isUnavailable = isCovered || (isSelectionFull && !isChecked);
const isDisabled = isUnavailable || isOnlySelectedFolder;
const FolderIcon = depth > 0 ? IconFolderRoot : IconFolder;
const childParentsIsLastList =
depth > 0 ? [...parentsIsLastList, isLast] : parentsIsLastList;
return (
<StyledItem>
<StyledRow
$depth={depth}
onClick={() => hasChildren && setIsExpanded(!isExpanded)}
>
{depth > 0 && (
<GranolaFolderTreeBreadcrumb
depth={depth}
isLast={isLast}
parentsIsLastList={parentsIsLastList}
/>
)}
<StyledFolderContent>
<FolderIcon size={ICON.size.md} stroke={ICON.stroke.sm} />
<StyledFolderName $disabled={isUnavailable} title={folder.name}>
{folder.name}
</StyledFolderName>
<StyledRightSection>
{hasChildren && (
<>
<StyledChildCount>
{countNestedGranolaFolders(node)}
</StyledChildCount>
<StyledExpandButton
type="button"
aria-label={
isExpanded ? t('Collapse folder') : t('Expand folder')
}
onClick={(event) => {
event.stopPropagation();
setIsExpanded(!isExpanded);
}}
>
{isExpanded ? (
<IconChevronUp size={ICON.size.md} />
) : (
<IconChevronDown size={ICON.size.md} />
)}
</StyledExpandButton>
</>
)}
<span onClick={(event) => event.stopPropagation()}>
<Checkbox
checked={isChecked}
indeterminate={isIndeterminate}
disabled={isDisabled}
aria-label={folder.name}
onChange={(checked) => onToggleFolder(folder.id, checked)}
/>
</span>
</StyledRightSection>
</StyledFolderContent>
</StyledRow>
{hasChildren && (
<StyledCollapsibleWrapper $isExpanded={isExpanded}>
<StyledCollapsibleContent>
<StyledNestedList>
{children.map((child, index) => (
<GranolaFolderTreeItem
key={child.folder.id}
node={child}
depth={depth + 1}
isLast={index === children.length - 1}
parentsIsLastList={childParentsIsLastList}
selectedFolderIds={selectedFolderIds}
coveredFolderIds={coveredFolderIds}
isSelectionFull={isSelectionFull}
onToggleFolder={onToggleFolder}
/>
))}
</StyledNestedList>
</StyledCollapsibleContent>
</StyledCollapsibleWrapper>
)}
</StyledItem>
);
};

View File

@@ -0,0 +1,135 @@
import styled from '@emotion/styled';
import { useId, useState } from 'react';
import { t } from 'twenty-sdk/front-component';
import { isDefined } from 'twenty-sdk/utils';
import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { H2Title } from 'twenty-ui/typography';
import { GRANOLA_BACKFILL_ROUTE_PATH } from 'src/constants/granola-backfill-route-path';
import { GRANOLA_HISTORY_MAX_IMPORT_DAYS } from 'src/constants/granola-history.constant';
import { StyledSettingsError } from 'src/front-components/components/StyledSettingsError';
import { StyledSettingsHint } from 'src/front-components/components/StyledSettingsHint';
import { StyledSettingsSectionStack } from 'src/front-components/components/StyledSettingsSectionStack';
import { StyledSettingsTextInput } from 'src/front-components/components/StyledSettingsTextInput';
import { DEFAULT_GRANOLA_HISTORY_IMPORT_DAYS } from 'src/front-components/constants/default-granola-history-import-days.constant';
import { parseHistoryImportDays } from 'src/front-components/utils/parse-history-import-days.util';
import { postGranolaSettingsRouteOrThrow } from 'src/front-components/utils/post-granola-settings-route-or-throw.util';
const StyledRow = styled.form`
align-items: center;
display: flex;
gap: ${() => themeCssVariables.spacing[2]};
`;
const StyledDaysInputContainer = styled.div`
display: flex;
width: ${() => themeCssVariables.spacing[20]};
`;
const StyledRowLabel = styled.label`
color: ${() => themeCssVariables.font.color.secondary};
font-family: ${() => themeCssVariables.font.family};
font-size: ${() => themeCssVariables.font.size.md};
`;
type ImportFeedback = { kind: 'success' | 'error'; message: string };
export const GranolaImportHistorySection = () => {
const inputId = useId();
const [daysDraft, setDaysDraft] = useState(
String(DEFAULT_GRANOLA_HISTORY_IMPORT_DAYS),
);
const [isImporting, setIsImporting] = useState(false);
const [feedback, setFeedback] = useState<ImportFeedback | undefined>(
undefined,
);
const days = parseHistoryImportDays(daysDraft);
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!isDefined(days)) {
return;
}
setIsImporting(true);
setFeedback(undefined);
try {
await postGranolaSettingsRouteOrThrow({
routePath: GRANOLA_BACKFILL_ROUTE_PATH,
body: { days },
});
setFeedback({
kind: 'success',
message: t(
'Import started. Notes from the last {days} days will appear in Call Recordings over the next few hours.',
{ days },
),
});
} catch (error) {
setFeedback({
kind: 'error',
message:
error instanceof Error
? error.message
: t('Could not start the import. Try again.'),
});
} finally {
setIsImporting(false);
}
};
return (
<Section>
<H2Title
title={t('Import history')}
description={t(
'Bring older notes from the synced folders into Call Recordings. Recordings you deleted in Twenty stay deleted.',
)}
/>
<StyledSettingsSectionStack>
<StyledRow onSubmit={handleSubmit}>
<StyledRowLabel htmlFor={inputId}>
{t('Import the last')}
</StyledRowLabel>
<StyledDaysInputContainer>
<StyledSettingsTextInput
id={inputId}
type="number"
min={1}
max={GRANOLA_HISTORY_MAX_IMPORT_DAYS}
step={1}
value={daysDraft}
disabled={isImporting}
onChange={(event) => setDaysDraft(event.target.value)}
/>
</StyledDaysInputContainer>
<StyledRowLabel htmlFor={inputId}>{t('days')}</StyledRowLabel>
<Button
type="submit"
title={t('Import')}
isLoading={isImporting}
disabled={isImporting || !isDefined(days)}
/>
</StyledRow>
{!isDefined(days) && (
<StyledSettingsError>
{t('Enter a whole number of days between 1 and {max}.', {
max: GRANOLA_HISTORY_MAX_IMPORT_DAYS,
})}
</StyledSettingsError>
)}
{feedback?.kind === 'success' && (
<StyledSettingsHint>{feedback.message}</StyledSettingsHint>
)}
{feedback?.kind === 'error' && (
<StyledSettingsError>{feedback.message}</StyledSettingsError>
)}
</StyledSettingsSectionStack>
</Section>
);
};

View File

@@ -0,0 +1,213 @@
import 'twenty-ui/style.css';
import styled from '@emotion/styled';
import { useState } from 'react';
import {
t,
useColorScheme,
useFrontComponentId,
} from 'twenty-sdk/front-component';
import { isDefined } from 'twenty-sdk/utils';
import { THEME_DARK, THEME_LIGHT } from 'twenty-ui/theme';
import {
ThemeContext,
themeCssVariables,
type ThemeType,
} from 'twenty-ui/theme-constants';
import { GranolaFolderSection } from 'src/front-components/components/GranolaFolderSection';
import { GranolaImportHistorySection } from 'src/front-components/components/GranolaImportHistorySection';
import { GRANOLA_WEBHOOK_REGISTRATION_ROUTE_PATH } from 'src/constants/granola-webhook-registration-route-path';
import { GRANOLA_WEBHOOK_REMOVAL_ROUTE_PATH } from 'src/constants/granola-webhook-removal-route-path';
import { GranolaConnectionSection } from 'src/front-components/components/GranolaConnectionSection';
import { OnMountEffect } from 'src/front-components/components/OnMountEffect';
import { type GranolaConnectionStatus } from 'src/front-components/types/granola-connection-status.type';
import { fetchGranolaConnectionStatusOrThrow } from 'src/front-components/utils/fetch-granola-connection-status-or-throw.util';
import { isGranolaConnectionReady } from 'src/front-components/utils/is-granola-connection-ready.util';
import { postGranolaSettingsRouteOrThrow } from 'src/front-components/utils/post-granola-settings-route-or-throw.util';
import { setGranolaApiKeyOrThrow } from 'src/front-components/utils/set-granola-api-key-or-throw.util';
const StyledContainer = styled.div`
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: ${() => themeCssVariables.spacing[8]};
width: 100%;
button svg {
pointer-events: none;
}
`;
const StyledNotice = styled.div`
color: ${() => themeCssVariables.font.color.tertiary};
font-family: ${() => themeCssVariables.font.family};
font-size: ${() => themeCssVariables.font.size.md};
line-height: ${() => themeCssVariables.text.lineHeight.md};
`;
type GranolaSettingsState =
| { step: 'loading' }
| { step: 'unavailable' }
| { step: 'ready'; status: GranolaConnectionStatus };
const shouldSetUpLiveSync = (status: GranolaConnectionStatus) =>
status.isConnected &&
status.canManage &&
status.needsRegistration &&
!isDefined(status.registration);
export const GranolaSettings = () => {
const colorScheme = useColorScheme();
const frontComponentId = useFrontComponentId();
const [state, setState] = useState<GranolaSettingsState>({ step: 'loading' });
const [isConnecting, setIsConnecting] = useState(false);
const [connectError, setConnectError] = useState<string | undefined>();
const [isRemoving, setIsRemoving] = useState(false);
const [removeError, setRemoveError] = useState<string | undefined>();
const [isRegistering, setIsRegistering] = useState(false);
const [registrationError, setRegistrationError] = useState<
string | undefined
>();
const refreshConnectionStatus = async () => {
try {
const status = await fetchGranolaConnectionStatusOrThrow();
setState({ step: 'ready', status });
return status;
} catch {
setRegistrationError(t('Could not connect to Granola. Try again.'));
setState((current) =>
current.step === 'ready' ? current : { step: 'unavailable' },
);
return undefined;
}
};
const registerLiveSync = async () => {
setIsRegistering(true);
setRegistrationError(undefined);
try {
await postGranolaSettingsRouteOrThrow({
routePath: GRANOLA_WEBHOOK_REGISTRATION_ROUTE_PATH,
body: {},
});
} catch {
setRegistrationError(t('Could not connect to Granola. Try again.'));
}
await refreshConnectionStatus();
setIsRegistering(false);
};
const loadConnectionStatus = async () => {
setIsRegistering(true);
setRegistrationError(undefined);
const status = await refreshConnectionStatus();
if (isDefined(status) && shouldSetUpLiveSync(status)) {
await registerLiveSync();
}
setIsRegistering(false);
};
const handleConnect = async (apiKey: string): Promise<boolean> => {
setIsConnecting(true);
setConnectError(undefined);
try {
await setGranolaApiKeyOrThrow({ frontComponentId, apiKey });
} catch {
setConnectError(t('Could not save the API key. Try again.'));
setIsConnecting(false);
return false;
}
await loadConnectionStatus();
setIsConnecting(false);
return true;
};
// Deleting the endpoint needs the key that created it, so the webhook goes
// before the variable is cleared.
const handleRemove = async () => {
setIsRemoving(true);
setRemoveError(undefined);
try {
await postGranolaSettingsRouteOrThrow({
routePath: GRANOLA_WEBHOOK_REMOVAL_ROUTE_PATH,
body: {},
});
await setGranolaApiKeyOrThrow({ frontComponentId, apiKey: '' });
setConnectError(undefined);
setRegistrationError(undefined);
} catch {
setRemoveError(t('Could not remove the API key. Try again.'));
}
await refreshConnectionStatus();
setIsRemoving(false);
};
// twenty-ui components read icon sizes off ThemeContext, and the context
// default resolves them to var() strings an SVG size attribute cannot use.
return (
<ThemeContext.Provider
value={{
theme: (colorScheme === 'dark'
? THEME_DARK
: THEME_LIGHT) as unknown as ThemeType,
colorScheme,
}}
>
<StyledContainer>
<OnMountEffect onMount={loadConnectionStatus} />
{state.step === 'loading' && (
<StyledNotice>{t('Checking your Granola connection…')}</StyledNotice>
)}
{state.step === 'unavailable' && (
<StyledNotice>
{t(
'Could not load Granola settings. Reload the page to try again.',
)}
</StyledNotice>
)}
{state.step === 'ready' && (
<>
<GranolaConnectionSection
status={state.status}
isConnecting={isConnecting || isRegistering}
isRemoving={isRemoving}
connectError={connectError ?? registrationError}
removeError={removeError}
onConnect={handleConnect}
onRemove={handleRemove}
onRetry={
state.status.isConnected && state.status.canManage
? registerLiveSync
: loadConnectionStatus
}
/>
{state.status.canManage &&
isGranolaConnectionReady(state.status) &&
!isConnecting &&
!isRegistering &&
!isDefined(registrationError) && (
<>
<GranolaFolderSection />
<GranolaImportHistorySection />
</>
)}
</>
)}
</StyledContainer>
</ThemeContext.Provider>
);
};

View File

@@ -0,0 +1,52 @@
import styled from '@emotion/styled';
import { isNonEmptyString } from '@sniptt/guards';
import { type ReactNode } from 'react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { StyledSettingsError } from 'src/front-components/components/StyledSettingsError';
const StyledField = styled.div`
display: flex;
flex-direction: column;
`;
// twenty-front hard-codes this size in FieldLabel.module.scss; there is no token.
const StyledLabel = styled.label`
color: ${() => themeCssVariables.font.color.primary};
font-family: ${() => themeCssVariables.font.family};
font-size: 11px;
font-weight: ${() => themeCssVariables.font.weight.semiBold};
margin-bottom: ${() => themeCssVariables.spacing[1]};
`;
const StyledHint = styled.span`
color: ${() => themeCssVariables.font.color.secondary};
font-family: ${() => themeCssVariables.font.family};
font-size: ${() => themeCssVariables.font.size.sm};
margin-top: ${() => themeCssVariables.spacing[3]};
`;
type LabelledSettingsFieldProps = {
label: string;
inputId: string;
hint?: string;
errorMessage?: string;
children: ReactNode;
};
export const LabelledSettingsField = ({
label,
inputId,
hint,
errorMessage,
children,
}: LabelledSettingsFieldProps) => (
<StyledField>
<StyledLabel htmlFor={inputId}>{label}</StyledLabel>
{children}
{isNonEmptyString(errorMessage) && (
<StyledSettingsError>{errorMessage}</StyledSettingsError>
)}
{isNonEmptyString(hint) && <StyledHint>{hint}</StyledHint>}
</StyledField>
);

View File

@@ -0,0 +1,13 @@
import { useEffect } from 'react';
type OnMountEffectProps = {
onMount: () => void;
};
export const OnMountEffect = ({ onMount }: OnMountEffectProps) => {
useEffect(() => {
onMount();
}, []);
return null;
};

View File

@@ -0,0 +1,82 @@
import styled from '@emotion/styled';
import { themeCssVariables } from 'twenty-ui/theme-constants';
// twenty-ui's Radio is a base-ui control whose click handler constructs a
// PointerEvent, which the front component sandbox does not provide.
const RADIO_CONTROL_SIZE_PIXELS = 16;
const RADIO_INDICATOR_SIZE_PIXELS = 6;
const StyledRadio = styled.button`
align-items: center;
appearance: none;
background: transparent;
border: none;
cursor: pointer;
display: inline-flex;
justify-content: center;
padding: 3px;
&:disabled {
cursor: not-allowed;
opacity: 0.32;
}
`;
const StyledControl = styled.span`
align-items: center;
background-color: transparent;
border: 1px solid ${() => themeCssVariables.font.color.secondary};
border-radius: 50%;
box-sizing: border-box;
corner-shape: round;
display: inline-flex;
height: ${RADIO_CONTROL_SIZE_PIXELS}px;
justify-content: center;
transition: transform
calc(${() => themeCssVariables.animation.duration.normal} * 1s) ease;
width: ${RADIO_CONTROL_SIZE_PIXELS}px;
&[data-checked='true'] {
background-color: ${() => themeCssVariables.color.blue};
border-color: ${() => themeCssVariables.color.blue};
transform: scale(1.05);
}
`;
const StyledIndicator = styled.span`
background-color: ${() => themeCssVariables.background.primary};
border-radius: 50%;
corner-shape: round;
height: ${RADIO_INDICATOR_SIZE_PIXELS}px;
width: ${RADIO_INDICATOR_SIZE_PIXELS}px;
`;
type RadioProps = {
checked: boolean;
disabled?: boolean;
'aria-labelledby'?: string;
'aria-describedby'?: string;
onSelect: () => void;
};
export const Radio = ({
checked,
disabled = false,
'aria-labelledby': ariaLabelledBy,
'aria-describedby': ariaDescribedBy,
onSelect,
}: RadioProps) => (
<StyledRadio
type="button"
role="radio"
aria-checked={checked ? 'true' : 'false'}
aria-labelledby={ariaLabelledBy}
aria-describedby={ariaDescribedBy}
disabled={disabled}
onClick={onSelect}
>
<StyledControl data-checked={checked ? 'true' : 'false'}>
{checked && <StyledIndicator />}
</StyledControl>
</StyledRadio>
);

View File

@@ -0,0 +1,14 @@
import styled from '@emotion/styled';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledHr = styled.hr`
border: none;
border-top: 1px solid ${() => themeCssVariables.border.color.light};
margin: 0px;
margin-left: ${() => themeCssVariables.spacing[4]};
margin-right: ${() => themeCssVariables.spacing[4]};
`;
export const Separator = () => {
return <StyledHr />;
};

View File

@@ -0,0 +1,41 @@
import styled from '@emotion/styled';
import { themeCssVariables } from 'twenty-ui/theme-constants';
export const StyledSettingsCardContent = styled.div`
align-items: center;
background-color: ${() => themeCssVariables.background.secondary};
display: flex;
gap: ${() => themeCssVariables.spacing[3]};
padding: ${() => themeCssVariables.spacing[4]};
`;
export const StyledSettingsCardIcon = styled.div`
align-items: center;
background-color: ${() => themeCssVariables.background.primary};
border: 2px solid ${() => themeCssVariables.border.color.light};
border-radius: ${() => themeCssVariables.border.radius.sm};
box-sizing: border-box;
display: flex;
height: ${() => themeCssVariables.spacing[8]};
justify-content: center;
min-width: ${() => themeCssVariables.spacing[8]};
width: ${() => themeCssVariables.spacing[8]};
`;
export const StyledSettingsCardTitle = styled.div`
color: ${() => themeCssVariables.font.color.primary};
font-weight: ${() => themeCssVariables.font.weight.medium};
margin-bottom: ${() => themeCssVariables.spacing[1]};
`;
export const StyledSettingsCardTextContainer = styled.div`
flex: 1;
min-width: 0;
`;
export const StyledSettingsCardDescription = styled.div`
color: ${() => themeCssVariables.font.color.secondary};
font-size: ${() => themeCssVariables.font.size.sm};
line-height: ${() => themeCssVariables.text.lineHeight.lg};
overflow: hidden;
`;

View File

@@ -0,0 +1,52 @@
import styled from '@emotion/styled';
import { type IconComponent } from 'twenty-ui/icon';
import { OverflowingTextWithTooltip } from 'twenty-ui/surfaces';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import {
StyledSettingsCardContent,
StyledSettingsCardDescription,
StyledSettingsCardIcon,
StyledSettingsCardTextContainer,
StyledSettingsCardTitle,
} from 'src/front-components/components/SettingsCardContentBase';
import { SettingsOptionIconCustomizer } from 'src/front-components/components/SettingsOptionIconCustomizer';
const StyledTrailingContainer = styled.div`
align-items: center;
display: flex;
flex-shrink: 0;
gap: ${() => themeCssVariables.spacing[2]};
margin-left: auto;
`;
type SettingsOptionCardContentProps = {
Icon?: IconComponent;
title: React.ReactNode;
description?: string;
children?: React.ReactNode;
};
export const SettingsOptionCardContent = ({
Icon,
title,
description,
children,
}: SettingsOptionCardContentProps) => (
<StyledSettingsCardContent>
{Icon && (
<StyledSettingsCardIcon>
<SettingsOptionIconCustomizer Icon={Icon} />
</StyledSettingsCardIcon>
)}
<StyledSettingsCardTextContainer>
<StyledSettingsCardTitle>{title}</StyledSettingsCardTitle>
{description && (
<StyledSettingsCardDescription>
<OverflowingTextWithTooltip text={description} />
</StyledSettingsCardDescription>
)}
</StyledSettingsCardTextContainer>
<StyledTrailingContainer>{children}</StyledTrailingContainer>
</StyledSettingsCardContent>
);

View File

@@ -0,0 +1,33 @@
import styled from '@emotion/styled';
import { type IconComponent } from 'twenty-ui/icon';
import { ICON } from 'twenty-ui/theme';
import { themeCssVariables } from 'twenty-ui/theme-constants';
// twenty-front reads these off ThemeContext, but a front component has no
// ThemeProvider: the context default resolves every value to a var() string,
// which an SVG size attribute cannot use. Sizes come from the numeric ICON
// constants and the colour is inherited through currentColor instead.
const StyledIconCustomizer = styled.div<{ $zoom: number; $rotate: number }>`
align-items: center;
color: ${() => themeCssVariables.IllustrationIcon.color.gray};
display: inline-flex;
justify-content: center;
pointer-events: none;
transform: ${({ $zoom, $rotate }) => `scale(${$zoom}) rotate(${$rotate}deg)`};
`;
type SettingsOptionIconCustomizerProps = {
Icon: IconComponent;
zoom?: number;
rotate?: number;
};
export const SettingsOptionIconCustomizer = ({
Icon,
zoom = 1,
rotate = -4,
}: SettingsOptionIconCustomizerProps) => (
<StyledIconCustomizer $zoom={zoom} $rotate={rotate}>
<Icon size={ICON.size.lg} stroke={ICON.stroke.md} />
</StyledIconCustomizer>
);

View File

@@ -0,0 +1,109 @@
import styled from '@emotion/styled';
import { Fragment, useId } from 'react';
import { isDefined } from 'twenty-sdk/utils';
import { OverflowingTextWithTooltip } from 'twenty-ui/surfaces';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { Radio } from 'src/front-components/components/Radio';
import { Separator } from 'src/front-components/components/Separator';
import {
StyledSettingsCardDescription,
StyledSettingsCardTextContainer,
StyledSettingsCardTitle,
} from 'src/front-components/components/SettingsCardContentBase';
import { StyledSettingsCard } from 'src/front-components/components/StyledSettingsCard';
const StyledOptionRow = styled.div<{ $disabled: boolean }>`
align-items: center;
background-color: ${() => themeCssVariables.background.secondary};
cursor: ${({ $disabled }) => ($disabled ? 'default' : 'pointer')};
display: flex;
gap: ${() => themeCssVariables.spacing[4]};
padding: ${() => themeCssVariables.spacing[4]};
pointer-events: ${({ $disabled }) => ($disabled ? 'none' : 'auto')};
&:hover {
background: ${() => themeCssVariables.background.transparent.lighter};
}
`;
const StyledRadioContainer = styled.span`
align-items: center;
display: flex;
flex-shrink: 0;
margin-left: auto;
`;
const StyledExpandedContent = styled.div`
background-color: ${() => themeCssVariables.background.secondary};
padding: 0 ${() => themeCssVariables.spacing[4]}
${() => themeCssVariables.spacing[4]};
`;
export type SettingsRadioCardOption<TValue extends string> = {
value: TValue;
cardMedia: React.ReactNode;
title: string;
description: string;
expandedContent?: React.ReactNode;
};
type SettingsRadioCardProps<TValue extends string> = {
options: SettingsRadioCardOption<TValue>[];
value: TValue;
disabled?: boolean;
onChange: (value: TValue) => void;
};
export const SettingsRadioCard = <TValue extends string>({
options,
value,
disabled = false,
onChange,
}: SettingsRadioCardProps<TValue>) => {
const groupId = useId();
return (
<StyledSettingsCard role="radiogroup">
{options.map((option, index) => {
const isSelected = option.value === value;
const titleId = `${groupId}-${index}-title`;
const descriptionId = `${groupId}-${index}-description`;
return (
<Fragment key={option.value}>
<StyledOptionRow
$disabled={disabled}
onClick={() => onChange(option.value)}
>
{option.cardMedia}
<StyledSettingsCardTextContainer>
<StyledSettingsCardTitle id={titleId}>
{option.title}
</StyledSettingsCardTitle>
<StyledSettingsCardDescription id={descriptionId}>
<OverflowingTextWithTooltip text={option.description} />
</StyledSettingsCardDescription>
</StyledSettingsCardTextContainer>
<StyledRadioContainer>
<Radio
checked={isSelected}
disabled={disabled}
aria-labelledby={titleId}
aria-describedby={descriptionId}
onSelect={() => onChange(option.value)}
/>
</StyledRadioContainer>
</StyledOptionRow>
{isSelected && isDefined(option.expandedContent) && (
<StyledExpandedContent>
{option.expandedContent}
</StyledExpandedContent>
)}
{index < options.length - 1 && <Separator />}
</Fragment>
);
})}
</StyledSettingsCard>
);
};

View File

@@ -0,0 +1,23 @@
import styled from '@emotion/styled';
import { themeCssVariables } from 'twenty-ui/theme-constants';
// Mirrors twenty-ui's Card without its overflow: hidden, which would clip the
// select menus that open inline below their rows. The rows paint their own
// background, so the first and last ones take over the card's corners.
export const StyledSettingsCard = styled.div`
border: 1px solid ${() => themeCssVariables.border.color.medium};
border-radius: ${() => themeCssVariables.border.radius.md};
box-sizing: border-box;
color: ${() => themeCssVariables.font.color.secondary};
width: 100%;
& > :first-child {
border-top-left-radius: inherit;
border-top-right-radius: inherit;
}
& > :last-child {
border-bottom-left-radius: inherit;
border-bottom-right-radius: inherit;
}
`;

View File

@@ -0,0 +1,9 @@
import styled from '@emotion/styled';
import { themeCssVariables } from 'twenty-ui/theme-constants';
export const StyledSettingsError = styled.div`
color: ${() => themeCssVariables.font.color.danger};
font-family: ${() => themeCssVariables.font.family};
font-size: ${() => themeCssVariables.font.size.xs};
margin-top: ${() => themeCssVariables.spacing[1]};
`;

View File

@@ -0,0 +1,9 @@
import styled from '@emotion/styled';
import { themeCssVariables } from 'twenty-ui/theme-constants';
export const StyledSettingsHint = styled.div`
color: ${() => themeCssVariables.font.color.tertiary};
font-family: ${() => themeCssVariables.font.family};
font-size: ${() => themeCssVariables.font.size.sm};
line-height: ${() => themeCssVariables.text.lineHeight.lg};
`;

View File

@@ -0,0 +1,8 @@
import styled from '@emotion/styled';
import { themeCssVariables } from 'twenty-ui/theme-constants';
export const StyledSettingsSectionStack = styled.div`
display: flex;
flex-direction: column;
gap: ${() => themeCssVariables.spacing[4]};
`;

View File

@@ -0,0 +1,11 @@
import styled from '@emotion/styled';
import { getSettingsControlStyles } from 'src/front-components/utils/get-settings-control-styles.util';
export const StyledSettingsTextInput = styled.input`
display: flex;
flex-grow: 1;
height: 32px;
text-overflow: ellipsis;
${getSettingsControlStyles}
`;

View File

@@ -0,0 +1 @@
export const DEFAULT_GRANOLA_HISTORY_IMPORT_DAYS = 90;

View File

@@ -0,0 +1,44 @@
import { useState } from 'react';
import { t } from 'twenty-sdk/front-component';
import { createSaveQueue } from 'src/front-components/utils/create-save-queue.util';
import { saveGranolaFolderSelectionOrThrow } from 'src/front-components/utils/save-granola-folder-selection-or-throw.util';
type UseAutosaveGranolaFolderSelectionParams = {
onSaveStart: () => void;
onSaveSuccess: (folderIds: string[]) => void;
onSaveError: (message: string) => void;
};
export const useAutosaveGranolaFolderSelection = ({
onSaveStart,
onSaveSuccess,
onSaveError,
}: UseAutosaveGranolaFolderSelectionParams) => {
const [saveQueue] = useState(() =>
createSaveQueue<string[]>({
saveValue: async (folderIds, isSupersededValue) => {
onSaveStart();
try {
const savedFolderIds =
await saveGranolaFolderSelectionOrThrow(folderIds);
if (!isSupersededValue()) {
onSaveSuccess(savedFolderIds);
}
} catch (error) {
if (!isSupersededValue()) {
onSaveError(
error instanceof Error
? error.message
: t('Could not save the folder selection. Try again.'),
);
}
}
},
}),
);
return { save: saveQueue.enqueueSave };
};

View File

@@ -0,0 +1,11 @@
import { defineSettingsFrontComponent } from 'twenty-sdk/define';
import { GRANOLA_SETTINGS_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { GranolaSettings } from 'src/front-components/components/GranolaSettings';
export default defineSettingsFrontComponent({
universalIdentifier: GRANOLA_SETTINGS_UNIVERSAL_IDENTIFIER,
name: 'granola-settings',
description:
'Granola account connection, folder, and history import settings.',
component: GranolaSettings,
});

View File

@@ -0,0 +1,22 @@
import { z } from 'zod';
import { GRANOLA_WEBHOOK_SCOPE_SCHEMA } from 'src/logic-functions/types/granola-api.type';
export const GRANOLA_CONNECTION_STATUS_SCHEMA = z.object({
isConnected: z.boolean(),
isApiKeySet: z.boolean(),
isGranolaReachable: z.boolean().default(true),
canManage: z.boolean().default(false),
needsRegistration: z.boolean().default(false),
error: z.string().optional(),
registration: z
.object({
scopes: z.array(GRANOLA_WEBHOOK_SCOPE_SCHEMA),
isActive: z.boolean(),
})
.optional(),
});
export type GranolaConnectionStatus = z.infer<
typeof GRANOLA_CONNECTION_STATUS_SCHEMA
>;

View File

@@ -0,0 +1 @@
export type GranolaFolderPolicy = 'ALL_FOLDERS' | 'SELECTED_FOLDERS';

View File

@@ -0,0 +1,11 @@
import { z } from 'zod';
export const GRANOLA_SETTINGS_FOLDER_SCHEMA = z.object({
id: z.string(),
name: z.string(),
parent_folder_id: z.string().nullable(),
});
export type GranolaSettingsFolder = z.infer<
typeof GRANOLA_SETTINGS_FOLDER_SCHEMA
>;

View File

@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest';
import { computeGranolaFolderAncestorIds } from 'src/front-components/utils/compute-granola-folder-ancestor-ids.util';
describe('computeGranolaFolderAncestorIds', () => {
it('lists ancestors nearest first and stays finite on missing parents and cycles', () => {
const ancestorIds = computeGranolaFolderAncestorIds([
{ id: 'root', name: 'Sales', parent_folder_id: null },
{ id: 'parent', name: 'Europe', parent_folder_id: 'root' },
{ id: 'child', name: 'Calls', parent_folder_id: 'parent' },
{ id: 'orphan', name: 'Old', parent_folder_id: 'missing' },
{ id: 'cycle', name: 'Team', parent_folder_id: 'cycle' },
]);
expect(ancestorIds.get('child')).toEqual(['parent', 'root']);
expect(ancestorIds.get('root')).toEqual([]);
expect(ancestorIds.get('orphan')).toEqual([]);
expect(ancestorIds.get('cycle')).toEqual([]);
});
});

View File

@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest';
import { computeGranolaFolderTree } from 'src/front-components/utils/compute-granola-folder-tree.util';
const toNames = (
nodes: ReturnType<typeof computeGranolaFolderTree>,
): unknown[] => nodes.map((node) => [node.folder.name, toNames(node.children)]);
describe('computeGranolaFolderTree', () => {
it('nests children under their parent sorted by name at every level', () => {
expect(
toNames(
computeGranolaFolderTree([
{ id: 'child', name: 'Calls', parent_folder_id: 'parent' },
{ id: 'sibling', name: 'Sales - EMEA', parent_folder_id: null },
{ id: 'root', name: 'Sales', parent_folder_id: null },
{ id: 'parent', name: 'Europe', parent_folder_id: 'root' },
{ id: 'asia', name: 'Asia', parent_folder_id: 'root' },
]),
),
).toEqual([
[
'Sales',
[
['Asia', []],
['Europe', [['Calls', []]]],
],
],
['Sales - EMEA', []],
]);
});
it('lifts folders with an inaccessible parent or cyclic ancestry to the top level', () => {
expect(
toNames(
computeGranolaFolderTree([
{ id: 'orphan', name: 'Calls', parent_folder_id: 'missing' },
{ id: 'self', name: 'Team', parent_folder_id: 'self' },
{ id: 'first', name: 'Loop A', parent_folder_id: 'second' },
{ id: 'second', name: 'Loop B', parent_folder_id: 'first' },
]),
),
).toEqual([
['Calls', []],
['Team', []],
['Loop A', [['Loop B', []]]],
]);
});
});

View File

@@ -0,0 +1,89 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createSaveQueue } from 'src/front-components/utils/create-save-queue.util';
describe('createSaveQueue', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('serializes saves and replaces pending values with the latest selection', async () => {
const saveValue = vi.fn(
async (_value: string[], _isSupersededValue: () => boolean) => {
await new Promise<void>((resolve) => setTimeout(resolve, 100));
},
);
const { enqueueSave } = createSaveQueue({ saveValue });
enqueueSave(['first']);
const isFirstValueSuperseded = saveValue.mock.calls[0][1];
expect(isFirstValueSuperseded()).toBe(false);
enqueueSave(['second']);
enqueueSave(['latest']);
expect(saveValue).toHaveBeenCalledTimes(1);
expect(isFirstValueSuperseded()).toBe(true);
await vi.advanceTimersByTimeAsync(100);
expect(saveValue.mock.calls.map(([value]) => value)).toEqual([
['first'],
['latest'],
]);
expect(saveValue.mock.calls[1][1]()).toBe(false);
await vi.runAllTimersAsync();
expect(saveValue).toHaveBeenCalledTimes(2);
});
it('starts saving again after the queue has drained, including an empty selection', async () => {
const saveValue = vi.fn(async (_value: string[]) => {});
const { enqueueSave } = createSaveQueue({ saveValue });
enqueueSave(['first']);
await vi.runAllTimersAsync();
enqueueSave([]);
await vi.runAllTimersAsync();
expect(saveValue.mock.calls.map(([value]) => value)).toEqual([
['first'],
[],
]);
});
it('drains values enqueued from within a save without overlapping saves', async () => {
const savedValues: string[][] = [];
const saveValue = vi.fn(
async (value: string[], isSupersededValue: () => boolean) => {
if (value.includes('first')) {
enqueueSave(['next']);
expect(saveValue).toHaveBeenCalledTimes(1);
expect(isSupersededValue()).toBe(true);
}
await new Promise<void>((resolve) => setTimeout(resolve, 100));
savedValues.push(value);
},
);
const { enqueueSave } = createSaveQueue({ saveValue });
enqueueSave(['first']);
await vi.advanceTimersByTimeAsync(100);
expect(savedValues).toEqual([['first']]);
expect(saveValue).toHaveBeenCalledTimes(2);
await vi.runAllTimersAsync();
expect(savedValues).toEqual([['first'], ['next']]);
});
});

View File

@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';
import { GRANOLA_CONNECTION_STATUS_SCHEMA } from 'src/front-components/types/granola-connection-status.type';
import { isGranolaConnectionReady } from 'src/front-components/utils/is-granola-connection-ready.util';
describe('isGranolaConnectionReady', () => {
it('requires an accepted key and an active registration', () => {
expect(
isGranolaConnectionReady(
GRANOLA_CONNECTION_STATUS_SCHEMA.parse({
isConnected: true,
isApiKeySet: true,
registration: { scopes: ['workspace'], isActive: true },
}),
),
).toBe(true);
});
it.each([
{ registration: undefined },
{ registration: { scopes: ['workspace'], isActive: false } },
{ needsRegistration: true },
{ isConnected: false },
{ isGranolaReachable: false },
{ error: 'Connection failed' },
])(
'does not show connected for an incomplete or failed connection: %j',
(overrides) => {
expect(
isGranolaConnectionReady(
GRANOLA_CONNECTION_STATUS_SCHEMA.parse({
isConnected: true,
isApiKeySet: true,
registration: { scopes: ['workspace'], isActive: true },
...overrides,
}),
),
).toBe(false);
},
);
});

View File

@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest';
import { parseHistoryImportDays } from 'src/front-components/utils/parse-history-import-days.util';
describe('parseHistoryImportDays', () => {
it.each([
{ text: '1', days: 1 },
{ text: '3650', days: 3650 },
{ text: ' 31 ', days: 31 },
])('accepts $text as $days days', ({ text, days }) => {
expect(parseHistoryImportDays(text)).toBe(days);
});
it.each([
'',
' ',
'0',
'-1',
'3651',
'1.5',
'31days',
'1e2',
'0x1f',
'Infinity',
])('rejects %j instead of silently importing another date range', (text) => {
expect(parseHistoryImportDays(text)).toBeUndefined();
});
});

View File

@@ -0,0 +1,31 @@
import { isDefined } from 'twenty-sdk/utils';
import { type GranolaSettingsFolder } from 'src/front-components/types/granola-settings-folder.type';
export const computeGranolaFolderAncestorIds = (
folders: GranolaSettingsFolder[],
): Map<string, string[]> => {
const foldersById = new Map(folders.map((folder) => [folder.id, folder]));
return new Map(
folders.map((folder) => {
const ancestorIds: string[] = [];
const visitedIds = new Set([folder.id]);
let parentId = folder.parent_folder_id;
while (isDefined(parentId) && !visitedIds.has(parentId)) {
visitedIds.add(parentId);
const parent = foldersById.get(parentId);
if (!isDefined(parent)) {
break;
}
ancestorIds.push(parent.id);
parentId = parent.parent_folder_id;
}
return [folder.id, ancestorIds];
}),
);
};

View File

@@ -0,0 +1,66 @@
import { isDefined } from 'twenty-sdk/utils';
import { type GranolaSettingsFolder } from 'src/front-components/types/granola-settings-folder.type';
export type GranolaFolderTreeNode = {
folder: GranolaSettingsFolder;
children: GranolaFolderTreeNode[];
};
const compareByName = (
left: GranolaSettingsFolder,
right: GranolaSettingsFolder,
) => left.name.localeCompare(right.name);
export const computeGranolaFolderTree = (
folders: GranolaSettingsFolder[],
): GranolaFolderTreeNode[] => {
const folderIds = new Set(folders.map((folder) => folder.id));
const childrenByParentId = new Map<string, GranolaSettingsFolder[]>();
const rootFolders: GranolaSettingsFolder[] = [];
for (const folder of folders) {
const parentId = folder.parent_folder_id;
if (
!isDefined(parentId) ||
parentId === folder.id ||
!folderIds.has(parentId)
) {
rootFolders.push(folder);
continue;
}
childrenByParentId.set(parentId, [
...(childrenByParentId.get(parentId) ?? []),
folder,
]);
}
const visitedIds = new Set<string>();
const buildNode = (folder: GranolaSettingsFolder): GranolaFolderTreeNode => {
visitedIds.add(folder.id);
const children = (childrenByParentId.get(folder.id) ?? [])
.filter((child) => !visitedIds.has(child.id))
.sort(compareByName);
return { folder, children: children.map(buildNode) };
};
const tree = rootFolders.sort(compareByName).map(buildNode);
// Folders whose ancestry loops never reach a root, so they surface at the top level.
const unreachableFolders = folders
.filter((folder) => !visitedIds.has(folder.id))
.sort(compareByName);
for (const folder of unreachableFolders) {
if (!visitedIds.has(folder.id)) {
tree.push(buildNode(folder));
}
}
return tree;
};

View File

@@ -0,0 +1,9 @@
import { type GranolaFolderTreeNode } from 'src/front-components/utils/compute-granola-folder-tree.util';
export const countNestedGranolaFolders = (
node: GranolaFolderTreeNode,
): number =>
node.children.reduce(
(count, child) => count + 1 + countNestedGranolaFolders(child),
0,
);

View File

@@ -0,0 +1,44 @@
import { isUndefined } from '@sniptt/guards';
type CreateSaveQueueParams<TValue> = {
saveValue: (value: TValue, isSupersededValue: () => boolean) => Promise<void>;
};
export const createSaveQueue = <TValue>({
saveValue,
}: CreateSaveQueueParams<TValue>) => {
let queuedValue: TValue | undefined;
let isSaving = false;
const persistQueuedValues = async () => {
if (isSaving) {
return;
}
isSaving = true;
try {
while (!isUndefined(queuedValue)) {
const valueToSave = queuedValue;
queuedValue = undefined;
await saveValue(valueToSave, () => !isUndefined(queuedValue));
}
} finally {
isSaving = false;
if (!isUndefined(queuedValue)) {
void persistQueuedValues();
}
}
};
const enqueueSave = (value: TValue) => {
queuedValue = value;
void persistQueuedValues();
};
return { enqueueSave };
};

View File

@@ -0,0 +1,15 @@
import { RestApiClient } from 'twenty-client-sdk/rest';
import { GRANOLA_CONNECTION_STATUS_ROUTE_PATH } from 'src/constants/granola-connection-status-route-path';
import {
GRANOLA_CONNECTION_STATUS_SCHEMA,
type GranolaConnectionStatus,
} from 'src/front-components/types/granola-connection-status.type';
export const fetchGranolaConnectionStatusOrThrow =
async (): Promise<GranolaConnectionStatus> =>
GRANOLA_CONNECTION_STATUS_SCHEMA.parse(
await new RestApiClient().get(
`/s${GRANOLA_CONNECTION_STATUS_ROUTE_PATH}`,
),
);

View File

@@ -0,0 +1,31 @@
import { RestApiClient } from 'twenty-client-sdk/rest';
import { z } from 'zod';
import { GRANOLA_FOLDERS_ROUTE_PATH } from 'src/constants/granola-folders-route-path';
import { GRANOLA_SETTINGS_FOLDER_SCHEMA } from 'src/front-components/types/granola-settings-folder.type';
const FOLDERS_RESULT_SCHEMA = z.discriminatedUnion('success', [
z.object({
success: z.literal(true),
folders: z.array(GRANOLA_SETTINGS_FOLDER_SCHEMA),
folderIds: z.array(z.string()),
pendingFolderIds: z.array(z.string()).optional(),
}),
z.object({ success: z.literal(false), error: z.string() }),
]);
export const fetchGranolaFoldersOrThrow = async () => {
const result = FOLDERS_RESULT_SCHEMA.parse(
await new RestApiClient().get(`/s${GRANOLA_FOLDERS_ROUTE_PATH}`),
);
if (!result.success) {
throw new Error(result.error);
}
return {
folders: result.folders,
selectedFolderIds: result.folderIds,
pendingFolderIds: result.pendingFolderIds,
};
};

View File

@@ -0,0 +1,37 @@
import { themeCssVariables } from 'twenty-ui/theme-constants';
// Mirrors the settings inputs twenty-front renders for application variables,
// which cannot be imported here: twenty-ui ships no text, select or textarea
// input, and front components cannot reach into twenty-front internals.
export const getSettingsControlStyles = () => `
background-color: ${themeCssVariables.background.transparent.light};
border: 1px solid ${themeCssVariables.border.color.medium};
border-radius: ${themeCssVariables.border.radius.md};
box-sizing: border-box;
color: ${themeCssVariables.font.color.primary};
font-family: ${themeCssVariables.font.family};
font-size: ${themeCssVariables.font.size.md};
font-weight: ${themeCssVariables.font.weight.regular};
outline: none;
padding: ${themeCssVariables.spacing[2]};
width: 100%;
&::placeholder,
&::-webkit-input-placeholder {
color: ${themeCssVariables.font.color.tertiary};
font-family: ${themeCssVariables.font.family};
font-weight: ${themeCssVariables.font.weight.medium};
}
&:disabled {
color: ${themeCssVariables.font.color.secondary};
}
&[readonly] {
pointer-events: none;
}
&:focus {
border-color: ${themeCssVariables.color.blue};
}
`;

View File

@@ -0,0 +1,11 @@
import { type GranolaFolderTreeNode } from 'src/front-components/utils/compute-granola-folder-tree.util';
export const hasSelectedGranolaDescendant = (
node: GranolaFolderTreeNode,
selectedFolderIds: Set<string>,
): boolean =>
node.children.some(
(child) =>
selectedFolderIds.has(child.folder.id) ||
hasSelectedGranolaDescendant(child, selectedFolderIds),
);

View File

@@ -0,0 +1,19 @@
import { isDefined } from 'twenty-sdk/utils';
import { type GranolaConnectionStatus } from 'src/front-components/types/granola-connection-status.type';
export const isGranolaConnectionReady = (
status: Pick<
GranolaConnectionStatus,
| 'isConnected'
| 'isGranolaReachable'
| 'needsRegistration'
| 'registration'
| 'error'
>,
): boolean =>
status.isConnected &&
status.isGranolaReachable &&
!status.needsRegistration &&
!isDefined(status.error) &&
(status.registration?.isActive ?? false);

View File

@@ -0,0 +1,13 @@
import { GRANOLA_HISTORY_MAX_IMPORT_DAYS } from 'src/constants/granola-history.constant';
export const parseHistoryImportDays = (value: string): number | undefined => {
if (!/^\d+$/.test(value.trim())) {
return undefined;
}
const days = Number(value);
return days >= 1 && days <= GRANOLA_HISTORY_MAX_IMPORT_DAYS
? days
: undefined;
};

View File

@@ -0,0 +1,23 @@
import { RestApiClient } from 'twenty-client-sdk/rest';
import { z } from 'zod';
const GRANOLA_SETTINGS_ROUTE_RESULT_SCHEMA = z.discriminatedUnion('success', [
z.object({ success: z.literal(true) }),
z.object({ success: z.literal(false), error: z.string() }),
]);
export const postGranolaSettingsRouteOrThrow = async ({
routePath,
body,
}: {
routePath: string;
body: Record<string, unknown>;
}): Promise<void> => {
const result = GRANOLA_SETTINGS_ROUTE_RESULT_SCHEMA.parse(
await new RestApiClient().post(`/s${routePath}`, body),
);
if (!result.success) {
throw new Error(result.error);
}
};

View File

@@ -0,0 +1,25 @@
import { RestApiClient } from 'twenty-client-sdk/rest';
import { z } from 'zod';
import { GRANOLA_FOLDER_SELECTION_ROUTE_PATH } from 'src/constants/granola-folder-selection-route-path';
const SAVE_RESULT_SCHEMA = z.discriminatedUnion('success', [
z.object({ success: z.literal(true), folderIds: z.array(z.string()) }),
z.object({ success: z.literal(false), error: z.string() }),
]);
export const saveGranolaFolderSelectionOrThrow = async (
folderIds: string[],
): Promise<string[]> => {
const result = SAVE_RESULT_SCHEMA.parse(
await new RestApiClient().post(`/s${GRANOLA_FOLDER_SELECTION_ROUTE_PATH}`, {
folderIds,
}),
);
if (!result.success) {
throw new Error(result.error);
}
return result.folderIds;
};

View File

@@ -0,0 +1,35 @@
import { isNonEmptyString } from '@sniptt/guards';
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
import { GRANOLA_API_KEY_ENV_VAR_NAME } from 'src/logic-functions/constants/granola-api-key-env-var-name';
export const setGranolaApiKeyOrThrow = async ({
frontComponentId,
apiKey,
}: {
frontComponentId: string;
apiKey: string;
}): Promise<void> => {
const client = new MetadataApiClient();
const result = await client.query({
frontComponent: {
__args: { id: frontComponentId },
applicationId: true,
},
});
const applicationId = result.frontComponent?.applicationId;
if (!isNonEmptyString(applicationId)) {
throw new Error('Could not find the Granola application.');
}
await client.mutation({
updateOneApplicationVariable: {
__args: {
key: GRANOLA_API_KEY_ENV_VAR_NAME,
value: apiKey,
applicationId,
},
},
});
};

View File

@@ -0,0 +1 @@
export const GRANOLA_API_KEY_ENV_VAR_NAME = 'GRANOLA_API_KEY';

View File

@@ -0,0 +1,2 @@
export const GRANOLA_NOT_CONNECTED_ERROR_MESSAGE =
'Connect Granola in the application settings first.';

View File

@@ -0,0 +1,73 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { defineLogicFunction } from 'twenty-sdk/define';
import { GRANOLA_BACKFILL_NOTE_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { GranolaApiError } from 'src/logic-functions/types/granola-api-error';
import { type GranolaBackfillNotePayload } from 'src/logic-functions/types/granola-backfill-note-payload.type';
import { GranolaInvalidResponseError } from 'src/logic-functions/types/granola-invalid-response-error';
import { GranolaTranscriptLimitError } from 'src/logic-functions/types/granola-transcript-limit-error';
import { assertGranolaFolderSelectionReadyOrThrow } from 'src/logic-functions/utils/assert-granola-folder-selection-ready-or-throw.util';
import { createGranolaClientOrThrow } from 'src/logic-functions/utils/create-granola-client-or-throw.util';
import { findGranolaRegistrationForCurrentKey } from 'src/logic-functions/utils/find-granola-registration-for-current-key.util';
import { isGranolaJobInRegistrationScope } from 'src/logic-functions/utils/is-granola-job-in-registration-scope.util';
import { rethrowKnownOrWrapGranolaError } from 'src/logic-functions/utils/rethrow-known-or-wrap-granola-error.util';
import { syncGranolaNoteToCallRecordingOrThrow } from 'src/logic-functions/utils/sync-granola-note-to-call-recording-or-throw.util';
export const granolaBackfillNoteHandler = async (
payload: GranolaBackfillNotePayload,
) => {
try {
await assertGranolaFolderSelectionReadyOrThrow();
const registration = await findGranolaRegistrationForCurrentKey();
if (
!isGranolaJobInRegistrationScope({
registration,
registrationId: payload.registrationId,
folderId: payload.folderId,
})
) {
return { success: true, skipped: true };
}
const coreApiClient = new CoreApiClient({ runAs: 'application' });
const client = createGranolaClientOrThrow();
try {
const result = await syncGranolaNoteToCallRecordingOrThrow({
coreApiClient,
client,
noteId: payload.noteId,
});
return {
success: true,
importedNoteCount: result.skipped ? 0 : 1,
};
} catch (error) {
if (
error instanceof GranolaInvalidResponseError ||
error instanceof GranolaTranscriptLimitError ||
(error instanceof GranolaApiError && [403, 404].includes(error.status))
) {
console.error(
`[granola] Skipped unreadable note ${payload.noteId}: ${error.message}`,
);
return { success: true, importedNoteCount: 0 };
}
throw error;
}
} catch (error) {
rethrowKnownOrWrapGranolaError({ operation: 'Note import', error });
}
};
export default defineLogicFunction({
universalIdentifier: GRANOLA_BACKFILL_NOTE_UNIVERSAL_IDENTIFIER,
name: 'granola-backfill-note',
description:
'Imports one paced Granola note while preserving deleted recordings.',
timeoutSeconds: 900,
handler: granolaBackfillNoteHandler,
});

View File

@@ -0,0 +1,135 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { defineLogicFunction } from 'twenty-sdk/define';
import { GRANOLA_MAX_PAGE_SIZE } from 'src/constants/granola-api.constant';
import {
GRANOLA_BACKFILL_NOTE_UNIVERSAL_IDENTIFIER,
GRANOLA_BACKFILL_WORKER_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
import { type GranolaBackfillNotePayload } from 'src/logic-functions/types/granola-backfill-note-payload.type';
import { type GranolaBackfillWorkerPayload } from 'src/logic-functions/types/granola-backfill-worker-payload.type';
import { assertGranolaFolderSelectionReadyOrThrow } from 'src/logic-functions/utils/assert-granola-folder-selection-ready-or-throw.util';
import { createGranolaClientOrThrow } from 'src/logic-functions/utils/create-granola-client-or-throw.util';
import { enqueueGranolaJobOrThrow } from 'src/logic-functions/utils/enqueue-granola-job-or-throw.util';
import { excludeDeletedGranolaNotesOrThrow } from 'src/logic-functions/utils/exclude-deleted-granola-notes-or-throw.util';
import { findGranolaRegistrationForCurrentKey } from 'src/logic-functions/utils/find-granola-registration-for-current-key.util';
import { getGranolaJobId } from 'src/logic-functions/utils/get-granola-job-id.util';
import { getGranolaNextPage } from 'src/logic-functions/utils/get-granola-next-page.util';
import { isGranolaJobInRegistrationScope } from 'src/logic-functions/utils/is-granola-job-in-registration-scope.util';
import { reserveGranolaNoteImportSlotsOrThrow } from 'src/logic-functions/utils/reserve-granola-note-import-slots-or-throw.util';
import { rethrowKnownOrWrapGranolaError } from 'src/logic-functions/utils/rethrow-known-or-wrap-granola-error.util';
export const granolaBackfillWorkerHandler = async (
payload: GranolaBackfillWorkerPayload,
) => {
try {
await assertGranolaFolderSelectionReadyOrThrow();
const registration = await findGranolaRegistrationForCurrentKey();
if (
!isGranolaJobInRegistrationScope({
registration,
registrationId: payload.registrationId,
folderId: payload.folderId,
})
) {
return { success: true, skipped: true };
}
const page = await createGranolaClientOrThrow().listNotes({
created_after: payload.createdAfter,
updated_after: payload.updatedAfter,
folder_id: payload.folderId,
cursor: payload.cursor,
page_size: GRANOLA_MAX_PAGE_SIZE,
});
const noteIds = await excludeDeletedGranolaNotesOrThrow({
coreApiClient: new CoreApiClient({ runAs: 'application' }),
noteIds: page.notes.map((note) => note.id),
});
const schedule = await reserveGranolaNoteImportSlotsOrThrow(noteIds.length);
for (const [index, noteId] of noteIds.entries()) {
const notePayload: GranolaBackfillNotePayload = {
registrationId: payload.registrationId,
folderId: payload.folderId,
noteId,
};
await enqueueGranolaJobOrThrow({
logicFunctionUniversalIdentifier:
GRANOLA_BACKFILL_NOTE_UNIVERSAL_IDENTIFIER,
payload: notePayload,
jobId: getGranolaJobId({
prefix: 'granola-note',
identity: {
...notePayload,
createdAfter: payload.createdAfter,
updatedAfter: payload.updatedAfter,
},
}),
delayMs: schedule.noteDelays[index],
});
}
const nextPage = getGranolaNextPage({
...page,
previousCursor: payload.cursor,
pageIndex: payload.pageIndex,
});
if (nextPage.kind === 'stalled') {
console.error(
`[granola] ${nextPage.reason} Registration ${payload.registrationId}, cursor ${page.cursor}.`,
);
return {
success: true,
discoveredNoteCount: page.notes.length,
enqueuedNoteCount: noteIds.length,
hasMore: true,
stopped: true,
};
}
if (nextPage.kind === 'next') {
const continuationPayload: GranolaBackfillWorkerPayload = {
...payload,
cursor: nextPage.cursor,
pageIndex: payload.pageIndex + 1,
};
await enqueueGranolaJobOrThrow({
logicFunctionUniversalIdentifier:
GRANOLA_BACKFILL_WORKER_UNIVERSAL_IDENTIFIER,
payload: continuationPayload,
jobId: getGranolaJobId({
prefix: 'granola-discovery',
identity: continuationPayload,
}),
delayMs: schedule.continuationDelay,
});
}
return {
success: true,
discoveredNoteCount: page.notes.length,
enqueuedNoteCount: noteIds.length,
hasMore: nextPage.kind === 'next',
};
} catch (error) {
rethrowKnownOrWrapGranolaError({
operation: 'History discovery',
error,
});
}
};
export default defineLogicFunction({
universalIdentifier: GRANOLA_BACKFILL_WORKER_UNIVERSAL_IDENTIFIER,
name: 'granola-backfill-worker',
description:
'Discovers one page of Granola notes and schedules paced note imports.',
timeoutSeconds: 120,
handler: granolaBackfillWorkerHandler,
});

View File

@@ -0,0 +1,68 @@
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import { isDefined } from 'twenty-sdk/utils';
import { z } from 'zod';
import { GRANOLA_BACKFILL_ROUTE_PATH } from 'src/constants/granola-backfill-route-path';
import {
GRANOLA_HISTORY_MAX_IMPORT_DAYS,
GRANOLA_MILLISECONDS_PER_DAY,
} from 'src/constants/granola-history.constant';
import { GRANOLA_BACKFILL_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { currentUserCanManageGranolaOrThrow } from 'src/logic-functions/utils/current-user-can-manage-granola-or-throw.util';
import { enqueueGranolaBackfillOrThrow } from 'src/logic-functions/utils/enqueue-granola-backfill-or-throw.util';
import { findGranolaRegistrationForCurrentKey } from 'src/logic-functions/utils/find-granola-registration-for-current-key.util';
const GRANOLA_BACKFILL_INPUT_SCHEMA = z.object({
days: z.number().int().min(1).max(GRANOLA_HISTORY_MAX_IMPORT_DAYS),
});
export const granolaBackfillHandler = async (
payload: RoutePayload<unknown>,
) => {
const parsed = GRANOLA_BACKFILL_INPUT_SCHEMA.safeParse(payload.body);
if (!parsed.success) {
return {
success: false,
error: `Choose a whole number of days between 1 and ${GRANOLA_HISTORY_MAX_IMPORT_DAYS}.`,
};
}
if (!(await currentUserCanManageGranolaOrThrow())) {
return {
success: false,
error: 'Application settings permission is required.',
};
}
const registration = await findGranolaRegistrationForCurrentKey();
if (!isDefined(registration)) {
return {
success: false,
error: 'Set up live sync before importing notes.',
};
}
const result = await enqueueGranolaBackfillOrThrow({
createdAfter: new Date(
Date.now() - parsed.data.days * GRANOLA_MILLISECONDS_PER_DAY,
).toISOString(),
});
return { success: true, days: parsed.data.days, ...result };
};
export default defineLogicFunction({
universalIdentifier: GRANOLA_BACKFILL_UNIVERSAL_IDENTIFIER,
name: 'granola-backfill',
description:
'Starts a paced import of Granola notes in the selected folders.',
timeoutSeconds: 60,
handler: granolaBackfillHandler,
httpRouteTriggerSettings: {
path: GRANOLA_BACKFILL_ROUTE_PATH,
httpMethod: 'POST',
isAuthRequired: true,
},
});

View File

@@ -0,0 +1,81 @@
import { defineLogicFunction } from 'twenty-sdk/define';
import { RetryableLogicFunctionError } from 'twenty-sdk/logic-function';
import { isDefined } from 'twenty-sdk/utils';
import { GRANOLA_CONNECTION_STATUS_ROUTE_PATH } from 'src/constants/granola-connection-status-route-path';
import { GRANOLA_CONNECTION_STATUS_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { GranolaApiError } from 'src/logic-functions/types/granola-api-error';
import { createGranolaClientOrThrow } from 'src/logic-functions/utils/create-granola-client-or-throw.util';
import { currentUserCanManageGranolaOrThrow } from 'src/logic-functions/utils/current-user-can-manage-granola-or-throw.util';
import { findGranolaRegistrationForCurrentKey } from 'src/logic-functions/utils/find-granola-registration-for-current-key.util';
import { isGranolaApiKeySet } from 'src/logic-functions/utils/is-granola-api-key-set.util';
export const granolaConnectionStatusHandler = async () => {
if (!isGranolaApiKeySet()) {
return { isConnected: false, isApiKeySet: false };
}
const client = createGranolaClientOrThrow();
try {
await client.listFolders({ page_size: 1 });
} catch (error) {
if (error instanceof GranolaApiError) {
return { isConnected: false, isApiKeySet: true, error: error.message };
}
if (error instanceof RetryableLogicFunctionError) {
return {
isConnected: false,
isApiKeySet: true,
isGranolaReachable: false,
error: error.message,
};
}
throw error;
}
const canManage = await currentUserCanManageGranolaOrThrow();
try {
const { webhook_endpoints: endpoints } =
await client.listWebhookEndpoints();
const registration = await findGranolaRegistrationForCurrentKey();
const endpoint = endpoints.find(
(candidate) => candidate.id === registration?.webhookEndpointId,
);
if (!isDefined(endpoint)) {
return {
isConnected: true,
isApiKeySet: true,
canManage,
needsRegistration: true,
};
}
return {
isConnected: true,
isApiKeySet: true,
canManage,
registration: { scopes: endpoint.scopes, isActive: endpoint.enabled },
};
} catch (error) {
if (error instanceof GranolaApiError) {
return {
isConnected: true,
isApiKeySet: true,
canManage,
needsRegistration: true,
error: error.message,
};
}
throw error;
}
};
export default defineLogicFunction({
universalIdentifier: GRANOLA_CONNECTION_STATUS_UNIVERSAL_IDENTIFIER,
name: 'granola-connection-status',
description: 'Checks the saved Granola API key and the live webhook status.',
timeoutSeconds: 60,
handler: granolaConnectionStatusHandler,
httpRouteTriggerSettings: {
path: GRANOLA_CONNECTION_STATUS_ROUTE_PATH,
httpMethod: 'GET',
isAuthRequired: true,
},
});

View File

@@ -0,0 +1,40 @@
import { defineLogicFunction } from 'twenty-sdk/define';
import { isDefined } from 'twenty-sdk/utils';
import {
GRANOLA_CATCH_UP_WINDOW_DAYS,
GRANOLA_MILLISECONDS_PER_DAY,
} from 'src/constants/granola-history.constant';
import { GRANOLA_DAILY_CATCH_UP_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { enqueueGranolaBackfillOrThrow } from 'src/logic-functions/utils/enqueue-granola-backfill-or-throw.util';
import { enqueueGranolaInitialBackfillOrThrow } from 'src/logic-functions/utils/enqueue-granola-initial-backfill-or-throw.util';
import { findGranolaRegistrationForCurrentKey } from 'src/logic-functions/utils/find-granola-registration-for-current-key.util';
import { reconcileGranolaFolderSelectionOrThrow } from 'src/logic-functions/utils/reconcile-granola-folder-selection-or-throw.util';
export const granolaDailyCatchUpHandler = async () => {
const registration = await findGranolaRegistrationForCurrentKey();
if (!isDefined(registration)) {
return { success: true, skipped: true };
}
await reconcileGranolaFolderSelectionOrThrow();
await enqueueGranolaInitialBackfillOrThrow();
const result = await enqueueGranolaBackfillOrThrow({
updatedAfter: new Date(
Date.now() - GRANOLA_CATCH_UP_WINDOW_DAYS * GRANOLA_MILLISECONDS_PER_DAY,
).toISOString(),
});
return { success: true, ...result };
};
export default defineLogicFunction({
universalIdentifier: GRANOLA_DAILY_CATCH_UP_UNIVERSAL_IDENTIFIER,
name: 'granola-daily-catch-up',
description:
'Imports Granola notes updated during the last two days to recover missed webhook deliveries.',
timeoutSeconds: 120,
handler: granolaDailyCatchUpHandler,
cronTriggerSettings: { pattern: '0 4 * * *' },
});

View File

@@ -0,0 +1,44 @@
import { defineLogicFunction } from 'twenty-sdk/define';
import { kv } from 'twenty-sdk/logic-function';
import { GRANOLA_FOLDERS_ROUTE_PATH } from 'src/constants/granola-folders-route-path';
import { GRANOLA_PENDING_FOLDER_SELECTION_KEY } from 'src/constants/granola.constant';
import { GRANOLA_FOLDERS_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { type GranolaPendingFolderSelection } from 'src/logic-functions/types/granola-pending-folder-selection.type';
import { currentUserCanManageGranolaOrThrow } from 'src/logic-functions/utils/current-user-can-manage-granola-or-throw.util';
import { findGranolaRegistrationForCurrentKey } from 'src/logic-functions/utils/find-granola-registration-for-current-key.util';
import { listAllGranolaFoldersOrThrow } from 'src/logic-functions/utils/list-all-granola-folders-or-throw.util';
export const granolaFoldersHandler = async () => {
if (!(await currentUserCanManageGranolaOrThrow())) {
return {
success: false,
error: 'Application settings permission is required.',
};
}
const registration = await findGranolaRegistrationForCurrentKey();
const pending = await kv.get<GranolaPendingFolderSelection>(
GRANOLA_PENDING_FOLDER_SELECTION_KEY,
);
return {
success: true,
folders: await listAllGranolaFoldersOrThrow(),
folderIds: registration?.folderIds ?? [],
pendingFolderIds: pending?.folderIds,
};
};
export default defineLogicFunction({
universalIdentifier: GRANOLA_FOLDERS_UNIVERSAL_IDENTIFIER,
name: 'granola-folders',
description: 'Lists accessible Granola folders for the settings picker.',
timeoutSeconds: 60,
handler: granolaFoldersHandler,
httpRouteTriggerSettings: {
path: GRANOLA_FOLDERS_ROUTE_PATH,
httpMethod: 'GET',
isAuthRequired: true,
},
});

View File

@@ -0,0 +1,57 @@
import { defineLogicFunction } from 'twenty-sdk/define';
import { type InputJsonSchema } from 'twenty-sdk/logic-function';
import { isDefined } from 'twenty-sdk/utils';
import { GRANOLA_PAGE_PARAMETERS_INPUT_SCHEMA } from 'src/constants/granola-page-parameters.constant';
import { GRANOLA_LIST_FOLDERS_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { GRANOLA_NOT_CONNECTED_ERROR_MESSAGE } from 'src/logic-functions/constants/granola-not-connected-error-message.constant';
import { GRANOLA_PAGE_PARAMETERS_SCHEMA } from 'src/logic-functions/schemas/granola-page-parameters.schema';
import { createGranolaClientOrThrow } from 'src/logic-functions/utils/create-granola-client-or-throw.util';
import { isGranolaApiKeySet } from 'src/logic-functions/utils/is-granola-api-key-set.util';
const GRANOLA_LIST_FOLDERS_INPUT_SCHEMA: InputJsonSchema = {
type: 'object',
properties: GRANOLA_PAGE_PARAMETERS_INPUT_SCHEMA,
additionalProperties: false,
};
export const granolaListFoldersHandler = async (parameters: unknown) => {
const parsed = GRANOLA_PAGE_PARAMETERS_SCHEMA.safeParse(parameters);
if (!parsed.success) {
return {
success: false,
error: 'Use a limit between 1 and 30 and a valid pagination cursor.',
};
}
if (!isGranolaApiKeySet()) {
return { success: false, error: GRANOLA_NOT_CONNECTED_ERROR_MESSAGE };
}
const page = await createGranolaClientOrThrow().listFolders({
cursor: parsed.data.cursor,
page_size: parsed.data.limit,
});
return {
success: true,
folders: page.folders.map(({ id, name, parent_folder_id }) => ({
id,
name,
parentFolderId: parent_folder_id,
})),
hasMore: page.hasMore,
...(isDefined(page.cursor) ? { cursor: page.cursor } : {}),
};
};
export default defineLogicFunction({
universalIdentifier: GRANOLA_LIST_FOLDERS_UNIVERSAL_IDENTIFIER,
name: 'granola-list-folders',
description:
'Lists a page of accessible Granola folders. Pass a folder ID to List Granola Notes; use the cursor to read more folders.',
timeoutSeconds: 30,
handler: granolaListFoldersHandler,
toolTriggerSettings: { inputSchema: GRANOLA_LIST_FOLDERS_INPUT_SCHEMA },
});

View File

@@ -0,0 +1,134 @@
import { defineLogicFunction } from 'twenty-sdk/define';
import {
type InputJsonSchema,
jsonSchemaToInputSchema,
} from 'twenty-sdk/logic-function';
import { z } from 'zod';
import { GRANOLA_LIST_NOTES_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { GRANOLA_FOLDER_ID_PATTERN } from 'src/constants/granola-api.constant';
import { GRANOLA_PAGE_PARAMETERS_INPUT_SCHEMA } from 'src/constants/granola-page-parameters.constant';
import { GRANOLA_NOT_CONNECTED_ERROR_MESSAGE } from 'src/logic-functions/constants/granola-not-connected-error-message.constant';
import { GRANOLA_PAGE_PARAMETERS_SCHEMA } from 'src/logic-functions/schemas/granola-page-parameters.schema';
import { GRANOLA_DATE_TIME_SCHEMA } from 'src/logic-functions/types/granola-api.type';
import { isGranolaApiKeySet } from 'src/logic-functions/utils/is-granola-api-key-set.util';
import { createGranolaClientOrThrow } from 'src/logic-functions/utils/create-granola-client-or-throw.util';
import { isDefined } from 'twenty-sdk/utils';
const GRANOLA_LIST_NOTES_INPUT_SCHEMA: InputJsonSchema = {
type: 'object',
properties: {
folderId: {
type: 'string',
label: 'Folder ID',
description: 'An optional fol_ identifier; includes subfolders.',
},
createdAfter: {
type: 'string',
label: 'Created after',
description: 'An ISO date or date-time.',
},
createdBefore: {
type: 'string',
label: 'Created before',
description: 'An ISO date or date-time.',
},
...GRANOLA_PAGE_PARAMETERS_INPUT_SCHEMA,
},
additionalProperties: false,
};
const GRANOLA_DATE_FILTER_SCHEMA = z.union([
z.iso.date(),
GRANOLA_DATE_TIME_SCHEMA,
]);
const GRANOLA_LIST_NOTES_PARAMETERS_SCHEMA =
GRANOLA_PAGE_PARAMETERS_SCHEMA.extend({
folderId: z.string().regex(GRANOLA_FOLDER_ID_PATTERN).optional(),
createdAfter: GRANOLA_DATE_FILTER_SCHEMA.optional(),
createdBefore: GRANOLA_DATE_FILTER_SCHEMA.optional(),
});
export const granolaListNotesHandler = async (parameters: unknown) => {
const parsed = GRANOLA_LIST_NOTES_PARAMETERS_SCHEMA.safeParse(parameters);
if (!parsed.success) {
return {
success: false,
error:
'Use valid date filters, a Granola folder ID, and a limit between 1 and 30.',
};
}
if (!isGranolaApiKeySet()) {
return { success: false, error: GRANOLA_NOT_CONNECTED_ERROR_MESSAGE };
}
const page = await createGranolaClientOrThrow().listNotes({
folder_id: parsed.data.folderId,
created_after: parsed.data.createdAfter,
created_before: parsed.data.createdBefore,
cursor: parsed.data.cursor,
page_size: parsed.data.limit,
});
const notes = page.notes.map((note) => ({
id: note.id,
...(isDefined(note.title) ? { title: note.title } : {}),
owner: {
email: note.owner.email,
...(isDefined(note.owner.name) ? { name: note.owner.name } : {}),
},
createdAt: note.created_at,
updatedAt: note.updated_at,
}));
return {
success: true,
notes,
hasMore: page.hasMore,
...(isDefined(page.cursor) ? { cursor: page.cursor } : {}),
};
};
export default defineLogicFunction({
universalIdentifier: GRANOLA_LIST_NOTES_UNIVERSAL_IDENTIFIER,
name: 'granola-list-notes',
description:
'Lists a page of Granola note IDs, titles, owners and dates using the workspace key. Filter by folder or date; pass a returned ID to Sync Granola Note to import its transcript and summary.',
timeoutSeconds: 30,
handler: granolaListNotesHandler,
toolTriggerSettings: { inputSchema: GRANOLA_LIST_NOTES_INPUT_SCHEMA },
workflowActionTriggerSettings: {
label: 'List Granola Notes',
inputSchema: jsonSchemaToInputSchema(GRANOLA_LIST_NOTES_INPUT_SCHEMA),
outputSchema: [
{
type: 'object',
properties: {
success: { type: 'boolean' },
error: { type: 'string' },
hasMore: { type: 'boolean' },
cursor: { type: 'string' },
notes: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string' },
title: { type: 'string' },
owner: {
type: 'object',
properties: {
email: { type: 'string' },
name: { type: 'string' },
},
},
createdAt: { type: 'string' },
updatedAt: { type: 'string' },
},
},
},
},
},
],
},
});

View File

@@ -0,0 +1,56 @@
import { defineLogicFunction } from 'twenty-sdk/define';
import { GRANOLA_WEBHOOK_REGISTRATION_ROUTE_PATH } from 'src/constants/granola-webhook-registration-route-path';
import { GRANOLA_REGISTER_WEBHOOK_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { GranolaApiError } from 'src/logic-functions/types/granola-api-error';
import { currentUserCanManageGranolaOrThrow } from 'src/logic-functions/utils/current-user-can-manage-granola-or-throw.util';
import { enqueueGranolaInitialBackfillOrThrow } from 'src/logic-functions/utils/enqueue-granola-initial-backfill-or-throw.util';
import { ensureGranolaWebhookRegistrationOrThrow } from 'src/logic-functions/utils/ensure-granola-webhook-registration-or-throw.util';
import { reconcileGranolaFolderSelectionOrThrow } from 'src/logic-functions/utils/reconcile-granola-folder-selection-or-throw.util';
import { toErrorMessage } from 'src/logic-functions/utils/to-error-message.util';
export const granolaRegisterWebhookHandler = async () => {
if (!(await currentUserCanManageGranolaOrThrow())) {
return {
success: false,
error: 'Application management permission is required.',
};
}
try {
const registration = await ensureGranolaWebhookRegistrationOrThrow();
try {
await reconcileGranolaFolderSelectionOrThrow();
} catch (error) {
console.error(
`[granola] Could not apply the pending folder selection for registration ${registration.registrationId}. ${toErrorMessage(error)}`,
);
}
try {
await enqueueGranolaInitialBackfillOrThrow();
} catch (error) {
console.error(
`[granola] Could not queue the initial import for registration ${registration.registrationId}; the daily catch-up will retry. ${toErrorMessage(error)}`,
);
}
return { success: true };
} catch (error) {
if (error instanceof GranolaApiError) {
return { success: false, error: error.message };
}
throw error;
}
};
export default defineLogicFunction({
universalIdentifier: GRANOLA_REGISTER_WEBHOOK_UNIVERSAL_IDENTIFIER,
name: 'granola-register-webhook',
description:
'Registers the Granola webhook for the saved key and starts the initial history import.',
timeoutSeconds: 60,
handler: granolaRegisterWebhookHandler,
httpRouteTriggerSettings: {
path: GRANOLA_WEBHOOK_REGISTRATION_ROUTE_PATH,
httpMethod: 'POST',
isAuthRequired: true,
},
});

View File

@@ -0,0 +1,35 @@
import { defineLogicFunction } from 'twenty-sdk/define';
import { GRANOLA_WEBHOOK_REMOVAL_ROUTE_PATH } from 'src/constants/granola-webhook-removal-route-path';
import { GRANOLA_REMOVE_WEBHOOK_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { createGranolaClientOrThrow } from 'src/logic-functions/utils/create-granola-client-or-throw.util';
import { currentUserCanManageGranolaOrThrow } from 'src/logic-functions/utils/current-user-can-manage-granola-or-throw.util';
import { removeGranolaWebhookRegistration } from 'src/logic-functions/utils/remove-granola-webhook-registration.util';
export const granolaRemoveWebhookHandler = async () => {
if (!(await currentUserCanManageGranolaOrThrow())) {
return {
success: false,
error: 'Application management permission is required.',
};
}
const { deletedWebhookCount } = await removeGranolaWebhookRegistration({
client: createGranolaClientOrThrow(),
});
return { success: true, deletedWebhookCount };
};
export default defineLogicFunction({
universalIdentifier: GRANOLA_REMOVE_WEBHOOK_UNIVERSAL_IDENTIFIER,
name: 'granola-remove-webhook',
description: 'Removes the registered Granola webhook for the saved key.',
timeoutSeconds: 60,
handler: granolaRemoveWebhookHandler,
httpRouteTriggerSettings: {
path: GRANOLA_WEBHOOK_REMOVAL_ROUTE_PATH,
httpMethod: 'POST',
isAuthRequired: true,
},
});

View File

@@ -0,0 +1,78 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { defineLogicFunction } from 'twenty-sdk/define';
import {
type InputJsonSchema,
jsonSchemaToInputSchema,
} from 'twenty-sdk/logic-function';
import { z } from 'zod';
import { GRANOLA_NOTE_ID_PATTERN } from 'src/constants/granola-api.constant';
import { GRANOLA_SYNC_NOTE_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { GRANOLA_NOT_CONNECTED_ERROR_MESSAGE } from 'src/logic-functions/constants/granola-not-connected-error-message.constant';
import { createGranolaClientOrThrow } from 'src/logic-functions/utils/create-granola-client-or-throw.util';
import { isGranolaApiKeySet } from 'src/logic-functions/utils/is-granola-api-key-set.util';
import { syncGranolaNoteToCallRecordingOrThrow } from 'src/logic-functions/utils/sync-granola-note-to-call-recording-or-throw.util';
const GRANOLA_SYNC_NOTE_INPUT_SCHEMA: InputJsonSchema = {
type: 'object',
properties: {
noteId: {
type: 'string',
label: 'Granola note ID',
description: 'The not_ identifier returned by List Granola Notes.',
},
},
required: ['noteId'],
additionalProperties: false,
};
const GRANOLA_SYNC_NOTE_PARAMETERS_SCHEMA = z.object({
noteId: z.string().regex(GRANOLA_NOTE_ID_PATTERN),
});
export const granolaSyncNoteHandler = async (parameters: unknown) => {
const parsed = GRANOLA_SYNC_NOTE_PARAMETERS_SCHEMA.safeParse(parameters);
if (!parsed.success) {
return { success: false, error: 'A valid Granola note ID is required.' };
}
if (!isGranolaApiKeySet()) {
return { success: false, error: GRANOLA_NOT_CONNECTED_ERROR_MESSAGE };
}
const result = await syncGranolaNoteToCallRecordingOrThrow({
coreApiClient: new CoreApiClient({ runAs: 'application' }),
client: createGranolaClientOrThrow(),
noteId: parsed.data.noteId,
});
return { success: true, noteId: parsed.data.noteId, ...result };
};
export default defineLogicFunction({
universalIdentifier: GRANOLA_SYNC_NOTE_UNIVERSAL_IDENTIFIER,
name: 'granola-sync-note',
description:
'Sync one accessible Granola note into a Call Recording with its transcript and summary. Uses the workspace API key and preserves recordings deleted in Twenty.',
timeoutSeconds: 900,
handler: granolaSyncNoteHandler,
toolTriggerSettings: { inputSchema: GRANOLA_SYNC_NOTE_INPUT_SCHEMA },
workflowActionTriggerSettings: {
label: 'Sync Granola Note',
inputSchema: jsonSchemaToInputSchema(GRANOLA_SYNC_NOTE_INPUT_SCHEMA),
outputSchema: [
{
type: 'object',
properties: {
success: { type: 'boolean' },
error: { type: 'string' },
noteId: { type: 'string' },
callRecordingId: { type: 'string' },
calendarEventId: { type: 'string' },
created: { type: 'boolean' },
skipped: { type: 'boolean' },
},
},
],
},
});

View File

@@ -0,0 +1,38 @@
import { defineUninstallLogicFunction } from 'twenty-sdk/define';
import { kv } from 'twenty-sdk/logic-function';
import { GRANOLA_HISTORY_SCHEDULE_KEY } from 'src/constants/granola.constant';
import { GRANOLA_UNINSTALL_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { cleanupPendingGranolaRegistrationOrThrow } from 'src/logic-functions/utils/cleanup-pending-granola-registration-or-throw.util';
import { createGranolaClientOrThrow } from 'src/logic-functions/utils/create-granola-client-or-throw.util';
import { removeGranolaWebhookRegistration } from 'src/logic-functions/utils/remove-granola-webhook-registration.util';
import { toErrorMessage } from 'src/logic-functions/utils/to-error-message.util';
export const granolaUninstallHandler = async () => {
const client = createGranolaClientOrThrow();
try {
const { webhook_endpoints: endpoints } =
await client.listWebhookEndpoints();
await cleanupPendingGranolaRegistrationOrThrow({
client,
endpoints,
apiUrl: process.env.TWENTY_API_URL,
});
} catch (error) {
console.error(
`[granola] Could not clean up the pending registration during uninstall; check Granola webhook settings. ${toErrorMessage(error)}`,
);
}
await kv.delete(GRANOLA_HISTORY_SCHEDULE_KEY);
return removeGranolaWebhookRegistration({ client });
};
export default defineUninstallLogicFunction({
universalIdentifier: GRANOLA_UNINSTALL_UNIVERSAL_IDENTIFIER,
name: 'granola-uninstall',
description: 'Removes the registered Granola webhook on uninstall.',
timeoutSeconds: 30,
handler: granolaUninstallHandler,
});

View File

@@ -0,0 +1,101 @@
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import { kv, RetryableLogicFunctionError } from 'twenty-sdk/logic-function';
import { isDefined } from 'twenty-sdk/utils';
import { z } from 'zod';
import {
GRANOLA_FOLDER_ID_PATTERN,
GRANOLA_FOLDER_SELECTION_LIMIT,
} from 'src/constants/granola-api.constant';
import { GRANOLA_FOLDER_SELECTION_ROUTE_PATH } from 'src/constants/granola-folder-selection-route-path';
import { GRANOLA_PENDING_FOLDER_SELECTION_KEY } from 'src/constants/granola.constant';
import { GRANOLA_UPDATE_WEBHOOK_FOLDERS_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { GranolaApiError } from 'src/logic-functions/types/granola-api-error';
import { currentUserCanManageGranolaOrThrow } from 'src/logic-functions/utils/current-user-can-manage-granola-or-throw.util';
import { findGranolaRegistrationForCurrentKey } from 'src/logic-functions/utils/find-granola-registration-for-current-key.util';
import { getGranolaSelectedParentFolders } from 'src/logic-functions/utils/get-granola-selected-parent-folders.util';
import { listAllGranolaFoldersOrThrow } from 'src/logic-functions/utils/list-all-granola-folders-or-throw.util';
import { reconcileGranolaFolderSelectionOrThrow } from 'src/logic-functions/utils/reconcile-granola-folder-selection-or-throw.util';
const GRANOLA_FOLDER_SELECTION_SCHEMA = z.object({
folderIds: z
.array(z.string().regex(GRANOLA_FOLDER_ID_PATTERN))
.max(GRANOLA_FOLDER_SELECTION_LIMIT),
});
export const granolaUpdateWebhookFoldersHandler = async (
payload: RoutePayload<unknown>,
) => {
const parsed = GRANOLA_FOLDER_SELECTION_SCHEMA.safeParse(payload.body);
if (!parsed.success) {
return {
success: false,
error: `Select up to ${GRANOLA_FOLDER_SELECTION_LIMIT} valid Granola folders.`,
};
}
if (!(await currentUserCanManageGranolaOrThrow())) {
return {
success: false,
error: 'Application settings permission is required.',
};
}
const registration = await findGranolaRegistrationForCurrentKey();
if (!isDefined(registration)) {
return {
success: false,
error: 'Set up live sync before choosing folders.',
};
}
const folders =
parsed.data.folderIds.length > 0
? await listAllGranolaFoldersOrThrow()
: [];
const accessibleIds = new Set(folders.map((folder) => folder.id));
if (parsed.data.folderIds.some((folderId) => !accessibleIds.has(folderId))) {
return {
success: false,
error: 'A selected folder is not accessible to this key.',
};
}
const folderIds = getGranolaSelectedParentFolders({
folderIds: parsed.data.folderIds,
folders,
});
await kv.set(GRANOLA_PENDING_FOLDER_SELECTION_KEY, { folderIds });
try {
await reconcileGranolaFolderSelectionOrThrow();
} catch (error) {
if (
error instanceof RetryableLogicFunctionError ||
error instanceof GranolaApiError
) {
return { success: false, error: error.message };
}
throw error;
}
return { success: true, folderIds };
};
export default defineLogicFunction({
universalIdentifier: GRANOLA_UPDATE_WEBHOOK_FOLDERS_UNIVERSAL_IDENTIFIER,
name: 'granola-update-webhook-folders',
description:
'Sets the folders used by both Granola webhook deliveries and history imports.',
timeoutSeconds: 60,
handler: granolaUpdateWebhookFoldersHandler,
httpRouteTriggerSettings: {
path: GRANOLA_FOLDER_SELECTION_ROUTE_PATH,
httpMethod: 'POST',
isAuthRequired: true,
},
});

View File

@@ -0,0 +1,58 @@
import { isNonEmptyString } from '@sniptt/guards';
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import {
kv,
Response,
type ServerRouteResolverResult,
} from 'twenty-sdk/logic-function';
import {
GRANOLA_WEBHOOK_RESOLVER_UNIVERSAL_IDENTIFIER,
GRANOLA_WEBHOOK_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers';
import { getGranolaRegistrationClaimKey } from 'src/logic-functions/utils/get-granola-registration-claim-key.util';
export const granolaWebhookResolverHandler = async (
routePayload: RoutePayload<unknown>,
): Promise<ServerRouteResolverResult> => {
const receivedAt = Date.now();
const registrationId = routePayload.queryStringParameters?.registrationId;
if (!isNonEmptyString(registrationId)) {
return new Response(
{ error: 'Missing Granola registration identifier' },
{ status: 400 },
);
}
const workspaceId = await kv.get<string>(
getGranolaRegistrationClaimKey(registrationId),
{ scope: 'SERVER' },
);
if (!isNonEmptyString(workspaceId)) {
return new Response(
{ error: 'Unknown Granola registration' },
{ status: 404 },
);
}
return {
workspaceId,
targetLogicFunctionUniversalIdentifier:
GRANOLA_WEBHOOK_UNIVERSAL_IDENTIFIER,
payload: { routePayload, receivedAt },
};
};
export default defineLogicFunction({
universalIdentifier: GRANOLA_WEBHOOK_RESOLVER_UNIVERSAL_IDENTIFIER,
name: 'granola-webhook-resolver',
description: 'Routes signed Granola deliveries to the registered workspace.',
timeoutSeconds: 15,
handler: granolaWebhookResolverHandler,
serverRouteTriggerSettings: {
httpMethods: ['POST'],
forwardedRequestHeaders: [
'webhook-id',
'webhook-timestamp',
'webhook-signature',
],
},
});

View File

@@ -0,0 +1,114 @@
import { isNonEmptyString } from '@sniptt/guards';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import { kv, RetryableLogicFunctionError } from 'twenty-sdk/logic-function';
import { isDefined } from 'twenty-sdk/utils';
import {
GRANOLA_PENDING_REGISTRATION_KEY,
GRANOLA_WEBHOOK_REGISTRATION_KEY,
} from 'src/constants/granola.constant';
import { GRANOLA_WEBHOOK_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { GRANOLA_API_KEY_ENV_VAR_NAME } from 'src/logic-functions/constants/granola-api-key-env-var-name';
import { GranolaApiError } from 'src/logic-functions/types/granola-api-error';
import { GRANOLA_WEBHOOK_PAYLOAD_SCHEMA } from 'src/logic-functions/types/granola-api.type';
import { GranolaInvalidResponseError } from 'src/logic-functions/types/granola-invalid-response-error';
import { GranolaTranscriptLimitError } from 'src/logic-functions/types/granola-transcript-limit-error';
import { type GranolaWebhookRegistration } from 'src/logic-functions/types/granola-webhook-registration.type';
import { assertGranolaFolderSelectionReadyOrThrow } from 'src/logic-functions/utils/assert-granola-folder-selection-ready-or-throw.util';
import { buildRetryableGranolaError } from 'src/logic-functions/utils/build-retryable-granola-error.util';
import { createGranolaClientOrThrow } from 'src/logic-functions/utils/create-granola-client-or-throw.util';
import { getGranolaApiKeyFingerprint } from 'src/logic-functions/utils/get-granola-api-key-fingerprint.util';
import { parseJsonOrUndefined } from 'src/logic-functions/utils/parse-json-or-undefined.util';
import { syncGranolaNoteToCallRecordingOrThrow } from 'src/logic-functions/utils/sync-granola-note-to-call-recording-or-throw.util';
import { verifyStandardWebhookSignature } from 'src/logic-functions/utils/verify-standard-webhook-signature.util';
export const granolaWebhookHandler = async ({
routePayload,
receivedAt,
}: {
routePayload: RoutePayload<unknown>;
receivedAt: number;
}) => {
const registrationId = routePayload.queryStringParameters?.registrationId;
const registration = await kv.get<GranolaWebhookRegistration>(
GRANOLA_WEBHOOK_REGISTRATION_KEY,
);
if (registration?.registrationId !== registrationId) {
const pendingId = await kv.get<string>(GRANOLA_PENDING_REGISTRATION_KEY);
if (pendingId === registrationId) {
throw new RetryableLogicFunctionError(
'Granola webhook registration is still being saved.',
);
}
}
const apiKey = process.env[GRANOLA_API_KEY_ENV_VAR_NAME];
if (
!isDefined(registration) ||
registration.registrationId !== registrationId ||
!isNonEmptyString(apiKey) ||
getGranolaApiKeyFingerprint(apiKey) !== registration.apiKeyFingerprint
) {
return {
success: false,
error: 'Unknown Granola registration',
};
}
const rawBody = routePayload.rawBody;
const webhookId = routePayload.headers['webhook-id'];
const timestamp = routePayload.headers['webhook-timestamp'];
const signature = routePayload.headers['webhook-signature'];
if (
!isNonEmptyString(rawBody) ||
!isNonEmptyString(webhookId) ||
!isNonEmptyString(timestamp) ||
!isNonEmptyString(signature) ||
!verifyStandardWebhookSignature({
signingSecret: registration.signingSecret,
webhookId,
timestamp,
signature,
rawBody,
receivedAt,
})
) {
return { success: false, error: 'Invalid Granola webhook signature' };
}
const parsed = GRANOLA_WEBHOOK_PAYLOAD_SCHEMA.safeParse(
parseJsonOrUndefined(rawBody),
);
if (!parsed.success || parsed.data.event_id !== webhookId) {
return { success: false, error: 'Invalid Granola webhook event' };
}
await assertGranolaFolderSelectionReadyOrThrow();
const result = await syncGranolaNoteToCallRecordingOrThrow({
coreApiClient: new CoreApiClient({ runAs: 'application' }),
client: createGranolaClientOrThrow(),
noteId: parsed.data.note_id,
}).catch((error: unknown) => {
if (
error instanceof GranolaApiError ||
error instanceof GranolaInvalidResponseError ||
error instanceof GranolaTranscriptLimitError
) {
return { skipped: true, reason: error.message };
}
if (error instanceof RetryableLogicFunctionError) {
throw error;
}
throw buildRetryableGranolaError({
operation: 'Note synchronization',
error,
});
});
return { success: true, ...result };
};
export default defineLogicFunction({
universalIdentifier: GRANOLA_WEBHOOK_UNIVERSAL_IDENTIFIER,
name: 'granola-webhook',
description:
'Verifies signed Granola events and synchronizes notes into Call Recordings.',
timeoutSeconds: 900,
handler: granolaWebhookHandler,
});

View File

@@ -0,0 +1,13 @@
import { z } from 'zod';
import { GRANOLA_MAX_PAGE_SIZE } from 'src/constants/granola-api.constant';
export const GRANOLA_PAGE_PARAMETERS_SCHEMA = z.object({
cursor: z.string().min(1).optional(),
limit: z
.number()
.int()
.min(1)
.max(GRANOLA_MAX_PAGE_SIZE)
.default(GRANOLA_MAX_PAGE_SIZE),
});

View File

@@ -0,0 +1,13 @@
import { type TranscriptEntry } from 'src/logic-functions/types/transcript-entry.type';
export type CallRecordingSyncFields = {
title?: string;
status?: 'PROCESSING' | 'COMPLETED';
recordingRequestStatus?: 'REQUESTED';
externalRecordingId?: string;
startedAt?: string;
endedAt?: string;
transcript?: TranscriptEntry[];
summary?: { markdown: string; blocknote: null };
calendarEventId?: string;
};

View File

@@ -0,0 +1,35 @@
import { z } from 'zod';
const GRANOLA_ERROR_BODY_SCHEMA = z.object({
details: z
.array(z.object({ field: z.string().optional(), issue: z.string() }))
.optional(),
});
const GRANOLA_API_ERROR_MESSAGE_BY_STATUS: Record<number, string> = {
400: 'Granola rejected the request. Check the selected scopes and folders.',
401: 'Key rejected by Granola.',
403: 'Granola denied access. Check API scopes and workspace permissions.',
404: 'Granola resource not found, or webhooks are unavailable for this plan.',
413: 'Granola transcript is too large to include inline.',
};
export type GranolaApiErrorDetail = { field?: string; issue: string };
export class GranolaApiError extends Error {
readonly status: number;
readonly details: GranolaApiErrorDetail[];
constructor({ status, body }: { status: number; body?: unknown }) {
super(
GRANOLA_API_ERROR_MESSAGE_BY_STATUS[status] ??
`Granola request failed (HTTP ${status}).`,
);
this.name = 'GranolaApiError';
this.status = status;
const parsedBody = GRANOLA_ERROR_BODY_SCHEMA.safeParse(body);
this.details = parsedBody.success ? (parsedBody.data.details ?? []) : [];
}
}

View File

@@ -0,0 +1,119 @@
import { z } from 'zod';
import { GRANOLA_NOTE_ID_PATTERN } from 'src/constants/granola-api.constant';
export const GRANOLA_DATE_TIME_SCHEMA = z.string().datetime({ offset: true });
const GRANOLA_USER_SCHEMA = z.object({
name: z.string().nullable(),
email: z.string(),
});
export const GRANOLA_FOLDER_SCHEMA = z.object({
id: z.string(),
object: z.literal('folder'),
name: z.string(),
parent_folder_id: z.string().nullable(),
});
export const GRANOLA_TRANSCRIPT_ITEM_SCHEMA = z.object({
speaker: z.object({
source: z.enum(['microphone', 'speaker']),
attribution: z.enum(['me', 'them']).optional(),
diarization_label: z.string().optional(),
name: z.string().optional(),
}),
text: z.string(),
start_time: GRANOLA_DATE_TIME_SCHEMA,
end_time: GRANOLA_DATE_TIME_SCHEMA,
});
export const GRANOLA_NOTE_SUMMARY_SCHEMA = z.object({
id: z.string(),
object: z.literal('note'),
title: z.string().nullable(),
owner: GRANOLA_USER_SCHEMA,
created_at: GRANOLA_DATE_TIME_SCHEMA,
updated_at: GRANOLA_DATE_TIME_SCHEMA,
});
export const GRANOLA_NOTE_SCHEMA = GRANOLA_NOTE_SUMMARY_SCHEMA.extend({
web_url: z.string().url(),
calendar_event: z
.object({
event_title: z.string().nullable(),
invitees: z.array(z.object({ email: z.string() })),
organiser: z.string().nullable(),
calendar_event_id: z.string().nullable(),
scheduled_start_time: GRANOLA_DATE_TIME_SCHEMA.nullable(),
scheduled_end_time: GRANOLA_DATE_TIME_SCHEMA.nullable(),
})
.nullable(),
attendees: z.array(GRANOLA_USER_SCHEMA),
folder_membership: z.array(GRANOLA_FOLDER_SCHEMA),
summary_text: z.string(),
summary_markdown: z.string().nullable(),
transcript: z.array(GRANOLA_TRANSCRIPT_ITEM_SCHEMA).nullable(),
});
export const GRANOLA_WEBHOOK_SCOPE_SCHEMA = z.enum([
'personal',
'public',
'workspace',
]);
export const GRANOLA_WEBHOOK_EVENT_SCHEMA = z.enum([
'note.access_granted',
'note.edited',
'note.generated',
]);
export const GRANOLA_WEBHOOK_PAYLOAD_SCHEMA = z.object({
event_id: z.string().min(1),
event_type: GRANOLA_WEBHOOK_EVENT_SCHEMA,
note_id: z.string().regex(GRANOLA_NOTE_ID_PATTERN),
});
export const GRANOLA_WEBHOOK_ENDPOINT_SCHEMA = z.object({
id: z.string(),
object: z.literal('webhook_endpoint'),
url: z.string().url(),
url_redacted: z.boolean(),
events: z.array(GRANOLA_WEBHOOK_EVENT_SCHEMA),
folder_ids: z.array(z.string()),
scopes: z.array(GRANOLA_WEBHOOK_SCOPE_SCHEMA),
created_by: GRANOLA_USER_SCHEMA.nullable(),
enabled: z.boolean(),
created_at: GRANOLA_DATE_TIME_SCHEMA,
});
export type GranolaNote = z.infer<typeof GRANOLA_NOTE_SCHEMA>;
export type GranolaNoteSummary = z.infer<typeof GRANOLA_NOTE_SUMMARY_SCHEMA>;
export type GranolaTranscriptItem = z.infer<
typeof GRANOLA_TRANSCRIPT_ITEM_SCHEMA
>;
export type GranolaFolder = z.infer<typeof GRANOLA_FOLDER_SCHEMA>;
export type GranolaWebhookEndpoint = z.infer<
typeof GRANOLA_WEBHOOK_ENDPOINT_SCHEMA
>;
export type GranolaWebhookScope = z.infer<typeof GRANOLA_WEBHOOK_SCOPE_SCHEMA>;
export type GranolaWebhookEvent = z.infer<typeof GRANOLA_WEBHOOK_EVENT_SCHEMA>;
export type GranolaListNotesParameters = {
created_after?: string;
created_before?: string;
updated_after?: string;
folder_id?: string;
cursor?: string;
page_size?: number;
};
export type GranolaCreateWebhookEndpointParameters = Pick<
GranolaWebhookEndpoint,
'url' | 'scopes'
> &
Partial<Pick<GranolaWebhookEndpoint, 'events' | 'folder_ids'>>;
export type GranolaUpdateWebhookEndpointParameters = {
webhookEndpointId: string;
} & Partial<
Pick<
GranolaWebhookEndpoint,
'url' | 'scopes' | 'events' | 'folder_ids' | 'enabled'
>
>;

View File

@@ -0,0 +1,5 @@
export type GranolaBackfillNotePayload = {
registrationId: string;
folderId?: string;
noteId: string;
};

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