Skip to content

Commit

Permalink
Merge pull request #8230 from ever-co/feat/activity-log-api
Browse files Browse the repository at this point in the history
[Feat] Activity Log Events / APIs
  • Loading branch information
rahul-rocket authored Sep 25, 2024
2 parents 6034264 + ae85f5a commit 39d9ab1
Show file tree
Hide file tree
Showing 23 changed files with 791 additions and 41 deletions.
32 changes: 24 additions & 8 deletions packages/contracts/src/activity-log.model.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { ActorTypeEnum, IBasePerTenantAndOrganizationEntityModel, ID } from './base-entity.model';
import { IUser } from './user.model';

// Define a type for JSON data
export type JsonData = Record<string, any> | string;

/**
* Interface representing an activity log entry.
*/
export interface IActivityLog extends IBasePerTenantAndOrganizationEntityModel {
entity: ActivityLogEntityEnum; // Entity / Table name concerned by activity log
entityId: ID; // The ID of the element we are interacting with (a task, an organization, an employee, ...)
Expand All @@ -14,19 +20,25 @@ export interface IActivityLog extends IBasePerTenantAndOrganizationEntityModel {
updatedEntities?: IActivityLogUpdatedValues[]; // Stores updated IDs, or other values for related entities. Eg : {members: ['member_1_ID', 'member_2_ID']},
creator?: IUser;
creatorId?: ID;
data?: Record<string, any>;
data?: JsonData;
}

export enum ActionTypeEnum {
CREATED = 'Created',
UPDATED = 'Updated',
DELETED = 'Deleted'
export interface IActivityLogUpdatedValues {
[x: string]: Record<string, any>;
}

export interface IActivityLogUpdatedValues {
[x: string]: any;
/**
* Enum for action types in the activity log.
*/
export enum ActionTypeEnum {
Created = 'Created',
Updated = 'Updated',
Deleted = 'Deleted'
}

/**
* Enum for entities that can be involved in activity logs.
*/
export enum ActivityLogEntityEnum {
Candidate = 'Candidate',
Contact = 'Contact',
Expand All @@ -45,5 +57,9 @@ export enum ActivityLogEntityEnum {
OrganizationSprint = 'OrganizationSprint',
Task = 'Task',
User = 'User'
// Add other entities as we can to use them for activity history
}

/**
* Input type for activity log creation, excluding `creatorId` and `creator`.
*/
export interface IActivityLogInput extends Omit<IActivityLog, 'creatorId' | 'creator'> {}
4 changes: 2 additions & 2 deletions packages/contracts/src/base-entity.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,6 @@ export interface IBasePerTenantAndOrganizationEntityMutationInput extends Partia

// Actor type defines if it's User or system performed some action
export enum ActorTypeEnum {
SYSTEM = 'SYSTEM',
USER = 'USER'
System = 0, // System performed the action
User = 1 // User performed the action
}
29 changes: 29 additions & 0 deletions packages/core/src/activity-log/activity-log.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { IActivityLog, IPagination } from '@gauzy/contracts';
import { Permissions } from '../shared/decorators';
import { PermissionGuard, TenantPermissionGuard } from '../shared/guards';
import { UseValidationPipe } from '../shared/pipes';
import { GetActivityLogsDTO } from './dto/get-activity-logs.dto';
import { ActivityLogService } from './activity-log.service';

@UseGuards(TenantPermissionGuard, PermissionGuard)
@Permissions()
@Controller('/activity-log')
export class ActivityLogController {
constructor(readonly _activityLogService: ActivityLogService) {}

/**
* Retrieves activity logs based on query parameters.
* Supports filtering, pagination, sorting, and ordering.
*
* @param query Query parameters for filtering, pagination, and ordering.
* @returns A list of activity logs.
*/
@Get('/')
@UseValidationPipe()
async getActivityLogs(
@Query() query: GetActivityLogsDTO
): Promise<IPagination<IActivityLog>> {
return await this._activityLogService.findActivityLogs(query);
}
}
23 changes: 9 additions & 14 deletions packages/core/src/activity-log/activity-log.entity.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { EntityRepositoryType } from '@mikro-orm/core';
import { JoinColumn, RelationId } from 'typeorm';
import { IsArray, IsEnum, IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator';
import { isMySQL, isPostgres } from '@gauzy/config';
import {
ActivityLogEntityEnum,
ActionTypeEnum,
ActorTypeEnum,
IActivityLog,
IActivityLogUpdatedValues,
ID,
IUser
IUser,
JsonData
} from '@gauzy/contracts';
import { TenantOrganizationBaseEntity, User } from '../core/entities/internal';
import { ColumnIndex, MultiORMColumn, MultiORMEntity, MultiORMManyToOne } from '../core/decorators/entity';
import { MikroOrmActivityLogRepository } from './repository/mikro-orm-activity-log.repository';
import { JoinColumn, RelationId } from 'typeorm';

@MultiORMEntity('activity_log', { mikroOrmRepository: () => MikroOrmActivityLogRepository })
export class ActivityLog extends TenantOrganizationBaseEntity implements IActivityLog {
Expand All @@ -32,7 +32,7 @@ export class ActivityLog extends TenantOrganizationBaseEntity implements IActivi
@IsUUID()
@ColumnIndex()
@MultiORMColumn()
entityId: string;
entityId: ID;

@ApiProperty({ type: () => String, enum: ActionTypeEnum })
@IsNotEmpty()
Expand Down Expand Up @@ -64,31 +64,31 @@ export class ActivityLog extends TenantOrganizationBaseEntity implements IActivi
@IsOptional()
@IsArray()
@MultiORMColumn({ type: isPostgres() ? 'jsonb' : isMySQL() ? 'json' : 'text', nullable: true })
previousValues?: IActivityLogUpdatedValues[];
previousValues?: Record<string, any>[];

@ApiPropertyOptional({ type: () => Array })
@IsOptional()
@IsArray()
@MultiORMColumn({ type: isPostgres() ? 'jsonb' : isMySQL() ? 'json' : 'text', nullable: true })
updatedValues?: IActivityLogUpdatedValues[];
updatedValues?: Record<string, any>[];

@ApiPropertyOptional({ type: () => Array })
@IsOptional()
@IsArray()
@MultiORMColumn({ type: isPostgres() ? 'jsonb' : isMySQL() ? 'json' : 'text', nullable: true })
previousEntities?: IActivityLogUpdatedValues[];
previousEntities?: Record<string, any>[];

@ApiPropertyOptional({ type: () => Array })
@IsOptional()
@IsArray()
@MultiORMColumn({ type: isPostgres() ? 'jsonb' : isMySQL() ? 'json' : 'text', nullable: true })
updatedEntities?: IActivityLogUpdatedValues[];
updatedEntities?: Record<string, any>[];

@ApiPropertyOptional({ type: () => Object })
@IsOptional()
@IsArray()
@MultiORMColumn({ type: isPostgres() ? 'jsonb' : isMySQL() ? 'json' : 'text', nullable: true })
data?: Record<string, any>;
data?: JsonData;

/*
|--------------------------------------------------------------------------
Expand All @@ -99,8 +99,6 @@ export class ActivityLog extends TenantOrganizationBaseEntity implements IActivi
/**
* User performed action
*/
@ApiPropertyOptional({ type: () => Object })
@IsOptional()
@MultiORMManyToOne(() => User, {
/** Indicates if relation column value can be nullable or not. */
nullable: true,
Expand All @@ -111,9 +109,6 @@ export class ActivityLog extends TenantOrganizationBaseEntity implements IActivi
@JoinColumn()
creator?: IUser;

@ApiPropertyOptional({ type: () => String })
@IsOptional()
@IsUUID()
@RelationId((it: ActivityLog) => it.creator)
@ColumnIndex()
@MultiORMColumn({ nullable: true, relationId: true })
Expand Down
37 changes: 37 additions & 0 deletions packages/core/src/activity-log/activity-log.helper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { ActionTypeEnum, ActivityLogEntityEnum } from "@gauzy/contracts";

const ActivityTemplates = {
[ActionTypeEnum.Created]: `{action} a new {entity} called "{entityName}"`,
[ActionTypeEnum.Updated]: `{action} {entity} "{entityName}"`,
[ActionTypeEnum.Deleted]: `{action} {entity} "{entityName}"`,
};

/**
* Generates an activity description based on the action type, entity, and entity name.
* @param action - The action performed (e.g., CREATED, UPDATED, DELETED).
* @param entity - The type of entity involved in the action (e.g., Project, User).
* @param entityName - The name of the specific entity instance.
* @returns A formatted description string.
*/
export function generateActivityLogDescription(
action: ActionTypeEnum,
entity: ActivityLogEntityEnum,
entityName: string
): string {
// Get the template corresponding to the action
const template = ActivityTemplates[action] || '{action} {entity} "{entityName}"';

// Replace placeholders in the template with actual values
return template.replace(/\{(\w+)\}/g, (_, key) => {
switch (key) {
case 'action':
return action;
case 'entity':
return entity;
case 'entityName':
return entityName;
default:
return '';
}
});
}
23 changes: 23 additions & 0 deletions packages/core/src/activity-log/activity-log.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { CqrsModule } from '@nestjs/cqrs';
import { Module } from '@nestjs/common';
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { TypeOrmModule } from '@nestjs/typeorm';
import { RolePermissionModule } from '../role-permission/role-permission.module';
import { ActivityLogController } from './activity-log.controller';
import { ActivityLog } from './activity-log.entity';
import { ActivityLogService } from './activity-log.service';
import { EventHandlers } from './events/handlers';
import { TypeOrmActivityLogRepository } from './repository/type-orm-activity-log.repository';

@Module({
imports: [
TypeOrmModule.forFeature([ActivityLog]),
MikroOrmModule.forFeature([ActivityLog]),
CqrsModule,
RolePermissionModule
],
controllers: [ActivityLogController],
providers: [ActivityLogService, TypeOrmActivityLogRepository, ...EventHandlers],
exports: [TypeOrmModule, MikroOrmModule, ActivityLogService, TypeOrmActivityLogRepository]
})
export class ActivityLogModule {}
97 changes: 97 additions & 0 deletions packages/core/src/activity-log/activity-log.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { FindManyOptions, FindOptionsOrder, FindOptionsWhere } from 'typeorm';
import { IActivityLog, IActivityLogInput, IPagination } from '@gauzy/contracts';
import { TenantAwareCrudService } from './../core/crud';
import { RequestContext } from '../core/context';
import { GetActivityLogsDTO, allowedOrderDirections, allowedOrderFields } from './dto/get-activity-logs.dto';
import { ActivityLog } from './activity-log.entity';
import { MikroOrmActivityLogRepository, TypeOrmActivityLogRepository } from './repository';

@Injectable()
export class ActivityLogService extends TenantAwareCrudService<ActivityLog> {
constructor(
readonly typeOrmActivityLogRepository: TypeOrmActivityLogRepository,
readonly mikroOrmActivityLogRepository: MikroOrmActivityLogRepository
) {
super(typeOrmActivityLogRepository, mikroOrmActivityLogRepository);
}

/**
* Finds and retrieves activity logs based on the given filter criteria.
*
* @param {GetActivityLogsDTO} filter - Filter criteria to find activity logs, including entity, entityId, action, actorType, isActive, isArchived, orderBy, and order.
* @returns {Promise<IPagination<IActivityLog>>} - A promise that resolves to a paginated list of activity logs.
*
* Example usage:
* ```
* const logs = await findActivityLogs({
* entity: 'User',
* action: 'CREATE',
* orderBy: 'updatedAt',
* order: 'ASC'
* });
* ```
*/
public async findActivityLogs(filter: GetActivityLogsDTO): Promise<IPagination<IActivityLog>> {
const {
entity,
entityId,
action,
actorType,
isActive = true,
isArchived = false,
orderBy = 'createdAt',
order = 'DESC',
relations = [],
skip,
take
} = filter;

// Build the 'where' condition using concise syntax
const where: FindOptionsWhere<ActivityLog> = {
...(entity && { entity }),
...(entityId && { entityId }),
...(action && { action }),
...(actorType && { actorType }),
isActive,
isArchived
};

// Fallback to default if invalid orderBy/order values are provided
const orderField = allowedOrderFields.includes(orderBy) ? orderBy : 'createdAt';
const orderDirection = allowedOrderDirections.includes(order.toUpperCase()) ? order.toUpperCase() : 'DESC';

// Define order option
const orderOption: FindOptionsOrder<ActivityLog> = { [orderField]: orderDirection };

// Define find options
const findOptions: FindManyOptions<ActivityLog> = {
where,
order: orderOption,
...(skip && { skip }),
...(take && { take }),
...(relations && { relations })
};

// Retrieve activity logs using the base class method
return await super.findAll(findOptions);
}

/**
* Creates a new activity log entry with the provided input, while associating it with the current user and tenant.
*
* @param input - The data required to create an activity log entry.
* @returns The created activity log entry.
* @throws BadRequestException when the log creation fails.
*/
async logActivity(input: IActivityLogInput): Promise<IActivityLog> {
try {
const creatorId = RequestContext.currentUserId(); // Retrieve the current user's ID from the request context
// Create the activity log entry using the provided input along with the tenantId and creatorId
return await super.create({ ...input, creatorId });
} catch (error) {
console.log('Error while creating activity log:', error);
throw new BadRequestException('Error while creating activity log', error);
}
}
}
Loading

0 comments on commit 39d9ab1

Please sign in to comment.