From 3722355360a80cb8956c634042e2b280545cba57 Mon Sep 17 00:00:00 2001 From: Adrian Flores Cortes Date: Tue, 3 Feb 2026 11:12:39 -0600 Subject: [PATCH] [GAP-AUTH-001, GAP-CORE-001] feat: Implement 14 missing services GAP-AUTH-001 (P0 Critical) - 13 AUTH services: - RBAC: RoleService, PermissionService, CompanyService, GroupService - Session: SessionService, DeviceService, TrustedDeviceService - Security: ApiKeyService, OAuthService, PasswordResetService, VerificationService - User: UserProfileService, MfaService GAP-CORE-001 (P2 Medium) - 1 CORE service: - DiscountRuleService with calculation, applicability, and usage tracking All services follow standalone pattern with: - Multi-tenant support via ServiceContext - Soft delete with audit trail - Pagination support - Type-safe DTOs Co-Authored-By: Claude Opus 4.5 --- src/modules/auth/services/api-key.service.ts | 261 ++++++++++ src/modules/auth/services/company.service.ts | 186 +++++++ src/modules/auth/services/device.service.ts | 222 ++++++++ src/modules/auth/services/group.service.ts | 266 ++++++++++ src/modules/auth/services/index.ts | 23 + src/modules/auth/services/mfa.service.ts | 353 +++++++++++++ src/modules/auth/services/oauth.service.ts | 304 +++++++++++ .../auth/services/password-reset.service.ts | 258 ++++++++++ .../auth/services/permission.service.ts | 208 ++++++++ src/modules/auth/services/role.service.ts | 239 +++++++++ src/modules/auth/services/session.service.ts | 225 +++++++++ .../auth/services/trusted-device.service.ts | 253 ++++++++++ .../auth/services/user-profile.service.ts | 335 +++++++++++++ .../auth/services/verification.service.ts | 317 ++++++++++++ .../core/services/discount-rule.service.ts | 474 ++++++++++++++++++ src/modules/core/services/index.ts | 11 + 16 files changed, 3935 insertions(+) create mode 100644 src/modules/auth/services/api-key.service.ts create mode 100644 src/modules/auth/services/company.service.ts create mode 100644 src/modules/auth/services/device.service.ts create mode 100644 src/modules/auth/services/group.service.ts create mode 100644 src/modules/auth/services/mfa.service.ts create mode 100644 src/modules/auth/services/oauth.service.ts create mode 100644 src/modules/auth/services/password-reset.service.ts create mode 100644 src/modules/auth/services/permission.service.ts create mode 100644 src/modules/auth/services/role.service.ts create mode 100644 src/modules/auth/services/session.service.ts create mode 100644 src/modules/auth/services/trusted-device.service.ts create mode 100644 src/modules/auth/services/user-profile.service.ts create mode 100644 src/modules/auth/services/verification.service.ts create mode 100644 src/modules/core/services/discount-rule.service.ts diff --git a/src/modules/auth/services/api-key.service.ts b/src/modules/auth/services/api-key.service.ts new file mode 100644 index 0000000..344f66d --- /dev/null +++ b/src/modules/auth/services/api-key.service.ts @@ -0,0 +1,261 @@ +import { Repository, IsNull, LessThan } from 'typeorm'; +import { randomBytes, createHash } from 'crypto'; +import { ApiKey } from '../entities'; + +export interface ServiceContext { + tenantId: string; + userId?: string; +} + +export interface CreateApiKeyDto { + name: string; + description?: string; + scopes?: string[]; + expiresAt?: Date; + ipWhitelist?: string[]; +} + +export interface UpdateApiKeyDto { + name?: string; + description?: string; + scopes?: string[]; + expiresAt?: Date; + ipWhitelist?: string[]; + isActive?: boolean; +} + +export interface ApiKeyFilters { + userId?: string; + isActive?: boolean; + isExpired?: boolean; + search?: string; +} + +export interface PaginatedResult { + data: T[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +export interface ApiKeyWithSecret { + apiKey: ApiKey; + secret: string; +} + +export class ApiKeyService { + constructor(private readonly apiKeyRepository: Repository) {} + + async create(ctx: ServiceContext, data: CreateApiKeyDto): Promise { + const secret = this.generateApiKey(); + const keyHash = this.hashKey(secret); + const keyPrefix = secret.substring(0, 8); + + const apiKey = this.apiKeyRepository.create({ + tenantId: ctx.tenantId, + userId: ctx.userId, + name: data.name, + description: data.description, + keyHash, + keyPrefix, + scopes: data.scopes || [], + expiresAt: data.expiresAt, + ipWhitelist: data.ipWhitelist || [], + isActive: true, + createdBy: ctx.userId ?? null, + }); + + const savedKey = await this.apiKeyRepository.save(apiKey); + + return { + apiKey: savedKey, + secret: secret, // Only returned once at creation + }; + } + + async findById(ctx: ServiceContext, id: string): Promise { + return this.apiKeyRepository.findOne({ + where: { id, tenantId: ctx.tenantId, deletedAt: IsNull() }, + relations: ['user'], + }); + } + + async findByPrefix(ctx: ServiceContext, prefix: string): Promise { + return this.apiKeyRepository.findOne({ + where: { tenantId: ctx.tenantId, keyPrefix: prefix, deletedAt: IsNull() }, + relations: ['user'], + }); + } + + async findByUserId(ctx: ServiceContext, userId: string): Promise { + return this.apiKeyRepository.find({ + where: { tenantId: ctx.tenantId, userId, deletedAt: IsNull() }, + order: { createdAt: 'DESC' }, + }); + } + + async findWithFilters( + ctx: ServiceContext, + filters: ApiKeyFilters, + page = 1, + limit = 50, + ): Promise> { + const qb = this.apiKeyRepository + .createQueryBuilder('apikey') + .leftJoinAndSelect('apikey.user', 'user') + .where('apikey.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('apikey.deleted_at IS NULL'); + + if (filters.userId) { + qb.andWhere('apikey.user_id = :userId', { userId: filters.userId }); + } + + if (filters.isActive !== undefined) { + qb.andWhere('apikey.is_active = :isActive', { isActive: filters.isActive }); + } + + if (filters.isExpired !== undefined) { + if (filters.isExpired) { + qb.andWhere('apikey.expires_at < :now', { now: new Date() }); + } else { + qb.andWhere('(apikey.expires_at IS NULL OR apikey.expires_at >= :now)', { now: new Date() }); + } + } + + if (filters.search) { + qb.andWhere('(apikey.name ILIKE :search OR apikey.description ILIKE :search)', { + search: `%${filters.search}%`, + }); + } + + const [data, total] = await qb + .orderBy('apikey.created_at', 'DESC') + .skip((page - 1) * limit) + .take(limit) + .getManyAndCount(); + + return { + data, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + async update(ctx: ServiceContext, id: string, data: UpdateApiKeyDto): Promise { + const apiKey = await this.findById(ctx, id); + if (!apiKey) return null; + + Object.assign(apiKey, { + ...data, + updatedBy: ctx.userId ?? null, + }); + + return this.apiKeyRepository.save(apiKey); + } + + async revoke(ctx: ServiceContext, id: string): Promise { + const apiKey = await this.findById(ctx, id); + if (!apiKey) return false; + + apiKey.isActive = false; + apiKey.revokedAt = new Date(); + apiKey.updatedBy = ctx.userId ?? null; + await this.apiKeyRepository.save(apiKey); + + return true; + } + + async delete(ctx: ServiceContext, id: string): Promise { + const apiKey = await this.findById(ctx, id); + if (!apiKey) return false; + + apiKey.deletedAt = new Date(); + apiKey.deletedBy = ctx.userId ?? null; + await this.apiKeyRepository.save(apiKey); + + return true; + } + + async validateKey(ctx: ServiceContext, secret: string): Promise { + const prefix = secret.substring(0, 8); + const apiKey = await this.findByPrefix(ctx, prefix); + + if (!apiKey) return null; + if (!apiKey.isActive) return null; + if (apiKey.expiresAt && apiKey.expiresAt < new Date()) return null; + + const hash = this.hashKey(secret); + if (hash !== apiKey.keyHash) return null; + + // Update last used + apiKey.lastUsedAt = new Date(); + await this.apiKeyRepository.save(apiKey); + + return apiKey; + } + + async validateKeyWithIp(ctx: ServiceContext, secret: string, ipAddress: string): Promise { + const apiKey = await this.validateKey(ctx, secret); + if (!apiKey) return null; + + if (apiKey.ipWhitelist && apiKey.ipWhitelist.length > 0) { + if (!apiKey.ipWhitelist.includes(ipAddress)) { + return null; + } + } + + return apiKey; + } + + async hasScope(ctx: ServiceContext, secret: string, requiredScope: string): Promise { + const apiKey = await this.validateKey(ctx, secret); + if (!apiKey) return false; + + if (!apiKey.scopes || apiKey.scopes.length === 0) return true; // No scopes = full access + return apiKey.scopes.includes(requiredScope) || apiKey.scopes.includes('*'); + } + + async rotate(ctx: ServiceContext, id: string): Promise { + const oldKey = await this.findById(ctx, id); + if (!oldKey) return null; + + // Revoke old key + await this.revoke(ctx, id); + + // Create new key with same settings + return this.create(ctx, { + name: oldKey.name, + description: oldKey.description, + scopes: oldKey.scopes, + expiresAt: oldKey.expiresAt, + ipWhitelist: oldKey.ipWhitelist, + }); + } + + async cleanupExpired(ctx: ServiceContext): Promise { + const result = await this.apiKeyRepository.update( + { + tenantId: ctx.tenantId, + isActive: true, + expiresAt: LessThan(new Date()), + }, + { + isActive: false, + revokedAt: new Date(), + }, + ); + + return result.affected ?? 0; + } + + private generateApiKey(): string { + return `sk_${randomBytes(32).toString('hex')}`; + } + + private hashKey(key: string): string { + return createHash('sha256').update(key).digest('hex'); + } +} diff --git a/src/modules/auth/services/company.service.ts b/src/modules/auth/services/company.service.ts new file mode 100644 index 0000000..3282642 --- /dev/null +++ b/src/modules/auth/services/company.service.ts @@ -0,0 +1,186 @@ +import { Repository, IsNull } from 'typeorm'; +import { Company } from '../entities'; + +export interface ServiceContext { + tenantId: string; + userId?: string; +} + +export interface CreateCompanyDto { + code: string; + name: string; + legalName?: string; + taxId?: string; + email?: string; + phone?: string; + address?: string; + city?: string; + state?: string; + country?: string; + postalCode?: string; + logo?: string; + isActive?: boolean; +} + +export interface UpdateCompanyDto { + code?: string; + name?: string; + legalName?: string; + taxId?: string; + email?: string; + phone?: string; + address?: string; + city?: string; + state?: string; + country?: string; + postalCode?: string; + logo?: string; + isActive?: boolean; +} + +export interface CompanyFilters { + code?: string; + isActive?: boolean; + search?: string; +} + +export interface PaginatedResult { + data: T[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +export class CompanyService { + constructor(private readonly companyRepository: Repository) {} + + async create(ctx: ServiceContext, data: CreateCompanyDto): Promise { + const existing = await this.companyRepository.findOne({ + where: { tenantId: ctx.tenantId, code: data.code, deletedAt: IsNull() }, + }); + + if (existing) { + throw new Error(`Company with code '${data.code}' already exists`); + } + + const company = this.companyRepository.create({ + tenantId: ctx.tenantId, + code: data.code, + name: data.name, + legalName: data.legalName, + taxId: data.taxId, + email: data.email, + phone: data.phone, + address: data.address, + city: data.city, + state: data.state, + country: data.country, + postalCode: data.postalCode, + logo: data.logo, + isActive: data.isActive ?? true, + createdBy: ctx.userId ?? null, + }); + + return this.companyRepository.save(company); + } + + async findById(ctx: ServiceContext, id: string): Promise { + return this.companyRepository.findOne({ + where: { id, tenantId: ctx.tenantId, deletedAt: IsNull() }, + }); + } + + async findByCode(ctx: ServiceContext, code: string): Promise { + return this.companyRepository.findOne({ + where: { tenantId: ctx.tenantId, code, deletedAt: IsNull() }, + }); + } + + async findAll(ctx: ServiceContext): Promise { + return this.companyRepository.find({ + where: { tenantId: ctx.tenantId, deletedAt: IsNull() }, + order: { name: 'ASC' }, + }); + } + + async findWithFilters( + ctx: ServiceContext, + filters: CompanyFilters, + page = 1, + limit = 50, + ): Promise> { + const qb = this.companyRepository + .createQueryBuilder('company') + .where('company.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('company.deleted_at IS NULL'); + + if (filters.code) { + qb.andWhere('company.code = :code', { code: filters.code }); + } + + if (filters.isActive !== undefined) { + qb.andWhere('company.is_active = :isActive', { isActive: filters.isActive }); + } + + if (filters.search) { + qb.andWhere( + '(company.name ILIKE :search OR company.code ILIKE :search OR company.legal_name ILIKE :search)', + { search: `%${filters.search}%` }, + ); + } + + const [data, total] = await qb + .orderBy('company.name', 'ASC') + .skip((page - 1) * limit) + .take(limit) + .getManyAndCount(); + + return { + data, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + async update(ctx: ServiceContext, id: string, data: UpdateCompanyDto): Promise { + const company = await this.findById(ctx, id); + if (!company) return null; + + if (data.code && data.code !== company.code) { + const existing = await this.companyRepository.findOne({ + where: { tenantId: ctx.tenantId, code: data.code, deletedAt: IsNull() }, + }); + if (existing) { + throw new Error(`Company with code '${data.code}' already exists`); + } + } + + Object.assign(company, { + ...data, + updatedBy: ctx.userId ?? null, + }); + + return this.companyRepository.save(company); + } + + async delete(ctx: ServiceContext, id: string): Promise { + const company = await this.findById(ctx, id); + if (!company) return false; + + company.deletedAt = new Date(); + company.deletedBy = ctx.userId ?? null; + await this.companyRepository.save(company); + + return true; + } + + async getActiveCompanies(ctx: ServiceContext): Promise { + return this.companyRepository.find({ + where: { tenantId: ctx.tenantId, isActive: true, deletedAt: IsNull() }, + order: { name: 'ASC' }, + }); + } +} diff --git a/src/modules/auth/services/device.service.ts b/src/modules/auth/services/device.service.ts new file mode 100644 index 0000000..4ecb847 --- /dev/null +++ b/src/modules/auth/services/device.service.ts @@ -0,0 +1,222 @@ +import { Repository, IsNull } from 'typeorm'; +import { Device } from '../entities'; + +export interface ServiceContext { + tenantId: string; + userId?: string; +} + +export interface CreateDeviceDto { + userId: string; + fingerprint: string; + name?: string; + deviceType?: string; + browser?: string; + os?: string; + ipAddress?: string; + userAgent?: string; +} + +export interface UpdateDeviceDto { + name?: string; + ipAddress?: string; + userAgent?: string; + isBlocked?: boolean; +} + +export interface DeviceFilters { + userId?: string; + deviceType?: string; + isBlocked?: boolean; + search?: string; +} + +export interface PaginatedResult { + data: T[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +export class DeviceService { + constructor(private readonly deviceRepository: Repository) {} + + async create(ctx: ServiceContext, data: CreateDeviceDto): Promise { + const existing = await this.findByFingerprint(ctx, data.userId, data.fingerprint); + if (existing) { + return this.updateLastSeen(ctx, existing.id) as Promise; + } + + const device = this.deviceRepository.create({ + tenantId: ctx.tenantId, + userId: data.userId, + fingerprint: data.fingerprint, + name: data.name || this.generateDeviceName(data), + deviceType: data.deviceType, + browser: data.browser, + os: data.os, + ipAddress: data.ipAddress, + userAgent: data.userAgent, + lastSeenAt: new Date(), + createdBy: ctx.userId ?? null, + }); + + return this.deviceRepository.save(device); + } + + async findById(ctx: ServiceContext, id: string): Promise { + return this.deviceRepository.findOne({ + where: { id, tenantId: ctx.tenantId, deletedAt: IsNull() }, + relations: ['user'], + }); + } + + async findByFingerprint(ctx: ServiceContext, userId: string, fingerprint: string): Promise { + return this.deviceRepository.findOne({ + where: { + tenantId: ctx.tenantId, + userId, + fingerprint, + deletedAt: IsNull(), + }, + }); + } + + async findByUserId(ctx: ServiceContext, userId: string): Promise { + return this.deviceRepository.find({ + where: { tenantId: ctx.tenantId, userId, deletedAt: IsNull() }, + order: { lastSeenAt: 'DESC' }, + }); + } + + async findWithFilters( + ctx: ServiceContext, + filters: DeviceFilters, + page = 1, + limit = 50, + ): Promise> { + const qb = this.deviceRepository + .createQueryBuilder('device') + .leftJoinAndSelect('device.user', 'user') + .where('device.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('device.deleted_at IS NULL'); + + if (filters.userId) { + qb.andWhere('device.user_id = :userId', { userId: filters.userId }); + } + + if (filters.deviceType) { + qb.andWhere('device.device_type = :deviceType', { deviceType: filters.deviceType }); + } + + if (filters.isBlocked !== undefined) { + qb.andWhere('device.is_blocked = :isBlocked', { isBlocked: filters.isBlocked }); + } + + if (filters.search) { + qb.andWhere('(device.name ILIKE :search OR device.fingerprint ILIKE :search)', { + search: `%${filters.search}%`, + }); + } + + const [data, total] = await qb + .orderBy('device.last_seen_at', 'DESC') + .skip((page - 1) * limit) + .take(limit) + .getManyAndCount(); + + return { + data, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + async update(ctx: ServiceContext, id: string, data: UpdateDeviceDto): Promise { + const device = await this.findById(ctx, id); + if (!device) return null; + + Object.assign(device, { + ...data, + updatedBy: ctx.userId ?? null, + }); + + return this.deviceRepository.save(device); + } + + async updateLastSeen(ctx: ServiceContext, id: string): Promise { + const device = await this.findById(ctx, id); + if (!device) return null; + + device.lastSeenAt = new Date(); + return this.deviceRepository.save(device); + } + + async block(ctx: ServiceContext, id: string): Promise { + return this.update(ctx, id, { isBlocked: true }); + } + + async unblock(ctx: ServiceContext, id: string): Promise { + return this.update(ctx, id, { isBlocked: false }); + } + + async delete(ctx: ServiceContext, id: string): Promise { + const device = await this.findById(ctx, id); + if (!device) return false; + + device.deletedAt = new Date(); + device.deletedBy = ctx.userId ?? null; + await this.deviceRepository.save(device); + + return true; + } + + async deleteAllUserDevices(ctx: ServiceContext, userId: string): Promise { + const devices = await this.findByUserId(ctx, userId); + let count = 0; + + for (const device of devices) { + device.deletedAt = new Date(); + device.deletedBy = ctx.userId ?? null; + await this.deviceRepository.save(device); + count++; + } + + return count; + } + + async isDeviceBlocked(ctx: ServiceContext, userId: string, fingerprint: string): Promise { + const device = await this.findByFingerprint(ctx, userId, fingerprint); + return device?.isBlocked ?? false; + } + + async getDeviceCount(ctx: ServiceContext, userId: string): Promise { + return this.deviceRepository.count({ + where: { tenantId: ctx.tenantId, userId, deletedAt: IsNull() }, + }); + } + + async getOrCreate(ctx: ServiceContext, data: CreateDeviceDto): Promise { + const existing = await this.findByFingerprint(ctx, data.userId, data.fingerprint); + if (existing) { + existing.lastSeenAt = new Date(); + existing.ipAddress = data.ipAddress || existing.ipAddress; + existing.userAgent = data.userAgent || existing.userAgent; + return this.deviceRepository.save(existing); + } + + return this.create(ctx, data); + } + + private generateDeviceName(data: CreateDeviceDto): string { + const parts: string[] = []; + if (data.browser) parts.push(data.browser); + if (data.os) parts.push(`on ${data.os}`); + if (data.deviceType) parts.push(`(${data.deviceType})`); + + return parts.length > 0 ? parts.join(' ') : 'Unknown Device'; + } +} diff --git a/src/modules/auth/services/group.service.ts b/src/modules/auth/services/group.service.ts new file mode 100644 index 0000000..1a2522c --- /dev/null +++ b/src/modules/auth/services/group.service.ts @@ -0,0 +1,266 @@ +import { Repository, IsNull, In } from 'typeorm'; +import { Group, User } from '../entities'; + +export interface ServiceContext { + tenantId: string; + userId?: string; +} + +export interface CreateGroupDto { + code: string; + name: string; + description?: string; + parentId?: string; + memberIds?: string[]; + isActive?: boolean; +} + +export interface UpdateGroupDto { + code?: string; + name?: string; + description?: string; + parentId?: string; + memberIds?: string[]; + isActive?: boolean; +} + +export interface GroupFilters { + code?: string; + parentId?: string; + isActive?: boolean; + search?: string; +} + +export interface PaginatedResult { + data: T[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +export class GroupService { + constructor( + private readonly groupRepository: Repository, + private readonly userRepository: Repository, + ) {} + + async create(ctx: ServiceContext, data: CreateGroupDto): Promise { + const existing = await this.groupRepository.findOne({ + where: { tenantId: ctx.tenantId, code: data.code, deletedAt: IsNull() }, + }); + + if (existing) { + throw new Error(`Group with code '${data.code}' already exists`); + } + + if (data.parentId) { + const parent = await this.findById(ctx, data.parentId); + if (!parent) { + throw new Error('Parent group not found'); + } + } + + const group = this.groupRepository.create({ + tenantId: ctx.tenantId, + code: data.code, + name: data.name, + description: data.description, + parentId: data.parentId, + isActive: data.isActive ?? true, + createdBy: ctx.userId ?? null, + }); + + const savedGroup = await this.groupRepository.save(group); + + if (data.memberIds?.length) { + await this.addMembers(ctx, savedGroup.id, data.memberIds); + } + + return this.findById(ctx, savedGroup.id) as Promise; + } + + async findById(ctx: ServiceContext, id: string): Promise { + return this.groupRepository.findOne({ + where: { id, tenantId: ctx.tenantId, deletedAt: IsNull() }, + relations: ['members', 'parent', 'children'], + }); + } + + async findByCode(ctx: ServiceContext, code: string): Promise { + return this.groupRepository.findOne({ + where: { tenantId: ctx.tenantId, code, deletedAt: IsNull() }, + relations: ['members', 'parent', 'children'], + }); + } + + async findAll(ctx: ServiceContext): Promise { + return this.groupRepository.find({ + where: { tenantId: ctx.tenantId, deletedAt: IsNull() }, + relations: ['members', 'parent'], + order: { name: 'ASC' }, + }); + } + + async findRootGroups(ctx: ServiceContext): Promise { + return this.groupRepository.find({ + where: { tenantId: ctx.tenantId, parentId: IsNull(), deletedAt: IsNull() }, + relations: ['children'], + order: { name: 'ASC' }, + }); + } + + async findWithFilters( + ctx: ServiceContext, + filters: GroupFilters, + page = 1, + limit = 50, + ): Promise> { + const qb = this.groupRepository + .createQueryBuilder('grp') + .leftJoinAndSelect('grp.members', 'members') + .leftJoinAndSelect('grp.parent', 'parent') + .where('grp.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('grp.deleted_at IS NULL'); + + if (filters.code) { + qb.andWhere('grp.code = :code', { code: filters.code }); + } + + if (filters.parentId) { + qb.andWhere('grp.parent_id = :parentId', { parentId: filters.parentId }); + } + + if (filters.isActive !== undefined) { + qb.andWhere('grp.is_active = :isActive', { isActive: filters.isActive }); + } + + if (filters.search) { + qb.andWhere('(grp.name ILIKE :search OR grp.code ILIKE :search)', { + search: `%${filters.search}%`, + }); + } + + const [data, total] = await qb + .orderBy('grp.name', 'ASC') + .skip((page - 1) * limit) + .take(limit) + .getManyAndCount(); + + return { + data, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + async update(ctx: ServiceContext, id: string, data: UpdateGroupDto): Promise { + const group = await this.findById(ctx, id); + if (!group) return null; + + if (data.code && data.code !== group.code) { + const existing = await this.groupRepository.findOne({ + where: { tenantId: ctx.tenantId, code: data.code, deletedAt: IsNull() }, + }); + if (existing) { + throw new Error(`Group with code '${data.code}' already exists`); + } + } + + if (data.parentId) { + if (data.parentId === id) { + throw new Error('A group cannot be its own parent'); + } + const parent = await this.findById(ctx, data.parentId); + if (!parent) { + throw new Error('Parent group not found'); + } + } + + Object.assign(group, { + code: data.code ?? group.code, + name: data.name ?? group.name, + description: data.description ?? group.description, + parentId: data.parentId !== undefined ? data.parentId : group.parentId, + isActive: data.isActive ?? group.isActive, + updatedBy: ctx.userId ?? null, + }); + + await this.groupRepository.save(group); + + if (data.memberIds !== undefined) { + await this.syncMembers(ctx, id, data.memberIds); + } + + return this.findById(ctx, id); + } + + async delete(ctx: ServiceContext, id: string): Promise { + const group = await this.findById(ctx, id); + if (!group) return false; + + group.deletedAt = new Date(); + group.deletedBy = ctx.userId ?? null; + await this.groupRepository.save(group); + + return true; + } + + async addMembers(ctx: ServiceContext, groupId: string, userIds: string[]): Promise { + const group = await this.findById(ctx, groupId); + if (!group) throw new Error('Group not found'); + + const users = await this.userRepository.find({ + where: { id: In(userIds), tenantId: ctx.tenantId, deletedAt: IsNull() }, + }); + + group.members = [...(group.members || []), ...users]; + await this.groupRepository.save(group); + } + + async removeMembers(ctx: ServiceContext, groupId: string, userIds: string[]): Promise { + const group = await this.findById(ctx, groupId); + if (!group) throw new Error('Group not found'); + + group.members = (group.members || []).filter(m => !userIds.includes(m.id)); + await this.groupRepository.save(group); + } + + async syncMembers(ctx: ServiceContext, groupId: string, userIds: string[]): Promise { + const group = await this.findById(ctx, groupId); + if (!group) throw new Error('Group not found'); + + const users = await this.userRepository.find({ + where: { id: In(userIds), tenantId: ctx.tenantId, deletedAt: IsNull() }, + }); + + group.members = users; + await this.groupRepository.save(group); + } + + async getGroupHierarchy(ctx: ServiceContext, groupId: string): Promise { + const hierarchy: Group[] = []; + let currentId: string | undefined = groupId; + + while (currentId) { + const group = await this.findById(ctx, currentId); + if (!group) break; + hierarchy.unshift(group); + currentId = group.parentId ?? undefined; + } + + return hierarchy; + } + + async getUserGroups(ctx: ServiceContext, userId: string): Promise { + return this.groupRepository + .createQueryBuilder('grp') + .innerJoin('grp.members', 'member') + .where('grp.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('grp.deleted_at IS NULL') + .andWhere('member.id = :userId', { userId }) + .getMany(); + } +} diff --git a/src/modules/auth/services/index.ts b/src/modules/auth/services/index.ts index 7255de3..2299d6c 100644 --- a/src/modules/auth/services/index.ts +++ b/src/modules/auth/services/index.ts @@ -1,5 +1,28 @@ /** * Auth Module - Service Exports + * Updated: 2026-02-03 - GAP-AUTH-001 remediation */ +// Core Authentication export * from './auth.service'; + +// RBAC Services +export * from './role.service'; +export * from './permission.service'; +export * from './company.service'; +export * from './group.service'; + +// Session & Device Management +export * from './session.service'; +export * from './device.service'; +export * from './trusted-device.service'; + +// Security & Authentication +export * from './api-key.service'; +export * from './oauth.service'; +export * from './password-reset.service'; +export * from './verification.service'; + +// User Management +export * from './user-profile.service'; +export * from './mfa.service'; diff --git a/src/modules/auth/services/mfa.service.ts b/src/modules/auth/services/mfa.service.ts new file mode 100644 index 0000000..48829c6 --- /dev/null +++ b/src/modules/auth/services/mfa.service.ts @@ -0,0 +1,353 @@ +import { Repository, IsNull } from 'typeorm'; +import { randomBytes, createHmac } from 'crypto'; +import { MfaAuditLog, MfaEventType, User } from '../entities'; + +export interface ServiceContext { + tenantId: string; + userId?: string; +} + +export interface MfaSetupResult { + secret: string; + qrCodeUrl: string; + backupCodes: string[]; +} + +export interface MfaVerifyResult { + success: boolean; + error?: string; + remainingAttempts?: number; +} + +export interface MfaFilters { + userId?: string; + eventType?: MfaEventType; + success?: boolean; + startDate?: Date; + endDate?: Date; +} + +export interface PaginatedResult { + data: T[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +export class MfaService { + private readonly issuer = 'ERP-Construccion'; + private readonly backupCodeCount = 10; + + constructor( + private readonly auditLogRepository: Repository, + private readonly userRepository: Repository, + ) {} + + async setupMfa(ctx: ServiceContext, userId: string): Promise { + const user = await this.userRepository.findOne({ + where: { id: userId, tenantId: ctx.tenantId, deletedAt: IsNull() }, + }); + + if (!user) { + throw new Error('User not found'); + } + + const secret = this.generateSecret(); + const backupCodes = this.generateBackupCodes(); + const qrCodeUrl = this.generateQrCodeUrl(user.email, secret); + + // Store secret and backup codes (hashed) on user + user.mfaSecret = secret; + user.mfaBackupCodes = backupCodes.map(code => this.hashCode(code)); + user.mfaEnabled = false; // Not enabled until verified + await this.userRepository.save(user); + + await this.logEvent(ctx, userId, MfaEventType.SETUP_INITIATED, true); + + return { + secret, + qrCodeUrl, + backupCodes, + }; + } + + async enableMfa(ctx: ServiceContext, userId: string, token: string): Promise { + const user = await this.userRepository.findOne({ + where: { id: userId, tenantId: ctx.tenantId, deletedAt: IsNull() }, + }); + + if (!user || !user.mfaSecret) { + throw new Error('MFA not set up for this user'); + } + + const isValid = this.verifyTotp(user.mfaSecret, token); + if (!isValid) { + await this.logEvent(ctx, userId, MfaEventType.SETUP_FAILED, false, 'Invalid token'); + return false; + } + + user.mfaEnabled = true; + user.mfaEnabledAt = new Date(); + await this.userRepository.save(user); + + await this.logEvent(ctx, userId, MfaEventType.SETUP_COMPLETED, true); + + return true; + } + + async disableMfa(ctx: ServiceContext, userId: string): Promise { + const user = await this.userRepository.findOne({ + where: { id: userId, tenantId: ctx.tenantId, deletedAt: IsNull() }, + }); + + if (!user) { + throw new Error('User not found'); + } + + user.mfaEnabled = false; + user.mfaSecret = null; + user.mfaBackupCodes = null; + user.mfaEnabledAt = null; + await this.userRepository.save(user); + + await this.logEvent(ctx, userId, MfaEventType.DISABLED, true); + + return true; + } + + async verifyMfa(ctx: ServiceContext, userId: string, token: string): Promise { + const user = await this.userRepository.findOne({ + where: { id: userId, tenantId: ctx.tenantId, deletedAt: IsNull() }, + }); + + if (!user || !user.mfaEnabled || !user.mfaSecret) { + return { success: false, error: 'MFA not enabled' }; + } + + // Check if it's a TOTP code + if (this.verifyTotp(user.mfaSecret, token)) { + await this.logEvent(ctx, userId, MfaEventType.VERIFIED, true); + return { success: true }; + } + + // Check if it's a backup code + if (await this.verifyBackupCode(ctx, userId, token)) { + await this.logEvent(ctx, userId, MfaEventType.BACKUP_CODE_USED, true); + return { success: true }; + } + + await this.logEvent(ctx, userId, MfaEventType.VERIFICATION_FAILED, false, 'Invalid token'); + return { success: false, error: 'Invalid MFA token' }; + } + + async verifyBackupCode(ctx: ServiceContext, userId: string, code: string): Promise { + const user = await this.userRepository.findOne({ + where: { id: userId, tenantId: ctx.tenantId, deletedAt: IsNull() }, + }); + + if (!user || !user.mfaBackupCodes) { + return false; + } + + const hashedCode = this.hashCode(code); + const codeIndex = user.mfaBackupCodes.indexOf(hashedCode); + + if (codeIndex === -1) { + return false; + } + + // Remove used backup code + user.mfaBackupCodes.splice(codeIndex, 1); + await this.userRepository.save(user); + + return true; + } + + async regenerateBackupCodes(ctx: ServiceContext, userId: string): Promise { + const user = await this.userRepository.findOne({ + where: { id: userId, tenantId: ctx.tenantId, deletedAt: IsNull() }, + }); + + if (!user || !user.mfaEnabled) { + throw new Error('MFA not enabled'); + } + + const newCodes = this.generateBackupCodes(); + user.mfaBackupCodes = newCodes.map(code => this.hashCode(code)); + await this.userRepository.save(user); + + await this.logEvent(ctx, userId, MfaEventType.BACKUP_CODES_REGENERATED, true); + + return newCodes; + } + + async getRemainingBackupCodes(ctx: ServiceContext, userId: string): Promise { + const user = await this.userRepository.findOne({ + where: { id: userId, tenantId: ctx.tenantId, deletedAt: IsNull() }, + }); + + return user?.mfaBackupCodes?.length ?? 0; + } + + async isMfaEnabled(ctx: ServiceContext, userId: string): Promise { + const user = await this.userRepository.findOne({ + where: { id: userId, tenantId: ctx.tenantId, deletedAt: IsNull() }, + }); + + return user?.mfaEnabled ?? false; + } + + async getAuditLogs( + ctx: ServiceContext, + filters: MfaFilters, + page = 1, + limit = 50, + ): Promise> { + const qb = this.auditLogRepository + .createQueryBuilder('log') + .leftJoinAndSelect('log.user', 'user') + .where('log.tenant_id = :tenantId', { tenantId: ctx.tenantId }); + + if (filters.userId) { + qb.andWhere('log.user_id = :userId', { userId: filters.userId }); + } + + if (filters.eventType) { + qb.andWhere('log.event_type = :eventType', { eventType: filters.eventType }); + } + + if (filters.success !== undefined) { + qb.andWhere('log.success = :success', { success: filters.success }); + } + + if (filters.startDate) { + qb.andWhere('log.created_at >= :startDate', { startDate: filters.startDate }); + } + + if (filters.endDate) { + qb.andWhere('log.created_at <= :endDate', { endDate: filters.endDate }); + } + + const [data, total] = await qb + .orderBy('log.created_at', 'DESC') + .skip((page - 1) * limit) + .take(limit) + .getManyAndCount(); + + return { + data, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + async getUserMfaStatus(ctx: ServiceContext, userId: string): Promise<{ + enabled: boolean; + enabledAt: Date | null; + backupCodesRemaining: number; + lastVerification: Date | null; + }> { + const user = await this.userRepository.findOne({ + where: { id: userId, tenantId: ctx.tenantId, deletedAt: IsNull() }, + }); + + if (!user) { + throw new Error('User not found'); + } + + const lastVerification = await this.auditLogRepository.findOne({ + where: { + tenantId: ctx.tenantId, + userId, + eventType: MfaEventType.VERIFIED, + success: true, + }, + order: { createdAt: 'DESC' }, + }); + + return { + enabled: user.mfaEnabled ?? false, + enabledAt: user.mfaEnabledAt ?? null, + backupCodesRemaining: user.mfaBackupCodes?.length ?? 0, + lastVerification: lastVerification?.createdAt ?? null, + }; + } + + private async logEvent( + ctx: ServiceContext, + userId: string, + eventType: MfaEventType, + success: boolean, + details?: string, + ): Promise { + const log = this.auditLogRepository.create({ + tenantId: ctx.tenantId, + userId, + eventType, + success, + details, + createdBy: ctx.userId ?? null, + }); + + await this.auditLogRepository.save(log); + } + + private generateSecret(): string { + return randomBytes(20).toString('base64').replace(/[^a-zA-Z2-7]/g, '').substring(0, 32); + } + + private generateBackupCodes(): string[] { + const codes: string[] = []; + for (let i = 0; i < this.backupCodeCount; i++) { + const code = randomBytes(4).toString('hex').toUpperCase(); + codes.push(`${code.substring(0, 4)}-${code.substring(4)}`); + } + return codes; + } + + private hashCode(code: string): string { + return createHmac('sha256', 'backup-code-salt').update(code).digest('hex'); + } + + private generateQrCodeUrl(email: string, secret: string): string { + const encodedIssuer = encodeURIComponent(this.issuer); + const encodedEmail = encodeURIComponent(email); + return `otpauth://totp/${encodedIssuer}:${encodedEmail}?secret=${secret}&issuer=${encodedIssuer}`; + } + + private verifyTotp(secret: string, token: string): boolean { + // Simplified TOTP verification + // In production, use a library like speakeasy or otplib + const currentTime = Math.floor(Date.now() / 1000 / 30); + + for (let i = -1; i <= 1; i++) { + const expectedToken = this.generateTotp(secret, currentTime + i); + if (expectedToken === token) { + return true; + } + } + + return false; + } + + private generateTotp(secret: string, counter: number): string { + const buffer = Buffer.alloc(8); + buffer.writeBigInt64BE(BigInt(counter)); + + const hmac = createHmac('sha1', Buffer.from(secret, 'base64')); + hmac.update(buffer); + const hash = hmac.digest(); + + const offset = hash[hash.length - 1] & 0xf; + const binary = ((hash[offset] & 0x7f) << 24) | + ((hash[offset + 1] & 0xff) << 16) | + ((hash[offset + 2] & 0xff) << 8) | + (hash[offset + 3] & 0xff); + + const otp = binary % 1000000; + return otp.toString().padStart(6, '0'); + } +} diff --git a/src/modules/auth/services/oauth.service.ts b/src/modules/auth/services/oauth.service.ts new file mode 100644 index 0000000..189422b --- /dev/null +++ b/src/modules/auth/services/oauth.service.ts @@ -0,0 +1,304 @@ +import { Repository, IsNull } from 'typeorm'; +import { randomBytes } from 'crypto'; +import { OAuthProvider, OAuthState, OAuthUserLink, User } from '../entities'; + +export interface ServiceContext { + tenantId: string; + userId?: string; +} + +export interface CreateOAuthProviderDto { + name: string; + provider: string; + clientId: string; + clientSecret: string; + authorizationUrl?: string; + tokenUrl?: string; + userInfoUrl?: string; + scopes?: string[]; + isActive?: boolean; +} + +export interface UpdateOAuthProviderDto { + name?: string; + clientId?: string; + clientSecret?: string; + authorizationUrl?: string; + tokenUrl?: string; + userInfoUrl?: string; + scopes?: string[]; + isActive?: boolean; +} + +export interface OAuthProviderFilters { + provider?: string; + isActive?: boolean; + search?: string; +} + +export interface PaginatedResult { + data: T[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +export interface OAuthStateData { + redirectUri?: string; + returnUrl?: string; + metadata?: Record; +} + +export class OAuthService { + constructor( + private readonly providerRepository: Repository, + private readonly stateRepository: Repository, + private readonly userLinkRepository: Repository, + private readonly userRepository: Repository, + ) {} + + // Provider Management + async createProvider(ctx: ServiceContext, data: CreateOAuthProviderDto): Promise { + const existing = await this.providerRepository.findOne({ + where: { tenantId: ctx.tenantId, provider: data.provider, deletedAt: IsNull() }, + }); + + if (existing) { + throw new Error(`OAuth provider '${data.provider}' already configured`); + } + + const provider = this.providerRepository.create({ + tenantId: ctx.tenantId, + name: data.name, + provider: data.provider, + clientId: data.clientId, + clientSecret: data.clientSecret, + authorizationUrl: data.authorizationUrl, + tokenUrl: data.tokenUrl, + userInfoUrl: data.userInfoUrl, + scopes: data.scopes || [], + isActive: data.isActive ?? true, + createdBy: ctx.userId ?? null, + }); + + return this.providerRepository.save(provider); + } + + async findProviderById(ctx: ServiceContext, id: string): Promise { + return this.providerRepository.findOne({ + where: { id, tenantId: ctx.tenantId, deletedAt: IsNull() }, + }); + } + + async findProviderByName(ctx: ServiceContext, provider: string): Promise { + return this.providerRepository.findOne({ + where: { tenantId: ctx.tenantId, provider, deletedAt: IsNull() }, + }); + } + + async findAllProviders(ctx: ServiceContext): Promise { + return this.providerRepository.find({ + where: { tenantId: ctx.tenantId, deletedAt: IsNull() }, + order: { name: 'ASC' }, + }); + } + + async findActiveProviders(ctx: ServiceContext): Promise { + return this.providerRepository.find({ + where: { tenantId: ctx.tenantId, isActive: true, deletedAt: IsNull() }, + order: { name: 'ASC' }, + }); + } + + async findProvidersWithFilters( + ctx: ServiceContext, + filters: OAuthProviderFilters, + page = 1, + limit = 50, + ): Promise> { + const qb = this.providerRepository + .createQueryBuilder('provider') + .where('provider.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('provider.deleted_at IS NULL'); + + if (filters.provider) { + qb.andWhere('provider.provider = :provider', { provider: filters.provider }); + } + + if (filters.isActive !== undefined) { + qb.andWhere('provider.is_active = :isActive', { isActive: filters.isActive }); + } + + if (filters.search) { + qb.andWhere('(provider.name ILIKE :search OR provider.provider ILIKE :search)', { + search: `%${filters.search}%`, + }); + } + + const [data, total] = await qb + .orderBy('provider.name', 'ASC') + .skip((page - 1) * limit) + .take(limit) + .getManyAndCount(); + + return { + data, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + async updateProvider(ctx: ServiceContext, id: string, data: UpdateOAuthProviderDto): Promise { + const provider = await this.findProviderById(ctx, id); + if (!provider) return null; + + Object.assign(provider, { + ...data, + updatedBy: ctx.userId ?? null, + }); + + return this.providerRepository.save(provider); + } + + async deleteProvider(ctx: ServiceContext, id: string): Promise { + const provider = await this.findProviderById(ctx, id); + if (!provider) return false; + + provider.deletedAt = new Date(); + provider.deletedBy = ctx.userId ?? null; + await this.providerRepository.save(provider); + + return true; + } + + // OAuth State Management + async createState(ctx: ServiceContext, providerId: string, data?: OAuthStateData): Promise { + const state = randomBytes(32).toString('hex'); + const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes + + const oauthState = this.stateRepository.create({ + tenantId: ctx.tenantId, + providerId, + state, + redirectUri: data?.redirectUri, + returnUrl: data?.returnUrl, + metadata: data?.metadata, + expiresAt, + }); + + return this.stateRepository.save(oauthState); + } + + async validateState(ctx: ServiceContext, state: string): Promise { + const oauthState = await this.stateRepository.findOne({ + where: { tenantId: ctx.tenantId, state, isUsed: false }, + relations: ['provider'], + }); + + if (!oauthState) return null; + if (oauthState.expiresAt < new Date()) return null; + + return oauthState; + } + + async consumeState(ctx: ServiceContext, state: string): Promise { + const oauthState = await this.validateState(ctx, state); + if (!oauthState) return null; + + oauthState.isUsed = true; + oauthState.usedAt = new Date(); + await this.stateRepository.save(oauthState); + + return oauthState; + } + + // User Link Management + async linkUser( + ctx: ServiceContext, + userId: string, + providerId: string, + providerUserId: string, + profile?: Record, + ): Promise { + const existing = await this.userLinkRepository.findOne({ + where: { + tenantId: ctx.tenantId, + providerId, + providerUserId, + }, + }); + + if (existing) { + existing.profile = profile || existing.profile; + existing.updatedBy = ctx.userId ?? null; + return this.userLinkRepository.save(existing); + } + + const link = this.userLinkRepository.create({ + tenantId: ctx.tenantId, + userId, + providerId, + providerUserId, + profile, + createdBy: ctx.userId ?? null, + }); + + return this.userLinkRepository.save(link); + } + + async findUserByProviderLink( + ctx: ServiceContext, + providerId: string, + providerUserId: string, + ): Promise { + const link = await this.userLinkRepository.findOne({ + where: { + tenantId: ctx.tenantId, + providerId, + providerUserId, + }, + relations: ['user'], + }); + + return link?.user || null; + } + + async findUserLinks(ctx: ServiceContext, userId: string): Promise { + return this.userLinkRepository.find({ + where: { tenantId: ctx.tenantId, userId }, + relations: ['provider'], + }); + } + + async unlinkUser(ctx: ServiceContext, userId: string, providerId: string): Promise { + const result = await this.userLinkRepository.delete({ + tenantId: ctx.tenantId, + userId, + providerId, + }); + + return (result.affected ?? 0) > 0; + } + + async cleanupExpiredStates(ctx: ServiceContext): Promise { + const result = await this.stateRepository.delete({ + tenantId: ctx.tenantId, + expiresAt: IsNull(), + }); + + // Also clean old used states + const cleanupDate = new Date(Date.now() - 24 * 60 * 60 * 1000); + const result2 = await this.stateRepository + .createQueryBuilder() + .delete() + .where('tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('is_used = true') + .andWhere('used_at < :cleanupDate', { cleanupDate }) + .execute(); + + return (result.affected ?? 0) + (result2.affected ?? 0); + } +} diff --git a/src/modules/auth/services/password-reset.service.ts b/src/modules/auth/services/password-reset.service.ts new file mode 100644 index 0000000..9608da9 --- /dev/null +++ b/src/modules/auth/services/password-reset.service.ts @@ -0,0 +1,258 @@ +import { Repository, IsNull, LessThan } from 'typeorm'; +import { randomBytes, createHash } from 'crypto'; +import { PasswordReset, User } from '../entities'; + +export interface ServiceContext { + tenantId: string; + userId?: string; +} + +export interface CreatePasswordResetDto { + userId: string; + email: string; + expiresInMinutes?: number; +} + +export interface PasswordResetFilters { + userId?: string; + email?: string; + isUsed?: boolean; + isExpired?: boolean; +} + +export interface PaginatedResult { + data: T[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +export interface PasswordResetResult { + passwordReset: PasswordReset; + token: string; +} + +export class PasswordResetService { + constructor( + private readonly passwordResetRepository: Repository, + private readonly userRepository: Repository, + ) {} + + async create(ctx: ServiceContext, data: CreatePasswordResetDto): Promise { + // Invalidate any existing reset tokens for this user + await this.invalidateUserTokens(ctx, data.userId); + + const token = this.generateToken(); + const tokenHash = this.hashToken(token); + const expiresInMinutes = data.expiresInMinutes ?? 60; // 1 hour default + const expiresAt = new Date(Date.now() + expiresInMinutes * 60 * 1000); + + const passwordReset = this.passwordResetRepository.create({ + tenantId: ctx.tenantId, + userId: data.userId, + email: data.email, + tokenHash, + expiresAt, + createdBy: ctx.userId ?? null, + }); + + const saved = await this.passwordResetRepository.save(passwordReset); + + return { + passwordReset: saved, + token, // Only returned once + }; + } + + async createForEmail(ctx: ServiceContext, email: string): Promise { + const user = await this.userRepository.findOne({ + where: { tenantId: ctx.tenantId, email, deletedAt: IsNull() }, + }); + + if (!user) return null; + + return this.create(ctx, { + userId: user.id, + email: user.email, + }); + } + + async findById(ctx: ServiceContext, id: string): Promise { + return this.passwordResetRepository.findOne({ + where: { id, tenantId: ctx.tenantId }, + relations: ['user'], + }); + } + + async findByUserId(ctx: ServiceContext, userId: string): Promise { + return this.passwordResetRepository.find({ + where: { tenantId: ctx.tenantId, userId }, + order: { createdAt: 'DESC' }, + }); + } + + async findWithFilters( + ctx: ServiceContext, + filters: PasswordResetFilters, + page = 1, + limit = 50, + ): Promise> { + const qb = this.passwordResetRepository + .createQueryBuilder('pr') + .leftJoinAndSelect('pr.user', 'user') + .where('pr.tenant_id = :tenantId', { tenantId: ctx.tenantId }); + + if (filters.userId) { + qb.andWhere('pr.user_id = :userId', { userId: filters.userId }); + } + + if (filters.email) { + qb.andWhere('pr.email = :email', { email: filters.email }); + } + + if (filters.isUsed !== undefined) { + qb.andWhere('pr.is_used = :isUsed', { isUsed: filters.isUsed }); + } + + if (filters.isExpired !== undefined) { + if (filters.isExpired) { + qb.andWhere('pr.expires_at < :now', { now: new Date() }); + } else { + qb.andWhere('pr.expires_at >= :now', { now: new Date() }); + } + } + + const [data, total] = await qb + .orderBy('pr.created_at', 'DESC') + .skip((page - 1) * limit) + .take(limit) + .getManyAndCount(); + + return { + data, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + async validateToken(ctx: ServiceContext, token: string): Promise { + const tokenHash = this.hashToken(token); + + const passwordReset = await this.passwordResetRepository.findOne({ + where: { + tenantId: ctx.tenantId, + tokenHash, + isUsed: false, + }, + relations: ['user'], + }); + + if (!passwordReset) return null; + if (passwordReset.expiresAt < new Date()) return null; + + return passwordReset; + } + + async consumeToken(ctx: ServiceContext, token: string): Promise { + const passwordReset = await this.validateToken(ctx, token); + if (!passwordReset) return null; + + passwordReset.isUsed = true; + passwordReset.usedAt = new Date(); + await this.passwordResetRepository.save(passwordReset); + + return passwordReset; + } + + async invalidateUserTokens(ctx: ServiceContext, userId: string): Promise { + const result = await this.passwordResetRepository.update( + { + tenantId: ctx.tenantId, + userId, + isUsed: false, + }, + { + isUsed: true, + usedAt: new Date(), + }, + ); + + return result.affected ?? 0; + } + + async isTokenValid(ctx: ServiceContext, token: string): Promise { + const passwordReset = await this.validateToken(ctx, token); + return passwordReset !== null; + } + + async getActiveResetForUser(ctx: ServiceContext, userId: string): Promise { + return this.passwordResetRepository.findOne({ + where: { + tenantId: ctx.tenantId, + userId, + isUsed: false, + }, + order: { createdAt: 'DESC' }, + }); + } + + async hasActiveReset(ctx: ServiceContext, userId: string): Promise { + const reset = await this.getActiveResetForUser(ctx, userId); + if (!reset) return false; + return reset.expiresAt > new Date(); + } + + async cleanupExpired(ctx: ServiceContext): Promise { + const cleanupDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); // 7 days old + + const result = await this.passwordResetRepository + .createQueryBuilder() + .delete() + .where('tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('(expires_at < :now OR (is_used = true AND used_at < :cleanupDate))', { + now: new Date(), + cleanupDate, + }) + .execute(); + + return result.affected ?? 0; + } + + async getRateLimitInfo(ctx: ServiceContext, email: string, windowMinutes = 60): Promise<{ + count: number; + firstRequestAt: Date | null; + canRequest: boolean; + maxRequests: number; + }> { + const windowStart = new Date(Date.now() - windowMinutes * 60 * 1000); + const maxRequests = 3; + + const resets = await this.passwordResetRepository.find({ + where: { + tenantId: ctx.tenantId, + email, + }, + order: { createdAt: 'ASC' }, + }); + + const recentResets = resets.filter(r => r.createdAt > windowStart); + + return { + count: recentResets.length, + firstRequestAt: recentResets[0]?.createdAt ?? null, + canRequest: recentResets.length < maxRequests, + maxRequests, + }; + } + + private generateToken(): string { + return randomBytes(32).toString('hex'); + } + + private hashToken(token: string): string { + return createHash('sha256').update(token).digest('hex'); + } +} diff --git a/src/modules/auth/services/permission.service.ts b/src/modules/auth/services/permission.service.ts new file mode 100644 index 0000000..02f8634 --- /dev/null +++ b/src/modules/auth/services/permission.service.ts @@ -0,0 +1,208 @@ +import { Repository, IsNull } from 'typeorm'; +import { Permission } from '../entities'; + +export interface ServiceContext { + tenantId: string; + userId?: string; +} + +export interface CreatePermissionDto { + code: string; + name: string; + description?: string; + module?: string; + action?: string; + isActive?: boolean; +} + +export interface UpdatePermissionDto { + code?: string; + name?: string; + description?: string; + module?: string; + action?: string; + isActive?: boolean; +} + +export interface PermissionFilters { + code?: string; + module?: string; + action?: string; + isActive?: boolean; + search?: string; +} + +export interface PaginatedResult { + data: T[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +export class PermissionService { + constructor(private readonly permissionRepository: Repository) {} + + async create(ctx: ServiceContext, data: CreatePermissionDto): Promise { + const existing = await this.permissionRepository.findOne({ + where: { tenantId: ctx.tenantId, code: data.code, deletedAt: IsNull() }, + }); + + if (existing) { + throw new Error(`Permission with code '${data.code}' already exists`); + } + + const permission = this.permissionRepository.create({ + tenantId: ctx.tenantId, + code: data.code, + name: data.name, + description: data.description, + module: data.module, + action: data.action, + isActive: data.isActive ?? true, + createdBy: ctx.userId ?? null, + }); + + return this.permissionRepository.save(permission); + } + + async findById(ctx: ServiceContext, id: string): Promise { + return this.permissionRepository.findOne({ + where: { id, tenantId: ctx.tenantId, deletedAt: IsNull() }, + }); + } + + async findByCode(ctx: ServiceContext, code: string): Promise { + return this.permissionRepository.findOne({ + where: { tenantId: ctx.tenantId, code, deletedAt: IsNull() }, + }); + } + + async findAll(ctx: ServiceContext): Promise { + return this.permissionRepository.find({ + where: { tenantId: ctx.tenantId, deletedAt: IsNull() }, + order: { module: 'ASC', name: 'ASC' }, + }); + } + + async findByModule(ctx: ServiceContext, module: string): Promise { + return this.permissionRepository.find({ + where: { tenantId: ctx.tenantId, module, deletedAt: IsNull() }, + order: { action: 'ASC' }, + }); + } + + async findWithFilters( + ctx: ServiceContext, + filters: PermissionFilters, + page = 1, + limit = 50, + ): Promise> { + const qb = this.permissionRepository + .createQueryBuilder('permission') + .where('permission.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('permission.deleted_at IS NULL'); + + if (filters.code) { + qb.andWhere('permission.code = :code', { code: filters.code }); + } + + if (filters.module) { + qb.andWhere('permission.module = :module', { module: filters.module }); + } + + if (filters.action) { + qb.andWhere('permission.action = :action', { action: filters.action }); + } + + if (filters.isActive !== undefined) { + qb.andWhere('permission.is_active = :isActive', { isActive: filters.isActive }); + } + + if (filters.search) { + qb.andWhere('(permission.name ILIKE :search OR permission.code ILIKE :search)', { + search: `%${filters.search}%`, + }); + } + + const [data, total] = await qb + .orderBy('permission.module', 'ASC') + .addOrderBy('permission.name', 'ASC') + .skip((page - 1) * limit) + .take(limit) + .getManyAndCount(); + + return { + data, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + async update(ctx: ServiceContext, id: string, data: UpdatePermissionDto): Promise { + const permission = await this.findById(ctx, id); + if (!permission) return null; + + if (data.code && data.code !== permission.code) { + const existing = await this.permissionRepository.findOne({ + where: { tenantId: ctx.tenantId, code: data.code, deletedAt: IsNull() }, + }); + if (existing) { + throw new Error(`Permission with code '${data.code}' already exists`); + } + } + + Object.assign(permission, { + ...data, + updatedBy: ctx.userId ?? null, + }); + + return this.permissionRepository.save(permission); + } + + async delete(ctx: ServiceContext, id: string): Promise { + const permission = await this.findById(ctx, id); + if (!permission) return false; + + permission.deletedAt = new Date(); + permission.deletedBy = ctx.userId ?? null; + await this.permissionRepository.save(permission); + + return true; + } + + async getModules(ctx: ServiceContext): Promise { + const result = await this.permissionRepository + .createQueryBuilder('permission') + .select('DISTINCT permission.module', 'module') + .where('permission.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('permission.deleted_at IS NULL') + .andWhere('permission.module IS NOT NULL') + .orderBy('permission.module', 'ASC') + .getRawMany(); + + return result.map(r => r.module); + } + + async checkPermission(ctx: ServiceContext, code: string): Promise { + const permission = await this.findByCode(ctx, code); + return permission !== null && permission.isActive; + } + + async bulkCreate(ctx: ServiceContext, permissions: CreatePermissionDto[]): Promise { + const created: Permission[] = []; + + for (const data of permissions) { + try { + const permission = await this.create(ctx, data); + created.push(permission); + } catch { + // Skip duplicates + } + } + + return created; + } +} diff --git a/src/modules/auth/services/role.service.ts b/src/modules/auth/services/role.service.ts new file mode 100644 index 0000000..d1b2b29 --- /dev/null +++ b/src/modules/auth/services/role.service.ts @@ -0,0 +1,239 @@ +import { Repository, IsNull, In } from 'typeorm'; +import { Role, Permission, UserRole } from '../entities'; + +export interface ServiceContext { + tenantId: string; + userId?: string; +} + +export interface CreateRoleDto { + code: string; + name: string; + description?: string; + permissionIds?: string[]; + isActive?: boolean; +} + +export interface UpdateRoleDto { + code?: string; + name?: string; + description?: string; + permissionIds?: string[]; + isActive?: boolean; +} + +export interface RoleFilters { + code?: string; + isActive?: boolean; + search?: string; +} + +export interface PaginatedResult { + data: T[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +export class RoleService { + constructor( + private readonly roleRepository: Repository, + private readonly permissionRepository: Repository, + private readonly userRoleRepository: Repository, + ) {} + + async create(ctx: ServiceContext, data: CreateRoleDto): Promise { + const existing = await this.roleRepository.findOne({ + where: { tenantId: ctx.tenantId, code: data.code, deletedAt: IsNull() }, + }); + + if (existing) { + throw new Error(`Role with code '${data.code}' already exists`); + } + + const role = this.roleRepository.create({ + tenantId: ctx.tenantId, + code: data.code, + name: data.name, + description: data.description, + isActive: data.isActive ?? true, + createdBy: ctx.userId ?? null, + }); + + const savedRole = await this.roleRepository.save(role); + + if (data.permissionIds?.length) { + await this.assignPermissions(ctx, savedRole.id, data.permissionIds); + } + + return this.findById(ctx, savedRole.id) as Promise; + } + + async findById(ctx: ServiceContext, id: string): Promise { + return this.roleRepository.findOne({ + where: { id, tenantId: ctx.tenantId, deletedAt: IsNull() }, + relations: ['permissions'], + }); + } + + async findByCode(ctx: ServiceContext, code: string): Promise { + return this.roleRepository.findOne({ + where: { tenantId: ctx.tenantId, code, deletedAt: IsNull() }, + relations: ['permissions'], + }); + } + + async findAll(ctx: ServiceContext): Promise { + return this.roleRepository.find({ + where: { tenantId: ctx.tenantId, deletedAt: IsNull() }, + relations: ['permissions'], + order: { name: 'ASC' }, + }); + } + + async findWithFilters( + ctx: ServiceContext, + filters: RoleFilters, + page = 1, + limit = 50, + ): Promise> { + const qb = this.roleRepository + .createQueryBuilder('role') + .leftJoinAndSelect('role.permissions', 'permissions') + .where('role.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('role.deleted_at IS NULL'); + + if (filters.code) { + qb.andWhere('role.code = :code', { code: filters.code }); + } + + if (filters.isActive !== undefined) { + qb.andWhere('role.is_active = :isActive', { isActive: filters.isActive }); + } + + if (filters.search) { + qb.andWhere('(role.name ILIKE :search OR role.code ILIKE :search)', { + search: `%${filters.search}%`, + }); + } + + const [data, total] = await qb + .orderBy('role.name', 'ASC') + .skip((page - 1) * limit) + .take(limit) + .getManyAndCount(); + + return { + data, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + async update(ctx: ServiceContext, id: string, data: UpdateRoleDto): Promise { + const role = await this.findById(ctx, id); + if (!role) return null; + + if (data.code && data.code !== role.code) { + const existing = await this.roleRepository.findOne({ + where: { tenantId: ctx.tenantId, code: data.code, deletedAt: IsNull() }, + }); + if (existing) { + throw new Error(`Role with code '${data.code}' already exists`); + } + } + + Object.assign(role, { + ...data, + updatedBy: ctx.userId ?? null, + }); + + await this.roleRepository.save(role); + + if (data.permissionIds !== undefined) { + await this.syncPermissions(ctx, id, data.permissionIds); + } + + return this.findById(ctx, id); + } + + async delete(ctx: ServiceContext, id: string): Promise { + const role = await this.findById(ctx, id); + if (!role) return false; + + role.deletedAt = new Date(); + role.deletedBy = ctx.userId ?? null; + await this.roleRepository.save(role); + + return true; + } + + async assignPermissions(ctx: ServiceContext, roleId: string, permissionIds: string[]): Promise { + const role = await this.findById(ctx, roleId); + if (!role) throw new Error('Role not found'); + + const permissions = await this.permissionRepository.find({ + where: { id: In(permissionIds), tenantId: ctx.tenantId, deletedAt: IsNull() }, + }); + + role.permissions = [...(role.permissions || []), ...permissions]; + await this.roleRepository.save(role); + } + + async removePermissions(ctx: ServiceContext, roleId: string, permissionIds: string[]): Promise { + const role = await this.findById(ctx, roleId); + if (!role) throw new Error('Role not found'); + + role.permissions = (role.permissions || []).filter(p => !permissionIds.includes(p.id)); + await this.roleRepository.save(role); + } + + async syncPermissions(ctx: ServiceContext, roleId: string, permissionIds: string[]): Promise { + const role = await this.findById(ctx, roleId); + if (!role) throw new Error('Role not found'); + + const permissions = await this.permissionRepository.find({ + where: { id: In(permissionIds), tenantId: ctx.tenantId, deletedAt: IsNull() }, + }); + + role.permissions = permissions; + await this.roleRepository.save(role); + } + + async getUsersWithRole(ctx: ServiceContext, roleId: string): Promise { + return this.userRoleRepository.find({ + where: { roleId, tenantId: ctx.tenantId }, + relations: ['user'], + }); + } + + async assignRoleToUser(ctx: ServiceContext, userId: string, roleId: string): Promise { + const existing = await this.userRoleRepository.findOne({ + where: { userId, roleId, tenantId: ctx.tenantId }, + }); + + if (existing) return existing; + + const userRole = this.userRoleRepository.create({ + tenantId: ctx.tenantId, + userId, + roleId, + createdBy: ctx.userId ?? null, + }); + + return this.userRoleRepository.save(userRole); + } + + async removeRoleFromUser(ctx: ServiceContext, userId: string, roleId: string): Promise { + const result = await this.userRoleRepository.delete({ + userId, + roleId, + tenantId: ctx.tenantId, + }); + + return (result.affected ?? 0) > 0; + } +} diff --git a/src/modules/auth/services/session.service.ts b/src/modules/auth/services/session.service.ts new file mode 100644 index 0000000..d48d22b --- /dev/null +++ b/src/modules/auth/services/session.service.ts @@ -0,0 +1,225 @@ +import { Repository, IsNull, LessThan } from 'typeorm'; +import { Session, SessionStatus } from '../entities'; + +export interface ServiceContext { + tenantId: string; + userId?: string; +} + +export interface CreateSessionDto { + userId: string; + deviceId?: string; + ipAddress?: string; + userAgent?: string; + expiresAt?: Date; +} + +export interface SessionFilters { + userId?: string; + deviceId?: string; + status?: SessionStatus; + isActive?: boolean; +} + +export interface PaginatedResult { + data: T[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +export class SessionService { + constructor(private readonly sessionRepository: Repository) {} + + async create(ctx: ServiceContext, data: CreateSessionDto): Promise { + const expiresAt = data.expiresAt || new Date(Date.now() + 24 * 60 * 60 * 1000); // 24h default + + const session = this.sessionRepository.create({ + tenantId: ctx.tenantId, + userId: data.userId, + deviceId: data.deviceId, + ipAddress: data.ipAddress, + userAgent: data.userAgent, + status: SessionStatus.ACTIVE, + expiresAt, + lastActivityAt: new Date(), + createdBy: ctx.userId ?? null, + }); + + return this.sessionRepository.save(session); + } + + async findById(ctx: ServiceContext, id: string): Promise { + return this.sessionRepository.findOne({ + where: { id, tenantId: ctx.tenantId }, + relations: ['user', 'device'], + }); + } + + async findActiveByUserId(ctx: ServiceContext, userId: string): Promise { + return this.sessionRepository.find({ + where: { + tenantId: ctx.tenantId, + userId, + status: SessionStatus.ACTIVE, + }, + relations: ['device'], + order: { lastActivityAt: 'DESC' }, + }); + } + + async findWithFilters( + ctx: ServiceContext, + filters: SessionFilters, + page = 1, + limit = 50, + ): Promise> { + const qb = this.sessionRepository + .createQueryBuilder('session') + .leftJoinAndSelect('session.user', 'user') + .leftJoinAndSelect('session.device', 'device') + .where('session.tenant_id = :tenantId', { tenantId: ctx.tenantId }); + + if (filters.userId) { + qb.andWhere('session.user_id = :userId', { userId: filters.userId }); + } + + if (filters.deviceId) { + qb.andWhere('session.device_id = :deviceId', { deviceId: filters.deviceId }); + } + + if (filters.status) { + qb.andWhere('session.status = :status', { status: filters.status }); + } + + if (filters.isActive) { + qb.andWhere('session.status = :activeStatus', { activeStatus: SessionStatus.ACTIVE }); + qb.andWhere('session.expires_at > :now', { now: new Date() }); + } + + const [data, total] = await qb + .orderBy('session.last_activity_at', 'DESC') + .skip((page - 1) * limit) + .take(limit) + .getManyAndCount(); + + return { + data, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + async updateActivity(ctx: ServiceContext, sessionId: string): Promise { + const session = await this.findById(ctx, sessionId); + if (!session || session.status !== SessionStatus.ACTIVE) return null; + + session.lastActivityAt = new Date(); + return this.sessionRepository.save(session); + } + + async invalidate(ctx: ServiceContext, sessionId: string): Promise { + const session = await this.findById(ctx, sessionId); + if (!session) return false; + + session.status = SessionStatus.INVALIDATED; + session.updatedBy = ctx.userId ?? null; + await this.sessionRepository.save(session); + + return true; + } + + async invalidateAllUserSessions(ctx: ServiceContext, userId: string): Promise { + const result = await this.sessionRepository.update( + { + tenantId: ctx.tenantId, + userId, + status: SessionStatus.ACTIVE, + }, + { + status: SessionStatus.INVALIDATED, + updatedBy: ctx.userId ?? null, + }, + ); + + return result.affected ?? 0; + } + + async invalidateAllExcept(ctx: ServiceContext, userId: string, keepSessionId: string): Promise { + const sessions = await this.sessionRepository.find({ + where: { + tenantId: ctx.tenantId, + userId, + status: SessionStatus.ACTIVE, + }, + }); + + let count = 0; + for (const session of sessions) { + if (session.id !== keepSessionId) { + session.status = SessionStatus.INVALIDATED; + session.updatedBy = ctx.userId ?? null; + await this.sessionRepository.save(session); + count++; + } + } + + return count; + } + + async isSessionValid(ctx: ServiceContext, sessionId: string): Promise { + const session = await this.findById(ctx, sessionId); + if (!session) return false; + + if (session.status !== SessionStatus.ACTIVE) return false; + if (session.expiresAt && session.expiresAt < new Date()) return false; + + return true; + } + + async cleanupExpiredSessions(ctx: ServiceContext): Promise { + const result = await this.sessionRepository.update( + { + tenantId: ctx.tenantId, + status: SessionStatus.ACTIVE, + expiresAt: LessThan(new Date()), + }, + { + status: SessionStatus.EXPIRED, + }, + ); + + return result.affected ?? 0; + } + + async getActiveSessionCount(ctx: ServiceContext, userId: string): Promise { + return this.sessionRepository.count({ + where: { + tenantId: ctx.tenantId, + userId, + status: SessionStatus.ACTIVE, + }, + }); + } + + async getSessionStats(ctx: ServiceContext, userId: string): Promise<{ + total: number; + active: number; + expired: number; + invalidated: number; + }> { + const sessions = await this.sessionRepository.find({ + where: { tenantId: ctx.tenantId, userId }, + }); + + return { + total: sessions.length, + active: sessions.filter(s => s.status === SessionStatus.ACTIVE).length, + expired: sessions.filter(s => s.status === SessionStatus.EXPIRED).length, + invalidated: sessions.filter(s => s.status === SessionStatus.INVALIDATED).length, + }; + } +} diff --git a/src/modules/auth/services/trusted-device.service.ts b/src/modules/auth/services/trusted-device.service.ts new file mode 100644 index 0000000..42d3209 --- /dev/null +++ b/src/modules/auth/services/trusted-device.service.ts @@ -0,0 +1,253 @@ +import { Repository, IsNull, LessThan } from 'typeorm'; +import { TrustedDevice, TrustLevel } from '../entities'; + +export interface ServiceContext { + tenantId: string; + userId?: string; +} + +export interface CreateTrustedDeviceDto { + userId: string; + deviceId: string; + trustLevel?: TrustLevel; + expiresAt?: Date; + skipMfa?: boolean; +} + +export interface UpdateTrustedDeviceDto { + trustLevel?: TrustLevel; + expiresAt?: Date; + skipMfa?: boolean; + isRevoked?: boolean; +} + +export interface TrustedDeviceFilters { + userId?: string; + deviceId?: string; + trustLevel?: TrustLevel; + isRevoked?: boolean; + isExpired?: boolean; +} + +export interface PaginatedResult { + data: T[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +export class TrustedDeviceService { + constructor(private readonly trustedDeviceRepository: Repository) {} + + async create(ctx: ServiceContext, data: CreateTrustedDeviceDto): Promise { + const existing = await this.findByUserAndDevice(ctx, data.userId, data.deviceId); + if (existing && !existing.isRevoked) { + return this.renew(ctx, existing.id) as Promise; + } + + const defaultExpiry = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days + + const trustedDevice = this.trustedDeviceRepository.create({ + tenantId: ctx.tenantId, + userId: data.userId, + deviceId: data.deviceId, + trustLevel: data.trustLevel ?? TrustLevel.STANDARD, + expiresAt: data.expiresAt ?? defaultExpiry, + skipMfa: data.skipMfa ?? false, + trustedAt: new Date(), + createdBy: ctx.userId ?? null, + }); + + return this.trustedDeviceRepository.save(trustedDevice); + } + + async findById(ctx: ServiceContext, id: string): Promise { + return this.trustedDeviceRepository.findOne({ + where: { id, tenantId: ctx.tenantId }, + relations: ['user', 'device'], + }); + } + + async findByUserAndDevice(ctx: ServiceContext, userId: string, deviceId: string): Promise { + return this.trustedDeviceRepository.findOne({ + where: { + tenantId: ctx.tenantId, + userId, + deviceId, + }, + relations: ['device'], + }); + } + + async findByUserId(ctx: ServiceContext, userId: string): Promise { + return this.trustedDeviceRepository.find({ + where: { tenantId: ctx.tenantId, userId, isRevoked: false }, + relations: ['device'], + order: { trustedAt: 'DESC' }, + }); + } + + async findWithFilters( + ctx: ServiceContext, + filters: TrustedDeviceFilters, + page = 1, + limit = 50, + ): Promise> { + const qb = this.trustedDeviceRepository + .createQueryBuilder('td') + .leftJoinAndSelect('td.user', 'user') + .leftJoinAndSelect('td.device', 'device') + .where('td.tenant_id = :tenantId', { tenantId: ctx.tenantId }); + + if (filters.userId) { + qb.andWhere('td.user_id = :userId', { userId: filters.userId }); + } + + if (filters.deviceId) { + qb.andWhere('td.device_id = :deviceId', { deviceId: filters.deviceId }); + } + + if (filters.trustLevel) { + qb.andWhere('td.trust_level = :trustLevel', { trustLevel: filters.trustLevel }); + } + + if (filters.isRevoked !== undefined) { + qb.andWhere('td.is_revoked = :isRevoked', { isRevoked: filters.isRevoked }); + } + + if (filters.isExpired !== undefined) { + if (filters.isExpired) { + qb.andWhere('td.expires_at < :now', { now: new Date() }); + } else { + qb.andWhere('(td.expires_at IS NULL OR td.expires_at >= :now)', { now: new Date() }); + } + } + + const [data, total] = await qb + .orderBy('td.trusted_at', 'DESC') + .skip((page - 1) * limit) + .take(limit) + .getManyAndCount(); + + return { + data, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + async update(ctx: ServiceContext, id: string, data: UpdateTrustedDeviceDto): Promise { + const trustedDevice = await this.findById(ctx, id); + if (!trustedDevice) return null; + + Object.assign(trustedDevice, { + ...data, + updatedBy: ctx.userId ?? null, + }); + + return this.trustedDeviceRepository.save(trustedDevice); + } + + async renew(ctx: ServiceContext, id: string, daysToAdd = 30): Promise { + const trustedDevice = await this.findById(ctx, id); + if (!trustedDevice || trustedDevice.isRevoked) return null; + + trustedDevice.expiresAt = new Date(Date.now() + daysToAdd * 24 * 60 * 60 * 1000); + trustedDevice.updatedBy = ctx.userId ?? null; + + return this.trustedDeviceRepository.save(trustedDevice); + } + + async revoke(ctx: ServiceContext, id: string): Promise { + const trustedDevice = await this.findById(ctx, id); + if (!trustedDevice) return false; + + trustedDevice.isRevoked = true; + trustedDevice.revokedAt = new Date(); + trustedDevice.updatedBy = ctx.userId ?? null; + await this.trustedDeviceRepository.save(trustedDevice); + + return true; + } + + async revokeAllUserDevices(ctx: ServiceContext, userId: string): Promise { + const devices = await this.findByUserId(ctx, userId); + let count = 0; + + for (const device of devices) { + device.isRevoked = true; + device.revokedAt = new Date(); + device.updatedBy = ctx.userId ?? null; + await this.trustedDeviceRepository.save(device); + count++; + } + + return count; + } + + async isTrusted(ctx: ServiceContext, userId: string, deviceId: string): Promise { + const trustedDevice = await this.findByUserAndDevice(ctx, userId, deviceId); + if (!trustedDevice) return false; + if (trustedDevice.isRevoked) return false; + if (trustedDevice.expiresAt && trustedDevice.expiresAt < new Date()) return false; + + return true; + } + + async shouldSkipMfa(ctx: ServiceContext, userId: string, deviceId: string): Promise { + const trustedDevice = await this.findByUserAndDevice(ctx, userId, deviceId); + if (!trustedDevice) return false; + if (trustedDevice.isRevoked) return false; + if (trustedDevice.expiresAt && trustedDevice.expiresAt < new Date()) return false; + + return trustedDevice.skipMfa; + } + + async getTrustLevel(ctx: ServiceContext, userId: string, deviceId: string): Promise { + const trustedDevice = await this.findByUserAndDevice(ctx, userId, deviceId); + if (!trustedDevice || trustedDevice.isRevoked) return null; + if (trustedDevice.expiresAt && trustedDevice.expiresAt < new Date()) return null; + + return trustedDevice.trustLevel; + } + + async cleanupExpired(ctx: ServiceContext): Promise { + const result = await this.trustedDeviceRepository.update( + { + tenantId: ctx.tenantId, + isRevoked: false, + expiresAt: LessThan(new Date()), + }, + { + isRevoked: true, + revokedAt: new Date(), + }, + ); + + return result.affected ?? 0; + } + + async trustDevice( + ctx: ServiceContext, + userId: string, + deviceId: string, + options?: { trustLevel?: TrustLevel; skipMfa?: boolean; days?: number }, + ): Promise { + const existing = await this.findByUserAndDevice(ctx, userId, deviceId); + + if (existing && !existing.isRevoked) { + return this.renew(ctx, existing.id, options?.days ?? 30) as Promise; + } + + return this.create(ctx, { + userId, + deviceId, + trustLevel: options?.trustLevel ?? TrustLevel.STANDARD, + skipMfa: options?.skipMfa ?? false, + expiresAt: new Date(Date.now() + (options?.days ?? 30) * 24 * 60 * 60 * 1000), + }); + } +} diff --git a/src/modules/auth/services/user-profile.service.ts b/src/modules/auth/services/user-profile.service.ts new file mode 100644 index 0000000..4ce1447 --- /dev/null +++ b/src/modules/auth/services/user-profile.service.ts @@ -0,0 +1,335 @@ +import { Repository, IsNull, In } from 'typeorm'; +import { UserProfile, UserProfileAssignment, ProfileModule, ProfileTool, User } from '../entities'; + +export interface ServiceContext { + tenantId: string; + userId?: string; +} + +export interface CreateUserProfileDto { + code: string; + name: string; + description?: string; + moduleIds?: string[]; + toolIds?: string[]; + isDefault?: boolean; + isActive?: boolean; +} + +export interface UpdateUserProfileDto { + code?: string; + name?: string; + description?: string; + moduleIds?: string[]; + toolIds?: string[]; + isDefault?: boolean; + isActive?: boolean; +} + +export interface UserProfileFilters { + code?: string; + isDefault?: boolean; + isActive?: boolean; + search?: string; +} + +export interface PaginatedResult { + data: T[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +export class UserProfileService { + constructor( + private readonly profileRepository: Repository, + private readonly assignmentRepository: Repository, + private readonly moduleRepository: Repository, + private readonly toolRepository: Repository, + private readonly userRepository: Repository, + ) {} + + async create(ctx: ServiceContext, data: CreateUserProfileDto): Promise { + const existing = await this.profileRepository.findOne({ + where: { tenantId: ctx.tenantId, code: data.code, deletedAt: IsNull() }, + }); + + if (existing) { + throw new Error(`Profile with code '${data.code}' already exists`); + } + + // If this is default, remove default from others + if (data.isDefault) { + await this.clearDefaultProfile(ctx); + } + + const profile = this.profileRepository.create({ + tenantId: ctx.tenantId, + code: data.code, + name: data.name, + description: data.description, + isDefault: data.isDefault ?? false, + isActive: data.isActive ?? true, + createdBy: ctx.userId ?? null, + }); + + const savedProfile = await this.profileRepository.save(profile); + + if (data.moduleIds?.length) { + await this.syncModules(ctx, savedProfile.id, data.moduleIds); + } + + if (data.toolIds?.length) { + await this.syncTools(ctx, savedProfile.id, data.toolIds); + } + + return this.findById(ctx, savedProfile.id) as Promise; + } + + async findById(ctx: ServiceContext, id: string): Promise { + return this.profileRepository.findOne({ + where: { id, tenantId: ctx.tenantId, deletedAt: IsNull() }, + relations: ['modules', 'tools'], + }); + } + + async findByCode(ctx: ServiceContext, code: string): Promise { + return this.profileRepository.findOne({ + where: { tenantId: ctx.tenantId, code, deletedAt: IsNull() }, + relations: ['modules', 'tools'], + }); + } + + async findAll(ctx: ServiceContext): Promise { + return this.profileRepository.find({ + where: { tenantId: ctx.tenantId, deletedAt: IsNull() }, + relations: ['modules', 'tools'], + order: { name: 'ASC' }, + }); + } + + async findDefault(ctx: ServiceContext): Promise { + return this.profileRepository.findOne({ + where: { tenantId: ctx.tenantId, isDefault: true, deletedAt: IsNull() }, + relations: ['modules', 'tools'], + }); + } + + async findWithFilters( + ctx: ServiceContext, + filters: UserProfileFilters, + page = 1, + limit = 50, + ): Promise> { + const qb = this.profileRepository + .createQueryBuilder('profile') + .leftJoinAndSelect('profile.modules', 'modules') + .leftJoinAndSelect('profile.tools', 'tools') + .where('profile.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('profile.deleted_at IS NULL'); + + if (filters.code) { + qb.andWhere('profile.code = :code', { code: filters.code }); + } + + if (filters.isDefault !== undefined) { + qb.andWhere('profile.is_default = :isDefault', { isDefault: filters.isDefault }); + } + + if (filters.isActive !== undefined) { + qb.andWhere('profile.is_active = :isActive', { isActive: filters.isActive }); + } + + if (filters.search) { + qb.andWhere('(profile.name ILIKE :search OR profile.code ILIKE :search)', { + search: `%${filters.search}%`, + }); + } + + const [data, total] = await qb + .orderBy('profile.name', 'ASC') + .skip((page - 1) * limit) + .take(limit) + .getManyAndCount(); + + return { + data, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + async update(ctx: ServiceContext, id: string, data: UpdateUserProfileDto): Promise { + const profile = await this.findById(ctx, id); + if (!profile) return null; + + if (data.code && data.code !== profile.code) { + const existing = await this.profileRepository.findOne({ + where: { tenantId: ctx.tenantId, code: data.code, deletedAt: IsNull() }, + }); + if (existing) { + throw new Error(`Profile with code '${data.code}' already exists`); + } + } + + if (data.isDefault && !profile.isDefault) { + await this.clearDefaultProfile(ctx); + } + + Object.assign(profile, { + code: data.code ?? profile.code, + name: data.name ?? profile.name, + description: data.description ?? profile.description, + isDefault: data.isDefault ?? profile.isDefault, + isActive: data.isActive ?? profile.isActive, + updatedBy: ctx.userId ?? null, + }); + + await this.profileRepository.save(profile); + + if (data.moduleIds !== undefined) { + await this.syncModules(ctx, id, data.moduleIds); + } + + if (data.toolIds !== undefined) { + await this.syncTools(ctx, id, data.toolIds); + } + + return this.findById(ctx, id); + } + + async delete(ctx: ServiceContext, id: string): Promise { + const profile = await this.findById(ctx, id); + if (!profile) return false; + + profile.deletedAt = new Date(); + profile.deletedBy = ctx.userId ?? null; + await this.profileRepository.save(profile); + + return true; + } + + // Module Management + async syncModules(ctx: ServiceContext, profileId: string, moduleIds: string[]): Promise { + const profile = await this.findById(ctx, profileId); + if (!profile) throw new Error('Profile not found'); + + const modules = await this.moduleRepository.find({ + where: { id: In(moduleIds), tenantId: ctx.tenantId }, + }); + + profile.modules = modules; + await this.profileRepository.save(profile); + } + + async addModule(ctx: ServiceContext, profileId: string, moduleId: string): Promise { + const profile = await this.findById(ctx, profileId); + if (!profile) throw new Error('Profile not found'); + + const module = await this.moduleRepository.findOne({ + where: { id: moduleId, tenantId: ctx.tenantId }, + }); + if (!module) throw new Error('Module not found'); + + profile.modules = [...(profile.modules || []), module]; + await this.profileRepository.save(profile); + } + + async removeModule(ctx: ServiceContext, profileId: string, moduleId: string): Promise { + const profile = await this.findById(ctx, profileId); + if (!profile) throw new Error('Profile not found'); + + profile.modules = (profile.modules || []).filter(m => m.id !== moduleId); + await this.profileRepository.save(profile); + } + + // Tool Management + async syncTools(ctx: ServiceContext, profileId: string, toolIds: string[]): Promise { + const profile = await this.findById(ctx, profileId); + if (!profile) throw new Error('Profile not found'); + + const tools = await this.toolRepository.find({ + where: { id: In(toolIds), tenantId: ctx.tenantId }, + }); + + profile.tools = tools; + await this.profileRepository.save(profile); + } + + async addTool(ctx: ServiceContext, profileId: string, toolId: string): Promise { + const profile = await this.findById(ctx, profileId); + if (!profile) throw new Error('Profile not found'); + + const tool = await this.toolRepository.findOne({ + where: { id: toolId, tenantId: ctx.tenantId }, + }); + if (!tool) throw new Error('Tool not found'); + + profile.tools = [...(profile.tools || []), tool]; + await this.profileRepository.save(profile); + } + + async removeTool(ctx: ServiceContext, profileId: string, toolId: string): Promise { + const profile = await this.findById(ctx, profileId); + if (!profile) throw new Error('Profile not found'); + + profile.tools = (profile.tools || []).filter(t => t.id !== toolId); + await this.profileRepository.save(profile); + } + + // User Assignment + async assignToUser(ctx: ServiceContext, userId: string, profileId: string): Promise { + const existing = await this.assignmentRepository.findOne({ + where: { tenantId: ctx.tenantId, userId, profileId }, + }); + + if (existing) return existing; + + const assignment = this.assignmentRepository.create({ + tenantId: ctx.tenantId, + userId, + profileId, + createdBy: ctx.userId ?? null, + }); + + return this.assignmentRepository.save(assignment); + } + + async removeFromUser(ctx: ServiceContext, userId: string, profileId: string): Promise { + const result = await this.assignmentRepository.delete({ + tenantId: ctx.tenantId, + userId, + profileId, + }); + + return (result.affected ?? 0) > 0; + } + + async getUserProfiles(ctx: ServiceContext, userId: string): Promise { + const assignments = await this.assignmentRepository.find({ + where: { tenantId: ctx.tenantId, userId }, + relations: ['profile', 'profile.modules', 'profile.tools'], + }); + + return assignments.map(a => a.profile).filter(p => p && !p.deletedAt); + } + + async getProfileUsers(ctx: ServiceContext, profileId: string): Promise { + const assignments = await this.assignmentRepository.find({ + where: { tenantId: ctx.tenantId, profileId }, + relations: ['user'], + }); + + return assignments.map(a => a.user).filter(u => u && !u.deletedAt); + } + + private async clearDefaultProfile(ctx: ServiceContext): Promise { + await this.profileRepository.update( + { tenantId: ctx.tenantId, isDefault: true }, + { isDefault: false }, + ); + } +} diff --git a/src/modules/auth/services/verification.service.ts b/src/modules/auth/services/verification.service.ts new file mode 100644 index 0000000..81b4a03 --- /dev/null +++ b/src/modules/auth/services/verification.service.ts @@ -0,0 +1,317 @@ +import { Repository, IsNull } from 'typeorm'; +import { randomInt } from 'crypto'; +import { VerificationCode, CodeType, User } from '../entities'; + +export interface ServiceContext { + tenantId: string; + userId?: string; +} + +export interface CreateVerificationCodeDto { + userId: string; + type: CodeType; + destination: string; // email or phone + expiresInMinutes?: number; +} + +export interface VerificationFilters { + userId?: string; + type?: CodeType; + isUsed?: boolean; + isExpired?: boolean; +} + +export interface PaginatedResult { + data: T[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +export interface VerificationResult { + verification: VerificationCode; + code: string; +} + +export class VerificationService { + constructor( + private readonly verificationRepository: Repository, + private readonly userRepository: Repository, + ) {} + + async create(ctx: ServiceContext, data: CreateVerificationCodeDto): Promise { + // Invalidate existing codes of same type + await this.invalidateExisting(ctx, data.userId, data.type); + + const code = this.generateCode(data.type); + const expiresInMinutes = data.expiresInMinutes ?? this.getDefaultExpiry(data.type); + const expiresAt = new Date(Date.now() + expiresInMinutes * 60 * 1000); + + const verification = this.verificationRepository.create({ + tenantId: ctx.tenantId, + userId: data.userId, + type: data.type, + code, + destination: data.destination, + expiresAt, + attempts: 0, + maxAttempts: 5, + createdBy: ctx.userId ?? null, + }); + + const saved = await this.verificationRepository.save(verification); + + return { + verification: saved, + code, + }; + } + + async createEmailVerification(ctx: ServiceContext, userId: string, email: string): Promise { + return this.create(ctx, { + userId, + type: CodeType.EMAIL, + destination: email, + }); + } + + async createSmsVerification(ctx: ServiceContext, userId: string, phone: string): Promise { + return this.create(ctx, { + userId, + type: CodeType.SMS, + destination: phone, + expiresInMinutes: 5, // Shorter for SMS + }); + } + + async createMfaVerification(ctx: ServiceContext, userId: string, destination: string): Promise { + return this.create(ctx, { + userId, + type: CodeType.MFA, + destination, + expiresInMinutes: 5, + }); + } + + async findById(ctx: ServiceContext, id: string): Promise { + return this.verificationRepository.findOne({ + where: { id, tenantId: ctx.tenantId }, + relations: ['user'], + }); + } + + async findActiveByUserId(ctx: ServiceContext, userId: string, type?: CodeType): Promise { + const where: Record = { + tenantId: ctx.tenantId, + userId, + isUsed: false, + }; + + if (type) { + where.type = type; + } + + return this.verificationRepository.findOne({ + where, + order: { createdAt: 'DESC' }, + }); + } + + async findWithFilters( + ctx: ServiceContext, + filters: VerificationFilters, + page = 1, + limit = 50, + ): Promise> { + const qb = this.verificationRepository + .createQueryBuilder('vc') + .leftJoinAndSelect('vc.user', 'user') + .where('vc.tenant_id = :tenantId', { tenantId: ctx.tenantId }); + + if (filters.userId) { + qb.andWhere('vc.user_id = :userId', { userId: filters.userId }); + } + + if (filters.type) { + qb.andWhere('vc.type = :type', { type: filters.type }); + } + + if (filters.isUsed !== undefined) { + qb.andWhere('vc.is_used = :isUsed', { isUsed: filters.isUsed }); + } + + if (filters.isExpired !== undefined) { + if (filters.isExpired) { + qb.andWhere('vc.expires_at < :now', { now: new Date() }); + } else { + qb.andWhere('vc.expires_at >= :now', { now: new Date() }); + } + } + + const [data, total] = await qb + .orderBy('vc.created_at', 'DESC') + .skip((page - 1) * limit) + .take(limit) + .getManyAndCount(); + + return { + data, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + async verify(ctx: ServiceContext, userId: string, code: string, type?: CodeType): Promise<{ + success: boolean; + verification?: VerificationCode; + error?: string; + }> { + const verification = await this.findActiveByUserId(ctx, userId, type); + + if (!verification) { + return { success: false, error: 'No active verification code found' }; + } + + if (verification.expiresAt < new Date()) { + return { success: false, error: 'Verification code has expired' }; + } + + if (verification.attempts >= verification.maxAttempts) { + return { success: false, error: 'Maximum verification attempts exceeded' }; + } + + verification.attempts++; + await this.verificationRepository.save(verification); + + if (verification.code !== code) { + return { + success: false, + error: `Invalid code. ${verification.maxAttempts - verification.attempts} attempts remaining`, + }; + } + + verification.isUsed = true; + verification.usedAt = new Date(); + await this.verificationRepository.save(verification); + + return { success: true, verification }; + } + + async invalidateExisting(ctx: ServiceContext, userId: string, type: CodeType): Promise { + const result = await this.verificationRepository.update( + { + tenantId: ctx.tenantId, + userId, + type, + isUsed: false, + }, + { + isUsed: true, + usedAt: new Date(), + }, + ); + + return result.affected ?? 0; + } + + async resend(ctx: ServiceContext, userId: string, type: CodeType): Promise { + const existing = await this.findActiveByUserId(ctx, userId, type); + if (!existing) return null; + + // Check rate limit + const rateLimit = await this.getRateLimitInfo(ctx, userId, type); + if (!rateLimit.canResend) { + throw new Error(`Please wait ${rateLimit.secondsUntilResend} seconds before requesting a new code`); + } + + return this.create(ctx, { + userId, + type, + destination: existing.destination, + }); + } + + async getRateLimitInfo(ctx: ServiceContext, userId: string, type: CodeType): Promise<{ + count: number; + canResend: boolean; + secondsUntilResend: number; + maxPerHour: number; + }> { + const hourAgo = new Date(Date.now() - 60 * 60 * 1000); + const maxPerHour = 5; + const minIntervalSeconds = 60; + + const recent = await this.verificationRepository.find({ + where: { + tenantId: ctx.tenantId, + userId, + type, + }, + order: { createdAt: 'DESC' }, + take: maxPerHour, + }); + + const recentInHour = recent.filter(v => v.createdAt > hourAgo); + const mostRecent = recent[0]; + + let secondsUntilResend = 0; + if (mostRecent) { + const timeSinceLastRequest = (Date.now() - mostRecent.createdAt.getTime()) / 1000; + if (timeSinceLastRequest < minIntervalSeconds) { + secondsUntilResend = Math.ceil(minIntervalSeconds - timeSinceLastRequest); + } + } + + return { + count: recentInHour.length, + canResend: recentInHour.length < maxPerHour && secondsUntilResend === 0, + secondsUntilResend, + maxPerHour, + }; + } + + async cleanupExpired(ctx: ServiceContext): Promise { + const cleanupDate = new Date(Date.now() - 24 * 60 * 60 * 1000); // 24 hours old + + const result = await this.verificationRepository + .createQueryBuilder() + .delete() + .where('tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('(expires_at < :now OR created_at < :cleanupDate)', { + now: new Date(), + cleanupDate, + }) + .execute(); + + return result.affected ?? 0; + } + + private generateCode(type: CodeType): string { + switch (type) { + case CodeType.EMAIL: + return String(randomInt(100000, 999999)); // 6 digits + case CodeType.SMS: + return String(randomInt(100000, 999999)); // 6 digits + case CodeType.MFA: + return String(randomInt(100000, 999999)); // 6 digits + default: + return String(randomInt(100000, 999999)); + } + } + + private getDefaultExpiry(type: CodeType): number { + switch (type) { + case CodeType.EMAIL: + return 60; // 1 hour + case CodeType.SMS: + return 5; // 5 minutes + case CodeType.MFA: + return 5; // 5 minutes + default: + return 15; + } + } +} diff --git a/src/modules/core/services/discount-rule.service.ts b/src/modules/core/services/discount-rule.service.ts new file mode 100644 index 0000000..1471eb8 --- /dev/null +++ b/src/modules/core/services/discount-rule.service.ts @@ -0,0 +1,474 @@ +import { Repository, IsNull, Between, LessThanOrEqual, MoreThanOrEqual } from 'typeorm'; +import { + DiscountRule, + DiscountType, + DiscountAppliesTo, + DiscountCondition, +} from '../entities'; + +export interface ServiceContext { + tenantId: string; + userId?: string; +} + +export interface CreateDiscountRuleDto { + code: string; + name: string; + description?: string; + companyId?: string; + discountType: DiscountType; + discountValue: number; + maxDiscountAmount?: number; + appliesTo: DiscountAppliesTo; + appliesToId?: string; + conditionType?: DiscountCondition; + conditionValue?: number; + startDate?: Date; + endDate?: Date; + priority?: number; + combinable?: boolean; + usageLimit?: number; + isActive?: boolean; +} + +export interface UpdateDiscountRuleDto { + code?: string; + name?: string; + description?: string; + companyId?: string; + discountType?: DiscountType; + discountValue?: number; + maxDiscountAmount?: number; + appliesTo?: DiscountAppliesTo; + appliesToId?: string; + conditionType?: DiscountCondition; + conditionValue?: number; + startDate?: Date; + endDate?: Date; + priority?: number; + combinable?: boolean; + usageLimit?: number; + isActive?: boolean; +} + +export interface DiscountRuleFilters { + code?: string; + discountType?: DiscountType; + appliesTo?: DiscountAppliesTo; + companyId?: string; + isActive?: boolean; + activeOnDate?: Date; + search?: string; +} + +export interface PaginatedResult { + data: T[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +export interface DiscountCalculation { + originalAmount: number; + discountAmount: number; + finalAmount: number; + discountPercentage: number; + appliedRule: DiscountRule; +} + +export interface ApplicabilityContext { + targetType: DiscountAppliesTo; + targetId?: string; + quantity?: number; + amount?: number; + customerId?: string; + customerGroupId?: string; + isFirstPurchase?: boolean; + date?: Date; +} + +export class DiscountRuleService { + constructor(private readonly discountRuleRepository: Repository) {} + + async create(ctx: ServiceContext, data: CreateDiscountRuleDto): Promise { + const existing = await this.discountRuleRepository.findOne({ + where: { tenantId: ctx.tenantId, code: data.code, deletedAt: IsNull() }, + }); + + if (existing) { + throw new Error(`Discount rule with code '${data.code}' already exists`); + } + + if (data.discountValue <= 0) { + throw new Error('Discount value must be positive'); + } + + if (data.discountType === DiscountType.PERCENTAGE && data.discountValue > 100) { + throw new Error('Percentage discount cannot exceed 100%'); + } + + const discountRule = this.discountRuleRepository.create({ + tenantId: ctx.tenantId, + code: data.code, + name: data.name, + description: data.description, + companyId: data.companyId, + discountType: data.discountType, + discountValue: data.discountValue, + maxDiscountAmount: data.maxDiscountAmount, + appliesTo: data.appliesTo, + appliesToId: data.appliesToId, + conditionType: data.conditionType ?? DiscountCondition.NONE, + conditionValue: data.conditionValue, + startDate: data.startDate, + endDate: data.endDate, + priority: data.priority ?? 10, + combinable: data.combinable ?? true, + usageLimit: data.usageLimit, + usageCount: 0, + isActive: data.isActive ?? true, + createdBy: ctx.userId ?? null, + }); + + return this.discountRuleRepository.save(discountRule); + } + + async findById(ctx: ServiceContext, id: string): Promise { + return this.discountRuleRepository.findOne({ + where: { id, tenantId: ctx.tenantId, deletedAt: IsNull() }, + }); + } + + async findByCode(ctx: ServiceContext, code: string): Promise { + return this.discountRuleRepository.findOne({ + where: { tenantId: ctx.tenantId, code, deletedAt: IsNull() }, + }); + } + + async findAll(ctx: ServiceContext): Promise { + return this.discountRuleRepository.find({ + where: { tenantId: ctx.tenantId, deletedAt: IsNull() }, + order: { priority: 'ASC', name: 'ASC' }, + }); + } + + async findActive(ctx: ServiceContext, date?: Date): Promise { + const targetDate = date || new Date(); + + const qb = this.discountRuleRepository + .createQueryBuilder('dr') + .where('dr.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('dr.deleted_at IS NULL') + .andWhere('dr.is_active = true') + .andWhere('(dr.start_date IS NULL OR dr.start_date <= :date)', { date: targetDate }) + .andWhere('(dr.end_date IS NULL OR dr.end_date >= :date)', { date: targetDate }) + .andWhere('(dr.usage_limit IS NULL OR dr.usage_count < dr.usage_limit)') + .orderBy('dr.priority', 'ASC'); + + return qb.getMany(); + } + + async findWithFilters( + ctx: ServiceContext, + filters: DiscountRuleFilters, + page = 1, + limit = 50, + ): Promise> { + const qb = this.discountRuleRepository + .createQueryBuilder('dr') + .where('dr.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('dr.deleted_at IS NULL'); + + if (filters.code) { + qb.andWhere('dr.code = :code', { code: filters.code }); + } + + if (filters.discountType) { + qb.andWhere('dr.discount_type = :discountType', { discountType: filters.discountType }); + } + + if (filters.appliesTo) { + qb.andWhere('dr.applies_to = :appliesTo', { appliesTo: filters.appliesTo }); + } + + if (filters.companyId) { + qb.andWhere('dr.company_id = :companyId', { companyId: filters.companyId }); + } + + if (filters.isActive !== undefined) { + qb.andWhere('dr.is_active = :isActive', { isActive: filters.isActive }); + } + + if (filters.activeOnDate) { + qb.andWhere('(dr.start_date IS NULL OR dr.start_date <= :date)', { date: filters.activeOnDate }); + qb.andWhere('(dr.end_date IS NULL OR dr.end_date >= :date)', { date: filters.activeOnDate }); + } + + if (filters.search) { + qb.andWhere('(dr.name ILIKE :search OR dr.code ILIKE :search OR dr.description ILIKE :search)', { + search: `%${filters.search}%`, + }); + } + + const [data, total] = await qb + .orderBy('dr.priority', 'ASC') + .addOrderBy('dr.name', 'ASC') + .skip((page - 1) * limit) + .take(limit) + .getManyAndCount(); + + return { + data, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + async update(ctx: ServiceContext, id: string, data: UpdateDiscountRuleDto): Promise { + const discountRule = await this.findById(ctx, id); + if (!discountRule) return null; + + if (data.code && data.code !== discountRule.code) { + const existing = await this.discountRuleRepository.findOne({ + where: { tenantId: ctx.tenantId, code: data.code, deletedAt: IsNull() }, + }); + if (existing) { + throw new Error(`Discount rule with code '${data.code}' already exists`); + } + } + + if (data.discountValue !== undefined && data.discountValue <= 0) { + throw new Error('Discount value must be positive'); + } + + const discountType = data.discountType ?? discountRule.discountType; + const discountValue = data.discountValue ?? discountRule.discountValue; + + if (discountType === DiscountType.PERCENTAGE && discountValue > 100) { + throw new Error('Percentage discount cannot exceed 100%'); + } + + Object.assign(discountRule, { + ...data, + updatedBy: ctx.userId ?? null, + }); + + return this.discountRuleRepository.save(discountRule); + } + + async delete(ctx: ServiceContext, id: string): Promise { + const discountRule = await this.findById(ctx, id); + if (!discountRule) return false; + + discountRule.deletedAt = new Date(); + discountRule.deletedBy = ctx.userId ?? null; + await this.discountRuleRepository.save(discountRule); + + return true; + } + + async activate(ctx: ServiceContext, id: string): Promise { + return this.update(ctx, id, { isActive: true }); + } + + async deactivate(ctx: ServiceContext, id: string): Promise { + return this.update(ctx, id, { isActive: false }); + } + + // Business Logic Methods + + async isApplicable(ctx: ServiceContext, ruleId: string, context: ApplicabilityContext): Promise { + const rule = await this.findById(ctx, ruleId); + if (!rule) return false; + + return this.checkApplicability(rule, context); + } + + async getApplicableRules(ctx: ServiceContext, context: ApplicabilityContext): Promise { + const activeRules = await this.findActive(ctx, context.date); + + const applicableRules = activeRules.filter(rule => this.checkApplicability(rule, context)); + + return applicableRules.sort((a, b) => a.priority - b.priority); + } + + async calculateDiscount( + ctx: ServiceContext, + ruleId: string, + baseAmount: number, + quantity = 1, + ): Promise { + const rule = await this.findById(ctx, ruleId); + if (!rule || !rule.isActive) return null; + + let discountAmount = 0; + + switch (rule.discountType) { + case DiscountType.PERCENTAGE: + discountAmount = (baseAmount * rule.discountValue) / 100; + break; + case DiscountType.FIXED: + discountAmount = rule.discountValue * quantity; + break; + case DiscountType.PRICE_OVERRIDE: + discountAmount = Math.max(0, baseAmount - rule.discountValue * quantity); + break; + } + + // Apply max discount cap if set + if (rule.maxDiscountAmount && discountAmount > rule.maxDiscountAmount) { + discountAmount = rule.maxDiscountAmount; + } + + // Ensure discount doesn't exceed base amount + discountAmount = Math.min(discountAmount, baseAmount); + + const finalAmount = baseAmount - discountAmount; + const discountPercentage = baseAmount > 0 ? (discountAmount / baseAmount) * 100 : 0; + + return { + originalAmount: baseAmount, + discountAmount, + finalAmount, + discountPercentage, + appliedRule: rule, + }; + } + + async calculateBestDiscount( + ctx: ServiceContext, + context: ApplicabilityContext, + baseAmount: number, + quantity = 1, + ): Promise { + const applicableRules = await this.getApplicableRules(ctx, context); + + if (applicableRules.length === 0) return null; + + // Calculate discount for each rule and find the best one + let bestDiscount: DiscountCalculation | null = null; + + for (const rule of applicableRules) { + const calculation = await this.calculateDiscount(ctx, rule.id, baseAmount, quantity); + if (calculation) { + if (!bestDiscount || calculation.discountAmount > bestDiscount.discountAmount) { + bestDiscount = calculation; + } + } + } + + return bestDiscount; + } + + async calculateCombinedDiscounts( + ctx: ServiceContext, + context: ApplicabilityContext, + baseAmount: number, + quantity = 1, + ): Promise<{ + totalDiscount: number; + finalAmount: number; + appliedRules: DiscountCalculation[]; + }> { + const applicableRules = await this.getApplicableRules(ctx, context); + const combinableRules = applicableRules.filter(r => r.combinable); + + const appliedRules: DiscountCalculation[] = []; + let currentAmount = baseAmount; + let totalDiscount = 0; + + for (const rule of combinableRules) { + const calculation = await this.calculateDiscount(ctx, rule.id, currentAmount, quantity); + if (calculation && calculation.discountAmount > 0) { + appliedRules.push(calculation); + totalDiscount += calculation.discountAmount; + currentAmount = calculation.finalAmount; + } + } + + return { + totalDiscount, + finalAmount: currentAmount, + appliedRules, + }; + } + + async incrementUsageCount(ctx: ServiceContext, id: string): Promise { + const rule = await this.findById(ctx, id); + if (!rule) return null; + + if (rule.usageLimit && rule.usageCount >= rule.usageLimit) { + throw new Error('Discount rule usage limit reached'); + } + + rule.usageCount = (rule.usageCount || 0) + 1; + return this.discountRuleRepository.save(rule); + } + + async resetUsageCount(ctx: ServiceContext, id: string): Promise { + const rule = await this.findById(ctx, id); + if (!rule) return null; + + rule.usageCount = 0; + rule.updatedBy = ctx.userId ?? null; + return this.discountRuleRepository.save(rule); + } + + async getRemainingUsage(ctx: ServiceContext, id: string): Promise { + const rule = await this.findById(ctx, id); + if (!rule) return null; + if (!rule.usageLimit) return null; // Unlimited + + return Math.max(0, rule.usageLimit - (rule.usageCount || 0)); + } + + private checkApplicability(rule: DiscountRule, context: ApplicabilityContext): boolean { + // Check if rule is active + if (!rule.isActive) return false; + + // Check date range + const checkDate = context.date || new Date(); + if (rule.startDate && rule.startDate > checkDate) return false; + if (rule.endDate && rule.endDate < checkDate) return false; + + // Check usage limit + if (rule.usageLimit && rule.usageCount >= rule.usageLimit) return false; + + // Check applies to + if (rule.appliesTo !== DiscountAppliesTo.ALL) { + if (rule.appliesTo !== context.targetType) return false; + if (rule.appliesToId && rule.appliesToId !== context.targetId) return false; + } + + // Check conditions + if (rule.conditionType && rule.conditionType !== DiscountCondition.NONE) { + if (!this.checkCondition(rule, context)) return false; + } + + return true; + } + + private checkCondition(rule: DiscountRule, context: ApplicabilityContext): boolean { + switch (rule.conditionType) { + case DiscountCondition.MIN_QUANTITY: + return (context.quantity || 0) >= (rule.conditionValue || 0); + + case DiscountCondition.MIN_AMOUNT: + return (context.amount || 0) >= (rule.conditionValue || 0); + + case DiscountCondition.DATE_RANGE: + // Already handled by startDate/endDate + return true; + + case DiscountCondition.FIRST_PURCHASE: + return context.isFirstPurchase === true; + + case DiscountCondition.NONE: + default: + return true; + } + } +} diff --git a/src/modules/core/services/index.ts b/src/modules/core/services/index.ts index d2c5630..39e6fac 100644 --- a/src/modules/core/services/index.ts +++ b/src/modules/core/services/index.ts @@ -61,3 +61,14 @@ export { ProductCategoryFilters, CategoryTreeNode, } from './product-category.service'; + +// Discount Rule Service - Discount management and calculations +// Added: 2026-02-03 - GAP-CORE-001 remediation +export { + DiscountRuleService, + CreateDiscountRuleDto, + UpdateDiscountRuleDto, + DiscountRuleFilters, + DiscountCalculation, + ApplicabilityContext, +} from './discount-rule.service';