diff --git a/backend/alembic/versions/039_add_umbral_name_to_users.py b/backend/alembic/versions/039_add_umbral_name_to_users.py new file mode 100644 index 0000000..8c02992 --- /dev/null +++ b/backend/alembic/versions/039_add_umbral_name_to_users.py @@ -0,0 +1,37 @@ +"""Add umbral_name to users table. + +3-step migration: add nullable → backfill from username → alter to NOT NULL. +Backfill uses username || '_' || id as fallback if uniqueness conflicts arise. + +Revision ID: 039 +Revises: 038 +""" +from alembic import op +import sqlalchemy as sa + +revision = "039" +down_revision = "038" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Step 1: Add nullable column + op.add_column("users", sa.Column("umbral_name", sa.String(50), nullable=True)) + + # Step 2: Backfill from username (handles uniqueness conflicts with fallback) + op.execute("UPDATE users SET umbral_name = username") + # Fix any remaining NULLs (shouldn't happen, but defensive) + op.execute( + "UPDATE users SET umbral_name = username || '_' || id " + "WHERE umbral_name IS NULL" + ) + + # Step 3: Alter to NOT NULL and add unique index + op.alter_column("users", "umbral_name", nullable=False) + op.create_index("ix_users_umbral_name", "users", ["umbral_name"], unique=True) + + +def downgrade() -> None: + op.drop_index("ix_users_umbral_name", table_name="users") + op.drop_column("users", "umbral_name") diff --git a/backend/alembic/versions/040_expand_settings_profile_social.py b/backend/alembic/versions/040_expand_settings_profile_social.py new file mode 100644 index 0000000..047c8c0 --- /dev/null +++ b/backend/alembic/versions/040_expand_settings_profile_social.py @@ -0,0 +1,85 @@ +"""Expand settings with profile, social, and sharing fields. + +Revision ID: 040 +Revises: 039 +""" +from alembic import op +import sqlalchemy as sa + +revision = "040" +down_revision = "039" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Profile fields + op.add_column("settings", sa.Column("phone", sa.String(50), nullable=True)) + op.add_column("settings", sa.Column("mobile", sa.String(50), nullable=True)) + op.add_column("settings", sa.Column("address", sa.Text, nullable=True)) + op.add_column("settings", sa.Column("company", sa.String(255), nullable=True)) + op.add_column("settings", sa.Column("job_title", sa.String(255), nullable=True)) + + # Social toggle + op.add_column( + "settings", + sa.Column("accept_connections", sa.Boolean, nullable=False, server_default="false"), + ) + + # Sharing defaults + op.add_column( + "settings", + sa.Column("share_preferred_name", sa.Boolean, nullable=False, server_default="true"), + ) + op.add_column( + "settings", + sa.Column("share_email", sa.Boolean, nullable=False, server_default="false"), + ) + op.add_column( + "settings", + sa.Column("share_phone", sa.Boolean, nullable=False, server_default="false"), + ) + op.add_column( + "settings", + sa.Column("share_mobile", sa.Boolean, nullable=False, server_default="false"), + ) + op.add_column( + "settings", + sa.Column("share_birthday", sa.Boolean, nullable=False, server_default="false"), + ) + op.add_column( + "settings", + sa.Column("share_address", sa.Boolean, nullable=False, server_default="false"), + ) + op.add_column( + "settings", + sa.Column("share_company", sa.Boolean, nullable=False, server_default="false"), + ) + op.add_column( + "settings", + sa.Column("share_job_title", sa.Boolean, nullable=False, server_default="false"), + ) + + # ntfy connection notifications toggle (gates push only, not in-app) + op.add_column( + "settings", + sa.Column("ntfy_connections_enabled", sa.Boolean, nullable=False, server_default="true"), + ) + + +def downgrade() -> None: + op.drop_column("settings", "ntfy_connections_enabled") + op.drop_column("settings", "share_job_title") + op.drop_column("settings", "share_company") + op.drop_column("settings", "share_address") + op.drop_column("settings", "share_birthday") + op.drop_column("settings", "share_mobile") + op.drop_column("settings", "share_phone") + op.drop_column("settings", "share_email") + op.drop_column("settings", "share_preferred_name") + op.drop_column("settings", "accept_connections") + op.drop_column("settings", "job_title") + op.drop_column("settings", "company") + op.drop_column("settings", "address") + op.drop_column("settings", "mobile") + op.drop_column("settings", "phone") diff --git a/backend/alembic/versions/041_create_notifications_table.py b/backend/alembic/versions/041_create_notifications_table.py new file mode 100644 index 0000000..466577f --- /dev/null +++ b/backend/alembic/versions/041_create_notifications_table.py @@ -0,0 +1,57 @@ +"""Create notifications table for in-app notification centre. + +Revision ID: 041 +Revises: 040 +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import JSONB + +revision = "041" +down_revision = "040" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "notifications", + sa.Column("id", sa.Integer, primary_key=True, index=True), + sa.Column( + "user_id", + sa.Integer, + sa.ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("type", sa.String(50), nullable=False), + sa.Column("title", sa.String(255), nullable=True), + sa.Column("message", sa.Text, nullable=True), + sa.Column("data", JSONB, nullable=True), + sa.Column("source_type", sa.String(50), nullable=True), + sa.Column("source_id", sa.Integer, nullable=True), + sa.Column("is_read", sa.Boolean, nullable=False, server_default="false"), + sa.Column( + "created_at", + sa.DateTime, + nullable=False, + server_default=sa.func.now(), + ), + ) + + # Fast unread count query + op.execute( + 'CREATE INDEX "ix_notifications_user_unread" ON notifications (user_id, is_read) ' + "WHERE is_read = false" + ) + # Paginated listing + op.create_index( + "ix_notifications_user_created", + "notifications", + ["user_id", sa.text("created_at DESC")], + ) + + +def downgrade() -> None: + op.drop_index("ix_notifications_user_created", table_name="notifications") + op.execute('DROP INDEX IF EXISTS "ix_notifications_user_unread"') + op.drop_table("notifications") diff --git a/backend/alembic/versions/042_create_connection_tables.py b/backend/alembic/versions/042_create_connection_tables.py new file mode 100644 index 0000000..b10d4ce --- /dev/null +++ b/backend/alembic/versions/042_create_connection_tables.py @@ -0,0 +1,109 @@ +"""Create connection_requests and user_connections tables. + +Revision ID: 042 +Revises: 041 +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import JSONB + +revision = "042" +down_revision = "041" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ── connection_requests ────────────────────────────────────────── + op.create_table( + "connection_requests", + sa.Column("id", sa.Integer, primary_key=True, index=True), + sa.Column( + "sender_id", + sa.Integer, + sa.ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "receiver_id", + sa.Integer, + sa.ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "status", + sa.String(20), + nullable=False, + server_default="pending", + ), + sa.Column( + "created_at", + sa.DateTime, + nullable=False, + server_default=sa.func.now(), + ), + sa.Column("resolved_at", sa.DateTime, nullable=True), + sa.CheckConstraint( + "status IN ('pending', 'accepted', 'rejected', 'cancelled')", + name="ck_connection_requests_status", + ), + ) + + # Only one pending request per sender→receiver pair + op.execute( + 'CREATE UNIQUE INDEX "ix_connection_requests_pending" ' + "ON connection_requests (sender_id, receiver_id) " + "WHERE status = 'pending'" + ) + # Incoming request listing + op.create_index( + "ix_connection_requests_receiver_status", + "connection_requests", + ["receiver_id", "status"], + ) + # Outgoing request listing + op.create_index( + "ix_connection_requests_sender_status", + "connection_requests", + ["sender_id", "status"], + ) + + # ── user_connections ───────────────────────────────────────────── + op.create_table( + "user_connections", + sa.Column("id", sa.Integer, primary_key=True, index=True), + sa.Column( + "user_id", + sa.Integer, + sa.ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "connected_user_id", + sa.Integer, + sa.ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "person_id", + sa.Integer, + sa.ForeignKey("people.id", ondelete="SET NULL"), + nullable=True, + ), + sa.Column("sharing_overrides", JSONB, nullable=True), + sa.Column( + "created_at", + sa.DateTime, + nullable=False, + server_default=sa.func.now(), + ), + sa.UniqueConstraint("user_id", "connected_user_id", name="uq_user_connections"), + ) + + +def downgrade() -> None: + op.drop_table("user_connections") + op.drop_index("ix_connection_requests_sender_status", table_name="connection_requests") + op.drop_index("ix_connection_requests_receiver_status", table_name="connection_requests") + op.execute('DROP INDEX IF EXISTS "ix_connection_requests_pending"') + op.drop_table("connection_requests") diff --git a/backend/alembic/versions/043_add_linked_fields_to_people.py b/backend/alembic/versions/043_add_linked_fields_to_people.py new file mode 100644 index 0000000..364aaf4 --- /dev/null +++ b/backend/alembic/versions/043_add_linked_fields_to_people.py @@ -0,0 +1,44 @@ +"""Add linked_user_id and is_umbral_contact to people table. + +Revision ID: 043 +Revises: 042 +""" +from alembic import op +import sqlalchemy as sa + +revision = "043" +down_revision = "042" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "people", + sa.Column( + "linked_user_id", + sa.Integer, + sa.ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + ), + ) + op.add_column( + "people", + sa.Column( + "is_umbral_contact", + sa.Boolean, + nullable=False, + server_default="false", + ), + ) + # Fast lookup of umbral contacts by owner + op.execute( + 'CREATE INDEX "ix_people_linked_user" ON people (user_id, linked_user_id) ' + "WHERE linked_user_id IS NOT NULL" + ) + + +def downgrade() -> None: + op.execute('DROP INDEX IF EXISTS "ix_people_linked_user"') + op.drop_column("people", "is_umbral_contact") + op.drop_column("people", "linked_user_id") diff --git a/backend/app/jobs/notifications.py b/backend/app/jobs/notifications.py index be463f4..2aa50a6 100644 --- a/backend/app/jobs/notifications.py +++ b/backend/app/jobs/notifications.py @@ -17,6 +17,7 @@ from sqlalchemy.orm import selectinload from app.database import AsyncSessionLocal from app.models.settings import Settings +from app.models.notification import Notification as AppNotification from app.models.reminder import Reminder from app.models.calendar_event import CalendarEvent from app.models.calendar import Calendar @@ -267,6 +268,13 @@ async def _purge_expired_sessions(db: AsyncSession) -> None: await db.commit() +async def _purge_old_notifications(db: AsyncSession) -> None: + """Remove in-app notifications older than 90 days.""" + cutoff = datetime.now() - timedelta(days=90) + await db.execute(delete(AppNotification).where(AppNotification.created_at < cutoff)) + await db.commit() + + # ── Entry point ─────────────────────────────────────────────────────────────── async def run_notification_dispatch() -> None: @@ -308,6 +316,7 @@ async def run_notification_dispatch() -> None: async with AsyncSessionLocal() as db: await _purge_totp_usage(db) await _purge_expired_sessions(db) + await _purge_old_notifications(db) except Exception: # Broad catch: job failure must never crash the scheduler or the app diff --git a/backend/app/main.py b/backend/app/main.py index 4f1ddcd..768e19c 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -7,7 +7,7 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler from app.config import settings from app.database import engine from app.routers import auth, todos, events, calendars, reminders, projects, people, locations, settings as settings_router, dashboard, weather, event_templates -from app.routers import totp, admin +from app.routers import totp, admin, notifications as notifications_router, connections as connections_router from app.jobs.notifications import run_notification_dispatch # Import models so Alembic's autogenerate can discover them @@ -17,6 +17,9 @@ from app.models import totp_usage as _totp_usage_model # noqa: F401 from app.models import backup_code as _backup_code_model # noqa: F401 from app.models import system_config as _system_config_model # noqa: F401 from app.models import audit_log as _audit_log_model # noqa: F401 +from app.models import notification as _notification_model # noqa: F401 +from app.models import connection_request as _connection_request_model # noqa: F401 +from app.models import user_connection as _user_connection_model # noqa: F401 # --------------------------------------------------------------------------- @@ -129,6 +132,8 @@ app.include_router(weather.router, prefix="/api/weather", tags=["Weather"]) app.include_router(event_templates.router, prefix="/api/event-templates", tags=["Event Templates"]) app.include_router(totp.router, prefix="/api/auth", tags=["TOTP MFA"]) app.include_router(admin.router, prefix="/api/admin", tags=["Admin"]) +app.include_router(notifications_router.router, prefix="/api/notifications", tags=["Notifications"]) +app.include_router(connections_router.router, prefix="/api/connections", tags=["Connections"]) @app.get("/") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index d9d7a34..0b96dc8 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -15,6 +15,9 @@ from app.models.totp_usage import TOTPUsage from app.models.backup_code import BackupCode from app.models.system_config import SystemConfig from app.models.audit_log import AuditLog +from app.models.notification import Notification +from app.models.connection_request import ConnectionRequest +from app.models.user_connection import UserConnection __all__ = [ "Settings", @@ -34,4 +37,7 @@ __all__ = [ "BackupCode", "SystemConfig", "AuditLog", + "Notification", + "ConnectionRequest", + "UserConnection", ] diff --git a/backend/app/models/connection_request.py b/backend/app/models/connection_request.py new file mode 100644 index 0000000..6a851f1 --- /dev/null +++ b/backend/app/models/connection_request.py @@ -0,0 +1,33 @@ +from sqlalchemy import String, Integer, ForeignKey, CheckConstraint, func +from sqlalchemy.orm import Mapped, mapped_column, relationship +from datetime import datetime +from typing import Optional, TYPE_CHECKING +from app.database import Base + +if TYPE_CHECKING: + from app.models.user import User + + +class ConnectionRequest(Base): + __tablename__ = "connection_requests" + __table_args__ = ( + CheckConstraint( + "status IN ('pending', 'accepted', 'rejected', 'cancelled')", + name="ck_connection_requests_status", + ), + ) + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + sender_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ) + receiver_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ) + 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) + + # Relationships with explicit foreign_keys to disambiguate + sender: Mapped["User"] = relationship(foreign_keys=[sender_id], lazy="selectin") + receiver: Mapped["User"] = relationship(foreign_keys=[receiver_id], lazy="selectin") diff --git a/backend/app/models/notification.py b/backend/app/models/notification.py new file mode 100644 index 0000000..f98ae7d --- /dev/null +++ b/backend/app/models/notification.py @@ -0,0 +1,23 @@ +from sqlalchemy import String, Text, Integer, Boolean, ForeignKey, func +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column +from datetime import datetime +from typing import Optional +from app.database import Base + + +class Notification(Base): + __tablename__ = "notifications" + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ) + type: Mapped[str] = mapped_column(String(50), nullable=False) + title: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) + message: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + data: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True) + source_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) + source_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + is_read: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") + created_at: Mapped[datetime] = mapped_column(default=func.now(), server_default=func.now()) diff --git a/backend/app/models/person.py b/backend/app/models/person.py index 66c3acd..1c02e43 100644 --- a/backend/app/models/person.py +++ b/backend/app/models/person.py @@ -27,6 +27,11 @@ class Person(Base): job_title: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) mobile: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) category: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) + # Umbral contact link + linked_user_id: Mapped[Optional[int]] = mapped_column( + Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True + ) + is_umbral_contact: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default=text('false')) created_at: Mapped[datetime] = mapped_column(default=func.now()) updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now()) diff --git a/backend/app/models/settings.py b/backend/app/models/settings.py index cf5ea87..dac766a 100644 --- a/backend/app/models/settings.py +++ b/backend/app/models/settings.py @@ -1,4 +1,4 @@ -from sqlalchemy import String, Integer, Float, Boolean, ForeignKey, func +from sqlalchemy import String, Text, Integer, Float, Boolean, ForeignKey, func from sqlalchemy.orm import Mapped, mapped_column from datetime import datetime from typing import Optional @@ -46,6 +46,29 @@ class Settings(Base): auto_lock_enabled: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") auto_lock_minutes: Mapped[int] = mapped_column(Integer, default=5, server_default="5") + # Profile fields (shareable with connections) + phone: Mapped[Optional[str]] = mapped_column(String(50), nullable=True, default=None) + mobile: Mapped[Optional[str]] = mapped_column(String(50), nullable=True, default=None) + address: Mapped[Optional[str]] = mapped_column(Text, nullable=True, default=None) + company: Mapped[Optional[str]] = mapped_column(String(255), nullable=True, default=None) + job_title: Mapped[Optional[str]] = mapped_column(String(255), nullable=True, default=None) + + # Social settings + accept_connections: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") + + # Sharing defaults (what fields are shared with connections by default) + share_preferred_name: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true") + share_email: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") + share_phone: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") + share_mobile: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") + share_birthday: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") + share_address: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") + share_company: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") + share_job_title: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") + + # ntfy connection notification toggle (gates push only, not in-app) + ntfy_connections_enabled: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true") + @property def ntfy_has_token(self) -> bool: """Derived field for SettingsResponse — True when an auth token is stored.""" diff --git a/backend/app/models/user.py b/backend/app/models/user.py index cc9a181..64e36e0 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -9,6 +9,7 @@ class User(Base): id: Mapped[int] = mapped_column(primary_key=True, index=True) username: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True) + umbral_name: Mapped[str] = mapped_column(String(50), unique=True, index=True) email: Mapped[str | None] = mapped_column(String(255), nullable=True) first_name: Mapped[str | None] = mapped_column(String(100), nullable=True) last_name: Mapped[str | None] = mapped_column(String(100), nullable=True) diff --git a/backend/app/models/user_connection.py b/backend/app/models/user_connection.py new file mode 100644 index 0000000..05f97a2 --- /dev/null +++ b/backend/app/models/user_connection.py @@ -0,0 +1,31 @@ +from sqlalchemy import Integer, ForeignKey, func +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column, relationship +from datetime import datetime +from typing import Optional, TYPE_CHECKING +from app.database import Base + +if TYPE_CHECKING: + from app.models.user import User + from app.models.person import Person + + +class UserConnection(Base): + __tablename__ = "user_connections" + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ) + connected_user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ) + person_id: Mapped[Optional[int]] = mapped_column( + Integer, ForeignKey("people.id", ondelete="SET NULL"), nullable=True + ) + sharing_overrides: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True) + created_at: Mapped[datetime] = mapped_column(default=func.now(), server_default=func.now()) + + # Relationships + connected_user: Mapped["User"] = relationship(foreign_keys=[connected_user_id], lazy="selectin") + person: Mapped[Optional["Person"]] = relationship(foreign_keys=[person_id], lazy="selectin") diff --git a/backend/app/routers/connections.py b/backend/app/routers/connections.py new file mode 100644 index 0000000..ad39a4a --- /dev/null +++ b/backend/app/routers/connections.py @@ -0,0 +1,642 @@ +""" +Connection router — search, request, respond, manage connections. + +Security: +- Timing-safe search (50ms sleep floor) +- Per-receiver pending request cap (5 within 10 minutes) +- Atomic accept via UPDATE...WHERE status='pending' RETURNING * +- All endpoints scoped by current_user.id +- Audit logging for all connection events +""" +import asyncio +from datetime import datetime, timedelta, timezone + +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Path, Query, Request +from sqlalchemy import select, func, and_, update +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.database import get_db +from app.models.connection_request import ConnectionRequest +from app.models.notification import Notification +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.routers.auth import get_current_user +from app.schemas.connection import ( + ConnectionRequestResponse, + ConnectionResponse, + RespondRequest, + SendConnectionRequest, + SharingOverrideUpdate, + UmbralSearchRequest, + UmbralSearchResponse, +) +from app.services.audit import get_client_ip, log_audit_event +from app.services.connection import ( + SHAREABLE_FIELDS, + create_person_from_connection, + detach_umbral_contact, + resolve_shared_profile, + send_connection_ntfy, +) +from app.services.notification import create_notification + +router = APIRouter() + + +# ── Helpers ────────────────────────────────────────────────────────── + +async def _get_settings_for_user(db: AsyncSession, user_id: int) -> Settings | None: + result = await db.execute(select(Settings).where(Settings.user_id == user_id)) + return result.scalar_one_or_none() + + +def _build_request_response( + req: ConnectionRequest, + sender: User, + sender_settings: Settings | None, + receiver: User, + receiver_settings: Settings | None, +) -> ConnectionRequestResponse: + return ConnectionRequestResponse( + id=req.id, + sender_umbral_name=sender.umbral_name, + sender_preferred_name=sender_settings.preferred_name if sender_settings else None, + receiver_umbral_name=receiver.umbral_name, + receiver_preferred_name=receiver_settings.preferred_name if receiver_settings else None, + status=req.status, + created_at=req.created_at, + ) + + +# ── POST /search ──────────────────────────────────────────────────── + +@router.post("/search", response_model=UmbralSearchResponse) +async def search_user( + body: UmbralSearchRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """ + Timing-safe user search. Always queries by umbral_name alone, + then checks accept_connections + is_active in Python. + Generic "not found" for non-existent, opted-out, AND inactive users. + 50ms sleep floor to eliminate timing side-channel. + """ + # Always sleep to prevent timing attacks + await asyncio.sleep(0.05) + + # Don't find yourself + if body.umbral_name == current_user.umbral_name: + return UmbralSearchResponse(found=False) + + result = await db.execute( + select(User).where(User.umbral_name == body.umbral_name) + ) + target = result.scalar_one_or_none() + + if not target or not target.is_active: + return UmbralSearchResponse(found=False) + + # Check if they accept connections + target_settings = await _get_settings_for_user(db, target.id) + if not target_settings or not target_settings.accept_connections: + return UmbralSearchResponse(found=False) + + return UmbralSearchResponse(found=True) + + +# ── POST /request ─────────────────────────────────────────────────── + +@router.post("/request", response_model=ConnectionRequestResponse, status_code=201) +async def send_connection_request( + body: SendConnectionRequest, + request: Request, + background_tasks: BackgroundTasks, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Send a connection request to another user.""" + # Resolve target + result = await db.execute( + select(User).where(User.umbral_name == body.umbral_name) + ) + target = result.scalar_one_or_none() + + if not target or not target.is_active: + raise HTTPException(status_code=404, detail="User not found") + + # Self-request guard + if target.id == current_user.id: + raise HTTPException(status_code=400, detail="Cannot send a connection request to yourself") + + # Check accept_connections + target_settings = await _get_settings_for_user(db, target.id) + if not target_settings or not target_settings.accept_connections: + raise HTTPException(status_code=404, detail="User not found") + + # Check existing connection + existing_conn = await db.execute( + select(UserConnection).where( + UserConnection.user_id == current_user.id, + UserConnection.connected_user_id == target.id, + ) + ) + if existing_conn.scalar_one_or_none(): + raise HTTPException(status_code=409, detail="Already connected") + + # Check pending request in either direction + existing_req = await db.execute( + select(ConnectionRequest).where( + and_( + ConnectionRequest.status == "pending", + ( + (ConnectionRequest.sender_id == current_user.id) & (ConnectionRequest.receiver_id == target.id) + ) | ( + (ConnectionRequest.sender_id == target.id) & (ConnectionRequest.receiver_id == current_user.id) + ), + ) + ) + ) + if existing_req.scalar_one_or_none(): + raise HTTPException(status_code=409, detail="A pending request already exists") + + # Per-receiver cap: max 5 pending requests within 10 minutes + ten_min_ago = datetime.now() - timedelta(minutes=10) + pending_count = await db.scalar( + select(func.count()) + .select_from(ConnectionRequest) + .where( + ConnectionRequest.receiver_id == target.id, + ConnectionRequest.status == "pending", + ConnectionRequest.created_at >= ten_min_ago, + ) + ) or 0 + if pending_count >= 5: + raise HTTPException(status_code=429, detail="Too many pending requests for this user") + + # Create the request + conn_request = ConnectionRequest( + sender_id=current_user.id, + receiver_id=target.id, + ) + db.add(conn_request) + + # Create in-app notification for receiver + sender_settings = await _get_settings_for_user(db, current_user.id) + sender_display = (sender_settings.preferred_name if sender_settings else None) or current_user.umbral_name + + await create_notification( + db, + user_id=target.id, + type="connection_request", + title="New Connection Request", + message=f"{sender_display} wants to connect with you", + data={"sender_umbral_name": current_user.umbral_name}, + source_type="connection_request", + source_id=None, # Will be set after flush + ) + + await log_audit_event( + db, + action="connection.request_sent", + actor_id=current_user.id, + target_id=target.id, + detail={"receiver_umbral_name": target.umbral_name}, + ip=get_client_ip(request), + ) + + await db.commit() + await db.refresh(conn_request) + + # ntfy push in background (non-blocking) + background_tasks.add_task( + send_connection_ntfy, + target_settings, + sender_display, + "request_received", + ) + + return _build_request_response(conn_request, current_user, sender_settings, target, target_settings) + + +# ── GET /requests/incoming ────────────────────────────────────────── + +@router.get("/requests/incoming", response_model=list[ConnectionRequestResponse]) +async def get_incoming_requests( + page: int = Query(1, ge=1), + per_page: int = Query(20, ge=1, le=100), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """List pending connection requests received by the current user.""" + offset = (page - 1) * per_page + result = await db.execute( + select(ConnectionRequest) + .where( + ConnectionRequest.receiver_id == current_user.id, + ConnectionRequest.status == "pending", + ) + .options(selectinload(ConnectionRequest.sender)) + .order_by(ConnectionRequest.created_at.desc()) + .offset(offset) + .limit(per_page) + ) + requests = result.scalars().all() + + responses = [] + for req in requests: + sender_settings = await _get_settings_for_user(db, req.sender_id) + receiver_settings = await _get_settings_for_user(db, current_user.id) + responses.append(_build_request_response(req, req.sender, sender_settings, current_user, receiver_settings)) + + return responses + + +# ── GET /requests/outgoing ────────────────────────────────────────── + +@router.get("/requests/outgoing", response_model=list[ConnectionRequestResponse]) +async def get_outgoing_requests( + page: int = Query(1, ge=1), + per_page: int = Query(20, ge=1, le=100), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """List pending connection requests sent by the current user.""" + offset = (page - 1) * per_page + result = await db.execute( + select(ConnectionRequest) + .where( + ConnectionRequest.sender_id == current_user.id, + ConnectionRequest.status == "pending", + ) + .options(selectinload(ConnectionRequest.receiver)) + .order_by(ConnectionRequest.created_at.desc()) + .offset(offset) + .limit(per_page) + ) + requests = result.scalars().all() + + responses = [] + for req in requests: + sender_settings = await _get_settings_for_user(db, current_user.id) + receiver_settings = await _get_settings_for_user(db, req.receiver_id) + responses.append(_build_request_response(req, current_user, sender_settings, req.receiver, receiver_settings)) + + return responses + + +# ── PUT /requests/{id}/respond ────────────────────────────────────── + +@router.put("/requests/{request_id}/respond") +async def respond_to_request( + body: RespondRequest, + request: Request, + background_tasks: BackgroundTasks, + request_id: int = Path(ge=1, le=2147483647), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Accept or reject a connection request. Atomic via UPDATE...WHERE status='pending'.""" + now = datetime.now() + + # Atomic update — only succeeds if status is still 'pending' and receiver is current user + result = await db.execute( + update(ConnectionRequest) + .where( + ConnectionRequest.id == request_id, + ConnectionRequest.receiver_id == current_user.id, + ConnectionRequest.status == "pending", + ) + .values(status=body.action + "ed", resolved_at=now) + .returning(ConnectionRequest.id, ConnectionRequest.sender_id, ConnectionRequest.receiver_id) + ) + row = result.first() + if not row: + raise HTTPException(status_code=409, detail="Request not found or already resolved") + + sender_id = row.sender_id + + if body.action == "accept": + # Verify sender is still active + sender_result = await db.execute(select(User).where(User.id == sender_id)) + sender = sender_result.scalar_one_or_none() + if not sender or not sender.is_active: + # Revert to rejected + await db.execute( + update(ConnectionRequest) + .where(ConnectionRequest.id == request_id) + .values(status="rejected") + ) + await db.commit() + raise HTTPException(status_code=409, detail="Sender account is no longer active") + + # Get settings for both users + sender_settings = await _get_settings_for_user(db, sender_id) + receiver_settings = await _get_settings_for_user(db, current_user.id) + + # Resolve shared profiles for both directions + sender_shared = resolve_shared_profile(sender, sender_settings, None) if sender_settings else {} + receiver_shared = resolve_shared_profile(current_user, receiver_settings, None) if receiver_settings else {} + + # Create Person records for both users + 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) + await db.flush() # populate person IDs + + # Create bidirectional connections + conn_a = UserConnection( + user_id=current_user.id, + connected_user_id=sender_id, + person_id=person_for_receiver.id, + ) + conn_b = UserConnection( + user_id=sender_id, + connected_user_id=current_user.id, + person_id=person_for_sender.id, + ) + db.add(conn_a) + db.add(conn_b) + + # Notification to sender + receiver_display = (receiver_settings.preferred_name if receiver_settings else None) or current_user.umbral_name + await create_notification( + db, + user_id=sender_id, + type="connection_accepted", + title="Connection Accepted", + message=f"{receiver_display} accepted your connection request", + data={"connected_umbral_name": current_user.umbral_name}, + source_type="user_connection", + source_id=None, + ) + + await log_audit_event( + db, + action="connection.accepted", + actor_id=current_user.id, + target_id=sender_id, + detail={"request_id": request_id}, + ip=get_client_ip(request), + ) + + await db.commit() + + # ntfy push in background + if sender_settings: + background_tasks.add_task( + send_connection_ntfy, + sender_settings, + receiver_display, + "request_accepted", + ) + + return {"message": "Connection accepted", "connection_id": conn_a.id} + + else: + # Reject — only create notification for receiver (not sender per plan) + await log_audit_event( + db, + action="connection.rejected", + actor_id=current_user.id, + target_id=sender_id, + detail={"request_id": request_id}, + ip=get_client_ip(request), + ) + await db.commit() + return {"message": "Connection request rejected"} + + +# ── GET / ─────────────────────────────────────────────────────────── + +@router.get("/", response_model=list[ConnectionResponse]) +async def list_connections( + page: int = Query(1, ge=1), + per_page: int = Query(50, ge=1, le=100), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """List all connections for the current user.""" + offset = (page - 1) * per_page + result = await db.execute( + select(UserConnection) + .where(UserConnection.user_id == current_user.id) + .options(selectinload(UserConnection.connected_user)) + .order_by(UserConnection.created_at.desc()) + .offset(offset) + .limit(per_page) + ) + connections = result.scalars().all() + + responses = [] + for conn in connections: + conn_settings = await _get_settings_for_user(db, conn.connected_user_id) + responses.append(ConnectionResponse( + id=conn.id, + connected_user_id=conn.connected_user_id, + connected_umbral_name=conn.connected_user.umbral_name, + connected_preferred_name=conn_settings.preferred_name if conn_settings else None, + person_id=conn.person_id, + created_at=conn.created_at, + )) + + return responses + + +# ── GET /{id} ─────────────────────────────────────────────────────── + +@router.get("/{connection_id}", response_model=ConnectionResponse) +async def get_connection( + connection_id: int = Path(ge=1, le=2147483647), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Get a single connection detail.""" + result = await db.execute( + select(UserConnection) + .where( + UserConnection.id == connection_id, + UserConnection.user_id == current_user.id, + ) + .options(selectinload(UserConnection.connected_user)) + ) + conn = result.scalar_one_or_none() + if not conn: + raise HTTPException(status_code=404, detail="Connection not found") + + conn_settings = await _get_settings_for_user(db, conn.connected_user_id) + return ConnectionResponse( + id=conn.id, + connected_user_id=conn.connected_user_id, + connected_umbral_name=conn.connected_user.umbral_name, + connected_preferred_name=conn_settings.preferred_name if conn_settings else None, + person_id=conn.person_id, + created_at=conn.created_at, + ) + + +# ── GET /{id}/shared-profile ──────────────────────────────────────── + +@router.get("/{connection_id}/shared-profile") +async def get_shared_profile( + connection_id: int = Path(ge=1, le=2147483647), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Get the resolved shared profile for a connection.""" + result = await db.execute( + select(UserConnection) + .where( + UserConnection.id == connection_id, + UserConnection.user_id == current_user.id, + ) + .options(selectinload(UserConnection.connected_user)) + ) + conn = result.scalar_one_or_none() + if not conn: + raise HTTPException(status_code=404, detail="Connection not found") + + conn_settings = await _get_settings_for_user(db, conn.connected_user_id) + if not conn_settings: + return {} + + return resolve_shared_profile( + conn.connected_user, + conn_settings, + conn.sharing_overrides, + ) + + +# ── PUT /{id}/sharing-overrides ───────────────────────────────────── + +@router.put("/{connection_id}/sharing-overrides") +async def update_sharing_overrides( + body: SharingOverrideUpdate, + connection_id: int = Path(ge=1, le=2147483647), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Update what YOU share with a specific connection.""" + # Find the connection where the OTHER user connects to YOU + result = await db.execute( + select(UserConnection).where( + UserConnection.connected_user_id == current_user.id, + UserConnection.user_id != current_user.id, + ) + ) + # We need the reverse connection (where we are the connected_user) + # Actually, we need to find the connection from the counterpart's perspective + # The connection_id is OUR connection. The sharing overrides go on the + # counterpart's connection row (since they determine what they see from us). + # Wait — per the plan, sharing overrides control what WE share with THEM. + # So they go on their connection row pointing to us. + + # First, get our connection to know who the counterpart is + our_conn = await db.execute( + select(UserConnection).where( + UserConnection.id == connection_id, + UserConnection.user_id == current_user.id, + ) + ) + conn = our_conn.scalar_one_or_none() + if not conn: + raise HTTPException(status_code=404, detail="Connection not found") + + # Find the reverse connection (their row pointing to us) + reverse_result = await db.execute( + select(UserConnection).where( + UserConnection.user_id == conn.connected_user_id, + UserConnection.connected_user_id == current_user.id, + ) + ) + reverse_conn = reverse_result.scalar_one_or_none() + if not reverse_conn: + raise HTTPException(status_code=404, detail="Reverse connection not found") + + # Build validated overrides dict — only SHAREABLE_FIELDS keys + overrides = {} + update_data = body.model_dump(exclude_unset=True) + for key, value in update_data.items(): + if key in SHAREABLE_FIELDS: + overrides[key] = value + + reverse_conn.sharing_overrides = overrides if overrides else None + + await db.commit() + return {"message": "Sharing overrides updated"} + + +# ── DELETE /{id} ──────────────────────────────────────────────────── + +@router.delete("/{connection_id}", status_code=204) +async def remove_connection( + request: Request, + connection_id: int = Path(ge=1, le=2147483647), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """ + Remove a connection. Removes BOTH UserConnection rows. + Detaches BOTH Person records (sets linked_user_id=null, is_umbral_contact=false). + Silent — no notification sent. + """ + # Get our connection + result = await db.execute( + select(UserConnection) + .where( + UserConnection.id == connection_id, + UserConnection.user_id == current_user.id, + ) + ) + conn = result.scalar_one_or_none() + if not conn: + raise HTTPException(status_code=404, detail="Connection not found") + + counterpart_id = conn.connected_user_id + + # 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 Person records + if conn.person_id: + person_result = await db.execute(select(Person).where(Person.id == conn.person_id)) + person = person_result.scalar_one_or_none() + if person: + await detach_umbral_contact(person) + + if reverse_conn and reverse_conn.person_id: + person_result = await db.execute(select(Person).where(Person.id == reverse_conn.person_id)) + person = person_result.scalar_one_or_none() + if person: + await detach_umbral_contact(person) + + # Delete both connections + await db.delete(conn) + if reverse_conn: + await db.delete(reverse_conn) + + await log_audit_event( + db, + action="connection.removed", + actor_id=current_user.id, + target_id=counterpart_id, + detail={"connection_id": connection_id}, + ip=get_client_ip(request), + ) + + await db.commit() + return None diff --git a/backend/app/routers/notifications.py b/backend/app/routers/notifications.py new file mode 100644 index 0000000..354f587 --- /dev/null +++ b/backend/app/routers/notifications.py @@ -0,0 +1,143 @@ +""" +Notification centre router — in-app notifications. + +All endpoints scoped by current_user.id to prevent IDOR. +""" +from fastapi import APIRouter, Depends, HTTPException, Path, Query +from sqlalchemy import select, func, update, delete, and_ +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.models.notification import Notification +from app.models.user import User +from app.routers.auth import get_current_user +from app.schemas.notification import ( + NotificationResponse, + NotificationListResponse, + MarkReadRequest, +) + +router = APIRouter() + + +@router.get("/", response_model=NotificationListResponse) +async def list_notifications( + unread_only: bool = Query(False), + type: str | None = Query(None, max_length=50), + page: int = Query(1, ge=1), + per_page: int = Query(20, ge=1, le=100), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Paginated notification list with optional filters.""" + base = select(Notification).where(Notification.user_id == current_user.id) + + if unread_only: + base = base.where(Notification.is_read == False) # noqa: E712 + if type: + base = base.where(Notification.type == type) + + # Total count + count_q = select(func.count()).select_from(base.subquery()) + total = await db.scalar(count_q) or 0 + + # Unread count (always full, regardless of filters) + unread_count = await db.scalar( + select(func.count()) + .select_from(Notification) + .where( + Notification.user_id == current_user.id, + Notification.is_read == False, # noqa: E712 + ) + ) or 0 + + # Paginated results + offset = (page - 1) * per_page + result = await db.execute( + base.order_by(Notification.created_at.desc()).offset(offset).limit(per_page) + ) + notifications = result.scalars().all() + + return NotificationListResponse( + notifications=notifications, + unread_count=unread_count, + total=total, + ) + + +@router.get("/unread-count") +async def get_unread_count( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Lightweight unread count endpoint (uses partial index).""" + count = await db.scalar( + select(func.count()) + .select_from(Notification) + .where( + Notification.user_id == current_user.id, + Notification.is_read == False, # noqa: E712 + ) + ) or 0 + return {"count": count} + + +@router.put("/read") +async def mark_read( + body: MarkReadRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Mark specific notification IDs as read (user_id scoped — IDOR prevention).""" + await db.execute( + update(Notification) + .where( + and_( + Notification.id.in_(body.notification_ids), + Notification.user_id == current_user.id, + ) + ) + .values(is_read=True) + ) + await db.commit() + return {"message": "Notifications marked as read"} + + +@router.put("/read-all") +async def mark_all_read( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Mark all notifications as read for current user.""" + await db.execute( + update(Notification) + .where( + Notification.user_id == current_user.id, + Notification.is_read == False, # noqa: E712 + ) + .values(is_read=True) + ) + await db.commit() + return {"message": "All notifications marked as read"} + + +@router.delete("/{notification_id}", status_code=204) +async def delete_notification( + notification_id: int = Path(ge=1, le=2147483647), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Delete a single notification (user_id scoped).""" + result = await db.execute( + select(Notification).where( + Notification.id == notification_id, + Notification.user_id == current_user.id, + ) + ) + notification = result.scalar_one_or_none() + if not notification: + raise HTTPException(status_code=404, detail="Notification not found") + + await db.delete(notification) + await db.commit() + return None diff --git a/backend/app/routers/people.py b/backend/app/routers/people.py index 2c1b517..3fff4b4 100644 --- a/backend/app/routers/people.py +++ b/backend/app/routers/people.py @@ -1,14 +1,18 @@ 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.models.user import User +from app.services.connection import resolve_shared_profile router = APIRouter() @@ -59,6 +63,53 @@ async def get_people( 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) + ) + + # 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 @@ -104,7 +155,28 @@ async def get_person( if not person: raise HTTPException(status_code=404, detail="Person not found") - return person + 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 + ) + return resp @router.put("/{person_id}", response_model=PersonResponse) diff --git a/backend/app/routers/settings.py b/backend/app/routers/settings.py index e6b205a..43f6f75 100644 --- a/backend/app/routers/settings.py +++ b/backend/app/routers/settings.py @@ -39,6 +39,25 @@ def _to_settings_response(s: Settings) -> SettingsResponse: ntfy_has_token=bool(s.ntfy_auth_token), # derived — never expose the token value auto_lock_enabled=s.auto_lock_enabled, auto_lock_minutes=s.auto_lock_minutes, + # Profile fields + phone=s.phone, + mobile=s.mobile, + address=s.address, + company=s.company, + job_title=s.job_title, + # Social settings + accept_connections=s.accept_connections, + # Sharing defaults + share_preferred_name=s.share_preferred_name, + share_email=s.share_email, + share_phone=s.share_phone, + share_mobile=s.share_mobile, + share_birthday=s.share_birthday, + share_address=s.share_address, + share_company=s.share_company, + share_job_title=s.share_job_title, + # ntfy connections toggle + ntfy_connections_enabled=s.ntfy_connections_enabled, created_at=s.created_at, updated_at=s.updated_at, ) diff --git a/backend/app/schemas/admin.py b/backend/app/schemas/admin.py index fa52d2a..bfc4441 100644 --- a/backend/app/schemas/admin.py +++ b/backend/app/schemas/admin.py @@ -20,6 +20,7 @@ from app.schemas.auth import _validate_username, _validate_password_strength, _v class UserListItem(BaseModel): id: int username: str + umbral_name: str = "" email: Optional[str] = None first_name: Optional[str] = None last_name: Optional[str] = None diff --git a/backend/app/schemas/connection.py b/backend/app/schemas/connection.py new file mode 100644 index 0000000..f060ddf --- /dev/null +++ b/backend/app/schemas/connection.py @@ -0,0 +1,75 @@ +""" +Connection schemas — search, request, respond, connection management. +All input schemas use extra="forbid" to prevent mass-assignment. +""" +import re +from typing import Literal, Optional +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +_UMBRAL_NAME_RE = re.compile(r'^[a-zA-Z0-9_-]{3,50}$') + + +class UmbralSearchRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + umbral_name: str = Field(..., max_length=50) + + @field_validator('umbral_name') + @classmethod + def validate_umbral_name(cls, v: str) -> str: + if not _UMBRAL_NAME_RE.match(v): + raise ValueError('Umbral name must be 3-50 alphanumeric characters, hyphens, or underscores') + return v + + +class UmbralSearchResponse(BaseModel): + found: bool + + +class SendConnectionRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + umbral_name: str = Field(..., max_length=50) + + @field_validator('umbral_name') + @classmethod + def validate_umbral_name(cls, v: str) -> str: + if not _UMBRAL_NAME_RE.match(v): + raise ValueError('Umbral name must be 3-50 alphanumeric characters, hyphens, or underscores') + return v + + +class ConnectionRequestResponse(BaseModel): + id: int + sender_umbral_name: str + sender_preferred_name: Optional[str] = None + receiver_umbral_name: str + receiver_preferred_name: Optional[str] = None + status: str + created_at: datetime + + +class RespondRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + action: Literal["accept", "reject"] + + +class ConnectionResponse(BaseModel): + id: int + connected_user_id: int + connected_umbral_name: str + connected_preferred_name: Optional[str] = None + person_id: Optional[int] = None + created_at: datetime + + +class SharingOverrideUpdate(BaseModel): + model_config = ConfigDict(extra="forbid") + preferred_name: Optional[bool] = None + email: Optional[bool] = None + phone: Optional[bool] = None + mobile: Optional[bool] = None + birthday: Optional[bool] = None + address: Optional[bool] = None + company: Optional[bool] = None + job_title: Optional[bool] = None diff --git a/backend/app/schemas/notification.py b/backend/app/schemas/notification.py new file mode 100644 index 0000000..c705ce3 --- /dev/null +++ b/backend/app/schemas/notification.py @@ -0,0 +1,30 @@ +from pydantic import BaseModel, ConfigDict, Field +from datetime import datetime +from typing import Optional + + +class NotificationResponse(BaseModel): + id: int + user_id: int + type: str + title: Optional[str] = None + message: Optional[str] = None + data: Optional[dict] = None + source_type: Optional[str] = None + source_id: Optional[int] = None + is_read: bool + created_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class NotificationListResponse(BaseModel): + notifications: list[NotificationResponse] + unread_count: int + total: int + + +class MarkReadRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + notification_ids: list[int] = Field(..., min_length=1, max_length=100) diff --git a/backend/app/schemas/person.py b/backend/app/schemas/person.py index 6e2a5e7..188467e 100644 --- a/backend/app/schemas/person.py +++ b/backend/app/schemas/person.py @@ -85,6 +85,9 @@ class PersonResponse(BaseModel): company: Optional[str] job_title: Optional[str] notes: Optional[str] + linked_user_id: Optional[int] = None + is_umbral_contact: bool = False + shared_fields: Optional[dict] = None created_at: datetime updated_at: datetime diff --git a/backend/app/schemas/settings.py b/backend/app/schemas/settings.py index 8f11b60..cfe09e3 100644 --- a/backend/app/schemas/settings.py +++ b/backend/app/schemas/settings.py @@ -37,6 +37,29 @@ class SettingsUpdate(BaseModel): auto_lock_enabled: Optional[bool] = None auto_lock_minutes: Optional[int] = None + # Profile fields (shareable with connections) + phone: Optional[str] = Field(None, max_length=50) + mobile: Optional[str] = Field(None, max_length=50) + address: Optional[str] = Field(None, max_length=2000) + company: Optional[str] = Field(None, max_length=255) + job_title: Optional[str] = Field(None, max_length=255) + + # Social settings + accept_connections: Optional[bool] = None + + # Sharing defaults + share_preferred_name: Optional[bool] = None + share_email: Optional[bool] = None + share_phone: Optional[bool] = None + share_mobile: Optional[bool] = None + share_birthday: Optional[bool] = None + share_address: Optional[bool] = None + share_company: Optional[bool] = None + share_job_title: Optional[bool] = None + + # ntfy connections toggle + ntfy_connections_enabled: Optional[bool] = None + @field_validator('auto_lock_minutes') @classmethod def validate_auto_lock_minutes(cls, v: Optional[int]) -> Optional[int]: @@ -151,6 +174,29 @@ class SettingsResponse(BaseModel): auto_lock_enabled: bool = False auto_lock_minutes: int = 5 + # Profile fields + phone: Optional[str] = None + mobile: Optional[str] = None + address: Optional[str] = None + company: Optional[str] = None + job_title: Optional[str] = None + + # Social settings + accept_connections: bool = False + + # Sharing defaults + share_preferred_name: bool = True + share_email: bool = False + share_phone: bool = False + share_mobile: bool = False + share_birthday: bool = False + share_address: bool = False + share_company: bool = False + share_job_title: bool = False + + # ntfy connections toggle + ntfy_connections_enabled: bool = True + created_at: datetime updated_at: datetime diff --git a/backend/app/services/connection.py b/backend/app/services/connection.py new file mode 100644 index 0000000..5af67aa --- /dev/null +++ b/backend/app/services/connection.py @@ -0,0 +1,168 @@ +""" +Connection service — shared profile resolution, Person creation, ntfy dispatch. + +SHAREABLE_FIELDS is the single source of truth for which fields can be shared. +""" +import asyncio +import logging +from typing import Optional + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.person import Person +from app.models.settings import Settings +from app.models.user import User +from app.services.ntfy import send_ntfy_notification + +logger = logging.getLogger(__name__) + +# Single source of truth — only these fields can be shared via connections +SHAREABLE_FIELDS = frozenset({ + "preferred_name", "email", "phone", "mobile", + "birthday", "address", "company", "job_title", +}) + +# Maps shareable field names to their Settings model column names +_SETTINGS_FIELD_MAP = { + "preferred_name": "preferred_name", + "email": None, # email comes from User model, not Settings + "phone": "phone", + "mobile": "mobile", + "birthday": None, # birthday comes from User model (date_of_birth) + "address": "address", + "company": "company", + "job_title": "job_title", +} + + +def resolve_shared_profile( + user: User, + settings: Settings, + overrides: Optional[dict] = None, +) -> dict: + """ + Merge global sharing defaults with per-connection overrides. + Returns {field: value} dict of fields the user is sharing. + Only fields in SHAREABLE_FIELDS are included. + """ + overrides = overrides or {} + result = {} + + for field in SHAREABLE_FIELDS: + # Determine if this field is shared: override wins, else global default + share_key = f"share_{field}" + global_share = getattr(settings, share_key, False) + is_shared = overrides.get(field, global_share) + + if not is_shared: + continue + + # Resolve the actual value + if field == "preferred_name": + result[field] = settings.preferred_name + elif field == "email": + result[field] = user.email + elif field == "birthday": + result[field] = str(user.date_of_birth) if user.date_of_birth else None + elif field in _SETTINGS_FIELD_MAP and _SETTINGS_FIELD_MAP[field]: + result[field] = getattr(settings, _SETTINGS_FIELD_MAP[field], None) + + return result + + +def create_person_from_connection( + owner_user_id: int, + connected_user: User, + connected_settings: Settings, + shared_profile: dict, +) -> Person: + """Create a Person record for a new connection. Does NOT add to session — caller does.""" + # Use shared preferred_name for display, fall back to umbral_name + first_name = shared_profile.get("preferred_name") or connected_user.umbral_name + email = shared_profile.get("email") + phone = shared_profile.get("phone") + mobile = shared_profile.get("mobile") + address = shared_profile.get("address") + company = shared_profile.get("company") + job_title = shared_profile.get("job_title") + birthday_str = shared_profile.get("birthday") + + from datetime import date as date_type + birthday = None + if birthday_str: + try: + birthday = date_type.fromisoformat(birthday_str) + except (ValueError, TypeError): + pass + + # Compute display name + display_name = first_name or connected_user.umbral_name + + return Person( + user_id=owner_user_id, + name=display_name, + first_name=first_name, + email=email, + phone=phone, + mobile=mobile, + address=address, + company=company, + job_title=job_title, + birthday=birthday, + category="Umbral", + linked_user_id=connected_user.id, + is_umbral_contact=True, + ) + + +async def detach_umbral_contact(person: Person) -> None: + """Convert an umbral contact back to a standard contact. Does NOT commit.""" + person.linked_user_id = None + person.is_umbral_contact = False + # Clear shared field values but preserve locally-entered data + # If no first_name exists, fill from the old name + if not person.first_name: + person.first_name = person.name or None + + +async def send_connection_ntfy( + settings: Settings, + sender_name: str, + event_type: str, +) -> None: + """Send ntfy push for connection events. Non-blocking with 3s timeout.""" + if not settings.ntfy_connections_enabled: + return + + title_map = { + "request_received": "New Connection Request", + "request_accepted": "Connection Accepted", + } + message_map = { + "request_received": f"{sender_name} wants to connect with you on Umbra", + "request_accepted": f"{sender_name} accepted your connection request", + } + tag_map = { + "request_received": ["handshake"], + "request_accepted": ["white_check_mark"], + } + + title = title_map.get(event_type, "Connection Update") + message = message_map.get(event_type, f"Connection update from {sender_name}") + tags = tag_map.get(event_type, ["bell"]) + + try: + await asyncio.wait_for( + send_ntfy_notification( + settings=settings, + title=title, + message=message, + tags=tags, + priority=3, + ), + timeout=3.0, + ) + except asyncio.TimeoutError: + logger.warning("ntfy connection push timed out for user_id=%s", settings.user_id) + except Exception: + logger.warning("ntfy connection push failed for user_id=%s", settings.user_id) diff --git a/backend/app/services/notification.py b/backend/app/services/notification.py new file mode 100644 index 0000000..6093be2 --- /dev/null +++ b/backend/app/services/notification.py @@ -0,0 +1,34 @@ +""" +In-app notification service. + +Creates notification records for the notification centre. +Separate from ntfy push — in-app notifications are always created; +ntfy push is gated by per-type toggles. +""" +from typing import Optional +from sqlalchemy.ext.asyncio import AsyncSession +from app.models.notification import Notification + + +async def create_notification( + db: AsyncSession, + user_id: int, + type: str, + title: str, + message: str, + data: Optional[dict] = None, + source_type: Optional[str] = None, + source_id: Optional[int] = None, +) -> Notification: + """Create an in-app notification. Does NOT commit — caller handles transaction.""" + notification = Notification( + user_id=user_id, + type=type, + title=title, + message=message, + data=data, + source_type=source_type, + source_id=source_id, + ) + db.add(notification) + return notification diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 17a0f09..c15d713 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -4,6 +4,9 @@ limit_req_zone $binary_remote_addr zone=auth_limit:10m rate=10r/m; limit_req_zone $binary_remote_addr zone=register_limit:10m rate=5r/m; # Admin API — generous for legitimate use but still guards against scraping/brute-force limit_req_zone $binary_remote_addr zone=admin_limit:10m rate=30r/m; +# Connection endpoints — prevent search enumeration and request spam +limit_req_zone $binary_remote_addr zone=conn_search_limit:10m rate=10r/m; +limit_req_zone $binary_remote_addr zone=conn_request_limit:10m rate=3r/m; # Use X-Forwarded-Proto from upstream proxy when present, fall back to $scheme for direct access map $http_x_forwarded_proto $forwarded_proto { @@ -82,6 +85,20 @@ server { include /etc/nginx/proxy-params.conf; } + # Connection search — rate-limited to prevent user enumeration + location /api/connections/search { + limit_req zone=conn_search_limit burst=5 nodelay; + limit_req_status 429; + include /etc/nginx/proxy-params.conf; + } + + # Connection request — rate-limited to prevent spam + location /api/connections/request { + limit_req zone=conn_request_limit burst=3 nodelay; + limit_req_status 429; + include /etc/nginx/proxy-params.conf; + } + # Admin API — rate-limited separately from general /api traffic location /api/admin/ { limit_req zone=admin_limit burst=10 nodelay; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a969411..66de45d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -12,6 +12,7 @@ import ProjectDetail from '@/components/projects/ProjectDetail'; import PeoplePage from '@/components/people/PeoplePage'; import LocationsPage from '@/components/locations/LocationsPage'; import SettingsPage from '@/components/settings/SettingsPage'; +import NotificationsPage from '@/components/notifications/NotificationsPage'; const AdminPortal = lazy(() => import('@/components/admin/AdminPortal')); @@ -72,6 +73,7 @@ function App() { } /> } /> } /> + } /> } /> Username + + Umbral Name + Email @@ -209,6 +212,9 @@ export default function IAMPage() { )} > {user.username} + + {user.umbral_name || user.username} + {user.email || '—'} diff --git a/frontend/src/components/connections/ConnectionRequestCard.tsx b/frontend/src/components/connections/ConnectionRequestCard.tsx new file mode 100644 index 0000000..c55ad3e --- /dev/null +++ b/frontend/src/components/connections/ConnectionRequestCard.tsx @@ -0,0 +1,75 @@ +import { useState } from 'react'; +import { Check, X, Loader2 } from 'lucide-react'; +import { toast } from 'sonner'; +import { formatDistanceToNow } from 'date-fns'; +import { Button } from '@/components/ui/button'; +import { useConnections } from '@/hooks/useConnections'; +import { getErrorMessage } from '@/lib/api'; +import { cn } from '@/lib/utils'; +import type { ConnectionRequest } from '@/types'; + +interface ConnectionRequestCardProps { + request: ConnectionRequest; +} + +export default function ConnectionRequestCard({ request }: ConnectionRequestCardProps) { + const { respond, isResponding } = useConnections(); + const [resolved, setResolved] = useState(false); + + const handleRespond = async (action: 'accept' | 'reject') => { + try { + await respond({ requestId: request.id, action }); + setResolved(true); + toast.success(action === 'accept' ? 'Connection accepted' : 'Request declined'); + } catch (err) { + toast.error(getErrorMessage(err, 'Failed to respond')); + } + }; + + const displayName = request.sender_preferred_name || request.sender_umbral_name; + + return ( +
+ {/* Avatar */} +
+ + {displayName.charAt(0).toUpperCase()} + +
+ + {/* Content */} +
+

