Skip to content
Open
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
40 changes: 33 additions & 7 deletions src/puzzles/category.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,17 @@ import {
Patch,
Param,
Delete,
Query,
ParseUUIDPipe,
} from '@nestjs/common';
import { CategoriesService } from './category.service';
import { CreateCategoryDto } from './dto/create-category.dto';
import { UpdateCategoryDto } from './dto/update-category.dto';
import { CategoryFilterDto, CategoryAnalyticsDto } from './dto/category-filter.dto';
import { Category } from './entities/category.entity';
import { Puzzle } from './entities/puzzle.entity';

@Controller('categories') // Base path for category API
@Controller('categories')
export class CategoriesController {
constructor(private readonly categoriesService: CategoriesService) {}

Expand All @@ -22,9 +25,36 @@ export class CategoriesController {
return this.categoriesService.create(createCategoryDto);
}

@Get('hierarchy')
getHierarchy(): Promise<Category[]> {
return this.categoriesService.getHierarchy();
}

@Get('recommendations')
getRecommendations(
@Query('limit') limit?: number,
): Promise<Category[]> {
return this.categoriesService.getRecommendations(limit ? Number(limit) : 5);
}

@Get()
findAll(): Promise<Category[]> {
return this.categoriesService.findAll();
findAll(@Query() filter: CategoryFilterDto): Promise<Category[]> {
return this.categoriesService.findAll(filter);
}

@Get(':id/analytics')
getAnalytics(
@Param('id', ParseUUIDPipe) id: string,
): Promise<CategoryAnalyticsDto[]> {
return this.categoriesService.getCategoryAnalytics(id);
}

@Get(':id/puzzles')
findCategoryPuzzles(
@Param('id', ParseUUIDPipe) id: string,
@Query() filter: CategoryFilterDto,
): Promise<Puzzle[]> {
return this.categoriesService.searchPuzzlesByCategory(id, filter);
}

@Get(':id')
Expand All @@ -44,8 +74,4 @@ export class CategoriesController {
remove(@Param('id', ParseUUIDPipe) id: string): Promise<void> {
return this.categoriesService.remove(id);
}

// Potentially add endpoints for linking puzzles to categories later if needed
// e.g., POST /categories/:id/puzzles/:puzzleId
// e.g., DELETE /categories/:id/puzzles/:puzzleId
}
266 changes: 235 additions & 31 deletions src/puzzles/category.service.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,86 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Repository, IsNull } from 'typeorm';
import { Category } from './entities/category.entity';
import { CreateCategoryDto } from './dto/create-category.dto';
import { UpdateCategoryDto } from './dto/update-category.dto';
import { Puzzle } from './entities/puzzle.entity'; // Import Puzzle entity for relation management
import { CategoryFilterDto, CategoryAnalyticsDto } from './dto/category-filter.dto';
import { Puzzle } from './entities/puzzle.entity';

@Injectable()
export class CategoriesService {
private readonly logger = new Logger(CategoriesService.name);

constructor(
@InjectRepository(Category)
private categoriesRepository: Repository<Category>,
@InjectRepository(Puzzle) // Inject Puzzle repository for potential relation operations
private puzzlesRepository: Repository<Puzzle>,
private readonly categoriesRepository: Repository<Category>,
@InjectRepository(Puzzle)
private readonly puzzlesRepository: Repository<Puzzle>,
) {}

async create(createCategoryDto: CreateCategoryDto): Promise<Category> {
const category = this.categoriesRepository.create(createCategoryDto);
if (createCategoryDto.parentId) {
const parent = await this.categoriesRepository.findOne({
where: { id: createCategoryDto.parentId },
});
if (!parent) {
throw new NotFoundException(
`Parent category with ID "${createCategoryDto.parentId}" not found`,
);
}
}

const slug =
createCategoryDto.slug ||
createCategoryDto.name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)+/g, '');

const category = this.categoriesRepository.create({
...createCategoryDto,
slug,
displayOrder: createCategoryDto.displayOrder ?? 0,
isActive: createCategoryDto.isActive ?? true,
});

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
});
async findAll(filter?: CategoryFilterDto): Promise<Category[]> {
const qb = this.categoriesRepository.createQueryBuilder('category');

if (filter?.isActive !== undefined) {
qb.andWhere('category.isActive = :isActive', { isActive: filter.isActive });
}

if (filter?.parentId) {
qb.andWhere('category.parentId = :parentId', { parentId: filter.parentId });
}

if (filter?.search) {
qb.andWhere(
'(LOWER(category.name) LIKE LOWER(:search) OR LOWER(category.description) LIKE LOWER(:search))',
{ search: `%${filter.search}%` },
);
}

qb.orderBy('category.displayOrder', 'ASC').addOrderBy('category.name', 'ASC');

if (filter?.limit) {
qb.take(filter.limit);
}
if (filter?.offset) {
qb.skip(filter.offset);
}

return qb.getMany();
}

