diff --git a/backend/alembic/versions/046_add_person_id_to_connection_requests.py b/backend/alembic/versions/046_add_person_id_to_connection_requests.py
new file mode 100644
index 0000000..5dcca47
--- /dev/null
+++ b/backend/alembic/versions/046_add_person_id_to_connection_requests.py
@@ -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")
diff --git a/backend/app/models/connection_request.py b/backend/app/models/connection_request.py
index 6a851f1..78ae150 100644
--- a/backend/app/models/connection_request.py
+++ b/backend/app/models/connection_request.py
@@ -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")
diff --git a/backend/app/routers/connections.py b/backend/app/routers/connections.py
index a8fe237..f63107e 100644
--- a/backend/app/routers/connections.py
+++ b/backend/app/routers/connections.py
@@ -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
)
- 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)
+
+ # 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_sender)
+
await db.flush() # populate person IDs
# Create bidirectional connections
diff --git a/backend/app/routers/people.py b/backend/app/routers/people.py
index 622bf93..5724f9c 100644
--- a/backend/app/routers/people.py
+++ b/backend/app/routers/people.py
@@ -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
diff --git a/backend/app/schemas/connection.py b/backend/app/schemas/connection.py
index d293290..7489baf 100644
--- a/backend/app/schemas/connection.py
+++ b/backend/app/schemas/connection.py
@@ -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
diff --git a/frontend/src/components/connections/ConnectionSearch.tsx b/frontend/src/components/connections/ConnectionSearch.tsx
index e7e2ecb..9dcf54f 100644
--- a/frontend/src/components/connections/ConnectionSearch.tsx
+++ b/frontend/src/components/connections/ConnectionSearch.tsx
@@ -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