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")
|
||||
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")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
},
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user