{displayName}

+

+ wants to connect · {formatDistanceToNow(new Date(request.created_at), { addSuffix: true })} +

+
+ + {/* Actions */} +
+ + +
+
+ ); +} diff --git a/frontend/src/components/connections/ConnectionSearch.tsx b/frontend/src/components/connections/ConnectionSearch.tsx new file mode 100644 index 0000000..b60cb2d --- /dev/null +++ b/frontend/src/components/connections/ConnectionSearch.tsx @@ -0,0 +1,142 @@ +import { useState } from 'react'; +import { Search, UserPlus, Loader2, AlertCircle, CheckCircle } from 'lucide-react'; +import { toast } from 'sonner'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { useConnections } from '@/hooks/useConnections'; +import { getErrorMessage } from '@/lib/api'; + +interface ConnectionSearchProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export default function ConnectionSearch({ open, onOpenChange }: ConnectionSearchProps) { + const { search, isSearching, sendRequest, isSending } = useConnections(); + const [umbralName, setUmbralName] = useState(''); + const [found, setFound] = useState(null); + const [sent, setSent] = useState(false); + + const handleSearch = async () => { + if (!umbralName.trim()) return; + setFound(null); + setSent(false); + try { + const result = await search(umbralName.trim()); + setFound(result.found); + } catch { + setFound(false); + } + }; + + const handleSend = async () => { + try { + await sendRequest(umbralName.trim()); + setSent(true); + toast.success('Connection request sent'); + } catch (err) { + toast.error(getErrorMessage(err, 'Failed to send request')); + } + }; + + const handleClose = () => { + setUmbralName(''); + setFound(null); + setSent(false); + onOpenChange(false); + }; + + return ( + + + + + + Find Umbra User + + + Search for a user by their umbral name to send a connection request. + + +
+
+ +
+ { + setUmbralName(e.target.value); + setFound(null); + setSent(false); + }} + onKeyDown={(e) => { if (e.key === 'Enter') handleSearch(); }} + maxLength={50} + /> + +
+
+ + {found === false && ( +
+ + User not found +
+ )} + + {found === true && !sent && ( +
+
+
+ + {umbralName.charAt(0).toUpperCase()} + +
+ {umbralName} +
+ +
+ )} + + {sent && ( +
+ + Connection request sent +
+ )} +
+
+
+ ); +} diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 78ea3ed..f41b619 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -22,6 +22,7 @@ import { cn } from '@/lib/utils'; import { useAuth } from '@/hooks/useAuth'; import { useLock } from '@/hooks/useLock'; import { useConfirmAction } from '@/hooks/useConfirmAction'; +import { useNotifications } from '@/hooks/useNotifications'; import { Button } from '@/components/ui/button'; import api from '@/lib/api'; import type { Project } from '@/types'; @@ -47,6 +48,7 @@ export default function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose const location = useLocation(); const { logout, isAdmin } = useAuth(); const { lock } = useLock(); + const { unreadCount } = useNotifications(); const [projectsExpanded, setProjectsExpanded] = useState(false); const { data: trackedProjects } = useQuery({ @@ -194,6 +196,28 @@ export default function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose {showExpanded && Lock} + +
+ + {unreadCount > 0 && !showExpanded && ( +
+ )} +
+ {showExpanded && ( + + Notifications + {unreadCount > 0 && ( + + {unreadCount} + + )} + + )} + {isAdmin && ( = { + connection_request: { icon: UserPlus, color: 'text-violet-400' }, + connection_accepted: { icon: UserPlus, color: 'text-green-400' }, + info: { icon: Info, color: 'text-blue-400' }, + warning: { icon: AlertCircle, color: 'text-amber-400' }, +}; + +type Filter = 'all' | 'unread'; + +export default function NotificationsPage() { + const { + notifications, + unreadCount, + isLoading, + markRead, + markAllRead, + deleteNotification, + } = useNotifications(); + + const navigate = useNavigate(); + const [filter, setFilter] = useState('all'); + + const filtered = useMemo(() => { + if (filter === 'unread') return notifications.filter((n) => !n.is_read); + return notifications; + }, [notifications, filter]); + + const handleMarkRead = async (id: number) => { + try { + await markRead([id]); + } catch { /* toast handled by mutation */ } + }; + + const handleDelete = async (id: number) => { + try { + await deleteNotification(id); + } catch { /* toast handled by mutation */ } + }; + + const handleMarkAllRead = async () => { + try { + await markAllRead(); + } catch { /* toast handled by mutation */ } + }; + + const getIcon = (type: string) => { + const config = typeIcons[type] || { icon: Bell, color: 'text-muted-foreground' }; + return config; + }; + + const handleNotificationClick = async (notification: AppNotification) => { + if (!notification.is_read) { + await markRead([notification.id]).catch(() => {}); + } + // Navigate to People for connection-related notifications + if (notification.type === 'connection_request' || notification.type === 'connection_accepted') { + navigate('/people'); + } + }; + + return ( +
+ {/* Page header */} +
+
+
+
+ {/* Filter */} +
+ {(['all', 'unread'] as const).map((f) => ( + + ))} +
+ {unreadCount > 0 && ( + + )} +
+
+ + {/* Content */} +
+ {isLoading ? ( +
+ +
+ ) : filtered.length === 0 ? ( +
+ +

+ {filter === 'unread' ? 'No unread notifications' : 'No notifications'} +

+
+ ) : ( +
+ {filtered.map((notification) => { + const iconConfig = getIcon(notification.type); + const Icon = iconConfig.icon; + return ( +
handleNotificationClick(notification)} + className={cn( + 'flex items-start gap-3 px-6 py-3.5 transition-colors hover:bg-card-elevated group cursor-pointer', + !notification.is_read && 'bg-card' + )} + > + {/* Type icon */} +
+ +
+ + {/* Content */} +
+
+
+

+ {notification.title} +

+ {notification.message && ( +

+ {notification.message} +

+ )} +
+ {/* Unread dot */} + {!notification.is_read && ( +
+ )} +
+
+ + {/* Timestamp + actions */} +
+ + {formatDistanceToNow(new Date(notification.created_at), { addSuffix: true })} + +
+ {!notification.is_read && ( + + )} + +
+
+
+ ); + })} +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/people/PeoplePage.tsx b/frontend/src/components/people/PeoplePage.tsx index b38318d..ed4b3dd 100644 --- a/frontend/src/components/people/PeoplePage.tsx +++ b/frontend/src/components/people/PeoplePage.tsx @@ -1,5 +1,5 @@ import { useState, useMemo, useRef, useEffect } from 'react'; -import { Plus, Users, Star, Cake, Phone, Mail, MapPin, Tag, Building2, Briefcase, AlignLeft } from 'lucide-react'; +import { Plus, Users, Star, Cake, Phone, Mail, MapPin, Tag, Building2, Briefcase, AlignLeft, Ghost, ChevronDown } from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { format, parseISO, differenceInYears } from 'date-fns'; @@ -23,6 +23,7 @@ import { import { useTableVisibility } from '@/hooks/useTableVisibility'; import { useCategoryOrder } from '@/hooks/useCategoryOrder'; import PersonForm from './PersonForm'; +import ConnectionSearch from '@/components/connections/ConnectionSearch'; // --------------------------------------------------------------------------- // StatCounter — inline helper @@ -98,6 +99,9 @@ const columns: ColumnDef[] = [ {getInitials(initialsName)}
{p.nickname || p.name} + {p.is_umbral_contact && ( + + )}
); }, @@ -193,9 +197,13 @@ export default function PeoplePage() { const [editingPerson, setEditingPerson] = useState(null); const [activeFilters, setActiveFilters] = useState([]); const [showPinned, setShowPinned] = useState(true); + const [showUmbralOnly, setShowUmbralOnly] = useState(false); const [search, setSearch] = useState(''); const [sortKey, setSortKey] = useState('name'); const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc'); + const [showConnectionSearch, setShowConnectionSearch] = useState(false); + const [showAddDropdown, setShowAddDropdown] = useState(false); + const addDropdownRef = useRef(null); const { data: people = [], isLoading } = useQuery({ queryKey: ['people'], @@ -228,6 +236,10 @@ export default function PeoplePage() { ? people.filter((p) => !p.is_favourite) : people; + if (showUmbralOnly) { + list = list.filter((p) => p.is_umbral_contact); + } + if (activeFilters.length > 0) { list = list.filter((p) => p.category && activeFilters.includes(p.category)); } @@ -249,7 +261,7 @@ export default function PeoplePage() { } return sortPeople(list, sortKey, sortDir); - }, [people, showPinned, activeFilters, search, sortKey, sortDir]); + }, [people, showPinned, showUmbralOnly, activeFilters, search, sortKey, sortDir]); // Build row groups for the table — ordered by custom category order const groups = useMemo(() => { @@ -347,6 +359,18 @@ export default function PeoplePage() { return () => document.removeEventListener('keydown', handler); }, [panelOpen]); + // Close add dropdown on outside click + useEffect(() => { + if (!showAddDropdown) return; + const handler = (e: MouseEvent) => { + if (addDropdownRef.current && !addDropdownRef.current.contains(e.target as Node)) { + setShowAddDropdown(false); + } + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [showAddDropdown]); + const handleCloseForm = () => { setShowForm(false); setEditingPerson(null); @@ -363,7 +387,12 @@ export default function PeoplePage() { {getInitials(initialsName)}
-

{p.name}

+
+

{p.name}

+ {p.is_umbral_contact && ( + + )} +
{p.category && ( {p.category} )} @@ -372,8 +401,49 @@ export default function PeoplePage() { ); }; - // Panel getValue + // Shared field key mapping (panel key -> shared_fields key) + const sharedKeyMap: Record = { + email: 'email', + phone: 'phone', + mobile: 'mobile', + birthday_display: 'birthday', + address: 'address', + company: 'company', + job_title: 'job_title', + }; + + // Build dynamic panel fields with synced labels for shared fields + const dynamicPanelFields = useMemo((): PanelField[] => { + if (!selectedPerson?.is_umbral_contact || !selectedPerson.shared_fields) return panelFields; + const shared = selectedPerson.shared_fields; + return panelFields.map((f) => { + const sharedKey = sharedKeyMap[f.key]; + if (sharedKey && sharedKey in shared) { + return { ...f, label: `${f.label} (synced)` }; + } + return f; + }); + }, [selectedPerson]); + + // Panel getValue — overlays shared fields from connected user const getPanelValue = (p: Person, key: string): string | undefined => { + // Check shared fields first for umbral contacts + if (p.is_umbral_contact && p.shared_fields) { + const sharedKey = sharedKeyMap[key]; + if (sharedKey && sharedKey in p.shared_fields) { + const sharedVal = p.shared_fields[sharedKey]; + if (key === 'birthday_display' && sharedVal) { + const bd = String(sharedVal); + try { + const age = differenceInYears(new Date(), parseISO(bd)); + return `${format(parseISO(bd), 'MMM d, yyyy')} (${age})`; + } catch { + return bd; + } + } + return sharedVal != null ? String(sharedVal) : undefined; + } + } if (key === 'birthday_display' && p.birthday) { const age = differenceInYears(new Date(), parseISO(p.birthday)); return `${format(parseISO(p.birthday), 'MMM d, yyyy')} (${age})`; @@ -385,7 +455,7 @@ export default function PeoplePage() { const renderPanel = () => ( item={selectedPerson} - fields={panelFields} + fields={dynamicPanelFields} onEdit={() => { setEditingPerson(selectedPerson); setShowForm(true); @@ -420,12 +490,53 @@ export default function PeoplePage() { onReorderCategories={reorderCategories} searchValue={search} onSearchChange={setSearch} + extraPinnedFilters={[ + { + label: 'Umbral', + isActive: showUmbralOnly, + onToggle: () => setShowUmbralOnly((p) => !p), + }, + ]} />
- +
+
+ + +
+ {showAddDropdown && ( +
+ + +
+ )} +
@@ -558,6 +669,11 @@ export default function PeoplePage() { onClose={handleCloseForm} /> )} + +
); } diff --git a/frontend/src/components/people/PersonForm.tsx b/frontend/src/components/people/PersonForm.tsx index 2683ae2..18918d8 100644 --- a/frontend/src/components/people/PersonForm.tsx +++ b/frontend/src/components/people/PersonForm.tsx @@ -1,7 +1,7 @@ import { useState, useMemo, FormEvent } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; -import { Star, StarOff, X } from 'lucide-react'; +import { Star, StarOff, X, Lock } from 'lucide-react'; import { parseISO, differenceInYears } from 'date-fns'; import api, { getErrorMessage } from '@/lib/api'; import type { Person } from '@/types'; @@ -30,6 +30,11 @@ interface PersonFormProps { export default function PersonForm({ person, categories, onClose }: PersonFormProps) { const queryClient = useQueryClient(); + // Helper to resolve a field value — prefer shared_fields for umbral contacts + const sf = person?.shared_fields; + const shared = (key: string, fallback: string) => + sf && key in sf && sf[key] != null ? String(sf[key]) : fallback; + const [formData, setFormData] = useState({ first_name: person?.first_name || @@ -38,20 +43,24 @@ export default function PersonForm({ person, categories, onClose }: PersonFormPr person?.last_name || (person?.name ? splitName(person.name).lastName : ''), nickname: person?.nickname || '', - email: person?.email || '', - phone: person?.phone || '', - mobile: person?.mobile || '', - address: person?.address || '', - birthday: person?.birthday - ? person.birthday.slice(0, 10) - : '', + email: shared('email', person?.email || ''), + phone: shared('phone', person?.phone || ''), + mobile: shared('mobile', person?.mobile || ''), + address: shared('address', person?.address || ''), + birthday: shared('birthday', person?.birthday ? person.birthday.slice(0, 10) : ''), category: person?.category || '', is_favourite: person?.is_favourite ?? false, - company: person?.company || '', - job_title: person?.job_title || '', + company: shared('company', person?.company || ''), + job_title: shared('job_title', person?.job_title || ''), notes: person?.notes || '', }); + // Check if a field is synced from an umbral connection (read-only) + const isShared = (fieldKey: string): boolean => { + if (!person?.is_umbral_contact || !person.shared_fields) return false; + return fieldKey in person.shared_fields; + }; + const age = useMemo(() => { if (!formData.birthday) return null; try { @@ -165,13 +174,25 @@ export default function PersonForm({ person, categories, onClose }: PersonFormPr {/* Row 4: Birthday + Age */}
- + + {isShared('birthday') ? ( + + ) : ( set('birthday', v)} /> + )}
@@ -200,65 +221,102 @@ export default function PersonForm({ person, categories, onClose }: PersonFormPr {/* Row 6: Mobile + Email */}
- + set('mobile', e.target.value)} + disabled={isShared('mobile')} + className={isShared('mobile') ? 'opacity-70 cursor-not-allowed' : ''} />
- + set('email', e.target.value)} + disabled={isShared('email')} + className={isShared('email') ? 'opacity-70 cursor-not-allowed' : ''} />
{/* Row 7: Phone */}
- + set('phone', e.target.value)} placeholder="Landline / work number" + disabled={isShared('phone')} + className={isShared('phone') ? 'opacity-70 cursor-not-allowed' : ''} />
{/* Row 8: Address */}
- - set('address', val)} - onSelect={(result) => set('address', result.address || result.name)} - placeholder="Search or enter address..." - /> + + {isShared('address') ? ( + + ) : ( + set('address', val)} + onSelect={(result) => set('address', result.address || result.name)} + placeholder="Search or enter address..." + /> + )}
{/* Row 9: Company + Job Title */}
- + set('company', e.target.value)} + disabled={isShared('company')} + className={isShared('company') ? 'opacity-70 cursor-not-allowed' : ''} />
- + set('job_title', e.target.value)} + disabled={isShared('job_title')} + className={isShared('job_title') ? 'opacity-70 cursor-not-allowed' : ''} />
diff --git a/frontend/src/components/settings/SettingsPage.tsx b/frontend/src/components/settings/SettingsPage.tsx index 6019c78..ba38b81 100644 --- a/frontend/src/components/settings/SettingsPage.tsx +++ b/frontend/src/components/settings/SettingsPage.tsx @@ -14,6 +14,7 @@ import { Loader2, Shield, Blocks, + Ghost, } from 'lucide-react'; import { useSettings } from '@/hooks/useSettings'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; @@ -24,6 +25,7 @@ import { cn } from '@/lib/utils'; import api from '@/lib/api'; import type { GeoLocation, UserProfile } from '@/types'; import { Switch } from '@/components/ui/switch'; +import CopyableField from '@/components/shared/CopyableField'; import TotpSetupSection from './TotpSetupSection'; import NtfySettingsSection from './NtfySettingsSection'; @@ -55,6 +57,24 @@ export default function SettingsPage() { const [autoLockEnabled, setAutoLockEnabled] = useState(settings?.auto_lock_enabled ?? false); const [autoLockMinutes, setAutoLockMinutes] = useState(settings?.auto_lock_minutes ?? 5); + // Profile extension fields (stored on Settings model) + const [settingsPhone, setSettingsPhone] = useState(settings?.phone ?? ''); + const [settingsMobile, setSettingsMobile] = useState(settings?.mobile ?? ''); + const [settingsAddress, setSettingsAddress] = useState(settings?.address ?? ''); + const [settingsCompany, setSettingsCompany] = useState(settings?.company ?? ''); + const [settingsJobTitle, setSettingsJobTitle] = useState(settings?.job_title ?? ''); + + // Social settings + const [acceptConnections, setAcceptConnections] = useState(settings?.accept_connections ?? false); + const [sharePreferredName, setSharePreferredName] = useState(settings?.share_preferred_name ?? true); + const [shareEmail, setShareEmail] = useState(settings?.share_email ?? false); + const [sharePhone, setSharePhone] = useState(settings?.share_phone ?? false); + const [shareMobile, setShareMobile] = useState(settings?.share_mobile ?? false); + const [shareBirthday, setShareBirthday] = useState(settings?.share_birthday ?? false); + const [shareAddress, setShareAddress] = useState(settings?.share_address ?? false); + const [shareCompany, setShareCompany] = useState(settings?.share_company ?? false); + const [shareJobTitle, setShareJobTitle] = useState(settings?.share_job_title ?? false); + // Profile fields (stored on User model, fetched from /auth/profile) const profileQuery = useQuery({ queryKey: ['profile'], @@ -87,6 +107,20 @@ export default function SettingsPage() { setFirstDayOfWeek(settings.first_day_of_week); setAutoLockEnabled(settings.auto_lock_enabled); setAutoLockMinutes(settings.auto_lock_minutes ?? 5); + setSettingsPhone(settings.phone ?? ''); + setSettingsMobile(settings.mobile ?? ''); + setSettingsAddress(settings.address ?? ''); + setSettingsCompany(settings.company ?? ''); + setSettingsJobTitle(settings.job_title ?? ''); + setAcceptConnections(settings.accept_connections); + setSharePreferredName(settings.share_preferred_name); + setShareEmail(settings.share_email); + setSharePhone(settings.share_phone); + setShareMobile(settings.share_mobile); + setShareBirthday(settings.share_birthday); + setShareAddress(settings.share_address); + setShareCompany(settings.share_company); + setShareJobTitle(settings.share_job_title); } }, [settings?.id]); // only re-sync on initial load (settings.id won't change) @@ -248,6 +282,29 @@ export default function SettingsPage() { } }; + const handleSettingsFieldSave = async (field: string, value: string) => { + const trimmed = value.trim(); + const currentVal = (settings as any)?.[field] || ''; + if (trimmed === (currentVal || '')) return; + try { + await updateSettings({ [field]: trimmed || null } as any); + toast.success('Profile updated'); + } catch { + toast.error('Failed to update profile'); + } + }; + + const handleSocialToggle = async (field: string, checked: boolean, setter: (v: boolean) => void) => { + const previous = (settings as any)?.[field]; + setter(checked); + try { + await updateSettings({ [field]: checked } as any); + } catch { + setter(previous); + toast.error('Failed to update setting'); + } + }; + const handleAutoLockMinutesSave = async () => { const raw = typeof autoLockMinutes === 'string' ? parseInt(autoLockMinutes) : autoLockMinutes; const clamped = Math.max(1, Math.min(60, isNaN(raw) ? 5 : raw)); @@ -363,6 +420,75 @@ export default function SettingsPage() { onKeyDown={(e) => { if (e.key === 'Enter') handleProfileSave('date_of_birth'); }} />
+
+
+ + setSettingsPhone(e.target.value)} + onBlur={() => handleSettingsFieldSave('phone', settingsPhone)} + onKeyDown={(e) => { if (e.key === 'Enter') handleSettingsFieldSave('phone', settingsPhone); }} + maxLength={50} + /> +
+
+ + setSettingsMobile(e.target.value)} + onBlur={() => handleSettingsFieldSave('mobile', settingsMobile)} + onKeyDown={(e) => { if (e.key === 'Enter') handleSettingsFieldSave('mobile', settingsMobile); }} + maxLength={50} + /> +
+
+
+ + setSettingsAddress(e.target.value)} + onBlur={() => handleSettingsFieldSave('address', settingsAddress)} + onKeyDown={(e) => { if (e.key === 'Enter') handleSettingsFieldSave('address', settingsAddress); }} + maxLength={2000} + /> +
+
+
+ + setSettingsCompany(e.target.value)} + onBlur={() => handleSettingsFieldSave('company', settingsCompany)} + onKeyDown={(e) => { if (e.key === 'Enter') handleSettingsFieldSave('company', settingsCompany); }} + maxLength={255} + /> +
+
+ + setSettingsJobTitle(e.target.value)} + onBlur={() => handleSettingsFieldSave('job_title', settingsJobTitle)} + onKeyDown={(e) => { if (e.key === 'Enter') handleSettingsFieldSave('job_title', settingsJobTitle); }} + maxLength={255} + /> +
+
@@ -586,9 +712,77 @@ export default function SettingsPage() {
- {/* ── Right column: Security, Authentication, Integrations ── */} + {/* ── Right column: Social, Security, Authentication, Integrations ── */}
+ {/* Social */} + + +
+
+
+
+ Social + Manage your Umbra identity and connections +
+
+
+ +
+ +
+ + +
+

+ How other Umbra users find you +

+
+
+
+ +

+ Allow other users to find and connect with you +

+
+ handleSocialToggle('accept_connections', checked, setAcceptConnections)} + /> +
+
+

