5622 add a syncemail onboarding step (#5689)

- add sync email onboarding step
- refactor calendar and email visibility enums
- add a new table `keyValuePair` in `core` schema
- add a new resolved boolean field `skipSyncEmail` in current user




https://github.com/twentyhq/twenty/assets/29927851/de791475-5bfe-47f9-8e90-76c349fba56f
This commit is contained in:
martmull
2024-06-05 18:16:53 +02:00
committed by GitHub
parent fda0d2a170
commit 9f6a6c3282
92 changed files with 2707 additions and 1246 deletions

View File

@ -15,10 +15,11 @@ import { StopDataSeedDemoWorkspaceCronCommand } from 'src/database/commands/data
import { WorkspaceAddTotalCountCommand } from 'src/database/commands/workspace-add-total-count.command';
import { DataSeedDemoWorkspaceCommand } from 'src/database/commands/data-seed-demo-workspace/data-seed-demo-workspace-command';
import { DataSeedDemoWorkspaceModule } from 'src/database/commands/data-seed-demo-workspace/data-seed-demo-workspace.module';
import { UpdateMessageChannelSyncStatusEnumCommand } from 'src/database/commands/0-20-update-message-channel-sync-status-enum.command';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { UpdateMessageChannelVisibilityEnumCommand } from 'src/database/commands/update-message-channel-visibility-enum.command';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { WorkspaceCacheVersionModule } from 'src/engine/metadata-modules/workspace-cache-version/workspace-cache-version.module';
import { UpdateMessageChannelSyncStatusEnumCommand } from 'src/database/commands/0-20-update-message-channel-sync-status-enum.command';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
@Module({
@ -45,6 +46,7 @@ import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadat
ConfirmationQuestion,
StartDataSeedDemoWorkspaceCronCommand,
StopDataSeedDemoWorkspaceCronCommand,
UpdateMessageChannelVisibilityEnumCommand,
UpdateMessageChannelSyncStatusEnumCommand,
],
})

View File

@ -0,0 +1,166 @@
import { InjectRepository } from '@nestjs/typeorm';
import { Logger } from '@nestjs/common';
import { Command, CommandRunner, Option } from 'nest-commander';
import { Repository } from 'typeorm';
import chalk from 'chalk';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { TypeORMService } from 'src/database/typeorm/typeorm.service';
import { MessageChannelVisibility } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { WorkspaceCacheVersionService } from 'src/engine/metadata-modules/workspace-cache-version/workspace-cache-version.service';
interface UpdateMessageChannelVisibilityEnumCommandOptions {
workspaceId?: string;
}
@Command({
name: 'migrate-0.20:update-message-channel-visibility-enum',
description:
'Change the messageChannel visibility type and update records.visibility',
})
export class UpdateMessageChannelVisibilityEnumCommand extends CommandRunner {
private readonly logger = new Logger(
UpdateMessageChannelVisibilityEnumCommand.name,
);
constructor(
@InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>,
@InjectRepository(FieldMetadataEntity, 'metadata')
private readonly fieldMetadataRepository: Repository<FieldMetadataEntity>,
private readonly typeORMService: TypeORMService,
private readonly dataSourceService: DataSourceService,
private readonly workspaceCacheVersionService: WorkspaceCacheVersionService,
) {
super();
}
@Option({
flags: '-w, --workspace-id [workspace_id]',
description: 'workspace id. Command runs on all workspaces if not provided',
required: false,
})
parseWorkspaceId(value: string): string {
return value;
}
async run(
_passedParam: string[],
options: UpdateMessageChannelVisibilityEnumCommandOptions,
): Promise<void> {
let workspaceIds: string[] = [];
if (options.workspaceId) {
workspaceIds = [options.workspaceId];
} else {
workspaceIds = (await this.workspaceRepository.find()).map(
(workspace) => workspace.id,
);
}
if (!workspaceIds.length) {
this.logger.log(chalk.yellow('No workspace found'));
return;
} else {
this.logger.log(
chalk.green(`Running command on ${workspaceIds.length} workspaces`),
);
}
for (const workspaceId of workspaceIds) {
const dataSourceMetadatas =
await this.dataSourceService.getDataSourcesMetadataFromWorkspaceId(
workspaceId,
);
for (const dataSourceMetadata of dataSourceMetadatas) {
const workspaceDataSource =
await this.typeORMService.connectToDataSource(dataSourceMetadata);
if (workspaceDataSource) {
const queryRunner = workspaceDataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
const newMessageChannelVisibilities = Object.values(
MessageChannelVisibility,
);
try {
await queryRunner.query(
`ALTER TYPE "${dataSourceMetadata.schema}"."messageChannel_visibility_enum" RENAME TO "messageChannel_visibility_enum_old"`,
);
await queryRunner.query(
`CREATE TYPE "${
dataSourceMetadata.schema
}"."messageChannel_visibility_enum" AS ENUM ('${newMessageChannelVisibilities.join(
"','",
)}')`,
);
await queryRunner.query(
`ALTER TABLE "${dataSourceMetadata.schema}"."messageChannel" ALTER COLUMN "visibility" DROP DEFAULT`,
);
await queryRunner.query(
`ALTER TABLE "${dataSourceMetadata.schema}"."messageChannel" ALTER COLUMN "visibility" TYPE text`,
);
for (const newMessageChannelVisibility of newMessageChannelVisibilities) {
await queryRunner.query(
`UPDATE "${
dataSourceMetadata.schema
}"."messageChannel" SET "visibility" = '${newMessageChannelVisibility}' WHERE "visibility" = '${newMessageChannelVisibility.toLowerCase()}'`,
);
}
await queryRunner.query(
`ALTER TABLE "${dataSourceMetadata.schema}"."messageChannel" ALTER COLUMN "visibility" TYPE "${dataSourceMetadata.schema}"."messageChannel_visibility_enum" USING "visibility"::text::"${dataSourceMetadata.schema}"."messageChannel_visibility_enum"`,
);
await queryRunner.query(
`ALTER TABLE "${dataSourceMetadata.schema}"."messageChannel" ALTER COLUMN "visibility" SET DEFAULT '${MessageChannelVisibility.SHARE_EVERYTHING}'`,
);
await queryRunner.query(
`DROP TYPE "${dataSourceMetadata.schema}"."messageChannel_visibility_enum_old"`,
);
await queryRunner.commitTransaction();
} catch (error) {
await queryRunner.rollbackTransaction();
this.logger.log(
chalk.red(`Running command on workspace ${workspaceId} failed`),
);
throw error;
} finally {
await queryRunner.release();
}
}
}
await this.workspaceCacheVersionService.incrementVersion(workspaceId);
const visibilityFieldsMetadata = await this.fieldMetadataRepository.find({
where: { name: 'visibility', workspaceId },
});
for (const visibilityFieldMetadata of visibilityFieldsMetadata) {
const newOptions = visibilityFieldMetadata.options.map((option) => {
return { ...option, value: option.value.toUpperCase() };
});
const newDefaultValue =
typeof visibilityFieldMetadata.defaultValue === 'string'
? visibilityFieldMetadata.defaultValue.toUpperCase()
: visibilityFieldMetadata.defaultValue;
await this.fieldMetadataRepository.update(visibilityFieldMetadata.id, {
defaultValue: newDefaultValue,
options: newOptions,
});
}
this.logger.log(
chalk.green(`Running command on workspace ${workspaceId} done`),
);
}
this.logger.log(chalk.green(`Command completed!`));
}
}

