Add email thread widget and message thread record page layout (#19351)

## Summary
- Move email thread display from side panel to a dedicated record page
with a new `EMAIL_THREAD` widget type
- Add message thread as a standard object with page layout, subject
field, and backfill command
- Add reply-to-email command menu item for message thread records
- Remove old side panel message thread components in favor of the new
widget-based approach

## Type fixes
- Add `EMAIL_THREAD` to `WidgetConfigurationType`, `WidgetType`, and all
configuration/validator maps
- Create `EmailThreadConfigurationDTO` and shared
`EmailThreadConfiguration` type
- Register EMAIL_THREAD in widget type validators, configuration
resolvers, and standard widget mappings

## Test plan
- [ ] Verify message thread record pages render with the email thread
widget
- [ ] Verify email thread preview navigates to the record page instead
of opening side panel
- [ ] Verify reply-to-email command appears for message thread records
- [ ] Verify typecheck passes for both twenty-front and twenty-server
- [ ] Run existing test suites to check for regressions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Félix Malfait
2026-04-06 12:02:04 +02:00
committed by GitHub
parent 4aa1d71b12
commit 8acfacc69c
56 changed files with 2441 additions and 2371 deletions
@@ -0,0 +1,73 @@
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 { getWorkspaceSchemaName } from 'src/engine/workspace-datasource/utils/get-workspace-schema-name.util';
@Command({
name: 'upgrade:1-21:backfill-message-thread-subject',
description:
'Backfill messageThread.subject from the most recently received message in each thread',
})
export class BackfillMessageThreadSubjectCommand extends ActiveOrSuspendedWorkspaceCommandRunner {
constructor(
protected readonly workspaceIteratorService: WorkspaceIteratorService,
) {
super(workspaceIteratorService);
}
override async runOnWorkspace({
workspaceId,
dataSource,
options,
}: RunOnWorkspaceArgs): Promise<void> {
if (!dataSource) {
this.logger.log(`No data source for workspace ${workspaceId}, skipping`);
return;
}
const schemaName = getWorkspaceSchemaName(workspaceId);
if (options.dryRun) {
this.logger.log(
`[DRY RUN] Would backfill messageThread.subject for workspace ${workspaceId}`,
);
return;
}
const columnExists = await dataSource.query(
`SELECT 1 FROM information_schema.columns
WHERE table_schema = $1
AND table_name = 'messageThread'
AND column_name = 'subject'`,
[schemaName],
);
if (columnExists.length === 0) {
this.logger.log(
`Column "subject" does not exist yet on messageThread for workspace ${workspaceId}, skipping (will be created by sync-metadata)`,
);
return;
}
const result = await dataSource.query(
`UPDATE "${schemaName}"."messageThread" mt
SET "subject" = sub.subject
FROM (
SELECT DISTINCT ON ("messageThreadId") "messageThreadId", "subject"
FROM "${schemaName}"."message"
ORDER BY "messageThreadId", "receivedAt" DESC NULLS LAST
) sub
WHERE mt.id = sub."messageThreadId"
AND mt."subject" IS NULL`,
);
this.logger.log(
`Backfilled subject for ${result?.[1] ?? 0} message threads in workspace ${workspaceId}`,
);
}
}
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module';
import { BackfillMessageThreadSubjectCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-backfill-message-thread-subject.command';
import { AddGlobalKeyValuePairUniqueIndexCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-add-global-key-value-pair-unique-index.command';
import { BackfillDatasourceToWorkspaceCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-backfill-datasource-to-workspace.command';
import { BackfillPageLayoutsAndFieldsWidgetViewFieldsCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-backfill-page-layouts-and-fields-widget-view-fields.command';
@@ -39,6 +40,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
providers: [
AddGlobalKeyValuePairUniqueIndexCommand,
BackfillDatasourceToWorkspaceCommand,
BackfillMessageThreadSubjectCommand,
BackfillPageLayoutsAndFieldsWidgetViewFieldsCommand,
DeduplicateEngineCommandsCommand,
FixSelectAllCommandMenuItemsCommand,
@@ -50,6 +52,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
exports: [
AddGlobalKeyValuePairUniqueIndexCommand,
BackfillDatasourceToWorkspaceCommand,
BackfillMessageThreadSubjectCommand,
BackfillPageLayoutsAndFieldsWidgetViewFieldsCommand,
DeduplicateEngineCommandsCommand,
FixSelectAllCommandMenuItemsCommand,
@@ -28,6 +28,7 @@ import { SeedCliApplicationRegistrationCommand } from 'src/database/commands/upg
import { UpdateStandardIndexViewNamesCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-update-standard-index-view-names.command';
import { AddGlobalKeyValuePairUniqueIndexCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-add-global-key-value-pair-unique-index.command';
import { BackfillDatasourceToWorkspaceCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-backfill-datasource-to-workspace.command';
import { BackfillMessageThreadSubjectCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-backfill-message-thread-subject.command';
import { BackfillPageLayoutsAndFieldsWidgetViewFieldsCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-backfill-page-layouts-and-fields-widget-view-fields.command';
import { DeduplicateEngineCommandsCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-deduplicate-engine-commands.command';
import { FixSelectAllCommandMenuItemsCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-fix-select-all-command-menu-items.command';
@@ -75,6 +76,7 @@ export class UpgradeCommand extends UpgradeCommandRunner {
// 1.21 Commands
private readonly addGlobalKeyValuePairUniqueIndexCommand: AddGlobalKeyValuePairUniqueIndexCommand,
private readonly backfillDatasourceToWorkspaceCommand: BackfillDatasourceToWorkspaceCommand,
private readonly backfillMessageThreadSubjectCommand: BackfillMessageThreadSubjectCommand,
private readonly backfillPageLayoutsAndFieldsWidgetViewFieldsCommand: BackfillPageLayoutsAndFieldsWidgetViewFieldsCommand,
private readonly deduplicateEngineCommandsCommand: DeduplicateEngineCommandsCommand,
private readonly fixSelectAllCommandMenuItemsCommand: FixSelectAllCommandMenuItemsCommand,
@@ -116,6 +118,7 @@ export class UpgradeCommand extends UpgradeCommandRunner {
const commands_1210: VersionCommands = [
this.addGlobalKeyValuePairUniqueIndexCommand,
this.backfillDatasourceToWorkspaceCommand,
this.backfillMessageThreadSubjectCommand,
this.backfillPageLayoutsAndFieldsWidgetViewFieldsCommand,
this.deduplicateEngineCommandsCommand,
this.fixSelectAllCommandMenuItemsCommand,
@@ -0,0 +1,47 @@
import { type MigrationInterface, type QueryRunner } from 'typeorm';
export class AddEmailThreadWidgetType1775200000000
implements MigrationInterface
{
name = 'AddEmailThreadWidgetType1775200000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TYPE "core"."pageLayoutWidget_type_enum" RENAME TO "pageLayoutWidget_type_enum_old"`,
);
await queryRunner.query(
`CREATE TYPE "core"."pageLayoutWidget_type_enum" AS ENUM('VIEW', 'IFRAME', 'FIELD', 'FIELDS', 'GRAPH', 'STANDALONE_RICH_TEXT', 'TIMELINE', 'TASKS', 'NOTES', 'FILES', 'EMAILS', 'CALENDAR', 'FIELD_RICH_TEXT', 'WORKFLOW', 'WORKFLOW_VERSION', 'WORKFLOW_RUN', 'FRONT_COMPONENT', 'RECORD_TABLE', 'EMAIL_THREAD')`,
);
await queryRunner.query(
`ALTER TABLE "core"."pageLayoutWidget" ALTER COLUMN "type" DROP DEFAULT`,
);
await queryRunner.query(
`ALTER TABLE "core"."pageLayoutWidget" ALTER COLUMN "type" TYPE "core"."pageLayoutWidget_type_enum" USING "type"::"text"::"core"."pageLayoutWidget_type_enum"`,
);
await queryRunner.query(
`ALTER TABLE "core"."pageLayoutWidget" ALTER COLUMN "type" SET DEFAULT 'VIEW'`,
);
await queryRunner.query(
`DROP TYPE "core"."pageLayoutWidget_type_enum_old"`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TYPE "core"."pageLayoutWidget_type_enum_old" AS ENUM('VIEW', 'IFRAME', 'FIELD', 'FIELDS', 'GRAPH', 'STANDALONE_RICH_TEXT', 'TIMELINE', 'TASKS', 'NOTES', 'FILES', 'EMAILS', 'CALENDAR', 'FIELD_RICH_TEXT', 'WORKFLOW', 'WORKFLOW_VERSION', 'WORKFLOW_RUN', 'FRONT_COMPONENT', 'RECORD_TABLE')`,
);
await queryRunner.query(
`ALTER TABLE "core"."pageLayoutWidget" ALTER COLUMN "type" DROP DEFAULT`,
);
await queryRunner.query(
`ALTER TABLE "core"."pageLayoutWidget" ALTER COLUMN "type" TYPE "core"."pageLayoutWidget_type_enum_old" USING "type"::"text"::"core"."pageLayoutWidget_type_enum_old"`,
);
await queryRunner.query(
`ALTER TABLE "core"."pageLayoutWidget" ALTER COLUMN "type" SET DEFAULT 'VIEW'`,
);
await queryRunner.query(`DROP TYPE "core"."pageLayoutWidget_type_enum"`);
await queryRunner.query(
`ALTER TYPE "core"."pageLayoutWidget_type_enum_old" RENAME TO "pageLayoutWidget_type_enum"`,
);
}
}
@@ -55,6 +55,7 @@ export enum EngineComponentKey {
VIEW_PREVIOUS_AI_CHATS = 'VIEW_PREVIOUS_AI_CHATS',
TRIGGER_WORKFLOW_VERSION = 'TRIGGER_WORKFLOW_VERSION',
FRONT_COMPONENT_RENDERER = 'FRONT_COMPONENT_RENDERER',
REPLY_TO_EMAIL_THREAD = 'REPLY_TO_EMAIL_THREAD',
// Deprecated keys kept for backward compatibility until migration runs
DELETE_SINGLE_RECORD = 'DELETE_SINGLE_RECORD',
@@ -92,6 +92,9 @@ export class FlatPageLayoutWidgetTypeValidatorService {
RECORD_TABLE: validateSimpleRecordPageWidgetForCreation(
WidgetConfigurationType.RECORD_TABLE,
),
EMAIL_THREAD: validateSimpleRecordPageWidgetForCreation(
WidgetConfigurationType.EMAIL_THREAD,
),
};
private readonly PAGE_LAYOUT_WIDGET_TYPE_VALIDATOR_FOR_UPDATE_HASHMAP: FlatPageLayoutWidgetTypeValidatorForUpdate =
@@ -143,6 +146,9 @@ export class FlatPageLayoutWidgetTypeValidatorService {
RECORD_TABLE: validateSimpleRecordPageWidgetForUpdate(
WidgetConfigurationType.RECORD_TABLE,
),
EMAIL_THREAD: validateSimpleRecordPageWidgetForUpdate(
WidgetConfigurationType.EMAIL_THREAD,
),
};
public validateFlatPageLayoutWidgetTypeSpecificitiesForCreation(
@@ -378,6 +378,7 @@ export const fromPageLayoutWidgetConfigurationToUniversalConfiguration = ({
case WidgetConfigurationType.WORKFLOW_RUN:
case WidgetConfigurationType.IFRAME:
case WidgetConfigurationType.STANDALONE_RICH_TEXT:
case WidgetConfigurationType.EMAIL_THREAD:
return configuration;
}
};
@@ -1,4 +1,5 @@
import { AggregateChartConfigurationDTO } from 'src/engine/metadata-modules/page-layout-widget/dtos/aggregate-chart-configuration.dto';
import { EmailThreadConfigurationDTO } from 'src/engine/metadata-modules/page-layout-widget/dtos/email-thread-configuration.dto';
import { BarChartConfigurationDTO } from 'src/engine/metadata-modules/page-layout-widget/dtos/bar-chart-configuration.dto';
import { CalendarConfigurationDTO } from 'src/engine/metadata-modules/page-layout-widget/dtos/calendar-configuration.dto';
import { EmailsConfigurationDTO } from 'src/engine/metadata-modules/page-layout-widget/dtos/emails-configuration.dto';
@@ -35,6 +36,7 @@ export const ALL_WIDGET_CONFIGURATION_TYPE_VALIDATOR_BY_WIDGET_CONFIGURATION_TYP
CALENDAR: CalendarConfigurationDTO,
FRONT_COMPONENT: FrontComponentConfigurationDTO,
EMAILS: EmailsConfigurationDTO,
EMAIL_THREAD: EmailThreadConfigurationDTO,
FIELD: FieldConfigurationDTO,
FIELD_RICH_TEXT: FieldRichTextConfigurationDTO,
FIELDS: FieldsConfigurationDTO,
@@ -0,0 +1,14 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { IsIn, IsNotEmpty } from 'class-validator';
import { type EmailThreadConfiguration } from 'twenty-shared/types';
import { WidgetConfigurationType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-configuration-type.type';
@ObjectType('EmailThreadConfiguration')
export class EmailThreadConfigurationDTO implements EmailThreadConfiguration {
@Field(() => WidgetConfigurationType)
@IsIn([WidgetConfigurationType.EMAIL_THREAD])
@IsNotEmpty()
configurationType: WidgetConfigurationType.EMAIL_THREAD;
}
@@ -27,6 +27,7 @@ export enum WidgetConfigurationType {
WORKFLOW_RUN = 'WORKFLOW_RUN',
FRONT_COMPONENT = 'FRONT_COMPONENT',
RECORD_TABLE = 'RECORD_TABLE',
EMAIL_THREAD = 'EMAIL_THREAD',
}
export type AllGraphWidgetConfigurationType =
| WidgetConfigurationType.AGGREGATE_CHART
@@ -17,4 +17,5 @@ export enum WidgetType {
WORKFLOW_RUN = 'WORKFLOW_RUN',
FRONT_COMPONENT = 'FRONT_COMPONENT',
RECORD_TABLE = 'RECORD_TABLE',
EMAIL_THREAD = 'EMAIL_THREAD',
}
@@ -3,10 +3,26 @@ type MessageThreadDataSeed = {
createdAt: Date;
updatedAt: Date;
deletedAt: Date | null;
subject: string;
};
export const MESSAGE_THREAD_DATA_SEED_COLUMNS: (keyof MessageThreadDataSeed)[] =
['id', 'createdAt', 'updatedAt', 'deletedAt'];
['id', 'createdAt', 'updatedAt', 'deletedAt', 'subject'];
const EMAIL_SUBJECTS = [
'Meeting Request',
'Project Update',
'Invoice for Services',
'Thank You for the Meeting',
'Proposal Submission',
'Follow-up on Discussion',
'Customer Feedback',
'Training Session Reminder',
'Contract Renewal',
'Quarterly Report',
'Partnership Opportunity',
'Event Invitation',
];
const GENERATE_MESSAGE_THREAD_IDS = (): Record<string, string> => {
const THREAD_IDS: Record<string, string> = {};
@@ -41,11 +57,14 @@ const GENERATE_MESSAGE_THREAD_SEEDS = (): MessageThreadDataSeed[] => {
CREATED_DATE.getTime() + UPDATE_OFFSET * 24 * 60 * 60 * 1000,
);
const TEMPLATE_INDEX = ((INDEX - 1) * 2) % EMAIL_SUBJECTS.length;
THREAD_SEEDS.push({
id: MESSAGE_THREAD_DATA_SEED_IDS[`ID_${INDEX}`],
createdAt: CREATED_DATE,
updatedAt: UPDATED_DATE,
deletedAt: null,
subject: EMAIL_SUBJECTS[TEMPLATE_INDEX],
});
}
@@ -787,4 +787,19 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
engineComponentKey: EngineComponentKey.VIEW_PREVIOUS_AI_CHATS,
hotKeys: null,
},
replyToEmailThread: {
universalIdentifier: '8f015cbd-c764-434e-a6c6-bb7581b4be44',
label: 'Reply',
icon: 'IconArrowBackUp',
isPinned: true,
position: 70,
shortLabel: 'Reply',
availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
conditionalAvailabilityExpression: 'numberOfSelectedRecords == 1',
availabilityObjectMetadataUniversalIdentifier:
STANDARD_OBJECTS.messageThread.universalIdentifier,
frontComponentUniversalIdentifier: null,
engineComponentKey: EngineComponentKey.REPLY_TO_EMAIL_THREAD,
hotKeys: null,
},
} as const;
@@ -223,4 +223,10 @@ export const WIDGET_PROPS = {
gridPosition: GRID_POSITIONS.FULL_WIDTH,
position: CANVAS_LAYOUT_POSITIONS.DEFAULT,
},
emailThread: {
title: 'Thread',
type: WidgetType.EMAIL_THREAD,
gridPosition: GRID_POSITIONS.FULL_WIDTH,
position: VERTICAL_LIST_LAYOUT_POSITIONS.SECOND,
},
} as const;
@@ -13,6 +13,7 @@ import {
STANDARD_MESSAGE_CHANNEL_PAGE_LAYOUT_CONFIG,
STANDARD_MESSAGE_FOLDER_PAGE_LAYOUT_CONFIG,
STANDARD_MESSAGE_PARTICIPANT_PAGE_LAYOUT_CONFIG,
STANDARD_MESSAGE_THREAD_PAGE_LAYOUT_CONFIG,
STANDARD_NOTE_PAGE_LAYOUT_CONFIG,
STANDARD_OPPORTUNITY_PAGE_LAYOUT_CONFIG,
STANDARD_PERSON_PAGE_LAYOUT_CONFIG,
@@ -43,6 +44,7 @@ export const STANDARD_PAGE_LAYOUTS = {
STANDARD_MESSAGE_CHANNEL_MESSAGE_ASSOCIATION_MESSAGE_FOLDER_PAGE_LAYOUT_CONFIG,
messageFolderRecordPage: STANDARD_MESSAGE_FOLDER_PAGE_LAYOUT_CONFIG,
messageParticipantRecordPage: STANDARD_MESSAGE_PARTICIPANT_PAGE_LAYOUT_CONFIG,
messageThreadRecordPage: STANDARD_MESSAGE_THREAD_PAGE_LAYOUT_CONFIG,
noteRecordPage: STANDARD_NOTE_PAGE_LAYOUT_CONFIG,
opportunityRecordPage: STANDARD_OPPORTUNITY_PAGE_LAYOUT_CONFIG,
personRecordPage: STANDARD_PERSON_PAGE_LAYOUT_CONFIG,
@@ -1,4 +1,4 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`getStandardPageLayoutMetadataRelatedEntityIds should return standard page layout metadata related entity ids 1`] = `
{
@@ -321,6 +321,19 @@ exports[`getStandardPageLayoutMetadataRelatedEntityIds should return standard pa
},
},
},
"messageThreadRecordPage": {
"id": "00000000-0000-0000-0000-000000000088",
"tabs": {
"home": {
"id": "00000000-0000-0000-0000-000000000089",
"widgets": {
"emailThread": {
"id": "00000000-0000-0000-0000-000000000090",
},
},
},
},
},
"myFirstDashboard": {
"id": "00000000-0000-0000-0000-000000000001",
"tabs": {
@@ -356,299 +369,299 @@ exports[`getStandardPageLayoutMetadataRelatedEntityIds should return standard pa
},
},
"noteRecordPage": {
"id": "00000000-0000-0000-0000-000000000088",
"id": "00000000-0000-0000-0000-000000000091",
"tabs": {
"files": {
"id": "00000000-0000-0000-0000-000000000096",
"id": "00000000-0000-0000-0000-000000000099",
"widgets": {
"files": {
"id": "00000000-0000-0000-0000-000000000097",
"id": "00000000-0000-0000-0000-000000000100",
},
},
},
"home": {
"id": "00000000-0000-0000-0000-000000000089",
"id": "00000000-0000-0000-0000-000000000092",
"widgets": {
"fields": {
"id": "00000000-0000-0000-0000-000000000090",
"id": "00000000-0000-0000-0000-000000000093",
},
"noteRichText": {
"id": "00000000-0000-0000-0000-000000000091",
"id": "00000000-0000-0000-0000-000000000094",
},
},
},
"note": {
"id": "00000000-0000-0000-0000-000000000092",
"id": "00000000-0000-0000-0000-000000000095",
"widgets": {
"noteRichText": {
"id": "00000000-0000-0000-0000-000000000093",
"id": "00000000-0000-0000-0000-000000000096",
},
},
},
"timeline": {
"id": "00000000-0000-0000-0000-000000000094",
"id": "00000000-0000-0000-0000-000000000097",
"widgets": {
"timeline": {
"id": "00000000-0000-0000-0000-000000000095",
"id": "00000000-0000-0000-0000-000000000098",
},
},
},
},
},
"opportunityRecordPage": {
"id": "00000000-0000-0000-0000-000000000098",
"id": "00000000-0000-0000-0000-000000000101",
"tabs": {
"calendar": {
"id": "00000000-0000-0000-0000-000000000114",
"id": "00000000-0000-0000-0000-000000000117",
"widgets": {
"calendar": {
"id": "00000000-0000-0000-0000-000000000115",
"id": "00000000-0000-0000-0000-000000000118",
},
},
},
"emails": {
"id": "00000000-0000-0000-0000-000000000112",
"id": "00000000-0000-0000-0000-000000000115",
"widgets": {
"emails": {
"id": "00000000-0000-0000-0000-000000000113",
"id": "00000000-0000-0000-0000-000000000116",
},
},
},
"files": {
"id": "00000000-0000-0000-0000-000000000110",
"id": "00000000-0000-0000-0000-000000000113",
"widgets": {
"files": {
"id": "00000000-0000-0000-0000-000000000111",
"id": "00000000-0000-0000-0000-000000000114",
},
},
},
"home": {
"id": "00000000-0000-0000-0000-000000000099",
"id": "00000000-0000-0000-0000-000000000102",
"widgets": {
"company": {
"id": "00000000-0000-0000-0000-000000000102",
"id": "00000000-0000-0000-0000-000000000105",
},
"fields": {
"id": "00000000-0000-0000-0000-000000000100",
},
"owner": {
"id": "00000000-0000-0000-0000-000000000103",
},
"owner": {
"id": "00000000-0000-0000-0000-000000000106",
},
"pointOfContact": {
"id": "00000000-0000-0000-0000-000000000101",
"id": "00000000-0000-0000-0000-000000000104",
},
},
},
"notes": {
"id": "00000000-0000-0000-0000-000000000108",
"id": "00000000-0000-0000-0000-000000000111",
"widgets": {
"notes": {
"id": "00000000-0000-0000-0000-000000000109",
"id": "00000000-0000-0000-0000-000000000112",
},
},
},
"tasks": {
"id": "00000000-0000-0000-0000-000000000106",
"id": "00000000-0000-0000-0000-000000000109",
"widgets": {
"tasks": {
"id": "00000000-0000-0000-0000-000000000107",
"id": "00000000-0000-0000-0000-000000000110",
},
},
},
"timeline": {
"id": "00000000-0000-0000-0000-000000000104",
"id": "00000000-0000-0000-0000-000000000107",
"widgets": {
"timeline": {
"id": "00000000-0000-0000-0000-000000000105",
"id": "00000000-0000-0000-0000-000000000108",
},
},
},
},
},
"personRecordPage": {
"id": "00000000-0000-0000-0000-000000000116",
"id": "00000000-0000-0000-0000-000000000119",
"tabs": {
"calendar": {
"id": "00000000-0000-0000-0000-000000000131",
"id": "00000000-0000-0000-0000-000000000134",
"widgets": {
"calendar": {
"id": "00000000-0000-0000-0000-000000000132",
"id": "00000000-0000-0000-0000-000000000135",
},
},
},
"emails": {
"id": "00000000-0000-0000-0000-000000000129",
"id": "00000000-0000-0000-0000-000000000132",
"widgets": {
"emails": {
"id": "00000000-0000-0000-0000-000000000130",
"id": "00000000-0000-0000-0000-000000000133",
},
},
},
"files": {
"id": "00000000-0000-0000-0000-000000000127",
"id": "00000000-0000-0000-0000-000000000130",
"widgets": {
"files": {
"id": "00000000-0000-0000-0000-000000000128",
"id": "00000000-0000-0000-0000-000000000131",
},
},
},
"home": {
"id": "00000000-0000-0000-0000-000000000117",
"id": "00000000-0000-0000-0000-000000000120",
"widgets": {
"company": {
"id": "00000000-0000-0000-0000-000000000119",
"id": "00000000-0000-0000-0000-000000000122",
},
"fields": {
"id": "00000000-0000-0000-0000-000000000118",
"id": "00000000-0000-0000-0000-000000000121",
},
"pointOfContactForOpportunities": {
"id": "00000000-0000-0000-0000-000000000120",
"id": "00000000-0000-0000-0000-000000000123",
},
},
},
"notes": {
"id": "00000000-0000-0000-0000-000000000125",
"id": "00000000-0000-0000-0000-000000000128",
"widgets": {
"notes": {
"id": "00000000-0000-0000-0000-000000000126",
"id": "00000000-0000-0000-0000-000000000129",
},
},
},
"tasks": {
"id": "00000000-0000-0000-0000-000000000123",
"id": "00000000-0000-0000-0000-000000000126",
"widgets": {
"tasks": {
"id": "00000000-0000-0000-0000-000000000124",
"id": "00000000-0000-0000-0000-000000000127",
},
},
},
"timeline": {
"id": "00000000-0000-0000-0000-000000000121",
"id": "00000000-0000-0000-0000-000000000124",
"widgets": {
"timeline": {
"id": "00000000-0000-0000-0000-000000000122",
"id": "00000000-0000-0000-0000-000000000125",
},
},
},
},
},
"taskRecordPage": {
"id": "00000000-0000-0000-0000-000000000133",
"id": "00000000-0000-0000-0000-000000000136",
"tabs": {
"files": {
"id": "00000000-0000-0000-0000-000000000141",
"id": "00000000-0000-0000-0000-000000000144",
"widgets": {
"files": {
"id": "00000000-0000-0000-0000-000000000142",
"id": "00000000-0000-0000-0000-000000000145",
},
},
},
"home": {
"id": "00000000-0000-0000-0000-000000000134",
"id": "00000000-0000-0000-0000-000000000137",
"widgets": {
"fields": {
"id": "00000000-0000-0000-0000-000000000135",
"id": "00000000-0000-0000-0000-000000000138",
},
"taskRichText": {
"id": "00000000-0000-0000-0000-000000000136",
"id": "00000000-0000-0000-0000-000000000139",
},
},
},
"note": {
"id": "00000000-0000-0000-0000-000000000137",
"id": "00000000-0000-0000-0000-000000000140",
"widgets": {
"taskRichText": {
"id": "00000000-0000-0000-0000-000000000138",
"id": "00000000-0000-0000-0000-000000000141",
},
},
},
"timeline": {
"id": "00000000-0000-0000-0000-000000000139",
"id": "00000000-0000-0000-0000-000000000142",
"widgets": {
"timeline": {
"id": "00000000-0000-0000-0000-000000000140",
"id": "00000000-0000-0000-0000-000000000143",
},
},
},
},
},
"workflowAutomatedTriggerRecordPage": {
"id": "00000000-0000-0000-0000-000000000146",
"id": "00000000-0000-0000-0000-000000000149",
"tabs": {
"home": {
"id": "00000000-0000-0000-0000-000000000147",
"id": "00000000-0000-0000-0000-000000000150",
"widgets": {
"fields": {
"id": "00000000-0000-0000-0000-000000000148",
"id": "00000000-0000-0000-0000-000000000151",
},
},
},
"timeline": {
"id": "00000000-0000-0000-0000-000000000149",
"id": "00000000-0000-0000-0000-000000000152",
"widgets": {
"timeline": {
"id": "00000000-0000-0000-0000-000000000150",
"id": "00000000-0000-0000-0000-000000000153",
},
},
},
},
},
"workflowRecordPage": {
"id": "00000000-0000-0000-0000-000000000143",
"id": "00000000-0000-0000-0000-000000000146",
"tabs": {
"flow": {
"id": "00000000-0000-0000-0000-000000000144",
"id": "00000000-0000-0000-0000-000000000147",
"widgets": {
"workflow": {
"id": "00000000-0000-0000-0000-000000000145",
"id": "00000000-0000-0000-0000-000000000148",
},
},
},
},
},
"workflowRunRecordPage": {
"id": "00000000-0000-0000-0000-000000000157",
"id": "00000000-0000-0000-0000-000000000160",
"tabs": {
"flow": {
"id": "00000000-0000-0000-0000-000000000161",
"id": "00000000-0000-0000-0000-000000000164",
"widgets": {
"workflowRun": {
"id": "00000000-0000-0000-0000-000000000162",
"id": "00000000-0000-0000-0000-000000000165",
},
},
},
"home": {
"id": "00000000-0000-0000-0000-000000000158",
"id": "00000000-0000-0000-0000-000000000161",
"widgets": {
"fields": {
"id": "00000000-0000-0000-0000-000000000159",
"id": "00000000-0000-0000-0000-000000000162",
},
"workflow": {
"id": "00000000-0000-0000-0000-000000000160",
"id": "00000000-0000-0000-0000-000000000163",
},
},
},
},
},
"workflowVersionRecordPage": {
"id": "00000000-0000-0000-0000-000000000151",
"id": "00000000-0000-0000-0000-000000000154",
"tabs": {
"flow": {
"id": "00000000-0000-0000-0000-000000000155",
"id": "00000000-0000-0000-0000-000000000158",
"widgets": {
"workflowVersion": {
"id": "00000000-0000-0000-0000-000000000156",
"id": "00000000-0000-0000-0000-000000000159",
},
},
},
"home": {
"id": "00000000-0000-0000-0000-000000000152",
"id": "00000000-0000-0000-0000-000000000155",
"widgets": {
"fields": {
"id": "00000000-0000-0000-0000-000000000153",
"id": "00000000-0000-0000-0000-000000000156",
},
"workflow": {
"id": "00000000-0000-0000-0000-000000000154",
"id": "00000000-0000-0000-0000-000000000157",
},
},
},
@@ -14,6 +14,7 @@ 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 { SEARCH_FIELDS_FOR_MESSAGE_THREAD } from 'src/modules/messaging/common/standard-objects/message-thread.workspace-entity';
export const buildMessageThreadStandardFlatFieldMetadatas = ({
now,
@@ -183,9 +184,9 @@ export const buildMessageThreadStandardFlatFieldMetadatas = ({
isNullable: true,
settings: {
generatedType: 'STORED',
asExpression: getTsVectorColumnExpressionFromFields([
{ name: 'id', type: FieldMetadataType.UUID },
]),
asExpression: getTsVectorColumnExpressionFromFields(
SEARCH_FIELDS_FOR_MESSAGE_THREAD,
),
},
},
standardObjectMetadataRelatedEntityIds,
@@ -193,6 +194,23 @@ export const buildMessageThreadStandardFlatFieldMetadatas = ({
twentyStandardApplicationId,
now,
}),
subject: createStandardFieldFlatMetadata({
objectName,
workspaceId,
context: {
fieldName: 'subject',
type: FieldMetadataType.TEXT,
label: i18nLabel(msg`Subject`),
description: i18nLabel(msg`Subject`),
icon: 'IconMessage',
isNullable: true,
isUIReadOnly: true,
},
standardObjectMetadataRelatedEntityIds,
dependencyFlatEntityMaps,
twentyStandardApplicationId,
now,
}),
messages: createStandardRelationFieldFlatMetadata({
objectName,
workspaceId,
@@ -510,7 +510,7 @@ export const STANDARD_FLAT_OBJECT_METADATA_BUILDERS_BY_OBJECT_NAME = {
icon: 'IconMessage',
isSystem: true,
isAuditLogged: false,
labelIdentifierFieldMetadataName: 'id',
labelIdentifierFieldMetadataName: 'subject',
},
workspaceId,
standardObjectMetadataRelatedEntityIds,
@@ -12,6 +12,7 @@ export { STANDARD_MESSAGE_CHANNEL_MESSAGE_ASSOCIATION_PAGE_LAYOUT_CONFIG } from
export { STANDARD_MESSAGE_CHANNEL_PAGE_LAYOUT_CONFIG } from './standard-message-channel-page-layout.config';
export { STANDARD_MESSAGE_FOLDER_PAGE_LAYOUT_CONFIG } from './standard-message-folder-page-layout.config';
export { STANDARD_MESSAGE_PARTICIPANT_PAGE_LAYOUT_CONFIG } from './standard-message-participant-page-layout.config';
export { STANDARD_MESSAGE_THREAD_PAGE_LAYOUT_CONFIG } from './standard-message-thread-page-layout.config';
export { STANDARD_NOTE_PAGE_LAYOUT_CONFIG } from './standard-note-page-layout.config';
export { STANDARD_OPPORTUNITY_PAGE_LAYOUT_CONFIG } from './standard-opportunity-page-layout.config';
export {
@@ -0,0 +1,33 @@
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
import { PageLayoutType } from 'src/engine/metadata-modules/page-layout/enums/page-layout-type.enum';
import {
TAB_PROPS,
WIDGET_PROPS,
} from 'src/engine/workspace-manager/twenty-standard-application/constants/standard-page-layout-tabs.template';
import {
type StandardPageLayoutConfig,
type StandardPageLayoutTabConfig,
} from 'src/engine/workspace-manager/twenty-standard-application/utils/page-layout-config/standard-page-layout-config.type';
const MESSAGE_THREAD_PAGE_TABS = {
home: {
universalIdentifier: '20202020-f639-48a0-9a44-027cf4e3cd15',
...TAB_PROPS.home,
widgets: {
emailThread: {
universalIdentifier: '20202020-d57e-44cb-b220-69a881feb9c3',
...WIDGET_PROPS.emailThread,
},
},
},
} as const satisfies Record<string, StandardPageLayoutTabConfig>;
export const STANDARD_MESSAGE_THREAD_PAGE_LAYOUT_CONFIG = {
name: 'Default Message Thread Layout',
type: PageLayoutType.RECORD_PAGE,
objectUniversalIdentifier: STANDARD_OBJECTS.messageThread.universalIdentifier,
universalIdentifier: '20202020-95bb-40eb-a699-70e7ea02a79e',
defaultTabUniversalIdentifier: null,
tabs: MESSAGE_THREAD_PAGE_TABS,
} as const satisfies StandardPageLayoutConfig;
@@ -61,6 +61,7 @@ const WIDGET_TYPE_TO_CONFIGURATION_TYPE: Partial<
[WidgetType.WORKFLOW_VERSION]: WidgetConfigurationType.WORKFLOW_VERSION,
[WidgetType.WORKFLOW_RUN]: WidgetConfigurationType.WORKFLOW_RUN,
[WidgetType.RECORD_TABLE]: WidgetConfigurationType.RECORD_TABLE,
[WidgetType.EMAIL_THREAD]: WidgetConfigurationType.EMAIL_THREAD,
};
const RECORD_PAGE_FIELDS_VIEW_NAME_BY_OBJECT: Partial<
@@ -135,10 +136,9 @@ const buildRecordPageWidgetConfigurations = ({
const baseConfig = { configurationType };
return {
// @ts-expect-error ignore - configurationType is validated but TS can't match to discriminated union
configuration: baseConfig,
// @ts-expect-error ignore - we'd need to implement for each widget type (including unused GRAPH type) to be able to match to the discriminated union
universalConfiguration: baseConfig,
configuration: baseConfig as AllPageLayoutWidgetConfiguration,
universalConfiguration:
baseConfig as CreateStandardPageLayoutWidgetContext['universalConfiguration'],
};
};
@@ -8,6 +8,18 @@ export const computeStandardMessageThreadViewFields = (
args: Omit<CreateStandardViewFieldArgs<'messageThread'>, 'context'>,
): Record<string, FlatViewField> => {
return {
allMessageThreadsSubject: createStandardViewFieldFlatMetadata({
...args,
objectName: 'messageThread',
context: {
viewName: 'allMessageThreads',
viewFieldName: 'subject',
fieldName: 'subject',
position: 0,
isVisible: true,
size: 300,
},
}),
allMessageThreadsMessages: createStandardViewFieldFlatMetadata({
...args,
objectName: 'messageThread',
@@ -15,9 +27,21 @@ export const computeStandardMessageThreadViewFields = (
viewName: 'allMessageThreads',
viewFieldName: 'messages',
fieldName: 'messages',
position: 0,
position: 1,
isVisible: true,
size: 180,
size: 150,
},
}),
allMessageThreadsUpdatedAt: createStandardViewFieldFlatMetadata({
...args,
objectName: 'messageThread',
context: {
viewName: 'allMessageThreads',
viewFieldName: 'updatedAt',
fieldName: 'updatedAt',
position: 2,
isVisible: true,
size: 150,
},
}),
allMessageThreadsCreatedAt: createStandardViewFieldFlatMetadata({
@@ -27,7 +51,7 @@ export const computeStandardMessageThreadViewFields = (
viewName: 'allMessageThreads',
viewFieldName: 'createdAt',
fieldName: 'createdAt',
position: 1,
position: 3,
isVisible: true,
size: 150,
},
@@ -1,4 +1,4 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`ALL_UNIVERSAL_FLAT_ENTITY_FOREIGN_KEY_AGGREGATOR_PROPERTIES should match snapshot 1`] = `
{
@@ -361,6 +361,7 @@ export const fromUniversalConfigurationToFlatPageLayoutWidgetConfiguration = ({
case WidgetConfigurationType.WORKFLOW_RUN:
case WidgetConfigurationType.IFRAME:
case WidgetConfigurationType.STANDALONE_RICH_TEXT:
case WidgetConfigurationType.EMAIL_THREAD:
return universalConfiguration;
}
};
@@ -1,7 +1,17 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity';
import { type FieldTypeAndNameMetadata } from 'src/engine/workspace-manager/utils/get-ts-vector-column-expression.util';
import { type EntityRelation } from 'src/engine/workspace-manager/workspace-migration/types/entity-relation.interface';
import { type MessageWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message.workspace-entity';
const SUBJECT_FIELD_NAME = 'subject';
export const SEARCH_FIELDS_FOR_MESSAGE_THREAD: FieldTypeAndNameMetadata[] = [
{ name: SUBJECT_FIELD_NAME, type: FieldMetadataType.TEXT },
];
export class MessageThreadWorkspaceEntity extends BaseWorkspaceEntity {
subject: string | null;
messages: EntityRelation<MessageWorkspaceEntity[]>;
}
@@ -25,7 +25,7 @@ type MessageAccumulator = {
| 'text'
| 'messageThreadId'
>;
threadToCreate?: Pick<MessageThreadWorkspaceEntity, 'id'>;
threadToCreate?: Pick<MessageThreadWorkspaceEntity, 'id' | 'subject'>;
messageChannelMessageAssociationToCreate?: Pick<
MessageChannelMessageAssociationWorkspaceEntity,
| 'id'
@@ -199,10 +199,52 @@ export class MessagingMessageService {
.map((accumulator) => accumulator.threadToCreate)
.filter(isDefined);
await messageThreadRepository.insert(
messageThreadsToCreate,
transactionManager,
);
const threadSubjectUpdates = new Map<
string,
{ subject: string; receivedAt: number }
>();
for (const message of messages) {
const messageAccumulator = messageAccumulatorMap.get(
message.externalId,
);
if (!isDefined(messageAccumulator)) {
continue;
}
if (
isDefined(messageAccumulator.existingThreadInDB) &&
isDefined(messageAccumulator.messageToCreate) &&
isDefined(message.subject)
) {
const threadId = messageAccumulator.existingThreadInDB.id;
const existing = threadSubjectUpdates.get(threadId);
const receivedAt = message.receivedAt?.getTime() ?? 0;
if (!isDefined(existing) || receivedAt > existing.receivedAt) {
threadSubjectUpdates.set(threadId, {
subject: message.subject,
receivedAt,
});
}
}
}
const threadsToUpsert = [
...messageThreadsToCreate,
...Array.from(threadSubjectUpdates.entries()).map(
([id, { subject }]) => ({ id, subject }),
),
];
if (threadsToUpsert.length > 0) {
await messageThreadRepository.upsert(
threadsToUpsert,
['id'],
transactionManager,
);
}
const messagesToCreate = Array.from(messageAccumulatorMap.values())
.map((accumulator) => accumulator.messageToCreate)
@@ -451,6 +493,7 @@ export class MessagingMessageService {
messageAccumulator.threadToCreate = {
id: newOrExistingMessageThreadId,
subject: message.subject,
};
}