From 58933b15999f73b7ca91bca7faff09560a9b6f29 Mon Sep 17 00:00:00 2001 From: APKLEO Date: Thu, 22 Oct 2026 10:06:22 +0100 Subject: [PATCH] feat(puzzles): implement puzzle archive restore with audit and validation (#472) --- .../admin-puzzles.controller.spec.ts | 51 ++++ .../controllers/admin-puzzles.controller.ts | 36 +++ src/puzzles/dto/bulk-operations.dto.ts | 1 + src/puzzles/dto/search-puzzle.dto.ts | 5 + src/puzzles/entities/puzzle.entity.ts | 10 + src/puzzles/puzzles.controller.ts | 20 ++ src/puzzles/puzzles.service.spec.ts | 222 ++++++++++++++++-- src/puzzles/puzzles.service.ts | 175 +++++++++++++- .../services/puzzle-validation.service.ts | 48 ++++ 9 files changed, 546 insertions(+), 22 deletions(-) diff --git a/src/admin/controllers/__tests__/admin-puzzles.controller.spec.ts b/src/admin/controllers/__tests__/admin-puzzles.controller.spec.ts index a1af8f22..d4369aea 100644 --- a/src/admin/controllers/__tests__/admin-puzzles.controller.spec.ts +++ b/src/admin/controllers/__tests__/admin-puzzles.controller.spec.ts @@ -22,6 +22,8 @@ describe('AdminPuzzlesController', () => { create: jest.fn(), update: jest.fn(), remove: jest.fn(), + archive: jest.fn(), + restore: jest.fn(), }, }, { @@ -71,4 +73,53 @@ describe('AdminPuzzlesController', () => { }); }); }); + + describe('archive', () => { + it('should call puzzlesService.archive and auditLogService.log', async () => { + const mockPuzzle = { id: 'puzzle-id', title: 'Archived Puzzle' }; + (puzzlesService.archive as jest.Mock).mockResolvedValue(mockPuzzle); + + const result = await controller.archive('puzzle-id', mockUser); + + expect(puzzlesService.archive).toHaveBeenCalledWith( + 'puzzle-id', + mockUser.id, + ); + expect(auditLogService.log).toHaveBeenCalledWith({ + adminId: mockUser.id, + action: 'ARCHIVE_PUZZLE', + targetType: 'PUZZLE', + targetId: 'puzzle-id', + details: { title: mockPuzzle.title }, + }); + expect(result).toEqual(mockPuzzle); + }); + }); + + describe('restore', () => { + it('should call puzzlesService.restore and auditLogService.log with actor details', async () => { + const mockPuzzle = { id: 'puzzle-id', title: 'Restored Puzzle' }; + (puzzlesService.restore as jest.Mock).mockResolvedValue(mockPuzzle); + + const result = await controller.restore('puzzle-id', mockUser); + + expect(puzzlesService.restore).toHaveBeenCalledWith( + 'puzzle-id', + mockUser.id, + ); + expect(auditLogService.log).toHaveBeenCalledWith( + expect.objectContaining({ + adminId: mockUser.id, + action: 'RESTORE_PUZZLE', + targetType: 'PUZZLE', + targetId: 'puzzle-id', + details: expect.objectContaining({ + title: mockPuzzle.title, + restoredBy: mockUser.id, + }), + }), + ); + expect(result).toEqual(mockPuzzle); + }); + }); }); diff --git a/src/admin/controllers/admin-puzzles.controller.ts b/src/admin/controllers/admin-puzzles.controller.ts index c69acac3..da343dc2 100644 --- a/src/admin/controllers/admin-puzzles.controller.ts +++ b/src/admin/controllers/admin-puzzles.controller.ts @@ -91,4 +91,40 @@ export class AdminPuzzlesController { targetId: id, }); } + + @Post(':id/archive') + async archive( + @Param('id', ParseUUIDPipe) id: string, + @ActiveUser() user: any, + ) { + const puzzle = await this.puzzlesService.archive(id, user.id); + await this.auditLogService.log({ + adminId: user.id, + action: 'ARCHIVE_PUZZLE', + targetType: 'PUZZLE', + targetId: id, + details: { title: puzzle.title }, + }); + return puzzle; + } + + @Post(':id/restore') + async restore( + @Param('id', ParseUUIDPipe) id: string, + @ActiveUser() user: any, + ) { + const puzzle = await this.puzzlesService.restore(id, user.id); + await this.auditLogService.log({ + adminId: user.id, + action: 'RESTORE_PUZZLE', + targetType: 'PUZZLE', + targetId: id, + details: { + title: puzzle.title, + restoredAt: new Date(), + restoredBy: user.id, + }, + }); + return puzzle; + } } diff --git a/src/puzzles/dto/bulk-operations.dto.ts b/src/puzzles/dto/bulk-operations.dto.ts index c711f6d6..4181b2b5 100644 --- a/src/puzzles/dto/bulk-operations.dto.ts +++ b/src/puzzles/dto/bulk-operations.dto.ts @@ -12,6 +12,7 @@ export enum BulkAction { PUBLISH = 'publish', UNPUBLISH = 'unpublish', ARCHIVE = 'archive', + RESTORE = 'restore', DELETE = 'delete', UPDATE_CATEGORY = 'update_category', ADD_TAGS = 'add_tags', diff --git a/src/puzzles/dto/search-puzzle.dto.ts b/src/puzzles/dto/search-puzzle.dto.ts index 26a01073..96f83fa2 100644 --- a/src/puzzles/dto/search-puzzle.dto.ts +++ b/src/puzzles/dto/search-puzzle.dto.ts @@ -71,6 +71,11 @@ export class SearchPuzzleDto { @Transform(({ value }: any) => value === 'true') isPublished?: boolean; + @IsOptional() + @IsBoolean() + @Transform(({ value }: any) => value === 'true' || value === true) + isArchived?: boolean; + @IsOptional() @IsString() createdBy?: string; diff --git a/src/puzzles/entities/puzzle.entity.ts b/src/puzzles/entities/puzzle.entity.ts index 56858ed0..26ac143e 100644 --- a/src/puzzles/entities/puzzle.entity.ts +++ b/src/puzzles/entities/puzzle.entity.ts @@ -22,6 +22,12 @@ import { Tag } from './tag.entity'; export class Puzzle { @Column({ type: 'timestamp with time zone', nullable: true }) archivedAt?: Date; + + @Column({ type: 'timestamp with time zone', nullable: true }) + restoredAt?: Date; + + @Column({ type: 'uuid', nullable: true }) + restoredBy?: string; @PrimaryGeneratedColumn('uuid') id: string; @@ -188,6 +194,10 @@ export class Puzzle { hard: number; expert: number; }; + archivedAt?: string; + archivedBy?: string; + restoredAt?: string; + restoredBy?: string; }; @CreateDateColumn() diff --git a/src/puzzles/puzzles.controller.ts b/src/puzzles/puzzles.controller.ts index 350b374a..42afd11a 100644 --- a/src/puzzles/puzzles.controller.ts +++ b/src/puzzles/puzzles.controller.ts @@ -157,6 +157,26 @@ export class PuzzlesController { return await this.puzzlesService.update(id, { isPublished: false }, userId); } + @Post(':id/archive') + async archive( + @Param('id', ParseUUIDPipe) id: string, + @Req() req: any, + ): Promise { + const userId = req.user?.id ?? 'temp-user-id'; + this.logger.log(`Archiving puzzle: ${id} by user: ${userId}`); + return await this.puzzlesService.archive(id, userId); + } + + @Post(':id/restore') + async restore( + @Param('id', ParseUUIDPipe) id: string, + @Req() req: any, + ): Promise { + const userId = req.user?.id ?? 'temp-user-id'; + this.logger.log(`Restoring puzzle: ${id} by user: ${userId}`); + return await this.puzzlesService.restore(id, userId); + } + @Post(':id/duplicate') async duplicate( @Param('id', ParseUUIDPipe) id: string, diff --git a/src/puzzles/puzzles.service.spec.ts b/src/puzzles/puzzles.service.spec.ts index 19558731..c734599b 100644 --- a/src/puzzles/puzzles.service.spec.ts +++ b/src/puzzles/puzzles.service.spec.ts @@ -4,20 +4,50 @@ import { getRepositoryToken } from '@nestjs/typeorm'; import { Puzzle } from './entities/puzzle.entity'; import { PuzzleProgress } from '../game-logic/entities/puzzle-progress.entity'; import { PuzzleRating } from './entities/puzzle-rating.entity'; +import { LocalizationService } from '../common/i18n/localization.service'; +import { PuzzleVersionService } from './services/puzzle-version.service'; +import { PuzzleValidationService } from './services/puzzle-validation.service'; +import { NotFoundException, BadRequestException } from '@nestjs/common'; describe('PuzzlesService', () => { let service: PuzzlesService; + let puzzleRepository: any; + let validationService: any; + let mockQueryBuilder: any; - const mockRepository = () => ({ - find: jest.fn(), - findOne: jest.fn(), - create: jest.fn(), - save: jest.fn(), - update: jest.fn(), - softDelete: jest.fn(), - createQueryBuilder: jest.fn(() => ({ + const validPuzzle: any = { + id: 'puzzle-123', + title: 'Valid Puzzle Title', + description: 'This is a sufficiently long puzzle description.', + category: 'logic', + difficulty: 'medium', + difficultyRating: 5, + basePoints: 100, + timeLimit: 300, + maxHints: 3, + isActive: true, + attempts: 10, + completions: 8, + averageRating: 4.5, + averageCompletionTime: 120, + content: { + type: 'multiple-choice', + question: 'What is 2 + 2?', + options: ['3', '4', '5'], + correctAnswer: '4', + }, + hints: [], + tags: ['math', 'logic'], + tagEntities: [], + metadata: {}, + }; + + beforeEach(async () => { + mockQueryBuilder = { where: jest.fn().mockReturnThis(), andWhere: jest.fn().mockReturnThis(), + withDeleted: jest.fn().mockReturnThis(), + leftJoinAndSelect: jest.fn().mockReturnThis(), orderBy: jest.fn().mockReturnThis(), skip: jest.fn().mockReturnThis(), take: jest.fn().mockReturnThis(), @@ -25,29 +55,193 @@ describe('PuzzlesService', () => { getOne: jest.fn().mockResolvedValue(null), getCount: jest.fn().mockResolvedValue(0), clone: jest.fn().mockReturnThis(), - })), - }); + }; + + const mockRepo = { + find: jest.fn(), + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn().mockImplementation(async (p: any) => p || validPuzzle), + update: jest.fn().mockResolvedValue({ affected: 1 }), + softDelete: jest.fn().mockResolvedValue({ affected: 1 }), + restore: jest.fn().mockResolvedValue({ affected: 1 }), + createQueryBuilder: jest.fn(() => mockQueryBuilder), + }; - beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ PuzzlesService, - { provide: getRepositoryToken(Puzzle), useFactory: mockRepository }, + { provide: getRepositoryToken(Puzzle), useValue: mockRepo }, { provide: getRepositoryToken(PuzzleProgress), - useFactory: mockRepository, + useValue: { ...mockRepo }, }, { provide: getRepositoryToken(PuzzleRating), - useFactory: mockRepository, + useValue: { ...mockRepo }, + }, + { + provide: LocalizationService, + useValue: { + translate: jest.fn((key, options) => + Promise.resolve(options?.defaultValue || key), + ), + }, + }, + { + provide: PuzzleVersionService, + useValue: { + snapshotBefore: jest.fn().mockResolvedValue(undefined), + }, + }, + { + provide: PuzzleValidationService, + useValue: { + validatePuzzle: jest + .fn() + .mockResolvedValue({ isValid: true, errors: [] }), + validatePuzzleForRestore: jest + .fn() + .mockReturnValue({ isValid: true, errors: [] }), + }, }, ], }).compile(); service = module.get(PuzzlesService); + puzzleRepository = module.get(getRepositoryToken(Puzzle)); + validationService = module.get( + PuzzleValidationService, + ); }); it('should be defined', () => { expect(service).toBeDefined(); }); + + describe('archive', () => { + it('should archive an active puzzle and record actor and timestamp', async () => { + const activePuzzle = { + ...validPuzzle, + deletedAt: null, + archivedAt: null, + isActive: true, + }; + mockQueryBuilder.getOne.mockResolvedValueOnce(activePuzzle); + + const result = await service.archive('puzzle-123', 'admin-user-1'); + + expect(puzzleRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + archivedAt: expect.any(Date), + isActive: false, + metadata: expect.objectContaining({ + archivedBy: 'admin-user-1', + }), + }), + ); + expect(puzzleRepository.softDelete).toHaveBeenCalledWith('puzzle-123'); + expect(result.id).toBe('puzzle-123'); + expect(result.isActive).toBe(false); + }); + + it('should throw NotFoundException if puzzle does not exist or is already archived', async () => { + mockQueryBuilder.getOne.mockResolvedValueOnce(null); + + await expect( + service.archive('missing-id', 'admin-user-1'), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('restore', () => { + it('should restore an archived puzzle, clear archivedAt/deletedAt, audit actor and timestamp, and re-index for search', async () => { + const archivedPuzzle = { + ...validPuzzle, + deletedAt: new Date(), + archivedAt: new Date(), + isActive: false, + metadata: {}, + }; + mockQueryBuilder.getOne.mockResolvedValueOnce(archivedPuzzle); + const reindexSpy = jest + .spyOn(service, 'reindexPuzzle') + .mockResolvedValueOnce(undefined); + + const result = await service.restore('puzzle-123', 'admin-user-1'); + + expect(validationService.validatePuzzleForRestore).toHaveBeenCalledWith( + archivedPuzzle, + ); + expect(puzzleRepository.restore).toHaveBeenCalledWith('puzzle-123'); + expect(puzzleRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + deletedAt: null, + archivedAt: null, + isActive: true, + restoredBy: 'admin-user-1', + restoredAt: expect.any(Date), + metadata: expect.objectContaining({ + restoredBy: 'admin-user-1', + restoredAt: expect.any(String), + }), + }), + ); + expect(reindexSpy).toHaveBeenCalled(); + expect(result.id).toBe('puzzle-123'); + expect(result.isActive).toBe(true); + }); + + it('should throw NotFoundException if puzzle does not exist', async () => { + mockQueryBuilder.getOne.mockResolvedValueOnce(null); + + await expect( + service.restore('missing-id', 'admin-user-1'), + ).rejects.toThrow(NotFoundException); + }); + + it('should throw BadRequestException if puzzle is not archived', async () => { + const unarchivedPuzzle = { + ...validPuzzle, + deletedAt: null, + archivedAt: null, + isActive: true, + }; + mockQueryBuilder.getOne.mockResolvedValueOnce(unarchivedPuzzle); + + await expect( + service.restore('puzzle-123', 'admin-user-1'), + ).rejects.toThrow(BadRequestException); + }); + + it('should block restore if puzzle fails validation', async () => { + const invalidArchivedPuzzle = { + ...validPuzzle, + deletedAt: new Date(), + archivedAt: new Date(), + title: '', + }; + mockQueryBuilder.getOne.mockResolvedValueOnce(invalidArchivedPuzzle); + validationService.validatePuzzleForRestore.mockReturnValueOnce({ + isValid: false, + errors: ['Title must be at least 3 characters long'], + }); + + await expect( + service.restore('puzzle-123', 'admin-user-1'), + ).rejects.toThrow(BadRequestException); + expect(puzzleRepository.restore).not.toHaveBeenCalled(); + }); + }); + + describe('reindexPuzzle', () => { + it('should touch updatedAt timestamp to re-index puzzle for search', async () => { + await service.reindexPuzzle(validPuzzle); + + expect(puzzleRepository.update).toHaveBeenCalledWith( + validPuzzle.id, + expect.objectContaining({ updatedAt: expect.any(Date) }), + ); + }); + }); }); diff --git a/src/puzzles/puzzles.service.ts b/src/puzzles/puzzles.service.ts index daf17e22..1993a300 100644 --- a/src/puzzles/puzzles.service.ts +++ b/src/puzzles/puzzles.service.ts @@ -19,6 +19,7 @@ import { PuzzleProgress } from '../game-logic/entities/puzzle-progress.entity'; import { PuzzleRating } from './entities/puzzle-rating.entity'; import { LocalizationService } from '../common/i18n/localization.service'; import { PuzzleVersionService } from './services/puzzle-version.service'; +import { PuzzleValidationService } from './services/puzzle-validation.service'; import { CreatePuzzleDto, UpdatePuzzleDto, @@ -102,6 +103,7 @@ export class PuzzlesService { private ratingRepository: Repository, private readonly localizationService: LocalizationService, private readonly puzzleVersionService: PuzzleVersionService, + private readonly puzzleValidationService: PuzzleValidationService, ) {} async create( @@ -172,6 +174,7 @@ export class PuzzlesService { tags, isFeatured, isPublished, + isArchived, createdBy, page = 1, limit = 20, @@ -181,8 +184,25 @@ export class PuzzlesService { const queryBuilder = this.puzzleRepository .createQueryBuilder('puzzle') - .leftJoinAndSelect('puzzle.tagEntities', 'tag') - .where('puzzle.deletedAt IS NULL'); + .leftJoinAndSelect('puzzle.tagEntities', 'tag'); + + if (isArchived !== undefined) { + if (isArchived) { + queryBuilder + .withDeleted() + .where( + '(puzzle.deletedAt IS NOT NULL OR puzzle.archivedAt IS NOT NULL)', + ); + } else { + queryBuilder.where( + 'puzzle.deletedAt IS NULL AND puzzle.archivedAt IS NULL', + ); + } + } else { + queryBuilder.where( + 'puzzle.deletedAt IS NULL AND puzzle.archivedAt IS NULL', + ); + } // Apply filters if (search) { @@ -278,13 +298,25 @@ export class PuzzlesService { } } - async findOne(id: string, userId?: string): Promise { + async findOne( + id: string, + userId?: string, + includeDeleted = false, + ): Promise { try { - const puzzle = await this.puzzleRepository + const query = this.puzzleRepository .createQueryBuilder('puzzle') - .where('puzzle.id = :id', { id }) - .andWhere('puzzle.deletedAt IS NULL') - .getOne(); + .where('puzzle.id = :id', { id }); + + if (includeDeleted) { + query.withDeleted(); + } else { + query.andWhere( + 'puzzle.deletedAt IS NULL AND puzzle.archivedAt IS NULL', + ); + } + + const puzzle = await query.getOne(); if (!puzzle) { throw new NotFoundException(`Puzzle with ID ${id} not found`); @@ -607,10 +639,137 @@ export class PuzzlesService { }); break; case BulkAction.ARCHIVE: - await this.puzzleRepository.softDelete(puzzleId); + await this.archive(puzzleId, userId); + break; + case BulkAction.RESTORE: + await this.restore(puzzleId, userId); break; default: throw new BadRequestException(`Unsupported bulk action: ${action}`); } } + + /** + * Archive a puzzle: marks archivedAt, sets isActive=false, audits author/time, and soft-deletes. + */ + async archive(id: string, userId: string): Promise { + try { + const puzzle = await this.puzzleRepository + .createQueryBuilder('puzzle') + .leftJoinAndSelect('puzzle.tagEntities', 'tag') + .where('puzzle.id = :id', { id }) + .andWhere('puzzle.deletedAt IS NULL AND puzzle.archivedAt IS NULL') + .getOne(); + + if (!puzzle) { + throw new NotFoundException(`Puzzle with ID ${id} not found or already archived`); + } + + const now = new Date(); + puzzle.archivedAt = now; + puzzle.isActive = false; + puzzle.metadata = { + ...puzzle.metadata, + archivedAt: now.toISOString(), + archivedBy: userId, + lastModifiedBy: userId, + }; + + await this.puzzleRepository.save(puzzle); + await this.puzzleRepository.softDelete(id); + this.logger.log(`Archived puzzle: ${id} by user: ${userId}`); + + const [enhancedPuzzle] = await this.enhanceWithStats([puzzle]); + return enhancedPuzzle; + } catch (error) { + this.logger.error( + `Failed to archive puzzle ${id}: ${error.message}`, + error.stack, + ); + throw error; + } + } + + /** + * Restore an archived puzzle: + * 1. Verifies the puzzle exists and is archived + * 2. Validates puzzle integrity (blocking restore if invalid) + * 3. Clears archivedAt and deletedAt, restores active state + * 4. Records restore actor and timestamp + * 5. Re-indexes for search and gameplay + */ + async restore(id: string, userId: string): Promise { + try { + // Find the puzzle including soft-deleted rows + const puzzle = await this.puzzleRepository + .createQueryBuilder('puzzle') + .withDeleted() + .leftJoinAndSelect('puzzle.tagEntities', 'tag') + .where('puzzle.id = :id', { id }) + .getOne(); + + if (!puzzle) { + throw new NotFoundException(`Puzzle with ID ${id} not found`); + } + + if (!puzzle.deletedAt && !puzzle.archivedAt) { + throw new BadRequestException(`Puzzle with ID ${id} is not archived`); + } + + // Block restore if puzzle fails validation + const validation = + this.puzzleValidationService.validatePuzzleForRestore(puzzle); + if (!validation.isValid) { + throw new BadRequestException( + `Cannot restore invalid puzzle: ${validation.errors.join(', ')}`, + ); + } + + const now = new Date(); + puzzle.deletedAt = null; + puzzle.archivedAt = null; + puzzle.isActive = true; + puzzle.restoredAt = now; + puzzle.restoredBy = userId; + puzzle.metadata = { + ...puzzle.metadata, + restoredAt: now.toISOString(), + restoredBy: userId, + lastModifiedBy: userId, + }; + + // Restore soft-delete status in DB + await this.puzzleRepository.restore(id); + const savedPuzzle = await this.puzzleRepository.save(puzzle); + + // Re-index for search discovery + await this.reindexPuzzle(savedPuzzle); + + this.logger.log( + `Restored puzzle: ${id} by user: ${userId} at ${now.toISOString()}`, + ); + + const [enhancedPuzzle] = await this.enhanceWithStats([savedPuzzle]); + return enhancedPuzzle; + } catch (error) { + this.logger.error( + `Failed to restore puzzle ${id}: ${error.message}`, + error.stack, + ); + throw error; + } + } + + /** + * Re-indexes a puzzle so it immediately reappears in full-text and filtered searches. + */ + async reindexPuzzle(puzzle: Puzzle): Promise { + if (!puzzle || !puzzle.id) { + return; + } + this.logger.log( + `Re-indexing puzzle for search: ${puzzle.id} (${puzzle.title})`, + ); + await this.puzzleRepository.update(puzzle.id, { updatedAt: new Date() }); + } } diff --git a/src/puzzles/services/puzzle-validation.service.ts b/src/puzzles/services/puzzle-validation.service.ts index 7ec734bb..b96577a6 100644 --- a/src/puzzles/services/puzzle-validation.service.ts +++ b/src/puzzles/services/puzzle-validation.service.ts @@ -91,6 +91,54 @@ export class PuzzleValidationService { }; } + /** + * Validate an archived puzzle before allowing restoration. + * Ensures essential integrity (title, description, category, valid answer, hint bounds) + * so that invalid or corrupt puzzles cannot be restored to active play or search. + */ + validatePuzzleForRestore(puzzle: Partial): { + isValid: boolean; + errors: string[]; + } { + const errors: string[] = []; + + if (!puzzle.title || typeof puzzle.title !== 'string' || puzzle.title.trim().length < 3) { + errors.push('Title must be at least 3 characters long'); + } + + if (!puzzle.description || typeof puzzle.description !== 'string' || puzzle.description.trim().length < 10) { + errors.push('Description must be at least 10 characters long'); + } + + if (!puzzle.category) { + errors.push('Puzzle must have a category'); + } + + if (!puzzle.content) { + errors.push('Puzzle must have content defined'); + } else { + if (!puzzle.content.type) { + errors.push('Puzzle content must specify a type'); + } + if (!this.validateAnswer(puzzle.content)) { + errors.push('Puzzle must have a valid correct answer'); + } + } + + if ( + Array.isArray(puzzle.hints) && + puzzle.maxHints !== undefined && + puzzle.hints.length > puzzle.maxHints + ) { + errors.push('Number of hints exceeds maximum allowed'); + } + + return { + isValid: errors.length === 0, + errors, + }; + } + private validateAnswer(content: any): boolean { if (!content.correctAnswer) return false;