diff --git a/backend/src/modules/tags/controllers/tags.controller.ts b/backend/src/modules/tags/controllers/tags.controller.ts new file mode 100644 index 0000000..744f43f --- /dev/null +++ b/backend/src/modules/tags/controllers/tags.controller.ts @@ -0,0 +1,124 @@ +import { + Controller, + Get, + Post, + Body, + Param, + Delete, + Put, + UseGuards, + Query, +} from '@nestjs/common'; +import { TagsService } from '../services/tags.service'; +import { CreateTagDto } from '../dto/create-tag.dto'; +import { UpdateTagDto } from '../dto/update-tag.dto'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; + +@Controller('tags') +@UseGuards(JwtAuthGuard) +export class TagsController { + constructor(private readonly tagsService: TagsService) {} + + /** + * Create a new tag + */ + @Post() + create(@Body() createTagDto: CreateTagDto) { + return this.tagsService.create(createTagDto); + } + + /** + * Get all tags or filter by type + */ + @Get() + findAll(@Query('type') type?: 'PROJECT' | 'PERSON') { + if (type) { + return this.tagsService.findByType(type); + } + return this.tagsService.findAll(); + } + + /** + * Get a tag by ID + */ + @Get(':id') + findOne(@Param('id') id: string) { + return this.tagsService.findById(id); + } + + /** + * Update a tag + */ + @Put(':id') + update(@Param('id') id: string, @Body() updateTagDto: UpdateTagDto) { + return this.tagsService.update(id, updateTagDto); + } + + /** + * Delete a tag + */ + @Delete(':id') + remove(@Param('id') id: string) { + return this.tagsService.remove(id); + } + + /** + * Add a tag to a person + */ + @Post('persons/:personId/tags/:tagId') + addTagToPerson( + @Param('personId') personId: string, + @Param('tagId') tagId: string, + ) { + return this.tagsService.addTagToPerson(tagId, personId); + } + + /** + * Remove a tag from a person + */ + @Delete('persons/:personId/tags/:tagId') + removeTagFromPerson( + @Param('personId') personId: string, + @Param('tagId') tagId: string, + ) { + return this.tagsService.removeTagFromPerson(tagId, personId); + } + + /** + * Get all tags for a person + */ + @Get('persons/:personId/tags') + getTagsForPerson(@Param('personId') personId: string) { + return this.tagsService.getTagsForPerson(personId); + } + + /** + * Add a tag to a project + */ + @Post('projects/:projectId/tags/:tagId') + addTagToProject( + @Param('projectId') projectId: string, + @Param('tagId') tagId: string, + ) { + return this.tagsService.addTagToProject(tagId, projectId); + } + + /** + * Remove a tag from a project + */ + @Delete('projects/:projectId/tags/:tagId') + removeTagFromProject( + @Param('projectId') projectId: string, + @Param('tagId') tagId: string, + ) { + return this.tagsService.removeTagFromProject(tagId, projectId); + } + + /** + * Get all tags for a project + */ + @Get('projects/:projectId/tags') + getTagsForProject(@Param('projectId') projectId: string) { + return this.tagsService.getTagsForProject(projectId); + } +} \ No newline at end of file diff --git a/backend/src/modules/tags/dto/create-tag.dto.ts b/backend/src/modules/tags/dto/create-tag.dto.ts new file mode 100644 index 0000000..347305b --- /dev/null +++ b/backend/src/modules/tags/dto/create-tag.dto.ts @@ -0,0 +1,32 @@ +import { IsNotEmpty, IsString, IsEnum, Matches } from 'class-validator'; + +/** + * DTO for creating a new tag + */ +export class CreateTagDto { + /** + * The name of the tag + */ + @IsNotEmpty() + @IsString() + name: string; + + /** + * The color of the tag (hex format) + */ + @IsNotEmpty() + @IsString() + @Matches(/^#[0-9A-Fa-f]{6}$/, { + message: 'Color must be a valid hex color code (e.g., #FF5733)', + }) + color: string; + + /** + * The type of the tag (PROJECT or PERSON) + */ + @IsNotEmpty() + @IsEnum(['PROJECT', 'PERSON'], { + message: 'Type must be either PROJECT or PERSON', + }) + type: 'PROJECT' | 'PERSON'; +} \ No newline at end of file diff --git a/backend/src/modules/tags/dto/update-tag.dto.ts b/backend/src/modules/tags/dto/update-tag.dto.ts new file mode 100644 index 0000000..e5ef075 --- /dev/null +++ b/backend/src/modules/tags/dto/update-tag.dto.ts @@ -0,0 +1,32 @@ +import { IsString, IsEnum, Matches, IsOptional } from 'class-validator'; + +/** + * DTO for updating an existing tag + */ +export class UpdateTagDto { + /** + * The name of the tag + */ + @IsOptional() + @IsString() + name?: string; + + /** + * The color of the tag (hex format) + */ + @IsOptional() + @IsString() + @Matches(/^#[0-9A-Fa-f]{6}$/, { + message: 'Color must be a valid hex color code (e.g., #FF5733)', + }) + color?: string; + + /** + * The type of the tag (PROJECT or PERSON) + */ + @IsOptional() + @IsEnum(['PROJECT', 'PERSON'], { + message: 'Type must be either PROJECT or PERSON', + }) + type?: 'PROJECT' | 'PERSON'; +} \ No newline at end of file diff --git a/backend/src/modules/tags/services/tags.service.ts b/backend/src/modules/tags/services/tags.service.ts new file mode 100644 index 0000000..5cf91e3 --- /dev/null +++ b/backend/src/modules/tags/services/tags.service.ts @@ -0,0 +1,277 @@ +import { Injectable, NotFoundException, Inject } from '@nestjs/common'; +import { eq, and } from 'drizzle-orm'; +import { DRIZZLE } from '../../../database/database.module'; +import * as schema from '../../../database/schema'; +import { CreateTagDto } from '../dto/create-tag.dto'; +import { UpdateTagDto } from '../dto/update-tag.dto'; + +@Injectable() +export class TagsService { + constructor(@Inject(DRIZZLE) private readonly db: any) {} + + /** + * Create a new tag + */ + async create(createTagDto: CreateTagDto) { + const [tag] = await this.db + .insert(schema.tags) + .values({ + ...createTagDto, + }) + .returning(); + return tag; + } + + /** + * Find all tags + */ + async findAll() { + return this.db.select().from(schema.tags); + } + + /** + * Find tags by type + */ + async findByType(type: 'PROJECT' | 'PERSON') { + return this.db + .select() + .from(schema.tags) + .where(eq(schema.tags.type, type)); + } + + /** + * Find a tag by ID + */ + async findById(id: string) { + const [tag] = await this.db + .select() + .from(schema.tags) + .where(eq(schema.tags.id, id)); + + if (!tag) { + throw new NotFoundException(`Tag with ID ${id} not found`); + } + + return tag; + } + + /** + * Update a tag + */ + async update(id: string, updateTagDto: UpdateTagDto) { + const [tag] = await this.db + .update(schema.tags) + .set({ + ...updateTagDto, + updatedAt: new Date(), + }) + .where(eq(schema.tags.id, id)) + .returning(); + + if (!tag) { + throw new NotFoundException(`Tag with ID ${id} not found`); + } + + return tag; + } + + /** + * Delete a tag + */ + async remove(id: string) { + const [tag] = await this.db + .delete(schema.tags) + .where(eq(schema.tags.id, id)) + .returning(); + + if (!tag) { + throw new NotFoundException(`Tag with ID ${id} not found`); + } + + return tag; + } + + /** + * Add a tag to a person + */ + async addTagToPerson(tagId: string, personId: string) { + // Check if the tag exists and is of type PERSON + const tag = await this.findById(tagId); + if (tag.type !== 'PERSON') { + throw new Error(`Tag with ID ${tagId} is not of type PERSON`); + } + + // Check if the person exists + const [person] = await this.db + .select() + .from(schema.persons) + .where(eq(schema.persons.id, personId)); + + if (!person) { + throw new NotFoundException(`Person with ID ${personId} not found`); + } + + // Check if the tag is already associated with the person + const [existingRelation] = await this.db + .select() + .from(schema.personToTag) + .where( + and( + eq(schema.personToTag.personId, personId), + eq(schema.personToTag.tagId, tagId) + ) + ); + + if (existingRelation) { + return existingRelation; + } + + // Add the tag to the person + const [relation] = await this.db + .insert(schema.personToTag) + .values({ + personId, + tagId, + }) + .returning(); + + return relation; + } + + /** + * Remove a tag from a person + */ + async removeTagFromPerson(tagId: string, personId: string) { + const [relation] = await this.db + .delete(schema.personToTag) + .where( + and( + eq(schema.personToTag.personId, personId), + eq(schema.personToTag.tagId, tagId) + ) + ) + .returning(); + + if (!relation) { + throw new NotFoundException(`Tag with ID ${tagId} is not associated with person with ID ${personId}`); + } + + return relation; + } + + /** + * Add a tag to a project + */ + async addTagToProject(tagId: string, projectId: string) { + // Check if the tag exists and is of type PROJECT + const tag = await this.findById(tagId); + if (tag.type !== 'PROJECT') { + throw new Error(`Tag with ID ${tagId} is not of type PROJECT`); + } + + // Check if the project exists + const [project] = await this.db + .select() + .from(schema.projects) + .where(eq(schema.projects.id, projectId)); + + if (!project) { + throw new NotFoundException(`Project with ID ${projectId} not found`); + } + + // Check if the tag is already associated with the project + const [existingRelation] = await this.db + .select() + .from(schema.projectToTag) + .where( + and( + eq(schema.projectToTag.projectId, projectId), + eq(schema.projectToTag.tagId, tagId) + ) + ); + + if (existingRelation) { + return existingRelation; + } + + // Add the tag to the project + const [relation] = await this.db + .insert(schema.projectToTag) + .values({ + projectId, + tagId, + }) + .returning(); + + return relation; + } + + /** + * Remove a tag from a project + */ + async removeTagFromProject(tagId: string, projectId: string) { + const [relation] = await this.db + .delete(schema.projectToTag) + .where( + and( + eq(schema.projectToTag.projectId, projectId), + eq(schema.projectToTag.tagId, tagId) + ) + ) + .returning(); + + if (!relation) { + throw new NotFoundException(`Tag with ID ${tagId} is not associated with project with ID ${projectId}`); + } + + return relation; + } + + /** + * Get all tags for a person + */ + async getTagsForPerson(personId: string) { + // Check if the person exists + const [person] = await this.db + .select() + .from(schema.persons) + .where(eq(schema.persons.id, personId)); + + if (!person) { + throw new NotFoundException(`Person with ID ${personId} not found`); + } + + // Get all tags for the person + return this.db + .select({ + tag: schema.tags, + }) + .from(schema.personToTag) + .innerJoin(schema.tags, eq(schema.personToTag.tagId, schema.tags.id)) + .where(eq(schema.personToTag.personId, personId)); + } + + /** + * Get all tags for a project + */ + async getTagsForProject(projectId: string) { + // Check if the project exists + const [project] = await this.db + .select() + .from(schema.projects) + .where(eq(schema.projects.id, projectId)); + + if (!project) { + throw new NotFoundException(`Project with ID ${projectId} not found`); + } + + // Get all tags for the project + return this.db + .select({ + tag: schema.tags, + }) + .from(schema.projectToTag) + .innerJoin(schema.tags, eq(schema.projectToTag.tagId, schema.tags.id)) + .where(eq(schema.projectToTag.projectId, projectId)); + } +} \ No newline at end of file diff --git a/backend/src/modules/tags/tags.module.ts b/backend/src/modules/tags/tags.module.ts new file mode 100644 index 0000000..99a4d66 --- /dev/null +++ b/backend/src/modules/tags/tags.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { TagsController } from './controllers/tags.controller'; +import { TagsService } from './services/tags.service'; + +@Module({ + controllers: [TagsController], + providers: [TagsService], + exports: [TagsService], +}) +export class TagsModule {} \ No newline at end of file