async findOne(id: string): Promise<Category> {
const category = await this.categoriesRepository.findOne({
where: { id },
// relations: ['puzzles'], // Include puzzles for detailed view
relations: ['parent', 'children', 'puzzles'],
});
if (!category) {
throw new NotFoundException(`Category with ID "${id}" not found`);
Expand All @@ -41,36 +92,189 @@ export class CategoriesService {
id: string,
updateCategoryDto: UpdateCategoryDto,
): Promise<Category> {
const category = await this.findOne(id); // Find existing category
Object.assign(category, updateCategoryDto); // Apply updates
const category = await this.findOne(id);

if (updateCategoryDto.parentId) {
if (updateCategoryDto.parentId === id) {
throw new BadRequestException('A category cannot be its own parent');
}
const parent = await this.categoriesRepository.findOne({
where: { id: updateCategoryDto.parentId },
});
if (!parent) {
throw new NotFoundException(
`Parent category with ID "${updateCategoryDto.parentId}" not found`,
);
}
}

Object.assign(category, updateCategoryDto);
return this.categoriesRepository.save(category);
}

async remove(id: string): Promise<void> {
const category = await this.findOne(id);
await this.findOne(id);
const result = await this.categoriesRepository.delete(id);
if (result.affected === 0) {
throw new NotFoundException(`Category with ID "${id}" not found`);
}
}

/**
* Builds and returns full hierarchical tree of categories.
*/
async getHierarchy(): Promise<Category[]> {
const allCategories = await this.categoriesRepository.find({
relations: ['children', 'puzzles'],
order: { displayOrder: 'ASC', name: 'ASC' },
});

const categoryMap = new Map<string, Category>();
allCategories.forEach((cat) => {
cat.children = [];
categoryMap.set(cat.id, cat);
});

// Before deleting, consider how to handle associated puzzles.
// For example, you might want to:
// 1. Remove the category from all associated puzzles.
// 2. Prevent deletion if any puzzles are associated with it.
// 3. Cascade delete (less common for categories).
const roots: Category[] = [];
allCategories.forEach((cat) => {
if (cat.parentId && categoryMap.has(cat.parentId)) {
categoryMap.get(cat.parentId)!.children.push(cat);
} else {
roots.push(cat);
}
});

// For now, we will simply remove the category. If TypeORM is configured with cascade
// options for the relation, it might handle some aspects.
// A more robust approach would be to disassociate puzzles first.
return roots;
}

// Example: Disassociate puzzles
// category.puzzles = [];
// await this.categoriesRepository.save(category); // Save with empty puzzles array
/**
* Collects analytics and popularity score for category.
*/
async getCategoryAnalytics(id?: string): Promise<CategoryAnalyticsDto[]> {
const categories = id
? [await this.findOne(id)]
: await this.categoriesRepository.find({ relations: ['puzzles'] });

// A simpler approach for now: just remove the category.
const result = await this.categoriesRepository.delete(id);
if (result.affected === 0) {
throw new NotFoundException(`Category with ID "${id}" not found`);
return categories.map((cat) => {
const puzzles = cat.puzzles || [];
const totalPuzzles = puzzles.length;
const totalCompletions = puzzles.reduce(
(sum, p) => sum + (p.completions || 0),
0,
);
const totalRating = puzzles.reduce(
(sum, p) => sum + Number(p.averageRating || 0),
0,
);
const averageRating =
totalPuzzles > 0 ? Number((totalRating / totalPuzzles).toFixed(2)) : 0;

const difficultyBreakdown: Record<string, number> = {
easy: 0,
medium: 0,
hard: 0,
expert: 0,
};
puzzles.forEach((p) => {
if (p.difficulty && difficultyBreakdown[p.difficulty] !== undefined) {
difficultyBreakdown[p.difficulty]++;
}
});

// Popularity formula: completions * 2 + totalPuzzles * 5 + averageRating * 10
const popularityScore = Math.round(
totalCompletions * 2 + totalPuzzles * 5 + averageRating * 10,
);

return {
categoryId: cat.id,
categoryName: cat.name,
totalPuzzles,
totalCompletions,
averageRating,
difficultyBreakdown,
popularityScore,
};
});
}

/**
* Recommends active categories sorted by popularity and content density.
*/
async getRecommendations(limit = 5): Promise<Category[]> {
const analytics = await this.getCategoryAnalytics();
analytics.sort((a, b) => b.popularityScore - a.popularityScore);

const topIds = analytics.slice(0, limit).map((a) => a.categoryId);
if (topIds.length === 0) {
return this.categoriesRepository.find({ take: limit });
}

const categories = await this.categoriesRepository
.createQueryBuilder('category')
.where('category.id IN (:...ids)', { ids: topIds })
.getMany();

// Preserve order of popularity
const catMap = new Map(categories.map((c) => [c.id, c]));
return topIds
.map((tid) => catMap.get(tid))
.filter((c): c is Category => c !== undefined);
}

/**
* Searches and filters puzzles within a category and optionally its subcategories.
*/
async searchPuzzlesByCategory(
categoryId: string,
filter?: CategoryFilterDto,
): Promise<Puzzle[]> {
const category = await this.findOne(categoryId);

// Collect category ID and any child category IDs if includeChildren is true
let categoryIds = [category.id];
if (filter?.includeChildren && category.children?.length) {
categoryIds = categoryIds.concat(category.children.map((c) => c.id));
}

const qb = this.puzzlesRepository
.createQueryBuilder('puzzle')
.leftJoinAndSelect('puzzle.categories', 'category')
.leftJoinAndSelect('puzzle.tagEntities', 'tag')
.where('category.id IN (:...categoryIds)', { categoryIds });

if (filter?.difficulty) {
qb.andWhere('puzzle.difficulty = :difficulty', {
difficulty: filter.difficulty,
});
}

if (filter?.tags && filter.tags.length > 0) {
qb.andWhere('tag.name IN (:...tags)', { tags: filter.tags });
}

if (filter?.search) {
qb.andWhere(
'(LOWER(puzzle.title) LIKE LOWER(:search) OR LOWER(puzzle.description) LIKE LOWER(:search))',
{ search: `%${filter.search}%` },
);
}

qb.orderBy('puzzle.averageRating', 'DESC').addOrderBy(
'puzzle.createdAt',
'DESC',
);

if (filter?.limit) {
qb.take(filter.limit);
}
if (filter?.offset) {
qb.skip(filter.offset);
}

return qb.getMany();
}

// Helper method to get repository if needed in other services
getCategoryRepository(): Repository<Category> {
return this.categoriesRepository;
}
Expand Down
Loading