From a609c5bbe206e51596fbd7f72888b56ee3689adc Mon Sep 17 00:00:00 2001 From: Bijin Krishn Date: Mon, 27 Apr 2026 16:19:00 +0530 Subject: [PATCH 1/3] feat: add user reactivation api Add 'deletedAt' attribute in User model --- .../migration.sql | 2 ++ prisma/schema.prisma | 13 +++++----- src/token/token.service.ts | 2 +- src/user/dto/index.ts | 1 + src/user/dto/reactivate-user.dto.ts | 20 ++++++++++++++++ src/user/user.controller.ts | 21 +++++++++++++--- src/user/user.service.ts | 24 ++++++++++++++++++- 7 files changed, 72 insertions(+), 11 deletions(-) create mode 100644 prisma/migrations/20260427103237_add_user_deleted_at/migration.sql create mode 100644 src/user/dto/reactivate-user.dto.ts diff --git a/prisma/migrations/20260427103237_add_user_deleted_at/migration.sql b/prisma/migrations/20260427103237_add_user_deleted_at/migration.sql new file mode 100644 index 0000000..1cc9ae3 --- /dev/null +++ b/prisma/migrations/20260427103237_add_user_deleted_at/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "users" ADD COLUMN "deletedAt" TIMESTAMP(3); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4aaaefa..ce34ba8 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -15,12 +15,13 @@ datasource db { } model User { - id Int @id @default(autoincrement()) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - deleted Boolean @default(false) - role Role @default(USER) - tokenVersion Int @default(1) + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deleted Boolean @default(false) + deletedAt DateTime? + role Role @default(USER) + tokenVersion Int @default(1) email String @unique password String diff --git a/src/token/token.service.ts b/src/token/token.service.ts index d0a0650..a960270 100644 --- a/src/token/token.service.ts +++ b/src/token/token.service.ts @@ -24,7 +24,7 @@ export class TokenService { private readonly prisma: PrismaService, ) {} - public async generateToken(user: User): Promise<{ + public async generateToken(user: Omit): Promise<{ accessToken: string refreshToken: string }> { diff --git a/src/user/dto/index.ts b/src/user/dto/index.ts index e06a282..71c4481 100644 --- a/src/user/dto/index.ts +++ b/src/user/dto/index.ts @@ -1,4 +1,5 @@ export * from './change-user-role.dto' export * from './create-user.dto' export * from './delete-user.dto' +export * from './reactivate-user.dto' export * from './update-user.dto' diff --git a/src/user/dto/reactivate-user.dto.ts b/src/user/dto/reactivate-user.dto.ts new file mode 100644 index 0000000..e28aa93 --- /dev/null +++ b/src/user/dto/reactivate-user.dto.ts @@ -0,0 +1,20 @@ +import { ApiProperty, ApiSchema } from '@nestjs/swagger' +import { IsEmail, IsNotEmpty, IsString } from 'class-validator' + +@ApiSchema({ name: 'ReactivateUserRequest' }) +export class ReactivateUserDto { + @ApiProperty({ example: 'user@example.com' }) + @IsEmail() + @IsNotEmpty() + email: string + + @ApiProperty({ example: 'password123' }) + @IsString() + @IsNotEmpty() + password: string + + @ApiProperty({ example: '019cdba1-96a7-7471-be71-42636407ce41' }) + @IsString() + @IsNotEmpty() + emailVerifiedCode: string +} diff --git a/src/user/user.controller.ts b/src/user/user.controller.ts index dc35865..61ee0d9 100644 --- a/src/user/user.controller.ts +++ b/src/user/user.controller.ts @@ -3,14 +3,22 @@ import { Controller, Delete, Get, + HttpCode, + HttpStatus, Param, ParseIntPipe, Patch, + Post, } from '@nestjs/common' -import { Role } from 'generated/prisma/enums' import { ApiBearerAuth, ApiTags } from '@nestjs/swagger' -import { Roles, User } from 'src/auth/decorator' -import { ChangeUserRoleDto, DeleteUserDto, UpdateUserDto } from './dto' +import { Role } from 'generated/prisma/enums' +import { Public, Roles, User } from 'src/auth/decorator' +import { + ChangeUserRoleDto, + DeleteUserDto, + ReactivateUserDto, + UpdateUserDto, +} from './dto' import { UserService } from './user.service' @ApiTags('Users') @@ -51,6 +59,13 @@ export class UserController { return this.userService.softDeleteUser(email, deleteUserDto) } + @Public() + @HttpCode(HttpStatus.OK) + @Post('reactivate-user') + reactivateUser(@Body() reactivateUserDto: ReactivateUserDto) { + return this.userService.reactivateUser(reactivateUserDto) + } + @Delete('hard-delete') hardDeleteUser( @User('email') email: string, diff --git a/src/user/user.service.ts b/src/user/user.service.ts index e43333f..b5359d2 100644 --- a/src/user/user.service.ts +++ b/src/user/user.service.ts @@ -14,6 +14,7 @@ import { ChangeUserRoleDto, CreateUserDto, DeleteUserDto, + ReactivateUserDto, UpdateUserDto, } from './dto' @@ -95,7 +96,7 @@ export class UserService { await this.otpService.verifyCode(email, dto.emailVerifiedCode, true) const deletedUser = await this.prisma.user.update({ where: { email }, - data: { deleted: true }, + data: { deleted: true, deletedAt: new Date() }, omit: { password: true }, }) await this.tokenService.revokeAllToken(deletedUser.id) @@ -115,6 +116,27 @@ export class UserService { } } + async reactivateUser(dto: ReactivateUserDto) { + const foundUser = await this.prisma.user.findUnique({ + where: { email: dto.email }, + omit: { password: true }, + }) + if (!foundUser) throw new NotFoundException('User not found') + if (!foundUser.deleted) throw new ConflictException('User already active') + await this.otpService.verifyCode(dto.email, dto.emailVerifiedCode) + const reactivatedUser = await this.prisma.user.update({ + where: { email: dto.email }, + data: { deleted: false, deletedAt: null }, + omit: { password: true }, + }) + const tokens = await this.tokenService.generateToken(reactivatedUser) + return { + id: reactivatedUser.id, + message: 'User reactivated', + ...tokens, + } + } + async hardDeleteUser(email: string, dto: DeleteUserDto) { try { await this.otpService.verifyCode(email, dto.emailVerifiedCode, true) From 3fda5c45e8baa138ec3168a58d7a165b9b6a3ad2 Mon Sep 17 00:00:00 2001 From: Bijin Krishn Date: Mon, 27 Apr 2026 17:02:39 +0530 Subject: [PATCH 2/3] test: add test for user reactivation --- package.json | 4 +- src/user/user.controller.spec.ts | 37 ++++++++++++++- src/user/user.service.spec.ts | 76 ++++++++++++++++++++++++++++++ test/user.e2e-spec.ts | 79 ++++++++++++++++++++++++++++++++ 4 files changed, 193 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 20605b0..f8c6f71 100644 --- a/package.json +++ b/package.json @@ -26,8 +26,8 @@ "test:cov": "jest --coverage", "test:ci": "jest --runInBand --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", - "test:e2e": "NODE_OPTIONS=--experimental-vm-modules dotenv -e .env.test -- jest --runInBand --config ./test/jest-e2e.json", - "test:e2e:watch": "NODE_OPTIONS=--experimental-vm-modules dotenv -e .env.test -- jest --runInBand --watch --config ./test/jest-e2e.json", + "test:e2e": "prisma generate && pnpm build && NODE_OPTIONS=--experimental-vm-modules dotenv -e .env.test -- jest --runInBand --config ./test/jest-e2e.json", + "test:e2e:watch": "prisma generate && pnpm build && NODE_OPTIONS=--experimental-vm-modules dotenv -e .env.test -- jest --runInBand --watch --config ./test/jest-e2e.json", "test:all": "pnpm test && pnpm test:e2e", "prepare": "husky" }, diff --git a/src/user/user.controller.spec.ts b/src/user/user.controller.spec.ts index b83f592..aee7a18 100644 --- a/src/user/user.controller.spec.ts +++ b/src/user/user.controller.spec.ts @@ -1,7 +1,7 @@ import { ForbiddenException, NotFoundException } from '@nestjs/common' import { Test, TestingModule } from '@nestjs/testing' import { Role } from 'generated/prisma/enums' -import { ChangeUserRoleDto, DeleteUserDto } from './dto' +import { ChangeUserRoleDto, DeleteUserDto, ReactivateUserDto } from './dto' import { UserController } from './user.controller' import { UserService } from './user.service' @@ -10,6 +10,7 @@ const mockUsersService = { findUserByUserId: jest.fn(), updateUser: jest.fn(), softDeleteUser: jest.fn(), + reactivateUser: jest.fn(), hardDeleteUser: jest.fn(), changeUserRole: jest.fn(), } @@ -169,6 +170,40 @@ describe('UsersController', () => { }) }) + describe('reactivateUser', () => { + const reactivateUserDto: ReactivateUserDto = { + email: 'test@email.com', + password: 'StrongPassword!123', + emailVerifiedCode: 'verification-code', + } + + it('should reactivate user and return tokens', async () => { + const expectedRes = { + id: 1, + message: 'User reactivated', + accessToken: 'new-access-token', + refreshToken: 'new-refresh-token', + } + + userService.reactivateUser.mockResolvedValue(expectedRes) + + await expect(controller.reactivateUser(reactivateUserDto)).resolves.toBe( + expectedRes, + ) + expect(userService.reactivateUser).toHaveBeenCalledWith(reactivateUserDto) + }) + + it('propagates NotFoundException from service', async () => { + const expectedErr = new NotFoundException('User not found') + + userService.reactivateUser.mockRejectedValue(expectedErr) + + await expect(controller.reactivateUser(reactivateUserDto)).rejects.toBe( + expectedErr, + ) + }) + }) + describe('changeUserRole', () => { it('should change user role and return response', async () => { const userId = 1 diff --git a/src/user/user.service.spec.ts b/src/user/user.service.spec.ts index 660a5ba..31c6c09 100644 --- a/src/user/user.service.spec.ts +++ b/src/user/user.service.spec.ts @@ -33,6 +33,7 @@ describe('UserService', () => { const mockTokenService = { revokeAllToken: jest.fn(), + generateToken: jest.fn(), } beforeEach(async () => { @@ -188,6 +189,81 @@ describe('UserService', () => { }) }) + describe('reactivateUser', () => { + it('should reactivate deleted user and return tokens', async () => { + mockPrisma.user.findUnique.mockResolvedValue({ + id: 1, + email: 'deleted@t.com', + deleted: true, + }) + mockOtpService.verifyCode.mockResolvedValue(true) + mockPrisma.user.update.mockResolvedValue({ + id: 1, + email: 'deleted@t.com', + deleted: false, + }) + mockTokenService.generateToken.mockResolvedValue({ + accessToken: 'access-token', + refreshToken: 'refresh-token', + }) + + const result = await service.reactivateUser({ + email: 'deleted@t.com', + password: 'password', + emailVerifiedCode: 'verified-code', + }) + + expect(mockOtpService.verifyCode).toHaveBeenCalledWith( + 'deleted@t.com', + 'verified-code', + ) + expect(mockPrisma.user.update).toHaveBeenCalledWith({ + where: { email: 'deleted@t.com' }, + data: { deleted: false, deletedAt: null }, + omit: { password: true }, + }) + expect(mockTokenService.generateToken).toHaveBeenCalledWith({ + id: 1, + email: 'deleted@t.com', + deleted: false, + }) + expect(result).toEqual({ + id: 1, + message: 'User reactivated', + accessToken: 'access-token', + refreshToken: 'refresh-token', + }) + }) + + it('should throw NotFoundException when user does not exist', async () => { + mockPrisma.user.findUnique.mockResolvedValue(null) + + await expect( + service.reactivateUser({ + email: 'missing@t.com', + password: 'password', + emailVerifiedCode: 'verified-code', + }), + ).rejects.toThrow(NotFoundException) + }) + + it('should throw ConflictException when user is already active', async () => { + mockPrisma.user.findUnique.mockResolvedValue({ + id: 1, + email: 'active@t.com', + deleted: false, + }) + + await expect( + service.reactivateUser({ + email: 'active@t.com', + password: 'password', + emailVerifiedCode: 'verified-code', + }), + ).rejects.toThrow(ConflictException) + }) + }) + describe('changeUserRole', () => { it('should throw NotFoundException if user does not exist', async () => { mockPrisma.user.findUnique.mockResolvedValue(null) diff --git a/test/user.e2e-spec.ts b/test/user.e2e-spec.ts index 9cbe274..9290b46 100644 --- a/test/user.e2e-spec.ts +++ b/test/user.e2e-spec.ts @@ -213,6 +213,85 @@ describe('UserController (e2e)', () => { }) }) + describe('POST /users/reactivate-user', () => { + it('should reactivate a soft-deleted user and issue tokens', async () => { + const email = 'reactivate@example.com' + const password = 'StrongPassword!123' + const { accessToken: oldAccessToken } = await getTokens(email) + + const otpRes: { body: { otp: string } } = await request( + app.getHttpServer(), + ) + .post('/auth/guarded-email-otp') + .set('Authorization', `Bearer ${oldAccessToken}`) + const otp = otpRes.body.otp + + const verifyRes: { body: { verifiedCode: string } } = await request( + app.getHttpServer(), + ) + .post('/auth/verify-otp') + .send({ email, otp }) + const emailVerifiedCode = verifyRes.body.verifiedCode + + await request(app.getHttpServer()) + .delete('/users') + .set('Authorization', `Bearer ${oldAccessToken}`) + .send({ emailVerifiedCode }) + .expect(200) + + const reactivationOtpRes: { body: { otp: string } } = await request( + app.getHttpServer(), + ) + .post('/auth/email-otp') + .send({ email }) + const reactivationOtp = reactivationOtpRes.body.otp + + const reactivationVerifyRes: { body: { verifiedCode: string } } = + await request(app.getHttpServer()) + .post('/auth/verify-otp') + .send({ email, otp: reactivationOtp }) + const reactivationCode = reactivationVerifyRes.body.verifiedCode + + const res: { body: { accessToken: string; refreshToken: string } } = + await request(app.getHttpServer()) + .post('/users/reactivate-user') + .send({ email, password, emailVerifiedCode: reactivationCode }) + .expect(200) + + expect(res.body.accessToken).toBeDefined() + expect(res.body.refreshToken).toBeDefined() + + const user = await prisma.user.findUnique({ where: { email } }) + expect(user?.deleted).toBe(false) + expect(user?.deletedAt).toBeNull() + }) + + it('should return 409 when user is already active', async () => { + const email = 'already-active@example.com' + const password = 'StrongPassword!123' + await getTokens(email) + + const otpRes: { body: { otp: string } } = await request( + app.getHttpServer(), + ) + .post('/auth/email-otp') + .send({ email }) + const otp = otpRes.body.otp + + const verifyRes: { body: { verifiedCode: string } } = await request( + app.getHttpServer(), + ) + .post('/auth/verify-otp') + .send({ email, otp }) + const emailVerifiedCode = verifyRes.body.verifiedCode + + await request(app.getHttpServer()) + .post('/users/reactivate-user') + .send({ email, password, emailVerifiedCode }) + .expect(409) + }) + }) + describe('GET /users (Admin only)', () => { it('should return all users for SUPER_ADMIN', async () => { const { accessToken } = await getTokens( From a81bd94bf86407dd950a733d8d6ec8c2e22feeff Mon Sep 17 00:00:00 2001 From: Bijin Krishn Date: Mon, 27 Apr 2026 23:31:36 +0530 Subject: [PATCH 3/3] feat: add scheduler to remove soft-deleted user acc --- docker-compose.dev.yml | 3 ++- package.json | 1 + pnpm-lock.yaml | 35 ++++++++++++++++++++++++++++++++ src/app.module.ts | 2 ++ src/user/user-cleanup.service.ts | 33 ++++++++++++++++++++++++++++++ src/user/user.module.ts | 3 ++- 6 files changed, 75 insertions(+), 2 deletions(-) create mode 100644 src/user/user-cleanup.service.ts diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 86b2284..1ae8131 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -5,7 +5,8 @@ services: target: development container_name: dev_api command: > - sh -c "pnpm prisma migrate deploy && + sh -c "pnpm install --frozen-lockfile && + pnpm prisma migrate deploy && pnpm prisma db seed && pnpm start:dev" ports: diff --git a/package.json b/package.json index f8c6f71..b015e03 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "@nestjs/jwt": "^11.0.2", "@nestjs/mapped-types": "*", "@nestjs/platform-express": "^11.0.1", + "@nestjs/schedule": "^6.1.3", "@nestjs/swagger": "^11.2.6", "@nestjs/throttler": "^6.5.0", "@prisma/adapter-pg": "^7.5.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 54d46a9..f7b5f61 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: '@nestjs/platform-express': specifier: ^11.0.1 version: 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16) + '@nestjs/schedule': + specifier: ^6.1.3 + version: 6.1.3(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16) '@nestjs/swagger': specifier: ^11.2.6 version: 11.2.6(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) @@ -1121,6 +1124,12 @@ packages: '@nestjs/common': ^11.0.0 '@nestjs/core': ^11.0.0 + '@nestjs/schedule@6.1.3': + resolution: {integrity: sha512-RflMFOpR16Dwd1jAUbeB4mfGTCh65fvEdL4mSjQPJChpkRGRjIXjb+6YQcK2faQrVT60c9DmLmoVR7/ONCtuYQ==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + '@nestjs/core': ^10.0.0 || ^11.0.0 + '@nestjs/schematics@11.0.9': resolution: {integrity: sha512-0NfPbPlEaGwIT8/TCThxLzrlz3yzDNkfRNpbL7FiplKq3w4qXpJg0JYwqgMEJnLQZm3L/L/5XjoyfJHUO3qX9g==} peerDependencies: @@ -1390,6 +1399,9 @@ packages: '@types/jsonwebtoken@9.0.10': resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + '@types/luxon@3.7.1': + resolution: {integrity: sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==} + '@types/methods@1.1.4': resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} @@ -2224,6 +2236,10 @@ packages: create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + cron@4.4.0: + resolution: {integrity: sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==} + engines: {node: '>=18.x'} + cross-env@10.1.0: resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==} engines: {node: '>=20'} @@ -3260,6 +3276,10 @@ packages: resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==} engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} + luxon@3.7.2: + resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} + engines: {node: '>=12'} + magic-string@0.30.17: resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} @@ -5519,6 +5539,12 @@ snapshots: transitivePeerDependencies: - supports-color + '@nestjs/schedule@6.1.3(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)': + dependencies: + '@nestjs/common': 11.1.16(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) + cron: 4.4.0 + '@nestjs/schematics@11.0.9(chokidar@4.0.3)(typescript@5.9.3)': dependencies: '@angular-devkit/core': 19.2.17(chokidar@4.0.3) @@ -5839,6 +5865,8 @@ snapshots: '@types/ms': 2.1.0 '@types/node': 22.19.15 + '@types/luxon@3.7.1': {} + '@types/methods@1.1.4': {} '@types/ms@2.1.0': {} @@ -6707,6 +6735,11 @@ snapshots: create-require@1.1.1: {} + cron@4.4.0: + dependencies: + '@types/luxon': 3.7.1 + luxon: 3.7.2 + cross-env@10.1.0: dependencies: '@epic-web/invariant': 1.0.0 @@ -7964,6 +7997,8 @@ snapshots: lru.min@1.1.4: {} + luxon@3.7.2: {} + magic-string@0.30.17: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 diff --git a/src/app.module.ts b/src/app.module.ts index bb96373..20fb841 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -2,6 +2,7 @@ import { ThrottlerStorageRedisService } from '@nest-lab/throttler-storage-redis' import { Module } from '@nestjs/common' import { ConfigModule, ConfigService } from '@nestjs/config' import { APP_GUARD } from '@nestjs/core' +import { ScheduleModule } from '@nestjs/schedule' import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler' import { AppController } from './app.controller' import { AppService } from './app.service' @@ -36,6 +37,7 @@ import { UserModule } from './user/user.module' storage: new ThrottlerStorageRedisService(redis), }), }), + ScheduleModule.forRoot(), ], controllers: [AppController], providers: [ diff --git a/src/user/user-cleanup.service.ts b/src/user/user-cleanup.service.ts new file mode 100644 index 0000000..46c5d22 --- /dev/null +++ b/src/user/user-cleanup.service.ts @@ -0,0 +1,33 @@ +import { Injectable } from '@nestjs/common' +import { Cron, CronExpression } from '@nestjs/schedule' +import { PrismaService } from 'src/infra/prisma/prisma.service' + +@Injectable() +export class UserCleanupService { + constructor(private readonly prisma: PrismaService) {} + + // runs every day at midnight + @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT) + async handleUserCleanup() { + const now = new Date() + // 7-day grace period + const threshold = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000) + + const usersToDelete = await this.prisma.user.findMany({ + where: { + deleted: true, + deletedAt: { + lte: threshold, + }, + }, + }) + + for (const user of usersToDelete) { + // IMPORTANT: handle dependencies before delete + + await this.prisma.$transaction([ + this.prisma.user.delete({ where: { id: user.id } }), + ]) + } + } +} diff --git a/src/user/user.module.ts b/src/user/user.module.ts index 78e55cb..a73dbf7 100644 --- a/src/user/user.module.ts +++ b/src/user/user.module.ts @@ -1,13 +1,14 @@ import { Module } from '@nestjs/common' import { OtpModule } from 'src/otp/otp.module' import { TokenModule } from 'src/token/token.module' +import { UserCleanupService } from './user-cleanup.service' import { UserController } from './user.controller' import { UserService } from './user.service' @Module({ imports: [OtpModule, TokenModule], controllers: [UserController], - providers: [UserService], + providers: [UserService, UserCleanupService], exports: [UserService], }) export class UserModule {}