Add umbral name header, preferred name field, and link button for contacts

- Inject umbral_name into shared_fields for umbral contacts (always visible)
- Show @umbralname subtitle in detail panel header
- Add preferred_name to panel fields with synced label for umbral contacts
- Add Link button on standard contacts to tie to umbral user via connection request
- Migration 046: person_id FK on connection_requests with index
- Validate person_id ownership on send, re-validate + convert on accept

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kyle 2026-03-04 08:37:01 +08:00
parent 4513227338
commit 73cef1df55
8 changed files with 139 additions and 15 deletions

View File

@ -0,0 +1,34 @@
"""Add person_id to connection_requests
Revision ID: 046
Revises: 045
"""
from alembic import op
import sqlalchemy as sa
revision = "046"
down_revision = "045"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"connection_requests",
sa.Column(
"person_id",
sa.Integer(),
sa.ForeignKey("people.id", ondelete="SET NULL"),
nullable=True,
),
)
op.create_index(
"ix_connection_requests_person_id",
"connection_requests",
["person_id"],
)
def downgrade() -> None:
op.drop_index("ix_connection_requests_person_id", table_name="connection_requests")
op.drop_column("connection_requests", "person_id")

View File

@ -27,6 +27,9 @@ class ConnectionRequest(Base):
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default="pending")
created_at: Mapped[datetime] = mapped_column(default=func.now(), server_default=func.now())
resolved_at: Mapped[Optional[datetime]] = mapped_column(nullable=True, default=None)
person_id: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("people.id", ondelete="SET NULL"), nullable=True
)
# Relationships with explicit foreign_keys to disambiguate
sender: Mapped["User"] = relationship(foreign_keys=[sender_id], lazy="selectin")

View File

