Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions src/migrations/1761000000000-add-puzzle-category-hierarchy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { MigrationInterface, QueryRunner, TableColumn, TableForeignKey, TableIndex } from 'typeorm';

export class AddPuzzleCategoryHierarchy1761000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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);
}
}
}
5 changes: 5 additions & 0 deletions src/puzzles/category.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Category> {
return this.categoriesService.findOne(id);
Expand Down
69 changes: 69 additions & 0 deletions src/puzzles/category.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, Category>();
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 },
]);
});
});
58 changes: 53 additions & 5 deletions src/puzzles/category.service.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -16,20 +16,25 @@ export class CategoriesService {
) {}

async create(createCategoryDto: CreateCategoryDto): Promise<Category> {
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<Category[]> {
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<Category> {
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`);
Expand All @@ -42,10 +47,53 @@ export class CategoriesService {
updateCategoryDto: UpdateCategoryDto,
): Promise<Category> {
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<void> {
const category = await this.findOne(id);

Expand Down
16 changes: 15 additions & 1 deletion src/puzzles/dto/create-category.dto.ts
Original file line number Diff line number Diff line change
@@ -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()
Expand All @@ -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,
Expand Down
18 changes: 18 additions & 0 deletions src/puzzles/entities/category.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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[];
Expand Down
11 changes: 7 additions & 4 deletions src/puzzles/puzzles.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}%` },
);
}
Expand Down Expand Up @@ -480,13 +484,11 @@ export class PuzzlesService {

// 2. Extract categories and tags
const categories = new Set<string>();
const tags = new Set<string>();
const playedPuzzleIds = new Set<string>();

topRatings.forEach((r) => {
if (r.puzzle) {
categories.add(r.puzzle.category);
r.puzzle.tags.forEach((t) => tags.add(t));
playedPuzzleIds.add(r.puzzle.id);
}
});
Expand All @@ -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();
Expand Down
5 changes: 5 additions & 0 deletions src/puzzles/tags.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,9 @@ export class TagsController {
async findAll(@Query() dto: ListTagsDto): Promise<Tag[]> {
return this.tagsService.listTags(dto);
}

@Get('trending')
async trending(@Query('limit') limit?: string) {
return this.tagsService.trending(limit ? Number(limit) : 10);
}
}
26 changes: 24 additions & 2 deletions src/puzzles/tags.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,27 @@ export class TagsService {
.getMany();
}

/** Return tags used by the most recently published puzzles. */
async trending(limit = 10): Promise<Array<{ id: string; name: string; usageCount: number }>> {
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.
*/
Expand All @@ -74,7 +95,7 @@ export class TagsService {
* usageCount is incremented for each newly attached tag.
*/
async attachTags(puzzleId: string, tagNames: string[]): Promise<Puzzle> {
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 },
Expand Down Expand Up @@ -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];
Expand Down