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:
parent
4513227338
commit
73cef1df55
@ -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")
|
||||||
@ -27,6 +27,9 @@ class ConnectionRequest(Base):
|
|||||||
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default="pending")
|
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())
|
created_at: Mapped[datetime] = mapped_column(default=func.now(), server_default=func.now())
|
||||||
resolved_at: Mapped[Optional[datetime]] = mapped_column(nullable=True, default=None)
|
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
|
# Relationships with explicit foreign_keys to disambiguate
|
||||||
sender: Mapped["User"] = relationship(foreign_keys=[sender_id], lazy="selectin")
|
sender: Mapped["User"] = relationship(foreign_keys=[sender_id], lazy="selectin")
|
||||||
|
|||||||
@ -195,10 +195,24 @@ async def send_connection_request(
|
|||||||
if pending_count >= 5:
|
if pending_count >= 5:
|
||||||
raise HTTPException(status_code=429, detail="Too many pending requests for this user")
|
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)
|
# Create the request (IntegrityError guard for TOCTOU race on partial unique index)
|
||||||
conn_request = ConnectionRequest(
|
conn_request = ConnectionRequest(
|
||||||
sender_id=current_user.id,
|
sender_id=current_user.id,
|
||||||
receiver_id=target.id,
|
receiver_id=target.id,
|
||||||
|
person_id=link_person_id,
|
||||||
)
|
)
|
||||||
db.add(conn_request)
|
db.add(conn_request)
|
||||||
try:
|
try:
|
||||||
@ -352,13 +366,19 @@ async def respond_to_request(
|
|||||||
ConnectionRequest.status == "pending",
|
ConnectionRequest.status == "pending",
|
||||||
)
|
)
|
||||||
.values(status=body.action + "ed", resolved_at=now)
|
.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()
|
row = result.first()
|
||||||
if not row:
|
if not row:
|
||||||
raise HTTPException(status_code=409, detail="Request not found or already resolved")
|
raise HTTPException(status_code=409, detail="Request not found or already resolved")
|
||||||
|
|
||||||
sender_id = row.sender_id
|
sender_id = row.sender_id
|
||||||
|
request_person_id = row.person_id
|
||||||
|
|
||||||
if body.action == "accept":
|
if body.action == "accept":
|
||||||
# Verify sender is still active
|
# Verify sender is still active
|
||||||
@ -386,11 +406,44 @@ async def respond_to_request(
|
|||||||
person_for_receiver = create_person_from_connection(
|
person_for_receiver = create_person_from_connection(
|
||||||
current_user.id, sender, sender_settings, sender_shared
|
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(
|
person_for_sender = create_person_from_connection(
|
||||||
sender_id, current_user, receiver_settings, receiver_shared
|
sender_id, current_user, receiver_settings, receiver_shared
|
||||||
)
|
)
|
||||||
db.add(person_for_receiver)
|
|
||||||
db.add(person_for_sender)
|
db.add(person_for_sender)
|
||||||
|
|
||||||
await db.flush() # populate person IDs
|
await db.flush() # populate person IDs
|
||||||
|
|
||||||
# Create bidirectional connections
|
# Create bidirectional connections
|
||||||
|
|||||||
@ -100,6 +100,8 @@ async def get_people(
|
|||||||
shared_profiles[uid] = resolve_shared_profile(
|
shared_profiles[uid] = resolve_shared_profile(
|
||||||
user, user_settings, overrides_by_user.get(uid)
|
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
|
# Attach to response
|
||||||
responses = []
|
responses = []
|
||||||
@ -176,6 +178,7 @@ async def get_person(
|
|||||||
resp.shared_fields = resolve_shared_profile(
|
resp.shared_fields = resolve_shared_profile(
|
||||||
linked_user, linked_settings, conn.sharing_overrides if conn else None
|
linked_user, linked_settings, conn.sharing_overrides if conn else None
|
||||||
)
|
)
|
||||||
|
resp.shared_fields["umbral_name"] = linked_user.umbral_name
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -30,6 +30,7 @@ class UmbralSearchResponse(BaseModel):
|
|||||||
class SendConnectionRequest(BaseModel):
|
class SendConnectionRequest(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
umbral_name: str = Field(..., max_length=50)
|
umbral_name: str = Field(..., max_length=50)
|
||||||
|
person_id: Optional[int] = Field(default=None, ge=1, le=2147483647)
|
||||||
|
|
||||||
@field_validator('umbral_name')
|
@field_validator('umbral_name')
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@ -19,9 +19,10 @@ import { getErrorMessage } from '@/lib/api';
|
|||||||
interface ConnectionSearchProps {
|
interface ConnectionSearchProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
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 { search, isSearching, sendRequest, isSending } = useConnections();
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@ -45,7 +46,7 @@ export default function ConnectionSearch({ open, onOpenChange }: ConnectionSearc
|
|||||||
|
|
||||||
const handleSend = async () => {
|
const handleSend = async () => {
|
||||||
try {
|
try {
|
||||||
await sendRequest(umbralName.trim());
|
await sendRequest({ umbralName: umbralName.trim(), personId });
|
||||||
setSent(true);
|
setSent(true);
|
||||||
toast.success('Connection request sent');
|
toast.success('Connection request sent');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -69,7 +70,9 @@ export default function ConnectionSearch({ open, onOpenChange }: ConnectionSearc
|
|||||||
Find Umbra User
|
Find Umbra User
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<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>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4 pt-2">
|
<div className="space-y-4 pt-2">
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useState, useMemo, useRef, useEffect } from 'react';
|
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 type { LucideIcon } from 'lucide-react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { format, parseISO, differenceInYears } from 'date-fns';
|
import { format, parseISO, differenceInYears } from 'date-fns';
|
||||||
@ -192,6 +192,7 @@ const columns: ColumnDef<Person>[] = [
|
|||||||
// Panel field config
|
// Panel field config
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
const panelFields: PanelField[] = [
|
const panelFields: PanelField[] = [
|
||||||
|
{ label: 'Preferred Name', key: 'preferred_name', icon: User2 },
|
||||||
{ label: 'Mobile', key: 'mobile', copyable: true, icon: Phone },
|
{ label: 'Mobile', key: 'mobile', copyable: true, icon: Phone },
|
||||||
{ label: 'Phone', key: 'phone', copyable: true, icon: Phone },
|
{ label: 'Phone', key: 'phone', copyable: true, icon: Phone },
|
||||||
{ label: 'Email', key: 'email', copyable: true, icon: Mail },
|
{ label: 'Email', key: 'email', copyable: true, icon: Mail },
|
||||||
@ -220,6 +221,7 @@ export default function PeoplePage() {
|
|||||||
const [sortKey, setSortKey] = useState<string>('name');
|
const [sortKey, setSortKey] = useState<string>('name');
|
||||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
|
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
|
||||||
const [showConnectionSearch, setShowConnectionSearch] = useState(false);
|
const [showConnectionSearch, setShowConnectionSearch] = useState(false);
|
||||||
|
const [linkPersonId, setLinkPersonId] = useState<number | null>(null);
|
||||||
const [showAddDropdown, setShowAddDropdown] = useState(false);
|
const [showAddDropdown, setShowAddDropdown] = useState(false);
|
||||||
const addDropdownRef = useRef<HTMLDivElement>(null);
|
const addDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@ -431,16 +433,24 @@ export default function PeoplePage() {
|
|||||||
<Ghost className="h-4 w-4 text-violet-400 shrink-0" />
|
<Ghost className="h-4 w-4 text-violet-400 shrink-0" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 && (
|
{p.category && (
|
||||||
<span className="text-xs text-muted-foreground">{p.category}</span>
|
<span className="text-xs text-muted-foreground">{p.category}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Shared field key mapping (panel key -> shared_fields key)
|
// Shared field key mapping (panel key -> shared_fields key)
|
||||||
const sharedKeyMap: Record<string, string> = {
|
const sharedKeyMap: Record<string, string> = {
|
||||||
|
preferred_name: 'preferred_name',
|
||||||
email: 'email',
|
email: 'email',
|
||||||
phone: 'phone',
|
phone: 'phone',
|
||||||
mobile: 'mobile',
|
mobile: 'mobile',
|
||||||
@ -519,7 +529,17 @@ export default function PeoplePage() {
|
|||||||
<Unlink className="h-3 w-3" />
|
<Unlink className="h-3 w-3" />
|
||||||
Unlink
|
Unlink
|
||||||
</Button>
|
</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}
|
open={showConnectionSearch}
|
||||||
onOpenChange={setShowConnectionSearch}
|
onOpenChange={setShowConnectionSearch}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ConnectionSearch
|
||||||
|
open={linkPersonId !== null}
|
||||||
|
onOpenChange={(open) => { if (!open) setLinkPersonId(null); }}
|
||||||
|
personId={linkPersonId ?? undefined}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -39,9 +39,10 @@ export function useConnections() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const sendRequestMutation = useMutation({
|
const sendRequestMutation = useMutation({
|
||||||
mutationFn: async (umbralName: string) => {
|
mutationFn: async (params: { umbralName: string; personId?: number }) => {
|
||||||
const { data } = await api.post('/connections/request', {
|
const { data } = await api.post('/connections/request', {
|
||||||
umbral_name: umbralName,
|
umbral_name: params.umbralName,
|
||||||
|
...(params.personId != null && { person_id: params.personId }),
|
||||||
});
|
});
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user