Add user connections, notification centre, and people integration
Implements the full User Connections & Notification Centre feature: Phase 1 - Database: migrations 039-043 adding umbral_name to users, profile/social fields to settings, notifications table, connection request/user_connection tables, and linked_user_id to people. Phase 2 - Notifications: backend CRUD router + service + 90-day purge, frontend NotificationsPage with All/Unread filter, bell icon in sidebar with unread badge polling every 60s. Phase 3 - Settings: profile fields (phone, mobile, address, company, job_title), social card with accept_connections toggle and per-field sharing defaults, umbral name display with CopyableField. Phase 4 - Connections: timing-safe user search, send/accept/reject flow with atomic status updates, bidirectional UserConnection + Person records, in-app + ntfy notifications, per-receiver pending cap, nginx rate limiting. Phase 5 - People integration: batch-loaded shared profiles (N+1 prevention), Ghost icon for umbral contacts, Umbral filter pill, split Add Person button, shared field indicators (synced labels + Lock icons), disabled form inputs for synced fields on umbral contacts. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
2a21809066
commit
3d22568b9c
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")
|
||||||
@ -17,6 +17,7 @@ from sqlalchemy.orm import selectinload
|
|||||||
|
|
||||||
from app.database import AsyncSessionLocal
|
from app.database import AsyncSessionLocal
|
||||||
from app.models.settings import Settings
|
from app.models.settings import Settings
|
||||||
|
from app.models.notification import Notification as AppNotification
|
||||||
from app.models.reminder import Reminder
|
from app.models.reminder import Reminder
|
||||||
from app.models.calendar_event import CalendarEvent
|
from app.models.calendar_event import CalendarEvent
|
||||||
from app.models.calendar import Calendar
|
from app.models.calendar import Calendar
|
||||||
@ -267,6 +268,13 @@ async def _purge_expired_sessions(db: AsyncSession) -> None:
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def _purge_old_notifications(db: AsyncSession) -> None:
|
||||||
|
"""Remove in-app notifications older than 90 days."""
|
||||||
|
cutoff = datetime.now() - timedelta(days=90)
|
||||||
|
await db.execute(delete(AppNotification).where(AppNotification.created_at < cutoff))
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
# ── Entry point ───────────────────────────────────────────────────────────────
|
# ── Entry point ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async def run_notification_dispatch() -> None:
|
async def run_notification_dispatch() -> None:
|
||||||
@ -308,6 +316,7 @@ async def run_notification_dispatch() -> None:
|
|||||||
async with AsyncSessionLocal() as db:
|
async with AsyncSessionLocal() as db:
|
||||||
await _purge_totp_usage(db)
|
await _purge_totp_usage(db)
|
||||||
await _purge_expired_sessions(db)
|
await _purge_expired_sessions(db)
|
||||||
|
await _purge_old_notifications(db)
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
# Broad catch: job failure must never crash the scheduler or the app
|
# 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.config import settings
|
||||||
from app.database import engine
|
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 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
|
from app.jobs.notifications import run_notification_dispatch
|
||||||
|
|
||||||
# Import models so Alembic's autogenerate can discover them
|
# 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 backup_code as _backup_code_model # noqa: F401
|
||||||
from app.models import system_config as _system_config_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 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(event_templates.router, prefix="/api/event-templates", tags=["Event Templates"])
|
||||||
app.include_router(totp.router, prefix="/api/auth", tags=["TOTP MFA"])
|
app.include_router(totp.router, prefix="/api/auth", tags=["TOTP MFA"])
|
||||||
app.include_router(admin.router, prefix="/api/admin", tags=["Admin"])
|
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("/")
|
@app.get("/")
|
||||||
|
|||||||
@ -15,6 +15,9 @@ from app.models.totp_usage import TOTPUsage
|
|||||||
from app.models.backup_code import BackupCode
|
from app.models.backup_code import BackupCode
|
||||||
from app.models.system_config import SystemConfig
|
from app.models.system_config import SystemConfig
|
||||||
from app.models.audit_log import AuditLog
|
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__ = [
|
__all__ = [
|
||||||
"Settings",
|
"Settings",
|
||||||
@ -34,4 +37,7 @@ __all__ = [
|
|||||||
"BackupCode",
|
"BackupCode",
|
||||||
"SystemConfig",
|
"SystemConfig",
|
||||||
"AuditLog",
|
"AuditLog",
|
||||||
|
"Notification",
|
||||||
|
"ConnectionRequest",
|
||||||
|
"UserConnection",
|
||||||
]
|
]
|
||||||
|
|||||||
33
backend/app/models/connection_request.py
Normal file
33
backend/app/models/connection_request.py
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
from sqlalchemy import String, Integer, ForeignKey, CheckConstraint, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, TYPE_CHECKING
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectionRequest(Base):
|
||||||
|
__tablename__ = "connection_requests"
|
||||||
|
__table_args__ = (
|
||||||
|
CheckConstraint(
|
||||||
|
"status IN ('pending', 'accepted', 'rejected', 'cancelled')",
|
||||||
|
name="ck_connection_requests_status",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||||
|
sender_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
receiver_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default="pending")
|
||||||
|
created_at: Mapped[datetime] = mapped_column(default=func.now(), server_default=func.now())
|
||||||
|
resolved_at: Mapped[Optional[datetime]] = mapped_column(nullable=True, default=None)
|
||||||
|
|
||||||
|
# Relationships with explicit foreign_keys to disambiguate
|
||||||
|
sender: Mapped["User"] = relationship(foreign_keys=[sender_id], lazy="selectin")
|
||||||
|
receiver: Mapped["User"] = relationship(foreign_keys=[receiver_id], lazy="selectin")
|
||||||
23
backend/app/models/notification.py
Normal file
23
backend/app/models/notification.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
from sqlalchemy import String, Text, Integer, Boolean, ForeignKey, func
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Notification(Base):
|
||||||
|
__tablename__ = "notifications"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
title: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||||
|
message: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
data: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)
|
||||||
|
source_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
|
||||||
|
source_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||||
|
is_read: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||||
|
created_at: Mapped[datetime] = mapped_column(default=func.now(), server_default=func.now())
|
||||||
@ -27,6 +27,11 @@ class Person(Base):
|
|||||||
job_title: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
job_title: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||||
mobile: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
|
mobile: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
|
||||||
category: Mapped[Optional[str]] = mapped_column(String(100), 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())
|
created_at: Mapped[datetime] = mapped_column(default=func.now())
|
||||||
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=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 sqlalchemy.orm import Mapped, mapped_column
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@ -46,6 +46,29 @@ class Settings(Base):
|
|||||||
auto_lock_enabled: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
auto_lock_enabled: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||||
auto_lock_minutes: Mapped[int] = mapped_column(Integer, default=5, server_default="5")
|
auto_lock_minutes: Mapped[int] = mapped_column(Integer, default=5, server_default="5")
|
||||||
|
|
||||||
|
# Profile fields (shareable with connections)
|
||||||
|
phone: Mapped[Optional[str]] = mapped_column(String(50), nullable=True, default=None)
|
||||||
|
mobile: Mapped[Optional[str]] = mapped_column(String(50), nullable=True, default=None)
|
||||||
|
address: Mapped[Optional[str]] = mapped_column(Text, nullable=True, default=None)
|
||||||
|
company: Mapped[Optional[str]] = mapped_column(String(255), nullable=True, default=None)
|
||||||
|
job_title: Mapped[Optional[str]] = mapped_column(String(255), nullable=True, default=None)
|
||||||
|
|
||||||
|
# Social settings
|
||||||
|
accept_connections: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||||
|
|
||||||
|
# Sharing defaults (what fields are shared with connections by default)
|
||||||
|
share_preferred_name: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true")
|
||||||
|
share_email: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||||
|
share_phone: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||||
|
share_mobile: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||||
|
share_birthday: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||||
|
share_address: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||||
|
share_company: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||||
|
share_job_title: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||||
|
|
||||||
|
# ntfy connection notification toggle (gates push only, not in-app)
|
||||||
|
ntfy_connections_enabled: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def ntfy_has_token(self) -> bool:
|
def ntfy_has_token(self) -> bool:
|
||||||
"""Derived field for SettingsResponse — True when an auth token is stored."""
|
"""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)
|
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||||
username: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, 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)
|
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
first_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
first_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
last_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")
|
||||||
642
backend/app/routers/connections.py
Normal file
642
backend/app/routers/connections.py
Normal file
@ -0,0 +1,642 @@
|
|||||||
|
"""
|
||||||
|
Connection router — search, request, respond, manage connections.
|
||||||
|
|
||||||
|
Security:
|
||||||
|
- Timing-safe search (50ms sleep floor)
|
||||||
|
- Per-receiver pending request cap (5 within 10 minutes)
|
||||||
|
- Atomic accept via UPDATE...WHERE status='pending' RETURNING *
|
||||||
|
- All endpoints scoped by current_user.id
|
||||||
|
- Audit logging for all connection events
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Path, Query, Request
|
||||||
|
from sqlalchemy import select, func, and_, update
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models.connection_request import ConnectionRequest
|
||||||
|
from app.models.notification import Notification
|
||||||
|
from app.models.person import Person
|
||||||
|
from app.models.settings import Settings
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.user_connection import UserConnection
|
||||||
|
from app.routers.auth import get_current_user
|
||||||
|
from app.schemas.connection import (
|
||||||
|
ConnectionRequestResponse,
|
||||||
|
ConnectionResponse,
|
||||||
|
RespondRequest,
|
||||||
|
SendConnectionRequest,
|
||||||
|
SharingOverrideUpdate,
|
||||||
|
UmbralSearchRequest,
|
||||||
|
UmbralSearchResponse,
|
||||||
|
)
|
||||||
|
from app.services.audit import get_client_ip, log_audit_event
|
||||||
|
from app.services.connection import (
|
||||||
|
SHAREABLE_FIELDS,
|
||||||
|
create_person_from_connection,
|
||||||
|
detach_umbral_contact,
|
||||||
|
resolve_shared_profile,
|
||||||
|
send_connection_ntfy,
|
||||||
|
)
|
||||||
|
from app.services.notification import create_notification
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _get_settings_for_user(db: AsyncSession, user_id: int) -> Settings | None:
|
||||||
|
result = await db.execute(select(Settings).where(Settings.user_id == user_id))
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
def _build_request_response(
|
||||||
|
req: ConnectionRequest,
|
||||||
|
sender: User,
|
||||||
|
sender_settings: Settings | None,
|
||||||
|
receiver: User,
|
||||||
|
receiver_settings: Settings | None,
|
||||||
|
) -> ConnectionRequestResponse:
|
||||||
|
return ConnectionRequestResponse(
|
||||||
|
id=req.id,
|
||||||
|
sender_umbral_name=sender.umbral_name,
|
||||||
|
sender_preferred_name=sender_settings.preferred_name if sender_settings else None,
|
||||||
|
receiver_umbral_name=receiver.umbral_name,
|
||||||
|
receiver_preferred_name=receiver_settings.preferred_name if receiver_settings else None,
|
||||||
|
status=req.status,
|
||||||
|
created_at=req.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── POST /search ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post("/search", response_model=UmbralSearchResponse)
|
||||||
|
async def search_user(
|
||||||
|
body: UmbralSearchRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Timing-safe user search. Always queries by umbral_name alone,
|
||||||
|
then checks accept_connections + is_active in Python.
|
||||||
|
Generic "not found" for non-existent, opted-out, AND inactive users.
|
||||||
|
50ms sleep floor to eliminate timing side-channel.
|
||||||
|
"""
|
||||||
|
# Always sleep to prevent timing attacks
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
|
# Don't find yourself
|
||||||
|
if body.umbral_name == current_user.umbral_name:
|
||||||
|
return UmbralSearchResponse(found=False)
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(User).where(User.umbral_name == body.umbral_name)
|
||||||
|
)
|
||||||
|
target = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not target or not target.is_active:
|
||||||
|
return UmbralSearchResponse(found=False)
|
||||||
|
|
||||||
|
# Check if they accept connections
|
||||||
|
target_settings = await _get_settings_for_user(db, target.id)
|
||||||
|
if not target_settings or not target_settings.accept_connections:
|
||||||
|
return UmbralSearchResponse(found=False)
|
||||||
|
|
||||||
|
return UmbralSearchResponse(found=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ── POST /request ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post("/request", response_model=ConnectionRequestResponse, status_code=201)
|
||||||
|
async def send_connection_request(
|
||||||
|
body: SendConnectionRequest,
|
||||||
|
request: Request,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Send a connection request to another user."""
|
||||||
|
# Resolve target
|
||||||
|
result = await db.execute(
|
||||||
|
select(User).where(User.umbral_name == body.umbral_name)
|
||||||
|
)
|
||||||
|
target = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not target or not target.is_active:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
# Self-request guard
|
||||||
|
if target.id == current_user.id:
|
||||||
|
raise HTTPException(status_code=400, detail="Cannot send a connection request to yourself")
|
||||||
|
|
||||||
|
# Check accept_connections
|
||||||
|
target_settings = await _get_settings_for_user(db, target.id)
|
||||||
|
if not target_settings or not target_settings.accept_connections:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
# Check existing connection
|
||||||
|
existing_conn = await db.execute(
|
||||||
|
select(UserConnection).where(
|
||||||
|
UserConnection.user_id == current_user.id,
|
||||||
|
UserConnection.connected_user_id == target.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing_conn.scalar_one_or_none():
|
||||||
|
raise HTTPException(status_code=409, detail="Already connected")
|
||||||
|
|
||||||
|
# Check pending request in either direction
|
||||||
|
existing_req = await db.execute(
|
||||||
|
select(ConnectionRequest).where(
|
||||||
|
and_(
|
||||||
|
ConnectionRequest.status == "pending",
|
||||||
|
(
|
||||||
|
(ConnectionRequest.sender_id == current_user.id) & (ConnectionRequest.receiver_id == target.id)
|
||||||
|
) | (
|
||||||
|
(ConnectionRequest.sender_id == target.id) & (ConnectionRequest.receiver_id == current_user.id)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing_req.scalar_one_or_none():
|
||||||
|
raise HTTPException(status_code=409, detail="A pending request already exists")
|
||||||
|
|
||||||
|
# Per-receiver cap: max 5 pending requests within 10 minutes
|
||||||
|
ten_min_ago = datetime.now() - timedelta(minutes=10)
|
||||||
|
pending_count = await db.scalar(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(ConnectionRequest)
|
||||||
|
.where(
|
||||||
|
ConnectionRequest.receiver_id == target.id,
|
||||||
|
ConnectionRequest.status == "pending",
|
||||||
|
ConnectionRequest.created_at >= ten_min_ago,
|
||||||
|
)
|
||||||
|
) or 0
|
||||||
|
if pending_count >= 5:
|
||||||
|
raise HTTPException(status_code=429, detail="Too many pending requests for this user")
|
||||||
|
|
||||||
|
# Create the request
|
||||||
|
conn_request = ConnectionRequest(
|
||||||
|
sender_id=current_user.id,
|
||||||
|
receiver_id=target.id,
|
||||||
|
)
|
||||||
|
db.add(conn_request)
|
||||||
|
|
||||||
|
# Create in-app notification for receiver
|
||||||
|
sender_settings = await _get_settings_for_user(db, current_user.id)
|
||||||
|
sender_display = (sender_settings.preferred_name if sender_settings else None) or current_user.umbral_name
|
||||||
|
|
||||||
|
await create_notification(
|
||||||
|
db,
|
||||||
|
user_id=target.id,
|
||||||
|
type="connection_request",
|
||||||
|
title="New Connection Request",
|
||||||
|
message=f"{sender_display} wants to connect with you",
|
||||||
|
data={"sender_umbral_name": current_user.umbral_name},
|
||||||
|
source_type="connection_request",
|
||||||
|
source_id=None, # Will be set after flush
|
||||||
|
)
|
||||||
|
|
||||||
|
await log_audit_event(
|
||||||
|
db,
|
||||||
|
action="connection.request_sent",
|
||||||
|
actor_id=current_user.id,
|
||||||
|
target_id=target.id,
|
||||||
|
detail={"receiver_umbral_name": target.umbral_name},
|
||||||
|
ip=get_client_ip(request),
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(conn_request)
|
||||||
|
|
||||||
|
# ntfy push in background (non-blocking)
|
||||||
|
background_tasks.add_task(
|
||||||
|
send_connection_ntfy,
|
||||||
|
target_settings,
|
||||||
|
sender_display,
|
||||||
|
"request_received",
|
||||||
|
)
|
||||||
|
|
||||||
|
return _build_request_response(conn_request, current_user, sender_settings, target, target_settings)
|
||||||
|
|
||||||
|
|
||||||
|
# ── GET /requests/incoming ──────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/requests/incoming", response_model=list[ConnectionRequestResponse])
|
||||||
|
async def get_incoming_requests(
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
per_page: int = Query(20, ge=1, le=100),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""List pending connection requests received by the current user."""
|
||||||
|
offset = (page - 1) * per_page
|
||||||
|
result = await db.execute(
|
||||||
|
select(ConnectionRequest)
|
||||||
|
.where(
|
||||||
|
ConnectionRequest.receiver_id == current_user.id,
|
||||||
|
ConnectionRequest.status == "pending",
|
||||||
|
)
|
||||||
|
.options(selectinload(ConnectionRequest.sender))
|
||||||
|
.order_by(ConnectionRequest.created_at.desc())
|
||||||
|
.offset(offset)
|
||||||
|
.limit(per_page)
|
||||||
|
)
|
||||||
|
requests = result.scalars().all()
|
||||||
|
|
||||||
|
responses = []
|
||||||
|
for req in requests:
|
||||||
|
sender_settings = await _get_settings_for_user(db, req.sender_id)
|
||||||
|
receiver_settings = await _get_settings_for_user(db, current_user.id)
|
||||||
|
responses.append(_build_request_response(req, req.sender, sender_settings, current_user, receiver_settings))
|
||||||
|
|
||||||
|
return responses
|
||||||
|
|
||||||
|
|
||||||
|
# ── GET /requests/outgoing ──────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/requests/outgoing", response_model=list[ConnectionRequestResponse])
|
||||||
|
async def get_outgoing_requests(
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
per_page: int = Query(20, ge=1, le=100),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""List pending connection requests sent by the current user."""
|
||||||
|
offset = (page - 1) * per_page
|
||||||
|
result = await db.execute(
|
||||||
|
select(ConnectionRequest)
|
||||||
|
.where(
|
||||||
|
ConnectionRequest.sender_id == current_user.id,
|
||||||
|
ConnectionRequest.status == "pending",
|
||||||
|
)
|
||||||
|
.options(selectinload(ConnectionRequest.receiver))
|
||||||
|
.order_by(ConnectionRequest.created_at.desc())
|
||||||
|
.offset(offset)
|
||||||
|
.limit(per_page)
|
||||||
|
)
|
||||||
|
requests = result.scalars().all()
|
||||||
|
|
||||||
|
responses = []
|
||||||
|
for req in requests:
|
||||||
|
sender_settings = await _get_settings_for_user(db, current_user.id)
|
||||||
|
receiver_settings = await _get_settings_for_user(db, req.receiver_id)
|
||||||
|
responses.append(_build_request_response(req, current_user, sender_settings, req.receiver, receiver_settings))
|
||||||
|
|
||||||
|
return responses
|
||||||
|
|
||||||
|
|
||||||
|
# ── PUT /requests/{id}/respond ──────────────────────────────────────
|
||||||
|
|
||||||
|
@router.put("/requests/{request_id}/respond")
|
||||||
|
async def respond_to_request(
|
||||||
|
body: RespondRequest,
|
||||||
|
request: Request,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
request_id: int = Path(ge=1, le=2147483647),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Accept or reject a connection request. Atomic via UPDATE...WHERE status='pending'."""
|
||||||
|
now = datetime.now()
|
||||||
|
|
||||||
|
# Atomic update — only succeeds if status is still 'pending' and receiver is current user
|
||||||
|
result = await db.execute(
|
||||||
|
update(ConnectionRequest)
|
||||||
|
.where(
|
||||||
|
ConnectionRequest.id == request_id,
|
||||||
|
ConnectionRequest.receiver_id == current_user.id,
|
||||||
|
ConnectionRequest.status == "pending",
|
||||||
|
)
|
||||||
|
.values(status=body.action + "ed", resolved_at=now)
|
||||||
|
.returning(ConnectionRequest.id, ConnectionRequest.sender_id, ConnectionRequest.receiver_id)
|
||||||
|
)
|
||||||
|
row = result.first()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=409, detail="Request not found or already resolved")
|
||||||
|
|
||||||
|
sender_id = row.sender_id
|
||||||
|
|
||||||
|
if body.action == "accept":
|
||||||
|
# Verify sender is still active
|
||||||
|
sender_result = await db.execute(select(User).where(User.id == sender_id))
|
||||||
|
sender = sender_result.scalar_one_or_none()
|
||||||
|
if not sender or not sender.is_active:
|
||||||
|
# Revert to rejected
|
||||||
|
await db.execute(
|
||||||
|
update(ConnectionRequest)
|
||||||
|
.where(ConnectionRequest.id == request_id)
|
||||||
|
.values(status="rejected")
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
raise HTTPException(status_code=409, detail="Sender account is no longer active")
|
||||||
|
|
||||||
|
# Get settings for both users
|
||||||
|
sender_settings = await _get_settings_for_user(db, sender_id)
|
||||||
|
receiver_settings = await _get_settings_for_user(db, current_user.id)
|
||||||
|
|
||||||
|
# Resolve shared profiles for both directions
|
||||||
|
sender_shared = resolve_shared_profile(sender, sender_settings, None) if sender_settings else {}
|
||||||
|
receiver_shared = resolve_shared_profile(current_user, receiver_settings, None) if receiver_settings else {}
|
||||||
|
|
||||||
|
# Create Person records for both users
|
||||||
|
person_for_receiver = create_person_from_connection(
|
||||||
|
current_user.id, sender, sender_settings, sender_shared
|
||||||
|
)
|
||||||
|
person_for_sender = create_person_from_connection(
|
||||||
|
sender_id, current_user, receiver_settings, receiver_shared
|
||||||
|
)
|
||||||
|
db.add(person_for_receiver)
|
||||||
|
db.add(person_for_sender)
|
||||||
|
await db.flush() # populate person IDs
|
||||||
|
|
||||||
|
# Create bidirectional connections
|
||||||
|
conn_a = UserConnection(
|
||||||
|
user_id=current_user.id,
|
||||||
|
connected_user_id=sender_id,
|
||||||
|
person_id=person_for_receiver.id,
|
||||||
|
)
|
||||||
|
conn_b = UserConnection(
|
||||||
|
user_id=sender_id,
|
||||||
|
connected_user_id=current_user.id,
|
||||||
|
person_id=person_for_sender.id,
|
||||||
|
)
|
||||||
|
db.add(conn_a)
|
||||||
|
db.add(conn_b)
|
||||||
|
|
||||||
|
# Notification to sender
|
||||||
|
receiver_display = (receiver_settings.preferred_name if receiver_settings else None) or current_user.umbral_name
|
||||||
|
await create_notification(
|
||||||
|
db,
|
||||||
|
user_id=sender_id,
|
||||||
|
type="connection_accepted",
|
||||||
|
title="Connection Accepted",
|
||||||
|
message=f"{receiver_display} accepted your connection request",
|
||||||
|
data={"connected_umbral_name": current_user.umbral_name},
|
||||||
|
source_type="user_connection",
|
||||||
|
source_id=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
await log_audit_event(
|
||||||
|
db,
|
||||||
|
action="connection.accepted",
|
||||||
|
actor_id=current_user.id,
|
||||||
|
target_id=sender_id,
|
||||||
|
detail={"request_id": request_id},
|
||||||
|
ip=get_client_ip(request),
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# ntfy push in background
|
||||||
|
if sender_settings:
|
||||||
|
background_tasks.add_task(
|
||||||
|
send_connection_ntfy,
|
||||||
|
sender_settings,
|
||||||
|
receiver_display,
|
||||||
|
"request_accepted",
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"message": "Connection accepted", "connection_id": conn_a.id}
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Reject — only create notification for receiver (not sender per plan)
|
||||||
|
await log_audit_event(
|
||||||
|
db,
|
||||||
|
action="connection.rejected",
|
||||||
|
actor_id=current_user.id,
|
||||||
|
target_id=sender_id,
|
||||||
|
detail={"request_id": request_id},
|
||||||
|
ip=get_client_ip(request),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return {"message": "Connection request rejected"}
|
||||||
|
|
||||||
|
|
||||||
|
# ── GET / ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/", response_model=list[ConnectionResponse])
|
||||||
|
async def list_connections(
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
per_page: int = Query(50, ge=1, le=100),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""List all connections for the current user."""
|
||||||
|
offset = (page - 1) * per_page
|
||||||
|
result = await db.execute(
|
||||||
|
select(UserConnection)
|
||||||
|
.where(UserConnection.user_id == current_user.id)
|
||||||
|
.options(selectinload(UserConnection.connected_user))
|
||||||
|
.order_by(UserConnection.created_at.desc())
|
||||||
|
.offset(offset)
|
||||||
|
.limit(per_page)
|
||||||
|
)
|
||||||
|
connections = result.scalars().all()
|
||||||
|
|
||||||
|
responses = []
|
||||||
|
for conn in connections:
|
||||||
|
conn_settings = await _get_settings_for_user(db, conn.connected_user_id)
|
||||||
|
responses.append(ConnectionResponse(
|
||||||
|
id=conn.id,
|
||||||
|
connected_user_id=conn.connected_user_id,
|
||||||
|
connected_umbral_name=conn.connected_user.umbral_name,
|
||||||
|
connected_preferred_name=conn_settings.preferred_name if conn_settings else None,
|
||||||
|
person_id=conn.person_id,
|
||||||
|
created_at=conn.created_at,
|
||||||
|
))
|
||||||
|
|
||||||
|
return responses
|
||||||
|
|
||||||
|
|
||||||
|
# ── GET /{id} ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/{connection_id}", response_model=ConnectionResponse)
|
||||||
|
async def get_connection(
|
||||||
|
connection_id: int = Path(ge=1, le=2147483647),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Get a single connection detail."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(UserConnection)
|
||||||
|
.where(
|
||||||
|
UserConnection.id == connection_id,
|
||||||
|
UserConnection.user_id == current_user.id,
|
||||||
|
)
|
||||||
|
.options(selectinload(UserConnection.connected_user))
|
||||||
|
)
|
||||||
|
conn = result.scalar_one_or_none()
|
||||||
|
if not conn:
|
||||||
|
raise HTTPException(status_code=404, detail="Connection not found")
|
||||||
|
|
||||||
|
conn_settings = await _get_settings_for_user(db, conn.connected_user_id)
|
||||||
|
return ConnectionResponse(
|
||||||
|
id=conn.id,
|
||||||
|
connected_user_id=conn.connected_user_id,
|
||||||
|
connected_umbral_name=conn.connected_user.umbral_name,
|
||||||
|
connected_preferred_name=conn_settings.preferred_name if conn_settings else None,
|
||||||
|
person_id=conn.person_id,
|
||||||
|
created_at=conn.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── GET /{id}/shared-profile ────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/{connection_id}/shared-profile")
|
||||||
|
async def get_shared_profile(
|
||||||
|
connection_id: int = Path(ge=1, le=2147483647),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Get the resolved shared profile for a connection."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(UserConnection)
|
||||||
|
.where(
|
||||||
|
UserConnection.id == connection_id,
|
||||||
|
UserConnection.user_id == current_user.id,
|
||||||
|
)
|
||||||
|
.options(selectinload(UserConnection.connected_user))
|
||||||
|
)
|
||||||
|
conn = result.scalar_one_or_none()
|
||||||
|
if not conn:
|
||||||
|
raise HTTPException(status_code=404, detail="Connection not found")
|
||||||
|
|
||||||
|
conn_settings = await _get_settings_for_user(db, conn.connected_user_id)
|
||||||
|
if not conn_settings:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
return resolve_shared_profile(
|
||||||
|
conn.connected_user,
|
||||||
|
conn_settings,
|
||||||
|
conn.sharing_overrides,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── PUT /{id}/sharing-overrides ─────────────────────────────────────
|
||||||
|
|
||||||
|
@router.put("/{connection_id}/sharing-overrides")
|
||||||
|
async def update_sharing_overrides(
|
||||||
|
body: SharingOverrideUpdate,
|
||||||
|
connection_id: int = Path(ge=1, le=2147483647),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Update what YOU share with a specific connection."""
|
||||||
|
# Find the connection where the OTHER user connects to YOU
|
||||||
|
result = await db.execute(
|
||||||
|
select(UserConnection).where(
|
||||||
|
UserConnection.connected_user_id == current_user.id,
|
||||||
|
UserConnection.user_id != current_user.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# We need the reverse connection (where we are the connected_user)
|
||||||
|
# Actually, we need to find the connection from the counterpart's perspective
|
||||||
|
# The connection_id is OUR connection. The sharing overrides go on the
|
||||||
|
# counterpart's connection row (since they determine what they see from us).
|
||||||
|
# Wait — per the plan, sharing overrides control what WE share with THEM.
|
||||||
|
# So they go on their connection row pointing to us.
|
||||||
|
|
||||||
|
# First, get our connection to know who the counterpart is
|
||||||
|
our_conn = await db.execute(
|
||||||
|
select(UserConnection).where(
|
||||||
|
UserConnection.id == connection_id,
|
||||||
|
UserConnection.user_id == current_user.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
conn = our_conn.scalar_one_or_none()
|
||||||
|
if not conn:
|
||||||
|
raise HTTPException(status_code=404, detail="Connection not found")
|
||||||
|
|
||||||
|
# Find the reverse connection (their row pointing to us)
|
||||||
|
reverse_result = await db.execute(
|
||||||
|
select(UserConnection).where(
|
||||||
|
UserConnection.user_id == conn.connected_user_id,
|
||||||
|
UserConnection.connected_user_id == current_user.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
reverse_conn = reverse_result.scalar_one_or_none()
|
||||||
|
if not reverse_conn:
|
||||||
|
raise HTTPException(status_code=404, detail="Reverse connection not found")
|
||||||
|
|
||||||
|
# Build validated overrides dict — only SHAREABLE_FIELDS keys
|
||||||
|
overrides = {}
|
||||||
|
update_data = body.model_dump(exclude_unset=True)
|
||||||
|
for key, value in update_data.items():
|
||||||
|
if key in SHAREABLE_FIELDS:
|
||||||
|
overrides[key] = value
|
||||||
|
|
||||||
|
reverse_conn.sharing_overrides = overrides if overrides else None
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
return {"message": "Sharing overrides updated"}
|
||||||
|
|
||||||
|
|
||||||
|
# ── DELETE /{id} ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.delete("/{connection_id}", status_code=204)
|
||||||
|
async def remove_connection(
|
||||||
|
request: Request,
|
||||||
|
connection_id: int = Path(ge=1, le=2147483647),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Remove a connection. Removes BOTH UserConnection rows.
|
||||||
|
Detaches BOTH Person records (sets linked_user_id=null, is_umbral_contact=false).
|
||||||
|
Silent — no notification sent.
|
||||||
|
"""
|
||||||
|
# Get our connection
|
||||||
|
result = await db.execute(
|
||||||
|
select(UserConnection)
|
||||||
|
.where(
|
||||||
|
UserConnection.id == connection_id,
|
||||||
|
UserConnection.user_id == current_user.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
conn = result.scalar_one_or_none()
|
||||||
|
if not conn:
|
||||||
|
raise HTTPException(status_code=404, detail="Connection not found")
|
||||||
|
|
||||||
|
counterpart_id = conn.connected_user_id
|
||||||
|
|
||||||
|
# Find reverse connection
|
||||||
|
reverse_result = await db.execute(
|
||||||
|
select(UserConnection).where(
|
||||||
|
UserConnection.user_id == counterpart_id,
|
||||||
|
UserConnection.connected_user_id == current_user.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
reverse_conn = reverse_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
# Detach Person records
|
||||||
|
if conn.person_id:
|
||||||
|
person_result = await db.execute(select(Person).where(Person.id == conn.person_id))
|
||||||
|
person = person_result.scalar_one_or_none()
|
||||||
|
if person:
|
||||||
|
await detach_umbral_contact(person)
|
||||||
|
|
||||||
|
if reverse_conn and reverse_conn.person_id:
|
||||||
|
person_result = await db.execute(select(Person).where(Person.id == reverse_conn.person_id))
|
||||||
|
person = person_result.scalar_one_or_none()
|
||||||
|
if person:
|
||||||
|
await detach_umbral_contact(person)
|
||||||
|
|
||||||
|
# Delete both connections
|
||||||
|
await db.delete(conn)
|
||||||
|
if reverse_conn:
|
||||||
|
await db.delete(reverse_conn)
|
||||||
|
|
||||||
|
await log_audit_event(
|
||||||
|
db,
|
||||||
|
action="connection.removed",
|
||||||
|
actor_id=current_user.id,
|
||||||
|
target_id=counterpart_id,
|
||||||
|
detail={"connection_id": connection_id},
|
||||||
|
ip=get_client_ip(request),
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
return None
|
||||||
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),
|
||||||
|
type: str | None = Query(None, max_length=50),
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
per_page: int = Query(20, ge=1, le=100),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Paginated notification list with optional filters."""
|
||||||
|
base = select(Notification).where(Notification.user_id == current_user.id)
|
||||||
|
|
||||||
|
if unread_only:
|
||||||
|
base = base.where(Notification.is_read == False) # noqa: E712
|
||||||
|
if type:
|
||||||
|
base = base.where(Notification.type == type)
|
||||||
|
|
||||||
|
# Total count
|
||||||
|
count_q = select(func.count()).select_from(base.subquery())
|
||||||
|
total = await db.scalar(count_q) or 0
|
||||||
|
|
||||||
|
# Unread count (always full, regardless of filters)
|
||||||
|
unread_count = await db.scalar(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(Notification)
|
||||||
|
.where(
|
||||||
|
Notification.user_id == current_user.id,
|
||||||
|
Notification.is_read == False, # noqa: E712
|
||||||
|
)
|
||||||
|
) or 0
|
||||||
|
|
||||||
|
# Paginated results
|
||||||
|
offset = (page - 1) * per_page
|
||||||
|
result = await db.execute(
|
||||||
|
base.order_by(Notification.created_at.desc()).offset(offset).limit(per_page)
|
||||||
|
)
|
||||||
|
notifications = result.scalars().all()
|
||||||
|
|
||||||
|
return NotificationListResponse(
|
||||||
|
notifications=notifications,
|
||||||
|
unread_count=unread_count,
|
||||||
|
total=total,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/unread-count")
|
||||||
|
async def get_unread_count(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Lightweight unread count endpoint (uses partial index)."""
|
||||||
|
count = await db.scalar(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(Notification)
|
||||||
|
.where(
|
||||||
|
Notification.user_id == current_user.id,
|
||||||
|
Notification.is_read == False, # noqa: E712
|
||||||
|
)
|
||||||
|
) or 0
|
||||||
|
return {"count": count}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/read")
|
||||||
|
async def mark_read(
|
||||||
|
body: MarkReadRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Mark specific notification IDs as read (user_id scoped — IDOR prevention)."""
|
||||||
|
await db.execute(
|
||||||
|
update(Notification)
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
Notification.id.in_(body.notification_ids),
|
||||||
|
Notification.user_id == current_user.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.values(is_read=True)
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return {"message": "Notifications marked as read"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/read-all")
|
||||||
|
async def mark_all_read(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Mark all notifications as read for current user."""
|
||||||
|
await db.execute(
|
||||||
|
update(Notification)
|
||||||
|
.where(
|
||||||
|
Notification.user_id == current_user.id,
|
||||||
|
Notification.is_read == False, # noqa: E712
|
||||||
|
)
|
||||||
|
.values(is_read=True)
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return {"message": "All notifications marked as read"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{notification_id}", status_code=204)
|
||||||
|
async def delete_notification(
|
||||||
|
notification_id: int = Path(ge=1, le=2147483647),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Delete a single notification (user_id scoped)."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Notification).where(
|
||||||
|
Notification.id == notification_id,
|
||||||
|
Notification.user_id == current_user.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
notification = result.scalar_one_or_none()
|
||||||
|
if not notification:
|
||||||
|
raise HTTPException(status_code=404, detail="Notification not found")
|
||||||
|
|
||||||
|
await db.delete(notification)
|
||||||
|
await db.commit()
|
||||||
|
return None
|
||||||
@ -1,14 +1,18 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query
|
from fastapi import APIRouter, Depends, HTTPException, Path, Query
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, or_
|
from sqlalchemy import select, or_
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models.person import Person
|
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.schemas.person import PersonCreate, PersonUpdate, PersonResponse
|
||||||
from app.routers.auth import get_current_user
|
from app.routers.auth import get_current_user
|
||||||
from app.models.user import User
|
from app.services.connection import resolve_shared_profile
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@ -59,6 +63,53 @@ async def get_people(
|
|||||||
result = await db.execute(query)
|
result = await db.execute(query)
|
||||||
people = result.scalars().all()
|
people = result.scalars().all()
|
||||||
|
|
||||||
|
# Batch-load shared profiles for umbral contacts
|
||||||
|
umbral_people = [p for p in people if p.linked_user_id is not None]
|
||||||
|
if umbral_people:
|
||||||
|
linked_user_ids = [p.linked_user_id for p in umbral_people]
|
||||||
|
|
||||||
|
# Batch fetch users and settings
|
||||||
|
users_result = await db.execute(
|
||||||
|
select(User).where(User.id.in_(linked_user_ids))
|
||||||
|
)
|
||||||
|
users_by_id = {u.id: u for u in users_result.scalars().all()}
|
||||||
|
|
||||||
|
settings_result = await db.execute(
|
||||||
|
select(Settings).where(Settings.user_id.in_(linked_user_ids))
|
||||||
|
)
|
||||||
|
settings_by_user = {s.user_id: s for s in settings_result.scalars().all()}
|
||||||
|
|
||||||
|
# Batch fetch connection overrides
|
||||||
|
conns_result = await db.execute(
|
||||||
|
select(UserConnection).where(
|
||||||
|
UserConnection.user_id == current_user.id,
|
||||||
|
UserConnection.connected_user_id.in_(linked_user_ids),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
overrides_by_user = {
|
||||||
|
c.connected_user_id: c.sharing_overrides
|
||||||
|
for c in conns_result.scalars().all()
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build shared profiles
|
||||||
|
shared_profiles: dict[int, dict] = {}
|
||||||
|
for uid in linked_user_ids:
|
||||||
|
user = users_by_id.get(uid)
|
||||||
|
user_settings = settings_by_user.get(uid)
|
||||||
|
if user and user_settings:
|
||||||
|
shared_profiles[uid] = resolve_shared_profile(
|
||||||
|
user, user_settings, overrides_by_user.get(uid)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Attach to response
|
||||||
|
responses = []
|
||||||
|
for p in people:
|
||||||
|
resp = PersonResponse.model_validate(p)
|
||||||
|
if p.linked_user_id and p.linked_user_id in shared_profiles:
|
||||||
|
resp.shared_fields = shared_profiles[p.linked_user_id]
|
||||||
|
responses.append(resp)
|
||||||
|
return responses
|
||||||
|
|
||||||
return people
|
return people
|
||||||
|
|
||||||
|
|
||||||
@ -104,7 +155,28 @@ async def get_person(
|
|||||||
if not person:
|
if not person:
|
||||||
raise HTTPException(status_code=404, detail="Person not found")
|
raise HTTPException(status_code=404, detail="Person not found")
|
||||||
|
|
||||||
return person
|
resp = PersonResponse.model_validate(person)
|
||||||
|
if person.linked_user_id:
|
||||||
|
linked_user_result = await db.execute(
|
||||||
|
select(User).where(User.id == person.linked_user_id)
|
||||||
|
)
|
||||||
|
linked_user = linked_user_result.scalar_one_or_none()
|
||||||
|
linked_settings_result = await db.execute(
|
||||||
|
select(Settings).where(Settings.user_id == person.linked_user_id)
|
||||||
|
)
|
||||||
|
linked_settings = linked_settings_result.scalar_one_or_none()
|
||||||
|
conn_result = await db.execute(
|
||||||
|
select(UserConnection).where(
|
||||||
|
UserConnection.user_id == current_user.id,
|
||||||
|
UserConnection.connected_user_id == person.linked_user_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
conn = conn_result.scalar_one_or_none()
|
||||||
|
if linked_user and linked_settings:
|
||||||
|
resp.shared_fields = resolve_shared_profile(
|
||||||
|
linked_user, linked_settings, conn.sharing_overrides if conn else None
|
||||||
|
)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{person_id}", response_model=PersonResponse)
|
@router.put("/{person_id}", response_model=PersonResponse)
|
||||||
|
|||||||
@ -39,6 +39,25 @@ def _to_settings_response(s: Settings) -> SettingsResponse:
|
|||||||
ntfy_has_token=bool(s.ntfy_auth_token), # derived — never expose the token value
|
ntfy_has_token=bool(s.ntfy_auth_token), # derived — never expose the token value
|
||||||
auto_lock_enabled=s.auto_lock_enabled,
|
auto_lock_enabled=s.auto_lock_enabled,
|
||||||
auto_lock_minutes=s.auto_lock_minutes,
|
auto_lock_minutes=s.auto_lock_minutes,
|
||||||
|
# Profile fields
|
||||||
|
phone=s.phone,
|
||||||
|
mobile=s.mobile,
|
||||||
|
address=s.address,
|
||||||
|
company=s.company,
|
||||||
|
job_title=s.job_title,
|
||||||
|
# Social settings
|
||||||
|
accept_connections=s.accept_connections,
|
||||||
|
# Sharing defaults
|
||||||
|
share_preferred_name=s.share_preferred_name,
|
||||||
|
share_email=s.share_email,
|
||||||
|
share_phone=s.share_phone,
|
||||||
|
share_mobile=s.share_mobile,
|
||||||
|
share_birthday=s.share_birthday,
|
||||||
|
share_address=s.share_address,
|
||||||
|
share_company=s.share_company,
|
||||||
|
share_job_title=s.share_job_title,
|
||||||
|
# ntfy connections toggle
|
||||||
|
ntfy_connections_enabled=s.ntfy_connections_enabled,
|
||||||
created_at=s.created_at,
|
created_at=s.created_at,
|
||||||
updated_at=s.updated_at,
|
updated_at=s.updated_at,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -20,6 +20,7 @@ from app.schemas.auth import _validate_username, _validate_password_strength, _v
|
|||||||
class UserListItem(BaseModel):
|
class UserListItem(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
username: str
|
username: str
|
||||||
|
umbral_name: str = ""
|
||||||
email: Optional[str] = None
|
email: Optional[str] = None
|
||||||
first_name: Optional[str] = None
|
first_name: Optional[str] = None
|
||||||
last_name: Optional[str] = None
|
last_name: Optional[str] = None
|
||||||
|
|||||||
75
backend/app/schemas/connection.py
Normal file
75
backend/app/schemas/connection.py
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
"""
|
||||||
|
Connection schemas — search, request, respond, connection management.
|
||||||
|
All input schemas use extra="forbid" to prevent mass-assignment.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
from typing import Literal, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||||
|
|
||||||
|
_UMBRAL_NAME_RE = re.compile(r'^[a-zA-Z0-9_-]{3,50}$')
|
||||||
|
|
||||||
|
|
||||||
|
class UmbralSearchRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
umbral_name: str = Field(..., max_length=50)
|
||||||
|
|
||||||
|
@field_validator('umbral_name')
|
||||||
|
@classmethod
|
||||||
|
def validate_umbral_name(cls, v: str) -> str:
|
||||||
|
if not _UMBRAL_NAME_RE.match(v):
|
||||||
|
raise ValueError('Umbral name must be 3-50 alphanumeric characters, hyphens, or underscores')
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class UmbralSearchResponse(BaseModel):
|
||||||
|
found: bool
|
||||||
|
|
||||||
|
|
||||||
|
class SendConnectionRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
umbral_name: str = Field(..., max_length=50)
|
||||||
|
|
||||||
|
@field_validator('umbral_name')
|
||||||
|
@classmethod
|
||||||
|
def validate_umbral_name(cls, v: str) -> str:
|
||||||
|
if not _UMBRAL_NAME_RE.match(v):
|
||||||
|
raise ValueError('Umbral name must be 3-50 alphanumeric characters, hyphens, or underscores')
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectionRequestResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
sender_umbral_name: str
|
||||||
|
sender_preferred_name: Optional[str] = None
|
||||||
|
receiver_umbral_name: str
|
||||||
|
receiver_preferred_name: Optional[str] = None
|
||||||
|
status: str
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class RespondRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
action: Literal["accept", "reject"]
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectionResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
connected_user_id: int
|
||||||
|
connected_umbral_name: str
|
||||||
|
connected_preferred_name: Optional[str] = None
|
||||||
|
person_id: Optional[int] = None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class SharingOverrideUpdate(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
preferred_name: Optional[bool] = None
|
||||||
|
email: Optional[bool] = None
|
||||||
|
phone: Optional[bool] = None
|
||||||
|
mobile: Optional[bool] = None
|
||||||
|
birthday: Optional[bool] = None
|
||||||
|
address: Optional[bool] = None
|
||||||
|
company: Optional[bool] = None
|
||||||
|
job_title: Optional[bool] = None
|
||||||
30
backend/app/schemas/notification.py
Normal file
30
backend/app/schemas/notification.py
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
user_id: int
|
||||||
|
type: str
|
||||||
|
title: Optional[str] = None
|
||||||
|
message: Optional[str] = None
|
||||||
|
data: Optional[dict] = None
|
||||||
|
source_type: Optional[str] = None
|
||||||
|
source_id: Optional[int] = None
|
||||||
|
is_read: bool
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationListResponse(BaseModel):
|
||||||
|
notifications: list[NotificationResponse]
|
||||||
|
unread_count: int
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
class MarkReadRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
notification_ids: list[int] = Field(..., min_length=1, max_length=100)
|
||||||
@ -85,6 +85,9 @@ class PersonResponse(BaseModel):
|
|||||||
company: Optional[str]
|
company: Optional[str]
|
||||||
job_title: Optional[str]
|
job_title: Optional[str]
|
||||||
notes: Optional[str]
|
notes: Optional[str]
|
||||||
|
linked_user_id: Optional[int] = None
|
||||||
|
is_umbral_contact: bool = False
|
||||||
|
shared_fields: Optional[dict] = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|||||||
@ -37,6 +37,29 @@ class SettingsUpdate(BaseModel):
|
|||||||
auto_lock_enabled: Optional[bool] = None
|
auto_lock_enabled: Optional[bool] = None
|
||||||
auto_lock_minutes: Optional[int] = None
|
auto_lock_minutes: Optional[int] = None
|
||||||
|
|
||||||
|
# Profile fields (shareable with connections)
|
||||||
|
phone: Optional[str] = Field(None, max_length=50)
|
||||||
|
mobile: Optional[str] = Field(None, max_length=50)
|
||||||
|
address: Optional[str] = Field(None, max_length=2000)
|
||||||
|
company: Optional[str] = Field(None, max_length=255)
|
||||||
|
job_title: Optional[str] = Field(None, max_length=255)
|
||||||
|
|
||||||
|
# Social settings
|
||||||
|
accept_connections: Optional[bool] = None
|
||||||
|
|
||||||
|
# Sharing defaults
|
||||||
|
share_preferred_name: Optional[bool] = None
|
||||||
|
share_email: Optional[bool] = None
|
||||||
|
share_phone: Optional[bool] = None
|
||||||
|
share_mobile: Optional[bool] = None
|
||||||
|
share_birthday: Optional[bool] = None
|
||||||
|
share_address: Optional[bool] = None
|
||||||
|
share_company: Optional[bool] = None
|
||||||
|
share_job_title: Optional[bool] = None
|
||||||
|
|
||||||
|
# ntfy connections toggle
|
||||||
|
ntfy_connections_enabled: Optional[bool] = None
|
||||||
|
|
||||||
@field_validator('auto_lock_minutes')
|
@field_validator('auto_lock_minutes')
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_auto_lock_minutes(cls, v: Optional[int]) -> Optional[int]:
|
def validate_auto_lock_minutes(cls, v: Optional[int]) -> Optional[int]:
|
||||||
@ -151,6 +174,29 @@ class SettingsResponse(BaseModel):
|
|||||||
auto_lock_enabled: bool = False
|
auto_lock_enabled: bool = False
|
||||||
auto_lock_minutes: int = 5
|
auto_lock_minutes: int = 5
|
||||||
|
|
||||||
|
# Profile fields
|
||||||
|
phone: Optional[str] = None
|
||||||
|
mobile: Optional[str] = None
|
||||||
|
address: Optional[str] = None
|
||||||
|
company: Optional[str] = None
|
||||||
|
job_title: Optional[str] = None
|
||||||
|
|
||||||
|
# Social settings
|
||||||
|
accept_connections: bool = False
|
||||||
|
|
||||||
|
# Sharing defaults
|
||||||
|
share_preferred_name: bool = True
|
||||||
|
share_email: bool = False
|
||||||
|
share_phone: bool = False
|
||||||
|
share_mobile: bool = False
|
||||||
|
share_birthday: bool = False
|
||||||
|
share_address: bool = False
|
||||||
|
share_company: bool = False
|
||||||
|
share_job_title: bool = False
|
||||||
|
|
||||||
|
# ntfy connections toggle
|
||||||
|
ntfy_connections_enabled: bool = True
|
||||||
|
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|||||||
168
backend/app/services/connection.py
Normal file
168
backend/app/services/connection.py
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
"""
|
||||||
|
Connection service — shared profile resolution, Person creation, ntfy dispatch.
|
||||||
|
|
||||||
|
SHAREABLE_FIELDS is the single source of truth for which fields can be shared.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.person import Person
|
||||||
|
from app.models.settings import Settings
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services.ntfy import send_ntfy_notification
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Single source of truth — only these fields can be shared via connections
|
||||||
|
SHAREABLE_FIELDS = frozenset({
|
||||||
|
"preferred_name", "email", "phone", "mobile",
|
||||||
|
"birthday", "address", "company", "job_title",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Maps shareable field names to their Settings model column names
|
||||||
|
_SETTINGS_FIELD_MAP = {
|
||||||
|
"preferred_name": "preferred_name",
|
||||||
|
"email": None, # email comes from User model, not Settings
|
||||||
|
"phone": "phone",
|
||||||
|
"mobile": "mobile",
|
||||||
|
"birthday": None, # birthday comes from User model (date_of_birth)
|
||||||
|
"address": "address",
|
||||||
|
"company": "company",
|
||||||
|
"job_title": "job_title",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_shared_profile(
|
||||||
|
user: User,
|
||||||
|
settings: Settings,
|
||||||
|
overrides: Optional[dict] = None,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Merge global sharing defaults with per-connection overrides.
|
||||||
|
Returns {field: value} dict of fields the user is sharing.
|
||||||
|
Only fields in SHAREABLE_FIELDS are included.
|
||||||
|
"""
|
||||||
|
overrides = overrides or {}
|
||||||
|
result = {}
|
||||||
|
|
||||||
|
for field in SHAREABLE_FIELDS:
|
||||||
|
# Determine if this field is shared: override wins, else global default
|
||||||
|
share_key = f"share_{field}"
|
||||||
|
global_share = getattr(settings, share_key, False)
|
||||||
|
is_shared = overrides.get(field, global_share)
|
||||||
|
|
||||||
|
if not is_shared:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Resolve the actual value
|
||||||
|
if field == "preferred_name":
|
||||||
|
result[field] = settings.preferred_name
|
||||||
|
elif field == "email":
|
||||||
|
result[field] = user.email
|
||||||
|
elif field == "birthday":
|
||||||
|
result[field] = str(user.date_of_birth) if user.date_of_birth else None
|
||||||
|
elif field in _SETTINGS_FIELD_MAP and _SETTINGS_FIELD_MAP[field]:
|
||||||
|
result[field] = getattr(settings, _SETTINGS_FIELD_MAP[field], None)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def create_person_from_connection(
|
||||||
|
owner_user_id: int,
|
||||||
|
connected_user: User,
|
||||||
|
connected_settings: Settings,
|
||||||
|
shared_profile: dict,
|
||||||
|
) -> Person:
|
||||||
|
"""Create a Person record for a new connection. Does NOT add to session — caller does."""
|
||||||
|
# Use shared preferred_name for display, fall back to umbral_name
|
||||||
|
first_name = shared_profile.get("preferred_name") or connected_user.umbral_name
|
||||||
|
email = shared_profile.get("email")
|
||||||
|
phone = shared_profile.get("phone")
|
||||||
|
mobile = shared_profile.get("mobile")
|
||||||
|
address = shared_profile.get("address")
|
||||||
|
company = shared_profile.get("company")
|
||||||
|
job_title = shared_profile.get("job_title")
|
||||||
|
birthday_str = shared_profile.get("birthday")
|
||||||
|
|
||||||
|
from datetime import date as date_type
|
||||||
|
birthday = None
|
||||||
|
if birthday_str:
|
||||||
|
try:
|
||||||
|
birthday = date_type.fromisoformat(birthday_str)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Compute display name
|
||||||
|
display_name = first_name or connected_user.umbral_name
|
||||||
|
|
||||||
|
return Person(
|
||||||
|
user_id=owner_user_id,
|
||||||
|
name=display_name,
|
||||||
|
first_name=first_name,
|
||||||
|
email=email,
|
||||||
|
phone=phone,
|
||||||
|
mobile=mobile,
|
||||||
|
address=address,
|
||||||
|
company=company,
|
||||||
|
job_title=job_title,
|
||||||
|
birthday=birthday,
|
||||||
|
category="Umbral",
|
||||||
|
linked_user_id=connected_user.id,
|
||||||
|
is_umbral_contact=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def detach_umbral_contact(person: Person) -> None:
|
||||||
|
"""Convert an umbral contact back to a standard contact. Does NOT commit."""
|
||||||
|
person.linked_user_id = None
|
||||||
|
person.is_umbral_contact = False
|
||||||
|
# Clear shared field values but preserve locally-entered data
|
||||||
|
# If no first_name exists, fill from the old name
|
||||||
|
if not person.first_name:
|
||||||
|
person.first_name = person.name or None
|
||||||
|
|
||||||
|
|
||||||
|
async def send_connection_ntfy(
|
||||||
|
settings: Settings,
|
||||||
|
sender_name: str,
|
||||||
|
event_type: str,
|
||||||
|
) -> None:
|
||||||
|
"""Send ntfy push for connection events. Non-blocking with 3s timeout."""
|
||||||
|
if not settings.ntfy_connections_enabled:
|
||||||
|
return
|
||||||
|
|
||||||
|
title_map = {
|
||||||
|
"request_received": "New Connection Request",
|
||||||
|
"request_accepted": "Connection Accepted",
|
||||||
|
}
|
||||||
|
message_map = {
|
||||||
|
"request_received": f"{sender_name} wants to connect with you on Umbra",
|
||||||
|
"request_accepted": f"{sender_name} accepted your connection request",
|
||||||
|
}
|
||||||
|
tag_map = {
|
||||||
|
"request_received": ["handshake"],
|
||||||
|
"request_accepted": ["white_check_mark"],
|
||||||
|
}
|
||||||
|
|
||||||
|
title = title_map.get(event_type, "Connection Update")
|
||||||
|
message = message_map.get(event_type, f"Connection update from {sender_name}")
|
||||||
|
tags = tag_map.get(event_type, ["bell"])
|
||||||
|
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(
|
||||||
|
send_ntfy_notification(
|
||||||
|
settings=settings,
|
||||||
|
title=title,
|
||||||
|
message=message,
|
||||||
|
tags=tags,
|
||||||
|
priority=3,
|
||||||
|
),
|
||||||
|
timeout=3.0,
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.warning("ntfy connection push timed out for user_id=%s", settings.user_id)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("ntfy connection push failed for user_id=%s", settings.user_id)
|
||||||
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;
|
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
|
# 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;
|
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
|
# Use X-Forwarded-Proto from upstream proxy when present, fall back to $scheme for direct access
|
||||||
map $http_x_forwarded_proto $forwarded_proto {
|
map $http_x_forwarded_proto $forwarded_proto {
|
||||||
@ -82,6 +85,20 @@ server {
|
|||||||
include /etc/nginx/proxy-params.conf;
|
include /etc/nginx/proxy-params.conf;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Connection search — rate-limited to prevent user enumeration
|
||||||
|
location /api/connections/search {
|
||||||
|
limit_req zone=conn_search_limit burst=5 nodelay;
|
||||||
|
limit_req_status 429;
|
||||||
|
include /etc/nginx/proxy-params.conf;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Connection request — rate-limited to prevent spam
|
||||||
|
location /api/connections/request {
|
||||||
|
limit_req zone=conn_request_limit burst=3 nodelay;
|
||||||
|
limit_req_status 429;
|
||||||
|
include /etc/nginx/proxy-params.conf;
|
||||||
|
}
|
||||||
|
|
||||||
# Admin API — rate-limited separately from general /api traffic
|
# Admin API — rate-limited separately from general /api traffic
|
||||||
location /api/admin/ {
|
location /api/admin/ {
|
||||||
limit_req zone=admin_limit burst=10 nodelay;
|
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 PeoplePage from '@/components/people/PeoplePage';
|
||||||
import LocationsPage from '@/components/locations/LocationsPage';
|
import LocationsPage from '@/components/locations/LocationsPage';
|
||||||
import SettingsPage from '@/components/settings/SettingsPage';
|
import SettingsPage from '@/components/settings/SettingsPage';
|
||||||
|
import NotificationsPage from '@/components/notifications/NotificationsPage';
|
||||||
|
|
||||||
const AdminPortal = lazy(() => import('@/components/admin/AdminPortal'));
|
const AdminPortal = lazy(() => import('@/components/admin/AdminPortal'));
|
||||||
|
|
||||||
@ -72,6 +73,7 @@ function App() {
|
|||||||
<Route path="projects/:id" element={<ProjectDetail />} />
|
<Route path="projects/:id" element={<ProjectDetail />} />
|
||||||
<Route path="people" element={<PeoplePage />} />
|
<Route path="people" element={<PeoplePage />} />
|
||||||
<Route path="locations" element={<LocationsPage />} />
|
<Route path="locations" element={<LocationsPage />} />
|
||||||
|
<Route path="notifications" element={<NotificationsPage />} />
|
||||||
<Route path="settings" element={<SettingsPage />} />
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
<Route
|
<Route
|
||||||
path="admin/*"
|
path="admin/*"
|
||||||
|
|||||||
@ -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">
|
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||||
Username
|
Username
|
||||||
</th>
|
</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">
|
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||||
Email
|
Email
|
||||||
</th>
|
</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 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">
|
<td className="px-5 py-3 text-muted-foreground text-xs">
|
||||||
{user.email || '—'}
|
{user.email || '—'}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@ -0,0 +1,75 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Check, X, Loader2 } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useConnections } from '@/hooks/useConnections';
|
||||||
|
import { getErrorMessage } from '@/lib/api';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { ConnectionRequest } from '@/types';
|
||||||
|
|
||||||
|
interface ConnectionRequestCardProps {
|
||||||
|
request: ConnectionRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ConnectionRequestCard({ request }: ConnectionRequestCardProps) {
|
||||||
|
const { respond, isResponding } = useConnections();
|
||||||
|
const [resolved, setResolved] = useState(false);
|
||||||
|
|
||||||
|
const handleRespond = async (action: 'accept' | 'reject') => {
|
||||||
|
try {
|
||||||
|
await respond({ requestId: request.id, action });
|
||||||
|
setResolved(true);
|
||||||
|
toast.success(action === 'accept' ? 'Connection accepted' : 'Request declined');
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(getErrorMessage(err, 'Failed to respond'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayName = request.sender_preferred_name || request.sender_umbral_name;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 rounded-lg border border-border p-3 transition-all duration-300',
|
||||||
|
resolved && 'opacity-0 translate-y-2'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Avatar */}
|
||||||
|
<div className="h-9 w-9 rounded-full bg-violet-500/15 flex items-center justify-center shrink-0">
|
||||||
|
<span className="text-sm font-medium text-violet-400">
|
||||||
|
{displayName.charAt(0).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium truncate">{displayName}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
wants to connect · {formatDistanceToNow(new Date(request.created_at), { addSuffix: true })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleRespond('accept')}
|
||||||
|
disabled={isResponding}
|
||||||
|
className="gap-1"
|
||||||
|
>
|
||||||
|
{isResponding ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />}
|
||||||
|
Accept
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleRespond('reject')}
|
||||||
|
disabled={isResponding}
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
142
frontend/src/components/connections/ConnectionSearch.tsx
Normal file
142
frontend/src/components/connections/ConnectionSearch.tsx
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Search, UserPlus, Loader2, AlertCircle, CheckCircle } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useConnections } from '@/hooks/useConnections';
|
||||||
|
import { getErrorMessage } from '@/lib/api';
|
||||||
|
|
||||||
|
interface ConnectionSearchProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ConnectionSearch({ open, onOpenChange }: ConnectionSearchProps) {
|
||||||
|
const { search, isSearching, sendRequest, isSending } = useConnections();
|
||||||
|
const [umbralName, setUmbralName] = useState('');
|
||||||
|
const [found, setFound] = useState<boolean | null>(null);
|
||||||
|
const [sent, setSent] = useState(false);
|
||||||
|
|
||||||
|
const handleSearch = async () => {
|
||||||
|
if (!umbralName.trim()) return;
|
||||||
|
setFound(null);
|
||||||
|
setSent(false);
|
||||||
|
try {
|
||||||
|
const result = await search(umbralName.trim());
|
||||||
|
setFound(result.found);
|
||||||
|
} catch {
|
||||||
|
setFound(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSend = async () => {
|
||||||
|
try {
|
||||||
|
await sendRequest(umbralName.trim());
|
||||||
|
setSent(true);
|
||||||
|
toast.success('Connection request sent');
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(getErrorMessage(err, 'Failed to send request'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setUmbralName('');
|
||||||
|
setFound(null);
|
||||||
|
setSent(false);
|
||||||
|
onOpenChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleClose}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<UserPlus className="h-5 w-5 text-violet-400" />
|
||||||
|
Find Umbra User
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Search for a user by their umbral name to send a connection request.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 pt-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="umbral_search">Umbral Name</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
id="umbral_search"
|
||||||
|
placeholder="Enter umbral name..."
|
||||||
|
value={umbralName}
|
||||||
|
onChange={(e) => {
|
||||||
|
setUmbralName(e.target.value);
|
||||||
|
setFound(null);
|
||||||
|
setSent(false);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') handleSearch(); }}
|
||||||
|
maxLength={50}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={handleSearch}
|
||||||
|
disabled={!umbralName.trim() || isSearching}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{isSearching ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Search className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{found === false && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
User not found
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{found === true && !sent && (
|
||||||
|
<div className="flex items-center justify-between rounded-lg border border-border p-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-8 w-8 rounded-full bg-violet-500/15 flex items-center justify-center">
|
||||||
|
<span className="text-sm font-medium text-violet-400">
|
||||||
|
{umbralName.charAt(0).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium">{umbralName}</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={isSending}
|
||||||
|
size="sm"
|
||||||
|
className="gap-1.5"
|
||||||
|
>
|
||||||
|
{isSending ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<UserPlus className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
Send Request
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sent && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-green-400">
|
||||||
|
<CheckCircle className="h-4 w-4" />
|
||||||
|
Connection request sent
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -22,6 +22,7 @@ import { cn } from '@/lib/utils';
|
|||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import { useLock } from '@/hooks/useLock';
|
import { useLock } from '@/hooks/useLock';
|
||||||
import { useConfirmAction } from '@/hooks/useConfirmAction';
|
import { useConfirmAction } from '@/hooks/useConfirmAction';
|
||||||
|
import { useNotifications } from '@/hooks/useNotifications';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import api from '@/lib/api';
|
import api from '@/lib/api';
|
||||||
import type { Project } from '@/types';
|
import type { Project } from '@/types';
|
||||||
@ -47,6 +48,7 @@ export default function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { logout, isAdmin } = useAuth();
|
const { logout, isAdmin } = useAuth();
|
||||||
const { lock } = useLock();
|
const { lock } = useLock();
|
||||||
|
const { unreadCount } = useNotifications();
|
||||||
const [projectsExpanded, setProjectsExpanded] = useState(false);
|
const [projectsExpanded, setProjectsExpanded] = useState(false);
|
||||||
|
|
||||||
const { data: trackedProjects } = useQuery({
|
const { data: trackedProjects } = useQuery({
|
||||||
@ -194,6 +196,28 @@ export default function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose
|
|||||||
<Lock className="h-5 w-5 shrink-0" />
|
<Lock className="h-5 w-5 shrink-0" />
|
||||||
{showExpanded && <span>Lock</span>}
|
{showExpanded && <span>Lock</span>}
|
||||||
</button>
|
</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 && (
|
{isAdmin && (
|
||||||
<NavLink
|
<NavLink
|
||||||
to="/admin"
|
to="/admin"
|
||||||
|
|||||||
203
frontend/src/components/notifications/NotificationsPage.tsx
Normal file
203
frontend/src/components/notifications/NotificationsPage.tsx
Normal file
@ -0,0 +1,203 @@
|
|||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Bell, Check, CheckCheck, Trash2, UserPlus, Info, AlertCircle } from 'lucide-react';
|
||||||
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
|
import { useNotifications } from '@/hooks/useNotifications';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { ListSkeleton } from '@/components/ui/skeleton';
|
||||||
|
import type { AppNotification } from '@/types';
|
||||||
|
|
||||||
|
const typeIcons: Record<string, { icon: typeof Bell; color: string }> = {
|
||||||
|
connection_request: { icon: UserPlus, color: 'text-violet-400' },
|
||||||
|
connection_accepted: { icon: UserPlus, color: 'text-green-400' },
|
||||||
|
info: { icon: Info, color: 'text-blue-400' },
|
||||||
|
warning: { icon: AlertCircle, color: 'text-amber-400' },
|
||||||
|
};
|
||||||
|
|
||||||
|
type Filter = 'all' | 'unread';
|
||||||
|
|
||||||
|
export default function NotificationsPage() {
|
||||||
|
const {
|
||||||
|
notifications,
|
||||||
|
unreadCount,
|
||||||
|
isLoading,
|
||||||
|
markRead,
|
||||||
|
markAllRead,
|
||||||
|
deleteNotification,
|
||||||
|
} = useNotifications();
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [filter, setFilter] = useState<Filter>('all');
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
if (filter === 'unread') return notifications.filter((n) => !n.is_read);
|
||||||
|
return notifications;
|
||||||
|
}, [notifications, filter]);
|
||||||
|
|
||||||
|
const handleMarkRead = async (id: number) => {
|
||||||
|
try {
|
||||||
|
await markRead([id]);
|
||||||
|
} catch { /* toast handled by mutation */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: number) => {
|
||||||
|
try {
|
||||||
|
await deleteNotification(id);
|
||||||
|
} catch { /* toast handled by mutation */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMarkAllRead = async () => {
|
||||||
|
try {
|
||||||
|
await markAllRead();
|
||||||
|
} catch { /* toast handled by mutation */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
const getIcon = (type: string) => {
|
||||||
|
const config = typeIcons[type] || { icon: Bell, color: 'text-muted-foreground' };
|
||||||
|
return config;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNotificationClick = async (notification: AppNotification) => {
|
||||||
|
if (!notification.is_read) {
|
||||||
|
await markRead([notification.id]).catch(() => {});
|
||||||
|
}
|
||||||
|
// Navigate to People for connection-related notifications
|
||||||
|
if (notification.type === 'connection_request' || notification.type === 'connection_accepted') {
|
||||||
|
navigate('/people');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full animate-fade-in">
|
||||||
|
{/* Page header */}
|
||||||
|
<div className="border-b bg-card px-6 h-16 flex items-center justify-between shrink-0">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Bell className="h-5 w-5 text-accent" aria-hidden="true" />
|
||||||
|
<h1 className="text-xl font-semibold font-heading">Notifications</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Filter */}
|
||||||
|
<div className="flex items-center rounded-md border border-border overflow-hidden">
|
||||||
|
{(['all', 'unread'] as const).map((f) => (
|
||||||
|
<button
|
||||||
|
key={f}
|
||||||
|
onClick={() => setFilter(f)}
|
||||||
|
className={cn(
|
||||||
|
'px-3 py-1.5 text-xs font-medium transition-colors capitalize',
|
||||||
|
filter === f
|
||||||
|
? 'bg-accent/15 text-accent'
|
||||||
|
: 'text-muted-foreground hover:text-foreground hover:bg-card-elevated'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{f}
|
||||||
|
{f === 'unread' && unreadCount > 0 && (
|
||||||
|
<span className="ml-1.5 text-[10px] bg-red-500/15 text-red-400 rounded-full px-1.5 py-0.5 tabular-nums">
|
||||||
|
{unreadCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleMarkAllRead}
|
||||||
|
className="text-xs gap-1.5"
|
||||||
|
>
|
||||||
|
<CheckCheck className="h-3.5 w-3.5" />
|
||||||
|
Mark all read
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="p-6">
|
||||||
|
<ListSkeleton rows={5} />
|
||||||
|
</div>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-3 py-20">
|
||||||
|
<Bell className="h-10 w-10 opacity-30" />
|
||||||
|
<p className="text-sm">
|
||||||
|
{filter === 'unread' ? 'No unread notifications' : 'No notifications'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{filtered.map((notification) => {
|
||||||
|
const iconConfig = getIcon(notification.type);
|
||||||
|
const Icon = iconConfig.icon;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={notification.id}
|
||||||
|
onClick={() => handleNotificationClick(notification)}
|
||||||
|
className={cn(
|
||||||
|
'flex items-start gap-3 px-6 py-3.5 transition-colors hover:bg-card-elevated group cursor-pointer',
|
||||||
|
!notification.is_read && 'bg-card'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Type icon */}
|
||||||
|
<div className={cn('mt-0.5 shrink-0', iconConfig.color)}>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className={cn(
|
||||||
|
'text-sm truncate',
|
||||||
|
!notification.is_read ? 'font-medium text-foreground' : 'text-muted-foreground'
|
||||||
|
)}>
|
||||||
|
{notification.title}
|
||||||
|
</p>
|
||||||
|
{notification.message && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
|
||||||
|
{notification.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Unread dot */}
|
||||||
|
{!notification.is_read && (
|
||||||
|
<div className="h-2 w-2 rounded-full bg-accent shrink-0 mt-1.5" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timestamp + actions */}
|
||||||
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
|
<span className="text-[11px] text-muted-foreground tabular-nums">
|
||||||
|
{formatDistanceToNow(new Date(notification.created_at), { addSuffix: true })}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
{!notification.is_read && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleMarkRead(notification.id)}
|
||||||
|
className="p-1 rounded hover:bg-accent/10 text-muted-foreground hover:text-accent transition-colors"
|
||||||
|
title="Mark as read"
|
||||||
|
>
|
||||||
|
<Check className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(notification.id)}
|
||||||
|
className="p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { useState, useMemo, useRef, useEffect } from 'react';
|
import { useState, useMemo, useRef, useEffect } from 'react';
|
||||||
import { Plus, Users, Star, Cake, Phone, Mail, MapPin, Tag, Building2, Briefcase, AlignLeft } from 'lucide-react';
|
import { Plus, Users, Star, Cake, Phone, Mail, MapPin, Tag, Building2, Briefcase, AlignLeft, Ghost, ChevronDown } from 'lucide-react';
|
||||||
import type { LucideIcon } from 'lucide-react';
|
import type { LucideIcon } from 'lucide-react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { format, parseISO, differenceInYears } from 'date-fns';
|
import { format, parseISO, differenceInYears } from 'date-fns';
|
||||||
@ -23,6 +23,7 @@ import {
|
|||||||
import { useTableVisibility } from '@/hooks/useTableVisibility';
|
import { useTableVisibility } from '@/hooks/useTableVisibility';
|
||||||
import { useCategoryOrder } from '@/hooks/useCategoryOrder';
|
import { useCategoryOrder } from '@/hooks/useCategoryOrder';
|
||||||
import PersonForm from './PersonForm';
|
import PersonForm from './PersonForm';
|
||||||
|
import ConnectionSearch from '@/components/connections/ConnectionSearch';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// StatCounter — inline helper
|
// StatCounter — inline helper
|
||||||
@ -98,6 +99,9 @@ const columns: ColumnDef<Person>[] = [
|
|||||||
{getInitials(initialsName)}
|
{getInitials(initialsName)}
|
||||||
</div>
|
</div>
|
||||||
<span className="font-medium truncate">{p.nickname || p.name}</span>
|
<span className="font-medium truncate">{p.nickname || p.name}</span>
|
||||||
|
{p.is_umbral_contact && (
|
||||||
|
<Ghost className="h-3.5 w-3.5 text-violet-400 shrink-0" aria-label="Umbral contact" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@ -193,9 +197,13 @@ export default function PeoplePage() {
|
|||||||
const [editingPerson, setEditingPerson] = useState<Person | null>(null);
|
const [editingPerson, setEditingPerson] = useState<Person | null>(null);
|
||||||
const [activeFilters, setActiveFilters] = useState<string[]>([]);
|
const [activeFilters, setActiveFilters] = useState<string[]>([]);
|
||||||
const [showPinned, setShowPinned] = useState(true);
|
const [showPinned, setShowPinned] = useState(true);
|
||||||
|
const [showUmbralOnly, setShowUmbralOnly] = useState(false);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [sortKey, setSortKey] = useState<string>('name');
|
const [sortKey, setSortKey] = useState<string>('name');
|
||||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
|
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
|
||||||
|
const [showConnectionSearch, setShowConnectionSearch] = useState(false);
|
||||||
|
const [showAddDropdown, setShowAddDropdown] = useState(false);
|
||||||
|
const addDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const { data: people = [], isLoading } = useQuery({
|
const { data: people = [], isLoading } = useQuery({
|
||||||
queryKey: ['people'],
|
queryKey: ['people'],
|
||||||
@ -228,6 +236,10 @@ export default function PeoplePage() {
|
|||||||
? people.filter((p) => !p.is_favourite)
|
? people.filter((p) => !p.is_favourite)
|
||||||
: people;
|
: people;
|
||||||
|
|
||||||
|
if (showUmbralOnly) {
|
||||||
|
list = list.filter((p) => p.is_umbral_contact);
|
||||||
|
}
|
||||||
|
|
||||||
if (activeFilters.length > 0) {
|
if (activeFilters.length > 0) {
|
||||||
list = list.filter((p) => p.category && activeFilters.includes(p.category));
|
list = list.filter((p) => p.category && activeFilters.includes(p.category));
|
||||||
}
|
}
|
||||||
@ -249,7 +261,7 @@ export default function PeoplePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return sortPeople(list, sortKey, sortDir);
|
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
|
// Build row groups for the table — ordered by custom category order
|
||||||
const groups = useMemo(() => {
|
const groups = useMemo(() => {
|
||||||
@ -347,6 +359,18 @@ export default function PeoplePage() {
|
|||||||
return () => document.removeEventListener('keydown', handler);
|
return () => document.removeEventListener('keydown', handler);
|
||||||
}, [panelOpen]);
|
}, [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 = () => {
|
const handleCloseForm = () => {
|
||||||
setShowForm(false);
|
setShowForm(false);
|
||||||
setEditingPerson(null);
|
setEditingPerson(null);
|
||||||
@ -363,7 +387,12 @@ export default function PeoplePage() {
|
|||||||
{getInitials(initialsName)}
|
{getInitials(initialsName)}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<h3 className="font-heading text-lg font-semibold truncate">{p.name}</h3>
|
<h3 className="font-heading text-lg font-semibold truncate">{p.name}</h3>
|
||||||
|
{p.is_umbral_contact && (
|
||||||
|
<Ghost className="h-4 w-4 text-violet-400 shrink-0" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{p.category && (
|
{p.category && (
|
||||||
<span className="text-xs text-muted-foreground">{p.category}</span>
|
<span className="text-xs text-muted-foreground">{p.category}</span>
|
||||||
)}
|
)}
|
||||||
@ -372,8 +401,49 @@ export default function PeoplePage() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Panel getValue
|
// Shared field key mapping (panel key -> shared_fields key)
|
||||||
|
const sharedKeyMap: Record<string, string> = {
|
||||||
|
email: 'email',
|
||||||
|
phone: 'phone',
|
||||||
|
mobile: 'mobile',
|
||||||
|
birthday_display: 'birthday',
|
||||||
|
address: 'address',
|
||||||
|
company: 'company',
|
||||||
|
job_title: 'job_title',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build dynamic panel fields with synced labels for shared fields
|
||||||
|
const dynamicPanelFields = useMemo((): PanelField[] => {
|
||||||
|
if (!selectedPerson?.is_umbral_contact || !selectedPerson.shared_fields) return panelFields;
|
||||||
|
const shared = selectedPerson.shared_fields;
|
||||||
|
return panelFields.map((f) => {
|
||||||
|
const sharedKey = sharedKeyMap[f.key];
|
||||||
|
if (sharedKey && sharedKey in shared) {
|
||||||
|
return { ...f, label: `${f.label} (synced)` };
|
||||||
|
}
|
||||||
|
return f;
|
||||||
|
});
|
||||||
|
}, [selectedPerson]);
|
||||||
|
|
||||||
|
// Panel getValue — overlays shared fields from connected user
|
||||||
const getPanelValue = (p: Person, key: string): string | undefined => {
|
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) {
|
if (key === 'birthday_display' && p.birthday) {
|
||||||
const age = differenceInYears(new Date(), parseISO(p.birthday));
|
const age = differenceInYears(new Date(), parseISO(p.birthday));
|
||||||
return `${format(parseISO(p.birthday), 'MMM d, yyyy')} (${age})`;
|
return `${format(parseISO(p.birthday), 'MMM d, yyyy')} (${age})`;
|
||||||
@ -385,7 +455,7 @@ export default function PeoplePage() {
|
|||||||
const renderPanel = () => (
|
const renderPanel = () => (
|
||||||
<EntityDetailPanel<Person>
|
<EntityDetailPanel<Person>
|
||||||
item={selectedPerson}
|
item={selectedPerson}
|
||||||
fields={panelFields}
|
fields={dynamicPanelFields}
|
||||||
onEdit={() => {
|
onEdit={() => {
|
||||||
setEditingPerson(selectedPerson);
|
setEditingPerson(selectedPerson);
|
||||||
setShowForm(true);
|
setShowForm(true);
|
||||||
@ -420,12 +490,53 @@ export default function PeoplePage() {
|
|||||||
onReorderCategories={reorderCategories}
|
onReorderCategories={reorderCategories}
|
||||||
searchValue={search}
|
searchValue={search}
|
||||||
onSearchChange={setSearch}
|
onSearchChange={setSearch}
|
||||||
|
extraPinnedFilters={[
|
||||||
|
{
|
||||||
|
label: 'Umbral',
|
||||||
|
isActive: showUmbralOnly,
|
||||||
|
onToggle: () => setShowUmbralOnly((p) => !p),
|
||||||
|
},
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Add Person
|
Add Person
|
||||||
</Button>
|
</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>
|
||||||
|
|
||||||
<div className="flex-1 overflow-hidden flex flex-col">
|
<div className="flex-1 overflow-hidden flex flex-col">
|
||||||
@ -558,6 +669,11 @@ export default function PeoplePage() {
|
|||||||
onClose={handleCloseForm}
|
onClose={handleCloseForm}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<ConnectionSearch
|
||||||
|
open={showConnectionSearch}
|
||||||
|
onOpenChange={setShowConnectionSearch}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { useState, useMemo, FormEvent } from 'react';
|
import { useState, useMemo, FormEvent } from 'react';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { toast } from 'sonner';
|
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 { parseISO, differenceInYears } from 'date-fns';
|
||||||
import api, { getErrorMessage } from '@/lib/api';
|
import api, { getErrorMessage } from '@/lib/api';
|
||||||
import type { Person } from '@/types';
|
import type { Person } from '@/types';
|
||||||
@ -30,6 +30,11 @@ interface PersonFormProps {
|
|||||||
export default function PersonForm({ person, categories, onClose }: PersonFormProps) {
|
export default function PersonForm({ person, categories, onClose }: PersonFormProps) {
|
||||||
const queryClient = useQueryClient();
|
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({
|
const [formData, setFormData] = useState({
|
||||||
first_name:
|
first_name:
|
||||||
person?.first_name ||
|
person?.first_name ||
|
||||||
@ -38,20 +43,24 @@ export default function PersonForm({ person, categories, onClose }: PersonFormPr
|
|||||||
person?.last_name ||
|
person?.last_name ||
|
||||||
(person?.name ? splitName(person.name).lastName : ''),
|
(person?.name ? splitName(person.name).lastName : ''),
|
||||||
nickname: person?.nickname || '',
|
nickname: person?.nickname || '',
|
||||||
email: person?.email || '',
|
email: shared('email', person?.email || ''),
|
||||||
phone: person?.phone || '',
|
phone: shared('phone', person?.phone || ''),
|
||||||
mobile: person?.mobile || '',
|
mobile: shared('mobile', person?.mobile || ''),
|
||||||
address: person?.address || '',
|
address: shared('address', person?.address || ''),
|
||||||
birthday: person?.birthday
|
birthday: shared('birthday', person?.birthday ? person.birthday.slice(0, 10) : ''),
|
||||||
? person.birthday.slice(0, 10)
|
|
||||||
: '',
|
|
||||||
category: person?.category || '',
|
category: person?.category || '',
|
||||||
is_favourite: person?.is_favourite ?? false,
|
is_favourite: person?.is_favourite ?? false,
|
||||||
company: person?.company || '',
|
company: shared('company', person?.company || ''),
|
||||||
job_title: person?.job_title || '',
|
job_title: shared('job_title', person?.job_title || ''),
|
||||||
notes: person?.notes || '',
|
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(() => {
|
const age = useMemo(() => {
|
||||||
if (!formData.birthday) return null;
|
if (!formData.birthday) return null;
|
||||||
try {
|
try {
|
||||||
@ -165,13 +174,25 @@ export default function PersonForm({ person, categories, onClose }: PersonFormPr
|
|||||||
{/* Row 4: Birthday + Age */}
|
{/* Row 4: Birthday + Age */}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<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
|
<DatePicker
|
||||||
variant="input"
|
variant="input"
|
||||||
id="birthday"
|
id="birthday"
|
||||||
value={formData.birthday}
|
value={formData.birthday}
|
||||||
onChange={(v) => set('birthday', v)}
|
onChange={(v) => set('birthday', v)}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="age">Age</Label>
|
<Label htmlFor="age">Age</Label>
|
||||||
@ -200,40 +221,66 @@ export default function PersonForm({ person, categories, onClose }: PersonFormPr
|
|||||||
{/* Row 6: Mobile + Email */}
|
{/* Row 6: Mobile + Email */}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<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
|
<Input
|
||||||
id="mobile"
|
id="mobile"
|
||||||
type="tel"
|
type="tel"
|
||||||
value={formData.mobile}
|
value={formData.mobile}
|
||||||
onChange={(e) => set('mobile', e.target.value)}
|
onChange={(e) => set('mobile', e.target.value)}
|
||||||
|
disabled={isShared('mobile')}
|
||||||
|
className={isShared('mobile') ? 'opacity-70 cursor-not-allowed' : ''}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<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
|
<Input
|
||||||
id="email"
|
id="email"
|
||||||
type="email"
|
type="email"
|
||||||
value={formData.email}
|
value={formData.email}
|
||||||
onChange={(e) => set('email', e.target.value)}
|
onChange={(e) => set('email', e.target.value)}
|
||||||
|
disabled={isShared('email')}
|
||||||
|
className={isShared('email') ? 'opacity-70 cursor-not-allowed' : ''}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Row 7: Phone */}
|
{/* Row 7: Phone */}
|
||||||
<div className="space-y-2">
|
<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
|
<Input
|
||||||
id="phone"
|
id="phone"
|
||||||
type="tel"
|
type="tel"
|
||||||
value={formData.phone}
|
value={formData.phone}
|
||||||
onChange={(e) => set('phone', e.target.value)}
|
onChange={(e) => set('phone', e.target.value)}
|
||||||
placeholder="Landline / work number"
|
placeholder="Landline / work number"
|
||||||
|
disabled={isShared('phone')}
|
||||||
|
className={isShared('phone') ? 'opacity-70 cursor-not-allowed' : ''}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Row 8: Address */}
|
{/* Row 8: Address */}
|
||||||
<div className="space-y-2">
|
<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
|
<LocationPicker
|
||||||
id="address"
|
id="address"
|
||||||
value={formData.address}
|
value={formData.address}
|
||||||
@ -241,24 +288,35 @@ export default function PersonForm({ person, categories, onClose }: PersonFormPr
|
|||||||
onSelect={(result) => set('address', result.address || result.name)}
|
onSelect={(result) => set('address', result.address || result.name)}
|
||||||
placeholder="Search or enter address..."
|
placeholder="Search or enter address..."
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Row 9: Company + Job Title */}
|
{/* Row 9: Company + Job Title */}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<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
|
<Input
|
||||||
id="company"
|
id="company"
|
||||||
value={formData.company}
|
value={formData.company}
|
||||||
onChange={(e) => set('company', e.target.value)}
|
onChange={(e) => set('company', e.target.value)}
|
||||||
|
disabled={isShared('company')}
|
||||||
|
className={isShared('company') ? 'opacity-70 cursor-not-allowed' : ''}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<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
|
<Input
|
||||||
id="job_title"
|
id="job_title"
|
||||||
value={formData.job_title}
|
value={formData.job_title}
|
||||||
onChange={(e) => set('job_title', e.target.value)}
|
onChange={(e) => set('job_title', e.target.value)}
|
||||||
|
disabled={isShared('job_title')}
|
||||||
|
className={isShared('job_title') ? 'opacity-70 cursor-not-allowed' : ''}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
Shield,
|
Shield,
|
||||||
Blocks,
|
Blocks,
|
||||||
|
Ghost,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useSettings } from '@/hooks/useSettings';
|
import { useSettings } from '@/hooks/useSettings';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
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 api from '@/lib/api';
|
||||||
import type { GeoLocation, UserProfile } from '@/types';
|
import type { GeoLocation, UserProfile } from '@/types';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import CopyableField from '@/components/shared/CopyableField';
|
||||||
import TotpSetupSection from './TotpSetupSection';
|
import TotpSetupSection from './TotpSetupSection';
|
||||||
import NtfySettingsSection from './NtfySettingsSection';
|
import NtfySettingsSection from './NtfySettingsSection';
|
||||||
|
|
||||||
@ -55,6 +57,24 @@ export default function SettingsPage() {
|
|||||||
const [autoLockEnabled, setAutoLockEnabled] = useState(settings?.auto_lock_enabled ?? false);
|
const [autoLockEnabled, setAutoLockEnabled] = useState(settings?.auto_lock_enabled ?? false);
|
||||||
const [autoLockMinutes, setAutoLockMinutes] = useState<number | string>(settings?.auto_lock_minutes ?? 5);
|
const [autoLockMinutes, setAutoLockMinutes] = useState<number | string>(settings?.auto_lock_minutes ?? 5);
|
||||||
|
|
||||||
|
// Profile extension fields (stored on Settings model)
|
||||||
|
const [settingsPhone, setSettingsPhone] = useState(settings?.phone ?? '');
|
||||||
|
const [settingsMobile, setSettingsMobile] = useState(settings?.mobile ?? '');
|
||||||
|
const [settingsAddress, setSettingsAddress] = useState(settings?.address ?? '');
|
||||||
|
const [settingsCompany, setSettingsCompany] = useState(settings?.company ?? '');
|
||||||
|
const [settingsJobTitle, setSettingsJobTitle] = useState(settings?.job_title ?? '');
|
||||||
|
|
||||||
|
// Social settings
|
||||||
|
const [acceptConnections, setAcceptConnections] = useState(settings?.accept_connections ?? false);
|
||||||
|
const [sharePreferredName, setSharePreferredName] = useState(settings?.share_preferred_name ?? true);
|
||||||
|
const [shareEmail, setShareEmail] = useState(settings?.share_email ?? false);
|
||||||
|
const [sharePhone, setSharePhone] = useState(settings?.share_phone ?? false);
|
||||||
|
const [shareMobile, setShareMobile] = useState(settings?.share_mobile ?? false);
|
||||||
|
const [shareBirthday, setShareBirthday] = useState(settings?.share_birthday ?? false);
|
||||||
|
const [shareAddress, setShareAddress] = useState(settings?.share_address ?? false);
|
||||||
|
const [shareCompany, setShareCompany] = useState(settings?.share_company ?? false);
|
||||||
|
const [shareJobTitle, setShareJobTitle] = useState(settings?.share_job_title ?? false);
|
||||||
|
|
||||||
// Profile fields (stored on User model, fetched from /auth/profile)
|
// Profile fields (stored on User model, fetched from /auth/profile)
|
||||||
const profileQuery = useQuery({
|
const profileQuery = useQuery({
|
||||||
queryKey: ['profile'],
|
queryKey: ['profile'],
|
||||||
@ -87,6 +107,20 @@ export default function SettingsPage() {
|
|||||||
setFirstDayOfWeek(settings.first_day_of_week);
|
setFirstDayOfWeek(settings.first_day_of_week);
|
||||||
setAutoLockEnabled(settings.auto_lock_enabled);
|
setAutoLockEnabled(settings.auto_lock_enabled);
|
||||||
setAutoLockMinutes(settings.auto_lock_minutes ?? 5);
|
setAutoLockMinutes(settings.auto_lock_minutes ?? 5);
|
||||||
|
setSettingsPhone(settings.phone ?? '');
|
||||||
|
setSettingsMobile(settings.mobile ?? '');
|
||||||
|
setSettingsAddress(settings.address ?? '');
|
||||||
|
setSettingsCompany(settings.company ?? '');
|
||||||
|
setSettingsJobTitle(settings.job_title ?? '');
|
||||||
|
setAcceptConnections(settings.accept_connections);
|
||||||
|
setSharePreferredName(settings.share_preferred_name);
|
||||||
|
setShareEmail(settings.share_email);
|
||||||
|
setSharePhone(settings.share_phone);
|
||||||
|
setShareMobile(settings.share_mobile);
|
||||||
|
setShareBirthday(settings.share_birthday);
|
||||||
|
setShareAddress(settings.share_address);
|
||||||
|
setShareCompany(settings.share_company);
|
||||||
|
setShareJobTitle(settings.share_job_title);
|
||||||
}
|
}
|
||||||
}, [settings?.id]); // only re-sync on initial load (settings.id won't change)
|
}, [settings?.id]); // only re-sync on initial load (settings.id won't change)
|
||||||
|
|
||||||
@ -248,6 +282,29 @@ export default function SettingsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSettingsFieldSave = async (field: string, value: string) => {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
const currentVal = (settings as any)?.[field] || '';
|
||||||
|
if (trimmed === (currentVal || '')) return;
|
||||||
|
try {
|
||||||
|
await updateSettings({ [field]: trimmed || null } as any);
|
||||||
|
toast.success('Profile updated');
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to update profile');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSocialToggle = async (field: string, checked: boolean, setter: (v: boolean) => void) => {
|
||||||
|
const previous = (settings as any)?.[field];
|
||||||
|
setter(checked);
|
||||||
|
try {
|
||||||
|
await updateSettings({ [field]: checked } as any);
|
||||||
|
} catch {
|
||||||
|
setter(previous);
|
||||||
|
toast.error('Failed to update setting');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleAutoLockMinutesSave = async () => {
|
const handleAutoLockMinutesSave = async () => {
|
||||||
const raw = typeof autoLockMinutes === 'string' ? parseInt(autoLockMinutes) : autoLockMinutes;
|
const raw = typeof autoLockMinutes === 'string' ? parseInt(autoLockMinutes) : autoLockMinutes;
|
||||||
const clamped = Math.max(1, Math.min(60, isNaN(raw) ? 5 : raw));
|
const clamped = Math.max(1, Math.min(60, isNaN(raw) ? 5 : raw));
|
||||||
@ -363,6 +420,75 @@ export default function SettingsPage() {
|
|||||||
onKeyDown={(e) => { if (e.key === 'Enter') handleProfileSave('date_of_birth'); }}
|
onKeyDown={(e) => { if (e.key === 'Enter') handleProfileSave('date_of_birth'); }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@ -586,9 +712,77 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Right column: Security, Authentication, Integrations ── */}
|
{/* ── Right column: Social, Security, Authentication, Integrations ── */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|
||||||
|
{/* Social */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-1.5 rounded-md bg-violet-500/10">
|
||||||
|
<Ghost className="h-4 w-4 text-violet-400" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle>Social</CardTitle>
|
||||||
|
<CardDescription>Manage your Umbra identity and connections</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Umbral Name</Label>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Input
|
||||||
|
value={profileQuery.data?.username ?? ''}
|
||||||
|
disabled
|
||||||
|
className="opacity-70 cursor-not-allowed"
|
||||||
|
/>
|
||||||
|
<CopyableField value={profileQuery.data?.username ?? ''} label="Umbral name" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
How other Umbra users find you
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label>Accept Connections</Label>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Allow other users to find and connect with you
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={acceptConnections}
|
||||||
|
onCheckedChange={(checked) => handleSocialToggle('accept_connections', checked, setAcceptConnections)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-border pt-4 mt-4">
|
||||||
|
<p className="text-[11px] uppercase tracking-wider text-muted-foreground mb-3">
|
||||||
|
Sharing Defaults
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{[
|
||||||
|
{ field: 'share_preferred_name', label: 'Preferred Name', state: sharePreferredName, setter: setSharePreferredName },
|
||||||
|
{ field: 'share_email', label: 'Email', state: shareEmail, setter: setShareEmail },
|
||||||
|
{ field: 'share_phone', label: 'Phone', state: sharePhone, setter: setSharePhone },
|
||||||
|
{ field: 'share_mobile', label: 'Mobile', state: shareMobile, setter: setShareMobile },
|
||||||
|
{ field: 'share_birthday', label: 'Birthday', state: shareBirthday, setter: setShareBirthday },
|
||||||
|
{ field: 'share_address', label: 'Address', state: shareAddress, setter: setShareAddress },
|
||||||
|
{ field: 'share_company', label: 'Company', state: shareCompany, setter: setShareCompany },
|
||||||
|
{ field: 'share_job_title', label: 'Job Title', state: shareJobTitle, setter: setShareJobTitle },
|
||||||
|
].map(({ field, label, state, setter }) => (
|
||||||
|
<div key={field} className="flex items-center justify-between">
|
||||||
|
<Label className="text-sm font-normal">{label}</Label>
|
||||||
|
<Switch
|
||||||
|
checked={state}
|
||||||
|
onCheckedChange={(checked) => handleSocialToggle(field, checked, setter)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Security (auto-lock) */}
|
{/* Security (auto-lock) */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|||||||
@ -18,6 +18,12 @@ import {
|
|||||||
} from '@dnd-kit/sortable';
|
} from '@dnd-kit/sortable';
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
|
|
||||||
|
interface ExtraPinnedFilter {
|
||||||
|
label: string;
|
||||||
|
isActive: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
interface CategoryFilterBarProps {
|
interface CategoryFilterBarProps {
|
||||||
activeFilters: string[];
|
activeFilters: string[];
|
||||||
pinnedLabel: string;
|
pinnedLabel: string;
|
||||||
@ -30,6 +36,7 @@ interface CategoryFilterBarProps {
|
|||||||
onReorderCategories?: (order: string[]) => void;
|
onReorderCategories?: (order: string[]) => void;
|
||||||
searchValue: string;
|
searchValue: string;
|
||||||
onSearchChange: (val: string) => void;
|
onSearchChange: (val: string) => void;
|
||||||
|
extraPinnedFilters?: ExtraPinnedFilter[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const pillBase =
|
const pillBase =
|
||||||
@ -116,6 +123,7 @@ export default function CategoryFilterBar({
|
|||||||
onReorderCategories,
|
onReorderCategories,
|
||||||
searchValue,
|
searchValue,
|
||||||
onSearchChange,
|
onSearchChange,
|
||||||
|
extraPinnedFilters = [],
|
||||||
}: CategoryFilterBarProps) {
|
}: CategoryFilterBarProps) {
|
||||||
const [otherOpen, setOtherOpen] = useState(false);
|
const [otherOpen, setOtherOpen] = useState(false);
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
@ -169,6 +177,22 @@ export default function CategoryFilterBar({
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</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 pill + expandable chips */}
|
||||||
{categories.length > 0 && (
|
{categories.length > 0 && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
88
frontend/src/hooks/useConnections.ts
Normal file
88
frontend/src/hooks/useConnections.ts
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import api from '@/lib/api';
|
||||||
|
import type { Connection, ConnectionRequest, UmbralSearchResponse } from '@/types';
|
||||||
|
|
||||||
|
export function useConnections() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const connectionsQuery = useQuery({
|
||||||
|
queryKey: ['connections'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await api.get<Connection[]>('/connections');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const incomingQuery = useQuery({
|
||||||
|
queryKey: ['connections', 'incoming'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await api.get<ConnectionRequest[]>('/connections/requests/incoming');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const outgoingQuery = useQuery({
|
||||||
|
queryKey: ['connections', 'outgoing'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await api.get<ConnectionRequest[]>('/connections/requests/outgoing');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const searchMutation = useMutation({
|
||||||
|
mutationFn: async (umbralName: string) => {
|
||||||
|
const { data } = await api.post<UmbralSearchResponse>('/connections/search', {
|
||||||
|
umbral_name: umbralName,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const sendRequestMutation = useMutation({
|
||||||
|
mutationFn: async (umbralName: string) => {
|
||||||
|
const { data } = await api.post('/connections/request', {
|
||||||
|
umbral_name: umbralName,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['connections'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const respondMutation = useMutation({
|
||||||
|
mutationFn: async ({ requestId, action }: { requestId: number; action: 'accept' | 'reject' }) => {
|
||||||
|
const { data } = await api.put(`/connections/requests/${requestId}/respond`, { action });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['connections'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['people'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const removeConnectionMutation = useMutation({
|
||||||
|
mutationFn: async (connectionId: number) => {
|
||||||
|
await api.delete(`/connections/${connectionId}`);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['connections'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['people'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
connections: connectionsQuery.data ?? [],
|
||||||
|
incomingRequests: incomingQuery.data ?? [],
|
||||||
|
outgoingRequests: outgoingQuery.data ?? [],
|
||||||
|
isLoading: connectionsQuery.isLoading,
|
||||||
|
search: searchMutation.mutateAsync,
|
||||||
|
isSearching: searchMutation.isPending,
|
||||||
|
sendRequest: sendRequestMutation.mutateAsync,
|
||||||
|
isSending: sendRequestMutation.isPending,
|
||||||
|
respond: respondMutation.mutateAsync,
|
||||||
|
isResponding: respondMutation.isPending,
|
||||||
|
removeConnection: removeConnectionMutation.mutateAsync,
|
||||||
|
};
|
||||||
|
}
|
||||||
76
frontend/src/hooks/useNotifications.ts
Normal file
76
frontend/src/hooks/useNotifications.ts
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import api from '@/lib/api';
|
||||||
|
import type { NotificationListResponse } from '@/types';
|
||||||
|
|
||||||
|
export function useNotifications() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const visibleRef = useRef(true);
|
||||||
|
|
||||||
|
// Track tab visibility to pause polling when hidden
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => {
|
||||||
|
visibleRef.current = document.visibilityState === 'visible';
|
||||||
|
};
|
||||||
|
document.addEventListener('visibilitychange', handler);
|
||||||
|
return () => document.removeEventListener('visibilitychange', handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const unreadQuery = useQuery({
|
||||||
|
queryKey: ['notifications', 'unread-count'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await api.get<{ count: number }>('/notifications/unread-count');
|
||||||
|
return data.count;
|
||||||
|
},
|
||||||
|
refetchInterval: () => (visibleRef.current ? 60_000 : false),
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const listQuery = useQuery({
|
||||||
|
queryKey: ['notifications', 'list'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await api.get<NotificationListResponse>('/notifications', {
|
||||||
|
params: { per_page: 50 },
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const markReadMutation = useMutation({
|
||||||
|
mutationFn: async (notificationIds: number[]) => {
|
||||||
|
await api.put('/notifications/read', { notification_ids: notificationIds });
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const markAllReadMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
await api.put('/notifications/read-all');
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: async (id: number) => {
|
||||||
|
await api.delete(`/notifications/${id}`);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
unreadCount: unreadQuery.data ?? 0,
|
||||||
|
notifications: listQuery.data?.notifications ?? [],
|
||||||
|
total: listQuery.data?.total ?? 0,
|
||||||
|
isLoading: listQuery.isLoading,
|
||||||
|
markRead: markReadMutation.mutateAsync,
|
||||||
|
markAllRead: markAllReadMutation.mutateAsync,
|
||||||
|
deleteNotification: deleteMutation.mutateAsync,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -23,6 +23,25 @@ export interface Settings {
|
|||||||
// Auto-lock settings
|
// Auto-lock settings
|
||||||
auto_lock_enabled: boolean;
|
auto_lock_enabled: boolean;
|
||||||
auto_lock_minutes: number;
|
auto_lock_minutes: number;
|
||||||
|
// Profile fields (shareable)
|
||||||
|
phone: string | null;
|
||||||
|
mobile: string | null;
|
||||||
|
address: string | null;
|
||||||
|
company: string | null;
|
||||||
|
job_title: string | null;
|
||||||
|
// Social settings
|
||||||
|
accept_connections: boolean;
|
||||||
|
// Sharing defaults
|
||||||
|
share_preferred_name: boolean;
|
||||||
|
share_email: boolean;
|
||||||
|
share_phone: boolean;
|
||||||
|
share_mobile: boolean;
|
||||||
|
share_birthday: boolean;
|
||||||
|
share_address: boolean;
|
||||||
|
share_company: boolean;
|
||||||
|
share_job_title: boolean;
|
||||||
|
// ntfy connections toggle
|
||||||
|
ntfy_connections_enabled: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
@ -171,6 +190,9 @@ export interface Person {
|
|||||||
company?: string;
|
company?: string;
|
||||||
job_title?: string;
|
job_title?: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
|
linked_user_id?: number | null;
|
||||||
|
is_umbral_contact: boolean;
|
||||||
|
shared_fields?: Record<string, unknown> | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
@ -222,6 +244,7 @@ export type LoginResponse = LoginSuccessResponse | LoginMfaRequiredResponse | Lo
|
|||||||
export interface AdminUser {
|
export interface AdminUser {
|
||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
|
umbral_name: string;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
first_name: string | null;
|
first_name: string | null;
|
||||||
last_name: string | null;
|
last_name: string | null;
|
||||||
@ -366,3 +389,48 @@ export interface EventTemplate {
|
|||||||
is_starred: boolean;
|
is_starred: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Notifications ──────────────────────────────────────────────────
|
||||||
|
// Named AppNotification to avoid collision with browser Notification API
|
||||||
|
|
||||||
|
export interface AppNotification {
|
||||||
|
id: number;
|
||||||
|
user_id: number;
|
||||||
|
type: string;
|
||||||
|
title: string | null;
|
||||||
|
message: string | null;
|
||||||
|
data: Record<string, unknown> | null;
|
||||||
|
source_type: string | null;
|
||||||
|
source_id: number | null;
|
||||||
|
is_read: boolean;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationListResponse {
|
||||||
|
notifications: AppNotification[];
|
||||||
|
unread_count: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Connections ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ConnectionRequest {
|
||||||
|
id: number;
|
||||||
|
sender_umbral_name: string;
|
||||||
|
sender_preferred_name: string | null;
|
||||||
|
status: 'pending' | 'accepted' | 'rejected' | 'cancelled';
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Connection {
|
||||||
|
id: number;
|
||||||
|
connected_user_id: number;
|
||||||
|
connected_umbral_name: string;
|
||||||
|
connected_preferred_name: string | null;
|
||||||
|
person_id: number | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UmbralSearchResponse {
|
||||||
|
found: boolean;
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user