View File

@ -1,6 +1,7 @@
import { EntityManager } from 'typeorm';
import { DEV_SEED_CONNECTED_ACCOUNT_IDS } from 'src/database/typeorm-seeds/workspace/connected-account';
import { CalendarChannelVisibility } from 'src/modules/calendar/standard-objects/calendar-channel.workspace-entity';
const tableName = 'calendarChannel';
@ -25,7 +26,7 @@ export const seedCalendarChannels = async (
id: '59efdefe-a40f-4faf-bb9f-c6f9945b8203',
connectedAccountId: DEV_SEED_CONNECTED_ACCOUNT_IDS.TIM,
handle: 'tim@apple.com',
visibility: 'SHARE_EVERYTHING',
visibility: CalendarChannelVisibility.SHARE_EVERYTHING,
isContactAutoCreationEnabled: true,
isSyncEnabled: true,
},

View File

@ -1,7 +1,10 @@
import { EntityManager } from 'typeorm';
import { DEV_SEED_CONNECTED_ACCOUNT_IDS } from 'src/database/typeorm-seeds/workspace/connected-account';
import { MessageChannelSyncStage } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
import {
MessageChannelSyncStage,
MessageChannelVisibility,
} from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
const tableName = 'messageChannel';
@ -41,8 +44,8 @@ export const seedMessageChannel = async (
type: 'email',
connectedAccountId: DEV_SEED_CONNECTED_ACCOUNT_IDS.TIM,
handle: 'tim@apple.dev',
visibility: 'share_everything',
syncStage: MessageChannelSyncStage.FULL_MESSAGE_LIST_FETCH_PENDING,
visibility: MessageChannelVisibility.SHARE_EVERYTHING,
syncSubStatus: MessageChannelSyncStage.FULL_MESSAGE_LIST_FETCH_PENDING,
},
{
id: DEV_SEED_MESSAGE_CHANNEL_IDS.JONY,
@ -53,8 +56,8 @@ export const seedMessageChannel = async (
type: 'email',
connectedAccountId: DEV_SEED_CONNECTED_ACCOUNT_IDS.JONY,
handle: 'jony.ive@apple.dev',
visibility: 'share_everything',
syncStage: MessageChannelSyncStage.FULL_MESSAGE_LIST_FETCH_PENDING,
visibility: MessageChannelVisibility.SHARE_EVERYTHING,
syncSubStatus: MessageChannelSyncStage.FULL_MESSAGE_LIST_FETCH_PENDING,
},
{
id: DEV_SEED_MESSAGE_CHANNEL_IDS.PHIL,
@ -65,8 +68,8 @@ export const seedMessageChannel = async (
type: 'email',
connectedAccountId: DEV_SEED_CONNECTED_ACCOUNT_IDS.PHIL,
handle: 'phil.schiler@apple.dev',
visibility: 'share_everything',
syncStage: MessageChannelSyncStage.FULL_MESSAGE_LIST_FETCH_PENDING,
visibility: MessageChannelVisibility.SHARE_EVERYTHING,
syncSubStatus: MessageChannelSyncStage.FULL_MESSAGE_LIST_FETCH_PENDING,
},
])
.execute();

