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>
204 lines
7.0 KiB
Python
204 lines
7.0 KiB
Python
import re
|
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
from datetime import datetime
|
|
from typing import Literal, Optional
|
|
|
|
AccentColor = Literal["cyan", "blue", "green", "purple", "red", "orange", "pink", "yellow"]
|
|
|
|
_NTFY_TOPIC_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}$')
|
|
|
|
|
|
class SettingsUpdate(BaseModel):
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
accent_color: Optional[AccentColor] = None
|
|
upcoming_days: int | None = None
|
|
preferred_name: str | None = Field(None, max_length=100)
|
|
weather_city: str | None = Field(None, max_length=100)
|
|
weather_lat: float | None = None
|
|
weather_lon: float | None = None
|
|
first_day_of_week: int | None = None
|
|
|
|
# ntfy configuration fields
|
|
ntfy_server_url: Optional[str] = Field(None, max_length=500)
|
|
ntfy_topic: Optional[str] = Field(None, max_length=100)
|
|
# Empty string means "clear the token"; None means "leave unchanged"
|
|
ntfy_auth_token: Optional[str] = None
|
|
ntfy_enabled: Optional[bool] = None
|
|
ntfy_events_enabled: Optional[bool] = None
|
|
ntfy_reminders_enabled: Optional[bool] = None
|
|
ntfy_todos_enabled: Optional[bool] = None
|
|
ntfy_projects_enabled: Optional[bool] = None
|
|
ntfy_event_lead_minutes: Optional[int] = None
|
|
ntfy_todo_lead_days: Optional[int] = None
|
|
ntfy_project_lead_days: Optional[int] = None
|
|
|
|
# Auto-lock settings
|
|
auto_lock_enabled: Optional[bool] = None
|
|
auto_lock_minutes: Optional[int] = None
|
|
|
|
# Profile fields (shareable with connections)
|
|
phone: Optional[str] = Field(None, max_length=50)
|
|
mobile: Optional[str] = Field(None, max_length=50)
|
|
address: Optional[str] = Field(None, max_length=2000)
|
|
company: Optional[str] = Field(None, max_length=255)
|
|
job_title: Optional[str] = Field(None, max_length=255)
|
|
|
|
# Social settings
|
|
accept_connections: Optional[bool] = None
|
|
|
|
# Sharing defaults
|
|
share_preferred_name: Optional[bool] = None
|
|
share_email: Optional[bool] = None
|
|
share_phone: Optional[bool] = None
|
|
share_mobile: Optional[bool] = None
|
|
share_birthday: Optional[bool] = None
|
|
share_address: Optional[bool] = None
|
|
share_company: Optional[bool] = None
|
|
share_job_title: Optional[bool] = None
|
|
|
|
# ntfy connections toggle
|
|
ntfy_connections_enabled: Optional[bool] = None
|
|
|
|
@field_validator('auto_lock_minutes')
|
|
@classmethod
|
|
def validate_auto_lock_minutes(cls, v: Optional[int]) -> Optional[int]:
|
|
if v is not None and not (1 <= v <= 60):
|
|
raise ValueError("auto_lock_minutes must be between 1 and 60")
|
|
return v
|
|
|
|
@field_validator('first_day_of_week')
|
|
@classmethod
|
|
def validate_first_day(cls, v: int | None) -> int | None:
|
|
if v is not None and v not in (0, 1):
|
|
raise ValueError('first_day_of_week must be 0 (Sunday) or 1 (Monday)')
|
|
return v
|
|
|
|
@field_validator('weather_lat')
|
|
@classmethod
|
|
def validate_lat(cls, v: float | None) -> float | None:
|
|
if v is not None and (v < -90 or v > 90):
|
|
raise ValueError('Latitude must be between -90 and 90')
|
|
return v
|
|
|
|
@field_validator('weather_lon')
|
|
@classmethod
|
|
def validate_lon(cls, v: float | None) -> float | None:
|
|
if v is not None and (v < -180 or v > 180):
|
|
raise ValueError('Longitude must be between -180 and 180')
|
|
return v
|
|
|
|
@field_validator('ntfy_server_url')
|
|
@classmethod
|
|
def validate_ntfy_url(cls, v: Optional[str]) -> Optional[str]:
|
|
if v is None or v == "":
|
|
return None
|
|
from urllib.parse import urlparse
|
|
parsed = urlparse(v)
|
|
if parsed.scheme not in ("http", "https"):
|
|
raise ValueError("ntfy server URL must use http or https")
|
|
if not parsed.netloc:
|
|
raise ValueError("ntfy server URL must include a hostname")
|
|
# Strip trailing slash — ntfy base URL must not have one
|
|
return v.rstrip("/")
|
|
|
|
@field_validator('ntfy_topic')
|
|
@classmethod
|
|
def validate_ntfy_topic(cls, v: Optional[str]) -> Optional[str]:
|
|
if v is None or v == "":
|
|
return None
|
|
if not _NTFY_TOPIC_RE.match(v):
|
|
raise ValueError(
|
|
"ntfy topic must be 1-64 alphanumeric characters, hyphens, or underscores"
|
|
)
|
|
return v
|
|
|
|
@field_validator('ntfy_auth_token')
|
|
@classmethod
|
|
def validate_ntfy_token(cls, v: Optional[str]) -> Optional[str]:
|
|
# Empty string signals "clear the token" — normalise to None for storage
|
|
if v == "":
|
|
return None
|
|
if v is not None and len(v) > 500:
|
|
raise ValueError("ntfy auth token must be at most 500 characters")
|
|
return v
|
|
|
|
@field_validator('ntfy_event_lead_minutes')
|
|
@classmethod
|
|
def validate_event_lead(cls, v: Optional[int]) -> Optional[int]:
|
|
if v is not None and not (1 <= v <= 1440):
|
|
raise ValueError("ntfy_event_lead_minutes must be between 1 and 1440")
|
|
return v
|
|
|
|
@field_validator('ntfy_todo_lead_days')
|
|
@classmethod
|
|
def validate_todo_lead(cls, v: Optional[int]) -> Optional[int]:
|
|
if v is not None and not (0 <= v <= 30):
|
|
raise ValueError("ntfy_todo_lead_days must be between 0 and 30")
|
|
return v
|
|
|
|
@field_validator('ntfy_project_lead_days')
|
|
@classmethod
|
|
def validate_project_lead(cls, v: Optional[int]) -> Optional[int]:
|
|
if v is not None and not (0 <= v <= 30):
|
|
raise ValueError("ntfy_project_lead_days must be between 0 and 30")
|
|
return v
|
|
|
|
|
|
class SettingsResponse(BaseModel):
|
|
id: int
|
|
user_id: int
|
|
accent_color: str
|
|
upcoming_days: int
|
|
preferred_name: str | None = None
|
|
weather_city: str | None = None
|
|
weather_lat: float | None = None
|
|
weather_lon: float | None = None
|
|
first_day_of_week: int = 0
|
|
|
|
# ntfy fields — ntfy_auth_token is NEVER included here (security requirement 6.2)
|
|
ntfy_server_url: Optional[str] = None
|
|
ntfy_topic: Optional[str] = None
|
|
ntfy_enabled: bool = False
|
|
ntfy_events_enabled: bool = True
|
|
ntfy_reminders_enabled: bool = True
|
|
ntfy_todos_enabled: bool = True
|
|
ntfy_projects_enabled: bool = True
|
|
ntfy_event_lead_minutes: int = 15
|
|
ntfy_todo_lead_days: int = 1
|
|
ntfy_project_lead_days: int = 2
|
|
# Derived field: computed via Settings.ntfy_has_token property (from_attributes reads it)
|
|
ntfy_has_token: bool = False
|
|
|
|
# Auto-lock settings
|
|
auto_lock_enabled: bool = False
|
|
auto_lock_minutes: int = 5
|
|
|
|
# Profile fields
|
|
phone: Optional[str] = None
|
|
mobile: Optional[str] = None
|
|
address: Optional[str] = None
|
|
company: Optional[str] = None
|
|
job_title: Optional[str] = None
|
|
|
|
# Social settings
|
|
accept_connections: bool = False
|
|
|
|
# Sharing defaults
|
|
share_preferred_name: bool = True
|
|
share_email: bool = False
|
|
share_phone: bool = False
|
|
share_mobile: bool = False
|
|
share_birthday: bool = False
|
|
share_address: bool = False
|
|
share_company: bool = False
|
|
share_job_title: bool = False
|
|
|
|
# ntfy connections toggle
|
|
ntfy_connections_enabled: bool = True
|
|
|
|
created_at: datetime
|
|
updated_at: datetime
|
|
|
|
model_config = ConfigDict(from_attributes=True)
|