3314 lines
117 KiB
Python
3314 lines
117 KiB
Python
import uuid
|
|
import logging
|
|
import os
|
|
import smtplib
|
|
import secrets
|
|
import hashlib
|
|
from datetime import datetime, timedelta, timezone
|
|
from email.message import EmailMessage
|
|
from typing import Literal
|
|
|
|
from email_validator import EmailNotValidError, validate_email
|
|
from fastapi import Depends, FastAPI, Header, HTTPException, Query, Request, status
|
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
from sqlalchemy import func, or_, select, text
|
|
from sqlalchemy.orm import Session as DbSession
|
|
|
|
from auth import (
|
|
create_access_token,
|
|
create_refresh_token,
|
|
create_one_time_token,
|
|
decode_access_token,
|
|
decode_firebase_id_token,
|
|
hash_password,
|
|
hash_refresh_token,
|
|
hash_one_time_token,
|
|
normalize_email,
|
|
refresh_expiry,
|
|
verify_password,
|
|
)
|
|
from db import check_database, engine
|
|
from models import (
|
|
OpsAction,
|
|
AuditEvent,
|
|
AuthRateLimit,
|
|
EmailVerificationToken,
|
|
Entitlement,
|
|
Operator,
|
|
OpsAuditEvent,
|
|
OpsIncident,
|
|
OpsIncidentNote,
|
|
OpsSession,
|
|
PasswordResetToken,
|
|
Ride,
|
|
Session,
|
|
Subscription,
|
|
TelemetryPoint,
|
|
User,
|
|
Vehicle,
|
|
)
|
|
from infrastructure_metrics import get_infrastructure_metrics
|
|
import ops_actions_service
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
app = FastAPI(
|
|
title="MotorSignal API",
|
|
version="1.0.0",
|
|
docs_url=None,
|
|
redoc_url=None,
|
|
openapi_url=None,
|
|
)
|
|
|
|
|
|
@app.on_event("startup")
|
|
def startup_reconcile_actions():
|
|
try:
|
|
with DbSession(engine) as db:
|
|
ops_actions_service.reconcile_stale_actions(db)
|
|
except Exception as exc:
|
|
logger.warning("Could not reconcile stale ops actions on startup: %s", exc)
|
|
|
|
|
|
|
|
class RegisterRequest(BaseModel):
|
|
email: str
|
|
password: str = Field(min_length=12, max_length=128)
|
|
device_name: str | None = Field(default=None, max_length=255)
|
|
|
|
|
|
class LoginRequest(BaseModel):
|
|
email: str
|
|
password: str
|
|
device_name: str | None = Field(default=None, max_length=255)
|
|
|
|
|
|
class RefreshRequest(BaseModel):
|
|
refresh_token: str
|
|
|
|
|
|
class EmailRequest(BaseModel):
|
|
email: str
|
|
|
|
|
|
class TokenRequest(BaseModel):
|
|
token: str = Field(min_length=20, max_length=256)
|
|
|
|
|
|
class FirebaseLinkRequest(BaseModel):
|
|
id_token: str = Field(min_length=100, max_length=8192)
|
|
|
|
|
|
class PasswordResetRequest(TokenRequest):
|
|
password: str = Field(min_length=12, max_length=128)
|
|
|
|
|
|
class TokenResponse(BaseModel):
|
|
access_token: str
|
|
refresh_token: str
|
|
token_type: str = "bearer"
|
|
ops_token: str | None = None
|
|
|
|
|
|
|
|
class SessionResponse(BaseModel):
|
|
id: uuid.UUID
|
|
device_name: str | None
|
|
created_at: datetime
|
|
expires_at: datetime
|
|
last_used_at: datetime | None
|
|
current: bool
|
|
|
|
|
|
class VehicleUpdate(BaseModel):
|
|
nickname: str | None = Field(default=None, min_length=1, max_length=120)
|
|
make: str | None = Field(default=None, min_length=1, max_length=120)
|
|
model: str | None = Field(default=None, min_length=1, max_length=120)
|
|
year: int | None = Field(default=None, ge=1885, le=2100)
|
|
vin: str | None = Field(default=None, min_length=11, max_length=17)
|
|
|
|
|
|
class UserVehicleCreate(BaseModel):
|
|
nickname: str = Field(min_length=1, max_length=120)
|
|
make: str = Field(min_length=1, max_length=120)
|
|
model: str = Field(min_length=1, max_length=120)
|
|
year: int | None = Field(default=None, ge=1885, le=2100)
|
|
vin: str | None = Field(default=None, min_length=11, max_length=17)
|
|
|
|
|
|
class UserRideCreate(BaseModel):
|
|
vehicle_id: uuid.UUID | None = None
|
|
started_at: datetime
|
|
ended_at: datetime | None = None
|
|
distance_miles: float | None = Field(default=None, ge=0)
|
|
top_speed_mph: float | None = Field(default=None, ge=0)
|
|
avg_speed_mph: float | None = Field(default=None, ge=0)
|
|
status: str = Field(default="completed", min_length=1, max_length=32)
|
|
|
|
|
|
class TelemetryPointInput(BaseModel):
|
|
recorded_at: datetime
|
|
latitude: float | None = Field(default=None, ge=-90, le=90)
|
|
longitude: float | None = Field(default=None, ge=-180, le=180)
|
|
speed_mph: float | None = Field(default=None, ge=0)
|
|
lean_angle_deg: float | None = Field(default=None, ge=-90, le=90)
|
|
g_force: float | None = Field(default=None, ge=0)
|
|
accel_g: float | None = Field(default=None, ge=-10, le=10)
|
|
brake_g: float | None = Field(default=None, ge=-10, le=10)
|
|
|
|
|
|
class TelemetryBatch(BaseModel):
|
|
points: list[TelemetryPointInput] = Field(min_length=1, max_length=1000)
|
|
|
|
|
|
def get_db():
|
|
db = DbSession(engine)
|
|
|
|
try:
|
|
yield db
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def enforce_auth_rate_limit(request: Request, db: DbSession, action: str, limit: int = 10, window_seconds: int = 900) -> None:
|
|
client = request.client.host if request.client else "unknown"
|
|
key = f"{action}:{client}"
|
|
now = datetime.now(timezone.utc)
|
|
bucket = db.get(AuthRateLimit, key, with_for_update=True)
|
|
if bucket is None:
|
|
db.add(AuthRateLimit(key=key, window_started=now, attempts=1))
|
|
db.commit()
|
|
return
|
|
elapsed = (now - bucket.window_started).total_seconds()
|
|
if elapsed >= window_seconds:
|
|
bucket.window_started = now
|
|
bucket.attempts = 1
|
|
elif bucket.attempts >= limit:
|
|
retry_after = max(1, int(window_seconds - elapsed))
|
|
raise HTTPException(status_code=429, detail="Too many authentication attempts", headers={"Retry-After": str(retry_after)})
|
|
else:
|
|
bucket.attempts += 1
|
|
db.commit()
|
|
|
|
|
|
def extract_bearer_token(authorization: str | None) -> str:
|
|
if not authorization:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Missing authorization token",
|
|
)
|
|
|
|
scheme, _, token = authorization.partition(" ")
|
|
|
|
if scheme.lower() != "bearer" or not token:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Invalid authorization header",
|
|
)
|
|
|
|
return token
|
|
|
|
|
|
def get_request_context(request: Request) -> tuple[str | None, str | None]:
|
|
return request.client.host if request.client else None, request.headers.get("user-agent")
|
|
|
|
|
|
def audit(
|
|
db: DbSession,
|
|
event_type: str,
|
|
actor_user_id: uuid.UUID | None = None,
|
|
target_user_id: uuid.UUID | None = None,
|
|
request: Request | None = None,
|
|
metadata: dict | None = None,
|
|
) -> None:
|
|
ip_address = request.client.host if request and request.client else None
|
|
user_agent = request.headers.get("user-agent")[:512] if request and request.headers.get("user-agent") else None
|
|
db.add(AuditEvent(event_type=event_type, actor_user_id=actor_user_id, target_user_id=target_user_id, ip_address=ip_address, user_agent=user_agent, metadata_json=metadata))
|
|
|
|
|
|
def send_email(recipient: str, subject: str, body: str) -> None:
|
|
host = os.getenv("SMTP_HOST")
|
|
if not host:
|
|
return
|
|
try:
|
|
message = EmailMessage()
|
|
message["From"] = os.environ["SMTP_FROM"]
|
|
message["To"] = recipient
|
|
message["Subject"] = subject
|
|
message.set_content(body)
|
|
port = int(os.getenv("SMTP_PORT", "587"))
|
|
with smtplib.SMTP(host, port, timeout=10) as smtp:
|
|
if os.getenv("SMTP_STARTTLS", "true").lower() == "true":
|
|
smtp.starttls()
|
|
if os.getenv("SMTP_USER"):
|
|
smtp.login(os.environ["SMTP_USER"], os.environ["SMTP_PASSWORD"])
|
|
smtp.send_message(message)
|
|
except (KeyError, OSError, smtplib.SMTPException):
|
|
logger.exception("MotorSignal email delivery failed")
|
|
|
|
|
|
def get_current_identity(
|
|
authorization: str | None = Header(default=None),
|
|
db: DbSession = Depends(get_db),
|
|
) -> tuple[User, Session]:
|
|
token = extract_bearer_token(authorization)
|
|
|
|
try:
|
|
payload = decode_access_token(token)
|
|
except Exception:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Invalid or expired access token",
|
|
)
|
|
|
|
if payload.get("type") != "access":
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Invalid access token",
|
|
)
|
|
|
|
try:
|
|
user_id = uuid.UUID(payload["sub"])
|
|
session_id = uuid.UUID(payload["sid"])
|
|
except Exception:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Invalid access token",
|
|
)
|
|
|
|
session = db.get(Session, session_id)
|
|
|
|
if (
|
|
session is None
|
|
or session.user_id != user_id
|
|
or session.revoked_at is not None
|
|
or session.expires_at <= datetime.now(timezone.utc)
|
|
):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Session is no longer valid",
|
|
)
|
|
|
|
user = db.get(User, user_id)
|
|
|
|
if user is None or not user.is_active:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="User is unavailable",
|
|
)
|
|
|
|
return user, session
|
|
|
|
|
|
def get_current_user(identity: tuple[User, Session] = Depends(get_current_identity)) -> User:
|
|
return identity[0]
|
|
|
|
|
|
|
|
@app.get("/api/v1/health")
|
|
def health():
|
|
return {
|
|
"status": "ok",
|
|
"service": "motorsignal-api",
|
|
}
|
|
|
|
|
|
@app.get("/api/v1/ready")
|
|
def readiness():
|
|
try:
|
|
check_database()
|
|
|
|
return {
|
|
"status": "ready",
|
|
"database": "ok",
|
|
}
|
|
except Exception:
|
|
raise HTTPException(
|
|
status_code=503,
|
|
detail="Service unavailable",
|
|
)
|
|
|
|
|
|
@app.post(
|
|
"/api/v1/auth/register",
|
|
response_model=TokenResponse,
|
|
status_code=201,
|
|
)
|
|
def register(
|
|
request: RegisterRequest,
|
|
http_request: Request,
|
|
db: DbSession = Depends(get_db),
|
|
):
|
|
enforce_auth_rate_limit(http_request, db, "register")
|
|
try:
|
|
validated = validate_email(
|
|
request.email,
|
|
check_deliverability=False,
|
|
)
|
|
email = normalize_email(validated.normalized)
|
|
|
|
except EmailNotValidError:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Invalid email address",
|
|
)
|
|
|
|
existing = db.scalar(
|
|
select(User).where(User.email == email)
|
|
)
|
|
|
|
if existing is not None:
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail="Account already exists",
|
|
)
|
|
|
|
user = User(
|
|
email=email,
|
|
password_hash=hash_password(request.password),
|
|
)
|
|
|
|
db.add(user)
|
|
db.flush()
|
|
|
|
raw_refresh, refresh_hash = create_refresh_token()
|
|
|
|
session = Session(
|
|
user_id=user.id,
|
|
refresh_token_hash=refresh_hash,
|
|
device_name=request.device_name,
|
|
expires_at=refresh_expiry(),
|
|
)
|
|
|
|
db.add(session)
|
|
verification_raw, verification_hash = create_one_time_token()
|
|
db.add(EmailVerificationToken(user_id=user.id, token_hash=verification_hash, expires_at=datetime.now(timezone.utc) + timedelta(hours=24)))
|
|
audit(db, "auth.register", actor_user_id=user.id, target_user_id=user.id)
|
|
db.commit()
|
|
|
|
web_base = os.getenv("WEB_BASE_URL", "https://motorsignal.app")
|
|
send_email(email, "Verify your MotorSignal email", f"Verify your email: {web_base}/verify-email?token={verification_raw}")
|
|
|
|
return TokenResponse(
|
|
access_token=create_access_token(user.id, session.id),
|
|
refresh_token=raw_refresh,
|
|
)
|
|
|
|
|
|
@app.post(
|
|
"/api/v1/auth/login",
|
|
response_model=TokenResponse,
|
|
)
|
|
def login(
|
|
request: LoginRequest,
|
|
http_request: Request,
|
|
db: DbSession = Depends(get_db),
|
|
):
|
|
enforce_auth_rate_limit(http_request, db, "login")
|
|
email = normalize_email(request.email)
|
|
|
|
user = db.scalar(
|
|
select(User).where(User.email == email)
|
|
)
|
|
|
|
if user is None or not verify_password(
|
|
request.password,
|
|
user.password_hash,
|
|
):
|
|
raise HTTPException(
|
|
status_code=401,
|
|
detail="Invalid email or password",
|
|
)
|
|
|
|
if not user.is_active:
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail="Account disabled",
|
|
)
|
|
|
|
raw_refresh, refresh_hash = create_refresh_token()
|
|
|
|
session = Session(
|
|
user_id=user.id,
|
|
refresh_token_hash=refresh_hash,
|
|
device_name=request.device_name,
|
|
expires_at=refresh_expiry(),
|
|
)
|
|
|
|
db.add(session)
|
|
audit(db, "auth.login", actor_user_id=user.id, target_user_id=user.id)
|
|
db.commit()
|
|
|
|
ops_token: str | None = None
|
|
stmt_op = select(Operator).where(
|
|
Operator.identity == email,
|
|
Operator.status == "active",
|
|
)
|
|
operator = db.scalar(stmt_op)
|
|
|
|
if operator is not None:
|
|
raw_ops_token = secrets.token_urlsafe(32)
|
|
token_hash = hashlib.sha256(raw_ops_token.encode("utf-8")).hexdigest()
|
|
now = datetime.now(timezone.utc)
|
|
expires = now + timedelta(hours=8)
|
|
auth_ver = getattr(operator, "authority_version", 1) or 1
|
|
db.execute(text("""
|
|
INSERT INTO ops.sessions (
|
|
id, operator_id, token_digest, authority_version,
|
|
firebase_auth_time, created_at, last_seen_at, absolute_expires_at,
|
|
idle_timeout_seconds, session_token_hash, expires_at, last_activity_at
|
|
) VALUES (
|
|
gen_random_uuid(), :op_id, :digest, :ver,
|
|
:auth_time, :now, :now, :expires,
|
|
900, :token_hash, :expires, :now
|
|
)
|
|
"""), {
|
|
"op_id": operator.id,
|
|
"digest": token_hash,
|
|
"ver": auth_ver,
|
|
"auth_time": int(now.timestamp()),
|
|
"now": now,
|
|
"expires": expires,
|
|
"token_hash": token_hash,
|
|
})
|
|
ops_audit(
|
|
db=db,
|
|
action="operator.login",
|
|
actor_identity=operator.identity,
|
|
actor_role=operator.role,
|
|
target_resource="ops.session",
|
|
result="success",
|
|
)
|
|
db.commit()
|
|
ops_token = raw_ops_token
|
|
|
|
return TokenResponse(
|
|
access_token=create_access_token(user.id, session.id),
|
|
refresh_token=raw_refresh,
|
|
ops_token=ops_token,
|
|
)
|
|
|
|
|
|
|
|
@app.post(
|
|
"/api/v1/auth/refresh",
|
|
response_model=TokenResponse,
|
|
)
|
|
def refresh(
|
|
request: RefreshRequest,
|
|
db: DbSession = Depends(get_db),
|
|
):
|
|
old_hash = hash_refresh_token(request.refresh_token)
|
|
|
|
session = db.scalar(
|
|
select(Session).where(
|
|
Session.refresh_token_hash == old_hash
|
|
)
|
|
)
|
|
|
|
now = datetime.now(timezone.utc)
|
|
|
|
if (
|
|
session is None
|
|
or session.revoked_at is not None
|
|
or session.expires_at <= now
|
|
):
|
|
raise HTTPException(
|
|
status_code=401,
|
|
detail="Invalid refresh token",
|
|
)
|
|
|
|
user = db.get(User, session.user_id)
|
|
|
|
if user is None or not user.is_active:
|
|
raise HTTPException(
|
|
status_code=401,
|
|
detail="Invalid refresh token",
|
|
)
|
|
|
|
new_raw, new_hash = create_refresh_token()
|
|
|
|
session.refresh_token_hash = new_hash
|
|
session.last_used_at = now
|
|
session.expires_at = refresh_expiry()
|
|
|
|
db.commit()
|
|
|
|
return TokenResponse(
|
|
access_token=create_access_token(user.id, session.id),
|
|
refresh_token=new_raw,
|
|
)
|
|
|
|
|
|
@app.post("/api/v1/auth/logout", status_code=204)
|
|
def logout(
|
|
request: RefreshRequest,
|
|
db: DbSession = Depends(get_db),
|
|
):
|
|
refresh_hash = hash_refresh_token(request.refresh_token)
|
|
|
|
session = db.scalar(
|
|
select(Session).where(
|
|
Session.refresh_token_hash == refresh_hash
|
|
)
|
|
)
|
|
|
|
if session is not None and session.revoked_at is None:
|
|
session.revoked_at = datetime.now(timezone.utc)
|
|
audit(db, "auth.logout", actor_user_id=session.user_id, target_user_id=session.user_id)
|
|
db.commit()
|
|
|
|
return None
|
|
|
|
|
|
@app.get("/api/v1/me")
|
|
def me(user: User = Depends(get_current_user)):
|
|
return {
|
|
"id": str(user.id),
|
|
"email": user.email,
|
|
"email_verified": user.email_verified,
|
|
"firebase_linked": user.firebase_uid is not None,
|
|
"role": user.role,
|
|
"created_at": user.created_at,
|
|
}
|
|
|
|
|
|
@app.post("/api/v1/auth/firebase/link")
|
|
def link_firebase_identity(request: FirebaseLinkRequest, user: User = Depends(get_current_user), db: DbSession = Depends(get_db)):
|
|
try:
|
|
claims = decode_firebase_id_token(request.id_token)
|
|
except Exception:
|
|
raise HTTPException(status_code=401, detail="Invalid Firebase identity")
|
|
firebase_email = claims.get("email")
|
|
if claims.get("email_verified") is not True or not isinstance(firebase_email, str) or normalize_email(firebase_email) != user.email:
|
|
raise HTTPException(status_code=403, detail="Firebase email must be verified and match this account")
|
|
firebase_uid = claims["sub"]
|
|
linked_user = db.scalar(select(User).where(User.firebase_uid == firebase_uid))
|
|
if linked_user is not None and linked_user.id != user.id:
|
|
raise HTTPException(status_code=409, detail="Firebase identity is already linked")
|
|
if user.firebase_uid is not None and user.firebase_uid != firebase_uid:
|
|
raise HTTPException(status_code=409, detail="Account is already linked to another Firebase identity")
|
|
if user.firebase_uid is None:
|
|
user.firebase_uid = firebase_uid
|
|
user.email_verified = True
|
|
audit(db, "auth.firebase_linked", actor_user_id=user.id, target_user_id=user.id)
|
|
db.commit()
|
|
return {"firebase_linked": True}
|
|
|
|
|
|
@app.post("/api/v1/auth/firebase/login", response_model=TokenResponse)
|
|
def login_with_firebase(request: FirebaseLinkRequest, db: DbSession = Depends(get_db)):
|
|
try:
|
|
claims = decode_firebase_id_token(request.id_token)
|
|
except Exception:
|
|
raise HTTPException(status_code=401, detail="Invalid Firebase identity token")
|
|
|
|
firebase_email = claims.get("email")
|
|
if not isinstance(firebase_email, str) or not firebase_email.strip():
|
|
raise HTTPException(status_code=400, detail="Firebase identity missing email")
|
|
|
|
email = normalize_email(firebase_email)
|
|
firebase_uid = claims["sub"]
|
|
|
|
user = db.scalar(select(User).where((User.firebase_uid == firebase_uid) | (User.email == email)))
|
|
if user is None:
|
|
random_pwd = secrets.token_urlsafe(32)
|
|
user = User(
|
|
email=email,
|
|
password_hash=hash_password(random_pwd),
|
|
email_verified=claims.get("email_verified", True),
|
|
firebase_uid=firebase_uid,
|
|
role="user",
|
|
)
|
|
db.add(user)
|
|
db.flush()
|
|
else:
|
|
if user.firebase_uid is None:
|
|
user.firebase_uid = firebase_uid
|
|
if claims.get("email_verified") is True:
|
|
user.email_verified = True
|
|
|
|
if not user.is_active:
|
|
raise HTTPException(status_code=403, detail="Account disabled")
|
|
|
|
raw_refresh, refresh_hash = create_refresh_token()
|
|
session = Session(
|
|
user_id=user.id,
|
|
refresh_token_hash=refresh_hash,
|
|
device_name="Google Sign-In",
|
|
expires_at=refresh_expiry(),
|
|
)
|
|
db.add(session)
|
|
audit(db, "auth.firebase_login", actor_user_id=user.id, target_user_id=user.id)
|
|
db.commit()
|
|
|
|
ops_token: str | None = None
|
|
operator = db.scalar(select(Operator).where(Operator.identity == email, Operator.status == "active"))
|
|
if operator is not None:
|
|
raw_ops_token = secrets.token_urlsafe(32)
|
|
token_hash = hashlib.sha256(raw_ops_token.encode("utf-8")).hexdigest()
|
|
now = datetime.now(timezone.utc)
|
|
expires = now + timedelta(hours=8)
|
|
auth_ver = getattr(operator, "authority_version", 1) or 1
|
|
db.execute(text("""
|
|
INSERT INTO ops.sessions (
|
|
id, operator_id, token_digest, authority_version,
|
|
firebase_auth_time, created_at, last_seen_at, absolute_expires_at,
|
|
idle_timeout_seconds, session_token_hash, expires_at, last_activity_at
|
|
) VALUES (
|
|
gen_random_uuid(), :op_id, :digest, :ver,
|
|
:auth_time, :now, :now, :expires,
|
|
900, :token_hash, :expires, :now
|
|
)
|
|
"""), {
|
|
"op_id": operator.id,
|
|
"digest": token_hash,
|
|
"ver": auth_ver,
|
|
"auth_time": int(now.timestamp()),
|
|
"now": now,
|
|
"expires": expires,
|
|
"token_hash": token_hash,
|
|
})
|
|
ops_audit(
|
|
db=db,
|
|
action="operator.firebase_login",
|
|
actor_identity=operator.identity,
|
|
actor_role=operator.role,
|
|
target_resource="ops.session",
|
|
result="success",
|
|
)
|
|
db.commit()
|
|
ops_token = raw_ops_token
|
|
|
|
return TokenResponse(
|
|
access_token=create_access_token(user.id, session.id),
|
|
refresh_token=raw_refresh,
|
|
ops_token=ops_token,
|
|
)
|
|
|
|
|
|
|
|
@app.post("/api/v1/auth/verify-email/request", status_code=202)
|
|
def request_email_verification(payload: EmailRequest, http_request: Request, db: DbSession = Depends(get_db)):
|
|
enforce_auth_rate_limit(http_request, db, "verify-email-request")
|
|
email = normalize_email(payload.email)
|
|
user = db.scalar(select(User).where(User.email == email))
|
|
if user is not None and not user.email_verified and user.is_active:
|
|
raw, token_hash = create_one_time_token()
|
|
db.add(EmailVerificationToken(user_id=user.id, token_hash=token_hash, expires_at=datetime.now(timezone.utc) + timedelta(hours=24)))
|
|
audit(db, "auth.email_verification_requested", target_user_id=user.id)
|
|
db.commit()
|
|
web_base = os.getenv("WEB_BASE_URL", "https://motorsignal.app")
|
|
send_email(email, "Verify your MotorSignal email", f"Verify your email: {web_base}/verify-email?token={raw}")
|
|
return {"detail": "If the account can be verified, instructions have been sent."}
|
|
|
|
|
|
@app.post("/api/v1/auth/verify-email", status_code=204)
|
|
def verify_email(request: TokenRequest, http_request: Request, db: DbSession = Depends(get_db)):
|
|
enforce_auth_rate_limit(http_request, db, "verify-email")
|
|
token = db.scalar(select(EmailVerificationToken).where(EmailVerificationToken.token_hash == hash_one_time_token(request.token)))
|
|
now = datetime.now(timezone.utc)
|
|
if token is None or token.used_at is not None or token.expires_at <= now:
|
|
raise HTTPException(status_code=400, detail="Invalid or expired verification token")
|
|
user = db.get(User, token.user_id)
|
|
if user is None:
|
|
raise HTTPException(status_code=400, detail="Invalid verification token")
|
|
user.email_verified = True
|
|
token.used_at = now
|
|
audit(db, "auth.email_verified", actor_user_id=user.id, target_user_id=user.id)
|
|
db.commit()
|
|
return None
|
|
|
|
|
|
@app.post("/api/v1/auth/password-reset/request", status_code=202)
|
|
def request_password_reset(payload: EmailRequest, http_request: Request, db: DbSession = Depends(get_db)):
|
|
enforce_auth_rate_limit(http_request, db, "password-reset-request")
|
|
email = normalize_email(payload.email)
|
|
user = db.scalar(select(User).where(User.email == email))
|
|
if user is not None and user.is_active:
|
|
raw, token_hash = create_one_time_token()
|
|
db.add(PasswordResetToken(user_id=user.id, token_hash=token_hash, expires_at=datetime.now(timezone.utc) + timedelta(hours=1)))
|
|
audit(db, "auth.password_reset_requested", target_user_id=user.id)
|
|
db.commit()
|
|
web_base = os.getenv("WEB_BASE_URL", "https://motorsignal.app")
|
|
send_email(email, "Reset your MotorSignal password", f"Reset your password: {web_base}/forgot-password?token={raw}")
|
|
return {"detail": "If the account exists, reset instructions have been sent."}
|
|
|
|
|
|
@app.post("/api/v1/auth/password-reset", status_code=204)
|
|
def reset_password(request: PasswordResetRequest, http_request: Request, db: DbSession = Depends(get_db)):
|
|
enforce_auth_rate_limit(http_request, db, "password-reset")
|
|
token = db.scalar(select(PasswordResetToken).where(PasswordResetToken.token_hash == hash_one_time_token(request.token)))
|
|
now = datetime.now(timezone.utc)
|
|
if token is None or token.used_at is not None or token.expires_at <= now:
|
|
raise HTTPException(status_code=400, detail="Invalid or expired reset token")
|
|
user = db.get(User, token.user_id)
|
|
if user is None or not user.is_active:
|
|
raise HTTPException(status_code=400, detail="Invalid reset token")
|
|
user.password_hash = hash_password(request.password)
|
|
token.used_at = now
|
|
db.query(Session).filter(Session.user_id == user.id, Session.revoked_at.is_(None)).update({Session.revoked_at: now}, synchronize_session=False)
|
|
audit(db, "auth.password_reset_completed", actor_user_id=user.id, target_user_id=user.id)
|
|
db.commit()
|
|
return None
|
|
|
|
|
|
@app.get("/api/v1/auth/sessions", response_model=list[SessionResponse])
|
|
def list_sessions(identity: tuple[User, Session] = Depends(get_current_identity), db: DbSession = Depends(get_db)):
|
|
user, current = identity
|
|
sessions = db.scalars(select(Session).where(Session.user_id == user.id, Session.revoked_at.is_(None)).order_by(Session.created_at.desc())).all()
|
|
return [SessionResponse(id=item.id, device_name=item.device_name, created_at=item.created_at, expires_at=item.expires_at, last_used_at=item.last_used_at, current=item.id == current.id) for item in sessions]
|
|
|
|
|
|
@app.delete("/api/v1/auth/sessions/{session_id}", status_code=204)
|
|
def revoke_session(session_id: uuid.UUID, identity: tuple[User, Session] = Depends(get_current_identity), db: DbSession = Depends(get_db)):
|
|
user, _ = identity
|
|
session = db.scalar(select(Session).where(Session.id == session_id, Session.user_id == user.id))
|
|
if session is None:
|
|
raise HTTPException(status_code=404, detail="Session not found")
|
|
if session.revoked_at is None:
|
|
session.revoked_at = datetime.now(timezone.utc)
|
|
audit(db, "auth.session_revoked", actor_user_id=user.id, target_user_id=user.id, metadata={"session_id": str(session.id)})
|
|
db.commit()
|
|
return None
|
|
|
|
|
|
@app.post("/api/v1/auth/logout-all", status_code=204)
|
|
def logout_all(identity: tuple[User, Session] = Depends(get_current_identity), db: DbSession = Depends(get_db)):
|
|
user, _ = identity
|
|
now = datetime.now(timezone.utc)
|
|
db.query(Session).filter(Session.user_id == user.id, Session.revoked_at.is_(None)).update({Session.revoked_at: now}, synchronize_session=False)
|
|
audit(db, "auth.logout_all", actor_user_id=user.id, target_user_id=user.id)
|
|
db.commit()
|
|
return None
|
|
|
|
|
|
def vehicle_payload(vehicle: Vehicle) -> dict:
|
|
return {"id": str(vehicle.id), "user_id": str(vehicle.user_id), "nickname": vehicle.nickname, "make": vehicle.make, "model": vehicle.model, "year": vehicle.year, "vin": vehicle.vin, "created_at": vehicle.created_at, "updated_at": vehicle.updated_at}
|
|
|
|
|
|
def ride_payload(ride: Ride) -> dict:
|
|
return {"id": str(ride.id), "user_id": str(ride.user_id), "vehicle_id": str(ride.vehicle_id) if ride.vehicle_id else None, "started_at": ride.started_at, "ended_at": ride.ended_at, "distance_miles": ride.distance_miles, "top_speed_mph": ride.top_speed_mph, "avg_speed_mph": ride.avg_speed_mph, "status": ride.status, "created_at": ride.created_at}
|
|
|
|
|
|
@app.get("/api/v1/vehicles")
|
|
def list_my_vehicles(user: User = Depends(get_current_user), db: DbSession = Depends(get_db)):
|
|
vehicles = db.scalars(select(Vehicle).where(Vehicle.user_id == user.id).order_by(Vehicle.created_at.desc())).all()
|
|
return [vehicle_payload(vehicle) for vehicle in vehicles]
|
|
|
|
|
|
@app.post("/api/v1/vehicles", status_code=201)
|
|
def create_my_vehicle(request: UserVehicleCreate, user: User = Depends(get_current_user), db: DbSession = Depends(get_db)):
|
|
vehicle = Vehicle(user_id=user.id, **request.model_dump())
|
|
db.add(vehicle)
|
|
db.flush()
|
|
audit(db, "vehicle.created", actor_user_id=user.id, target_user_id=user.id, metadata={"vehicle_id": str(vehicle.id)})
|
|
db.commit()
|
|
db.refresh(vehicle)
|
|
return vehicle_payload(vehicle)
|
|
|
|
|
|
@app.get("/api/v1/vehicles/{vehicle_id}")
|
|
def get_my_vehicle(vehicle_id: uuid.UUID, user: User = Depends(get_current_user), db: DbSession = Depends(get_db)):
|
|
vehicle = db.scalar(select(Vehicle).where(Vehicle.id == vehicle_id, Vehicle.user_id == user.id))
|
|
if vehicle is None:
|
|
raise HTTPException(status_code=404, detail="Vehicle not found")
|
|
return vehicle_payload(vehicle)
|
|
|
|
|
|
@app.patch("/api/v1/vehicles/{vehicle_id}")
|
|
def update_my_vehicle(vehicle_id: uuid.UUID, request: VehicleUpdate, user: User = Depends(get_current_user), db: DbSession = Depends(get_db)):
|
|
vehicle = db.scalar(select(Vehicle).where(Vehicle.id == vehicle_id, Vehicle.user_id == user.id))
|
|
if vehicle is None:
|
|
raise HTTPException(status_code=404, detail="Vehicle not found")
|
|
changes = request.model_dump(exclude_unset=True)
|
|
for field, value in changes.items():
|
|
setattr(vehicle, field, value)
|
|
audit(db, "vehicle.updated", actor_user_id=user.id, target_user_id=user.id, metadata={"vehicle_id": str(vehicle.id), "fields": list(changes)})
|
|
db.commit()
|
|
db.refresh(vehicle)
|
|
return vehicle_payload(vehicle)
|
|
|
|
|
|
@app.delete("/api/v1/vehicles/{vehicle_id}", status_code=204)
|
|
def delete_my_vehicle(vehicle_id: uuid.UUID, user: User = Depends(get_current_user), db: DbSession = Depends(get_db)):
|
|
vehicle = db.scalar(select(Vehicle).where(Vehicle.id == vehicle_id, Vehicle.user_id == user.id))
|
|
if vehicle is None:
|
|
raise HTTPException(status_code=404, detail="Vehicle not found")
|
|
audit(db, "vehicle.deleted", actor_user_id=user.id, target_user_id=user.id, metadata={"vehicle_id": str(vehicle.id)})
|
|
db.delete(vehicle)
|
|
db.commit()
|
|
return None
|
|
|
|
|
|
@app.get("/api/v1/rides")
|
|
def list_my_rides(page: int = Query(default=1, ge=1), page_size: int = Query(default=50, ge=1, le=100), user: User = Depends(get_current_user), db: DbSession = Depends(get_db)):
|
|
query = select(Ride).where(Ride.user_id == user.id)
|
|
total = db.scalar(select(func.count()).select_from(Ride).where(Ride.user_id == user.id)) or 0
|
|
rides = db.scalars(query.order_by(Ride.started_at.desc()).offset((page - 1) * page_size).limit(page_size)).all()
|
|
return {"items": [ride_payload(ride) for ride in rides], "page": page, "page_size": page_size, "total": total}
|
|
|
|
|
|
@app.post("/api/v1/rides", status_code=201)
|
|
def create_my_ride(request: UserRideCreate, user: User = Depends(get_current_user), db: DbSession = Depends(get_db)):
|
|
if request.vehicle_id is not None:
|
|
vehicle = db.scalar(select(Vehicle).where(Vehicle.id == request.vehicle_id, Vehicle.user_id == user.id))
|
|
if vehicle is None:
|
|
raise HTTPException(status_code=400, detail="Vehicle not found")
|
|
ride = Ride(user_id=user.id, **request.model_dump())
|
|
db.add(ride)
|
|
db.flush()
|
|
audit(db, "ride.created", actor_user_id=user.id, target_user_id=user.id, metadata={"ride_id": str(ride.id)})
|
|
db.commit()
|
|
db.refresh(ride)
|
|
return ride_payload(ride)
|
|
|
|
|
|
@app.get("/api/v1/rides/{ride_id}")
|
|
def get_my_ride(ride_id: uuid.UUID, user: User = Depends(get_current_user), db: DbSession = Depends(get_db)):
|
|
ride = db.scalar(select(Ride).where(Ride.id == ride_id, Ride.user_id == user.id))
|
|
if ride is None:
|
|
raise HTTPException(status_code=404, detail="Ride not found")
|
|
return ride_payload(ride)
|
|
|
|
|
|
def telemetry_payload(point: TelemetryPoint) -> dict:
|
|
return {"id": str(point.id), "ride_id": str(point.ride_id), "recorded_at": point.recorded_at, "latitude": point.latitude, "longitude": point.longitude, "speed_mph": point.speed_mph, "lean_angle_deg": point.lean_angle_deg, "g_force": point.g_force, "accel_g": point.accel_g, "brake_g": point.brake_g}
|
|
|
|
|
|
def get_owned_ride(ride_id: uuid.UUID, user_id: uuid.UUID, db: DbSession) -> Ride:
|
|
ride = db.scalar(select(Ride).where(Ride.id == ride_id, Ride.user_id == user_id))
|
|
if ride is None:
|
|
raise HTTPException(status_code=404, detail="Ride not found")
|
|
return ride
|
|
|
|
|
|
@app.post("/api/v1/rides/{ride_id}/telemetry", status_code=201)
|
|
def ingest_telemetry(ride_id: uuid.UUID, request: TelemetryBatch, user: User = Depends(get_current_user), db: DbSession = Depends(get_db)):
|
|
get_owned_ride(ride_id, user.id, db)
|
|
points = [TelemetryPoint(ride_id=ride_id, **point.model_dump()) for point in request.points]
|
|
db.add_all(points)
|
|
audit(db, "telemetry.ingested", actor_user_id=user.id, target_user_id=user.id, metadata={"ride_id": str(ride_id), "point_count": len(points)})
|
|
db.commit()
|
|
return {"accepted": len(points)}
|
|
|
|
|
|
@app.get("/api/v1/rides/{ride_id}/telemetry")
|
|
def list_my_telemetry(ride_id: uuid.UUID, limit: int = Query(default=1000, ge=1, le=5000), user: User = Depends(get_current_user), db: DbSession = Depends(get_db)):
|
|
get_owned_ride(ride_id, user.id, db)
|
|
points = db.scalars(select(TelemetryPoint).where(TelemetryPoint.ride_id == ride_id).order_by(TelemetryPoint.recorded_at.asc()).limit(limit)).all()
|
|
return {"items": [telemetry_payload(point) for point in points], "count": len(points)}
|
|
|
|
|
|
def telemetry_summary(ride_id: uuid.UUID, db: DbSession) -> dict:
|
|
count, first_recorded, last_recorded, max_speed, max_lean, max_g = db.execute(
|
|
select(func.count(TelemetryPoint.id), func.min(TelemetryPoint.recorded_at), func.max(TelemetryPoint.recorded_at), func.max(TelemetryPoint.speed_mph), func.max(func.abs(TelemetryPoint.lean_angle_deg)), func.max(TelemetryPoint.g_force)).where(TelemetryPoint.ride_id == ride_id)
|
|
).one()
|
|
duration_seconds = (last_recorded - first_recorded).total_seconds() if first_recorded and last_recorded else 0
|
|
return {"ride_id": str(ride_id), "point_count": count, "duration_seconds": duration_seconds, "max_speed_mph": max_speed, "max_lean_angle_deg": max_lean, "max_g_force": max_g}
|
|
|
|
|
|
@app.get("/api/v1/rides/{ride_id}/telemetry/summary")
|
|
def my_telemetry_summary(ride_id: uuid.UUID, user: User = Depends(get_current_user), db: DbSession = Depends(get_db)):
|
|
get_owned_ride(ride_id, user.id, db)
|
|
return telemetry_summary(ride_id, db)
|
|
|
|
|
|
|
|
def subscription_payload(subscription: Subscription) -> dict:
|
|
return {"id": str(subscription.id), "user_id": str(subscription.user_id), "plan": subscription.plan, "status": subscription.status, "current_period_start": subscription.current_period_start, "current_period_end": subscription.current_period_end, "created_at": subscription.created_at, "updated_at": subscription.updated_at}
|
|
|
|
|
|
def entitlement_payload(entitlement: Entitlement) -> dict:
|
|
return {"id": str(entitlement.id), "user_id": str(entitlement.user_id), "key": entitlement.key, "value": entitlement.value, "expires_at": entitlement.expires_at, "created_at": entitlement.created_at}
|
|
|
|
|
|
def get_or_create_subscription(user_id: uuid.UUID, db: DbSession) -> Subscription:
|
|
subscription = db.scalar(select(Subscription).where(Subscription.user_id == user_id))
|
|
if subscription is None:
|
|
subscription = Subscription(user_id=user_id, plan="free", status="active")
|
|
db.add(subscription)
|
|
db.flush()
|
|
return subscription
|
|
|
|
|
|
@app.get("/api/v1/me/subscription")
|
|
def get_my_subscription(user: User = Depends(get_current_user), db: DbSession = Depends(get_db)):
|
|
subscription = get_or_create_subscription(user.id, db)
|
|
db.commit()
|
|
db.refresh(subscription)
|
|
return subscription_payload(subscription)
|
|
|
|
|
|
@app.get("/api/v1/me/entitlements")
|
|
def list_my_entitlements(user: User = Depends(get_current_user), db: DbSession = Depends(get_db)):
|
|
entitlements = db.scalars(select(Entitlement).where(Entitlement.user_id == user.id).order_by(Entitlement.created_at.desc())).all()
|
|
return [entitlement_payload(item) for item in entitlements if item.expires_at is None or item.expires_at > datetime.now(timezone.utc)]
|
|
|
|
|
|
|
|
# ============================================================
|
|
# Operations (OPS-3B / OPS-3C / OPS-4) Platform Endpoints
|
|
# ============================================================
|
|
|
|
import hashlib
|
|
|
|
|
|
def get_current_operator(
|
|
request: Request,
|
|
db: DbSession = Depends(get_db),
|
|
) -> tuple[Operator, OpsSession]:
|
|
auth_header = request.headers.get("authorization")
|
|
token: str | None = None
|
|
if auth_header:
|
|
scheme, _, param = auth_header.partition(" ")
|
|
if scheme.lower() == "bearer" and param:
|
|
token = param
|
|
if not token:
|
|
token = request.cookies.get("__Host-motorsignal_ops") or request.cookies.get("motorsignal_ops")
|
|
|
|
if not token:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Missing Operations session credentials",
|
|
)
|
|
|
|
token_hash = hashlib.sha256(token.encode("utf-8")).hexdigest()
|
|
now = datetime.now(timezone.utc)
|
|
|
|
stmt = (
|
|
select(OpsSession, Operator)
|
|
.join(Operator, OpsSession.operator_id == Operator.id)
|
|
.where(
|
|
OpsSession.session_token_hash == token_hash,
|
|
OpsSession.revoked_at.is_(None),
|
|
OpsSession.expires_at > now,
|
|
Operator.status == "active",
|
|
)
|
|
)
|
|
result = db.execute(stmt).first()
|
|
if result is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Operations session expired or invalid",
|
|
)
|
|
|
|
ops_session, operator = result
|
|
ops_session.last_activity_at = now
|
|
db.commit()
|
|
return operator, ops_session
|
|
|
|
|
|
def require_ops_roles(*roles: str):
|
|
def dependency(
|
|
auth: tuple[Operator, OpsSession] = Depends(get_current_operator),
|
|
) -> tuple[Operator, OpsSession]:
|
|
operator, session = auth
|
|
if operator.role not in roles:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Insufficient Operations authority",
|
|
)
|
|
return operator, session
|
|
|
|
return dependency
|
|
|
|
|
|
def ops_audit(
|
|
db: DbSession,
|
|
action: str,
|
|
actor_identity: str,
|
|
actor_role: str | None,
|
|
target_resource: str | None = None,
|
|
reason_code: str | None = None,
|
|
case_reference: str | None = None,
|
|
result: str = "success",
|
|
metadata: dict | None = None,
|
|
) -> OpsAuditEvent:
|
|
event = OpsAuditEvent(
|
|
timestamp=datetime.now(timezone.utc),
|
|
action=action,
|
|
actor_identity=actor_identity,
|
|
actor_role=actor_role,
|
|
target_resource=target_resource,
|
|
reason_code=reason_code,
|
|
case_reference=case_reference,
|
|
result=result,
|
|
metadata_json=metadata,
|
|
)
|
|
db.add(event)
|
|
return event
|
|
|
|
|
|
def generate_incident_case_reference(db: DbSession) -> str:
|
|
year = datetime.now(timezone.utc).year
|
|
seq_val = db.execute(text("SELECT nextval('ops.incident_case_seq')")).scalar()
|
|
return f"OPS-{year}-{int(seq_val):06d}"
|
|
|
|
|
|
class OpsIncidentCreate(BaseModel):
|
|
model_config = ConfigDict(extra="forbid")
|
|
title: str = Field(min_length=1, max_length=255)
|
|
summary: str = Field(min_length=1, max_length=4000)
|
|
severity: str = Field(default="medium")
|
|
|
|
|
|
class OpsIncidentPatch(BaseModel):
|
|
model_config = ConfigDict(extra="forbid")
|
|
severity: str | None = None
|
|
status: str | None = None
|
|
resolution: str | None = Field(default=None, max_length=4000)
|
|
|
|
|
|
class OpsIncidentNoteCreate(BaseModel):
|
|
model_config = ConfigDict(extra="forbid")
|
|
body: str = Field(min_length=1, max_length=4000)
|
|
|
|
|
|
class OpsUserStatusUpdate(BaseModel):
|
|
model_config = ConfigDict(extra="forbid")
|
|
is_active: bool
|
|
reason: str = Field(min_length=3)
|
|
|
|
|
|
class OpsUserActionRequest(BaseModel):
|
|
model_config = ConfigDict(extra="forbid")
|
|
reason: str = Field(min_length=3)
|
|
|
|
|
|
class OpsSupportDiagnosticBundle(BaseModel):
|
|
model_config = ConfigDict(extra="ignore")
|
|
app_version: str
|
|
os_version: str
|
|
device_model: str
|
|
stack_trace: str | None = None
|
|
sensor_status: str
|
|
logs_snippet: str | None = None
|
|
submitted_at: str
|
|
|
|
|
|
|
|
class OpsActionCreate(BaseModel):
|
|
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
|
|
action_type: Literal["deploy_website", "restart_website", "restart_api", "reboot_server"]
|
|
idempotency_key: str | None = Field(default=None, min_length=8, max_length=128)
|
|
confirmation_text: str | None = Field(default=None, max_length=64)
|
|
|
|
class OpsActionRead(BaseModel):
|
|
id: uuid.UUID
|
|
action_type: str
|
|
requested_by_id: uuid.UUID
|
|
request_timestamp: datetime
|
|
start_timestamp: datetime | None
|
|
completion_timestamp: datetime | None
|
|
state: str
|
|
status_message: str | None
|
|
deployed_revision: str | None
|
|
target_revision: str | None
|
|
preflight_result: str | None
|
|
health_result: str | None
|
|
rollback_status: str | None
|
|
failure_code: str | None
|
|
idempotency_key: str | None
|
|
|
|
class OpsSupportTicketCreate(BaseModel):
|
|
model_config = ConfigDict(extra="forbid")
|
|
ticket_type: str = Field(default="general")
|
|
reporter_email: str = Field(min_length=3, max_length=255)
|
|
subject: str = Field(min_length=1, max_length=255)
|
|
description: str = Field(min_length=1, max_length=10000)
|
|
severity: str = Field(default="medium")
|
|
diagnostic_bundle: OpsSupportDiagnosticBundle | dict | None = None
|
|
|
|
@field_validator("reporter_email")
|
|
@classmethod
|
|
def validate_reporter_email(cls, v: str) -> str:
|
|
s = v.strip()
|
|
if len(s) < 3:
|
|
raise ValueError("Reporter email must not be empty or whitespace only.")
|
|
return s
|
|
|
|
@field_validator("subject", "description")
|
|
@classmethod
|
|
def validate_non_whitespace(cls, v: str) -> str:
|
|
s = v.strip()
|
|
if not s:
|
|
raise ValueError("Field cannot be empty or contain only whitespace.")
|
|
return s
|
|
|
|
|
|
class OpsSupportTicketPatch(BaseModel):
|
|
model_config = ConfigDict(extra="forbid")
|
|
status: str | None = None
|
|
severity: str | None = None
|
|
|
|
|
|
class OpsSupportTicketNoteCreate(BaseModel):
|
|
model_config = ConfigDict(extra="forbid")
|
|
body: str = Field(min_length=1, max_length=10000)
|
|
|
|
|
|
@app.get("/api/v1/ops/me")
|
|
def ops_get_me(
|
|
auth: tuple[Operator, OpsSession] = Depends(get_current_operator),
|
|
):
|
|
operator, session = auth
|
|
return {
|
|
"identity": operator.identity,
|
|
"role": operator.role,
|
|
"status": operator.status,
|
|
"created_at": operator.created_at.isoformat(),
|
|
"last_activity_at": session.last_activity_at.isoformat() if session.last_activity_at else None,
|
|
"expires_at": session.expires_at.isoformat(),
|
|
}
|
|
|
|
|
|
@app.get("/api/v1/ops/health")
|
|
def ops_get_health():
|
|
now_iso = datetime.now(timezone.utc).isoformat()
|
|
db_ok = False
|
|
try:
|
|
db_ok = check_database()
|
|
except Exception:
|
|
db_ok = False
|
|
|
|
overall = "available" if db_ok else "degraded"
|
|
return {
|
|
"status": overall,
|
|
"checked_at": now_iso,
|
|
"components": {
|
|
"operations_api": {
|
|
"status": "available",
|
|
"description": "FastAPI core endpoint runtime & ingress",
|
|
},
|
|
"operations_database": {
|
|
"status": "available" if db_ok else "unavailable",
|
|
"description": "PostgreSQL ops schema relational persistence",
|
|
},
|
|
"session_validation": {
|
|
"status": "available" if db_ok else "degraded",
|
|
"description": "__Host-motorsignal_ops token verification & authority",
|
|
},
|
|
"audit_persistence": {
|
|
"status": "available" if db_ok else "degraded",
|
|
"description": "Immutable operational security activity persistence",
|
|
},
|
|
},
|
|
}
|
|
|
|
|
|
@app.get("/api/v1/ops/infrastructure")
|
|
def ops_get_infrastructure(
|
|
auth: tuple[Operator, OpsSession] = Depends(require_ops_roles("operator", "owner")),
|
|
db: DbSession = Depends(get_db),
|
|
):
|
|
db_ok = False
|
|
try:
|
|
db_ok = check_database()
|
|
except Exception:
|
|
db_ok = False
|
|
|
|
return get_infrastructure_metrics(database_ok=db_ok)
|
|
|
|
|
|
@app.get("/api/v1/ops/overview")
|
|
def ops_get_overview(
|
|
auth: tuple[Operator, OpsSession] = Depends(get_current_operator),
|
|
db: DbSession = Depends(get_db),
|
|
):
|
|
now = datetime.now(timezone.utc)
|
|
t_24h = now - timedelta(hours=24)
|
|
t_7d = now - timedelta(days=7)
|
|
|
|
total_users = db.scalar(select(func.count()).select_from(User)) or 0
|
|
new_users_7d = db.scalar(select(func.count()).select_from(User).where(User.created_at >= t_7d)) or 0
|
|
active_users_24h = db.scalar(
|
|
select(func.count(func.distinct(Session.user_id)))
|
|
.where(Session.last_used_at >= t_24h, Session.revoked_at.is_(None))
|
|
) or 0
|
|
active_users_7d = db.scalar(
|
|
select(func.count(func.distinct(Session.user_id)))
|
|
.where(Session.last_used_at >= t_7d, Session.revoked_at.is_(None))
|
|
) or 0
|
|
|
|
total_subscribers = db.scalar(select(func.count()).select_from(Subscription)) or 0
|
|
pro_members = db.scalar(
|
|
select(func.count()).select_from(Subscription)
|
|
.where(Subscription.plan.in_(["pro", "premium"]), Subscription.status == "active")
|
|
) or 0
|
|
free_members = db.scalar(
|
|
select(func.count()).select_from(Subscription)
|
|
.where((Subscription.plan == "free") | (Subscription.status != "active"))
|
|
) or (total_users - pro_members if total_users > pro_members else 0)
|
|
conversion_rate = round((pro_members / max(1, total_users)) * 100, 1)
|
|
|
|
web_sessions_24h = db.scalar(
|
|
select(func.count()).select_from(Session)
|
|
.where(
|
|
(Session.device_name.ilike("%web%") | Session.device_name.ilike("%browser%")),
|
|
Session.last_used_at >= t_24h,
|
|
Session.revoked_at.is_(None)
|
|
)
|
|
) or 0
|
|
web_logins_24h = db.scalar(
|
|
select(func.count()).select_from(AuditEvent)
|
|
.where(AuditEvent.event_type == "auth.login", AuditEvent.created_at >= t_24h)
|
|
) or 0
|
|
|
|
mobile_sessions_24h = db.scalar(
|
|
select(func.count()).select_from(Session)
|
|
.where(
|
|
~Session.device_name.ilike("%web%"),
|
|
~Session.device_name.ilike("%browser%"),
|
|
Session.last_used_at >= t_24h,
|
|
Session.revoked_at.is_(None)
|
|
)
|
|
) or 0
|
|
mobile_rides_today = db.scalar(
|
|
select(func.count()).select_from(Ride)
|
|
.where(Ride.started_at >= t_24h)
|
|
) or 0
|
|
mobile_rides_active = db.scalar(
|
|
select(func.count()).select_from(Ride)
|
|
.where(Ride.status.in_(["recording", "in_progress", "active"]))
|
|
) or 0
|
|
mobile_datapoints_24h = db.scalar(
|
|
select(func.count()).select_from(TelemetryPoint)
|
|
.where(TelemetryPoint.recorded_at >= t_24h)
|
|
) or 0
|
|
|
|
open_incidents = db.scalars(
|
|
select(OpsIncident)
|
|
.where(OpsIncident.status.in_(["open", "investigating", "monitoring"]))
|
|
.order_by(OpsIncident.created_at.desc())
|
|
).all()
|
|
total_open = len(open_incidents)
|
|
crit_count = sum(1 for i in open_incidents if i.severity == "critical")
|
|
high_count = sum(1 for i in open_incidents if i.severity == "high")
|
|
med_count = sum(1 for i in open_incidents if i.severity == "medium")
|
|
low_count = sum(1 for i in open_incidents if i.severity == "low")
|
|
recent_open = [
|
|
{
|
|
"case_reference": inc.case_reference,
|
|
"title": inc.title,
|
|
"severity": inc.severity,
|
|
"status": inc.status,
|
|
"created_at": inc.created_at.isoformat(),
|
|
"updated_at": inc.updated_at.isoformat(),
|
|
}
|
|
for inc in open_incidents[:5]
|
|
]
|
|
|
|
error_events = db.scalars(
|
|
select(OpsAuditEvent)
|
|
.where(OpsAuditEvent.result.in_(["failure", "denied"]))
|
|
.order_by(OpsAuditEvent.timestamp.desc())
|
|
.limit(5)
|
|
).all()
|
|
errors_24h = db.scalar(
|
|
select(func.count()).select_from(OpsAuditEvent)
|
|
.where(OpsAuditEvent.result.in_(["failure", "denied"]), OpsAuditEvent.timestamp >= t_24h)
|
|
) or 0
|
|
denials_24h = db.scalar(
|
|
select(func.count()).select_from(OpsAuditEvent)
|
|
.where(OpsAuditEvent.result == "denied", OpsAuditEvent.timestamp >= t_24h)
|
|
) or 0
|
|
|
|
recent_errors = [
|
|
{
|
|
"id": str(e.id),
|
|
"timestamp": e.timestamp.isoformat(),
|
|
"action": e.action,
|
|
"actor_identity": e.actor_identity,
|
|
"reason_code": e.reason_code,
|
|
"result": e.result,
|
|
"target_resource": e.target_resource,
|
|
}
|
|
for e in error_events
|
|
]
|
|
|
|
return {
|
|
"generated_at": now.isoformat(),
|
|
"users": {
|
|
"total_registered": total_users,
|
|
"active_24h": active_users_24h,
|
|
"active_7d": active_users_7d,
|
|
"new_this_week": new_users_7d,
|
|
},
|
|
"membership": {
|
|
"total_subscribers": total_subscribers,
|
|
"premium_members": pro_members,
|
|
"pro_members": pro_members,
|
|
"free_members": free_members,
|
|
"conversion_rate_pct": conversion_rate,
|
|
},
|
|
"activity": {
|
|
"web_active_sessions_24h": web_sessions_24h,
|
|
"web_logins_24h": web_logins_24h,
|
|
"web_signups_7d": new_users_7d,
|
|
"mobile_active_sessions_24h": mobile_sessions_24h,
|
|
"mobile_rides_today": mobile_rides_today,
|
|
"mobile_rides_active": mobile_rides_active,
|
|
"mobile_datapoints_24h": mobile_datapoints_24h,
|
|
},
|
|
"incidents": {
|
|
"total_open": total_open,
|
|
"critical_count": crit_count,
|
|
"high_count": high_count,
|
|
"medium_count": med_count,
|
|
"low_count": low_count,
|
|
"recent_open": recent_open,
|
|
},
|
|
"errors": {
|
|
"errors_24h": errors_24h,
|
|
"denials_24h": denials_24h,
|
|
"recent": recent_errors,
|
|
},
|
|
"health": ops_get_health(),
|
|
}
|
|
|
|
|
|
@app.delete("/api/v1/ops/sessions/current", status_code=204)
|
|
def ops_logout_current_session(
|
|
auth: tuple[Operator, OpsSession] = Depends(get_current_operator),
|
|
db: DbSession = Depends(get_db),
|
|
):
|
|
operator, session = auth
|
|
session.revoked_at = datetime.now(timezone.utc)
|
|
ops_audit(
|
|
db=db,
|
|
action="auth.session_revoked",
|
|
actor_identity=operator.identity,
|
|
actor_role=operator.role,
|
|
target_resource="ops.session",
|
|
result="success",
|
|
)
|
|
db.commit()
|
|
|
|
|
|
@app.post("/api/v1/ops/session/bootstrap")
|
|
def ops_bootstrap_session(
|
|
current_user: User = Depends(get_current_user),
|
|
db: DbSession = Depends(get_db),
|
|
):
|
|
stmt = select(Operator).where(
|
|
Operator.identity == current_user.email,
|
|
Operator.status == "active",
|
|
)
|
|
operator = db.scalar(stmt)
|
|
|
|
if operator is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="User identity is not an active Operations operator",
|
|
)
|
|
|
|
raw_ops_token = secrets.token_urlsafe(32)
|
|
token_hash = hashlib.sha256(raw_ops_token.encode("utf-8")).hexdigest()
|
|
now = datetime.now(timezone.utc)
|
|
expires = now + timedelta(hours=8)
|
|
auth_ver = getattr(operator, "authority_version", 1) or 1
|
|
|
|
db.execute(text("""
|
|
INSERT INTO ops.sessions (
|
|
id, operator_id, token_digest, authority_version,
|
|
firebase_auth_time, created_at, last_seen_at, absolute_expires_at,
|
|
idle_timeout_seconds, session_token_hash, expires_at, last_activity_at
|
|
) VALUES (
|
|
gen_random_uuid(), :op_id, :digest, :ver,
|
|
:auth_time, :now, :now, :expires,
|
|
900, :token_hash, :expires, :now
|
|
)
|
|
"""), {
|
|
"op_id": operator.id,
|
|
"digest": token_hash,
|
|
"ver": auth_ver,
|
|
"auth_time": int(now.timestamp()),
|
|
"now": now,
|
|
"expires": expires,
|
|
"token_hash": token_hash,
|
|
})
|
|
|
|
ops_audit(
|
|
db=db,
|
|
action="operator.session_bootstrap",
|
|
actor_identity=operator.identity,
|
|
actor_role=operator.role,
|
|
target_resource="ops.session",
|
|
result="success",
|
|
)
|
|
db.commit()
|
|
|
|
return {
|
|
"ops_token": raw_ops_token,
|
|
"expires_at": expires.isoformat(),
|
|
"role": operator.role,
|
|
"identity": operator.identity,
|
|
}
|
|
|
|
|
|
@app.get("/api/v1/ops/audit-events")
|
|
|
|
def ops_list_audit_events(
|
|
cursor: str | None = None,
|
|
limit: int = Query(default=25, ge=1, le=100),
|
|
auth: tuple[Operator, OpsSession] = Depends(require_ops_roles("owner")),
|
|
db: DbSession = Depends(get_db),
|
|
):
|
|
stmt = select(OpsAuditEvent).order_by(OpsAuditEvent.timestamp.desc())
|
|
if cursor:
|
|
try:
|
|
cursor_dt = datetime.fromisoformat(cursor)
|
|
stmt = stmt.where(OpsAuditEvent.timestamp < cursor_dt)
|
|
except ValueError:
|
|
pass
|
|
|
|
events = db.scalars(stmt.limit(limit + 1)).all()
|
|
has_more = len(events) > limit
|
|
returned_events = events[:limit]
|
|
|
|
next_cursor = returned_events[-1].timestamp.isoformat() if has_more and returned_events else None
|
|
total_count = db.scalar(select(func.count(OpsAuditEvent.id))) or 0
|
|
|
|
items = [
|
|
{
|
|
"id": str(e.id),
|
|
"timestamp": e.timestamp.isoformat(),
|
|
"action": e.action,
|
|
"actor_identity": e.actor_identity,
|
|
"actor_role": e.actor_role,
|
|
"target_resource": e.target_resource,
|
|
"reason_code": e.reason_code,
|
|
"case_reference": e.case_reference,
|
|
"result": e.result,
|
|
"metadata": e.metadata_json or {},
|
|
}
|
|
for e in returned_events
|
|
]
|
|
|
|
return {
|
|
"items": items,
|
|
"next_cursor": next_cursor,
|
|
"total": total_count,
|
|
}
|
|
|
|
|
|
# ============================================================
|
|
# Incidents Endpoints (OPS-4)
|
|
# ============================================================
|
|
|
|
VALID_SEVERITIES = {"low", "medium", "high", "critical"}
|
|
VALID_STATUSES = {"open", "investigating", "monitoring", "resolved", "closed", "archived"}
|
|
|
|
ALLOWED_TRANSITIONS = {
|
|
"open": {"investigating", "resolved", "closed"},
|
|
"investigating": {"monitoring", "resolved", "closed"},
|
|
"monitoring": {"investigating", "resolved", "closed"},
|
|
"resolved": {"closed", "investigating"},
|
|
"closed": {"investigating"}, # Reopen (Owner only)
|
|
"archived": set(), # Terminal (no further mutation)
|
|
}
|
|
|
|
|
|
@app.get("/api/v1/ops/incidents")
|
|
def ops_list_incidents(
|
|
status: str | None = None,
|
|
severity: str | None = None,
|
|
case_reference: str | None = None,
|
|
cursor: str | None = None,
|
|
limit: int = Query(default=25, ge=1, le=100),
|
|
auth: tuple[Operator, OpsSession] = Depends(require_ops_roles("operator", "owner")),
|
|
db: DbSession = Depends(get_db),
|
|
):
|
|
stmt = select(OpsIncident).order_by(OpsIncident.created_at.desc())
|
|
|
|
if case_reference:
|
|
stmt = stmt.where(OpsIncident.case_reference == case_reference.strip())
|
|
if status:
|
|
stmt = stmt.where(OpsIncident.status == status.strip().lower())
|
|
if severity:
|
|
stmt = stmt.where(OpsIncident.severity == severity.strip().lower())
|
|
|
|
if cursor:
|
|
try:
|
|
cursor_dt = datetime.fromisoformat(cursor)
|
|
stmt = stmt.where(OpsIncident.created_at < cursor_dt)
|
|
except ValueError:
|
|
pass
|
|
|
|
incidents = db.scalars(stmt.limit(limit + 1)).all()
|
|
has_more = len(incidents) > limit
|
|
returned_incidents = incidents[:limit]
|
|
next_cursor = returned_incidents[-1].created_at.isoformat() if has_more and returned_incidents else None
|
|
|
|
items = [
|
|
{
|
|
"case_reference": inc.case_reference,
|
|
"title": inc.title,
|
|
"severity": inc.severity,
|
|
"status": inc.status,
|
|
"created_at": inc.created_at.isoformat(),
|
|
"updated_at": inc.updated_at.isoformat(),
|
|
}
|
|
for inc in returned_incidents
|
|
]
|
|
|
|
return {
|
|
"items": items,
|
|
"next_cursor": next_cursor,
|
|
}
|
|
|
|
|
|
@app.post("/api/v1/ops/incidents", status_code=201)
|
|
def ops_create_incident(
|
|
request: OpsIncidentCreate,
|
|
auth: tuple[Operator, OpsSession] = Depends(require_ops_roles("operator", "owner")),
|
|
db: DbSession = Depends(get_db),
|
|
):
|
|
operator, _ = auth
|
|
sev = request.severity.strip().lower()
|
|
if sev not in VALID_SEVERITIES:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail=f"Invalid severity '{request.severity}'. Must be one of: {sorted(VALID_SEVERITIES)}",
|
|
)
|
|
|
|
case_ref = generate_incident_case_reference(db)
|
|
now = datetime.now(timezone.utc)
|
|
|
|
incident = OpsIncident(
|
|
case_reference=case_ref,
|
|
title=request.title.strip(),
|
|
summary=request.summary.strip(),
|
|
severity=sev,
|
|
status="open",
|
|
created_at=now,
|
|
created_by_operator_id=operator.id,
|
|
updated_at=now,
|
|
)
|
|
db.add(incident)
|
|
db.flush()
|
|
|
|
# Transactional Audit
|
|
ops_audit(
|
|
db=db,
|
|
action="incident.created",
|
|
actor_identity=operator.identity,
|
|
actor_role=operator.role,
|
|
target_resource="ops.incident",
|
|
case_reference=case_ref,
|
|
result="success",
|
|
metadata={"severity": sev},
|
|
)
|
|
|
|
db.commit()
|
|
db.refresh(incident)
|
|
|
|
return {
|
|
"case_reference": incident.case_reference,
|
|
"title": incident.title,
|
|
"summary": incident.summary,
|
|
"severity": incident.severity,
|
|
"status": incident.status,
|
|
"created_at": incident.created_at.isoformat(),
|
|
"updated_at": incident.updated_at.isoformat(),
|
|
"closed_at": None,
|
|
"resolution": None,
|
|
"created_by_identity": operator.identity,
|
|
"closed_by_identity": None,
|
|
}
|
|
|
|
|
|
@app.get("/api/v1/ops/incidents/{case_reference}")
|
|
def ops_get_incident_detail(
|
|
case_reference: str,
|
|
auth: tuple[Operator, OpsSession] = Depends(require_ops_roles("operator", "owner")),
|
|
db: DbSession = Depends(get_db),
|
|
):
|
|
stmt = select(OpsIncident).where(OpsIncident.case_reference == case_reference.strip())
|
|
incident = db.scalar(stmt)
|
|
if incident is None:
|
|
raise HTTPException(status_code=404, detail="Incident not found")
|
|
|
|
creator = db.get(Operator, incident.created_by_operator_id)
|
|
closer = db.get(Operator, incident.closed_by_operator_id) if incident.closed_by_operator_id else None
|
|
|
|
return {
|
|
"case_reference": incident.case_reference,
|
|
"title": incident.title,
|
|
"summary": incident.summary,
|
|
"severity": incident.severity,
|
|
"status": incident.status,
|
|
"created_at": incident.created_at.isoformat(),
|
|
"updated_at": incident.updated_at.isoformat(),
|
|
"closed_at": incident.closed_at.isoformat() if incident.closed_at else None,
|
|
"resolution": incident.resolution,
|
|
"created_by_identity": creator.identity if creator else "operator",
|
|
"closed_by_identity": closer.identity if closer else None,
|
|
}
|
|
|
|
|
|
@app.patch("/api/v1/ops/incidents/{case_reference}")
|
|
def ops_patch_incident(
|
|
case_reference: str,
|
|
patch: OpsIncidentPatch,
|
|
auth: tuple[Operator, OpsSession] = Depends(require_ops_roles("operator", "owner")),
|
|
db: DbSession = Depends(get_db),
|
|
):
|
|
operator, _ = auth
|
|
stmt = select(OpsIncident).where(OpsIncident.case_reference == case_reference.strip())
|
|
incident = db.scalar(stmt)
|
|
if incident is None:
|
|
raise HTTPException(status_code=404, detail="Incident not found")
|
|
|
|
if incident.status == "archived":
|
|
raise HTTPException(status_code=409, detail="Archived incidents cannot be modified")
|
|
|
|
now = datetime.now(timezone.utc)
|
|
target_status = patch.status.strip().lower() if patch.status else None
|
|
target_severity = patch.severity.strip().lower() if patch.severity else None
|
|
new_resolution = patch.resolution.strip() if patch.resolution is not None else None
|
|
|
|
# Severity update
|
|
if target_severity:
|
|
if target_severity not in VALID_SEVERITIES:
|
|
raise HTTPException(
|
|
status_code=422,
|
|
detail=f"Invalid severity '{patch.severity}'. Must be: {sorted(VALID_SEVERITIES)}",
|
|
)
|
|
if target_severity != incident.severity:
|
|
old_sev = incident.severity
|
|
incident.severity = target_severity
|
|
incident.updated_at = now
|
|
ops_audit(
|
|
db=db,
|
|
action="incident.severity_changed",
|
|
actor_identity=operator.identity,
|
|
actor_role=operator.role,
|
|
target_resource="ops.incident",
|
|
case_reference=incident.case_reference,
|
|
result="success",
|
|
metadata={"from": old_sev, "to": target_severity},
|
|
)
|
|
|
|
# Resolution update
|
|
if new_resolution is not None and new_resolution != incident.resolution:
|
|
incident.resolution = new_resolution
|
|
incident.updated_at = now
|
|
ops_audit(
|
|
db=db,
|
|
action="incident.resolved",
|
|
actor_identity=operator.identity,
|
|
actor_role=operator.role,
|
|
target_resource="ops.incident",
|
|
case_reference=incident.case_reference,
|
|
result="success",
|
|
)
|
|
|
|
# Status transition
|
|
if target_status and target_status != incident.status:
|
|
if target_status not in VALID_STATUSES:
|
|
raise HTTPException(
|
|
status_code=422,
|
|
detail=f"Invalid status '{patch.status}'. Must be: {sorted(VALID_STATUSES)}",
|
|
)
|
|
|
|
# Owner only transitions
|
|
if target_status == "archived" and operator.role != "owner":
|
|
raise HTTPException(status_code=403, detail="Only Owners may archive incidents")
|
|
|
|
if incident.status == "closed" and target_status == "investigating" and operator.role != "owner":
|
|
raise HTTPException(status_code=403, detail="Only Owners may reopen closed incidents")
|
|
|
|
# Allowed transitions
|
|
allowed = ALLOWED_TRANSITIONS.get(incident.status, set())
|
|
# Add archived as valid destination for non-archived if owner
|
|
if operator.role == "owner" and incident.status != "archived":
|
|
allowed = allowed | {"archived"}
|
|
|
|
if target_status not in allowed:
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail=f"Invalid status transition from '{incident.status}' to '{target_status}'",
|
|
)
|
|
|
|
# Resolution required for resolved or closed
|
|
effective_res = new_resolution if new_resolution is not None else incident.resolution
|
|
if target_status in {"resolved", "closed"} and (not effective_res or not effective_res.strip()):
|
|
raise HTTPException(
|
|
status_code=422,
|
|
detail="Resolution text is required before resolving or closing an incident",
|
|
)
|
|
|
|
old_status = incident.status
|
|
incident.status = target_status
|
|
incident.updated_at = now
|
|
|
|
if target_status == "closed":
|
|
incident.closed_at = now
|
|
incident.closed_by_operator_id = operator.id
|
|
audit_action = "incident.closed"
|
|
elif target_status == "resolved":
|
|
audit_action = "incident.resolved"
|
|
elif old_status == "closed" and target_status == "investigating":
|
|
incident.closed_at = None
|
|
incident.closed_by_operator_id = None
|
|
audit_action = "incident.reopened"
|
|
elif target_status == "archived":
|
|
audit_action = "incident.archived"
|
|
else:
|
|
audit_action = "incident.status_changed"
|
|
|
|
ops_audit(
|
|
db=db,
|
|
action=audit_action,
|
|
actor_identity=operator.identity,
|
|
actor_role=operator.role,
|
|
target_resource="ops.incident",
|
|
case_reference=incident.case_reference,
|
|
result="success",
|
|
metadata={"from": old_status, "to": target_status},
|
|
)
|
|
|
|
db.commit()
|
|
db.refresh(incident)
|
|
|
|
creator = db.get(Operator, incident.created_by_operator_id)
|
|
closer = db.get(Operator, incident.closed_by_operator_id) if incident.closed_by_operator_id else None
|
|
|
|
return {
|
|
"case_reference": incident.case_reference,
|
|
"title": incident.title,
|
|
"summary": incident.summary,
|
|
"severity": incident.severity,
|
|
"status": incident.status,
|
|
"created_at": incident.created_at.isoformat(),
|
|
"updated_at": incident.updated_at.isoformat(),
|
|
"closed_at": incident.closed_at.isoformat() if incident.closed_at else None,
|
|
"resolution": incident.resolution,
|
|
"created_by_identity": creator.identity if creator else "operator",
|
|
"closed_by_identity": closer.identity if closer else None,
|
|
}
|
|
|
|
|
|
@app.get("/api/v1/ops/incidents/{case_reference}/notes")
|
|
def ops_list_incident_notes(
|
|
case_reference: str,
|
|
auth: tuple[Operator, OpsSession] = Depends(require_ops_roles("operator", "owner")),
|
|
db: DbSession = Depends(get_db),
|
|
):
|
|
stmt = select(OpsIncident).where(OpsIncident.case_reference == case_reference.strip())
|
|
incident = db.scalar(stmt)
|
|
if incident is None:
|
|
raise HTTPException(status_code=404, detail="Incident not found")
|
|
|
|
notes_stmt = (
|
|
select(OpsIncidentNote, Operator.identity)
|
|
.join(Operator, OpsIncidentNote.created_by_operator_id == Operator.id)
|
|
.where(OpsIncidentNote.incident_id == incident.id)
|
|
.order_by(OpsIncidentNote.created_at.desc())
|
|
)
|
|
notes_rows = db.execute(notes_stmt).all()
|
|
|
|
return {
|
|
"notes": [
|
|
{
|
|
"created_at": note.created_at.isoformat(),
|
|
"created_by_identity": op_identity,
|
|
"body": note.body,
|
|
}
|
|
for note, op_identity in notes_rows
|
|
]
|
|
}
|
|
|
|
|
|
@app.post("/api/v1/ops/incidents/{case_reference}/notes", status_code=201)
|
|
def ops_create_incident_note(
|
|
case_reference: str,
|
|
request: OpsIncidentNoteCreate,
|
|
auth: tuple[Operator, OpsSession] = Depends(require_ops_roles("operator", "owner")),
|
|
db: DbSession = Depends(get_db),
|
|
):
|
|
operator, _ = auth
|
|
stmt = select(OpsIncident).where(OpsIncident.case_reference == case_reference.strip())
|
|
incident = db.scalar(stmt)
|
|
if incident is None:
|
|
raise HTTPException(status_code=404, detail="Incident not found")
|
|
|
|
if incident.status == "archived":
|
|
raise HTTPException(status_code=409, detail="Archived incidents cannot be modified")
|
|
|
|
body_text = request.body.strip()
|
|
if not body_text:
|
|
raise HTTPException(status_code=422, detail="Note body cannot be empty")
|
|
|
|
now = datetime.now(timezone.utc)
|
|
note = OpsIncidentNote(
|
|
incident_id=incident.id,
|
|
created_at=now,
|
|
created_by_operator_id=operator.id,
|
|
body=body_text,
|
|
)
|
|
db.add(note)
|
|
incident.updated_at = now
|
|
db.flush()
|
|
|
|
# Transactional Audit
|
|
ops_audit(
|
|
db=db,
|
|
action="incident.note_added",
|
|
actor_identity=operator.identity,
|
|
actor_role=operator.role,
|
|
target_resource="ops.incident",
|
|
case_reference=incident.case_reference,
|
|
result="success",
|
|
)
|
|
|
|
db.commit()
|
|
db.refresh(note)
|
|
|
|
return {
|
|
"created_at": note.created_at.isoformat(),
|
|
"created_by_identity": operator.identity,
|
|
"body": note.body,
|
|
}
|
|
|
|
|
|
# ============================================================
|
|
# Operations (ops) - Section 2: User Management
|
|
# ============================================================
|
|
|
|
|
|
@app.get("/api/v1/ops/users")
|
|
def ops_list_users(
|
|
q: str | None = None,
|
|
cursor: str | None = None,
|
|
limit: int = Query(default=25, ge=1, le=100),
|
|
status_filter: str | None = None,
|
|
verified_filter: str | None = None,
|
|
auth: tuple[Operator, OpsSession] = Depends(require_ops_roles("operator", "owner")),
|
|
db: DbSession = Depends(get_db),
|
|
):
|
|
base_stmt = select(User)
|
|
|
|
if q and q.strip():
|
|
clean_q = q.strip()
|
|
try:
|
|
uuid_q = uuid.UUID(clean_q)
|
|
base_stmt = base_stmt.where(or_(User.id == uuid_q, User.email.ilike(f"%{clean_q}%")))
|
|
except ValueError:
|
|
base_stmt = base_stmt.where(User.email.ilike(f"%{clean_q}%"))
|
|
|
|
if status_filter:
|
|
s_filter = status_filter.strip().lower()
|
|
if s_filter == "active":
|
|
base_stmt = base_stmt.where(User.is_active.is_(True))
|
|
elif s_filter == "suspended":
|
|
base_stmt = base_stmt.where(User.is_active.is_(False))
|
|
|
|
if verified_filter:
|
|
v_filter = verified_filter.strip().lower()
|
|
if v_filter == "verified":
|
|
base_stmt = base_stmt.where(User.email_verified.is_(True))
|
|
elif v_filter == "unverified":
|
|
base_stmt = base_stmt.where(User.email_verified.is_(False))
|
|
|
|
total = db.scalar(select(func.count()).select_from(base_stmt.subquery())) or 0
|
|
|
|
stmt = base_stmt.order_by(User.created_at.desc(), User.id.desc())
|
|
|
|
if cursor:
|
|
try:
|
|
cursor_dt = datetime.fromisoformat(cursor)
|
|
stmt = stmt.where(User.created_at < cursor_dt)
|
|
except ValueError:
|
|
pass
|
|
|
|
users = db.scalars(stmt.limit(limit + 1)).all()
|
|
has_more = len(users) > limit
|
|
returned_users = users[:limit]
|
|
next_cursor = returned_users[-1].created_at.isoformat() if has_more and returned_users else None
|
|
|
|
user_ids = [u.id for u in returned_users]
|
|
now = datetime.now(timezone.utc)
|
|
|
|
active_sessions_count_map: dict[uuid.UUID, int] = {}
|
|
total_rides_map: dict[uuid.UUID, int] = {}
|
|
last_sync_map: dict[uuid.UUID, datetime] = {}
|
|
latest_version_map: dict[uuid.UUID, str | None] = {}
|
|
|
|
if user_ids:
|
|
session_counts = db.execute(
|
|
select(Session.user_id, func.count(Session.id))
|
|
.where(
|
|
Session.user_id.in_(user_ids),
|
|
Session.revoked_at.is_(None),
|
|
Session.expires_at > now,
|
|
)
|
|
.group_by(Session.user_id)
|
|
).all()
|
|
for uid, count in session_counts:
|
|
active_sessions_count_map[uid] = count
|
|
|
|
ride_counts = db.execute(
|
|
select(Ride.user_id, func.count(Ride.id))
|
|
.where(Ride.user_id.in_(user_ids))
|
|
.group_by(Ride.user_id)
|
|
).all()
|
|
for uid, count in ride_counts:
|
|
total_rides_map[uid] = count
|
|
|
|
last_rides = db.execute(
|
|
select(Ride.user_id, func.max(Ride.created_at))
|
|
.where(Ride.user_id.in_(user_ids))
|
|
.group_by(Ride.user_id)
|
|
).all()
|
|
for uid, dt in last_rides:
|
|
if dt:
|
|
last_sync_map[uid] = dt
|
|
|
|
last_telemetry = db.execute(
|
|
select(AuditEvent.actor_user_id, func.max(AuditEvent.created_at))
|
|
.where(
|
|
AuditEvent.actor_user_id.in_(user_ids),
|
|
AuditEvent.event_type == "telemetry.ingested",
|
|
)
|
|
.group_by(AuditEvent.actor_user_id)
|
|
).all()
|
|
for uid, dt in last_telemetry:
|
|
if dt and (uid not in last_sync_map or dt > last_sync_map[uid]):
|
|
last_sync_map[uid] = dt
|
|
|
|
for uid in user_ids:
|
|
ev = db.scalar(
|
|
select(AuditEvent)
|
|
.where(
|
|
or_(AuditEvent.actor_user_id == uid, AuditEvent.target_user_id == uid),
|
|
)
|
|
.order_by(AuditEvent.created_at.desc())
|
|
.limit(1)
|
|
)
|
|
if ev and ev.metadata_json and isinstance(ev.metadata_json, dict) and ev.metadata_json.get("app_version"):
|
|
latest_version_map[uid] = str(ev.metadata_json["app_version"])
|
|
elif ev and ev.user_agent and "MotorSignal/" in ev.user_agent:
|
|
latest_version_map[uid] = ev.user_agent.split("MotorSignal/")[1].split()[0]
|
|
else:
|
|
sess = db.scalar(
|
|
select(Session)
|
|
.where(Session.user_id == uid)
|
|
.order_by(Session.created_at.desc())
|
|
.limit(1)
|
|
)
|
|
if sess and sess.device_name:
|
|
latest_version_map[uid] = sess.device_name
|
|
else:
|
|
latest_version_map[uid] = None
|
|
|
|
items = [
|
|
{
|
|
"id": str(u.id),
|
|
"email": u.email,
|
|
"email_verified": u.email_verified,
|
|
"is_active": u.is_active,
|
|
"role": u.role,
|
|
"created_at": u.created_at.isoformat(),
|
|
"latest_app_version": latest_version_map.get(u.id),
|
|
"last_sync_at": last_sync_map.get(u.id).isoformat() if last_sync_map.get(u.id) else None,
|
|
"active_sessions_count": active_sessions_count_map.get(u.id, 0),
|
|
"total_rides": total_rides_map.get(u.id, 0),
|
|
}
|
|
for u in returned_users
|
|
]
|
|
|
|
return {
|
|
"items": items,
|
|
"next_cursor": next_cursor,
|
|
"total": total,
|
|
}
|
|
|
|
|
|
@app.get("/api/v1/ops/users/{user_id}")
|
|
def ops_get_user(
|
|
user_id: str,
|
|
auth: tuple[Operator, OpsSession] = Depends(require_ops_roles("operator", "owner")),
|
|
db: DbSession = Depends(get_db),
|
|
):
|
|
try:
|
|
u_uuid = uuid.UUID(user_id)
|
|
except ValueError:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
|
|
user = db.get(User, u_uuid)
|
|
if user is None:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
|
|
now = datetime.now(timezone.utc)
|
|
|
|
sessions = db.scalars(
|
|
select(Session)
|
|
.where(Session.user_id == user.id)
|
|
.order_by(Session.created_at.desc())
|
|
).all()
|
|
sessions_payload = [
|
|
{
|
|
"id": str(s.id),
|
|
"device_name": s.device_name,
|
|
"created_at": s.created_at.isoformat(),
|
|
"last_used_at": s.last_used_at.isoformat() if s.last_used_at else None,
|
|
"is_revoked": s.revoked_at is not None or s.expires_at <= now,
|
|
}
|
|
for s in sessions
|
|
]
|
|
|
|
subscription = db.scalar(
|
|
select(Subscription).where(Subscription.user_id == user.id)
|
|
)
|
|
sub_payload = None
|
|
if subscription:
|
|
sub_payload = {
|
|
"plan": subscription.plan,
|
|
"status": subscription.status,
|
|
"expires_at": subscription.current_period_end.isoformat() if subscription.current_period_end else None,
|
|
}
|
|
|
|
total_rides = db.scalar(
|
|
select(func.count(Ride.id)).where(Ride.user_id == user.id)
|
|
) or 0
|
|
last_ride_dt = db.scalar(
|
|
select(func.max(Ride.created_at)).where(Ride.user_id == user.id)
|
|
)
|
|
ride_summary = {
|
|
"total_rides": total_rides,
|
|
"last_ride_at": last_ride_dt.isoformat() if last_ride_dt else None,
|
|
}
|
|
|
|
audit_events = db.scalars(
|
|
select(AuditEvent)
|
|
.where(or_(AuditEvent.actor_user_id == user.id, AuditEvent.target_user_id == user.id))
|
|
.order_by(AuditEvent.created_at.desc())
|
|
.limit(10)
|
|
).all()
|
|
|
|
ops_audit_events = db.scalars(
|
|
select(OpsAuditEvent)
|
|
.where(OpsAuditEvent.target_resource == f"user:{user.id}")
|
|
.order_by(OpsAuditEvent.timestamp.desc())
|
|
.limit(10)
|
|
).all()
|
|
|
|
combined_audit = []
|
|
for ae in audit_events:
|
|
combined_audit.append({
|
|
"action": ae.event_type,
|
|
"timestamp": ae.created_at.isoformat(),
|
|
"ip": ae.ip_address,
|
|
"_raw_time": ae.created_at,
|
|
})
|
|
for oae in ops_audit_events:
|
|
combined_audit.append({
|
|
"action": oae.action,
|
|
"timestamp": oae.timestamp.isoformat(),
|
|
"ip": None,
|
|
"_raw_time": oae.timestamp,
|
|
})
|
|
|
|
combined_audit.sort(key=lambda x: x["_raw_time"], reverse=True)
|
|
recent_audit = [
|
|
{"action": a["action"], "timestamp": a["timestamp"], "ip": a["ip"]}
|
|
for a in combined_audit[:10]
|
|
]
|
|
|
|
return {
|
|
"id": str(user.id),
|
|
"email": user.email,
|
|
"email_verified": user.email_verified,
|
|
"is_active": user.is_active,
|
|
"role": user.role,
|
|
"created_at": user.created_at.isoformat(),
|
|
"updated_at": user.updated_at.isoformat(),
|
|
"sessions": sessions_payload,
|
|
"subscription": sub_payload,
|
|
"ride_summary": ride_summary,
|
|
"recent_audit": recent_audit,
|
|
}
|
|
|
|
|
|
@app.post("/api/v1/ops/users/{user_id}/status")
|
|
def ops_update_user_status(
|
|
user_id: str,
|
|
request: OpsUserStatusUpdate,
|
|
auth: tuple[Operator, OpsSession] = Depends(require_ops_roles("operator", "owner")),
|
|
db: DbSession = Depends(get_db),
|
|
):
|
|
operator, _ = auth
|
|
try:
|
|
u_uuid = uuid.UUID(user_id)
|
|
except ValueError:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
|
|
user = db.get(User, u_uuid)
|
|
if user is None:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
|
|
now = datetime.now(timezone.utc)
|
|
user.is_active = request.is_active
|
|
user.updated_at = now
|
|
|
|
if not request.is_active:
|
|
db.query(Session).filter(
|
|
Session.user_id == user.id,
|
|
Session.revoked_at.is_(None),
|
|
).update({Session.revoked_at: now}, synchronize_session=False)
|
|
|
|
ops_audit(
|
|
db=db,
|
|
action="user.suspend" if not request.is_active else "user.reinstate",
|
|
actor_identity=operator.identity,
|
|
actor_role=operator.role,
|
|
target_resource=f"user:{user.id}",
|
|
reason_code=request.reason,
|
|
result="success",
|
|
)
|
|
db.commit()
|
|
db.refresh(user)
|
|
|
|
return {
|
|
"id": str(user.id),
|
|
"is_active": user.is_active,
|
|
"status": "active" if user.is_active else "suspended",
|
|
}
|
|
|
|
|
|
@app.post("/api/v1/ops/users/{user_id}/revoke-sessions")
|
|
def ops_revoke_user_sessions(
|
|
user_id: str,
|
|
request: OpsUserActionRequest,
|
|
auth: tuple[Operator, OpsSession] = Depends(require_ops_roles("operator", "owner")),
|
|
db: DbSession = Depends(get_db),
|
|
):
|
|
operator, _ = auth
|
|
try:
|
|
u_uuid = uuid.UUID(user_id)
|
|
except ValueError:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
|
|
user = db.get(User, u_uuid)
|
|
if user is None:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
|
|
now = datetime.now(timezone.utc)
|
|
db.query(Session).filter(
|
|
Session.user_id == user.id,
|
|
Session.revoked_at.is_(None),
|
|
).update({Session.revoked_at: now}, synchronize_session=False)
|
|
|
|
ops_audit(
|
|
db=db,
|
|
action="user.sessions_revoked",
|
|
actor_identity=operator.identity,
|
|
actor_role=operator.role,
|
|
target_resource=f"user:{user.id}",
|
|
reason_code=request.reason,
|
|
result="success",
|
|
)
|
|
db.commit()
|
|
|
|
return {
|
|
"id": str(user.id),
|
|
"status": "revoked",
|
|
}
|
|
|
|
|
|
@app.post("/api/v1/ops/users/{user_id}/reset-password")
|
|
def ops_reset_user_password(
|
|
user_id: str,
|
|
request: OpsUserActionRequest,
|
|
auth: tuple[Operator, OpsSession] = Depends(require_ops_roles("operator", "owner")),
|
|
db: DbSession = Depends(get_db),
|
|
):
|
|
operator, _ = auth
|
|
try:
|
|
u_uuid = uuid.UUID(user_id)
|
|
except ValueError:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
|
|
user = db.get(User, u_uuid)
|
|
if user is None:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
|
|
raw, token_hash = create_one_time_token()
|
|
now = datetime.now(timezone.utc)
|
|
expires_at = now + timedelta(hours=24)
|
|
|
|
db.add(
|
|
PasswordResetToken(
|
|
user_id=user.id,
|
|
token_hash=token_hash,
|
|
expires_at=expires_at,
|
|
)
|
|
)
|
|
|
|
ops_audit(
|
|
db=db,
|
|
action="user.password_reset_initiated",
|
|
actor_identity=operator.identity,
|
|
actor_role=operator.role,
|
|
target_resource=f"user:{user.id}",
|
|
reason_code=request.reason,
|
|
result="success",
|
|
)
|
|
db.commit()
|
|
|
|
web_base = os.getenv("WEB_BASE_URL", "https://motorsignal.app")
|
|
send_email(
|
|
user.email,
|
|
"Reset your MotorSignal password",
|
|
f"Reset your password: {web_base}/forgot-password?token={raw}",
|
|
)
|
|
|
|
# Never expose passwords or raw tokens
|
|
return {
|
|
"status": "dispatched",
|
|
"expires_at": expires_at.isoformat(),
|
|
}
|
|
|
|
|
|
# ============================================================
|
|
# Operations (ops) - Section 3: Support Center & Diagnostics
|
|
# ============================================================
|
|
|
|
_support_case_seq = 100
|
|
|
|
|
|
def generate_support_case_reference() -> str:
|
|
global _support_case_seq
|
|
year = datetime.now(timezone.utc).year
|
|
_support_case_seq += 1
|
|
return f"SUP-{year}-{_support_case_seq:06d}"
|
|
|
|
|
|
SUPPORT_TICKETS: dict[str, dict] = {
|
|
"e1a11111-2222-3333-4444-555555555551": {
|
|
"id": "e1a11111-2222-3333-4444-555555555551",
|
|
"case_reference": "SUP-2026-000101",
|
|
"ticket_type": "bug_report",
|
|
"reporter_email": "rider.alex@example.com",
|
|
"subject": "Lean angle reading freeze during high-bank turn",
|
|
"description": "During a canyon ride yesterday, the lean angle widget stuck at 28 degrees right and stopped updating until I restarted the app. Telemetry was still logging speed and GPS coordinates.",
|
|
"severity": "high",
|
|
"status": "open",
|
|
"created_at": "2026-09-14T18:22:15Z",
|
|
"updated_at": "2026-09-14T19:00:00Z",
|
|
"resolved_at": None,
|
|
"notes": [
|
|
{
|
|
"id": "n1111111-0000-0000-0000-000000000001",
|
|
"author_identity": "ops-lead@motorsignal.com",
|
|
"body": "Reproduced in lab with high vibration profile. Escalating to firmware/sensor fusion team.",
|
|
"created_at": "2026-09-14T19:00:00Z",
|
|
}
|
|
],
|
|
"diagnostic_bundle": {
|
|
"app_version": "2.4.1 (build 184)",
|
|
"os_version": "Android 14 (API 34)",
|
|
"device_model": "Google Pixel 8 Pro",
|
|
"stack_trace": "SensorFusionException: IMU sample buffer overflow at SensorFusionService.processGyro (sensor_fusion_service.dart:182)\n at TelemetryPipeline.ingest (telemetry_service.dart:94)",
|
|
"sensor_status": "IMU: DEGRADED (sample rate jitter > 45ms), GPS: OK (3D Fix, 11 satellites), BLE: CONNECTED",
|
|
"logs_snippet": "[14:22:01.102] [SensorFusion] Buffer warning: 82 pending frames\n[14:22:01.152] [SensorFusion] Gyro acquisition hitch: 52ms > 50ms ceiling\n[14:22:01.204] [SensorFusion] Fallback complementary filter engaged",
|
|
"submitted_at": "2026-09-14T18:22:15Z",
|
|
},
|
|
},
|
|
"e1a11111-2222-3333-4444-555555555552": {
|
|
"id": "e1a11111-2222-3333-4444-555555555552",
|
|
"case_reference": "SUP-2026-000102",
|
|
"ticket_type": "contact_request",
|
|
"reporter_email": "marcus.v@motoclub-pnw.org",
|
|
"subject": "Club Fleet Telemetry Export & Multi-rider Group Inquiries",
|
|
"description": "We run a 40-rider club in Oregon and want to know if MotorSignal Pro supports group ride telemetry aggregation or team dashboard exports.",
|
|
"severity": "medium",
|
|
"status": "in_progress",
|
|
"created_at": "2026-09-13T10:15:00Z",
|
|
"updated_at": "2026-09-13T11:20:00Z",
|
|
"resolved_at": None,
|
|
"notes": [
|
|
{
|
|
"id": "n1111111-0000-0000-0000-000000000002",
|
|
"author_identity": "operator@motorsignal.com",
|
|
"body": "Replied via email explaining Pro group ride features and export formats.",
|
|
"created_at": "2026-09-13T11:20:00Z",
|
|
}
|
|
],
|
|
"diagnostic_bundle": None,
|
|
},
|
|
"e1a11111-2222-3333-4444-555555555553": {
|
|
"id": "e1a11111-2222-3333-4444-555555555553",
|
|
"case_reference": "SUP-2026-000103",
|
|
"ticket_type": "telemetry_issue",
|
|
"reporter_email": "trackday_sam@fastlap.net",
|
|
"subject": "0-60 timer false trigger while idling on paddock stand",
|
|
"description": "Kickstand was down on rear paddock stand while warming up engine. The 0-60 timer armed and triggered a 1.2s run due to vibration spike.",
|
|
"severity": "critical",
|
|
"status": "open",
|
|
"created_at": "2026-09-15T07:15:00Z",
|
|
"updated_at": "2026-09-15T07:15:00Z",
|
|
"resolved_at": None,
|
|
"notes": [],
|
|
"diagnostic_bundle": {
|
|
"app_version": "2.4.2-beta.3",
|
|
"os_version": "iOS 18.0.1",
|
|
"device_model": "iPhone 16 Pro",
|
|
"stack_trace": None,
|
|
"sensor_status": "IMU: OK, GPS: STATIONARY (Speed 0.2 mph), KICKSTAND_ISOLATION: INACTIVE",
|
|
"logs_snippet": "[09:12:00.040] [RideStateMachine] Speed 0.18 mph < 3.0 mph gate\n[09:12:00.090] [AccelTrigger] Peak longitudinal G: 1.42g\n[09:12:00.100] [PerfTimer] Run triggered in paddock",
|
|
"submitted_at": "2026-09-15T07:15:00Z",
|
|
},
|
|
},
|
|
"e1a11111-2222-3333-4444-555555555554": {
|
|
"id": "e1a11111-2222-3333-4444-555555555554",
|
|
"case_reference": "SUP-2026-000104",
|
|
"ticket_type": "general",
|
|
"reporter_email": "elena.r@example.com",
|
|
"subject": "Account email change verification link expired",
|
|
"description": "Requested email change link but clicked it 30 hours later and it said token expired.",
|
|
"severity": "low",
|
|
"status": "resolved",
|
|
"created_at": "2026-09-14T14:30:00Z",
|
|
"updated_at": "2026-09-14T16:00:00Z",
|
|
"resolved_at": "2026-09-14T16:00:00Z",
|
|
"notes": [
|
|
{
|
|
"id": "n1111111-0000-0000-0000-000000000003",
|
|
"author_identity": "operator@motorsignal.com",
|
|
"body": "Re-issued email change verification token from user management.",
|
|
"created_at": "2026-09-14T15:58:00Z",
|
|
}
|
|
],
|
|
"diagnostic_bundle": None,
|
|
},
|
|
}
|
|
|
|
|
|
def find_support_ticket(ticket_id: str) -> dict | None:
|
|
clean_id = ticket_id.strip()
|
|
if clean_id in SUPPORT_TICKETS:
|
|
return SUPPORT_TICKETS[clean_id]
|
|
for t in SUPPORT_TICKETS.values():
|
|
if t["case_reference"] == clean_id or t["id"] == clean_id:
|
|
return t
|
|
return None
|
|
|
|
|
|
@app.get("/api/v1/ops/support/tickets")
|
|
def ops_list_support_tickets(
|
|
type_filter: str | None = None,
|
|
status_filter: str | None = None,
|
|
q: str | None = None,
|
|
auth: tuple[Operator, OpsSession] = Depends(require_ops_roles("operator", "owner")),
|
|
db: DbSession = Depends(get_db),
|
|
):
|
|
tickets = list(SUPPORT_TICKETS.values())
|
|
if type_filter and type_filter.strip() and type_filter.strip().lower() != "all":
|
|
tf = type_filter.strip().lower()
|
|
tickets = [t for t in tickets if t["ticket_type"] == tf]
|
|
|
|
if status_filter and status_filter.strip() and status_filter.strip().lower() != "all":
|
|
sf = status_filter.strip().lower()
|
|
tickets = [t for t in tickets if t["status"] == sf]
|
|
|
|
if q and q.strip():
|
|
query = q.strip().lower()
|
|
tickets = [
|
|
t
|
|
for t in tickets
|
|
if query in t["case_reference"].lower()
|
|
or query in t["reporter_email"].lower()
|
|
or query in t["subject"].lower()
|
|
or query in t["description"].lower()
|
|
]
|
|
|
|
tickets.sort(key=lambda t: t["created_at"], reverse=True)
|
|
items = [
|
|
{
|
|
"id": t["id"],
|
|
"case_reference": t["case_reference"],
|
|
"ticket_type": t["ticket_type"],
|
|
"reporter_email": t["reporter_email"],
|
|
"subject": t["subject"],
|
|
"severity": t["severity"],
|
|
"status": t["status"],
|
|
"created_at": t["created_at"],
|
|
"updated_at": t["updated_at"],
|
|
"resolved_at": t.get("resolved_at"),
|
|
"has_diagnostics": t.get("diagnostic_bundle") is not None,
|
|
"notes_count": len(t.get("notes", [])),
|
|
}
|
|
for t in tickets
|
|
]
|
|
return {"items": items, "total": len(items)}
|
|
|
|
|
|
@app.get("/api/v1/ops/support/tickets/{ticket_id}")
|
|
def ops_get_support_ticket(
|
|
ticket_id: str,
|
|
auth: tuple[Operator, OpsSession] = Depends(require_ops_roles("operator", "owner")),
|
|
db: DbSession = Depends(get_db),
|
|
):
|
|
ticket = find_support_ticket(ticket_id)
|
|
if ticket is None:
|
|
raise HTTPException(status_code=404, detail="Support ticket not found")
|
|
|
|
return {
|
|
"id": ticket["id"],
|
|
"case_reference": ticket["case_reference"],
|
|
"ticket_type": ticket["ticket_type"],
|
|
"reporter_email": ticket["reporter_email"],
|
|
"subject": ticket["subject"],
|
|
"description": ticket["description"],
|
|
"severity": ticket["severity"],
|
|
"status": ticket["status"],
|
|
"created_at": ticket["created_at"],
|
|
"updated_at": ticket["updated_at"],
|
|
"resolved_at": ticket.get("resolved_at"),
|
|
"has_diagnostics": ticket.get("diagnostic_bundle") is not None,
|
|
"notes": ticket.get("notes", []),
|
|
}
|
|
|
|
|
|
|
|
@app.post("/api/v1/ops/actions", response_model=OpsActionRead)
|
|
def ops_create_action(
|
|
req: OpsActionCreate,
|
|
db: DbSession = Depends(get_db),
|
|
auth: tuple[Operator, OpsSession] = Depends(require_ops_roles("owner")),
|
|
):
|
|
operator, session = auth
|
|
|
|
if req.action_type in ("reboot_server", "restart_api"):
|
|
expected = "REBOOT MOTORSIGNAL SERVER" if req.action_type == "reboot_server" else "RESTART API"
|
|
if req.confirmation_text != expected:
|
|
raise HTTPException(status_code=422, detail="Invalid confirmation text.")
|
|
|
|
# Mutual exclusion check: Only one active mutation at a time
|
|
active = ops_actions_service.get_active_action(db)
|
|
if active:
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail=f"Another operations action ({active.action_type}) is currently active.",
|
|
)
|
|
|
|
# Idempotency lookup
|
|
if req.idempotency_key:
|
|
existing = db.scalars(
|
|
select(OpsAction).where(
|
|
OpsAction.requested_by_id == operator.id,
|
|
OpsAction.action_type == req.action_type,
|
|
OpsAction.idempotency_key == req.idempotency_key,
|
|
)
|
|
).first()
|
|
if existing:
|
|
return existing
|
|
|
|
# Check helper availability unless mocked in tests
|
|
if not ops_actions_service.is_helper_configured() and not os.environ.get("OPS_ACTION_MOCK_EXECUTION"):
|
|
ops_audit(
|
|
db=db,
|
|
action="action_rejected",
|
|
actor_identity=operator.identity,
|
|
actor_role=operator.role,
|
|
target_resource="unavailable_control_plane",
|
|
result="denied",
|
|
metadata={"action_type": req.action_type, "status": "rejected_unavailable"},
|
|
)
|
|
db.commit()
|
|
raise HTTPException(
|
|
status_code=503,
|
|
detail="Action control plane is currently unconfigured. Missing ops-action-helper.sh.",
|
|
)
|
|
|
|
# Create new action record
|
|
action = OpsAction(
|
|
id=uuid.uuid4(),
|
|
action_type=req.action_type,
|
|
requested_by_id=operator.id,
|
|
request_timestamp=datetime.now(timezone.utc),
|
|
state="queued",
|
|
idempotency_key=req.idempotency_key,
|
|
)
|
|
db.add(action)
|
|
db.commit()
|
|
|
|
ops_audit(
|
|
db=db,
|
|
action="ops.action_requested",
|
|
actor_identity=operator.identity,
|
|
actor_role=operator.role,
|
|
target_resource=f"action:{action.id}",
|
|
metadata={"action_type": action.action_type},
|
|
)
|
|
db.commit()
|
|
|
|
# Execute action via helper service
|
|
action = ops_actions_service.execute_action_sync(action, db)
|
|
|
|
ops_audit(
|
|
db=db,
|
|
action=f"ops.action_{action.state}",
|
|
actor_identity=operator.identity,
|
|
actor_role=operator.role,
|
|
target_resource=f"action:{action.id}",
|
|
result="success" if action.state == "succeeded" else "failed",
|
|
metadata={
|
|
"action_type": action.action_type,
|
|
"failure_code": action.failure_code,
|
|
"deployed_revision": action.deployed_revision,
|
|
},
|
|
)
|
|
db.commit()
|
|
return action
|
|
|
|
@app.get("/api/v1/ops/actions", response_model=list[OpsActionRead])
|
|
def ops_list_actions(
|
|
db: DbSession = Depends(get_db),
|
|
auth: tuple[Operator, OpsSession] = Depends(require_ops_roles("operator", "owner")),
|
|
limit: int = Query(default=50, ge=1, le=100),
|
|
offset: int = Query(default=0, ge=0),
|
|
):
|
|
return db.scalars(select(OpsAction).order_by(OpsAction.request_timestamp.desc(), OpsAction.id.desc()).offset(offset).limit(limit)).all()
|
|
|
|
@app.get("/api/v1/ops/actions/capabilities")
|
|
def ops_get_action_capabilities(
|
|
auth: tuple[Operator, OpsSession] = Depends(require_ops_roles("operator", "owner")),
|
|
db: DbSession = Depends(get_db),
|
|
):
|
|
active = ops_actions_service.get_active_action(db)
|
|
return {
|
|
"supported_actions": list(ops_actions_service.ALLOWED_ACTION_TYPES),
|
|
"helper_configured": ops_actions_service.is_helper_configured() or bool(os.environ.get("OPS_ACTION_MOCK_EXECUTION")),
|
|
"active_action_id": str(active.id) if active else None,
|
|
"active_action_type": active.action_type if active else None,
|
|
}
|
|
|
|
@app.get("/api/v1/ops/actions/{action_id}", response_model=OpsActionRead)
|
|
def ops_get_action(
|
|
action_id: uuid.UUID,
|
|
db: DbSession = Depends(get_db),
|
|
auth: tuple[Operator, OpsSession] = Depends(require_ops_roles("operator", "owner")),
|
|
):
|
|
action = db.get(OpsAction, action_id)
|
|
if not action:
|
|
raise HTTPException(status_code=404, detail="Action not found")
|
|
return action
|
|
|
|
@app.post("/api/v1/ops/support/tickets", status_code=201)
|
|
def ops_create_support_ticket(
|
|
request: OpsSupportTicketCreate,
|
|
auth: tuple[Operator, OpsSession] = Depends(require_ops_roles("operator", "owner")),
|
|
db: DbSession = Depends(get_db),
|
|
):
|
|
operator, _ = auth
|
|
ticket_type = request.ticket_type.strip().lower()
|
|
if ticket_type not in ("bug_report", "contact_request", "telemetry_issue", "general"):
|
|
raise HTTPException(status_code=422, detail="Invalid ticket type")
|
|
|
|
severity = request.severity.strip().lower()
|
|
if severity not in ("low", "medium", "high", "critical"):
|
|
raise HTTPException(status_code=422, detail="Invalid severity level")
|
|
|
|
ticket_id = str(uuid.uuid4())
|
|
case_ref = generate_support_case_reference()
|
|
now_iso = datetime.now(timezone.utc).isoformat()
|
|
|
|
bundle = None
|
|
if request.diagnostic_bundle:
|
|
if isinstance(request.diagnostic_bundle, BaseModel):
|
|
bundle = request.diagnostic_bundle.model_dump()
|
|
elif isinstance(request.diagnostic_bundle, dict):
|
|
bundle = request.diagnostic_bundle
|
|
|
|
new_ticket = {
|
|
"id": ticket_id,
|
|
"case_reference": case_ref,
|
|
"ticket_type": ticket_type,
|
|
"reporter_email": request.reporter_email.strip().lower(),
|
|
"subject": request.subject.strip(),
|
|
"description": request.description.strip(),
|
|
"severity": severity,
|
|
"status": "open",
|
|
"created_at": now_iso,
|
|
"updated_at": now_iso,
|
|
"resolved_at": None,
|
|
"notes": [],
|
|
"diagnostic_bundle": bundle,
|
|
}
|
|
SUPPORT_TICKETS[ticket_id] = new_ticket
|
|
|
|
ops_audit(
|
|
db=db,
|
|
action="support.ticket_created",
|
|
actor_identity=operator.identity,
|
|
actor_role=operator.role,
|
|
target_resource=f"ticket:{ticket_id}",
|
|
case_reference=case_ref,
|
|
result="success",
|
|
)
|
|
db.commit()
|
|
|
|
return {
|
|
"id": new_ticket["id"],
|
|
"case_reference": new_ticket["case_reference"],
|
|
"ticket_type": new_ticket["ticket_type"],
|
|
"reporter_email": new_ticket["reporter_email"],
|
|
"subject": new_ticket["subject"],
|
|
"description": new_ticket["description"],
|
|
"severity": new_ticket["severity"],
|
|
"status": new_ticket["status"],
|
|
"created_at": new_ticket["created_at"],
|
|
"updated_at": new_ticket["updated_at"],
|
|
"resolved_at": new_ticket["resolved_at"],
|
|
"has_diagnostics": new_ticket["diagnostic_bundle"] is not None,
|
|
"notes": new_ticket["notes"],
|
|
}
|
|
|
|
|
|
@app.post("/api/v1/ops/support/tickets/{ticket_id}/notes", status_code=201)
|
|
def ops_add_support_ticket_note(
|
|
ticket_id: str,
|
|
request: OpsSupportTicketNoteCreate,
|
|
auth: tuple[Operator, OpsSession] = Depends(require_ops_roles("operator", "owner")),
|
|
db: DbSession = Depends(get_db),
|
|
):
|
|
operator, _ = auth
|
|
ticket = find_support_ticket(ticket_id)
|
|
if ticket is None:
|
|
raise HTTPException(status_code=404, detail="Support ticket not found")
|
|
|
|
body_text = request.body.strip()
|
|
if not body_text:
|
|
raise HTTPException(status_code=422, detail="Note body cannot be empty")
|
|
|
|
now_iso = datetime.now(timezone.utc).isoformat()
|
|
note_id = str(uuid.uuid4())
|
|
note = {
|
|
"id": note_id,
|
|
"author_identity": operator.identity,
|
|
"body": body_text,
|
|
"created_at": now_iso,
|
|
}
|
|
ticket.setdefault("notes", []).append(note)
|
|
ticket["updated_at"] = now_iso
|
|
|
|
ops_audit(
|
|
db=db,
|
|
action="support.note_added",
|
|
actor_identity=operator.identity,
|
|
actor_role=operator.role,
|
|
target_resource=f"ticket:{ticket['id']}",
|
|
case_reference=ticket["case_reference"],
|
|
result="success",
|
|
)
|
|
db.commit()
|
|
|
|
return note
|
|
|
|
|
|
@app.patch("/api/v1/ops/support/tickets/{ticket_id}")
|
|
def ops_update_support_ticket(
|
|
ticket_id: str,
|
|
patch: OpsSupportTicketPatch,
|
|
auth: tuple[Operator, OpsSession] = Depends(require_ops_roles("operator", "owner")),
|
|
db: DbSession = Depends(get_db),
|
|
):
|
|
operator, _ = auth
|
|
ticket = find_support_ticket(ticket_id)
|
|
if ticket is None:
|
|
raise HTTPException(status_code=404, detail="Support ticket not found")
|
|
|
|
if patch.status is None and patch.severity is None:
|
|
raise HTTPException(status_code=422, detail="Must provide status or severity to update")
|
|
|
|
now_iso = datetime.now(timezone.utc).isoformat()
|
|
|
|
if patch.status is not None:
|
|
st = patch.status.strip().lower()
|
|
if st not in ("open", "in_progress", "resolved", "closed"):
|
|
raise HTTPException(status_code=422, detail="Invalid ticket status")
|
|
ticket["status"] = st
|
|
if st in ("resolved", "closed"):
|
|
if not ticket.get("resolved_at"):
|
|
ticket["resolved_at"] = now_iso
|
|
elif st in ("open", "in_progress"):
|
|
ticket["resolved_at"] = None
|
|
|
|
if patch.severity is not None:
|
|
sev = patch.severity.strip().lower()
|
|
if sev not in ("low", "medium", "high", "critical"):
|
|
raise HTTPException(status_code=422, detail="Invalid severity level")
|
|
ticket["severity"] = sev
|
|
|
|
ticket["updated_at"] = now_iso
|
|
|
|
ops_audit(
|
|
db=db,
|
|
action="support.ticket_updated",
|
|
actor_identity=operator.identity,
|
|
actor_role=operator.role,
|
|
target_resource=f"ticket:{ticket['id']}",
|
|
case_reference=ticket["case_reference"],
|
|
result="success",
|
|
metadata={"status": ticket["status"], "severity": ticket["severity"]},
|
|
)
|
|
db.commit()
|
|
|
|
return {
|
|
"id": ticket["id"],
|
|
"case_reference": ticket["case_reference"],
|
|
"ticket_type": ticket["ticket_type"],
|
|
"reporter_email": ticket["reporter_email"],
|
|
"subject": ticket["subject"],
|
|
"description": ticket["description"],
|
|
"severity": ticket["severity"],
|
|
"status": ticket["status"],
|
|
"created_at": ticket["created_at"],
|
|
"updated_at": ticket["updated_at"],
|
|
"resolved_at": ticket.get("resolved_at"),
|
|
"has_diagnostics": ticket.get("diagnostic_bundle") is not None,
|
|
"notes": ticket.get("notes", []),
|
|
}
|
|
|
|
|
|
@app.get("/api/v1/ops/support/tickets/{ticket_id}/diagnostics")
|
|
def ops_get_support_ticket_diagnostics(
|
|
ticket_id: str,
|
|
reason: str = Query(..., min_length=3),
|
|
auth: tuple[Operator, OpsSession] = Depends(require_ops_roles("operator", "owner")),
|
|
db: DbSession = Depends(get_db),
|
|
):
|
|
operator, _ = auth
|
|
ticket = find_support_ticket(ticket_id)
|
|
if ticket is None:
|
|
raise HTTPException(status_code=404, detail="Support ticket not found")
|
|
|
|
bundle = ticket.get("diagnostic_bundle")
|
|
if bundle is None:
|
|
raise HTTPException(status_code=404, detail="No diagnostic bundle found for this ticket")
|
|
|
|
ops_audit(
|
|
db=db,
|
|
action="support.diagnostics_viewed",
|
|
actor_identity=operator.identity,
|
|
actor_role=operator.role,
|
|
target_resource=f"ticket:{ticket['id']}",
|
|
reason_code=reason.strip(),
|
|
case_reference=ticket["case_reference"],
|
|
result="success",
|
|
)
|
|
db.commit()
|
|
|
|
return bundle
|
|
|
|
|
|
# ==============================================================================
|
|
# SECTION 4: WEBSITE CONTENT CMS (OPERATIONS)
|
|
# ==============================================================================
|
|
|
|
class OpsCmsUpdateRequest(BaseModel):
|
|
action: Literal["draft", "publish"]
|
|
content: dict
|
|
change_summary: str = Field(default="Content updated")
|
|
|
|
|
|
class OpsCmsRollbackRequest(BaseModel):
|
|
revision_id: str
|
|
reason: str = Field(min_length=3)
|
|
|
|
|
|
CMS_DEFAULT_DOCUMENTS: dict[str, dict] = {
|
|
"homepage_hero": {
|
|
"slug": "homepage_hero",
|
|
"title": "Homepage Hero",
|
|
"description": "Main landing hero copy, value proposition headline, and primary call-to-action.",
|
|
"published": {
|
|
"headline": "Precision Telemetry for Every Corner",
|
|
"subheadline": "Professional-grade motorcycle lean angle, 20Hz dynamics, and track analysis directly on your smartphone.",
|
|
"cta_text": "Download App",
|
|
"cta_link": "/download",
|
|
"badge_text": "Engineered for Riders",
|
|
},
|
|
"draft": None,
|
|
"updated_at": "2026-09-12T12:00:00Z",
|
|
"updated_by": "system",
|
|
"revisions": [
|
|
{
|
|
"revision_id": "rev-hero-init-001",
|
|
"created_at": "2026-09-12T12:00:00Z",
|
|
"created_by": "system",
|
|
"status": "published",
|
|
"change_summary": "Initial baseline published content",
|
|
"content": {
|
|
"headline": "Precision Telemetry for Every Corner",
|
|
"subheadline": "Professional-grade motorcycle lean angle, 20Hz dynamics, and track analysis directly on your smartphone.",
|
|
"cta_text": "Download App",
|
|
"cta_link": "/download",
|
|
"badge_text": "Engineered for Riders",
|
|
},
|
|
}
|
|
],
|
|
},
|
|
"features": {
|
|
"slug": "features",
|
|
"title": "Key Features",
|
|
"description": "High-level feature cards detailing 20Hz sensor fusion, automatic ride gate, and telemetry.",
|
|
"published": {
|
|
"items": [
|
|
{
|
|
"title": "20Hz Lean & Dynamics",
|
|
"description": "Sub-50ms authoritative sensor fusion derived lean angle.",
|
|
"icon": "speed",
|
|
},
|
|
{
|
|
"title": "Automatic Ride Detection",
|
|
"description": "Zero-touch start and stop with 3.0 MPH moving gate.",
|
|
"icon": "auto_mode",
|
|
},
|
|
{
|
|
"title": "Track & Road Telemetry",
|
|
"description": "Forensic high-rate GPS & IMU playback with corner metrics.",
|
|
"icon": "timeline",
|
|
},
|
|
]
|
|
},
|
|
"draft": None,
|
|
"updated_at": "2026-09-12T12:00:00Z",
|
|
"updated_by": "system",
|
|
"revisions": [
|
|
{
|
|
"revision_id": "rev-features-init-001",
|
|
"created_at": "2026-09-12T12:00:00Z",
|
|
"created_by": "system",
|
|
"status": "published",
|
|
"change_summary": "Initial baseline published content",
|
|
"content": {
|
|
"items": [
|
|
{
|
|
"title": "20Hz Lean & Dynamics",
|
|
"description": "Sub-50ms authoritative sensor fusion derived lean angle.",
|
|
"icon": "speed",
|
|
},
|
|
{
|
|
"title": "Automatic Ride Detection",
|
|
"description": "Zero-touch start and stop with 3.0 MPH moving gate.",
|
|
"icon": "auto_mode",
|
|
},
|
|
{
|
|
"title": "Track & Road Telemetry",
|
|
"description": "Forensic high-rate GPS & IMU playback with corner metrics.",
|
|
"icon": "timeline",
|
|
},
|
|
]
|
|
},
|
|
}
|
|
],
|
|
},
|
|
"screenshots": {
|
|
"slug": "screenshots",
|
|
"title": "App Screenshots & Captions",
|
|
"description": "App gallery visuals showcasing live dashboard and interactive turn analysis.",
|
|
"published": {
|
|
"items": [
|
|
{
|
|
"title": "Live Dashboard",
|
|
"caption": "Real-time lean gauge and cockpit telemetry",
|
|
"image_url": "/screenshots/dashboard.png",
|
|
},
|
|
{
|
|
"title": "Ride Forensic Analysis",
|
|
"caption": "Interactive turn analysis with braking points",
|
|
"image_url": "/screenshots/analysis.png",
|
|
},
|
|
]
|
|
},
|
|
"draft": None,
|
|
"updated_at": "2026-09-12T12:00:00Z",
|
|
"updated_by": "system",
|
|
"revisions": [
|
|
{
|
|
"revision_id": "rev-screenshots-init-001",
|
|
"created_at": "2026-09-12T12:00:00Z",
|
|
"created_by": "system",
|
|
"status": "published",
|
|
"change_summary": "Initial baseline published content",
|
|
"content": {
|
|
"items": [
|
|
{
|
|
"title": "Live Dashboard",
|
|
"caption": "Real-time lean gauge and cockpit telemetry",
|
|
"image_url": "/screenshots/dashboard.png",
|
|
},
|
|
{
|
|
"title": "Ride Forensic Analysis",
|
|
"caption": "Interactive turn analysis with braking points",
|
|
"image_url": "/screenshots/analysis.png",
|
|
},
|
|
]
|
|
},
|
|
}
|
|
],
|
|
},
|
|
"faqs": {
|
|
"slug": "faqs",
|
|
"title": "Frequently Asked Questions",
|
|
"description": "Hardware mounting guidelines and offline data recording details for riders.",
|
|
"published": {
|
|
"items": [
|
|
{
|
|
"question": "What mounting setup is required?",
|
|
"answer": "Any rigid phone mount such as QuadLock or RAM Mount positioned in landscape or portrait.",
|
|
},
|
|
{
|
|
"question": "Does it work without cellular signal?",
|
|
"answer": "Yes, MotorSignal records 100% locally offline and syncs when connectivity returns.",
|
|
},
|
|
]
|
|
},
|
|
"draft": None,
|
|
"updated_at": "2026-09-12T12:00:00Z",
|
|
"updated_by": "system",
|
|
"revisions": [
|
|
{
|
|
"revision_id": "rev-faqs-init-001",
|
|
"created_at": "2026-09-12T12:00:00Z",
|
|
"created_by": "system",
|
|
"status": "published",
|
|
"change_summary": "Initial baseline published content",
|
|
"content": {
|
|
"items": [
|
|
{
|
|
"question": "What mounting setup is required?",
|
|
"answer": "Any rigid phone mount such as QuadLock or RAM Mount positioned in landscape or portrait.",
|
|
},
|
|
{
|
|
"question": "Does it work without cellular signal?",
|
|
"answer": "Yes, MotorSignal records 100% locally offline and syncs when connectivity returns.",
|
|
},
|
|
]
|
|
},
|
|
}
|
|
],
|
|
},
|
|
"download_links": {
|
|
"slug": "download_links",
|
|
"title": "Download & Store Links",
|
|
"description": "Direct APK download URL, Google Play store link, and latest production version tag.",
|
|
"published": {
|
|
"android_url": "https://motorsignal.app/builds/motorsignal-release.apk",
|
|
"testflight_url": "",
|
|
"play_store_url": "https://play.google.com/store/apps/details?id=app.motorsignal",
|
|
"version_name": "1.0.4",
|
|
"build_number": 42,
|
|
},
|
|
"draft": None,
|
|
"updated_at": "2026-09-12T12:00:00Z",
|
|
"updated_by": "system",
|
|
"revisions": [
|
|
{
|
|
"revision_id": "rev-downloads-init-001",
|
|
"created_at": "2026-09-12T12:00:00Z",
|
|
"created_by": "system",
|
|
"status": "published",
|
|
"change_summary": "Initial baseline published content",
|
|
"content": {
|
|
"android_url": "https://motorsignal.app/builds/motorsignal-release.apk",
|
|
"testflight_url": "",
|
|
"play_store_url": "https://play.google.com/store/apps/details?id=app.motorsignal",
|
|
"version_name": "1.0.4",
|
|
"build_number": 42,
|
|
},
|
|
}
|
|
],
|
|
},
|
|
"changelog": {
|
|
"slug": "changelog",
|
|
"title": "Release Changelog",
|
|
"description": "Public release notes, version dates, and acquisition refinement summaries.",
|
|
"published": {
|
|
"releases": [
|
|
{
|
|
"version": "v1.0.4",
|
|
"release_date": "2026-09-12",
|
|
"title": "Operations Platform V1",
|
|
"notes": "Added real-time system health, telemetry diagnostics, and improved offline sync.",
|
|
},
|
|
{
|
|
"version": "v1.0.3",
|
|
"release_date": "2026-09-01",
|
|
"title": "20Hz Acquisition Refinements",
|
|
"notes": "Optimized sensor fusion filter latency.",
|
|
},
|
|
]
|
|
},
|
|
"draft": None,
|
|
"updated_at": "2026-09-12T12:00:00Z",
|
|
"updated_by": "system",
|
|
"revisions": [
|
|
{
|
|
"revision_id": "rev-changelog-init-001",
|
|
"created_at": "2026-09-12T12:00:00Z",
|
|
"created_by": "system",
|
|
"status": "published",
|
|
"change_summary": "Initial baseline published content",
|
|
"content": {
|
|
"releases": [
|
|
{
|
|
"version": "v1.0.4",
|
|
"release_date": "2026-09-12",
|
|
"title": "Operations Platform V1",
|
|
"notes": "Added real-time system health, telemetry diagnostics, and improved offline sync.",
|
|
},
|
|
{
|
|
"version": "v1.0.3",
|
|
"release_date": "2026-09-01",
|
|
"title": "20Hz Acquisition Refinements",
|
|
"notes": "Optimized sensor fusion filter latency.",
|
|
},
|
|
]
|
|
},
|
|
}
|
|
],
|
|
},
|
|
"policy_pages": {
|
|
"slug": "policy_pages",
|
|
"title": "Legal & Policy Pages",
|
|
"description": "Rider telemetry data minimization privacy policy and terms of service terms.",
|
|
"published": {
|
|
"privacy_policy": "MotorSignal operates under strict data minimization. Rider telemetry is private and never sold.",
|
|
"terms_of_service": "MotorSignal telemetry is for closed-course performance analysis and recreational touring.",
|
|
},
|
|
"draft": None,
|
|
"updated_at": "2026-09-12T12:00:00Z",
|
|
"updated_by": "system",
|
|
"revisions": [
|
|
{
|
|
"revision_id": "rev-policies-init-001",
|
|
"created_at": "2026-09-12T12:00:00Z",
|
|
"created_by": "system",
|
|
"status": "published",
|
|
"change_summary": "Initial baseline published content",
|
|
"content": {
|
|
"privacy_policy": "MotorSignal operates under strict data minimization. Rider telemetry is private and never sold.",
|
|
"terms_of_service": "MotorSignal telemetry is for closed-course performance analysis and recreational touring.",
|
|
},
|
|
}
|
|
],
|
|
},
|
|
}
|
|
|
|
# Mutable in-memory store initialized from defaults
|
|
CMS_DOCUMENTS: dict[str, dict] = {
|
|
k: {
|
|
"slug": v["slug"],
|
|
"title": v["title"],
|
|
"description": v["description"],
|
|
"published": dict(v["published"]) if isinstance(v["published"], dict) else v["published"],
|
|
"draft": dict(v["draft"]) if isinstance(v["draft"], dict) else None,
|
|
"updated_at": v["updated_at"],
|
|
"updated_by": v["updated_by"],
|
|
"revisions": list(v["revisions"]),
|
|
}
|
|
for k, v in CMS_DEFAULT_DOCUMENTS.items()
|
|
}
|
|
|
|
|
|
@app.get("/api/v1/ops/cms/documents")
|
|
def ops_get_cms_documents(
|
|
auth: tuple[Operator, OpsSession] = Depends(require_ops_roles("operator", "owner")),
|
|
db: DbSession = Depends(get_db),
|
|
):
|
|
summaries = []
|
|
for doc in CMS_DOCUMENTS.values():
|
|
summaries.append({
|
|
"slug": doc["slug"],
|
|
"title": doc["title"],
|
|
"description": doc["description"],
|
|
"has_draft": doc["draft"] is not None,
|
|
"updated_at": doc["updated_at"],
|
|
"updated_by": doc["updated_by"],
|
|
})
|
|
return summaries
|
|
|
|
|
|
@app.get("/api/v1/ops/cms/documents/{slug}")
|
|
def ops_get_cms_document_detail(
|
|
slug: str,
|
|
auth: tuple[Operator, OpsSession] = Depends(require_ops_roles("operator", "owner")),
|
|
db: DbSession = Depends(get_db),
|
|
):
|
|
clean_slug = slug.strip()
|
|
if clean_slug not in CMS_DOCUMENTS:
|
|
raise HTTPException(status_code=404, detail="CMS document not found")
|
|
|
|
doc = CMS_DOCUMENTS[clean_slug]
|
|
return {
|
|
"slug": doc["slug"],
|
|
"title": doc["title"],
|
|
"description": doc["description"],
|
|
"published": doc["published"],
|
|
"draft": doc["draft"],
|
|
"has_draft": doc["draft"] is not None,
|
|
"updated_at": doc["updated_at"],
|
|
"updated_by": doc["updated_by"],
|
|
"revisions": doc["revisions"],
|
|
}
|
|
|
|
|
|
@app.put("/api/v1/ops/cms/documents/{slug}")
|
|
def ops_update_cms_document(
|
|
slug: str,
|
|
body: OpsCmsUpdateRequest,
|
|
auth: tuple[Operator, OpsSession] = Depends(require_ops_roles("operator", "owner")),
|
|
db: DbSession = Depends(get_db),
|
|
):
|
|
operator, _ = auth
|
|
clean_slug = slug.strip()
|
|
if clean_slug not in CMS_DOCUMENTS:
|
|
raise HTTPException(status_code=404, detail="CMS document not found")
|
|
|
|
doc = CMS_DOCUMENTS[clean_slug]
|
|
now_str = datetime.now(timezone.utc).isoformat()
|
|
rev_id = f"rev-{clean_slug[:8]}-{uuid.uuid4().hex[:8]}"
|
|
summary_text = body.change_summary.strip() or ("Draft saved" if body.action == "draft" else "Published live")
|
|
|
|
if body.action == "draft":
|
|
doc["draft"] = body.content
|
|
doc["updated_at"] = now_str
|
|
doc["updated_by"] = operator.identity
|
|
|
|
rev = {
|
|
"revision_id": rev_id,
|
|
"created_at": now_str,
|
|
"created_by": operator.identity,
|
|
"status": "draft",
|
|
"change_summary": summary_text,
|
|
"content": body.content,
|
|
}
|
|
doc["revisions"].insert(0, rev)
|
|
|
|
ops_audit(
|
|
db=db,
|
|
action="cms.draft_saved",
|
|
actor_identity=operator.identity,
|
|
actor_role=operator.role,
|
|
target_resource=f"cms:{clean_slug}",
|
|
reason_code="content_draft_update",
|
|
result="success",
|
|
metadata={"slug": clean_slug, "revision_id": rev_id, "summary": summary_text},
|
|
)
|
|
db.commit()
|
|
|
|
elif body.action == "publish":
|
|
doc["published"] = body.content
|
|
doc["draft"] = None
|
|
doc["updated_at"] = now_str
|
|
doc["updated_by"] = operator.identity
|
|
|
|
rev = {
|
|
"revision_id": rev_id,
|
|
"created_at": now_str,
|
|
"created_by": operator.identity,
|
|
"status": "published",
|
|
"change_summary": summary_text,
|
|
"content": body.content,
|
|
}
|
|
doc["revisions"].insert(0, rev)
|
|
|
|
ops_audit(
|
|
db=db,
|
|
action="cms.published",
|
|
actor_identity=operator.identity,
|
|
actor_role=operator.role,
|
|
target_resource=f"cms:{clean_slug}",
|
|
reason_code="content_published",
|
|
result="success",
|
|
metadata={"slug": clean_slug, "revision_id": rev_id, "summary": summary_text},
|
|
)
|
|
db.commit()
|
|
|
|
return {
|
|
"slug": doc["slug"],
|
|
"title": doc["title"],
|
|
"description": doc["description"],
|
|
"published": doc["published"],
|
|
"draft": doc["draft"],
|
|
"has_draft": doc["draft"] is not None,
|
|
"updated_at": doc["updated_at"],
|
|
"updated_by": doc["updated_by"],
|
|
"revisions": doc["revisions"],
|
|
}
|
|
|
|
|
|
@app.post("/api/v1/ops/cms/documents/{slug}/rollback")
|
|
def ops_rollback_cms_document(
|
|
slug: str,
|
|
body: OpsCmsRollbackRequest,
|
|
auth: tuple[Operator, OpsSession] = Depends(require_ops_roles("operator", "owner")),
|
|
db: DbSession = Depends(get_db),
|
|
):
|
|
operator, _ = auth
|
|
clean_slug = slug.strip()
|
|
if clean_slug not in CMS_DOCUMENTS:
|
|
raise HTTPException(status_code=404, detail="CMS document not found")
|
|
|
|
doc = CMS_DOCUMENTS[clean_slug]
|
|
target_rev = next((r for r in doc["revisions"] if r["revision_id"] == body.revision_id.strip()), None)
|
|
if not target_rev:
|
|
raise HTTPException(status_code=404, detail="Revision not found")
|
|
|
|
now_str = datetime.now(timezone.utc).isoformat()
|
|
rev_id = f"rev-{clean_slug[:8]}-{uuid.uuid4().hex[:8]}"
|
|
restored_content = dict(target_rev["content"])
|
|
|
|
doc["published"] = restored_content
|
|
doc["draft"] = None
|
|
doc["updated_at"] = now_str
|
|
doc["updated_by"] = operator.identity
|
|
|
|
rev = {
|
|
"revision_id": rev_id,
|
|
"created_at": now_str,
|
|
"created_by": operator.identity,
|
|
"status": "published",
|
|
"change_summary": f"Rollback to {body.revision_id.strip()}: {body.reason.strip()}",
|
|
"content": restored_content,
|
|
}
|
|
doc["revisions"].insert(0, rev)
|
|
|
|
ops_audit(
|
|
db=db,
|
|
action="cms.revision_restored",
|
|
actor_identity=operator.identity,
|
|
actor_role=operator.role,
|
|
target_resource=f"cms:{clean_slug}",
|
|
reason_code=body.reason.strip(),
|
|
result="success",
|
|
metadata={
|
|
"slug": clean_slug,
|
|
"restored_revision_id": body.revision_id.strip(),
|
|
"new_revision_id": rev_id,
|
|
},
|
|
)
|
|
db.commit()
|
|
|
|
return {
|
|
"slug": doc["slug"],
|
|
"title": doc["title"],
|
|
"description": doc["description"],
|
|
"published": doc["published"],
|
|
"draft": doc["draft"],
|
|
"has_draft": doc["draft"] is not None,
|
|
"updated_at": doc["updated_at"],
|
|
"updated_by": doc["updated_by"],
|
|
"revisions": doc["revisions"],
|
|
}
|