Compare commits

...

38 Commits

Author SHA1 Message Date
47645ec115 Merge feature/user-connections into main
User connections system: search by umbral name, send/accept/reject/cancel
requests, bidirectional Person records on accept, per-connection sharing
overrides, in-app notification centre with toast popups, ntfy push
integration. Includes QA fixes, pentest hardening, and contact sync fix.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 01:36:02 +08:00
c8805ee4c4 Fix registration enumeration + contact detail panel name sync
M-01: Unify registration error messages to prevent username/email
enumeration — both now return the same generic message.

Bug fix: Contact detail panel header and initials now use shared_fields
for umbral contacts instead of stale Person record first_name/last_name.
The table list already did this via sf() helper; the detail panel header
and getPersonInitialsName were bypassing it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 00:31:10 +08:00
d22600ac19 Fix remaining bare notification type string literal in cancel endpoint
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 23:58:09 +08:00
20692632f2 Address QA warnings W-02 through W-07
W-02: Purge accepted connection requests after 90 days (rejected/cancelled stay at 30)
W-04: Rename shadowed `type` parameter to `notification_type` with alias
W-05: Extract notification type string literals to constants in connection service
W-06: Match notification list polling interval to unread count (15s when visible)
W-07: Add filter_to_shareable defence-in-depth gate on resolve_shared_profile output
W-03: Verified false positive — no double person lookup exists in accept flow

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 23:55:11 +08:00
3fe344c3a0 Fix QA review findings: per-card responding state, preserve data on detach
C-01: ConnectionRequestCard now uses local isResponding state instead of
shared hook boolean, so accepting one card doesn't disable all others.

C-03: detach_umbral_contact no longer wipes person data (name, email,
phone, etc.) when a connection is severed. The person becomes a standard
contact with all data preserved, preventing data loss for pre-existing
contacts that were linked to connections.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 22:40:24 +08:00
aeb30afbce Fix nginx rate limit blocking accept: exact match for send request location
location /api/connections/request was a prefix match, unintentionally
rate-limiting /api/connections/requests/incoming, /outgoing, and
/requests/{id}/respond at 3r/m. This caused the accept button on toast
notifications to get 429'd when clicked immediately — the incoming
requests poll + outgoing poll had already exhausted the rate limit.