@ -195,10 +195,24 @@ async def send_connection_request(
if pending_count >= 5:
raise HTTPException(status_code=429, detail="Too many pending requests for this user")
# Validate person_id if provided (link existing standard contact)
link_person_id = None
if body.person_id is not None:
person_result = await db.execute(
select(Person).where(Person.id == body.person_id, Person.user_id == current_user.id)
)
link_person = person_result.scalar_one_or_none()
if not link_person:
raise HTTPException(status_code=400, detail="Person not found or not owned by you")
if link_person.is_umbral_contact:
raise HTTPException(status_code=400, detail="Person is already an umbral contact")
link_person_id = body.person_id
# Create the request (IntegrityError guard for TOCTOU race on partial unique index)
conn_request = ConnectionRequest(
sender_id=current_user.id,
receiver_id=target.id,
person_id=link_person_id,
)
db.add(conn_request)
try:
@ -352,13 +366,19 @@ async def respond_to_request(
ConnectionRequest.status == "pending",
)
.values(status=body.action + "ed", resolved_at=now)
.returning(ConnectionRequest.id, ConnectionRequest.sender_id, ConnectionRequest.receiver_id)
.returning(
ConnectionRequest.id,
ConnectionRequest.sender_id,
ConnectionRequest.receiver_id,
ConnectionRequest.person_id,
)
)
row = result.first()
if not row:
raise HTTPException(status_code=409, detail="Request not found or already resolved")
sender_id = row.sender_id
request_person_id = row.person_id
if body.action == "accept":
# Verify sender is still active
@ -386,11 +406,44 @@ async def respond_to_request(
person_for_receiver = create_person_from_connection(
current_user.id, sender, sender_settings, sender_shared
)
db.add(person_for_receiver)
# Sender side: reuse existing Person if person_id was provided on the request
person_for_sender = None
if request_person_id:
existing_result = await db.execute(
select(Person).where(Person.id == request_person_id)
)
existing_person = existing_result.scalar_one_or_none()
# Re-validate at accept time (C-01, W-01): ownership must match sender,
# and must not already be umbral (prevents double-conversion races)
if existing_person and existing_person.user_id == sender_id and not existing_person.is_umbral_contact:
# Convert existing standard contact to umbral
existing_person.linked_user_id = current_user.id
existing_person.is_umbral_contact = True
existing_person.category = "Umbral"
# Update from shared profile
first_name = receiver_shared.get("first_name") or receiver_shared.get("preferred_name") or current_user.umbral_name
last_name = receiver_shared.get("last_name")
existing_person.first_name = first_name
existing_person.last_name = last_name
existing_person.email = receiver_shared.get("email") or existing_person.email
existing_person.phone = receiver_shared.get("phone") or existing_person.phone
existing_person.mobile = receiver_shared.get("mobile") or existing_person.mobile
existing_person.address = receiver_shared.get("address") or existing_person.address
existing_person.company = receiver_shared.get("company") or existing_person.company
existing_person.job_title = receiver_shared.get("job_title") or existing_person.job_title
# Recompute display name
full = ((first_name or '') + ' ' + (last_name or '')).strip()
existing_person.name = full or current_user.umbral_name
person_for_sender = existing_person
if person_for_sender is None:
person_for_sender = create_person_from_connection(
sender_id, current_user, receiver_settings, receiver_shared
)
db.add(person_for_receiver)
db.add(person_for_sender)
await db.flush() # populate person IDs
# Create bidirectional connections

View File

@ -100,6 +100,8 @@ async def get_people(
shared_profiles[uid] = resolve_shared_profile(
user, user_settings, overrides_by_user.get(uid)
)
# umbral_name is always visible (public identity), not a shareable field
shared_profiles[uid]["umbral_name"] = user.umbral_name
# Attach to response
responses = []
@ -176,6 +178,7 @@ async def get_person(
resp.shared_fields = resolve_shared_profile(
linked_user, linked_settings, conn.sharing_overrides if conn else None
)
resp.shared_fields["umbral_name"] = linked_user.umbral_name
return resp

View File

@ -30,6 +30,7 @@ class UmbralSearchResponse(BaseModel):
class SendConnectionRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
umbral_name: str = Field(..., max_length=50)
person_id: Optional[int] = Field(default=None, ge=1, le=2147483647)
@field_validator('umbral_name')
@classmethod

View File

@ -19,9 +19,10 @@ import { getErrorMessage } from '@/lib/api';
interface ConnectionSearchProps {
open: boolean;
onOpenChange: (open: boolean) => void;
personId?: number;
}
export default function ConnectionSearch({ open, onOpenChange }: ConnectionSearchProps) {
export default function ConnectionSearch({ open, onOpenChange, personId }: ConnectionSearchProps) {
const { search, isSearching, sendRequest, isSending } = useConnections();
const { settings } = useSettings();
const navigate = useNavigate();
@ -45,7 +46,7 @@ export default function ConnectionSearch({ open, onOpenChange }: ConnectionSearc
const handleSend = async () => {
try {
await sendRequest(umbralName.trim());
await sendRequest({ umbralName: umbralName.trim(), personId });
setSent(true);
toast.success('Connection request sent');
} catch (err) {
@ -69,7 +70,9 @@ export default function ConnectionSearch({ open, onOpenChange }: ConnectionSearc
Find Umbra User
</DialogTitle>
<DialogDescription>
Search for a user by their umbral name to send a connection request.
{personId
? 'Search for an umbral user to link this contact to.'
: 'Search for a user by their umbral name to send a connection request.'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 pt-2">

View File

@ -1,5 +1,5 @@
import { useState, useMemo, useRef, useEffect } from 'react';
import { Plus, Users, Star, Cake, Phone, Mail, MapPin, Tag, Building2, Briefcase, AlignLeft, Ghost, ChevronDown, Unlink } from 'lucide-react';
import { Plus, Users, Star, Cake, Phone, Mail, MapPin, Tag, Building2, Briefcase, AlignLeft, Ghost, ChevronDown, Unlink, Link2, User2 } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { format, parseISO, differenceInYears } from 'date-fns';
@ -192,6 +192,7 @@ const columns: ColumnDef<Person>[] = [
// Panel field config
// ---------------------------------------------------------------------------
const panelFields: PanelField[] = [
{ label: 'Preferred Name', key: 'preferred_name', icon: User2 },
{ label: 'Mobile', key: 'mobile', copyable: true, icon: Phone },
{ label: 'Phone', key: 'phone', copyable: true, icon: Phone },
{ label: 'Email', key: 'email', copyable: true, icon: Mail },
@ -220,6 +221,7 @@ export default function PeoplePage() {
const [sortKey, setSortKey] = useState<string>('name');
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
const [showConnectionSearch, setShowConnectionSearch] = useState(false);
const [linkPersonId, setLinkPersonId] = useState<number | null>(null);
const [showAddDropdown, setShowAddDropdown] = useState(false);
const addDropdownRef = useRef<HTMLDivElement>(null);
@ -431,16 +433,24 @@ export default function PeoplePage() {
<Ghost className="h-4 w-4 text-violet-400 shrink-0" />
)}
</div>
<div className="flex items-center gap-2">
{p.is_umbral_contact && p.shared_fields?.umbral_name ? (
<span className="text-xs text-violet-400/80 font-normal">
@{String(p.shared_fields.umbral_name)}
</span>
) : null}
{p.category && (
<span className="text-xs text-muted-foreground">{p.category}</span>
)}
</div>
</div>
</div>
);
};
// Shared field key mapping (panel key -> shared_fields key)
const sharedKeyMap: Record<string, string> = {
preferred_name: 'preferred_name',
email: 'email',
phone: 'phone',
mobile: 'mobile',
@ -519,7 +529,17 @@ export default function PeoplePage() {
<Unlink className="h-3 w-3" />
Unlink
</Button>
) : null
) : (
<Button
variant="ghost"
size="sm"
onClick={() => setLinkPersonId(p.id)}
className="h-7 text-[11px] text-muted-foreground hover:text-foreground gap-1"
>
<Link2 className="h-3 w-3" />
Link
</Button>
)
}
/>
);
@ -760,6 +780,12 @@ export default function PeoplePage() {
open={showConnectionSearch}
onOpenChange={setShowConnectionSearch}
/>
<ConnectionSearch
open={linkPersonId !== null}
onOpenChange={(open) => { if (!open) setLinkPersonId(null); }}
personId={linkPersonId ?? undefined}
/>
</div>
);
}

View File

@ -39,9 +39,10 @@ export function useConnections() {
});
const sendRequestMutation = useMutation({
mutationFn: async (umbralName: string) => {
mutationFn: async (params: { umbralName: string; personId?: number }) => {
const { data } = await api.post('/connections/request', {
umbral_name: umbralName,
umbral_name: params.umbralName,
...(params.personId != null && { person_id: params.personId }),
});
return data;
},