Add call recording schema and meeting bot scaffold (#21584)

## Summary
- add 2.13 upgrade commands for call recording request status and
dropping CalendarEvent recordingPreference
- remove the recording preference from the core CalendarEvent standard
object
- add a scaffold-generated twenty-meeting-bot app with logo and the
CalendarEvent meetingBotPreference field

## Tests
- yarn install
- yarn lint
- yarn twenty dev:typecheck
- git diff --check


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21584?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
nitin
2026-06-15 19:03:12 +05:30
committed by GitHub
parent d1ba63d4a4
commit 8a866dba54
47 changed files with 5716 additions and 1502 deletions
@@ -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=
@@ -0,0 +1,42 @@
name: CD
on:
push:
branches:
- main
pull_request:
types: [labeled]
permissions:
contents: read
env:
TWENTY_DEPLOY_URL: http://localhost:2020
concurrency:
group: cd-${{ github.ref }}
cancel-in-progress: true
jobs:
deploy-and-install:
if: >-
github.event_name == 'push' ||
(github.event_name == 'pull_request' && github.event.label.name == 'deploy')
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Deploy
uses: twentyhq/twenty/.github/actions/deploy-twenty-app@main
with:
api-url: ${{ env.TWENTY_DEPLOY_URL }}
api-key: ${{ secrets.TWENTY_DEPLOY_API_KEY }}
- name: Install
uses: twentyhq/twenty/.github/actions/install-twenty-app@main
with:
api-url: ${{ env.TWENTY_DEPLOY_URL }}
api-key: ${{ secrets.TWENTY_DEPLOY_API_KEY }}
@@ -0,0 +1,48 @@
name: CI
on:
push:
branches:
- main
pull_request: {}
permissions:
contents: read
env:
TWENTY_VERSION: latest
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Spawn Twenty test instance
id: twenty
uses: twentyhq/twenty/.github/actions/spawn-twenty-app-dev-test@main
with:
twenty-version: ${{ env.TWENTY_VERSION }}
- name: Enable Corepack
run: corepack enable
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Run integration tests
run: yarn test
env:
TWENTY_API_URL: ${{ steps.twenty.outputs.server-url }}
TWENTY_API_KEY: ${{ steps.twenty.outputs.api-key }}
@@ -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
@@ -0,0 +1 @@
24.5.0
@@ -0,0 +1,37 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript"],
"categories": {
"correctness": "off"
},
"ignorePatterns": ["node_modules", "dist"],
"rules": {
"no-unused-vars": "off",
"typescript/no-unused-vars": [
"warn",
{
"argsIgnorePattern": "^_"
}
],
"typescript/no-explicit-any": "off"
},
"overrides": [
{
"files": ["**/*.logic-function.ts", "**/logic-functions/**/*.ts"],
"rules": {
"no-restricted-imports": [
"error",
{
"patterns": [
{
"group": ["twenty-shared", "twenty-shared/*"],
"message": "Logic functions must not import from twenty-shared directly. Import runtime types and helpers from `twenty-sdk/logic-function` instead so the logic-function bundle stays minimal."
}
]
}
]
}
}
]
}
@@ -0,0 +1 @@
nodeLinker: node-modules
@@ -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/<name>.ts` |
| Field | `yarn twenty dev:add field` | `src/fields/<name>.ts` |
| Logic function | `yarn twenty dev:add logicFunction` | `src/logic-functions/<name>.ts` |
| Front component | `yarn twenty dev:add frontComponent` | `src/front-components/<name>.tsx` |
| Role | `yarn twenty dev:add role` | `src/roles/<name>.ts` |
| Skill | `yarn twenty dev:add skill` | `src/skills/<name>.ts` |
| Agent | `yarn twenty dev:add agent` | `src/agents/<name>.ts` |
| View | `yarn twenty dev:add view` | `src/views/<name>.ts` |
| Navigation menu item | `yarn twenty dev:add navigationMenuItem` | `src/navigation-menu-items/<name>.ts` |
| Page layout | `yarn twenty dev:add pageLayout` | `src/page-layouts/<name>.ts` |
This helps automatically generate required IDs etc.
@@ -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/<name>.ts` |
| Field | `yarn twenty dev:add field` | `src/fields/<name>.ts` |
| Logic function | `yarn twenty dev:add logicFunction` | `src/logic-functions/<name>.ts` |
| Front component | `yarn twenty dev:add frontComponent` | `src/front-components/<name>.tsx` |
| Role | `yarn twenty dev:add role` | `src/roles/<name>.ts` |
| Skill | `yarn twenty dev:add skill` | `src/skills/<name>.ts` |
| Agent | `yarn twenty dev:add agent` | `src/agents/<name>.ts` |
| View | `yarn twenty dev:add view` | `src/views/<name>.ts` |
| Navigation menu item | `yarn twenty dev:add navigationMenuItem` | `src/navigation-menu-items/<name>.ts` |
| Page layout | `yarn twenty dev:add pageLayout` | `src/page-layouts/<name>.ts` |
This helps automatically generate required IDs etc.
@@ -0,0 +1,22 @@
This is a [Twenty](https://twenty.com) application bootstrapped with [`create-twenty-app`](https://www.npmjs.com/package/create-twenty-app).
## Getting Started
This app was scaffolded with a local Twenty server running at [http://localhost:2020](http://localhost:2020).
Login with the default development credentials: `tim@apple.dev` / `tim@apple.dev`.
Run `yarn twenty help` to list all available commands.
## Useful Commands
- `yarn twenty dev` - Start the development server and sync your app
- `yarn twenty docker:status` - Check the local Twenty server status
- `yarn twenty docker:start` - Start the local Twenty server
- `yarn test` - Run integration tests
## Learn More
- [Twenty Apps documentation](https://docs.twenty.com/developers/extend/apps/getting-started/quick-start)
- [twenty-sdk CLI reference](https://www.npmjs.com/package/twenty-sdk)
- [Discord](https://discord.gg/cx5n4Jzs57)
@@ -0,0 +1,33 @@
{
"name": "twenty-meeting-bot",
"version": "0.1.0",
"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 .",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"twenty-client-sdk": "2.13.0"
},
"devDependencies": {
"@types/node": "^24.7.2",
"@types/react": "^19.0.0",
"oxlint": "^0.16.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"twenty-sdk": "2.13.0",
"typescript": "^5.9.3",
"vite-tsconfig-paths": "^4.2.1",
"vitest": "^3.1.1"
}
}
@@ -0,0 +1,5 @@
<svg width="136" height="136" viewBox="0 0 136 136" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="136" height="136" fill="#D9F3EE"/>
<path d="M75.9072 46.0928C76.7667 46.9522 77.2499 48.1176 77.25 49.333V86.667C77.2499 87.8824 76.7667 89.0478 75.9072 89.9072C75.0478 90.7667 73.8824 91.2499 72.667 91.25H35.333C34.1176 91.2499 32.9522 90.7667 32.0928 89.9072C31.2333 89.0478 30.7501 87.8825 30.75 86.667V50.2044C30.75 49.6487 30.5188 49.1181 30.1119 48.7397L22.9309 42.0629C22.2912 41.4681 21.25 41.9218 21.25 42.7953V86.667C21.2501 90.402 22.7339 93.9839 25.375 96.625C28.0161 99.2661 31.598 100.75 35.333 100.75H72.667C76.402 100.75 79.9839 99.2661 82.625 96.625C85.2661 93.9839 86.7499 90.402 86.75 86.667V49.333C86.7499 45.598 85.2661 42.0161 82.625 39.375C79.9839 36.7339 76.402 35.2501 72.667 35.25H27.157C26.2342 35.25 25.8038 36.3934 26.4977 37.0018L34.7671 44.2537C35.1319 44.5736 35.6005 44.75 36.0857 44.75H72.667C73.8825 44.7501 75.0478 45.2333 75.9072 46.0928Z" fill="#12A594"/>
<path d="M104.757 42.8125C106.259 42.8802 107.723 43.3068 109.024 44.0547L109.283 44.208L109.536 44.3701C110.704 45.1443 111.685 46.1699 112.406 47.3711L112.557 47.6309L112.699 47.8955C113.389 49.2291 113.749 50.7108 113.75 52.2149V83.7852C113.749 85.3894 113.338 86.9672 112.557 88.3682C111.775 89.7692 110.648 90.9477 109.283 91.791C107.918 92.6344 106.36 93.1143 104.757 93.1865C103.154 93.2587 101.559 92.9203 100.124 92.2031L92.1058 88.1953C91.4281 87.8566 91 87.164 91 86.4063V78.6384C91 77.8951 91.7823 77.4116 92.4472 77.744L104.25 83.6445V52.3535L92.4471 58.2532C91.7822 58.5856 91 58.1021 91 57.3588V49.5926C91 48.835 91.4281 48.1425 92.1057 47.8037L100.123 43.7959C101.558 43.0788 103.154 42.7404 104.757 42.8125Z" fill="#12A594"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -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();
}
}
@@ -0,0 +1,46 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/constants/application-universal-identifier';
import { describe, expect, it } from 'vitest';
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('CoreApiClient', () => {
it('should support CRUD on standard objects', async () => {
const client = new CoreApiClient();
const created = await client.mutation({
createNote: {
__args: { data: { title: 'Integration test note' } },
id: true,
},
});
expect(created.createNote.id).toBeDefined();
await client.mutation({
destroyNote: {
__args: { id: created.createNote.id },
id: true,
},
});
});
});
@@ -0,0 +1,12 @@
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';
export default defineApplication({
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
displayName: APP_DISPLAY_NAME,
description: APP_DESCRIPTION,
logoUrl: 'public/logo.svg',
});
@@ -0,0 +1,2 @@
export const APP_DESCRIPTION =
'Capture every customer conversation automatically. A meeting bot joins eligible meetings and records calls for you.';
@@ -0,0 +1 @@
export const APP_DISPLAY_NAME = 'Twenty Meeting Bot';
@@ -0,0 +1,2 @@
export const APPLICATION_UNIVERSAL_IDENTIFIER =
'8da4b8b5-5edf-4880-b51f-ab6e679ec617';
@@ -0,0 +1,2 @@
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER =
'5fcf4d3a-0aca-42d9-9beb-7387f43ec180';
@@ -0,0 +1,2 @@
export const MEETING_BOT_PREFERENCE_AUTO_OPTION_ID =
'72431216-49c4-47c8-99af-de4c3831b0be';
@@ -0,0 +1,2 @@
export const MEETING_BOT_PREFERENCE_OFF_OPTION_ID =
'cc7de62a-08b6-46c8-aa69-f8117e7dd722';
@@ -0,0 +1,2 @@
export const MEETING_BOT_PREFERENCE_ON_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIER =
'8ee9444a-2437-4def-8e61-6e493862a4fd';
@@ -0,0 +1,2 @@
export const MEETING_BOT_PREFERENCE_ON_OPTION_ID =
'd7b437b1-d6a3-4e99-8bb8-ba632d4a544e';
@@ -0,0 +1,5 @@
export enum MeetingBotPreference {
AUTO = 'AUTO',
ON = 'ON',
OFF = 'OFF',
}
@@ -0,0 +1,66 @@
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, their calendar channel associations, and workspace member auto-record settings to decide whether the meeting bot should attend a meeting; writes and converges the resulting CallRecording records and serves the transcript viewer front component.',
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.calendarChannelEventAssociation
.universalIdentifier,
canReadObjectRecords: true,
canUpdateObjectRecords: false,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
},
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.workspaceMember
.universalIdentifier,
canReadObjectRecords: true,
canUpdateObjectRecords: false,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
},
{
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.callRecording.universalIdentifier,
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
},
],
fieldPermissions: [],
// UPLOAD_FILE: media ingestion uploads Recall artifacts into FILES fields.
// CONNECTED_ACCOUNTS: calendarChannelOwners resolves whose calendar synced a meeting.
permissionFlagUniversalIdentifiers: [
SystemPermissionFlag.UPLOAD_FILE,
SystemPermissionFlag.CONNECTED_ACCOUNTS,
],
});
@@ -0,0 +1,49 @@
import {
defineField,
FieldType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk/define';
import { MeetingBotPreference } from 'src/constants/meeting-bot-preference';
import { MEETING_BOT_PREFERENCE_AUTO_OPTION_ID } from 'src/constants/meeting-bot-preference-auto-option-id';
import { MEETING_BOT_PREFERENCE_OFF_OPTION_ID } from 'src/constants/meeting-bot-preference-off-option-id';
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_OPTION_ID } from 'src/constants/meeting-bot-preference-on-option-id';
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:
'Whether the meeting bot records this event. Auto follows the auto-record settings of participating workspace members.',
icon: 'IconRobot',
isNullable: false,
defaultValue: `'${MeetingBotPreference.AUTO}'`,
options: [
{
id: MEETING_BOT_PREFERENCE_AUTO_OPTION_ID,
value: MeetingBotPreference.AUTO,
label: 'Auto',
position: 0,
color: 'gray',
},
{
id: MEETING_BOT_PREFERENCE_ON_OPTION_ID,
value: MeetingBotPreference.ON,
label: 'Recording on',
position: 1,
color: 'green',
},
{
id: MEETING_BOT_PREFERENCE_OFF_OPTION_ID,
value: MeetingBotPreference.OFF,
label: 'Recording off',
position: 2,
color: 'red',
},
],
});
@@ -0,0 +1,42 @@
{
"compileOnSave": false,
"compilerOptions": {
"sourceMap": true,
"declaration": true,
"outDir": "./dist",
"rootDir": ".",
"jsx": "react-jsx",
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"importHelpers": true,
"allowUnreachableCode": false,
"strict": true,
"alwaysStrict": true,
"noImplicitAny": true,
"strictBindCallApply": false,
"target": "es2018",
"module": "esnext",
"lib": ["es2020", "dom"],
"skipLibCheck": true,
"skipDefaultLibCheck": true,
"resolveJsonModule": true,
"paths": {
"src/*": ["./src/*"],
"~/*": ["./*"]
}
},
"exclude": [
"node_modules",
"dist",
"**/*.test.ts",
"**/*.spec.ts",
"**/*.integration-test.ts"
],
"references": [
{
"path": "./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"]
}
@@ -0,0 +1,35 @@
import { loadEnv } from 'vite';
import tsconfigPaths from 'vite-tsconfig-paths';
import { defineConfig } from 'vitest/config';
const fileEnv = loadEnv('test', process.cwd(), 'TWENTY_');
const TWENTY_API_URL =
process.env.TWENTY_API_URL ?? fileEnv.TWENTY_API_URL ?? 'http://localhost:2020';
const TWENTY_API_KEY = process.env.TWENTY_API_KEY ?? fileEnv.TWENTY_API_KEY;
// Make env vars available to globalSetup (test.env only applies to workers)
process.env.TWENTY_API_URL = TWENTY_API_URL;
if (TWENTY_API_KEY) {
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 ? { TWENTY_API_KEY } : {}),
},
},
});
File diff suppressed because it is too large Load Diff
@@ -1,9 +1,12 @@
import { Command } from 'nest-commander';
import { TWENTY_STANDARD_APPLICATION_UNIVERSAL_IDENTIFIER } from 'twenty-shared/application';
import {
STANDARD_OBJECTS,
STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-shared/metadata';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { v4 as uuidv4 } from 'uuid';
import { ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner';
import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service';
@@ -12,6 +15,7 @@ import { buildNavigationCommandMenuItemOperationsOrThrow } from 'src/database/co
import {
buildCalendarEventFieldRenameUpdates,
buildCallRecordingObjectRenameUpdates,
LEGACY_CALENDAR_EVENT_RECORDING_PREFERENCE_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/database/commands/upgrade-version-command/2-10/utils/call-recording-name-collision.util';
import {
getExistingOrStandardFlatEntityOrThrow,
@@ -43,7 +47,6 @@ const CALL_RECORDING_OBJECT_METADATA_UNIVERSAL_IDENTIFIERS = [
const CALL_RECORDING_FIELD_METADATA_UNIVERSAL_IDENTIFIERS = [
...getUniversalIdentifiers(STANDARD_OBJECTS.callRecording.fields),
STANDARD_OBJECTS.calendarEvent.fields.recordingPreference.universalIdentifier,
STANDARD_OBJECTS.calendarEvent.fields.callRecordings.universalIdentifier,
];
@@ -92,6 +95,91 @@ const CALL_RECORDING_PAGE_LAYOUT_WIDGET_UNIVERSAL_IDENTIFIERS = [
.timeline.widgets.timeline.universalIdentifier,
];
// Preserves the shipped 2.10 upgrade path after recordingPreference moved out of
// current standard metadata.
const buildLegacyCalendarEventRecordingPreferenceFieldMetadata = ({
calendarEventObjectMetadata,
now,
twentyStandardApplicationId,
workspaceId,
}: {
calendarEventObjectMetadata: FlatObjectMetadata;
now: string;
twentyStandardApplicationId: string;
workspaceId: string;
}): FlatFieldMetadata<FieldMetadataType.SELECT> => ({
id: uuidv4(),
universalIdentifier:
LEGACY_CALENDAR_EVENT_RECORDING_PREFERENCE_FIELD_UNIVERSAL_IDENTIFIER,
applicationId: twentyStandardApplicationId,
workspaceId,
objectMetadataId: calendarEventObjectMetadata.id,
type: FieldMetadataType.SELECT,
name: 'recordingPreference',
label: 'Recording Preference',
description:
'Whether to record this event, applied on top of the workspace policy',
icon: 'IconSettingsAutomation',
isActive: true,
isSystem: false,
isNullable: false,
isUnique: false,
isUIEditable: true,
isLabelSyncedWithName: false,
standardOverrides: null,
defaultValue: "'AUTO'",
settings: null,
options: [
{
id: '4c4761ce-ffbf-4176-be7f-5cf5257c8bff',
value: 'AUTO',
label: 'Auto',
position: 0,
color: 'blue',
},
{
id: '1ae19135-e1a1-4a96-b866-91643622e554',
value: 'ON',
label: 'On',
position: 1,
color: 'green',
},
{
id: '8c69a74f-2ab7-4c19-a813-eb0ea3533fd3',
value: 'OFF',
label: 'Off',
position: 2,
color: 'gray',
},
],
relationTargetFieldMetadataId: null,
relationTargetObjectMetadataId: null,
morphId: null,
viewFieldIds: [],
viewFilterIds: [],
fieldPermissionIds: [],
kanbanAggregateOperationViewIds: [],
calendarViewIds: [],
mainGroupByFieldMetadataViewIds: [],
createdAt: now,
updatedAt: now,
applicationUniversalIdentifier:
TWENTY_STANDARD_APPLICATION_UNIVERSAL_IDENTIFIER,
objectMetadataUniversalIdentifier:
STANDARD_OBJECTS.calendarEvent.universalIdentifier,
relationTargetObjectMetadataUniversalIdentifier: null,
relationTargetFieldMetadataUniversalIdentifier: null,
viewFilterUniversalIdentifiers: [],
viewFieldUniversalIdentifiers: [],
fieldPermissionUniversalIdentifiers: [],
kanbanAggregateOperationViewUniversalIdentifiers: [],
calendarViewUniversalIdentifiers: [],
mainGroupByFieldMetadataViewUniversalIdentifiers: [],
viewSortIds: [],
viewSortUniversalIdentifiers: [],
universalSettings: null,
});
@RegisteredWorkspaceCommand('2.10.0', 1799000055000)
@Command({
name: 'upgrade:2-10:sync-call-recording-standard-objects',
@@ -200,6 +288,22 @@ export class SyncCallRecordingStandardObjectsCommand extends ActiveOrSuspendedWo
renamedCollisionObjectMetadatas,
});
const legacyCalendarEventRecordingPreferenceFieldMetadata =
flatFieldMetadataMaps.byUniversalIdentifier[
LEGACY_CALENDAR_EVENT_RECORDING_PREFERENCE_FIELD_UNIVERSAL_IDENTIFIER
];
const legacyCalendarEventRecordingPreferenceFieldMetadataToCreate =
isDefined(legacyCalendarEventRecordingPreferenceFieldMetadata)
? []
: [
buildLegacyCalendarEventRecordingPreferenceFieldMetadata({
calendarEventObjectMetadata,
now,
twentyStandardApplicationId: twentyStandardFlatApplication.id,
workspaceId,
}),
];
const allFlatEntityOperationByMetadataName = {
objectMetadata: {
flatEntityToCreate:
@@ -214,14 +318,16 @@ export class SyncCallRecordingStandardObjectsCommand extends ActiveOrSuspendedWo
flatEntityToUpdate: [],
},
fieldMetadata: {
flatEntityToCreate:
getStandardFlatEntitiesToCreateOrThrow<FlatFieldMetadata>({
flatEntityToCreate: [
...getStandardFlatEntitiesToCreateOrThrow<FlatFieldMetadata>({
standardFlatEntityMaps:
standardAllFlatEntityMaps.flatFieldMetadataMaps,
existingFlatEntityMaps: flatFieldMetadataMaps,
universalIdentifiers:
CALL_RECORDING_FIELD_METADATA_UNIVERSAL_IDENTIFIERS,
}),
...legacyCalendarEventRecordingPreferenceFieldMetadataToCreate,
],
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
@@ -6,6 +6,7 @@ import {
buildCallRecordingObjectRenameUpdates,
findCalendarEventFieldNameCollisionsForCallRecording,
findCallRecordingObjectNameCollisions,
LEGACY_CALENDAR_EVENT_RECORDING_PREFERENCE_FIELD_UNIVERSAL_IDENTIFIER,
resolveAvailableOldCalendarEventFieldName,
resolveAvailableOldCallRecordingObjectNames,
} from 'src/database/commands/upgrade-version-command/2-10/utils/call-recording-name-collision.util';
@@ -327,8 +328,7 @@ describe('findCalendarEventFieldNameCollisionsForCallRecording', () => {
const maps = buildFlatFieldMetadataMaps([
getCalendarEventFieldMetadataMock({
universalIdentifier:
STANDARD_OBJECTS.calendarEvent.fields.recordingPreference
.universalIdentifier,
LEGACY_CALENDAR_EVENT_RECORDING_PREFERENCE_FIELD_UNIVERSAL_IDENTIFIER,
name: 'recordingPreference',
}),
getCalendarEventFieldMetadataMock({
@@ -15,6 +15,9 @@ const FIELD_OLD_NAME_SUFFIX = 'Old';
const FIELD_OLD_LABEL_SUFFIX = ' (Old)';
const MAX_OLD_NAME_ATTEMPTS = 100;
export const LEGACY_CALENDAR_EVENT_RECORDING_PREFERENCE_FIELD_UNIVERSAL_IDENTIFIER =
'1d231e7e-9bbe-410b-8007-ea7678a83e58';
const CALL_RECORDING_CALENDAR_EVENT_FIELD_NAMES = [
CALENDAR_EVENT_RECORDING_PREFERENCE_FIELD_NAME,
CALENDAR_EVENT_CALL_RECORDINGS_FIELD_NAME,
@@ -22,8 +25,7 @@ const CALL_RECORDING_CALENDAR_EVENT_FIELD_NAMES = [
const CALL_RECORDING_CALENDAR_EVENT_FIELD_UNIVERSAL_IDENTIFIERS =
new Set<string>([
STANDARD_OBJECTS.calendarEvent.fields.recordingPreference
.universalIdentifier,
LEGACY_CALENDAR_EVENT_RECORDING_PREFERENCE_FIELD_UNIVERSAL_IDENTIFIER,
STANDARD_OBJECTS.calendarEvent.fields.callRecordings.universalIdentifier,
]);
@@ -1,6 +1,8 @@
import { Module } from '@nestjs/common';
import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module';
import { SyncCallRecordingRequestStatusCommand } from 'src/database/commands/upgrade-version-command/2-14/2-14-workspace-command-1799000065000-sync-call-recording-request-status.command';
import { DropCalendarEventRecordingPreferenceCommand } from 'src/database/commands/upgrade-version-command/2-14/2-14-workspace-command-1799000066000-drop-calendar-event-recording-preference.command';
import { FixStandardRelationFieldLabelsIconsCommand } from 'src/database/commands/upgrade-version-command/2-14/2-14-workspace-command-1799000040000-fix-standard-relation-field-labels-icons.command';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
@@ -13,6 +15,10 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
WorkspaceIteratorModule,
WorkspaceMigrationModule,
],
providers: [FixStandardRelationFieldLabelsIconsCommand],
providers: [
FixStandardRelationFieldLabelsIconsCommand,
SyncCallRecordingRequestStatusCommand,
DropCalendarEventRecordingPreferenceCommand,
],
})
export class V2_14_UpgradeVersionCommandModule {}
@@ -0,0 +1,193 @@
import { Command } from 'nest-commander';
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
import { isDefined } from 'twenty-shared/utils';
import { ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner';
import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service';
import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner';
import { getStandardFlatEntitiesToCreateOrThrow } from 'src/database/commands/upgrade-version-command/2-10/utils/get-standard-flat-entities-to-create-or-throw.util';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { type FlatViewField } from 'src/engine/metadata-modules/flat-view-field/types/flat-view-field.type';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { computeTwentyStandardApplicationAllFlatEntityMaps } from 'src/engine/workspace-manager/twenty-standard-application/utils/twenty-standard-application-all-flat-entity-maps.constant';
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
const CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER =
STANDARD_OBJECTS.callRecording.universalIdentifier;
const CALL_RECORDING_REQUEST_STATUS_FIELD_UNIVERSAL_IDENTIFIER =
STANDARD_OBJECTS.callRecording.fields.recordingRequestStatus
.universalIdentifier;
const CALL_RECORDING_REQUEST_STATUS_VIEW_FIELD_UNIVERSAL_IDENTIFIERS = [
STANDARD_OBJECTS.callRecording.views.allCallRecordings.viewFields
.recordingRequestStatus.universalIdentifier,
STANDARD_OBJECTS.callRecording.views.callRecordingRecordPageFields.viewFields
.recordingRequestStatus.universalIdentifier,
];
const CALL_RECORDING_REQUEST_STATUS_FIELD_NAME = 'recordingRequestStatus';
@RegisteredWorkspaceCommand('2.14.0', 1799000065000)
@Command({
name: 'upgrade:2-14:sync-call-recording-request-status',
description:
'Create the CallRecording recordingRequestStatus metadata in existing workspaces',
})
export class SyncCallRecordingRequestStatusCommand extends ActiveOrSuspendedWorkspaceCommandRunner {
constructor(
protected readonly workspaceIteratorService: WorkspaceIteratorService,
private readonly applicationService: ApplicationService,
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
) {
super(workspaceIteratorService);
}
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
const isDryRun = options.dryRun ?? false;
const { twentyStandardFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
);
const { flatFieldMetadataMaps, flatObjectMetadataMaps, flatViewFieldMaps } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatFieldMetadataMaps',
'flatObjectMetadataMaps',
'flatViewFieldMaps',
]);
const existingCallRecordingObjectMetadata =
flatObjectMetadataMaps.byUniversalIdentifier[
CALL_RECORDING_OBJECT_UNIVERSAL_IDENTIFIER
];
if (!isDefined(existingCallRecordingObjectMetadata)) {
this.logger.log(
`CallRecording object metadata does not exist for workspace ${workspaceId}, skipping`,
);
return;
}
const existingRecordingRequestStatusField =
flatFieldMetadataMaps.byUniversalIdentifier[
CALL_RECORDING_REQUEST_STATUS_FIELD_UNIVERSAL_IDENTIFIER
];
if (
!isDefined(existingRecordingRequestStatusField) &&
hasFieldNameConflict({
flatFieldMetadatas: Object.values(
flatFieldMetadataMaps.byUniversalIdentifier,
).filter(isDefined),
callRecordingObjectMetadata: existingCallRecordingObjectMetadata,
})
) {
this.logger.warn(
`Field name "${CALL_RECORDING_REQUEST_STATUS_FIELD_NAME}" is already taken on CallRecording for workspace ${workspaceId}; skipping`,
);
return;
}
const { allFlatEntityMaps: standardAllFlatEntityMaps } =
computeTwentyStandardApplicationAllFlatEntityMaps({
now: new Date().toISOString(),
workspaceId,
twentyStandardApplicationId: twentyStandardFlatApplication.id,
});
const recordingRequestStatusFieldsToCreate =
getStandardFlatEntitiesToCreateOrThrow<FlatFieldMetadata>({
standardFlatEntityMaps: standardAllFlatEntityMaps.flatFieldMetadataMaps,
existingFlatEntityMaps: flatFieldMetadataMaps,
universalIdentifiers: [
CALL_RECORDING_REQUEST_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
],
});
const recordingRequestStatusViewFieldsToCreate =
getStandardFlatEntitiesToCreateOrThrow<FlatViewField>({
standardFlatEntityMaps: standardAllFlatEntityMaps.flatViewFieldMaps,
existingFlatEntityMaps: flatViewFieldMaps,
universalIdentifiers:
CALL_RECORDING_REQUEST_STATUS_VIEW_FIELD_UNIVERSAL_IDENTIFIERS,
});
const totalOperationCount =
recordingRequestStatusFieldsToCreate.length +
recordingRequestStatusViewFieldsToCreate.length;
if (totalOperationCount === 0) {
this.logger.log(
`CallRecording recordingRequestStatus metadata already exists for workspace ${workspaceId}, skipping`,
);
return;
}
this.logger.log(
`${isDryRun ? '[DRY RUN] ' : ''}Creating ${totalOperationCount} CallRecording recordingRequestStatus metadata item(s) for workspace ${workspaceId}`,
);
if (isDryRun) {
return;
}
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
isSystemBuild: true,
applicationUniversalIdentifier:
twentyStandardFlatApplication.universalIdentifier,
workspaceId,
allFlatEntityOperationByMetadataName: {
fieldMetadata: {
flatEntityToCreate: recordingRequestStatusFieldsToCreate,
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
viewField: {
flatEntityToCreate: recordingRequestStatusViewFieldsToCreate,
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
},
},
);
if (validateAndBuildResult.status === 'fail') {
throw new Error(
`Failed to create CallRecording recordingRequestStatus metadata for workspace ${workspaceId}: ${JSON.stringify(
validateAndBuildResult,
null,
2,
)}`,
);
}
this.logger.log(
`Created ${totalOperationCount} CallRecording recordingRequestStatus metadata item(s) for workspace ${workspaceId}`,
);
}
}
const hasFieldNameConflict = ({
flatFieldMetadatas,
callRecordingObjectMetadata,
}: {
flatFieldMetadatas: FlatFieldMetadata[];
callRecordingObjectMetadata: FlatObjectMetadata;
}): boolean =>
flatFieldMetadatas.some(
(flatFieldMetadata) =>
flatFieldMetadata.objectMetadataUniversalIdentifier ===
callRecordingObjectMetadata.universalIdentifier &&
flatFieldMetadata.name === CALL_RECORDING_REQUEST_STATUS_FIELD_NAME,
);
@@ -0,0 +1,109 @@
import { Command } from 'nest-commander';
import { ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner';
import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service';
import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator';
import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
const CALENDAR_EVENT_RECORDING_PREFERENCE_FIELD_UNIVERSAL_IDENTIFIER =
'1d231e7e-9bbe-410b-8007-ea7678a83e58';
@RegisteredWorkspaceCommand('2.14.0', 1799000066000)
@Command({
name: 'upgrade:2-14:drop-calendar-event-recording-preference',
description: 'Drop the CalendarEvent recordingPreference field metadata',
})
export class DropCalendarEventRecordingPreferenceCommand extends ActiveOrSuspendedWorkspaceCommandRunner {
constructor(
protected readonly workspaceIteratorService: WorkspaceIteratorService,
private readonly applicationService: ApplicationService,
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
private readonly workspaceCacheService: WorkspaceCacheService,
) {
super(workspaceIteratorService);
}
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
const isDryRun = options.dryRun ?? false;
this.logger.log(
`${isDryRun ? '[DRY RUN] ' : ''}Starting CalendarEvent recordingPreference field removal for workspace ${workspaceId}`,
);
const { flatFieldMetadataMaps } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatFieldMetadataMaps',
]);
const recordingPreferenceFieldMetadata =
findFlatEntityByUniversalIdentifier<FlatFieldMetadata>({
flatEntityMaps: flatFieldMetadataMaps,
universalIdentifier:
CALENDAR_EVENT_RECORDING_PREFERENCE_FIELD_UNIVERSAL_IDENTIFIER,
});
if (!recordingPreferenceFieldMetadata) {
this.logger.log(
`CalendarEvent recordingPreference field already absent for workspace ${workspaceId}`,
);
return;
}
if (isDryRun) {
this.logger.log(
`[DRY RUN] Would delete CalendarEvent recordingPreference field for workspace ${workspaceId}`,
);
return;
}
const { twentyStandardFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
);
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
isSystemBuild: true,
allFlatEntityOperationByMetadataName: {
fieldMetadata: {
flatEntityToCreate: [],
flatEntityToDelete: [recordingPreferenceFieldMetadata],
flatEntityToUpdate: [],
},
},
workspaceId,
applicationUniversalIdentifier:
twentyStandardFlatApplication.universalIdentifier,
},
);
if (validateAndBuildResult.status === 'fail') {
this.logger.error(
`Failed to delete CalendarEvent recordingPreference field:\n${JSON.stringify(
validateAndBuildResult,
null,
2,
)}`,
);
throw new Error(
`Failed to delete CalendarEvent recordingPreference field for workspace ${workspaceId}`,
);
}
this.logger.log(
`Deleted CalendarEvent recordingPreference field for workspace ${workspaceId}`,
);
}
}
@@ -55,17 +55,7 @@ describe('CallRecording standard metadata build', () => {
expect(calendarEventIdIndex).toBeDefined();
});
it('stores the per-event recording preference on calendarEvent', () => {
const recordingPreferenceField =
allFlatEntityMaps.flatFieldMetadataMaps.byUniversalIdentifier[
STANDARD_OBJECTS.calendarEvent.fields.recordingPreference
.universalIdentifier
];
expect(recordingPreferenceField).toBeDefined();
});
it('keeps the callRecording table view focused on its label identifier and status', () => {
it('keeps the callRecording table view focused on its label identifier and statuses', () => {
const viewFieldFieldUniversalIdentifiers = Object.values(
allFlatEntityMaps.flatViewFieldMaps.byUniversalIdentifier,
)
@@ -78,11 +68,13 @@ describe('CallRecording standard metadata build', () => {
)
.map((viewField) => viewField.fieldMetadataUniversalIdentifier);
expect(viewFieldFieldUniversalIdentifiers).toHaveLength(3);
expect(viewFieldFieldUniversalIdentifiers).toHaveLength(4);
expect(viewFieldFieldUniversalIdentifiers).toEqual(
expect.arrayContaining([
STANDARD_OBJECTS.callRecording.fields.title.universalIdentifier,
STANDARD_OBJECTS.callRecording.fields.status.universalIdentifier,
STANDARD_OBJECTS.callRecording.fields.recordingRequestStatus
.universalIdentifier,
STANDARD_OBJECTS.callRecording.fields.startedAt.universalIdentifier,
]),
);
@@ -101,11 +93,13 @@ describe('CallRecording standard metadata build', () => {
)
.map((viewField) => viewField.fieldMetadataUniversalIdentifier);
expect(viewFieldFieldUniversalIdentifiers).toHaveLength(8);
expect(viewFieldFieldUniversalIdentifiers).toHaveLength(9);
expect(viewFieldFieldUniversalIdentifiers).toEqual(
expect.arrayContaining([
STANDARD_OBJECTS.callRecording.fields.title.universalIdentifier,
STANDARD_OBJECTS.callRecording.fields.status.universalIdentifier,
STANDARD_OBJECTS.callRecording.fields.recordingRequestStatus
.universalIdentifier,
STANDARD_OBJECTS.callRecording.fields.startedAt.universalIdentifier,
STANDARD_OBJECTS.callRecording.fields.endedAt.universalIdentifier,
STANDARD_OBJECTS.callRecording.fields.video.universalIdentifier,
@@ -401,48 +401,6 @@ export const buildCalendarEventStandardFlatFieldMetadatas = ({
twentyStandardApplicationId,
now,
}),
recordingPreference: createStandardFieldFlatMetadata({
objectName,
workspaceId,
context: {
fieldName: 'recordingPreference',
type: FieldMetadataType.SELECT,
label: i18nLabel(msg`Recording Preference`),
description: i18nLabel(
msg`Whether to record this event, applied on top of the workspace policy`,
),
icon: 'IconSettingsAutomation',
isNullable: false,
defaultValue: "'AUTO'",
options: [
{
id: '4c4761ce-ffbf-4176-be7f-5cf5257c8bff',
value: 'AUTO',
label: i18nLabel(msg`Auto`),
position: 0,
color: 'blue',
},
{
id: '1ae19135-e1a1-4a96-b866-91643622e554',
value: 'ON',
label: i18nLabel(msg`On`),
position: 1,
color: 'green',
},
{
id: '8c69a74f-2ab7-4c19-a813-eb0ea3533fd3',
value: 'OFF',
label: i18nLabel(msg`Off`),
position: 2,
color: 'gray',
},
],
},
standardObjectMetadataRelatedEntityIds,
dependencyFlatEntityMaps,
twentyStandardApplicationId,
now,
}),
calendarChannelEventAssociations: createStandardRelationFieldFlatMetadata({
objectName,
workspaceId,
@@ -15,6 +15,8 @@ import {
} from 'src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/create-standard-field-flat-metadata.util';
import { createStandardRelationFieldFlatMetadata } from 'src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/create-standard-relation-field-flat-metadata.util';
import { getTsVectorColumnExpressionFromFields } from 'src/engine/workspace-manager/utils/get-ts-vector-column-expression.util';
import { CallRecordingRequestStatus } from 'src/modules/call-recording/common/enums/call-recording-request-status.enum';
import { CallRecordingStatus } from 'src/modules/call-recording/common/enums/call-recording-status.enum';
export const buildCallRecordingStandardFlatFieldMetadatas = ({
now,
@@ -137,42 +139,42 @@ export const buildCallRecordingStandardFlatFieldMetadatas = ({
options: [
{
id: '7fa515ba-e3cb-48f7-914f-e1f664d5d920',
value: 'SCHEDULED',
value: CallRecordingStatus.SCHEDULED,
label: i18nLabel(msg`Scheduled`),
position: 0,
color: 'sky',
},
{
id: '96844ba3-364b-4975-8abc-886cca92ec99',
value: 'JOINING',
value: CallRecordingStatus.JOINING,
label: i18nLabel(msg`Joining`),
position: 1,
color: 'blue',
},
{
id: 'eccdad8b-8424-48ba-ad7f-f38517fa83fc',
value: 'RECORDING',
value: CallRecordingStatus.RECORDING,
label: i18nLabel(msg`Recording`),
position: 2,
color: 'red',
},
{
id: 'c8222203-5b44-4ac6-8142-0a7eb2074d7b',
value: 'PROCESSING',
value: CallRecordingStatus.PROCESSING,
label: i18nLabel(msg`Processing`),
position: 3,
color: 'orange',
},
{
id: 'd17faf71-af3c-4260-9021-2ffaaa5648c4',
value: 'COMPLETED',
value: CallRecordingStatus.COMPLETED,
label: i18nLabel(msg`Completed`),
position: 4,
color: 'green',
},
{
id: '4800777e-54a8-4464-9c01-07d6eefd04da',
value: 'FAILED_UNKNOWN',
value: CallRecordingStatus.FAILED_UNKNOWN,
label: i18nLabel(msg`Failed`),
position: 5,
color: 'gray',
@@ -184,6 +186,40 @@ export const buildCallRecordingStandardFlatFieldMetadatas = ({
twentyStandardApplicationId,
now,
}),
recordingRequestStatus: createStandardFieldFlatMetadata({
objectName,
workspaceId,
context: {
fieldName: 'recordingRequestStatus',
type: FieldMetadataType.SELECT,
label: i18nLabel(msg`Request Status`),
description: i18nLabel(msg`Recording request status`),
icon: 'IconCircleCheck',
isNullable: false,
isUIEditable: false,
defaultValue: "'REQUESTED'",
options: [
{
id: 'fe992923-2f51-494d-bb32-42e96a703778',
value: CallRecordingRequestStatus.REQUESTED,
label: i18nLabel(msg`Requested`),
position: 0,
color: 'sky',
},
{
id: '485767c2-2dda-4b83-91d8-6025cdb4b9df',
value: CallRecordingRequestStatus.CANCELED,
label: i18nLabel(msg`Canceled`),
position: 1,
color: 'gray',
},
],
},
standardObjectMetadataRelatedEntityIds,
dependencyFlatEntityMaps,
twentyStandardApplicationId,
now,
}),
applicationId: createStandardFieldFlatMetadata({
objectName,
workspaceId,
@@ -40,11 +40,24 @@ export const computeStandardCallRecordingViewFields = (
viewName: 'allCallRecordings',
viewFieldName: 'startedAt',
fieldName: 'startedAt',
position: 2,
position: 3,
isVisible: true,
size: 150,
},
}),
allCallRecordingsRecordingRequestStatus:
createStandardViewFieldFlatMetadata({
...args,
objectName: 'callRecording',
context: {
viewName: 'allCallRecordings',
viewFieldName: 'recordingRequestStatus',
fieldName: 'recordingRequestStatus',
position: 2,
isVisible: true,
size: 150,
},
}),
callRecordingRecordPageFieldsTitle: createStandardViewFieldFlatMetadata({
...args,
objectName: 'callRecording',
@@ -79,7 +92,7 @@ export const computeStandardCallRecordingViewFields = (
viewName: 'callRecordingRecordPageFields',
viewFieldName: 'startedAt',
fieldName: 'startedAt',
position: 2,
position: 3,
isVisible: true,
size: 150,
viewFieldGroupName: 'general',
@@ -93,12 +106,26 @@ export const computeStandardCallRecordingViewFields = (
viewName: 'callRecordingRecordPageFields',
viewFieldName: 'endedAt',
fieldName: 'endedAt',
position: 3,
position: 4,
isVisible: true,
size: 150,
viewFieldGroupName: 'general',
},
}),
callRecordingRecordPageFieldsRecordingRequestStatus:
createStandardViewFieldFlatMetadata({
...args,
objectName: 'callRecording',
context: {
viewName: 'callRecordingRecordPageFields',
viewFieldName: 'recordingRequestStatus',
fieldName: 'recordingRequestStatus',
position: 2,
isVisible: true,
size: 150,
viewFieldGroupName: 'general',
},
}),
callRecordingRecordPageFieldsVideo: createStandardViewFieldFlatMetadata({
...args,
objectName: 'callRecording',
@@ -106,7 +133,7 @@ export const computeStandardCallRecordingViewFields = (
viewName: 'callRecordingRecordPageFields',
viewFieldName: 'video',
fieldName: 'video',
position: 4,
position: 5,
isVisible: true,
size: 150,
viewFieldGroupName: 'general',
@@ -119,7 +146,7 @@ export const computeStandardCallRecordingViewFields = (
viewName: 'callRecordingRecordPageFields',
viewFieldName: 'audio',
fieldName: 'audio',
position: 5,
position: 6,
isVisible: true,
size: 150,
viewFieldGroupName: 'general',
@@ -133,7 +160,7 @@ export const computeStandardCallRecordingViewFields = (
viewName: 'callRecordingRecordPageFields',
viewFieldName: 'transcript',
fieldName: 'transcript',
position: 6,
position: 7,
isVisible: true,
size: 150,
viewFieldGroupName: 'general',
@@ -146,7 +173,7 @@ export const computeStandardCallRecordingViewFields = (
viewName: 'callRecordingRecordPageFields',
viewFieldName: 'summary',
fieldName: 'summary',
position: 7,
position: 8,
isVisible: true,
size: 200,
viewFieldGroupName: 'general',
@@ -37,7 +37,6 @@ const createMockCalendarEvent = (
updatedAt: '2024-03-20T09:00:00Z',
iCalUid: '',
conferenceSolution: '',
recordingPreference: 'AUTO',
calendarChannelEventAssociations: [],
calendarEventParticipants: [],
});
@@ -25,7 +25,6 @@ export class CalendarEventWorkspaceEntity extends BaseWorkspaceEntity {
iCalUid: string | null;
conferenceSolution: string | null;
conferenceLink: LinksMetadata;
recordingPreference: string;
calendarChannelEventAssociations: EntityRelation<
CalendarChannelEventAssociationWorkspaceEntity[]
>;
@@ -0,0 +1,4 @@
export enum CallRecordingRequestStatus {
REQUESTED = 'REQUESTED',
CANCELED = 'CANCELED',
}
@@ -0,0 +1,8 @@
export enum CallRecordingStatus {
SCHEDULED = 'SCHEDULED',
JOINING = 'JOINING',
RECORDING = 'RECORDING',
PROCESSING = 'PROCESSING',
COMPLETED = 'COMPLETED',
FAILED_UNKNOWN = 'FAILED_UNKNOWN',
}
@@ -4,10 +4,13 @@ import { type FileOutput } from 'src/engine/api/common/common-args-processors/da
import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity';
import { type EntityRelation } from 'src/engine/workspace-manager/workspace-migration/types/entity-relation.interface';
import { type CalendarEventWorkspaceEntity } from 'src/modules/calendar/common/standard-objects/calendar-event.workspace-entity';
import { type CallRecordingRequestStatus } from 'src/modules/call-recording/common/enums/call-recording-request-status.enum';
import { type CallRecordingStatus } from 'src/modules/call-recording/common/enums/call-recording-status.enum';
export class CallRecordingWorkspaceEntity extends BaseWorkspaceEntity {
title: string | null;
status: string;
status: CallRecordingStatus;
recordingRequestStatus: CallRecordingRequestStatus;
applicationId: string | null;
externalBotId: string | null;
externalRecordingId: string | null;
@@ -480,9 +480,6 @@ export const STANDARD_OBJECTS = {
calendarEventParticipants: {
universalIdentifier: '20202020-e07e-4ccb-88f5-6f3d00458eec',
},
recordingPreference: {
universalIdentifier: '1d231e7e-9bbe-410b-8007-ea7678a83e58',
},
callRecordings: {
universalIdentifier: '48d6d151-18e2-4111-b405-d85fb9d860d8',
},
@@ -553,6 +550,9 @@ export const STANDARD_OBJECTS = {
status: {
universalIdentifier: '3e617680-d93e-4309-a54f-90f69528bfd7',
},
recordingRequestStatus: {
universalIdentifier: '7fd681c9-244c-4e98-8939-7b175d472638',
},
applicationId: {
universalIdentifier: '24ec1239-1240-42cb-8a2d-302632378e09',
},
@@ -608,6 +608,9 @@ export const STANDARD_OBJECTS = {
status: {
universalIdentifier: '6c4a81a2-d9c1-4f82-984c-f97e083ca710',
},
recordingRequestStatus: {
universalIdentifier: '3bdedacd-0fd5-4175-8d28-2fe41bb5aa77',
},
title: {
universalIdentifier: 'b1d5051b-071d-4514-93cf-704724cdc8f6',
},
@@ -630,6 +633,9 @@ export const STANDARD_OBJECTS = {
status: {
universalIdentifier: '93483569-fcd2-46cf-b576-9f0318ad2b3b',
},
recordingRequestStatus: {
universalIdentifier: '364a90b1-e9aa-4606-996b-46e579ebeb28',
},
startedAt: {
universalIdentifier: '3fd00fbb-c153-45e3-b6e6-43d18d34052a',
},