diff --git a/src/migrations/1761000000000-add-puzzle-category-hierarchy.ts b/src/migrations/1761000000000-add-puzzle-category-hierarchy.ts new file mode 100644 index 00000000..7d60c3b6 --- /dev/null +++ b/src/migrations/1761000000000-add-puzzle-category-hierarchy.ts @@ -0,0 +1,37 @@ +import { MigrationInterface, QueryRunner, TableColumn, TableForeignKey, TableIndex } from 'typeorm'; + +export class AddPuzzleCategoryHierarchy1761000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + const table = await queryRunner.getTable('category'); + if (!table) return; + + if (!table.findColumnByName('slug')) { + await queryRunner.addColumn('category', new TableColumn({ name: 'slug', type: 'varchar', length: '100', isNullable: true })); + } + if (!table.findColumnByName('sortOrder')) { + await queryRunner.addColumn('category', new TableColumn({ name: 'sortOrder', type: 'int', default: 0 })); + } + if (!table.findColumnByName('parentCategoryId')) { + await queryRunner.addColumn('category', new TableColumn({ name: 'parentCategoryId', type: 'uuid', isNullable: true })); + await queryRunner.createForeignKey('category', new TableForeignKey({ + columnNames: ['parentCategoryId'], referencedTableName: 'category', + referencedColumnNames: ['id'], onDelete: 'SET NULL', + })); + } + await queryRunner.createIndex('category', new TableIndex({ name: 'IDX_category_parent_sort', columnNames: ['parentCategoryId', 'sortOrder'] })); + await queryRunner.query('CREATE UNIQUE INDEX IF NOT EXISTS "IDX_category_slug_unique" ON "category" ("slug") WHERE "slug" IS NOT NULL'); + } + + public async down(queryRunner: QueryRunner): Promise { + const table = await queryRunner.getTable('category'); + if (!table) return; + await queryRunner.query('DROP INDEX IF EXISTS "IDX_category_slug_unique"'); + const index = table.indices.find((item) => item.name === 'IDX_category_parent_sort'); + if (index) await queryRunner.dropIndex('category', index); + const fk = table.foreignKeys.find((item) => item.columnNames.includes('parentCategoryId')); + if (fk) await queryRunner.dropForeignKey('category', fk); + for (const name of ['parentCategoryId', 'sortOrder', 'slug']) { + if (table.findColumnByName(name)) await queryRunner.dropColumn('category', name); + } + } +} diff --git a/src/puzzles/category.controller.ts b/src/puzzles/category.controller.ts index 8fc7f680..e522c6ce 100644 --- a/src/puzzles/category.controller.ts +++ b/src/puzzles/category.controller.ts @@ -27,6 +27,11 @@ export class CategoriesController { return this.categoriesService.findAll(); } + @Get('analytics') + analytics() { + return this.categoriesService.analytics(); + } + @Get(':id') findOne(@Param('id', ParseUUIDPipe) id: string): Promise { return this.categoriesService.findOne(id); diff --git a/src/puzzles/category.service.spec.ts b/src/puzzles/category.service.spec.ts new file mode 100644 index 00000000..992d7a52 --- /dev/null +++ b/src/puzzles/category.service.spec.ts @@ -0,0 +1,69 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { CategoriesService } from './category.service'; +import { Category } from './entities/category.entity'; +import { Puzzle } from './entities/puzzle.entity'; + +describe('CategoriesService', () => { + let service: CategoriesService; + const categories = new Map(); + const repository = { + create: jest.fn((data) => data), + save: jest.fn(async (category) => category), + find: jest.fn(async () => [...categories.values()]), + findOne: jest.fn(async ({ where }) => categories.get(where.id) ?? null), + delete: jest.fn(async () => ({ affected: 1 })), + createQueryBuilder: jest.fn(() => { + const query = { + leftJoin: jest.fn(() => query), select: jest.fn(() => query), + addSelect: jest.fn(() => query), groupBy: jest.fn(() => query), + addGroupBy: jest.fn(() => query), orderBy: jest.fn(() => query), + getRawMany: jest.fn(async () => []), + }; + return query; + }), + }; + + beforeEach(async () => { + categories.clear(); + jest.clearAllMocks(); + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CategoriesService, + { provide: getRepositoryToken(Category), useValue: repository }, + { provide: getRepositoryToken(Puzzle), useValue: {} }, + ], + }).compile(); + service = module.get(CategoriesService); + }); + + it('creates a category beneath its selected parent', async () => { + const parent = { id: 'parent', name: 'Logic' } as Category; + categories.set(parent.id, parent); + await service.create({ name: 'Deduction', parentCategoryId: parent.id }); + expect(repository.create).toHaveBeenCalledWith({ name: 'Deduction', parentCategory: parent }); + }); + + it('rejects hierarchy cycles', async () => { + const child = { id: 'child', parentCategory: { id: 'root' } } as Category; + const root = { id: 'root', parentCategory: null } as Category; + categories.set('child', child); + categories.set('root', root); + await expect(service.update('root', { parentCategoryId: 'child' })).rejects.toThrow( + 'Category hierarchy cannot contain cycles', + ); + }); + + it('returns category popularity metrics', async () => { + const metrics = [{ id: 'root', name: 'Logic', puzzleCount: '4', attempts: '20', completions: '12' }]; + repository.createQueryBuilder.mockReturnValueOnce({ + leftJoin: jest.fn().mockReturnThis(), select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), groupBy: jest.fn().mockReturnThis(), + addGroupBy: jest.fn().mockReturnThis(), orderBy: jest.fn().mockReturnThis(), + getRawMany: jest.fn().mockResolvedValue(metrics), + }); + await expect(service.analytics()).resolves.toEqual([ + { ...metrics[0], puzzleCount: 4, attempts: 20, completions: 12 }, + ]); + }); +}); diff --git a/src/puzzles/category.service.ts b/src/puzzles/category.service.ts index c84bae32..128d0377 100644 --- a/src/puzzles/category.service.ts +++ b/src/puzzles/category.service.ts @@ -1,4 +1,4 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Category } from './entities/category.entity'; @@ -16,20 +16,25 @@ export class CategoriesService { ) {} async create(createCategoryDto: CreateCategoryDto): Promise { - const category = this.categoriesRepository.create(createCategoryDto); + const { parentCategoryId, ...data } = createCategoryDto; + const parentCategory = parentCategoryId + ? await this.findOne(parentCategoryId) + : undefined; + const category = this.categoriesRepository.create({ ...data, parentCategory }); return this.categoriesRepository.save(category); } async findAll(): Promise { return this.categoriesRepository.find({ - // relations: ['puzzles'], // Include puzzles if you want to see them in the list + relations: ['parentCategory', 'children'], + order: { sortOrder: 'ASC', name: 'ASC' }, }); } async findOne(id: string): Promise { const category = await this.categoriesRepository.findOne({ where: { id }, - // relations: ['puzzles'], // Include puzzles for detailed view + relations: ['parentCategory'], }); if (!category) { throw new NotFoundException(`Category with ID "${id}" not found`); @@ -42,10 +47,53 @@ export class CategoriesService { updateCategoryDto: UpdateCategoryDto, ): Promise { const category = await this.findOne(id); // Find existing category - Object.assign(category, updateCategoryDto); // Apply updates + const { parentCategoryId, ...data } = updateCategoryDto; + if (parentCategoryId !== undefined) { + if (parentCategoryId === id) { + throw new BadRequestException('A category cannot be its own parent'); + } + category.parentCategory = parentCategoryId + ? await this.findOne(parentCategoryId) + : null; + // Prevent introducing a cycle in the hierarchy. + let ancestor = category.parentCategory; + while (ancestor) { + if (ancestor.id === id) { + throw new BadRequestException('Category hierarchy cannot contain cycles'); + } + ancestor = ancestor.parentCategory; + if (ancestor?.id) { + ancestor = await this.categoriesRepository.findOne({ + where: { id: ancestor.id }, relations: ['parentCategory'], + }); + } + } + } + Object.assign(category, data); return this.categoriesRepository.save(category); } + async analytics() { + const rows = await this.categoriesRepository + .createQueryBuilder('category') + .leftJoin('category.puzzles', 'puzzle') + .select('category.id', 'id') + .addSelect('category.name', 'name') + .addSelect('COUNT(DISTINCT puzzle.id)', 'puzzleCount') + .addSelect('COALESCE(SUM(puzzle.attempts), 0)', 'attempts') + .addSelect('COALESCE(SUM(puzzle.completions), 0)', 'completions') + .groupBy('category.id') + .addGroupBy('category.name') + .orderBy('COUNT(DISTINCT puzzle.id)', 'DESC') + .getRawMany(); + return rows.map((row) => ({ + ...row, + puzzleCount: Number(row.puzzleCount), + attempts: Number(row.attempts), + completions: Number(row.completions), + })); + } + async remove(id: string): Promise { const category = await this.findOne(id); diff --git a/src/puzzles/dto/create-category.dto.ts b/src/puzzles/dto/create-category.dto.ts index cf1975d4..1347b735 100644 --- a/src/puzzles/dto/create-category.dto.ts +++ b/src/puzzles/dto/create-category.dto.ts @@ -1,4 +1,4 @@ -import { IsString, IsOptional, IsUUID } from 'class-validator'; +import { IsString, IsOptional, IsUUID, IsInt, Min, MaxLength } from 'class-validator'; export class CreateCategoryDto { @IsString() @@ -8,6 +8,20 @@ export class CreateCategoryDto { @IsString() description?: string; + @IsOptional() + @IsString() + @MaxLength(100) + slug?: string; + + @IsOptional() + @IsUUID() + parentCategoryId?: string; + + @IsOptional() + @IsInt() + @Min(0) + sortOrder?: number; + // We might not need to link puzzles or collections directly during creation, // as those can be managed via separate endpoints or by updating the puzzle/collection. // However, if the schema implies a direct link that must be set at creation, diff --git a/src/puzzles/entities/category.entity.ts b/src/puzzles/entities/category.entity.ts index 656eddb7..82f5e183 100644 --- a/src/puzzles/entities/category.entity.ts +++ b/src/puzzles/entities/category.entity.ts @@ -4,11 +4,16 @@ import { Column, ManyToMany, JoinTable, + ManyToOne, + OneToMany, + JoinColumn, + Index, } from 'typeorm'; import { Puzzle } from './puzzle.entity'; import { Collection } from './collection.entity'; @Entity() +@Index(['parentCategory', 'sortOrder']) export class Category { @PrimaryGeneratedColumn('uuid') id: string; @@ -19,6 +24,19 @@ export class Category { @Column({ nullable: true }) description?: string; + @Column({ type: 'varchar', length: 100, unique: true, nullable: true }) + slug?: string; + + @Column({ type: 'int', default: 0 }) + sortOrder: number; + + @ManyToOne(() => Category, (category) => category.children, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'parentCategoryId' }) + parentCategory?: Category; + + @OneToMany(() => Category, (category) => category.parentCategory) + children: Category[]; + @ManyToMany(() => Puzzle, (puzzle) => puzzle.categories) @JoinTable() puzzles: Puzzle[]; diff --git a/src/puzzles/puzzles.service.ts b/src/puzzles/puzzles.service.ts index 4b3d2434..daf17e22 100644 --- a/src/puzzles/puzzles.service.ts +++ b/src/puzzles/puzzles.service.ts @@ -187,7 +187,11 @@ export class PuzzlesService { // Apply filters if (search) { queryBuilder.andWhere( - '(puzzle.title ILIKE :search OR puzzle.description ILIKE :search)', + `(puzzle.title ILIKE :search OR puzzle.description ILIKE :search + OR puzzle.category ILIKE :search + OR EXISTS (SELECT 1 FROM puzzle_tags search_pt + INNER JOIN tags search_tag ON search_tag.id = search_pt."tagId" + WHERE search_pt."puzzleId" = puzzle.id AND search_tag.name ILIKE :search))`, { search: `%${search}%` }, ); } @@ -480,13 +484,11 @@ export class PuzzlesService { // 2. Extract categories and tags const categories = new Set(); - const tags = new Set(); const playedPuzzleIds = new Set(); topRatings.forEach((r) => { if (r.puzzle) { categories.add(r.puzzle.category); - r.puzzle.tags.forEach((t) => tags.add(t)); playedPuzzleIds.add(r.puzzle.id); } }); @@ -511,7 +513,8 @@ export class PuzzlesService { } }), ) - .orderBy('puzzle.averageRating', 'DESC') + .orderBy('puzzle.completions', 'DESC') + .addOrderBy('puzzle.averageRating', 'DESC') .take(limit); const puzzles = await queryBuilder.getMany(); diff --git a/src/puzzles/tags.controller.ts b/src/puzzles/tags.controller.ts index bc805a8f..fb44d781 100644 --- a/src/puzzles/tags.controller.ts +++ b/src/puzzles/tags.controller.ts @@ -37,4 +37,9 @@ export class TagsController { async findAll(@Query() dto: ListTagsDto): Promise { return this.tagsService.listTags(dto); } + + @Get('trending') + async trending(@Query('limit') limit?: string) { + return this.tagsService.trending(limit ? Number(limit) : 10); + } } diff --git a/src/puzzles/tags.service.ts b/src/puzzles/tags.service.ts index 15bc2bab..82563503 100644 --- a/src/puzzles/tags.service.ts +++ b/src/puzzles/tags.service.ts @@ -57,6 +57,27 @@ export class TagsService { .getMany(); } + /** Return tags used by the most recently published puzzles. */ + async trending(limit = 10): Promise> { + const since = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + const boundedLimit = Math.min(Math.max(Math.trunc(limit) || 10, 1), 50); + const rows = await this.tagRepository + .createQueryBuilder('tag') + .innerJoin('tag.puzzles', 'puzzle') + .where('puzzle.publishedAt >= :since', { since }) + .andWhere('puzzle.deletedAt IS NULL') + .select('tag.id', 'id') + .addSelect('tag.name', 'name') + .addSelect('COUNT(DISTINCT puzzle.id)', 'usageCount') + .groupBy('tag.id') + .addGroupBy('tag.name') + .orderBy('COUNT(DISTINCT puzzle.id)', 'DESC') + .addOrderBy('tag.name', 'ASC') + .limit(boundedLimit) + .getRawMany(); + return rows.map((row) => ({ ...row, usageCount: Number(row.usageCount) })); + } + /** * Find a tag by id or throw NotFoundException. */ @@ -74,7 +95,7 @@ export class TagsService { * usageCount is incremented for each newly attached tag. */ async attachTags(puzzleId: string, tagNames: string[]): Promise { - const normalised = tagNames.map((t) => t.trim().toLowerCase()); + const normalised = [...new Set(tagNames.map((t) => t.trim().toLowerCase()).filter(Boolean))]; const puzzle = await this.puzzleRepository.findOne({ where: { id: puzzleId }, @@ -103,7 +124,8 @@ export class TagsService { // Determine which tags are truly new for this puzzle const currentIds = new Set((puzzle.tagEntities ?? []).map((t) => t.id)); - const newTags = resolvedTags.filter((t) => !currentIds.has(t.id)); + const newTags = [...new Map(resolvedTags.map((tag) => [tag.id, tag])).values()] + .filter((tag) => !currentIds.has(tag.id)); // Attach new tags to the puzzle puzzle.tagEntities = [...(puzzle.tagEntities ?? []), ...newTags];