Kyle Pope 3d22568b9c Add user connections, notification centre, and people integration
Implements the full User Connections & Notification Centre feature:

Phase 1 - Database: migrations 039-043 adding umbral_name to users,
profile/social fields to settings, notifications table, connection
request/user_connection tables, and linked_user_id to people.

Phase 2 - Notifications: backend CRUD router + service + 90-day purge,
frontend NotificationsPage with All/Unread filter, bell icon in sidebar
with unread badge polling every 60s.

Phase 3 - Settings: profile fields (phone, mobile, address, company,
job_title), social card with accept_connections toggle and per-field
sharing defaults, umbral name display with CopyableField.

Phase 4 - Connections: timing-safe user search, send/accept/reject flow
with atomic status updates, bidirectional UserConnection + Person records,
in-app + ntfy notifications, per-receiver pending cap, nginx rate limiting.

Phase 5 - People integration: batch-loaded shared profiles (N+1 prevention),
Ghost icon for umbral contacts, Umbral filter pill, split Add Person button,
shared field indicators (synced labels + Lock icons), disabled form inputs
for synced fields on umbral contacts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 02:10:16 +08:00

350 lines
13 KiB
TypeScript

import { useState, useMemo, FormEvent } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Star, StarOff, X, Lock } from 'lucide-react';
import { parseISO, differenceInYears } from 'date-fns';
import api, { getErrorMessage } from '@/lib/api';
import type { Person } from '@/types';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetFooter,
} from '@/components/ui/sheet';
import { Input } from '@/components/ui/input';
import { DatePicker } from '@/components/ui/date-picker';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import LocationPicker from '@/components/ui/location-picker';
import CategoryAutocomplete from '@/components/shared/CategoryAutocomplete';
import { splitName } from '@/components/shared/utils';
interface PersonFormProps {
person: Person | null;
categories: string[];
onClose: () => void;
}
export default function PersonForm({ person, categories, onClose }: PersonFormProps) {
const queryClient = useQueryClient();
// Helper to resolve a field value — prefer shared_fields for umbral contacts
const sf = person?.shared_fields;
const shared = (key: string, fallback: string) =>
sf && key in sf && sf[key] != null ? String(sf[key]) : fallback;
const [formData, setFormData] = useState({
first_name:
person?.first_name ||
(person?.name ? splitName(person.name).firstName : ''),
last_name:
person?.last_name ||
(person?.name ? splitName(person.name).lastName : ''),
nickname: person?.nickname || '',
email: shared('email', person?.email || ''),
phone: shared('phone', person?.phone || ''),
mobile: shared('mobile', person?.mobile || ''),
address: shared('address', person?.address || ''),
birthday: shared('birthday', person?.birthday ? person.birthday.slice(0, 10) : ''),
category: person?.category || '',
is_favourite: person?.is_favourite ?? false,
company: shared('company', person?.company || ''),
job_title: shared('job_title', person?.job_title || ''),
notes: person?.notes || '',
});
// Check if a field is synced from an umbral connection (read-only)
const isShared = (fieldKey: string): boolean => {
if (!person?.is_umbral_contact || !person.shared_fields) return false;
return fieldKey in person.shared_fields;
};
const age = useMemo(() => {
if (!formData.birthday) return null;
try {
return differenceInYears(new Date(), parseISO(formData.birthday));
} catch {
return null;
}
}, [formData.birthday]);
const set = <K extends keyof typeof formData>(key: K, value: (typeof formData)[K]) => {
setFormData((prev) => ({ ...prev, [key]: value }));
};
const mutation = useMutation({
mutationFn: async (data: typeof formData) => {
if (person) {
const { data: res } = await api.put(`/people/${person.id}`, data);
return res;
}
const { data: res } = await api.post('/people', data);
return res;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['people'] });
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
queryClient.invalidateQueries({ queryKey: ['upcoming'] });
toast.success(person ? 'Person updated' : 'Person created');
onClose();
},
onError: (error) => {
toast.error(
getErrorMessage(error, person ? 'Failed to update person' : 'Failed to create person')
);
},
});
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
mutation.mutate({ ...formData, birthday: formData.birthday || null } as typeof formData);
};
return (
<Sheet open={true} onOpenChange={onClose}>
<SheetContent>
<SheetHeader>
<div className="flex items-center justify-between">
<SheetTitle>{person ? 'Edit Person' : 'New Person'}</SheetTitle>
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className={`h-7 w-7 ${formData.is_favourite ? 'text-yellow-400' : 'text-muted-foreground'}`}
onClick={() => set('is_favourite', !formData.is_favourite)}
aria-label={formData.is_favourite ? 'Remove from favourites' : 'Add to favourites'}
>
{formData.is_favourite ? (
<Star className="h-4 w-4 fill-yellow-400" />
) : (
<StarOff className="h-4 w-4" />
)}
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onClick={onClose}
aria-label="Close"
className="h-7 w-7 shrink-0"
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
</SheetHeader>
<form onSubmit={handleSubmit} className="flex flex-col flex-1 overflow-y-auto">
<div className="px-6 py-5 space-y-4 flex-1">
{/* Row 2: First + Last name */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="first_name" required>First Name</Label>
<Input
id="first_name"
value={formData.first_name}
onChange={(e) => set('first_name', e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="last_name">Last Name</Label>
<Input
id="last_name"
value={formData.last_name}
onChange={(e) => set('last_name', e.target.value)}
/>
</div>
</div>
{/* Row 3: Nickname */}
<div className="space-y-2">
<Label htmlFor="nickname">Nickname</Label>
<Input
id="nickname"
value={formData.nickname}
onChange={(e) => set('nickname', e.target.value)}
placeholder="Optional display name"
/>
</div>
{/* Row 4: Birthday + Age */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="birthday" className="flex items-center gap-1">
Birthday
{isShared('birthday') && <Lock className="h-3 w-3 text-violet-400" />}
</Label>
{isShared('birthday') ? (
<Input
id="birthday"
value={formData.birthday}
disabled
className="opacity-70 cursor-not-allowed"
/>
) : (
<DatePicker
variant="input"
id="birthday"
value={formData.birthday}
onChange={(v) => set('birthday', v)}
/>
)}
</div>
<div className="space-y-2">
<Label htmlFor="age">Age</Label>
<Input
id="age"
value={age !== null ? String(age) : ''}
disabled
placeholder="—"
aria-label="Calculated age"
/>
</div>
</div>
{/* Row 5: Category */}
<div className="space-y-2">
<Label htmlFor="category">Category</Label>
<CategoryAutocomplete
id="category"
value={formData.category}
onChange={(val) => set('category', val)}
categories={categories}
placeholder="e.g. Friend, Family, Colleague"
/>
</div>
{/* Row 6: Mobile + Email */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="mobile" className="flex items-center gap-1">
Mobile
{isShared('mobile') && <Lock className="h-3 w-3 text-violet-400" />}
</Label>
<Input
id="mobile"
type="tel"
value={formData.mobile}
onChange={(e) => set('mobile', e.target.value)}
disabled={isShared('mobile')}
className={isShared('mobile') ? 'opacity-70 cursor-not-allowed' : ''}
/>
</div>
<div className="space-y-2">
<Label htmlFor="email" className="flex items-center gap-1">
Email
{isShared('email') && <Lock className="h-3 w-3 text-violet-400" />}
</Label>
<Input
id="email"
type="email"
value={formData.email}
onChange={(e) => set('email', e.target.value)}
disabled={isShared('email')}
className={isShared('email') ? 'opacity-70 cursor-not-allowed' : ''}
/>
</div>
</div>
{/* Row 7: Phone */}
<div className="space-y-2">
<Label htmlFor="phone" className="flex items-center gap-1">
Phone
{isShared('phone') && <Lock className="h-3 w-3 text-violet-400" />}
</Label>
<Input
id="phone"
type="tel"
value={formData.phone}
onChange={(e) => set('phone', e.target.value)}
placeholder="Landline / work number"
disabled={isShared('phone')}
className={isShared('phone') ? 'opacity-70 cursor-not-allowed' : ''}
/>
</div>
{/* Row 8: Address */}
<div className="space-y-2">
<Label htmlFor="address" className="flex items-center gap-1">
Address
{isShared('address') && <Lock className="h-3 w-3 text-violet-400" />}
</Label>
{isShared('address') ? (
<Input
id="address"
value={formData.address}
disabled
className="opacity-70 cursor-not-allowed"
/>
) : (
<LocationPicker
id="address"
value={formData.address}
onChange={(val) => set('address', val)}
onSelect={(result) => set('address', result.address || result.name)}
placeholder="Search or enter address..."
/>
)}
</div>
{/* Row 9: Company + Job Title */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="company" className="flex items-center gap-1">
Company
{isShared('company') && <Lock className="h-3 w-3 text-violet-400" />}
</Label>
<Input
id="company"
value={formData.company}
onChange={(e) => set('company', e.target.value)}
disabled={isShared('company')}
className={isShared('company') ? 'opacity-70 cursor-not-allowed' : ''}
/>
</div>
<div className="space-y-2">
<Label htmlFor="job_title" className="flex items-center gap-1">
Job Title
{isShared('job_title') && <Lock className="h-3 w-3 text-violet-400" />}
</Label>
<Input
id="job_title"
value={formData.job_title}
onChange={(e) => set('job_title', e.target.value)}
disabled={isShared('job_title')}
className={isShared('job_title') ? 'opacity-70 cursor-not-allowed' : ''}
/>
</div>
</div>
{/* Row 10: Notes */}
<div className="space-y-2">
<Label htmlFor="notes">Notes</Label>
<Textarea
id="notes"
value={formData.notes}
onChange={(e) => set('notes', e.target.value)}
rows={3}
placeholder="Any additional context..."
/>
</div>
</div>
<SheetFooter>
<Button type="button" variant="outline" onClick={onClose}>
Cancel
</Button>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Saving...' : person ? 'Update' : 'Create'}
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
);
}