diff --git a/.github/dependabot.yml b/.github/dependabot.yml index b7aca1c498..da2e6eae79 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -18,3 +18,28 @@ updates: # chokidar is pinned to v3 via a resolution: v4+ dropped native macOS # FSEvents and hits EMFILE on a repo this size (see #20316) - dependency-name: "chokidar" + # Public Twenty apps are standalone packages (own lockfiles) excluded from the + # root entry above. Each app folder is updated weekly, grouped into a single PR. + - package-ecosystem: "npm" + directories: + - "/packages/twenty-apps/public/*" + schedule: + interval: "weekly" + open-pull-requests-limit: 3 + versioning-strategy: "lockfile-only" + groups: + twenty-apps-public: + patterns: + - "*" + # Internal Twenty apps, same treatment as the public apps above. + - package-ecosystem: "npm" + directories: + - "/packages/twenty-apps/internal/*" + schedule: + interval: "weekly" + open-pull-requests-limit: 3 + versioning-strategy: "lockfile-only" + groups: + twenty-apps-internal: + patterns: + - "*" diff --git a/.github/workflows/ci-internal-apps.yaml b/.github/workflows/ci-twenty-apps.yaml similarity index 67% rename from .github/workflows/ci-internal-apps.yaml rename to .github/workflows/ci-twenty-apps.yaml index a3efb721a7..0f64eea223 100644 --- a/.github/workflows/ci-internal-apps.yaml +++ b/.github/workflows/ci-twenty-apps.yaml @@ -1,4 +1,4 @@ -name: CI Internal Apps +name: CI Twenty Apps on: push: @@ -8,7 +8,7 @@ on: workflow_dispatch: inputs: application: - description: 'Internal app folder name to test (e.g. "twenty-linear"). Leave empty to test all apps.' + description: 'App folder name to test (e.g. "twenty-linear"). Leave empty to test all apps.' required: false default: '' type: string @@ -35,9 +35,9 @@ jobs: - name: Ensure no .github folder inside app folders run: | - offenders=$(find packages/twenty-apps/internal -mindepth 2 -maxdepth 2 -type d -name .github) + offenders=$(find packages/twenty-apps/internal packages/twenty-apps/public -mindepth 2 -maxdepth 2 -type d -name .github) if [ -n "$offenders" ]; then - echo "::error::Internal apps must not define their own .github folder. Offenders:" + echo "::error::Apps must not define their own .github folder. Offenders:" echo "$offenders" exit 1 fi @@ -49,7 +49,9 @@ jobs: with: json: true escape_json: false - files: packages/twenty-apps/internal/** + files: | + packages/twenty-apps/internal/** + packages/twenty-apps/public/** - name: Build matrix of changed apps id: set-matrix @@ -61,36 +63,40 @@ jobs: node <<'NODE' const fs = require('fs'); const path = require('path'); - const root = 'packages/twenty-apps/internal'; + const roots = ['packages/twenty-apps/internal', 'packages/twenty-apps/public']; const eventName = process.env.EVENT_NAME; const changedFiles = JSON.parse(process.env.CHANGED_FILES || '[]'); const changedApps = new Set(); for (const file of changedFiles) { - const match = file.match(/^packages\/twenty-apps\/internal\/([^/]+)\//); + const match = file.match(/^packages\/twenty-apps\/(?:internal|public)\/([^/]+)\//); if (match) changedApps.add(match[1]); } const requestedApp = (process.env.APPLICATION_INPUT || '').trim(); - const matrix = fs.readdirSync(root, { withFileTypes: true }) - .filter((entry) => entry.isDirectory() && entry.name !== 'node_modules') - .map((entry) => entry.name) - .filter((name) => fs.existsSync(path.join(root, name, 'package.json'))) - .filter((name) => { - if (eventName === 'workflow_dispatch') { - return requestedApp ? name === requestedApp : true; - } - return changedApps.has(name); - }) - .map((name) => { - const appPath = path.join(root, name); - const scripts = JSON.parse(fs.readFileSync(path.join(appPath, 'package.json'), 'utf8')).scripts || {}; - return { - name, - path: appPath, - hasTypecheck: Boolean(scripts.typecheck), - hasUnit: Boolean(scripts['test:unit']), - hasIntegration: Boolean(scripts.test), - }; - }); + const matrix = roots + .filter((root) => fs.existsSync(root)) + .flatMap((root) => + fs.readdirSync(root, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && entry.name !== 'node_modules') + .map((entry) => entry.name) + .filter((name) => fs.existsSync(path.join(root, name, 'package.json'))) + .filter((name) => { + if (eventName === 'workflow_dispatch') { + return requestedApp ? name === requestedApp : true; + } + return changedApps.has(name); + }) + .map((name) => { + const appPath = path.join(root, name); + const scripts = JSON.parse(fs.readFileSync(path.join(appPath, 'package.json'), 'utf8')).scripts || {}; + return { + name, + path: appPath, + hasTypecheck: Boolean(scripts.typecheck), + hasUnit: Boolean(scripts['test:unit']), + hasIntegration: Boolean(scripts.test), + }; + }) + ); fs.appendFileSync(process.env.GITHUB_OUTPUT, `matrix=${JSON.stringify(matrix)}\n`); fs.appendFileSync(process.env.GITHUB_OUTPUT, `has_apps=${matrix.length > 0}\n`); NODE @@ -150,7 +156,7 @@ jobs: TWENTY_API_URL: ${{ steps.twenty.outputs.server-url }} TWENTY_API_KEY: ${{ steps.twenty.outputs.api-key }} - ci-internal-apps-status-check: + ci-twenty-apps-status-check: if: always() && !cancelled() timeout-minutes: 5 runs-on: ubuntu-latest diff --git a/packages/twenty-apps/internal/people-data-labs/.gitignore b/packages/twenty-apps/public/people-data-labs/.gitignore similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/.gitignore rename to packages/twenty-apps/public/people-data-labs/.gitignore diff --git a/packages/twenty-apps/internal/people-data-labs/.nvmrc b/packages/twenty-apps/public/people-data-labs/.nvmrc similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/.nvmrc rename to packages/twenty-apps/public/people-data-labs/.nvmrc diff --git a/packages/twenty-apps/internal/people-data-labs/.oxlintrc.json b/packages/twenty-apps/public/people-data-labs/.oxlintrc.json similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/.oxlintrc.json rename to packages/twenty-apps/public/people-data-labs/.oxlintrc.json diff --git a/packages/twenty-apps/internal/people-data-labs/.yarnrc.yml b/packages/twenty-apps/public/people-data-labs/.yarnrc.yml similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/.yarnrc.yml rename to packages/twenty-apps/public/people-data-labs/.yarnrc.yml diff --git a/packages/twenty-apps/internal/people-data-labs/README.md b/packages/twenty-apps/public/people-data-labs/README.md similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/README.md rename to packages/twenty-apps/public/people-data-labs/README.md diff --git a/packages/twenty-apps/internal/people-data-labs/package.json b/packages/twenty-apps/public/people-data-labs/package.json similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/package.json rename to packages/twenty-apps/public/people-data-labs/package.json diff --git a/packages/twenty-apps/internal/people-data-labs/public/gallery/cover.png b/packages/twenty-apps/public/people-data-labs/public/gallery/cover.png similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/public/gallery/cover.png rename to packages/twenty-apps/public/people-data-labs/public/gallery/cover.png diff --git a/packages/twenty-apps/internal/people-data-labs/public/people-data-labs-icon.png b/packages/twenty-apps/public/people-data-labs/public/people-data-labs-icon.png similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/public/people-data-labs-icon.png rename to packages/twenty-apps/public/people-data-labs/public/people-data-labs-icon.png diff --git a/packages/twenty-apps/internal/people-data-labs/src/__tests__/global-setup.ts b/packages/twenty-apps/public/people-data-labs/src/__tests__/global-setup.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/__tests__/global-setup.ts rename to packages/twenty-apps/public/people-data-labs/src/__tests__/global-setup.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/__tests__/people-data-labs.integration-test.ts b/packages/twenty-apps/public/people-data-labs/src/__tests__/people-data-labs.integration-test.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/__tests__/people-data-labs.integration-test.ts rename to packages/twenty-apps/public/people-data-labs/src/__tests__/people-data-labs.integration-test.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/application-config.ts b/packages/twenty-apps/public/people-data-labs/src/application-config.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/application-config.ts rename to packages/twenty-apps/public/people-data-labs/src/application-config.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/constants/__tests__/select-option-constants.spec.ts b/packages/twenty-apps/public/people-data-labs/src/constants/__tests__/select-option-constants.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/constants/__tests__/select-option-constants.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/constants/__tests__/select-option-constants.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/constants/billing-margin-multiplier.ts b/packages/twenty-apps/public/people-data-labs/src/constants/billing-margin-multiplier.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/constants/billing-margin-multiplier.ts rename to packages/twenty-apps/public/people-data-labs/src/constants/billing-margin-multiplier.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/constants/company-match-cost-dollars.ts b/packages/twenty-apps/public/people-data-labs/src/constants/company-match-cost-dollars.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/constants/company-match-cost-dollars.ts rename to packages/twenty-apps/public/people-data-labs/src/constants/company-match-cost-dollars.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/constants/company-type-options.ts b/packages/twenty-apps/public/people-data-labs/src/constants/company-type-options.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/constants/company-type-options.ts rename to packages/twenty-apps/public/people-data-labs/src/constants/company-type-options.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/constants/enrichment-status-options.ts b/packages/twenty-apps/public/people-data-labs/src/constants/enrichment-status-options.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/constants/enrichment-status-options.ts rename to packages/twenty-apps/public/people-data-labs/src/constants/enrichment-status-options.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/constants/enrichment-workflow-seeds.ts b/packages/twenty-apps/public/people-data-labs/src/constants/enrichment-workflow-seeds.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/constants/enrichment-workflow-seeds.ts rename to packages/twenty-apps/public/people-data-labs/src/constants/enrichment-workflow-seeds.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/constants/funding-stage-options.ts b/packages/twenty-apps/public/people-data-labs/src/constants/funding-stage-options.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/constants/funding-stage-options.ts rename to packages/twenty-apps/public/people-data-labs/src/constants/funding-stage-options.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/constants/industry-options.ts b/packages/twenty-apps/public/people-data-labs/src/constants/industry-options.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/constants/industry-options.ts rename to packages/twenty-apps/public/people-data-labs/src/constants/industry-options.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/constants/inferred-salary-options.ts b/packages/twenty-apps/public/people-data-labs/src/constants/inferred-salary-options.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/constants/inferred-salary-options.ts rename to packages/twenty-apps/public/people-data-labs/src/constants/inferred-salary-options.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/constants/job-role-options.ts b/packages/twenty-apps/public/people-data-labs/src/constants/job-role-options.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/constants/job-role-options.ts rename to packages/twenty-apps/public/people-data-labs/src/constants/job-role-options.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/constants/job-title-class-options.ts b/packages/twenty-apps/public/people-data-labs/src/constants/job-title-class-options.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/constants/job-title-class-options.ts rename to packages/twenty-apps/public/people-data-labs/src/constants/job-title-class-options.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/constants/job-title-sub-role-options.ts b/packages/twenty-apps/public/people-data-labs/src/constants/job-title-sub-role-options.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/constants/job-title-sub-role-options.ts rename to packages/twenty-apps/public/people-data-labs/src/constants/job-title-sub-role-options.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/constants/location-continent-options.ts b/packages/twenty-apps/public/people-data-labs/src/constants/location-continent-options.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/constants/location-continent-options.ts rename to packages/twenty-apps/public/people-data-labs/src/constants/location-continent-options.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/constants/metro-options.ts b/packages/twenty-apps/public/people-data-labs/src/constants/metro-options.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/constants/metro-options.ts rename to packages/twenty-apps/public/people-data-labs/src/constants/metro-options.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/constants/mic-exchange-options.ts b/packages/twenty-apps/public/people-data-labs/src/constants/mic-exchange-options.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/constants/mic-exchange-options.ts rename to packages/twenty-apps/public/people-data-labs/src/constants/mic-exchange-options.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/constants/micro-credits-per-dollar.ts b/packages/twenty-apps/public/people-data-labs/src/constants/micro-credits-per-dollar.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/constants/micro-credits-per-dollar.ts rename to packages/twenty-apps/public/people-data-labs/src/constants/micro-credits-per-dollar.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/constants/pdl-base-url.ts b/packages/twenty-apps/public/people-data-labs/src/constants/pdl-base-url.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/constants/pdl-base-url.ts rename to packages/twenty-apps/public/people-data-labs/src/constants/pdl-base-url.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/constants/person-match-cost-dollars.ts b/packages/twenty-apps/public/people-data-labs/src/constants/person-match-cost-dollars.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/constants/person-match-cost-dollars.ts rename to packages/twenty-apps/public/people-data-labs/src/constants/person-match-cost-dollars.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/constants/seniority-options.ts b/packages/twenty-apps/public/people-data-labs/src/constants/seniority-options.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/constants/seniority-options.ts rename to packages/twenty-apps/public/people-data-labs/src/constants/seniority-options.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/constants/sex-options.ts b/packages/twenty-apps/public/people-data-labs/src/constants/sex-options.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/constants/sex-options.ts rename to packages/twenty-apps/public/people-data-labs/src/constants/sex-options.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/constants/size-options.ts b/packages/twenty-apps/public/people-data-labs/src/constants/size-options.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/constants/size-options.ts rename to packages/twenty-apps/public/people-data-labs/src/constants/size-options.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/constants/universal-identifiers.ts b/packages/twenty-apps/public/people-data-labs/src/constants/universal-identifiers.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/constants/universal-identifiers.ts rename to packages/twenty-apps/public/people-data-labs/src/constants/universal-identifiers.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/constants/update-fields-option-values.ts b/packages/twenty-apps/public/people-data-labs/src/constants/update-fields-option-values.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/constants/update-fields-option-values.ts rename to packages/twenty-apps/public/people-data-labs/src/constants/update-fields-option-values.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/constants/update-fields-options.ts b/packages/twenty-apps/public/people-data-labs/src/constants/update-fields-options.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/constants/update-fields-options.ts rename to packages/twenty-apps/public/people-data-labs/src/constants/update-fields-options.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/fields/company/pdl-affiliated-profiles.field.ts b/packages/twenty-apps/public/people-data-labs/src/fields/company/pdl-affiliated-profiles.field.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/fields/company/pdl-affiliated-profiles.field.ts rename to packages/twenty-apps/public/people-data-labs/src/fields/company/pdl-affiliated-profiles.field.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/fields/company/pdl-alternative-domains.field.ts b/packages/twenty-apps/public/people-data-labs/src/fields/company/pdl-alternative-domains.field.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/fields/company/pdl-alternative-domains.field.ts rename to packages/twenty-apps/public/people-data-labs/src/fields/company/pdl-alternative-domains.field.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/fields/company/pdl-alternative-names.field.ts b/packages/twenty-apps/public/people-data-labs/src/fields/company/pdl-alternative-names.field.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/fields/company/pdl-alternative-names.field.ts rename to packages/twenty-apps/public/people-data-labs/src/fields/company/pdl-alternative-names.field.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/fields/company/pdl-company-type.field.ts b/packages/twenty-apps/public/people-data-labs/src/fields/company/pdl-company-type.field.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/fields/company/pdl-company-type.field.ts rename to packages/twenty-apps/public/people-data-labs/src/fields/company/pdl-company-type.field.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/fields/company/pdl-employee-count-by-country.field.ts b/packages/twenty-apps/public/people-data-labs/src/fields/company/pdl-employee-count-by-country.field.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/fields/company/pdl-employee-count-by-country.field.ts rename to packages/twenty-apps/public/people-data-labs/src/fields/company/pdl-employee-count-by-country.field.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/fields/company/pdl-employee-count.field.ts b/packages/twenty-apps/public/people-data-labs/src/fields/company/pdl-employee-count.field.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/fields/company/pdl-employee-count.field.ts rename to packages/twenty-apps/public/people-data-labs/src/fields/company/pdl-employee-count.field.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/fields/company/pdl-enrichment-status.field.ts b/packages/twenty-apps/public/people-data-labs/src/fields/company/pdl-enrichment-status.field.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/fields/company/pdl-enrichment-status.field.ts rename to packages/twenty-apps/public/people-data-labs/src/fields/company/pdl-enrichment-status.field.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/fields/company/pdl-facebook-url.field.ts b/packages/twenty-apps/public/people-data-labs/src/fields/company/pdl-facebook-url.field.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/fields/company/pdl-facebook-url.field.ts rename to packages/twenty-apps/public/people-data-labs/src/fields/company/pdl-facebook-url.field.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/fields/company/pdl-founded-year.field.ts b/packages/twenty-apps/public/people-data-labs/src/fields/company/pdl-founded-year.field.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/fields/company/pdl-founded-year.field.ts rename to packages/twenty-apps/public/people-data-labs/src/fields/company/pdl-founded-year.field.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/fields/company/pdl-funding-stages.field.ts b/packages/twenty-apps/public/people-data-labs/src/fields/company/pdl-funding-stages.field.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/fields/company/pdl-funding-stages.field.ts rename to packages/twenty-apps/public/people-data-labs/src/fields/company/pdl-funding-stages.field.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/fields/company/pdl-headline.field.ts b/packages/twenty-apps/public/people-data-labs/src/fields/company/pdl-headline.field.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/fields/company/pdl-headline.field.ts rename to packages/twenty-apps/public/people-data-labs/src/fields/company/pdl-headline.field.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/fields/company/pdl-id.field.ts b/packages/twenty-apps/public/people-data-labs/src/fields/company/pdl-id.field.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/fields/company/pdl-id.field.ts rename to packages/twenty-apps/public/people-data-labs/src/fields/company/pdl-id.field.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/fields/company/pdl-industry-detail.field.ts b/packages/twenty-apps/public/people-data-labs/src/fields/company/pdl-industry-detail.field.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/fields/company/pdl-industry-detail.field.ts rename to packages/twenty-apps/public/people-data-labs/src/fields/company/pdl-industry-detail.field.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/fields/company/pdl-industry.field.ts b/packages/twenty-apps/public/people-data-labs/src/fields/company/pdl-industry.field.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/fields/company/pdl-industry.field.ts rename to packages/twenty-apps/public/people-data-labs/src/fields/company/pdl-industry.field.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/fields/company/pdl-last-enriched-at.field.ts b/packages/twenty-apps/public/people-data-labs/src/fields/company/pdl-last-enriched-at.field.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/fields/company/pdl-last-enriched-at.field.ts rename to packages/twenty-apps/public/people-data-labs/src/fields/company/pdl-last-enriched-at.field.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/fields/company/pdl-last-funding-date.field.ts b/packages/twenty-apps/public/people-data-labs/src/fields/company/pdl-last-funding-date.field.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/fields/company/pdl-last-funding-date.field.ts rename to packages/twenty-apps/public/people-data-labs/src/fields/company/pdl-last-funding-date.field.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/fields/company/pdl-latest-funding-stage.field.ts b/packages/twenty-apps/public/people-data-labs/src/fields/company/pdl-latest-funding-stage.field.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/fields/company/pdl-latest-funding-stage.field.ts rename to packages/twenty-apps/public/people-data-labs/src/fields/company/pdl-latest-funding-stage.field.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/fields/company/pdl-legal-name.field.ts b/packages/twenty-apps/public/people-data-labs/src/fields/company/pdl-legal-name.field.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/fields/company/pdl-legal-name.field.ts rename to packages/twenty-apps/public/people-data-labs/src/fields/company/pdl-legal-name.field.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/fields/company/pdl-linkedin-id.field.ts b/packages/twenty-apps/public/people-data-labs/src/fields/company/pdl-linkedin-id.field.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/fields/company/pdl-linkedin-id.field.ts rename to packages/twenty-apps/public/people-data-labs/src/fields/company/pdl-linkedin-id.field.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/fields/company/pdl-location-continent.field.ts b/packages/twenty-apps/public/people-data-labs/src/fields/company/pdl-location-continent.field.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/fields/company/pdl-location-continent.field.ts rename to packages/twenty-apps/public/people-data-labs/src/fields/company/pdl-location-continent.field.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/fields/company/pdl-location-metro.field.ts b/packages/twenty-apps/public/people-data-labs/src/fields/company/pdl-location-metro.field.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/fields/company/pdl-location-metro.field.ts rename to packages/twenty-apps/public/people-data-labs/src/fields/company/pdl-location-metro.field.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/fields/company/pdl-mic-exchange.field.ts b/packages/twenty-apps/public/people-data-labs/src/fields/company/pdl-mic-exchange.field.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/fields/company/pdl-mic-exchange.field.ts rename to packages/twenty-apps/public/people-data-labs/src/fields/company/pdl-mic-exchange.field.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/fields/company/pdl-naics.field.ts b/packages/twenty-apps/public/people-data-labs/src/fields/company/pdl-naics.field.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/fields/company/pdl-naics.field.ts rename to packages/twenty-apps/public/people-data-labs/src/fields/company/pdl-naics.field.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/fields/company/pdl-number-funding-rounds.field.ts b/packages/twenty-apps/public/people-data-labs/src/fields/company/pdl-number-funding-rounds.field.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/fields/company/pdl-number-funding-rounds.field.ts rename to packages/twenty-apps/public/people-data-labs/src/fields/company/pdl-number-funding-rounds.field.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/fields/company/pdl-raw-payload.field.ts b/packages/twenty-apps/public/people-data-labs/src/fields/company/pdl-raw-payload.field.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/fields/company/pdl-raw-payload.field.ts rename to packages/twenty-apps/public/people-data-labs/src/fields/company/pdl-raw-payload.field.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/fields/company/pdl-sic.field.ts b/packages/twenty-apps/public/people-data-labs/src/fields/company/pdl-sic.field.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/fields/company/pdl-sic.field.ts rename to packages/twenty-apps/public/people-data-labs/src/fields/company/pdl-sic.field.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/fields/company/pdl-size-range.field.ts b/packages/twenty-apps/public/people-data-labs/src/fields/company/pdl-size-range.field.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/fields/company/pdl-size-range.field.ts rename to packages/twenty-apps/public/people-data-labs/src/fields/company/pdl-size-range.field.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/fields/company/pdl-summary.field.ts b/packages/twenty-apps/public/people-data-labs/src/fields/company/pdl-summary.field.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/fields/company/pdl-summary.field.ts rename to packages/twenty-apps/public/people-data-labs/src/fields/company/pdl-summary.field.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/fields/company/pdl-tags.field.ts b/packages/twenty-apps/public/people-data-labs/src/fields/company/pdl-tags.field.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/fields/company/pdl-tags.field.ts rename to packages/twenty-apps/public/people-data-labs/src/fields/company/pdl-tags.field.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/fields/company/pdl-ticker.field.ts b/packages/twenty-apps/public/people-data-labs/src/fields/company/pdl-ticker.field.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/fields/company/pdl-ticker.field.ts rename to packages/twenty-apps/public/people-data-labs/src/fields/company/pdl-ticker.field.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/fields/company/pdl-total-funding.field.ts b/packages/twenty-apps/public/people-data-labs/src/fields/company/pdl-total-funding.field.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/fields/company/pdl-total-funding.field.ts rename to packages/twenty-apps/public/people-data-labs/src/fields/company/pdl-total-funding.field.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/fields/company/pdl-twitter-url.field.ts b/packages/twenty-apps/public/people-data-labs/src/fields/company/pdl-twitter-url.field.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/fields/company/pdl-twitter-url.field.ts rename to packages/twenty-apps/public/people-data-labs/src/fields/company/pdl-twitter-url.field.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-birth-date.field.ts b/packages/twenty-apps/public/people-data-labs/src/fields/person/pdl-birth-date.field.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-birth-date.field.ts rename to packages/twenty-apps/public/people-data-labs/src/fields/person/pdl-birth-date.field.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-birth-year.field.ts b/packages/twenty-apps/public/people-data-labs/src/fields/person/pdl-birth-year.field.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-birth-year.field.ts rename to packages/twenty-apps/public/people-data-labs/src/fields/person/pdl-birth-year.field.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-certifications.field.ts b/packages/twenty-apps/public/people-data-labs/src/fields/person/pdl-certifications.field.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-certifications.field.ts rename to packages/twenty-apps/public/people-data-labs/src/fields/person/pdl-certifications.field.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-education.field.ts b/packages/twenty-apps/public/people-data-labs/src/fields/person/pdl-education.field.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-education.field.ts rename to packages/twenty-apps/public/people-data-labs/src/fields/person/pdl-education.field.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-enrichment-status.field.ts b/packages/twenty-apps/public/people-data-labs/src/fields/person/pdl-enrichment-status.field.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-enrichment-status.field.ts rename to packages/twenty-apps/public/people-data-labs/src/fields/person/pdl-enrichment-status.field.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-experience.field.ts b/packages/twenty-apps/public/people-data-labs/src/fields/person/pdl-experience.field.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-experience.field.ts rename to packages/twenty-apps/public/people-data-labs/src/fields/person/pdl-experience.field.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-facebook-url.field.ts b/packages/twenty-apps/public/people-data-labs/src/fields/person/pdl-facebook-url.field.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-facebook-url.field.ts rename to packages/twenty-apps/public/people-data-labs/src/fields/person/pdl-facebook-url.field.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-github-url.field.ts b/packages/twenty-apps/public/people-data-labs/src/fields/person/pdl-github-url.field.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-github-url.field.ts rename to packages/twenty-apps/public/people-data-labs/src/fields/person/pdl-github-url.field.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-headline.field.ts b/packages/twenty-apps/public/people-data-labs/src/fields/person/pdl-headline.field.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-headline.field.ts rename to packages/twenty-apps/public/people-data-labs/src/fields/person/pdl-headline.field.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-id.field.ts b/packages/twenty-apps/public/people-data-labs/src/fields/person/pdl-id.field.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-id.field.ts rename to packages/twenty-apps/public/people-data-labs/src/fields/person/pdl-id.field.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-industry.field.ts b/packages/twenty-apps/public/people-data-labs/src/fields/person/pdl-industry.field.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-industry.field.ts rename to packages/twenty-apps/public/people-data-labs/src/fields/person/pdl-industry.field.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-inferred-salary.field.ts b/packages/twenty-apps/public/people-data-labs/src/fields/person/pdl-inferred-salary.field.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-inferred-salary.field.ts rename to packages/twenty-apps/public/people-data-labs/src/fields/person/pdl-inferred-salary.field.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-interests.field.ts b/packages/twenty-apps/public/people-data-labs/src/fields/person/pdl-interests.field.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-interests.field.ts rename to packages/twenty-apps/public/people-data-labs/src/fields/person/pdl-interests.field.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-job-onet-code.field.ts b/packages/twenty-apps/public/people-data-labs/src/fields/person/pdl-job-onet-code.field.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-job-onet-code.field.ts rename to packages/twenty-apps/public/people-data-labs/src/fields/person/pdl-job-onet-code.field.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-job-role.field.ts b/packages/twenty-apps/public/people-data-labs/src/fields/person/pdl-job-role.field.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-job-role.field.ts rename to packages/twenty-apps/public/people-data-labs/src/fields/person/pdl-job-role.field.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-job-start-date.field.ts b/packages/twenty-apps/public/people-data-labs/src/fields/person/pdl-job-start-date.field.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-job-start-date.field.ts rename to packages/twenty-apps/public/people-data-labs/src/fields/person/pdl-job-start-date.field.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-job-summary.field.ts b/packages/twenty-apps/public/people-data-labs/src/fields/person/pdl-job-summary.field.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-job-summary.field.ts rename to packages/twenty-apps/public/people-data-labs/src/fields/person/pdl-job-summary.field.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-job-title-class.field.ts b/packages/twenty-apps/public/people-data-labs/src/fields/person/pdl-job-title-class.field.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-job-title-class.field.ts rename to packages/twenty-apps/public/people-data-labs/src/fields/person/pdl-job-title-class.field.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-job-title-sub-role.field.ts b/packages/twenty-apps/public/people-data-labs/src/fields/person/pdl-job-title-sub-role.field.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-job-title-sub-role.field.ts rename to packages/twenty-apps/public/people-data-labs/src/fields/person/pdl-job-title-sub-role.field.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-languages.field.ts b/packages/twenty-apps/public/people-data-labs/src/fields/person/pdl-languages.field.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-languages.field.ts rename to packages/twenty-apps/public/people-data-labs/src/fields/person/pdl-languages.field.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-last-enriched-at.field.ts b/packages/twenty-apps/public/people-data-labs/src/fields/person/pdl-last-enriched-at.field.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-last-enriched-at.field.ts rename to packages/twenty-apps/public/people-data-labs/src/fields/person/pdl-last-enriched-at.field.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-likelihood.field.ts b/packages/twenty-apps/public/people-data-labs/src/fields/person/pdl-likelihood.field.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-likelihood.field.ts rename to packages/twenty-apps/public/people-data-labs/src/fields/person/pdl-likelihood.field.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-linkedin-connections.field.ts b/packages/twenty-apps/public/people-data-labs/src/fields/person/pdl-linkedin-connections.field.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-linkedin-connections.field.ts rename to packages/twenty-apps/public/people-data-labs/src/fields/person/pdl-linkedin-connections.field.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-linkedin-username.field.ts b/packages/twenty-apps/public/people-data-labs/src/fields/person/pdl-linkedin-username.field.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-linkedin-username.field.ts rename to packages/twenty-apps/public/people-data-labs/src/fields/person/pdl-linkedin-username.field.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-location-metro.field.ts b/packages/twenty-apps/public/people-data-labs/src/fields/person/pdl-location-metro.field.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-location-metro.field.ts rename to packages/twenty-apps/public/people-data-labs/src/fields/person/pdl-location-metro.field.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-location.field.ts b/packages/twenty-apps/public/people-data-labs/src/fields/person/pdl-location.field.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-location.field.ts rename to packages/twenty-apps/public/people-data-labs/src/fields/person/pdl-location.field.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-name-aliases.field.ts b/packages/twenty-apps/public/people-data-labs/src/fields/person/pdl-name-aliases.field.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-name-aliases.field.ts rename to packages/twenty-apps/public/people-data-labs/src/fields/person/pdl-name-aliases.field.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-profiles.field.ts b/packages/twenty-apps/public/people-data-labs/src/fields/person/pdl-profiles.field.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-profiles.field.ts rename to packages/twenty-apps/public/people-data-labs/src/fields/person/pdl-profiles.field.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-raw-payload.field.ts b/packages/twenty-apps/public/people-data-labs/src/fields/person/pdl-raw-payload.field.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-raw-payload.field.ts rename to packages/twenty-apps/public/people-data-labs/src/fields/person/pdl-raw-payload.field.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-seniority.field.ts b/packages/twenty-apps/public/people-data-labs/src/fields/person/pdl-seniority.field.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-seniority.field.ts rename to packages/twenty-apps/public/people-data-labs/src/fields/person/pdl-seniority.field.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-sex.field.ts b/packages/twenty-apps/public/people-data-labs/src/fields/person/pdl-sex.field.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-sex.field.ts rename to packages/twenty-apps/public/people-data-labs/src/fields/person/pdl-sex.field.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-skills.field.ts b/packages/twenty-apps/public/people-data-labs/src/fields/person/pdl-skills.field.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-skills.field.ts rename to packages/twenty-apps/public/people-data-labs/src/fields/person/pdl-skills.field.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-summary.field.ts b/packages/twenty-apps/public/people-data-labs/src/fields/person/pdl-summary.field.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-summary.field.ts rename to packages/twenty-apps/public/people-data-labs/src/fields/person/pdl-summary.field.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-twitter-url.field.ts b/packages/twenty-apps/public/people-data-labs/src/fields/person/pdl-twitter-url.field.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-twitter-url.field.ts rename to packages/twenty-apps/public/people-data-labs/src/fields/person/pdl-twitter-url.field.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-years-experience.field.ts b/packages/twenty-apps/public/people-data-labs/src/fields/person/pdl-years-experience.field.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/fields/person/pdl-years-experience.field.ts rename to packages/twenty-apps/public/people-data-labs/src/fields/person/pdl-years-experience.field.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/__mocks__/company-node.mock.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/__mocks__/company-node.mock.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/__mocks__/company-node.mock.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/__mocks__/company-node.mock.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/__mocks__/create-core-api-client-mock.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/__mocks__/create-core-api-client-mock.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/__mocks__/create-core-api-client-mock.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/__mocks__/create-core-api-client-mock.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/__mocks__/pdl-company-data.mock.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/__mocks__/pdl-company-data.mock.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/__mocks__/pdl-company-data.mock.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/__mocks__/pdl-company-data.mock.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/__mocks__/pdl-person-data.mock.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/__mocks__/pdl-person-data.mock.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/__mocks__/pdl-person-data.mock.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/__mocks__/pdl-person-data.mock.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/__mocks__/person-node.mock.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/__mocks__/person-node.mock.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/__mocks__/person-node.mock.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/__mocks__/person-node.mock.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/enrich-companies.function.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/enrich-companies.function.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/enrich-companies.function.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/enrich-companies.function.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/enrich-company.function.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/enrich-company.function.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/enrich-company.function.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/enrich-company.function.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/enrich-people.function.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/enrich-people.function.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/enrich-people.function.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/enrich-people.function.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/enrich-person.function.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/enrich-person.function.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/enrich-person.function.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/enrich-person.function.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/errors/__tests__/pdl-config-error.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/errors/__tests__/pdl-config-error.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/errors/__tests__/pdl-config-error.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/errors/__tests__/pdl-config-error.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/errors/__tests__/pdl-invalid-input-error.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/errors/__tests__/pdl-invalid-input-error.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/errors/__tests__/pdl-invalid-input-error.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/errors/__tests__/pdl-invalid-input-error.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/errors/__tests__/pdl-operation-error.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/errors/__tests__/pdl-operation-error.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/errors/__tests__/pdl-operation-error.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/errors/__tests__/pdl-operation-error.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/errors/__tests__/pdl-record-not-found-error.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/errors/__tests__/pdl-record-not-found-error.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/errors/__tests__/pdl-record-not-found-error.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/errors/__tests__/pdl-record-not-found-error.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/errors/pdl-config-error.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/errors/pdl-config-error.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/errors/pdl-config-error.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/errors/pdl-config-error.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/errors/pdl-error-code.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/errors/pdl-error-code.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/errors/pdl-error-code.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/errors/pdl-error-code.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/errors/pdl-error.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/errors/pdl-error.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/errors/pdl-error.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/errors/pdl-error.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/errors/pdl-invalid-input-error.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/errors/pdl-invalid-input-error.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/errors/pdl-invalid-input-error.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/errors/pdl-invalid-input-error.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/errors/pdl-operation-error.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/errors/pdl-operation-error.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/errors/pdl-operation-error.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/errors/pdl-operation-error.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/errors/pdl-record-not-found-error.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/errors/pdl-record-not-found-error.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/errors/pdl-record-not-found-error.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/errors/pdl-record-not-found-error.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/handlers/__tests__/enrich-companies.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/handlers/__tests__/enrich-companies.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/handlers/__tests__/enrich-companies.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/handlers/__tests__/enrich-companies.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/handlers/__tests__/enrich-company.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/handlers/__tests__/enrich-company.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/handlers/__tests__/enrich-company.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/handlers/__tests__/enrich-company.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/handlers/__tests__/enrich-people.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/handlers/__tests__/enrich-people.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/handlers/__tests__/enrich-people.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/handlers/__tests__/enrich-people.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/handlers/__tests__/enrich-person.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/handlers/__tests__/enrich-person.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/handlers/__tests__/enrich-person.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/handlers/__tests__/enrich-person.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/handlers/__tests__/post-install.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/handlers/__tests__/post-install.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/handlers/__tests__/post-install.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/handlers/__tests__/post-install.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/handlers/company-enrichment-adapter.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/handlers/company-enrichment-adapter.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/handlers/company-enrichment-adapter.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/handlers/company-enrichment-adapter.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/handlers/company-single-enrichment-adapter.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/handlers/company-single-enrichment-adapter.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/handlers/company-single-enrichment-adapter.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/handlers/company-single-enrichment-adapter.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/handlers/enrich-companies.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/handlers/enrich-companies.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/handlers/enrich-companies.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/handlers/enrich-companies.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/handlers/enrich-company.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/handlers/enrich-company.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/handlers/enrich-company.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/handlers/enrich-company.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/handlers/enrich-people.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/handlers/enrich-people.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/handlers/enrich-people.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/handlers/enrich-people.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/handlers/enrich-person.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/handlers/enrich-person.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/handlers/enrich-person.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/handlers/enrich-person.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/handlers/person-enrichment-adapter.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/handlers/person-enrichment-adapter.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/handlers/person-enrichment-adapter.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/handlers/person-enrichment-adapter.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/handlers/person-single-enrichment-adapter.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/handlers/person-single-enrichment-adapter.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/handlers/person-single-enrichment-adapter.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/handlers/person-single-enrichment-adapter.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/handlers/post-install.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/handlers/post-install.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/handlers/post-install.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/handlers/post-install.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/post-install.function.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/post-install.function.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/post-install.function.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/post-install.function.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/aggregate-bulk-enrich-result.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/aggregate-bulk-enrich-result.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/aggregate-bulk-enrich-result.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/aggregate-bulk-enrich-result.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/build-address.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/build-address.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/build-address.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/build-address.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/build-allowed-values.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/build-allowed-values.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/build-allowed-values.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/build-allowed-values.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/build-bulk-records-trigger.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/build-bulk-records-trigger.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/build-bulk-records-trigger.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/build-bulk-records-trigger.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/build-company-create-data.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/build-company-create-data.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/build-company-create-data.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/build-company-create-data.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/build-company-match-keys.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/build-company-match-keys.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/build-company-match-keys.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/build-company-match-keys.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/build-company-matched-data.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/build-company-matched-data.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/build-company-matched-data.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/build-company-matched-data.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/build-currency-from-usd.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/build-currency-from-usd.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/build-currency-from-usd.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/build-currency-from-usd.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/build-emails.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/build-emails.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/build-emails.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/build-emails.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/build-error-result.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/build-error-result.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/build-error-result.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/build-error-result.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/build-full-name.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/build-full-name.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/build-full-name.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/build-full-name.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/build-links.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/build-links.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/build-links.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/build-links.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/build-logic-function-step.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/build-logic-function-step.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/build-logic-function-step.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/build-logic-function-step.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/build-matched-result.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/build-matched-result.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/build-matched-result.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/build-matched-result.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/build-not-found-result.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/build-not-found-result.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/build-not-found-result.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/build-not-found-result.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/build-person-matched-data.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/build-person-matched-data.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/build-person-matched-data.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/build-person-matched-data.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/build-person-name-param.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/build-person-name-param.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/build-person-name-param.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/build-person-name-param.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/build-phones.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/build-phones.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/build-phones.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/build-phones.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/build-skipped-result.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/build-skipped-result.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/build-skipped-result.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/build-skipped-result.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/capitalize-name.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/capitalize-name.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/capitalize-name.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/capitalize-name.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/capitalize-name.test.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/capitalize-name.test.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/capitalize-name.test.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/capitalize-name.test.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/charge-matched-enrichments.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/charge-matched-enrichments.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/charge-matched-enrichments.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/charge-matched-enrichments.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/chunk.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/chunk.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/chunk.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/chunk.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/enrich-companies.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/enrich-companies.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/enrich-companies.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/enrich-companies.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/enrich-company.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/enrich-company.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/enrich-company.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/enrich-company.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/enrich-people.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/enrich-people.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/enrich-people.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/enrich-people.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/enrich-person.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/enrich-person.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/enrich-person.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/enrich-person.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/extract-company-match-params.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/extract-company-match-params.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/extract-company-match-params.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/extract-company-match-params.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/extract-pdl-error-message.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/extract-pdl-error-message.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/extract-pdl-error-message.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/extract-pdl-error-message.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/extract-person-match-params.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/extract-person-match-params.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/extract-person-match-params.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/extract-person-match-params.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/extract-record-ids.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/extract-record-ids.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/extract-record-ids.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/extract-record-ids.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/find-company-id-by-filter.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/find-company-id-by-filter.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/find-company-id-by-filter.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/find-company-id-by-filter.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/find-company-id.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/find-company-id.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/find-company-id.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/find-company-id.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/find-existing-workflow-id.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/find-existing-workflow-id.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/find-existing-workflow-id.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/find-existing-workflow-id.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/find-or-create-current-company.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/find-or-create-current-company.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/find-or-create-current-company.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/find-or-create-current-company.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/get-pdl-api-key.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/get-pdl-api-key.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/get-pdl-api-key.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/get-pdl-api-key.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/is-empty-address.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/is-empty-address.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/is-empty-address.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/is-empty-address.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/is-empty-emails.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/is-empty-emails.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/is-empty-emails.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/is-empty-emails.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/is-empty-full-name.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/is-empty-full-name.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/is-empty-full-name.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/is-empty-full-name.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/is-empty-links.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/is-empty-links.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/is-empty-links.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/is-empty-links.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/is-empty-phones.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/is-empty-phones.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/is-empty-phones.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/is-empty-phones.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/is-empty-text.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/is-empty-text.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/is-empty-text.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/is-empty-text.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/map-company.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/map-company.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/map-company.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/map-company.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/map-person.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/map-person.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/map-person.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/map-person.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/normalize-domain.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/normalize-domain.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/normalize-domain.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/normalize-domain.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/normalize-linkedin-url.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/normalize-linkedin-url.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/normalize-linkedin-url.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/normalize-linkedin-url.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/now-iso.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/now-iso.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/now-iso.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/now-iso.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/parse-geo.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/parse-geo.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/parse-geo.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/parse-geo.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/parse-partial-date.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/parse-partial-date.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/parse-partial-date.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/parse-partial-date.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/parse-pdl-item.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/parse-pdl-item.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/parse-pdl-item.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/parse-pdl-item.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/pick-multi-select.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/pick-multi-select.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/pick-multi-select.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/pick-multi-select.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/pick-select.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/pick-select.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/pick-select.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/pick-select.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/pick-writable-standard.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/pick-writable-standard.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/pick-writable-standard.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/pick-writable-standard.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/post-pdl-enrich.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/post-pdl-enrich.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/post-pdl-enrich.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/post-pdl-enrich.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/post-pdl-single-enrich.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/post-pdl-single-enrich.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/post-pdl-single-enrich.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/post-pdl-single-enrich.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/read-companies.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/read-companies.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/read-companies.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/read-companies.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/read-people.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/read-people.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/read-people.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/read-people.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/resolve-logic-function-id.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/resolve-logic-function-id.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/resolve-logic-function-id.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/resolve-logic-function-id.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/resolve-update-fields-mode.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/resolve-update-fields-mode.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/resolve-update-fields-mode.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/resolve-update-fields-mode.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/run-batch-enrichment.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/run-batch-enrichment.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/run-batch-enrichment.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/run-batch-enrichment.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/salary-transform.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/salary-transform.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/salary-transform.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/salary-transform.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/seed-enrichment-workflow.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/seed-enrichment-workflow.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/seed-enrichment-workflow.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/seed-enrichment-workflow.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/size-transform.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/size-transform.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/size-transform.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/size-transform.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/to-json-array.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/to-json-array.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/to-json-array.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/to-json-array.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/to-json-object.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/to-json-object.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/to-json-object.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/to-json-object.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/to-number.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/to-number.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/to-number.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/to-number.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/to-string-array.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/to-string-array.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/to-string-array.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/to-string-array.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/to-text.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/to-text.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/to-text.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/to-text.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/update-companies-status.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/update-companies-status.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/update-companies-status.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/update-companies-status.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/update-company-record.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/update-company-record.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/update-company-record.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/update-company-record.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/update-people-status.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/update-people-status.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/update-people-status.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/update-people-status.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/update-person-record.spec.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/update-person-record.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/__tests__/update-person-record.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/__tests__/update-person-record.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/aggregate-bulk-enrich-result.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/aggregate-bulk-enrich-result.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/aggregate-bulk-enrich-result.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/aggregate-bulk-enrich-result.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/build-address.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/build-address.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/build-address.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/build-address.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/build-allowed-values.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/build-allowed-values.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/build-allowed-values.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/build-allowed-values.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/build-bulk-records-trigger.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/build-bulk-records-trigger.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/build-bulk-records-trigger.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/build-bulk-records-trigger.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/build-company-create-data.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/build-company-create-data.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/build-company-create-data.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/build-company-create-data.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/build-company-match-keys.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/build-company-match-keys.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/build-company-match-keys.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/build-company-match-keys.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/build-company-matched-data.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/build-company-matched-data.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/build-company-matched-data.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/build-company-matched-data.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/build-currency-from-usd.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/build-currency-from-usd.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/build-currency-from-usd.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/build-currency-from-usd.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/build-emails.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/build-emails.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/build-emails.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/build-emails.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/build-error-result.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/build-error-result.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/build-error-result.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/build-error-result.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/build-full-name.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/build-full-name.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/build-full-name.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/build-full-name.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/build-links.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/build-links.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/build-links.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/build-links.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/build-logic-function-step.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/build-logic-function-step.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/build-logic-function-step.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/build-logic-function-step.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/build-matched-result.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/build-matched-result.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/build-matched-result.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/build-matched-result.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/build-not-found-result.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/build-not-found-result.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/build-not-found-result.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/build-not-found-result.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/build-person-matched-data.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/build-person-matched-data.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/build-person-matched-data.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/build-person-matched-data.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/build-person-name-param.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/build-person-name-param.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/build-person-name-param.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/build-person-name-param.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/build-phones.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/build-phones.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/build-phones.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/build-phones.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/build-skipped-result.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/build-skipped-result.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/build-skipped-result.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/build-skipped-result.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/capitalize-name.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/capitalize-name.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/capitalize-name.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/capitalize-name.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/charge-matched-enrichments.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/charge-matched-enrichments.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/charge-matched-enrichments.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/charge-matched-enrichments.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/chunk.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/chunk.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/chunk.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/chunk.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/enrich-chunk.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/enrich-chunk.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/enrich-chunk.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/enrich-chunk.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/enrich-companies.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/enrich-companies.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/enrich-companies.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/enrich-companies.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/enrich-company.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/enrich-company.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/enrich-company.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/enrich-company.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/enrich-people.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/enrich-people.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/enrich-people.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/enrich-people.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/enrich-person.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/enrich-person.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/enrich-person.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/enrich-person.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/extract-company-match-params.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/extract-company-match-params.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/extract-company-match-params.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/extract-company-match-params.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/extract-pdl-error-message.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/extract-pdl-error-message.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/extract-pdl-error-message.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/extract-pdl-error-message.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/extract-person-match-params.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/extract-person-match-params.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/extract-person-match-params.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/extract-person-match-params.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/extract-record-ids.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/extract-record-ids.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/extract-record-ids.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/extract-record-ids.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/find-company-id-by-filter.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/find-company-id-by-filter.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/find-company-id-by-filter.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/find-company-id-by-filter.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/find-company-id.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/find-company-id.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/find-company-id.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/find-company-id.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/find-existing-workflow-id.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/find-existing-workflow-id.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/find-existing-workflow-id.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/find-existing-workflow-id.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/find-or-create-current-company.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/find-or-create-current-company.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/find-or-create-current-company.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/find-or-create-current-company.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/get-pdl-api-key.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/get-pdl-api-key.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/get-pdl-api-key.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/get-pdl-api-key.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/internal-field-names.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/internal-field-names.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/internal-field-names.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/internal-field-names.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/is-empty-address.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/is-empty-address.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/is-empty-address.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/is-empty-address.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/is-empty-emails.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/is-empty-emails.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/is-empty-emails.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/is-empty-emails.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/is-empty-full-name.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/is-empty-full-name.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/is-empty-full-name.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/is-empty-full-name.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/is-empty-links.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/is-empty-links.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/is-empty-links.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/is-empty-links.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/is-empty-phones.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/is-empty-phones.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/is-empty-phones.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/is-empty-phones.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/is-empty-text.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/is-empty-text.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/is-empty-text.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/is-empty-text.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/map-company.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/map-company.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/map-company.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/map-company.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/map-person.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/map-person.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/map-person.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/map-person.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/normalize-domain.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/normalize-domain.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/normalize-domain.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/normalize-domain.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/normalize-linkedin-url.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/normalize-linkedin-url.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/normalize-linkedin-url.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/normalize-linkedin-url.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/now-iso.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/now-iso.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/now-iso.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/now-iso.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/parse-geo.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/parse-geo.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/parse-geo.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/parse-geo.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/parse-partial-date.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/parse-partial-date.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/parse-partial-date.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/parse-partial-date.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/parse-pdl-item.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/parse-pdl-item.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/parse-pdl-item.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/parse-pdl-item.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/pick-multi-select.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/pick-multi-select.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/pick-multi-select.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/pick-multi-select.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/pick-select.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/pick-select.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/pick-select.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/pick-select.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/pick-writable-standard.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/pick-writable-standard.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/pick-writable-standard.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/pick-writable-standard.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/post-pdl-enrich.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/post-pdl-enrich.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/post-pdl-enrich.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/post-pdl-enrich.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/post-pdl-single-enrich.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/post-pdl-single-enrich.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/post-pdl-single-enrich.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/post-pdl-single-enrich.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/read-companies.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/read-companies.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/read-companies.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/read-companies.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/read-people.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/read-people.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/read-people.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/read-people.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/resolve-logic-function-id.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/resolve-logic-function-id.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/resolve-logic-function-id.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/resolve-logic-function-id.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/resolve-min-likelihood.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/resolve-min-likelihood.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/resolve-min-likelihood.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/resolve-min-likelihood.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/resolve-update-fields-mode.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/resolve-update-fields-mode.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/resolve-update-fields-mode.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/resolve-update-fields-mode.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/run-batch-enrichment.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/run-batch-enrichment.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/run-batch-enrichment.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/run-batch-enrichment.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/run-single-enrichment.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/run-single-enrichment.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/run-single-enrichment.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/run-single-enrichment.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/salary-transform.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/salary-transform.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/salary-transform.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/salary-transform.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/seed-enrichment-workflow.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/seed-enrichment-workflow.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/seed-enrichment-workflow.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/seed-enrichment-workflow.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/size-transform.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/size-transform.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/size-transform.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/size-transform.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/to-json-array.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/to-json-array.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/to-json-array.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/to-json-array.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/to-json-object.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/to-json-object.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/to-json-object.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/to-json-object.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/to-number.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/to-number.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/to-number.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/to-number.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/to-string-array.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/to-string-array.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/to-string-array.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/to-string-array.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/to-text.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/to-text.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/to-text.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/to-text.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/update-companies-status.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/update-companies-status.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/update-companies-status.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/update-companies-status.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/update-company-record.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/update-company-record.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/update-company-record.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/update-company-record.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/update-people-status.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/update-people-status.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/update-people-status.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/update-people-status.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/update-person-record.ts b/packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/update-person-record.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/logic-functions/utils/update-person-record.ts rename to packages/twenty-apps/public/people-data-labs/src/logic-functions/utils/update-person-record.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/roles/default-function.role.ts b/packages/twenty-apps/public/people-data-labs/src/roles/default-function.role.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/roles/default-function.role.ts rename to packages/twenty-apps/public/people-data-labs/src/roles/default-function.role.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/types/address-parts.ts b/packages/twenty-apps/public/people-data-labs/src/types/address-parts.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/types/address-parts.ts rename to packages/twenty-apps/public/people-data-labs/src/types/address-parts.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/types/address-value.ts b/packages/twenty-apps/public/people-data-labs/src/types/address-value.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/types/address-value.ts rename to packages/twenty-apps/public/people-data-labs/src/types/address-value.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/types/batch-enrichment-adapter.ts b/packages/twenty-apps/public/people-data-labs/src/types/batch-enrichment-adapter.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/types/batch-enrichment-adapter.ts rename to packages/twenty-apps/public/people-data-labs/src/types/batch-enrichment-adapter.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/types/bulk-enrich-input.ts b/packages/twenty-apps/public/people-data-labs/src/types/bulk-enrich-input.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/types/bulk-enrich-input.ts rename to packages/twenty-apps/public/people-data-labs/src/types/bulk-enrich-input.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/types/bulk-enrich-result.ts b/packages/twenty-apps/public/people-data-labs/src/types/bulk-enrich-result.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/types/bulk-enrich-result.ts rename to packages/twenty-apps/public/people-data-labs/src/types/bulk-enrich-result.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/types/company-id-by-match-key-cache.ts b/packages/twenty-apps/public/people-data-labs/src/types/company-id-by-match-key-cache.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/types/company-id-by-match-key-cache.ts rename to packages/twenty-apps/public/people-data-labs/src/types/company-id-by-match-key-cache.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/types/company-match-keys.ts b/packages/twenty-apps/public/people-data-labs/src/types/company-match-keys.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/types/company-match-keys.ts rename to packages/twenty-apps/public/people-data-labs/src/types/company-match-keys.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/types/company-node.ts b/packages/twenty-apps/public/people-data-labs/src/types/company-node.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/types/company-node.ts rename to packages/twenty-apps/public/people-data-labs/src/types/company-node.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/types/currency-value.ts b/packages/twenty-apps/public/people-data-labs/src/types/currency-value.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/types/currency-value.ts rename to packages/twenty-apps/public/people-data-labs/src/types/currency-value.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/types/emails-value.ts b/packages/twenty-apps/public/people-data-labs/src/types/emails-value.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/types/emails-value.ts rename to packages/twenty-apps/public/people-data-labs/src/types/emails-value.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/types/enrich-result.ts b/packages/twenty-apps/public/people-data-labs/src/types/enrich-result.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/types/enrich-result.ts rename to packages/twenty-apps/public/people-data-labs/src/types/enrich-result.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/types/enrich-status.ts b/packages/twenty-apps/public/people-data-labs/src/types/enrich-status.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/types/enrich-status.ts rename to packages/twenty-apps/public/people-data-labs/src/types/enrich-status.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/types/enrichment-workflow-seed.ts b/packages/twenty-apps/public/people-data-labs/src/types/enrichment-workflow-seed.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/types/enrichment-workflow-seed.ts rename to packages/twenty-apps/public/people-data-labs/src/types/enrichment-workflow-seed.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/types/full-name-value.ts b/packages/twenty-apps/public/people-data-labs/src/types/full-name-value.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/types/full-name-value.ts rename to packages/twenty-apps/public/people-data-labs/src/types/full-name-value.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/types/links-value.ts b/packages/twenty-apps/public/people-data-labs/src/types/links-value.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/types/links-value.ts rename to packages/twenty-apps/public/people-data-labs/src/types/links-value.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/types/mapped-record.ts b/packages/twenty-apps/public/people-data-labs/src/types/mapped-record.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/types/mapped-record.ts rename to packages/twenty-apps/public/people-data-labs/src/types/mapped-record.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/types/pdl-company-data.ts b/packages/twenty-apps/public/people-data-labs/src/types/pdl-company-data.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/types/pdl-company-data.ts rename to packages/twenty-apps/public/people-data-labs/src/types/pdl-company-data.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/types/pdl-company-enrich-params.ts b/packages/twenty-apps/public/people-data-labs/src/types/pdl-company-enrich-params.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/types/pdl-company-enrich-params.ts rename to packages/twenty-apps/public/people-data-labs/src/types/pdl-company-enrich-params.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/types/pdl-enrich-result.ts b/packages/twenty-apps/public/people-data-labs/src/types/pdl-enrich-result.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/types/pdl-enrich-result.ts rename to packages/twenty-apps/public/people-data-labs/src/types/pdl-enrich-result.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/types/pdl-person-data.ts b/packages/twenty-apps/public/people-data-labs/src/types/pdl-person-data.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/types/pdl-person-data.ts rename to packages/twenty-apps/public/people-data-labs/src/types/pdl-person-data.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/types/pdl-person-enrich-params.ts b/packages/twenty-apps/public/people-data-labs/src/types/pdl-person-enrich-params.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/types/pdl-person-enrich-params.ts rename to packages/twenty-apps/public/people-data-labs/src/types/pdl-person-enrich-params.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/types/person-node.ts b/packages/twenty-apps/public/people-data-labs/src/types/person-node.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/types/person-node.ts rename to packages/twenty-apps/public/people-data-labs/src/types/person-node.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/types/phones-value.ts b/packages/twenty-apps/public/people-data-labs/src/types/phones-value.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/types/phones-value.ts rename to packages/twenty-apps/public/people-data-labs/src/types/phones-value.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/types/post-install-result.ts b/packages/twenty-apps/public/people-data-labs/src/types/post-install-result.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/types/post-install-result.ts rename to packages/twenty-apps/public/people-data-labs/src/types/post-install-result.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/types/seed-enrichment-workflow-result.ts b/packages/twenty-apps/public/people-data-labs/src/types/seed-enrichment-workflow-result.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/types/seed-enrichment-workflow-result.ts rename to packages/twenty-apps/public/people-data-labs/src/types/seed-enrichment-workflow-result.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/types/select-option-meta.ts b/packages/twenty-apps/public/people-data-labs/src/types/select-option-meta.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/types/select-option-meta.ts rename to packages/twenty-apps/public/people-data-labs/src/types/select-option-meta.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/types/single-enrich-input.ts b/packages/twenty-apps/public/people-data-labs/src/types/single-enrich-input.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/types/single-enrich-input.ts rename to packages/twenty-apps/public/people-data-labs/src/types/single-enrich-input.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/types/tag-color.ts b/packages/twenty-apps/public/people-data-labs/src/types/tag-color.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/types/tag-color.ts rename to packages/twenty-apps/public/people-data-labs/src/types/tag-color.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/types/update-fields-option.ts b/packages/twenty-apps/public/people-data-labs/src/types/update-fields-option.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/types/update-fields-option.ts rename to packages/twenty-apps/public/people-data-labs/src/types/update-fields-option.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/types/workflow-bulk-records-trigger.ts b/packages/twenty-apps/public/people-data-labs/src/types/workflow-bulk-records-trigger.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/types/workflow-bulk-records-trigger.ts rename to packages/twenty-apps/public/people-data-labs/src/types/workflow-bulk-records-trigger.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/types/workflow-logic-function-step.ts b/packages/twenty-apps/public/people-data-labs/src/types/workflow-logic-function-step.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/types/workflow-logic-function-step.ts rename to packages/twenty-apps/public/people-data-labs/src/types/workflow-logic-function-step.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/utils/__tests__/build-select-options.spec.ts b/packages/twenty-apps/public/people-data-labs/src/utils/__tests__/build-select-options.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/utils/__tests__/build-select-options.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/utils/__tests__/build-select-options.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/utils/__tests__/is-defined.spec.ts b/packages/twenty-apps/public/people-data-labs/src/utils/__tests__/is-defined.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/utils/__tests__/is-defined.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/utils/__tests__/is-defined.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/utils/__tests__/is-unique-violation-error.spec.ts b/packages/twenty-apps/public/people-data-labs/src/utils/__tests__/is-unique-violation-error.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/utils/__tests__/is-unique-violation-error.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/utils/__tests__/is-unique-violation-error.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/utils/__tests__/normalize-enum-value.spec.ts b/packages/twenty-apps/public/people-data-labs/src/utils/__tests__/normalize-enum-value.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/utils/__tests__/normalize-enum-value.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/utils/__tests__/normalize-enum-value.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/utils/__tests__/prune-undefined.spec.ts b/packages/twenty-apps/public/people-data-labs/src/utils/__tests__/prune-undefined.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/utils/__tests__/prune-undefined.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/utils/__tests__/prune-undefined.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/utils/__tests__/strip-separators.spec.ts b/packages/twenty-apps/public/people-data-labs/src/utils/__tests__/strip-separators.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/utils/__tests__/strip-separators.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/utils/__tests__/strip-separators.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/utils/__tests__/to-error-message.spec.ts b/packages/twenty-apps/public/people-data-labs/src/utils/__tests__/to-error-message.spec.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/utils/__tests__/to-error-message.spec.ts rename to packages/twenty-apps/public/people-data-labs/src/utils/__tests__/to-error-message.spec.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/utils/build-select-options.ts b/packages/twenty-apps/public/people-data-labs/src/utils/build-select-options.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/utils/build-select-options.ts rename to packages/twenty-apps/public/people-data-labs/src/utils/build-select-options.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/utils/is-defined.ts b/packages/twenty-apps/public/people-data-labs/src/utils/is-defined.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/utils/is-defined.ts rename to packages/twenty-apps/public/people-data-labs/src/utils/is-defined.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/utils/is-unique-violation-error.ts b/packages/twenty-apps/public/people-data-labs/src/utils/is-unique-violation-error.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/utils/is-unique-violation-error.ts rename to packages/twenty-apps/public/people-data-labs/src/utils/is-unique-violation-error.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/utils/normalize-enum-value.ts b/packages/twenty-apps/public/people-data-labs/src/utils/normalize-enum-value.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/utils/normalize-enum-value.ts rename to packages/twenty-apps/public/people-data-labs/src/utils/normalize-enum-value.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/utils/prune-undefined.ts b/packages/twenty-apps/public/people-data-labs/src/utils/prune-undefined.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/utils/prune-undefined.ts rename to packages/twenty-apps/public/people-data-labs/src/utils/prune-undefined.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/utils/strip-separators.ts b/packages/twenty-apps/public/people-data-labs/src/utils/strip-separators.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/utils/strip-separators.ts rename to packages/twenty-apps/public/people-data-labs/src/utils/strip-separators.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/utils/to-error-message.ts b/packages/twenty-apps/public/people-data-labs/src/utils/to-error-message.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/utils/to-error-message.ts rename to packages/twenty-apps/public/people-data-labs/src/utils/to-error-message.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/views/enriched-companies.view.ts b/packages/twenty-apps/public/people-data-labs/src/views/enriched-companies.view.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/views/enriched-companies.view.ts rename to packages/twenty-apps/public/people-data-labs/src/views/enriched-companies.view.ts diff --git a/packages/twenty-apps/internal/people-data-labs/src/views/enriched-people.view.ts b/packages/twenty-apps/public/people-data-labs/src/views/enriched-people.view.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/src/views/enriched-people.view.ts rename to packages/twenty-apps/public/people-data-labs/src/views/enriched-people.view.ts diff --git a/packages/twenty-apps/internal/people-data-labs/tsconfig.json b/packages/twenty-apps/public/people-data-labs/tsconfig.json similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/tsconfig.json rename to packages/twenty-apps/public/people-data-labs/tsconfig.json diff --git a/packages/twenty-apps/internal/people-data-labs/tsconfig.spec.json b/packages/twenty-apps/public/people-data-labs/tsconfig.spec.json similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/tsconfig.spec.json rename to packages/twenty-apps/public/people-data-labs/tsconfig.spec.json diff --git a/packages/twenty-apps/internal/people-data-labs/vitest.config.ts b/packages/twenty-apps/public/people-data-labs/vitest.config.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/vitest.config.ts rename to packages/twenty-apps/public/people-data-labs/vitest.config.ts diff --git a/packages/twenty-apps/internal/people-data-labs/vitest.unit.config.ts b/packages/twenty-apps/public/people-data-labs/vitest.unit.config.ts similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/vitest.unit.config.ts rename to packages/twenty-apps/public/people-data-labs/vitest.unit.config.ts diff --git a/packages/twenty-apps/internal/people-data-labs/yarn.lock b/packages/twenty-apps/public/people-data-labs/yarn.lock similarity index 100% rename from packages/twenty-apps/internal/people-data-labs/yarn.lock rename to packages/twenty-apps/public/people-data-labs/yarn.lock diff --git a/packages/twenty-apps/internal/twenty-discord/.gitignore b/packages/twenty-apps/public/twenty-discord/.gitignore similarity index 100% rename from packages/twenty-apps/internal/twenty-discord/.gitignore rename to packages/twenty-apps/public/twenty-discord/.gitignore diff --git a/packages/twenty-apps/internal/twenty-discord/.nvmrc b/packages/twenty-apps/public/twenty-discord/.nvmrc similarity index 100% rename from packages/twenty-apps/internal/twenty-discord/.nvmrc rename to packages/twenty-apps/public/twenty-discord/.nvmrc diff --git a/packages/twenty-apps/internal/twenty-discord/.oxlintrc.json b/packages/twenty-apps/public/twenty-discord/.oxlintrc.json similarity index 100% rename from packages/twenty-apps/internal/twenty-discord/.oxlintrc.json rename to packages/twenty-apps/public/twenty-discord/.oxlintrc.json diff --git a/packages/twenty-apps/internal/twenty-discord/.yarn/install-state.gz b/packages/twenty-apps/public/twenty-discord/.yarn/install-state.gz similarity index 100% rename from packages/twenty-apps/internal/twenty-discord/.yarn/install-state.gz rename to packages/twenty-apps/public/twenty-discord/.yarn/install-state.gz diff --git a/packages/twenty-apps/internal/twenty-discord/.yarnrc.yml b/packages/twenty-apps/public/twenty-discord/.yarnrc.yml similarity index 100% rename from packages/twenty-apps/internal/twenty-discord/.yarnrc.yml rename to packages/twenty-apps/public/twenty-discord/.yarnrc.yml diff --git a/packages/twenty-apps/internal/twenty-discord/README.md b/packages/twenty-apps/public/twenty-discord/README.md similarity index 100% rename from packages/twenty-apps/internal/twenty-discord/README.md rename to packages/twenty-apps/public/twenty-discord/README.md diff --git a/packages/twenty-apps/internal/twenty-discord/package.json b/packages/twenty-apps/public/twenty-discord/package.json similarity index 100% rename from packages/twenty-apps/internal/twenty-discord/package.json rename to packages/twenty-apps/public/twenty-discord/package.json diff --git a/packages/twenty-apps/internal/twenty-discord/public/twenty-discord.svg b/packages/twenty-apps/public/twenty-discord/public/twenty-discord.svg similarity index 100% rename from packages/twenty-apps/internal/twenty-discord/public/twenty-discord.svg rename to packages/twenty-apps/public/twenty-discord/public/twenty-discord.svg diff --git a/packages/twenty-apps/internal/twenty-discord/src/__tests__/global-setup.ts b/packages/twenty-apps/public/twenty-discord/src/__tests__/global-setup.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-discord/src/__tests__/global-setup.ts rename to packages/twenty-apps/public/twenty-discord/src/__tests__/global-setup.ts diff --git a/packages/twenty-apps/internal/twenty-discord/src/__tests__/twenty-discord.integration-test.ts b/packages/twenty-apps/public/twenty-discord/src/__tests__/twenty-discord.integration-test.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-discord/src/__tests__/twenty-discord.integration-test.ts rename to packages/twenty-apps/public/twenty-discord/src/__tests__/twenty-discord.integration-test.ts diff --git a/packages/twenty-apps/internal/twenty-discord/src/application.config.ts b/packages/twenty-apps/public/twenty-discord/src/application.config.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-discord/src/application.config.ts rename to packages/twenty-apps/public/twenty-discord/src/application.config.ts diff --git a/packages/twenty-apps/internal/twenty-discord/src/command-menu-items/send-discord-message.command-menu-item.ts b/packages/twenty-apps/public/twenty-discord/src/command-menu-items/send-discord-message.command-menu-item.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-discord/src/command-menu-items/send-discord-message.command-menu-item.ts rename to packages/twenty-apps/public/twenty-discord/src/command-menu-items/send-discord-message.command-menu-item.ts diff --git a/packages/twenty-apps/internal/twenty-discord/src/components/send-message-form.front-component.tsx b/packages/twenty-apps/public/twenty-discord/src/components/send-message-form.front-component.tsx similarity index 100% rename from packages/twenty-apps/internal/twenty-discord/src/components/send-message-form.front-component.tsx rename to packages/twenty-apps/public/twenty-discord/src/components/send-message-form.front-component.tsx diff --git a/packages/twenty-apps/internal/twenty-discord/src/constants/universal-identifiers.ts b/packages/twenty-apps/public/twenty-discord/src/constants/universal-identifiers.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-discord/src/constants/universal-identifiers.ts rename to packages/twenty-apps/public/twenty-discord/src/constants/universal-identifiers.ts diff --git a/packages/twenty-apps/internal/twenty-discord/src/logic-functions/discord-add-reaction.ts b/packages/twenty-apps/public/twenty-discord/src/logic-functions/discord-add-reaction.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-discord/src/logic-functions/discord-add-reaction.ts rename to packages/twenty-apps/public/twenty-discord/src/logic-functions/discord-add-reaction.ts diff --git a/packages/twenty-apps/internal/twenty-discord/src/logic-functions/discord-delete-message.ts b/packages/twenty-apps/public/twenty-discord/src/logic-functions/discord-delete-message.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-discord/src/logic-functions/discord-delete-message.ts rename to packages/twenty-apps/public/twenty-discord/src/logic-functions/discord-delete-message.ts diff --git a/packages/twenty-apps/internal/twenty-discord/src/logic-functions/discord-list-channels-route.ts b/packages/twenty-apps/public/twenty-discord/src/logic-functions/discord-list-channels-route.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-discord/src/logic-functions/discord-list-channels-route.ts rename to packages/twenty-apps/public/twenty-discord/src/logic-functions/discord-list-channels-route.ts diff --git a/packages/twenty-apps/internal/twenty-discord/src/logic-functions/discord-list-channels.ts b/packages/twenty-apps/public/twenty-discord/src/logic-functions/discord-list-channels.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-discord/src/logic-functions/discord-list-channels.ts rename to packages/twenty-apps/public/twenty-discord/src/logic-functions/discord-list-channels.ts diff --git a/packages/twenty-apps/internal/twenty-discord/src/logic-functions/discord-list-guilds-route.ts b/packages/twenty-apps/public/twenty-discord/src/logic-functions/discord-list-guilds-route.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-discord/src/logic-functions/discord-list-guilds-route.ts rename to packages/twenty-apps/public/twenty-discord/src/logic-functions/discord-list-guilds-route.ts diff --git a/packages/twenty-apps/internal/twenty-discord/src/logic-functions/discord-list-guilds.ts b/packages/twenty-apps/public/twenty-discord/src/logic-functions/discord-list-guilds.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-discord/src/logic-functions/discord-list-guilds.ts rename to packages/twenty-apps/public/twenty-discord/src/logic-functions/discord-list-guilds.ts diff --git a/packages/twenty-apps/internal/twenty-discord/src/logic-functions/discord-post-message-route.ts b/packages/twenty-apps/public/twenty-discord/src/logic-functions/discord-post-message-route.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-discord/src/logic-functions/discord-post-message-route.ts rename to packages/twenty-apps/public/twenty-discord/src/logic-functions/discord-post-message-route.ts diff --git a/packages/twenty-apps/internal/twenty-discord/src/logic-functions/discord-post-message.ts b/packages/twenty-apps/public/twenty-discord/src/logic-functions/discord-post-message.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-discord/src/logic-functions/discord-post-message.ts rename to packages/twenty-apps/public/twenty-discord/src/logic-functions/discord-post-message.ts diff --git a/packages/twenty-apps/internal/twenty-discord/src/logic-functions/discord-update-message.ts b/packages/twenty-apps/public/twenty-discord/src/logic-functions/discord-update-message.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-discord/src/logic-functions/discord-update-message.ts rename to packages/twenty-apps/public/twenty-discord/src/logic-functions/discord-update-message.ts diff --git a/packages/twenty-apps/internal/twenty-discord/src/logic-functions/handlers/discord-add-reaction-handler.ts b/packages/twenty-apps/public/twenty-discord/src/logic-functions/handlers/discord-add-reaction-handler.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-discord/src/logic-functions/handlers/discord-add-reaction-handler.ts rename to packages/twenty-apps/public/twenty-discord/src/logic-functions/handlers/discord-add-reaction-handler.ts diff --git a/packages/twenty-apps/internal/twenty-discord/src/logic-functions/handlers/discord-delete-message-handler.ts b/packages/twenty-apps/public/twenty-discord/src/logic-functions/handlers/discord-delete-message-handler.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-discord/src/logic-functions/handlers/discord-delete-message-handler.ts rename to packages/twenty-apps/public/twenty-discord/src/logic-functions/handlers/discord-delete-message-handler.ts diff --git a/packages/twenty-apps/internal/twenty-discord/src/logic-functions/handlers/discord-list-channels-handler.ts b/packages/twenty-apps/public/twenty-discord/src/logic-functions/handlers/discord-list-channels-handler.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-discord/src/logic-functions/handlers/discord-list-channels-handler.ts rename to packages/twenty-apps/public/twenty-discord/src/logic-functions/handlers/discord-list-channels-handler.ts diff --git a/packages/twenty-apps/internal/twenty-discord/src/logic-functions/handlers/discord-list-guilds-handler.ts b/packages/twenty-apps/public/twenty-discord/src/logic-functions/handlers/discord-list-guilds-handler.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-discord/src/logic-functions/handlers/discord-list-guilds-handler.ts rename to packages/twenty-apps/public/twenty-discord/src/logic-functions/handlers/discord-list-guilds-handler.ts diff --git a/packages/twenty-apps/internal/twenty-discord/src/logic-functions/handlers/discord-post-message-handler.ts b/packages/twenty-apps/public/twenty-discord/src/logic-functions/handlers/discord-post-message-handler.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-discord/src/logic-functions/handlers/discord-post-message-handler.ts rename to packages/twenty-apps/public/twenty-discord/src/logic-functions/handlers/discord-post-message-handler.ts diff --git a/packages/twenty-apps/internal/twenty-discord/src/logic-functions/handlers/discord-update-message-handler.ts b/packages/twenty-apps/public/twenty-discord/src/logic-functions/handlers/discord-update-message-handler.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-discord/src/logic-functions/handlers/discord-update-message-handler.ts rename to packages/twenty-apps/public/twenty-discord/src/logic-functions/handlers/discord-update-message-handler.ts diff --git a/packages/twenty-apps/internal/twenty-discord/src/logic-functions/schemas/discord-add-reaction-input.schema.ts b/packages/twenty-apps/public/twenty-discord/src/logic-functions/schemas/discord-add-reaction-input.schema.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-discord/src/logic-functions/schemas/discord-add-reaction-input.schema.ts rename to packages/twenty-apps/public/twenty-discord/src/logic-functions/schemas/discord-add-reaction-input.schema.ts diff --git a/packages/twenty-apps/internal/twenty-discord/src/logic-functions/schemas/discord-delete-message-input.schema.ts b/packages/twenty-apps/public/twenty-discord/src/logic-functions/schemas/discord-delete-message-input.schema.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-discord/src/logic-functions/schemas/discord-delete-message-input.schema.ts rename to packages/twenty-apps/public/twenty-discord/src/logic-functions/schemas/discord-delete-message-input.schema.ts diff --git a/packages/twenty-apps/internal/twenty-discord/src/logic-functions/schemas/discord-list-channels-input.schema.ts b/packages/twenty-apps/public/twenty-discord/src/logic-functions/schemas/discord-list-channels-input.schema.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-discord/src/logic-functions/schemas/discord-list-channels-input.schema.ts rename to packages/twenty-apps/public/twenty-discord/src/logic-functions/schemas/discord-list-channels-input.schema.ts diff --git a/packages/twenty-apps/internal/twenty-discord/src/logic-functions/schemas/discord-list-guilds-input.schema.ts b/packages/twenty-apps/public/twenty-discord/src/logic-functions/schemas/discord-list-guilds-input.schema.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-discord/src/logic-functions/schemas/discord-list-guilds-input.schema.ts rename to packages/twenty-apps/public/twenty-discord/src/logic-functions/schemas/discord-list-guilds-input.schema.ts diff --git a/packages/twenty-apps/internal/twenty-discord/src/logic-functions/schemas/discord-post-message-input.schema.ts b/packages/twenty-apps/public/twenty-discord/src/logic-functions/schemas/discord-post-message-input.schema.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-discord/src/logic-functions/schemas/discord-post-message-input.schema.ts rename to packages/twenty-apps/public/twenty-discord/src/logic-functions/schemas/discord-post-message-input.schema.ts diff --git a/packages/twenty-apps/internal/twenty-discord/src/logic-functions/schemas/discord-update-message-input.schema.ts b/packages/twenty-apps/public/twenty-discord/src/logic-functions/schemas/discord-update-message-input.schema.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-discord/src/logic-functions/schemas/discord-update-message-input.schema.ts rename to packages/twenty-apps/public/twenty-discord/src/logic-functions/schemas/discord-update-message-input.schema.ts diff --git a/packages/twenty-apps/internal/twenty-discord/src/logic-functions/types/discord-add-reaction-input.type.ts b/packages/twenty-apps/public/twenty-discord/src/logic-functions/types/discord-add-reaction-input.type.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-discord/src/logic-functions/types/discord-add-reaction-input.type.ts rename to packages/twenty-apps/public/twenty-discord/src/logic-functions/types/discord-add-reaction-input.type.ts diff --git a/packages/twenty-apps/internal/twenty-discord/src/logic-functions/types/discord-delete-message-input.type.ts b/packages/twenty-apps/public/twenty-discord/src/logic-functions/types/discord-delete-message-input.type.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-discord/src/logic-functions/types/discord-delete-message-input.type.ts rename to packages/twenty-apps/public/twenty-discord/src/logic-functions/types/discord-delete-message-input.type.ts diff --git a/packages/twenty-apps/internal/twenty-discord/src/logic-functions/types/discord-list-channels-input.type.ts b/packages/twenty-apps/public/twenty-discord/src/logic-functions/types/discord-list-channels-input.type.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-discord/src/logic-functions/types/discord-list-channels-input.type.ts rename to packages/twenty-apps/public/twenty-discord/src/logic-functions/types/discord-list-channels-input.type.ts diff --git a/packages/twenty-apps/internal/twenty-discord/src/logic-functions/types/discord-list-channels-output.type.ts b/packages/twenty-apps/public/twenty-discord/src/logic-functions/types/discord-list-channels-output.type.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-discord/src/logic-functions/types/discord-list-channels-output.type.ts rename to packages/twenty-apps/public/twenty-discord/src/logic-functions/types/discord-list-channels-output.type.ts diff --git a/packages/twenty-apps/internal/twenty-discord/src/logic-functions/types/discord-list-guilds-output.type.ts b/packages/twenty-apps/public/twenty-discord/src/logic-functions/types/discord-list-guilds-output.type.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-discord/src/logic-functions/types/discord-list-guilds-output.type.ts rename to packages/twenty-apps/public/twenty-discord/src/logic-functions/types/discord-list-guilds-output.type.ts diff --git a/packages/twenty-apps/internal/twenty-discord/src/logic-functions/types/discord-post-message-input.type.ts b/packages/twenty-apps/public/twenty-discord/src/logic-functions/types/discord-post-message-input.type.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-discord/src/logic-functions/types/discord-post-message-input.type.ts rename to packages/twenty-apps/public/twenty-discord/src/logic-functions/types/discord-post-message-input.type.ts diff --git a/packages/twenty-apps/internal/twenty-discord/src/logic-functions/types/discord-tool-result.type.ts b/packages/twenty-apps/public/twenty-discord/src/logic-functions/types/discord-tool-result.type.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-discord/src/logic-functions/types/discord-tool-result.type.ts rename to packages/twenty-apps/public/twenty-discord/src/logic-functions/types/discord-tool-result.type.ts diff --git a/packages/twenty-apps/internal/twenty-discord/src/logic-functions/types/discord-update-message-input.type.ts b/packages/twenty-apps/public/twenty-discord/src/logic-functions/types/discord-update-message-input.type.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-discord/src/logic-functions/types/discord-update-message-input.type.ts rename to packages/twenty-apps/public/twenty-discord/src/logic-functions/types/discord-update-message-input.type.ts diff --git a/packages/twenty-apps/internal/twenty-discord/src/logic-functions/utils/__tests__/build-discord-failure-result.test.ts b/packages/twenty-apps/public/twenty-discord/src/logic-functions/utils/__tests__/build-discord-failure-result.test.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-discord/src/logic-functions/utils/__tests__/build-discord-failure-result.test.ts rename to packages/twenty-apps/public/twenty-discord/src/logic-functions/utils/__tests__/build-discord-failure-result.test.ts diff --git a/packages/twenty-apps/internal/twenty-discord/src/logic-functions/utils/build-discord-failure-result.ts b/packages/twenty-apps/public/twenty-discord/src/logic-functions/utils/build-discord-failure-result.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-discord/src/logic-functions/utils/build-discord-failure-result.ts rename to packages/twenty-apps/public/twenty-discord/src/logic-functions/utils/build-discord-failure-result.ts diff --git a/packages/twenty-apps/internal/twenty-discord/src/logic-functions/utils/discord-api-request.ts b/packages/twenty-apps/public/twenty-discord/src/logic-functions/utils/discord-api-request.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-discord/src/logic-functions/utils/discord-api-request.ts rename to packages/twenty-apps/public/twenty-discord/src/logic-functions/utils/discord-api-request.ts diff --git a/packages/twenty-apps/internal/twenty-discord/src/logic-functions/utils/fetch-discord-guilds.ts b/packages/twenty-apps/public/twenty-discord/src/logic-functions/utils/fetch-discord-guilds.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-discord/src/logic-functions/utils/fetch-discord-guilds.ts rename to packages/twenty-apps/public/twenty-discord/src/logic-functions/utils/fetch-discord-guilds.ts diff --git a/packages/twenty-apps/internal/twenty-discord/src/logic-functions/utils/get-discord-bot-token.ts b/packages/twenty-apps/public/twenty-discord/src/logic-functions/utils/get-discord-bot-token.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-discord/src/logic-functions/utils/get-discord-bot-token.ts rename to packages/twenty-apps/public/twenty-discord/src/logic-functions/utils/get-discord-bot-token.ts diff --git a/packages/twenty-apps/internal/twenty-discord/src/logic-functions/utils/json-schema-to-input-schema.ts b/packages/twenty-apps/public/twenty-discord/src/logic-functions/utils/json-schema-to-input-schema.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-discord/src/logic-functions/utils/json-schema-to-input-schema.ts rename to packages/twenty-apps/public/twenty-discord/src/logic-functions/utils/json-schema-to-input-schema.ts diff --git a/packages/twenty-apps/internal/twenty-discord/src/roles/default-function.role.ts b/packages/twenty-apps/public/twenty-discord/src/roles/default-function.role.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-discord/src/roles/default-function.role.ts rename to packages/twenty-apps/public/twenty-discord/src/roles/default-function.role.ts diff --git a/packages/twenty-apps/internal/twenty-discord/src/utils/is-defined.ts b/packages/twenty-apps/public/twenty-discord/src/utils/is-defined.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-discord/src/utils/is-defined.ts rename to packages/twenty-apps/public/twenty-discord/src/utils/is-defined.ts diff --git a/packages/twenty-apps/internal/twenty-discord/tsconfig.json b/packages/twenty-apps/public/twenty-discord/tsconfig.json similarity index 100% rename from packages/twenty-apps/internal/twenty-discord/tsconfig.json rename to packages/twenty-apps/public/twenty-discord/tsconfig.json diff --git a/packages/twenty-apps/internal/twenty-discord/tsconfig.spec.json b/packages/twenty-apps/public/twenty-discord/tsconfig.spec.json similarity index 100% rename from packages/twenty-apps/internal/twenty-discord/tsconfig.spec.json rename to packages/twenty-apps/public/twenty-discord/tsconfig.spec.json diff --git a/packages/twenty-apps/internal/twenty-discord/vitest.config.ts b/packages/twenty-apps/public/twenty-discord/vitest.config.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-discord/vitest.config.ts rename to packages/twenty-apps/public/twenty-discord/vitest.config.ts diff --git a/packages/twenty-apps/internal/twenty-discord/vitest.unit.config.ts b/packages/twenty-apps/public/twenty-discord/vitest.unit.config.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-discord/vitest.unit.config.ts rename to packages/twenty-apps/public/twenty-discord/vitest.unit.config.ts diff --git a/packages/twenty-apps/internal/twenty-discord/yarn.lock b/packages/twenty-apps/public/twenty-discord/yarn.lock similarity index 100% rename from packages/twenty-apps/internal/twenty-discord/yarn.lock rename to packages/twenty-apps/public/twenty-discord/yarn.lock diff --git a/packages/twenty-apps/internal/twenty-exa/.gitignore b/packages/twenty-apps/public/twenty-exa/.gitignore similarity index 100% rename from packages/twenty-apps/internal/twenty-exa/.gitignore rename to packages/twenty-apps/public/twenty-exa/.gitignore diff --git a/packages/twenty-apps/internal/twenty-exa/.nvmrc b/packages/twenty-apps/public/twenty-exa/.nvmrc similarity index 100% rename from packages/twenty-apps/internal/twenty-exa/.nvmrc rename to packages/twenty-apps/public/twenty-exa/.nvmrc diff --git a/packages/twenty-apps/internal/twenty-exa/.oxlintrc.json b/packages/twenty-apps/public/twenty-exa/.oxlintrc.json similarity index 100% rename from packages/twenty-apps/internal/twenty-exa/.oxlintrc.json rename to packages/twenty-apps/public/twenty-exa/.oxlintrc.json diff --git a/packages/twenty-apps/internal/twenty-exa/.yarnrc.yml b/packages/twenty-apps/public/twenty-exa/.yarnrc.yml similarity index 100% rename from packages/twenty-apps/internal/twenty-exa/.yarnrc.yml rename to packages/twenty-apps/public/twenty-exa/.yarnrc.yml diff --git a/packages/twenty-apps/internal/twenty-exa/README.md b/packages/twenty-apps/public/twenty-exa/README.md similarity index 100% rename from packages/twenty-apps/internal/twenty-exa/README.md rename to packages/twenty-apps/public/twenty-exa/README.md diff --git a/packages/twenty-apps/internal/twenty-exa/package.json b/packages/twenty-apps/public/twenty-exa/package.json similarity index 100% rename from packages/twenty-apps/internal/twenty-exa/package.json rename to packages/twenty-apps/public/twenty-exa/package.json diff --git a/packages/twenty-apps/internal/twenty-exa/public/exa-logo.svg b/packages/twenty-apps/public/twenty-exa/public/exa-logo.svg similarity index 100% rename from packages/twenty-apps/internal/twenty-exa/public/exa-logo.svg rename to packages/twenty-apps/public/twenty-exa/public/exa-logo.svg diff --git a/packages/twenty-apps/internal/twenty-exa/public/exa-logomark.svg b/packages/twenty-apps/public/twenty-exa/public/exa-logomark.svg similarity index 100% rename from packages/twenty-apps/internal/twenty-exa/public/exa-logomark.svg rename to packages/twenty-apps/public/twenty-exa/public/exa-logomark.svg diff --git a/packages/twenty-apps/internal/twenty-exa/src/__tests__/global-setup.ts b/packages/twenty-apps/public/twenty-exa/src/__tests__/global-setup.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-exa/src/__tests__/global-setup.ts rename to packages/twenty-apps/public/twenty-exa/src/__tests__/global-setup.ts diff --git a/packages/twenty-apps/internal/twenty-exa/src/__tests__/twenty-exa.integration-test.ts b/packages/twenty-apps/public/twenty-exa/src/__tests__/twenty-exa.integration-test.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-exa/src/__tests__/twenty-exa.integration-test.ts rename to packages/twenty-apps/public/twenty-exa/src/__tests__/twenty-exa.integration-test.ts diff --git a/packages/twenty-apps/internal/twenty-exa/src/application.config.ts b/packages/twenty-apps/public/twenty-exa/src/application.config.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-exa/src/application.config.ts rename to packages/twenty-apps/public/twenty-exa/src/application.config.ts diff --git a/packages/twenty-apps/internal/twenty-exa/src/logic-functions/__tests__/exa-web-search.test.ts b/packages/twenty-apps/public/twenty-exa/src/logic-functions/__tests__/exa-web-search.test.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-exa/src/logic-functions/__tests__/exa-web-search.test.ts rename to packages/twenty-apps/public/twenty-exa/src/logic-functions/__tests__/exa-web-search.test.ts diff --git a/packages/twenty-apps/internal/twenty-exa/src/logic-functions/constants/default-num-results.constant.ts b/packages/twenty-apps/public/twenty-exa/src/logic-functions/constants/default-num-results.constant.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-exa/src/logic-functions/constants/default-num-results.constant.ts rename to packages/twenty-apps/public/twenty-exa/src/logic-functions/constants/default-num-results.constant.ts diff --git a/packages/twenty-apps/internal/twenty-exa/src/logic-functions/constants/exa-categories.constant.ts b/packages/twenty-apps/public/twenty-exa/src/logic-functions/constants/exa-categories.constant.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-exa/src/logic-functions/constants/exa-categories.constant.ts rename to packages/twenty-apps/public/twenty-exa/src/logic-functions/constants/exa-categories.constant.ts diff --git a/packages/twenty-apps/internal/twenty-exa/src/logic-functions/exa-web-search.ts b/packages/twenty-apps/public/twenty-exa/src/logic-functions/exa-web-search.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-exa/src/logic-functions/exa-web-search.ts rename to packages/twenty-apps/public/twenty-exa/src/logic-functions/exa-web-search.ts diff --git a/packages/twenty-apps/internal/twenty-exa/src/logic-functions/schemas/exa-web-search-input.schema.ts b/packages/twenty-apps/public/twenty-exa/src/logic-functions/schemas/exa-web-search-input.schema.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-exa/src/logic-functions/schemas/exa-web-search-input.schema.ts rename to packages/twenty-apps/public/twenty-exa/src/logic-functions/schemas/exa-web-search-input.schema.ts diff --git a/packages/twenty-apps/internal/twenty-exa/src/logic-functions/types/exa-web-search-input.type.ts b/packages/twenty-apps/public/twenty-exa/src/logic-functions/types/exa-web-search-input.type.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-exa/src/logic-functions/types/exa-web-search-input.type.ts rename to packages/twenty-apps/public/twenty-exa/src/logic-functions/types/exa-web-search-input.type.ts diff --git a/packages/twenty-apps/internal/twenty-exa/src/roles/default-function.role.ts b/packages/twenty-apps/public/twenty-exa/src/roles/default-function.role.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-exa/src/roles/default-function.role.ts rename to packages/twenty-apps/public/twenty-exa/src/roles/default-function.role.ts diff --git a/packages/twenty-apps/internal/twenty-exa/tsconfig.json b/packages/twenty-apps/public/twenty-exa/tsconfig.json similarity index 100% rename from packages/twenty-apps/internal/twenty-exa/tsconfig.json rename to packages/twenty-apps/public/twenty-exa/tsconfig.json diff --git a/packages/twenty-apps/internal/twenty-exa/tsconfig.spec.json b/packages/twenty-apps/public/twenty-exa/tsconfig.spec.json similarity index 100% rename from packages/twenty-apps/internal/twenty-exa/tsconfig.spec.json rename to packages/twenty-apps/public/twenty-exa/tsconfig.spec.json diff --git a/packages/twenty-apps/internal/twenty-exa/vitest.config.ts b/packages/twenty-apps/public/twenty-exa/vitest.config.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-exa/vitest.config.ts rename to packages/twenty-apps/public/twenty-exa/vitest.config.ts diff --git a/packages/twenty-apps/internal/twenty-exa/vitest.unit.config.ts b/packages/twenty-apps/public/twenty-exa/vitest.unit.config.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-exa/vitest.unit.config.ts rename to packages/twenty-apps/public/twenty-exa/vitest.unit.config.ts diff --git a/packages/twenty-apps/internal/twenty-exa/yarn.lock b/packages/twenty-apps/public/twenty-exa/yarn.lock similarity index 100% rename from packages/twenty-apps/internal/twenty-exa/yarn.lock rename to packages/twenty-apps/public/twenty-exa/yarn.lock diff --git a/packages/twenty-apps/internal/twenty-fireflies/.gitignore b/packages/twenty-apps/public/twenty-fireflies/.gitignore similarity index 100% rename from packages/twenty-apps/internal/twenty-fireflies/.gitignore rename to packages/twenty-apps/public/twenty-fireflies/.gitignore diff --git a/packages/twenty-apps/internal/twenty-fireflies/.nvmrc b/packages/twenty-apps/public/twenty-fireflies/.nvmrc similarity index 100% rename from packages/twenty-apps/internal/twenty-fireflies/.nvmrc rename to packages/twenty-apps/public/twenty-fireflies/.nvmrc diff --git a/packages/twenty-apps/internal/twenty-fireflies/.oxlintrc.json b/packages/twenty-apps/public/twenty-fireflies/.oxlintrc.json similarity index 100% rename from packages/twenty-apps/internal/twenty-fireflies/.oxlintrc.json rename to packages/twenty-apps/public/twenty-fireflies/.oxlintrc.json diff --git a/packages/twenty-apps/internal/twenty-fireflies/.yarn/install-state.gz b/packages/twenty-apps/public/twenty-fireflies/.yarn/install-state.gz similarity index 100% rename from packages/twenty-apps/internal/twenty-fireflies/.yarn/install-state.gz rename to packages/twenty-apps/public/twenty-fireflies/.yarn/install-state.gz diff --git a/packages/twenty-apps/internal/twenty-fireflies/.yarnrc.yml b/packages/twenty-apps/public/twenty-fireflies/.yarnrc.yml similarity index 100% rename from packages/twenty-apps/internal/twenty-fireflies/.yarnrc.yml rename to packages/twenty-apps/public/twenty-fireflies/.yarnrc.yml diff --git a/packages/twenty-apps/internal/twenty-fireflies/README.md b/packages/twenty-apps/public/twenty-fireflies/README.md similarity index 100% rename from packages/twenty-apps/internal/twenty-fireflies/README.md rename to packages/twenty-apps/public/twenty-fireflies/README.md diff --git a/packages/twenty-apps/internal/twenty-fireflies/package.json b/packages/twenty-apps/public/twenty-fireflies/package.json similarity index 100% rename from packages/twenty-apps/internal/twenty-fireflies/package.json rename to packages/twenty-apps/public/twenty-fireflies/package.json diff --git a/packages/twenty-apps/internal/twenty-fireflies/public/gallery/app-settings.png b/packages/twenty-apps/public/twenty-fireflies/public/gallery/app-settings.png similarity index 100% rename from packages/twenty-apps/internal/twenty-fireflies/public/gallery/app-settings.png rename to packages/twenty-apps/public/twenty-fireflies/public/gallery/app-settings.png diff --git a/packages/twenty-apps/internal/twenty-fireflies/public/gallery/summary-on-calendar-event.png b/packages/twenty-apps/public/twenty-fireflies/public/gallery/summary-on-calendar-event.png similarity index 100% rename from packages/twenty-apps/internal/twenty-fireflies/public/gallery/summary-on-calendar-event.png rename to packages/twenty-apps/public/twenty-fireflies/public/gallery/summary-on-calendar-event.png diff --git a/packages/twenty-apps/internal/twenty-fireflies/public/gallery/transcript-on-calendar-event.png b/packages/twenty-apps/public/twenty-fireflies/public/gallery/transcript-on-calendar-event.png similarity index 100% rename from packages/twenty-apps/internal/twenty-fireflies/public/gallery/transcript-on-calendar-event.png rename to packages/twenty-apps/public/twenty-fireflies/public/gallery/transcript-on-calendar-event.png diff --git a/packages/twenty-apps/internal/twenty-fireflies/public/gallery/workflow-builder-actions.png b/packages/twenty-apps/public/twenty-fireflies/public/gallery/workflow-builder-actions.png similarity index 100% rename from packages/twenty-apps/internal/twenty-fireflies/public/gallery/workflow-builder-actions.png rename to packages/twenty-apps/public/twenty-fireflies/public/gallery/workflow-builder-actions.png diff --git a/packages/twenty-apps/internal/twenty-fireflies/public/twenty-fireflies.svg b/packages/twenty-apps/public/twenty-fireflies/public/twenty-fireflies.svg similarity index 100% rename from packages/twenty-apps/internal/twenty-fireflies/public/twenty-fireflies.svg rename to packages/twenty-apps/public/twenty-fireflies/public/twenty-fireflies.svg diff --git a/packages/twenty-apps/internal/twenty-fireflies/src/__tests__/global-setup.ts b/packages/twenty-apps/public/twenty-fireflies/src/__tests__/global-setup.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-fireflies/src/__tests__/global-setup.ts rename to packages/twenty-apps/public/twenty-fireflies/src/__tests__/global-setup.ts diff --git a/packages/twenty-apps/internal/twenty-fireflies/src/__tests__/twenty-fireflies.integration-test.ts b/packages/twenty-apps/public/twenty-fireflies/src/__tests__/twenty-fireflies.integration-test.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-fireflies/src/__tests__/twenty-fireflies.integration-test.ts rename to packages/twenty-apps/public/twenty-fireflies/src/__tests__/twenty-fireflies.integration-test.ts diff --git a/packages/twenty-apps/internal/twenty-fireflies/src/application.config.ts b/packages/twenty-apps/public/twenty-fireflies/src/application.config.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-fireflies/src/application.config.ts rename to packages/twenty-apps/public/twenty-fireflies/src/application.config.ts diff --git a/packages/twenty-apps/internal/twenty-fireflies/src/constants/universal-identifiers.ts b/packages/twenty-apps/public/twenty-fireflies/src/constants/universal-identifiers.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-fireflies/src/constants/universal-identifiers.ts rename to packages/twenty-apps/public/twenty-fireflies/src/constants/universal-identifiers.ts diff --git a/packages/twenty-apps/internal/twenty-fireflies/src/logic-functions/fireflies-list-calls-by-participant.ts b/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/fireflies-list-calls-by-participant.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-fireflies/src/logic-functions/fireflies-list-calls-by-participant.ts rename to packages/twenty-apps/public/twenty-fireflies/src/logic-functions/fireflies-list-calls-by-participant.ts diff --git a/packages/twenty-apps/internal/twenty-fireflies/src/logic-functions/fireflies-search-calls.ts b/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/fireflies-search-calls.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-fireflies/src/logic-functions/fireflies-search-calls.ts rename to packages/twenty-apps/public/twenty-fireflies/src/logic-functions/fireflies-search-calls.ts diff --git a/packages/twenty-apps/internal/twenty-fireflies/src/logic-functions/fireflies-sync-call.ts b/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/fireflies-sync-call.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-fireflies/src/logic-functions/fireflies-sync-call.ts rename to packages/twenty-apps/public/twenty-fireflies/src/logic-functions/fireflies-sync-call.ts diff --git a/packages/twenty-apps/internal/twenty-fireflies/src/logic-functions/fireflies-webhook.ts b/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/fireflies-webhook.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-fireflies/src/logic-functions/fireflies-webhook.ts rename to packages/twenty-apps/public/twenty-fireflies/src/logic-functions/fireflies-webhook.ts diff --git a/packages/twenty-apps/internal/twenty-fireflies/src/logic-functions/handlers/fireflies-list-calls-by-participant-handler.ts b/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/handlers/fireflies-list-calls-by-participant-handler.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-fireflies/src/logic-functions/handlers/fireflies-list-calls-by-participant-handler.ts rename to packages/twenty-apps/public/twenty-fireflies/src/logic-functions/handlers/fireflies-list-calls-by-participant-handler.ts diff --git a/packages/twenty-apps/internal/twenty-fireflies/src/logic-functions/handlers/fireflies-search-calls-handler.ts b/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/handlers/fireflies-search-calls-handler.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-fireflies/src/logic-functions/handlers/fireflies-search-calls-handler.ts rename to packages/twenty-apps/public/twenty-fireflies/src/logic-functions/handlers/fireflies-search-calls-handler.ts diff --git a/packages/twenty-apps/internal/twenty-fireflies/src/logic-functions/handlers/fireflies-sync-call-handler.ts b/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/handlers/fireflies-sync-call-handler.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-fireflies/src/logic-functions/handlers/fireflies-sync-call-handler.ts rename to packages/twenty-apps/public/twenty-fireflies/src/logic-functions/handlers/fireflies-sync-call-handler.ts diff --git a/packages/twenty-apps/internal/twenty-fireflies/src/logic-functions/handlers/fireflies-webhook-handler.ts b/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/handlers/fireflies-webhook-handler.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-fireflies/src/logic-functions/handlers/fireflies-webhook-handler.ts rename to packages/twenty-apps/public/twenty-fireflies/src/logic-functions/handlers/fireflies-webhook-handler.ts diff --git a/packages/twenty-apps/internal/twenty-fireflies/src/logic-functions/schemas/fireflies-list-calls-by-participant-input.schema.ts b/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/schemas/fireflies-list-calls-by-participant-input.schema.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-fireflies/src/logic-functions/schemas/fireflies-list-calls-by-participant-input.schema.ts rename to packages/twenty-apps/public/twenty-fireflies/src/logic-functions/schemas/fireflies-list-calls-by-participant-input.schema.ts diff --git a/packages/twenty-apps/internal/twenty-fireflies/src/logic-functions/schemas/fireflies-search-calls-input.schema.ts b/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/schemas/fireflies-search-calls-input.schema.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-fireflies/src/logic-functions/schemas/fireflies-search-calls-input.schema.ts rename to packages/twenty-apps/public/twenty-fireflies/src/logic-functions/schemas/fireflies-search-calls-input.schema.ts diff --git a/packages/twenty-apps/internal/twenty-fireflies/src/logic-functions/schemas/fireflies-sync-call-input.schema.ts b/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/schemas/fireflies-sync-call-input.schema.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-fireflies/src/logic-functions/schemas/fireflies-sync-call-input.schema.ts rename to packages/twenty-apps/public/twenty-fireflies/src/logic-functions/schemas/fireflies-sync-call-input.schema.ts diff --git a/packages/twenty-apps/internal/twenty-fireflies/src/logic-functions/types/fireflies-call-list-result.type.ts b/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/types/fireflies-call-list-result.type.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-fireflies/src/logic-functions/types/fireflies-call-list-result.type.ts rename to packages/twenty-apps/public/twenty-fireflies/src/logic-functions/types/fireflies-call-list-result.type.ts diff --git a/packages/twenty-apps/internal/twenty-fireflies/src/logic-functions/types/fireflies-list-calls-by-participant-input.type.ts b/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/types/fireflies-list-calls-by-participant-input.type.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-fireflies/src/logic-functions/types/fireflies-list-calls-by-participant-input.type.ts rename to packages/twenty-apps/public/twenty-fireflies/src/logic-functions/types/fireflies-list-calls-by-participant-input.type.ts diff --git a/packages/twenty-apps/internal/twenty-fireflies/src/logic-functions/types/fireflies-search-calls-input.type.ts b/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/types/fireflies-search-calls-input.type.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-fireflies/src/logic-functions/types/fireflies-search-calls-input.type.ts rename to packages/twenty-apps/public/twenty-fireflies/src/logic-functions/types/fireflies-search-calls-input.type.ts diff --git a/packages/twenty-apps/internal/twenty-fireflies/src/logic-functions/types/fireflies-sync-call-input.type.ts b/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/types/fireflies-sync-call-input.type.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-fireflies/src/logic-functions/types/fireflies-sync-call-input.type.ts rename to packages/twenty-apps/public/twenty-fireflies/src/logic-functions/types/fireflies-sync-call-input.type.ts diff --git a/packages/twenty-apps/internal/twenty-fireflies/src/logic-functions/types/fireflies-sync-call-result.type.ts b/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/types/fireflies-sync-call-result.type.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-fireflies/src/logic-functions/types/fireflies-sync-call-result.type.ts rename to packages/twenty-apps/public/twenty-fireflies/src/logic-functions/types/fireflies-sync-call-result.type.ts diff --git a/packages/twenty-apps/internal/twenty-fireflies/src/logic-functions/types/fireflies-transcript.type.ts b/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/types/fireflies-transcript.type.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-fireflies/src/logic-functions/types/fireflies-transcript.type.ts rename to packages/twenty-apps/public/twenty-fireflies/src/logic-functions/types/fireflies-transcript.type.ts diff --git a/packages/twenty-apps/internal/twenty-fireflies/src/logic-functions/types/fireflies-webhook-payload.type.ts b/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/types/fireflies-webhook-payload.type.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-fireflies/src/logic-functions/types/fireflies-webhook-payload.type.ts rename to packages/twenty-apps/public/twenty-fireflies/src/logic-functions/types/fireflies-webhook-payload.type.ts diff --git a/packages/twenty-apps/internal/twenty-fireflies/src/logic-functions/utils/__tests__/format-summary-as-markdown.test.ts b/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/__tests__/format-summary-as-markdown.test.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-fireflies/src/logic-functions/utils/__tests__/format-summary-as-markdown.test.ts rename to packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/__tests__/format-summary-as-markdown.test.ts diff --git a/packages/twenty-apps/internal/twenty-fireflies/src/logic-functions/utils/__tests__/format-transcript-as-markdown.test.ts b/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/__tests__/format-transcript-as-markdown.test.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-fireflies/src/logic-functions/utils/__tests__/format-transcript-as-markdown.test.ts rename to packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/__tests__/format-transcript-as-markdown.test.ts diff --git a/packages/twenty-apps/internal/twenty-fireflies/src/logic-functions/utils/__tests__/verify-fireflies-webhook-signature.test.ts b/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/__tests__/verify-fireflies-webhook-signature.test.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-fireflies/src/logic-functions/utils/__tests__/verify-fireflies-webhook-signature.test.ts rename to packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/__tests__/verify-fireflies-webhook-signature.test.ts diff --git a/packages/twenty-apps/internal/twenty-fireflies/src/logic-functions/utils/fetch-fireflies-summary.ts b/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/fetch-fireflies-summary.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-fireflies/src/logic-functions/utils/fetch-fireflies-summary.ts rename to packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/fetch-fireflies-summary.ts diff --git a/packages/twenty-apps/internal/twenty-fireflies/src/logic-functions/utils/fetch-fireflies-transcript.ts b/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/fetch-fireflies-transcript.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-fireflies/src/logic-functions/utils/fetch-fireflies-transcript.ts rename to packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/fetch-fireflies-transcript.ts diff --git a/packages/twenty-apps/internal/twenty-fireflies/src/logic-functions/utils/find-matching-calendar-event.ts b/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/find-matching-calendar-event.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-fireflies/src/logic-functions/utils/find-matching-calendar-event.ts rename to packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/find-matching-calendar-event.ts diff --git a/packages/twenty-apps/internal/twenty-fireflies/src/logic-functions/utils/fireflies-api-request.ts b/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/fireflies-api-request.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-fireflies/src/logic-functions/utils/fireflies-api-request.ts rename to packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/fireflies-api-request.ts diff --git a/packages/twenty-apps/internal/twenty-fireflies/src/logic-functions/utils/format-summary-as-markdown.ts b/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/format-summary-as-markdown.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-fireflies/src/logic-functions/utils/format-summary-as-markdown.ts rename to packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/format-summary-as-markdown.ts diff --git a/packages/twenty-apps/internal/twenty-fireflies/src/logic-functions/utils/format-transcript-as-markdown.ts b/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/format-transcript-as-markdown.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-fireflies/src/logic-functions/utils/format-transcript-as-markdown.ts rename to packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/format-transcript-as-markdown.ts diff --git a/packages/twenty-apps/internal/twenty-fireflies/src/logic-functions/utils/get-fireflies-api-key.ts b/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/get-fireflies-api-key.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-fireflies/src/logic-functions/utils/get-fireflies-api-key.ts rename to packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/get-fireflies-api-key.ts diff --git a/packages/twenty-apps/internal/twenty-fireflies/src/logic-functions/utils/get-fireflies-webhook-secret.ts b/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/get-fireflies-webhook-secret.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-fireflies/src/logic-functions/utils/get-fireflies-webhook-secret.ts rename to packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/get-fireflies-webhook-secret.ts diff --git a/packages/twenty-apps/internal/twenty-fireflies/src/logic-functions/utils/json-schema-to-input-schema.ts b/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/json-schema-to-input-schema.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-fireflies/src/logic-functions/utils/json-schema-to-input-schema.ts rename to packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/json-schema-to-input-schema.ts diff --git a/packages/twenty-apps/internal/twenty-fireflies/src/logic-functions/utils/list-fireflies-transcripts.ts b/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/list-fireflies-transcripts.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-fireflies/src/logic-functions/utils/list-fireflies-transcripts.ts rename to packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/list-fireflies-transcripts.ts diff --git a/packages/twenty-apps/internal/twenty-fireflies/src/logic-functions/utils/sync-fireflies-field-to-calendar-event.ts b/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/sync-fireflies-field-to-calendar-event.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-fireflies/src/logic-functions/utils/sync-fireflies-field-to-calendar-event.ts rename to packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/sync-fireflies-field-to-calendar-event.ts diff --git a/packages/twenty-apps/internal/twenty-fireflies/src/logic-functions/utils/update-calendar-event-summary.ts b/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/update-calendar-event-summary.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-fireflies/src/logic-functions/utils/update-calendar-event-summary.ts rename to packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/update-calendar-event-summary.ts diff --git a/packages/twenty-apps/internal/twenty-fireflies/src/logic-functions/utils/update-calendar-event-transcript.ts b/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/update-calendar-event-transcript.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-fireflies/src/logic-functions/utils/update-calendar-event-transcript.ts rename to packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/update-calendar-event-transcript.ts diff --git a/packages/twenty-apps/internal/twenty-fireflies/src/logic-functions/utils/verify-fireflies-webhook-signature.ts b/packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/verify-fireflies-webhook-signature.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-fireflies/src/logic-functions/utils/verify-fireflies-webhook-signature.ts rename to packages/twenty-apps/public/twenty-fireflies/src/logic-functions/utils/verify-fireflies-webhook-signature.ts diff --git a/packages/twenty-apps/internal/twenty-fireflies/src/roles/default-function.role.ts b/packages/twenty-apps/public/twenty-fireflies/src/roles/default-function.role.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-fireflies/src/roles/default-function.role.ts rename to packages/twenty-apps/public/twenty-fireflies/src/roles/default-function.role.ts diff --git a/packages/twenty-apps/internal/twenty-fireflies/src/schema/fields/summary-on-calendar-event.field.ts b/packages/twenty-apps/public/twenty-fireflies/src/schema/fields/summary-on-calendar-event.field.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-fireflies/src/schema/fields/summary-on-calendar-event.field.ts rename to packages/twenty-apps/public/twenty-fireflies/src/schema/fields/summary-on-calendar-event.field.ts diff --git a/packages/twenty-apps/internal/twenty-fireflies/src/schema/fields/transcript-on-calendar-event.field.ts b/packages/twenty-apps/public/twenty-fireflies/src/schema/fields/transcript-on-calendar-event.field.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-fireflies/src/schema/fields/transcript-on-calendar-event.field.ts rename to packages/twenty-apps/public/twenty-fireflies/src/schema/fields/transcript-on-calendar-event.field.ts diff --git a/packages/twenty-apps/internal/twenty-fireflies/src/utils/is-defined.ts b/packages/twenty-apps/public/twenty-fireflies/src/utils/is-defined.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-fireflies/src/utils/is-defined.ts rename to packages/twenty-apps/public/twenty-fireflies/src/utils/is-defined.ts diff --git a/packages/twenty-apps/internal/twenty-fireflies/tsconfig.json b/packages/twenty-apps/public/twenty-fireflies/tsconfig.json similarity index 100% rename from packages/twenty-apps/internal/twenty-fireflies/tsconfig.json rename to packages/twenty-apps/public/twenty-fireflies/tsconfig.json diff --git a/packages/twenty-apps/internal/twenty-fireflies/tsconfig.spec.json b/packages/twenty-apps/public/twenty-fireflies/tsconfig.spec.json similarity index 100% rename from packages/twenty-apps/internal/twenty-fireflies/tsconfig.spec.json rename to packages/twenty-apps/public/twenty-fireflies/tsconfig.spec.json diff --git a/packages/twenty-apps/internal/twenty-fireflies/vitest.config.ts b/packages/twenty-apps/public/twenty-fireflies/vitest.config.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-fireflies/vitest.config.ts rename to packages/twenty-apps/public/twenty-fireflies/vitest.config.ts diff --git a/packages/twenty-apps/internal/twenty-fireflies/vitest.unit.config.ts b/packages/twenty-apps/public/twenty-fireflies/vitest.unit.config.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-fireflies/vitest.unit.config.ts rename to packages/twenty-apps/public/twenty-fireflies/vitest.unit.config.ts diff --git a/packages/twenty-apps/internal/twenty-fireflies/yarn.lock b/packages/twenty-apps/public/twenty-fireflies/yarn.lock similarity index 100% rename from packages/twenty-apps/internal/twenty-fireflies/yarn.lock rename to packages/twenty-apps/public/twenty-fireflies/yarn.lock diff --git a/packages/twenty-apps/internal/twenty-last-contact/.gitignore b/packages/twenty-apps/public/twenty-last-contact/.gitignore similarity index 100% rename from packages/twenty-apps/internal/twenty-last-contact/.gitignore rename to packages/twenty-apps/public/twenty-last-contact/.gitignore diff --git a/packages/twenty-apps/internal/twenty-last-contact/.nvmrc b/packages/twenty-apps/public/twenty-last-contact/.nvmrc similarity index 100% rename from packages/twenty-apps/internal/twenty-last-contact/.nvmrc rename to packages/twenty-apps/public/twenty-last-contact/.nvmrc diff --git a/packages/twenty-apps/internal/twenty-last-contact/.oxlintrc.json b/packages/twenty-apps/public/twenty-last-contact/.oxlintrc.json similarity index 100% rename from packages/twenty-apps/internal/twenty-last-contact/.oxlintrc.json rename to packages/twenty-apps/public/twenty-last-contact/.oxlintrc.json diff --git a/packages/twenty-apps/internal/twenty-last-contact/.yarnrc.yml b/packages/twenty-apps/public/twenty-last-contact/.yarnrc.yml similarity index 100% rename from packages/twenty-apps/internal/twenty-last-contact/.yarnrc.yml rename to packages/twenty-apps/public/twenty-last-contact/.yarnrc.yml diff --git a/packages/twenty-apps/internal/twenty-last-contact/AGENTS.md b/packages/twenty-apps/public/twenty-last-contact/AGENTS.md similarity index 100% rename from packages/twenty-apps/internal/twenty-last-contact/AGENTS.md rename to packages/twenty-apps/public/twenty-last-contact/AGENTS.md diff --git a/packages/twenty-apps/internal/twenty-last-contact/CLAUDE.md b/packages/twenty-apps/public/twenty-last-contact/CLAUDE.md similarity index 100% rename from packages/twenty-apps/internal/twenty-last-contact/CLAUDE.md rename to packages/twenty-apps/public/twenty-last-contact/CLAUDE.md diff --git a/packages/twenty-apps/internal/twenty-last-contact/README.md b/packages/twenty-apps/public/twenty-last-contact/README.md similarity index 100% rename from packages/twenty-apps/internal/twenty-last-contact/README.md rename to packages/twenty-apps/public/twenty-last-contact/README.md diff --git a/packages/twenty-apps/internal/twenty-last-contact/package.json b/packages/twenty-apps/public/twenty-last-contact/package.json similarity index 100% rename from packages/twenty-apps/internal/twenty-last-contact/package.json rename to packages/twenty-apps/public/twenty-last-contact/package.json diff --git a/packages/twenty-apps/internal/twenty-last-contact/public/gallery/cover.png b/packages/twenty-apps/public/twenty-last-contact/public/gallery/cover.png similarity index 100% rename from packages/twenty-apps/internal/twenty-last-contact/public/gallery/cover.png rename to packages/twenty-apps/public/twenty-last-contact/public/gallery/cover.png diff --git a/packages/twenty-apps/internal/twenty-last-contact/public/logo.png b/packages/twenty-apps/public/twenty-last-contact/public/logo.png similarity index 100% rename from packages/twenty-apps/internal/twenty-last-contact/public/logo.png rename to packages/twenty-apps/public/twenty-last-contact/public/logo.png diff --git a/packages/twenty-apps/internal/twenty-last-contact/src/__tests__/global-setup.ts b/packages/twenty-apps/public/twenty-last-contact/src/__tests__/global-setup.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-last-contact/src/__tests__/global-setup.ts rename to packages/twenty-apps/public/twenty-last-contact/src/__tests__/global-setup.ts diff --git a/packages/twenty-apps/internal/twenty-last-contact/src/__tests__/last-contact.integration-test.ts b/packages/twenty-apps/public/twenty-last-contact/src/__tests__/last-contact.integration-test.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-last-contact/src/__tests__/last-contact.integration-test.ts rename to packages/twenty-apps/public/twenty-last-contact/src/__tests__/last-contact.integration-test.ts diff --git a/packages/twenty-apps/internal/twenty-last-contact/src/application-config.ts b/packages/twenty-apps/public/twenty-last-contact/src/application-config.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-last-contact/src/application-config.ts rename to packages/twenty-apps/public/twenty-last-contact/src/application-config.ts diff --git a/packages/twenty-apps/internal/twenty-last-contact/src/constants/universal-identifiers.ts b/packages/twenty-apps/public/twenty-last-contact/src/constants/universal-identifiers.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-last-contact/src/constants/universal-identifiers.ts rename to packages/twenty-apps/public/twenty-last-contact/src/constants/universal-identifiers.ts diff --git a/packages/twenty-apps/internal/twenty-last-contact/src/default-role.ts b/packages/twenty-apps/public/twenty-last-contact/src/default-role.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-last-contact/src/default-role.ts rename to packages/twenty-apps/public/twenty-last-contact/src/default-role.ts diff --git a/packages/twenty-apps/internal/twenty-last-contact/src/fields/last-contact-at.field.ts b/packages/twenty-apps/public/twenty-last-contact/src/fields/last-contact-at.field.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-last-contact/src/fields/last-contact-at.field.ts rename to packages/twenty-apps/public/twenty-last-contact/src/fields/last-contact-at.field.ts diff --git a/packages/twenty-apps/internal/twenty-last-contact/src/logic-functions/__tests__/on-calendar-event-started.test.ts b/packages/twenty-apps/public/twenty-last-contact/src/logic-functions/__tests__/on-calendar-event-started.test.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-last-contact/src/logic-functions/__tests__/on-calendar-event-started.test.ts rename to packages/twenty-apps/public/twenty-last-contact/src/logic-functions/__tests__/on-calendar-event-started.test.ts diff --git a/packages/twenty-apps/internal/twenty-last-contact/src/logic-functions/__tests__/on-calendar-interaction.test.ts b/packages/twenty-apps/public/twenty-last-contact/src/logic-functions/__tests__/on-calendar-interaction.test.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-last-contact/src/logic-functions/__tests__/on-calendar-interaction.test.ts rename to packages/twenty-apps/public/twenty-last-contact/src/logic-functions/__tests__/on-calendar-interaction.test.ts diff --git a/packages/twenty-apps/internal/twenty-last-contact/src/logic-functions/__tests__/on-email-interaction.test.ts b/packages/twenty-apps/public/twenty-last-contact/src/logic-functions/__tests__/on-email-interaction.test.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-last-contact/src/logic-functions/__tests__/on-email-interaction.test.ts rename to packages/twenty-apps/public/twenty-last-contact/src/logic-functions/__tests__/on-email-interaction.test.ts diff --git a/packages/twenty-apps/internal/twenty-last-contact/src/logic-functions/backfill-last-contact-at.ts b/packages/twenty-apps/public/twenty-last-contact/src/logic-functions/backfill-last-contact-at.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-last-contact/src/logic-functions/backfill-last-contact-at.ts rename to packages/twenty-apps/public/twenty-last-contact/src/logic-functions/backfill-last-contact-at.ts diff --git a/packages/twenty-apps/internal/twenty-last-contact/src/logic-functions/on-calendar-event-started.ts b/packages/twenty-apps/public/twenty-last-contact/src/logic-functions/on-calendar-event-started.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-last-contact/src/logic-functions/on-calendar-event-started.ts rename to packages/twenty-apps/public/twenty-last-contact/src/logic-functions/on-calendar-event-started.ts diff --git a/packages/twenty-apps/internal/twenty-last-contact/src/logic-functions/on-calendar-interaction.ts b/packages/twenty-apps/public/twenty-last-contact/src/logic-functions/on-calendar-interaction.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-last-contact/src/logic-functions/on-calendar-interaction.ts rename to packages/twenty-apps/public/twenty-last-contact/src/logic-functions/on-calendar-interaction.ts diff --git a/packages/twenty-apps/internal/twenty-last-contact/src/logic-functions/on-email-interaction.ts b/packages/twenty-apps/public/twenty-last-contact/src/logic-functions/on-email-interaction.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-last-contact/src/logic-functions/on-email-interaction.ts rename to packages/twenty-apps/public/twenty-last-contact/src/logic-functions/on-email-interaction.ts diff --git a/packages/twenty-apps/internal/twenty-last-contact/src/utils/__tests__/update-person-last-contact-at-from-calendar.test.ts b/packages/twenty-apps/public/twenty-last-contact/src/utils/__tests__/update-person-last-contact-at-from-calendar.test.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-last-contact/src/utils/__tests__/update-person-last-contact-at-from-calendar.test.ts rename to packages/twenty-apps/public/twenty-last-contact/src/utils/__tests__/update-person-last-contact-at-from-calendar.test.ts diff --git a/packages/twenty-apps/internal/twenty-last-contact/src/utils/__tests__/update-person-last-contact-at.test.ts b/packages/twenty-apps/public/twenty-last-contact/src/utils/__tests__/update-person-last-contact-at.test.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-last-contact/src/utils/__tests__/update-person-last-contact-at.test.ts rename to packages/twenty-apps/public/twenty-last-contact/src/utils/__tests__/update-person-last-contact-at.test.ts diff --git a/packages/twenty-apps/internal/twenty-last-contact/src/utils/update-person-last-contact-at-from-calendar.ts b/packages/twenty-apps/public/twenty-last-contact/src/utils/update-person-last-contact-at-from-calendar.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-last-contact/src/utils/update-person-last-contact-at-from-calendar.ts rename to packages/twenty-apps/public/twenty-last-contact/src/utils/update-person-last-contact-at-from-calendar.ts diff --git a/packages/twenty-apps/internal/twenty-last-contact/src/utils/update-person-last-contact-at.ts b/packages/twenty-apps/public/twenty-last-contact/src/utils/update-person-last-contact-at.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-last-contact/src/utils/update-person-last-contact-at.ts rename to packages/twenty-apps/public/twenty-last-contact/src/utils/update-person-last-contact-at.ts diff --git a/packages/twenty-apps/internal/twenty-last-contact/src/view-fields/last-contact-at.view-field.ts b/packages/twenty-apps/public/twenty-last-contact/src/view-fields/last-contact-at.view-field.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-last-contact/src/view-fields/last-contact-at.view-field.ts rename to packages/twenty-apps/public/twenty-last-contact/src/view-fields/last-contact-at.view-field.ts diff --git a/packages/twenty-apps/internal/twenty-last-contact/tsconfig.json b/packages/twenty-apps/public/twenty-last-contact/tsconfig.json similarity index 100% rename from packages/twenty-apps/internal/twenty-last-contact/tsconfig.json rename to packages/twenty-apps/public/twenty-last-contact/tsconfig.json diff --git a/packages/twenty-apps/internal/twenty-last-contact/tsconfig.spec.json b/packages/twenty-apps/public/twenty-last-contact/tsconfig.spec.json similarity index 100% rename from packages/twenty-apps/internal/twenty-last-contact/tsconfig.spec.json rename to packages/twenty-apps/public/twenty-last-contact/tsconfig.spec.json diff --git a/packages/twenty-apps/internal/twenty-last-contact/vitest.config.ts b/packages/twenty-apps/public/twenty-last-contact/vitest.config.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-last-contact/vitest.config.ts rename to packages/twenty-apps/public/twenty-last-contact/vitest.config.ts diff --git a/packages/twenty-apps/internal/twenty-last-contact/vitest.unit.config.ts b/packages/twenty-apps/public/twenty-last-contact/vitest.unit.config.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-last-contact/vitest.unit.config.ts rename to packages/twenty-apps/public/twenty-last-contact/vitest.unit.config.ts diff --git a/packages/twenty-apps/internal/twenty-last-contact/yarn.lock b/packages/twenty-apps/public/twenty-last-contact/yarn.lock similarity index 100% rename from packages/twenty-apps/internal/twenty-last-contact/yarn.lock rename to packages/twenty-apps/public/twenty-last-contact/yarn.lock diff --git a/packages/twenty-apps/internal/twenty-linear/.gitignore b/packages/twenty-apps/public/twenty-linear/.gitignore similarity index 100% rename from packages/twenty-apps/internal/twenty-linear/.gitignore rename to packages/twenty-apps/public/twenty-linear/.gitignore diff --git a/packages/twenty-apps/internal/twenty-linear/.nvmrc b/packages/twenty-apps/public/twenty-linear/.nvmrc similarity index 100% rename from packages/twenty-apps/internal/twenty-linear/.nvmrc rename to packages/twenty-apps/public/twenty-linear/.nvmrc diff --git a/packages/twenty-apps/internal/twenty-linear/.oxlintrc.json b/packages/twenty-apps/public/twenty-linear/.oxlintrc.json similarity index 100% rename from packages/twenty-apps/internal/twenty-linear/.oxlintrc.json rename to packages/twenty-apps/public/twenty-linear/.oxlintrc.json diff --git a/packages/twenty-apps/internal/twenty-linear/.yarn/install-state.gz b/packages/twenty-apps/public/twenty-linear/.yarn/install-state.gz similarity index 100% rename from packages/twenty-apps/internal/twenty-linear/.yarn/install-state.gz rename to packages/twenty-apps/public/twenty-linear/.yarn/install-state.gz diff --git a/packages/twenty-apps/internal/twenty-linear/.yarnrc.yml b/packages/twenty-apps/public/twenty-linear/.yarnrc.yml similarity index 100% rename from packages/twenty-apps/internal/twenty-linear/.yarnrc.yml rename to packages/twenty-apps/public/twenty-linear/.yarnrc.yml diff --git a/packages/twenty-apps/internal/twenty-linear/AGENT.md b/packages/twenty-apps/public/twenty-linear/AGENT.md similarity index 100% rename from packages/twenty-apps/internal/twenty-linear/AGENT.md rename to packages/twenty-apps/public/twenty-linear/AGENT.md diff --git a/packages/twenty-apps/internal/twenty-linear/CLAUDE.md b/packages/twenty-apps/public/twenty-linear/CLAUDE.md similarity index 100% rename from packages/twenty-apps/internal/twenty-linear/CLAUDE.md rename to packages/twenty-apps/public/twenty-linear/CLAUDE.md diff --git a/packages/twenty-apps/internal/twenty-linear/LLMS.md b/packages/twenty-apps/public/twenty-linear/LLMS.md similarity index 100% rename from packages/twenty-apps/internal/twenty-linear/LLMS.md rename to packages/twenty-apps/public/twenty-linear/LLMS.md diff --git a/packages/twenty-apps/internal/twenty-linear/README.md b/packages/twenty-apps/public/twenty-linear/README.md similarity index 100% rename from packages/twenty-apps/internal/twenty-linear/README.md rename to packages/twenty-apps/public/twenty-linear/README.md diff --git a/packages/twenty-apps/internal/twenty-linear/package.json b/packages/twenty-apps/public/twenty-linear/package.json similarity index 100% rename from packages/twenty-apps/internal/twenty-linear/package.json rename to packages/twenty-apps/public/twenty-linear/package.json diff --git a/packages/twenty-apps/internal/twenty-linear/public/gallery/command-menu-item-1.png b/packages/twenty-apps/public/twenty-linear/public/gallery/command-menu-item-1.png similarity index 100% rename from packages/twenty-apps/internal/twenty-linear/public/gallery/command-menu-item-1.png rename to packages/twenty-apps/public/twenty-linear/public/gallery/command-menu-item-1.png diff --git a/packages/twenty-apps/internal/twenty-linear/public/gallery/command-menu-item-2.png b/packages/twenty-apps/public/twenty-linear/public/gallery/command-menu-item-2.png similarity index 100% rename from packages/twenty-apps/internal/twenty-linear/public/gallery/command-menu-item-2.png rename to packages/twenty-apps/public/twenty-linear/public/gallery/command-menu-item-2.png diff --git a/packages/twenty-apps/internal/twenty-linear/public/gallery/command-menu-item-3.png b/packages/twenty-apps/public/twenty-linear/public/gallery/command-menu-item-3.png similarity index 100% rename from packages/twenty-apps/internal/twenty-linear/public/gallery/command-menu-item-3.png rename to packages/twenty-apps/public/twenty-linear/public/gallery/command-menu-item-3.png diff --git a/packages/twenty-apps/internal/twenty-linear/public/gallery/command-menu-item-4.png b/packages/twenty-apps/public/twenty-linear/public/gallery/command-menu-item-4.png similarity index 100% rename from packages/twenty-apps/internal/twenty-linear/public/gallery/command-menu-item-4.png rename to packages/twenty-apps/public/twenty-linear/public/gallery/command-menu-item-4.png diff --git a/packages/twenty-apps/internal/twenty-linear/public/linear-logomark.svg b/packages/twenty-apps/public/twenty-linear/public/linear-logomark.svg similarity index 100% rename from packages/twenty-apps/internal/twenty-linear/public/linear-logomark.svg rename to packages/twenty-apps/public/twenty-linear/public/linear-logomark.svg diff --git a/packages/twenty-apps/internal/twenty-linear/src/__tests__/global-setup.ts b/packages/twenty-apps/public/twenty-linear/src/__tests__/global-setup.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-linear/src/__tests__/global-setup.ts rename to packages/twenty-apps/public/twenty-linear/src/__tests__/global-setup.ts diff --git a/packages/twenty-apps/internal/twenty-linear/src/__tests__/twenty-linear.integration-test.ts b/packages/twenty-apps/public/twenty-linear/src/__tests__/twenty-linear.integration-test.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-linear/src/__tests__/twenty-linear.integration-test.ts rename to packages/twenty-apps/public/twenty-linear/src/__tests__/twenty-linear.integration-test.ts diff --git a/packages/twenty-apps/internal/twenty-linear/src/application.config.ts b/packages/twenty-apps/public/twenty-linear/src/application.config.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-linear/src/application.config.ts rename to packages/twenty-apps/public/twenty-linear/src/application.config.ts diff --git a/packages/twenty-apps/internal/twenty-linear/src/command-menu-items/create-linear-issue.command-menu-item.ts b/packages/twenty-apps/public/twenty-linear/src/command-menu-items/create-linear-issue.command-menu-item.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-linear/src/command-menu-items/create-linear-issue.command-menu-item.ts rename to packages/twenty-apps/public/twenty-linear/src/command-menu-items/create-linear-issue.command-menu-item.ts diff --git a/packages/twenty-apps/internal/twenty-linear/src/components/create-issue-form.front-component.tsx b/packages/twenty-apps/public/twenty-linear/src/components/create-issue-form.front-component.tsx similarity index 100% rename from packages/twenty-apps/internal/twenty-linear/src/components/create-issue-form.front-component.tsx rename to packages/twenty-apps/public/twenty-linear/src/components/create-issue-form.front-component.tsx diff --git a/packages/twenty-apps/internal/twenty-linear/src/connection-providers/linear-connection.ts b/packages/twenty-apps/public/twenty-linear/src/connection-providers/linear-connection.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-linear/src/connection-providers/linear-connection.ts rename to packages/twenty-apps/public/twenty-linear/src/connection-providers/linear-connection.ts diff --git a/packages/twenty-apps/internal/twenty-linear/src/constants/universal-identifiers.ts b/packages/twenty-apps/public/twenty-linear/src/constants/universal-identifiers.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-linear/src/constants/universal-identifiers.ts rename to packages/twenty-apps/public/twenty-linear/src/constants/universal-identifiers.ts diff --git a/packages/twenty-apps/internal/twenty-linear/src/logic-functions/__tests__/create-linear-issue.test.ts b/packages/twenty-apps/public/twenty-linear/src/logic-functions/__tests__/create-linear-issue.test.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-linear/src/logic-functions/__tests__/create-linear-issue.test.ts rename to packages/twenty-apps/public/twenty-linear/src/logic-functions/__tests__/create-linear-issue.test.ts diff --git a/packages/twenty-apps/internal/twenty-linear/src/logic-functions/__tests__/list-linear-teams.test.ts b/packages/twenty-apps/public/twenty-linear/src/logic-functions/__tests__/list-linear-teams.test.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-linear/src/logic-functions/__tests__/list-linear-teams.test.ts rename to packages/twenty-apps/public/twenty-linear/src/logic-functions/__tests__/list-linear-teams.test.ts diff --git a/packages/twenty-apps/internal/twenty-linear/src/logic-functions/__tests__/test-utils.ts b/packages/twenty-apps/public/twenty-linear/src/logic-functions/__tests__/test-utils.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-linear/src/logic-functions/__tests__/test-utils.ts rename to packages/twenty-apps/public/twenty-linear/src/logic-functions/__tests__/test-utils.ts diff --git a/packages/twenty-apps/internal/twenty-linear/src/logic-functions/constants/issue-create-mutation.constant.ts b/packages/twenty-apps/public/twenty-linear/src/logic-functions/constants/issue-create-mutation.constant.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-linear/src/logic-functions/constants/issue-create-mutation.constant.ts rename to packages/twenty-apps/public/twenty-linear/src/logic-functions/constants/issue-create-mutation.constant.ts diff --git a/packages/twenty-apps/internal/twenty-linear/src/logic-functions/create-linear-issue-route.ts b/packages/twenty-apps/public/twenty-linear/src/logic-functions/create-linear-issue-route.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-linear/src/logic-functions/create-linear-issue-route.ts rename to packages/twenty-apps/public/twenty-linear/src/logic-functions/create-linear-issue-route.ts diff --git a/packages/twenty-apps/internal/twenty-linear/src/logic-functions/create-linear-issue.ts b/packages/twenty-apps/public/twenty-linear/src/logic-functions/create-linear-issue.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-linear/src/logic-functions/create-linear-issue.ts rename to packages/twenty-apps/public/twenty-linear/src/logic-functions/create-linear-issue.ts diff --git a/packages/twenty-apps/internal/twenty-linear/src/logic-functions/handlers/create-linear-issue-handler.ts b/packages/twenty-apps/public/twenty-linear/src/logic-functions/handlers/create-linear-issue-handler.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-linear/src/logic-functions/handlers/create-linear-issue-handler.ts rename to packages/twenty-apps/public/twenty-linear/src/logic-functions/handlers/create-linear-issue-handler.ts diff --git a/packages/twenty-apps/internal/twenty-linear/src/logic-functions/handlers/list-linear-issue-options-handler.ts b/packages/twenty-apps/public/twenty-linear/src/logic-functions/handlers/list-linear-issue-options-handler.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-linear/src/logic-functions/handlers/list-linear-issue-options-handler.ts rename to packages/twenty-apps/public/twenty-linear/src/logic-functions/handlers/list-linear-issue-options-handler.ts diff --git a/packages/twenty-apps/internal/twenty-linear/src/logic-functions/handlers/list-linear-teams-handler.ts b/packages/twenty-apps/public/twenty-linear/src/logic-functions/handlers/list-linear-teams-handler.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-linear/src/logic-functions/handlers/list-linear-teams-handler.ts rename to packages/twenty-apps/public/twenty-linear/src/logic-functions/handlers/list-linear-teams-handler.ts diff --git a/packages/twenty-apps/internal/twenty-linear/src/logic-functions/handlers/list-linear-workflow-states-handler.ts b/packages/twenty-apps/public/twenty-linear/src/logic-functions/handlers/list-linear-workflow-states-handler.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-linear/src/logic-functions/handlers/list-linear-workflow-states-handler.ts rename to packages/twenty-apps/public/twenty-linear/src/logic-functions/handlers/list-linear-workflow-states-handler.ts diff --git a/packages/twenty-apps/internal/twenty-linear/src/logic-functions/list-linear-issue-options-route.ts b/packages/twenty-apps/public/twenty-linear/src/logic-functions/list-linear-issue-options-route.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-linear/src/logic-functions/list-linear-issue-options-route.ts rename to packages/twenty-apps/public/twenty-linear/src/logic-functions/list-linear-issue-options-route.ts diff --git a/packages/twenty-apps/internal/twenty-linear/src/logic-functions/list-linear-issue-options.ts b/packages/twenty-apps/public/twenty-linear/src/logic-functions/list-linear-issue-options.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-linear/src/logic-functions/list-linear-issue-options.ts rename to packages/twenty-apps/public/twenty-linear/src/logic-functions/list-linear-issue-options.ts diff --git a/packages/twenty-apps/internal/twenty-linear/src/logic-functions/list-linear-teams-route.ts b/packages/twenty-apps/public/twenty-linear/src/logic-functions/list-linear-teams-route.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-linear/src/logic-functions/list-linear-teams-route.ts rename to packages/twenty-apps/public/twenty-linear/src/logic-functions/list-linear-teams-route.ts diff --git a/packages/twenty-apps/internal/twenty-linear/src/logic-functions/list-linear-teams.ts b/packages/twenty-apps/public/twenty-linear/src/logic-functions/list-linear-teams.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-linear/src/logic-functions/list-linear-teams.ts rename to packages/twenty-apps/public/twenty-linear/src/logic-functions/list-linear-teams.ts diff --git a/packages/twenty-apps/internal/twenty-linear/src/logic-functions/list-linear-workflow-states-route.ts b/packages/twenty-apps/public/twenty-linear/src/logic-functions/list-linear-workflow-states-route.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-linear/src/logic-functions/list-linear-workflow-states-route.ts rename to packages/twenty-apps/public/twenty-linear/src/logic-functions/list-linear-workflow-states-route.ts diff --git a/packages/twenty-apps/internal/twenty-linear/src/logic-functions/types/create-issue-input.type.ts b/packages/twenty-apps/public/twenty-linear/src/logic-functions/types/create-issue-input.type.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-linear/src/logic-functions/types/create-issue-input.type.ts rename to packages/twenty-apps/public/twenty-linear/src/logic-functions/types/create-issue-input.type.ts diff --git a/packages/twenty-apps/internal/twenty-linear/src/logic-functions/types/create-issue-mutation-result.type.ts b/packages/twenty-apps/public/twenty-linear/src/logic-functions/types/create-issue-mutation-result.type.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-linear/src/logic-functions/types/create-issue-mutation-result.type.ts rename to packages/twenty-apps/public/twenty-linear/src/logic-functions/types/create-issue-mutation-result.type.ts diff --git a/packages/twenty-apps/internal/twenty-linear/src/logic-functions/utils/call-linear-graphql.ts b/packages/twenty-apps/public/twenty-linear/src/logic-functions/utils/call-linear-graphql.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-linear/src/logic-functions/utils/call-linear-graphql.ts rename to packages/twenty-apps/public/twenty-linear/src/logic-functions/utils/call-linear-graphql.ts diff --git a/packages/twenty-apps/internal/twenty-linear/src/logic-functions/utils/types/linear-graphql-result.type.ts b/packages/twenty-apps/public/twenty-linear/src/logic-functions/utils/types/linear-graphql-result.type.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-linear/src/logic-functions/utils/types/linear-graphql-result.type.ts rename to packages/twenty-apps/public/twenty-linear/src/logic-functions/utils/types/linear-graphql-result.type.ts diff --git a/packages/twenty-apps/internal/twenty-linear/src/roles/default-function.role.ts b/packages/twenty-apps/public/twenty-linear/src/roles/default-function.role.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-linear/src/roles/default-function.role.ts rename to packages/twenty-apps/public/twenty-linear/src/roles/default-function.role.ts diff --git a/packages/twenty-apps/internal/twenty-linear/tsconfig.json b/packages/twenty-apps/public/twenty-linear/tsconfig.json similarity index 100% rename from packages/twenty-apps/internal/twenty-linear/tsconfig.json rename to packages/twenty-apps/public/twenty-linear/tsconfig.json diff --git a/packages/twenty-apps/internal/twenty-linear/tsconfig.spec.json b/packages/twenty-apps/public/twenty-linear/tsconfig.spec.json similarity index 100% rename from packages/twenty-apps/internal/twenty-linear/tsconfig.spec.json rename to packages/twenty-apps/public/twenty-linear/tsconfig.spec.json diff --git a/packages/twenty-apps/internal/twenty-linear/vitest.config.ts b/packages/twenty-apps/public/twenty-linear/vitest.config.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-linear/vitest.config.ts rename to packages/twenty-apps/public/twenty-linear/vitest.config.ts diff --git a/packages/twenty-apps/internal/twenty-linear/vitest.unit.config.ts b/packages/twenty-apps/public/twenty-linear/vitest.unit.config.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-linear/vitest.unit.config.ts rename to packages/twenty-apps/public/twenty-linear/vitest.unit.config.ts diff --git a/packages/twenty-apps/internal/twenty-linear/yarn.lock b/packages/twenty-apps/public/twenty-linear/yarn.lock similarity index 100% rename from packages/twenty-apps/internal/twenty-linear/yarn.lock rename to packages/twenty-apps/public/twenty-linear/yarn.lock diff --git a/packages/twenty-apps/public/twenty-meeting-bot/.env.example b/packages/twenty-apps/public/twenty-meeting-bot/.env.example new file mode 100644 index 0000000000..1a1972fe60 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/.env.example @@ -0,0 +1,5 @@ +# Credentials for integration tests. Copy this file to .env.local and fill in the key. +# Get an API key from the Twenty UI: Settings -> APIs & Webhooks. +# .env.local is gitignored; never commit a real key. +TWENTY_API_URL=http://localhost:2020 +TWENTY_API_KEY= diff --git a/packages/twenty-apps/public/twenty-meeting-bot/.gitignore b/packages/twenty-apps/public/twenty-meeting-bot/.gitignore new file mode 100644 index 0000000000..6699cb048a --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/.gitignore @@ -0,0 +1,39 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn + +# codegen +generated + +# testing +/coverage + +# dev +/dist/ + +.twenty + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* +!.env.example + +# typescript +*.tsbuildinfo +*.d.ts diff --git a/packages/twenty-apps/internal/twenty-slack/.nvmrc b/packages/twenty-apps/public/twenty-meeting-bot/.nvmrc similarity index 100% rename from packages/twenty-apps/internal/twenty-slack/.nvmrc rename to packages/twenty-apps/public/twenty-meeting-bot/.nvmrc diff --git a/packages/twenty-apps/public/twenty-meeting-bot/.oxlintrc.json b/packages/twenty-apps/public/twenty-meeting-bot/.oxlintrc.json new file mode 100644 index 0000000000..34c54bff59 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/.oxlintrc.json @@ -0,0 +1,20 @@ +{ + "$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" + } +} diff --git a/packages/twenty-apps/internal/twenty-slack/.yarnrc.yml b/packages/twenty-apps/public/twenty-meeting-bot/.yarnrc.yml similarity index 100% rename from packages/twenty-apps/internal/twenty-slack/.yarnrc.yml rename to packages/twenty-apps/public/twenty-meeting-bot/.yarnrc.yml diff --git a/packages/twenty-apps/public/twenty-meeting-bot/AGENTS.md b/packages/twenty-apps/public/twenty-meeting-bot/AGENTS.md new file mode 100644 index 0000000000..9078a7be23 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/AGENTS.md @@ -0,0 +1,67 @@ +## 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 an object without an index view associated. Unless this is a technical object, user will need to visualize it. +- Creating a view without a navigationMenuItem associated. This will make the view 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/.ts` | +| Field | `yarn twenty dev:add field` | `src/fields/.ts` | +| Logic function | `yarn twenty dev:add logicFunction` | `src/logic-functions/.ts` | +| Front component | `yarn twenty dev:add frontComponent` | `src/front-components/.tsx` | +| Role | `yarn twenty dev:add role` | `src/roles/.ts` | +| Skill | `yarn twenty dev:add skill` | `src/skills/.ts` | +| Agent | `yarn twenty dev:add agent` | `src/agents/.ts` | +| View | `yarn twenty dev:add view` | `src/views/.ts` | +| Navigation menu item | `yarn twenty dev:add navigationMenuItem` | `src/navigation-menu-items/.ts` | +| Page layout | `yarn twenty dev:add pageLayout` | `src/page-layouts/.ts` | + +This helps automatically generate required IDs etc. diff --git a/packages/twenty-apps/public/twenty-meeting-bot/CLAUDE.md b/packages/twenty-apps/public/twenty-meeting-bot/CLAUDE.md new file mode 100644 index 0000000000..9078a7be23 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/CLAUDE.md @@ -0,0 +1,67 @@ +## 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 an object without an index view associated. Unless this is a technical object, user will need to visualize it. +- Creating a view without a navigationMenuItem associated. This will make the view 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/.ts` | +| Field | `yarn twenty dev:add field` | `src/fields/.ts` | +| Logic function | `yarn twenty dev:add logicFunction` | `src/logic-functions/.ts` | +| Front component | `yarn twenty dev:add frontComponent` | `src/front-components/.tsx` | +| Role | `yarn twenty dev:add role` | `src/roles/.ts` | +| Skill | `yarn twenty dev:add skill` | `src/skills/.ts` | +| Agent | `yarn twenty dev:add agent` | `src/agents/.ts` | +| View | `yarn twenty dev:add view` | `src/views/.ts` | +| Navigation menu item | `yarn twenty dev:add navigationMenuItem` | `src/navigation-menu-items/.ts` | +| Page layout | `yarn twenty dev:add pageLayout` | `src/page-layouts/.ts` | + +This helps automatically generate required IDs etc. diff --git a/packages/twenty-apps/public/twenty-meeting-bot/README.md b/packages/twenty-apps/public/twenty-meeting-bot/README.md new file mode 100644 index 0000000000..1d7ab672eb --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/README.md @@ -0,0 +1,173 @@ +# Twenty Meeting Bot + +Record your meetings automatically and keep every call inside your CRM. Twenty +Meeting Bot sends a recording bot to your team's calendar meetings, then stores +the video, audio, and a speaker-attributed transcript on the meeting's record — +searchable, in context, and ready for people, AI agents, and workflows to act +on. + +## What this app does + +1. A teammate has an upcoming meeting on a synced calendar with a video + conference link (Zoom, Google Meet, Microsoft Teams — anything Recall.ai + supports). +2. Because recording is on by default, the app schedules a meeting bot to join + that event shortly before it starts. +3. The bot joins under the configured display name and records the meeting's + audio and video for its duration. +4. When the call ends, Recall.ai processes the recording; the app ingests the + video, the audio, and a speaker-attributed transcript and stores them as a + **Call Recording**. +5. The recording and transcript surface on the meeting's **Calendar Event**, + under a **Call Recording** tab — ready to review, and for AI agents and + workflows to act on. + +## What gets added to your Twenty workspace + +- **A "Recording Bot" field on Calendar Events.** A select field (On / Off, On + by default) on every CalendarEvent. Leave it On to record the meeting; switch + it Off to keep the bot out of that specific event. +- **A "Call Recording" tab on the Calendar Event record page.** A viewer with + the meeting's video player and a speaker-attributed, timestamped transcript + that follows along as the recording plays. +- **Call Recording records.** Each recording is stored as a standard + **CallRecording**: the mixed audio (MP3) and video (MP4), the transcript, the + call's actual start and end times, and a lifecycle status (`SCHEDULED` → + `JOINING` → `RECORDING` → `PROCESSING` → `COMPLETED`, or `FAILED`), with a + Meeting Bot Failure Reason when failure details are available. +- **A default role.** A scoped application role that reads calendar events, + participants, people, and workspace members to decide attendance, and writes + the resulting CallRecording records, uploads recording media, and fills + transcripts. It cannot delete records or change settings. + +## How recording works + +- **On by default.** Once an admin installs the app and configures Recall.ai + credentials, every eligible meeting is recorded automatically — there is + nothing each person has to switch on. +- **A meeting is eligible when** it is not canceled, has a conference link, has + not ended yet, and its **Recording Bot** field is On. If any of those isn't + true — the event was canceled, you turned recording Off, there's no video + link, or the meeting has already ended — no bot is scheduled. +- **Opting out of a single meeting.** Open the event and set **Recording Bot** + to Off (or do it from the calendar events list view). The app cancels any bot + it had scheduled for that meeting. +- **Joining and leaving.** By default the bot joins one minute before the start + time and waits in the lobby up to twenty minutes to be admitted. It leaves on + its own if no one ever joins, or shortly after everyone else has left. These + are all tunable — see [Application variables](#application-variables). +- **It tracks the calendar.** If a meeting's time, link, or recording + preference changes, the app reschedules or cancels the bot to match. A + periodic reconciliation job runs as a safety net, keeping recordings and bots + in sync even when a real-time update is missed. + +## Billing + +Recording is a metered feature. Each recording is charged on its **actual call +duration** — from when the bot starts recording to when it stops — prorated, at +a rate of **1 credit per recording-hour** (1,000,000 micro-credits). A meeting +the bot never recorded (opted out, canceled, or no one showed) is not charged. + +## Installing + +1. Open **Settings → Applications** in your Twenty workspace. +2. Find **Twenty Meeting Bot** and click **Install**. +3. A server admin completes the one-time + [Self-hosting setup](#self-hosting-setup-admin-only) below to wire up the + Recall.ai API key and webhook. On Twenty Cloud these may already be + configured. + +## Limitations + +What this app intentionally does **not** do in v1: + +- **Recording is workspace-wide, not per person.** When the app is installed, + every eligible meeting is recorded by default; there is no per-user "record + my meetings" toggle yet. Control is per-meeting via the **Recording Bot** + field. Per-user opt-in/out is planned for a later version. +- **The meeting must be a synced calendar event with a conference link.** The + bot is scheduled from CalendarEvents that Twenty has synced from a connected + calendar (Google / Outlook / CalDAV) and that carry a video-conference link. + Ad-hoc calls that were never on a synced calendar are not recorded. +- **A recording completes only when both its audio and video are ingested.** + Recall produces only the artifacts requested at bot creation (mixed MP3 + + MP4); a recording reaches `COMPLETED` once both have been stored. If + processing fails, it is marked `FAILED`. +- **Recall.ai media is temporary; Twenty's copy is not.** Recall retains the + source media for a limited window (about seven days by default) to stay + inside its free-storage window. Twenty ingests and stores the video, audio, + and transcript in its own storage, so they remain available after Recall's + media expires. + +## Troubleshooting + +| Symptom | Likely cause | Fix | +|---|---|---| +| No bot joined a meeting | **Recording Bot** was Off, the event had no conference link, it wasn't synced from a connected calendar, or `RECALL_API_KEY` isn't set | Confirm the event is On, upcoming, has a video link, and came from a synced calendar; admin: confirm `RECALL_API_KEY` is set | +| Recording never reaches `COMPLETED` | A Recall webhook was missed, or only one of audio/video was produced | The reconciliation job pulls the latest status from Recall within a few minutes; if it is marked `FAILED`, inspect the bot in the Recall dashboard | +| Transcript empty, or marked pending/failed | Recall hasn't finished async transcription yet, or transcription failed for that call | Wait for the reconciliation job to ingest the transcript; a persistent failure leaves a marker in the transcript | +| Webhook rejected with `401` (Recall keeps retrying) | `RECALL_WEBHOOK_SECRET` doesn't match the Recall endpoint's signing secret | Re-copy the `whsec_…` secret from the Recall webhook endpoint into the `RECALL_WEBHOOK_SECRET` server variable | +| Webhook rejected with `500` about the secret | `RECALL_WEBHOOK_SECRET` is not set | Admin: set it on the application registration | +| Bot left almost immediately | No one was admitted before the lobby / no-one-joined timeout, or everyone left | Adjust `MEETING_BOT_WAITING_ROOM_TIMEOUT_SECONDS` / `MEETING_BOT_NOONE_JOINED_TIMEOUT_SECONDS` if too aggressive | +| Bot joined a meeting you didn't want recorded | Recording is on by default | Set the event's **Recording Bot** field to Off; the scheduled bot is canceled | + +--- + +## Self-hosting setup (admin-only) + +This section is for Twenty server admins. If you're on Twenty Cloud, the +credentials may already be configured. + +### Server variables + +Set these on the application registration after installing (Settings → +Applications → Twenty Meeting Bot): + +| Server variable | Required | Purpose | +|---|---|---| +| `RECALL_API_KEY` | Yes | Recall.ai API key for the configured region; used to schedule, update, and cancel bots. | +| `RECALL_REGION` | No | Recall.ai region for API requests. Defaults to `eu-central-1` (Europe / Frankfurt). | +| `MEETING_BOT_RECORDING_RETENTION_HOURS` | No | How long Recall.ai retains the source media after processing. Defaults to `166` hours (6 days 22 hours), just under Recall's 168-hour free-storage window. Values above `168` may incur Recall storage charges. Twenty's ingested copy is unaffected. | +| `RECALL_WEBHOOK_SECRET` | Yes | Svix signing secret (`whsec_…`) used to verify incoming Recall webhooks. | + +### Application variables + +A workspace admin can tune bot behavior through application variables: + +| Application variable | Default | Purpose | +|---|---|---| +| `MEETING_BOT_NAME` | `Twenty Meeting Bot` | Display name the bot uses when it joins a call. | +| `MEETING_BOT_JOIN_EARLY_MINUTES` | `1` | Minutes before the start time the bot joins. Set to `0` to join at the scheduled start. | +| `MEETING_BOT_WAITING_ROOM_TIMEOUT_SECONDS` | `1200` | Seconds the bot waits in the lobby before giving up and leaving. | +| `MEETING_BOT_NOONE_JOINED_TIMEOUT_SECONDS` | `1200` | Seconds the bot stays in an empty meeting when no one else ever joins. | +| `MEETING_BOT_EVERYONE_LEFT_TIMEOUT_SECONDS` | `2` | Seconds the bot keeps recording after everyone else leaves. | + +### Configuring the Recall webhook + +The app exposes a server webhook route that verifies the Recall/Svix signature, +advances the matching CallRecording's lifecycle status (`JOINING` → `RECORDING` +→ `PROCESSING`, or `FAILED`), and — once the recording finishes — +ingests the audio, video, and transcript. It never moves a status backward, so +out-of-order or duplicate deliveries are safe, and it returns a non-2xx response +on signature failures so Recall retries. + +Use this URL on your deployment, replacing only the host: + +```text +https:///webhooks/server/8da4b8b5-5edf-4880-b51f-ab6e679ec617/9215afe6-1497-4149-a49d-e608e239bbaf +``` + +The first ID is the **Twenty Meeting Bot application registration**. The second +ID is the **Recall webhook logic function**. + +1. In the Recall.ai dashboard, create a webhook endpoint pointing at your + deployment's webhook URL, subscribed to the **bot status-change**, + **recording**, and **transcript** events (`bot.status_change`, + `recording.done`, `recording.failed`, `transcript.done`, + `transcript.failed`). Status-change drives the lifecycle; the recording and + transcript events trigger media and transcript ingestion. Subscribing to + status changes alone leaves ingestion to the reconciliation backstop. +2. Copy the endpoint's signing secret — it starts with `whsec_`. +3. Set it as the `RECALL_WEBHOOK_SECRET` server variable on the **Twenty + Meeting Bot** application registration. +4. Set `RECALL_API_KEY` (and optionally `RECALL_REGION`) the same way. diff --git a/packages/twenty-apps/public/twenty-meeting-bot/package.json b/packages/twenty-apps/public/twenty-meeting-bot/package.json new file mode 100644 index 0000000000..8316af39e0 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/package.json @@ -0,0 +1,40 @@ +{ + "name": "twenty-meeting-bot", + "version": "0.1.4", + "license": "MIT", + "engines": { + "node": "^24.5.0", + "npm": "please-use-yarn", + "yarn": ">=4.0.2" + }, + "keywords": [], + "packageManager": "yarn@4.9.2", + "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:unit": "vitest run --config vitest.unit.config.ts", + "test:unit:watch": "vitest --config vitest.unit.config.ts", + "test:watch": "vitest" + }, + "dependencies": { + "@sniptt/guards": "^0.2.0" + }, + "devDependencies": { + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.0", + "@types/node": "^24.7.2", + "@types/react": "^19.0.0", + "@typescript/native-preview": "^7.0.0-dev.20260116.1", + "oxlint": "^0.16.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "twenty-client-sdk": "2.15.0", + "twenty-sdk": "2.15.0", + "typescript": "^5.9.3", + "vite-tsconfig-paths": "^4.2.1", + "vitest": "^4.1.9" + } +} diff --git a/packages/twenty-apps/public/twenty-meeting-bot/public/gallery/twenty-meeting-bot-cover.png b/packages/twenty-apps/public/twenty-meeting-bot/public/gallery/twenty-meeting-bot-cover.png new file mode 100644 index 0000000000..41b463347b Binary files /dev/null and b/packages/twenty-apps/public/twenty-meeting-bot/public/gallery/twenty-meeting-bot-cover.png differ diff --git a/packages/twenty-apps/public/twenty-meeting-bot/public/logo.svg b/packages/twenty-apps/public/twenty-meeting-bot/public/logo.svg new file mode 100644 index 0000000000..a8bed68a83 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/public/logo.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/__tests__/global-setup.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/__tests__/global-setup.ts new file mode 100644 index 0000000000..5d6782a1ce --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/__tests__/global-setup.ts @@ -0,0 +1,100 @@ +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 CONFIG_PATH = path.join(CONFIG_DIR, 'config.test.json'); + +function validateEnv(): { apiUrl: string; apiKey: string } { + const apiUrl = process.env.TWENTY_API_URL; + const apiKey = process.env.TWENTY_API_KEY; + + if (!apiUrl || !apiKey) { + throw new Error( + 'TWENTY_API_URL and TWENTY_API_KEY must be set.\n' + + 'Start a local server: yarn twenty docker:start\n' + + 'Or set them in vitest env config.', + ); + } + + return { apiUrl, apiKey }; +} + +async function checkServer(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: string, apiKey: string) { + const payload = JSON.stringify( + { + remotes: { + local: { apiUrl, apiKey, accessToken: apiKey }, + }, + defaultRemote: 'local', + }, + null, + 2, + ); + + fs.mkdirSync(CONFIG_DIR, { recursive: true }); + fs.writeFileSync(CONFIG_PATH, payload); +} + +function removeConfig() { + fs.rmSync(CONFIG_PATH, { force: true }); +} + +export async function setup() { + const { apiUrl, apiKey } = validateEnv(); + + await checkServer(apiUrl); + + writeConfig(apiUrl, apiKey); + + 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'}`, + ); + } +} + +export async function teardown() { + try { + const uninstallResult = await appUninstall({ appPath: APP_PATH }); + + if (!uninstallResult.success) { + console.warn( + `App uninstall failed: ${uninstallResult.error?.message ?? 'Unknown error'}`, + ); + } + } catch (error) { + console.warn( + `App uninstall failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } finally { + removeConfig(); + } +} diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/__tests__/schema.integration-test.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/__tests__/schema.integration-test.ts new file mode 100644 index 0000000000..b9623320cf --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/__tests__/schema.integration-test.ts @@ -0,0 +1,123 @@ +import { CoreApiClient } from 'twenty-client-sdk/core'; +import { MetadataApiClient } from 'twenty-client-sdk/metadata'; +import { describe, expect, it } from 'vitest'; + +import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/constants/application-universal-identifier'; +import { CallRecordingRequestStatus } from 'src/logic-functions/constants/call-recording-request-status'; +import { CallRecordingStatus } from 'src/logic-functions/constants/call-recording-status'; +import { + executeCurrentSchemaMutation, + type CurrentSchemaUpdateCallRecordingMutation, +} from 'src/logic-functions/data/execute-current-schema-mutation.util'; + +describe('App installation', () => { + it('should find the installed app in the applications list', async () => { + const client = new MetadataApiClient(); + + const result = await client.query({ + findManyApplications: { + id: true, + name: true, + universalIdentifier: true, + }, + }); + + const app = result.findManyApplications.find( + (a: { universalIdentifier: string }) => + a.universalIdentifier === APPLICATION_UNIVERSAL_IDENTIFIER, + ); + + expect(app).toBeDefined(); + }); +}); + +describe('CallRecording status contract', () => { + it('accepts every app status supported by the current server and every request status value the app mirrors', async () => { + const client = new CoreApiClient(); + const serverCallRecordingStatuses = await getServerCallRecordingStatuses(); + + const created = await client.mutation({ + createCallRecording: { + __args: { + data: { + title: 'Integration test recording', + status: CallRecordingStatus.SCHEDULED, + recordingRequestStatus: CallRecordingRequestStatus.REQUESTED, + }, + }, + id: true, + }, + }); + + const callRecordingId = created.createCallRecording?.id; + + expect(callRecordingId).toBeDefined(); + + if (callRecordingId === undefined) { + throw new Error('Expected call recording creation to return an id'); + } + + expect(serverCallRecordingStatuses).toEqual( + expect.arrayContaining([ + CallRecordingStatus.SCHEDULED, + CallRecordingStatus.JOINING, + CallRecordingStatus.RECORDING, + CallRecordingStatus.PROCESSING, + CallRecordingStatus.COMPLETED, + ]), + ); + + // TODO: Remove this compatibility filter once the released server/SDK + // exposes FAILED instead of FAILED_UNKNOWN. + const statusesAcceptedByCurrentServer = Object.values( + CallRecordingStatus, + ).filter((status) => serverCallRecordingStatuses.includes(status)); + + for (const status of statusesAcceptedByCurrentServer) { + const mutation = { + updateCallRecording: { + __args: { id: callRecordingId, data: { status } }, + status: true, + }, + } satisfies CurrentSchemaUpdateCallRecordingMutation; + + const updated = await executeCurrentSchemaMutation(client, mutation); + + expect(updated.updateCallRecording?.status).toBe(status); + } + + for (const recordingRequestStatus of Object.values( + CallRecordingRequestStatus, + )) { + const updated = await client.mutation({ + updateCallRecording: { + __args: { id: callRecordingId, data: { recordingRequestStatus } }, + recordingRequestStatus: true, + }, + }); + + expect(updated.updateCallRecording?.recordingRequestStatus).toBe( + recordingRequestStatus, + ); + } + + await client.mutation({ + destroyCallRecording: { + __args: { id: callRecordingId }, + id: true, + }, + }); + }); +}); + +type GeneratedCoreSchemaRuntime = { + enumCallRecordingStatusEnum: Record; +}; + +const getServerCallRecordingStatuses = async (): Promise => { + const generatedCoreSchema = (await import( + 'twenty-client-sdk/core' + )) as unknown as GeneratedCoreSchemaRuntime; + + return Object.values(generatedCoreSchema.enumCallRecordingStatusEnum); +}; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/application-config.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/application-config.ts new file mode 100644 index 0000000000..0ecf95e8a8 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/application-config.ts @@ -0,0 +1,96 @@ +import { defineApplication } from 'twenty-sdk/define'; + +import { APP_DESCRIPTION } from 'src/constants/app-description'; +import { APP_DISPLAY_NAME } from 'src/constants/app-display-name'; +import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/constants/application-universal-identifier'; +import { MEETING_BOT_EVERYONE_LEFT_TIMEOUT_SECONDS_APP_VARIABLE_UNIVERSAL_IDENTIFIER } from 'src/constants/meeting-bot-everyone-left-timeout-seconds-app-variable-universal-identifier'; +import { MEETING_BOT_JOIN_EARLY_MINUTES_APP_VARIABLE_UNIVERSAL_IDENTIFIER } from 'src/constants/meeting-bot-join-early-minutes-app-variable-universal-identifier'; +import { MEETING_BOT_NAME_APP_VARIABLE_UNIVERSAL_IDENTIFIER } from 'src/constants/meeting-bot-name-app-variable-universal-identifier'; +import { MEETING_BOT_NOONE_JOINED_TIMEOUT_SECONDS_APP_VARIABLE_UNIVERSAL_IDENTIFIER } from 'src/constants/meeting-bot-noone-joined-timeout-seconds-app-variable-universal-identifier'; +import { MEETING_BOT_WAITING_ROOM_TIMEOUT_SECONDS_APP_VARIABLE_UNIVERSAL_IDENTIFIER } from 'src/constants/meeting-bot-waiting-room-timeout-seconds-app-variable-universal-identifier'; +import { DEFAULT_MEETING_BOT_JOIN_EARLY_MINUTES } from 'src/logic-functions/constants/default-meeting-bot-join-early-minutes'; +import { DEFAULT_MEETING_BOT_NAME } from 'src/logic-functions/constants/default-meeting-bot-name'; +import { DEFAULT_MEETING_BOT_RECORDING_RETENTION_HOURS } from 'src/logic-functions/constants/default-meeting-bot-recording-retention-hours'; +import { DEFAULT_RECALL_REGION } from 'src/logic-functions/constants/default-recall-region'; +import { MEETING_BOT_EVERYONE_LEFT_TIMEOUT_SECONDS } from 'src/logic-functions/constants/meeting-bot-everyone-left-timeout-seconds'; +import { MEETING_BOT_EVERYONE_LEFT_TIMEOUT_SECONDS_ENV_VAR_NAME } from 'src/logic-functions/constants/meeting-bot-everyone-left-timeout-seconds-env-var-name'; +import { MEETING_BOT_JOIN_EARLY_MINUTES_ENV_VAR_NAME } from 'src/logic-functions/constants/meeting-bot-join-early-minutes-env-var-name'; +import { MEETING_BOT_NAME_ENV_VAR_NAME } from 'src/logic-functions/constants/meeting-bot-name-env-var-name'; +import { MEETING_BOT_NOONE_JOINED_TIMEOUT_SECONDS } from 'src/logic-functions/constants/meeting-bot-noone-joined-timeout-seconds'; +import { MEETING_BOT_NOONE_JOINED_TIMEOUT_SECONDS_ENV_VAR_NAME } from 'src/logic-functions/constants/meeting-bot-noone-joined-timeout-seconds-env-var-name'; +import { MEETING_BOT_RECORDING_RETENTION_HOURS_ENV_VAR_NAME } from 'src/logic-functions/constants/meeting-bot-recording-retention-hours-env-var-name'; +import { MEETING_BOT_WAITING_ROOM_TIMEOUT_SECONDS } from 'src/logic-functions/constants/meeting-bot-waiting-room-timeout-seconds'; +import { MEETING_BOT_WAITING_ROOM_TIMEOUT_SECONDS_ENV_VAR_NAME } from 'src/logic-functions/constants/meeting-bot-waiting-room-timeout-seconds-env-var-name'; +import { RECALL_API_KEY_ENV_VAR_NAME } from 'src/logic-functions/constants/recall-api-key-env-var-name'; +import { RECALL_REGION_ENV_VAR_NAME } from 'src/logic-functions/constants/recall-region-env-var-name'; +import { RECALL_WEBHOOK_SECRET_ENV_VAR_NAME } from 'src/logic-functions/constants/recall-webhook-secret-env-var-name'; + +export default defineApplication({ + universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER, + displayName: APP_DISPLAY_NAME, + description: APP_DESCRIPTION, + logoUrl: 'public/logo.svg', + screenshots: ['public/gallery/twenty-meeting-bot-cover.png'], + applicationVariables: { + [MEETING_BOT_NAME_ENV_VAR_NAME]: { + universalIdentifier: MEETING_BOT_NAME_APP_VARIABLE_UNIVERSAL_IDENTIFIER, + description: 'Display name the meeting bot uses when it joins a call.', + isSecret: false, + value: DEFAULT_MEETING_BOT_NAME, + }, + [MEETING_BOT_JOIN_EARLY_MINUTES_ENV_VAR_NAME]: { + universalIdentifier: + MEETING_BOT_JOIN_EARLY_MINUTES_APP_VARIABLE_UNIVERSAL_IDENTIFIER, + description: + 'How many minutes before the meeting start time the bot should join. Set to 0 to join at the scheduled start time.', + isSecret: false, + value: String(DEFAULT_MEETING_BOT_JOIN_EARLY_MINUTES), + }, + [MEETING_BOT_WAITING_ROOM_TIMEOUT_SECONDS_ENV_VAR_NAME]: { + universalIdentifier: + MEETING_BOT_WAITING_ROOM_TIMEOUT_SECONDS_APP_VARIABLE_UNIVERSAL_IDENTIFIER, + description: + 'How many seconds the bot waits in a meeting lobby before giving up and leaving.', + isSecret: false, + value: String(MEETING_BOT_WAITING_ROOM_TIMEOUT_SECONDS), + }, + [MEETING_BOT_NOONE_JOINED_TIMEOUT_SECONDS_ENV_VAR_NAME]: { + universalIdentifier: + MEETING_BOT_NOONE_JOINED_TIMEOUT_SECONDS_APP_VARIABLE_UNIVERSAL_IDENTIFIER, + description: + 'How many seconds the bot stays in an empty meeting when no one else ever joins.', + isSecret: false, + value: String(MEETING_BOT_NOONE_JOINED_TIMEOUT_SECONDS), + }, + [MEETING_BOT_EVERYONE_LEFT_TIMEOUT_SECONDS_ENV_VAR_NAME]: { + universalIdentifier: + MEETING_BOT_EVERYONE_LEFT_TIMEOUT_SECONDS_APP_VARIABLE_UNIVERSAL_IDENTIFIER, + description: + 'How many seconds the bot keeps recording after everyone else leaves the meeting.', + isSecret: false, + value: String(MEETING_BOT_EVERYONE_LEFT_TIMEOUT_SECONDS), + }, + }, + serverVariables: { + [RECALL_API_KEY_ENV_VAR_NAME]: { + description: + 'Recall.ai API key for the configured region. Set by the server admin on this registration after installation; used to create, update, and cancel scheduled meeting bots.', + isSecret: true, + isRequired: true, + }, + [RECALL_REGION_ENV_VAR_NAME]: { + description: `Recall.ai region used for API requests. Defaults to ${DEFAULT_RECALL_REGION} when unset. Europe Frankfurt is eu-central-1.`, + isSecret: false, + }, + [MEETING_BOT_RECORDING_RETENTION_HOURS_ENV_VAR_NAME]: { + description: `How many hours Recall.ai retains recording media after processing. Defaults to ${DEFAULT_MEETING_BOT_RECORDING_RETENTION_HOURS} hours (6 days and 22 hours) to stay below Recall.ai's 7-day free storage window. Values above 168 hours may incur Recall.ai storage charges.`, + isSecret: false, + }, + [RECALL_WEBHOOK_SECRET_ENV_VAR_NAME]: { + description: + 'Recall.ai webhook signing secret (whsec_...). Set by the server admin from the Recall webhook endpoint settings; used to verify the Svix signature of incoming Recall webhook deliveries.', + isSecret: true, + isRequired: true, + }, + }, +}); diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/constants/__tests__/call-recording-field-universal-identifiers.test.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/constants/__tests__/call-recording-field-universal-identifiers.test.ts new file mode 100644 index 0000000000..78d6c1b799 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/constants/__tests__/call-recording-field-universal-identifiers.test.ts @@ -0,0 +1,19 @@ +import { STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define'; +import { describe, expect, it } from 'vitest'; + +import { CALL_RECORDING_AUDIO_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/call-recording-audio-field-universal-identifier'; +import { CALL_RECORDING_VIDEO_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/call-recording-video-field-universal-identifier'; + +// This test is nothing more than a sanity check to ensure that the universal identifiers for the call recording media fields are correct. +describe('call recording field universal identifiers', () => { + it('matches the standard CallRecording media field identifiers', () => { + expect(CALL_RECORDING_AUDIO_FIELD_UNIVERSAL_IDENTIFIER).toBe( + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.callRecording.fields.audio + .universalIdentifier, + ); + expect(CALL_RECORDING_VIDEO_FIELD_UNIVERSAL_IDENTIFIER).toBe( + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.callRecording.fields.video + .universalIdentifier, + ); + }); +}); diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/constants/app-description.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/constants/app-description.ts new file mode 100644 index 0000000000..e5580e7cae --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/constants/app-description.ts @@ -0,0 +1,2 @@ +export const APP_DESCRIPTION = + 'Capture every customer conversation automatically. A meeting bot joins eligible meetings and records calls for you.'; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/constants/app-display-name.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/constants/app-display-name.ts new file mode 100644 index 0000000000..9208fcde47 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/constants/app-display-name.ts @@ -0,0 +1 @@ +export const APP_DISPLAY_NAME = 'Twenty Meeting Bot'; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/constants/application-universal-identifier.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/constants/application-universal-identifier.ts new file mode 100644 index 0000000000..b88164429b --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/constants/application-universal-identifier.ts @@ -0,0 +1,2 @@ +export const APPLICATION_UNIVERSAL_IDENTIFIER = + '8da4b8b5-5edf-4880-b51f-ab6e679ec617'; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/constants/calendar-event-reconciliation-logic-function-universal-identifier.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/constants/calendar-event-reconciliation-logic-function-universal-identifier.ts new file mode 100644 index 0000000000..b9f7a2ee81 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/constants/calendar-event-reconciliation-logic-function-universal-identifier.ts @@ -0,0 +1,2 @@ +export const CALENDAR_EVENT_RECONCILIATION_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER = + '1f28c477-6423-4911-85bf-2296ef112be9'; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/constants/calendar-event-record-page-layout-universal-identifier.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/constants/calendar-event-record-page-layout-universal-identifier.ts new file mode 100644 index 0000000000..a66d22a24f --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/constants/calendar-event-record-page-layout-universal-identifier.ts @@ -0,0 +1,2 @@ +export const CALENDAR_EVENT_RECORD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER = + 'b9b10e40-9ce2-4704-8ac6-c6e92e2563c1'; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/constants/calendar-event-recording-front-component-universal-identifier.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/constants/calendar-event-recording-front-component-universal-identifier.ts new file mode 100644 index 0000000000..3fe07b41b8 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/constants/calendar-event-recording-front-component-universal-identifier.ts @@ -0,0 +1,2 @@ +export const CALENDAR_EVENT_RECORDING_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER = + '445d742e-1c27-46ee-a9ab-4a1dde65adcf'; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/constants/calendar-event-recording-page-layout-tab-universal-identifier.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/constants/calendar-event-recording-page-layout-tab-universal-identifier.ts new file mode 100644 index 0000000000..c559deebca --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/constants/calendar-event-recording-page-layout-tab-universal-identifier.ts @@ -0,0 +1,2 @@ +export const CALENDAR_EVENT_RECORDING_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER = + '10c2c22d-952b-42ea-81fe-2e93e7d30f86'; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/constants/calendar-event-recording-page-layout-widget-universal-identifier.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/constants/calendar-event-recording-page-layout-widget-universal-identifier.ts new file mode 100644 index 0000000000..1588ffe3b7 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/constants/calendar-event-recording-page-layout-widget-universal-identifier.ts @@ -0,0 +1,2 @@ +export const CALENDAR_EVENT_RECORDING_PAGE_LAYOUT_WIDGET_UNIVERSAL_IDENTIFIER = + '29c2315b-2ee6-41df-91b7-42f58f6c1a53'; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/constants/call-recording-audio-field-universal-identifier.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/constants/call-recording-audio-field-universal-identifier.ts new file mode 100644 index 0000000000..6d17dc45d9 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/constants/call-recording-audio-field-universal-identifier.ts @@ -0,0 +1,2 @@ +export const CALL_RECORDING_AUDIO_FIELD_UNIVERSAL_IDENTIFIER = + '2eafc2d0-8fec-430c-a939-65ca5fbc0f08'; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/constants/call-recording-video-field-universal-identifier.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/constants/call-recording-video-field-universal-identifier.ts new file mode 100644 index 0000000000..ff4ebcf5fa --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/constants/call-recording-video-field-universal-identifier.ts @@ -0,0 +1,2 @@ +export const CALL_RECORDING_VIDEO_FIELD_UNIVERSAL_IDENTIFIER = + 'bb9523d3-457e-4f4b-8c79-27a77afb87da'; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/constants/default-role-universal-identifier.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/constants/default-role-universal-identifier.ts new file mode 100644 index 0000000000..1c23dd519d --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/constants/default-role-universal-identifier.ts @@ -0,0 +1,2 @@ +export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER = + '5fcf4d3a-0aca-42d9-9beb-7387f43ec180'; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/constants/meeting-bot-everyone-left-timeout-seconds-app-variable-universal-identifier.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/constants/meeting-bot-everyone-left-timeout-seconds-app-variable-universal-identifier.ts new file mode 100644 index 0000000000..6197b972b7 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/constants/meeting-bot-everyone-left-timeout-seconds-app-variable-universal-identifier.ts @@ -0,0 +1,2 @@ +export const MEETING_BOT_EVERYONE_LEFT_TIMEOUT_SECONDS_APP_VARIABLE_UNIVERSAL_IDENTIFIER = + 'c866ddd4-fb7b-4cb4-8ad1-5599755e495c'; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/constants/meeting-bot-failure-reason-on-call-recording-field-universal-identifier.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/constants/meeting-bot-failure-reason-on-call-recording-field-universal-identifier.ts new file mode 100644 index 0000000000..8de20ef3a7 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/constants/meeting-bot-failure-reason-on-call-recording-field-universal-identifier.ts @@ -0,0 +1,2 @@ +export const MEETING_BOT_FAILURE_REASON_ON_CALL_RECORDING_FIELD_UNIVERSAL_IDENTIFIER = + '33a577e4-02f5-48bd-8bed-d365949caa72'; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/constants/meeting-bot-join-early-minutes-app-variable-universal-identifier.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/constants/meeting-bot-join-early-minutes-app-variable-universal-identifier.ts new file mode 100644 index 0000000000..3d4165ec0e --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/constants/meeting-bot-join-early-minutes-app-variable-universal-identifier.ts @@ -0,0 +1,2 @@ +export const MEETING_BOT_JOIN_EARLY_MINUTES_APP_VARIABLE_UNIVERSAL_IDENTIFIER = + '0568ebb2-3f64-47de-8c0d-d367dfbb7462'; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/constants/meeting-bot-name-app-variable-universal-identifier.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/constants/meeting-bot-name-app-variable-universal-identifier.ts new file mode 100644 index 0000000000..51a8ac9177 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/constants/meeting-bot-name-app-variable-universal-identifier.ts @@ -0,0 +1,2 @@ +export const MEETING_BOT_NAME_APP_VARIABLE_UNIVERSAL_IDENTIFIER = + 'c54cbacd-ad10-40b4-9056-7aaf23846d64'; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/constants/meeting-bot-noone-joined-timeout-seconds-app-variable-universal-identifier.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/constants/meeting-bot-noone-joined-timeout-seconds-app-variable-universal-identifier.ts new file mode 100644 index 0000000000..60b8a04a00 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/constants/meeting-bot-noone-joined-timeout-seconds-app-variable-universal-identifier.ts @@ -0,0 +1,2 @@ +export const MEETING_BOT_NOONE_JOINED_TIMEOUT_SECONDS_APP_VARIABLE_UNIVERSAL_IDENTIFIER = + '241180e8-d864-4160-ad02-db44a9e8d395'; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/constants/meeting-bot-preference-off-option-id.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/constants/meeting-bot-preference-off-option-id.ts new file mode 100644 index 0000000000..7a51ea4c2f --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/constants/meeting-bot-preference-off-option-id.ts @@ -0,0 +1,2 @@ +export const MEETING_BOT_PREFERENCE_OFF_OPTION_ID = + 'cc7de62a-08b6-46c8-aa69-f8117e7dd722'; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/constants/meeting-bot-preference-on-calendar-event-field-universal-identifier.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/constants/meeting-bot-preference-on-calendar-event-field-universal-identifier.ts new file mode 100644 index 0000000000..c321578654 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/constants/meeting-bot-preference-on-calendar-event-field-universal-identifier.ts @@ -0,0 +1,2 @@ +export const MEETING_BOT_PREFERENCE_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER = + '8ee9444a-2437-4def-8e61-6e493862a4fd'; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/constants/meeting-bot-preference-on-calendar-event-view-field-universal-identifier.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/constants/meeting-bot-preference-on-calendar-event-view-field-universal-identifier.ts new file mode 100644 index 0000000000..477d5abd6b --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/constants/meeting-bot-preference-on-calendar-event-view-field-universal-identifier.ts @@ -0,0 +1,2 @@ +export const MEETING_BOT_PREFERENCE_ON_CALENDAR_EVENT_VIEW_FIELD_UNIVERSAL_IDENTIFIER = + 'e8c7e9c5-2b4f-4a75-ae46-ea331809106c'; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/constants/meeting-bot-preference-on-option-id.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/constants/meeting-bot-preference-on-option-id.ts new file mode 100644 index 0000000000..77a82fa86d --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/constants/meeting-bot-preference-on-option-id.ts @@ -0,0 +1,2 @@ +export const MEETING_BOT_PREFERENCE_ON_OPTION_ID = + '72431216-49c4-47c8-99af-de4c3831b0be'; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/constants/meeting-bot-preference.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/constants/meeting-bot-preference.ts new file mode 100644 index 0000000000..670e71c935 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/constants/meeting-bot-preference.ts @@ -0,0 +1,4 @@ +export enum MeetingBotPreference { + ON = 'ON', + OFF = 'OFF', +} diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/constants/meeting-bot-waiting-room-timeout-seconds-app-variable-universal-identifier.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/constants/meeting-bot-waiting-room-timeout-seconds-app-variable-universal-identifier.ts new file mode 100644 index 0000000000..bec5abdce4 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/constants/meeting-bot-waiting-room-timeout-seconds-app-variable-universal-identifier.ts @@ -0,0 +1,2 @@ +export const MEETING_BOT_WAITING_ROOM_TIMEOUT_SECONDS_APP_VARIABLE_UNIVERSAL_IDENTIFIER = + '12e4e14d-d539-4d07-b477-4773539dd20b'; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/constants/recall-webhook-logic-function-universal-identifier.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/constants/recall-webhook-logic-function-universal-identifier.ts new file mode 100644 index 0000000000..98e5e559be --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/constants/recall-webhook-logic-function-universal-identifier.ts @@ -0,0 +1,2 @@ +export const RECALL_WEBHOOK_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER = + '9215afe6-1497-4149-a49d-e608e239bbaf'; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/constants/stale-bot-state-logic-function-universal-identifier.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/constants/stale-bot-state-logic-function-universal-identifier.ts new file mode 100644 index 0000000000..79ea9ad293 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/constants/stale-bot-state-logic-function-universal-identifier.ts @@ -0,0 +1,2 @@ +export const STALE_BOT_STATE_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER = + 'e362aa9b-52c6-4b7e-bb20-927e0e8d7cbe'; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/default-role.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/default-role.ts new file mode 100644 index 0000000000..62df301574 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/default-role.ts @@ -0,0 +1,69 @@ +import { + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, + SystemPermissionFlag, + defineApplicationRole, +} from 'twenty-sdk/define'; + +import { APP_DISPLAY_NAME } from 'src/constants/app-display-name'; +import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/constants/default-role-universal-identifier'; + +export default defineApplicationRole({ + universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER, + label: `${APP_DISPLAY_NAME} default role`, + description: + 'Reads calendar events to decide whether the meeting bot should attend a meeting; writes the resulting CallRecording records, uploads recording media, and fills transcripts.', + canReadAllObjectRecords: false, + canUpdateAllObjectRecords: false, + canSoftDeleteAllObjectRecords: false, + canDestroyAllObjectRecords: false, + canUpdateAllSettings: false, + canBeAssignedToAgents: false, + canBeAssignedToUsers: false, + canBeAssignedToApiKeys: false, + objectPermissions: [ + { + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.calendarEvent.universalIdentifier, + canReadObjectRecords: true, + canUpdateObjectRecords: false, + canSoftDeleteObjectRecords: false, + canDestroyObjectRecords: false, + }, + { + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.calendarEventParticipant + .universalIdentifier, + canReadObjectRecords: true, + canUpdateObjectRecords: false, + canSoftDeleteObjectRecords: false, + canDestroyObjectRecords: false, + }, + { + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.callRecording.universalIdentifier, + canReadObjectRecords: true, + canUpdateObjectRecords: true, + canSoftDeleteObjectRecords: false, + canDestroyObjectRecords: false, + }, + { + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier, + canReadObjectRecords: true, + canUpdateObjectRecords: false, + canSoftDeleteObjectRecords: false, + canDestroyObjectRecords: false, + }, + { + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.workspaceMember + .universalIdentifier, + canReadObjectRecords: true, + canUpdateObjectRecords: false, + canSoftDeleteObjectRecords: false, + canDestroyObjectRecords: false, + }, + ], + fieldPermissions: [], + permissionFlagUniversalIdentifiers: [SystemPermissionFlag.UPLOAD_FILE], +}); diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/fields/meeting-bot-failure-reason-on-call-recording.field.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/fields/meeting-bot-failure-reason-on-call-recording.field.ts new file mode 100644 index 0000000000..af87ce4b87 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/fields/meeting-bot-failure-reason-on-call-recording.field.ts @@ -0,0 +1,22 @@ +import { + defineField, + FieldType, + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, +} from 'twenty-sdk/define'; + +import { MEETING_BOT_FAILURE_REASON_ON_CALL_RECORDING_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/meeting-bot-failure-reason-on-call-recording-field-universal-identifier'; + +export default defineField({ + universalIdentifier: + MEETING_BOT_FAILURE_REASON_ON_CALL_RECORDING_FIELD_UNIVERSAL_IDENTIFIER, + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.callRecording.universalIdentifier, + type: FieldType.TEXT, + name: 'meetingBotFailureReason', + label: 'Meeting Bot Failure Reason', + description: + 'Provider-specific reason the meeting bot could not produce a recording.', + icon: 'IconAlertTriangle', + isNullable: true, + isUIEditable: false, +}); diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/fields/meeting-bot-preference-on-calendar-event.field.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/fields/meeting-bot-preference-on-calendar-event.field.ts new file mode 100644 index 0000000000..078c91a327 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/fields/meeting-bot-preference-on-calendar-event.field.ts @@ -0,0 +1,41 @@ +import { + defineField, + FieldType, + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, +} from 'twenty-sdk/define'; + +import { MeetingBotPreference } from 'src/constants/meeting-bot-preference'; +import { MEETING_BOT_PREFERENCE_OFF_OPTION_ID } from 'src/constants/meeting-bot-preference-off-option-id'; +import { MEETING_BOT_PREFERENCE_ON_OPTION_ID } from 'src/constants/meeting-bot-preference-on-option-id'; +import { MEETING_BOT_PREFERENCE_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/meeting-bot-preference-on-calendar-event-field-universal-identifier'; + +export default defineField({ + universalIdentifier: + MEETING_BOT_PREFERENCE_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER, + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.calendarEvent.universalIdentifier, + type: FieldType.SELECT, + name: 'meetingBotPreference', + label: 'Recording Bot', + description: + 'Meeting bot recording is on by default when the app is installed. Turn it off for this event when needed.', + icon: 'IconRobot', + isNullable: false, + defaultValue: `'${MeetingBotPreference.ON}'`, + options: [ + { + id: MEETING_BOT_PREFERENCE_ON_OPTION_ID, + value: MeetingBotPreference.ON, + label: 'On', + position: 0, + color: 'green', + }, + { + id: MEETING_BOT_PREFERENCE_OFF_OPTION_ID, + value: MeetingBotPreference.OFF, + label: 'Off', + position: 1, + color: 'red', + }, + ], +}); diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/calendar-event-recording.front-component.tsx b/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/calendar-event-recording.front-component.tsx new file mode 100644 index 0000000000..76eae30dc5 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/calendar-event-recording.front-component.tsx @@ -0,0 +1,13 @@ +import { defineFrontComponent } from 'twenty-sdk/define'; + +import { CALENDAR_EVENT_RECORDING_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/constants/calendar-event-recording-front-component-universal-identifier'; +import { CalendarEventRecording } from 'src/front-components/components/CalendarEventRecording'; + +export default defineFrontComponent({ + universalIdentifier: + CALENDAR_EVENT_RECORDING_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER, + name: 'calendar-event-recording', + description: + 'Read-only recording viewer with synced transcript for the calendar event record page.', + component: CalendarEventRecording, +}); diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/components/CalendarEventRecording.tsx b/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/components/CalendarEventRecording.tsx new file mode 100644 index 0000000000..ce05393821 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/components/CalendarEventRecording.tsx @@ -0,0 +1,39 @@ +import styled from '@emotion/styled'; +import { isUndefined } from '@sniptt/guards'; +import { useSelectedRecordIds } from 'twenty-sdk/front-component'; + +import { CalendarEventRecordingContent } from 'src/front-components/components/CalendarEventRecordingContent'; +import { recordingThemeCssVariables } from 'src/front-components/constants/recording-theme-css-variables'; + +const StyledCenteredState = styled.div` + align-items: center; + box-sizing: border-box; + color: ${recordingThemeCssVariables.font.colorTertiary}; + display: flex; + font-family: ${recordingThemeCssVariables.font.family}; + font-size: ${recordingThemeCssVariables.font.sizeSm}; + height: 100%; + justify-content: center; + padding: ${recordingThemeCssVariables.spacing[4]}; +`; + +export const CalendarEventRecording = () => { + const selectedRecordIds = useSelectedRecordIds(); + const calendarEventId = + selectedRecordIds.length === 1 ? selectedRecordIds[0] : undefined; + + if (isUndefined(calendarEventId)) { + return ( + + Open a calendar event to see its recording. + + ); + } + + return ( + + ); +}; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/components/CalendarEventRecordingBody.tsx b/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/components/CalendarEventRecordingBody.tsx new file mode 100644 index 0000000000..7847f43409 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/components/CalendarEventRecordingBody.tsx @@ -0,0 +1,96 @@ +import styled from '@emotion/styled'; +import { isUndefined } from '@sniptt/guards'; + +import { RecordingTranscript } from 'src/front-components/components/RecordingTranscript'; +import { RecordingVideoPlayer } from 'src/front-components/components/RecordingVideoPlayer'; +import { TranscriptErrorBox } from 'src/front-components/components/TranscriptErrorBox'; +import { recordingThemeCssVariables } from 'src/front-components/constants/recording-theme-css-variables'; +import { type CalendarEventRecordingParticipant } from 'src/front-components/types/calendar-event-recording-participant.type'; + +const StyledCenteredState = styled.div` + align-items: center; + box-sizing: border-box; + color: ${recordingThemeCssVariables.font.colorTertiary}; + display: flex; + font-family: ${recordingThemeCssVariables.font.family}; + font-size: ${recordingThemeCssVariables.font.sizeSm}; + justify-content: center; + min-height: 240px; + padding: ${recordingThemeCssVariables.spacing[4]}; +`; + +const StyledRecordingContainer = styled.div<{ + $hasVideo?: boolean; +}>` + display: grid; + gap: ${recordingThemeCssVariables.spacing[2]}; + grid-template-rows: ${({ $hasVideo }) => + $hasVideo ? 'auto minmax(0, 1fr)' : 'minmax(0, 1fr)'}; + min-height: 0; +`; + +type CalendarEventRecordingBodyProps = { + transcript: unknown; + videoFileUrl: string | undefined; + isCalendarEventRecordingQueryLoading: boolean; + errorMessage: string | undefined; + currentTimeSeconds: number; + calendarEventParticipants: CalendarEventRecordingParticipant[]; + onVideoTimeUpdate: (videoCurrentTimeSeconds: number) => void; +}; + +export const CalendarEventRecordingBody = ({ + transcript, + videoFileUrl, + isCalendarEventRecordingQueryLoading, + errorMessage, + currentTimeSeconds, + calendarEventParticipants, + onVideoTimeUpdate, +}: CalendarEventRecordingBodyProps) => { + const hasVideo = !isUndefined(videoFileUrl); + + if (!isUndefined(errorMessage)) { + return ( + + ); + } + + if (isCalendarEventRecordingQueryLoading) { + return ( + + + + ); + } + + if (isUndefined(transcript) && !hasVideo) { + return ( + + No recording for this calendar event yet. + + ); + } + + return ( + + {hasVideo && ( + + )} + + + ); +}; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/components/CalendarEventRecordingContent.tsx b/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/components/CalendarEventRecordingContent.tsx new file mode 100644 index 0000000000..3b1e5f07ec --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/components/CalendarEventRecordingContent.tsx @@ -0,0 +1,111 @@ +import styled from '@emotion/styled'; +import { useCallback, useState } from 'react'; + +import { CalendarEventRecordingBody } from 'src/front-components/components/CalendarEventRecordingBody'; +import { recordingThemeCssVariables } from 'src/front-components/constants/recording-theme-css-variables'; +import { useCalendarEventParticipants } from 'src/front-components/hooks/use-calendar-event-participants'; +import { useCalendarEventRecording } from 'src/front-components/hooks/use-calendar-event-recording'; + +const TRANSCRIPT_TIME_UPDATE_INTERVAL_SECONDS = 0.25; + +const StyledRecordingShell = styled.div` + background: ${recordingThemeCssVariables.background.primary}; + border: 1px solid transparent; + border-bottom: 1px solid transparent; + border-radius: ${recordingThemeCssVariables.border.radiusMd}; + box-sizing: border-box; + font-family: ${recordingThemeCssVariables.font.family}; + padding: ${recordingThemeCssVariables.spacing[4]}; + position: relative; + width: 100%; +`; + +const StyledRecordingHeader = styled.div` + align-items: center; + box-sizing: border-box; + display: flex; + height: ${recordingThemeCssVariables.spacing[6]}; +`; + +const StyledRecordingTitle = styled.h2` + color: ${recordingThemeCssVariables.font.colorPrimary}; + flex: 1; + font-size: ${recordingThemeCssVariables.font.sizeMd}; + font-weight: ${recordingThemeCssVariables.font.weightMedium}; + margin: 0; + overflow: hidden; + padding-inline: ${recordingThemeCssVariables.spacing[1]}; + user-select: none; +`; + +const StyledRecordingBody = styled.div` + box-sizing: border-box; + margin-top: ${recordingThemeCssVariables.spacing[2]}; +`; + +const StyledRecordingContentFrame = styled.div` + background-color: ${recordingThemeCssVariables.background.secondary}; + border: 1px solid ${recordingThemeCssVariables.border.colorMedium}; + border-radius: ${recordingThemeCssVariables.border.radiusMd}; + box-sizing: border-box; + padding: ${recordingThemeCssVariables.spacing[2]}; +`; + +type CalendarEventRecordingContentProps = { + calendarEventId: string; +}; + +export const CalendarEventRecordingContent = ({ + calendarEventId, +}: CalendarEventRecordingContentProps) => { + const [currentTimeSeconds, setCurrentTimeSeconds] = useState(0); + const updateCurrentTimeSeconds = useCallback( + (videoCurrentTimeSeconds: number) => { + const nextCurrentTimeSeconds = + Math.floor( + videoCurrentTimeSeconds / TRANSCRIPT_TIME_UPDATE_INTERVAL_SECONDS, + ) * TRANSCRIPT_TIME_UPDATE_INTERVAL_SECONDS; + + setCurrentTimeSeconds((previousCurrentTimeSeconds) => + previousCurrentTimeSeconds === nextCurrentTimeSeconds + ? previousCurrentTimeSeconds + : nextCurrentTimeSeconds, + ); + }, + [], + ); + + const { + transcript, + videoFile, + isCalendarEventRecordingQueryLoading, + errorMessage, + } = useCalendarEventRecording(calendarEventId); + const { calendarEventParticipants } = + useCalendarEventParticipants(calendarEventId); + + const videoFileUrl = videoFile?.url ?? undefined; + + return ( + + + Recording and Transcript + + + + + + + + ); +}; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/components/RecordingTranscript.tsx b/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/components/RecordingTranscript.tsx new file mode 100644 index 0000000000..5666e2f5e0 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/components/RecordingTranscript.tsx @@ -0,0 +1,92 @@ +import styled from '@emotion/styled'; +import { isUndefined } from '@sniptt/guards'; +import { useMemo } from 'react'; + +import { TranscriptEntryList } from 'src/front-components/components/TranscriptEntryList'; +import { TranscriptErrorBox } from 'src/front-components/components/TranscriptErrorBox'; +import { recordingThemeCssVariables } from 'src/front-components/constants/recording-theme-css-variables'; +import { type CalendarEventRecordingParticipant } from 'src/front-components/types/calendar-event-recording-participant.type'; +import { parseTranscriptEntries } from 'src/front-components/utils/parse-transcript-entries.util'; +import { parseTranscriptMarker } from 'src/logic-functions/domain/parse-transcript-marker.util'; +import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util'; + +const StyledTranscriptCenteredState = styled.div` + align-items: center; + color: ${recordingThemeCssVariables.font.colorTertiary}; + display: flex; + flex: 1; + font-size: ${recordingThemeCssVariables.font.sizeSm}; + justify-content: center; +`; + +type RecordingTranscriptProps = { + transcript: unknown; + currentTimeSeconds: number; + calendarEventParticipants: CalendarEventRecordingParticipant[]; +}; + +export const RecordingTranscript = ({ + transcript, + currentTimeSeconds, + calendarEventParticipants, +}: RecordingTranscriptProps) => { + const marker = useMemo(() => parseTranscriptMarker(transcript), [transcript]); + const entries = useMemo( + () => parseTranscriptEntries(transcript), + [transcript], + ); + + if (isUndefined(transcript)) { + return ( + + No transcript for this calendar event yet. + + ); + } + + if (marker?.status === 'PENDING') { + return ( + + The transcript is being generated. Check back in a few minutes. + + ); + } + + if (marker?.status === 'FAILED') { + return ( + + ); + } + + if (isUndefined(entries)) { + return ( + + ); + } + + if (entries.length === 0) { + return ( + + The transcript is empty. + + ); + } + + return ( + + ); +}; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/components/RecordingVideoPlayer.tsx b/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/components/RecordingVideoPlayer.tsx new file mode 100644 index 0000000000..40a60ab04a --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/components/RecordingVideoPlayer.tsx @@ -0,0 +1,52 @@ +import styled from '@emotion/styled'; +import { memo, type SyntheticEvent } from 'react'; + +import { recordingThemeCssVariables } from 'src/front-components/constants/recording-theme-css-variables'; + +const DEFAULT_VIDEO_ASPECT_RATIO = '16 / 9'; + +const StyledVideoViewport = styled.div` + aspect-ratio: ${DEFAULT_VIDEO_ASPECT_RATIO}; + background: ${recordingThemeCssVariables.background.primary}; + border-radius: ${recordingThemeCssVariables.border.radiusSm}; + overflow: hidden; + width: 100%; +`; + +const StyledVideo = styled.video` + accent-color: ${recordingThemeCssVariables.accent.primary}; + background: ${recordingThemeCssVariables.background.primary}; + color-scheme: light dark; + display: block; + height: 100%; + object-fit: contain; + width: 100%; +`; + +type RecordingVideoPlayerProps = { + src: string | undefined; + onTimeUpdate: (currentTimeSeconds: number) => void; +}; + +const RecordingVideoPlayerComponent = ({ + src, + onTimeUpdate, +}: RecordingVideoPlayerProps) => { + const handleTimeUpdate = (event: SyntheticEvent) => { + onTimeUpdate(event.currentTarget.currentTime); + }; + + return ( + + + + ); +}; + +export const RecordingVideoPlayer = memo(RecordingVideoPlayerComponent); diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/components/TranscriptEntryList.tsx b/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/components/TranscriptEntryList.tsx new file mode 100644 index 0000000000..43f4b7a479 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/components/TranscriptEntryList.tsx @@ -0,0 +1,61 @@ +import styled from '@emotion/styled'; +import { useMemo } from 'react'; + +import { TranscriptEntryListItem } from 'src/front-components/components/TranscriptEntryListItem'; +import { recordingThemeCssVariables } from 'src/front-components/constants/recording-theme-css-variables'; +import { type CalendarEventRecordingParticipant } from 'src/front-components/types/calendar-event-recording-participant.type'; +import { type TranscriptEntry } from 'src/front-components/types/transcript-entry.type'; +import { buildCalendarEventParticipantBySpeakerName } from 'src/front-components/utils/build-calendar-event-participant-by-speaker-name.util'; +import { findActiveTranscriptEntryIndex } from 'src/front-components/utils/find-active-transcript-entry-index.util'; +import { getCalendarEventParticipantForSpeakerName } from 'src/front-components/utils/get-calendar-event-participant-for-speaker-name.util'; + +const StyledTranscriptContainer = styled.div` + display: flex; + flex: 1; + flex-direction: column; + gap: ${recordingThemeCssVariables.spacing[2]}; + min-height: 0; +`; + +type TranscriptEntryListProps = { + entries: TranscriptEntry[]; + currentTimeSeconds: number; + calendarEventParticipants: CalendarEventRecordingParticipant[]; +}; + +export const TranscriptEntryList = ({ + entries, + currentTimeSeconds, + calendarEventParticipants, +}: TranscriptEntryListProps) => { + const activeEntryIndex = findActiveTranscriptEntryIndex( + entries, + currentTimeSeconds, + ); + const calendarEventParticipantBySpeakerName = useMemo( + () => buildCalendarEventParticipantBySpeakerName(calendarEventParticipants), + [calendarEventParticipants], + ); + + return ( + + {entries.map((entry, entryIndex) => { + const calendarEventParticipant = + getCalendarEventParticipantForSpeakerName({ + speakerName: entry.speakerName, + calendarEventParticipantBySpeakerName, + }); + + return ( + + ); + })} + + ); +}; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/components/TranscriptEntryListItem.tsx b/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/components/TranscriptEntryListItem.tsx new file mode 100644 index 0000000000..044489c48b --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/components/TranscriptEntryListItem.tsx @@ -0,0 +1,115 @@ +import styled from '@emotion/styled'; +import { isUndefined } from '@sniptt/guards'; + +import { TranscriptSpeakerChip } from 'src/front-components/components/TranscriptSpeakerChip'; +import { recordingThemeCssVariables } from 'src/front-components/constants/recording-theme-css-variables'; +import { type CalendarEventRecordingParticipant } from 'src/front-components/types/calendar-event-recording-participant.type'; +import { + type TranscriptEntry, + type TranscriptWord, +} from 'src/front-components/types/transcript-entry.type'; +import { formatTranscriptTimestamp } from 'src/front-components/utils/format-transcript-timestamp.util'; + +const StyledEntry = styled.div<{ $isActive: boolean }>` + align-items: flex-start; + background: ${({ $isActive }) => + $isActive + ? recordingThemeCssVariables.background.transparentBlue + : 'transparent'}; + border-radius: ${recordingThemeCssVariables.border.radiusSm}; + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: ${recordingThemeCssVariables.spacing[2]}; + justify-content: center; + padding: ${recordingThemeCssVariables.spacing[2]}; + width: 100%; +`; + +const StyledEntryHeader = styled.div` + align-items: center; + align-self: stretch; + display: flex; + gap: ${recordingThemeCssVariables.spacing[2]}; + min-height: ${recordingThemeCssVariables.spacing[6]}; + min-width: 0; +`; + +const StyledTimestamp = styled.span` + color: ${recordingThemeCssVariables.font.colorTertiary}; + font-size: ${recordingThemeCssVariables.font.sizeXs}; + line-height: 1.4; +`; + +const StyledEntryText = styled.p` + align-self: stretch; + color: ${recordingThemeCssVariables.font.colorSecondary}; + font-size: ${recordingThemeCssVariables.font.sizeSm}; + line-height: 1.4; + margin: 0; +`; + +const StyledWord = styled.span<{ $isSpoken: boolean }>` + color: ${({ $isSpoken }) => + $isSpoken + ? recordingThemeCssVariables.font.colorPrimary + : recordingThemeCssVariables.font.colorSecondary}; + line-height: 1.4; + transition: color 0.15s ease; +`; + +type TranscriptEntryListItemProps = { + entry: TranscriptEntry; + isActive: boolean; + currentTimeSeconds: number; + calendarEventParticipant: CalendarEventRecordingParticipant | undefined; +}; + +export const TranscriptEntryListItem = ({ + entry, + isActive, + currentTimeSeconds, + calendarEventParticipant, +}: TranscriptEntryListItemProps) => { + const speakerDisplayName = + calendarEventParticipant?.displayName ?? entry.speakerName; + + return ( + + + + {!isUndefined(entry.startSeconds) && ( + + {formatTranscriptTimestamp(entry.startSeconds)} + + )} + + + {entry.words.map((word, wordIndex) => ( + + {wordIndex > 0 ? ' ' : ''} + {word.text} + + ))} + + + ); +}; + +const isWordSpoken = ({ + word, + currentTimeSeconds, +}: { + word: TranscriptWord; + currentTimeSeconds: number; +}): boolean => + !isUndefined(word.startSeconds) && currentTimeSeconds >= word.startSeconds; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/components/TranscriptErrorBox.tsx b/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/components/TranscriptErrorBox.tsx new file mode 100644 index 0000000000..2808bd3f97 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/components/TranscriptErrorBox.tsx @@ -0,0 +1,48 @@ +import styled from '@emotion/styled'; + +import { recordingThemeCssVariables } from 'src/front-components/constants/recording-theme-css-variables'; + +const StyledStateContainer = styled.div` + box-sizing: border-box; + font-family: ${recordingThemeCssVariables.font.family}; + height: 100%; + padding: ${recordingThemeCssVariables.spacing[4]}; +`; + +const StyledErrorBox = styled.div` + background: ${recordingThemeCssVariables.background.transparentDanger}; + border: 1px solid ${recordingThemeCssVariables.border.colorDanger}; + border-radius: ${recordingThemeCssVariables.border.radiusMd}; + display: flex; + flex-direction: column; + gap: ${recordingThemeCssVariables.spacing[1]}; + padding: ${recordingThemeCssVariables.spacing[3]}; +`; + +const StyledErrorTitle = styled.span` + color: ${recordingThemeCssVariables.font.colorDanger}; + font-size: ${recordingThemeCssVariables.font.sizeSm}; + font-weight: ${recordingThemeCssVariables.font.weightMedium}; +`; + +const StyledErrorDescription = styled.span` + color: ${recordingThemeCssVariables.font.colorSecondary}; + font-size: ${recordingThemeCssVariables.font.sizeSm}; +`; + +type TranscriptErrorBoxProps = { + title: string; + description: string; +}; + +export const TranscriptErrorBox = ({ + title, + description, +}: TranscriptErrorBoxProps) => ( + + + {title} + {description} + + +); diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/components/TranscriptSpeakerAvatar.tsx b/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/components/TranscriptSpeakerAvatar.tsx new file mode 100644 index 0000000000..c2695ab2c3 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/components/TranscriptSpeakerAvatar.tsx @@ -0,0 +1,141 @@ +// Duplicates minimal twenty-ui Avatar logic for this app. +// Remove once twenty-ui can be imported safely in front components. +import styled from '@emotion/styled'; +import { useState } from 'react'; + +import { recordingThemeCssVariables } from 'src/front-components/constants/recording-theme-css-variables'; +import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util'; + +const AVATAR_COLOR_NAMES = [ + 'red', + 'ruby', + 'crimson', + 'tomato', + 'orange', + 'amber', + 'yellow', + 'lime', + 'grass', + 'green', + 'jade', + 'mint', + 'turquoise', + 'cyan', + 'sky', + 'blue', + 'iris', + 'violet', + 'purple', + 'plum', + 'pink', + 'bronze', + 'gold', + 'brown', + 'gray', +] as const; + +const StyledAvatar = styled.div<{ + $backgroundColor: string; + $color: string; +}>` + align-items: center; + background: ${({ $backgroundColor }) => $backgroundColor}; + border-radius: 50px; + box-sizing: border-box; + color: ${({ $color }) => $color}; + display: flex; + flex-shrink: 0; + font-size: ${recordingThemeCssVariables.font.sizeXs}; + font-weight: ${recordingThemeCssVariables.font.weightMedium}; + height: 16px; + justify-content: center; + line-height: 15px; + overflow: hidden; + width: 16px; +`; + +const StyledAvatarImage = styled.img` + height: 100%; + object-fit: cover; + width: 100%; +`; + +type TranscriptSpeakerAvatarProps = { + speakerName: string; + avatarUrl: string | undefined; + placeholderColorSeed: string; +}; + +const getSpeakerInitial = (speakerName: string) => + speakerName.trim().charAt(0).toUpperCase() || '-'; + +export const TranscriptSpeakerAvatar = ({ + speakerName, + avatarUrl, + placeholderColorSeed, +}: TranscriptSpeakerAvatarProps) => { + const [erroredAvatarUrl, setErroredAvatarUrl] = useState< + string | undefined + >(undefined); + + const shouldShowAvatarImage = + isNonEmptyString(avatarUrl) && erroredAvatarUrl !== avatarUrl; + + const handleAvatarImageError = () => { + if (isNonEmptyString(avatarUrl)) { + setErroredAvatarUrl(avatarUrl); + } + }; + + const avatarPlaceholderColor = getAvatarPlaceholderColor({ + placeholderColorSeed, + variant: 12, + }); + const avatarPlaceholderBackgroundColor = getAvatarPlaceholderColor({ + placeholderColorSeed, + variant: 4, + }); + + return ( + + ); +}; + +const getAvatarPlaceholderColor = ({ + placeholderColorSeed, + variant, +}: { + placeholderColorSeed: string; + variant: 4 | 12; +}): string => { + const avatarColorName = + AVATAR_COLOR_NAMES[ + Math.abs(hashString(placeholderColorSeed)) % AVATAR_COLOR_NAMES.length + ]; + + return `var(--t-color-${avatarColorName}${variant})`; +}; + +const hashString = (value: string): number => { + let hash = 0; + + for (let valueIndex = 0; valueIndex < value.length; valueIndex++) { + hash = value.charCodeAt(valueIndex) + ((hash << 5) - hash); + } + + return hash; +}; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/components/TranscriptSpeakerChip.tsx b/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/components/TranscriptSpeakerChip.tsx new file mode 100644 index 0000000000..d7863eb42a --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/components/TranscriptSpeakerChip.tsx @@ -0,0 +1,51 @@ +// Duplicates minimal twenty-ui Chip logic for this app. +// Remove once twenty-ui can be imported safely in front components. +import styled from '@emotion/styled'; + +import { TranscriptSpeakerAvatar } from 'src/front-components/components/TranscriptSpeakerAvatar'; +import { recordingThemeCssVariables } from 'src/front-components/constants/recording-theme-css-variables'; + +const StyledSpeakerChip = styled.span` + align-items: center; + border-radius: ${recordingThemeCssVariables.border.radiusSm}; + color: ${recordingThemeCssVariables.font.colorPrimary}; + display: inline-flex; + font-size: ${recordingThemeCssVariables.font.sizeSm}; + font-weight: ${recordingThemeCssVariables.font.weightMedium}; + gap: ${recordingThemeCssVariables.spacing[1]}; + line-height: 1.4; + max-width: 100%; + min-width: 0; + text-decoration: none; + white-space: nowrap; +`; + +const StyledSpeakerName = styled.span` + flex-shrink: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +`; + +type TranscriptSpeakerChipProps = { + speakerName: string; + avatarUrl: string | undefined; + placeholderColorSeed: string; +}; + +export const TranscriptSpeakerChip = ({ + speakerName, + avatarUrl, + placeholderColorSeed, +}: TranscriptSpeakerChipProps) => { + return ( + + + {speakerName} + + ); +}; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/constants/recording-theme-css-variables.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/constants/recording-theme-css-variables.ts new file mode 100644 index 0000000000..a8cce2347f --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/constants/recording-theme-css-variables.ts @@ -0,0 +1,40 @@ +// Avoid the SDK UI entrypoint until its bundle is safe for the browser runtime. +export const recordingThemeCssVariables = { + accent: { + primary: 'var(--t-accent-accent9)', + }, + background: { + primary: 'var(--t-background-primary)', + secondary: 'var(--t-background-secondary)', + transparentBlue: 'var(--t-background-transparent-blue)', + transparentDanger: 'var(--t-background-transparent-danger)', + }, + border: { + colorDanger: 'var(--t-border-color-danger)', + colorLight: 'var(--t-border-color-light)', + colorMedium: 'var(--t-border-color-medium)', + radiusMd: 'var(--t-border-radius-md)', + radiusSm: 'var(--t-border-radius-sm)', + }, + boxShadow: { + light: 'var(--t-box-shadow-light)', + }, + font: { + colorDanger: 'var(--t-font-color-danger)', + colorPrimary: 'var(--t-font-color-primary)', + colorSecondary: 'var(--t-font-color-secondary)', + colorTertiary: 'var(--t-font-color-tertiary)', + family: 'var(--t-font-family)', + sizeMd: 'var(--t-font-size-md)', + sizeSm: 'var(--t-font-size-sm)', + sizeXs: 'var(--t-font-size-xs)', + weightMedium: 'var(--t-font-weight-medium)', + }, + spacing: { + 1: 'var(--t-spacing-1)', + 2: 'var(--t-spacing-2)', + 3: 'var(--t-spacing-3)', + 4: 'var(--t-spacing-4)', + 6: 'var(--t-spacing-6)', + }, +} as const; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/hooks/use-calendar-event-participants.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/hooks/use-calendar-event-participants.ts new file mode 100644 index 0000000000..a1eb0ef14a --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/hooks/use-calendar-event-participants.ts @@ -0,0 +1,174 @@ +import { useEffect, useState } from 'react'; +import { CoreApiClient } from 'twenty-client-sdk/core'; + +import { type CalendarEventRecordingParticipant } from 'src/front-components/types/calendar-event-recording-participant.type'; +import { getAbsoluteAvatarUrl } from 'src/front-components/utils/get-absolute-avatar-url.util'; +import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util'; + +const CALENDAR_EVENT_PARTICIPANT_LOOKUP_LIMIT = 100; + +type CalendarEventParticipantName = { + firstName?: string | null; + lastName?: string | null; +}; + +type CalendarEventParticipantRelatedRecord = { + id?: string | null; + avatarUrl?: string | null; + name?: CalendarEventParticipantName | null; +}; + +type CalendarEventParticipantNode = { + id: string; + displayName?: string | null; + handle?: string | null; + personId?: string | null; + workspaceMemberId?: string | null; + person?: CalendarEventParticipantRelatedRecord | null; + workspaceMember?: CalendarEventParticipantRelatedRecord | null; +}; + +type CalendarEventParticipantEdge = { + node: CalendarEventParticipantNode; +}; + +type UseCalendarEventParticipantsReturn = { + calendarEventParticipants: CalendarEventRecordingParticipant[]; +}; + +export const useCalendarEventParticipants = ( + calendarEventId: string | undefined, +): UseCalendarEventParticipantsReturn => { + const [calendarEventParticipants, setCalendarEventParticipants] = useState< + CalendarEventRecordingParticipant[] + >([]); + + useEffect(() => { + if (!isNonEmptyString(calendarEventId)) { + setCalendarEventParticipants([]); + return; + } + + let cancelled = false; + + const fetchCalendarEventParticipants = async () => { + try { + const client = new CoreApiClient(); + const queryResult = await client.query({ + calendarEventParticipants: { + __args: { + filter: { calendarEventId: { eq: calendarEventId } }, + first: CALENDAR_EVENT_PARTICIPANT_LOOKUP_LIMIT, + }, + edges: { + node: { + id: true, + displayName: true, + handle: true, + personId: true, + workspaceMemberId: true, + person: { + id: true, + avatarUrl: true, + name: { + firstName: true, + lastName: true, + }, + }, + workspaceMember: { + id: true, + avatarUrl: true, + name: { + firstName: true, + lastName: true, + }, + }, + }, + }, + }, + }); + + if (cancelled) { + return; + } + + const calendarEventParticipantEdges = (queryResult + .calendarEventParticipants?.edges ?? + []) as CalendarEventParticipantEdge[]; + + setCalendarEventParticipants( + calendarEventParticipantEdges.map((calendarEventParticipantEdge) => + mapCalendarEventParticipantNode( + calendarEventParticipantEdge.node, + ), + ), + ); + } catch { + if (cancelled) { + return; + } + + setCalendarEventParticipants([]); + } + }; + + fetchCalendarEventParticipants(); + + return () => { + cancelled = true; + }; + }, [calendarEventId]); + + return { calendarEventParticipants }; +}; + +const mapCalendarEventParticipantNode = ( + calendarEventParticipantNode: CalendarEventParticipantNode, +): CalendarEventRecordingParticipant => { + const personName = readFullName(calendarEventParticipantNode.person?.name); + const workspaceMemberName = readFullName( + calendarEventParticipantNode.workspaceMember?.name, + ); + const calendarDisplayName = readOptionalString( + calendarEventParticipantNode.displayName, + ); + const handle = readOptionalString(calendarEventParticipantNode.handle); + + return { + id: calendarEventParticipantNode.id, + avatarUrl: getAbsoluteAvatarUrl( + calendarEventParticipantNode.person?.avatarUrl ?? + calendarEventParticipantNode.workspaceMember?.avatarUrl, + ), + displayName: + personName ?? workspaceMemberName ?? calendarDisplayName ?? handle, + nameCandidates: [ + calendarDisplayName, + personName, + workspaceMemberName, + handle, + ].filter((nameCandidate): nameCandidate is string => + isNonEmptyString(nameCandidate), + ), + placeholderColorSeed: + calendarEventParticipantNode.workspaceMemberId ?? + calendarEventParticipantNode.personId ?? + calendarEventParticipantNode.id, + }; +}; + +const readFullName = ( + name: CalendarEventParticipantName | null | undefined, +): string | undefined => { + const firstName = readOptionalString(name?.firstName); + const lastName = readOptionalString(name?.lastName); + const fullName = [firstName, lastName] + .filter((namePart): namePart is string => isNonEmptyString(namePart)) + .join(' '); + + return isNonEmptyString(fullName) ? fullName : undefined; +}; + +const readOptionalString = ( + value: string | null | undefined, +): string | undefined => (isNonEmptyString(value) ? value.trim() : undefined); diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/hooks/use-calendar-event-recording.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/hooks/use-calendar-event-recording.ts new file mode 100644 index 0000000000..d1bb8803e4 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/hooks/use-calendar-event-recording.ts @@ -0,0 +1,156 @@ +import { isUndefined } from '@sniptt/guards'; +import { useEffect, useState } from 'react'; +import { CoreApiClient } from 'twenty-client-sdk/core'; + +type CalendarEventRecordingState = { + transcript: unknown; + videoFile: CalendarEventRecordingVideoFile | undefined; + isCalendarEventRecordingQueryLoading: boolean; + errorMessage: string | undefined; +}; + +type CalendarEventRecordingVideoFile = { + fileId: string; + label: string | null; + url: string | null; + extension: string | null; +}; + +type CalendarEventRecordingCallRecordingNode = { + id: string; + transcript: unknown; + video: CalendarEventRecordingVideoFile[] | null; +}; + +type CalendarEventRecordingCallRecordingEdge = { + node: CalendarEventRecordingCallRecordingNode; +}; + +const CALENDAR_EVENT_RECORDING_LOOKUP_LIMIT = 10; +const CALENDAR_EVENT_RECORDING_ERROR_MESSAGE = 'Please try again later.'; + +export const useCalendarEventRecording = ( + calendarEventId: string | undefined, +): CalendarEventRecordingState => { + const [state, setState] = useState({ + transcript: undefined, + videoFile: undefined, + isCalendarEventRecordingQueryLoading: !isUndefined(calendarEventId), + errorMessage: undefined, + }); + + useEffect(() => { + if (isUndefined(calendarEventId)) { + setState({ + transcript: undefined, + videoFile: undefined, + isCalendarEventRecordingQueryLoading: false, + errorMessage: undefined, + }); + return; + } + + let cancelled = false; + + const fetchRecording = async () => { + setState({ + transcript: undefined, + videoFile: undefined, + isCalendarEventRecordingQueryLoading: true, + errorMessage: undefined, + }); + + try { + const client = new CoreApiClient(); + const queryResult = await client.query({ + callRecordings: { + __args: { + filter: { calendarEventId: { eq: calendarEventId } }, + orderBy: [{ startedAt: 'DescNullsLast' }], + first: CALENDAR_EVENT_RECORDING_LOOKUP_LIMIT, + }, + edges: { + node: { + id: true, + transcript: true, + video: { + fileId: true, + label: true, + url: true, + extension: true, + }, + }, + }, + }, + }); + + if (cancelled) { + return; + } + + const callRecordingEdges = (queryResult.callRecordings?.edges ?? + []) as CalendarEventRecordingCallRecordingEdge[]; + const callRecordingNodes = callRecordingEdges.map( + (callRecordingEdge) => callRecordingEdge.node, + ); + const callRecordingNode = selectCalendarEventRecording( + callRecordingNodes, + ); + + setState({ + transcript: callRecordingNode?.transcript ?? undefined, + videoFile: isUndefined(callRecordingNode) + ? undefined + : getVideoFile(callRecordingNode), + isCalendarEventRecordingQueryLoading: false, + errorMessage: undefined, + }); + } catch { + if (cancelled) { + return; + } + + setState({ + transcript: undefined, + videoFile: undefined, + isCalendarEventRecordingQueryLoading: false, + errorMessage: CALENDAR_EVENT_RECORDING_ERROR_MESSAGE, + }); + } + }; + + fetchRecording(); + + return () => { + cancelled = true; + }; + }, [calendarEventId]); + + return state; +}; + +const hasTranscript = ( + callRecordingNode: CalendarEventRecordingCallRecordingNode, +): boolean => + !isUndefined(callRecordingNode.transcript) && + callRecordingNode.transcript !== null; + +const getVideoFile = ( + callRecordingNode: CalendarEventRecordingCallRecordingNode, +): CalendarEventRecordingVideoFile | undefined => + callRecordingNode.video?.find( + (videoFile) => !isUndefined(videoFile.url) && videoFile.url !== null, + ); + +const selectCalendarEventRecording = ( + callRecordingNodes: CalendarEventRecordingCallRecordingNode[], +): CalendarEventRecordingCallRecordingNode | undefined => + callRecordingNodes.find( + (callRecordingNode) => + hasTranscript(callRecordingNode) && + !isUndefined(getVideoFile(callRecordingNode)), + ) ?? + callRecordingNodes.find(hasTranscript) ?? + callRecordingNodes.find( + (callRecordingNode) => !isUndefined(getVideoFile(callRecordingNode)), + ); diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/types/calendar-event-participant-by-speaker-name.type.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/types/calendar-event-participant-by-speaker-name.type.ts new file mode 100644 index 0000000000..c6fbe5e000 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/types/calendar-event-participant-by-speaker-name.type.ts @@ -0,0 +1,6 @@ +import { type CalendarEventRecordingParticipant } from 'src/front-components/types/calendar-event-recording-participant.type'; + +export type CalendarEventParticipantBySpeakerName = Map< + string, + CalendarEventRecordingParticipant +>; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/types/calendar-event-recording-participant.type.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/types/calendar-event-recording-participant.type.ts new file mode 100644 index 0000000000..ec760f1bb6 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/types/calendar-event-recording-participant.type.ts @@ -0,0 +1,7 @@ +export type CalendarEventRecordingParticipant = { + id: string; + avatarUrl: string | undefined; + displayName: string | undefined; + nameCandidates: string[]; + placeholderColorSeed: string; +}; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/types/transcript-entry.type.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/types/transcript-entry.type.ts new file mode 100644 index 0000000000..0be2540670 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/types/transcript-entry.type.ts @@ -0,0 +1,13 @@ +export type TranscriptWord = { + text: string; + startSeconds: number | undefined; + endSeconds: number | undefined; +}; + +export type TranscriptEntry = { + speakerName: string; + startSeconds: number | undefined; + endSeconds: number | undefined; + text: string; + words: TranscriptWord[]; +}; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/utils/__tests__/find-active-transcript-entry-index.test.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/utils/__tests__/find-active-transcript-entry-index.test.ts new file mode 100644 index 0000000000..5616dbef33 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/utils/__tests__/find-active-transcript-entry-index.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; + +import { type TranscriptEntry } from 'src/front-components/types/transcript-entry.type'; +import { findActiveTranscriptEntryIndex } from 'src/front-components/utils/find-active-transcript-entry-index.util'; + +const makeTranscriptEntry = ({ + startSeconds, + endSeconds, +}: { + startSeconds: number | undefined; + endSeconds: number | undefined; +}): TranscriptEntry => ({ + speakerName: 'Ada Lovelace', + startSeconds, + endSeconds, + text: 'Hello', + words: [{ text: 'Hello', startSeconds, endSeconds }], +}); + +describe('findActiveTranscriptEntryIndex', () => { + it('does not keep an open-ended entry active after the next entry starts', () => { + expect( + findActiveTranscriptEntryIndex( + [ + makeTranscriptEntry({ startSeconds: 1, endSeconds: undefined }), + makeTranscriptEntry({ startSeconds: 10, endSeconds: 20 }), + ], + 25, + ), + ).toBe(-1); + }); + + it('uses the next known start as the boundary for entries without an end time', () => { + expect( + findActiveTranscriptEntryIndex( + [ + makeTranscriptEntry({ startSeconds: 1, endSeconds: undefined }), + makeTranscriptEntry({ startSeconds: 10, endSeconds: 20 }), + ], + 9, + ), + ).toBe(0); + + expect( + findActiveTranscriptEntryIndex( + [ + makeTranscriptEntry({ startSeconds: 1, endSeconds: undefined }), + makeTranscriptEntry({ startSeconds: 10, endSeconds: 20 }), + ], + 10, + ), + ).toBe(1); + }); + + it('keeps the final open-ended entry active after it starts', () => { + expect( + findActiveTranscriptEntryIndex( + [ + makeTranscriptEntry({ startSeconds: 1, endSeconds: 2 }), + makeTranscriptEntry({ startSeconds: 10, endSeconds: undefined }), + ], + 25, + ), + ).toBe(1); + }); +}); diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/utils/__tests__/format-transcript-timestamp.test.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/utils/__tests__/format-transcript-timestamp.test.ts new file mode 100644 index 0000000000..79eddbd8de --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/utils/__tests__/format-transcript-timestamp.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; + +import { formatTranscriptTimestamp } from 'src/front-components/utils/format-transcript-timestamp.util'; + +describe('formatTranscriptTimestamp', () => { + it('formats sub-hour durations as minutes and padded seconds', () => { + expect(formatTranscriptTimestamp(0)).toBe('0:00'); + expect(formatTranscriptTimestamp(5)).toBe('0:05'); + expect(formatTranscriptTimestamp(65)).toBe('1:05'); + expect(formatTranscriptTimestamp(3599)).toBe('59:59'); + }); + + it('adds an hour part with padded minutes past one hour', () => { + expect(formatTranscriptTimestamp(3600)).toBe('1:00:00'); + expect(formatTranscriptTimestamp(3725)).toBe('1:02:05'); + expect(formatTranscriptTimestamp(7322)).toBe('2:02:02'); + }); + + it('floors fractional seconds', () => { + expect(formatTranscriptTimestamp(1.9)).toBe('0:01'); + expect(formatTranscriptTimestamp(59.999)).toBe('0:59'); + }); + + it('clamps negative and non-finite input to zero', () => { + expect(formatTranscriptTimestamp(-12)).toBe('0:00'); + expect(formatTranscriptTimestamp(Number.NaN)).toBe('0:00'); + expect(formatTranscriptTimestamp(Number.POSITIVE_INFINITY)).toBe('0:00'); + }); +}); diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/utils/__tests__/get-speaker-name-match-keys.test.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/utils/__tests__/get-speaker-name-match-keys.test.ts new file mode 100644 index 0000000000..32711416bd --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/utils/__tests__/get-speaker-name-match-keys.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; + +import { getSpeakerNameMatchKeys } from 'src/front-components/utils/get-speaker-name-match-keys.util'; + +describe('getSpeakerNameMatchKeys', () => { + it('matches transcript full names to compact calendar aliases', () => { + expect(getSpeakerNameMatchKeys('Martin Muller')).toContain('martmull'); + expect(getSpeakerNameMatchKeys('Martmull92')).toContain('martmull'); + }); + + it('keeps exact normalized full names available for regular participant names', () => { + expect(getSpeakerNameMatchKeys('Nitin Koche')).toEqual([ + 'nitin koche', + 'nitinkoche', + 'nitikoch', + ]); + }); + + it('folds accents before generating compact match keys', () => { + expect(getSpeakerNameMatchKeys('Martin Müller')).toContain('martmull'); + }); +}); diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/utils/__tests__/parse-transcript-entries.test.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/utils/__tests__/parse-transcript-entries.test.ts new file mode 100644 index 0000000000..998896dc27 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/utils/__tests__/parse-transcript-entries.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it } from 'vitest'; + +import { parseTranscriptEntries } from 'src/front-components/utils/parse-transcript-entries.util'; + +describe('parseTranscriptEntries', () => { + it('parses diarized entries into speaker, start time, and joined text', () => { + expect( + parseTranscriptEntries([ + { + participant: { id: 100, name: 'Ada Lovelace' }, + words: [ + { + text: 'Hello', + start_timestamp: { + relative: 1.2, + absolute: '2026-06-12T10:00:01Z', + }, + end_timestamp: { + relative: 1.6, + absolute: '2026-06-12T10:00:01Z', + }, + }, + { + text: 'there', + start_timestamp: { + relative: 1.7, + absolute: '2026-06-12T10:00:02Z', + }, + end_timestamp: { + relative: 2.1, + absolute: '2026-06-12T10:00:02Z', + }, + }, + ], + }, + { + participant: { id: 101, name: 'Grace Hopper' }, + words: [ + { + text: 'Hi', + start_timestamp: { + relative: 3.4, + absolute: '2026-06-12T10:00:03Z', + }, + }, + ], + }, + ]), + ).toEqual([ + { + speakerName: 'Ada Lovelace', + startSeconds: 1.2, + endSeconds: 2.1, + text: 'Hello there', + words: [ + { text: 'Hello', startSeconds: 1.2, endSeconds: 1.6 }, + { text: 'there', startSeconds: 1.7, endSeconds: 2.1 }, + ], + }, + { + speakerName: 'Grace Hopper', + startSeconds: 3.4, + endSeconds: undefined, + text: 'Hi', + words: [{ text: 'Hi', startSeconds: 3.4, endSeconds: undefined }], + }, + ]); + }); + + it('falls back to an unknown speaker when the participant has no name', () => { + expect( + parseTranscriptEntries([ + { participant: { id: 100, name: null }, words: [{ text: 'Hello' }] }, + { words: [{ text: 'Hi' }] }, + ]), + ).toEqual([ + { + speakerName: 'Unknown speaker', + startSeconds: undefined, + endSeconds: undefined, + text: 'Hello', + words: [ + { text: 'Hello', startSeconds: undefined, endSeconds: undefined }, + ], + }, + { + speakerName: 'Unknown speaker', + startSeconds: undefined, + endSeconds: undefined, + text: 'Hi', + words: [{ text: 'Hi', startSeconds: undefined, endSeconds: undefined }], + }, + ]); + }); + + it('returns an undefined start time when the first word has no relative timestamp', () => { + expect( + parseTranscriptEntries([ + { + participant: { name: 'Ada Lovelace' }, + words: [ + { + text: 'Hello', + start_timestamp: { absolute: '2026-06-12T10:00:01Z' }, + }, + ], + }, + ]), + ).toEqual([ + { + speakerName: 'Ada Lovelace', + startSeconds: undefined, + endSeconds: undefined, + text: 'Hello', + words: [ + { text: 'Hello', startSeconds: undefined, endSeconds: undefined }, + ], + }, + ]); + }); + + it('skips entries without usable words instead of failing the whole transcript', () => { + expect( + parseTranscriptEntries([ + { participant: { name: 'Ada Lovelace' }, words: [] }, + { participant: { name: 'Grace Hopper' } }, + { + participant: { name: 'Alan Turing' }, + words: [{ text: ' ' }, 42, null], + }, + { participant: { name: 'Joan Clarke' }, words: [{ text: 'Kept' }] }, + 'not an entry', + ]), + ).toEqual([ + { + speakerName: 'Joan Clarke', + startSeconds: undefined, + endSeconds: undefined, + text: 'Kept', + words: [ + { text: 'Kept', startSeconds: undefined, endSeconds: undefined }, + ], + }, + ]); + }); + + it('returns an empty list for an empty transcript array', () => { + expect(parseTranscriptEntries([])).toEqual([]); + }); + + it('returns undefined for values that are not a diarized transcript array', () => { + expect(parseTranscriptEntries(null)).toBeUndefined(); + expect(parseTranscriptEntries(undefined)).toBeUndefined(); + expect(parseTranscriptEntries('transcript text')).toBeUndefined(); + expect( + parseTranscriptEntries({ + recallTranscriptId: 'recall-transcript-1', + status: 'PENDING', + }), + ).toBeUndefined(); + }); +}); diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/utils/build-calendar-event-participant-by-speaker-name.util.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/utils/build-calendar-event-participant-by-speaker-name.util.ts new file mode 100644 index 0000000000..cb70604e1a --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/utils/build-calendar-event-participant-by-speaker-name.util.ts @@ -0,0 +1,45 @@ +import { isUndefined } from '@sniptt/guards'; + +import { type CalendarEventParticipantBySpeakerName } from 'src/front-components/types/calendar-event-participant-by-speaker-name.type'; +import { type CalendarEventRecordingParticipant } from 'src/front-components/types/calendar-event-recording-participant.type'; +import { getSpeakerNameMatchKeys } from 'src/front-components/utils/get-speaker-name-match-keys.util'; + +export const buildCalendarEventParticipantBySpeakerName = ( + calendarEventParticipants: CalendarEventRecordingParticipant[], +): CalendarEventParticipantBySpeakerName => { + const calendarEventParticipantBySpeakerName: CalendarEventParticipantBySpeakerName = + new Map(); + const ambiguousSpeakerNameMatchKeys = new Set(); + + for (const calendarEventParticipant of calendarEventParticipants) { + for (const nameCandidate of calendarEventParticipant.nameCandidates) { + const speakerNameMatchKeys = getSpeakerNameMatchKeys(nameCandidate); + + for (const speakerNameMatchKey of speakerNameMatchKeys) { + const matchingCalendarEventParticipant = + calendarEventParticipantBySpeakerName.get(speakerNameMatchKey); + + if (ambiguousSpeakerNameMatchKeys.has(speakerNameMatchKey)) { + continue; + } + + if (isUndefined(matchingCalendarEventParticipant)) { + calendarEventParticipantBySpeakerName.set( + speakerNameMatchKey, + calendarEventParticipant, + ); + continue; + } + + if ( + matchingCalendarEventParticipant.id !== calendarEventParticipant.id + ) { + calendarEventParticipantBySpeakerName.delete(speakerNameMatchKey); + ambiguousSpeakerNameMatchKeys.add(speakerNameMatchKey); + } + } + } + } + + return calendarEventParticipantBySpeakerName; +}; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/utils/find-active-transcript-entry-index.util.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/utils/find-active-transcript-entry-index.util.ts new file mode 100644 index 0000000000..54fcd26283 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/utils/find-active-transcript-entry-index.util.ts @@ -0,0 +1,77 @@ +import { isUndefined } from '@sniptt/guards'; + +import { type TranscriptEntry } from 'src/front-components/types/transcript-entry.type'; + +export const findActiveTranscriptEntryIndex = ( + entries: TranscriptEntry[], + currentTimeSeconds: number, +): number => { + for (let entryIndex = entries.length - 1; entryIndex >= 0; entryIndex--) { + const entry = entries[entryIndex]; + + if ( + isTranscriptEntryActive({ + entries, + entry, + entryIndex, + currentTimeSeconds, + }) + ) { + return entryIndex; + } + } + + return -1; +}; + +const isTranscriptEntryActive = ({ + entries, + entry, + entryIndex, + currentTimeSeconds, +}: { + entries: TranscriptEntry[]; + entry: TranscriptEntry; + entryIndex: number; + currentTimeSeconds: number; +}): boolean => { + if ( + isUndefined(entry.startSeconds) || + currentTimeSeconds < entry.startSeconds + ) { + return false; + } + + if (!isUndefined(entry.endSeconds)) { + return currentTimeSeconds <= entry.endSeconds; + } + + const nextTranscriptEntryStartSeconds = findNextTranscriptEntryStartSeconds( + entries, + entryIndex, + ); + + return isUndefined(nextTranscriptEntryStartSeconds) + ? true + : currentTimeSeconds < nextTranscriptEntryStartSeconds; +}; + +const findNextTranscriptEntryStartSeconds = ( + entries: TranscriptEntry[], + entryIndex: number, +): number | undefined => { + for ( + let nextEntryIndex = entryIndex + 1; + nextEntryIndex < entries.length; + nextEntryIndex++ + ) { + const nextTranscriptEntryStartSeconds = + entries[nextEntryIndex].startSeconds; + + if (!isUndefined(nextTranscriptEntryStartSeconds)) { + return nextTranscriptEntryStartSeconds; + } + } + + return undefined; +}; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/utils/format-transcript-timestamp.util.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/utils/format-transcript-timestamp.util.ts new file mode 100644 index 0000000000..1551203ece --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/utils/format-transcript-timestamp.util.ts @@ -0,0 +1,16 @@ +export const formatTranscriptTimestamp = (totalSeconds: number): string => { + const safeSeconds = Number.isFinite(totalSeconds) + ? Math.max(0, Math.floor(totalSeconds)) + : 0; + + const hours = Math.floor(safeSeconds / 3600); + const minutes = Math.floor((safeSeconds % 3600) / 60); + const seconds = safeSeconds % 60; + const paddedSeconds = String(seconds).padStart(2, '0'); + + if (hours > 0) { + return `${hours}:${String(minutes).padStart(2, '0')}:${paddedSeconds}`; + } + + return `${minutes}:${paddedSeconds}`; +}; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/utils/get-absolute-avatar-url.util.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/utils/get-absolute-avatar-url.util.ts new file mode 100644 index 0000000000..404717f70c --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/utils/get-absolute-avatar-url.util.ts @@ -0,0 +1,48 @@ +// Duplicates minimal front image URL logic for this app. +// Remove once shared front utilities can be imported safely in front components. +import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util'; + +type GetImageAbsoluteUrlArgs = { + imageUrl: string; + baseUrl: string; +}; + +const getImageAbsoluteUrl = ({ + imageUrl, + baseUrl, +}: GetImageAbsoluteUrlArgs): string => { + const lowerCaseImageUrl = imageUrl.toLowerCase(); + const isAlreadyAbsoluteUrl = + ['http:', 'https:', 'data:', 'blob:'].some((scheme) => + lowerCaseImageUrl.startsWith(scheme), + ) || imageUrl.startsWith('//'); + + if (isAlreadyAbsoluteUrl) { + return imageUrl; + } + + if (imageUrl.startsWith('/')) { + return new URL(`/files${imageUrl}`, baseUrl).toString(); + } + + return new URL(`/files/${imageUrl}`, baseUrl).toString(); +}; + +export const getAbsoluteAvatarUrl = ( + avatarUrl: string | null | undefined, +): string | undefined => { + if (!isNonEmptyString(avatarUrl)) { + return undefined; + } + + const apiBaseUrl = process.env.TWENTY_API_URL; + + if (!isNonEmptyString(apiBaseUrl)) { + return avatarUrl.trim(); + } + + return getImageAbsoluteUrl({ + imageUrl: avatarUrl.trim(), + baseUrl: apiBaseUrl, + }); +}; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/utils/get-calendar-event-participant-for-speaker-name.util.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/utils/get-calendar-event-participant-for-speaker-name.util.ts new file mode 100644 index 0000000000..261f4dc79b --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/utils/get-calendar-event-participant-for-speaker-name.util.ts @@ -0,0 +1,24 @@ +import { isUndefined } from '@sniptt/guards'; + +import { type CalendarEventParticipantBySpeakerName } from 'src/front-components/types/calendar-event-participant-by-speaker-name.type'; +import { type CalendarEventRecordingParticipant } from 'src/front-components/types/calendar-event-recording-participant.type'; +import { getSpeakerNameMatchKeys } from 'src/front-components/utils/get-speaker-name-match-keys.util'; + +export const getCalendarEventParticipantForSpeakerName = ({ + speakerName, + calendarEventParticipantBySpeakerName, +}: { + speakerName: string; + calendarEventParticipantBySpeakerName: CalendarEventParticipantBySpeakerName; +}): CalendarEventRecordingParticipant | undefined => { + for (const speakerNameMatchKey of getSpeakerNameMatchKeys(speakerName)) { + const calendarEventParticipant = + calendarEventParticipantBySpeakerName.get(speakerNameMatchKey); + + if (!isUndefined(calendarEventParticipant)) { + return calendarEventParticipant; + } + } + + return undefined; +}; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/utils/get-speaker-name-match-keys.util.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/utils/get-speaker-name-match-keys.util.ts new file mode 100644 index 0000000000..c570effd71 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/utils/get-speaker-name-match-keys.util.ts @@ -0,0 +1,64 @@ +import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util'; + +const MINIMUM_FUZZY_MATCH_KEY_LENGTH = 5; + +export const getSpeakerNameMatchKeys = (speakerName: string): string[] => { + const normalizedSpeakerName = normalizeSpeakerName(speakerName); + const compactSpeakerName = getCompactSpeakerName(normalizedSpeakerName); + const compactSpeakerNameWithoutDigits = compactSpeakerName.replace(/\d/g, ''); + const abbreviatedSpeakerNameMatchKey = + getAbbreviatedSpeakerNameMatchKey(normalizedSpeakerName); + + return [ + ...new Set( + [ + normalizedSpeakerName, + compactSpeakerName, + compactSpeakerNameWithoutDigits, + abbreviatedSpeakerNameMatchKey, + ].filter(isSpeakerNameMatchKey), + ), + ]; +}; + +const normalizeSpeakerName = (speakerName: string): string => + speakerName + .trim() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .toLocaleLowerCase(); + +const getCompactSpeakerName = (speakerName: string): string => + normalizeSpeakerName(speakerName).replace(/[^a-z0-9]/g, ''); + +const getAbbreviatedSpeakerNameMatchKey = ( + speakerName: string, +): string | undefined => { + const speakerNameParts = normalizeSpeakerName(speakerName) + .split(/\s+/) + .map(getCompactSpeakerName) + .filter(isNonEmptyString); + + if (speakerNameParts.length < 2) { + return undefined; + } + + const firstSpeakerNamePart = speakerNameParts[0]; + const lastSpeakerNamePart = speakerNameParts[speakerNameParts.length - 1]; + const abbreviatedSpeakerNameMatchKey = `${firstSpeakerNamePart.slice( + 0, + 4, + )}${lastSpeakerNamePart.slice(0, 4)}`; + + return abbreviatedSpeakerNameMatchKey.length >= + MINIMUM_FUZZY_MATCH_KEY_LENGTH + ? abbreviatedSpeakerNameMatchKey + : undefined; +}; + +const isSpeakerNameMatchKey = ( + speakerNameMatchKey: string | undefined, +): speakerNameMatchKey is string => + isNonEmptyString(speakerNameMatchKey) && + (speakerNameMatchKey.includes(' ') || + speakerNameMatchKey.length >= MINIMUM_FUZZY_MATCH_KEY_LENGTH); diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/utils/get-video-file-extension.util.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/utils/get-video-file-extension.util.ts new file mode 100644 index 0000000000..7dbfd4efab --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/utils/get-video-file-extension.util.ts @@ -0,0 +1,23 @@ +import { isUndefined } from '@sniptt/guards'; + +import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util'; + +export const getVideoFileExtension = ({ + extension, + label, +}: { + extension: string | null; + label: string | null; +}): string | undefined => { + const labelParts = label?.split('.'); + const videoFileExtension = + extension ?? + (isUndefined(labelParts) ? undefined : labelParts[labelParts.length - 1]); + const normalizedVideoFileExtension = videoFileExtension + ?.toLowerCase() + .replace(/^\./, ''); + + return isNonEmptyString(normalizedVideoFileExtension) + ? normalizedVideoFileExtension + : undefined; +}; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/utils/parse-transcript-entries.util.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/utils/parse-transcript-entries.util.ts new file mode 100644 index 0000000000..e35a79b6e5 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/front-components/utils/parse-transcript-entries.util.ts @@ -0,0 +1,87 @@ +import { isArray, isNumber, isUndefined } from '@sniptt/guards'; + +import { + type TranscriptEntry, + type TranscriptWord, +} from 'src/front-components/types/transcript-entry.type'; +import { asRecord } from 'src/logic-functions/utils/as-record.util'; +import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util'; + +type TranscriptRecord = NonNullable>; + +const isTranscriptRecord = ( + candidate: TranscriptRecord | undefined, +): candidate is TranscriptRecord => !isUndefined(candidate); + +const readRelativeTimestamp = ( + timestamp: TranscriptRecord | undefined, +): number | undefined => { + const relativeTimestamp = timestamp?.relative; + + return isNumber(relativeTimestamp) && Number.isFinite(relativeTimestamp) + ? relativeTimestamp + : undefined; +}; + +const readTranscriptWord = ( + candidate: TranscriptRecord, +): TranscriptWord | undefined => { + if (!isNonEmptyString(candidate.text)) { + return undefined; + } + + return { + text: candidate.text.trim(), + startSeconds: readRelativeTimestamp(asRecord(candidate.start_timestamp)), + endSeconds: readRelativeTimestamp(asRecord(candidate.end_timestamp)), + }; +}; + +const readSpeakerName = ( + participant: TranscriptRecord | undefined, +): string => { + const name = participant?.name; + + return isNonEmptyString(name) ? name.trim() : 'Unknown speaker'; +}; + +const readTranscriptEntry = ( + candidate: TranscriptRecord, +): TranscriptEntry | undefined => { + if (!isArray(candidate.words)) { + return undefined; + } + + const words = candidate.words + .map(asRecord) + .filter(isTranscriptRecord) + .map(readTranscriptWord) + .filter((word): word is TranscriptWord => !isUndefined(word)); + + if (words.length === 0) { + return undefined; + } + + return { + speakerName: readSpeakerName(asRecord(candidate.participant)), + startSeconds: words[0].startSeconds, + endSeconds: words[words.length - 1].endSeconds, + text: words.map((word) => word.text).join(' '), + words, + }; +}; + +// Undefined means the value is not a diarized transcript; malformed entries are skipped, not fatal. +export const parseTranscriptEntries = ( + transcript: unknown, +): TranscriptEntry[] | undefined => { + if (!isArray(transcript)) { + return undefined; + } + + return transcript + .map(asRecord) + .filter(isTranscriptRecord) + .map(readTranscriptEntry) + .filter((entry): entry is TranscriptEntry => !isUndefined(entry)); +}; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/__tests__/recall-webhook.test.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/__tests__/recall-webhook.test.ts new file mode 100644 index 0000000000..3a20df91e5 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/__tests__/recall-webhook.test.ts @@ -0,0 +1,194 @@ +import { createHmac } from 'crypto'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import recallWebhookLogicFunction, { + recallWebhookRouteHandler, +} from 'src/logic-functions/recall-webhook'; + +const getApplicationVariableValueMock = vi.hoisted(() => vi.fn()); +const handleRecallWebhookMock = vi.hoisted(() => vi.fn()); + +vi.mock( + 'src/logic-functions/utils/get-application-variable-value.util', + () => ({ + getApplicationVariableValue: getApplicationVariableValueMock, + }), +); + +vi.mock('src/logic-functions/flows/handle-recall-webhook.util', () => ({ + handleRecallWebhook: handleRecallWebhookMock, +})); + +vi.mock('twenty-client-sdk/core', () => ({ + CoreApiClient: vi.fn(), +})); + +const SECRET_BYTES = Buffer.from('entry-test-secret'); +const SECRET = `whsec_${SECRET_BYTES.toString('base64')}`; +const WORKSPACE_ID = '123e4567-e89b-12d3-a456-426614174000'; + +type RecallWebhookRoutePayload = Parameters< + typeof recallWebhookRouteHandler +>[0]; + +const buildRoutePayload = ( + overrides: Partial, +): RecallWebhookRoutePayload => + ({ + headers: {}, + ...overrides, + }) as RecallWebhookRoutePayload; + +const buildSignedHeaders = (rawBody: string): Record => { + const webhookId = 'msg_entry_test'; + const webhookTimestamp = Math.floor(Date.now() / 1000).toString(); + const signature = createHmac('sha256', SECRET_BYTES) + .update(`${webhookId}.${webhookTimestamp}.${rawBody}`) + .digest('base64'); + + return { + 'webhook-id': webhookId, + 'webhook-timestamp': webhookTimestamp, + 'webhook-signature': `v1,${signature}`, + }; +}; + +const buildRecordingDoneWebhookBody = () => ({ + event: 'recording.done', + data: { + bot: { + id: 'recall-bot-1', + metadata: { + twentyWorkspaceId: WORKSPACE_ID, + }, + }, + recording: { + id: 'recall-recording-1', + }, + }, +}); + +describe('recallWebhookRouteHandler', () => { + beforeEach(() => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + getApplicationVariableValueMock.mockReset(); + getApplicationVariableValueMock.mockReturnValue(SECRET); + handleRecallWebhookMock.mockReset(); + handleRecallWebhookMock.mockResolvedValue({ status: 'updated' }); + }); + + it('declares a server webhook resolver for Recall bot workspace metadata', () => { + expect(recallWebhookLogicFunction.success).toBe(true); + expect( + recallWebhookLogicFunction.config.httpRouteTriggerSettings, + ).toBeUndefined(); + expect( + recallWebhookLogicFunction.config.serverWebhookTriggerSettings, + ).toEqual({ + workspaceIdResolver: { + source: 'body', + path: 'data.bot.metadata.twentyWorkspaceId', + }, + forwardedRequestHeaders: [ + 'webhook-id', + 'webhook-timestamp', + 'webhook-signature', + 'svix-id', + 'svix-timestamp', + 'svix-signature', + ], + }); + }); + + it('responds 500 when the webhook secret is not configured', async () => { + getApplicationVariableValueMock.mockReturnValue(undefined); + + const result = await recallWebhookRouteHandler( + buildRoutePayload({ rawBody: '{}', body: {} }), + ); + + expect(result).toMatchObject({ + __twentyHttpResponse: true, + status: 500, + body: { + error: expect.stringContaining('RECALL_WEBHOOK_SECRET'), + }, + }); + }); + + it('responds 500 when the raw body is not forwarded', async () => { + const result = await recallWebhookRouteHandler( + buildRoutePayload({ body: {} }), + ); + + expect(result).toMatchObject({ + __twentyHttpResponse: true, + status: 500, + body: { + error: expect.stringContaining('Raw request body'), + }, + }); + }); + + it('responds 401 when the signature is invalid', async () => { + const result = await recallWebhookRouteHandler( + buildRoutePayload({ + rawBody: '{}', + body: {}, + headers: { + 'webhook-id': 'msg_entry_test', + 'webhook-timestamp': Math.floor(Date.now() / 1000).toString(), + 'webhook-signature': 'v1,not-a-real-signature', + }, + }), + ); + + expect(result).toMatchObject({ + __twentyHttpResponse: true, + status: 401, + body: { + error: expect.stringContaining('Invalid webhook signature'), + }, + }); + }); + + it('responds 400 when a correctly signed payload is empty', async () => { + const rawBody = 'null'; + + const result = await recallWebhookRouteHandler( + buildRoutePayload({ + rawBody, + body: null, + headers: buildSignedHeaders(rawBody), + }), + ); + + expect(result).toMatchObject({ + __twentyHttpResponse: true, + status: 400, + body: { + error: 'Webhook payload was empty', + }, + }); + }); + + it('dispatches a correctly signed payload to the handler', async () => { + const body = buildRecordingDoneWebhookBody(); + const rawBody = JSON.stringify(body); + + const result = await recallWebhookRouteHandler( + buildRoutePayload({ + rawBody, + body, + headers: buildSignedHeaders(rawBody), + }), + ); + + expect(handleRecallWebhookMock).toHaveBeenCalledTimes(1); + expect(handleRecallWebhookMock).toHaveBeenCalledWith( + expect.objectContaining({ body }), + ); + expect(result).toEqual({ status: 'updated' }); + }); +}); diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/call-recording-micro-credits-per-hour.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/call-recording-micro-credits-per-hour.ts new file mode 100644 index 0000000000..8fa84cc76f --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/call-recording-micro-credits-per-hour.ts @@ -0,0 +1 @@ +export const CALL_RECORDING_MICRO_CREDITS_PER_HOUR = 1_000_000; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/call-recording-request-status.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/call-recording-request-status.ts new file mode 100644 index 0000000000..0413760cbc --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/call-recording-request-status.ts @@ -0,0 +1,5 @@ +// Mirrors the core select options; guarded by the schema integration test. +export enum CallRecordingRequestStatus { + REQUESTED = 'REQUESTED', + CANCELED = 'CANCELED', +} diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/call-recording-status.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/call-recording-status.ts new file mode 100644 index 0000000000..60b8b6f14f --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/call-recording-status.ts @@ -0,0 +1,9 @@ +// Mirrors the core select options; guarded by the schema integration test. +export enum CallRecordingStatus { + SCHEDULED = 'SCHEDULED', + JOINING = 'JOINING', + RECORDING = 'RECORDING', + PROCESSING = 'PROCESSING', + COMPLETED = 'COMPLETED', + FAILED = 'FAILED', +} diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/default-meeting-bot-join-early-minutes.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/default-meeting-bot-join-early-minutes.ts new file mode 100644 index 0000000000..4af45b74e9 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/default-meeting-bot-join-early-minutes.ts @@ -0,0 +1 @@ +export const DEFAULT_MEETING_BOT_JOIN_EARLY_MINUTES = 1; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/default-meeting-bot-name.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/default-meeting-bot-name.ts new file mode 100644 index 0000000000..4c2a6f77e3 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/default-meeting-bot-name.ts @@ -0,0 +1 @@ +export const DEFAULT_MEETING_BOT_NAME = 'Twenty Meeting Bot'; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/default-meeting-bot-recording-retention-hours.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/default-meeting-bot-recording-retention-hours.ts new file mode 100644 index 0000000000..3ec7807e42 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/default-meeting-bot-recording-retention-hours.ts @@ -0,0 +1,2 @@ +// Twenty stores ingested recording artifacts, so Recall.ai media is temporary. Keep the default below Recall.ai's 168-hour free storage window. +export const DEFAULT_MEETING_BOT_RECORDING_RETENTION_HOURS = 166; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/default-recall-region.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/default-recall-region.ts new file mode 100644 index 0000000000..fdafca5053 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/default-recall-region.ts @@ -0,0 +1 @@ +export const DEFAULT_RECALL_REGION = 'eu-central-1'; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/meeting-bot-everyone-left-timeout-seconds-env-var-name.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/meeting-bot-everyone-left-timeout-seconds-env-var-name.ts new file mode 100644 index 0000000000..2528599282 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/meeting-bot-everyone-left-timeout-seconds-env-var-name.ts @@ -0,0 +1,2 @@ +export const MEETING_BOT_EVERYONE_LEFT_TIMEOUT_SECONDS_ENV_VAR_NAME = + 'MEETING_BOT_EVERYONE_LEFT_TIMEOUT_SECONDS'; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/meeting-bot-everyone-left-timeout-seconds.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/meeting-bot-everyone-left-timeout-seconds.ts new file mode 100644 index 0000000000..e4c841805a --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/meeting-bot-everyone-left-timeout-seconds.ts @@ -0,0 +1 @@ +export const MEETING_BOT_EVERYONE_LEFT_TIMEOUT_SECONDS = 2; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/meeting-bot-join-early-minutes-env-var-name.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/meeting-bot-join-early-minutes-env-var-name.ts new file mode 100644 index 0000000000..7cb8ac5121 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/meeting-bot-join-early-minutes-env-var-name.ts @@ -0,0 +1,2 @@ +export const MEETING_BOT_JOIN_EARLY_MINUTES_ENV_VAR_NAME = + 'MEETING_BOT_JOIN_EARLY_MINUTES'; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/meeting-bot-name-env-var-name.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/meeting-bot-name-env-var-name.ts new file mode 100644 index 0000000000..efa200e15e --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/meeting-bot-name-env-var-name.ts @@ -0,0 +1 @@ +export const MEETING_BOT_NAME_ENV_VAR_NAME = 'MEETING_BOT_NAME'; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/meeting-bot-noone-joined-timeout-seconds-env-var-name.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/meeting-bot-noone-joined-timeout-seconds-env-var-name.ts new file mode 100644 index 0000000000..f530a83ca4 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/meeting-bot-noone-joined-timeout-seconds-env-var-name.ts @@ -0,0 +1,2 @@ +export const MEETING_BOT_NOONE_JOINED_TIMEOUT_SECONDS_ENV_VAR_NAME = + 'MEETING_BOT_NOONE_JOINED_TIMEOUT_SECONDS'; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/meeting-bot-noone-joined-timeout-seconds.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/meeting-bot-noone-joined-timeout-seconds.ts new file mode 100644 index 0000000000..52ed2c3672 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/meeting-bot-noone-joined-timeout-seconds.ts @@ -0,0 +1 @@ +export const MEETING_BOT_NOONE_JOINED_TIMEOUT_SECONDS = 20 * 60; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/meeting-bot-recording-retention-hours-env-var-name.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/meeting-bot-recording-retention-hours-env-var-name.ts new file mode 100644 index 0000000000..f8a6573c9e --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/meeting-bot-recording-retention-hours-env-var-name.ts @@ -0,0 +1,2 @@ +export const MEETING_BOT_RECORDING_RETENTION_HOURS_ENV_VAR_NAME = + 'MEETING_BOT_RECORDING_RETENTION_HOURS'; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/meeting-bot-waiting-room-timeout-seconds-env-var-name.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/meeting-bot-waiting-room-timeout-seconds-env-var-name.ts new file mode 100644 index 0000000000..ca872670bc --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/meeting-bot-waiting-room-timeout-seconds-env-var-name.ts @@ -0,0 +1,2 @@ +export const MEETING_BOT_WAITING_ROOM_TIMEOUT_SECONDS_ENV_VAR_NAME = + 'MEETING_BOT_WAITING_ROOM_TIMEOUT_SECONDS'; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/meeting-bot-waiting-room-timeout-seconds.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/meeting-bot-waiting-room-timeout-seconds.ts new file mode 100644 index 0000000000..42bfa92498 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/meeting-bot-waiting-room-timeout-seconds.ts @@ -0,0 +1 @@ +export const MEETING_BOT_WAITING_ROOM_TIMEOUT_SECONDS = 1200; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/milliseconds-per-minute.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/milliseconds-per-minute.ts new file mode 100644 index 0000000000..c72bff9db3 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/milliseconds-per-minute.ts @@ -0,0 +1 @@ +export const MILLISECONDS_PER_MINUTE = 60_000; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/non-terminal-call-recording-statuses.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/non-terminal-call-recording-statuses.ts new file mode 100644 index 0000000000..dd604c7e85 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/non-terminal-call-recording-statuses.ts @@ -0,0 +1,8 @@ +import { CallRecordingStatus } from 'src/logic-functions/constants/call-recording-status'; + +export const NON_TERMINAL_CALL_RECORDING_STATUSES = [ + CallRecordingStatus.SCHEDULED, + CallRecordingStatus.JOINING, + CallRecordingStatus.RECORDING, + CallRecordingStatus.PROCESSING, +] satisfies CallRecordingStatus[]; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/recall-api-key-env-var-name.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/recall-api-key-env-var-name.ts new file mode 100644 index 0000000000..aa5f13a0ac --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/recall-api-key-env-var-name.ts @@ -0,0 +1 @@ +export const RECALL_API_KEY_ENV_VAR_NAME = 'RECALL_API_KEY'; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/recall-api-max-attempts.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/recall-api-max-attempts.ts new file mode 100644 index 0000000000..b8c6d08365 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/recall-api-max-attempts.ts @@ -0,0 +1 @@ +export const RECALL_API_MAX_ATTEMPTS = 3; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/recall-api-retry-delay-ms.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/recall-api-retry-delay-ms.ts new file mode 100644 index 0000000000..06dba9a11d --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/recall-api-retry-delay-ms.ts @@ -0,0 +1 @@ +export const RECALL_API_RETRY_DELAY_MS = 500; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/recall-bot-automatic-leave.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/recall-bot-automatic-leave.ts new file mode 100644 index 0000000000..5eb60dd57d --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/recall-bot-automatic-leave.ts @@ -0,0 +1,74 @@ +import { isUndefined } from '@sniptt/guards'; + +import { MEETING_BOT_EVERYONE_LEFT_TIMEOUT_SECONDS_ENV_VAR_NAME } from 'src/logic-functions/constants/meeting-bot-everyone-left-timeout-seconds-env-var-name'; +import { MEETING_BOT_NOONE_JOINED_TIMEOUT_SECONDS_ENV_VAR_NAME } from 'src/logic-functions/constants/meeting-bot-noone-joined-timeout-seconds-env-var-name'; +import { MEETING_BOT_WAITING_ROOM_TIMEOUT_SECONDS_ENV_VAR_NAME } from 'src/logic-functions/constants/meeting-bot-waiting-room-timeout-seconds-env-var-name'; +import { RECALL_BOT_EVERYONE_LEFT_MIN_ACTIVATE_AFTER_SECONDS } from 'src/logic-functions/constants/recall-bot-everyone-left-min-activate-after-seconds'; +import { getApplicationVariableValue } from 'src/logic-functions/utils/get-application-variable-value.util'; +import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util'; + +type RecallBotAutomaticLeave = { + waiting_room_timeout?: number; + noone_joined_timeout?: number; + everyone_left_timeout?: { + timeout: number; + activate_after: number; + }; +}; + +export const getRecallBotAutomaticLeave = (): + | RecallBotAutomaticLeave + | undefined => { + const waitingRoomTimeoutSeconds = getOptionalPositiveIntegerVariable( + MEETING_BOT_WAITING_ROOM_TIMEOUT_SECONDS_ENV_VAR_NAME, + ); + const nooneJoinedTimeoutSeconds = getOptionalPositiveIntegerVariable( + MEETING_BOT_NOONE_JOINED_TIMEOUT_SECONDS_ENV_VAR_NAME, + ); + const everyoneLeftTimeoutSeconds = getOptionalPositiveIntegerVariable( + MEETING_BOT_EVERYONE_LEFT_TIMEOUT_SECONDS_ENV_VAR_NAME, + ); + + const automaticLeave: RecallBotAutomaticLeave = {}; + + if (!isUndefined(waitingRoomTimeoutSeconds)) { + automaticLeave.waiting_room_timeout = waitingRoomTimeoutSeconds; + } + + if (!isUndefined(nooneJoinedTimeoutSeconds)) { + automaticLeave.noone_joined_timeout = nooneJoinedTimeoutSeconds; + } + + if (!isUndefined(everyoneLeftTimeoutSeconds)) { + automaticLeave.everyone_left_timeout = { + timeout: everyoneLeftTimeoutSeconds, + activate_after: RECALL_BOT_EVERYONE_LEFT_MIN_ACTIVATE_AFTER_SECONDS, + }; + } + + return Object.keys(automaticLeave).length === 0 ? undefined : automaticLeave; +}; + +const getOptionalPositiveIntegerVariable = ( + variableName: string, +): number | undefined => { + const rawValue = normalizeOptionalString( + getApplicationVariableValue(variableName), + ); + + if (isUndefined(rawValue)) { + return undefined; + } + + const timeoutSeconds = Number(rawValue); + + if (!Number.isInteger(timeoutSeconds) || timeoutSeconds <= 0) { + return undefined; + } + + return timeoutSeconds; +}; + +const normalizeOptionalString = ( + value: string | undefined, +): string | undefined => (isNonEmptyString(value) ? value.trim() : undefined); diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/recall-bot-everyone-left-min-activate-after-seconds.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/recall-bot-everyone-left-min-activate-after-seconds.ts new file mode 100644 index 0000000000..1279213348 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/recall-bot-everyone-left-min-activate-after-seconds.ts @@ -0,0 +1 @@ +export const RECALL_BOT_EVERYONE_LEFT_MIN_ACTIVATE_AFTER_SECONDS = 1; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/recall-bot-recording-config.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/recall-bot-recording-config.ts new file mode 100644 index 0000000000..7c6655b581 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/recall-bot-recording-config.ts @@ -0,0 +1,34 @@ +import { DEFAULT_MEETING_BOT_RECORDING_RETENTION_HOURS } from 'src/logic-functions/constants/default-meeting-bot-recording-retention-hours'; +import { MEETING_BOT_RECORDING_RETENTION_HOURS_ENV_VAR_NAME } from 'src/logic-functions/constants/meeting-bot-recording-retention-hours-env-var-name'; +import { getApplicationVariableValue } from 'src/logic-functions/utils/get-application-variable-value.util'; +import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util'; + +type RecallBotRecordingConfig = { + video_mixed_mp4: Record; + audio_mixed_mp3: Record; + retention: { type: 'timed'; hours: number }; +}; + +// Recall only produces artifacts declared at bot creation; both gate COMPLETED. +export const getRecallBotRecordingConfig = (): RecallBotRecordingConfig => { + const configuredRecordingRetentionHours = getApplicationVariableValue( + MEETING_BOT_RECORDING_RETENTION_HOURS_ENV_VAR_NAME, + ); + + const recordingRetentionHours = isNonEmptyString( + configuredRecordingRetentionHours, + ) + ? Number(configuredRecordingRetentionHours.trim()) + : NaN; + + const resolvedRecordingRetentionHours = + Number.isInteger(recordingRetentionHours) && recordingRetentionHours > 0 + ? recordingRetentionHours + : DEFAULT_MEETING_BOT_RECORDING_RETENTION_HOURS; + + return { + video_mixed_mp4: {}, + audio_mixed_mp3: {}, + retention: { type: 'timed', hours: resolvedRecordingRetentionHours }, + }; +}; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/recall-region-env-var-name.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/recall-region-env-var-name.ts new file mode 100644 index 0000000000..31c9a29523 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/recall-region-env-var-name.ts @@ -0,0 +1 @@ +export const RECALL_REGION_ENV_VAR_NAME = 'RECALL_REGION'; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/recall-webhook-secret-env-var-name.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/recall-webhook-secret-env-var-name.ts new file mode 100644 index 0000000000..520dfd2ff1 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/recall-webhook-secret-env-var-name.ts @@ -0,0 +1 @@ +export const RECALL_WEBHOOK_SECRET_ENV_VAR_NAME = 'RECALL_WEBHOOK_SECRET'; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/restricted-field-placeholder.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/restricted-field-placeholder.ts new file mode 100644 index 0000000000..80388082c8 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/restricted-field-placeholder.ts @@ -0,0 +1,3 @@ +// Mirrors twenty-shared; calendar restrictions write it over title/description. +export const RESTRICTED_FIELD_PLACEHOLDER = + 'FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED'; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/stale-bot-state-cron-pattern.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/stale-bot-state-cron-pattern.ts new file mode 100644 index 0000000000..a191b0af71 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/stale-bot-state-cron-pattern.ts @@ -0,0 +1 @@ +export const STALE_BOT_STATE_CRON_PATTERN = '*/5 * * * *'; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/twenty-page-size.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/twenty-page-size.ts new file mode 100644 index 0000000000..51637dc13d --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/constants/twenty-page-size.ts @@ -0,0 +1 @@ +export const TWENTY_PAGE_SIZE = 100; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/data/__tests__/complete-call-recording-ingestion.test.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/data/__tests__/complete-call-recording-ingestion.test.ts new file mode 100644 index 0000000000..820f7d3b3a --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/data/__tests__/complete-call-recording-ingestion.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { completeCallRecordingIngestion } from 'src/logic-functions/data/complete-call-recording-ingestion.util'; + +describe('completeCallRecordingIngestion', () => { + it('guards the flip with non-terminal statuses and returns true when the row is claimed', async () => { + let capturedArgs: { filter: unknown; data: unknown } | undefined; + const mutation = vi.fn(async (mutationArg: any) => { + capturedArgs = mutationArg.updateCallRecordings.__args; + + return { updateCallRecordings: [{ id: 'call-recording-1' }] }; + }); + + const claimed = await completeCallRecordingIngestion( + { mutation } as never, + { + id: 'call-recording-1', + }, + ); + + expect(claimed).toBe(true); + expect(mutation).toHaveBeenCalledTimes(1); + expect(capturedArgs?.filter).toEqual({ + id: { eq: 'call-recording-1' }, + status: { in: ['SCHEDULED', 'JOINING', 'RECORDING', 'PROCESSING'] }, + }); + expect(capturedArgs?.data).toEqual({ status: 'COMPLETED' }); + }); + + it('returns false when the row was already COMPLETED, so the loser cannot charge', async () => { + const mutation = vi.fn(async () => ({ updateCallRecordings: [] })); + + const claimed = await completeCallRecordingIngestion( + { mutation } as never, + { + id: 'call-recording-1', + }, + ); + + expect(claimed).toBe(false); + }); + + it('returns false when the API omits the result list', async () => { + const mutation = vi.fn(async () => ({})); + + const claimed = await completeCallRecordingIngestion( + { mutation } as never, + { + id: 'call-recording-1', + }, + ); + + expect(claimed).toBe(false); + }); +}); diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/data/__tests__/fetch-all-nodes.test.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/data/__tests__/fetch-all-nodes.test.ts new file mode 100644 index 0000000000..c82576d67f --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/data/__tests__/fetch-all-nodes.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { fetchAllNodes } from 'src/logic-functions/data/fetch-all-nodes.util'; + +describe('fetchAllNodes', () => { + it('collects nodes across pages until hasNextPage is false', async () => { + const fetchPage = vi + .fn() + .mockResolvedValueOnce({ + pageInfo: { hasNextPage: true, endCursor: 'cursor-1' }, + edges: [{ node: 'node-1' }, { node: 'node-2' }], + }) + .mockResolvedValueOnce({ + pageInfo: { hasNextPage: false, endCursor: 'cursor-2' }, + edges: [{ node: 'node-3' }], + }); + + const nodes = await fetchAllNodes(fetchPage); + + expect(nodes).toEqual(['node-1', 'node-2', 'node-3']); + expect(fetchPage).toHaveBeenNthCalledWith(1, undefined); + expect(fetchPage).toHaveBeenNthCalledWith(2, 'cursor-1'); + }); + + it('throws when hasNextPage is true without an endCursor', async () => { + const fetchPage = vi.fn().mockResolvedValue({ + pageInfo: { hasNextPage: true, endCursor: null }, + edges: [{ node: 'node-1' }], + }); + + await expect(fetchAllNodes(fetchPage)).rejects.toThrow( + 'Inconsistent pagination state: hasNextPage is true without an endCursor', + ); + }); + + it('throws when the query returns no connection', async () => { + const fetchPage = vi.fn().mockResolvedValue(undefined); + + await expect(fetchAllNodes(fetchPage)).rejects.toThrow( + 'Pagination query returned no connection', + ); + }); +}); diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/data/__tests__/get-current-workspace-id.test.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/data/__tests__/get-current-workspace-id.test.ts new file mode 100644 index 0000000000..4e061d9c29 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/data/__tests__/get-current-workspace-id.test.ts @@ -0,0 +1,39 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { getCurrentWorkspaceId } from 'src/logic-functions/data/get-current-workspace-id.util'; + +const APP_ACCESS_TOKEN_ENV_VAR_NAME = 'TWENTY_APP_ACCESS_TOKEN'; +const ORIGINAL_APP_ACCESS_TOKEN = + process.env[APP_ACCESS_TOKEN_ENV_VAR_NAME]; +const WORKSPACE_ID = '123e4567-e89b-12d3-a456-426614174000'; + +const restoreOriginalAppAccessToken = () => { + if (ORIGINAL_APP_ACCESS_TOKEN === undefined) { + delete process.env[APP_ACCESS_TOKEN_ENV_VAR_NAME]; + + return; + } + + process.env[APP_ACCESS_TOKEN_ENV_VAR_NAME] = ORIGINAL_APP_ACCESS_TOKEN; +}; + +const buildAccessToken = (payload: Record): string => + [ + Buffer.from(JSON.stringify({ alg: 'none' })).toString('base64url'), + Buffer.from(JSON.stringify(payload)).toString('base64url'), + 'signature', + ].join('.'); + +describe('getCurrentWorkspaceId', () => { + afterEach(() => { + restoreOriginalAppAccessToken(); + }); + + it('reads the workspace id from the app access token payload', () => { + process.env[APP_ACCESS_TOKEN_ENV_VAR_NAME] = buildAccessToken({ + workspaceId: WORKSPACE_ID, + }); + + expect(getCurrentWorkspaceId()).toBe(WORKSPACE_ID); + }); +}); diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/data/__tests__/strip-restricted-field-value.test.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/data/__tests__/strip-restricted-field-value.test.ts new file mode 100644 index 0000000000..27bc08f2f9 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/data/__tests__/strip-restricted-field-value.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; + +import { RESTRICTED_FIELD_PLACEHOLDER } from 'src/logic-functions/constants/restricted-field-placeholder'; +import { stripRestrictedFieldValue } from 'src/logic-functions/data/strip-restricted-field-value.util'; + +describe('stripRestrictedFieldValue', () => { + it('drops the calendar visibility restriction placeholder', () => { + expect( + stripRestrictedFieldValue(RESTRICTED_FIELD_PLACEHOLDER), + ).toBeUndefined(); + }); + + it('keeps regular values', () => { + expect(stripRestrictedFieldValue('Customer Discovery Call')).toBe( + 'Customer Discovery Call', + ); + }); + + it('keeps undefined', () => { + expect(stripRestrictedFieldValue(undefined)).toBeUndefined(); + }); +}); diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/data/complete-call-recording-ingestion.util.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/data/complete-call-recording-ingestion.util.ts new file mode 100644 index 0000000000..b1f2dbc566 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/data/complete-call-recording-ingestion.util.ts @@ -0,0 +1,30 @@ +import { type CoreApiClient } from 'twenty-client-sdk/core'; + +import { CallRecordingStatus } from 'src/logic-functions/constants/call-recording-status'; +import { NON_TERMINAL_CALL_RECORDING_STATUSES } from 'src/logic-functions/constants/non-terminal-call-recording-statuses'; +import { + executeCurrentSchemaMutation, + type CurrentSchemaUpdateCallRecordingsMutation, +} from 'src/logic-functions/data/execute-current-schema-mutation.util'; + +export const completeCallRecordingIngestion = async ( + client: CoreApiClient, + { id }: { id: string }, +): Promise => { + const mutation = { + updateCallRecordings: { + __args: { + filter: { + id: { eq: id }, + status: { in: NON_TERMINAL_CALL_RECORDING_STATUSES }, + }, + data: { status: CallRecordingStatus.COMPLETED }, + }, + id: true, + }, + } satisfies CurrentSchemaUpdateCallRecordingsMutation; + + const result = await executeCurrentSchemaMutation(client, mutation); + + return (result.updateCallRecordings ?? []).length > 0; +}; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/data/create-call-recording.util.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/data/create-call-recording.util.ts new file mode 100644 index 0000000000..c8ed9a95f5 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/data/create-call-recording.util.ts @@ -0,0 +1,41 @@ +import { isUndefined } from '@sniptt/guards'; +import { type CoreApiClient } from 'twenty-client-sdk/core'; + +import { type CallRecordingRequestStatus } from 'src/logic-functions/constants/call-recording-request-status'; +import { type CallRecordingStatus } from 'src/logic-functions/constants/call-recording-status'; + +export type ScheduledCallRecordingFields = { + title: string | null; + status: CallRecordingStatus.SCHEDULED; + recordingRequestStatus: CallRecordingRequestStatus.REQUESTED; + calendarEventId: string; +}; + +export const createCallRecording = async ( + client: CoreApiClient, + { + id, + data, + }: { + id: string; + data: ScheduledCallRecordingFields; + }, +): Promise => { + const mutationResult = await client.mutation({ + createCallRecording: { + __args: { + data: { id, ...data }, + }, + id: true, + }, + }); + const createdCallRecordingId = mutationResult.createCallRecording?.id; + + if (isUndefined(createdCallRecordingId)) { + throw new Error( + 'createCallRecording mutation did not return a call recording id', + ); + } + + return createdCallRecordingId; +}; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/data/execute-current-schema-mutation.util.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/data/execute-current-schema-mutation.util.ts new file mode 100644 index 0000000000..8e5dfcc03b --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/data/execute-current-schema-mutation.util.ts @@ -0,0 +1,54 @@ +import { type CoreApiClient } from 'twenty-client-sdk/core'; + +import { type CallRecordingStatus } from 'src/logic-functions/constants/call-recording-status'; +import { type CallRecordingUpdateFields } from 'src/logic-functions/data/update-call-recording.util'; + +type CurrentSchemaMutationFunction = ( + mutation: CurrentSchemaMutation, +) => Promise; + +export type CurrentSchemaUpdateCallRecordingMutation = { + updateCallRecording: { + __args: { + id: string; + data: CallRecordingUpdateFields; + }; + id?: true; + status?: true; + }; +}; + +export type CurrentSchemaUpdateCallRecordingsMutation = { + updateCallRecordings: { + __args: { + filter: { + id: { eq: string }; + status?: { in: CallRecordingStatus[] }; + }; + data: Pick; + }; + id?: true; + }; +}; + +type CurrentSchemaMutation = + | CurrentSchemaUpdateCallRecordingMutation + | CurrentSchemaUpdateCallRecordingsMutation; + +type CurrentSchemaMutationResult = { + updateCallRecording?: { id?: string; status?: string | null } | null; + updateCallRecordings?: { id?: string }[] | null; +}; + +// TODO: Remove this bridge once the released SDK includes the current +// CallRecording schema with FAILED and meetingBotFailureReason. +export const executeCurrentSchemaMutation = ( + client: CoreApiClient, + mutation: CurrentSchemaMutation, +): Promise => { + const currentSchemaClient = client as { + mutation: CurrentSchemaMutationFunction; + }; + + return currentSchemaClient.mutation(mutation); +}; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/data/fetch-all-nodes.util.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/data/fetch-all-nodes.util.ts new file mode 100644 index 0000000000..ce16ce4fe8 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/data/fetch-all-nodes.util.ts @@ -0,0 +1,44 @@ +import { isString, isUndefined } from '@sniptt/guards'; + +export type ConnectionPage = { + pageInfo?: { + hasNextPage?: boolean | null; + endCursor?: string | null; + } | null; + edges?: Array<{ node: TNode }> | null; +}; + +export const fetchAllNodes = async ( + fetchPage: ( + afterCursor: string | undefined, + ) => Promise | undefined>, +): Promise => { + const nodes: TNode[] = []; + let hasNextPage = true; + let afterCursor: string | undefined; + + while (hasNextPage) { + const connection = await fetchPage(afterCursor); + + if (isUndefined(connection)) { + throw new Error('Pagination query returned no connection'); + } + + for (const edge of connection.edges ?? []) { + nodes.push(edge.node); + } + + hasNextPage = connection.pageInfo?.hasNextPage === true; + const endCursor = connection.pageInfo?.endCursor; + + if (hasNextPage && !isString(endCursor)) { + throw new Error( + 'Inconsistent pagination state: hasNextPage is true without an endCursor', + ); + } + + afterCursor = isString(endCursor) ? endCursor : undefined; + } + + return nodes; +}; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/data/fetch-calendar-events-by-filter.util.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/data/fetch-calendar-events-by-filter.util.ts new file mode 100644 index 0000000000..f7d6b7ec8f --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/data/fetch-calendar-events-by-filter.util.ts @@ -0,0 +1,80 @@ +import { isString, isUndefined } from '@sniptt/guards'; +import { type CoreApiClient } from 'twenty-client-sdk/core'; + +import { TWENTY_PAGE_SIZE } from 'src/logic-functions/constants/twenty-page-size'; +import { type CalendarEventRecord } from 'src/logic-functions/types/calendar-event-record.type'; +import { + fetchAllNodes, + type ConnectionPage, +} from 'src/logic-functions/data/fetch-all-nodes.util'; +import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util'; +import { stripRestrictedFieldValue } from 'src/logic-functions/data/strip-restricted-field-value.util'; + +type CalendarEventNode = { + id: string; + title?: string | null; + isCanceled?: boolean | null; + startsAt?: string | null; + endsAt?: string | null; + iCalUid?: string | null; + conferenceLink?: { primaryLinkUrl?: string | null } | null; + meetingBotPreference?: string | null; +}; + +export const fetchCalendarEventsByFilter = async ( + client: CoreApiClient, + filter: Record, +): Promise => { + const calendarEventNodes = await fetchAllNodes( + async (afterCursor) => { + const queryResult = await client.query({ + calendarEvents: { + __args: { + filter, + first: TWENTY_PAGE_SIZE, + ...(isUndefined(afterCursor) ? {} : { after: afterCursor }), + }, + pageInfo: { + hasNextPage: true, + endCursor: true, + }, + edges: { + node: { + id: true, + title: true, + isCanceled: true, + startsAt: true, + endsAt: true, + iCalUid: true, + conferenceLink: { + primaryLinkUrl: true, + }, + meetingBotPreference: true, + }, + }, + }, + }); + + return queryResult.calendarEvents as + | ConnectionPage + | undefined; + }, + ); + + return calendarEventNodes.map((calendarEvent) => ({ + id: calendarEvent.id, + title: stripRestrictedFieldValue(calendarEvent.title ?? undefined), + isCanceled: calendarEvent.isCanceled ?? false, + startsAt: calendarEvent.startsAt ?? undefined, + endsAt: calendarEvent.endsAt ?? undefined, + iCalUid: calendarEvent.iCalUid ?? undefined, + conferenceLinkUrl: isNonEmptyString( + calendarEvent.conferenceLink?.primaryLinkUrl, + ) + ? calendarEvent.conferenceLink.primaryLinkUrl + : undefined, + meetingBotPreference: isString(calendarEvent.meetingBotPreference) + ? calendarEvent.meetingBotPreference + : undefined, + })); +}; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/data/fetch-calendar-events-by-ids.util.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/data/fetch-calendar-events-by-ids.util.ts new file mode 100644 index 0000000000..d703198a5f --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/data/fetch-calendar-events-by-ids.util.ts @@ -0,0 +1,20 @@ +import { type CoreApiClient } from 'twenty-client-sdk/core'; + +import { type CalendarEventRecord } from 'src/logic-functions/types/calendar-event-record.type'; +import { fetchCalendarEventsByFilter } from 'src/logic-functions/data/fetch-calendar-events-by-filter.util'; +import { getUniqueSortedIds } from 'src/logic-functions/utils/get-unique-sorted-ids.util'; + +export const fetchCalendarEventsByIds = async ( + client: CoreApiClient, + calendarEventIds: string[], +): Promise => { + const uniqueCalendarEventIds = getUniqueSortedIds(calendarEventIds); + + if (uniqueCalendarEventIds.length === 0) { + return []; + } + + return fetchCalendarEventsByFilter(client, { + id: { in: uniqueCalendarEventIds }, + }); +}; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/data/fetch-calendar-events-by-starts-at-values.util.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/data/fetch-calendar-events-by-starts-at-values.util.ts new file mode 100644 index 0000000000..aa5609cf8c --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/data/fetch-calendar-events-by-starts-at-values.util.ts @@ -0,0 +1,19 @@ +import { type CoreApiClient } from 'twenty-client-sdk/core'; + +import { type CalendarEventRecord } from 'src/logic-functions/types/calendar-event-record.type'; +import { fetchCalendarEventsByFilter } from 'src/logic-functions/data/fetch-calendar-events-by-filter.util'; + +export const fetchCalendarEventsByStartsAtValues = async ( + client: CoreApiClient, + startsAtValues: string[], +): Promise => { + const uniqueStartsAtValues = [...new Set(startsAtValues)].sort(); + + if (uniqueStartsAtValues.length === 0) { + return []; + } + + return fetchCalendarEventsByFilter(client, { + startsAt: { in: uniqueStartsAtValues }, + }); +}; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/data/find-call-recordings-by-calendar-event-ids.util.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/data/find-call-recordings-by-calendar-event-ids.util.ts new file mode 100644 index 0000000000..b07ed738e7 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/data/find-call-recordings-by-calendar-event-ids.util.ts @@ -0,0 +1,17 @@ +import { type CoreApiClient } from 'twenty-client-sdk/core'; + +import { type CallRecordingRecord } from 'src/logic-functions/types/call-recording-record.type'; +import { findCallRecordingsByFilter } from 'src/logic-functions/data/find-call-recordings-by-filter.util'; + +export const findCallRecordingsByCalendarEventIds = async ( + client: CoreApiClient, + calendarEventIds: string[], +): Promise => { + if (calendarEventIds.length === 0) { + return []; + } + + return findCallRecordingsByFilter(client, { + calendarEventId: { in: calendarEventIds }, + }); +}; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/data/find-call-recordings-by-filter.util.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/data/find-call-recordings-by-filter.util.ts new file mode 100644 index 0000000000..fd2de1ab96 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/data/find-call-recordings-by-filter.util.ts @@ -0,0 +1,102 @@ +import { isUndefined } from '@sniptt/guards'; +import { type CoreApiClient } from 'twenty-client-sdk/core'; + +import { CallRecordingRequestStatus } from 'src/logic-functions/constants/call-recording-request-status'; +import { TWENTY_PAGE_SIZE } from 'src/logic-functions/constants/twenty-page-size'; +import { type CallRecordingRecord } from 'src/logic-functions/types/call-recording-record.type'; +import { + fetchAllNodes, + type ConnectionPage, +} from 'src/logic-functions/data/fetch-all-nodes.util'; +import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util'; + +type CallRecordingNode = { + id: string; + title?: string | null; + status?: string | null; + recordingRequestStatus?: unknown; + startedAt?: string | null; + endedAt?: string | null; + calendarEventId?: string | null; + externalBotId?: string | null; + externalRecordingId?: string | null; + meetingBotFailureReason?: string | null; +}; + +export const findCallRecordingsByFilter = async ( + client: CoreApiClient, + filter: Record, +): Promise => { + const callRecordingNodes = await fetchAllNodes( + async (afterCursor) => { + const queryResult = await client.query({ + callRecordings: { + __args: { + filter, + first: TWENTY_PAGE_SIZE, + ...(isUndefined(afterCursor) ? {} : { after: afterCursor }), + }, + pageInfo: { + hasNextPage: true, + endCursor: true, + }, + edges: { + node: { + id: true, + title: true, + status: true, + recordingRequestStatus: true, + startedAt: true, + endedAt: true, + calendarEventId: true, + externalBotId: true, + externalRecordingId: true, + meetingBotFailureReason: true, + }, + }, + }, + }); + + return queryResult.callRecordings as + | ConnectionPage + | undefined; + }, + ); + + return callRecordingNodes.map((callRecording) => ({ + id: callRecording.id, + title: callRecording.title ?? undefined, + status: callRecording.status ?? undefined, + recordingRequestStatus: normalizeCallRecordingRequestStatus( + callRecording.recordingRequestStatus, + ), + startedAt: callRecording.startedAt ?? undefined, + endedAt: callRecording.endedAt ?? undefined, + calendarEventId: callRecording.calendarEventId ?? undefined, + externalBotId: normalizeOptionalString(callRecording.externalBotId), + externalRecordingId: normalizeOptionalString( + callRecording.externalRecordingId, + ), + meetingBotFailureReason: normalizeOptionalString( + callRecording.meetingBotFailureReason, + ), + })); +}; + +const normalizeOptionalString = ( + value: string | null | undefined, +): string | undefined => (isNonEmptyString(value) ? value : undefined); + +const normalizeCallRecordingRequestStatus = ( + recordingRequestStatus: unknown, +): CallRecordingRequestStatus | undefined => { + if (recordingRequestStatus === CallRecordingRequestStatus.REQUESTED) { + return recordingRequestStatus; + } + + if (recordingRequestStatus === CallRecordingRequestStatus.CANCELED) { + return recordingRequestStatus; + } + + return undefined; +}; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/data/find-call-recordings-by-ids.util.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/data/find-call-recordings-by-ids.util.ts new file mode 100644 index 0000000000..5ef2493faf --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/data/find-call-recordings-by-ids.util.ts @@ -0,0 +1,17 @@ +import { type CoreApiClient } from 'twenty-client-sdk/core'; + +import { type CallRecordingRecord } from 'src/logic-functions/types/call-recording-record.type'; +import { findCallRecordingsByFilter } from 'src/logic-functions/data/find-call-recordings-by-filter.util'; + +export const findCallRecordingsByIds = async ( + client: CoreApiClient, + callRecordingIds: string[], +): Promise => { + if (callRecordingIds.length === 0) { + return []; + } + + return findCallRecordingsByFilter(client, { + id: { in: callRecordingIds }, + }); +}; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/data/find-open-scheduled-call-recordings.util.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/data/find-open-scheduled-call-recordings.util.ts new file mode 100644 index 0000000000..5bd855c6f4 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/data/find-open-scheduled-call-recordings.util.ts @@ -0,0 +1,14 @@ +import { type CoreApiClient } from 'twenty-client-sdk/core'; + +import { CallRecordingRequestStatus } from 'src/logic-functions/constants/call-recording-request-status'; +import { CallRecordingStatus } from 'src/logic-functions/constants/call-recording-status'; +import { type CallRecordingRecord } from 'src/logic-functions/types/call-recording-record.type'; +import { findCallRecordingsByFilter } from 'src/logic-functions/data/find-call-recordings-by-filter.util'; + +export const findOpenScheduledCallRecordings = async ( + client: CoreApiClient, +): Promise => + findCallRecordingsByFilter(client, { + recordingRequestStatus: { eq: CallRecordingRequestStatus.REQUESTED }, + status: { eq: CallRecordingStatus.SCHEDULED }, + }); diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/data/get-current-workspace-id.util.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/data/get-current-workspace-id.util.ts new file mode 100644 index 0000000000..4f0c892200 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/data/get-current-workspace-id.util.ts @@ -0,0 +1,36 @@ +import { isUndefined } from '@sniptt/guards'; + +import { asRecord } from 'src/logic-functions/utils/as-record.util'; +import { getString } from 'src/logic-functions/utils/get-string.util'; + +const APP_ACCESS_TOKEN_ENV_VAR_NAME = 'TWENTY_APP_ACCESS_TOKEN'; + +export const getCurrentWorkspaceId = (): string | undefined => { + const accessToken = getString(process.env[APP_ACCESS_TOKEN_ENV_VAR_NAME]); + + if (isUndefined(accessToken)) { + return undefined; + } + + return getWorkspaceIdFromAccessToken(accessToken); +}; + +const getWorkspaceIdFromAccessToken = ( + accessToken: string, +): string | undefined => { + const encodedPayload = accessToken.split('.')[1]; + + if (isUndefined(encodedPayload)) { + return undefined; + } + + try { + const payload = asRecord( + JSON.parse(Buffer.from(encodedPayload, 'base64url').toString('utf8')), + ); + + return getString(payload?.workspaceId); + } catch { + return undefined; + } +}; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/data/strip-restricted-field-value.util.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/data/strip-restricted-field-value.util.ts new file mode 100644 index 0000000000..06b4a79a47 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/data/strip-restricted-field-value.util.ts @@ -0,0 +1,6 @@ +import { RESTRICTED_FIELD_PLACEHOLDER } from 'src/logic-functions/constants/restricted-field-placeholder'; + +export const stripRestrictedFieldValue = ( + value: string | undefined, +): string | undefined => + value === RESTRICTED_FIELD_PLACEHOLDER ? undefined : value; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/data/update-call-recording.util.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/data/update-call-recording.util.ts new file mode 100644 index 0000000000..251e325702 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/data/update-call-recording.util.ts @@ -0,0 +1,48 @@ +import { type CoreApiClient } from 'twenty-client-sdk/core'; + +import { type CallRecordingRequestStatus } from 'src/logic-functions/constants/call-recording-request-status'; +import { type CallRecordingStatus } from 'src/logic-functions/constants/call-recording-status'; +import { + executeCurrentSchemaMutation, + type CurrentSchemaUpdateCallRecordingMutation, +} from 'src/logic-functions/data/execute-current-schema-mutation.util'; + +export type CallRecordingUpdateFields = Partial<{ + // null clears a previously synced title when the calendar title disappears. + title: string | null; + status: CallRecordingStatus; + recordingRequestStatus: CallRecordingRequestStatus; + startedAt: string; + endedAt: string; + calendarEventId: string; + // null clears stale app-owned state on cancel/eject or reschedule. + externalBotId: string | null; + externalRecordingId: string; + meetingBotFailureReason: string | null; + transcript: Record; + audio: { fileId: string; label: string }[]; + video: { fileId: string; label: string }[]; +}>; + +export const updateCallRecording = async ( + client: CoreApiClient, + { + id, + data, + }: { + id: string; + data: CallRecordingUpdateFields; + }, +): Promise => { + const mutation = { + updateCallRecording: { + __args: { + id, + data, + }, + id: true, + }, + } satisfies CurrentSchemaUpdateCallRecordingMutation; + + await executeCurrentSchemaMutation(client, mutation); +}; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/domain/__tests__/build-meeting-bot-policy-result.test.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/domain/__tests__/build-meeting-bot-policy-result.test.ts new file mode 100644 index 0000000000..919b8d7948 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/domain/__tests__/build-meeting-bot-policy-result.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; + +import { buildMeetingBotPolicyResult } from 'src/logic-functions/domain/build-meeting-bot-policy-result.util'; +import { type MeetingBotPolicyCalendarEventInput } from 'src/logic-functions/types/meeting-bot-policy-calendar-event-input.type'; + +const NOW = new Date('2026-01-01T12:00:00.000Z'); + +const buildCalendarEventInput = ( + overrides: Partial, +): MeetingBotPolicyCalendarEventInput => ({ + id: 'calendar-event-1', + isCanceled: false, + startsAt: '2026-01-01T13:00:00.000Z', + endsAt: '2026-01-01T14:00:00.000Z', + iCalUid: 'ical-uid-1', + conferenceLinkUrl: 'https://meet.example.com/customer-sync', + meetingBotPreference: undefined, + ...overrides, +}); + +describe('buildMeetingBotPolicyResult', () => { + it('requests a bot for the ON wire value', () => { + const policyResult = buildMeetingBotPolicyResult( + buildCalendarEventInput({ + meetingBotPreference: 'ON', + }), + NOW, + ); + + expect(policyResult.meetingBotPreference).toBe('ON'); + expect(policyResult.shouldRequestBot).toBe(true); + expect(policyResult.reason).toBe('RECORDING_ENABLED'); + }); + + it('does not request a bot for the OFF wire value', () => { + const policyResult = buildMeetingBotPolicyResult( + buildCalendarEventInput({ + meetingBotPreference: 'OFF', + }), + NOW, + ); + + expect(policyResult.meetingBotPreference).toBe('OFF'); + expect(policyResult.shouldRequestBot).toBe(false); + expect(policyResult.reason).toBe('PREFERENCE_OFF'); + }); +}); diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/domain/__tests__/compute-call-recording-charge.test.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/domain/__tests__/compute-call-recording-charge.test.ts new file mode 100644 index 0000000000..7abf925f9e --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/domain/__tests__/compute-call-recording-charge.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; + +import { computeCallRecordingCharge } from 'src/logic-functions/domain/compute-call-recording-charge.util'; + +describe('computeCallRecordingCharge', () => { + it('charges one credit for a one-hour recording', () => { + expect( + computeCallRecordingCharge({ + startedAt: '2026-06-10T09:00:00.000Z', + endedAt: '2026-06-10T10:00:00.000Z', + }), + ).toEqual({ + creditsUsedMicro: 1_000_000, + quantityMinutes: 60, + }); + }); + + it('prorates partial hours by duration', () => { + expect( + computeCallRecordingCharge({ + startedAt: '2026-06-10T09:00:00.000Z', + endedAt: '2026-06-10T09:45:00.000Z', + }), + ).toEqual({ + creditsUsedMicro: 750_000, + quantityMinutes: 45, + }); + }); + + it('reports at least one minute for very short recordings', () => { + expect( + computeCallRecordingCharge({ + startedAt: '2026-06-10T09:00:00.000Z', + endedAt: '2026-06-10T09:00:30.000Z', + }), + ).toEqual({ + creditsUsedMicro: 8_333, + quantityMinutes: 1, + }); + }); + + it('returns undefined when either timestamp is missing', () => { + expect( + computeCallRecordingCharge({ + startedAt: undefined, + endedAt: '2026-06-10T10:00:00.000Z', + }), + ).toBeUndefined(); + expect( + computeCallRecordingCharge({ + startedAt: '2026-06-10T09:00:00.000Z', + endedAt: undefined, + }), + ).toBeUndefined(); + }); + + it('returns undefined for non-positive or unparseable durations', () => { + expect( + computeCallRecordingCharge({ + startedAt: '2026-06-10T10:00:00.000Z', + endedAt: '2026-06-10T09:00:00.000Z', + }), + ).toBeUndefined(); + expect( + computeCallRecordingCharge({ + startedAt: 'not-a-date', + endedAt: '2026-06-10T10:00:00.000Z', + }), + ).toBeUndefined(); + }); +}); diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/domain/__tests__/compute-call-recording-id-for-meeting.test.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/domain/__tests__/compute-call-recording-id-for-meeting.test.ts new file mode 100644 index 0000000000..45fea40e4b --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/domain/__tests__/compute-call-recording-id-for-meeting.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; + +import { computeCallRecordingIdForMeeting } from 'src/logic-functions/domain/compute-call-recording-id-for-meeting.util'; + +const UUID_V4_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + +describe('computeCallRecordingIdForMeeting', () => { + it('returns the same id for the same real meeting key', () => { + const realMeetingKey = + 'link:meet.example.com/sync:2026-01-01T13:00:00.000Z'; + + expect(computeCallRecordingIdForMeeting(realMeetingKey)).toBe( + computeCallRecordingIdForMeeting(realMeetingKey), + ); + }); + + it('returns different ids for different real meeting keys', () => { + expect( + computeCallRecordingIdForMeeting( + 'link:meet.example.com/sync:2026-01-01T13:00:00.000Z', + ), + ).not.toBe( + computeCallRecordingIdForMeeting( + 'link:meet.example.com/sync:2026-01-02T13:00:00.000Z', + ), + ); + }); + + it('returns a v4-shaped uuid', () => { + expect( + computeCallRecordingIdForMeeting( + 'ical:some-uid:2026-01-01T13:00:00.000Z', + ), + ).toMatch(UUID_V4_PATTERN); + }); +}); diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/domain/__tests__/compute-real-meeting-key.test.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/domain/__tests__/compute-real-meeting-key.test.ts new file mode 100644 index 0000000000..940316139e --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/domain/__tests__/compute-real-meeting-key.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest'; + +import { computeRealMeetingKey } from 'src/logic-functions/domain/compute-real-meeting-key.util'; + +const STARTS_AT = '2026-01-01T13:00:00.000Z'; + +const buildInput = ( + overrides: Partial[0]> = {}, +) => ({ + calendarEventId: 'calendar-event-1', + conferenceLinkUrl: 'https://meet.example.com/customer-sync', + iCalUid: 'calendar-event-uid', + startsAt: STARTS_AT, + ...overrides, +}); + +describe('computeRealMeetingKey', () => { + it.each([ + [ + 'strips protocol, query, and fragment', + 'https://zoom.us/j/123?pwd=abc#section', + `link:zoom.us/j/123:${STARTS_AT}`, + ], + [ + 'strips www and lowercases', + 'HTTPS://WWW.Meet.Example.com/Customer-Sync', + `link:meet.example.com/customer-sync:${STARTS_AT}`, + ], + [ + 'strips trailing slashes', + 'https://meet.example.com/customer-sync///', + `link:meet.example.com/customer-sync:${STARTS_AT}`, + ], + [ + 'supports plain http links', + 'http://meet.example.com/customer-sync', + `link:meet.example.com/customer-sync:${STARTS_AT}`, + ], + ])('%s', (_label, conferenceLinkUrl, expectedKey) => { + expect(computeRealMeetingKey(buildInput({ conferenceLinkUrl }))).toBe( + expectedKey, + ); + }); + + it('produces the same key for the same meeting synced from two calendars', () => { + const fromFirstAttendee = computeRealMeetingKey( + buildInput({ + calendarEventId: 'calendar-event-1', + conferenceLinkUrl: 'https://zoom.us/j/123?pwd=first-attendee-token', + }), + ); + const fromSecondAttendee = computeRealMeetingKey( + buildInput({ + calendarEventId: 'calendar-event-2', + conferenceLinkUrl: + 'https://www.zoom.us/j/123?pwd=second-attendee-token', + }), + ); + + expect(fromFirstAttendee).toBe(fromSecondAttendee); + }); + + it('falls back to the iCal uid when the link is blank', () => { + expect( + computeRealMeetingKey(buildInput({ conferenceLinkUrl: ' ' })), + ).toBe(`ical:calendar-event-uid:${STARTS_AT}`); + }); + + it('falls back to the iCal uid when the link is not a string', () => { + expect(computeRealMeetingKey(buildInput({ conferenceLinkUrl: 42 }))).toBe( + `ical:calendar-event-uid:${STARTS_AT}`, + ); + }); + + it('falls back to the calendar event id when link and iCal uid are missing', () => { + expect( + computeRealMeetingKey( + buildInput({ conferenceLinkUrl: undefined, iCalUid: '' }), + ), + ).toBe('event:calendar-event-1'); + }); + + it('keeps link keys distinct across start times', () => { + expect(computeRealMeetingKey(buildInput({ startsAt: undefined }))).toBe( + 'link:meet.example.com/customer-sync:', + ); + }); +}); diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/domain/__tests__/is-call-recording-ingestion-complete.test.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/domain/__tests__/is-call-recording-ingestion-complete.test.ts new file mode 100644 index 0000000000..a7052f6fa7 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/domain/__tests__/is-call-recording-ingestion-complete.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; + +import { isCallRecordingIngestionComplete } from 'src/logic-functions/domain/is-call-recording-ingestion-complete.util'; + +const AUDIO_VALUE = [{ fileId: 'file-audio-1', label: 'audio.mp3' }]; +const VIDEO_VALUE = [{ fileId: 'file-video-1', label: 'video.mp4' }]; +const TRANSCRIPT_CONTENT = [{ participant: { id: 1 }, words: [] }]; + +describe('isCallRecordingIngestionComplete', () => { + it('is complete when transcript content and both media files are present', () => { + expect( + isCallRecordingIngestionComplete({ + transcript: TRANSCRIPT_CONTENT, + audio: AUDIO_VALUE, + video: VIDEO_VALUE, + }), + ).toBe(true); + }); + + it('is incomplete while the transcript holds a marker', () => { + expect( + isCallRecordingIngestionComplete({ + transcript: { + recallTranscriptId: 'recall-transcript-1', + status: 'PENDING', + }, + audio: AUDIO_VALUE, + video: VIDEO_VALUE, + }), + ).toBe(false); + }); + + it('is incomplete when the transcript is unset', () => { + expect( + isCallRecordingIngestionComplete({ + transcript: null, + audio: AUDIO_VALUE, + video: VIDEO_VALUE, + }), + ).toBe(false); + }); + + it('is incomplete while any media field is empty', () => { + expect( + isCallRecordingIngestionComplete({ + transcript: TRANSCRIPT_CONTENT, + audio: undefined, + video: VIDEO_VALUE, + }), + ).toBe(false); + expect( + isCallRecordingIngestionComplete({ + transcript: TRANSCRIPT_CONTENT, + audio: AUDIO_VALUE, + video: [], + }), + ).toBe(false); + }); +}); diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/domain/__tests__/is-call-recording-status-downgrade.test.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/domain/__tests__/is-call-recording-status-downgrade.test.ts new file mode 100644 index 0000000000..3e73245e19 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/domain/__tests__/is-call-recording-status-downgrade.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; + +import { isCallRecordingStatusDowngrade } from 'src/logic-functions/domain/is-call-recording-status-downgrade.util'; + +describe('isCallRecordingStatusDowngrade', () => { + it.each([ + ['SCHEDULED', 'JOINING', false], + ['JOINING', 'RECORDING', false], + ['RECORDING', 'PROCESSING', false], + ['PROCESSING', 'FAILED', false], + ['PROCESSING', 'COMPLETED', false], + ['RECORDING', 'RECORDING', false], + ['COMPLETED', 'RECORDING', true], + ['PROCESSING', 'JOINING', true], + ['FAILED', 'RECORDING', true], + ['JOINING', 'SCHEDULED', true], + ])('from %s to %s -> %s', (fromStatus, toStatus, expected) => { + expect(isCallRecordingStatusDowngrade({ fromStatus, toStatus })).toBe( + expected, + ); + }); + + it('never treats transitions from unknown statuses as downgrades', () => { + expect( + isCallRecordingStatusDowngrade({ + fromStatus: undefined, + toStatus: 'COMPLETED', + }), + ).toBe(false); + expect( + isCallRecordingStatusDowngrade({ + fromStatus: 'NOT_A_STATUS', + toStatus: 'SCHEDULED', + }), + ).toBe(false); + }); +}); diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/domain/__tests__/resolve-meeting-bot-policy-result.test.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/domain/__tests__/resolve-meeting-bot-policy-result.test.ts new file mode 100644 index 0000000000..4ed5739e37 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/domain/__tests__/resolve-meeting-bot-policy-result.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from 'vitest'; + +import { MeetingBotPreference } from 'src/constants/meeting-bot-preference'; +import { resolveMeetingBotPolicyResult } from 'src/logic-functions/domain/resolve-meeting-bot-policy-result.util'; + +const NOW = new Date('2026-01-01T12:00:00.000Z'); +const FUTURE_STARTS_AT = '2026-01-01T13:00:00.000Z'; +const FUTURE_ENDS_AT = '2026-01-01T14:00:00.000Z'; +const PAST_STARTS_AT = '2026-01-01T09:00:00.000Z'; +const PAST_ENDS_AT = '2026-01-01T10:00:00.000Z'; + +describe('resolveMeetingBotPolicyResult', () => { + it('requires a bot when preference is ON and the event is upcoming', () => { + expect( + resolveMeetingBotPolicyResult({ + input: { + meetingBotPreference: MeetingBotPreference.ON, + isCanceled: false, + startsAt: FUTURE_STARTS_AT, + endsAt: FUTURE_ENDS_AT, + conferenceLinkUrl: 'https://meet.example.com/team-sync', + }, + now: NOW, + }), + ).toEqual({ + shouldRequestBot: true, + reason: 'RECORDING_ENABLED', + }); + }); + + it('does not request a bot for ON when the meeting has no conference link', () => { + expect( + resolveMeetingBotPolicyResult({ + input: { + meetingBotPreference: MeetingBotPreference.ON, + isCanceled: false, + startsAt: FUTURE_STARTS_AT, + endsAt: FUTURE_ENDS_AT, + conferenceLinkUrl: undefined, + }, + now: NOW, + }), + ).toEqual({ + shouldRequestBot: false, + reason: 'MISSING_CONFERENCE_LINK', + }); + }); + + it('requires a bot without an event preference override', () => { + expect( + resolveMeetingBotPolicyResult({ + input: { + meetingBotPreference: undefined, + isCanceled: false, + startsAt: FUTURE_STARTS_AT, + endsAt: FUTURE_ENDS_AT, + conferenceLinkUrl: 'https://meet.example.com/team-sync', + }, + now: NOW, + }), + ).toEqual({ + shouldRequestBot: true, + reason: 'RECORDING_ENABLED', + }); + }); + + it('lets an OFF event preference opt out of workspace auto-recording', () => { + expect( + resolveMeetingBotPolicyResult({ + input: { + meetingBotPreference: MeetingBotPreference.OFF, + isCanceled: false, + startsAt: FUTURE_STARTS_AT, + endsAt: FUTURE_ENDS_AT, + conferenceLinkUrl: 'https://meet.example.com/team-sync', + }, + now: NOW, + }), + ).toEqual({ + shouldRequestBot: false, + reason: 'PREFERENCE_OFF', + }); + }); + + it('does not request a bot for a meeting that already ended', () => { + expect( + resolveMeetingBotPolicyResult({ + input: { + meetingBotPreference: undefined, + isCanceled: false, + startsAt: PAST_STARTS_AT, + endsAt: PAST_ENDS_AT, + conferenceLinkUrl: 'https://meet.example.com/team-sync', + }, + now: NOW, + }), + ).toEqual({ + shouldRequestBot: false, + reason: 'EVENT_NOT_UPCOMING', + }); + }); + + it('does not request a bot for a canceled meeting', () => { + expect( + resolveMeetingBotPolicyResult({ + input: { + meetingBotPreference: undefined, + isCanceled: true, + startsAt: FUTURE_STARTS_AT, + endsAt: FUTURE_ENDS_AT, + conferenceLinkUrl: 'https://meet.example.com/team-sync', + }, + now: NOW, + }), + ).toEqual({ + shouldRequestBot: false, + reason: 'EVENT_CANCELED', + }); + }); +}); diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/domain/__tests__/should-complete-call-recording-ingestion.test.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/domain/__tests__/should-complete-call-recording-ingestion.test.ts new file mode 100644 index 0000000000..87c95e643a --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/domain/__tests__/should-complete-call-recording-ingestion.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest'; + +import { CallRecordingStatus } from 'src/logic-functions/constants/call-recording-status'; +import { shouldCompleteCallRecordingIngestion } from 'src/logic-functions/domain/should-complete-call-recording-ingestion.util'; + +const filledTranscript = [{ participant: { id: 1 }, words: [] }]; +const filledAudio = [{ fileId: 'file-audio-1', label: 'audio.mp3' }]; +const filledVideo = [{ fileId: 'file-video-1', label: 'video.mp4' }]; + +describe('shouldCompleteCallRecordingIngestion', () => { + it('requires complete artifacts and billable timestamps before completion', () => { + expect( + shouldCompleteCallRecordingIngestion({ + current: { + status: CallRecordingStatus.PROCESSING, + startedAt: '2026-06-10T09:00:00.000Z', + endedAt: '2026-06-10T10:00:00.000Z', + transcript: filledTranscript, + audio: filledAudio, + video: filledVideo, + }, + updateData: {}, + }), + ).toBe(true); + + expect( + shouldCompleteCallRecordingIngestion({ + current: { + status: CallRecordingStatus.PROCESSING, + endedAt: '2026-06-10T10:00:00.000Z', + transcript: filledTranscript, + audio: filledAudio, + video: filledVideo, + }, + updateData: {}, + }), + ).toBe(false); + + expect( + shouldCompleteCallRecordingIngestion({ + current: { + status: CallRecordingStatus.PROCESSING, + transcript: filledTranscript, + audio: filledAudio, + video: filledVideo, + }, + updateData: { + startedAt: '2026-06-10T09:00:00.000Z', + endedAt: '2026-06-10T10:00:00.000Z', + }, + }), + ).toBe(true); + + expect( + shouldCompleteCallRecordingIngestion({ + current: { + status: CallRecordingStatus.PROCESSING, + startedAt: '2026-06-10T10:00:00.000Z', + endedAt: '2026-06-10T09:00:00.000Z', + transcript: filledTranscript, + audio: filledAudio, + video: filledVideo, + }, + updateData: {}, + }), + ).toBe(false); + }); + + it('does not complete a persisted failed recording', () => { + expect( + shouldCompleteCallRecordingIngestion({ + current: { + status: CallRecordingStatus.FAILED, + startedAt: '2026-06-10T09:00:00.000Z', + endedAt: '2026-06-10T10:00:00.000Z', + transcript: filledTranscript, + audio: filledAudio, + video: filledVideo, + }, + updateData: {}, + }), + ).toBe(false); + }); + + it('does not complete when the incoming update marks the recording as failed', () => { + expect( + shouldCompleteCallRecordingIngestion({ + current: { + status: CallRecordingStatus.PROCESSING, + startedAt: '2026-06-10T09:00:00.000Z', + endedAt: '2026-06-10T10:00:00.000Z', + transcript: filledTranscript, + audio: filledAudio, + video: filledVideo, + }, + updateData: { + status: CallRecordingStatus.FAILED, + }, + }), + ).toBe(false); + }); +}); diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/domain/aggregate-meeting-bot-policy-results-by-meeting.util.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/domain/aggregate-meeting-bot-policy-results-by-meeting.util.ts new file mode 100644 index 0000000000..07c7975b8c --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/domain/aggregate-meeting-bot-policy-results-by-meeting.util.ts @@ -0,0 +1,42 @@ +import { type MeetingBotPolicyResultForCalendarEvent } from 'src/logic-functions/types/meeting-bot-policy-result-for-calendar-event.type'; +import { type MeetingBotPolicyResultForMeeting } from 'src/logic-functions/types/meeting-bot-policy-result-for-meeting.type'; + +type MeetingBotPolicyResultForMeetingInput = Pick< + MeetingBotPolicyResultForCalendarEvent, + 'calendarEventId' | 'realMeetingKey' | 'shouldRequestBot' +>; + +export const aggregateMeetingBotPolicyResultsByMeeting = ( + perCalendarEventPolicyResults: MeetingBotPolicyResultForMeetingInput[], +): MeetingBotPolicyResultForMeeting[] => { + const meetingPolicyResultsByMeetingKey = new Map< + string, + MeetingBotPolicyResultForMeeting + >(); + + for (const { + calendarEventId, + realMeetingKey, + shouldRequestBot, + } of perCalendarEventPolicyResults) { + const meetingPolicyResult = meetingPolicyResultsByMeetingKey.get( + realMeetingKey, + ) ?? { + realMeetingKey, + shouldRequestBot: false, + calendarEventIds: [], + requestingCalendarEventIds: [], + }; + + meetingPolicyResult.calendarEventIds.push(calendarEventId); + + if (shouldRequestBot) { + meetingPolicyResult.shouldRequestBot = true; + meetingPolicyResult.requestingCalendarEventIds.push(calendarEventId); + } + + meetingPolicyResultsByMeetingKey.set(realMeetingKey, meetingPolicyResult); + } + + return [...meetingPolicyResultsByMeetingKey.values()]; +}; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/domain/build-failed-transcript-marker.util.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/domain/build-failed-transcript-marker.util.ts new file mode 100644 index 0000000000..c7719537f6 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/domain/build-failed-transcript-marker.util.ts @@ -0,0 +1,13 @@ +import { type TranscriptMarker } from 'src/logic-functions/types/transcript-marker.type'; + +export const buildFailedTranscriptMarker = ({ + recallTranscriptId, + subCode, +}: { + recallTranscriptId: string | null; + subCode: string | null; +}): TranscriptMarker => ({ + recallTranscriptId, + status: 'FAILED', + subCode, +}); diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/domain/build-meeting-bot-policy-result.util.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/domain/build-meeting-bot-policy-result.util.ts new file mode 100644 index 0000000000..fc181bcd06 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/domain/build-meeting-bot-policy-result.util.ts @@ -0,0 +1,53 @@ +import { MeetingBotPreference } from 'src/constants/meeting-bot-preference'; +import { type MeetingBotPolicyCalendarEventInput } from 'src/logic-functions/types/meeting-bot-policy-calendar-event-input.type'; +import { type MeetingBotPolicyResultForCalendarEvent } from 'src/logic-functions/types/meeting-bot-policy-result-for-calendar-event.type'; +import { computeRealMeetingKey } from 'src/logic-functions/domain/compute-real-meeting-key.util'; +import { resolveMeetingBotPolicyResult } from 'src/logic-functions/domain/resolve-meeting-bot-policy-result.util'; + +export const buildMeetingBotPolicyResult = ( + calendarEvent: MeetingBotPolicyCalendarEventInput, + now: Date, +): MeetingBotPolicyResultForCalendarEvent => { + const realMeetingKey = computeRealMeetingKey({ + calendarEventId: calendarEvent.id, + conferenceLinkUrl: calendarEvent.conferenceLinkUrl, + iCalUid: calendarEvent.iCalUid, + startsAt: calendarEvent.startsAt, + }); + + const meetingBotPreference = normalizeMeetingBotPreference( + calendarEvent.meetingBotPreference, + ); + + const policyResult = resolveMeetingBotPolicyResult({ + input: { + meetingBotPreference, + isCanceled: calendarEvent.isCanceled, + startsAt: calendarEvent.startsAt, + endsAt: calendarEvent.endsAt, + conferenceLinkUrl: calendarEvent.conferenceLinkUrl, + }, + now, + }); + + return { + calendarEventId: calendarEvent.id, + meetingBotPreference, + realMeetingKey, + ...policyResult, + }; +}; + +const normalizeMeetingBotPreference = ( + meetingBotPreference: string | undefined, +): MeetingBotPreference | undefined => + isMeetingBotPreference(meetingBotPreference) + ? meetingBotPreference + : undefined; + +const isMeetingBotPreference = ( + meetingBotPreference: string | undefined, +): meetingBotPreference is MeetingBotPreference => + Object.values(MeetingBotPreference).some( + (preference) => preference === meetingBotPreference, + ); diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/domain/build-pending-transcript-marker.util.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/domain/build-pending-transcript-marker.util.ts new file mode 100644 index 0000000000..ae65c4b556 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/domain/build-pending-transcript-marker.util.ts @@ -0,0 +1,13 @@ +import { type TranscriptMarker } from 'src/logic-functions/types/transcript-marker.type'; + +export const buildPendingTranscriptMarker = ({ + recallTranscriptId, + requestedAt, +}: { + recallTranscriptId: string; + requestedAt: string; +}): TranscriptMarker => ({ + recallTranscriptId, + status: 'PENDING', + requestedAt, +}); diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/domain/build-recall-bot-metadata.util.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/domain/build-recall-bot-metadata.util.ts new file mode 100644 index 0000000000..ce8f6b240a --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/domain/build-recall-bot-metadata.util.ts @@ -0,0 +1,21 @@ +import { type MeetingRecording } from 'src/logic-functions/types/meeting-recording.type'; +import { type RecallBotMetadata } from 'src/logic-functions/types/recall-bot-metadata.type'; +import { computeRealMeetingKey } from 'src/logic-functions/domain/compute-real-meeting-key.util'; + +export const buildRecallBotMetadata = ({ + callRecording, + calendarEvent, + workspaceId, +}: MeetingRecording & { workspaceId: string }): RecallBotMetadata => { + return { + twentyWorkspaceId: workspaceId, + twentyCallRecordingId: callRecording.id, + twentyCalendarEventId: calendarEvent.id, + twentyRealMeetingKey: computeRealMeetingKey({ + calendarEventId: calendarEvent.id, + conferenceLinkUrl: calendarEvent.conferenceLinkUrl, + iCalUid: calendarEvent.iCalUid, + startsAt: calendarEvent.startsAt, + }), + }; +}; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/domain/build-transcript-failure-reason.util.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/domain/build-transcript-failure-reason.util.ts new file mode 100644 index 0000000000..aab66570b5 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/domain/build-transcript-failure-reason.util.ts @@ -0,0 +1,7 @@ +import { isNull } from '@sniptt/guards'; + +export const buildTranscriptFailureReason = (subCode: string | null): string => { + return isNull(subCode) + ? 'transcript_failed' + : `transcript_failed:${subCode}`; +}; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/domain/compute-call-recording-charge.util.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/domain/compute-call-recording-charge.util.ts new file mode 100644 index 0000000000..b7d58291bb --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/domain/compute-call-recording-charge.util.ts @@ -0,0 +1,41 @@ +import { isUndefined } from '@sniptt/guards'; + +import { CALL_RECORDING_MICRO_CREDITS_PER_HOUR } from 'src/logic-functions/constants/call-recording-micro-credits-per-hour'; +import { MILLISECONDS_PER_MINUTE } from 'src/logic-functions/constants/milliseconds-per-minute'; + +const MILLISECONDS_PER_HOUR = 3_600_000; + +export type CallRecordingCharge = { + creditsUsedMicro: number; + quantityMinutes: number; +}; + +export const computeCallRecordingCharge = ({ + startedAt, + endedAt, +}: { + startedAt: string | undefined; + endedAt: string | undefined; +}): CallRecordingCharge | undefined => { + if (isUndefined(startedAt) || isUndefined(endedAt)) { + return undefined; + } + + const durationMilliseconds = + new Date(endedAt).getTime() - new Date(startedAt).getTime(); + + if (!Number.isFinite(durationMilliseconds) || durationMilliseconds <= 0) { + return undefined; + } + + return { + creditsUsedMicro: Math.round( + (durationMilliseconds / MILLISECONDS_PER_HOUR) * + CALL_RECORDING_MICRO_CREDITS_PER_HOUR, + ), + quantityMinutes: Math.max( + 1, + Math.round(durationMilliseconds / MILLISECONDS_PER_MINUTE), + ), + }; +}; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/domain/compute-call-recording-id-for-meeting.util.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/domain/compute-call-recording-id-for-meeting.util.ts new file mode 100644 index 0000000000..5df420dd35 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/domain/compute-call-recording-id-for-meeting.util.ts @@ -0,0 +1,16 @@ +import { createHash } from 'crypto'; + +// Same meeting key → same id: the primary key serializes concurrent creates. +export const computeCallRecordingIdForMeeting = ( + realMeetingKey: string, +): string => { + const bytes = createHash('sha256').update(realMeetingKey).digest(); + + // v4 version/variant bits so server-side UUID validation accepts the hash. + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + + const hex = bytes.subarray(0, 16).toString('hex'); + + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +}; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/domain/compute-real-meeting-key.util.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/domain/compute-real-meeting-key.util.ts new file mode 100644 index 0000000000..56f59cf7f3 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/domain/compute-real-meeting-key.util.ts @@ -0,0 +1,48 @@ +import { isUndefined } from '@sniptt/guards'; + +import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util'; + +type ComputeRealMeetingKeyInput = { + calendarEventId: string; + conferenceLinkUrl: unknown; + iCalUid: string | undefined; + startsAt: string | undefined; +}; + +export const computeRealMeetingKey = ({ + calendarEventId, + conferenceLinkUrl, + iCalUid, + startsAt, +}: ComputeRealMeetingKeyInput): string => { + const normalizedConferenceLink = normalizeConferenceLink(conferenceLinkUrl); + + if (!isUndefined(normalizedConferenceLink)) { + return `link:${normalizedConferenceLink}:${startsAt ?? ''}`; + } + + if (isNonEmptyString(iCalUid)) { + return `ical:${iCalUid}:${startsAt ?? ''}`; + } + + return `event:${calendarEventId}`; +}; + +const normalizeConferenceLink = ( + conferenceLinkUrl: unknown, +): string | undefined => { + if (!isNonEmptyString(conferenceLinkUrl)) { + return undefined; + } + + const withoutProtocol = conferenceLinkUrl + .trim() + .toLowerCase() + .replace(/^https?:\/\//, '') + .replace(/^www\./, ''); + + const withoutQueryAndFragment = withoutProtocol.split(/[?#]/)[0]; + const withoutTrailingSlash = withoutQueryAndFragment.replace(/\/+$/, ''); + + return withoutTrailingSlash === '' ? undefined : withoutTrailingSlash; +}; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/domain/compute-recall-bot-join-at.util.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/domain/compute-recall-bot-join-at.util.ts new file mode 100644 index 0000000000..cfef069543 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/domain/compute-recall-bot-join-at.util.ts @@ -0,0 +1,34 @@ +import { DEFAULT_MEETING_BOT_JOIN_EARLY_MINUTES } from 'src/logic-functions/constants/default-meeting-bot-join-early-minutes'; +import { MILLISECONDS_PER_MINUTE } from 'src/logic-functions/constants/milliseconds-per-minute'; +import { MEETING_BOT_JOIN_EARLY_MINUTES_ENV_VAR_NAME } from 'src/logic-functions/constants/meeting-bot-join-early-minutes-env-var-name'; +import { getApplicationVariableValue } from 'src/logic-functions/utils/get-application-variable-value.util'; +import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util'; + +export const computeRecallBotJoinAt = (meetingStartsAt: string): string => { + const meetingStartTimeInMilliseconds = new Date(meetingStartsAt).getTime(); + + if (Number.isNaN(meetingStartTimeInMilliseconds)) { + return meetingStartsAt; + } + + return new Date( + meetingStartTimeInMilliseconds - + getRecallBotJoinEarlyMinutes() * MILLISECONDS_PER_MINUTE, + ).toISOString(); +}; + +const getRecallBotJoinEarlyMinutes = (): number => { + const rawValue = getApplicationVariableValue( + MEETING_BOT_JOIN_EARLY_MINUTES_ENV_VAR_NAME, + ); + + if (!isNonEmptyString(rawValue)) { + return DEFAULT_MEETING_BOT_JOIN_EARLY_MINUTES; + } + + const joinEarlyMinutes = Number(rawValue.trim()); + + return Number.isInteger(joinEarlyMinutes) && joinEarlyMinutes >= 0 + ? joinEarlyMinutes + : DEFAULT_MEETING_BOT_JOIN_EARLY_MINUTES; +}; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/domain/is-call-recording-ingestion-complete.util.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/domain/is-call-recording-ingestion-complete.util.ts new file mode 100644 index 0000000000..ec32b40758 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/domain/is-call-recording-ingestion-complete.util.ts @@ -0,0 +1,19 @@ +import { isNonEmptyArray, isNull, isUndefined } from '@sniptt/guards'; + +import { type FilesFieldValue } from 'src/logic-functions/types/files-field-value.type'; +import { parseTranscriptMarker } from 'src/logic-functions/domain/parse-transcript-marker.util'; + +export const isCallRecordingIngestionComplete = ({ + transcript, + audio, + video, +}: { + transcript: unknown; + audio: FilesFieldValue | undefined; + video: FilesFieldValue | undefined; +}): boolean => + !isNull(transcript) && + !isUndefined(transcript) && + isUndefined(parseTranscriptMarker(transcript)) && + isNonEmptyArray(audio) && + isNonEmptyArray(video); diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/domain/is-call-recording-status-downgrade.util.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/domain/is-call-recording-status-downgrade.util.ts new file mode 100644 index 0000000000..45843102bb --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/domain/is-call-recording-status-downgrade.util.ts @@ -0,0 +1,37 @@ +import { isUndefined } from '@sniptt/guards'; + +import { CallRecordingStatus } from 'src/logic-functions/constants/call-recording-status'; + +// Deliveries are unordered; a late event must never move status backwards. +const CALL_RECORDING_STATUS_PROGRESSION: Record = { + [CallRecordingStatus.SCHEDULED]: 0, + [CallRecordingStatus.JOINING]: 1, + [CallRecordingStatus.RECORDING]: 2, + [CallRecordingStatus.PROCESSING]: 3, + [CallRecordingStatus.FAILED]: 4, + [CallRecordingStatus.COMPLETED]: 5, +}; + +const getCallRecordingStatusRank = (status: string): number | undefined => + status in CALL_RECORDING_STATUS_PROGRESSION + ? CALL_RECORDING_STATUS_PROGRESSION[status as CallRecordingStatus] + : undefined; + +export const isCallRecordingStatusDowngrade = ({ + fromStatus, + toStatus, +}: { + fromStatus: string | undefined; + toStatus: string; +}): boolean => { + const fromRank = isUndefined(fromStatus) + ? undefined + : getCallRecordingStatusRank(fromStatus); + const toRank = getCallRecordingStatusRank(toStatus); + + if (isUndefined(fromRank) || isUndefined(toRank)) { + return false; + } + + return toRank < fromRank; +}; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/domain/is-recall-recording-done-signal.util.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/domain/is-recall-recording-done-signal.util.ts new file mode 100644 index 0000000000..50b2efef7c --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/domain/is-recall-recording-done-signal.util.ts @@ -0,0 +1,13 @@ +export const isRecallRecordingDoneSignal = ({ + event, + statusCode, +}: { + event: string; + statusCode: string | undefined; +}): boolean => { + return ( + event === 'recording.done' || + event === 'recording.failed' || + statusCode === 'done' + ); +}; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/domain/map-recall-status-code-to-call-recording-status.util.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/domain/map-recall-status-code-to-call-recording-status.util.ts new file mode 100644 index 0000000000..9af31afc32 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/domain/map-recall-status-code-to-call-recording-status.util.ts @@ -0,0 +1,26 @@ +import { CallRecordingStatus } from 'src/logic-functions/constants/call-recording-status'; + +export const mapRecallStatusCodeToCallRecordingStatus = ( + statusCode: string | undefined, +): CallRecordingStatus | undefined => { + switch (statusCode) { + case 'joining_call': + case 'in_waiting_room': + return CallRecordingStatus.JOINING; + case 'in_call_not_recording': + case 'recording_permission_allowed': + case 'in_call_recording': + return CallRecordingStatus.RECORDING; + // 'done' stays PROCESSING: COMPLETED is set only after all artifacts are ingested. + case 'call_ended': + case 'analysis_done': + case 'done': + return CallRecordingStatus.PROCESSING; + case 'fatal': + case 'analysis_failed': + case 'recording_permission_denied': + return CallRecordingStatus.FAILED; + default: + return undefined; + } +}; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/domain/parse-transcript-marker.util.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/domain/parse-transcript-marker.util.ts new file mode 100644 index 0000000000..b2806f2a7b --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/domain/parse-transcript-marker.util.ts @@ -0,0 +1,29 @@ +import { isString, isUndefined } from '@sniptt/guards'; + +import { type TranscriptMarker } from 'src/logic-functions/types/transcript-marker.type'; +import { asRecord } from 'src/logic-functions/utils/as-record.util'; + +export const parseTranscriptMarker = ( + transcript: unknown, +): TranscriptMarker | undefined => { + const candidate = asRecord(transcript); + + if (isUndefined(candidate)) { + return undefined; + } + + if (candidate.status !== 'PENDING' && candidate.status !== 'FAILED') { + return undefined; + } + + return { + recallTranscriptId: isString(candidate.recallTranscriptId) + ? candidate.recallTranscriptId + : null, + status: candidate.status, + ...(isString(candidate.requestedAt) + ? { requestedAt: candidate.requestedAt } + : {}), + ...(isString(candidate.subCode) ? { subCode: candidate.subCode } : {}), + }; +}; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/domain/resolve-meeting-bot-policy-result.util.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/domain/resolve-meeting-bot-policy-result.util.ts new file mode 100644 index 0000000000..d90cc1906f --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/domain/resolve-meeting-bot-policy-result.util.ts @@ -0,0 +1,72 @@ +import { MeetingBotPreference } from 'src/constants/meeting-bot-preference'; +import { type MeetingBotPolicyInput } from 'src/logic-functions/types/meeting-bot-policy-input.type'; +import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util'; +import { type MeetingBotPolicyNotRequiredReason } from 'src/logic-functions/types/meeting-bot-policy-not-required-reason.type'; +import { type MeetingBotPolicyRequiredReason } from 'src/logic-functions/types/meeting-bot-policy-required-reason.type'; +import { type MeetingBotPolicyResult } from 'src/logic-functions/types/meeting-bot-policy-result.type'; + +type ResolveMeetingBotPolicyResultArgs = { + input: MeetingBotPolicyInput; + now: Date; +}; + +export const resolveMeetingBotPolicyResult = ({ + input, + now, +}: ResolveMeetingBotPolicyResultArgs): MeetingBotPolicyResult => { + if (input.isCanceled) { + return botNotRequired('EVENT_CANCELED'); + } + + if (input.meetingBotPreference === MeetingBotPreference.OFF) { + return botNotRequired('PREFERENCE_OFF'); + } + + if (!isNonEmptyString(input.conferenceLinkUrl)) { + return botNotRequired('MISSING_CONFERENCE_LINK'); + } + + if ( + !isCalendarEventInFuture({ + startsAt: input.startsAt, + endsAt: input.endsAt, + now, + }) + ) { + return botNotRequired('EVENT_NOT_UPCOMING'); + } + + return botRequired('RECORDING_ENABLED'); +}; + +const isCalendarEventInFuture = ({ + startsAt, + endsAt, + now, +}: { + startsAt: string | undefined; + endsAt: string | undefined; + now: Date; +}): boolean => { + const reference = endsAt ?? startsAt; + + if (!isNonEmptyString(reference)) { + return false; + } + + const referenceTime = new Date(reference).getTime(); + + if (Number.isNaN(referenceTime)) { + return false; + } + + return referenceTime > now.getTime(); +}; + +const botRequired = ( + reason: MeetingBotPolicyRequiredReason, +): MeetingBotPolicyResult => ({ shouldRequestBot: true, reason }); + +const botNotRequired = ( + reason: MeetingBotPolicyNotRequiredReason, +): MeetingBotPolicyResult => ({ shouldRequestBot: false, reason }); diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/domain/should-complete-call-recording-ingestion.util.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/domain/should-complete-call-recording-ingestion.util.ts new file mode 100644 index 0000000000..5e2f827e45 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/domain/should-complete-call-recording-ingestion.util.ts @@ -0,0 +1,32 @@ +import { CallRecordingStatus } from 'src/logic-functions/constants/call-recording-status'; +import { type FilesFieldValue } from 'src/logic-functions/types/files-field-value.type'; +import { computeCallRecordingCharge } from 'src/logic-functions/domain/compute-call-recording-charge.util'; +import { isCallRecordingIngestionComplete } from 'src/logic-functions/domain/is-call-recording-ingestion-complete.util'; +import { type CallRecordingUpdateFields } from 'src/logic-functions/data/update-call-recording.util'; + +export const shouldCompleteCallRecordingIngestion = ({ + current, + updateData, +}: { + current: { + status?: string; + startedAt?: string; + endedAt?: string; + transcript?: unknown; + audio?: FilesFieldValue; + video?: FilesFieldValue; + }; + updateData: CallRecordingUpdateFields; +}): boolean => + current.status !== CallRecordingStatus.COMPLETED && + current.status !== CallRecordingStatus.FAILED && + updateData.status !== CallRecordingStatus.FAILED && + computeCallRecordingCharge({ + startedAt: updateData.startedAt ?? current.startedAt, + endedAt: updateData.endedAt ?? current.endedAt, + }) !== undefined && + isCallRecordingIngestionComplete({ + transcript: updateData.transcript ?? current.transcript, + audio: updateData.audio ?? current.audio, + video: updateData.video ?? current.video, + }); diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/flows/__tests__/charge-completed-call-recording.test.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/flows/__tests__/charge-completed-call-recording.test.ts new file mode 100644 index 0000000000..f1e9077ed8 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/flows/__tests__/charge-completed-call-recording.test.ts @@ -0,0 +1,45 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { chargeCompletedCallRecording } from 'src/logic-functions/flows/charge-completed-call-recording.util'; + +const chargeCreditsMock = vi.hoisted(() => vi.fn()); + +vi.mock('twenty-sdk/billing', () => ({ + chargeCredits: chargeCreditsMock, +})); + +describe('chargeCompletedCallRecording', () => { + beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + chargeCreditsMock.mockReset(); + chargeCreditsMock.mockResolvedValue(undefined); + }); + + it('charges prorated micro-credits with the recording duration in minutes', async () => { + await chargeCompletedCallRecording({ + callRecordingId: 'call-recording-1', + startedAt: '2026-06-10T09:00:00.000Z', + endedAt: '2026-06-10T09:30:00.000Z', + }); + + expect(chargeCreditsMock).toHaveBeenCalledWith({ + creditsUsedMicro: 500_000, + quantity: 30, + operationType: 'CALL_RECORDING', + resourceContext: 'recall', + }); + }); + + it('skips and warns loudly when timestamps are unusable', async () => { + await chargeCompletedCallRecording({ + callRecordingId: 'call-recording-1', + startedAt: undefined, + endedAt: '2026-06-10T09:30:00.000Z', + }); + + expect(chargeCreditsMock).not.toHaveBeenCalled(); + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('will not be billed'), + ); + }); +}); diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/flows/__tests__/complete-and-charge-call-recording.test.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/flows/__tests__/complete-and-charge-call-recording.test.ts new file mode 100644 index 0000000000..42c48e63cf --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/flows/__tests__/complete-and-charge-call-recording.test.ts @@ -0,0 +1,61 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const completeCallRecordingIngestionMock = vi.hoisted(() => vi.fn()); +const chargeCompletedCallRecordingMock = vi.hoisted(() => vi.fn()); + +vi.mock( + 'src/logic-functions/data/complete-call-recording-ingestion.util', + () => ({ + completeCallRecordingIngestion: completeCallRecordingIngestionMock, + }), +); + +vi.mock( + 'src/logic-functions/flows/charge-completed-call-recording.util', + () => ({ + chargeCompletedCallRecording: chargeCompletedCallRecordingMock, + }), +); + +import { completeAndChargeCallRecording } from 'src/logic-functions/flows/complete-and-charge-call-recording.util'; + +describe('completeAndChargeCallRecording', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('charges exactly once when this path wins the completion claim', async () => { + completeCallRecordingIngestionMock.mockResolvedValue(true); + + const claimed = await completeAndChargeCallRecording({} as never, { + id: 'call-recording-1', + startedAt: '2026-06-10T12:00:00.000Z', + endedAt: '2026-06-10T13:00:00.000Z', + }); + + expect(claimed).toBe(true); + expect(completeCallRecordingIngestionMock).toHaveBeenCalledWith( + {}, + { id: 'call-recording-1' }, + ); + expect(chargeCompletedCallRecordingMock).toHaveBeenCalledTimes(1); + expect(chargeCompletedCallRecordingMock).toHaveBeenCalledWith({ + callRecordingId: 'call-recording-1', + startedAt: '2026-06-10T12:00:00.000Z', + endedAt: '2026-06-10T13:00:00.000Z', + }); + }); + + it('does not charge when another path already completed the recording', async () => { + completeCallRecordingIngestionMock.mockResolvedValue(false); + + const claimed = await completeAndChargeCallRecording({} as never, { + id: 'call-recording-1', + startedAt: '2026-06-10T12:00:00.000Z', + endedAt: '2026-06-10T13:00:00.000Z', + }); + + expect(claimed).toBe(false); + expect(chargeCompletedCallRecordingMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/flows/__tests__/converge-diverged-call-recordings.test.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/flows/__tests__/converge-diverged-call-recordings.test.ts new file mode 100644 index 0000000000..7981d02a5b --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/flows/__tests__/converge-diverged-call-recordings.test.ts @@ -0,0 +1,729 @@ +import { type CoreApiClient } from 'twenty-client-sdk/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { convergeDivergedCallRecordings } from 'src/logic-functions/flows/converge-diverged-call-recordings.util'; + +const getRecallBotMock = vi.hoisted(() => vi.fn()); +const listRecallTranscriptsMock = vi.hoisted(() => vi.fn()); +const createAsyncRecallTranscriptMock = vi.hoisted(() => vi.fn()); +const downloadTranscriptMock = vi.hoisted(() => vi.fn()); +const ingestCallRecordingMediaMock = vi.hoisted(() => vi.fn()); +const chargeCompletedCallRecordingMock = vi.hoisted(() => vi.fn()); + +vi.mock('src/logic-functions/recall-api/get-recall-bot.util', () => ({ + getRecallBot: getRecallBotMock, +})); + +vi.mock('src/logic-functions/recall-api/list-recall-transcripts.util', () => ({ + listRecallTranscripts: listRecallTranscriptsMock, +})); + +vi.mock( + 'src/logic-functions/recall-api/create-async-recall-transcript.util', + () => ({ + createAsyncRecallTranscript: createAsyncRecallTranscriptMock, + }), +); + +vi.mock('src/logic-functions/flows/download-transcript.util', () => ({ + downloadTranscript: downloadTranscriptMock, +})); + +vi.mock('src/logic-functions/flows/ingest-call-recording-media.util', () => ({ + ingestCallRecordingMedia: ingestCallRecordingMediaMock, +})); + +vi.mock( + 'src/logic-functions/flows/charge-completed-call-recording.util', + () => ({ + chargeCompletedCallRecording: chargeCompletedCallRecordingMock, + }), +); + +const NOW = new Date('2026-06-10T12:00:00.000Z'); + +type CallRecordingNode = Record; + +class FakeCoreApiClient { + mutations: Array<{ id: string; data: Record }> = []; + + constructor(private callRecordingNodes: CallRecordingNode[]) {} + + async query(_query: any): Promise { + return { + callRecordings: { + pageInfo: { hasNextPage: false, endCursor: undefined }, + edges: this.callRecordingNodes.map((node) => ({ node })), + }, + }; + } + + async mutation(mutation: any): Promise { + if (mutation.updateCallRecordings !== undefined) { + const { filter, data } = mutation.updateCallRecordings.__args; + const id = filter.id.eq; + + this.mutations.push({ id, data }); + + return { updateCallRecordings: [{ id }] }; + } + + const { id, data } = mutation.updateCallRecording.__args; + + this.mutations.push({ id, data }); + + return { updateCallRecording: { id } }; + } +} + +const buildClient = (callRecordingNodes: CallRecordingNode[]) => + new FakeCoreApiClient(callRecordingNodes); + +const buildStuckRecordingNode = ( + overrides: CallRecordingNode = {}, +): CallRecordingNode => ({ + id: 'call-recording-1', + status: 'RECORDING', + startedAt: null, + endedAt: null, + externalBotId: 'recall-bot-1', + externalRecordingId: null, + transcript: null, + audio: null, + video: null, + createdAt: '2026-06-09T12:00:00.000Z', + calendarEvent: { + startsAt: '2026-06-09T12:00:00.000Z', + endsAt: '2026-06-09T13:00:00.000Z', + }, + ...overrides, +}); + +describe('convergeDivergedCallRecordings', () => { + beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + getRecallBotMock.mockReset(); + listRecallTranscriptsMock.mockReset(); + listRecallTranscriptsMock.mockResolvedValue({ + ok: true, + transcripts: [], + }); + createAsyncRecallTranscriptMock.mockReset(); + createAsyncRecallTranscriptMock.mockResolvedValue({ + ok: true, + transcriptId: 'recall-transcript-1', + }); + downloadTranscriptMock.mockReset(); + downloadTranscriptMock.mockResolvedValue({ outcome: 'pending' }); + ingestCallRecordingMediaMock.mockReset(); + ingestCallRecordingMediaMock.mockResolvedValue({}); + chargeCompletedCallRecordingMock.mockReset(); + chargeCompletedCallRecordingMock.mockResolvedValue(undefined); + }); + + it('heals a stuck RECORDING record from the Recall bot state', async () => { + getRecallBotMock.mockResolvedValue({ + ok: true, + bot: { + status_changes: [ + { code: 'in_call_recording', created_at: '2026-06-09T13:02:30.000Z' }, + { code: 'call_ended', created_at: '2026-06-09T14:00:30.000Z' }, + { code: 'done', created_at: '2026-06-09T14:05:00.000Z' }, + ], + recordings: [ + { + id: 'recall-recording-1', + started_at: '2026-06-09T13:02:00.000Z', + completed_at: '2026-06-09T14:00:00.000Z', + }, + ], + }, + }); + const client = buildClient([buildStuckRecordingNode()]); + + const result = await convergeDivergedCallRecordings({ + client: client as unknown as CoreApiClient, + now: NOW, + }); + + expect(getRecallBotMock).toHaveBeenCalledWith({ + externalBotId: 'recall-bot-1', + }); + expect(ingestCallRecordingMediaMock).toHaveBeenCalledWith({ + callRecordingId: 'call-recording-1', + externalRecordingId: 'recall-recording-1', + hasAudio: false, + hasVideo: false, + }); + expect(listRecallTranscriptsMock).toHaveBeenCalledWith({ + externalRecordingId: 'recall-recording-1', + }); + expect(createAsyncRecallTranscriptMock).toHaveBeenCalledWith({ + externalRecordingId: 'recall-recording-1', + callRecordingId: 'call-recording-1', + }); + expect(client.mutations).toEqual([ + expect.objectContaining({ + id: 'call-recording-1', + data: expect.objectContaining({ + status: 'PROCESSING', + startedAt: '2026-06-09T13:02:00.000Z', + endedAt: '2026-06-09T14:00:00.000Z', + externalRecordingId: 'recall-recording-1', + }), + }), + ]); + expect(chargeCompletedCallRecordingMock).not.toHaveBeenCalled(); + expect(result).toEqual({ + candidateCount: 1, + updatedCallRecordingIds: ['call-recording-1'], + markedFailedCallRecordingIds: [], + requestedTranscriptCallRecordingIds: ['call-recording-1'], + unconvergeableCallRecordingIds: [], + skippedNotStartedCallRecordingIds: [], + }); + }); + + it('marks FAILED when Recall is done but has no recording artifact path', async () => { + getRecallBotMock.mockResolvedValue({ + ok: true, + bot: { + status_changes: [ + { code: 'done', created_at: '2026-06-09T14:05:00.000Z' }, + ], + recordings: [], + }, + }); + const client = buildClient([buildStuckRecordingNode()]); + + const result = await convergeDivergedCallRecordings({ + client: client as unknown as CoreApiClient, + now: NOW, + }); + + expect(listRecallTranscriptsMock).not.toHaveBeenCalled(); + expect(ingestCallRecordingMediaMock).not.toHaveBeenCalled(); + expect(client.mutations).toEqual([ + { + id: 'call-recording-1', + data: { + status: 'FAILED', + meetingBotFailureReason: 'recording_artifacts_unavailable', + }, + }, + ]); + expect(result.updatedCallRecordingIds).toEqual(['call-recording-1']); + }); + + it('completes and charges when convergence lands the last artifact', async () => { + getRecallBotMock.mockResolvedValue({ + ok: true, + bot: { + status_changes: [ + { code: 'done', created_at: '2026-06-09T14:05:00.000Z' }, + ], + recordings: [ + { + id: 'recall-recording-1', + started_at: '2026-06-09T13:02:00.000Z', + completed_at: '2026-06-09T14:00:00.000Z', + }, + ], + }, + }); + ingestCallRecordingMediaMock.mockResolvedValue({ + audio: [{ fileId: 'file-audio-1', label: 'audio.mp3' }], + video: [{ fileId: 'file-video-1', label: 'video.mp4' }], + }); + const client = buildClient([ + buildStuckRecordingNode({ + status: 'PROCESSING', + startedAt: '2026-06-09T13:02:00.000Z', + endedAt: '2026-06-09T14:00:00.000Z', + externalRecordingId: 'recall-recording-1', + transcript: [{ participant: { id: 1 }, words: [] }], + }), + ]); + + const result = await convergeDivergedCallRecordings({ + client: client as unknown as CoreApiClient, + now: NOW, + }); + + expect(createAsyncRecallTranscriptMock).not.toHaveBeenCalled(); + expect(listRecallTranscriptsMock).not.toHaveBeenCalled(); + expect(client.mutations).toEqual([ + { + id: 'call-recording-1', + data: { + audio: [{ fileId: 'file-audio-1', label: 'audio.mp3' }], + video: [{ fileId: 'file-video-1', label: 'video.mp4' }], + }, + }, + { + id: 'call-recording-1', + data: { status: 'COMPLETED' }, + }, + ]); + expect(chargeCompletedCallRecordingMock).toHaveBeenCalledWith({ + callRecordingId: 'call-recording-1', + startedAt: '2026-06-09T13:02:00.000Z', + endedAt: '2026-06-09T14:00:00.000Z', + }); + expect(result).toEqual({ + candidateCount: 1, + updatedCallRecordingIds: ['call-recording-1'], + markedFailedCallRecordingIds: [], + requestedTranscriptCallRecordingIds: [], + unconvergeableCallRecordingIds: [], + skippedNotStartedCallRecordingIds: [], + }); + }); + + it('skips records whose meeting has not started yet', async () => { + const client = buildClient([ + buildStuckRecordingNode({ + calendarEvent: { + startsAt: '2026-06-10T12:30:00.000Z', + endsAt: '2026-06-10T13:30:00.000Z', + }, + }), + ]); + + const result = await convergeDivergedCallRecordings({ + client: client as unknown as CoreApiClient, + now: NOW, + }); + + expect(getRecallBotMock).not.toHaveBeenCalled(); + expect(client.mutations).toEqual([]); + expect(result.skippedNotStartedCallRecordingIds).toEqual([ + 'call-recording-1', + ]); + }); + + it('converges a meeting that ended early while its scheduled end is still in the future', async () => { + getRecallBotMock.mockResolvedValue({ + ok: true, + bot: { + status_changes: [ + { code: 'done', created_at: '2026-06-10T11:30:00.000Z' }, + ], + recordings: [ + { + id: 'recall-recording-1', + started_at: '2026-06-10T11:05:00.000Z', + completed_at: '2026-06-10T11:25:00.000Z', + }, + ], + }, + }); + const client = buildClient([ + buildStuckRecordingNode({ + calendarEvent: { + startsAt: '2026-06-10T11:00:00.000Z', + endsAt: '2026-06-10T13:00:00.000Z', + }, + }), + ]); + + const result = await convergeDivergedCallRecordings({ + client: client as unknown as CoreApiClient, + now: NOW, + }); + + expect(getRecallBotMock).toHaveBeenCalledWith({ + externalBotId: 'recall-bot-1', + }); + expect(result.updatedCallRecordingIds).toEqual(['call-recording-1']); + expect(result.skippedNotStartedCallRecordingIds).toEqual([]); + }); + + it('marks FAILED without clearing the bot id when Recall returns 404', async () => { + getRecallBotMock.mockResolvedValue({ + ok: false, + status: 404, + errorMessage: 'Recall API responded with HTTP 404', + }); + const client = buildClient([buildStuckRecordingNode()]); + + const result = await convergeDivergedCallRecordings({ + client: client as unknown as CoreApiClient, + now: NOW, + }); + + expect(client.mutations).toEqual([ + { + id: 'call-recording-1', + data: { + status: 'FAILED', + meetingBotFailureReason: 'recall_bot_not_found', + }, + }, + ]); + expect(result.markedFailedCallRecordingIds).toEqual(['call-recording-1']); + expect(console.warn).toHaveBeenCalled(); + }); + + it('does not downgrade a COMPLETED record when its bot 404s', async () => { + getRecallBotMock.mockResolvedValue({ + ok: false, + status: 404, + errorMessage: 'Recall API responded with HTTP 404', + }); + const client = buildClient([ + buildStuckRecordingNode({ + status: 'COMPLETED', + startedAt: '2026-06-09T13:02:00.000Z', + transcript: [{ participant: { id: 1 }, words: [] }], + }), + ]); + + const result = await convergeDivergedCallRecordings({ + client: client as unknown as CoreApiClient, + now: NOW, + }); + + expect(client.mutations).toEqual([]); + expect(result.unconvergeableCallRecordingIds).toEqual(['call-recording-1']); + }); + + it('logs candidates whose meeting ended before the lookback bound instead of converging them', async () => { + const client = buildClient([ + buildStuckRecordingNode({ + calendarEvent: { endsAt: '2026-06-01T13:00:00.000Z' }, + }), + ]); + + const result = await convergeDivergedCallRecordings({ + client: client as unknown as CoreApiClient, + now: NOW, + }); + + expect(getRecallBotMock).not.toHaveBeenCalled(); + expect(client.mutations).toEqual([]); + expect(result.unconvergeableCallRecordingIds).toEqual(['call-recording-1']); + expect(console.warn).toHaveBeenCalled(); + }); + + it('converges candidates created long before a recently ended meeting', async () => { + getRecallBotMock.mockResolvedValue({ + ok: true, + bot: { + status_changes: [ + { code: 'in_call_recording', created_at: '2026-06-09T13:02:00.000Z' }, + ], + }, + }); + const client = buildClient([ + buildStuckRecordingNode({ + createdAt: '2026-06-01T12:00:00.000Z', + startedAt: '2026-06-09T13:02:00.000Z', + }), + ]); + + const result = await convergeDivergedCallRecordings({ + client: client as unknown as CoreApiClient, + now: NOW, + }); + + expect(getRecallBotMock).toHaveBeenCalledWith({ + externalBotId: 'recall-bot-1', + }); + expect(result.unconvergeableCallRecordingIds).toEqual([]); + }); + + it('applies the downgrade guard to pulled statuses while still filling timestamps', async () => { + getRecallBotMock.mockResolvedValue({ + ok: true, + bot: { + status_changes: [ + { code: 'in_call_recording', created_at: '2026-06-09T13:02:00.000Z' }, + ], + recordings: [ + { id: 'recall-recording-1', started_at: '2026-06-09T13:02:00.000Z' }, + ], + }, + }); + const client = buildClient([ + buildStuckRecordingNode({ status: 'PROCESSING' }), + ]); + + await convergeDivergedCallRecordings({ + client: client as unknown as CoreApiClient, + now: NOW, + }); + + expect(client.mutations).toEqual([ + { + id: 'call-recording-1', + data: { + startedAt: '2026-06-09T13:02:00.000Z', + externalRecordingId: 'recall-recording-1', + }, + }, + ]); + }); + + it('requests a transcript for a COMPLETED candidate that has none', async () => { + getRecallBotMock.mockResolvedValue({ + ok: true, + bot: { + status_changes: [ + { code: 'done', created_at: '2026-06-09T14:05:00.000Z' }, + ], + recordings: [ + { + id: 'recall-recording-1', + started_at: '2026-06-09T13:02:00.000Z', + completed_at: '2026-06-09T14:00:00.000Z', + }, + ], + }, + }); + const client = buildClient([ + buildStuckRecordingNode({ + status: 'COMPLETED', + startedAt: '2026-06-09T13:02:00.000Z', + externalRecordingId: 'recall-recording-1', + }), + ]); + + const result = await convergeDivergedCallRecordings({ + client: client as unknown as CoreApiClient, + now: NOW, + }); + + expect(createAsyncRecallTranscriptMock).toHaveBeenCalledTimes(1); + expect(createAsyncRecallTranscriptMock).toHaveBeenCalledWith({ + externalRecordingId: 'recall-recording-1', + callRecordingId: 'call-recording-1', + }); + expect(client.mutations).toEqual([ + { + id: 'call-recording-1', + data: { + endedAt: '2026-06-09T14:00:00.000Z', + transcript: { + recallTranscriptId: 'recall-transcript-1', + status: 'PENDING', + requestedAt: NOW.toISOString(), + }, + }, + }, + ]); + expect(result.requestedTranscriptCallRecordingIds).toEqual([ + 'call-recording-1', + ]); + }); + + it('does not create a duplicate transcript when Recall already has one processing', async () => { + getRecallBotMock.mockResolvedValue({ + ok: true, + bot: { + status_changes: [ + { code: 'done', created_at: '2026-06-09T14:05:00.000Z' }, + ], + recordings: [ + { + id: 'recall-recording-1', + started_at: '2026-06-09T13:02:00.000Z', + completed_at: '2026-06-09T14:00:00.000Z', + }, + ], + }, + }); + listRecallTranscriptsMock.mockResolvedValue({ + ok: true, + transcripts: [ + { + id: 'recall-transcript-1', + statusCode: 'processing', + statusSubCode: undefined, + }, + ], + }); + const client = buildClient([buildStuckRecordingNode()]); + + const result = await convergeDivergedCallRecordings({ + client: client as unknown as CoreApiClient, + now: NOW, + }); + + expect(createAsyncRecallTranscriptMock).not.toHaveBeenCalled(); + expect(downloadTranscriptMock).not.toHaveBeenCalled(); + expect(client.mutations).toEqual([ + { + id: 'call-recording-1', + data: { + status: 'PROCESSING', + startedAt: '2026-06-09T13:02:00.000Z', + endedAt: '2026-06-09T14:00:00.000Z', + externalRecordingId: 'recall-recording-1', + }, + }, + ]); + expect(result.requestedTranscriptCallRecordingIds).toEqual([]); + }); + + it('fills a completed Recall transcript artifact during convergence', async () => { + const transcriptContent = [ + { + participant: { id: 1, name: 'Ada' }, + words: [{ text: 'hello', start_timestamp: 1, end_timestamp: 2 }], + }, + ]; + + getRecallBotMock.mockResolvedValue({ + ok: true, + bot: { + status_changes: [ + { code: 'done', created_at: '2026-06-09T14:05:00.000Z' }, + ], + recordings: [ + { + id: 'recall-recording-1', + started_at: '2026-06-09T13:02:00.000Z', + completed_at: '2026-06-09T14:00:00.000Z', + }, + ], + }, + }); + listRecallTranscriptsMock.mockResolvedValue({ + ok: true, + transcripts: [ + { + id: 'recall-transcript-1', + statusCode: 'done', + statusSubCode: undefined, + }, + ], + }); + downloadTranscriptMock.mockResolvedValue({ + outcome: 'filled', + content: transcriptContent, + }); + const client = buildClient([ + buildStuckRecordingNode({ + status: 'PROCESSING', + startedAt: '2026-06-09T13:02:00.000Z', + endedAt: '2026-06-09T14:00:00.000Z', + externalRecordingId: 'recall-recording-1', + transcript: { + recallTranscriptId: 'legacy-pending-transcript', + status: 'PENDING', + requestedAt: '2026-06-09T14:05:30.000Z', + }, + audio: [{ fileId: 'file-audio-1', label: 'audio.mp3' }], + video: [{ fileId: 'file-video-1', label: 'video.mp4' }], + }), + ]); + + const result = await convergeDivergedCallRecordings({ + client: client as unknown as CoreApiClient, + now: NOW, + }); + + expect(createAsyncRecallTranscriptMock).not.toHaveBeenCalled(); + expect(downloadTranscriptMock).toHaveBeenCalledWith({ + transcriptId: 'recall-transcript-1', + }); + expect(client.mutations).toEqual([ + { + id: 'call-recording-1', + data: { transcript: transcriptContent }, + }, + { + id: 'call-recording-1', + data: { status: 'COMPLETED' }, + }, + ]); + expect(chargeCompletedCallRecordingMock).toHaveBeenCalledWith({ + callRecordingId: 'call-recording-1', + startedAt: '2026-06-09T13:02:00.000Z', + endedAt: '2026-06-09T14:00:00.000Z', + }); + expect(result.requestedTranscriptCallRecordingIds).toEqual([]); + }); + + it('marks the call recording failed when Recall has a failed transcript artifact', async () => { + getRecallBotMock.mockResolvedValue({ + ok: true, + bot: { + status_changes: [ + { code: 'done', created_at: '2026-06-09T14:05:00.000Z' }, + ], + recordings: [ + { + id: 'recall-recording-1', + started_at: '2026-06-09T13:02:00.000Z', + completed_at: '2026-06-09T14:00:00.000Z', + }, + ], + }, + }); + listRecallTranscriptsMock.mockResolvedValue({ + ok: true, + transcripts: [ + { + id: 'recall-transcript-1', + statusCode: 'failed', + statusSubCode: 'audio_missing', + }, + ], + }); + const client = buildClient([ + buildStuckRecordingNode({ + status: 'PROCESSING', + startedAt: '2026-06-09T13:02:00.000Z', + endedAt: '2026-06-09T14:00:00.000Z', + externalRecordingId: 'recall-recording-1', + }), + ]); + + const result = await convergeDivergedCallRecordings({ + client: client as unknown as CoreApiClient, + now: NOW, + }); + + expect(createAsyncRecallTranscriptMock).not.toHaveBeenCalled(); + expect(downloadTranscriptMock).not.toHaveBeenCalled(); + expect(client.mutations).toEqual([ + { + id: 'call-recording-1', + data: { + status: 'FAILED', + transcript: { + recallTranscriptId: 'recall-transcript-1', + status: 'FAILED', + subCode: 'audio_missing', + }, + meetingBotFailureReason: 'transcript_failed:audio_missing', + }, + }, + ]); + expect(result.requestedTranscriptCallRecordingIds).toEqual([]); + }); + + it('does not mutate a record the bot state agrees with', async () => { + getRecallBotMock.mockResolvedValue({ + ok: true, + bot: { + status_changes: [ + { code: 'in_call_recording', created_at: '2026-06-09T13:02:00.000Z' }, + ], + }, + }); + const client = buildClient([ + buildStuckRecordingNode({ startedAt: '2026-06-09T13:02:00.000Z' }), + ]); + + const result = await convergeDivergedCallRecordings({ + client: client as unknown as CoreApiClient, + now: NOW, + }); + + expect(client.mutations).toEqual([]); + expect(result.updatedCallRecordingIds).toEqual([]); + }); +}); diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/flows/__tests__/download-transcript.test.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/flows/__tests__/download-transcript.test.ts new file mode 100644 index 0000000000..bc1b7f0ee3 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/flows/__tests__/download-transcript.test.ts @@ -0,0 +1,74 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { downloadTranscript } from 'src/logic-functions/flows/download-transcript.util'; + +const retrieveRecallTranscriptMock = vi.hoisted(() => vi.fn()); + +vi.mock( + 'src/logic-functions/recall-api/retrieve-recall-transcript.util', + () => ({ + retrieveRecallTranscript: retrieveRecallTranscriptMock, + }), +); + +describe('downloadTranscript', () => { + const fetchMock = vi.fn(); + + beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + retrieveRecallTranscriptMock.mockReset(); + fetchMock.mockReset(); + vi.stubGlobal('fetch', fetchMock); + }); + + it('downloads transcript content with a timeout', async () => { + const transcriptContent = [{ participant: { id: 1 }, words: [] }]; + + retrieveRecallTranscriptMock.mockResolvedValue({ + ok: true, + transcript: { + downloadUrl: 'https://recall-transcripts.example.com/transcript-1', + statusCode: 'done', + statusSubCode: undefined, + }, + }); + fetchMock.mockResolvedValue({ + ok: true, + json: async () => transcriptContent, + }); + + const result = await downloadTranscript({ + transcriptId: 'recall-transcript-1', + }); + + expect(result).toEqual({ outcome: 'filled', content: transcriptContent }); + expect(fetchMock).toHaveBeenCalledWith( + 'https://recall-transcripts.example.com/transcript-1', + expect.objectContaining({ + signal: expect.any(AbortSignal), + }), + ); + }); + + it('logs raw download failures but returns a generic error', async () => { + retrieveRecallTranscriptMock.mockResolvedValue({ + ok: true, + transcript: { + downloadUrl: 'https://recall-transcripts.example.com/transcript-1', + statusCode: 'done', + statusSubCode: undefined, + }, + }); + fetchMock.mockRejectedValue(new Error('socket leaked detail')); + + await expect( + downloadTranscript({ transcriptId: 'recall-transcript-1' }), + ).resolves.toEqual({ + outcome: 'error', + errorMessage: 'transcript download failed', + }); + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('socket leaked detail'), + ); + }); +}); diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/flows/__tests__/handle-recall-webhook.test.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/flows/__tests__/handle-recall-webhook.test.ts new file mode 100644 index 0000000000..ae2e85b9aa --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/flows/__tests__/handle-recall-webhook.test.ts @@ -0,0 +1,1286 @@ +import { type CoreApiClient } from 'twenty-client-sdk/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { handleRecallWebhook } from 'src/logic-functions/flows/handle-recall-webhook.util'; + +const WORKSPACE_ID = '123e4567-e89b-12d3-a456-426614174000'; + +const buildRecordingDoneWebhookBody = () => ({ + event: 'recording.done', + data: { + bot: { + id: 'recall-bot-1', + metadata: { + twentyWorkspaceId: WORKSPACE_ID, + }, + }, + recording: { + id: 'recall-recording-1', + }, + }, +}); + +const getRecallBotMock = vi.hoisted(() => vi.fn()); +const listRecallTranscriptsMock = vi.hoisted(() => vi.fn()); +const createAsyncRecallTranscriptMock = vi.hoisted(() => vi.fn()); +const retrieveRecallTranscriptMock = vi.hoisted(() => vi.fn()); +const ingestCallRecordingMediaMock = vi.hoisted(() => vi.fn()); +const chargeCompletedCallRecordingMock = vi.hoisted(() => vi.fn()); + +vi.mock('src/logic-functions/recall-api/get-recall-bot.util', () => ({ + getRecallBot: getRecallBotMock, +})); + +vi.mock('src/logic-functions/recall-api/list-recall-transcripts.util', () => ({ + listRecallTranscripts: listRecallTranscriptsMock, +})); + +vi.mock( + 'src/logic-functions/recall-api/create-async-recall-transcript.util', + () => ({ + createAsyncRecallTranscript: createAsyncRecallTranscriptMock, + }), +); + +vi.mock( + 'src/logic-functions/recall-api/retrieve-recall-transcript.util', + () => ({ + retrieveRecallTranscript: retrieveRecallTranscriptMock, + }), +); + +vi.mock('src/logic-functions/flows/ingest-call-recording-media.util', () => ({ + ingestCallRecordingMedia: ingestCallRecordingMediaMock, +})); + +vi.mock( + 'src/logic-functions/flows/charge-completed-call-recording.util', + () => ({ + chargeCompletedCallRecording: chargeCompletedCallRecordingMock, + }), +); + +type CallRecordingNode = { + id: string; + status?: string | null; + externalBotId?: string | null; + externalRecordingId?: string | null; + startedAt?: string | null; + endedAt?: string | null; + transcript?: unknown; + audio?: unknown; + video?: unknown; +}; + +class FakeCoreApiClient { + callRecordings: CallRecordingNode[]; + mutations: Array<{ id: string; data: Record }> = []; + + constructor(callRecordings: CallRecordingNode[]) { + this.callRecordings = callRecordings; + } + + async query(query: any): Promise { + if (query.callRecordings !== undefined) { + const filter = query.callRecordings.__args.filter; + + return { + callRecordings: { + edges: this.filterCallRecordings(filter).map((callRecording) => ({ + node: callRecording, + })), + }, + }; + } + + throw new Error(`Unhandled query: ${JSON.stringify(query)}`); + } + + async mutation(mutation: any): Promise { + if (mutation.updateCallRecordings !== undefined) { + const { filter, data } = mutation.updateCallRecordings.__args; + const id = filter.id.eq; + + this.mutations.push({ id, data }); + + return { updateCallRecordings: [{ id }] }; + } + + if (mutation.updateCallRecording !== undefined) { + const { id, data } = mutation.updateCallRecording.__args; + + this.mutations.push({ id, data }); + + return { + updateCallRecording: { + id, + }, + }; + } + + throw new Error(`Unhandled mutation: ${JSON.stringify(mutation)}`); + } + + private filterCallRecordings(filter: any): CallRecordingNode[] { + if (filter.id?.eq !== undefined) { + return this.callRecordings.filter( + (callRecording) => callRecording.id === filter.id.eq, + ); + } + + if (filter.externalBotId?.eq !== undefined) { + return this.callRecordings.filter( + (callRecording) => + callRecording.externalBotId === filter.externalBotId.eq, + ); + } + + throw new Error( + `Unhandled call recording filter: ${JSON.stringify(filter)}`, + ); + } +} + +describe('handleRecallWebhook', () => { + beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + getRecallBotMock.mockReset(); + getRecallBotMock.mockResolvedValue({ + ok: false, + status: null, + errorMessage: 'bot fetch disabled in test', + }); + listRecallTranscriptsMock.mockReset(); + listRecallTranscriptsMock.mockResolvedValue({ + ok: true, + transcripts: [], + }); + createAsyncRecallTranscriptMock.mockReset(); + createAsyncRecallTranscriptMock.mockResolvedValue({ + ok: false, + status: null, + errorMessage: 'transcript request disabled in test', + }); + retrieveRecallTranscriptMock.mockReset(); + retrieveRecallTranscriptMock.mockResolvedValue({ + ok: false, + status: null, + errorMessage: 'transcript retrieval disabled in test', + }); + ingestCallRecordingMediaMock.mockReset(); + ingestCallRecordingMediaMock.mockResolvedValue({}); + chargeCompletedCallRecordingMock.mockReset(); + chargeCompletedCallRecordingMock.mockResolvedValue(undefined); + }); + + it('updates a call recording from bot metadata on status change events', async () => { + const client = new FakeCoreApiClient([ + { + id: 'call-recording-1', + status: 'JOINING', + externalBotId: 'recall-bot-1', + }, + ]); + + const result = await handleRecallWebhook({ + client: client as unknown as CoreApiClient, + body: { + event: 'bot.status_change', + data: { + bot: { + id: 'recall-bot-1', + metadata: { + twentyCallRecordingId: 'call-recording-1', + }, + }, + status: { + code: 'in_call_recording', + }, + }, + }, + }); + + expect(result).toEqual({ + status: 'updated', + event: 'bot.status_change', + callRecordingId: 'call-recording-1', + callRecordingStatus: 'RECORDING', + }); + expect(client.mutations).toEqual([ + { + id: 'call-recording-1', + data: { + status: 'RECORDING', + externalBotId: 'recall-bot-1', + }, + }, + ]); + }); + + it('reads bot metadata nested under data when a top-level bot has none', async () => { + const client = new FakeCoreApiClient([ + { + id: 'call-recording-1', + status: 'JOINING', + externalBotId: 'recall-bot-1', + }, + ]); + + const result = await handleRecallWebhook({ + client: client as unknown as CoreApiClient, + body: { + event: 'bot.status_change', + bot: { + id: 'recall-bot-1', + }, + data: { + bot: { + id: 'recall-bot-1', + metadata: { + twentyCallRecordingId: 'call-recording-1', + }, + }, + status: { + code: 'in_call_recording', + }, + }, + }, + }); + + expect(result).toEqual({ + status: 'updated', + event: 'bot.status_change', + callRecordingId: 'call-recording-1', + callRecordingStatus: 'RECORDING', + }); + }); + + it('matches by metadata id when the recording carries no external bot id', async () => { + const client = new FakeCoreApiClient([ + { + id: 'call-recording-1', + status: 'SCHEDULED', + externalBotId: null, + }, + ]); + + const result = await handleRecallWebhook({ + client: client as unknown as CoreApiClient, + body: { + event: 'bot.status_change', + data: { + bot: { + id: 'recall-bot-1', + metadata: { + twentyCallRecordingId: 'call-recording-1', + }, + }, + status: { + code: 'in_call_recording', + }, + }, + }, + }); + + expect(result).toEqual({ + status: 'updated', + event: 'bot.status_change', + callRecordingId: 'call-recording-1', + callRecordingStatus: 'RECORDING', + }); + expect(client.mutations).toEqual([ + { + id: 'call-recording-1', + data: { + status: 'RECORDING', + externalBotId: 'recall-bot-1', + }, + }, + ]); + }); + + it('prefers the metadata id over a different recording carrying the bot id', async () => { + const client = new FakeCoreApiClient([ + { + id: 'call-recording-stale', + status: 'SCHEDULED', + externalBotId: 'recall-bot-1', + }, + { + id: 'call-recording-current', + status: 'SCHEDULED', + externalBotId: null, + }, + ]); + + const result = await handleRecallWebhook({ + client: client as unknown as CoreApiClient, + body: { + event: 'bot.status_change', + data: { + bot: { + id: 'recall-bot-1', + metadata: { + twentyCallRecordingId: 'call-recording-current', + }, + }, + status: { + code: 'in_call_recording', + }, + }, + }, + }); + + expect(result).toEqual({ + status: 'updated', + event: 'bot.status_change', + callRecordingId: 'call-recording-current', + callRecordingStatus: 'RECORDING', + }); + expect(client.mutations).toEqual([ + { + id: 'call-recording-current', + data: { + status: 'RECORDING', + externalBotId: 'recall-bot-1', + }, + }, + ]); + }); + + it('falls back to external bot id matching when call recording metadata is absent', async () => { + const client = new FakeCoreApiClient([ + { + id: 'call-recording-1', + status: 'PROCESSING', + externalBotId: 'recall-bot-1', + }, + ]); + + const result = await handleRecallWebhook({ + client: client as unknown as CoreApiClient, + body: { + event: 'recording.done', + data: { + bot: { + id: 'recall-bot-1', + metadata: { + twentyWorkspaceId: WORKSPACE_ID, + }, + }, + recording: { + id: 'recall-recording-1', + }, + }, + }, + }); + + expect(result).toEqual({ + status: 'updated', + event: 'recording.done', + callRecordingId: 'call-recording-1', + callRecordingStatus: 'PROCESSING', + }); + expect(client.mutations).toEqual([ + { + id: 'call-recording-1', + data: { + status: 'PROCESSING', + externalBotId: 'recall-bot-1', + externalRecordingId: 'recall-recording-1', + }, + }, + ]); + }); + + it('fills startedAt from the status timestamp when the bot starts recording', async () => { + const client = new FakeCoreApiClient([ + { + id: 'call-recording-1', + status: 'JOINING', + externalBotId: 'recall-bot-1', + }, + ]); + + const result = await handleRecallWebhook({ + client: client as unknown as CoreApiClient, + body: { + event: 'bot.status_change', + data: { + bot: { + id: 'recall-bot-1', + metadata: { + twentyCallRecordingId: 'call-recording-1', + }, + }, + status: { + code: 'in_call_recording', + created_at: '2026-01-01T13:02:00.000Z', + }, + }, + }, + }); + + expect(result).toEqual({ + status: 'updated', + event: 'bot.status_change', + callRecordingId: 'call-recording-1', + callRecordingStatus: 'RECORDING', + }); + expect(client.mutations).toEqual([ + { + id: 'call-recording-1', + data: { + status: 'RECORDING', + externalBotId: 'recall-bot-1', + startedAt: '2026-01-01T13:02:00.000Z', + }, + }, + ]); + }); + + it('fills endedAt from the status timestamp when the recording is done', async () => { + const client = new FakeCoreApiClient([ + { + id: 'call-recording-1', + status: 'PROCESSING', + externalBotId: 'recall-bot-1', + startedAt: '2026-01-01T13:02:00.000Z', + }, + ]); + + const result = await handleRecallWebhook({ + client: client as unknown as CoreApiClient, + body: { + event: 'bot.status_change', + data: { + bot: { + id: 'recall-bot-1', + metadata: { + twentyCallRecordingId: 'call-recording-1', + }, + }, + status: { + code: 'done', + created_at: '2026-01-01T14:05:00.000Z', + }, + }, + }, + }); + + expect(result).toEqual({ + status: 'updated', + event: 'bot.status_change', + callRecordingId: 'call-recording-1', + callRecordingStatus: 'PROCESSING', + }); + expect(client.mutations).toEqual([ + { + id: 'call-recording-1', + data: { + status: 'PROCESSING', + externalBotId: 'recall-bot-1', + endedAt: '2026-01-01T14:05:00.000Z', + }, + }, + ]); + }); + + it('normalizes microsecond-precision Recall timestamps before writing them', async () => { + const client = new FakeCoreApiClient([ + { + id: 'call-recording-1', + status: 'PROCESSING', + externalBotId: 'recall-bot-1', + startedAt: '2026-06-10T11:02:00.000Z', + }, + ]); + + await handleRecallWebhook({ + client: client as unknown as CoreApiClient, + body: { + event: 'bot.status_change', + data: { + bot: { + id: 'recall-bot-1', + metadata: { + twentyCallRecordingId: 'call-recording-1', + }, + }, + status: { + code: 'done', + created_at: '2026-06-10T12:17:28.281597+00:00', + }, + }, + }, + }); + + expect(client.mutations).toEqual([ + { + id: 'call-recording-1', + data: { + status: 'PROCESSING', + externalBotId: 'recall-bot-1', + endedAt: '2026-06-10T12:17:28.281Z', + }, + }, + ]); + }); + + it('does not overwrite an already-set startedAt on a redelivered recording event', async () => { + const client = new FakeCoreApiClient([ + { + id: 'call-recording-1', + status: 'RECORDING', + externalBotId: 'recall-bot-1', + startedAt: '2026-01-01T13:02:00.000Z', + }, + ]); + + await handleRecallWebhook({ + client: client as unknown as CoreApiClient, + body: { + event: 'bot.status_change', + data: { + bot: { + id: 'recall-bot-1', + metadata: { + twentyCallRecordingId: 'call-recording-1', + }, + }, + status: { + code: 'in_call_recording', + created_at: '2026-01-01T13:09:00.000Z', + }, + }, + }, + }); + + expect(client.mutations).toEqual([ + { + id: 'call-recording-1', + data: { + status: 'RECORDING', + externalBotId: 'recall-bot-1', + }, + }, + ]); + }); + + it('does not overwrite an already-set endedAt on a redelivered done event', async () => { + const client = new FakeCoreApiClient([ + { + id: 'call-recording-1', + status: 'PROCESSING', + externalBotId: 'recall-bot-1', + startedAt: '2026-01-01T13:02:00.000Z', + endedAt: '2026-01-01T14:05:00.000Z', + transcript: { + recallTranscriptId: 'recall-transcript-1', + status: 'PENDING', + requestedAt: '2026-01-01T14:06:00.000Z', + }, + }, + ]); + + await handleRecallWebhook({ + client: client as unknown as CoreApiClient, + body: { + event: 'bot.status_change', + data: { + bot: { + id: 'recall-bot-1', + metadata: { + twentyCallRecordingId: 'call-recording-1', + }, + }, + status: { + code: 'done', + created_at: '2026-01-01T14:11:00.000Z', + }, + }, + }, + }); + + expect(client.mutations).toEqual([ + { + id: 'call-recording-1', + data: { + status: 'PROCESSING', + externalBotId: 'recall-bot-1', + }, + }, + ]); + }); + + it('skips a late done event once the recording is COMPLETED', async () => { + const client = new FakeCoreApiClient([ + { + id: 'call-recording-1', + status: 'COMPLETED', + externalBotId: 'recall-bot-1', + startedAt: '2026-01-01T13:02:00.000Z', + endedAt: '2026-01-01T14:05:00.000Z', + }, + ]); + + const result = await handleRecallWebhook({ + client: client as unknown as CoreApiClient, + body: { + event: 'bot.status_change', + data: { + bot: { + id: 'recall-bot-1', + metadata: { + twentyCallRecordingId: 'call-recording-1', + }, + }, + status: { + code: 'done', + created_at: '2026-01-01T14:11:00.000Z', + }, + }, + }, + }); + + expect(result).toEqual({ + status: 'skipped', + event: 'bot.status_change', + reason: 'stale status event (COMPLETED -> PROCESSING)', + }); + expect(client.mutations).toEqual([]); + }); + + it('skips out-of-order events that would move the status backwards', async () => { + const client = new FakeCoreApiClient([ + { + id: 'call-recording-1', + status: 'COMPLETED', + externalBotId: 'recall-bot-1', + }, + ]); + + const result = await handleRecallWebhook({ + client: client as unknown as CoreApiClient, + body: { + event: 'bot.status_change', + data: { + bot: { + id: 'recall-bot-1', + metadata: { + twentyCallRecordingId: 'call-recording-1', + }, + }, + status: { + code: 'in_call_recording', + }, + }, + }, + }); + + expect(result).toEqual({ + status: 'skipped', + event: 'bot.status_change', + reason: 'stale status event (COMPLETED -> RECORDING)', + }); + expect(client.mutations).toEqual([]); + }); + + it('skips events whose metadata points at a missing call recording', async () => { + const client = new FakeCoreApiClient([]); + + const result = await handleRecallWebhook({ + client: client as unknown as CoreApiClient, + body: { + event: 'bot.status_change', + data: { + bot: { + metadata: { + twentyCallRecordingId: 'call-recording-deleted', + }, + }, + status: { + code: 'in_call_recording', + }, + }, + }, + }); + + expect(result).toEqual({ + status: 'skipped', + event: 'bot.status_change', + reason: 'no matching call recording', + }); + expect(client.mutations).toEqual([]); + }); + + it('skips unsupported events', async () => { + const client = new FakeCoreApiClient([]); + + const result = await handleRecallWebhook({ + client: client as unknown as CoreApiClient, + body: { + event: 'participant_events.done', + data: {}, + }, + }); + + expect(result).toEqual({ + status: 'skipped', + event: 'participant_events.done', + reason: 'unsupported Recall event status participant_events.done', + }); + expect(client.mutations).toEqual([]); + }); + + it('requests a transcript once when the recording first completes', async () => { + createAsyncRecallTranscriptMock.mockResolvedValue({ + ok: true, + transcriptId: 'recall-transcript-1', + }); + const client = new FakeCoreApiClient([ + { + id: 'call-recording-1', + status: 'PROCESSING', + externalBotId: 'recall-bot-1', + transcript: null, + }, + ]); + + await handleRecallWebhook({ + client: client as unknown as CoreApiClient, + body: buildRecordingDoneWebhookBody(), + }); + + expect(createAsyncRecallTranscriptMock).toHaveBeenCalledTimes(1); + expect(createAsyncRecallTranscriptMock).toHaveBeenCalledWith({ + externalRecordingId: 'recall-recording-1', + callRecordingId: 'call-recording-1', + }); + expect(client.mutations).toEqual([ + { + id: 'call-recording-1', + data: { + status: 'PROCESSING', + externalBotId: 'recall-bot-1', + externalRecordingId: 'recall-recording-1', + transcript: { + recallTranscriptId: 'recall-transcript-1', + status: 'PENDING', + requestedAt: expect.any(String), + }, + }, + }, + ]); + }); + + it('does not re-request a transcript on a redelivered done event while Recall list is stale', async () => { + const client = new FakeCoreApiClient([ + { + id: 'call-recording-1', + status: 'PROCESSING', + externalBotId: 'recall-bot-1', + externalRecordingId: 'recall-recording-1', + transcript: { + recallTranscriptId: 'recall-transcript-1', + status: 'PENDING', + requestedAt: '2026-01-01T14:06:00.000Z', + }, + }, + ]); + + await handleRecallWebhook({ + client: client as unknown as CoreApiClient, + body: buildRecordingDoneWebhookBody(), + }); + + expect(createAsyncRecallTranscriptMock).not.toHaveBeenCalled(); + expect(listRecallTranscriptsMock).toHaveBeenCalledWith({ + externalRecordingId: 'recall-recording-1', + }); + expect(retrieveRecallTranscriptMock).toHaveBeenCalledWith({ + transcriptId: 'recall-transcript-1', + }); + expect(client.mutations).toEqual([ + { + id: 'call-recording-1', + data: { + status: 'PROCESSING', + externalBotId: 'recall-bot-1', + externalRecordingId: 'recall-recording-1', + }, + }, + ]); + }); + + it('resolves the recording id from the bot when the payload and record lack one', async () => { + getRecallBotMock.mockResolvedValue({ + ok: true, + bot: { + recordings: [{ id: 'recall-recording-9' }], + }, + }); + createAsyncRecallTranscriptMock.mockResolvedValue({ + ok: true, + transcriptId: 'recall-transcript-9', + }); + const client = new FakeCoreApiClient([ + { + id: 'call-recording-1', + status: 'PROCESSING', + externalBotId: 'recall-bot-1', + transcript: null, + }, + ]); + + await handleRecallWebhook({ + client: client as unknown as CoreApiClient, + body: { + event: 'bot.status_change', + data: { + bot: { + id: 'recall-bot-1', + metadata: { + twentyCallRecordingId: 'call-recording-1', + }, + }, + status: { + code: 'done', + }, + }, + }, + }); + + expect(getRecallBotMock).toHaveBeenCalledWith({ + externalBotId: 'recall-bot-1', + }); + expect(createAsyncRecallTranscriptMock).toHaveBeenCalledWith({ + externalRecordingId: 'recall-recording-9', + callRecordingId: 'call-recording-1', + }); + expect(client.mutations).toEqual([ + expect.objectContaining({ + id: 'call-recording-1', + data: expect.objectContaining({ + status: 'PROCESSING', + externalBotId: 'recall-bot-1', + externalRecordingId: 'recall-recording-9', + }), + }), + ]); + }); + + it('ingests media on recording.done and completes once all artifacts are present', async () => { + getRecallBotMock.mockResolvedValue({ + ok: true, + bot: { id: 'recall-bot-1' }, + }); + ingestCallRecordingMediaMock.mockResolvedValue({ + audio: [{ fileId: 'file-audio-1', label: 'audio.mp3' }], + video: [{ fileId: 'file-video-1', label: 'video.mp4' }], + }); + const client = new FakeCoreApiClient([ + { + id: 'call-recording-1', + status: 'PROCESSING', + externalBotId: 'recall-bot-1', + externalRecordingId: 'recall-recording-1', + startedAt: '2026-01-01T13:02:00.000Z', + endedAt: '2026-01-01T14:05:00.000Z', + transcript: [{ participant: { id: 1 }, words: [] }], + }, + ]); + + await handleRecallWebhook({ + client: client as unknown as CoreApiClient, + body: buildRecordingDoneWebhookBody(), + }); + + expect(ingestCallRecordingMediaMock).toHaveBeenCalledWith({ + callRecordingId: 'call-recording-1', + externalRecordingId: 'recall-recording-1', + hasAudio: false, + hasVideo: false, + }); + expect(client.mutations).toEqual([ + { + id: 'call-recording-1', + data: { + externalBotId: 'recall-bot-1', + externalRecordingId: 'recall-recording-1', + audio: [{ fileId: 'file-audio-1', label: 'audio.mp3' }], + video: [{ fileId: 'file-video-1', label: 'video.mp4' }], + }, + }, + { + id: 'call-recording-1', + data: { status: 'COMPLETED' }, + }, + ]); + expect(chargeCompletedCallRecordingMock).toHaveBeenCalledWith({ + callRecordingId: 'call-recording-1', + startedAt: '2026-01-01T13:02:00.000Z', + endedAt: '2026-01-01T14:05:00.000Z', + }); + }); + + it('stays PROCESSING on recording.done while artifacts are missing', async () => { + getRecallBotMock.mockResolvedValue({ + ok: true, + bot: { id: 'recall-bot-1' }, + }); + ingestCallRecordingMediaMock.mockResolvedValue({ + audio: [{ fileId: 'file-audio-1', label: 'audio.mp3' }], + }); + createAsyncRecallTranscriptMock.mockResolvedValue({ + ok: true, + transcriptId: 'recall-transcript-1', + }); + const client = new FakeCoreApiClient([ + { + id: 'call-recording-1', + status: 'PROCESSING', + externalBotId: 'recall-bot-1', + startedAt: '2026-01-01T13:02:00.000Z', + endedAt: '2026-01-01T14:05:00.000Z', + transcript: null, + }, + ]); + + await handleRecallWebhook({ + client: client as unknown as CoreApiClient, + body: buildRecordingDoneWebhookBody(), + }); + + expect(createAsyncRecallTranscriptMock).toHaveBeenCalledWith({ + externalRecordingId: 'recall-recording-1', + callRecordingId: 'call-recording-1', + }); + expect(client.mutations).toEqual([ + expect.objectContaining({ + id: 'call-recording-1', + data: expect.objectContaining({ + status: 'PROCESSING', + externalBotId: 'recall-bot-1', + externalRecordingId: 'recall-recording-1', + audio: [{ fileId: 'file-audio-1', label: 'audio.mp3' }], + }), + }), + ]); + expect(chargeCompletedCallRecordingMock).not.toHaveBeenCalled(); + }); + + it('marks FAILED on recording.done when no recording artifact path exists', async () => { + getRecallBotMock.mockResolvedValue({ + ok: true, + bot: { id: 'recall-bot-1', recordings: [] }, + }); + const client = new FakeCoreApiClient([ + { + id: 'call-recording-1', + status: 'PROCESSING', + externalBotId: 'recall-bot-1', + startedAt: '2026-01-01T13:02:00.000Z', + endedAt: '2026-01-01T14:05:00.000Z', + transcript: null, + }, + ]); + + const result = await handleRecallWebhook({ + client: client as unknown as CoreApiClient, + body: { + event: 'recording.done', + data: { + bot: { + id: 'recall-bot-1', + metadata: { + twentyWorkspaceId: WORKSPACE_ID, + }, + }, + }, + }, + }); + + expect(result).toEqual({ + status: 'updated', + event: 'recording.done', + callRecordingId: 'call-recording-1', + callRecordingStatus: 'FAILED', + }); + expect(client.mutations).toEqual([ + { + id: 'call-recording-1', + data: { + status: 'FAILED', + externalBotId: 'recall-bot-1', + meetingBotFailureReason: 'recording_artifacts_unavailable', + }, + }, + ]); + expect(chargeCompletedCallRecordingMock).not.toHaveBeenCalled(); + }); + + it('completes and charges on transcript.done when media is already ingested', async () => { + const transcriptContent = [ + { + participant: { id: 1, name: 'Alice' }, + words: [{ text: 'hello', start_timestamp: { relative: 0.5 } }], + }, + ]; + + retrieveRecallTranscriptMock.mockResolvedValue({ + ok: true, + transcript: { + downloadUrl: 'https://recall-transcripts.example.com/transcript-1', + statusCode: 'done', + statusSubCode: null, + }, + }); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => transcriptContent, + }), + ); + + const client = new FakeCoreApiClient([ + { + id: 'call-recording-1', + status: 'PROCESSING', + externalBotId: 'recall-bot-1', + externalRecordingId: 'recall-recording-1', + startedAt: '2026-01-01T13:02:00.000Z', + endedAt: '2026-01-01T14:05:00.000Z', + transcript: { + recallTranscriptId: 'recall-transcript-1', + status: 'PENDING', + requestedAt: '2026-01-01T14:06:00.000Z', + }, + audio: [{ fileId: 'file-audio-1', label: 'audio.mp3' }], + video: [{ fileId: 'file-video-1', label: 'video.mp4' }], + }, + ]); + + const result = await handleRecallWebhook({ + client: client as unknown as CoreApiClient, + body: { + event: 'transcript.done', + data: { + bot: { + id: 'recall-bot-1', + metadata: { + twentyCallRecordingId: 'call-recording-1', + }, + }, + transcript: { + id: 'recall-transcript-1', + }, + }, + }, + }); + + expect(result).toEqual({ + status: 'updated', + event: 'transcript.done', + callRecordingId: 'call-recording-1', + transcriptOutcome: 'FILLED', + }); + expect(client.mutations).toEqual([ + { + id: 'call-recording-1', + data: { transcript: transcriptContent }, + }, + { + id: 'call-recording-1', + data: { status: 'COMPLETED' }, + }, + ]); + expect(chargeCompletedCallRecordingMock).toHaveBeenCalledWith({ + callRecordingId: 'call-recording-1', + startedAt: '2026-01-01T13:02:00.000Z', + endedAt: '2026-01-01T14:05:00.000Z', + }); + + vi.unstubAllGlobals(); + }); + + it('fills the transcript from the download URL on transcript.done', async () => { + const transcriptContent = [ + { + participant: { id: 1, name: 'Alice' }, + words: [{ text: 'hello', start_timestamp: { relative: 0.5 } }], + }, + ]; + + retrieveRecallTranscriptMock.mockResolvedValue({ + ok: true, + transcript: { + downloadUrl: 'https://recall-transcripts.example.com/transcript-1', + statusCode: 'done', + statusSubCode: null, + }, + }); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => transcriptContent, + }), + ); + + const client = new FakeCoreApiClient([ + { + id: 'call-recording-1', + status: 'COMPLETED', + externalBotId: 'recall-bot-1', + transcript: { + recallTranscriptId: 'recall-transcript-1', + status: 'PENDING', + requestedAt: '2026-01-01T14:06:00.000Z', + }, + }, + ]); + + const result = await handleRecallWebhook({ + client: client as unknown as CoreApiClient, + body: { + event: 'transcript.done', + data: { + bot: { + id: 'recall-bot-1', + metadata: { + twentyCallRecordingId: 'call-recording-1', + }, + }, + transcript: { + id: 'recall-transcript-1', + }, + recording: { + id: 'recall-recording-1', + }, + }, + }, + }); + + expect(result).toEqual({ + status: 'updated', + event: 'transcript.done', + callRecordingId: 'call-recording-1', + transcriptOutcome: 'FILLED', + }); + expect(retrieveRecallTranscriptMock).toHaveBeenCalledWith({ + transcriptId: 'recall-transcript-1', + }); + expect(client.mutations).toEqual([ + { + id: 'call-recording-1', + data: { + transcript: transcriptContent, + externalRecordingId: 'recall-recording-1', + }, + }, + ]); + expect(chargeCompletedCallRecordingMock).not.toHaveBeenCalled(); + + vi.unstubAllGlobals(); + }); + + it('writes a FAILED marker on transcript.failed', async () => { + const client = new FakeCoreApiClient([ + { + id: 'call-recording-1', + status: 'PROCESSING', + externalBotId: 'recall-bot-1', + externalRecordingId: 'recall-recording-1', + transcript: { + recallTranscriptId: 'recall-transcript-1', + status: 'PENDING', + requestedAt: '2026-01-01T14:06:00.000Z', + }, + }, + ]); + + const result = await handleRecallWebhook({ + client: client as unknown as CoreApiClient, + body: { + event: 'transcript.failed', + data: { + bot: { + id: 'recall-bot-1', + metadata: { + twentyCallRecordingId: 'call-recording-1', + }, + }, + transcript: { + id: 'recall-transcript-1', + }, + status: { + sub_code: 'transcription_failed', + }, + }, + }, + }); + + expect(result).toEqual({ + status: 'updated', + event: 'transcript.failed', + callRecordingId: 'call-recording-1', + transcriptOutcome: 'FAILED', + }); + expect(client.mutations).toEqual([ + { + id: 'call-recording-1', + data: { + transcript: { + recallTranscriptId: 'recall-transcript-1', + status: 'FAILED', + subCode: 'transcription_failed', + }, + meetingBotFailureReason: 'transcript_failed:transcription_failed', + status: 'FAILED', + }, + }, + ]); + expect(console.warn).toHaveBeenCalled(); + }); + + it('does not clobber a downloaded transcript with a late transcript.failed', async () => { + const client = new FakeCoreApiClient([ + { + id: 'call-recording-1', + status: 'COMPLETED', + externalBotId: 'recall-bot-1', + transcript: [{ participant: { id: 1 }, words: [] }], + }, + ]); + + const result = await handleRecallWebhook({ + client: client as unknown as CoreApiClient, + body: { + event: 'transcript.failed', + data: { + bot: { + id: 'recall-bot-1', + metadata: { + twentyCallRecordingId: 'call-recording-1', + }, + }, + transcript: { + id: 'recall-transcript-1', + }, + status: { + sub_code: 'transcription_failed', + }, + }, + }, + }); + + expect(result).toEqual({ + status: 'skipped', + event: 'transcript.failed', + reason: 'transcript already filled', + }); + expect(client.mutations).toEqual([]); + }); +}); diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/flows/__tests__/heal-call-recordings-missing-bot.test.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/flows/__tests__/heal-call-recordings-missing-bot.test.ts new file mode 100644 index 0000000000..4d732bd9c1 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/flows/__tests__/heal-call-recordings-missing-bot.test.ts @@ -0,0 +1,225 @@ +import { type CoreApiClient } from 'twenty-client-sdk/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { healCallRecordingsMissingBot } from 'src/logic-functions/flows/heal-call-recordings-missing-bot.util'; + +const scheduleRecallBotMock = vi.hoisted(() => vi.fn()); +const getCurrentWorkspaceIdMock = vi.hoisted(() => vi.fn()); + +vi.mock('src/logic-functions/data/get-current-workspace-id.util', () => ({ + getCurrentWorkspaceId: getCurrentWorkspaceIdMock, +})); + +vi.mock('src/logic-functions/recall-api/schedule-recall-bot.util', () => ({ + scheduleRecallBot: scheduleRecallBotMock, +})); + +const NOW = new Date('2026-01-01T12:00:00.000Z'); +const WORKSPACE_ID = '123e4567-e89b-12d3-a456-426614174000'; +const UPCOMING_STARTS_AT = '2026-01-01T13:00:00.000Z'; +const UPCOMING_ENDS_AT = '2026-01-01T14:00:00.000Z'; +const PAST_STARTS_AT = '2026-01-01T10:00:00.000Z'; +const PAST_ENDS_AT = '2026-01-01T11:00:00.000Z'; + +type CallRecordingNode = { + id: string; + status?: string; + recordingRequestStatus?: string | null; + calendarEventId?: string | null; + externalBotId?: string | null; +}; + +type CalendarEventNode = { + id: string; + startsAt?: string | null; + endsAt?: string | null; + iCalUid?: string | null; + conferenceLink?: { primaryLinkUrl?: string | null } | null; +}; + +class FakeCoreApiClient { + callRecordings: CallRecordingNode[]; + calendarEvents: CalendarEventNode[]; + + constructor({ + callRecordings = [], + calendarEvents = [], + }: { + callRecordings?: CallRecordingNode[]; + calendarEvents?: CalendarEventNode[]; + }) { + this.callRecordings = callRecordings; + this.calendarEvents = calendarEvents; + } + + async query(query: any): Promise { + if (query.callRecordings !== undefined) { + const filter = query.callRecordings.__args.filter; + const matches = + filter.id?.in !== undefined + ? this.callRecordings.filter((callRecording) => + filter.id.in.includes(callRecording.id), + ) + : this.callRecordings.filter( + (callRecording) => + callRecording.recordingRequestStatus === + filter.recordingRequestStatus.eq && + callRecording.status === filter.status.eq, + ); + + return { callRecordings: buildConnection(matches) }; + } + + if (query.calendarEvents !== undefined) { + const calendarEventIds = query.calendarEvents.__args.filter.id.in; + + return { + calendarEvents: buildConnection( + this.calendarEvents.filter((calendarEvent) => + calendarEventIds.includes(calendarEvent.id), + ), + ), + }; + } + + throw new Error(`Unhandled query: ${JSON.stringify(query)}`); + } + + async mutation(mutation: any): Promise { + if (mutation.updateCallRecording !== undefined) { + const { id, data } = mutation.updateCallRecording.__args; + const callRecording = this.callRecordings.find( + (candidate) => candidate.id === id, + ); + + if (callRecording !== undefined) { + Object.assign(callRecording, data); + } + + return { updateCallRecording: { id } }; + } + + throw new Error(`Unhandled mutation: ${JSON.stringify(mutation)}`); + } +} + +const buildConnection = (nodes: Node[]) => ({ + pageInfo: { hasNextPage: false, endCursor: undefined }, + edges: nodes.map((node) => ({ node })), +}); + +const buildBotlessCallRecording = ( + overrides: Partial = {}, +): CallRecordingNode => ({ + id: 'call-recording-1', + status: 'SCHEDULED', + recordingRequestStatus: 'REQUESTED', + calendarEventId: 'calendar-event-1', + externalBotId: null, + ...overrides, +}); + +const buildCalendarEvent = ( + overrides: Partial = {}, +): CalendarEventNode => ({ + id: 'calendar-event-1', + startsAt: UPCOMING_STARTS_AT, + endsAt: UPCOMING_ENDS_AT, + iCalUid: 'calendar-event-uid', + conferenceLink: { primaryLinkUrl: 'https://meet.example.com/customer-sync' }, + ...overrides, +}); + +describe('healCallRecordingsMissingBot', () => { + beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + getCurrentWorkspaceIdMock.mockReset(); + getCurrentWorkspaceIdMock.mockReturnValue(WORKSPACE_ID); + scheduleRecallBotMock.mockReset(); + scheduleRecallBotMock.mockResolvedValue({ + ok: true, + externalBotId: 'recall-bot-1', + }); + }); + + it('schedules a bot and writes the id for an upcoming botless recording', async () => { + const client = new FakeCoreApiClient({ + callRecordings: [buildBotlessCallRecording()], + calendarEvents: [buildCalendarEvent()], + }); + + const result = await healCallRecordingsMissingBot({ + client: client as unknown as CoreApiClient, + now: NOW, + }); + + expect(result.scheduledCallRecordingIds).toEqual(['call-recording-1']); + expect(scheduleRecallBotMock).toHaveBeenCalledTimes(1); + expect(scheduleRecallBotMock).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ + twentyWorkspaceId: WORKSPACE_ID, + }), + }), + ); + expect(client.callRecordings[0].externalBotId).toBe('recall-bot-1'); + }); + + it('does not report a recording as scheduled when Recall scheduling fails', async () => { + scheduleRecallBotMock.mockResolvedValue({ + ok: false, + status: 500, + errorMessage: 'Recall API responded with HTTP 500', + }); + const client = new FakeCoreApiClient({ + callRecordings: [buildBotlessCallRecording()], + calendarEvents: [buildCalendarEvent()], + }); + + const result = await healCallRecordingsMissingBot({ + client: client as unknown as CoreApiClient, + now: NOW, + }); + + expect(result.scheduledCallRecordingIds).toEqual([]); + expect(scheduleRecallBotMock).toHaveBeenCalledTimes(1); + expect(client.callRecordings[0].externalBotId).toBeNull(); + }); + + it('skips a recording whose meeting has already ended', async () => { + const client = new FakeCoreApiClient({ + callRecordings: [buildBotlessCallRecording()], + calendarEvents: [ + buildCalendarEvent({ + startsAt: PAST_STARTS_AT, + endsAt: PAST_ENDS_AT, + }), + ], + }); + + const result = await healCallRecordingsMissingBot({ + client: client as unknown as CoreApiClient, + now: NOW, + }); + + expect(result.scheduledCallRecordingIds).toEqual([]); + expect(scheduleRecallBotMock).not.toHaveBeenCalled(); + }); + + it('does nothing when every scheduled recording already has a bot', async () => { + const client = new FakeCoreApiClient({ + callRecordings: [ + buildBotlessCallRecording({ externalBotId: 'recall-bot-existing' }), + ], + calendarEvents: [buildCalendarEvent()], + }); + + const result = await healCallRecordingsMissingBot({ + client: client as unknown as CoreApiClient, + now: NOW, + }); + + expect(result.scheduledCallRecordingIds).toEqual([]); + expect(scheduleRecallBotMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/flows/__tests__/ingest-call-recording-media.test.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/flows/__tests__/ingest-call-recording-media.test.ts new file mode 100644 index 0000000000..3506fd2505 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/flows/__tests__/ingest-call-recording-media.test.ts @@ -0,0 +1,153 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ingestCallRecordingMedia } from 'src/logic-functions/flows/ingest-call-recording-media.util'; + +const uploadFileMock = vi.hoisted(() => vi.fn()); +const getRecallRecordingMock = vi.hoisted(() => vi.fn()); + +vi.mock('twenty-client-sdk/metadata', () => ({ + MetadataApiClient: class { + uploadFile = uploadFileMock; + }, +})); + +vi.mock('src/logic-functions/recall-api/get-recall-recording.util', () => ({ + getRecallRecording: getRecallRecordingMock, +})); + +const RECORDING_WITH_MEDIA = { + id: 'recall-recording-1', + media_shortcuts: { + video_mixed: { download_url: 'https://media.example.com/video.mp4' }, + audio_mixed: { download_url: 'https://media.example.com/audio.mp3' }, + }, +}; + +const buildFetchResponse = () => ({ + ok: true, + headers: { get: () => 'video/mp4' }, + arrayBuffer: async () => new ArrayBuffer(8), +}); + +describe('ingestCallRecordingMedia', () => { + beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + uploadFileMock.mockReset(); + getRecallRecordingMock.mockReset(); + getRecallRecordingMock.mockResolvedValue({ + ok: true, + recording: RECORDING_WITH_MEDIA, + }); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(buildFetchResponse())); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('downloads and uploads every missing artifact', async () => { + uploadFileMock + .mockResolvedValueOnce({ id: 'file-video-1' }) + .mockResolvedValueOnce({ id: 'file-audio-1' }); + + const updateFields = await ingestCallRecordingMedia({ + callRecordingId: 'call-recording-1', + externalRecordingId: 'recall-recording-1', + hasAudio: false, + hasVideo: false, + }); + + expect(updateFields).toEqual({ + video: [{ fileId: 'file-video-1', label: 'video.mp4' }], + audio: [{ fileId: 'file-audio-1', label: 'audio.mp3' }], + }); + expect(uploadFileMock).toHaveBeenCalledTimes(2); + }); + + it('skips artifacts already on the record', async () => { + uploadFileMock.mockResolvedValue({ id: 'file-audio-1' }); + + const updateFields = await ingestCallRecordingMedia({ + callRecordingId: 'call-recording-1', + externalRecordingId: 'recall-recording-1', + hasAudio: false, + hasVideo: true, + }); + + expect(updateFields).toEqual({ + audio: [{ fileId: 'file-audio-1', label: 'audio.mp3' }], + }); + expect(uploadFileMock).toHaveBeenCalledTimes(1); + }); + + it('does not fetch the recording when both artifacts are present', async () => { + const updateFields = await ingestCallRecordingMedia({ + callRecordingId: 'call-recording-1', + externalRecordingId: 'recall-recording-1', + hasAudio: true, + hasVideo: true, + }); + + expect(updateFields).toEqual({}); + expect(getRecallRecordingMock).not.toHaveBeenCalled(); + expect(uploadFileMock).not.toHaveBeenCalled(); + }); + + it('omits an artifact and warns when its transfer fails', async () => { + uploadFileMock.mockRejectedValueOnce(new Error('upload exploded')); + uploadFileMock.mockResolvedValueOnce({ id: 'file-audio-1' }); + + const updateFields = await ingestCallRecordingMedia({ + callRecordingId: 'call-recording-1', + externalRecordingId: 'recall-recording-1', + hasAudio: false, + hasVideo: false, + }); + + expect(updateFields).toEqual({ + audio: [{ fileId: 'file-audio-1', label: 'audio.mp3' }], + }); + expect(Object.keys(updateFields)).toEqual(['audio']); + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('upload exploded'), + ); + }); + + it('returns nothing when the recording exposes no media urls', async () => { + getRecallRecordingMock.mockResolvedValue({ + ok: true, + recording: { id: 'recall-recording-1' }, + }); + + const updateFields = await ingestCallRecordingMedia({ + callRecordingId: 'call-recording-1', + externalRecordingId: 'recall-recording-1', + hasAudio: false, + hasVideo: false, + }); + + expect(updateFields).toEqual({}); + expect(uploadFileMock).not.toHaveBeenCalled(); + }); + + it('warns and returns nothing when the recording fetch fails', async () => { + getRecallRecordingMock.mockResolvedValue({ + ok: false, + status: 500, + errorMessage: 'recording boom', + }); + + const updateFields = await ingestCallRecordingMedia({ + callRecordingId: 'call-recording-1', + externalRecordingId: 'recall-recording-1', + hasAudio: false, + hasVideo: false, + }); + + expect(updateFields).toEqual({}); + expect(uploadFileMock).not.toHaveBeenCalled(); + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('recording boom'), + ); + }); +}); diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/flows/__tests__/reap-orphaned-meeting-bots.test.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/flows/__tests__/reap-orphaned-meeting-bots.test.ts new file mode 100644 index 0000000000..e99d6d66a5 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/flows/__tests__/reap-orphaned-meeting-bots.test.ts @@ -0,0 +1,425 @@ +import { type CoreApiClient } from 'twenty-client-sdk/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { reapOrphanedMeetingBots } from 'src/logic-functions/flows/reap-orphaned-meeting-bots.util'; + +const listScheduledRecallBotsMock = vi.hoisted(() => vi.fn()); +const cancelRecallBotMock = vi.hoisted(() => vi.fn()); +const ejectRecallBotMock = vi.hoisted(() => vi.fn()); +const getCurrentWorkspaceIdMock = vi.hoisted(() => vi.fn()); + +vi.mock('src/logic-functions/data/get-current-workspace-id.util', () => ({ + getCurrentWorkspaceId: getCurrentWorkspaceIdMock, +})); + +vi.mock( + 'src/logic-functions/recall-api/list-scheduled-recall-bots.util', + () => ({ + listScheduledRecallBots: listScheduledRecallBotsMock, + }), +); + +vi.mock('src/logic-functions/recall-api/cancel-recall-bot.util', () => ({ + cancelRecallBot: cancelRecallBotMock, +})); + +vi.mock('src/logic-functions/recall-api/eject-recall-bot.util', () => ({ + ejectRecallBot: ejectRecallBotMock, +})); + +const JOIN_AT_AFTER = '2026-01-01T08:00:00.000Z'; +const JOIN_AT_BEFORE = '2026-01-02T12:00:00.000Z'; +const CURRENT_WORKSPACE_ID = '123e4567-e89b-12d3-a456-426614174000'; +const OTHER_WORKSPACE_ID = '123e4567-e89b-12d3-a456-426614174999'; + +type CallRecordingNode = { + id: string; + recordingRequestStatus?: string | null; + externalBotId?: string | null; +}; + +class FakeCoreApiClient { + constructor(private callRecordings: CallRecordingNode[]) {} + + async query(query: any): Promise { + const callRecordingIds = query.callRecordings.__args.filter.id.in; + + return { + callRecordings: { + pageInfo: { hasNextPage: false, endCursor: undefined }, + edges: this.callRecordings + .filter((callRecording) => + callRecordingIds.includes(callRecording.id), + ) + .map((node) => ({ node })), + }, + }; + } +} + +const buildClient = (callRecordings: CallRecordingNode[]): CoreApiClient => + new FakeCoreApiClient(callRecordings) as unknown as CoreApiClient; + +const buildBot = ({ + id, + twentyCallRecordingId, + twentyWorkspaceId, +}: { + id: string; + twentyCallRecordingId?: string; + twentyWorkspaceId?: string; +}) => ({ + id, + metadata: { + ...(twentyCallRecordingId === undefined ? {} : { twentyCallRecordingId }), + ...(twentyWorkspaceId === undefined ? {} : { twentyWorkspaceId }), + }, +}); + +const buildCurrentWorkspaceBot = ({ + id, + twentyCallRecordingId, +}: { + id: string; + twentyCallRecordingId: string; +}) => + buildBot({ + id, + twentyCallRecordingId, + twentyWorkspaceId: CURRENT_WORKSPACE_ID, + }); + +describe('reapOrphanedMeetingBots', () => { + beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + getCurrentWorkspaceIdMock.mockReset(); + getCurrentWorkspaceIdMock.mockReturnValue(CURRENT_WORKSPACE_ID); + listScheduledRecallBotsMock.mockReset(); + cancelRecallBotMock.mockReset(); + cancelRecallBotMock.mockResolvedValue({ ok: true }); + ejectRecallBotMock.mockReset(); + ejectRecallBotMock.mockResolvedValue({ ok: true }); + }); + + it('keeps bots that their call recording still references', async () => { + listScheduledRecallBotsMock.mockResolvedValue({ + ok: true, + bots: [ + buildCurrentWorkspaceBot({ + id: 'claimed-bot', + twentyCallRecordingId: 'call-recording-1', + }), + ], + }); + + const result = await reapOrphanedMeetingBots({ + client: buildClient([ + { + id: 'call-recording-1', + recordingRequestStatus: 'REQUESTED', + externalBotId: 'claimed-bot', + }, + ]), + joinAtAfter: JOIN_AT_AFTER, + joinAtBefore: JOIN_AT_BEFORE, + }); + + expect(result).toEqual({ + scannedBotCount: 1, + canceledExternalBotIds: [], + }); + expect(cancelRecallBotMock).not.toHaveBeenCalled(); + }); + + it('cancels bots whose call recording request was canceled locally', async () => { + listScheduledRecallBotsMock.mockResolvedValue({ + ok: true, + bots: [ + buildCurrentWorkspaceBot({ + id: 'stale-cancel-bot', + twentyCallRecordingId: 'call-recording-1', + }), + ], + }); + + const result = await reapOrphanedMeetingBots({ + client: buildClient([ + { + id: 'call-recording-1', + recordingRequestStatus: 'CANCELED', + externalBotId: 'stale-cancel-bot', + }, + ]), + joinAtAfter: JOIN_AT_AFTER, + joinAtBefore: JOIN_AT_BEFORE, + }); + + expect(result).toEqual({ + scannedBotCount: 1, + canceledExternalBotIds: ['stale-cancel-bot'], + }); + expect(cancelRecallBotMock).toHaveBeenCalledWith({ + externalBotId: 'stale-cancel-bot', + }); + }); + + it('cancels bots whose call recording references another bot', async () => { + listScheduledRecallBotsMock.mockResolvedValue({ + ok: true, + bots: [ + buildCurrentWorkspaceBot({ + id: 'superseded-bot', + twentyCallRecordingId: 'call-recording-1', + }), + buildCurrentWorkspaceBot({ + id: 'claimed-bot', + twentyCallRecordingId: 'call-recording-1', + }), + ], + }); + + const result = await reapOrphanedMeetingBots({ + client: buildClient([ + { + id: 'call-recording-1', + recordingRequestStatus: 'REQUESTED', + externalBotId: 'claimed-bot', + }, + ]), + joinAtAfter: JOIN_AT_AFTER, + joinAtBefore: JOIN_AT_BEFORE, + }); + + expect(result).toEqual({ + scannedBotCount: 2, + canceledExternalBotIds: ['superseded-bot'], + }); + expect(cancelRecallBotMock).toHaveBeenCalledTimes(1); + expect(cancelRecallBotMock).toHaveBeenCalledWith({ + externalBotId: 'superseded-bot', + }); + }); + + it('cancels bots whose call recording no longer exists', async () => { + listScheduledRecallBotsMock.mockResolvedValue({ + ok: true, + bots: [ + buildCurrentWorkspaceBot({ + id: 'orphan-bot', + twentyCallRecordingId: 'call-recording-gone', + }), + ], + }); + + const result = await reapOrphanedMeetingBots({ + client: buildClient([]), + joinAtAfter: JOIN_AT_AFTER, + joinAtBefore: JOIN_AT_BEFORE, + }); + + expect(result).toEqual({ + scannedBotCount: 1, + canceledExternalBotIds: ['orphan-bot'], + }); + }); + + it('grants a grace round to requested recordings without a bot id yet', async () => { + listScheduledRecallBotsMock.mockResolvedValue({ + ok: true, + bots: [ + buildCurrentWorkspaceBot({ + id: 'pending-bot', + twentyCallRecordingId: 'call-recording-1', + }), + ], + }); + + const result = await reapOrphanedMeetingBots({ + client: buildClient([ + { + id: 'call-recording-1', + recordingRequestStatus: 'REQUESTED', + externalBotId: null, + }, + ]), + joinAtAfter: JOIN_AT_AFTER, + joinAtBefore: JOIN_AT_BEFORE, + }); + + expect(result).toEqual({ + scannedBotCount: 1, + canceledExternalBotIds: [], + }); + expect(cancelRecallBotMock).not.toHaveBeenCalled(); + }); + + it('ignores bots that were not created by this app', async () => { + listScheduledRecallBotsMock.mockResolvedValue({ + ok: true, + bots: [buildBot({ id: 'unrelated-bot' })], + }); + + const result = await reapOrphanedMeetingBots({ + client: buildClient([]), + joinAtAfter: JOIN_AT_AFTER, + joinAtBefore: JOIN_AT_BEFORE, + }); + + expect(result).toEqual({ + scannedBotCount: 1, + canceledExternalBotIds: [], + }); + expect(cancelRecallBotMock).not.toHaveBeenCalled(); + }); + + it('ignores untagged bots even when they carry call recording metadata', async () => { + listScheduledRecallBotsMock.mockResolvedValue({ + ok: true, + bots: [ + buildBot({ + id: 'untagged-bot', + twentyCallRecordingId: 'call-recording-gone', + }), + ], + }); + + const result = await reapOrphanedMeetingBots({ + client: buildClient([]), + joinAtAfter: JOIN_AT_AFTER, + joinAtBefore: JOIN_AT_BEFORE, + }); + + expect(result).toEqual({ + scannedBotCount: 1, + canceledExternalBotIds: [], + }); + expect(cancelRecallBotMock).not.toHaveBeenCalled(); + }); + + it('ignores bots claimed by another workspace', async () => { + listScheduledRecallBotsMock.mockResolvedValue({ + ok: true, + bots: [ + buildBot({ + id: 'other-workspace-bot', + twentyCallRecordingId: 'call-recording-gone', + twentyWorkspaceId: OTHER_WORKSPACE_ID, + }), + ], + }); + + const result = await reapOrphanedMeetingBots({ + client: buildClient([]), + joinAtAfter: JOIN_AT_AFTER, + joinAtBefore: JOIN_AT_BEFORE, + }); + + expect(result).toEqual({ + scannedBotCount: 1, + canceledExternalBotIds: [], + }); + expect(cancelRecallBotMock).not.toHaveBeenCalled(); + }); + + it('cancels orphaned bots claimed by this workspace', async () => { + listScheduledRecallBotsMock.mockResolvedValue({ + ok: true, + bots: [ + buildCurrentWorkspaceBot({ + id: 'same-workspace-bot', + twentyCallRecordingId: 'call-recording-gone', + }), + ], + }); + + const result = await reapOrphanedMeetingBots({ + client: buildClient([]), + joinAtAfter: JOIN_AT_AFTER, + joinAtBefore: JOIN_AT_BEFORE, + }); + + expect(result).toEqual({ + scannedBotCount: 1, + canceledExternalBotIds: ['same-workspace-bot'], + }); + expect(cancelRecallBotMock).toHaveBeenCalledWith({ + externalBotId: 'same-workspace-bot', + }); + }); + + it('ejects an orphaned bot that already joined when deletion is rejected', async () => { + listScheduledRecallBotsMock.mockResolvedValue({ + ok: true, + bots: [ + buildCurrentWorkspaceBot({ + id: 'in-call-orphan', + twentyCallRecordingId: 'call-recording-gone', + }), + ], + }); + cancelRecallBotMock.mockResolvedValue({ + ok: false, + status: 409, + errorMessage: 'Recall API responded with HTTP 409', + }); + + const result = await reapOrphanedMeetingBots({ + client: buildClient([]), + joinAtAfter: JOIN_AT_AFTER, + joinAtBefore: JOIN_AT_BEFORE, + }); + + expect(result).toEqual({ + scannedBotCount: 1, + canceledExternalBotIds: ['in-call-orphan'], + }); + expect(ejectRecallBotMock).toHaveBeenCalledWith({ + externalBotId: 'in-call-orphan', + }); + }); + + it('reports nothing reaped when listing Recall bots fails', async () => { + listScheduledRecallBotsMock.mockResolvedValue({ + ok: false, + status: 500, + errorMessage: 'Recall API responded with HTTP 500', + }); + + const result = await reapOrphanedMeetingBots({ + client: buildClient([]), + joinAtAfter: JOIN_AT_AFTER, + joinAtBefore: JOIN_AT_BEFORE, + }); + + expect(result).toEqual({ + scannedBotCount: 0, + canceledExternalBotIds: [], + }); + expect(cancelRecallBotMock).not.toHaveBeenCalled(); + }); + + it('skips reaping when the current workspace cannot be resolved', async () => { + getCurrentWorkspaceIdMock.mockReturnValue(undefined); + listScheduledRecallBotsMock.mockResolvedValue({ + ok: true, + bots: [ + buildCurrentWorkspaceBot({ + id: 'same-workspace-bot', + twentyCallRecordingId: 'call-recording-gone', + }), + ], + }); + + const result = await reapOrphanedMeetingBots({ + client: buildClient([]), + joinAtAfter: JOIN_AT_AFTER, + joinAtBefore: JOIN_AT_BEFORE, + }); + + expect(result).toEqual({ + scannedBotCount: 1, + canceledExternalBotIds: [], + }); + expect(cancelRecallBotMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/flows/__tests__/reconcile-meeting-bot.test.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/flows/__tests__/reconcile-meeting-bot.test.ts new file mode 100644 index 0000000000..6729dcd74f --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/flows/__tests__/reconcile-meeting-bot.test.ts @@ -0,0 +1,1013 @@ +import { type CoreApiClient } from 'twenty-client-sdk/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { computeCallRecordingIdForMeeting } from 'src/logic-functions/domain/compute-call-recording-id-for-meeting.util'; +import { reconcileMeetingBotForCalendarEventIds } from 'src/logic-functions/flows/reconcile-meeting-bot.util'; + +const scheduleRecallBotMock = vi.hoisted(() => vi.fn()); +const rescheduleRecallBotMock = vi.hoisted(() => vi.fn()); +const cancelRecallBotMock = vi.hoisted(() => vi.fn()); +const getCurrentWorkspaceIdMock = vi.hoisted(() => vi.fn()); + +vi.mock('src/logic-functions/data/get-current-workspace-id.util', () => ({ + getCurrentWorkspaceId: getCurrentWorkspaceIdMock, +})); + +vi.mock('src/logic-functions/recall-api/schedule-recall-bot.util', () => ({ + scheduleRecallBot: scheduleRecallBotMock, +})); + +vi.mock('src/logic-functions/recall-api/reschedule-recall-bot.util', () => ({ + rescheduleRecallBot: rescheduleRecallBotMock, +})); + +vi.mock('src/logic-functions/recall-api/cancel-recall-bot.util', () => ({ + cancelRecallBot: cancelRecallBotMock, +})); + +const NOW = new Date('2026-01-01T12:00:00.000Z'); +const WORKSPACE_ID = '123e4567-e89b-12d3-a456-426614174000'; +const FUTURE_STARTS_AT = '2026-01-01T13:00:00.000Z'; +const FUTURE_RECALL_BOT_JOIN_AT = '2026-01-01T12:59:00.000Z'; +const FUTURE_ENDS_AT = '2026-01-01T14:00:00.000Z'; + +const buildCustomerSyncCallRecordingId = ( + startsAt: string = FUTURE_STARTS_AT, +): string => + computeCallRecordingIdForMeeting( + `link:meet.example.com/customer-sync:${startsAt}`, + ); + +type CalendarEventNode = { + id: string; + title?: string | null; + isCanceled?: boolean | null; + startsAt?: string | null; + endsAt?: string | null; + iCalUid?: string | null; + conferenceLink?: { primaryLinkUrl?: string | null } | null; + meetingBotPreference?: string | null; +}; + +type CallRecordingNode = { + id: string; + title?: string | null; + status?: string | null; + recordingRequestStatus?: string | null; + startedAt?: string | null; + endedAt?: string | null; + calendarEventId?: string | null; + externalBotId?: string | null; + externalRecordingId?: string | null; +}; + +type FakeCoreApiClientFixture = { + calendarEvents: CalendarEventNode[]; + callRecordings?: CallRecordingNode[]; +}; + +class FakeCoreApiClient { + calendarEvents: CalendarEventNode[]; + callRecordings: CallRecordingNode[]; + mutations: Array<{ name: string; args: unknown }> = []; + + constructor({ + calendarEvents, + callRecordings = [], + }: FakeCoreApiClientFixture) { + this.calendarEvents = calendarEvents; + this.callRecordings = callRecordings; + } + + async query(query: any): Promise { + if (query.calendarEvents !== undefined) { + return { + calendarEvents: buildConnection( + this.filterCalendarEvents(query.calendarEvents.__args.filter), + ), + }; + } + + if (query.callRecordings !== undefined) { + const filter = query.callRecordings.__args.filter; + + if (filter.id?.in !== undefined) { + return { + callRecordings: buildConnection( + this.callRecordings.filter((callRecording) => + filter.id.in.includes(callRecording.id), + ), + ), + }; + } + + return { + callRecordings: buildConnection( + this.callRecordings.filter((callRecording) => + filter.calendarEventId.in.includes(callRecording.calendarEventId), + ), + ), + }; + } + + throw new Error(`Unhandled query: ${JSON.stringify(query)}`); + } + + async mutation(mutation: any): Promise { + if (mutation.createCallRecording !== undefined) { + const data = mutation.createCallRecording.__args.data; + + if (this.callRecordings.some((candidate) => candidate.id === data.id)) { + throw new Error(`Duplicate call recording id ${data.id}`); + } + + const createdCallRecording = { ...data }; + + this.callRecordings.push(createdCallRecording); + this.mutations.push({ + name: 'createCallRecording', + args: data, + }); + + return { + createCallRecording: { + id: createdCallRecording.id, + }, + }; + } + + if (mutation.updateCallRecording !== undefined) { + const { id, data } = mutation.updateCallRecording.__args; + const callRecording = this.callRecordings.find( + (candidate) => candidate.id === id, + ); + + if (callRecording === undefined) { + throw new Error(`Could not find call recording ${id}`); + } + + Object.assign(callRecording, data); + this.mutations.push({ + name: 'updateCallRecording', + args: { id, data }, + }); + + return { + updateCallRecording: { + id, + }, + }; + } + + throw new Error(`Unhandled mutation: ${JSON.stringify(mutation)}`); + } + + private filterCalendarEvents(filter: any): CalendarEventNode[] { + if (filter.id?.in !== undefined) { + return this.calendarEvents.filter((calendarEvent) => + filter.id.in.includes(calendarEvent.id), + ); + } + + if (filter.startsAt?.in !== undefined) { + return this.calendarEvents.filter((calendarEvent) => + filter.startsAt.in.includes(calendarEvent.startsAt), + ); + } + + throw new Error( + `Unhandled calendar event filter: ${JSON.stringify(filter)}`, + ); + } +} + +const buildConnection = (nodes: Node[]) => ({ + pageInfo: { + hasNextPage: false, + endCursor: undefined, + }, + edges: nodes.map((node) => ({ node })), +}); + +const buildCalendarEvent = ( + overrides: Partial = {}, +): CalendarEventNode => ({ + id: 'calendar-event-1', + title: 'Customer Sync', + isCanceled: false, + startsAt: FUTURE_STARTS_AT, + endsAt: FUTURE_ENDS_AT, + iCalUid: 'calendar-event-uid', + conferenceLink: { + primaryLinkUrl: 'https://meet.example.com/customer-sync', + }, + meetingBotPreference: 'ON', + ...overrides, +}); + +const buildFakeCoreApiClient = ( + fixture: FakeCoreApiClientFixture, +): FakeCoreApiClient => new FakeCoreApiClient(fixture); + +describe('reconcileMeetingBotForCalendarEventIds', () => { + beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + getCurrentWorkspaceIdMock.mockReset(); + getCurrentWorkspaceIdMock.mockReturnValue(WORKSPACE_ID); + scheduleRecallBotMock.mockReset(); + scheduleRecallBotMock.mockResolvedValue({ + ok: true, + externalBotId: 'recall-bot-1', + }); + rescheduleRecallBotMock.mockReset(); + rescheduleRecallBotMock.mockResolvedValue({ + ok: true, + externalBotId: 'recall-bot-1', + }); + cancelRecallBotMock.mockReset(); + cancelRecallBotMock.mockResolvedValue({ + ok: true, + externalBotId: null, + }); + }); + + it('creates a scheduled call recording when the policy requests a bot', async () => { + const client = buildFakeCoreApiClient({ + calendarEvents: [buildCalendarEvent()], + }); + + const result = await reconcileMeetingBotForCalendarEventIds({ + client: client as unknown as CoreApiClient, + calendarEventIds: ['calendar-event-1'], + now: NOW, + }); + + expect(result).toEqual([ + expect.objectContaining({ + action: 'CREATED', + callRecordingId: buildCustomerSyncCallRecordingId(), + }), + ]); + expect(client.callRecordings).toEqual([ + { + id: buildCustomerSyncCallRecordingId(), + title: 'Customer Sync', + status: 'SCHEDULED', + recordingRequestStatus: 'REQUESTED', + calendarEventId: 'calendar-event-1', + externalBotId: 'recall-bot-1', + }, + ]); + expect(scheduleRecallBotMock).toHaveBeenCalledWith({ + meetingUrl: 'https://meet.example.com/customer-sync', + joinAt: FUTURE_RECALL_BOT_JOIN_AT, + metadata: { + twentyWorkspaceId: WORKSPACE_ID, + twentyCallRecordingId: buildCustomerSyncCallRecordingId(), + twentyCalendarEventId: 'calendar-event-1', + twentyRealMeetingKey: + 'link:meet.example.com/customer-sync:2026-01-01T13:00:00.000Z', + }, + }); + }); + + it('creates a scheduled call recording for the default ON preference', async () => { + const client = buildFakeCoreApiClient({ + calendarEvents: [buildCalendarEvent({ meetingBotPreference: null })], + }); + + const result = await reconcileMeetingBotForCalendarEventIds({ + client: client as unknown as CoreApiClient, + calendarEventIds: ['calendar-event-1'], + now: NOW, + }); + + expect(result).toEqual([ + expect.objectContaining({ + action: 'CREATED', + callRecordingId: buildCustomerSyncCallRecordingId(), + }), + ]); + expect(scheduleRecallBotMock).toHaveBeenCalledTimes(1); + }); + + it('creates a recording for an in-progress meeting that has not ended', async () => { + const client = buildFakeCoreApiClient({ + calendarEvents: [ + buildCalendarEvent({ + meetingBotPreference: null, + startsAt: '2026-01-01T11:30:00.000Z', + endsAt: '2026-01-01T13:00:00.000Z', + }), + ], + }); + + const result = await reconcileMeetingBotForCalendarEventIds({ + client: client as unknown as CoreApiClient, + calendarEventIds: ['calendar-event-1'], + now: NOW, + }); + + expect(result).toEqual([ + expect.objectContaining({ + action: 'CREATED', + callRecordingId: buildCustomerSyncCallRecordingId( + '2026-01-01T11:30:00.000Z', + ), + }), + ]); + expect(scheduleRecallBotMock).toHaveBeenCalledTimes(1); + }); + + it('updates an existing in-progress recording', async () => { + const client = buildFakeCoreApiClient({ + calendarEvents: [ + buildCalendarEvent({ + meetingBotPreference: null, + title: 'Updated Customer Sync', + startsAt: '2026-01-01T11:30:00.000Z', + endsAt: '2026-01-01T13:00:00.000Z', + }), + ], + callRecordings: [ + { + id: buildCustomerSyncCallRecordingId('2026-01-01T11:30:00.000Z'), + title: 'Old Customer Sync', + status: 'SCHEDULED', + recordingRequestStatus: 'REQUESTED', + calendarEventId: 'calendar-event-1', + externalBotId: 'recall-bot-1', + }, + ], + }); + + const result = await reconcileMeetingBotForCalendarEventIds({ + client: client as unknown as CoreApiClient, + calendarEventIds: ['calendar-event-1'], + now: NOW, + }); + + expect(result).toEqual([ + expect.objectContaining({ + action: 'UPDATED', + callRecordingId: buildCustomerSyncCallRecordingId( + '2026-01-01T11:30:00.000Z', + ), + }), + ]); + expect(client.callRecordings).toEqual([ + expect.objectContaining({ + title: 'Updated Customer Sync', + recordingRequestStatus: 'REQUESTED', + }), + ]); + }); + + it('updates an existing policy-managed scheduled call recording', async () => { + const client = buildFakeCoreApiClient({ + calendarEvents: [ + buildCalendarEvent({ + title: 'Updated Customer Sync', + }), + ], + callRecordings: [ + { + id: buildCustomerSyncCallRecordingId(), + title: 'Old Customer Sync', + status: 'SCHEDULED', + recordingRequestStatus: 'REQUESTED', + startedAt: FUTURE_STARTS_AT, + endedAt: FUTURE_ENDS_AT, + calendarEventId: 'calendar-event-1', + externalBotId: 'recall-bot-1', + }, + ], + }); + + const result = await reconcileMeetingBotForCalendarEventIds({ + client: client as unknown as CoreApiClient, + calendarEventIds: ['calendar-event-1'], + now: NOW, + }); + + expect(result).toEqual([ + expect.objectContaining({ + action: 'UPDATED', + callRecordingId: buildCustomerSyncCallRecordingId(), + }), + ]); + expect(client.callRecordings).toEqual([ + expect.objectContaining({ + id: buildCustomerSyncCallRecordingId(), + title: 'Updated Customer Sync', + status: 'SCHEDULED', + recordingRequestStatus: 'REQUESTED', + startedAt: FUTURE_STARTS_AT, + endedAt: FUTURE_ENDS_AT, + calendarEventId: 'calendar-event-1', + externalBotId: 'recall-bot-1', + }), + ]); + expect(rescheduleRecallBotMock).toHaveBeenCalledWith({ + externalBotId: 'recall-bot-1', + meetingUrl: 'https://meet.example.com/customer-sync', + joinAt: FUTURE_RECALL_BOT_JOIN_AT, + metadata: { + twentyWorkspaceId: WORKSPACE_ID, + twentyCallRecordingId: buildCustomerSyncCallRecordingId(), + twentyCalendarEventId: 'calendar-event-1', + twentyRealMeetingKey: + 'link:meet.example.com/customer-sync:2026-01-01T13:00:00.000Z', + }, + }); + }); + + it('cancels an existing scheduled request when the policy no longer requests a bot', async () => { + const client = buildFakeCoreApiClient({ + calendarEvents: [ + buildCalendarEvent({ + meetingBotPreference: 'OFF', + }), + ], + callRecordings: [ + { + id: 'call-recording-1', + title: 'Customer Sync', + status: 'SCHEDULED', + recordingRequestStatus: 'REQUESTED', + startedAt: FUTURE_STARTS_AT, + endedAt: FUTURE_ENDS_AT, + calendarEventId: 'calendar-event-1', + externalBotId: 'recall-bot-1', + }, + ], + }); + + const result = await reconcileMeetingBotForCalendarEventIds({ + client: client as unknown as CoreApiClient, + calendarEventIds: ['calendar-event-1'], + now: NOW, + }); + + expect(result).toEqual([ + expect.objectContaining({ + action: 'CANCELED', + callRecordingId: 'call-recording-1', + }), + ]); + expect(client.callRecordings).toEqual([ + expect.objectContaining({ + id: 'call-recording-1', + recordingRequestStatus: 'CANCELED', + externalBotId: null, + }), + ]); + expect(cancelRecallBotMock).toHaveBeenCalledWith({ + externalBotId: 'recall-bot-1', + }); + }); + + it('persists the cancel intent and leaves the bot for the planned stale-state cron when the Recall cancel fails', async () => { + cancelRecallBotMock.mockResolvedValue({ + ok: false, + status: 500, + errorMessage: 'Recall API responded with HTTP 500', + }); + + const client = buildFakeCoreApiClient({ + calendarEvents: [ + buildCalendarEvent({ + meetingBotPreference: 'OFF', + }), + ], + callRecordings: [ + { + id: 'call-recording-1', + title: 'Customer Sync', + status: 'SCHEDULED', + recordingRequestStatus: 'REQUESTED', + startedAt: FUTURE_STARTS_AT, + endedAt: FUTURE_ENDS_AT, + calendarEventId: 'calendar-event-1', + externalBotId: 'recall-bot-1', + }, + ], + }); + + const result = await reconcileMeetingBotForCalendarEventIds({ + client: client as unknown as CoreApiClient, + calendarEventIds: ['calendar-event-1'], + now: NOW, + }); + + expect(result).toEqual([ + expect.objectContaining({ + action: 'CANCELED', + callRecordingId: 'call-recording-1', + }), + ]); + expect(client.callRecordings).toEqual([ + expect.objectContaining({ + id: 'call-recording-1', + recordingRequestStatus: 'CANCELED', + externalBotId: 'recall-bot-1', + }), + ]); + }); + + it('does not reset the status of a recording whose bot is already live', async () => { + const client = buildFakeCoreApiClient({ + calendarEvents: [ + buildCalendarEvent({ + title: 'Renamed Customer Sync', + }), + ], + callRecordings: [ + { + id: buildCustomerSyncCallRecordingId(), + title: 'Customer Sync', + status: 'JOINING', + recordingRequestStatus: 'REQUESTED', + startedAt: FUTURE_STARTS_AT, + endedAt: FUTURE_ENDS_AT, + calendarEventId: 'calendar-event-1', + externalBotId: 'recall-bot-1', + }, + ], + }); + + const result = await reconcileMeetingBotForCalendarEventIds({ + client: client as unknown as CoreApiClient, + calendarEventIds: ['calendar-event-1'], + now: NOW, + }); + + expect(result).toEqual([ + expect.objectContaining({ + action: 'UPDATED', + callRecordingId: buildCustomerSyncCallRecordingId(), + }), + ]); + expect(client.callRecordings).toEqual([ + expect.objectContaining({ + id: buildCustomerSyncCallRecordingId(), + title: 'Renamed Customer Sync', + status: 'JOINING', + }), + ]); + }); + + it('creates a single recording when duplicate synced rows share the same real meeting', async () => { + const client = buildFakeCoreApiClient({ + calendarEvents: [ + buildCalendarEvent(), + buildCalendarEvent({ + id: 'calendar-event-2', + iCalUid: 'calendar-event-uid-from-other-channel', + }), + ], + }); + + const result = await reconcileMeetingBotForCalendarEventIds({ + client: client as unknown as CoreApiClient, + calendarEventIds: ['calendar-event-1'], + now: NOW, + }); + + expect(result).toEqual([ + expect.objectContaining({ + action: 'CREATED', + callRecordingId: buildCustomerSyncCallRecordingId(), + }), + ]); + expect(client.callRecordings).toHaveLength(1); + expect(scheduleRecallBotMock).toHaveBeenCalledTimes(1); + }); + + it('does not create a duplicate when a non-policy-managed open recording already exists', async () => { + const client = buildFakeCoreApiClient({ + calendarEvents: [buildCalendarEvent()], + callRecordings: [ + { + id: 'call-recording-1', + title: 'Manual Recording', + status: 'SCHEDULED', + recordingRequestStatus: null, + startedAt: FUTURE_STARTS_AT, + endedAt: FUTURE_ENDS_AT, + calendarEventId: 'calendar-event-1', + }, + ], + }); + + const result = await reconcileMeetingBotForCalendarEventIds({ + client: client as unknown as CoreApiClient, + calendarEventIds: ['calendar-event-1'], + now: NOW, + }); + + expect(result).toEqual([ + expect.objectContaining({ + action: 'SKIPPED', + callRecordingId: 'call-recording-1', + }), + ]); + expect(client.callRecordings).toHaveLength(1); + expect(client.mutations).toEqual([]); + expect(scheduleRecallBotMock).not.toHaveBeenCalled(); + }); + + it('cancels the scheduled request when the calendar event is deleted', async () => { + const client = buildFakeCoreApiClient({ + calendarEvents: [], + callRecordings: [ + { + id: 'call-recording-1', + title: 'Customer Sync', + status: 'SCHEDULED', + recordingRequestStatus: 'REQUESTED', + startedAt: FUTURE_STARTS_AT, + endedAt: FUTURE_ENDS_AT, + calendarEventId: 'calendar-event-1', + externalBotId: 'recall-bot-1', + }, + ], + }); + + const result = await reconcileMeetingBotForCalendarEventIds({ + client: client as unknown as CoreApiClient, + calendarEventIds: [], + removedOccurrences: [ + { + calendarEventId: 'calendar-event-1', + realMeetingKey: `link:meet.example.com/customer-sync:${FUTURE_STARTS_AT}`, + startsAt: FUTURE_STARTS_AT, + }, + ], + now: NOW, + }); + + expect(result).toEqual([ + expect.objectContaining({ + action: 'CANCELED', + callRecordingId: 'call-recording-1', + }), + ]); + expect(client.callRecordings).toEqual([ + expect.objectContaining({ + id: 'call-recording-1', + recordingRequestStatus: 'CANCELED', + externalBotId: null, + }), + ]); + expect(cancelRecallBotMock).toHaveBeenCalledWith({ + externalBotId: 'recall-bot-1', + }); + }); + + it('cancels the old occurrence and creates a fresh recording when the meeting moves to a new time', async () => { + const NEW_STARTS_AT = '2026-01-02T13:00:00.000Z'; + const NEW_RECALL_BOT_JOIN_AT = '2026-01-02T12:59:00.000Z'; + const NEW_ENDS_AT = '2026-01-02T14:00:00.000Z'; + const client = buildFakeCoreApiClient({ + calendarEvents: [ + buildCalendarEvent({ + startsAt: NEW_STARTS_AT, + endsAt: NEW_ENDS_AT, + }), + ], + callRecordings: [ + { + id: buildCustomerSyncCallRecordingId(), + title: 'Customer Sync', + status: 'SCHEDULED', + recordingRequestStatus: 'REQUESTED', + startedAt: FUTURE_STARTS_AT, + endedAt: FUTURE_ENDS_AT, + calendarEventId: 'calendar-event-1', + externalBotId: 'recall-bot-old', + }, + ], + }); + + const result = await reconcileMeetingBotForCalendarEventIds({ + client: client as unknown as CoreApiClient, + calendarEventIds: ['calendar-event-1'], + removedOccurrences: [ + { + calendarEventId: 'calendar-event-1', + realMeetingKey: `link:meet.example.com/customer-sync:${FUTURE_STARTS_AT}`, + startsAt: FUTURE_STARTS_AT, + }, + ], + now: NOW, + }); + + expect(result).toEqual([ + expect.objectContaining({ + action: 'CANCELED', + callRecordingId: buildCustomerSyncCallRecordingId(), + }), + expect.objectContaining({ + action: 'CREATED', + callRecordingId: buildCustomerSyncCallRecordingId(NEW_STARTS_AT), + }), + ]); + expect(cancelRecallBotMock).toHaveBeenCalledExactlyOnceWith({ + externalBotId: 'recall-bot-old', + }); + expect(scheduleRecallBotMock).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ joinAt: NEW_RECALL_BOT_JOIN_AT }), + ); + expect(client.callRecordings).toEqual([ + expect.objectContaining({ + id: buildCustomerSyncCallRecordingId(), + recordingRequestStatus: 'CANCELED', + externalBotId: null, + }), + expect.objectContaining({ + id: buildCustomerSyncCallRecordingId(NEW_STARTS_AT), + recordingRequestStatus: 'REQUESTED', + externalBotId: 'recall-bot-1', + }), + ]); + }); + + it('reconciles the remaining meetings when one meeting fails', async () => { + cancelRecallBotMock.mockRejectedValue(new Error('recall exploded')); + + const client = buildFakeCoreApiClient({ + calendarEvents: [ + buildCalendarEvent({ + meetingBotPreference: 'OFF', + }), + buildCalendarEvent({ + id: 'calendar-event-2', + iCalUid: 'other-meeting-uid', + conferenceLink: { + primaryLinkUrl: 'https://meet.example.com/other-sync', + }, + }), + ], + callRecordings: [ + { + id: 'call-recording-1', + title: 'Customer Sync', + status: 'SCHEDULED', + recordingRequestStatus: 'REQUESTED', + startedAt: FUTURE_STARTS_AT, + endedAt: FUTURE_ENDS_AT, + calendarEventId: 'calendar-event-1', + externalBotId: 'recall-bot-1', + }, + ], + }); + + const result = await reconcileMeetingBotForCalendarEventIds({ + client: client as unknown as CoreApiClient, + calendarEventIds: ['calendar-event-1', 'calendar-event-2'], + now: NOW, + }); + + expect(result).toEqual([ + expect.objectContaining({ + action: 'FAILED', + realMeetingKey: `link:meet.example.com/customer-sync:${FUTURE_STARTS_AT}`, + errorMessage: 'recall exploded', + }), + expect.objectContaining({ action: 'CREATED' }), + ]); + expect(client.callRecordings).toEqual([ + expect.objectContaining({ + id: 'call-recording-1', + recordingRequestStatus: 'CANCELED', + externalBotId: 'recall-bot-1', + }), + expect.objectContaining({ + calendarEventId: 'calendar-event-2', + status: 'SCHEDULED', + }), + ]); + }); + + it('cancels the scheduled request when the conference link is removed', async () => { + const client = buildFakeCoreApiClient({ + calendarEvents: [ + buildCalendarEvent({ + conferenceLink: null, + }), + ], + callRecordings: [ + { + id: 'call-recording-1', + title: 'Customer Sync', + status: 'SCHEDULED', + recordingRequestStatus: 'REQUESTED', + startedAt: FUTURE_STARTS_AT, + endedAt: FUTURE_ENDS_AT, + calendarEventId: 'calendar-event-1', + externalBotId: 'recall-bot-1', + }, + ], + }); + + const result = await reconcileMeetingBotForCalendarEventIds({ + client: client as unknown as CoreApiClient, + calendarEventIds: ['calendar-event-1'], + now: NOW, + }); + + expect(result).toEqual([ + expect.objectContaining({ + action: 'CANCELED', + callRecordingId: 'call-recording-1', + }), + ]); + expect(client.callRecordings).toEqual([ + expect.objectContaining({ + id: 'call-recording-1', + recordingRequestStatus: 'CANCELED', + externalBotId: null, + }), + ]); + }); + + it('clears the stale bot id for the stale-state cron to re-create when the existing Recall bot no longer exists', async () => { + rescheduleRecallBotMock.mockResolvedValue({ + ok: false, + status: 404, + errorMessage: 'Recall API responded with HTTP 404', + }); + + const client = buildFakeCoreApiClient({ + calendarEvents: [buildCalendarEvent()], + callRecordings: [ + { + id: buildCustomerSyncCallRecordingId(), + title: 'Customer Sync', + status: 'SCHEDULED', + recordingRequestStatus: 'REQUESTED', + startedAt: FUTURE_STARTS_AT, + endedAt: FUTURE_ENDS_AT, + calendarEventId: 'calendar-event-1', + externalBotId: 'recall-bot-stale', + }, + ], + }); + + const result = await reconcileMeetingBotForCalendarEventIds({ + client: client as unknown as CoreApiClient, + calendarEventIds: ['calendar-event-1'], + now: NOW, + }); + + expect(result).toEqual([ + expect.objectContaining({ + action: 'UPDATED', + callRecordingId: buildCustomerSyncCallRecordingId(), + }), + ]); + expect(rescheduleRecallBotMock).toHaveBeenCalledWith( + expect.objectContaining({ externalBotId: 'recall-bot-stale' }), + ); + // The event path no longer re-creates the bot; the stale id is cleared and the cron heals the botless row. + expect(scheduleRecallBotMock).not.toHaveBeenCalled(); + expect(client.callRecordings).toEqual([ + expect.objectContaining({ + id: buildCustomerSyncCallRecordingId(), + externalBotId: null, + }), + ]); + }); + + it('adopts the concurrently created recording when it loses the deterministic-id insert race', async () => { + class InsertRaceFakeCoreApiClient extends FakeCoreApiClient { + override async mutation(mutation: any): Promise { + if (mutation.createCallRecording !== undefined) { + const concurrentlyInsertedId = + mutation.createCallRecording.__args.data.id; + + if ( + !this.callRecordings.some( + (candidate) => candidate.id === concurrentlyInsertedId, + ) + ) { + this.callRecordings.push({ + id: concurrentlyInsertedId, + status: 'SCHEDULED', + recordingRequestStatus: 'REQUESTED', + calendarEventId: 'calendar-event-1', + externalBotId: 'sibling-bot', + }); + } + } + + return super.mutation(mutation); + } + } + + const client = new InsertRaceFakeCoreApiClient({ + calendarEvents: [buildCalendarEvent()], + }); + + const result = await reconcileMeetingBotForCalendarEventIds({ + client: client as unknown as CoreApiClient, + calendarEventIds: ['calendar-event-1'], + now: NOW, + }); + + expect(result).toEqual([ + expect.objectContaining({ + action: 'UPDATED', + callRecordingId: buildCustomerSyncCallRecordingId(), + }), + ]); + expect(client.callRecordings).toHaveLength(1); + expect(scheduleRecallBotMock).not.toHaveBeenCalled(); + expect(rescheduleRecallBotMock).toHaveBeenCalledWith( + expect.objectContaining({ externalBotId: 'sibling-bot' }), + ); + }); + + it('fails the meeting when the create conflicts without a readable recording', async () => { + class TombstoneFakeCoreApiClient extends FakeCoreApiClient { + override async mutation(mutation: any): Promise { + if (mutation.createCallRecording !== undefined) { + throw new Error('Duplicate id on a soft-deleted record'); + } + + return super.mutation(mutation); + } + } + + const client = new TombstoneFakeCoreApiClient({ + calendarEvents: [buildCalendarEvent()], + }); + + const result = await reconcileMeetingBotForCalendarEventIds({ + client: client as unknown as CoreApiClient, + calendarEventIds: ['calendar-event-1'], + now: NOW, + }); + + expect(result).toEqual([ + expect.objectContaining({ + action: 'FAILED', + errorMessage: 'Duplicate id on a soft-deleted record', + }), + ]); + expect(scheduleRecallBotMock).not.toHaveBeenCalled(); + }); + + it('schedules exactly one bot when concurrent reconciles race for the same meeting', async () => { + const client = buildFakeCoreApiClient({ + calendarEvents: [buildCalendarEvent()], + }); + + await Promise.all( + Array.from({ length: 4 }, () => + reconcileMeetingBotForCalendarEventIds({ + client: client as unknown as CoreApiClient, + calendarEventIds: ['calendar-event-1'], + now: NOW, + }), + ), + ); + + expect(client.callRecordings).toHaveLength(1); + expect(scheduleRecallBotMock).toHaveBeenCalledTimes(1); + expect(client.callRecordings[0].externalBotId).toBe('recall-bot-1'); + }); + + it('does not schedule a bot when the recording is canceled between decide and schedule', async () => { + class CancelRaceFakeCoreApiClient extends FakeCoreApiClient { + override async query(query: any): Promise { + if (query.callRecordings?.__args.filter.id?.in !== undefined) { + const callRecording = this.callRecordings.find((candidate) => + query.callRecordings.__args.filter.id.in.includes(candidate.id), + ); + + if (callRecording !== undefined) { + callRecording.recordingRequestStatus = 'CANCELED'; + } + } + + return super.query(query); + } + } + + const client = new CancelRaceFakeCoreApiClient({ + calendarEvents: [buildCalendarEvent()], + }); + + const result = await reconcileMeetingBotForCalendarEventIds({ + client: client as unknown as CoreApiClient, + calendarEventIds: ['calendar-event-1'], + now: NOW, + }); + + expect(result).toEqual([expect.objectContaining({ action: 'CREATED' })]); + expect(scheduleRecallBotMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/flows/cancel-call-recording-request.util.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/flows/cancel-call-recording-request.util.ts new file mode 100644 index 0000000000..8184955a53 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/flows/cancel-call-recording-request.util.ts @@ -0,0 +1,46 @@ +import { isUndefined } from '@sniptt/guards'; +import { type CoreApiClient } from 'twenty-client-sdk/core'; + +import { CallRecordingRequestStatus } from 'src/logic-functions/constants/call-recording-request-status'; +import { type CallRecordingRecord } from 'src/logic-functions/types/call-recording-record.type'; +import { cancelRecallBot } from 'src/logic-functions/recall-api/cancel-recall-bot.util'; +import { updateCallRecording } from 'src/logic-functions/data/update-call-recording.util'; + +// Intent-first: the stale-state cron finishes the Recall half when this call fails. +export const cancelCallRecordingRequest = async ({ + client, + callRecording, +}: { + client: CoreApiClient; + callRecording: CallRecordingRecord; +}): Promise => { + await updateCallRecording(client, { + id: callRecording.id, + data: { + recordingRequestStatus: CallRecordingRequestStatus.CANCELED, + }, + }); + + if (isUndefined(callRecording.externalBotId)) { + return; + } + + const cancelResult = await cancelRecallBot({ + externalBotId: callRecording.externalBotId, + }); + + if (!cancelResult.ok) { + console.warn( + `[twenty-meeting-bot] failed to cancel Recall bot for callRecording ${callRecording.id}, leaving it for the stale-state cron: ${cancelResult.errorMessage}`, + ); + + return; + } + + await updateCallRecording(client, { + id: callRecording.id, + data: { + externalBotId: null, + }, + }); +}; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/flows/charge-completed-call-recording.util.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/flows/charge-completed-call-recording.util.ts new file mode 100644 index 0000000000..dd1a02be0f --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/flows/charge-completed-call-recording.util.ts @@ -0,0 +1,31 @@ +import { isUndefined } from '@sniptt/guards'; +import { chargeCredits } from 'twenty-sdk/billing'; + +import { computeCallRecordingCharge } from 'src/logic-functions/domain/compute-call-recording-charge.util'; + +export const chargeCompletedCallRecording = async ({ + callRecordingId, + startedAt, + endedAt, +}: { + callRecordingId: string; + startedAt: string | undefined; + endedAt: string | undefined; +}): Promise => { + const charge = computeCallRecordingCharge({ startedAt, endedAt }); + + if (isUndefined(charge)) { + console.warn( + `[twenty-meeting-bot] call recording ${callRecordingId} completed without usable recording timestamps; it will not be billed`, + ); + + return; + } + + await chargeCredits({ + creditsUsedMicro: charge.creditsUsedMicro, + quantity: charge.quantityMinutes, + operationType: 'CALL_RECORDING', + resourceContext: 'recall', + }); +}; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/flows/complete-and-charge-call-recording.util.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/flows/complete-and-charge-call-recording.util.ts new file mode 100644 index 0000000000..1c1dff964b --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/flows/complete-and-charge-call-recording.util.ts @@ -0,0 +1,29 @@ +import { type CoreApiClient } from 'twenty-client-sdk/core'; + +import { completeCallRecordingIngestion } from 'src/logic-functions/data/complete-call-recording-ingestion.util'; +import { chargeCompletedCallRecording } from 'src/logic-functions/flows/charge-completed-call-recording.util'; + +export const completeAndChargeCallRecording = async ( + client: CoreApiClient, + { + id, + startedAt, + endedAt, + }: { + id: string; + startedAt: string | undefined; + endedAt: string | undefined; + }, +): Promise => { + const claimed = await completeCallRecordingIngestion(client, { id }); + + if (claimed) { + await chargeCompletedCallRecording({ + callRecordingId: id, + startedAt, + endedAt, + }); + } + + return claimed; +}; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/flows/converge-diverged-call-recordings-result.type.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/flows/converge-diverged-call-recordings-result.type.ts new file mode 100644 index 0000000000..d76740720d --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/flows/converge-diverged-call-recordings-result.type.ts @@ -0,0 +1,8 @@ +export type ConvergeDivergedCallRecordingsResult = { + candidateCount: number; + updatedCallRecordingIds: string[]; + markedFailedCallRecordingIds: string[]; + requestedTranscriptCallRecordingIds: string[]; + unconvergeableCallRecordingIds: string[]; + skippedNotStartedCallRecordingIds: string[]; +}; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/flows/converge-diverged-call-recordings.util.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/flows/converge-diverged-call-recordings.util.ts new file mode 100644 index 0000000000..0e27173f4e --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/flows/converge-diverged-call-recordings.util.ts @@ -0,0 +1,449 @@ +import { isNonEmptyArray, isUndefined } from '@sniptt/guards'; +import { type CoreApiClient } from 'twenty-client-sdk/core'; + +import { CallRecordingRequestStatus } from 'src/logic-functions/constants/call-recording-request-status'; +import { CallRecordingStatus } from 'src/logic-functions/constants/call-recording-status'; +import { NON_TERMINAL_CALL_RECORDING_STATUSES } from 'src/logic-functions/constants/non-terminal-call-recording-statuses'; +import { TWENTY_PAGE_SIZE } from 'src/logic-functions/constants/twenty-page-size'; +import { type FilesFieldValue } from 'src/logic-functions/types/files-field-value.type'; +import { + extractRecallBotConvergence, + type RecallBotConvergence, +} from 'src/logic-functions/recall-api/extract-recall-bot-convergence.util'; +import { + fetchAllNodes, + type ConnectionPage, +} from 'src/logic-functions/data/fetch-all-nodes.util'; +import { getRecallBot } from 'src/logic-functions/recall-api/get-recall-bot.util'; +import { ingestCallRecordingMedia } from 'src/logic-functions/flows/ingest-call-recording-media.util'; +import { isCallRecordingStatusDowngrade } from 'src/logic-functions/domain/is-call-recording-status-downgrade.util'; +import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util'; +import { parseTranscriptMarker } from 'src/logic-functions/domain/parse-transcript-marker.util'; +import { persistCallRecordingProgress } from 'src/logic-functions/flows/persist-call-recording-progress.util'; +import { reconcileCallRecordingTranscriptArtifact } from 'src/logic-functions/flows/reconcile-call-recording-transcript-artifact.util'; +import { type ConvergeDivergedCallRecordingsResult } from 'src/logic-functions/flows/converge-diverged-call-recordings-result.type'; +import { shouldCompleteCallRecordingIngestion } from 'src/logic-functions/domain/should-complete-call-recording-ingestion.util'; +import { + updateCallRecording, + type CallRecordingUpdateFields, +} from 'src/logic-functions/data/update-call-recording.util'; + +const CONVERGENCE_LOOKBACK_DAYS = 7; + +type DivergedCallRecordingCandidate = { + id: string; + status: string | undefined; + startedAt: string | undefined; + endedAt: string | undefined; + externalBotId: string | undefined; + externalRecordingId: string | undefined; + transcript: unknown; + audio: FilesFieldValue | undefined; + video: FilesFieldValue | undefined; + createdAt: string | undefined; + calendarEventStartsAt: string | undefined; + calendarEventEndsAt: string | undefined; +}; + +type DivergedCallRecordingNode = { + id: string; + status?: string | null; + startedAt?: string | null; + endedAt?: string | null; + externalBotId?: string | null; + externalRecordingId?: string | null; + transcript?: unknown; + audio?: FilesFieldValue | null; + video?: FilesFieldValue | null; + createdAt?: string | null; + calendarEvent?: { startsAt?: string | null; endsAt?: string | null } | null; +}; + +// Webhook deliveries get lost; this pull pass re-derives state from Recall. +export const convergeDivergedCallRecordings = async ({ + client, + now, +}: { + client: CoreApiClient; + now: Date; +}): Promise => { + const candidates = await fetchDivergedCallRecordingCandidates(client); + const convergenceLowerBound = new Date( + now.getTime() - CONVERGENCE_LOOKBACK_DAYS * 24 * 60 * 60 * 1000, + ); + const result: ConvergeDivergedCallRecordingsResult = { + candidateCount: candidates.length, + updatedCallRecordingIds: [], + markedFailedCallRecordingIds: [], + requestedTranscriptCallRecordingIds: [], + unconvergeableCallRecordingIds: [], + skippedNotStartedCallRecordingIds: [], + }; + + for (const candidate of candidates) { + if (isOutsideConvergenceBound(candidate, convergenceLowerBound)) { + console.warn( + `[twenty-meeting-bot] call recording ${candidate.id} diverged but its meeting ended more than ${CONVERGENCE_LOOKBACK_DAYS} days ago; it will not converge automatically`, + ); + result.unconvergeableCallRecordingIds.push(candidate.id); + continue; + } + + if (isUndefined(candidate.externalBotId)) { + console.warn( + `[twenty-meeting-bot] call recording ${candidate.id} diverged but has no Recall bot id; it will not converge automatically`, + ); + result.unconvergeableCallRecordingIds.push(candidate.id); + continue; + } + + if (isBeforeMeetingStart(candidate, now)) { + result.skippedNotStartedCallRecordingIds.push(candidate.id); + continue; + } + + await convergeCallRecording({ + client, + candidate, + externalBotId: candidate.externalBotId, + now, + result, + }); + } + + return result; +}; + +const fetchDivergedCallRecordingCandidates = async ( + client: CoreApiClient, +): Promise => { + // No createdAt bound: older-than-lookback candidates must surface in logs. + const filter: Record = { + or: [ + { + recordingRequestStatus: { eq: CallRecordingRequestStatus.REQUESTED }, + status: { in: NON_TERMINAL_CALL_RECORDING_STATUSES }, + externalBotId: { is: 'NOT_NULL' }, + }, + { + status: { eq: CallRecordingStatus.COMPLETED }, + or: [{ startedAt: { is: 'NULL' } }, { endedAt: { is: 'NULL' } }], + }, + ], + }; + const candidateNodes = await fetchAllNodes( + async (afterCursor) => { + const queryResult = await client.query({ + callRecordings: { + __args: { + filter, + first: TWENTY_PAGE_SIZE, + ...(isUndefined(afterCursor) ? {} : { after: afterCursor }), + }, + pageInfo: { + hasNextPage: true, + endCursor: true, + }, + edges: { + node: { + id: true, + status: true, + startedAt: true, + endedAt: true, + externalBotId: true, + externalRecordingId: true, + transcript: true, + audio: { fileId: true }, + video: { fileId: true }, + createdAt: true, + calendarEvent: { + startsAt: true, + endsAt: true, + }, + }, + }, + }, + }); + + return (queryResult.callRecordings ?? undefined) as + | ConnectionPage + | undefined; + }, + ); + + return candidateNodes.map((node) => ({ + id: node.id, + status: node.status ?? undefined, + startedAt: node.startedAt ?? undefined, + endedAt: node.endedAt ?? undefined, + externalBotId: isNonEmptyString(node.externalBotId) + ? node.externalBotId + : undefined, + externalRecordingId: isNonEmptyString(node.externalRecordingId) + ? node.externalRecordingId + : undefined, + transcript: node.transcript ?? undefined, + audio: node.audio ?? undefined, + video: node.video ?? undefined, + createdAt: node.createdAt ?? undefined, + calendarEventStartsAt: node.calendarEvent?.startsAt ?? undefined, + calendarEventEndsAt: node.calendarEvent?.endsAt ?? undefined, + })); +}; + +// Anchored to meeting end: createdAt is scheduling time and can predate the meeting by weeks. +const isOutsideConvergenceBound = ( + candidate: DivergedCallRecordingCandidate, + convergenceLowerBound: Date, +): boolean => { + const meetingEndReference = + candidate.calendarEventEndsAt ?? candidate.createdAt; + + return ( + !isUndefined(meetingEndReference) && + new Date(meetingEndReference).getTime() < convergenceLowerBound.getTime() + ); +}; + +// Until the meeting starts the bot has recorded nothing, so there is nothing to pull yet. +const isBeforeMeetingStart = ( + candidate: DivergedCallRecordingCandidate, + now: Date, +): boolean => + !isUndefined(candidate.calendarEventStartsAt) && + new Date(candidate.calendarEventStartsAt).getTime() > now.getTime(); + +const convergeCallRecording = async ({ + client, + candidate, + externalBotId, + now, + result, +}: { + client: CoreApiClient; + candidate: DivergedCallRecordingCandidate; + externalBotId: string; + now: Date; + result: ConvergeDivergedCallRecordingsResult; +}): Promise => { + const botResult = await getRecallBot({ externalBotId }); + + if (!botResult.ok) { + if (botResult.status === 404) { + await markCallRecordingFailedAfterBotLoss({ + client, + candidate, + externalBotId, + result, + }); + + return; + } + + console.warn( + `[twenty-meeting-bot] failed to fetch Recall bot ${externalBotId} for call recording ${candidate.id}: ${botResult.errorMessage}`, + ); + + return; + } + + const convergence = extractRecallBotConvergence(botResult.bot); + const updateData = buildConvergenceFieldUpdates({ candidate, convergence }); + + const externalRecordingId = + candidate.externalRecordingId ?? convergence.externalRecordingId; + + if (convergence.isRecallRecordingDone && !isUndefined(externalRecordingId)) { + const transcriptArtifactResult = + await reconcileCallRecordingTranscriptArtifact({ + callRecordingId: candidate.id, + currentStatus: candidate.status, + externalRecordingId, + requestedAt: now.toISOString(), + transcript: candidate.transcript, + }); + + Object.assign(updateData, transcriptArtifactResult.updateData); + + if (transcriptArtifactResult.requestedTranscript) { + result.requestedTranscriptCallRecordingIds.push(candidate.id); + } + + Object.assign( + updateData, + await ingestCallRecordingMedia({ + callRecordingId: candidate.id, + externalRecordingId, + hasAudio: isNonEmptyArray(candidate.audio), + hasVideo: isNonEmptyArray(candidate.video), + }), + ); + } + + const terminalArtifactGateFailureUpdate = + buildTerminalArtifactGateFailureUpdate({ + candidate, + convergence, + externalRecordingId, + updateData, + }); + + if (!isUndefined(terminalArtifactGateFailureUpdate)) { + Object.assign(updateData, terminalArtifactGateFailureUpdate); + } + + const completesIngestion = shouldCompleteCallRecordingIngestion({ + current: candidate, + updateData, + }); + + if (Object.keys(updateData).length === 0 && !completesIngestion) { + return; + } + + await persistCallRecordingProgress(client, { + id: candidate.id, + current: candidate, + updateData, + }); + + result.updatedCallRecordingIds.push(candidate.id); +}; + +// Pure merge: fill only unset candidate fields and never downgrade status. +const buildConvergenceFieldUpdates = ({ + candidate, + convergence, +}: { + candidate: DivergedCallRecordingCandidate; + convergence: RecallBotConvergence; +}): CallRecordingUpdateFields => { + const updateData: CallRecordingUpdateFields = {}; + + if ( + !isUndefined(convergence.status) && + convergence.status !== candidate.status && + !isCallRecordingStatusDowngrade({ + fromStatus: candidate.status, + toStatus: convergence.status, + }) + ) { + updateData.status = convergence.status; + + if (convergence.status === CallRecordingStatus.FAILED) { + updateData.meetingBotFailureReason = + convergence.failureReason ?? 'recall_bot_failed'; + } + } + + if (isUndefined(candidate.startedAt) && !isUndefined(convergence.startedAt)) { + updateData.startedAt = convergence.startedAt; + } + + if (isUndefined(candidate.endedAt) && !isUndefined(convergence.endedAt)) { + updateData.endedAt = convergence.endedAt; + } + + if ( + isUndefined(candidate.externalRecordingId) && + !isUndefined(convergence.externalRecordingId) + ) { + updateData.externalRecordingId = convergence.externalRecordingId; + } + + return updateData; +}; + +type TerminalArtifactGateFailureUpdate = { + status: CallRecordingStatus.FAILED; + meetingBotFailureReason: string; +}; + +const buildTerminalArtifactGateFailureUpdate = ({ + candidate, + convergence, + externalRecordingId, + updateData, +}: { + candidate: DivergedCallRecordingCandidate; + convergence: RecallBotConvergence; + externalRecordingId: string | undefined; + updateData: CallRecordingUpdateFields; +}): TerminalArtifactGateFailureUpdate | undefined => { + if ( + candidate.status === CallRecordingStatus.COMPLETED || + updateData.status === CallRecordingStatus.FAILED || + !convergence.isRecallRecordingDone || + !isUndefined(externalRecordingId) || + hasRecordingArtifactPath({ candidate, updateData }) + ) { + return undefined; + } + + return { + status: CallRecordingStatus.FAILED, + meetingBotFailureReason: + convergence.failureReason ?? 'recording_artifacts_unavailable', + }; +}; + +const hasRecordingArtifactPath = ({ + candidate, + updateData, +}: { + candidate: DivergedCallRecordingCandidate; + updateData: CallRecordingUpdateFields; +}): boolean => { + return ( + isNonEmptyArray(updateData.audio ?? candidate.audio) || + isNonEmptyArray(updateData.video ?? candidate.video) || + hasReachableTranscript(updateData.transcript ?? candidate.transcript) + ); +}; + +const hasReachableTranscript = (transcript: unknown): boolean => { + if (isUndefined(transcript)) { + return false; + } + + const marker = parseTranscriptMarker(transcript); + + return isUndefined(marker) || marker.status === 'PENDING'; +}; + +const markCallRecordingFailedAfterBotLoss = async ({ + client, + candidate, + externalBotId, + result, +}: { + client: CoreApiClient; + candidate: DivergedCallRecordingCandidate; + externalBotId: string; + result: ConvergeDivergedCallRecordingsResult; +}): Promise => { + // externalBotId is kept for audit even though the bot is gone at Recall. + console.warn( + `[twenty-meeting-bot] Recall bot ${externalBotId} for call recording ${candidate.id} no longer exists; it will not converge automatically`, + ); + + if ( + isCallRecordingStatusDowngrade({ + fromStatus: candidate.status, + toStatus: CallRecordingStatus.FAILED, + }) + ) { + result.unconvergeableCallRecordingIds.push(candidate.id); + + return; + } + + await updateCallRecording(client, { + id: candidate.id, + data: { + status: CallRecordingStatus.FAILED, + meetingBotFailureReason: 'recall_bot_not_found', + }, + }); + result.markedFailedCallRecordingIds.push(candidate.id); +}; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/flows/download-transcript.util.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/flows/download-transcript.util.ts new file mode 100644 index 0000000000..f4a69ea813 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/flows/download-transcript.util.ts @@ -0,0 +1,67 @@ +import { isUndefined } from '@sniptt/guards'; + +import { retrieveRecallTranscript } from 'src/logic-functions/recall-api/retrieve-recall-transcript.util'; + +const TRANSCRIPT_DOWNLOAD_TIMEOUT_MS = 20_000; + +export type DownloadTranscriptResult = + | { outcome: 'filled'; content: unknown } + | { outcome: 'failed'; subCode: string | null } + | { outcome: 'pending' } + | { outcome: 'error'; errorMessage: string }; + +export const downloadTranscript = async ({ + transcriptId, +}: { + transcriptId: string; +}): Promise => { + const retrieveResult = await retrieveRecallTranscript({ transcriptId }); + + if (!retrieveResult.ok) { + return { outcome: 'error', errorMessage: retrieveResult.errorMessage }; + } + + const { downloadUrl, statusCode, statusSubCode } = retrieveResult.transcript; + + if (!isUndefined(downloadUrl)) { + return downloadTranscriptContent(downloadUrl); + } + + if (statusCode === 'error' || statusCode === 'failed') { + return { outcome: 'failed', subCode: statusSubCode ?? null }; + } + + return { outcome: 'pending' }; +}; + +const downloadTranscriptContent = async ( + downloadUrl: string, +): Promise => { + try { + const response = await fetch(downloadUrl, { + signal: AbortSignal.timeout(TRANSCRIPT_DOWNLOAD_TIMEOUT_MS), + }); + + if (!response.ok) { + console.warn( + `[twenty-meeting-bot] transcript download responded with HTTP ${response.status}`, + ); + + return { + outcome: 'error', + errorMessage: 'transcript download failed', + }; + } + + return { outcome: 'filled', content: await response.json() }; + } catch (error) { + console.warn( + `[twenty-meeting-bot] transcript download failed: ${error instanceof Error ? error.message : String(error)}`, + ); + + return { + outcome: 'error', + errorMessage: 'transcript download failed', + }; + } +}; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/flows/ensure-meeting-bot.util.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/flows/ensure-meeting-bot.util.ts new file mode 100644 index 0000000000..165e9a88ae --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/flows/ensure-meeting-bot.util.ts @@ -0,0 +1,74 @@ +import { isUndefined } from '@sniptt/guards'; +import { type CoreApiClient } from 'twenty-client-sdk/core'; + +import { CallRecordingRequestStatus } from 'src/logic-functions/constants/call-recording-request-status'; +import { type MeetingRecording } from 'src/logic-functions/types/meeting-recording.type'; +import { buildRecallBotMetadata } from 'src/logic-functions/domain/build-recall-bot-metadata.util'; +import { computeRecallBotJoinAt } from 'src/logic-functions/domain/compute-recall-bot-join-at.util'; +import { findCallRecordingsByIds } from 'src/logic-functions/data/find-call-recordings-by-ids.util'; +import { getCurrentWorkspaceId } from 'src/logic-functions/data/get-current-workspace-id.util'; +import { scheduleRecallBot } from 'src/logic-functions/recall-api/schedule-recall-bot.util'; +import { updateCallRecording } from 'src/logic-functions/data/update-call-recording.util'; + +// The sole place a Recall bot is created. Only the deterministic-create winner and the stale-state cron call it, so one writer per meeting POSTs exactly one bot. +export const ensureMeetingBot = async ( + client: CoreApiClient, + { callRecording, calendarEvent }: MeetingRecording, +): Promise => { + const meetingUrl = calendarEvent.conferenceLinkUrl; + const meetingStartsAt = calendarEvent.startsAt; + + if (isUndefined(meetingUrl) || isUndefined(meetingStartsAt)) { + return false; + } + + const joinAt = computeRecallBotJoinAt(meetingStartsAt); + + const freshCallRecording = ( + await findCallRecordingsByIds(client, [callRecording.id]) + )[0]; + + if ( + isUndefined(freshCallRecording) || + freshCallRecording.recordingRequestStatus !== + CallRecordingRequestStatus.REQUESTED || + !isUndefined(freshCallRecording.externalBotId) + ) { + return false; + } + + const workspaceId = getCurrentWorkspaceId(); + + if (isUndefined(workspaceId)) { + console.error( + `[twenty-meeting-bot] cannot schedule Recall bot for callRecording ${callRecording.id}: workspace id unavailable, the shared webhook could not be routed back`, + ); + + return false; + } + + const scheduleResult = await scheduleRecallBot({ + meetingUrl, + joinAt, + metadata: buildRecallBotMetadata({ + callRecording, + calendarEvent, + workspaceId, + }), + }); + + if (!scheduleResult.ok) { + console.warn( + `[twenty-meeting-bot] failed to schedule Recall bot for callRecording ${callRecording.id}: ${scheduleResult.errorMessage}`, + ); + + return false; + } + + await updateCallRecording(client, { + id: callRecording.id, + data: { externalBotId: scheduleResult.externalBotId }, + }); + + return true; +}; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/flows/handle-recall-webhook.util.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/flows/handle-recall-webhook.util.ts new file mode 100644 index 0000000000..3ef4ed12af --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/flows/handle-recall-webhook.util.ts @@ -0,0 +1,673 @@ +import { isNonEmptyArray, isNull, isUndefined } from '@sniptt/guards'; +import { type CoreApiClient } from 'twenty-client-sdk/core'; + +import { CallRecordingStatus } from 'src/logic-functions/constants/call-recording-status'; +import { type FilesFieldValue } from 'src/logic-functions/types/files-field-value.type'; +import { buildFailedTranscriptMarker } from 'src/logic-functions/domain/build-failed-transcript-marker.util'; +import { buildTranscriptFailureReason } from 'src/logic-functions/domain/build-transcript-failure-reason.util'; +import { downloadTranscript } from 'src/logic-functions/flows/download-transcript.util'; +import { extractRecallBotConvergence } from 'src/logic-functions/recall-api/extract-recall-bot-convergence.util'; +import { getRecallBot } from 'src/logic-functions/recall-api/get-recall-bot.util'; +import { getString } from 'src/logic-functions/utils/get-string.util'; +import { ingestCallRecordingMedia } from 'src/logic-functions/flows/ingest-call-recording-media.util'; +import { isCallRecordingStatusDowngrade } from 'src/logic-functions/domain/is-call-recording-status-downgrade.util'; +import { isRecallRecordingDoneSignal } from 'src/logic-functions/domain/is-recall-recording-done-signal.util'; +import { mapRecallStatusCodeToCallRecordingStatus } from 'src/logic-functions/domain/map-recall-status-code-to-call-recording-status.util'; +import { + parseRecallWebhookEvent, + type RecallWebhookBody, + type RecallWebhookEvent, +} from 'src/logic-functions/recall-api/parse-recall-webhook-event.util'; +import { parseTranscriptMarker } from 'src/logic-functions/domain/parse-transcript-marker.util'; +import { persistCallRecordingProgress } from 'src/logic-functions/flows/persist-call-recording-progress.util'; +import { reconcileCallRecordingTranscriptArtifact } from 'src/logic-functions/flows/reconcile-call-recording-transcript-artifact.util'; +import { + updateCallRecording, + type CallRecordingUpdateFields, +} from 'src/logic-functions/data/update-call-recording.util'; + +type MatchedCallRecording = { + id: string; + status?: string; + startedAt?: string; + endedAt?: string; + externalRecordingId?: string; + transcript?: unknown; + audio?: FilesFieldValue; + video?: FilesFieldValue; +}; + +type ExternalRecordingIdResolution = { + externalRecordingId: string | undefined; + providerLookupFailed: boolean; +}; + +type RecallWebhookHandlerResult = + | { + status: 'updated'; + callRecordingId: string; + event: string; + callRecordingStatus: string; + } + | { + status: 'updated'; + callRecordingId: string; + event: string; + transcriptOutcome: 'FILLED' | 'FAILED'; + } + | { + status: 'skipped'; + event: string | null; + reason: string; + }; + +export const handleRecallWebhook = async ({ + client, + body, +}: { + client: CoreApiClient; + body: RecallWebhookBody; +}): Promise => { + const webhookEvent = parseRecallWebhookEvent(body); + + if (isUndefined(webhookEvent)) { + return { + status: 'skipped', + event: null, + reason: 'missing event type', + }; + } + + const { event } = webhookEvent; + + if (event === 'transcript.done' || event === 'transcript.failed') { + return handleRecallTranscriptEvent({ client, webhookEvent, event }); + } + + return handleRecallStatusEvent({ client, webhookEvent }); +}; + +const handleRecallStatusEvent = async ({ + client, + webhookEvent, +}: { + client: CoreApiClient; + webhookEvent: RecallWebhookEvent; +}): Promise => { + const { event, statusCode } = webhookEvent; + const callRecordingStatus = mapRecallEventToCallRecordingStatus({ + event, + statusCode, + }); + + if (isUndefined(callRecordingStatus)) { + return { + status: 'skipped', + event, + reason: `unsupported Recall event status ${statusCode ?? event}`, + }; + } + + const callRecording = await findMatchingCallRecording({ + client, + webhookEvent, + }); + + if (isUndefined(callRecording)) { + return { + status: 'skipped', + event, + reason: 'no matching call recording', + }; + } + + if ( + isCallRecordingStatusDowngrade({ + fromStatus: callRecording.status, + toStatus: callRecordingStatus, + }) + ) { + return { + status: 'skipped', + event, + reason: `stale status event (${callRecording.status} -> ${callRecordingStatus})`, + }; + } + + const updateData: CallRecordingUpdateFields = { + ...(isUndefined(webhookEvent.externalBotId) + ? {} + : { externalBotId: webhookEvent.externalBotId }), + ...buildExternalRecordingIdUpdate(webhookEvent), + ...buildCallRecordingStatusUpdate({ + reason: getRecallWebhookFailureReason(webhookEvent), + status: callRecordingStatus, + }), + ...buildRecordingTimestampsUpdate({ webhookEvent, callRecording }), + }; + + if (isRecallRecordingDoneSignal({ event, statusCode })) { + const externalRecordingIdResolution = await resolveExternalRecordingId({ + callRecording, + webhookEvent, + }); + + Object.assign( + updateData, + await buildTranscriptArtifactUpdate({ + callRecording, + externalRecordingId: externalRecordingIdResolution.externalRecordingId, + }), + ); + + Object.assign( + updateData, + await buildMediaIngestionUpdate({ + callRecording, + externalRecordingId: externalRecordingIdResolution.externalRecordingId, + }), + ); + + const terminalArtifactGateFailureUpdate = + buildTerminalArtifactGateFailureUpdate({ + callRecording, + providerLookupFailed: + externalRecordingIdResolution.providerLookupFailed, + updateData, + webhookEvent, + }); + + if (!isUndefined(terminalArtifactGateFailureUpdate)) { + Object.assign(updateData, terminalArtifactGateFailureUpdate); + } + } + + const { completesIngestion } = await persistCallRecordingProgress(client, { + id: callRecording.id, + current: callRecording, + updateData, + }); + + return { + status: 'updated', + event, + callRecordingId: callRecording.id, + callRecordingStatus: completesIngestion + ? CallRecordingStatus.COMPLETED + : (updateData.status ?? callRecordingStatus), + }; +}; + +const findMatchingCallRecording = async ({ + client, + webhookEvent, +}: { + client: CoreApiClient; + webhookEvent: RecallWebhookEvent; +}): Promise => { + if (!isUndefined(webhookEvent.callRecordingIdFromMetadata)) { + return findCallRecordingByFilter(client, { + id: { eq: webhookEvent.callRecordingIdFromMetadata }, + }); + } + + if (isUndefined(webhookEvent.externalBotId)) { + return undefined; + } + + return findCallRecordingByFilter(client, { + externalBotId: { eq: webhookEvent.externalBotId }, + }); +}; + +const findCallRecordingByFilter = async ( + client: CoreApiClient, + filter: Record, +): Promise => { + const queryResult = await client.query({ + callRecordings: { + __args: { + filter, + first: 1, + }, + edges: { + node: { + id: true, + status: true, + startedAt: true, + endedAt: true, + externalRecordingId: true, + transcript: true, + audio: { fileId: true }, + video: { fileId: true }, + }, + }, + }, + }); + + const node = queryResult.callRecordings?.edges?.[0]?.node; + + if (isUndefined(node) || isNull(node)) { + return undefined; + } + + return { + id: node.id, + status: getString(node.status), + startedAt: getString(node.startedAt), + endedAt: getString(node.endedAt), + externalRecordingId: getString(node.externalRecordingId), + transcript: node.transcript ?? undefined, + audio: node.audio ?? undefined, + video: node.video ?? undefined, + }; +}; + +const mapRecallEventToCallRecordingStatus = ({ + event, + statusCode, +}: { + event: string; + statusCode: string | undefined; +}): CallRecordingStatus | undefined => { + if (event === 'recording.done') { + return CallRecordingStatus.PROCESSING; + } + + if (event === 'recording.failed') { + return CallRecordingStatus.FAILED; + } + + return mapRecallStatusCodeToCallRecordingStatus(statusCode); +}; + +const buildRecordingTimestampsUpdate = ({ + webhookEvent, + callRecording, +}: { + webhookEvent: RecallWebhookEvent; + callRecording: MatchedCallRecording; +}): { startedAt?: string; endedAt?: string } => { + const { event, statusCode, statusTimestamp } = webhookEvent; + + const impliesRecordingStarted = statusCode === 'in_call_recording'; + const impliesRecordingEnded = + event === 'recording.done' || + statusCode === 'call_ended' || + statusCode === 'done'; + + const startedAt = + webhookEvent.recordingStartedAt ?? + (impliesRecordingStarted ? statusTimestamp : undefined); + const endedAt = + webhookEvent.recordingEndedAt ?? + (impliesRecordingEnded ? statusTimestamp : undefined); + + return { + ...(!isUndefined(startedAt) && isUndefined(callRecording.startedAt) + ? { startedAt } + : {}), + ...(!isUndefined(endedAt) && isUndefined(callRecording.endedAt) + ? { endedAt } + : {}), + }; +}; + +const buildExternalRecordingIdUpdate = ( + webhookEvent: RecallWebhookEvent, +): { externalRecordingId?: string } => + isUndefined(webhookEvent.externalRecordingId) + ? {} + : { externalRecordingId: webhookEvent.externalRecordingId }; + +type NonFailedCallRecordingStatus = Exclude< + CallRecordingStatus, + CallRecordingStatus.FAILED +>; + +type CallRecordingStatusUpdate = + | { + status: NonFailedCallRecordingStatus; + } + | { + status: CallRecordingStatus.FAILED; + meetingBotFailureReason: string; + }; + +type TerminalArtifactGateFailureUpdate = { + status: CallRecordingStatus.FAILED; + meetingBotFailureReason: string; +}; + +const buildCallRecordingStatusUpdate = ({ + reason, + status, +}: { + reason: string; + status: CallRecordingStatus; +}): CallRecordingStatusUpdate => { + if (status === CallRecordingStatus.FAILED) { + return { status, meetingBotFailureReason: reason }; + } + + return { status }; +}; + +const buildTerminalArtifactGateFailureUpdate = ({ + callRecording, + providerLookupFailed, + updateData, + webhookEvent, +}: { + callRecording: MatchedCallRecording; + providerLookupFailed: boolean; + updateData: CallRecordingUpdateFields; + webhookEvent: RecallWebhookEvent; +}): TerminalArtifactGateFailureUpdate | undefined => { + if (updateData.status === CallRecordingStatus.FAILED) { + return isUndefined(updateData.meetingBotFailureReason) + ? { + status: CallRecordingStatus.FAILED, + meetingBotFailureReason: getRecallWebhookFailureReason(webhookEvent), + } + : undefined; + } + + if ( + providerLookupFailed || + hasRecordingArtifactPath({ callRecording, updateData }) + ) { + return undefined; + } + + return { + status: CallRecordingStatus.FAILED, + meetingBotFailureReason: 'recording_artifacts_unavailable', + }; +}; + +const getRecallWebhookFailureReason = ({ + event, + statusCode, +}: RecallWebhookEvent): string => statusCode ?? event; + +const hasRecordingArtifactPath = ({ + callRecording, + updateData, +}: { + callRecording: MatchedCallRecording; + updateData: CallRecordingUpdateFields; +}): boolean => { + return ( + !isUndefined( + updateData.externalRecordingId ?? callRecording.externalRecordingId, + ) || + isNonEmptyArray(updateData.audio ?? callRecording.audio) || + isNonEmptyArray(updateData.video ?? callRecording.video) || + hasReachableTranscript(updateData.transcript ?? callRecording.transcript) + ); +}; + +const hasReachableTranscript = (transcript: unknown): boolean => { + if (isNull(transcript) || isUndefined(transcript)) { + return false; + } + + const marker = parseTranscriptMarker(transcript); + + return isUndefined(marker) || marker.status === 'PENDING'; +}; + +const isTranscriptUnset = (callRecording: MatchedCallRecording): boolean => + isUndefined(callRecording.transcript); + +const buildMediaIngestionUpdate = async ({ + callRecording, + externalRecordingId, +}: { + callRecording: MatchedCallRecording; + externalRecordingId: string | undefined; +}): Promise> => { + const hasAudio = isNonEmptyArray(callRecording.audio); + const hasVideo = isNonEmptyArray(callRecording.video); + + if (hasAudio && hasVideo) { + return {}; + } + + if (isUndefined(externalRecordingId)) { + console.warn( + `[twenty-meeting-bot] cannot ingest media for call recording ${callRecording.id}: no Recall recording id available`, + ); + + return {}; + } + + return ingestCallRecordingMedia({ + callRecordingId: callRecording.id, + externalRecordingId, + hasAudio, + hasVideo, + }); +}; + +const buildTranscriptArtifactUpdate = async ({ + callRecording, + externalRecordingId, +}: { + callRecording: MatchedCallRecording; + externalRecordingId: string | undefined; +}): Promise => { + if (isUndefined(externalRecordingId)) { + console.warn( + `[twenty-meeting-bot] cannot reconcile transcript for call recording ${callRecording.id}: no Recall recording id available`, + ); + + return {}; + } + + const transcriptArtifactResult = + await reconcileCallRecordingTranscriptArtifact({ + callRecordingId: callRecording.id, + currentStatus: callRecording.status, + externalRecordingId, + requestedAt: new Date().toISOString(), + transcript: callRecording.transcript, + }); + + return { + ...(isUndefined(callRecording.externalRecordingId) + ? { externalRecordingId } + : {}), + ...transcriptArtifactResult.updateData, + }; +}; + +const resolveExternalRecordingId = async ({ + callRecording, + webhookEvent, +}: { + callRecording: MatchedCallRecording; + webhookEvent: RecallWebhookEvent; +}): Promise => { + const externalRecordingId = + webhookEvent.externalRecordingId ?? callRecording.externalRecordingId; + + if (!isUndefined(externalRecordingId)) { + return { externalRecordingId, providerLookupFailed: false }; + } + + if (isUndefined(webhookEvent.externalBotId)) { + return { externalRecordingId: undefined, providerLookupFailed: false }; + } + + return fetchExternalRecordingIdFromRecallBot(webhookEvent.externalBotId); +}; + +const fetchExternalRecordingIdFromRecallBot = async ( + externalBotId: string, +): Promise => { + const botResult = await getRecallBot({ externalBotId }); + + if (!botResult.ok) { + console.warn( + `[twenty-meeting-bot] failed to fetch Recall bot ${externalBotId} while resolving a recording id: ${botResult.errorMessage}`, + ); + + return { externalRecordingId: undefined, providerLookupFailed: true }; + } + + return { + externalRecordingId: extractRecallBotConvergence(botResult.bot) + .externalRecordingId, + providerLookupFailed: false, + }; +}; + +const handleRecallTranscriptEvent = async ({ + client, + webhookEvent, + event, +}: { + client: CoreApiClient; + webhookEvent: RecallWebhookEvent; + event: 'transcript.done' | 'transcript.failed'; +}): Promise => { + const callRecording = await findMatchingCallRecording({ + client, + webhookEvent, + }); + + if (isUndefined(callRecording)) { + return { + status: 'skipped', + event, + reason: 'no matching call recording', + }; + } + + const { transcriptId } = webhookEvent; + + if (event === 'transcript.failed') { + return applyTranscriptFailure({ + client, + callRecording, + event, + transcriptId, + subCode: webhookEvent.transcriptFailureSubCode ?? null, + }); + } + + if (isUndefined(transcriptId)) { + return { + status: 'skipped', + event, + reason: 'missing transcript id', + }; + } + + const downloadResult = await downloadTranscript({ transcriptId }); + + switch (downloadResult.outcome) { + case 'filled': { + const updateData: CallRecordingUpdateFields = { + transcript: downloadResult.content as Record, + ...(isUndefined(callRecording.externalRecordingId) + ? buildExternalRecordingIdUpdate(webhookEvent) + : {}), + }; + + await persistCallRecordingProgress(client, { + id: callRecording.id, + current: callRecording, + updateData, + }); + + return { + status: 'updated', + event, + callRecordingId: callRecording.id, + transcriptOutcome: 'FILLED', + }; + } + case 'failed': + return applyTranscriptFailure({ + client, + callRecording, + event, + transcriptId, + subCode: downloadResult.subCode, + }); + case 'pending': + case 'error': { + // 200-acked either way, Svix never redelivers; the cron re-check retries this. + const reason = + downloadResult.outcome === 'pending' + ? 'transcript not downloadable yet' + : downloadResult.errorMessage; + + console.warn( + `[twenty-meeting-bot] could not fill transcript for call recording ${callRecording.id}: ${reason}`, + ); + + return { + status: 'skipped', + event, + reason, + }; + } + } +}; + +const applyTranscriptFailure = async ({ + client, + callRecording, + event, + transcriptId, + subCode, +}: { + client: CoreApiClient; + callRecording: MatchedCallRecording; + event: string; + transcriptId: string | undefined; + subCode: string | null; +}): Promise => { + const existingMarker = parseTranscriptMarker(callRecording.transcript); + + if (!isTranscriptUnset(callRecording) && isUndefined(existingMarker)) { + return { + status: 'skipped', + event, + reason: 'transcript already filled', + }; + } + + console.warn( + `[twenty-meeting-bot] transcript failed for call recording ${callRecording.id}${isNull(subCode) ? '' : ` (${subCode})`}`, + ); + + await updateCallRecording(client, { + id: callRecording.id, + data: { + transcript: buildFailedTranscriptMarker({ + recallTranscriptId: + transcriptId ?? existingMarker?.recallTranscriptId ?? null, + subCode, + }), + meetingBotFailureReason: buildTranscriptFailureReason(subCode), + ...(isCallRecordingStatusDowngrade({ + fromStatus: callRecording.status, + toStatus: CallRecordingStatus.FAILED, + }) + ? {} + : { status: CallRecordingStatus.FAILED }), + }, + }); + + return { + status: 'updated', + event, + callRecordingId: callRecording.id, + transcriptOutcome: 'FAILED', + }; +}; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/flows/heal-call-recordings-missing-bot.util.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/flows/heal-call-recordings-missing-bot.util.ts new file mode 100644 index 0000000000..a445a00a2a --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/flows/heal-call-recordings-missing-bot.util.ts @@ -0,0 +1,82 @@ +import { isUndefined } from '@sniptt/guards'; +import { type CoreApiClient } from 'twenty-client-sdk/core'; + +import { type CalendarEventRecord } from 'src/logic-functions/types/calendar-event-record.type'; +import { ensureMeetingBot } from 'src/logic-functions/flows/ensure-meeting-bot.util'; +import { fetchCalendarEventsByIds } from 'src/logic-functions/data/fetch-calendar-events-by-ids.util'; +import { findOpenScheduledCallRecordings } from 'src/logic-functions/data/find-open-scheduled-call-recordings.util'; +import { getUniqueSortedIds } from 'src/logic-functions/utils/get-unique-sorted-ids.util'; + +export type HealCallRecordingsMissingBotResult = { + scheduledCallRecordingIds: string[]; +}; + +// Closes the create-winner crash gap: a run that inserted the row but died before POSTing leaves a botless recording, and the cron is the single writer that re-POSTs it. +export const healCallRecordingsMissingBot = async ({ + client, + now, +}: { + client: CoreApiClient; + now: Date; +}): Promise => { + const botlessCallRecordings = ( + await findOpenScheduledCallRecordings(client) + ).filter((callRecording) => isUndefined(callRecording.externalBotId)); + + if (botlessCallRecordings.length === 0) { + return { scheduledCallRecordingIds: [] }; + } + + const calendarEventsById = new Map( + ( + await fetchCalendarEventsByIds( + client, + getUniqueSortedIds( + botlessCallRecordings.map( + (callRecording) => callRecording.calendarEventId, + ), + ), + ) + ).map((calendarEvent) => [calendarEvent.id, calendarEvent]), + ); + const scheduledCallRecordingIds: string[] = []; + + for (const callRecording of botlessCallRecordings) { + const calendarEvent = isUndefined(callRecording.calendarEventId) + ? undefined + : calendarEventsById.get(callRecording.calendarEventId); + + if (isUndefined(calendarEvent) || hasMeetingEnded({ calendarEvent, now })) { + continue; + } + + const didScheduleMeetingBot = await ensureMeetingBot(client, { + callRecording, + calendarEvent, + }); + + if (didScheduleMeetingBot) { + scheduledCallRecordingIds.push(callRecording.id); + } + } + + return { scheduledCallRecordingIds }; +}; + +const hasMeetingEnded = ({ + calendarEvent, + now, +}: { + calendarEvent: CalendarEventRecord; + now: Date; +}): boolean => { + const reference = calendarEvent.endsAt ?? calendarEvent.startsAt; + + if (isUndefined(reference)) { + return false; + } + + const referenceTime = new Date(reference).getTime(); + + return !Number.isNaN(referenceTime) && referenceTime <= now.getTime(); +}; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/flows/ingest-call-recording-media.util.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/flows/ingest-call-recording-media.util.ts new file mode 100644 index 0000000000..79259d9ce2 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/flows/ingest-call-recording-media.util.ts @@ -0,0 +1,127 @@ +import { isUndefined } from '@sniptt/guards'; +import { MetadataApiClient } from 'twenty-client-sdk/metadata'; + +import { CALL_RECORDING_AUDIO_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/call-recording-audio-field-universal-identifier'; +import { CALL_RECORDING_VIDEO_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/call-recording-video-field-universal-identifier'; +import { extractRecallMediaUrls } from 'src/logic-functions/recall-api/extract-recall-media-urls.util'; +import { getRecallRecording } from 'src/logic-functions/recall-api/get-recall-recording.util'; +import { type CallRecordingUpdateFields } from 'src/logic-functions/data/update-call-recording.util'; + +type CallRecordingMediaUpdateFields = Pick< + CallRecordingUpdateFields, + 'audio' | 'video' +>; + +const MEDIA_DOWNLOAD_TIMEOUT_MS = 120_000; + +export const ingestCallRecordingMedia = async ({ + callRecordingId, + externalRecordingId, + hasAudio, + hasVideo, +}: { + callRecordingId: string; + externalRecordingId: string; + hasAudio: boolean; + hasVideo: boolean; +}): Promise => { + if (hasAudio && hasVideo) { + return {}; + } + + const recordingResult = await getRecallRecording({ externalRecordingId }); + + if (!recordingResult.ok) { + console.warn( + `[twenty-meeting-bot] failed to fetch Recall recording ${externalRecordingId} while ingesting media for call recording ${callRecordingId}: ${recordingResult.errorMessage}`, + ); + + return {}; + } + + const mediaUrls = extractRecallMediaUrls(recordingResult.recording); + const metadataClient = new MetadataApiClient(); + const updateFields: CallRecordingMediaUpdateFields = {}; + + if (!hasVideo && !isUndefined(mediaUrls.videoUrl)) { + const video = await ingestMediaArtifact({ + callRecordingId, + metadataClient, + url: mediaUrls.videoUrl, + fileName: 'video.mp4', + fieldMetadataUniversalIdentifier: + CALL_RECORDING_VIDEO_FIELD_UNIVERSAL_IDENTIFIER, + }); + + if (!isUndefined(video)) { + updateFields.video = video; + } + } + + if (!hasAudio && !isUndefined(mediaUrls.audioUrl)) { + const audio = await ingestMediaArtifact({ + callRecordingId, + metadataClient, + url: mediaUrls.audioUrl, + fileName: 'audio.mp3', + fieldMetadataUniversalIdentifier: + CALL_RECORDING_AUDIO_FIELD_UNIVERSAL_IDENTIFIER, + }); + + if (!isUndefined(audio)) { + updateFields.audio = audio; + } + } + + return updateFields; +}; + +const ingestMediaArtifact = async ({ + callRecordingId, + metadataClient, + url, + fileName, + fieldMetadataUniversalIdentifier, +}: { + callRecordingId: string; + metadataClient: InstanceType; + url: string; + fileName: string; + fieldMetadataUniversalIdentifier: string; +}): Promise<{ fileId: string; label: string }[] | undefined> => { + try { + const { buffer, contentType } = await downloadMediaFile(url); + const uploadedFile = await metadataClient.uploadFile( + buffer, + fileName, + contentType, + fieldMetadataUniversalIdentifier, + ); + + return [{ fileId: uploadedFile.id, label: fileName }]; + } catch (error) { + console.warn( + `[twenty-meeting-bot] failed to ingest ${fileName} for call recording ${callRecordingId}: ${error instanceof Error ? error.message : String(error)}`, + ); + + return undefined; + } +}; + +const downloadMediaFile = async ( + url: string, +): Promise<{ buffer: Buffer; contentType: string }> => { + const response = await fetch(url, { + signal: AbortSignal.timeout(MEDIA_DOWNLOAD_TIMEOUT_MS), + }); + + if (!response.ok) { + throw new Error(`download failed with status ${response.status}`); + } + + return { + buffer: Buffer.from(await response.arrayBuffer()), + contentType: + response.headers.get('content-type') ?? 'application/octet-stream', + }; +}; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/flows/persist-call-recording-progress.util.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/flows/persist-call-recording-progress.util.ts new file mode 100644 index 0000000000..d72f9c402c --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/flows/persist-call-recording-progress.util.ts @@ -0,0 +1,60 @@ +import { type CoreApiClient } from 'twenty-client-sdk/core'; + +import { type FilesFieldValue } from 'src/logic-functions/types/files-field-value.type'; +import { completeAndChargeCallRecording } from 'src/logic-functions/flows/complete-and-charge-call-recording.util'; +import { shouldCompleteCallRecordingIngestion } from 'src/logic-functions/domain/should-complete-call-recording-ingestion.util'; +import { + updateCallRecording, + type CallRecordingUpdateFields, +} from 'src/logic-functions/data/update-call-recording.util'; + +type PersistCallRecordingProgressCurrent = { + status?: string; + startedAt?: string; + endedAt?: string; + transcript?: unknown; + audio?: FilesFieldValue; + video?: FilesFieldValue; +}; + +export const persistCallRecordingProgress = async ( + client: CoreApiClient, + { + id, + current, + updateData, + }: { + id: string; + current: PersistCallRecordingProgressCurrent; + updateData: CallRecordingUpdateFields; + }, +): Promise<{ completesIngestion: boolean }> => { + const completesIngestion = shouldCompleteCallRecordingIngestion({ + current, + updateData, + }); + + if (!completesIngestion) { + await updateCallRecording(client, { id, data: updateData }); + + return { completesIngestion: false }; + } + + // Strip status so COMPLETED is written only by the atomic claim — its single winner bills once. + const nonStatusUpdate: CallRecordingUpdateFields = { ...updateData }; + + delete nonStatusUpdate.status; + delete nonStatusUpdate.meetingBotFailureReason; + + if (Object.keys(nonStatusUpdate).length > 0) { + await updateCallRecording(client, { id, data: nonStatusUpdate }); + } + + await completeAndChargeCallRecording(client, { + id, + startedAt: updateData.startedAt ?? current.startedAt, + endedAt: updateData.endedAt ?? current.endedAt, + }); + + return { completesIngestion: true }; +}; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/flows/reap-orphaned-meeting-bots.util.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/flows/reap-orphaned-meeting-bots.util.ts new file mode 100644 index 0000000000..f7c8a35cbb --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/flows/reap-orphaned-meeting-bots.util.ts @@ -0,0 +1,185 @@ +import { isNull, isUndefined } from '@sniptt/guards'; +import { type CoreApiClient } from 'twenty-client-sdk/core'; + +import { CallRecordingRequestStatus } from 'src/logic-functions/constants/call-recording-request-status'; +import { type CallRecordingRecord } from 'src/logic-functions/types/call-recording-record.type'; +import { cancelRecallBot } from 'src/logic-functions/recall-api/cancel-recall-bot.util'; +import { ejectRecallBot } from 'src/logic-functions/recall-api/eject-recall-bot.util'; +import { findCallRecordingsByIds } from 'src/logic-functions/data/find-call-recordings-by-ids.util'; +import { getCurrentWorkspaceId } from 'src/logic-functions/data/get-current-workspace-id.util'; +import { getUniqueSortedIds } from 'src/logic-functions/utils/get-unique-sorted-ids.util'; +import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util'; +import { + listScheduledRecallBots, + type RecallScheduledBot, +} from 'src/logic-functions/recall-api/list-scheduled-recall-bots.util'; + +export type ReapOrphanedMeetingBotsResult = { + scannedBotCount: number; + canceledExternalBotIds: string[]; +}; + +// Bots no open CallRecording request claims would still join; cancel them on Recall. +export const reapOrphanedMeetingBots = async ({ + client, + joinAtAfter, + joinAtBefore, +}: { + client: CoreApiClient; + joinAtAfter: string; + joinAtBefore: string; +}): Promise => { + const listResult = await listScheduledRecallBots({ + joinAtAfter, + joinAtBefore, + }); + + if (!listResult.ok) { + console.warn( + `[twenty-meeting-bot] failed to list Recall bots for orphan reaping: ${listResult.errorMessage}`, + ); + + return { scannedBotCount: 0, canceledExternalBotIds: [] }; + } + + const currentWorkspaceId = getCurrentWorkspaceId(); + + if (isUndefined(currentWorkspaceId)) { + console.warn( + '[twenty-meeting-bot] cannot reap orphaned Recall bots: workspace id unavailable', + ); + + return { + scannedBotCount: listResult.bots.length, + canceledExternalBotIds: [], + }; + } + + const workspaceManagedBots = listResult.bots.filter((bot) => + isCurrentWorkspaceManagedBot({ bot, currentWorkspaceId }), + ); + + if (workspaceManagedBots.length === 0) { + return { + scannedBotCount: listResult.bots.length, + canceledExternalBotIds: [], + }; + } + + const callRecordings = await findCallRecordingsByIds( + client, + getUniqueSortedIds( + workspaceManagedBots.map((bot) => getClaimedCallRecordingId(bot)), + ), + ); + const callRecordingsById = new Map( + callRecordings.map((callRecording) => [callRecording.id, callRecording]), + ); + const canceledExternalBotIds: string[] = []; + + for (const bot of workspaceManagedBots) { + const claimedCallRecordingId = getClaimedCallRecordingId(bot); + const callRecording = isUndefined(claimedCallRecordingId) + ? undefined + : callRecordingsById.get(claimedCallRecordingId); + + if (isBotClaimed({ bot, callRecording })) { + continue; + } + + console.warn( + `[twenty-meeting-bot] canceling orphaned Recall bot ${bot.id} (claimed callRecording: ${claimedCallRecordingId})`, + ); + + if (await cancelOrEjectRecallBot(bot.id)) { + canceledExternalBotIds.push(bot.id); + } + } + + return { + scannedBotCount: listResult.bots.length, + canceledExternalBotIds, + }; +}; + +const getClaimedCallRecordingId = ( + bot: RecallScheduledBot, +): string | undefined => { + const claimedCallRecordingId = bot.metadata.twentyCallRecordingId; + + return normalizeOptionalString(claimedCallRecordingId); +}; + +const getClaimedWorkspaceId = ( + bot: RecallScheduledBot, +): string | undefined => { + const claimedWorkspaceId = bot.metadata.twentyWorkspaceId; + + return normalizeOptionalString(claimedWorkspaceId); +}; + +const isCurrentWorkspaceManagedBot = ({ + bot, + currentWorkspaceId, +}: { + bot: RecallScheduledBot; + currentWorkspaceId: string; +}): boolean => { + if (isUndefined(getClaimedCallRecordingId(bot))) { + return false; + } + + const claimedWorkspaceId = getClaimedWorkspaceId(bot); + + return claimedWorkspaceId === currentWorkspaceId; +}; + +const isBotClaimed = ({ + bot, + callRecording, +}: { + bot: RecallScheduledBot; + callRecording: CallRecordingRecord | undefined; +}): boolean => { + if ( + callRecording?.recordingRequestStatus !== + CallRecordingRequestStatus.REQUESTED + ) { + return false; + } + + if (callRecording.externalBotId === bot.id) { + return true; + } + + // An id-less REQUESTED recording may have a bot-id write-back in flight; spare its bot. + return isUndefined(callRecording.externalBotId); +}; + +const cancelOrEjectRecallBot = async ( + externalBotId: string, +): Promise => { + const cancelResult = await cancelRecallBot({ externalBotId }); + + if (cancelResult.ok) { + return true; + } + + // Deleting only works for not-yet-joined bots; eject the ones already in a call. + if (!isNull(cancelResult.status)) { + const ejectResult = await ejectRecallBot({ externalBotId }); + + if (ejectResult.ok) { + return true; + } + } + + console.warn( + `[twenty-meeting-bot] failed to cancel orphaned Recall bot ${externalBotId}: ${cancelResult.errorMessage}`, + ); + + return false; +}; + +const normalizeOptionalString = (value: unknown): string | undefined => + isNonEmptyString(value) ? value.trim() : undefined; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/flows/reconcile-call-recording-transcript-artifact-result.type.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/flows/reconcile-call-recording-transcript-artifact-result.type.ts new file mode 100644 index 0000000000..d7226cd243 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/flows/reconcile-call-recording-transcript-artifact-result.type.ts @@ -0,0 +1,11 @@ +import { type CallRecordingUpdateFields } from 'src/logic-functions/data/update-call-recording.util'; + +type CallRecordingTranscriptArtifactUpdateFields = Pick< + CallRecordingUpdateFields, + 'meetingBotFailureReason' | 'status' | 'transcript' +>; + +export type ReconcileCallRecordingTranscriptArtifactResult = { + updateData: CallRecordingTranscriptArtifactUpdateFields; + requestedTranscript: boolean; +}; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/flows/reconcile-call-recording-transcript-artifact.util.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/flows/reconcile-call-recording-transcript-artifact.util.ts new file mode 100644 index 0000000000..a657ae961a --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/flows/reconcile-call-recording-transcript-artifact.util.ts @@ -0,0 +1,183 @@ +import { isNull, isUndefined } from '@sniptt/guards'; + +import { CallRecordingStatus } from 'src/logic-functions/constants/call-recording-status'; +import { buildFailedTranscriptMarker } from 'src/logic-functions/domain/build-failed-transcript-marker.util'; +import { buildPendingTranscriptMarker } from 'src/logic-functions/domain/build-pending-transcript-marker.util'; +import { buildTranscriptFailureReason } from 'src/logic-functions/domain/build-transcript-failure-reason.util'; +import { isCallRecordingStatusDowngrade } from 'src/logic-functions/domain/is-call-recording-status-downgrade.util'; +import { parseTranscriptMarker } from 'src/logic-functions/domain/parse-transcript-marker.util'; +import { createAsyncRecallTranscript } from 'src/logic-functions/recall-api/create-async-recall-transcript.util'; +import { listRecallTranscripts } from 'src/logic-functions/recall-api/list-recall-transcripts.util'; +import { type RecallTranscriptSummary } from 'src/logic-functions/recall-api/recall-transcript-summary.type'; +import { downloadTranscript } from 'src/logic-functions/flows/download-transcript.util'; +import { type ReconcileCallRecordingTranscriptArtifactResult } from 'src/logic-functions/flows/reconcile-call-recording-transcript-artifact-result.type'; + +type CallRecordingTranscriptArtifactUpdateFields = + ReconcileCallRecordingTranscriptArtifactResult['updateData']; + +export const reconcileCallRecordingTranscriptArtifact = async ({ + callRecordingId, + currentStatus, + externalRecordingId, + requestedAt, + transcript, +}: { + callRecordingId: string; + currentStatus: string | undefined; + externalRecordingId: string; + requestedAt: string; + transcript: unknown; +}): Promise => { + const existingTranscriptMarker = parseTranscriptMarker(transcript); + + if ( + !isNull(transcript) && + !isUndefined(transcript) && + isUndefined(existingTranscriptMarker) + ) { + return buildEmptyTranscriptArtifactResult(); + } + + if (existingTranscriptMarker?.status === 'FAILED') { + return buildEmptyTranscriptArtifactResult(); + } + + const listResult = await listRecallTranscripts({ externalRecordingId }); + + if (!listResult.ok) { + console.warn( + `[twenty-meeting-bot] failed to list Recall transcripts for recording ${externalRecordingId}: ${listResult.errorMessage}`, + ); + + return buildEmptyTranscriptArtifactResult(); + } + + const transcriptArtifact = selectRecallTranscriptArtifact( + listResult.transcripts, + ); + const pendingTranscriptMarkerRecallTranscriptId = + existingTranscriptMarker?.status === 'PENDING' + ? (existingTranscriptMarker.recallTranscriptId ?? undefined) + : undefined; + const transcriptIdToDownload = + transcriptArtifact?.id ?? pendingTranscriptMarkerRecallTranscriptId; + + if ( + isUndefined(transcriptArtifact) && + isUndefined(pendingTranscriptMarkerRecallTranscriptId) + ) { + const createResult = await createAsyncRecallTranscript({ + externalRecordingId, + callRecordingId, + }); + + if (!createResult.ok) { + console.warn( + `[twenty-meeting-bot] failed to request transcript for Recall recording ${externalRecordingId}: ${createResult.errorMessage}`, + ); + + return buildEmptyTranscriptArtifactResult(); + } + + return { + updateData: { + transcript: buildPendingTranscriptMarker({ + recallTranscriptId: createResult.transcriptId, + requestedAt, + }), + }, + requestedTranscript: true, + }; + } + + if ( + !isUndefined(transcriptArtifact) && + (transcriptArtifact.statusCode === 'failed' || + transcriptArtifact.statusCode === 'error') + ) { + return { + updateData: buildTranscriptFailureUpdate({ + currentStatus, + transcriptId: transcriptArtifact.id, + subCode: transcriptArtifact.statusSubCode ?? null, + }), + requestedTranscript: false, + }; + } + + if ( + !isUndefined(transcriptArtifact) && + transcriptArtifact.statusCode !== 'done' + ) { + return buildEmptyTranscriptArtifactResult(); + } + + if (isUndefined(transcriptIdToDownload)) { + return buildEmptyTranscriptArtifactResult(); + } + + const downloadResult = await downloadTranscript({ + transcriptId: transcriptIdToDownload, + }); + + if (downloadResult.outcome === 'filled') { + return { + updateData: { + transcript: downloadResult.content as Record, + }, + requestedTranscript: false, + }; + } + + if (downloadResult.outcome === 'failed') { + return { + updateData: buildTranscriptFailureUpdate({ + currentStatus, + transcriptId: transcriptIdToDownload, + subCode: downloadResult.subCode, + }), + requestedTranscript: false, + }; + } + + if (downloadResult.outcome === 'error') { + console.warn( + `[twenty-meeting-bot] could not fill transcript for call recording ${callRecordingId}: ${downloadResult.errorMessage}`, + ); + } + + return buildEmptyTranscriptArtifactResult(); +}; + +const buildEmptyTranscriptArtifactResult = + (): ReconcileCallRecordingTranscriptArtifactResult => ({ + updateData: {}, + requestedTranscript: false, + }); + +const selectRecallTranscriptArtifact = ( + transcripts: RecallTranscriptSummary[], +): RecallTranscriptSummary | undefined => + transcripts.find((transcript) => transcript.statusCode !== 'deleted'); + +const buildTranscriptFailureUpdate = ({ + currentStatus, + transcriptId, + subCode, +}: { + currentStatus: string | undefined; + transcriptId: string; + subCode: string | null; +}): CallRecordingTranscriptArtifactUpdateFields => ({ + transcript: buildFailedTranscriptMarker({ + recallTranscriptId: transcriptId, + subCode, + }), + meetingBotFailureReason: buildTranscriptFailureReason(subCode), + ...(isCallRecordingStatusDowngrade({ + fromStatus: currentStatus, + toStatus: CallRecordingStatus.FAILED, + }) + ? {} + : { status: CallRecordingStatus.FAILED }), +}); diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/flows/reconcile-meeting-bot.util.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/flows/reconcile-meeting-bot.util.ts new file mode 100644 index 0000000000..052e3b9629 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/flows/reconcile-meeting-bot.util.ts @@ -0,0 +1,496 @@ +import { isUndefined } from '@sniptt/guards'; +import { type CoreApiClient } from 'twenty-client-sdk/core'; + +import { CallRecordingRequestStatus } from 'src/logic-functions/constants/call-recording-request-status'; +import { CallRecordingStatus } from 'src/logic-functions/constants/call-recording-status'; +import { type CalendarEventRecord } from 'src/logic-functions/types/calendar-event-record.type'; +import { type CallRecordingRecord } from 'src/logic-functions/types/call-recording-record.type'; +import { type MeetingBotPolicyResultForMeeting } from 'src/logic-functions/types/meeting-bot-policy-result-for-meeting.type'; +import { type MeetingBotReconciliationResult } from 'src/logic-functions/types/meeting-bot-reconciliation-result.type'; +import { type RemovedMeetingBotOccurrence } from 'src/logic-functions/types/removed-meeting-bot-occurrence.type'; +import { aggregateMeetingBotPolicyResultsByMeeting } from 'src/logic-functions/domain/aggregate-meeting-bot-policy-results-by-meeting.util'; +import { buildMeetingBotPolicyResult } from 'src/logic-functions/domain/build-meeting-bot-policy-result.util'; +import { cancelCallRecordingRequest } from 'src/logic-functions/flows/cancel-call-recording-request.util'; +import { computeCallRecordingIdForMeeting } from 'src/logic-functions/domain/compute-call-recording-id-for-meeting.util'; +import { + createCallRecording, + type ScheduledCallRecordingFields, +} from 'src/logic-functions/data/create-call-recording.util'; +import { ensureMeetingBot } from 'src/logic-functions/flows/ensure-meeting-bot.util'; +import { fetchCalendarEventsByIds } from 'src/logic-functions/data/fetch-calendar-events-by-ids.util'; +import { fetchCalendarEventsByStartsAtValues } from 'src/logic-functions/data/fetch-calendar-events-by-starts-at-values.util'; +import { findCallRecordingsByCalendarEventIds } from 'src/logic-functions/data/find-call-recordings-by-calendar-event-ids.util'; +import { findCallRecordingsByIds } from 'src/logic-functions/data/find-call-recordings-by-ids.util'; +import { getUniqueSortedIds } from 'src/logic-functions/utils/get-unique-sorted-ids.util'; +import { rescheduleCallRecordingBot } from 'src/logic-functions/flows/reschedule-call-recording-bot.util'; +import { + updateCallRecording, + type CallRecordingUpdateFields, +} from 'src/logic-functions/data/update-call-recording.util'; + +export const reconcileMeetingBotForCalendarEventIds = async ({ + client, + calendarEventIds, + removedOccurrences = [], + now = new Date(), +}: { + client: CoreApiClient; + calendarEventIds: string[]; + removedOccurrences?: RemovedMeetingBotOccurrence[]; + now?: Date; +}): Promise => { + const meetingPolicyResults = await resolveMeetingBotPolicyResultsForMeetings({ + client, + calendarEventIds, + removedOccurrences, + now, + }); + + return reconcileMeetingBotForMeetingOccurrences({ + client, + meetingPolicyResults, + removedOccurrences, + }); +}; + +const resolveMeetingBotPolicyResultsForMeetings = async ({ + client, + calendarEventIds, + removedOccurrences = [], + now = new Date(), +}: { + client: CoreApiClient; + calendarEventIds: string[]; + removedOccurrences?: RemovedMeetingBotOccurrence[]; + now?: Date; +}): Promise => { + const changedCalendarEvents = await fetchCalendarEventsByIds( + client, + getUniqueSortedIds(calendarEventIds), + ); + const affectedMeetingKeys = new Set(); + const occurrenceStartsAtAnchors = new Set(); + const changedCalendarEventPolicyResults = changedCalendarEvents.map( + (calendarEvent) => buildMeetingBotPolicyResult(calendarEvent, now), + ); + + for (const policyResult of changedCalendarEventPolicyResults) { + affectedMeetingKeys.add(policyResult.realMeetingKey); + } + + for (const calendarEvent of changedCalendarEvents) { + if (!isUndefined(calendarEvent.startsAt)) { + occurrenceStartsAtAnchors.add(calendarEvent.startsAt); + } + } + + for (const removedOccurrence of removedOccurrences) { + affectedMeetingKeys.add(removedOccurrence.realMeetingKey); + + if (!isUndefined(removedOccurrence.startsAt)) { + occurrenceStartsAtAnchors.add(removedOccurrence.startsAt); + } + } + + if (affectedMeetingKeys.size === 0) { + return []; + } + + const occurrenceSiblingEvents = await fetchCalendarEventsByStartsAtValues( + client, + [...occurrenceStartsAtAnchors], + ); + const policyResultsByCalendarEventId = new Map( + changedCalendarEventPolicyResults.map((policyResult) => [ + policyResult.calendarEventId, + policyResult, + ]), + ); + + for (const calendarEvent of occurrenceSiblingEvents) { + if (policyResultsByCalendarEventId.has(calendarEvent.id)) { + continue; + } + + policyResultsByCalendarEventId.set( + calendarEvent.id, + buildMeetingBotPolicyResult(calendarEvent, now), + ); + } + + const perCalendarEventPolicyResults = [ + ...policyResultsByCalendarEventId.values(), + ] + .filter((policyResult) => + affectedMeetingKeys.has(policyResult.realMeetingKey), + ) + .map((policyResult) => ({ + calendarEventId: policyResult.calendarEventId, + realMeetingKey: policyResult.realMeetingKey, + shouldRequestBot: policyResult.shouldRequestBot, + })); + const meetingPolicyResults = aggregateMeetingBotPolicyResultsByMeeting( + perCalendarEventPolicyResults, + ); + const meetingKeysWithPolicyResult = new Set( + meetingPolicyResults.map( + (meetingPolicyResult) => meetingPolicyResult.realMeetingKey, + ), + ); + + for (const meetingKey of [...affectedMeetingKeys].sort()) { + if (meetingKeysWithPolicyResult.has(meetingKey)) { + continue; + } + + meetingPolicyResults.push({ + realMeetingKey: meetingKey, + shouldRequestBot: false, + calendarEventIds: [], + requestingCalendarEventIds: [], + }); + } + + return meetingPolicyResults; +}; + +const reconcileMeetingBotForMeetingOccurrences = async ({ + client, + meetingPolicyResults, + removedOccurrences = [], +}: { + client: CoreApiClient; + meetingPolicyResults: MeetingBotPolicyResultForMeeting[]; + removedOccurrences?: RemovedMeetingBotOccurrence[]; +}): Promise => { + const removedCalendarEventIdsByMeetingKey = + buildRemovedCalendarEventIdsByMeetingKey(removedOccurrences); + const reconciliationResults: MeetingBotReconciliationResult[] = []; + const orderedMeetingPolicyResults = [ + ...meetingPolicyResults.filter( + (meetingPolicyResult) => !meetingPolicyResult.shouldRequestBot, + ), + ...meetingPolicyResults.filter( + (meetingPolicyResult) => meetingPolicyResult.shouldRequestBot, + ), + ]; + + for (const meetingPolicyResult of orderedMeetingPolicyResults) { + const removedCalendarEventIds = + removedCalendarEventIdsByMeetingKey.get( + meetingPolicyResult.realMeetingKey, + ) ?? []; + + try { + reconciliationResults.push( + meetingPolicyResult.shouldRequestBot + ? await reconcileActiveMeeting({ + client, + meetingPolicyResult, + removedCalendarEventIds, + }) + : await reconcileCanceledMeeting({ + client, + meetingPolicyResult, + removedCalendarEventIds, + }), + ); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + + console.error( + `[twenty-meeting-bot] reconciliation failed for meeting ${meetingPolicyResult.realMeetingKey}: ${errorMessage}`, + ); + reconciliationResults.push({ + action: 'FAILED', + realMeetingKey: meetingPolicyResult.realMeetingKey, + errorMessage, + }); + } + } + + return reconciliationResults; +}; + +const reconcileActiveMeeting = async ({ + client, + meetingPolicyResult, + removedCalendarEventIds, +}: { + client: CoreApiClient; + meetingPolicyResult: MeetingBotPolicyResultForMeeting; + removedCalendarEventIds: string[]; +}): Promise => { + const representativeCalendarEventId = getUniqueSortedIds( + meetingPolicyResult.requestingCalendarEventIds, + )[0]; + + if (isUndefined(representativeCalendarEventId)) { + return buildSkippedResult(meetingPolicyResult.realMeetingKey); + } + + const representativeCalendarEvent = ( + await fetchCalendarEventsByIds(client, [representativeCalendarEventId]) + )[0]; + + if (isUndefined(representativeCalendarEvent)) { + return buildSkippedResult(meetingPolicyResult.realMeetingKey); + } + + const callRecordingId = computeCallRecordingIdForMeeting( + meetingPolicyResult.realMeetingKey, + ); + const existingCallRecording = ( + await findCallRecordingsByIds(client, [callRecordingId]) + )[0]; + + if (!isUndefined(existingCallRecording)) { + return updatePolicyManagedCallRecording({ + client, + existingCallRecording, + representativeCalendarEvent, + realMeetingKey: meetingPolicyResult.realMeetingKey, + }); + } + + const manualOpenCallRecording = await findManualOpenCallRecording({ + client, + meetingPolicyResult, + removedCalendarEventIds, + }); + + if (!isUndefined(manualOpenCallRecording)) { + return { + action: 'SKIPPED', + realMeetingKey: meetingPolicyResult.realMeetingKey, + callRecordingId: manualOpenCallRecording.id, + }; + } + + return createPolicyManagedCallRecording({ + client, + callRecordingId, + representativeCalendarEvent, + realMeetingKey: meetingPolicyResult.realMeetingKey, + }); +}; + +const updatePolicyManagedCallRecording = async ({ + client, + existingCallRecording, + representativeCalendarEvent, + realMeetingKey, +}: { + client: CoreApiClient; + existingCallRecording: CallRecordingRecord; + representativeCalendarEvent: CalendarEventRecord; + realMeetingKey: string; +}): Promise => { + await updateCallRecording(client, { + id: existingCallRecording.id, + data: buildPolicyManagedCallRecordingUpdateFields({ + existingCallRecording, + calendarEvent: representativeCalendarEvent, + }), + }); + await rescheduleCallRecordingBot(client, { + callRecording: existingCallRecording, + calendarEvent: representativeCalendarEvent, + }); + + return { + action: 'UPDATED', + realMeetingKey, + callRecordingId: existingCallRecording.id, + }; +}; + +const createPolicyManagedCallRecording = async ({ + client, + callRecordingId, + representativeCalendarEvent, + realMeetingKey, +}: { + client: CoreApiClient; + callRecordingId: string; + representativeCalendarEvent: CalendarEventRecord; + realMeetingKey: string; +}): Promise => { + const scheduledFields = buildScheduledCallRecordingFields( + representativeCalendarEvent, + ); + + try { + await createCallRecording(client, { + id: callRecordingId, + data: scheduledFields, + }); + } catch (error) { + // The id is deterministic, so a conflict means a concurrent run created the row first. + const concurrentlyCreatedCallRecording = ( + await findCallRecordingsByIds(client, [callRecordingId]) + )[0]; + + if (isUndefined(concurrentlyCreatedCallRecording)) { + throw error; + } + + return updatePolicyManagedCallRecording({ + client, + existingCallRecording: concurrentlyCreatedCallRecording, + representativeCalendarEvent, + realMeetingKey, + }); + } + + // Winning the deterministic-id insert elects this run as the single writer that creates the bot. + await ensureMeetingBot(client, { + callRecording: { + id: callRecordingId, + ...scheduledFields, + title: scheduledFields.title ?? undefined, + }, + calendarEvent: representativeCalendarEvent, + }); + + return { + action: 'CREATED', + realMeetingKey, + callRecordingId, + }; +}; + +const findManualOpenCallRecording = async ({ + client, + meetingPolicyResult, + removedCalendarEventIds, +}: { + client: CoreApiClient; + meetingPolicyResult: MeetingBotPolicyResultForMeeting; + removedCalendarEventIds: string[]; +}): Promise => { + const calendarEventIds = getUniqueSortedIds([ + ...meetingPolicyResult.calendarEventIds, + ...meetingPolicyResult.requestingCalendarEventIds, + ...removedCalendarEventIds, + ]); + const callRecordings = await findCallRecordingsByCalendarEventIds( + client, + calendarEventIds, + ); + + return [...callRecordings] + .sort((firstCallRecording, secondCallRecording) => + firstCallRecording.id.localeCompare(secondCallRecording.id), + ) + .find( + (callRecording) => + callRecording.status !== CallRecordingStatus.COMPLETED && + isUndefined(callRecording.recordingRequestStatus), + ); +}; + +const reconcileCanceledMeeting = async ({ + client, + meetingPolicyResult, + removedCalendarEventIds, +}: { + client: CoreApiClient; + meetingPolicyResult: MeetingBotPolicyResultForMeeting; + removedCalendarEventIds: string[]; +}): Promise => { + const calendarEventIds = getUniqueSortedIds([ + ...meetingPolicyResult.calendarEventIds, + ...removedCalendarEventIds, + ]); + const cancellableCallRecordings = ( + await findCallRecordingsByCalendarEventIds(client, calendarEventIds) + ).filter( + (callRecording) => + callRecording.status === CallRecordingStatus.SCHEDULED && + callRecording.recordingRequestStatus === + CallRecordingRequestStatus.REQUESTED, + ); + + if (cancellableCallRecordings.length === 0) { + return buildSkippedResult(meetingPolicyResult.realMeetingKey); + } + + for (const callRecording of cancellableCallRecordings) { + await cancelCallRecordingRequest({ + client, + callRecording, + }); + } + + return { + action: 'CANCELED', + realMeetingKey: meetingPolicyResult.realMeetingKey, + callRecordingId: cancellableCallRecordings[0].id, + }; +}; + +// startedAt/endedAt come from the webhook; calendar writes never touch them. +const buildCalendarDrivenCallRecordingFields = ( + calendarEvent: CalendarEventRecord, +): Omit => ({ + // Wire null clears a stale title when the calendar title is gone or restricted. + title: calendarEvent.title ?? null, + recordingRequestStatus: CallRecordingRequestStatus.REQUESTED, + calendarEventId: calendarEvent.id, +}); + +const buildScheduledCallRecordingFields = ( + calendarEvent: CalendarEventRecord, +): ScheduledCallRecordingFields => ({ + ...buildCalendarDrivenCallRecordingFields(calendarEvent), + status: CallRecordingStatus.SCHEDULED, +}); + +// A live or finished bot lifecycle must never be reset to SCHEDULED by a calendar-driven update. +const buildPolicyManagedCallRecordingUpdateFields = ({ + existingCallRecording, + calendarEvent, +}: { + existingCallRecording: CallRecordingRecord; + calendarEvent: CalendarEventRecord; +}): CallRecordingUpdateFields => + canResetCallRecordingStatusToScheduled(existingCallRecording.status) + ? { + ...buildScheduledCallRecordingFields(calendarEvent), + ...(isUndefined(existingCallRecording.meetingBotFailureReason) + ? {} + : { meetingBotFailureReason: null }), + } + : buildCalendarDrivenCallRecordingFields(calendarEvent); + +const canResetCallRecordingStatusToScheduled = ( + status: string | undefined, +): boolean => + status === CallRecordingStatus.SCHEDULED || + status === CallRecordingStatus.FAILED; + +const buildRemovedCalendarEventIdsByMeetingKey = ( + removedOccurrences: RemovedMeetingBotOccurrence[], +): Map => { + const calendarEventIdsByMeetingKey = new Map(); + + for (const removedOccurrence of removedOccurrences) { + calendarEventIdsByMeetingKey.set(removedOccurrence.realMeetingKey, [ + ...(calendarEventIdsByMeetingKey.get(removedOccurrence.realMeetingKey) ?? + []), + removedOccurrence.calendarEventId, + ]); + } + + return calendarEventIdsByMeetingKey; +}; + +const buildSkippedResult = ( + realMeetingKey: string, +): MeetingBotReconciliationResult => ({ + action: 'SKIPPED', + realMeetingKey, + callRecordingId: null, +}); diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/flows/reschedule-call-recording-bot.util.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/flows/reschedule-call-recording-bot.util.ts new file mode 100644 index 0000000000..f3be32c694 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/flows/reschedule-call-recording-bot.util.ts @@ -0,0 +1,70 @@ +import { isUndefined } from '@sniptt/guards'; +import { type CoreApiClient } from 'twenty-client-sdk/core'; + +import { type MeetingRecording } from 'src/logic-functions/types/meeting-recording.type'; +import { buildRecallBotMetadata } from 'src/logic-functions/domain/build-recall-bot-metadata.util'; +import { computeRecallBotJoinAt } from 'src/logic-functions/domain/compute-recall-bot-join-at.util'; +import { getCurrentWorkspaceId } from 'src/logic-functions/data/get-current-workspace-id.util'; +import { rescheduleRecallBot } from 'src/logic-functions/recall-api/reschedule-recall-bot.util'; +import { updateCallRecording } from 'src/logic-functions/data/update-call-recording.util'; + +const RECALL_BOT_NOT_FOUND_STATUS = 404; + +export const rescheduleCallRecordingBot = async ( + client: CoreApiClient, + { callRecording, calendarEvent }: MeetingRecording, +): Promise => { + const externalBotId = callRecording.externalBotId; + + if (isUndefined(externalBotId)) { + return; + } + + const meetingUrl = calendarEvent.conferenceLinkUrl; + const meetingStartsAt = calendarEvent.startsAt; + + if (isUndefined(meetingUrl) || isUndefined(meetingStartsAt)) { + return; + } + + const joinAt = computeRecallBotJoinAt(meetingStartsAt); + + const workspaceId = getCurrentWorkspaceId(); + + if (isUndefined(workspaceId)) { + console.warn( + `[twenty-meeting-bot] cannot reschedule Recall bot for callRecording ${callRecording.id}: workspace id unavailable`, + ); + + return; + } + + const rescheduleResult = await rescheduleRecallBot({ + externalBotId, + meetingUrl, + joinAt, + metadata: buildRecallBotMetadata({ + callRecording, + calendarEvent, + workspaceId, + }), + }); + + if (rescheduleResult.ok) { + return; + } + + // The bot vanished externally; drop the id so the stale-state cron re-creates it as the single writer. + if (rescheduleResult.status === RECALL_BOT_NOT_FOUND_STATUS) { + await updateCallRecording(client, { + id: callRecording.id, + data: { externalBotId: null }, + }); + + return; + } + + console.warn( + `[twenty-meeting-bot] failed to update Recall bot for callRecording ${callRecording.id}: ${rescheduleResult.errorMessage}`, + ); +}; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/recall-api/__tests__/extract-recall-bot-convergence.test.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/recall-api/__tests__/extract-recall-bot-convergence.test.ts new file mode 100644 index 0000000000..0b2ece8240 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/recall-api/__tests__/extract-recall-bot-convergence.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from 'vitest'; + +import { extractRecallBotConvergence } from 'src/logic-functions/recall-api/extract-recall-bot-convergence.util'; + +describe('extractRecallBotConvergence', () => { + it('maps the latest status change code to a call recording status', () => { + const convergence = extractRecallBotConvergence({ + status_changes: [ + { code: 'joining_call', created_at: '2026-01-01T12:58:00.000Z' }, + { code: 'in_call_recording', created_at: '2026-01-01T13:02:00.000Z' }, + { code: 'call_ended', created_at: '2026-01-01T14:00:00.000Z' }, + { code: 'done', created_at: '2026-01-01T14:05:00.000Z' }, + ], + }); + + // COMPLETED is reserved for full artifact ingestion, never bot state. + expect(convergence.status).toBe('PROCESSING'); + expect(convergence.isRecallRecordingDone).toBe(true); + }); + + it('uses created_at to find the latest status when Recall returns status changes out of order', () => { + const convergence = extractRecallBotConvergence({ + status_changes: [ + { code: 'done', created_at: '2026-01-01T14:05:00.000Z' }, + { code: 'joining_call', created_at: '2026-01-01T12:58:00.000Z' }, + { code: 'in_call_recording', created_at: '2026-01-01T13:02:00.000Z' }, + ], + }); + + expect(convergence.status).toBe('PROCESSING'); + }); + + it('prefers recording-object timestamps over status change entries', () => { + const convergence = extractRecallBotConvergence({ + status_changes: [ + { code: 'in_call_recording', created_at: '2026-01-01T13:02:30.000Z' }, + { code: 'call_ended', created_at: '2026-01-01T14:00:30.000Z' }, + ], + recordings: [ + { + id: 'recall-recording-1', + started_at: '2026-01-01T13:02:00.000Z', + completed_at: '2026-01-01T14:00:00.000Z', + }, + ], + }); + + expect(convergence).toEqual({ + status: 'PROCESSING', + failureReason: undefined, + startedAt: '2026-01-01T13:02:00.000Z', + endedAt: '2026-01-01T14:00:00.000Z', + externalRecordingId: 'recall-recording-1', + isRecallRecordingDone: true, + }); + }); + + it('falls back to status change timestamps when recordings carry none', () => { + const convergence = extractRecallBotConvergence({ + status_changes: [ + { code: 'in_call_recording', created_at: '2026-01-01T13:02:00.000Z' }, + { code: 'call_ended', created_at: '2026-01-01T14:00:00.000Z' }, + ], + recordings: [{ id: 'recall-recording-1' }], + }); + + expect(convergence).toEqual({ + status: 'PROCESSING', + failureReason: undefined, + startedAt: '2026-01-01T13:02:00.000Z', + endedAt: '2026-01-01T14:00:00.000Z', + externalRecordingId: 'recall-recording-1', + isRecallRecordingDone: false, + }); + }); + + it('normalizes microsecond-precision Recall timestamps to millisecond ISO', () => { + const convergence = extractRecallBotConvergence({ + status_changes: [ + { code: 'done', created_at: '2026-06-10T12:20:00.123456+00:00' }, + ], + recordings: [ + { + id: 'recall-recording-1', + started_at: '2026-06-10T11:02:28.281597+00:00', + completed_at: '2026-06-10T12:17:28.281597+00:00', + }, + ], + }); + + expect(convergence.startedAt).toBe('2026-06-10T11:02:28.281Z'); + expect(convergence.endedAt).toBe('2026-06-10T12:17:28.281Z'); + }); + + it('returns nothing derivable from an empty bot response', () => { + expect(extractRecallBotConvergence({})).toEqual({ + status: undefined, + failureReason: undefined, + startedAt: undefined, + endedAt: undefined, + externalRecordingId: undefined, + isRecallRecordingDone: false, + }); + }); + + it('skips malformed status change entries', () => { + const convergence = extractRecallBotConvergence({ + status_changes: [ + null, + 'not-an-object', + { created_at: '2026-01-01T13:00:00.000Z' }, + { code: 'in_call_recording', created_at: '2026-01-01T13:02:00.000Z' }, + ], + recordings: 'not-an-array', + }); + + expect(convergence).toEqual({ + status: 'RECORDING', + failureReason: undefined, + startedAt: '2026-01-01T13:02:00.000Z', + endedAt: undefined, + externalRecordingId: undefined, + isRecallRecordingDone: false, + }); + }); + + it('carries the failing Recall status code as the failure reason', () => { + const convergence = extractRecallBotConvergence({ + status_changes: [ + { code: 'joining_call', created_at: '2026-01-01T12:58:00.000Z' }, + { + code: 'recording_permission_denied', + created_at: '2026-01-01T13:02:00.000Z', + }, + ], + }); + + expect(convergence.status).toBe('FAILED'); + expect(convergence.failureReason).toBe('recording_permission_denied'); + }); + + it('leaves the status undefined for unknown latest codes', () => { + const convergence = extractRecallBotConvergence({ + status_changes: [ + { code: 'in_call_recording', created_at: '2026-01-01T13:02:00.000Z' }, + { code: 'some_future_code', created_at: '2026-01-01T13:30:00.000Z' }, + ], + }); + + expect(convergence.status).toBeUndefined(); + expect(convergence.startedAt).toBe('2026-01-01T13:02:00.000Z'); + }); +}); diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/recall-api/__tests__/extract-recall-media-urls.test.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/recall-api/__tests__/extract-recall-media-urls.test.ts new file mode 100644 index 0000000000..1f254c3cc9 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/recall-api/__tests__/extract-recall-media-urls.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; + +import { extractRecallMediaUrls } from 'src/logic-functions/recall-api/extract-recall-media-urls.util'; + +describe('extractRecallMediaUrls', () => { + it('reads both download urls flat from the v1.11 media shortcuts', () => { + expect( + extractRecallMediaUrls({ + id: 'recall-recording-1', + media_shortcuts: { + video_mixed: { + download_url: 'https://media.example.com/video.mp4', + }, + audio_mixed: { + download_url: 'https://media.example.com/audio.mp3', + }, + }, + }), + ).toEqual({ + videoUrl: 'https://media.example.com/video.mp4', + audioUrl: 'https://media.example.com/audio.mp3', + }); + }); + + it('falls back to the nested data.download_url shape', () => { + expect( + extractRecallMediaUrls({ + id: 'recall-recording-1', + media_shortcuts: { + video_mixed: { + data: { download_url: 'https://media.example.com/video.mp4' }, + }, + audio_mixed: { + data: { download_url: 'https://media.example.com/audio.mp3' }, + }, + }, + }), + ).toEqual({ + videoUrl: 'https://media.example.com/video.mp4', + audioUrl: 'https://media.example.com/audio.mp3', + }); + }); + + it('returns undefined urls when artifacts are absent', () => { + expect( + extractRecallMediaUrls({ + id: 'recall-recording-1', + media_shortcuts: { + video_mixed: {}, + }, + }), + ).toEqual({ videoUrl: undefined, audioUrl: undefined }); + }); + + it('tolerates malformed recording payloads', () => { + expect(extractRecallMediaUrls({})).toEqual({ + videoUrl: undefined, + audioUrl: undefined, + }); + expect(extractRecallMediaUrls({ media_shortcuts: 'not-a-record' })).toEqual( + { + videoUrl: undefined, + audioUrl: undefined, + }, + ); + }); +}); diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/recall-api/__tests__/recall-bot-api.test.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/recall-api/__tests__/recall-bot-api.test.ts new file mode 100644 index 0000000000..8e301c55d9 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/recall-api/__tests__/recall-bot-api.test.ts @@ -0,0 +1,795 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { cancelRecallBot } from 'src/logic-functions/recall-api/cancel-recall-bot.util'; +import { createAsyncRecallTranscript } from 'src/logic-functions/recall-api/create-async-recall-transcript.util'; +import { ejectRecallBot } from 'src/logic-functions/recall-api/eject-recall-bot.util'; +import { getRecallBot } from 'src/logic-functions/recall-api/get-recall-bot.util'; +import { listRecallTranscripts } from 'src/logic-functions/recall-api/list-recall-transcripts.util'; +import { listScheduledRecallBots } from 'src/logic-functions/recall-api/list-scheduled-recall-bots.util'; +import { rescheduleRecallBot } from 'src/logic-functions/recall-api/reschedule-recall-bot.util'; +import { retrieveRecallTranscript } from 'src/logic-functions/recall-api/retrieve-recall-transcript.util'; +import { scheduleRecallBot } from 'src/logic-functions/recall-api/schedule-recall-bot.util'; +import { MEETING_BOT_RECORDING_RETENTION_HOURS_ENV_VAR_NAME } from 'src/logic-functions/constants/meeting-bot-recording-retention-hours-env-var-name'; + +const getRecallApiConfigMock = vi.hoisted(() => vi.fn()); +const WORKSPACE_ID = '123e4567-e89b-12d3-a456-426614174000'; + +vi.mock('src/logic-functions/recall-api/get-recall-api-config.util', () => ({ + getRecallApiConfig: getRecallApiConfigMock, +})); + +describe('recall bot api', () => { + const fetchMock = vi.fn(); + + beforeEach(() => { + delete process.env[MEETING_BOT_RECORDING_RETENTION_HOURS_ENV_VAR_NAME]; + getRecallApiConfigMock.mockReset(); + getRecallApiConfigMock.mockReturnValue({ + success: true, + config: { + apiKey: 'recall-api-key', + baseUrl: 'https://ap-northeast-1.recall.ai/api/v1', + botName: 'Twenty Meeting Bot', + }, + }); + fetchMock.mockReset(); + fetchMock.mockResolvedValue({ + ok: true, + status: 201, + json: async () => ({ id: 'recall-bot-id' }), + }); + vi.stubGlobal('fetch', fetchMock); + }); + + it('creates Recall bot requests with the Token authorization scheme', async () => { + const result = await scheduleRecallBot({ + meetingUrl: 'https://meet.google.com/abc-defg-hij', + joinAt: '2026-01-01T13:00:00.000Z', + metadata: { + twentyWorkspaceId: WORKSPACE_ID, + twentyCallRecordingId: 'call-recording-id', + twentyCalendarEventId: 'calendar-event-id', + twentyRealMeetingKey: 'meeting-key', + }, + }); + + expect(result).toEqual({ ok: true, externalBotId: 'recall-bot-id' }); + expect(fetchMock).toHaveBeenCalledWith( + 'https://ap-northeast-1.recall.ai/api/v1/bot/', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + Authorization: 'Token recall-api-key', + 'Content-Type': 'application/json', + }), + }), + ); + expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ + meeting_url: 'https://meet.google.com/abc-defg-hij', + join_at: '2026-01-01T13:00:00.000Z', + bot_name: 'Twenty Meeting Bot', + recording_config: { + video_mixed_mp4: {}, + audio_mixed_mp3: {}, + retention: { type: 'timed', hours: 166 }, + }, + metadata: { + twentyWorkspaceId: WORKSPACE_ID, + twentyCallRecordingId: 'call-recording-id', + twentyCalendarEventId: 'calendar-event-id', + twentyRealMeetingKey: 'meeting-key', + }, + }); + }); + + it('uses the configured Recall recording retention hours when scheduling a bot', async () => { + process.env[MEETING_BOT_RECORDING_RETENTION_HOURS_ENV_VAR_NAME] = '240'; + + const result = await scheduleRecallBot({ + meetingUrl: 'https://meet.google.com/abc-defg-hij', + joinAt: '2026-01-01T13:00:00.000Z', + metadata: { + twentyWorkspaceId: WORKSPACE_ID, + twentyCallRecordingId: 'call-recording-id', + twentyCalendarEventId: 'calendar-event-id', + twentyRealMeetingKey: 'meeting-key', + }, + }); + + expect(result).toEqual({ ok: true, externalBotId: 'recall-bot-id' }); + expect(JSON.parse(fetchMock.mock.calls[0][1].body).recording_config).toEqual( + { + video_mixed_mp4: {}, + audio_mixed_mp3: {}, + retention: { type: 'timed', hours: 240 }, + }, + ); + }); + + it('falls back to safe Recall recording retention hours when the configured value is invalid', async () => { + process.env[MEETING_BOT_RECORDING_RETENTION_HOURS_ENV_VAR_NAME] = + 'seven-days'; + + const result = await scheduleRecallBot({ + meetingUrl: 'https://meet.google.com/abc-defg-hij', + joinAt: '2026-01-01T13:00:00.000Z', + metadata: { + twentyWorkspaceId: WORKSPACE_ID, + twentyCallRecordingId: 'call-recording-id', + twentyCalendarEventId: 'calendar-event-id', + twentyRealMeetingKey: 'meeting-key', + }, + }); + + expect(result).toEqual({ ok: true, externalBotId: 'recall-bot-id' }); + expect(JSON.parse(fetchMock.mock.calls[0][1].body).recording_config).toEqual( + { + video_mixed_mp4: {}, + audio_mixed_mp3: {}, + retention: { type: 'timed', hours: 166 }, + }, + ); + }); + + it('fails when the create response does not include a bot id', async () => { + fetchMock.mockResolvedValue({ + ok: true, + status: 201, + json: async () => ({}), + }); + + const result = await scheduleRecallBot({ + meetingUrl: 'https://meet.google.com/abc-defg-hij', + joinAt: '2026-01-01T13:00:00.000Z', + metadata: { + twentyWorkspaceId: WORKSPACE_ID, + twentyCallRecordingId: 'call-recording-id', + twentyCalendarEventId: 'calendar-event-id', + twentyRealMeetingKey: 'meeting-key', + }, + }); + + expect(result).toEqual({ + ok: false, + status: null, + errorMessage: + 'Recall API created a bot but the response did not include a bot id', + }); + }); + + it('reports the HTTP status when rescheduling a bot that no longer exists', async () => { + fetchMock.mockResolvedValue({ + ok: false, + status: 404, + json: async () => ({ detail: 'Not found.' }), + }); + + const result = await rescheduleRecallBot({ + externalBotId: 'recall-bot-gone', + meetingUrl: 'https://meet.google.com/abc-defg-hij', + joinAt: '2026-01-01T13:00:00.000Z', + metadata: { + twentyWorkspaceId: WORKSPACE_ID, + twentyCallRecordingId: 'call-recording-id', + twentyCalendarEventId: 'calendar-event-id', + twentyRealMeetingKey: 'meeting-key', + }, + }); + + expect(result).toEqual({ + ok: false, + status: 404, + errorMessage: + 'Recall API responded with HTTP 404: {"detail":"Not found."}', + }); + expect(JSON.parse(fetchMock.mock.calls[0][1].body).recording_config).toEqual( + { + video_mixed_mp4: {}, + audio_mixed_mp3: {}, + retention: { type: 'timed', hours: 166 }, + }, + ); + }); + + it('does not duplicate an existing Token authorization prefix', async () => { + getRecallApiConfigMock.mockReturnValue({ + success: true, + config: { + apiKey: 'Token recall-api-key', + baseUrl: 'https://ap-northeast-1.recall.ai/api/v1', + botName: 'Twenty Meeting Bot', + }, + }); + + await scheduleRecallBot({ + meetingUrl: 'https://meet.google.com/abc-defg-hij', + joinAt: '2026-01-01T13:00:00.000Z', + metadata: { + twentyWorkspaceId: WORKSPACE_ID, + twentyCallRecordingId: 'call-recording-id', + twentyCalendarEventId: 'calendar-event-id', + twentyRealMeetingKey: 'meeting-key', + }, + }); + + expect(fetchMock).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Token recall-api-key', + }), + }), + ); + }); + + it('lists scheduled bots in a join-at window and follows pagination', async () => { + fetchMock + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + next: 'https://ap-northeast-1.recall.ai/api/v1/bot/?cursor=page-2', + results: [ + { id: 'bot-1', metadata: { twentyCallRecordingId: 'recording-1' } }, + ], + }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + next: null, + results: [{ id: 'bot-2' }], + }), + }); + + const result = await listScheduledRecallBots({ + joinAtAfter: '2026-01-01T08:00:00.000Z', + joinAtBefore: '2026-01-02T12:00:00.000Z', + }); + + expect(result).toEqual({ + ok: true, + bots: [ + { id: 'bot-1', metadata: { twentyCallRecordingId: 'recording-1' } }, + { id: 'bot-2', metadata: {} }, + ], + }); + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + 'https://ap-northeast-1.recall.ai/api/v1/bot/?join_at_after=2026-01-01T08%3A00%3A00.000Z&join_at_before=2026-01-02T12%3A00%3A00.000Z', + expect.objectContaining({ method: 'GET' }), + ); + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + 'https://ap-northeast-1.recall.ai/api/v1/bot/?cursor=page-2', + expect.objectContaining({ method: 'GET' }), + ); + }); + + it('fails the scheduled bot list when the pagination cap would truncate results', async () => { + for (let pageIndex = 1; pageIndex <= 10; pageIndex++) { + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + next: `https://ap-northeast-1.recall.ai/api/v1/bot/?cursor=page-${pageIndex + 1}`, + results: [{ id: `bot-${pageIndex}` }], + }), + }); + } + + const result = await listScheduledRecallBots({ + joinAtAfter: '2026-01-01T08:00:00.000Z', + joinAtBefore: '2026-01-02T12:00:00.000Z', + }); + + expect(result).toEqual({ + ok: false, + status: null, + errorMessage: 'Recall bot list exceeded 10 pages', + }); + expect(fetchMock).toHaveBeenCalledTimes(10); + }); + + it('stops paginating when the next link points outside the configured region', async () => { + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + next: 'https://evil.example.com/api/v1/bot/?cursor=page-2', + results: [{ id: 'bot-1' }], + }), + }); + + const result = await listScheduledRecallBots({ + joinAtAfter: '2026-01-01T08:00:00.000Z', + joinAtBefore: '2026-01-02T12:00:00.000Z', + }); + + expect(result).toEqual({ + ok: true, + bots: [{ id: 'bot-1', metadata: {} }], + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('cancels a scheduled Recall bot request', async () => { + fetchMock.mockResolvedValue({ + ok: true, + status: 204, + json: async () => ({}), + }); + + const result = await cancelRecallBot({ + externalBotId: 'recall-bot-id', + }); + + expect(result).toEqual({ ok: true }); + expect(fetchMock).toHaveBeenCalledWith( + 'https://ap-northeast-1.recall.ai/api/v1/bot/recall-bot-id/', + expect.objectContaining({ method: 'DELETE' }), + ); + }); + + it('ejects a bot through the leave_call endpoint', async () => { + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ id: 'recall-bot-id' }), + }); + + const result = await ejectRecallBot({ + externalBotId: 'recall-bot-id', + }); + + expect(result).toEqual({ ok: true }); + expect(fetchMock).toHaveBeenCalledWith( + 'https://ap-northeast-1.recall.ai/api/v1/bot/recall-bot-id/leave_call/', + expect.objectContaining({ method: 'POST' }), + ); + }); + + it('fetches a single bot and returns the raw response', async () => { + const botResponse = { + id: 'recall-bot-id', + status_changes: [{ code: 'done' }], + recordings: [{ id: 'recall-recording-id' }], + }; + + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + json: async () => botResponse, + }); + + const result = await getRecallBot({ externalBotId: 'recall-bot-id' }); + + expect(result).toEqual({ ok: true, bot: botResponse }); + expect(fetchMock).toHaveBeenCalledWith( + 'https://ap-northeast-1.recall.ai/api/v1/bot/recall-bot-id/', + expect.objectContaining({ method: 'GET' }), + ); + }); + + it('reports the HTTP status when fetching a bot that no longer exists', async () => { + fetchMock.mockResolvedValue({ + ok: false, + status: 404, + json: async () => ({ detail: 'Not found.' }), + }); + + const result = await getRecallBot({ externalBotId: 'recall-bot-gone' }); + + expect(result).toEqual({ + ok: false, + status: 404, + errorMessage: + 'Recall API responded with HTTP 404: {"detail":"Not found."}', + }); + }); + + it('lists transcripts for a recording id and normalizes status fields', async () => { + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + next: null, + results: [ + { + id: 'recall-transcript-id', + status: { code: 'done', sub_code: null }, + }, + ], + }), + }); + + const result = await listRecallTranscripts({ + externalRecordingId: 'recall-recording-id', + }); + + expect(result).toEqual({ + ok: true, + transcripts: [ + { + id: 'recall-transcript-id', + statusCode: 'done', + statusSubCode: undefined, + }, + ], + }); + expect(fetchMock).toHaveBeenCalledWith( + 'https://ap-northeast-1.recall.ai/api/v1/transcript/?recording_id=recall-recording-id', + expect.objectContaining({ method: 'GET' }), + ); + }); + + it('follows transcript list pagination within the configured Recall region', async () => { + fetchMock + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + next: 'https://ap-northeast-1.recall.ai/api/v1/transcript/?cursor=page-2', + results: [ + { + id: 'recall-transcript-id-1', + status: { code: 'processing' }, + }, + ], + }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + next: null, + results: [ + { + id: 'recall-transcript-id-2', + status: { code: 'failed', sub_code: 'audio_missing' }, + }, + ], + }), + }); + + const result = await listRecallTranscripts({ + externalRecordingId: 'recall-recording-id', + }); + + expect(result).toEqual({ + ok: true, + transcripts: [ + { + id: 'recall-transcript-id-1', + statusCode: 'processing', + statusSubCode: undefined, + }, + { + id: 'recall-transcript-id-2', + statusCode: 'failed', + statusSubCode: 'audio_missing', + }, + ], + }); + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + 'https://ap-northeast-1.recall.ai/api/v1/transcript/?cursor=page-2', + expect.objectContaining({ method: 'GET' }), + ); + }); + + it('rejects malformed transcript lists', async () => { + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + next: null, + results: [{}], + }), + }); + + const result = await listRecallTranscripts({ + externalRecordingId: 'recall-recording-id', + }); + + expect(result).toEqual({ + ok: false, + status: 200, + errorMessage: 'Recall API returned malformed transcript list', + }); + }); + + it('creates an async transcript with the locked provider settings', async () => { + fetchMock.mockResolvedValue({ + ok: true, + status: 201, + json: async () => ({ id: 'recall-transcript-id' }), + }); + + const result = await createAsyncRecallTranscript({ + externalRecordingId: 'recall-recording-id', + }); + + expect(result).toEqual({ ok: true, transcriptId: 'recall-transcript-id' }); + expect(fetchMock).toHaveBeenCalledWith( + 'https://ap-northeast-1.recall.ai/api/v1/recording/recall-recording-id/create_transcript/', + expect.objectContaining({ method: 'POST' }), + ); + expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ + provider: { recallai_async: { language_code: 'auto' } }, + diarization: { use_separate_streams_when_available: true }, + }); + }); + + it('adds call recording metadata when convergence creates an async transcript', async () => { + fetchMock.mockResolvedValue({ + ok: true, + status: 201, + json: async () => ({ id: 'recall-transcript-id' }), + }); + + const result = await createAsyncRecallTranscript({ + externalRecordingId: 'recall-recording-id', + callRecordingId: 'call-recording-id', + }); + + expect(result).toEqual({ ok: true, transcriptId: 'recall-transcript-id' }); + expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ + provider: { recallai_async: { language_code: 'auto' } }, + diarization: { use_separate_streams_when_available: true }, + metadata: { twentyCallRecordingId: 'call-recording-id' }, + }); + }); + + it('does not retry async transcript creation failures', async () => { + fetchMock.mockResolvedValue({ + ok: false, + status: 503, + json: async () => ({ detail: 'service unavailable' }), + }); + + const result = await createAsyncRecallTranscript({ + externalRecordingId: 'recall-recording-id', + }); + + expect(result).toEqual({ + ok: false, + status: 503, + errorMessage: + 'Recall API responded with HTTP 503: {"detail":"service unavailable"}', + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('fails when the transcript creation response has no id', async () => { + fetchMock.mockResolvedValue({ + ok: true, + status: 201, + json: async () => ({}), + }); + + const result = await createAsyncRecallTranscript({ + externalRecordingId: 'recall-recording-id', + }); + + expect(result).toEqual({ + ok: false, + status: null, + errorMessage: + 'Recall API created a transcript but the response did not include a transcript id', + }); + }); + + it('retrieves transcript details with the download URL and status', async () => { + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + id: 'recall-transcript-id', + status: { code: 'done', sub_code: null }, + data: { + download_url: 'https://recall-transcripts.example.com/transcript', + }, + }), + }); + + const result = await retrieveRecallTranscript({ + transcriptId: 'recall-transcript-id', + }); + + expect(result).toEqual({ + ok: true, + transcript: { + downloadUrl: 'https://recall-transcripts.example.com/transcript', + statusCode: 'done', + statusSubCode: undefined, + }, + }); + expect(fetchMock).toHaveBeenCalledWith( + 'https://ap-northeast-1.recall.ai/api/v1/transcript/recall-transcript-id/', + expect.objectContaining({ method: 'GET' }), + ); + }); + + it('surfaces the failure sub code of an errored transcript', async () => { + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + id: 'recall-transcript-id', + status: { code: 'error', sub_code: 'audio_missing' }, + data: {}, + }), + }); + + const result = await retrieveRecallTranscript({ + transcriptId: 'recall-transcript-id', + }); + + expect(result).toEqual({ + ok: true, + transcript: { + downloadUrl: undefined, + statusCode: 'error', + statusSubCode: 'audio_missing', + }, + }); + }); + + it('rejects malformed transcript details', async () => { + fetchMock + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + id: 'recall-transcript-id', + status: { code: 'done' }, + data: {}, + }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + id: 'recall-transcript-id', + data: {}, + }), + }); + + await expect( + retrieveRecallTranscript({ transcriptId: 'recall-transcript-id' }), + ).resolves.toEqual({ + ok: false, + status: 200, + errorMessage: 'Recall API returned malformed transcript details', + }); + await expect( + retrieveRecallTranscript({ transcriptId: 'recall-transcript-id' }), + ).resolves.toEqual({ + ok: false, + status: 200, + errorMessage: 'Recall API returned malformed transcript details', + }); + }); + + describe('transient failure retries', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('retries a network failure and succeeds on the next attempt', async () => { + fetchMock.mockRejectedValueOnce(new Error('socket hang up')); + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ id: 'recall-bot-id' }), + }); + + const resultPromise = getRecallBot({ externalBotId: 'recall-bot-id' }); + + await vi.runAllTimersAsync(); + + expect(await resultPromise).toEqual({ + ok: true, + bot: { id: 'recall-bot-id' }, + }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('retries a 503 response and succeeds on the next attempt', async () => { + fetchMock.mockResolvedValueOnce({ + ok: false, + status: 503, + json: async () => ({ detail: 'service unavailable' }), + }); + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ id: 'recall-bot-id' }), + }); + + const resultPromise = getRecallBot({ externalBotId: 'recall-bot-id' }); + + await vi.runAllTimersAsync(); + + expect(await resultPromise).toEqual({ + ok: true, + bot: { id: 'recall-bot-id' }, + }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('gives up after the attempt budget on persistent server errors', async () => { + fetchMock.mockResolvedValue({ + ok: false, + status: 500, + json: async () => ({ detail: 'server error' }), + }); + + const resultPromise = getRecallBot({ externalBotId: 'recall-bot-id' }); + + await vi.runAllTimersAsync(); + + expect(await resultPromise).toEqual({ + ok: false, + status: 500, + errorMessage: + 'Recall API responded with HTTP 500: {"detail":"server error"}', + }); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it('does not retry client errors', async () => { + fetchMock.mockResolvedValue({ + ok: false, + status: 400, + json: async () => ({ detail: 'bad request' }), + }); + + const result = await getRecallBot({ externalBotId: 'recall-bot-id' }); + + expect(result).toEqual({ + ok: false, + status: 400, + errorMessage: + 'Recall API responded with HTTP 400: {"detail":"bad request"}', + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('does not retry an allowed 404 on cancel', async () => { + fetchMock.mockResolvedValue({ + ok: false, + status: 404, + json: async () => ({ detail: 'not found' }), + }); + + const result = await cancelRecallBot({ + externalBotId: 'recall-bot-id', + }); + + expect(result).toEqual({ ok: true }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('does not retry an allowed 404 on eject', async () => { + fetchMock.mockResolvedValue({ + ok: false, + status: 404, + json: async () => ({ detail: 'not found' }), + }); + + const result = await ejectRecallBot({ + externalBotId: 'recall-bot-id', + }); + + expect(result).toEqual({ ok: true }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/recall-api/__tests__/verify-recall-webhook-signature.test.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/recall-api/__tests__/verify-recall-webhook-signature.test.ts new file mode 100644 index 0000000000..b70c0fb2cd --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/recall-api/__tests__/verify-recall-webhook-signature.test.ts @@ -0,0 +1,122 @@ +import { createHmac } from 'crypto'; + +import { describe, expect, it } from 'vitest'; + +import { verifyRecallWebhookSignature } from 'src/logic-functions/recall-api/verify-recall-webhook-signature.util'; + +const SECRET_BYTES = Buffer.from('test-secret-abc123'); +const SECRET = `whsec_${SECRET_BYTES.toString('base64')}`; +const WEBHOOK_ID = 'msg_123'; +const WEBHOOK_TIMESTAMP = '1760000000'; +const NOW = new Date(Number(WEBHOOK_TIMESTAMP) * 1000); + +const sign = (body: string): string => + createHmac('sha256', SECRET_BYTES) + .update(`${WEBHOOK_ID}.${WEBHOOK_TIMESTAMP}.${body}`) + .digest('base64'); + +describe('verifyRecallWebhookSignature', () => { + it('accepts valid Recall webhook-* signature headers', () => { + const body = JSON.stringify({ event: 'recording.done' }); + + const result = verifyRecallWebhookSignature({ + rawBody: body, + secret: SECRET, + now: NOW, + headers: { + 'webhook-id': WEBHOOK_ID, + 'webhook-timestamp': WEBHOOK_TIMESTAMP, + 'webhook-signature': `v1,${sign(body)}`, + }, + }); + + expect(result).toEqual({ valid: true }); + }); + + it('accepts valid svix-* signature headers', () => { + const body = JSON.stringify({ event: 'recording.done' }); + + const result = verifyRecallWebhookSignature({ + rawBody: body, + secret: SECRET, + now: NOW, + headers: { + 'svix-id': WEBHOOK_ID, + 'svix-timestamp': WEBHOOK_TIMESTAMP, + 'svix-signature': `v1,${sign(body)}`, + }, + }); + + expect(result).toEqual({ valid: true }); + }); + + it('rejects deliveries whose timestamp is outside of the tolerance', () => { + const body = JSON.stringify({ event: 'recording.done' }); + + const result = verifyRecallWebhookSignature({ + rawBody: body, + secret: SECRET, + now: new Date(Number(WEBHOOK_TIMESTAMP) * 1000 + 6 * 60 * 1000), + headers: { + 'webhook-id': WEBHOOK_ID, + 'webhook-timestamp': WEBHOOK_TIMESTAMP, + 'webhook-signature': `v1,${sign(body)}`, + }, + }); + + expect(result).toEqual({ + valid: false, + error: 'Webhook timestamp is outside of the allowed tolerance', + }); + }); + + it('rejects non-numeric timestamps', () => { + const body = JSON.stringify({ event: 'recording.done' }); + + const result = verifyRecallWebhookSignature({ + rawBody: body, + secret: SECRET, + now: NOW, + headers: { + 'webhook-id': WEBHOOK_ID, + 'webhook-timestamp': 'not-a-timestamp', + 'webhook-signature': `v1,${sign(body)}`, + }, + }); + + expect(result).toEqual({ + valid: false, + error: 'Invalid webhook timestamp', + }); + }); + + it('rejects missing signature headers', () => { + const result = verifyRecallWebhookSignature({ + rawBody: '{}', + secret: SECRET, + headers: {}, + }); + + expect(result).toEqual({ + valid: false, + error: 'Missing webhook signature headers', + }); + }); + + it('rejects signatures computed from a different body', () => { + const body = JSON.stringify({ event: 'recording.done' }); + + const result = verifyRecallWebhookSignature({ + rawBody: JSON.stringify({ event: 'recording.failed' }), + secret: SECRET, + now: NOW, + headers: { + 'webhook-id': WEBHOOK_ID, + 'webhook-timestamp': WEBHOOK_TIMESTAMP, + 'webhook-signature': `v1,${sign(body)}`, + }, + }); + + expect(result.valid).toBe(false); + }); +}); diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/recall-api/cancel-recall-bot.util.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/recall-api/cancel-recall-bot.util.ts new file mode 100644 index 0000000000..04d7bf5ee0 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/recall-api/cancel-recall-bot.util.ts @@ -0,0 +1,28 @@ +import { type RecallBotRemovalResult } from 'src/logic-functions/types/recall-bot-operation-result.type'; +import { getRecallApiConfig } from 'src/logic-functions/recall-api/get-recall-api-config.util'; +import { recallBotApiRequest } from 'src/logic-functions/recall-api/recall-bot-api-request.util'; + +export const cancelRecallBot = async ({ + externalBotId, +}: { + externalBotId: string; +}): Promise => { + const configResult = getRecallApiConfig(); + + if (!configResult.success) { + return { ok: false, status: null, errorMessage: configResult.error }; + } + + const result = await recallBotApiRequest({ + config: configResult.config, + path: `/bot/${externalBotId}/`, + method: 'DELETE', + allowNotFound: true, + }); + + if (!result.ok) { + return result; + } + + return { ok: true }; +}; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/recall-api/create-async-recall-transcript.util.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/recall-api/create-async-recall-transcript.util.ts new file mode 100644 index 0000000000..c52ed514ee --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/recall-api/create-async-recall-transcript.util.ts @@ -0,0 +1,52 @@ +import { isString } from '@sniptt/guards'; + +import { type RecallBotOperationFailure } from 'src/logic-functions/types/recall-bot-operation-result.type'; +import { getRecallApiConfig } from 'src/logic-functions/recall-api/get-recall-api-config.util'; +import { recallBotApiRequest } from 'src/logic-functions/recall-api/recall-bot-api-request.util'; + +type CreateAsyncRecallTranscriptResult = + | { ok: true; transcriptId: string } + | RecallBotOperationFailure; + +export const createAsyncRecallTranscript = async ({ + externalRecordingId, + callRecordingId, +}: { + externalRecordingId: string; + callRecordingId?: string; +}): Promise => { + const configResult = getRecallApiConfig(); + + if (!configResult.success) { + return { ok: false, status: null, errorMessage: configResult.error }; + } + + const result = await recallBotApiRequest<{ id?: unknown }>({ + config: configResult.config, + path: `/recording/${externalRecordingId}/create_transcript/`, + method: 'POST', + body: { + provider: { recallai_async: { language_code: 'auto' } }, + diarization: { use_separate_streams_when_available: true }, + ...(callRecordingId === undefined + ? {} + : { metadata: { twentyCallRecordingId: callRecordingId } }), + }, + maxAttempts: 1, + }); + + if (!result.ok) { + return result; + } + + if (!isString(result.data?.id)) { + return { + ok: false, + status: null, + errorMessage: + 'Recall API created a transcript but the response did not include a transcript id', + }; + } + + return { ok: true, transcriptId: result.data.id }; +}; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/recall-api/eject-recall-bot.util.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/recall-api/eject-recall-bot.util.ts new file mode 100644 index 0000000000..c1bd3fc5dc --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/recall-api/eject-recall-bot.util.ts @@ -0,0 +1,28 @@ +import { type RecallBotRemovalResult } from 'src/logic-functions/types/recall-bot-operation-result.type'; +import { getRecallApiConfig } from 'src/logic-functions/recall-api/get-recall-api-config.util'; +import { recallBotApiRequest } from 'src/logic-functions/recall-api/recall-bot-api-request.util'; + +export const ejectRecallBot = async ({ + externalBotId, +}: { + externalBotId: string; +}): Promise => { + const configResult = getRecallApiConfig(); + + if (!configResult.success) { + return { ok: false, status: null, errorMessage: configResult.error }; + } + + const result = await recallBotApiRequest({ + config: configResult.config, + path: `/bot/${externalBotId}/leave_call/`, + method: 'POST', + allowNotFound: true, + }); + + if (!result.ok) { + return result; + } + + return { ok: true }; +}; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/recall-api/extract-recall-bot-convergence.util.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/recall-api/extract-recall-bot-convergence.util.ts new file mode 100644 index 0000000000..d6af6a3a16 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/recall-api/extract-recall-bot-convergence.util.ts @@ -0,0 +1,149 @@ +import { isArray, isUndefined } from '@sniptt/guards'; + +import { CallRecordingStatus } from 'src/logic-functions/constants/call-recording-status'; +import { asRecord } from 'src/logic-functions/utils/as-record.util'; +import { getString } from 'src/logic-functions/utils/get-string.util'; +import { mapRecallStatusCodeToCallRecordingStatus } from 'src/logic-functions/domain/map-recall-status-code-to-call-recording-status.util'; +import { normalizeRecallTimestamp } from 'src/logic-functions/recall-api/normalize-recall-timestamp.util'; + +export type RecallBotConvergence = { + status: CallRecordingStatus | undefined; + failureReason: string | undefined; + startedAt: string | undefined; + endedAt: string | undefined; + externalRecordingId: string | undefined; + isRecallRecordingDone: boolean; +}; + +type RecallBotStatusChange = { + code: string; + createdAt: string | undefined; +}; + +// Derives the state a full webhook history would have produced from GET /bot. +export const extractRecallBotConvergence = ( + bot: Record, +): RecallBotConvergence => { + const statusChanges = extractStatusChanges(bot); + const latestStatusChange = getLatestStatusChange(statusChanges); + const status = mapRecallStatusCodeToCallRecordingStatus( + latestStatusChange?.code, + ); + const recording = extractFirstRecording(bot); + + return { + status, + failureReason: + status === CallRecordingStatus.FAILED + ? latestStatusChange?.code + : undefined, + startedAt: normalizeRecallTimestamp( + recording?.startedAt ?? + findStatusChangeTimestamp(statusChanges, 'in_call_recording'), + ), + endedAt: normalizeRecallTimestamp( + recording?.completedAt ?? + findStatusChangeTimestamp(statusChanges, 'call_ended'), + ), + externalRecordingId: recording?.id, + isRecallRecordingDone: + !isUndefined(recording?.completedAt) || + statusChanges.some((statusChange) => statusChange.code === 'done'), + }; +}; + +const extractStatusChanges = ( + bot: Record, +): RecallBotStatusChange[] => { + if (!isArray(bot.status_changes)) { + return []; + } + + return bot.status_changes.flatMap((statusChange: unknown) => { + const code = getString(asRecord(statusChange)?.code); + + if (isUndefined(code)) { + return []; + } + + return [{ code, createdAt: getString(asRecord(statusChange)?.created_at) }]; + }); +}; + +const getLatestStatusChange = ( + statusChanges: RecallBotStatusChange[], +): RecallBotStatusChange | undefined => + statusChanges.reduce( + (latestStatusChange, statusChange) => { + if (isUndefined(latestStatusChange)) { + return statusChange; + } + + const statusChangeTime = getStatusChangeTime(statusChange); + const latestStatusChangeTime = getStatusChangeTime(latestStatusChange); + + if ( + isUndefined(statusChangeTime) && + isUndefined(latestStatusChangeTime) + ) { + return statusChange; + } + + if (isUndefined(statusChangeTime)) { + return latestStatusChange; + } + + if (isUndefined(latestStatusChangeTime)) { + return statusChange; + } + + return statusChangeTime >= latestStatusChangeTime + ? statusChange + : latestStatusChange; + }, + undefined, + ); + +const getStatusChangeTime = ( + statusChange: RecallBotStatusChange, +): number | undefined => { + const normalizedTimestamp = normalizeRecallTimestamp(statusChange.createdAt); + + if (isUndefined(normalizedTimestamp)) { + return undefined; + } + + return new Date(normalizedTimestamp).getTime(); +}; + +const extractFirstRecording = ( + bot: Record, +): + | { + id: string | undefined; + startedAt: string | undefined; + completedAt: string | undefined; + } + | undefined => { + if (!isArray(bot.recordings)) { + return undefined; + } + + const recording = asRecord(bot.recordings[0]); + + if (isUndefined(recording)) { + return undefined; + } + + return { + id: getString(recording.id), + startedAt: getString(recording.started_at), + completedAt: getString(recording.completed_at), + }; +}; + +const findStatusChangeTimestamp = ( + statusChanges: RecallBotStatusChange[], + code: string, +): string | undefined => + statusChanges.find((statusChange) => statusChange.code === code)?.createdAt; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/recall-api/extract-recall-bot-id.util.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/recall-api/extract-recall-bot-id.util.ts new file mode 100644 index 0000000000..165ba2d327 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/recall-api/extract-recall-bot-id.util.ts @@ -0,0 +1,10 @@ +import { getString } from 'src/logic-functions/utils/get-string.util'; + +export type RecallBotResponse = { + id?: unknown; + bot_id?: unknown; +}; + +export const extractRecallBotId = ( + response: RecallBotResponse | undefined, +): string | undefined => getString(response?.id) ?? getString(response?.bot_id); diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/recall-api/extract-recall-media-urls.util.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/recall-api/extract-recall-media-urls.util.ts new file mode 100644 index 0000000000..83d4bdba7c --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/recall-api/extract-recall-media-urls.util.ts @@ -0,0 +1,30 @@ +import { asRecord } from 'src/logic-functions/utils/as-record.util'; +import { getRecordAtPath } from 'src/logic-functions/utils/get-record-at-path.util'; +import { getString } from 'src/logic-functions/utils/get-string.util'; + +export type RecallMediaUrls = { + videoUrl: string | undefined; + audioUrl: string | undefined; +}; + +// Pre-signed URLs expire within hours; always re-extract from a fresh GET /recording. +export const extractRecallMediaUrls = ( + recording: Record, +): RecallMediaUrls => { + const mediaShortcuts = asRecord(recording.media_shortcuts); + + return { + videoUrl: extractArtifactDownloadUrl(mediaShortcuts, 'video_mixed'), + audioUrl: extractArtifactDownloadUrl(mediaShortcuts, 'audio_mixed'), + }; +}; + +// v1.11 exposes download_url flat on the artifact; older artifacts nest it under data. +const extractArtifactDownloadUrl = ( + mediaShortcuts: Record | undefined, + artifactKey: string, +): string | undefined => + getString(getRecordAtPath(mediaShortcuts, [artifactKey, 'download_url'])) ?? + getString( + getRecordAtPath(mediaShortcuts, [artifactKey, 'data', 'download_url']), + ); diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/recall-api/get-recall-api-config.util.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/recall-api/get-recall-api-config.util.ts new file mode 100644 index 0000000000..3bac96a932 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/recall-api/get-recall-api-config.util.ts @@ -0,0 +1,59 @@ +import { isUndefined } from '@sniptt/guards'; + +import { DEFAULT_MEETING_BOT_NAME } from 'src/logic-functions/constants/default-meeting-bot-name'; +import { DEFAULT_RECALL_REGION } from 'src/logic-functions/constants/default-recall-region'; +import { MEETING_BOT_NAME_ENV_VAR_NAME } from 'src/logic-functions/constants/meeting-bot-name-env-var-name'; +import { RECALL_API_KEY_ENV_VAR_NAME } from 'src/logic-functions/constants/recall-api-key-env-var-name'; +import { RECALL_REGION_ENV_VAR_NAME } from 'src/logic-functions/constants/recall-region-env-var-name'; +import { getApplicationVariableValue } from 'src/logic-functions/utils/get-application-variable-value.util'; +import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util'; + +export type RecallApiConfig = { + apiKey: string; + baseUrl: string; + botName: string; +}; + +export const getRecallApiConfig = (): + | { + success: true; + config: RecallApiConfig; + } + | { + success: false; + error: string; + } => { + const apiKey = normalizeOptionalString( + getApplicationVariableValue(RECALL_API_KEY_ENV_VAR_NAME), + ); + + if (isUndefined(apiKey)) { + return { + success: false, + error: + 'RECALL_API_KEY server variable is not set. A server admin must set it on the Twenty Meeting Bot application registration before scheduling bots.', + }; + } + + const region = + normalizeOptionalString( + getApplicationVariableValue(RECALL_REGION_ENV_VAR_NAME), + ) ?? DEFAULT_RECALL_REGION; + const botName = + normalizeOptionalString( + getApplicationVariableValue(MEETING_BOT_NAME_ENV_VAR_NAME), + ) ?? DEFAULT_MEETING_BOT_NAME; + + return { + success: true, + config: { + apiKey, + baseUrl: `https://${region}.recall.ai/api/v1`, + botName, + }, + }; +}; + +const normalizeOptionalString = ( + value: string | undefined, +): string | undefined => (isNonEmptyString(value) ? value.trim() : undefined); diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/recall-api/get-recall-bot.util.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/recall-api/get-recall-bot.util.ts new file mode 100644 index 0000000000..7772622d6d --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/recall-api/get-recall-bot.util.ts @@ -0,0 +1,42 @@ +import { type RecallBotOperationFailure } from 'src/logic-functions/types/recall-bot-operation-result.type'; +import { asRecord } from 'src/logic-functions/utils/as-record.util'; +import { getRecallApiConfig } from 'src/logic-functions/recall-api/get-recall-api-config.util'; +import { recallBotApiRequest } from 'src/logic-functions/recall-api/recall-bot-api-request.util'; + +type GetRecallBotResult = + | { ok: true; bot: Record } + | RecallBotOperationFailure; + +export const getRecallBot = async ({ + externalBotId, +}: { + externalBotId: string; +}): Promise => { + const configResult = getRecallApiConfig(); + + if (!configResult.success) { + return { ok: false, status: null, errorMessage: configResult.error }; + } + + const result = await recallBotApiRequest>({ + config: configResult.config, + path: `/bot/${externalBotId}/`, + method: 'GET', + }); + + if (!result.ok) { + return result; + } + + const bot = asRecord(result.data); + + if (bot === undefined) { + return { + ok: false, + status: result.status, + errorMessage: 'Recall API returned an empty bot response', + }; + } + + return { ok: true, bot }; +}; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/recall-api/get-recall-recording.util.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/recall-api/get-recall-recording.util.ts new file mode 100644 index 0000000000..a81133ec09 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/recall-api/get-recall-recording.util.ts @@ -0,0 +1,31 @@ +import { type RecallBotOperationFailure } from 'src/logic-functions/types/recall-bot-operation-result.type'; +import { getRecallApiConfig } from 'src/logic-functions/recall-api/get-recall-api-config.util'; +import { recallBotApiRequest } from 'src/logic-functions/recall-api/recall-bot-api-request.util'; + +type GetRecallRecordingResult = + | { ok: true; recording: Record } + | RecallBotOperationFailure; + +export const getRecallRecording = async ({ + externalRecordingId, +}: { + externalRecordingId: string; +}): Promise => { + const configResult = getRecallApiConfig(); + + if (!configResult.success) { + return { ok: false, status: null, errorMessage: configResult.error }; + } + + const result = await recallBotApiRequest>({ + config: configResult.config, + path: `/recording/${externalRecordingId}/`, + method: 'GET', + }); + + if (!result.ok) { + return result; + } + + return { ok: true, recording: result.data ?? {} }; +}; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/recall-api/list-recall-transcripts.util.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/recall-api/list-recall-transcripts.util.ts new file mode 100644 index 0000000000..21c6d925f2 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/recall-api/list-recall-transcripts.util.ts @@ -0,0 +1,141 @@ +import { isArray, isUndefined } from '@sniptt/guards'; + +import { type RecallBotOperationFailure } from 'src/logic-functions/types/recall-bot-operation-result.type'; +import { asRecord } from 'src/logic-functions/utils/as-record.util'; +import { getString } from 'src/logic-functions/utils/get-string.util'; +import { getRecallApiConfig } from 'src/logic-functions/recall-api/get-recall-api-config.util'; +import { recallBotApiRequest } from 'src/logic-functions/recall-api/recall-bot-api-request.util'; +import { type RecallTranscriptSummary } from 'src/logic-functions/recall-api/recall-transcript-summary.type'; + +type ListRecallTranscriptsResult = + | { ok: true; transcripts: RecallTranscriptSummary[] } + | RecallBotOperationFailure; + +type RecallTranscriptListResponse = { + next?: unknown; + results?: unknown; +}; + +const RECALL_TRANSCRIPT_LIST_MAX_PAGES = 10; + +export const listRecallTranscripts = async ({ + externalRecordingId, +}: { + externalRecordingId: string; +}): Promise => { + const configResult = getRecallApiConfig(); + + if (!configResult.success) { + return { ok: false, status: null, errorMessage: configResult.error }; + } + + const transcripts: RecallTranscriptSummary[] = []; + let path: string | undefined = buildListRecallTranscriptsPath({ + externalRecordingId, + }); + + for ( + let pageIndex = 0; + !isUndefined(path) && pageIndex < RECALL_TRANSCRIPT_LIST_MAX_PAGES; + pageIndex++ + ) { + const result = await recallBotApiRequest({ + config: configResult.config, + path, + method: 'GET', + }); + + if (!result.ok) { + return result; + } + + const pageTranscripts = extractRecallTranscriptSummaries(result.data); + + if (isUndefined(pageTranscripts)) { + return { + ok: false, + status: result.status, + errorMessage: 'Recall API returned malformed transcript list', + }; + } + + transcripts.push(...pageTranscripts); + path = extractNextPath(result.data, configResult.config.baseUrl); + } + + if (!isUndefined(path)) { + return { + ok: false, + status: null, + errorMessage: `Recall transcript list exceeded ${RECALL_TRANSCRIPT_LIST_MAX_PAGES} pages`, + }; + } + + return { ok: true, transcripts }; +}; + +const buildListRecallTranscriptsPath = ({ + externalRecordingId, +}: { + externalRecordingId: string; +}): string => { + const searchParams = new URLSearchParams({ + recording_id: externalRecordingId, + }); + + return `/transcript/?${searchParams.toString()}`; +}; + +const extractRecallTranscriptSummaries = ( + response: RecallTranscriptListResponse | undefined, +): RecallTranscriptSummary[] | undefined => { + if (!isArray(response?.results)) { + return undefined; + } + + const transcripts: RecallTranscriptSummary[] = []; + + for (const result of response.results) { + const transcript = extractRecallTranscriptSummary(result); + + if (isUndefined(transcript)) { + return undefined; + } + + transcripts.push(transcript); + } + + return transcripts; +}; + +const extractRecallTranscriptSummary = ( + transcript: unknown, +): RecallTranscriptSummary | undefined => { + const transcriptRecord = asRecord(transcript); + const transcriptId = getString(transcriptRecord?.id); + + if (isUndefined(transcriptRecord) || isUndefined(transcriptId)) { + return undefined; + } + + const status = asRecord(transcriptRecord.status); + + return { + id: transcriptId, + statusCode: getString(status?.code), + statusSubCode: getString(status?.sub_code), + }; +}; + +const extractNextPath = ( + response: RecallTranscriptListResponse | undefined, + baseUrl: string, +): string | undefined => { + const nextPage = getString(response?.next); + + if (isUndefined(nextPage) || !nextPage.startsWith(baseUrl)) { + return undefined; + } + + return nextPage.slice(baseUrl.length); +}; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/recall-api/list-scheduled-recall-bots.util.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/recall-api/list-scheduled-recall-bots.util.ts new file mode 100644 index 0000000000..19fe22fae3 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/recall-api/list-scheduled-recall-bots.util.ts @@ -0,0 +1,106 @@ +import { isString, isUndefined } from '@sniptt/guards'; + +import { type RecallBotOperationFailure } from 'src/logic-functions/types/recall-bot-operation-result.type'; +import { asRecord } from 'src/logic-functions/utils/as-record.util'; +import { getRecallApiConfig } from 'src/logic-functions/recall-api/get-recall-api-config.util'; +import { recallBotApiRequest } from 'src/logic-functions/recall-api/recall-bot-api-request.util'; + +export type RecallScheduledBot = { + id: string; + metadata: Record; +}; + +type RecallBotListResponse = { + next?: unknown; + results?: unknown; +}; + +type ListScheduledRecallBotsResult = + | { ok: true; bots: RecallScheduledBot[] } + | RecallBotOperationFailure; + +const RECALL_BOT_LIST_MAX_PAGES = 10; + +export const listScheduledRecallBots = async ({ + joinAtAfter, + joinAtBefore, +}: { + joinAtAfter: string; + joinAtBefore: string; +}): Promise => { + const configResult = getRecallApiConfig(); + + if (!configResult.success) { + return { ok: false, status: null, errorMessage: configResult.error }; + } + + const bots: RecallScheduledBot[] = []; + let path: string | undefined = `/bot/?join_at_after=${encodeURIComponent( + joinAtAfter, + )}&join_at_before=${encodeURIComponent(joinAtBefore)}`; + + for ( + let pageIndex = 0; + !isUndefined(path) && pageIndex < RECALL_BOT_LIST_MAX_PAGES; + pageIndex++ + ) { + const result = await recallBotApiRequest({ + config: configResult.config, + path, + method: 'GET', + }); + + if (!result.ok) { + return result; + } + + bots.push(...extractRecallBots(result.data)); + path = extractNextPath(result.data, configResult.config.baseUrl); + } + + if (!isUndefined(path)) { + return { + ok: false, + status: null, + errorMessage: `Recall bot list exceeded ${RECALL_BOT_LIST_MAX_PAGES} pages`, + }; + } + + return { ok: true, bots }; +}; + +const extractRecallBots = ( + response: RecallBotListResponse | undefined, +): RecallScheduledBot[] => { + if (!Array.isArray(response?.results)) { + return []; + } + + return response.results.flatMap((candidate: unknown) => { + const bot = asRecord(candidate); + + if (isUndefined(bot) || !isString(bot.id)) { + return []; + } + + return [ + { + id: bot.id, + metadata: asRecord(bot.metadata) ?? {}, + }, + ]; + }); +}; + +const extractNextPath = ( + response: RecallBotListResponse | undefined, + baseUrl: string, +): string | undefined => { + const next = response?.next; + + if (!isString(next) || !next.startsWith(baseUrl)) { + return undefined; + } + + return next.slice(baseUrl.length); +}; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/recall-api/normalize-recall-timestamp.util.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/recall-api/normalize-recall-timestamp.util.ts new file mode 100644 index 0000000000..1c2dd44cde --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/recall-api/normalize-recall-timestamp.util.ts @@ -0,0 +1,14 @@ +import { isUndefined } from '@sniptt/guards'; + +// Twenty rejects Recall's microsecond precision; truncate to millisecond ISO. +export const normalizeRecallTimestamp = ( + value: string | undefined, +): string | undefined => { + if (isUndefined(value)) { + return undefined; + } + + const parsed = new Date(value); + + return Number.isNaN(parsed.getTime()) ? undefined : parsed.toISOString(); +}; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/recall-api/parse-recall-webhook-event.util.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/recall-api/parse-recall-webhook-event.util.ts new file mode 100644 index 0000000000..e1752ac8c8 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/recall-api/parse-recall-webhook-event.util.ts @@ -0,0 +1,104 @@ +import { isUndefined } from '@sniptt/guards'; + +import { asRecord } from 'src/logic-functions/utils/as-record.util'; +import { getRecordAtPath } from 'src/logic-functions/utils/get-record-at-path.util'; +import { getString } from 'src/logic-functions/utils/get-string.util'; +import { normalizeRecallTimestamp } from 'src/logic-functions/recall-api/normalize-recall-timestamp.util'; + +export type RecallWebhookBody = { + event?: unknown; + type?: unknown; + data?: unknown; + bot?: unknown; +}; + +export type RecallWebhookEvent = { + event: string; + statusCode: string | undefined; + statusTimestamp: string | undefined; + externalBotId: string | undefined; + externalRecordingId: string | undefined; + callRecordingIdFromMetadata: string | undefined; + recordingStartedAt: string | undefined; + recordingEndedAt: string | undefined; + transcriptId: string | undefined; + transcriptFailureSubCode: string | undefined; +}; + +// The only reader of raw webhook payloads; Recall delivers several body shapes per event family. +export const parseRecallWebhookEvent = ( + body: RecallWebhookBody, +): RecallWebhookEvent | undefined => { + const event = getString(body.event) ?? getString(body.type); + + if (isUndefined(event)) { + return undefined; + } + + const data = asRecord(body.data); + const bot = asRecord(body.bot); + + return { + event, + statusCode: + getString(getRecordAtPath(data, ['status', 'code'])) ?? + getString(getRecordAtPath(data, ['data', 'code'])) ?? + getString(getRecordAtPath(bot, ['status', 'code'])) ?? + getStatusCodeFromEventName(event), + statusTimestamp: normalizeRecallTimestamp( + getString(getRecordAtPath(data, ['status', 'created_at'])) ?? + getString(getRecordAtPath(data, ['data', 'updated_at'])) ?? + getString(getRecordAtPath(bot, ['status', 'created_at'])), + ), + externalBotId: + getString(data?.bot_id) ?? + getString(getRecordAtPath(data, ['bot', 'id'])) ?? + getString(getRecordAtPath(data, ['recording', 'bot_id'])) ?? + getString(getRecordAtPath(data, ['recording', 'bot', 'id'])) ?? + getString(bot?.id), + externalRecordingId: + getString(getRecordAtPath(data, ['status', 'recording_id'])) ?? + getString(getRecordAtPath(data, ['recording', 'id'])) ?? + getString(data?.recording_id), + callRecordingIdFromMetadata: extractCallRecordingIdFromMetadata({ + data, + bot, + }), + recordingStartedAt: normalizeRecallTimestamp( + getString(getRecordAtPath(data, ['recording', 'started_at'])), + ), + recordingEndedAt: normalizeRecallTimestamp( + getString(getRecordAtPath(data, ['recording', 'completed_at'])), + ), + transcriptId: getString(getRecordAtPath(data, ['transcript', 'id'])), + transcriptFailureSubCode: getString( + getRecordAtPath(data, ['status', 'sub_code']), + ), + }; +}; + +const getStatusCodeFromEventName = (event: string): string | undefined => { + if (!event.startsWith('bot.')) { + return undefined; + } + + const statusCode = event.slice('bot.'.length); + + return statusCode === 'status_change' ? undefined : statusCode; +}; + +const extractCallRecordingIdFromMetadata = ({ + data, + bot, +}: { + data: Record | undefined; + bot: Record | undefined; +}): string | undefined => { + const metadata = + asRecord(bot?.metadata) ?? + asRecord(getRecordAtPath(data, ['bot', 'metadata'])) ?? + asRecord(getRecordAtPath(data, ['recording', 'metadata'])) ?? + asRecord(data?.metadata); + + return getString(metadata?.twentyCallRecordingId); +}; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/recall-api/recall-bot-api-request.util.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/recall-api/recall-bot-api-request.util.ts new file mode 100644 index 0000000000..62374f7f4b --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/recall-api/recall-bot-api-request.util.ts @@ -0,0 +1,165 @@ +import { isUndefined } from '@sniptt/guards'; + +import { RECALL_API_MAX_ATTEMPTS } from 'src/logic-functions/constants/recall-api-max-attempts'; +import { RECALL_API_RETRY_DELAY_MS } from 'src/logic-functions/constants/recall-api-retry-delay-ms'; +import { type RecallApiConfig } from 'src/logic-functions/recall-api/get-recall-api-config.util'; + +type RecallBotApiRequestArgs = { + config: RecallApiConfig; + path: string; + method: 'GET' | 'POST' | 'PATCH' | 'DELETE'; + body?: unknown; + allowNotFound?: boolean; + maxAttempts?: number; +}; + +type RecallBotApiRequestResult = + | { + ok: true; + status: number; + data: TData; + } + | { + ok: false; + status: number | null; + errorMessage: string; + }; + +// Bot creates tolerate retries because duplicates stay unclaimed and get reaped. +// Callers that cannot retry idempotently can lower maxAttempts. +export const recallBotApiRequest = async ( + requestArgs: RecallBotApiRequestArgs, +): Promise> => { + const maxAttempts = requestArgs.maxAttempts ?? RECALL_API_MAX_ATTEMPTS; + + for (let attemptNumber = 1; ; attemptNumber++) { + const { result, isRetryable } = + await performRecallBotApiRequestAttempt(requestArgs); + + if (!isRetryable || attemptNumber >= maxAttempts) { + return result; + } + + await sleep(RECALL_API_RETRY_DELAY_MS * attemptNumber); + } +}; + +const performRecallBotApiRequestAttempt = async ({ + config, + path, + method, + body, + allowNotFound = false, +}: RecallBotApiRequestArgs): Promise<{ + result: RecallBotApiRequestResult; + isRetryable: boolean; +}> => { + let response: Response; + + try { + response = await fetch(`${config.baseUrl}${path}`, { + method, + headers: { + Authorization: buildRecallApiAuthorizationHeader(config.apiKey), + ...(isUndefined(body) ? {} : { 'Content-Type': 'application/json' }), + }, + ...(isUndefined(body) ? {} : { body: JSON.stringify(body) }), + }); + } catch (error) { + return { + isRetryable: true, + result: { + ok: false, + status: null, + errorMessage: `Recall API request failed: ${ + error instanceof Error ? error.message : String(error) + }`, + }, + }; + } + + if (allowNotFound && response.status === 404) { + return { + isRetryable: false, + result: { + ok: true, + status: response.status, + data: undefined as TData, + }, + }; + } + + if (response.status === 204) { + return { + isRetryable: false, + result: { + ok: true, + status: response.status, + data: undefined as TData, + }, + }; + } + + if (!response.ok) { + return { + isRetryable: isRetryableRecallApiStatus(response.status), + result: { + ok: false, + status: response.status, + errorMessage: await extractRecallApiErrorMessage(response), + }, + }; + } + + try { + return { + isRetryable: false, + result: { + ok: true, + status: response.status, + data: (await response.json()) as TData, + }, + }; + } catch (error) { + return { + isRetryable: false, + result: { + ok: false, + status: response.status, + errorMessage: `Recall API returned a non-JSON response: ${ + error instanceof Error ? error.message : String(error) + }`, + }, + }; + } +}; + +const isRetryableRecallApiStatus = (status: number): boolean => + status === 429 || status >= 500; + +const sleep = (delayMs: number): Promise => + new Promise((resolve) => { + setTimeout(resolve, delayMs); + }); + +const buildRecallApiAuthorizationHeader = (apiKey: string): string => { + const trimmedApiKey = apiKey.trim(); + + return trimmedApiKey.toLowerCase().startsWith('token ') + ? trimmedApiKey + : `Token ${trimmedApiKey}`; +}; + +const extractRecallApiErrorMessage = async ( + response: Response, +): Promise => { + const fallback = `Recall API responded with HTTP ${response.status}`; + + try { + const body = (await response.json()) as unknown; + + return `${fallback}: ${JSON.stringify(body)}`; + } catch { + return fallback; + } +}; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/recall-api/recall-transcript-summary.type.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/recall-api/recall-transcript-summary.type.ts new file mode 100644 index 0000000000..32f2683c89 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/recall-api/recall-transcript-summary.type.ts @@ -0,0 +1,5 @@ +export type RecallTranscriptSummary = { + id: string; + statusCode: string | undefined; + statusSubCode: string | undefined; +}; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/recall-api/reschedule-recall-bot.util.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/recall-api/reschedule-recall-bot.util.ts new file mode 100644 index 0000000000..cf6cc2d0f8 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/recall-api/reschedule-recall-bot.util.ts @@ -0,0 +1,54 @@ +import { isUndefined } from '@sniptt/guards'; + +import { getRecallBotAutomaticLeave } from 'src/logic-functions/constants/recall-bot-automatic-leave'; +import { getRecallBotRecordingConfig } from 'src/logic-functions/constants/recall-bot-recording-config'; +import { type RecallBotScheduleResult } from 'src/logic-functions/types/recall-bot-operation-result.type'; +import { + extractRecallBotId, + type RecallBotResponse, +} from 'src/logic-functions/recall-api/extract-recall-bot-id.util'; +import { getRecallApiConfig } from 'src/logic-functions/recall-api/get-recall-api-config.util'; +import { recallBotApiRequest } from 'src/logic-functions/recall-api/recall-bot-api-request.util'; +import { type ScheduleRecallBotArgs } from 'src/logic-functions/recall-api/schedule-recall-bot.util'; + +type RescheduleRecallBotArgs = ScheduleRecallBotArgs & { + externalBotId: string; +}; + +export const rescheduleRecallBot = async ({ + externalBotId, + meetingUrl, + joinAt, + metadata, +}: RescheduleRecallBotArgs): Promise => { + const configResult = getRecallApiConfig(); + + if (!configResult.success) { + return { ok: false, status: null, errorMessage: configResult.error }; + } + + const automaticLeave = getRecallBotAutomaticLeave(); + + const result = await recallBotApiRequest({ + config: configResult.config, + path: `/bot/${externalBotId}/`, + method: 'PATCH', + body: { + meeting_url: meetingUrl, + join_at: joinAt, + bot_name: configResult.config.botName, + ...(isUndefined(automaticLeave) ? {} : { automatic_leave: automaticLeave }), + recording_config: getRecallBotRecordingConfig(), + metadata, + }, + }); + + if (!result.ok) { + return result; + } + + return { + ok: true, + externalBotId: extractRecallBotId(result.data) ?? externalBotId, + }; +}; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/recall-api/retrieve-recall-transcript.util.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/recall-api/retrieve-recall-transcript.util.ts new file mode 100644 index 0000000000..a096da7c62 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/recall-api/retrieve-recall-transcript.util.ts @@ -0,0 +1,71 @@ +import { isUndefined } from '@sniptt/guards'; + +import { type RecallBotOperationFailure } from 'src/logic-functions/types/recall-bot-operation-result.type'; +import { asRecord } from 'src/logic-functions/utils/as-record.util'; +import { getRecallApiConfig } from 'src/logic-functions/recall-api/get-recall-api-config.util'; +import { getString } from 'src/logic-functions/utils/get-string.util'; +import { recallBotApiRequest } from 'src/logic-functions/recall-api/recall-bot-api-request.util'; + +export type RecallTranscriptDetails = { + downloadUrl: string | undefined; + statusCode: string | undefined; + statusSubCode: string | undefined; +}; + +type RetrieveRecallTranscriptResult = + | { ok: true; transcript: RecallTranscriptDetails } + | RecallBotOperationFailure; + +export const retrieveRecallTranscript = async ({ + transcriptId, +}: { + transcriptId: string; +}): Promise => { + const configResult = getRecallApiConfig(); + + if (!configResult.success) { + return { ok: false, status: null, errorMessage: configResult.error }; + } + + const result = await recallBotApiRequest>({ + config: configResult.config, + path: `/transcript/${transcriptId}/`, + method: 'GET', + }); + + if (!result.ok) { + return result; + } + + const transcript = extractRecallTranscriptDetails(result.data); + + if (isMalformedRecallTranscriptDetails(transcript)) { + return { + ok: false, + status: result.status, + errorMessage: 'Recall API returned malformed transcript details', + }; + } + + return { ok: true, transcript }; +}; + +const extractRecallTranscriptDetails = ( + response: Record | undefined, +): RecallTranscriptDetails => { + const data = asRecord(response?.data); + const status = asRecord(response?.status); + + return { + downloadUrl: getString(data?.download_url), + statusCode: getString(status?.code), + statusSubCode: getString(status?.sub_code), + }; +}; + +const isMalformedRecallTranscriptDetails = ({ + downloadUrl, + statusCode, +}: RecallTranscriptDetails): boolean => + (isUndefined(downloadUrl) && isUndefined(statusCode)) || + (isUndefined(downloadUrl) && statusCode === 'done'); diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/recall-api/schedule-recall-bot.util.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/recall-api/schedule-recall-bot.util.ts new file mode 100644 index 0000000000..8de80b44e1 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/recall-api/schedule-recall-bot.util.ts @@ -0,0 +1,66 @@ +import { isUndefined } from '@sniptt/guards'; + +import { getRecallBotAutomaticLeave } from 'src/logic-functions/constants/recall-bot-automatic-leave'; +import { getRecallBotRecordingConfig } from 'src/logic-functions/constants/recall-bot-recording-config'; +import { type RecallBotMetadata } from 'src/logic-functions/types/recall-bot-metadata.type'; +import { type RecallBotScheduleResult } from 'src/logic-functions/types/recall-bot-operation-result.type'; +import { + extractRecallBotId, + type RecallBotResponse, +} from 'src/logic-functions/recall-api/extract-recall-bot-id.util'; +import { getRecallApiConfig } from 'src/logic-functions/recall-api/get-recall-api-config.util'; +import { recallBotApiRequest } from 'src/logic-functions/recall-api/recall-bot-api-request.util'; + +export type ScheduleRecallBotArgs = { + meetingUrl: string; + joinAt: string; + metadata: RecallBotMetadata; +}; + +export const scheduleRecallBot = async ({ + meetingUrl, + joinAt, + metadata, +}: ScheduleRecallBotArgs): Promise => { + const configResult = getRecallApiConfig(); + + if (!configResult.success) { + return { ok: false, status: null, errorMessage: configResult.error }; + } + + const automaticLeave = getRecallBotAutomaticLeave(); + + const result = await recallBotApiRequest({ + config: configResult.config, + path: '/bot/', + method: 'POST', + body: { + meeting_url: meetingUrl, + join_at: joinAt, + bot_name: configResult.config.botName, + ...(isUndefined(automaticLeave) ? {} : { automatic_leave: automaticLeave }), + recording_config: getRecallBotRecordingConfig(), + metadata, + }, + }); + + if (!result.ok) { + return result; + } + + const externalBotId = extractRecallBotId(result.data); + + if (isUndefined(externalBotId)) { + return { + ok: false, + status: null, + errorMessage: + 'Recall API created a bot but the response did not include a bot id', + }; + } + + return { + ok: true, + externalBotId, + }; +}; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/recall-api/verify-recall-webhook-signature.util.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/recall-api/verify-recall-webhook-signature.util.ts new file mode 100644 index 0000000000..f42106aae5 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/recall-api/verify-recall-webhook-signature.util.ts @@ -0,0 +1,109 @@ +import { createHmac, timingSafeEqual } from 'crypto'; + +import { isUndefined } from '@sniptt/guards'; + +const RECALL_WEBHOOK_SECRET_PREFIX = 'whsec_'; +const RECALL_WEBHOOK_TIMESTAMP_TOLERANCE_SECONDS = 5 * 60; + +export const verifyRecallWebhookSignature = ({ + rawBody, + headers, + secret, + now = new Date(), +}: { + rawBody: string; + headers: Record; + secret: string; + now?: Date; +}): { valid: true } | { valid: false; error: string } => { + if (!secret.startsWith(RECALL_WEBHOOK_SECRET_PREFIX)) { + return { + valid: false, + error: 'Webhook secret must start with whsec_', + }; + } + + const webhookId = headers['webhook-id'] ?? headers['svix-id']; + const webhookTimestamp = + headers['webhook-timestamp'] ?? headers['svix-timestamp']; + const webhookSignature = + headers['webhook-signature'] ?? headers['svix-signature']; + + if ( + isUndefined(webhookId) || + isUndefined(webhookTimestamp) || + isUndefined(webhookSignature) + ) { + return { + valid: false, + error: 'Missing webhook signature headers', + }; + } + + const webhookTimestampSeconds = Number(webhookTimestamp); + + if (!Number.isInteger(webhookTimestampSeconds)) { + return { + valid: false, + error: 'Invalid webhook timestamp', + }; + } + + const nowSeconds = Math.floor(now.getTime() / 1000); + + if ( + Math.abs(nowSeconds - webhookTimestampSeconds) > + RECALL_WEBHOOK_TIMESTAMP_TOLERANCE_SECONDS + ) { + return { + valid: false, + error: 'Webhook timestamp is outside of the allowed tolerance', + }; + } + + const secretBytes = Buffer.from( + secret.slice(RECALL_WEBHOOK_SECRET_PREFIX.length), + 'base64', + ); + const expectedSignature = createHmac('sha256', secretBytes) + .update(`${webhookId}.${webhookTimestamp}.${rawBody}`) + .digest('base64'); + const providedSignatures = webhookSignature + .split(' ') + .map((signaturePart) => signaturePart.trim()) + .filter((signaturePart) => signaturePart !== '') + .flatMap((signaturePart) => { + if (signaturePart.startsWith('v1,') || signaturePart.startsWith('v1=')) { + return [signaturePart.slice(3).trim()]; + } + + return []; + }) + .filter((signaturePart) => signaturePart !== ''); + + if (providedSignatures.length === 0) { + return { + valid: false, + error: 'Missing v1 signature', + }; + } + + const expectedSignatureBuffer = Buffer.from(expectedSignature, 'base64'); + + for (const providedSignature of providedSignatures) { + const providedSignatureBuffer = Buffer.from(providedSignature, 'base64'); + + if (providedSignatureBuffer.length !== expectedSignatureBuffer.length) { + continue; + } + + if (timingSafeEqual(providedSignatureBuffer, expectedSignatureBuffer)) { + return { valid: true }; + } + } + + return { + valid: false, + error: 'Signature verification failed', + }; +}; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/recall-webhook.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/recall-webhook.ts new file mode 100644 index 0000000000..770989bab1 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/recall-webhook.ts @@ -0,0 +1,88 @@ +import { isNull, isUndefined } from '@sniptt/guards'; +import { CoreApiClient } from 'twenty-client-sdk/core'; +import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define'; +import { Response } from 'twenty-sdk/logic-function'; + +import { RECALL_WEBHOOK_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/recall-webhook-logic-function-universal-identifier'; +import { RECALL_WEBHOOK_SECRET_ENV_VAR_NAME } from 'src/logic-functions/constants/recall-webhook-secret-env-var-name'; +import { handleRecallWebhook } from 'src/logic-functions/flows/handle-recall-webhook.util'; +import { type RecallWebhookBody } from 'src/logic-functions/recall-api/parse-recall-webhook-event.util'; +import { verifyRecallWebhookSignature } from 'src/logic-functions/recall-api/verify-recall-webhook-signature.util'; +import { getApplicationVariableValue } from 'src/logic-functions/utils/get-application-variable-value.util'; +import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util'; + +// Non-2xx makes Svix retry; a returned plain object would 200-ack permanently. +const rejectWebhook = (status: number, error: string): Response => { + console.error(`[twenty-meeting-bot] webhook rejected: ${error}`); + + return new Response({ error }, { status }); +}; + +export const recallWebhookRouteHandler = async ( + routePayload: RoutePayload, +): Promise => { + const webhookSecret = getApplicationVariableValue( + RECALL_WEBHOOK_SECRET_ENV_VAR_NAME, + ); + + if (!isNonEmptyString(webhookSecret)) { + return rejectWebhook( + 500, + 'RECALL_WEBHOOK_SECRET server variable is not set. A server admin must copy it from the Recall webhook endpoint settings and set it on the Twenty Meeting Bot application registration.', + ); + } + + const { rawBody } = routePayload; + + if (isUndefined(rawBody)) { + return rejectWebhook( + 500, + 'Raw request body was not forwarded by the server; cannot verify the webhook signature', + ); + } + + const signatureCheck = verifyRecallWebhookSignature({ + rawBody, + headers: routePayload.headers, + secret: webhookSecret, + }); + + if (!signatureCheck.valid) { + return rejectWebhook( + 401, + `Invalid webhook signature: ${signatureCheck.error}`, + ); + } + + if (isUndefined(routePayload.body) || isNull(routePayload.body)) { + return rejectWebhook(400, 'Webhook payload was empty'); + } + + return handleRecallWebhook({ + client: new CoreApiClient(), + body: routePayload.body, + }); +}; + +export default defineLogicFunction({ + universalIdentifier: RECALL_WEBHOOK_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER, + name: 'recall-webhook', + description: + 'Receives Recall.ai webhook events and updates the matching CallRecording lifecycle status.', + timeoutSeconds: 30, + handler: recallWebhookRouteHandler, + serverWebhookTriggerSettings: { + workspaceIdResolver: { + source: 'body', + path: 'data.bot.metadata.twentyWorkspaceId', + }, + forwardedRequestHeaders: [ + 'webhook-id', + 'webhook-timestamp', + 'webhook-signature', + 'svix-id', + 'svix-timestamp', + 'svix-signature', + ], + }, +}); diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/reconcile-meeting-bot-calendar-event.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/reconcile-meeting-bot-calendar-event.ts new file mode 100644 index 0000000000..e023330383 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/reconcile-meeting-bot-calendar-event.ts @@ -0,0 +1,178 @@ +import { isUndefined } from '@sniptt/guards'; +import { CoreApiClient } from 'twenty-client-sdk/core'; +import { + defineLogicFunction, + type DatabaseEventPayload, + type ObjectRecordBaseEvent, +} from 'twenty-sdk/define'; + +import { CALENDAR_EVENT_RECONCILIATION_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/calendar-event-reconciliation-logic-function-universal-identifier'; +import { type RemovedMeetingBotOccurrence } from 'src/logic-functions/types/removed-meeting-bot-occurrence.type'; +import { computeRealMeetingKey } from 'src/logic-functions/domain/compute-real-meeting-key.util'; +import { getUniqueSortedIds } from 'src/logic-functions/utils/get-unique-sorted-ids.util'; +import { reconcileMeetingBotForCalendarEventIds } from 'src/logic-functions/flows/reconcile-meeting-bot.util'; + +const CALENDAR_EVENT_OBJECT_NAME = 'calendarEvent'; + +const MEETING_BOT_RELEVANT_CALENDAR_EVENT_FIELDS = [ + 'title', + 'meetingBotPreference', + 'conferenceLink', + 'startsAt', + 'endsAt', + 'isCanceled', + 'iCalUid', +]; + +const MEETING_BOT_KEY_CALENDAR_EVENT_FIELDS = [ + 'conferenceLink', + 'startsAt', + 'iCalUid', +]; + +type CalendarEventForDatabaseEvent = { + id: string; + conferenceLink?: { primaryLinkUrl?: string | null } | null; + iCalUid?: string | null; + startsAt?: string | null; +}; + +type CalendarEventDatabaseEvent = DatabaseEventPayload< + ObjectRecordBaseEvent +>; + +type CalendarEventReconciliationPayload = { + calendarEventIds: string[]; + removedOccurrences: RemovedMeetingBotOccurrence[]; +}; + +const handler = async ( + event: CalendarEventDatabaseEvent, +): Promise => { + const [objectName, action] = event.name.split('.'); + + if (objectName !== CALENDAR_EVENT_OBJECT_NAME) { + return { skipped: true, reason: 'not a calendar event' }; + } + + const reconciliationPayload = buildCalendarEventReconciliationPayload({ + event, + action, + }); + + if ( + reconciliationPayload.calendarEventIds.length === 0 && + reconciliationPayload.removedOccurrences.length === 0 + ) { + return { skipped: true, reason: 'no relevant calendar event change' }; + } + + const client = new CoreApiClient(); + const reconciliationResults = await reconcileMeetingBotForCalendarEventIds({ + client, + calendarEventIds: reconciliationPayload.calendarEventIds, + removedOccurrences: reconciliationPayload.removedOccurrences, + }); + + return { + reconciled: true, + calendarEventIds: reconciliationPayload.calendarEventIds, + removedOccurrenceCount: reconciliationPayload.removedOccurrences.length, + reconciliationResults, + }; +}; + +const buildCalendarEventReconciliationPayload = ({ + event, + action, +}: { + event: CalendarEventDatabaseEvent; + action: string | undefined; +}): CalendarEventReconciliationPayload => { + if (action === 'created') { + return { + calendarEventIds: getUniqueSortedIds([ + event.recordId, + event.properties.after?.id, + ]), + removedOccurrences: [], + }; + } + + if (action === 'updated') { + const updatedFields = event.properties.updatedFields ?? []; + + if (!hasRelevantFieldChange(updatedFields)) { + return { calendarEventIds: [], removedOccurrences: [] }; + } + + const removedOccurrence = hasKeyFieldChange(updatedFields) + ? buildRemovedOccurrence(event.properties.before) + : undefined; + + return { + calendarEventIds: getUniqueSortedIds([ + event.recordId, + event.properties.after?.id, + ]), + removedOccurrences: isUndefined(removedOccurrence) + ? [] + : [removedOccurrence], + }; + } + + if (action === 'deleted' || action === 'destroyed') { + const removedOccurrence = buildRemovedOccurrence(event.properties.before); + + return { + calendarEventIds: [], + removedOccurrences: isUndefined(removedOccurrence) + ? [] + : [removedOccurrence], + }; + } + + return { calendarEventIds: [], removedOccurrences: [] }; +}; + +const hasRelevantFieldChange = (updatedFields: string[]): boolean => + updatedFields.some((updatedField) => + MEETING_BOT_RELEVANT_CALENDAR_EVENT_FIELDS.includes(updatedField), + ); + +const hasKeyFieldChange = (updatedFields: string[]): boolean => + updatedFields.some((updatedField) => + MEETING_BOT_KEY_CALENDAR_EVENT_FIELDS.includes(updatedField), + ); + +const buildRemovedOccurrence = ( + calendarEvent: CalendarEventForDatabaseEvent | undefined, +): RemovedMeetingBotOccurrence | undefined => { + if (isUndefined(calendarEvent)) { + return undefined; + } + + return { + calendarEventId: calendarEvent.id, + realMeetingKey: computeRealMeetingKey({ + calendarEventId: calendarEvent.id, + conferenceLinkUrl: calendarEvent.conferenceLink?.primaryLinkUrl, + iCalUid: calendarEvent.iCalUid ?? undefined, + startsAt: calendarEvent.startsAt ?? undefined, + }), + startsAt: calendarEvent.startsAt ?? undefined, + }; +}; + +export default defineLogicFunction({ + universalIdentifier: + CALENDAR_EVENT_RECONCILIATION_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER, + name: 'reconcile-meeting-bot-calendar-event', + description: + 'Reconciles app-managed Recall bot recording requests when calendar events change.', + timeoutSeconds: 60, + handler, + databaseEventTriggerSettings: { + eventName: `${CALENDAR_EVENT_OBJECT_NAME}.*`, + }, +}); diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/reconcile-stale-bot-state.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/reconcile-stale-bot-state.ts new file mode 100644 index 0000000000..e83b36477d --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/reconcile-stale-bot-state.ts @@ -0,0 +1,110 @@ +import { CoreApiClient } from 'twenty-client-sdk/core'; +import { defineLogicFunction } from 'twenty-sdk/define'; + +import { STALE_BOT_STATE_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/stale-bot-state-logic-function-universal-identifier'; +import { STALE_BOT_STATE_CRON_PATTERN } from 'src/logic-functions/constants/stale-bot-state-cron-pattern'; +import { + convergeDivergedCallRecordings, +} from 'src/logic-functions/flows/converge-diverged-call-recordings.util'; +import { type ConvergeDivergedCallRecordingsResult } from 'src/logic-functions/flows/converge-diverged-call-recordings-result.type'; +import { + healCallRecordingsMissingBot, + type HealCallRecordingsMissingBotResult, +} from 'src/logic-functions/flows/heal-call-recordings-missing-bot.util'; +import { + reapOrphanedMeetingBots, + type ReapOrphanedMeetingBotsResult, +} from 'src/logic-functions/flows/reap-orphaned-meeting-bots.util'; + +// Every unwanted bot passes through this join_at window before it can attend. +const REAPER_JOIN_AT_LOOKBACK_HOURS = 4; +const REAPER_JOIN_AT_LOOKAHEAD_HOURS = 24; + +type StepFailure = { error: string }; + +const reconcileStaleBotStateHandler = async (): Promise => { + const now = new Date(); + const client = new CoreApiClient(); + + const botlessHealResult = await healCallRecordingsMissingBotSafely( + client, + now, + ); + const orphanedBotReapingResult = await reapOrphanedMeetingBotsInJoinAtWindow( + client, + now, + ); + const statusConvergenceResult = await convergeDivergedCallRecordingsSafely( + client, + now, + ); + + return { + botlessHealResult, + orphanedBotReapingResult, + statusConvergenceResult, + }; +}; + +const healCallRecordingsMissingBotSafely = async ( + client: CoreApiClient, + now: Date, +): Promise => { + try { + return await healCallRecordingsMissingBot({ client, now }); + } catch (error) { + return buildStepFailure('botless call recording healing', error); + } +}; + +const reapOrphanedMeetingBotsInJoinAtWindow = async ( + client: CoreApiClient, + now: Date, +): Promise => { + try { + return await reapOrphanedMeetingBots({ + client, + joinAtAfter: new Date( + now.getTime() - REAPER_JOIN_AT_LOOKBACK_HOURS * 60 * 60 * 1000, + ).toISOString(), + joinAtBefore: new Date( + now.getTime() + REAPER_JOIN_AT_LOOKAHEAD_HOURS * 60 * 60 * 1000, + ).toISOString(), + }); + } catch (error) { + return buildStepFailure('orphaned bot reaping', error); + } +}; + +const convergeDivergedCallRecordingsSafely = async ( + client: CoreApiClient, + now: Date, +): Promise => { + try { + return await convergeDivergedCallRecordings({ client, now }); + } catch (error) { + return buildStepFailure('call recording status convergence', error); + } +}; + +const buildStepFailure = (stepLabel: string, error: unknown): StepFailure => { + const errorMessage = error instanceof Error ? error.message : String(error); + + if (process.env.NODE_ENV !== 'test') { + console.error(`[twenty-meeting-bot] ${stepLabel} failed: ${errorMessage}`); + } + + return { error: `${stepLabel} failed` }; +}; + +export default defineLogicFunction({ + universalIdentifier: STALE_BOT_STATE_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER, + name: 'reconcile-stale-bot-state', + description: + 'Converges call recordings with Recall on a schedule: pulls stale bot statuses and overdue transcripts, finishes failed cancellations, schedules bots for recordings still missing one, and reaps unclaimed bots. Reads calendar events only to heal already-decided recordings, never to discover meetings.', + timeoutSeconds: 250, + handler: reconcileStaleBotStateHandler, + cronTriggerSettings: { + pattern: STALE_BOT_STATE_CRON_PATTERN, + }, +}); diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/types/calendar-event-record.type.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/types/calendar-event-record.type.ts new file mode 100644 index 0000000000..1871fee9a2 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/types/calendar-event-record.type.ts @@ -0,0 +1,5 @@ +import { type MeetingBotPolicyCalendarEventInput } from 'src/logic-functions/types/meeting-bot-policy-calendar-event-input.type'; + +export type CalendarEventRecord = MeetingBotPolicyCalendarEventInput & { + title: string | undefined; +}; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/types/call-recording-record.type.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/types/call-recording-record.type.ts new file mode 100644 index 0000000000..8d046052c8 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/types/call-recording-record.type.ts @@ -0,0 +1,15 @@ +import { type CallRecordingRequestStatus } from 'src/logic-functions/constants/call-recording-request-status'; + +// Domain read shape: absence is always undefined; null lives only on wire types. +export type CallRecordingRecord = { + id: string; + title?: string; + status?: string; + recordingRequestStatus?: CallRecordingRequestStatus; + startedAt?: string; + endedAt?: string; + calendarEventId?: string; + externalBotId?: string; + externalRecordingId?: string; + meetingBotFailureReason?: string; +}; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/types/files-field-value.type.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/types/files-field-value.type.ts new file mode 100644 index 0000000000..e75a1bddd7 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/types/files-field-value.type.ts @@ -0,0 +1 @@ +export type FilesFieldValue = { fileId: string }[]; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/types/meeting-bot-policy-calendar-event-input.type.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/types/meeting-bot-policy-calendar-event-input.type.ts new file mode 100644 index 0000000000..0a87c8114a --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/types/meeting-bot-policy-calendar-event-input.type.ts @@ -0,0 +1,10 @@ +// Domain read shape: wire composites are flattened and absence is undefined. +export type MeetingBotPolicyCalendarEventInput = { + id: string; + isCanceled: boolean; + startsAt: string | undefined; + endsAt: string | undefined; + iCalUid: string | undefined; + conferenceLinkUrl: string | undefined; + meetingBotPreference: string | undefined; +}; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/types/meeting-bot-policy-input.type.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/types/meeting-bot-policy-input.type.ts new file mode 100644 index 0000000000..5a90fd60f4 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/types/meeting-bot-policy-input.type.ts @@ -0,0 +1,9 @@ +import { type MeetingBotPreference } from 'src/constants/meeting-bot-preference'; + +export type MeetingBotPolicyInput = { + meetingBotPreference: MeetingBotPreference | undefined; + isCanceled: boolean; + startsAt: string | undefined; + endsAt: string | undefined; + conferenceLinkUrl: string | undefined; +}; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/types/meeting-bot-policy-not-required-reason.type.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/types/meeting-bot-policy-not-required-reason.type.ts new file mode 100644 index 0000000000..748a5a5dc0 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/types/meeting-bot-policy-not-required-reason.type.ts @@ -0,0 +1,5 @@ +export type MeetingBotPolicyNotRequiredReason = + | 'EVENT_CANCELED' + | 'PREFERENCE_OFF' + | 'MISSING_CONFERENCE_LINK' + | 'EVENT_NOT_UPCOMING'; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/types/meeting-bot-policy-required-reason.type.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/types/meeting-bot-policy-required-reason.type.ts new file mode 100644 index 0000000000..ef80f8eaeb --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/types/meeting-bot-policy-required-reason.type.ts @@ -0,0 +1 @@ +export type MeetingBotPolicyRequiredReason = 'RECORDING_ENABLED'; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/types/meeting-bot-policy-result-for-calendar-event.type.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/types/meeting-bot-policy-result-for-calendar-event.type.ts new file mode 100644 index 0000000000..a8c5b0762e --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/types/meeting-bot-policy-result-for-calendar-event.type.ts @@ -0,0 +1,8 @@ +import { type MeetingBotPreference } from 'src/constants/meeting-bot-preference'; +import { type MeetingBotPolicyResult } from 'src/logic-functions/types/meeting-bot-policy-result.type'; + +export type MeetingBotPolicyResultForCalendarEvent = MeetingBotPolicyResult & { + calendarEventId: string; + meetingBotPreference: MeetingBotPreference | undefined; + realMeetingKey: string; +}; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/types/meeting-bot-policy-result-for-meeting.type.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/types/meeting-bot-policy-result-for-meeting.type.ts new file mode 100644 index 0000000000..7091c0addb --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/types/meeting-bot-policy-result-for-meeting.type.ts @@ -0,0 +1,6 @@ +export type MeetingBotPolicyResultForMeeting = { + realMeetingKey: string; + shouldRequestBot: boolean; + calendarEventIds: string[]; + requestingCalendarEventIds: string[]; +}; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/types/meeting-bot-policy-result.type.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/types/meeting-bot-policy-result.type.ts new file mode 100644 index 0000000000..2b420ff94f --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/types/meeting-bot-policy-result.type.ts @@ -0,0 +1,12 @@ +import { type MeetingBotPolicyNotRequiredReason } from 'src/logic-functions/types/meeting-bot-policy-not-required-reason.type'; +import { type MeetingBotPolicyRequiredReason } from 'src/logic-functions/types/meeting-bot-policy-required-reason.type'; + +export type MeetingBotPolicyResult = + | { + shouldRequestBot: true; + reason: MeetingBotPolicyRequiredReason; + } + | { + shouldRequestBot: false; + reason: MeetingBotPolicyNotRequiredReason; + }; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/types/meeting-bot-reconciliation-result.type.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/types/meeting-bot-reconciliation-result.type.ts new file mode 100644 index 0000000000..ae463e04b5 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/types/meeting-bot-reconciliation-result.type.ts @@ -0,0 +1,16 @@ +export type MeetingBotReconciliationResult = + | { + action: 'CREATED' | 'UPDATED' | 'CANCELED'; + realMeetingKey: string; + callRecordingId: string; + } + | { + action: 'SKIPPED'; + realMeetingKey: string; + callRecordingId: string | null; + } + | { + action: 'FAILED'; + realMeetingKey: string; + errorMessage: string; + }; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/types/meeting-recording.type.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/types/meeting-recording.type.ts new file mode 100644 index 0000000000..4c672082f4 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/types/meeting-recording.type.ts @@ -0,0 +1,7 @@ +import { type CalendarEventRecord } from 'src/logic-functions/types/calendar-event-record.type'; +import { type CallRecordingRecord } from 'src/logic-functions/types/call-recording-record.type'; + +export type MeetingRecording = { + callRecording: CallRecordingRecord; + calendarEvent: CalendarEventRecord; +}; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/types/recall-bot-metadata.type.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/types/recall-bot-metadata.type.ts new file mode 100644 index 0000000000..196f08cf02 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/types/recall-bot-metadata.type.ts @@ -0,0 +1,6 @@ +export type RecallBotMetadata = { + twentyWorkspaceId: string; + twentyCallRecordingId: string; + twentyCalendarEventId: string; + twentyRealMeetingKey: string; +}; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/types/recall-bot-operation-result.type.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/types/recall-bot-operation-result.type.ts new file mode 100644 index 0000000000..3279288413 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/types/recall-bot-operation-result.type.ts @@ -0,0 +1,19 @@ +export type RecallBotOperationFailure = { + ok: false; + // null = no HTTP response (network failure), distinct from any status code. + status: number | null; + errorMessage: string; +}; + +export type RecallBotScheduleResult = + | { + ok: true; + externalBotId: string; + } + | RecallBotOperationFailure; + +export type RecallBotRemovalResult = + | { + ok: true; + } + | RecallBotOperationFailure; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/types/removed-meeting-bot-occurrence.type.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/types/removed-meeting-bot-occurrence.type.ts new file mode 100644 index 0000000000..506a3490d5 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/types/removed-meeting-bot-occurrence.type.ts @@ -0,0 +1,6 @@ +// An occurrence whose event was deleted/moved; key + start re-checks siblings. +export type RemovedMeetingBotOccurrence = { + calendarEventId: string; + realMeetingKey: string; + startsAt: string | undefined; +}; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/types/transcript-marker.type.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/types/transcript-marker.type.ts new file mode 100644 index 0000000000..205ce51d1c --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/types/transcript-marker.type.ts @@ -0,0 +1,6 @@ +export type TranscriptMarker = { + recallTranscriptId: string | null; + status: 'PENDING' | 'FAILED'; + requestedAt?: string; + subCode?: string | null; +}; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/utils/as-record.util.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/utils/as-record.util.ts new file mode 100644 index 0000000000..63e7cfcddd --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/utils/as-record.util.ts @@ -0,0 +1,6 @@ +import { isArray, isObject } from '@sniptt/guards'; + +export const asRecord = (value: unknown): Record | undefined => + isObject(value) && !isArray(value) + ? (value as Record) + : undefined; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/utils/get-application-variable-value.util.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/utils/get-application-variable-value.util.ts new file mode 100644 index 0000000000..19a9c2c7cd --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/utils/get-application-variable-value.util.ts @@ -0,0 +1,3 @@ +// Application and server variables are injected into process.env on every execution. +export const getApplicationVariableValue = (key: string): string | undefined => + process.env[key]; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/utils/get-record-at-path.util.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/utils/get-record-at-path.util.ts new file mode 100644 index 0000000000..1ee635a334 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/utils/get-record-at-path.util.ts @@ -0,0 +1,10 @@ +import { asRecord } from 'src/logic-functions/utils/as-record.util'; + +export const getRecordAtPath = ( + record: Record | undefined, + path: string[], +): unknown => + path.reduce( + (currentValue, pathPart) => asRecord(currentValue)?.[pathPart], + record, + ); diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/utils/get-string.util.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/utils/get-string.util.ts new file mode 100644 index 0000000000..f222af01b1 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/utils/get-string.util.ts @@ -0,0 +1,4 @@ +import { isNonEmptyString } from 'src/logic-functions/utils/is-non-empty-string.util'; + +export const getString = (value: unknown): string | undefined => + isNonEmptyString(value) ? value : undefined; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/utils/get-unique-sorted-ids.util.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/utils/get-unique-sorted-ids.util.ts new file mode 100644 index 0000000000..a296142c0d --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/utils/get-unique-sorted-ids.util.ts @@ -0,0 +1,8 @@ +import { isString } from '@sniptt/guards'; + +export const getUniqueSortedIds = ( + ids: Array, +): string[] => + [...new Set(ids.filter(isString))].sort((firstId, secondId) => + firstId.localeCompare(secondId), + ); diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/utils/is-non-empty-string.util.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/utils/is-non-empty-string.util.ts new file mode 100644 index 0000000000..175943f383 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/logic-functions/utils/is-non-empty-string.util.ts @@ -0,0 +1,5 @@ +import { isString } from '@sniptt/guards'; + +// Trimming variant of @sniptt/guards isNonEmptyString, for normalizing at read boundaries. +export const isNonEmptyString = (value: unknown): value is string => + isString(value) && value.trim() !== ''; diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/page-layouts/calendar-event-recording-tab.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/page-layouts/calendar-event-recording-tab.ts new file mode 100644 index 0000000000..90d63b7142 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/page-layouts/calendar-event-recording-tab.ts @@ -0,0 +1,33 @@ +import { + definePageLayoutTab, + PageLayoutTabLayoutMode, +} from 'twenty-sdk/define'; + +import { CALENDAR_EVENT_RECORD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER } from 'src/constants/calendar-event-record-page-layout-universal-identifier'; +import { CALENDAR_EVENT_RECORDING_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/constants/calendar-event-recording-front-component-universal-identifier'; +import { CALENDAR_EVENT_RECORDING_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER } from 'src/constants/calendar-event-recording-page-layout-tab-universal-identifier'; +import { CALENDAR_EVENT_RECORDING_PAGE_LAYOUT_WIDGET_UNIVERSAL_IDENTIFIER } from 'src/constants/calendar-event-recording-page-layout-widget-universal-identifier'; + +export default definePageLayoutTab({ + universalIdentifier: + CALENDAR_EVENT_RECORDING_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER, + title: 'Call Recording', + position: 25, + icon: 'IconVideo', + layoutMode: PageLayoutTabLayoutMode.CANVAS, + pageLayoutUniversalIdentifier: + CALENDAR_EVENT_RECORD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER, + widgets: [ + { + universalIdentifier: + CALENDAR_EVENT_RECORDING_PAGE_LAYOUT_WIDGET_UNIVERSAL_IDENTIFIER, + title: 'Transcript', + type: 'FRONT_COMPONENT', + configuration: { + configurationType: 'FRONT_COMPONENT', + frontComponentUniversalIdentifier: + CALENDAR_EVENT_RECORDING_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER, + }, + }, + ], +}); diff --git a/packages/twenty-apps/public/twenty-meeting-bot/src/view-fields/meeting-bot-preference-on-calendar-event.view-field.ts b/packages/twenty-apps/public/twenty-meeting-bot/src/view-fields/meeting-bot-preference-on-calendar-event.view-field.ts new file mode 100644 index 0000000000..8cba6d1ae8 --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/src/view-fields/meeting-bot-preference-on-calendar-event.view-field.ts @@ -0,0 +1,27 @@ +import { defineViewField } from 'twenty-sdk/define'; + +import { MEETING_BOT_PREFERENCE_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/meeting-bot-preference-on-calendar-event-field-universal-identifier'; +import { MEETING_BOT_PREFERENCE_ON_CALENDAR_EVENT_VIEW_FIELD_UNIVERSAL_IDENTIFIER } from 'src/constants/meeting-bot-preference-on-calendar-event-view-field-universal-identifier'; + +// TODO: hardcoded because the published twenty-sdk (2.14.0) predates the +// calendarEventRecordPageFields view. Replace both with +// STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.calendarEvent.views.calendarEventRecordPageFields +// once the SDK is republished with this view. +const CALENDAR_EVENT_RECORD_PAGE_FIELDS_VIEW_UNIVERSAL_IDENTIFIER = + 'c73668d1-022d-4eaf-b825-4e2548180db6'; +const CALENDAR_EVENT_RECORD_PAGE_FIELDS_GENERAL_GROUP_UNIVERSAL_IDENTIFIER = + 'aeadeb9e-3673-4c0c-8845-f59cb1e6ca42'; + +export default defineViewField({ + universalIdentifier: + MEETING_BOT_PREFERENCE_ON_CALENDAR_EVENT_VIEW_FIELD_UNIVERSAL_IDENTIFIER, + viewUniversalIdentifier: + CALENDAR_EVENT_RECORD_PAGE_FIELDS_VIEW_UNIVERSAL_IDENTIFIER, + viewFieldGroupUniversalIdentifier: + CALENDAR_EVENT_RECORD_PAGE_FIELDS_GENERAL_GROUP_UNIVERSAL_IDENTIFIER, + fieldMetadataUniversalIdentifier: + MEETING_BOT_PREFERENCE_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER, + position: 8, + isVisible: true, + size: 150, +}); diff --git a/packages/twenty-apps/public/twenty-meeting-bot/tsconfig.json b/packages/twenty-apps/public/twenty-meeting-bot/tsconfig.json new file mode 100644 index 0000000000..f71645f97a --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/tsconfig.json @@ -0,0 +1,42 @@ +{ + "compileOnSave": false, + "compilerOptions": { + "sourceMap": true, + "declaration": true, + "outDir": "./dist", + "rootDir": ".", + "jsx": "react-jsx", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "importHelpers": true, + "allowUnreachableCode": false, + "strict": true, + "alwaysStrict": true, + "noImplicitAny": true, + "strictBindCallApply": false, + "target": "es2018", + "module": "esnext", + "lib": ["es2020", "dom"], + "skipLibCheck": true, + "skipDefaultLibCheck": true, + "resolveJsonModule": true, + "paths": { + "src/*": ["./src/*"], + "~/*": ["./*"] + } + }, + "exclude": [ + "node_modules", + "dist", + "**/*.test.ts", + "**/*.spec.ts", + "**/*.integration-test.ts" + ], + "references": [ + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/packages/twenty-apps/internal/twenty-slack/tsconfig.spec.json b/packages/twenty-apps/public/twenty-meeting-bot/tsconfig.spec.json similarity index 100% rename from packages/twenty-apps/internal/twenty-slack/tsconfig.spec.json rename to packages/twenty-apps/public/twenty-meeting-bot/tsconfig.spec.json diff --git a/packages/twenty-apps/internal/twenty-slack/vitest.config.ts b/packages/twenty-apps/public/twenty-meeting-bot/vitest.config.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-slack/vitest.config.ts rename to packages/twenty-apps/public/twenty-meeting-bot/vitest.config.ts diff --git a/packages/twenty-apps/internal/twenty-slack/vitest.unit.config.ts b/packages/twenty-apps/public/twenty-meeting-bot/vitest.unit.config.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-slack/vitest.unit.config.ts rename to packages/twenty-apps/public/twenty-meeting-bot/vitest.unit.config.ts diff --git a/packages/twenty-apps/public/twenty-meeting-bot/yarn.lock b/packages/twenty-apps/public/twenty-meeting-bot/yarn.lock new file mode 100644 index 0000000000..2282a5403f --- /dev/null +++ b/packages/twenty-apps/public/twenty-meeting-bot/yarn.lock @@ -0,0 +1,3396 @@ +# This file is generated by running "yarn install" inside your project. +# Manual changes might be lost - proceed with caution! + +__metadata: + version: 8 + cacheKey: 10c0 + +"@alcalzone/ansi-tokenize@npm:^0.2.4": + version: 0.2.5 + resolution: "@alcalzone/ansi-tokenize@npm:0.2.5" + dependencies: + ansi-styles: "npm:^6.2.1" + is-fullwidth-code-point: "npm:^5.0.0" + checksum: 10c0/dd8622288426b5b7dbf8b68d51d4b930cea591d0e3b9dbc3f523131464d78ac922c165fcb7ba5307f133d35dbcaa0e6648a1c3fb6a0dc2725546d6f867b70af4 + languageName: node + linkType: hard + +"@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/code-frame@npm:7.29.7" + dependencies: + "@babel/helper-validator-identifier": "npm:^7.29.7" + js-tokens: "npm:^4.0.0" + picocolors: "npm:^1.1.1" + checksum: 10c0/169fc2080169a40c1760155eaaaf739bcb882df0bec76a83adbda5493645bc17270a3434b8848c494b1933e96fe1d147370001e3cda09a39f43ae30f08ef2069 + languageName: node + linkType: hard + +"@babel/generator@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/generator@npm:7.29.7" + dependencies: + "@babel/parser": "npm:^7.29.7" + "@babel/types": "npm:^7.29.7" + "@jridgewell/gen-mapping": "npm:^0.3.12" + "@jridgewell/trace-mapping": "npm:^0.3.28" + jsesc: "npm:^3.0.2" + checksum: 10c0/9bf72b01b5bd0ea5b1288a0e37dbd360bff2f2b1ce73342c0d40fb3db2ec3dc004ada5ffa925c5e12939a416eed59e600d562b8ecd938ce0d27dfd0eb6c6c2b7 + languageName: node + linkType: hard + +"@babel/helper-globals@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-globals@npm:7.29.7" + checksum: 10c0/f38417c40b1129a1b2b519ca961b9040c8827d1444fd74068702286b91b77089431dc76b6b9d5c1496e5da2a4f3ad329c6946e688ba3fa0d1d0b3d2b4f34f36a + languageName: node + linkType: hard + +"@babel/helper-module-imports@npm:^7.16.7": + version: 7.29.7 + resolution: "@babel/helper-module-imports@npm:7.29.7" + dependencies: + "@babel/traverse": "npm:^7.29.7" + "@babel/types": "npm:^7.29.7" + checksum: 10c0/6adf60d97356027413342a092f818d9678c4f5caff716a33e3284b5ae14e47a9e88059d421dde4ee4894691260039a12602c0e7becadc175602194b40dfa345d + languageName: node + linkType: hard + +"@babel/helper-string-parser@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-string-parser@npm:7.29.7" + checksum: 10c0/194bc0f1716e396d5ffde56ad6119745fb9557662c98611590e5e454906783a4ccb21ce93056b8eb69a4909044834e45d96e50ac695bbe9e3221648fe033c06c + languageName: node + linkType: hard + +"@babel/helper-validator-identifier@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-validator-identifier@npm:7.29.7" + checksum: 10c0/4795354e7ae0dcafa72de1cd04ec51252dc1498517170beaf019e03effc5b7bf13c6b21a3949a77e07b8125be7f106ed1131350d8ebd4566ae874094a726d62b + languageName: node + linkType: hard + +"@babel/parser@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/parser@npm:7.29.7" + dependencies: + "@babel/types": "npm:^7.29.7" + bin: + parser: ./bin/babel-parser.js + checksum: 10c0/65133038f80b54a714d6027cb77cee3f9a6b5c4c6842ce674301e13947cbcbfa8055e63acaf1b84c085d34226a14425b2c2b97b829e0e226d2e8f1299942a51d + languageName: node + linkType: hard + +"@babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.18.3": + version: 7.29.7 + resolution: "@babel/runtime@npm:7.29.7" + checksum: 10c0/ca11572f7146b21e0bde6a9ed4bb6a89eafbee5f0944c7eb54d0d8a2dac962c33638a1d611e14faa71dfbb92b4b5f9236232208568a6b7d5c6f3f39ddb91771e + languageName: node + linkType: hard + +"@babel/template@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/template@npm:7.29.7" + dependencies: + "@babel/code-frame": "npm:^7.29.7" + "@babel/parser": "npm:^7.29.7" + "@babel/types": "npm:^7.29.7" + checksum: 10c0/8bb7f900dcab0e9e1c5ffbc33ca10e0d26b7b2e2ca804becb73ee771b9c4ed6e2908a4ae4a14c08560febb45d2b6b9a173955e42ad404d05f8b04840a14d9c58 + languageName: node + linkType: hard + +"@babel/traverse@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/traverse@npm:7.29.7" + dependencies: + "@babel/code-frame": "npm:^7.29.7" + "@babel/generator": "npm:^7.29.7" + "@babel/helper-globals": "npm:^7.29.7" + "@babel/parser": "npm:^7.29.7" + "@babel/template": "npm:^7.29.7" + "@babel/types": "npm:^7.29.7" + debug: "npm:^4.3.1" + checksum: 10c0/e256a1fbdb956555b76f3c285b1e453f6bedec8b3afb61751d99d933efd11c7d79caf5ddf2493570058a9f7deaa1b48324380d7c1aa1443fd9508becbf56331a + languageName: node + linkType: hard + +"@babel/types@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/types@npm:7.29.7" + dependencies: + "@babel/helper-string-parser": "npm:^7.29.7" + "@babel/helper-validator-identifier": "npm:^7.29.7" + checksum: 10c0/b6623994c69717fa27294f5fa46d59140338e2d86c6c1c13085c84ef7d53086ee357fbf4fe9abe3dd3da75734dc77c4c0df2f90fb29e667558bb3b3fb705e88f + languageName: node + linkType: hard + +"@emnapi/core@npm:1.10.0": + version: 1.10.0 + resolution: "@emnapi/core@npm:1.10.0" + dependencies: + "@emnapi/wasi-threads": "npm:1.2.1" + tslib: "npm:^2.4.0" + checksum: 10c0/f51d08227857b60632de7714d708124f0e100a1462dde6df8221760939aa3204a73193830371830fac0716f3ccd2129f2cac1b17cd7d7958bc4da9018a296edb + languageName: node + linkType: hard + +"@emnapi/runtime@npm:1.10.0": + version: 1.10.0 + resolution: "@emnapi/runtime@npm:1.10.0" + dependencies: + tslib: "npm:^2.4.0" + checksum: 10c0/953f14991d1aefb92ee6f8eb27dea725e484791a53a0cb5f47d9e0087b9a2c929ff2e92adf95af15d6ad456db6300c6b761ebf72b50a875b874a83520b3ba093 + languageName: node + linkType: hard + +"@emnapi/wasi-threads@npm:1.2.1": + version: 1.2.1 + resolution: "@emnapi/wasi-threads@npm:1.2.1" + dependencies: + tslib: "npm:^2.4.0" + checksum: 10c0/32fcfa81ab396533b2ec1f4082b1ff779a05d9c836bbbd3f4398405b0e6814c0d9503b7993130e37bc6941dbc1ded49f55e9700ae9ca4e803bab2b5bc5deb331 + languageName: node + linkType: hard + +"@emotion/babel-plugin@npm:^11.13.5": + version: 11.13.5 + resolution: "@emotion/babel-plugin@npm:11.13.5" + dependencies: + "@babel/helper-module-imports": "npm:^7.16.7" + "@babel/runtime": "npm:^7.18.3" + "@emotion/hash": "npm:^0.9.2" + "@emotion/memoize": "npm:^0.9.0" + "@emotion/serialize": "npm:^1.3.3" + babel-plugin-macros: "npm:^3.1.0" + convert-source-map: "npm:^1.5.0" + escape-string-regexp: "npm:^4.0.0" + find-root: "npm:^1.1.0" + source-map: "npm:^0.5.7" + stylis: "npm:4.2.0" + checksum: 10c0/8ccbfec7defd0e513cb8a1568fa179eac1e20c35fda18aed767f6c59ea7314363ebf2de3e9d2df66c8ad78928dc3dceeded84e6fa8059087cae5c280090aeeeb + languageName: node + linkType: hard + +"@emotion/cache@npm:^11.14.0": + version: 11.14.0 + resolution: "@emotion/cache@npm:11.14.0" + dependencies: + "@emotion/memoize": "npm:^0.9.0" + "@emotion/sheet": "npm:^1.4.0" + "@emotion/utils": "npm:^1.4.2" + "@emotion/weak-memoize": "npm:^0.4.0" + stylis: "npm:4.2.0" + checksum: 10c0/3fa3e7a431ab6f8a47c67132a00ac8358f428c1b6c8421d4b20de9df7c18e95eec04a5a6ff5a68908f98d3280044f247b4965ac63df8302d2c94dba718769724 + languageName: node + linkType: hard + +"@emotion/hash@npm:^0.9.2": + version: 0.9.2 + resolution: "@emotion/hash@npm:0.9.2" + checksum: 10c0/0dc254561a3cc0a06a10bbce7f6a997883fd240c8c1928b93713f803a2e9153a257a488537012efe89dbe1246f2abfe2add62cdb3471a13d67137fcb808e81c2 + languageName: node + linkType: hard + +"@emotion/is-prop-valid@npm:^1.3.0": + version: 1.4.0 + resolution: "@emotion/is-prop-valid@npm:1.4.0" + dependencies: + "@emotion/memoize": "npm:^0.9.0" + checksum: 10c0/5f857814ec7d8c7e727727346dfb001af6b1fb31d621a3ce9c3edf944a484d8b0d619546c30899ae3ade2f317c76390ba4394449728e9bf628312defc2c41ac3 + languageName: node + linkType: hard + +"@emotion/memoize@npm:^0.9.0": + version: 0.9.0 + resolution: "@emotion/memoize@npm:0.9.0" + checksum: 10c0/13f474a9201c7f88b543e6ea42f55c04fb2fdc05e6c5a3108aced2f7e7aa7eda7794c56bba02985a46d8aaa914fcdde238727a98341a96e2aec750d372dadd15 + languageName: node + linkType: hard + +"@emotion/react@npm:^11.14.0": + version: 11.14.0 + resolution: "@emotion/react@npm:11.14.0" + dependencies: + "@babel/runtime": "npm:^7.18.3" + "@emotion/babel-plugin": "npm:^11.13.5" + "@emotion/cache": "npm:^11.14.0" + "@emotion/serialize": "npm:^1.3.3" + "@emotion/use-insertion-effect-with-fallbacks": "npm:^1.2.0" + "@emotion/utils": "npm:^1.4.2" + "@emotion/weak-memoize": "npm:^0.4.0" + hoist-non-react-statics: "npm:^3.3.1" + peerDependencies: + react: ">=16.8.0" + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10c0/d0864f571a9f99ec643420ef31fde09e2006d3943a6aba079980e4d5f6e9f9fecbcc54b8f617fe003c00092ff9d5241179149ffff2810cb05cf72b4620cfc031 + languageName: node + linkType: hard + +"@emotion/serialize@npm:^1.3.3": + version: 1.3.3 + resolution: "@emotion/serialize@npm:1.3.3" + dependencies: + "@emotion/hash": "npm:^0.9.2" + "@emotion/memoize": "npm:^0.9.0" + "@emotion/unitless": "npm:^0.10.0" + "@emotion/utils": "npm:^1.4.2" + csstype: "npm:^3.0.2" + checksum: 10c0/b28cb7de59de382021de2b26c0c94ebbfb16967a1b969a56fdb6408465a8993df243bfbd66430badaa6800e1834724e84895f5a6a9d97d0d224de3d77852acb4 + languageName: node + linkType: hard + +"@emotion/sheet@npm:^1.4.0": + version: 1.4.0 + resolution: "@emotion/sheet@npm:1.4.0" + checksum: 10c0/3ca72d1650a07d2fbb7e382761b130b4a887dcd04e6574b2d51ce578791240150d7072a9bcb4161933abbcd1e38b243a6fb4464a7fe991d700c17aa66bb5acc7 + languageName: node + linkType: hard + +"@emotion/styled@npm:^11.14.0": + version: 11.14.1 + resolution: "@emotion/styled@npm:11.14.1" + dependencies: + "@babel/runtime": "npm:^7.18.3" + "@emotion/babel-plugin": "npm:^11.13.5" + "@emotion/is-prop-valid": "npm:^1.3.0" + "@emotion/serialize": "npm:^1.3.3" + "@emotion/use-insertion-effect-with-fallbacks": "npm:^1.2.0" + "@emotion/utils": "npm:^1.4.2" + peerDependencies: + "@emotion/react": ^11.0.0-rc.0 + react: ">=16.8.0" + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10c0/2bbf8451df49c967e41fbcf8111a7f6dafe6757f0cc113f2f6e287206c45ac1d54dc8a95a483b7c0cee8614b8a8d08155bded6453d6721de1f8cc8d5b9216963 + languageName: node + linkType: hard + +"@emotion/unitless@npm:^0.10.0": + version: 0.10.0 + resolution: "@emotion/unitless@npm:0.10.0" + checksum: 10c0/150943192727b7650eb9a6851a98034ddb58a8b6958b37546080f794696141c3760966ac695ab9af97efe10178690987aee4791f9f0ad1ff76783cdca83c1d49 + languageName: node + linkType: hard + +"@emotion/use-insertion-effect-with-fallbacks@npm:^1.2.0": + version: 1.2.0 + resolution: "@emotion/use-insertion-effect-with-fallbacks@npm:1.2.0" + peerDependencies: + react: ">=16.8.0" + checksum: 10c0/074dbc92b96bdc09209871070076e3b0351b6b47efefa849a7d9c37ab142130767609ca1831da0055988974e3b895c1de7606e4c421fecaa27c3e56a2afd3b08 + languageName: node + linkType: hard + +"@emotion/utils@npm:^1.4.2": + version: 1.4.2 + resolution: "@emotion/utils@npm:1.4.2" + checksum: 10c0/7d0010bf60a2a8c1a033b6431469de4c80e47aeb8fd856a17c1d1f76bbc3a03161a34aeaa78803566e29681ca551e7bf9994b68e9c5f5c796159923e44f78d9a + languageName: node + linkType: hard + +"@emotion/weak-memoize@npm:^0.4.0": + version: 0.4.0 + resolution: "@emotion/weak-memoize@npm:0.4.0" + checksum: 10c0/64376af11f1266042d03b3305c30b7502e6084868e33327e944b539091a472f089db307af69240f7188f8bc6b319276fd7b141a36613f1160d73d12a60f6ca1a + languageName: node + linkType: hard + +"@esbuild/aix-ppc64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/aix-ppc64@npm:0.28.1" + conditions: os=aix & cpu=ppc64 + languageName: node + linkType: hard + +"@esbuild/android-arm64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/android-arm64@npm:0.28.1" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/android-arm@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/android-arm@npm:0.28.1" + conditions: os=android & cpu=arm + languageName: node + linkType: hard + +"@esbuild/android-x64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/android-x64@npm:0.28.1" + conditions: os=android & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/darwin-arm64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/darwin-arm64@npm:0.28.1" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/darwin-x64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/darwin-x64@npm:0.28.1" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/freebsd-arm64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/freebsd-arm64@npm:0.28.1" + conditions: os=freebsd & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/freebsd-x64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/freebsd-x64@npm:0.28.1" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/linux-arm64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/linux-arm64@npm:0.28.1" + conditions: os=linux & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/linux-arm@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/linux-arm@npm:0.28.1" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"@esbuild/linux-ia32@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/linux-ia32@npm:0.28.1" + conditions: os=linux & cpu=ia32 + languageName: node + linkType: hard + +"@esbuild/linux-loong64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/linux-loong64@npm:0.28.1" + conditions: os=linux & cpu=loong64 + languageName: node + linkType: hard + +"@esbuild/linux-mips64el@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/linux-mips64el@npm:0.28.1" + conditions: os=linux & cpu=mips64el + languageName: node + linkType: hard + +"@esbuild/linux-ppc64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/linux-ppc64@npm:0.28.1" + conditions: os=linux & cpu=ppc64 + languageName: node + linkType: hard + +"@esbuild/linux-riscv64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/linux-riscv64@npm:0.28.1" + conditions: os=linux & cpu=riscv64 + languageName: node + linkType: hard + +"@esbuild/linux-s390x@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/linux-s390x@npm:0.28.1" + conditions: os=linux & cpu=s390x + languageName: node + linkType: hard + +"@esbuild/linux-x64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/linux-x64@npm:0.28.1" + conditions: os=linux & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/netbsd-arm64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/netbsd-arm64@npm:0.28.1" + conditions: os=netbsd & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/netbsd-x64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/netbsd-x64@npm:0.28.1" + conditions: os=netbsd & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/openbsd-arm64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/openbsd-arm64@npm:0.28.1" + conditions: os=openbsd & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/openbsd-x64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/openbsd-x64@npm:0.28.1" + conditions: os=openbsd & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/openharmony-arm64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/openharmony-arm64@npm:0.28.1" + conditions: os=openharmony & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/sunos-x64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/sunos-x64@npm:0.28.1" + conditions: os=sunos & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/win32-arm64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/win32-arm64@npm:0.28.1" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/win32-ia32@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/win32-ia32@npm:0.28.1" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + +"@esbuild/win32-x64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/win32-x64@npm:0.28.1" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@genql/runtime@npm:^2.10.0": + version: 2.10.0 + resolution: "@genql/runtime@npm:2.10.0" + dependencies: + "@types/qs": "npm:^6.9.0" + "@types/ws": "npm:^6.0.1" + graphql-query-batcher: "npm:^1.0.1" + isomorphic-unfetch: "npm:^3.0.0" + lodash: "npm:^4.17.20" + subscriptions-transport-ws: "npm:^0.9.16" + tslib: "npm:^2.0.0" + utility-types: "npm:^3.10.0" + ws: "npm:^6.1.4" + zen-observable-ts: "npm:^0.8.21" + peerDependencies: + graphql: "*" + checksum: 10c0/e2a886c2469c933681e2b0ddd6a5b7f4cb12932251ba460e3cf2db4246817da79313ea4ba9769ec7cbe53ab9c1cb81ad8fcce6a969cd241185b79398d2a4f3c6 + languageName: node + linkType: hard + +"@inquirer/ansi@npm:^2.0.7": + version: 2.0.7 + resolution: "@inquirer/ansi@npm:2.0.7" + checksum: 10c0/a574f97a899f0d9346fa26b528b3f4a9ba6dcb9172288efb6b4314d8486470ed53d2f538200f66a25b843c6e0cbf83688c6d5174a8dc6eca853b291b09609c5a + languageName: node + linkType: hard + +"@inquirer/checkbox@npm:^5.2.1": + version: 5.2.1 + resolution: "@inquirer/checkbox@npm:5.2.1" + dependencies: + "@inquirer/ansi": "npm:^2.0.7" + "@inquirer/core": "npm:^11.2.1" + "@inquirer/figures": "npm:^2.0.7" + "@inquirer/type": "npm:^4.0.7" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 10c0/e3a845156c718fbbec228a0e7fd2a4f10775185615eac1f405b3a2bb2ae607dda6baf178fbd6487db0e12e5e33014536e81acefe65de57cd476a7aeaea6fb35d + languageName: node + linkType: hard + +"@inquirer/confirm@npm:^6.1.1": + version: 6.1.1 + resolution: "@inquirer/confirm@npm:6.1.1" + dependencies: + "@inquirer/core": "npm:^11.2.1" + "@inquirer/type": "npm:^4.0.7" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 10c0/4684406161c09327df830b4026f3165b31e13831276d215051586408ed434423263b15686393ce95a4b55058c1b7f9b08aa4b66f5ac930b47523fff75051d36f + languageName: node + linkType: hard + +"@inquirer/core@npm:^11.2.1": + version: 11.2.1 + resolution: "@inquirer/core@npm:11.2.1" + dependencies: + "@inquirer/ansi": "npm:^2.0.7" + "@inquirer/figures": "npm:^2.0.7" + "@inquirer/type": "npm:^4.0.7" + cli-width: "npm:^4.1.0" + fast-wrap-ansi: "npm:^0.2.0" + mute-stream: "npm:^3.0.0" + signal-exit: "npm:^4.1.0" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 10c0/b5be386cecd9e441ac2f9d3417a6ae1c4658b3ee6cdf5dae791211400f4de158851f81fca2245e2062833716f95366b9e1717770828cb7365e756c16e822f0d2 + languageName: node + linkType: hard + +"@inquirer/editor@npm:^5.2.2": + version: 5.2.2 + resolution: "@inquirer/editor@npm:5.2.2" + dependencies: + "@inquirer/core": "npm:^11.2.1" + "@inquirer/external-editor": "npm:^3.0.3" + "@inquirer/type": "npm:^4.0.7" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 10c0/a75e7012aad3ccc3ec84893463ed689924515a6cbaee8e2a475769fb27c12bcf359fcdd5f62e92107fc4be88fd69444c15adf13071982efc140c7015a6604d9a + languageName: node + linkType: hard + +"@inquirer/expand@npm:^5.1.1": + version: 5.1.1 + resolution: "@inquirer/expand@npm:5.1.1" + dependencies: + "@inquirer/core": "npm:^11.2.1" + "@inquirer/type": "npm:^4.0.7" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 10c0/4de661a042147759ce351971a4f92010ab66cb76450424e7d8aec5de79b449d14e8751f54f557d0b047180bca2b76707626f4835ee46603c6200139c31b59652 + languageName: node + linkType: hard + +"@inquirer/external-editor@npm:^3.0.3": + version: 3.0.3 + resolution: "@inquirer/external-editor@npm:3.0.3" + dependencies: + chardet: "npm:^2.1.1" + iconv-lite: "npm:^0.7.2" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 10c0/fc24ddbff15f0874db6498c16d2fe153bb19a670d83605f480486d6ff0ea872ae2f32a548fd555797989db09850fa019a6b5e49a8d40cfee0d7deacad17edc1e + languageName: node + linkType: hard + +"@inquirer/figures@npm:^2.0.7": + version: 2.0.7 + resolution: "@inquirer/figures@npm:2.0.7" + checksum: 10c0/e0573dc9ad25fa3628d5164745e52852d8cd832a9918605b7716df2e37a0005a0aaf40b6d81cef2ca09cb708b200e61b82d1dcd17003f572577e233c19a9ec7b + languageName: node + linkType: hard + +"@inquirer/input@npm:^5.1.2": + version: 5.1.2 + resolution: "@inquirer/input@npm:5.1.2" + dependencies: + "@inquirer/core": "npm:^11.2.1" + "@inquirer/type": "npm:^4.0.7" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 10c0/9c3614f8d79fc2bec07a088858a58967a162046c9292b0c6f0c6f63396cf391181cfb54bb636f5b3dce8d23924c24ee4a0310183a493c94190e4750218f3744d + languageName: node + linkType: hard + +"@inquirer/number@npm:^4.1.1": + version: 4.1.1 + resolution: "@inquirer/number@npm:4.1.1" + dependencies: + "@inquirer/core": "npm:^11.2.1" + "@inquirer/type": "npm:^4.0.7" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 10c0/06210dd70bf89d35242af43009b5e3f76a5f07d6275a9d842bbed61536032f5f349ecba1c20012975d7b0362deb89746d5903e90f21516da10bfd8c2055829a9 + languageName: node + linkType: hard + +"@inquirer/password@npm:^5.1.1": + version: 5.1.1 + resolution: "@inquirer/password@npm:5.1.1" + dependencies: + "@inquirer/ansi": "npm:^2.0.7" + "@inquirer/core": "npm:^11.2.1" + "@inquirer/type": "npm:^4.0.7" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 10c0/e4cb3a53b56743808f83958a8fe85738d721599690a4bc7640eaa07fb8f4765bc6f174e40c16efcc8a5958a7169667e240714a706a36e831f9c7a6822cbe8b55 + languageName: node + linkType: hard + +"@inquirer/prompts@npm:^8.5.2": + version: 8.5.2 + resolution: "@inquirer/prompts@npm:8.5.2" + dependencies: + "@inquirer/checkbox": "npm:^5.2.1" + "@inquirer/confirm": "npm:^6.1.1" + "@inquirer/editor": "npm:^5.2.2" + "@inquirer/expand": "npm:^5.1.1" + "@inquirer/input": "npm:^5.1.2" + "@inquirer/number": "npm:^4.1.1" + "@inquirer/password": "npm:^5.1.1" + "@inquirer/rawlist": "npm:^5.3.1" + "@inquirer/search": "npm:^4.2.1" + "@inquirer/select": "npm:^5.2.1" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 10c0/253b92e31c6a1f8f00a778eda8196bb53fc931723f7db4a03937f38ccd4d07c987766d4f60b9250b5d90bfe30b04747dfa73927a9f6fb886bd48091ccb202535 + languageName: node + linkType: hard + +"@inquirer/rawlist@npm:^5.3.1": + version: 5.3.1 + resolution: "@inquirer/rawlist@npm:5.3.1" + dependencies: + "@inquirer/core": "npm:^11.2.1" + "@inquirer/type": "npm:^4.0.7" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 10c0/a05eb6a16633bd7b32b3b6b2c1f645e6e28d955475b21ba7222e7e66806b1be15be9f91235b2933e8d27e04fdad81ebfb2d64fc1fc0d4b8d82dcd2718817b2b9 + languageName: node + linkType: hard + +"@inquirer/search@npm:^4.2.1": + version: 4.2.1 + resolution: "@inquirer/search@npm:4.2.1" + dependencies: + "@inquirer/core": "npm:^11.2.1" + "@inquirer/figures": "npm:^2.0.7" + "@inquirer/type": "npm:^4.0.7" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 10c0/eae9b2d524aae58266f96987696be22ad62f608661d28763c413f2560094d571a39d72a40630d2470f503ad5e6e50d8c574a653cc028bf79b51104fa91f59a67 + languageName: node + linkType: hard + +"@inquirer/select@npm:^5.2.1": + version: 5.2.1 + resolution: "@inquirer/select@npm:5.2.1" + dependencies: + "@inquirer/ansi": "npm:^2.0.7" + "@inquirer/core": "npm:^11.2.1" + "@inquirer/figures": "npm:^2.0.7" + "@inquirer/type": "npm:^4.0.7" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 10c0/3c6d0151b5a29111254a58eaf8b93bb4c476e68b0751526d8bff61a4bea457bdbe782890ae33977c5d2015f436596b5d32a8bb0677bfdf610f5f5998e65f5a92 + languageName: node + linkType: hard + +"@inquirer/type@npm:^4.0.7": + version: 4.0.7 + resolution: "@inquirer/type@npm:4.0.7" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 10c0/80678ac1c6e19ce309909e4a54a69adc95697ea3abc2cb92f17b1bc52f4caadbcb4003ae7339fb5a70c0d36d3bde975e1bb4450069662f41c953a0d28695bb70 + languageName: node + linkType: hard + +"@isaacs/fs-minipass@npm:^4.0.0": + version: 4.0.1 + resolution: "@isaacs/fs-minipass@npm:4.0.1" + dependencies: + minipass: "npm:^7.0.4" + checksum: 10c0/c25b6dc1598790d5b55c0947a9b7d111cfa92594db5296c3b907e2f533c033666f692a3939eadac17b1c7c40d362d0b0635dc874cbfe3e70db7c2b07cc97a5d2 + languageName: node + linkType: hard + +"@jridgewell/gen-mapping@npm:^0.3.12": + version: 0.3.13 + resolution: "@jridgewell/gen-mapping@npm:0.3.13" + dependencies: + "@jridgewell/sourcemap-codec": "npm:^1.5.0" + "@jridgewell/trace-mapping": "npm:^0.3.24" + checksum: 10c0/9a7d65fb13bd9aec1fbab74cda08496839b7e2ceb31f5ab922b323e94d7c481ce0fc4fd7e12e2610915ed8af51178bdc61e168e92a8c8b8303b030b03489b13b + languageName: node + linkType: hard + +"@jridgewell/resolve-uri@npm:^3.1.0": + version: 3.1.2 + resolution: "@jridgewell/resolve-uri@npm:3.1.2" + checksum: 10c0/d502e6fb516b35032331406d4e962c21fe77cdf1cbdb49c6142bcbd9e30507094b18972778a6e27cbad756209cfe34b1a27729e6fa08a2eb92b33943f680cf1e + languageName: node + linkType: hard + +"@jridgewell/sourcemap-codec@npm:^1.4.14, @jridgewell/sourcemap-codec@npm:^1.5.0, @jridgewell/sourcemap-codec@npm:^1.5.5": + version: 1.5.5 + resolution: "@jridgewell/sourcemap-codec@npm:1.5.5" + checksum: 10c0/f9e538f302b63c0ebc06eecb1dd9918dd4289ed36147a0ddce35d6ea4d7ebbda243cda7b2213b6a5e1d8087a298d5cf630fb2bd39329cdecb82017023f6081a0 + languageName: node + linkType: hard + +"@jridgewell/trace-mapping@npm:^0.3.24, @jridgewell/trace-mapping@npm:^0.3.28": + version: 0.3.31 + resolution: "@jridgewell/trace-mapping@npm:0.3.31" + dependencies: + "@jridgewell/resolve-uri": "npm:^3.1.0" + "@jridgewell/sourcemap-codec": "npm:^1.4.14" + checksum: 10c0/4b30ec8cd56c5fd9a661f088230af01e0c1a3888d11ffb6b47639700f71225be21d1f7e168048d6d4f9449207b978a235c07c8f15c07705685d16dc06280e9d9 + languageName: node + linkType: hard + +"@napi-rs/wasm-runtime@npm:^1.1.4": + version: 1.1.5 + resolution: "@napi-rs/wasm-runtime@npm:1.1.5" + dependencies: + "@tybys/wasm-util": "npm:^0.10.2" + peerDependencies: + "@emnapi/core": ^1.7.1 + "@emnapi/runtime": ^1.7.1 + checksum: 10c0/727f2b6ae0e68bbe5d39aeb68aa6f183314e9f03dc50bb55a962849535b2db53ecc3fbf1554d8656a54488a608df5a2634670595cf5874dc4af2ee59f817c65d + languageName: node + linkType: hard + +"@oxc-project/types@npm:=0.133.0": + version: 0.133.0 + resolution: "@oxc-project/types@npm:0.133.0" + checksum: 10c0/70c57ba58644f7ec217b670c301801f4d06995f4ccdba6b2bd106ea3e5ee49d616573e6ef8d55530b87571a960696543687f3850e87ad173d3f88965c30cdd63 + languageName: node + linkType: hard + +"@oxlint/darwin-arm64@npm:0.16.12": + version: 0.16.12 + resolution: "@oxlint/darwin-arm64@npm:0.16.12" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@oxlint/darwin-x64@npm:0.16.12": + version: 0.16.12 + resolution: "@oxlint/darwin-x64@npm:0.16.12" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@oxlint/linux-arm64-gnu@npm:0.16.12": + version: 0.16.12 + resolution: "@oxlint/linux-arm64-gnu@npm:0.16.12" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@oxlint/linux-arm64-musl@npm:0.16.12": + version: 0.16.12 + resolution: "@oxlint/linux-arm64-musl@npm:0.16.12" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@oxlint/linux-x64-gnu@npm:0.16.12": + version: 0.16.12 + resolution: "@oxlint/linux-x64-gnu@npm:0.16.12" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@oxlint/linux-x64-musl@npm:0.16.12": + version: 0.16.12 + resolution: "@oxlint/linux-x64-musl@npm:0.16.12" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@oxlint/win32-arm64@npm:0.16.12": + version: 0.16.12 + resolution: "@oxlint/win32-arm64@npm:0.16.12" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@oxlint/win32-x64@npm:0.16.12": + version: 0.16.12 + resolution: "@oxlint/win32-x64@npm:0.16.12" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@rolldown/binding-android-arm64@npm:1.0.3": + version: 1.0.3 + resolution: "@rolldown/binding-android-arm64@npm:1.0.3" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"@rolldown/binding-darwin-arm64@npm:1.0.3": + version: 1.0.3 + resolution: "@rolldown/binding-darwin-arm64@npm:1.0.3" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@rolldown/binding-darwin-x64@npm:1.0.3": + version: 1.0.3 + resolution: "@rolldown/binding-darwin-x64@npm:1.0.3" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@rolldown/binding-freebsd-x64@npm:1.0.3": + version: 1.0.3 + resolution: "@rolldown/binding-freebsd-x64@npm:1.0.3" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"@rolldown/binding-linux-arm-gnueabihf@npm:1.0.3": + version: 1.0.3 + resolution: "@rolldown/binding-linux-arm-gnueabihf@npm:1.0.3" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"@rolldown/binding-linux-arm64-gnu@npm:1.0.3": + version: 1.0.3 + resolution: "@rolldown/binding-linux-arm64-gnu@npm:1.0.3" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@rolldown/binding-linux-arm64-musl@npm:1.0.3": + version: 1.0.3 + resolution: "@rolldown/binding-linux-arm64-musl@npm:1.0.3" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@rolldown/binding-linux-ppc64-gnu@npm:1.0.3": + version: 1.0.3 + resolution: "@rolldown/binding-linux-ppc64-gnu@npm:1.0.3" + conditions: os=linux & cpu=ppc64 & libc=glibc + languageName: node + linkType: hard + +"@rolldown/binding-linux-s390x-gnu@npm:1.0.3": + version: 1.0.3 + resolution: "@rolldown/binding-linux-s390x-gnu@npm:1.0.3" + conditions: os=linux & cpu=s390x & libc=glibc + languageName: node + linkType: hard + +"@rolldown/binding-linux-x64-gnu@npm:1.0.3": + version: 1.0.3 + resolution: "@rolldown/binding-linux-x64-gnu@npm:1.0.3" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@rolldown/binding-linux-x64-musl@npm:1.0.3": + version: 1.0.3 + resolution: "@rolldown/binding-linux-x64-musl@npm:1.0.3" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@rolldown/binding-openharmony-arm64@npm:1.0.3": + version: 1.0.3 + resolution: "@rolldown/binding-openharmony-arm64@npm:1.0.3" + conditions: os=openharmony & cpu=arm64 + languageName: node + linkType: hard + +"@rolldown/binding-wasm32-wasi@npm:1.0.3": + version: 1.0.3 + resolution: "@rolldown/binding-wasm32-wasi@npm:1.0.3" + dependencies: + "@emnapi/core": "npm:1.10.0" + "@emnapi/runtime": "npm:1.10.0" + "@napi-rs/wasm-runtime": "npm:^1.1.4" + conditions: cpu=wasm32 + languageName: node + linkType: hard + +"@rolldown/binding-win32-arm64-msvc@npm:1.0.3": + version: 1.0.3 + resolution: "@rolldown/binding-win32-arm64-msvc@npm:1.0.3" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@rolldown/binding-win32-x64-msvc@npm:1.0.3": + version: 1.0.3 + resolution: "@rolldown/binding-win32-x64-msvc@npm:1.0.3" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@rolldown/pluginutils@npm:^1.0.0": + version: 1.0.1 + resolution: "@rolldown/pluginutils@npm:1.0.1" + checksum: 10c0/99d9b06d90196823e4d8c841f258db7a16e5dbba5824a2962b05d907b79f1ba929d56f22dd744fd530936e568c865ee56a719dc31e57e13bc0a8eb4764a8d8dd + languageName: node + linkType: hard + +"@sniptt/guards@npm:^0.2.0": + version: 0.2.0 + resolution: "@sniptt/guards@npm:0.2.0" + checksum: 10c0/749bb0f550d1ddd4abdb23dc1076cba26e977659922b73d000bceeb253c09270aaced0d18e92f09d4ce2fdaae33e55f60f2142f5359bc90ba505422af0873526 + languageName: node + linkType: hard + +"@standard-schema/spec@npm:^1.1.0": + version: 1.1.0 + resolution: "@standard-schema/spec@npm:1.1.0" + checksum: 10c0/d90f55acde4b2deb983529c87e8025fa693de1a5e8b49ecc6eb84d1fd96328add0e03d7d551442156c7432fd78165b2c26ff561b970a9a881f046abb78d6a526 + languageName: node + linkType: hard + +"@tybys/wasm-util@npm:^0.10.2": + version: 0.10.2 + resolution: "@tybys/wasm-util@npm:0.10.2" + dependencies: + tslib: "npm:^2.4.0" + checksum: 10c0/26165bcd1fd7269f42d7fbe3de318f854a8968de8397e89fc9a423bb3e2da35a52150f382e6323b3367595beb16d9800a6f35971a5599daf76da1742ec3afc25 + languageName: node + linkType: hard + +"@types/chai@npm:^5.2.2": + version: 5.2.3 + resolution: "@types/chai@npm:5.2.3" + dependencies: + "@types/deep-eql": "npm:*" + assertion-error: "npm:^2.0.1" + checksum: 10c0/e0ef1de3b6f8045a5e473e867c8565788c444271409d155588504840ad1a53611011f85072188c2833941189400228c1745d78323dac13fcede9c2b28bacfb2f + languageName: node + linkType: hard + +"@types/deep-eql@npm:*": + version: 4.0.2 + resolution: "@types/deep-eql@npm:4.0.2" + checksum: 10c0/bf3f811843117900d7084b9d0c852da9a044d12eb40e6de73b552598a6843c21291a8a381b0532644574beecd5e3491c5ff3a0365ab86b15d59862c025384844 + languageName: node + linkType: hard + +"@types/estree@npm:^1.0.0": + version: 1.0.9 + resolution: "@types/estree@npm:1.0.9" + checksum: 10c0/3ad3286ca2988cd550dafb8f2ad599c8474868e954fa601a36655bdfefd8039f7c714b8c1c7f2ae219ffbd58bd4660e66fa7479a0120fc02d4777057d4865387 + languageName: node + linkType: hard + +"@types/node@npm:*": + version: 25.9.3 + resolution: "@types/node@npm:25.9.3" + dependencies: + undici-types: "npm:>=7.24.0 <7.24.7" + checksum: 10c0/72d3aece9d42c2c641bcd3f3cb2dc2828b4bd384dfcbd910c404b8859a68bd69d50c4769ce7defd4ff5e049768e23e615f09407ea2cbbb5f44b90d75a7c6b8ca + languageName: node + linkType: hard + +"@types/node@npm:^24.7.2": + version: 24.13.2 + resolution: "@types/node@npm:24.13.2" + dependencies: + undici-types: "npm:~7.18.0" + checksum: 10c0/d7d48a88a4feb0a6aac3cbfaf9ef3b12752b4b09447f88dd0b4c77c03b281e3d4330fe6982a99aedcd63fc16c7540a0c248b91eb2abb0b3edd884d7fe684e9ea + languageName: node + linkType: hard + +"@types/parse-json@npm:^4.0.0": + version: 4.0.2 + resolution: "@types/parse-json@npm:4.0.2" + checksum: 10c0/b1b863ac34a2c2172fbe0807a1ec4d5cb684e48d422d15ec95980b81475fac4fdb3768a8b13eef39130203a7c04340fc167bae057c7ebcafd7dec9fe6c36aeb1 + languageName: node + linkType: hard + +"@types/qs@npm:^6.9.0": + version: 6.15.1 + resolution: "@types/qs@npm:6.15.1" + checksum: 10c0/1dfdbcb4cf2a8f66d57f0b9a9fe6b1c7091cb816687b6698c1351eaf31f62e412cea9b7453a9637b570cd5fad8dced527e5a9e69b4fcc6e318daacd8b749f094 + languageName: node + linkType: hard + +"@types/react@npm:^19.0.0": + version: 19.2.17 + resolution: "@types/react@npm:19.2.17" + dependencies: + csstype: "npm:^3.2.2" + checksum: 10c0/bc2c4af96b3e480604424de70d5ebda90c5f4b485df471858c0bc2d7d70364b606ec3c4d8579f94f01aa0c6c0591f56bcf14cba5689f5eea4b74250ccdc3a232 + languageName: node + linkType: hard + +"@types/ws@npm:^6.0.1": + version: 6.0.4 + resolution: "@types/ws@npm:6.0.4" + dependencies: + "@types/node": "npm:*" + checksum: 10c0/fa958e64596ca9487c3ed6012834de70b47f25d971f1950cfb8e6a99cb77ff340ae82ac7627744e01b58010674ef8ede07d5a2ac29ca9ad0d67a430fcc69ae14 + languageName: node + linkType: hard + +"@typescript/native-preview-darwin-arm64@npm:7.0.0-dev.20260619.1": + version: 7.0.0-dev.20260619.1 + resolution: "@typescript/native-preview-darwin-arm64@npm:7.0.0-dev.20260619.1" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@typescript/native-preview-darwin-x64@npm:7.0.0-dev.20260619.1": + version: 7.0.0-dev.20260619.1 + resolution: "@typescript/native-preview-darwin-x64@npm:7.0.0-dev.20260619.1" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@typescript/native-preview-linux-arm64@npm:7.0.0-dev.20260619.1": + version: 7.0.0-dev.20260619.1 + resolution: "@typescript/native-preview-linux-arm64@npm:7.0.0-dev.20260619.1" + conditions: os=linux & cpu=arm64 + languageName: node + linkType: hard + +"@typescript/native-preview-linux-arm@npm:7.0.0-dev.20260619.1": + version: 7.0.0-dev.20260619.1 + resolution: "@typescript/native-preview-linux-arm@npm:7.0.0-dev.20260619.1" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"@typescript/native-preview-linux-x64@npm:7.0.0-dev.20260619.1": + version: 7.0.0-dev.20260619.1 + resolution: "@typescript/native-preview-linux-x64@npm:7.0.0-dev.20260619.1" + conditions: os=linux & cpu=x64 + languageName: node + linkType: hard + +"@typescript/native-preview-win32-arm64@npm:7.0.0-dev.20260619.1": + version: 7.0.0-dev.20260619.1 + resolution: "@typescript/native-preview-win32-arm64@npm:7.0.0-dev.20260619.1" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@typescript/native-preview-win32-x64@npm:7.0.0-dev.20260619.1": + version: 7.0.0-dev.20260619.1 + resolution: "@typescript/native-preview-win32-x64@npm:7.0.0-dev.20260619.1" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@typescript/native-preview@npm:^7.0.0-dev.20260116.1": + version: 7.0.0-dev.20260619.1 + resolution: "@typescript/native-preview@npm:7.0.0-dev.20260619.1" + dependencies: + "@typescript/native-preview-darwin-arm64": "npm:7.0.0-dev.20260619.1" + "@typescript/native-preview-darwin-x64": "npm:7.0.0-dev.20260619.1" + "@typescript/native-preview-linux-arm": "npm:7.0.0-dev.20260619.1" + "@typescript/native-preview-linux-arm64": "npm:7.0.0-dev.20260619.1" + "@typescript/native-preview-linux-x64": "npm:7.0.0-dev.20260619.1" + "@typescript/native-preview-win32-arm64": "npm:7.0.0-dev.20260619.1" + "@typescript/native-preview-win32-x64": "npm:7.0.0-dev.20260619.1" + dependenciesMeta: + "@typescript/native-preview-darwin-arm64": + optional: true + "@typescript/native-preview-darwin-x64": + optional: true + "@typescript/native-preview-linux-arm": + optional: true + "@typescript/native-preview-linux-arm64": + optional: true + "@typescript/native-preview-linux-x64": + optional: true + "@typescript/native-preview-win32-arm64": + optional: true + "@typescript/native-preview-win32-x64": + optional: true + bin: + tsgo: bin/tsgo.js + checksum: 10c0/e8bc6cf171ee29131934f662c9b3577374d7e872247831da36a5723abe175998f1318a134ed8eb0ee5213887dbb1f1209fac5555cdf5b6b19d0d7da044d0cdc9 + languageName: node + linkType: hard + +"@vitest/expect@npm:4.1.9": + version: 4.1.9 + resolution: "@vitest/expect@npm:4.1.9" + dependencies: + "@standard-schema/spec": "npm:^1.1.0" + "@types/chai": "npm:^5.2.2" + "@vitest/spy": "npm:4.1.9" + "@vitest/utils": "npm:4.1.9" + chai: "npm:^6.2.2" + tinyrainbow: "npm:^3.1.0" + checksum: 10c0/243bacaed2cba5e0ea4ec7465662fcec465a358a0e06381e337fac49426aa67a73b104fbb9d65d8bccadfba8f70e27f57ffb897aacfa140f579a556367357875 + languageName: node + linkType: hard + +"@vitest/mocker@npm:4.1.9": + version: 4.1.9 + resolution: "@vitest/mocker@npm:4.1.9" + dependencies: + "@vitest/spy": "npm:4.1.9" + estree-walker: "npm:^3.0.3" + magic-string: "npm:^0.30.21" + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + checksum: 10c0/707353b7435bbfd441cc754e4ee7bc5921b70d07b051c6e414b6bbe4ca369154702b0ddeb603389469fe87ca1983e002eb2d55044582661f54a1945dd27e5c82 + languageName: node + linkType: hard + +"@vitest/pretty-format@npm:4.1.9": + version: 4.1.9 + resolution: "@vitest/pretty-format@npm:4.1.9" + dependencies: + tinyrainbow: "npm:^3.1.0" + checksum: 10c0/5b96295f25ab885616230ad1355fc82f490bebb39cc707688d7c8969c08270d7e076ed8a10af4e762ed57145193c6061a1f549f136f0ded344f8db0c2b3fb3de + languageName: node + linkType: hard + +"@vitest/runner@npm:4.1.9": + version: 4.1.9 + resolution: "@vitest/runner@npm:4.1.9" + dependencies: + "@vitest/utils": "npm:4.1.9" + pathe: "npm:^2.0.3" + checksum: 10c0/d206b4891a64b1f55c346f832b0a7b489108094d8ae34438d3b53e78be7b45b139fa95ffa027c98c357bd532268ee573168de1943235b7eed32a9236ed5978bb + languageName: node + linkType: hard + +"@vitest/snapshot@npm:4.1.9": + version: 4.1.9 + resolution: "@vitest/snapshot@npm:4.1.9" + dependencies: + "@vitest/pretty-format": "npm:4.1.9" + "@vitest/utils": "npm:4.1.9" + magic-string: "npm:^0.30.21" + pathe: "npm:^2.0.3" + checksum: 10c0/c3099df12ad1f9c1e180441856c9eb82f1990f87ff16aafedd6fa19978eaff20bc59220b692a99fcc822daef86eab256ba3dadb49544b7bd625b57c49cd9d995 + languageName: node + linkType: hard + +"@vitest/spy@npm:4.1.9": + version: 4.1.9 + resolution: "@vitest/spy@npm:4.1.9" + checksum: 10c0/e51f328f55b76e8ba66e5e18f183484a8dc0a092685b101112d3e9fb8e989ddca162c98ddf00254476502c25bc05c4ec1e277fd6ad8bfc702464c08f6b5dd115 + languageName: node + linkType: hard + +"@vitest/utils@npm:4.1.9": + version: 4.1.9 + resolution: "@vitest/utils@npm:4.1.9" + dependencies: + "@vitest/pretty-format": "npm:4.1.9" + convert-source-map: "npm:^2.0.0" + tinyrainbow: "npm:^3.1.0" + checksum: 10c0/d55506c077fd72c091eb66f02926f0abf72801c87a085f565698289562f47befa114ae2c680ab8736dfe46abab0cfd6b8031f2ac519bafeb37578aa6e5ad03c5 + languageName: node + linkType: hard + +"abbrev@npm:^4.0.0": + version: 4.0.0 + resolution: "abbrev@npm:4.0.0" + checksum: 10c0/b4cc16935235e80702fc90192e349e32f8ef0ed151ef506aa78c81a7c455ec18375c4125414b99f84b2e055199d66383e787675f0bcd87da7a4dbd59f9eac1d5 + languageName: node + linkType: hard + +"agent-base@npm:6": + version: 6.0.2 + resolution: "agent-base@npm:6.0.2" + dependencies: + debug: "npm:4" + checksum: 10c0/dc4f757e40b5f3e3d674bc9beb4f1048f4ee83af189bae39be99f57bf1f48dde166a8b0a5342a84b5944ee8e6ed1e5a9d801858f4ad44764e84957122fe46261 + languageName: node + linkType: hard + +"ansi-escapes@npm:^7.3.0": + version: 7.3.0 + resolution: "ansi-escapes@npm:7.3.0" + dependencies: + environment: "npm:^1.0.0" + checksum: 10c0/068961d99f0ef28b661a4a9f84a5d645df93ccf3b9b93816cc7d46bbe1913321d4cdf156bb842a4e1e4583b7375c631fa963efb43001c4eb7ff9ab8f78fc0679 + languageName: node + linkType: hard + +"ansi-regex@npm:^6.2.2": + version: 6.2.2 + resolution: "ansi-regex@npm:6.2.2" + checksum: 10c0/05d4acb1d2f59ab2cf4b794339c7b168890d44dda4bf0ce01152a8da0213aca207802f930442ce8cd22d7a92f44907664aac6508904e75e038fa944d2601b30f + languageName: node + linkType: hard + +"ansi-styles@npm:^6.2.1, ansi-styles@npm:^6.2.3": + version: 6.2.3 + resolution: "ansi-styles@npm:6.2.3" + checksum: 10c0/23b8a4ce14e18fb854693b95351e286b771d23d8844057ed2e7d083cd3e708376c3323707ec6a24365f7d7eda3ca00327fe04092e29e551499ec4c8b7bfac868 + languageName: node + linkType: hard + +"assertion-error@npm:^2.0.1": + version: 2.0.1 + resolution: "assertion-error@npm:2.0.1" + checksum: 10c0/bbbcb117ac6480138f8c93cf7f535614282dea9dc828f540cdece85e3c665e8f78958b96afac52f29ff883c72638e6a87d469ecc9fe5bc902df03ed24a55dba8 + languageName: node + linkType: hard + +"async-function@npm:^1.0.0": + version: 1.0.0 + resolution: "async-function@npm:1.0.0" + checksum: 10c0/669a32c2cb7e45091330c680e92eaeb791bc1d4132d827591e499cd1f776ff5a873e77e5f92d0ce795a8d60f10761dec9ddfe7225a5de680f5d357f67b1aac73 + languageName: node + linkType: hard + +"async-generator-function@npm:^1.0.0": + version: 1.0.0 + resolution: "async-generator-function@npm:1.0.0" + checksum: 10c0/2c50ef856c543ad500d8d8777d347e3c1ba623b93e99c9263ecc5f965c1b12d2a140e2ab6e43c3d0b85366110696f28114649411cbcd10b452a92a2318394186 + languageName: node + linkType: hard + +"async-limiter@npm:~1.0.0": + version: 1.0.1 + resolution: "async-limiter@npm:1.0.1" + checksum: 10c0/0693d378cfe86842a70d4c849595a0bb50dc44c11649640ca982fa90cbfc74e3cc4753b5a0847e51933f2e9c65ce8e05576e75e5e1fd963a086e673735b35969 + languageName: node + linkType: hard + +"asynckit@npm:^0.4.0": + version: 0.4.0 + resolution: "asynckit@npm:0.4.0" + checksum: 10c0/d73e2ddf20c4eb9337e1b3df1a0f6159481050a5de457c55b14ea2e5cb6d90bb69e004c9af54737a5ee0917fcf2c9e25de67777bbe58261847846066ba75bc9d + languageName: node + linkType: hard + +"auto-bind@npm:^5.0.1": + version: 5.0.1 + resolution: "auto-bind@npm:5.0.1" + checksum: 10c0/a703375350ea7b6e92405d8e6bcc6dbfb84b0d7c7172b33e5788a7593929a18227999ff9aa9c32436741d06d021e6672457b1cec73287efe3fab95cff6627eaf + languageName: node + linkType: hard + +"axios@npm:^1.16.0": + version: 1.17.0 + resolution: "axios@npm:1.17.0" + dependencies: + follow-redirects: "npm:^1.16.0" + form-data: "npm:^4.0.5" + https-proxy-agent: "npm:^5.0.1" + proxy-from-env: "npm:^2.1.0" + checksum: 10c0/c4fa19ff3a3a63bde48beec03ad816b133b9a6385cccffffe172577ab18c6a70e299280d57f12c80c867fe25df41f92cb91d3a8258708a6d2be3e9e085f92650 + languageName: node + linkType: hard + +"babel-plugin-macros@npm:^3.1.0": + version: 3.1.0 + resolution: "babel-plugin-macros@npm:3.1.0" + dependencies: + "@babel/runtime": "npm:^7.12.5" + cosmiconfig: "npm:^7.0.0" + resolve: "npm:^1.19.0" + checksum: 10c0/c6dfb15de96f67871d95bd2e8c58b0c81edc08b9b087dc16755e7157f357dc1090a8dc60ebab955e92587a9101f02eba07e730adc253a1e4cf593ca3ebd3839c + languageName: node + linkType: hard + +"backo2@npm:^1.0.2": + version: 1.0.2 + resolution: "backo2@npm:1.0.2" + checksum: 10c0/a9e825a6a38a6d1c4a94476eabc13d6127dfaafb0967baf104affbb67806ae26abbb58dab8d572d2cd21ef06634ff57c3ad48dff14b904e18de1474cc2f22bf3 + languageName: node + linkType: hard + +"call-bind-apply-helpers@npm:^1.0.1, call-bind-apply-helpers@npm:^1.0.2": + version: 1.0.2 + resolution: "call-bind-apply-helpers@npm:1.0.2" + dependencies: + es-errors: "npm:^1.3.0" + function-bind: "npm:^1.1.2" + checksum: 10c0/47bd9901d57b857590431243fea704ff18078b16890a6b3e021e12d279bbf211d039155e27d7566b374d49ee1f8189344bac9833dec7a20cdec370506361c938 + languageName: node + linkType: hard + +"callsites@npm:^3.0.0": + version: 3.1.0 + resolution: "callsites@npm:3.1.0" + checksum: 10c0/fff92277400eb06c3079f9e74f3af120db9f8ea03bad0e84d9aede54bbe2d44a56cccb5f6cf12211f93f52306df87077ecec5b712794c5a9b5dac6d615a3f301 + languageName: node + linkType: hard + +"chai@npm:^6.2.2": + version: 6.2.2 + resolution: "chai@npm:6.2.2" + checksum: 10c0/e6c69e5f0c11dffe6ea13d0290936ebb68fcc1ad688b8e952e131df6a6d5797d5e860bc55cef1aca2e950c3e1f96daf79e9d5a70fb7dbaab4e46355e2635ed53 + languageName: node + linkType: hard + +"chalk@npm:^5.3.0, chalk@npm:^5.6.0": + version: 5.6.2 + resolution: "chalk@npm:5.6.2" + checksum: 10c0/99a4b0f0e7991796b1e7e3f52dceb9137cae2a9dfc8fc0784a550dc4c558e15ab32ed70b14b21b52beb2679b4892b41a0aa44249bcb996f01e125d58477c6976 + languageName: node + linkType: hard + +"chardet@npm:^2.1.1": + version: 2.1.1 + resolution: "chardet@npm:2.1.1" + checksum: 10c0/d8391dd412338442b3de0d3a488aa9327f8bcf74b62b8723d6bd0b85c4084d50b731320e0a7c710edb1d44de75969995d2784b80e4c13b004a6c7a0db4c6e793 + languageName: node + linkType: hard + +"chokidar@npm:^4.0.0": + version: 4.0.3 + resolution: "chokidar@npm:4.0.3" + dependencies: + readdirp: "npm:^4.0.1" + checksum: 10c0/a58b9df05bb452f7d105d9e7229ac82fa873741c0c40ddcc7bb82f8a909fbe3f7814c9ebe9bc9a2bef9b737c0ec6e2d699d179048ef06ad3ec46315df0ebe6ad + languageName: node + linkType: hard + +"chownr@npm:^3.0.0": + version: 3.0.0 + resolution: "chownr@npm:3.0.0" + checksum: 10c0/43925b87700f7e3893296c8e9c56cc58f926411cce3a6e5898136daaf08f08b9a8eb76d37d3267e707d0dcc17aed2e2ebdf5848c0c3ce95cf910a919935c1b10 + languageName: node + linkType: hard + +"cli-boxes@npm:^3.0.0": + version: 3.0.0 + resolution: "cli-boxes@npm:3.0.0" + checksum: 10c0/4db3e8fbfaf1aac4fb3a6cbe5a2d3fa048bee741a45371b906439b9ffc821c6e626b0f108bdcd3ddf126a4a319409aedcf39a0730573ff050fdd7b6731e99fb9 + languageName: node + linkType: hard + +"cli-cursor@npm:^4.0.0": + version: 4.0.0 + resolution: "cli-cursor@npm:4.0.0" + dependencies: + restore-cursor: "npm:^4.0.0" + checksum: 10c0/e776e8c3c6727300d0539b0d25160b2bb56aed1a63942753ba1826b012f337a6f4b7ace3548402e4f2f13b5e16bfd751be672c44b203205e7eca8be94afec42c + languageName: node + linkType: hard + +"cli-truncate@npm:^5.1.1": + version: 5.2.0 + resolution: "cli-truncate@npm:5.2.0" + dependencies: + slice-ansi: "npm:^8.0.0" + string-width: "npm:^8.2.0" + checksum: 10c0/0d4ec94702ca85b64522ac93633837fb5ea7db17b79b1322a60f6045e6ae2b8cd7bd4c1d19ac7d1f9e10e3bbda1112e172e439b68c02b785ee00da8d6a5c5471 + languageName: node + linkType: hard + +"cli-width@npm:^4.1.0": + version: 4.1.0 + resolution: "cli-width@npm:4.1.0" + checksum: 10c0/1fbd56413578f6117abcaf858903ba1f4ad78370a4032f916745fa2c7e390183a9d9029cf837df320b0fdce8137668e522f60a30a5f3d6529ff3872d265a955f + languageName: node + linkType: hard + +"code-excerpt@npm:^4.0.0": + version: 4.0.0 + resolution: "code-excerpt@npm:4.0.0" + dependencies: + convert-to-spaces: "npm:^2.0.1" + checksum: 10c0/b6c5a06e039cecd2ab6a0e10ee0831de8362107d1f298ca3558b5f9004cb8e0260b02dd6c07f57b9a0e346c76864d2873311ee1989809fdeb05bd5fbbadde773 + languageName: node + linkType: hard + +"combined-stream@npm:^1.0.8": + version: 1.0.8 + resolution: "combined-stream@npm:1.0.8" + dependencies: + delayed-stream: "npm:~1.0.0" + checksum: 10c0/0dbb829577e1b1e839fa82b40c07ffaf7de8a09b935cadd355a73652ae70a88b4320db322f6634a4ad93424292fa80973ac6480986247f1734a1137debf271d5 + languageName: node + linkType: hard + +"commander@npm:^12.0.0": + version: 12.1.0 + resolution: "commander@npm:12.1.0" + checksum: 10c0/6e1996680c083b3b897bfc1cfe1c58dfbcd9842fd43e1aaf8a795fbc237f65efcc860a3ef457b318e73f29a4f4a28f6403c3d653d021d960e4632dd45bde54a9 + languageName: node + linkType: hard + +"convert-source-map@npm:^1.5.0": + version: 1.9.0 + resolution: "convert-source-map@npm:1.9.0" + checksum: 10c0/281da55454bf8126cbc6625385928c43479f2060984180c42f3a86c8b8c12720a24eac260624a7d1e090004028d2dee78602330578ceec1a08e27cb8bb0a8a5b + languageName: node + linkType: hard + +"convert-source-map@npm:^2.0.0": + version: 2.0.0 + resolution: "convert-source-map@npm:2.0.0" + checksum: 10c0/8f2f7a27a1a011cc6cc88cc4da2d7d0cfa5ee0369508baae3d98c260bb3ac520691464e5bbe4ae7cdf09860c1d69ecc6f70c63c6e7c7f7e3f18ec08484dc7d9b + languageName: node + linkType: hard + +"convert-to-spaces@npm:^2.0.1": + version: 2.0.1 + resolution: "convert-to-spaces@npm:2.0.1" + checksum: 10c0/d90aa0e3b6a27f9d5265a8d32def3c5c855b3e823a9db1f26d772f8146d6b91020a2fdfd905ce8048a73fad3aaf836fef8188c67602c374405e2ae8396c4ac46 + languageName: node + linkType: hard + +"cosmiconfig@npm:^7.0.0": + version: 7.1.0 + resolution: "cosmiconfig@npm:7.1.0" + dependencies: + "@types/parse-json": "npm:^4.0.0" + import-fresh: "npm:^3.2.1" + parse-json: "npm:^5.0.0" + path-type: "npm:^4.0.0" + yaml: "npm:^1.10.0" + checksum: 10c0/b923ff6af581638128e5f074a5450ba12c0300b71302398ea38dbeabd33bbcaa0245ca9adbedfcf284a07da50f99ede5658c80bb3e39e2ce770a99d28a21ef03 + languageName: node + linkType: hard + +"csstype@npm:^3.0.2, csstype@npm:^3.2.2": + version: 3.2.3 + resolution: "csstype@npm:3.2.3" + checksum: 10c0/cd29c51e70fa822f1cecd8641a1445bed7063697469d35633b516e60fe8c1bde04b08f6c5b6022136bb669b64c63d4173af54864510fbb4ee23281801841a3ce + languageName: node + linkType: hard + +"debug@npm:4, debug@npm:^4.1.1, debug@npm:^4.3.1": + version: 4.4.3 + resolution: "debug@npm:4.4.3" + dependencies: + ms: "npm:^2.1.3" + peerDependenciesMeta: + supports-color: + optional: true + checksum: 10c0/d79136ec6c83ecbefd0f6a5593da6a9c91ec4d7ddc4b54c883d6e71ec9accb5f67a1a5e96d00a328196b5b5c86d365e98d8a3a70856aaf16b4e7b1985e67f5a6 + languageName: node + linkType: hard + +"delayed-stream@npm:~1.0.0": + version: 1.0.0 + resolution: "delayed-stream@npm:1.0.0" + checksum: 10c0/d758899da03392e6712f042bec80aa293bbe9e9ff1b2634baae6a360113e708b91326594c8a486d475c69d6259afb7efacdc3537bfcda1c6c648e390ce601b19 + languageName: node + linkType: hard + +"detect-libc@npm:^2.0.3": + version: 2.1.2 + resolution: "detect-libc@npm:2.1.2" + checksum: 10c0/acc675c29a5649fa1fb6e255f993b8ee829e510b6b56b0910666949c80c364738833417d0edb5f90e4e46be17228b0f2b66a010513984e18b15deeeac49369c4 + languageName: node + linkType: hard + +"dunder-proto@npm:^1.0.1": + version: 1.0.1 + resolution: "dunder-proto@npm:1.0.1" + dependencies: + call-bind-apply-helpers: "npm:^1.0.1" + es-errors: "npm:^1.3.0" + gopd: "npm:^1.2.0" + checksum: 10c0/199f2a0c1c16593ca0a145dbf76a962f8033ce3129f01284d48c45ed4e14fea9bbacd7b3610b6cdc33486cef20385ac054948fefc6272fcce645c09468f93031 + languageName: node + linkType: hard + +"emoji-regex@npm:^10.3.0": + version: 10.6.0 + resolution: "emoji-regex@npm:10.6.0" + checksum: 10c0/1e4aa097bb007301c3b4b1913879ae27327fdc48e93eeefefe3b87e495eb33c5af155300be951b4349ff6ac084f4403dc9eff970acba7c1c572d89396a9a32d7 + languageName: node + linkType: hard + +"env-paths@npm:^2.2.0": + version: 2.2.1 + resolution: "env-paths@npm:2.2.1" + checksum: 10c0/285325677bf00e30845e330eec32894f5105529db97496ee3f598478e50f008c5352a41a30e5e72ec9de8a542b5a570b85699cd63bd2bc646dbcb9f311d83bc4 + languageName: node + linkType: hard + +"environment@npm:^1.0.0": + version: 1.1.0 + resolution: "environment@npm:1.1.0" + checksum: 10c0/fb26434b0b581ab397039e51ff3c92b34924a98b2039dcb47e41b7bca577b9dbf134a8eadb364415c74464b682e2d3afe1a4c0eb9873dc44ea814c5d3103331d + languageName: node + linkType: hard + +"error-ex@npm:^1.3.1": + version: 1.3.4 + resolution: "error-ex@npm:1.3.4" + dependencies: + is-arrayish: "npm:^0.2.1" + checksum: 10c0/b9e34ff4778b8f3b31a8377e1c654456f4c41aeaa3d10a1138c3b7635d8b7b2e03eb2475d46d8ae055c1f180a1063e100bffabf64ea7e7388b37735df5328664 + languageName: node + linkType: hard + +"es-define-property@npm:^1.0.1": + version: 1.0.1 + resolution: "es-define-property@npm:1.0.1" + checksum: 10c0/3f54eb49c16c18707949ff25a1456728c883e81259f045003499efba399c08bad00deebf65cccde8c0e07908c1a225c9d472b7107e558f2a48e28d530e34527c + languageName: node + linkType: hard + +"es-errors@npm:^1.3.0": + version: 1.3.0 + resolution: "es-errors@npm:1.3.0" + checksum: 10c0/0a61325670072f98d8ae3b914edab3559b6caa980f08054a3b872052640d91da01d38df55df797fcc916389d77fc92b8d5906cf028f4db46d7e3003abecbca85 + languageName: node + linkType: hard + +"es-module-lexer@npm:^2.0.0": + version: 2.1.0 + resolution: "es-module-lexer@npm:2.1.0" + checksum: 10c0/93bcf2454fa72d67fe3ccd0abef8ce7933f5840a319513418a643dd8e9c6aa8f49709cecfae02ded722805dd327232d30723a807cc52e6809d6ac697c62c29fb + languageName: node + linkType: hard + +"es-object-atoms@npm:^1.0.0, es-object-atoms@npm:^1.1.1": + version: 1.1.2 + resolution: "es-object-atoms@npm:1.1.2" + dependencies: + es-errors: "npm:^1.3.0" + checksum: 10c0/1772861f094f739d6f41b579cfb9a18579daffeb434552a370a5fbef50a32d22227e27b63fdbb757b7ddd429d1b42fe52ccae7966d9302a2ec221b6f1b41bbc4 + languageName: node + linkType: hard + +"es-set-tostringtag@npm:^2.1.0": + version: 2.1.0 + resolution: "es-set-tostringtag@npm:2.1.0" + dependencies: + es-errors: "npm:^1.3.0" + get-intrinsic: "npm:^1.2.6" + has-tostringtag: "npm:^1.0.2" + hasown: "npm:^2.0.2" + checksum: 10c0/ef2ca9ce49afe3931cb32e35da4dcb6d86ab02592cfc2ce3e49ced199d9d0bb5085fc7e73e06312213765f5efa47cc1df553a6a5154584b21448e9fb8355b1af + languageName: node + linkType: hard + +"es-toolkit@npm:^1.39.10": + version: 1.47.0 + resolution: "es-toolkit@npm:1.47.0" + dependenciesMeta: + "@trivago/prettier-plugin-sort-imports@4.3.0": + unplugged: true + prettier-plugin-sort-re-exports@0.0.1: + unplugged: true + vitepress-plugin-sandpack@1.1.4: + unplugged: true + checksum: 10c0/6edc9709fccc409fa4adddd318971d8a18335de9225f2dc99021aacba44fe66a2437a831923589c643865caab261a087bd974338294a60bb5a74932caa102901 + languageName: node + linkType: hard + +"esbuild@npm:^0.28.1": + version: 0.28.1 + resolution: "esbuild@npm:0.28.1" + dependencies: + "@esbuild/aix-ppc64": "npm:0.28.1" + "@esbuild/android-arm": "npm:0.28.1" + "@esbuild/android-arm64": "npm:0.28.1" + "@esbuild/android-x64": "npm:0.28.1" + "@esbuild/darwin-arm64": "npm:0.28.1" + "@esbuild/darwin-x64": "npm:0.28.1" + "@esbuild/freebsd-arm64": "npm:0.28.1" + "@esbuild/freebsd-x64": "npm:0.28.1" + "@esbuild/linux-arm": "npm:0.28.1" + "@esbuild/linux-arm64": "npm:0.28.1" + "@esbuild/linux-ia32": "npm:0.28.1" + "@esbuild/linux-loong64": "npm:0.28.1" + "@esbuild/linux-mips64el": "npm:0.28.1" + "@esbuild/linux-ppc64": "npm:0.28.1" + "@esbuild/linux-riscv64": "npm:0.28.1" + "@esbuild/linux-s390x": "npm:0.28.1" + "@esbuild/linux-x64": "npm:0.28.1" + "@esbuild/netbsd-arm64": "npm:0.28.1" + "@esbuild/netbsd-x64": "npm:0.28.1" + "@esbuild/openbsd-arm64": "npm:0.28.1" + "@esbuild/openbsd-x64": "npm:0.28.1" + "@esbuild/openharmony-arm64": "npm:0.28.1" + "@esbuild/sunos-x64": "npm:0.28.1" + "@esbuild/win32-arm64": "npm:0.28.1" + "@esbuild/win32-ia32": "npm:0.28.1" + "@esbuild/win32-x64": "npm:0.28.1" + dependenciesMeta: + "@esbuild/aix-ppc64": + optional: true + "@esbuild/android-arm": + optional: true + "@esbuild/android-arm64": + optional: true + "@esbuild/android-x64": + optional: true + "@esbuild/darwin-arm64": + optional: true + "@esbuild/darwin-x64": + optional: true + "@esbuild/freebsd-arm64": + optional: true + "@esbuild/freebsd-x64": + optional: true + "@esbuild/linux-arm": + optional: true + "@esbuild/linux-arm64": + optional: true + "@esbuild/linux-ia32": + optional: true + "@esbuild/linux-loong64": + optional: true + "@esbuild/linux-mips64el": + optional: true + "@esbuild/linux-ppc64": + optional: true + "@esbuild/linux-riscv64": + optional: true + "@esbuild/linux-s390x": + optional: true + "@esbuild/linux-x64": + optional: true + "@esbuild/netbsd-arm64": + optional: true + "@esbuild/netbsd-x64": + optional: true + "@esbuild/openbsd-arm64": + optional: true + "@esbuild/openbsd-x64": + optional: true + "@esbuild/openharmony-arm64": + optional: true + "@esbuild/sunos-x64": + optional: true + "@esbuild/win32-arm64": + optional: true + "@esbuild/win32-ia32": + optional: true + "@esbuild/win32-x64": + optional: true + bin: + esbuild: bin/esbuild + checksum: 10c0/29cd456a79ce35ac2c7e05fe871330416b2c395c045d849653f843e51378d6e0d6e774d6dcd01b35f4e83238a29bf8decd04fcd34b3780c589a250b21e5f92bb + languageName: node + linkType: hard + +"escape-string-regexp@npm:^2.0.0": + version: 2.0.0 + resolution: "escape-string-regexp@npm:2.0.0" + checksum: 10c0/2530479fe8db57eace5e8646c9c2a9c80fa279614986d16dcc6bcaceb63ae77f05a851ba6c43756d816c61d7f4534baf56e3c705e3e0d884818a46808811c507 + languageName: node + linkType: hard + +"escape-string-regexp@npm:^4.0.0": + version: 4.0.0 + resolution: "escape-string-regexp@npm:4.0.0" + checksum: 10c0/9497d4dd307d845bd7f75180d8188bb17ea8c151c1edbf6b6717c100e104d629dc2dfb687686181b0f4b7d732c7dfdc4d5e7a8ff72de1b0ca283a75bbb3a9cd9 + languageName: node + linkType: hard + +"estree-walker@npm:^3.0.3": + version: 3.0.3 + resolution: "estree-walker@npm:3.0.3" + dependencies: + "@types/estree": "npm:^1.0.0" + checksum: 10c0/c12e3c2b2642d2bcae7d5aa495c60fa2f299160946535763969a1c83fc74518ffa9c2cd3a8b69ac56aea547df6a8aac25f729a342992ef0bbac5f1c73e78995d + languageName: node + linkType: hard + +"eventemitter3@npm:^3.1.0": + version: 3.1.2 + resolution: "eventemitter3@npm:3.1.2" + checksum: 10c0/c67262eccbf85848b7cc6d4abb6c6e34155e15686db2a01c57669fd0d44441a574a19d44d25948b442929e065774cbe5003d8e77eed47674fbf876ac77887793 + languageName: node + linkType: hard + +"expect-type@npm:^1.3.0": + version: 1.3.0 + resolution: "expect-type@npm:1.3.0" + checksum: 10c0/8412b3fe4f392c420ab41dae220b09700e4e47c639a29ba7ba2e83cc6cffd2b4926f7ac9e47d7e277e8f4f02acda76fd6931cb81fd2b382fa9477ef9ada953fd + languageName: node + linkType: hard + +"exponential-backoff@npm:^3.1.1": + version: 3.1.3 + resolution: "exponential-backoff@npm:3.1.3" + checksum: 10c0/77e3ae682b7b1f4972f563c6dbcd2b0d54ac679e62d5d32f3e5085feba20483cf28bd505543f520e287a56d4d55a28d7874299941faf637e779a1aa5994d1267 + languageName: node + linkType: hard + +"fast-string-truncated-width@npm:^3.0.2": + version: 3.0.3 + resolution: "fast-string-truncated-width@npm:3.0.3" + checksum: 10c0/043b8663397d14a3880ce4f3407bcda60b40db9bbeafe62863a35d1f9c69ea17c8da3fcd72de235553e6c9cd053128cde9e24ca0d4a7463208f48db3cd23d981 + languageName: node + linkType: hard + +"fast-string-width@npm:^3.0.2": + version: 3.0.2 + resolution: "fast-string-width@npm:3.0.2" + dependencies: + fast-string-truncated-width: "npm:^3.0.2" + checksum: 10c0/c8822d175315bb353ebe782b65214ac53b13e3bf704e03b132ea7bdfa8de6a636375b3ab7a4097545393d109381c37c4f387c72a462c90b61412dbc4632f39a7 + languageName: node + linkType: hard + +"fast-wrap-ansi@npm:^0.2.0": + version: 0.2.2 + resolution: "fast-wrap-ansi@npm:0.2.2" + dependencies: + fast-string-width: "npm:^3.0.2" + checksum: 10c0/1aa7be4f7cb86f4bdb14691cb6bcc0b8df8b3b89df142ade3ae1602332dcf6f990cd750a923cd581ca0847808cb4ec1aa5afaafa7a72f849e87a2a62c98fa370 + languageName: node + linkType: hard + +"fdir@npm:^6.5.0": + version: 6.5.0 + resolution: "fdir@npm:6.5.0" + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + checksum: 10c0/e345083c4306b3aed6cb8ec551e26c36bab5c511e99ea4576a16750ddc8d3240e63826cc624f5ae17ad4dc82e68a253213b60d556c11bfad064b7607847ed07f + languageName: node + linkType: hard + +"find-root@npm:^1.1.0": + version: 1.1.0 + resolution: "find-root@npm:1.1.0" + checksum: 10c0/1abc7f3bf2f8d78ff26d9e00ce9d0f7b32e5ff6d1da2857bcdf4746134c422282b091c672cde0572cac3840713487e0a7a636af9aa1b74cb11894b447a521efa + languageName: node + linkType: hard + +"follow-redirects@npm:^1.16.0": + version: 1.16.0 + resolution: "follow-redirects@npm:1.16.0" + peerDependenciesMeta: + debug: + optional: true + checksum: 10c0/a1e2900163e6f1b4d1ed5c221b607f41decbab65534c63fe7e287e40a5d552a6496e7d9d7d976fa4ba77b4c51c11e5e9f683f10b43011ea11e442ff128d0e181 + languageName: node + linkType: hard + +"form-data@npm:^4.0.5": + version: 4.0.6 + resolution: "form-data@npm:4.0.6" + dependencies: + asynckit: "npm:^0.4.0" + combined-stream: "npm:^1.0.8" + es-set-tostringtag: "npm:^2.1.0" + hasown: "npm:^2.0.4" + mime-types: "npm:^2.1.35" + checksum: 10c0/43947a77bf0ff45c6ceed789778982d47a3f3e720a74b71721174ebf3310a5f1a8be1d6b38a3ee3688e8a18a2c4273073ec0844cd37efda3eaf46d41c9c318ff + languageName: node + linkType: hard + +"fsevents@npm:~2.3.3": + version: 2.3.3 + resolution: "fsevents@npm:2.3.3" + dependencies: + node-gyp: "npm:latest" + checksum: 10c0/a1f0c44595123ed717febbc478aa952e47adfc28e2092be66b8ab1635147254ca6cfe1df792a8997f22716d4cbafc73309899ff7bfac2ac3ad8cf2e4ecc3ec60 + conditions: os=darwin + languageName: node + linkType: hard + +"fsevents@patch:fsevents@npm%3A~2.3.3#optional!builtin": + version: 2.3.3 + resolution: "fsevents@patch:fsevents@npm%3A2.3.3#optional!builtin::version=2.3.3&hash=df0bf1" + dependencies: + node-gyp: "npm:latest" + conditions: os=darwin + languageName: node + linkType: hard + +"function-bind@npm:^1.1.2": + version: 1.1.2 + resolution: "function-bind@npm:1.1.2" + checksum: 10c0/d8680ee1e5fcd4c197e4ac33b2b4dce03c71f4d91717292785703db200f5c21f977c568d28061226f9b5900cbcd2c84463646134fd5337e7925e0942bc3f46d5 + languageName: node + linkType: hard + +"generator-function@npm:^2.0.0": + version: 2.0.1 + resolution: "generator-function@npm:2.0.1" + checksum: 10c0/8a9f59df0f01cfefafdb3b451b80555e5cf6d76487095db91ac461a0e682e4ff7a9dbce15f4ecec191e53586d59eece01949e05a4b4492879600bbbe8e28d6b8 + languageName: node + linkType: hard + +"get-east-asian-width@npm:^1.0.0, get-east-asian-width@npm:^1.3.1, get-east-asian-width@npm:^1.5.0": + version: 1.6.0 + resolution: "get-east-asian-width@npm:1.6.0" + checksum: 10c0/7e72e9550fd49ca5b246f9af6bb2afc129c96412845ff6556b3274fd44817a381702ca17028efe9866b261a3d44254cbf21e6c90cf05b4b61675630af776d431 + languageName: node + linkType: hard + +"get-intrinsic@npm:^1.2.6": + version: 1.3.1 + resolution: "get-intrinsic@npm:1.3.1" + dependencies: + async-function: "npm:^1.0.0" + async-generator-function: "npm:^1.0.0" + call-bind-apply-helpers: "npm:^1.0.2" + es-define-property: "npm:^1.0.1" + es-errors: "npm:^1.3.0" + es-object-atoms: "npm:^1.1.1" + function-bind: "npm:^1.1.2" + generator-function: "npm:^2.0.0" + get-proto: "npm:^1.0.1" + gopd: "npm:^1.2.0" + has-symbols: "npm:^1.1.0" + hasown: "npm:^2.0.2" + math-intrinsics: "npm:^1.1.0" + checksum: 10c0/9f4ab0cf7efe0fd2c8185f52e6f637e708f3a112610c88869f8f041bb9ecc2ce44bf285dfdbdc6f4f7c277a5b88d8e94a432374d97cca22f3de7fc63795deb5d + languageName: node + linkType: hard + +"get-proto@npm:^1.0.1": + version: 1.0.1 + resolution: "get-proto@npm:1.0.1" + dependencies: + dunder-proto: "npm:^1.0.1" + es-object-atoms: "npm:^1.0.0" + checksum: 10c0/9224acb44603c5526955e83510b9da41baf6ae73f7398875fba50edc5e944223a89c4a72b070fcd78beb5f7bdda58ecb6294adc28f7acfc0da05f76a2399643c + languageName: node + linkType: hard + +"globrex@npm:^0.1.2": + version: 0.1.2 + resolution: "globrex@npm:0.1.2" + checksum: 10c0/a54c029520cf58bda1d8884f72bd49b4cd74e977883268d931fd83bcbd1a9eb96d57c7dbd4ad80148fb9247467ebfb9b215630b2ed7563b2a8de02e1ff7f89d1 + languageName: node + linkType: hard + +"gopd@npm:^1.2.0": + version: 1.2.0 + resolution: "gopd@npm:1.2.0" + checksum: 10c0/50fff1e04ba2b7737c097358534eacadad1e68d24cccee3272e04e007bed008e68d2614f3987788428fd192a5ae3889d08fb2331417e4fc4a9ab366b2043cead + languageName: node + linkType: hard + +"graceful-fs@npm:^4.2.6": + version: 4.2.11 + resolution: "graceful-fs@npm:4.2.11" + checksum: 10c0/386d011a553e02bc594ac2ca0bd6d9e4c22d7fa8cfbfc448a6d148c59ea881b092db9dbe3547ae4b88e55f1b01f7c4a2ecc53b310c042793e63aa44cf6c257f2 + languageName: node + linkType: hard + +"graphql-query-batcher@npm:^1.0.1": + version: 1.0.1 + resolution: "graphql-query-batcher@npm:1.0.1" + checksum: 10c0/804d0f4064721a2116a16b9eac422e9233e85f4ab5b250cb8f83662725658ffde35779a8ae8211037f3dd2f9717de8cb63b394ad8057ce22171a83db2471196a + languageName: node + linkType: hard + +"graphql-sse@npm:^2.5.4": + version: 2.6.0 + resolution: "graphql-sse@npm:2.6.0" + peerDependencies: + graphql: ">=0.11 <=16" + checksum: 10c0/e05f0b5c8539d61e5ce34af8e0bb418c02bf922d6a7f9232a9abd53c77df5654684ca14f674ac771645c8fba9c37ce666c5d06f46eef54ea07e63653468065b0 + languageName: node + linkType: hard + +"graphql@npm:^16.8.1": + version: 16.14.2 + resolution: "graphql@npm:16.14.2" + checksum: 10c0/a95a96961eaff55cc9fe9d31fae6f33499ac988b972d07ea5085024cb1333f515b902f376e7393a5489aa82200a8aff3eb96580e4d1b69d702ed19b6eb1ce97a + languageName: node + linkType: hard + +"has-symbols@npm:^1.0.3, has-symbols@npm:^1.1.0": + version: 1.1.0 + resolution: "has-symbols@npm:1.1.0" + checksum: 10c0/dde0a734b17ae51e84b10986e651c664379018d10b91b6b0e9b293eddb32f0f069688c841fb40f19e9611546130153e0a2a48fd7f512891fb000ddfa36f5a20e + languageName: node + linkType: hard + +"has-tostringtag@npm:^1.0.2": + version: 1.0.2 + resolution: "has-tostringtag@npm:1.0.2" + dependencies: + has-symbols: "npm:^1.0.3" + checksum: 10c0/a8b166462192bafe3d9b6e420a1d581d93dd867adb61be223a17a8d6dad147aa77a8be32c961bb2f27b3ef893cae8d36f564ab651f5e9b7938ae86f74027c48c + languageName: node + linkType: hard + +"hasown@npm:^2.0.2, hasown@npm:^2.0.3, hasown@npm:^2.0.4": + version: 2.0.4 + resolution: "hasown@npm:2.0.4" + dependencies: + function-bind: "npm:^1.1.2" + checksum: 10c0/2d8de939e270b70618f8cebb69746620db10617dbb495bc66ddad326955ea24d3ca4af133aff3eb7c1853e0218f867bc2b050ec26fe02e3aea58f880ffc5e506 + languageName: node + linkType: hard + +"hoist-non-react-statics@npm:^3.3.1": + version: 3.3.2 + resolution: "hoist-non-react-statics@npm:3.3.2" + dependencies: + react-is: "npm:^16.7.0" + checksum: 10c0/fe0889169e845d738b59b64badf5e55fa3cf20454f9203d1eb088df322d49d4318df774828e789898dcb280e8a5521bb59b3203385662ca5e9218a6ca5820e74 + languageName: node + linkType: hard + +"https-proxy-agent@npm:^5.0.1": + version: 5.0.1 + resolution: "https-proxy-agent@npm:5.0.1" + dependencies: + agent-base: "npm:6" + debug: "npm:4" + checksum: 10c0/6dd639f03434003577c62b27cafdb864784ef19b2de430d8ae2a1d45e31c4fd60719e5637b44db1a88a046934307da7089e03d6089ec3ddacc1189d8de8897d1 + languageName: node + linkType: hard + +"iconv-lite@npm:^0.7.2": + version: 0.7.2 + resolution: "iconv-lite@npm:0.7.2" + dependencies: + safer-buffer: "npm:>= 2.1.2 < 3.0.0" + checksum: 10c0/3c228920f3bd307f56bf8363706a776f4a060eb042f131cd23855ceca962951b264d0997ab38a1ad340e1c5df8499ed26e1f4f0db6b2a2ad9befaff22f14b722 + languageName: node + linkType: hard + +"import-fresh@npm:^3.2.1": + version: 3.3.1 + resolution: "import-fresh@npm:3.3.1" + dependencies: + parent-module: "npm:^1.0.0" + resolve-from: "npm:^4.0.0" + checksum: 10c0/bf8cc494872fef783249709385ae883b447e3eb09db0ebd15dcead7d9afe7224dad7bd7591c6b73b0b19b3c0f9640eb8ee884f01cfaf2887ab995b0b36a0cbec + languageName: node + linkType: hard + +"indent-string@npm:^5.0.0": + version: 5.0.0 + resolution: "indent-string@npm:5.0.0" + checksum: 10c0/8ee77b57d92e71745e133f6f444d6fa3ed503ad0e1bcd7e80c8da08b42375c07117128d670589725ed07b1978065803fa86318c309ba45415b7fe13e7f170220 + languageName: node + linkType: hard + +"ink@npm:^6.8.0": + version: 6.8.0 + resolution: "ink@npm:6.8.0" + dependencies: + "@alcalzone/ansi-tokenize": "npm:^0.2.4" + ansi-escapes: "npm:^7.3.0" + ansi-styles: "npm:^6.2.1" + auto-bind: "npm:^5.0.1" + chalk: "npm:^5.6.0" + cli-boxes: "npm:^3.0.0" + cli-cursor: "npm:^4.0.0" + cli-truncate: "npm:^5.1.1" + code-excerpt: "npm:^4.0.0" + es-toolkit: "npm:^1.39.10" + indent-string: "npm:^5.0.0" + is-in-ci: "npm:^2.0.0" + patch-console: "npm:^2.0.0" + react-reconciler: "npm:^0.33.0" + scheduler: "npm:^0.27.0" + signal-exit: "npm:^3.0.7" + slice-ansi: "npm:^8.0.0" + stack-utils: "npm:^2.0.6" + string-width: "npm:^8.1.1" + terminal-size: "npm:^4.0.1" + type-fest: "npm:^5.4.1" + widest-line: "npm:^6.0.0" + wrap-ansi: "npm:^9.0.0" + ws: "npm:^8.18.0" + yoga-layout: "npm:~3.2.1" + peerDependencies: + "@types/react": ">=19.0.0" + react: ">=19.0.0" + react-devtools-core: ">=6.1.2" + peerDependenciesMeta: + "@types/react": + optional: true + react-devtools-core: + optional: true + checksum: 10c0/50500e547fdf6a1f1d836d6befbd4770e3ab649ef0be1884500a6da411fb68a90e22dd7dcc9c404911d30e9f87506b3b9d8e997c6c6ceac85ee054b4dadefaff + languageName: node + linkType: hard + +"inquirer@npm:^14.0.0": + version: 14.0.2 + resolution: "inquirer@npm:14.0.2" + dependencies: + "@inquirer/ansi": "npm:^2.0.7" + "@inquirer/core": "npm:^11.2.1" + "@inquirer/prompts": "npm:^8.5.2" + "@inquirer/type": "npm:^4.0.7" + mute-stream: "npm:^3.0.0" + run-async: "npm:^4.0.6" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 10c0/31ec80b7d599dcb19568540d694865b1da116f701b75503a23ed38189d96fca70eb1fb6ccb1dbd994f6f79d407d51dc1a94d06dcef5370644ce736792414781b + languageName: node + linkType: hard + +"is-arrayish@npm:^0.2.1": + version: 0.2.1 + resolution: "is-arrayish@npm:0.2.1" + checksum: 10c0/e7fb686a739068bb70f860b39b67afc62acc62e36bb61c5f965768abce1873b379c563e61dd2adad96ebb7edf6651111b385e490cf508378959b0ed4cac4e729 + languageName: node + linkType: hard + +"is-core-module@npm:^2.16.1": + version: 2.16.2 + resolution: "is-core-module@npm:2.16.2" + dependencies: + hasown: "npm:^2.0.3" + checksum: 10c0/14b4258390283709c15476d023ec173e27458d5d014ccdb8ed39d576e551c3fa45498b7c9fe178f1529c4cb2648ddd58852a6a62107a019f6e349529f277518a + languageName: node + linkType: hard + +"is-fullwidth-code-point@npm:^5.0.0, is-fullwidth-code-point@npm:^5.1.0": + version: 5.1.0 + resolution: "is-fullwidth-code-point@npm:5.1.0" + dependencies: + get-east-asian-width: "npm:^1.3.1" + checksum: 10c0/c1172c2e417fb73470c56c431851681591f6a17233603a9e6f94b7ba870b2e8a5266506490573b607fb1081318589372034aa436aec07b465c2029c0bc7f07a4 + languageName: node + linkType: hard + +"is-in-ci@npm:^2.0.0": + version: 2.0.0 + resolution: "is-in-ci@npm:2.0.0" + bin: + is-in-ci: cli.js + checksum: 10c0/1e1d1056939a681e8206035de5ad84e0404556eaa7622bb55f0f1868b9788bff3df427bc0b1ed5a172623154a90fcb1e759a230817cd73d09435543ae3c71feb + languageName: node + linkType: hard + +"isexe@npm:^4.0.0": + version: 4.0.0 + resolution: "isexe@npm:4.0.0" + checksum: 10c0/5884815115bceac452877659a9c7726382531592f43dc29e5d48b7c4100661aed54018cb90bd36cb2eaeba521092570769167acbb95c18d39afdccbcca06c5ce + languageName: node + linkType: hard + +"isomorphic-unfetch@npm:^3.0.0": + version: 3.1.0 + resolution: "isomorphic-unfetch@npm:3.1.0" + dependencies: + node-fetch: "npm:^2.6.1" + unfetch: "npm:^4.2.0" + checksum: 10c0/d3b61fca06304db692b7f76bdfd3a00f410e42cfa7403c3b250546bf71589d18cf2f355922f57198e4cc4a9872d3647b20397a5c3edf1a347c90d57c83cf2a89 + languageName: node + linkType: hard + +"iterall@npm:^1.2.1": + version: 1.3.0 + resolution: "iterall@npm:1.3.0" + checksum: 10c0/40de624e5fe937c4c0e511981b91caea9ff2142bfc0316cccc8506eaa03aa253820cc17c5bc5f0a98706c7268a373e5ebee9af9a0c8a359730cf7c05938b57b5 + languageName: node + linkType: hard + +"js-tokens@npm:^4.0.0": + version: 4.0.0 + resolution: "js-tokens@npm:4.0.0" + checksum: 10c0/e248708d377aa058eacf2037b07ded847790e6de892bbad3dac0abba2e759cb9f121b00099a65195616badcb6eca8d14d975cb3e89eb1cfda644756402c8aeed + languageName: node + linkType: hard + +"jsesc@npm:^3.0.2": + version: 3.1.0 + resolution: "jsesc@npm:3.1.0" + bin: + jsesc: bin/jsesc + checksum: 10c0/531779df5ec94f47e462da26b4cbf05eb88a83d9f08aac2ba04206508fc598527a153d08bd462bae82fc78b3eaa1a908e1a4a79f886e9238641c4cdefaf118b1 + languageName: node + linkType: hard + +"json-parse-even-better-errors@npm:^2.3.0": + version: 2.3.1 + resolution: "json-parse-even-better-errors@npm:2.3.1" + checksum: 10c0/140932564c8f0b88455432e0f33c4cb4086b8868e37524e07e723f4eaedb9425bdc2bafd71bd1d9765bd15fd1e2d126972bc83990f55c467168c228c24d665f3 + languageName: node + linkType: hard + +"jsonc-parser@npm:^3.2.0": + version: 3.3.1 + resolution: "jsonc-parser@npm:3.3.1" + checksum: 10c0/269c3ae0a0e4f907a914bf334306c384aabb9929bd8c99f909275ebd5c2d3bc70b9bcd119ad794f339dec9f24b6a4ee9cd5a8ab2e6435e730ad4075388fc2ab6 + languageName: node + linkType: hard + +"lightningcss-android-arm64@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-android-arm64@npm:1.32.0" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"lightningcss-darwin-arm64@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-darwin-arm64@npm:1.32.0" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"lightningcss-darwin-x64@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-darwin-x64@npm:1.32.0" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"lightningcss-freebsd-x64@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-freebsd-x64@npm:1.32.0" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"lightningcss-linux-arm-gnueabihf@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-linux-arm-gnueabihf@npm:1.32.0" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"lightningcss-linux-arm64-gnu@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-linux-arm64-gnu@npm:1.32.0" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"lightningcss-linux-arm64-musl@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-linux-arm64-musl@npm:1.32.0" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"lightningcss-linux-x64-gnu@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-linux-x64-gnu@npm:1.32.0" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"lightningcss-linux-x64-musl@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-linux-x64-musl@npm:1.32.0" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"lightningcss-win32-arm64-msvc@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-win32-arm64-msvc@npm:1.32.0" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"lightningcss-win32-x64-msvc@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-win32-x64-msvc@npm:1.32.0" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"lightningcss@npm:^1.32.0": + version: 1.32.0 + resolution: "lightningcss@npm:1.32.0" + dependencies: + detect-libc: "npm:^2.0.3" + lightningcss-android-arm64: "npm:1.32.0" + lightningcss-darwin-arm64: "npm:1.32.0" + lightningcss-darwin-x64: "npm:1.32.0" + lightningcss-freebsd-x64: "npm:1.32.0" + lightningcss-linux-arm-gnueabihf: "npm:1.32.0" + lightningcss-linux-arm64-gnu: "npm:1.32.0" + lightningcss-linux-arm64-musl: "npm:1.32.0" + lightningcss-linux-x64-gnu: "npm:1.32.0" + lightningcss-linux-x64-musl: "npm:1.32.0" + lightningcss-win32-arm64-msvc: "npm:1.32.0" + lightningcss-win32-x64-msvc: "npm:1.32.0" + dependenciesMeta: + lightningcss-android-arm64: + optional: true + lightningcss-darwin-arm64: + optional: true + lightningcss-darwin-x64: + optional: true + lightningcss-freebsd-x64: + optional: true + lightningcss-linux-arm-gnueabihf: + optional: true + lightningcss-linux-arm64-gnu: + optional: true + lightningcss-linux-arm64-musl: + optional: true + lightningcss-linux-x64-gnu: + optional: true + lightningcss-linux-x64-musl: + optional: true + lightningcss-win32-arm64-msvc: + optional: true + lightningcss-win32-x64-msvc: + optional: true + checksum: 10c0/70945bd55097af46fc9fab7f5ed09cd5869d85940a2acab7ee06d0117004a1d68155708a2d462531cea2fc3c67aefc9333a7068c80b0b78dd404c16838809e03 + languageName: node + linkType: hard + +"lines-and-columns@npm:^1.1.6": + version: 1.2.4 + resolution: "lines-and-columns@npm:1.2.4" + checksum: 10c0/3da6ee62d4cd9f03f5dc90b4df2540fb85b352081bee77fe4bbcd12c9000ead7f35e0a38b8d09a9bb99b13223446dd8689ff3c4959807620726d788701a83d2d + languageName: node + linkType: hard + +"lodash@npm:^4.17.20, lodash@npm:^4.17.21": + version: 4.18.1 + resolution: "lodash@npm:4.18.1" + checksum: 10c0/757228fc68805c59789e82185135cf85f05d0b2d3d54631d680ca79ec21944ec8314d4533639a14b8bcfbd97a517e78960933041a5af17ecb693ec6eecb99a27 + languageName: node + linkType: hard + +"magic-string@npm:^0.30.21": + version: 0.30.21 + resolution: "magic-string@npm:0.30.21" + dependencies: + "@jridgewell/sourcemap-codec": "npm:^1.5.5" + checksum: 10c0/299378e38f9a270069fc62358522ddfb44e94244baa0d6a8980ab2a9b2490a1d03b236b447eee309e17eb3bddfa482c61259d47960eb018a904f0ded52780c4a + languageName: node + linkType: hard + +"math-intrinsics@npm:^1.1.0": + version: 1.1.0 + resolution: "math-intrinsics@npm:1.1.0" + checksum: 10c0/7579ff94e899e2f76ab64491d76cf606274c874d8f2af4a442c016bd85688927fcfca157ba6bf74b08e9439dc010b248ce05b96cc7c126a354c3bae7fcb48b7f + languageName: node + linkType: hard + +"mime-db@npm:1.52.0": + version: 1.52.0 + resolution: "mime-db@npm:1.52.0" + checksum: 10c0/0557a01deebf45ac5f5777fe7740b2a5c309c6d62d40ceab4e23da9f821899ce7a900b7ac8157d4548ddbb7beffe9abc621250e6d182b0397ec7f10c7b91a5aa + languageName: node + linkType: hard + +"mime-types@npm:^2.1.35": + version: 2.1.35 + resolution: "mime-types@npm:2.1.35" + dependencies: + mime-db: "npm:1.52.0" + checksum: 10c0/82fb07ec56d8ff1fc999a84f2f217aa46cb6ed1033fefaabd5785b9a974ed225c90dc72fff460259e66b95b73648596dbcc50d51ed69cdf464af2d237d3149b2 + languageName: node + linkType: hard + +"mimic-fn@npm:^2.1.0": + version: 2.1.0 + resolution: "mimic-fn@npm:2.1.0" + checksum: 10c0/b26f5479d7ec6cc2bce275a08f146cf78f5e7b661b18114e2506dd91ec7ec47e7a25bf4360e5438094db0560bcc868079fb3b1fb3892b833c1ecbf63f80c95a4 + languageName: node + linkType: hard + +"minipass@npm:^7.0.4, minipass@npm:^7.1.2": + version: 7.1.3 + resolution: "minipass@npm:7.1.3" + checksum: 10c0/539da88daca16533211ea5a9ee98dc62ff5742f531f54640dd34429e621955e91cc280a91a776026264b7f9f6735947629f920944e9c1558369e8bf22eb33fbb + languageName: node + linkType: hard + +"minizlib@npm:^3.1.0": + version: 3.1.0 + resolution: "minizlib@npm:3.1.0" + dependencies: + minipass: "npm:^7.1.2" + checksum: 10c0/5aad75ab0090b8266069c9aabe582c021ae53eb33c6c691054a13a45db3b4f91a7fb1bd79151e6b4e9e9a86727b522527c0a06ec7d45206b745d54cd3097bcec + languageName: node + linkType: hard + +"ms@npm:^2.1.3": + version: 2.1.3 + resolution: "ms@npm:2.1.3" + checksum: 10c0/d924b57e7312b3b63ad21fc5b3dc0af5e78d61a1fc7cfb5457edaf26326bf62be5307cc87ffb6862ef1c2b33b0233cdb5d4f01c4c958cc0d660948b65a287a48 + languageName: node + linkType: hard + +"mute-stream@npm:^3.0.0": + version: 3.0.0 + resolution: "mute-stream@npm:3.0.0" + checksum: 10c0/12cdb36a101694c7a6b296632e6d93a30b74401873cf7507c88861441a090c71c77a58f213acadad03bc0c8fa186639dec99d68a14497773a8744320c136e701 + languageName: node + linkType: hard + +"nanoid@npm:^3.3.12": + version: 3.3.12 + resolution: "nanoid@npm:3.3.12" + bin: + nanoid: bin/nanoid.cjs + checksum: 10c0/ba142b7b39e11e80c16dd74b0365d407880c87c1cf7e1480956981ae940ee36060fa5b6f092cd1e315184dd19244c657bd017d03327bd3c62247d691c5e8edfb + languageName: node + linkType: hard + +"node-fetch@npm:^2.6.1": + version: 2.7.0 + resolution: "node-fetch@npm:2.7.0" + dependencies: + whatwg-url: "npm:^5.0.0" + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + checksum: 10c0/b55786b6028208e6fbe594ccccc213cab67a72899c9234eb59dba51062a299ea853210fcf526998eaa2867b0963ad72338824450905679ff0fa304b8c5093ae8 + languageName: node + linkType: hard + +"node-gyp@npm:latest": + version: 12.4.0 + resolution: "node-gyp@npm:12.4.0" + dependencies: + env-paths: "npm:^2.2.0" + exponential-backoff: "npm:^3.1.1" + graceful-fs: "npm:^4.2.6" + nopt: "npm:^9.0.0" + proc-log: "npm:^6.0.0" + semver: "npm:^7.3.5" + tar: "npm:^7.5.4" + tinyglobby: "npm:^0.2.12" + undici: "npm:^6.25.0" + which: "npm:^6.0.0" + bin: + node-gyp: bin/node-gyp.js + checksum: 10c0/9acb7c798e124275a6f9c1f7eb64b5abd6196bb885a3945fb44ee0dccf435514e88cdfb0f228ee7ff76ef25107c1f39ff37a067bf92fd00b9aff9234db29ff9e + languageName: node + linkType: hard + +"nopt@npm:^9.0.0": + version: 9.0.0 + resolution: "nopt@npm:9.0.0" + dependencies: + abbrev: "npm:^4.0.0" + bin: + nopt: bin/nopt.js + checksum: 10c0/1822eb6f9b020ef6f7a7516d7b64a8036e09666ea55ac40416c36e4b2b343122c3cff0e2f085675f53de1d2db99a2a89a60ccea1d120bcd6a5347bf6ceb4a7fd + languageName: node + linkType: hard + +"obug@npm:^2.1.1": + version: 2.1.3 + resolution: "obug@npm:2.1.3" + checksum: 10c0/cb8187fed0a5fc8445507c950e89f3c1bd43895658c398b5803f6b7804dfa0c562975ecce1e67f3d9247d521452a5bfade9e0e951cc0326b7444272f7c24d25f + languageName: node + linkType: hard + +"onetime@npm:^5.1.0": + version: 5.1.2 + resolution: "onetime@npm:5.1.2" + dependencies: + mimic-fn: "npm:^2.1.0" + checksum: 10c0/ffcef6fbb2692c3c40749f31ea2e22677a876daea92959b8a80b521d95cca7a668c884d8b2045d1d8ee7d56796aa405c405462af112a1477594cc63531baeb8f + languageName: node + linkType: hard + +"oxlint@npm:^0.16.0": + version: 0.16.12 + resolution: "oxlint@npm:0.16.12" + dependencies: + "@oxlint/darwin-arm64": "npm:0.16.12" + "@oxlint/darwin-x64": "npm:0.16.12" + "@oxlint/linux-arm64-gnu": "npm:0.16.12" + "@oxlint/linux-arm64-musl": "npm:0.16.12" + "@oxlint/linux-x64-gnu": "npm:0.16.12" + "@oxlint/linux-x64-musl": "npm:0.16.12" + "@oxlint/win32-arm64": "npm:0.16.12" + "@oxlint/win32-x64": "npm:0.16.12" + dependenciesMeta: + "@oxlint/darwin-arm64": + optional: true + "@oxlint/darwin-x64": + optional: true + "@oxlint/linux-arm64-gnu": + optional: true + "@oxlint/linux-arm64-musl": + optional: true + "@oxlint/linux-x64-gnu": + optional: true + "@oxlint/linux-x64-musl": + optional: true + "@oxlint/win32-arm64": + optional: true + "@oxlint/win32-x64": + optional: true + bin: + oxc_language_server: bin/oxc_language_server + oxlint: bin/oxlint + checksum: 10c0/209c3484039c4f1fdd340689e81be93a70fdf74e8c111731e70d81580d223b0029439107c95fa4118cec3c9b03ba6c56624d3dcb8cdc79af222da3561122b3e7 + languageName: node + linkType: hard + +"parent-module@npm:^1.0.0": + version: 1.0.1 + resolution: "parent-module@npm:1.0.1" + dependencies: + callsites: "npm:^3.0.0" + checksum: 10c0/c63d6e80000d4babd11978e0d3fee386ca7752a02b035fd2435960ffaa7219dc42146f07069fb65e6e8bf1caef89daf9af7535a39bddf354d78bf50d8294f556 + languageName: node + linkType: hard + +"parse-json@npm:^5.0.0": + version: 5.2.0 + resolution: "parse-json@npm:5.2.0" + dependencies: + "@babel/code-frame": "npm:^7.0.0" + error-ex: "npm:^1.3.1" + json-parse-even-better-errors: "npm:^2.3.0" + lines-and-columns: "npm:^1.1.6" + checksum: 10c0/77947f2253005be7a12d858aedbafa09c9ae39eb4863adf330f7b416ca4f4a08132e453e08de2db46459256fb66afaac5ee758b44fe6541b7cdaf9d252e59585 + languageName: node + linkType: hard + +"patch-console@npm:^2.0.0": + version: 2.0.0 + resolution: "patch-console@npm:2.0.0" + checksum: 10c0/486602591a0af7af8d4c76d8eea42cad32b6de7200488819c6383c75e43733ca7bdc80e30f2e68ce05f06a1607cce1683a1706c6672ca27dada1921b366e8f1c + languageName: node + linkType: hard + +"path-parse@npm:^1.0.7": + version: 1.0.7 + resolution: "path-parse@npm:1.0.7" + checksum: 10c0/11ce261f9d294cc7a58d6a574b7f1b935842355ec66fba3c3fd79e0f036462eaf07d0aa95bb74ff432f9afef97ce1926c720988c6a7451d8a584930ae7de86e1 + languageName: node + linkType: hard + +"path-type@npm:^4.0.0": + version: 4.0.0 + resolution: "path-type@npm:4.0.0" + checksum: 10c0/666f6973f332f27581371efaf303fd6c272cc43c2057b37aa99e3643158c7e4b2626549555d88626e99ea9e046f82f32e41bbde5f1508547e9a11b149b52387c + languageName: node + linkType: hard + +"pathe@npm:^2.0.3": + version: 2.0.3 + resolution: "pathe@npm:2.0.3" + checksum: 10c0/c118dc5a8b5c4166011b2b70608762e260085180bb9e33e80a50dcdb1e78c010b1624f4280c492c92b05fc276715a4c357d1f9edc570f8f1b3d90b6839ebaca1 + languageName: node + linkType: hard + +"picocolors@npm:^1.1.1": + version: 1.1.1 + resolution: "picocolors@npm:1.1.1" + checksum: 10c0/e2e3e8170ab9d7c7421969adaa7e1b31434f789afb9b3f115f6b96d91945041ac3ceb02e9ec6fe6510ff036bcc0bf91e69a1772edc0b707e12b19c0f2d6bcf58 + languageName: node + linkType: hard + +"picomatch@npm:^4.0.3, picomatch@npm:^4.0.4": + version: 4.0.4 + resolution: "picomatch@npm:4.0.4" + checksum: 10c0/e2c6023372cc7b5764719a5ffb9da0f8e781212fa7ca4bd0562db929df8e117460f00dff3cb7509dacfc06b86de924b247f504d0ce1806a37fac4633081466b0 + languageName: node + linkType: hard + +"postcss@npm:^8.5.15": + version: 8.5.15 + resolution: "postcss@npm:8.5.15" + dependencies: + nanoid: "npm:^3.3.12" + picocolors: "npm:^1.1.1" + source-map-js: "npm:^1.2.1" + checksum: 10c0/7f2e63ae22fbe43aace1bf652bd99da4e90737c64194d49e51ddc9cd0f9e51ff2861a7d734379b494deffa03a880a5c65eec70bc29ee9ebaa7136dde3eee8f31 + languageName: node + linkType: hard + +"preact@npm:^10.28.3": + version: 10.29.2 + resolution: "preact@npm:10.29.2" + checksum: 10c0/2ee161274e475804524608d42bba8c79920636b322293248796a9e5bf4c3332336f8c0eca1b1566ed09cdee95e408eb20b5b7613d95820ea6b6eb8fc090ef60a + languageName: node + linkType: hard + +"prettier@npm:^3.8.3": + version: 3.8.4 + resolution: "prettier@npm:3.8.4" + bin: + prettier: bin/prettier.cjs + checksum: 10c0/b90a0cbe75b88ac0af9c13fe0f359bd19926fabccd88483227b21f71f0c1cc42da056fc1ac3a361e665577c568371d5ccfb2c62c31c8a1186f8d1bd531a063e9 + languageName: node + linkType: hard + +"proc-log@npm:^6.0.0": + version: 6.1.0 + resolution: "proc-log@npm:6.1.0" + checksum: 10c0/4f178d4062733ead9d71a9b1ab24ebcecdfe2250916a5b1555f04fe2eda972a0ec76fbaa8df1ad9c02707add6749219d118a4fc46dc56bdfe4dde4b47d80bb82 + languageName: node + linkType: hard + +"proxy-from-env@npm:^2.1.0": + version: 2.1.0 + resolution: "proxy-from-env@npm:2.1.0" + checksum: 10c0/ed01729fd4d094eab619cd7e17ce3698b3413b31eb102c4904f9875e677cd207392795d5b4adee9cec359dfd31c44d5ad7595a3a3ad51c40250e141512281c58 + languageName: node + linkType: hard + +"react-dom@npm:^19.0.0, react-dom@npm:^19.2.0": + version: 19.2.7 + resolution: "react-dom@npm:19.2.7" + dependencies: + scheduler: "npm:^0.27.0" + peerDependencies: + react: ^19.2.7 + checksum: 10c0/970ff600f6e80d47d39e2f226f12f226173b3cba3382efc97c5f0cd663de9af38c7a4c11c213fb936094faeac83060d660247accaa96b752180d5b951b9cfecb + languageName: node + linkType: hard + +"react-is@npm:^16.7.0": + version: 16.13.1 + resolution: "react-is@npm:16.13.1" + checksum: 10c0/33977da7a5f1a287936a0c85639fec6ca74f4f15ef1e59a6bc20338fc73dc69555381e211f7a3529b8150a1f71e4225525b41b60b52965bda53ce7d47377ada1 + languageName: node + linkType: hard + +"react-reconciler@npm:^0.33.0": + version: 0.33.0 + resolution: "react-reconciler@npm:0.33.0" + dependencies: + scheduler: "npm:^0.27.0" + peerDependencies: + react: ^19.2.0 + checksum: 10c0/3f7b27ea8d0ff4c8bf0e402a285e1af9b7d0e6f4c1a70a28f4384938bc1130bc82a90a31df0b79ef5e380e2e55e2598bd90b4dbf802b1203d735ba0355817d3a + languageName: node + linkType: hard + +"react@npm:^19.0.0, react@npm:^19.2.0": + version: 19.2.7 + resolution: "react@npm:19.2.7" + checksum: 10c0/0bd0e2f1bbd4ba97561c6597bf8a5fec05e6476fe61e165c1065598d16668efc6715205599c94d3ddd49d36cb0f21cbf1b9bcc18ee840b805ce222c3e8d558ac + languageName: node + linkType: hard + +"readdirp@npm:^4.0.1": + version: 4.1.2 + resolution: "readdirp@npm:4.1.2" + checksum: 10c0/60a14f7619dec48c9c850255cd523e2717001b0e179dc7037cfa0895da7b9e9ab07532d324bfb118d73a710887d1e35f79c495fa91582784493e085d18c72c62 + languageName: node + linkType: hard + +"resolve-from@npm:^4.0.0": + version: 4.0.0 + resolution: "resolve-from@npm:4.0.0" + checksum: 10c0/8408eec31a3112ef96e3746c37be7d64020cda07c03a920f5024e77290a218ea758b26ca9529fd7b1ad283947f34b2291c1c0f6aa0ed34acfdda9c6014c8d190 + languageName: node + linkType: hard + +"resolve@npm:^1.19.0": + version: 1.22.12 + resolution: "resolve@npm:1.22.12" + dependencies: + es-errors: "npm:^1.3.0" + is-core-module: "npm:^2.16.1" + path-parse: "npm:^1.0.7" + supports-preserve-symlinks-flag: "npm:^1.0.0" + bin: + resolve: bin/resolve + checksum: 10c0/b16dc9b537c02e8c3388f7d3dcff9741d3071625f9a97ac1c885f2b0ca51e78df22328fb6d6ef214dd9101fb7cfc19aa2836fe3410402a94f3f7b8639c7149bf + languageName: node + linkType: hard + +"resolve@patch:resolve@npm%3A^1.19.0#optional!builtin": + version: 1.22.12 + resolution: "resolve@patch:resolve@npm%3A1.22.12#optional!builtin::version=1.22.12&hash=c3c19d" + dependencies: + es-errors: "npm:^1.3.0" + is-core-module: "npm:^2.16.1" + path-parse: "npm:^1.0.7" + supports-preserve-symlinks-flag: "npm:^1.0.0" + bin: + resolve: bin/resolve + checksum: 10c0/fc6519984ae1f894d877c0060ba8b1f5ba3bc0e85a02f74e141929c118c23d74d9735619a9cc2965397387e514884245c65d72a40731dcb6cfc84c7bcdc8321e + languageName: node + linkType: hard + +"restore-cursor@npm:^4.0.0": + version: 4.0.0 + resolution: "restore-cursor@npm:4.0.0" + dependencies: + onetime: "npm:^5.1.0" + signal-exit: "npm:^3.0.2" + checksum: 10c0/6f7da8c5e422ac26aa38354870b1afac09963572cf2879443540449068cb43476e9cbccf6f8de3e0171e0d6f7f533c2bc1a0a008003c9a525bbc098e89041318 + languageName: node + linkType: hard + +"rolldown@npm:1.0.3": + version: 1.0.3 + resolution: "rolldown@npm:1.0.3" + dependencies: + "@oxc-project/types": "npm:=0.133.0" + "@rolldown/binding-android-arm64": "npm:1.0.3" + "@rolldown/binding-darwin-arm64": "npm:1.0.3" + "@rolldown/binding-darwin-x64": "npm:1.0.3" + "@rolldown/binding-freebsd-x64": "npm:1.0.3" + "@rolldown/binding-linux-arm-gnueabihf": "npm:1.0.3" + "@rolldown/binding-linux-arm64-gnu": "npm:1.0.3" + "@rolldown/binding-linux-arm64-musl": "npm:1.0.3" + "@rolldown/binding-linux-ppc64-gnu": "npm:1.0.3" + "@rolldown/binding-linux-s390x-gnu": "npm:1.0.3" + "@rolldown/binding-linux-x64-gnu": "npm:1.0.3" + "@rolldown/binding-linux-x64-musl": "npm:1.0.3" + "@rolldown/binding-openharmony-arm64": "npm:1.0.3" + "@rolldown/binding-wasm32-wasi": "npm:1.0.3" + "@rolldown/binding-win32-arm64-msvc": "npm:1.0.3" + "@rolldown/binding-win32-x64-msvc": "npm:1.0.3" + "@rolldown/pluginutils": "npm:^1.0.0" + dependenciesMeta: + "@rolldown/binding-android-arm64": + optional: true + "@rolldown/binding-darwin-arm64": + optional: true + "@rolldown/binding-darwin-x64": + optional: true + "@rolldown/binding-freebsd-x64": + optional: true + "@rolldown/binding-linux-arm-gnueabihf": + optional: true + "@rolldown/binding-linux-arm64-gnu": + optional: true + "@rolldown/binding-linux-arm64-musl": + optional: true + "@rolldown/binding-linux-ppc64-gnu": + optional: true + "@rolldown/binding-linux-s390x-gnu": + optional: true + "@rolldown/binding-linux-x64-gnu": + optional: true + "@rolldown/binding-linux-x64-musl": + optional: true + "@rolldown/binding-openharmony-arm64": + optional: true + "@rolldown/binding-wasm32-wasi": + optional: true + "@rolldown/binding-win32-arm64-msvc": + optional: true + "@rolldown/binding-win32-x64-msvc": + optional: true + bin: + rolldown: ./bin/cli.mjs + checksum: 10c0/5f9dd47b7abf203b16bc600db68542f245e974c800e59ff50b76157d1dada1403657690435b036fabca88e93d13a67c31abe5cfaa6f61ce33717f61720204cdf + languageName: node + linkType: hard + +"run-async@npm:^4.0.6": + version: 4.0.6 + resolution: "run-async@npm:4.0.6" + checksum: 10c0/3e512c689d356238a06a59839deddeb09aec23bc66f780fe970fcf12b64bfc00c6880e9530ea22b8cf88a927145561f5a43343d8be87166e849ec0daaa3d4cf4 + languageName: node + linkType: hard + +"safer-buffer@npm:>= 2.1.2 < 3.0.0": + version: 2.1.2 + resolution: "safer-buffer@npm:2.1.2" + checksum: 10c0/7e3c8b2e88a1841c9671094bbaeebd94448111dd90a81a1f606f3f67708a6ec57763b3b47f06da09fc6054193e0e6709e77325415dc8422b04497a8070fa02d4 + languageName: node + linkType: hard + +"scheduler@npm:^0.27.0": + version: 0.27.0 + resolution: "scheduler@npm:0.27.0" + checksum: 10c0/4f03048cb05a3c8fddc45813052251eca00688f413a3cee236d984a161da28db28ba71bd11e7a3dd02f7af84ab28d39fb311431d3b3772fed557945beb00c452 + languageName: node + linkType: hard + +"semver@npm:7.6.3": + version: 7.6.3 + resolution: "semver@npm:7.6.3" + bin: + semver: bin/semver.js + checksum: 10c0/88f33e148b210c153873cb08cfe1e281d518aaa9a666d4d148add6560db5cd3c582f3a08ccb91f38d5f379ead256da9931234ed122057f40bb5766e65e58adaf + languageName: node + linkType: hard + +"semver@npm:^7.3.5": + version: 7.8.4 + resolution: "semver@npm:7.8.4" + bin: + semver: bin/semver.js + checksum: 10c0/81b7c296fd7927b80f67fa516b75fa1017caac8167795320de28e76ccbc6f7f01763c30ecd10d6a0d8fd089708ab0548a5aebb94b0870e99c2a2b4600a46389b + languageName: node + linkType: hard + +"siginfo@npm:^2.0.0": + version: 2.0.0 + resolution: "siginfo@npm:2.0.0" + checksum: 10c0/3def8f8e516fbb34cb6ae415b07ccc5d9c018d85b4b8611e3dc6f8be6d1899f693a4382913c9ed51a06babb5201639d76453ab297d1c54a456544acf5c892e34 + languageName: node + linkType: hard + +"signal-exit@npm:^3.0.2, signal-exit@npm:^3.0.7": + version: 3.0.7 + resolution: "signal-exit@npm:3.0.7" + checksum: 10c0/25d272fa73e146048565e08f3309d5b942c1979a6f4a58a8c59d5fa299728e9c2fcd1a759ec870863b1fd38653670240cd420dad2ad9330c71f36608a6a1c912 + languageName: node + linkType: hard + +"signal-exit@npm:^4.1.0": + version: 4.1.0 + resolution: "signal-exit@npm:4.1.0" + checksum: 10c0/41602dce540e46d599edba9d9860193398d135f7ff72cab629db5171516cfae628d21e7bfccde1bbfdf11c48726bc2a6d1a8fb8701125852fbfda7cf19c6aa83 + languageName: node + linkType: hard + +"slice-ansi@npm:^8.0.0": + version: 8.0.0 + resolution: "slice-ansi@npm:8.0.0" + dependencies: + ansi-styles: "npm:^6.2.3" + is-fullwidth-code-point: "npm:^5.1.0" + checksum: 10c0/0ce4aa91febb7cea4a00c2c27bb820fa53b6d2862ce0f80f7120134719f7914fc416b0ed966cf35250a3169e152916392f35917a2d7cad0fcc5d8b841010fa9a + languageName: node + linkType: hard + +"source-map-js@npm:^1.2.1": + version: 1.2.1 + resolution: "source-map-js@npm:1.2.1" + checksum: 10c0/7bda1fc4c197e3c6ff17de1b8b2c20e60af81b63a52cb32ec5a5d67a20a7d42651e2cb34ebe93833c5a2a084377e17455854fee3e21e7925c64a51b6a52b0faf + languageName: node + linkType: hard + +"source-map@npm:^0.5.7": + version: 0.5.7 + resolution: "source-map@npm:0.5.7" + checksum: 10c0/904e767bb9c494929be013017380cbba013637da1b28e5943b566031e29df04fba57edf3f093e0914be094648b577372bd8ad247fa98cfba9c600794cd16b599 + languageName: node + linkType: hard + +"stack-utils@npm:^2.0.6": + version: 2.0.6 + resolution: "stack-utils@npm:2.0.6" + dependencies: + escape-string-regexp: "npm:^2.0.0" + checksum: 10c0/651c9f87667e077584bbe848acaecc6049bc71979f1e9a46c7b920cad4431c388df0f51b8ad7cfd6eed3db97a2878d0fc8b3122979439ea8bac29c61c95eec8a + languageName: node + linkType: hard + +"stackback@npm:0.0.2": + version: 0.0.2 + resolution: "stackback@npm:0.0.2" + checksum: 10c0/89a1416668f950236dd5ac9f9a6b2588e1b9b62b1b6ad8dff1bfc5d1a15dbf0aafc9b52d2226d00c28dffff212da464eaeebfc6b7578b9d180cef3e3782c5983 + languageName: node + linkType: hard + +"std-env@npm:^4.0.0-rc.1": + version: 4.1.0 + resolution: "std-env@npm:4.1.0" + checksum: 10c0/2e14b6b490db34cb969a48d9cf7c35bca4a47653914aac2814221baae7b867a5b15940d133625c391621971f98cd2266a5dc7036669960e883f1081db2a56558 + languageName: node + linkType: hard + +"string-width@npm:^7.0.0": + version: 7.2.0 + resolution: "string-width@npm:7.2.0" + dependencies: + emoji-regex: "npm:^10.3.0" + get-east-asian-width: "npm:^1.0.0" + strip-ansi: "npm:^7.1.0" + checksum: 10c0/eb0430dd43f3199c7a46dcbf7a0b34539c76fe3aa62763d0b0655acdcbdf360b3f66f3d58ca25ba0205f42ea3491fa00f09426d3b7d3040e506878fc7664c9b9 + languageName: node + linkType: hard + +"string-width@npm:^8.1.0, string-width@npm:^8.1.1, string-width@npm:^8.2.0": + version: 8.2.1 + resolution: "string-width@npm:8.2.1" + dependencies: + get-east-asian-width: "npm:^1.5.0" + strip-ansi: "npm:^7.1.2" + checksum: 10c0/d467b4eaf4c40a01bb438a2620e77badd2456ffd5131c9973abe4f3acf7c802d5b21f3b6a00a5e33a7fc28ca8f9c103226e01bac61e9f259659c6f46d78e353a + languageName: node + linkType: hard + +"strip-ansi@npm:^7.1.0, strip-ansi@npm:^7.1.2": + version: 7.2.0 + resolution: "strip-ansi@npm:7.2.0" + dependencies: + ansi-regex: "npm:^6.2.2" + checksum: 10c0/544d13b7582f8254811ea97db202f519e189e59d35740c46095897e254e4f1aa9fe1524a83ad6bc5ad67d4dd6c0281d2e0219ed62b880a6238a16a17d375f221 + languageName: node + linkType: hard + +"stylis@npm:4.2.0": + version: 4.2.0 + resolution: "stylis@npm:4.2.0" + checksum: 10c0/a7128ad5a8ed72652c6eba46bed4f416521bc9745a460ef5741edc725252cebf36ee45e33a8615a7057403c93df0866ab9ee955960792db210bb80abd5ac6543 + languageName: node + linkType: hard + +"subscriptions-transport-ws@npm:^0.9.16": + version: 0.9.19 + resolution: "subscriptions-transport-ws@npm:0.9.19" + dependencies: + backo2: "npm:^1.0.2" + eventemitter3: "npm:^3.1.0" + iterall: "npm:^1.2.1" + symbol-observable: "npm:^1.0.4" + ws: "npm:^5.2.0 || ^6.0.0 || ^7.0.0" + peerDependencies: + graphql: ">=0.10.0" + checksum: 10c0/6f2ade56865f0ba291d3ff82c79781b051c2374873bac853286fedfdbc05001b8c4018ab7cba44af667ead7f573e48d18892d58a8f9ca8d90dfb4bff5c125045 + languageName: node + linkType: hard + +"supports-preserve-symlinks-flag@npm:^1.0.0": + version: 1.0.0 + resolution: "supports-preserve-symlinks-flag@npm:1.0.0" + checksum: 10c0/6c4032340701a9950865f7ae8ef38578d8d7053f5e10518076e6554a9381fa91bd9c6850193695c141f32b21f979c985db07265a758867bac95de05f7d8aeb39 + languageName: node + linkType: hard + +"symbol-observable@npm:^1.0.4": + version: 1.2.0 + resolution: "symbol-observable@npm:1.2.0" + checksum: 10c0/009fee50798ef80ed4b8195048288f108b03de162db07493f2e1fd993b33fafa72d659e832b584da5a2427daa78e5a738fb2a9ab027ee9454252e0bedbcd1fdc + languageName: node + linkType: hard + +"tagged-tag@npm:^1.0.0": + version: 1.0.0 + resolution: "tagged-tag@npm:1.0.0" + checksum: 10c0/91d25c9ffb86a91f20522cefb2cbec9b64caa1febe27ad0df52f08993ff60888022d771e868e6416cf2e72dab68449d2139e8709ba009b74c6c7ecd4000048d1 + languageName: node + linkType: hard + +"tar@npm:^7.5.4": + version: 7.5.16 + resolution: "tar@npm:7.5.16" + dependencies: + "@isaacs/fs-minipass": "npm:^4.0.0" + chownr: "npm:^3.0.0" + minipass: "npm:^7.1.2" + minizlib: "npm:^3.1.0" + yallist: "npm:^5.0.0" + checksum: 10c0/4f37f3c4bd2ca2755fd736a5df1d573c1a868ec1b1e893346aeafa95ac510f9e2fd1469420bd866cc7904799e5bd4ac62b5d4f03fe27747d6e1e373b44505c5c + languageName: node + linkType: hard + +"terminal-size@npm:^4.0.1": + version: 4.0.1 + resolution: "terminal-size@npm:4.0.1" + checksum: 10c0/89afd9d816dd9dbfe4499da9aeea70491bbde4ff4592226a9c8ac71074a7580afead6a78e95ecc35f6d42e09087b55ffcb1019302cd55e0cc957b6ce5c4847e8 + languageName: node + linkType: hard + +"tinybench@npm:^2.9.0": + version: 2.9.0 + resolution: "tinybench@npm:2.9.0" + checksum: 10c0/c3500b0f60d2eb8db65250afe750b66d51623057ee88720b7f064894a6cb7eb93360ca824a60a31ab16dab30c7b1f06efe0795b352e37914a9d4bad86386a20c + languageName: node + linkType: hard + +"tinyexec@npm:^1.0.2": + version: 1.2.4 + resolution: "tinyexec@npm:1.2.4" + checksum: 10c0/153b8db6b080194b558ff145b9cffc36b80a6e07babd644dcfbe49c807eee668c876049d28bdee90b96304476f883352f2dad91b3f86bc23832532f4363e66ff + languageName: node + linkType: hard + +"tinyglobby@npm:^0.2.12, tinyglobby@npm:^0.2.15, tinyglobby@npm:^0.2.17": + version: 0.2.17 + resolution: "tinyglobby@npm:0.2.17" + dependencies: + fdir: "npm:^6.5.0" + picomatch: "npm:^4.0.4" + checksum: 10c0/7f7bb0f197c88bc4b20c231e0deca4240ca3bf313a88f5a7fee93a872b84966a4d50220947c0455ad07a60b3b360961c5b7fd979222aeb716a9f99b412002e4c + languageName: node + linkType: hard + +"tinyrainbow@npm:^3.1.0": + version: 3.1.0 + resolution: "tinyrainbow@npm:3.1.0" + checksum: 10c0/f11cf387a26c5c9255bec141a90ac511b26172981b10c3e50053bc6700ea7d2336edcc4a3a21dbb8412fe7c013477d2ba4d7e4877800f3f8107be5105aad6511 + languageName: node + linkType: hard + +"tr46@npm:~0.0.3": + version: 0.0.3 + resolution: "tr46@npm:0.0.3" + checksum: 10c0/047cb209a6b60c742f05c9d3ace8fa510bff609995c129a37ace03476a9b12db4dbf975e74600830ef0796e18882b2381fb5fb1f6b4f96b832c374de3ab91a11 + languageName: node + linkType: hard + +"tsconfck@npm:^3.0.3": + version: 3.1.6 + resolution: "tsconfck@npm:3.1.6" + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + bin: + tsconfck: bin/tsconfck.js + checksum: 10c0/269c3c513540be44844117bb9b9258fe6f8aeab026d32aeebf458d5299125f330711429dbb556dbf125a0bc25f4a81e6c24ac96de2740badd295c3fb400f66c4 + languageName: node + linkType: hard + +"tslib@npm:^1.9.3": + version: 1.14.1 + resolution: "tslib@npm:1.14.1" + checksum: 10c0/69ae09c49eea644bc5ebe1bca4fa4cc2c82b7b3e02f43b84bd891504edf66dbc6b2ec0eef31a957042de2269139e4acff911e6d186a258fb14069cd7f6febce2 + languageName: node + linkType: hard + +"tslib@npm:^2.0.0, tslib@npm:^2.4.0": + version: 2.8.1 + resolution: "tslib@npm:2.8.1" + checksum: 10c0/9c4759110a19c53f992d9aae23aac5ced636e99887b51b9e61def52611732872ff7668757d4e4c61f19691e36f4da981cd9485e869b4a7408d689f6bf1f14e62 + languageName: node + linkType: hard + +"twenty-client-sdk@npm:2.15.0": + version: 2.15.0 + resolution: "twenty-client-sdk@npm:2.15.0" + dependencies: + "@genql/runtime": "npm:^2.10.0" + esbuild: "npm:^0.28.1" + graphql: "npm:^16.8.1" + lodash: "npm:^4.17.21" + prettier: "npm:^3.8.3" + checksum: 10c0/ea5143511ec3d42a0c2eaabda8833bac5c73c25fe04ba967d76b47b22c67468dd59d9e748a00e99a8085ffdb02d6cc5f41556f9e85a9785b2909b63adcb4205f + languageName: node + linkType: hard + +"twenty-meeting-bot@workspace:.": + version: 0.0.0-use.local + resolution: "twenty-meeting-bot@workspace:." + dependencies: + "@emotion/react": "npm:^11.14.0" + "@emotion/styled": "npm:^11.14.0" + "@sniptt/guards": "npm:^0.2.0" + "@types/node": "npm:^24.7.2" + "@types/react": "npm:^19.0.0" + "@typescript/native-preview": "npm:^7.0.0-dev.20260116.1" + oxlint: "npm:^0.16.0" + react: "npm:^19.0.0" + react-dom: "npm:^19.0.0" + twenty-client-sdk: "npm:2.15.0" + twenty-sdk: "npm:2.15.0" + typescript: "npm:^5.9.3" + vite-tsconfig-paths: "npm:^4.2.1" + vitest: "npm:^4.1.9" + languageName: unknown + linkType: soft + +"twenty-sdk@npm:2.15.0": + version: 2.15.0 + resolution: "twenty-sdk@npm:2.15.0" + dependencies: + "@sniptt/guards": "npm:^0.2.0" + axios: "npm:^1.16.0" + chalk: "npm:^5.3.0" + chokidar: "npm:^4.0.0" + commander: "npm:^12.0.0" + esbuild: "npm:^0.28.1" + graphql: "npm:^16.8.1" + graphql-sse: "npm:^2.5.4" + ink: "npm:^6.8.0" + inquirer: "npm:^14.0.0" + jsonc-parser: "npm:^3.2.0" + preact: "npm:^10.28.3" + react: "npm:^19.2.0" + react-dom: "npm:^19.2.0" + semver: "npm:7.6.3" + tinyglobby: "npm:^0.2.15" + twenty-client-sdk: "npm:2.15.0" + typescript: "npm:^5.9.3" + uuid: "npm:^13.0.2" + bin: + twenty: dist/cli.cjs + checksum: 10c0/0fe9a3653f3adaa54eb8398e520328ed2fd51b4c4284b4ed4105dfd92e3c866f2e9b5c6933f952e974684cd38169cdb26d64cfe06f058f4f4cb5bb1f6be7d2fc + languageName: node + linkType: hard + +"type-fest@npm:^5.4.1": + version: 5.7.0 + resolution: "type-fest@npm:5.7.0" + dependencies: + tagged-tag: "npm:^1.0.0" + checksum: 10c0/f71ed17b753649421e419db8cc2e140f930333a1467b1d9cca2e0e4052900fd442f2360bae73f3a6bf9340d949ac46d9a1598c709b4c8089272e7624df9c8716 + languageName: node + linkType: hard + +"typescript@npm:^5.9.3": + version: 5.9.3 + resolution: "typescript@npm:5.9.3" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10c0/6bd7552ce39f97e711db5aa048f6f9995b53f1c52f7d8667c1abdc1700c68a76a308f579cd309ce6b53646deb4e9a1be7c813a93baaf0a28ccd536a30270e1c5 + languageName: node + linkType: hard + +"typescript@patch:typescript@npm%3A^5.9.3#optional!builtin": + version: 5.9.3 + resolution: "typescript@patch:typescript@npm%3A5.9.3#optional!builtin::version=5.9.3&hash=5786d5" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10c0/ad09fdf7a756814dce65bc60c1657b40d44451346858eea230e10f2e95a289d9183b6e32e5c11e95acc0ccc214b4f36289dcad4bf1886b0adb84d711d336a430 + languageName: node + linkType: hard + +"undici-types@npm:>=7.24.0 <7.24.7": + version: 7.24.6 + resolution: "undici-types@npm:7.24.6" + checksum: 10c0/d9cd8befb643ac904615c280a095ba4240531f6bb4a5e75a22a7483630ca8d3f1016d2ab6ace6ceda1f63b3a2db2fe037fafe121d6917a0187573aa548ff78ca + languageName: node + linkType: hard + +"undici-types@npm:~7.18.0": + version: 7.18.2 + resolution: "undici-types@npm:7.18.2" + checksum: 10c0/85a79189113a238959d7a647368e4f7c5559c3a404ebdb8fc4488145ce9426fcd82252a844a302798dfc0e37e6fb178ff481ed03bc4caf634c5757d9ef43521d + languageName: node + linkType: hard + +"undici@npm:^6.25.0": + version: 6.27.0 + resolution: "undici@npm:6.27.0" + checksum: 10c0/f88c3dae3957dbf9d93cb481440aced317bd3c4941b5914fea5efba516d51138988cdb5c76006f0bb1337e41d56c3443351055d492e73af2428521c37ba2a76f + languageName: node + linkType: hard + +"unfetch@npm:^4.2.0": + version: 4.2.0 + resolution: "unfetch@npm:4.2.0" + checksum: 10c0/a5c0a896a6f09f278b868075aea65652ad185db30e827cb7df45826fe5ab850124bf9c44c4dafca4bf0c55a0844b17031e8243467fcc38dd7a7d435007151f1b + languageName: node + linkType: hard + +"utility-types@npm:^3.10.0": + version: 3.11.0 + resolution: "utility-types@npm:3.11.0" + checksum: 10c0/2f1580137b0c3e6cf5405f37aaa8f5249961a76d26f1ca8efc0ff49a2fc0e0b2db56de8e521a174d075758e0c7eb3e590edec0832eb44478b958f09914920f19 + languageName: node + linkType: hard + +"uuid@npm:^13.0.2": + version: 13.0.2 + resolution: "uuid@npm:13.0.2" + bin: + uuid: dist-node/bin/uuid + checksum: 10c0/32c7ee84fa7c7966cc09b3a1514a752a8e2f609f15c00033fbaedc0255d577d1877b839f11ee537f37e587bbc620bbb98c3b3a1482931b8ed47d79497cd7a7cd + languageName: node + linkType: hard + +"vite-tsconfig-paths@npm:^4.2.1": + version: 4.3.2 + resolution: "vite-tsconfig-paths@npm:4.3.2" + dependencies: + debug: "npm:^4.1.1" + globrex: "npm:^0.1.2" + tsconfck: "npm:^3.0.3" + peerDependencies: + vite: "*" + peerDependenciesMeta: + vite: + optional: true + checksum: 10c0/f390ac1d1c3992fc5ac50f9274c1090f8b55ab34a89ea88893db9a6924a3b26c9f64bc1163615150ad100749db73b6b2cf1d57f6cd60df6e762ceb5b8ad30024 + languageName: node + linkType: hard + +"vite@npm:^6.0.0 || ^7.0.0 || ^8.0.0": + version: 8.0.16 + resolution: "vite@npm:8.0.16" + dependencies: + fsevents: "npm:~2.3.3" + lightningcss: "npm:^1.32.0" + picomatch: "npm:^4.0.4" + postcss: "npm:^8.5.15" + rolldown: "npm:1.0.3" + tinyglobby: "npm:^0.2.17" + peerDependencies: + "@types/node": ^20.19.0 || >=22.12.0 + "@vitejs/devtools": ^0.1.18 + esbuild: ^0.27.0 || ^0.28.0 + jiti: ">=1.21.0" + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: ">=0.54.8" + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + dependenciesMeta: + fsevents: + optional: true + peerDependenciesMeta: + "@types/node": + optional: true + "@vitejs/devtools": + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + bin: + vite: bin/vite.js + checksum: 10c0/d75be3fbe2f63e6a8145325970338afaf0dd4d96ba9175c13f9a286fd5f95afc489401b693e4fa6c0899a4dd0e137be91cdf9401a40a635563911ad5036e3467 + languageName: node + linkType: hard + +"vitest@npm:^4.1.9": + version: 4.1.9 + resolution: "vitest@npm:4.1.9" + dependencies: + "@vitest/expect": "npm:4.1.9" + "@vitest/mocker": "npm:4.1.9" + "@vitest/pretty-format": "npm:4.1.9" + "@vitest/runner": "npm:4.1.9" + "@vitest/snapshot": "npm:4.1.9" + "@vitest/spy": "npm:4.1.9" + "@vitest/utils": "npm:4.1.9" + es-module-lexer: "npm:^2.0.0" + expect-type: "npm:^1.3.0" + magic-string: "npm:^0.30.21" + obug: "npm:^2.1.1" + pathe: "npm:^2.0.3" + picomatch: "npm:^4.0.3" + std-env: "npm:^4.0.0-rc.1" + tinybench: "npm:^2.9.0" + tinyexec: "npm:^1.0.2" + tinyglobby: "npm:^0.2.15" + tinyrainbow: "npm:^3.1.0" + vite: "npm:^6.0.0 || ^7.0.0 || ^8.0.0" + why-is-node-running: "npm:^2.3.0" + peerDependencies: + "@edge-runtime/vm": "*" + "@opentelemetry/api": ^1.9.0 + "@types/node": ^20.0.0 || ^22.0.0 || >=24.0.0 + "@vitest/browser-playwright": 4.1.9 + "@vitest/browser-preview": 4.1.9 + "@vitest/browser-webdriverio": 4.1.9 + "@vitest/coverage-istanbul": 4.1.9 + "@vitest/coverage-v8": 4.1.9 + "@vitest/ui": 4.1.9 + happy-dom: "*" + jsdom: "*" + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + "@edge-runtime/vm": + optional: true + "@opentelemetry/api": + optional: true + "@types/node": + optional: true + "@vitest/browser-playwright": + optional: true + "@vitest/browser-preview": + optional: true + "@vitest/browser-webdriverio": + optional: true + "@vitest/coverage-istanbul": + optional: true + "@vitest/coverage-v8": + optional: true + "@vitest/ui": + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vite: + optional: false + bin: + vitest: ./vitest.mjs + checksum: 10c0/1ac80ef4991be82822a52aea48415f1bc64ddf8fd88ee24c172ec368f1d480fefacbde622c3c951982f7961a1d07313e18deaafc774d29e42ad6f6ffa63334a7 + languageName: node + linkType: hard + +"webidl-conversions@npm:^3.0.0": + version: 3.0.1 + resolution: "webidl-conversions@npm:3.0.1" + checksum: 10c0/5612d5f3e54760a797052eb4927f0ddc01383550f542ccd33d5238cfd65aeed392a45ad38364970d0a0f4fea32e1f4d231b3d8dac4a3bdd385e5cf802ae097db + languageName: node + linkType: hard + +"whatwg-url@npm:^5.0.0": + version: 5.0.0 + resolution: "whatwg-url@npm:5.0.0" + dependencies: + tr46: "npm:~0.0.3" + webidl-conversions: "npm:^3.0.0" + checksum: 10c0/1588bed84d10b72d5eec1d0faa0722ba1962f1821e7539c535558fb5398d223b0c50d8acab950b8c488b4ba69043fd833cc2697056b167d8ad46fac3995a55d5 + languageName: node + linkType: hard + +"which@npm:^6.0.0": + version: 6.0.1 + resolution: "which@npm:6.0.1" + dependencies: + isexe: "npm:^4.0.0" + bin: + node-which: bin/which.js + checksum: 10c0/7e710e54ea36d2d6183bee2f9caa27a3b47b9baf8dee55a199b736fcf85eab3b9df7556fca3d02b50af7f3dfba5ea3a45644189836df06267df457e354da66d5 + languageName: node + linkType: hard + +"why-is-node-running@npm:^2.3.0": + version: 2.3.0 + resolution: "why-is-node-running@npm:2.3.0" + dependencies: + siginfo: "npm:^2.0.0" + stackback: "npm:0.0.2" + bin: + why-is-node-running: cli.js + checksum: 10c0/1cde0b01b827d2cf4cb11db962f3958b9175d5d9e7ac7361d1a7b0e2dc6069a263e69118bd974c4f6d0a890ef4eedfe34cf3d5167ec14203dbc9a18620537054 + languageName: node + linkType: hard + +"widest-line@npm:^6.0.0": + version: 6.0.0 + resolution: "widest-line@npm:6.0.0" + dependencies: + string-width: "npm:^8.1.0" + checksum: 10c0/735f1fdcd97fe765a07bb8b5e73c020bed8e53ab34e83ce0ef01693ba3c914d9e7977fe5f5facf0d0b670297a82dd5e376d3efa0896860dfcdaf7cd6924c0fb7 + languageName: node + linkType: hard + +"wrap-ansi@npm:^9.0.0": + version: 9.0.2 + resolution: "wrap-ansi@npm:9.0.2" + dependencies: + ansi-styles: "npm:^6.2.1" + string-width: "npm:^7.0.0" + strip-ansi: "npm:^7.1.0" + checksum: 10c0/3305839b9a0d6fb930cb63a52f34d3936013d8b0682ff3ec133c9826512620f213800ffa19ea22904876d5b7e9a3c1f40682f03597d986a4ca881fa7b033688c + languageName: node + linkType: hard + +"ws@npm:^5.2.0 || ^6.0.0 || ^7.0.0": + version: 7.5.11 + resolution: "ws@npm:7.5.11" + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ^5.0.2 + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + checksum: 10c0/7972670b676fb1ccba73b0899ca3c2e04e8c2075629c2614cced7f556536f96a672bbf4619fc5a06c8b8720bb839a47ca88c69c95dc14c9c61a99fbecba1c866 + languageName: node + linkType: hard + +"ws@npm:^6.1.4": + version: 6.2.4 + resolution: "ws@npm:6.2.4" + dependencies: + async-limiter: "npm:~1.0.0" + checksum: 10c0/5c2b9474164f9cb68c7776a1d10b0461c186f3a69bffb1028fca33eba5ab7206a09173fb0b311d6c5a81c8cf148406f8deb0b7d899542ab8ca67407d99717dad + languageName: node + linkType: hard + +"ws@npm:^8.18.0": + version: 8.21.0 + resolution: "ws@npm:8.21.0" + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ">=5.0.2" + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + checksum: 10c0/ef4a243476283fc49bc7550966c4af4aa0eef56273837211e700de3b664e08604a760cdddcb5ba43c049140e74ccfec5b0ee0bb439e08c2adf9138902fdde5f9 + languageName: node + linkType: hard + +"yallist@npm:^5.0.0": + version: 5.0.0 + resolution: "yallist@npm:5.0.0" + checksum: 10c0/a499c81ce6d4a1d260d4ea0f6d49ab4da09681e32c3f0472dee16667ed69d01dae63a3b81745a24bd78476ec4fcf856114cb4896ace738e01da34b2c42235416 + languageName: node + linkType: hard + +"yaml@npm:^1.10.0": + version: 1.10.3 + resolution: "yaml@npm:1.10.3" + checksum: 10c0/c309ff85a0a569a981d71ab9cf0fef68672a16b9cdf40639d1c3b30034f6cd16ee428602bd6d64ecf006f8c8bee499023cac236538f79898aa99fb5db529a2ed + languageName: node + linkType: hard + +"yoga-layout@npm:~3.2.1": + version: 3.2.1 + resolution: "yoga-layout@npm:3.2.1" + checksum: 10c0/9001e51be993c85e03757e5a04a2b61b8b30c9e5a7865d0156ca87a6431a3b717d51eb4990bfe588189fcfeac688dd9c3de707bbd50d1c344a84e63974cc54a8 + languageName: node + linkType: hard + +"zen-observable-ts@npm:^0.8.21": + version: 0.8.21 + resolution: "zen-observable-ts@npm:0.8.21" + dependencies: + tslib: "npm:^1.9.3" + zen-observable: "npm:^0.8.0" + checksum: 10c0/fe4a02f862b5f7e8ae0f86230c37b84c7d5611f5c206981afb4043e732d04cf7067a6cbe1ba82d20f18b735a3387937195a12542158a631d308ae3959a1d93c4 + languageName: node + linkType: hard + +"zen-observable@npm:^0.8.0": + version: 0.8.15 + resolution: "zen-observable@npm:0.8.15" + checksum: 10c0/71cc2f2bbb537300c3f569e25693d37b3bc91f225cefce251a71c30bc6bb3e7f8e9420ca0eb57f2ac9e492b085b8dfa075fd1e8195c40b83c951dd59c6e4fbf8 + languageName: node + linkType: hard diff --git a/packages/twenty-apps/internal/twenty-slack/.gitignore b/packages/twenty-apps/public/twenty-slack/.gitignore similarity index 100% rename from packages/twenty-apps/internal/twenty-slack/.gitignore rename to packages/twenty-apps/public/twenty-slack/.gitignore diff --git a/packages/twenty-apps/public/twenty-slack/.nvmrc b/packages/twenty-apps/public/twenty-slack/.nvmrc new file mode 100644 index 0000000000..341cb50613 --- /dev/null +++ b/packages/twenty-apps/public/twenty-slack/.nvmrc @@ -0,0 +1 @@ +24.5.0 diff --git a/packages/twenty-apps/internal/twenty-slack/.oxlintrc.json b/packages/twenty-apps/public/twenty-slack/.oxlintrc.json similarity index 100% rename from packages/twenty-apps/internal/twenty-slack/.oxlintrc.json rename to packages/twenty-apps/public/twenty-slack/.oxlintrc.json diff --git a/packages/twenty-apps/internal/twenty-slack/.yarn/install-state.gz b/packages/twenty-apps/public/twenty-slack/.yarn/install-state.gz similarity index 100% rename from packages/twenty-apps/internal/twenty-slack/.yarn/install-state.gz rename to packages/twenty-apps/public/twenty-slack/.yarn/install-state.gz diff --git a/packages/twenty-apps/public/twenty-slack/.yarnrc.yml b/packages/twenty-apps/public/twenty-slack/.yarnrc.yml new file mode 100644 index 0000000000..3186f3f079 --- /dev/null +++ b/packages/twenty-apps/public/twenty-slack/.yarnrc.yml @@ -0,0 +1 @@ +nodeLinker: node-modules diff --git a/packages/twenty-apps/internal/twenty-slack/README.md b/packages/twenty-apps/public/twenty-slack/README.md similarity index 100% rename from packages/twenty-apps/internal/twenty-slack/README.md rename to packages/twenty-apps/public/twenty-slack/README.md diff --git a/packages/twenty-apps/internal/twenty-slack/package.json b/packages/twenty-apps/public/twenty-slack/package.json similarity index 100% rename from packages/twenty-apps/internal/twenty-slack/package.json rename to packages/twenty-apps/public/twenty-slack/package.json diff --git a/packages/twenty-apps/internal/twenty-slack/public/twenty-slack.svg b/packages/twenty-apps/public/twenty-slack/public/twenty-slack.svg similarity index 100% rename from packages/twenty-apps/internal/twenty-slack/public/twenty-slack.svg rename to packages/twenty-apps/public/twenty-slack/public/twenty-slack.svg diff --git a/packages/twenty-apps/internal/twenty-slack/src/__tests__/global-setup.ts b/packages/twenty-apps/public/twenty-slack/src/__tests__/global-setup.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-slack/src/__tests__/global-setup.ts rename to packages/twenty-apps/public/twenty-slack/src/__tests__/global-setup.ts diff --git a/packages/twenty-apps/internal/twenty-slack/src/__tests__/twenty-slack.integration-test.ts b/packages/twenty-apps/public/twenty-slack/src/__tests__/twenty-slack.integration-test.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-slack/src/__tests__/twenty-slack.integration-test.ts rename to packages/twenty-apps/public/twenty-slack/src/__tests__/twenty-slack.integration-test.ts diff --git a/packages/twenty-apps/internal/twenty-slack/src/application.config.ts b/packages/twenty-apps/public/twenty-slack/src/application.config.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-slack/src/application.config.ts rename to packages/twenty-apps/public/twenty-slack/src/application.config.ts diff --git a/packages/twenty-apps/internal/twenty-slack/src/command-menu-items/send-slack-message.command-menu-item.ts b/packages/twenty-apps/public/twenty-slack/src/command-menu-items/send-slack-message.command-menu-item.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-slack/src/command-menu-items/send-slack-message.command-menu-item.ts rename to packages/twenty-apps/public/twenty-slack/src/command-menu-items/send-slack-message.command-menu-item.ts diff --git a/packages/twenty-apps/internal/twenty-slack/src/components/send-message-form.front-component.tsx b/packages/twenty-apps/public/twenty-slack/src/components/send-message-form.front-component.tsx similarity index 100% rename from packages/twenty-apps/internal/twenty-slack/src/components/send-message-form.front-component.tsx rename to packages/twenty-apps/public/twenty-slack/src/components/send-message-form.front-component.tsx diff --git a/packages/twenty-apps/internal/twenty-slack/src/connection-providers/slack-connection.ts b/packages/twenty-apps/public/twenty-slack/src/connection-providers/slack-connection.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-slack/src/connection-providers/slack-connection.ts rename to packages/twenty-apps/public/twenty-slack/src/connection-providers/slack-connection.ts diff --git a/packages/twenty-apps/internal/twenty-slack/src/constants/universal-identifiers.ts b/packages/twenty-apps/public/twenty-slack/src/constants/universal-identifiers.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-slack/src/constants/universal-identifiers.ts rename to packages/twenty-apps/public/twenty-slack/src/constants/universal-identifiers.ts diff --git a/packages/twenty-apps/internal/twenty-slack/src/logic-functions/handlers/__tests__/slack-add-reaction-handler.test.ts b/packages/twenty-apps/public/twenty-slack/src/logic-functions/handlers/__tests__/slack-add-reaction-handler.test.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-slack/src/logic-functions/handlers/__tests__/slack-add-reaction-handler.test.ts rename to packages/twenty-apps/public/twenty-slack/src/logic-functions/handlers/__tests__/slack-add-reaction-handler.test.ts diff --git a/packages/twenty-apps/internal/twenty-slack/src/logic-functions/handlers/__tests__/slack-post-message-handler.test.ts b/packages/twenty-apps/public/twenty-slack/src/logic-functions/handlers/__tests__/slack-post-message-handler.test.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-slack/src/logic-functions/handlers/__tests__/slack-post-message-handler.test.ts rename to packages/twenty-apps/public/twenty-slack/src/logic-functions/handlers/__tests__/slack-post-message-handler.test.ts diff --git a/packages/twenty-apps/internal/twenty-slack/src/logic-functions/handlers/slack-add-reaction-handler.ts b/packages/twenty-apps/public/twenty-slack/src/logic-functions/handlers/slack-add-reaction-handler.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-slack/src/logic-functions/handlers/slack-add-reaction-handler.ts rename to packages/twenty-apps/public/twenty-slack/src/logic-functions/handlers/slack-add-reaction-handler.ts diff --git a/packages/twenty-apps/internal/twenty-slack/src/logic-functions/handlers/slack-delete-message-handler.ts b/packages/twenty-apps/public/twenty-slack/src/logic-functions/handlers/slack-delete-message-handler.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-slack/src/logic-functions/handlers/slack-delete-message-handler.ts rename to packages/twenty-apps/public/twenty-slack/src/logic-functions/handlers/slack-delete-message-handler.ts diff --git a/packages/twenty-apps/internal/twenty-slack/src/logic-functions/handlers/slack-list-channels-handler.ts b/packages/twenty-apps/public/twenty-slack/src/logic-functions/handlers/slack-list-channels-handler.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-slack/src/logic-functions/handlers/slack-list-channels-handler.ts rename to packages/twenty-apps/public/twenty-slack/src/logic-functions/handlers/slack-list-channels-handler.ts diff --git a/packages/twenty-apps/internal/twenty-slack/src/logic-functions/handlers/slack-post-ephemeral-message-handler.ts b/packages/twenty-apps/public/twenty-slack/src/logic-functions/handlers/slack-post-ephemeral-message-handler.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-slack/src/logic-functions/handlers/slack-post-ephemeral-message-handler.ts rename to packages/twenty-apps/public/twenty-slack/src/logic-functions/handlers/slack-post-ephemeral-message-handler.ts diff --git a/packages/twenty-apps/internal/twenty-slack/src/logic-functions/handlers/slack-post-message-handler.ts b/packages/twenty-apps/public/twenty-slack/src/logic-functions/handlers/slack-post-message-handler.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-slack/src/logic-functions/handlers/slack-post-message-handler.ts rename to packages/twenty-apps/public/twenty-slack/src/logic-functions/handlers/slack-post-message-handler.ts diff --git a/packages/twenty-apps/internal/twenty-slack/src/logic-functions/handlers/slack-update-message-handler.ts b/packages/twenty-apps/public/twenty-slack/src/logic-functions/handlers/slack-update-message-handler.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-slack/src/logic-functions/handlers/slack-update-message-handler.ts rename to packages/twenty-apps/public/twenty-slack/src/logic-functions/handlers/slack-update-message-handler.ts diff --git a/packages/twenty-apps/internal/twenty-slack/src/logic-functions/schemas/slack-add-reaction-input.schema.ts b/packages/twenty-apps/public/twenty-slack/src/logic-functions/schemas/slack-add-reaction-input.schema.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-slack/src/logic-functions/schemas/slack-add-reaction-input.schema.ts rename to packages/twenty-apps/public/twenty-slack/src/logic-functions/schemas/slack-add-reaction-input.schema.ts diff --git a/packages/twenty-apps/internal/twenty-slack/src/logic-functions/schemas/slack-delete-message-input.schema.ts b/packages/twenty-apps/public/twenty-slack/src/logic-functions/schemas/slack-delete-message-input.schema.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-slack/src/logic-functions/schemas/slack-delete-message-input.schema.ts rename to packages/twenty-apps/public/twenty-slack/src/logic-functions/schemas/slack-delete-message-input.schema.ts diff --git a/packages/twenty-apps/internal/twenty-slack/src/logic-functions/schemas/slack-list-channels-input.schema.ts b/packages/twenty-apps/public/twenty-slack/src/logic-functions/schemas/slack-list-channels-input.schema.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-slack/src/logic-functions/schemas/slack-list-channels-input.schema.ts rename to packages/twenty-apps/public/twenty-slack/src/logic-functions/schemas/slack-list-channels-input.schema.ts diff --git a/packages/twenty-apps/internal/twenty-slack/src/logic-functions/schemas/slack-post-ephemeral-message-input.schema.ts b/packages/twenty-apps/public/twenty-slack/src/logic-functions/schemas/slack-post-ephemeral-message-input.schema.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-slack/src/logic-functions/schemas/slack-post-ephemeral-message-input.schema.ts rename to packages/twenty-apps/public/twenty-slack/src/logic-functions/schemas/slack-post-ephemeral-message-input.schema.ts diff --git a/packages/twenty-apps/internal/twenty-slack/src/logic-functions/schemas/slack-post-message-input.schema.ts b/packages/twenty-apps/public/twenty-slack/src/logic-functions/schemas/slack-post-message-input.schema.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-slack/src/logic-functions/schemas/slack-post-message-input.schema.ts rename to packages/twenty-apps/public/twenty-slack/src/logic-functions/schemas/slack-post-message-input.schema.ts diff --git a/packages/twenty-apps/internal/twenty-slack/src/logic-functions/schemas/slack-update-message-input.schema.ts b/packages/twenty-apps/public/twenty-slack/src/logic-functions/schemas/slack-update-message-input.schema.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-slack/src/logic-functions/schemas/slack-update-message-input.schema.ts rename to packages/twenty-apps/public/twenty-slack/src/logic-functions/schemas/slack-update-message-input.schema.ts diff --git a/packages/twenty-apps/internal/twenty-slack/src/logic-functions/slack-add-reaction.ts b/packages/twenty-apps/public/twenty-slack/src/logic-functions/slack-add-reaction.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-slack/src/logic-functions/slack-add-reaction.ts rename to packages/twenty-apps/public/twenty-slack/src/logic-functions/slack-add-reaction.ts diff --git a/packages/twenty-apps/internal/twenty-slack/src/logic-functions/slack-delete-message.ts b/packages/twenty-apps/public/twenty-slack/src/logic-functions/slack-delete-message.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-slack/src/logic-functions/slack-delete-message.ts rename to packages/twenty-apps/public/twenty-slack/src/logic-functions/slack-delete-message.ts diff --git a/packages/twenty-apps/internal/twenty-slack/src/logic-functions/slack-list-channels-route.ts b/packages/twenty-apps/public/twenty-slack/src/logic-functions/slack-list-channels-route.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-slack/src/logic-functions/slack-list-channels-route.ts rename to packages/twenty-apps/public/twenty-slack/src/logic-functions/slack-list-channels-route.ts diff --git a/packages/twenty-apps/internal/twenty-slack/src/logic-functions/slack-list-channels.ts b/packages/twenty-apps/public/twenty-slack/src/logic-functions/slack-list-channels.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-slack/src/logic-functions/slack-list-channels.ts rename to packages/twenty-apps/public/twenty-slack/src/logic-functions/slack-list-channels.ts diff --git a/packages/twenty-apps/internal/twenty-slack/src/logic-functions/slack-post-ephemeral-message.ts b/packages/twenty-apps/public/twenty-slack/src/logic-functions/slack-post-ephemeral-message.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-slack/src/logic-functions/slack-post-ephemeral-message.ts rename to packages/twenty-apps/public/twenty-slack/src/logic-functions/slack-post-ephemeral-message.ts diff --git a/packages/twenty-apps/internal/twenty-slack/src/logic-functions/slack-post-message-route.ts b/packages/twenty-apps/public/twenty-slack/src/logic-functions/slack-post-message-route.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-slack/src/logic-functions/slack-post-message-route.ts rename to packages/twenty-apps/public/twenty-slack/src/logic-functions/slack-post-message-route.ts diff --git a/packages/twenty-apps/internal/twenty-slack/src/logic-functions/slack-post-message.ts b/packages/twenty-apps/public/twenty-slack/src/logic-functions/slack-post-message.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-slack/src/logic-functions/slack-post-message.ts rename to packages/twenty-apps/public/twenty-slack/src/logic-functions/slack-post-message.ts diff --git a/packages/twenty-apps/internal/twenty-slack/src/logic-functions/slack-update-message.ts b/packages/twenty-apps/public/twenty-slack/src/logic-functions/slack-update-message.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-slack/src/logic-functions/slack-update-message.ts rename to packages/twenty-apps/public/twenty-slack/src/logic-functions/slack-update-message.ts diff --git a/packages/twenty-apps/internal/twenty-slack/src/logic-functions/types/slack-add-reaction-input.type.ts b/packages/twenty-apps/public/twenty-slack/src/logic-functions/types/slack-add-reaction-input.type.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-slack/src/logic-functions/types/slack-add-reaction-input.type.ts rename to packages/twenty-apps/public/twenty-slack/src/logic-functions/types/slack-add-reaction-input.type.ts diff --git a/packages/twenty-apps/internal/twenty-slack/src/logic-functions/types/slack-delete-message-input.type.ts b/packages/twenty-apps/public/twenty-slack/src/logic-functions/types/slack-delete-message-input.type.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-slack/src/logic-functions/types/slack-delete-message-input.type.ts rename to packages/twenty-apps/public/twenty-slack/src/logic-functions/types/slack-delete-message-input.type.ts diff --git a/packages/twenty-apps/internal/twenty-slack/src/logic-functions/types/slack-list-channels-input.type.ts b/packages/twenty-apps/public/twenty-slack/src/logic-functions/types/slack-list-channels-input.type.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-slack/src/logic-functions/types/slack-list-channels-input.type.ts rename to packages/twenty-apps/public/twenty-slack/src/logic-functions/types/slack-list-channels-input.type.ts diff --git a/packages/twenty-apps/internal/twenty-slack/src/logic-functions/types/slack-list-channels-result.type.ts b/packages/twenty-apps/public/twenty-slack/src/logic-functions/types/slack-list-channels-result.type.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-slack/src/logic-functions/types/slack-list-channels-result.type.ts rename to packages/twenty-apps/public/twenty-slack/src/logic-functions/types/slack-list-channels-result.type.ts diff --git a/packages/twenty-apps/internal/twenty-slack/src/logic-functions/types/slack-message-body-format.type.ts b/packages/twenty-apps/public/twenty-slack/src/logic-functions/types/slack-message-body-format.type.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-slack/src/logic-functions/types/slack-message-body-format.type.ts rename to packages/twenty-apps/public/twenty-slack/src/logic-functions/types/slack-message-body-format.type.ts diff --git a/packages/twenty-apps/internal/twenty-slack/src/logic-functions/types/slack-post-ephemeral-message-input.type.ts b/packages/twenty-apps/public/twenty-slack/src/logic-functions/types/slack-post-ephemeral-message-input.type.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-slack/src/logic-functions/types/slack-post-ephemeral-message-input.type.ts rename to packages/twenty-apps/public/twenty-slack/src/logic-functions/types/slack-post-ephemeral-message-input.type.ts diff --git a/packages/twenty-apps/internal/twenty-slack/src/logic-functions/types/slack-post-message-input.type.ts b/packages/twenty-apps/public/twenty-slack/src/logic-functions/types/slack-post-message-input.type.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-slack/src/logic-functions/types/slack-post-message-input.type.ts rename to packages/twenty-apps/public/twenty-slack/src/logic-functions/types/slack-post-message-input.type.ts diff --git a/packages/twenty-apps/internal/twenty-slack/src/logic-functions/types/slack-tool-result.type.ts b/packages/twenty-apps/public/twenty-slack/src/logic-functions/types/slack-tool-result.type.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-slack/src/logic-functions/types/slack-tool-result.type.ts rename to packages/twenty-apps/public/twenty-slack/src/logic-functions/types/slack-tool-result.type.ts diff --git a/packages/twenty-apps/internal/twenty-slack/src/logic-functions/types/slack-update-message-input.type.ts b/packages/twenty-apps/public/twenty-slack/src/logic-functions/types/slack-update-message-input.type.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-slack/src/logic-functions/types/slack-update-message-input.type.ts rename to packages/twenty-apps/public/twenty-slack/src/logic-functions/types/slack-update-message-input.type.ts diff --git a/packages/twenty-apps/internal/twenty-slack/src/logic-functions/utils/__tests__/get-slack-chat-message-body-fields.test.ts b/packages/twenty-apps/public/twenty-slack/src/logic-functions/utils/__tests__/get-slack-chat-message-body-fields.test.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-slack/src/logic-functions/utils/__tests__/get-slack-chat-message-body-fields.test.ts rename to packages/twenty-apps/public/twenty-slack/src/logic-functions/utils/__tests__/get-slack-chat-message-body-fields.test.ts diff --git a/packages/twenty-apps/internal/twenty-slack/src/logic-functions/utils/__tests__/get-slack-connection.test.ts b/packages/twenty-apps/public/twenty-slack/src/logic-functions/utils/__tests__/get-slack-connection.test.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-slack/src/logic-functions/utils/__tests__/get-slack-connection.test.ts rename to packages/twenty-apps/public/twenty-slack/src/logic-functions/utils/__tests__/get-slack-connection.test.ts diff --git a/packages/twenty-apps/internal/twenty-slack/src/logic-functions/utils/__tests__/slack-tool-failure.test.ts b/packages/twenty-apps/public/twenty-slack/src/logic-functions/utils/__tests__/slack-tool-failure.test.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-slack/src/logic-functions/utils/__tests__/slack-tool-failure.test.ts rename to packages/twenty-apps/public/twenty-slack/src/logic-functions/utils/__tests__/slack-tool-failure.test.ts diff --git a/packages/twenty-apps/internal/twenty-slack/src/logic-functions/utils/get-slack-chat-message-body-fields.ts b/packages/twenty-apps/public/twenty-slack/src/logic-functions/utils/get-slack-chat-message-body-fields.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-slack/src/logic-functions/utils/get-slack-chat-message-body-fields.ts rename to packages/twenty-apps/public/twenty-slack/src/logic-functions/utils/get-slack-chat-message-body-fields.ts diff --git a/packages/twenty-apps/internal/twenty-slack/src/logic-functions/utils/get-slack-client.ts b/packages/twenty-apps/public/twenty-slack/src/logic-functions/utils/get-slack-client.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-slack/src/logic-functions/utils/get-slack-client.ts rename to packages/twenty-apps/public/twenty-slack/src/logic-functions/utils/get-slack-client.ts diff --git a/packages/twenty-apps/internal/twenty-slack/src/logic-functions/utils/get-slack-connection.ts b/packages/twenty-apps/public/twenty-slack/src/logic-functions/utils/get-slack-connection.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-slack/src/logic-functions/utils/get-slack-connection.ts rename to packages/twenty-apps/public/twenty-slack/src/logic-functions/utils/get-slack-connection.ts diff --git a/packages/twenty-apps/internal/twenty-slack/src/logic-functions/utils/json-schema-to-input-schema.ts b/packages/twenty-apps/public/twenty-slack/src/logic-functions/utils/json-schema-to-input-schema.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-slack/src/logic-functions/utils/json-schema-to-input-schema.ts rename to packages/twenty-apps/public/twenty-slack/src/logic-functions/utils/json-schema-to-input-schema.ts diff --git a/packages/twenty-apps/internal/twenty-slack/src/logic-functions/utils/slack-tool-failure.ts b/packages/twenty-apps/public/twenty-slack/src/logic-functions/utils/slack-tool-failure.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-slack/src/logic-functions/utils/slack-tool-failure.ts rename to packages/twenty-apps/public/twenty-slack/src/logic-functions/utils/slack-tool-failure.ts diff --git a/packages/twenty-apps/internal/twenty-slack/src/roles/default-function.role.ts b/packages/twenty-apps/public/twenty-slack/src/roles/default-function.role.ts similarity index 100% rename from packages/twenty-apps/internal/twenty-slack/src/roles/default-function.role.ts rename to packages/twenty-apps/public/twenty-slack/src/roles/default-function.role.ts diff --git a/packages/twenty-apps/internal/twenty-slack/tsconfig.json b/packages/twenty-apps/public/twenty-slack/tsconfig.json similarity index 100% rename from packages/twenty-apps/internal/twenty-slack/tsconfig.json rename to packages/twenty-apps/public/twenty-slack/tsconfig.json diff --git a/packages/twenty-apps/public/twenty-slack/tsconfig.spec.json b/packages/twenty-apps/public/twenty-slack/tsconfig.spec.json new file mode 100644 index 0000000000..ea69a6c010 --- /dev/null +++ b/packages/twenty-apps/public/twenty-slack/tsconfig.spec.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": true, + "types": ["vitest/globals", "node"] + }, + "include": ["src/**/*.ts", "src/**/*.tsx"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/twenty-apps/public/twenty-slack/vitest.config.ts b/packages/twenty-apps/public/twenty-slack/vitest.config.ts new file mode 100644 index 0000000000..055af77dbe --- /dev/null +++ b/packages/twenty-apps/public/twenty-slack/vitest.config.ts @@ -0,0 +1,31 @@ +import tsconfigPaths from 'vite-tsconfig-paths'; +import { defineConfig } from 'vitest/config'; + +const TWENTY_API_URL = process.env.TWENTY_API_URL ?? 'http://localhost:2020'; +const TWENTY_API_KEY = + process.env.TWENTY_API_KEY ?? + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC0xYzI1LTRkMDItYmYyNS02YWVjY2Y3ZWE0MTkiLCJ0eXBlIjoiQVBJX0tFWSIsIndvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWMyNS00ZDAyLWJmMjUtNmFlY2NmN2VhNDE5IiwiaWF0IjoxNzM1Njg5NjAwLCJleHAiOjQ4OTE0NDk2MDAsImp0aSI6IjIwMjAyMDIwLWY0MDEtNGQ4YS1hNzMxLTY0ZDAwN2MyN2JhZCJ9.bfQjfyN0NEtTCLE_xPyNcwonDzlSXFoP8kdCQTdnuDc'; + +// Make env vars available to globalSetup (test.env only applies to workers) +process.env.TWENTY_API_URL = TWENTY_API_URL; +process.env.TWENTY_API_KEY = TWENTY_API_KEY; + +export default defineConfig({ + plugins: [ + tsconfigPaths({ + projects: ['tsconfig.spec.json'], + ignoreConfigErrors: true, + }), + ], + test: { + testTimeout: 120_000, + hookTimeout: 120_000, + fileParallelism: false, + include: ['src/**/*.integration-test.ts'], + globalSetup: ['src/__tests__/global-setup.ts'], + env: { + TWENTY_API_URL, + TWENTY_API_KEY, + }, + }, +}); diff --git a/packages/twenty-apps/public/twenty-slack/vitest.unit.config.ts b/packages/twenty-apps/public/twenty-slack/vitest.unit.config.ts new file mode 100644 index 0000000000..e0d140015b --- /dev/null +++ b/packages/twenty-apps/public/twenty-slack/vitest.unit.config.ts @@ -0,0 +1,14 @@ +import tsconfigPaths from 'vite-tsconfig-paths'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + plugins: [ + tsconfigPaths({ + projects: ['tsconfig.spec.json'], + ignoreConfigErrors: true, + }), + ], + test: { + include: ['src/**/*.test.ts'], + }, +}); diff --git a/packages/twenty-apps/internal/twenty-slack/yarn.lock b/packages/twenty-apps/public/twenty-slack/yarn.lock similarity index 100% rename from packages/twenty-apps/internal/twenty-slack/yarn.lock rename to packages/twenty-apps/public/twenty-slack/yarn.lock