mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 23:41:08 +02:00
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:
parent
99091ecf9e
commit
afa9f99705
13 changed files with 185 additions and 21 deletions
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue