from pydantic import BaseModel, Field from typing import Optional, List, Dict, Any from datetime import datetime # --------------------------------------------------------------------------- # Organizations — the tenant boundary. See SAAS_PLAN.md B2 and # app/internal/auth.py's require_org(). plan_id/subscription_status and the # stripe_* fields are deliberately inert (None) here: no billing model has # been decided yet (participation-based / reciprocal access, not per-seat # SaaS — see the note on FOUNDING_ORG_ID in app/internal/tenancy.py), so this # is just the seam a future billing pass would write into, not a promise # about what that pass looks like. # --------------------------------------------------------------------------- class OrganizationRecord(BaseModel): org_id: str name: str created_at: datetime created_by_uid: str plan_id: Optional[str] = None subscription_status: Optional[str] = None stripe_customer_id: Optional[str] = None stripe_subscription_id: Optional[str] = None current_period_end: Optional[datetime] = None seat_limit: Optional[int] = None node_limit: Optional[int] = None retention_days: Optional[int] = None class OrgMember(BaseModel): uid: str org_id: str org_role: str # "owner" | "member" email: Optional[str] = None added_at: datetime class EnrollmentTokenRecord(BaseModel): """Firestore doc id is the SHA-256 hash of the raw token — see routers/enrollment.py's _hash_secret pattern (pickup_secret_hash).""" org_id: str label: str created_at: datetime created_by_uid: str revoked: bool = False uses: int = 0 # --------------------------------------------------------------------------- # Nodes # --------------------------------------------------------------------------- class NodeRecord(BaseModel): node_id: str name: str org_id: Optional[str] = None # stamped at enrollment; None only on pre-tenancy docs awaiting backfill lat: float = 0.0 lon: float = 0.0 status: str = "offline" # online / offline / recording / unconfigured configured: bool = False last_seen: Optional[datetime] = None assigned_system_id: Optional[str] = None node_type: str = "fixed" # fixed or portable enforce_override_timeout: bool = True is_overridden: bool = False override_system_id: Optional[str] = None override_timeout_at: Optional[datetime] = None class CommandPayload(BaseModel): action: str # discord_join / discord_leave / op25_restart guild_id: Optional[str] = None channel_id: Optional[str] = None # --------------------------------------------------------------------------- # Systems # --------------------------------------------------------------------------- class SystemRecord(BaseModel): system_id: str org_id: Optional[str] = None name: str type: str # P25 / DMR / NBFM config: Dict[str, Any] = {} # OP25-compatible config blob ten_codes: Dict[str, str] = {} # {"10-10": "Commercial Alarm", ...} # Ground truth about the area this system covers, fed to the transcript # corrector (server-26#36): {municipality, county, roads[], landmarks[]}. # Per-talkgroup overrides live inside config.talkgroups[] and rank ABOVE # this, so a multi-county system can narrow per channel. area_context: Dict[str, Any] = {} class SystemCreate(BaseModel): name: str type: str config: Dict[str, Any] = {} ten_codes: Dict[str, str] = {} area_context: Dict[str, Any] = {} # --------------------------------------------------------------------------- # Calls # --------------------------------------------------------------------------- class CallRecord(BaseModel): call_id: str node_id: str org_id: Optional[str] = None # inherited from the node at call_start/upload — see internal/mqtt_handler.py system_id: Optional[str] = None talkgroup_id: Optional[int] = None talkgroup_name: Optional[str] = None freq: Optional[float] = None srcaddr: Optional[str] = None started_at: datetime ended_at: Optional[datetime] = None audio_gcs_uri: Optional[str] = None # canonical gs:// object location audio_url: Optional[str] = None # NOT stored — minted per read, see internal/storage.py duplicate_of: Optional[str] = None # another node recorded this same transmission first transcript: Optional[str] = None # populated later by STT incident_ids: List[str] = [] # one per scene detected in the recording location: Optional[Dict[str, float]] = None # {lat, lng} tags: List[str] = [] status: str = "active" # active / ended # --------------------------------------------------------------------------- # Incidents # --------------------------------------------------------------------------- class IncidentRecord(BaseModel): incident_id: str org_id: Optional[str] = None # inherited from the calls that created it — see internal/incident_correlator.py title: Optional[str] = None type: Optional[str] = None # fire / police / ems / etc. status: str = "active" # active / resolved location: Optional[Dict[str, float]] = None call_ids: List[str] = [] started_at: datetime updated_at: datetime summary: Optional[str] = None tags: List[str] = [] class IncidentCreate(BaseModel): title: str type: str = "other" status: str = "active" location: Optional[Dict[str, float]] = None call_ids: List[str] = [] summary: Optional[str] = None tags: List[str] = [] class IncidentUpdate(BaseModel): title: Optional[str] = None type: Optional[str] = None status: Optional[str] = None location: Optional[Dict[str, float]] = None summary: Optional[str] = None tags: Optional[List[str]] = None # --------------------------------------------------------------------------- # Alerts # --------------------------------------------------------------------------- class AlertRule(BaseModel): rule_id: Optional[str] = None org_id: Optional[str] = None name: str keywords: List[str] = [] talkgroup_ids: List[int] = [] enabled: bool = True discord_webhook: Optional[str] = None # POST here when rule fires class AlertRuleUpdate(BaseModel): name: Optional[str] = None keywords: Optional[List[str]] = None talkgroup_ids: Optional[List[int]] = None enabled: Optional[bool] = None discord_webhook: Optional[str] = None class AlertEvent(BaseModel): alert_id: Optional[str] = None org_id: Optional[str] = None rule_id: str rule_name: str call_id: str node_id: str talkgroup_id: Optional[int] = None talkgroup_name: Optional[str] = None matched_keywords: List[str] = [] transcript_snippet: Optional[str] = None triggered_at: Optional[datetime] = None acknowledged: bool = False # --------------------------------------------------------------------------- # Trips # --------------------------------------------------------------------------- class TripCreate(BaseModel): name: str location: str maps_link: Optional[str] = None start_date: str # YYYY-MM-DD end_date: str # YYYY-MM-DD available_tags: List[str] = [] # tag labels configured for this trip overlap_tags: List[str] = [] # subset of available_tags that allow time overlap visibility: str = "public" # "public" | "private" invited_discord_ids: List[str] = [] # discord user IDs allowed on private trips class TripEventCreate(BaseModel): title: str date: str # YYYY-MM-DD, must fall within parent trip range start_time: Optional[str] = None # HH:MM (24h) end_time: Optional[str] = None # HH:MM (24h) location: Optional[str] = None # inherits trip location if None maps_link: Optional[str] = None place_id: Optional[str] = None # Google Place ID notes: Optional[str] = None tags: List[str] = [] # tag labels applied to this event class TripEventUpdate(BaseModel): title: Optional[str] = None date: Optional[str] = None start_time: Optional[str] = None end_time: Optional[str] = None location: Optional[str] = None maps_link: Optional[str] = None place_id: Optional[str] = None notes: Optional[str] = None tags: Optional[List[str]] = None class AttendeeAction(BaseModel): discord_user_id: str discord_username: Optional[str] = None