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 <noreply@anthropic.com>
This commit is contained in:
Kyle 2026-03-04 02:10:16 +08:00
parent 2a21809066
commit 3d22568b9c
39 changed files with 2832 additions and 38 deletions

View File

@ -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")

View File

@ -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")

View File

@ -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")

View File

@ -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")

View File

@ -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")

View File

@ -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

View File

@ -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("/")

View File

@ -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",
]

View File

@ -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")

View File

@ -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())

View File

@ -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())

View File

@ -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."""

View File

@ -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)

View File

@ -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")

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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,
)

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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;

View File

@ -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() {
<Route path="projects/:id" element={<ProjectDetail />} />
<Route path="people" element={<PeoplePage />} />
<Route path="locations" element={<LocationsPage />} />
<Route path="notifications" element={<NotificationsPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route
path="admin/*"

View File

@ -167,6 +167,9 @@ export default function IAMPage() {
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
Username
</th>
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
Umbral Name
</th>
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
Email
</th>
@ -209,6 +212,9 @@ export default function IAMPage() {
)}
>
<td className="px-5 py-3 font-medium">{user.username}</td>
<td className="px-5 py-3 text-muted-foreground text-xs">
{user.umbral_name || user.username}
</td>
<td className="px-5 py-3 text-muted-foreground text-xs">
{user.email || '—'}
</td>

View File

@ -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 (
<div
className={cn(
'flex items-center gap-3 rounded-lg border border-border p-3 transition-all duration-300',
resolved && 'opacity-0 translate-y-2'
)}
>
{/* Avatar */}
<div className="h-9 w-9 rounded-full bg-violet-500/15 flex items-center justify-center shrink-0">
<span className="text-sm font-medium text-violet-400">
{displayName.charAt(0).toUpperCase()}
</span>
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{displayName}</p>
<p className="text-xs text-muted-foreground">
wants to connect · {formatDistanceToNow(new Date(request.created_at), { addSuffix: true })}
</p>
</div>
{/* Actions */}
<div className="flex items-center gap-1.5 shrink-0">
<Button
size="sm"
onClick={() => handleRespond('accept')}
disabled={isResponding}
className="gap-1"
>
{isResponding ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />}
Accept
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleRespond('reject')}
disabled={isResponding}
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
</div>
);
}

View File

@ -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<boolean | null>(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 (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<UserPlus className="h-5 w-5 text-violet-400" />
Find Umbra User
</DialogTitle>
<DialogDescription>
Search for a user by their umbral name to send a connection request.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 pt-2">
<div className="space-y-2">
<Label htmlFor="umbral_search">Umbral Name</Label>
<div className="flex gap-2">
<Input
id="umbral_search"
placeholder="Enter umbral name..."
value={umbralName}
onChange={(e) => {
setUmbralName(e.target.value);
setFound(null);
setSent(false);
}}
onKeyDown={(e) => { if (e.key === 'Enter') handleSearch(); }}
maxLength={50}
/>
<Button
onClick={handleSearch}
disabled={!umbralName.trim() || isSearching}
size="sm"
>
{isSearching ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Search className="h-4 w-4" />
)}
</Button>
</div>
</div>
{found === false && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<AlertCircle className="h-4 w-4" />
User not found
</div>
)}
{found === true && !sent && (
<div className="flex items-center justify-between rounded-lg border border-border p-3">
<div className="flex items-center gap-2">
<div className="h-8 w-8 rounded-full bg-violet-500/15 flex items-center justify-center">
<span className="text-sm font-medium text-violet-400">
{umbralName.charAt(0).toUpperCase()}
</span>
</div>
<span className="text-sm font-medium">{umbralName}</span>
</div>
<Button
onClick={handleSend}
disabled={isSending}
size="sm"
className="gap-1.5"
>
{isSending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<UserPlus className="h-3.5 w-3.5" />
)}
Send Request
</Button>
</div>
)}
{sent && (
<div className="flex items-center gap-2 text-sm text-green-400">
<CheckCircle className="h-4 w-4" />
Connection request sent
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -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
<Lock className="h-5 w-5 shrink-0" />
{showExpanded && <span>Lock</span>}
</button>
<NavLink
to="/notifications"
onClick={mobileOpen ? onMobileClose : undefined}
className={navLinkClass}
>
<div className="relative shrink-0">
<Bell className="h-5 w-5" />
{unreadCount > 0 && !showExpanded && (
<div className="absolute -top-1 -right-1 h-2 w-2 rounded-full bg-red-500" />
)}
</div>
{showExpanded && (
<span className="flex items-center gap-2">
Notifications
{unreadCount > 0 && (
<span className="text-[10px] bg-red-500/15 text-red-400 rounded-full px-1.5 py-0.5 tabular-nums">
{unreadCount}
</span>
)}
</span>
)}
</NavLink>
{isAdmin && (
<NavLink
to="/admin"

View File

@ -0,0 +1,203 @@
import { useState, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { Bell, Check, CheckCheck, Trash2, UserPlus, Info, AlertCircle } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
import { useNotifications } from '@/hooks/useNotifications';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { ListSkeleton } from '@/components/ui/skeleton';
import type { AppNotification } from '@/types';
const typeIcons: Record<string, { icon: typeof Bell; color: string }> = {
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<Filter>('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 (
<div className="flex flex-col h-full animate-fade-in">
{/* Page header */}
<div className="border-b bg-card px-6 h-16 flex items-center justify-between shrink-0">
<div className="flex items-center gap-3">
<Bell className="h-5 w-5 text-accent" aria-hidden="true" />
<h1 className="text-xl font-semibold font-heading">Notifications</h1>
</div>
<div className="flex items-center gap-2">
{/* Filter */}
<div className="flex items-center rounded-md border border-border overflow-hidden">
{(['all', 'unread'] as const).map((f) => (
<button
key={f}
onClick={() => setFilter(f)}
className={cn(
'px-3 py-1.5 text-xs font-medium transition-colors capitalize',
filter === f
? 'bg-accent/15 text-accent'
: 'text-muted-foreground hover:text-foreground hover:bg-card-elevated'
)}
>
{f}
{f === 'unread' && unreadCount > 0 && (
<span className="ml-1.5 text-[10px] bg-red-500/15 text-red-400 rounded-full px-1.5 py-0.5 tabular-nums">
{unreadCount}
</span>
)}
</button>
))}
</div>
{unreadCount > 0 && (
<Button
variant="ghost"
size="sm"
onClick={handleMarkAllRead}
className="text-xs gap-1.5"
>
<CheckCheck className="h-3.5 w-3.5" />
Mark all read
</Button>
)}
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto">
{isLoading ? (
<div className="p-6">
<ListSkeleton rows={5} />
</div>
) : filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-3 py-20">
<Bell className="h-10 w-10 opacity-30" />
<p className="text-sm">
{filter === 'unread' ? 'No unread notifications' : 'No notifications'}
</p>
</div>
) : (
<div className="divide-y divide-border">
{filtered.map((notification) => {
const iconConfig = getIcon(notification.type);
const Icon = iconConfig.icon;
return (
<div
key={notification.id}
onClick={() => 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 */}
<div className={cn('mt-0.5 shrink-0', iconConfig.color)}>
<Icon className="h-4 w-4" />
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-start gap-2">
<div className="flex-1 min-w-0">
<p className={cn(
'text-sm truncate',
!notification.is_read ? 'font-medium text-foreground' : 'text-muted-foreground'
)}>
{notification.title}
</p>
{notification.message && (
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
{notification.message}
</p>
)}
</div>
{/* Unread dot */}
{!notification.is_read && (
<div className="h-2 w-2 rounded-full bg-accent shrink-0 mt-1.5" />
)}
</div>
</div>
{/* Timestamp + actions */}
<div className="flex items-center gap-1.5 shrink-0">
<span className="text-[11px] text-muted-foreground tabular-nums">
{formatDistanceToNow(new Date(notification.created_at), { addSuffix: true })}
</span>
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
{!notification.is_read && (
<button
onClick={() => handleMarkRead(notification.id)}
className="p-1 rounded hover:bg-accent/10 text-muted-foreground hover:text-accent transition-colors"
title="Mark as read"
>
<Check className="h-3.5 w-3.5" />
</button>
)}
<button
onClick={() => handleDelete(notification.id)}
className="p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
title="Delete"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
);
}

View File

@ -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<Person>[] = [
{getInitials(initialsName)}
</div>
<span className="font-medium truncate">{p.nickname || p.name}</span>
{p.is_umbral_contact && (
<Ghost className="h-3.5 w-3.5 text-violet-400 shrink-0" aria-label="Umbral contact" />
)}
</div>
);
},
@ -193,9 +197,13 @@ export default function PeoplePage() {
const [editingPerson, setEditingPerson] = useState<Person | null>(null);
const [activeFilters, setActiveFilters] = useState<string[]>([]);
const [showPinned, setShowPinned] = useState(true);
const [showUmbralOnly, setShowUmbralOnly] = useState(false);
const [search, setSearch] = useState('');
const [sortKey, setSortKey] = useState<string>('name');
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
const [showConnectionSearch, setShowConnectionSearch] = useState(false);
const [showAddDropdown, setShowAddDropdown] = useState(false);
const addDropdownRef = useRef<HTMLDivElement>(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)}
</div>
<div className="min-w-0">
<h3 className="font-heading text-lg font-semibold truncate">{p.name}</h3>
<div className="flex items-center gap-2">
<h3 className="font-heading text-lg font-semibold truncate">{p.name}</h3>
{p.is_umbral_contact && (
<Ghost className="h-4 w-4 text-violet-400 shrink-0" />
)}
</div>
{p.category && (
<span className="text-xs text-muted-foreground">{p.category}</span>
)}
@ -372,8 +401,49 @@ export default function PeoplePage() {
);
};
// Panel getValue
// Shared field key mapping (panel key -> shared_fields key)
const sharedKeyMap: Record<string, string> = {
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 = () => (
<EntityDetailPanel<Person>
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),
},
]}
/>
</div>
<Button onClick={() => setShowForm(true)} size="sm" aria-label="Add person">
<Plus className="mr-2 h-4 w-4" />
Add Person
</Button>
<div className="relative" ref={addDropdownRef}>
<div className="flex">
<Button
onClick={() => setShowForm(true)}
size="sm"
aria-label="Add person"
className="rounded-r-none"
>
<Plus className="mr-2 h-4 w-4" />
Add Person
</Button>
<Button
size="sm"
onClick={() => setShowAddDropdown((p) => !p)}
aria-label="More add options"
className="rounded-l-none border-l border-background/20 px-1.5"
>
<ChevronDown className="h-3.5 w-3.5" />
</Button>
</div>
{showAddDropdown && (
<div className="absolute right-0 top-full mt-1 w-44 rounded-md border border-border bg-card shadow-lg z-50 py-1">
<button
className="w-full text-left px-3 py-1.5 text-sm hover:bg-card-elevated transition-colors"
onClick={() => { setShowAddDropdown(false); setShowForm(true); }}
>
Standard Contact
</button>
<button
className="w-full text-left px-3 py-1.5 text-sm hover:bg-card-elevated transition-colors flex items-center gap-2"
onClick={() => { setShowAddDropdown(false); setShowConnectionSearch(true); }}
>
<Ghost className="h-3.5 w-3.5 text-violet-400" />
Umbra Contact
</button>
</div>
)}
</div>
</div>
<div className="flex-1 overflow-hidden flex flex-col">
@ -558,6 +669,11 @@ export default function PeoplePage() {
onClose={handleCloseForm}
/>
)}
<ConnectionSearch
open={showConnectionSearch}
onOpenChange={setShowConnectionSearch}
/>
</div>
);
}

View File

@ -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 */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="birthday">Birthday</Label>
<Label htmlFor="birthday" className="flex items-center gap-1">
Birthday
{isShared('birthday') && <Lock className="h-3 w-3 text-violet-400" />}
</Label>
{isShared('birthday') ? (
<Input
id="birthday"
value={formData.birthday}
disabled
className="opacity-70 cursor-not-allowed"
/>
) : (
<DatePicker
variant="input"
id="birthday"
value={formData.birthday}
onChange={(v) => set('birthday', v)}
/>
)}
</div>
<div className="space-y-2">
<Label htmlFor="age">Age</Label>
@ -200,65 +221,102 @@ export default function PersonForm({ person, categories, onClose }: PersonFormPr
{/* Row 6: Mobile + Email */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="mobile">Mobile</Label>
<Label htmlFor="mobile" className="flex items-center gap-1">
Mobile
{isShared('mobile') && <Lock className="h-3 w-3 text-violet-400" />}
</Label>
<Input
id="mobile"
type="tel"
value={formData.mobile}
onChange={(e) => set('mobile', e.target.value)}
disabled={isShared('mobile')}
className={isShared('mobile') ? 'opacity-70 cursor-not-allowed' : ''}
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Label htmlFor="email" className="flex items-center gap-1">
Email
{isShared('email') && <Lock className="h-3 w-3 text-violet-400" />}
</Label>
<Input
id="email"
type="email"
value={formData.email}
onChange={(e) => set('email', e.target.value)}
disabled={isShared('email')}
className={isShared('email') ? 'opacity-70 cursor-not-allowed' : ''}
/>
</div>
</div>
{/* Row 7: Phone */}
<div className="space-y-2">
<Label htmlFor="phone">Phone</Label>
<Label htmlFor="phone" className="flex items-center gap-1">
Phone
{isShared('phone') && <Lock className="h-3 w-3 text-violet-400" />}
</Label>
<Input
id="phone"
type="tel"
value={formData.phone}
onChange={(e) => set('phone', e.target.value)}
placeholder="Landline / work number"
disabled={isShared('phone')}
className={isShared('phone') ? 'opacity-70 cursor-not-allowed' : ''}
/>
</div>
{/* Row 8: Address */}
<div className="space-y-2">
<Label htmlFor="address">Address</Label>
<LocationPicker
id="address"
value={formData.address}
onChange={(val) => set('address', val)}
onSelect={(result) => set('address', result.address || result.name)}
placeholder="Search or enter address..."
/>
<Label htmlFor="address" className="flex items-center gap-1">
Address
{isShared('address') && <Lock className="h-3 w-3 text-violet-400" />}
</Label>
{isShared('address') ? (
<Input
id="address"
value={formData.address}
disabled
className="opacity-70 cursor-not-allowed"
/>
) : (
<LocationPicker
id="address"
value={formData.address}
onChange={(val) => set('address', val)}
onSelect={(result) => set('address', result.address || result.name)}
placeholder="Search or enter address..."
/>
)}
</div>
{/* Row 9: Company + Job Title */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="company">Company</Label>
<Label htmlFor="company" className="flex items-center gap-1">
Company
{isShared('company') && <Lock className="h-3 w-3 text-violet-400" />}
</Label>
<Input
id="company"
value={formData.company}
onChange={(e) => set('company', e.target.value)}
disabled={isShared('company')}
className={isShared('company') ? 'opacity-70 cursor-not-allowed' : ''}
/>
</div>
<div className="space-y-2">
<Label htmlFor="job_title">Job Title</Label>
<Label htmlFor="job_title" className="flex items-center gap-1">
Job Title
{isShared('job_title') && <Lock className="h-3 w-3 text-violet-400" />}
</Label>
<Input
id="job_title"
value={formData.job_title}
onChange={(e) => set('job_title', e.target.value)}
disabled={isShared('job_title')}
className={isShared('job_title') ? 'opacity-70 cursor-not-allowed' : ''}
/>
</div>
</div>

View File

@ -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<number | string>(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'); }}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="settings_phone">Phone</Label>
<Input
id="settings_phone"
type="tel"
placeholder="Phone number"
value={settingsPhone}
onChange={(e) => setSettingsPhone(e.target.value)}
onBlur={() => handleSettingsFieldSave('phone', settingsPhone)}
onKeyDown={(e) => { if (e.key === 'Enter') handleSettingsFieldSave('phone', settingsPhone); }}
maxLength={50}
/>
</div>
<div className="space-y-2">
<Label htmlFor="settings_mobile">Mobile</Label>
<Input
id="settings_mobile"
type="tel"
placeholder="Mobile number"
value={settingsMobile}
onChange={(e) => setSettingsMobile(e.target.value)}
onBlur={() => handleSettingsFieldSave('mobile', settingsMobile)}
onKeyDown={(e) => { if (e.key === 'Enter') handleSettingsFieldSave('mobile', settingsMobile); }}
maxLength={50}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="settings_address">Address</Label>
<Input
id="settings_address"
type="text"
placeholder="Your address"
value={settingsAddress}
onChange={(e) => setSettingsAddress(e.target.value)}
onBlur={() => handleSettingsFieldSave('address', settingsAddress)}
onKeyDown={(e) => { if (e.key === 'Enter') handleSettingsFieldSave('address', settingsAddress); }}
maxLength={2000}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="settings_company">Company</Label>
<Input
id="settings_company"
type="text"
placeholder="Company name"
value={settingsCompany}
onChange={(e) => setSettingsCompany(e.target.value)}
onBlur={() => handleSettingsFieldSave('company', settingsCompany)}
onKeyDown={(e) => { if (e.key === 'Enter') handleSettingsFieldSave('company', settingsCompany); }}
maxLength={255}
/>
</div>
<div className="space-y-2">
<Label htmlFor="settings_job_title">Job Title</Label>
<Input
id="settings_job_title"
type="text"
placeholder="Your role"
value={settingsJobTitle}
onChange={(e) => setSettingsJobTitle(e.target.value)}
onBlur={() => handleSettingsFieldSave('job_title', settingsJobTitle)}
onKeyDown={(e) => { if (e.key === 'Enter') handleSettingsFieldSave('job_title', settingsJobTitle); }}
maxLength={255}
/>
</div>
</div>
</CardContent>
</Card>
@ -586,9 +712,77 @@ export default function SettingsPage() {
</div>
{/* ── Right column: Security, Authentication, Integrations ── */}
{/* ── Right column: Social, Security, Authentication, Integrations ── */}
<div className="space-y-6">
{/* Social */}
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<div className="p-1.5 rounded-md bg-violet-500/10">
<Ghost className="h-4 w-4 text-violet-400" aria-hidden="true" />
</div>
<div>
<CardTitle>Social</CardTitle>
<CardDescription>Manage your Umbra identity and connections</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Umbral Name</Label>
<div className="flex items-center gap-3">
<Input
value={profileQuery.data?.username ?? ''}
disabled
className="opacity-70 cursor-not-allowed"
/>
<CopyableField value={profileQuery.data?.username ?? ''} label="Umbral name" />
</div>
<p className="text-sm text-muted-foreground">
How other Umbra users find you
</p>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Accept Connections</Label>
<p className="text-sm text-muted-foreground">
Allow other users to find and connect with you
</p>
</div>
<Switch
checked={acceptConnections}
onCheckedChange={(checked) => handleSocialToggle('accept_connections', checked, setAcceptConnections)}
/>
</div>
<div className="border-t border-border pt-4 mt-4">
<p className="text-[11px] uppercase tracking-wider text-muted-foreground mb-3">
Sharing Defaults
</p>
<div className="grid grid-cols-2 gap-3">
{[
{ 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 }) => (
<div key={field} className="flex items-center justify-between">
<Label className="text-sm font-normal">{label}</Label>
<Switch
checked={state}
onCheckedChange={(checked) => handleSocialToggle(field, checked, setter)}
/>
</div>
))}
</div>
</div>
</CardContent>
</Card>
{/* Security (auto-lock) */}
<Card>
<CardHeader>

View File

@ -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<HTMLInputElement>(null);
@ -169,6 +177,22 @@ export default function CategoryFilterBar({
</span>
</button>
{/* Extra pinned filters (e.g. "Umbral") */}
{extraPinnedFilters.map((epf) => (
<button
key={epf.label}
type="button"
onClick={epf.onToggle}
aria-label={`Filter by ${epf.label}`}
className={pillBase}
style={epf.isActive ? activePillStyle : undefined}
>
<span className={epf.isActive ? '' : 'text-muted-foreground hover:text-foreground'}>
{epf.label}
</span>
</button>
))}
{/* Categories pill + expandable chips */}
{categories.length > 0 && (
<>

View File

@ -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<Connection[]>('/connections');
return data;
},
});
const incomingQuery = useQuery({
queryKey: ['connections', 'incoming'],
queryFn: async () => {
const { data } = await api.get<ConnectionRequest[]>('/connections/requests/incoming');
return data;
},
});
const outgoingQuery = useQuery({
queryKey: ['connections', 'outgoing'],
queryFn: async () => {
const { data } = await api.get<ConnectionRequest[]>('/connections/requests/outgoing');
return data;
},
});
const searchMutation = useMutation({
mutationFn: async (umbralName: string) => {
const { data } = await api.post<UmbralSearchResponse>('/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,
};
}

View File

@ -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<NotificationListResponse>('/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,
};
}

View File

@ -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<string, unknown> | 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<string, unknown> | 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;
}