Changed to exact match (= /api/connections/request) so only the send
endpoint is rate-limited, not the entire /requests/* tree.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 21:31:28 +08:00
b16bca919f Fix send request 500: revert to naive datetime.now() per project contract
The QA fix incorrectly changed datetime.now() to datetime.now(timezone.utc),
but the DB uses TIMESTAMP WITHOUT TIME ZONE (naive). Passing a tz-aware
datetime to a naive column causes asyncpg DataError. Also removes the
temporary diagnostic wrapper and builds response before commit.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 21:14:39 +08:00
ea491a4b89 Add temporary diagnostic wrapper to send_connection_request
Exposes actual exception type and message in 500 response detail
to identify the unhandled error. Will be removed after diagnosis.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 20:54:38 +08:00
87d232cbcd Fix send request 500: build response before commit to avoid MissingGreenlet
After db.commit(), all ORM objects are expired. Accessing their attributes
in _build_request_response triggered lazy loads which fail in async
SQLAlchemy with MissingGreenlet. Move response construction before commit.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 20:48:25 +08:00
416f616457 Allow dots in umbral name validation (matches username regex)
Username validation allows dots ([a-z0-9_.\-]+) but the connection
search and umbral name validators used [a-zA-Z0-9_-] which rejected
dots. This caused a 422 on any search for users with dots in their
username (e.g. rca.account01), silently showing "User not found".

Fixed regex in both connection.py schema and auth.py ProfileUpdate
to include dots: [a-zA-Z0-9_.-]

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 20:30:27 +08:00
9bcf5ace5d Fix search cold-cache gate, 429 handling, and datetime.now() violations
ConnectionSearch.tsx:
- Add loading guard for useSettings() — prevents cold cache from showing
  "enable connections" gate when settings haven't loaded yet
- Add 429 rate limit handling in search catch block — shows user-friendly
  message instead of silently showing "User not found"
- Import axios for isAxiosError type guard

connections.py:
- Fix 3x datetime.now() → datetime.now(timezone.utc) per hard rule
  (lines 187, 378, 565)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 20:02:52 +08:00
360a14b87b Restore refetchIntervalInBackground for unread count polling
The W-03 QA fix (removing refetchIntervalInBackground) broke toast
notifications when the receiver tab is hidden (e.g. user switches to
sender's tab). Without background polling, unread count never updates,
NotificationToaster never detects new notifications, and no toast fires.

Restored with explanatory comment documenting the dependency.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 19:30:16 +08:00
4e2d48c50b Fix QA review findings: detach cleanup, sf() fallthrough, polling, commit guard
- W-01: Wrap accept flow db.commit() in IntegrityError handler (409)
- W-03: Remove refetchIntervalInBackground from unread count polling
- W-04: detach_umbral_contact now clears all shared fields on Person
- W-05: sf() callers no longer fall through via ?? to stale local data

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 19:12:31 +08:00
053c2ae85e Mark notification as read when accepting via toast
Toast accept/reject now calls markRead on the corresponding notification
so it clears from unread in the notifications tab. Uses markReadRef to
avoid stale closure in Sonner toast callbacks. Covers both success and
409 (already resolved) paths.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 18:32:54 +08:00
1e736eb333 Fix connection accept regression: revert disabled gates, add 409 handling
Previous fix (2139ea8) caused regression: staleTime:0 nuked cache on
every mount, isLoadingIncoming disabled buttons during cold-cache fetch.

Changes:
- Remove staleTime:0 (keep refetchOnMount:'always' for background refresh)
- Revert button gate to pendingRequestIds.has() only (no is_read gate)
- Remove isLoadingIncoming from disabled prop and spinner condition
- Add 409-as-success handling to ConnectionRequestCard (People tab)
- Use axios.isAxiosError() instead of err:any in all 3 catch blocks
- Add markRead() call to 409 branch so notifications clear properly

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 18:23:47 +08:00
2139ea8077 Fix connection accept: stale cache, hidden button, and false 409 error
- incomingQuery: staleTime:0 + refetchOnMount:'always' so pending
  requests are always fresh when components mount (was inheriting
  5-min global staleTime, causing empty pendingRequestIds on nav)
- NotificationsPage: show Accept button while incoming data loads
  (was hidden during async gap); disable with spinner until ready
- Both toast and page: treat 409 as success ("already accepted")
  instead of showing error (fixes race when both fire respond)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 17:37:21 +08:00
2fb41e0cf4 Fix toast accept stale closure + harden backend error responses
Toast accept button captured a stale `respond` reference from the
Sonner closure. Use respondRef pattern so clicks always dispatch
through the current mutation. Backend respond endpoint now catches
unhandled exceptions and returns proper JSON with detail field
instead of plain-text 500s.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 16:54:28 +08:00
6b59d61bf3 Fix connection mutation delays: make all onSuccess fire-and-forget
sendRequestMutation and cancelMutation were awaiting query invalidation
in onSuccess, which blocked mutateAsync until 3+ network refetches
completed (~1s+ delay). This caused noticeable lag on send/link/cancel
operations. Now all mutations use sync fire-and-forget invalidation,
matching respondMutation's pattern.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 21:22:51 +08:00
dff36f30c8 Fix toast accept button: instant feedback + double-click guard
Toast buttons are static Sonner elements that can't bind React state,
so clicks had no visual feedback and allowed duplicate mutations.
Now: dismiss custom toast immediately, show loading toast, and block
concurrent clicks via a ref-based Set guard.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 19:38:29 +08:00
60281caa64 Unify toast accept path with notification center via useConnections
Toast now uses the same respond() from useConnections hook instead
of raw api.put, making both accept surfaces share identical code.
Also made respondMutation.onSuccess fire-and-forget to prevent
invalidation errors from surfacing as mutation failures.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 10:30:35 +08:00
5828bbf8e2 Fix toast accept showing false error when invalidations fail
Separate API error handling from query invalidation in
NotificationToaster's handleConnectionRespond so a failed refetch
doesn't surface as "Failed to respond" after a successful accept.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 10:15:23 +08:00
f854987f53 Fix ~60s delay before accept buttons work on new requests
Root cause: ['connections', 'incoming'] query has no polling, so
pendingRequestIds stays empty until manually refetched. Accept buttons
on NotificationsPage are gated by this set.

- NotificationsPage: detect connection_request notifications not in
  pendingRequestIds and eagerly invalidate incoming requests query
- NotificationToaster: invalidate ['connections', 'incoming'] when
  connection_request notifications arrive, so accept buttons are
  ready on all surfaces immediately

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 10:02:50 +08:00
14a77f0f11 Fix stale UI after accept: await invalidations, dismiss toasts
- Await all query invalidations in respondMutation/cancelMutation
  onSuccess so UI has fresh data before mutation promise resolves
- Use deterministic toast IDs (connection-request-{id}) for Sonner
  toasts so they can be dismissed from any accept surface
- Dismiss stale connection toasts in respondMutation.onSuccess
- Fix handleCancel setting resolved before API call completes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 09:43:22 +08:00
b554ba7151 Fix QA: IntegrityError handling, dict mutation, birthday sync, None guard
- C-01: Wrap accept flow flush/commit in IntegrityError handling (409)
- C-02: Use separate remote_timestamps dict instead of pop() on shared profile
- W-01: Add birthday sync in Link conversion path (existing person → umbral)
- W-02: Add None guard on max(updated_at) comparison in get_person

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 09:05:46 +08:00
568a78e64b Show connected user's latest update time on umbral contacts
Override updated_at on PersonResponse with max(Person, User, Settings)
so the panel reflects when the connected user last changed their profile.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 08:44:54 +08:00
73cef1df55 Add umbral name header, preferred name field, and link button for contacts
- Inject umbral_name into shared_fields for umbral contacts (always visible)
- Show @umbralname subtitle in detail panel header
- Add preferred_name to panel fields with synced label for umbral contacts
- Add Link button on standard contacts to tie to umbral user via connection request
- Migration 046: person_id FK on connection_requests with index
- Validate person_id ownership on send, re-validate + convert on accept

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 08:37:01 +08:00
4513227338 Fix share name toggle revert and stale table data for umbral contacts
Bug 1: _to_settings_response() was missing share_first_name and
share_last_name — the response always returned false (Pydantic default),
causing the frontend to sync toggles back to off after save.

Bug 2: Table column renderers read from stale Person record fields.
Added sf() helper that overlays shared_fields for umbral contacts,
applied to name, phone, email, role, and birthday columns. The table
now shows live shared profile data matching the detail panel.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 08:07:45 +08:00
33aac72639 Add delete-with-sever and unlink actions for umbral contacts
Delete person now severs the bidirectional connection when the person
is an umbral contact — removes both UserConnection rows and detaches
the counterpart's Person record. Fixes "Already connected" error
when trying to reconnect after deleting an umbral contact.

New PUT /people/{id}/unlink endpoint converts an umbral contact to a
standard contact (detaches linked fields) while also severing the
bidirectional connection, keeping the Person in the contact list.

Frontend: EntityDetailPanel gains extraActions prop. PeoplePage renders
an "Unlink" button in the panel footer for umbral contacts. Delete
mutation now also invalidates connections query.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 07:50:31 +08:00
75fc3e3485 Fix notification background polling, add first/last name sharing
Notifications: enable refetchIntervalInBackground on unread count
query so notifications appear in background tabs without requiring
a tab switch to trigger refetchOnWindowFocus.

Name sharing: add share_first_name and share_last_name to the full
sharing pipeline — migration 045, Settings model/schema, SHAREABLE_FIELDS,
resolve_shared_profile, create_person_from_connection (now populates
first_name + last_name + computed display name), SharingOverrideUpdate,
frontend types and SettingsPage toggles.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 07:34:13 +08:00
820ff46efa Fix QA W-01/W-05/W-06/W-08: cancel requests, detach umbral, notifications
W-08: Add CHECK constraint on notifications.type (migration 044) with
defensive pre-check and matching __table_args__ on model.

W-05: Auto-detach umbral contact before Person delete — nulls out
connection's person_id so the connection survives deletion.

W-01: Add PUT /requests/{id}/cancel endpoint with atomic UPDATE,
silent notification cleanup, and audit logging. Frontend: direction-aware
ConnectionRequestCard, cancel mutation, pending requests section on
PeoplePage with incoming/outgoing subsections.

W-06: Convert useNotifications to context provider pattern — single
subscription shared via NotificationProvider in AppLayout. Adds
refreshNotifications convenience function.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 07:17:31 +08:00
0e94b6e1f7 Fix QA review findings: race condition, detached session, validation
- C-01: Wrap connection request flush in IntegrityError handler for
  TOCTOU race on partial unique index
- W-02: Extract ntfy config into plain dict before commit to avoid
  DetachedInstanceError in background tasks
- W-04: Add integer range validation (1–2147483647) on notification IDs
- W-07: Add typed response models for respond_to_request endpoint
- W-09: Document resolved_at requirement for future cancel endpoint
- S-02: Use Literal type for ConnectionRequestResponse.status
- S-04: Check ntfy master switch in extract_ntfy_config
- S-05: Move date import to module level in connection service

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 06:36:14 +08:00
e27beb7736 Fix toast notifications, require accept_connections for senders
- Rewrite NotificationToaster with max-ID watermark for reliable
  new-notification detection and faster unread count polling (15s)
- Block connection search and requests when sender has
  accept_connections disabled (backend + frontend gate)
- Remove duplicate sender_settings fetch in send_connection_request
- Show actionable error messages in toast respond failures

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 06:21:43 +08:00
03fd8dba97 Fix notification UX, admin panel error handling, and data bugs
Notification fixes:
- Add NotificationToaster component with real-time toast notifications
  for new incoming notifications (30s polling, 15s stale time)
- Connection request toasts show inline Accept/Reject buttons
- Add inline Accept/Reject buttons to connection_request notifications
  in NotificationsPage (prevents bricked requests after navigation)
- Don't mark connection_request as read or navigate away when pending
- Auto-refetch notification list when unread count increases

Admin panel fixes:
- Add error state UI to UserDetailSection and ConfigPage (previously
  silently returned null/empty on API errors)
- Fix get_user response missing must_change_password and locked_until
- Fix create_user response missing preferred_name and date_of_birth
- Add defensive limit(1) on settings query to prevent MultipleResultsFound
- Guard _target_username_col JSONB cast with CASE to prevent crash on
  non-JSON audit detail values
- Add connection audit action types to ConfigPage filter dropdown

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 05:55:14 +08:00
03abbbf8a7 Restrict umbral name to single word (no spaces)
Explicit space check with clear error message on both backend
validator and frontend client-side validation. The existing regex
already disallows spaces but the dedicated check gives a better UX.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 05:03:22 +08:00
6130d09ae8 Make umbral name editable in user settings
- Add umbral_name to ProfileUpdate schema with regex validation
- Add uniqueness check in PUT /auth/profile handler
- Replace disabled input with editable save-on-blur field in Social card
- Client-side validation (3-50 chars, alphanumeric/hyphens/underscores)
- Inline error display for validation failures and taken names

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 05:00:33 +08:00
8e226fee0f Set umbral_name=username on all user creation paths
Admin create, first-user setup, and registration endpoints were
missing umbral_name assignment, causing NOT NULL constraint failures
when creating new users after migration 039.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 04:56:22 +08:00
337b50c7ce Fix QA review findings: source_id, N+1 queries, event bubbling, type mismatches
Critical fixes:
- C-01: Add receiver_umbral_name/receiver_preferred_name to frontend ConnectionRequest type
- C-02: Flush connection request before notification to populate source_id
- C-03: Add umbral_name to ProfileResponse/UserProfile, use in Settings Social card
- C-04: Remove dead code in sharing-overrides endpoint, merge instead of replace

Warning fixes:
- W-01/W-02: Batch-fetch settings in incoming/outgoing/list connection endpoints (N+1 fix)
- W-04: Add _purge_resolved_requests job for rejected/cancelled requests (30-day retention)
- W-10: Add e.stopPropagation() to notification mark-read and delete buttons

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 02:29:04 +08:00
3d22568b9c Add user connections, notification centre, and people integration
Implements the full User Connections & Notification Centre feature:

Phase 1 - Database: migrations 039-043 adding umbral_name to users,
profile/social fields to settings, notifications table, connection
request/user_connection tables, and linked_user_id to people.

Phase 2 - Notifications: backend CRUD router + service + 90-day purge,
frontend NotificationsPage with All/Unread filter, bell icon in sidebar
with unread badge polling every 60s.

Phase 3 - Settings: profile fields (phone, mobile, address, company,
job_title), social card with accept_connections toggle and per-field
sharing defaults, umbral name display with CopyableField.

Phase 4 - Connections: timing-safe user search, send/accept/reject flow
with atomic status updates, bidirectional UserConnection + Person records,
in-app + ntfy notifications, per-receiver pending cap, nginx rate limiting.

Phase 5 - People integration: batch-loaded shared profiles (N+1 prevention),
Ghost icon for umbral contacts, Umbral filter pill, split Add Person button,
shared field indicators (synced labels + Lock icons), disabled form inputs
for synced fields on umbral contacts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 02:10:16 +08:00
50 changed files with 3998 additions and 91 deletions

View File

@ -0,0 +1,37 @@
"""Add umbral_name to users table.
3-step migration: add nullable backfill from username alter to NOT NULL.
Backfill uses username || '_' || id as fallback if uniqueness conflicts arise.
Revision ID: 039
Revises: 038
"""
from alembic import op
import sqlalchemy as sa
revision = "039"
down_revision = "038"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Step 1: Add nullable column
op.add_column("users", sa.Column("umbral_name", sa.String(50), nullable=True))
# Step 2: Backfill from username (handles uniqueness conflicts with fallback)
op.execute("UPDATE users SET umbral_name = username")
# Fix any remaining NULLs (shouldn't happen, but defensive)
op.execute(
"UPDATE users SET umbral_name = username || '_' || id "
"WHERE umbral_name IS NULL"
)
# Step 3: Alter to NOT NULL and add unique index
op.alter_column("users", "umbral_name", nullable=False)
op.create_index("ix_users_umbral_name", "users", ["umbral_name"], unique=True)
def downgrade() -> None:
op.drop_index("ix_users_umbral_name", table_name="users")
op.drop_column("users", "umbral_name")

View File

@ -0,0 +1,85 @@
"""Expand settings with profile, social, and sharing fields.
Revision ID: 040
Revises: 039
"""
from alembic import op
import sqlalchemy as sa
revision = "040"
down_revision = "039"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Profile fields
op.add_column("settings", sa.Column("phone", sa.String(50), nullable=True))
op.add_column("settings", sa.Column("mobile", sa.String(50), nullable=True))
op.add_column("settings", sa.Column("address", sa.Text, nullable=True))
op.add_column("settings", sa.Column("company", sa.String(255), nullable=True))
op.add_column("settings", sa.Column("job_title", sa.String(255), nullable=True))
# Social toggle
op.add_column(
"settings",
sa.Column("accept_connections", sa.Boolean, nullable=False, server_default="false"),
)
# Sharing defaults
op.add_column(
"settings",
sa.Column("share_preferred_name", sa.Boolean, nullable=False, server_default="true"),
)
op.add_column(
"settings",
sa.Column("share_email", sa.Boolean, nullable=False, server_default="false"),
)
op.add_column(
"settings",
sa.Column("share_phone", sa.Boolean, nullable=False, server_default="false"),
)
op.add_column(
"settings",
sa.Column("share_mobile", sa.Boolean, nullable=False, server_default="false"),
)
op.add_column(
"settings",
sa.Column("share_birthday", sa.Boolean, nullable=False, server_default="false"),
)
op.add_column(
"settings",
sa.Column("share_address", sa.Boolean, nullable=False, server_default="false"),
)
op.add_column(
"settings",
sa.Column("share_company", sa.Boolean, nullable=False, server_default="false"),
)
op.add_column(
"settings",
sa.Column("share_job_title", sa.Boolean, nullable=False, server_default="false"),
)
# ntfy connection notifications toggle (gates push only, not in-app)
op.add_column(
"settings",
sa.Column("ntfy_connections_enabled", sa.Boolean, nullable=False, server_default="true"),
)
def downgrade() -> None:
op.drop_column("settings", "ntfy_connections_enabled")
op.drop_column("settings", "share_job_title")
op.drop_column("settings", "share_company")
op.drop_column("settings", "share_address")
op.drop_column("settings", "share_birthday")
op.drop_column("settings", "share_mobile")
op.drop_column("settings", "share_phone")
op.drop_column("settings", "share_email")
op.drop_column("settings", "share_preferred_name")
op.drop_column("settings", "accept_connections")
op.drop_column("settings", "job_title")
op.drop_column("settings", "company")
op.drop_column("settings", "address")
op.drop_column("settings", "mobile")
op.drop_column("settings", "phone")

View File

@ -0,0 +1,57 @@
"""Create notifications table for in-app notification centre.
Revision ID: 041
Revises: 040
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSONB
revision = "041"
down_revision = "040"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"notifications",
sa.Column("id", sa.Integer, primary_key=True, index=True),
sa.Column(
"user_id",
sa.Integer,
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("type", sa.String(50), nullable=False),
sa.Column("title", sa.String(255), nullable=True),
sa.Column("message", sa.Text, nullable=True),
sa.Column("data", JSONB, nullable=True),
sa.Column("source_type", sa.String(50), nullable=True),
sa.Column("source_id", sa.Integer, nullable=True),
sa.Column("is_read", sa.Boolean, nullable=False, server_default="false"),
sa.Column(
"created_at",
sa.DateTime,
nullable=False,
server_default=sa.func.now(),
),
)
# Fast unread count query
op.execute(
'CREATE INDEX "ix_notifications_user_unread" ON notifications (user_id, is_read) '
"WHERE is_read = false"
)
# Paginated listing
op.create_index(
"ix_notifications_user_created",
"notifications",
["user_id", sa.text("created_at DESC")],
)
def downgrade() -> None:
op.drop_index("ix_notifications_user_created", table_name="notifications")
op.execute('DROP INDEX IF EXISTS "ix_notifications_user_unread"')
op.drop_table("notifications")

View File

@ -0,0 +1,109 @@
"""Create connection_requests and user_connections tables.
Revision ID: 042
Revises: 041
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSONB
revision = "042"
down_revision = "041"
branch_labels = None
depends_on = None
def upgrade() -> None:
# ── connection_requests ──────────────────────────────────────────
op.create_table(
"connection_requests",
sa.Column("id", sa.Integer, primary_key=True, index=True),
sa.Column(
"sender_id",
sa.Integer,
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"receiver_id",
sa.Integer,
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"status",
sa.String(20),
nullable=False,
server_default="pending",
),
sa.Column(
"created_at",
sa.DateTime,
nullable=False,
server_default=sa.func.now(),
),
sa.Column("resolved_at", sa.DateTime, nullable=True),
sa.CheckConstraint(
"status IN ('pending', 'accepted', 'rejected', 'cancelled')",
name="ck_connection_requests_status",
),
)
# Only one pending request per sender→receiver pair
op.execute(
'CREATE UNIQUE INDEX "ix_connection_requests_pending" '
"ON connection_requests (sender_id, receiver_id) "
"WHERE status = 'pending'"
)
# Incoming request listing
op.create_index(
"ix_connection_requests_receiver_status",
"connection_requests",
["receiver_id", "status"],
)
# Outgoing request listing
op.create_index(
"ix_connection_requests_sender_status",
"connection_requests",
["sender_id", "status"],
)
# ── user_connections ─────────────────────────────────────────────
op.create_table(
"user_connections",
sa.Column("id", sa.Integer, primary_key=True, index=True),
sa.Column(
"user_id",
sa.Integer,
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"connected_user_id",
sa.Integer,
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"person_id",
sa.Integer,
sa.ForeignKey("people.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("sharing_overrides", JSONB, nullable=True),
sa.Column(
"created_at",
sa.DateTime,
nullable=False,
server_default=sa.func.now(),
),
sa.UniqueConstraint("user_id", "connected_user_id", name="uq_user_connections"),
)
def downgrade() -> None:
op.drop_table("user_connections")
op.drop_index("ix_connection_requests_sender_status", table_name="connection_requests")
op.drop_index("ix_connection_requests_receiver_status", table_name="connection_requests")
op.execute('DROP INDEX IF EXISTS "ix_connection_requests_pending"')
op.drop_table("connection_requests")

View File

@ -0,0 +1,44 @@
"""Add linked_user_id and is_umbral_contact to people table.
Revision ID: 043
Revises: 042
"""
from alembic import op
import sqlalchemy as sa
revision = "043"
down_revision = "042"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"people",
sa.Column(
"linked_user_id",
sa.Integer,
sa.ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
),
)
op.add_column(
"people",
sa.Column(
"is_umbral_contact",
sa.Boolean,
nullable=False,
server_default="false",
),
)
# Fast lookup of umbral contacts by owner
op.execute(
'CREATE INDEX "ix_people_linked_user" ON people (user_id, linked_user_id) '
"WHERE linked_user_id IS NOT NULL"
)
def downgrade() -> None:
op.execute('DROP INDEX IF EXISTS "ix_people_linked_user"')
op.drop_column("people", "is_umbral_contact")
op.drop_column("people", "linked_user_id")

View File

@ -0,0 +1,45 @@
"""Add CHECK constraint on notifications.type column.
Revision ID: 044
Revises: 043
"""
from alembic import op
import sqlalchemy as sa
revision = "044"
down_revision = "043"
branch_labels = None
depends_on = None
ALLOWED_TYPES = (
"connection_request",
"connection_accepted",
"connection_rejected",
"info",
"warning",
"reminder",
"system",
)
def upgrade() -> None:
# Defensive: ensure no existing rows violate the constraint
conn = op.get_bind()
placeholders = ", ".join(f"'{t}'" for t in ALLOWED_TYPES)
bad = conn.execute(
sa.text(f"SELECT COUNT(*) FROM notifications WHERE type NOT IN ({placeholders})")
).scalar()
if bad:
raise RuntimeError(
f"Cannot apply CHECK constraint: {bad} notification(s) have types outside the allowed list"
)
op.create_check_constraint(
"ck_notifications_type",
"notifications",
f"type IN ({placeholders})",
)
def downgrade() -> None:
op.drop_constraint("ck_notifications_type", "notifications", type_="check")

View File

@ -0,0 +1,28 @@
"""Add share_first_name and share_last_name to settings.
Revision ID: 045
Revises: 044
"""
from alembic import op
import sqlalchemy as sa
revision = "045"
down_revision = "044"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"settings",
sa.Column("share_first_name", sa.Boolean, nullable=False, server_default="false"),
)
op.add_column(
"settings",
sa.Column("share_last_name", sa.Boolean, nullable=False, server_default="false"),
)
def downgrade() -> None:
op.drop_column("settings", "share_last_name")
op.drop_column("settings", "share_first_name")

View File

@ -0,0 +1,34 @@
"""Add person_id to connection_requests
Revision ID: 046
Revises: 045
"""
from alembic import op
import sqlalchemy as sa
revision = "046"
down_revision = "045"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"connection_requests",
sa.Column(
"person_id",
sa.Integer(),
sa.ForeignKey("people.id", ondelete="SET NULL"),
nullable=True,
),
)
op.create_index(
"ix_connection_requests_person_id",
"connection_requests",
["person_id"],
)
def downgrade() -> None:
op.drop_index("ix_connection_requests_person_id", table_name="connection_requests")
op.drop_column("connection_requests", "person_id")

View File

@ -17,6 +17,7 @@ from sqlalchemy.orm import selectinload
from app.database import AsyncSessionLocal
from app.models.settings import Settings
from app.models.notification import Notification as AppNotification
from app.models.reminder import Reminder
from app.models.calendar_event import CalendarEvent
from app.models.calendar import Calendar
@ -25,6 +26,7 @@ from app.models.project import Project
from app.models.ntfy_sent import NtfySent
from app.models.totp_usage import TOTPUsage
from app.models.session import UserSession
from app.models.connection_request import ConnectionRequest
from app.services.ntfy import send_ntfy_notification
from app.services.ntfy_templates import (
build_event_notification,
@ -267,6 +269,37 @@ async def _purge_expired_sessions(db: AsyncSession) -> None:
await db.commit()
async def _purge_old_notifications(db: AsyncSession) -> None:
"""Remove in-app notifications older than 90 days."""
cutoff = datetime.now() - timedelta(days=90)
await db.execute(delete(AppNotification).where(AppNotification.created_at < cutoff))
await db.commit()
async def _purge_resolved_requests(db: AsyncSession) -> None:
"""Remove resolved connection requests after retention period.
Rejected/cancelled: 30 days. Accepted: 90 days (longer for audit trail).
resolved_at must be set when changing status. NULL resolved_at rows are
preserved (comparison with NULL yields NULL).
"""
reject_cutoff = datetime.now() - timedelta(days=30)
accept_cutoff = datetime.now() - timedelta(days=90)
await db.execute(
delete(ConnectionRequest).where(
ConnectionRequest.status.in_(["rejected", "cancelled"]),
ConnectionRequest.resolved_at < reject_cutoff,
)
)
await db.execute(
delete(ConnectionRequest).where(
ConnectionRequest.status == "accepted",
ConnectionRequest.resolved_at < accept_cutoff,
)
)
await db.commit()
# ── Entry point ───────────────────────────────────────────────────────────────
async def run_notification_dispatch() -> None:
@ -308,6 +341,8 @@ async def run_notification_dispatch() -> None:
async with AsyncSessionLocal() as db:
await _purge_totp_usage(db)
await _purge_expired_sessions(db)
await _purge_old_notifications(db)
await _purge_resolved_requests(db)
except Exception:
# Broad catch: job failure must never crash the scheduler or the app

View File

@ -7,7 +7,7 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler
from app.config import settings
from app.database import engine
from app.routers import auth, todos, events, calendars, reminders, projects, people, locations, settings as settings_router, dashboard, weather, event_templates
from app.routers import totp, admin
from app.routers import totp, admin, notifications as notifications_router, connections as connections_router
from app.jobs.notifications import run_notification_dispatch
# Import models so Alembic's autogenerate can discover them
@ -17,6 +17,9 @@ from app.models import totp_usage as _totp_usage_model # noqa: F401
from app.models import backup_code as _backup_code_model # noqa: F401
from app.models import system_config as _system_config_model # noqa: F401
from app.models import audit_log as _audit_log_model # noqa: F401
from app.models import notification as _notification_model # noqa: F401
from app.models import connection_request as _connection_request_model # noqa: F401
from app.models import user_connection as _user_connection_model # noqa: F401
# ---------------------------------------------------------------------------
@ -129,6 +132,8 @@ app.include_router(weather.router, prefix="/api/weather", tags=["Weather"])
app.include_router(event_templates.router, prefix="/api/event-templates", tags=["Event Templates"])
app.include_router(totp.router, prefix="/api/auth", tags=["TOTP MFA"])
app.include_router(admin.router, prefix="/api/admin", tags=["Admin"])
app.include_router(notifications_router.router, prefix="/api/notifications", tags=["Notifications"])
app.include_router(connections_router.router, prefix="/api/connections", tags=["Connections"])
@app.get("/")

View File

@ -15,6 +15,9 @@ from app.models.totp_usage import TOTPUsage
from app.models.backup_code import BackupCode
from app.models.system_config import SystemConfig
from app.models.audit_log import AuditLog
from app.models.notification import Notification
from app.models.connection_request import ConnectionRequest
from app.models.user_connection import UserConnection
__all__ = [
"Settings",
@ -34,4 +37,7 @@ __all__ = [
"BackupCode",
"SystemConfig",
"AuditLog",
"Notification",
"ConnectionRequest",
"UserConnection",
]

View File

@ -0,0 +1,36 @@
from sqlalchemy import String, Integer, ForeignKey, CheckConstraint, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from datetime import datetime
from typing import Optional, TYPE_CHECKING
from app.database import Base
if TYPE_CHECKING:
from app.models.user import User
class ConnectionRequest(Base):
__tablename__ = "connection_requests"
__table_args__ = (
CheckConstraint(
"status IN ('pending', 'accepted', 'rejected', 'cancelled')",
name="ck_connection_requests_status",
),
)
id: Mapped[int] = mapped_column(primary_key=True, index=True)
sender_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
receiver_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default="pending")
created_at: Mapped[datetime] = mapped_column(default=func.now(), server_default=func.now())
resolved_at: Mapped[Optional[datetime]] = mapped_column(nullable=True, default=None)
person_id: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("people.id", ondelete="SET NULL"), nullable=True
)
# Relationships with explicit foreign_keys to disambiguate
sender: Mapped["User"] = relationship(foreign_keys=[sender_id], lazy="selectin")
receiver: Mapped["User"] = relationship(foreign_keys=[receiver_id], lazy="selectin")

View File

@ -0,0 +1,36 @@
from sqlalchemy import CheckConstraint, String, Text, Integer, Boolean, ForeignKey, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from datetime import datetime
from typing import Optional
from app.database import Base
# Active: connection_request, connection_accepted
# Reserved: connection_rejected, info, warning, reminder, system
_NOTIFICATION_TYPES = (
"connection_request", "connection_accepted", "connection_rejected",
"info", "warning", "reminder", "system",
)
class Notification(Base):
__tablename__ = "notifications"
__table_args__ = (
CheckConstraint(
f"type IN ({', '.join(repr(t) for t in _NOTIFICATION_TYPES)})",
name="ck_notifications_type",
),
)
id: Mapped[int] = mapped_column(primary_key=True, index=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
type: Mapped[str] = mapped_column(String(50), nullable=False)
title: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
message: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
data: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)
source_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
source_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
is_read: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
created_at: Mapped[datetime] = mapped_column(default=func.now(), server_default=func.now())

View File

@ -27,6 +27,11 @@ class Person(Base):
job_title: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
mobile: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
category: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
# Umbral contact link
linked_user_id: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
is_umbral_contact: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default=text('false'))
created_at: Mapped[datetime] = mapped_column(default=func.now())
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())

View File

@ -1,4 +1,4 @@
from sqlalchemy import String, Integer, Float, Boolean, ForeignKey, func
from sqlalchemy import String, Text, Integer, Float, Boolean, ForeignKey, func
from sqlalchemy.orm import Mapped, mapped_column
from datetime import datetime
from typing import Optional
@ -46,6 +46,31 @@ class Settings(Base):
auto_lock_enabled: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
auto_lock_minutes: Mapped[int] = mapped_column(Integer, default=5, server_default="5")
# Profile fields (shareable with connections)
phone: Mapped[Optional[str]] = mapped_column(String(50), nullable=True, default=None)
mobile: Mapped[Optional[str]] = mapped_column(String(50), nullable=True, default=None)
address: Mapped[Optional[str]] = mapped_column(Text, nullable=True, default=None)
company: Mapped[Optional[str]] = mapped_column(String(255), nullable=True, default=None)
job_title: Mapped[Optional[str]] = mapped_column(String(255), nullable=True, default=None)
# Social settings
accept_connections: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
# Sharing defaults (what fields are shared with connections by default)
share_first_name: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
share_last_name: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
share_preferred_name: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true")
share_email: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
share_phone: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
share_mobile: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
share_birthday: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
share_address: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
share_company: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
share_job_title: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
# ntfy connection notification toggle (gates push only, not in-app)
ntfy_connections_enabled: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true")
@property
def ntfy_has_token(self) -> bool:
"""Derived field for SettingsResponse — True when an auth token is stored."""

View File

@ -9,6 +9,7 @@ class User(Base):
id: Mapped[int] = mapped_column(primary_key=True, index=True)
username: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True)
umbral_name: Mapped[str] = mapped_column(String(50), unique=True, index=True)
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
first_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
last_name: Mapped[str | None] = mapped_column(String(100), nullable=True)

View File

@ -0,0 +1,31 @@
from sqlalchemy import Integer, ForeignKey, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from datetime import datetime
from typing import Optional, TYPE_CHECKING
from app.database import Base
if TYPE_CHECKING:
from app.models.user import User
from app.models.person import Person
class UserConnection(Base):
__tablename__ = "user_connections"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
connected_user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
person_id: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("people.id", ondelete="SET NULL"), nullable=True
)
sharing_overrides: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)
created_at: Mapped[datetime] = mapped_column(default=func.now(), server_default=func.now())
# Relationships
connected_user: Mapped["User"] = relationship(foreign_keys=[connected_user_id], lazy="selectin")
person: Mapped[Optional["Person"]] = relationship(foreign_keys=[person_id], lazy="selectin")

View File

@ -70,10 +70,21 @@ def _target_username_col(target_alias, audit_model):
COALESCE: prefer the live username from the users table,
fall back to the username stored in the audit detail JSON
(survives user deletion since audit_log.target_user_id SET NULL).
Guard the JSONB cast with a CASE to avoid errors on non-JSON detail values.
"""
json_fallback = sa.case(
(
sa.and_(
audit_model.detail.is_not(None),
audit_model.detail.startswith("{"),
),
sa.cast(audit_model.detail, JSONB)["username"].as_string(),
),
else_=sa.null(),
)
return sa.func.coalesce(
target_alias.username,
sa.cast(audit_model.detail, JSONB)["username"].as_string(),
json_fallback,
).label("target_username")
@ -170,9 +181,9 @@ async def get_user(
)
active_sessions = session_result.scalar_one()
# Fetch preferred_name from Settings
# Fetch preferred_name from Settings (limit 1 defensive)
settings_result = await db.execute(
sa.select(Settings.preferred_name).where(Settings.user_id == user_id)
sa.select(Settings.preferred_name).where(Settings.user_id == user_id).limit(1)
)
preferred_name = settings_result.scalar_one_or_none()
@ -181,6 +192,8 @@ async def get_user(
active_sessions=active_sessions,
preferred_name=preferred_name,
date_of_birth=user.date_of_birth,
must_change_password=user.must_change_password,
locked_until=user.locked_until,
)
@ -209,6 +222,7 @@ async def create_user(
new_user = User(
username=data.username,
umbral_name=data.username,
password_hash=hash_password(data.password),
role=data.role,
email=email,
@ -241,6 +255,10 @@ async def create_user(
return UserDetailResponse(
**UserListItem.model_validate(new_user).model_dump(exclude={"active_sessions"}),
active_sessions=0,
preferred_name=data.preferred_name,
date_of_birth=None,
must_change_password=new_user.must_change_password,
locked_until=new_user.locked_until,
)

View File

@ -288,6 +288,7 @@ async def setup(
password_hash = hash_password(data.password)
new_user = User(
username=data.username,
umbral_name=data.username,
password_hash=password_hash,
role="admin",
last_password_change_at=datetime.now(),
@ -440,7 +441,7 @@ async def register(
select(User).where(User.username == data.username)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Registration could not be completed. Please try a different username.")
raise HTTPException(status_code=400, detail="Registration could not be completed. Please check your details and try again.")
# Check email uniqueness (generic error to prevent enumeration)
if data.email:
@ -454,6 +455,7 @@ async def register(
# SEC-01: Explicit field assignment — never **data.model_dump()
new_user = User(
username=data.username,
umbral_name=data.username,
password_hash=password_hash,
role="standard",
email=data.email,
@ -666,6 +668,15 @@ async def update_profile(
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Email is already in use")
# Umbral name uniqueness check if changing
if "umbral_name" in update_data and update_data["umbral_name"] != current_user.umbral_name:
new_name = update_data["umbral_name"]
existing = await db.execute(
select(User).where(User.umbral_name == new_name, User.id != current_user.id)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Umbral name is already taken")
# SEC-01: Explicit field assignment — only allowed profile fields
if "first_name" in update_data:
current_user.first_name = update_data["first_name"]
@ -675,6 +686,8 @@ async def update_profile(
current_user.email = update_data["email"]
if "date_of_birth" in update_data:
current_user.date_of_birth = update_data["date_of_birth"]
if "umbral_name" in update_data:
current_user.umbral_name = update_data["umbral_name"]
await log_audit_event(
db, action="auth.profile_updated", actor_id=current_user.id,

View File

@ -0,0 +1,836 @@
"""
Connection router search, request, respond, manage connections.
Security:
- Timing-safe search (50ms sleep floor)
- Per-receiver pending request cap (5 within 10 minutes)
- Atomic accept via UPDATE...WHERE status='pending' RETURNING *
- All endpoints scoped by current_user.id
- Audit logging for all connection events
"""
import asyncio
import logging
from datetime import date as date_type, datetime, timedelta
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Path, Query, Request
from sqlalchemy import delete, select, func, and_, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database import get_db
from app.models.connection_request import ConnectionRequest
from app.models.notification import Notification
from app.models.person import Person
from app.models.settings import Settings
from app.models.user import User
from app.models.user_connection import UserConnection
from app.routers.auth import get_current_user
from app.schemas.connection import (
CancelResponse,
ConnectionRequestResponse,
ConnectionResponse,
RespondAcceptResponse,
RespondRejectResponse,
RespondRequest,
SendConnectionRequest,
SharingOverrideUpdate,
UmbralSearchRequest,
UmbralSearchResponse,
)
from app.services.audit import get_client_ip, log_audit_event
from app.services.connection import (
NOTIF_TYPE_CONNECTION_ACCEPTED,
NOTIF_TYPE_CONNECTION_REQUEST,
SHAREABLE_FIELDS,
create_person_from_connection,
detach_umbral_contact,
extract_ntfy_config,
resolve_shared_profile,
send_connection_ntfy,
)
from app.services.notification import create_notification
router = APIRouter()
logger = logging.getLogger(__name__)
# ── Helpers ──────────────────────────────────────────────────────────
async def _get_settings_for_user(db: AsyncSession, user_id: int) -> Settings | None:
result = await db.execute(select(Settings).where(Settings.user_id == user_id))
return result.scalar_one_or_none()
def _build_request_response(
req: ConnectionRequest,
sender: User,
sender_settings: Settings | None,
receiver: User,
receiver_settings: Settings | None,
) -> ConnectionRequestResponse:
return ConnectionRequestResponse(
id=req.id,
sender_umbral_name=sender.umbral_name,
sender_preferred_name=sender_settings.preferred_name if sender_settings else None,
receiver_umbral_name=receiver.umbral_name,
receiver_preferred_name=receiver_settings.preferred_name if receiver_settings else None,
status=req.status,
created_at=req.created_at,
)
# ── POST /search ────────────────────────────────────────────────────
@router.post("/search", response_model=UmbralSearchResponse)
async def search_user(
body: UmbralSearchRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Timing-safe user search. Always queries by umbral_name alone,
then checks accept_connections + is_active in Python.
Generic "not found" for non-existent, opted-out, AND inactive users.
50ms sleep floor to eliminate timing side-channel.
"""
# Always sleep to prevent timing attacks
await asyncio.sleep(0.05)
# Sender must have accept_connections enabled to search
sender_settings = await _get_settings_for_user(db, current_user.id)
if not sender_settings or not sender_settings.accept_connections:
return UmbralSearchResponse(found=False)
# Don't find yourself
if body.umbral_name == current_user.umbral_name:
return UmbralSearchResponse(found=False)
result = await db.execute(
select(User).where(User.umbral_name == body.umbral_name)
)
target = result.scalar_one_or_none()
if not target or not target.is_active:
return UmbralSearchResponse(found=False)
# Check if they accept connections
target_settings = await _get_settings_for_user(db, target.id)
if not target_settings or not target_settings.accept_connections:
return UmbralSearchResponse(found=False)
return UmbralSearchResponse(found=True)
# ── POST /request ───────────────────────────────────────────────────
@router.post("/request", response_model=ConnectionRequestResponse, status_code=201)
async def send_connection_request(
body: SendConnectionRequest,
request: Request,
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Send a connection request to another user."""
# Resolve target
result = await db.execute(
select(User).where(User.umbral_name == body.umbral_name)
)
target = result.scalar_one_or_none()
if not target or not target.is_active:
raise HTTPException(status_code=404, detail="User not found")
# Self-request guard
if target.id == current_user.id:
raise HTTPException(status_code=400, detail="Cannot send a connection request to yourself")
# Sender must have accept_connections enabled to participate
sender_settings = await _get_settings_for_user(db, current_user.id)
if not sender_settings or not sender_settings.accept_connections:
raise HTTPException(
status_code=403,
detail="You must enable 'Accept Connections' in your settings before sending requests",
)
# Check accept_connections on target
target_settings = await _get_settings_for_user(db, target.id)
if not target_settings or not target_settings.accept_connections:
raise HTTPException(status_code=404, detail="User not found")
# Check existing connection
existing_conn = await db.execute(
select(UserConnection).where(
UserConnection.user_id == current_user.id,
UserConnection.connected_user_id == target.id,
)
)
if existing_conn.scalar_one_or_none():
raise HTTPException(status_code=409, detail="Already connected")
# Check pending request in either direction
existing_req = await db.execute(
select(ConnectionRequest).where(
and_(
ConnectionRequest.status == "pending",
(
(ConnectionRequest.sender_id == current_user.id) & (ConnectionRequest.receiver_id == target.id)
) | (
(ConnectionRequest.sender_id == target.id) & (ConnectionRequest.receiver_id == current_user.id)
),
)
)
)
if existing_req.scalar_one_or_none():
raise HTTPException(status_code=409, detail="A pending request already exists")
# Per-receiver cap: max 5 pending requests within 10 minutes
ten_min_ago = datetime.now() - timedelta(minutes=10)
pending_count = await db.scalar(
select(func.count())
.select_from(ConnectionRequest)
.where(
ConnectionRequest.receiver_id == target.id,
ConnectionRequest.status == "pending",
ConnectionRequest.created_at >= ten_min_ago,
)
) or 0
if pending_count >= 5:
raise HTTPException(status_code=429, detail="Too many pending requests for this user")
# Validate person_id if provided (link existing standard contact)
link_person_id = None
if body.person_id is not None:
person_result = await db.execute(
select(Person).where(Person.id == body.person_id, Person.user_id == current_user.id)
)
link_person = person_result.scalar_one_or_none()
if not link_person:
raise HTTPException(status_code=400, detail="Person not found or not owned by you")
if link_person.is_umbral_contact:
raise HTTPException(status_code=400, detail="Person is already an umbral contact")
link_person_id = body.person_id
# Create the request (IntegrityError guard for TOCTOU race on partial unique index)
conn_request = ConnectionRequest(
sender_id=current_user.id,
receiver_id=target.id,
person_id=link_person_id,
)
db.add(conn_request)
try:
await db.flush() # populate conn_request.id for source_id
except IntegrityError:
await db.rollback()
raise HTTPException(status_code=409, detail="A pending request already exists")
# Create in-app notification for receiver (sender_settings already fetched above)
sender_display = (sender_settings.preferred_name if sender_settings else None) or current_user.umbral_name
await create_notification(
db,
user_id=target.id,
type=NOTIF_TYPE_CONNECTION_REQUEST,
title="New Connection Request",
message=f"{sender_display} wants to connect with you",
data={"sender_umbral_name": current_user.umbral_name},
source_type=NOTIF_TYPE_CONNECTION_REQUEST,
source_id=conn_request.id,
)
await log_audit_event(
db,
action="connection.request_sent",
actor_id=current_user.id,
target_id=target.id,
detail={"receiver_umbral_name": target.umbral_name},
ip=get_client_ip(request),
)
# Extract ntfy config before commit (avoids detached SA object in background task)
target_ntfy = extract_ntfy_config(target_settings) if target_settings else None
# Build response BEFORE commit — commit expires all ORM objects, and accessing
# their attributes after commit triggers lazy loads → MissingGreenlet in async SA.
response = _build_request_response(conn_request, current_user, sender_settings, target, target_settings)
await db.commit()
# ntfy push in background (non-blocking)
background_tasks.add_task(
send_connection_ntfy,
target_ntfy,
sender_display,
"request_received",
)
return response
# ── GET /requests/incoming ──────────────────────────────────────────
@router.get("/requests/incoming", response_model=list[ConnectionRequestResponse])
async def get_incoming_requests(
page: int = Query(1, ge=1),
per_page: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""List pending connection requests received by the current user."""
offset = (page - 1) * per_page
result = await db.execute(
select(ConnectionRequest)
.where(
ConnectionRequest.receiver_id == current_user.id,
ConnectionRequest.status == "pending",
)
.options(selectinload(ConnectionRequest.sender))
.order_by(ConnectionRequest.created_at.desc())
.offset(offset)
.limit(per_page)
)
requests = result.scalars().all()
# Fetch current user's settings once, batch-fetch sender settings
receiver_settings = await _get_settings_for_user(db, current_user.id)
sender_ids = [req.sender_id for req in requests]
if sender_ids:
settings_result = await db.execute(select(Settings).where(Settings.user_id.in_(sender_ids)))
settings_by_user = {s.user_id: s for s in settings_result.scalars().all()}
else:
settings_by_user = {}
responses = []
for req in requests:
sender_settings = settings_by_user.get(req.sender_id)
responses.append(_build_request_response(req, req.sender, sender_settings, current_user, receiver_settings))
return responses
# ── GET /requests/outgoing ──────────────────────────────────────────
@router.get("/requests/outgoing", response_model=list[ConnectionRequestResponse])
async def get_outgoing_requests(
page: int = Query(1, ge=1),
per_page: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""List pending connection requests sent by the current user."""
offset = (page - 1) * per_page
result = await db.execute(
select(ConnectionRequest)
.where(
ConnectionRequest.sender_id == current_user.id,
ConnectionRequest.status == "pending",
)
.options(selectinload(ConnectionRequest.receiver))
.order_by(ConnectionRequest.created_at.desc())
.offset(offset)
.limit(per_page)
)
requests = result.scalars().all()
# Fetch current user's settings once, batch-fetch receiver settings
sender_settings = await _get_settings_for_user(db, current_user.id)
receiver_ids = [req.receiver_id for req in requests]
if receiver_ids:
settings_result = await db.execute(select(Settings).where(Settings.user_id.in_(receiver_ids)))
settings_by_user = {s.user_id: s for s in settings_result.scalars().all()}
else:
settings_by_user = {}
responses = []
for req in requests:
receiver_settings = settings_by_user.get(req.receiver_id)
responses.append(_build_request_response(req, current_user, sender_settings, req.receiver, receiver_settings))
return responses
# ── PUT /requests/{id}/respond ──────────────────────────────────────
@router.put("/requests/{request_id}/respond", response_model=RespondAcceptResponse | RespondRejectResponse)
async def respond_to_request(
body: RespondRequest,
request: Request,
background_tasks: BackgroundTasks,
request_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Accept or reject a connection request. Atomic via UPDATE...WHERE status='pending'."""
try:
return await _respond_to_request_inner(body, request, background_tasks, request_id, db, current_user)
except HTTPException:
raise
except Exception:
# get_db middleware auto-rollbacks on unhandled exceptions
logger.exception("Unhandled error in respond_to_request (request_id=%s, user=%s)", request_id, current_user.id)
raise HTTPException(status_code=500, detail=f"Internal server error while processing connection response (request {request_id})")
async def _respond_to_request_inner(
body: RespondRequest,
request: Request,
background_tasks: BackgroundTasks,
request_id: int,
db: AsyncSession,
current_user: User,
) -> RespondAcceptResponse | RespondRejectResponse:
now = datetime.now()
# Atomic update — only succeeds if status is still 'pending' and receiver is current user
result = await db.execute(
update(ConnectionRequest)
.where(
ConnectionRequest.id == request_id,
ConnectionRequest.receiver_id == current_user.id,
ConnectionRequest.status == "pending",
)
.values(status=body.action + "ed", resolved_at=now)
.returning(
ConnectionRequest.id,
ConnectionRequest.sender_id,
ConnectionRequest.receiver_id,
ConnectionRequest.person_id,
)
)
row = result.first()
if not row:
raise HTTPException(status_code=409, detail="Request not found or already resolved")
sender_id = row.sender_id
request_person_id = row.person_id
if body.action == "accept":
# Verify sender is still active
sender_result = await db.execute(select(User).where(User.id == sender_id))
sender = sender_result.scalar_one_or_none()
if not sender or not sender.is_active:
# Revert to rejected
await db.execute(
update(ConnectionRequest)
.where(ConnectionRequest.id == request_id)
.values(status="rejected")
)
await db.commit()
raise HTTPException(status_code=409, detail="Sender account is no longer active")
# Get settings for both users
sender_settings = await _get_settings_for_user(db, sender_id)
receiver_settings = await _get_settings_for_user(db, current_user.id)
# Resolve shared profiles for both directions
sender_shared = resolve_shared_profile(sender, sender_settings, None) if sender_settings else {}
receiver_shared = resolve_shared_profile(current_user, receiver_settings, None) if receiver_settings else {}
# Create Person records for both users
person_for_receiver = create_person_from_connection(
current_user.id, sender, sender_settings, sender_shared
)
db.add(person_for_receiver)
# Sender side: reuse existing Person if person_id was provided on the request
person_for_sender = None
if request_person_id:
existing_result = await db.execute(
select(Person).where(Person.id == request_person_id)
)
existing_person = existing_result.scalar_one_or_none()
# Re-validate at accept time: ownership must match sender,
# and must not already be umbral (prevents double-conversion races)
if existing_person and existing_person.user_id == sender_id and not existing_person.is_umbral_contact:
# Convert existing standard contact to umbral
existing_person.linked_user_id = current_user.id
existing_person.is_umbral_contact = True
existing_person.category = "Umbral"
# Update from shared profile
first_name = receiver_shared.get("first_name") or receiver_shared.get("preferred_name") or current_user.umbral_name
last_name = receiver_shared.get("last_name")
existing_person.first_name = first_name
existing_person.last_name = last_name
existing_person.email = receiver_shared.get("email") or existing_person.email
existing_person.phone = receiver_shared.get("phone") or existing_person.phone
existing_person.mobile = receiver_shared.get("mobile") or existing_person.mobile
existing_person.address = receiver_shared.get("address") or existing_person.address
existing_person.company = receiver_shared.get("company") or existing_person.company
existing_person.job_title = receiver_shared.get("job_title") or existing_person.job_title
# Sync birthday from shared profile
birthday_str = receiver_shared.get("birthday")
if birthday_str:
try:
existing_person.birthday = date_type.fromisoformat(birthday_str)
except (ValueError, TypeError):
pass
# Recompute display name
full = ((first_name or '') + ' ' + (last_name or '')).strip()
existing_person.name = full or current_user.umbral_name
person_for_sender = existing_person
if person_for_sender is None:
person_for_sender = create_person_from_connection(
sender_id, current_user, receiver_settings, receiver_shared
)
db.add(person_for_sender)
try:
await db.flush() # populate person IDs
except IntegrityError:
await db.rollback()
raise HTTPException(status_code=409, detail="Connection already exists")
# Create bidirectional connections
conn_a = UserConnection(
user_id=current_user.id,
connected_user_id=sender_id,
person_id=person_for_receiver.id,
)
conn_b = UserConnection(
user_id=sender_id,
connected_user_id=current_user.id,
person_id=person_for_sender.id,
)
db.add(conn_a)
db.add(conn_b)
try:
await db.flush() # populate conn_a.id for source_id
except IntegrityError:
await db.rollback()
raise HTTPException(status_code=409, detail="Connection already exists")
# Notification to sender
receiver_display = (receiver_settings.preferred_name if receiver_settings else None) or current_user.umbral_name
await create_notification(
db,
user_id=sender_id,
type=NOTIF_TYPE_CONNECTION_ACCEPTED,
title="Connection Accepted",
message=f"{receiver_display} accepted your connection request",
data={"connected_umbral_name": current_user.umbral_name},
source_type="user_connection",
source_id=conn_b.id,
)
await log_audit_event(
db,
action="connection.accepted",
actor_id=current_user.id,
target_id=sender_id,
detail={"request_id": request_id},
ip=get_client_ip(request),
)
# Extract ntfy config before commit (avoids detached SA object in background task)
sender_ntfy = extract_ntfy_config(sender_settings) if sender_settings else None
try:
await db.commit()
except IntegrityError:
await db.rollback()
raise HTTPException(status_code=409, detail="Connection already exists")
# ntfy push in background
background_tasks.add_task(
send_connection_ntfy,
sender_ntfy,
receiver_display,
"request_accepted",
)
return {"message": "Connection accepted", "connection_id": conn_a.id}
else:
# Reject — only create notification for receiver (not sender per plan)
await log_audit_event(
db,
action="connection.rejected",
actor_id=current_user.id,
target_id=sender_id,
detail={"request_id": request_id},
ip=get_client_ip(request),
)
await db.commit()
return {"message": "Connection request rejected"}
# ── PUT /requests/{id}/cancel ──────────────────────────────────────
@router.put("/requests/{request_id}/cancel", response_model=CancelResponse)
async def cancel_request(
request: Request,
request_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Cancel an outgoing connection request. Atomic via UPDATE...WHERE status='pending'."""
now = datetime.now()
# Atomic update — only succeeds if sender is current user and status is still pending
result = await db.execute(
update(ConnectionRequest)
.where(
ConnectionRequest.id == request_id,
ConnectionRequest.sender_id == current_user.id,
ConnectionRequest.status == "pending",
)
.values(status="cancelled", resolved_at=now)
.returning(ConnectionRequest.id, ConnectionRequest.receiver_id)
)
row = result.first()
if not row:
raise HTTPException(status_code=409, detail="Request not found or already resolved")
receiver_id = row.receiver_id
# Silent cleanup: remove the notification sent to the receiver
await db.execute(
delete(Notification).where(
Notification.source_type == NOTIF_TYPE_CONNECTION_REQUEST,
Notification.source_id == request_id,
Notification.user_id == receiver_id,
)
)
# Look up receiver umbral_name for audit detail
receiver_result = await db.execute(select(User.umbral_name).where(User.id == receiver_id))
receiver_umbral_name = receiver_result.scalar_one_or_none() or "unknown"
await log_audit_event(
db,
action="connection.request_cancelled",
actor_id=current_user.id,
target_id=receiver_id,
detail={"request_id": request_id, "receiver_umbral_name": receiver_umbral_name},
ip=get_client_ip(request),
)
await db.commit()
return {"message": "Connection request cancelled"}
# ── GET / ───────────────────────────────────────────────────────────
@router.get("/", response_model=list[ConnectionResponse])
async def list_connections(
page: int = Query(1, ge=1),
per_page: int = Query(50, ge=1, le=100),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""List all connections for the current user."""
offset = (page - 1) * per_page
result = await db.execute(
select(UserConnection)
.where(UserConnection.user_id == current_user.id)
.options(selectinload(UserConnection.connected_user))
.order_by(UserConnection.created_at.desc())
.offset(offset)
.limit(per_page)
)
connections = result.scalars().all()
# Batch-fetch settings for connected users
connected_ids = [conn.connected_user_id for conn in connections]
if connected_ids:
settings_result = await db.execute(select(Settings).where(Settings.user_id.in_(connected_ids)))
settings_by_user = {s.user_id: s for s in settings_result.scalars().all()}
else:
settings_by_user = {}
responses = []
for conn in connections:
conn_settings = settings_by_user.get(conn.connected_user_id)
responses.append(ConnectionResponse(
id=conn.id,
connected_user_id=conn.connected_user_id,
connected_umbral_name=conn.connected_user.umbral_name,
connected_preferred_name=conn_settings.preferred_name if conn_settings else None,
person_id=conn.person_id,
created_at=conn.created_at,
))
return responses
# ── GET /{id} ───────────────────────────────────────────────────────
@router.get("/{connection_id}", response_model=ConnectionResponse)
async def get_connection(
connection_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get a single connection detail."""
result = await db.execute(
select(UserConnection)
.where(
UserConnection.id == connection_id,
UserConnection.user_id == current_user.id,
)
.options(selectinload(UserConnection.connected_user))
)
conn = result.scalar_one_or_none()
if not conn:
raise HTTPException(status_code=404, detail="Connection not found")
conn_settings = await _get_settings_for_user(db, conn.connected_user_id)
return ConnectionResponse(
id=conn.id,
connected_user_id=conn.connected_user_id,
connected_umbral_name=conn.connected_user.umbral_name,
connected_preferred_name=conn_settings.preferred_name if conn_settings else None,
person_id=conn.person_id,
created_at=conn.created_at,
)
# ── GET /{id}/shared-profile ────────────────────────────────────────
@router.get("/{connection_id}/shared-profile")
async def get_shared_profile(
connection_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get the resolved shared profile for a connection."""
result = await db.execute(
select(UserConnection)
.where(
UserConnection.id == connection_id,
UserConnection.user_id == current_user.id,
)
.options(selectinload(UserConnection.connected_user))
)
conn = result.scalar_one_or_none()
if not conn:
raise HTTPException(status_code=404, detail="Connection not found")
conn_settings = await _get_settings_for_user(db, conn.connected_user_id)
if not conn_settings:
return {}
return resolve_shared_profile(
conn.connected_user,
conn_settings,
conn.sharing_overrides,
)
# ── PUT /{id}/sharing-overrides ─────────────────────────────────────
@router.put("/{connection_id}/sharing-overrides")
async def update_sharing_overrides(
body: SharingOverrideUpdate,
connection_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Update what YOU share with a specific connection."""
# Get our connection to know who the counterpart is
our_conn = await db.execute(
select(UserConnection).where(
UserConnection.id == connection_id,
UserConnection.user_id == current_user.id,
)
)
conn = our_conn.scalar_one_or_none()
if not conn:
raise HTTPException(status_code=404, detail="Connection not found")
# Find the reverse connection (their row pointing to us)
reverse_result = await db.execute(
select(UserConnection).where(
UserConnection.user_id == conn.connected_user_id,
UserConnection.connected_user_id == current_user.id,
)
)
reverse_conn = reverse_result.scalar_one_or_none()
if not reverse_conn:
raise HTTPException(status_code=404, detail="Reverse connection not found")
# Merge validated overrides — only SHAREABLE_FIELDS keys
existing = dict(reverse_conn.sharing_overrides or {})
update_data = body.model_dump(exclude_unset=True)
for key, value in update_data.items():
if key in SHAREABLE_FIELDS:
if value is None:
existing.pop(key, None)
else:
existing[key] = value
reverse_conn.sharing_overrides = existing if existing else None
await db.commit()
return {"message": "Sharing overrides updated"}
# ── DELETE /{id} ────────────────────────────────────────────────────
@router.delete("/{connection_id}", status_code=204)
async def remove_connection(
request: Request,
connection_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Remove a connection. Removes BOTH UserConnection rows.
Detaches BOTH Person records (sets linked_user_id=null, is_umbral_contact=false).
Silent no notification sent.
"""
# Get our connection
result = await db.execute(
select(UserConnection)
.where(
UserConnection.id == connection_id,
UserConnection.user_id == current_user.id,
)
)
conn = result.scalar_one_or_none()
if not conn:
raise HTTPException(status_code=404, detail="Connection not found")
counterpart_id = conn.connected_user_id
# Find reverse connection
reverse_result = await db.execute(
select(UserConnection).where(
UserConnection.user_id == counterpart_id,
UserConnection.connected_user_id == current_user.id,
)
)
reverse_conn = reverse_result.scalar_one_or_none()
# Detach Person records
if conn.person_id:
person_result = await db.execute(select(Person).where(Person.id == conn.person_id))
person = person_result.scalar_one_or_none()
if person:
await detach_umbral_contact(person)
if reverse_conn and reverse_conn.person_id:
person_result = await db.execute(select(Person).where(Person.id == reverse_conn.person_id))
person = person_result.scalar_one_or_none()
if person:
await detach_umbral_contact(person)
# Delete both connections
await db.delete(conn)
if reverse_conn:
await db.delete(reverse_conn)
await log_audit_event(
db,
action="connection.removed",
actor_id=current_user.id,
target_id=counterpart_id,
detail={"connection_id": connection_id},
ip=get_client_ip(request),
)
await db.commit()
return None

View File

@ -0,0 +1,143 @@
"""
Notification centre router in-app notifications.
All endpoints scoped by current_user.id to prevent IDOR.
"""
from fastapi import APIRouter, Depends, HTTPException, Path, Query
from sqlalchemy import select, func, update, delete, and_
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.notification import Notification
from app.models.user import User
from app.routers.auth import get_current_user
from app.schemas.notification import (
NotificationResponse,
NotificationListResponse,
MarkReadRequest,
)
router = APIRouter()
@router.get("/", response_model=NotificationListResponse)
async def list_notifications(
unread_only: bool = Query(False),
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

View File

@ -1,14 +1,18 @@
from fastapi import APIRouter, Depends, HTTPException, Path, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, or_
from sqlalchemy.orm import selectinload
from datetime import datetime, timezone
from typing import Optional, List
from app.database import get_db
from app.models.person import Person
from app.models.settings import Settings
from app.models.user import User
from app.models.user_connection import UserConnection
from app.schemas.person import PersonCreate, PersonUpdate, PersonResponse
from app.routers.auth import get_current_user
from app.models.user import User
from app.services.connection import detach_umbral_contact, resolve_shared_profile
router = APIRouter()
@ -59,6 +63,62 @@ async def get_people(
result = await db.execute(query)
people = result.scalars().all()
# Batch-load shared profiles for umbral contacts
umbral_people = [p for p in people if p.linked_user_id is not None]
if umbral_people:
linked_user_ids = [p.linked_user_id for p in umbral_people]
# Batch fetch users and settings
users_result = await db.execute(
select(User).where(User.id.in_(linked_user_ids))
)
users_by_id = {u.id: u for u in users_result.scalars().all()}
settings_result = await db.execute(
select(Settings).where(Settings.user_id.in_(linked_user_ids))
)
settings_by_user = {s.user_id: s for s in settings_result.scalars().all()}
# Batch fetch connection overrides
conns_result = await db.execute(
select(UserConnection).where(
UserConnection.user_id == current_user.id,
UserConnection.connected_user_id.in_(linked_user_ids),
)
)
overrides_by_user = {
c.connected_user_id: c.sharing_overrides
for c in conns_result.scalars().all()
}
# Build shared profiles and track remote timestamps separately
shared_profiles: dict[int, dict] = {}
remote_timestamps: dict[int, datetime] = {}
for uid in linked_user_ids:
user = users_by_id.get(uid)
user_settings = settings_by_user.get(uid)
if user and user_settings:
shared_profiles[uid] = resolve_shared_profile(
user, user_settings, overrides_by_user.get(uid)
)
# umbral_name is always visible (public identity), not a shareable field
shared_profiles[uid]["umbral_name"] = user.umbral_name
if user.updated_at and user_settings.updated_at:
remote_timestamps[uid] = max(user.updated_at, user_settings.updated_at)
# Attach to response
responses = []
for p in people:
resp = PersonResponse.model_validate(p)
if p.linked_user_id and p.linked_user_id in shared_profiles:
resp.shared_fields = shared_profiles[p.linked_user_id]
# Show the latest update time across local record and connected user's profile
remote_updated = remote_timestamps.get(p.linked_user_id)
if remote_updated and remote_updated > p.updated_at:
resp.updated_at = remote_updated
responses.append(resp)
return responses
return people
@ -104,7 +164,34 @@ async def get_person(
if not person:
raise HTTPException(status_code=404, detail="Person not found")
return person
resp = PersonResponse.model_validate(person)
if person.linked_user_id:
linked_user_result = await db.execute(
select(User).where(User.id == person.linked_user_id)
)
linked_user = linked_user_result.scalar_one_or_none()
linked_settings_result = await db.execute(
select(Settings).where(Settings.user_id == person.linked_user_id)
)
linked_settings = linked_settings_result.scalar_one_or_none()
conn_result = await db.execute(
select(UserConnection).where(
UserConnection.user_id == current_user.id,
UserConnection.connected_user_id == person.linked_user_id,
)
)
conn = conn_result.scalar_one_or_none()
if linked_user and linked_settings:
resp.shared_fields = resolve_shared_profile(
linked_user, linked_settings, conn.sharing_overrides if conn else None
)
resp.shared_fields["umbral_name"] = linked_user.umbral_name
# Show the latest update time across local record and connected user's profile
if linked_user.updated_at and linked_settings.updated_at:
remote_updated = max(linked_user.updated_at, linked_settings.updated_at)
if remote_updated > person.updated_at:
resp.updated_at = remote_updated
return resp
@router.put("/{person_id}", response_model=PersonResponse)
@ -144,13 +231,79 @@ async def update_person(
return person
async def _sever_connection(db: AsyncSession, current_user: User, person: Person) -> None:
"""Remove bidirectional UserConnection rows and detach the counterpart's Person."""
if not person.linked_user_id:
return
counterpart_id = person.linked_user_id
# Find our connection
conn_result = await db.execute(
select(UserConnection).where(
UserConnection.user_id == current_user.id,
UserConnection.connected_user_id == counterpart_id,
)
)
our_conn = conn_result.scalar_one_or_none()
# Find reverse connection
reverse_result = await db.execute(
select(UserConnection).where(
UserConnection.user_id == counterpart_id,
UserConnection.connected_user_id == current_user.id,
)
)
reverse_conn = reverse_result.scalar_one_or_none()
# Detach the counterpart's Person record (if it exists)
if reverse_conn and reverse_conn.person_id:
cp_result = await db.execute(
select(Person).where(Person.id == reverse_conn.person_id)
)
cp_person = cp_result.scalar_one_or_none()
if cp_person:
await detach_umbral_contact(cp_person)
# Delete both connection rows
if our_conn:
await db.delete(our_conn)
if reverse_conn:
await db.delete(reverse_conn)
@router.put("/{person_id}/unlink", response_model=PersonResponse)
async def unlink_person(
person_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Unlink an umbral contact — convert to standard contact and sever the connection."""
result = await db.execute(
select(Person).where(Person.id == person_id, Person.user_id == current_user.id)
)
person = result.scalar_one_or_none()
if not person:
raise HTTPException(status_code=404, detail="Person not found")
if not person.is_umbral_contact:
raise HTTPException(status_code=400, detail="Person is not an umbral contact")
await _sever_connection(db, current_user, person)
await detach_umbral_contact(person)
await db.commit()
await db.refresh(person)
return person
@router.delete("/{person_id}", status_code=204)
async def delete_person(
person_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Delete a person."""
"""Delete a person. If umbral contact, also severs the bidirectional connection."""
result = await db.execute(
select(Person).where(Person.id == person_id, Person.user_id == current_user.id)
)
@ -159,6 +312,9 @@ async def delete_person(
if not person:
raise HTTPException(status_code=404, detail="Person not found")
if person.is_umbral_contact:
await _sever_connection(db, current_user, person)
await db.delete(person)
await db.commit()

View File

@ -39,6 +39,27 @@ def _to_settings_response(s: Settings) -> SettingsResponse:
ntfy_has_token=bool(s.ntfy_auth_token), # derived — never expose the token value
auto_lock_enabled=s.auto_lock_enabled,
auto_lock_minutes=s.auto_lock_minutes,
# Profile fields
phone=s.phone,
mobile=s.mobile,
address=s.address,
company=s.company,
job_title=s.job_title,
# Social settings
accept_connections=s.accept_connections,
# Sharing defaults
share_first_name=s.share_first_name,
share_last_name=s.share_last_name,
share_preferred_name=s.share_preferred_name,
share_email=s.share_email,
share_phone=s.share_phone,
share_mobile=s.share_mobile,
share_birthday=s.share_birthday,
share_address=s.share_address,
share_company=s.share_company,
share_job_title=s.share_job_title,
# ntfy connections toggle
ntfy_connections_enabled=s.ntfy_connections_enabled,
created_at=s.created_at,
updated_at=s.updated_at,
)

View File

@ -20,6 +20,7 @@ from app.schemas.auth import _validate_username, _validate_password_strength, _v
class UserListItem(BaseModel):
id: int
username: str
umbral_name: str = ""
email: Optional[str] = None
first_name: Optional[str] = None
last_name: Optional[str] = None

View File

@ -172,6 +172,19 @@ class ProfileUpdate(BaseModel):
last_name: str | None = Field(None, max_length=100)
email: str | None = Field(None, max_length=254)
date_of_birth: date | None = None
umbral_name: str | None = Field(None, min_length=3, max_length=50)
@field_validator("umbral_name")
@classmethod
def validate_umbral_name(cls, v: str | None) -> str | None:
if v is None:
return v
import re
if ' ' in v:
raise ValueError('Umbral name must be a single word with no spaces')
if not re.match(r'^[a-zA-Z0-9_.-]{3,50}$', v):
raise ValueError('Umbral name must be 3-50 alphanumeric characters, dots, hyphens, or underscores')
return v
@field_validator("email")
@classmethod
@ -199,6 +212,7 @@ class ProfileResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
username: str
umbral_name: str
email: str | None
first_name: str | None
last_name: str | None

View File

@ -0,0 +1,91 @@
"""
Connection schemas search, request, respond, connection management.
All input schemas use extra="forbid" to prevent mass-assignment.
"""
import re
from typing import Literal, Optional
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field, field_validator
_UMBRAL_NAME_RE = re.compile(r'^[a-zA-Z0-9_.-]{3,50}$')
class UmbralSearchRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
umbral_name: str = Field(..., max_length=50)
@field_validator('umbral_name')
@classmethod
def validate_umbral_name(cls, v: str) -> str:
if not _UMBRAL_NAME_RE.match(v):
raise ValueError('Umbral name must be 3-50 alphanumeric characters, dots, hyphens, or underscores')
return v
class UmbralSearchResponse(BaseModel):
found: bool
class SendConnectionRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
umbral_name: str = Field(..., max_length=50)
person_id: Optional[int] = Field(default=None, ge=1, le=2147483647)
@field_validator('umbral_name')
@classmethod
def validate_umbral_name(cls, v: str) -> str:
if not _UMBRAL_NAME_RE.match(v):
raise ValueError('Umbral name must be 3-50 alphanumeric characters, dots, hyphens, or underscores')
return v
class ConnectionRequestResponse(BaseModel):
id: int
sender_umbral_name: str
sender_preferred_name: Optional[str] = None
receiver_umbral_name: str
receiver_preferred_name: Optional[str] = None
status: Literal["pending", "accepted", "rejected", "cancelled"]
created_at: datetime
class RespondRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
action: Literal["accept", "reject"]
class ConnectionResponse(BaseModel):
id: int
connected_user_id: int
connected_umbral_name: str
connected_preferred_name: Optional[str] = None
person_id: Optional[int] = None
created_at: datetime
class RespondAcceptResponse(BaseModel):
message: str
connection_id: int
class RespondRejectResponse(BaseModel):
message: str
class CancelResponse(BaseModel):
message: str
class SharingOverrideUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
first_name: Optional[bool] = None
last_name: Optional[bool] = None
preferred_name: Optional[bool] = None
email: Optional[bool] = None
phone: Optional[bool] = None
mobile: Optional[bool] = None
birthday: Optional[bool] = None
address: Optional[bool] = None
company: Optional[bool] = None
job_title: Optional[bool] = None

View File

@ -0,0 +1,38 @@
from pydantic import BaseModel, ConfigDict, Field, field_validator
from datetime import datetime
from typing import Optional
class NotificationResponse(BaseModel):
id: int
user_id: int
type: str
title: Optional[str] = None
message: Optional[str] = None
data: Optional[dict] = None
source_type: Optional[str] = None
source_id: Optional[int] = None
is_read: bool
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class NotificationListResponse(BaseModel):
notifications: list[NotificationResponse]
unread_count: int
total: int
class MarkReadRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
notification_ids: list[int] = Field(..., min_length=1, max_length=100, json_schema_extra={"items": {"minimum": 1, "maximum": 2147483647}})
@field_validator('notification_ids')
@classmethod
def validate_ids(cls, v: list[int]) -> list[int]:
for i in v:
if i < 1 or i > 2147483647:
raise ValueError('Each notification ID must be between 1 and 2147483647')
return v

View File

@ -85,6 +85,9 @@ class PersonResponse(BaseModel):
company: Optional[str]
job_title: Optional[str]
notes: Optional[str]
linked_user_id: Optional[int] = None
is_umbral_contact: bool = False
shared_fields: Optional[dict] = None
created_at: datetime
updated_at: datetime

View File

@ -37,6 +37,31 @@ class SettingsUpdate(BaseModel):
auto_lock_enabled: Optional[bool] = None
auto_lock_minutes: Optional[int] = None
# Profile fields (shareable with connections)
phone: Optional[str] = Field(None, max_length=50)
mobile: Optional[str] = Field(None, max_length=50)
address: Optional[str] = Field(None, max_length=2000)
company: Optional[str] = Field(None, max_length=255)
job_title: Optional[str] = Field(None, max_length=255)
# Social settings
accept_connections: Optional[bool] = None
# Sharing defaults
share_first_name: Optional[bool] = None
share_last_name: Optional[bool] = None
share_preferred_name: Optional[bool] = None
share_email: Optional[bool] = None
share_phone: Optional[bool] = None
share_mobile: Optional[bool] = None
share_birthday: Optional[bool] = None
share_address: Optional[bool] = None
share_company: Optional[bool] = None
share_job_title: Optional[bool] = None
# ntfy connections toggle
ntfy_connections_enabled: Optional[bool] = None
@field_validator('auto_lock_minutes')
@classmethod
def validate_auto_lock_minutes(cls, v: Optional[int]) -> Optional[int]:
@ -151,6 +176,31 @@ class SettingsResponse(BaseModel):
auto_lock_enabled: bool = False
auto_lock_minutes: int = 5
# Profile fields
phone: Optional[str] = None
mobile: Optional[str] = None
address: Optional[str] = None
company: Optional[str] = None
job_title: Optional[str] = None
# Social settings
accept_connections: bool = False
# Sharing defaults
share_first_name: bool = False
share_last_name: bool = False
share_preferred_name: bool = True
share_email: bool = False
share_phone: bool = False
share_mobile: bool = False
share_birthday: bool = False
share_address: bool = False
share_company: bool = False
share_job_title: bool = False
# ntfy connections toggle
ntfy_connections_enabled: bool = True
created_at: datetime
updated_at: datetime

View File

@ -0,0 +1,208 @@
"""
Connection service shared profile resolution, Person creation, ntfy dispatch.
SHAREABLE_FIELDS is the single source of truth for which fields can be shared.
"""
import asyncio
import logging
from datetime import date as date_type
from types import SimpleNamespace
from typing import Optional
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.person import Person
from app.models.settings import Settings
from app.models.user import User
from app.services.ntfy import send_ntfy_notification
logger = logging.getLogger(__name__)
# Notification type constants — keep in sync with notifications model CHECK constraint
NOTIF_TYPE_CONNECTION_REQUEST = "connection_request"
NOTIF_TYPE_CONNECTION_ACCEPTED = "connection_accepted"
# Single source of truth — only these fields can be shared via connections
SHAREABLE_FIELDS = frozenset({
"first_name", "last_name", "preferred_name", "email", "phone", "mobile",
"birthday", "address", "company", "job_title",
})
# Maps shareable field names to their Settings model column names
_SETTINGS_FIELD_MAP = {
"first_name": None, # first_name comes from User model
"last_name": None, # last_name comes from User model
"preferred_name": "preferred_name",
"email": None, # email comes from User model
"phone": "phone",
"mobile": "mobile",
"birthday": None, # birthday comes from User model (date_of_birth)
"address": "address",
"company": "company",
"job_title": "job_title",
}
def resolve_shared_profile(
user: User,
settings: Settings,
overrides: Optional[dict] = None,
) -> dict:
"""
Merge global sharing defaults with per-connection overrides.
Returns {field: value} dict of fields the user is sharing.
Only fields in SHAREABLE_FIELDS are included.
"""
overrides = overrides or {}
result = {}
for field in SHAREABLE_FIELDS:
# Determine if this field is shared: override wins, else global default
share_key = f"share_{field}"
global_share = getattr(settings, share_key, False)
is_shared = overrides.get(field, global_share)
if not is_shared:
continue
# Resolve the actual value
if field == "first_name":
result[field] = user.first_name
elif field == "last_name":
result[field] = user.last_name
elif field == "preferred_name":
result[field] = settings.preferred_name
elif field == "email":
result[field] = user.email
elif field == "birthday":
result[field] = str(user.date_of_birth) if user.date_of_birth else None
elif field in _SETTINGS_FIELD_MAP and _SETTINGS_FIELD_MAP[field]:
result[field] = getattr(settings, _SETTINGS_FIELD_MAP[field], None)
return filter_to_shareable(result)
def filter_to_shareable(profile: dict) -> dict:
"""Strip any keys not in SHAREABLE_FIELDS. Defence-in-depth gate."""
return {k: v for k, v in profile.items() if k in SHAREABLE_FIELDS}
def create_person_from_connection(
owner_user_id: int,
connected_user: User,
connected_settings: Settings,
shared_profile: dict,
) -> Person:
"""Create a Person record for a new connection. Does NOT add to session — caller does."""
# Use shared first_name, fall back to preferred_name, then umbral_name
first_name = shared_profile.get("first_name") or shared_profile.get("preferred_name") or connected_user.umbral_name
last_name = shared_profile.get("last_name")
email = shared_profile.get("email")
phone = shared_profile.get("phone")
mobile = shared_profile.get("mobile")
address = shared_profile.get("address")
company = shared_profile.get("company")
job_title = shared_profile.get("job_title")
birthday_str = shared_profile.get("birthday")
birthday = None
if birthday_str:
try:
birthday = date_type.fromisoformat(birthday_str)
except (ValueError, TypeError):
pass
# Compute display name
full = ((first_name or '') + ' ' + (last_name or '')).strip()
display_name = full or connected_user.umbral_name
return Person(
user_id=owner_user_id,
name=display_name,
first_name=first_name,
last_name=last_name,
email=email,
phone=phone,
mobile=mobile,
address=address,
company=company,
job_title=job_title,
birthday=birthday,
category="Umbral",
linked_user_id=connected_user.id,
is_umbral_contact=True,
)
async def detach_umbral_contact(person: Person) -> None:
"""Convert an umbral contact back to a standard contact. Does NOT commit.
Preserves all person data (name, email, phone, etc.) so the user does not
lose contact information when a connection is severed. Only unlinks the
umbral association the person becomes a standard contact.
"""
person.linked_user_id = None
person.is_umbral_contact = False
person.category = None
def extract_ntfy_config(settings: Settings) -> dict | None:
"""Extract ntfy config values into a plain dict safe for use after session close."""
if not settings.ntfy_enabled or not settings.ntfy_connections_enabled:
return None
return {
"ntfy_enabled": True,
"ntfy_server_url": settings.ntfy_server_url,
"ntfy_topic": settings.ntfy_topic,
"ntfy_auth_token": settings.ntfy_auth_token,
"user_id": settings.user_id,
}
async def send_connection_ntfy(
ntfy_config: dict | None,
sender_name: str,
event_type: str,
) -> None:
"""Send ntfy push for connection events. Non-blocking with 3s timeout.
Accepts a plain dict (from extract_ntfy_config) to avoid accessing
detached SQLAlchemy objects after session close.
"""
if not ntfy_config:
return
title_map = {
"request_received": "New Connection Request",
"request_accepted": "Connection Accepted",
}
message_map = {
"request_received": f"{sender_name} wants to connect with you on Umbra",
"request_accepted": f"{sender_name} accepted your connection request",
}
tag_map = {
"request_received": ["handshake"],
"request_accepted": ["white_check_mark"],
}
title = title_map.get(event_type, "Connection Update")
message = message_map.get(event_type, f"Connection update from {sender_name}")
tags = tag_map.get(event_type, ["bell"])
# Build a settings-like object for send_ntfy_notification (avoids detached SA objects)
settings_proxy = SimpleNamespace(**ntfy_config)
try:
await asyncio.wait_for(
send_ntfy_notification(
settings=settings_proxy,
title=title,
message=message,
tags=tags,
priority=3,
),
timeout=3.0,
)
except asyncio.TimeoutError:
logger.warning("ntfy connection push timed out for user_id=%s", ntfy_config["user_id"])
except Exception:
logger.warning("ntfy connection push failed for user_id=%s", ntfy_config["user_id"])

View File

@ -0,0 +1,34 @@
"""
In-app notification service.
Creates notification records for the notification centre.
Separate from ntfy push in-app notifications are always created;
ntfy push is gated by per-type toggles.
"""
from typing import Optional
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.notification import Notification
async def create_notification(
db: AsyncSession,
user_id: int,
type: str,
title: str,
message: str,
data: Optional[dict] = None,
source_type: Optional[str] = None,
source_id: Optional[int] = None,
) -> Notification:
"""Create an in-app notification. Does NOT commit — caller handles transaction."""
notification = Notification(
user_id=user_id,
type=type,
title=title,
message=message,
data=data,
source_type=source_type,
source_id=source_id,
)
db.add(notification)
return notification

View File

@ -4,6 +4,9 @@ limit_req_zone $binary_remote_addr zone=auth_limit:10m rate=10r/m;
limit_req_zone $binary_remote_addr zone=register_limit:10m rate=5r/m;
# Admin API generous for legitimate use but still guards against scraping/brute-force
limit_req_zone $binary_remote_addr zone=admin_limit:10m rate=30r/m;
# Connection endpoints prevent search enumeration and request spam
limit_req_zone $binary_remote_addr zone=conn_search_limit:10m rate=10r/m;
limit_req_zone $binary_remote_addr zone=conn_request_limit:10m rate=3r/m;
# Use X-Forwarded-Proto from upstream proxy when present, fall back to $scheme for direct access
map $http_x_forwarded_proto $forwarded_proto {
@ -82,6 +85,20 @@ server {
include /etc/nginx/proxy-params.conf;
}
# Connection search rate-limited to prevent user enumeration
location /api/connections/search {
limit_req zone=conn_search_limit burst=5 nodelay;
limit_req_status 429;
include /etc/nginx/proxy-params.conf;
}
# Connection request (send) exact match to avoid catching /requests/*
location = /api/connections/request {
limit_req zone=conn_request_limit burst=3 nodelay;
limit_req_status 429;
include /etc/nginx/proxy-params.conf;
}
# Admin API rate-limited separately from general /api traffic
location /api/admin/ {
limit_req zone=admin_limit burst=10 nodelay;

View File

@ -12,6 +12,7 @@ import ProjectDetail from '@/components/projects/ProjectDetail';
import PeoplePage from '@/components/people/PeoplePage';
import LocationsPage from '@/components/locations/LocationsPage';
import SettingsPage from '@/components/settings/SettingsPage';
import NotificationsPage from '@/components/notifications/NotificationsPage';
const AdminPortal = lazy(() => import('@/components/admin/AdminPortal'));
@ -72,6 +73,7 @@ function App() {
<Route path="projects/:id" element={<ProjectDetail />} />
<Route path="people" element={<PeoplePage />} />
<Route path="locations" element={<LocationsPage />} />
<Route path="notifications" element={<NotificationsPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route
path="admin/*"

View File

@ -30,6 +30,11 @@ const ACTION_TYPES = [
'auth.setup_complete',
'auth.registration',
'auth.mfa_enforce_prompted',
'connection.request_sent',
'connection.request_cancelled',
'connection.accepted',
'connection.rejected',
'connection.removed',
];
function actionLabel(action: string): string {
@ -44,7 +49,7 @@ export default function ConfigPage() {
const [filterAction, setFilterAction] = useState<string>('');
const PER_PAGE = 25;
const { data, isLoading } = useAuditLog(page, PER_PAGE, filterAction || undefined);
const { data, isLoading, error } = useAuditLog(page, PER_PAGE, filterAction || undefined);
const totalPages = data ? Math.ceil(data.total / PER_PAGE) : 1;
@ -111,6 +116,11 @@ export default function ConfigPage() {
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
) : error ? (
<div className="px-5 pb-5">
<p className="text-sm text-destructive">Failed to load audit log</p>
<p className="text-xs text-muted-foreground mt-1">{error.message}</p>
</div>
) : !data?.entries?.length ? (
<p className="px-5 pb-5 text-sm text-muted-foreground">No audit entries found.</p>
) : (

View File

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

View File

@ -55,7 +55,7 @@ function MfaBadge({ enabled, pending }: { enabled: boolean; pending: boolean })
}
export default function UserDetailSection({ userId, onClose }: UserDetailSectionProps) {
const { data: user, isLoading } = useAdminUserDetail(userId);
const { data: user, isLoading, error } = useAdminUserDetail(userId);
const updateRole = useUpdateRole();
const handleRoleChange = async (newRole: UserRole) => {
@ -89,6 +89,22 @@ export default function UserDetailSection({ userId, onClose }: UserDetailSection
);
}
if (error) {
return (
<Card>
<CardContent className="p-5">
<div className="flex items-center justify-between">
<p className="text-sm text-destructive">Failed to load user details</p>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" onClick={onClose}>
<X className="h-3.5 w-3.5" />
</Button>
</div>
<p className="text-xs text-muted-foreground mt-1">{error.message}</p>
</CardContent>
</Card>
);
}
if (!user) return null;
return (

View File

@ -0,0 +1,124 @@
import { useState, useEffect } from 'react';
import { Check, X, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { formatDistanceToNow } from 'date-fns';
import { Button } from '@/components/ui/button';
import { useConnections } from '@/hooks/useConnections';
import axios from 'axios';
import { getErrorMessage } from '@/lib/api';
import { cn } from '@/lib/utils';
import type { ConnectionRequest } from '@/types';
interface ConnectionRequestCardProps {
request: ConnectionRequest;
direction: 'incoming' | 'outgoing';
}
export default function ConnectionRequestCard({ request, direction }: ConnectionRequestCardProps) {
const { respond, cancelRequest, isCancelling } = useConnections();
const [isResponding, setIsResponding] = useState(false);
const [resolved, setResolved] = useState(false);
// Clean up invisible DOM element after fade-out transition
const [hidden, setHidden] = useState(false);
useEffect(() => {
if (!resolved) return;
const timer = setTimeout(() => setHidden(true), 300);
return () => clearTimeout(timer);
}, [resolved]);
if (hidden) return null;
const handleRespond = async (action: 'accept' | 'reject') => {
setIsResponding(true);
try {
await respond({ requestId: request.id, action });
setResolved(true);
toast.success(action === 'accept' ? 'Connection accepted' : 'Request declined');
} catch (err) {
// 409 means the request was already resolved (e.g. accepted via toast or notification center)
if (axios.isAxiosError(err) && err.response?.status === 409) {
setResolved(true);
toast.success(action === 'accept' ? 'Connection already accepted' : 'Request already resolved');
} else {
toast.error(getErrorMessage(err, 'Failed to respond'));
}
} finally {
setIsResponding(false);
}
};
const handleCancel = async () => {
try {
await cancelRequest(request.id);
setResolved(true);
toast.success('Request cancelled');
} catch (err) {
toast.error(getErrorMessage(err, 'Failed to cancel request'));
}
};
const isIncoming = direction === 'incoming';
const displayName = isIncoming
? request.sender_preferred_name || request.sender_umbral_name
: request.receiver_preferred_name || request.receiver_umbral_name;
return (
<div
className={cn(
'flex items-center gap-3 rounded-lg border border-border p-3 transition-all duration-300',
resolved && 'opacity-0 translate-y-2'
)}
>
{/* Avatar */}
<div className="h-9 w-9 rounded-full bg-violet-500/15 flex items-center justify-center shrink-0">
<span className="text-sm font-medium text-violet-400">
{displayName.charAt(0).toUpperCase()}
</span>
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{displayName}</p>
<p className="text-xs text-muted-foreground">
{isIncoming ? 'wants to connect' : 'request pending'} · {formatDistanceToNow(new Date(request.created_at), { addSuffix: true })}
</p>
</div>
{/* Actions */}
<div className="flex items-center gap-1.5 shrink-0">
{isIncoming ? (
<>
<Button
size="sm"
onClick={() => handleRespond('accept')}
disabled={isResponding}
className="gap-1"
>
{isResponding ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />}
Accept
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleRespond('reject')}
disabled={isResponding}
>
<X className="h-3.5 w-3.5" />
</Button>
</>
) : (
<Button
variant="ghost"
size="sm"
onClick={handleCancel}
disabled={isCancelling}
className="text-muted-foreground hover:text-destructive hover:bg-destructive/10"
>
{isCancelling ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <X className="h-3.5 w-3.5" />}
</Button>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,178 @@
import { useState } from 'react';
import { Search, UserPlus, Loader2, AlertCircle, CheckCircle, Settings } from 'lucide-react';
import { toast } from 'sonner';
import { useNavigate } from 'react-router-dom';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { useConnections } from '@/hooks/useConnections';
import { useSettings } from '@/hooks/useSettings';
import axios from 'axios';
import { getErrorMessage } from '@/lib/api';
interface ConnectionSearchProps {
open: boolean;
onOpenChange: (open: boolean) => void;
personId?: number;
}
export default function ConnectionSearch({ open, onOpenChange, personId }: ConnectionSearchProps) {
const { search, isSearching, sendRequest, isSending } = useConnections();
const { settings, isLoading: isLoadingSettings } = useSettings();
const navigate = useNavigate();
const [umbralName, setUmbralName] = useState('');
const [found, setFound] = useState<boolean | null>(null);
const [sent, setSent] = useState(false);
const acceptConnectionsEnabled = settings?.accept_connections ?? false;
const handleSearch = async () => {
if (!umbralName.trim()) return;
setFound(null);
setSent(false);
try {
const result = await search(umbralName.trim());
setFound(result.found);
} catch (err) {
if (axios.isAxiosError(err) && err.response?.status === 429) {
toast.error('Too many searches — please wait a moment and try again');
} else {
setFound(false);
}
}
};
const handleSend = async () => {
try {
await sendRequest({ umbralName: umbralName.trim(), personId });
setSent(true);
toast.success('Connection request sent');
} catch (err) {
toast.error(getErrorMessage(err, 'Failed to send request'));
}
};
const handleClose = () => {
setUmbralName('');
setFound(null);
setSent(false);
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<UserPlus className="h-5 w-5 text-violet-400" />
Find Umbra User
</DialogTitle>
<DialogDescription>
{personId
? 'Search for an umbral user to link this contact to.'
: 'Search for a user by their umbral name to send a connection request.'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 pt-2">
{isLoadingSettings ? (
<div className="flex justify-center py-6"><Loader2 className="h-5 w-5 animate-spin text-muted-foreground" /></div>
) : !acceptConnectionsEnabled ? (
<div className="flex flex-col items-center gap-3 py-4 text-center">
<AlertCircle className="h-8 w-8 text-amber-400" />
<p className="text-sm text-muted-foreground">
You need to enable <span className="text-foreground font-medium">Accept Connections</span> in your settings before you can send or receive connection requests.
</p>
<Button
size="sm"
variant="outline"
className="gap-1.5"
onClick={() => { handleClose(); navigate('/settings'); }}
>
<Settings className="h-3.5 w-3.5" />
Go to Settings
</Button>
</div>
) : (
<>
<div className="space-y-2">
<Label htmlFor="umbral_search">Umbral Name</Label>
<div className="flex gap-2">
<Input
id="umbral_search"
placeholder="Enter umbral name..."
value={umbralName}
onChange={(e) => {
setUmbralName(e.target.value);
setFound(null);
setSent(false);
}}
onKeyDown={(e) => { if (e.key === 'Enter') handleSearch(); }}
maxLength={50}
/>
<Button
onClick={handleSearch}
disabled={!umbralName.trim() || isSearching}
size="sm"
>
{isSearching ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Search className="h-4 w-4" />
)}
</Button>
</div>
</div>
{found === false && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<AlertCircle className="h-4 w-4" />
User not found
</div>
)}
{found === true && !sent && (
<div className="flex items-center justify-between rounded-lg border border-border p-3">
<div className="flex items-center gap-2">
<div className="h-8 w-8 rounded-full bg-violet-500/15 flex items-center justify-center">
<span className="text-sm font-medium text-violet-400">
{umbralName.charAt(0).toUpperCase()}
</span>
</div>
<span className="text-sm font-medium">{umbralName}</span>
</div>
<Button
onClick={handleSend}
disabled={isSending}
size="sm"
className="gap-1.5"
>
{isSending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<UserPlus className="h-3.5 w-3.5" />
)}
Send Request
</Button>
</div>
)}
{sent && (
<div className="flex items-center gap-2 text-sm text-green-400">
<CheckCircle className="h-4 w-4" />
Connection request sent
</div>
)}
</>
)}
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -4,9 +4,11 @@ import { Menu } from 'lucide-react';
import { useTheme } from '@/hooks/useTheme';
import { AlertsProvider } from '@/hooks/useAlerts';
import { LockProvider } from '@/hooks/useLock';
import { NotificationProvider } from '@/hooks/useNotifications';
import { Button } from '@/components/ui/button';
import Sidebar from './Sidebar';
import LockOverlay from './LockOverlay';
import NotificationToaster from '@/components/notifications/NotificationToaster';
export default function AppLayout() {
useTheme();
@ -19,31 +21,34 @@ export default function AppLayout() {
return (
<LockProvider>
<AlertsProvider>
<div className="flex h-screen overflow-hidden bg-background">
<Sidebar
collapsed={collapsed}
onToggle={() => {
const next = !collapsed;
setCollapsed(next);
localStorage.setItem('umbra-sidebar-collapsed', JSON.stringify(next));
}}
mobileOpen={mobileOpen}
onMobileClose={() => setMobileOpen(false)}
/>
<div className="flex-1 flex flex-col overflow-hidden">
{/* Mobile header */}
<div className="flex md:hidden items-center h-14 border-b bg-card px-4">
<Button variant="ghost" size="icon" onClick={() => setMobileOpen(true)}>
<Menu className="h-5 w-5" />
</Button>
<h1 className="text-lg font-bold text-accent ml-3">UMBRA</h1>
<NotificationProvider>
<div className="flex h-screen overflow-hidden bg-background">
<Sidebar
collapsed={collapsed}
onToggle={() => {
const next = !collapsed;
setCollapsed(next);
localStorage.setItem('umbra-sidebar-collapsed', JSON.stringify(next));
}}
mobileOpen={mobileOpen}
onMobileClose={() => setMobileOpen(false)}
/>
<div className="flex-1 flex flex-col overflow-hidden">
{/* Mobile header */}
<div className="flex md:hidden items-center h-14 border-b bg-card px-4">
<Button variant="ghost" size="icon" onClick={() => setMobileOpen(true)}>
<Menu className="h-5 w-5" />
</Button>
<h1 className="text-lg font-bold text-accent ml-3">UMBRA</h1>
</div>
<main className="flex-1 overflow-y-auto">
<Outlet />
</main>
</div>
<main className="flex-1 overflow-y-auto">
<Outlet />
</main>
</div>
</div>
<LockOverlay />
<LockOverlay />
<NotificationToaster />
</NotificationProvider>
</AlertsProvider>
</LockProvider>
);

View File

@ -22,6 +22,7 @@ import { cn } from '@/lib/utils';
import { useAuth } from '@/hooks/useAuth';
import { useLock } from '@/hooks/useLock';
import { useConfirmAction } from '@/hooks/useConfirmAction';
import { useNotifications } from '@/hooks/useNotifications';
import { Button } from '@/components/ui/button';
import api from '@/lib/api';
import type { Project } from '@/types';
@ -47,6 +48,7 @@ export default function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose
const location = useLocation();
const { logout, isAdmin } = useAuth();
const { lock } = useLock();
const { unreadCount } = useNotifications();
const [projectsExpanded, setProjectsExpanded] = useState(false);
const { data: trackedProjects } = useQuery({
@ -194,6 +196,28 @@ export default function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose
<Lock className="h-5 w-5 shrink-0" />
{showExpanded && <span>Lock</span>}
</button>
<NavLink
to="/notifications"
onClick={mobileOpen ? onMobileClose : undefined}
className={navLinkClass}
>
<div className="relative shrink-0">
<Bell className="h-5 w-5" />
{unreadCount > 0 && !showExpanded && (
<div className="absolute -top-1 -right-1 h-2 w-2 rounded-full bg-red-500" />
)}
</div>
{showExpanded && (
<span className="flex items-center gap-2">
Notifications
{unreadCount > 0 && (
<span className="text-[10px] bg-red-500/15 text-red-400 rounded-full px-1.5 py-0.5 tabular-nums">
{unreadCount}
</span>
)}
</span>
)}
</NavLink>
{isAdmin && (
<NavLink
to="/admin"

View File

@ -0,0 +1,149 @@
import { useEffect, useRef, useCallback } from 'react';
import { toast } from 'sonner';
import { Check, X, Bell, UserPlus } from 'lucide-react';
import { useQueryClient } from '@tanstack/react-query';
import { useNotifications } from '@/hooks/useNotifications';
import { useConnections } from '@/hooks/useConnections';
import axios from 'axios';
import { getErrorMessage } from '@/lib/api';
import type { AppNotification } from '@/types';
export default function NotificationToaster() {
const { notifications, unreadCount, markRead } = useNotifications();
const { respond } = useConnections();
const queryClient = useQueryClient();
const maxSeenIdRef = useRef(0);
const initializedRef = useRef(false);
const prevUnreadRef = useRef(0);
// Track in-flight request IDs so repeated clicks are blocked
const respondingRef = useRef<Set<number>>(new Set());
// Always call the latest respond — Sonner toasts capture closures at creation time
const respondRef = useRef(respond);
respondRef.current = respond;
const markReadRef = useRef(markRead);
markReadRef.current = markRead;
const handleConnectionRespond = useCallback(
async (requestId: number, action: 'accept' | 'reject', toastId: string | number, notificationId: number) => {
// Guard against double-clicks (Sonner toasts are static, no disabled prop)
if (respondingRef.current.has(requestId)) return;
respondingRef.current.add(requestId);
// Immediately dismiss the custom toast and show a loading indicator
toast.dismiss(toastId);
const loadingId = toast.loading(
action === 'accept' ? 'Accepting connection…' : 'Declining request…',
);
try {
await respondRef.current({ requestId, action });
toast.dismiss(loadingId);
toast.success(action === 'accept' ? 'Connection accepted' : 'Request declined');
markReadRef.current([notificationId]).catch(() => {});
} catch (err) {
toast.dismiss(loadingId);
// 409 means the request was already resolved (e.g. accepted via notification center)
if (axios.isAxiosError(err) && err.response?.status === 409) {
toast.success(action === 'accept' ? 'Connection already accepted' : 'Request already resolved');
markReadRef.current([notificationId]).catch(() => {});
} else {
toast.error(getErrorMessage(err, 'Failed to respond to request'));
}
} finally {
respondingRef.current.delete(requestId);
}
},
[],
);
// Track unread count changes to force-refetch the list
useEffect(() => {
if (unreadCount > prevUnreadRef.current && initializedRef.current) {
queryClient.invalidateQueries({ queryKey: ['notifications', 'list'] });
}
prevUnreadRef.current = unreadCount;
}, [unreadCount, queryClient]);
// Show toasts for new notifications (ID > max seen)
useEffect(() => {
if (!notifications.length) return;
// On first load, record the max ID without toasting
if (!initializedRef.current) {
maxSeenIdRef.current = Math.max(...notifications.map((n) => n.id));
initializedRef.current = true;
return;
}
// Find unread notifications with IDs higher than our watermark
const newNotifications = notifications.filter(
(n) => !n.is_read && n.id > maxSeenIdRef.current,
);
// Advance watermark
const maxCurrent = Math.max(...notifications.map((n) => n.id));
if (maxCurrent > maxSeenIdRef.current) {
maxSeenIdRef.current = maxCurrent;
}
// Eagerly refresh incoming requests when connection_request notifications arrive
// so accept buttons work immediately on NotificationsPage / PeoplePage
if (newNotifications.some((n) => n.type === 'connection_request')) {
queryClient.invalidateQueries({ queryKey: ['connections', 'incoming'] });
}
// Show toasts
newNotifications.forEach((notification) => {
if (notification.type === 'connection_request' && notification.source_id) {
showConnectionRequestToast(notification);
} else {
toast(notification.title || 'New Notification', {
description: notification.message || undefined,
icon: <Bell className="h-4 w-4" />,
duration: 8000,
});
}
});
}, [notifications, handleConnectionRespond]);
const showConnectionRequestToast = (notification: AppNotification) => {
const requestId = notification.source_id!;
toast.custom(
(id) => (
<div className="w-[356px] rounded-lg border border-border bg-card p-4 shadow-lg">
<div className="flex items-start gap-3">
<div className="h-9 w-9 rounded-full bg-violet-500/15 flex items-center justify-center shrink-0">
<UserPlus className="h-4 w-4 text-violet-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground">Connection Request</p>
<p className="text-xs text-muted-foreground mt-0.5">
{notification.message || 'Someone wants to connect with you'}
</p>
<div className="flex items-center gap-2 mt-3">
<button
onClick={() => handleConnectionRespond(requestId, 'accept', id, notification.id)}
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-md bg-accent text-accent-foreground hover:bg-accent/90 transition-colors"
>
<Check className="h-3.5 w-3.5" />
Accept
</button>
<button
onClick={() => handleConnectionRespond(requestId, 'reject', id, notification.id)}
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-md text-muted-foreground hover:bg-card-elevated transition-colors"
>
<X className="h-3.5 w-3.5" />
Reject
</button>
</div>
</div>
</div>
</div>
),
{ id: `connection-request-${requestId}`, duration: 30000 },
);
};
return null;
}

View File

@ -0,0 +1,285 @@
import { useState, useMemo, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { Bell, Check, CheckCheck, Trash2, UserPlus, Info, AlertCircle, X, Loader2 } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
import { toast } from 'sonner';
import { useNotifications } from '@/hooks/useNotifications';
import { useConnections } from '@/hooks/useConnections';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import axios from 'axios';
import { getErrorMessage } from '@/lib/api';
import { ListSkeleton } from '@/components/ui/skeleton';
import type { AppNotification } from '@/types';
const typeIcons: Record<string, { icon: typeof Bell; color: string }> = {
connection_request: { icon: UserPlus, color: 'text-violet-400' },
connection_accepted: { icon: UserPlus, color: 'text-green-400' },
info: { icon: Info, color: 'text-blue-400' },
warning: { icon: AlertCircle, color: 'text-amber-400' },
};
type Filter = 'all' | 'unread';
export default function NotificationsPage() {
const {
notifications,
unreadCount,
isLoading,
markRead,
markAllRead,
deleteNotification,
} = useNotifications();
const { incomingRequests, respond, isResponding } = useConnections();
const queryClient = useQueryClient();
const navigate = useNavigate();
const [filter, setFilter] = useState<Filter>('all');
// Build a set of pending connection request IDs for quick lookup
const pendingRequestIds = useMemo(
() => new Set(incomingRequests.map((r) => r.id)),
[incomingRequests],
);
// Eagerly fetch incoming requests when notifications contain connection_request
// entries whose source_id isn't in pendingRequestIds yet (stale connections data)
useEffect(() => {
const hasMissing = notifications.some(
(n) => n.type === 'connection_request' && n.source_id && !n.is_read && !pendingRequestIds.has(n.source_id),
);
if (hasMissing) {
queryClient.invalidateQueries({ queryKey: ['connections', 'incoming'] });
}
}, [notifications, pendingRequestIds, queryClient]);
const filtered = useMemo(() => {
if (filter === 'unread') return notifications.filter((n) => !n.is_read);
return notifications;
}, [notifications, filter]);
const handleMarkRead = async (id: number) => {
try {
await markRead([id]);
} catch { /* toast handled by mutation */ }
};
const handleDelete = async (id: number) => {
try {
await deleteNotification(id);
} catch { /* toast handled by mutation */ }
};
const handleMarkAllRead = async () => {
try {
await markAllRead();
} catch { /* toast handled by mutation */ }
};
const getIcon = (type: string) => {
const config = typeIcons[type] || { icon: Bell, color: 'text-muted-foreground' };
return config;
};
const handleConnectionRespond = async (
notification: AppNotification,
action: 'accept' | 'reject',
) => {
if (!notification.source_id) return;
try {
await respond({ requestId: notification.source_id, action });
if (!notification.is_read) {
await markRead([notification.id]).catch(() => {});
}
toast.success(action === 'accept' ? 'Connection accepted' : 'Request declined');
} catch (err) {
// 409 means the request was already resolved (e.g. accepted via toast)
if (axios.isAxiosError(err) && err.response?.status === 409) {
if (!notification.is_read) {
await markRead([notification.id]).catch(() => {});
}
toast.success(action === 'accept' ? 'Connection already accepted' : 'Request already resolved');
} else {
toast.error(getErrorMessage(err, 'Failed to respond'));
}
}
};
const handleNotificationClick = async (notification: AppNotification) => {
// Don't navigate for pending connection requests — let user act inline
if (
notification.type === 'connection_request' &&
notification.source_id &&
pendingRequestIds.has(notification.source_id)
) {
return;
}
if (!notification.is_read) {
await markRead([notification.id]).catch(() => {});
}
// Navigate to People for connection-related notifications
if (notification.type === 'connection_request' || notification.type === 'connection_accepted') {
navigate('/people');
}
};
return (
<div className="flex flex-col h-full animate-fade-in">
{/* Page header */}
<div className="border-b bg-card px-6 h-16 flex items-center justify-between shrink-0">
<div className="flex items-center gap-3">
<Bell className="h-5 w-5 text-accent" aria-hidden="true" />
<h1 className="text-xl font-semibold font-heading">Notifications</h1>
</div>
<div className="flex items-center gap-2">
{/* Filter */}
<div className="flex items-center rounded-md border border-border overflow-hidden">
{(['all', 'unread'] as const).map((f) => (
<button
key={f}
onClick={() => setFilter(f)}
className={cn(
'px-3 py-1.5 text-xs font-medium transition-colors capitalize',
filter === f
? 'bg-accent/15 text-accent'
: 'text-muted-foreground hover:text-foreground hover:bg-card-elevated'
)}
>
{f}
{f === 'unread' && unreadCount > 0 && (
<span className="ml-1.5 text-[10px] bg-red-500/15 text-red-400 rounded-full px-1.5 py-0.5 tabular-nums">
{unreadCount}
</span>
)}
</button>
))}
</div>
{unreadCount > 0 && (
<Button
variant="ghost"
size="sm"
onClick={handleMarkAllRead}
className="text-xs gap-1.5"
>
<CheckCheck className="h-3.5 w-3.5" />
Mark all read
</Button>
)}
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto">
{isLoading ? (
<div className="p-6">
<ListSkeleton rows={5} />
</div>
) : filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-3 py-20">
<Bell className="h-10 w-10 opacity-30" />
<p className="text-sm">
{filter === 'unread' ? 'No unread notifications' : 'No notifications'}
</p>
</div>
) : (
<div className="divide-y divide-border">
{filtered.map((notification) => {
const iconConfig = getIcon(notification.type);
const Icon = iconConfig.icon;
return (
<div
key={notification.id}
onClick={() => handleNotificationClick(notification)}
className={cn(
'flex items-start gap-3 px-6 py-3.5 transition-colors hover:bg-card-elevated group cursor-pointer',
!notification.is_read && 'bg-card'
)}
>
{/* Type icon */}
<div className={cn('mt-0.5 shrink-0', iconConfig.color)}>
<Icon className="h-4 w-4" />
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-start gap-2">
<div className="flex-1 min-w-0">
<p className={cn(
'text-sm truncate',
!notification.is_read ? 'font-medium text-foreground' : 'text-muted-foreground'
)}>
{notification.title}
</p>
{notification.message && (
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
{notification.message}
</p>
)}
</div>
{/* Unread dot */}
{!notification.is_read && (
<div className="h-2 w-2 rounded-full bg-accent shrink-0 mt-1.5" />
)}
</div>
</div>
{/* Connection request actions (inline) */}
{notification.type === 'connection_request' &&
notification.source_id &&
pendingRequestIds.has(notification.source_id) && (
<div className="flex items-center gap-1.5 shrink-0">
<Button
size="sm"
onClick={(e) => { e.stopPropagation(); handleConnectionRespond(notification, 'accept'); }}
disabled={isResponding}
className="gap-1 h-7 text-xs"
>
{isResponding ? <Loader2 className="h-3 w-3 animate-spin" /> : <Check className="h-3 w-3" />}
Accept
</Button>
<Button
variant="ghost"
size="sm"
onClick={(e) => { e.stopPropagation(); handleConnectionRespond(notification, 'reject'); }}
disabled={isResponding}
className="h-7 text-xs"
>
<X className="h-3 w-3" />
</Button>
</div>
)}
{/* Timestamp + actions */}
<div className="flex items-center gap-1.5 shrink-0">
<span className="text-[11px] text-muted-foreground tabular-nums">
{formatDistanceToNow(new Date(notification.created_at), { addSuffix: true })}
</span>
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
{!notification.is_read && (
<button
onClick={(e) => { e.stopPropagation(); handleMarkRead(notification.id); }}
className="p-1 rounded hover:bg-accent/10 text-muted-foreground hover:text-accent transition-colors"
title="Mark as read"
>
<Check className="h-3.5 w-3.5" />
</button>
)}
<button
onClick={(e) => { e.stopPropagation(); handleDelete(notification.id); }}
className="p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
title="Delete"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
);
}

View File

@ -1,5 +1,5 @@
import { useState, useMemo, useRef, useEffect } from 'react';
import { Plus, Users, Star, Cake, Phone, Mail, MapPin, Tag, Building2, Briefcase, AlignLeft } from 'lucide-react';
import { Plus, Users, Star, Cake, Phone, Mail, MapPin, Tag, Building2, Briefcase, AlignLeft, Ghost, ChevronDown, Unlink, Link2, User2 } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { format, parseISO, differenceInYears } from 'date-fns';
@ -23,6 +23,9 @@ import {
import { useTableVisibility } from '@/hooks/useTableVisibility';
import { useCategoryOrder } from '@/hooks/useCategoryOrder';
import PersonForm from './PersonForm';
import ConnectionSearch from '@/components/connections/ConnectionSearch';
import ConnectionRequestCard from '@/components/connections/ConnectionRequestCard';
import { useConnections } from '@/hooks/useConnections';
// ---------------------------------------------------------------------------
// StatCounter — inline helper
@ -57,7 +60,11 @@ function StatCounter({
// Helpers
// ---------------------------------------------------------------------------
function getPersonInitialsName(p: Person): string {
const parts = [p.first_name, p.last_name].filter(Boolean);
const firstName = p.is_umbral_contact && p.shared_fields?.first_name
? String(p.shared_fields.first_name) : p.first_name;
const lastName = p.is_umbral_contact && p.shared_fields?.last_name
? String(p.shared_fields.last_name) : p.last_name;
const parts = [firstName, lastName].filter(Boolean);
return parts.length > 0 ? parts.join(' ') : p.name;
}
@ -82,6 +89,14 @@ function sortPeople(people: Person[], key: string, dir: 'asc' | 'desc'): Person[
// ---------------------------------------------------------------------------
// Column definitions
// ---------------------------------------------------------------------------
/** Get a field value, preferring shared_fields for umbral contacts. */
function sf(p: Person, key: string): string | null | undefined {
if (p.is_umbral_contact && p.shared_fields && key in p.shared_fields) {
return p.shared_fields[key] as string | null;
}
return p[key as keyof Person] as string | null | undefined;
}
const columns: ColumnDef<Person>[] = [
{
key: 'name',
@ -89,7 +104,10 @@ const columns: ColumnDef<Person>[] = [
sortable: true,
visibilityLevel: 'essential',
render: (p) => {
const initialsName = getPersonInitialsName(p);
const firstName = sf(p, 'first_name');
const lastName = sf(p, 'last_name');
const liveName = [firstName, lastName].filter(Boolean).join(' ') || p.nickname || p.name;
const initialsName = liveName || getPersonInitialsName(p);
return (
<div className="flex items-center gap-2.5">
<div
@ -97,7 +115,10 @@ const columns: ColumnDef<Person>[] = [
>
{getInitials(initialsName)}
</div>
<span className="font-medium truncate">{p.nickname || p.name}</span>
<span className="font-medium truncate">{liveName}</span>
{p.is_umbral_contact && (
<Ghost className="h-3.5 w-3.5 text-violet-400 shrink-0" aria-label="Umbral contact" />
)}
</div>
);
},
@ -107,18 +128,21 @@ const columns: ColumnDef<Person>[] = [
label: 'Number',
sortable: false,
visibilityLevel: 'essential',
render: (p) => (
<span className="text-muted-foreground truncate">{p.mobile || p.phone || '—'}</span>
),
render: (p) => {
const mobile = sf(p, 'mobile');
const phone = sf(p, 'phone');
return <span className="text-muted-foreground truncate">{mobile || phone || '—'}</span>;
},
},
{
key: 'email',
label: 'Email',
sortable: true,
visibilityLevel: 'essential',
render: (p) => (
<span className="text-muted-foreground truncate">{p.email || '—'}</span>
),
render: (p) => {
const email = sf(p, 'email');
return <span className="text-muted-foreground truncate">{email || '—'}</span>;
},
},
{
key: 'job_title',
@ -126,10 +150,10 @@ const columns: ColumnDef<Person>[] = [
sortable: true,
visibilityLevel: 'filtered',
render: (p) => {
const parts = [p.job_title, p.company].filter(Boolean);
return (
<span className="text-muted-foreground truncate">{parts.join(', ') || '—'}</span>
);
const jobTitle = sf(p, 'job_title');
const company = sf(p, 'company');
const parts = [jobTitle, company].filter(Boolean);
return <span className="text-muted-foreground truncate">{parts.join(', ') || '—'}</span>;
},
},
{
@ -137,12 +161,14 @@ const columns: ColumnDef<Person>[] = [
label: 'Birthday',
sortable: true,
visibilityLevel: 'filtered',
render: (p) =>
p.birthday ? (
<span className="text-muted-foreground">{format(parseISO(p.birthday), 'MMM d')}</span>
render: (p) => {
const birthday = sf(p, 'birthday');
return birthday ? (
<span className="text-muted-foreground">{format(parseISO(birthday), 'MMM d')}</span>
) : (
<span className="text-muted-foreground"></span>
),
);
},
},
{
key: 'category',
@ -170,6 +196,7 @@ const columns: ColumnDef<Person>[] = [
// Panel field config
// ---------------------------------------------------------------------------
const panelFields: PanelField[] = [
{ label: 'Preferred Name', key: 'preferred_name', icon: User2 },
{ label: 'Mobile', key: 'mobile', copyable: true, icon: Phone },
{ label: 'Phone', key: 'phone', copyable: true, icon: Phone },
{ label: 'Email', key: 'email', copyable: true, icon: Mail },
@ -193,9 +220,17 @@ export default function PeoplePage() {
const [editingPerson, setEditingPerson] = useState<Person | null>(null);
const [activeFilters, setActiveFilters] = useState<string[]>([]);
const [showPinned, setShowPinned] = useState(true);
const [showUmbralOnly, setShowUmbralOnly] = useState(false);
const [search, setSearch] = useState('');
const [sortKey, setSortKey] = useState<string>('name');
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
const [showConnectionSearch, setShowConnectionSearch] = useState(false);
const [linkPersonId, setLinkPersonId] = useState<number | null>(null);
const [showAddDropdown, setShowAddDropdown] = useState(false);
const addDropdownRef = useRef<HTMLDivElement>(null);
const { incomingRequests, outgoingRequests } = useConnections();
const hasRequests = incomingRequests.length > 0 || outgoingRequests.length > 0;
const { data: people = [], isLoading } = useQuery({
queryKey: ['people'],
@ -228,6 +263,10 @@ export default function PeoplePage() {
? people.filter((p) => !p.is_favourite)
: people;
if (showUmbralOnly) {
list = list.filter((p) => p.is_umbral_contact);
}
if (activeFilters.length > 0) {
list = list.filter((p) => p.category && activeFilters.includes(p.category));
}
@ -249,7 +288,7 @@ export default function PeoplePage() {
}
return sortPeople(list, sortKey, sortDir);
}, [people, showPinned, activeFilters, search, sortKey, sortDir]);
}, [people, showPinned, showUmbralOnly, activeFilters, search, sortKey, sortDir]);
// Build row groups for the table — ordered by custom category order
const groups = useMemo(() => {
@ -314,6 +353,7 @@ export default function PeoplePage() {
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['people'] });
queryClient.invalidateQueries({ queryKey: ['connections'] });
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
queryClient.invalidateQueries({ queryKey: ['upcoming'] });
toast.success('Person deleted');
@ -324,6 +364,22 @@ export default function PeoplePage() {
},
});
// Unlink umbral contact mutation
const unlinkMutation = useMutation({
mutationFn: async (personId: number) => {
const { data } = await api.put(`/people/${personId}/unlink`);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['people'] });
queryClient.invalidateQueries({ queryKey: ['connections'] });
toast.success('Contact unlinked — converted to standard contact');
},
onError: (error) => {
toast.error(getErrorMessage(error, 'Failed to unlink contact'));
},
});
// Toggle favourite mutation
const toggleFavouriteMutation = useMutation({
mutationFn: async (person: Person) => {
@ -347,6 +403,18 @@ export default function PeoplePage() {
return () => document.removeEventListener('keydown', handler);
}, [panelOpen]);
// Close add dropdown on outside click
useEffect(() => {
if (!showAddDropdown) return;
const handler = (e: MouseEvent) => {
if (addDropdownRef.current && !addDropdownRef.current.contains(e.target as Node)) {
setShowAddDropdown(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [showAddDropdown]);
const handleCloseForm = () => {
setShowForm(false);
setEditingPerson(null);
@ -363,17 +431,75 @@ export default function PeoplePage() {
{getInitials(initialsName)}
</div>
<div className="min-w-0">
<h3 className="font-heading text-lg font-semibold truncate">{p.name}</h3>
{p.category && (
<span className="text-xs text-muted-foreground">{p.category}</span>
)}
<div className="flex items-center gap-2">
<h3 className="font-heading text-lg font-semibold truncate">{
p.is_umbral_contact && p.shared_fields
? [sf(p, 'first_name'), sf(p, 'last_name')].filter(Boolean).join(' ') || p.name
: p.name
}</h3>
{p.is_umbral_contact && (
<Ghost className="h-4 w-4 text-violet-400 shrink-0" />
)}
</div>
<div className="flex items-center gap-2">
{p.is_umbral_contact && p.shared_fields?.umbral_name ? (
<span className="text-xs text-violet-400/80 font-normal">
@{String(p.shared_fields.umbral_name)}
</span>
) : null}
{p.category && (
<span className="text-xs text-muted-foreground">{p.category}</span>
)}
</div>
</div>
</div>
);
};
// Panel getValue
// Shared field key mapping (panel key -> shared_fields key)
const sharedKeyMap: Record<string, string> = {
preferred_name: 'preferred_name',
email: 'email',
phone: 'phone',
mobile: 'mobile',
birthday_display: 'birthday',
address: 'address',
company: 'company',
job_title: 'job_title',
};
// Build dynamic panel fields with synced labels for shared fields
const dynamicPanelFields = useMemo((): PanelField[] => {
if (!selectedPerson?.is_umbral_contact || !selectedPerson.shared_fields) return panelFields;
const shared = selectedPerson.shared_fields;
return panelFields.map((f) => {
const sharedKey = sharedKeyMap[f.key];
if (sharedKey && sharedKey in shared) {
return { ...f, label: `${f.label} (synced)` };
}
return f;
});
}, [selectedPerson]);
// Panel getValue — overlays shared fields from connected user
const getPanelValue = (p: Person, key: string): string | undefined => {
// Check shared fields first for umbral contacts
if (p.is_umbral_contact && p.shared_fields) {
const sharedKey = sharedKeyMap[key];
if (sharedKey && sharedKey in p.shared_fields) {
const sharedVal = p.shared_fields[sharedKey];
if (key === 'birthday_display' && sharedVal) {
const bd = String(sharedVal);
try {
const age = differenceInYears(new Date(), parseISO(bd));
return `${format(parseISO(bd), 'MMM d, yyyy')} (${age})`;
} catch {
return bd;
}
}
return sharedVal != null ? String(sharedVal) : undefined;
}
}
if (key === 'birthday_display' && p.birthday) {
const age = differenceInYears(new Date(), parseISO(p.birthday));
return `${format(parseISO(p.birthday), 'MMM d, yyyy')} (${age})`;
@ -385,7 +511,7 @@ export default function PeoplePage() {
const renderPanel = () => (
<EntityDetailPanel<Person>
item={selectedPerson}
fields={panelFields}
fields={dynamicPanelFields}
onEdit={() => {
setEditingPerson(selectedPerson);
setShowForm(true);
@ -399,6 +525,30 @@ export default function PeoplePage() {
isFavourite={selectedPerson?.is_favourite}
onToggleFavourite={() => selectedPerson && toggleFavouriteMutation.mutate(selectedPerson)}
favouriteLabel="favourite"
extraActions={(p) =>
p.is_umbral_contact ? (
<Button
variant="ghost"
size="sm"
onClick={() => unlinkMutation.mutate(p.id)}
disabled={unlinkMutation.isPending}
className="h-7 text-[11px] text-muted-foreground hover:text-foreground gap-1"
>
<Unlink className="h-3 w-3" />
Unlink
</Button>
) : (
<Button
variant="ghost"
size="sm"
onClick={() => setLinkPersonId(p.id)}
className="h-7 text-[11px] text-muted-foreground hover:text-foreground gap-1"
>
<Link2 className="h-3 w-3" />
Link
</Button>
)
}
/>
);
@ -420,12 +570,53 @@ export default function PeoplePage() {
onReorderCategories={reorderCategories}
searchValue={search}
onSearchChange={setSearch}
extraPinnedFilters={[
{
label: 'Umbral',
isActive: showUmbralOnly,
onToggle: () => setShowUmbralOnly((p) => !p),
},
]}
/>
</div>
<Button onClick={() => setShowForm(true)} size="sm" aria-label="Add person">
<Plus className="mr-2 h-4 w-4" />
Add Person
</Button>
<div className="relative" ref={addDropdownRef}>
<div className="flex">
<Button
onClick={() => setShowForm(true)}
size="sm"
aria-label="Add person"
className="rounded-r-none"
>
<Plus className="mr-2 h-4 w-4" />
Add Person
</Button>
<Button
size="sm"
onClick={() => setShowAddDropdown((p) => !p)}
aria-label="More add options"
className="rounded-l-none border-l border-background/20 px-1.5"
>
<ChevronDown className="h-3.5 w-3.5" />
</Button>
</div>
{showAddDropdown && (
<div className="absolute right-0 top-full mt-1 w-44 rounded-md border border-border bg-card shadow-lg z-50 py-1">
<button
className="w-full text-left px-3 py-1.5 text-sm hover:bg-card-elevated transition-colors"
onClick={() => { setShowAddDropdown(false); setShowForm(true); }}
>
Standard Contact
</button>
<button
className="w-full text-left px-3 py-1.5 text-sm hover:bg-card-elevated transition-colors flex items-center gap-2"
onClick={() => { setShowAddDropdown(false); setShowConnectionSearch(true); }}
>
<Ghost className="h-3.5 w-3.5 text-violet-400" />
Umbra Contact
</button>
</div>
)}
</div>
</div>
<div className="flex-1 overflow-hidden flex flex-col">
@ -472,6 +663,40 @@ export default function PeoplePage() {
</div>
)}
{/* Pending requests */}
{hasRequests && (
<div className="px-6 pb-3">
<div className="flex items-center gap-2 mb-2">
<span className="text-xs text-muted-foreground uppercase tracking-wider font-medium">
Pending Requests
</span>
<span className="text-[10px] tabular-nums bg-accent/15 text-accent px-1.5 py-0.5 rounded-full font-medium">
{incomingRequests.length + outgoingRequests.length}
</span>
</div>
<div className="space-y-2">
{incomingRequests.length > 0 && outgoingRequests.length > 0 && (
<p className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider">Incoming</p>
)}
{incomingRequests.slice(0, 5).map((req) => (
<ConnectionRequestCard key={req.id} request={req} direction="incoming" />
))}
{incomingRequests.length > 5 && (
<p className="text-xs text-muted-foreground">+{incomingRequests.length - 5} more</p>
)}
{incomingRequests.length > 0 && outgoingRequests.length > 0 && (
<p className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider mt-3">Outgoing</p>
)}
{outgoingRequests.slice(0, 5).map((req) => (
<ConnectionRequestCard key={req.id} request={req} direction="outgoing" />
))}
{outgoingRequests.length > 5 && (
<p className="text-xs text-muted-foreground">+{outgoingRequests.length - 5} more</p>
)}
</div>
</div>
)}
{/* Main content: table + panel */}
<div className="flex-1 overflow-hidden flex">
{/* Table */}
@ -558,6 +783,17 @@ export default function PeoplePage() {
onClose={handleCloseForm}
/>
)}
<ConnectionSearch
open={showConnectionSearch}
onOpenChange={setShowConnectionSearch}
/>
<ConnectionSearch
open={linkPersonId !== null}
onOpenChange={(open) => { if (!open) setLinkPersonId(null); }}
personId={linkPersonId ?? undefined}
/>
</div>
);
}

View File

@ -1,7 +1,7 @@
import { useState, useMemo, FormEvent } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Star, StarOff, X } from 'lucide-react';
import { Star, StarOff, X, Lock } from 'lucide-react';
import { parseISO, differenceInYears } from 'date-fns';
import api, { getErrorMessage } from '@/lib/api';
import type { Person } from '@/types';
@ -30,6 +30,11 @@ interface PersonFormProps {
export default function PersonForm({ person, categories, onClose }: PersonFormProps) {
const queryClient = useQueryClient();
// Helper to resolve a field value — prefer shared_fields for umbral contacts
const sf = person?.shared_fields;
const shared = (key: string, fallback: string) =>
sf && key in sf && sf[key] != null ? String(sf[key]) : fallback;
const [formData, setFormData] = useState({
first_name:
person?.first_name ||
@ -38,20 +43,24 @@ export default function PersonForm({ person, categories, onClose }: PersonFormPr
person?.last_name ||
(person?.name ? splitName(person.name).lastName : ''),
nickname: person?.nickname || '',
email: person?.email || '',
phone: person?.phone || '',
mobile: person?.mobile || '',
address: person?.address || '',
birthday: person?.birthday
? person.birthday.slice(0, 10)
: '',
email: shared('email', person?.email || ''),
phone: shared('phone', person?.phone || ''),
mobile: shared('mobile', person?.mobile || ''),
address: shared('address', person?.address || ''),
birthday: shared('birthday', person?.birthday ? person.birthday.slice(0, 10) : ''),
category: person?.category || '',
is_favourite: person?.is_favourite ?? false,
company: person?.company || '',
job_title: person?.job_title || '',
company: shared('company', person?.company || ''),
job_title: shared('job_title', person?.job_title || ''),
notes: person?.notes || '',
});
// Check if a field is synced from an umbral connection (read-only)
const isShared = (fieldKey: string): boolean => {
if (!person?.is_umbral_contact || !person.shared_fields) return false;
return fieldKey in person.shared_fields;
};
const age = useMemo(() => {
if (!formData.birthday) return null;
try {
@ -165,13 +174,25 @@ export default function PersonForm({ person, categories, onClose }: PersonFormPr
{/* Row 4: Birthday + Age */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="birthday">Birthday</Label>
<Label htmlFor="birthday" className="flex items-center gap-1">
Birthday
{isShared('birthday') && <Lock className="h-3 w-3 text-violet-400" />}
</Label>
{isShared('birthday') ? (
<Input
id="birthday"
value={formData.birthday}
disabled
className="opacity-70 cursor-not-allowed"
/>
) : (
<DatePicker
variant="input"
id="birthday"
value={formData.birthday}
onChange={(v) => set('birthday', v)}
/>
)}
</div>
<div className="space-y-2">
<Label htmlFor="age">Age</Label>
@ -200,65 +221,102 @@ export default function PersonForm({ person, categories, onClose }: PersonFormPr
{/* Row 6: Mobile + Email */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="mobile">Mobile</Label>
<Label htmlFor="mobile" className="flex items-center gap-1">
Mobile
{isShared('mobile') && <Lock className="h-3 w-3 text-violet-400" />}
</Label>
<Input
id="mobile"
type="tel"
value={formData.mobile}
onChange={(e) => set('mobile', e.target.value)}
disabled={isShared('mobile')}
className={isShared('mobile') ? 'opacity-70 cursor-not-allowed' : ''}
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Label htmlFor="email" className="flex items-center gap-1">
Email
{isShared('email') && <Lock className="h-3 w-3 text-violet-400" />}
</Label>
<Input
id="email"
type="email"
value={formData.email}
onChange={(e) => set('email', e.target.value)}
disabled={isShared('email')}
className={isShared('email') ? 'opacity-70 cursor-not-allowed' : ''}
/>
</div>
</div>
{/* Row 7: Phone */}
<div className="space-y-2">
<Label htmlFor="phone">Phone</Label>
<Label htmlFor="phone" className="flex items-center gap-1">
Phone
{isShared('phone') && <Lock className="h-3 w-3 text-violet-400" />}
</Label>
<Input
id="phone"
type="tel"
value={formData.phone}
onChange={(e) => set('phone', e.target.value)}
placeholder="Landline / work number"
disabled={isShared('phone')}
className={isShared('phone') ? 'opacity-70 cursor-not-allowed' : ''}
/>
</div>
{/* Row 8: Address */}
<div className="space-y-2">
<Label htmlFor="address">Address</Label>
<LocationPicker
id="address"
value={formData.address}
onChange={(val) => set('address', val)}
onSelect={(result) => set('address', result.address || result.name)}
placeholder="Search or enter address..."
/>
<Label htmlFor="address" className="flex items-center gap-1">
Address
{isShared('address') && <Lock className="h-3 w-3 text-violet-400" />}
</Label>
{isShared('address') ? (
<Input
id="address"
value={formData.address}
disabled
className="opacity-70 cursor-not-allowed"
/>
) : (
<LocationPicker
id="address"
value={formData.address}
onChange={(val) => set('address', val)}
onSelect={(result) => set('address', result.address || result.name)}
placeholder="Search or enter address..."
/>
)}
</div>
{/* Row 9: Company + Job Title */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="company">Company</Label>
<Label htmlFor="company" className="flex items-center gap-1">
Company
{isShared('company') && <Lock className="h-3 w-3 text-violet-400" />}
</Label>
<Input
id="company"
value={formData.company}
onChange={(e) => set('company', e.target.value)}
disabled={isShared('company')}
className={isShared('company') ? 'opacity-70 cursor-not-allowed' : ''}
/>
</div>
<div className="space-y-2">
<Label htmlFor="job_title">Job Title</Label>
<Label htmlFor="job_title" className="flex items-center gap-1">
Job Title
{isShared('job_title') && <Lock className="h-3 w-3 text-violet-400" />}
</Label>
<Input
id="job_title"
value={formData.job_title}
onChange={(e) => set('job_title', e.target.value)}
disabled={isShared('job_title')}
className={isShared('job_title') ? 'opacity-70 cursor-not-allowed' : ''}
/>
</div>
</div>

View File

@ -14,6 +14,7 @@ import {
Loader2,
Shield,
Blocks,
Ghost,
} from 'lucide-react';
import { useSettings } from '@/hooks/useSettings';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
@ -24,6 +25,7 @@ import { cn } from '@/lib/utils';
import api from '@/lib/api';
import type { GeoLocation, UserProfile } from '@/types';
import { Switch } from '@/components/ui/switch';
import CopyableField from '@/components/shared/CopyableField';
import TotpSetupSection from './TotpSetupSection';
import NtfySettingsSection from './NtfySettingsSection';
@ -55,6 +57,26 @@ export default function SettingsPage() {
const [autoLockEnabled, setAutoLockEnabled] = useState(settings?.auto_lock_enabled ?? false);
const [autoLockMinutes, setAutoLockMinutes] = useState<number | string>(settings?.auto_lock_minutes ?? 5);
// Profile extension fields (stored on Settings model)
const [settingsPhone, setSettingsPhone] = useState(settings?.phone ?? '');
const [settingsMobile, setSettingsMobile] = useState(settings?.mobile ?? '');
const [settingsAddress, setSettingsAddress] = useState(settings?.address ?? '');
const [settingsCompany, setSettingsCompany] = useState(settings?.company ?? '');
const [settingsJobTitle, setSettingsJobTitle] = useState(settings?.job_title ?? '');
// Social settings
const [acceptConnections, setAcceptConnections] = useState(settings?.accept_connections ?? false);
const [shareFirstName, setShareFirstName] = useState(settings?.share_first_name ?? false);
const [shareLastName, setShareLastName] = useState(settings?.share_last_name ?? false);
const [sharePreferredName, setSharePreferredName] = useState(settings?.share_preferred_name ?? true);
const [shareEmail, setShareEmail] = useState(settings?.share_email ?? false);
const [sharePhone, setSharePhone] = useState(settings?.share_phone ?? false);
const [shareMobile, setShareMobile] = useState(settings?.share_mobile ?? false);
const [shareBirthday, setShareBirthday] = useState(settings?.share_birthday ?? false);
const [shareAddress, setShareAddress] = useState(settings?.share_address ?? false);
const [shareCompany, setShareCompany] = useState(settings?.share_company ?? false);
const [shareJobTitle, setShareJobTitle] = useState(settings?.share_job_title ?? false);
// Profile fields (stored on User model, fetched from /auth/profile)
const profileQuery = useQuery({
queryKey: ['profile'],
@ -68,6 +90,8 @@ export default function SettingsPage() {
const [profileEmail, setProfileEmail] = useState('');
const [dateOfBirth, setDateOfBirth] = useState('');
const [emailError, setEmailError] = useState<string | null>(null);
const [umbralName, setUmbralName] = useState('');
const [umbralNameError, setUmbralNameError] = useState<string | null>(null);
useEffect(() => {
if (profileQuery.data) {
@ -75,6 +99,7 @@ export default function SettingsPage() {
setLastName(profileQuery.data.last_name ?? '');
setProfileEmail(profileQuery.data.email ?? '');
setDateOfBirth(profileQuery.data.date_of_birth ?? '');
setUmbralName(profileQuery.data.umbral_name ?? '');
}
}, [profileQuery.dataUpdatedAt]);
@ -87,6 +112,22 @@ export default function SettingsPage() {
setFirstDayOfWeek(settings.first_day_of_week);
setAutoLockEnabled(settings.auto_lock_enabled);
setAutoLockMinutes(settings.auto_lock_minutes ?? 5);
setSettingsPhone(settings.phone ?? '');
setSettingsMobile(settings.mobile ?? '');
setSettingsAddress(settings.address ?? '');
setSettingsCompany(settings.company ?? '');
setSettingsJobTitle(settings.job_title ?? '');
setAcceptConnections(settings.accept_connections);
setShareFirstName(settings.share_first_name);
setShareLastName(settings.share_last_name);
setSharePreferredName(settings.share_preferred_name);
setShareEmail(settings.share_email);
setSharePhone(settings.share_phone);
setShareMobile(settings.share_mobile);
setShareBirthday(settings.share_birthday);
setShareAddress(settings.share_address);
setShareCompany(settings.share_company);
setShareJobTitle(settings.share_job_title);
}
}, [settings?.id]); // only re-sync on initial load (settings.id won't change)
@ -173,8 +214,8 @@ export default function SettingsPage() {
}
};
const handleProfileSave = async (field: 'first_name' | 'last_name' | 'email' | 'date_of_birth') => {
const values: Record<string, string> = { first_name: firstName, last_name: lastName, email: profileEmail, date_of_birth: dateOfBirth };
const handleProfileSave = async (field: 'first_name' | 'last_name' | 'email' | 'date_of_birth' | 'umbral_name') => {
const values: Record<string, string> = { first_name: firstName, last_name: lastName, email: profileEmail, date_of_birth: dateOfBirth, umbral_name: umbralName };
const current = values[field].trim();
const original = profileQuery.data?.[field] ?? '';
if (current === (original || '')) return;
@ -188,6 +229,19 @@ export default function SettingsPage() {
}
setEmailError(null);
// Client-side umbral name validation
if (field === 'umbral_name') {
if (current.includes(' ')) {
setUmbralNameError('Must be a single word with no spaces');
return;
}
if (!current || !/^[a-zA-Z0-9_-]{3,50}$/.test(current)) {
setUmbralNameError('3-50 characters: letters, numbers, hyphens, underscores');
return;
}
setUmbralNameError(null);
}
try {
await api.put('/auth/profile', { [field]: current || null });
queryClient.invalidateQueries({ queryKey: ['profile'] });
@ -196,6 +250,8 @@ export default function SettingsPage() {
const detail = err?.response?.data?.detail;
if (field === 'email' && detail) {
setEmailError(typeof detail === 'string' ? detail : 'Failed to update email');
} else if (field === 'umbral_name' && detail) {
setUmbralNameError(typeof detail === 'string' ? detail : 'Failed to update umbral name');
} else {
toast.error(typeof detail === 'string' ? detail : 'Failed to update profile');
}
@ -248,6 +304,29 @@ export default function SettingsPage() {
}
};
const handleSettingsFieldSave = async (field: string, value: string) => {
const trimmed = value.trim();
const currentVal = (settings as any)?.[field] || '';
if (trimmed === (currentVal || '')) return;
try {
await updateSettings({ [field]: trimmed || null } as any);
toast.success('Profile updated');
} catch {
toast.error('Failed to update profile');
}
};
const handleSocialToggle = async (field: string, checked: boolean, setter: (v: boolean) => void) => {
const previous = (settings as any)?.[field];
setter(checked);
try {
await updateSettings({ [field]: checked } as any);
} catch {
setter(previous);
toast.error('Failed to update setting');
}
};
const handleAutoLockMinutesSave = async () => {
const raw = typeof autoLockMinutes === 'string' ? parseInt(autoLockMinutes) : autoLockMinutes;
const clamped = Math.max(1, Math.min(60, isNaN(raw) ? 5 : raw));
@ -363,6 +442,75 @@ export default function SettingsPage() {
onKeyDown={(e) => { if (e.key === 'Enter') handleProfileSave('date_of_birth'); }}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="settings_phone">Phone</Label>
<Input
id="settings_phone"
type="tel"
placeholder="Phone number"
value={settingsPhone}
onChange={(e) => setSettingsPhone(e.target.value)}
onBlur={() => handleSettingsFieldSave('phone', settingsPhone)}
onKeyDown={(e) => { if (e.key === 'Enter') handleSettingsFieldSave('phone', settingsPhone); }}
maxLength={50}
/>
</div>
<div className="space-y-2">
<Label htmlFor="settings_mobile">Mobile</Label>
<Input
id="settings_mobile"
type="tel"
placeholder="Mobile number"
value={settingsMobile}
onChange={(e) => setSettingsMobile(e.target.value)}
onBlur={() => handleSettingsFieldSave('mobile', settingsMobile)}
onKeyDown={(e) => { if (e.key === 'Enter') handleSettingsFieldSave('mobile', settingsMobile); }}
maxLength={50}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="settings_address">Address</Label>
<Input
id="settings_address"
type="text"
placeholder="Your address"
value={settingsAddress}
onChange={(e) => setSettingsAddress(e.target.value)}
onBlur={() => handleSettingsFieldSave('address', settingsAddress)}
onKeyDown={(e) => { if (e.key === 'Enter') handleSettingsFieldSave('address', settingsAddress); }}
maxLength={2000}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="settings_company">Company</Label>
<Input
id="settings_company"
type="text"
placeholder="Company name"
value={settingsCompany}
onChange={(e) => setSettingsCompany(e.target.value)}
onBlur={() => handleSettingsFieldSave('company', settingsCompany)}
onKeyDown={(e) => { if (e.key === 'Enter') handleSettingsFieldSave('company', settingsCompany); }}
maxLength={255}
/>
</div>
<div className="space-y-2">
<Label htmlFor="settings_job_title">Job Title</Label>
<Input
id="settings_job_title"
type="text"
placeholder="Your role"
value={settingsJobTitle}
onChange={(e) => setSettingsJobTitle(e.target.value)}
onBlur={() => handleSettingsFieldSave('job_title', settingsJobTitle)}
onKeyDown={(e) => { if (e.key === 'Enter') handleSettingsFieldSave('job_title', settingsJobTitle); }}
maxLength={255}
/>
</div>
</div>
</CardContent>
</Card>
@ -586,9 +734,88 @@ export default function SettingsPage() {
</div>
{/* ── Right column: Security, Authentication, Integrations ── */}
{/* ── Right column: Social, Security, Authentication, Integrations ── */}
<div className="space-y-6">
{/* Social */}
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<div className="p-1.5 rounded-md bg-violet-500/10">
<Ghost className="h-4 w-4 text-violet-400" aria-hidden="true" />
</div>
<div>
<CardTitle>Social</CardTitle>
<CardDescription>Manage your Umbra identity and connections</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="umbral_name">Umbral Name</Label>
<div className="flex items-center gap-3">
<Input
id="umbral_name"
value={umbralName}
onChange={(e) => { setUmbralName(e.target.value); setUmbralNameError(null); }}
onBlur={() => handleProfileSave('umbral_name')}
onKeyDown={(e) => { if (e.key === 'Enter') handleProfileSave('umbral_name'); }}
maxLength={50}
placeholder="Your discoverable name"
className={umbralNameError ? 'border-red-500/50' : ''}
/>
<CopyableField value={umbralName} label="Umbral name" />
</div>
{umbralNameError ? (
<p className="text-xs text-red-400">{umbralNameError}</p>
) : (
<p className="text-sm text-muted-foreground">
How other Umbra users find you
</p>
)}
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Accept Connections</Label>
<p className="text-sm text-muted-foreground">
Allow other users to find and connect with you
</p>
</div>
<Switch
checked={acceptConnections}
onCheckedChange={(checked) => handleSocialToggle('accept_connections', checked, setAcceptConnections)}
/>
</div>
<div className="border-t border-border pt-4 mt-4">
<p className="text-[11px] uppercase tracking-wider text-muted-foreground mb-3">
Sharing Defaults
</p>
<div className="grid grid-cols-2 gap-3">
{[
{ field: 'share_first_name', label: 'First Name', state: shareFirstName, setter: setShareFirstName },
{ field: 'share_last_name', label: 'Last Name', state: shareLastName, setter: setShareLastName },
{ field: 'share_preferred_name', label: 'Preferred Name', state: sharePreferredName, setter: setSharePreferredName },
{ field: 'share_email', label: 'Email', state: shareEmail, setter: setShareEmail },
{ field: 'share_phone', label: 'Phone', state: sharePhone, setter: setSharePhone },
{ field: 'share_mobile', label: 'Mobile', state: shareMobile, setter: setShareMobile },
{ field: 'share_birthday', label: 'Birthday', state: shareBirthday, setter: setShareBirthday },
{ field: 'share_address', label: 'Address', state: shareAddress, setter: setShareAddress },
{ field: 'share_company', label: 'Company', state: shareCompany, setter: setShareCompany },
{ field: 'share_job_title', label: 'Job Title', state: shareJobTitle, setter: setShareJobTitle },
].map(({ field, label, state, setter }) => (
<div key={field} className="flex items-center justify-between">
<Label className="text-sm font-normal">{label}</Label>
<Switch
checked={state}
onCheckedChange={(checked) => handleSocialToggle(field, checked, setter)}
/>
</div>
))}
</div>
</div>
</CardContent>
</Card>
{/* Security (auto-lock) */}
<Card>
<CardHeader>

View File

@ -18,6 +18,12 @@ import {
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
interface ExtraPinnedFilter {
label: string;
isActive: boolean;
onToggle: () => void;
}
interface CategoryFilterBarProps {
activeFilters: string[];
pinnedLabel: string;
@ -30,6 +36,7 @@ interface CategoryFilterBarProps {
onReorderCategories?: (order: string[]) => void;
searchValue: string;
onSearchChange: (val: string) => void;
extraPinnedFilters?: ExtraPinnedFilter[];
}
const pillBase =
@ -116,6 +123,7 @@ export default function CategoryFilterBar({
onReorderCategories,
searchValue,
onSearchChange,
extraPinnedFilters = [],
}: CategoryFilterBarProps) {
const [otherOpen, setOtherOpen] = useState(false);
const searchInputRef = useRef<HTMLInputElement>(null);
@ -169,6 +177,22 @@ export default function CategoryFilterBar({
</span>
</button>
{/* Extra pinned filters (e.g. "Umbral") */}
{extraPinnedFilters.map((epf) => (
<button
key={epf.label}
type="button"
onClick={epf.onToggle}
aria-label={`Filter by ${epf.label}`}
className={pillBase}
style={epf.isActive ? activePillStyle : undefined}
>
<span className={epf.isActive ? '' : 'text-muted-foreground hover:text-foreground'}>
{epf.label}
</span>
</button>
))}
{/* Categories pill + expandable chips */}
{categories.length > 0 && (
<>

View File

@ -27,6 +27,7 @@ interface EntityDetailPanelProps<T> {
isFavourite?: boolean;
onToggleFavourite?: () => void;
favouriteLabel?: string;
extraActions?: (item: T) => React.ReactNode;
}
export function EntityDetailPanel<T>({
@ -42,6 +43,7 @@ export function EntityDetailPanel<T>({
isFavourite,
onToggleFavourite,
favouriteLabel = 'favourite',
extraActions,
}: EntityDetailPanelProps<T>) {
const { confirming, handleClick: handleDelete } = useConfirmAction(onDelete);
@ -134,7 +136,10 @@ export function EntityDetailPanel<T>({
{/* Footer */}
<div className="px-5 py-4 border-t border-border flex items-center justify-between">
<span className="text-[11px] text-muted-foreground">{formatUpdatedAt(getUpdatedAt(item))}</span>
<div className="flex items-center gap-2">
<span className="text-[11px] text-muted-foreground">{formatUpdatedAt(getUpdatedAt(item))}</span>
{extraActions?.(item)}
</div>
<div className="flex gap-2">
<Button
variant="outline"

View File

@ -0,0 +1,110 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import api from '@/lib/api';
import type { Connection, ConnectionRequest, UmbralSearchResponse } from '@/types';
export function useConnections() {
const queryClient = useQueryClient();
const connectionsQuery = useQuery({
queryKey: ['connections'],
queryFn: async () => {
const { data } = await api.get<Connection[]>('/connections');
return data;
},
});
const incomingQuery = useQuery({
queryKey: ['connections', 'incoming'],
queryFn: async () => {
const { data } = await api.get<ConnectionRequest[]>('/connections/requests/incoming');
return data;
},
refetchOnMount: 'always',
});
const outgoingQuery = useQuery({
queryKey: ['connections', 'outgoing'],
queryFn: async () => {
const { data } = await api.get<ConnectionRequest[]>('/connections/requests/outgoing');
return data;
},
});
const searchMutation = useMutation({
mutationFn: async (umbralName: string) => {
const { data } = await api.post<UmbralSearchResponse>('/connections/search', {
umbral_name: umbralName,
});
return data;
},
});
const sendRequestMutation = useMutation({
mutationFn: async (params: { umbralName: string; personId?: number }) => {
const { data } = await api.post('/connections/request', {
umbral_name: params.umbralName,
...(params.personId != null && { person_id: params.personId }),
});
return data;
},
onSuccess: () => {
// Fire-and-forget — don't block mutateAsync on query refetches
queryClient.invalidateQueries({ queryKey: ['connections'] });
},
});
const respondMutation = useMutation({
mutationFn: async ({ requestId, action }: { requestId: number; action: 'accept' | 'reject' }) => {
const { data } = await api.put(`/connections/requests/${requestId}/respond`, { action });
return data;
},
onSuccess: (_, variables) => {
// Dismiss any lingering Sonner toast for this request
toast.dismiss(`connection-request-${variables.requestId}`);
// Fire-and-forget — errors here must not surface as mutation failures
queryClient.invalidateQueries({ queryKey: ['connections'] });
queryClient.invalidateQueries({ queryKey: ['people'] });
queryClient.invalidateQueries({ queryKey: ['notifications'] });
},
});
const cancelMutation = useMutation({
mutationFn: async (requestId: number) => {
const { data } = await api.put(`/connections/requests/${requestId}/cancel`);
return data;
},
onSuccess: () => {
// Fire-and-forget — don't block mutateAsync on query refetches
queryClient.invalidateQueries({ queryKey: ['connections'] });
queryClient.invalidateQueries({ queryKey: ['notifications'] });
},
});
const removeConnectionMutation = useMutation({
mutationFn: async (connectionId: number) => {
await api.delete(`/connections/${connectionId}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['connections'] });
queryClient.invalidateQueries({ queryKey: ['people'] });
},
});
return {
connections: connectionsQuery.data ?? [],
incomingRequests: incomingQuery.data ?? [],
outgoingRequests: outgoingQuery.data ?? [],
isLoading: connectionsQuery.isLoading,
isLoadingIncoming: incomingQuery.isLoading,
search: searchMutation.mutateAsync,
isSearching: searchMutation.isPending,
sendRequest: sendRequestMutation.mutateAsync,
isSending: sendRequestMutation.isPending,
respond: respondMutation.mutateAsync,
isResponding: respondMutation.isPending,
cancelRequest: cancelMutation.mutateAsync,
isCancelling: cancelMutation.isPending,
removeConnection: removeConnectionMutation.mutateAsync,
};
}

View File

@ -0,0 +1,113 @@
import { createContext, useContext, type ReactNode } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useEffect, useRef, createElement } from 'react';
import api from '@/lib/api';
import type { NotificationListResponse } from '@/types';
interface NotificationContextValue {
unreadCount: number;
notifications: NotificationListResponse['notifications'];
total: number;
isLoading: boolean;
markRead: (ids: number[]) => Promise<void>;
markAllRead: () => Promise<void>;
deleteNotification: (id: number) => Promise<void>;
refreshNotifications: () => void;
}
const NotificationContext = createContext<NotificationContextValue>({
unreadCount: 0,
notifications: [],
total: 0,
isLoading: true,
markRead: async () => {},
markAllRead: async () => {},
deleteNotification: async () => {},
refreshNotifications: () => {},
});
export function useNotifications() {
return useContext(NotificationContext);
}
export function NotificationProvider({ children }: { children: ReactNode }) {
const queryClient = useQueryClient();
const visibleRef = useRef(true);
useEffect(() => {
const handler = () => {
visibleRef.current = document.visibilityState === 'visible';
};
document.addEventListener('visibilitychange', handler);
return () => document.removeEventListener('visibilitychange', handler);
}, []);
const unreadQuery = useQuery({
queryKey: ['notifications', 'unread-count'],
queryFn: async () => {
const { data } = await api.get<{ count: number }>('/notifications/unread-count');
return data.count;
},
refetchInterval: 15_000,
// Required: toast notifications depend on background polling to detect new
// notifications when the tab is hidden (e.g. user switches to sender tab).
refetchIntervalInBackground: true,
staleTime: 10_000,
});
const listQuery = useQuery({
queryKey: ['notifications', 'list'],
queryFn: async () => {
const { data } = await api.get<NotificationListResponse>('/notifications', {
params: { per_page: 50 },
});
return data;
},
staleTime: 15_000,
refetchInterval: () => (visibleRef.current ? 15_000 : false),
});
const markReadMutation = useMutation({
mutationFn: async (notificationIds: number[]) => {
await api.put('/notifications/read', { notification_ids: notificationIds });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notifications'] });
},
});
const markAllReadMutation = useMutation({
mutationFn: async () => {
await api.put('/notifications/read-all');
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notifications'] });
},
});
const deleteMutation = useMutation({
mutationFn: async (id: number) => {
await api.delete(`/notifications/${id}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notifications'] });
},
});
const refreshNotifications = () => {
queryClient.invalidateQueries({ queryKey: ['notifications'] });
};
const value: NotificationContextValue = {
unreadCount: unreadQuery.data ?? 0,
notifications: listQuery.data?.notifications ?? [],
total: listQuery.data?.total ?? 0,
isLoading: listQuery.isLoading,
markRead: markReadMutation.mutateAsync,
markAllRead: markAllReadMutation.mutateAsync,
deleteNotification: deleteMutation.mutateAsync,
refreshNotifications,
};
return createElement(NotificationContext.Provider, { value }, children);
}

View File

@ -23,6 +23,27 @@ export interface Settings {
// Auto-lock settings
auto_lock_enabled: boolean;
auto_lock_minutes: number;
// Profile fields (shareable)
phone: string | null;
mobile: string | null;
address: string | null;
company: string | null;
job_title: string | null;
// Social settings
accept_connections: boolean;
// Sharing defaults
share_first_name: boolean;
share_last_name: boolean;
share_preferred_name: boolean;
share_email: boolean;
share_phone: boolean;
share_mobile: boolean;
share_birthday: boolean;
share_address: boolean;
share_company: boolean;
share_job_title: boolean;
// ntfy connections toggle
ntfy_connections_enabled: boolean;
created_at: string;
updated_at: string;
}
@ -171,6 +192,9 @@ export interface Person {
company?: string;
job_title?: string;
notes?: string;
linked_user_id?: number | null;
is_umbral_contact: boolean;
shared_fields?: Record<string, unknown> | null;
created_at: string;
updated_at: string;
}
@ -222,6 +246,7 @@ export type LoginResponse = LoginSuccessResponse | LoginMfaRequiredResponse | Lo
export interface AdminUser {
id: number;
username: string;
umbral_name: string;
email: string | null;
first_name: string | null;
last_name: string | null;
@ -348,6 +373,7 @@ export interface UpcomingResponse {
export interface UserProfile {
username: string;
umbral_name: string;
email: string | null;
first_name: string | null;
last_name: string | null;
@ -366,3 +392,50 @@ export interface EventTemplate {
is_starred: boolean;
created_at: string;
}
// ── Notifications ──────────────────────────────────────────────────
// Named AppNotification to avoid collision with browser Notification API
export interface AppNotification {
id: number;
user_id: number;
type: string;
title: string | null;
message: string | null;
data: Record<string, unknown> | null;
source_type: string | null;
source_id: number | null;
is_read: boolean;
created_at: string;
}
export interface NotificationListResponse {
notifications: AppNotification[];
unread_count: number;
total: number;
}
// ── Connections ────────────────────────────────────────────────────
export interface ConnectionRequest {
id: number;
sender_umbral_name: string;
sender_preferred_name: string | null;
receiver_umbral_name: string;
receiver_preferred_name: string | null;
status: 'pending' | 'accepted' | 'rejected' | 'cancelled';
created_at: string;
}
export interface Connection {
id: number;
connected_user_id: number;
connected_umbral_name: string;
connected_preferred_name: string | null;
person_id: number | null;
created_at: string;
}
export interface UmbralSearchResponse {
found: boolean;
}