Compare commits
No commits in common. "47645ec11595754168db40437717454681935c86" and "2a21809066276038a92b1a4e0721bd1233c250dd" have entirely different histories.
47645ec115
...
2a21809066
@ -1,37 +0,0 @@
|
|||||||
"""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")
|
|
||||||
@ -1,85 +0,0 @@
|
|||||||
"""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")
|
|
||||||
@ -1,57 +0,0 @@
|
|||||||
"""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")
|
|
||||||
@ -1,109 +0,0 @@
|
|||||||
"""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")
|
|
||||||
@ -1,44 +0,0 @@
|
|||||||
"""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")
|
|
||||||
@ -1,45 +0,0 @@
|
|||||||
"""Add CHECK constraint on notifications.type column.
|
|
||||||
|
|
||||||
Revision ID: 044
|
|
||||||
Revises: 043
|
|
||||||
"""
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
revision = "044"
|
|
||||||
down_revision = "043"
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
ALLOWED_TYPES = (
|
|
||||||
"connection_request",
|
|
||||||
"connection_accepted",
|
|
||||||
"connection_rejected",
|
|
||||||
"info",
|
|
||||||
"warning",
|
|
||||||
"reminder",
|
|
||||||
"system",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
# Defensive: ensure no existing rows violate the constraint
|
|
||||||
conn = op.get_bind()
|
|
||||||
placeholders = ", ".join(f"'{t}'" for t in ALLOWED_TYPES)
|
|
||||||
bad = conn.execute(
|
|
||||||
sa.text(f"SELECT COUNT(*) FROM notifications WHERE type NOT IN ({placeholders})")
|
|
||||||
).scalar()
|
|
||||||
if bad:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"Cannot apply CHECK constraint: {bad} notification(s) have types outside the allowed list"
|
|
||||||
)
|
|
||||||
|
|
||||||
op.create_check_constraint(
|
|
||||||
"ck_notifications_type",
|
|
||||||
"notifications",
|
|
||||||
f"type IN ({placeholders})",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
op.drop_constraint("ck_notifications_type", "notifications", type_="check")
|
|
||||||
@ -1,28 +0,0 @@
|
|||||||
"""Add share_first_name and share_last_name to settings.
|
|
||||||
|
|
||||||
Revision ID: 045
|
|
||||||
Revises: 044
|
|
||||||
"""
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
revision = "045"
|
|
||||||
down_revision = "044"
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
op.add_column(
|
|
||||||
"settings",
|
|
||||||
sa.Column("share_first_name", sa.Boolean, nullable=False, server_default="false"),
|
|
||||||
)
|
|
||||||
op.add_column(
|
|
||||||
"settings",
|
|
||||||
sa.Column("share_last_name", sa.Boolean, nullable=False, server_default="false"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
op.drop_column("settings", "share_last_name")
|
|
||||||
op.drop_column("settings", "share_first_name")
|
|
||||||
@ -1,34 +0,0 @@
|
|||||||
"""Add person_id to connection_requests
|
|
||||||
|
|
||||||
Revision ID: 046
|
|
||||||
Revises: 045
|
|
||||||
"""
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
revision = "046"
|
|
||||||
down_revision = "045"
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
op.add_column(
|
|
||||||
"connection_requests",
|
|
||||||
sa.Column(
|
|
||||||
"person_id",
|
|
||||||
sa.Integer(),
|
|
||||||
sa.ForeignKey("people.id", ondelete="SET NULL"),
|
|
||||||
nullable=True,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
op.create_index(
|
|
||||||
"ix_connection_requests_person_id",
|
|
||||||
"connection_requests",
|
|
||||||
["person_id"],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
op.drop_index("ix_connection_requests_person_id", table_name="connection_requests")
|
|
||||||
op.drop_column("connection_requests", "person_id")
|
|
||||||
@ -17,7 +17,6 @@ from sqlalchemy.orm import selectinload
|
|||||||
|
|
||||||
from app.database import AsyncSessionLocal
|
from app.database import AsyncSessionLocal
|
||||||
from app.models.settings import Settings
|
from app.models.settings import Settings
|
||||||
from app.models.notification import Notification as AppNotification
|
|
||||||
from app.models.reminder import Reminder
|
from app.models.reminder import Reminder
|
||||||
from app.models.calendar_event import CalendarEvent
|
from app.models.calendar_event import CalendarEvent
|
||||||
from app.models.calendar import Calendar
|
from app.models.calendar import Calendar
|
||||||
@ -26,7 +25,6 @@ from app.models.project import Project
|
|||||||
from app.models.ntfy_sent import NtfySent
|
from app.models.ntfy_sent import NtfySent
|
||||||
from app.models.totp_usage import TOTPUsage
|
from app.models.totp_usage import TOTPUsage
|
||||||
from app.models.session import UserSession
|
from app.models.session import UserSession
|
||||||
from app.models.connection_request import ConnectionRequest
|
|
||||||
from app.services.ntfy import send_ntfy_notification
|
from app.services.ntfy import send_ntfy_notification
|
||||||
from app.services.ntfy_templates import (
|
from app.services.ntfy_templates import (
|
||||||
build_event_notification,
|
build_event_notification,
|
||||||
@ -269,37 +267,6 @@ async def _purge_expired_sessions(db: AsyncSession) -> None:
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
async def _purge_old_notifications(db: AsyncSession) -> None:
|
|
||||||
"""Remove in-app notifications older than 90 days."""
|
|
||||||
cutoff = datetime.now() - timedelta(days=90)
|
|
||||||
await db.execute(delete(AppNotification).where(AppNotification.created_at < cutoff))
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
|
|
||||||
async def _purge_resolved_requests(db: AsyncSession) -> None:
|
|
||||||
"""Remove resolved connection requests after retention period.
|
|
||||||
|
|
||||||
Rejected/cancelled: 30 days. Accepted: 90 days (longer for audit trail).
|
|
||||||
resolved_at must be set when changing status. NULL resolved_at rows are
|
|
||||||
preserved (comparison with NULL yields NULL).
|
|
||||||
"""
|
|
||||||
reject_cutoff = datetime.now() - timedelta(days=30)
|
|
||||||
accept_cutoff = datetime.now() - timedelta(days=90)
|
|
||||||
await db.execute(
|
|
||||||
delete(ConnectionRequest).where(
|
|
||||||
ConnectionRequest.status.in_(["rejected", "cancelled"]),
|
|
||||||
ConnectionRequest.resolved_at < reject_cutoff,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
await db.execute(
|
|
||||||
delete(ConnectionRequest).where(
|
|
||||||
ConnectionRequest.status == "accepted",
|
|
||||||
ConnectionRequest.resolved_at < accept_cutoff,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
|
|
||||||
# ── Entry point ───────────────────────────────────────────────────────────────
|
# ── Entry point ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async def run_notification_dispatch() -> None:
|
async def run_notification_dispatch() -> None:
|
||||||
@ -341,8 +308,6 @@ async def run_notification_dispatch() -> None:
|
|||||||
async with AsyncSessionLocal() as db:
|
async with AsyncSessionLocal() as db:
|
||||||
await _purge_totp_usage(db)
|
await _purge_totp_usage(db)
|
||||||
await _purge_expired_sessions(db)
|
await _purge_expired_sessions(db)
|
||||||
await _purge_old_notifications(db)
|
|
||||||
await _purge_resolved_requests(db)
|
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
# Broad catch: job failure must never crash the scheduler or the app
|
# Broad catch: job failure must never crash the scheduler or the app
|
||||||
|
|||||||
@ -7,7 +7,7 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
|||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.database import engine
|
from app.database import engine
|
||||||
from app.routers import auth, todos, events, calendars, reminders, projects, people, locations, settings as settings_router, dashboard, weather, event_templates
|
from app.routers import auth, todos, events, calendars, reminders, projects, people, locations, settings as settings_router, dashboard, weather, event_templates
|
||||||
from app.routers import totp, admin, notifications as notifications_router, connections as connections_router
|
from app.routers import totp, admin
|
||||||
from app.jobs.notifications import run_notification_dispatch
|
from app.jobs.notifications import run_notification_dispatch
|
||||||
|
|
||||||
# Import models so Alembic's autogenerate can discover them
|
# Import models so Alembic's autogenerate can discover them
|
||||||
@ -17,9 +17,6 @@ from app.models import totp_usage as _totp_usage_model # noqa: F401
|
|||||||
from app.models import backup_code as _backup_code_model # noqa: F401
|
from app.models import backup_code as _backup_code_model # noqa: F401
|
||||||
from app.models import system_config as _system_config_model # noqa: F401
|
from app.models import system_config as _system_config_model # noqa: F401
|
||||||
from app.models import audit_log as _audit_log_model # noqa: F401
|
from app.models import audit_log as _audit_log_model # noqa: F401
|
||||||
from app.models import notification as _notification_model # noqa: F401
|
|
||||||
from app.models import connection_request as _connection_request_model # noqa: F401
|
|
||||||
from app.models import user_connection as _user_connection_model # noqa: F401
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@ -132,8 +129,6 @@ app.include_router(weather.router, prefix="/api/weather", tags=["Weather"])
|
|||||||
app.include_router(event_templates.router, prefix="/api/event-templates", tags=["Event Templates"])
|
app.include_router(event_templates.router, prefix="/api/event-templates", tags=["Event Templates"])
|
||||||
app.include_router(totp.router, prefix="/api/auth", tags=["TOTP MFA"])
|
app.include_router(totp.router, prefix="/api/auth", tags=["TOTP MFA"])
|
||||||
app.include_router(admin.router, prefix="/api/admin", tags=["Admin"])
|
app.include_router(admin.router, prefix="/api/admin", tags=["Admin"])
|
||||||
app.include_router(notifications_router.router, prefix="/api/notifications", tags=["Notifications"])
|
|
||||||
app.include_router(connections_router.router, prefix="/api/connections", tags=["Connections"])
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
|
|||||||
@ -15,9 +15,6 @@ from app.models.totp_usage import TOTPUsage
|
|||||||
from app.models.backup_code import BackupCode
|
from app.models.backup_code import BackupCode
|
||||||
from app.models.system_config import SystemConfig
|
from app.models.system_config import SystemConfig
|
||||||
from app.models.audit_log import AuditLog
|
from app.models.audit_log import AuditLog
|
||||||
from app.models.notification import Notification
|
|
||||||
from app.models.connection_request import ConnectionRequest
|
|
||||||
from app.models.user_connection import UserConnection
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Settings",
|
"Settings",
|
||||||
@ -37,7 +34,4 @@ __all__ = [
|
|||||||
"BackupCode",
|
"BackupCode",
|
||||||
"SystemConfig",
|
"SystemConfig",
|
||||||
"AuditLog",
|
"AuditLog",
|
||||||
"Notification",
|
|
||||||
"ConnectionRequest",
|
|
||||||
"UserConnection",
|
|
||||||
]
|
]
|
||||||
|
|||||||
@ -1,36 +0,0 @@
|
|||||||
from sqlalchemy import String, Integer, ForeignKey, CheckConstraint, func
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Optional, TYPE_CHECKING
|
|
||||||
from app.database import Base
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from app.models.user import User
|
|
||||||
|
|
||||||
|
|
||||||
class ConnectionRequest(Base):
|
|
||||||
__tablename__ = "connection_requests"
|
|
||||||
__table_args__ = (
|
|
||||||
CheckConstraint(
|
|
||||||
"status IN ('pending', 'accepted', 'rejected', 'cancelled')",
|
|
||||||
name="ck_connection_requests_status",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
|
||||||
sender_id: Mapped[int] = mapped_column(
|
|
||||||
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
|
||||||
)
|
|
||||||
receiver_id: Mapped[int] = mapped_column(
|
|
||||||
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
|
||||||
)
|
|
||||||
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default="pending")
|
|
||||||
created_at: Mapped[datetime] = mapped_column(default=func.now(), server_default=func.now())
|
|
||||||
resolved_at: Mapped[Optional[datetime]] = mapped_column(nullable=True, default=None)
|
|
||||||
person_id: Mapped[Optional[int]] = mapped_column(
|
|
||||||
Integer, ForeignKey("people.id", ondelete="SET NULL"), nullable=True
|
|
||||||
)
|
|
||||||
|
|
||||||
# Relationships with explicit foreign_keys to disambiguate
|
|
||||||
sender: Mapped["User"] = relationship(foreign_keys=[sender_id], lazy="selectin")
|
|
||||||
receiver: Mapped["User"] = relationship(foreign_keys=[receiver_id], lazy="selectin")
|
|
||||||
@ -1,36 +0,0 @@
|
|||||||
from sqlalchemy import CheckConstraint, String, Text, Integer, Boolean, ForeignKey, func
|
|
||||||
from sqlalchemy.dialects.postgresql import JSONB
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Optional
|
|
||||||
from app.database import Base
|
|
||||||
|
|
||||||
# Active: connection_request, connection_accepted
|
|
||||||
# Reserved: connection_rejected, info, warning, reminder, system
|
|
||||||
_NOTIFICATION_TYPES = (
|
|
||||||
"connection_request", "connection_accepted", "connection_rejected",
|
|
||||||
"info", "warning", "reminder", "system",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Notification(Base):
|
|
||||||
__tablename__ = "notifications"
|
|
||||||
__table_args__ = (
|
|
||||||
CheckConstraint(
|
|
||||||
f"type IN ({', '.join(repr(t) for t in _NOTIFICATION_TYPES)})",
|
|
||||||
name="ck_notifications_type",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
|
||||||
user_id: Mapped[int] = mapped_column(
|
|
||||||
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
|
||||||
)
|
|
||||||
type: Mapped[str] = mapped_column(String(50), nullable=False)
|
|
||||||
title: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
|
||||||
message: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
|
||||||
data: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)
|
|
||||||
source_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
|
|
||||||
source_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
|
||||||
is_read: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
|
||||||
created_at: Mapped[datetime] = mapped_column(default=func.now(), server_default=func.now())
|
|
||||||
@ -27,11 +27,6 @@ class Person(Base):
|
|||||||
job_title: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
job_title: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||||
mobile: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
|
mobile: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
|
||||||
category: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
category: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||||
# Umbral contact link
|
|
||||||
linked_user_id: Mapped[Optional[int]] = mapped_column(
|
|
||||||
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
|
||||||
)
|
|
||||||
is_umbral_contact: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default=text('false'))
|
|
||||||
created_at: Mapped[datetime] = mapped_column(default=func.now())
|
created_at: Mapped[datetime] = mapped_column(default=func.now())
|
||||||
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())
|
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
from sqlalchemy import String, Text, Integer, Float, Boolean, ForeignKey, func
|
from sqlalchemy import String, Integer, Float, Boolean, ForeignKey, func
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@ -46,31 +46,6 @@ class Settings(Base):
|
|||||||
auto_lock_enabled: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
auto_lock_enabled: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||||
auto_lock_minutes: Mapped[int] = mapped_column(Integer, default=5, server_default="5")
|
auto_lock_minutes: Mapped[int] = mapped_column(Integer, default=5, server_default="5")
|
||||||
|
|
||||||
# Profile fields (shareable with connections)
|
|
||||||
phone: Mapped[Optional[str]] = mapped_column(String(50), nullable=True, default=None)
|
|
||||||
mobile: Mapped[Optional[str]] = mapped_column(String(50), nullable=True, default=None)
|
|
||||||
address: Mapped[Optional[str]] = mapped_column(Text, nullable=True, default=None)
|
|
||||||
company: Mapped[Optional[str]] = mapped_column(String(255), nullable=True, default=None)
|
|
||||||
job_title: Mapped[Optional[str]] = mapped_column(String(255), nullable=True, default=None)
|
|
||||||
|
|
||||||
# Social settings
|
|
||||||
accept_connections: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
|
||||||
|
|
||||||
# Sharing defaults (what fields are shared with connections by default)
|
|
||||||
share_first_name: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
|
||||||
share_last_name: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
|
||||||
share_preferred_name: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true")
|
|
||||||
share_email: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
|
||||||
share_phone: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
|
||||||
share_mobile: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
|
||||||
share_birthday: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
|
||||||
share_address: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
|
||||||
share_company: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
|
||||||
share_job_title: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
|
||||||
|
|
||||||
# ntfy connection notification toggle (gates push only, not in-app)
|
|
||||||
ntfy_connections_enabled: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true")
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def ntfy_has_token(self) -> bool:
|
def ntfy_has_token(self) -> bool:
|
||||||
"""Derived field for SettingsResponse — True when an auth token is stored."""
|
"""Derived field for SettingsResponse — True when an auth token is stored."""
|
||||||
|
|||||||
@ -9,7 +9,6 @@ class User(Base):
|
|||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||||
username: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True)
|
username: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True)
|
||||||
umbral_name: Mapped[str] = mapped_column(String(50), unique=True, index=True)
|
|
||||||
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
first_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
first_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
last_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
last_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
|||||||
@ -1,31 +0,0 @@
|
|||||||
from sqlalchemy import Integer, ForeignKey, func
|
|
||||||
from sqlalchemy.dialects.postgresql import JSONB
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Optional, TYPE_CHECKING
|
|
||||||
from app.database import Base
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from app.models.user import User
|
|
||||||
from app.models.person import Person
|
|
||||||
|
|
||||||
|
|
||||||
class UserConnection(Base):
|
|
||||||
__tablename__ = "user_connections"
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
|
||||||
user_id: Mapped[int] = mapped_column(
|
|
||||||
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
|
||||||
)
|
|
||||||
connected_user_id: Mapped[int] = mapped_column(
|
|
||||||
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
|
||||||
)
|
|
||||||
person_id: Mapped[Optional[int]] = mapped_column(
|
|
||||||
Integer, ForeignKey("people.id", ondelete="SET NULL"), nullable=True
|
|
||||||
)
|
|
||||||
sharing_overrides: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)
|
|
||||||
created_at: Mapped[datetime] = mapped_column(default=func.now(), server_default=func.now())
|
|
||||||
|
|
||||||
# Relationships
|
|
||||||
connected_user: Mapped["User"] = relationship(foreign_keys=[connected_user_id], lazy="selectin")
|
|
||||||
person: Mapped[Optional["Person"]] = relationship(foreign_keys=[person_id], lazy="selectin")
|
|
||||||
@ -70,21 +70,10 @@ def _target_username_col(target_alias, audit_model):
|
|||||||
COALESCE: prefer the live username from the users table,
|
COALESCE: prefer the live username from the users table,
|
||||||
fall back to the username stored in the audit detail JSON
|
fall back to the username stored in the audit detail JSON
|
||||||
(survives user deletion since audit_log.target_user_id → SET NULL).
|
(survives user deletion since audit_log.target_user_id → SET NULL).
|
||||||
Guard the JSONB cast with a CASE to avoid errors on non-JSON detail values.
|
|
||||||
"""
|
"""
|
||||||
json_fallback = sa.case(
|
|
||||||
(
|
|
||||||
sa.and_(
|
|
||||||
audit_model.detail.is_not(None),
|
|
||||||
audit_model.detail.startswith("{"),
|
|
||||||
),
|
|
||||||
sa.cast(audit_model.detail, JSONB)["username"].as_string(),
|
|
||||||
),
|
|
||||||
else_=sa.null(),
|
|
||||||
)
|
|
||||||
return sa.func.coalesce(
|
return sa.func.coalesce(
|
||||||
target_alias.username,
|
target_alias.username,
|
||||||
json_fallback,
|
sa.cast(audit_model.detail, JSONB)["username"].as_string(),
|
||||||
).label("target_username")
|
).label("target_username")
|
||||||
|
|
||||||
|
|
||||||
@ -181,9 +170,9 @@ async def get_user(
|
|||||||
)
|
)
|
||||||
active_sessions = session_result.scalar_one()
|
active_sessions = session_result.scalar_one()
|
||||||
|
|
||||||
# Fetch preferred_name from Settings (limit 1 defensive)
|
# Fetch preferred_name from Settings
|
||||||
settings_result = await db.execute(
|
settings_result = await db.execute(
|
||||||
sa.select(Settings.preferred_name).where(Settings.user_id == user_id).limit(1)
|
sa.select(Settings.preferred_name).where(Settings.user_id == user_id)
|
||||||
)
|
)
|
||||||
preferred_name = settings_result.scalar_one_or_none()
|
preferred_name = settings_result.scalar_one_or_none()
|
||||||
|
|
||||||
@ -192,8 +181,6 @@ async def get_user(
|
|||||||
active_sessions=active_sessions,
|
active_sessions=active_sessions,
|
||||||
preferred_name=preferred_name,
|
preferred_name=preferred_name,
|
||||||
date_of_birth=user.date_of_birth,
|
date_of_birth=user.date_of_birth,
|
||||||
must_change_password=user.must_change_password,
|
|
||||||
locked_until=user.locked_until,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -222,7 +209,6 @@ async def create_user(
|
|||||||
|
|
||||||
new_user = User(
|
new_user = User(
|
||||||
username=data.username,
|
username=data.username,
|
||||||
umbral_name=data.username,
|
|
||||||
password_hash=hash_password(data.password),
|
password_hash=hash_password(data.password),
|
||||||
role=data.role,
|
role=data.role,
|
||||||
email=email,
|
email=email,
|
||||||
@ -255,10 +241,6 @@ async def create_user(
|
|||||||
return UserDetailResponse(
|
return UserDetailResponse(
|
||||||
**UserListItem.model_validate(new_user).model_dump(exclude={"active_sessions"}),
|
**UserListItem.model_validate(new_user).model_dump(exclude={"active_sessions"}),
|
||||||
active_sessions=0,
|
active_sessions=0,
|
||||||
preferred_name=data.preferred_name,
|
|
||||||
date_of_birth=None,
|
|
||||||
must_change_password=new_user.must_change_password,
|
|
||||||
locked_until=new_user.locked_until,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -288,7 +288,6 @@ async def setup(
|
|||||||
password_hash = hash_password(data.password)
|
password_hash = hash_password(data.password)
|
||||||
new_user = User(
|
new_user = User(
|
||||||
username=data.username,
|
username=data.username,
|
||||||
umbral_name=data.username,
|
|
||||||
password_hash=password_hash,
|
password_hash=password_hash,
|
||||||
role="admin",
|
role="admin",
|
||||||
last_password_change_at=datetime.now(),
|
last_password_change_at=datetime.now(),
|
||||||
@ -441,7 +440,7 @@ async def register(
|
|||||||
select(User).where(User.username == data.username)
|
select(User).where(User.username == data.username)
|
||||||
)
|
)
|
||||||
if existing.scalar_one_or_none():
|
if existing.scalar_one_or_none():
|
||||||
raise HTTPException(status_code=400, detail="Registration could not be completed. Please check your details and try again.")
|
raise HTTPException(status_code=400, detail="Registration could not be completed. Please try a different username.")
|
||||||
|
|
||||||
# Check email uniqueness (generic error to prevent enumeration)
|
# Check email uniqueness (generic error to prevent enumeration)
|
||||||
if data.email:
|
if data.email:
|
||||||
@ -455,7 +454,6 @@ async def register(
|
|||||||
# SEC-01: Explicit field assignment — never **data.model_dump()
|
# SEC-01: Explicit field assignment — never **data.model_dump()
|
||||||
new_user = User(
|
new_user = User(
|
||||||
username=data.username,
|
username=data.username,
|
||||||
umbral_name=data.username,
|
|
||||||
password_hash=password_hash,
|
password_hash=password_hash,
|
||||||
role="standard",
|
role="standard",
|
||||||
email=data.email,
|
email=data.email,
|
||||||
@ -668,15 +666,6 @@ async def update_profile(
|
|||||||
if existing.scalar_one_or_none():
|
if existing.scalar_one_or_none():
|
||||||
raise HTTPException(status_code=400, detail="Email is already in use")
|
raise HTTPException(status_code=400, detail="Email is already in use")
|
||||||
|
|
||||||
# Umbral name uniqueness check if changing
|
|
||||||
if "umbral_name" in update_data and update_data["umbral_name"] != current_user.umbral_name:
|
|
||||||
new_name = update_data["umbral_name"]
|
|
||||||
existing = await db.execute(
|
|
||||||
select(User).where(User.umbral_name == new_name, User.id != current_user.id)
|
|
||||||
)
|
|
||||||
if existing.scalar_one_or_none():
|
|
||||||
raise HTTPException(status_code=400, detail="Umbral name is already taken")
|
|
||||||
|
|
||||||
# SEC-01: Explicit field assignment — only allowed profile fields
|
# SEC-01: Explicit field assignment — only allowed profile fields
|
||||||
if "first_name" in update_data:
|
if "first_name" in update_data:
|
||||||
current_user.first_name = update_data["first_name"]
|
current_user.first_name = update_data["first_name"]
|
||||||
@ -686,8 +675,6 @@ async def update_profile(
|
|||||||
current_user.email = update_data["email"]
|
current_user.email = update_data["email"]
|
||||||
if "date_of_birth" in update_data:
|
if "date_of_birth" in update_data:
|
||||||
current_user.date_of_birth = update_data["date_of_birth"]
|
current_user.date_of_birth = update_data["date_of_birth"]
|
||||||
if "umbral_name" in update_data:
|
|
||||||
current_user.umbral_name = update_data["umbral_name"]
|
|
||||||
|
|
||||||
await log_audit_event(
|
await log_audit_event(
|
||||||
db, action="auth.profile_updated", actor_id=current_user.id,
|
db, action="auth.profile_updated", actor_id=current_user.id,
|
||||||
|
|||||||
@ -1,836 +0,0 @@
|
|||||||
"""
|
|
||||||
Connection router — search, request, respond, manage connections.
|
|
||||||
|
|
||||||
Security:
|
|
||||||
- Timing-safe search (50ms sleep floor)
|
|
||||||
- Per-receiver pending request cap (5 within 10 minutes)
|
|
||||||
- Atomic accept via UPDATE...WHERE status='pending' RETURNING *
|
|
||||||
- All endpoints scoped by current_user.id
|
|
||||||
- Audit logging for all connection events
|
|
||||||
"""
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
from datetime import date as date_type, datetime, timedelta
|
|
||||||
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Path, Query, Request
|
|
||||||
from sqlalchemy import delete, select, func, and_, update
|
|
||||||
from sqlalchemy.exc import IntegrityError
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
from sqlalchemy.orm import selectinload
|
|
||||||
|
|
||||||
from app.database import get_db
|
|
||||||
from app.models.connection_request import ConnectionRequest
|
|
||||||
from app.models.notification import Notification
|
|
||||||
from app.models.person import Person
|
|
||||||
from app.models.settings import Settings
|
|
||||||
from app.models.user import User
|
|
||||||
from app.models.user_connection import UserConnection
|
|
||||||
from app.routers.auth import get_current_user
|
|
||||||
from app.schemas.connection import (
|
|
||||||
CancelResponse,
|
|
||||||
ConnectionRequestResponse,
|
|
||||||
ConnectionResponse,
|
|
||||||
RespondAcceptResponse,
|
|
||||||
RespondRejectResponse,
|
|
||||||
RespondRequest,
|
|
||||||
SendConnectionRequest,
|
|
||||||
SharingOverrideUpdate,
|
|
||||||
UmbralSearchRequest,
|
|
||||||
UmbralSearchResponse,
|
|
||||||
)
|
|
||||||
from app.services.audit import get_client_ip, log_audit_event
|
|
||||||
from app.services.connection import (
|
|
||||||
NOTIF_TYPE_CONNECTION_ACCEPTED,
|
|
||||||
NOTIF_TYPE_CONNECTION_REQUEST,
|
|
||||||
SHAREABLE_FIELDS,
|
|
||||||
create_person_from_connection,
|
|
||||||
detach_umbral_contact,
|
|
||||||
extract_ntfy_config,
|
|
||||||
resolve_shared_profile,
|
|
||||||
send_connection_ntfy,
|
|
||||||
)
|
|
||||||
from app.services.notification import create_notification
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Helpers ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async def _get_settings_for_user(db: AsyncSession, user_id: int) -> Settings | None:
|
|
||||||
result = await db.execute(select(Settings).where(Settings.user_id == user_id))
|
|
||||||
return result.scalar_one_or_none()
|
|
||||||
|
|
||||||
|
|
||||||
def _build_request_response(
|
|
||||||
req: ConnectionRequest,
|
|
||||||
sender: User,
|
|
||||||
sender_settings: Settings | None,
|
|
||||||
receiver: User,
|
|
||||||
receiver_settings: Settings | None,
|
|
||||||
) -> ConnectionRequestResponse:
|
|
||||||
return ConnectionRequestResponse(
|
|
||||||
id=req.id,
|
|
||||||
sender_umbral_name=sender.umbral_name,
|
|
||||||
sender_preferred_name=sender_settings.preferred_name if sender_settings else None,
|
|
||||||
receiver_umbral_name=receiver.umbral_name,
|
|
||||||
receiver_preferred_name=receiver_settings.preferred_name if receiver_settings else None,
|
|
||||||
status=req.status,
|
|
||||||
created_at=req.created_at,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ── POST /search ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.post("/search", response_model=UmbralSearchResponse)
|
|
||||||
async def search_user(
|
|
||||||
body: UmbralSearchRequest,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Timing-safe user search. Always queries by umbral_name alone,
|
|
||||||
then checks accept_connections + is_active in Python.
|
|
||||||
Generic "not found" for non-existent, opted-out, AND inactive users.
|
|
||||||
50ms sleep floor to eliminate timing side-channel.
|
|
||||||
"""
|
|
||||||
# Always sleep to prevent timing attacks
|
|
||||||
await asyncio.sleep(0.05)
|
|
||||||
|
|
||||||
# Sender must have accept_connections enabled to search
|
|
||||||
sender_settings = await _get_settings_for_user(db, current_user.id)
|
|
||||||
if not sender_settings or not sender_settings.accept_connections:
|
|
||||||
return UmbralSearchResponse(found=False)
|
|
||||||
|
|
||||||
# Don't find yourself
|
|
||||||
if body.umbral_name == current_user.umbral_name:
|
|
||||||
return UmbralSearchResponse(found=False)
|
|
||||||
|
|
||||||
result = await db.execute(
|
|
||||||
select(User).where(User.umbral_name == body.umbral_name)
|
|
||||||
)
|
|
||||||
target = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if not target or not target.is_active:
|
|
||||||
return UmbralSearchResponse(found=False)
|
|
||||||
|
|
||||||
# Check if they accept connections
|
|
||||||
target_settings = await _get_settings_for_user(db, target.id)
|
|
||||||
if not target_settings or not target_settings.accept_connections:
|
|
||||||
return UmbralSearchResponse(found=False)
|
|
||||||
|
|
||||||
return UmbralSearchResponse(found=True)
|
|
||||||
|
|
||||||
|
|
||||||
# ── POST /request ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.post("/request", response_model=ConnectionRequestResponse, status_code=201)
|
|
||||||
async def send_connection_request(
|
|
||||||
body: SendConnectionRequest,
|
|
||||||
request: Request,
|
|
||||||
background_tasks: BackgroundTasks,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""Send a connection request to another user."""
|
|
||||||
# Resolve target
|
|
||||||
result = await db.execute(
|
|
||||||
select(User).where(User.umbral_name == body.umbral_name)
|
|
||||||
)
|
|
||||||
target = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if not target or not target.is_active:
|
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
|
||||||
|
|
||||||
# Self-request guard
|
|
||||||
if target.id == current_user.id:
|
|
||||||
raise HTTPException(status_code=400, detail="Cannot send a connection request to yourself")
|
|
||||||
|
|
||||||
# Sender must have accept_connections enabled to participate
|
|
||||||
sender_settings = await _get_settings_for_user(db, current_user.id)
|
|
||||||
if not sender_settings or not sender_settings.accept_connections:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=403,
|
|
||||||
detail="You must enable 'Accept Connections' in your settings before sending requests",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check accept_connections on target
|
|
||||||
target_settings = await _get_settings_for_user(db, target.id)
|
|
||||||
if not target_settings or not target_settings.accept_connections:
|
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
|
||||||
|
|
||||||
# Check existing connection
|
|
||||||
existing_conn = await db.execute(
|
|
||||||
select(UserConnection).where(
|
|
||||||
UserConnection.user_id == current_user.id,
|
|
||||||
UserConnection.connected_user_id == target.id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if existing_conn.scalar_one_or_none():
|
|
||||||
raise HTTPException(status_code=409, detail="Already connected")
|
|
||||||
|
|
||||||
# Check pending request in either direction
|
|
||||||
existing_req = await db.execute(
|
|
||||||
select(ConnectionRequest).where(
|
|
||||||
and_(
|
|
||||||
ConnectionRequest.status == "pending",
|
|
||||||
(
|
|
||||||
(ConnectionRequest.sender_id == current_user.id) & (ConnectionRequest.receiver_id == target.id)
|
|
||||||
) | (
|
|
||||||
(ConnectionRequest.sender_id == target.id) & (ConnectionRequest.receiver_id == current_user.id)
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if existing_req.scalar_one_or_none():
|
|
||||||
raise HTTPException(status_code=409, detail="A pending request already exists")
|
|
||||||
|
|
||||||
# Per-receiver cap: max 5 pending requests within 10 minutes
|
|
||||||
ten_min_ago = datetime.now() - timedelta(minutes=10)
|
|
||||||
pending_count = await db.scalar(
|
|
||||||
select(func.count())
|
|
||||||
.select_from(ConnectionRequest)
|
|
||||||
.where(
|
|
||||||
ConnectionRequest.receiver_id == target.id,
|
|
||||||
ConnectionRequest.status == "pending",
|
|
||||||
ConnectionRequest.created_at >= ten_min_ago,
|
|
||||||
)
|
|
||||||
) or 0
|
|
||||||
if pending_count >= 5:
|
|
||||||
raise HTTPException(status_code=429, detail="Too many pending requests for this user")
|
|
||||||
|
|
||||||
# Validate person_id if provided (link existing standard contact)
|
|
||||||
link_person_id = None
|
|
||||||
if body.person_id is not None:
|
|
||||||
person_result = await db.execute(
|
|
||||||
select(Person).where(Person.id == body.person_id, Person.user_id == current_user.id)
|
|
||||||
)
|
|
||||||
link_person = person_result.scalar_one_or_none()
|
|
||||||
if not link_person:
|
|
||||||
raise HTTPException(status_code=400, detail="Person not found or not owned by you")
|
|
||||||
if link_person.is_umbral_contact:
|
|
||||||
raise HTTPException(status_code=400, detail="Person is already an umbral contact")
|
|
||||||
link_person_id = body.person_id
|
|
||||||
|
|
||||||
# Create the request (IntegrityError guard for TOCTOU race on partial unique index)
|
|
||||||
conn_request = ConnectionRequest(
|
|
||||||
sender_id=current_user.id,
|
|
||||||
receiver_id=target.id,
|
|
||||||
person_id=link_person_id,
|
|
||||||
)
|
|
||||||
db.add(conn_request)
|
|
||||||
try:
|
|
||||||
await db.flush() # populate conn_request.id for source_id
|
|
||||||
except IntegrityError:
|
|
||||||
await db.rollback()
|
|
||||||
raise HTTPException(status_code=409, detail="A pending request already exists")
|
|
||||||
|
|
||||||
# Create in-app notification for receiver (sender_settings already fetched above)
|
|
||||||
sender_display = (sender_settings.preferred_name if sender_settings else None) or current_user.umbral_name
|
|
||||||
|
|
||||||
await create_notification(
|
|
||||||
db,
|
|
||||||
user_id=target.id,
|
|
||||||
type=NOTIF_TYPE_CONNECTION_REQUEST,
|
|
||||||
title="New Connection Request",
|
|
||||||
message=f"{sender_display} wants to connect with you",
|
|
||||||
data={"sender_umbral_name": current_user.umbral_name},
|
|
||||||
source_type=NOTIF_TYPE_CONNECTION_REQUEST,
|
|
||||||
source_id=conn_request.id,
|
|
||||||
)
|
|
||||||
|
|
||||||
await log_audit_event(
|
|
||||||
db,
|
|
||||||
action="connection.request_sent",
|
|
||||||
actor_id=current_user.id,
|
|
||||||
target_id=target.id,
|
|
||||||
detail={"receiver_umbral_name": target.umbral_name},
|
|
||||||
ip=get_client_ip(request),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Extract ntfy config before commit (avoids detached SA object in background task)
|
|
||||||
target_ntfy = extract_ntfy_config(target_settings) if target_settings else None
|
|
||||||
|
|
||||||
# Build response BEFORE commit — commit expires all ORM objects, and accessing
|
|
||||||
# their attributes after commit triggers lazy loads → MissingGreenlet in async SA.
|
|
||||||
response = _build_request_response(conn_request, current_user, sender_settings, target, target_settings)
|
|
||||||
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
# ntfy push in background (non-blocking)
|
|
||||||
background_tasks.add_task(
|
|
||||||
send_connection_ntfy,
|
|
||||||
target_ntfy,
|
|
||||||
sender_display,
|
|
||||||
"request_received",
|
|
||||||
)
|
|
||||||
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
# ── GET /requests/incoming ──────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.get("/requests/incoming", response_model=list[ConnectionRequestResponse])
|
|
||||||
async def get_incoming_requests(
|
|
||||||
page: int = Query(1, ge=1),
|
|
||||||
per_page: int = Query(20, ge=1, le=100),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""List pending connection requests received by the current user."""
|
|
||||||
offset = (page - 1) * per_page
|
|
||||||
result = await db.execute(
|
|
||||||
select(ConnectionRequest)
|
|
||||||
.where(
|
|
||||||
ConnectionRequest.receiver_id == current_user.id,
|
|
||||||
ConnectionRequest.status == "pending",
|
|
||||||
)
|
|
||||||
.options(selectinload(ConnectionRequest.sender))
|
|
||||||
.order_by(ConnectionRequest.created_at.desc())
|
|
||||||
.offset(offset)
|
|
||||||
.limit(per_page)
|
|
||||||
)
|
|
||||||
requests = result.scalars().all()
|
|
||||||
|
|
||||||
# Fetch current user's settings once, batch-fetch sender settings
|
|
||||||
receiver_settings = await _get_settings_for_user(db, current_user.id)
|
|
||||||
sender_ids = [req.sender_id for req in requests]
|
|
||||||
if sender_ids:
|
|
||||||
settings_result = await db.execute(select(Settings).where(Settings.user_id.in_(sender_ids)))
|
|
||||||
settings_by_user = {s.user_id: s for s in settings_result.scalars().all()}
|
|
||||||
else:
|
|
||||||
settings_by_user = {}
|
|
||||||
|
|
||||||
responses = []
|
|
||||||
for req in requests:
|
|
||||||
sender_settings = settings_by_user.get(req.sender_id)
|
|
||||||
responses.append(_build_request_response(req, req.sender, sender_settings, current_user, receiver_settings))
|
|
||||||
|
|
||||||
return responses
|
|
||||||
|
|
||||||
|
|
||||||
# ── GET /requests/outgoing ──────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.get("/requests/outgoing", response_model=list[ConnectionRequestResponse])
|
|
||||||
async def get_outgoing_requests(
|
|
||||||
page: int = Query(1, ge=1),
|
|
||||||
per_page: int = Query(20, ge=1, le=100),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""List pending connection requests sent by the current user."""
|
|
||||||
offset = (page - 1) * per_page
|
|
||||||
result = await db.execute(
|
|
||||||
select(ConnectionRequest)
|
|
||||||
.where(
|
|
||||||
ConnectionRequest.sender_id == current_user.id,
|
|
||||||
ConnectionRequest.status == "pending",
|
|
||||||
)
|
|
||||||
.options(selectinload(ConnectionRequest.receiver))
|
|
||||||
.order_by(ConnectionRequest.created_at.desc())
|
|
||||||
.offset(offset)
|
|
||||||
.limit(per_page)
|
|
||||||
)
|
|
||||||
requests = result.scalars().all()
|
|
||||||
|
|
||||||
# Fetch current user's settings once, batch-fetch receiver settings
|
|
||||||
sender_settings = await _get_settings_for_user(db, current_user.id)
|
|
||||||
receiver_ids = [req.receiver_id for req in requests]
|
|
||||||
if receiver_ids:
|
|
||||||
settings_result = await db.execute(select(Settings).where(Settings.user_id.in_(receiver_ids)))
|
|
||||||
settings_by_user = {s.user_id: s for s in settings_result.scalars().all()}
|
|
||||||
else:
|
|
||||||
settings_by_user = {}
|
|
||||||
|
|
||||||
responses = []
|
|
||||||
for req in requests:
|
|
||||||
receiver_settings = settings_by_user.get(req.receiver_id)
|
|
||||||
responses.append(_build_request_response(req, current_user, sender_settings, req.receiver, receiver_settings))
|
|
||||||
|
|
||||||
return responses
|
|
||||||
|
|
||||||
|
|
||||||
# ── PUT /requests/{id}/respond ──────────────────────────────────────
|
|
||||||
|
|
||||||
@router.put("/requests/{request_id}/respond", response_model=RespondAcceptResponse | RespondRejectResponse)
|
|
||||||
async def respond_to_request(
|
|
||||||
body: RespondRequest,
|
|
||||||
request: Request,
|
|
||||||
background_tasks: BackgroundTasks,
|
|
||||||
request_id: int = Path(ge=1, le=2147483647),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""Accept or reject a connection request. Atomic via UPDATE...WHERE status='pending'."""
|
|
||||||
try:
|
|
||||||
return await _respond_to_request_inner(body, request, background_tasks, request_id, db, current_user)
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception:
|
|
||||||
# get_db middleware auto-rollbacks on unhandled exceptions
|
|
||||||
logger.exception("Unhandled error in respond_to_request (request_id=%s, user=%s)", request_id, current_user.id)
|
|
||||||
raise HTTPException(status_code=500, detail=f"Internal server error while processing connection response (request {request_id})")
|
|
||||||
|
|
||||||
|
|
||||||
async def _respond_to_request_inner(
|
|
||||||
body: RespondRequest,
|
|
||||||
request: Request,
|
|
||||||
background_tasks: BackgroundTasks,
|
|
||||||
request_id: int,
|
|
||||||
db: AsyncSession,
|
|
||||||
current_user: User,
|
|
||||||
) -> RespondAcceptResponse | RespondRejectResponse:
|
|
||||||
now = datetime.now()
|
|
||||||
|
|
||||||
# Atomic update — only succeeds if status is still 'pending' and receiver is current user
|
|
||||||
result = await db.execute(
|
|
||||||
update(ConnectionRequest)
|
|
||||||
.where(
|
|
||||||
ConnectionRequest.id == request_id,
|
|
||||||
ConnectionRequest.receiver_id == current_user.id,
|
|
||||||
ConnectionRequest.status == "pending",
|
|
||||||
)
|
|
||||||
.values(status=body.action + "ed", resolved_at=now)
|
|
||||||
.returning(
|
|
||||||
ConnectionRequest.id,
|
|
||||||
ConnectionRequest.sender_id,
|
|
||||||
ConnectionRequest.receiver_id,
|
|
||||||
ConnectionRequest.person_id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
row = result.first()
|
|
||||||
if not row:
|
|
||||||
raise HTTPException(status_code=409, detail="Request not found or already resolved")
|
|
||||||
|
|
||||||
sender_id = row.sender_id
|
|
||||||
request_person_id = row.person_id
|
|
||||||
|
|
||||||
if body.action == "accept":
|
|
||||||
# Verify sender is still active
|
|
||||||
sender_result = await db.execute(select(User).where(User.id == sender_id))
|
|
||||||
sender = sender_result.scalar_one_or_none()
|
|
||||||
if not sender or not sender.is_active:
|
|
||||||
# Revert to rejected
|
|
||||||
await db.execute(
|
|
||||||
update(ConnectionRequest)
|
|
||||||
.where(ConnectionRequest.id == request_id)
|
|
||||||
.values(status="rejected")
|
|
||||||
)
|
|
||||||
await db.commit()
|
|
||||||
raise HTTPException(status_code=409, detail="Sender account is no longer active")
|
|
||||||
|
|
||||||
# Get settings for both users
|
|
||||||
sender_settings = await _get_settings_for_user(db, sender_id)
|
|
||||||
receiver_settings = await _get_settings_for_user(db, current_user.id)
|
|
||||||
|
|
||||||
# Resolve shared profiles for both directions
|
|
||||||
sender_shared = resolve_shared_profile(sender, sender_settings, None) if sender_settings else {}
|
|
||||||
receiver_shared = resolve_shared_profile(current_user, receiver_settings, None) if receiver_settings else {}
|
|
||||||
|
|
||||||
# Create Person records for both users
|
|
||||||
person_for_receiver = create_person_from_connection(
|
|
||||||
current_user.id, sender, sender_settings, sender_shared
|
|
||||||
)
|
|
||||||
db.add(person_for_receiver)
|
|
||||||
|
|
||||||
# Sender side: reuse existing Person if person_id was provided on the request
|
|
||||||
person_for_sender = None
|
|
||||||
if request_person_id:
|
|
||||||
existing_result = await db.execute(
|
|
||||||
select(Person).where(Person.id == request_person_id)
|
|
||||||
)
|
|
||||||
existing_person = existing_result.scalar_one_or_none()
|
|
||||||
# Re-validate at accept time: ownership must match sender,
|
|
||||||
# and must not already be umbral (prevents double-conversion races)
|
|
||||||
if existing_person and existing_person.user_id == sender_id and not existing_person.is_umbral_contact:
|
|
||||||
# Convert existing standard contact to umbral
|
|
||||||
existing_person.linked_user_id = current_user.id
|
|
||||||
existing_person.is_umbral_contact = True
|
|
||||||
existing_person.category = "Umbral"
|
|
||||||
# Update from shared profile
|
|
||||||
first_name = receiver_shared.get("first_name") or receiver_shared.get("preferred_name") or current_user.umbral_name
|
|
||||||
last_name = receiver_shared.get("last_name")
|
|
||||||
existing_person.first_name = first_name
|
|
||||||
existing_person.last_name = last_name
|
|
||||||
existing_person.email = receiver_shared.get("email") or existing_person.email
|
|
||||||
existing_person.phone = receiver_shared.get("phone") or existing_person.phone
|
|
||||||
existing_person.mobile = receiver_shared.get("mobile") or existing_person.mobile
|
|
||||||
existing_person.address = receiver_shared.get("address") or existing_person.address
|
|
||||||
existing_person.company = receiver_shared.get("company") or existing_person.company
|
|
||||||
existing_person.job_title = receiver_shared.get("job_title") or existing_person.job_title
|
|
||||||
# Sync birthday from shared profile
|
|
||||||
birthday_str = receiver_shared.get("birthday")
|
|
||||||
if birthday_str:
|
|
||||||
try:
|
|
||||||
existing_person.birthday = date_type.fromisoformat(birthday_str)
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
# Recompute display name
|
|
||||||
full = ((first_name or '') + ' ' + (last_name or '')).strip()
|
|
||||||
existing_person.name = full or current_user.umbral_name
|
|
||||||
person_for_sender = existing_person
|
|
||||||
|
|
||||||
if person_for_sender is None:
|
|
||||||
person_for_sender = create_person_from_connection(
|
|
||||||
sender_id, current_user, receiver_settings, receiver_shared
|
|
||||||
)
|
|
||||||
db.add(person_for_sender)
|
|
||||||
|
|
||||||
try:
|
|
||||||
await db.flush() # populate person IDs
|
|
||||||
except IntegrityError:
|
|
||||||
await db.rollback()
|
|
||||||
raise HTTPException(status_code=409, detail="Connection already exists")
|
|
||||||
|
|
||||||
# Create bidirectional connections
|
|
||||||
conn_a = UserConnection(
|
|
||||||
user_id=current_user.id,
|
|
||||||
connected_user_id=sender_id,
|
|
||||||
person_id=person_for_receiver.id,
|
|
||||||
)
|
|
||||||
conn_b = UserConnection(
|
|
||||||
user_id=sender_id,
|
|
||||||
connected_user_id=current_user.id,
|
|
||||||
person_id=person_for_sender.id,
|
|
||||||
)
|
|
||||||
db.add(conn_a)
|
|
||||||
db.add(conn_b)
|
|
||||||
|
|
||||||
try:
|
|
||||||
await db.flush() # populate conn_a.id for source_id
|
|
||||||
except IntegrityError:
|
|
||||||
await db.rollback()
|
|
||||||
raise HTTPException(status_code=409, detail="Connection already exists")
|
|
||||||
|
|
||||||
# Notification to sender
|
|
||||||
receiver_display = (receiver_settings.preferred_name if receiver_settings else None) or current_user.umbral_name
|
|
||||||
await create_notification(
|
|
||||||
db,
|
|
||||||
user_id=sender_id,
|
|
||||||
type=NOTIF_TYPE_CONNECTION_ACCEPTED,
|
|
||||||
title="Connection Accepted",
|
|
||||||
message=f"{receiver_display} accepted your connection request",
|
|
||||||
data={"connected_umbral_name": current_user.umbral_name},
|
|
||||||
source_type="user_connection",
|
|
||||||
source_id=conn_b.id,
|
|
||||||
)
|
|
||||||
|
|
||||||
await log_audit_event(
|
|
||||||
db,
|
|
||||||
action="connection.accepted",
|
|
||||||
actor_id=current_user.id,
|
|
||||||
target_id=sender_id,
|
|
||||||
detail={"request_id": request_id},
|
|
||||||
ip=get_client_ip(request),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Extract ntfy config before commit (avoids detached SA object in background task)
|
|
||||||
sender_ntfy = extract_ntfy_config(sender_settings) if sender_settings else None
|
|
||||||
|
|
||||||
try:
|
|
||||||
await db.commit()
|
|
||||||
except IntegrityError:
|
|
||||||
await db.rollback()
|
|
||||||
raise HTTPException(status_code=409, detail="Connection already exists")
|
|
||||||
|
|
||||||
# ntfy push in background
|
|
||||||
background_tasks.add_task(
|
|
||||||
send_connection_ntfy,
|
|
||||||
sender_ntfy,
|
|
||||||
receiver_display,
|
|
||||||
"request_accepted",
|
|
||||||
)
|
|
||||||
|
|
||||||
return {"message": "Connection accepted", "connection_id": conn_a.id}
|
|
||||||
|
|
||||||
else:
|
|
||||||
# Reject — only create notification for receiver (not sender per plan)
|
|
||||||
await log_audit_event(
|
|
||||||
db,
|
|
||||||
action="connection.rejected",
|
|
||||||
actor_id=current_user.id,
|
|
||||||
target_id=sender_id,
|
|
||||||
detail={"request_id": request_id},
|
|
||||||
ip=get_client_ip(request),
|
|
||||||
)
|
|
||||||
await db.commit()
|
|
||||||
return {"message": "Connection request rejected"}
|
|
||||||
|
|
||||||
|
|
||||||
# ── PUT /requests/{id}/cancel ──────────────────────────────────────
|
|
||||||
|
|
||||||
@router.put("/requests/{request_id}/cancel", response_model=CancelResponse)
|
|
||||||
async def cancel_request(
|
|
||||||
request: Request,
|
|
||||||
request_id: int = Path(ge=1, le=2147483647),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""Cancel an outgoing connection request. Atomic via UPDATE...WHERE status='pending'."""
|
|
||||||
now = datetime.now()
|
|
||||||
|
|
||||||
# Atomic update — only succeeds if sender is current user and status is still pending
|
|
||||||
result = await db.execute(
|
|
||||||
update(ConnectionRequest)
|
|
||||||
.where(
|
|
||||||
ConnectionRequest.id == request_id,
|
|
||||||
ConnectionRequest.sender_id == current_user.id,
|
|
||||||
ConnectionRequest.status == "pending",
|
|
||||||
)
|
|
||||||
.values(status="cancelled", resolved_at=now)
|
|
||||||
.returning(ConnectionRequest.id, ConnectionRequest.receiver_id)
|
|
||||||
)
|
|
||||||
row = result.first()
|
|
||||||
if not row:
|
|
||||||
raise HTTPException(status_code=409, detail="Request not found or already resolved")
|
|
||||||
|
|
||||||
receiver_id = row.receiver_id
|
|
||||||
|
|
||||||
# Silent cleanup: remove the notification sent to the receiver
|
|
||||||
await db.execute(
|
|
||||||
delete(Notification).where(
|
|
||||||
Notification.source_type == NOTIF_TYPE_CONNECTION_REQUEST,
|
|
||||||
Notification.source_id == request_id,
|
|
||||||
Notification.user_id == receiver_id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Look up receiver umbral_name for audit detail
|
|
||||||
receiver_result = await db.execute(select(User.umbral_name).where(User.id == receiver_id))
|
|
||||||
receiver_umbral_name = receiver_result.scalar_one_or_none() or "unknown"
|
|
||||||
|
|
||||||
await log_audit_event(
|
|
||||||
db,
|
|
||||||
action="connection.request_cancelled",
|
|
||||||
actor_id=current_user.id,
|
|
||||||
target_id=receiver_id,
|
|
||||||
detail={"request_id": request_id, "receiver_umbral_name": receiver_umbral_name},
|
|
||||||
ip=get_client_ip(request),
|
|
||||||
)
|
|
||||||
|
|
||||||
await db.commit()
|
|
||||||
return {"message": "Connection request cancelled"}
|
|
||||||
|
|
||||||
|
|
||||||
# ── GET / ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.get("/", response_model=list[ConnectionResponse])
|
|
||||||
async def list_connections(
|
|
||||||
page: int = Query(1, ge=1),
|
|
||||||
per_page: int = Query(50, ge=1, le=100),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""List all connections for the current user."""
|
|
||||||
offset = (page - 1) * per_page
|
|
||||||
result = await db.execute(
|
|
||||||
select(UserConnection)
|
|
||||||
.where(UserConnection.user_id == current_user.id)
|
|
||||||
.options(selectinload(UserConnection.connected_user))
|
|
||||||
.order_by(UserConnection.created_at.desc())
|
|
||||||
.offset(offset)
|
|
||||||
.limit(per_page)
|
|
||||||
)
|
|
||||||
connections = result.scalars().all()
|
|
||||||
|
|
||||||
# Batch-fetch settings for connected users
|
|
||||||
connected_ids = [conn.connected_user_id for conn in connections]
|
|
||||||
if connected_ids:
|
|
||||||
settings_result = await db.execute(select(Settings).where(Settings.user_id.in_(connected_ids)))
|
|
||||||
settings_by_user = {s.user_id: s for s in settings_result.scalars().all()}
|
|
||||||
else:
|
|
||||||
settings_by_user = {}
|
|
||||||
|
|
||||||
responses = []
|
|
||||||
for conn in connections:
|
|
||||||
conn_settings = settings_by_user.get(conn.connected_user_id)
|
|
||||||
responses.append(ConnectionResponse(
|
|
||||||
id=conn.id,
|
|
||||||
connected_user_id=conn.connected_user_id,
|
|
||||||
connected_umbral_name=conn.connected_user.umbral_name,
|
|
||||||
connected_preferred_name=conn_settings.preferred_name if conn_settings else None,
|
|
||||||
person_id=conn.person_id,
|
|
||||||
created_at=conn.created_at,
|
|
||||||
))
|
|
||||||
|
|
||||||
return responses
|
|
||||||
|
|
||||||
|
|
||||||
# ── GET /{id} ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.get("/{connection_id}", response_model=ConnectionResponse)
|
|
||||||
async def get_connection(
|
|
||||||
connection_id: int = Path(ge=1, le=2147483647),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""Get a single connection detail."""
|
|
||||||
result = await db.execute(
|
|
||||||
select(UserConnection)
|
|
||||||
.where(
|
|
||||||
UserConnection.id == connection_id,
|
|
||||||
UserConnection.user_id == current_user.id,
|
|
||||||
)
|
|
||||||
.options(selectinload(UserConnection.connected_user))
|
|
||||||
)
|
|
||||||
conn = result.scalar_one_or_none()
|
|
||||||
if not conn:
|
|
||||||
raise HTTPException(status_code=404, detail="Connection not found")
|
|
||||||
|
|
||||||
conn_settings = await _get_settings_for_user(db, conn.connected_user_id)
|
|
||||||
return ConnectionResponse(
|
|
||||||
id=conn.id,
|
|
||||||
connected_user_id=conn.connected_user_id,
|
|
||||||
connected_umbral_name=conn.connected_user.umbral_name,
|
|
||||||
connected_preferred_name=conn_settings.preferred_name if conn_settings else None,
|
|
||||||
person_id=conn.person_id,
|
|
||||||
created_at=conn.created_at,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ── GET /{id}/shared-profile ────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.get("/{connection_id}/shared-profile")
|
|
||||||
async def get_shared_profile(
|
|
||||||
connection_id: int = Path(ge=1, le=2147483647),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""Get the resolved shared profile for a connection."""
|
|
||||||
result = await db.execute(
|
|
||||||
select(UserConnection)
|
|
||||||
.where(
|
|
||||||
UserConnection.id == connection_id,
|
|
||||||
UserConnection.user_id == current_user.id,
|
|
||||||
)
|
|
||||||
.options(selectinload(UserConnection.connected_user))
|
|
||||||
)
|
|
||||||
conn = result.scalar_one_or_none()
|
|
||||||
if not conn:
|
|
||||||
raise HTTPException(status_code=404, detail="Connection not found")
|
|
||||||
|
|
||||||
conn_settings = await _get_settings_for_user(db, conn.connected_user_id)
|
|
||||||
if not conn_settings:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
return resolve_shared_profile(
|
|
||||||
conn.connected_user,
|
|
||||||
conn_settings,
|
|
||||||
conn.sharing_overrides,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ── PUT /{id}/sharing-overrides ─────────────────────────────────────
|
|
||||||
|
|
||||||
@router.put("/{connection_id}/sharing-overrides")
|
|
||||||
async def update_sharing_overrides(
|
|
||||||
body: SharingOverrideUpdate,
|
|
||||||
connection_id: int = Path(ge=1, le=2147483647),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""Update what YOU share with a specific connection."""
|
|
||||||
# Get our connection to know who the counterpart is
|
|
||||||
our_conn = await db.execute(
|
|
||||||
select(UserConnection).where(
|
|
||||||
UserConnection.id == connection_id,
|
|
||||||
UserConnection.user_id == current_user.id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
conn = our_conn.scalar_one_or_none()
|
|
||||||
if not conn:
|
|
||||||
raise HTTPException(status_code=404, detail="Connection not found")
|
|
||||||
|
|
||||||
# Find the reverse connection (their row pointing to us)
|
|
||||||
reverse_result = await db.execute(
|
|
||||||
select(UserConnection).where(
|
|
||||||
UserConnection.user_id == conn.connected_user_id,
|
|
||||||
UserConnection.connected_user_id == current_user.id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
reverse_conn = reverse_result.scalar_one_or_none()
|
|
||||||
if not reverse_conn:
|
|
||||||
raise HTTPException(status_code=404, detail="Reverse connection not found")
|
|
||||||
|
|
||||||
# Merge validated overrides — only SHAREABLE_FIELDS keys
|
|
||||||
existing = dict(reverse_conn.sharing_overrides or {})
|
|
||||||
update_data = body.model_dump(exclude_unset=True)
|
|
||||||
for key, value in update_data.items():
|
|
||||||
if key in SHAREABLE_FIELDS:
|
|
||||||
if value is None:
|
|
||||||
existing.pop(key, None)
|
|
||||||
else:
|
|
||||||
existing[key] = value
|
|
||||||
|
|
||||||
reverse_conn.sharing_overrides = existing if existing else None
|
|
||||||
|
|
||||||
await db.commit()
|
|
||||||
return {"message": "Sharing overrides updated"}
|
|
||||||
|
|
||||||
|
|
||||||
# ── DELETE /{id} ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.delete("/{connection_id}", status_code=204)
|
|
||||||
async def remove_connection(
|
|
||||||
request: Request,
|
|
||||||
connection_id: int = Path(ge=1, le=2147483647),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Remove a connection. Removes BOTH UserConnection rows.
|
|
||||||
Detaches BOTH Person records (sets linked_user_id=null, is_umbral_contact=false).
|
|
||||||
Silent — no notification sent.
|
|
||||||
"""
|
|
||||||
# Get our connection
|
|
||||||
result = await db.execute(
|
|
||||||
select(UserConnection)
|
|
||||||
.where(
|
|
||||||
UserConnection.id == connection_id,
|
|
||||||
UserConnection.user_id == current_user.id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
conn = result.scalar_one_or_none()
|
|
||||||
if not conn:
|
|
||||||
raise HTTPException(status_code=404, detail="Connection not found")
|
|
||||||
|
|
||||||
counterpart_id = conn.connected_user_id
|
|
||||||
|
|
||||||
# Find reverse connection
|
|
||||||
reverse_result = await db.execute(
|
|
||||||
select(UserConnection).where(
|
|
||||||
UserConnection.user_id == counterpart_id,
|
|
||||||
UserConnection.connected_user_id == current_user.id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
reverse_conn = reverse_result.scalar_one_or_none()
|
|
||||||
|
|
||||||
# Detach Person records
|
|
||||||
if conn.person_id:
|
|
||||||
person_result = await db.execute(select(Person).where(Person.id == conn.person_id))
|
|
||||||
person = person_result.scalar_one_or_none()
|
|
||||||
if person:
|
|
||||||
await detach_umbral_contact(person)
|
|
||||||
|
|
||||||
if reverse_conn and reverse_conn.person_id:
|
|
||||||
person_result = await db.execute(select(Person).where(Person.id == reverse_conn.person_id))
|
|
||||||
person = person_result.scalar_one_or_none()
|
|
||||||
if person:
|
|
||||||
await detach_umbral_contact(person)
|
|
||||||
|
|
||||||
# Delete both connections
|
|
||||||
await db.delete(conn)
|
|
||||||
if reverse_conn:
|
|
||||||
await db.delete(reverse_conn)
|
|
||||||
|
|
||||||
await log_audit_event(
|
|
||||||
db,
|
|
||||||
action="connection.removed",
|
|
||||||
actor_id=current_user.id,
|
|
||||||
target_id=counterpart_id,
|
|
||||||
detail={"connection_id": connection_id},
|
|
||||||
ip=get_client_ip(request),
|
|
||||||
)
|
|
||||||
|
|
||||||
await db.commit()
|
|
||||||
return None
|
|
||||||
@ -1,143 +0,0 @@
|
|||||||
"""
|
|
||||||
Notification centre router — in-app notifications.
|
|
||||||
|
|
||||||
All endpoints scoped by current_user.id to prevent IDOR.
|
|
||||||
"""
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query
|
|
||||||
from sqlalchemy import select, func, update, delete, and_
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.database import get_db
|
|
||||||
from app.models.notification import Notification
|
|
||||||
from app.models.user import User
|
|
||||||
from app.routers.auth import get_current_user
|
|
||||||
from app.schemas.notification import (
|
|
||||||
NotificationResponse,
|
|
||||||
NotificationListResponse,
|
|
||||||
MarkReadRequest,
|
|
||||||
)
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_model=NotificationListResponse)
|
|
||||||
async def list_notifications(
|
|
||||||
unread_only: bool = Query(False),
|
|
||||||
notification_type: str | None = Query(None, max_length=50, alias="type"),
|
|
||||||
page: int = Query(1, ge=1),
|
|
||||||
per_page: int = Query(20, ge=1, le=100),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""Paginated notification list with optional filters."""
|
|
||||||
base = select(Notification).where(Notification.user_id == current_user.id)
|
|
||||||
|
|
||||||
if unread_only:
|
|
||||||
base = base.where(Notification.is_read == False) # noqa: E712
|
|
||||||
if notification_type:
|
|
||||||
base = base.where(Notification.type == notification_type)
|
|
||||||
|
|
||||||
# Total count
|
|
||||||
count_q = select(func.count()).select_from(base.subquery())
|
|
||||||
total = await db.scalar(count_q) or 0
|
|
||||||
|
|
||||||
# Unread count (always full, regardless of filters)
|
|
||||||
unread_count = await db.scalar(
|
|
||||||
select(func.count())
|
|
||||||
.select_from(Notification)
|
|
||||||
.where(
|
|
||||||
Notification.user_id == current_user.id,
|
|
||||||
Notification.is_read == False, # noqa: E712
|
|
||||||
)
|
|
||||||
) or 0
|
|
||||||
|
|
||||||
# Paginated results
|
|
||||||
offset = (page - 1) * per_page
|
|
||||||
result = await db.execute(
|
|
||||||
base.order_by(Notification.created_at.desc()).offset(offset).limit(per_page)
|
|
||||||
)
|
|
||||||
notifications = result.scalars().all()
|
|
||||||
|
|
||||||
return NotificationListResponse(
|
|
||||||
notifications=notifications,
|
|
||||||
unread_count=unread_count,
|
|
||||||
total=total,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/unread-count")
|
|
||||||
async def get_unread_count(
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""Lightweight unread count endpoint (uses partial index)."""
|
|
||||||
count = await db.scalar(
|
|
||||||
select(func.count())
|
|
||||||
.select_from(Notification)
|
|
||||||
.where(
|
|
||||||
Notification.user_id == current_user.id,
|
|
||||||
Notification.is_read == False, # noqa: E712
|
|
||||||
)
|
|
||||||
) or 0
|
|
||||||
return {"count": count}
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/read")
|
|
||||||
async def mark_read(
|
|
||||||
body: MarkReadRequest,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""Mark specific notification IDs as read (user_id scoped — IDOR prevention)."""
|
|
||||||
await db.execute(
|
|
||||||
update(Notification)
|
|
||||||
.where(
|
|
||||||
and_(
|
|
||||||
Notification.id.in_(body.notification_ids),
|
|
||||||
Notification.user_id == current_user.id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.values(is_read=True)
|
|
||||||
)
|
|
||||||
await db.commit()
|
|
||||||
return {"message": "Notifications marked as read"}
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/read-all")
|
|
||||||
async def mark_all_read(
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""Mark all notifications as read for current user."""
|
|
||||||
await db.execute(
|
|
||||||
update(Notification)
|
|
||||||
.where(
|
|
||||||
Notification.user_id == current_user.id,
|
|
||||||
Notification.is_read == False, # noqa: E712
|
|
||||||
)
|
|
||||||
.values(is_read=True)
|
|
||||||
)
|
|
||||||
await db.commit()
|
|
||||||
return {"message": "All notifications marked as read"}
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{notification_id}", status_code=204)
|
|
||||||
async def delete_notification(
|
|
||||||
notification_id: int = Path(ge=1, le=2147483647),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""Delete a single notification (user_id scoped)."""
|
|
||||||
result = await db.execute(
|
|
||||||
select(Notification).where(
|
|
||||||
Notification.id == notification_id,
|
|
||||||
Notification.user_id == current_user.id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
notification = result.scalar_one_or_none()
|
|
||||||
if not notification:
|
|
||||||
raise HTTPException(status_code=404, detail="Notification not found")
|
|
||||||
|
|
||||||
await db.delete(notification)
|
|
||||||
await db.commit()
|
|
||||||
return None
|
|
||||||
@ -1,18 +1,14 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query
|
from fastapi import APIRouter, Depends, HTTPException, Path, Query
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, or_
|
from sqlalchemy import select, or_
|
||||||
from sqlalchemy.orm import selectinload
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models.person import Person
|
from app.models.person import Person
|
||||||
from app.models.settings import Settings
|
|
||||||
from app.models.user import User
|
|
||||||
from app.models.user_connection import UserConnection
|
|
||||||
from app.schemas.person import PersonCreate, PersonUpdate, PersonResponse
|
from app.schemas.person import PersonCreate, PersonUpdate, PersonResponse
|
||||||
from app.routers.auth import get_current_user
|
from app.routers.auth import get_current_user
|
||||||
from app.services.connection import detach_umbral_contact, resolve_shared_profile
|
from app.models.user import User
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@ -63,62 +59,6 @@ async def get_people(
|
|||||||
result = await db.execute(query)
|
result = await db.execute(query)
|
||||||
people = result.scalars().all()
|
people = result.scalars().all()
|
||||||
|
|
||||||
# Batch-load shared profiles for umbral contacts
|
|
||||||
umbral_people = [p for p in people if p.linked_user_id is not None]
|
|
||||||
if umbral_people:
|
|
||||||
linked_user_ids = [p.linked_user_id for p in umbral_people]
|
|
||||||
|
|
||||||
# Batch fetch users and settings
|
|
||||||
users_result = await db.execute(
|
|
||||||
select(User).where(User.id.in_(linked_user_ids))
|
|
||||||
)
|
|
||||||
users_by_id = {u.id: u for u in users_result.scalars().all()}
|
|
||||||
|
|
||||||
settings_result = await db.execute(
|
|
||||||
select(Settings).where(Settings.user_id.in_(linked_user_ids))
|
|
||||||
)
|
|
||||||
settings_by_user = {s.user_id: s for s in settings_result.scalars().all()}
|
|
||||||
|
|
||||||
# Batch fetch connection overrides
|
|
||||||
conns_result = await db.execute(
|
|
||||||
select(UserConnection).where(
|
|
||||||
UserConnection.user_id == current_user.id,
|
|
||||||
UserConnection.connected_user_id.in_(linked_user_ids),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
overrides_by_user = {
|
|
||||||
c.connected_user_id: c.sharing_overrides
|
|
||||||
for c in conns_result.scalars().all()
|
|
||||||
}
|
|
||||||
|
|
||||||
# Build shared profiles and track remote timestamps separately
|
|
||||||
shared_profiles: dict[int, dict] = {}
|
|
||||||
remote_timestamps: dict[int, datetime] = {}
|
|
||||||
for uid in linked_user_ids:
|
|
||||||
user = users_by_id.get(uid)
|
|
||||||
user_settings = settings_by_user.get(uid)
|
|
||||||
if user and user_settings:
|
|
||||||
shared_profiles[uid] = resolve_shared_profile(
|
|
||||||
user, user_settings, overrides_by_user.get(uid)
|
|
||||||
)
|
|
||||||
# umbral_name is always visible (public identity), not a shareable field
|
|
||||||
shared_profiles[uid]["umbral_name"] = user.umbral_name
|
|
||||||
if user.updated_at and user_settings.updated_at:
|
|
||||||
remote_timestamps[uid] = max(user.updated_at, user_settings.updated_at)
|
|
||||||
|
|
||||||
# Attach to response
|
|
||||||
responses = []
|
|
||||||
for p in people:
|
|
||||||
resp = PersonResponse.model_validate(p)
|
|
||||||
if p.linked_user_id and p.linked_user_id in shared_profiles:
|
|
||||||
resp.shared_fields = shared_profiles[p.linked_user_id]
|
|
||||||
# Show the latest update time across local record and connected user's profile
|
|
||||||
remote_updated = remote_timestamps.get(p.linked_user_id)
|
|
||||||
if remote_updated and remote_updated > p.updated_at:
|
|
||||||
resp.updated_at = remote_updated
|
|
||||||
responses.append(resp)
|
|
||||||
return responses
|
|
||||||
|
|
||||||
return people
|
return people
|
||||||
|
|
||||||
|
|
||||||
@ -164,34 +104,7 @@ async def get_person(
|
|||||||
if not person:
|
if not person:
|
||||||
raise HTTPException(status_code=404, detail="Person not found")
|
raise HTTPException(status_code=404, detail="Person not found")
|
||||||
|
|
||||||
resp = PersonResponse.model_validate(person)
|
return person
|
||||||
if person.linked_user_id:
|
|
||||||
linked_user_result = await db.execute(
|
|
||||||
select(User).where(User.id == person.linked_user_id)
|
|
||||||
)
|
|
||||||
linked_user = linked_user_result.scalar_one_or_none()
|
|
||||||
linked_settings_result = await db.execute(
|
|
||||||
select(Settings).where(Settings.user_id == person.linked_user_id)
|
|
||||||
)
|
|
||||||
linked_settings = linked_settings_result.scalar_one_or_none()
|
|
||||||
conn_result = await db.execute(
|
|
||||||
select(UserConnection).where(
|
|
||||||
UserConnection.user_id == current_user.id,
|
|
||||||
UserConnection.connected_user_id == person.linked_user_id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
conn = conn_result.scalar_one_or_none()
|
|
||||||
if linked_user and linked_settings:
|
|
||||||
resp.shared_fields = resolve_shared_profile(
|
|
||||||
linked_user, linked_settings, conn.sharing_overrides if conn else None
|
|
||||||
)
|
|
||||||
resp.shared_fields["umbral_name"] = linked_user.umbral_name
|
|
||||||
# Show the latest update time across local record and connected user's profile
|
|
||||||
if linked_user.updated_at and linked_settings.updated_at:
|
|
||||||
remote_updated = max(linked_user.updated_at, linked_settings.updated_at)
|
|
||||||
if remote_updated > person.updated_at:
|
|
||||||
resp.updated_at = remote_updated
|
|
||||||
return resp
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{person_id}", response_model=PersonResponse)
|
@router.put("/{person_id}", response_model=PersonResponse)
|
||||||
@ -231,79 +144,13 @@ async def update_person(
|
|||||||
return person
|
return person
|
||||||
|
|
||||||
|
|
||||||
async def _sever_connection(db: AsyncSession, current_user: User, person: Person) -> None:
|
|
||||||
"""Remove bidirectional UserConnection rows and detach the counterpart's Person."""
|
|
||||||
if not person.linked_user_id:
|
|
||||||
return
|
|
||||||
|
|
||||||
counterpart_id = person.linked_user_id
|
|
||||||
|
|
||||||
# Find our connection
|
|
||||||
conn_result = await db.execute(
|
|
||||||
select(UserConnection).where(
|
|
||||||
UserConnection.user_id == current_user.id,
|
|
||||||
UserConnection.connected_user_id == counterpart_id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
our_conn = conn_result.scalar_one_or_none()
|
|
||||||
|
|
||||||
# Find reverse connection
|
|
||||||
reverse_result = await db.execute(
|
|
||||||
select(UserConnection).where(
|
|
||||||
UserConnection.user_id == counterpart_id,
|
|
||||||
UserConnection.connected_user_id == current_user.id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
reverse_conn = reverse_result.scalar_one_or_none()
|
|
||||||
|
|
||||||
# Detach the counterpart's Person record (if it exists)
|
|
||||||
if reverse_conn and reverse_conn.person_id:
|
|
||||||
cp_result = await db.execute(
|
|
||||||
select(Person).where(Person.id == reverse_conn.person_id)
|
|
||||||
)
|
|
||||||
cp_person = cp_result.scalar_one_or_none()
|
|
||||||
if cp_person:
|
|
||||||
await detach_umbral_contact(cp_person)
|
|
||||||
|
|
||||||
# Delete both connection rows
|
|
||||||
if our_conn:
|
|
||||||
await db.delete(our_conn)
|
|
||||||
if reverse_conn:
|
|
||||||
await db.delete(reverse_conn)
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{person_id}/unlink", response_model=PersonResponse)
|
|
||||||
async def unlink_person(
|
|
||||||
person_id: int = Path(ge=1, le=2147483647),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
current_user: User = Depends(get_current_user)
|
|
||||||
):
|
|
||||||
"""Unlink an umbral contact — convert to standard contact and sever the connection."""
|
|
||||||
result = await db.execute(
|
|
||||||
select(Person).where(Person.id == person_id, Person.user_id == current_user.id)
|
|
||||||
)
|
|
||||||
person = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if not person:
|
|
||||||
raise HTTPException(status_code=404, detail="Person not found")
|
|
||||||
if not person.is_umbral_contact:
|
|
||||||
raise HTTPException(status_code=400, detail="Person is not an umbral contact")
|
|
||||||
|
|
||||||
await _sever_connection(db, current_user, person)
|
|
||||||
await detach_umbral_contact(person)
|
|
||||||
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(person)
|
|
||||||
return person
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{person_id}", status_code=204)
|
@router.delete("/{person_id}", status_code=204)
|
||||||
async def delete_person(
|
async def delete_person(
|
||||||
person_id: int = Path(ge=1, le=2147483647),
|
person_id: int = Path(ge=1, le=2147483647),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user)
|
current_user: User = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""Delete a person. If umbral contact, also severs the bidirectional connection."""
|
"""Delete a person."""
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Person).where(Person.id == person_id, Person.user_id == current_user.id)
|
select(Person).where(Person.id == person_id, Person.user_id == current_user.id)
|
||||||
)
|
)
|
||||||
@ -312,9 +159,6 @@ async def delete_person(
|
|||||||
if not person:
|
if not person:
|
||||||
raise HTTPException(status_code=404, detail="Person not found")
|
raise HTTPException(status_code=404, detail="Person not found")
|
||||||
|
|
||||||
if person.is_umbral_contact:
|
|
||||||
await _sever_connection(db, current_user, person)
|
|
||||||
|
|
||||||
await db.delete(person)
|
await db.delete(person)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
|||||||
@ -39,27 +39,6 @@ def _to_settings_response(s: Settings) -> SettingsResponse:
|
|||||||
ntfy_has_token=bool(s.ntfy_auth_token), # derived — never expose the token value
|
ntfy_has_token=bool(s.ntfy_auth_token), # derived — never expose the token value
|
||||||
auto_lock_enabled=s.auto_lock_enabled,
|
auto_lock_enabled=s.auto_lock_enabled,
|
||||||
auto_lock_minutes=s.auto_lock_minutes,
|
auto_lock_minutes=s.auto_lock_minutes,
|
||||||
# Profile fields
|
|
||||||
phone=s.phone,
|
|
||||||
mobile=s.mobile,
|
|
||||||
address=s.address,
|
|
||||||
company=s.company,
|
|
||||||
job_title=s.job_title,
|
|
||||||
# Social settings
|
|
||||||
accept_connections=s.accept_connections,
|
|
||||||
# Sharing defaults
|
|
||||||
share_first_name=s.share_first_name,
|
|
||||||
share_last_name=s.share_last_name,
|
|
||||||
share_preferred_name=s.share_preferred_name,
|
|
||||||
share_email=s.share_email,
|
|
||||||
share_phone=s.share_phone,
|
|
||||||
share_mobile=s.share_mobile,
|
|
||||||
share_birthday=s.share_birthday,
|
|
||||||
share_address=s.share_address,
|
|
||||||
share_company=s.share_company,
|
|
||||||
share_job_title=s.share_job_title,
|
|
||||||
# ntfy connections toggle
|
|
||||||
ntfy_connections_enabled=s.ntfy_connections_enabled,
|
|
||||||
created_at=s.created_at,
|
created_at=s.created_at,
|
||||||
updated_at=s.updated_at,
|
updated_at=s.updated_at,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -20,7 +20,6 @@ from app.schemas.auth import _validate_username, _validate_password_strength, _v
|
|||||||
class UserListItem(BaseModel):
|
class UserListItem(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
username: str
|
username: str
|
||||||
umbral_name: str = ""
|
|
||||||
email: Optional[str] = None
|
email: Optional[str] = None
|
||||||
first_name: Optional[str] = None
|
first_name: Optional[str] = None
|
||||||
last_name: Optional[str] = None
|
last_name: Optional[str] = None
|
||||||
|
|||||||
@ -172,19 +172,6 @@ class ProfileUpdate(BaseModel):
|
|||||||
last_name: str | None = Field(None, max_length=100)
|
last_name: str | None = Field(None, max_length=100)
|
||||||
email: str | None = Field(None, max_length=254)
|
email: str | None = Field(None, max_length=254)
|
||||||
date_of_birth: date | None = None
|
date_of_birth: date | None = None
|
||||||
umbral_name: str | None = Field(None, min_length=3, max_length=50)
|
|
||||||
|
|
||||||
@field_validator("umbral_name")
|
|
||||||
@classmethod
|
|
||||||
def validate_umbral_name(cls, v: str | None) -> str | None:
|
|
||||||
if v is None:
|
|
||||||
return v
|
|
||||||
import re
|
|
||||||
if ' ' in v:
|
|
||||||
raise ValueError('Umbral name must be a single word with no spaces')
|
|
||||||
if not re.match(r'^[a-zA-Z0-9_.-]{3,50}$', v):
|
|
||||||
raise ValueError('Umbral name must be 3-50 alphanumeric characters, dots, hyphens, or underscores')
|
|
||||||
return v
|
|
||||||
|
|
||||||
@field_validator("email")
|
@field_validator("email")
|
||||||
@classmethod
|
@classmethod
|
||||||
@ -212,7 +199,6 @@ class ProfileResponse(BaseModel):
|
|||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
username: str
|
username: str
|
||||||
umbral_name: str
|
|
||||||
email: str | None
|
email: str | None
|
||||||
first_name: str | None
|
first_name: str | None
|
||||||
last_name: str | None
|
last_name: str | None
|
||||||
|
|||||||
@ -1,91 +0,0 @@
|
|||||||
"""
|
|
||||||
Connection schemas — search, request, respond, connection management.
|
|
||||||
All input schemas use extra="forbid" to prevent mass-assignment.
|
|
||||||
"""
|
|
||||||
import re
|
|
||||||
from typing import Literal, Optional
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
||||||
|
|
||||||
_UMBRAL_NAME_RE = re.compile(r'^[a-zA-Z0-9_.-]{3,50}$')
|
|
||||||
|
|
||||||
|
|
||||||
class UmbralSearchRequest(BaseModel):
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
|
||||||
umbral_name: str = Field(..., max_length=50)
|
|
||||||
|
|
||||||
@field_validator('umbral_name')
|
|
||||||
@classmethod
|
|
||||||
def validate_umbral_name(cls, v: str) -> str:
|
|
||||||
if not _UMBRAL_NAME_RE.match(v):
|
|
||||||
raise ValueError('Umbral name must be 3-50 alphanumeric characters, dots, hyphens, or underscores')
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
class UmbralSearchResponse(BaseModel):
|
|
||||||
found: bool
|
|
||||||
|
|
||||||
|
|
||||||
class SendConnectionRequest(BaseModel):
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
|
||||||
umbral_name: str = Field(..., max_length=50)
|
|
||||||
person_id: Optional[int] = Field(default=None, ge=1, le=2147483647)
|
|
||||||
|
|
||||||
@field_validator('umbral_name')
|
|
||||||
@classmethod
|
|
||||||
def validate_umbral_name(cls, v: str) -> str:
|
|
||||||
if not _UMBRAL_NAME_RE.match(v):
|
|
||||||
raise ValueError('Umbral name must be 3-50 alphanumeric characters, dots, hyphens, or underscores')
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
class ConnectionRequestResponse(BaseModel):
|
|
||||||
id: int
|
|
||||||
sender_umbral_name: str
|
|
||||||
sender_preferred_name: Optional[str] = None
|
|
||||||
receiver_umbral_name: str
|
|
||||||
receiver_preferred_name: Optional[str] = None
|
|
||||||
status: Literal["pending", "accepted", "rejected", "cancelled"]
|
|
||||||
created_at: datetime
|
|
||||||
|
|
||||||
|
|
||||||
class RespondRequest(BaseModel):
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
|
||||||
action: Literal["accept", "reject"]
|
|
||||||
|
|
||||||
|
|
||||||
class ConnectionResponse(BaseModel):
|
|
||||||
id: int
|
|
||||||
connected_user_id: int
|
|
||||||
connected_umbral_name: str
|
|
||||||
connected_preferred_name: Optional[str] = None
|
|
||||||
person_id: Optional[int] = None
|
|
||||||
created_at: datetime
|
|
||||||
|
|
||||||
|
|
||||||
class RespondAcceptResponse(BaseModel):
|
|
||||||
message: str
|
|
||||||
connection_id: int
|
|
||||||
|
|
||||||
|
|
||||||
class RespondRejectResponse(BaseModel):
|
|
||||||
message: str
|
|
||||||
|
|
||||||
|
|
||||||
class CancelResponse(BaseModel):
|
|
||||||
message: str
|
|
||||||
|
|
||||||
|
|
||||||
class SharingOverrideUpdate(BaseModel):
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
|
||||||
first_name: Optional[bool] = None
|
|
||||||
last_name: Optional[bool] = None
|
|
||||||
preferred_name: Optional[bool] = None
|
|
||||||
email: Optional[bool] = None
|
|
||||||
phone: Optional[bool] = None
|
|
||||||
mobile: Optional[bool] = None
|
|
||||||
birthday: Optional[bool] = None
|
|
||||||
address: Optional[bool] = None
|
|
||||||
company: Optional[bool] = None
|
|
||||||
job_title: Optional[bool] = None
|
|
||||||
@ -1,38 +0,0 @@
|
|||||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
|
|
||||||
class NotificationResponse(BaseModel):
|
|
||||||
id: int
|
|
||||||
user_id: int
|
|
||||||
type: str
|
|
||||||
title: Optional[str] = None
|
|
||||||
message: Optional[str] = None
|
|
||||||
data: Optional[dict] = None
|
|
||||||
source_type: Optional[str] = None
|
|
||||||
source_id: Optional[int] = None
|
|
||||||
is_read: bool
|
|
||||||
created_at: datetime
|
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
|
|
||||||
|
|
||||||
class NotificationListResponse(BaseModel):
|
|
||||||
notifications: list[NotificationResponse]
|
|
||||||
unread_count: int
|
|
||||||
total: int
|
|
||||||
|
|
||||||
|
|
||||||
class MarkReadRequest(BaseModel):
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
|
||||||
|
|
||||||
notification_ids: list[int] = Field(..., min_length=1, max_length=100, json_schema_extra={"items": {"minimum": 1, "maximum": 2147483647}})
|
|
||||||
|
|
||||||
@field_validator('notification_ids')
|
|
||||||
@classmethod
|
|
||||||
def validate_ids(cls, v: list[int]) -> list[int]:
|
|
||||||
for i in v:
|
|
||||||
if i < 1 or i > 2147483647:
|
|
||||||
raise ValueError('Each notification ID must be between 1 and 2147483647')
|
|
||||||
return v
|
|
||||||
@ -85,9 +85,6 @@ class PersonResponse(BaseModel):
|
|||||||
company: Optional[str]
|
company: Optional[str]
|
||||||
job_title: Optional[str]
|
job_title: Optional[str]
|
||||||
notes: Optional[str]
|
notes: Optional[str]
|
||||||
linked_user_id: Optional[int] = None
|
|
||||||
is_umbral_contact: bool = False
|
|
||||||
shared_fields: Optional[dict] = None
|
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|||||||
@ -37,31 +37,6 @@ class SettingsUpdate(BaseModel):
|
|||||||
auto_lock_enabled: Optional[bool] = None
|
auto_lock_enabled: Optional[bool] = None
|
||||||
auto_lock_minutes: Optional[int] = None
|
auto_lock_minutes: Optional[int] = None
|
||||||
|
|
||||||
# Profile fields (shareable with connections)
|
|
||||||
phone: Optional[str] = Field(None, max_length=50)
|
|
||||||
mobile: Optional[str] = Field(None, max_length=50)
|
|
||||||
address: Optional[str] = Field(None, max_length=2000)
|
|
||||||
company: Optional[str] = Field(None, max_length=255)
|
|
||||||
job_title: Optional[str] = Field(None, max_length=255)
|
|
||||||
|
|
||||||
# Social settings
|
|
||||||
accept_connections: Optional[bool] = None
|
|
||||||
|
|
||||||
# Sharing defaults
|
|
||||||
share_first_name: Optional[bool] = None
|
|
||||||
share_last_name: Optional[bool] = None
|
|
||||||
share_preferred_name: Optional[bool] = None
|
|
||||||
share_email: Optional[bool] = None
|
|
||||||
share_phone: Optional[bool] = None
|
|
||||||
share_mobile: Optional[bool] = None
|
|
||||||
share_birthday: Optional[bool] = None
|
|
||||||
share_address: Optional[bool] = None
|
|
||||||
share_company: Optional[bool] = None
|
|
||||||
share_job_title: Optional[bool] = None
|
|
||||||
|
|
||||||
# ntfy connections toggle
|
|
||||||
ntfy_connections_enabled: Optional[bool] = None
|
|
||||||
|
|
||||||
@field_validator('auto_lock_minutes')
|
@field_validator('auto_lock_minutes')
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_auto_lock_minutes(cls, v: Optional[int]) -> Optional[int]:
|
def validate_auto_lock_minutes(cls, v: Optional[int]) -> Optional[int]:
|
||||||
@ -176,31 +151,6 @@ class SettingsResponse(BaseModel):
|
|||||||
auto_lock_enabled: bool = False
|
auto_lock_enabled: bool = False
|
||||||
auto_lock_minutes: int = 5
|
auto_lock_minutes: int = 5
|
||||||
|
|
||||||
# Profile fields
|
|
||||||
phone: Optional[str] = None
|
|
||||||
mobile: Optional[str] = None
|
|
||||||
address: Optional[str] = None
|
|
||||||
company: Optional[str] = None
|
|
||||||
job_title: Optional[str] = None
|
|
||||||
|
|
||||||
# Social settings
|
|
||||||
accept_connections: bool = False
|
|
||||||
|
|
||||||
# Sharing defaults
|
|
||||||
share_first_name: bool = False
|
|
||||||
share_last_name: bool = False
|
|
||||||
share_preferred_name: bool = True
|
|
||||||
share_email: bool = False
|
|
||||||
share_phone: bool = False
|
|
||||||
share_mobile: bool = False
|
|
||||||
share_birthday: bool = False
|
|
||||||
share_address: bool = False
|
|
||||||
share_company: bool = False
|
|
||||||
share_job_title: bool = False
|
|
||||||
|
|
||||||
# ntfy connections toggle
|
|
||||||
ntfy_connections_enabled: bool = True
|
|
||||||
|
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|||||||
@ -1,208 +0,0 @@
|
|||||||
"""
|
|
||||||
Connection service — shared profile resolution, Person creation, ntfy dispatch.
|
|
||||||
|
|
||||||
SHAREABLE_FIELDS is the single source of truth for which fields can be shared.
|
|
||||||
"""
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
from datetime import date as date_type
|
|
||||||
from types import SimpleNamespace
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.models.person import Person
|
|
||||||
from app.models.settings import Settings
|
|
||||||
from app.models.user import User
|
|
||||||
from app.services.ntfy import send_ntfy_notification
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Notification type constants — keep in sync with notifications model CHECK constraint
|
|
||||||
NOTIF_TYPE_CONNECTION_REQUEST = "connection_request"
|
|
||||||
NOTIF_TYPE_CONNECTION_ACCEPTED = "connection_accepted"
|
|
||||||
|
|
||||||
# Single source of truth — only these fields can be shared via connections
|
|
||||||
SHAREABLE_FIELDS = frozenset({
|
|
||||||
"first_name", "last_name", "preferred_name", "email", "phone", "mobile",
|
|
||||||
"birthday", "address", "company", "job_title",
|
|
||||||
})
|
|
||||||
|
|
||||||
# Maps shareable field names to their Settings model column names
|
|
||||||
_SETTINGS_FIELD_MAP = {
|
|
||||||
"first_name": None, # first_name comes from User model
|
|
||||||
"last_name": None, # last_name comes from User model
|
|
||||||
"preferred_name": "preferred_name",
|
|
||||||
"email": None, # email comes from User model
|
|
||||||
"phone": "phone",
|
|
||||||
"mobile": "mobile",
|
|
||||||
"birthday": None, # birthday comes from User model (date_of_birth)
|
|
||||||
"address": "address",
|
|
||||||
"company": "company",
|
|
||||||
"job_title": "job_title",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_shared_profile(
|
|
||||||
user: User,
|
|
||||||
settings: Settings,
|
|
||||||
overrides: Optional[dict] = None,
|
|
||||||
) -> dict:
|
|
||||||
"""
|
|
||||||
Merge global sharing defaults with per-connection overrides.
|
|
||||||
Returns {field: value} dict of fields the user is sharing.
|
|
||||||
Only fields in SHAREABLE_FIELDS are included.
|
|
||||||
"""
|
|
||||||
overrides = overrides or {}
|
|
||||||
result = {}
|
|
||||||
|
|
||||||
for field in SHAREABLE_FIELDS:
|
|
||||||
# Determine if this field is shared: override wins, else global default
|
|
||||||
share_key = f"share_{field}"
|
|
||||||
global_share = getattr(settings, share_key, False)
|
|
||||||
is_shared = overrides.get(field, global_share)
|
|
||||||
|
|
||||||
if not is_shared:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Resolve the actual value
|
|
||||||
if field == "first_name":
|
|
||||||
result[field] = user.first_name
|
|
||||||
elif field == "last_name":
|
|
||||||
result[field] = user.last_name
|
|
||||||
elif field == "preferred_name":
|
|
||||||
result[field] = settings.preferred_name
|
|
||||||
elif field == "email":
|
|
||||||
result[field] = user.email
|
|
||||||
elif field == "birthday":
|
|
||||||
result[field] = str(user.date_of_birth) if user.date_of_birth else None
|
|
||||||
elif field in _SETTINGS_FIELD_MAP and _SETTINGS_FIELD_MAP[field]:
|
|
||||||
result[field] = getattr(settings, _SETTINGS_FIELD_MAP[field], None)
|
|
||||||
|
|
||||||
return filter_to_shareable(result)
|
|
||||||
|
|
||||||
|
|
||||||
def filter_to_shareable(profile: dict) -> dict:
|
|
||||||
"""Strip any keys not in SHAREABLE_FIELDS. Defence-in-depth gate."""
|
|
||||||
return {k: v for k, v in profile.items() if k in SHAREABLE_FIELDS}
|
|
||||||
|
|
||||||
|
|
||||||
def create_person_from_connection(
|
|
||||||
owner_user_id: int,
|
|
||||||
connected_user: User,
|
|
||||||
connected_settings: Settings,
|
|
||||||
shared_profile: dict,
|
|
||||||
) -> Person:
|
|
||||||
"""Create a Person record for a new connection. Does NOT add to session — caller does."""
|
|
||||||
# Use shared first_name, fall back to preferred_name, then umbral_name
|
|
||||||
first_name = shared_profile.get("first_name") or shared_profile.get("preferred_name") or connected_user.umbral_name
|
|
||||||
last_name = shared_profile.get("last_name")
|
|
||||||
email = shared_profile.get("email")
|
|
||||||
phone = shared_profile.get("phone")
|
|
||||||
mobile = shared_profile.get("mobile")
|
|
||||||
address = shared_profile.get("address")
|
|
||||||
company = shared_profile.get("company")
|
|
||||||
job_title = shared_profile.get("job_title")
|
|
||||||
birthday_str = shared_profile.get("birthday")
|
|
||||||
|
|
||||||
birthday = None
|
|
||||||
if birthday_str:
|
|
||||||
try:
|
|
||||||
birthday = date_type.fromisoformat(birthday_str)
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Compute display name
|
|
||||||
full = ((first_name or '') + ' ' + (last_name or '')).strip()
|
|
||||||
display_name = full or connected_user.umbral_name
|
|
||||||
|
|
||||||
return Person(
|
|
||||||
user_id=owner_user_id,
|
|
||||||
name=display_name,
|
|
||||||
first_name=first_name,
|
|
||||||
last_name=last_name,
|
|
||||||
email=email,
|
|
||||||
phone=phone,
|
|
||||||
mobile=mobile,
|
|
||||||
address=address,
|
|
||||||
company=company,
|
|
||||||
job_title=job_title,
|
|
||||||
birthday=birthday,
|
|
||||||
category="Umbral",
|
|
||||||
linked_user_id=connected_user.id,
|
|
||||||
is_umbral_contact=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def detach_umbral_contact(person: Person) -> None:
|
|
||||||
"""Convert an umbral contact back to a standard contact. Does NOT commit.
|
|
||||||
|
|
||||||
Preserves all person data (name, email, phone, etc.) so the user does not
|
|
||||||
lose contact information when a connection is severed. Only unlinks the
|
|
||||||
umbral association — the person becomes a standard contact.
|
|
||||||
"""
|
|
||||||
person.linked_user_id = None
|
|
||||||
person.is_umbral_contact = False
|
|
||||||
person.category = None
|
|
||||||
|
|
||||||
def extract_ntfy_config(settings: Settings) -> dict | None:
|
|
||||||
"""Extract ntfy config values into a plain dict safe for use after session close."""
|
|
||||||
if not settings.ntfy_enabled or not settings.ntfy_connections_enabled:
|
|
||||||
return None
|
|
||||||
return {
|
|
||||||
"ntfy_enabled": True,
|
|
||||||
"ntfy_server_url": settings.ntfy_server_url,
|
|
||||||
"ntfy_topic": settings.ntfy_topic,
|
|
||||||
"ntfy_auth_token": settings.ntfy_auth_token,
|
|
||||||
"user_id": settings.user_id,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def send_connection_ntfy(
|
|
||||||
ntfy_config: dict | None,
|
|
||||||
sender_name: str,
|
|
||||||
event_type: str,
|
|
||||||
) -> None:
|
|
||||||
"""Send ntfy push for connection events. Non-blocking with 3s timeout.
|
|
||||||
|
|
||||||
Accepts a plain dict (from extract_ntfy_config) to avoid accessing
|
|
||||||
detached SQLAlchemy objects after session close.
|
|
||||||
"""
|
|
||||||
if not ntfy_config:
|
|
||||||
return
|
|
||||||
|
|
||||||
title_map = {
|
|
||||||
"request_received": "New Connection Request",
|
|
||||||
"request_accepted": "Connection Accepted",
|
|
||||||
}
|
|
||||||
message_map = {
|
|
||||||
"request_received": f"{sender_name} wants to connect with you on Umbra",
|
|
||||||
"request_accepted": f"{sender_name} accepted your connection request",
|
|
||||||
}
|
|
||||||
tag_map = {
|
|
||||||
"request_received": ["handshake"],
|
|
||||||
"request_accepted": ["white_check_mark"],
|
|
||||||
}
|
|
||||||
|
|
||||||
title = title_map.get(event_type, "Connection Update")
|
|
||||||
message = message_map.get(event_type, f"Connection update from {sender_name}")
|
|
||||||
tags = tag_map.get(event_type, ["bell"])
|
|
||||||
|
|
||||||
# Build a settings-like object for send_ntfy_notification (avoids detached SA objects)
|
|
||||||
settings_proxy = SimpleNamespace(**ntfy_config)
|
|
||||||
|
|
||||||
try:
|
|
||||||
await asyncio.wait_for(
|
|
||||||
send_ntfy_notification(
|
|
||||||
settings=settings_proxy,
|
|
||||||
title=title,
|
|
||||||
message=message,
|
|
||||||
tags=tags,
|
|
||||||
priority=3,
|
|
||||||
),
|
|
||||||
timeout=3.0,
|
|
||||||
)
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
logger.warning("ntfy connection push timed out for user_id=%s", ntfy_config["user_id"])
|
|
||||||
except Exception:
|
|
||||||
logger.warning("ntfy connection push failed for user_id=%s", ntfy_config["user_id"])
|
|
||||||
@ -1,34 +0,0 @@
|
|||||||
"""
|
|
||||||
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,9 +4,6 @@ limit_req_zone $binary_remote_addr zone=auth_limit:10m rate=10r/m;
|
|||||||
limit_req_zone $binary_remote_addr zone=register_limit:10m rate=5r/m;
|
limit_req_zone $binary_remote_addr zone=register_limit:10m rate=5r/m;
|
||||||
# Admin API — generous for legitimate use but still guards against scraping/brute-force
|
# Admin API — generous for legitimate use but still guards against scraping/brute-force
|
||||||
limit_req_zone $binary_remote_addr zone=admin_limit:10m rate=30r/m;
|
limit_req_zone $binary_remote_addr zone=admin_limit:10m rate=30r/m;
|
||||||
# Connection endpoints — prevent search enumeration and request spam
|
|
||||||
limit_req_zone $binary_remote_addr zone=conn_search_limit:10m rate=10r/m;
|
|
||||||
limit_req_zone $binary_remote_addr zone=conn_request_limit:10m rate=3r/m;
|
|
||||||
|
|
||||||
# Use X-Forwarded-Proto from upstream proxy when present, fall back to $scheme for direct access
|
# Use X-Forwarded-Proto from upstream proxy when present, fall back to $scheme for direct access
|
||||||
map $http_x_forwarded_proto $forwarded_proto {
|
map $http_x_forwarded_proto $forwarded_proto {
|
||||||
@ -85,20 +82,6 @@ server {
|
|||||||
include /etc/nginx/proxy-params.conf;
|
include /etc/nginx/proxy-params.conf;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Connection search — rate-limited to prevent user enumeration
|
|
||||||
location /api/connections/search {
|
|
||||||
limit_req zone=conn_search_limit burst=5 nodelay;
|
|
||||||
limit_req_status 429;
|
|
||||||
include /etc/nginx/proxy-params.conf;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Connection request (send) — exact match to avoid catching /requests/*
|
|
||||||
location = /api/connections/request {
|
|
||||||
limit_req zone=conn_request_limit burst=3 nodelay;
|
|
||||||
limit_req_status 429;
|
|
||||||
include /etc/nginx/proxy-params.conf;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Admin API — rate-limited separately from general /api traffic
|
# Admin API — rate-limited separately from general /api traffic
|
||||||
location /api/admin/ {
|
location /api/admin/ {
|
||||||
limit_req zone=admin_limit burst=10 nodelay;
|
limit_req zone=admin_limit burst=10 nodelay;
|
||||||
|
|||||||
@ -12,7 +12,6 @@ import ProjectDetail from '@/components/projects/ProjectDetail';
|
|||||||
import PeoplePage from '@/components/people/PeoplePage';
|
import PeoplePage from '@/components/people/PeoplePage';
|
||||||
import LocationsPage from '@/components/locations/LocationsPage';
|
import LocationsPage from '@/components/locations/LocationsPage';
|
||||||
import SettingsPage from '@/components/settings/SettingsPage';
|
import SettingsPage from '@/components/settings/SettingsPage';
|
||||||
import NotificationsPage from '@/components/notifications/NotificationsPage';
|
|
||||||
|
|
||||||
const AdminPortal = lazy(() => import('@/components/admin/AdminPortal'));
|
const AdminPortal = lazy(() => import('@/components/admin/AdminPortal'));
|
||||||
|
|
||||||
@ -73,7 +72,6 @@ function App() {
|
|||||||
<Route path="projects/:id" element={<ProjectDetail />} />
|
<Route path="projects/:id" element={<ProjectDetail />} />
|
||||||
<Route path="people" element={<PeoplePage />} />
|
<Route path="people" element={<PeoplePage />} />
|
||||||
<Route path="locations" element={<LocationsPage />} />
|
<Route path="locations" element={<LocationsPage />} />
|
||||||
<Route path="notifications" element={<NotificationsPage />} />
|
|
||||||
<Route path="settings" element={<SettingsPage />} />
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
<Route
|
<Route
|
||||||
path="admin/*"
|
path="admin/*"
|
||||||
|
|||||||
@ -30,11 +30,6 @@ const ACTION_TYPES = [
|
|||||||
'auth.setup_complete',
|
'auth.setup_complete',
|
||||||
'auth.registration',
|
'auth.registration',
|
||||||
'auth.mfa_enforce_prompted',
|
'auth.mfa_enforce_prompted',
|
||||||
'connection.request_sent',
|
|
||||||
'connection.request_cancelled',
|
|
||||||
'connection.accepted',
|
|
||||||
'connection.rejected',
|
|
||||||
'connection.removed',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
function actionLabel(action: string): string {
|
function actionLabel(action: string): string {
|
||||||
@ -49,7 +44,7 @@ export default function ConfigPage() {
|
|||||||
const [filterAction, setFilterAction] = useState<string>('');
|
const [filterAction, setFilterAction] = useState<string>('');
|
||||||
const PER_PAGE = 25;
|
const PER_PAGE = 25;
|
||||||
|
|
||||||
const { data, isLoading, error } = useAuditLog(page, PER_PAGE, filterAction || undefined);
|
const { data, isLoading } = useAuditLog(page, PER_PAGE, filterAction || undefined);
|
||||||
|
|
||||||
const totalPages = data ? Math.ceil(data.total / PER_PAGE) : 1;
|
const totalPages = data ? Math.ceil(data.total / PER_PAGE) : 1;
|
||||||
|
|
||||||
@ -116,11 +111,6 @@ export default function ConfigPage() {
|
|||||||
<Skeleton key={i} className="h-10 w-full" />
|
<Skeleton key={i} className="h-10 w-full" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : error ? (
|
|
||||||
<div className="px-5 pb-5">
|
|
||||||
<p className="text-sm text-destructive">Failed to load audit log</p>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">{error.message}</p>
|
|
||||||
</div>
|
|
||||||
) : !data?.entries?.length ? (
|
) : !data?.entries?.length ? (
|
||||||
<p className="px-5 pb-5 text-sm text-muted-foreground">No audit entries found.</p>
|
<p className="px-5 pb-5 text-sm text-muted-foreground">No audit entries found.</p>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -167,9 +167,6 @@ export default function IAMPage() {
|
|||||||
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||||
Username
|
Username
|
||||||
</th>
|
</th>
|
||||||
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
|
||||||
Umbral Name
|
|
||||||
</th>
|
|
||||||
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
<th className="px-5 py-3 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||||
Email
|
Email
|
||||||
</th>
|
</th>
|
||||||
@ -212,9 +209,6 @@ export default function IAMPage() {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<td className="px-5 py-3 font-medium">{user.username}</td>
|
<td className="px-5 py-3 font-medium">{user.username}</td>
|
||||||
<td className="px-5 py-3 text-muted-foreground text-xs">
|
|
||||||
{user.umbral_name || user.username}
|
|
||||||
</td>
|
|
||||||
<td className="px-5 py-3 text-muted-foreground text-xs">
|
<td className="px-5 py-3 text-muted-foreground text-xs">
|
||||||
{user.email || '—'}
|
{user.email || '—'}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@ -55,7 +55,7 @@ function MfaBadge({ enabled, pending }: { enabled: boolean; pending: boolean })
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function UserDetailSection({ userId, onClose }: UserDetailSectionProps) {
|
export default function UserDetailSection({ userId, onClose }: UserDetailSectionProps) {
|
||||||
const { data: user, isLoading, error } = useAdminUserDetail(userId);
|
const { data: user, isLoading } = useAdminUserDetail(userId);
|
||||||
const updateRole = useUpdateRole();
|
const updateRole = useUpdateRole();
|
||||||
|
|
||||||
const handleRoleChange = async (newRole: UserRole) => {
|
const handleRoleChange = async (newRole: UserRole) => {
|
||||||
@ -89,22 +89,6 @@ export default function UserDetailSection({ userId, onClose }: UserDetailSection
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-5">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<p className="text-sm text-destructive">Failed to load user details</p>
|
|
||||||
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" onClick={onClose}>
|
|
||||||
<X className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">{error.message}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -1,124 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { Check, X, Loader2 } from 'lucide-react';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { useConnections } from '@/hooks/useConnections';
|
|
||||||
import axios from 'axios';
|
|
||||||
import { getErrorMessage } from '@/lib/api';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import type { ConnectionRequest } from '@/types';
|
|
||||||
|
|
||||||
interface ConnectionRequestCardProps {
|
|
||||||
request: ConnectionRequest;
|
|
||||||
direction: 'incoming' | 'outgoing';
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ConnectionRequestCard({ request, direction }: ConnectionRequestCardProps) {
|
|
||||||
const { respond, cancelRequest, isCancelling } = useConnections();
|
|
||||||
const [isResponding, setIsResponding] = useState(false);
|
|
||||||
const [resolved, setResolved] = useState(false);
|
|
||||||
|
|
||||||
// Clean up invisible DOM element after fade-out transition
|
|
||||||
const [hidden, setHidden] = useState(false);
|
|
||||||
useEffect(() => {
|
|
||||||
if (!resolved) return;
|
|
||||||
const timer = setTimeout(() => setHidden(true), 300);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [resolved]);
|
|
||||||
|
|
||||||
if (hidden) return null;
|
|
||||||
|
|
||||||
const handleRespond = async (action: 'accept' | 'reject') => {
|
|
||||||
setIsResponding(true);
|
|
||||||
try {
|
|
||||||
await respond({ requestId: request.id, action });
|
|
||||||
setResolved(true);
|
|
||||||
toast.success(action === 'accept' ? 'Connection accepted' : 'Request declined');
|
|
||||||
} catch (err) {
|
|
||||||
// 409 means the request was already resolved (e.g. accepted via toast or notification center)
|
|
||||||
if (axios.isAxiosError(err) && err.response?.status === 409) {
|
|
||||||
setResolved(true);
|
|
||||||
toast.success(action === 'accept' ? 'Connection already accepted' : 'Request already resolved');
|
|
||||||
} else {
|
|
||||||
toast.error(getErrorMessage(err, 'Failed to respond'));
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setIsResponding(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCancel = async () => {
|
|
||||||
try {
|
|
||||||
await cancelRequest(request.id);
|
|
||||||
setResolved(true);
|
|
||||||
toast.success('Request cancelled');
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(getErrorMessage(err, 'Failed to cancel request'));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const isIncoming = direction === 'incoming';
|
|
||||||
const displayName = isIncoming
|
|
||||||
? request.sender_preferred_name || request.sender_umbral_name
|
|
||||||
: request.receiver_preferred_name || request.receiver_umbral_name;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'flex items-center gap-3 rounded-lg border border-border p-3 transition-all duration-300',
|
|
||||||
resolved && 'opacity-0 translate-y-2'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* Avatar */}
|
|
||||||
<div className="h-9 w-9 rounded-full bg-violet-500/15 flex items-center justify-center shrink-0">
|
|
||||||
<span className="text-sm font-medium text-violet-400">
|
|
||||||
{displayName.charAt(0).toUpperCase()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="text-sm font-medium truncate">{displayName}</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{isIncoming ? 'wants to connect' : 'request pending'} · {formatDistanceToNow(new Date(request.created_at), { addSuffix: true })}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="flex items-center gap-1.5 shrink-0">
|
|
||||||
{isIncoming ? (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleRespond('accept')}
|
|
||||||
disabled={isResponding}
|
|
||||||
className="gap-1"
|
|
||||||
>
|
|
||||||
{isResponding ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />}
|
|
||||||
Accept
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleRespond('reject')}
|
|
||||||
disabled={isResponding}
|
|
||||||
>
|
|
||||||
<X className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleCancel}
|
|
||||||
disabled={isCancelling}
|
|
||||||
className="text-muted-foreground hover:text-destructive hover:bg-destructive/10"
|
|
||||||
>
|
|
||||||
{isCancelling ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <X className="h-3.5 w-3.5" />}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,178 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { Search, UserPlus, Loader2, AlertCircle, CheckCircle, Settings } from 'lucide-react';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogDescription,
|
|
||||||
} from '@/components/ui/dialog';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import { useConnections } from '@/hooks/useConnections';
|
|
||||||
import { useSettings } from '@/hooks/useSettings';
|
|
||||||
import axios from 'axios';
|
|
||||||
import { getErrorMessage } from '@/lib/api';
|
|
||||||
|
|
||||||
interface ConnectionSearchProps {
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
personId?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ConnectionSearch({ open, onOpenChange, personId }: ConnectionSearchProps) {
|
|
||||||
const { search, isSearching, sendRequest, isSending } = useConnections();
|
|
||||||
const { settings, isLoading: isLoadingSettings } = useSettings();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [umbralName, setUmbralName] = useState('');
|
|
||||||
const [found, setFound] = useState<boolean | null>(null);
|
|
||||||
const [sent, setSent] = useState(false);
|
|
||||||
|
|
||||||
const acceptConnectionsEnabled = settings?.accept_connections ?? false;
|
|
||||||
|
|
||||||
const handleSearch = async () => {
|
|
||||||
if (!umbralName.trim()) return;
|
|
||||||
setFound(null);
|
|
||||||
setSent(false);
|
|
||||||
try {
|
|
||||||
const result = await search(umbralName.trim());
|
|
||||||
setFound(result.found);
|
|
||||||
} catch (err) {
|
|
||||||
if (axios.isAxiosError(err) && err.response?.status === 429) {
|
|
||||||
toast.error('Too many searches — please wait a moment and try again');
|
|
||||||
} else {
|
|
||||||
setFound(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSend = async () => {
|
|
||||||
try {
|
|
||||||
await sendRequest({ umbralName: umbralName.trim(), personId });
|
|
||||||
setSent(true);
|
|
||||||
toast.success('Connection request sent');
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(getErrorMessage(err, 'Failed to send request'));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
setUmbralName('');
|
|
||||||
setFound(null);
|
|
||||||
setSent(false);
|
|
||||||
onOpenChange(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={handleClose}>
|
|
||||||
<DialogContent className="sm:max-w-md">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="flex items-center gap-2">
|
|
||||||
<UserPlus className="h-5 w-5 text-violet-400" />
|
|
||||||
Find Umbra User
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
{personId
|
|
||||||
? 'Search for an umbral user to link this contact to.'
|
|
||||||
: 'Search for a user by their umbral name to send a connection request.'}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="space-y-4 pt-2">
|
|
||||||
{isLoadingSettings ? (
|
|
||||||
<div className="flex justify-center py-6"><Loader2 className="h-5 w-5 animate-spin text-muted-foreground" /></div>
|
|
||||||
) : !acceptConnectionsEnabled ? (
|
|
||||||
<div className="flex flex-col items-center gap-3 py-4 text-center">
|
|
||||||
<AlertCircle className="h-8 w-8 text-amber-400" />
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
You need to enable <span className="text-foreground font-medium">Accept Connections</span> in your settings before you can send or receive connection requests.
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
className="gap-1.5"
|
|
||||||
onClick={() => { handleClose(); navigate('/settings'); }}
|
|
||||||
>
|
|
||||||
<Settings className="h-3.5 w-3.5" />
|
|
||||||
Go to Settings
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="umbral_search">Umbral Name</Label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Input
|
|
||||||
id="umbral_search"
|
|
||||||
placeholder="Enter umbral name..."
|
|
||||||
value={umbralName}
|
|
||||||
onChange={(e) => {
|
|
||||||
setUmbralName(e.target.value);
|
|
||||||
setFound(null);
|
|
||||||
setSent(false);
|
|
||||||
}}
|
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') handleSearch(); }}
|
|
||||||
maxLength={50}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
onClick={handleSearch}
|
|
||||||
disabled={!umbralName.trim() || isSearching}
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
{isSearching ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Search className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{found === false && (
|
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
||||||
<AlertCircle className="h-4 w-4" />
|
|
||||||
User not found
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{found === true && !sent && (
|
|
||||||
<div className="flex items-center justify-between rounded-lg border border-border p-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="h-8 w-8 rounded-full bg-violet-500/15 flex items-center justify-center">
|
|
||||||
<span className="text-sm font-medium text-violet-400">
|
|
||||||
{umbralName.charAt(0).toUpperCase()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm font-medium">{umbralName}</span>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
onClick={handleSend}
|
|
||||||
disabled={isSending}
|
|
||||||
size="sm"
|
|
||||||
className="gap-1.5"
|
|
||||||
>
|
|
||||||
{isSending ? (
|
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<UserPlus className="h-3.5 w-3.5" />
|
|
||||||
)}
|
|
||||||
Send Request
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{sent && (
|
|
||||||
<div className="flex items-center gap-2 text-sm text-green-400">
|
|
||||||
<CheckCircle className="h-4 w-4" />
|
|
||||||
Connection request sent
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -4,11 +4,9 @@ import { Menu } from 'lucide-react';
|
|||||||
import { useTheme } from '@/hooks/useTheme';
|
import { useTheme } from '@/hooks/useTheme';
|
||||||
import { AlertsProvider } from '@/hooks/useAlerts';
|
import { AlertsProvider } from '@/hooks/useAlerts';
|
||||||
import { LockProvider } from '@/hooks/useLock';
|
import { LockProvider } from '@/hooks/useLock';
|
||||||
import { NotificationProvider } from '@/hooks/useNotifications';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import Sidebar from './Sidebar';
|
import Sidebar from './Sidebar';
|
||||||
import LockOverlay from './LockOverlay';
|
import LockOverlay from './LockOverlay';
|
||||||
import NotificationToaster from '@/components/notifications/NotificationToaster';
|
|
||||||
|
|
||||||
export default function AppLayout() {
|
export default function AppLayout() {
|
||||||
useTheme();
|
useTheme();
|
||||||
@ -21,34 +19,31 @@ export default function AppLayout() {
|
|||||||
return (
|
return (
|
||||||
<LockProvider>
|
<LockProvider>
|
||||||
<AlertsProvider>
|
<AlertsProvider>
|
||||||
<NotificationProvider>
|
<div className="flex h-screen overflow-hidden bg-background">
|
||||||
<div className="flex h-screen overflow-hidden bg-background">
|
<Sidebar
|
||||||
<Sidebar
|
collapsed={collapsed}
|
||||||
collapsed={collapsed}
|
onToggle={() => {
|
||||||
onToggle={() => {
|
const next = !collapsed;
|
||||||
const next = !collapsed;
|
setCollapsed(next);
|
||||||
setCollapsed(next);
|
localStorage.setItem('umbra-sidebar-collapsed', JSON.stringify(next));
|
||||||
localStorage.setItem('umbra-sidebar-collapsed', JSON.stringify(next));
|
}}
|
||||||
}}
|
mobileOpen={mobileOpen}
|
||||||
mobileOpen={mobileOpen}
|
onMobileClose={() => setMobileOpen(false)}
|
||||||
onMobileClose={() => setMobileOpen(false)}
|
/>
|
||||||
/>
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
{/* Mobile header */}
|
||||||
{/* Mobile header */}
|
<div className="flex md:hidden items-center h-14 border-b bg-card px-4">
|
||||||
<div className="flex md:hidden items-center h-14 border-b bg-card px-4">
|
<Button variant="ghost" size="icon" onClick={() => setMobileOpen(true)}>
|
||||||
<Button variant="ghost" size="icon" onClick={() => setMobileOpen(true)}>
|
<Menu className="h-5 w-5" />
|
||||||
<Menu className="h-5 w-5" />
|
</Button>
|
||||||
</Button>
|
<h1 className="text-lg font-bold text-accent ml-3">UMBRA</h1>
|
||||||
<h1 className="text-lg font-bold text-accent ml-3">UMBRA</h1>
|
|
||||||
</div>
|
|
||||||
<main className="flex-1 overflow-y-auto">
|
|
||||||
<Outlet />
|
|
||||||
</main>
|
|
||||||
</div>
|
</div>
|
||||||
|
<main className="flex-1 overflow-y-auto">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<LockOverlay />
|
</div>
|
||||||
<NotificationToaster />
|
<LockOverlay />
|
||||||
</NotificationProvider>
|
|
||||||
</AlertsProvider>
|
</AlertsProvider>
|
||||||
</LockProvider>
|
</LockProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -22,7 +22,6 @@ import { cn } from '@/lib/utils';
|
|||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import { useLock } from '@/hooks/useLock';
|
import { useLock } from '@/hooks/useLock';
|
||||||
import { useConfirmAction } from '@/hooks/useConfirmAction';
|
import { useConfirmAction } from '@/hooks/useConfirmAction';
|
||||||
import { useNotifications } from '@/hooks/useNotifications';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import api from '@/lib/api';
|
import api from '@/lib/api';
|
||||||
import type { Project } from '@/types';
|
import type { Project } from '@/types';
|
||||||
@ -48,7 +47,6 @@ export default function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { logout, isAdmin } = useAuth();
|
const { logout, isAdmin } = useAuth();
|
||||||
const { lock } = useLock();
|
const { lock } = useLock();
|
||||||
const { unreadCount } = useNotifications();
|
|
||||||
const [projectsExpanded, setProjectsExpanded] = useState(false);
|
const [projectsExpanded, setProjectsExpanded] = useState(false);
|
||||||
|
|
||||||
const { data: trackedProjects } = useQuery({
|
const { data: trackedProjects } = useQuery({
|
||||||
@ -196,28 +194,6 @@ export default function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose
|
|||||||
<Lock className="h-5 w-5 shrink-0" />
|
<Lock className="h-5 w-5 shrink-0" />
|
||||||
{showExpanded && <span>Lock</span>}
|
{showExpanded && <span>Lock</span>}
|
||||||
</button>
|
</button>
|
||||||
<NavLink
|
|
||||||
to="/notifications"
|
|
||||||
onClick={mobileOpen ? onMobileClose : undefined}
|
|
||||||
className={navLinkClass}
|
|
||||||
>
|
|
||||||
<div className="relative shrink-0">
|
|
||||||
<Bell className="h-5 w-5" />
|
|
||||||
{unreadCount > 0 && !showExpanded && (
|
|
||||||
<div className="absolute -top-1 -right-1 h-2 w-2 rounded-full bg-red-500" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{showExpanded && (
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
Notifications
|
|
||||||
{unreadCount > 0 && (
|
|
||||||
<span className="text-[10px] bg-red-500/15 text-red-400 rounded-full px-1.5 py-0.5 tabular-nums">
|
|
||||||
{unreadCount}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</NavLink>
|
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<NavLink
|
<NavLink
|
||||||
to="/admin"
|
to="/admin"
|
||||||
|
|||||||
@ -1,149 +0,0 @@
|
|||||||
import { useEffect, useRef, useCallback } from 'react';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { Check, X, Bell, UserPlus } from 'lucide-react';
|
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
|
||||||
import { useNotifications } from '@/hooks/useNotifications';
|
|
||||||
import { useConnections } from '@/hooks/useConnections';
|
|
||||||
import axios from 'axios';
|
|
||||||
import { getErrorMessage } from '@/lib/api';
|
|
||||||
import type { AppNotification } from '@/types';
|
|
||||||
|
|
||||||
export default function NotificationToaster() {
|
|
||||||
const { notifications, unreadCount, markRead } = useNotifications();
|
|
||||||
const { respond } = useConnections();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const maxSeenIdRef = useRef(0);
|
|
||||||
const initializedRef = useRef(false);
|
|
||||||
const prevUnreadRef = useRef(0);
|
|
||||||
// Track in-flight request IDs so repeated clicks are blocked
|
|
||||||
const respondingRef = useRef<Set<number>>(new Set());
|
|
||||||
// Always call the latest respond — Sonner toasts capture closures at creation time
|
|
||||||
const respondRef = useRef(respond);
|
|
||||||
respondRef.current = respond;
|
|
||||||
const markReadRef = useRef(markRead);
|
|
||||||
markReadRef.current = markRead;
|
|
||||||
|
|
||||||
const handleConnectionRespond = useCallback(
|
|
||||||
async (requestId: number, action: 'accept' | 'reject', toastId: string | number, notificationId: number) => {
|
|
||||||
// Guard against double-clicks (Sonner toasts are static, no disabled prop)
|
|
||||||
if (respondingRef.current.has(requestId)) return;
|
|
||||||
respondingRef.current.add(requestId);
|
|
||||||
|
|
||||||
// Immediately dismiss the custom toast and show a loading indicator
|
|
||||||
toast.dismiss(toastId);
|
|
||||||
const loadingId = toast.loading(
|
|
||||||
action === 'accept' ? 'Accepting connection…' : 'Declining request…',
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await respondRef.current({ requestId, action });
|
|
||||||
toast.dismiss(loadingId);
|
|
||||||
toast.success(action === 'accept' ? 'Connection accepted' : 'Request declined');
|
|
||||||
markReadRef.current([notificationId]).catch(() => {});
|
|
||||||
} catch (err) {
|
|
||||||
toast.dismiss(loadingId);
|
|
||||||
// 409 means the request was already resolved (e.g. accepted via notification center)
|
|
||||||
if (axios.isAxiosError(err) && err.response?.status === 409) {
|
|
||||||
toast.success(action === 'accept' ? 'Connection already accepted' : 'Request already resolved');
|
|
||||||
markReadRef.current([notificationId]).catch(() => {});
|
|
||||||
} else {
|
|
||||||
toast.error(getErrorMessage(err, 'Failed to respond to request'));
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
respondingRef.current.delete(requestId);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Track unread count changes to force-refetch the list
|
|
||||||
useEffect(() => {
|
|
||||||
if (unreadCount > prevUnreadRef.current && initializedRef.current) {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['notifications', 'list'] });
|
|
||||||
}
|
|
||||||
prevUnreadRef.current = unreadCount;
|
|
||||||
}, [unreadCount, queryClient]);
|
|
||||||
|
|
||||||
// Show toasts for new notifications (ID > max seen)
|
|
||||||
useEffect(() => {
|
|
||||||
if (!notifications.length) return;
|
|
||||||
|
|
||||||
// On first load, record the max ID without toasting
|
|
||||||
if (!initializedRef.current) {
|
|
||||||
maxSeenIdRef.current = Math.max(...notifications.map((n) => n.id));
|
|
||||||
initializedRef.current = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find unread notifications with IDs higher than our watermark
|
|
||||||
const newNotifications = notifications.filter(
|
|
||||||
(n) => !n.is_read && n.id > maxSeenIdRef.current,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Advance watermark
|
|
||||||
const maxCurrent = Math.max(...notifications.map((n) => n.id));
|
|
||||||
if (maxCurrent > maxSeenIdRef.current) {
|
|
||||||
maxSeenIdRef.current = maxCurrent;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Eagerly refresh incoming requests when connection_request notifications arrive
|
|
||||||
// so accept buttons work immediately on NotificationsPage / PeoplePage
|
|
||||||
if (newNotifications.some((n) => n.type === 'connection_request')) {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['connections', 'incoming'] });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show toasts
|
|
||||||
newNotifications.forEach((notification) => {
|
|
||||||
if (notification.type === 'connection_request' && notification.source_id) {
|
|
||||||
showConnectionRequestToast(notification);
|
|
||||||
} else {
|
|
||||||
toast(notification.title || 'New Notification', {
|
|
||||||
description: notification.message || undefined,
|
|
||||||
icon: <Bell className="h-4 w-4" />,
|
|
||||||
duration: 8000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, [notifications, handleConnectionRespond]);
|
|
||||||
|
|
||||||
const showConnectionRequestToast = (notification: AppNotification) => {
|
|
||||||
const requestId = notification.source_id!;
|
|
||||||
|
|
||||||
toast.custom(
|
|
||||||
(id) => (
|
|
||||||
<div className="w-[356px] rounded-lg border border-border bg-card p-4 shadow-lg">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className="h-9 w-9 rounded-full bg-violet-500/15 flex items-center justify-center shrink-0">
|
|
||||||
<UserPlus className="h-4 w-4 text-violet-400" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="text-sm font-medium text-foreground">Connection Request</p>
|
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">
|
|
||||||
{notification.message || 'Someone wants to connect with you'}
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-2 mt-3">
|
|
||||||
<button
|
|
||||||
onClick={() => handleConnectionRespond(requestId, 'accept', id, notification.id)}
|
|
||||||
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-md bg-accent text-accent-foreground hover:bg-accent/90 transition-colors"
|
|
||||||
>
|
|
||||||
<Check className="h-3.5 w-3.5" />
|
|
||||||
Accept
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleConnectionRespond(requestId, 'reject', id, notification.id)}
|
|
||||||
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-md text-muted-foreground hover:bg-card-elevated transition-colors"
|
|
||||||
>
|
|
||||||
<X className="h-3.5 w-3.5" />
|
|
||||||
Reject
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
{ id: `connection-request-${requestId}`, duration: 30000 },
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@ -1,285 +0,0 @@
|
|||||||
import { useState, useMemo, useEffect } from 'react';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
|
||||||
import { Bell, Check, CheckCheck, Trash2, UserPlus, Info, AlertCircle, X, Loader2 } from 'lucide-react';
|
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { useNotifications } from '@/hooks/useNotifications';
|
|
||||||
import { useConnections } from '@/hooks/useConnections';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import axios from 'axios';
|
|
||||||
import { getErrorMessage } from '@/lib/api';
|
|
||||||
import { ListSkeleton } from '@/components/ui/skeleton';
|
|
||||||
import type { AppNotification } from '@/types';
|
|
||||||
|
|
||||||
const typeIcons: Record<string, { icon: typeof Bell; color: string }> = {
|
|
||||||
connection_request: { icon: UserPlus, color: 'text-violet-400' },
|
|
||||||
connection_accepted: { icon: UserPlus, color: 'text-green-400' },
|
|
||||||
info: { icon: Info, color: 'text-blue-400' },
|
|
||||||
warning: { icon: AlertCircle, color: 'text-amber-400' },
|
|
||||||
};
|
|
||||||
|
|
||||||
type Filter = 'all' | 'unread';
|
|
||||||
|
|
||||||
export default function NotificationsPage() {
|
|
||||||
const {
|
|
||||||
notifications,
|
|
||||||
unreadCount,
|
|
||||||
isLoading,
|
|
||||||
markRead,
|
|
||||||
markAllRead,
|
|
||||||
deleteNotification,
|
|
||||||
} = useNotifications();
|
|
||||||
|
|
||||||
const { incomingRequests, respond, isResponding } = useConnections();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [filter, setFilter] = useState<Filter>('all');
|
|
||||||
|
|
||||||
// Build a set of pending connection request IDs for quick lookup
|
|
||||||
const pendingRequestIds = useMemo(
|
|
||||||
() => new Set(incomingRequests.map((r) => r.id)),
|
|
||||||
[incomingRequests],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Eagerly fetch incoming requests when notifications contain connection_request
|
|
||||||
// entries whose source_id isn't in pendingRequestIds yet (stale connections data)
|
|
||||||
useEffect(() => {
|
|
||||||
const hasMissing = notifications.some(
|
|
||||||
(n) => n.type === 'connection_request' && n.source_id && !n.is_read && !pendingRequestIds.has(n.source_id),
|
|
||||||
);
|
|
||||||
if (hasMissing) {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['connections', 'incoming'] });
|
|
||||||
}
|
|
||||||
}, [notifications, pendingRequestIds, queryClient]);
|
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
|
||||||
if (filter === 'unread') return notifications.filter((n) => !n.is_read);
|
|
||||||
return notifications;
|
|
||||||
}, [notifications, filter]);
|
|
||||||
|
|
||||||
const handleMarkRead = async (id: number) => {
|
|
||||||
try {
|
|
||||||
await markRead([id]);
|
|
||||||
} catch { /* toast handled by mutation */ }
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async (id: number) => {
|
|
||||||
try {
|
|
||||||
await deleteNotification(id);
|
|
||||||
} catch { /* toast handled by mutation */ }
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMarkAllRead = async () => {
|
|
||||||
try {
|
|
||||||
await markAllRead();
|
|
||||||
} catch { /* toast handled by mutation */ }
|
|
||||||
};
|
|
||||||
|
|
||||||
const getIcon = (type: string) => {
|
|
||||||
const config = typeIcons[type] || { icon: Bell, color: 'text-muted-foreground' };
|
|
||||||
return config;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleConnectionRespond = async (
|
|
||||||
notification: AppNotification,
|
|
||||||
action: 'accept' | 'reject',
|
|
||||||
) => {
|
|
||||||
if (!notification.source_id) return;
|
|
||||||
try {
|
|
||||||
await respond({ requestId: notification.source_id, action });
|
|
||||||
if (!notification.is_read) {
|
|
||||||
await markRead([notification.id]).catch(() => {});
|
|
||||||
}
|
|
||||||
toast.success(action === 'accept' ? 'Connection accepted' : 'Request declined');
|
|
||||||
} catch (err) {
|
|
||||||
// 409 means the request was already resolved (e.g. accepted via toast)
|
|
||||||
if (axios.isAxiosError(err) && err.response?.status === 409) {
|
|
||||||
if (!notification.is_read) {
|
|
||||||
await markRead([notification.id]).catch(() => {});
|
|
||||||
}
|
|
||||||
toast.success(action === 'accept' ? 'Connection already accepted' : 'Request already resolved');
|
|
||||||
} else {
|
|
||||||
toast.error(getErrorMessage(err, 'Failed to respond'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNotificationClick = async (notification: AppNotification) => {
|
|
||||||
// Don't navigate for pending connection requests — let user act inline
|
|
||||||
if (
|
|
||||||
notification.type === 'connection_request' &&
|
|
||||||
notification.source_id &&
|
|
||||||
pendingRequestIds.has(notification.source_id)
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!notification.is_read) {
|
|
||||||
await markRead([notification.id]).catch(() => {});
|
|
||||||
}
|
|
||||||
// Navigate to People for connection-related notifications
|
|
||||||
if (notification.type === 'connection_request' || notification.type === 'connection_accepted') {
|
|
||||||
navigate('/people');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col h-full animate-fade-in">
|
|
||||||
{/* Page header */}
|
|
||||||
<div className="border-b bg-card px-6 h-16 flex items-center justify-between shrink-0">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Bell className="h-5 w-5 text-accent" aria-hidden="true" />
|
|
||||||
<h1 className="text-xl font-semibold font-heading">Notifications</h1>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{/* Filter */}
|
|
||||||
<div className="flex items-center rounded-md border border-border overflow-hidden">
|
|
||||||
{(['all', 'unread'] as const).map((f) => (
|
|
||||||
<button
|
|
||||||
key={f}
|
|
||||||
onClick={() => setFilter(f)}
|
|
||||||
className={cn(
|
|
||||||
'px-3 py-1.5 text-xs font-medium transition-colors capitalize',
|
|
||||||
filter === f
|
|
||||||
? 'bg-accent/15 text-accent'
|
|
||||||
: 'text-muted-foreground hover:text-foreground hover:bg-card-elevated'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{f}
|
|
||||||
{f === 'unread' && unreadCount > 0 && (
|
|
||||||
<span className="ml-1.5 text-[10px] bg-red-500/15 text-red-400 rounded-full px-1.5 py-0.5 tabular-nums">
|
|
||||||
{unreadCount}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{unreadCount > 0 && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleMarkAllRead}
|
|
||||||
className="text-xs gap-1.5"
|
|
||||||
>
|
|
||||||
<CheckCheck className="h-3.5 w-3.5" />
|
|
||||||
Mark all read
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="p-6">
|
|
||||||
<ListSkeleton rows={5} />
|
|
||||||
</div>
|
|
||||||
) : filtered.length === 0 ? (
|
|
||||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-3 py-20">
|
|
||||||
<Bell className="h-10 w-10 opacity-30" />
|
|
||||||
<p className="text-sm">
|
|
||||||
{filter === 'unread' ? 'No unread notifications' : 'No notifications'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="divide-y divide-border">
|
|
||||||
{filtered.map((notification) => {
|
|
||||||
const iconConfig = getIcon(notification.type);
|
|
||||||
const Icon = iconConfig.icon;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={notification.id}
|
|
||||||
onClick={() => handleNotificationClick(notification)}
|
|
||||||
className={cn(
|
|
||||||
'flex items-start gap-3 px-6 py-3.5 transition-colors hover:bg-card-elevated group cursor-pointer',
|
|
||||||
!notification.is_read && 'bg-card'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* Type icon */}
|
|
||||||
<div className={cn('mt-0.5 shrink-0', iconConfig.color)}>
|
|
||||||
<Icon className="h-4 w-4" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className={cn(
|
|
||||||
'text-sm truncate',
|
|
||||||
!notification.is_read ? 'font-medium text-foreground' : 'text-muted-foreground'
|
|
||||||
)}>
|
|
||||||
{notification.title}
|
|
||||||
</p>
|
|
||||||
{notification.message && (
|
|
||||||
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
|
|
||||||
{notification.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{/* Unread dot */}
|
|
||||||
{!notification.is_read && (
|
|
||||||
<div className="h-2 w-2 rounded-full bg-accent shrink-0 mt-1.5" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Connection request actions (inline) */}
|
|
||||||
{notification.type === 'connection_request' &&
|
|
||||||
notification.source_id &&
|
|
||||||
pendingRequestIds.has(notification.source_id) && (
|
|
||||||
<div className="flex items-center gap-1.5 shrink-0">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={(e) => { e.stopPropagation(); handleConnectionRespond(notification, 'accept'); }}
|
|
||||||
disabled={isResponding}
|
|
||||||
className="gap-1 h-7 text-xs"
|
|
||||||
>
|
|
||||||
{isResponding ? <Loader2 className="h-3 w-3 animate-spin" /> : <Check className="h-3 w-3" />}
|
|
||||||
Accept
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={(e) => { e.stopPropagation(); handleConnectionRespond(notification, 'reject'); }}
|
|
||||||
disabled={isResponding}
|
|
||||||
className="h-7 text-xs"
|
|
||||||
>
|
|
||||||
<X className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Timestamp + actions */}
|
|
||||||
<div className="flex items-center gap-1.5 shrink-0">
|
|
||||||
<span className="text-[11px] text-muted-foreground tabular-nums">
|
|
||||||
{formatDistanceToNow(new Date(notification.created_at), { addSuffix: true })}
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
||||||
{!notification.is_read && (
|
|
||||||
<button
|
|
||||||
onClick={(e) => { e.stopPropagation(); handleMarkRead(notification.id); }}
|
|
||||||
className="p-1 rounded hover:bg-accent/10 text-muted-foreground hover:text-accent transition-colors"
|
|
||||||
title="Mark as read"
|
|
||||||
>
|
|
||||||
<Check className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={(e) => { e.stopPropagation(); handleDelete(notification.id); }}
|
|
||||||
className="p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
|
|
||||||
title="Delete"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { useState, useMemo, useRef, useEffect } from 'react';
|
import { useState, useMemo, useRef, useEffect } from 'react';
|
||||||
import { Plus, Users, Star, Cake, Phone, Mail, MapPin, Tag, Building2, Briefcase, AlignLeft, Ghost, ChevronDown, Unlink, Link2, User2 } from 'lucide-react';
|
import { Plus, Users, Star, Cake, Phone, Mail, MapPin, Tag, Building2, Briefcase, AlignLeft } from 'lucide-react';
|
||||||
import type { LucideIcon } from 'lucide-react';
|
import type { LucideIcon } from 'lucide-react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { format, parseISO, differenceInYears } from 'date-fns';
|
import { format, parseISO, differenceInYears } from 'date-fns';
|
||||||
@ -23,9 +23,6 @@ import {
|
|||||||
import { useTableVisibility } from '@/hooks/useTableVisibility';
|
import { useTableVisibility } from '@/hooks/useTableVisibility';
|
||||||
import { useCategoryOrder } from '@/hooks/useCategoryOrder';
|
import { useCategoryOrder } from '@/hooks/useCategoryOrder';
|
||||||
import PersonForm from './PersonForm';
|
import PersonForm from './PersonForm';
|
||||||
import ConnectionSearch from '@/components/connections/ConnectionSearch';
|
|
||||||
import ConnectionRequestCard from '@/components/connections/ConnectionRequestCard';
|
|
||||||
import { useConnections } from '@/hooks/useConnections';
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// StatCounter — inline helper
|
// StatCounter — inline helper
|
||||||
@ -60,11 +57,7 @@ function StatCounter({
|
|||||||
// Helpers
|
// Helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
function getPersonInitialsName(p: Person): string {
|
function getPersonInitialsName(p: Person): string {
|
||||||
const firstName = p.is_umbral_contact && p.shared_fields?.first_name
|
const parts = [p.first_name, p.last_name].filter(Boolean);
|
||||||
? String(p.shared_fields.first_name) : p.first_name;
|
|
||||||
const lastName = p.is_umbral_contact && p.shared_fields?.last_name
|
|
||||||
? String(p.shared_fields.last_name) : p.last_name;
|
|
||||||
const parts = [firstName, lastName].filter(Boolean);
|
|
||||||
return parts.length > 0 ? parts.join(' ') : p.name;
|
return parts.length > 0 ? parts.join(' ') : p.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -89,14 +82,6 @@ function sortPeople(people: Person[], key: string, dir: 'asc' | 'desc'): Person[
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Column definitions
|
// Column definitions
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
/** Get a field value, preferring shared_fields for umbral contacts. */
|
|
||||||
function sf(p: Person, key: string): string | null | undefined {
|
|
||||||
if (p.is_umbral_contact && p.shared_fields && key in p.shared_fields) {
|
|
||||||
return p.shared_fields[key] as string | null;
|
|
||||||
}
|
|
||||||
return p[key as keyof Person] as string | null | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const columns: ColumnDef<Person>[] = [
|
const columns: ColumnDef<Person>[] = [
|
||||||
{
|
{
|
||||||
key: 'name',
|
key: 'name',
|
||||||
@ -104,10 +89,7 @@ const columns: ColumnDef<Person>[] = [
|
|||||||
sortable: true,
|
sortable: true,
|
||||||
visibilityLevel: 'essential',
|
visibilityLevel: 'essential',
|
||||||
render: (p) => {
|
render: (p) => {
|
||||||
const firstName = sf(p, 'first_name');
|
const initialsName = getPersonInitialsName(p);
|
||||||
const lastName = sf(p, 'last_name');
|
|
||||||
const liveName = [firstName, lastName].filter(Boolean).join(' ') || p.nickname || p.name;
|
|
||||||
const initialsName = liveName || getPersonInitialsName(p);
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<div
|
<div
|
||||||
@ -115,10 +97,7 @@ const columns: ColumnDef<Person>[] = [
|
|||||||
>
|
>
|
||||||
{getInitials(initialsName)}
|
{getInitials(initialsName)}
|
||||||
</div>
|
</div>
|
||||||
<span className="font-medium truncate">{liveName}</span>
|
<span className="font-medium truncate">{p.nickname || p.name}</span>
|
||||||
{p.is_umbral_contact && (
|
|
||||||
<Ghost className="h-3.5 w-3.5 text-violet-400 shrink-0" aria-label="Umbral contact" />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@ -128,21 +107,18 @@ const columns: ColumnDef<Person>[] = [
|
|||||||
label: 'Number',
|
label: 'Number',
|
||||||
sortable: false,
|
sortable: false,
|
||||||
visibilityLevel: 'essential',
|
visibilityLevel: 'essential',
|
||||||
render: (p) => {
|
render: (p) => (
|
||||||
const mobile = sf(p, 'mobile');
|
<span className="text-muted-foreground truncate">{p.mobile || p.phone || '—'}</span>
|
||||||
const phone = sf(p, 'phone');
|
),
|
||||||
return <span className="text-muted-foreground truncate">{mobile || phone || '—'}</span>;
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'email',
|
key: 'email',
|
||||||
label: 'Email',
|
label: 'Email',
|
||||||
sortable: true,
|
sortable: true,
|
||||||
visibilityLevel: 'essential',
|
visibilityLevel: 'essential',
|
||||||
render: (p) => {
|
render: (p) => (
|
||||||
const email = sf(p, 'email');
|
<span className="text-muted-foreground truncate">{p.email || '—'}</span>
|
||||||
return <span className="text-muted-foreground truncate">{email || '—'}</span>;
|
),
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'job_title',
|
key: 'job_title',
|
||||||
@ -150,10 +126,10 @@ const columns: ColumnDef<Person>[] = [
|
|||||||
sortable: true,
|
sortable: true,
|
||||||
visibilityLevel: 'filtered',
|
visibilityLevel: 'filtered',
|
||||||
render: (p) => {
|
render: (p) => {
|
||||||
const jobTitle = sf(p, 'job_title');
|
const parts = [p.job_title, p.company].filter(Boolean);
|
||||||
const company = sf(p, 'company');
|
return (
|
||||||
const parts = [jobTitle, company].filter(Boolean);
|
<span className="text-muted-foreground truncate">{parts.join(', ') || '—'}</span>
|
||||||
return <span className="text-muted-foreground truncate">{parts.join(', ') || '—'}</span>;
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -161,14 +137,12 @@ const columns: ColumnDef<Person>[] = [
|
|||||||
label: 'Birthday',
|
label: 'Birthday',
|
||||||
sortable: true,
|
sortable: true,
|
||||||
visibilityLevel: 'filtered',
|
visibilityLevel: 'filtered',
|
||||||
render: (p) => {
|
render: (p) =>
|
||||||
const birthday = sf(p, 'birthday');
|
p.birthday ? (
|
||||||
return birthday ? (
|
<span className="text-muted-foreground">{format(parseISO(p.birthday), 'MMM d')}</span>
|
||||||
<span className="text-muted-foreground">{format(parseISO(birthday), 'MMM d')}</span>
|
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted-foreground">—</span>
|
<span className="text-muted-foreground">—</span>
|
||||||
);
|
),
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'category',
|
key: 'category',
|
||||||
@ -196,7 +170,6 @@ const columns: ColumnDef<Person>[] = [
|
|||||||
// Panel field config
|
// Panel field config
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
const panelFields: PanelField[] = [
|
const panelFields: PanelField[] = [
|
||||||
{ label: 'Preferred Name', key: 'preferred_name', icon: User2 },
|
|
||||||
{ label: 'Mobile', key: 'mobile', copyable: true, icon: Phone },
|
{ label: 'Mobile', key: 'mobile', copyable: true, icon: Phone },
|
||||||
{ label: 'Phone', key: 'phone', copyable: true, icon: Phone },
|
{ label: 'Phone', key: 'phone', copyable: true, icon: Phone },
|
||||||
{ label: 'Email', key: 'email', copyable: true, icon: Mail },
|
{ label: 'Email', key: 'email', copyable: true, icon: Mail },
|
||||||
@ -220,17 +193,9 @@ export default function PeoplePage() {
|
|||||||
const [editingPerson, setEditingPerson] = useState<Person | null>(null);
|
const [editingPerson, setEditingPerson] = useState<Person | null>(null);
|
||||||
const [activeFilters, setActiveFilters] = useState<string[]>([]);
|
const [activeFilters, setActiveFilters] = useState<string[]>([]);
|
||||||
const [showPinned, setShowPinned] = useState(true);
|
const [showPinned, setShowPinned] = useState(true);
|
||||||
const [showUmbralOnly, setShowUmbralOnly] = useState(false);
|
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [sortKey, setSortKey] = useState<string>('name');
|
const [sortKey, setSortKey] = useState<string>('name');
|
||||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
|
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
|
||||||
const [showConnectionSearch, setShowConnectionSearch] = useState(false);
|
|
||||||
const [linkPersonId, setLinkPersonId] = useState<number | null>(null);
|
|
||||||
const [showAddDropdown, setShowAddDropdown] = useState(false);
|
|
||||||
const addDropdownRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const { incomingRequests, outgoingRequests } = useConnections();
|
|
||||||
const hasRequests = incomingRequests.length > 0 || outgoingRequests.length > 0;
|
|
||||||
|
|
||||||
const { data: people = [], isLoading } = useQuery({
|
const { data: people = [], isLoading } = useQuery({
|
||||||
queryKey: ['people'],
|
queryKey: ['people'],
|
||||||
@ -263,10 +228,6 @@ export default function PeoplePage() {
|
|||||||
? people.filter((p) => !p.is_favourite)
|
? people.filter((p) => !p.is_favourite)
|
||||||
: people;
|
: people;
|
||||||
|
|
||||||
if (showUmbralOnly) {
|
|
||||||
list = list.filter((p) => p.is_umbral_contact);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activeFilters.length > 0) {
|
if (activeFilters.length > 0) {
|
||||||
list = list.filter((p) => p.category && activeFilters.includes(p.category));
|
list = list.filter((p) => p.category && activeFilters.includes(p.category));
|
||||||
}
|
}
|
||||||
@ -288,7 +249,7 @@ export default function PeoplePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return sortPeople(list, sortKey, sortDir);
|
return sortPeople(list, sortKey, sortDir);
|
||||||
}, [people, showPinned, showUmbralOnly, activeFilters, search, sortKey, sortDir]);
|
}, [people, showPinned, activeFilters, search, sortKey, sortDir]);
|
||||||
|
|
||||||
// Build row groups for the table — ordered by custom category order
|
// Build row groups for the table — ordered by custom category order
|
||||||
const groups = useMemo(() => {
|
const groups = useMemo(() => {
|
||||||
@ -353,7 +314,6 @@ export default function PeoplePage() {
|
|||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['people'] });
|
queryClient.invalidateQueries({ queryKey: ['people'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['connections'] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
|
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['upcoming'] });
|
queryClient.invalidateQueries({ queryKey: ['upcoming'] });
|
||||||
toast.success('Person deleted');
|
toast.success('Person deleted');
|
||||||
@ -364,22 +324,6 @@ export default function PeoplePage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Unlink umbral contact mutation
|
|
||||||
const unlinkMutation = useMutation({
|
|
||||||
mutationFn: async (personId: number) => {
|
|
||||||
const { data } = await api.put(`/people/${personId}/unlink`);
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['people'] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['connections'] });
|
|
||||||
toast.success('Contact unlinked — converted to standard contact');
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
toast.error(getErrorMessage(error, 'Failed to unlink contact'));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Toggle favourite mutation
|
// Toggle favourite mutation
|
||||||
const toggleFavouriteMutation = useMutation({
|
const toggleFavouriteMutation = useMutation({
|
||||||
mutationFn: async (person: Person) => {
|
mutationFn: async (person: Person) => {
|
||||||
@ -403,18 +347,6 @@ export default function PeoplePage() {
|
|||||||
return () => document.removeEventListener('keydown', handler);
|
return () => document.removeEventListener('keydown', handler);
|
||||||
}, [panelOpen]);
|
}, [panelOpen]);
|
||||||
|
|
||||||
// Close add dropdown on outside click
|
|
||||||
useEffect(() => {
|
|
||||||
if (!showAddDropdown) return;
|
|
||||||
const handler = (e: MouseEvent) => {
|
|
||||||
if (addDropdownRef.current && !addDropdownRef.current.contains(e.target as Node)) {
|
|
||||||
setShowAddDropdown(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener('mousedown', handler);
|
|
||||||
return () => document.removeEventListener('mousedown', handler);
|
|
||||||
}, [showAddDropdown]);
|
|
||||||
|
|
||||||
const handleCloseForm = () => {
|
const handleCloseForm = () => {
|
||||||
setShowForm(false);
|
setShowForm(false);
|
||||||
setEditingPerson(null);
|
setEditingPerson(null);
|
||||||
@ -431,75 +363,17 @@ export default function PeoplePage() {
|
|||||||
{getInitials(initialsName)}
|
{getInitials(initialsName)}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<h3 className="font-heading text-lg font-semibold truncate">{p.name}</h3>
|
||||||
<h3 className="font-heading text-lg font-semibold truncate">{
|
{p.category && (
|
||||||
p.is_umbral_contact && p.shared_fields
|
<span className="text-xs text-muted-foreground">{p.category}</span>
|
||||||
? [sf(p, 'first_name'), sf(p, 'last_name')].filter(Boolean).join(' ') || p.name
|
)}
|
||||||
: p.name
|
|
||||||
}</h3>
|
|
||||||
{p.is_umbral_contact && (
|
|
||||||
<Ghost className="h-4 w-4 text-violet-400 shrink-0" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{p.is_umbral_contact && p.shared_fields?.umbral_name ? (
|
|
||||||
<span className="text-xs text-violet-400/80 font-normal">
|
|
||||||
@{String(p.shared_fields.umbral_name)}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
{p.category && (
|
|
||||||
<span className="text-xs text-muted-foreground">{p.category}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Shared field key mapping (panel key -> shared_fields key)
|
// Panel getValue
|
||||||
const sharedKeyMap: Record<string, string> = {
|
|
||||||
preferred_name: 'preferred_name',
|
|
||||||
email: 'email',
|
|
||||||
phone: 'phone',
|
|
||||||
mobile: 'mobile',
|
|
||||||
birthday_display: 'birthday',
|
|
||||||
address: 'address',
|
|
||||||
company: 'company',
|
|
||||||
job_title: 'job_title',
|
|
||||||
};
|
|
||||||
|
|
||||||
// Build dynamic panel fields with synced labels for shared fields
|
|
||||||
const dynamicPanelFields = useMemo((): PanelField[] => {
|
|
||||||
if (!selectedPerson?.is_umbral_contact || !selectedPerson.shared_fields) return panelFields;
|
|
||||||
const shared = selectedPerson.shared_fields;
|
|
||||||
return panelFields.map((f) => {
|
|
||||||
const sharedKey = sharedKeyMap[f.key];
|
|
||||||
if (sharedKey && sharedKey in shared) {
|
|
||||||
return { ...f, label: `${f.label} (synced)` };
|
|
||||||
}
|
|
||||||
return f;
|
|
||||||
});
|
|
||||||
}, [selectedPerson]);
|
|
||||||
|
|
||||||
// Panel getValue — overlays shared fields from connected user
|
|
||||||
const getPanelValue = (p: Person, key: string): string | undefined => {
|
const getPanelValue = (p: Person, key: string): string | undefined => {
|
||||||
// Check shared fields first for umbral contacts
|
|
||||||
if (p.is_umbral_contact && p.shared_fields) {
|
|
||||||
const sharedKey = sharedKeyMap[key];
|
|
||||||
if (sharedKey && sharedKey in p.shared_fields) {
|
|
||||||
const sharedVal = p.shared_fields[sharedKey];
|
|
||||||
if (key === 'birthday_display' && sharedVal) {
|
|
||||||
const bd = String(sharedVal);
|
|
||||||
try {
|
|
||||||
const age = differenceInYears(new Date(), parseISO(bd));
|
|
||||||
return `${format(parseISO(bd), 'MMM d, yyyy')} (${age})`;
|
|
||||||
} catch {
|
|
||||||
return bd;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return sharedVal != null ? String(sharedVal) : undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (key === 'birthday_display' && p.birthday) {
|
if (key === 'birthday_display' && p.birthday) {
|
||||||
const age = differenceInYears(new Date(), parseISO(p.birthday));
|
const age = differenceInYears(new Date(), parseISO(p.birthday));
|
||||||
return `${format(parseISO(p.birthday), 'MMM d, yyyy')} (${age})`;
|
return `${format(parseISO(p.birthday), 'MMM d, yyyy')} (${age})`;
|
||||||
@ -511,7 +385,7 @@ export default function PeoplePage() {
|
|||||||
const renderPanel = () => (
|
const renderPanel = () => (
|
||||||
<EntityDetailPanel<Person>
|
<EntityDetailPanel<Person>
|
||||||
item={selectedPerson}
|
item={selectedPerson}
|
||||||
fields={dynamicPanelFields}
|
fields={panelFields}
|
||||||
onEdit={() => {
|
onEdit={() => {
|
||||||
setEditingPerson(selectedPerson);
|
setEditingPerson(selectedPerson);
|
||||||
setShowForm(true);
|
setShowForm(true);
|
||||||
@ -525,30 +399,6 @@ export default function PeoplePage() {
|
|||||||
isFavourite={selectedPerson?.is_favourite}
|
isFavourite={selectedPerson?.is_favourite}
|
||||||
onToggleFavourite={() => selectedPerson && toggleFavouriteMutation.mutate(selectedPerson)}
|
onToggleFavourite={() => selectedPerson && toggleFavouriteMutation.mutate(selectedPerson)}
|
||||||
favouriteLabel="favourite"
|
favouriteLabel="favourite"
|
||||||
extraActions={(p) =>
|
|
||||||
p.is_umbral_contact ? (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => unlinkMutation.mutate(p.id)}
|
|
||||||
disabled={unlinkMutation.isPending}
|
|
||||||
className="h-7 text-[11px] text-muted-foreground hover:text-foreground gap-1"
|
|
||||||
>
|
|
||||||
<Unlink className="h-3 w-3" />
|
|
||||||
Unlink
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setLinkPersonId(p.id)}
|
|
||||||
className="h-7 text-[11px] text-muted-foreground hover:text-foreground gap-1"
|
|
||||||
>
|
|
||||||
<Link2 className="h-3 w-3" />
|
|
||||||
Link
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -570,53 +420,12 @@ export default function PeoplePage() {
|
|||||||
onReorderCategories={reorderCategories}
|
onReorderCategories={reorderCategories}
|
||||||
searchValue={search}
|
searchValue={search}
|
||||||
onSearchChange={setSearch}
|
onSearchChange={setSearch}
|
||||||
extraPinnedFilters={[
|
|
||||||
{
|
|
||||||
label: 'Umbral',
|
|
||||||
isActive: showUmbralOnly,
|
|
||||||
onToggle: () => setShowUmbralOnly((p) => !p),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative" ref={addDropdownRef}>
|
<Button onClick={() => setShowForm(true)} size="sm" aria-label="Add person">
|
||||||
<div className="flex">
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
<Button
|
Add Person
|
||||||
onClick={() => setShowForm(true)}
|
</Button>
|
||||||
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>
|
||||||
|
|
||||||
<div className="flex-1 overflow-hidden flex flex-col">
|
<div className="flex-1 overflow-hidden flex flex-col">
|
||||||
@ -663,40 +472,6 @@ export default function PeoplePage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Pending requests */}
|
|
||||||
{hasRequests && (
|
|
||||||
<div className="px-6 pb-3">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<span className="text-xs text-muted-foreground uppercase tracking-wider font-medium">
|
|
||||||
Pending Requests
|
|
||||||
</span>
|
|
||||||
<span className="text-[10px] tabular-nums bg-accent/15 text-accent px-1.5 py-0.5 rounded-full font-medium">
|
|
||||||
{incomingRequests.length + outgoingRequests.length}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{incomingRequests.length > 0 && outgoingRequests.length > 0 && (
|
|
||||||
<p className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider">Incoming</p>
|
|
||||||
)}
|
|
||||||
{incomingRequests.slice(0, 5).map((req) => (
|
|
||||||
<ConnectionRequestCard key={req.id} request={req} direction="incoming" />
|
|
||||||
))}
|
|
||||||
{incomingRequests.length > 5 && (
|
|
||||||
<p className="text-xs text-muted-foreground">+{incomingRequests.length - 5} more</p>
|
|
||||||
)}
|
|
||||||
{incomingRequests.length > 0 && outgoingRequests.length > 0 && (
|
|
||||||
<p className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider mt-3">Outgoing</p>
|
|
||||||
)}
|
|
||||||
{outgoingRequests.slice(0, 5).map((req) => (
|
|
||||||
<ConnectionRequestCard key={req.id} request={req} direction="outgoing" />
|
|
||||||
))}
|
|
||||||
{outgoingRequests.length > 5 && (
|
|
||||||
<p className="text-xs text-muted-foreground">+{outgoingRequests.length - 5} more</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Main content: table + panel */}
|
{/* Main content: table + panel */}
|
||||||
<div className="flex-1 overflow-hidden flex">
|
<div className="flex-1 overflow-hidden flex">
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
@ -783,17 +558,6 @@ export default function PeoplePage() {
|
|||||||
onClose={handleCloseForm}
|
onClose={handleCloseForm}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ConnectionSearch
|
|
||||||
open={showConnectionSearch}
|
|
||||||
onOpenChange={setShowConnectionSearch}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ConnectionSearch
|
|
||||||
open={linkPersonId !== null}
|
|
||||||
onOpenChange={(open) => { if (!open) setLinkPersonId(null); }}
|
|
||||||
personId={linkPersonId ?? undefined}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { useState, useMemo, FormEvent } from 'react';
|
import { useState, useMemo, FormEvent } from 'react';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Star, StarOff, X, Lock } from 'lucide-react';
|
import { Star, StarOff, X } from 'lucide-react';
|
||||||
import { parseISO, differenceInYears } from 'date-fns';
|
import { parseISO, differenceInYears } from 'date-fns';
|
||||||
import api, { getErrorMessage } from '@/lib/api';
|
import api, { getErrorMessage } from '@/lib/api';
|
||||||
import type { Person } from '@/types';
|
import type { Person } from '@/types';
|
||||||
@ -30,11 +30,6 @@ interface PersonFormProps {
|
|||||||
export default function PersonForm({ person, categories, onClose }: PersonFormProps) {
|
export default function PersonForm({ person, categories, onClose }: PersonFormProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
// Helper to resolve a field value — prefer shared_fields for umbral contacts
|
|
||||||
const sf = person?.shared_fields;
|
|
||||||
const shared = (key: string, fallback: string) =>
|
|
||||||
sf && key in sf && sf[key] != null ? String(sf[key]) : fallback;
|
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
first_name:
|
first_name:
|
||||||
person?.first_name ||
|
person?.first_name ||
|
||||||
@ -43,24 +38,20 @@ export default function PersonForm({ person, categories, onClose }: PersonFormPr
|
|||||||
person?.last_name ||
|
person?.last_name ||
|
||||||
(person?.name ? splitName(person.name).lastName : ''),
|
(person?.name ? splitName(person.name).lastName : ''),
|
||||||
nickname: person?.nickname || '',
|
nickname: person?.nickname || '',
|
||||||
email: shared('email', person?.email || ''),
|
email: person?.email || '',
|
||||||
phone: shared('phone', person?.phone || ''),
|
phone: person?.phone || '',
|
||||||
mobile: shared('mobile', person?.mobile || ''),
|
mobile: person?.mobile || '',
|
||||||
address: shared('address', person?.address || ''),
|
address: person?.address || '',
|
||||||
birthday: shared('birthday', person?.birthday ? person.birthday.slice(0, 10) : ''),
|
birthday: person?.birthday
|
||||||
|
? person.birthday.slice(0, 10)
|
||||||
|
: '',
|
||||||
category: person?.category || '',
|
category: person?.category || '',
|
||||||
is_favourite: person?.is_favourite ?? false,
|
is_favourite: person?.is_favourite ?? false,
|
||||||
company: shared('company', person?.company || ''),
|
company: person?.company || '',
|
||||||
job_title: shared('job_title', person?.job_title || ''),
|
job_title: person?.job_title || '',
|
||||||
notes: person?.notes || '',
|
notes: person?.notes || '',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check if a field is synced from an umbral connection (read-only)
|
|
||||||
const isShared = (fieldKey: string): boolean => {
|
|
||||||
if (!person?.is_umbral_contact || !person.shared_fields) return false;
|
|
||||||
return fieldKey in person.shared_fields;
|
|
||||||
};
|
|
||||||
|
|
||||||
const age = useMemo(() => {
|
const age = useMemo(() => {
|
||||||
if (!formData.birthday) return null;
|
if (!formData.birthday) return null;
|
||||||
try {
|
try {
|
||||||
@ -174,25 +165,13 @@ export default function PersonForm({ person, categories, onClose }: PersonFormPr
|
|||||||
{/* Row 4: Birthday + Age */}
|
{/* Row 4: Birthday + Age */}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="birthday" className="flex items-center gap-1">
|
<Label htmlFor="birthday">Birthday</Label>
|
||||||
Birthday
|
|
||||||
{isShared('birthday') && <Lock className="h-3 w-3 text-violet-400" />}
|
|
||||||
</Label>
|
|
||||||
{isShared('birthday') ? (
|
|
||||||
<Input
|
|
||||||
id="birthday"
|
|
||||||
value={formData.birthday}
|
|
||||||
disabled
|
|
||||||
className="opacity-70 cursor-not-allowed"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<DatePicker
|
<DatePicker
|
||||||
variant="input"
|
variant="input"
|
||||||
id="birthday"
|
id="birthday"
|
||||||
value={formData.birthday}
|
value={formData.birthday}
|
||||||
onChange={(v) => set('birthday', v)}
|
onChange={(v) => set('birthday', v)}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="age">Age</Label>
|
<Label htmlFor="age">Age</Label>
|
||||||
@ -221,102 +200,65 @@ export default function PersonForm({ person, categories, onClose }: PersonFormPr
|
|||||||
{/* Row 6: Mobile + Email */}
|
{/* Row 6: Mobile + Email */}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="mobile" className="flex items-center gap-1">
|
<Label htmlFor="mobile">Mobile</Label>
|
||||||
Mobile
|
|
||||||
{isShared('mobile') && <Lock className="h-3 w-3 text-violet-400" />}
|
|
||||||
</Label>
|
|
||||||
<Input
|
<Input
|
||||||
id="mobile"
|
id="mobile"
|
||||||
type="tel"
|
type="tel"
|
||||||
value={formData.mobile}
|
value={formData.mobile}
|
||||||
onChange={(e) => set('mobile', e.target.value)}
|
onChange={(e) => set('mobile', e.target.value)}
|
||||||
disabled={isShared('mobile')}
|
|
||||||
className={isShared('mobile') ? 'opacity-70 cursor-not-allowed' : ''}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="email" className="flex items-center gap-1">
|
<Label htmlFor="email">Email</Label>
|
||||||
Email
|
|
||||||
{isShared('email') && <Lock className="h-3 w-3 text-violet-400" />}
|
|
||||||
</Label>
|
|
||||||
<Input
|
<Input
|
||||||
id="email"
|
id="email"
|
||||||
type="email"
|
type="email"
|
||||||
value={formData.email}
|
value={formData.email}
|
||||||
onChange={(e) => set('email', e.target.value)}
|
onChange={(e) => set('email', e.target.value)}
|
||||||
disabled={isShared('email')}
|
|
||||||
className={isShared('email') ? 'opacity-70 cursor-not-allowed' : ''}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Row 7: Phone */}
|
{/* Row 7: Phone */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="phone" className="flex items-center gap-1">
|
<Label htmlFor="phone">Phone</Label>
|
||||||
Phone
|
|
||||||
{isShared('phone') && <Lock className="h-3 w-3 text-violet-400" />}
|
|
||||||
</Label>
|
|
||||||
<Input
|
<Input
|
||||||
id="phone"
|
id="phone"
|
||||||
type="tel"
|
type="tel"
|
||||||
value={formData.phone}
|
value={formData.phone}
|
||||||
onChange={(e) => set('phone', e.target.value)}
|
onChange={(e) => set('phone', e.target.value)}
|
||||||
placeholder="Landline / work number"
|
placeholder="Landline / work number"
|
||||||
disabled={isShared('phone')}
|
|
||||||
className={isShared('phone') ? 'opacity-70 cursor-not-allowed' : ''}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Row 8: Address */}
|
{/* Row 8: Address */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="address" className="flex items-center gap-1">
|
<Label htmlFor="address">Address</Label>
|
||||||
Address
|
<LocationPicker
|
||||||
{isShared('address') && <Lock className="h-3 w-3 text-violet-400" />}
|
id="address"
|
||||||
</Label>
|
value={formData.address}
|
||||||
{isShared('address') ? (
|
onChange={(val) => set('address', val)}
|
||||||
<Input
|
onSelect={(result) => set('address', result.address || result.name)}
|
||||||
id="address"
|
placeholder="Search or enter address..."
|
||||||
value={formData.address}
|
/>
|
||||||
disabled
|
|
||||||
className="opacity-70 cursor-not-allowed"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<LocationPicker
|
|
||||||
id="address"
|
|
||||||
value={formData.address}
|
|
||||||
onChange={(val) => set('address', val)}
|
|
||||||
onSelect={(result) => set('address', result.address || result.name)}
|
|
||||||
placeholder="Search or enter address..."
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Row 9: Company + Job Title */}
|
{/* Row 9: Company + Job Title */}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="company" className="flex items-center gap-1">
|
<Label htmlFor="company">Company</Label>
|
||||||
Company
|
|
||||||
{isShared('company') && <Lock className="h-3 w-3 text-violet-400" />}
|
|
||||||
</Label>
|
|
||||||
<Input
|
<Input
|
||||||
id="company"
|
id="company"
|
||||||
value={formData.company}
|
value={formData.company}
|
||||||
onChange={(e) => set('company', e.target.value)}
|
onChange={(e) => set('company', e.target.value)}
|
||||||
disabled={isShared('company')}
|
|
||||||
className={isShared('company') ? 'opacity-70 cursor-not-allowed' : ''}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="job_title" className="flex items-center gap-1">
|
<Label htmlFor="job_title">Job Title</Label>
|
||||||
Job Title
|
|
||||||
{isShared('job_title') && <Lock className="h-3 w-3 text-violet-400" />}
|
|
||||||
</Label>
|
|
||||||
<Input
|
<Input
|
||||||
id="job_title"
|
id="job_title"
|
||||||
value={formData.job_title}
|
value={formData.job_title}
|
||||||
onChange={(e) => set('job_title', e.target.value)}
|
onChange={(e) => set('job_title', e.target.value)}
|
||||||
disabled={isShared('job_title')}
|
|
||||||
className={isShared('job_title') ? 'opacity-70 cursor-not-allowed' : ''}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -14,7 +14,6 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
Shield,
|
Shield,
|
||||||
Blocks,
|
Blocks,
|
||||||
Ghost,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useSettings } from '@/hooks/useSettings';
|
import { useSettings } from '@/hooks/useSettings';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
@ -25,7 +24,6 @@ import { cn } from '@/lib/utils';
|
|||||||
import api from '@/lib/api';
|
import api from '@/lib/api';
|
||||||
import type { GeoLocation, UserProfile } from '@/types';
|
import type { GeoLocation, UserProfile } from '@/types';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import CopyableField from '@/components/shared/CopyableField';
|
|
||||||
import TotpSetupSection from './TotpSetupSection';
|
import TotpSetupSection from './TotpSetupSection';
|
||||||
import NtfySettingsSection from './NtfySettingsSection';
|
import NtfySettingsSection from './NtfySettingsSection';
|
||||||
|
|
||||||
@ -57,26 +55,6 @@ export default function SettingsPage() {
|
|||||||
const [autoLockEnabled, setAutoLockEnabled] = useState(settings?.auto_lock_enabled ?? false);
|
const [autoLockEnabled, setAutoLockEnabled] = useState(settings?.auto_lock_enabled ?? false);
|
||||||
const [autoLockMinutes, setAutoLockMinutes] = useState<number | string>(settings?.auto_lock_minutes ?? 5);
|
const [autoLockMinutes, setAutoLockMinutes] = useState<number | string>(settings?.auto_lock_minutes ?? 5);
|
||||||
|
|
||||||
// Profile extension fields (stored on Settings model)
|
|
||||||
const [settingsPhone, setSettingsPhone] = useState(settings?.phone ?? '');
|
|
||||||
const [settingsMobile, setSettingsMobile] = useState(settings?.mobile ?? '');
|
|
||||||
const [settingsAddress, setSettingsAddress] = useState(settings?.address ?? '');
|
|
||||||
const [settingsCompany, setSettingsCompany] = useState(settings?.company ?? '');
|
|
||||||
const [settingsJobTitle, setSettingsJobTitle] = useState(settings?.job_title ?? '');
|
|
||||||
|
|
||||||
// Social settings
|
|
||||||
const [acceptConnections, setAcceptConnections] = useState(settings?.accept_connections ?? false);
|
|
||||||
const [shareFirstName, setShareFirstName] = useState(settings?.share_first_name ?? false);
|
|
||||||
const [shareLastName, setShareLastName] = useState(settings?.share_last_name ?? false);
|
|
||||||
const [sharePreferredName, setSharePreferredName] = useState(settings?.share_preferred_name ?? true);
|
|
||||||
const [shareEmail, setShareEmail] = useState(settings?.share_email ?? false);
|
|
||||||
const [sharePhone, setSharePhone] = useState(settings?.share_phone ?? false);
|
|
||||||
const [shareMobile, setShareMobile] = useState(settings?.share_mobile ?? false);
|
|
||||||
const [shareBirthday, setShareBirthday] = useState(settings?.share_birthday ?? false);
|
|
||||||
const [shareAddress, setShareAddress] = useState(settings?.share_address ?? false);
|
|
||||||
const [shareCompany, setShareCompany] = useState(settings?.share_company ?? false);
|
|
||||||
const [shareJobTitle, setShareJobTitle] = useState(settings?.share_job_title ?? false);
|
|
||||||
|
|
||||||
// Profile fields (stored on User model, fetched from /auth/profile)
|
// Profile fields (stored on User model, fetched from /auth/profile)
|
||||||
const profileQuery = useQuery({
|
const profileQuery = useQuery({
|
||||||
queryKey: ['profile'],
|
queryKey: ['profile'],
|
||||||
@ -90,8 +68,6 @@ export default function SettingsPage() {
|
|||||||
const [profileEmail, setProfileEmail] = useState('');
|
const [profileEmail, setProfileEmail] = useState('');
|
||||||
const [dateOfBirth, setDateOfBirth] = useState('');
|
const [dateOfBirth, setDateOfBirth] = useState('');
|
||||||
const [emailError, setEmailError] = useState<string | null>(null);
|
const [emailError, setEmailError] = useState<string | null>(null);
|
||||||
const [umbralName, setUmbralName] = useState('');
|
|
||||||
const [umbralNameError, setUmbralNameError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (profileQuery.data) {
|
if (profileQuery.data) {
|
||||||
@ -99,7 +75,6 @@ export default function SettingsPage() {
|
|||||||
setLastName(profileQuery.data.last_name ?? '');
|
setLastName(profileQuery.data.last_name ?? '');
|
||||||
setProfileEmail(profileQuery.data.email ?? '');
|
setProfileEmail(profileQuery.data.email ?? '');
|
||||||
setDateOfBirth(profileQuery.data.date_of_birth ?? '');
|
setDateOfBirth(profileQuery.data.date_of_birth ?? '');
|
||||||
setUmbralName(profileQuery.data.umbral_name ?? '');
|
|
||||||
}
|
}
|
||||||
}, [profileQuery.dataUpdatedAt]);
|
}, [profileQuery.dataUpdatedAt]);
|
||||||
|
|
||||||
@ -112,22 +87,6 @@ export default function SettingsPage() {
|
|||||||
setFirstDayOfWeek(settings.first_day_of_week);
|
setFirstDayOfWeek(settings.first_day_of_week);
|
||||||
setAutoLockEnabled(settings.auto_lock_enabled);
|
setAutoLockEnabled(settings.auto_lock_enabled);
|
||||||
setAutoLockMinutes(settings.auto_lock_minutes ?? 5);
|
setAutoLockMinutes(settings.auto_lock_minutes ?? 5);
|
||||||
setSettingsPhone(settings.phone ?? '');
|
|
||||||
setSettingsMobile(settings.mobile ?? '');
|
|
||||||
setSettingsAddress(settings.address ?? '');
|
|
||||||
setSettingsCompany(settings.company ?? '');
|
|
||||||
setSettingsJobTitle(settings.job_title ?? '');
|
|
||||||
setAcceptConnections(settings.accept_connections);
|
|
||||||
setShareFirstName(settings.share_first_name);
|
|
||||||
setShareLastName(settings.share_last_name);
|
|
||||||
setSharePreferredName(settings.share_preferred_name);
|
|
||||||
setShareEmail(settings.share_email);
|
|
||||||
setSharePhone(settings.share_phone);
|
|
||||||
setShareMobile(settings.share_mobile);
|
|
||||||
setShareBirthday(settings.share_birthday);
|
|
||||||
setShareAddress(settings.share_address);
|
|
||||||
setShareCompany(settings.share_company);
|
|
||||||
setShareJobTitle(settings.share_job_title);
|
|
||||||
}
|
}
|
||||||
}, [settings?.id]); // only re-sync on initial load (settings.id won't change)
|
}, [settings?.id]); // only re-sync on initial load (settings.id won't change)
|
||||||
|
|
||||||
@ -214,8 +173,8 @@ export default function SettingsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleProfileSave = async (field: 'first_name' | 'last_name' | 'email' | 'date_of_birth' | 'umbral_name') => {
|
const handleProfileSave = async (field: 'first_name' | 'last_name' | 'email' | 'date_of_birth') => {
|
||||||
const values: Record<string, string> = { first_name: firstName, last_name: lastName, email: profileEmail, date_of_birth: dateOfBirth, umbral_name: umbralName };
|
const values: Record<string, string> = { first_name: firstName, last_name: lastName, email: profileEmail, date_of_birth: dateOfBirth };
|
||||||
const current = values[field].trim();
|
const current = values[field].trim();
|
||||||
const original = profileQuery.data?.[field] ?? '';
|
const original = profileQuery.data?.[field] ?? '';
|
||||||
if (current === (original || '')) return;
|
if (current === (original || '')) return;
|
||||||
@ -229,19 +188,6 @@ export default function SettingsPage() {
|
|||||||
}
|
}
|
||||||
setEmailError(null);
|
setEmailError(null);
|
||||||
|
|
||||||
// Client-side umbral name validation
|
|
||||||
if (field === 'umbral_name') {
|
|
||||||
if (current.includes(' ')) {
|
|
||||||
setUmbralNameError('Must be a single word with no spaces');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!current || !/^[a-zA-Z0-9_-]{3,50}$/.test(current)) {
|
|
||||||
setUmbralNameError('3-50 characters: letters, numbers, hyphens, underscores');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setUmbralNameError(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.put('/auth/profile', { [field]: current || null });
|
await api.put('/auth/profile', { [field]: current || null });
|
||||||
queryClient.invalidateQueries({ queryKey: ['profile'] });
|
queryClient.invalidateQueries({ queryKey: ['profile'] });
|
||||||
@ -250,8 +196,6 @@ export default function SettingsPage() {
|
|||||||
const detail = err?.response?.data?.detail;
|
const detail = err?.response?.data?.detail;
|
||||||
if (field === 'email' && detail) {
|
if (field === 'email' && detail) {
|
||||||
setEmailError(typeof detail === 'string' ? detail : 'Failed to update email');
|
setEmailError(typeof detail === 'string' ? detail : 'Failed to update email');
|
||||||
} else if (field === 'umbral_name' && detail) {
|
|
||||||
setUmbralNameError(typeof detail === 'string' ? detail : 'Failed to update umbral name');
|
|
||||||
} else {
|
} else {
|
||||||
toast.error(typeof detail === 'string' ? detail : 'Failed to update profile');
|
toast.error(typeof detail === 'string' ? detail : 'Failed to update profile');
|
||||||
}
|
}
|
||||||
@ -304,29 +248,6 @@ export default function SettingsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSettingsFieldSave = async (field: string, value: string) => {
|
|
||||||
const trimmed = value.trim();
|
|
||||||
const currentVal = (settings as any)?.[field] || '';
|
|
||||||
if (trimmed === (currentVal || '')) return;
|
|
||||||
try {
|
|
||||||
await updateSettings({ [field]: trimmed || null } as any);
|
|
||||||
toast.success('Profile updated');
|
|
||||||
} catch {
|
|
||||||
toast.error('Failed to update profile');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSocialToggle = async (field: string, checked: boolean, setter: (v: boolean) => void) => {
|
|
||||||
const previous = (settings as any)?.[field];
|
|
||||||
setter(checked);
|
|
||||||
try {
|
|
||||||
await updateSettings({ [field]: checked } as any);
|
|
||||||
} catch {
|
|
||||||
setter(previous);
|
|
||||||
toast.error('Failed to update setting');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAutoLockMinutesSave = async () => {
|
const handleAutoLockMinutesSave = async () => {
|
||||||
const raw = typeof autoLockMinutes === 'string' ? parseInt(autoLockMinutes) : autoLockMinutes;
|
const raw = typeof autoLockMinutes === 'string' ? parseInt(autoLockMinutes) : autoLockMinutes;
|
||||||
const clamped = Math.max(1, Math.min(60, isNaN(raw) ? 5 : raw));
|
const clamped = Math.max(1, Math.min(60, isNaN(raw) ? 5 : raw));
|
||||||
@ -442,75 +363,6 @@ export default function SettingsPage() {
|
|||||||
onKeyDown={(e) => { if (e.key === 'Enter') handleProfileSave('date_of_birth'); }}
|
onKeyDown={(e) => { if (e.key === 'Enter') handleProfileSave('date_of_birth'); }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="settings_phone">Phone</Label>
|
|
||||||
<Input
|
|
||||||
id="settings_phone"
|
|
||||||
type="tel"
|
|
||||||
placeholder="Phone number"
|
|
||||||
value={settingsPhone}
|
|
||||||
onChange={(e) => setSettingsPhone(e.target.value)}
|
|
||||||
onBlur={() => handleSettingsFieldSave('phone', settingsPhone)}
|
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') handleSettingsFieldSave('phone', settingsPhone); }}
|
|
||||||
maxLength={50}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="settings_mobile">Mobile</Label>
|
|
||||||
<Input
|
|
||||||
id="settings_mobile"
|
|
||||||
type="tel"
|
|
||||||
placeholder="Mobile number"
|
|
||||||
value={settingsMobile}
|
|
||||||
onChange={(e) => setSettingsMobile(e.target.value)}
|
|
||||||
onBlur={() => handleSettingsFieldSave('mobile', settingsMobile)}
|
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') handleSettingsFieldSave('mobile', settingsMobile); }}
|
|
||||||
maxLength={50}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="settings_address">Address</Label>
|
|
||||||
<Input
|
|
||||||
id="settings_address"
|
|
||||||
type="text"
|
|
||||||
placeholder="Your address"
|
|
||||||
value={settingsAddress}
|
|
||||||
onChange={(e) => setSettingsAddress(e.target.value)}
|
|
||||||
onBlur={() => handleSettingsFieldSave('address', settingsAddress)}
|
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') handleSettingsFieldSave('address', settingsAddress); }}
|
|
||||||
maxLength={2000}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="settings_company">Company</Label>
|
|
||||||
<Input
|
|
||||||
id="settings_company"
|
|
||||||
type="text"
|
|
||||||
placeholder="Company name"
|
|
||||||
value={settingsCompany}
|
|
||||||
onChange={(e) => setSettingsCompany(e.target.value)}
|
|
||||||
onBlur={() => handleSettingsFieldSave('company', settingsCompany)}
|
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') handleSettingsFieldSave('company', settingsCompany); }}
|
|
||||||
maxLength={255}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="settings_job_title">Job Title</Label>
|
|
||||||
<Input
|
|
||||||
id="settings_job_title"
|
|
||||||
type="text"
|
|
||||||
placeholder="Your role"
|
|
||||||
value={settingsJobTitle}
|
|
||||||
onChange={(e) => setSettingsJobTitle(e.target.value)}
|
|
||||||
onBlur={() => handleSettingsFieldSave('job_title', settingsJobTitle)}
|
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') handleSettingsFieldSave('job_title', settingsJobTitle); }}
|
|
||||||
maxLength={255}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@ -734,88 +586,9 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Right column: Social, Security, Authentication, Integrations ── */}
|
{/* ── Right column: Security, Authentication, Integrations ── */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|
||||||
{/* Social */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="p-1.5 rounded-md bg-violet-500/10">
|
|
||||||
<Ghost className="h-4 w-4 text-violet-400" aria-hidden="true" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<CardTitle>Social</CardTitle>
|
|
||||||
<CardDescription>Manage your Umbra identity and connections</CardDescription>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="umbral_name">Umbral Name</Label>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Input
|
|
||||||
id="umbral_name"
|
|
||||||
value={umbralName}
|
|
||||||
onChange={(e) => { setUmbralName(e.target.value); setUmbralNameError(null); }}
|
|
||||||
onBlur={() => handleProfileSave('umbral_name')}
|
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') handleProfileSave('umbral_name'); }}
|
|
||||||
maxLength={50}
|
|
||||||
placeholder="Your discoverable name"
|
|
||||||
className={umbralNameError ? 'border-red-500/50' : ''}
|
|
||||||
/>
|
|
||||||
<CopyableField value={umbralName} label="Umbral name" />
|
|
||||||
</div>
|
|
||||||
{umbralNameError ? (
|
|
||||||
<p className="text-xs text-red-400">{umbralNameError}</p>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
How other Umbra users find you
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<Label>Accept Connections</Label>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Allow other users to find and connect with you
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
checked={acceptConnections}
|
|
||||||
onCheckedChange={(checked) => handleSocialToggle('accept_connections', checked, setAcceptConnections)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="border-t border-border pt-4 mt-4">
|
|
||||||
<p className="text-[11px] uppercase tracking-wider text-muted-foreground mb-3">
|
|
||||||
Sharing Defaults
|
|
||||||
</p>
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
{[
|
|
||||||
{ field: 'share_first_name', label: 'First Name', state: shareFirstName, setter: setShareFirstName },
|
|
||||||
{ field: 'share_last_name', label: 'Last Name', state: shareLastName, setter: setShareLastName },
|
|
||||||
{ field: 'share_preferred_name', label: 'Preferred Name', state: sharePreferredName, setter: setSharePreferredName },
|
|
||||||
{ field: 'share_email', label: 'Email', state: shareEmail, setter: setShareEmail },
|
|
||||||
{ field: 'share_phone', label: 'Phone', state: sharePhone, setter: setSharePhone },
|
|
||||||
{ field: 'share_mobile', label: 'Mobile', state: shareMobile, setter: setShareMobile },
|
|
||||||
{ field: 'share_birthday', label: 'Birthday', state: shareBirthday, setter: setShareBirthday },
|
|
||||||
{ field: 'share_address', label: 'Address', state: shareAddress, setter: setShareAddress },
|
|
||||||
{ field: 'share_company', label: 'Company', state: shareCompany, setter: setShareCompany },
|
|
||||||
{ field: 'share_job_title', label: 'Job Title', state: shareJobTitle, setter: setShareJobTitle },
|
|
||||||
].map(({ field, label, state, setter }) => (
|
|
||||||
<div key={field} className="flex items-center justify-between">
|
|
||||||
<Label className="text-sm font-normal">{label}</Label>
|
|
||||||
<Switch
|
|
||||||
checked={state}
|
|
||||||
onCheckedChange={(checked) => handleSocialToggle(field, checked, setter)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Security (auto-lock) */}
|
{/* Security (auto-lock) */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|||||||
@ -18,12 +18,6 @@ import {
|
|||||||
} from '@dnd-kit/sortable';
|
} from '@dnd-kit/sortable';
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
|
|
||||||
interface ExtraPinnedFilter {
|
|
||||||
label: string;
|
|
||||||
isActive: boolean;
|
|
||||||
onToggle: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CategoryFilterBarProps {
|
interface CategoryFilterBarProps {
|
||||||
activeFilters: string[];
|
activeFilters: string[];
|
||||||
pinnedLabel: string;
|
pinnedLabel: string;
|
||||||
@ -36,7 +30,6 @@ interface CategoryFilterBarProps {
|
|||||||
onReorderCategories?: (order: string[]) => void;
|
onReorderCategories?: (order: string[]) => void;
|
||||||
searchValue: string;
|
searchValue: string;
|
||||||
onSearchChange: (val: string) => void;
|
onSearchChange: (val: string) => void;
|
||||||
extraPinnedFilters?: ExtraPinnedFilter[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const pillBase =
|
const pillBase =
|
||||||
@ -123,7 +116,6 @@ export default function CategoryFilterBar({
|
|||||||
onReorderCategories,
|
onReorderCategories,
|
||||||
searchValue,
|
searchValue,
|
||||||
onSearchChange,
|
onSearchChange,
|
||||||
extraPinnedFilters = [],
|
|
||||||
}: CategoryFilterBarProps) {
|
}: CategoryFilterBarProps) {
|
||||||
const [otherOpen, setOtherOpen] = useState(false);
|
const [otherOpen, setOtherOpen] = useState(false);
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
@ -177,22 +169,6 @@ export default function CategoryFilterBar({
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Extra pinned filters (e.g. "Umbral") */}
|
|
||||||
{extraPinnedFilters.map((epf) => (
|
|
||||||
<button
|
|
||||||
key={epf.label}
|
|
||||||
type="button"
|
|
||||||
onClick={epf.onToggle}
|
|
||||||
aria-label={`Filter by ${epf.label}`}
|
|
||||||
className={pillBase}
|
|
||||||
style={epf.isActive ? activePillStyle : undefined}
|
|
||||||
>
|
|
||||||
<span className={epf.isActive ? '' : 'text-muted-foreground hover:text-foreground'}>
|
|
||||||
{epf.label}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Categories pill + expandable chips */}
|
{/* Categories pill + expandable chips */}
|
||||||
{categories.length > 0 && (
|
{categories.length > 0 && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@ -27,7 +27,6 @@ interface EntityDetailPanelProps<T> {
|
|||||||
isFavourite?: boolean;
|
isFavourite?: boolean;
|
||||||
onToggleFavourite?: () => void;
|
onToggleFavourite?: () => void;
|
||||||
favouriteLabel?: string;
|
favouriteLabel?: string;
|
||||||
extraActions?: (item: T) => React.ReactNode;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EntityDetailPanel<T>({
|
export function EntityDetailPanel<T>({
|
||||||
@ -43,7 +42,6 @@ export function EntityDetailPanel<T>({
|
|||||||
isFavourite,
|
isFavourite,
|
||||||
onToggleFavourite,
|
onToggleFavourite,
|
||||||
favouriteLabel = 'favourite',
|
favouriteLabel = 'favourite',
|
||||||
extraActions,
|
|
||||||
}: EntityDetailPanelProps<T>) {
|
}: EntityDetailPanelProps<T>) {
|
||||||
const { confirming, handleClick: handleDelete } = useConfirmAction(onDelete);
|
const { confirming, handleClick: handleDelete } = useConfirmAction(onDelete);
|
||||||
|
|
||||||
@ -136,10 +134,7 @@ export function EntityDetailPanel<T>({
|
|||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="px-5 py-4 border-t border-border flex items-center justify-between">
|
<div className="px-5 py-4 border-t border-border flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<span className="text-[11px] text-muted-foreground">{formatUpdatedAt(getUpdatedAt(item))}</span>
|
||||||
<span className="text-[11px] text-muted-foreground">{formatUpdatedAt(getUpdatedAt(item))}</span>
|
|
||||||
{extraActions?.(item)}
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@ -1,110 +0,0 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import api from '@/lib/api';
|
|
||||||
import type { Connection, ConnectionRequest, UmbralSearchResponse } from '@/types';
|
|
||||||
|
|
||||||
export function useConnections() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const connectionsQuery = useQuery({
|
|
||||||
queryKey: ['connections'],
|
|
||||||
queryFn: async () => {
|
|
||||||
const { data } = await api.get<Connection[]>('/connections');
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const incomingQuery = useQuery({
|
|
||||||
queryKey: ['connections', 'incoming'],
|
|
||||||
queryFn: async () => {
|
|
||||||
const { data } = await api.get<ConnectionRequest[]>('/connections/requests/incoming');
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
refetchOnMount: 'always',
|
|
||||||
});
|
|
||||||
|
|
||||||
const outgoingQuery = useQuery({
|
|
||||||
queryKey: ['connections', 'outgoing'],
|
|
||||||
queryFn: async () => {
|
|
||||||
const { data } = await api.get<ConnectionRequest[]>('/connections/requests/outgoing');
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const searchMutation = useMutation({
|
|
||||||
mutationFn: async (umbralName: string) => {
|
|
||||||
const { data } = await api.post<UmbralSearchResponse>('/connections/search', {
|
|
||||||
umbral_name: umbralName,
|
|
||||||
});
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const sendRequestMutation = useMutation({
|
|
||||||
mutationFn: async (params: { umbralName: string; personId?: number }) => {
|
|
||||||
const { data } = await api.post('/connections/request', {
|
|
||||||
umbral_name: params.umbralName,
|
|
||||||
...(params.personId != null && { person_id: params.personId }),
|
|
||||||
});
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
// Fire-and-forget — don't block mutateAsync on query refetches
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['connections'] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const respondMutation = useMutation({
|
|
||||||
mutationFn: async ({ requestId, action }: { requestId: number; action: 'accept' | 'reject' }) => {
|
|
||||||
const { data } = await api.put(`/connections/requests/${requestId}/respond`, { action });
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
onSuccess: (_, variables) => {
|
|
||||||
// Dismiss any lingering Sonner toast for this request
|
|
||||||
toast.dismiss(`connection-request-${variables.requestId}`);
|
|
||||||
// Fire-and-forget — errors here must not surface as mutation failures
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['connections'] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['people'] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const cancelMutation = useMutation({
|
|
||||||
mutationFn: async (requestId: number) => {
|
|
||||||
const { data } = await api.put(`/connections/requests/${requestId}/cancel`);
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
// Fire-and-forget — don't block mutateAsync on query refetches
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['connections'] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const removeConnectionMutation = useMutation({
|
|
||||||
mutationFn: async (connectionId: number) => {
|
|
||||||
await api.delete(`/connections/${connectionId}`);
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['connections'] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['people'] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
connections: connectionsQuery.data ?? [],
|
|
||||||
incomingRequests: incomingQuery.data ?? [],
|
|
||||||
outgoingRequests: outgoingQuery.data ?? [],
|
|
||||||
isLoading: connectionsQuery.isLoading,
|
|
||||||
isLoadingIncoming: incomingQuery.isLoading,
|
|
||||||
search: searchMutation.mutateAsync,
|
|
||||||
isSearching: searchMutation.isPending,
|
|
||||||
sendRequest: sendRequestMutation.mutateAsync,
|
|
||||||
isSending: sendRequestMutation.isPending,
|
|
||||||
respond: respondMutation.mutateAsync,
|
|
||||||
isResponding: respondMutation.isPending,
|
|
||||||
cancelRequest: cancelMutation.mutateAsync,
|
|
||||||
isCancelling: cancelMutation.isPending,
|
|
||||||
removeConnection: removeConnectionMutation.mutateAsync,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,113 +0,0 @@
|
|||||||
import { createContext, useContext, type ReactNode } from 'react';
|
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
||||||
import { useEffect, useRef, createElement } from 'react';
|
|
||||||
import api from '@/lib/api';
|
|
||||||
import type { NotificationListResponse } from '@/types';
|
|
||||||
|
|
||||||
interface NotificationContextValue {
|
|
||||||
unreadCount: number;
|
|
||||||
notifications: NotificationListResponse['notifications'];
|
|
||||||
total: number;
|
|
||||||
isLoading: boolean;
|
|
||||||
markRead: (ids: number[]) => Promise<void>;
|
|
||||||
markAllRead: () => Promise<void>;
|
|
||||||
deleteNotification: (id: number) => Promise<void>;
|
|
||||||
refreshNotifications: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const NotificationContext = createContext<NotificationContextValue>({
|
|
||||||
unreadCount: 0,
|
|
||||||
notifications: [],
|
|
||||||
total: 0,
|
|
||||||
isLoading: true,
|
|
||||||
markRead: async () => {},
|
|
||||||
markAllRead: async () => {},
|
|
||||||
deleteNotification: async () => {},
|
|
||||||
refreshNotifications: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
export function useNotifications() {
|
|
||||||
return useContext(NotificationContext);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function NotificationProvider({ children }: { children: ReactNode }) {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const visibleRef = useRef(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handler = () => {
|
|
||||||
visibleRef.current = document.visibilityState === 'visible';
|
|
||||||
};
|
|
||||||
document.addEventListener('visibilitychange', handler);
|
|
||||||
return () => document.removeEventListener('visibilitychange', handler);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const unreadQuery = useQuery({
|
|
||||||
queryKey: ['notifications', 'unread-count'],
|
|
||||||
queryFn: async () => {
|
|
||||||
const { data } = await api.get<{ count: number }>('/notifications/unread-count');
|
|
||||||
return data.count;
|
|
||||||
},
|
|
||||||
refetchInterval: 15_000,
|
|
||||||
// Required: toast notifications depend on background polling to detect new
|
|
||||||
// notifications when the tab is hidden (e.g. user switches to sender tab).
|
|
||||||
refetchIntervalInBackground: true,
|
|
||||||
staleTime: 10_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const listQuery = useQuery({
|
|
||||||
queryKey: ['notifications', 'list'],
|
|
||||||
queryFn: async () => {
|
|
||||||
const { data } = await api.get<NotificationListResponse>('/notifications', {
|
|
||||||
params: { per_page: 50 },
|
|
||||||
});
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
staleTime: 15_000,
|
|
||||||
refetchInterval: () => (visibleRef.current ? 15_000 : false),
|
|
||||||
});
|
|
||||||
|
|
||||||
const markReadMutation = useMutation({
|
|
||||||
mutationFn: async (notificationIds: number[]) => {
|
|
||||||
await api.put('/notifications/read', { notification_ids: notificationIds });
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const markAllReadMutation = useMutation({
|
|
||||||
mutationFn: async () => {
|
|
||||||
await api.put('/notifications/read-all');
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
|
||||||
mutationFn: async (id: number) => {
|
|
||||||
await api.delete(`/notifications/${id}`);
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const refreshNotifications = () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
|
||||||
};
|
|
||||||
|
|
||||||
const value: NotificationContextValue = {
|
|
||||||
unreadCount: unreadQuery.data ?? 0,
|
|
||||||
notifications: listQuery.data?.notifications ?? [],
|
|
||||||
total: listQuery.data?.total ?? 0,
|
|
||||||
isLoading: listQuery.isLoading,
|
|
||||||
markRead: markReadMutation.mutateAsync,
|
|
||||||
markAllRead: markAllReadMutation.mutateAsync,
|
|
||||||
deleteNotification: deleteMutation.mutateAsync,
|
|
||||||
refreshNotifications,
|
|
||||||
};
|
|
||||||
|
|
||||||
return createElement(NotificationContext.Provider, { value }, children);
|
|
||||||
}
|
|
||||||
@ -23,27 +23,6 @@ export interface Settings {
|
|||||||
// Auto-lock settings
|
// Auto-lock settings
|
||||||
auto_lock_enabled: boolean;
|
auto_lock_enabled: boolean;
|
||||||
auto_lock_minutes: number;
|
auto_lock_minutes: number;
|
||||||
// Profile fields (shareable)
|
|
||||||
phone: string | null;
|
|
||||||
mobile: string | null;
|
|
||||||
address: string | null;
|
|
||||||
company: string | null;
|
|
||||||
job_title: string | null;
|
|
||||||
// Social settings
|
|
||||||
accept_connections: boolean;
|
|
||||||
// Sharing defaults
|
|
||||||
share_first_name: boolean;
|
|
||||||
share_last_name: boolean;
|
|
||||||
share_preferred_name: boolean;
|
|
||||||
share_email: boolean;
|
|
||||||
share_phone: boolean;
|
|
||||||
share_mobile: boolean;
|
|
||||||
share_birthday: boolean;
|
|
||||||
share_address: boolean;
|
|
||||||
share_company: boolean;
|
|
||||||
share_job_title: boolean;
|
|
||||||
// ntfy connections toggle
|
|
||||||
ntfy_connections_enabled: boolean;
|
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
@ -192,9 +171,6 @@ export interface Person {
|
|||||||
company?: string;
|
company?: string;
|
||||||
job_title?: string;
|
job_title?: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
linked_user_id?: number | null;
|
|
||||||
is_umbral_contact: boolean;
|
|
||||||
shared_fields?: Record<string, unknown> | null;
|
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
@ -246,7 +222,6 @@ export type LoginResponse = LoginSuccessResponse | LoginMfaRequiredResponse | Lo
|
|||||||
export interface AdminUser {
|
export interface AdminUser {
|
||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
umbral_name: string;
|
|
||||||
email: string | null;
|
email: string | null;
|
||||||
first_name: string | null;
|
first_name: string | null;
|
||||||
last_name: string | null;
|
last_name: string | null;
|
||||||
@ -373,7 +348,6 @@ export interface UpcomingResponse {
|
|||||||
|
|
||||||
export interface UserProfile {
|
export interface UserProfile {
|
||||||
username: string;
|
username: string;
|
||||||
umbral_name: string;
|
|
||||||
email: string | null;
|
email: string | null;
|
||||||
first_name: string | null;
|
first_name: string | null;
|
||||||
last_name: string | null;
|
last_name: string | null;
|
||||||
@ -392,50 +366,3 @@ export interface EventTemplate {
|
|||||||
is_starred: boolean;
|
is_starred: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Notifications ──────────────────────────────────────────────────
|
|
||||||
// Named AppNotification to avoid collision with browser Notification API
|
|
||||||
|
|
||||||
export interface AppNotification {
|
|
||||||
id: number;
|
|
||||||
user_id: number;
|
|
||||||
type: string;
|
|
||||||
title: string | null;
|
|
||||||
message: string | null;
|
|
||||||
data: Record<string, unknown> | null;
|
|
||||||
source_type: string | null;
|
|
||||||
source_id: number | null;
|
|
||||||
is_read: boolean;
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NotificationListResponse {
|
|
||||||
notifications: AppNotification[];
|
|
||||||
unread_count: number;
|
|
||||||
total: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Connections ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface ConnectionRequest {
|
|
||||||
id: number;
|
|
||||||
sender_umbral_name: string;
|
|
||||||
sender_preferred_name: string | null;
|
|
||||||
receiver_umbral_name: string;
|
|
||||||
receiver_preferred_name: string | null;
|
|
||||||
status: 'pending' | 'accepted' | 'rejected' | 'cancelled';
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Connection {
|
|
||||||
id: number;
|
|
||||||
connected_user_id: number;
|
|
||||||
connected_umbral_name: string;
|
|
||||||
connected_preferred_name: string | null;
|
|
||||||
person_id: number | null;
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UmbralSearchResponse {
|
|
||||||
found: boolean;
|
|
||||||
}
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user