[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:
parent
e2d446181c
commit
3722355360
261
src/modules/auth/services/api-key.service.ts
Normal file
261
src/modules/auth/services/api-key.service.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
186
src/modules/auth/services/company.service.ts
Normal file
186
src/modules/auth/services/company.service.ts
Normal 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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
222
src/modules/auth/services/device.service.ts
Normal file
222
src/modules/auth/services/device.service.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
266
src/modules/auth/services/group.service.ts
Normal file
266
src/modules/auth/services/group.service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@ -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';
|
||||
|
||||
353
src/modules/auth/services/mfa.service.ts
Normal file
353
src/modules/auth/services/mfa.service.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
304
src/modules/auth/services/oauth.service.ts
Normal file
304
src/modules/auth/services/oauth.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
258
src/modules/auth/services/password-reset.service.ts
Normal file
258
src/modules/auth/services/password-reset.service.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
208
src/modules/auth/services/permission.service.ts
Normal file
208
src/modules/auth/services/permission.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
239
src/modules/auth/services/role.service.ts
Normal file
239
src/modules/auth/services/role.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
225
src/modules/auth/services/session.service.ts
Normal file
225
src/modules/auth/services/session.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
253
src/modules/auth/services/trusted-device.service.ts
Normal file
253
src/modules/auth/services/trusted-device.service.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
}
|
||||
335
src/modules/auth/services/user-profile.service.ts
Normal file
335
src/modules/auth/services/user-profile.service.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
317
src/modules/auth/services/verification.service.ts
Normal file
317
src/modules/auth/services/verification.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
474
src/modules/core/services/discount-rule.service.ts
Normal file
474
src/modules/core/services/discount-rule.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user