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.models.settings import Settings
|
||||
from app.models.notification import Notification as AppNotification
|
||||
from app.models.reminder import Reminder
|
||||
from app.models.calendar_event import CalendarEvent
|
||||
from app.models.calendar import Calendar
|
||||
@ -267,6 +268,13 @@ async def _purge_expired_sessions(db: AsyncSession) -> None:
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def _purge_old_notifications(db: AsyncSession) -> None:
|
||||
"""Remove in-app notifications older than 90 days."""
|
||||
cutoff = datetime.now() - timedelta(days=90)
|
||||
await db.execute(delete(AppNotification).where(AppNotification.created_at < cutoff))
|
||||
await db.commit()
|
||||
|
||||
|
||||
# ── Entry point ───────────────────────────────────────────────────────────────
|
||||
|
||||
async def run_notification_dispatch() -> None:
|
||||
@ -308,6 +316,7 @@ async def run_notification_dispatch() -> None:
|
||||
async with AsyncSessionLocal() as db:
|
||||
await _purge_totp_usage(db)
|
||||
await _purge_expired_sessions(db)
|
||||
await _purge_old_notifications(db)
|
||||
|
||||
except Exception:
|
||||
# Broad catch: job failure must never crash the scheduler or the app
|
||||
|
||||
@ -7,7 +7,7 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from app.config import settings
|
||||
from app.database import engine
|
||||
from app.routers import auth, todos, events, calendars, reminders, projects, people, locations, settings as settings_router, dashboard, weather, event_templates
|
||||
from app.routers import totp, admin
|
||||
from app.routers import totp, admin, notifications as notifications_router, connections as connections_router
|
||||
from app.jobs.notifications import run_notification_dispatch
|
||||
|
||||
# Import models so Alembic's autogenerate can discover them
|
||||
@ -17,6 +17,9 @@ from app.models import totp_usage as _totp_usage_model # noqa: F401
|
||||
from app.models import backup_code as _backup_code_model # noqa: F401
|
||||
from app.models import system_config as _system_config_model # noqa: F401
|
||||
from app.models import audit_log as _audit_log_model # noqa: F401
|
||||
from app.models import notification as _notification_model # noqa: F401
|
||||
from app.models import connection_request as _connection_request_model # noqa: F401
|
||||
from app.models import user_connection as _user_connection_model # noqa: F401
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@ -129,6 +132,8 @@ app.include_router(weather.router, prefix="/api/weather", tags=["Weather"])
|
||||
app.include_router(event_templates.router, prefix="/api/event-templates", tags=["Event Templates"])
|
||||
app.include_router(totp.router, prefix="/api/auth", tags=["TOTP MFA"])
|
||||
app.include_router(admin.router, prefix="/api/admin", tags=["Admin"])
|
||||
app.include_router(notifications_router.router, prefix="/api/notifications", tags=["Notifications"])
|
||||
app.include_router(connections_router.router, prefix="/api/connections", tags=["Connections"])
|
||||
|
||||
|
||||
@app.get("/")
|
||||
|
||||
@ -15,6 +15,9 @@ from app.models.totp_usage import TOTPUsage
|
||||
from app.models.backup_code import BackupCode
|
||||
from app.models.system_config import SystemConfig
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.notification import Notification
|
||||
from app.models.connection_request import ConnectionRequest
|
||||
from app.models.user_connection import UserConnection
|
||||
|
||||
__all__ = [
|
||||
"Settings",
|
||||
@ -34,4 +37,7 @@ __all__ = [
|
||||
"BackupCode",
|
||||
"SystemConfig",
|
||||
"AuditLog",
|
||||
"Notification",
|
||||
"ConnectionRequest",
|
||||
"UserConnection",
|
||||
]
|
||||
|
||||
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)
|
||||
mobile: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
|
||||
category: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||
# Umbral contact link
|
||||
linked_user_id: Mapped[Optional[int]] = mapped_column(
|
||||
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
is_umbral_contact: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default=text('false'))
|
||||
created_at: Mapped[datetime] = mapped_column(default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
from sqlalchemy import String, Integer, Float, Boolean, ForeignKey, func
|
||||
from sqlalchemy import String, Text, Integer, Float, Boolean, ForeignKey, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
@ -46,6 +46,29 @@ class Settings(Base):
|
||||
auto_lock_enabled: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
auto_lock_minutes: Mapped[int] = mapped_column(Integer, default=5, server_default="5")
|
||||
|
||||
# Profile fields (shareable with connections)
|
||||
phone: Mapped[Optional[str]] = mapped_column(String(50), nullable=True, default=None)
|
||||
mobile: Mapped[Optional[str]] = mapped_column(String(50), nullable=True, default=None)
|
||||
address: Mapped[Optional[str]] = mapped_column(Text, nullable=True, default=None)
|
||||
company: Mapped[Optional[str]] = mapped_column(String(255), nullable=True, default=None)
|
||||
job_title: Mapped[Optional[str]] = mapped_column(String(255), nullable=True, default=None)
|
||||
|
||||
# Social settings
|
||||
accept_connections: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
|
||||
# Sharing defaults (what fields are shared with connections by default)
|
||||
share_preferred_name: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true")
|
||||
share_email: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
share_phone: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
share_mobile: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
share_birthday: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
share_address: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
share_company: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
share_job_title: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
|
||||
# ntfy connection notification toggle (gates push only, not in-app)
|
||||
ntfy_connections_enabled: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true")
|
||||
|
||||
@property
|
||||
def ntfy_has_token(self) -> bool:
|
||||
"""Derived field for SettingsResponse — True when an auth token is stored."""
|
||||
|
||||
@ -9,6 +9,7 @@ class User(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||
username: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True)
|
||||
umbral_name: Mapped[str] = mapped_column(String(50), unique=True, index=True)
|
||||
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
first_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
last_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
|
||||
31
backend/app/models/user_connection.py
Normal file
31
backend/app/models/user_connection.py
Normal file
@ -0,0 +1,31 @@
|
||||
from sqlalchemy import Integer, ForeignKey, func
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from datetime import datetime
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
from app.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.user import User
|
||||
from app.models.person import Person
|
||||
|
||||
|
||||
class UserConnection(Base):
|
||||
__tablename__ = "user_connections"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
connected_user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
person_id: Mapped[Optional[int]] = mapped_column(
|
||||
Integer, ForeignKey("people.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
sharing_overrides: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(default=func.now(), server_default=func.now())
|
||||
|
||||
# Relationships
|
||||
connected_user: Mapped["User"] = relationship(foreign_keys=[connected_user_id], lazy="selectin")
|
||||
person: Mapped[Optional["Person"]] = relationship(foreign_keys=[person_id], lazy="selectin")
|
||||
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 sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, or_
|
||||
from sqlalchemy.orm import selectinload
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, List
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.person import Person
|
||||
from app.models.settings import Settings
|
||||
from app.models.user import User
|
||||
from app.models.user_connection import UserConnection
|
||||
from app.schemas.person import PersonCreate, PersonUpdate, PersonResponse
|
||||
from app.routers.auth import get_current_user
|
||||
from app.models.user import User
|
||||
from app.services.connection import resolve_shared_profile
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@ -59,6 +63,53 @@ async def get_people(
|
||||
result = await db.execute(query)
|
||||
people = result.scalars().all()
|
||||
|
||||
# Batch-load shared profiles for umbral contacts
|
||||
umbral_people = [p for p in people if p.linked_user_id is not None]
|
||||
if umbral_people:
|
||||
linked_user_ids = [p.linked_user_id for p in umbral_people]
|
||||
|
||||
# Batch fetch users and settings
|
||||
users_result = await db.execute(
|
||||
select(User).where(User.id.in_(linked_user_ids))
|
||||
)
|
||||
users_by_id = {u.id: u for u in users_result.scalars().all()}
|
||||
|
||||
settings_result = await db.execute(
|
||||
select(Settings).where(Settings.user_id.in_(linked_user_ids))
|
||||
)
|
||||
settings_by_user = {s.user_id: s for s in settings_result.scalars().all()}
|
||||
|
||||
# Batch fetch connection overrides
|
||||
conns_result = await db.execute(
|
||||
select(UserConnection).where(
|
||||
UserConnection.user_id == current_user.id,
|
||||
UserConnection.connected_user_id.in_(linked_user_ids),
|
||||
)
|
||||
)
|
||||
overrides_by_user = {
|
||||
c.connected_user_id: c.sharing_overrides
|
||||
for c in conns_result.scalars().all()
|
||||
}
|
||||
|
||||
# Build shared profiles
|
||||
shared_profiles: dict[int, dict] = {}
|
||||
for uid in linked_user_ids:
|
||||
user = users_by_id.get(uid)
|
||||
user_settings = settings_by_user.get(uid)
|
||||
if user and user_settings:
|
||||
shared_profiles[uid] = resolve_shared_profile(
|
||||
user, user_settings, overrides_by_user.get(uid)
|
||||
)
|
||||
|
||||
# Attach to response
|
||||
responses = []
|
||||
for p in people:
|
||||
resp = PersonResponse.model_validate(p)
|
||||
if p.linked_user_id and p.linked_user_id in shared_profiles:
|
||||
resp.shared_fields = shared_profiles[p.linked_user_id]
|
||||
responses.append(resp)
|
||||
return responses
|
||||
|
||||
return people
|
||||
|
||||
|
||||
@ -104,7 +155,28 @@ async def get_person(
|
||||
if not person:
|
||||
raise HTTPException(status_code=404, detail="Person not found")
|
||||
|
||||
return person
|
||||
resp = PersonResponse.model_validate(person)
|
||||
if person.linked_user_id:
|
||||
linked_user_result = await db.execute(
|
||||
select(User).where(User.id == person.linked_user_id)
|
||||
)
|
||||
linked_user = linked_user_result.scalar_one_or_none()
|
||||
linked_settings_result = await db.execute(
|
||||
select(Settings).where(Settings.user_id == person.linked_user_id)
|
||||
)
|
||||
linked_settings = linked_settings_result.scalar_one_or_none()
|
||||
conn_result = await db.execute(
|
||||
select(UserConnection).where(
|
||||
UserConnection.user_id == current_user.id,
|
||||
UserConnection.connected_user_id == person.linked_user_id,
|
||||
)
|
||||
)
|
||||
conn = conn_result.scalar_one_or_none()
|
||||
if linked_user and linked_settings:
|
||||
resp.shared_fields = resolve_shared_profile(
|
||||
linked_user, linked_settings, conn.sharing_overrides if conn else None
|
||||
)
|
||||
return resp
|
||||
|
||||
|
||||
@router.put("/{person_id}", response_model=PersonResponse)
|
||||
|
||||
@ -39,6 +39,25 @@ def _to_settings_response(s: Settings) -> SettingsResponse:
|
||||
ntfy_has_token=bool(s.ntfy_auth_token), # derived — never expose the token value
|
||||
auto_lock_enabled=s.auto_lock_enabled,
|
||||
auto_lock_minutes=s.auto_lock_minutes,
|
||||
# Profile fields
|
||||
phone=s.phone,
|
||||
mobile=s.mobile,
|
||||
address=s.address,
|
||||
company=s.company,
|
||||
job_title=s.job_title,
|
||||
# Social settings
|
||||
accept_connections=s.accept_connections,
|
||||
# Sharing defaults
|
||||
share_preferred_name=s.share_preferred_name,
|
||||
share_email=s.share_email,
|
||||
share_phone=s.share_phone,
|
||||
share_mobile=s.share_mobile,
|
||||
share_birthday=s.share_birthday,
|
||||
share_address=s.share_address,
|
||||
share_company=s.share_company,
|
||||
share_job_title=s.share_job_title,
|
||||
# ntfy connections toggle
|
||||
ntfy_connections_enabled=s.ntfy_connections_enabled,
|
||||
created_at=s.created_at,
|
||||
updated_at=s.updated_at,
|
||||
)
|
||||
|
||||
@ -20,6 +20,7 @@ from app.schemas.auth import _validate_username, _validate_password_strength, _v
|
||||
class UserListItem(BaseModel):
|
||||
id: int
|
||||
username: str
|
||||
umbral_name: str = ""
|
||||
email: Optional[str] = None
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
|
||||
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]
|
||||
job_title: Optional[str]
|
||||
notes: Optional[str]
|
||||
linked_user_id: Optional[int] = None
|
||||
is_umbral_contact: bool = False
|
||||
shared_fields: Optional[dict] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
@ -37,6 +37,29 @@ class SettingsUpdate(BaseModel):
|
||||
auto_lock_enabled: Optional[bool] = None
|
||||
auto_lock_minutes: Optional[int] = None
|
||||
|
||||
# Profile fields (shareable with connections)
|
||||
phone: Optional[str] = Field(None, max_length=50)
|
||||
mobile: Optional[str] = Field(None, max_length=50)
|
||||
address: Optional[str] = Field(None, max_length=2000)
|
||||
company: Optional[str] = Field(None, max_length=255)
|
||||
job_title: Optional[str] = Field(None, max_length=255)
|
||||
|
||||
# Social settings
|
||||
accept_connections: Optional[bool] = None
|
||||
|
||||
# Sharing defaults
|
||||
share_preferred_name: Optional[bool] = None
|
||||
share_email: Optional[bool] = None
|
||||
share_phone: Optional[bool] = None
|
||||
share_mobile: Optional[bool] = None
|
||||
share_birthday: Optional[bool] = None
|
||||
share_address: Optional[bool] = None
|
||||
share_company: Optional[bool] = None
|
||||
share_job_title: Optional[bool] = None
|
||||
|
||||
# ntfy connections toggle
|
||||
ntfy_connections_enabled: Optional[bool] = None
|
||||
|
||||
@field_validator('auto_lock_minutes')
|
||||
@classmethod
|
||||
def validate_auto_lock_minutes(cls, v: Optional[int]) -> Optional[int]:
|
||||
@ -151,6 +174,29 @@ class SettingsResponse(BaseModel):
|
||||
auto_lock_enabled: bool = False
|
||||
auto_lock_minutes: int = 5
|
||||
|
||||
# Profile fields
|
||||
phone: Optional[str] = None
|
||||
mobile: Optional[str] = None
|
||||
address: Optional[str] = None
|
||||
company: Optional[str] = None
|
||||
job_title: Optional[str] = None
|
||||
|
||||
# Social settings
|
||||
accept_connections: bool = False
|
||||
|
||||
# Sharing defaults
|
||||
share_preferred_name: bool = True
|
||||
share_email: bool = False
|
||||
share_phone: bool = False
|
||||
share_mobile: bool = False
|
||||
share_birthday: bool = False
|
||||
share_address: bool = False
|
||||
share_company: bool = False
|
||||
share_job_title: bool = False
|
||||
|
||||
# ntfy connections toggle
|
||||
ntfy_connections_enabled: bool = True
|
||||
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
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;
|
||||
# Admin API — generous for legitimate use but still guards against scraping/brute-force
|
||||
limit_req_zone $binary_remote_addr zone=admin_limit:10m rate=30r/m;
|
||||
# Connection endpoints — prevent search enumeration and request spam
|
||||
limit_req_zone $binary_remote_addr zone=conn_search_limit:10m rate=10r/m;
|
||||
limit_req_zone $binary_remote_addr zone=conn_request_limit:10m rate=3r/m;
|
||||
|
||||
# Use X-Forwarded-Proto from upstream proxy when present, fall back to $scheme for direct access
|
||||
map $http_x_forwarded_proto $forwarded_proto {
|
||||
@ -82,6 +85,20 @@ server {
|
||||
include /etc/nginx/proxy-params.conf;
|
||||
}
|
||||
|
||||
# Connection search — rate-limited to prevent user enumeration
|
||||
location /api/connections/search {
|
||||
limit_req zone=conn_search_limit burst=5 nodelay;
|
||||
limit_req_status 429;
|
||||
include /etc/nginx/proxy-params.conf;
|
||||
}
|
||||
|
||||
# Connection request — rate-limited to prevent spam
|
||||
location /api/connections/request {
|
||||
limit_req zone=conn_request_limit burst=3 nodelay;
|
||||
limit_req_status 429;
|
||||
include /etc/nginx/proxy-params.conf;
|
||||
}
|
||||
|
||||
# Admin API — rate-limited separately from general /api traffic
|
||||
location /api/admin/ {
|
||||
limit_req zone=admin_limit burst=10 nodelay;
|
||||
|
||||
@ -12,6 +12,7 @@ import ProjectDetail from '@/components/projects/ProjectDetail';
|
||||
import PeoplePage from '@/components/people/PeoplePage';
|
||||
import LocationsPage from '@/components/locations/LocationsPage';
|
||||
import SettingsPage from '@/components/settings/SettingsPage';
|
||||
import NotificationsPage from '@/components/notifications/NotificationsPage';
|
||||
|
||||
const AdminPortal = lazy(() => import('@/components/admin/AdminPortal'));
|
||||
|
||||
@ -72,6 +73,7 @@ function App() {
|
||||
<Route path="projects/:id" element={<ProjectDetail />} />
|
||||
<Route path="people" element={<PeoplePage />} />
|
||||
<Route path="locations" element={<LocationsPage />} />
|
||||
<Route path="notifications" element={<NotificationsPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route
|
||||
path="admin/*"
|
||||
|
||||
@ -167,6 +167,9 @@ export default function IAMPage() {
|
||||
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
Username
|
||||
</th>
|
||||
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
Umbral Name
|
||||
</th>
|
||||
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
Email
|
||||
</th>
|
||||
@ -209,6 +212,9 @@ export default function IAMPage() {
|
||||
)}
|
||||
>
|
||||
<td className="px-5 py-3 font-medium">{user.username}</td>
|
||||
<td className="px-5 py-3 text-muted-foreground text-xs">
|
||||
{user.umbral_name || user.username}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-muted-foreground text-xs">
|
||||
{user.email || '—'}
|
||||
</td>
|
||||
|
||||
@ -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 { useLock } from '@/hooks/useLock';
|
||||
import { useConfirmAction } from '@/hooks/useConfirmAction';
|
||||
import { useNotifications } from '@/hooks/useNotifications';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import api from '@/lib/api';
|
||||
import type { Project } from '@/types';
|
||||
@ -47,6 +48,7 @@ export default function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose
|
||||
const location = useLocation();
|
||||
const { logout, isAdmin } = useAuth();
|
||||
const { lock } = useLock();
|
||||
const { unreadCount } = useNotifications();
|
||||
const [projectsExpanded, setProjectsExpanded] = useState(false);
|
||||
|
||||
const { data: trackedProjects } = useQuery({
|
||||
@ -194,6 +196,28 @@ export default function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose
|
||||
<Lock className="h-5 w-5 shrink-0" />
|
||||
{showExpanded && <span>Lock</span>}
|
||||
</button>
|
||||
<NavLink
|
||||
to="/notifications"
|
||||
onClick={mobileOpen ? onMobileClose : undefined}
|
||||
className={navLinkClass}
|
||||
>
|
||||
<div className="relative shrink-0">
|
||||
<Bell className="h-5 w-5" />
|
||||
{unreadCount > 0 && !showExpanded && (
|
||||
<div className="absolute -top-1 -right-1 h-2 w-2 rounded-full bg-red-500" />
|
||||
)}
|
||||
</div>
|
||||
{showExpanded && (
|
||||
<span className="flex items-center gap-2">
|
||||
Notifications
|
||||
{unreadCount > 0 && (
|
||||
<span className="text-[10px] bg-red-500/15 text-red-400 rounded-full px-1.5 py-0.5 tabular-nums">
|
||||
{unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</NavLink>
|
||||
{isAdmin && (
|
||||
<NavLink
|
||||
to="/admin"
|
||||
|
||||
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 { Plus, Users, Star, Cake, Phone, Mail, MapPin, Tag, Building2, Briefcase, AlignLeft } from 'lucide-react';
|
||||
import { Plus, Users, Star, Cake, Phone, Mail, MapPin, Tag, Building2, Briefcase, AlignLeft, Ghost, ChevronDown } from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { format, parseISO, differenceInYears } from 'date-fns';
|
||||
@ -23,6 +23,7 @@ import {
|
||||
import { useTableVisibility } from '@/hooks/useTableVisibility';
|
||||
import { useCategoryOrder } from '@/hooks/useCategoryOrder';
|
||||
import PersonForm from './PersonForm';
|
||||
import ConnectionSearch from '@/components/connections/ConnectionSearch';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// StatCounter — inline helper
|
||||
@ -98,6 +99,9 @@ const columns: ColumnDef<Person>[] = [
|
||||
{getInitials(initialsName)}
|
||||
</div>
|
||||
<span className="font-medium truncate">{p.nickname || p.name}</span>
|
||||
{p.is_umbral_contact && (
|
||||
<Ghost className="h-3.5 w-3.5 text-violet-400 shrink-0" aria-label="Umbral contact" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@ -193,9 +197,13 @@ export default function PeoplePage() {
|
||||
const [editingPerson, setEditingPerson] = useState<Person | null>(null);
|
||||
const [activeFilters, setActiveFilters] = useState<string[]>([]);
|
||||
const [showPinned, setShowPinned] = useState(true);
|
||||
const [showUmbralOnly, setShowUmbralOnly] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [sortKey, setSortKey] = useState<string>('name');
|
||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
|
||||
const [showConnectionSearch, setShowConnectionSearch] = useState(false);
|
||||
const [showAddDropdown, setShowAddDropdown] = useState(false);
|
||||
const addDropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { data: people = [], isLoading } = useQuery({
|
||||
queryKey: ['people'],
|
||||
@ -228,6 +236,10 @@ export default function PeoplePage() {
|
||||
? people.filter((p) => !p.is_favourite)
|
||||
: people;
|
||||
|
||||
if (showUmbralOnly) {
|
||||
list = list.filter((p) => p.is_umbral_contact);
|
||||
}
|
||||
|
||||
if (activeFilters.length > 0) {
|
||||
list = list.filter((p) => p.category && activeFilters.includes(p.category));
|
||||
}
|
||||
@ -249,7 +261,7 @@ export default function PeoplePage() {
|
||||
}
|
||||
|
||||
return sortPeople(list, sortKey, sortDir);
|
||||
}, [people, showPinned, activeFilters, search, sortKey, sortDir]);
|
||||
}, [people, showPinned, showUmbralOnly, activeFilters, search, sortKey, sortDir]);
|
||||
|
||||
// Build row groups for the table — ordered by custom category order
|
||||
const groups = useMemo(() => {
|
||||
@ -347,6 +359,18 @@ export default function PeoplePage() {
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
}, [panelOpen]);
|
||||
|
||||
// Close add dropdown on outside click
|
||||
useEffect(() => {
|
||||
if (!showAddDropdown) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (addDropdownRef.current && !addDropdownRef.current.contains(e.target as Node)) {
|
||||
setShowAddDropdown(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [showAddDropdown]);
|
||||
|
||||
const handleCloseForm = () => {
|
||||
setShowForm(false);
|
||||
setEditingPerson(null);
|
||||
@ -363,7 +387,12 @@ export default function PeoplePage() {
|
||||
{getInitials(initialsName)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-heading text-lg font-semibold truncate">{p.name}</h3>
|
||||
{p.is_umbral_contact && (
|
||||
<Ghost className="h-4 w-4 text-violet-400 shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
{p.category && (
|
||||
<span className="text-xs text-muted-foreground">{p.category}</span>
|
||||
)}
|
||||
@ -372,8 +401,49 @@ export default function PeoplePage() {
|
||||
);
|
||||
};
|
||||
|
||||
// Panel getValue
|
||||
// Shared field key mapping (panel key -> shared_fields key)
|
||||
const sharedKeyMap: Record<string, string> = {
|
||||
email: 'email',
|
||||
phone: 'phone',
|
||||
mobile: 'mobile',
|
||||
birthday_display: 'birthday',
|
||||
address: 'address',
|
||||
company: 'company',
|
||||
job_title: 'job_title',
|
||||
};
|
||||
|
||||
// Build dynamic panel fields with synced labels for shared fields
|
||||
const dynamicPanelFields = useMemo((): PanelField[] => {
|
||||
if (!selectedPerson?.is_umbral_contact || !selectedPerson.shared_fields) return panelFields;
|
||||
const shared = selectedPerson.shared_fields;
|
||||
return panelFields.map((f) => {
|
||||
const sharedKey = sharedKeyMap[f.key];
|
||||
if (sharedKey && sharedKey in shared) {
|
||||
return { ...f, label: `${f.label} (synced)` };
|
||||
}
|
||||
return f;
|
||||
});
|
||||
}, [selectedPerson]);
|
||||
|
||||
// Panel getValue — overlays shared fields from connected user
|
||||
const getPanelValue = (p: Person, key: string): string | undefined => {
|
||||
// Check shared fields first for umbral contacts
|
||||
if (p.is_umbral_contact && p.shared_fields) {
|
||||
const sharedKey = sharedKeyMap[key];
|
||||
if (sharedKey && sharedKey in p.shared_fields) {
|
||||
const sharedVal = p.shared_fields[sharedKey];
|
||||
if (key === 'birthday_display' && sharedVal) {
|
||||
const bd = String(sharedVal);
|
||||
try {
|
||||
const age = differenceInYears(new Date(), parseISO(bd));
|
||||
return `${format(parseISO(bd), 'MMM d, yyyy')} (${age})`;
|
||||
} catch {
|
||||
return bd;
|
||||
}
|
||||
}
|
||||
return sharedVal != null ? String(sharedVal) : undefined;
|
||||
}
|
||||
}
|
||||
if (key === 'birthday_display' && p.birthday) {
|
||||
const age = differenceInYears(new Date(), parseISO(p.birthday));
|
||||
return `${format(parseISO(p.birthday), 'MMM d, yyyy')} (${age})`;
|
||||
@ -385,7 +455,7 @@ export default function PeoplePage() {
|
||||
const renderPanel = () => (
|
||||
<EntityDetailPanel<Person>
|
||||
item={selectedPerson}
|
||||
fields={panelFields}
|
||||
fields={dynamicPanelFields}
|
||||
onEdit={() => {
|
||||
setEditingPerson(selectedPerson);
|
||||
setShowForm(true);
|
||||
@ -420,12 +490,53 @@ export default function PeoplePage() {
|
||||
onReorderCategories={reorderCategories}
|
||||
searchValue={search}
|
||||
onSearchChange={setSearch}
|
||||
extraPinnedFilters={[
|
||||
{
|
||||
label: 'Umbral',
|
||||
isActive: showUmbralOnly,
|
||||
onToggle: () => setShowUmbralOnly((p) => !p),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={() => setShowForm(true)} size="sm" aria-label="Add person">
|
||||
<div className="relative" ref={addDropdownRef}>
|
||||
<div className="flex">
|
||||
<Button
|
||||
onClick={() => setShowForm(true)}
|
||||
size="sm"
|
||||
aria-label="Add person"
|
||||
className="rounded-r-none"
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Person
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setShowAddDropdown((p) => !p)}
|
||||
aria-label="More add options"
|
||||
className="rounded-l-none border-l border-background/20 px-1.5"
|
||||
>
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
{showAddDropdown && (
|
||||
<div className="absolute right-0 top-full mt-1 w-44 rounded-md border border-border bg-card shadow-lg z-50 py-1">
|
||||
<button
|
||||
className="w-full text-left px-3 py-1.5 text-sm hover:bg-card-elevated transition-colors"
|
||||
onClick={() => { setShowAddDropdown(false); setShowForm(true); }}
|
||||
>
|
||||
Standard Contact
|
||||
</button>
|
||||
<button
|
||||
className="w-full text-left px-3 py-1.5 text-sm hover:bg-card-elevated transition-colors flex items-center gap-2"
|
||||
onClick={() => { setShowAddDropdown(false); setShowConnectionSearch(true); }}
|
||||
>
|
||||
<Ghost className="h-3.5 w-3.5 text-violet-400" />
|
||||
Umbra Contact
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden flex flex-col">
|
||||
@ -558,6 +669,11 @@ export default function PeoplePage() {
|
||||
onClose={handleCloseForm}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ConnectionSearch
|
||||
open={showConnectionSearch}
|
||||
onOpenChange={setShowConnectionSearch}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { useState, useMemo, FormEvent } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { Star, StarOff, X } from 'lucide-react';
|
||||
import { Star, StarOff, X, Lock } from 'lucide-react';
|
||||
import { parseISO, differenceInYears } from 'date-fns';
|
||||
import api, { getErrorMessage } from '@/lib/api';
|
||||
import type { Person } from '@/types';
|
||||
@ -30,6 +30,11 @@ interface PersonFormProps {
|
||||
export default function PersonForm({ person, categories, onClose }: PersonFormProps) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Helper to resolve a field value — prefer shared_fields for umbral contacts
|
||||
const sf = person?.shared_fields;
|
||||
const shared = (key: string, fallback: string) =>
|
||||
sf && key in sf && sf[key] != null ? String(sf[key]) : fallback;
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
first_name:
|
||||
person?.first_name ||
|
||||
@ -38,20 +43,24 @@ export default function PersonForm({ person, categories, onClose }: PersonFormPr
|
||||
person?.last_name ||
|
||||
(person?.name ? splitName(person.name).lastName : ''),
|
||||
nickname: person?.nickname || '',
|
||||
email: person?.email || '',
|
||||
phone: person?.phone || '',
|
||||
mobile: person?.mobile || '',
|
||||
address: person?.address || '',
|
||||
birthday: person?.birthday
|
||||
? person.birthday.slice(0, 10)
|
||||
: '',
|
||||
email: shared('email', person?.email || ''),
|
||||
phone: shared('phone', person?.phone || ''),
|
||||
mobile: shared('mobile', person?.mobile || ''),
|
||||
address: shared('address', person?.address || ''),
|
||||
birthday: shared('birthday', person?.birthday ? person.birthday.slice(0, 10) : ''),
|
||||
category: person?.category || '',
|
||||
is_favourite: person?.is_favourite ?? false,
|
||||
company: person?.company || '',
|
||||
job_title: person?.job_title || '',
|
||||
company: shared('company', person?.company || ''),
|
||||
job_title: shared('job_title', person?.job_title || ''),
|
||||
notes: person?.notes || '',
|
||||
});
|
||||
|
||||
// Check if a field is synced from an umbral connection (read-only)
|
||||
const isShared = (fieldKey: string): boolean => {
|
||||
if (!person?.is_umbral_contact || !person.shared_fields) return false;
|
||||
return fieldKey in person.shared_fields;
|
||||
};
|
||||
|
||||
const age = useMemo(() => {
|
||||
if (!formData.birthday) return null;
|
||||
try {
|
||||
@ -165,13 +174,25 @@ export default function PersonForm({ person, categories, onClose }: PersonFormPr
|
||||
{/* Row 4: Birthday + Age */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="birthday">Birthday</Label>
|
||||
<Label htmlFor="birthday" className="flex items-center gap-1">
|
||||
Birthday
|
||||
{isShared('birthday') && <Lock className="h-3 w-3 text-violet-400" />}
|
||||
</Label>
|
||||
{isShared('birthday') ? (
|
||||
<Input
|
||||
id="birthday"
|
||||
value={formData.birthday}
|
||||
disabled
|
||||
className="opacity-70 cursor-not-allowed"
|
||||
/>
|
||||
) : (
|
||||
<DatePicker
|
||||
variant="input"
|
||||
id="birthday"
|
||||
value={formData.birthday}
|
||||
onChange={(v) => set('birthday', v)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="age">Age</Label>
|
||||
@ -200,40 +221,66 @@ export default function PersonForm({ person, categories, onClose }: PersonFormPr
|
||||
{/* Row 6: Mobile + Email */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="mobile">Mobile</Label>
|
||||
<Label htmlFor="mobile" className="flex items-center gap-1">
|
||||
Mobile
|
||||
{isShared('mobile') && <Lock className="h-3 w-3 text-violet-400" />}
|
||||
</Label>
|
||||
<Input
|
||||
id="mobile"
|
||||
type="tel"
|
||||
value={formData.mobile}
|
||||
onChange={(e) => set('mobile', e.target.value)}
|
||||
disabled={isShared('mobile')}
|
||||
className={isShared('mobile') ? 'opacity-70 cursor-not-allowed' : ''}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Label htmlFor="email" className="flex items-center gap-1">
|
||||
Email
|
||||
{isShared('email') && <Lock className="h-3 w-3 text-violet-400" />}
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => set('email', e.target.value)}
|
||||
disabled={isShared('email')}
|
||||
className={isShared('email') ? 'opacity-70 cursor-not-allowed' : ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 7: Phone */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone">Phone</Label>
|
||||
<Label htmlFor="phone" className="flex items-center gap-1">
|
||||
Phone
|
||||
{isShared('phone') && <Lock className="h-3 w-3 text-violet-400" />}
|
||||
</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
type="tel"
|
||||
value={formData.phone}
|
||||
onChange={(e) => set('phone', e.target.value)}
|
||||
placeholder="Landline / work number"
|
||||
disabled={isShared('phone')}
|
||||
className={isShared('phone') ? 'opacity-70 cursor-not-allowed' : ''}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Row 8: Address */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="address">Address</Label>
|
||||
<Label htmlFor="address" className="flex items-center gap-1">
|
||||
Address
|
||||
{isShared('address') && <Lock className="h-3 w-3 text-violet-400" />}
|
||||
</Label>
|
||||
{isShared('address') ? (
|
||||
<Input
|
||||
id="address"
|
||||
value={formData.address}
|
||||
disabled
|
||||
className="opacity-70 cursor-not-allowed"
|
||||
/>
|
||||
) : (
|
||||
<LocationPicker
|
||||
id="address"
|
||||
value={formData.address}
|
||||
@ -241,24 +288,35 @@ export default function PersonForm({ person, categories, onClose }: PersonFormPr
|
||||
onSelect={(result) => set('address', result.address || result.name)}
|
||||
placeholder="Search or enter address..."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Row 9: Company + Job Title */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="company">Company</Label>
|
||||
<Label htmlFor="company" className="flex items-center gap-1">
|
||||
Company
|
||||
{isShared('company') && <Lock className="h-3 w-3 text-violet-400" />}
|
||||
</Label>
|
||||
<Input
|
||||
id="company"
|
||||
value={formData.company}
|
||||
onChange={(e) => set('company', e.target.value)}
|
||||
disabled={isShared('company')}
|
||||
className={isShared('company') ? 'opacity-70 cursor-not-allowed' : ''}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="job_title">Job Title</Label>
|
||||
<Label htmlFor="job_title" className="flex items-center gap-1">
|
||||
Job Title
|
||||
{isShared('job_title') && <Lock className="h-3 w-3 text-violet-400" />}
|
||||
</Label>
|
||||
<Input
|
||||
id="job_title"
|
||||
value={formData.job_title}
|
||||
onChange={(e) => set('job_title', e.target.value)}
|
||||
disabled={isShared('job_title')}
|
||||
className={isShared('job_title') ? 'opacity-70 cursor-not-allowed' : ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -14,6 +14,7 @@ import {
|
||||
Loader2,
|
||||
Shield,
|
||||
Blocks,
|
||||
Ghost,
|
||||
} from 'lucide-react';
|
||||
import { useSettings } from '@/hooks/useSettings';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@ -24,6 +25,7 @@ import { cn } from '@/lib/utils';
|
||||
import api from '@/lib/api';
|
||||
import type { GeoLocation, UserProfile } from '@/types';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import CopyableField from '@/components/shared/CopyableField';
|
||||
import TotpSetupSection from './TotpSetupSection';
|
||||
import NtfySettingsSection from './NtfySettingsSection';
|
||||
|
||||
@ -55,6 +57,24 @@ export default function SettingsPage() {
|
||||
const [autoLockEnabled, setAutoLockEnabled] = useState(settings?.auto_lock_enabled ?? false);
|
||||
const [autoLockMinutes, setAutoLockMinutes] = useState<number | string>(settings?.auto_lock_minutes ?? 5);
|
||||
|
||||
// Profile extension fields (stored on Settings model)
|
||||
const [settingsPhone, setSettingsPhone] = useState(settings?.phone ?? '');
|
||||
const [settingsMobile, setSettingsMobile] = useState(settings?.mobile ?? '');
|
||||
const [settingsAddress, setSettingsAddress] = useState(settings?.address ?? '');
|
||||
const [settingsCompany, setSettingsCompany] = useState(settings?.company ?? '');
|
||||
const [settingsJobTitle, setSettingsJobTitle] = useState(settings?.job_title ?? '');
|
||||
|
||||
// Social settings
|
||||
const [acceptConnections, setAcceptConnections] = useState(settings?.accept_connections ?? false);
|
||||
const [sharePreferredName, setSharePreferredName] = useState(settings?.share_preferred_name ?? true);
|
||||
const [shareEmail, setShareEmail] = useState(settings?.share_email ?? false);
|
||||
const [sharePhone, setSharePhone] = useState(settings?.share_phone ?? false);
|
||||
const [shareMobile, setShareMobile] = useState(settings?.share_mobile ?? false);
|
||||
const [shareBirthday, setShareBirthday] = useState(settings?.share_birthday ?? false);
|
||||
const [shareAddress, setShareAddress] = useState(settings?.share_address ?? false);
|
||||
const [shareCompany, setShareCompany] = useState(settings?.share_company ?? false);
|
||||
const [shareJobTitle, setShareJobTitle] = useState(settings?.share_job_title ?? false);
|
||||
|
||||
// Profile fields (stored on User model, fetched from /auth/profile)
|
||||
const profileQuery = useQuery({
|
||||
queryKey: ['profile'],
|
||||
@ -87,6 +107,20 @@ export default function SettingsPage() {
|
||||
setFirstDayOfWeek(settings.first_day_of_week);
|
||||
setAutoLockEnabled(settings.auto_lock_enabled);
|
||||
setAutoLockMinutes(settings.auto_lock_minutes ?? 5);
|
||||
setSettingsPhone(settings.phone ?? '');
|
||||
setSettingsMobile(settings.mobile ?? '');
|
||||
setSettingsAddress(settings.address ?? '');
|
||||
setSettingsCompany(settings.company ?? '');
|
||||
setSettingsJobTitle(settings.job_title ?? '');
|
||||
setAcceptConnections(settings.accept_connections);
|
||||
setSharePreferredName(settings.share_preferred_name);
|
||||
setShareEmail(settings.share_email);
|
||||
setSharePhone(settings.share_phone);
|
||||
setShareMobile(settings.share_mobile);
|
||||
setShareBirthday(settings.share_birthday);
|
||||
setShareAddress(settings.share_address);
|
||||
setShareCompany(settings.share_company);
|
||||
setShareJobTitle(settings.share_job_title);
|
||||
}
|
||||
}, [settings?.id]); // only re-sync on initial load (settings.id won't change)
|
||||
|
||||
@ -248,6 +282,29 @@ export default function SettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSettingsFieldSave = async (field: string, value: string) => {
|
||||
const trimmed = value.trim();
|
||||
const currentVal = (settings as any)?.[field] || '';
|
||||
if (trimmed === (currentVal || '')) return;
|
||||
try {
|
||||
await updateSettings({ [field]: trimmed || null } as any);
|
||||
toast.success('Profile updated');
|
||||
} catch {
|
||||
toast.error('Failed to update profile');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSocialToggle = async (field: string, checked: boolean, setter: (v: boolean) => void) => {
|
||||
const previous = (settings as any)?.[field];
|
||||
setter(checked);
|
||||
try {
|
||||
await updateSettings({ [field]: checked } as any);
|
||||
} catch {
|
||||
setter(previous);
|
||||
toast.error('Failed to update setting');
|
||||
}
|
||||
};
|
||||
|
||||
const handleAutoLockMinutesSave = async () => {
|
||||
const raw = typeof autoLockMinutes === 'string' ? parseInt(autoLockMinutes) : autoLockMinutes;
|
||||
const clamped = Math.max(1, Math.min(60, isNaN(raw) ? 5 : raw));
|
||||
@ -363,6 +420,75 @@ export default function SettingsPage() {
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleProfileSave('date_of_birth'); }}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="settings_phone">Phone</Label>
|
||||
<Input
|
||||
id="settings_phone"
|
||||
type="tel"
|
||||
placeholder="Phone number"
|
||||
value={settingsPhone}
|
||||
onChange={(e) => setSettingsPhone(e.target.value)}
|
||||
onBlur={() => handleSettingsFieldSave('phone', settingsPhone)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleSettingsFieldSave('phone', settingsPhone); }}
|
||||
maxLength={50}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="settings_mobile">Mobile</Label>
|
||||
<Input
|
||||
id="settings_mobile"
|
||||
type="tel"
|
||||
placeholder="Mobile number"
|
||||
value={settingsMobile}
|
||||
onChange={(e) => setSettingsMobile(e.target.value)}
|
||||
onBlur={() => handleSettingsFieldSave('mobile', settingsMobile)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleSettingsFieldSave('mobile', settingsMobile); }}
|
||||
maxLength={50}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="settings_address">Address</Label>
|
||||
<Input
|
||||
id="settings_address"
|
||||
type="text"
|
||||
placeholder="Your address"
|
||||
value={settingsAddress}
|
||||
onChange={(e) => setSettingsAddress(e.target.value)}
|
||||
onBlur={() => handleSettingsFieldSave('address', settingsAddress)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleSettingsFieldSave('address', settingsAddress); }}
|
||||
maxLength={2000}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="settings_company">Company</Label>
|
||||
<Input
|
||||
id="settings_company"
|
||||
type="text"
|
||||
placeholder="Company name"
|
||||
value={settingsCompany}
|
||||
onChange={(e) => setSettingsCompany(e.target.value)}
|
||||
onBlur={() => handleSettingsFieldSave('company', settingsCompany)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleSettingsFieldSave('company', settingsCompany); }}
|
||||
maxLength={255}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="settings_job_title">Job Title</Label>
|
||||
<Input
|
||||
id="settings_job_title"
|
||||
type="text"
|
||||
placeholder="Your role"
|
||||
value={settingsJobTitle}
|
||||
onChange={(e) => setSettingsJobTitle(e.target.value)}
|
||||
onBlur={() => handleSettingsFieldSave('job_title', settingsJobTitle)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleSettingsFieldSave('job_title', settingsJobTitle); }}
|
||||
maxLength={255}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@ -586,9 +712,77 @@ export default function SettingsPage() {
|
||||
|
||||
</div>
|
||||
|
||||
{/* ── Right column: Security, Authentication, Integrations ── */}
|
||||
{/* ── Right column: Social, Security, Authentication, Integrations ── */}
|
||||
<div className="space-y-6">
|
||||
|
||||
{/* Social */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-1.5 rounded-md bg-violet-500/10">
|
||||
<Ghost className="h-4 w-4 text-violet-400" aria-hidden="true" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>Social</CardTitle>
|
||||
<CardDescription>Manage your Umbra identity and connections</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Umbral Name</Label>
|
||||
<div className="flex items-center gap-3">
|
||||
<Input
|
||||
value={profileQuery.data?.username ?? ''}
|
||||
disabled
|
||||
className="opacity-70 cursor-not-allowed"
|
||||
/>
|
||||
<CopyableField value={profileQuery.data?.username ?? ''} label="Umbral name" />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
How other Umbra users find you
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Accept Connections</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Allow other users to find and connect with you
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={acceptConnections}
|
||||
onCheckedChange={(checked) => handleSocialToggle('accept_connections', checked, setAcceptConnections)}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-t border-border pt-4 mt-4">
|
||||
<p className="text-[11px] uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Sharing Defaults
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{[
|
||||
{ field: 'share_preferred_name', label: 'Preferred Name', state: sharePreferredName, setter: setSharePreferredName },
|
||||
{ field: 'share_email', label: 'Email', state: shareEmail, setter: setShareEmail },
|
||||
{ field: 'share_phone', label: 'Phone', state: sharePhone, setter: setSharePhone },
|
||||
{ field: 'share_mobile', label: 'Mobile', state: shareMobile, setter: setShareMobile },
|
||||
{ field: 'share_birthday', label: 'Birthday', state: shareBirthday, setter: setShareBirthday },
|
||||
{ field: 'share_address', label: 'Address', state: shareAddress, setter: setShareAddress },
|
||||
{ field: 'share_company', label: 'Company', state: shareCompany, setter: setShareCompany },
|
||||
{ field: 'share_job_title', label: 'Job Title', state: shareJobTitle, setter: setShareJobTitle },
|
||||
].map(({ field, label, state, setter }) => (
|
||||
<div key={field} className="flex items-center justify-between">
|
||||
<Label className="text-sm font-normal">{label}</Label>
|
||||
<Switch
|
||||
checked={state}
|
||||
onCheckedChange={(checked) => handleSocialToggle(field, checked, setter)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Security (auto-lock) */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
||||
@ -18,6 +18,12 @@ import {
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
|
||||
interface ExtraPinnedFilter {
|
||||
label: string;
|
||||
isActive: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
interface CategoryFilterBarProps {
|
||||
activeFilters: string[];
|
||||
pinnedLabel: string;
|
||||
@ -30,6 +36,7 @@ interface CategoryFilterBarProps {
|
||||
onReorderCategories?: (order: string[]) => void;
|
||||
searchValue: string;
|
||||
onSearchChange: (val: string) => void;
|
||||
extraPinnedFilters?: ExtraPinnedFilter[];
|
||||
}
|
||||
|
||||
const pillBase =
|
||||
@ -116,6 +123,7 @@ export default function CategoryFilterBar({
|
||||
onReorderCategories,
|
||||
searchValue,
|
||||
onSearchChange,
|
||||
extraPinnedFilters = [],
|
||||
}: CategoryFilterBarProps) {
|
||||
const [otherOpen, setOtherOpen] = useState(false);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
@ -169,6 +177,22 @@ export default function CategoryFilterBar({
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Extra pinned filters (e.g. "Umbral") */}
|
||||
{extraPinnedFilters.map((epf) => (
|
||||
<button
|
||||
key={epf.label}
|
||||
type="button"
|
||||
onClick={epf.onToggle}
|
||||
aria-label={`Filter by ${epf.label}`}
|
||||
className={pillBase}
|
||||
style={epf.isActive ? activePillStyle : undefined}
|
||||
>
|
||||
<span className={epf.isActive ? '' : 'text-muted-foreground hover:text-foreground'}>
|
||||
{epf.label}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* Categories pill + expandable chips */}
|
||||
{categories.length > 0 && (
|
||||
<>
|
||||
|
||||
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_enabled: boolean;
|
||||
auto_lock_minutes: number;
|
||||
// Profile fields (shareable)
|
||||
phone: string | null;
|
||||
mobile: string | null;
|
||||
address: string | null;
|
||||
company: string | null;
|
||||
job_title: string | null;
|
||||
// Social settings
|
||||
accept_connections: boolean;
|
||||
// Sharing defaults
|
||||
share_preferred_name: boolean;
|
||||
share_email: boolean;
|
||||
share_phone: boolean;
|
||||
share_mobile: boolean;
|
||||
share_birthday: boolean;
|
||||
share_address: boolean;
|
||||
share_company: boolean;
|
||||
share_job_title: boolean;
|
||||
// ntfy connections toggle
|
||||
ntfy_connections_enabled: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@ -171,6 +190,9 @@ export interface Person {
|
||||
company?: string;
|
||||
job_title?: string;
|
||||
notes?: string;
|
||||
linked_user_id?: number | null;
|
||||
is_umbral_contact: boolean;
|
||||
shared_fields?: Record<string, unknown> | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@ -222,6 +244,7 @@ export type LoginResponse = LoginSuccessResponse | LoginMfaRequiredResponse | Lo
|
||||
export interface AdminUser {
|
||||
id: number;
|
||||
username: string;
|
||||
umbral_name: string;
|
||||
email: string | null;
|
||||
first_name: string | null;
|
||||
last_name: string | null;
|
||||
@ -366,3 +389,48 @@ export interface EventTemplate {
|
||||
is_starred: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// ── Notifications ──────────────────────────────────────────────────
|
||||
// Named AppNotification to avoid collision with browser Notification API
|
||||
|
||||
export interface AppNotification {
|
||||
id: number;
|
||||
user_id: number;
|
||||
type: string;
|
||||
title: string | null;
|
||||
message: string | null;
|
||||
data: Record<string, unknown> | null;
|
||||
source_type: string | null;
|
||||
source_id: number | null;
|
||||
is_read: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface NotificationListResponse {
|
||||
notifications: AppNotification[];
|
||||
unread_count: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
// ── Connections ────────────────────────────────────────────────────
|
||||
|
||||
export interface ConnectionRequest {
|
||||
id: number;
|
||||
sender_umbral_name: string;
|
||||
sender_preferred_name: string | null;
|
||||
status: 'pending' | 'accepted' | 'rejected' | 'cancelled';
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Connection {
|
||||
id: number;
|
||||
connected_user_id: number;
|
||||
connected_umbral_name: string;
|
||||
connected_preferred_name: string | null;
|
||||
person_id: number | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface UmbralSearchResponse {
|
||||
found: boolean;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user