From 99c28242c5ae1f0c6107f3b2c8e78597e0ebee8d Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Tue, 9 Dec 2025 18:15:32 +0100 Subject: [PATCH] refactor(contacts): consolidate groups into tags feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove groups functionality and use only tags for contact organization. Tags provide a simpler, more intuitive approach for categorizing contacts. Frontend changes: - Remove groups pages (/groups, /groups/new, /groups/[id]) - Remove groups from navigation - Update FilterBar to use tags instead of groups - Update ContactList to use selectedTagId - Remove groupsApi and batch group operations - Update i18n translations (group → tag) Backend changes: - Remove GroupModule and group controller/service - Remove groups.schema.ts and contact_to_groups relation - Remove group-related batch operations (addToGroup, removeFromGroup) - Remove groupId filtering from contacts and export - Remove preset groups from seed.ts đŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- apps/contacts/CLAUDE.md | 19 - apps/contacts/apps/backend/src/app.module.ts | 2 - .../backend/src/batch/batch.controller.ts | 21 - .../apps/backend/src/batch/batch.service.ts | 86 +- .../backend/src/contact/contact.controller.ts | 4 - .../backend/src/contact/contact.service.ts | 1 - .../backend/src/db/schema/groups.schema.ts | 33 - .../apps/backend/src/db/schema/index.ts | 1 - apps/contacts/apps/backend/src/db/seed.ts | 117 +- .../apps/backend/src/export/dto/export.dto.ts | 4 - .../apps/backend/src/export/export.service.ts | 23 +- .../backend/src/group/group.controller.ts | 130 -- .../apps/backend/src/group/group.module.ts | 10 - .../apps/backend/src/group/group.service.ts | 121 -- apps/contacts/apps/web/src/lib/api/batch.ts | 17 +- apps/contacts/apps/web/src/lib/api/config.ts | 7 + .../contacts/apps/web/src/lib/api/contacts.ts | 60 +- .../apps/web/src/lib/api/duplicates.ts | 3 +- apps/contacts/apps/web/src/lib/api/export.ts | 4 +- apps/contacts/apps/web/src/lib/api/google.ts | 3 +- apps/contacts/apps/web/src/lib/api/import.ts | 3 +- .../web/src/lib/components/FilterBar.svelte | 56 +- .../apps/web/src/lib/i18n/locales/de.json | 4 +- .../apps/web/src/lib/i18n/locales/en.json | 4 +- .../web/src/lib/stores/contacts.svelte.ts | 6 +- .../apps/web/src/routes/(app)/+layout.svelte | 1 - .../web/src/routes/(app)/groups/+page.svelte | 618 --------- .../src/routes/(app)/groups/[id]/+page.svelte | 1140 ----------------- .../src/routes/(app)/groups/new/+page.svelte | 581 --------- .../apps/web/src/routes/+layout.svelte | 61 + 30 files changed, 116 insertions(+), 3024 deletions(-) delete mode 100644 apps/contacts/apps/backend/src/db/schema/groups.schema.ts delete mode 100644 apps/contacts/apps/backend/src/group/group.controller.ts delete mode 100644 apps/contacts/apps/backend/src/group/group.module.ts delete mode 100644 apps/contacts/apps/backend/src/group/group.service.ts create mode 100644 apps/contacts/apps/web/src/lib/api/config.ts delete mode 100644 apps/contacts/apps/web/src/routes/(app)/groups/+page.svelte delete mode 100644 apps/contacts/apps/web/src/routes/(app)/groups/[id]/+page.svelte delete mode 100644 apps/contacts/apps/web/src/routes/(app)/groups/new/+page.svelte diff --git a/apps/contacts/CLAUDE.md b/apps/contacts/CLAUDE.md index fde22e3f9..1ccf4b001 100644 --- a/apps/contacts/CLAUDE.md +++ b/apps/contacts/CLAUDE.md @@ -83,11 +83,6 @@ pnpm build # Build for production | `/api/v1/contacts/:id/favorite` | POST | Toggle favorite | | `/api/v1/contacts/:id/archive` | POST | Toggle archive | | `/api/v1/contacts/:id/photo` | POST | Upload contact photo | -| `/api/v1/groups` | GET | Get user's groups | -| `/api/v1/groups` | POST | Create new group | -| `/api/v1/groups/:id` | PATCH | Update group | -| `/api/v1/groups/:id` | DELETE | Delete group | -| `/api/v1/groups/:id/contacts` | POST | Add contacts to group | | `/api/v1/tags` | GET | Get user's tags | | `/api/v1/tags` | POST | Create new tag | | `/api/v1/tags/:id` | DELETE | Delete tag | @@ -129,20 +124,6 @@ pnpm build # Build for production - `shared_with` (JSONB) - Array of user IDs - `created_at`, `updated_at` (TIMESTAMP) -**contact_groups** - Groups for organizing contacts - -- `id` (UUID) - Primary key -- `user_id` (VARCHAR) - User reference -- `name` (VARCHAR) - Group name -- `description` (TEXT) - Optional description -- `color` (VARCHAR) - Group color -- `created_at` (TIMESTAMP) - -**contact_to_groups** - Many-to-many relation - -- `contact_id` (UUID) - Contact reference -- `group_id` (UUID) - Group reference - **contact_tags** - Tags for contacts - `id` (UUID) - Primary key diff --git a/apps/contacts/apps/backend/src/app.module.ts b/apps/contacts/apps/backend/src/app.module.ts index 6d45e8468..9edecaeda 100644 --- a/apps/contacts/apps/backend/src/app.module.ts +++ b/apps/contacts/apps/backend/src/app.module.ts @@ -2,7 +2,6 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { DatabaseModule } from './db/database.module'; import { ContactModule } from './contact/contact.module'; -import { GroupModule } from './group/group.module'; import { TagModule } from './tag/tag.module'; import { NoteModule } from './note/note.module'; import { ActivityModule } from './activity/activity.module'; @@ -22,7 +21,6 @@ import { BatchModule } from './batch/batch.module'; }), DatabaseModule, ContactModule, - GroupModule, TagModule, NoteModule, ActivityModule, diff --git a/apps/contacts/apps/backend/src/batch/batch.controller.ts b/apps/contacts/apps/backend/src/batch/batch.controller.ts index e038b6ffd..80633add1 100644 --- a/apps/contacts/apps/backend/src/batch/batch.controller.ts +++ b/apps/contacts/apps/backend/src/batch/batch.controller.ts @@ -22,11 +22,6 @@ class BatchFavoriteDto extends BatchContactIdsDto { favorite?: boolean = true; } -class BatchGroupDto extends BatchContactIdsDto { - @IsString() - groupId: string; -} - class BatchTagsDto extends BatchContactIdsDto { @IsArray() @IsString({ each: true }) @@ -65,22 +60,6 @@ export class BatchController { return result; } - @Post('add-to-group') - async addToGroup(@CurrentUser() user: CurrentUserData, @Body() dto: BatchGroupDto) { - const result = await this.batchService.addToGroup(dto.contactIds, dto.groupId, user.userId); - return result; - } - - @Post('remove-from-group') - async removeFromGroup(@CurrentUser() user: CurrentUserData, @Body() dto: BatchGroupDto) { - const result = await this.batchService.removeFromGroup( - dto.contactIds, - dto.groupId, - user.userId - ); - return result; - } - @Post('add-tags') async addTags(@CurrentUser() user: CurrentUserData, @Body() dto: BatchTagsDto) { const result = await this.batchService.addTags(dto.contactIds, dto.tagIds, user.userId); diff --git a/apps/contacts/apps/backend/src/batch/batch.service.ts b/apps/contacts/apps/backend/src/batch/batch.service.ts index 156a0da3d..d45a707fd 100644 --- a/apps/contacts/apps/backend/src/batch/batch.service.ts +++ b/apps/contacts/apps/backend/src/batch/batch.service.ts @@ -2,7 +2,7 @@ import { Injectable, Inject, BadRequestException } from '@nestjs/common'; import { eq, and, inArray } from 'drizzle-orm'; import { DATABASE_CONNECTION } from '../db/database.module'; import { Database } from '../db/connection'; -import { contacts, contactToGroups, contactToTags } from '../db/schema'; +import { contacts, contactToTags } from '../db/schema'; import type { Contact } from '../db/schema'; export interface BatchResult { @@ -112,90 +112,6 @@ export class BatchService { return result; } - /** - * Add multiple contacts to a group - */ - async addToGroup(contactIds: string[], groupId: string, userId: string): Promise { - if (contactIds.length === 0) { - throw new BadRequestException('No contacts specified'); - } - - const result: BatchResult = { success: 0, failed: 0, errors: [] }; - - // Verify contacts belong to user - const validContacts = await this.db - .select({ id: contacts.id }) - .from(contacts) - .where(and(eq(contacts.userId, userId), inArray(contacts.id, contactIds))); - - const validIds = new Set(validContacts.map((c) => c.id)); - - for (const contactId of contactIds) { - if (!validIds.has(contactId)) { - result.failed++; - continue; - } - - try { - // Insert if not exists (ignore duplicates) - await this.db.insert(contactToGroups).values({ contactId, groupId }).onConflictDoNothing(); - result.success++; - } catch { - result.failed++; - } - } - - if (result.failed > 0) { - result.errors.push(`${result.failed} contacts could not be added to group`); - } - - return result; - } - - /** - * Remove multiple contacts from a group - */ - async removeFromGroup( - contactIds: string[], - groupId: string, - userId: string - ): Promise { - if (contactIds.length === 0) { - throw new BadRequestException('No contacts specified'); - } - - const result: BatchResult = { success: 0, failed: 0, errors: [] }; - - // Verify contacts belong to user first - const validContacts = await this.db - .select({ id: contacts.id }) - .from(contacts) - .where(and(eq(contacts.userId, userId), inArray(contacts.id, contactIds))); - - const validIds = validContacts.map((c) => c.id); - - if (validIds.length === 0) { - result.failed = contactIds.length; - result.errors.push('No valid contacts found'); - return result; - } - - try { - await this.db - .delete(contactToGroups) - .where( - and(eq(contactToGroups.groupId, groupId), inArray(contactToGroups.contactId, validIds)) - ); - result.success = validIds.length; - result.failed = contactIds.length - validIds.length; - } catch (e) { - result.failed = contactIds.length; - result.errors.push(e instanceof Error ? e.message : 'Remove from group failed'); - } - - return result; - } - /** * Add tags to multiple contacts */ diff --git a/apps/contacts/apps/backend/src/contact/contact.controller.ts b/apps/contacts/apps/backend/src/contact/contact.controller.ts index aec5fb2ce..9add5756d 100644 --- a/apps/contacts/apps/backend/src/contact/contact.controller.ts +++ b/apps/contacts/apps/backend/src/contact/contact.controller.ts @@ -143,10 +143,6 @@ class ContactQueryDto { @Transform(({ value }) => value === 'true') isArchived?: boolean; - @IsUUID() - @IsOptional() - groupId?: string; - @IsUUID() @IsOptional() tagId?: string; diff --git a/apps/contacts/apps/backend/src/contact/contact.service.ts b/apps/contacts/apps/backend/src/contact/contact.service.ts index 3d6433e04..2431d3b5a 100644 --- a/apps/contacts/apps/backend/src/contact/contact.service.ts +++ b/apps/contacts/apps/backend/src/contact/contact.service.ts @@ -9,7 +9,6 @@ export interface ContactFilters { search?: string; isFavorite?: boolean; isArchived?: boolean; - groupId?: string; tagId?: string; limit?: number; offset?: number; diff --git a/apps/contacts/apps/backend/src/db/schema/groups.schema.ts b/apps/contacts/apps/backend/src/db/schema/groups.schema.ts deleted file mode 100644 index 77a64d3ce..000000000 --- a/apps/contacts/apps/backend/src/db/schema/groups.schema.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { pgTable, uuid, timestamp, varchar, text, primaryKey, boolean } from 'drizzle-orm/pg-core'; -import { contacts } from './contacts.schema'; - -export const contactGroups = pgTable('contact_groups', { - id: uuid('id').primaryKey().defaultRandom(), - userId: varchar('user_id', { length: 255 }).notNull(), - name: varchar('name', { length: 100 }).notNull(), - description: text('description'), - color: varchar('color', { length: 20 }), - icon: varchar('icon', { length: 50 }), - isPreset: boolean('is_preset').default(false).notNull(), - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), -}); - -export const contactToGroups = pgTable( - 'contact_to_groups', - { - contactId: uuid('contact_id') - .references(() => contacts.id, { onDelete: 'cascade' }) - .notNull(), - groupId: uuid('group_id') - .references(() => contactGroups.id, { onDelete: 'cascade' }) - .notNull(), - }, - (table) => ({ - pk: primaryKey({ columns: [table.contactId, table.groupId] }), - }) -); - -export type ContactGroup = typeof contactGroups.$inferSelect; -export type NewContactGroup = typeof contactGroups.$inferInsert; -export type ContactToGroup = typeof contactToGroups.$inferSelect; -export type NewContactToGroup = typeof contactToGroups.$inferInsert; diff --git a/apps/contacts/apps/backend/src/db/schema/index.ts b/apps/contacts/apps/backend/src/db/schema/index.ts index 81f1d7ca3..8f3f9f945 100644 --- a/apps/contacts/apps/backend/src/db/schema/index.ts +++ b/apps/contacts/apps/backend/src/db/schema/index.ts @@ -1,5 +1,4 @@ export * from './contacts.schema'; -export * from './groups.schema'; export * from './tags.schema'; export * from './notes.schema'; export * from './activities.schema'; diff --git a/apps/contacts/apps/backend/src/db/seed.ts b/apps/contacts/apps/backend/src/db/seed.ts index 5b38dd41e..5e0204a6f 100644 --- a/apps/contacts/apps/backend/src/db/seed.ts +++ b/apps/contacts/apps/backend/src/db/seed.ts @@ -1,75 +1,14 @@ import 'dotenv/config'; import { drizzle } from 'drizzle-orm/postgres-js'; import postgres from 'postgres'; -import { contacts, contactGroups } from './schema'; +import { contacts } from './schema'; const DATABASE_URL = process.env.DATABASE_URL || 'postgresql://manacore:devpassword@localhost:5432/contacts'; // User ID - can be set via environment variable or defaults to dev user -const USER_ID = process.env.SEED_USER_ID || process.env.DEV_USER_ID || '00000000-0000-0000-0000-000000000000'; - -// System user ID for preset groups (visible to all users) -const SYSTEM_USER_ID = 'system'; - -// Preset groups available to all users -interface PresetGroup { - name: string; - description: string; - color: string; - icon: string; -} - -const presetGroups: PresetGroup[] = [ - { - name: 'Familie', - description: 'Familienmitglieder und Verwandte', - color: '#ef4444', // Red - icon: 'home', - }, - { - name: 'Freunde', - description: 'Freunde und Bekannte', - color: '#f97316', // Orange - icon: 'users', - }, - { - name: 'Arbeit', - description: 'Kollegen und GeschĂ€ftskontakte', - color: '#3b82f6', // Blue - icon: 'briefcase', - }, - { - name: 'Kunden', - description: 'Kunden und Auftraggeber', - color: '#22c55e', // Green - icon: 'building', - }, - { - name: 'Partner', - description: 'GeschĂ€ftspartner und Lieferanten', - color: '#8b5cf6', // Purple - icon: 'handshake', - }, - { - name: 'VIP', - description: 'Wichtige Kontakte', - color: '#eab308', // Yellow/Gold - icon: 'star', - }, - { - name: 'Nachbarn', - description: 'Nachbarn und Anwohner', - color: '#14b8a6', // Teal - icon: 'map-pin', - }, - { - name: 'Vereine', - description: 'Vereinsmitglieder und Clubs', - color: '#ec4899', // Pink - icon: 'flag', - }, -]; +const USER_ID = + process.env.SEED_USER_ID || process.env.DEV_USER_ID || '00000000-0000-0000-0000-000000000000'; interface SeedContact { firstName: string; @@ -534,46 +473,6 @@ const seedContacts: SeedContact[] = [ }, ]; -async function seedPresetGroups() { - console.log('đŸ·ïž Seeding preset groups...'); - - const connection = postgres(DATABASE_URL); - const db = drizzle(connection); - - try { - const { sql, eq, and } = await import('drizzle-orm'); - - // Check if preset groups already exist - const existingPresets = await db - .select() - .from(contactGroups) - .where(and(eq(contactGroups.userId, SYSTEM_USER_ID), eq(contactGroups.isPreset, true))); - - if (existingPresets.length > 0) { - console.log(` â„č ${existingPresets.length} preset groups already exist, skipping...`); - return; - } - - // Insert preset groups - const groupsToInsert = presetGroups.map((group) => ({ - userId: SYSTEM_USER_ID, - name: group.name, - description: group.description, - color: group.color, - icon: group.icon, - isPreset: true, - })); - - await db.insert(contactGroups).values(groupsToInsert); - console.log(` ✅ Inserted ${presetGroups.length} preset groups`); - } catch (error) { - console.error('❌ Preset groups seed failed:', error); - throw error; - } finally { - await connection.end(); - } -} - async function seed() { console.log('đŸŒ± Starting seed...'); console.log(`📊 Preparing to insert ${seedContacts.length} contacts`); @@ -633,15 +532,7 @@ async function seed() { } } -async function main() { - // First seed preset groups (system-wide) - await seedPresetGroups(); - - // Then seed contacts for test user - await seed(); -} - -main() +seed() .then(() => { console.log('🎉 Seed completed!'); process.exit(0); diff --git a/apps/contacts/apps/backend/src/export/dto/export.dto.ts b/apps/contacts/apps/backend/src/export/dto/export.dto.ts index f9214ba3c..04c6a2789 100644 --- a/apps/contacts/apps/backend/src/export/dto/export.dto.ts +++ b/apps/contacts/apps/backend/src/export/dto/export.dto.ts @@ -11,10 +11,6 @@ export class ExportRequestDto { @IsUUID('4', { each: true }) contactIds?: string[]; - @IsOptional() - @IsUUID('4') - groupId?: string; - @IsOptional() @IsUUID('4') tagId?: string; diff --git a/apps/contacts/apps/backend/src/export/export.service.ts b/apps/contacts/apps/backend/src/export/export.service.ts index 8238fa660..02bc59c1f 100644 --- a/apps/contacts/apps/backend/src/export/export.service.ts +++ b/apps/contacts/apps/backend/src/export/export.service.ts @@ -3,7 +3,7 @@ import { eq, and, inArray } from 'drizzle-orm'; import { DATABASE_CONNECTION } from '../db/database.module'; import { type Database } from '../db/connection'; import { contacts, type Contact } from '../db/schema'; -import { contactToGroups, contactToTags } from '../db/schema'; +import { contactToTags } from '../db/schema'; import { ExportRequestDto, ExportFormat } from './dto/export.dto'; import { generateVCardFile } from './generators/vcard.generator'; import { generateCsvFile } from './generators/csv.generator'; @@ -48,7 +48,7 @@ export class ExportService { userId: string, options: ExportRequestDto ): Promise { - const { contactIds, groupId, tagId, includeFavorites, includeArchived = false } = options; + const { contactIds, tagId, includeFavorites, includeArchived = false } = options; // If specific contact IDs are provided, fetch those if (contactIds && contactIds.length > 0) { @@ -58,25 +58,6 @@ export class ExportService { .where(and(eq(contacts.userId, userId), inArray(contacts.id, contactIds))); } - // If a group is specified, get contacts in that group - if (groupId) { - const groupContacts = await this.db - .select({ contactId: contactToGroups.contactId }) - .from(contactToGroups) - .where(eq(contactToGroups.groupId, groupId)); - - const contactIdsInGroup = groupContacts.map((gc) => gc.contactId); - - if (contactIdsInGroup.length === 0) { - return []; - } - - return this.db - .select() - .from(contacts) - .where(and(eq(contacts.userId, userId), inArray(contacts.id, contactIdsInGroup))); - } - // If a tag is specified, get contacts with that tag if (tagId) { const taggedContacts = await this.db diff --git a/apps/contacts/apps/backend/src/group/group.controller.ts b/apps/contacts/apps/backend/src/group/group.controller.ts deleted file mode 100644 index ca802e1f4..000000000 --- a/apps/contacts/apps/backend/src/group/group.controller.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { - Controller, - Get, - Post, - Patch, - Delete, - Body, - Param, - UseGuards, - ParseUUIDPipe, -} from '@nestjs/common'; -import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; -import { GroupService } from './group.service'; -import { IsString, IsOptional, MaxLength, IsArray, IsUUID } from 'class-validator'; - -class CreateGroupDto { - @IsString() - @MaxLength(100) - name!: string; - - @IsString() - @IsOptional() - description?: string; - - @IsString() - @IsOptional() - @MaxLength(20) - color?: string; -} - -class UpdateGroupDto { - @IsString() - @IsOptional() - @MaxLength(100) - name?: string; - - @IsString() - @IsOptional() - description?: string; - - @IsString() - @IsOptional() - @MaxLength(20) - color?: string; -} - -class AddContactsDto { - @IsArray() - @IsUUID('4', { each: true }) - contactIds!: string[]; -} - -@Controller('groups') -@UseGuards(JwtAuthGuard) -export class GroupController { - constructor(private readonly groupService: GroupService) {} - - @Get() - async findAll(@CurrentUser() user: CurrentUserData) { - const groups = await this.groupService.findByUserId(user.userId); - return { groups }; - } - - @Get(':id') - async findOne(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) { - const group = await this.groupService.findById(id, user.userId); - const contactIds = group ? await this.groupService.getContactsInGroup(id) : []; - return { group, contactIds }; - } - - @Post() - async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateGroupDto) { - const group = await this.groupService.create({ - ...dto, - userId: user.userId, - }); - return { group }; - } - - @Patch(':id') - async update( - @CurrentUser() user: CurrentUserData, - @Param('id', ParseUUIDPipe) id: string, - @Body() dto: UpdateGroupDto - ) { - const group = await this.groupService.update(id, user.userId, dto); - return { group }; - } - - @Delete(':id') - async delete(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) { - await this.groupService.delete(id, user.userId); - return { success: true }; - } - - @Post(':id/contacts') - async addContacts( - @CurrentUser() user: CurrentUserData, - @Param('id', ParseUUIDPipe) id: string, - @Body() dto: AddContactsDto - ) { - // Verify group belongs to user - const group = await this.groupService.findById(id, user.userId); - if (!group) { - return { success: false, error: 'Group not found' }; - } - - for (const contactId of dto.contactIds) { - await this.groupService.addContactToGroup(contactId, id); - } - - return { success: true }; - } - - @Delete(':id/contacts/:contactId') - async removeContact( - @CurrentUser() user: CurrentUserData, - @Param('id', ParseUUIDPipe) id: string, - @Param('contactId', ParseUUIDPipe) contactId: string - ) { - // Verify group belongs to user - const group = await this.groupService.findById(id, user.userId); - if (!group) { - return { success: false, error: 'Group not found' }; - } - - await this.groupService.removeContactFromGroup(contactId, id); - return { success: true }; - } -} diff --git a/apps/contacts/apps/backend/src/group/group.module.ts b/apps/contacts/apps/backend/src/group/group.module.ts deleted file mode 100644 index 4fa59ab56..000000000 --- a/apps/contacts/apps/backend/src/group/group.module.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Module } from '@nestjs/common'; -import { GroupController } from './group.controller'; -import { GroupService } from './group.service'; - -@Module({ - controllers: [GroupController], - providers: [GroupService], - exports: [GroupService], -}) -export class GroupModule {} diff --git a/apps/contacts/apps/backend/src/group/group.service.ts b/apps/contacts/apps/backend/src/group/group.service.ts deleted file mode 100644 index 04bd6ba55..000000000 --- a/apps/contacts/apps/backend/src/group/group.service.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { Injectable, Inject, NotFoundException, ForbiddenException } from '@nestjs/common'; -import { eq, and, or } from 'drizzle-orm'; -import { DATABASE_CONNECTION } from '../db/database.module'; -import { Database } from '../db/connection'; -import { - contactGroups, - contactToGroups, - type ContactGroup, - type NewContactGroup, -} from '../db/schema'; - -// System user ID for preset groups (visible to all users) -const SYSTEM_USER_ID = 'system'; - -@Injectable() -export class GroupService { - constructor(@Inject(DATABASE_CONNECTION) private db: Database) {} - - /** - * Get all groups for a user, including preset groups (system groups) - */ - async findByUserId(userId: string): Promise { - // Get user's own groups + preset groups (system) - return this.db - .select() - .from(contactGroups) - .where( - or( - eq(contactGroups.userId, userId), - and(eq(contactGroups.userId, SYSTEM_USER_ID), eq(contactGroups.isPreset, true)) - ) - ); - } - - /** - * Find a group by ID (user's own or preset) - */ - async findById(id: string, userId: string): Promise { - const [group] = await this.db - .select() - .from(contactGroups) - .where( - and( - eq(contactGroups.id, id), - or( - eq(contactGroups.userId, userId), - and(eq(contactGroups.userId, SYSTEM_USER_ID), eq(contactGroups.isPreset, true)) - ) - ) - ); - return group || null; - } - - async create(data: NewContactGroup): Promise { - const [group] = await this.db.insert(contactGroups).values(data).returning(); - return group; - } - - /** - * Update a group - preset groups cannot be modified - */ - async update(id: string, userId: string, data: Partial): Promise { - // First check if this is a preset group - const existingGroup = await this.findById(id, userId); - if (!existingGroup) { - throw new NotFoundException('Group not found'); - } - if (existingGroup.isPreset) { - throw new ForbiddenException('Preset groups cannot be modified'); - } - - const [group] = await this.db - .update(contactGroups) - .set(data) - .where(and(eq(contactGroups.id, id), eq(contactGroups.userId, userId))) - .returning(); - - if (!group) { - throw new NotFoundException('Group not found'); - } - - return group; - } - - /** - * Delete a group - preset groups cannot be deleted - */ - async delete(id: string, userId: string): Promise { - // First check if this is a preset group - const existingGroup = await this.findById(id, userId); - if (!existingGroup) { - throw new NotFoundException('Group not found'); - } - if (existingGroup.isPreset) { - throw new ForbiddenException('Preset groups cannot be deleted'); - } - - await this.db - .delete(contactGroups) - .where(and(eq(contactGroups.id, id), eq(contactGroups.userId, userId))); - } - - async addContactToGroup(contactId: string, groupId: string): Promise { - await this.db.insert(contactToGroups).values({ contactId, groupId }).onConflictDoNothing(); - } - - async removeContactFromGroup(contactId: string, groupId: string): Promise { - await this.db - .delete(contactToGroups) - .where(and(eq(contactToGroups.contactId, contactId), eq(contactToGroups.groupId, groupId))); - } - - async getContactsInGroup(groupId: string): Promise { - const results = await this.db - .select({ contactId: contactToGroups.contactId }) - .from(contactToGroups) - .where(eq(contactToGroups.groupId, groupId)); - - return results.map((r) => r.contactId); - } -} diff --git a/apps/contacts/apps/web/src/lib/api/batch.ts b/apps/contacts/apps/web/src/lib/api/batch.ts index 4bcedb918..5307425ee 100644 --- a/apps/contacts/apps/web/src/lib/api/batch.ts +++ b/apps/contacts/apps/web/src/lib/api/batch.ts @@ -1,6 +1,5 @@ import { authStore } from '$lib/stores/auth.svelte'; - -const API_BASE = 'http://localhost:3015/api/v1'; +import { API_BASE } from './config'; async function fetchWithAuth(url: string, options: RequestInit = {}) { const token = await authStore.getAccessToken(); @@ -55,20 +54,6 @@ export const batchApi = { }); }, - async addToGroup(contactIds: string[], groupId: string): Promise { - return fetchWithAuth('/batch/add-to-group', { - method: 'POST', - body: JSON.stringify({ contactIds, groupId }), - }); - }, - - async removeFromGroup(contactIds: string[], groupId: string): Promise { - return fetchWithAuth('/batch/remove-from-group', { - method: 'POST', - body: JSON.stringify({ contactIds, groupId }), - }); - }, - async addTags(contactIds: string[], tagIds: string[]): Promise { return fetchWithAuth('/batch/add-tags', { method: 'POST', diff --git a/apps/contacts/apps/web/src/lib/api/config.ts b/apps/contacts/apps/web/src/lib/api/config.ts new file mode 100644 index 000000000..21c4a43ec --- /dev/null +++ b/apps/contacts/apps/web/src/lib/api/config.ts @@ -0,0 +1,7 @@ +import { PUBLIC_BACKEND_URL } from '$env/static/public'; + +/** + * API Configuration + * Uses environment variable PUBLIC_BACKEND_URL with fallback for development + */ +export const API_BASE = `${PUBLIC_BACKEND_URL || 'http://localhost:3015'}/api/v1`; diff --git a/apps/contacts/apps/web/src/lib/api/contacts.ts b/apps/contacts/apps/web/src/lib/api/contacts.ts index b7b6be705..4e5c406bb 100644 --- a/apps/contacts/apps/web/src/lib/api/contacts.ts +++ b/apps/contacts/apps/web/src/lib/api/contacts.ts @@ -1,6 +1,5 @@ import { authStore } from '$lib/stores/auth.svelte'; - -const API_BASE = 'http://localhost:3015/api/v1'; +import { API_BASE } from './config'; async function fetchWithAuth(url: string, options: RequestInit = {}) { const token = await authStore.getAccessToken(); @@ -57,17 +56,6 @@ export interface Contact { updatedAt: string; } -export interface ContactGroup { - id: string; - userId: string; - name: string; - description?: string | null; - color?: string | null; - icon?: string | null; - isPreset: boolean; - createdAt: string; -} - export interface ContactTag { id: string; userId: string; @@ -100,7 +88,6 @@ export interface ContactFilters { search?: string; isFavorite?: boolean; isArchived?: boolean; - groupId?: string; tagId?: string; limit?: number; offset?: number; @@ -113,7 +100,6 @@ export const contactsApi = { if (filters.search) params.set('search', filters.search); if (filters.isFavorite !== undefined) params.set('isFavorite', String(filters.isFavorite)); if (filters.isArchived !== undefined) params.set('isArchived', String(filters.isArchived)); - if (filters.groupId) params.set('groupId', filters.groupId); if (filters.tagId) params.set('tagId', filters.tagId); if (filters.limit) params.set('limit', String(filters.limit)); if (filters.offset) params.set('offset', String(filters.offset)); @@ -164,50 +150,6 @@ export const contactsApi = { }, }; -// Groups API -export const groupsApi = { - async list() { - return fetchWithAuth('/groups'); - }, - - async get(id: string) { - return fetchWithAuth(`/groups/${id}`); - }, - - async create(data: { name: string; description?: string; color?: string }) { - return fetchWithAuth('/groups', { - method: 'POST', - body: JSON.stringify(data), - }); - }, - - async update(id: string, data: { name?: string; description?: string; color?: string }) { - return fetchWithAuth(`/groups/${id}`, { - method: 'PATCH', - body: JSON.stringify(data), - }); - }, - - async delete(id: string) { - return fetchWithAuth(`/groups/${id}`, { - method: 'DELETE', - }); - }, - - async addContacts(groupId: string, contactIds: string[]) { - return fetchWithAuth(`/groups/${groupId}/contacts`, { - method: 'POST', - body: JSON.stringify({ contactIds }), - }); - }, - - async removeContact(groupId: string, contactId: string) { - return fetchWithAuth(`/groups/${groupId}/contacts/${contactId}`, { - method: 'DELETE', - }); - }, -}; - // Tags API export const tagsApi = { async list(): Promise<{ tags: ContactTag[] }> { diff --git a/apps/contacts/apps/web/src/lib/api/duplicates.ts b/apps/contacts/apps/web/src/lib/api/duplicates.ts index 2c8a8c903..1f95610e6 100644 --- a/apps/contacts/apps/web/src/lib/api/duplicates.ts +++ b/apps/contacts/apps/web/src/lib/api/duplicates.ts @@ -1,8 +1,7 @@ import { authStore } from '$lib/stores/auth.svelte'; +import { API_BASE } from './config'; import type { Contact } from './contacts'; -const API_BASE = 'http://localhost:3015/api/v1'; - async function fetchWithAuth(url: string, options: RequestInit = {}) { const token = await authStore.getAccessToken(); diff --git a/apps/contacts/apps/web/src/lib/api/export.ts b/apps/contacts/apps/web/src/lib/api/export.ts index b78485020..93727bcb7 100644 --- a/apps/contacts/apps/web/src/lib/api/export.ts +++ b/apps/contacts/apps/web/src/lib/api/export.ts @@ -1,13 +1,11 @@ import { authStore } from '$lib/stores/auth.svelte'; - -const API_BASE = 'http://localhost:3015/api/v1'; +import { API_BASE } from './config'; export type ExportFormat = 'vcard' | 'csv'; export interface ExportOptions { format: ExportFormat; contactIds?: string[]; - groupId?: string; tagId?: string; includeFavorites?: boolean; includeArchived?: boolean; diff --git a/apps/contacts/apps/web/src/lib/api/google.ts b/apps/contacts/apps/web/src/lib/api/google.ts index a7e0b459d..960a0dba1 100644 --- a/apps/contacts/apps/web/src/lib/api/google.ts +++ b/apps/contacts/apps/web/src/lib/api/google.ts @@ -1,6 +1,5 @@ import { authStore } from '$lib/stores/auth.svelte'; - -const API_BASE = 'http://localhost:3015/api/v1'; +import { API_BASE } from './config'; export interface GoogleContact { resourceName: string; diff --git a/apps/contacts/apps/web/src/lib/api/import.ts b/apps/contacts/apps/web/src/lib/api/import.ts index 964dca29d..64128d3d7 100644 --- a/apps/contacts/apps/web/src/lib/api/import.ts +++ b/apps/contacts/apps/web/src/lib/api/import.ts @@ -1,6 +1,5 @@ import { authStore } from '$lib/stores/auth.svelte'; - -const API_BASE = 'http://localhost:3015/api/v1'; +import { API_BASE } from './config'; export interface ParsedContact { firstName?: string; diff --git a/apps/contacts/apps/web/src/lib/components/FilterBar.svelte b/apps/contacts/apps/web/src/lib/components/FilterBar.svelte index bbe3d546c..2eb3b305d 100644 --- a/apps/contacts/apps/web/src/lib/components/FilterBar.svelte +++ b/apps/contacts/apps/web/src/lib/components/FilterBar.svelte @@ -1,15 +1,15 @@ @@ -106,12 +106,12 @@ {#if activeFilterCount > 0 && !showFilters}
- {#if selectedGroupId} - {@const group = groups.find((g) => g.id === selectedGroupId)} - {#if group} - - - - -
- - {/each} - - {/if} - -

{groups.length} Gruppe{groups.length !== 1 ? 'n' : ''}

- {/if} - - - diff --git a/apps/contacts/apps/web/src/routes/(app)/groups/[id]/+page.svelte b/apps/contacts/apps/web/src/routes/(app)/groups/[id]/+page.svelte deleted file mode 100644 index ffc681080..000000000 --- a/apps/contacts/apps/web/src/routes/(app)/groups/[id]/+page.svelte +++ /dev/null @@ -1,1140 +0,0 @@ - - - - {group?.name || 'Gruppe'} - Contacts - - -
- -
- - - - - -

{isEditing ? 'Gruppe bearbeiten' : group?.name || 'Gruppe'}

- {#if !loading && group && !isEditing} - - {:else} -
- {/if} -
- - {#if loading} -
-
-
- {:else if error && !group} -
-
- - - -
-

Fehler

-

{error}

- ZurĂŒck zu Gruppen -
- {:else if group} - {#if error} - - {/if} - - {#if isEditing} - -
-
- - - -
-

{name || 'Gruppenname'}

-
- -
{ - e.preventDefault(); - handleSave(); - }} - class="form" - > -
-
-
- - - -
-

Details

-
-
- - -
-
- - -
-
- -
-
-
- - - -
-

Farbe

-
-
- {#each presetColors as presetColor} - - {/each} -
-
- -
- - -
-
- - - - {:else} - -
-
- - - -
-

{group.name}

- {#if group.description} -

{group.description}

- {/if} -
- - -
-
-
- - - -
-

Kontakte ({groupContacts().length})

- -
- - {#if groupContacts().length === 0} -

Keine Kontakte in dieser Gruppe

- {:else} -
- {#each groupContacts() as contact (contact.id)} -
-
- {#if contact.photoUrl} - {getDisplayName(contact)} - {:else} - {getInitials(contact)} - {/if} -
-
- {getDisplayName(contact)} - {#if contact.email} - {contact.email} - {/if} -
- -
- {/each} -
- {/if} -
- {/if} - {/if} -
- - -{#if showAddContacts} - -{/if} - - diff --git a/apps/contacts/apps/web/src/routes/(app)/groups/new/+page.svelte b/apps/contacts/apps/web/src/routes/(app)/groups/new/+page.svelte deleted file mode 100644 index 87a2b8266..000000000 --- a/apps/contacts/apps/web/src/routes/(app)/groups/new/+page.svelte +++ /dev/null @@ -1,581 +0,0 @@ - - - - Neue Gruppe - Contacts - - -
- -
- - - - - -

Neue Gruppe

-
-
- - -
-
- - - -
-

{name || 'Neue Gruppe'}

- {#if description} -

{description}

- {/if} -
- - {#if error} - - {/if} - -
{ - e.preventDefault(); - handleSubmit(); - }} - class="form" - > - -
-
-
- - - -
-

Gruppenname

-
-
- - -
-
- - -
-
- - -
-
-
- - - -
-

Farbe

-
-
- {#each presetColors as presetColor} - - {/each} -
-
- -
- - -
-
-
- - -
- Abbrechen - -
-
-
- - diff --git a/apps/contacts/apps/web/src/routes/+layout.svelte b/apps/contacts/apps/web/src/routes/+layout.svelte index 5604dd4c8..397b1ff4a 100644 --- a/apps/contacts/apps/web/src/routes/+layout.svelte +++ b/apps/contacts/apps/web/src/routes/+layout.svelte @@ -1,15 +1,76 @@