From 085e192d62b974346055e0d3a5af067d6ac050b4 Mon Sep 17 00:00:00 2001 From: John Ughiovhe Date: Mon, 8 Jun 2026 11:19:38 +0100 Subject: [PATCH 01/37] chore: fix truncated HBS templates, env config for admin seeding, and admin-users reformat --- src/config/env.ts | 3 + src/database/seeds/user.seeder.ts | 5 +- src/email/templates/delete-account.hbs | 4 +- src/email/templates/notification-alert.hbs | 4 +- src/email/templates/payment-failed.hbs | 4 +- .../templates/subscription-cancelled.hbs | 6 +- src/email/templates/welcome.hbs | 10 +- src/modules/admin/users/admin-users.module.ts | 13 +- .../admin/users/admin-users.service.ts | 564 +++++++++--------- src/modules/auth/guards/roles.guard.spec.ts | 49 ++ 10 files changed, 356 insertions(+), 306 deletions(-) create mode 100644 src/modules/auth/guards/roles.guard.spec.ts diff --git a/src/config/env.ts b/src/config/env.ts index 9b0bd41b..4b3ab082 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -36,6 +36,9 @@ const envSchema = z.object({ CONTACT_ADMIN_EMAIL: z.string().email().default('useseil@hng14.com'), + SEED_ADMIN_EMAIL: z.string().email().default(''), + SEED_ADMIN_PASSWORD: z.string().default(''), + UPLOAD_STORAGE_ENDPOINT: z.string().default(''), UPLOAD_STORAGE_ACCESS_KEY: z.string().default(''), UPLOAD_STORAGE_SECRET_KEY: z.string().default(''), diff --git a/src/database/seeds/user.seeder.ts b/src/database/seeds/user.seeder.ts index f2e3fb7c..f55dd539 100644 --- a/src/database/seeds/user.seeder.ts +++ b/src/database/seeds/user.seeder.ts @@ -1,5 +1,6 @@ import * as bcrypt from 'bcrypt'; import { DataSource } from 'typeorm'; +import { env } from '../../config/env'; import { User } from '../../modules/users/entities/user.entity'; import { Seeder } from './seeder.interface'; import { UserRole } from '../../modules/users/enums/user-role.enum'; @@ -27,8 +28,8 @@ function validatePassword(password: string): void { export const userSeeder: Seeder = { name: 'UserSeeder', async run(dataSource: DataSource) { - const email = process.env.SEED_ADMIN_EMAIL; - const password = process.env.SEED_ADMIN_PASSWORD; + const email = env.SEED_ADMIN_EMAIL; + const password = env.SEED_ADMIN_PASSWORD; if (!email) { throw new Error('SEED_ADMIN_EMAIL env var is required'); diff --git a/src/email/templates/delete-account.hbs b/src/email/templates/delete-account.hbs index 80594029..40caa6f7 100644 --- a/src/email/templates/delete-account.hbs +++ b/src/email/templates/delete-account.hbs @@ -3,7 +3,7 @@

Delete Account Request

Hi {{name}},

We received a request to permanently delete your Seil account.

-

If you initiated this request, no further action is required. Your account will be deleted according to our standard process.

-

If you did not request this change, please reset your password immediately or contact our support team.

+

If you initiated this request, no further action is required. Your account will be deleted according to our standard +

If you did not request this change, please reset your password immediately or contact our Notification alert

Hi {{name}},

You have {{unreadCount}} new notification(s) from FlowBrand. Open the app to stay up to date.

-
View notifications -

To manage your notification preferences, click here.

+ To manage your notification preferences,

We can't process your payment

Hi {{name}},

-

We're having some trouble collecting your Seil premium payment. Please take a moment to review your payment details and double check that there was money in your associated account. We'll try to process the payment again in a few days.

+

We're having some trouble collecting your Seil premium payment. Please take a moment to review your payment det {{#if failureReason}}

Reason: {{failureReason}}

{{/if}} @@ -13,7 +13,7 @@ @@ -11,7 +11,7 @@
- Update payment details +

Your subscription has been canceled

Hi {{name}},

-

We've confirmed your cancellation. You'll continue to have full access to Seil premium until {{accessUntil}}. After that date, your account will move to the free plan and you will not be charged again.

-

If you didn't mean to cancel, you can restart your plan at any time before your expiry date, you'll pick up right where you left off.

+

We've confirmed your cancellation. You'll continue to have full access to Seil premium until {{accessUntil +

If you didn't mean to cancel, you can restart your plan at any time before your expiry date, you'll pick up right

From 5f13ab55d5b80994077ee795c13dfe55986d798e Mon Sep 17 00:00:00 2001 From: Prestige Nsien Date: Mon, 8 Jun 2026 18:52:28 +0100 Subject: [PATCH 04/37] fix(funnels): allocate LLM context budget per document instead of slicing after join --- src/modules/funnels/services/funnels.service.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/modules/funnels/services/funnels.service.ts b/src/modules/funnels/services/funnels.service.ts index a03f0614..bd1e9171 100644 --- a/src/modules/funnels/services/funnels.service.ts +++ b/src/modules/funnels/services/funnels.service.ts @@ -573,11 +573,11 @@ export class FunnelsService { if (docs.some((d) => d.status !== UploadDocumentStatus.READY)) throw new UnprocessableEntityException(SYS_MSG.UPLOAD_NOT_READY); + const perDoc = Math.floor(4000 / docs.length); const parsedJoin = docs - .map((d) => d.parsed_text ?? '') + .map((d) => (d.parsed_text ?? '').slice(0, perDoc)) .filter(Boolean) - .join('\n') - .slice(0, 4000); + .join('\n'); const funnelName = await this.generateFunnelNameWithFallback(parsedJoin, 'unknown'); const uploadUser = await this.funnelAction.getUserProfile(userId); const businessContext: BusinessContext = { From 8c0d2a4b8725342d3311d9558a1f48a5dc202466 Mon Sep 17 00:00:00 2001 From: Prestige Nsien Date: Mon, 8 Jun 2026 18:52:43 +0100 Subject: [PATCH 05/37] =?UTF-8?q?test(funnels):=20cover=20multi-document?= =?UTF-8?q?=20context=20budget=20distribution=20(UPL-01=E2=80=93UPL-05)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/tests/funnels.service.spec.ts | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/src/modules/funnels/services/tests/funnels.service.spec.ts b/src/modules/funnels/services/tests/funnels.service.spec.ts index b372c3e5..76cb1ab0 100644 --- a/src/modules/funnels/services/tests/funnels.service.spec.ts +++ b/src/modules/funnels/services/tests/funnels.service.spec.ts @@ -404,6 +404,74 @@ describe('FunnelsService', () => { const saved = queryRunner.manager.save.mock.calls[0][1] as SavedFunnel; expect(saved.business_context.business_name).toBe('Leather Craft Co'); }); + + it('UPL-01: multi-doc — second document contributes context when first doc exceeds per-doc budget', async () => { + const firstDocText = 'A'.repeat(5000); + const secondDocText = 'B'.repeat(5000); + funnelAction.getUploadedDocuments.mockResolvedValue([ + { ...READY_DOC, id: 'u1', parsed_text: firstDocText } as any, + { ...READY_DOC, id: 'u2', parsed_text: secondDocText } as any, + ]); + + await service.createGeneration(USER_ID, { ...UPLOAD_DTO, upload_ids: ['u1', 'u2'] }); + + const geminiArg: string = mockLlmService.generateFunnelNameWithGemini.mock.calls[0][0]; + expect(geminiArg).toContain('B'); + expect(geminiArg.length).toBeLessThanOrEqual(4000 + 1); // content + one '\n' separator + }); + + it('UPL-02: single document — gets the full 4000-char budget', async () => { + const longText = 'X'.repeat(5000); + funnelAction.getUploadedDocuments.mockResolvedValue([ + { ...READY_DOC, parsed_text: longText } as any, + ]); + + await service.createGeneration(USER_ID, UPLOAD_DTO); + + const geminiArg: string = mockLlmService.generateFunnelNameWithGemini.mock.calls[0][0]; + expect(geminiArg.length).toBe(4000); + }); + + it('UPL-03: document with null parsed_text does not crash and does not contribute empty string to context', async () => { + funnelAction.getUploadedDocuments.mockResolvedValue([ + { ...READY_DOC, id: 'u1', parsed_text: 'real content' } as any, + { ...READY_DOC, id: 'u2', parsed_text: null } as any, + ]); + + await service.createGeneration(USER_ID, { ...UPLOAD_DTO, upload_ids: ['u1', 'u2'] }); + + const geminiArg: string = mockLlmService.generateFunnelNameWithGemini.mock.calls[0][0]; + expect(geminiArg).toBe('real content'); + }); + + it('UPL-04: multi-doc where all docs fit within budget — full text from every document is preserved', async () => { + funnelAction.getUploadedDocuments.mockResolvedValue([ + { ...READY_DOC, id: 'u1', parsed_text: 'Doc one content' } as any, + { ...READY_DOC, id: 'u2', parsed_text: 'Doc two content' } as any, + ]); + + await service.createGeneration(USER_ID, { ...UPLOAD_DTO, upload_ids: ['u1', 'u2'] }); + + const geminiArg: string = mockLlmService.generateFunnelNameWithGemini.mock.calls[0][0]; + expect(geminiArg).toContain('Doc one content'); + expect(geminiArg).toContain('Doc two content'); + }); + + it('UPL-05: parsedJoin is used as business_description in businessContext', async () => { + const firstDocText = 'A'.repeat(5000); + const secondDocText = 'B'.repeat(5000); + funnelAction.getUploadedDocuments.mockResolvedValue([ + { ...READY_DOC, id: 'u1', parsed_text: firstDocText } as any, + { ...READY_DOC, id: 'u2', parsed_text: secondDocText } as any, + ]); + + await service.createGeneration(USER_ID, { ...UPLOAD_DTO, upload_ids: ['u1', 'u2'] }); + + const saved = queryRunner.manager.save.mock.calls[0][1] as SavedFunnel & { business_context: { business_description: string } }; + expect(saved.business_context.business_description).toContain('A'); + expect(saved.business_context.business_description).toContain('B'); + expect(saved.business_context.business_description.length).toBeLessThanOrEqual(4001); + }); }); describe('AC-09: rollback on queue dispatch failure', () => { From cb5759672799ea73724bd871efc0dd77efbc3f91 Mon Sep 17 00:00:00 2001 From: ibraheembello Date: Mon, 8 Jun 2026 18:50:52 +0100 Subject: [PATCH 06/37] feat(email): add dashboard CTA links to funnel-ready, weekly-digest and payment-successful These three transactional emails told users to "open SEIL" or confirmed a payment but gave no way back into the app. They now render a clickable CTA pointing at the dashboard. - funnel-ready and weekly-digest: wrap "SEIL" in an inline {{dashboardUrl}} link, matching the existing stage-unlocked / stage-completed pattern - payment-successful: add a full-width "Go to dashboard" button below the receipt, matching the button style used in payment-failed - no service change needed: dashboardUrl is already injected from FRONTEND_URL - extend template.service.spec to assert the CTA renders in all three Refs #225 --- src/email/templates/funnel-ready.hbs | 5 +++- src/email/templates/payment-successful.hbs | 27 ++++++++++++++++++++++ src/email/templates/weekly-digest.hbs | 5 +++- src/email/tests/template.service.spec.ts | 27 +++++++++++++++++++--- 4 files changed, 59 insertions(+), 5 deletions(-) diff --git a/src/email/templates/funnel-ready.hbs b/src/email/templates/funnel-ready.hbs index df6f21e6..081f360e 100644 --- a/src/email/templates/funnel-ready.hbs +++ b/src/email/templates/funnel-ready.hbs @@ -3,6 +3,9 @@

Your funnel is ready

Hi {{name}},

Your funnel {{funnelName}} is ready and your plan is updated.

-

Open SEIL to review your stages and start your tasks.

+

+ Open SEIL + to review your stages and start your tasks. +

diff --git a/src/email/templates/payment-successful.hbs b/src/email/templates/payment-successful.hbs index b04ee300..3fc5ba0b 100644 --- a/src/email/templates/payment-successful.hbs +++ b/src/email/templates/payment-successful.hbs @@ -15,3 +15,30 @@
- Reactivate my plan +

Welcome to Seil {{name}}

Your custom sales funnel is just a few minutes away

-

We are thrilled to have you on board. Let's start transforming your digital presence into a high conversion engine today

+

We are thrilled to have you on board. Let's start transforming your digital presence into a high conversion eng - + - + - +
1
Answer a few questions
2
We build your custom funnel
3
Get your action plan
@@ -24,7 +24,7 @@ diff --git a/src/email/templates/notification-alert.hbs b/src/email/templates/notification-alert.hbs index f8ad9601..9ffda883 100644 --- a/src/email/templates/notification-alert.hbs +++ b/src/email/templates/notification-alert.hbs @@ -1,9 +1,27 @@ diff --git a/src/email/templates/payment-failed.hbs b/src/email/templates/payment-failed.hbs index 4386140e..125d09f3 100644 --- a/src/email/templates/payment-failed.hbs +++ b/src/email/templates/payment-failed.hbs @@ -1,8 +1,16 @@ @@ -11,7 +22,12 @@
- Get my strategy plan + { + await queryRunner.query( + `ALTER TABLE "admin_notification_preferences" DROP CONSTRAINT "FK_admin_notification_preferences_user_id"`, + ); + await queryRunner.query(`DROP INDEX "public"."IDX_admin_notification_preferences_user_id"`); + await queryRunner.query(`DROP TABLE "admin_notification_preferences"`); + } +} diff --git a/src/database/seeds/user.seeder.ts b/src/database/seeds/user.seeder.ts index f55dd539..2edc9177 100644 --- a/src/database/seeds/user.seeder.ts +++ b/src/database/seeds/user.seeder.ts @@ -5,6 +5,8 @@ import { User } from '../../modules/users/entities/user.entity'; import { Seeder } from './seeder.interface'; import { UserRole } from '../../modules/users/enums/user-role.enum'; import { UserRoleEntity } from '../../modules/users/entities/user-role.entity'; +import { AdminNotificationPreference } from '../../modules/admin/profile/entities/admin-notification-preference.entity'; +import { AdminNotificationPreferenceModelAction } from '../../modules/admin/profile/actions/admin-notification-preference.action'; /** Validates that a password meets the minimum policy. */ function validatePassword(password: string): void { @@ -54,6 +56,7 @@ export const userSeeder: Seeder = { await dataSource.transaction(async (manager) => { const userRepository = manager.getRepository(User); const txRoleRepository = manager.getRepository(UserRoleEntity); + const admin = userRepository.create({ email, @@ -67,6 +70,9 @@ export const userSeeder: Seeder = { user_id: savedAdmin.id, role: UserRole.SUPER_ADMIN, }); + + const action = new AdminNotificationPreferenceModelAction(manager.getRepository(AdminNotificationPreference)); + await action.createDefaultForUser(savedAdmin.id, manager); }); console.log('[UserSeeder] Super admin created successfully'); diff --git a/src/modules/admin/profile/actions/admin-notification-preference.action.ts b/src/modules/admin/profile/actions/admin-notification-preference.action.ts new file mode 100644 index 00000000..93b48956 --- /dev/null +++ b/src/modules/admin/profile/actions/admin-notification-preference.action.ts @@ -0,0 +1,40 @@ +import { AbstractModelAction } from '@hng-sdk/orm'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { EntityManager, Repository } from 'typeorm'; +import { AdminNotificationPreference } from '../entities/admin-notification-preference.entity'; +import { AdminNotificationPreferenceUpdatePayload } from '../interfaces/admin-notification-preference.interface'; + +@Injectable() +export class AdminNotificationPreferenceModelAction extends AbstractModelAction { + constructor( + @InjectRepository(AdminNotificationPreference) + repository: Repository, + ) { + super(repository, AdminNotificationPreference); + } + + async findByUserId(userId: string): Promise { + return this.get({ identifierOptions: { user_id: userId } }); + } + + async createDefaultForUser(userId: string, transaction?: EntityManager): Promise { + return this.create({ + createPayload: { user_id: userId }, + transactionOptions: transaction + ? { useTransaction: true, transaction } + : { useTransaction: false }, + }); + } + + async updateByUserId( + userId: string, + payload: AdminNotificationPreferenceUpdatePayload, + ): Promise { + return this.update({ + identifierOptions: { user_id: userId }, + updatePayload: payload, + transactionOptions: { useTransaction: false }, + }); + } +} \ No newline at end of file diff --git a/src/modules/admin/profile/admin-profile.controller.ts b/src/modules/admin/profile/admin-profile.controller.ts index 71bde0c8..ef44b5b3 100644 --- a/src/modules/admin/profile/admin-profile.controller.ts +++ b/src/modules/admin/profile/admin-profile.controller.ts @@ -1,9 +1,9 @@ import { Body, Controller, - Get, HttpCode, HttpStatus, + Get, Patch, UnprocessableEntityException, UseGuards, @@ -21,10 +21,13 @@ import { UserRole } from '../../users/enums/user-role.enum'; import { AdminProfileService } from './admin-profile.service'; import { ChangeAdminPasswordDocs, + GetAdminNotificationPreferencesDocs, GetAdminProfileDocs, + UpdateAdminNotificationPreferencesDocs, UpdateAdminProfileDocs, } from './docs/admin-profile-swagger.doc'; import { ChangeAdminPasswordDto } from './dto/change-admin-password.dto'; +import { UpdateAdminNotificationPreferencesDto } from './dto/update-admin-notification-preferences.dto'; import { UpdateAdminProfileDto } from './dto/update-admin-profile.dto'; @ApiTags('admin') @@ -47,6 +50,52 @@ export class AdminProfileController { }; } + @Get('notification-preferences') + @HttpCode(HttpStatus.OK) + @GetAdminNotificationPreferencesDocs() + async getNotificationPreferences(@CurrentUser() currentUser: AuthenticatedUser) { + const data = await this.adminProfileService.getNotificationPreferences(currentUser.userId); + return { + statusCode: HttpStatus.OK, + message: SYS_MSG.ADMIN_NOTIFICATION_PREFERENCES_RETRIEVED_SUCCESSFULLY, + data, + }; + } + + @Patch('notification-preferences') + @HttpCode(HttpStatus.OK) + @UpdateAdminNotificationPreferencesDocs() + async updateNotificationPreferences( + @CurrentUser() currentUser: AuthenticatedUser, + @Body( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + transformOptions: { enableImplicitConversion: false }, + expectedType: UpdateAdminNotificationPreferencesDto, + validationError: { target: false, value: false }, + exceptionFactory: (errors: ValidationError[]) => + new UnprocessableEntityException({ + success: false, + statusCode: HttpStatus.UNPROCESSABLE_ENTITY, + error: 'UnprocessableEntityException', + message: SYS_MSG.VALIDATION_FAILED, + details: errors, + }), + }), + ) + rawDto: Record, + ) { + const dto = rawDto as UpdateAdminNotificationPreferencesDto; + const data = await this.adminProfileService.updateNotificationPreferences(currentUser.userId, dto); + return { + statusCode: HttpStatus.OK, + message: SYS_MSG.ADMIN_NOTIFICATION_PREFERENCES_UPDATED_SUCCESSFULLY, + data, + }; + } + @Patch() @HttpCode(HttpStatus.OK) @UpdateAdminProfileDocs() @@ -67,7 +116,8 @@ export class AdminProfileController { error: 'UnprocessableEntityException', message: SYS_MSG.VALIDATION_FAILED, details: errors, - }), }), + }), + }), ) dto: UpdateAdminProfileDto, ) { diff --git a/src/modules/admin/profile/admin-profile.module.ts b/src/modules/admin/profile/admin-profile.module.ts index 2216461a..c37c391a 100644 --- a/src/modules/admin/profile/admin-profile.module.ts +++ b/src/modules/admin/profile/admin-profile.module.ts @@ -5,13 +5,16 @@ import { UsersModule } from '../../users/users.module'; import { User } from '../../users/entities/user.entity'; import { AdminAuthModule } from '../auth/admin-auth.module'; import { AdminProfileModelAction } from './actions/admin-profile.action'; +import { AdminNotificationPreferenceModelAction } from './actions/admin-notification-preference.action'; import { AdminProfileController } from './admin-profile.controller'; import { AdminProfileService } from './admin-profile.service'; +import { AdminNotificationPreference } from './entities/admin-notification-preference.entity'; import { LogService } from './services/log.service'; @Module({ - imports: [TypeOrmModule.forFeature([User]), AdminAuthModule, UsersModule], + imports: [TypeOrmModule.forFeature([User, AdminNotificationPreference]), AdminAuthModule, UsersModule], controllers: [AdminProfileController], - providers: [AdminProfileService, AdminProfileModelAction, RolesGuard, LogService], + providers: [AdminProfileService, AdminProfileModelAction, AdminNotificationPreferenceModelAction, RolesGuard, LogService], + exports: [AdminNotificationPreferenceModelAction], }) export class AdminProfileModule {} diff --git a/src/modules/admin/profile/admin-profile.service.ts b/src/modules/admin/profile/admin-profile.service.ts index 41450d82..fd9cb82d 100644 --- a/src/modules/admin/profile/admin-profile.service.ts +++ b/src/modules/admin/profile/admin-profile.service.ts @@ -2,6 +2,7 @@ import { Injectable, InternalServerErrorException, Logger, + ConflictException, NotFoundException, UnauthorizedException, UnprocessableEntityException, @@ -12,9 +13,16 @@ import { UserRoleModelAction } from '../../users/actions/user-role.action'; import { UserRole } from '../../users/enums/user-role.enum'; import { User } from '../../users/entities/user.entity'; import { AdminProfileModelAction } from './actions/admin-profile.action'; +import { AdminNotificationPreferenceModelAction } from './actions/admin-notification-preference.action'; import { ChangeAdminPasswordDto } from './dto/change-admin-password.dto'; +import { UpdateAdminNotificationPreferencesDto } from './dto/update-admin-notification-preferences.dto'; import { UpdateAdminProfileDto } from './dto/update-admin-profile.dto'; import { AdminProfileActionType } from './enums/admin-profile-action-type.enum'; +import { + ADMIN_NOTIFICATION_PREFERENCE_FIELDS, + AdminNotificationPreferenceUpdatePayload, + AdminNotificationPreferencesResponse, +} from './interfaces/admin-notification-preference.interface'; import { IAdminProfile } from './interfaces/admin-profile.interface'; import { LogService } from './services/log.service'; @@ -26,6 +34,7 @@ export class AdminProfileService { constructor( private readonly adminProfileAction: AdminProfileModelAction, + private readonly adminNotificationPreferenceAction: AdminNotificationPreferenceModelAction, private readonly userRoleModelAction: UserRoleModelAction, private readonly logService: LogService, ) {} @@ -131,6 +140,48 @@ export class AdminProfileService { }); } + /** Returns the authenticated admin's notification preferences, creating defaults when needed. */ + async getNotificationPreferences(adminId: string): Promise { + const existing = await this.adminNotificationPreferenceAction.findByUserId(adminId); + if (existing) { + return this.toNotificationPreferenceResponse(existing); + } + + try { + const created = await this.adminNotificationPreferenceAction.createDefaultForUser(adminId); + return this.toNotificationPreferenceResponse(created); + } catch (error: unknown) { + if (this.isUniqueViolation(error)) { + const createdByConcurrentRequest = await this.adminNotificationPreferenceAction.findByUserId(adminId); + if (createdByConcurrentRequest) { + return this.toNotificationPreferenceResponse(createdByConcurrentRequest); + } + } + + throw error; + } + } + + /** Partially updates admin notification preferences and returns the current row for empty updates. */ + async updateNotificationPreferences( + adminId: string, + dto: UpdateAdminNotificationPreferencesDto, + ): Promise { + const updatePayload = this.toNotificationPreferenceUpdatePayload(dto); + + if (Object.keys(updatePayload).length === 0) { + return this.getNotificationPreferences(adminId); + } + + await this.getNotificationPreferences(adminId); + const updated = await this.adminNotificationPreferenceAction.updateByUserId(adminId, updatePayload); + if (updated) { + return this.toNotificationPreferenceResponse(updated); + } + + throw new ConflictException(SYS_MSG.ADMIN_NOTIFICATION_PREFERENCES_UPDATE_FAILED); + } + private async logPasswordChangeFailure(adminId: string, failedStage: string): Promise { await this.logService.logAction({ admin_id: adminId, @@ -166,4 +217,34 @@ export class AdminProfileService { created_at: user.created_at, }; } + + private toNotificationPreferenceResponse(preference: { + general_notifications: boolean; + push_email: boolean; + }): AdminNotificationPreferencesResponse { + return { + generalNotifications: preference.general_notifications, + pushEmail: preference.push_email, + }; + } + + private toNotificationPreferenceUpdatePayload( + dto: UpdateAdminNotificationPreferencesDto, + ): AdminNotificationPreferenceUpdatePayload { + return ADMIN_NOTIFICATION_PREFERENCE_FIELDS.reduce((payload, field) => { + if (dto[field] !== undefined) { + payload[field] = dto[field]; + } + + return payload; + }, {}); + } + + private isUniqueViolation(error: unknown): boolean { + if (!error || typeof error !== 'object') { + return false; + } + + return (error as { driverError?: { code?: string } }).driverError?.code === '23505'; + } } diff --git a/src/modules/admin/profile/docs/admin-profile-swagger.doc.ts b/src/modules/admin/profile/docs/admin-profile-swagger.doc.ts index dc37b26f..9244eb10 100644 --- a/src/modules/admin/profile/docs/admin-profile-swagger.doc.ts +++ b/src/modules/admin/profile/docs/admin-profile-swagger.doc.ts @@ -10,6 +10,7 @@ import { } from '@nestjs/swagger'; import * as SYS_MSG from '../../../../constants/system.messages'; import { ChangeAdminPasswordDto } from '../dto/change-admin-password.dto'; +import { UpdateAdminNotificationPreferencesDto } from '../dto/update-admin-notification-preferences.dto'; import { UpdateAdminProfileDto } from '../dto/update-admin-profile.dto'; const profileExample = { @@ -257,3 +258,123 @@ export function ChangeAdminPasswordDocs() { }), ); } + +export function GetAdminNotificationPreferencesDocs() { + return applyDecorators( + ApiBearerAuth('JWT'), + ApiOperation({ + summary: 'Get authenticated admin notification preferences', + description: + 'Returns the current authenticated admin notification preferences for the Profile > Notification Preferences tab. ' + + 'If no preferences row exists yet, the service creates one on the fly with both settings enabled and returns it. ' + + 'The endpoint is always scoped to the authenticated admin and never exposes another user row.', + }), + ApiOkResponse({ + description: 'Admin notification preferences retrieved successfully', + schema: { + example: { + success: true, + statusCode: HttpStatus.OK, + message: SYS_MSG.ADMIN_NOTIFICATION_PREFERENCES_RETRIEVED_SUCCESSFULLY, + data: { + generalNotifications: true, + pushEmail: true, + }, + }, + }, + }), + ApiUnauthorizedResponse({ + description: 'Missing or invalid JWT', + schema: { + example: { + success: false, + statusCode: HttpStatus.UNAUTHORIZED, + error: 'UnauthorizedException', + message: SYS_MSG.AUTH_UNAUTHENTICATED_MESSAGE, + }, + }, + }), + ApiResponse({ + status: HttpStatus.FORBIDDEN, + description: 'Authenticated but role is not admin/super_admin', + schema: { + example: { + success: false, + statusCode: HttpStatus.FORBIDDEN, + error: 'ForbiddenException', + message: SYS_MSG.ADMIN_ACCESS_DENIED, + }, + }, + }), + ); +} + +export function UpdateAdminNotificationPreferencesDocs() { + return applyDecorators( + ApiBearerAuth('JWT'), + ApiOperation({ + summary: 'Update authenticated admin notification preferences', + description: + 'Accepts partial updates for general_notifications and push_email. ' + + 'Unknown fields are rejected with HTTP 422. ' + + 'An empty request body is valid and returns HTTP 200 with the current preferences unchanged. ' + + 'If the row does not exist, it is created with defaults before the update is applied.', + }), + ApiBody({ type: UpdateAdminNotificationPreferencesDto }), + ApiOkResponse({ + description: 'Admin notification preferences updated successfully', + schema: { + example: { + success: true, + statusCode: HttpStatus.OK, + message: SYS_MSG.ADMIN_NOTIFICATION_PREFERENCES_UPDATED_SUCCESSFULLY, + data: { + generalNotifications: true, + pushEmail: false, + }, + }, + }, + }), + ApiResponse({ + status: HttpStatus.UNPROCESSABLE_ENTITY, + description: 'Validation failed', + schema: { + example: { + success: false, + statusCode: HttpStatus.UNPROCESSABLE_ENTITY, + error: 'UnprocessableEntityException', + message: SYS_MSG.VALIDATION_FAILED, + details: [ + { + property: 'general_notifications', + constraints: { isBoolean: 'general_notifications must be a boolean value' }, + }, + ], + }, + }, + }), + ApiUnauthorizedResponse({ + description: 'Missing or invalid JWT', + schema: { + example: { + success: false, + statusCode: HttpStatus.UNAUTHORIZED, + error: 'UnauthorizedException', + message: SYS_MSG.AUTH_UNAUTHENTICATED_MESSAGE, + }, + }, + }), + ApiResponse({ + status: HttpStatus.FORBIDDEN, + description: 'Authenticated but role is not admin/super_admin', + schema: { + example: { + success: false, + statusCode: HttpStatus.FORBIDDEN, + error: 'ForbiddenException', + message: SYS_MSG.ADMIN_ACCESS_DENIED, + }, + }, + }), + ); +} diff --git a/src/modules/admin/profile/dto/update-admin-notification-preferences.dto.ts b/src/modules/admin/profile/dto/update-admin-notification-preferences.dto.ts new file mode 100644 index 00000000..bdc91efa --- /dev/null +++ b/src/modules/admin/profile/dto/update-admin-notification-preferences.dto.ts @@ -0,0 +1,14 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsBoolean, IsOptional } from 'class-validator'; + +export class UpdateAdminNotificationPreferencesDto { + @ApiPropertyOptional({ example: true }) + @IsOptional() + @IsBoolean() + general_notifications?: boolean; + + @ApiPropertyOptional({ example: true }) + @IsOptional() + @IsBoolean() + push_email?: boolean; +} \ No newline at end of file diff --git a/src/modules/admin/profile/entities/admin-notification-preference.entity.ts b/src/modules/admin/profile/entities/admin-notification-preference.entity.ts new file mode 100644 index 00000000..653d3dd1 --- /dev/null +++ b/src/modules/admin/profile/entities/admin-notification-preference.entity.ts @@ -0,0 +1,20 @@ +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import { BaseEntity } from '../../../../common/entities/base.entity'; +import { User } from '../../../users/entities/user.entity'; + +@Entity('admin_notification_preferences') +export class AdminNotificationPreference extends BaseEntity { + @Index({ unique: true }) + @Column({ type: 'uuid' }) + user_id: string; + + @Column({ type: 'boolean', default: true }) + general_notifications: boolean; + + @Column({ type: 'boolean', default: true }) + push_email: boolean; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'user_id' }) + user: User; +} \ No newline at end of file diff --git a/src/modules/admin/profile/interfaces/admin-notification-preference.interface.ts b/src/modules/admin/profile/interfaces/admin-notification-preference.interface.ts new file mode 100644 index 00000000..184b97c3 --- /dev/null +++ b/src/modules/admin/profile/interfaces/admin-notification-preference.interface.ts @@ -0,0 +1,12 @@ +import { AdminNotificationPreference } from '../entities/admin-notification-preference.entity'; + +export type AdminNotificationPreferenceUpdatePayload = Partial< + Pick +>; + +export const ADMIN_NOTIFICATION_PREFERENCE_FIELDS = ['general_notifications', 'push_email'] as const; + +export interface AdminNotificationPreferencesResponse { + generalNotifications: boolean; + pushEmail: boolean; +} \ No newline at end of file diff --git a/src/modules/admin/profile/tests/admin-notification-preference.action.spec.ts b/src/modules/admin/profile/tests/admin-notification-preference.action.spec.ts new file mode 100644 index 00000000..0ab79904 --- /dev/null +++ b/src/modules/admin/profile/tests/admin-notification-preference.action.spec.ts @@ -0,0 +1,48 @@ +import { Repository } from 'typeorm'; +import { AdminNotificationPreferenceModelAction } from '../actions/admin-notification-preference.action'; +import { AdminNotificationPreference } from '../entities/admin-notification-preference.entity'; + +describe('AdminNotificationPreferenceModelAction', () => { + it('creates defaults without a transaction by default', async () => { + const action = new AdminNotificationPreferenceModelAction({} as Repository); + const createSpy = jest.spyOn(action, 'create').mockResolvedValue({} as AdminNotificationPreference); + + await action.createDefaultForUser('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'); + + expect(createSpy).toHaveBeenCalledWith( + expect.objectContaining({ + createPayload: { user_id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' }, + transactionOptions: { useTransaction: false }, + }), + ); + }); + + it('creates defaults within a supplied transaction manager', async () => { + const transaction = {} as never; + const action = new AdminNotificationPreferenceModelAction({} as Repository); + const createSpy = jest.spyOn(action, 'create').mockResolvedValue({} as AdminNotificationPreference); + + await action.createDefaultForUser('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', transaction); + + expect(createSpy).toHaveBeenCalledWith( + expect.objectContaining({ + transactionOptions: { useTransaction: true, transaction }, + }), + ); + }); + + it('updates by user id without a transaction', async () => { + const action = new AdminNotificationPreferenceModelAction({} as Repository); + const updateSpy = jest.spyOn(action, 'update').mockResolvedValue({} as AdminNotificationPreference); + + await action.updateByUserId('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', { push_email: false }); + + expect(updateSpy).toHaveBeenCalledWith( + expect.objectContaining({ + identifierOptions: { user_id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' }, + updatePayload: { push_email: false }, + transactionOptions: { useTransaction: false }, + }), + ); + }); +}); \ No newline at end of file diff --git a/src/modules/admin/profile/tests/admin-profile.controller.spec.ts b/src/modules/admin/profile/tests/admin-profile.controller.spec.ts index 0e99fc76..9c105d94 100644 --- a/src/modules/admin/profile/tests/admin-profile.controller.spec.ts +++ b/src/modules/admin/profile/tests/admin-profile.controller.spec.ts @@ -3,11 +3,14 @@ import * as SYS_MSG from '../../../../constants/system.messages'; import { AuthenticatedUser } from '../../../../common/decorators/current-user.decorator'; import { UserRole } from '../../../users/enums/user-role.enum'; import { UpdateAdminProfileDto } from '../dto/update-admin-profile.dto'; +import { UpdateAdminNotificationPreferencesDto } from '../dto/update-admin-notification-preferences.dto'; import { AdminProfileController } from '../admin-profile.controller'; import { AdminProfileService } from '../admin-profile.service'; const mockAdminProfileService = { getProfile: jest.fn(), + getNotificationPreferences: jest.fn(), + updateNotificationPreferences: jest.fn(), updateProfile: jest.fn(), changePassword: jest.fn(), }; @@ -53,11 +56,27 @@ describe('AdminProfileController', () => { }), }); + const createNotificationPreferencesValidationPipe = () => + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + transformOptions: { enableImplicitConversion: false }, + expectedType: UpdateAdminNotificationPreferencesDto, + validationError: { target: false, value: false }, + exceptionFactory: (errors: ValidationError[]) => + new UnprocessableEntityException({ + success: false, + statusCode: HttpStatus.UNPROCESSABLE_ENTITY, + error: 'UnprocessableEntityException', + message: SYS_MSG.VALIDATION_FAILED, + details: errors, + }), + }); + beforeEach(() => { jest.clearAllMocks(); - controller = new AdminProfileController( - mockAdminProfileService as unknown as AdminProfileService, - ); + controller = new AdminProfileController(mockAdminProfileService as unknown as AdminProfileService); }); describe('GET /admin/profile', () => { @@ -97,9 +116,13 @@ describe('AdminProfileController', () => { message: SYS_MSG.ADMIN_PROFILE_UPDATED_SUCCESSFULLY, data: updated, }); - expect(mockAdminProfileService.updateProfile).toHaveBeenCalledWith(ADMIN_ID, { - full_name: 'Jane Updated', - }, 'admin'); + expect(mockAdminProfileService.updateProfile).toHaveBeenCalledWith( + ADMIN_ID, + { + full_name: 'Jane Updated', + }, + 'admin', + ); }); it('AC-05: empty body returns HTTP 200 with unchanged profile', async () => { @@ -113,6 +136,92 @@ describe('AdminProfileController', () => { }); }); + describe('GET /admin/profile/notification-preferences', () => { + it('AC-01: returns authenticated admin notification preferences', async () => { + mockAdminProfileService.getNotificationPreferences.mockResolvedValue({ + generalNotifications: true, + pushEmail: true, + }); + + const result = await controller.getNotificationPreferences(ADMIN_USER); + + expect(result).toEqual({ + statusCode: HttpStatus.OK, + message: SYS_MSG.ADMIN_NOTIFICATION_PREFERENCES_RETRIEVED_SUCCESSFULLY, + data: { + generalNotifications: true, + pushEmail: true, + }, + }); + expect(mockAdminProfileService.getNotificationPreferences).toHaveBeenCalledWith(ADMIN_ID); + }); + }); + + describe('PATCH /admin/profile/notification-preferences', () => { + it('AC-02: updates admin preferences and returns HTTP 200', async () => { + mockAdminProfileService.updateNotificationPreferences.mockResolvedValue({ + generalNotifications: true, + pushEmail: false, + }); + + const result = await controller.updateNotificationPreferences(ADMIN_USER, { + push_email: false, + }); + + expect(result).toEqual({ + statusCode: HttpStatus.OK, + message: SYS_MSG.ADMIN_NOTIFICATION_PREFERENCES_UPDATED_SUCCESSFULLY, + data: { + generalNotifications: true, + pushEmail: false, + }, + }); + expect(mockAdminProfileService.updateNotificationPreferences).toHaveBeenCalledWith(ADMIN_ID, { + push_email: false, + }); + }); + + it('AC-05: empty body returns HTTP 200 with unchanged preferences', async () => { + mockAdminProfileService.updateNotificationPreferences.mockResolvedValue({ + generalNotifications: true, + pushEmail: true, + }); + + const result = await controller.updateNotificationPreferences(ADMIN_USER, {}); + + expect(result.statusCode).toBe(HttpStatus.OK); + expect(mockAdminProfileService.updateNotificationPreferences).toHaveBeenCalledWith(ADMIN_ID, {}); + }); + + it('AC-06: validation pipe rejects unknown keys with 422 envelope', async () => { + try { + await createNotificationPreferencesValidationPipe().transform( + { push_email: false, extra_field: 'ignored' }, + { type: 'body', metatype: UpdateAdminNotificationPreferencesDto, data: '' }, + ); + fail('Expected validation to throw'); + } catch (error) { + expect(error).toBeInstanceOf(UnprocessableEntityException); + expect((error as UnprocessableEntityException).getStatus()).toBe(HttpStatus.UNPROCESSABLE_ENTITY); + expect((error as UnprocessableEntityException).getResponse()).toMatchObject({ + success: false, + statusCode: HttpStatus.UNPROCESSABLE_ENTITY, + error: 'UnprocessableEntityException', + message: SYS_MSG.VALIDATION_FAILED, + }); + } + }); + + it('AC-06: validation pipe accepts an empty body', async () => { + const result = (await createNotificationPreferencesValidationPipe().transform( + {}, + { type: 'body', metatype: UpdateAdminNotificationPreferencesDto, data: '' }, + )) as UpdateAdminNotificationPreferencesDto; + + expect(result).toEqual({}); + }); + }); + describe('PATCH /admin/profile/password', () => { it('AC-01: returns HTTP 200 when old password is correct and new password is valid', async () => { mockAdminProfileService.changePassword.mockResolvedValue(undefined); @@ -138,11 +247,11 @@ describe('AdminProfileController', () => { it('AC-07: controller never includes password values in response payload', async () => { mockAdminProfileService.changePassword.mockResolvedValue(undefined); - const result = await controller.changePassword(ADMIN_ID, { + const result = (await controller.changePassword(ADMIN_ID, { old_password: 'CurrentAdmin@123', new_password: 'NewAdmin!789', confirm_password: 'NewAdmin!789', - }) as unknown as Record; + })) as unknown as Record; const serialized = JSON.stringify(result); expect(serialized).not.toContain('CurrentAdmin@123'); diff --git a/src/modules/admin/profile/tests/admin-profile.service.spec.ts b/src/modules/admin/profile/tests/admin-profile.service.spec.ts index 4cc44b1d..474d8393 100644 --- a/src/modules/admin/profile/tests/admin-profile.service.spec.ts +++ b/src/modules/admin/profile/tests/admin-profile.service.spec.ts @@ -7,11 +7,13 @@ import { import * as bcrypt from 'bcrypt'; import { Test, TestingModule } from '@nestjs/testing'; import * as SYS_MSG from '../../../../constants/system.messages'; +import { AdminNotificationPreferenceModelAction } from '../actions/admin-notification-preference.action'; import { UserRoleModelAction } from '../../../users/actions/user-role.action'; import { UserRole } from '../../../users/enums/user-role.enum'; import { AdminProfileModelAction } from '../actions/admin-profile.action'; import { AdminProfileService } from '../admin-profile.service'; import { AdminProfileActionType } from '../enums/admin-profile-action-type.enum'; +import { UpdateAdminNotificationPreferencesDto } from '../dto/update-admin-notification-preferences.dto'; import { LogService } from '../services/log.service'; jest.mock('bcrypt', () => ({ @@ -25,6 +27,12 @@ const mockAdminProfileAction = { updatePasswordAndRevokeSessions: jest.fn(), }; +const mockAdminNotificationPreferenceAction = { + findByUserId: jest.fn(), + createDefaultForUser: jest.fn(), + updateByUserId: jest.fn(), +}; + const mockUserRoleModelAction = { resolveHighestRole: jest.fn(), }; @@ -57,6 +65,7 @@ describe('AdminProfileService', () => { providers: [ AdminProfileService, { provide: AdminProfileModelAction, useValue: mockAdminProfileAction }, + { provide: AdminNotificationPreferenceModelAction, useValue: mockAdminNotificationPreferenceAction }, { provide: UserRoleModelAction, useValue: mockUserRoleModelAction }, { provide: LogService, useValue: mockLogService }, ], @@ -371,4 +380,130 @@ describe('AdminProfileService', () => { }); }); }); + + describe('notification preferences', () => { + const current = { + id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + user_id: ADMIN_ID, + general_notifications: true, + push_email: true, + created_at: new Date('2026-05-29T10:30:00.000Z'), + updated_at: new Date('2026-05-29T10:30:00.000Z'), + }; + + beforeEach(() => { + mockAdminNotificationPreferenceAction.findByUserId.mockReset(); + mockAdminNotificationPreferenceAction.createDefaultForUser.mockReset(); + mockAdminNotificationPreferenceAction.updateByUserId.mockReset(); + }); + + it('AC-01: returns current preferences for the authenticated admin', async () => { + mockAdminNotificationPreferenceAction.findByUserId.mockResolvedValue(current); + + const result = await service.getNotificationPreferences(ADMIN_ID); + + expect(result).toEqual({ generalNotifications: true, pushEmail: true }); + expect(mockAdminNotificationPreferenceAction.createDefaultForUser).not.toHaveBeenCalled(); + }); + + it('AC-03: creates default preferences when none exist', async () => { + mockAdminNotificationPreferenceAction.findByUserId.mockResolvedValue(null); + mockAdminNotificationPreferenceAction.createDefaultForUser.mockResolvedValue(current); + + const result = await service.getNotificationPreferences(ADMIN_ID); + + expect(mockAdminNotificationPreferenceAction.createDefaultForUser).toHaveBeenCalledWith(ADMIN_ID); + expect(result).toEqual({ generalNotifications: true, pushEmail: true }); + }); + + it('AC-04: returns concurrently created defaults after a unique violation race', async () => { + mockAdminNotificationPreferenceAction.findByUserId + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(current); + mockAdminNotificationPreferenceAction.createDefaultForUser.mockRejectedValue({ + driverError: { code: '23505' }, + }); + + const result = await service.getNotificationPreferences(ADMIN_ID); + + expect(mockAdminNotificationPreferenceAction.findByUserId).toHaveBeenCalledTimes(2); + expect(result).toEqual({ generalNotifications: true, pushEmail: true }); + }); + + it('AC-02: updates a single preference field and returns the updated preferences', async () => { + mockAdminNotificationPreferenceAction.findByUserId.mockResolvedValue(current); + mockAdminNotificationPreferenceAction.updateByUserId.mockResolvedValue({ + ...current, + push_email: false, + }); + + const result = await service.updateNotificationPreferences(ADMIN_ID, { + push_email: false, + }); + + expect(mockAdminNotificationPreferenceAction.updateByUserId).toHaveBeenCalledWith(ADMIN_ID, { + push_email: false, + }); + expect(result).toEqual({ generalNotifications: true, pushEmail: false }); + }); + + it('AC-03 / EC-03: allows both preferences to be false at the same time', async () => { + mockAdminNotificationPreferenceAction.findByUserId.mockResolvedValue(current); + mockAdminNotificationPreferenceAction.updateByUserId.mockResolvedValue({ + ...current, + general_notifications: false, + push_email: false, + }); + + const result = await service.updateNotificationPreferences(ADMIN_ID, { + general_notifications: false, + push_email: false, + }); + + expect(result).toEqual({ generalNotifications: false, pushEmail: false }); + }); + + it('AC-05: empty body returns unchanged preferences', async () => { + mockAdminNotificationPreferenceAction.findByUserId.mockResolvedValue(current); + + const result = await service.updateNotificationPreferences(ADMIN_ID, {} as UpdateAdminNotificationPreferencesDto); + + expect(mockAdminNotificationPreferenceAction.updateByUserId).not.toHaveBeenCalled(); + expect(result).toEqual({ generalNotifications: true, pushEmail: true }); + }); + + it('AC-05: empty body creates defaults when row does not exist', async () => { + mockAdminNotificationPreferenceAction.findByUserId.mockResolvedValue(null); + mockAdminNotificationPreferenceAction.createDefaultForUser.mockResolvedValue(current); + + const result = await service.updateNotificationPreferences(ADMIN_ID, {} as UpdateAdminNotificationPreferencesDto); + + expect(mockAdminNotificationPreferenceAction.createDefaultForUser).toHaveBeenCalledWith(ADMIN_ID); + expect(result).toEqual({ generalNotifications: true, pushEmail: true }); + }); + + it('SEC-01: scopes preference reads and writes to the provided user id', async () => { + mockAdminNotificationPreferenceAction.findByUserId.mockResolvedValue(current); + mockAdminNotificationPreferenceAction.updateByUserId.mockResolvedValue({ + ...current, + push_email: false, + }); + + await service.updateNotificationPreferences(ADMIN_ID, { push_email: false }); + + expect(mockAdminNotificationPreferenceAction.findByUserId).toHaveBeenCalledWith(ADMIN_ID); + expect(mockAdminNotificationPreferenceAction.updateByUserId).toHaveBeenCalledWith(ADMIN_ID, { + push_email: false, + }); + }); + + it('throws a conflict when the update affects no row', async () => { + mockAdminNotificationPreferenceAction.findByUserId.mockResolvedValue(current); + mockAdminNotificationPreferenceAction.updateByUserId.mockResolvedValue(null); + + await expect( + service.updateNotificationPreferences(ADMIN_ID, { push_email: false }), + ).rejects.toThrow(SYS_MSG.ADMIN_NOTIFICATION_PREFERENCES_UPDATE_FAILED); + }); + }); }); diff --git a/src/modules/admin/users/admin-users.module.ts b/src/modules/admin/users/admin-users.module.ts index 11e279fb..d42c1f4b 100644 --- a/src/modules/admin/users/admin-users.module.ts +++ b/src/modules/admin/users/admin-users.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { RolesGuard } from '../../auth/guards/roles.guard'; import { UsersModule } from '../../users/users.module'; import { AdminAuthModule } from '../auth/admin-auth.module'; +import { AdminProfileModule } from '../profile/admin-profile.module'; import { AdminUsersListAction } from './actions/admin-users-list.action'; import { AdminUserDetailAction } from './actions/admin-user-detail.action'; import { AdminUsersController } from './admin-users.controller'; @@ -9,7 +10,7 @@ import { AdminUsersService } from './admin-users.service'; import { LogService } from '../profile/services/log.service'; @Module({ - imports: [AdminAuthModule, UsersModule], + imports: [AdminAuthModule, UsersModule, AdminProfileModule], controllers: [AdminUsersController], providers: [AdminUsersService, AdminUsersListAction, AdminUserDetailAction, RolesGuard, LogService], }) diff --git a/src/modules/admin/users/admin-users.service.ts b/src/modules/admin/users/admin-users.service.ts index 018d2b42..5e71f6db 100644 --- a/src/modules/admin/users/admin-users.service.ts +++ b/src/modules/admin/users/admin-users.service.ts @@ -19,6 +19,7 @@ import { UserAccountStatus } from '../../users/enums/user-account-status.enum'; import { User } from '../../users/entities/user.entity'; import { ACCOUNT_DELETION_QUEUE } from '../../users/processors/account-deletion.processor'; import { RedisService } from '../../redis/redis.service'; +import { AdminNotificationPreferenceModelAction } from '../profile/actions/admin-notification-preference.action'; import { LogService } from '../profile/services/log.service'; import { AdminProfileActionType } from '../profile/enums/admin-profile-action-type.enum'; import { ACTIVE_WINDOW_DAYS, AdminUsersListAction } from './actions/admin-users-list.action'; @@ -41,6 +42,7 @@ export class AdminUsersService { private readonly userModelAction: UserModelAction, private readonly userRoleModelAction: UserRoleModelAction, private readonly userSessionModelAction: UserSessionModelAction, + private readonly adminNotificationPreferenceAction: AdminNotificationPreferenceModelAction, private readonly dataSource: DataSource, private readonly adminUsersListAction: AdminUsersListAction, private readonly adminUserDetailAction: AdminUserDetailAction, @@ -75,6 +77,8 @@ export class AdminUsersService { createPayload: { user_id: user.id, role: dto.role }, transactionOptions: { useTransaction: true, transaction: manager }, }); + + await this.adminNotificationPreferenceAction.createDefaultForUser(user.id, manager); }); } catch (error: unknown) { if (this.isUniqueEmailConflict(error)) { diff --git a/src/modules/admin/users/tests/admin-users.service.spec.ts b/src/modules/admin/users/tests/admin-users.service.spec.ts index b6b8f11d..0e638942 100644 --- a/src/modules/admin/users/tests/admin-users.service.spec.ts +++ b/src/modules/admin/users/tests/admin-users.service.spec.ts @@ -7,6 +7,7 @@ import { UserModelAction } from '../../../users/actions/user.action'; import { UserRoleModelAction } from '../../../users/actions/user-role.action'; import { UsersService } from '../../../users/users.service'; import { UserRole } from '../../../users/enums/user-role.enum'; +import { AdminNotificationPreferenceModelAction } from '../../profile/actions/admin-notification-preference.action'; import { UserAccountStatus } from '../../../users/enums/user-account-status.enum'; import * as SYS_MSG from '../../../../constants/system.messages'; import { UserSessionModelAction } from '../../../users/actions/user-session.action'; @@ -22,6 +23,7 @@ jest.mock('bcrypt'); const mockUsersService = { findByEmail: jest.fn() }; const mockUserModelAction = { create: jest.fn(), findById: jest.fn(), update: jest.fn() }; const mockUserRoleModelAction = { create: jest.fn() }; +const mockAdminNotificationPreferenceAction = { createDefaultForUser: jest.fn() }; const mockUserSessionModelAction = { revokeAllUserSessionsInDb: jest.fn() }; const mockAdminUserDetailAction = { findUserWithDetails: jest.fn() }; const mockLogService = { logAction: jest.fn() }; @@ -65,6 +67,7 @@ describe('AdminUsersService', () => { { provide: UsersService, useValue: mockUsersService }, { provide: UserModelAction, useValue: mockUserModelAction }, { provide: UserRoleModelAction, useValue: mockUserRoleModelAction }, + { provide: AdminNotificationPreferenceModelAction, useValue: mockAdminNotificationPreferenceAction }, { provide: UserSessionModelAction, useValue: mockUserSessionModelAction }, { provide: AdminUserDetailAction, useValue: mockAdminUserDetailAction }, { provide: AdminUsersListAction, useValue: {} }, @@ -83,6 +86,7 @@ describe('AdminUsersService', () => { mockUsersService.findByEmail.mockResolvedValue(null); mockUserModelAction.create.mockResolvedValue(CREATED_USER); mockUserRoleModelAction.create.mockResolvedValue(undefined); + mockAdminNotificationPreferenceAction.createDefaultForUser.mockResolvedValue(undefined); const result = await service.createAdmin(CREATE_ADMIN_DTO); @@ -101,12 +105,17 @@ describe('AdminUsersService', () => { createPayload: { user_id: CREATED_USER.id, role: UserRole.ADMIN }, }), ); + expect(mockAdminNotificationPreferenceAction.createDefaultForUser).toHaveBeenCalledWith( + CREATED_USER.id, + expect.any(Object), + ); }); it('hashes the password with bcrypt 12 rounds before storing', async () => { mockUsersService.findByEmail.mockResolvedValue(null); mockUserModelAction.create.mockResolvedValue(CREATED_USER); mockUserRoleModelAction.create.mockResolvedValue(undefined); + mockAdminNotificationPreferenceAction.createDefaultForUser.mockResolvedValue(undefined); await service.createAdmin(CREATE_ADMIN_DTO); @@ -119,6 +128,7 @@ describe('AdminUsersService', () => { mockUsersService.findByEmail.mockResolvedValue(null); mockUserModelAction.create.mockResolvedValue(CREATED_USER); mockUserRoleModelAction.create.mockResolvedValue(undefined); + mockAdminNotificationPreferenceAction.createDefaultForUser.mockResolvedValue(undefined); await service.createAdmin({ ...CREATE_ADMIN_DTO, role: UserRole.SUPER_ADMIN }); @@ -162,6 +172,7 @@ describe('AdminUsersService', () => { await expect(service.createAdmin(CREATE_ADMIN_DTO)).rejects.toThrow(); expect(mockUserRoleModelAction.create).not.toHaveBeenCalled(); + expect(mockAdminNotificationPreferenceAction.createDefaultForUser).not.toHaveBeenCalled(); }); }); From 5328f01c1203cf76d99484b1bd49e865efe6ccfe Mon Sep 17 00:00:00 2001 From: John Ughiovhe Date: Mon, 8 Jun 2026 12:14:38 +0100 Subject: [PATCH 03/37] chore:update SEED_ADMIN_EMAIL type to allow empty string; improve formatting in email templates for better readability --- src/config/env.ts | 2 +- src/email/templates/delete-account.hbs | 20 ++++- src/email/templates/notification-alert.hbs | 26 +++++- src/email/templates/payment-failed.hbs | 19 +++- .../templates/subscription-cancelled.hbs | 24 +++++- src/email/templates/welcome.hbs | 86 +++++++++++++++---- 6 files changed, 144 insertions(+), 33 deletions(-) diff --git a/src/config/env.ts b/src/config/env.ts index 4b3ab082..dc3e17a7 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -36,7 +36,7 @@ const envSchema = z.object({ CONTACT_ADMIN_EMAIL: z.string().email().default('useseil@hng14.com'), - SEED_ADMIN_EMAIL: z.string().email().default(''), + SEED_ADMIN_EMAIL: z.union([z.literal(''), z.string().email()]).default(''), SEED_ADMIN_PASSWORD: z.string().default(''), UPLOAD_STORAGE_ENDPOINT: z.string().default(''), diff --git a/src/email/templates/delete-account.hbs b/src/email/templates/delete-account.hbs index 40caa6f7..aab4a1b3 100644 --- a/src/email/templates/delete-account.hbs +++ b/src/email/templates/delete-account.hbs @@ -1,9 +1,21 @@
-

Delete Account Request

+

+ Delete Account Request +

Hi {{name}},

-

We received a request to permanently delete your Seil account.

-

If you initiated this request, no further action is required. Your account will be deleted according to our standard -

If you did not request this change, please reset your password immediately or contact our + We received a request to permanently delete your Seil account. +

+

+ If you initiated this request, no further action is required. Your account will be deleted + according to our standard process. +

+

+ If you did not request this change, please reset your password immediately or contact our + support team. +

-

Notification alert

+

+ Notification alert +

Hi {{name}},

-

You have {{unreadCount}} new notification(s) from FlowBrand. Open the app to stay up to date.

- To manage your notification preferences, + You have {{unreadCount}} new notification(s) from FlowBrand. + Open the app to stay up to date. +

+
+ View notifications + +

+ To manage your notification preferences, + + click here + . +

-

We can't process your payment

+

+ We can't process your payment +

Hi {{name}},

-

We're having some trouble collecting your Seil premium payment. Please take a moment to review your payment det +

+ We're having some trouble collecting your Seil premium payment. Please take a moment to + review your payment details and double check that there was money in your associated account. + We'll try to process the payment again in a few days. +

{{#if failureReason}}

Reason: {{failureReason}}

{{/if}} @@ -13,7 +21,12 @@
- + Update payment details +
diff --git a/src/email/templates/subscription-cancelled.hbs b/src/email/templates/subscription-cancelled.hbs index ae47e7ee..adf4e3a2 100644 --- a/src/email/templates/subscription-cancelled.hbs +++ b/src/email/templates/subscription-cancelled.hbs @@ -1,9 +1,20 @@
-

Your subscription has been canceled

+

+ Your subscription has been canceled +

Hi {{name}},

-

We've confirmed your cancellation. You'll continue to have full access to Seil premium until {{accessUntil -

If you didn't mean to cancel, you can restart your plan at any time before your expiry date, you'll pick up right +

+ We've confirmed your cancellation. You'll continue to have full access to Seil premium + until {{accessUntil}}. After that date, your account will move to the free plan and you will not + be charged again. +

+

+ If you didn't mean to cancel, you can restart your plan at any time before your expiry date, + you'll pick up right where you left off. +

- + Reactivate my plan +
diff --git a/src/email/templates/welcome.hbs b/src/email/templates/welcome.hbs index 53ce13ea..caa6a723 100644 --- a/src/email/templates/welcome.hbs +++ b/src/email/templates/welcome.hbs @@ -1,19 +1,50 @@

-

Welcome to Seil {{name}}

-

Your custom sales funnel is just a few minutes away

-

We are thrilled to have you on board. Let's start transforming your digital presence into a high conversion eng +

+ Welcome to Seil {{name}} +

+

+ Your custom sales funnel is just a few minutes away +

+

+ We are thrilled to have you on board. Let's start transforming your digital presence into a + high conversion engine today +

- + + - + + -
Answer a few questions
+
1
+
+ Answer a few questions +
We build your custom funnel
+
2
+
+ We build your custom funnel +
+
3
+
Get your action plan
@@ -24,7 +55,12 @@
- + Get my strategy plan +
@@ -32,24 +68,40 @@
-

Your answers are auto-saved — leave and come back anytime

+

+ Your answers are auto-saved — leave and come back anytime +

-

Before you start, keep in mind:

+

+ Before you start, keep in mind: +

- - + + - - + + - - + +
There are no wrong answers — just describe how your business works right now + ✓ + + There are no wrong answers — just describe how your business works right now +
Your answers are saved automatically so you can pick up where you left off + ✓ + + Your answers are saved automatically so you can pick up where you left off +
The whole thing takes less than 8 minutes on mobile + ✓ + + The whole thing takes less than 8 minutes on mobile +
+ + + + + + +
+ Go to dashboard +
+ + diff --git a/src/email/templates/weekly-digest.hbs b/src/email/templates/weekly-digest.hbs index 8b7f004a..ece181a9 100644 --- a/src/email/templates/weekly-digest.hbs +++ b/src/email/templates/weekly-digest.hbs @@ -6,6 +6,9 @@ {{#if activeStageName}}

Your active stage right now is "{{activeStageName}}".

{{/if}} -

Open SEIL to keep building momentum this week.

+

+ Open SEIL + to keep building momentum this week. +

diff --git a/src/email/tests/template.service.spec.ts b/src/email/tests/template.service.spec.ts index 258f88c7..884e878a 100644 --- a/src/email/tests/template.service.spec.ts +++ b/src/email/tests/template.service.spec.ts @@ -12,12 +12,12 @@ const WAITLIST_HBS = `

Hi {{user.name}}

You are on the waitlist

`; const CONTACT_CONFIRMATION_HBS = `

Hi {{fullName}}

We've received your message

`; const CONTACT_ADMIN_HBS = `

New message from {{fullName}}

{{message}}

`; const PASSWORD_RESET_HBS = `

Hi {{fullName}}

Your reset code is {{otpCode}}

`; -const FUNNEL_READY_HBS = `

Hi {{name}}

Your funnel for {{businessName}} is ready

`; +const FUNNEL_READY_HBS = `

Hi {{name}}

Your funnel for {{businessName}} is ready

Open SEIL

`; const STAGE_UNLOCKED_HBS = `

Hi {{name}}

SEIL {{stageName}} is now active

`; const STAGE_COMPLETED_HBS = `

Hi {{name}}

You completed {{stageName}}

Open SEIL

`; -const WEEKLY_DIGEST_HBS = `

Hi {{name}}

{{completedTasks}} of {{totalTasks}}

`; +const WEEKLY_DIGEST_HBS = `

Hi {{name}}

{{completedTasks}} of {{totalTasks}}

Open SEIL

`; const TEAM_INVITE_HBS = `

Hi {{inviteeName}}

You have been invited to {{teamName}}

Join

`; -const PAYMENT_SUCCESSFUL_HBS = `

Hi {{name}}

Amount: {{amount}}

Ref: {{reference}}

`; +const PAYMENT_SUCCESSFUL_HBS = `

Hi {{name}}

Amount: {{amount}}

Ref: {{reference}}

Go to dashboard`; const PAYMENT_FAILED_HBS = `

Hi {{name}}

Payment failed

Update details`; const SUBSCRIPTION_CANCELLED_HBS = `

Hi {{name}}

Access until {{accessUntil}}

Reactivate`; const NOTIFICATION_ALERT_HBS = `

Hi {{name}}

You have {{unreadCount}} notifications

View`; @@ -105,6 +105,8 @@ describe('TemplateService', () => { expect(html).toContain('Ada'); expect(html).toContain('Acme'); + expect(html).toContain('/dashboard'); + expect(html).toContain('>SEIL<'); expect(subject).toBe('Your funnel is ready'); }); @@ -136,9 +138,28 @@ describe('TemplateService', () => { expect(html).toContain('3'); expect(html).toContain('6'); + expect(html).toContain('/dashboard'); + expect(html).toContain('>SEIL<'); expect(subject).toBe('Your weekly SEIL progress'); }); + it('renders payment-successful with a dashboard CTA button', () => { + const { html, subject } = service.render('payment-successful', { + name: 'Ada', + amount: '₦10,000.00', + cardLast4: null, + cardBrand: null, + reference: 'ref-123', + paidAt: null, + }); + + expect(html).toContain('₦10,000.00'); + expect(html).toContain('ref-123'); + expect(html).toContain('/dashboard'); + expect(html).toContain('Go to dashboard'); + expect(subject).toBe('Payment Successful — Your FlowBrand subscription is now active'); + }); + it('wraps inner content in base layout (contains DOCTYPE)', () => { const { html } = service.render('otp-verification', { fullName: 'Ada', From 588f82e263cff13785471cffaca1f948bd1459f4 Mon Sep 17 00:00:00 2001 From: Prestige Nsien Date: Mon, 8 Jun 2026 19:05:04 +0100 Subject: [PATCH 07/37] fix(funnels): adjust document context budget calculation for funnel generation --- src/modules/funnels/services/funnels.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/funnels/services/funnels.service.ts b/src/modules/funnels/services/funnels.service.ts index bd1e9171..d9d34cb5 100644 --- a/src/modules/funnels/services/funnels.service.ts +++ b/src/modules/funnels/services/funnels.service.ts @@ -573,7 +573,7 @@ export class FunnelsService { if (docs.some((d) => d.status !== UploadDocumentStatus.READY)) throw new UnprocessableEntityException(SYS_MSG.UPLOAD_NOT_READY); - const perDoc = Math.floor(4000 / docs.length); + const perDoc = Math.floor((4001 - docs.length) / docs.length); const parsedJoin = docs .map((d) => (d.parsed_text ?? '').slice(0, perDoc)) .filter(Boolean) From fcc35207fc181f005a58248b55e0ea45bfc90151 Mon Sep 17 00:00:00 2001 From: Prestige Nsien Date: Mon, 8 Jun 2026 19:05:33 +0100 Subject: [PATCH 08/37] fix(funnels): enforce 4000-char budget limit for funnel generation context --- .../services/tests/funnels.service.spec.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/modules/funnels/services/tests/funnels.service.spec.ts b/src/modules/funnels/services/tests/funnels.service.spec.ts index 76cb1ab0..b2f318d8 100644 --- a/src/modules/funnels/services/tests/funnels.service.spec.ts +++ b/src/modules/funnels/services/tests/funnels.service.spec.ts @@ -417,7 +417,7 @@ describe('FunnelsService', () => { const geminiArg: string = mockLlmService.generateFunnelNameWithGemini.mock.calls[0][0]; expect(geminiArg).toContain('B'); - expect(geminiArg.length).toBeLessThanOrEqual(4000 + 1); // content + one '\n' separator + expect(geminiArg.length).toBeLessThanOrEqual(4000); }); it('UPL-02: single document — gets the full 4000-char budget', async () => { @@ -470,7 +470,22 @@ describe('FunnelsService', () => { const saved = queryRunner.manager.save.mock.calls[0][1] as SavedFunnel & { business_context: { business_description: string } }; expect(saved.business_context.business_description).toContain('A'); expect(saved.business_context.business_description).toContain('B'); - expect(saved.business_context.business_description.length).toBeLessThanOrEqual(4001); + expect(saved.business_context.business_description.length).toBeLessThanOrEqual(4000); + }); + + it('UPL-06: four documents — total including newline separators stays within 4000-char budget', async () => { + const longText = 'X'.repeat(5000); + funnelAction.getUploadedDocuments.mockResolvedValue([ + { ...READY_DOC, id: 'u1', parsed_text: longText } as any, + { ...READY_DOC, id: 'u2', parsed_text: longText } as any, + { ...READY_DOC, id: 'u3', parsed_text: longText } as any, + { ...READY_DOC, id: 'u4', parsed_text: longText } as any, + ]); + + await service.createGeneration(USER_ID, { ...UPLOAD_DTO, upload_ids: ['u1', 'u2', 'u3', 'u4'] }); + + const geminiArg: string = mockLlmService.generateFunnelNameWithGemini.mock.calls[0][0]; + expect(geminiArg.length).toBeLessThanOrEqual(4000); }); }); From 4c551a0df79aeca7e45e03e5f09b96e9639c671a Mon Sep 17 00:00:00 2001 From: Prestige Nsien Date: Mon, 8 Jun 2026 19:11:22 +0100 Subject: [PATCH 09/37] fix(funnels): add tests for handling funnel failures and ensure no PII in payload --- .../tests/funnel-generation.processor.spec.ts | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/src/queue/processors/tests/funnel-generation.processor.spec.ts b/src/queue/processors/tests/funnel-generation.processor.spec.ts index 42a019fb..dce1a5f7 100644 --- a/src/queue/processors/tests/funnel-generation.processor.spec.ts +++ b/src/queue/processors/tests/funnel-generation.processor.spec.ts @@ -543,6 +543,32 @@ describe('FunnelGenerationProcessor', () => { await expect(processor.onFailed(job, new Error('boom'))).resolves.toBeUndefined(); }); + + + it('EC-01: skips FAILED write and logs warning when funnel no longer exists in DB', async () => { + const job = makeJob({ attemptsMade: 3 }); + mockFunnelAction.get.mockResolvedValue(null); + const warnSpy = jest.spyOn((processor as any).logger, 'warn'); + + + await processor.onFailed(job, new Error('boom')); + + + expect(mockFunnelAction.update).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledWith(expect.objectContaining({ event: 'funnel_job_failed_skip' })); + }); + + + it('EC-02: funnelAction.update is called exactly once per terminal failure — regression guard for double-write', async () => { + const job = makeJob({ attemptsMade: 3 }); + mockFunnelAction.get.mockResolvedValue({ id: 'funnel-uuid', status: FunnelStatus.GENERATING }); + + + await processor.onFailed(job, new Error('boom')); + + + expect(mockFunnelAction.update).toHaveBeenCalledTimes(1); + }); }); @@ -660,6 +686,37 @@ describe('FunnelGenerationProcessor', () => { expect(mockEventEmitter.emit).not.toHaveBeenCalledWith(APP_EVENTS.FUNNEL_FAILED, expect.anything()); }); + + + it('EC-03: does NOT emit FUNNEL_FAILED when the DB status update throws', async () => { + const job = makeJob({ attemptsMade: 3, opts: { attempts: 3 } }); + mockFunnelAction.get.mockResolvedValue({ id: 'funnel-uuid', status: FunnelStatus.GENERATING }); + mockFunnelAction.update.mockRejectedValueOnce(new Error('DB down')); + + + await processor.onFailed(job, new Error('boom')); + + + expect(mockEventEmitter.emit).not.toHaveBeenCalledWith(APP_EVENTS.FUNNEL_FAILED, expect.anything()); + }); + + + it('SEC-01: FUNNEL_FAILED payload contains only userId and funnelId — no PII', async () => { + const job = makeJob({ attemptsMade: 3, opts: { attempts: 3 } }); + mockFunnelAction.get.mockResolvedValue({ id: 'funnel-uuid', status: FunnelStatus.GENERATING }); + + + await processor.onFailed(job, new Error('boom')); + + + const [, payload] = (mockEventEmitter.emit as jest.Mock).mock.calls[0]; + expect(payload).not.toHaveProperty('email'); + expect(payload).not.toHaveProperty('password'); + expect(payload).not.toHaveProperty('token'); + expect(payload).not.toHaveProperty('hash'); + expect(payload).toHaveProperty('userId', 'user-uuid'); + expect(payload).toHaveProperty('funnelId', 'funnel-uuid'); + }); }); From 7d88a85ff40cdf1b8e1fe3087c56a81741356555 Mon Sep 17 00:00:00 2001 From: elijah arhinful Date: Mon, 8 Jun 2026 18:23:07 +0000 Subject: [PATCH 10/37] feat(audio): add config files --- src/config/env.ts | 3 +++ src/config/llm.config.ts | 2 ++ 2 files changed, 5 insertions(+) diff --git a/src/config/env.ts b/src/config/env.ts index 9b0bd41b..04c717e1 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -51,6 +51,9 @@ const envSchema = z.object({ GROQ_API_KEY: z.string().min(1, 'GROQ_API_KEY is required'), GROQ_MODEL: z.string().default('llama-3.3-70b-versatile'), GROQ_TIMEOUT_MS: z.coerce.number().int().positive().default(60_000), + GROQ_WHISPER_MODEL: z.string().default('whisper-large-v3-turbo'), + + ASSEMBLYAI_API_KEY: z.string().optional(), QUEUE_CONCURRENCY: z.coerce .number() diff --git a/src/config/llm.config.ts b/src/config/llm.config.ts index fbd92d39..97ae4061 100644 --- a/src/config/llm.config.ts +++ b/src/config/llm.config.ts @@ -8,4 +8,6 @@ export const llmConfig = registerAs('llm', () => ({ groqApiKey: env.GROQ_API_KEY, groqModel: env.GROQ_MODEL, groqTimeoutMs: env.GROQ_TIMEOUT_MS, + groqWhisperModel: env.GROQ_WHISPER_MODEL, + assemblyApiKey: env.ASSEMBLYAI_API_KEY, })); From ae2222329b5c04e9ab7eea4ddb2efa274bda43c6 Mon Sep 17 00:00:00 2001 From: elijah arhinful Date: Mon, 8 Jun 2026 18:25:15 +0000 Subject: [PATCH 11/37] feat(audio): add constants --- .env.example | 3 +++ src/constants/redis-keys.ts | 2 ++ src/constants/system.messages.ts | 10 ++++++++++ 3 files changed, 15 insertions(+) diff --git a/.env.example b/.env.example index bbf1a400..a7e7c8ad 100644 --- a/.env.example +++ b/.env.example @@ -63,6 +63,9 @@ GEMINI_TIMEOUT_MS=60000 GROQ_API_KEY= GROQ_MODEL=llama-3.3-70b-versatile GROQ_TIMEOUT_MS=60000 +GROQ_WHISPER_MODEL=whisper-large-v3-turbo + +ASSEMBLYAI_API_KEY= CONTACT_ADMIN_EMAIL=useseil@hng14.com diff --git a/src/constants/redis-keys.ts b/src/constants/redis-keys.ts index 9a937746..ef49405e 100644 --- a/src/constants/redis-keys.ts +++ b/src/constants/redis-keys.ts @@ -20,4 +20,6 @@ export const redisKeys = { adminDashboardFunnelPerformance: () => 'admin-dashboard:funnel-performance', adminDashboardUserStages: () => 'admin-dashboard:user-stages', adminDashboardUserRetention: () => 'admin-dashboard:user-retention', + + voiceSession: (sessionId: string) => `voice_session:${sessionId}`, }; diff --git a/src/constants/system.messages.ts b/src/constants/system.messages.ts index baa4c95a..c76f2a7d 100644 --- a/src/constants/system.messages.ts +++ b/src/constants/system.messages.ts @@ -364,3 +364,13 @@ export const MEMBER_REVOKE_SELF_FORBIDDEN = 'You cannot revoke your own access. export const MEMBER_NOT_FOUND = 'Team member not found.'; export const MEMBER_REVOKED_SUCCESSFULLY = 'Member access revoked successfully.'; export const PASSWORD_VALIDATION_FAILED = 'password must contain at least one uppercase letter, one lowercase letter, one digit, and one special character' + +// Voice Onboarding +export const VOICE_UPLOAD_ACCEPTED = 'Voice recording accepted for processing'; +export const VOICE_SESSION_COMPLETED = 'Voice session completed successfully'; +export const VOICE_INVALID_AUDIO_FORMAT = 'Please upload a WebM, MP3, WAV, OGG, or M4A file.'; +export const VOICE_FILE_TOO_LARGE = 'Recording is too large. Please try a shorter clip.'; +export const VOICE_AUDIO_TOO_LONG = 'Please keep each recording under 2 minutes.'; +export const VOICE_SESSION_EXPIRED = 'Your session has expired. Please start again.'; +export const VOICE_TRANSCRIPTION_EMPTY = 'We couldn\'t understand that recording. Please try again in a quieter environment.'; +export const VOICE_TRANSCRIPTION_UNAVAILABLE = 'Transcription is temporarily unavailable. Please try again in a moment.'; \ No newline at end of file From 57d84749f330a3baefb8164efa099c5f5984106b Mon Sep 17 00:00:00 2001 From: elijah arhinful Date: Mon, 8 Jun 2026 18:27:40 +0000 Subject: [PATCH 12/37] feat(upload): add source to entity --- src/modules/upload/entities/uploaded-document.entity.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/modules/upload/entities/uploaded-document.entity.ts b/src/modules/upload/entities/uploaded-document.entity.ts index 5820164f..d57f105f 100644 --- a/src/modules/upload/entities/uploaded-document.entity.ts +++ b/src/modules/upload/entities/uploaded-document.entity.ts @@ -43,6 +43,9 @@ export class UploadedDocument extends BaseEntity { @Column({ type: 'varchar', length: 200, nullable: true }) failure_reason: string | null; + @Column({ type: 'varchar', default: 'document' }) + source_type: string; + @ManyToOne(() => User, { onDelete: 'CASCADE' }) @JoinColumn({ name: 'user_id' }) user: User; From b7120185065e6cd9c665bb91dfb01c0be1494e0d Mon Sep 17 00:00:00 2001 From: elijah arhinful Date: Mon, 8 Jun 2026 18:36:05 +0000 Subject: [PATCH 13/37] docs(audio): add swagger docs --- .../docs/voice-onboarding-swagger.doc.ts | 41 +++++++++++++++++++ .../voice/dto/voice-onboarding.dto.ts | 29 +++++++++++++ .../voice/enums/voice-onboarding.enums.ts | 4 ++ .../interfaces/voice-onboarding.interfaces.ts | 9 ++++ 4 files changed, 83 insertions(+) create mode 100644 src/modules/onboarding/voice/docs/voice-onboarding-swagger.doc.ts create mode 100644 src/modules/onboarding/voice/dto/voice-onboarding.dto.ts create mode 100644 src/modules/onboarding/voice/enums/voice-onboarding.enums.ts create mode 100644 src/modules/onboarding/voice/interfaces/voice-onboarding.interfaces.ts diff --git a/src/modules/onboarding/voice/docs/voice-onboarding-swagger.doc.ts b/src/modules/onboarding/voice/docs/voice-onboarding-swagger.doc.ts new file mode 100644 index 00000000..0a03bf02 --- /dev/null +++ b/src/modules/onboarding/voice/docs/voice-onboarding-swagger.doc.ts @@ -0,0 +1,41 @@ +import { applyDecorators } from '@nestjs/common'; +import { ApiBody, ApiConsumes, ApiOkResponse, ApiOperation } from '@nestjs/swagger'; +import * as SYS_MSG from '../../../../constants/system.messages'; +import { VoiceSessionCompleteResponseDto, VoiceSessionResponseDto } from '../dto/voice-onboarding.dto'; + +export function UploadVoiceRoundDocs() { + return applyDecorators( + ApiOperation({ summary: 'Upload a voice round for onboarding' }), + ApiConsumes('multipart/form-data'), + ApiBody({ + schema: { + type: 'object', + properties: { + file: { + type: 'string', + format: 'binary', + }, + voice_session_id: { + type: 'string', + format: 'uuid', + description: 'Include to append to an existing session, omit for a new session', + }, + }, + }, + }), + ApiOkResponse({ + description: SYS_MSG.VOICE_UPLOAD_ACCEPTED, + type: VoiceSessionResponseDto, + }), + ); +} + +export function CompleteVoiceSessionDocs() { + return applyDecorators( + ApiOperation({ summary: 'Complete voice onboarding session and generate document' }), + ApiOkResponse({ + description: SYS_MSG.VOICE_SESSION_COMPLETED, + type: VoiceSessionCompleteResponseDto, + }), + ); +} \ No newline at end of file diff --git a/src/modules/onboarding/voice/dto/voice-onboarding.dto.ts b/src/modules/onboarding/voice/dto/voice-onboarding.dto.ts new file mode 100644 index 00000000..d729e268 --- /dev/null +++ b/src/modules/onboarding/voice/dto/voice-onboarding.dto.ts @@ -0,0 +1,29 @@ +import { IsString, IsUUID } from 'class-validator'; + +export class CompleteVoiceSessionDto { + @IsString() + @IsUUID() + voice_session_id: string; +} + +export class VoiceSessionResponseDto { + voice_session_id: string; + status: string; + + static from(sessionId: string): VoiceSessionResponseDto { + const dto = new VoiceSessionResponseDto(); + dto.voice_session_id = sessionId; + dto.status = 'processing'; + return dto; + } +} + +export class VoiceSessionCompleteResponseDto { + upload_id: string; + + static from(uploadId: string): VoiceSessionCompleteResponseDto { + const dto = new VoiceSessionCompleteResponseDto(); + dto.upload_id = uploadId; + return dto; + } +} \ No newline at end of file diff --git a/src/modules/onboarding/voice/enums/voice-onboarding.enums.ts b/src/modules/onboarding/voice/enums/voice-onboarding.enums.ts new file mode 100644 index 00000000..f0864b28 --- /dev/null +++ b/src/modules/onboarding/voice/enums/voice-onboarding.enums.ts @@ -0,0 +1,4 @@ +export enum VoiceProvider { + GROQ = 'groq', + ASSEMBLYAI = 'assemblyai', +} \ No newline at end of file diff --git a/src/modules/onboarding/voice/interfaces/voice-onboarding.interfaces.ts b/src/modules/onboarding/voice/interfaces/voice-onboarding.interfaces.ts new file mode 100644 index 00000000..19540988 --- /dev/null +++ b/src/modules/onboarding/voice/interfaces/voice-onboarding.interfaces.ts @@ -0,0 +1,9 @@ +export interface VoiceSessionRound { + transcript: string; +} + +export interface VoiceTranscriptionJobData { + userId: string; + voiceSessionId: string; + storagePath: string; +} \ No newline at end of file From b2578a9656547c554b91b619e14917bb44bdbaf9 Mon Sep 17 00:00:00 2001 From: elijah arhinful Date: Mon, 8 Jun 2026 18:50:05 +0000 Subject: [PATCH 14/37] feat(audio): add base files --- src/config/llm.config.ts | 2 - .../voice-onboarding.controller.ts | 73 ++++++++++++ .../voice-transcription.processor.ts | 52 +++++++++ .../services/voice-onboarding.service.ts | 97 ++++++++++++++++ .../services/voice-transcription.service.ts | 105 ++++++++++++++++++ .../voice/voice-onboarding.module.ts | 26 +++++ 6 files changed, 353 insertions(+), 2 deletions(-) create mode 100644 src/modules/onboarding/voice/controllers/voice-onboarding.controller.ts create mode 100644 src/modules/onboarding/voice/processors/voice-transcription.processor.ts create mode 100644 src/modules/onboarding/voice/services/voice-onboarding.service.ts create mode 100644 src/modules/onboarding/voice/services/voice-transcription.service.ts create mode 100644 src/modules/onboarding/voice/voice-onboarding.module.ts diff --git a/src/config/llm.config.ts b/src/config/llm.config.ts index 97ae4061..fbd92d39 100644 --- a/src/config/llm.config.ts +++ b/src/config/llm.config.ts @@ -8,6 +8,4 @@ export const llmConfig = registerAs('llm', () => ({ groqApiKey: env.GROQ_API_KEY, groqModel: env.GROQ_MODEL, groqTimeoutMs: env.GROQ_TIMEOUT_MS, - groqWhisperModel: env.GROQ_WHISPER_MODEL, - assemblyApiKey: env.ASSEMBLYAI_API_KEY, })); diff --git a/src/modules/onboarding/voice/controllers/voice-onboarding.controller.ts b/src/modules/onboarding/voice/controllers/voice-onboarding.controller.ts new file mode 100644 index 00000000..5feef25a --- /dev/null +++ b/src/modules/onboarding/voice/controllers/voice-onboarding.controller.ts @@ -0,0 +1,73 @@ +import { + BadRequestException, + Body, + Controller, + HttpCode, + HttpStatus, + ParseFilePipeBuilder, + Post, + UploadedFile, + UseInterceptors, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { CurrentUser } from '../../../common/decorators/current-user.decorator'; +import * as SYS_MSG from '../../../constants/system.messages'; +import { CompleteVoiceSessionDto, VoiceSessionCompleteResponseDto, VoiceSessionResponseDto } from './dto/voice-onboarding.dto'; +import { CompleteVoiceSessionDocs, UploadVoiceRoundDocs } from './docs/voice-onboarding-swagger.doc'; +import { VoiceOnboardingService } from './services/voice-onboarding.service'; + +const MAX_AUDIO_BYTES = 10 * 1024 * 1024; // 10MB +const ALLOWED_MIME_TYPES = /(audio\/webm|audio\/mpeg|audio\/mp3|audio\/wav|audio\/ogg|audio\/mp4|audio\/m4a)/; + +@ApiTags('onboarding') +@ApiBearerAuth('JWT') +@Controller('api/v1/onboarding/voice') +export class VoiceOnboardingController { + constructor(private readonly voiceOnboardingService: VoiceOnboardingService) {} + + @Post() + @UploadVoiceRoundDocs() + @UseInterceptors(FileInterceptor('file')) + @HttpCode(HttpStatus.OK) + async uploadVoiceRound( + @CurrentUser('sub') userId: string, + @UploadedFile( + new ParseFilePipeBuilder() + .addFileTypeValidator({ fileType: ALLOWED_MIME_TYPES }) + .addMaxSizeValidator({ maxSize: MAX_AUDIO_BYTES, message: SYS_MSG.VOICE_FILE_TOO_LARGE }) + .build({ + errorHttpStatusCode: HttpStatus.UNPROCESSABLE_ENTITY, + fileIsRequired: true, + }), + ) + file: Express.Multer.File, + @Body('voice_session_id') voiceSessionId?: string, + ) { + // Note: Length validation (120s limit) requires an audio parsing library + // to strictly enforce without ffmpeg, but a 10MB limit generally handles it for compressed audio. + const sessionId = await this.voiceOnboardingService.handleAudioUpload(userId, file, voiceSessionId); + + return { + statusCode: HttpStatus.OK, + message: SYS_MSG.VOICE_UPLOAD_ACCEPTED, + data: VoiceSessionResponseDto.from(sessionId), + }; + } + + @Post('complete') + @CompleteVoiceSessionDocs() + @HttpCode(HttpStatus.OK) + async completeVoiceSession( + @CurrentUser('sub') userId: string, + @Body() dto: CompleteVoiceSessionDto, + ) { + const uploadId = await this.voiceOnboardingService.completeSession(userId, dto.voice_session_id); + + return { + statusCode: HttpStatus.OK, + message: SYS_MSG.VOICE_SESSION_COMPLETED, + data: VoiceSessionCompleteResponseDto.from(uploadId), + }; + } +} \ No newline at end of file diff --git a/src/modules/onboarding/voice/processors/voice-transcription.processor.ts b/src/modules/onboarding/voice/processors/voice-transcription.processor.ts new file mode 100644 index 00000000..af131db3 --- /dev/null +++ b/src/modules/onboarding/voice/processors/voice-transcription.processor.ts @@ -0,0 +1,52 @@ +import { Process, Processor } from '@nestjs/bull'; +import { Inject, Logger } from '@nestjs/common'; +import { Job } from 'bull'; +import { ObjectStorage, UPLOAD_OBJECT_STORAGE } from '../../../upload/upload.types'; +import { VoiceTranscriptionService } from '../services/voice-transcription.service'; +import { VoiceTranscriptionJobData } from '../interfaces/voice-onboarding.interfaces'; +import { RedisService } from '../../../redis/redis.service'; +import { redisKeys } from '../../../../constants/redis-keys'; + +@Processor('voice-transcription') +export class VoiceTranscriptionProcessor { + private readonly logger = new Logger(VoiceTranscriptionProcessor.name); + + constructor( + @Inject(UPLOAD_OBJECT_STORAGE) private readonly objectStorage: ObjectStorage, + private readonly transcriptionService: VoiceTranscriptionService, + private readonly redisService: RedisService, + ) {} + + @Process() + async processTranscription(job: Job): Promise { + const { voiceSessionId, storagePath } = job.data; + this.logger.debug(\`Processing transcription for session: \${voiceSessionId}\`); + + try { + const audioBuffer = await this.objectStorage.getObject(storagePath); + + const { transcript, provider } = await this.transcriptionService.transcribe(audioBuffer, 'audio.webm'); + this.logger.debug(\`Transcription successful via \${provider} for session: \${voiceSessionId}\`); + + // Append to Redis list and reset TTL + const sessionKey = redisKeys.voiceSession(voiceSessionId); + const redisClient = this.redisService.getClient(); + + await redisClient.rpush(sessionKey, transcript); + await redisClient.expire(sessionKey, 1800); // 30 minutes TTL + + // Cleanup S3 audio + await this.objectStorage.deleteObject(storagePath); + + } catch (error: unknown) { + this.logger.error(\`Transcription failed for session \${voiceSessionId}\`, error instanceof Error ? error.stack : 'Unknown error'); + + // Jitter for rate limits + const baseDelay = job.opts.backoff && typeof job.opts.backoff === 'object' ? job.opts.backoff.delay ?? 2000 : 2000; + const jitter = Math.floor(Math.random() * 1000) - 500; + await new Promise(resolve => setTimeout(resolve, baseDelay + jitter)); + + throw error; // Let Bull retry according to policy + } + } +} \ No newline at end of file diff --git a/src/modules/onboarding/voice/services/voice-onboarding.service.ts b/src/modules/onboarding/voice/services/voice-onboarding.service.ts new file mode 100644 index 00000000..9b11693c --- /dev/null +++ b/src/modules/onboarding/voice/services/voice-onboarding.service.ts @@ -0,0 +1,97 @@ +import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { InjectQueue } from '@nestjs/bull'; +import { Queue } from 'bull'; +import { v4 as uuidv4 } from 'uuid'; +import { RedisService } from '../../../redis/redis.service'; +import { redisKeys } from '../../../../constants/redis-keys'; +import { ObjectStorage, UPLOAD_OBJECT_STORAGE, UploadDocumentStatus } from '../../../upload/upload.types'; +import { UploadedDocumentModelAction } from '../../../upload/actions/uploaded-document.action'; +import { VoiceTranscriptionJobData } from '../interfaces/voice-onboarding.interfaces'; + +@Injectable() +export class VoiceOnboardingService { + constructor( + @InjectQueue('voice-transcription') private readonly transcriptionQueue: Queue, + private readonly redisService: RedisService, + @Inject(UPLOAD_OBJECT_STORAGE) private readonly objectStorage: ObjectStorage, + private readonly documentAction: UploadedDocumentModelAction, + ) {} + + async handleAudioUpload(userId: string, file: Express.Multer.File, existingSessionId?: string): Promise { + const sessionId = existingSessionId || uuidv4(); + const storagePath = `voice-onboarding/\${userId}/\${uuidv4()}\`; + + // Ensure session validity if providing an existing one + if (existingSessionId) { + const exists = await this.redisService.exists(redisKeys.voiceSession(existingSessionId)); + if (!exists) { + throw new NotFoundException('SESSION_EXPIRED'); + } + } else { + // Initialize an empty list to track the session creation + await this.redisService.getClient().lpush(redisKeys.voiceSession(sessionId), 'SESSION_START'); + await this.redisService.getClient().expire(redisKeys.voiceSession(sessionId), 1800); + await this.redisService.getClient().lpop(redisKeys.voiceSession(sessionId)); // remove placeholder + } + + // Upload to MinIO/S3 + await this.objectStorage.putObject({ + storagePath, + body: file.buffer, + contentType: file.mimetype, + contentLength: file.size, + }); + + // Enqueue transcription job + await this.transcriptionQueue.add( + { + userId, + voiceSessionId: sessionId, + storagePath, + }, + { + attempts: 3, + backoff: { + type: 'exponential', + delay: 2000, + }, + removeOnComplete: true, + removeOnFail: false, + }, + ); + + return sessionId; + } + + async completeSession(userId: string, sessionId: string): Promise { + const sessionKey = redisKeys.voiceSession(sessionId); + const exists = await this.redisService.exists(sessionKey); + + if (!exists) { + throw new NotFoundException('SESSION_EXPIRED'); + } + + const transcripts = await this.redisService.getClient().lrange(sessionKey, 0, -1); + if (transcripts.length === 0) { + throw new BadRequestException('TRANSCRIPTION_EMPTY'); + } + + const combinedTranscript = transcripts.join(' '); + + const document = await this.documentAction.createDocument({ + user_id: userId, + file_name: \`Voice Onboarding - \${new Date().toISOString()}\`, + file_size_bytes: String(Buffer.byteLength(combinedTranscript, 'utf-8')), + file_type: 'doc', // Fallback type as it's not a real file + status: UploadDocumentStatus.READY, + percent_complete: 100, + storage_path: 'voice-onboarding-virtual', + source_type: 'voice', + parsed_text: combinedTranscript, + }); + + await this.redisService.getClient().del(sessionKey); + + return document.id; + } +} \ No newline at end of file diff --git a/src/modules/onboarding/voice/services/voice-transcription.service.ts b/src/modules/onboarding/voice/services/voice-transcription.service.ts new file mode 100644 index 00000000..52baaa95 --- /dev/null +++ b/src/modules/onboarding/voice/services/voice-transcription.service.ts @@ -0,0 +1,105 @@ +import { Injectable, Logger } from '@nestjs/common'; +import Groq from 'groq-sdk'; +import { env } from '../../../../config/env'; +import { VoiceProvider } from '../enums/voice-onboarding.enums'; + +@Injectable() +export class VoiceTranscriptionService { + private readonly logger = new Logger(VoiceTranscriptionService.name); + private readonly groq: Groq; + + constructor() { + this.groq = new Groq({ apiKey: env.GROQ_API_KEY }); + } + + async transcribe(audioBuffer: Buffer, fileName: string): Promise<{ transcript: string; provider: VoiceProvider }> { + try { + const transcript = await this.transcribeWithGroq(audioBuffer, fileName); + return { transcript, provider: VoiceProvider.GROQ }; + } catch (error: unknown) { + this.logger.error('Groq transcription failed', error instanceof Error ? error.stack : 'Unknown error'); + + // Fallback to AssemblyAI + if (!env.ASSEMBLYAI_API_KEY) { + throw new Error('AssemblyAI fallback unavailable: Missing API key'); + } + + try { + const transcript = await this.transcribeWithAssemblyAI(audioBuffer); + return { transcript, provider: VoiceProvider.ASSEMBLYAI }; + } catch (fallbackError: unknown) { + this.logger.error('AssemblyAI transcription failed', fallbackError instanceof Error ? fallbackError.stack : 'Unknown error'); + throw new Error('Transcription failed on both providers'); + } + } + } + + private async transcribeWithGroq(audioBuffer: Buffer, fileName: string): Promise { + const file = new File([audioBuffer], fileName, { type: 'audio/webm' }); // Type is ignored by Groq for Buffer/File blobs usually, but required for SDK signature. + + const transcription = await this.groq.audio.transcriptions.create({ + file, + model: env.GROQ_WHISPER_MODEL, + }); + + return transcription.text; + } + + private async transcribeWithAssemblyAI(audioBuffer: Buffer): Promise { + // Note: To avoid installing a new SDK just for fallback, using native fetch + const uploadRes = await fetch('https://api.assemblyai.com/v2/upload', { + method: 'POST', + headers: { + authorization: env.ASSEMBLYAI_API_KEY!, + }, + body: audioBuffer, + }); + + if (!uploadRes.ok) { + throw new Error(`AssemblyAI upload failed: ${uploadRes.status}`); + } + + const { upload_url } = (await uploadRes.json()) as { upload_url: string }; + + const transcriptRes = await fetch('https://api.assemblyai.com/v2/transcript', { + method: 'POST', + headers: { + authorization: env.ASSEMBLYAI_API_KEY!, + 'content-type': 'application/json', + }, + body: JSON.stringify({ audio_url: upload_url }), + }); + + if (!transcriptRes.ok) { + throw new Error(`AssemblyAI transcript request failed: ${transcriptRes.status}`); + } + + const { id } = (await transcriptRes.json()) as { id: string }; + + return this.pollAssemblyAITranscript(id); + } + + private async pollAssemblyAITranscript(id: string): Promise { + const maxAttempts = 30; + const delayMs = 3000; + + for (let i = 0; i < maxAttempts; i++) { + const res = await fetch(`https://api.assemblyai.com/v2/transcript/${id}`, { + headers: { authorization: env.ASSEMBLYAI_API_KEY! }, + }); + + const data = (await res.json()) as { status: string; text?: string; error?: string }; + + if (data.status === 'completed' && data.text) { + return data.text; + } + if (data.status === 'error') { + throw new Error(`AssemblyAI error: ${data.error}`); + } + + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + + throw new Error('AssemblyAI polling timed out'); + } +} \ No newline at end of file diff --git a/src/modules/onboarding/voice/voice-onboarding.module.ts b/src/modules/onboarding/voice/voice-onboarding.module.ts new file mode 100644 index 00000000..a15fb1cd --- /dev/null +++ b/src/modules/onboarding/voice/voice-onboarding.module.ts @@ -0,0 +1,26 @@ +import { BullModule } from '@nestjs/bull'; +import { Module } from '@nestjs/common'; +import { VoiceOnboardingController } from './voice-onboarding.controller'; +import { VoiceOnboardingService } from './services/voice-onboarding.service'; +import { VoiceTranscriptionService } from './services/voice-transcription.service'; +import { VoiceTranscriptionProcessor } from './processors/voice-transcription.processor'; +import { UploadModule } from '../../upload/upload.module'; +import { RedisModule } from '../../redis/redis.module'; + +@Module({ + imports: [ + UploadModule, // Provides UploadedDocumentModelAction and Minio Storage + RedisModule, + BullModule.registerQueue({ + name: 'voice-transcription', + }), + ], + controllers: [VoiceOnboardingController], + providers: [ + VoiceOnboardingService, + VoiceTranscriptionService, + VoiceTranscriptionProcessor, + ], + exports: [VoiceOnboardingService], +}) +export class VoiceOnboardingModule {} \ No newline at end of file From 7f10aea449352f2b7e23428bffbf8c9c4b7b03ca Mon Sep 17 00:00:00 2001 From: elijah arhinful Date: Mon, 8 Jun 2026 19:11:04 +0000 Subject: [PATCH 15/37] fix(audio): resolve errors in voice services and controllers --- src/common/constants/queue.constants.ts | 1 + src/modules/onboarding/onboarding.module.ts | 3 ++- .../controllers/voice-onboarding.controller.ts | 13 ++++++------- .../processors/voice-transcription.processor.ts | 10 +++++----- .../voice/services/voice-onboarding.service.ts | 12 ++++++------ .../voice/services/voice-transcription.service.ts | 2 +- .../onboarding/voice/voice-onboarding.module.ts | 7 ++++--- src/modules/redis/redis.service.ts | 4 ++++ 8 files changed, 29 insertions(+), 23 deletions(-) diff --git a/src/common/constants/queue.constants.ts b/src/common/constants/queue.constants.ts index 7179c24a..13f9ec96 100644 --- a/src/common/constants/queue.constants.ts +++ b/src/common/constants/queue.constants.ts @@ -2,6 +2,7 @@ export const QUEUES = { FUNNEL_GENERATION: 'funnel-generation', EMAIL: 'email', DOCUMENT_EXTRACTION: 'document-extraction', + VOICE_TRANSCRIPTION: 'voice-transcription', } as const; export const JOBS = { diff --git a/src/modules/onboarding/onboarding.module.ts b/src/modules/onboarding/onboarding.module.ts index 9420a5c9..6ac777c2 100644 --- a/src/modules/onboarding/onboarding.module.ts +++ b/src/modules/onboarding/onboarding.module.ts @@ -4,9 +4,10 @@ import { WizardSessionModelAction } from './actions/wizard-session.action'; import { WizardSession } from './entities/wizzard-session.entity'; import { OnboardingController } from './onboarding.controller'; import { OnboardingService } from './onboarding.service'; +import { VoiceOnboardingModule } from './voice/voice-onboarding.module'; @Module({ - imports: [TypeOrmModule.forFeature([WizardSession])], + imports: [TypeOrmModule.forFeature([WizardSession]), VoiceOnboardingModule], controllers: [OnboardingController], providers: [OnboardingService, WizardSessionModelAction], exports: [OnboardingService, WizardSessionModelAction], diff --git a/src/modules/onboarding/voice/controllers/voice-onboarding.controller.ts b/src/modules/onboarding/voice/controllers/voice-onboarding.controller.ts index 5feef25a..dbe84029 100644 --- a/src/modules/onboarding/voice/controllers/voice-onboarding.controller.ts +++ b/src/modules/onboarding/voice/controllers/voice-onboarding.controller.ts @@ -1,5 +1,4 @@ import { - BadRequestException, Body, Controller, HttpCode, @@ -11,18 +10,18 @@ import { } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; -import { CurrentUser } from '../../../common/decorators/current-user.decorator'; -import * as SYS_MSG from '../../../constants/system.messages'; -import { CompleteVoiceSessionDto, VoiceSessionCompleteResponseDto, VoiceSessionResponseDto } from './dto/voice-onboarding.dto'; -import { CompleteVoiceSessionDocs, UploadVoiceRoundDocs } from './docs/voice-onboarding-swagger.doc'; -import { VoiceOnboardingService } from './services/voice-onboarding.service'; +import { CurrentUser } from '../../../../common/decorators/current-user.decorator'; +import * as SYS_MSG from '../../../../constants/system.messages'; +import { CompleteVoiceSessionDto, VoiceSessionCompleteResponseDto, VoiceSessionResponseDto } from '../dto/voice-onboarding.dto'; +import { CompleteVoiceSessionDocs, UploadVoiceRoundDocs } from '../docs/voice-onboarding-swagger.doc'; +import { VoiceOnboardingService } from '../services/voice-onboarding.service'; const MAX_AUDIO_BYTES = 10 * 1024 * 1024; // 10MB const ALLOWED_MIME_TYPES = /(audio\/webm|audio\/mpeg|audio\/mp3|audio\/wav|audio\/ogg|audio\/mp4|audio\/m4a)/; @ApiTags('onboarding') @ApiBearerAuth('JWT') -@Controller('api/v1/onboarding/voice') +@Controller('onboarding/voice') export class VoiceOnboardingController { constructor(private readonly voiceOnboardingService: VoiceOnboardingService) {} diff --git a/src/modules/onboarding/voice/processors/voice-transcription.processor.ts b/src/modules/onboarding/voice/processors/voice-transcription.processor.ts index af131db3..ad6acaf2 100644 --- a/src/modules/onboarding/voice/processors/voice-transcription.processor.ts +++ b/src/modules/onboarding/voice/processors/voice-transcription.processor.ts @@ -1,7 +1,7 @@ import { Process, Processor } from '@nestjs/bull'; import { Inject, Logger } from '@nestjs/common'; -import { Job } from 'bull'; -import { ObjectStorage, UPLOAD_OBJECT_STORAGE } from '../../../upload/upload.types'; +import type { Job } from 'bull'; +import { UPLOAD_OBJECT_STORAGE, type ObjectStorage } from '../../../upload/upload.types'; import { VoiceTranscriptionService } from '../services/voice-transcription.service'; import { VoiceTranscriptionJobData } from '../interfaces/voice-onboarding.interfaces'; import { RedisService } from '../../../redis/redis.service'; @@ -20,13 +20,13 @@ export class VoiceTranscriptionProcessor { @Process() async processTranscription(job: Job): Promise { const { voiceSessionId, storagePath } = job.data; - this.logger.debug(\`Processing transcription for session: \${voiceSessionId}\`); + this.logger.debug(`Processing transcription for session: ${voiceSessionId}`); try { const audioBuffer = await this.objectStorage.getObject(storagePath); const { transcript, provider } = await this.transcriptionService.transcribe(audioBuffer, 'audio.webm'); - this.logger.debug(\`Transcription successful via \${provider} for session: \${voiceSessionId}\`); + this.logger.debug(`Transcription successful via ${provider} for session: ${voiceSessionId}`); // Append to Redis list and reset TTL const sessionKey = redisKeys.voiceSession(voiceSessionId); @@ -39,7 +39,7 @@ export class VoiceTranscriptionProcessor { await this.objectStorage.deleteObject(storagePath); } catch (error: unknown) { - this.logger.error(\`Transcription failed for session \${voiceSessionId}\`, error instanceof Error ? error.stack : 'Unknown error'); + this.logger.error(`Transcription failed for session ${voiceSessionId}`, error instanceof Error ? error.stack : 'Unknown error'); // Jitter for rate limits const baseDelay = job.opts.backoff && typeof job.opts.backoff === 'object' ? job.opts.backoff.delay ?? 2000 : 2000; diff --git a/src/modules/onboarding/voice/services/voice-onboarding.service.ts b/src/modules/onboarding/voice/services/voice-onboarding.service.ts index 9b11693c..8e33ff0a 100644 --- a/src/modules/onboarding/voice/services/voice-onboarding.service.ts +++ b/src/modules/onboarding/voice/services/voice-onboarding.service.ts @@ -1,10 +1,10 @@ import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common'; import { InjectQueue } from '@nestjs/bull'; -import { Queue } from 'bull'; -import { v4 as uuidv4 } from 'uuid'; +import type { Queue } from 'bull'; +import { randomUUID } from 'node:crypto'; import { RedisService } from '../../../redis/redis.service'; import { redisKeys } from '../../../../constants/redis-keys'; -import { ObjectStorage, UPLOAD_OBJECT_STORAGE, UploadDocumentStatus } from '../../../upload/upload.types'; +import { UPLOAD_OBJECT_STORAGE, UploadDocumentStatus, type ObjectStorage } from '../../../upload/upload.types'; import { UploadedDocumentModelAction } from '../../../upload/actions/uploaded-document.action'; import { VoiceTranscriptionJobData } from '../interfaces/voice-onboarding.interfaces'; @@ -18,8 +18,8 @@ export class VoiceOnboardingService { ) {} async handleAudioUpload(userId: string, file: Express.Multer.File, existingSessionId?: string): Promise { - const sessionId = existingSessionId || uuidv4(); - const storagePath = `voice-onboarding/\${userId}/\${uuidv4()}\`; + const sessionId = existingSessionId || randomUUID(); + const storagePath = `voice-onboarding/${userId}/${randomUUID()}`; // Ensure session validity if providing an existing one if (existingSessionId) { @@ -80,7 +80,7 @@ export class VoiceOnboardingService { const document = await this.documentAction.createDocument({ user_id: userId, - file_name: \`Voice Onboarding - \${new Date().toISOString()}\`, + file_name: `Voice Onboarding - ${new Date().toISOString()}`, file_size_bytes: String(Buffer.byteLength(combinedTranscript, 'utf-8')), file_type: 'doc', // Fallback type as it's not a real file status: UploadDocumentStatus.READY, diff --git a/src/modules/onboarding/voice/services/voice-transcription.service.ts b/src/modules/onboarding/voice/services/voice-transcription.service.ts index 52baaa95..70ee7068 100644 --- a/src/modules/onboarding/voice/services/voice-transcription.service.ts +++ b/src/modules/onboarding/voice/services/voice-transcription.service.ts @@ -35,7 +35,7 @@ export class VoiceTranscriptionService { } private async transcribeWithGroq(audioBuffer: Buffer, fileName: string): Promise { - const file = new File([audioBuffer], fileName, { type: 'audio/webm' }); // Type is ignored by Groq for Buffer/File blobs usually, but required for SDK signature. + const file = new File([new Uint8Array(audioBuffer)], fileName, { type: 'audio/webm' }); // Type is ignored by Groq for Buffer/File blobs usually, but required for SDK signature. const transcription = await this.groq.audio.transcriptions.create({ file, diff --git a/src/modules/onboarding/voice/voice-onboarding.module.ts b/src/modules/onboarding/voice/voice-onboarding.module.ts index a15fb1cd..f3750bb4 100644 --- a/src/modules/onboarding/voice/voice-onboarding.module.ts +++ b/src/modules/onboarding/voice/voice-onboarding.module.ts @@ -1,18 +1,19 @@ import { BullModule } from '@nestjs/bull'; import { Module } from '@nestjs/common'; -import { VoiceOnboardingController } from './voice-onboarding.controller'; +import { VoiceOnboardingController } from './controllers/voice-onboarding.controller'; import { VoiceOnboardingService } from './services/voice-onboarding.service'; import { VoiceTranscriptionService } from './services/voice-transcription.service'; import { VoiceTranscriptionProcessor } from './processors/voice-transcription.processor'; import { UploadModule } from '../../upload/upload.module'; import { RedisModule } from '../../redis/redis.module'; +import { QUEUES } from '../../../common/constants/queue.constants'; @Module({ imports: [ - UploadModule, // Provides UploadedDocumentModelAction and Minio Storage + UploadModule, RedisModule, BullModule.registerQueue({ - name: 'voice-transcription', + name: QUEUES.VOICE_TRANSCRIPTION, }), ], controllers: [VoiceOnboardingController], diff --git a/src/modules/redis/redis.service.ts b/src/modules/redis/redis.service.ts index a4b3daa2..57c7da17 100644 --- a/src/modules/redis/redis.service.ts +++ b/src/modules/redis/redis.service.ts @@ -219,4 +219,8 @@ export class RedisService implements OnModuleInit, OnModuleDestroy { await this.client?.quit(); this.logger.log(SYS_MSG.REDIS_CONNECTION_CLOSED); } + + getClient(): Redis { + return this.client; + } } From a0e3b16d079a209d9fb5f9445f69c41366832509 Mon Sep 17 00:00:00 2001 From: elijah arhinful Date: Mon, 8 Jun 2026 19:58:57 +0000 Subject: [PATCH 16/37] docs(audio): add swagger documentation for complete endpoint --- .../voice-onboarding.controller.ts | 4 +- .../docs/voice-onboarding-swagger.doc.ts | 152 ++++++++++++++++-- .../voice/dto/voice-onboarding.dto.ts | 24 ++- 3 files changed, 165 insertions(+), 15 deletions(-) diff --git a/src/modules/onboarding/voice/controllers/voice-onboarding.controller.ts b/src/modules/onboarding/voice/controllers/voice-onboarding.controller.ts index dbe84029..021ac1e3 100644 --- a/src/modules/onboarding/voice/controllers/voice-onboarding.controller.ts +++ b/src/modules/onboarding/voice/controllers/voice-onboarding.controller.ts @@ -41,7 +41,7 @@ export class VoiceOnboardingController { }), ) file: Express.Multer.File, - @Body('voice_session_id') voiceSessionId?: string, + @Body('voiceSessionId') voiceSessionId?: string, ) { // Note: Length validation (120s limit) requires an audio parsing library // to strictly enforce without ffmpeg, but a 10MB limit generally handles it for compressed audio. @@ -61,7 +61,7 @@ export class VoiceOnboardingController { @CurrentUser('sub') userId: string, @Body() dto: CompleteVoiceSessionDto, ) { - const uploadId = await this.voiceOnboardingService.completeSession(userId, dto.voice_session_id); + const uploadId = await this.voiceOnboardingService.completeSession(userId, dto.voiceSessionId); return { statusCode: HttpStatus.OK, diff --git a/src/modules/onboarding/voice/docs/voice-onboarding-swagger.doc.ts b/src/modules/onboarding/voice/docs/voice-onboarding-swagger.doc.ts index 0a03bf02..499bcdd7 100644 --- a/src/modules/onboarding/voice/docs/voice-onboarding-swagger.doc.ts +++ b/src/modules/onboarding/voice/docs/voice-onboarding-swagger.doc.ts @@ -1,41 +1,173 @@ -import { applyDecorators } from '@nestjs/common'; -import { ApiBody, ApiConsumes, ApiOkResponse, ApiOperation } from '@nestjs/swagger'; +import { applyDecorators, HttpStatus } from '@nestjs/common'; +import { + ApiBadRequestResponse, + ApiBody, + ApiConsumes, + ApiNotFoundResponse, + ApiOkResponse, + ApiOperation, + ApiUnprocessableEntityResponse, + ApiUnauthorizedResponse, +} from '@nestjs/swagger'; import * as SYS_MSG from '../../../../constants/system.messages'; -import { VoiceSessionCompleteResponseDto, VoiceSessionResponseDto } from '../dto/voice-onboarding.dto'; +import { CompleteVoiceSessionDto } from '../dto/voice-onboarding.dto'; + +const unauthorizedExample = { + success: false, + statusCode: HttpStatus.UNAUTHORIZED, + error: 'UnauthorizedException', + message: SYS_MSG.AUTH_UNAUTHENTICATED_MESSAGE, +}; export function UploadVoiceRoundDocs() { return applyDecorators( - ApiOperation({ summary: 'Upload a voice round for onboarding' }), + ApiOperation({ + summary: 'Upload a voice round for onboarding', + description: + 'Accepts a multipart/form-data request with an audio file and an optional voiceSessionId. ' + + 'If no voiceSessionId is provided, a new session is created. ' + + 'The audio is transcribed asynchronously and the text is accumulated ' + + 'under the session. Max file size is 10MB. Allowed formats include webm, mpeg, mp3, wav, ogg, mp4, m4a.', + }), ApiConsumes('multipart/form-data'), ApiBody({ schema: { type: 'object', + required: ['file'], properties: { file: { type: 'string', format: 'binary', + description: 'The audio file to upload and transcribe. Max size 10MB.', }, voice_session_id: { type: 'string', format: 'uuid', - description: 'Include to append to an existing session, omit for a new session', + description: 'Optional. Include to append to an existing session, omit for a new session.', }, }, }, }), ApiOkResponse({ - description: SYS_MSG.VOICE_UPLOAD_ACCEPTED, - type: VoiceSessionResponseDto, + description: 'Audio uploaded successfully. Returns the voice session ID.', + schema: { + example: { + success: true, + statusCode: HttpStatus.OK, + message: SYS_MSG.VOICE_UPLOAD_ACCEPTED, + data: { + voiceSessionId: '550e8400-e29b-41d4-a716-446655440001', + status: 'processing', + }, + }, + }, + }), + ApiUnprocessableEntityResponse({ + description: 'File validation failed (invalid audio format or file too large).', + schema: { + examples: { + fileTooLarge: { + summary: 'File exceeds 10MB limit', + value: { + success: false, + statusCode: HttpStatus.UNPROCESSABLE_ENTITY, + error: 'UnprocessableEntityException', + message: SYS_MSG.VOICE_FILE_TOO_LARGE, + }, + }, + invalidFormat: { + summary: 'Unsupported audio format', + value: { + success: false, + statusCode: HttpStatus.UNPROCESSABLE_ENTITY, + error: 'UnprocessableEntityException', + message: SYS_MSG.VOICE_INVALID_AUDIO_FORMAT, + }, + }, + }, + }, + }), + ApiNotFoundResponse({ + description: 'Session expired or invalid voiceSessionId.', + schema: { + example: { + success: false, + statusCode: HttpStatus.NOT_FOUND, + error: 'NotFoundException', + message: 'SESSION_EXPIRED', + }, + }, + }), + ApiUnauthorizedResponse({ + description: 'Missing or invalid JWT token.', + schema: { example: unauthorizedExample }, }), ); } export function CompleteVoiceSessionDocs() { return applyDecorators( - ApiOperation({ summary: 'Complete voice onboarding session and generate document' }), + ApiOperation({ + summary: 'Complete voice onboarding session and generate document', + description: + 'Finalizes a voice onboarding session by aggregating all transcriptions ' + + 'associated with the session ID. The aggregated text is then saved as a new ' + + 'UploadDocument record. Returns the resulting upload_id which can be passed ' + + 'to the funnel generation endpoints.', + }), + ApiBody({ type: CompleteVoiceSessionDto }), ApiOkResponse({ - description: SYS_MSG.VOICE_SESSION_COMPLETED, - type: VoiceSessionCompleteResponseDto, + description: 'Voice session completed successfully. Returns the generated UploadDocument ID.', + schema: { + example: { + success: true, + statusCode: HttpStatus.OK, + message: SYS_MSG.VOICE_SESSION_COMPLETED, + data: { + upload_id: '123e4567-e89b-12d3-a456-426614174000', + }, + }, + }, + }), + ApiBadRequestResponse({ + description: 'Validation failed or transcription is completely empty.', + schema: { + examples: { + emptyTranscription: { + summary: 'No transcription available', + value: { + success: false, + statusCode: HttpStatus.BAD_REQUEST, + error: 'BadRequestException', + message: 'TRANSCRIPTION_EMPTY', + }, + }, + validationFailed: { + summary: 'Invalid session ID format', + value: { + success: false, + statusCode: HttpStatus.BAD_REQUEST, + error: 'BadRequestException', + message: 'voiceSessionId must be a UUID', + }, + }, + }, + }, + }), + ApiNotFoundResponse({ + description: 'Session expired, deleted, or does not exist.', + schema: { + example: { + success: false, + statusCode: HttpStatus.NOT_FOUND, + error: 'NotFoundException', + message: 'SESSION_EXPIRED', + }, + }, + }), + ApiUnauthorizedResponse({ + description: 'Missing or invalid JWT token.', + schema: { example: unauthorizedExample }, }), ); } \ No newline at end of file diff --git a/src/modules/onboarding/voice/dto/voice-onboarding.dto.ts b/src/modules/onboarding/voice/dto/voice-onboarding.dto.ts index d729e268..47fb7013 100644 --- a/src/modules/onboarding/voice/dto/voice-onboarding.dto.ts +++ b/src/modules/onboarding/voice/dto/voice-onboarding.dto.ts @@ -1,24 +1,42 @@ +import { ApiProperty } from '@nestjs/swagger'; import { IsString, IsUUID } from 'class-validator'; export class CompleteVoiceSessionDto { + @ApiProperty({ + description: 'The UUID of the voice session to complete', + example: '550e8400-e29b-41d4-a716-446655440001', + }) @IsString() @IsUUID() - voice_session_id: string; + voiceSessionId: string; } export class VoiceSessionResponseDto { - voice_session_id: string; + @ApiProperty({ + description: 'The UUID of the created or updated voice session', + example: '550e8400-e29b-41d4-a716-446655440001', + }) + voiceSessionId: string; + + @ApiProperty({ + description: 'The current status of the voice session', + example: 'processing', + }) status: string; static from(sessionId: string): VoiceSessionResponseDto { const dto = new VoiceSessionResponseDto(); - dto.voice_session_id = sessionId; + dto.voiceSessionId = sessionId; dto.status = 'processing'; return dto; } } export class VoiceSessionCompleteResponseDto { + @ApiProperty({ + description: 'The generated UploadDocument ID representing the finalized transcription', + example: '123e4567-e89b-12d3-a456-426614174000', + }) upload_id: string; static from(uploadId: string): VoiceSessionCompleteResponseDto { From e3b2ea22a0d898b8b27d1057b86dab2d55b2b074 Mon Sep 17 00:00:00 2001 From: elijah arhinful Date: Mon, 8 Jun 2026 20:05:58 +0000 Subject: [PATCH 17/37] feat(audio): add status endpoint for voice upload --- src/constants/redis-keys.ts | 1 + .../voice-onboarding.controller.ts | 22 ++++++++++- .../docs/voice-onboarding-swagger.doc.ts | 39 +++++++++++++++++++ .../voice/dto/voice-onboarding.dto.ts | 28 +++++++++++++ .../voice-transcription.processor.ts | 4 ++ .../services/voice-onboarding.service.ts | 25 ++++++++++++ 6 files changed, 117 insertions(+), 2 deletions(-) diff --git a/src/constants/redis-keys.ts b/src/constants/redis-keys.ts index ef49405e..faad947c 100644 --- a/src/constants/redis-keys.ts +++ b/src/constants/redis-keys.ts @@ -22,4 +22,5 @@ export const redisKeys = { adminDashboardUserRetention: () => 'admin-dashboard:user-retention', voiceSession: (sessionId: string) => `voice_session:${sessionId}`, + voiceSessionMeta: (sessionId: string) => `voice_session_meta:${sessionId}`, }; diff --git a/src/modules/onboarding/voice/controllers/voice-onboarding.controller.ts b/src/modules/onboarding/voice/controllers/voice-onboarding.controller.ts index 021ac1e3..6bbcd487 100644 --- a/src/modules/onboarding/voice/controllers/voice-onboarding.controller.ts +++ b/src/modules/onboarding/voice/controllers/voice-onboarding.controller.ts @@ -1,9 +1,12 @@ import { Body, Controller, + Get, HttpCode, HttpStatus, + Param, ParseFilePipeBuilder, + ParseUUIDPipe, Post, UploadedFile, UseInterceptors, @@ -12,8 +15,8 @@ import { FileInterceptor } from '@nestjs/platform-express'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { CurrentUser } from '../../../../common/decorators/current-user.decorator'; import * as SYS_MSG from '../../../../constants/system.messages'; -import { CompleteVoiceSessionDto, VoiceSessionCompleteResponseDto, VoiceSessionResponseDto } from '../dto/voice-onboarding.dto'; -import { CompleteVoiceSessionDocs, UploadVoiceRoundDocs } from '../docs/voice-onboarding-swagger.doc'; +import { CompleteVoiceSessionDto, VoiceSessionCompleteResponseDto, VoiceSessionResponseDto, VoiceSessionStatusResponseDto } from '../dto/voice-onboarding.dto'; +import { CompleteVoiceSessionDocs, UploadVoiceRoundDocs, GetVoiceSessionStatusDocs } from '../docs/voice-onboarding-swagger.doc'; import { VoiceOnboardingService } from '../services/voice-onboarding.service'; const MAX_AUDIO_BYTES = 10 * 1024 * 1024; // 10MB @@ -69,4 +72,19 @@ export class VoiceOnboardingController { data: VoiceSessionCompleteResponseDto.from(uploadId), }; } + + @Get(':voiceSessionId/status') + @GetVoiceSessionStatusDocs() + @HttpCode(HttpStatus.OK) + async getVoiceSessionStatus( + @Param('voiceSessionId', new ParseUUIDPipe()) voiceSessionId: string, + ) { + const status = await this.voiceOnboardingService.getSessionStatus(voiceSessionId); + + return { + statusCode: HttpStatus.OK, + message: 'Status retrieved successfully', + data: VoiceSessionStatusResponseDto.from(status.expectedCount, status.completedCount), + }; + } } \ No newline at end of file diff --git a/src/modules/onboarding/voice/docs/voice-onboarding-swagger.doc.ts b/src/modules/onboarding/voice/docs/voice-onboarding-swagger.doc.ts index 499bcdd7..8180fe4c 100644 --- a/src/modules/onboarding/voice/docs/voice-onboarding-swagger.doc.ts +++ b/src/modules/onboarding/voice/docs/voice-onboarding-swagger.doc.ts @@ -170,4 +170,43 @@ export function CompleteVoiceSessionDocs() { schema: { example: unauthorizedExample }, }), ); +} + +export function GetVoiceSessionStatusDocs() { + return applyDecorators( + ApiOperation({ + summary: 'Check transcription status of a voice session', + description: 'Retrieves the number of uploaded chunks and the number of successfully transcribed chunks for a given voice session.', + }), + ApiOkResponse({ + description: 'Session status retrieved successfully', + schema: { + example: { + success: true, + statusCode: HttpStatus.OK, + message: 'Status retrieved successfully', + data: { + expectedCount: 3, + completedCount: 3, + isReady: true, + }, + }, + }, + }), + ApiNotFoundResponse({ + description: 'Session expired, deleted, or does not exist.', + schema: { + example: { + success: false, + statusCode: HttpStatus.NOT_FOUND, + error: 'NotFoundException', + message: 'SESSION_EXPIRED', + }, + }, + }), + ApiUnauthorizedResponse({ + description: 'Missing or invalid JWT token.', + schema: { example: unauthorizedExample }, + }), + ); } \ No newline at end of file diff --git a/src/modules/onboarding/voice/dto/voice-onboarding.dto.ts b/src/modules/onboarding/voice/dto/voice-onboarding.dto.ts index 47fb7013..a29c3327 100644 --- a/src/modules/onboarding/voice/dto/voice-onboarding.dto.ts +++ b/src/modules/onboarding/voice/dto/voice-onboarding.dto.ts @@ -44,4 +44,32 @@ export class VoiceSessionCompleteResponseDto { dto.upload_id = uploadId; return dto; } +} + +export class VoiceSessionStatusResponseDto { + @ApiProperty({ + description: 'The number of audio chunks that have been uploaded for transcription', + example: 3, + }) + expectedCount: number; + + @ApiProperty({ + description: 'The number of audio chunks that have finished transcribing', + example: 3, + }) + completedCount: number; + + @ApiProperty({ + description: 'True if all uploaded chunks have finished transcribing, and there is at least 1 chunk', + example: true, + }) + isReady: boolean; + + static from(expectedCount: number, completedCount: number): VoiceSessionStatusResponseDto { + const dto = new VoiceSessionStatusResponseDto(); + dto.expectedCount = expectedCount; + dto.completedCount = completedCount; + dto.isReady = expectedCount > 0 && expectedCount === completedCount; + return dto; + } } \ No newline at end of file diff --git a/src/modules/onboarding/voice/processors/voice-transcription.processor.ts b/src/modules/onboarding/voice/processors/voice-transcription.processor.ts index ad6acaf2..c658975f 100644 --- a/src/modules/onboarding/voice/processors/voice-transcription.processor.ts +++ b/src/modules/onboarding/voice/processors/voice-transcription.processor.ts @@ -35,6 +35,10 @@ export class VoiceTranscriptionProcessor { await redisClient.rpush(sessionKey, transcript); await redisClient.expire(sessionKey, 1800); // 30 minutes TTL + // Update completed count + const metaKey = redisKeys.voiceSessionMeta(voiceSessionId); + await redisClient.hincrby(metaKey, 'completedCount', 1); + // Cleanup S3 audio await this.objectStorage.deleteObject(storagePath); diff --git a/src/modules/onboarding/voice/services/voice-onboarding.service.ts b/src/modules/onboarding/voice/services/voice-onboarding.service.ts index 8e33ff0a..aae70cc8 100644 --- a/src/modules/onboarding/voice/services/voice-onboarding.service.ts +++ b/src/modules/onboarding/voice/services/voice-onboarding.service.ts @@ -42,6 +42,11 @@ export class VoiceOnboardingService { contentLength: file.size, }); + // Update meta tracking + const metaKey = redisKeys.voiceSessionMeta(sessionId); + await this.redisService.getClient().hincrby(metaKey, 'expectedCount', 1); + await this.redisService.getClient().expire(metaKey, 1800); + // Enqueue transcription job await this.transcriptionQueue.add( { @@ -91,7 +96,27 @@ export class VoiceOnboardingService { }); await this.redisService.getClient().del(sessionKey); + await this.redisService.getClient().del(redisKeys.voiceSessionMeta(sessionId)); return document.id; } + + async getSessionStatus(sessionId: string): Promise<{ expectedCount: number; completedCount: number; isReady: boolean }> { + const metaKey = redisKeys.voiceSessionMeta(sessionId); + const exists = await this.redisService.exists(metaKey); + + if (!exists) { + throw new NotFoundException('SESSION_EXPIRED'); + } + + const meta = await this.redisService.getClient().hgetall(metaKey); + const expectedCount = parseInt(meta.expectedCount || '0', 10); + const completedCount = parseInt(meta.completedCount || '0', 10); + + return { + expectedCount, + completedCount, + isReady: expectedCount > 0 && expectedCount === completedCount, + }; + } } \ No newline at end of file From 14cbca107f34f5508098f0ea06761f9977adfe09 Mon Sep 17 00:00:00 2001 From: elijah arhinful Date: Mon, 8 Jun 2026 20:29:54 +0000 Subject: [PATCH 18/37] feat(audio): add migration migration file --- ...80950180322-AddSourceTypeToUploadEntity.ts | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 src/database/migrations/1780950180322-AddSourceTypeToUploadEntity.ts diff --git a/src/database/migrations/1780950180322-AddSourceTypeToUploadEntity.ts b/src/database/migrations/1780950180322-AddSourceTypeToUploadEntity.ts new file mode 100644 index 00000000..7c8dac2a --- /dev/null +++ b/src/database/migrations/1780950180322-AddSourceTypeToUploadEntity.ts @@ -0,0 +1,62 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddSourceTypeToUploadEntity1780950180322 implements MigrationInterface { + name = 'AddSourceTypeToUploadEntity1780950180322' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "team_invitations" DROP CONSTRAINT "FK_team_invitations_team"`); + await queryRunner.query(`ALTER TABLE "team_invitations" DROP CONSTRAINT "FK_team_invitations_invited_by"`); + await queryRunner.query(`ALTER TABLE "admin_teams" DROP CONSTRAINT "FK_admin_teams_created_by"`); + await queryRunner.query(`ALTER TABLE "team_memberships" DROP CONSTRAINT "FK_team_memberships_team"`); + await queryRunner.query(`ALTER TABLE "team_memberships" DROP CONSTRAINT "FK_team_memberships_user"`); + await queryRunner.query(`ALTER TABLE "admin_logs" DROP CONSTRAINT "FK_admin_logs_user_id"`); + await queryRunner.query(`DROP INDEX "public"."IDX_team_invitations_team_email_pending"`); + await queryRunner.query(`DROP INDEX "public"."IDX_team_memberships_team_user"`); + await queryRunner.query(`DROP INDEX "public"."IDX_admin_logs_created_at"`); + await queryRunner.query(`DROP INDEX "public"."IDX_admin_logs_user_id"`); + await queryRunner.query(`DROP INDEX "public"."IDX_admin_logs_action_type"`); + await queryRunner.query(`DROP INDEX "public"."IDX_admin_logs_status"`); + await queryRunner.query(`ALTER TABLE "uploaded_documents" ADD "source_type" character varying NOT NULL DEFAULT 'document'`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_b780a282603aaa80e05def2203" ON "team_invitations" ("team_id", "email") WHERE "status" = 'pending'`); + await queryRunner.query(`CREATE INDEX "IDX_7ace7c4b3262abd89cb75ae53b" ON "admin_logs" ("user_id") `); + await queryRunner.query(`CREATE INDEX "IDX_d2b22ec3e7c92f1e670f91a305" ON "admin_logs" ("action_type") `); + await queryRunner.query(`CREATE INDEX "IDX_d51744dc54aab8e69c2e3bb662" ON "admin_logs" ("status") `); + await queryRunner.query(`CREATE INDEX "IDX_c328cf8abb6bd5fabdd090d677" ON "admin_logs" ("created_at") `); + await queryRunner.query(`ALTER TABLE "team_memberships" ADD CONSTRAINT "UQ_11c823f69a675c3f05d0fc31958" UNIQUE ("team_id", "user_id")`); + await queryRunner.query(`ALTER TABLE "team_invitations" ADD CONSTRAINT "FK_47d9ff0726cf20571e29480a99b" FOREIGN KEY ("team_id") REFERENCES "admin_teams"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "team_invitations" ADD CONSTRAINT "FK_92d21809e16a56887210bb4dbc5" FOREIGN KEY ("invited_by") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "admin_teams" ADD CONSTRAINT "FK_5b12c080ebe16ac4bdaae422b58" FOREIGN KEY ("created_by") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "team_memberships" ADD CONSTRAINT "FK_b917b8603c6d5c526fcdb2009de" FOREIGN KEY ("team_id") REFERENCES "admin_teams"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "team_memberships" ADD CONSTRAINT "FK_c9eb2ded8e0e2f4bcb41fd0984a" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "admin_logs" ADD CONSTRAINT "FK_7ace7c4b3262abd89cb75ae53b1" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "admin_logs" DROP CONSTRAINT "FK_7ace7c4b3262abd89cb75ae53b1"`); + await queryRunner.query(`ALTER TABLE "team_memberships" DROP CONSTRAINT "FK_c9eb2ded8e0e2f4bcb41fd0984a"`); + await queryRunner.query(`ALTER TABLE "team_memberships" DROP CONSTRAINT "FK_b917b8603c6d5c526fcdb2009de"`); + await queryRunner.query(`ALTER TABLE "admin_teams" DROP CONSTRAINT "FK_5b12c080ebe16ac4bdaae422b58"`); + await queryRunner.query(`ALTER TABLE "team_invitations" DROP CONSTRAINT "FK_92d21809e16a56887210bb4dbc5"`); + await queryRunner.query(`ALTER TABLE "team_invitations" DROP CONSTRAINT "FK_47d9ff0726cf20571e29480a99b"`); + await queryRunner.query(`ALTER TABLE "team_memberships" DROP CONSTRAINT "UQ_11c823f69a675c3f05d0fc31958"`); + await queryRunner.query(`DROP INDEX "public"."IDX_c328cf8abb6bd5fabdd090d677"`); + await queryRunner.query(`DROP INDEX "public"."IDX_d51744dc54aab8e69c2e3bb662"`); + await queryRunner.query(`DROP INDEX "public"."IDX_d2b22ec3e7c92f1e670f91a305"`); + await queryRunner.query(`DROP INDEX "public"."IDX_7ace7c4b3262abd89cb75ae53b"`); + await queryRunner.query(`DROP INDEX "public"."IDX_b780a282603aaa80e05def2203"`); + await queryRunner.query(`ALTER TABLE "uploaded_documents" DROP COLUMN "source_type"`); + await queryRunner.query(`CREATE INDEX "IDX_admin_logs_status" ON "admin_logs" ("status") `); + await queryRunner.query(`CREATE INDEX "IDX_admin_logs_action_type" ON "admin_logs" ("action_type") `); + await queryRunner.query(`CREATE INDEX "IDX_admin_logs_user_id" ON "admin_logs" ("user_id") `); + await queryRunner.query(`CREATE INDEX "IDX_admin_logs_created_at" ON "admin_logs" ("created_at") `); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_team_memberships_team_user" ON "team_memberships" ("team_id", "user_id") `); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_team_invitations_team_email_pending" ON "team_invitations" ("email", "team_id") WHERE (status = 'pending'::team_invitations_status_enum)`); + await queryRunner.query(`ALTER TABLE "admin_logs" ADD CONSTRAINT "FK_admin_logs_user_id" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "team_memberships" ADD CONSTRAINT "FK_team_memberships_user" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "team_memberships" ADD CONSTRAINT "FK_team_memberships_team" FOREIGN KEY ("team_id") REFERENCES "admin_teams"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "admin_teams" ADD CONSTRAINT "FK_admin_teams_created_by" FOREIGN KEY ("created_by") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "team_invitations" ADD CONSTRAINT "FK_team_invitations_invited_by" FOREIGN KEY ("invited_by") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "team_invitations" ADD CONSTRAINT "FK_team_invitations_team" FOREIGN KEY ("team_id") REFERENCES "admin_teams"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + +} From 975a3e9e4f4831d53d67a510c2e889dd1c8d3284 Mon Sep 17 00:00:00 2001 From: elijah arhinful Date: Mon, 8 Jun 2026 21:05:26 +0000 Subject: [PATCH 19/37] test(audio): add unit tests for voice service --- .../tests/voice-onboarding.controller.spec.ts | 123 ++++++++++ .../tests/voice-onboarding.service.spec.ts | 228 ++++++++++++++++++ .../tests/voice-transcription.service.spec.ts | 134 ++++++++++ 3 files changed, 485 insertions(+) create mode 100644 src/modules/onboarding/voice/tests/voice-onboarding.controller.spec.ts create mode 100644 src/modules/onboarding/voice/tests/voice-onboarding.service.spec.ts create mode 100644 src/modules/onboarding/voice/tests/voice-transcription.service.spec.ts diff --git a/src/modules/onboarding/voice/tests/voice-onboarding.controller.spec.ts b/src/modules/onboarding/voice/tests/voice-onboarding.controller.spec.ts new file mode 100644 index 00000000..e5687fef --- /dev/null +++ b/src/modules/onboarding/voice/tests/voice-onboarding.controller.spec.ts @@ -0,0 +1,123 @@ +import { HttpStatus } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { VoiceOnboardingController } from '../controllers/voice-onboarding.controller'; +import { VoiceOnboardingService } from '../services/voice-onboarding.service'; +import * as SYS_MSG from '../../../../constants/system.messages'; +import { CompleteVoiceSessionDto } from '../dto/voice-onboarding.dto'; + +describe('VoiceOnboardingController', () => { + let controller: VoiceOnboardingController; + + const mockVoiceOnboardingService = { + handleAudioUpload: jest.fn(), + completeSession: jest.fn(), + getSessionStatus: jest.fn(), + }; + + beforeEach(async () => { + jest.clearAllMocks(); + + const module: TestingModule = await Test.createTestingModule({ + controllers: [VoiceOnboardingController], + providers: [ + { + provide: VoiceOnboardingService, + useValue: mockVoiceOnboardingService, + }, + ], + }).compile(); + + controller = module.get(VoiceOnboardingController); + }); + + describe('uploadVoiceRound', () => { + it('should successfully handle audio upload and return correct response', async () => { + const mockUserId = 'user-123'; + const mockSessionId = 'session-456'; + const mockFile = { + buffer: Buffer.from('test-audio'), + mimetype: 'audio/webm', + size: 1024, + } as Express.Multer.File; + + mockVoiceOnboardingService.handleAudioUpload.mockResolvedValue(mockSessionId); + + const result = await controller.uploadVoiceRound(mockUserId, mockFile, undefined); + + expect(mockVoiceOnboardingService.handleAudioUpload).toHaveBeenCalledWith(mockUserId, mockFile, undefined); + expect(result).toEqual({ + statusCode: HttpStatus.OK, + message: SYS_MSG.VOICE_UPLOAD_ACCEPTED, + data: { + voiceSessionId: mockSessionId, + status: 'processing', + }, + }); + }); + + it('should handle audio upload with an existing session ID', async () => { + const mockUserId = 'user-123'; + const mockSessionId = 'existing-session-456'; + const mockFile = { + buffer: Buffer.from('test-audio'), + mimetype: 'audio/webm', + size: 1024, + } as Express.Multer.File; + + mockVoiceOnboardingService.handleAudioUpload.mockResolvedValue(mockSessionId); + + const result = await controller.uploadVoiceRound(mockUserId, mockFile, mockSessionId); + + expect(mockVoiceOnboardingService.handleAudioUpload).toHaveBeenCalledWith(mockUserId, mockFile, mockSessionId); + expect(result.data.voiceSessionId).toBe(mockSessionId); + }); + }); + + describe('completeVoiceSession', () => { + it('should successfully complete the voice session and return the upload ID', async () => { + const mockUserId = 'user-123'; + const mockUploadId = 'upload-789'; + const mockDto: CompleteVoiceSessionDto = { + voiceSessionId: 'session-456', + }; + + mockVoiceOnboardingService.completeSession.mockResolvedValue(mockUploadId); + + const result = await controller.completeVoiceSession(mockUserId, mockDto); + + expect(mockVoiceOnboardingService.completeSession).toHaveBeenCalledWith(mockUserId, mockDto.voiceSessionId); + expect(result).toEqual({ + statusCode: HttpStatus.OK, + message: SYS_MSG.VOICE_SESSION_COMPLETED, + data: { + upload_id: mockUploadId, + }, + }); + }); + }); + + describe('getVoiceSessionStatus', () => { + it('should retrieve status and compute correctly formatted response', async () => { + const mockSessionId = 'session-456'; + + mockVoiceOnboardingService.getSessionStatus.mockResolvedValue({ + expectedCount: 3, + completedCount: 3, + isReady: true, + }); + + const result = await controller.getVoiceSessionStatus(mockSessionId); + + expect(mockVoiceOnboardingService.getSessionStatus).toHaveBeenCalledWith(mockSessionId); + expect(result).toEqual({ + statusCode: HttpStatus.OK, + message: 'Status retrieved successfully', + data: { + expectedCount: 3, + completedCount: 3, + isReady: true, + }, + }); + }); + }); +}); diff --git a/src/modules/onboarding/voice/tests/voice-onboarding.service.spec.ts b/src/modules/onboarding/voice/tests/voice-onboarding.service.spec.ts new file mode 100644 index 00000000..dea58039 --- /dev/null +++ b/src/modules/onboarding/voice/tests/voice-onboarding.service.spec.ts @@ -0,0 +1,228 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getQueueToken } from '@nestjs/bull'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { VoiceOnboardingService } from '../services/voice-onboarding.service'; +import { RedisService } from '../../../redis/redis.service'; +import { UPLOAD_OBJECT_STORAGE, UploadDocumentStatus } from '../../../upload/upload.types'; +import { UploadedDocumentModelAction } from '../../../upload/actions/uploaded-document.action'; +import { redisKeys } from '../../../../constants/redis-keys'; + +jest.mock('node:crypto', () => ({ + randomUUID: jest.fn(() => 'mocked-uuid'), +})); + +describe('VoiceOnboardingService', () => { + let service: VoiceOnboardingService; + + const mockQueue = { + add: jest.fn(), + }; + + const mockRedisClient = { + lpush: jest.fn(), + lpop: jest.fn(), + expire: jest.fn(), + hincrby: jest.fn(), + lrange: jest.fn(), + del: jest.fn(), + hgetall: jest.fn(), + }; + + const mockRedisService = { + exists: jest.fn(), + getClient: jest.fn().mockReturnValue(mockRedisClient), + }; + + const mockObjectStorage = { + putObject: jest.fn(), + }; + + const mockDocumentAction = { + createDocument: jest.fn(), + }; + + beforeEach(async () => { + jest.clearAllMocks(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + VoiceOnboardingService, + { provide: getQueueToken('voice-transcription'), useValue: mockQueue }, + { provide: RedisService, useValue: mockRedisService }, + { provide: UPLOAD_OBJECT_STORAGE, useValue: mockObjectStorage }, + { provide: UploadedDocumentModelAction, useValue: mockDocumentAction }, + ], + }).compile(); + + service = module.get(VoiceOnboardingService); + }); + + describe('handleAudioUpload', () => { + const mockUserId = 'user-123'; + const mockFile = { + buffer: Buffer.from('test'), + mimetype: 'audio/webm', + size: 1024, + } as Express.Multer.File; + + it('should initialize tracking, upload file, and queue job for a NEW session', async () => { + mockQueue.add.mockResolvedValue({ id: 'job-1' }); + + const sessionId = await service.handleAudioUpload(mockUserId, mockFile); + + expect(sessionId).toBe('mocked-uuid'); + + // Verify list initialization + const sessionKey = redisKeys.voiceSession('mocked-uuid'); + expect(mockRedisClient.lpush).toHaveBeenCalledWith(sessionKey, 'SESSION_START'); + expect(mockRedisClient.expire).toHaveBeenCalledWith(sessionKey, 1800); + expect(mockRedisClient.lpop).toHaveBeenCalledWith(sessionKey); + + // Verify tracking + const metaKey = redisKeys.voiceSessionMeta('mocked-uuid'); + expect(mockRedisClient.hincrby).toHaveBeenCalledWith(metaKey, 'expectedCount', 1); + expect(mockRedisClient.expire).toHaveBeenCalledWith(metaKey, 1800); + + // Verify object storage + expect(mockObjectStorage.putObject).toHaveBeenCalledWith({ + storagePath: expect.stringContaining(`voice-onboarding/${mockUserId}/mocked-uuid`), + body: mockFile.buffer, + contentType: mockFile.mimetype, + contentLength: mockFile.size, + }); + + // Verify Bull Queue + expect(mockQueue.add).toHaveBeenCalledWith( + { + userId: mockUserId, + voiceSessionId: 'mocked-uuid', + storagePath: `voice-onboarding/${mockUserId}/mocked-uuid`, + }, + expect.objectContaining({ attempts: 3 }), + ); + }); + + it('should append to an EXISTING session without initializing the list', async () => { + const existingSessionId = 'existing-session-456'; + mockRedisService.exists.mockResolvedValue(true); + + const sessionId = await service.handleAudioUpload(mockUserId, mockFile, existingSessionId); + + expect(sessionId).toBe(existingSessionId); + expect(mockRedisService.exists).toHaveBeenCalledWith(redisKeys.voiceSession(existingSessionId)); + expect(mockRedisClient.lpush).not.toHaveBeenCalled(); + + const metaKey = redisKeys.voiceSessionMeta(existingSessionId); + expect(mockRedisClient.hincrby).toHaveBeenCalledWith(metaKey, 'expectedCount', 1); + }); + + it('should throw NotFoundException if EXISTING session is expired/missing', async () => { + mockRedisService.exists.mockResolvedValue(false); + + await expect(service.handleAudioUpload(mockUserId, mockFile, 'expired-session')).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe('completeSession', () => { + const mockUserId = 'user-123'; + const mockSessionId = 'session-456'; + const sessionKey = redisKeys.voiceSession(mockSessionId); + const metaKey = redisKeys.voiceSessionMeta(mockSessionId); + + it('should aggregate transcripts, create a document, and cleanup keys', async () => { + mockRedisService.exists.mockResolvedValue(true); + mockRedisClient.lrange.mockResolvedValue(['transcript 1.', 'transcript 2.']); + mockDocumentAction.createDocument.mockResolvedValue({ id: 'doc-789' }); + + const result = await service.completeSession(mockUserId, mockSessionId); + + expect(result).toBe('doc-789'); + expect(mockRedisClient.lrange).toHaveBeenCalledWith(sessionKey, 0, -1); + expect(mockDocumentAction.createDocument).toHaveBeenCalledWith( + expect.objectContaining({ + user_id: mockUserId, + file_type: 'doc', + status: UploadDocumentStatus.READY, + percent_complete: 100, + source_type: 'voice', + parsed_text: 'transcript 1. transcript 2.', + }), + ); + + // Cleanup + expect(mockRedisClient.del).toHaveBeenCalledWith(sessionKey); + expect(mockRedisClient.del).toHaveBeenCalledWith(metaKey); + }); + + it('should throw BadRequestException if transcripts list is empty', async () => { + mockRedisService.exists.mockResolvedValue(true); + mockRedisClient.lrange.mockResolvedValue([]); + + await expect(service.completeSession(mockUserId, mockSessionId)).rejects.toThrow(BadRequestException); + expect(mockDocumentAction.createDocument).not.toHaveBeenCalled(); + }); + + it('should throw NotFoundException if session does not exist', async () => { + mockRedisService.exists.mockResolvedValue(false); + + await expect(service.completeSession(mockUserId, mockSessionId)).rejects.toThrow(NotFoundException); + }); + }); + + describe('getSessionStatus', () => { + const mockSessionId = 'session-456'; + + it('should compute isReady as false if not all jobs are completed', async () => { + mockRedisService.exists.mockResolvedValue(true); + mockRedisClient.hgetall.mockResolvedValue({ + expectedCount: '3', + completedCount: '2', + }); + + const result = await service.getSessionStatus(mockSessionId); + + expect(result).toEqual({ + expectedCount: 3, + completedCount: 2, + isReady: false, + }); + }); + + it('should compute isReady as true if all jobs are completed and > 0', async () => { + mockRedisService.exists.mockResolvedValue(true); + mockRedisClient.hgetall.mockResolvedValue({ + expectedCount: '3', + completedCount: '3', + }); + + const result = await service.getSessionStatus(mockSessionId); + + expect(result).toEqual({ + expectedCount: 3, + completedCount: 3, + isReady: true, + }); + }); + + it('should compute isReady as false if 0 jobs are expected (e.g. tracking anomaly)', async () => { + mockRedisService.exists.mockResolvedValue(true); + mockRedisClient.hgetall.mockResolvedValue({}); // defaults to 0 + + const result = await service.getSessionStatus(mockSessionId); + + expect(result).toEqual({ + expectedCount: 0, + completedCount: 0, + isReady: false, + }); + }); + + it('should throw NotFoundException if meta tracking key does not exist', async () => { + mockRedisService.exists.mockResolvedValue(false); + + await expect(service.getSessionStatus(mockSessionId)).rejects.toThrow(NotFoundException); + }); + }); +}); diff --git a/src/modules/onboarding/voice/tests/voice-transcription.service.spec.ts b/src/modules/onboarding/voice/tests/voice-transcription.service.spec.ts new file mode 100644 index 00000000..92ac050d --- /dev/null +++ b/src/modules/onboarding/voice/tests/voice-transcription.service.spec.ts @@ -0,0 +1,134 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { VoiceTranscriptionService } from '../services/voice-transcription.service'; +import { VoiceProvider } from '../enums/voice-onboarding.enums'; + +// Mock Groq SDK +const mockGroqCreate = jest.fn(); +jest.mock('groq-sdk', () => { + return jest.fn().mockImplementation(() => ({ + audio: { + transcriptions: { + create: mockGroqCreate, + }, + }, + })); +}); + +describe('VoiceTranscriptionService', () => { + let service: VoiceTranscriptionService; + + beforeEach(async () => { + jest.clearAllMocks(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [VoiceTranscriptionService], + }).compile(); + + module.useLogger(false); // Suppresses logger outputs during tests + service = module.get(VoiceTranscriptionService); + + // Mock global fetch for AssemblyAI fallback + global.fetch = jest.fn(); + }); + + afterEach(() => { + (global.fetch as jest.Mock).mockClear(); + }); + + describe('transcribe', () => { + const mockAudioBuffer = Buffer.from('test-audio'); + const mockFileName = 'test.webm'; + + it('should successfully transcribe audio using Groq API', async () => { + mockGroqCreate.mockResolvedValue({ text: 'This is a test transcript from Groq.' }); + + const result = await service.transcribe(mockAudioBuffer, mockFileName); + + expect(mockGroqCreate).toHaveBeenCalled(); + // Groq receives a File-like blob, which SDK handles internally from node Blob + expect(result).toEqual({ + transcript: 'This is a test transcript from Groq.', + provider: VoiceProvider.GROQ, + }); + expect(global.fetch).not.toHaveBeenCalled(); // AssemblyAI fallback should not be called + }); + + it('should fallback to AssemblyAI if Groq throws an error', async () => { + // Force Groq to fail + mockGroqCreate.mockRejectedValue(new Error('Groq rate limit exceeded')); + + // Mock AssemblyAI Upload step + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue({ upload_url: 'https://assembly.ai/upload-123' }), + }); + + // Mock AssemblyAI Transcript Request step + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue({ id: 'transcript-456' }), + }); + + // Mock AssemblyAI Polling step (simulating polling taking 1 attempt) + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue({ status: 'completed', text: 'This is a test transcript from AssemblyAI.' }), + }); + + const result = await service.transcribe(mockAudioBuffer, mockFileName); + + expect(mockGroqCreate).toHaveBeenCalled(); // Tried Groq first + expect(global.fetch).toHaveBeenCalledTimes(3); // Upload, Request, Poll + + expect(result).toEqual({ + transcript: 'This is a test transcript from AssemblyAI.', + provider: VoiceProvider.ASSEMBLYAI, + }); + }); + + it('should throw an error if BOTH Groq and AssemblyAI APIs fail', async () => { + // Force Groq to fail + mockGroqCreate.mockRejectedValue(new Error('Groq rate limit exceeded')); + + // Force AssemblyAI Upload to fail + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 500, + }); + + await expect(service.transcribe(mockAudioBuffer, mockFileName)).rejects.toThrow( + 'Transcription failed on both providers', + ); + + expect(mockGroqCreate).toHaveBeenCalled(); + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + + it('should throw AssemblyAI timeout/error if polling fails', async () => { + // Force Groq to fail + mockGroqCreate.mockRejectedValue(new Error('Groq failed')); + + // Upload succeeds + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue({ upload_url: 'https://assembly.ai/upload-123' }), + }); + + // Transcript request succeeds + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue({ id: 'transcript-456' }), + }); + + // Polling returns error status + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue({ status: 'error', error: 'Audio unreadable' }), + }); + + await expect(service.transcribe(mockAudioBuffer, mockFileName)).rejects.toThrow( + 'Transcription failed on both providers', + ); + }); + }); +}); From 0aacea3139da1fbbf851bbd050fea8d8b3f265dc Mon Sep 17 00:00:00 2001 From: elijah arhinful Date: Mon, 8 Jun 2026 21:27:37 +0000 Subject: [PATCH 20/37] fix(audio): resolve failing lint tests --- .../onboarding/voice/services/voice-transcription.service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/modules/onboarding/voice/services/voice-transcription.service.ts b/src/modules/onboarding/voice/services/voice-transcription.service.ts index 70ee7068..f3d6d220 100644 --- a/src/modules/onboarding/voice/services/voice-transcription.service.ts +++ b/src/modules/onboarding/voice/services/voice-transcription.service.ts @@ -21,7 +21,7 @@ export class VoiceTranscriptionService { // Fallback to AssemblyAI if (!env.ASSEMBLYAI_API_KEY) { - throw new Error('AssemblyAI fallback unavailable: Missing API key'); + throw new Error('AssemblyAI fallback unavailable: Missing API key', { cause: error }); } try { @@ -29,7 +29,7 @@ export class VoiceTranscriptionService { return { transcript, provider: VoiceProvider.ASSEMBLYAI }; } catch (fallbackError: unknown) { this.logger.error('AssemblyAI transcription failed', fallbackError instanceof Error ? fallbackError.stack : 'Unknown error'); - throw new Error('Transcription failed on both providers'); + throw new Error('Transcription failed on both providers', { cause: fallbackError }); } } } From 9037226585b78079cad47be650a92475967ea76c Mon Sep 17 00:00:00 2001 From: elijah arhinful Date: Mon, 8 Jun 2026 21:58:09 +0000 Subject: [PATCH 21/37] fix(audio): resolve rabbit comments --- src/constants/redis-keys.ts | 4 +- src/constants/system.messages.ts | 3 +- ...80950180322-AddSourceTypeToUploadEntity.ts | 4 +- .../voice-onboarding.controller.ts | 7 +- .../docs/voice-onboarding-swagger.doc.ts | 2 +- .../interfaces/voice-onboarding.interfaces.ts | 2 + .../voice-transcription.processor.ts | 27 ++++++-- .../services/voice-onboarding.service.ts | 45 +++++++----- .../services/voice-transcription.service.ts | 7 ++ .../tests/voice-onboarding.controller.spec.ts | 7 +- .../tests/voice-onboarding.service.spec.ts | 69 ++++++++++--------- .../tests/voice-transcription.service.spec.ts | 9 ++- src/modules/redis/redis.service.ts | 58 ++++++++++++++++ .../entities/uploaded-document.entity.ts | 13 +++- 14 files changed, 184 insertions(+), 73 deletions(-) diff --git a/src/constants/redis-keys.ts b/src/constants/redis-keys.ts index faad947c..6334d44e 100644 --- a/src/constants/redis-keys.ts +++ b/src/constants/redis-keys.ts @@ -21,6 +21,6 @@ export const redisKeys = { adminDashboardUserStages: () => 'admin-dashboard:user-stages', adminDashboardUserRetention: () => 'admin-dashboard:user-retention', - voiceSession: (sessionId: string) => `voice_session:${sessionId}`, - voiceSessionMeta: (sessionId: string) => `voice_session_meta:${sessionId}`, + voiceSession: (userId: string, sessionId: string) => `voice_session:${userId}:${sessionId}`, + voiceSessionMeta: (userId: string, sessionId: string) => `voice_session_meta:${userId}:${sessionId}`, }; diff --git a/src/constants/system.messages.ts b/src/constants/system.messages.ts index c76f2a7d..0913c83e 100644 --- a/src/constants/system.messages.ts +++ b/src/constants/system.messages.ts @@ -373,4 +373,5 @@ export const VOICE_FILE_TOO_LARGE = 'Recording is too large. Please try a shorte export const VOICE_AUDIO_TOO_LONG = 'Please keep each recording under 2 minutes.'; export const VOICE_SESSION_EXPIRED = 'Your session has expired. Please start again.'; export const VOICE_TRANSCRIPTION_EMPTY = 'We couldn\'t understand that recording. Please try again in a quieter environment.'; -export const VOICE_TRANSCRIPTION_UNAVAILABLE = 'Transcription is temporarily unavailable. Please try again in a moment.'; \ No newline at end of file +export const VOICE_TRANSCRIPTION_UNAVAILABLE = 'Transcription is temporarily unavailable. Please try again in a moment.'; +export const VOICE_STATUS_RETRIEVED = 'Status retrieved successfully'; \ No newline at end of file diff --git a/src/database/migrations/1780950180322-AddSourceTypeToUploadEntity.ts b/src/database/migrations/1780950180322-AddSourceTypeToUploadEntity.ts index 7c8dac2a..6ec682cd 100644 --- a/src/database/migrations/1780950180322-AddSourceTypeToUploadEntity.ts +++ b/src/database/migrations/1780950180322-AddSourceTypeToUploadEntity.ts @@ -16,7 +16,8 @@ export class AddSourceTypeToUploadEntity1780950180322 implements MigrationInterf await queryRunner.query(`DROP INDEX "public"."IDX_admin_logs_user_id"`); await queryRunner.query(`DROP INDEX "public"."IDX_admin_logs_action_type"`); await queryRunner.query(`DROP INDEX "public"."IDX_admin_logs_status"`); - await queryRunner.query(`ALTER TABLE "uploaded_documents" ADD "source_type" character varying NOT NULL DEFAULT 'document'`); + await queryRunner.query(`CREATE TYPE "public"."uploaded_documents_source_type_enum" AS ENUM('document', 'voice')`); + await queryRunner.query(`ALTER TABLE "uploaded_documents" ADD "source_type" "public"."uploaded_documents_source_type_enum" NOT NULL DEFAULT 'document'`); await queryRunner.query(`CREATE UNIQUE INDEX "IDX_b780a282603aaa80e05def2203" ON "team_invitations" ("team_id", "email") WHERE "status" = 'pending'`); await queryRunner.query(`CREATE INDEX "IDX_7ace7c4b3262abd89cb75ae53b" ON "admin_logs" ("user_id") `); await queryRunner.query(`CREATE INDEX "IDX_d2b22ec3e7c92f1e670f91a305" ON "admin_logs" ("action_type") `); @@ -45,6 +46,7 @@ export class AddSourceTypeToUploadEntity1780950180322 implements MigrationInterf await queryRunner.query(`DROP INDEX "public"."IDX_7ace7c4b3262abd89cb75ae53b"`); await queryRunner.query(`DROP INDEX "public"."IDX_b780a282603aaa80e05def2203"`); await queryRunner.query(`ALTER TABLE "uploaded_documents" DROP COLUMN "source_type"`); + await queryRunner.query(`DROP TYPE "public"."uploaded_documents_source_type_enum"`); await queryRunner.query(`CREATE INDEX "IDX_admin_logs_status" ON "admin_logs" ("status") `); await queryRunner.query(`CREATE INDEX "IDX_admin_logs_action_type" ON "admin_logs" ("action_type") `); await queryRunner.query(`CREATE INDEX "IDX_admin_logs_user_id" ON "admin_logs" ("user_id") `); diff --git a/src/modules/onboarding/voice/controllers/voice-onboarding.controller.ts b/src/modules/onboarding/voice/controllers/voice-onboarding.controller.ts index 6bbcd487..a9630b82 100644 --- a/src/modules/onboarding/voice/controllers/voice-onboarding.controller.ts +++ b/src/modules/onboarding/voice/controllers/voice-onboarding.controller.ts @@ -44,7 +44,7 @@ export class VoiceOnboardingController { }), ) file: Express.Multer.File, - @Body('voiceSessionId') voiceSessionId?: string, + @Body('voiceSessionId', new ParseUUIDPipe({ optional: true })) voiceSessionId?: string, ) { // Note: Length validation (120s limit) requires an audio parsing library // to strictly enforce without ffmpeg, but a 10MB limit generally handles it for compressed audio. @@ -77,13 +77,14 @@ export class VoiceOnboardingController { @GetVoiceSessionStatusDocs() @HttpCode(HttpStatus.OK) async getVoiceSessionStatus( + @CurrentUser('sub') userId: string, @Param('voiceSessionId', new ParseUUIDPipe()) voiceSessionId: string, ) { - const status = await this.voiceOnboardingService.getSessionStatus(voiceSessionId); + const status = await this.voiceOnboardingService.getSessionStatus(userId, voiceSessionId); return { statusCode: HttpStatus.OK, - message: 'Status retrieved successfully', + message: SYS_MSG.VOICE_STATUS_RETRIEVED, data: VoiceSessionStatusResponseDto.from(status.expectedCount, status.completedCount), }; } diff --git a/src/modules/onboarding/voice/docs/voice-onboarding-swagger.doc.ts b/src/modules/onboarding/voice/docs/voice-onboarding-swagger.doc.ts index 8180fe4c..02005478 100644 --- a/src/modules/onboarding/voice/docs/voice-onboarding-swagger.doc.ts +++ b/src/modules/onboarding/voice/docs/voice-onboarding-swagger.doc.ts @@ -40,7 +40,7 @@ export function UploadVoiceRoundDocs() { format: 'binary', description: 'The audio file to upload and transcribe. Max size 10MB.', }, - voice_session_id: { + voiceSessionId: { type: 'string', format: 'uuid', description: 'Optional. Include to append to an existing session, omit for a new session.', diff --git a/src/modules/onboarding/voice/interfaces/voice-onboarding.interfaces.ts b/src/modules/onboarding/voice/interfaces/voice-onboarding.interfaces.ts index 19540988..85c8c897 100644 --- a/src/modules/onboarding/voice/interfaces/voice-onboarding.interfaces.ts +++ b/src/modules/onboarding/voice/interfaces/voice-onboarding.interfaces.ts @@ -6,4 +6,6 @@ export interface VoiceTranscriptionJobData { userId: string; voiceSessionId: string; storagePath: string; + mimeType: string; + originalName: string; } \ No newline at end of file diff --git a/src/modules/onboarding/voice/processors/voice-transcription.processor.ts b/src/modules/onboarding/voice/processors/voice-transcription.processor.ts index c658975f..521f16c1 100644 --- a/src/modules/onboarding/voice/processors/voice-transcription.processor.ts +++ b/src/modules/onboarding/voice/processors/voice-transcription.processor.ts @@ -25,19 +25,32 @@ export class VoiceTranscriptionProcessor { try { const audioBuffer = await this.objectStorage.getObject(storagePath); - const { transcript, provider } = await this.transcriptionService.transcribe(audioBuffer, 'audio.webm'); + const { transcript, provider } = await this.transcriptionService.transcribe(audioBuffer, job.data.originalName || 'audio.webm'); this.logger.debug(`Transcription successful via ${provider} for session: ${voiceSessionId}`); + // Check idempotency guard + const idempotencyKey = `voice_job_processed:${job.id}`; + const isFirstProcessing = await this.redisService.setNx(idempotencyKey, '1', 1800); + + if (!isFirstProcessing) { + this.logger.debug(`Job ${job.id} already processed, skipping redis updates.`); + await this.objectStorage.deleteObject(storagePath); + return; + } + // Append to Redis list and reset TTL - const sessionKey = redisKeys.voiceSession(voiceSessionId); - const redisClient = this.redisService.getClient(); + const sessionKey = redisKeys.voiceSession(job.data.userId, voiceSessionId); - await redisClient.rpush(sessionKey, transcript); - await redisClient.expire(sessionKey, 1800); // 30 minutes TTL + await this.redisService.rpush(sessionKey, transcript); + await this.redisService.expire(sessionKey, 1800); // 30 minutes TTL // Update completed count - const metaKey = redisKeys.voiceSessionMeta(voiceSessionId); - await redisClient.hincrby(metaKey, 'completedCount', 1); + const metaKey = redisKeys.voiceSessionMeta(job.data.userId, voiceSessionId); + + if (await this.redisService.exists(metaKey)) { + await this.redisService.hincrby(metaKey, 'completedCount', 1); + await this.redisService.expire(metaKey, 1800); + } // Cleanup S3 audio await this.objectStorage.deleteObject(storagePath); diff --git a/src/modules/onboarding/voice/services/voice-onboarding.service.ts b/src/modules/onboarding/voice/services/voice-onboarding.service.ts index aae70cc8..3af7ac8c 100644 --- a/src/modules/onboarding/voice/services/voice-onboarding.service.ts +++ b/src/modules/onboarding/voice/services/voice-onboarding.service.ts @@ -21,17 +21,14 @@ export class VoiceOnboardingService { const sessionId = existingSessionId || randomUUID(); const storagePath = `voice-onboarding/${userId}/${randomUUID()}`; + const metaKey = redisKeys.voiceSessionMeta(userId, sessionId); + // Ensure session validity if providing an existing one if (existingSessionId) { - const exists = await this.redisService.exists(redisKeys.voiceSession(existingSessionId)); + const exists = await this.redisService.exists(metaKey); if (!exists) { throw new NotFoundException('SESSION_EXPIRED'); } - } else { - // Initialize an empty list to track the session creation - await this.redisService.getClient().lpush(redisKeys.voiceSession(sessionId), 'SESSION_START'); - await this.redisService.getClient().expire(redisKeys.voiceSession(sessionId), 1800); - await this.redisService.getClient().lpop(redisKeys.voiceSession(sessionId)); // remove placeholder } // Upload to MinIO/S3 @@ -42,17 +39,14 @@ export class VoiceOnboardingService { contentLength: file.size, }); - // Update meta tracking - const metaKey = redisKeys.voiceSessionMeta(sessionId); - await this.redisService.getClient().hincrby(metaKey, 'expectedCount', 1); - await this.redisService.getClient().expire(metaKey, 1800); - // Enqueue transcription job await this.transcriptionQueue.add( { userId, voiceSessionId: sessionId, storagePath, + mimeType: file.mimetype, + originalName: file.originalname, }, { attempts: 3, @@ -65,18 +59,31 @@ export class VoiceOnboardingService { }, ); + // Update meta tracking only after successful enqueue + await this.redisService.hincrby(metaKey, 'expectedCount', 1); + await this.redisService.expire(metaKey, 1800); + return sessionId; } async completeSession(userId: string, sessionId: string): Promise { - const sessionKey = redisKeys.voiceSession(sessionId); - const exists = await this.redisService.exists(sessionKey); + const sessionKey = redisKeys.voiceSession(userId, sessionId); + const metaKey = redisKeys.voiceSessionMeta(userId, sessionId); + const exists = await this.redisService.exists(metaKey); if (!exists) { throw new NotFoundException('SESSION_EXPIRED'); } - const transcripts = await this.redisService.getClient().lrange(sessionKey, 0, -1); + const meta = await this.redisService.hgetall(metaKey); + const expectedCount = parseInt(meta.expectedCount || '0', 10); + const completedCount = parseInt(meta.completedCount || '0', 10); + + if (expectedCount === 0 || expectedCount !== completedCount) { + throw new BadRequestException('TRANSCRIPTION_INCOMPLETE'); + } + + const transcripts = await this.redisService.lrange(sessionKey, 0, -1); if (transcripts.length === 0) { throw new BadRequestException('TRANSCRIPTION_EMPTY'); } @@ -95,21 +102,21 @@ export class VoiceOnboardingService { parsed_text: combinedTranscript, }); - await this.redisService.getClient().del(sessionKey); - await this.redisService.getClient().del(redisKeys.voiceSessionMeta(sessionId)); + await this.redisService.del(sessionKey); + await this.redisService.del(metaKey); return document.id; } - async getSessionStatus(sessionId: string): Promise<{ expectedCount: number; completedCount: number; isReady: boolean }> { - const metaKey = redisKeys.voiceSessionMeta(sessionId); + async getSessionStatus(userId: string, sessionId: string): Promise<{ expectedCount: number; completedCount: number; isReady: boolean }> { + const metaKey = redisKeys.voiceSessionMeta(userId, sessionId); const exists = await this.redisService.exists(metaKey); if (!exists) { throw new NotFoundException('SESSION_EXPIRED'); } - const meta = await this.redisService.getClient().hgetall(metaKey); + const meta = await this.redisService.hgetall(metaKey); const expectedCount = parseInt(meta.expectedCount || '0', 10); const completedCount = parseInt(meta.completedCount || '0', 10); diff --git a/src/modules/onboarding/voice/services/voice-transcription.service.ts b/src/modules/onboarding/voice/services/voice-transcription.service.ts index f3d6d220..f98db277 100644 --- a/src/modules/onboarding/voice/services/voice-transcription.service.ts +++ b/src/modules/onboarding/voice/services/voice-transcription.service.ts @@ -53,6 +53,7 @@ export class VoiceTranscriptionService { authorization: env.ASSEMBLYAI_API_KEY!, }, body: audioBuffer, + signal: AbortSignal.timeout(10000), // 10s timeout }); if (!uploadRes.ok) { @@ -68,6 +69,7 @@ export class VoiceTranscriptionService { 'content-type': 'application/json', }, body: JSON.stringify({ audio_url: upload_url }), + signal: AbortSignal.timeout(10000), // 10s timeout }); if (!transcriptRes.ok) { @@ -86,8 +88,13 @@ export class VoiceTranscriptionService { for (let i = 0; i < maxAttempts; i++) { const res = await fetch(`https://api.assemblyai.com/v2/transcript/${id}`, { headers: { authorization: env.ASSEMBLYAI_API_KEY! }, + signal: AbortSignal.timeout(10000), // 10s timeout }); + if (!res.ok) { + throw new Error(`AssemblyAI polling failed: ${res.status}`); + } + const data = (await res.json()) as { status: string; text?: string; error?: string }; if (data.status === 'completed' && data.text) { diff --git a/src/modules/onboarding/voice/tests/voice-onboarding.controller.spec.ts b/src/modules/onboarding/voice/tests/voice-onboarding.controller.spec.ts index e5687fef..4fb801c2 100644 --- a/src/modules/onboarding/voice/tests/voice-onboarding.controller.spec.ts +++ b/src/modules/onboarding/voice/tests/voice-onboarding.controller.spec.ts @@ -98,6 +98,7 @@ describe('VoiceOnboardingController', () => { describe('getVoiceSessionStatus', () => { it('should retrieve status and compute correctly formatted response', async () => { + const mockUserId = 'user-123'; const mockSessionId = 'session-456'; mockVoiceOnboardingService.getSessionStatus.mockResolvedValue({ @@ -106,12 +107,12 @@ describe('VoiceOnboardingController', () => { isReady: true, }); - const result = await controller.getVoiceSessionStatus(mockSessionId); + const result = await controller.getVoiceSessionStatus(mockUserId, mockSessionId); - expect(mockVoiceOnboardingService.getSessionStatus).toHaveBeenCalledWith(mockSessionId); + expect(mockVoiceOnboardingService.getSessionStatus).toHaveBeenCalledWith(mockUserId, mockSessionId); expect(result).toEqual({ statusCode: HttpStatus.OK, - message: 'Status retrieved successfully', + message: SYS_MSG.VOICE_STATUS_RETRIEVED, data: { expectedCount: 3, completedCount: 3, diff --git a/src/modules/onboarding/voice/tests/voice-onboarding.service.spec.ts b/src/modules/onboarding/voice/tests/voice-onboarding.service.spec.ts index dea58039..1be0e22b 100644 --- a/src/modules/onboarding/voice/tests/voice-onboarding.service.spec.ts +++ b/src/modules/onboarding/voice/tests/voice-onboarding.service.spec.ts @@ -18,7 +18,8 @@ describe('VoiceOnboardingService', () => { add: jest.fn(), }; - const mockRedisClient = { + const mockRedisService = { + exists: jest.fn(), lpush: jest.fn(), lpop: jest.fn(), expire: jest.fn(), @@ -28,11 +29,6 @@ describe('VoiceOnboardingService', () => { hgetall: jest.fn(), }; - const mockRedisService = { - exists: jest.fn(), - getClient: jest.fn().mockReturnValue(mockRedisClient), - }; - const mockObjectStorage = { putObject: jest.fn(), }; @@ -73,15 +69,7 @@ describe('VoiceOnboardingService', () => { expect(sessionId).toBe('mocked-uuid'); // Verify list initialization - const sessionKey = redisKeys.voiceSession('mocked-uuid'); - expect(mockRedisClient.lpush).toHaveBeenCalledWith(sessionKey, 'SESSION_START'); - expect(mockRedisClient.expire).toHaveBeenCalledWith(sessionKey, 1800); - expect(mockRedisClient.lpop).toHaveBeenCalledWith(sessionKey); - - // Verify tracking - const metaKey = redisKeys.voiceSessionMeta('mocked-uuid'); - expect(mockRedisClient.hincrby).toHaveBeenCalledWith(metaKey, 'expectedCount', 1); - expect(mockRedisClient.expire).toHaveBeenCalledWith(metaKey, 1800); + const metaKey = redisKeys.voiceSessionMeta(mockUserId, 'mocked-uuid'); // Verify object storage expect(mockObjectStorage.putObject).toHaveBeenCalledWith({ @@ -97,9 +85,15 @@ describe('VoiceOnboardingService', () => { userId: mockUserId, voiceSessionId: 'mocked-uuid', storagePath: `voice-onboarding/${mockUserId}/mocked-uuid`, + mimeType: mockFile.mimetype, + originalName: mockFile.originalname, }, expect.objectContaining({ attempts: 3 }), ); + + // Verify tracking after enqueue + expect(mockRedisService.hincrby).toHaveBeenCalledWith(metaKey, 'expectedCount', 1); + expect(mockRedisService.expire).toHaveBeenCalledWith(metaKey, 1800); }); it('should append to an EXISTING session without initializing the list', async () => { @@ -109,11 +103,11 @@ describe('VoiceOnboardingService', () => { const sessionId = await service.handleAudioUpload(mockUserId, mockFile, existingSessionId); expect(sessionId).toBe(existingSessionId); - expect(mockRedisService.exists).toHaveBeenCalledWith(redisKeys.voiceSession(existingSessionId)); - expect(mockRedisClient.lpush).not.toHaveBeenCalled(); + expect(mockRedisService.exists).toHaveBeenCalledWith(redisKeys.voiceSessionMeta(mockUserId, existingSessionId)); + expect(mockRedisService.lpush).not.toHaveBeenCalled(); - const metaKey = redisKeys.voiceSessionMeta(existingSessionId); - expect(mockRedisClient.hincrby).toHaveBeenCalledWith(metaKey, 'expectedCount', 1); + const metaKey = redisKeys.voiceSessionMeta(mockUserId, existingSessionId); + expect(mockRedisService.hincrby).toHaveBeenCalledWith(metaKey, 'expectedCount', 1); }); it('should throw NotFoundException if EXISTING session is expired/missing', async () => { @@ -128,18 +122,22 @@ describe('VoiceOnboardingService', () => { describe('completeSession', () => { const mockUserId = 'user-123'; const mockSessionId = 'session-456'; - const sessionKey = redisKeys.voiceSession(mockSessionId); - const metaKey = redisKeys.voiceSessionMeta(mockSessionId); + const sessionKey = redisKeys.voiceSession(mockUserId, mockSessionId); + const metaKey = redisKeys.voiceSessionMeta(mockUserId, mockSessionId); it('should aggregate transcripts, create a document, and cleanup keys', async () => { mockRedisService.exists.mockResolvedValue(true); - mockRedisClient.lrange.mockResolvedValue(['transcript 1.', 'transcript 2.']); + mockRedisService.hgetall.mockResolvedValue({ + expectedCount: '2', + completedCount: '2', + }); + mockRedisService.lrange.mockResolvedValue(['transcript 1.', 'transcript 2.']); mockDocumentAction.createDocument.mockResolvedValue({ id: 'doc-789' }); const result = await service.completeSession(mockUserId, mockSessionId); expect(result).toBe('doc-789'); - expect(mockRedisClient.lrange).toHaveBeenCalledWith(sessionKey, 0, -1); + expect(mockRedisService.lrange).toHaveBeenCalledWith(sessionKey, 0, -1); expect(mockDocumentAction.createDocument).toHaveBeenCalledWith( expect.objectContaining({ user_id: mockUserId, @@ -152,13 +150,17 @@ describe('VoiceOnboardingService', () => { ); // Cleanup - expect(mockRedisClient.del).toHaveBeenCalledWith(sessionKey); - expect(mockRedisClient.del).toHaveBeenCalledWith(metaKey); + expect(mockRedisService.del).toHaveBeenCalledWith(sessionKey); + expect(mockRedisService.del).toHaveBeenCalledWith(metaKey); }); it('should throw BadRequestException if transcripts list is empty', async () => { mockRedisService.exists.mockResolvedValue(true); - mockRedisClient.lrange.mockResolvedValue([]); + mockRedisService.hgetall.mockResolvedValue({ + expectedCount: '1', + completedCount: '1', + }); + mockRedisService.lrange.mockResolvedValue([]); await expect(service.completeSession(mockUserId, mockSessionId)).rejects.toThrow(BadRequestException); expect(mockDocumentAction.createDocument).not.toHaveBeenCalled(); @@ -172,16 +174,17 @@ describe('VoiceOnboardingService', () => { }); describe('getSessionStatus', () => { + const mockUserId = 'user-123'; const mockSessionId = 'session-456'; it('should compute isReady as false if not all jobs are completed', async () => { mockRedisService.exists.mockResolvedValue(true); - mockRedisClient.hgetall.mockResolvedValue({ + mockRedisService.hgetall.mockResolvedValue({ expectedCount: '3', completedCount: '2', }); - const result = await service.getSessionStatus(mockSessionId); + const result = await service.getSessionStatus(mockUserId, mockSessionId); expect(result).toEqual({ expectedCount: 3, @@ -192,12 +195,12 @@ describe('VoiceOnboardingService', () => { it('should compute isReady as true if all jobs are completed and > 0', async () => { mockRedisService.exists.mockResolvedValue(true); - mockRedisClient.hgetall.mockResolvedValue({ + mockRedisService.hgetall.mockResolvedValue({ expectedCount: '3', completedCount: '3', }); - const result = await service.getSessionStatus(mockSessionId); + const result = await service.getSessionStatus(mockUserId, mockSessionId); expect(result).toEqual({ expectedCount: 3, @@ -208,9 +211,9 @@ describe('VoiceOnboardingService', () => { it('should compute isReady as false if 0 jobs are expected (e.g. tracking anomaly)', async () => { mockRedisService.exists.mockResolvedValue(true); - mockRedisClient.hgetall.mockResolvedValue({}); // defaults to 0 + mockRedisService.hgetall.mockResolvedValue({}); // defaults to 0 - const result = await service.getSessionStatus(mockSessionId); + const result = await service.getSessionStatus(mockUserId, mockSessionId); expect(result).toEqual({ expectedCount: 0, @@ -222,7 +225,7 @@ describe('VoiceOnboardingService', () => { it('should throw NotFoundException if meta tracking key does not exist', async () => { mockRedisService.exists.mockResolvedValue(false); - await expect(service.getSessionStatus(mockSessionId)).rejects.toThrow(NotFoundException); + await expect(service.getSessionStatus(mockUserId, mockSessionId)).rejects.toThrow(NotFoundException); }); }); }); diff --git a/src/modules/onboarding/voice/tests/voice-transcription.service.spec.ts b/src/modules/onboarding/voice/tests/voice-transcription.service.spec.ts index 92ac050d..d28d38ba 100644 --- a/src/modules/onboarding/voice/tests/voice-transcription.service.spec.ts +++ b/src/modules/onboarding/voice/tests/voice-transcription.service.spec.ts @@ -16,6 +16,8 @@ jest.mock('groq-sdk', () => { describe('VoiceTranscriptionService', () => { let service: VoiceTranscriptionService; + let originalFetch: typeof fetch; + let originalApiKey: string | undefined; beforeEach(async () => { jest.clearAllMocks(); @@ -28,11 +30,16 @@ describe('VoiceTranscriptionService', () => { service = module.get(VoiceTranscriptionService); // Mock global fetch for AssemblyAI fallback + originalFetch = global.fetch; global.fetch = jest.fn(); + + originalApiKey = process.env.ASSEMBLYAI_API_KEY; + process.env.ASSEMBLYAI_API_KEY = 'test-key'; }); afterEach(() => { - (global.fetch as jest.Mock).mockClear(); + global.fetch = originalFetch; + process.env.ASSEMBLYAI_API_KEY = originalApiKey; }); describe('transcribe', () => { diff --git a/src/modules/redis/redis.service.ts b/src/modules/redis/redis.service.ts index 57c7da17..ce55d05f 100644 --- a/src/modules/redis/redis.service.ts +++ b/src/modules/redis/redis.service.ts @@ -220,6 +220,64 @@ export class RedisService implements OnModuleInit, OnModuleDestroy { this.logger.log(SYS_MSG.REDIS_CONNECTION_CLOSED); } + async rpush(key: string, ...values: (string | number | Buffer)[]): Promise { + try { + return await this.client.rpush(key, ...values); + } catch (err) { + this.logger.error(`RPUSH failed`, (err as Error).message); + return 0; + } + } + + async lpush(key: string, ...values: (string | number | Buffer)[]): Promise { + try { + return await this.client.lpush(key, ...values); + } catch (err) { + this.logger.error(`LPUSH failed`, (err as Error).message); + return 0; + } + } + + async lpop(key: string): Promise { + try { + return await this.client.lpop(key); + } catch (err) { + this.logger.error(`LPOP failed`, (err as Error).message); + return null; + } + } + + async lrange(key: string, start: number, stop: number): Promise { + try { + return await this.client.lrange(key, start, stop); + } catch (err) { + this.logger.error(`LRANGE failed`, (err as Error).message); + return []; + } + } + + async hincrby(key: string, field: string, increment: number): Promise { + try { + return await this.client.hincrby(key, field, increment); + } catch (err) { + this.logger.error(`HINCRBY failed`, (err as Error).message); + return 0; + } + } + + async hgetall(key: string): Promise> { + try { + return await this.client.hgetall(key); + } catch (err) { + this.logger.error(`HGETALL failed`, (err as Error).message); + return {}; + } + } + + /** + * @deprecated Do not use the raw Redis client directly. + * Use the wrapper methods provided by RedisService instead. + */ getClient(): Redis { return this.client; } diff --git a/src/modules/upload/entities/uploaded-document.entity.ts b/src/modules/upload/entities/uploaded-document.entity.ts index d57f105f..fcad4692 100644 --- a/src/modules/upload/entities/uploaded-document.entity.ts +++ b/src/modules/upload/entities/uploaded-document.entity.ts @@ -3,6 +3,11 @@ import { BaseEntity } from '../../../common/entities/base.entity'; import { User } from '../../users/entities/user.entity'; import { UploadDocumentStatus, type UploadFileType } from '../upload.types'; +export enum DocumentSourceType { + DOCUMENT = 'document', + VOICE = 'voice', +} + /** * Funnel upload metadata — matches ERD `uploaded_documents`. * File bytes live in object storage at `storage_path`. @@ -43,8 +48,12 @@ export class UploadedDocument extends BaseEntity { @Column({ type: 'varchar', length: 200, nullable: true }) failure_reason: string | null; - @Column({ type: 'varchar', default: 'document' }) - source_type: string; + @Column({ + type: 'enum', + enum: DocumentSourceType, + default: DocumentSourceType.DOCUMENT, + }) + source_type: DocumentSourceType; @ManyToOne(() => User, { onDelete: 'CASCADE' }) @JoinColumn({ name: 'user_id' }) From 928f633acf869a9c1667b9f69eea7657aa149121 Mon Sep 17 00:00:00 2001 From: elijah arhinful Date: Mon, 8 Jun 2026 22:04:03 +0000 Subject: [PATCH 22/37] fix(audio): resolve document type mismatch --- .../onboarding/voice/services/voice-onboarding.service.ts | 3 ++- .../onboarding/voice/tests/voice-onboarding.service.spec.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/modules/onboarding/voice/services/voice-onboarding.service.ts b/src/modules/onboarding/voice/services/voice-onboarding.service.ts index 3af7ac8c..7c8d396b 100644 --- a/src/modules/onboarding/voice/services/voice-onboarding.service.ts +++ b/src/modules/onboarding/voice/services/voice-onboarding.service.ts @@ -6,6 +6,7 @@ import { RedisService } from '../../../redis/redis.service'; import { redisKeys } from '../../../../constants/redis-keys'; import { UPLOAD_OBJECT_STORAGE, UploadDocumentStatus, type ObjectStorage } from '../../../upload/upload.types'; import { UploadedDocumentModelAction } from '../../../upload/actions/uploaded-document.action'; +import { DocumentSourceType } from '../../../upload/entities/uploaded-document.entity'; import { VoiceTranscriptionJobData } from '../interfaces/voice-onboarding.interfaces'; @Injectable() @@ -98,7 +99,7 @@ export class VoiceOnboardingService { status: UploadDocumentStatus.READY, percent_complete: 100, storage_path: 'voice-onboarding-virtual', - source_type: 'voice', + source_type: DocumentSourceType.VOICE, parsed_text: combinedTranscript, }); diff --git a/src/modules/onboarding/voice/tests/voice-onboarding.service.spec.ts b/src/modules/onboarding/voice/tests/voice-onboarding.service.spec.ts index 1be0e22b..e021dbd6 100644 --- a/src/modules/onboarding/voice/tests/voice-onboarding.service.spec.ts +++ b/src/modules/onboarding/voice/tests/voice-onboarding.service.spec.ts @@ -4,6 +4,7 @@ import { BadRequestException, NotFoundException } from '@nestjs/common'; import { VoiceOnboardingService } from '../services/voice-onboarding.service'; import { RedisService } from '../../../redis/redis.service'; import { UPLOAD_OBJECT_STORAGE, UploadDocumentStatus } from '../../../upload/upload.types'; +import { DocumentSourceType } from '../../../upload/entities/uploaded-document.entity'; import { UploadedDocumentModelAction } from '../../../upload/actions/uploaded-document.action'; import { redisKeys } from '../../../../constants/redis-keys'; @@ -144,7 +145,7 @@ describe('VoiceOnboardingService', () => { file_type: 'doc', status: UploadDocumentStatus.READY, percent_complete: 100, - source_type: 'voice', + source_type: DocumentSourceType.VOICE, parsed_text: 'transcript 1. transcript 2.', }), ); From 0fb57c89a3c2cb656253a91e3c9888a5034257de Mon Sep 17 00:00:00 2001 From: elijah arhinful Date: Mon, 8 Jun 2026 22:36:36 +0000 Subject: [PATCH 23/37] feat(audio): add active session retrieval endpoint --- src/constants/redis-keys.ts | 1 + src/constants/system.messages.ts | 4 +- .../voice-onboarding.controller.ts | 24 +- .../docs/voice-onboarding-swagger.doc.ts | 40 ++- .../voice/dto/voice-onboarding.dto.ts | 4 +- .../services/voice-onboarding.service.ts | 20 ++ .../tests/voice-onboarding.controller.spec.ts | 284 ++++++++++-------- .../tests/voice-onboarding.service.spec.ts | 36 +++ 8 files changed, 283 insertions(+), 130 deletions(-) diff --git a/src/constants/redis-keys.ts b/src/constants/redis-keys.ts index 6334d44e..e767307c 100644 --- a/src/constants/redis-keys.ts +++ b/src/constants/redis-keys.ts @@ -23,4 +23,5 @@ export const redisKeys = { voiceSession: (userId: string, sessionId: string) => `voice_session:${userId}:${sessionId}`, voiceSessionMeta: (userId: string, sessionId: string) => `voice_session_meta:${userId}:${sessionId}`, + activeVoiceSession: (userId: string) => `active_voice_session:${userId}`, }; diff --git a/src/constants/system.messages.ts b/src/constants/system.messages.ts index 0913c83e..a0e8dc98 100644 --- a/src/constants/system.messages.ts +++ b/src/constants/system.messages.ts @@ -374,4 +374,6 @@ export const VOICE_AUDIO_TOO_LONG = 'Please keep each recording under 2 minutes. export const VOICE_SESSION_EXPIRED = 'Your session has expired. Please start again.'; export const VOICE_TRANSCRIPTION_EMPTY = 'We couldn\'t understand that recording. Please try again in a quieter environment.'; export const VOICE_TRANSCRIPTION_UNAVAILABLE = 'Transcription is temporarily unavailable. Please try again in a moment.'; -export const VOICE_STATUS_RETRIEVED = 'Status retrieved successfully'; \ No newline at end of file +export const VOICE_STATUS_RETRIEVED = 'Status retrieved successfully'; +export const VOICE_ACTIVE_SESSION_RETRIEVED = 'Active session retrieved successfully'; +export const VOICE_NO_ACTIVE_SESSION = 'No active session exists'; \ No newline at end of file diff --git a/src/modules/onboarding/voice/controllers/voice-onboarding.controller.ts b/src/modules/onboarding/voice/controllers/voice-onboarding.controller.ts index a9630b82..728c2fe9 100644 --- a/src/modules/onboarding/voice/controllers/voice-onboarding.controller.ts +++ b/src/modules/onboarding/voice/controllers/voice-onboarding.controller.ts @@ -16,7 +16,7 @@ import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { CurrentUser } from '../../../../common/decorators/current-user.decorator'; import * as SYS_MSG from '../../../../constants/system.messages'; import { CompleteVoiceSessionDto, VoiceSessionCompleteResponseDto, VoiceSessionResponseDto, VoiceSessionStatusResponseDto } from '../dto/voice-onboarding.dto'; -import { CompleteVoiceSessionDocs, UploadVoiceRoundDocs, GetVoiceSessionStatusDocs } from '../docs/voice-onboarding-swagger.doc'; +import { CompleteVoiceSessionDocs, UploadVoiceRoundDocs, GetVoiceSessionStatusDocs, GetActiveVoiceSessionDocs } from '../docs/voice-onboarding-swagger.doc'; import { VoiceOnboardingService } from '../services/voice-onboarding.service'; const MAX_AUDIO_BYTES = 10 * 1024 * 1024; // 10MB @@ -73,6 +73,28 @@ export class VoiceOnboardingController { }; } + @Get('active') + @GetActiveVoiceSessionDocs() + @HttpCode(HttpStatus.OK) + async getActiveVoiceSession(@CurrentUser('sub') userId: string) { + const sessionId = await this.voiceOnboardingService.getActiveSession(userId); + if (!sessionId) { + return { + statusCode: HttpStatus.NOT_FOUND, + message: SYS_MSG.VOICE_NO_ACTIVE_SESSION, + data: null, + }; + } + + return { + statusCode: HttpStatus.OK, + message: SYS_MSG.VOICE_ACTIVE_SESSION_RETRIEVED, + data: { + voiceSessionId: sessionId, + }, + }; + } + @Get(':voiceSessionId/status') @GetVoiceSessionStatusDocs() @HttpCode(HttpStatus.OK) diff --git a/src/modules/onboarding/voice/docs/voice-onboarding-swagger.doc.ts b/src/modules/onboarding/voice/docs/voice-onboarding-swagger.doc.ts index 02005478..f1fab47d 100644 --- a/src/modules/onboarding/voice/docs/voice-onboarding-swagger.doc.ts +++ b/src/modules/onboarding/voice/docs/voice-onboarding-swagger.doc.ts @@ -112,7 +112,7 @@ export function CompleteVoiceSessionDocs() { description: 'Finalizes a voice onboarding session by aggregating all transcriptions ' + 'associated with the session ID. The aggregated text is then saved as a new ' + - 'UploadDocument record. Returns the resulting upload_id which can be passed ' + + 'UploadDocument record. Returns the resulting uploadId which can be passed ' + 'to the funnel generation endpoints.', }), ApiBody({ type: CompleteVoiceSessionDto }), @@ -124,7 +124,7 @@ export function CompleteVoiceSessionDocs() { statusCode: HttpStatus.OK, message: SYS_MSG.VOICE_SESSION_COMPLETED, data: { - upload_id: '123e4567-e89b-12d3-a456-426614174000', + uploadId: '123e4567-e89b-12d3-a456-426614174000', }, }, }, @@ -204,6 +204,42 @@ export function GetVoiceSessionStatusDocs() { }, }, }), + ApiUnauthorizedResponse({ + schema: { example: unauthorizedExample }, + }), + ); +} + +export function GetActiveVoiceSessionDocs() { + return applyDecorators( + ApiOperation({ + summary: 'Retrieve the currently active voice session', + description: 'Fetches the voiceSessionId of the current user\'s active voice session, if one exists.', + }), + ApiOkResponse({ + description: 'Active session retrieved successfully', + schema: { + example: { + success: true, + statusCode: HttpStatus.OK, + message: SYS_MSG.VOICE_ACTIVE_SESSION_RETRIEVED, + data: { + voiceSessionId: '550e8400-e29b-41d4-a716-446655440001', + }, + }, + }, + }), + ApiNotFoundResponse({ + description: 'No active session exists for this user.', + schema: { + example: { + success: false, + statusCode: HttpStatus.NOT_FOUND, + error: 'NotFoundException', + message: SYS_MSG.VOICE_NO_ACTIVE_SESSION, + }, + }, + }), ApiUnauthorizedResponse({ description: 'Missing or invalid JWT token.', schema: { example: unauthorizedExample }, diff --git a/src/modules/onboarding/voice/dto/voice-onboarding.dto.ts b/src/modules/onboarding/voice/dto/voice-onboarding.dto.ts index a29c3327..e18dcedb 100644 --- a/src/modules/onboarding/voice/dto/voice-onboarding.dto.ts +++ b/src/modules/onboarding/voice/dto/voice-onboarding.dto.ts @@ -37,11 +37,11 @@ export class VoiceSessionCompleteResponseDto { description: 'The generated UploadDocument ID representing the finalized transcription', example: '123e4567-e89b-12d3-a456-426614174000', }) - upload_id: string; + uploadId: string; static from(uploadId: string): VoiceSessionCompleteResponseDto { const dto = new VoiceSessionCompleteResponseDto(); - dto.upload_id = uploadId; + dto.uploadId = uploadId; return dto; } } diff --git a/src/modules/onboarding/voice/services/voice-onboarding.service.ts b/src/modules/onboarding/voice/services/voice-onboarding.service.ts index 7c8d396b..46dfc1ee 100644 --- a/src/modules/onboarding/voice/services/voice-onboarding.service.ts +++ b/src/modules/onboarding/voice/services/voice-onboarding.service.ts @@ -64,6 +64,9 @@ export class VoiceOnboardingService { await this.redisService.hincrby(metaKey, 'expectedCount', 1); await this.redisService.expire(metaKey, 1800); + // Track as the currently active session for this user + await this.redisService.setStrict(redisKeys.activeVoiceSession(userId), sessionId, 1800); + return sessionId; } @@ -105,6 +108,7 @@ export class VoiceOnboardingService { await this.redisService.del(sessionKey); await this.redisService.del(metaKey); + await this.redisService.del(redisKeys.activeVoiceSession(userId)); return document.id; } @@ -127,4 +131,20 @@ export class VoiceOnboardingService { isReady: expectedCount > 0 && expectedCount === completedCount, }; } + + async getActiveSession(userId: string): Promise { + const sessionId = await this.redisService.get(redisKeys.activeVoiceSession(userId)); + if (!sessionId) { + return null; + } + + // Verify it's still a valid session (hasn't expired/completed) + const exists = await this.redisService.exists(redisKeys.voiceSessionMeta(userId, sessionId)); + if (!exists) { + await this.redisService.del(redisKeys.activeVoiceSession(userId)); + return null; + } + + return sessionId; + } } \ No newline at end of file diff --git a/src/modules/onboarding/voice/tests/voice-onboarding.controller.spec.ts b/src/modules/onboarding/voice/tests/voice-onboarding.controller.spec.ts index 4fb801c2..44dfceb3 100644 --- a/src/modules/onboarding/voice/tests/voice-onboarding.controller.spec.ts +++ b/src/modules/onboarding/voice/tests/voice-onboarding.controller.spec.ts @@ -1,124 +1,160 @@ -import { HttpStatus } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; -import { VoiceOnboardingController } from '../controllers/voice-onboarding.controller'; -import { VoiceOnboardingService } from '../services/voice-onboarding.service'; -import * as SYS_MSG from '../../../../constants/system.messages'; -import { CompleteVoiceSessionDto } from '../dto/voice-onboarding.dto'; - -describe('VoiceOnboardingController', () => { - let controller: VoiceOnboardingController; - - const mockVoiceOnboardingService = { - handleAudioUpload: jest.fn(), - completeSession: jest.fn(), - getSessionStatus: jest.fn(), - }; - - beforeEach(async () => { - jest.clearAllMocks(); - - const module: TestingModule = await Test.createTestingModule({ - controllers: [VoiceOnboardingController], - providers: [ - { - provide: VoiceOnboardingService, - useValue: mockVoiceOnboardingService, - }, - ], - }).compile(); - - controller = module.get(VoiceOnboardingController); - }); - - describe('uploadVoiceRound', () => { - it('should successfully handle audio upload and return correct response', async () => { - const mockUserId = 'user-123'; - const mockSessionId = 'session-456'; - const mockFile = { - buffer: Buffer.from('test-audio'), - mimetype: 'audio/webm', - size: 1024, - } as Express.Multer.File; - - mockVoiceOnboardingService.handleAudioUpload.mockResolvedValue(mockSessionId); - - const result = await controller.uploadVoiceRound(mockUserId, mockFile, undefined); - - expect(mockVoiceOnboardingService.handleAudioUpload).toHaveBeenCalledWith(mockUserId, mockFile, undefined); - expect(result).toEqual({ - statusCode: HttpStatus.OK, - message: SYS_MSG.VOICE_UPLOAD_ACCEPTED, - data: { - voiceSessionId: mockSessionId, - status: 'processing', - }, - }); - }); - - it('should handle audio upload with an existing session ID', async () => { - const mockUserId = 'user-123'; - const mockSessionId = 'existing-session-456'; - const mockFile = { - buffer: Buffer.from('test-audio'), - mimetype: 'audio/webm', - size: 1024, - } as Express.Multer.File; - - mockVoiceOnboardingService.handleAudioUpload.mockResolvedValue(mockSessionId); - - const result = await controller.uploadVoiceRound(mockUserId, mockFile, mockSessionId); - - expect(mockVoiceOnboardingService.handleAudioUpload).toHaveBeenCalledWith(mockUserId, mockFile, mockSessionId); - expect(result.data.voiceSessionId).toBe(mockSessionId); - }); - }); - - describe('completeVoiceSession', () => { - it('should successfully complete the voice session and return the upload ID', async () => { - const mockUserId = 'user-123'; - const mockUploadId = 'upload-789'; - const mockDto: CompleteVoiceSessionDto = { - voiceSessionId: 'session-456', - }; - - mockVoiceOnboardingService.completeSession.mockResolvedValue(mockUploadId); - - const result = await controller.completeVoiceSession(mockUserId, mockDto); - - expect(mockVoiceOnboardingService.completeSession).toHaveBeenCalledWith(mockUserId, mockDto.voiceSessionId); - expect(result).toEqual({ - statusCode: HttpStatus.OK, - message: SYS_MSG.VOICE_SESSION_COMPLETED, - data: { - upload_id: mockUploadId, - }, - }); - }); - }); - - describe('getVoiceSessionStatus', () => { - it('should retrieve status and compute correctly formatted response', async () => { - const mockUserId = 'user-123'; - const mockSessionId = 'session-456'; - - mockVoiceOnboardingService.getSessionStatus.mockResolvedValue({ - expectedCount: 3, - completedCount: 3, - isReady: true, - }); - - const result = await controller.getVoiceSessionStatus(mockUserId, mockSessionId); - - expect(mockVoiceOnboardingService.getSessionStatus).toHaveBeenCalledWith(mockUserId, mockSessionId); - expect(result).toEqual({ - statusCode: HttpStatus.OK, - message: SYS_MSG.VOICE_STATUS_RETRIEVED, - data: { - expectedCount: 3, - completedCount: 3, - isReady: true, - }, - }); - }); - }); -}); +import { HttpStatus } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { VoiceOnboardingController } from '../controllers/voice-onboarding.controller'; +import { VoiceOnboardingService } from '../services/voice-onboarding.service'; +import * as SYS_MSG from '../../../../constants/system.messages'; +import { CompleteVoiceSessionDto } from '../dto/voice-onboarding.dto'; + +describe('VoiceOnboardingController', () => { + let controller: VoiceOnboardingController; + + const mockVoiceOnboardingService = { + handleAudioUpload: jest.fn(), + completeSession: jest.fn(), + getSessionStatus: jest.fn(), + getActiveSession: jest.fn(), + }; + + beforeEach(async () => { + jest.clearAllMocks(); + + const module: TestingModule = await Test.createTestingModule({ + controllers: [VoiceOnboardingController], + providers: [ + { + provide: VoiceOnboardingService, + useValue: mockVoiceOnboardingService, + }, + ], + }).compile(); + + controller = module.get(VoiceOnboardingController); + }); + + describe('uploadVoiceRound', () => { + it('should successfully handle audio upload and return correct response', async () => { + const mockUserId = 'user-123'; + const mockSessionId = 'session-456'; + const mockFile = { + buffer: Buffer.from('test-audio'), + mimetype: 'audio/webm', + size: 1024, + } as Express.Multer.File; + + mockVoiceOnboardingService.handleAudioUpload.mockResolvedValue(mockSessionId); + + const result = await controller.uploadVoiceRound(mockUserId, mockFile, undefined); + + expect(mockVoiceOnboardingService.handleAudioUpload).toHaveBeenCalledWith(mockUserId, mockFile, undefined); + expect(result).toEqual({ + statusCode: HttpStatus.OK, + message: SYS_MSG.VOICE_UPLOAD_ACCEPTED, + data: { + voiceSessionId: mockSessionId, + status: 'processing', + }, + }); + }); + + it('should handle audio upload with an existing session ID', async () => { + const mockUserId = 'user-123'; + const mockSessionId = 'existing-session-456'; + const mockFile = { + buffer: Buffer.from('test-audio'), + mimetype: 'audio/webm', + size: 1024, + } as Express.Multer.File; + + mockVoiceOnboardingService.handleAudioUpload.mockResolvedValue(mockSessionId); + + const result = await controller.uploadVoiceRound(mockUserId, mockFile, mockSessionId); + + expect(mockVoiceOnboardingService.handleAudioUpload).toHaveBeenCalledWith(mockUserId, mockFile, mockSessionId); + expect(result.data.voiceSessionId).toBe(mockSessionId); + }); + }); + + describe('completeVoiceSession', () => { + it('should successfully complete the voice session and return the upload ID', async () => { + const mockUserId = 'user-123'; + const mockUploadId = 'upload-789'; + const mockDto: CompleteVoiceSessionDto = { + voiceSessionId: 'session-456', + }; + + mockVoiceOnboardingService.completeSession.mockResolvedValue(mockUploadId); + + const result = await controller.completeVoiceSession(mockUserId, mockDto); + + expect(mockVoiceOnboardingService.completeSession).toHaveBeenCalledWith(mockUserId, mockDto.voiceSessionId); + expect(result).toEqual({ + statusCode: HttpStatus.OK, + message: SYS_MSG.VOICE_SESSION_COMPLETED, + data: { + uploadId: mockUploadId, + }, + }); + }); + }); + + describe('getActiveVoiceSession', () => { + it('should return the active session if one exists', async () => { + const mockUserId = 'user-123'; + const mockSessionId = 'active-session-456'; + + mockVoiceOnboardingService.getActiveSession.mockResolvedValue(mockSessionId); + + const result = await controller.getActiveVoiceSession(mockUserId); + + expect(mockVoiceOnboardingService.getActiveSession).toHaveBeenCalledWith(mockUserId); + expect(result).toEqual({ + statusCode: HttpStatus.OK, + message: SYS_MSG.VOICE_ACTIVE_SESSION_RETRIEVED, + data: { + voiceSessionId: mockSessionId, + }, + }); + }); + + it('should return 404 if no active session exists', async () => { + const mockUserId = 'user-123'; + + mockVoiceOnboardingService.getActiveSession.mockResolvedValue(null); + + const result = await controller.getActiveVoiceSession(mockUserId); + + expect(mockVoiceOnboardingService.getActiveSession).toHaveBeenCalledWith(mockUserId); + expect(result).toEqual({ + statusCode: HttpStatus.NOT_FOUND, + message: SYS_MSG.VOICE_NO_ACTIVE_SESSION, + data: null, + }); + }); + }); + + describe('getVoiceSessionStatus', () => { + it('should retrieve status and compute correctly formatted response', async () => { + const mockUserId = 'user-123'; + const mockSessionId = 'session-456'; + + mockVoiceOnboardingService.getSessionStatus.mockResolvedValue({ + expectedCount: 3, + completedCount: 3, + isReady: true, + }); + + const result = await controller.getVoiceSessionStatus(mockUserId, mockSessionId); + + expect(mockVoiceOnboardingService.getSessionStatus).toHaveBeenCalledWith(mockUserId, mockSessionId); + expect(result).toEqual({ + statusCode: HttpStatus.OK, + message: SYS_MSG.VOICE_STATUS_RETRIEVED, + data: { + expectedCount: 3, + completedCount: 3, + isReady: true, + }, + }); + }); + }); +}); diff --git a/src/modules/onboarding/voice/tests/voice-onboarding.service.spec.ts b/src/modules/onboarding/voice/tests/voice-onboarding.service.spec.ts index e021dbd6..867b19b6 100644 --- a/src/modules/onboarding/voice/tests/voice-onboarding.service.spec.ts +++ b/src/modules/onboarding/voice/tests/voice-onboarding.service.spec.ts @@ -21,6 +21,8 @@ describe('VoiceOnboardingService', () => { const mockRedisService = { exists: jest.fn(), + get: jest.fn(), + setStrict: jest.fn(), lpush: jest.fn(), lpop: jest.fn(), expire: jest.fn(), @@ -229,4 +231,38 @@ describe('VoiceOnboardingService', () => { await expect(service.getSessionStatus(mockUserId, mockSessionId)).rejects.toThrow(NotFoundException); }); }); + + describe('getActiveSession', () => { + const mockUserId = 'user-123'; + const mockSessionId = 'active-session-456'; + + it('should return null if no active session key exists', async () => { + mockRedisService.get.mockResolvedValue(null); + + const result = await service.getActiveSession(mockUserId); + + expect(mockRedisService.get).toHaveBeenCalledWith(redisKeys.activeVoiceSession(mockUserId)); + expect(result).toBeNull(); + }); + + it('should return null and delete key if active session key exists but meta does not', async () => { + mockRedisService.get.mockResolvedValue(mockSessionId); + mockRedisService.exists.mockResolvedValue(false); + + const result = await service.getActiveSession(mockUserId); + + expect(mockRedisService.exists).toHaveBeenCalledWith(redisKeys.voiceSessionMeta(mockUserId, mockSessionId)); + expect(mockRedisService.del).toHaveBeenCalledWith(redisKeys.activeVoiceSession(mockUserId)); + expect(result).toBeNull(); + }); + + it('should return sessionId if active session is fully valid', async () => { + mockRedisService.get.mockResolvedValue(mockSessionId); + mockRedisService.exists.mockResolvedValue(true); + + const result = await service.getActiveSession(mockUserId); + + expect(result).toBe(mockSessionId); + }); + }); }); From 4cd190ab60d71fb8e68460a7d6adc3e374542f37 Mon Sep 17 00:00:00 2001 From: elijah arhinful Date: Mon, 8 Jun 2026 23:00:36 +0000 Subject: [PATCH 24/37] fix(audio): fix code rabbit comments --- .../voice/controllers/voice-onboarding.controller.ts | 2 +- .../voice/docs/voice-onboarding-swagger.doc.ts | 11 ----------- .../voice/interfaces/voice-onboarding.interfaces.ts | 2 +- .../voice/processors/voice-transcription.processor.ts | 6 +++--- .../voice/tests/voice-onboarding.controller.spec.ts | 3 +-- .../voice/tests/voice-onboarding.service.spec.ts | 1 + src/modules/redis/redis.service.ts | 6 +++--- 7 files changed, 10 insertions(+), 21 deletions(-) diff --git a/src/modules/onboarding/voice/controllers/voice-onboarding.controller.ts b/src/modules/onboarding/voice/controllers/voice-onboarding.controller.ts index 728c2fe9..37df5b69 100644 --- a/src/modules/onboarding/voice/controllers/voice-onboarding.controller.ts +++ b/src/modules/onboarding/voice/controllers/voice-onboarding.controller.ts @@ -80,7 +80,7 @@ export class VoiceOnboardingController { const sessionId = await this.voiceOnboardingService.getActiveSession(userId); if (!sessionId) { return { - statusCode: HttpStatus.NOT_FOUND, + statusCode: HttpStatus.OK, message: SYS_MSG.VOICE_NO_ACTIVE_SESSION, data: null, }; diff --git a/src/modules/onboarding/voice/docs/voice-onboarding-swagger.doc.ts b/src/modules/onboarding/voice/docs/voice-onboarding-swagger.doc.ts index f1fab47d..c002293a 100644 --- a/src/modules/onboarding/voice/docs/voice-onboarding-swagger.doc.ts +++ b/src/modules/onboarding/voice/docs/voice-onboarding-swagger.doc.ts @@ -229,17 +229,6 @@ export function GetActiveVoiceSessionDocs() { }, }, }), - ApiNotFoundResponse({ - description: 'No active session exists for this user.', - schema: { - example: { - success: false, - statusCode: HttpStatus.NOT_FOUND, - error: 'NotFoundException', - message: SYS_MSG.VOICE_NO_ACTIVE_SESSION, - }, - }, - }), ApiUnauthorizedResponse({ description: 'Missing or invalid JWT token.', schema: { example: unauthorizedExample }, diff --git a/src/modules/onboarding/voice/interfaces/voice-onboarding.interfaces.ts b/src/modules/onboarding/voice/interfaces/voice-onboarding.interfaces.ts index 85c8c897..628b4a03 100644 --- a/src/modules/onboarding/voice/interfaces/voice-onboarding.interfaces.ts +++ b/src/modules/onboarding/voice/interfaces/voice-onboarding.interfaces.ts @@ -7,5 +7,5 @@ export interface VoiceTranscriptionJobData { voiceSessionId: string; storagePath: string; mimeType: string; - originalName: string; + originalName?: string; } \ No newline at end of file diff --git a/src/modules/onboarding/voice/processors/voice-transcription.processor.ts b/src/modules/onboarding/voice/processors/voice-transcription.processor.ts index 521f16c1..451492c6 100644 --- a/src/modules/onboarding/voice/processors/voice-transcription.processor.ts +++ b/src/modules/onboarding/voice/processors/voice-transcription.processor.ts @@ -30,9 +30,7 @@ export class VoiceTranscriptionProcessor { // Check idempotency guard const idempotencyKey = `voice_job_processed:${job.id}`; - const isFirstProcessing = await this.redisService.setNx(idempotencyKey, '1', 1800); - - if (!isFirstProcessing) { + if (await this.redisService.exists(idempotencyKey)) { this.logger.debug(`Job ${job.id} already processed, skipping redis updates.`); await this.objectStorage.deleteObject(storagePath); return; @@ -52,6 +50,8 @@ export class VoiceTranscriptionProcessor { await this.redisService.expire(metaKey, 1800); } + await this.redisService.setStrict(idempotencyKey, '1', 1800); + // Cleanup S3 audio await this.objectStorage.deleteObject(storagePath); diff --git a/src/modules/onboarding/voice/tests/voice-onboarding.controller.spec.ts b/src/modules/onboarding/voice/tests/voice-onboarding.controller.spec.ts index 44dfceb3..e637ddad 100644 --- a/src/modules/onboarding/voice/tests/voice-onboarding.controller.spec.ts +++ b/src/modules/onboarding/voice/tests/voice-onboarding.controller.spec.ts @@ -125,7 +125,7 @@ describe('VoiceOnboardingController', () => { expect(mockVoiceOnboardingService.getActiveSession).toHaveBeenCalledWith(mockUserId); expect(result).toEqual({ - statusCode: HttpStatus.NOT_FOUND, + statusCode: HttpStatus.OK, message: SYS_MSG.VOICE_NO_ACTIVE_SESSION, data: null, }); @@ -140,7 +140,6 @@ describe('VoiceOnboardingController', () => { mockVoiceOnboardingService.getSessionStatus.mockResolvedValue({ expectedCount: 3, completedCount: 3, - isReady: true, }); const result = await controller.getVoiceSessionStatus(mockUserId, mockSessionId); diff --git a/src/modules/onboarding/voice/tests/voice-onboarding.service.spec.ts b/src/modules/onboarding/voice/tests/voice-onboarding.service.spec.ts index 867b19b6..4b0ab0a7 100644 --- a/src/modules/onboarding/voice/tests/voice-onboarding.service.spec.ts +++ b/src/modules/onboarding/voice/tests/voice-onboarding.service.spec.ts @@ -155,6 +155,7 @@ describe('VoiceOnboardingService', () => { // Cleanup expect(mockRedisService.del).toHaveBeenCalledWith(sessionKey); expect(mockRedisService.del).toHaveBeenCalledWith(metaKey); + expect(mockRedisService.del).toHaveBeenCalledWith(redisKeys.activeVoiceSession(mockUserId)); }); it('should throw BadRequestException if transcripts list is empty', async () => { diff --git a/src/modules/redis/redis.service.ts b/src/modules/redis/redis.service.ts index ce55d05f..f4baaef8 100644 --- a/src/modules/redis/redis.service.ts +++ b/src/modules/redis/redis.service.ts @@ -225,7 +225,7 @@ export class RedisService implements OnModuleInit, OnModuleDestroy { return await this.client.rpush(key, ...values); } catch (err) { this.logger.error(`RPUSH failed`, (err as Error).message); - return 0; + throw err; } } @@ -234,7 +234,7 @@ export class RedisService implements OnModuleInit, OnModuleDestroy { return await this.client.lpush(key, ...values); } catch (err) { this.logger.error(`LPUSH failed`, (err as Error).message); - return 0; + throw err; } } @@ -261,7 +261,7 @@ export class RedisService implements OnModuleInit, OnModuleDestroy { return await this.client.hincrby(key, field, increment); } catch (err) { this.logger.error(`HINCRBY failed`, (err as Error).message); - return 0; + throw err; } } From 2a6aa08704006d07eeecc06d759a79f957d7a463 Mon Sep 17 00:00:00 2001 From: ibraheembello Date: Tue, 9 Jun 2026 00:52:09 +0100 Subject: [PATCH 25/37] feat(llm): generate longer, more detailed funnel task descriptions Generated funnel tasks returned only short, two-sentence descriptions. The system prompt now asks the model for a comprehensive, detailed guide per task (4-6 sentences: what to do, the concrete steps, why it matters for the business, and what a good result looks like), matching the Figma design. - rewrite the taskText instruction and add explicit length and content rules to the SYSTEM_PROMPT - raise the funnel-generation token budget from 2000 to 4096 (maxOutputTokens for Gemini, max_tokens for Groq) so twelve longer descriptions are not truncated and forced into the template fallback - task_text is already an unbounded text column, so no schema change is needed - update the two AC-10 token assertions to 4096 --- src/modules/llm/llm.service.spec.ts | 8 ++++---- src/modules/llm/llm.service.ts | 14 +++++++++----- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/modules/llm/llm.service.spec.ts b/src/modules/llm/llm.service.spec.ts index d51984e0..5ba223d7 100644 --- a/src/modules/llm/llm.service.spec.ts +++ b/src/modules/llm/llm.service.spec.ts @@ -144,7 +144,7 @@ describe('LlmServiceImpl', () => { ); }); - it('AC-10: max_tokens=2000 is in the request body', async () => { + it('AC-10: maxOutputTokens=4096 is in the request body', async () => { const geminiWrapped = JSON.stringify({ candidates: [{ content: { parts: [{ text: VALID_STAGES_JSON }] } }], }); @@ -158,7 +158,7 @@ describe('LlmServiceImpl', () => { const [, init] = (global.fetch as jest.Mock).mock.calls[0] as [string, RequestInit]; const body = JSON.parse(init.body as string); - expect(body.generationConfig.maxOutputTokens).toBe(2000); + expect(body.generationConfig.maxOutputTokens).toBe(4096); }); }); @@ -201,7 +201,7 @@ describe('LlmServiceImpl', () => { ); }); - it('AC-10: max_tokens=2000 is in the request body', async () => { + it('AC-10: max_tokens=4096 is in the request body', async () => { const groqWrapped = JSON.stringify({ choices: [{ message: { content: VALID_STAGES_JSON } }], }); @@ -215,7 +215,7 @@ describe('LlmServiceImpl', () => { const [, init] = (global.fetch as jest.Mock).mock.calls[0] as [string, RequestInit]; const body = JSON.parse(init.body as string); - expect(body.max_tokens).toBe(2000); + expect(body.max_tokens).toBe(4096); }); }); diff --git a/src/modules/llm/llm.service.ts b/src/modules/llm/llm.service.ts index 9a688c55..d7a9532a 100644 --- a/src/modules/llm/llm.service.ts +++ b/src/modules/llm/llm.service.ts @@ -19,9 +19,9 @@ Output schema (strict): "explanation": "<2-3 sentences, 10-2000 chars>", "actionPrompt": "<1 clear action, 10-500 chars>", "tasks": [ - { "name": "", "taskText": "" }, - { "name": "", "taskText": "" }, - { "name": "", "taskText": "" } + { "name": "", "taskText": "" }, + { "name": "", "taskText": "" }, + { "name": "", "taskText": "" } ] } ] @@ -31,6 +31,10 @@ Rules: - stages array must have EXACTLY 4 items (positions 1, 2, 3, 4). - Each tasks array must have EXACTLY 3 items. - All string fields must be non-empty. +- Each taskText must be a detailed, comprehensive guide of 4-6 full sentences (roughly 350-700 characters). Never return a single short sentence. +- In each taskText cover: what to do, the concrete step-by-step actions, why it matters for this specific business and its customers, and what a good result looks like. +- Keep name a short punchy 2-5 word title; put all the detail in taskText, not the name. +- Write taskText in a clear, encouraging, coaching tone for the business owner. - Plain text only inside field values — no nested JSON, no HTML. - Return ONLY the JSON object. No text before or after. No markdown.`; @@ -87,7 +91,7 @@ export class LlmServiceImpl extends LlmService { const body = JSON.stringify({ system_instruction: { parts: [{ text: SYSTEM_PROMPT }] }, contents: [{ role: 'user', parts: [{ text: prompt }] }], - generationConfig: { maxOutputTokens: 2000, temperature: 0.4 }, + generationConfig: { maxOutputTokens: 4096, temperature: 0.4 }, }); let raw: string; @@ -134,7 +138,7 @@ export class LlmServiceImpl extends LlmService { const body = JSON.stringify({ model, - max_tokens: 2000, + max_tokens: 4096, temperature: 0.4, messages: [ { role: 'system', content: SYSTEM_PROMPT }, From 4b08fe5b8eec84d361ffb008653f3a198f2d4ae7 Mon Sep 17 00:00:00 2001 From: Prestige Nsien Date: Tue, 9 Jun 2026 01:00:53 +0100 Subject: [PATCH 26/37] fix(auth): add message for conflict when Google email is linked to a local account --- src/constants/system.messages.ts | 1 + src/modules/auth/auth.service.ts | 10 ++++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/constants/system.messages.ts b/src/constants/system.messages.ts index baa4c95a..9928eef4 100644 --- a/src/constants/system.messages.ts +++ b/src/constants/system.messages.ts @@ -28,6 +28,7 @@ export const USER_UNAUTHORIZED = 'User not authorized'; // OAuth and external auth messages export const GOOGLE_ACCOUNT_NO_EMAIL = 'Google account has no email'; export const GOOGLE_ACCOUNT_LINK_CONFLICT = 'Google account is linked to a different account'; +export const GOOGLE_EMAIL_ALREADY_LOCAL_ACCOUNT = 'This email is registered with a password. Please sign in with your email and password.'; export const GOOGLE_OAUTH_FAILED = 'Google OAuth authentication failed'; export const GOOGLE_OAUTH_CONFIGURATION_INVALID = 'Google OAuth configuration is missing'; export const USER_OAUTH_CREATION_FAILED = 'Failed to create user account'; diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index fb5b2c8f..c2b5eeec 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -195,8 +195,11 @@ export class AuthService { let user: User; if (existingUser) { + if (existingUser.auth_provider !== 'google') { + throw new ConflictException(SYS_MSG.GOOGLE_EMAIL_ALREADY_LOCAL_ACCOUNT); + } + if ( - existingUser.auth_provider === 'google' && existingUser.provider_user_id && existingUser.provider_user_id !== profile.providerId ) { @@ -223,8 +226,11 @@ export class AuthService { throw error; } + if (concurrentUser.auth_provider !== 'google') { + throw new ConflictException(SYS_MSG.GOOGLE_EMAIL_ALREADY_LOCAL_ACCOUNT); + } + if ( - concurrentUser.auth_provider === 'google' && concurrentUser.provider_user_id && concurrentUser.provider_user_id !== profile.providerId ) { From c58375f5014c30494b91a05ebb79b9d986debb45 Mon Sep 17 00:00:00 2001 From: Prestige Nsien Date: Tue, 9 Jun 2026 01:01:41 +0100 Subject: [PATCH 27/37] tests(auth): handle ConflictException for local account linking with Google provider --- src/modules/auth/auth.service.spec.ts | 95 ++++++++++++++++++++++----- 1 file changed, 78 insertions(+), 17 deletions(-) diff --git a/src/modules/auth/auth.service.spec.ts b/src/modules/auth/auth.service.spec.ts index 328a25cb..f45deb22 100644 --- a/src/modules/auth/auth.service.spec.ts +++ b/src/modules/auth/auth.service.spec.ts @@ -5,6 +5,7 @@ import { UnauthorizedException, ForbiddenException, BadRequestException, + ConflictException, } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { Test, TestingModule } from '@nestjs/testing'; @@ -541,41 +542,101 @@ describe('AuthService login lockout (BE-005)', () => { }); }); - it('links an existing local account to the Google provider', async () => { + it('AC-01: throws ConflictException when the email belongs to a local account', async () => { mockUsersService.findByEmail.mockResolvedValue({ ...TEST_USER, auth_provider: 'local', provider_user_id: null, - password_hash: TEST_USER.password_hash, }); - mockUsersService.updateGoogleAccount.mockResolvedValue({ + + await expect( + service.handleOAuthLogin({ + provider: 'google', + providerId: 'google-456', + email: TEST_USER.email, + fullName: TEST_USER.full_name, + avatarUrl: null, + }), + ).rejects.toThrow(new ConflictException(SYS_MSG.GOOGLE_EMAIL_ALREADY_LOCAL_ACCOUNT)); + }); + + it('AC-02: succeeds when an existing Google account uses the same providerId', async () => { + const googleUser = { ...TEST_USER, auth_provider: 'google', - provider_user_id: 'google-456', - is_verified: true, - }); + provider_user_id: 'google-123', + password_hash: null, + }; + mockUsersService.findByEmail.mockResolvedValue(googleUser); + mockUsersService.updateGoogleAccount.mockResolvedValue(googleUser); const result = await service.handleOAuthLogin({ provider: 'google', - providerId: 'google-456', + providerId: 'google-123', email: TEST_USER.email, fullName: TEST_USER.full_name, avatarUrl: null, }); - expect(mockUsersService.updateGoogleAccount).toHaveBeenCalledWith( - TEST_USER.id, - expect.objectContaining({ - providerUserId: 'google-456', + expect(result).toMatchObject({ statusCode: HttpStatus.OK, message: SYS_MSG.OAUTH_LOGIN_SUCCESSFUL }); + }); + + it('AC-03: throws GOOGLE_ACCOUNT_LINK_CONFLICT when a Google account has a different providerId', async () => { + mockUsersService.findByEmail.mockResolvedValue({ + ...TEST_USER, + auth_provider: 'google', + provider_user_id: 'google-OTHER', + password_hash: null, + }); + + await expect( + service.handleOAuthLogin({ + provider: 'google', + providerId: 'google-NEW', + email: TEST_USER.email, fullName: TEST_USER.full_name, + avatarUrl: null, }), - ); - expect(result).toMatchObject({ - statusCode: HttpStatus.OK, - message: SYS_MSG.OAUTH_LOGIN_SUCCESSFUL, - accessToken: 'signed.jwt.token', - refreshToken: 'signed.jwt.token', + ).rejects.toThrow(new ConflictException(SYS_MSG.GOOGLE_ACCOUNT_LINK_CONFLICT)); + }); + + it('AC-04: throws ConflictException in concurrent fallback when the race-created account is local', async () => { + const uniqueConstraintError = { driverError: { code: '23505' } }; + mockUsersService.findByEmail.mockResolvedValueOnce(null); + mockUsersService.createGoogleAccount.mockRejectedValueOnce(uniqueConstraintError); + mockUsersService.findByEmail.mockResolvedValueOnce({ + ...TEST_USER, + auth_provider: 'local', + provider_user_id: null, + }); + + await expect( + service.handleOAuthLogin({ + provider: 'google', + providerId: 'google-456', + email: TEST_USER.email, + fullName: TEST_USER.full_name, + avatarUrl: null, + }), + ).rejects.toThrow(new ConflictException(SYS_MSG.GOOGLE_EMAIL_ALREADY_LOCAL_ACCOUNT)); + }); + + it('SEC-01: updateGoogleAccount is never called when the existing account is local', async () => { + mockUsersService.findByEmail.mockResolvedValue({ + ...TEST_USER, + auth_provider: 'local', + provider_user_id: null, }); + + await service.handleOAuthLogin({ + provider: 'google', + providerId: 'google-456', + email: TEST_USER.email, + fullName: TEST_USER.full_name, + avatarUrl: null, + }).catch(() => null); + + expect(mockUsersService.updateGoogleAccount).not.toHaveBeenCalled(); }); }); From d90672b4e0c7b35c99dc58fac52bcd66305836d9 Mon Sep 17 00:00:00 2001 From: Prestige Nsien Date: Tue, 9 Jun 2026 02:08:24 +0100 Subject: [PATCH 28/37] fix(auth): update system messages for clarity and consistency --- src/constants/system.messages.ts | 23 ++++++++++++----------- src/modules/users/actions/user.action.ts | 4 ++++ 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/src/constants/system.messages.ts b/src/constants/system.messages.ts index 9928eef4..e486872d 100644 --- a/src/constants/system.messages.ts +++ b/src/constants/system.messages.ts @@ -28,7 +28,8 @@ export const USER_UNAUTHORIZED = 'User not authorized'; // OAuth and external auth messages export const GOOGLE_ACCOUNT_NO_EMAIL = 'Google account has no email'; export const GOOGLE_ACCOUNT_LINK_CONFLICT = 'Google account is linked to a different account'; -export const GOOGLE_EMAIL_ALREADY_LOCAL_ACCOUNT = 'This email is registered with a password. Please sign in with your email and password.'; +export const GOOGLE_EMAIL_ALREADY_LOCAL_ACCOUNT = + 'This email is registered with a password. Please sign in with your email and password.'; export const GOOGLE_OAUTH_FAILED = 'Google OAuth authentication failed'; export const GOOGLE_OAUTH_CONFIGURATION_INVALID = 'Google OAuth configuration is missing'; export const USER_OAUTH_CREATION_FAILED = 'Failed to create user account'; @@ -274,8 +275,7 @@ export const ADMIN_PROFILE_EMAIL_CHANGE_FORBIDDEN = 'Email cannot be changed her export const ADMIN_PROFILE_RESPONSE_ROLE_RESOLUTION_FAILED = 'Failed to resolve admin role for profile response'; export const ADMIN_PASSWORD_UPDATED_SUCCESSFULLY = 'Password updated successfully'; export const ADMIN_OLD_PASSWORD_INCORRECT = 'Old password is incorrect'; -export const ADMIN_NEW_PASSWORD_MUST_DIFFER_FROM_OLD = - 'New password must be different from your current password'; +export const ADMIN_NEW_PASSWORD_MUST_DIFFER_FROM_OLD = 'New password must be different from your current password'; export const ADMIN_CONFIRM_PASSWORD_MISMATCH = 'Confirm password must match new password'; export const ADMIN_PASSWORD_POLICY_VALIDATION_FAILED = 'new_password must be at least 8 characters long and include at least one uppercase letter, one lowercase letter, one digit, and one symbol'; @@ -357,11 +357,12 @@ export const TEAM_INVITE_ALREADY_PENDING = 'An invitation for this email is alre export const TEAM_ALREADY_MEMBER = 'This user is already a team member.'; // Invite Accept & member Revoke -export const INVITE_TOKEN_INVALID = 'Invalid invitation token.'; -export const INVITE_ALREADY_USED = 'This invitation has already been used or revoked.'; -export const INVITE_EXPIRED = 'This invitation has expired.'; -export const INVITE_ACCEPTED_SUCCESSFULLY = 'Invitation accepted successfully.'; -export const MEMBER_REVOKE_SELF_FORBIDDEN = 'You cannot revoke your own access.'; -export const MEMBER_NOT_FOUND = 'Team member not found.'; -export const MEMBER_REVOKED_SUCCESSFULLY = 'Member access revoked successfully.'; -export const PASSWORD_VALIDATION_FAILED = 'password must contain at least one uppercase letter, one lowercase letter, one digit, and one special character' +export const INVITE_TOKEN_INVALID = 'Invalid invitation token.'; +export const INVITE_ALREADY_USED = 'This invitation has already been used or revoked.'; +export const INVITE_EXPIRED = 'This invitation has expired.'; +export const INVITE_ACCEPTED_SUCCESSFULLY = 'Invitation accepted successfully.'; +export const MEMBER_REVOKE_SELF_FORBIDDEN = 'You cannot revoke your own access.'; +export const MEMBER_NOT_FOUND = 'Team member not found.'; +export const MEMBER_REVOKED_SUCCESSFULLY = 'Member access revoked successfully.'; +export const PASSWORD_VALIDATION_FAILED = + 'password must contain at least one uppercase letter, one lowercase letter, one digit, and one special character'; diff --git a/src/modules/users/actions/user.action.ts b/src/modules/users/actions/user.action.ts index e6a60171..cf64b9aa 100644 --- a/src/modules/users/actions/user.action.ts +++ b/src/modules/users/actions/user.action.ts @@ -17,6 +17,10 @@ export class UserModelAction extends AbstractModelAction { return this.get({ identifierOptions: { email } }); } + async findByEmailWithDeleted(email: string): Promise { + return this.repository.findOne({ where: { email }, withDeleted: true }); + } + async findById(id: string): Promise { return this.get({ identifierOptions: { id } }); } From 448d42574196ae6853a1a2827af3eb9845c1a47a Mon Sep 17 00:00:00 2001 From: Prestige Nsien Date: Tue, 9 Jun 2026 02:08:42 +0100 Subject: [PATCH 29/37] fix(auth): improve code formatting and enhance conflict handling for deleted accounts --- src/modules/auth/auth.service.ts | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index c2b5eeec..11f801b9 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -68,7 +68,7 @@ export class AuthService { private readonly logService: LogService, @Optional() private readonly logger = new Logger(AuthService.name), private readonly eventEmitter: EventEmitter2, - ) { } + ) {} // Local minimal interface to avoid unsafe-call lint issues from third-party model action types private get userSessionAction(): { @@ -199,10 +199,7 @@ export class AuthService { throw new ConflictException(SYS_MSG.GOOGLE_EMAIL_ALREADY_LOCAL_ACCOUNT); } - if ( - existingUser.provider_user_id && - existingUser.provider_user_id !== profile.providerId - ) { + if (existingUser.provider_user_id && existingUser.provider_user_id !== profile.providerId) { throw new ConflictException(SYS_MSG.GOOGLE_ACCOUNT_LINK_CONFLICT); } @@ -212,6 +209,11 @@ export class AuthService { avatarUrl: profile.avatarUrl, }); } else { + const deletedUser = await this.usersService.findByEmailWithDeleted(email); + if (deletedUser?.deleted_at) { + throw new ConflictException(SYS_MSG.ACCOUNT_EXISTS_WITH_RETENTION); + } + try { user = await this.usersService.createGoogleAccount({ email, @@ -223,6 +225,10 @@ export class AuthService { if (this.isUniqueEmailConflict(error)) { const concurrentUser = await this.usersService.findByEmail(email); if (!concurrentUser) { + const deletedConcurrent = await this.usersService.findByEmailWithDeleted(email); + if (deletedConcurrent?.deleted_at) { + throw new ConflictException(SYS_MSG.ACCOUNT_EXISTS_WITH_RETENTION); + } throw error; } @@ -230,10 +236,7 @@ export class AuthService { throw new ConflictException(SYS_MSG.GOOGLE_EMAIL_ALREADY_LOCAL_ACCOUNT); } - if ( - concurrentUser.provider_user_id && - concurrentUser.provider_user_id !== profile.providerId - ) { + if (concurrentUser.provider_user_id && concurrentUser.provider_user_id !== profile.providerId) { throw new ConflictException(SYS_MSG.GOOGLE_ACCOUNT_LINK_CONFLICT); } @@ -616,7 +619,11 @@ export class AuthService { const authResponse = await this.issueTokens(user); - this.logger.log({ message: 'Password reset successful with auto-login', userId: user.id, email: maskEmail(user.email) }); + this.logger.log({ + message: 'Password reset successful with auto-login', + userId: user.id, + email: maskEmail(user.email), + }); return authResponse; } From ed8a7b8506d7802ebf4f182ddffd134aaa9fb817 Mon Sep 17 00:00:00 2001 From: Prestige Nsien Date: Tue, 9 Jun 2026 02:09:54 +0100 Subject: [PATCH 30/37] tests(auth): enhance user account handling in OAuth login flow and improve test assertions --- src/modules/auth/auth.service.spec.ts | 85 +++++++++++++++++++-------- 1 file changed, 60 insertions(+), 25 deletions(-) diff --git a/src/modules/auth/auth.service.spec.ts b/src/modules/auth/auth.service.spec.ts index f45deb22..0ca3fcaa 100644 --- a/src/modules/auth/auth.service.spec.ts +++ b/src/modules/auth/auth.service.spec.ts @@ -28,6 +28,7 @@ jest.mock('bcrypt'); const mockUsersService = { findByEmail: jest.fn(), + findByEmailWithDeleted: jest.fn(), findById: jest.fn(), create: jest.fn(), createGoogleAccount: jest.fn(), @@ -136,9 +137,7 @@ describe('AuthService login lockout (BE-005)', () => { await service.register({ ...REGISTER_DTO, businessName: 'Ben Clothing' }); - expect(mockUsersService.create).toHaveBeenCalledWith( - expect.objectContaining({ businessName: 'Ben Clothing' }), - ); + expect(mockUsersService.create).toHaveBeenCalledWith(expect.objectContaining({ businessName: 'Ben Clothing' })); }); it('forwards undefined businessName when omitted', async () => { @@ -146,9 +145,7 @@ describe('AuthService login lockout (BE-005)', () => { await service.register(REGISTER_DTO); - expect(mockUsersService.create).toHaveBeenCalledWith( - expect.objectContaining({ businessName: undefined }), - ); + expect(mockUsersService.create).toHaveBeenCalledWith(expect.objectContaining({ businessName: undefined })); }); it('emits USER_SIGNED_UP with the new user id on success', async () => { @@ -156,10 +153,7 @@ describe('AuthService login lockout (BE-005)', () => { await service.register(REGISTER_DTO); - expect(mockEventEmitter.emit).toHaveBeenCalledWith( - APP_EVENTS.USER_SIGNED_UP, - expect.any(UserSignedUpEvent), - ); + expect(mockEventEmitter.emit).toHaveBeenCalledWith(APP_EVENTS.USER_SIGNED_UP, expect.any(UserSignedUpEvent)); const emittedEvent = mockEventEmitter.emit.mock.calls[0][1] as UserSignedUpEvent; expect(emittedEvent.userId).toBe(TEST_USER.id); }); @@ -431,9 +425,7 @@ describe('AuthService login lockout (BE-005)', () => { describe('M-1: ensureAuthMetadata concurrent first-login guard', () => { it('returns the row created by a concurrent request on unique-constraint collision', async () => { const concurrentMeta = buildMetadata(); - mockAuthMetadataModelAction.findByUserId - .mockResolvedValueOnce(null) - .mockResolvedValueOnce(concurrentMeta); + mockAuthMetadataModelAction.findByUserId.mockResolvedValueOnce(null).mockResolvedValueOnce(concurrentMeta); mockAuthMetadataModelAction.createForUser.mockRejectedValueOnce({ driverError: { code: '23505' }, }); @@ -628,16 +620,59 @@ describe('AuthService login lockout (BE-005)', () => { provider_user_id: null, }); - await service.handleOAuthLogin({ - provider: 'google', - providerId: 'google-456', - email: TEST_USER.email, - fullName: TEST_USER.full_name, - avatarUrl: null, - }).catch(() => null); + await service + .handleOAuthLogin({ + provider: 'google', + providerId: 'google-456', + email: TEST_USER.email, + fullName: TEST_USER.full_name, + avatarUrl: null, + }) + .catch(() => null); expect(mockUsersService.updateGoogleAccount).not.toHaveBeenCalled(); }); + + it('AC-04: throws ACCOUNT_EXISTS_WITH_RETENTION when a soft-deleted account holds the email', async () => { + mockUsersService.findByEmail.mockResolvedValueOnce(null); + mockUsersService.findByEmailWithDeleted.mockResolvedValueOnce({ + ...TEST_USER, + deleted_at: new Date('2026-01-01'), + }); + + await expect( + service.handleOAuthLogin({ + provider: 'google', + providerId: 'google-456', + email: TEST_USER.email, + fullName: TEST_USER.full_name, + avatarUrl: null, + }), + ).rejects.toThrow(new ConflictException(SYS_MSG.ACCOUNT_EXISTS_WITH_RETENTION)); + + expect(mockUsersService.createGoogleAccount).not.toHaveBeenCalled(); + }); + + it('AC-05: throws ACCOUNT_EXISTS_WITH_RETENTION in concurrent fallback when soft-deleted account causes the 23505', async () => { + const uniqueConstraintError = { driverError: { code: '23505' } }; + mockUsersService.findByEmail + .mockResolvedValueOnce(null) // initial check — no active user + .mockResolvedValueOnce(null); // concurrent fallback — still no active user + mockUsersService.findByEmailWithDeleted + .mockResolvedValueOnce(null) // pre-check passes (soft-deleted check before createGoogleAccount) + .mockResolvedValueOnce({ ...TEST_USER, deleted_at: new Date() }); // fallback finds soft-deleted + mockUsersService.createGoogleAccount.mockRejectedValueOnce(uniqueConstraintError); + + await expect( + service.handleOAuthLogin({ + provider: 'google', + providerId: 'google-456', + email: TEST_USER.email, + fullName: TEST_USER.full_name, + avatarUrl: null, + }), + ).rejects.toThrow(new ConflictException(SYS_MSG.ACCOUNT_EXISTS_WITH_RETENTION)); + }); }); describe('Google OAuth Short-lived Exchange Flow', () => { @@ -685,9 +720,9 @@ describe('AuthService login lockout (BE-005)', () => { expect(code).toHaveLength(64); // 32 bytes in hex = 64 characters expect(mockRedisService.setStrict).toHaveBeenCalledWith(`oauth:exchange:${code}`, expect.any(String), 60); - const exchangeCall = ( - mockRedisService.setStrict.mock.calls as [string, string, number][] - ).find(([key]) => key === `oauth:exchange:${code}`); + const exchangeCall = (mockRedisService.setStrict.mock.calls as [string, string, number][]).find( + ([key]) => key === `oauth:exchange:${code}`, + ); const storedData = JSON.parse(exchangeCall?.[1] ?? '{}') as { accessToken: string; refreshToken: string }; expect(storedData).toMatchObject({ @@ -956,8 +991,8 @@ describe('AuthService - Password Reset Flow (BE-012)', () => { await service.verifyResetOtp(USER_EMAIL, OTP_CODE); - const [lockKey, lockToken, ttl] = mockRedisService.setNx.mock.calls.find( - ([k]: [string]) => k.includes('password-reset:verify:lock:'), + const [lockKey, lockToken, ttl] = mockRedisService.setNx.mock.calls.find(([k]: [string]) => + k.includes('password-reset:verify:lock:'), ) as [string, string, number]; expect(lockKey).toContain('password-reset:verify:lock:'); expect(typeof lockToken).toBe('string'); From e082839f47344e8cc0554556c95d604a35821d14 Mon Sep 17 00:00:00 2001 From: Prestige Nsien Date: Tue, 9 Jun 2026 02:10:10 +0100 Subject: [PATCH 31/37] fix(auth): enhance user retrieval by including soft-deleted accounts and improve conflict handling --- src/modules/users/users.service.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/modules/users/users.service.ts b/src/modules/users/users.service.ts index 4a455ea0..c615ae49 100644 --- a/src/modules/users/users.service.ts +++ b/src/modules/users/users.service.ts @@ -92,8 +92,11 @@ export class UsersService { ) {} async create(dto: CreateUserDto): Promise { - const existing = await this.userModelAction.findByEmail(dto.email); + const existing = await this.userModelAction.findByEmailWithDeleted(dto.email); if (existing) { + if (existing.deleted_at) { + throw new ConflictException(SYS_MSG.ACCOUNT_EXISTS_WITH_RETENTION); + } if (existing.is_active === false) { throw new ConflictException(SYS_MSG.USER_ACCOUNT_LOCKED); } @@ -179,6 +182,11 @@ export class UsersService { return this.userModelAction.findByEmail(email); } + /** Finds a user by email, including soft-deleted rows. Used to detect retention-period accounts. */ + findByEmailWithDeleted(email: string): Promise { + return this.userModelAction.findByEmailWithDeleted(email); + } + async update(id: string, dto: UpdateUserDto): Promise { await this.findById(id); From f9108d5a8c8a53226905d9fc80ad64737efe8234 Mon Sep 17 00:00:00 2001 From: Prestige Nsien Date: Tue, 9 Jun 2026 02:10:37 +0100 Subject: [PATCH 32/37] tests(auth): update user creation logic to handle soft-deleted accounts and improve conflict error handling --- src/modules/users/users.service.spec.ts | 58 +++++++++++++++++-------- 1 file changed, 39 insertions(+), 19 deletions(-) diff --git a/src/modules/users/users.service.spec.ts b/src/modules/users/users.service.spec.ts index 893901ec..38bd725d 100644 --- a/src/modules/users/users.service.spec.ts +++ b/src/modules/users/users.service.spec.ts @@ -61,6 +61,7 @@ jest.mock('file-type', () => ({ // Mock UserModelAction const mockUserModelAction = { findByEmail: jest.fn(), + findByEmailWithDeleted: jest.fn(), create: jest.fn(), get: jest.fn(), list: jest.fn(), @@ -265,20 +266,20 @@ describe('UsersService', () => { termsAccepted: true, }; - it('creates a user and returns the created user', async () => { - mockUserModelAction.findByEmail.mockResolvedValue(null); - mockUserModelAction.create.mockResolvedValue(mockUser()); + it('AC-02: creates a user and returns the created user when email is fresh', async () => { + mockUserModelAction.findByEmailWithDeleted.mockResolvedValueOnce(null); + mockUserModelAction.create.mockResolvedValueOnce(mockUser()); const result = await service.create(createDto); - expect(mockUserModelAction.findByEmail).toHaveBeenCalledWith(USER_EMAIL); + expect(mockUserModelAction.findByEmailWithDeleted).toHaveBeenCalledWith(USER_EMAIL); expect(bcrypt.hash).toHaveBeenCalledWith('Password123!', 10); expect(result).toEqual(mockUser()); }); it('persists business_name when businessName is provided', async () => { - mockUserModelAction.findByEmail.mockResolvedValue(null); - mockUserModelAction.create.mockResolvedValue(mockUser()); + mockUserModelAction.findByEmailWithDeleted.mockResolvedValueOnce(null); + mockUserModelAction.create.mockResolvedValueOnce(mockUser()); await service.create({ ...createDto, businessName: 'Ben Clothing' }); @@ -290,8 +291,8 @@ describe('UsersService', () => { }); it('stores null business_name when businessName is omitted', async () => { - mockUserModelAction.findByEmail.mockResolvedValue(null); - mockUserModelAction.create.mockResolvedValue(mockUser()); + mockUserModelAction.findByEmailWithDeleted.mockResolvedValueOnce(null); + mockUserModelAction.create.mockResolvedValueOnce(mockUser()); await service.create(createDto); @@ -302,27 +303,46 @@ describe('UsersService', () => { ); }); - it('throws 409 when email already exists', async () => { - mockUserModelAction.findByEmail.mockResolvedValue(mockUser()); + it('AC-01: throws 409 ACCOUNT_EXISTS_WITH_RETENTION when email belongs to a soft-deleted account', async () => { + mockUserModelAction.findByEmailWithDeleted.mockResolvedValueOnce({ + ...mockUser(), + deleted_at: new Date('2026-01-01'), + }); + + const error = await service.create(createDto).catch((e: unknown) => e); - await expect(service.create(createDto)).rejects.toBeInstanceOf(ConflictException); + expect(error).toBeInstanceOf(ConflictException); + expect((error as ConflictException).message).toBe(SYS_MSG.ACCOUNT_EXISTS_WITH_RETENTION); + expect(mockUserModelAction.create).not.toHaveBeenCalled(); + }); + + it('AC-03: throws 409 USER_EMAIL_IN_USE when an active account already holds the email', async () => { + mockUserModelAction.findByEmailWithDeleted.mockResolvedValueOnce({ ...mockUser(), deleted_at: null }); + + const error = await service.create(createDto).catch((e: unknown) => e); + + expect(error).toBeInstanceOf(ConflictException); + expect((error as ConflictException).message).toBe(SYS_MSG.USER_EMAIL_IN_USE); expect(mockUserModelAction.create).not.toHaveBeenCalled(); }); it('throws 409 with USER_ACCOUNT_LOCKED when account is inactive', async () => { - mockUserModelAction.findByEmail.mockResolvedValue({ ...mockUser(), is_active: false }); + mockUserModelAction.findByEmailWithDeleted.mockResolvedValueOnce({ ...mockUser(), is_active: false, deleted_at: null }); await expect(service.create(createDto)).rejects.toThrow(SYS_MSG.USER_ACCOUNT_LOCKED); }); - it('throws 409 on duplicate key DB error', async () => { - mockUserModelAction.findByEmail.mockResolvedValue(null); + it('EC-01: throws 409 USER_EMAIL_IN_USE on concurrent INSERT race for a fresh email', async () => { + mockUserModelAction.findByEmailWithDeleted.mockResolvedValueOnce(null); const dbError = Object.assign(new QueryFailedError('', [], new Error()), { driverError: { code: '23505' }, }); - mockUserModelAction.create.mockRejectedValue(dbError); + mockUserModelAction.create.mockRejectedValueOnce(dbError); + + const error = await service.create(createDto).catch((e: unknown) => e); - await expect(service.create(createDto)).rejects.toBeInstanceOf(ConflictException); + expect(error).toBeInstanceOf(ConflictException); + expect((error as ConflictException).message).toBe(SYS_MSG.USER_EMAIL_IN_USE); }); }); @@ -1370,9 +1390,9 @@ describe('UsersService', () => { }); describe('EC-01: re-registration during retention window', () => { - it('should throw USER_ACCOUNT_LOCKED when user exists with deleted_at not null', async () => { + it('should throw ACCOUNT_EXISTS_WITH_RETENTION when email belongs to a soft-deleted account', async () => { const deletedUser = { ...mockLocalUser, is_active: false, deleted_at: new Date() }; - mockUserModelAction.findByEmail.mockResolvedValue(deletedUser); + mockUserModelAction.findByEmailWithDeleted.mockResolvedValueOnce(deletedUser); const createDto = { email: USER_EMAIL, @@ -1381,7 +1401,7 @@ describe('UsersService', () => { termsAccepted: true, }; - await expect(service.create(createDto)).rejects.toThrow(SYS_MSG.USER_ACCOUNT_LOCKED); + await expect(service.create(createDto)).rejects.toThrow(SYS_MSG.ACCOUNT_EXISTS_WITH_RETENTION); expect(mockUserModelAction.create).not.toHaveBeenCalled(); }); }); From 26ff45eda128a07e321f8d3bdf696ed801004ec5 Mon Sep 17 00:00:00 2001 From: Prestige Nsien Date: Tue, 9 Jun 2026 02:20:18 +0100 Subject: [PATCH 33/37] tests(auth): update test case identifiers for soft-deleted account handling --- src/modules/auth/auth.service.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/modules/auth/auth.service.spec.ts b/src/modules/auth/auth.service.spec.ts index 0ca3fcaa..d36933e8 100644 --- a/src/modules/auth/auth.service.spec.ts +++ b/src/modules/auth/auth.service.spec.ts @@ -633,7 +633,7 @@ describe('AuthService login lockout (BE-005)', () => { expect(mockUsersService.updateGoogleAccount).not.toHaveBeenCalled(); }); - it('AC-04: throws ACCOUNT_EXISTS_WITH_RETENTION when a soft-deleted account holds the email', async () => { + it('AC-05: throws ACCOUNT_EXISTS_WITH_RETENTION when a soft-deleted account holds the email', async () => { mockUsersService.findByEmail.mockResolvedValueOnce(null); mockUsersService.findByEmailWithDeleted.mockResolvedValueOnce({ ...TEST_USER, @@ -653,7 +653,7 @@ describe('AuthService login lockout (BE-005)', () => { expect(mockUsersService.createGoogleAccount).not.toHaveBeenCalled(); }); - it('AC-05: throws ACCOUNT_EXISTS_WITH_RETENTION in concurrent fallback when soft-deleted account causes the 23505', async () => { + it('AC-06: throws ACCOUNT_EXISTS_WITH_RETENTION in concurrent fallback when soft-deleted account causes the 23505', async () => { const uniqueConstraintError = { driverError: { code: '23505' } }; mockUsersService.findByEmail .mockResolvedValueOnce(null) // initial check — no active user From fbad226330c206547ce7656e443adcdd3326dd8f Mon Sep 17 00:00:00 2001 From: elijah arhinful Date: Tue, 9 Jun 2026 07:13:27 +0000 Subject: [PATCH 34/37] chore(audio): use system messagese in tes in ts in test file --- src/constants/system.messages.ts | 1 + .../voice/docs/voice-onboarding-swagger.doc.ts | 10 +++++----- .../processors/voice-transcription.processor.ts | 11 ++++++----- .../voice/services/voice-onboarding.service.ts | 14 ++++++++------ .../voice/tests/voice-onboarding.service.spec.ts | 4 ++-- 5 files changed, 22 insertions(+), 18 deletions(-) diff --git a/src/constants/system.messages.ts b/src/constants/system.messages.ts index a0e8dc98..f0e4f846 100644 --- a/src/constants/system.messages.ts +++ b/src/constants/system.messages.ts @@ -373,6 +373,7 @@ export const VOICE_FILE_TOO_LARGE = 'Recording is too large. Please try a shorte export const VOICE_AUDIO_TOO_LONG = 'Please keep each recording under 2 minutes.'; export const VOICE_SESSION_EXPIRED = 'Your session has expired. Please start again.'; export const VOICE_TRANSCRIPTION_EMPTY = 'We couldn\'t understand that recording. Please try again in a quieter environment.'; +export const VOICE_TRANSCRIPTION_INCOMPLETE = 'Transcription incomplete'; export const VOICE_TRANSCRIPTION_UNAVAILABLE = 'Transcription is temporarily unavailable. Please try again in a moment.'; export const VOICE_STATUS_RETRIEVED = 'Status retrieved successfully'; export const VOICE_ACTIVE_SESSION_RETRIEVED = 'Active session retrieved successfully'; diff --git a/src/modules/onboarding/voice/docs/voice-onboarding-swagger.doc.ts b/src/modules/onboarding/voice/docs/voice-onboarding-swagger.doc.ts index c002293a..2fc28dc7 100644 --- a/src/modules/onboarding/voice/docs/voice-onboarding-swagger.doc.ts +++ b/src/modules/onboarding/voice/docs/voice-onboarding-swagger.doc.ts @@ -94,7 +94,7 @@ export function UploadVoiceRoundDocs() { success: false, statusCode: HttpStatus.NOT_FOUND, error: 'NotFoundException', - message: 'SESSION_EXPIRED', + message: SYS_MSG.VOICE_SESSION_EXPIRED, }, }, }), @@ -139,7 +139,7 @@ export function CompleteVoiceSessionDocs() { success: false, statusCode: HttpStatus.BAD_REQUEST, error: 'BadRequestException', - message: 'TRANSCRIPTION_EMPTY', + message: SYS_MSG.VOICE_TRANSCRIPTION_EMPTY, }, }, validationFailed: { @@ -161,7 +161,7 @@ export function CompleteVoiceSessionDocs() { success: false, statusCode: HttpStatus.NOT_FOUND, error: 'NotFoundException', - message: 'SESSION_EXPIRED', + message: SYS_MSG.VOICE_SESSION_EXPIRED, }, }, }), @@ -184,7 +184,7 @@ export function GetVoiceSessionStatusDocs() { example: { success: true, statusCode: HttpStatus.OK, - message: 'Status retrieved successfully', + message: SYS_MSG.VOICE_STATUS_RETRIEVED, data: { expectedCount: 3, completedCount: 3, @@ -200,7 +200,7 @@ export function GetVoiceSessionStatusDocs() { success: false, statusCode: HttpStatus.NOT_FOUND, error: 'NotFoundException', - message: 'SESSION_EXPIRED', + message: SYS_MSG.VOICE_SESSION_EXPIRED, }, }, }), diff --git a/src/modules/onboarding/voice/processors/voice-transcription.processor.ts b/src/modules/onboarding/voice/processors/voice-transcription.processor.ts index 451492c6..1e5735fc 100644 --- a/src/modules/onboarding/voice/processors/voice-transcription.processor.ts +++ b/src/modules/onboarding/voice/processors/voice-transcription.processor.ts @@ -6,8 +6,9 @@ import { VoiceTranscriptionService } from '../services/voice-transcription.servi import { VoiceTranscriptionJobData } from '../interfaces/voice-onboarding.interfaces'; import { RedisService } from '../../../redis/redis.service'; import { redisKeys } from '../../../../constants/redis-keys'; +import { QUEUES } from '../../../../common/constants/queue.constants'; -@Processor('voice-transcription') +@Processor(QUEUES.VOICE_TRANSCRIPTION) export class VoiceTranscriptionProcessor { private readonly logger = new Logger(VoiceTranscriptionProcessor.name); @@ -24,17 +25,17 @@ export class VoiceTranscriptionProcessor { try { const audioBuffer = await this.objectStorage.getObject(storagePath); - - const { transcript, provider } = await this.transcriptionService.transcribe(audioBuffer, job.data.originalName || 'audio.webm'); - this.logger.debug(`Transcription successful via ${provider} for session: ${voiceSessionId}`); - // Check idempotency guard + // Check idempotency guard before expensive transcribe call const idempotencyKey = `voice_job_processed:${job.id}`; if (await this.redisService.exists(idempotencyKey)) { this.logger.debug(`Job ${job.id} already processed, skipping redis updates.`); await this.objectStorage.deleteObject(storagePath); return; } + + const { transcript, provider } = await this.transcriptionService.transcribe(audioBuffer, job.data.originalName || 'audio.webm'); + this.logger.debug(`Transcription successful via ${provider} for session: ${voiceSessionId}`); // Append to Redis list and reset TTL const sessionKey = redisKeys.voiceSession(job.data.userId, voiceSessionId); diff --git a/src/modules/onboarding/voice/services/voice-onboarding.service.ts b/src/modules/onboarding/voice/services/voice-onboarding.service.ts index 46dfc1ee..90f1aaeb 100644 --- a/src/modules/onboarding/voice/services/voice-onboarding.service.ts +++ b/src/modules/onboarding/voice/services/voice-onboarding.service.ts @@ -8,11 +8,13 @@ import { UPLOAD_OBJECT_STORAGE, UploadDocumentStatus, type ObjectStorage } from import { UploadedDocumentModelAction } from '../../../upload/actions/uploaded-document.action'; import { DocumentSourceType } from '../../../upload/entities/uploaded-document.entity'; import { VoiceTranscriptionJobData } from '../interfaces/voice-onboarding.interfaces'; +import * as SYS_MSG from '../../../../constants/system.messages'; +import { QUEUES } from '../../../../common/constants/queue.constants'; @Injectable() export class VoiceOnboardingService { constructor( - @InjectQueue('voice-transcription') private readonly transcriptionQueue: Queue, + @InjectQueue(QUEUES.VOICE_TRANSCRIPTION) private readonly transcriptionQueue: Queue, private readonly redisService: RedisService, @Inject(UPLOAD_OBJECT_STORAGE) private readonly objectStorage: ObjectStorage, private readonly documentAction: UploadedDocumentModelAction, @@ -28,7 +30,7 @@ export class VoiceOnboardingService { if (existingSessionId) { const exists = await this.redisService.exists(metaKey); if (!exists) { - throw new NotFoundException('SESSION_EXPIRED'); + throw new NotFoundException(SYS_MSG.VOICE_SESSION_EXPIRED); } } @@ -76,7 +78,7 @@ export class VoiceOnboardingService { const exists = await this.redisService.exists(metaKey); if (!exists) { - throw new NotFoundException('SESSION_EXPIRED'); + throw new NotFoundException(SYS_MSG.VOICE_SESSION_EXPIRED); } const meta = await this.redisService.hgetall(metaKey); @@ -84,12 +86,12 @@ export class VoiceOnboardingService { const completedCount = parseInt(meta.completedCount || '0', 10); if (expectedCount === 0 || expectedCount !== completedCount) { - throw new BadRequestException('TRANSCRIPTION_INCOMPLETE'); + throw new BadRequestException(SYS_MSG.VOICE_TRANSCRIPTION_INCOMPLETE); } const transcripts = await this.redisService.lrange(sessionKey, 0, -1); if (transcripts.length === 0) { - throw new BadRequestException('TRANSCRIPTION_EMPTY'); + throw new BadRequestException(SYS_MSG.VOICE_TRANSCRIPTION_EMPTY); } const combinedTranscript = transcripts.join(' '); @@ -118,7 +120,7 @@ export class VoiceOnboardingService { const exists = await this.redisService.exists(metaKey); if (!exists) { - throw new NotFoundException('SESSION_EXPIRED'); + throw new NotFoundException(SYS_MSG.VOICE_SESSION_EXPIRED); } const meta = await this.redisService.hgetall(metaKey); diff --git a/src/modules/onboarding/voice/tests/voice-onboarding.service.spec.ts b/src/modules/onboarding/voice/tests/voice-onboarding.service.spec.ts index 4b0ab0a7..b3ba56fb 100644 --- a/src/modules/onboarding/voice/tests/voice-onboarding.service.spec.ts +++ b/src/modules/onboarding/voice/tests/voice-onboarding.service.spec.ts @@ -7,6 +7,7 @@ import { UPLOAD_OBJECT_STORAGE, UploadDocumentStatus } from '../../../upload/upl import { DocumentSourceType } from '../../../upload/entities/uploaded-document.entity'; import { UploadedDocumentModelAction } from '../../../upload/actions/uploaded-document.action'; import { redisKeys } from '../../../../constants/redis-keys'; +import { QUEUES } from '../../../../common/constants/queue.constants'; jest.mock('node:crypto', () => ({ randomUUID: jest.fn(() => 'mocked-uuid'), @@ -24,7 +25,6 @@ describe('VoiceOnboardingService', () => { get: jest.fn(), setStrict: jest.fn(), lpush: jest.fn(), - lpop: jest.fn(), expire: jest.fn(), hincrby: jest.fn(), lrange: jest.fn(), @@ -46,7 +46,7 @@ describe('VoiceOnboardingService', () => { const module: TestingModule = await Test.createTestingModule({ providers: [ VoiceOnboardingService, - { provide: getQueueToken('voice-transcription'), useValue: mockQueue }, + { provide: getQueueToken(QUEUES.VOICE_TRANSCRIPTION), useValue: mockQueue }, { provide: RedisService, useValue: mockRedisService }, { provide: UPLOAD_OBJECT_STORAGE, useValue: mockObjectStorage }, { provide: UploadedDocumentModelAction, useValue: mockDocumentAction }, From 4b4f77f2793360381c3fb60e63952f8262fa2559 Mon Sep 17 00:00:00 2001 From: John Ughiovhe Date: Tue, 9 Jun 2026 11:40:00 +0100 Subject: [PATCH 35/37] refactor: update code sections to tighten validations and easy readability --- src/modules/admin/profile/admin-profile.controller.ts | 3 +-- .../profile/tests/admin-notification-preference.action.spec.ts | 3 ++- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/modules/admin/profile/admin-profile.controller.ts b/src/modules/admin/profile/admin-profile.controller.ts index ef44b5b3..83303fcd 100644 --- a/src/modules/admin/profile/admin-profile.controller.ts +++ b/src/modules/admin/profile/admin-profile.controller.ts @@ -85,9 +85,8 @@ export class AdminProfileController { }), }), ) - rawDto: Record, + dto: UpdateAdminNotificationPreferencesDto, ) { - const dto = rawDto as UpdateAdminNotificationPreferencesDto; const data = await this.adminProfileService.updateNotificationPreferences(currentUser.userId, dto); return { statusCode: HttpStatus.OK, diff --git a/src/modules/admin/profile/tests/admin-notification-preference.action.spec.ts b/src/modules/admin/profile/tests/admin-notification-preference.action.spec.ts index 0ab79904..dee19ebe 100644 --- a/src/modules/admin/profile/tests/admin-notification-preference.action.spec.ts +++ b/src/modules/admin/profile/tests/admin-notification-preference.action.spec.ts @@ -1,6 +1,7 @@ import { Repository } from 'typeorm'; import { AdminNotificationPreferenceModelAction } from '../actions/admin-notification-preference.action'; import { AdminNotificationPreference } from '../entities/admin-notification-preference.entity'; +import { EntityManager } from 'typeorm'; describe('AdminNotificationPreferenceModelAction', () => { it('creates defaults without a transaction by default', async () => { @@ -18,7 +19,7 @@ describe('AdminNotificationPreferenceModelAction', () => { }); it('creates defaults within a supplied transaction manager', async () => { - const transaction = {} as never; + const transaction = {} as EntityManager; const action = new AdminNotificationPreferenceModelAction({} as Repository); const createSpy = jest.spyOn(action, 'create').mockResolvedValue({} as AdminNotificationPreference); From bca113f0d1324e22a1715fc8e5f3d93e9705d69b Mon Sep 17 00:00:00 2001 From: ibraheembello Date: Wed, 10 Jun 2026 23:04:30 +0100 Subject: [PATCH 36/37] feat(admin/logs): capture request user agent on log entries Add a nullable user_agent column to admin_logs and capture the request User-Agent header in LogService at write time (the request object is recycled before the deferred insert, so it must be read synchronously). Stored raw and capped at 512 chars; the admin logs read side parses it into a device label. Non-HTTP actions and absent headers store null. --- src/common/services/log.service.ts | 16 ++++++++++++ src/common/services/tests/log.service.spec.ts | 25 +++++++++++++++++++ .../1781308800000-AddUserAgentToAdminLogs.ts | 21 ++++++++++++++++ .../admin/logs/entities/admin-log.entity.ts | 4 +++ 4 files changed, 66 insertions(+) create mode 100644 src/database/migrations/1781308800000-AddUserAgentToAdminLogs.ts diff --git a/src/common/services/log.service.ts b/src/common/services/log.service.ts index f4f9b7a4..eb75b1c8 100644 --- a/src/common/services/log.service.ts +++ b/src/common/services/log.service.ts @@ -14,6 +14,8 @@ const MAX_STRING_LENGTH = 500; const MAX_SCRUB_DEPTH = 8; /** ip_address column is varchar(45) — a full IPv6 textual address. */ const MAX_IP_LENGTH = 45; +/** user_agent column is varchar(512); truncate anything longer before persisting. */ +const MAX_USER_AGENT_LENGTH = 512; /** * Shared audit-trail writer (BE-ADM-609). Persists admin_logs rows for the @@ -44,6 +46,7 @@ export class LogService { // Capture request-derived data synchronously: the request object may be // recycled by the framework before the deferred insert runs. const ipAddress = this.extractIpAddress(req); + const userAgent = this.extractUserAgent(req); const scrubbedMetadata = metadata ? this.scrubObject(metadata, 0) : {}; setImmediate(() => { @@ -55,6 +58,7 @@ export class LogService { action_type: actionType, description, ip_address: ipAddress, + user_agent: userAgent, status, metadata: scrubbedMetadata, }); @@ -84,6 +88,18 @@ export class LogService { return clientIp ? clientIp.slice(0, MAX_IP_LENGTH) : null; } + /** Captures the raw User-Agent header; the read side parses it into a device label. */ + private extractUserAgent(req: Request | null): string | null { + if (!req) { + return null; + } + + // The 'user-agent' header is a single-value header (string | undefined). + const userAgent = req.headers?.['user-agent']; + + return userAgent ? userAgent.slice(0, MAX_USER_AGENT_LENGTH) : null; + } + /** FR-4: redact sensitive keys and truncate oversized strings, recursively. */ private scrubObject(value: Record, depth: number): Record { const scrubbed: Record = {}; diff --git a/src/common/services/tests/log.service.spec.ts b/src/common/services/tests/log.service.spec.ts index 252d75bf..3a871033 100644 --- a/src/common/services/tests/log.service.spec.ts +++ b/src/common/services/tests/log.service.spec.ts @@ -56,11 +56,36 @@ describe('LogService', () => { action_type: AdminLogActionType.LOGIN, description: 'User logged in', ip_address: '127.0.0.1', + user_agent: null, status: AdminLogStatus.SUCCESS, metadata: {}, }); }); + it('captures the User-Agent header when present', async () => { + const req = makeRequest({ + headers: { 'user-agent': 'Mozilla/5.0 (Macintosh) Chrome/134.0.0.0 Safari/537.36' }, + } as Partial); + + service.log('user-1', AdminLogActionType.LOGIN, 'x', req, AdminLogStatus.SUCCESS); + await flushImmediates(); + + expect(mockAdminLogRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + user_agent: 'Mozilla/5.0 (Macintosh) Chrome/134.0.0.0 Safari/537.36', + }), + ); + }); + + it('stores a null user_agent when the header is absent', async () => { + service.log('user-1', AdminLogActionType.LOGIN, 'x', makeRequest(), AdminLogStatus.SUCCESS); + await flushImmediates(); + + expect(mockAdminLogRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ user_agent: null }), + ); + }); + it('AC-04 / FR-2: returns before the database write starts', () => { service.log('user-1', AdminLogActionType.LOGIN, 'x', null, AdminLogStatus.SUCCESS); diff --git a/src/database/migrations/1781308800000-AddUserAgentToAdminLogs.ts b/src/database/migrations/1781308800000-AddUserAgentToAdminLogs.ts new file mode 100644 index 00000000..4dbe720b --- /dev/null +++ b/src/database/migrations/1781308800000-AddUserAgentToAdminLogs.ts @@ -0,0 +1,21 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Adds the raw User-Agent capture column to admin_logs. The value is parsed into + * a "Browser Major · OS Version" device label on read; storing it raw lets the + * parser improve later without a backfill. Nullable: non-HTTP actions and older + * rows have no user agent. varchar(512) comfortably fits real-world UA strings. + */ +export class AddUserAgentToAdminLogs1781308800000 implements MigrationInterface { + name = 'AddUserAgentToAdminLogs1781308800000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "admin_logs" ADD COLUMN IF NOT EXISTS "user_agent" character varying(512)`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "admin_logs" DROP COLUMN IF EXISTS "user_agent"`); + } +} diff --git a/src/modules/admin/logs/entities/admin-log.entity.ts b/src/modules/admin/logs/entities/admin-log.entity.ts index 8cc7b8c8..0eac4bc1 100644 --- a/src/modules/admin/logs/entities/admin-log.entity.ts +++ b/src/modules/admin/logs/entities/admin-log.entity.ts @@ -37,6 +37,10 @@ export class AdminLog { @Column({ type: 'varchar', length: 45, nullable: true }) ip_address: string | null; + /** Raw User-Agent header captured at write time; parsed into a device label on read. */ + @Column({ type: 'varchar', length: 512, nullable: true }) + user_agent: string | null; + @Index() @Column({ type: 'varchar', length: 10 }) status: AdminLogStatus; From bc3c4f446b6ec4b83b7e7aa5253d7fc98c24847b Mon Sep 17 00:00:00 2001 From: ibraheembello Date: Wed, 10 Jun 2026 23:06:31 +0100 Subject: [PATCH 37/37] feat(admin/logs): expose location and device on admin logs feed GET /admin/logs now returns two derived fields per entry: - device: the stored user agent parsed into a 'Browser Major . OS Version' label by a dependency-free parser (Chrome, Firefox, Safari, Edge, Opera across Windows, macOS, iOS, Android, Linux). - location: a 'Region, CC' label resolved from ip_address via freeipapi.com, a keyless HTTPS endpoint, over the built-in fetch. Cached per IP and degrades to null on any failure so the feed never breaks. The offline geo-IP database that would avoid the network call is a banned dependency. Both fields are null when they cannot be resolved (no IP, no user agent, private/loopback address, or provider unreachable). --- .../logs/actions/admin-logs-list.action.ts | 2 + src/modules/admin/logs/admin-logs.module.ts | 3 +- src/modules/admin/logs/admin-logs.service.ts | 16 ++- .../admin/logs/docs/admin-logs-swagger.doc.ts | 11 +- .../logs/interfaces/admin-logs.interfaces.ts | 4 + .../logs/services/geo-location.service.ts | 113 ++++++++++++++++++ .../logs/tests/admin-logs.controller.spec.ts | 2 + .../logs/tests/admin-logs.service.spec.ts | 28 ++++- .../logs/tests/geo-location.service.spec.ts | 91 ++++++++++++++ .../logs/tests/parse-user-agent.util.spec.ts | 53 ++++++++ .../admin/logs/utils/parse-user-agent.util.ts | 112 +++++++++++++++++ 11 files changed, 427 insertions(+), 8 deletions(-) create mode 100644 src/modules/admin/logs/services/geo-location.service.ts create mode 100644 src/modules/admin/logs/tests/geo-location.service.spec.ts create mode 100644 src/modules/admin/logs/tests/parse-user-agent.util.spec.ts create mode 100644 src/modules/admin/logs/utils/parse-user-agent.util.ts diff --git a/src/modules/admin/logs/actions/admin-logs-list.action.ts b/src/modules/admin/logs/actions/admin-logs-list.action.ts index 37f6e5e5..b58a1cb4 100644 --- a/src/modules/admin/logs/actions/admin-logs-list.action.ts +++ b/src/modules/admin/logs/actions/admin-logs-list.action.ts @@ -23,6 +23,7 @@ export interface RawAdminLogRow { log_action_type: AdminLogActionType; log_description: string; log_ip_address: string | null; + log_user_agent: string | null; log_status: AdminLogStatus; log_created_at: Date; } @@ -45,6 +46,7 @@ export class AdminLogsListAction { .addSelect('log.action_type', 'log_action_type') .addSelect('log.description', 'log_description') .addSelect('log.ip_address', 'log_ip_address') + .addSelect('log.user_agent', 'log_user_agent') .addSelect('log.status', 'log_status') .addSelect('log.created_at', 'log_created_at'); diff --git a/src/modules/admin/logs/admin-logs.module.ts b/src/modules/admin/logs/admin-logs.module.ts index 17167796..78e789d9 100644 --- a/src/modules/admin/logs/admin-logs.module.ts +++ b/src/modules/admin/logs/admin-logs.module.ts @@ -4,10 +4,11 @@ import { AdminAuthModule } from '../auth/admin-auth.module'; import { AdminLogsListAction } from './actions/admin-logs-list.action'; import { AdminLogsController } from './admin-logs.controller'; import { AdminLogsService } from './admin-logs.service'; +import { GeoLocationService } from './services/geo-location.service'; @Module({ imports: [RedisModule, AdminAuthModule], controllers: [AdminLogsController], - providers: [AdminLogsService, AdminLogsListAction], + providers: [AdminLogsService, AdminLogsListAction, GeoLocationService], }) export class AdminLogsModule {} diff --git a/src/modules/admin/logs/admin-logs.service.ts b/src/modules/admin/logs/admin-logs.service.ts index 95893b56..77c42402 100644 --- a/src/modules/admin/logs/admin-logs.service.ts +++ b/src/modules/admin/logs/admin-logs.service.ts @@ -3,6 +3,8 @@ import * as SYS_MSG from '../../../constants/system.messages'; import { AdminLogsListAction, RawAdminLogRow } from './actions/admin-logs-list.action'; import { GetAdminLogsQueryDto } from './dto/get-admin-logs-query.dto'; import { AdminLogItem, AdminLogsListMeta, AdminLogsListResponse } from './interfaces/admin-logs.interfaces'; +import { GeoLocationService } from './services/geo-location.service'; +import { formatDevice } from './utils/parse-user-agent.util'; const MAX_PER_PAGE = 50; const DEFAULT_PAGE = 1; @@ -13,7 +15,10 @@ const DATE_ONLY_LENGTH = 10; @Injectable() export class AdminLogsService { - constructor(private readonly adminLogsListAction: AdminLogsListAction) {} + constructor( + private readonly adminLogsListAction: AdminLogsListAction, + private readonly geoLocationService: GeoLocationService, + ) {} /** Returns the paginated, filtered audit-log feed, newest first. */ async listLogs(dto: GetAdminLogsQueryDto): Promise { @@ -49,11 +54,14 @@ export class AdminLogsService { ...(capped ? { capped: true } : {}), }; - return { data: rows.map((row) => this.toLogItem(row)), meta }; + // Resolve every row's location in one deduplicated pass before mapping. + const locations = await this.geoLocationService.resolveMany(rows.map((row) => row.log_ip_address)); + + return { data: rows.map((row, index) => this.toLogItem(row, locations[index])), meta }; } /** Maps a raw joined row to the FR-3 response shape (EC-01: never a null reference). */ - private toLogItem(row: RawAdminLogRow): AdminLogItem { + private toLogItem(row: RawAdminLogRow, location: string | null): AdminLogItem { return { id: row.log_id, user_id: row.log_user_id, @@ -62,6 +70,8 @@ export class AdminLogsService { action_type: row.log_action_type, description: row.log_description, ip_address: row.log_ip_address, + location, + device: formatDevice(row.log_user_agent), created_at: row.log_created_at, status: row.log_status, }; diff --git a/src/modules/admin/logs/docs/admin-logs-swagger.doc.ts b/src/modules/admin/logs/docs/admin-logs-swagger.doc.ts index 136a1054..e6849f1f 100644 --- a/src/modules/admin/logs/docs/admin-logs-swagger.doc.ts +++ b/src/modules/admin/logs/docs/admin-logs-swagger.doc.ts @@ -17,8 +17,11 @@ export function GetAdminLogsDocs(): ReturnType { 'Returns the paginated audit trail of user and system activity, newest first. ' + 'Filter by action_type, status and created_at date range; search matches the ' + 'acting user by full_name or email. Entries whose user was deleted display ' + - '`Deleted User` with a null email. per_page values above 50 are silently ' + - 'capped and flagged via `meta.capped`. Requires a valid admin JWT.', + '`Deleted User` with a null email. `location` is derived from `ip_address` ' + + '(format `Region, CC`) and `device` is parsed from the captured user agent ' + + '(format `Browser Major · OS Version`); either is null when it cannot be ' + + 'resolved. per_page values above 50 are silently capped and flagged via ' + + '`meta.capped`. Requires a valid admin JWT.', }), ApiOkResponse({ description: 'Logs retrieved successfully', @@ -37,6 +40,8 @@ export function GetAdminLogsDocs(): ReturnType { action_type: 'login', description: 'User logged in', ip_address: '102.89.33.21', + location: 'Lagos, NG', + device: 'Chrome 134 · macOS 10.15.7', created_at: '2026-06-06T09:15:00.000Z', status: 'success', }, @@ -48,6 +53,8 @@ export function GetAdminLogsDocs(): ReturnType { action_type: 'account_deleted', description: 'User deleted their account', ip_address: '102.89.33.22', + location: 'Abuja, NG', + device: 'Safari 17 · iOS 17.1', created_at: '2026-06-05T18:42:00.000Z', status: 'success', }, diff --git a/src/modules/admin/logs/interfaces/admin-logs.interfaces.ts b/src/modules/admin/logs/interfaces/admin-logs.interfaces.ts index 24449003..16943cd0 100644 --- a/src/modules/admin/logs/interfaces/admin-logs.interfaces.ts +++ b/src/modules/admin/logs/interfaces/admin-logs.interfaces.ts @@ -9,6 +9,10 @@ export interface AdminLogItem { action_type: AdminLogActionType; description: string; ip_address: string | null; + /** "Region, CC" derived from ip_address, or null when it cannot be resolved. */ + location: string | null; + /** "Browser Major · OS Version" parsed from the stored user agent, or null. */ + device: string | null; created_at: Date; status: AdminLogStatus; } diff --git a/src/modules/admin/logs/services/geo-location.service.ts b/src/modules/admin/logs/services/geo-location.service.ts new file mode 100644 index 00000000..0ee7c6e9 --- /dev/null +++ b/src/modules/admin/logs/services/geo-location.service.ts @@ -0,0 +1,113 @@ +import { Injectable, Logger } from '@nestjs/common'; + +/** Shape of the fields we read from the keyless geo-IP endpoint (freeipapi.com). */ +interface GeoLookupResponse { + regionName?: string; + countryCode?: string; +} + +/** + * Resolves an IP address to a compact "Region, CC" label (for example "Lagos, NG") + * for the admin logs feed. + * + * Uses freeipapi.com, a keyless HTTPS geo-IP endpoint, over the built-in fetch. + * The offline geo-IP database that would have made this dependency-free of a + * network call is a new package, which the team blocks, so we look it up at read + * time instead. Results are cached per IP for the process lifetime, and every + * failure path degrades to null so the logs endpoint never fails when lookup is + * unavailable. + */ +@Injectable() +export class GeoLocationService { + private readonly logger = new Logger(GeoLocationService.name); + private readonly cache = new Map(); + + private static readonly ENDPOINT = 'https://freeipapi.com/api/json'; + private static readonly LOOKUP_TIMEOUT_MS = 2000; + + /** Resolves a single IP, using and populating the per-process cache. */ + async resolve(ip: string | null): Promise { + if (!ip || this.isNonRoutable(ip)) { + return null; + } + + const cached = this.cache.get(ip); + if (cached !== undefined) { + return cached; + } + + const location = await this.lookup(ip); + this.cache.set(ip, location); + return location; + } + + /** + * Resolves many IPs at once, deduplicating lookups, and returns labels aligned + * one-to-one with the input order (null entries stay null). + */ + async resolveMany(ips: Array): Promise> { + const unique = [...new Set(ips.filter((ip): ip is string => Boolean(ip)))]; + await Promise.all(unique.map((ip) => this.resolve(ip))); + return ips.map((ip) => (ip ? (this.cache.get(ip) ?? null) : null)); + } + + private async lookup(ip: string): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), GeoLocationService.LOOKUP_TIMEOUT_MS); + + try { + const response = await fetch(`${GeoLocationService.ENDPOINT}/${ip}`, { + signal: controller.signal, + }); + + if (!response.ok) { + return null; + } + + const body = (await response.json()) as GeoLookupResponse; + return this.format(body); + } catch (error: unknown) { + const detail = error instanceof Error ? error.message : String(error); + this.logger.warn(`Geo lookup failed for ${ip}: ${detail}`); + return null; + } finally { + clearTimeout(timer); + } + } + + private format(body: GeoLookupResponse): string | null { + const region = this.clean(body.regionName); + const country = this.clean(body.countryCode); + + if (!country) { + return null; + } + + return region ? `${region}, ${country}` : country; + } + + /** Normalises a field, treating blanks and the provider's "-"/"Unknown" as absent. */ + private clean(value: string | undefined): string | null { + const trimmed = value?.trim(); + + if (!trimmed || trimmed === '-' || trimmed.toLowerCase() === 'unknown') { + return null; + } + + return trimmed; + } + + /** Private, loopback and link-local addresses have no public geolocation. */ + private isNonRoutable(ip: string): boolean { + return ( + ip === '127.0.0.1' || + ip === '::1' || + ip.startsWith('10.') || + ip.startsWith('192.168.') || + ip.startsWith('169.254.') || + /^172\.(1[6-9]|2\d|3[0-1])\./.test(ip) || + ip.startsWith('fc') || + ip.startsWith('fd') + ); + } +} diff --git a/src/modules/admin/logs/tests/admin-logs.controller.spec.ts b/src/modules/admin/logs/tests/admin-logs.controller.spec.ts index 9b5791b8..56bc16ed 100644 --- a/src/modules/admin/logs/tests/admin-logs.controller.spec.ts +++ b/src/modules/admin/logs/tests/admin-logs.controller.spec.ts @@ -17,6 +17,8 @@ const MOCK_LIST_RESPONSE = { action_type: AdminLogActionType.LOGIN, description: 'User logged in', ip_address: '102.89.33.21', + location: 'Lagos, NG', + device: 'Chrome 134 · macOS 10.15.7', created_at: new Date('2026-06-06T09:15:00.000Z'), status: AdminLogStatus.SUCCESS, }, diff --git a/src/modules/admin/logs/tests/admin-logs.service.spec.ts b/src/modules/admin/logs/tests/admin-logs.service.spec.ts index e2f2a439..f7452434 100644 --- a/src/modules/admin/logs/tests/admin-logs.service.spec.ts +++ b/src/modules/admin/logs/tests/admin-logs.service.spec.ts @@ -5,6 +5,11 @@ import { AdminLogsListAction, RawAdminLogRow } from '../actions/admin-logs-list. import { AdminLogsService } from '../admin-logs.service'; import { GetAdminLogsQueryDto } from '../dto/get-admin-logs-query.dto'; import { AdminLogActionType, AdminLogStatus } from '../enums/admin-log.enum'; +import { GeoLocationService } from '../services/geo-location.service'; + +const CHROME_MAC_UA = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 ' + + '(KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36'; const makeRow = (overrides: Partial = {}): RawAdminLogRow => ({ log_id: 'log-uuid-1', @@ -14,12 +19,17 @@ const makeRow = (overrides: Partial = {}): RawAdminLogRow => ({ log_action_type: AdminLogActionType.LOGIN, log_description: 'User logged in', log_ip_address: '102.89.33.21', + log_user_agent: CHROME_MAC_UA, log_status: AdminLogStatus.SUCCESS, log_created_at: new Date('2026-06-06T09:15:00.000Z'), ...overrides, }); const mockAdminLogsListAction = { findLogsWithFilters: jest.fn() }; +// Echoes one label per input IP so location mapping is deterministic in tests. +const mockGeoLocationService = { + resolveMany: jest.fn((ips: Array) => Promise.resolve(ips.map((ip) => (ip ? 'Lagos, NG' : null)))), +}; describe('AdminLogsService', () => { let service: AdminLogsService; @@ -27,11 +37,15 @@ describe('AdminLogsService', () => { beforeEach(async () => { jest.clearAllMocks(); mockAdminLogsListAction.findLogsWithFilters.mockResolvedValue([[makeRow()], 1]); + mockGeoLocationService.resolveMany.mockImplementation((ips: Array) => + Promise.resolve(ips.map((ip) => (ip ? 'Lagos, NG' : null))), + ); const module: TestingModule = await Test.createTestingModule({ providers: [ AdminLogsService, { provide: AdminLogsListAction, useValue: mockAdminLogsListAction }, + { provide: GeoLocationService, useValue: mockGeoLocationService }, ], }).compile(); @@ -56,7 +70,7 @@ describe('AdminLogsService', () => { ); }); - it('FR-3: maps a row to exactly the nine allowed response fields', async () => { + it('FR-3: maps a row to exactly the allowed response fields, with location and device', async () => { const result = await service.listLogs({} as GetAdminLogsQueryDto); const item = result.data[0]; @@ -68,10 +82,20 @@ describe('AdminLogsService', () => { action_type: AdminLogActionType.LOGIN, description: 'User logged in', ip_address: '102.89.33.21', + location: 'Lagos, NG', + device: 'Chrome 134 · macOS 10.15.7', created_at: new Date('2026-06-06T09:15:00.000Z'), status: AdminLogStatus.SUCCESS, }); - expect(Object.keys(item)).toHaveLength(9); + expect(Object.keys(item)).toHaveLength(11); + }); + + it('maps a null user agent to a null device', async () => { + mockAdminLogsListAction.findLogsWithFilters.mockResolvedValue([[makeRow({ log_user_agent: null })], 1]); + + const result = await service.listLogs({} as GetAdminLogsQueryDto); + + expect(result.data[0].device).toBeNull(); }); it('computes has_next true when more rows exist beyond the current page', async () => { diff --git a/src/modules/admin/logs/tests/geo-location.service.spec.ts b/src/modules/admin/logs/tests/geo-location.service.spec.ts new file mode 100644 index 00000000..f5ac5d32 --- /dev/null +++ b/src/modules/admin/logs/tests/geo-location.service.spec.ts @@ -0,0 +1,91 @@ +import { Logger } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { GeoLocationService } from '../services/geo-location.service'; + +const okResponse = (body: unknown): Response => + ({ ok: true, json: () => Promise.resolve(body) }) as unknown as Response; + +describe('GeoLocationService', () => { + let service: GeoLocationService; + + beforeEach(async () => { + jest.clearAllMocks(); + jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); + + const module: TestingModule = await Test.createTestingModule({ + providers: [GeoLocationService], + }).compile(); + + service = module.get(GeoLocationService); + }); + + afterEach(() => jest.restoreAllMocks()); + + describe('resolve', () => { + it('formats a successful lookup as "Region, CC"', async () => { + global.fetch = jest + .fn() + .mockResolvedValueOnce(okResponse({ regionName: 'Lagos', countryCode: 'NG' })); + + await expect(service.resolve('102.89.33.21')).resolves.toBe('Lagos, NG'); + }); + + it('falls back to the country code alone when the region is absent', async () => { + global.fetch = jest.fn().mockResolvedValueOnce(okResponse({ regionName: '-', countryCode: 'NG' })); + + await expect(service.resolve('102.89.33.21')).resolves.toBe('NG'); + }); + + it('returns null and never calls fetch for a null IP', async () => { + global.fetch = jest.fn(); + + await expect(service.resolve(null)).resolves.toBeNull(); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('returns null and never calls fetch for a private IP', async () => { + global.fetch = jest.fn(); + + await expect(service.resolve('192.168.0.10')).resolves.toBeNull(); + await expect(service.resolve('10.0.0.4')).resolves.toBeNull(); + await expect(service.resolve('127.0.0.1')).resolves.toBeNull(); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('returns null when the provider returns no country code', async () => { + global.fetch = jest.fn().mockResolvedValueOnce(okResponse({ regionName: '-', countryCode: '' })); + + await expect(service.resolve('8.8.8.8')).resolves.toBeNull(); + }); + + it('returns null and swallows a network error', async () => { + global.fetch = jest.fn().mockRejectedValueOnce(new Error('network down')); + + await expect(service.resolve('8.8.8.8')).resolves.toBeNull(); + }); + + it('caches a resolved IP and does not call fetch again', async () => { + global.fetch = jest + .fn() + .mockResolvedValueOnce(okResponse({ regionName: 'Abuja', countryCode: 'NG' })); + + await expect(service.resolve('41.58.1.1')).resolves.toBe('Abuja, NG'); + await expect(service.resolve('41.58.1.1')).resolves.toBe('Abuja, NG'); + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + }); + + describe('resolveMany', () => { + it('deduplicates lookups and aligns results with the input order', async () => { + global.fetch = jest + .fn() + .mockResolvedValue(okResponse({ regionName: 'Lagos', countryCode: 'NG' })); + + const result = await service.resolveMany(['9.9.9.9', null, '9.9.9.9']); + + expect(result).toEqual(['Lagos, NG', null, 'Lagos, NG']); + // One unique routable IP means exactly one network call. + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/modules/admin/logs/tests/parse-user-agent.util.spec.ts b/src/modules/admin/logs/tests/parse-user-agent.util.spec.ts new file mode 100644 index 00000000..7d924e1e --- /dev/null +++ b/src/modules/admin/logs/tests/parse-user-agent.util.spec.ts @@ -0,0 +1,53 @@ +import { formatDevice } from '../utils/parse-user-agent.util'; + +const SEP = '·'; + +describe('formatDevice', () => { + it('returns null for null, undefined or empty input', () => { + expect(formatDevice(null)).toBeNull(); + expect(formatDevice(undefined)).toBeNull(); + expect(formatDevice('')).toBeNull(); + }); + + it('parses Chrome on macOS', () => { + const ua = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 ' + + '(KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36'; + expect(formatDevice(ua)).toBe(`Chrome 134 ${SEP} macOS 10.15.7`); + }); + + it('parses Firefox on Windows 10', () => { + const ua = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:132.0) Gecko/20100101 Firefox/132.0'; + expect(formatDevice(ua)).toBe(`Firefox 132 ${SEP} Windows 10`); + }); + + it('parses Safari on iOS', () => { + const ua = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_1 like Mac OS X) AppleWebKit/605.1.15 ' + + '(KHTML, like Gecko) Version/17.1 Mobile/15E148 Safari/604.1'; + expect(formatDevice(ua)).toBe(`Safari 17 ${SEP} iOS 17.1`); + }); + + it('detects Edge before Chrome (Edge embeds the Chrome token)', () => { + const ua = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) ' + + 'Chrome/134.0.0.0 Safari/537.36 Edg/134.0.0.0'; + expect(formatDevice(ua)).toBe(`Edge 134 ${SEP} Windows 10`); + }); + + it('parses Chrome on Android', () => { + const ua = + 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) ' + + 'Chrome/134.0.0.0 Mobile Safari/537.36'; + expect(formatDevice(ua)).toBe(`Chrome 134 ${SEP} Android 14`); + }); + + it('returns the browser alone when the OS is unrecognised', () => { + const ua = 'Mozilla/5.0 (Unknown) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0'; + expect(formatDevice(ua)).toBe('Chrome 134'); + }); + + it('returns null when neither browser nor OS can be identified', () => { + expect(formatDevice('curl/8.4.0')).toBeNull(); + }); +}); diff --git a/src/modules/admin/logs/utils/parse-user-agent.util.ts b/src/modules/admin/logs/utils/parse-user-agent.util.ts new file mode 100644 index 00000000..3ccdb78e --- /dev/null +++ b/src/modules/admin/logs/utils/parse-user-agent.util.ts @@ -0,0 +1,112 @@ +/** + * Dependency-free User-Agent formatter for the admin logs feed. + * + * Produces a compact "Browser Major · OS Version" label (for example + * "Chrome 134 · macOS 10.15.7") to match the activity-log design. An offline + * geo/UA parsing library would have been a new dependency, which the team + * blocks, so this parses the common browsers and platforms by hand. + * + * Caveat: modern browsers freeze the high-entropy OS version in the legacy UA + * string (macOS is pinned at 10_15_7, Windows 11 still reports NT 10.0), so the + * OS portion is best-effort. Returns null when nothing recognisable is found. + */ + +/** Middle dot (U+00B7) used as the browser/OS separator in the design. */ +const SEPARATOR = '·'; + +interface ParsedAgent { + name: string; + version: string | null; +} + +export function formatDevice(userAgent: string | null | undefined): string | null { + if (!userAgent) { + return null; + } + + const browser = parseBrowser(userAgent); + const os = parseOs(userAgent); + const browserLabel = browser ? joinNameVersion(browser) : null; + const osLabel = os ? joinNameVersion(os) : null; + + if (browserLabel && osLabel) { + return `${browserLabel} ${SEPARATOR} ${osLabel}`; + } + + return browserLabel ?? osLabel; +} + +function joinNameVersion(parsed: ParsedAgent): string { + return parsed.version ? `${parsed.name} ${parsed.version}` : parsed.name; +} + +/** Order matters: Edge and Opera embed "Chrome", and Chrome embeds "Safari". */ +function parseBrowser(ua: string): ParsedAgent | null { + const edge = /Edg(?:e|A|iOS)?\/(\d+)/.exec(ua); + if (edge) { + return { name: 'Edge', version: edge[1] }; + } + + const opera = /(?:OPR|Opera)\/(\d+)/.exec(ua); + if (opera) { + return { name: 'Opera', version: opera[1] }; + } + + const firefox = /(?:Firefox|FxiOS)\/(\d+)/.exec(ua); + if (firefox) { + return { name: 'Firefox', version: firefox[1] }; + } + + const chrome = /(?:Chrome|CriOS)\/(\d+)/.exec(ua); + if (chrome) { + return { name: 'Chrome', version: chrome[1] }; + } + + if (/Safari\//.test(ua)) { + const version = /Version\/(\d+)/.exec(ua); + return { name: 'Safari', version: version ? version[1] : null }; + } + + return null; +} + +/** iOS is checked before macOS because iPad UAs can also mention "Mac OS X". */ +function parseOs(ua: string): ParsedAgent | null { + const windows = /Windows NT (\d+\.\d+)/.exec(ua); + if (windows) { + return { name: 'Windows', version: mapWindowsVersion(windows[1]) }; + } + + const ios = /(?:iPhone OS|CPU OS) (\d+(?:_\d+)*)/.exec(ua); + if (ios) { + return { name: 'iOS', version: ios[1].replace(/_/g, '.') }; + } + + const mac = /Mac OS X (\d+(?:_\d+)*)/.exec(ua); + if (mac) { + return { name: 'macOS', version: mac[1].replace(/_/g, '.') }; + } + + const android = /Android (\d+(?:\.\d+)?)/.exec(ua); + if (android) { + return { name: 'Android', version: android[1] }; + } + + if (/Linux/.test(ua)) { + return { name: 'Linux', version: null }; + } + + return null; +} + +/** Maps Windows NT kernel versions to their marketing names where well known. */ +function mapWindowsVersion(ntVersion: string): string { + const marketingNames: Record = { + '10.0': '10', + '6.3': '8.1', + '6.2': '8', + '6.1': '7', + }; + + return marketingNames[ntVersion] ?? ntVersion; +}