Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Fix] Improve Mention and Subscription APIs #8557

Merged
merged 9 commits into from
Nov 29, 2024
3 changes: 2 additions & 1 deletion packages/contracts/src/base-entity.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,5 +94,6 @@ export enum BaseEntityEnum {
Task = 'Task',
TaskView = 'TaskView',
TaskLinkedIssue = 'TaskLinkedIssue',
User = 'User'
User = 'User',
Comment = 'Comment'
}
7 changes: 5 additions & 2 deletions packages/contracts/src/comment.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { ActorTypeEnum, IBasePerEntityType, IBasePerTenantAndOrganizationEntityM
import { IUser } from './user.model';
import { IEmployee } from './employee.model';
import { IOrganizationTeam } from './organization-team.model';
import { IMentionedUserIds } from './mention.model';

export interface IComment extends IBasePerTenantAndOrganizationEntityModel, IBasePerEntityType {
comment: string;
Expand All @@ -20,13 +21,15 @@ export interface IComment extends IBasePerTenantAndOrganizationEntityModel, IBas
replies?: IComment[];
}

export interface ICommentCreateInput extends IBasePerEntityType {
export interface ICommentCreateInput extends IBasePerEntityType, IMentionedUserIds {
comment: string;
parentId?: ID;
members?: IEmployee[];
teams?: IOrganizationTeam[];
}

export interface ICommentUpdateInput extends Partial<Omit<IComment, 'entity' | 'entityId' | 'creatorId' | 'creator'>> {}
export interface ICommentUpdateInput
extends IMentionedUserIds,
Partial<Omit<IComment, 'entity' | 'entityId' | 'creatorId' | 'creator'>> {}

export interface ICommentFindInput extends Pick<IComment, 'entity' | 'entityId'> {}
8 changes: 7 additions & 1 deletion packages/contracts/src/mention.model.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import { IBasePerEntityType, IBasePerTenantAndOrganizationEntityModel, ID } from './base-entity.model';
import { BaseEntityEnum, IBasePerEntityType, IBasePerTenantAndOrganizationEntityModel, ID } from './base-entity.model';
import { IUser } from './user.model';

export interface IMention extends IBasePerTenantAndOrganizationEntityModel, IBasePerEntityType {
mentionedUserId: ID;
mentionedUser?: IUser;
mentionById: ID;
mentionBy?: IUser;
parentEntityId?: ID; // E.g : If the mention is in a comment, we need this for subscription and notifications purpose (It could be the task ID concerned by comment, then the user will be subscribed to that task instead of to a comment itself)
parentEntityType?: BaseEntityEnum;
}

export interface IMentionCreateInput extends Omit<IMention, 'mentionBy'> {}

export interface IMentionedUserIds {
mentionIds?: ID[];
}
3 changes: 2 additions & 1 deletion packages/contracts/src/subscription.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ export enum SubscriptionTypeEnum {
MANUAL = 'manual',
MENTION = 'mention',
ASSIGNMENT = 'assignment',
COMMENT = 'comment'
COMMENT = 'comment',
CREATED_ENTITY = 'created-entity'
}

export interface ISubscriptionCreateInput
Expand Down
3 changes: 2 additions & 1 deletion packages/contracts/src/task.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { ITaskPriority, TaskPriorityEnum } from './task-priority.model';
import { ITaskSize, TaskSizeEnum } from './task-size.model';
import { IOrganizationProjectModule } from './organization-project-module.model';
import { TaskTypeEnum } from './issue-type.model';
import { IMentionedUserIds } from './mention.model';

export interface ITask
extends IBasePerTenantAndOrganizationEntityModel,
Expand Down Expand Up @@ -75,7 +76,7 @@ export enum TaskParticipantEnum {
TEAMS = 'teams'
}

export type ITaskCreateInput = ITask;
export interface ITaskCreateInput extends ITask, IMentionedUserIds {}

export interface ITaskUpdateInput extends ITaskCreateInput {
id?: string;
Expand Down
51 changes: 42 additions & 9 deletions packages/core/src/comment/comment.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/comm
import { UpdateResult } from 'typeorm';
import { TenantAwareCrudService } from './../core/crud';
import { RequestContext } from '../core/context';
import { IComment, ICommentCreateInput, ICommentUpdateInput, ID } from '@gauzy/contracts';
import { BaseEntityEnum, IComment, ICommentCreateInput, ICommentUpdateInput, ID } from '@gauzy/contracts';
import { UserService } from '../user/user.service';
import { MentionService } from '../mention/mention.service';
import { Comment } from './comment.entity';
import { TypeOrmCommentRepository } from './repository/type-orm.comment.repository';
import { MikroOrmCommentRepository } from './repository/mikro-orm-comment.repository';
Expand All @@ -13,7 +14,8 @@ export class CommentService extends TenantAwareCrudService<Comment> {
constructor(
readonly typeOrmCommentRepository: TypeOrmCommentRepository,
readonly mikroOrmCommentRepository: MikroOrmCommentRepository,
private readonly userService: UserService
private readonly userService: UserService,
private readonly mentionService: MentionService
) {
super(typeOrmCommentRepository, mikroOrmCommentRepository);
}
Expand All @@ -28,20 +30,37 @@ export class CommentService extends TenantAwareCrudService<Comment> {
try {
const userId = RequestContext.currentUserId();
const tenantId = RequestContext.currentTenantId();
const { ...entity } = input;
const { mentionIds = [], ...data } = input;

// Employee existence validation
const user = await this.userService.findOneByIdString(userId);
if (!user) {
throw new NotFoundException('User not found');
}

// return created comment
return await super.create({
...entity,
// create comment
const comment = await super.create({
...data,
tenantId,
creatorId: user.id
});

// Apply mentions if needed
await Promise.all(
mentionIds.map((mentionedUserId) =>
this.mentionService.publishMention({
entity: BaseEntityEnum.Comment,
entityId: comment.id,
mentionedUserId,
mentionById: user.id,
parentEntityId: comment.entityId,
parentEntityType: comment.entity
})
)
);

// Return created Comment
return comment;
} catch (error) {
console.log(error); // Debug Logging
throw new BadRequestException('Comment post failed', error);
Expand All @@ -50,12 +69,15 @@ export class CommentService extends TenantAwareCrudService<Comment> {

/**
* @description Update comment - Note
* @param {ICommentUpdateInput} input - Data to update comment
* @returns A promise that resolves to the updated comment OR Update result
* @param id - The comment ID to be updated.
* @param {ICommentUpdateInput} input - Data to update comment.
* @returns A promise that resolves to the updated comment OR Update result.
* @memberof CommentService
*/
async update(id: ID, input: ICommentUpdateInput): Promise<IComment | UpdateResult> {
try {
const { mentionIds = [] } = input;

const userId = RequestContext.currentUserId();
const comment = await this.findOneByOptions({
where: {
Expand All @@ -68,10 +90,21 @@ export class CommentService extends TenantAwareCrudService<Comment> {
throw new BadRequestException('Comment not found');
}

return await super.create({
const updatedComment = await super.create({
...input,
id
});

// Synchronize mentions
await this.mentionService.updateEntityMentions(
BaseEntityEnum.Comment,
id,
mentionIds,
updatedComment.entityId,
updatedComment.entity
);

return updatedComment;
} catch (error) {
console.log(error); // Debug Logging
throw new BadRequestException('Comment update failed', error);
Expand Down
8 changes: 6 additions & 2 deletions packages/core/src/comment/dto/create-comment.dto.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import { IntersectionType, OmitType } from '@nestjs/swagger';
import { TenantOrganizationBaseDTO } from './../../core/dto';
import { ICommentCreateInput } from '@gauzy/contracts';
import { TenantOrganizationBaseDTO } from './../../core/dto';
import { MentionedUserIdsDTO } from '../../mention/dto';
import { Comment } from '../comment.entity';

/**
* Create Comment data validation request DTO
*/
export class CreateCommentDTO
extends IntersectionType(TenantOrganizationBaseDTO, OmitType(Comment, ['creatorId', 'creator']))
extends IntersectionType(
TenantOrganizationBaseDTO,
IntersectionType(OmitType(Comment, ['creatorId', 'creator']), MentionedUserIdsDTO)
)
implements ICommentCreateInput {}
2 changes: 1 addition & 1 deletion packages/core/src/comment/dto/update-comment.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { ICommentUpdateInput } from '@gauzy/contracts';
import { CreateCommentDTO } from './create-comment.dto';

/**
* Create Comment data validation request DTO
* Update Comment data validation request DTO
*/
export class UpdateCommentDTO
extends PartialType(OmitType(CreateCommentDTO, ['entity', 'entityId']))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import { Logger } from '@nestjs/common';
import { MigrationInterface, QueryRunner } from 'typeorm';
import { yellow } from 'chalk';
import { DatabaseTypeEnum } from '@gauzy/config';

export class AtlerMentionTableAddParentEntityFields1732775571004 implements MigrationInterface {
name = 'AtlerMentionTableAddParentEntityFields1732775571004';

/**
* Up Migration
*
* @param queryRunner
*/
public async up(queryRunner: QueryRunner): Promise<void> {
Logger.debug(yellow(this.name + ' start running!'), 'Migration');

switch (queryRunner.connection.options.type) {
case DatabaseTypeEnum.sqlite:
case DatabaseTypeEnum.betterSqlite3:
await this.sqliteUpQueryRunner(queryRunner);
break;
case DatabaseTypeEnum.postgres:
await this.postgresUpQueryRunner(queryRunner);
break;
case DatabaseTypeEnum.mysql:
await this.mysqlUpQueryRunner(queryRunner);
break;
default:
throw Error(`Unsupported database: ${queryRunner.connection.options.type}`);
}
}

/**
* Down Migration
*
* @param queryRunner
*/
public async down(queryRunner: QueryRunner): Promise<void> {
switch (queryRunner.connection.options.type) {
case DatabaseTypeEnum.sqlite:
case DatabaseTypeEnum.betterSqlite3:
await this.sqliteDownQueryRunner(queryRunner);
break;
case DatabaseTypeEnum.postgres:
await this.postgresDownQueryRunner(queryRunner);
break;
case DatabaseTypeEnum.mysql:
await this.mysqlDownQueryRunner(queryRunner);
break;
default:
throw Error(`Unsupported database: ${queryRunner.connection.options.type}`);
}
}

/**
* PostgresDB Up Migration
*
* @param queryRunner
*/
public async postgresUpQueryRunner(queryRunner: QueryRunner): Promise<any> {
await queryRunner.query(`ALTER TABLE "mention" ADD "parentEntityId" character varying NOT NULL`);
await queryRunner.query(`ALTER TABLE "mention" ADD "parentEntityType" character varying NOT NULL`);
await queryRunner.query(`CREATE INDEX "IDX_5b95805861f9de5cf7760a964a" ON "mention" ("parentEntityId") `);
await queryRunner.query(`CREATE INDEX "IDX_4f9397b277ec0791c5f9e2fd62" ON "mention" ("parentEntityType") `);
}

/**
* PostgresDB Down Migration
*
* @param queryRunner
*/
public async postgresDownQueryRunner(queryRunner: QueryRunner): Promise<any> {
await queryRunner.query(`DROP INDEX "public"."IDX_4f9397b277ec0791c5f9e2fd62"`);
await queryRunner.query(`DROP INDEX "public"."IDX_5b95805861f9de5cf7760a964a"`);
await queryRunner.query(`ALTER TABLE "mention" DROP COLUMN "parentEntityType"`);
await queryRunner.query(`ALTER TABLE "mention" DROP COLUMN "parentEntityId"`);
}

/**
* SqliteDB and BetterSQlite3DB Up Migration
*
* @param queryRunner
*/
public async sqliteUpQueryRunner(queryRunner: QueryRunner): Promise<any> {
await queryRunner.query(`DROP INDEX "IDX_34b0087a30379c86b470a4298c"`);
await queryRunner.query(`DROP INDEX "IDX_16a2deee0d7ea361950eed1b94"`);
await queryRunner.query(`DROP INDEX "IDX_3d6a8e3430779c21f04513cc5a"`);
await queryRunner.query(`DROP INDEX "IDX_d01675da9ddf57bef5692fca8b"`);
await queryRunner.query(`DROP INDEX "IDX_4f018d32b6d2e2c907833d0db1"`);
await queryRunner.query(`DROP INDEX "IDX_580d84e23219b07f520131f927"`);
await queryRunner.query(`DROP INDEX "IDX_9597d3f3afbf40e6ffd1b0ebc9"`);
await queryRunner.query(`DROP INDEX "IDX_2c71b2f53b9162a94e1f02e40b"`);
await queryRunner.query(
`CREATE TABLE "temporary_mention" ("deletedAt" datetime, "id" varchar PRIMARY KEY NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "isActive" boolean DEFAULT (1), "isArchived" boolean DEFAULT (0), "archivedAt" datetime, "tenantId" varchar, "organizationId" varchar, "entityId" varchar NOT NULL, "entity" varchar NOT NULL, "mentionedUserId" varchar NOT NULL, "mentionById" varchar NOT NULL, "parentEntityId" varchar NOT NULL, "parentEntityType" varchar NOT NULL, CONSTRAINT "FK_34b0087a30379c86b470a4298ca" FOREIGN KEY ("mentionById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_16a2deee0d7ea361950eed1b944" FOREIGN KEY ("mentionedUserId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_4f018d32b6d2e2c907833d0db11" FOREIGN KEY ("organizationId") REFERENCES "organization" ("id") ON DELETE CASCADE ON UPDATE CASCADE, CONSTRAINT "FK_580d84e23219b07f520131f9271" FOREIGN KEY ("tenantId") REFERENCES "tenant" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
);
await queryRunner.query(
`INSERT INTO "temporary_mention"("deletedAt", "id", "createdAt", "updatedAt", "isActive", "isArchived", "archivedAt", "tenantId", "organizationId", "entityId", "entity", "mentionedUserId", "mentionById") SELECT "deletedAt", "id", "createdAt", "updatedAt", "isActive", "isArchived", "archivedAt", "tenantId", "organizationId", "entityId", "entity", "mentionedUserId", "mentionById" FROM "mention"`
);
await queryRunner.query(`DROP TABLE "mention"`);
await queryRunner.query(`ALTER TABLE "temporary_mention" RENAME TO "mention"`);
await queryRunner.query(`CREATE INDEX "IDX_34b0087a30379c86b470a4298c" ON "mention" ("mentionById") `);
await queryRunner.query(`CREATE INDEX "IDX_16a2deee0d7ea361950eed1b94" ON "mention" ("mentionedUserId") `);
await queryRunner.query(`CREATE INDEX "IDX_3d6a8e3430779c21f04513cc5a" ON "mention" ("entity") `);
await queryRunner.query(`CREATE INDEX "IDX_d01675da9ddf57bef5692fca8b" ON "mention" ("entityId") `);
await queryRunner.query(`CREATE INDEX "IDX_4f018d32b6d2e2c907833d0db1" ON "mention" ("organizationId") `);
await queryRunner.query(`CREATE INDEX "IDX_580d84e23219b07f520131f927" ON "mention" ("tenantId") `);
await queryRunner.query(`CREATE INDEX "IDX_9597d3f3afbf40e6ffd1b0ebc9" ON "mention" ("isArchived") `);
await queryRunner.query(`CREATE INDEX "IDX_2c71b2f53b9162a94e1f02e40b" ON "mention" ("isActive") `);
await queryRunner.query(`CREATE INDEX "IDX_5b95805861f9de5cf7760a964a" ON "mention" ("parentEntityId") `);
await queryRunner.query(`CREATE INDEX "IDX_4f9397b277ec0791c5f9e2fd62" ON "mention" ("parentEntityType") `);
}

/**
* SqliteDB and BetterSQlite3DB Down Migration
*
* @param queryRunner
*/
public async sqliteDownQueryRunner(queryRunner: QueryRunner): Promise<any> {
await queryRunner.query(`DROP INDEX "IDX_4f9397b277ec0791c5f9e2fd62"`);
await queryRunner.query(`DROP INDEX "IDX_5b95805861f9de5cf7760a964a"`);
await queryRunner.query(`DROP INDEX "IDX_2c71b2f53b9162a94e1f02e40b"`);
await queryRunner.query(`DROP INDEX "IDX_9597d3f3afbf40e6ffd1b0ebc9"`);
await queryRunner.query(`DROP INDEX "IDX_580d84e23219b07f520131f927"`);
await queryRunner.query(`DROP INDEX "IDX_4f018d32b6d2e2c907833d0db1"`);
await queryRunner.query(`DROP INDEX "IDX_d01675da9ddf57bef5692fca8b"`);
await queryRunner.query(`DROP INDEX "IDX_3d6a8e3430779c21f04513cc5a"`);
await queryRunner.query(`DROP INDEX "IDX_16a2deee0d7ea361950eed1b94"`);
await queryRunner.query(`DROP INDEX "IDX_34b0087a30379c86b470a4298c"`);
await queryRunner.query(`ALTER TABLE "mention" RENAME TO "temporary_mention"`);
await queryRunner.query(
`CREATE TABLE "mention" ("deletedAt" datetime, "id" varchar PRIMARY KEY NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "isActive" boolean DEFAULT (1), "isArchived" boolean DEFAULT (0), "archivedAt" datetime, "tenantId" varchar, "organizationId" varchar, "entityId" varchar NOT NULL, "entity" varchar NOT NULL, "mentionedUserId" varchar NOT NULL, "mentionById" varchar NOT NULL, CONSTRAINT "FK_34b0087a30379c86b470a4298ca" FOREIGN KEY ("mentionById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_16a2deee0d7ea361950eed1b944" FOREIGN KEY ("mentionedUserId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_4f018d32b6d2e2c907833d0db11" FOREIGN KEY ("organizationId") REFERENCES "organization" ("id") ON DELETE CASCADE ON UPDATE CASCADE, CONSTRAINT "FK_580d84e23219b07f520131f9271" FOREIGN KEY ("tenantId") REFERENCES "tenant" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
);
await queryRunner.query(
`INSERT INTO "mention"("deletedAt", "id", "createdAt", "updatedAt", "isActive", "isArchived", "archivedAt", "tenantId", "organizationId", "entityId", "entity", "mentionedUserId", "mentionById") SELECT "deletedAt", "id", "createdAt", "updatedAt", "isActive", "isArchived", "archivedAt", "tenantId", "organizationId", "entityId", "entity", "mentionedUserId", "mentionById" FROM "temporary_mention"`
);
await queryRunner.query(`DROP TABLE "temporary_mention"`);
await queryRunner.query(`CREATE INDEX "IDX_2c71b2f53b9162a94e1f02e40b" ON "mention" ("isActive") `);
await queryRunner.query(`CREATE INDEX "IDX_9597d3f3afbf40e6ffd1b0ebc9" ON "mention" ("isArchived") `);
await queryRunner.query(`CREATE INDEX "IDX_580d84e23219b07f520131f927" ON "mention" ("tenantId") `);
await queryRunner.query(`CREATE INDEX "IDX_4f018d32b6d2e2c907833d0db1" ON "mention" ("organizationId") `);
await queryRunner.query(`CREATE INDEX "IDX_d01675da9ddf57bef5692fca8b" ON "mention" ("entityId") `);
await queryRunner.query(`CREATE INDEX "IDX_3d6a8e3430779c21f04513cc5a" ON "mention" ("entity") `);
await queryRunner.query(`CREATE INDEX "IDX_16a2deee0d7ea361950eed1b94" ON "mention" ("mentionedUserId") `);
await queryRunner.query(`CREATE INDEX "IDX_34b0087a30379c86b470a4298c" ON "mention" ("mentionById") `);
}

/**
* MySQL Up Migration
*
* @param queryRunner
*/
public async mysqlUpQueryRunner(queryRunner: QueryRunner): Promise<any> {
await queryRunner.query(`ALTER TABLE \`mention\` ADD \`parentEntityId\` varchar(255) NOT NULL`);
await queryRunner.query(`ALTER TABLE \`mention\` ADD \`parentEntityType\` varchar(255) NOT NULL`);
await queryRunner.query(`CREATE INDEX \`IDX_5b95805861f9de5cf7760a964a\` ON \`mention\` (\`parentEntityId\`)`);
await queryRunner.query(
`CREATE INDEX \`IDX_4f9397b277ec0791c5f9e2fd62\` ON \`mention\` (\`parentEntityType\`)`
);
}

/**
* MySQL Down Migration
*
* @param queryRunner
*/
public async mysqlDownQueryRunner(queryRunner: QueryRunner): Promise<any> {
await queryRunner.query(`DROP INDEX \`IDX_4f9397b277ec0791c5f9e2fd62\` ON \`mention\``);
await queryRunner.query(`DROP INDEX \`IDX_5b95805861f9de5cf7760a964a\` ON \`mention\``);
await queryRunner.query(`ALTER TABLE \`mention\` DROP COLUMN \`parentEntityType\``);
await queryRunner.query(`ALTER TABLE \`mention\` DROP COLUMN \`parentEntityId\``);
}
}
1 change: 1 addition & 0 deletions packages/core/src/mention/dto/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './mentioned-user-ids.dto';
10 changes: 10 additions & 0 deletions packages/core/src/mention/dto/mentioned-user-ids.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsArray, IsOptional } from 'class-validator';
import { ID, IMentionedUserIds } from '@gauzy/contracts';

export class MentionedUserIdsDTO implements IMentionedUserIds {
@ApiPropertyOptional({ type: () => Array })
@IsOptional()
@IsArray()
mentionIds?: ID[];
}
Loading