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 secondary_sdr_mode: str = "none" # none | adsb | ais | op25_2 — requires a second physical SDR sdr_count: int = 1 # self-reported by the node's checkin, best-effort enforce_override_timeout: bool = True is_overridden: bool = False override_system_id: Optional[str] = None override_timeout_at: Optional[datetime] = None class AircraftTrack(BaseModel): """Live ADS-B position, one doc per icao. Overwritten on every sighting — this is a live-map snapshot, not a history (see node-26#9).""" icao: str org_id: Optional[str] = None node_id: str callsign: Optional[str] = None lat: Optional[float] = None lon: Optional[float] = None altitude_ft: Optional[float] = None ground_speed_kt: Optional[float] = None track_deg: Optional[float] = None last_seen: datetime class VesselTrack(BaseModel): """Live AIS position, one doc per mmsi. Same live-snapshot shape as AircraftTrack — overwritten on every sighting (see node-26#9).""" mmsi: str org_id: Optional[str] = None node_id: str name: Optional[str] = None lat: Optional[float] = None lon: Optional[float] = None speed_kt: Optional[float] = None heading_deg: Optional[float] = None last_seen: datetime class CommandPayload(BaseModel): action: str # discord_join / discord_leave / op25_restart guild_id: Optional[str] = None channel_id: Optional[str] = None # --------------------------------------------------------------------------- # Systems # --------------------------------------------------------------------------- class LocalKnowledgeEntry(BaseModel): """A local name and what it is. Both scopes, one shape (server-26#36).""" term: str meaning: Optional[str] = None class AreaContextBody(BaseModel): """ Ground truth about the area a system or talkgroup covers. Every field is nullable on purpose: which SCOPE an operator fills is their declaration of how homogeneous the system is. A single-municipality system is described once at system level and inherited by every talkgroup; a statewide one is left null there and described per talkgroup. See `internal/area_context.py` for the merge rules and the anchor. `center`/`radius_km`/`resolved_from`/`resolved_at` are absent here by design — the backend geocodes and writes those. A client that sends them is ignored. """ municipality: Optional[str] = None county: Optional[str] = None state: Optional[str] = None local_knowledge: List[LocalKnowledgeEntry] = [] class TalkgroupEntry(BaseModel): """ One entry in `config.talkgroups[]`. Declared so the talkgroup copy of `area_context` stops being unvalidated JSON riding inside the config blob — it is the same shape as the system's and gets the same validator (server-26#36). """ model_config = {"extra": "allow"} id: int name: str = "" tag: str = "other" vocabulary: List[str] = [] area_context: Optional[AreaContextBody] = None 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 and the place verifier (server-26#36 / #37). Shape is # AreaContextBody plus the backend-owned anchor. Per-talkgroup overrides # live inside config.talkgroups[] and rank ABOVE this, so a multi-county # system narrows per channel rather than replacing this wholesale. 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