from fastapi import APIRouter, Depends, HTTPException, Path, Query from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, or_ from sqlalchemy.orm import selectinload from datetime import datetime, timezone from typing import Optional, List from app.database import get_db from app.models.person import Person from app.models.settings import Settings from app.models.user import User from app.models.user_connection import UserConnection from app.schemas.person import PersonCreate, PersonUpdate, PersonResponse from app.routers.auth import get_current_user from app.services.connection import detach_umbral_contact, resolve_shared_profile router = APIRouter() def _compute_display_name( first_name: Optional[str], last_name: Optional[str], nickname: Optional[str], name: Optional[str], ) -> str: """Denormalise a display name. Nickname wins; else 'First Last'; else legacy name; else empty.""" if nickname: return nickname full = ((first_name or '') + ' ' + (last_name or '')).strip() if full: return full # Don't fall back to stale `name` if all fields were explicitly cleared return (name or '').strip() if name else '' @router.get("/", response_model=List[PersonResponse]) async def get_people( search: Optional[str] = Query(None), category: Optional[str] = Query(None), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """Get all people with optional search and category filter.""" query = select(Person).where(Person.user_id == current_user.id) if search: term = f"%{search}%" query = query.where( or_( Person.name.ilike(term), Person.first_name.ilike(term), Person.last_name.ilike(term), Person.nickname.ilike(term), Person.email.ilike(term), Person.company.ilike(term), ) ) if category: query = query.where(Person.category == category) query = query.order_by(Person.name.asc()) result = await db.execute(query) people = result.scalars().all() # Batch-load shared profiles for umbral contacts umbral_people = [p for p in people if p.linked_user_id is not None] if umbral_people: linked_user_ids = [p.linked_user_id for p in umbral_people] # Batch fetch users and settings users_result = await db.execute( select(User).where(User.id.in_(linked_user_ids)) ) users_by_id = {u.id: u for u in users_result.scalars().all()} settings_result = await db.execute( select(Settings).where(Settings.user_id.in_(linked_user_ids)) ) settings_by_user = {s.user_id: s for s in settings_result.scalars().all()} # Batch fetch connection overrides conns_result = await db.execute( select(UserConnection).where( UserConnection.user_id == current_user.id, UserConnection.connected_user_id.in_(linked_user_ids), ) ) overrides_by_user = { c.connected_user_id: c.sharing_overrides for c in conns_result.scalars().all() } # Build shared profiles shared_profiles: dict[int, dict] = {} for uid in linked_user_ids: user = users_by_id.get(uid) user_settings = settings_by_user.get(uid) if user and user_settings: 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 = [] for p in people: resp = PersonResponse.model_validate(p) if p.linked_user_id and p.linked_user_id in shared_profiles: resp.shared_fields = shared_profiles[p.linked_user_id] responses.append(resp) return responses return people @router.post("/", response_model=PersonResponse, status_code=201) async def create_person( person: PersonCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """Create a new person with denormalised display name.""" data = person.model_dump() # Auto-split legacy name into first/last if only name is provided if data.get('name') and not data.get('first_name') and not data.get('last_name') and not data.get('nickname'): parts = data['name'].split(' ', 1) data['first_name'] = parts[0] data['last_name'] = parts[1] if len(parts) > 1 else None new_person = Person(**data, user_id=current_user.id) new_person.name = _compute_display_name( new_person.first_name, new_person.last_name, new_person.nickname, new_person.name, ) db.add(new_person) await db.commit() await db.refresh(new_person) return new_person @router.get("/{person_id}", response_model=PersonResponse) async def get_person( person_id: int = Path(ge=1, le=2147483647), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """Get a specific person by ID.""" result = await db.execute( select(Person).where(Person.id == person_id, Person.user_id == current_user.id) ) person = result.scalar_one_or_none() if not person: raise HTTPException(status_code=404, detail="Person not found") resp = PersonResponse.model_validate(person) if person.linked_user_id: linked_user_result = await db.execute( select(User).where(User.id == person.linked_user_id) ) linked_user = linked_user_result.scalar_one_or_none() linked_settings_result = await db.execute( select(Settings).where(Settings.user_id == person.linked_user_id) ) linked_settings = linked_settings_result.scalar_one_or_none() conn_result = await db.execute( select(UserConnection).where( UserConnection.user_id == current_user.id, UserConnection.connected_user_id == person.linked_user_id, ) ) conn = conn_result.scalar_one_or_none() if linked_user and linked_settings: 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 @router.put("/{person_id}", response_model=PersonResponse) async def update_person( person_id: int = Path(ge=1, le=2147483647), person_update: PersonUpdate = ..., db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """Update a person and refresh the denormalised display name.""" result = await db.execute( select(Person).where(Person.id == person_id, Person.user_id == current_user.id) ) person = result.scalar_one_or_none() if not person: raise HTTPException(status_code=404, detail="Person not found") update_data = person_update.model_dump(exclude_unset=True) for key, value in update_data.items(): setattr(person, key, value) # Recompute display name after applying updates person.name = _compute_display_name( person.first_name, person.last_name, person.nickname, person.name, ) # Guarantee timestamp refresh regardless of DB driver behaviour person.updated_at = datetime.now(timezone.utc).replace(tzinfo=None) await db.commit() await db.refresh(person) return person async def _sever_connection(db: AsyncSession, current_user: User, person: Person) -> None: """Remove bidirectional UserConnection rows and detach the counterpart's Person.""" if not person.linked_user_id: return counterpart_id = person.linked_user_id # Find our connection conn_result = await db.execute( select(UserConnection).where( UserConnection.user_id == current_user.id, UserConnection.connected_user_id == counterpart_id, ) ) our_conn = conn_result.scalar_one_or_none() # Find reverse connection reverse_result = await db.execute( select(UserConnection).where( UserConnection.user_id == counterpart_id, UserConnection.connected_user_id == current_user.id, ) ) reverse_conn = reverse_result.scalar_one_or_none() # Detach the counterpart's Person record (if it exists) if reverse_conn and reverse_conn.person_id: cp_result = await db.execute( select(Person).where(Person.id == reverse_conn.person_id) ) cp_person = cp_result.scalar_one_or_none() if cp_person: await detach_umbral_contact(cp_person) # Delete both connection rows if our_conn: await db.delete(our_conn) if reverse_conn: await db.delete(reverse_conn) @router.put("/{person_id}/unlink", response_model=PersonResponse) async def unlink_person( person_id: int = Path(ge=1, le=2147483647), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """Unlink an umbral contact — convert to standard contact and sever the connection.""" result = await db.execute( select(Person).where(Person.id == person_id, Person.user_id == current_user.id) ) person = result.scalar_one_or_none() if not person: raise HTTPException(status_code=404, detail="Person not found") if not person.is_umbral_contact: raise HTTPException(status_code=400, detail="Person is not an umbral contact") await _sever_connection(db, current_user, person) await detach_umbral_contact(person) await db.commit() await db.refresh(person) return person @router.delete("/{person_id}", status_code=204) async def delete_person( person_id: int = Path(ge=1, le=2147483647), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """Delete a person. If umbral contact, also severs the bidirectional connection.""" result = await db.execute( select(Person).where(Person.id == person_id, Person.user_id == current_user.id) ) person = result.scalar_one_or_none() if not person: raise HTTPException(status_code=404, detail="Person not found") if person.is_umbral_contact: await _sever_connection(db, current_user, person) await db.delete(person) await db.commit() return None