Merge feature/user-connections into main
User connections system: search by umbral name, send/accept/reject/cancel requests, bidirectional Person records on accept, per-connection sharing overrides, in-app notification centre with toast popups, ntfy push integration. Includes QA fixes, pentest hardening, and contact sync fix. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
47645ec115
37
backend/alembic/versions/039_add_umbral_name_to_users.py
Normal file
37
backend/alembic/versions/039_add_umbral_name_to_users.py
Normal 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")
|
||||
@ -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")
|
||||
57
backend/alembic/versions/041_create_notifications_table.py
Normal file
57
backend/alembic/versions/041_create_notifications_table.py
Normal 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")
|
||||
109
backend/alembic/versions/042_create_connection_tables.py
Normal file
109
backend/alembic/versions/042_create_connection_tables.py
Normal 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")
|
||||
44
backend/alembic/versions/043_add_linked_fields_to_people.py
Normal file
44
backend/alembic/versions/043_add_linked_fields_to_people.py
Normal 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")
|
||||
45
backend/alembic/versions/044_add_notification_type_check.py
Normal file
45
backend/alembic/versions/044_add_notification_type_check.py
Normal file
@ -0,0 +1,45 @@
|
||||
"""Add CHECK constraint on notifications.type column.
|
||||
|
||||
Revision ID: 044
|
||||
Revises: 043
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "044"
|
||||
down_revision = "043"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
ALLOWED_TYPES = (
|
||||
"connection_request",
|
||||
"connection_accepted",
|
||||
"connection_rejected",
|
||||
"info",
|
||||
"warning",
|
||||
"reminder",
|
||||
"system",
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Defensive: ensure no existing rows violate the constraint
|
||||
conn = op.get_bind()
|
||||
placeholders = ", ".join(f"'{t}'" for t in ALLOWED_TYPES)
|
||||
bad = conn.execute(
|
||||
sa.text(f"SELECT COUNT(*) FROM notifications WHERE type NOT IN ({placeholders})")
|
||||
).scalar()
|
||||
if bad:
|
||||
raise RuntimeError(
|
||||
f"Cannot apply CHECK constraint: {bad} notification(s) have types outside the allowed list"
|
||||
)
|
||||
|
||||
op.create_check_constraint(
|
||||
"ck_notifications_type",
|
||||
"notifications",
|
||||
f"type IN ({placeholders})",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint("ck_notifications_type", "notifications", type_="check")
|
||||
28
backend/alembic/versions/045_add_share_name_fields.py
Normal file
28
backend/alembic/versions/045_add_share_name_fields.py
Normal file
@ -0,0 +1,28 @@
|
||||
"""Add share_first_name and share_last_name to settings.
|
||||
|
||||
Revision ID: 045
|
||||
Revises: 044
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "045"
|
||||
down_revision = "044"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"settings",
|
||||
sa.Column("share_first_name", sa.Boolean, nullable=False, server_default="false"),
|
||||
)
|
||||
op.add_column(
|
||||
"settings",
|
||||
sa.Column("share_last_name", sa.Boolean, nullable=False, server_default="false"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("settings", "share_last_name")
|
||||
op.drop_column("settings", "share_first_name")
|
||||
@ -0,0 +1,34 @@
|
||||
"""Add person_id to connection_requests
|
||||
|
||||
Revision ID: 046
|
||||
Revises: 045
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "046"
|
||||
down_revision = "045"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"connection_requests",
|
||||
sa.Column(
|
||||
"person_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("people.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_connection_requests_person_id",
|
||||
"connection_requests",
|
||||
["person_id"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_connection_requests_person_id", table_name="connection_requests")
|
||||
op.drop_column("connection_requests", "person_id")
|
||||
@ -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
|
||||
@ -25,6 +26,7 @@ from app.models.project import Project
|
||||
from app.models.ntfy_sent import NtfySent
|
||||
from app.models.totp_usage import TOTPUsage
|
||||
from app.models.session import UserSession
|
||||
from app.models.connection_request import ConnectionRequest
|
||||
from app.services.ntfy import send_ntfy_notification
|
||||
from app.services.ntfy_templates import (
|
||||
build_event_notification,
|
||||
@ -267,6 +269,37 @@ 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()
|
||||
|
||||
|
||||
async def _purge_resolved_requests(db: AsyncSession) -> None:
|
||||
"""Remove resolved connection requests after retention period.
|
||||
|
||||
Rejected/cancelled: 30 days. Accepted: 90 days (longer for audit trail).
|
||||
resolved_at must be set when changing status. NULL resolved_at rows are
|
||||
preserved (comparison with NULL yields NULL).
|
||||
"""
|
||||
reject_cutoff = datetime.now() - timedelta(days=30)
|
||||
accept_cutoff = datetime.now() - timedelta(days=90)
|
||||
await db.execute(
|
||||
delete(ConnectionRequest).where(
|
||||
ConnectionRequest.status.in_(["rejected", "cancelled"]),
|
||||
ConnectionRequest.resolved_at < reject_cutoff,
|
||||
)
|
||||
)
|
||||
await db.execute(
|
||||
delete(ConnectionRequest).where(
|
||||
ConnectionRequest.status == "accepted",
|
||||
ConnectionRequest.resolved_at < accept_cutoff,
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
# ── Entry point ───────────────────────────────────────────────────────────────
|
||||
|
||||
async def run_notification_dispatch() -> None:
|
||||
@ -308,6 +341,8 @@ 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)
|
||||
await _purge_resolved_requests(db)
|
||||
|
||||
except Exception:
|
||||
# Broad catch: job failure must never crash the scheduler or the app
|
||||
|
||||
@ -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("/")
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
36
backend/app/models/connection_request.py
Normal file
36
backend/app/models/connection_request.py
Normal file
@ -0,0 +1,36 @@
|
||||
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)
|
||||
person_id: Mapped[Optional[int]] = mapped_column(
|
||||
Integer, ForeignKey("people.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
|
||||
# Relationships with explicit foreign_keys to disambiguate
|
||||
sender: Mapped["User"] = relationship(foreign_keys=[sender_id], lazy="selectin")
|
||||
receiver: Mapped["User"] = relationship(foreign_keys=[receiver_id], lazy="selectin")
|
||||
36
backend/app/models/notification.py
Normal file
36
backend/app/models/notification.py
Normal file
@ -0,0 +1,36 @@
|
||||
from sqlalchemy import CheckConstraint, 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
|
||||
|
||||
# Active: connection_request, connection_accepted
|
||||
# Reserved: connection_rejected, info, warning, reminder, system
|
||||
_NOTIFICATION_TYPES = (
|
||||
"connection_request", "connection_accepted", "connection_rejected",
|
||||
"info", "warning", "reminder", "system",
|
||||
)
|
||||
|
||||
|
||||
class Notification(Base):
|
||||
__tablename__ = "notifications"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
f"type IN ({', '.join(repr(t) for t in _NOTIFICATION_TYPES)})",
|
||||
name="ck_notifications_type",
|
||||
),
|
||||
)
|
||||
|
||||
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())
|
||||
@ -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())
|
||||
|
||||
|
||||
@ -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,31 @@ 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_first_name: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
share_last_name: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
share_preferred_name: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true")
|
||||
share_email: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
share_phone: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
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."""
|
||||
|
||||
@ -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)
|
||||
|
||||
31
backend/app/models/user_connection.py
Normal file
31
backend/app/models/user_connection.py
Normal 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")
|
||||
@ -70,10 +70,21 @@ def _target_username_col(target_alias, audit_model):
|
||||
COALESCE: prefer the live username from the users table,
|
||||
fall back to the username stored in the audit detail JSON
|
||||
(survives user deletion since audit_log.target_user_id → SET NULL).
|
||||
Guard the JSONB cast with a CASE to avoid errors on non-JSON detail values.
|
||||
"""
|
||||
json_fallback = sa.case(
|
||||
(
|
||||
sa.and_(
|
||||
audit_model.detail.is_not(None),
|
||||
audit_model.detail.startswith("{"),
|
||||
),
|
||||
sa.cast(audit_model.detail, JSONB)["username"].as_string(),
|
||||
),
|
||||
else_=sa.null(),
|
||||
)
|
||||
return sa.func.coalesce(
|
||||
target_alias.username,
|
||||
sa.cast(audit_model.detail, JSONB)["username"].as_string(),
|
||||
json_fallback,
|
||||
).label("target_username")
|
||||
|
||||
|
||||
@ -170,9 +181,9 @@ async def get_user(
|
||||
)
|
||||
active_sessions = session_result.scalar_one()
|
||||
|
||||
# Fetch preferred_name from Settings
|
||||
# Fetch preferred_name from Settings (limit 1 defensive)
|
||||
settings_result = await db.execute(
|
||||
sa.select(Settings.preferred_name).where(Settings.user_id == user_id)
|
||||
sa.select(Settings.preferred_name).where(Settings.user_id == user_id).limit(1)
|
||||
)
|
||||
preferred_name = settings_result.scalar_one_or_none()
|
||||
|
||||
@ -181,6 +192,8 @@ async def get_user(
|
||||
active_sessions=active_sessions,
|
||||
preferred_name=preferred_name,
|
||||
date_of_birth=user.date_of_birth,
|
||||
must_change_password=user.must_change_password,
|
||||
locked_until=user.locked_until,
|
||||
)
|
||||
|
||||
|
||||
@ -209,6 +222,7 @@ async def create_user(
|
||||
|
||||
new_user = User(
|
||||
username=data.username,
|
||||
umbral_name=data.username,
|
||||
password_hash=hash_password(data.password),
|
||||
role=data.role,
|
||||
email=email,
|
||||
@ -241,6 +255,10 @@ async def create_user(
|
||||
return UserDetailResponse(
|
||||
**UserListItem.model_validate(new_user).model_dump(exclude={"active_sessions"}),
|
||||
active_sessions=0,
|
||||
preferred_name=data.preferred_name,
|
||||
date_of_birth=None,
|
||||
must_change_password=new_user.must_change_password,
|
||||
locked_until=new_user.locked_until,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -288,6 +288,7 @@ async def setup(
|
||||
password_hash = hash_password(data.password)
|
||||
new_user = User(
|
||||
username=data.username,
|
||||
umbral_name=data.username,
|
||||
password_hash=password_hash,
|
||||
role="admin",
|
||||
last_password_change_at=datetime.now(),
|
||||
@ -440,7 +441,7 @@ async def register(
|
||||
select(User).where(User.username == data.username)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="Registration could not be completed. Please try a different username.")
|
||||
raise HTTPException(status_code=400, detail="Registration could not be completed. Please check your details and try again.")
|
||||
|
||||
# Check email uniqueness (generic error to prevent enumeration)
|
||||
if data.email:
|
||||
@ -454,6 +455,7 @@ async def register(
|
||||
# SEC-01: Explicit field assignment — never **data.model_dump()
|
||||
new_user = User(
|
||||
username=data.username,
|
||||
umbral_name=data.username,
|
||||
password_hash=password_hash,
|
||||
role="standard",
|
||||
email=data.email,
|
||||
@ -666,6 +668,15 @@ async def update_profile(
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="Email is already in use")
|
||||
|
||||
# Umbral name uniqueness check if changing
|
||||
if "umbral_name" in update_data and update_data["umbral_name"] != current_user.umbral_name:
|
||||
new_name = update_data["umbral_name"]
|
||||
existing = await db.execute(
|
||||
select(User).where(User.umbral_name == new_name, User.id != current_user.id)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="Umbral name is already taken")
|
||||
|
||||
# SEC-01: Explicit field assignment — only allowed profile fields
|
||||
if "first_name" in update_data:
|
||||
current_user.first_name = update_data["first_name"]
|
||||
@ -675,6 +686,8 @@ async def update_profile(
|
||||
current_user.email = update_data["email"]
|
||||
if "date_of_birth" in update_data:
|
||||
current_user.date_of_birth = update_data["date_of_birth"]
|
||||
if "umbral_name" in update_data:
|
||||
current_user.umbral_name = update_data["umbral_name"]
|
||||
|
||||
await log_audit_event(
|
||||
db, action="auth.profile_updated", actor_id=current_user.id,
|
||||
|
||||
836
backend/app/routers/connections.py
Normal file
836
backend/app/routers/connections.py
Normal file
@ -0,0 +1,836 @@
|
||||
"""
|
||||
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
|
||||
import logging
|
||||
from datetime import date as date_type, datetime, timedelta
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Path, Query, Request
|
||||
from sqlalchemy import delete, select, func, and_, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
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 (
|
||||
CancelResponse,
|
||||
ConnectionRequestResponse,
|
||||
ConnectionResponse,
|
||||
RespondAcceptResponse,
|
||||
RespondRejectResponse,
|
||||
RespondRequest,
|
||||
SendConnectionRequest,
|
||||
SharingOverrideUpdate,
|
||||
UmbralSearchRequest,
|
||||
UmbralSearchResponse,
|
||||
)
|
||||
from app.services.audit import get_client_ip, log_audit_event
|
||||
from app.services.connection import (
|
||||
NOTIF_TYPE_CONNECTION_ACCEPTED,
|
||||
NOTIF_TYPE_CONNECTION_REQUEST,
|
||||
SHAREABLE_FIELDS,
|
||||
create_person_from_connection,
|
||||
detach_umbral_contact,
|
||||
extract_ntfy_config,
|
||||
resolve_shared_profile,
|
||||
send_connection_ntfy,
|
||||
)
|
||||
from app.services.notification import create_notification
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── 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)
|
||||
|
||||
# Sender must have accept_connections enabled to search
|
||||
sender_settings = await _get_settings_for_user(db, current_user.id)
|
||||
if not sender_settings or not sender_settings.accept_connections:
|
||||
return UmbralSearchResponse(found=False)
|
||||
|
||||
# Don't find yourself
|
||||
if body.umbral_name == current_user.umbral_name:
|
||||
return UmbralSearchResponse(found=False)
|
||||
|
||||
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")
|
||||
|
||||
# Sender must have accept_connections enabled to participate
|
||||
sender_settings = await _get_settings_for_user(db, current_user.id)
|
||||
if not sender_settings or not sender_settings.accept_connections:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="You must enable 'Accept Connections' in your settings before sending requests",
|
||||
)
|
||||
|
||||
# Check accept_connections on target
|
||||
target_settings = await _get_settings_for_user(db, target.id)
|
||||
if not target_settings or not target_settings.accept_connections:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# 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")
|
||||
|
||||
# Validate person_id if provided (link existing standard contact)
|
||||
link_person_id = None
|
||||
if body.person_id is not None:
|
||||
person_result = await db.execute(
|
||||
select(Person).where(Person.id == body.person_id, Person.user_id == current_user.id)
|
||||
)
|
||||
link_person = person_result.scalar_one_or_none()
|
||||
if not link_person:
|
||||
raise HTTPException(status_code=400, detail="Person not found or not owned by you")
|
||||
if link_person.is_umbral_contact:
|
||||
raise HTTPException(status_code=400, detail="Person is already an umbral contact")
|
||||
link_person_id = body.person_id
|
||||
|
||||
# Create the request (IntegrityError guard for TOCTOU race on partial unique index)
|
||||
conn_request = ConnectionRequest(
|
||||
sender_id=current_user.id,
|
||||
receiver_id=target.id,
|
||||
person_id=link_person_id,
|
||||
)
|
||||
db.add(conn_request)
|
||||
try:
|
||||
await db.flush() # populate conn_request.id for source_id
|
||||
except IntegrityError:
|
||||
await db.rollback()
|
||||
raise HTTPException(status_code=409, detail="A pending request already exists")
|
||||
|
||||
# Create in-app notification for receiver (sender_settings already fetched above)
|
||||
sender_display = (sender_settings.preferred_name if sender_settings else None) or current_user.umbral_name
|
||||
|
||||
await create_notification(
|
||||
db,
|
||||
user_id=target.id,
|
||||
type=NOTIF_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=NOTIF_TYPE_CONNECTION_REQUEST,
|
||||
source_id=conn_request.id,
|
||||
)
|
||||
|
||||
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),
|
||||
)
|
||||
|
||||
# Extract ntfy config before commit (avoids detached SA object in background task)
|
||||
target_ntfy = extract_ntfy_config(target_settings) if target_settings else None
|
||||
|
||||
# Build response BEFORE commit — commit expires all ORM objects, and accessing
|
||||
# their attributes after commit triggers lazy loads → MissingGreenlet in async SA.
|
||||
response = _build_request_response(conn_request, current_user, sender_settings, target, target_settings)
|
||||
|
||||
await db.commit()
|
||||
|
||||
# ntfy push in background (non-blocking)
|
||||
background_tasks.add_task(
|
||||
send_connection_ntfy,
|
||||
target_ntfy,
|
||||
sender_display,
|
||||
"request_received",
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
# ── 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()
|
||||
|
||||
# Fetch current user's settings once, batch-fetch sender settings
|
||||
receiver_settings = await _get_settings_for_user(db, current_user.id)
|
||||
sender_ids = [req.sender_id for req in requests]
|
||||
if sender_ids:
|
||||
settings_result = await db.execute(select(Settings).where(Settings.user_id.in_(sender_ids)))
|
||||
settings_by_user = {s.user_id: s for s in settings_result.scalars().all()}
|
||||
else:
|
||||
settings_by_user = {}
|
||||
|
||||
responses = []
|
||||
for req in requests:
|
||||
sender_settings = settings_by_user.get(req.sender_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()
|
||||
|
||||
# Fetch current user's settings once, batch-fetch receiver settings
|
||||
sender_settings = await _get_settings_for_user(db, current_user.id)
|
||||
receiver_ids = [req.receiver_id for req in requests]
|
||||
if receiver_ids:
|
||||
settings_result = await db.execute(select(Settings).where(Settings.user_id.in_(receiver_ids)))
|
||||
settings_by_user = {s.user_id: s for s in settings_result.scalars().all()}
|
||||
else:
|
||||
settings_by_user = {}
|
||||
|
||||
responses = []
|
||||
for req in requests:
|
||||
receiver_settings = settings_by_user.get(req.receiver_id)
|
||||
responses.append(_build_request_response(req, current_user, sender_settings, req.receiver, receiver_settings))
|
||||
|
||||
return responses
|
||||
|
||||
|
||||
# ── PUT /requests/{id}/respond ──────────────────────────────────────
|
||||
|
||||
@router.put("/requests/{request_id}/respond", response_model=RespondAcceptResponse | RespondRejectResponse)
|
||||
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'."""
|
||||
try:
|
||||
return await _respond_to_request_inner(body, request, background_tasks, request_id, db, current_user)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
# get_db middleware auto-rollbacks on unhandled exceptions
|
||||
logger.exception("Unhandled error in respond_to_request (request_id=%s, user=%s)", request_id, current_user.id)
|
||||
raise HTTPException(status_code=500, detail=f"Internal server error while processing connection response (request {request_id})")
|
||||
|
||||
|
||||
async def _respond_to_request_inner(
|
||||
body: RespondRequest,
|
||||
request: Request,
|
||||
background_tasks: BackgroundTasks,
|
||||
request_id: int,
|
||||
db: AsyncSession,
|
||||
current_user: User,
|
||||
) -> RespondAcceptResponse | RespondRejectResponse:
|
||||
now = datetime.now()
|
||||
|
||||
# Atomic update — only succeeds if status is still 'pending' and receiver is current user
|
||||
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,
|
||||
ConnectionRequest.person_id,
|
||||
)
|
||||
)
|
||||
row = result.first()
|
||||
if not row:
|
||||
raise HTTPException(status_code=409, detail="Request not found or already resolved")
|
||||
|
||||
sender_id = row.sender_id
|
||||
request_person_id = row.person_id
|
||||
|
||||
if body.action == "accept":
|
||||
# Verify sender is still active
|
||||
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
|
||||
)
|
||||
db.add(person_for_receiver)
|
||||
|
||||
# Sender side: reuse existing Person if person_id was provided on the request
|
||||
person_for_sender = None
|
||||
if request_person_id:
|
||||
existing_result = await db.execute(
|
||||
select(Person).where(Person.id == request_person_id)
|
||||
)
|
||||
existing_person = existing_result.scalar_one_or_none()
|
||||
# Re-validate at accept time: ownership must match sender,
|
||||
# and must not already be umbral (prevents double-conversion races)
|
||||
if existing_person and existing_person.user_id == sender_id and not existing_person.is_umbral_contact:
|
||||
# Convert existing standard contact to umbral
|
||||
existing_person.linked_user_id = current_user.id
|
||||
existing_person.is_umbral_contact = True
|
||||
existing_person.category = "Umbral"
|
||||
# Update from shared profile
|
||||
first_name = receiver_shared.get("first_name") or receiver_shared.get("preferred_name") or current_user.umbral_name
|
||||
last_name = receiver_shared.get("last_name")
|
||||
existing_person.first_name = first_name
|
||||
existing_person.last_name = last_name
|
||||
existing_person.email = receiver_shared.get("email") or existing_person.email
|
||||
existing_person.phone = receiver_shared.get("phone") or existing_person.phone
|
||||
existing_person.mobile = receiver_shared.get("mobile") or existing_person.mobile
|
||||
existing_person.address = receiver_shared.get("address") or existing_person.address
|
||||
existing_person.company = receiver_shared.get("company") or existing_person.company
|
||||
existing_person.job_title = receiver_shared.get("job_title") or existing_person.job_title
|
||||
# Sync birthday from shared profile
|
||||
birthday_str = receiver_shared.get("birthday")
|
||||
if birthday_str:
|
||||
try:
|
||||
existing_person.birthday = date_type.fromisoformat(birthday_str)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
# Recompute display name
|
||||
full = ((first_name or '') + ' ' + (last_name or '')).strip()
|
||||
existing_person.name = full or current_user.umbral_name
|
||||
person_for_sender = existing_person
|
||||
|
||||
if person_for_sender is None:
|
||||
person_for_sender = create_person_from_connection(
|
||||
sender_id, current_user, receiver_settings, receiver_shared
|
||||
)
|
||||
db.add(person_for_sender)
|
||||
|
||||
try:
|
||||
await db.flush() # populate person IDs
|
||||
except IntegrityError:
|
||||
await db.rollback()
|
||||
raise HTTPException(status_code=409, detail="Connection already exists")
|
||||
|
||||
# Create bidirectional connections
|
||||
conn_a = UserConnection(
|
||||
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)
|
||||
|
||||
try:
|
||||
await db.flush() # populate conn_a.id for source_id
|
||||
except IntegrityError:
|
||||
await db.rollback()
|
||||
raise HTTPException(status_code=409, detail="Connection already exists")
|
||||
|
||||
# Notification to sender
|
||||
receiver_display = (receiver_settings.preferred_name if receiver_settings else None) or current_user.umbral_name
|
||||
await create_notification(
|
||||
db,
|
||||
user_id=sender_id,
|
||||
type=NOTIF_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=conn_b.id,
|
||||
)
|
||||
|
||||
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),
|
||||
)
|
||||
|
||||
# Extract ntfy config before commit (avoids detached SA object in background task)
|
||||
sender_ntfy = extract_ntfy_config(sender_settings) if sender_settings else None
|
||||
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError:
|
||||
await db.rollback()
|
||||
raise HTTPException(status_code=409, detail="Connection already exists")
|
||||
|
||||
# ntfy push in background
|
||||
background_tasks.add_task(
|
||||
send_connection_ntfy,
|
||||
sender_ntfy,
|
||||
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"}
|
||||
|
||||
|
||||
# ── PUT /requests/{id}/cancel ──────────────────────────────────────
|
||||
|
||||
@router.put("/requests/{request_id}/cancel", response_model=CancelResponse)
|
||||
async def cancel_request(
|
||||
request: Request,
|
||||
request_id: int = Path(ge=1, le=2147483647),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Cancel an outgoing connection request. Atomic via UPDATE...WHERE status='pending'."""
|
||||
now = datetime.now()
|
||||
|
||||
# Atomic update — only succeeds if sender is current user and status is still pending
|
||||
result = await db.execute(
|
||||
update(ConnectionRequest)
|
||||
.where(
|
||||
ConnectionRequest.id == request_id,
|
||||
ConnectionRequest.sender_id == current_user.id,
|
||||
ConnectionRequest.status == "pending",
|
||||
)
|
||||
.values(status="cancelled", resolved_at=now)
|
||||
.returning(ConnectionRequest.id, ConnectionRequest.receiver_id)
|
||||
)
|
||||
row = result.first()
|
||||
if not row:
|
||||
raise HTTPException(status_code=409, detail="Request not found or already resolved")
|
||||
|
||||
receiver_id = row.receiver_id
|
||||
|
||||
# Silent cleanup: remove the notification sent to the receiver
|
||||
await db.execute(
|
||||
delete(Notification).where(
|
||||
Notification.source_type == NOTIF_TYPE_CONNECTION_REQUEST,
|
||||
Notification.source_id == request_id,
|
||||
Notification.user_id == receiver_id,
|
||||
)
|
||||
)
|
||||
|
||||
# Look up receiver umbral_name for audit detail
|
||||
receiver_result = await db.execute(select(User.umbral_name).where(User.id == receiver_id))
|
||||
receiver_umbral_name = receiver_result.scalar_one_or_none() or "unknown"
|
||||
|
||||
await log_audit_event(
|
||||
db,
|
||||
action="connection.request_cancelled",
|
||||
actor_id=current_user.id,
|
||||
target_id=receiver_id,
|
||||
detail={"request_id": request_id, "receiver_umbral_name": receiver_umbral_name},
|
||||
ip=get_client_ip(request),
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
return {"message": "Connection request cancelled"}
|
||||
|
||||
|
||||
# ── 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()
|
||||
|
||||
# Batch-fetch settings for connected users
|
||||
connected_ids = [conn.connected_user_id for conn in connections]
|
||||
if connected_ids:
|
||||
settings_result = await db.execute(select(Settings).where(Settings.user_id.in_(connected_ids)))
|
||||
settings_by_user = {s.user_id: s for s in settings_result.scalars().all()}
|
||||
else:
|
||||
settings_by_user = {}
|
||||
|
||||
responses = []
|
||||
for conn in connections:
|
||||
conn_settings = settings_by_user.get(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."""
|
||||
# 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")
|
||||
|
||||
# Merge validated overrides — only SHAREABLE_FIELDS keys
|
||||
existing = dict(reverse_conn.sharing_overrides or {})
|
||||
update_data = body.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
if key in SHAREABLE_FIELDS:
|
||||
if value is None:
|
||||
existing.pop(key, None)
|
||||
else:
|
||||
existing[key] = value
|
||||
|
||||
reverse_conn.sharing_overrides = existing if existing 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
|
||||
143
backend/app/routers/notifications.py
Normal file
143
backend/app/routers/notifications.py
Normal 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),
|
||||
notification_type: str | None = Query(None, max_length=50, alias="type"),
|
||||
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 notification_type:
|
||||
base = base.where(Notification.type == notification_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
|
||||
@ -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 detach_umbral_contact, resolve_shared_profile
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@ -59,6 +63,62 @@ 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 and track remote timestamps separately
|
||||
shared_profiles: dict[int, dict] = {}
|
||||
remote_timestamps: dict[int, datetime] = {}
|
||||
for uid in linked_user_ids:
|
||||
user = users_by_id.get(uid)
|
||||
user_settings = settings_by_user.get(uid)
|
||||
if user and user_settings:
|
||||
shared_profiles[uid] = resolve_shared_profile(
|
||||
user, user_settings, overrides_by_user.get(uid)
|
||||
)
|
||||
# umbral_name is always visible (public identity), not a shareable field
|
||||
shared_profiles[uid]["umbral_name"] = user.umbral_name
|
||||
if user.updated_at and user_settings.updated_at:
|
||||
remote_timestamps[uid] = max(user.updated_at, user_settings.updated_at)
|
||||
|
||||
# Attach to response
|
||||
responses = []
|
||||
for p in people:
|
||||
resp = PersonResponse.model_validate(p)
|
||||
if p.linked_user_id and p.linked_user_id in shared_profiles:
|
||||
resp.shared_fields = shared_profiles[p.linked_user_id]
|
||||
# Show the latest update time across local record and connected user's profile
|
||||
remote_updated = remote_timestamps.get(p.linked_user_id)
|
||||
if remote_updated and remote_updated > p.updated_at:
|
||||
resp.updated_at = remote_updated
|
||||
responses.append(resp)
|
||||
return responses
|
||||
|
||||
return people
|
||||
|
||||
|
||||
@ -104,7 +164,34 @@ 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
|
||||
)
|
||||
resp.shared_fields["umbral_name"] = linked_user.umbral_name
|
||||
# Show the latest update time across local record and connected user's profile
|
||||
if linked_user.updated_at and linked_settings.updated_at:
|
||||
remote_updated = max(linked_user.updated_at, linked_settings.updated_at)
|
||||
if remote_updated > person.updated_at:
|
||||
resp.updated_at = remote_updated
|
||||
return resp
|
||||
|
||||
|
||||
@router.put("/{person_id}", response_model=PersonResponse)
|
||||
@ -144,13 +231,79 @@ async def update_person(
|
||||
return person
|
||||
|
||||
|
||||
async def _sever_connection(db: AsyncSession, current_user: User, person: Person) -> None:
|
||||
"""Remove bidirectional UserConnection rows and detach the counterpart's Person."""
|
||||
if not person.linked_user_id:
|
||||
return
|
||||
|
||||
counterpart_id = person.linked_user_id
|
||||
|
||||
# Find our connection
|
||||
conn_result = await db.execute(
|
||||
select(UserConnection).where(
|
||||
UserConnection.user_id == current_user.id,
|
||||
UserConnection.connected_user_id == counterpart_id,
|
||||
)
|
||||
)
|
||||
our_conn = conn_result.scalar_one_or_none()
|
||||
|
||||
# Find reverse connection
|
||||
reverse_result = await db.execute(
|
||||
select(UserConnection).where(
|
||||
UserConnection.user_id == counterpart_id,
|
||||
UserConnection.connected_user_id == current_user.id,
|
||||
)
|
||||
)
|
||||
reverse_conn = reverse_result.scalar_one_or_none()
|
||||
|
||||
# Detach the counterpart's Person record (if it exists)
|
||||
if reverse_conn and reverse_conn.person_id:
|
||||
cp_result = await db.execute(
|
||||
select(Person).where(Person.id == reverse_conn.person_id)
|
||||
)
|
||||
cp_person = cp_result.scalar_one_or_none()
|
||||
if cp_person:
|
||||
await detach_umbral_contact(cp_person)
|
||||
|
||||
# Delete both connection rows
|
||||
if our_conn:
|
||||
await db.delete(our_conn)
|
||||
if reverse_conn:
|
||||
await db.delete(reverse_conn)
|
||||
|
||||
|
||||
@router.put("/{person_id}/unlink", response_model=PersonResponse)
|
||||
async def unlink_person(
|
||||
person_id: int = Path(ge=1, le=2147483647),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Unlink an umbral contact — convert to standard contact and sever the connection."""
|
||||
result = await db.execute(
|
||||
select(Person).where(Person.id == person_id, Person.user_id == current_user.id)
|
||||
)
|
||||
person = result.scalar_one_or_none()
|
||||
|
||||
if not person:
|
||||
raise HTTPException(status_code=404, detail="Person not found")
|
||||
if not person.is_umbral_contact:
|
||||
raise HTTPException(status_code=400, detail="Person is not an umbral contact")
|
||||
|
||||
await _sever_connection(db, current_user, person)
|
||||
await detach_umbral_contact(person)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(person)
|
||||
return person
|
||||
|
||||
|
||||
@router.delete("/{person_id}", status_code=204)
|
||||
async def delete_person(
|
||||
person_id: int = Path(ge=1, le=2147483647),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Delete a person."""
|
||||
"""Delete a person. If umbral contact, also severs the bidirectional connection."""
|
||||
result = await db.execute(
|
||||
select(Person).where(Person.id == person_id, Person.user_id == current_user.id)
|
||||
)
|
||||
@ -159,6 +312,9 @@ async def delete_person(
|
||||
if not person:
|
||||
raise HTTPException(status_code=404, detail="Person not found")
|
||||
|
||||
if person.is_umbral_contact:
|
||||
await _sever_connection(db, current_user, person)
|
||||
|
||||
await db.delete(person)
|
||||
await db.commit()
|
||||
|
||||
|
||||
@ -39,6 +39,27 @@ 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_first_name=s.share_first_name,
|
||||
share_last_name=s.share_last_name,
|
||||
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,
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -172,6 +172,19 @@ class ProfileUpdate(BaseModel):
|
||||
last_name: str | None = Field(None, max_length=100)
|
||||
email: str | None = Field(None, max_length=254)
|
||||
date_of_birth: date | None = None
|
||||
umbral_name: str | None = Field(None, min_length=3, max_length=50)
|
||||
|
||||
@field_validator("umbral_name")
|
||||
@classmethod
|
||||
def validate_umbral_name(cls, v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return v
|
||||
import re
|
||||
if ' ' in v:
|
||||
raise ValueError('Umbral name must be a single word with no spaces')
|
||||
if not re.match(r'^[a-zA-Z0-9_.-]{3,50}$', v):
|
||||
raise ValueError('Umbral name must be 3-50 alphanumeric characters, dots, hyphens, or underscores')
|
||||
return v
|
||||
|
||||
@field_validator("email")
|
||||
@classmethod
|
||||
@ -199,6 +212,7 @@ class ProfileResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
username: str
|
||||
umbral_name: str
|
||||
email: str | None
|
||||
first_name: str | None
|
||||
last_name: str | None
|
||||
|
||||
91
backend/app/schemas/connection.py
Normal file
91
backend/app/schemas/connection.py
Normal file
@ -0,0 +1,91 @@
|
||||
"""
|
||||
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, dots, 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)
|
||||
person_id: Optional[int] = Field(default=None, ge=1, le=2147483647)
|
||||
|
||||
@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, dots, 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: Literal["pending", "accepted", "rejected", "cancelled"]
|
||||
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 RespondAcceptResponse(BaseModel):
|
||||
message: str
|
||||
connection_id: int
|
||||
|
||||
|
||||
class RespondRejectResponse(BaseModel):
|
||||
message: str
|
||||
|
||||
|
||||
class CancelResponse(BaseModel):
|
||||
message: str
|
||||
|
||||
|
||||
class SharingOverrideUpdate(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
first_name: Optional[bool] = None
|
||||
last_name: Optional[bool] = None
|
||||
preferred_name: Optional[bool] = None
|
||||
email: Optional[bool] = None
|
||||
phone: Optional[bool] = None
|
||||
mobile: Optional[bool] = None
|
||||
birthday: Optional[bool] = None
|
||||
address: Optional[bool] = None
|
||||
company: Optional[bool] = None
|
||||
job_title: Optional[bool] = None
|
||||
38
backend/app/schemas/notification.py
Normal file
38
backend/app/schemas/notification.py
Normal file
@ -0,0 +1,38 @@
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
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, json_schema_extra={"items": {"minimum": 1, "maximum": 2147483647}})
|
||||
|
||||
@field_validator('notification_ids')
|
||||
@classmethod
|
||||
def validate_ids(cls, v: list[int]) -> list[int]:
|
||||
for i in v:
|
||||
if i < 1 or i > 2147483647:
|
||||
raise ValueError('Each notification ID must be between 1 and 2147483647')
|
||||
return v
|
||||
@ -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
|
||||
|
||||
|
||||
@ -37,6 +37,31 @@ 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_first_name: Optional[bool] = None
|
||||
share_last_name: Optional[bool] = None
|
||||
share_preferred_name: Optional[bool] = None
|
||||
share_email: Optional[bool] = None
|
||||
share_phone: Optional[bool] = None
|
||||
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 +176,31 @@ 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_first_name: bool = False
|
||||
share_last_name: bool = False
|
||||
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
|
||||
|
||||
|
||||
208
backend/app/services/connection.py
Normal file
208
backend/app/services/connection.py
Normal file
@ -0,0 +1,208 @@
|
||||
"""
|
||||
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 datetime import date as date_type
|
||||
from types import SimpleNamespace
|
||||
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__)
|
||||
|
||||
# Notification type constants — keep in sync with notifications model CHECK constraint
|
||||
NOTIF_TYPE_CONNECTION_REQUEST = "connection_request"
|
||||
NOTIF_TYPE_CONNECTION_ACCEPTED = "connection_accepted"
|
||||
|
||||
# Single source of truth — only these fields can be shared via connections
|
||||
SHAREABLE_FIELDS = frozenset({
|
||||
"first_name", "last_name", "preferred_name", "email", "phone", "mobile",
|
||||
"birthday", "address", "company", "job_title",
|
||||
})
|
||||
|
||||
# Maps shareable field names to their Settings model column names
|
||||
_SETTINGS_FIELD_MAP = {
|
||||
"first_name": None, # first_name comes from User model
|
||||
"last_name": None, # last_name comes from User model
|
||||
"preferred_name": "preferred_name",
|
||||
"email": None, # email comes from User model
|
||||
"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 == "first_name":
|
||||
result[field] = user.first_name
|
||||
elif field == "last_name":
|
||||
result[field] = user.last_name
|
||||
elif field == "preferred_name":
|
||||
result[field] = settings.preferred_name
|
||||
elif field == "email":
|
||||
result[field] = user.email
|
||||
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 filter_to_shareable(result)
|
||||
|
||||
|
||||
def filter_to_shareable(profile: dict) -> dict:
|
||||
"""Strip any keys not in SHAREABLE_FIELDS. Defence-in-depth gate."""
|
||||
return {k: v for k, v in profile.items() if k in SHAREABLE_FIELDS}
|
||||
|
||||
|
||||
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 first_name, fall back to preferred_name, then umbral_name
|
||||
first_name = shared_profile.get("first_name") or shared_profile.get("preferred_name") or connected_user.umbral_name
|
||||
last_name = shared_profile.get("last_name")
|
||||
email = shared_profile.get("email")
|
||||
phone = shared_profile.get("phone")
|
||||
mobile = shared_profile.get("mobile")
|
||||
address = shared_profile.get("address")
|
||||
company = shared_profile.get("company")
|
||||
job_title = shared_profile.get("job_title")
|
||||
birthday_str = shared_profile.get("birthday")
|
||||
|
||||
birthday = None
|
||||
if birthday_str:
|
||||
try:
|
||||
birthday = date_type.fromisoformat(birthday_str)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Compute display name
|
||||
full = ((first_name or '') + ' ' + (last_name or '')).strip()
|
||||
display_name = full or connected_user.umbral_name
|
||||
|
||||
return Person(
|
||||
user_id=owner_user_id,
|
||||
name=display_name,
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
email=email,
|
||||
phone=phone,
|
||||
mobile=mobile,
|
||||
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.
|
||||
|
||||
Preserves all person data (name, email, phone, etc.) so the user does not
|
||||
lose contact information when a connection is severed. Only unlinks the
|
||||
umbral association — the person becomes a standard contact.
|
||||
"""
|
||||
person.linked_user_id = None
|
||||
person.is_umbral_contact = False
|
||||
person.category = None
|
||||
|
||||
def extract_ntfy_config(settings: Settings) -> dict | None:
|
||||
"""Extract ntfy config values into a plain dict safe for use after session close."""
|
||||
if not settings.ntfy_enabled or not settings.ntfy_connections_enabled:
|
||||
return None
|
||||
return {
|
||||
"ntfy_enabled": True,
|
||||
"ntfy_server_url": settings.ntfy_server_url,
|
||||
"ntfy_topic": settings.ntfy_topic,
|
||||
"ntfy_auth_token": settings.ntfy_auth_token,
|
||||
"user_id": settings.user_id,
|
||||
}
|
||||
|
||||
|
||||
async def send_connection_ntfy(
|
||||
ntfy_config: dict | None,
|
||||
sender_name: str,
|
||||
event_type: str,
|
||||
) -> None:
|
||||
"""Send ntfy push for connection events. Non-blocking with 3s timeout.
|
||||
|
||||
Accepts a plain dict (from extract_ntfy_config) to avoid accessing
|
||||
detached SQLAlchemy objects after session close.
|
||||
"""
|
||||
if not ntfy_config:
|
||||
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"])
|
||||
|
||||
# Build a settings-like object for send_ntfy_notification (avoids detached SA objects)
|
||||
settings_proxy = SimpleNamespace(**ntfy_config)
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
send_ntfy_notification(
|
||||
settings=settings_proxy,
|
||||
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", ntfy_config["user_id"])
|
||||
except Exception:
|
||||
logger.warning("ntfy connection push failed for user_id=%s", ntfy_config["user_id"])
|
||||
34
backend/app/services/notification.py
Normal file
34
backend/app/services/notification.py
Normal 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
|
||||
@ -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 (send) — exact match to avoid catching /requests/*
|
||||
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;
|
||||
|
||||
@ -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/*"
|
||||
|
||||
@ -30,6 +30,11 @@ const ACTION_TYPES = [
|
||||
'auth.setup_complete',
|
||||
'auth.registration',
|
||||
'auth.mfa_enforce_prompted',
|
||||
'connection.request_sent',
|
||||
'connection.request_cancelled',
|
||||
'connection.accepted',
|
||||
'connection.rejected',
|
||||
'connection.removed',
|
||||
];
|
||||
|
||||
function actionLabel(action: string): string {
|
||||
@ -44,7 +49,7 @@ export default function ConfigPage() {
|
||||
const [filterAction, setFilterAction] = useState<string>('');
|
||||
const PER_PAGE = 25;
|
||||
|
||||
const { data, isLoading } = useAuditLog(page, PER_PAGE, filterAction || undefined);
|
||||
const { data, isLoading, error } = useAuditLog(page, PER_PAGE, filterAction || undefined);
|
||||
|
||||
const totalPages = data ? Math.ceil(data.total / PER_PAGE) : 1;
|
||||
|
||||
@ -111,6 +116,11 @@ export default function ConfigPage() {
|
||||
<Skeleton key={i} className="h-10 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="px-5 pb-5">
|
||||
<p className="text-sm text-destructive">Failed to load audit log</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">{error.message}</p>
|
||||
</div>
|
||||
) : !data?.entries?.length ? (
|
||||
<p className="px-5 pb-5 text-sm text-muted-foreground">No audit entries found.</p>
|
||||
) : (
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -55,7 +55,7 @@ function MfaBadge({ enabled, pending }: { enabled: boolean; pending: boolean })
|
||||
}
|
||||
|
||||
export default function UserDetailSection({ userId, onClose }: UserDetailSectionProps) {
|
||||
const { data: user, isLoading } = useAdminUserDetail(userId);
|
||||
const { data: user, isLoading, error } = useAdminUserDetail(userId);
|
||||
const updateRole = useUpdateRole();
|
||||
|
||||
const handleRoleChange = async (newRole: UserRole) => {
|
||||
@ -89,6 +89,22 @@ export default function UserDetailSection({ userId, onClose }: UserDetailSection
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-destructive">Failed to load user details</p>
|
||||
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" onClick={onClose}>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">{error.message}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
|
||||
124
frontend/src/components/connections/ConnectionRequestCard.tsx
Normal file
124
frontend/src/components/connections/ConnectionRequestCard.tsx
Normal file
@ -0,0 +1,124 @@
|
||||
import { useState, useEffect } 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 axios from 'axios';
|
||||
import { getErrorMessage } from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ConnectionRequest } from '@/types';
|
||||
|
||||
interface ConnectionRequestCardProps {
|
||||
request: ConnectionRequest;
|
||||
direction: 'incoming' | 'outgoing';
|
||||
}
|
||||
|
||||
export default function ConnectionRequestCard({ request, direction }: ConnectionRequestCardProps) {
|
||||
const { respond, cancelRequest, isCancelling } = useConnections();
|
||||
const [isResponding, setIsResponding] = useState(false);
|
||||
const [resolved, setResolved] = useState(false);
|
||||
|
||||
// Clean up invisible DOM element after fade-out transition
|
||||
const [hidden, setHidden] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!resolved) return;
|
||||
const timer = setTimeout(() => setHidden(true), 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [resolved]);
|
||||
|
||||
if (hidden) return null;
|
||||
|
||||
const handleRespond = async (action: 'accept' | 'reject') => {
|
||||
setIsResponding(true);
|
||||
try {
|
||||
await respond({ requestId: request.id, action });
|
||||
setResolved(true);
|
||||
toast.success(action === 'accept' ? 'Connection accepted' : 'Request declined');
|
||||
} catch (err) {
|
||||
// 409 means the request was already resolved (e.g. accepted via toast or notification center)
|
||||
if (axios.isAxiosError(err) && err.response?.status === 409) {
|
||||
setResolved(true);
|
||||
toast.success(action === 'accept' ? 'Connection already accepted' : 'Request already resolved');
|
||||
} else {
|
||||
toast.error(getErrorMessage(err, 'Failed to respond'));
|
||||
}
|
||||
} finally {
|
||||
setIsResponding(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async () => {
|
||||
try {
|
||||
await cancelRequest(request.id);
|
||||
setResolved(true);
|
||||
toast.success('Request cancelled');
|
||||
} catch (err) {
|
||||
toast.error(getErrorMessage(err, 'Failed to cancel request'));
|
||||
}
|
||||
};
|
||||
|
||||
const isIncoming = direction === 'incoming';
|
||||
const displayName = isIncoming
|
||||
? request.sender_preferred_name || request.sender_umbral_name
|
||||
: request.receiver_preferred_name || request.receiver_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">
|
||||
{isIncoming ? 'wants to connect' : 'request pending'} · {formatDistanceToNow(new Date(request.created_at), { addSuffix: true })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{isIncoming ? (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCancel}
|
||||
disabled={isCancelling}
|
||||
className="text-muted-foreground hover:text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
{isCancelling ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <X className="h-3.5 w-3.5" />}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
178
frontend/src/components/connections/ConnectionSearch.tsx
Normal file
178
frontend/src/components/connections/ConnectionSearch.tsx
Normal file
@ -0,0 +1,178 @@
|
||||
import { useState } from 'react';
|
||||
import { Search, UserPlus, Loader2, AlertCircle, CheckCircle, Settings } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
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 { useSettings } from '@/hooks/useSettings';
|
||||
import axios from 'axios';
|
||||
import { getErrorMessage } from '@/lib/api';
|
||||
|
||||
interface ConnectionSearchProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
personId?: number;
|
||||
}
|
||||
|
||||
export default function ConnectionSearch({ open, onOpenChange, personId }: ConnectionSearchProps) {
|
||||
const { search, isSearching, sendRequest, isSending } = useConnections();
|
||||
const { settings, isLoading: isLoadingSettings } = useSettings();
|
||||
const navigate = useNavigate();
|
||||
const [umbralName, setUmbralName] = useState('');
|
||||
const [found, setFound] = useState<boolean | null>(null);
|
||||
const [sent, setSent] = useState(false);
|
||||
|
||||
const acceptConnectionsEnabled = settings?.accept_connections ?? false;
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (!umbralName.trim()) return;
|
||||
setFound(null);
|
||||
setSent(false);
|
||||
try {
|
||||
const result = await search(umbralName.trim());
|
||||
setFound(result.found);
|
||||
} catch (err) {
|
||||
if (axios.isAxiosError(err) && err.response?.status === 429) {
|
||||
toast.error('Too many searches — please wait a moment and try again');
|
||||
} else {
|
||||
setFound(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSend = async () => {
|
||||
try {
|
||||
await sendRequest({ umbralName: umbralName.trim(), personId });
|
||||
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>
|
||||
{personId
|
||||
? 'Search for an umbral user to link this contact to.'
|
||||
: 'Search for a user by their umbral name to send a connection request.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 pt-2">
|
||||
{isLoadingSettings ? (
|
||||
<div className="flex justify-center py-6"><Loader2 className="h-5 w-5 animate-spin text-muted-foreground" /></div>
|
||||
) : !acceptConnectionsEnabled ? (
|
||||
<div className="flex flex-col items-center gap-3 py-4 text-center">
|
||||
<AlertCircle className="h-8 w-8 text-amber-400" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
You need to enable <span className="text-foreground font-medium">Accept Connections</span> in your settings before you can send or receive connection requests.
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="gap-1.5"
|
||||
onClick={() => { handleClose(); navigate('/settings'); }}
|
||||
>
|
||||
<Settings className="h-3.5 w-3.5" />
|
||||
Go to Settings
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@ -4,9 +4,11 @@ import { Menu } from 'lucide-react';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { AlertsProvider } from '@/hooks/useAlerts';
|
||||
import { LockProvider } from '@/hooks/useLock';
|
||||
import { NotificationProvider } from '@/hooks/useNotifications';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import Sidebar from './Sidebar';
|
||||
import LockOverlay from './LockOverlay';
|
||||
import NotificationToaster from '@/components/notifications/NotificationToaster';
|
||||
|
||||
export default function AppLayout() {
|
||||
useTheme();
|
||||
@ -19,6 +21,7 @@ export default function AppLayout() {
|
||||
return (
|
||||
<LockProvider>
|
||||
<AlertsProvider>
|
||||
<NotificationProvider>
|
||||
<div className="flex h-screen overflow-hidden bg-background">
|
||||
<Sidebar
|
||||
collapsed={collapsed}
|
||||
@ -44,6 +47,8 @@ export default function AppLayout() {
|
||||
</div>
|
||||
</div>
|
||||
<LockOverlay />
|
||||
<NotificationToaster />
|
||||
</NotificationProvider>
|
||||
</AlertsProvider>
|
||||
</LockProvider>
|
||||
);
|
||||
|
||||
@ -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"
|
||||
|
||||
149
frontend/src/components/notifications/NotificationToaster.tsx
Normal file
149
frontend/src/components/notifications/NotificationToaster.tsx
Normal file
@ -0,0 +1,149 @@
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Check, X, Bell, UserPlus } from 'lucide-react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useNotifications } from '@/hooks/useNotifications';
|
||||
import { useConnections } from '@/hooks/useConnections';
|
||||
import axios from 'axios';
|
||||
import { getErrorMessage } from '@/lib/api';
|
||||
import type { AppNotification } from '@/types';
|
||||
|
||||
export default function NotificationToaster() {
|
||||
const { notifications, unreadCount, markRead } = useNotifications();
|
||||
const { respond } = useConnections();
|
||||
const queryClient = useQueryClient();
|
||||
const maxSeenIdRef = useRef(0);
|
||||
const initializedRef = useRef(false);
|
||||
const prevUnreadRef = useRef(0);
|
||||
// Track in-flight request IDs so repeated clicks are blocked
|
||||
const respondingRef = useRef<Set<number>>(new Set());
|
||||
// Always call the latest respond — Sonner toasts capture closures at creation time
|
||||
const respondRef = useRef(respond);
|
||||
respondRef.current = respond;
|
||||
const markReadRef = useRef(markRead);
|
||||
markReadRef.current = markRead;
|
||||
|
||||
const handleConnectionRespond = useCallback(
|
||||
async (requestId: number, action: 'accept' | 'reject', toastId: string | number, notificationId: number) => {
|
||||
// Guard against double-clicks (Sonner toasts are static, no disabled prop)
|
||||
if (respondingRef.current.has(requestId)) return;
|
||||
respondingRef.current.add(requestId);
|
||||
|
||||
// Immediately dismiss the custom toast and show a loading indicator
|
||||
toast.dismiss(toastId);
|
||||
const loadingId = toast.loading(
|
||||
action === 'accept' ? 'Accepting connection…' : 'Declining request…',
|
||||
);
|
||||
|
||||
try {
|
||||
await respondRef.current({ requestId, action });
|
||||
toast.dismiss(loadingId);
|
||||
toast.success(action === 'accept' ? 'Connection accepted' : 'Request declined');
|
||||
markReadRef.current([notificationId]).catch(() => {});
|
||||
} catch (err) {
|
||||
toast.dismiss(loadingId);
|
||||
// 409 means the request was already resolved (e.g. accepted via notification center)
|
||||
if (axios.isAxiosError(err) && err.response?.status === 409) {
|
||||
toast.success(action === 'accept' ? 'Connection already accepted' : 'Request already resolved');
|
||||
markReadRef.current([notificationId]).catch(() => {});
|
||||
} else {
|
||||
toast.error(getErrorMessage(err, 'Failed to respond to request'));
|
||||
}
|
||||
} finally {
|
||||
respondingRef.current.delete(requestId);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Track unread count changes to force-refetch the list
|
||||
useEffect(() => {
|
||||
if (unreadCount > prevUnreadRef.current && initializedRef.current) {
|
||||
queryClient.invalidateQueries({ queryKey: ['notifications', 'list'] });
|
||||
}
|
||||
prevUnreadRef.current = unreadCount;
|
||||
}, [unreadCount, queryClient]);
|
||||
|
||||
// Show toasts for new notifications (ID > max seen)
|
||||
useEffect(() => {
|
||||
if (!notifications.length) return;
|
||||
|
||||
// On first load, record the max ID without toasting
|
||||
if (!initializedRef.current) {
|
||||
maxSeenIdRef.current = Math.max(...notifications.map((n) => n.id));
|
||||
initializedRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Find unread notifications with IDs higher than our watermark
|
||||
const newNotifications = notifications.filter(
|
||||
(n) => !n.is_read && n.id > maxSeenIdRef.current,
|
||||
);
|
||||
|
||||
// Advance watermark
|
||||
const maxCurrent = Math.max(...notifications.map((n) => n.id));
|
||||
if (maxCurrent > maxSeenIdRef.current) {
|
||||
maxSeenIdRef.current = maxCurrent;
|
||||
}
|
||||
|
||||
// Eagerly refresh incoming requests when connection_request notifications arrive
|
||||
// so accept buttons work immediately on NotificationsPage / PeoplePage
|
||||
if (newNotifications.some((n) => n.type === 'connection_request')) {
|
||||
queryClient.invalidateQueries({ queryKey: ['connections', 'incoming'] });
|
||||
}
|
||||
|
||||
// Show toasts
|
||||
newNotifications.forEach((notification) => {
|
||||
if (notification.type === 'connection_request' && notification.source_id) {
|
||||
showConnectionRequestToast(notification);
|
||||
} else {
|
||||
toast(notification.title || 'New Notification', {
|
||||
description: notification.message || undefined,
|
||||
icon: <Bell className="h-4 w-4" />,
|
||||
duration: 8000,
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [notifications, handleConnectionRespond]);
|
||||
|
||||
const showConnectionRequestToast = (notification: AppNotification) => {
|
||||
const requestId = notification.source_id!;
|
||||
|
||||
toast.custom(
|
||||
(id) => (
|
||||
<div className="w-[356px] rounded-lg border border-border bg-card p-4 shadow-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="h-9 w-9 rounded-full bg-violet-500/15 flex items-center justify-center shrink-0">
|
||||
<UserPlus className="h-4 w-4 text-violet-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-foreground">Connection Request</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{notification.message || 'Someone wants to connect with you'}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-3">
|
||||
<button
|
||||
onClick={() => handleConnectionRespond(requestId, 'accept', id, notification.id)}
|
||||
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-md bg-accent text-accent-foreground hover:bg-accent/90 transition-colors"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
Accept
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleConnectionRespond(requestId, 'reject', id, notification.id)}
|
||||
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-md text-muted-foreground hover:bg-card-elevated transition-colors"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
{ id: `connection-request-${requestId}`, duration: 30000 },
|
||||
);
|
||||
};
|
||||
|
||||
return null;
|
||||
}
|
||||
285
frontend/src/components/notifications/NotificationsPage.tsx
Normal file
285
frontend/src/components/notifications/NotificationsPage.tsx
Normal file
@ -0,0 +1,285 @@
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { Bell, Check, CheckCheck, Trash2, UserPlus, Info, AlertCircle, X, Loader2 } from 'lucide-react';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { toast } from 'sonner';
|
||||
import { useNotifications } from '@/hooks/useNotifications';
|
||||
import { useConnections } from '@/hooks/useConnections';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import axios from 'axios';
|
||||
import { getErrorMessage } from '@/lib/api';
|
||||
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 { incomingRequests, respond, isResponding } = useConnections();
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const [filter, setFilter] = useState<Filter>('all');
|
||||
|
||||
// Build a set of pending connection request IDs for quick lookup
|
||||
const pendingRequestIds = useMemo(
|
||||
() => new Set(incomingRequests.map((r) => r.id)),
|
||||
[incomingRequests],
|
||||
);
|
||||
|
||||
// Eagerly fetch incoming requests when notifications contain connection_request
|
||||
// entries whose source_id isn't in pendingRequestIds yet (stale connections data)
|
||||
useEffect(() => {
|
||||
const hasMissing = notifications.some(
|
||||
(n) => n.type === 'connection_request' && n.source_id && !n.is_read && !pendingRequestIds.has(n.source_id),
|
||||
);
|
||||
if (hasMissing) {
|
||||
queryClient.invalidateQueries({ queryKey: ['connections', 'incoming'] });
|
||||
}
|
||||
}, [notifications, pendingRequestIds, queryClient]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (filter === 'unread') return notifications.filter((n) => !n.is_read);
|
||||
return notifications;
|
||||
}, [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 handleConnectionRespond = async (
|
||||
notification: AppNotification,
|
||||
action: 'accept' | 'reject',
|
||||
) => {
|
||||
if (!notification.source_id) return;
|
||||
try {
|
||||
await respond({ requestId: notification.source_id, action });
|
||||
if (!notification.is_read) {
|
||||
await markRead([notification.id]).catch(() => {});
|
||||
}
|
||||
toast.success(action === 'accept' ? 'Connection accepted' : 'Request declined');
|
||||
} catch (err) {
|
||||
// 409 means the request was already resolved (e.g. accepted via toast)
|
||||
if (axios.isAxiosError(err) && err.response?.status === 409) {
|
||||
if (!notification.is_read) {
|
||||
await markRead([notification.id]).catch(() => {});
|
||||
}
|
||||
toast.success(action === 'accept' ? 'Connection already accepted' : 'Request already resolved');
|
||||
} else {
|
||||
toast.error(getErrorMessage(err, 'Failed to respond'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleNotificationClick = async (notification: AppNotification) => {
|
||||
// Don't navigate for pending connection requests — let user act inline
|
||||
if (
|
||||
notification.type === 'connection_request' &&
|
||||
notification.source_id &&
|
||||
pendingRequestIds.has(notification.source_id)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (!notification.is_read) {
|
||||
await markRead([notification.id]).catch(() => {});
|
||||
}
|
||||
// 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>
|
||||
|
||||
{/* Connection request actions (inline) */}
|
||||
{notification.type === 'connection_request' &&
|
||||
notification.source_id &&
|
||||
pendingRequestIds.has(notification.source_id) && (
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={(e) => { e.stopPropagation(); handleConnectionRespond(notification, 'accept'); }}
|
||||
disabled={isResponding}
|
||||
className="gap-1 h-7 text-xs"
|
||||
>
|
||||
{isResponding ? <Loader2 className="h-3 w-3 animate-spin" /> : <Check className="h-3 w-3" />}
|
||||
Accept
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => { e.stopPropagation(); handleConnectionRespond(notification, 'reject'); }}
|
||||
disabled={isResponding}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</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={(e) => { e.stopPropagation(); 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={(e) => { e.stopPropagation(); 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>
|
||||
);
|
||||
}
|
||||
@ -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, Unlink, Link2, User2 } 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,9 @@ import {
|
||||
import { useTableVisibility } from '@/hooks/useTableVisibility';
|
||||
import { useCategoryOrder } from '@/hooks/useCategoryOrder';
|
||||
import PersonForm from './PersonForm';
|
||||
import ConnectionSearch from '@/components/connections/ConnectionSearch';
|
||||
import ConnectionRequestCard from '@/components/connections/ConnectionRequestCard';
|
||||
import { useConnections } from '@/hooks/useConnections';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// StatCounter — inline helper
|
||||
@ -57,7 +60,11 @@ function StatCounter({
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
function getPersonInitialsName(p: Person): string {
|
||||
const parts = [p.first_name, p.last_name].filter(Boolean);
|
||||
const firstName = p.is_umbral_contact && p.shared_fields?.first_name
|
||||
? String(p.shared_fields.first_name) : p.first_name;
|
||||
const lastName = p.is_umbral_contact && p.shared_fields?.last_name
|
||||
? String(p.shared_fields.last_name) : p.last_name;
|
||||
const parts = [firstName, lastName].filter(Boolean);
|
||||
return parts.length > 0 ? parts.join(' ') : p.name;
|
||||
}
|
||||
|
||||
@ -82,6 +89,14 @@ function sortPeople(people: Person[], key: string, dir: 'asc' | 'desc'): Person[
|
||||
// ---------------------------------------------------------------------------
|
||||
// Column definitions
|
||||
// ---------------------------------------------------------------------------
|
||||
/** Get a field value, preferring shared_fields for umbral contacts. */
|
||||
function sf(p: Person, key: string): string | null | undefined {
|
||||
if (p.is_umbral_contact && p.shared_fields && key in p.shared_fields) {
|
||||
return p.shared_fields[key] as string | null;
|
||||
}
|
||||
return p[key as keyof Person] as string | null | undefined;
|
||||
}
|
||||
|
||||
const columns: ColumnDef<Person>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
@ -89,7 +104,10 @@ const columns: ColumnDef<Person>[] = [
|
||||
sortable: true,
|
||||
visibilityLevel: 'essential',
|
||||
render: (p) => {
|
||||
const initialsName = getPersonInitialsName(p);
|
||||
const firstName = sf(p, 'first_name');
|
||||
const lastName = sf(p, 'last_name');
|
||||
const liveName = [firstName, lastName].filter(Boolean).join(' ') || p.nickname || p.name;
|
||||
const initialsName = liveName || getPersonInitialsName(p);
|
||||
return (
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div
|
||||
@ -97,7 +115,10 @@ const columns: ColumnDef<Person>[] = [
|
||||
>
|
||||
{getInitials(initialsName)}
|
||||
</div>
|
||||
<span className="font-medium truncate">{p.nickname || p.name}</span>
|
||||
<span className="font-medium truncate">{liveName}</span>
|
||||
{p.is_umbral_contact && (
|
||||
<Ghost className="h-3.5 w-3.5 text-violet-400 shrink-0" aria-label="Umbral contact" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@ -107,18 +128,21 @@ const columns: ColumnDef<Person>[] = [
|
||||
label: 'Number',
|
||||
sortable: false,
|
||||
visibilityLevel: 'essential',
|
||||
render: (p) => (
|
||||
<span className="text-muted-foreground truncate">{p.mobile || p.phone || '—'}</span>
|
||||
),
|
||||
render: (p) => {
|
||||
const mobile = sf(p, 'mobile');
|
||||
const phone = sf(p, 'phone');
|
||||
return <span className="text-muted-foreground truncate">{mobile || phone || '—'}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'email',
|
||||
label: 'Email',
|
||||
sortable: true,
|
||||
visibilityLevel: 'essential',
|
||||
render: (p) => (
|
||||
<span className="text-muted-foreground truncate">{p.email || '—'}</span>
|
||||
),
|
||||
render: (p) => {
|
||||
const email = sf(p, 'email');
|
||||
return <span className="text-muted-foreground truncate">{email || '—'}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'job_title',
|
||||
@ -126,10 +150,10 @@ const columns: ColumnDef<Person>[] = [
|
||||
sortable: true,
|
||||
visibilityLevel: 'filtered',
|
||||
render: (p) => {
|
||||
const parts = [p.job_title, p.company].filter(Boolean);
|
||||
return (
|
||||
<span className="text-muted-foreground truncate">{parts.join(', ') || '—'}</span>
|
||||
);
|
||||
const jobTitle = sf(p, 'job_title');
|
||||
const company = sf(p, 'company');
|
||||
const parts = [jobTitle, company].filter(Boolean);
|
||||
return <span className="text-muted-foreground truncate">{parts.join(', ') || '—'}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -137,12 +161,14 @@ const columns: ColumnDef<Person>[] = [
|
||||
label: 'Birthday',
|
||||
sortable: true,
|
||||
visibilityLevel: 'filtered',
|
||||
render: (p) =>
|
||||
p.birthday ? (
|
||||
<span className="text-muted-foreground">{format(parseISO(p.birthday), 'MMM d')}</span>
|
||||
render: (p) => {
|
||||
const birthday = sf(p, 'birthday');
|
||||
return birthday ? (
|
||||
<span className="text-muted-foreground">{format(parseISO(birthday), 'MMM d')}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
),
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'category',
|
||||
@ -170,6 +196,7 @@ const columns: ColumnDef<Person>[] = [
|
||||
// Panel field config
|
||||
// ---------------------------------------------------------------------------
|
||||
const panelFields: PanelField[] = [
|
||||
{ label: 'Preferred Name', key: 'preferred_name', icon: User2 },
|
||||
{ label: 'Mobile', key: 'mobile', copyable: true, icon: Phone },
|
||||
{ label: 'Phone', key: 'phone', copyable: true, icon: Phone },
|
||||
{ label: 'Email', key: 'email', copyable: true, icon: Mail },
|
||||
@ -193,9 +220,17 @@ 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 [linkPersonId, setLinkPersonId] = useState<number | null>(null);
|
||||
const [showAddDropdown, setShowAddDropdown] = useState(false);
|
||||
const addDropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { incomingRequests, outgoingRequests } = useConnections();
|
||||
const hasRequests = incomingRequests.length > 0 || outgoingRequests.length > 0;
|
||||
|
||||
const { data: people = [], isLoading } = useQuery({
|
||||
queryKey: ['people'],
|
||||
@ -228,6 +263,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 +288,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(() => {
|
||||
@ -314,6 +353,7 @@ export default function PeoplePage() {
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['people'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['connections'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['upcoming'] });
|
||||
toast.success('Person deleted');
|
||||
@ -324,6 +364,22 @@ export default function PeoplePage() {
|
||||
},
|
||||
});
|
||||
|
||||
// Unlink umbral contact mutation
|
||||
const unlinkMutation = useMutation({
|
||||
mutationFn: async (personId: number) => {
|
||||
const { data } = await api.put(`/people/${personId}/unlink`);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['people'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['connections'] });
|
||||
toast.success('Contact unlinked — converted to standard contact');
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(getErrorMessage(error, 'Failed to unlink contact'));
|
||||
},
|
||||
});
|
||||
|
||||
// Toggle favourite mutation
|
||||
const toggleFavouriteMutation = useMutation({
|
||||
mutationFn: async (person: Person) => {
|
||||
@ -347,6 +403,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,17 +431,75 @@ 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.is_umbral_contact && p.shared_fields
|
||||
? [sf(p, 'first_name'), sf(p, 'last_name')].filter(Boolean).join(' ') || p.name
|
||||
: p.name
|
||||
}</h3>
|
||||
{p.is_umbral_contact && (
|
||||
<Ghost className="h-4 w-4 text-violet-400 shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{p.is_umbral_contact && p.shared_fields?.umbral_name ? (
|
||||
<span className="text-xs text-violet-400/80 font-normal">
|
||||
@{String(p.shared_fields.umbral_name)}
|
||||
</span>
|
||||
) : null}
|
||||
{p.category && (
|
||||
<span className="text-xs text-muted-foreground">{p.category}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Panel getValue
|
||||
// Shared field key mapping (panel key -> shared_fields key)
|
||||
const sharedKeyMap: Record<string, string> = {
|
||||
preferred_name: 'preferred_name',
|
||||
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 +511,7 @@ export default function PeoplePage() {
|
||||
const renderPanel = () => (
|
||||
<EntityDetailPanel<Person>
|
||||
item={selectedPerson}
|
||||
fields={panelFields}
|
||||
fields={dynamicPanelFields}
|
||||
onEdit={() => {
|
||||
setEditingPerson(selectedPerson);
|
||||
setShowForm(true);
|
||||
@ -399,6 +525,30 @@ export default function PeoplePage() {
|
||||
isFavourite={selectedPerson?.is_favourite}
|
||||
onToggleFavourite={() => selectedPerson && toggleFavouriteMutation.mutate(selectedPerson)}
|
||||
favouriteLabel="favourite"
|
||||
extraActions={(p) =>
|
||||
p.is_umbral_contact ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => unlinkMutation.mutate(p.id)}
|
||||
disabled={unlinkMutation.isPending}
|
||||
className="h-7 text-[11px] text-muted-foreground hover:text-foreground gap-1"
|
||||
>
|
||||
<Unlink className="h-3 w-3" />
|
||||
Unlink
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setLinkPersonId(p.id)}
|
||||
className="h-7 text-[11px] text-muted-foreground hover:text-foreground gap-1"
|
||||
>
|
||||
<Link2 className="h-3 w-3" />
|
||||
Link
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -420,12 +570,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">
|
||||
<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">
|
||||
@ -472,6 +663,40 @@ export default function PeoplePage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pending requests */}
|
||||
{hasRequests && (
|
||||
<div className="px-6 pb-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-xs text-muted-foreground uppercase tracking-wider font-medium">
|
||||
Pending Requests
|
||||
</span>
|
||||
<span className="text-[10px] tabular-nums bg-accent/15 text-accent px-1.5 py-0.5 rounded-full font-medium">
|
||||
{incomingRequests.length + outgoingRequests.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{incomingRequests.length > 0 && outgoingRequests.length > 0 && (
|
||||
<p className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider">Incoming</p>
|
||||
)}
|
||||
{incomingRequests.slice(0, 5).map((req) => (
|
||||
<ConnectionRequestCard key={req.id} request={req} direction="incoming" />
|
||||
))}
|
||||
{incomingRequests.length > 5 && (
|
||||
<p className="text-xs text-muted-foreground">+{incomingRequests.length - 5} more</p>
|
||||
)}
|
||||
{incomingRequests.length > 0 && outgoingRequests.length > 0 && (
|
||||
<p className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider mt-3">Outgoing</p>
|
||||
)}
|
||||
{outgoingRequests.slice(0, 5).map((req) => (
|
||||
<ConnectionRequestCard key={req.id} request={req} direction="outgoing" />
|
||||
))}
|
||||
{outgoingRequests.length > 5 && (
|
||||
<p className="text-xs text-muted-foreground">+{outgoingRequests.length - 5} more</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main content: table + panel */}
|
||||
<div className="flex-1 overflow-hidden flex">
|
||||
{/* Table */}
|
||||
@ -558,6 +783,17 @@ export default function PeoplePage() {
|
||||
onClose={handleCloseForm}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ConnectionSearch
|
||||
open={showConnectionSearch}
|
||||
onOpenChange={setShowConnectionSearch}
|
||||
/>
|
||||
|
||||
<ConnectionSearch
|
||||
open={linkPersonId !== null}
|
||||
onOpenChange={(open) => { if (!open) setLinkPersonId(null); }}
|
||||
personId={linkPersonId ?? undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,40 +221,66 @@ 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>
|
||||
<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}
|
||||
@ -241,24 +288,35 @@ export default function PersonForm({ person, categories, onClose }: PersonFormPr
|
||||
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>
|
||||
|
||||
@ -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,26 @@ 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 [shareFirstName, setShareFirstName] = useState(settings?.share_first_name ?? false);
|
||||
const [shareLastName, setShareLastName] = useState(settings?.share_last_name ?? false);
|
||||
const [sharePreferredName, setSharePreferredName] = useState(settings?.share_preferred_name ?? true);
|
||||
const [shareEmail, setShareEmail] = useState(settings?.share_email ?? false);
|
||||
const [sharePhone, setSharePhone] = useState(settings?.share_phone ?? false);
|
||||
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'],
|
||||
@ -68,6 +90,8 @@ export default function SettingsPage() {
|
||||
const [profileEmail, setProfileEmail] = useState('');
|
||||
const [dateOfBirth, setDateOfBirth] = useState('');
|
||||
const [emailError, setEmailError] = useState<string | null>(null);
|
||||
const [umbralName, setUmbralName] = useState('');
|
||||
const [umbralNameError, setUmbralNameError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (profileQuery.data) {
|
||||
@ -75,6 +99,7 @@ export default function SettingsPage() {
|
||||
setLastName(profileQuery.data.last_name ?? '');
|
||||
setProfileEmail(profileQuery.data.email ?? '');
|
||||
setDateOfBirth(profileQuery.data.date_of_birth ?? '');
|
||||
setUmbralName(profileQuery.data.umbral_name ?? '');
|
||||
}
|
||||
}, [profileQuery.dataUpdatedAt]);
|
||||
|
||||
@ -87,6 +112,22 @@ 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);
|
||||
setShareFirstName(settings.share_first_name);
|
||||
setShareLastName(settings.share_last_name);
|
||||
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)
|
||||
|
||||
@ -173,8 +214,8 @@ export default function SettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleProfileSave = async (field: 'first_name' | 'last_name' | 'email' | 'date_of_birth') => {
|
||||
const values: Record<string, string> = { first_name: firstName, last_name: lastName, email: profileEmail, date_of_birth: dateOfBirth };
|
||||
const handleProfileSave = async (field: 'first_name' | 'last_name' | 'email' | 'date_of_birth' | 'umbral_name') => {
|
||||
const values: Record<string, string> = { first_name: firstName, last_name: lastName, email: profileEmail, date_of_birth: dateOfBirth, umbral_name: umbralName };
|
||||
const current = values[field].trim();
|
||||
const original = profileQuery.data?.[field] ?? '';
|
||||
if (current === (original || '')) return;
|
||||
@ -188,6 +229,19 @@ export default function SettingsPage() {
|
||||
}
|
||||
setEmailError(null);
|
||||
|
||||
// Client-side umbral name validation
|
||||
if (field === 'umbral_name') {
|
||||
if (current.includes(' ')) {
|
||||
setUmbralNameError('Must be a single word with no spaces');
|
||||
return;
|
||||
}
|
||||
if (!current || !/^[a-zA-Z0-9_-]{3,50}$/.test(current)) {
|
||||
setUmbralNameError('3-50 characters: letters, numbers, hyphens, underscores');
|
||||
return;
|
||||
}
|
||||
setUmbralNameError(null);
|
||||
}
|
||||
|
||||
try {
|
||||
await api.put('/auth/profile', { [field]: current || null });
|
||||
queryClient.invalidateQueries({ queryKey: ['profile'] });
|
||||
@ -196,6 +250,8 @@ export default function SettingsPage() {
|
||||
const detail = err?.response?.data?.detail;
|
||||
if (field === 'email' && detail) {
|
||||
setEmailError(typeof detail === 'string' ? detail : 'Failed to update email');
|
||||
} else if (field === 'umbral_name' && detail) {
|
||||
setUmbralNameError(typeof detail === 'string' ? detail : 'Failed to update umbral name');
|
||||
} else {
|
||||
toast.error(typeof detail === 'string' ? detail : 'Failed to update profile');
|
||||
}
|
||||
@ -248,6 +304,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 +442,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 +734,88 @@ 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 htmlFor="umbral_name">Umbral Name</Label>
|
||||
<div className="flex items-center gap-3">
|
||||
<Input
|
||||
id="umbral_name"
|
||||
value={umbralName}
|
||||
onChange={(e) => { setUmbralName(e.target.value); setUmbralNameError(null); }}
|
||||
onBlur={() => handleProfileSave('umbral_name')}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleProfileSave('umbral_name'); }}
|
||||
maxLength={50}
|
||||
placeholder="Your discoverable name"
|
||||
className={umbralNameError ? 'border-red-500/50' : ''}
|
||||
/>
|
||||
<CopyableField value={umbralName} label="Umbral name" />
|
||||
</div>
|
||||
{umbralNameError ? (
|
||||
<p className="text-xs text-red-400">{umbralNameError}</p>
|
||||
) : (
|
||||
<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_first_name', label: 'First Name', state: shareFirstName, setter: setShareFirstName },
|
||||
{ field: 'share_last_name', label: 'Last Name', state: shareLastName, setter: setShareLastName },
|
||||
{ field: 'share_preferred_name', label: 'Preferred Name', state: sharePreferredName, setter: setSharePreferredName },
|
||||
{ field: 'share_email', label: 'Email', state: shareEmail, setter: setShareEmail },
|
||||
{ field: 'share_phone', label: 'Phone', state: sharePhone, setter: setSharePhone },
|
||||
{ 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>
|
||||
|
||||
@ -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 && (
|
||||
<>
|
||||
|
||||
@ -27,6 +27,7 @@ interface EntityDetailPanelProps<T> {
|
||||
isFavourite?: boolean;
|
||||
onToggleFavourite?: () => void;
|
||||
favouriteLabel?: string;
|
||||
extraActions?: (item: T) => React.ReactNode;
|
||||
}
|
||||
|
||||
export function EntityDetailPanel<T>({
|
||||
@ -42,6 +43,7 @@ export function EntityDetailPanel<T>({
|
||||
isFavourite,
|
||||
onToggleFavourite,
|
||||
favouriteLabel = 'favourite',
|
||||
extraActions,
|
||||
}: EntityDetailPanelProps<T>) {
|
||||
const { confirming, handleClick: handleDelete } = useConfirmAction(onDelete);
|
||||
|
||||
@ -134,7 +136,10 @@ export function EntityDetailPanel<T>({
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-5 py-4 border-t border-border flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] text-muted-foreground">{formatUpdatedAt(getUpdatedAt(item))}</span>
|
||||
{extraActions?.(item)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
110
frontend/src/hooks/useConnections.ts
Normal file
110
frontend/src/hooks/useConnections.ts
Normal file
@ -0,0 +1,110 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
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;
|
||||
},
|
||||
refetchOnMount: 'always',
|
||||
});
|
||||
|
||||
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 (params: { umbralName: string; personId?: number }) => {
|
||||
const { data } = await api.post('/connections/request', {
|
||||
umbral_name: params.umbralName,
|
||||
...(params.personId != null && { person_id: params.personId }),
|
||||
});
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Fire-and-forget — don't block mutateAsync on query refetches
|
||||
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: (_, variables) => {
|
||||
// Dismiss any lingering Sonner toast for this request
|
||||
toast.dismiss(`connection-request-${variables.requestId}`);
|
||||
// Fire-and-forget — errors here must not surface as mutation failures
|
||||
queryClient.invalidateQueries({ queryKey: ['connections'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['people'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
||||
},
|
||||
});
|
||||
|
||||
const cancelMutation = useMutation({
|
||||
mutationFn: async (requestId: number) => {
|
||||
const { data } = await api.put(`/connections/requests/${requestId}/cancel`);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Fire-and-forget — don't block mutateAsync on query refetches
|
||||
queryClient.invalidateQueries({ queryKey: ['connections'] });
|
||||
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,
|
||||
isLoadingIncoming: incomingQuery.isLoading,
|
||||
search: searchMutation.mutateAsync,
|
||||
isSearching: searchMutation.isPending,
|
||||
sendRequest: sendRequestMutation.mutateAsync,
|
||||
isSending: sendRequestMutation.isPending,
|
||||
respond: respondMutation.mutateAsync,
|
||||
isResponding: respondMutation.isPending,
|
||||
cancelRequest: cancelMutation.mutateAsync,
|
||||
isCancelling: cancelMutation.isPending,
|
||||
removeConnection: removeConnectionMutation.mutateAsync,
|
||||
};
|
||||
}
|
||||
113
frontend/src/hooks/useNotifications.ts
Normal file
113
frontend/src/hooks/useNotifications.ts
Normal file
@ -0,0 +1,113 @@
|
||||
import { createContext, useContext, type ReactNode } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useRef, createElement } from 'react';
|
||||
import api from '@/lib/api';
|
||||
import type { NotificationListResponse } from '@/types';
|
||||
|
||||
interface NotificationContextValue {
|
||||
unreadCount: number;
|
||||
notifications: NotificationListResponse['notifications'];
|
||||
total: number;
|
||||
isLoading: boolean;
|
||||
markRead: (ids: number[]) => Promise<void>;
|
||||
markAllRead: () => Promise<void>;
|
||||
deleteNotification: (id: number) => Promise<void>;
|
||||
refreshNotifications: () => void;
|
||||
}
|
||||
|
||||
const NotificationContext = createContext<NotificationContextValue>({
|
||||
unreadCount: 0,
|
||||
notifications: [],
|
||||
total: 0,
|
||||
isLoading: true,
|
||||
markRead: async () => {},
|
||||
markAllRead: async () => {},
|
||||
deleteNotification: async () => {},
|
||||
refreshNotifications: () => {},
|
||||
});
|
||||
|
||||
export function useNotifications() {
|
||||
return useContext(NotificationContext);
|
||||
}
|
||||
|
||||
export function NotificationProvider({ children }: { children: ReactNode }) {
|
||||
const queryClient = useQueryClient();
|
||||
const visibleRef = useRef(true);
|
||||
|
||||
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: 15_000,
|
||||
// Required: toast notifications depend on background polling to detect new
|
||||
// notifications when the tab is hidden (e.g. user switches to sender tab).
|
||||
refetchIntervalInBackground: true,
|
||||
staleTime: 10_000,
|
||||
});
|
||||
|
||||
const listQuery = useQuery({
|
||||
queryKey: ['notifications', 'list'],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get<NotificationListResponse>('/notifications', {
|
||||
params: { per_page: 50 },
|
||||
});
|
||||
return data;
|
||||
},
|
||||
staleTime: 15_000,
|
||||
refetchInterval: () => (visibleRef.current ? 15_000 : false),
|
||||
});
|
||||
|
||||
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'] });
|
||||
},
|
||||
});
|
||||
|
||||
const refreshNotifications = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
||||
};
|
||||
|
||||
const value: NotificationContextValue = {
|
||||
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,
|
||||
refreshNotifications,
|
||||
};
|
||||
|
||||
return createElement(NotificationContext.Provider, { value }, children);
|
||||
}
|
||||
@ -23,6 +23,27 @@ 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_first_name: boolean;
|
||||
share_last_name: boolean;
|
||||
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 +192,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 +246,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;
|
||||
@ -348,6 +373,7 @@ export interface UpcomingResponse {
|
||||
|
||||
export interface UserProfile {
|
||||
username: string;
|
||||
umbral_name: string;
|
||||
email: string | null;
|
||||
first_name: string | null;
|
||||
last_name: string | null;
|
||||
@ -366,3 +392,50 @@ 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;
|
||||
receiver_umbral_name: string;
|
||||
receiver_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;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user