[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 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-02-03 11:12:39 -06:00
parent e2d446181c
commit 3722355360
16 changed files with 3935 additions and 0 deletions

View File

@ -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<T> {
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<ApiKey>) {}
async create(ctx: ServiceContext, data: CreateApiKeyDto): Promise<ApiKeyWithSecret> {
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<ApiKey | null> {
return this.apiKeyRepository.findOne({
where: { id, tenantId: ctx.tenantId, deletedAt: IsNull() },
relations: ['user'],
});
}
async findByPrefix(ctx: ServiceContext, prefix: string): Promise<ApiKey | null> {
return this.apiKeyRepository.findOne({
where: { tenantId: ctx.tenantId, keyPrefix: prefix, deletedAt: IsNull() },
relations: ['user'],
});
}
async findByUserId(ctx: ServiceContext, userId: string): Promise<ApiKey[]> {
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<PaginatedResult<ApiKey>> {
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<ApiKey | null> {
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<boolean> {
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<boolean> {
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<ApiKey | null> {
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<ApiKey | null> {
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<boolean> {
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<ApiKeyWithSecret | null> {
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<number> {
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');
}
}

View File

@ -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<T> {
data: T[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export class CompanyService {
constructor(private readonly companyRepository: Repository<Company>) {}
async create(ctx: ServiceContext, data: CreateCompanyDto): Promise<Company> {
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<Company | null> {
return this.companyRepository.findOne({
where: { id, tenantId: ctx.tenantId, deletedAt: IsNull() },
});
}
async findByCode(ctx: ServiceContext, code: string): Promise<Company | null> {
return this.companyRepository.findOne({
where: { tenantId: ctx.tenantId, code, deletedAt: IsNull() },
});
}
async findAll(ctx: ServiceContext): Promise<Company[]> {
return this.companyRepository.find({
where: { tenantId: ctx.tenantId, deletedAt: IsNull() },
order: { name: 'ASC' },
});
}
async findWithFilters(
ctx: ServiceContext,
filters: CompanyFilters,
page = 1,
limit = 50,
): Promise<PaginatedResult<Company>> {
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<Company | null> {
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<boolean> {
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<Company[]> {
return this.companyRepository.find({
where: { tenantId: ctx.tenantId, isActive: true, deletedAt: IsNull() },
order: { name: 'ASC' },
});
}
}

View File

@ -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<T> {
data: T[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export class DeviceService {
constructor(private readonly deviceRepository: Repository<Device>) {}
async create(ctx: ServiceContext, data: CreateDeviceDto): Promise<Device> {
const existing = await this.findByFingerprint(ctx, data.userId, data.fingerprint);
if (existing) {
return this.updateLastSeen(ctx, existing.id) as Promise<Device>;
}
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<Device | null> {
return this.deviceRepository.findOne({
where: { id, tenantId: ctx.tenantId, deletedAt: IsNull() },
relations: ['user'],
});
}
async findByFingerprint(ctx: ServiceContext, userId: string, fingerprint: string): Promise<Device | null> {
return this.deviceRepository.findOne({
where: {
tenantId: ctx.tenantId,
userId,
fingerprint,
deletedAt: IsNull(),
},
});
}
async findByUserId(ctx: ServiceContext, userId: string): Promise<Device[]> {
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<PaginatedResult<Device>> {
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<Device | null> {
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<Device | null> {
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<Device | null> {
return this.update(ctx, id, { isBlocked: true });
}
async unblock(ctx: ServiceContext, id: string): Promise<Device | null> {
return this.update(ctx, id, { isBlocked: false });
}
async delete(ctx: ServiceContext, id: string): Promise<boolean> {
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<number> {
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<boolean> {
const device = await this.findByFingerprint(ctx, userId, fingerprint);
return device?.isBlocked ?? false;
}
async getDeviceCount(ctx: ServiceContext, userId: string): Promise<number> {
return this.deviceRepository.count({
where: { tenantId: ctx.tenantId, userId, deletedAt: IsNull() },
});
}
async getOrCreate(ctx: ServiceContext, data: CreateDeviceDto): Promise<Device> {
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';
}
}

View File

@ -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<T> {
data: T[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export class GroupService {
constructor(
private readonly groupRepository: Repository<Group>,
private readonly userRepository: Repository<User>,
) {}
async create(ctx: ServiceContext, data: CreateGroupDto): Promise<Group> {
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<Group>;
}
async findById(ctx: ServiceContext, id: string): Promise<Group | null> {
return this.groupRepository.findOne({
where: { id, tenantId: ctx.tenantId, deletedAt: IsNull() },
relations: ['members', 'parent', 'children'],
});
}
async findByCode(ctx: ServiceContext, code: string): Promise<Group | null> {
return this.groupRepository.findOne({
where: { tenantId: ctx.tenantId, code, deletedAt: IsNull() },
relations: ['members', 'parent', 'children'],
});
}
async findAll(ctx: ServiceContext): Promise<Group[]> {
return this.groupRepository.find({
where: { tenantId: ctx.tenantId, deletedAt: IsNull() },
relations: ['members', 'parent'],
order: { name: 'ASC' },
});
}
async findRootGroups(ctx: ServiceContext): Promise<Group[]> {
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<PaginatedResult<Group>> {
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<Group | null> {
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<boolean> {
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<void> {
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<void> {
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<void> {
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<Group[]> {
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<Group[]> {
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();
}
}

View File

@ -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';

View File

@ -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<T> {
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<MfaAuditLog>,
private readonly userRepository: Repository<User>,
) {}
async setupMfa(ctx: ServiceContext, userId: string): Promise<MfaSetupResult> {
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<boolean> {
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<boolean> {
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<MfaVerifyResult> {
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<boolean> {
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<string[]> {
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<number> {
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<boolean> {
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<PaginatedResult<MfaAuditLog>> {
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<void> {
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');
}
}

View File

@ -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<T> {
data: T[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export interface OAuthStateData {
redirectUri?: string;
returnUrl?: string;
metadata?: Record<string, unknown>;
}
export class OAuthService {
constructor(
private readonly providerRepository: Repository<OAuthProvider>,
private readonly stateRepository: Repository<OAuthState>,
private readonly userLinkRepository: Repository<OAuthUserLink>,
private readonly userRepository: Repository<User>,
) {}
// Provider Management
async createProvider(ctx: ServiceContext, data: CreateOAuthProviderDto): Promise<OAuthProvider> {
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<OAuthProvider | null> {
return this.providerRepository.findOne({
where: { id, tenantId: ctx.tenantId, deletedAt: IsNull() },
});
}
async findProviderByName(ctx: ServiceContext, provider: string): Promise<OAuthProvider | null> {
return this.providerRepository.findOne({
where: { tenantId: ctx.tenantId, provider, deletedAt: IsNull() },
});
}
async findAllProviders(ctx: ServiceContext): Promise<OAuthProvider[]> {
return this.providerRepository.find({
where: { tenantId: ctx.tenantId, deletedAt: IsNull() },
order: { name: 'ASC' },
});
}
async findActiveProviders(ctx: ServiceContext): Promise<OAuthProvider[]> {
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<PaginatedResult<OAuthProvider>> {
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<OAuthProvider | null> {
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<boolean> {
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<OAuthState> {
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<OAuthState | null> {
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<OAuthState | null> {
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<string, unknown>,
): Promise<OAuthUserLink> {
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<User | null> {
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<OAuthUserLink[]> {
return this.userLinkRepository.find({
where: { tenantId: ctx.tenantId, userId },
relations: ['provider'],
});
}
async unlinkUser(ctx: ServiceContext, userId: string, providerId: string): Promise<boolean> {
const result = await this.userLinkRepository.delete({
tenantId: ctx.tenantId,
userId,
providerId,
});
return (result.affected ?? 0) > 0;
}
async cleanupExpiredStates(ctx: ServiceContext): Promise<number> {
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);
}
}

View File

@ -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<T> {
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<PasswordReset>,
private readonly userRepository: Repository<User>,
) {}
async create(ctx: ServiceContext, data: CreatePasswordResetDto): Promise<PasswordResetResult> {
// 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<PasswordResetResult | null> {
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<PasswordReset | null> {
return this.passwordResetRepository.findOne({
where: { id, tenantId: ctx.tenantId },
relations: ['user'],
});
}
async findByUserId(ctx: ServiceContext, userId: string): Promise<PasswordReset[]> {
return this.passwordResetRepository.find({
where: { tenantId: ctx.tenantId, userId },
order: { createdAt: 'DESC' },
});
}
async findWithFilters(
ctx: ServiceContext,
filters: PasswordResetFilters,
page = 1,
limit = 50,
): Promise<PaginatedResult<PasswordReset>> {
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<PasswordReset | null> {
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<PasswordReset | null> {
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<number> {
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<boolean> {
const passwordReset = await this.validateToken(ctx, token);
return passwordReset !== null;
}
async getActiveResetForUser(ctx: ServiceContext, userId: string): Promise<PasswordReset | null> {
return this.passwordResetRepository.findOne({
where: {
tenantId: ctx.tenantId,
userId,
isUsed: false,
},
order: { createdAt: 'DESC' },
});
}
async hasActiveReset(ctx: ServiceContext, userId: string): Promise<boolean> {
const reset = await this.getActiveResetForUser(ctx, userId);
if (!reset) return false;
return reset.expiresAt > new Date();
}
async cleanupExpired(ctx: ServiceContext): Promise<number> {
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');
}
}

View File

@ -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<T> {
data: T[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export class PermissionService {
constructor(private readonly permissionRepository: Repository<Permission>) {}
async create(ctx: ServiceContext, data: CreatePermissionDto): Promise<Permission> {
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<Permission | null> {
return this.permissionRepository.findOne({
where: { id, tenantId: ctx.tenantId, deletedAt: IsNull() },
});
}
async findByCode(ctx: ServiceContext, code: string): Promise<Permission | null> {
return this.permissionRepository.findOne({
where: { tenantId: ctx.tenantId, code, deletedAt: IsNull() },
});
}
async findAll(ctx: ServiceContext): Promise<Permission[]> {
return this.permissionRepository.find({
where: { tenantId: ctx.tenantId, deletedAt: IsNull() },
order: { module: 'ASC', name: 'ASC' },
});
}
async findByModule(ctx: ServiceContext, module: string): Promise<Permission[]> {
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<PaginatedResult<Permission>> {
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<Permission | null> {
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<boolean> {
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<string[]> {
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<boolean> {
const permission = await this.findByCode(ctx, code);
return permission !== null && permission.isActive;
}
async bulkCreate(ctx: ServiceContext, permissions: CreatePermissionDto[]): Promise<Permission[]> {
const created: Permission[] = [];
for (const data of permissions) {
try {
const permission = await this.create(ctx, data);
created.push(permission);
} catch {
// Skip duplicates
}
}
return created;
}
}

View File

@ -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<T> {
data: T[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export class RoleService {
constructor(
private readonly roleRepository: Repository<Role>,
private readonly permissionRepository: Repository<Permission>,
private readonly userRoleRepository: Repository<UserRole>,
) {}
async create(ctx: ServiceContext, data: CreateRoleDto): Promise<Role> {
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<Role>;
}
async findById(ctx: ServiceContext, id: string): Promise<Role | null> {
return this.roleRepository.findOne({
where: { id, tenantId: ctx.tenantId, deletedAt: IsNull() },
relations: ['permissions'],
});
}
async findByCode(ctx: ServiceContext, code: string): Promise<Role | null> {
return this.roleRepository.findOne({
where: { tenantId: ctx.tenantId, code, deletedAt: IsNull() },
relations: ['permissions'],
});
}
async findAll(ctx: ServiceContext): Promise<Role[]> {
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<PaginatedResult<Role>> {
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<Role | null> {
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<boolean> {
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<void> {
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<void> {
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<void> {
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<UserRole[]> {
return this.userRoleRepository.find({
where: { roleId, tenantId: ctx.tenantId },
relations: ['user'],
});
}
async assignRoleToUser(ctx: ServiceContext, userId: string, roleId: string): Promise<UserRole> {
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<boolean> {
const result = await this.userRoleRepository.delete({
userId,
roleId,
tenantId: ctx.tenantId,
});
return (result.affected ?? 0) > 0;
}
}

View File

@ -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<T> {
data: T[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export class SessionService {
constructor(private readonly sessionRepository: Repository<Session>) {}
async create(ctx: ServiceContext, data: CreateSessionDto): Promise<Session> {
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<Session | null> {
return this.sessionRepository.findOne({
where: { id, tenantId: ctx.tenantId },
relations: ['user', 'device'],
});
}
async findActiveByUserId(ctx: ServiceContext, userId: string): Promise<Session[]> {
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<PaginatedResult<Session>> {
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<Session | null> {
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<boolean> {
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<number> {
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<number> {
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<boolean> {
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<number> {
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<number> {
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,
};
}
}

View File

@ -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<T> {
data: T[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export class TrustedDeviceService {
constructor(private readonly trustedDeviceRepository: Repository<TrustedDevice>) {}
async create(ctx: ServiceContext, data: CreateTrustedDeviceDto): Promise<TrustedDevice> {
const existing = await this.findByUserAndDevice(ctx, data.userId, data.deviceId);
if (existing && !existing.isRevoked) {
return this.renew(ctx, existing.id) as Promise<TrustedDevice>;
}
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<TrustedDevice | null> {
return this.trustedDeviceRepository.findOne({
where: { id, tenantId: ctx.tenantId },
relations: ['user', 'device'],
});
}
async findByUserAndDevice(ctx: ServiceContext, userId: string, deviceId: string): Promise<TrustedDevice | null> {
return this.trustedDeviceRepository.findOne({
where: {
tenantId: ctx.tenantId,
userId,
deviceId,
},
relations: ['device'],
});
}
async findByUserId(ctx: ServiceContext, userId: string): Promise<TrustedDevice[]> {
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<PaginatedResult<TrustedDevice>> {
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<TrustedDevice | null> {
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<TrustedDevice | null> {
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<boolean> {
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<number> {
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<boolean> {
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<boolean> {
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<TrustLevel | null> {
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<number> {
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<TrustedDevice> {
const existing = await this.findByUserAndDevice(ctx, userId, deviceId);
if (existing && !existing.isRevoked) {
return this.renew(ctx, existing.id, options?.days ?? 30) as Promise<TrustedDevice>;
}
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),
});
}
}

View File

@ -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<T> {
data: T[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export class UserProfileService {
constructor(
private readonly profileRepository: Repository<UserProfile>,
private readonly assignmentRepository: Repository<UserProfileAssignment>,
private readonly moduleRepository: Repository<ProfileModule>,
private readonly toolRepository: Repository<ProfileTool>,
private readonly userRepository: Repository<User>,
) {}
async create(ctx: ServiceContext, data: CreateUserProfileDto): Promise<UserProfile> {
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<UserProfile>;
}
async findById(ctx: ServiceContext, id: string): Promise<UserProfile | null> {
return this.profileRepository.findOne({
where: { id, tenantId: ctx.tenantId, deletedAt: IsNull() },
relations: ['modules', 'tools'],
});
}
async findByCode(ctx: ServiceContext, code: string): Promise<UserProfile | null> {
return this.profileRepository.findOne({
where: { tenantId: ctx.tenantId, code, deletedAt: IsNull() },
relations: ['modules', 'tools'],
});
}
async findAll(ctx: ServiceContext): Promise<UserProfile[]> {
return this.profileRepository.find({
where: { tenantId: ctx.tenantId, deletedAt: IsNull() },
relations: ['modules', 'tools'],
order: { name: 'ASC' },
});
}
async findDefault(ctx: ServiceContext): Promise<UserProfile | null> {
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<PaginatedResult<UserProfile>> {
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<UserProfile | null> {
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<boolean> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<UserProfileAssignment> {
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<boolean> {
const result = await this.assignmentRepository.delete({
tenantId: ctx.tenantId,
userId,
profileId,
});
return (result.affected ?? 0) > 0;
}
async getUserProfiles(ctx: ServiceContext, userId: string): Promise<UserProfile[]> {
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<User[]> {
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<void> {
await this.profileRepository.update(
{ tenantId: ctx.tenantId, isDefault: true },
{ isDefault: false },
);
}
}

View File

@ -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<T> {
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<VerificationCode>,
private readonly userRepository: Repository<User>,
) {}
async create(ctx: ServiceContext, data: CreateVerificationCodeDto): Promise<VerificationResult> {
// 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<VerificationResult> {
return this.create(ctx, {
userId,
type: CodeType.EMAIL,
destination: email,
});
}
async createSmsVerification(ctx: ServiceContext, userId: string, phone: string): Promise<VerificationResult> {
return this.create(ctx, {
userId,
type: CodeType.SMS,
destination: phone,
expiresInMinutes: 5, // Shorter for SMS
});
}
async createMfaVerification(ctx: ServiceContext, userId: string, destination: string): Promise<VerificationResult> {
return this.create(ctx, {
userId,
type: CodeType.MFA,
destination,
expiresInMinutes: 5,
});
}
async findById(ctx: ServiceContext, id: string): Promise<VerificationCode | null> {
return this.verificationRepository.findOne({
where: { id, tenantId: ctx.tenantId },
relations: ['user'],
});
}
async findActiveByUserId(ctx: ServiceContext, userId: string, type?: CodeType): Promise<VerificationCode | null> {
const where: Record<string, unknown> = {
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<PaginatedResult<VerificationCode>> {
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<number> {
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<VerificationResult | null> {
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<number> {
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;
}
}
}

View File

@ -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<T> {
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<DiscountRule>) {}
async create(ctx: ServiceContext, data: CreateDiscountRuleDto): Promise<DiscountRule> {
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<DiscountRule | null> {
return this.discountRuleRepository.findOne({
where: { id, tenantId: ctx.tenantId, deletedAt: IsNull() },
});
}
async findByCode(ctx: ServiceContext, code: string): Promise<DiscountRule | null> {
return this.discountRuleRepository.findOne({
where: { tenantId: ctx.tenantId, code, deletedAt: IsNull() },
});
}
async findAll(ctx: ServiceContext): Promise<DiscountRule[]> {
return this.discountRuleRepository.find({
where: { tenantId: ctx.tenantId, deletedAt: IsNull() },
order: { priority: 'ASC', name: 'ASC' },
});
}
async findActive(ctx: ServiceContext, date?: Date): Promise<DiscountRule[]> {
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<PaginatedResult<DiscountRule>> {
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<DiscountRule | null> {
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<boolean> {
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<DiscountRule | null> {
return this.update(ctx, id, { isActive: true });
}
async deactivate(ctx: ServiceContext, id: string): Promise<DiscountRule | null> {
return this.update(ctx, id, { isActive: false });
}
// Business Logic Methods
async isApplicable(ctx: ServiceContext, ruleId: string, context: ApplicabilityContext): Promise<boolean> {
const rule = await this.findById(ctx, ruleId);
if (!rule) return false;
return this.checkApplicability(rule, context);
}
async getApplicableRules(ctx: ServiceContext, context: ApplicabilityContext): Promise<DiscountRule[]> {
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<DiscountCalculation | null> {
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<DiscountCalculation | null> {
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<DiscountRule | null> {
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<DiscountRule | null> {
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<number | null> {
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;
}
}
}

View File

@ -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';