+ Sharing Defaults +

+
+ {[ + { field: 'share_preferred_name', label: 'Preferred Name', state: sharePreferredName, setter: setSharePreferredName }, + { field: 'share_email', label: 'Email', state: shareEmail, setter: setShareEmail }, + { field: 'share_phone', label: 'Phone', state: sharePhone, setter: setSharePhone }, + { field: 'share_mobile', label: 'Mobile', state: shareMobile, setter: setShareMobile }, + { field: 'share_birthday', label: 'Birthday', state: shareBirthday, setter: setShareBirthday }, + { field: 'share_address', label: 'Address', state: shareAddress, setter: setShareAddress }, + { field: 'share_company', label: 'Company', state: shareCompany, setter: setShareCompany }, + { field: 'share_job_title', label: 'Job Title', state: shareJobTitle, setter: setShareJobTitle }, + ].map(({ field, label, state, setter }) => ( +
+ + handleSocialToggle(field, checked, setter)} + /> +
+ ))} +
+
+
+
+ {/* Security (auto-lock) */} diff --git a/frontend/src/components/shared/CategoryFilterBar.tsx b/frontend/src/components/shared/CategoryFilterBar.tsx index 892a926..4739557 100644 --- a/frontend/src/components/shared/CategoryFilterBar.tsx +++ b/frontend/src/components/shared/CategoryFilterBar.tsx @@ -18,6 +18,12 @@ import { } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; +interface ExtraPinnedFilter { + label: string; + isActive: boolean; + onToggle: () => void; +} + interface CategoryFilterBarProps { activeFilters: string[]; pinnedLabel: string; @@ -30,6 +36,7 @@ interface CategoryFilterBarProps { onReorderCategories?: (order: string[]) => void; searchValue: string; onSearchChange: (val: string) => void; + extraPinnedFilters?: ExtraPinnedFilter[]; } const pillBase = @@ -116,6 +123,7 @@ export default function CategoryFilterBar({ onReorderCategories, searchValue, onSearchChange, + extraPinnedFilters = [], }: CategoryFilterBarProps) { const [otherOpen, setOtherOpen] = useState(false); const searchInputRef = useRef(null); @@ -169,6 +177,22 @@ export default function CategoryFilterBar({ + {/* Extra pinned filters (e.g. "Umbral") */} + {extraPinnedFilters.map((epf) => ( + + ))} + {/* Categories pill + expandable chips */} {categories.length > 0 && ( <> diff --git a/frontend/src/hooks/useConnections.ts b/frontend/src/hooks/useConnections.ts new file mode 100644 index 0000000..fae3a46 --- /dev/null +++ b/frontend/src/hooks/useConnections.ts @@ -0,0 +1,88 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import api from '@/lib/api'; +import type { Connection, ConnectionRequest, UmbralSearchResponse } from '@/types'; + +export function useConnections() { + const queryClient = useQueryClient(); + + const connectionsQuery = useQuery({ + queryKey: ['connections'], + queryFn: async () => { + const { data } = await api.get('/connections'); + return data; + }, + }); + + const incomingQuery = useQuery({ + queryKey: ['connections', 'incoming'], + queryFn: async () => { + const { data } = await api.get('/connections/requests/incoming'); + return data; + }, + }); + + const outgoingQuery = useQuery({ + queryKey: ['connections', 'outgoing'], + queryFn: async () => { + const { data } = await api.get('/connections/requests/outgoing'); + return data; + }, + }); + + const searchMutation = useMutation({ + mutationFn: async (umbralName: string) => { + const { data } = await api.post('/connections/search', { + umbral_name: umbralName, + }); + return data; + }, + }); + + const sendRequestMutation = useMutation({ + mutationFn: async (umbralName: string) => { + const { data } = await api.post('/connections/request', { + umbral_name: umbralName, + }); + return data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['connections'] }); + }, + }); + + const respondMutation = useMutation({ + mutationFn: async ({ requestId, action }: { requestId: number; action: 'accept' | 'reject' }) => { + const { data } = await api.put(`/connections/requests/${requestId}/respond`, { action }); + return data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['connections'] }); + queryClient.invalidateQueries({ queryKey: ['people'] }); + queryClient.invalidateQueries({ queryKey: ['notifications'] }); + }, + }); + + const removeConnectionMutation = useMutation({ + mutationFn: async (connectionId: number) => { + await api.delete(`/connections/${connectionId}`); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['connections'] }); + queryClient.invalidateQueries({ queryKey: ['people'] }); + }, + }); + + return { + connections: connectionsQuery.data ?? [], + incomingRequests: incomingQuery.data ?? [], + outgoingRequests: outgoingQuery.data ?? [], + isLoading: connectionsQuery.isLoading, + search: searchMutation.mutateAsync, + isSearching: searchMutation.isPending, + sendRequest: sendRequestMutation.mutateAsync, + isSending: sendRequestMutation.isPending, + respond: respondMutation.mutateAsync, + isResponding: respondMutation.isPending, + removeConnection: removeConnectionMutation.mutateAsync, + }; +} diff --git a/frontend/src/hooks/useNotifications.ts b/frontend/src/hooks/useNotifications.ts new file mode 100644 index 0000000..cfd9347 --- /dev/null +++ b/frontend/src/hooks/useNotifications.ts @@ -0,0 +1,76 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useEffect, useRef } from 'react'; +import api from '@/lib/api'; +import type { NotificationListResponse } from '@/types'; + +export function useNotifications() { + const queryClient = useQueryClient(); + const visibleRef = useRef(true); + + // Track tab visibility to pause polling when hidden + useEffect(() => { + const handler = () => { + visibleRef.current = document.visibilityState === 'visible'; + }; + document.addEventListener('visibilitychange', handler); + return () => document.removeEventListener('visibilitychange', handler); + }, []); + + const unreadQuery = useQuery({ + queryKey: ['notifications', 'unread-count'], + queryFn: async () => { + const { data } = await api.get<{ count: number }>('/notifications/unread-count'); + return data.count; + }, + refetchInterval: () => (visibleRef.current ? 60_000 : false), + staleTime: 30_000, + }); + + const listQuery = useQuery({ + queryKey: ['notifications', 'list'], + queryFn: async () => { + const { data } = await api.get('/notifications', { + params: { per_page: 50 }, + }); + return data; + }, + staleTime: 30_000, + }); + + const markReadMutation = useMutation({ + mutationFn: async (notificationIds: number[]) => { + await api.put('/notifications/read', { notification_ids: notificationIds }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['notifications'] }); + }, + }); + + const markAllReadMutation = useMutation({ + mutationFn: async () => { + await api.put('/notifications/read-all'); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['notifications'] }); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: async (id: number) => { + await api.delete(`/notifications/${id}`); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['notifications'] }); + }, + }); + + return { + unreadCount: unreadQuery.data ?? 0, + notifications: listQuery.data?.notifications ?? [], + total: listQuery.data?.total ?? 0, + isLoading: listQuery.isLoading, + markRead: markReadMutation.mutateAsync, + markAllRead: markAllReadMutation.mutateAsync, + deleteNotification: deleteMutation.mutateAsync, + }; +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 20e209f..f77ef95 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -23,6 +23,25 @@ export interface Settings { // Auto-lock settings auto_lock_enabled: boolean; auto_lock_minutes: number; + // Profile fields (shareable) + phone: string | null; + mobile: string | null; + address: string | null; + company: string | null; + job_title: string | null; + // Social settings + accept_connections: boolean; + // Sharing defaults + share_preferred_name: boolean; + share_email: boolean; + share_phone: boolean; + share_mobile: boolean; + share_birthday: boolean; + share_address: boolean; + share_company: boolean; + share_job_title: boolean; + // ntfy connections toggle + ntfy_connections_enabled: boolean; created_at: string; updated_at: string; } @@ -171,6 +190,9 @@ export interface Person { company?: string; job_title?: string; notes?: string; + linked_user_id?: number | null; + is_umbral_contact: boolean; + shared_fields?: Record | null; created_at: string; updated_at: string; } @@ -222,6 +244,7 @@ export type LoginResponse = LoginSuccessResponse | LoginMfaRequiredResponse | Lo export interface AdminUser { id: number; username: string; + umbral_name: string; email: string | null; first_name: string | null; last_name: string | null; @@ -366,3 +389,48 @@ export interface EventTemplate { is_starred: boolean; created_at: string; } + +// ── Notifications ────────────────────────────────────────────────── +// Named AppNotification to avoid collision with browser Notification API + +export interface AppNotification { + id: number; + user_id: number; + type: string; + title: string | null; + message: string | null; + data: Record | null; + source_type: string | null; + source_id: number | null; + is_read: boolean; + created_at: string; +} + +export interface NotificationListResponse { + notifications: AppNotification[]; + unread_count: number; + total: number; +} + +// ── Connections ──────────────────────────────────────────────────── + +export interface ConnectionRequest { + id: number; + sender_umbral_name: string; + sender_preferred_name: string | null; + status: 'pending' | 'accepted' | 'rejected' | 'cancelled'; + created_at: string; +} + +export interface Connection { + id: number; + connected_user_id: number; + connected_umbral_name: string; + connected_preferred_name: string | null; + person_id: number | null; + created_at: string; +} + +export interface UmbralSearchResponse { + found: boolean; +}