feat(backend): add the ability to disable tracking request history (#4594)

HSB-505
This commit is contained in:
Mir Arif Hasan 2024-12-16 14:33:09 +06:00 committed by jamesgeorge007
parent 12f4849061
commit e30a6c9db5
18 changed files with 301 additions and 20 deletions

View file

@ -12,6 +12,7 @@ import { TeamRequestModule } from '../team-request/team-request.module';
import { InfraResolver } from './infra.resolver';
import { ShortcodeModule } from 'src/shortcode/shortcode.module';
import { InfraConfigModule } from 'src/infra-config/infra-config.module';
import { UserHistoryModule } from 'src/user-history/user-history.module';
@Module({
imports: [
@ -25,6 +26,7 @@ import { InfraConfigModule } from 'src/infra-config/infra-config.module';
TeamRequestModule,
ShortcodeModule,
InfraConfigModule,
UserHistoryModule,
],
providers: [InfraResolver, AdminResolver, AdminService],
exports: [AdminService],

View file

@ -117,9 +117,8 @@ export class AdminResolver {
})
userUIDs: string[],
): Promise<UserDeletionResult[]> {
const deletionResults = await this.adminService.removeUserAccounts(
userUIDs,
);
const deletionResults =
await this.adminService.removeUserAccounts(userUIDs);
if (E.isLeft(deletionResults)) throwErr(deletionResults.left);
return deletionResults.right;
}
@ -360,6 +359,16 @@ export class AdminResolver {
return true;
}
@Mutation(() => Boolean, {
description: 'Revoke all User History',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async revokeAllUserHistoryByAdmin(): Promise<boolean> {
const isDeleted = await this.adminService.deleteAllUserHistory();
if (E.isLeft(isDeleted)) throwErr(isDeleted.left);
return true;
}
/* Subscriptions */
@Subscription(() => InvitedUser, {

View file

@ -22,6 +22,7 @@ import { ShortcodeService } from 'src/shortcode/shortcode.service';
import { ConfigService } from '@nestjs/config';
import { OffsetPaginationArgs } from 'src/types/input-types.args';
import * as E from 'fp-ts/Either';
import { UserHistoryService } from 'src/user-history/user-history.service';
const mockPrisma = mockDeep<PrismaService>();
const mockPubSub = mockDeep<PubSubService>();
@ -34,6 +35,7 @@ const mockTeamCollectionService = mockDeep<TeamCollectionService>();
const mockMailerService = mockDeep<MailerService>();
const mockShortcodeService = mockDeep<ShortcodeService>();
const mockConfigService = mockDeep<ConfigService>();
const mockUserHistoryService = mockDeep<UserHistoryService>();
const adminService = new AdminService(
mockUserService,
@ -47,6 +49,7 @@ const adminService = new AdminService(
mockMailerService,
mockShortcodeService,
mockConfigService,
mockUserHistoryService,
);
const invitedUsers: InvitedUsers[] = [
@ -292,4 +295,15 @@ describe('AdminService', () => {
expect(result).toEqual(10);
});
});
describe('deleteAllUserHistory', () => {
test('should resolve right and delete all user history', async () => {
mockUserHistoryService.deleteAllHistories.mockResolvedValueOnce(
E.right(true),
);
const result = await adminService.deleteAllUserHistory();
expect(result).toEqualRight(true);
});
});
});

View file

@ -31,6 +31,7 @@ import { ShortcodeService } from 'src/shortcode/shortcode.service';
import { ConfigService } from '@nestjs/config';
import { OffsetPaginationArgs } from 'src/types/input-types.args';
import { UserDeletionResult } from 'src/user/user.model';
import { UserHistoryService } from 'src/user-history/user-history.service';
@Injectable()
export class AdminService {
@ -46,6 +47,7 @@ export class AdminService {
private readonly mailerService: MailerService,
private readonly shortcodeService: ShortcodeService,
private readonly configService: ConfigService,
private readonly userHistoryService: UserHistoryService,
) {}
/**
@ -650,4 +652,15 @@ export class AdminService {
if (E.isLeft(result)) return E.left(result.left);
return E.right(result.right);
}
/**
* Delete all user history
* @returns Boolean on successful deletion
*/
async deleteAllUserHistory() {
const result = await this.userHistoryService.deleteAllHistories();
if (E.isLeft(result)) return E.left(result.left);
return E.right(result.right);
}
}

View file

@ -214,9 +214,8 @@ export class InfraResolver {
})
teamID: string,
) {
const invitations = await this.adminService.pendingInvitationCountInTeam(
teamID,
);
const invitations =
await this.adminService.pendingInvitationCountInTeam(teamID);
return invitations;
}
@ -352,9 +351,8 @@ export class InfraResolver {
})
providerInfo: EnableAndDisableSSOArgs[],
) {
const isUpdated = await this.infraConfigService.enableAndDisableSSO(
providerInfo,
);
const isUpdated =
await this.infraConfigService.enableAndDisableSSO(providerInfo);
if (E.isLeft(isUpdated)) throwErr(isUpdated.left);
return true;
@ -372,7 +370,26 @@ export class InfraResolver {
})
status: ServiceStatus,
) {
const isUpdated = await this.infraConfigService.enableAndDisableSMTP(
const isUpdated =
await this.infraConfigService.enableAndDisableSMTP(status);
if (E.isLeft(isUpdated)) throwErr(isUpdated.left);
return true;
}
@Mutation(() => Boolean, {
description: 'Enable or Disable User History Storing in DB',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async toggleUserHistoryStore(
@Args({
name: 'status',
type: () => ServiceStatus,
description: 'Toggle User History Store',
})
status: ServiceStatus,
) {
const isUpdated = await this.infraConfigService.toggleServiceStatus(
InfraConfigEnum.USER_HISTORY_STORE_ENABLED,
status,
);
if (E.isLeft(isUpdated)) throwErr(isUpdated.left);

View file

@ -492,7 +492,19 @@ export const USER_ENVIRONMENT_INVALID_ENVIRONMENT_NAME =
*/
export const USER_HISTORY_NOT_FOUND = 'user_history/history_not_found' as const;
/*
/**
* User history deletion failed
* (UserHistoryService)
*/
export const USER_HISTORY_DELETION_FAILED =
'user_history/deletion_failed' as const;
/**
* User history feature flag is disabled
* (UserHistoryService)
*/
export const USER_HISTORY_FEATURE_FLAG_DISABLED =
'user_history/feature_flag_disabled';
/**
* Invalid Request Type in History

View file

@ -262,6 +262,12 @@ export async function getDefaultInfraConfigs(): Promise<DefaultInfraConfig[]> {
lastSyncedEnvFileValue: null,
isEncrypted: false,
},
{
name: InfraConfigEnum.USER_HISTORY_STORE_ENABLED,
value: 'true',
lastSyncedEnvFileValue: null,
isEncrypted: false,
},
];
return infraConfigDefaultObjs;

View file

@ -3,9 +3,10 @@ import { InfraConfigService } from './infra-config.service';
import { PrismaModule } from 'src/prisma/prisma.module';
import { SiteController } from './infra-config.controller';
import { InfraConfigResolver } from './infra-config.resolver';
import { PubSubModule } from 'src/pubsub/pubsub.module';
@Module({
imports: [PrismaModule],
imports: [PrismaModule, PubSubModule],
providers: [InfraConfigResolver, InfraConfigService],
exports: [InfraConfigService],
controllers: [SiteController],

View file

@ -1,14 +1,24 @@
import { UseGuards } from '@nestjs/common';
import { Query, Resolver } from '@nestjs/graphql';
import { Args, Query, Resolver, Subscription } from '@nestjs/graphql';
import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard';
import { InfraConfig } from './infra-config.model';
import { InfraConfigService } from './infra-config.service';
import { GqlAuthGuard } from 'src/guards/gql-auth.guard';
import { SkipThrottle } from '@nestjs/throttler';
import { PubSubService } from 'src/pubsub/pubsub.service';
import { InfraConfigEnum } from 'src/types/InfraConfig';
import * as E from 'fp-ts/Either';
import { throwErr } from 'src/utils';
@UseGuards(GqlThrottlerGuard)
@Resolver(() => InfraConfig)
export class InfraConfigResolver {
constructor(private infraConfigService: InfraConfigService) {}
constructor(
private infraConfigService: InfraConfigService,
private pubsub: PubSubService,
) {}
/* Query */
@Query(() => Boolean, {
description: 'Check if the SMTP is enabled or not',
@ -17,4 +27,33 @@ export class InfraConfigResolver {
isSMTPEnabled() {
return this.infraConfigService.isSMTPEnabled();
}
@Query(() => InfraConfig, {
description: 'Check if user history is enabled or not',
})
@UseGuards(GqlAuthGuard)
async isUserHistoryEnabled() {
const isEnabled = await this.infraConfigService.isUserHistoryEnabled();
if (E.isLeft(isEnabled)) throwErr(isEnabled.left);
return isEnabled.right;
}
/* Subscriptions */
@Subscription(() => String, {
description: 'Subscription for infra config update',
resolve: (value) => value,
})
@SkipThrottle()
@UseGuards(GqlAuthGuard)
infraConfigUpdate(
@Args({
name: 'configName',
description: 'Infra config key',
type: () => InfraConfigEnum,
})
configName: string,
) {
return this.pubsub.asyncIterator(`infra_config/${configName}/updated`);
}
}

View file

@ -11,15 +11,20 @@ import { ConfigService } from '@nestjs/config';
import * as helper from './helper';
import { InfraConfig as dbInfraConfig } from '@prisma/client';
import { InfraConfig } from './infra-config.model';
import { PubSubService } from 'src/pubsub/pubsub.service';
import { ServiceStatus } from './helper';
import * as E from 'fp-ts/Either';
const mockPrisma = mockDeep<PrismaService>();
const mockConfigService = mockDeep<ConfigService>();
const mockPubsub = mockDeep<PubSubService>();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const infraConfigService = new InfraConfigService(
mockPrisma,
mockConfigService,
mockPubsub,
);
const INITIALIZED_DATE_CONST = new Date();
@ -243,4 +248,59 @@ describe('InfraConfigService', () => {
);
});
});
describe('toggleServiceStatus', () => {
it('should toggle the service status', async () => {
const configName = infraConfigs[0].name;
const configStatus = ServiceStatus.DISABLE;
jest
.spyOn(infraConfigService, 'update')
.mockResolvedValueOnce(
E.right({ name: configName, value: configStatus }),
);
expect(
await infraConfigService.toggleServiceStatus(configName, configStatus),
).toEqualRight(true);
});
it('should publish the updated config value', async () => {
const configName = infraConfigs[0].name;
const configStatus = ServiceStatus.DISABLE;
jest
.spyOn(infraConfigService, 'update')
.mockResolvedValueOnce(
E.right({ name: configName, value: configStatus }),
);
await infraConfigService.toggleServiceStatus(configName, configStatus);
expect(mockPubsub.publish).toHaveBeenCalledTimes(1);
expect(mockPubsub.publish).toHaveBeenCalledWith(
'infra_config/GOOGLE_CLIENT_ID/updated',
configStatus,
);
});
});
describe('isUserHistoryEnabled', () => {
it('should return true if the user history is enabled', async () => {
const response = {
name: InfraConfigEnum.USER_HISTORY_STORE_ENABLED,
value: ServiceStatus.ENABLE,
};
jest.spyOn(infraConfigService, 'get').mockResolvedValueOnce(
E.right({
name: InfraConfigEnum.USER_HISTORY_STORE_ENABLED,
value: ServiceStatus.ENABLE,
}),
);
expect(await infraConfigService.isUserHistoryEnabled()).toEqualRight(
response,
);
});
});
});

View file

@ -33,12 +33,14 @@ import {
} from './helper';
import { EnableAndDisableSSOArgs, InfraConfigArgs } from './input-args';
import { AuthProvider } from 'src/auth/helper';
import { PubSubService } from 'src/pubsub/pubsub.service';
@Injectable()
export class InfraConfigService implements OnModuleInit {
constructor(
private readonly prisma: PrismaService,
private readonly configService: ConfigService,
private readonly pubsub: PubSubService,
) {}
// Following fields are not updatable by `infraConfigs` Mutation. Use dedicated mutations for these fields instead.
@ -48,6 +50,7 @@ export class InfraConfigService implements OnModuleInit {
InfraConfigEnum.ANALYTICS_USER_ID,
InfraConfigEnum.IS_FIRST_TIME_INFRA_SETUP,
InfraConfigEnum.MAILER_SMTP_ENABLE,
InfraConfigEnum.USER_HISTORY_STORE_ENABLED,
];
// Following fields can not be fetched by `infraConfigs` Query. Use dedicated queries for these fields instead.
EXCLUDE_FROM_FETCH_CONFIGS = [
@ -132,6 +135,17 @@ export class InfraConfigService implements OnModuleInit {
* @returns InfraConfig model
*/
cast(dbInfraConfig: DBInfraConfig) {
switch (dbInfraConfig.name) {
case InfraConfigEnum.USER_HISTORY_STORE_ENABLED:
dbInfraConfig.value =
dbInfraConfig.value === 'true'
? ServiceStatus.ENABLE
: ServiceStatus.DISABLE;
break;
default:
break;
}
const plainValue = dbInfraConfig.isEncrypted
? decrypt(dbInfraConfig.value)
: dbInfraConfig.value;
@ -342,6 +356,11 @@ export class InfraConfigService implements OnModuleInit {
);
if (E.isLeft(isUpdated)) return E.left(isUpdated.left);
this.pubsub.publish(
`infra_config/${configName}/updated`,
isUpdated.right.value,
);
return E.right(true);
}
@ -453,6 +472,19 @@ export class InfraConfigService implements OnModuleInit {
);
}
/**
* Check if user history is enabled or not
* @returns InfraConfig model
*/
async isUserHistoryEnabled() {
const infraConfig = await this.get(
InfraConfigEnum.USER_HISTORY_STORE_ENABLED,
);
if (E.isLeft(infraConfig)) return E.left(infraConfig.left);
return E.right(infraConfig.right);
}
/**
* Reset all the InfraConfigs to their default values (from .env)
*/

View file

@ -43,9 +43,10 @@ export type TopicDef = {
topic: `user_request/${string}/${'created' | 'updated' | 'deleted'}`
]: UserRequest;
[topic: `user_request/${string}/${'moved'}`]: UserRequestReorderData;
[
topic: `user_history/${string}/${'created' | 'updated' | 'deleted'}`
]: UserHistory;
[topic: `user_history/${string}/${'created' | 'updated' | 'deleted'}`]:
| UserHistory
| boolean;
[topic: `user_history/${string}/deleted_many`]: UserHistoryDeletedManyData;
[
topic: `user_coll/${string}/${'created' | 'updated' | 'moved'}`
]: UserCollection;
@ -63,7 +64,6 @@ export type TopicDef = {
[topic: `team_coll/${string}/${'coll_removed'}`]: string;
[topic: `team_coll/${string}/${'coll_moved'}`]: TeamCollection;
[topic: `team_coll/${string}/${'coll_order_updated'}`]: CollectionReorderData;
[topic: `user_history/${string}/deleted_many`]: UserHistoryDeletedManyData;
[
topic: `team_req/${string}/${'req_created' | 'req_updated' | 'req_moved'}`
]: TeamRequest;
@ -74,4 +74,5 @@ export type TopicDef = {
[
topic: `shortcode/${string}/${'created' | 'revoked' | 'updated'}`
]: Shortcode;
[topic: `infra_config/${string}/${'updated'}`]: string;
};

View file

@ -32,4 +32,6 @@ export enum InfraConfigEnum {
ALLOW_ANALYTICS_COLLECTION = 'ALLOW_ANALYTICS_COLLECTION',
ANALYTICS_USER_ID = 'ANALYTICS_USER_ID',
IS_FIRST_TIME_INFRA_SETUP = 'IS_FIRST_TIME_INFRA_SETUP',
USER_HISTORY_STORE_ENABLED = 'USER_HISTORY_STORE_ENABLED',
}

View file

@ -0,0 +1,21 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { USER_HISTORY_FEATURE_FLAG_DISABLED } from 'src/errors';
import { InfraConfigService } from 'src/infra-config/infra-config.service';
import { throwErr } from 'src/utils';
import * as E from 'fp-ts/Either';
import { ServiceStatus } from 'src/infra-config/helper';
@Injectable()
export class UserHistoryFeatureFlagGuard implements CanActivate {
constructor(private readonly infraConfigService: InfraConfigService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const isEnabled = await this.infraConfigService.isUserHistoryEnabled();
if (E.isLeft(isEnabled)) throwErr(isEnabled.left);
if (isEnabled.right.value !== ServiceStatus.ENABLE)
throwErr(USER_HISTORY_FEATURE_FLAG_DISABLED);
return true;
}
}

View file

@ -5,9 +5,10 @@ import { UserModule } from '../user/user.module';
import { UserHistoryUserResolver } from './user.resolver';
import { UserHistoryResolver } from './user-history.resolver';
import { UserHistoryService } from './user-history.service';
import { InfraConfigModule } from 'src/infra-config/infra-config.module';
@Module({
imports: [PrismaModule, PubSubModule, UserModule],
imports: [PrismaModule, PubSubModule, UserModule, InfraConfigModule],
providers: [UserHistoryResolver, UserHistoryService, UserHistoryUserResolver],
exports: [UserHistoryService],
})

View file

@ -11,8 +11,9 @@ import { throwErr } from '../utils';
import * as E from 'fp-ts/Either';
import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard';
import { SkipThrottle } from '@nestjs/throttler';
import { UserHistoryFeatureFlagGuard } from './user-history-feature-flag.guard';
@UseGuards(GqlThrottlerGuard)
@UseGuards(GqlThrottlerGuard, UserHistoryFeatureFlagGuard)
@Resolver()
export class UserHistoryResolver {
constructor(
@ -156,4 +157,14 @@ export class UserHistoryResolver {
userHistoryDeletedMany(@GqlUser() user: User) {
return this.pubsub.asyncIterator(`user_history/${user.uid}/deleted_many`);
}
@Subscription(() => Boolean, {
description: 'Listen for All User History deleted',
resolve: (value) => value,
})
@SkipThrottle()
@UseGuards(GqlAuthGuard)
userHistoryAllDeleted() {
return this.pubsub.asyncIterator(`user_history/all/deleted`);
}
}

View file

@ -493,6 +493,30 @@ describe('UserHistoryService', () => {
);
});
});
describe('deleteAllHistories', () => {
test('Should resolve right and delete all user history', async () => {
mockPrisma.userHistory.deleteMany.mockResolvedValueOnce({
count: 2,
});
return expect(await userHistoryService.deleteAllHistories()).toEqualRight(
true,
);
});
test('Should publish all user history delete event', async () => {
mockPrisma.userHistory.deleteMany.mockResolvedValueOnce({
count: 2,
});
await userHistoryService.deleteAllHistories();
expect(mockPubSub.publish).toHaveBeenCalledTimes(1);
return expect(mockPubSub.publish).toHaveBeenCalledWith(
`user_history/all/deleted`,
true,
);
});
});
describe('validateReqType', () => {
test('Should resolve right when a valid REST ReqType is provided', async () => {
return expect(userHistoryService.validateReqType('REST')).toEqualRight(

View file

@ -6,6 +6,7 @@ import { ReqType } from 'src/types/RequestTypes';
import * as E from 'fp-ts/Either';
import * as O from 'fp-ts/Option';
import {
USER_HISTORY_DELETION_FAILED,
USER_HISTORY_INVALID_REQ_TYPE,
USER_HISTORY_NOT_FOUND,
} from '../errors';
@ -188,6 +189,21 @@ export class UserHistoryService {
return E.right(deletionInfo);
}
/**
* Delete all user history from DB
* @returns a boolean
*/
async deleteAllHistories() {
try {
await this.prisma.userHistory.deleteMany();
} catch (error) {
return E.left(USER_HISTORY_DELETION_FAILED);
}
this.pubsub.publish('user_history/all/deleted', true);
return E.right(true);
}
/**
* Fetch a user history based on history ID.
* @param id User History ID