feat(contacts): add "My Card" self-contact — auto-created on first load

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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-21 11:14:44 +01:00
parent 99091ecf9e
commit afa9f99705
13 changed files with 185 additions and 21 deletions

View file

@ -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);
});

View file

@ -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

View file

@ -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<Contact | null> {
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<Contact> {
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<Contact> {
const contact = await this.findById(id, userId);
if (!contact) {

View file

@ -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'),

View file

@ -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;

View file

@ -336,21 +336,23 @@
/>
</svg>
</button>
<button
onclick={handleDelete}
disabled={deleting}
class="action-btn action-btn-danger"
aria-label="Löschen"
>
<svg class="icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
{#if !contact?.isSelf}
<button
onclick={handleDelete}
disabled={deleting}
class="action-btn action-btn-danger"
aria-label="Löschen"
>
<svg class="icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
{/if}
</div>
{:else}
<div class="header-spacer"></div>

View file

@ -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}
<ContactListSkeleton count={10} />
{/if}
{:else if contactsStore.contacts.length === 0}
{:else if contactsStore.contacts.length === 0 && !contactsStore.selfContact}
<!-- Empty state -->
<div class="text-center py-12">
<div class="text-6xl mb-4">👤</div>
@ -390,6 +390,45 @@
</button>
</div>
{:else}
<!-- Self Contact Card ("My Card") -->
{#if contactsStore.selfContact}
{@const self = contactsStore.selfContact}
<button type="button" class="self-contact-card" onclick={() => goto(`/contacts/${self.id}`)}>
<div class="self-contact-avatar">
{#if self.photoUrl}
<img
src={self.photoUrl}
alt={self.displayName || ''}
class="w-full h-full object-cover rounded-full"
/>
{:else}
<span class="text-lg font-semibold text-primary">
{(self.firstName?.[0] || self.email?.[0] || '?').toUpperCase()}
</span>
{/if}
</div>
<div class="flex-1 min-w-0 text-left">
<div class="flex items-center gap-2">
<span class="font-semibold text-foreground truncate">
{self.displayName || self.email || $_('contacts.myCard')}
</span>
<span class="self-badge">{$_('contacts.me')}</span>
</div>
{#if self.email}
<p class="text-sm text-muted-foreground truncate">{self.email}</p>
{/if}
</div>
<svg
class="w-5 h-5 text-muted-foreground shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</button>
{/if}
<!-- Contacts View -->
{#if viewModeStore.mode === 'grid'}
<ContactGridView
@ -433,6 +472,48 @@
</div>
<style>
.self-contact-card {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 0.75rem 1rem;
background: hsl(var(--color-surface));
border: 1px solid hsl(var(--color-border));
border-radius: 0.75rem;
cursor: pointer;
transition: all 0.2s ease;
}
.self-contact-card:hover {
background: hsl(var(--color-surface-hover, var(--color-muted)));
border-color: hsl(var(--color-primary) / 0.3);
}
.self-contact-avatar {
width: 2.75rem;
height: 2.75rem;
border-radius: 50%;
background: hsl(var(--color-primary) / 0.1);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
shrink: 0;
}
.self-badge {
font-size: 0.625rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
background: hsl(var(--color-primary) / 0.1);
color: hsl(var(--color-primary));
white-space: nowrap;
}
.batch-actions-bar {
display: flex;
align-items: center;

View file

@ -68,6 +68,8 @@
"search": "Kontakte durchsuchen...",
"noContacts": "Keine Kontakte gefunden",
"addFirst": "Füge deinen ersten Kontakt hinzu",
"me": "Ich",
"myCard": "Meine Karte",
"favorites": "Favoriten",
"archive": "Archiv",
"contact": "Kontakt",

View file

@ -68,6 +68,8 @@
"search": "Search contacts...",
"noContacts": "No contacts found",
"addFirst": "Add your first contact",
"me": "Me",
"myCard": "My Card",
"favorites": "Favorites",
"archive": "Archive",
"contact": "Contact",

View file

@ -68,6 +68,8 @@
"search": "Buscar contactos...",
"noContacts": "No se encontraron contactos",
"addFirst": "Añade tu primer contacto",
"me": "Yo",
"myCard": "Mi tarjeta",
"favorites": "Favoritos",
"archive": "Archivo",
"contact": "Contacto",

View file

@ -68,6 +68,8 @@
"search": "Rechercher des contacts...",
"noContacts": "Aucun contact trouvé",
"addFirst": "Ajoutez votre premier contact",
"me": "Moi",
"myCard": "Ma fiche",
"favorites": "Favoris",
"archive": "Archives",
"contact": "Contact",

View file

@ -68,6 +68,8 @@
"search": "Cerca contatti...",
"noContacts": "Nessun contatto trovato",
"addFirst": "Aggiungi il tuo primo contatto",
"me": "Io",
"myCard": "La mia scheda",
"favorites": "Preferiti",
"archive": "Archivio",
"contact": "Contatto",

View file

@ -14,6 +14,7 @@ const DEFAULT_PAGE_SIZE = 50;
// State
let contacts = $state<Contact[]>([]);
let selfContact = $state<Contact | null>(null);
let selectedContact = $state<Contact | null>(null);
let loading = $state(false);
let loadingMore = $state(false);
@ -28,6 +29,9 @@ export const contactsStore = {
get contacts() {
return contacts;
},
get selfContact() {
return selfContact;
},
get selectedContact() {
return selectedContact;
},
@ -98,7 +102,12 @@ export const contactsStore = {
limit: DEFAULT_PAGE_SIZE,
offset: 0,
});
contacts = result.contacts;
// Extract self contact from the list
const self = result.contacts.find((c) => c.isSelf);
if (self) {
selfContact = self;
}
contacts = result.contacts.filter((c) => !c.isSelf);
total = result.total;
hasMore = contacts.length < total;
currentOffset = contacts.length;
@ -126,7 +135,7 @@ export const contactsStore = {
offset: currentOffset,
});
const newContacts = result.contacts;
const newContacts = result.contacts.filter((c) => !c.isSelf);
contacts = [...contacts, ...newContacts];
total = result.total;
currentOffset += newContacts.length;
@ -203,7 +212,11 @@ export const contactsStore = {
try {
const contact = await contactsApi.update(id, data);
// Update in local state
contacts = contacts.map((c) => (c.id === id ? contact : c));
if (contact.isSelf) {
selfContact = contact;
} else {
contacts = contacts.map((c) => (c.id === id ? contact : c));
}
if (selectedContact?.id === id) {
selectedContact = contact;
}
@ -227,6 +240,11 @@ export const contactsStore = {
return { error: 'auth_required' as const };
}
// Prevent deleting self contact
if (selfContact?.id === id) {
return;
}
loading = true;
error = null;