From 0c479b3e88731d1d0a64fcd30f24883c85bec7e2 Mon Sep 17 00:00:00 2001 From: Till JS Date: Thu, 26 Mar 2026 20:43:34 +0100 Subject: [PATCH] feat(tags): implement cross-app tag system with groups and entity links Backend (mana-core-auth): - Add tag_groups table (name, color, icon, sortOrder per user) - Add tag_links table (tagId + appId + entityId + entityType, cross-app) - Extend tags table with groupId and sortOrder fields - Tag Groups API: CRUD + reorder at /tag-groups - Tag Links API: link/unlink/bulk/sync/query at /tag-links - Tags API: updated DTOs for groupId/sortOrder Frontend client (@manacore/shared-tags): - Add TagGroup, TagLink types and response types - Add tag group methods: getGroups, createGroup, updateGroup, deleteGroup, reorderGroups - Add tag link methods: linkTag, bulkLinkTags, unlinkTag, getTagsForEntity, syncEntityTags Shared UI (@manacore/shared-ui): - Add TagStrip component with glass-pill styling, tag filtering, management link - Consistent look across all apps (replaces 3 app-specific implementations) Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/shared-tags/src/client.ts | 169 ++++++++- packages/shared-tags/src/types.ts | 92 +++++ packages/shared-ui/src/index.ts | 1 + .../shared-ui/src/navigation/TagStrip.svelte | 342 ++++++++++++++++++ packages/shared-ui/src/navigation/index.ts | 1 + services/mana-core-auth/src/app.module.ts | 4 + .../mana-core-auth/src/db/schema/index.ts | 2 + .../src/db/schema/tag-groups.schema.ts | 31 ++ .../src/db/schema/tag-links.schema.ts | 26 ++ .../src/db/schema/tags.schema.ts | 15 +- .../tag-groups/dto/create-tag-group.dto.ts | 22 ++ .../src/tag-groups/dto/index.ts | 2 + .../tag-groups/dto/update-tag-group.dto.ts | 23 ++ .../mana-core-auth/src/tag-groups/index.ts | 4 + .../src/tag-groups/tag-groups.controller.ts | 68 ++++ .../src/tag-groups/tag-groups.module.ts | 10 + .../src/tag-groups/tag-groups.service.ts | 171 +++++++++ .../src/tag-links/dto/create-tag-link.dto.ts | 44 +++ .../mana-core-auth/src/tag-links/dto/index.ts | 2 + .../src/tag-links/dto/query-tag-links.dto.ts | 19 + .../mana-core-auth/src/tag-links/index.ts | 4 + .../src/tag-links/tag-links.controller.ts | 88 +++++ .../src/tag-links/tag-links.module.ts | 10 + .../src/tag-links/tag-links.service.ts | 234 ++++++++++++ .../src/tags/dto/create-tag.dto.ts | 10 +- .../src/tags/dto/update-tag.dto.ts | 10 +- .../mana-core-auth/src/tags/tags.service.ts | 13 + 27 files changed, 1412 insertions(+), 5 deletions(-) create mode 100644 packages/shared-ui/src/navigation/TagStrip.svelte create mode 100644 services/mana-core-auth/src/db/schema/tag-groups.schema.ts create mode 100644 services/mana-core-auth/src/db/schema/tag-links.schema.ts create mode 100644 services/mana-core-auth/src/tag-groups/dto/create-tag-group.dto.ts create mode 100644 services/mana-core-auth/src/tag-groups/dto/index.ts create mode 100644 services/mana-core-auth/src/tag-groups/dto/update-tag-group.dto.ts create mode 100644 services/mana-core-auth/src/tag-groups/index.ts create mode 100644 services/mana-core-auth/src/tag-groups/tag-groups.controller.ts create mode 100644 services/mana-core-auth/src/tag-groups/tag-groups.module.ts create mode 100644 services/mana-core-auth/src/tag-groups/tag-groups.service.ts create mode 100644 services/mana-core-auth/src/tag-links/dto/create-tag-link.dto.ts create mode 100644 services/mana-core-auth/src/tag-links/dto/index.ts create mode 100644 services/mana-core-auth/src/tag-links/dto/query-tag-links.dto.ts create mode 100644 services/mana-core-auth/src/tag-links/index.ts create mode 100644 services/mana-core-auth/src/tag-links/tag-links.controller.ts create mode 100644 services/mana-core-auth/src/tag-links/tag-links.module.ts create mode 100644 services/mana-core-auth/src/tag-links/tag-links.service.ts diff --git a/packages/shared-tags/src/client.ts b/packages/shared-tags/src/client.ts index 3bdd30efb..9a8036542 100644 --- a/packages/shared-tags/src/client.ts +++ b/packages/shared-tags/src/client.ts @@ -1,4 +1,17 @@ -import type { Tag, TagResponse, CreateTagInput, UpdateTagInput } from './types'; +import type { + Tag, + TagResponse, + CreateTagInput, + UpdateTagInput, + TagGroup, + TagGroupResponse, + CreateTagGroupInput, + UpdateTagGroupInput, + TagLink, + TagLinkResponse, + CreateTagLinkInput, + SyncTagLinksInput, +} from './types'; /** * Configuration for TagsClient @@ -125,6 +138,139 @@ export class TagsClient { return tags.map(this.normalizeTag); } + // ── Tag Groups ────────────────────────────────────────── + + /** + * Get all tag groups for the current user + */ + async getGroups(): Promise { + const groups = await this.request('/tag-groups'); + return groups.map(this.normalizeTagGroup); + } + + /** + * Create a new tag group + */ + async createGroup(data: CreateTagGroupInput): Promise { + const group = await this.request('/tag-groups', { + method: 'POST', + body: JSON.stringify(data), + }); + return this.normalizeTagGroup(group); + } + + /** + * Update an existing tag group + */ + async updateGroup(id: string, data: UpdateTagGroupInput): Promise { + const group = await this.request(`/tag-groups/${id}`, { + method: 'PUT', + body: JSON.stringify(data), + }); + return this.normalizeTagGroup(group); + } + + /** + * Delete a tag group + */ + async deleteGroup(id: string): Promise { + await this.request(`/tag-groups/${id}`, { + method: 'DELETE', + }); + } + + /** + * Reorder tag groups by providing an ordered array of IDs + */ + async reorderGroups(ids: string[]): Promise { + await this.request('/tag-groups/reorder', { + method: 'PUT', + body: JSON.stringify({ ids }), + }); + } + + // ── Tag Links ─────────────────────────────────────────── + + /** + * Link a tag to an entity + */ + async linkTag(data: CreateTagLinkInput): Promise { + const link = await this.request('/tag-links', { + method: 'POST', + body: JSON.stringify(data), + }); + return this.normalizeTagLink(link); + } + + /** + * Bulk link multiple tags to entities + */ + async bulkLinkTags(links: CreateTagLinkInput[]): Promise { + const result = await this.request('/tag-links/bulk', { + method: 'POST', + body: JSON.stringify({ links }), + }); + return result.map(this.normalizeTagLink); + } + + /** + * Remove a tag link + */ + async unlinkTag(linkId: string): Promise { + await this.request(`/tag-links/${linkId}`, { + method: 'DELETE', + }); + } + + /** + * Get all tag links for a specific entity + */ + async getLinksForEntity(appId: string, entityId: string): Promise { + const links = await this.request( + `/tag-links?appId=${encodeURIComponent(appId)}&entityId=${encodeURIComponent(entityId)}` + ); + return links.map(this.normalizeTagLink); + } + + /** + * Get all tags for a specific entity (resolved Tag objects) + */ + async getTagsForEntity(appId: string, entityId: string): Promise { + const tags = await this.request( + `/tag-links/tags-for-entity?appId=${encodeURIComponent(appId)}&entityId=${encodeURIComponent(entityId)}` + ); + return tags.map(this.normalizeTag); + } + + /** + * Get all links for a specific tag + */ + async getLinksForTag(tagId: string): Promise { + const links = await this.request( + `/tag-links?tagId=${encodeURIComponent(tagId)}` + ); + return links.map(this.normalizeTagLink); + } + + /** + * Sync tags for an entity (add missing, remove extra) + */ + async syncEntityTags(data: SyncTagLinksInput): Promise<{ added: TagLink[]; removed: string[] }> { + const result = await this.request<{ added: TagLinkResponse[]; removed: string[] }>( + '/tag-links/sync', + { + method: 'POST', + body: JSON.stringify(data), + } + ); + return { + added: result.added.map(this.normalizeTagLink), + removed: result.removed, + }; + } + + // ── Normalizers ───────────────────────────────────────── + /** * Normalize API response to Tag type */ @@ -135,6 +281,27 @@ export class TagsClient { updatedAt: new Date(tag.updatedAt), }; } + + /** + * Normalize API response to TagGroup type + */ + private normalizeTagGroup(group: TagGroupResponse): TagGroup { + return { + ...group, + createdAt: new Date(group.createdAt), + updatedAt: new Date(group.updatedAt), + }; + } + + /** + * Normalize API response to TagLink type + */ + private normalizeTagLink(link: TagLinkResponse): TagLink { + return { + ...link, + createdAt: new Date(link.createdAt), + }; + } } /** diff --git a/packages/shared-tags/src/types.ts b/packages/shared-tags/src/types.ts index c962bac62..11a7485ef 100644 --- a/packages/shared-tags/src/types.ts +++ b/packages/shared-tags/src/types.ts @@ -8,6 +8,8 @@ export interface Tag { name: string; color: string; icon?: string | null; + groupId?: string | null; + sortOrder?: number; createdAt: Date | string; updatedAt: Date | string; } @@ -19,6 +21,8 @@ export interface CreateTagInput { name: string; color?: string; icon?: string; + groupId?: string | null; + sortOrder?: number; } /** @@ -28,6 +32,8 @@ export interface UpdateTagInput { name?: string; color?: string; icon?: string; + groupId?: string | null; + sortOrder?: number; } /** @@ -37,3 +43,89 @@ export type TagResponse = Omit & { createdAt: string; updatedAt: string; }; + +// ── Tag Groups ────────────────────────────────────────────── + +/** + * Tag group entity for organizing tags into categories + */ +export interface TagGroup { + id: string; + userId: string; + name: string; + color: string; + icon?: string | null; + sortOrder: number; + createdAt: Date | string; + updatedAt: Date | string; +} + +/** + * Input for creating a new tag group + */ +export interface CreateTagGroupInput { + name: string; + color?: string; + icon?: string; + sortOrder?: number; +} + +/** + * Input for updating an existing tag group + */ +export interface UpdateTagGroupInput { + name?: string; + color?: string; + icon?: string; + sortOrder?: number; +} + +/** + * API response type for tag groups + */ +export type TagGroupResponse = Omit & { + createdAt: string; + updatedAt: string; +}; + +// ── Tag Links ─────────────────────────────────────────────── + +/** + * Tag link entity that connects a tag to an entity in any app + */ +export interface TagLink { + id: string; + tagId: string; + appId: string; + entityId: string; + entityType: string; + userId: string; + createdAt: Date | string; +} + +/** + * Input for creating a tag link + */ +export interface CreateTagLinkInput { + tagId: string; + appId: string; + entityId: string; + entityType: string; +} + +/** + * Input for syncing tag links on an entity (replaces all tags) + */ +export interface SyncTagLinksInput { + appId: string; + entityId: string; + entityType: string; + tagIds: string[]; +} + +/** + * API response type for tag links + */ +export type TagLinkResponse = Omit & { + createdAt: string; +}; diff --git a/packages/shared-ui/src/index.ts b/packages/shared-ui/src/index.ts index 4bd6a5778..22080401e 100644 --- a/packages/shared-ui/src/index.ts +++ b/packages/shared-ui/src/index.ts @@ -91,6 +91,7 @@ export { PillToolbar, PillToolbarButton, PillToolbarDivider, + TagStrip, ExpandableToolbar, } from './navigation'; export type { diff --git a/packages/shared-ui/src/navigation/TagStrip.svelte b/packages/shared-ui/src/navigation/TagStrip.svelte new file mode 100644 index 000000000..e634177e1 --- /dev/null +++ b/packages/shared-ui/src/navigation/TagStrip.svelte @@ -0,0 +1,342 @@ + + +
+
+ + + + + + + {#if loading} +
Lädt...
+ {:else if !hasTags} + + {:else} + {#each sortedTags as tag (tag.id)} + + {/each} + + + {#if showCreateButton} + + {/if} + {/if} +
+
+ + diff --git a/packages/shared-ui/src/navigation/index.ts b/packages/shared-ui/src/navigation/index.ts index 5cc287faf..65edb0eec 100644 --- a/packages/shared-ui/src/navigation/index.ts +++ b/packages/shared-ui/src/navigation/index.ts @@ -11,6 +11,7 @@ export { default as PillViewSwitcher } from './PillViewSwitcher.svelte'; export { default as PillToolbar } from './PillToolbar.svelte'; export { default as PillToolbarButton } from './PillToolbarButton.svelte'; export { default as PillToolbarDivider } from './PillToolbarDivider.svelte'; +export { default as TagStrip } from './TagStrip.svelte'; export { ExpandableToolbar } from './expandable-toolbar'; export type { ExpandableToolbarProps } from './expandable-toolbar'; export type { diff --git a/services/mana-core-auth/src/app.module.ts b/services/mana-core-auth/src/app.module.ts index fd4de20a4..47c25c8be 100644 --- a/services/mana-core-auth/src/app.module.ts +++ b/services/mana-core-auth/src/app.module.ts @@ -14,6 +14,8 @@ import { GiftsModule } from './gifts/gifts.module'; import { HealthModule } from './health/health.module'; import { SettingsModule } from './settings/settings.module'; import { StorageModule } from './storage/storage.module'; +import { TagGroupsModule } from './tag-groups/tag-groups.module'; +import { TagLinksModule } from './tag-links/tag-links.module'; import { TagsModule } from './tags/tags.module'; import { MeModule } from './me/me.module'; import { SubscriptionsModule } from './subscriptions/subscriptions.module'; @@ -59,6 +61,8 @@ import { SecurityModule } from './security'; SettingsModule, StorageModule, TagsModule, + TagGroupsModule, + TagLinksModule, MeModule, StripeModule, SubscriptionsModule, diff --git a/services/mana-core-auth/src/db/schema/index.ts b/services/mana-core-auth/src/db/schema/index.ts index eff039c56..1d30c165b 100644 --- a/services/mana-core-auth/src/db/schema/index.ts +++ b/services/mana-core-auth/src/db/schema/index.ts @@ -6,4 +6,6 @@ export * from './gifts.schema'; export * from './login-attempts.schema'; export * from './organizations.schema'; export * from './subscriptions.schema'; +export * from './tag-groups.schema'; +export * from './tag-links.schema'; export * from './tags.schema'; diff --git a/services/mana-core-auth/src/db/schema/tag-groups.schema.ts b/services/mana-core-auth/src/db/schema/tag-groups.schema.ts new file mode 100644 index 000000000..e07b8bd20 --- /dev/null +++ b/services/mana-core-auth/src/db/schema/tag-groups.schema.ts @@ -0,0 +1,31 @@ +import { + pgTable, + varchar, + text, + uuid, + timestamp, + index, + unique, + integer, +} from 'drizzle-orm/pg-core'; + +export const tagGroups = pgTable( + 'tag_groups', + { + id: uuid('id').primaryKey().defaultRandom(), + userId: text('user_id').notNull(), + name: varchar('name', { length: 100 }).notNull(), + color: varchar('color', { length: 7 }).default('#3B82F6'), + icon: varchar('icon', { length: 50 }), + sortOrder: integer('sort_order').default(0).notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), + }, + (table) => [ + index('tag_groups_user_idx').on(table.userId), + unique('tag_groups_user_name_unique').on(table.userId, table.name), + ] +); + +export type TagGroup = typeof tagGroups.$inferSelect; +export type NewTagGroup = typeof tagGroups.$inferInsert; diff --git a/services/mana-core-auth/src/db/schema/tag-links.schema.ts b/services/mana-core-auth/src/db/schema/tag-links.schema.ts new file mode 100644 index 000000000..869a40f3c --- /dev/null +++ b/services/mana-core-auth/src/db/schema/tag-links.schema.ts @@ -0,0 +1,26 @@ +import { pgTable, varchar, text, uuid, timestamp, index, unique } from 'drizzle-orm/pg-core'; +import { tags } from './tags.schema'; + +export const tagLinks = pgTable( + 'tag_links', + { + id: uuid('id').primaryKey().defaultRandom(), + tagId: uuid('tag_id') + .notNull() + .references(() => tags.id, { onDelete: 'cascade' }), + appId: varchar('app_id', { length: 50 }).notNull(), + entityId: varchar('entity_id', { length: 255 }).notNull(), + entityType: varchar('entity_type', { length: 100 }).notNull(), + userId: text('user_id').notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), + }, + (table) => [ + index('tag_links_tag_idx').on(table.tagId), + index('tag_links_entity_idx').on(table.appId, table.entityId), + index('tag_links_user_app_idx').on(table.userId, table.appId), + unique('tag_links_unique').on(table.tagId, table.appId, table.entityId), + ] +); + +export type TagLink = typeof tagLinks.$inferSelect; +export type NewTagLink = typeof tagLinks.$inferInsert; diff --git a/services/mana-core-auth/src/db/schema/tags.schema.ts b/services/mana-core-auth/src/db/schema/tags.schema.ts index 9628c977a..b30a6a662 100644 --- a/services/mana-core-auth/src/db/schema/tags.schema.ts +++ b/services/mana-core-auth/src/db/schema/tags.schema.ts @@ -1,5 +1,13 @@ -import { pgTable, varchar, text, uuid, timestamp, index, unique } from 'drizzle-orm/pg-core'; -import { relations } from 'drizzle-orm'; +import { + pgTable, + varchar, + text, + uuid, + timestamp, + index, + unique, + integer, +} from 'drizzle-orm/pg-core'; /** * Central tags table for all Manacore applications. @@ -13,11 +21,14 @@ export const tags = pgTable( name: varchar('name', { length: 100 }).notNull(), color: varchar('color', { length: 7 }).default('#3B82F6'), icon: varchar('icon', { length: 50 }), // Optional: Phosphor Icon name + groupId: uuid('group_id'), // Reference to tag_groups (validated in service layer) + sortOrder: integer('sort_order').default(0).notNull(), createdAt: timestamp('created_at').defaultNow().notNull(), updatedAt: timestamp('updated_at').defaultNow().notNull(), }, (table) => [ index('tags_user_idx').on(table.userId), + index('tags_group_idx').on(table.groupId), unique('tags_user_name_unique').on(table.userId, table.name), ] ); diff --git a/services/mana-core-auth/src/tag-groups/dto/create-tag-group.dto.ts b/services/mana-core-auth/src/tag-groups/dto/create-tag-group.dto.ts new file mode 100644 index 000000000..e2e8b3515 --- /dev/null +++ b/services/mana-core-auth/src/tag-groups/dto/create-tag-group.dto.ts @@ -0,0 +1,22 @@ +import { IsString, IsOptional, IsInt, MaxLength, Matches } from 'class-validator'; + +export class CreateTagGroupDto { + @IsString() + @MaxLength(100) + name: string; + + @IsOptional() + @IsString() + @MaxLength(7) + @Matches(/^#[0-9A-Fa-f]{6}$/, { message: 'color must be a valid hex color (e.g., #3B82F6)' }) + color?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + icon?: string; + + @IsOptional() + @IsInt() + sortOrder?: number; +} diff --git a/services/mana-core-auth/src/tag-groups/dto/index.ts b/services/mana-core-auth/src/tag-groups/dto/index.ts new file mode 100644 index 000000000..0139a1369 --- /dev/null +++ b/services/mana-core-auth/src/tag-groups/dto/index.ts @@ -0,0 +1,2 @@ +export * from './create-tag-group.dto'; +export * from './update-tag-group.dto'; diff --git a/services/mana-core-auth/src/tag-groups/dto/update-tag-group.dto.ts b/services/mana-core-auth/src/tag-groups/dto/update-tag-group.dto.ts new file mode 100644 index 000000000..065bb0492 --- /dev/null +++ b/services/mana-core-auth/src/tag-groups/dto/update-tag-group.dto.ts @@ -0,0 +1,23 @@ +import { IsString, IsOptional, IsInt, MaxLength, Matches } from 'class-validator'; + +export class UpdateTagGroupDto { + @IsOptional() + @IsString() + @MaxLength(100) + name?: string; + + @IsOptional() + @IsString() + @MaxLength(7) + @Matches(/^#[0-9A-Fa-f]{6}$/, { message: 'color must be a valid hex color (e.g., #3B82F6)' }) + color?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + icon?: string; + + @IsOptional() + @IsInt() + sortOrder?: number; +} diff --git a/services/mana-core-auth/src/tag-groups/index.ts b/services/mana-core-auth/src/tag-groups/index.ts new file mode 100644 index 000000000..ac9b381e7 --- /dev/null +++ b/services/mana-core-auth/src/tag-groups/index.ts @@ -0,0 +1,4 @@ +export * from './tag-groups.module'; +export * from './tag-groups.service'; +export * from './tag-groups.controller'; +export * from './dto'; diff --git a/services/mana-core-auth/src/tag-groups/tag-groups.controller.ts b/services/mana-core-auth/src/tag-groups/tag-groups.controller.ts new file mode 100644 index 000000000..b05cf5187 --- /dev/null +++ b/services/mana-core-auth/src/tag-groups/tag-groups.controller.ts @@ -0,0 +1,68 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + UseGuards, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { TagGroupsService } from './tag-groups.service'; +import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; +import { CurrentUser } from '../common/decorators/current-user.decorator'; +import type { CurrentUserData } from '../common/decorators/current-user.decorator'; +import { CreateTagGroupDto, UpdateTagGroupDto } from './dto'; + +@Controller('tag-groups') +@UseGuards(JwtAuthGuard) +export class TagGroupsController { + constructor(private readonly tagGroupsService: TagGroupsService) {} + + /** + * Get all tag groups for the authenticated user + */ + @Get() + async findAll(@CurrentUser() user: CurrentUserData) { + return this.tagGroupsService.findByUserId(user.userId); + } + + /** + * Create a new tag group + */ + @Post() + async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateTagGroupDto) { + return this.tagGroupsService.create(user.userId, dto); + } + + /** + * Reorder tag groups + */ + @Put('reorder') + async reorder(@CurrentUser() user: CurrentUserData, @Body() body: { ids: string[] }) { + return this.tagGroupsService.reorder(user.userId, body.ids); + } + + /** + * Update an existing tag group + */ + @Put(':id') + async update( + @CurrentUser() user: CurrentUserData, + @Param('id') id: string, + @Body() dto: UpdateTagGroupDto + ) { + return this.tagGroupsService.update(id, user.userId, dto); + } + + /** + * Delete a tag group (tags in group get groupId = null) + */ + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + async delete(@CurrentUser() user: CurrentUserData, @Param('id') id: string) { + await this.tagGroupsService.delete(id, user.userId); + } +} diff --git a/services/mana-core-auth/src/tag-groups/tag-groups.module.ts b/services/mana-core-auth/src/tag-groups/tag-groups.module.ts new file mode 100644 index 000000000..661bde0a1 --- /dev/null +++ b/services/mana-core-auth/src/tag-groups/tag-groups.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { TagGroupsController } from './tag-groups.controller'; +import { TagGroupsService } from './tag-groups.service'; + +@Module({ + controllers: [TagGroupsController], + providers: [TagGroupsService], + exports: [TagGroupsService], +}) +export class TagGroupsModule {} diff --git a/services/mana-core-auth/src/tag-groups/tag-groups.service.ts b/services/mana-core-auth/src/tag-groups/tag-groups.service.ts new file mode 100644 index 000000000..c0bd97aac --- /dev/null +++ b/services/mana-core-auth/src/tag-groups/tag-groups.service.ts @@ -0,0 +1,171 @@ +import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { eq, and, inArray } from 'drizzle-orm'; +import { getDb } from '../db/connection'; +import { tagGroups, tags } from '../db/schema'; +import { CreateTagGroupDto } from './dto/create-tag-group.dto'; +import { UpdateTagGroupDto } from './dto/update-tag-group.dto'; + +@Injectable() +export class TagGroupsService { + constructor(private configService: ConfigService) {} + + private getDb() { + const databaseUrl = this.configService.get('database.url'); + return getDb(databaseUrl!); + } + + /** + * Get all tag groups for a user, ordered by sortOrder + */ + async findByUserId(userId: string) { + const db = this.getDb(); + return db + .select() + .from(tagGroups) + .where(eq(tagGroups.userId, userId)) + .orderBy(tagGroups.sortOrder); + } + + /** + * Get a single tag group by ID (only if owned by user) + */ + async findById(id: string, userId: string) { + const db = this.getDb(); + const [group] = await db + .select() + .from(tagGroups) + .where(and(eq(tagGroups.id, id), eq(tagGroups.userId, userId))) + .limit(1); + + return group || null; + } + + /** + * Create a new tag group + */ + async create(userId: string, dto: CreateTagGroupDto) { + const db = this.getDb(); + + // Check for duplicate name + const [existing] = await db + .select() + .from(tagGroups) + .where(and(eq(tagGroups.userId, userId), eq(tagGroups.name, dto.name))) + .limit(1); + + if (existing) { + throw new ConflictException(`Tag group "${dto.name}" already exists`); + } + + const [group] = await db + .insert(tagGroups) + .values({ + userId, + name: dto.name, + color: dto.color || '#3B82F6', + icon: dto.icon || null, + sortOrder: dto.sortOrder ?? 0, + }) + .returning(); + + return group; + } + + /** + * Update an existing tag group + */ + async update(id: string, userId: string, dto: UpdateTagGroupDto) { + const db = this.getDb(); + + // Verify group exists and belongs to user + const [existing] = await db + .select() + .from(tagGroups) + .where(and(eq(tagGroups.id, id), eq(tagGroups.userId, userId))) + .limit(1); + + if (!existing) { + throw new NotFoundException(`Tag group not found`); + } + + // Check for duplicate name if name is being changed + if (dto.name && dto.name !== existing.name) { + const [duplicate] = await db + .select() + .from(tagGroups) + .where(and(eq(tagGroups.userId, userId), eq(tagGroups.name, dto.name))) + .limit(1); + + if (duplicate) { + throw new ConflictException(`Tag group "${dto.name}" already exists`); + } + } + + const [group] = await db + .update(tagGroups) + .set({ + ...dto, + updatedAt: new Date(), + }) + .where(and(eq(tagGroups.id, id), eq(tagGroups.userId, userId))) + .returning(); + + return group; + } + + /** + * Delete a tag group. Tags in the group get groupId set to null. + */ + async delete(id: string, userId: string) { + const db = this.getDb(); + + // Verify group exists and belongs to user + const [existing] = await db + .select() + .from(tagGroups) + .where(and(eq(tagGroups.id, id), eq(tagGroups.userId, userId))) + .limit(1); + + if (!existing) { + throw new NotFoundException(`Tag group not found`); + } + + // Unlink tags from this group (set groupId to null) + await db + .update(tags) + .set({ groupId: null, updatedAt: new Date() }) + .where(and(eq(tags.groupId, id), eq(tags.userId, userId))); + + // Delete the group + await db.delete(tagGroups).where(and(eq(tagGroups.id, id), eq(tagGroups.userId, userId))); + } + + /** + * Reorder tag groups by providing an ordered array of IDs + */ + async reorder(userId: string, ids: string[]) { + const db = this.getDb(); + + // Verify all groups belong to user + const userGroups = await db + .select() + .from(tagGroups) + .where(and(eq(tagGroups.userId, userId), inArray(tagGroups.id, ids))); + + if (userGroups.length !== ids.length) { + throw new NotFoundException('One or more tag groups not found'); + } + + // Update sort order for each group + for (let i = 0; i < ids.length; i++) { + await db + .update(tagGroups) + .set({ sortOrder: i, updatedAt: new Date() }) + .where(and(eq(tagGroups.id, ids[i]), eq(tagGroups.userId, userId))); + } + + // Return updated groups + return this.findByUserId(userId); + } +} diff --git a/services/mana-core-auth/src/tag-links/dto/create-tag-link.dto.ts b/services/mana-core-auth/src/tag-links/dto/create-tag-link.dto.ts new file mode 100644 index 000000000..fbddff2a1 --- /dev/null +++ b/services/mana-core-auth/src/tag-links/dto/create-tag-link.dto.ts @@ -0,0 +1,44 @@ +import { IsString, IsUUID, IsArray, MaxLength, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class CreateTagLinkDto { + @IsUUID() + tagId: string; + + @IsString() + @MaxLength(50) + appId: string; + + @IsString() + @MaxLength(255) + entityId: string; + + @IsString() + @MaxLength(100) + entityType: string; +} + +export class BulkCreateTagLinksDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => CreateTagLinkDto) + links: CreateTagLinkDto[]; +} + +export class SyncTagLinksDto { + @IsString() + @MaxLength(50) + appId: string; + + @IsString() + @MaxLength(255) + entityId: string; + + @IsString() + @MaxLength(100) + entityType: string; + + @IsArray() + @IsUUID('4', { each: true }) + tagIds: string[]; +} diff --git a/services/mana-core-auth/src/tag-links/dto/index.ts b/services/mana-core-auth/src/tag-links/dto/index.ts new file mode 100644 index 000000000..d05759070 --- /dev/null +++ b/services/mana-core-auth/src/tag-links/dto/index.ts @@ -0,0 +1,2 @@ +export * from './create-tag-link.dto'; +export * from './query-tag-links.dto'; diff --git a/services/mana-core-auth/src/tag-links/dto/query-tag-links.dto.ts b/services/mana-core-auth/src/tag-links/dto/query-tag-links.dto.ts new file mode 100644 index 000000000..722e5b97f --- /dev/null +++ b/services/mana-core-auth/src/tag-links/dto/query-tag-links.dto.ts @@ -0,0 +1,19 @@ +import { IsString, IsOptional, IsUUID } from 'class-validator'; + +export class QueryTagLinksDto { + @IsOptional() + @IsString() + appId?: string; + + @IsOptional() + @IsString() + entityId?: string; + + @IsOptional() + @IsString() + entityType?: string; + + @IsOptional() + @IsUUID() + tagId?: string; +} diff --git a/services/mana-core-auth/src/tag-links/index.ts b/services/mana-core-auth/src/tag-links/index.ts new file mode 100644 index 000000000..ead52312a --- /dev/null +++ b/services/mana-core-auth/src/tag-links/index.ts @@ -0,0 +1,4 @@ +export * from './tag-links.module'; +export * from './tag-links.service'; +export * from './tag-links.controller'; +export * from './dto'; diff --git a/services/mana-core-auth/src/tag-links/tag-links.controller.ts b/services/mana-core-auth/src/tag-links/tag-links.controller.ts new file mode 100644 index 000000000..2c9f58887 --- /dev/null +++ b/services/mana-core-auth/src/tag-links/tag-links.controller.ts @@ -0,0 +1,88 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + Query, + UseGuards, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { TagLinksService } from './tag-links.service'; +import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; +import { CurrentUser } from '../common/decorators/current-user.decorator'; +import type { CurrentUserData } from '../common/decorators/current-user.decorator'; +import { + CreateTagLinkDto, + BulkCreateTagLinksDto, + SyncTagLinksDto, +} from './dto/create-tag-link.dto'; +import { QueryTagLinksDto } from './dto/query-tag-links.dto'; + +@Controller('tag-links') +@UseGuards(JwtAuthGuard) +export class TagLinksController { + constructor(private readonly tagLinksService: TagLinksService) {} + + /** + * Link a tag to an entity + */ + @Post() + async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateTagLinkDto) { + return this.tagLinksService.create(user.userId, dto); + } + + /** + * Bulk link tags to entities + */ + @Post('bulk') + async bulkCreate(@CurrentUser() user: CurrentUserData, @Body() dto: BulkCreateTagLinksDto) { + return this.tagLinksService.bulkCreate(user.userId, dto.links); + } + + /** + * Sync tags for an entity (replaces all tag links) + */ + @Put('sync') + async sync(@CurrentUser() user: CurrentUserData, @Body() dto: SyncTagLinksDto) { + return this.tagLinksService.sync( + user.userId, + dto.appId, + dto.entityId, + dto.entityType, + dto.tagIds + ); + } + + /** + * Get full Tag objects for a specific entity + */ + @Get('tags-for-entity') + async getTagsForEntity( + @CurrentUser() user: CurrentUserData, + @Query('appId') appId: string, + @Query('entityId') entityId: string + ) { + return this.tagLinksService.getTagsForEntity(user.userId, appId, entityId); + } + + /** + * Query tag links with optional filters + */ + @Get() + async query(@CurrentUser() user: CurrentUserData, @Query() query: QueryTagLinksDto) { + return this.tagLinksService.query(user.userId, query); + } + + /** + * Delete a tag link by ID + */ + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + async delete(@CurrentUser() user: CurrentUserData, @Param('id') id: string) { + await this.tagLinksService.delete(id, user.userId); + } +} diff --git a/services/mana-core-auth/src/tag-links/tag-links.module.ts b/services/mana-core-auth/src/tag-links/tag-links.module.ts new file mode 100644 index 000000000..8fcec6302 --- /dev/null +++ b/services/mana-core-auth/src/tag-links/tag-links.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { TagLinksController } from './tag-links.controller'; +import { TagLinksService } from './tag-links.service'; + +@Module({ + controllers: [TagLinksController], + providers: [TagLinksService], + exports: [TagLinksService], +}) +export class TagLinksModule {} diff --git a/services/mana-core-auth/src/tag-links/tag-links.service.ts b/services/mana-core-auth/src/tag-links/tag-links.service.ts new file mode 100644 index 000000000..50ac009cf --- /dev/null +++ b/services/mana-core-auth/src/tag-links/tag-links.service.ts @@ -0,0 +1,234 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { eq, and, inArray } from 'drizzle-orm'; +import { getDb } from '../db/connection'; +import { tagLinks, tags } from '../db/schema'; +import { CreateTagLinkDto } from './dto/create-tag-link.dto'; +import { QueryTagLinksDto } from './dto/query-tag-links.dto'; + +@Injectable() +export class TagLinksService { + constructor(private configService: ConfigService) {} + + private getDb() { + const databaseUrl = this.configService.get('database.url'); + return getDb(databaseUrl!); + } + + /** + * Link a tag to an entity + */ + async create(userId: string, dto: CreateTagLinkDto) { + const db = this.getDb(); + + // Verify tag belongs to user + const [tag] = await db + .select() + .from(tags) + .where(and(eq(tags.id, dto.tagId), eq(tags.userId, userId))) + .limit(1); + + if (!tag) { + throw new NotFoundException('Tag not found'); + } + + const [link] = await db + .insert(tagLinks) + .values({ + tagId: dto.tagId, + appId: dto.appId, + entityId: dto.entityId, + entityType: dto.entityType, + userId, + }) + .onConflictDoNothing() + .returning(); + + // If conflict (already exists), return the existing link + if (!link) { + const [existing] = await db + .select() + .from(tagLinks) + .where( + and( + eq(tagLinks.tagId, dto.tagId), + eq(tagLinks.appId, dto.appId), + eq(tagLinks.entityId, dto.entityId) + ) + ) + .limit(1); + return existing; + } + + return link; + } + + /** + * Bulk link tags to entities + */ + async bulkCreate(userId: string, dtos: CreateTagLinkDto[]) { + if (dtos.length === 0) return []; + + const db = this.getDb(); + + // Verify all tags belong to user + const tagIds = [...new Set(dtos.map((d) => d.tagId))]; + const userTags = await db + .select() + .from(tags) + .where(and(inArray(tags.id, tagIds), eq(tags.userId, userId))); + + if (userTags.length !== tagIds.length) { + throw new NotFoundException('One or more tags not found'); + } + + const values = dtos.map((dto) => ({ + tagId: dto.tagId, + appId: dto.appId, + entityId: dto.entityId, + entityType: dto.entityType, + userId, + })); + + const links = await db.insert(tagLinks).values(values).onConflictDoNothing().returning(); + + return links; + } + + /** + * Delete a tag link by ID + */ + async delete(id: string, userId: string) { + const db = this.getDb(); + + const [existing] = await db + .select() + .from(tagLinks) + .where(and(eq(tagLinks.id, id), eq(tagLinks.userId, userId))) + .limit(1); + + if (!existing) { + throw new NotFoundException('Tag link not found'); + } + + await db.delete(tagLinks).where(and(eq(tagLinks.id, id), eq(tagLinks.userId, userId))); + } + + /** + * Query tag links with optional filters + */ + async query(userId: string, query: QueryTagLinksDto) { + const db = this.getDb(); + + const conditions = [eq(tagLinks.userId, userId)]; + + if (query.appId) { + conditions.push(eq(tagLinks.appId, query.appId)); + } + if (query.entityId) { + conditions.push(eq(tagLinks.entityId, query.entityId)); + } + if (query.entityType) { + conditions.push(eq(tagLinks.entityType, query.entityType)); + } + if (query.tagId) { + conditions.push(eq(tagLinks.tagId, query.tagId)); + } + + return db + .select() + .from(tagLinks) + .where(and(...conditions)); + } + + /** + * Get full Tag objects for a specific entity (joins with tags table) + */ + async getTagsForEntity(userId: string, appId: string, entityId: string) { + const db = this.getDb(); + + const results = await db + .select({ + id: tags.id, + userId: tags.userId, + name: tags.name, + color: tags.color, + icon: tags.icon, + groupId: tags.groupId, + sortOrder: tags.sortOrder, + createdAt: tags.createdAt, + updatedAt: tags.updatedAt, + }) + .from(tagLinks) + .innerJoin(tags, eq(tagLinks.tagId, tags.id)) + .where( + and(eq(tagLinks.userId, userId), eq(tagLinks.appId, appId), eq(tagLinks.entityId, entityId)) + ); + + return results; + } + + /** + * Sync tags for an entity: adds missing links, removes extra ones + */ + async sync( + userId: string, + appId: string, + entityId: string, + entityType: string, + tagIds: string[] + ) { + const db = this.getDb(); + + // Verify all tags belong to user + if (tagIds.length > 0) { + const userTags = await db + .select() + .from(tags) + .where(and(inArray(tags.id, tagIds), eq(tags.userId, userId))); + + if (userTags.length !== tagIds.length) { + throw new NotFoundException('One or more tags not found'); + } + } + + // Get current links for this entity + const currentLinks = await db + .select() + .from(tagLinks) + .where( + and(eq(tagLinks.userId, userId), eq(tagLinks.appId, appId), eq(tagLinks.entityId, entityId)) + ); + + const currentTagIds = currentLinks.map((l) => l.tagId); + const toAdd = tagIds.filter((id) => !currentTagIds.includes(id)); + const toRemove = currentLinks.filter((l) => !tagIds.includes(l.tagId)); + + // Add missing links + if (toAdd.length > 0) { + await db + .insert(tagLinks) + .values( + toAdd.map((tagId) => ({ + tagId, + appId, + entityId, + entityType, + userId, + })) + ) + .onConflictDoNothing(); + } + + // Remove extra links + if (toRemove.length > 0) { + const removeIds = toRemove.map((l) => l.id); + await db + .delete(tagLinks) + .where(and(inArray(tagLinks.id, removeIds), eq(tagLinks.userId, userId))); + } + + // Return updated tags for entity + return this.getTagsForEntity(userId, appId, entityId); + } +} diff --git a/services/mana-core-auth/src/tags/dto/create-tag.dto.ts b/services/mana-core-auth/src/tags/dto/create-tag.dto.ts index 5062e85a5..31c76bc2e 100644 --- a/services/mana-core-auth/src/tags/dto/create-tag.dto.ts +++ b/services/mana-core-auth/src/tags/dto/create-tag.dto.ts @@ -1,4 +1,4 @@ -import { IsString, IsOptional, MaxLength, Matches } from 'class-validator'; +import { IsString, IsOptional, IsUUID, IsInt, MaxLength, Matches } from 'class-validator'; export class CreateTagDto { @IsString() @@ -15,4 +15,12 @@ export class CreateTagDto { @IsString() @MaxLength(50) icon?: string; + + @IsOptional() + @IsUUID() + groupId?: string; + + @IsOptional() + @IsInt() + sortOrder?: number; } diff --git a/services/mana-core-auth/src/tags/dto/update-tag.dto.ts b/services/mana-core-auth/src/tags/dto/update-tag.dto.ts index a3a8508b8..6b96d6201 100644 --- a/services/mana-core-auth/src/tags/dto/update-tag.dto.ts +++ b/services/mana-core-auth/src/tags/dto/update-tag.dto.ts @@ -1,4 +1,4 @@ -import { IsString, IsOptional, MaxLength, Matches } from 'class-validator'; +import { IsString, IsOptional, IsUUID, IsInt, MaxLength, Matches } from 'class-validator'; export class UpdateTagDto { @IsOptional() @@ -16,4 +16,12 @@ export class UpdateTagDto { @IsString() @MaxLength(50) icon?: string; + + @IsOptional() + @IsUUID() + groupId?: string | null; + + @IsOptional() + @IsInt() + sortOrder?: number; } diff --git a/services/mana-core-auth/src/tags/tags.service.ts b/services/mana-core-auth/src/tags/tags.service.ts index 70015e643..b2426f379 100644 --- a/services/mana-core-auth/src/tags/tags.service.ts +++ b/services/mana-core-auth/src/tags/tags.service.ts @@ -83,6 +83,8 @@ export class TagsService { name: dto.name, color: dto.color || '#3B82F6', icon: dto.icon || null, + groupId: dto.groupId || null, + sortOrder: dto.sortOrder ?? 0, }) .returning(); @@ -151,6 +153,17 @@ export class TagsService { await db.delete(tags).where(and(eq(tags.id, id), eq(tags.userId, userId))); } + /** + * Get all tags in a specific group (only those owned by user) + */ + async findByGroupId(groupId: string, userId: string) { + const db = this.getDb(); + return db + .select() + .from(tags) + .where(and(eq(tags.groupId, groupId), eq(tags.userId, userId))); + } + /** * Create default tags for a new user * Called during user registration or first access