From afa9f997057a2188a3f0d327e88b9090eaef3a1d Mon Sep 17 00:00:00 2001 From: Till JS Date: Sat, 21 Mar 2026 11:14:44 +0100 Subject: [PATCH] =?UTF-8?q?feat(contacts):=20add=20"My=20Card"=20self-cont?= =?UTF-8?q?act=20=E2=80=94=20auto-created=20on=20first=20load?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every user now gets their own contact card (like iOS "My Card") automatically created on first API call, pre-filled with their email. The self-contact is shown prominently at the top of the list with an "Ich/Me" badge, can be fully edited, but cannot be deleted. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../__tests__/contact.controller.spec.ts | 2 + .../backend/src/contact/contact.controller.ts | 12 +++ .../backend/src/contact/contact.service.ts | 39 ++++++++- .../backend/src/db/schema/contacts.schema.ts | 1 + .../contacts/apps/web/src/lib/api/contacts.ts | 1 + .../lib/components/ContactDetailModal.svelte | 32 +++---- .../web/src/lib/components/ContactList.svelte | 85 ++++++++++++++++++- .../apps/web/src/lib/i18n/locales/de.json | 2 + .../apps/web/src/lib/i18n/locales/en.json | 2 + .../apps/web/src/lib/i18n/locales/es.json | 2 + .../apps/web/src/lib/i18n/locales/fr.json | 2 + .../apps/web/src/lib/i18n/locales/it.json | 2 + .../web/src/lib/stores/contacts.svelte.ts | 24 +++++- 13 files changed, 185 insertions(+), 21 deletions(-) diff --git a/apps/contacts/apps/backend/src/contact/__tests__/contact.controller.spec.ts b/apps/contacts/apps/backend/src/contact/__tests__/contact.controller.spec.ts index 829e40819..ac8ac7089 100644 --- a/apps/contacts/apps/backend/src/contact/__tests__/contact.controller.spec.ts +++ b/apps/contacts/apps/backend/src/contact/__tests__/contact.controller.spec.ts @@ -34,6 +34,8 @@ describe('ContactController', () => { delete: jest.fn(), toggleFavorite: jest.fn(), toggleArchive: jest.fn(), + ensureSelfContact: jest.fn().mockResolvedValue(createMockContact({ isSelf: true })), + findSelfContact: jest.fn(), }; controller = new ContactController(service); }); diff --git a/apps/contacts/apps/backend/src/contact/contact.controller.ts b/apps/contacts/apps/backend/src/contact/contact.controller.ts index 61efe2cb4..922cca191 100644 --- a/apps/contacts/apps/backend/src/contact/contact.controller.ts +++ b/apps/contacts/apps/backend/src/contact/contact.controller.ts @@ -249,11 +249,23 @@ export class ContactController { @Get() async findAll(@CurrentUser() user: CurrentUserData, @Query() query: ContactQueryDto) { + // Ensure the user's own contact card exists (lazy creation) + await this.contactService.ensureSelfContact(user.userId, user.email); + const contacts = await this.contactService.findByUserId(user.userId, query); const total = await this.contactService.count(user.userId, query.isArchived); return { contacts, total }; } + /** + * Get the user's own contact card + */ + @Get('self') + async findSelf(@CurrentUser() user: CurrentUserData) { + const contact = await this.contactService.ensureSelfContact(user.userId, user.email); + return { contact }; + } + /** * Get all contacts with birthdays (for calendar integration) * Returns lightweight data: id, displayName, firstName, lastName, birthday, photoUrl diff --git a/apps/contacts/apps/backend/src/contact/contact.service.ts b/apps/contacts/apps/backend/src/contact/contact.service.ts index d38abc921..2ec9cabae 100644 --- a/apps/contacts/apps/backend/src/contact/contact.service.ts +++ b/apps/contacts/apps/backend/src/contact/contact.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Inject, NotFoundException } from '@nestjs/common'; +import { Injectable, Inject, NotFoundException, BadRequestException } from '@nestjs/common'; import { eq, and, or, ilike, desc, sql, isNotNull, inArray } from 'drizzle-orm'; import { DATABASE_CONNECTION } from '../db/database.module'; import { Database } from '../db/connection'; @@ -144,12 +144,49 @@ export class ContactService { throw new NotFoundException('Contact not found'); } + if (existing.isSelf) { + throw new BadRequestException('Cannot delete your own contact card'); + } + // Clean up photo from S3 before deleting the DB record await this.photoService.deletePhotoByUrl(existing.photoUrl); await this.db.delete(contacts).where(and(eq(contacts.id, id), eq(contacts.userId, userId))); } + async findSelfContact(userId: string): Promise { + const [contact] = await this.db + .select() + .from(contacts) + .where(and(eq(contacts.userId, userId), eq(contacts.isSelf, true))); + return contact || null; + } + + async ensureSelfContact(userId: string, email: string): Promise { + const existing = await this.findSelfContact(userId); + if (existing) { + return existing; + } + + // Create self contact with email prefix as display name + const emailPrefix = email.split('@')[0] || ''; + const displayName = emailPrefix.charAt(0).toUpperCase() + emailPrefix.slice(1); + + const [contact] = await this.db + .insert(contacts) + .values({ + userId, + email, + displayName, + firstName: displayName, + isSelf: true, + createdBy: userId, + }) + .returning(); + + return contact; + } + async toggleFavorite(id: string, userId: string): Promise { const contact = await this.findById(id, userId); if (!contact) { diff --git a/apps/contacts/apps/backend/src/db/schema/contacts.schema.ts b/apps/contacts/apps/backend/src/db/schema/contacts.schema.ts index 2dcda4a7d..ba0a9851d 100644 --- a/apps/contacts/apps/backend/src/db/schema/contacts.schema.ts +++ b/apps/contacts/apps/backend/src/db/schema/contacts.schema.ts @@ -63,6 +63,7 @@ export const contacts = pgTable( // Flags isFavorite: boolean('is_favorite').default(false), isArchived: boolean('is_archived').default(false), + isSelf: boolean('is_self').default(false), // Manacore Integration organizationId: uuid('organization_id'), diff --git a/apps/contacts/apps/web/src/lib/api/contacts.ts b/apps/contacts/apps/web/src/lib/api/contacts.ts index 65aa21bbe..6cb265219 100644 --- a/apps/contacts/apps/web/src/lib/api/contacts.ts +++ b/apps/contacts/apps/web/src/lib/api/contacts.ts @@ -57,6 +57,7 @@ export interface Contact { tags?: Array<{ id: string; name: string; color: string | null }>; isFavorite: boolean; isArchived: boolean; + isSelf: boolean; organizationId?: string | null; teamId?: string | null; visibility: string; diff --git a/apps/contacts/apps/web/src/lib/components/ContactDetailModal.svelte b/apps/contacts/apps/web/src/lib/components/ContactDetailModal.svelte index 2b4c4e63b..273e61fb1 100644 --- a/apps/contacts/apps/web/src/lib/components/ContactDetailModal.svelte +++ b/apps/contacts/apps/web/src/lib/components/ContactDetailModal.svelte @@ -336,21 +336,23 @@ /> - + {#if !contact?.isSelf} + + {/if} {:else}
diff --git a/apps/contacts/apps/web/src/lib/components/ContactList.svelte b/apps/contacts/apps/web/src/lib/components/ContactList.svelte index 897027acb..fe5fd93bc 100644 --- a/apps/contacts/apps/web/src/lib/components/ContactList.svelte +++ b/apps/contacts/apps/web/src/lib/components/ContactList.svelte @@ -253,7 +253,7 @@ onMount(async () => { // Only load if not already loaded - if (contactsStore.contacts.length === 0) { + if (contactsStore.contacts.length === 0 && !contactsStore.selfContact) { await contactsStore.loadContacts(); } @@ -379,7 +379,7 @@ {:else} {/if} - {:else if contactsStore.contacts.length === 0} + {:else if contactsStore.contacts.length === 0 && !contactsStore.selfContact}
👤
@@ -390,6 +390,45 @@
{:else} + + {#if contactsStore.selfContact} + {@const self = contactsStore.selfContact} + + {/if} + {#if viewModeStore.mode === 'grid'}