From 3d22568b9c36134f258edd97b7ce3bd0ddb92b29 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Wed, 4 Mar 2026 02:10:16 +0800 Subject: [PATCH 01/37] Add user connections, notification centre, and people integration Implements the full User Connections & Notification Centre feature: Phase 1 - Database: migrations 039-043 adding umbral_name to users, profile/social fields to settings, notifications table, connection request/user_connection tables, and linked_user_id to people. Phase 2 - Notifications: backend CRUD router + service + 90-day purge, frontend NotificationsPage with All/Unread filter, bell icon in sidebar with unread badge polling every 60s. Phase 3 - Settings: profile fields (phone, mobile, address, company, job_title), social card with accept_connections toggle and per-field sharing defaults, umbral name display with CopyableField. Phase 4 - Connections: timing-safe user search, send/accept/reject flow with atomic status updates, bidirectional UserConnection + Person records, in-app + ntfy notifications, per-receiver pending cap, nginx rate limiting. Phase 5 - People integration: batch-loaded shared profiles (N+1 prevention), Ghost icon for umbral contacts, Umbral filter pill, split Add Person button, shared field indicators (synced labels + Lock icons), disabled form inputs for synced fields on umbral contacts. Co-Authored-By: Claude Opus 4.6 --- .../versions/039_add_umbral_name_to_users.py | 37 + .../040_expand_settings_profile_social.py | 85 +++ .../041_create_notifications_table.py | 57 ++ .../versions/042_create_connection_tables.py | 109 +++ .../043_add_linked_fields_to_people.py | 44 ++ backend/app/jobs/notifications.py | 9 + backend/app/main.py | 7 +- backend/app/models/__init__.py | 6 + backend/app/models/connection_request.py | 33 + backend/app/models/notification.py | 23 + backend/app/models/person.py | 5 + backend/app/models/settings.py | 25 +- backend/app/models/user.py | 1 + backend/app/models/user_connection.py | 31 + backend/app/routers/connections.py | 642 ++++++++++++++++++ backend/app/routers/notifications.py | 143 ++++ backend/app/routers/people.py | 76 ++- backend/app/routers/settings.py | 19 + backend/app/schemas/admin.py | 1 + backend/app/schemas/connection.py | 75 ++ backend/app/schemas/notification.py | 30 + backend/app/schemas/person.py | 3 + backend/app/schemas/settings.py | 46 ++ backend/app/services/connection.py | 168 +++++ backend/app/services/notification.py | 34 + frontend/nginx.conf | 17 + frontend/src/App.tsx | 2 + frontend/src/components/admin/IAMPage.tsx | 6 + .../connections/ConnectionRequestCard.tsx | 75 ++ .../connections/ConnectionSearch.tsx | 142 ++++ frontend/src/components/layout/Sidebar.tsx | 24 + .../notifications/NotificationsPage.tsx | 203 ++++++ frontend/src/components/people/PeoplePage.tsx | 134 +++- frontend/src/components/people/PersonForm.tsx | 106 ++- .../src/components/settings/SettingsPage.tsx | 196 +++++- .../components/shared/CategoryFilterBar.tsx | 24 + frontend/src/hooks/useConnections.ts | 88 +++ frontend/src/hooks/useNotifications.ts | 76 +++ frontend/src/types/index.ts | 68 ++ 39 files changed, 2832 insertions(+), 38 deletions(-) create mode 100644 backend/alembic/versions/039_add_umbral_name_to_users.py create mode 100644 backend/alembic/versions/040_expand_settings_profile_social.py create mode 100644 backend/alembic/versions/041_create_notifications_table.py create mode 100644 backend/alembic/versions/042_create_connection_tables.py create mode 100644 backend/alembic/versions/043_add_linked_fields_to_people.py create mode 100644 backend/app/models/connection_request.py create mode 100644 backend/app/models/notification.py create mode 100644 backend/app/models/user_connection.py create mode 100644 backend/app/routers/connections.py create mode 100644 backend/app/routers/notifications.py create mode 100644 backend/app/schemas/connection.py create mode 100644 backend/app/schemas/notification.py create mode 100644 backend/app/services/connection.py create mode 100644 backend/app/services/notification.py create mode 100644 frontend/src/components/connections/ConnectionRequestCard.tsx create mode 100644 frontend/src/components/connections/ConnectionSearch.tsx create mode 100644 frontend/src/components/notifications/NotificationsPage.tsx create mode 100644 frontend/src/hooks/useConnections.ts create mode 100644 frontend/src/hooks/useNotifications.ts 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; +} From 337b50c7ce2c38998a1070c1d3d99efce84b678c Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Wed, 4 Mar 2026 02:29:04 +0800 Subject: [PATCH 02/37] Fix QA review findings: source_id, N+1 queries, event bubbling, type mismatches Critical fixes: - C-01: Add receiver_umbral_name/receiver_preferred_name to frontend ConnectionRequest type - C-02: Flush connection request before notification to populate source_id - C-03: Add umbral_name to ProfileResponse/UserProfile, use in Settings Social card - C-04: Remove dead code in sharing-overrides endpoint, merge instead of replace Warning fixes: - W-01/W-02: Batch-fetch settings in incoming/outgoing/list connection endpoints (N+1 fix) - W-04: Add _purge_resolved_requests job for rejected/cancelled requests (30-day retention) - W-10: Add e.stopPropagation() to notification mark-read and delete buttons Co-Authored-By: Claude Opus 4.6 --- backend/app/jobs/notifications.py | 14 ++++ backend/app/routers/connections.py | 68 ++++++++++++------- backend/app/schemas/auth.py | 1 + .../notifications/NotificationsPage.tsx | 4 +- .../src/components/settings/SettingsPage.tsx | 4 +- frontend/src/types/index.ts | 3 + 6 files changed, 64 insertions(+), 30 deletions(-) diff --git a/backend/app/jobs/notifications.py b/backend/app/jobs/notifications.py index 2aa50a6..242ff00 100644 --- a/backend/app/jobs/notifications.py +++ b/backend/app/jobs/notifications.py @@ -26,6 +26,7 @@ from app.models.project import Project from app.models.ntfy_sent import NtfySent from app.models.totp_usage import TOTPUsage from app.models.session import UserSession +from app.models.connection_request import ConnectionRequest from app.services.ntfy import send_ntfy_notification from app.services.ntfy_templates import ( build_event_notification, @@ -275,6 +276,18 @@ async def _purge_old_notifications(db: AsyncSession) -> None: await db.commit() +async def _purge_resolved_requests(db: AsyncSession) -> None: + """Remove rejected/cancelled connection requests older than 30 days.""" + cutoff = datetime.now() - timedelta(days=30) + await db.execute( + delete(ConnectionRequest).where( + ConnectionRequest.status.in_(["rejected", "cancelled"]), + ConnectionRequest.resolved_at < cutoff, + ) + ) + await db.commit() + + # ── Entry point ─────────────────────────────────────────────────────────────── async def run_notification_dispatch() -> None: @@ -317,6 +330,7 @@ async def run_notification_dispatch() -> None: await _purge_totp_usage(db) await _purge_expired_sessions(db) await _purge_old_notifications(db) + await _purge_resolved_requests(db) except Exception: # Broad catch: job failure must never crash the scheduler or the app diff --git a/backend/app/routers/connections.py b/backend/app/routers/connections.py index ad39a4a..c46a83a 100644 --- a/backend/app/routers/connections.py +++ b/backend/app/routers/connections.py @@ -183,6 +183,7 @@ async def send_connection_request( receiver_id=target.id, ) db.add(conn_request) + await db.flush() # populate conn_request.id for source_id # Create in-app notification for receiver sender_settings = await _get_settings_for_user(db, current_user.id) @@ -196,7 +197,7 @@ async def send_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 + source_id=conn_request.id, ) await log_audit_event( @@ -246,10 +247,18 @@ async def get_incoming_requests( ) requests = result.scalars().all() + # Fetch current user's settings once, batch-fetch sender settings + receiver_settings = await _get_settings_for_user(db, current_user.id) + sender_ids = [req.sender_id for req in requests] + if sender_ids: + settings_result = await db.execute(select(Settings).where(Settings.user_id.in_(sender_ids))) + settings_by_user = {s.user_id: s for s in settings_result.scalars().all()} + else: + settings_by_user = {} + 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) + sender_settings = settings_by_user.get(req.sender_id) responses.append(_build_request_response(req, req.sender, sender_settings, current_user, receiver_settings)) return responses @@ -279,10 +288,18 @@ async def get_outgoing_requests( ) requests = result.scalars().all() + # Fetch current user's settings once, batch-fetch receiver settings + sender_settings = await _get_settings_for_user(db, current_user.id) + receiver_ids = [req.receiver_id for req in requests] + if receiver_ids: + settings_result = await db.execute(select(Settings).where(Settings.user_id.in_(receiver_ids))) + settings_by_user = {s.user_id: s for s in settings_result.scalars().all()} + else: + settings_by_user = {} + 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) + receiver_settings = settings_by_user.get(req.receiver_id) responses.append(_build_request_response(req, current_user, sender_settings, req.receiver, receiver_settings)) return responses @@ -366,6 +383,8 @@ async def respond_to_request( db.add(conn_a) db.add(conn_b) + await db.flush() # populate conn_a.id for source_id + # Notification to sender receiver_display = (receiver_settings.preferred_name if receiver_settings else None) or current_user.umbral_name await create_notification( @@ -376,7 +395,7 @@ async def respond_to_request( message=f"{receiver_display} accepted your connection request", data={"connected_umbral_name": current_user.umbral_name}, source_type="user_connection", - source_id=None, + source_id=conn_b.id, ) await log_audit_event( @@ -436,9 +455,17 @@ async def list_connections( ) connections = result.scalars().all() + # Batch-fetch settings for connected users + connected_ids = [conn.connected_user_id for conn in connections] + if connected_ids: + settings_result = await db.execute(select(Settings).where(Settings.user_id.in_(connected_ids))) + settings_by_user = {s.user_id: s for s in settings_result.scalars().all()} + else: + settings_by_user = {} + responses = [] for conn in connections: - conn_settings = await _get_settings_for_user(db, conn.connected_user_id) + conn_settings = settings_by_user.get(conn.connected_user_id) responses.append(ConnectionResponse( id=conn.id, connected_user_id=conn.connected_user_id, @@ -525,21 +552,7 @@ async def update_sharing_overrides( 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 + # Get our connection to know who the counterpart is our_conn = await db.execute( select(UserConnection).where( UserConnection.id == connection_id, @@ -561,14 +574,17 @@ async def update_sharing_overrides( if not reverse_conn: raise HTTPException(status_code=404, detail="Reverse connection not found") - # Build validated overrides dict — only SHAREABLE_FIELDS keys - overrides = {} + # Merge validated overrides — only SHAREABLE_FIELDS keys + existing = dict(reverse_conn.sharing_overrides or {}) update_data = body.model_dump(exclude_unset=True) for key, value in update_data.items(): if key in SHAREABLE_FIELDS: - overrides[key] = value + if value is None: + existing.pop(key, None) + else: + existing[key] = value - reverse_conn.sharing_overrides = overrides if overrides else None + reverse_conn.sharing_overrides = existing if existing else None await db.commit() return {"message": "Sharing overrides updated"} diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index 29e178b..9b358a6 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -199,6 +199,7 @@ class ProfileResponse(BaseModel): model_config = ConfigDict(from_attributes=True) username: str + umbral_name: str email: str | None first_name: str | None last_name: str | None diff --git a/frontend/src/components/notifications/NotificationsPage.tsx b/frontend/src/components/notifications/NotificationsPage.tsx index e3162fb..177e78d 100644 --- a/frontend/src/components/notifications/NotificationsPage.tsx +++ b/frontend/src/components/notifications/NotificationsPage.tsx @@ -176,7 +176,7 @@ export default function NotificationsPage() {
{!notification.is_read && ( )} +
+

{error.message}

+ +
+ ); + } + if (!user) return null; return ( diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx index 22a173a..55ee07c 100644 --- a/frontend/src/components/layout/AppLayout.tsx +++ b/frontend/src/components/layout/AppLayout.tsx @@ -7,6 +7,7 @@ import { LockProvider } from '@/hooks/useLock'; import { Button } from '@/components/ui/button'; import Sidebar from './Sidebar'; import LockOverlay from './LockOverlay'; +import NotificationToaster from '@/components/notifications/NotificationToaster'; export default function AppLayout() { useTheme(); @@ -44,6 +45,7 @@ export default function AppLayout() {
+ ); diff --git a/frontend/src/components/notifications/NotificationToaster.tsx b/frontend/src/components/notifications/NotificationToaster.tsx new file mode 100644 index 0000000..ea69f3e --- /dev/null +++ b/frontend/src/components/notifications/NotificationToaster.tsx @@ -0,0 +1,106 @@ +import { useEffect, useRef, useCallback } from 'react'; +import { toast } from 'sonner'; +import { Check, X, Bell, UserPlus } from 'lucide-react'; +import { useQueryClient } from '@tanstack/react-query'; +import { useNotifications } from '@/hooks/useNotifications'; +import api from '@/lib/api'; +import type { AppNotification } from '@/types'; + +export default function NotificationToaster() { + const { notifications } = useNotifications(); + const queryClient = useQueryClient(); + const seenIdsRef = useRef(new Set()); + const initializedRef = useRef(false); + + const handleConnectionRespond = useCallback( + async (requestId: number, action: 'accept' | 'reject', toastId: string | number) => { + try { + await api.put(`/connections/requests/${requestId}/respond`, { action }); + toast.dismiss(toastId); + toast.success(action === 'accept' ? 'Connection accepted' : 'Request declined'); + queryClient.invalidateQueries({ queryKey: ['connections'] }); + queryClient.invalidateQueries({ queryKey: ['people'] }); + queryClient.invalidateQueries({ queryKey: ['notifications'] }); + } catch { + toast.dismiss(toastId); + toast.error('Failed to respond to request'); + } + }, + [queryClient], + ); + + useEffect(() => { + if (!notifications.length && !initializedRef.current) return; + + // On first load, record all existing IDs without toasting + if (!initializedRef.current) { + notifications.forEach((n) => seenIdsRef.current.add(n.id)); + initializedRef.current = true; + return; + } + + // Find new notifications we haven't seen + const newNotifications = notifications.filter( + (n) => !n.is_read && !seenIdsRef.current.has(n.id), + ); + + // Record all current IDs + notifications.forEach((n) => seenIdsRef.current.add(n.id)); + + // Show toasts for new notifications + newNotifications.forEach((notification) => { + if (notification.type === 'connection_request' && notification.source_id) { + showConnectionRequestToast(notification); + } else { + toast(notification.title || 'New Notification', { + description: notification.message || undefined, + icon: , + duration: 8000, + }); + } + }); + }, [notifications, handleConnectionRespond]); + + const showConnectionRequestToast = (notification: AppNotification) => { + const requestId = notification.source_id!; + const senderName = + (notification.data as Record)?.sender_umbral_name || 'Someone'; + + toast.custom( + (id) => ( +
+
+
+ +
+
+

Connection Request

+

+ {notification.message || `${senderName} wants to connect with you`} +

+
+ + +
+
+
+
+ ), + { duration: 30000 }, + ); + }; + + return null; +} diff --git a/frontend/src/components/notifications/NotificationsPage.tsx b/frontend/src/components/notifications/NotificationsPage.tsx index 177e78d..35533b2 100644 --- a/frontend/src/components/notifications/NotificationsPage.tsx +++ b/frontend/src/components/notifications/NotificationsPage.tsx @@ -1,10 +1,13 @@ import { useState, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; -import { Bell, Check, CheckCheck, Trash2, UserPlus, Info, AlertCircle } from 'lucide-react'; +import { Bell, Check, CheckCheck, Trash2, UserPlus, Info, AlertCircle, X, Loader2 } from 'lucide-react'; import { formatDistanceToNow } from 'date-fns'; +import { toast } from 'sonner'; import { useNotifications } from '@/hooks/useNotifications'; +import { useConnections } from '@/hooks/useConnections'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; +import { getErrorMessage } from '@/lib/api'; import { ListSkeleton } from '@/components/ui/skeleton'; import type { AppNotification } from '@/types'; @@ -27,9 +30,16 @@ export default function NotificationsPage() { deleteNotification, } = useNotifications(); + const { incomingRequests, respond, isResponding } = useConnections(); const navigate = useNavigate(); const [filter, setFilter] = useState('all'); + // Build a set of pending connection request IDs for quick lookup + const pendingRequestIds = useMemo( + () => new Set(incomingRequests.map((r) => r.id)), + [incomingRequests], + ); + const filtered = useMemo(() => { if (filter === 'unread') return notifications.filter((n) => !n.is_read); return notifications; @@ -58,7 +68,31 @@ export default function NotificationsPage() { return config; }; + const handleConnectionRespond = async ( + notification: AppNotification, + action: 'accept' | 'reject', + ) => { + if (!notification.source_id) return; + try { + await respond({ requestId: notification.source_id, action }); + if (!notification.is_read) { + await markRead([notification.id]).catch(() => {}); + } + toast.success(action === 'accept' ? 'Connection accepted' : 'Request declined'); + } catch (err) { + toast.error(getErrorMessage(err, 'Failed to respond')); + } + }; + const handleNotificationClick = async (notification: AppNotification) => { + // Don't navigate for pending connection requests — let user act inline + if ( + notification.type === 'connection_request' && + notification.source_id && + pendingRequestIds.has(notification.source_id) + ) { + return; + } if (!notification.is_read) { await markRead([notification.id]).catch(() => {}); } @@ -168,6 +202,32 @@ export default function NotificationsPage() { + {/* Connection request actions (inline) */} + {notification.type === 'connection_request' && + notification.source_id && + pendingRequestIds.has(notification.source_id) && ( +
+ + +
+ )} + {/* Timestamp + actions */}
diff --git a/frontend/src/hooks/useNotifications.ts b/frontend/src/hooks/useNotifications.ts index cfd9347..4097bb9 100644 --- a/frontend/src/hooks/useNotifications.ts +++ b/frontend/src/hooks/useNotifications.ts @@ -6,6 +6,7 @@ import type { NotificationListResponse } from '@/types'; export function useNotifications() { const queryClient = useQueryClient(); const visibleRef = useRef(true); + const prevUnreadRef = useRef(undefined); // Track tab visibility to pause polling when hidden useEffect(() => { @@ -22,10 +23,20 @@ export function useNotifications() { const { data } = await api.get<{ count: number }>('/notifications/unread-count'); return data.count; }, - refetchInterval: () => (visibleRef.current ? 60_000 : false), - staleTime: 30_000, + refetchInterval: () => (visibleRef.current ? 30_000 : false), + staleTime: 15_000, }); + // When unread count increases, immediately refetch the notification list + useEffect(() => { + const count = unreadQuery.data; + if (count === undefined) return; + if (prevUnreadRef.current !== undefined && count > prevUnreadRef.current) { + queryClient.invalidateQueries({ queryKey: ['notifications', 'list'] }); + } + prevUnreadRef.current = count; + }, [unreadQuery.data, queryClient]); + const listQuery = useQuery({ queryKey: ['notifications', 'list'], queryFn: async () => { @@ -34,7 +45,8 @@ export function useNotifications() { }); return data; }, - staleTime: 30_000, + staleTime: 15_000, + refetchInterval: () => (visibleRef.current ? 60_000 : false), }); const markReadMutation = useMutation({ From e27beb77369207611b0b22a128827c3072c8bce3 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Wed, 4 Mar 2026 06:21:43 +0800 Subject: [PATCH 07/37] Fix toast notifications, require accept_connections for senders - Rewrite NotificationToaster with max-ID watermark for reliable new-notification detection and faster unread count polling (15s) - Block connection search and requests when sender has accept_connections disabled (backend + frontend gate) - Remove duplicate sender_settings fetch in send_connection_request - Show actionable error messages in toast respond failures Co-Authored-By: Claude Opus 4.6 --- backend/app/routers/connections.py | 18 ++++++-- .../connections/ConnectionSearch.tsx | 28 +++++++++++- .../notifications/NotificationToaster.tsx | 43 ++++++++++++------- frontend/src/hooks/useNotifications.ts | 15 +------ 4 files changed, 71 insertions(+), 33 deletions(-) diff --git a/backend/app/routers/connections.py b/backend/app/routers/connections.py index c46a83a..9329acf 100644 --- a/backend/app/routers/connections.py +++ b/backend/app/routers/connections.py @@ -88,6 +88,11 @@ async def search_user( # Always sleep to prevent timing attacks await asyncio.sleep(0.05) + # Sender must have accept_connections enabled to search + sender_settings = await _get_settings_for_user(db, current_user.id) + if not sender_settings or not sender_settings.accept_connections: + return UmbralSearchResponse(found=False) + # Don't find yourself if body.umbral_name == current_user.umbral_name: return UmbralSearchResponse(found=False) @@ -132,7 +137,15 @@ async def send_connection_request( if target.id == current_user.id: raise HTTPException(status_code=400, detail="Cannot send a connection request to yourself") - # Check accept_connections + # Sender must have accept_connections enabled to participate + sender_settings = await _get_settings_for_user(db, current_user.id) + if not sender_settings or not sender_settings.accept_connections: + raise HTTPException( + status_code=403, + detail="You must enable 'Accept Connections' in your settings before sending requests", + ) + + # Check accept_connections on target 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") @@ -185,8 +198,7 @@ async def send_connection_request( db.add(conn_request) await db.flush() # populate conn_request.id for source_id - # Create in-app notification for receiver - sender_settings = await _get_settings_for_user(db, current_user.id) + # Create in-app notification for receiver (sender_settings already fetched above) sender_display = (sender_settings.preferred_name if sender_settings else None) or current_user.umbral_name await create_notification( diff --git a/frontend/src/components/connections/ConnectionSearch.tsx b/frontend/src/components/connections/ConnectionSearch.tsx index b60cb2d..e7e2ecb 100644 --- a/frontend/src/components/connections/ConnectionSearch.tsx +++ b/frontend/src/components/connections/ConnectionSearch.tsx @@ -1,6 +1,7 @@ import { useState } from 'react'; -import { Search, UserPlus, Loader2, AlertCircle, CheckCircle } from 'lucide-react'; +import { Search, UserPlus, Loader2, AlertCircle, CheckCircle, Settings } from 'lucide-react'; import { toast } from 'sonner'; +import { useNavigate } from 'react-router-dom'; import { Dialog, DialogContent, @@ -12,6 +13,7 @@ import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; import { useConnections } from '@/hooks/useConnections'; +import { useSettings } from '@/hooks/useSettings'; import { getErrorMessage } from '@/lib/api'; interface ConnectionSearchProps { @@ -21,10 +23,14 @@ interface ConnectionSearchProps { export default function ConnectionSearch({ open, onOpenChange }: ConnectionSearchProps) { const { search, isSearching, sendRequest, isSending } = useConnections(); + const { settings } = useSettings(); + const navigate = useNavigate(); const [umbralName, setUmbralName] = useState(''); const [found, setFound] = useState(null); const [sent, setSent] = useState(false); + const acceptConnectionsEnabled = settings?.accept_connections ?? false; + const handleSearch = async () => { if (!umbralName.trim()) return; setFound(null); @@ -67,6 +73,24 @@ export default function ConnectionSearch({ open, onOpenChange }: ConnectionSearc
+ {!acceptConnectionsEnabled ? ( +
+ +

+ You need to enable Accept Connections in your settings before you can send or receive connection requests. +

+ +
+ ) : ( + <>
@@ -135,6 +159,8 @@ export default function ConnectionSearch({ open, onOpenChange }: ConnectionSearc Connection request sent
)} + + )}
diff --git a/frontend/src/components/notifications/NotificationToaster.tsx b/frontend/src/components/notifications/NotificationToaster.tsx index ea69f3e..3e02d51 100644 --- a/frontend/src/components/notifications/NotificationToaster.tsx +++ b/frontend/src/components/notifications/NotificationToaster.tsx @@ -3,14 +3,15 @@ import { toast } from 'sonner'; import { Check, X, Bell, UserPlus } from 'lucide-react'; import { useQueryClient } from '@tanstack/react-query'; import { useNotifications } from '@/hooks/useNotifications'; -import api from '@/lib/api'; +import api, { getErrorMessage } from '@/lib/api'; import type { AppNotification } from '@/types'; export default function NotificationToaster() { - const { notifications } = useNotifications(); + const { notifications, unreadCount } = useNotifications(); const queryClient = useQueryClient(); - const seenIdsRef = useRef(new Set()); + const maxSeenIdRef = useRef(0); const initializedRef = useRef(false); + const prevUnreadRef = useRef(0); const handleConnectionRespond = useCallback( async (requestId: number, action: 'accept' | 'reject', toastId: string | number) => { @@ -21,33 +22,45 @@ export default function NotificationToaster() { queryClient.invalidateQueries({ queryKey: ['connections'] }); queryClient.invalidateQueries({ queryKey: ['people'] }); queryClient.invalidateQueries({ queryKey: ['notifications'] }); - } catch { + } catch (err) { toast.dismiss(toastId); - toast.error('Failed to respond to request'); + toast.error(getErrorMessage(err, 'Failed to respond to request')); } }, [queryClient], ); + // Track unread count changes to force-refetch the list useEffect(() => { - if (!notifications.length && !initializedRef.current) return; + if (unreadCount > prevUnreadRef.current && initializedRef.current) { + queryClient.invalidateQueries({ queryKey: ['notifications', 'list'] }); + } + prevUnreadRef.current = unreadCount; + }, [unreadCount, queryClient]); - // On first load, record all existing IDs without toasting + // Show toasts for new notifications (ID > max seen) + useEffect(() => { + if (!notifications.length) return; + + // On first load, record the max ID without toasting if (!initializedRef.current) { - notifications.forEach((n) => seenIdsRef.current.add(n.id)); + maxSeenIdRef.current = Math.max(...notifications.map((n) => n.id)); initializedRef.current = true; return; } - // Find new notifications we haven't seen + // Find unread notifications with IDs higher than our watermark const newNotifications = notifications.filter( - (n) => !n.is_read && !seenIdsRef.current.has(n.id), + (n) => !n.is_read && n.id > maxSeenIdRef.current, ); - // Record all current IDs - notifications.forEach((n) => seenIdsRef.current.add(n.id)); + // Advance watermark + const maxCurrent = Math.max(...notifications.map((n) => n.id)); + if (maxCurrent > maxSeenIdRef.current) { + maxSeenIdRef.current = maxCurrent; + } - // Show toasts for new notifications + // Show toasts newNotifications.forEach((notification) => { if (notification.type === 'connection_request' && notification.source_id) { showConnectionRequestToast(notification); @@ -63,8 +76,6 @@ export default function NotificationToaster() { const showConnectionRequestToast = (notification: AppNotification) => { const requestId = notification.source_id!; - const senderName = - (notification.data as Record)?.sender_umbral_name || 'Someone'; toast.custom( (id) => ( @@ -76,7 +87,7 @@ export default function NotificationToaster() {

Connection Request

- {notification.message || `${senderName} wants to connect with you`} + {notification.message || 'Someone wants to connect with you'}

- + {isIncoming ? ( + <> + + + + ) : ( + + )}
); diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx index 55ee07c..7b7a89c 100644 --- a/frontend/src/components/layout/AppLayout.tsx +++ b/frontend/src/components/layout/AppLayout.tsx @@ -4,6 +4,7 @@ import { Menu } from 'lucide-react'; import { useTheme } from '@/hooks/useTheme'; import { AlertsProvider } from '@/hooks/useAlerts'; import { LockProvider } from '@/hooks/useLock'; +import { NotificationProvider } from '@/hooks/useNotifications'; import { Button } from '@/components/ui/button'; import Sidebar from './Sidebar'; import LockOverlay from './LockOverlay'; @@ -20,32 +21,34 @@ export default function AppLayout() { return ( -
- { - const next = !collapsed; - setCollapsed(next); - localStorage.setItem('umbra-sidebar-collapsed', JSON.stringify(next)); - }} - mobileOpen={mobileOpen} - onMobileClose={() => setMobileOpen(false)} - /> -
- {/* Mobile header */} -
- -

UMBRA

+ +
+ { + const next = !collapsed; + setCollapsed(next); + localStorage.setItem('umbra-sidebar-collapsed', JSON.stringify(next)); + }} + mobileOpen={mobileOpen} + onMobileClose={() => setMobileOpen(false)} + /> +
+ {/* Mobile header */} +
+ +

