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/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/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/config/env.ts b/src/config/env.ts index 9b0bd41b..8f51305d 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.union([z.literal(''), 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(''), @@ -51,6 +54,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/constants/redis-keys.ts b/src/constants/redis-keys.ts index 9a937746..e767307c 100644 --- a/src/constants/redis-keys.ts +++ b/src/constants/redis-keys.ts @@ -20,4 +20,8 @@ export const redisKeys = { adminDashboardFunnelPerformance: () => 'admin-dashboard:funnel-performance', adminDashboardUserStages: () => 'admin-dashboard:user-stages', adminDashboardUserRetention: () => 'admin-dashboard:user-retention', + + 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 baa4c95a..c097ac41 100644 --- a/src/constants/system.messages.ts +++ b/src/constants/system.messages.ts @@ -28,6 +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_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'; @@ -236,6 +238,11 @@ export const PROFILE_AVATAR_DELETE_FAILED = 'Failed to remove profile avatar'; export const NOTIFICATION_PREFERENCES_RETRIEVED_SUCCESSFULLY = 'Notification preferences retrieved successfully'; export const NOTIFICATION_PREFERENCES_UPDATED_SUCCESSFULLY = 'Notification preferences updated successfully'; +// Admin notification preferences +export const ADMIN_NOTIFICATION_PREFERENCES_RETRIEVED_SUCCESSFULLY = 'Admin notification preferences retrieved successfully'; +export const ADMIN_NOTIFICATION_PREFERENCES_UPDATED_SUCCESSFULLY = 'Admin notification preferences updated successfully'; +export const ADMIN_NOTIFICATION_PREFERENCES_UPDATE_FAILED = 'Failed to update admin notification preferences'; + // Account Deletion export const ACCOUNT_DELETED_SUCCESSFULLY = 'Your account has been deleted. You will be signed out.'; export const ACCOUNT_ALREADY_DELETED = 'Account already deleted'; @@ -273,8 +280,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'; @@ -363,4 +369,18 @@ 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 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_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'; +export const VOICE_NO_ACTIVE_SESSION = 'No active session exists'; diff --git a/src/database/migrations/1780737043609-admin-notification-preferences.ts b/src/database/migrations/1780737043609-admin-notification-preferences.ts new file mode 100644 index 00000000..0d03e4ea --- /dev/null +++ b/src/database/migrations/1780737043609-admin-notification-preferences.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AdminNotificationPreferences1780737043609 implements MigrationInterface { + name = 'AdminNotificationPreferences1780737043609'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "admin_notification_preferences" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "user_id" uuid NOT NULL, "general_notifications" boolean NOT NULL DEFAULT true, "push_email" boolean NOT NULL DEFAULT true, CONSTRAINT "PK_admin_notification_preferences_id" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_admin_notification_preferences_user_id" ON "admin_notification_preferences" ("user_id")`, + ); + await queryRunner.query( + `ALTER TABLE "admin_notification_preferences" ADD CONSTRAINT "FK_admin_notification_preferences_user_id" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + 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/migrations/1780950180322-AddSourceTypeToUploadEntity.ts b/src/database/migrations/1780950180322-AddSourceTypeToUploadEntity.ts new file mode 100644 index 00000000..6ec682cd --- /dev/null +++ b/src/database/migrations/1780950180322-AddSourceTypeToUploadEntity.ts @@ -0,0 +1,64 @@ +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(`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") `); + 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(`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") `); + 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`); + } + +} 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/database/seeds/user.seeder.ts b/src/database/seeds/user.seeder.ts index f2e3fb7c..2edc9177 100644 --- a/src/database/seeds/user.seeder.ts +++ b/src/database/seeds/user.seeder.ts @@ -1,9 +1,12 @@ 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'; 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 { @@ -27,8 +30,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'); @@ -53,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, @@ -66,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/email/templates/delete-account.hbs b/src/email/templates/delete-account.hbs index 80594029..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 process.

-

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

+

+ 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. +

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/notification-alert.hbs b/src/email/templates/notification-alert.hbs index 0a7082a2..9ffda883 100644 --- a/src/email/templates/notification-alert.hbs +++ b/src/email/templates/notification-alert.hbs @@ -1,9 +1,27 @@ -

Notification alert

+

+ 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.

+

+ 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 + . +

diff --git a/src/email/templates/payment-failed.hbs b/src/email/templates/payment-failed.hbs index 371c0dcd..125d09f3 100644 --- a/src/email/templates/payment-failed.hbs +++ b/src/email/templates/payment-failed.hbs @@ -1,8 +1,16 @@ -

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 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 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 + + Update payment details +
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 @@ + + + + + + +
+ Go to dashboard +
+ + diff --git a/src/email/templates/subscription-cancelled.hbs b/src/email/templates/subscription-cancelled.hbs index e5c0807f..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}}. 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}}. 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. +

@@ -11,7 +22,12 @@
- Reactivate my plan + + Reactivate my plan +
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/templates/welcome.hbs b/src/email/templates/welcome.hbs index 2882fc8a..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 engine today

+

+ 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 +