View File

@ -0,0 +1,27 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddKeyValuePairTable1717425967770 implements MigrationInterface {
name = 'AddKeyValuePairTable1717425967770';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "core"."keyValuePair" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "userId" uuid, "workspaceId" uuid, "key" text NOT NULL, "value" text, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "deletedAt" TIMESTAMP WITH TIME ZONE, CONSTRAINT "IndexOnKeyUserIdWorkspaceIdUnique" UNIQUE ("key", "userId", "workspaceId"), CONSTRAINT "PK_c5a1ca828435d3eaf8f9361ed4b" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`ALTER TABLE "core"."keyValuePair" ADD CONSTRAINT "FK_0dae35d1c0fbdda6495be4ae71a" FOREIGN KEY ("userId") REFERENCES "core"."user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "core"."keyValuePair" ADD CONSTRAINT "FK_c137e3d8b3980901e114941daa2" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."keyValuePair" DROP CONSTRAINT "FK_c137e3d8b3980901e114941daa2"`,
);
await queryRunner.query(
`ALTER TABLE "core"."keyValuePair" DROP CONSTRAINT "FK_0dae35d1c0fbdda6495be4ae71a"`,
);
await queryRunner.query(`DROP TABLE "core"."keyValuePair"`);
}
}

View File

@ -11,6 +11,7 @@ import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-
import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { BillingSubscriptionItem } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { KeyValuePair } from 'src/engine/core-modules/key-value-pair/key-value-pair.entity';
@Injectable()
export class TypeORMService implements OnModuleInit, OnModuleDestroy {
@ -29,6 +30,7 @@ export class TypeORMService implements OnModuleInit, OnModuleDestroy {
Workspace,
UserWorkspace,
AppToken,
KeyValuePair,
FeatureFlagEntity,
BillingSubscription,
BillingSubscriptionItem,

View File

@ -27,6 +27,7 @@ import { ObjectMetadataRepositoryModule } from 'src/engine/object-metadata-repos
import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
import { CalendarChannelWorkspaceEntity } from 'src/modules/calendar/standard-objects/calendar-channel.workspace-entity';
import { MessageChannelWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
import { UserStateModule } from 'src/engine/core-modules/user-state/user-state.module';
import { AuthResolver } from './auth.resolver';
@ -63,6 +64,7 @@ const jwtModule = JwtModule.registerAsync({
]),
HttpModule,
UserWorkspaceModule,
UserStateModule,
],
controllers: [
GoogleAuthController,

View File

@ -15,6 +15,10 @@ import { GoogleAPIsRequest } from 'src/engine/core-modules/auth/strategies/googl
import { GoogleAPIsService } from 'src/engine/core-modules/auth/services/google-apis.service';
import { TokenService } from 'src/engine/core-modules/auth/services/token.service';
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
import { UserStateService } from 'src/engine/core-modules/user-state/user-state.service';
import { InjectObjectMetadataRepository } from 'src/engine/object-metadata-repository/object-metadata-repository.decorator';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
import { WorkspaceMemberRepository } from 'src/modules/workspace-member/repositories/workspace-member.repository';
@Controller('auth/google-apis')
export class GoogleAPIsAuthController {
@ -22,6 +26,9 @@ export class GoogleAPIsAuthController {
private readonly googleAPIsService: GoogleAPIsService,
private readonly tokenService: TokenService,
private readonly environmentService: EnvironmentService,
private readonly userStateService: UserStateService,
@InjectObjectMetadataRepository(WorkspaceMemberWorkspaceEntity)
private readonly workspaceMemberService: WorkspaceMemberRepository,
) {}
@Get()
@ -39,7 +46,15 @@ export class GoogleAPIsAuthController {
) {
const { user } = req;
const { email, accessToken, refreshToken, transientToken } = user;
const {
email,
accessToken,
refreshToken,
transientToken,
redirectLocation,
calendarVisibility,
messageVisibility,
} = user;
const { workspaceMemberId, workspaceId } =
await this.tokenService.verifyTransientToken(transientToken);
@ -62,10 +77,25 @@ export class GoogleAPIsAuthController {
workspaceId: workspaceId,
accessToken,
refreshToken,
calendarVisibility,
messageVisibility,
});
const userId = (
await this.workspaceMemberService.find(workspaceMemberId, workspaceId)
)?.userId;
if (userId) {
await this.userStateService.skipSyncEmailOnboardingStep(
userId,
workspaceId,
);
}
return res.redirect(
`${this.environmentService.get('FRONT_BASE_URL')}/settings/accounts`,
`${this.environmentService.get('FRONT_BASE_URL')}${
redirectLocation || '/settings/accounts'
}`,
);
}
}

View File

@ -12,10 +12,26 @@ export class GoogleAPIsOauthGuard extends AuthGuard('google-apis') {
async canActivate(context: ExecutionContext) {
const request = context.switchToHttp().getRequest();
const transientToken = request.query.transientToken;
const redirectLocation = request.query.redirectLocation;
const calendarVisibility = request.query.calendarVisibility;
const messageVisibility = request.query.messageVisibility;
if (transientToken && typeof transientToken === 'string') {
request.params.transientToken = transientToken;
}
if (redirectLocation && typeof redirectLocation === 'string') {
request.params.redirectLocation = redirectLocation;
}
if (calendarVisibility && typeof calendarVisibility === 'string') {
request.params.calendarVisibility = calendarVisibility;
}
if (messageVisibility && typeof messageVisibility === 'string') {
request.params.messageVisibility = messageVisibility;
}
const activate = (await super.canActivate(context)) as boolean;
return activate;

View File

@ -58,8 +58,16 @@ export class GoogleAPIsService {
workspaceId: string;
accessToken: string;
refreshToken: string;
calendarVisibility: CalendarChannelVisibility | undefined;
messageVisibility: MessageChannelVisibility | undefined;
}) {
const { handle, workspaceId, workspaceMemberId } = input;
const {
handle,
workspaceId,
workspaceMemberId,
calendarVisibility,
messageVisibility,
} = input;
const dataSourceMetadata =
await this.dataSourceService.getLastDataSourceMetadataFromWorkspaceIdOrFail(
@ -104,7 +112,8 @@ export class GoogleAPIsService {
connectedAccountId: newOrExistingConnectedAccountId,
type: MessageChannelType.EMAIL,
handle,
visibility: MessageChannelVisibility.SHARE_EVERYTHING,
visibility:
messageVisibility || MessageChannelVisibility.SHARE_EVERYTHING,
},
workspaceId,
manager,
@ -116,7 +125,9 @@ export class GoogleAPIsService {
id: v4(),
connectedAccountId: newOrExistingConnectedAccountId,
handle,
visibility: CalendarChannelVisibility.SHARE_EVERYTHING,
visibility:
calendarVisibility ||
CalendarChannelVisibility.SHARE_EVERYTHING,
},
workspaceId,
manager,

View File

@ -5,6 +5,8 @@ import { Strategy, VerifyCallback } from 'passport-google-oauth20';
import { Request } from 'express';
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
import { CalendarChannelVisibility } from 'src/modules/calendar/standard-objects/calendar-channel.workspace-entity';
import { MessageChannelVisibility } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
export type GoogleAPIsRequest = Omit<
Request,
@ -19,6 +21,9 @@ export type GoogleAPIsRequest = Omit<
accessToken: string;
refreshToken: string;
transientToken: string;
redirectLocation?: string;
calendarVisibility?: CalendarChannelVisibility;
messageVisibility?: MessageChannelVisibility;
};
};
@ -64,6 +69,9 @@ export class GoogleAPIsStrategy extends PassportStrategy(
prompt: 'consent',
state: JSON.stringify({
transientToken: req.params.transientToken,
redirectLocation: req.params.redirectLocation,
calendarVisibility: req.params.calendarVisibility,
messageVisibility: req.params.messageVisibility,
}),
};
@ -92,6 +100,9 @@ export class GoogleAPIsStrategy extends PassportStrategy(
accessToken,
refreshToken,
transientToken: state.transientToken,
redirectLocation: state.redirectLocation,
calendarVisibility: state.calendarVisibility,
messageVisibility: state.messageVisibility,
};
done(null, user);

View File

@ -2,15 +2,11 @@ import { ObjectType, Field, registerEnumType } from '@nestjs/graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { TimelineCalendarEventParticipant } from 'src/engine/core-modules/calendar/dtos/timeline-calendar-event-participant.dto';
import { CalendarChannelVisibility } from 'src/modules/calendar/standard-objects/calendar-channel.workspace-entity';
export enum TimelineCalendarEventVisibility {
METADATA = 'METADATA',
SHARE_EVERYTHING = 'SHARE_EVERYTHING',
}
registerEnumType(TimelineCalendarEventVisibility, {
name: 'TimelineCalendarEventVisibility',
description: 'Visibility of the calendar event',
registerEnumType(CalendarChannelVisibility, {
name: 'CalendarChannelVisibility',
description: 'Visibility of the calendar channel',
});
@ObjectType('LinkMetadata')
@ -57,6 +53,6 @@ export class TimelineCalendarEvent {
@Field(() => [TimelineCalendarEventParticipant])
participants: TimelineCalendarEventParticipant[];
@Field(() => TimelineCalendarEventVisibility)
visibility: TimelineCalendarEventVisibility;
@Field(() => CalendarChannelVisibility)
visibility: CalendarChannelVisibility;
}

View File

@ -4,12 +4,12 @@ import { Any } from 'typeorm';
import omit from 'lodash.omit';
import { TIMELINE_CALENDAR_EVENTS_DEFAULT_PAGE_SIZE } from 'src/engine/core-modules/calendar/constants/calendar.constants';
import { TimelineCalendarEventVisibility } from 'src/engine/core-modules/calendar/dtos/timeline-calendar-event.dto';
import { TimelineCalendarEventsWithTotal } from 'src/engine/core-modules/calendar/dtos/timeline-calendar-events-with-total.dto';
import { InjectWorkspaceRepository } from 'src/engine/twenty-orm/decorators/inject-workspace-repository.decorator';
import { WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';
import { CalendarEventWorkspaceEntity } from 'src/modules/calendar/standard-objects/calendar-event.workspace-entity';
import { PersonWorkspaceEntity } from 'src/modules/person/standard-objects/person.workspace-entity';
import { CalendarChannelVisibility } from 'src/modules/calendar/standard-objects/calendar-channel.workspace-entity';
@Injectable()
export class TimelineCalendarEventService {
@ -107,8 +107,8 @@ export class TimelineCalendarEventService {
const visibility = event.calendarChannelEventAssociations.some(
(association) => association.calendarChannel.visibility === 'METADATA',
)
? TimelineCalendarEventVisibility.METADATA
: TimelineCalendarEventVisibility.SHARE_EVERYTHING;
? CalendarChannelVisibility.METADATA
: CalendarChannelVisibility.SHARE_EVERYTHING;
return {
...omit(event, [

View File

@ -0,0 +1,62 @@
import { Field, ObjectType } from '@nestjs/graphql';
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
Relation,
Unique,
UpdateDateColumn,
} from 'typeorm';
import { IDField } from '@ptc-org/nestjs-query-graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
@Entity({ name: 'keyValuePair', schema: 'core' })
@ObjectType('KeyValuePair')
@Unique('IndexOnKeyUserIdWorkspaceIdUnique', ['key', 'userId', 'workspaceId'])
export class KeyValuePair {
@IDField(() => UUIDScalarType)
@PrimaryGeneratedColumn('uuid')
id: string;
@ManyToOne(() => User, (user) => user.keyValuePairs, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'userId' })
user: Relation<User>;
@Column({ nullable: true })
userId: string;
@ManyToOne(() => Workspace, (workspace) => workspace.keyValuePairs, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'workspaceId' })
workspace: Relation<Workspace>;
@Column({ nullable: true })
workspaceId: string;
@Field(() => String)
@Column({ nullable: false, type: 'text' })
key: string;
@Field(() => String, { nullable: true })
@Column({ nullable: true, type: 'text' })
value: string;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
@Column({ nullable: true, type: 'timestamptz' })
deletedAt: Date | null;
}

View File

@ -0,0 +1,22 @@
import { Module } from '@nestjs/common';
import { NestjsQueryGraphQLModule } from '@ptc-org/nestjs-query-graphql';
import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
import { KeyValuePairService } from 'src/engine/core-modules/key-value-pair/key-value-pair.service';
import { KeyValuePair } from 'src/engine/core-modules/key-value-pair/key-value-pair.entity';
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
@Module({
imports: [
NestjsQueryGraphQLModule.forFeature({
imports: [
NestjsQueryTypeOrmModule.forFeature([KeyValuePair], 'core'),
TypeORMModule,
],
}),
],
exports: [KeyValuePairService],
providers: [KeyValuePairService],
})
export class KeyValuePairModule {}

View File

@ -0,0 +1,55 @@
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { KeyValuePair } from 'src/engine/core-modules/key-value-pair/key-value-pair.entity';
import { UserStates } from 'src/engine/core-modules/user-state/enums/user-states.enum';
import { UserStateEmailSyncValues } from 'src/engine/core-modules/user-state/enums/user-state-email-sync-values.enum';
export enum KeyValueTypes {
USER_STATE = 'USER_STATE',
}
type KeyValuePairs = {
[KeyValueTypes.USER_STATE]: {
[UserStates.SYNC_EMAIL_ONBOARDING_STEP]: UserStateEmailSyncValues;
};
};
export class KeyValuePairService<TYPE extends keyof KeyValuePairs> {
constructor(
@InjectRepository(KeyValuePair, 'core')
private readonly keyValuePairRepository: Repository<KeyValuePair>,
) {}
async get<K extends keyof KeyValuePairs[TYPE]>(
userId: string,
workspaceId: string,
key: K,
) {
return await this.keyValuePairRepository.findOne({
where: {
userId,
workspaceId,
key: key as string,
},
});
}
async set<K extends keyof KeyValuePairs[TYPE]>(
userId: string,
workspaceId: string,
key: K,
value: KeyValuePairs[TYPE][K],
) {
await this.keyValuePairRepository.upsert(
{
userId,
workspaceId,
key: key as string,
value: value as string,
},
{ conflictPaths: ['userId', 'workspaceId', 'key'] },
);
}
}

View File

@ -1,7 +1,13 @@
import { ObjectType, Field } from '@nestjs/graphql';
import { ObjectType, Field, registerEnumType } from '@nestjs/graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { TimelineThreadParticipant } from 'src/engine/core-modules/messaging/dtos/timeline-thread-participant.dto';
import { MessageChannelVisibility } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
registerEnumType(MessageChannelVisibility, {
name: 'MessageChannelVisibility',
description: 'Visibility of the message channel',
});
@ObjectType('TimelineThread')
export class TimelineThread {
@ -11,8 +17,8 @@ export class TimelineThread {
@Field()
read: boolean;
@Field()
visibility: string;
@Field(() => MessageChannelVisibility)
visibility: MessageChannelVisibility;
@Field()
firstParticipant: TimelineThreadParticipant;

View File

@ -3,6 +3,7 @@ import { Injectable } from '@nestjs/common';
import { TIMELINE_THREADS_DEFAULT_PAGE_SIZE } from 'src/engine/core-modules/messaging/constants/messaging.constants';
import { TimelineThreadsWithTotal } from 'src/engine/core-modules/messaging/dtos/timeline-threads-with-total.dto';
import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service';
import { MessageChannelVisibility } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
type TimelineThreadParticipant = {
personId: string;
@ -352,7 +353,7 @@ export class TimelineMessagingService {
const threadVisibility:
| {
id: string;
visibility: 'metadata' | 'subject' | 'share_everything';
visibility: MessageChannelVisibility;
}[]
| undefined = await this.workspaceDataSourceService.executeRawQuery(
`
@ -372,11 +373,11 @@ export class TimelineMessagingService {
workspaceId,
);
const visibilityValues = ['metadata', 'subject', 'share_everything'];
const visibilityValues = Object.values(MessageChannelVisibility);
const threadVisibilityByThreadIdForWhichWorkspaceMemberIsNotInParticipants:
| {
[key: string]: 'metadata' | 'subject' | 'share_everything';
[key: string]: MessageChannelVisibility;
}
| undefined = threadVisibility?.reduce(
(threadVisibilityAcc, threadVisibility) => {
@ -385,7 +386,8 @@ export class TimelineMessagingService {
Math.max(
visibilityValues.indexOf(threadVisibility.visibility),
visibilityValues.indexOf(
threadVisibilityAcc[threadVisibility.id] ?? 'metadata',
threadVisibilityAcc[threadVisibility.id] ??
MessageChannelVisibility.METADATA,
),
)
];
@ -396,7 +398,7 @@ export class TimelineMessagingService {
);
const threadVisibilityByThreadId: {
[key: string]: 'metadata' | 'subject' | 'share_everything';
[key: string]: MessageChannelVisibility;
} = messageThreadIds.reduce((threadVisibilityAcc, messageThreadId) => {
// If the workspace member is not in the participants of the thread, use the visibility value from the query
threadVisibilityAcc[messageThreadId] =
@ -405,8 +407,8 @@ export class TimelineMessagingService {
)
? threadVisibilityByThreadIdForWhichWorkspaceMemberIsNotInParticipants?.[
messageThreadId
] ?? 'metadata'
: 'share_everything';
] ?? MessageChannelVisibility.METADATA
: MessageChannelVisibility.SHARE_EVERYTHING;
return threadVisibilityAcc;
}, {});
@ -461,7 +463,9 @@ export class TimelineMessagingService {
lastTwoParticipants,
lastMessageReceivedAt: thread.lastMessageReceivedAt,
lastMessageBody: thread.lastMessageBody,
visibility: threadVisibilityByThreadId?.[messageThreadId] ?? 'metadata',
visibility:
threadVisibilityByThreadId?.[messageThreadId] ??
MessageChannelVisibility.METADATA,
subject: threadSubject,
numberOfMessagesInThread: numberOfMessages,
participantCount: threadActiveParticipants.length,

View File

@ -0,0 +1,5 @@
import { UserState } from 'src/engine/core-modules/user-state/dtos/user-state.dto';
export const DEFAULT_USER_STATE: UserState = {
skipSyncEmailOnboardingStep: true,
};

View File

@ -0,0 +1,9 @@
import { Field, ObjectType } from '@nestjs/graphql';
@ObjectType()
export class SkipSyncEmailOnboardingStep {
@Field(() => Boolean, {
description: 'Boolean that confirms query was dispatched',
})
success: boolean;
}

View File

@ -0,0 +1,7 @@
import { Field, ObjectType } from '@nestjs/graphql';
@ObjectType('UserState')
export class UserState {
@Field(() => Boolean, { nullable: true })
skipSyncEmailOnboardingStep: boolean | null;
}

View File

@ -0,0 +1,3 @@
export enum UserStateEmailSyncValues {
SKIPPED = 'SKIPPED',
}

View File

@ -0,0 +1,3 @@
export enum UserStates {
SYNC_EMAIL_ONBOARDING_STEP = 'SYNC_EMAIL_ONBOARDING_STEP',
}

View File

@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { UserStateService } from 'src/engine/core-modules/user-state/user-state.service';
import { UserStateResolver } from 'src/engine/core-modules/user-state/user-state.resolver';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { KeyValuePairModule } from 'src/engine/core-modules/key-value-pair/key-value-pair.module';
@Module({
imports: [DataSourceModule, KeyValuePairModule],
exports: [UserStateService],
providers: [UserStateService, UserStateResolver],
})
export class UserStateModule {}

View File

@ -0,0 +1,28 @@
import { UseGuards } from '@nestjs/common';
import { Mutation, Resolver } from '@nestjs/graphql';
import { JwtAuthGuard } from 'src/engine/guards/jwt.auth.guard';
import { UserState } from 'src/engine/core-modules/user-state/dtos/user-state.dto';
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
import { User } from 'src/engine/core-modules/user/user.entity';
import { UserStateService } from 'src/engine/core-modules/user-state/user-state.service';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { SkipSyncEmailOnboardingStep } from 'src/engine/core-modules/user-state/dtos/skip-sync-email.entity-onboarding-step';
@UseGuards(JwtAuthGuard)
@Resolver(() => UserState)
export class UserStateResolver {
constructor(private readonly userStateService: UserStateService) {}
@Mutation(() => SkipSyncEmailOnboardingStep)
async skipSyncEmailOnboardingStep(
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
): Promise<SkipSyncEmailOnboardingStep> {
return await this.userStateService.skipSyncEmailOnboardingStep(
user.id,
workspace.id,
);
}
}

View File

@ -0,0 +1,64 @@
import { Injectable } from '@nestjs/common';
import { InjectObjectMetadataRepository } from 'src/engine/object-metadata-repository/object-metadata-repository.decorator';
import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
import { ConnectedAccountRepository } from 'src/modules/connected-account/repositories/connected-account.repository';
import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { UserState } from 'src/engine/core-modules/user-state/dtos/user-state.dto';
import {
KeyValuePairService,
KeyValueTypes,
} from 'src/engine/core-modules/key-value-pair/key-value-pair.service';
import { UserStates } from 'src/engine/core-modules/user-state/enums/user-states.enum';
import { UserStateEmailSyncValues } from 'src/engine/core-modules/user-state/enums/user-state-email-sync-values.enum';
import { SkipSyncEmailOnboardingStep } from 'src/engine/core-modules/user-state/dtos/skip-sync-email.entity-onboarding-step';
@Injectable()
export class UserStateService {
constructor(
@InjectObjectMetadataRepository(ConnectedAccountWorkspaceEntity)
private readonly connectedAccountRepository: ConnectedAccountRepository,
private readonly keyValuePairService: KeyValuePairService<KeyValueTypes.USER_STATE>,
) {}
async getUserState(user: User, workspace: Workspace): Promise<UserState> {
const connectedAccounts =
await this.connectedAccountRepository.getAllByUserId(
user.id,
workspace.id,
);
if (connectedAccounts?.length) {
return {
skipSyncEmailOnboardingStep: true,
};
}
const skipSyncEmail = await this.keyValuePairService.get(
user.id,
workspace.id,
UserStates.SYNC_EMAIL_ONBOARDING_STEP,
);
return {
skipSyncEmailOnboardingStep:
!!skipSyncEmail &&
skipSyncEmail.value === UserStateEmailSyncValues.SKIPPED,
};
}
async skipSyncEmailOnboardingStep(
userId: string,
workspaceId: string,
): Promise<SkipSyncEmailOnboardingStep> {
await this.keyValuePairService.set(
userId,
workspaceId,
UserStates.SYNC_EMAIL_ONBOARDING_STEP,
UserStateEmailSyncValues.SKIPPED,
);
return { success: true };
}
}

View File

@ -2,14 +2,13 @@ import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { UserService } from 'src/engine/core-modules/user/services/user.service';
import { User } from 'src/engine/core-modules/user/user.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { TypeORMService } from 'src/database/typeorm/typeorm.service';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service';
import { UserService } from './user.service';
describe('UserService', () => {
let service: UserService;

View File

@ -17,6 +17,8 @@ import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { WorkspaceMember } from 'src/engine/core-modules/user/dtos/workspace-member.dto';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { KeyValuePair } from 'src/engine/core-modules/key-value-pair/key-value-pair.entity';
import { UserState } from 'src/engine/core-modules/user-state/dtos/user-state.dto';
@Entity({ name: 'user', schema: 'core' })
@ObjectType('User')
@ -100,10 +102,18 @@ export class User {
})
appTokens: Relation<AppToken[]>;
@OneToMany(() => KeyValuePair, (keyValuePair) => keyValuePair.user, {
cascade: true,
})
keyValuePairs: Relation<KeyValuePair[]>;
@Field(() => WorkspaceMember, { nullable: true })
workspaceMember: Relation<WorkspaceMember>;
@Field(() => [UserWorkspace])
@OneToMany(() => UserWorkspace, (userWorkspace) => userWorkspace.user)
workspaces: Relation<UserWorkspace[]>;
@Field(() => UserState, { nullable: false })
state: UserState;
}

View File

@ -11,6 +11,7 @@ import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-s
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
import { FileUploadModule } from 'src/engine/core-modules/file/file-upload/file-upload.module';
import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module';
import { UserStateModule } from 'src/engine/core-modules/user-state/user-state.module';
import { userAutoResolverOpts } from './user.auto-resolver-opts';
@ -27,6 +28,7 @@ import { UserService } from './services/user.service';
}),
DataSourceModule,
FileUploadModule,
UserStateModule,
WorkspaceModule,
],
exports: [UserService],

View File

@ -1,10 +1,10 @@
import {
Resolver,
Query,
Args,
Parent,
ResolveField,
Mutation,
Parent,
Query,
ResolveField,
Resolver,
} from '@nestjs/graphql';
import { UseGuards } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
@ -17,6 +17,7 @@ import { Repository } from 'typeorm';
import { SupportDriver } from 'src/engine/integrations/environment/interfaces/support.interface';
import { FileFolder } from 'src/engine/core-modules/file/interfaces/file-folder.interface';
import { UserService } from 'src/engine/core-modules/user/services/user.service';
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
import { EnvironmentService } from 'src/engine/integrations/environment/environment.service';
import { streamToBuffer } from 'src/utils/stream-to-buffer';
@ -26,8 +27,11 @@ import { DemoEnvGuard } from 'src/engine/guards/demo.env.guard';
import { JwtAuthGuard } from 'src/engine/guards/jwt.auth.guard';
import { User } from 'src/engine/core-modules/user/user.entity';
import { WorkspaceMember } from 'src/engine/core-modules/user/dtos/workspace-member.dto';
import { UserService } from './services/user.service';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { UserState } from 'src/engine/core-modules/user-state/dtos/user-state.dto';
import { UserStateService } from 'src/engine/core-modules/user-state/user-state.service';
import { DEFAULT_USER_STATE } from 'src/engine/core-modules/user-state/constants/default-user-state';
const getHMACKey = (email?: string, key?: string | null) => {
if (!email || !key) return null;
@ -43,6 +47,7 @@ export class UserResolver {
constructor(
@InjectRepository(User, 'core')
private readonly userRepository: Repository<User>,
private readonly userStateService: UserStateService,
private readonly userService: UserService,
private readonly environmentService: EnvironmentService,
private readonly fileUploadService: FileUploadService,
@ -113,4 +118,16 @@ export class UserResolver {
// Proceed with user deletion
return this.userService.deleteUser(userId);
}
@ResolveField(() => UserState)
async state(
@Parent() user: User,
@AuthWorkspace() workspace: Workspace,
): Promise<UserState> {
if (!user || !workspace) {
return DEFAULT_USER_STATE;
}
return this.userStateService.getUserState(user, workspace);
}
}

View File

@ -18,6 +18,7 @@ import { BillingSubscription } from 'src/engine/core-modules/billing/entities/bi
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { KeyValuePair } from 'src/engine/core-modules/key-value-pair/key-value-pair.entity';
@Entity({ name: 'workspace', schema: 'core' })
@ObjectType('Workspace')
@ -63,6 +64,11 @@ export class Workspace {
})
appTokens: Relation<AppToken[]>;
@OneToMany(() => KeyValuePair, (keyValuePair) => keyValuePair.workspace, {
cascade: true,
})
keyValuePairs: Relation<KeyValuePair[]>;
@OneToMany(() => User, (user) => user.defaultWorkspace)
users: Relation<User[]>;

View File

@ -29,6 +29,15 @@ export class WorkspaceDataSourceService {
return dataSource;
}
public async checkSchemaExists(workspaceId: string) {
const dataSource =
await this.dataSourceService.getDataSourcesMetadataFromWorkspaceId(
workspaceId,
);
return dataSource.length > 0;
}
public async connectedToWorkspaceDataSourceAndReturnMetadata(
workspaceId: string,
): Promise<{ dataSource: DataSource; dataSourceMetadata: DataSourceEntity }> {

View File

@ -62,6 +62,41 @@ export class ConnectedAccountRepository {
return connectedAccounts;
}
public async getAllByUserId(
userId: string,
workspaceId: string,
transactionManager?: EntityManager,
): Promise<ObjectRecord<ConnectedAccountWorkspaceEntity>[] | undefined> {
const schemaExists =
await this.workspaceDataSourceService.checkSchemaExists(workspaceId);
if (!schemaExists) {
return;
}
const dataSourceSchema =
this.workspaceDataSourceService.getSchemaName(workspaceId);
const workspaceMember = (
await this.workspaceDataSourceService.executeRawQuery(
`SELECT * FROM ${dataSourceSchema}."workspaceMember" WHERE "userId" = $1`,
[userId],
workspaceId,
transactionManager,
)
)?.[0];
if (!workspaceMember) {
return;
}
return await this.getAllByWorkspaceMemberId(
workspaceMember.id,
workspaceId,
transactionManager,
);
}
public async getAllByHandleAndWorkspaceMemberId(
handle: string,
workspaceMemberId: string,

View File

@ -41,9 +41,9 @@ export enum MessageChannelSyncStage {
}
export enum MessageChannelVisibility {
METADATA = 'metadata',
SUBJECT = 'subject',
SHARE_EVERYTHING = 'share_everything',
METADATA = 'METADATA',
SUBJECT = 'SUBJECT',
SHARE_EVERYTHING = 'SHARE_EVERYTHING',
}
export enum MessageChannelType {

View File

@ -28,6 +28,20 @@ export class WorkspaceMemberRepository {
return result;
}
public async find(workspaceMemberId: string, workspaceId: string) {
const dataSourceSchema =
this.workspaceDataSourceService.getSchemaName(workspaceId);
const workspaceMembers =
await this.workspaceDataSourceService.executeRawQuery(
`SELECT * FROM ${dataSourceSchema}."workspaceMember" WHERE "id" = $1`,
[workspaceMemberId],
workspaceId,
);
return workspaceMembers?.[0];
}
public async getByIdOrFail(
userId: string,
workspaceId: string,