UMBRA

+
+
+ +
-
- -
-
- - + + + ); diff --git a/frontend/src/components/people/PeoplePage.tsx b/frontend/src/components/people/PeoplePage.tsx index ed4b3dd..aa1cd7a 100644 --- a/frontend/src/components/people/PeoplePage.tsx +++ b/frontend/src/components/people/PeoplePage.tsx @@ -24,6 +24,8 @@ import { useTableVisibility } from '@/hooks/useTableVisibility'; import { useCategoryOrder } from '@/hooks/useCategoryOrder'; import PersonForm from './PersonForm'; import ConnectionSearch from '@/components/connections/ConnectionSearch'; +import ConnectionRequestCard from '@/components/connections/ConnectionRequestCard'; +import { useConnections } from '@/hooks/useConnections'; // --------------------------------------------------------------------------- // StatCounter — inline helper @@ -205,6 +207,9 @@ export default function PeoplePage() { const [showAddDropdown, setShowAddDropdown] = useState(false); const addDropdownRef = useRef(null); + const { incomingRequests, outgoingRequests } = useConnections(); + const hasRequests = incomingRequests.length > 0 || outgoingRequests.length > 0; + const { data: people = [], isLoading } = useQuery({ queryKey: ['people'], queryFn: async () => { @@ -583,6 +588,40 @@ export default function PeoplePage() {
)} + {/* Pending requests */} + {hasRequests && ( +
+
+ + Pending Requests + + + {incomingRequests.length + outgoingRequests.length} + +
+
+ {incomingRequests.length > 0 && outgoingRequests.length > 0 && ( +

Incoming

+ )} + {incomingRequests.slice(0, 5).map((req) => ( + + ))} + {incomingRequests.length > 5 && ( +

+{incomingRequests.length - 5} more

+ )} + {incomingRequests.length > 0 && outgoingRequests.length > 0 && ( +

Outgoing

+ )} + {outgoingRequests.slice(0, 5).map((req) => ( + + ))} + {outgoingRequests.length > 5 && ( +

+{outgoingRequests.length - 5} more

+ )} +
+
+ )} + {/* Main content: table + panel */}
{/* Table */} diff --git a/frontend/src/hooks/useConnections.ts b/frontend/src/hooks/useConnections.ts index fae3a46..d92eec4 100644 --- a/frontend/src/hooks/useConnections.ts +++ b/frontend/src/hooks/useConnections.ts @@ -62,6 +62,17 @@ export function useConnections() { }, }); + const cancelMutation = useMutation({ + mutationFn: async (requestId: number) => { + const { data } = await api.put(`/connections/requests/${requestId}/cancel`); + return data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['connections'] }); + queryClient.invalidateQueries({ queryKey: ['notifications'] }); + }, + }); + const removeConnectionMutation = useMutation({ mutationFn: async (connectionId: number) => { await api.delete(`/connections/${connectionId}`); @@ -83,6 +94,8 @@ export function useConnections() { isSending: sendRequestMutation.isPending, respond: respondMutation.mutateAsync, isResponding: respondMutation.isPending, + cancelRequest: cancelMutation.mutateAsync, + isCancelling: cancelMutation.isPending, removeConnection: removeConnectionMutation.mutateAsync, }; } diff --git a/frontend/src/hooks/useNotifications.ts b/frontend/src/hooks/useNotifications.ts index 978a4ce..e2663ad 100644 --- a/frontend/src/hooks/useNotifications.ts +++ b/frontend/src/hooks/useNotifications.ts @@ -1,13 +1,39 @@ +import { createContext, useContext, type ReactNode } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, createElement } from 'react'; import api from '@/lib/api'; import type { NotificationListResponse } from '@/types'; +interface NotificationContextValue { + unreadCount: number; + notifications: NotificationListResponse['notifications']; + total: number; + isLoading: boolean; + markRead: (ids: number[]) => Promise; + markAllRead: () => Promise; + deleteNotification: (id: number) => Promise; + refreshNotifications: () => void; +} + +const NotificationContext = createContext({ + unreadCount: 0, + notifications: [], + total: 0, + isLoading: true, + markRead: async () => {}, + markAllRead: async () => {}, + deleteNotification: async () => {}, + refreshNotifications: () => {}, +}); + export function useNotifications() { + return useContext(NotificationContext); +} + +export function NotificationProvider({ children }: { children: ReactNode }) { const queryClient = useQueryClient(); const visibleRef = useRef(true); - // Track tab visibility to pause polling when hidden useEffect(() => { const handler = () => { visibleRef.current = document.visibilityState === 'visible'; @@ -65,7 +91,11 @@ export function useNotifications() { }, }); - return { + const refreshNotifications = () => { + queryClient.invalidateQueries({ queryKey: ['notifications'] }); + }; + + const value: NotificationContextValue = { unreadCount: unreadQuery.data ?? 0, notifications: listQuery.data?.notifications ?? [], total: listQuery.data?.total ?? 0, @@ -73,5 +103,8 @@ export function useNotifications() { markRead: markReadMutation.mutateAsync, markAllRead: markAllReadMutation.mutateAsync, deleteNotification: deleteMutation.mutateAsync, + refreshNotifications, }; + + return createElement(NotificationContext.Provider, { value }, children); } From 75fc3e3485915826145b02dbd55b682151a5e308 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Wed, 4 Mar 2026 07:34:13 +0800 Subject: [PATCH 10/37] Fix notification background polling, add first/last name sharing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Notifications: enable refetchIntervalInBackground on unread count query so notifications appear in background tabs without requiring a tab switch to trigger refetchOnWindowFocus. Name sharing: add share_first_name and share_last_name to the full sharing pipeline — migration 045, Settings model/schema, SHAREABLE_FIELDS, resolve_shared_profile, create_person_from_connection (now populates first_name + last_name + computed display name), SharingOverrideUpdate, frontend types and SettingsPage toggles. Co-Authored-By: Claude Opus 4.6 --- .../versions/045_add_share_name_fields.py | 28 +++++++++++++++++++ backend/app/models/settings.py | 2 ++ backend/app/schemas/connection.py | 2 ++ backend/app/schemas/settings.py | 4 +++ backend/app/services/connection.py | 23 ++++++++++----- .../src/components/settings/SettingsPage.tsx | 6 ++++ frontend/src/hooks/useNotifications.ts | 3 +- frontend/src/types/index.ts | 2 ++ 8 files changed, 62 insertions(+), 8 deletions(-) create mode 100644 backend/alembic/versions/045_add_share_name_fields.py diff --git a/backend/alembic/versions/045_add_share_name_fields.py b/backend/alembic/versions/045_add_share_name_fields.py new file mode 100644 index 0000000..674060a --- /dev/null +++ b/backend/alembic/versions/045_add_share_name_fields.py @@ -0,0 +1,28 @@ +"""Add share_first_name and share_last_name to settings. + +Revision ID: 045 +Revises: 044 +""" +from alembic import op +import sqlalchemy as sa + +revision = "045" +down_revision = "044" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "settings", + sa.Column("share_first_name", sa.Boolean, nullable=False, server_default="false"), + ) + op.add_column( + "settings", + sa.Column("share_last_name", sa.Boolean, nullable=False, server_default="false"), + ) + + +def downgrade() -> None: + op.drop_column("settings", "share_last_name") + op.drop_column("settings", "share_first_name") diff --git a/backend/app/models/settings.py b/backend/app/models/settings.py index dac766a..2f3128a 100644 --- a/backend/app/models/settings.py +++ b/backend/app/models/settings.py @@ -57,6 +57,8 @@ class Settings(Base): accept_connections: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") # Sharing defaults (what fields are shared with connections by default) + share_first_name: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") + share_last_name: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") 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") diff --git a/backend/app/schemas/connection.py b/backend/app/schemas/connection.py index c313e72..d293290 100644 --- a/backend/app/schemas/connection.py +++ b/backend/app/schemas/connection.py @@ -78,6 +78,8 @@ class CancelResponse(BaseModel): class SharingOverrideUpdate(BaseModel): model_config = ConfigDict(extra="forbid") + first_name: Optional[bool] = None + last_name: Optional[bool] = None preferred_name: Optional[bool] = None email: Optional[bool] = None phone: Optional[bool] = None diff --git a/backend/app/schemas/settings.py b/backend/app/schemas/settings.py index cfe09e3..f78dfc6 100644 --- a/backend/app/schemas/settings.py +++ b/backend/app/schemas/settings.py @@ -48,6 +48,8 @@ class SettingsUpdate(BaseModel): accept_connections: Optional[bool] = None # Sharing defaults + share_first_name: Optional[bool] = None + share_last_name: Optional[bool] = None share_preferred_name: Optional[bool] = None share_email: Optional[bool] = None share_phone: Optional[bool] = None @@ -185,6 +187,8 @@ class SettingsResponse(BaseModel): accept_connections: bool = False # Sharing defaults + share_first_name: bool = False + share_last_name: bool = False share_preferred_name: bool = True share_email: bool = False share_phone: bool = False diff --git a/backend/app/services/connection.py b/backend/app/services/connection.py index 4aa1fb2..33eeb31 100644 --- a/backend/app/services/connection.py +++ b/backend/app/services/connection.py @@ -20,17 +20,19 @@ logger = logging.getLogger(__name__) # Single source of truth — only these fields can be shared via connections SHAREABLE_FIELDS = frozenset({ - "preferred_name", "email", "phone", "mobile", + "first_name", "last_name", "preferred_name", "email", "phone", "mobile", "birthday", "address", "company", "job_title", }) # Maps shareable field names to their Settings model column names _SETTINGS_FIELD_MAP = { + "first_name": None, # first_name comes from User model + "last_name": None, # last_name comes from User model "preferred_name": "preferred_name", - "email": None, # email comes from User model, not Settings + "email": None, # email comes from User model "phone": "phone", "mobile": "mobile", - "birthday": None, # birthday comes from User model (date_of_birth) + "birthday": None, # birthday comes from User model (date_of_birth) "address": "address", "company": "company", "job_title": "job_title", @@ -60,7 +62,11 @@ def resolve_shared_profile( continue # Resolve the actual value - if field == "preferred_name": + if field == "first_name": + result[field] = user.first_name + elif field == "last_name": + result[field] = user.last_name + elif field == "preferred_name": result[field] = settings.preferred_name elif field == "email": result[field] = user.email @@ -79,8 +85,9 @@ def create_person_from_connection( 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 + # Use shared first_name, fall back to preferred_name, then umbral_name + first_name = shared_profile.get("first_name") or shared_profile.get("preferred_name") or connected_user.umbral_name + last_name = shared_profile.get("last_name") email = shared_profile.get("email") phone = shared_profile.get("phone") mobile = shared_profile.get("mobile") @@ -97,12 +104,14 @@ def create_person_from_connection( pass # Compute display name - display_name = first_name or connected_user.umbral_name + full = ((first_name or '') + ' ' + (last_name or '')).strip() + display_name = full or connected_user.umbral_name return Person( user_id=owner_user_id, name=display_name, first_name=first_name, + last_name=last_name, email=email, phone=phone, mobile=mobile, diff --git a/frontend/src/components/settings/SettingsPage.tsx b/frontend/src/components/settings/SettingsPage.tsx index dda8c69..e76e550 100644 --- a/frontend/src/components/settings/SettingsPage.tsx +++ b/frontend/src/components/settings/SettingsPage.tsx @@ -66,6 +66,8 @@ export default function SettingsPage() { // Social settings const [acceptConnections, setAcceptConnections] = useState(settings?.accept_connections ?? false); + const [shareFirstName, setShareFirstName] = useState(settings?.share_first_name ?? false); + const [shareLastName, setShareLastName] = useState(settings?.share_last_name ?? 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); @@ -116,6 +118,8 @@ export default function SettingsPage() { setSettingsCompany(settings.company ?? ''); setSettingsJobTitle(settings.job_title ?? ''); setAcceptConnections(settings.accept_connections); + setShareFirstName(settings.share_first_name); + setShareLastName(settings.share_last_name); setSharePreferredName(settings.share_preferred_name); setShareEmail(settings.share_email); setSharePhone(settings.share_phone); @@ -788,6 +792,8 @@ export default function SettingsPage() {

{[ + { field: 'share_first_name', label: 'First Name', state: shareFirstName, setter: setShareFirstName }, + { field: 'share_last_name', label: 'Last Name', state: shareLastName, setter: setShareLastName }, { 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 }, diff --git a/frontend/src/hooks/useNotifications.ts b/frontend/src/hooks/useNotifications.ts index e2663ad..0681773 100644 --- a/frontend/src/hooks/useNotifications.ts +++ b/frontend/src/hooks/useNotifications.ts @@ -48,7 +48,8 @@ export function NotificationProvider({ children }: { children: ReactNode }) { const { data } = await api.get<{ count: number }>('/notifications/unread-count'); return data.count; }, - refetchInterval: () => (visibleRef.current ? 15_000 : false), + refetchInterval: 15_000, + refetchIntervalInBackground: true, staleTime: 10_000, }); diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 9fb7552..4476907 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -32,6 +32,8 @@ export interface Settings { // Social settings accept_connections: boolean; // Sharing defaults + share_first_name: boolean; + share_last_name: boolean; share_preferred_name: boolean; share_email: boolean; share_phone: boolean; From 33aac72639b885748f8f2ea913902050ea8fa775 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Wed, 4 Mar 2026 07:50:31 +0800 Subject: [PATCH 11/37] Add delete-with-sever and unlink actions for umbral contacts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delete person now severs the bidirectional connection when the person is an umbral contact — removes both UserConnection rows and detaches the counterpart's Person record. Fixes "Already connected" error when trying to reconnect after deleting an umbral contact. New PUT /people/{id}/unlink endpoint converts an umbral contact to a standard contact (detaches linked fields) while also severing the bidirectional connection, keeping the Person in the contact list. Frontend: EntityDetailPanel gains extraActions prop. PeoplePage renders an "Unlink" button in the panel footer for umbral contacts. Delete mutation now also invalidates connections query. Co-Authored-By: Claude Opus 4.6 --- backend/app/routers/people.py | 81 ++++++++++++++++--- frontend/src/components/people/PeoplePage.tsx | 33 +++++++- .../components/shared/EntityDetailPanel.tsx | 7 +- 3 files changed, 106 insertions(+), 15 deletions(-) diff --git a/backend/app/routers/people.py b/backend/app/routers/people.py index 6dfe41c..622bf93 100644 --- a/backend/app/routers/people.py +++ b/backend/app/routers/people.py @@ -216,13 +216,79 @@ async def update_person( return person +async def _sever_connection(db: AsyncSession, current_user: User, person: Person) -> None: + """Remove bidirectional UserConnection rows and detach the counterpart's Person.""" + if not person.linked_user_id: + return + + counterpart_id = person.linked_user_id + + # Find our connection + conn_result = await db.execute( + select(UserConnection).where( + UserConnection.user_id == current_user.id, + UserConnection.connected_user_id == counterpart_id, + ) + ) + our_conn = conn_result.scalar_one_or_none() + + # Find reverse connection + reverse_result = await db.execute( + select(UserConnection).where( + UserConnection.user_id == counterpart_id, + UserConnection.connected_user_id == current_user.id, + ) + ) + reverse_conn = reverse_result.scalar_one_or_none() + + # Detach the counterpart's Person record (if it exists) + if reverse_conn and reverse_conn.person_id: + cp_result = await db.execute( + select(Person).where(Person.id == reverse_conn.person_id) + ) + cp_person = cp_result.scalar_one_or_none() + if cp_person: + await detach_umbral_contact(cp_person) + + # Delete both connection rows + if our_conn: + await db.delete(our_conn) + if reverse_conn: + await db.delete(reverse_conn) + + +@router.put("/{person_id}/unlink", response_model=PersonResponse) +async def unlink_person( + person_id: int = Path(ge=1, le=2147483647), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """Unlink an umbral contact — convert to standard contact and sever the connection.""" + result = await db.execute( + select(Person).where(Person.id == person_id, Person.user_id == current_user.id) + ) + person = result.scalar_one_or_none() + + if not person: + raise HTTPException(status_code=404, detail="Person not found") + if not person.is_umbral_contact: + raise HTTPException(status_code=400, detail="Person is not an umbral contact") + + await _sever_connection(db, current_user, person) + await detach_umbral_contact(person) + + await db.commit() + await db.refresh(person) + return person + + @router.delete("/{person_id}", status_code=204) async def delete_person( person_id: int = Path(ge=1, le=2147483647), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): - """Delete a person.""" + """Delete a person. If umbral contact, also severs the bidirectional connection.""" result = await db.execute( select(Person).where(Person.id == person_id, Person.user_id == current_user.id) ) @@ -231,19 +297,8 @@ async def delete_person( if not person: raise HTTPException(status_code=404, detail="Person not found") - # Auto-detach umbral contact before delete if person.is_umbral_contact: - await detach_umbral_contact(person) - # Null out the current user's connection person_id so the connection survives - conn_result = await db.execute( - select(UserConnection).where( - UserConnection.user_id == current_user.id, - UserConnection.person_id == person.id, - ) - ) - conn = conn_result.scalar_one_or_none() - if conn: - conn.person_id = None + await _sever_connection(db, current_user, person) await db.delete(person) await db.commit() diff --git a/frontend/src/components/people/PeoplePage.tsx b/frontend/src/components/people/PeoplePage.tsx index aa1cd7a..6b8ec70 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, Ghost, ChevronDown } from 'lucide-react'; +import { Plus, Users, Star, Cake, Phone, Mail, MapPin, Tag, Building2, Briefcase, AlignLeft, Ghost, ChevronDown, Unlink } from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { format, parseISO, differenceInYears } from 'date-fns'; @@ -331,6 +331,7 @@ export default function PeoplePage() { }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['people'] }); + queryClient.invalidateQueries({ queryKey: ['connections'] }); queryClient.invalidateQueries({ queryKey: ['dashboard'] }); queryClient.invalidateQueries({ queryKey: ['upcoming'] }); toast.success('Person deleted'); @@ -341,6 +342,22 @@ export default function PeoplePage() { }, }); + // Unlink umbral contact mutation + const unlinkMutation = useMutation({ + mutationFn: async (personId: number) => { + const { data } = await api.put(`/people/${personId}/unlink`); + return data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['people'] }); + queryClient.invalidateQueries({ queryKey: ['connections'] }); + toast.success('Contact unlinked — converted to standard contact'); + }, + onError: (error) => { + toast.error(getErrorMessage(error, 'Failed to unlink contact')); + }, + }); + // Toggle favourite mutation const toggleFavouriteMutation = useMutation({ mutationFn: async (person: Person) => { @@ -474,6 +491,20 @@ export default function PeoplePage() { isFavourite={selectedPerson?.is_favourite} onToggleFavourite={() => selectedPerson && toggleFavouriteMutation.mutate(selectedPerson)} favouriteLabel="favourite" + extraActions={(p) => + p.is_umbral_contact ? ( + + ) : null + } /> ); diff --git a/frontend/src/components/shared/EntityDetailPanel.tsx b/frontend/src/components/shared/EntityDetailPanel.tsx index 2a2781d..d0ac938 100644 --- a/frontend/src/components/shared/EntityDetailPanel.tsx +++ b/frontend/src/components/shared/EntityDetailPanel.tsx @@ -27,6 +27,7 @@ interface EntityDetailPanelProps { isFavourite?: boolean; onToggleFavourite?: () => void; favouriteLabel?: string; + extraActions?: (item: T) => React.ReactNode; } export function EntityDetailPanel({ @@ -42,6 +43,7 @@ export function EntityDetailPanel({ isFavourite, onToggleFavourite, favouriteLabel = 'favourite', + extraActions, }: EntityDetailPanelProps) { const { confirming, handleClick: handleDelete } = useConfirmAction(onDelete); @@ -134,7 +136,10 @@ export function EntityDetailPanel({ {/* Footer */}
- {formatUpdatedAt(getUpdatedAt(item))} +
+ {formatUpdatedAt(getUpdatedAt(item))} + {extraActions?.(item)} +
); @@ -441,6 +450,7 @@ export default function PeoplePage() { // Shared field key mapping (panel key -> shared_fields key) const sharedKeyMap: Record = { + preferred_name: 'preferred_name', email: 'email', phone: 'phone', mobile: 'mobile', @@ -519,7 +529,17 @@ export default function PeoplePage() { Unlink - ) : null + ) : ( + + ) } /> ); @@ -760,6 +780,12 @@ export default function PeoplePage() { open={showConnectionSearch} onOpenChange={setShowConnectionSearch} /> + + { if (!open) setLinkPersonId(null); }} + personId={linkPersonId ?? undefined} + />
); } diff --git a/frontend/src/hooks/useConnections.ts b/frontend/src/hooks/useConnections.ts index d92eec4..61ea79e 100644 --- a/frontend/src/hooks/useConnections.ts +++ b/frontend/src/hooks/useConnections.ts @@ -39,9 +39,10 @@ export function useConnections() { }); const sendRequestMutation = useMutation({ - mutationFn: async (umbralName: string) => { + mutationFn: async (params: { umbralName: string; personId?: number }) => { const { data } = await api.post('/connections/request', { - umbral_name: umbralName, + umbral_name: params.umbralName, + ...(params.personId != null && { person_id: params.personId }), }); return data; }, From 568a78e64b9bad05b39d20230e9988a19408fa9b Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Wed, 4 Mar 2026 08:44:54 +0800 Subject: [PATCH 14/37] Show connected user's latest update time on umbral contacts Override updated_at on PersonResponse with max(Person, User, Settings) so the panel reflects when the connected user last changed their profile. Co-Authored-By: Claude Opus 4.6 --- backend/app/routers/people.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/backend/app/routers/people.py b/backend/app/routers/people.py index 5724f9c..5c031ad 100644 --- a/backend/app/routers/people.py +++ b/backend/app/routers/people.py @@ -102,13 +102,21 @@ async def get_people( ) # umbral_name is always visible (public identity), not a shareable field shared_profiles[uid]["umbral_name"] = user.umbral_name + shared_profiles[uid]["_updated_at"] = max( + user.updated_at, user_settings.updated_at + ) # 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] + profile = shared_profiles[p.linked_user_id] + resp.shared_fields = profile + # Show the latest update time across local record and connected user's profile + remote_updated = profile.pop("_updated_at", None) + if remote_updated and remote_updated > p.updated_at: + resp.updated_at = remote_updated responses.append(resp) return responses @@ -179,6 +187,10 @@ async def get_person( linked_user, linked_settings, conn.sharing_overrides if conn else None ) resp.shared_fields["umbral_name"] = linked_user.umbral_name + # Show the latest update time across local record and connected user's profile + remote_updated = max(linked_user.updated_at, linked_settings.updated_at) + if remote_updated > person.updated_at: + resp.updated_at = remote_updated return resp From b554ba71514879ba3d4cdce9b3397b82d30b7c54 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Wed, 4 Mar 2026 09:05:46 +0800 Subject: [PATCH 15/37] Fix QA: IntegrityError handling, dict mutation, birthday sync, None guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - C-01: Wrap accept flow flush/commit in IntegrityError handling (409) - C-02: Use separate remote_timestamps dict instead of pop() on shared profile - W-01: Add birthday sync in Link conversion path (existing person → umbral) - W-02: Add None guard on max(updated_at) comparison in get_person Co-Authored-By: Claude Opus 4.6 --- backend/app/routers/connections.py | 23 +++++++++++++++++++---- backend/app/routers/people.py | 20 ++++++++++---------- 2 files changed, 29 insertions(+), 14 deletions(-) diff --git a/backend/app/routers/connections.py b/backend/app/routers/connections.py index f63107e..430c015 100644 --- a/backend/app/routers/connections.py +++ b/backend/app/routers/connections.py @@ -9,7 +9,7 @@ Security: - Audit logging for all connection events """ import asyncio -from datetime import datetime, timedelta, timezone +from datetime import date as date_type, datetime, timedelta, timezone from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Path, Query, Request from sqlalchemy import delete, select, func, and_, update @@ -415,7 +415,7 @@ async def respond_to_request( select(Person).where(Person.id == request_person_id) ) existing_person = existing_result.scalar_one_or_none() - # Re-validate at accept time (C-01, W-01): ownership must match sender, + # Re-validate at accept time: ownership must match sender, # and must not already be umbral (prevents double-conversion races) if existing_person and existing_person.user_id == sender_id and not existing_person.is_umbral_contact: # Convert existing standard contact to umbral @@ -433,6 +433,13 @@ async def respond_to_request( existing_person.address = receiver_shared.get("address") or existing_person.address existing_person.company = receiver_shared.get("company") or existing_person.company existing_person.job_title = receiver_shared.get("job_title") or existing_person.job_title + # Sync birthday from shared profile + birthday_str = receiver_shared.get("birthday") + if birthday_str: + try: + existing_person.birthday = date_type.fromisoformat(birthday_str) + except (ValueError, TypeError): + pass # Recompute display name full = ((first_name or '') + ' ' + (last_name or '')).strip() existing_person.name = full or current_user.umbral_name @@ -444,7 +451,11 @@ async def respond_to_request( ) db.add(person_for_sender) - await db.flush() # populate person IDs + try: + await db.flush() # populate person IDs + except IntegrityError: + await db.rollback() + raise HTTPException(status_code=409, detail="Connection already exists") # Create bidirectional connections conn_a = UserConnection( @@ -460,7 +471,11 @@ async def respond_to_request( db.add(conn_a) db.add(conn_b) - await db.flush() # populate conn_a.id for source_id + try: + await db.flush() # populate conn_a.id for source_id + except IntegrityError: + await db.rollback() + raise HTTPException(status_code=409, detail="Connection already exists") # Notification to sender receiver_display = (receiver_settings.preferred_name if receiver_settings else None) or current_user.umbral_name diff --git a/backend/app/routers/people.py b/backend/app/routers/people.py index 5c031ad..6420e52 100644 --- a/backend/app/routers/people.py +++ b/backend/app/routers/people.py @@ -91,8 +91,9 @@ async def get_people( for c in conns_result.scalars().all() } - # Build shared profiles + # Build shared profiles and track remote timestamps separately shared_profiles: dict[int, dict] = {} + remote_timestamps: dict[int, datetime] = {} for uid in linked_user_ids: user = users_by_id.get(uid) user_settings = settings_by_user.get(uid) @@ -102,19 +103,17 @@ async def get_people( ) # umbral_name is always visible (public identity), not a shareable field shared_profiles[uid]["umbral_name"] = user.umbral_name - shared_profiles[uid]["_updated_at"] = max( - user.updated_at, user_settings.updated_at - ) + if user.updated_at and user_settings.updated_at: + remote_timestamps[uid] = max(user.updated_at, user_settings.updated_at) # 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: - profile = shared_profiles[p.linked_user_id] - resp.shared_fields = profile + resp.shared_fields = shared_profiles[p.linked_user_id] # Show the latest update time across local record and connected user's profile - remote_updated = profile.pop("_updated_at", None) + remote_updated = remote_timestamps.get(p.linked_user_id) if remote_updated and remote_updated > p.updated_at: resp.updated_at = remote_updated responses.append(resp) @@ -188,9 +187,10 @@ async def get_person( ) resp.shared_fields["umbral_name"] = linked_user.umbral_name # Show the latest update time across local record and connected user's profile - remote_updated = max(linked_user.updated_at, linked_settings.updated_at) - if remote_updated > person.updated_at: - resp.updated_at = remote_updated + if linked_user.updated_at and linked_settings.updated_at: + remote_updated = max(linked_user.updated_at, linked_settings.updated_at) + if remote_updated > person.updated_at: + resp.updated_at = remote_updated return resp From 14a77f0f11134c3f85250fae14a0e182f53450d7 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Wed, 4 Mar 2026 09:43:22 +0800 Subject: [PATCH 16/37] Fix stale UI after accept: await invalidations, dismiss toasts - Await all query invalidations in respondMutation/cancelMutation onSuccess so UI has fresh data before mutation promise resolves - Use deterministic toast IDs (connection-request-{id}) for Sonner toasts so they can be dismissed from any accept surface - Dismiss stale connection toasts in respondMutation.onSuccess - Fix handleCancel setting resolved before API call completes Co-Authored-By: Claude Opus 4.6 --- .../connections/ConnectionRequestCard.tsx | 3 +-- .../notifications/NotificationToaster.tsx | 10 +++++--- frontend/src/hooks/useConnections.ts | 25 ++++++++++++------- 3 files changed, 23 insertions(+), 15 deletions(-) diff --git a/frontend/src/components/connections/ConnectionRequestCard.tsx b/frontend/src/components/connections/ConnectionRequestCard.tsx index d4bebdf..7ac76a8 100644 --- a/frontend/src/components/connections/ConnectionRequestCard.tsx +++ b/frontend/src/components/connections/ConnectionRequestCard.tsx @@ -38,12 +38,11 @@ export default function ConnectionRequestCard({ request, direction }: Connection }; const handleCancel = async () => { - setResolved(true); try { await cancelRequest(request.id); + setResolved(true); toast.success('Request cancelled'); } catch (err) { - setResolved(false); toast.error(getErrorMessage(err, 'Failed to cancel request')); } }; diff --git a/frontend/src/components/notifications/NotificationToaster.tsx b/frontend/src/components/notifications/NotificationToaster.tsx index 3e02d51..22e52dd 100644 --- a/frontend/src/components/notifications/NotificationToaster.tsx +++ b/frontend/src/components/notifications/NotificationToaster.tsx @@ -19,9 +19,11 @@ export default function NotificationToaster() { await api.put(`/connections/requests/${requestId}/respond`, { action }); toast.dismiss(toastId); toast.success(action === 'accept' ? 'Connection accepted' : 'Request declined'); - queryClient.invalidateQueries({ queryKey: ['connections'] }); - queryClient.invalidateQueries({ queryKey: ['people'] }); - queryClient.invalidateQueries({ queryKey: ['notifications'] }); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['connections'] }), + queryClient.invalidateQueries({ queryKey: ['people'] }), + queryClient.invalidateQueries({ queryKey: ['notifications'] }), + ]); } catch (err) { toast.dismiss(toastId); toast.error(getErrorMessage(err, 'Failed to respond to request')); @@ -109,7 +111,7 @@ export default function NotificationToaster() {
), - { duration: 30000 }, + { id: `connection-request-${requestId}`, duration: 30000 }, ); }; diff --git a/frontend/src/hooks/useConnections.ts b/frontend/src/hooks/useConnections.ts index 61ea79e..65e31b5 100644 --- a/frontend/src/hooks/useConnections.ts +++ b/frontend/src/hooks/useConnections.ts @@ -1,4 +1,5 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; import api from '@/lib/api'; import type { Connection, ConnectionRequest, UmbralSearchResponse } from '@/types'; @@ -46,8 +47,8 @@ export function useConnections() { }); return data; }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['connections'] }); + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ['connections'] }); }, }); @@ -56,10 +57,14 @@ export function useConnections() { 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'] }); + onSuccess: async (_, variables) => { + // Dismiss any lingering Sonner toast for this request + toast.dismiss(`connection-request-${variables.requestId}`); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['connections'] }), + queryClient.invalidateQueries({ queryKey: ['people'] }), + queryClient.invalidateQueries({ queryKey: ['notifications'] }), + ]); }, }); @@ -68,9 +73,11 @@ export function useConnections() { const { data } = await api.put(`/connections/requests/${requestId}/cancel`); return data; }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['connections'] }); - queryClient.invalidateQueries({ queryKey: ['notifications'] }); + onSuccess: async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['connections'] }), + queryClient.invalidateQueries({ queryKey: ['notifications'] }), + ]); }, }); From f854987f53a96efbb4a4138fac71566159b7a991 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Wed, 4 Mar 2026 10:02:50 +0800 Subject: [PATCH 17/37] Fix ~60s delay before accept buttons work on new requests Root cause: ['connections', 'incoming'] query has no polling, so pendingRequestIds stays empty until manually refetched. Accept buttons on NotificationsPage are gated by this set. - NotificationsPage: detect connection_request notifications not in pendingRequestIds and eagerly invalidate incoming requests query - NotificationToaster: invalidate ['connections', 'incoming'] when connection_request notifications arrive, so accept buttons are ready on all surfaces immediately Co-Authored-By: Claude Opus 4.6 --- .../notifications/NotificationToaster.tsx | 6 ++++++ .../notifications/NotificationsPage.tsx | 15 ++++++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/notifications/NotificationToaster.tsx b/frontend/src/components/notifications/NotificationToaster.tsx index 22e52dd..11597f0 100644 --- a/frontend/src/components/notifications/NotificationToaster.tsx +++ b/frontend/src/components/notifications/NotificationToaster.tsx @@ -62,6 +62,12 @@ export default function NotificationToaster() { maxSeenIdRef.current = maxCurrent; } + // Eagerly refresh incoming requests when connection_request notifications arrive + // so accept buttons work immediately on NotificationsPage / PeoplePage + if (newNotifications.some((n) => n.type === 'connection_request')) { + queryClient.invalidateQueries({ queryKey: ['connections', 'incoming'] }); + } + // Show toasts newNotifications.forEach((notification) => { if (notification.type === 'connection_request' && notification.source_id) { diff --git a/frontend/src/components/notifications/NotificationsPage.tsx b/frontend/src/components/notifications/NotificationsPage.tsx index 35533b2..2ab5f9c 100644 --- a/frontend/src/components/notifications/NotificationsPage.tsx +++ b/frontend/src/components/notifications/NotificationsPage.tsx @@ -1,5 +1,6 @@ -import { useState, useMemo } from 'react'; +import { useState, useMemo, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; +import { useQueryClient } from '@tanstack/react-query'; import { Bell, Check, CheckCheck, Trash2, UserPlus, Info, AlertCircle, X, Loader2 } from 'lucide-react'; import { formatDistanceToNow } from 'date-fns'; import { toast } from 'sonner'; @@ -31,6 +32,7 @@ export default function NotificationsPage() { } = useNotifications(); const { incomingRequests, respond, isResponding } = useConnections(); + const queryClient = useQueryClient(); const navigate = useNavigate(); const [filter, setFilter] = useState('all'); @@ -40,6 +42,17 @@ export default function NotificationsPage() { [incomingRequests], ); + // Eagerly fetch incoming requests when notifications contain connection_request + // entries whose source_id isn't in pendingRequestIds yet (stale connections data) + useEffect(() => { + const hasMissing = notifications.some( + (n) => n.type === 'connection_request' && n.source_id && !n.is_read && !pendingRequestIds.has(n.source_id), + ); + if (hasMissing) { + queryClient.invalidateQueries({ queryKey: ['connections', 'incoming'] }); + } + }, [notifications, pendingRequestIds, queryClient]); + const filtered = useMemo(() => { if (filter === 'unread') return notifications.filter((n) => !n.is_read); return notifications; From 5828bbf8e214c79f40ee04cd72eedab6d12bff5d Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Wed, 4 Mar 2026 10:15:23 +0800 Subject: [PATCH 18/37] Fix toast accept showing false error when invalidations fail Separate API error handling from query invalidation in NotificationToaster's handleConnectionRespond so a failed refetch doesn't surface as "Failed to respond" after a successful accept. Co-Authored-By: Claude Opus 4.6 --- .../notifications/NotificationToaster.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/notifications/NotificationToaster.tsx b/frontend/src/components/notifications/NotificationToaster.tsx index 11597f0..9ac1f36 100644 --- a/frontend/src/components/notifications/NotificationToaster.tsx +++ b/frontend/src/components/notifications/NotificationToaster.tsx @@ -17,17 +17,17 @@ export default function NotificationToaster() { async (requestId: number, action: 'accept' | 'reject', toastId: string | number) => { try { await api.put(`/connections/requests/${requestId}/respond`, { action }); - toast.dismiss(toastId); - toast.success(action === 'accept' ? 'Connection accepted' : 'Request declined'); - await Promise.all([ - queryClient.invalidateQueries({ queryKey: ['connections'] }), - queryClient.invalidateQueries({ queryKey: ['people'] }), - queryClient.invalidateQueries({ queryKey: ['notifications'] }), - ]); } catch (err) { toast.dismiss(toastId); toast.error(getErrorMessage(err, 'Failed to respond to request')); + return; } + toast.dismiss(toastId); + toast.success(action === 'accept' ? 'Connection accepted' : 'Request declined'); + // Fire-and-forget — invalidation errors should not surface as "Failed to respond" + queryClient.invalidateQueries({ queryKey: ['connections'] }); + queryClient.invalidateQueries({ queryKey: ['people'] }); + queryClient.invalidateQueries({ queryKey: ['notifications'] }); }, [queryClient], ); From 60281caa64bf35d495917c4fa040951bdc80d1e9 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Wed, 4 Mar 2026 10:30:35 +0800 Subject: [PATCH 19/37] Unify toast accept path with notification center via useConnections Toast now uses the same respond() from useConnections hook instead of raw api.put, making both accept surfaces share identical code. Also made respondMutation.onSuccess fire-and-forget to prevent invalidation errors from surfacing as mutation failures. Co-Authored-By: Claude Opus 4.6 --- .../notifications/NotificationToaster.tsx | 17 +++++++---------- frontend/src/hooks/useConnections.ts | 11 +++++------ 2 files changed, 12 insertions(+), 16 deletions(-) diff --git a/frontend/src/components/notifications/NotificationToaster.tsx b/frontend/src/components/notifications/NotificationToaster.tsx index 9ac1f36..0dff878 100644 --- a/frontend/src/components/notifications/NotificationToaster.tsx +++ b/frontend/src/components/notifications/NotificationToaster.tsx @@ -3,11 +3,13 @@ import { toast } from 'sonner'; import { Check, X, Bell, UserPlus } from 'lucide-react'; import { useQueryClient } from '@tanstack/react-query'; import { useNotifications } from '@/hooks/useNotifications'; -import api, { getErrorMessage } from '@/lib/api'; +import { useConnections } from '@/hooks/useConnections'; +import { getErrorMessage } from '@/lib/api'; import type { AppNotification } from '@/types'; export default function NotificationToaster() { const { notifications, unreadCount } = useNotifications(); + const { respond } = useConnections(); const queryClient = useQueryClient(); const maxSeenIdRef = useRef(0); const initializedRef = useRef(false); @@ -16,20 +18,15 @@ export default function NotificationToaster() { const handleConnectionRespond = useCallback( async (requestId: number, action: 'accept' | 'reject', toastId: string | number) => { try { - await api.put(`/connections/requests/${requestId}/respond`, { action }); + await respond({ requestId, action }); + // onSuccess in useConnections already dismisses the custom toast and invalidates caches + toast.success(action === 'accept' ? 'Connection accepted' : 'Request declined'); } catch (err) { toast.dismiss(toastId); toast.error(getErrorMessage(err, 'Failed to respond to request')); - return; } - toast.dismiss(toastId); - toast.success(action === 'accept' ? 'Connection accepted' : 'Request declined'); - // Fire-and-forget — invalidation errors should not surface as "Failed to respond" - queryClient.invalidateQueries({ queryKey: ['connections'] }); - queryClient.invalidateQueries({ queryKey: ['people'] }); - queryClient.invalidateQueries({ queryKey: ['notifications'] }); }, - [queryClient], + [respond], ); // Track unread count changes to force-refetch the list diff --git a/frontend/src/hooks/useConnections.ts b/frontend/src/hooks/useConnections.ts index 65e31b5..919abed 100644 --- a/frontend/src/hooks/useConnections.ts +++ b/frontend/src/hooks/useConnections.ts @@ -57,14 +57,13 @@ export function useConnections() { const { data } = await api.put(`/connections/requests/${requestId}/respond`, { action }); return data; }, - onSuccess: async (_, variables) => { + onSuccess: (_, variables) => { // Dismiss any lingering Sonner toast for this request toast.dismiss(`connection-request-${variables.requestId}`); - await Promise.all([ - queryClient.invalidateQueries({ queryKey: ['connections'] }), - queryClient.invalidateQueries({ queryKey: ['people'] }), - queryClient.invalidateQueries({ queryKey: ['notifications'] }), - ]); + // Fire-and-forget — errors here must not surface as mutation failures + queryClient.invalidateQueries({ queryKey: ['connections'] }); + queryClient.invalidateQueries({ queryKey: ['people'] }); + queryClient.invalidateQueries({ queryKey: ['notifications'] }); }, }); From dff36f30c8f02c6ecbc4322ae2b0a418d9645689 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Wed, 4 Mar 2026 19:38:29 +0800 Subject: [PATCH 20/37] Fix toast accept button: instant feedback + double-click guard Toast buttons are static Sonner elements that can't bind React state, so clicks had no visual feedback and allowed duplicate mutations. Now: dismiss custom toast immediately, show loading toast, and block concurrent clicks via a ref-based Set guard. Co-Authored-By: Claude Opus 4.6 --- .../notifications/NotificationToaster.tsx | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/notifications/NotificationToaster.tsx b/frontend/src/components/notifications/NotificationToaster.tsx index 0dff878..4ebd234 100644 --- a/frontend/src/components/notifications/NotificationToaster.tsx +++ b/frontend/src/components/notifications/NotificationToaster.tsx @@ -14,16 +14,30 @@ export default function NotificationToaster() { const maxSeenIdRef = useRef(0); const initializedRef = useRef(false); const prevUnreadRef = useRef(0); + // Track in-flight request IDs so repeated clicks are blocked + const respondingRef = useRef>(new Set()); const handleConnectionRespond = useCallback( async (requestId: number, action: 'accept' | 'reject', toastId: string | number) => { + // Guard against double-clicks (Sonner toasts are static, no disabled prop) + if (respondingRef.current.has(requestId)) return; + respondingRef.current.add(requestId); + + // Immediately dismiss the custom toast and show a loading indicator + toast.dismiss(toastId); + const loadingId = toast.loading( + action === 'accept' ? 'Accepting connection…' : 'Declining request…', + ); + try { await respond({ requestId, action }); - // onSuccess in useConnections already dismisses the custom toast and invalidates caches + toast.dismiss(loadingId); toast.success(action === 'accept' ? 'Connection accepted' : 'Request declined'); } catch (err) { - toast.dismiss(toastId); + toast.dismiss(loadingId); toast.error(getErrorMessage(err, 'Failed to respond to request')); + } finally { + respondingRef.current.delete(requestId); } }, [respond], From 6b59d61bf39ccd7c82fb41c3ad88d96a536aa448 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Wed, 4 Mar 2026 21:22:51 +0800 Subject: [PATCH 21/37] Fix connection mutation delays: make all onSuccess fire-and-forget sendRequestMutation and cancelMutation were awaiting query invalidation in onSuccess, which blocked mutateAsync until 3+ network refetches completed (~1s+ delay). This caused noticeable lag on send/link/cancel operations. Now all mutations use sync fire-and-forget invalidation, matching respondMutation's pattern. Co-Authored-By: Claude Opus 4.6 --- frontend/src/hooks/useConnections.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/frontend/src/hooks/useConnections.ts b/frontend/src/hooks/useConnections.ts index 919abed..76ecf72 100644 --- a/frontend/src/hooks/useConnections.ts +++ b/frontend/src/hooks/useConnections.ts @@ -47,8 +47,9 @@ export function useConnections() { }); return data; }, - onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: ['connections'] }); + onSuccess: () => { + // Fire-and-forget — don't block mutateAsync on query refetches + queryClient.invalidateQueries({ queryKey: ['connections'] }); }, }); @@ -72,11 +73,10 @@ export function useConnections() { const { data } = await api.put(`/connections/requests/${requestId}/cancel`); return data; }, - onSuccess: async () => { - await Promise.all([ - queryClient.invalidateQueries({ queryKey: ['connections'] }), - queryClient.invalidateQueries({ queryKey: ['notifications'] }), - ]); + onSuccess: () => { + // Fire-and-forget — don't block mutateAsync on query refetches + queryClient.invalidateQueries({ queryKey: ['connections'] }); + queryClient.invalidateQueries({ queryKey: ['notifications'] }); }, }); From 2fb41e0cf4b2624687918a7cc59dfad637726c1f Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Thu, 5 Mar 2026 16:54:28 +0800 Subject: [PATCH 22/37] Fix toast accept stale closure + harden backend error responses Toast accept button captured a stale `respond` reference from the Sonner closure. Use respondRef pattern so clicks always dispatch through the current mutation. Backend respond endpoint now catches unhandled exceptions and returns proper JSON with detail field instead of plain-text 500s. Co-Authored-By: Claude Opus 4.6 --- backend/app/routers/connections.py | 20 +++++++++++++++++++ .../notifications/NotificationToaster.tsx | 7 +++++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/backend/app/routers/connections.py b/backend/app/routers/connections.py index 430c015..2f90b3b 100644 --- a/backend/app/routers/connections.py +++ b/backend/app/routers/connections.py @@ -9,6 +9,7 @@ Security: - Audit logging for all connection events """ import asyncio +import logging from datetime import date as date_type, datetime, timedelta, timezone from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Path, Query, Request @@ -49,6 +50,7 @@ from app.services.connection import ( from app.services.notification import create_notification router = APIRouter() +logger = logging.getLogger(__name__) # ── Helpers ────────────────────────────────────────────────────────── @@ -355,6 +357,24 @@ async def respond_to_request( current_user: User = Depends(get_current_user), ): """Accept or reject a connection request. Atomic via UPDATE...WHERE status='pending'.""" + try: + return await _respond_to_request_inner(body, request, background_tasks, request_id, db, current_user) + except HTTPException: + raise + except Exception: + # get_db middleware auto-rollbacks on unhandled exceptions + logger.exception("Unhandled error in respond_to_request (request_id=%s, user=%s)", request_id, current_user.id) + raise HTTPException(status_code=500, detail=f"Internal server error while processing connection response (request {request_id})") + + +async def _respond_to_request_inner( + body: RespondRequest, + request: Request, + background_tasks: BackgroundTasks, + request_id: int, + db: AsyncSession, + current_user: User, +) -> RespondAcceptResponse | RespondRejectResponse: now = datetime.now() # Atomic update — only succeeds if status is still 'pending' and receiver is current user diff --git a/frontend/src/components/notifications/NotificationToaster.tsx b/frontend/src/components/notifications/NotificationToaster.tsx index 4ebd234..53f6d0c 100644 --- a/frontend/src/components/notifications/NotificationToaster.tsx +++ b/frontend/src/components/notifications/NotificationToaster.tsx @@ -16,6 +16,9 @@ export default function NotificationToaster() { const prevUnreadRef = useRef(0); // Track in-flight request IDs so repeated clicks are blocked const respondingRef = useRef>(new Set()); + // Always call the latest respond — Sonner toasts capture closures at creation time + const respondRef = useRef(respond); + respondRef.current = respond; const handleConnectionRespond = useCallback( async (requestId: number, action: 'accept' | 'reject', toastId: string | number) => { @@ -30,7 +33,7 @@ export default function NotificationToaster() { ); try { - await respond({ requestId, action }); + await respondRef.current({ requestId, action }); toast.dismiss(loadingId); toast.success(action === 'accept' ? 'Connection accepted' : 'Request declined'); } catch (err) { @@ -40,7 +43,7 @@ export default function NotificationToaster() { respondingRef.current.delete(requestId); } }, - [respond], + [], ); // Track unread count changes to force-refetch the list From 2139ea80774b765eb2332e6077a78fa67e425c46 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Thu, 5 Mar 2026 17:37:21 +0800 Subject: [PATCH 23/37] Fix connection accept: stale cache, hidden button, and false 409 error - incomingQuery: staleTime:0 + refetchOnMount:'always' so pending requests are always fresh when components mount (was inheriting 5-min global staleTime, causing empty pendingRequestIds on nav) - NotificationsPage: show Accept button while incoming data loads (was hidden during async gap); disable with spinner until ready - Both toast and page: treat 409 as success ("already accepted") instead of showing error (fixes race when both fire respond) Co-Authored-By: Claude Opus 4.6 --- .../notifications/NotificationToaster.tsx | 10 ++++++++-- .../notifications/NotificationsPage.tsx | 19 +++++++++++++------ frontend/src/hooks/useConnections.ts | 3 +++ 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/notifications/NotificationToaster.tsx b/frontend/src/components/notifications/NotificationToaster.tsx index 53f6d0c..89f2ac6 100644 --- a/frontend/src/components/notifications/NotificationToaster.tsx +++ b/frontend/src/components/notifications/NotificationToaster.tsx @@ -36,9 +36,15 @@ export default function NotificationToaster() { await respondRef.current({ requestId, action }); toast.dismiss(loadingId); toast.success(action === 'accept' ? 'Connection accepted' : 'Request declined'); - } catch (err) { + } catch (err: any) { toast.dismiss(loadingId); - toast.error(getErrorMessage(err, 'Failed to respond to request')); + // 409 means the request was already resolved (e.g. accepted via notification center) + const status = err?.response?.status; + if (status === 409) { + toast.success(action === 'accept' ? 'Connection already accepted' : 'Request already resolved'); + } else { + toast.error(getErrorMessage(err, 'Failed to respond to request')); + } } finally { respondingRef.current.delete(requestId); } diff --git a/frontend/src/components/notifications/NotificationsPage.tsx b/frontend/src/components/notifications/NotificationsPage.tsx index 2ab5f9c..6ea9c09 100644 --- a/frontend/src/components/notifications/NotificationsPage.tsx +++ b/frontend/src/components/notifications/NotificationsPage.tsx @@ -31,7 +31,7 @@ export default function NotificationsPage() { deleteNotification, } = useNotifications(); - const { incomingRequests, respond, isResponding } = useConnections(); + const { incomingRequests, respond, isResponding, isLoadingIncoming } = useConnections(); const queryClient = useQueryClient(); const navigate = useNavigate(); const [filter, setFilter] = useState('all'); @@ -93,7 +93,13 @@ export default function NotificationsPage() { } toast.success(action === 'accept' ? 'Connection accepted' : 'Request declined'); } catch (err) { - toast.error(getErrorMessage(err, 'Failed to respond')); + // 409 means the request was already resolved (e.g. accepted via toast) + const status = (err as any)?.response?.status; + if (status === 409) { + toast.success(action === 'accept' ? 'Connection already accepted' : 'Request already resolved'); + } else { + toast.error(getErrorMessage(err, 'Failed to respond')); + } } }; @@ -218,22 +224,23 @@ export default function NotificationsPage() { {/* Connection request actions (inline) */} {notification.type === 'connection_request' && notification.source_id && - pendingRequestIds.has(notification.source_id) && ( + !notification.is_read && + (isLoadingIncoming || pendingRequestIds.has(notification.source_id)) && (