- - + + - - + + - +
1
Answer a few questions +
1
+
+ Answer a few questions +
2
We build your custom funnel +
2
+
+ We build your custom funnel +
3
+
3
+
Get your action plan
@@ -24,7 +55,12 @@
- Get my strategy plan + + 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 +
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', 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/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; 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; +} 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..83303fcd 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,51 @@ 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, + }), + }), + ) + dto: 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 +115,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..dee19ebe --- /dev/null +++ b/src/modules/admin/profile/tests/admin-notification-preference.action.spec.ts @@ -0,0 +1,49 @@ +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 () => { + 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 EntityManager; + 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 75ab91dc..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,17 +10,8 @@ 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 - ], + providers: [AdminUsersService, AdminUsersListAction, AdminUserDetailAction, RolesGuard, LogService], }) export class AdminUsersModule {} diff --git a/src/modules/admin/users/admin-users.service.ts b/src/modules/admin/users/admin-users.service.ts index b1ffb1e4..5e71f6db 100644 --- a/src/modules/admin/users/admin-users.service.ts +++ b/src/modules/admin/users/admin-users.service.ts @@ -1,279 +1,289 @@ -import { ConflictException, Injectable, NotFoundException, ForbiddenException, InternalServerErrorException } from '@nestjs/common'; -import * as bcrypt from 'bcrypt'; -import { DataSource } from 'typeorm'; -import { InjectQueue } from '@nestjs/bull'; -import type { Queue } from 'bull'; -import * as SYS_MSG from '../../../constants/system.messages'; -import { UserModelAction } from '../../users/actions/user.action'; -import { UserRoleModelAction } from '../../users/actions/user-role.action'; -import { UserSessionModelAction } from '../../users/actions/user-session.action'; -import { UsersService } from '../../users/users.service'; -import { UserPlan } from '../../users/enums/user-plan.enum'; -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 { 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'; -import { AdminUserDetailAction } from './actions/admin-user-detail.action'; -import { CreateAdminDto } from './dto/create-admin.dto'; -import { GetAdminUsersQueryDto } from './dto/get-admin-users-query.dto'; -import { AdminUserDetailResponseDto } from './dto/admin-user-detail-response.dto'; -import { SortDir, UserSortBy, UserStatusFilter } from './enums/admin-users-query.enum'; -import { AdminUsersListResponse } from './interfaces/admin-users-list-response.interface'; -import { AdminUserItem } from './interfaces/admin-user-item.interface'; - -const MAX_PER_PAGE = 50; -const DEFAULT_PAGE = 1; -const DEFAULT_PER_PAGE = 20; - -@Injectable() -export class AdminUsersService { - constructor( - private readonly usersService: UsersService, - private readonly userModelAction: UserModelAction, - private readonly userRoleModelAction: UserRoleModelAction, - private readonly userSessionModelAction: UserSessionModelAction, - private readonly dataSource: DataSource, - private readonly adminUsersListAction: AdminUsersListAction, - private readonly adminUserDetailAction: AdminUserDetailAction, - private readonly logService: LogService, - private readonly redisService: RedisService, - @InjectQueue(ACCOUNT_DELETION_QUEUE) - private readonly accountDeletionQueue: Queue, - ) {} - - /** Creates a new admin or super-admin account and assigns the specified role. */ - async createAdmin(dto: CreateAdminDto): Promise<{ message: string }> { - const existing = await this.usersService.findByEmail(dto.email); - if (existing) { - throw new ConflictException(SYS_MSG.ADMIN_EMAIL_CONFLICT); - } - - try { - const passwordHash = await bcrypt.hash(dto.password, 12); - await this.dataSource.transaction(async (manager) => { - const user = await this.userModelAction.create({ - createPayload: { - email: dto.email, - full_name: dto.full_name, - password_hash: passwordHash, - is_verified: true, - termsAccepted: true, - }, - transactionOptions: { useTransaction: true, transaction: manager }, - }); - - await this.userRoleModelAction.create({ - createPayload: { user_id: user.id, role: dto.role }, - transactionOptions: { useTransaction: true, transaction: manager }, - }); - }); - } catch (error: unknown) { - if (this.isUniqueEmailConflict(error)) { - throw new ConflictException(SYS_MSG.ADMIN_EMAIL_CONFLICT); - } - throw error; - } - - return { message: SYS_MSG.ADMIN_CREATED_SUCCESSFULLY }; - } - - /** Returns a paginated, filtered list of platform users for admin review. */ - async listUsers(dto: GetAdminUsersQueryDto): Promise { - const status = dto.status ?? UserStatusFilter.ALL; - const page = dto.page ?? DEFAULT_PAGE; - const perPage = Math.min(dto.perPage ?? DEFAULT_PER_PAGE, MAX_PER_PAGE); - const sortBy = dto.sortBy ?? UserSortBy.CREATED_AT; - const sortDir = dto.sortDir ?? SortDir.DESC; - - const [rows, total] = await this.adminUsersListAction.findUsersWithFilters( - status, - dto.search, - page, - perPage, - sortBy, - sortDir, - ); - - const thirtyDaysAgo = new Date(); - thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - ACTIVE_WINDOW_DAYS); - - const data: AdminUserItem[] = rows.map((row) => { - const lastActiveAt = row.auth_last_login_at ? new Date(row.auth_last_login_at) : null; - const isActive = lastActiveAt !== null && lastActiveAt > thirtyDaysAgo; - - return { - id: row.user_id, - full_name: row.user_full_name, - email: row.user_email, - plan: row.user_plan as UserPlan, - status: isActive ? UserStatusFilter.ACTIVE : UserStatusFilter.INACTIVE, - created_at: new Date(row.user_created_at), - last_active_at: lastActiveAt, - funnel_count: parseInt(row.funnel_count, 10), - }; - }); - - return { - data, - meta: { - total, - page, - per_page: perPage, - has_next: page * perPage < total, - }, - }; - } - - async getUserProfile(userId: string): Promise { - const { user, funnels, documents } = await this.adminUserDetailAction.findUserWithDetails(userId); - - if (!user) { - throw new NotFoundException(SYS_MSG.ADMIN_USER_NOT_FOUND); - } - - let statusIndicator: UserAccountStatus | 'deleted' = user.status; - if (user.deleted_at) { - statusIndicator = 'deleted'; - } - - return { - profile: { - fullName: user.full_name, - email: user.email, - plan: user.plan, - country: user.country, - createdAt: user.created_at, - lastActiveAt: user.auth_metadata?.last_login_at ?? null, - status: statusIndicator, - }, - informationProvided: { - businessType: user.business_type, - targetCustomer: user.target_customer, - primaryGoal: user.primary_goal, - }, - strategies: funnels.map(f => ({ - id: f.id, - funnelName: f.funnel_name, - stageCount: f.stage_count, - createdAt: f.created_at, - status: f.status, - })), - documents: documents.map(d => ({ - id: d.id, - fileName: d.file_name, - fileSizeBytes: d.file_size_bytes, - uploadedAt: d.created_at, - status: d.status, - })), - }; - } - - async updateUserStatus(userId: string, status: UserAccountStatus, adminId: string): Promise { - const user = await this.userModelAction.findById(userId); - if (!user) { - throw new NotFoundException(SYS_MSG.ADMIN_USER_NOT_FOUND); - } - - if (user.deleted_at && status !== UserAccountStatus.ACTIVE) { - throw new ConflictException('Cannot change status of a deleted user unless reactivating'); - } - - if (status === UserAccountStatus.DELETED) { - return this.deleteUser(userId, adminId); - } - - const isActive = status === UserAccountStatus.ACTIVE; - const deletedAt = status === UserAccountStatus.ACTIVE ? null : user.deleted_at; - - await this.userModelAction.update({ - identifierOptions: { id: userId }, - updatePayload: { status, is_active: isActive, deleted_at: deletedAt }, - transactionOptions: { useTransaction: false }, - }); - - await this.logService.logAction({ - admin_id: adminId, - action_type: AdminProfileActionType.ADMIN_STATUS_CHANGE, - status: 'success', - metadata: { targetUserId: userId, newStatus: status }, - }); - } - - async deleteUser(userId: string, adminId: string): Promise { - if (userId === adminId) { - throw new ForbiddenException(SYS_MSG.ADMIN_CANNOT_DELETE_SELF); - } - - const user = await this.userModelAction.findById(userId); - if (!user) { - throw new NotFoundException(SYS_MSG.ADMIN_USER_NOT_FOUND); - } - - const queryRunner = this.dataSource.createQueryRunner(); - let committed = false; - - try { - await queryRunner.connect(); - await queryRunner.startTransaction(); - - const now = new Date(); - await queryRunner.manager.update( - User, - userId, - { - deleted_at: now, - is_active: false, - status: UserAccountStatus.DELETED, - ...(user.auth_provider === 'google' ? { provider_user_id: null } : {}), - } - ); - - const revokedSessionIds = await this.userSessionModelAction.revokeAllUserSessionsInDb( - userId, - queryRunner.manager, - ); - - await queryRunner.commitTransaction(); - committed = true; - - try { - if (revokedSessionIds.length > 0) { - await this.redisService.delByPattern(`sess:${userId}:*`); - } - - await this.accountDeletionQueue.add('hard-delete', { userId, email: user.email }, { delay: 30 * 24 * 60 * 60 * 1000 }); - - await this.logService.logAction({ - admin_id: adminId, - action_type: AdminProfileActionType.ADMIN_ACCOUNT_DELETED, - status: 'success', - metadata: { targetUserId: userId }, - }); - } catch (err) { - console.error('Post-commit deletion tasks failed:', err); - } - } catch { - if (!committed) { - await queryRunner.rollbackTransaction(); - throw new InternalServerErrorException(SYS_MSG.ACCOUNT_DELETION_FAILED); - } - } finally { - await queryRunner.release(); - } - } - - private isUniqueEmailConflict(error: unknown): boolean { - return ( - error instanceof ConflictException || - Boolean( - error && - typeof error === 'object' && - 'driverError' in error && - (error as { driverError?: { code?: string } }).driverError?.code === '23505', - ) - ); - } -} +import { + ConflictException, + Injectable, + NotFoundException, + ForbiddenException, + InternalServerErrorException, +} from '@nestjs/common'; +import * as bcrypt from 'bcrypt'; +import { DataSource } from 'typeorm'; +import { InjectQueue } from '@nestjs/bull'; +import type { Queue } from 'bull'; +import * as SYS_MSG from '../../../constants/system.messages'; +import { UserModelAction } from '../../users/actions/user.action'; +import { UserRoleModelAction } from '../../users/actions/user-role.action'; +import { UserSessionModelAction } from '../../users/actions/user-session.action'; +import { UsersService } from '../../users/users.service'; +import { UserPlan } from '../../users/enums/user-plan.enum'; +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'; +import { AdminUserDetailAction } from './actions/admin-user-detail.action'; +import { CreateAdminDto } from './dto/create-admin.dto'; +import { GetAdminUsersQueryDto } from './dto/get-admin-users-query.dto'; +import { AdminUserDetailResponseDto } from './dto/admin-user-detail-response.dto'; +import { SortDir, UserSortBy, UserStatusFilter } from './enums/admin-users-query.enum'; +import { AdminUsersListResponse } from './interfaces/admin-users-list-response.interface'; +import { AdminUserItem } from './interfaces/admin-user-item.interface'; + +const MAX_PER_PAGE = 50; +const DEFAULT_PAGE = 1; +const DEFAULT_PER_PAGE = 20; + +@Injectable() +export class AdminUsersService { + constructor( + private readonly usersService: UsersService, + 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, + private readonly logService: LogService, + private readonly redisService: RedisService, + @InjectQueue(ACCOUNT_DELETION_QUEUE) + private readonly accountDeletionQueue: Queue, + ) {} + + /** Creates a new admin or super-admin account and assigns the specified role. */ + async createAdmin(dto: CreateAdminDto): Promise<{ message: string }> { + const existing = await this.usersService.findByEmail(dto.email); + if (existing) { + throw new ConflictException(SYS_MSG.ADMIN_EMAIL_CONFLICT); + } + + try { + const passwordHash = await bcrypt.hash(dto.password, 12); + await this.dataSource.transaction(async (manager) => { + const user = await this.userModelAction.create({ + createPayload: { + email: dto.email, + full_name: dto.full_name, + password_hash: passwordHash, + is_verified: true, + termsAccepted: true, + }, + transactionOptions: { useTransaction: true, transaction: manager }, + }); + + await this.userRoleModelAction.create({ + 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)) { + throw new ConflictException(SYS_MSG.ADMIN_EMAIL_CONFLICT); + } + throw error; + } + + return { message: SYS_MSG.ADMIN_CREATED_SUCCESSFULLY }; + } + + /** Returns a paginated, filtered list of platform users for admin review. */ + async listUsers(dto: GetAdminUsersQueryDto): Promise { + const status = dto.status ?? UserStatusFilter.ALL; + const page = dto.page ?? DEFAULT_PAGE; + const perPage = Math.min(dto.perPage ?? DEFAULT_PER_PAGE, MAX_PER_PAGE); + const sortBy = dto.sortBy ?? UserSortBy.CREATED_AT; + const sortDir = dto.sortDir ?? SortDir.DESC; + + const [rows, total] = await this.adminUsersListAction.findUsersWithFilters( + status, + dto.search, + page, + perPage, + sortBy, + sortDir, + ); + + const thirtyDaysAgo = new Date(); + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - ACTIVE_WINDOW_DAYS); + + const data: AdminUserItem[] = rows.map((row) => { + const lastActiveAt = row.auth_last_login_at ? new Date(row.auth_last_login_at) : null; + const isActive = lastActiveAt !== null && lastActiveAt > thirtyDaysAgo; + + return { + id: row.user_id, + full_name: row.user_full_name, + email: row.user_email, + plan: row.user_plan as UserPlan, + status: isActive ? UserStatusFilter.ACTIVE : UserStatusFilter.INACTIVE, + created_at: new Date(row.user_created_at), + last_active_at: lastActiveAt, + funnel_count: parseInt(row.funnel_count, 10), + }; + }); + + return { + data, + meta: { + total, + page, + per_page: perPage, + has_next: page * perPage < total, + }, + }; + } + + async getUserProfile(userId: string): Promise { + const { user, funnels, documents } = await this.adminUserDetailAction.findUserWithDetails(userId); + + if (!user) { + throw new NotFoundException(SYS_MSG.ADMIN_USER_NOT_FOUND); + } + + let statusIndicator: UserAccountStatus | 'deleted' = user.status; + if (user.deleted_at) { + statusIndicator = 'deleted'; + } + + return { + profile: { + fullName: user.full_name, + email: user.email, + plan: user.plan, + country: user.country, + createdAt: user.created_at, + lastActiveAt: user.auth_metadata?.last_login_at ?? null, + status: statusIndicator, + }, + informationProvided: { + businessType: user.business_type, + targetCustomer: user.target_customer, + primaryGoal: user.primary_goal, + }, + strategies: funnels.map((f) => ({ + id: f.id, + funnelName: f.funnel_name, + stageCount: f.stage_count, + createdAt: f.created_at, + status: f.status, + })), + documents: documents.map((d) => ({ + id: d.id, + fileName: d.file_name, + fileSizeBytes: d.file_size_bytes, + uploadedAt: d.created_at, + status: d.status, + })), + }; + } + + async updateUserStatus(userId: string, status: UserAccountStatus, adminId: string): Promise { + const user = await this.userModelAction.findById(userId); + if (!user) { + throw new NotFoundException(SYS_MSG.ADMIN_USER_NOT_FOUND); + } + + if (user.deleted_at && status !== UserAccountStatus.ACTIVE) { + throw new ConflictException('Cannot change status of a deleted user unless reactivating'); + } + + if (status === UserAccountStatus.DELETED) { + return this.deleteUser(userId, adminId); + } + + const isActive = status === UserAccountStatus.ACTIVE; + const deletedAt = status === UserAccountStatus.ACTIVE ? null : user.deleted_at; + + await this.userModelAction.update({ + identifierOptions: { id: userId }, + updatePayload: { status, is_active: isActive, deleted_at: deletedAt }, + transactionOptions: { useTransaction: false }, + }); + + await this.logService.logAction({ + admin_id: adminId, + action_type: AdminProfileActionType.ADMIN_STATUS_CHANGE, + status: 'success', + metadata: { targetUserId: userId, newStatus: status }, + }); + } + + async deleteUser(userId: string, adminId: string): Promise { + if (userId === adminId) { + throw new ForbiddenException(SYS_MSG.ADMIN_CANNOT_DELETE_SELF); + } + + const user = await this.userModelAction.findById(userId); + if (!user) { + throw new NotFoundException(SYS_MSG.ADMIN_USER_NOT_FOUND); + } + + const queryRunner = this.dataSource.createQueryRunner(); + let committed = false; + + try { + await queryRunner.connect(); + await queryRunner.startTransaction(); + + const now = new Date(); + await queryRunner.manager.update(User, userId, { + deleted_at: now, + is_active: false, + status: UserAccountStatus.DELETED, + ...(user.auth_provider === 'google' ? { provider_user_id: null } : {}), + }); + + const revokedSessionIds = await this.userSessionModelAction.revokeAllUserSessionsInDb( + userId, + queryRunner.manager, + ); + + await queryRunner.commitTransaction(); + committed = true; + + try { + if (revokedSessionIds.length > 0) { + await this.redisService.delByPattern(`sess:${userId}:*`); + } + + await this.accountDeletionQueue.add( + 'hard-delete', + { userId, email: user.email }, + { delay: 30 * 24 * 60 * 60 * 1000 }, + ); + + await this.logService.logAction({ + admin_id: adminId, + action_type: AdminProfileActionType.ADMIN_ACCOUNT_DELETED, + status: 'success', + metadata: { targetUserId: userId }, + }); + } catch (err) { + console.error('Post-commit deletion tasks failed:', err); + } + } catch { + if (!committed) { + await queryRunner.rollbackTransaction(); + throw new InternalServerErrorException(SYS_MSG.ACCOUNT_DELETION_FAILED); + } + } finally { + await queryRunner.release(); + } + } + + private isUniqueEmailConflict(error: unknown): boolean { + return ( + error instanceof ConflictException || + Boolean( + error && + typeof error === 'object' && + 'driverError' in error && + (error as { driverError?: { code?: string } }).driverError?.code === '23505', + ) + ); + } +} 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(); }); }); diff --git a/src/modules/auth/auth.service.spec.ts b/src/modules/auth/auth.service.spec.ts index 328a25cb..d36933e8 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'; @@ -27,6 +28,7 @@ jest.mock('bcrypt'); const mockUsersService = { findByEmail: jest.fn(), + findByEmailWithDeleted: jest.fn(), findById: jest.fn(), create: jest.fn(), createGoogleAccount: jest.fn(), @@ -135,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 () => { @@ -145,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 () => { @@ -155,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); }); @@ -430,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' }, }); @@ -541,41 +534,144 @@ 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(); + }); + + 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, + 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-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 + .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)); }); }); @@ -624,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({ @@ -895,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'); diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index fb5b2c8f..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(): { @@ -195,11 +195,11 @@ export class AuthService { let user: User; if (existingUser) { - if ( - existingUser.auth_provider === 'google' && - existingUser.provider_user_id && - existingUser.provider_user_id !== profile.providerId - ) { + if (existingUser.auth_provider !== 'google') { + throw new ConflictException(SYS_MSG.GOOGLE_EMAIL_ALREADY_LOCAL_ACCOUNT); + } + + if (existingUser.provider_user_id && existingUser.provider_user_id !== profile.providerId) { throw new ConflictException(SYS_MSG.GOOGLE_ACCOUNT_LINK_CONFLICT); } @@ -209,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, @@ -220,14 +225,18 @@ 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; } - if ( - concurrentUser.auth_provider === 'google' && - concurrentUser.provider_user_id && - concurrentUser.provider_user_id !== profile.providerId - ) { + if (concurrentUser.auth_provider !== 'google') { + throw new ConflictException(SYS_MSG.GOOGLE_EMAIL_ALREADY_LOCAL_ACCOUNT); + } + + if (concurrentUser.provider_user_id && concurrentUser.provider_user_id !== profile.providerId) { throw new ConflictException(SYS_MSG.GOOGLE_ACCOUNT_LINK_CONFLICT); } @@ -610,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; } diff --git a/src/modules/auth/guards/roles.guard.spec.ts b/src/modules/auth/guards/roles.guard.spec.ts new file mode 100644 index 00000000..c906fa8d --- /dev/null +++ b/src/modules/auth/guards/roles.guard.spec.ts @@ -0,0 +1,49 @@ +import { ExecutionContext, ForbiddenException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import * as SYS_MSG from '../../../constants/system.messages'; +import { UserRole } from '../../users/enums/user-role.enum'; +import { RolesGuard } from './roles.guard'; + +describe('RolesGuard', () => { + const reflector = { + getAllAndOverride: jest.fn(), + } as unknown as Reflector; + + const context = { + getHandler: jest.fn(), + getClass: jest.fn(), + switchToHttp: jest.fn(), + } as unknown as ExecutionContext; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('allows access when the user has a required admin role', () => { + const guard = new RolesGuard(reflector); + const request = { user: { role: UserRole.ADMIN } }; + + (reflector.getAllAndOverride as jest.Mock).mockReturnValue([UserRole.ADMIN, UserRole.SUPER_ADMIN]); + (context.switchToHttp as jest.Mock).mockReturnValue({ getRequest: () => request }); + + expect(guard.canActivate(context)).toBe(true); + }); + + it('throws 403 when the user role does not match the required admin roles', () => { + const guard = new RolesGuard(reflector); + const request = { user: { role: UserRole.USER } }; + + (reflector.getAllAndOverride as jest.Mock).mockReturnValue([UserRole.ADMIN, UserRole.SUPER_ADMIN]); + (context.switchToHttp as jest.Mock).mockReturnValue({ getRequest: () => request }); + + expect(() => guard.canActivate(context)).toThrow(new ForbiddenException(SYS_MSG.ADMIN_ACCESS_DENIED)); + }); + + it('allows access when no roles metadata is present', () => { + const guard = new RolesGuard(reflector); + + (reflector.getAllAndOverride as jest.Mock).mockReturnValue(undefined); + + expect(guard.canActivate(context)).toBe(true); + }); +}); \ No newline at end of file diff --git a/src/modules/funnels/services/funnels.service.ts b/src/modules/funnels/services/funnels.service.ts index a03f0614..d9d34cb5 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((4001 - docs.length) / 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 = { diff --git a/src/modules/funnels/services/tests/funnels.service.spec.ts b/src/modules/funnels/services/tests/funnels.service.spec.ts index b372c3e5..b2f318d8 100644 --- a/src/modules/funnels/services/tests/funnels.service.spec.ts +++ b/src/modules/funnels/services/tests/funnels.service.spec.ts @@ -404,6 +404,89 @@ 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); + }); + + 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(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); + }); }); describe('AC-09: rollback on queue dispatch failure', () => { 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 }, 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 new file mode 100644 index 00000000..37df5b69 --- /dev/null +++ b/src/modules/onboarding/voice/controllers/voice-onboarding.controller.ts @@ -0,0 +1,113 @@ +import { + Body, + Controller, + Get, + HttpCode, + HttpStatus, + Param, + ParseFilePipeBuilder, + ParseUUIDPipe, + 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, VoiceSessionStatusResponseDto } from '../dto/voice-onboarding.dto'; +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 +const ALLOWED_MIME_TYPES = /(audio\/webm|audio\/mpeg|audio\/mp3|audio\/wav|audio\/ogg|audio\/mp4|audio\/m4a)/; + +@ApiTags('onboarding') +@ApiBearerAuth('JWT') +@Controller('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('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. + 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.voiceSessionId); + + return { + statusCode: HttpStatus.OK, + message: SYS_MSG.VOICE_SESSION_COMPLETED, + data: VoiceSessionCompleteResponseDto.from(uploadId), + }; + } + + @Get('active') + @GetActiveVoiceSessionDocs() + @HttpCode(HttpStatus.OK) + async getActiveVoiceSession(@CurrentUser('sub') userId: string) { + const sessionId = await this.voiceOnboardingService.getActiveSession(userId); + if (!sessionId) { + return { + statusCode: HttpStatus.OK, + 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) + async getVoiceSessionStatus( + @CurrentUser('sub') userId: string, + @Param('voiceSessionId', new ParseUUIDPipe()) voiceSessionId: string, + ) { + const status = await this.voiceOnboardingService.getSessionStatus(userId, voiceSessionId); + + return { + statusCode: HttpStatus.OK, + message: SYS_MSG.VOICE_STATUS_RETRIEVED, + 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 new file mode 100644 index 00000000..2fc28dc7 --- /dev/null +++ b/src/modules/onboarding/voice/docs/voice-onboarding-swagger.doc.ts @@ -0,0 +1,237 @@ +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 { 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', + 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.', + }, + voiceSessionId: { + type: 'string', + format: 'uuid', + description: 'Optional. Include to append to an existing session, omit for a new session.', + }, + }, + }, + }), + ApiOkResponse({ + 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: SYS_MSG.VOICE_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', + 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 uploadId which can be passed ' + + 'to the funnel generation endpoints.', + }), + ApiBody({ type: CompleteVoiceSessionDto }), + ApiOkResponse({ + description: 'Voice session completed successfully. Returns the generated UploadDocument ID.', + schema: { + example: { + success: true, + statusCode: HttpStatus.OK, + message: SYS_MSG.VOICE_SESSION_COMPLETED, + data: { + uploadId: '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: SYS_MSG.VOICE_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: SYS_MSG.VOICE_SESSION_EXPIRED, + }, + }, + }), + ApiUnauthorizedResponse({ + description: 'Missing or invalid JWT token.', + 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: SYS_MSG.VOICE_STATUS_RETRIEVED, + 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: SYS_MSG.VOICE_SESSION_EXPIRED, + }, + }, + }), + 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', + }, + }, + }, + }), + 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 new file mode 100644 index 00000000..e18dcedb --- /dev/null +++ b/src/modules/onboarding/voice/dto/voice-onboarding.dto.ts @@ -0,0 +1,75 @@ +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() + voiceSessionId: string; +} + +export class VoiceSessionResponseDto { + @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.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', + }) + uploadId: string; + + static from(uploadId: string): VoiceSessionCompleteResponseDto { + const dto = new VoiceSessionCompleteResponseDto(); + dto.uploadId = 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/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..628b4a03 --- /dev/null +++ b/src/modules/onboarding/voice/interfaces/voice-onboarding.interfaces.ts @@ -0,0 +1,11 @@ +export interface VoiceSessionRound { + transcript: string; +} + +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 new file mode 100644 index 00000000..1e5735fc --- /dev/null +++ b/src/modules/onboarding/voice/processors/voice-transcription.processor.ts @@ -0,0 +1,70 @@ +import { Process, Processor } from '@nestjs/bull'; +import { Inject, Logger } from '@nestjs/common'; +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'; +import { redisKeys } from '../../../../constants/redis-keys'; +import { QUEUES } from '../../../../common/constants/queue.constants'; + +@Processor(QUEUES.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); + + // 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); + + await this.redisService.rpush(sessionKey, transcript); + await this.redisService.expire(sessionKey, 1800); // 30 minutes TTL + + // Update completed count + 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); + } + + await this.redisService.setStrict(idempotencyKey, '1', 1800); + + // 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..90f1aaeb --- /dev/null +++ b/src/modules/onboarding/voice/services/voice-onboarding.service.ts @@ -0,0 +1,152 @@ +import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { InjectQueue } from '@nestjs/bull'; +import type { Queue } from 'bull'; +import { randomUUID } from 'node:crypto'; +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'; +import * as SYS_MSG from '../../../../constants/system.messages'; +import { QUEUES } from '../../../../common/constants/queue.constants'; + +@Injectable() +export class VoiceOnboardingService { + constructor( + @InjectQueue(QUEUES.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 || 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(metaKey); + if (!exists) { + throw new NotFoundException(SYS_MSG.VOICE_SESSION_EXPIRED); + } + } + + // 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, + mimeType: file.mimetype, + originalName: file.originalname, + }, + { + attempts: 3, + backoff: { + type: 'exponential', + delay: 2000, + }, + removeOnComplete: true, + removeOnFail: false, + }, + ); + + // Update meta tracking only after successful enqueue + 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; + } + + async completeSession(userId: string, sessionId: string): Promise { + const sessionKey = redisKeys.voiceSession(userId, sessionId); + const metaKey = redisKeys.voiceSessionMeta(userId, sessionId); + const exists = await this.redisService.exists(metaKey); + + if (!exists) { + throw new NotFoundException(SYS_MSG.VOICE_SESSION_EXPIRED); + } + + 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(SYS_MSG.VOICE_TRANSCRIPTION_INCOMPLETE); + } + + const transcripts = await this.redisService.lrange(sessionKey, 0, -1); + if (transcripts.length === 0) { + throw new BadRequestException(SYS_MSG.VOICE_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: DocumentSourceType.VOICE, + parsed_text: combinedTranscript, + }); + + await this.redisService.del(sessionKey); + await this.redisService.del(metaKey); + await this.redisService.del(redisKeys.activeVoiceSession(userId)); + + return document.id; + } + + 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(SYS_MSG.VOICE_SESSION_EXPIRED); + } + + const meta = await this.redisService.hgetall(metaKey); + const expectedCount = parseInt(meta.expectedCount || '0', 10); + const completedCount = parseInt(meta.completedCount || '0', 10); + + return { + expectedCount, + completedCount, + 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/services/voice-transcription.service.ts b/src/modules/onboarding/voice/services/voice-transcription.service.ts new file mode 100644 index 00000000..f98db277 --- /dev/null +++ b/src/modules/onboarding/voice/services/voice-transcription.service.ts @@ -0,0 +1,112 @@ +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', { cause: error }); + } + + 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', { cause: fallbackError }); + } + } + } + + private async transcribeWithGroq(audioBuffer: Buffer, fileName: string): Promise { + 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, + 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, + signal: AbortSignal.timeout(10000), // 10s timeout + }); + + 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 }), + signal: AbortSignal.timeout(10000), // 10s timeout + }); + + 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! }, + 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) { + 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/tests/voice-onboarding.controller.spec.ts b/src/modules/onboarding/voice/tests/voice-onboarding.controller.spec.ts new file mode 100644 index 00000000..e637ddad --- /dev/null +++ b/src/modules/onboarding/voice/tests/voice-onboarding.controller.spec.ts @@ -0,0 +1,159 @@ +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.OK, + 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, + }); + + 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 new file mode 100644 index 00000000..b3ba56fb --- /dev/null +++ b/src/modules/onboarding/voice/tests/voice-onboarding.service.spec.ts @@ -0,0 +1,269 @@ +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 { 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'), +})); + +describe('VoiceOnboardingService', () => { + let service: VoiceOnboardingService; + + const mockQueue = { + add: jest.fn(), + }; + + const mockRedisService = { + exists: jest.fn(), + get: jest.fn(), + setStrict: jest.fn(), + lpush: jest.fn(), + expire: jest.fn(), + hincrby: jest.fn(), + lrange: jest.fn(), + del: jest.fn(), + hgetall: jest.fn(), + }; + + const mockObjectStorage = { + putObject: jest.fn(), + }; + + const mockDocumentAction = { + createDocument: jest.fn(), + }; + + beforeEach(async () => { + jest.clearAllMocks(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + VoiceOnboardingService, + { provide: getQueueToken(QUEUES.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 metaKey = redisKeys.voiceSessionMeta(mockUserId, 'mocked-uuid'); + + // 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`, + 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 () => { + 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.voiceSessionMeta(mockUserId, existingSessionId)); + expect(mockRedisService.lpush).not.toHaveBeenCalled(); + + const metaKey = redisKeys.voiceSessionMeta(mockUserId, existingSessionId); + expect(mockRedisService.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(mockUserId, mockSessionId); + const metaKey = redisKeys.voiceSessionMeta(mockUserId, mockSessionId); + + it('should aggregate transcripts, create a document, and cleanup keys', async () => { + mockRedisService.exists.mockResolvedValue(true); + 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(mockRedisService.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: DocumentSourceType.VOICE, + parsed_text: 'transcript 1. transcript 2.', + }), + ); + + // 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 () => { + mockRedisService.exists.mockResolvedValue(true); + mockRedisService.hgetall.mockResolvedValue({ + expectedCount: '1', + completedCount: '1', + }); + mockRedisService.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 mockUserId = 'user-123'; + const mockSessionId = 'session-456'; + + it('should compute isReady as false if not all jobs are completed', async () => { + mockRedisService.exists.mockResolvedValue(true); + mockRedisService.hgetall.mockResolvedValue({ + expectedCount: '3', + completedCount: '2', + }); + + const result = await service.getSessionStatus(mockUserId, 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); + mockRedisService.hgetall.mockResolvedValue({ + expectedCount: '3', + completedCount: '3', + }); + + const result = await service.getSessionStatus(mockUserId, 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); + mockRedisService.hgetall.mockResolvedValue({}); // defaults to 0 + + const result = await service.getSessionStatus(mockUserId, 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(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); + }); + }); +}); 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..d28d38ba --- /dev/null +++ b/src/modules/onboarding/voice/tests/voice-transcription.service.spec.ts @@ -0,0 +1,141 @@ +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; + let originalFetch: typeof fetch; + let originalApiKey: string | undefined; + + 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 + originalFetch = global.fetch; + global.fetch = jest.fn(); + + originalApiKey = process.env.ASSEMBLYAI_API_KEY; + process.env.ASSEMBLYAI_API_KEY = 'test-key'; + }); + + afterEach(() => { + global.fetch = originalFetch; + process.env.ASSEMBLYAI_API_KEY = originalApiKey; + }); + + 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', + ); + }); + }); +}); 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..f3750bb4 --- /dev/null +++ b/src/modules/onboarding/voice/voice-onboarding.module.ts @@ -0,0 +1,27 @@ +import { BullModule } from '@nestjs/bull'; +import { Module } from '@nestjs/common'; +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, + RedisModule, + BullModule.registerQueue({ + name: QUEUES.VOICE_TRANSCRIPTION, + }), + ], + controllers: [VoiceOnboardingController], + providers: [ + VoiceOnboardingService, + VoiceTranscriptionService, + VoiceTranscriptionProcessor, + ], + exports: [VoiceOnboardingService], +}) +export class VoiceOnboardingModule {} \ No newline at end of file diff --git a/src/modules/redis/redis.service.ts b/src/modules/redis/redis.service.ts index a4b3daa2..f4baaef8 100644 --- a/src/modules/redis/redis.service.ts +++ b/src/modules/redis/redis.service.ts @@ -219,4 +219,66 @@ export class RedisService implements OnModuleInit, OnModuleDestroy { await this.client?.quit(); 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); + throw err; + } + } + + 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); + throw err; + } + } + + 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); + throw err; + } + } + + 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 5820164f..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,6 +48,13 @@ export class UploadedDocument extends BaseEntity { @Column({ type: 'varchar', length: 200, nullable: true }) failure_reason: string | null; + @Column({ + type: 'enum', + enum: DocumentSourceType, + default: DocumentSourceType.DOCUMENT, + }) + source_type: DocumentSourceType; + @ManyToOne(() => User, { onDelete: 'CASCADE' }) @JoinColumn({ name: 'user_id' }) user: User; 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 } }); } 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(); }); }); 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); 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'); + }); });