From 17afade5c5501852e67b0d47e423dda3aed3905f Mon Sep 17 00:00:00 2001 From: Chinonso-Peter Date: Wed, 22 Apr 2026 23:20:22 +0100 Subject: [PATCH 1/3] feat/Soft Delete Implementation --- .codex | 0 .../entities/experiment-variant.entity.ts | 4 ++ .../experiments/experiment.service.ts | 2 +- src/assessment/assessments.service.ts | 16 ++++- src/assessment/entities/assessment.entity.ts | 12 +++- src/assessment/entities/question.entity.ts | 5 +- src/backup/entities/backup-record.entity.ts | 4 ++ .../processing/backup-queue.processor.ts | 2 +- src/courses/courses.service.ts | 21 +++++- src/courses/entities/course-module.entity.ts | 13 +++- src/courses/entities/course.entity.ts | 4 ++ src/courses/entities/lesson.entity.ts | 12 +++- src/courses/lessons/lessons.service.ts | 4 +- src/courses/modules/modules.service.ts | 6 +- .../automation/automation.service.ts | 10 ++- .../email-marketing.service.ts | 5 +- .../entities/automation-action.entity.ts | 12 +++- .../entities/automation-trigger.entity.ts | 12 +++- .../entities/automation-workflow.entity.ts | 4 ++ .../entities/campaign-recipient.entity.ts | 13 +++- .../entities/campaign.entity.ts | 4 ++ .../entities/email-template.entity.ts | 4 ++ .../entities/segment-rule.entity.ts | 12 +++- .../entities/segment.entity.ts | 4 ++ .../segmentation/segmentation.service.ts | 9 ++- .../templates/template-management.service.ts | 4 +- .../entities/translation.entity.ts | 4 ++ src/localization/localization.service.ts | 68 ++++++++++++++----- .../entities/notification.entity.ts | 4 ++ src/notifications/notifications.service.ts | 2 +- src/tenancy/entities/tenant-billing.entity.ts | 4 ++ src/tenancy/entities/tenant-config.entity.ts | 4 ++ .../entities/tenant-customization.entity.ts | 4 ++ src/tenancy/entities/tenant.entity.ts | 4 ++ src/tenancy/tenancy.service.ts | 11 ++- src/users/entities/user.entity.ts | 4 ++ src/users/users.service.ts | 5 +- 37 files changed, 265 insertions(+), 47 deletions(-) create mode 100644 .codex diff --git a/.codex b/.codex new file mode 100644 index 00000000..e69de29b diff --git a/src/ab-testing/entities/experiment-variant.entity.ts b/src/ab-testing/entities/experiment-variant.entity.ts index b6bb38cf..a092681c 100644 --- a/src/ab-testing/entities/experiment-variant.entity.ts +++ b/src/ab-testing/entities/experiment-variant.entity.ts @@ -4,6 +4,7 @@ import { PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, + DeleteDateColumn, ManyToOne, OneToMany, } from 'typeorm'; @@ -39,6 +40,9 @@ export class ExperimentVariant { @UpdateDateColumn() updatedAt: Date; + @DeleteDateColumn() + deletedAt?: Date; + @ManyToOne(() => Experiment, (experiment) => experiment.variants) experiment: Experiment; diff --git a/src/ab-testing/experiments/experiment.service.ts b/src/ab-testing/experiments/experiment.service.ts index 6e307f12..dac99762 100644 --- a/src/ab-testing/experiments/experiment.service.ts +++ b/src/ab-testing/experiments/experiment.service.ts @@ -73,7 +73,7 @@ export class ExperimentService { */ async removeVariant(variantId: string): Promise { this.logger.log(`Removing variant: ${variantId}`); - await this.variantRepository.delete(variantId); + await this.variantRepository.softDelete(variantId); this.logger.log(`Variant removed: ${variantId}`); } diff --git a/src/assessment/assessments.service.ts b/src/assessment/assessments.service.ts index 4adcd092..33f16a24 100644 --- a/src/assessment/assessments.service.ts +++ b/src/assessment/assessments.service.ts @@ -7,6 +7,7 @@ import { AssessmentAttempt } from './entities/assessment-attempt.entity'; import { FeedbackGenerationService } from './feedback/feedback-generation.service'; import { Answer } from './entities/answer.entity'; import { ScoreCalculationService } from './scoring/score-calculation.service'; +import { Question } from './entities/question.entity'; @Injectable() export class AssessmentsService { @@ -65,7 +66,20 @@ export class AssessmentsService { } async remove(id: string): Promise { - await this.assessmentRepo.delete(id); + const assessment = await this.findOne(id); + if (!assessment) { + return; + } + + await this.assessmentRepo.manager.transaction(async (manager) => { + await manager + .getRepository(Question) + .createQueryBuilder() + .softDelete() + .where(`"assessmentId" = :assessmentId`, { assessmentId: id }) + .execute(); + await manager.getRepository(Assessment).softDelete(id); + }); } async submitAssessment(attemptId: string, answers: any[]) { diff --git a/src/assessment/entities/assessment.entity.ts b/src/assessment/entities/assessment.entity.ts index 5612f609..fb51befa 100644 --- a/src/assessment/entities/assessment.entity.ts +++ b/src/assessment/entities/assessment.entity.ts @@ -1,4 +1,11 @@ -import { Column, CreateDateColumn, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm'; +import { + Column, + CreateDateColumn, + DeleteDateColumn, + Entity, + OneToMany, + PrimaryGeneratedColumn, +} from 'typeorm'; import { Question } from './question.entity'; @Entity() @@ -22,4 +29,7 @@ export class Assessment { @CreateDateColumn() createdAt: Date; + + @DeleteDateColumn() + deletedAt?: Date; } diff --git a/src/assessment/entities/question.entity.ts b/src/assessment/entities/question.entity.ts index 8dc1b753..4f57fc14 100644 --- a/src/assessment/entities/question.entity.ts +++ b/src/assessment/entities/question.entity.ts @@ -1,4 +1,4 @@ -import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; +import { Column, DeleteDateColumn, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; import { QuestionType } from '../enums/question-type.enum'; import { Assessment } from './assessment.entity'; @@ -26,4 +26,7 @@ export class Question { onDelete: 'CASCADE', }) assessment: Assessment; + + @DeleteDateColumn() + deletedAt?: Date; } diff --git a/src/backup/entities/backup-record.entity.ts b/src/backup/entities/backup-record.entity.ts index 35d9797b..845cf4ab 100644 --- a/src/backup/entities/backup-record.entity.ts +++ b/src/backup/entities/backup-record.entity.ts @@ -4,6 +4,7 @@ import { Column, CreateDateColumn, UpdateDateColumn, + DeleteDateColumn, Index, } from 'typeorm'; import { BackupStatus } from '../enums/backup-status.enum'; @@ -87,4 +88,7 @@ export class BackupRecord { @UpdateDateColumn() updatedAt: Date; + + @DeleteDateColumn() + deletedAt?: Date; } diff --git a/src/backup/processing/backup-queue.processor.ts b/src/backup/processing/backup-queue.processor.ts index ee9f44d6..e5cdb61f 100644 --- a/src/backup/processing/backup-queue.processor.ts +++ b/src/backup/processing/backup-queue.processor.ts @@ -229,7 +229,7 @@ export class BackupQueueProcessor { } // Delete from database - await this.backupRepository.remove(backup); + await this.backupRepository.softRemove(backup); this.logger.log(`Backup ${backupRecordId} deleted successfully`); } catch (error) { diff --git a/src/courses/courses.service.ts b/src/courses/courses.service.ts index b5232c34..9d47065d 100644 --- a/src/courses/courses.service.ts +++ b/src/courses/courses.service.ts @@ -1,6 +1,6 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { In, Repository } from 'typeorm'; import { Course } from './entities/course.entity'; import { UpdateCourseDto } from './dto/update-course.dto'; import { @@ -15,6 +15,8 @@ import { CacheInvalidationService } from '../caching/invalidation/invalidation.s import { CACHE_TTL, CACHE_PREFIXES, CACHE_EVENTS } from '../caching/caching.constants'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { sanitizeSqlLike, enforceWhitelistedValue } from '../common/utils/sanitization.utils'; +import { CourseModule } from './entities/course-module.entity'; +import { Lesson } from './entities/lesson.entity'; @Injectable() export class CoursesService { @@ -181,11 +183,24 @@ export class CoursesService { } async remove(id: string): Promise { - const course = await this.coursesRepository.findOne({ where: { id } }); + const course = await this.coursesRepository.findOne({ + where: { id }, + relations: ['modules'], + }); if (!course) { throw new NotFoundException(`Course with ID ${id} not found`); } - await this.coursesRepository.remove(course); + + await this.coursesRepository.manager.transaction(async (manager) => { + const moduleIds = course.modules.map((module) => module.id); + + if (moduleIds.length > 0) { + await manager.getRepository(Lesson).softDelete({ moduleId: In(moduleIds) }); + } + + await manager.getRepository(CourseModule).softDelete({ courseId: id }); + await manager.getRepository(Course).softDelete(id); + }); // Invalidate cache after delete this.eventEmitter.emit(CACHE_EVENTS.COURSE_DELETED, { courseId: id }); diff --git a/src/courses/entities/course-module.entity.ts b/src/courses/entities/course-module.entity.ts index 3d3d7a42..ef52a3e9 100644 --- a/src/courses/entities/course-module.entity.ts +++ b/src/courses/entities/course-module.entity.ts @@ -1,4 +1,12 @@ -import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, OneToMany, Index } from 'typeorm'; +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + OneToMany, + Index, + DeleteDateColumn, +} from 'typeorm'; import { Course } from './course.entity'; import { Lesson } from './lesson.entity'; @@ -22,4 +30,7 @@ export class CourseModule { @OneToMany(() => Lesson, (lesson) => lesson.module) lessons: Lesson[]; + + @DeleteDateColumn() + deletedAt?: Date; } diff --git a/src/courses/entities/course.entity.ts b/src/courses/entities/course.entity.ts index 170e0a9b..43eb7400 100644 --- a/src/courses/entities/course.entity.ts +++ b/src/courses/entities/course.entity.ts @@ -4,6 +4,7 @@ import { Column, CreateDateColumn, UpdateDateColumn, + DeleteDateColumn, ManyToOne, OneToMany, Index, @@ -51,4 +52,7 @@ export class Course { @UpdateDateColumn() updatedAt: Date; + + @DeleteDateColumn() + deletedAt?: Date; } diff --git a/src/courses/entities/lesson.entity.ts b/src/courses/entities/lesson.entity.ts index 03ea7d86..564d1bae 100644 --- a/src/courses/entities/lesson.entity.ts +++ b/src/courses/entities/lesson.entity.ts @@ -1,4 +1,11 @@ -import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, Index } from 'typeorm'; +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + Index, + DeleteDateColumn, +} from 'typeorm'; import { CourseModule } from './course-module.entity'; @Entity() @@ -27,4 +34,7 @@ export class Lesson { @Column({ name: 'module_id' }) @Index() moduleId: string; + + @DeleteDateColumn() + deletedAt?: Date; } diff --git a/src/courses/lessons/lessons.service.ts b/src/courses/lessons/lessons.service.ts index 08dfe223..233879df 100644 --- a/src/courses/lessons/lessons.service.ts +++ b/src/courses/lessons/lessons.service.ts @@ -42,7 +42,7 @@ export class LessonsService { } async remove(id: string): Promise { - const lesson = await this.findOne(id); - await this.lessonsRepository.remove(lesson); + await this.findOne(id); + await this.lessonsRepository.softDelete(id); } } diff --git a/src/courses/modules/modules.service.ts b/src/courses/modules/modules.service.ts index af1781ba..46ad69fe 100644 --- a/src/courses/modules/modules.service.ts +++ b/src/courses/modules/modules.service.ts @@ -4,6 +4,7 @@ import { Repository } from 'typeorm'; import { CourseModule } from '../entities/course-module.entity'; import { CreateModuleDto } from '../dto/create-module.dto'; import { Course } from '../entities/course.entity'; +import { Lesson } from '../entities/lesson.entity'; @Injectable() export class ModulesService { @@ -51,6 +52,9 @@ export class ModulesService { async remove(id: string): Promise { const module = await this.findOne(id); - await this.modulesRepository.remove(module); + await this.modulesRepository.manager.transaction(async (manager) => { + await manager.getRepository(Lesson).softDelete({ moduleId: module.id }); + await manager.getRepository(CourseModule).softDelete(module.id); + }); } } diff --git a/src/email-marketing/automation/automation.service.ts b/src/email-marketing/automation/automation.service.ts index bc118e9e..06281fc5 100644 --- a/src/email-marketing/automation/automation.service.ts +++ b/src/email-marketing/automation/automation.service.ts @@ -129,7 +129,7 @@ export class AutomationService { // Update triggers if provided if (updateAutomationDto.triggers) { - await this.triggerRepository.delete({ workflowId: id }); + await this.triggerRepository.softDelete({ workflowId: id }); const triggers = updateAutomationDto.triggers.map((trigger) => this.triggerRepository.create({ ...trigger, @@ -141,7 +141,7 @@ export class AutomationService { // Update actions if provided if (updateAutomationDto.actions) { - await this.actionRepository.delete({ workflowId: id }); + await this.actionRepository.softDelete({ workflowId: id }); const actions = updateAutomationDto.actions.map((action, index) => this.actionRepository.create({ ...action, @@ -165,7 +165,11 @@ export class AutomationService { throw new BadRequestException('Deactivate workflow before deleting'); } - await this.workflowRepository.remove(workflow); + await this.workflowRepository.manager.transaction(async (manager) => { + await manager.getRepository(AutomationTrigger).softDelete({ workflowId: id }); + await manager.getRepository(AutomationAction).softDelete({ workflowId: id }); + await manager.getRepository(AutomationWorkflow).softDelete(id); + }); } /** diff --git a/src/email-marketing/email-marketing.service.ts b/src/email-marketing/email-marketing.service.ts index e6b19ac8..0d363a27 100644 --- a/src/email-marketing/email-marketing.service.ts +++ b/src/email-marketing/email-marketing.service.ts @@ -123,7 +123,10 @@ export class EmailMarketingService { throw new BadRequestException('Cannot delete a campaign that is currently sending'); } - await this.campaignRepository.remove(campaign); + await this.campaignRepository.manager.transaction(async (manager) => { + await manager.getRepository(CampaignRecipient).softDelete({ campaignId: id }); + await manager.getRepository(Campaign).softDelete(id); + }); } /** diff --git a/src/email-marketing/entities/automation-action.entity.ts b/src/email-marketing/entities/automation-action.entity.ts index 5312ccaf..f67c399b 100644 --- a/src/email-marketing/entities/automation-action.entity.ts +++ b/src/email-marketing/entities/automation-action.entity.ts @@ -1,4 +1,11 @@ -import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm'; +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, + DeleteDateColumn, +} from 'typeorm'; import { ApiProperty } from '@nestjs/swagger'; import { AutomationWorkflow } from './automation-workflow.entity'; @@ -33,4 +40,7 @@ export class AutomationAction { @ApiProperty({ required: false }) @Column({ type: 'text', nullable: true }) description?: string; + + @DeleteDateColumn() + deletedAt?: Date; } diff --git a/src/email-marketing/entities/automation-trigger.entity.ts b/src/email-marketing/entities/automation-trigger.entity.ts index e3a0a13e..be89e55d 100644 --- a/src/email-marketing/entities/automation-trigger.entity.ts +++ b/src/email-marketing/entities/automation-trigger.entity.ts @@ -1,4 +1,11 @@ -import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm'; +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, + DeleteDateColumn, +} from 'typeorm'; import { ApiProperty } from '@nestjs/swagger'; import { AutomationWorkflow } from './automation-workflow.entity'; @@ -29,4 +36,7 @@ export class AutomationTrigger { @ApiProperty({ required: false }) @Column({ type: 'text', nullable: true }) description?: string; + + @DeleteDateColumn() + deletedAt?: Date; } diff --git a/src/email-marketing/entities/automation-workflow.entity.ts b/src/email-marketing/entities/automation-workflow.entity.ts index ddecc7b6..5de35511 100644 --- a/src/email-marketing/entities/automation-workflow.entity.ts +++ b/src/email-marketing/entities/automation-workflow.entity.ts @@ -4,6 +4,7 @@ import { Column, CreateDateColumn, UpdateDateColumn, + DeleteDateColumn, OneToMany, } from 'typeorm'; import { ApiProperty } from '@nestjs/swagger'; @@ -59,4 +60,7 @@ export class AutomationWorkflow { @ApiProperty() @UpdateDateColumn() updatedAt: Date; + + @DeleteDateColumn() + deletedAt?: Date; } diff --git a/src/email-marketing/entities/campaign-recipient.entity.ts b/src/email-marketing/entities/campaign-recipient.entity.ts index 3c5ac387..4f637470 100644 --- a/src/email-marketing/entities/campaign-recipient.entity.ts +++ b/src/email-marketing/entities/campaign-recipient.entity.ts @@ -1,4 +1,12 @@ -import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, Index } from 'typeorm'; +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, + Index, + DeleteDateColumn, +} from 'typeorm'; import { ApiProperty } from '@nestjs/swagger'; import { Campaign } from './campaign.entity'; @@ -38,4 +46,7 @@ export class CampaignRecipient { @ApiProperty({ required: false }) @Column({ nullable: true }) variantId?: string; + + @DeleteDateColumn() + deletedAt?: Date; } diff --git a/src/email-marketing/entities/campaign.entity.ts b/src/email-marketing/entities/campaign.entity.ts index 6577ee9a..79adbd31 100644 --- a/src/email-marketing/entities/campaign.entity.ts +++ b/src/email-marketing/entities/campaign.entity.ts @@ -4,6 +4,7 @@ import { Column, CreateDateColumn, UpdateDateColumn, + DeleteDateColumn, ManyToOne, OneToMany, OneToOne, @@ -79,4 +80,7 @@ export class Campaign { @ApiProperty() @UpdateDateColumn() updatedAt: Date; + + @DeleteDateColumn() + deletedAt?: Date; } diff --git a/src/email-marketing/entities/email-template.entity.ts b/src/email-marketing/entities/email-template.entity.ts index 1429f8a3..3e3fbd94 100644 --- a/src/email-marketing/entities/email-template.entity.ts +++ b/src/email-marketing/entities/email-template.entity.ts @@ -4,6 +4,7 @@ import { Column, CreateDateColumn, UpdateDateColumn, + DeleteDateColumn, } from 'typeorm'; import { ApiProperty } from '@nestjs/swagger'; @@ -52,4 +53,7 @@ export class EmailTemplate { @ApiProperty() @UpdateDateColumn() updatedAt: Date; + + @DeleteDateColumn() + deletedAt?: Date; } diff --git a/src/email-marketing/entities/segment-rule.entity.ts b/src/email-marketing/entities/segment-rule.entity.ts index 3e6bbb19..51b86cc1 100644 --- a/src/email-marketing/entities/segment-rule.entity.ts +++ b/src/email-marketing/entities/segment-rule.entity.ts @@ -1,4 +1,11 @@ -import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm'; +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, + DeleteDateColumn, +} from 'typeorm'; import { ApiProperty } from '@nestjs/swagger'; import { Segment } from './segment.entity'; @@ -38,4 +45,7 @@ export class SegmentRule { @ApiProperty({ default: 'AND' }) @Column({ default: 'AND' }) logicalOperator: 'AND' | 'OR'; + + @DeleteDateColumn() + deletedAt?: Date; } diff --git a/src/email-marketing/entities/segment.entity.ts b/src/email-marketing/entities/segment.entity.ts index da702707..338e4acd 100644 --- a/src/email-marketing/entities/segment.entity.ts +++ b/src/email-marketing/entities/segment.entity.ts @@ -4,6 +4,7 @@ import { Column, CreateDateColumn, UpdateDateColumn, + DeleteDateColumn, OneToMany, } from 'typeorm'; import { ApiProperty } from '@nestjs/swagger'; @@ -45,4 +46,7 @@ export class Segment { @ApiProperty() @UpdateDateColumn() updatedAt: Date; + + @DeleteDateColumn() + deletedAt?: Date; } diff --git a/src/email-marketing/segmentation/segmentation.service.ts b/src/email-marketing/segmentation/segmentation.service.ts index 60aec6e3..6ecc1769 100644 --- a/src/email-marketing/segmentation/segmentation.service.ts +++ b/src/email-marketing/segmentation/segmentation.service.ts @@ -133,7 +133,7 @@ export class SegmentationService { // Update rules if provided if (updateSegmentDto.rules) { - await this.ruleRepository.delete({ segmentId: id }); + await this.ruleRepository.softDelete({ segmentId: id }); const rules = updateSegmentDto.rules.map((rule, index) => this.ruleRepository.create({ ...rule, @@ -151,8 +151,11 @@ export class SegmentationService { * Delete a segment */ async remove(id: string): Promise { - const segment = await this.findOne(id); - await this.segmentRepository.remove(segment); + await this.findOne(id); + await this.segmentRepository.manager.transaction(async (manager) => { + await manager.getRepository(SegmentRule).softDelete({ segmentId: id }); + await manager.getRepository(Segment).softDelete(id); + }); } /** diff --git a/src/email-marketing/templates/template-management.service.ts b/src/email-marketing/templates/template-management.service.ts index f9e649cc..eb27d9b6 100644 --- a/src/email-marketing/templates/template-management.service.ts +++ b/src/email-marketing/templates/template-management.service.ts @@ -85,8 +85,8 @@ export class TemplateManagementService { * Delete a template */ async remove(id: string): Promise { - const template = await this.findOne(id); - await this.templateRepository.remove(template); + await this.findOne(id); + await this.templateRepository.softDelete(id); } /** diff --git a/src/localization/entities/translation.entity.ts b/src/localization/entities/translation.entity.ts index 995212ad..d9929398 100644 --- a/src/localization/entities/translation.entity.ts +++ b/src/localization/entities/translation.entity.ts @@ -4,6 +4,7 @@ import { Column, CreateDateColumn, UpdateDateColumn, + DeleteDateColumn, Index, Unique, } from 'typeorm'; @@ -32,4 +33,7 @@ export class Translation { @UpdateDateColumn() updatedAt: Date; + + @DeleteDateColumn() + deletedAt?: Date; } diff --git a/src/localization/localization.service.ts b/src/localization/localization.service.ts index 6154faa1..8482e2db 100644 --- a/src/localization/localization.service.ts +++ b/src/localization/localization.service.ts @@ -149,10 +149,31 @@ export class LocalizationService { } async create(dto: CreateTranslationDto): Promise { + const namespace = dto.namespace.trim(); + const key = dto.key.trim(); + const locale = languageDetectionNormalize(dto.locale); + const existing = await this.translationRepo.findOne({ + where: { namespace, translationKey: key, locale }, + withDeleted: true, + }); + + if (existing) { + if (!existing.deletedAt) { + throw new ConflictException('Translation already exists for this namespace, key, and locale'); + } + + existing.value = dto.value; + existing.deletedAt = null; + + const restored = await this.translationRepo.save(existing); + await this.invalidateBundles([{ namespace: restored.namespace, locale: restored.locale }]); + return this.toItem(restored); + } + const entity = this.translationRepo.create({ - namespace: dto.namespace.trim(), - translationKey: dto.key.trim(), - locale: languageDetectionNormalize(dto.locale), + namespace, + translationKey: key, + locale, value: dto.value, }); try { @@ -228,7 +249,7 @@ export class LocalizationService { async remove(id: string): Promise { const row = await this.translationRepo.findOne({ where: { id } }); if (!row) throw new NotFoundException('Translation not found'); - await this.translationRepo.remove(row); + await this.translationRepo.softRemove(row); await this.invalidateBundles([{ namespace: row.namespace, locale: row.locale }]); } @@ -236,18 +257,33 @@ export class LocalizationService { if (!rows?.length) { throw new BadRequestException('Import payload must contain at least one row'); } - const entities = rows.map((r) => - this.translationRepo.create({ - namespace: r.namespace.trim(), - translationKey: r.key.trim(), - locale: languageDetectionNormalize(r.locale), - value: r.value, - }), - ); - await this.translationRepo.upsert(entities, { - conflictPaths: ['namespace', 'translationKey', 'locale'], - skipUpdateIfNoValuesChanged: true, - }); + + for (const row of rows) { + const namespace = row.namespace.trim(); + const translationKey = row.key.trim(); + const locale = languageDetectionNormalize(row.locale); + const existing = await this.translationRepo.findOne({ + where: { namespace, translationKey, locale }, + withDeleted: true, + }); + + if (existing) { + existing.value = row.value; + existing.deletedAt = null; + await this.translationRepo.save(existing); + continue; + } + + await this.translationRepo.save( + this.translationRepo.create({ + namespace, + translationKey, + locale, + value: row.value, + }), + ); + } + const pairs = rows.map((r) => ({ namespace: r.namespace.trim(), locale: languageDetectionNormalize(r.locale), diff --git a/src/notifications/entities/notification.entity.ts b/src/notifications/entities/notification.entity.ts index 9fe558fd..d99d27a6 100644 --- a/src/notifications/entities/notification.entity.ts +++ b/src/notifications/entities/notification.entity.ts @@ -4,6 +4,7 @@ import { Column, CreateDateColumn, UpdateDateColumn, + DeleteDateColumn, ManyToOne, Index, } from 'typeorm'; @@ -70,4 +71,7 @@ export class Notification { @UpdateDateColumn() @Index() updatedAt: Date; + + @DeleteDateColumn() + deletedAt?: Date; } diff --git a/src/notifications/notifications.service.ts b/src/notifications/notifications.service.ts index 9d57412b..3fe5a128 100644 --- a/src/notifications/notifications.service.ts +++ b/src/notifications/notifications.service.ts @@ -182,7 +182,7 @@ export class NotificationsService { * Delete a notification */ async remove(id: string, userId: string): Promise { - const result = await this.notificationRepository.delete({ id, userId }); + const result = await this.notificationRepository.softDelete({ id, userId }); if (result.affected === 0) { throw new NotFoundException(`Notification with ID ${id} not found`); diff --git a/src/tenancy/entities/tenant-billing.entity.ts b/src/tenancy/entities/tenant-billing.entity.ts index 36b183a9..db40a3a3 100644 --- a/src/tenancy/entities/tenant-billing.entity.ts +++ b/src/tenancy/entities/tenant-billing.entity.ts @@ -4,6 +4,7 @@ import { Column, CreateDateColumn, UpdateDateColumn, + DeleteDateColumn, ManyToOne, JoinColumn, Index, @@ -85,4 +86,7 @@ export class TenantBilling { @UpdateDateColumn() updatedAt: Date; + + @DeleteDateColumn() + deletedAt?: Date; } diff --git a/src/tenancy/entities/tenant-config.entity.ts b/src/tenancy/entities/tenant-config.entity.ts index 7f6bd3ea..bbb7767b 100644 --- a/src/tenancy/entities/tenant-config.entity.ts +++ b/src/tenancy/entities/tenant-config.entity.ts @@ -4,6 +4,7 @@ import { Column, CreateDateColumn, UpdateDateColumn, + DeleteDateColumn, ManyToOne, JoinColumn, Index, @@ -79,4 +80,7 @@ export class TenantConfig { @UpdateDateColumn() updatedAt: Date; + + @DeleteDateColumn() + deletedAt?: Date; } diff --git a/src/tenancy/entities/tenant-customization.entity.ts b/src/tenancy/entities/tenant-customization.entity.ts index 7af6973d..0a710d84 100644 --- a/src/tenancy/entities/tenant-customization.entity.ts +++ b/src/tenancy/entities/tenant-customization.entity.ts @@ -4,6 +4,7 @@ import { Column, CreateDateColumn, UpdateDateColumn, + DeleteDateColumn, ManyToOne, JoinColumn, Index, @@ -99,4 +100,7 @@ export class TenantCustomization { @UpdateDateColumn() updatedAt: Date; + + @DeleteDateColumn() + deletedAt?: Date; } diff --git a/src/tenancy/entities/tenant.entity.ts b/src/tenancy/entities/tenant.entity.ts index 8f6226b1..a1a6647a 100644 --- a/src/tenancy/entities/tenant.entity.ts +++ b/src/tenancy/entities/tenant.entity.ts @@ -4,6 +4,7 @@ import { Column, CreateDateColumn, UpdateDateColumn, + DeleteDateColumn, Index, } from 'typeorm'; @@ -94,4 +95,7 @@ export class Tenant { @UpdateDateColumn() updatedAt: Date; + + @DeleteDateColumn() + deletedAt?: Date; } diff --git a/src/tenancy/tenancy.service.ts b/src/tenancy/tenancy.service.ts index 9d5ef007..f39e2728 100644 --- a/src/tenancy/tenancy.service.ts +++ b/src/tenancy/tenancy.service.ts @@ -31,6 +31,7 @@ export class TenancyService { // Check if slug already exists const existingTenant = await this.tenantRepository.findOne({ where: { slug: createTenantDto.slug }, + withDeleted: true, }); if (existingTenant) { @@ -125,8 +126,14 @@ export class TenancyService { * Delete tenant */ async remove(id: string): Promise { - const tenant = await this.findOne(id); - await this.tenantRepository.remove(tenant); + await this.findOne(id); + + await this.tenantRepository.manager.transaction(async (manager) => { + await manager.getRepository(TenantConfig).softDelete({ tenantId: id }); + await manager.getRepository(TenantBilling).softDelete({ tenantId: id }); + await manager.getRepository(TenantCustomization).softDelete({ tenantId: id }); + await manager.getRepository(Tenant).softDelete(id); + }); } /** diff --git a/src/users/entities/user.entity.ts b/src/users/entities/user.entity.ts index f27e99d2..a419d4c2 100644 --- a/src/users/entities/user.entity.ts +++ b/src/users/entities/user.entity.ts @@ -4,6 +4,7 @@ import { Column, CreateDateColumn, UpdateDateColumn, + DeleteDateColumn, Index, OneToMany, } from 'typeorm'; @@ -100,4 +101,7 @@ export class User { @UpdateDateColumn() updatedAt: Date; + + @DeleteDateColumn() + deletedAt?: Date; } diff --git a/src/users/users.service.ts b/src/users/users.service.ts index b3655186..8f539532 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -27,6 +27,7 @@ export class UsersService { // Check if user already exists const existingUser = await this.userRepository.findOne({ where: { email: createUserDto.email }, + withDeleted: true, }); ensureUserDoesNotExist(existingUser, 'User with this email already exists'); @@ -185,8 +186,8 @@ export class UsersService { } async remove(id: string): Promise { - const user = await this.findUserOrThrow(id); - await this.userRepository.remove(user); + await this.findUserOrThrow(id); + await this.userRepository.softDelete(id); // Invalidate cache after delete this.eventEmitter.emit(CACHE_EVENTS.USER_DELETED, { userId: id }); From 2bcf3d43141bfbb8bffeaed13cc95db5fa4c8f94 Mon Sep 17 00:00:00 2001 From: Chinonso-Peter Date: Wed, 22 Apr 2026 23:30:21 +0100 Subject: [PATCH 2/3] feat/API Versioning Strategy --- README.md | 16 ++ src/app.controller.ts | 3 +- src/app.module.ts | 2 + .../api-version.interceptor.spec.ts | 32 +++ .../interceptors/api-version.interceptor.ts | 225 ++++-------------- src/common/modules/api-versioning.module.ts | 111 +++++++-- src/config/env.validation.ts | 7 + src/config/swagger.config.ts | 3 +- src/health/health.controller.ts | 3 +- src/main.ts | 19 +- src/monitoring/monitoring.controller.ts | 3 +- src/payments/webhooks/webhook.controller.ts | 3 + 12 files changed, 221 insertions(+), 206 deletions(-) create mode 100644 src/common/interceptors/api-version.interceptor.spec.ts diff --git a/README.md b/README.md index f05395db..b53a25fd 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,22 @@ TeachLink Backend provides secure and scalable APIs to power features such as: - ๐Ÿ“Š Analytics and activity insights - ๐Ÿงพ DAO integration for content moderation and governance +## ๐Ÿ”€ API Versioning + +TeachLink uses a header-based API versioning strategy for application endpoints. + +- Send `X-API-Version: 1` with every versioned API request. +- Supported versions are configured through `API_SUPPORTED_VERSIONS` and default to `1`. +- `API_DEFAULT_VERSION` controls the currently active route version and defaults to `1`. +- Health checks, metrics endpoints, the root route, and payment webhooks are version-neutral. +- Requests with a missing or invalid API version header return a client error before the request reaches the controller. + +Example: + +```bash +curl -H "X-API-Version: 1" http://localhost:3000/users +``` + ## ๐Ÿ“Š Architecture ## โš™๏ธ Tech Stack diff --git a/src/app.controller.ts b/src/app.controller.ts index cce879ee..63d8eb82 100644 --- a/src/app.controller.ts +++ b/src/app.controller.ts @@ -1,6 +1,7 @@ -import { Controller, Get } from '@nestjs/common'; +import { Controller, Get, VERSION_NEUTRAL, Version } from '@nestjs/common'; import { AppService } from './app.service'; +@Version(VERSION_NEUTRAL) @Controller() export class AppController { constructor(private readonly appService: AppService) {} diff --git a/src/app.module.ts b/src/app.module.ts index d8393dd5..ade5ccfd 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -21,6 +21,7 @@ import { ThrottlerModule } from '@nestjs/throttler'; import { CustomThrottleGuard } from './common/guards/throttle.guard'; import { loadFeatureFlags } from './config/feature-flags.config'; import { StartupLogger } from './common/lazy-loading/startup-logger.service'; +import { ApiVersioningModule } from './common/modules/api-versioning.module'; // Feature modules - conditionally loaded based on feature flags import { SyncModule } from './sync/sync.module'; @@ -124,6 +125,7 @@ export class AppModule { limit: parseInt(process.env.THROTTLE_LIMIT || '10'), }, ]), + ApiVersioningModule, HealthModule, DatabaseModule, ]; diff --git a/src/common/interceptors/api-version.interceptor.spec.ts b/src/common/interceptors/api-version.interceptor.spec.ts new file mode 100644 index 00000000..0bbc790b --- /dev/null +++ b/src/common/interceptors/api-version.interceptor.spec.ts @@ -0,0 +1,32 @@ +import { + isVersionNeutralPath, + normalizeRequestedApiVersion, + parseSupportedApiVersions, +} from './api-version.interceptor'; + +describe('api version helpers', () => { + it('normalizes supported version formats', () => { + expect(normalizeRequestedApiVersion('1')).toBe('1'); + expect(normalizeRequestedApiVersion('v1')).toBe('1'); + expect(normalizeRequestedApiVersion('1.0')).toBe('1'); + expect(normalizeRequestedApiVersion('v1.0')).toBe('1'); + }); + + it('rejects invalid version formats', () => { + expect(normalizeRequestedApiVersion('latest')).toBeNull(); + expect(normalizeRequestedApiVersion('v1.2')).toBeNull(); + expect(normalizeRequestedApiVersion(undefined)).toBeNull(); + }); + + it('parses configured supported versions', () => { + expect(parseSupportedApiVersions('1, v1, 2')).toEqual(['1', '2']); + }); + + it('detects version-neutral routes', () => { + expect(isVersionNeutralPath('/')).toBe(true); + expect(isVersionNeutralPath('/health')).toBe(true); + expect(isVersionNeutralPath('/metrics/scheduled-tasks/dashboard')).toBe(true); + expect(isVersionNeutralPath('/webhooks/stripe')).toBe(true); + expect(isVersionNeutralPath('/users')).toBe(false); + }); +}); diff --git a/src/common/interceptors/api-version.interceptor.ts b/src/common/interceptors/api-version.interceptor.ts index abd42be7..95604108 100644 --- a/src/common/interceptors/api-version.interceptor.ts +++ b/src/common/interceptors/api-version.interceptor.ts @@ -1,208 +1,83 @@ -import { Injectable, NestInterceptor, ExecutionContext, CallHandler, Logger } from '@nestjs/common'; +import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'; import { Observable } from 'rxjs'; -import { tap } from 'rxjs/operators'; -export interface ApiVersion { - major: number; - minor: number; - string: string; -} +export const API_VERSION_HEADER = process.env.API_VERSION_HEADER_NAME?.trim() || 'X-API-Version'; +export const API_VERSION_HEADER_KEY = API_VERSION_HEADER.toLowerCase(); +export const DEFAULT_API_VERSION = normalizeConfiguredVersion( + process.env.API_DEFAULT_VERSION?.trim() || '1', +); +export const SUPPORTED_API_VERSIONS = parseSupportedApiVersions(process.env.API_SUPPORTED_VERSIONS); + +const VERSION_NEUTRAL_PATH_PREFIXES = ['/api', '/health', '/metrics', '/webhooks']; +const VERSION_NEUTRAL_EXACT_PATHS = ['/', '/api-json', '/favicon.ico']; export interface VersionedRequest { - apiVersion: ApiVersion; + apiVersion?: string; } -/** - * API Version Interceptor - * Extracts version from URL path or header and attaches to request - */ @Injectable() export class ApiVersionInterceptor implements NestInterceptor { - private readonly logger = new Logger(ApiVersionInterceptor.name); - - // Supported API versions - readonly supportedVersions: ApiVersion[] = [ - { major: 1, minor: 0, string: 'v1' }, - { major: 2, minor: 0, string: 'v2' }, - ]; - - // Default version if none specified - readonly defaultVersion: ApiVersion = { major: 1, minor: 0, string: 'v1' }; - - intercept(context: ExecutionContext, next: CallHandler): Observable { - const request = context.switchToHttp().getRequest(); - const version = this.extractVersion(request); - - // Attach version to request - (request as VersionedRequest).apiVersion = version; - - this.logger.debug(`API Version: ${version.string} for ${request.method} ${request.url}`); - - return next.handle().pipe( - tap(() => { - // Add version header to response - const response = context.switchToHttp().getResponse(); - response.setHeader('X-API-Version', version.string); - }), - ); - } + intercept(context: ExecutionContext, next: CallHandler): Observable { + const http = context.switchToHttp(); + const request = http.getRequest }>(); + const response = http.getResponse<{ setHeader: (name: string, value: string) => void }>(); - /** - * Extract version from request - * Priority: URL path > Header > Query param > Default - */ - private extractVersion(request: any): ApiVersion { - // 1. Check URL path for version (e.g., /api/v1/users) - const pathVersion = this.extractFromPath(request.path || request.url); - if (pathVersion) { - return pathVersion; - } - - // 2. Check Accept header (e.g., Accept: application/vnd.teachlink.v1+json) - const acceptHeader = request.headers?.accept || request.headers?.['accept']; - if (acceptHeader) { - const headerVersion = this.extractFromAcceptHeader(acceptHeader); - if (headerVersion) { - return headerVersion; - } - } + const resolvedVersion = + request.apiVersion || request.headers?.[API_VERSION_HEADER_KEY] || DEFAULT_API_VERSION; - // 3. Check custom header (e.g., X-API-Version: v1) - const customHeader = request.headers?.['x-api-version']; - if (customHeader) { - const headerVersion = this.parseVersionString(customHeader); - if (headerVersion && this.isSupported(headerVersion)) { - return headerVersion; - } - } + request.apiVersion = resolvedVersion; - // 4. Check query parameter (e.g., ?version=v1) - const queryVersion = request.query?.version; - if (queryVersion) { - const parsed = this.parseVersionString(queryVersion); - if (parsed && this.isSupported(parsed)) { - return parsed; - } + if (!isVersionNeutralPath((request as { path?: string; url?: string }).path || '')) { + response.setHeader(API_VERSION_HEADER, resolvedVersion); } - // Return default version - return this.defaultVersion; + return next.handle(); } +} - /** - * Extract version from URL path - */ - private extractFromPath(path: string): ApiVersion | null { - if (!path) return null; - - // Match /api/v1 or /v1 patterns - const match = path.match(/[\/]v(\d+)(?:\.(\d+))?[\/]/); - if (match) { - const version: ApiVersion = { - major: parseInt(match[1], 10), - minor: match[2] ? parseInt(match[2], 10) : 0, - string: `v${match[1]}${match[2] ? `.${match[2]}` : ''}`, - }; - if (this.isSupported(version)) { - return version; - } - } - +export function normalizeRequestedApiVersion(version?: string | string[]): string | null { + if (!version) { return null; } - /** - * Extract version from Accept header - */ - private extractFromAcceptHeader(acceptHeader: string): ApiVersion | null { - // Match application/vnd.teachlink.v1+json or similar - const match = acceptHeader.match(/v(\d+)(?:\.(\d+))?/); - if (match) { - const version: ApiVersion = { - major: parseInt(match[1], 10), - minor: match[2] ? parseInt(match[2], 10) : 0, - string: `v${match[1]}${match[2] ? `.${match[2]}` : ''}`, - }; - if (this.isSupported(version)) { - return version; - } - } + const raw = Array.isArray(version) ? version[0] : version; + const trimmed = raw.trim(); + const match = trimmed.match(/^v?(\d+)(?:\.0+)?$/i); + if (!match) { return null; } - /** - * Parse version string to ApiVersion - */ - private parseVersionString(version: string): ApiVersion | null { - if (!version) return null; - - // Handle v1, v1.0, v2, v2.1 formats - const match = version.match(/^v?(\d+)(?:\.(\d+))?$/); - if (match) { - return { - major: parseInt(match[1], 10), - minor: match[2] ? parseInt(match[2], 10) : 0, - string: `v${match[1]}${match[2] ? `.${match[2]}` : ''}`, - }; - } + return match[1]; +} - return null; - } +export function normalizeConfiguredVersion(version: string): string { + const normalized = normalizeRequestedApiVersion(version); + return normalized || '1'; +} - /** - * Check if version is supported - */ - private isSupported(version: ApiVersion): boolean { - return this.supportedVersions.some( - (v) => v.major === version.major && v.minor === version.minor, - ); - } +export function parseSupportedApiVersions(raw = process.env.API_SUPPORTED_VERSIONS): string[] { + const configured = raw?.trim() ? raw : DEFAULT_API_VERSION; + const versions = configured + .split(',') + .map((version) => normalizeRequestedApiVersion(version)) + .filter((version): version is string => Boolean(version)); - /** - * Get supported versions for documentation - */ - getSupportedVersions(): string[] { - return this.supportedVersions.map((v) => v.string); + if (!versions.length) { + return [DEFAULT_API_VERSION]; } -} -/** - * Guard to enforce API versioning - */ -@Injectable() -export class ApiVersionGuard { - private readonly logger = new Logger(ApiVersionGuard.name); - private readonly supportedVersions = ['v1', 'v2']; - - canActivate(context: ExecutionContext): boolean { - const request = context.switchToHttp().getRequest(); - const version = (request as VersionedRequest).apiVersion; + return Array.from(new Set(versions)); +} - if (!version || !this.supportedVersions.includes(version.string)) { - this.logger.warn(`Unsupported API version: ${version?.string}`); - return false; - } +export function isVersionNeutralPath(pathOrUrl: string): boolean { + const path = (pathOrUrl || '/').split('?')[0]; + if (VERSION_NEUTRAL_EXACT_PATHS.includes(path)) { return true; } -} -/** - * Decorator for version-specific endpoints - */ -export function ApiVersion(version: string): MethodDecorator { - return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) { - Reflect.defineMetadata('api:version', version, descriptor.value); - return descriptor; - }; + return VERSION_NEUTRAL_PATH_PREFIXES.some( + (prefix) => path === prefix || path.startsWith(`${prefix}/`), + ); } - -/** - * Decorator to get the current API version from request - */ -export function GetApiVersion(): ParameterDecorator { - return function (target: object, propertyKey: string | symbol, parameterIndex: number) { - // This will be handled by the interceptor to inject the version - }; -} \ No newline at end of file diff --git a/src/common/modules/api-versioning.module.ts b/src/common/modules/api-versioning.module.ts index 9687867b..ccac22c6 100644 --- a/src/common/modules/api-versioning.module.ts +++ b/src/common/modules/api-versioning.module.ts @@ -1,30 +1,91 @@ -import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common'; -import { ApiVersionInterceptor, ApiVersionGuard } from '../interceptors/api-version.interceptor'; - -/** - * API Versioning Module - * - * Provides: - * - URL-based versioning (/api/v1/, /api/v2/) - * - Header-based versioning (X-API-Version, Accept header) - * - Query parameter versioning (?version=v1) - * - Version routing strategy - */ +import { + BadRequestException, + Injectable, + MiddlewareConsumer, + Module, + NestMiddleware, + NestModule, + NotAcceptableException, + RequestMethod, +} from '@nestjs/common'; +import { APP_INTERCEPTOR } from '@nestjs/core'; +import type { NextFunction, Request, Response } from 'express'; +import { + ApiVersionInterceptor, + API_VERSION_HEADER, + API_VERSION_HEADER_KEY, + DEFAULT_API_VERSION, + isVersionNeutralPath, + normalizeRequestedApiVersion, + SUPPORTED_API_VERSIONS, + VersionedRequest, +} from '../interceptors/api-version.interceptor'; + +export const API_VERSIONING_DOCUMENTATION = [ + 'TeachLink uses header-based API versioning.', + `Send ${API_VERSION_HEADER}: ${DEFAULT_API_VERSION} on versioned endpoints.`, + `Supported versions: ${SUPPORTED_API_VERSIONS.join(', ')}.`, + 'Health, metrics, root, and webhook endpoints are version-neutral.', +].join(' '); + +@Injectable() +export class ApiVersionValidationMiddleware implements NestMiddleware { + use( + req: Request & VersionedRequest & { headers: Record }, + res: Response, + next: NextFunction, + ): void { + const path = req.path || req.url || '/'; + + if (isVersionNeutralPath(path)) { + req.apiVersion = DEFAULT_API_VERSION; + next(); + return; + } + + const rawVersion = req.headers[API_VERSION_HEADER_KEY]; + if (!rawVersion) { + throw new BadRequestException( + `Missing required ${API_VERSION_HEADER} header. Supported versions: ${SUPPORTED_API_VERSIONS.join(', ')}`, + ); + } + + const normalizedVersion = normalizeRequestedApiVersion(rawVersion); + if (!normalizedVersion) { + throw new BadRequestException( + `Invalid ${API_VERSION_HEADER} header value "${Array.isArray(rawVersion) ? rawVersion[0] : rawVersion}". Expected values like "1" or "v1".`, + ); + } + + if (!SUPPORTED_API_VERSIONS.includes(normalizedVersion)) { + throw new NotAcceptableException( + `Unsupported API version "${normalizedVersion}". Supported versions: ${SUPPORTED_API_VERSIONS.join(', ')}`, + ); + } + + req.headers[API_VERSION_HEADER_KEY] = normalizedVersion; + req.apiVersion = normalizedVersion; + res.setHeader(API_VERSION_HEADER, normalizedVersion); + next(); + } +} + @Module({ - providers: [ApiVersionInterceptor, ApiVersionGuard], - exports: [ApiVersionInterceptor, ApiVersionGuard], + providers: [ + ApiVersionValidationMiddleware, + ApiVersionInterceptor, + { + provide: APP_INTERCEPTOR, + useClass: ApiVersionInterceptor, + }, + ], + exports: [ApiVersionValidationMiddleware, ApiVersionInterceptor], }) export class ApiVersioningModule implements NestModule { - configure(consumer: MiddlewareConsumer) { - // Apply version interceptor to all routes - consumer - .apply((req, res, next) => { - // The interceptor will be applied globally - next(); - }) - .forRoutes('*'); + configure(consumer: MiddlewareConsumer): void { + consumer.apply(ApiVersionValidationMiddleware).forRoutes({ + path: '*', + method: RequestMethod.ALL, + }); } } - -// Re-export for convenience -export { ApiVersionInterceptor, ApiVersionGuard } from '../interceptors/api-version.interceptor'; diff --git a/src/config/env.validation.ts b/src/config/env.validation.ts index 5a651d45..38b141c2 100644 --- a/src/config/env.validation.ts +++ b/src/config/env.validation.ts @@ -123,4 +123,11 @@ export const envValidationSchema = Joi.object({ // Application URL APP_URL: Joi.string().uri().default('http://localhost:3000'), + + // API Versioning + API_VERSION_HEADER_NAME: Joi.string().default('X-API-Version'), + API_DEFAULT_VERSION: Joi.string() + .pattern(/^\d+(?:\.0+)?$/) + .default('1'), + API_SUPPORTED_VERSIONS: Joi.string().default('1'), }); diff --git a/src/config/swagger.config.ts b/src/config/swagger.config.ts index e107f814..b74acdf5 100644 --- a/src/config/swagger.config.ts +++ b/src/config/swagger.config.ts @@ -1,10 +1,11 @@ import { INestApplication } from '@nestjs/common'; import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; +import { API_VERSIONING_DOCUMENTATION } from '../common/modules/api-versioning.module'; export function setupSwagger(app: INestApplication): void { const config = new DocumentBuilder() .setTitle('TeachLink API') - .setDescription('TeachLink backend API documentation') + .setDescription(`TeachLink backend API documentation. ${API_VERSIONING_DOCUMENTATION}`) .setVersion('1.0') .addBearerAuth( { diff --git a/src/health/health.controller.ts b/src/health/health.controller.ts index 86c9271a..ae851ed4 100644 --- a/src/health/health.controller.ts +++ b/src/health/health.controller.ts @@ -1,8 +1,9 @@ -import { Controller, Get } from '@nestjs/common'; +import { Controller, Get, VERSION_NEUTRAL, Version } from '@nestjs/common'; import { DataSource } from 'typeorm'; import Redis from 'ioredis'; import { HealthService } from './health.service'; +@Version(VERSION_NEUTRAL) @Controller('health') export class HealthController { private redis: Redis; diff --git a/src/main.ts b/src/main.ts index 4b014843..c1f10a18 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,5 @@ import { NestFactory } from '@nestjs/core'; -import { ValidationPipe, Logger } from '@nestjs/common'; +import { ValidationPipe, Logger, VersioningType } from '@nestjs/common'; import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; import cluster from 'node:cluster'; import { cpus } from 'node:os'; @@ -14,6 +14,12 @@ import { correlationMiddleware } from './common/utils/correlation.utils'; import { sessionConfig } from './config/cache.config'; import { SESSION_REDIS_CLIENT } from './session/session.constants'; import helmet from 'helmet'; +import { API_VERSIONING_DOCUMENTATION } from './common/modules/api-versioning.module'; +import { + API_VERSION_HEADER, + DEFAULT_API_VERSION, + SUPPORTED_API_VERSIONS, +} from './common/interceptors/api-version.interceptor'; async function bootstrapWorker() { const logger = new Logger('Bootstrap'); @@ -22,6 +28,12 @@ async function bootstrapWorker() { // Create the application with dynamic module loading const app = await NestFactory.create(await AppModule.forRoot(), { rawBody: true }); + app.enableVersioning({ + type: VersioningType.HEADER, + header: API_VERSION_HEADER, + defaultVersion: DEFAULT_API_VERSION, + }); + // โ”€โ”€โ”€ Security Headers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ app.use( helmet({ @@ -91,7 +103,7 @@ async function bootstrapWorker() { // โ”€โ”€โ”€ Swagger โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const config = new DocumentBuilder() .setTitle('TeachLink API') - .setDescription('The TeachLink API documentation - Unified System') + .setDescription(`The TeachLink API documentation - Unified System. ${API_VERSIONING_DOCUMENTATION}`) .setVersion('1.0') .addBearerAuth() .addTag('gamification', 'Gamification and user rewards') @@ -119,6 +131,9 @@ async function bootstrapWorker() { logger.log(`๐Ÿš€ TeachLink API running on http://localhost:${port}`); logger.log(`๐Ÿ“š Swagger docs available at http://localhost:${port}/api`); + logger.log( + `๐Ÿงญ API versioning enabled via ${API_VERSION_HEADER}. Supported versions: ${SUPPORTED_API_VERSIONS.join(', ')}; default route version: ${DEFAULT_API_VERSION}.`, + ); logger.log(`โฑ๏ธ Application startup completed in ${startupTime}ms`); } diff --git a/src/monitoring/monitoring.controller.ts b/src/monitoring/monitoring.controller.ts index 80f0c5c8..c66da6d2 100644 --- a/src/monitoring/monitoring.controller.ts +++ b/src/monitoring/monitoring.controller.ts @@ -1,8 +1,9 @@ -import { Controller, Get, Res } from '@nestjs/common'; +import { Controller, Get, Res, VERSION_NEUTRAL, Version } from '@nestjs/common'; import { MetricsCollectionService } from './metrics/metrics-collection.service'; import { Response } from 'express'; import { ScheduledTaskMonitoringService } from './scheduled-task-monitoring.service'; +@Version(VERSION_NEUTRAL) @Controller('metrics') export class MonitoringController { constructor( diff --git a/src/payments/webhooks/webhook.controller.ts b/src/payments/webhooks/webhook.controller.ts index 01be80f9..6f8c2d89 100644 --- a/src/payments/webhooks/webhook.controller.ts +++ b/src/payments/webhooks/webhook.controller.ts @@ -7,12 +7,15 @@ import { HttpStatus, Req, RawBodyRequest, + VERSION_NEUTRAL, + Version, } from '@nestjs/common'; import { Request } from 'express'; import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; import { WebhookService } from './webhook.service'; @ApiTags('webhooks') +@Version(VERSION_NEUTRAL) @Controller('webhooks') export class WebhookController { constructor(private readonly webhookService: WebhookService) {} From 5b1e150eee2cdeff2aba7c87ab7ed91d978db3a8 Mon Sep 17 00:00:00 2001 From: Chinonso-Peter Date: Thu, 23 Apr 2026 15:44:41 +0100 Subject: [PATCH 3/3] fixed ci issues --- package-lock.json | 246 ++++++++++++++++-- src/audit-log/audit-log.controller.ts | 60 ++++- src/audit-log/audit-log.module.ts | 17 +- src/audit-log/decorators/audit.decorator.ts | 18 +- .../interceptors/audit-log.interceptor.ts | 26 +- src/auth/auth.service.ts | 13 +- src/backup/backup.service.ts | 7 +- .../gateway/collaboration.gateway.ts | 1 - .../interceptors/api-version.interceptor.ts | 10 +- .../interceptors/logging.interceptor.ts | 12 +- src/common/utils/pagination.util.ts | 7 +- src/common/utils/websocket.utils.ts | 7 +- src/health/health.service.ts | 1 - src/localization/localization.service.ts | 4 +- .../processing/image-processing.service.ts | 11 +- .../validation/file-validation.constants.ts | 32 ++- .../validation/upload-progress.service.ts | 25 +- .../scheduled-task-monitoring.service.ts | 15 +- src/notifications/notifications.controller.ts | 3 +- .../preferences/preferences.service.ts | 17 +- .../autocomplete/autocomplete.service.ts | 4 +- src/search/indexing/indexing.service.ts | 2 +- src/search/search.service.ts | 14 +- src/users/users.module.ts | 8 +- 24 files changed, 413 insertions(+), 147 deletions(-) diff --git a/package-lock.json b/package-lock.json index 60074ec9..64e63d14 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2654,7 +2654,7 @@ "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "0.3.9" @@ -2667,7 +2667,7 @@ "version": "0.3.9", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", @@ -4274,7 +4274,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -4295,7 +4295,7 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { @@ -6653,6 +6653,83 @@ "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", "license": "BSD-3-Clause" }, + "node_modules/@redis/bloom": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.12.1.tgz", + "integrity": "sha512-PUUfv+ms7jgPSBVoo/DN4AkPHj4D5TZSd6SbJX7egzBplkYUcKmHRE8RKia7UtZ8bSQbLguLvxVO+asKtQfZWA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@redis/client": "^5.12.1" + } + }, + "node_modules/@redis/client": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-5.12.1.tgz", + "integrity": "sha512-7aPGWeqA3uFm43o19umzdl16CEjK/JQGtSXVPevplTaOU3VJA/rseBC1QvYUz9lLDIMBimc4SW/zrW4S89BaCA==", + "license": "MIT", + "peer": true, + "dependencies": { + "cluster-key-slot": "1.1.2" + }, + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@node-rs/xxhash": "^1.1.0", + "@opentelemetry/api": ">=1 <2" + }, + "peerDependenciesMeta": { + "@node-rs/xxhash": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + } + } + }, + "node_modules/@redis/json": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-5.12.1.tgz", + "integrity": "sha512-eOze75esLve4vfqDel7aMX08CNaiLLQS2fV8mpRN9NxPe1rVR4vQyYiW/OgtGUysF6QOr9ANhfxABKNOJfXdKg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@redis/client": "^5.12.1" + } + }, + "node_modules/@redis/search": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-5.12.1.tgz", + "integrity": "sha512-ItlxbxC9cKI6IU1TLWoczwJCRb6TdmkEpWv05UrPawqaAnWGRu3rcIqsc5vN483T2fSociuyV1UkWIL5I4//2w==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@redis/client": "^5.12.1" + } + }, + "node_modules/@redis/time-series": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-5.12.1.tgz", + "integrity": "sha512-c6JL6E3EcZJuNqKFz+KM+l9l5mpcQiKvTwgA3blt5glWJ8hjDk0yeHN3beE/MpqYIQ8UEX44ItQzgkE/gCBELQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@redis/client": "^5.12.1" + } + }, "node_modules/@sideway/address": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", @@ -7469,28 +7546,28 @@ "version": "1.0.12", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@tsconfig/node12": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@tsconfig/node14": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@tsconfig/node16": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/babel__core": { @@ -8534,6 +8611,20 @@ "acorn": "^8" } }, + "node_modules/acorn-import-phases": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", + "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10.13.0" + }, + "peerDependencies": { + "acorn": "^8.14.0" + } + }, "node_modules/acorn-jsx": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", @@ -8548,7 +8639,7 @@ "version": "8.3.5", "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "acorn": "^8.11.0" @@ -8765,7 +8856,7 @@ "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/argparse": { @@ -10468,7 +10559,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/cron": { @@ -10730,7 +10821,7 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" @@ -11058,6 +11149,14 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -15702,7 +15801,7 @@ "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/makeerror": { @@ -17337,6 +17436,23 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/redis": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/redis/-/redis-5.12.1.tgz", + "integrity": "sha512-LDsoVvb/CpoV9EN3FXvgvSHNJWuCIzl9MiO3ppOevuGLpSGJhwfQjpEwfFJcQvNSddHADDdZaWx0HnmMxRXG7g==", + "license": "MIT", + "peer": true, + "dependencies": { + "@redis/bloom": "5.12.1", + "@redis/client": "5.12.1", + "@redis/json": "5.12.1", + "@redis/search": "5.12.1", + "@redis/time-series": "5.12.1" + }, + "engines": { + "node": ">= 18.19.0" + } + }, "node_modules/redis-errors": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", @@ -19229,7 +19345,7 @@ "version": "10.9.2", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@cspotcode/source-map-support": "^0.8.0", @@ -19643,7 +19759,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -19831,7 +19947,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/v8-to-istanbul": { @@ -19916,6 +20032,55 @@ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", "license": "BSD-2-Clause" }, + "node_modules/webpack": { + "version": "5.106.2", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.106.2.tgz", + "integrity": "sha512-wGN3qcrBQIFmQ/c0AiOAQBvrZ5lmY8vbbMv4Mxfgzqd/B6+9pXtLo73WuS1dSGXM5QYY3hZnIbvx+K1xxe6FyA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/eslint-scope": "^3.7.7", + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.16.0", + "acorn-import-phases": "^1.0.3", + "browserslist": "^4.28.1", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.20.0", + "es-module-lexer": "^2.0.0", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.11", + "loader-runner": "^4.3.1", + "mime-db": "^1.54.0", + "neo-async": "^2.6.2", + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", + "terser-webpack-plugin": "^5.3.17", + "watchpack": "^2.5.1", + "webpack-sources": "^3.3.4" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, "node_modules/webpack-node-externals": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/webpack-node-externals/-/webpack-node-externals-3.0.0.tgz", @@ -19936,6 +20101,53 @@ "node": ">=10.13.0" } }, + "node_modules/webpack/node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/webpack/node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/webpack/node_modules/schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/whatwg-mimetype": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", @@ -20235,7 +20447,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6" diff --git a/src/audit-log/audit-log.controller.ts b/src/audit-log/audit-log.controller.ts index 40e0609e..75386f31 100644 --- a/src/audit-log/audit-log.controller.ts +++ b/src/audit-log/audit-log.controller.ts @@ -12,7 +12,14 @@ import { ParseIntPipe, DefaultValuePipe, } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiResponse, ApiQuery, ApiParam, ApiBearerAuth } from '@nestjs/swagger'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiQuery, + ApiParam, + ApiBearerAuth, +} from '@nestjs/swagger'; import { Response } from 'express'; import { AuditLogService, AuditLogSearchFilters } from './audit-log.service'; import { AuditLog } from './audit-log.entity'; @@ -32,9 +39,21 @@ export class AuditLogController { @ApiOperation({ summary: 'Search audit logs with filters' }) @ApiQuery({ name: 'userId', required: false, description: 'Filter by user ID' }) @ApiQuery({ name: 'userEmail', required: false, description: 'Filter by user email' }) - @ApiQuery({ name: 'actions', required: false, description: 'Filter by actions (comma-separated)' }) - @ApiQuery({ name: 'categories', required: false, description: 'Filter by categories (comma-separated)' }) - @ApiQuery({ name: 'severities', required: false, description: 'Filter by severities (comma-separated)' }) + @ApiQuery({ + name: 'actions', + required: false, + description: 'Filter by actions (comma-separated)', + }) + @ApiQuery({ + name: 'categories', + required: false, + description: 'Filter by categories (comma-separated)', + }) + @ApiQuery({ + name: 'severities', + required: false, + description: 'Filter by severities (comma-separated)', + }) @ApiQuery({ name: 'entityType', required: false, description: 'Filter by entity type' }) @ApiQuery({ name: 'entityId', required: false, description: 'Filter by entity ID' }) @ApiQuery({ name: 'ipAddress', required: false, description: 'Filter by IP address' }) @@ -90,7 +109,12 @@ export class AuditLogController { @Get('recent') @ApiOperation({ summary: 'Get recent audit logs' }) - @ApiQuery({ name: 'limit', required: false, description: 'Number of logs to return', type: Number }) + @ApiQuery({ + name: 'limit', + required: false, + description: 'Number of logs to return', + type: Number, + }) @ApiResponse({ status: 200, description: 'Recent audit logs' }) async getRecent( @Query('limit', new DefaultValuePipe(100), ParseIntPipe) limit?: number, @@ -101,7 +125,12 @@ export class AuditLogController { @Get('user/:userId') @ApiOperation({ summary: 'Get audit logs for a specific user' }) @ApiParam({ name: 'userId', description: 'User ID' }) - @ApiQuery({ name: 'limit', required: false, description: 'Number of logs to return', type: Number }) + @ApiQuery({ + name: 'limit', + required: false, + description: 'Number of logs to return', + type: Number, + }) @ApiResponse({ status: 200, description: 'User audit logs' }) async getByUser( @Param('userId') userId: string, @@ -114,7 +143,12 @@ export class AuditLogController { @ApiOperation({ summary: 'Get audit logs for a specific entity' }) @ApiParam({ name: 'entityType', description: 'Entity type (e.g., user, course)' }) @ApiParam({ name: 'entityId', description: 'Entity ID' }) - @ApiQuery({ name: 'limit', required: false, description: 'Number of logs to return', type: Number }) + @ApiQuery({ + name: 'limit', + required: false, + description: 'Number of logs to return', + type: Number, + }) @ApiResponse({ status: 200, description: 'Entity audit logs' }) async getByEntity( @Param('entityType') entityType: string, @@ -127,7 +161,12 @@ export class AuditLogController { @Get('ip/:ipAddress') @ApiOperation({ summary: 'Get audit logs by IP address' }) @ApiParam({ name: 'ipAddress', description: 'IP address' }) - @ApiQuery({ name: 'limit', required: false, description: 'Number of logs to return', type: Number }) + @ApiQuery({ + name: 'limit', + required: false, + description: 'Number of logs to return', + type: Number, + }) @ApiResponse({ status: 200, description: 'IP audit logs' }) async getByIpAddress( @Param('ipAddress') ipAddress: string, @@ -148,10 +187,7 @@ export class AuditLogController { @ApiQuery({ name: 'startDate', required: true, description: 'Start date (ISO 8601)' }) @ApiQuery({ name: 'endDate', required: true, description: 'End date (ISO 8601)' }) @ApiResponse({ status: 200, description: 'Audit report' }) - async generateReport( - @Query('startDate') startDate: string, - @Query('endDate') endDate: string, - ) { + async generateReport(@Query('startDate') startDate: string, @Query('endDate') endDate: string) { if (!startDate || !endDate) { throw new HttpException('Start date and end date are required', HttpStatus.BAD_REQUEST); } diff --git a/src/audit-log/audit-log.module.ts b/src/audit-log/audit-log.module.ts index 5659510e..f3696692 100644 --- a/src/audit-log/audit-log.module.ts +++ b/src/audit-log/audit-log.module.ts @@ -9,20 +9,9 @@ import { AuditLogInterceptor } from './interceptors/audit-log.interceptor'; import { AuditRetentionTask } from './tasks/audit-retention.task'; @Module({ - imports: [ - TypeOrmModule.forFeature([AuditLog]), - ConfigModule, - ScheduleModule.forRoot(), - ], + imports: [TypeOrmModule.forFeature([AuditLog]), ConfigModule, ScheduleModule.forRoot()], controllers: [AuditLogController], - providers: [ - AuditLogService, - AuditLogInterceptor, - AuditRetentionTask, - ], - exports: [ - AuditLogService, - AuditLogInterceptor, - ], + providers: [AuditLogService, AuditLogInterceptor, AuditRetentionTask], + exports: [AuditLogService, AuditLogInterceptor], }) export class AuditLogModule {} diff --git a/src/audit-log/decorators/audit.decorator.ts b/src/audit-log/decorators/audit.decorator.ts index deb2dcab..0c3c2063 100644 --- a/src/audit-log/decorators/audit.decorator.ts +++ b/src/audit-log/decorators/audit.decorator.ts @@ -32,7 +32,11 @@ export const AuditCreate = (entityType: string, options?: Partial) => +export const AuditUpdate = ( + entityType: string, + entityIdParam: string, + options?: Partial, +) => AuditLog({ action: AuditAction.DATA_UPDATED, category: AuditCategory.DATA_MODIFICATION, @@ -41,7 +45,11 @@ export const AuditUpdate = (entityType: string, entityIdParam: string, options?: ...options, }); -export const AuditDelete = (entityType: string, entityIdParam: string, options?: Partial) => +export const AuditDelete = ( + entityType: string, + entityIdParam: string, + options?: Partial, +) => AuditLog({ action: AuditAction.DATA_DELETED, category: AuditCategory.DATA_MODIFICATION, @@ -51,7 +59,11 @@ export const AuditDelete = (entityType: string, entityIdParam: string, options?: ...options, }); -export const AuditView = (entityType: string, entityIdParam?: string, options?: Partial) => +export const AuditView = ( + entityType: string, + entityIdParam?: string, + options?: Partial, +) => AuditLog({ action: AuditAction.DATA_VIEWED, category: AuditCategory.DATA_ACCESS, diff --git a/src/audit-log/interceptors/audit-log.interceptor.ts b/src/audit-log/interceptors/audit-log.interceptor.ts index 1e9cdb82..388ffcd3 100644 --- a/src/audit-log/interceptors/audit-log.interceptor.ts +++ b/src/audit-log/interceptors/audit-log.interceptor.ts @@ -1,10 +1,4 @@ -import { - Injectable, - NestInterceptor, - ExecutionContext, - CallHandler, - Logger, -} from '@nestjs/common'; +import { Injectable, NestInterceptor, ExecutionContext, CallHandler, Logger } from '@nestjs/common'; import { Observable } from 'rxjs'; import { tap } from 'rxjs/operators'; import { AuditLogService } from '../audit-log.service'; @@ -31,14 +25,7 @@ export class AuditLogInterceptor implements NestInterceptor { const response = context.switchToHttp().getResponse(); const startTime = Date.now(); - const { - method, - path, - ip, - headers, - user, - requestId, - } = request; + const { method, path, ip, headers, user, requestId } = request; const userAgent = headers['user-agent'] || 'Unknown'; const userId = user?.id || null; @@ -96,9 +83,12 @@ export class AuditLogInterceptor implements NestInterceptor { return; } - const severity = statusCode >= 500 ? AuditSeverity.ERROR : - statusCode >= 400 ? AuditSeverity.WARNING : - AuditSeverity.INFO; + const severity = + statusCode >= 500 + ? AuditSeverity.ERROR + : statusCode >= 400 + ? AuditSeverity.WARNING + : AuditSeverity.INFO; await this.auditLogService.log({ userId: userId || undefined, diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index c846d265..f2fe5889 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -71,7 +71,11 @@ export class AuthService { private readonly auditLogService: AuditLogService, ) {} - async register(registerDto: RegisterDto, ipAddress?: string, userAgent?: string): Promise { + async register( + registerDto: RegisterDto, + ipAddress?: string, + userAgent?: string, + ): Promise { return await this.transactionService.runInTransaction(async (_manager) => { // Create user const user = await this.usersService.create(registerDto); @@ -254,7 +258,12 @@ export class AuthService { } } - async logout(userId: string, sessionId?: string, ipAddress?: string, userAgent?: string): Promise<{ message: string }> { + async logout( + userId: string, + sessionId?: string, + ipAddress?: string, + userAgent?: string, + ): Promise<{ message: string }> { const user = await this.usersService.findOne(userId); await this.sessionService.withLock(`logout:${userId}`, async () => { diff --git a/src/backup/backup.service.ts b/src/backup/backup.service.ts index 6f6cdc74..554e141f 100644 --- a/src/backup/backup.service.ts +++ b/src/backup/backup.service.ts @@ -199,7 +199,12 @@ export class BackupService { ); if (shouldRetry) { - this.scheduledTaskMonitoringService.recordRetry(taskName, attempt, maxAttempts - 1, errorMessage); + this.scheduledTaskMonitoringService.recordRetry( + taskName, + attempt, + maxAttempts - 1, + errorMessage, + ); await this.delay(this.scheduledTaskRetryDelayMs); continue; } diff --git a/src/collaboration/gateway/collaboration.gateway.ts b/src/collaboration/gateway/collaboration.gateway.ts index 3e3565a8..b60d012e 100644 --- a/src/collaboration/gateway/collaboration.gateway.ts +++ b/src/collaboration/gateway/collaboration.gateway.ts @@ -8,7 +8,6 @@ import { MessageBody, ConnectedSocket, } from '@nestjs/websockets'; -import { wsManager } from '../../common/utils/websocket.utils'; import { Server, Socket } from 'socket.io'; import { Logger } from '@nestjs/common'; import { CollaborationService } from '../collaboration.service'; diff --git a/src/common/interceptors/api-version.interceptor.ts b/src/common/interceptors/api-version.interceptor.ts index abd42be7..3ccc0027 100644 --- a/src/common/interceptors/api-version.interceptor.ts +++ b/src/common/interceptors/api-version.interceptor.ts @@ -32,7 +32,7 @@ export class ApiVersionInterceptor implements NestInterceptor { intercept(context: ExecutionContext, next: CallHandler): Observable { const request = context.switchToHttp().getRequest(); const version = this.extractVersion(request); - + // Attach version to request (request as VersionedRequest).apiVersion = version; @@ -96,7 +96,7 @@ export class ApiVersionInterceptor implements NestInterceptor { if (!path) return null; // Match /api/v1 or /v1 patterns - const match = path.match(/[\/]v(\d+)(?:\.(\d+))?[\/]/); + const match = path.match(/[/]v(\d+)(?:\.(\d+))?[/]/); if (match) { const version: ApiVersion = { major: parseInt(match[1], 10), @@ -192,7 +192,7 @@ export class ApiVersionGuard { * Decorator for version-specific endpoints */ export function ApiVersion(version: string): MethodDecorator { - return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) { + return function (_target: any, _propertyKey: string, descriptor: PropertyDescriptor) { Reflect.defineMetadata('api:version', version, descriptor.value); return descriptor; }; @@ -202,7 +202,7 @@ export function ApiVersion(version: string): MethodDecorator { * Decorator to get the current API version from request */ export function GetApiVersion(): ParameterDecorator { - return function (target: object, propertyKey: string | symbol, parameterIndex: number) { + return function (_target: object, _propertyKey: string | symbol, _parameterIndex: number) { // This will be handled by the interceptor to inject the version }; -} \ No newline at end of file +} diff --git a/src/common/interceptors/logging.interceptor.ts b/src/common/interceptors/logging.interceptor.ts index ed8341f8..33848feb 100644 --- a/src/common/interceptors/logging.interceptor.ts +++ b/src/common/interceptors/logging.interceptor.ts @@ -2,7 +2,11 @@ import { Injectable, NestInterceptor, ExecutionContext, CallHandler, Logger } fr import { Observable, throwError } from 'rxjs'; import { tap, catchError } from 'rxjs/operators'; import { Request, Response } from 'express'; -import { CORRELATION_ID_HEADER, getCorrelationId } from '../utils/correlation.utils'; +import { + CORRELATION_ID_HEADER, + generateCorrelationId, + getCorrelationId, +} from '../utils/correlation.utils'; export interface RequestLog { requestId: string; @@ -50,7 +54,7 @@ export class LoggingInterceptor implements NestInterceptor { } const startTime = Date.now(); - const requestId = getCorrelationId() || this.generateRequestId(); + const requestId = getCorrelationId() || generateCorrelationId(); const response = httpCtx.getResponse(); response?.setHeader(CORRELATION_ID_HEADER, requestId); @@ -122,10 +126,6 @@ export class LoggingInterceptor implements NestInterceptor { } } - private generateRequestId(): string { - return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; - } - private resolveClientIp(request: Request): string { const forwarded = request.headers['x-forwarded-for']; if (typeof forwarded === 'string') { diff --git a/src/common/utils/pagination.util.ts b/src/common/utils/pagination.util.ts index 4a640009..0b64a23c 100644 --- a/src/common/utils/pagination.util.ts +++ b/src/common/utils/pagination.util.ts @@ -1,6 +1,11 @@ import { BadRequestException } from '@nestjs/common'; import { SelectQueryBuilder } from 'typeorm'; -import { PaginationQueryDto, SortOrder, CursorPaginationQueryDto, CursorDirection } from '../dto/pagination.dto'; +import { + PaginationQueryDto, + SortOrder, + CursorPaginationQueryDto, + CursorDirection, +} from '../dto/pagination.dto'; export interface PaginatedResponse { data: T[]; diff --git a/src/common/utils/websocket.utils.ts b/src/common/utils/websocket.utils.ts index 4807d2ae..39b8432c 100644 --- a/src/common/utils/websocket.utils.ts +++ b/src/common/utils/websocket.utils.ts @@ -46,7 +46,10 @@ class WebSocketManager { this.connections.set(userId, new Set()); } - const userConnections = this.connections.get(userId)!; + const userConnections = this.connections.get(userId); + if (!userConnections) { + return; + } // enforce max connections if (userConnections.size >= this.MAX_CONNECTIONS_PER_USER) { @@ -93,4 +96,4 @@ class WebSocketManager { } } -export const wsManager = new WebSocketManager(); \ No newline at end of file +export const wsManager = new WebSocketManager(); diff --git a/src/health/health.service.ts b/src/health/health.service.ts index 80db142f..3f483d96 100644 --- a/src/health/health.service.ts +++ b/src/health/health.service.ts @@ -2,7 +2,6 @@ import { Injectable, Logger } from '@nestjs/common'; import { DataSource } from 'typeorm'; import Redis from 'ioredis'; import * as fs from 'fs'; -import * as path from 'path'; import axios from 'axios'; export interface HealthStatus { diff --git a/src/localization/localization.service.ts b/src/localization/localization.service.ts index 8482e2db..80870ac3 100644 --- a/src/localization/localization.service.ts +++ b/src/localization/localization.service.ts @@ -159,7 +159,9 @@ export class LocalizationService { if (existing) { if (!existing.deletedAt) { - throw new ConflictException('Translation already exists for this namespace, key, and locale'); + throw new ConflictException( + 'Translation already exists for this namespace, key, and locale', + ); } existing.value = dto.value; diff --git a/src/media/processing/image-processing.service.ts b/src/media/processing/image-processing.service.ts index 25e1d7d8..d5852f95 100644 --- a/src/media/processing/image-processing.service.ts +++ b/src/media/processing/image-processing.service.ts @@ -137,9 +137,8 @@ export class ImageProcessingService { const processedBuffer = await pipeline.toBuffer(); const processedMetadata = await sharp(processedBuffer).metadata(); - const compressionRatio = originalSize > 0 - ? ((originalSize - processedBuffer.length) / originalSize) * 100 - : 0; + const compressionRatio = + originalSize > 0 ? ((originalSize - processedBuffer.length) / originalSize) * 100 : 0; this.logger.log( `Image compressed: ${originalSize} -> ${processedBuffer.length} bytes (${compressionRatio.toFixed(1)}% reduction)`, @@ -162,7 +161,7 @@ export class ImageProcessingService { async generateThumbnails( buffer: Buffer, options?: { - sizes?: { name: string; width: number; height: number }[]; + sizes?: Array<{ name: string; width: number; height: number }>; format?: 'jpeg' | 'png' | 'webp'; quality?: number; }, @@ -328,9 +327,7 @@ export class ImageProcessingService { * Strip metadata from image (privacy/security) */ async stripMetadata(buffer: Buffer): Promise { - return sharp(buffer) - .withMetadata() - .toBuffer(); + return sharp(buffer).withMetadata().toBuffer(); } /** diff --git a/src/media/validation/file-validation.constants.ts b/src/media/validation/file-validation.constants.ts index 5c44c481..d015e21c 100644 --- a/src/media/validation/file-validation.constants.ts +++ b/src/media/validation/file-validation.constants.ts @@ -49,10 +49,7 @@ export const ALLOWED_FILE_TYPES = { ], // Archives (limited) - ARCHIVES: [ - 'application/zip', - 'application/x-zip-compressed', - ], + ARCHIVES: ['application/zip', 'application/x-zip-compressed'], } as const; export const ALLOWED_EXTENSIONS = { @@ -124,24 +121,35 @@ export const UPLOAD_PROGRESS_CONFIG = { export const MAGIC_NUMBERS: Record = { // Images - 'image/jpeg': [Buffer.from([0xFF, 0xD8, 0xFF])], - 'image/png': [Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])], - 'image/gif': [Buffer.from([0x47, 0x49, 0x46, 0x38, 0x37, 0x61]), Buffer.from([0x47, 0x49, 0x46, 0x38, 0x39, 0x61])], + 'image/jpeg': [Buffer.from([0xff, 0xd8, 0xff])], + 'image/png': [Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])], + 'image/gif': [ + Buffer.from([0x47, 0x49, 0x46, 0x38, 0x37, 0x61]), + Buffer.from([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]), + ], 'image/webp': [Buffer.from([0x52, 0x49, 0x46, 0x46])], // RIFF header - 'image/bmp': [Buffer.from([0x42, 0x4D])], // BM - 'image/tiff': [Buffer.from([0x49, 0x49, 0x2A, 0x00]), Buffer.from([0x4D, 0x4D, 0x00, 0x2A])], + 'image/bmp': [Buffer.from([0x42, 0x4d])], // BM + 'image/tiff': [Buffer.from([0x49, 0x49, 0x2a, 0x00]), Buffer.from([0x4d, 0x4d, 0x00, 0x2a])], // PDF 'application/pdf': [Buffer.from([0x25, 0x50, 0x44, 0x46])], // %PDF // ZIP (also for docx, xlsx, pptx) - 'application/zip': [Buffer.from([0x50, 0x4B, 0x03, 0x04])], + 'application/zip': [Buffer.from([0x50, 0x4b, 0x03, 0x04])], // MP4 - 'video/mp4': [Buffer.from([0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70]), Buffer.from([0x00, 0x00, 0x00, 0x20, 0x66, 0x74, 0x79, 0x70])], + 'video/mp4': [ + Buffer.from([0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70]), + Buffer.from([0x00, 0x00, 0x00, 0x20, 0x66, 0x74, 0x79, 0x70]), + ], // MP3 - 'audio/mpeg': [Buffer.from([0xFF, 0xFB]), Buffer.from([0xFF, 0xF3]), Buffer.from([0xFF, 0xF2]), Buffer.from([0x49, 0x44, 0x33])], + 'audio/mpeg': [ + Buffer.from([0xff, 0xfb]), + Buffer.from([0xff, 0xf3]), + Buffer.from([0xff, 0xf2]), + Buffer.from([0x49, 0x44, 0x33]), + ], // WAV 'audio/wav': [Buffer.from([0x52, 0x49, 0x46, 0x46])], // RIFF diff --git a/src/media/validation/upload-progress.service.ts b/src/media/validation/upload-progress.service.ts index 096322c4..0795e07f 100644 --- a/src/media/validation/upload-progress.service.ts +++ b/src/media/validation/upload-progress.service.ts @@ -4,7 +4,14 @@ import { UPLOAD_PROGRESS_CONFIG } from './file-validation.constants'; export interface UploadProgress { uploadId: string; - status: 'pending' | 'validating' | 'scanning' | 'processing' | 'uploading' | 'completed' | 'failed'; + status: + | 'pending' + | 'validating' + | 'scanning' + | 'processing' + | 'uploading' + | 'completed' + | 'failed'; progress: number; // 0-100 fileName: string; fileSize: number; @@ -181,8 +188,8 @@ export class UploadProgressService { } } - return uploads.sort((a, b) => - new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() + return uploads.sort( + (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(), ); } catch (error) { this.logger.error('Failed to list active uploads:', error); @@ -214,8 +221,10 @@ export class UploadProgressService { const updatedAt = new Date(progress.updatedAt).getTime(); // Delete if old and completed/failed - if ((progress.status === 'completed' || progress.status === 'failed') && - (now - updatedAt > maxAgeMs)) { + if ( + (progress.status === 'completed' || progress.status === 'failed') && + now - updatedAt > maxAgeMs + ) { await this.redis.del(keys[i]); deletedCount++; } @@ -301,11 +310,7 @@ export class UploadProgressService { */ private async saveProgress(uploadId: string, progress: UploadProgress): Promise { const key = this.getRedisKey(uploadId); - await this.redis.setex( - key, - UPLOAD_PROGRESS_CONFIG.EXPIRY_SECONDS, - JSON.stringify(progress), - ); + await this.redis.setex(key, UPLOAD_PROGRESS_CONFIG.EXPIRY_SECONDS, JSON.stringify(progress)); } /** diff --git a/src/monitoring/scheduled-task-monitoring.service.ts b/src/monitoring/scheduled-task-monitoring.service.ts index af446c4f..5dcffda4 100644 --- a/src/monitoring/scheduled-task-monitoring.service.ts +++ b/src/monitoring/scheduled-task-monitoring.service.ts @@ -200,17 +200,21 @@ export class ScheduledTaskMonitoringService { const tasks = Array.from(this.taskConfigs.entries()).map(([taskName, config]) => { const history = this.executionHistory.get(taskName) || []; const lastExecution = history[history.length - 1] || null; - const lastSuccess = [...history].reverse().find((entry) => entry.status === 'SUCCESS') || null; + const lastSuccess = + [...history].reverse().find((entry) => entry.status === 'SUCCESS') || null; const lastFailure = - [...history].reverse().find((entry) => entry.status === 'FAILED' || entry.status === 'TIMED_OUT') || - null; + [...history] + .reverse() + .find((entry) => entry.status === 'FAILED' || entry.status === 'TIMED_OUT') || null; const activeCount = Array.from(this.activeExecutions.values()).filter( (entry) => entry.taskName === taskName, ).length; const threshold = config.expectedIntervalMs + (config.missedExecutionGraceMs || 0); const missed = - !!lastExecution && now.getTime() - lastExecution.startedAt.getTime() > threshold && activeCount === 0; + !!lastExecution && + now.getTime() - lastExecution.startedAt.getTime() > threshold && + activeCount === 0; const retryStats = this.retryStats.get(taskName) || { totalRetries: 0 }; @@ -234,7 +238,8 @@ export class ScheduledTaskMonitoringService { activeExecutions: this.activeExecutions.size, tasksWithMissedExecutions: tasks.filter((task) => task.missed).length, tasksWithRecentFailures: tasks.filter( - (task) => task.lastExecution?.status === 'FAILED' || task.lastExecution?.status === 'TIMED_OUT', + (task) => + task.lastExecution?.status === 'FAILED' || task.lastExecution?.status === 'TIMED_OUT', ).length, }, tasks, diff --git a/src/notifications/notifications.controller.ts b/src/notifications/notifications.controller.ts index 7581abaa..b04ad4cd 100644 --- a/src/notifications/notifications.controller.ts +++ b/src/notifications/notifications.controller.ts @@ -1,7 +1,6 @@ import { Controller, Get, - Post, Patch, Delete, Param, @@ -15,7 +14,7 @@ import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiResponse } from '@ne import { NotificationsService } from './notifications.service'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; -import { NotificationResponseDto, UpdateNotificationDto } from './dto/notification.dto'; +import { NotificationResponseDto } from './dto/notification.dto'; import { NotificationPreferences } from './entities/notification-preferences.entity'; @ApiTags('Notifications') diff --git a/src/notifications/preferences/preferences.service.ts b/src/notifications/preferences/preferences.service.ts index a52ee17a..77fab88c 100644 --- a/src/notifications/preferences/preferences.service.ts +++ b/src/notifications/preferences/preferences.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { NotificationPreferences } from '../entities/notification-preferences.entity'; @@ -28,7 +28,10 @@ export class PreferencesService { /** * Update user preferences */ - async updatePreferences(userId: string, updateDto: Partial): Promise { + async updatePreferences( + userId: string, + updateDto: Partial, + ): Promise { const preferences = await this.getPreferences(userId); Object.assign(preferences, updateDto); return this.preferencesRepository.save(preferences); @@ -37,7 +40,10 @@ export class PreferencesService { /** * Check if a specific channel is enabled for a user */ - async isChannelEnabled(userId: string, channel: 'emailEnabled' | 'pushEnabled' | 'inAppEnabled' | 'smsEnabled'): Promise { + async isChannelEnabled( + userId: string, + channel: 'emailEnabled' | 'pushEnabled' | 'inAppEnabled' | 'smsEnabled', + ): Promise { const preferences = await this.getPreferences(userId); return !!preferences[channel]; } @@ -45,7 +51,10 @@ export class PreferencesService { /** * Toggle a specific channel for a user */ - async toggleChannel(userId: string, channel: 'emailEnabled' | 'pushEnabled' | 'inAppEnabled' | 'smsEnabled'): Promise { + async toggleChannel( + userId: string, + channel: 'emailEnabled' | 'pushEnabled' | 'inAppEnabled' | 'smsEnabled', + ): Promise { const preferences = await this.getPreferences(userId); preferences[channel] = !preferences[channel]; await this.preferencesRepository.save(preferences); diff --git a/src/search/autocomplete/autocomplete.service.ts b/src/search/autocomplete/autocomplete.service.ts index 6f64f450..65a2929e 100644 --- a/src/search/autocomplete/autocomplete.service.ts +++ b/src/search/autocomplete/autocomplete.service.ts @@ -22,8 +22,6 @@ export class AutoCompleteService { }); const options = result.suggest?.title_suggest?.[0]?.options ?? []; - return Array.isArray(options) - ? options.map((option: any) => option.text as string) - : []; + return Array.isArray(options) ? options.map((option: any) => option.text as string) : []; } } diff --git a/src/search/indexing/indexing.service.ts b/src/search/indexing/indexing.service.ts index 4f3b7a93..35b53139 100644 --- a/src/search/indexing/indexing.service.ts +++ b/src/search/indexing/indexing.service.ts @@ -61,7 +61,7 @@ export class IndexingService implements OnModuleInit { await this.elasticsearchService.delete({ index: COURSES_INDEX, id }); } - async reindexAll(courses: Record[]) { + async reindexAll(courses: Array>) { if (courses.length === 0) return; const operations = courses.flatMap((course) => { diff --git a/src/search/search.service.ts b/src/search/search.service.ts index b8234245..082b4d3b 100644 --- a/src/search/search.service.ts +++ b/src/search/search.service.ts @@ -91,12 +91,7 @@ export class SearchService { price_ranges: { range: { field: 'price', - ranges: [ - { to: 50 }, - { from: 50, to: 100 }, - { from: 100, to: 200 }, - { from: 200 }, - ], + ranges: [{ to: 50 }, { from: 50, to: 100 }, { from: 100, to: 200 }, { from: 200 }], }, }, }, @@ -237,12 +232,7 @@ export class SearchService { })); } - private logSearch( - query: string, - resultsCount: number, - filters?: any, - sort?: string, - ): void { + private logSearch(query: string, resultsCount: number, filters?: any, sort?: string): void { // Fire-and-forget: analytics must not slow down or fail search responses this.elasticsearchService .index({ diff --git a/src/users/users.module.ts b/src/users/users.module.ts index 0f12e68c..0372d320 100644 --- a/src/users/users.module.ts +++ b/src/users/users.module.ts @@ -19,13 +19,7 @@ import { BullModule.registerQueue({ name: 'user-data-export' }), ], controllers: [UsersController], - providers: [ - UsersService, - ExportService, - UserDataExportProcessor, - RolesGuard, - JwtAuthGuard, - ], + providers: [UsersService, ExportService, UserDataExportProcessor, RolesGuard, JwtAuthGuard], exports: [UsersService], }) export class UsersModule {}