diff --git a/backend/motorsignal_api/.env.example b/backend/motorsignal_api/.env.example index c0c1440..e1a8eaf 100644 --- a/backend/motorsignal_api/.env.example +++ b/backend/motorsignal_api/.env.example @@ -8,6 +8,7 @@ DATABASE_NAME=motorsignal_dev DATABASE_USER=motorsignal DATABASE_PASSWORD=replace-me WEB_BASE_URL=http://localhost:3000 +FIREBASE_PROJECT_ID=motoapp-d22e0 # Optional email delivery. Without SMTP_HOST, token emails are not sent. SMTP_HOST= diff --git a/backend/motorsignal_api/app/main.py b/backend/motorsignal_api/app/main.py index ba95988..c162ea5 100644 --- a/backend/motorsignal_api/app/main.py +++ b/backend/motorsignal_api/app/main.py @@ -2,8 +2,11 @@ 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 @@ -93,6 +96,8 @@ class TokenResponse(BaseModel): access_token: str refresh_token: str token_type: str = "bearer" + ops_token: str | None = None + class SessionResponse(BaseModel): @@ -423,12 +428,57 @@ def login( 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, @@ -535,6 +585,98 @@ def link_firebase_identity(request: FirebaseLinkRequest, user: User = Depends(ge 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") @@ -912,6 +1054,49 @@ class OpsIncidentNoteCreate(BaseModel): 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 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 + + +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), @@ -961,6 +1146,163 @@ def ops_get_health(): } +@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), @@ -979,7 +1321,69 @@ def ops_logout_current_session( 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), @@ -1403,3 +1807,1314 @@ def ops_create_incident_note( "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/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"], + } diff --git a/backend/motorsignal_api/tests/test_api_smoke.py b/backend/motorsignal_api/tests/test_api_smoke.py index 8386068..ed42f6d 100644 --- a/backend/motorsignal_api/tests/test_api_smoke.py +++ b/backend/motorsignal_api/tests/test_api_smoke.py @@ -119,8 +119,12 @@ def main() -> None: assert status == 201 and telemetry["accepted"] == 1 status, telemetry = request("GET", f"/api/v1/rides/{created_ride['id']}/telemetry", token=admin_access) assert status == 200 and telemetry["count"] == 1 + status, summary = request("GET", f"/api/v1/rides/{created_ride['id']}/telemetry/summary", token=admin_access) + assert status == 200 and summary["point_count"] == 1 and summary["max_speed_mph"] == 42.0 status, telemetry = request("GET", f"/api/v1/admin/rides/{created_ride['id']}/telemetry", token=admin_access) assert status == 200 and telemetry["count"] == 1 + status, summary = request("GET", f"/api/v1/admin/rides/{created_ride['id']}/telemetry/summary", token=admin_access) + assert status == 200 and summary["point_count"] == 1 status, subscription = request("GET", "/api/v1/me/subscription", token=admin_access) assert status == 200 and subscription["plan"] == "free" status, entitlements = request("GET", "/api/v1/me/entitlements", token=admin_access) diff --git a/backend/motorsignal_api/tests/test_ops_support.py b/backend/motorsignal_api/tests/test_ops_support.py new file mode 100644 index 0000000..99b3e8a --- /dev/null +++ b/backend/motorsignal_api/tests/test_ops_support.py @@ -0,0 +1,276 @@ +import hashlib +import os +import sys +import unittest +import uuid +from datetime import datetime, timedelta, timezone + +ROOT = os.path.dirname(os.path.dirname(__file__)) +sys.path.insert(0, os.path.join(ROOT, ".python-packages")) +sys.path.insert(0, os.path.join(ROOT, "app")) + +os.environ.setdefault("JWT_SECRET", "local-test-secret") +os.environ["FIREBASE_PROJECT_ID"] = "motoapp-d22e0" +os.environ["DATABASE_USER"] = "test" +os.environ["DATABASE_PASSWORD"] = "test" +os.environ["DATABASE_NAME"] = "test" + +from sqlalchemy import create_engine, event, select +from sqlalchemy.orm import Session as DbSession +from starlette.testclient import TestClient + +import main +from main import app, get_db, SUPPORT_TICKETS +from models import Base, Operator, OpsAuditEvent, OpsSession + + +def hash_token(raw_token: str) -> str: + return hashlib.sha256(raw_token.encode()).hexdigest() + + +class TestOpsSupport(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.db_path = os.path.join(ROOT, "tests", "test_ops_support.db") + cls.ops_db_path = os.path.join(ROOT, "tests", "test_ops_support_schema.db") + for p in (cls.db_path, cls.ops_db_path): + if os.path.exists(p): + try: + os.remove(p) + except OSError: + pass + + cls.engine = create_engine(f"sqlite:///{cls.db_path}") + + @event.listens_for(cls.engine, "connect") + def attach_ops(dbapi_con, con_record): + dbapi_con.execute(f"ATTACH DATABASE '{cls.ops_db_path}' AS ops") + + Base.metadata.create_all(cls.engine) + + def override_get_db(): + db = DbSession(cls.engine) + try: + yield db + finally: + db.close() + + app.dependency_overrides[get_db] = override_get_db + cls.client = TestClient(app, raise_server_exceptions=False) + + @classmethod + def tearDownClass(cls): + app.dependency_overrides.clear() + cls.engine.dispose() + for p in (cls.db_path, cls.ops_db_path): + if os.path.exists(p): + try: + os.remove(p) + except OSError: + pass + + def setUp(self): + with DbSession(self.engine) as db: + db.query(OpsAuditEvent).delete() + db.query(OpsSession).delete() + db.query(Operator).delete() + db.commit() + + self.op_id = uuid.uuid4() + self.op_identity = "operator@motorsignal.internal" + self.op_token = "valid-operator-token-123" + op = Operator( + id=self.op_id, + identity=self.op_identity, + role="operator", + status="active", + created_at=datetime.now(timezone.utc), + ) + op_session = OpsSession( + operator_id=self.op_id, + session_token_hash=hash_token(self.op_token), + expires_at=datetime.now(timezone.utc) + timedelta(hours=8), + created_at=datetime.now(timezone.utc), + ) + db.add(op) + db.add(op_session) + db.commit() + + def test_list_tickets_unauthorized(self): + resp = self.client.get("/api/v1/ops/support/tickets") + self.assertEqual(resp.status_code, 401) + + def test_list_tickets_authorized(self): + resp = self.client.get( + "/api/v1/ops/support/tickets", + headers={"Authorization": f"Bearer {self.op_token}"}, + ) + self.assertEqual(resp.status_code, 200) + data = resp.json() + self.assertIn("items", data) + self.assertIn("total", data) + self.assertGreaterEqual(data["total"], 4) + + def test_filter_tickets_by_type_and_status(self): + resp = self.client.get( + "/api/v1/ops/support/tickets?type_filter=bug_report", + headers={"Authorization": f"Bearer {self.op_token}"}, + ) + self.assertEqual(resp.status_code, 200) + items = resp.json()["items"] + for it in items: + self.assertEqual(it["ticket_type"], "bug_report") + + resp = self.client.get( + "/api/v1/ops/support/tickets?status_filter=resolved", + headers={"Authorization": f"Bearer {self.op_token}"}, + ) + self.assertEqual(resp.status_code, 200) + items = resp.json()["items"] + for it in items: + self.assertEqual(it["status"], "resolved") + + def test_search_tickets(self): + resp = self.client.get( + "/api/v1/ops/support/tickets?q=freeze", + headers={"Authorization": f"Bearer {self.op_token}"}, + ) + self.assertEqual(resp.status_code, 200) + items = resp.json()["items"] + self.assertGreaterEqual(len(items), 1) + self.assertTrue(any("freeze" in it["subject"].lower() for it in items)) + + def test_get_ticket_detail(self): + ticket_id = "e1a11111-2222-3333-4444-555555555551" + resp = self.client.get( + f"/api/v1/ops/support/tickets/{ticket_id}", + headers={"Authorization": f"Bearer {self.op_token}"}, + ) + self.assertEqual(resp.status_code, 200) + data = resp.json() + self.assertEqual(data["case_reference"], "SUP-2026-000101") + self.assertEqual(data["has_diagnostics"], True) + self.assertIn("notes", data) + + def test_create_ticket_and_audit(self): + payload = { + "ticket_type": "bug_report", + "reporter_email": "testrider@example.com", + "subject": "Bluetooth disconnects after 10 mins", + "description": "App drops connection to OBD reader after 10 minutes of riding.", + "severity": "medium", + } + resp = self.client.post( + "/api/v1/ops/support/tickets", + json=payload, + headers={"Authorization": f"Bearer {self.op_token}"}, + ) + self.assertEqual(resp.status_code, 201) + data = resp.json() + self.assertTrue(data["case_reference"].startswith("SUP-2026-")) + self.assertEqual(data["status"], "open") + self.assertEqual(data["reporter_email"], "testrider@example.com") + + # Verify audit event + with DbSession(self.engine) as db: + event = db.scalar( + select(OpsAuditEvent).where( + OpsAuditEvent.action == "support.ticket_created", + OpsAuditEvent.case_reference == data["case_reference"], + ) + ) + self.assertIsNotNone(event) + self.assertEqual(event.actor_identity, self.op_identity) + + def test_add_ticket_note_and_audit(self): + ticket_id = "e1a11111-2222-3333-4444-555555555551" + resp = self.client.post( + f"/api/v1/ops/support/tickets/{ticket_id}/notes", + json={"body": "Investigated BLE stack disconnect log."}, + headers={"Authorization": f"Bearer {self.op_token}"}, + ) + self.assertEqual(resp.status_code, 201) + note = resp.json() + self.assertEqual(note["author_identity"], self.op_identity) + self.assertEqual(note["body"], "Investigated BLE stack disconnect log.") + + with DbSession(self.engine) as db: + event = db.scalar( + select(OpsAuditEvent).where( + OpsAuditEvent.action == "support.note_added", + OpsAuditEvent.target_resource == f"ticket:{ticket_id}", + ) + ) + self.assertIsNotNone(event) + + def test_patch_ticket_status_severity_and_audit(self): + ticket_id = "e1a11111-2222-3333-4444-555555555552" + resp = self.client.patch( + f"/api/v1/ops/support/tickets/{ticket_id}", + json={"status": "resolved", "severity": "low"}, + headers={"Authorization": f"Bearer {self.op_token}"}, + ) + self.assertEqual(resp.status_code, 200) + data = resp.json() + self.assertEqual(data["status"], "resolved") + self.assertEqual(data["severity"], "low") + self.assertIsNotNone(data["resolved_at"]) + + with DbSession(self.engine) as db: + event = db.scalar( + select(OpsAuditEvent).where( + OpsAuditEvent.action == "support.ticket_updated", + OpsAuditEvent.target_resource == f"ticket:{ticket_id}", + ) + ) + self.assertIsNotNone(event) + + def test_get_diagnostics_requires_reason(self): + ticket_id = "e1a11111-2222-3333-4444-555555555551" + # Reason missing + resp = self.client.get( + f"/api/v1/ops/support/tickets/{ticket_id}/diagnostics", + headers={"Authorization": f"Bearer {self.op_token}"}, + ) + self.assertEqual(resp.status_code, 422) + + # Reason too short (< 3 chars) + resp = self.client.get( + f"/api/v1/ops/support/tickets/{ticket_id}/diagnostics?reason=ab", + headers={"Authorization": f"Bearer {self.op_token}"}, + ) + self.assertEqual(resp.status_code, 422) + + # Valid reason + reason_text = "Debugging gyro freeze issue for user" + resp = self.client.get( + f"/api/v1/ops/support/tickets/{ticket_id}/diagnostics?reason={reason_text}", + headers={"Authorization": f"Bearer {self.op_token}"}, + ) + self.assertEqual(resp.status_code, 200) + bundle = resp.json() + self.assertEqual(bundle["device_model"], "Google Pixel 8 Pro") + self.assertIn("SensorFusionException", bundle["stack_trace"]) + + # Audit event recorded + with DbSession(self.engine) as db: + event = db.scalar( + select(OpsAuditEvent).where( + OpsAuditEvent.action == "support.diagnostics_viewed", + OpsAuditEvent.target_resource == f"ticket:{ticket_id}", + ) + ) + self.assertIsNotNone(event) + self.assertEqual(event.reason_code, reason_text) + + def test_get_diagnostics_not_found(self): + ticket_id = "e1a11111-2222-3333-4444-555555555552" + resp = self.client.get( + f"/api/v1/ops/support/tickets/{ticket_id}/diagnostics?reason=investigation", + headers={"Authorization": f"Bearer {self.op_token}"}, + ) + self.assertEqual(resp.status_code, 404) + + +if __name__ == "__main__": + unittest.main() diff --git a/website/app/admin/page.tsx b/website/app/admin/page.tsx index 336b10d..e05b700 100644 --- a/website/app/admin/page.tsx +++ b/website/app/admin/page.tsx @@ -1,4 +1,5 @@ import { cookies } from 'next/headers'; +import { redirect } from 'next/navigation'; import { OperationsShell } from '../../components/ops/operations-shell'; import { OperationsErrorState } from '../../components/ops/operations-error-state'; import { normalizeHealthResponse, sanitizeAuditEvent } from '../../lib/ops-data'; @@ -13,9 +14,11 @@ interface AdminPageProps { searchParams?: Promise<{ nav?: string; case?: string; + error?: string; }>; } + function parseNavParam(nav?: string): OperationsNavId { if (nav === 'incidents' || nav === 'audit' || nav === 'operators' || nav === 'session') { return nav; @@ -35,10 +38,26 @@ export default async function AdminPage({ searchParams }: AdminPageProps) { : cookieStore.get('__Host-motorsignal_ops')?.value ?? cookieStore.get('motorsignal_ops')?.value; + const riderToken = cookieStore.get('motorsignal_access_token')?.value; + + if (resolvedParams.error === 'forbidden') { + return ( +
+ +
+ ); + } + // Unauthenticated - prompt for login (Zero query parameter preview/bypass) if (!opsToken) { + if (riderToken) { + redirect('/api/ops/auth/session'); + } return ( -
+
+
+
+
+
({})); + return NextResponse.json(err, { status: upstream.status }); + } + + const data = (await upstream.json()) as { ops_token?: string }; + const opsToken = data.ops_token; + + if (!opsToken) { + return NextResponse.json({ detail: 'Failed to issue operations token.' }, { status: 500 }); + } + + const response = NextResponse.json({ success: true }, { status: 200 }); + const opsCookieOptions = { + httpOnly: true, + secure: true, + sameSite: 'lax' as const, + path: '/', + maxAge: 60 * 60 * 8, + }; + + response.cookies.set('__Host-motorsignal_ops', opsToken, opsCookieOptions); + response.cookies.set('motorsignal_ops', opsToken, { + ...opsCookieOptions, + secure: process.env.NODE_ENV === 'production', + }); + + return response; + } catch { + return NextResponse.json({ detail: 'Operations bootstrap unavailable.' }, { status: 503 }); + } +} diff --git a/website/app/api/ops/cms/documents/[slug]/rollback/route.ts b/website/app/api/ops/cms/documents/[slug]/rollback/route.ts new file mode 100644 index 0000000..f8867c7 --- /dev/null +++ b/website/app/api/ops/cms/documents/[slug]/rollback/route.ts @@ -0,0 +1,95 @@ +import { cookies } from 'next/headers'; +import { NextRequest, NextResponse } from 'next/server'; +import { hasValidOrigin } from '../../../../../../../lib/request-origin'; + +interface RouteContext { + params: Promise<{ + slug: string; + }>; +} + +export async function POST(request: NextRequest, context: RouteContext) { + if (!hasValidOrigin(request)) { + return NextResponse.json({ detail: 'Invalid request origin.' }, { status: 403 }); + } + + const cookieStore = await cookies(); + const isProduction = process.env.NODE_ENV === 'production' && process.env.NEXT_PUBLIC_APP_ENV === 'production'; + const opsToken = isProduction + ? cookieStore.get('__Host-motorsignal_ops')?.value + : cookieStore.get('__Host-motorsignal_ops')?.value ?? + cookieStore.get('motorsignal_ops')?.value; + + if (!opsToken) { + return NextResponse.json( + { detail: 'Authentication required.' }, + { status: 401 } + ); + } + + const { slug } = await context.params; + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json( + { detail: 'Invalid request body.' }, + { status: 422 } + ); + } + + const baseUrl = process.env.MOTORSIGNAL_API_BASE_URL ?? 'https://api.motorsignal.app'; + + try { + const response = await fetch(`${baseUrl}/api/v1/ops/cms/documents/${encodeURIComponent(slug)}/rollback`, { + method: 'POST', + headers: { + authorization: `Bearer ${opsToken}`, + 'content-type': 'application/json', + accept: 'application/json', + }, + body: JSON.stringify(body), + cache: 'no-store', + }); + + if (response.status === 401) { + return NextResponse.json( + { detail: 'Your Operations session has expired.' }, + { status: 401 } + ); + } + if (response.status === 403) { + return NextResponse.json( + { detail: 'You do not have authority to rollback website content.' }, + { status: 403 } + ); + } + if (response.status === 404) { + return NextResponse.json( + { detail: 'CMS document or revision not found.' }, + { status: 404 } + ); + } + if (response.status === 422) { + const data = await response.json().catch(() => null); + return NextResponse.json( + { detail: data?.detail ?? 'Invalid rollback parameters.' }, + { status: 422 } + ); + } + if (!response.ok) { + return NextResponse.json( + { detail: 'Operations is temporarily unavailable.' }, + { status: response.status >= 500 ? response.status : 503 } + ); + } + + const data = await response.json(); + return NextResponse.json(data); + } catch { + return NextResponse.json( + { detail: 'Unable to connect to Operations platform.' }, + { status: 503 } + ); + } +} diff --git a/website/app/api/ops/cms/documents/[slug]/route.ts b/website/app/api/ops/cms/documents/[slug]/route.ts new file mode 100644 index 0000000..64ed979 --- /dev/null +++ b/website/app/api/ops/cms/documents/[slug]/route.ts @@ -0,0 +1,158 @@ +import { cookies } from 'next/headers'; +import { NextRequest, NextResponse } from 'next/server'; +import { hasValidOrigin } from '../../../../../../lib/request-origin'; + +interface RouteContext { + params: Promise<{ + slug: string; + }>; +} + +export async function GET(_request: NextRequest, context: RouteContext) { + const cookieStore = await cookies(); + const isProduction = process.env.NODE_ENV === 'production' && process.env.NEXT_PUBLIC_APP_ENV === 'production'; + const opsToken = isProduction + ? cookieStore.get('__Host-motorsignal_ops')?.value + : cookieStore.get('__Host-motorsignal_ops')?.value ?? + cookieStore.get('motorsignal_ops')?.value; + + if (!opsToken) { + return NextResponse.json( + { detail: 'Authentication required.' }, + { status: 401 } + ); + } + + const { slug } = await context.params; + const baseUrl = process.env.MOTORSIGNAL_API_BASE_URL ?? 'https://api.motorsignal.app'; + + try { + const response = await fetch(`${baseUrl}/api/v1/ops/cms/documents/${encodeURIComponent(slug)}`, { + method: 'GET', + headers: { + authorization: `Bearer ${opsToken}`, + accept: 'application/json', + }, + cache: 'no-store', + }); + + if (response.status === 401) { + return NextResponse.json( + { detail: 'Your Operations session has expired.' }, + { status: 401 } + ); + } + if (response.status === 403) { + return NextResponse.json( + { detail: 'You do not have authority to view this content document.' }, + { status: 403 } + ); + } + if (response.status === 404) { + return NextResponse.json( + { detail: 'CMS document not found.' }, + { status: 404 } + ); + } + if (!response.ok) { + return NextResponse.json( + { detail: 'Operations is temporarily unavailable.' }, + { status: response.status >= 500 ? response.status : 503 } + ); + } + + const data = await response.json(); + return NextResponse.json(data); + } catch { + return NextResponse.json( + { detail: 'Unable to connect to Operations platform.' }, + { status: 503 } + ); + } +} + +export async function PUT(request: NextRequest, context: RouteContext) { + if (!hasValidOrigin(request)) { + return NextResponse.json({ detail: 'Invalid request origin.' }, { status: 403 }); + } + + const cookieStore = await cookies(); + const isProduction = process.env.NODE_ENV === 'production' && process.env.NEXT_PUBLIC_APP_ENV === 'production'; + const opsToken = isProduction + ? cookieStore.get('__Host-motorsignal_ops')?.value + : cookieStore.get('__Host-motorsignal_ops')?.value ?? + cookieStore.get('motorsignal_ops')?.value; + + if (!opsToken) { + return NextResponse.json( + { detail: 'Authentication required.' }, + { status: 401 } + ); + } + + const { slug } = await context.params; + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json( + { detail: 'Invalid request body.' }, + { status: 422 } + ); + } + + const baseUrl = process.env.MOTORSIGNAL_API_BASE_URL ?? 'https://api.motorsignal.app'; + + try { + const response = await fetch(`${baseUrl}/api/v1/ops/cms/documents/${encodeURIComponent(slug)}`, { + method: 'PUT', + headers: { + authorization: `Bearer ${opsToken}`, + 'content-type': 'application/json', + accept: 'application/json', + }, + body: JSON.stringify(body), + cache: 'no-store', + }); + + if (response.status === 401) { + return NextResponse.json( + { detail: 'Your Operations session has expired.' }, + { status: 401 } + ); + } + if (response.status === 403) { + return NextResponse.json( + { detail: 'You do not have authority to update website content.' }, + { status: 403 } + ); + } + if (response.status === 404) { + return NextResponse.json( + { detail: 'CMS document not found.' }, + { status: 404 } + ); + } + if (response.status === 422) { + const data = await response.json().catch(() => null); + return NextResponse.json( + { detail: data?.detail ?? 'Invalid CMS update fields.' }, + { status: 422 } + ); + } + if (!response.ok) { + return NextResponse.json( + { detail: 'Operations is temporarily unavailable.' }, + { status: response.status >= 500 ? response.status : 503 } + ); + } + + const data = await response.json(); + return NextResponse.json(data); + } catch { + return NextResponse.json( + { detail: 'Unable to connect to Operations platform.' }, + { status: 503 } + ); + } +} diff --git a/website/app/api/ops/cms/documents/route.ts b/website/app/api/ops/cms/documents/route.ts new file mode 100644 index 0000000..4296408 --- /dev/null +++ b/website/app/api/ops/cms/documents/route.ts @@ -0,0 +1,57 @@ +import { cookies } from 'next/headers'; +import { NextRequest, NextResponse } from 'next/server'; + +export async function GET(_request: NextRequest) { + const cookieStore = await cookies(); + const isProduction = process.env.NODE_ENV === 'production' && process.env.NEXT_PUBLIC_APP_ENV === 'production'; + const opsToken = isProduction + ? cookieStore.get('__Host-motorsignal_ops')?.value + : cookieStore.get('__Host-motorsignal_ops')?.value ?? + cookieStore.get('motorsignal_ops')?.value; + + if (!opsToken) { + return NextResponse.json( + { detail: 'Authentication required.' }, + { status: 401 } + ); + } + + const baseUrl = process.env.MOTORSIGNAL_API_BASE_URL ?? 'https://api.motorsignal.app'; + try { + const response = await fetch(`${baseUrl}/api/v1/ops/cms/documents`, { + method: 'GET', + headers: { + authorization: `Bearer ${opsToken}`, + accept: 'application/json', + }, + cache: 'no-store', + }); + + if (response.status === 401) { + return NextResponse.json( + { detail: 'Your Operations session has expired.' }, + { status: 401 } + ); + } + if (response.status === 403) { + return NextResponse.json( + { detail: 'You do not have authority to view website content.' }, + { status: 403 } + ); + } + if (!response.ok) { + return NextResponse.json( + { detail: 'Operations is temporarily unavailable.' }, + { status: response.status >= 500 ? response.status : 503 } + ); + } + + const data = await response.json(); + return NextResponse.json(data); + } catch { + return NextResponse.json( + { detail: 'Unable to connect to Operations platform.' }, + { status: 503 } + ); + } +} diff --git a/website/app/api/ops/overview/route.ts b/website/app/api/ops/overview/route.ts new file mode 100644 index 0000000..bb7f305 --- /dev/null +++ b/website/app/api/ops/overview/route.ts @@ -0,0 +1,60 @@ +import { cookies } from 'next/headers'; +import { NextRequest, NextResponse } from 'next/server'; +import { normalizeOverviewResponse } from '../../../../lib/ops-data'; + +export async function GET(request: NextRequest) { + const cookieStore = await cookies(); + const isProduction = process.env.NODE_ENV === 'production' && process.env.NEXT_PUBLIC_APP_ENV === 'production'; + const opsToken = isProduction + ? cookieStore.get('__Host-motorsignal_ops')?.value + : cookieStore.get('__Host-motorsignal_ops')?.value ?? + cookieStore.get('motorsignal_ops')?.value; + + if (!opsToken) { + return NextResponse.json( + { detail: 'Authentication required.' }, + { status: 401 } + ); + } + + const baseUrl = process.env.MOTORSIGNAL_API_BASE_URL ?? 'https://api.motorsignal.app'; + const headers: Record = { + accept: 'application/json', + authorization: `Bearer ${opsToken}`, + }; + + try { + const response = await fetch(`${baseUrl}/api/v1/ops/overview`, { + method: 'GET', + headers, + cache: 'no-store', + }); + + if (!response.ok) { + if (response.status === 401) { + return NextResponse.json( + { detail: 'Authentication required.' }, + { status: 401 } + ); + } + if (response.status === 403) { + return NextResponse.json( + { detail: 'Access restricted.' }, + { status: 403 } + ); + } + return NextResponse.json( + normalizeOverviewResponse(null), + { status: 503 } + ); + } + + const data = await response.json(); + return NextResponse.json(normalizeOverviewResponse(data), { status: 200 }); + } catch { + return NextResponse.json( + normalizeOverviewResponse(null), + { status: 503 } + ); + } +} diff --git a/website/app/api/ops/support/tickets/[ticketId]/diagnostics/route.ts b/website/app/api/ops/support/tickets/[ticketId]/diagnostics/route.ts new file mode 100644 index 0000000..49aa0c1 --- /dev/null +++ b/website/app/api/ops/support/tickets/[ticketId]/diagnostics/route.ts @@ -0,0 +1,93 @@ +import { cookies } from 'next/headers'; +import { NextRequest, NextResponse } from 'next/server'; + +interface RouteContext { + params: Promise<{ + ticketId: string; + }>; +} + +export async function GET(request: NextRequest, context: RouteContext) { + const cookieStore = await cookies(); + const isProduction = process.env.NODE_ENV === 'production' && process.env.NEXT_PUBLIC_APP_ENV === 'production'; + const opsToken = isProduction + ? cookieStore.get('__Host-motorsignal_ops')?.value + : cookieStore.get('__Host-motorsignal_ops')?.value ?? + cookieStore.get('motorsignal_ops')?.value; + + if (!opsToken) { + return NextResponse.json( + { detail: 'Authentication required.' }, + { status: 401 } + ); + } + + const { ticketId } = await context.params; + const reasonParam = request.nextUrl.searchParams.get('reason'); + const reasonHeader = request.headers.get('x-diagnostic-reason'); + const reason = (reasonParam ?? reasonHeader ?? '').trim(); + + if (!reason || reason.length < 3) { + return NextResponse.json( + { detail: 'A valid reason of at least 3 characters is required to inspect diagnostics.' }, + { status: 422 } + ); + } + + const baseUrl = process.env.MOTORSIGNAL_API_BASE_URL ?? 'https://api.motorsignal.app'; + const targetUrl = new URL( + `${baseUrl}/api/v1/ops/support/tickets/${encodeURIComponent(ticketId)}/diagnostics` + ); + targetUrl.searchParams.set('reason', reason); + + try { + const response = await fetch(targetUrl.toString(), { + method: 'GET', + headers: { + authorization: `Bearer ${opsToken}`, + accept: 'application/json', + }, + cache: 'no-store', + }); + + if (response.status === 401) { + return NextResponse.json( + { detail: 'Your Operations session has expired.' }, + { status: 401 } + ); + } + if (response.status === 403) { + return NextResponse.json( + { detail: 'You do not have authority to inspect diagnostics.' }, + { status: 403 } + ); + } + if (response.status === 404) { + return NextResponse.json( + { detail: 'No diagnostic bundle found for this ticket.' }, + { status: 404 } + ); + } + if (response.status === 422) { + const data = await response.json().catch(() => null); + return NextResponse.json( + { detail: data?.detail ?? 'Invalid reason parameter.' }, + { status: 422 } + ); + } + if (!response.ok) { + return NextResponse.json( + { detail: 'Operations is temporarily unavailable.' }, + { status: response.status >= 500 ? response.status : 503 } + ); + } + + const data = await response.json(); + return NextResponse.json(data); + } catch { + return NextResponse.json( + { detail: 'Unable to connect to Operations platform.' }, + { status: 503 } + ); + } +} diff --git a/website/app/api/ops/support/tickets/[ticketId]/notes/route.ts b/website/app/api/ops/support/tickets/[ticketId]/notes/route.ts new file mode 100644 index 0000000..6f54e4e --- /dev/null +++ b/website/app/api/ops/support/tickets/[ticketId]/notes/route.ts @@ -0,0 +1,95 @@ +import { cookies } from 'next/headers'; +import { NextRequest, NextResponse } from 'next/server'; +import { hasValidOrigin } from '../../../../../../../lib/request-origin'; + +interface RouteContext { + params: Promise<{ + ticketId: string; + }>; +} + +export async function POST(request: NextRequest, context: RouteContext) { + if (!hasValidOrigin(request)) { + return NextResponse.json({ detail: 'Invalid request origin.' }, { status: 403 }); + } + + const cookieStore = await cookies(); + const isProduction = process.env.NODE_ENV === 'production' && process.env.NEXT_PUBLIC_APP_ENV === 'production'; + const opsToken = isProduction + ? cookieStore.get('__Host-motorsignal_ops')?.value + : cookieStore.get('__Host-motorsignal_ops')?.value ?? + cookieStore.get('motorsignal_ops')?.value; + + if (!opsToken) { + return NextResponse.json( + { detail: 'Authentication required.' }, + { status: 401 } + ); + } + + const { ticketId } = await context.params; + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json( + { detail: 'Invalid request body.' }, + { status: 422 } + ); + } + + const baseUrl = process.env.MOTORSIGNAL_API_BASE_URL ?? 'https://api.motorsignal.app'; + + try { + const response = await fetch(`${baseUrl}/api/v1/ops/support/tickets/${encodeURIComponent(ticketId)}/notes`, { + method: 'POST', + headers: { + authorization: `Bearer ${opsToken}`, + 'content-type': 'application/json', + accept: 'application/json', + }, + body: JSON.stringify(body), + cache: 'no-store', + }); + + if (response.status === 401) { + return NextResponse.json( + { detail: 'Your Operations session has expired.' }, + { status: 401 } + ); + } + if (response.status === 403) { + return NextResponse.json( + { detail: 'You do not have authority to add notes.' }, + { status: 403 } + ); + } + if (response.status === 404) { + return NextResponse.json( + { detail: 'Support ticket not found.' }, + { status: 404 } + ); + } + if (response.status === 422) { + const data = await response.json().catch(() => null); + return NextResponse.json( + { detail: data?.detail ?? 'Note body cannot be empty.' }, + { status: 422 } + ); + } + if (!response.ok) { + return NextResponse.json( + { detail: 'Operations is temporarily unavailable.' }, + { status: response.status >= 500 ? response.status : 503 } + ); + } + + const data = await response.json(); + return NextResponse.json(data, { status: 201 }); + } catch { + return NextResponse.json( + { detail: 'Unable to connect to Operations platform.' }, + { status: 503 } + ); + } +} diff --git a/website/app/api/ops/support/tickets/[ticketId]/route.ts b/website/app/api/ops/support/tickets/[ticketId]/route.ts new file mode 100644 index 0000000..40cd3ed --- /dev/null +++ b/website/app/api/ops/support/tickets/[ticketId]/route.ts @@ -0,0 +1,158 @@ +import { cookies } from 'next/headers'; +import { NextRequest, NextResponse } from 'next/server'; +import { hasValidOrigin } from '../../../../../../lib/request-origin'; + +interface RouteContext { + params: Promise<{ + ticketId: string; + }>; +} + +export async function GET(request: NextRequest, context: RouteContext) { + const cookieStore = await cookies(); + const isProduction = process.env.NODE_ENV === 'production' && process.env.NEXT_PUBLIC_APP_ENV === 'production'; + const opsToken = isProduction + ? cookieStore.get('__Host-motorsignal_ops')?.value + : cookieStore.get('__Host-motorsignal_ops')?.value ?? + cookieStore.get('motorsignal_ops')?.value; + + if (!opsToken) { + return NextResponse.json( + { detail: 'Authentication required.' }, + { status: 401 } + ); + } + + const { ticketId } = await context.params; + const baseUrl = process.env.MOTORSIGNAL_API_BASE_URL ?? 'https://api.motorsignal.app'; + + try { + const response = await fetch(`${baseUrl}/api/v1/ops/support/tickets/${encodeURIComponent(ticketId)}`, { + method: 'GET', + headers: { + authorization: `Bearer ${opsToken}`, + accept: 'application/json', + }, + cache: 'no-store', + }); + + if (response.status === 401) { + return NextResponse.json( + { detail: 'Your Operations session has expired.' }, + { status: 401 } + ); + } + if (response.status === 403) { + return NextResponse.json( + { detail: 'You do not have authority to view this support ticket.' }, + { status: 403 } + ); + } + if (response.status === 404) { + return NextResponse.json( + { detail: 'Support ticket not found.' }, + { status: 404 } + ); + } + if (!response.ok) { + return NextResponse.json( + { detail: 'Operations is temporarily unavailable.' }, + { status: response.status >= 500 ? response.status : 503 } + ); + } + + const data = await response.json(); + return NextResponse.json(data); + } catch { + return NextResponse.json( + { detail: 'Unable to connect to Operations platform.' }, + { status: 503 } + ); + } +} + +export async function PATCH(request: NextRequest, context: RouteContext) { + if (!hasValidOrigin(request)) { + return NextResponse.json({ detail: 'Invalid request origin.' }, { status: 403 }); + } + + const cookieStore = await cookies(); + const isProduction = process.env.NODE_ENV === 'production' && process.env.NEXT_PUBLIC_APP_ENV === 'production'; + const opsToken = isProduction + ? cookieStore.get('__Host-motorsignal_ops')?.value + : cookieStore.get('__Host-motorsignal_ops')?.value ?? + cookieStore.get('motorsignal_ops')?.value; + + if (!opsToken) { + return NextResponse.json( + { detail: 'Authentication required.' }, + { status: 401 } + ); + } + + const { ticketId } = await context.params; + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json( + { detail: 'Invalid request body.' }, + { status: 422 } + ); + } + + const baseUrl = process.env.MOTORSIGNAL_API_BASE_URL ?? 'https://api.motorsignal.app'; + + try { + const response = await fetch(`${baseUrl}/api/v1/ops/support/tickets/${encodeURIComponent(ticketId)}`, { + method: 'PATCH', + headers: { + authorization: `Bearer ${opsToken}`, + 'content-type': 'application/json', + accept: 'application/json', + }, + body: JSON.stringify(body), + cache: 'no-store', + }); + + if (response.status === 401) { + return NextResponse.json( + { detail: 'Your Operations session has expired.' }, + { status: 401 } + ); + } + if (response.status === 403) { + return NextResponse.json( + { detail: 'You do not have authority to update support tickets.' }, + { status: 403 } + ); + } + if (response.status === 404) { + return NextResponse.json( + { detail: 'Support ticket not found.' }, + { status: 404 } + ); + } + if (response.status === 422) { + const data = await response.json().catch(() => null); + return NextResponse.json( + { detail: data?.detail ?? 'Invalid update values.' }, + { status: 422 } + ); + } + if (!response.ok) { + return NextResponse.json( + { detail: 'Operations is temporarily unavailable.' }, + { status: response.status >= 500 ? response.status : 503 } + ); + } + + const data = await response.json(); + return NextResponse.json(data); + } catch { + return NextResponse.json( + { detail: 'Unable to connect to Operations platform.' }, + { status: 503 } + ); + } +} diff --git a/website/app/api/ops/support/tickets/route.ts b/website/app/api/ops/support/tickets/route.ts new file mode 100644 index 0000000..529dd50 --- /dev/null +++ b/website/app/api/ops/support/tickets/route.ts @@ -0,0 +1,150 @@ +import { cookies } from 'next/headers'; +import { NextRequest, NextResponse } from 'next/server'; +import { hasValidOrigin } from '../../../../../lib/request-origin'; + +export async function GET(request: NextRequest) { + const cookieStore = await cookies(); + const isProduction = process.env.NODE_ENV === 'production' && process.env.NEXT_PUBLIC_APP_ENV === 'production'; + const opsToken = isProduction + ? cookieStore.get('__Host-motorsignal_ops')?.value + : cookieStore.get('__Host-motorsignal_ops')?.value ?? + cookieStore.get('motorsignal_ops')?.value; + + if (!opsToken) { + return NextResponse.json( + { detail: 'Authentication required.' }, + { status: 401 } + ); + } + + const searchParams = request.nextUrl.searchParams; + const typeFilter = searchParams.get('type_filter'); + const statusFilter = searchParams.get('status_filter'); + const q = searchParams.get('q'); + const cursor = searchParams.get('cursor'); + const limit = searchParams.get('limit'); + + const baseUrl = process.env.MOTORSIGNAL_API_BASE_URL ?? 'https://api.motorsignal.app'; + const targetUrl = new URL(`${baseUrl}/api/v1/ops/support/tickets`); + if (typeFilter) targetUrl.searchParams.set('type_filter', typeFilter); + if (statusFilter) targetUrl.searchParams.set('status_filter', statusFilter); + if (q) targetUrl.searchParams.set('q', q); + if (cursor) targetUrl.searchParams.set('cursor', cursor); + if (limit) targetUrl.searchParams.set('limit', limit); + + try { + const response = await fetch(targetUrl.toString(), { + method: 'GET', + headers: { + authorization: `Bearer ${opsToken}`, + accept: 'application/json', + }, + cache: 'no-store', + }); + + if (response.status === 401) { + return NextResponse.json( + { detail: 'Your Operations session has expired.' }, + { status: 401 } + ); + } + if (response.status === 403) { + return NextResponse.json( + { detail: 'You do not have authority to view support tickets.' }, + { status: 403 } + ); + } + if (!response.ok) { + return NextResponse.json( + { detail: 'Operations is temporarily unavailable.' }, + { status: response.status >= 500 ? response.status : 503 } + ); + } + + const data = await response.json(); + return NextResponse.json(data); + } catch { + return NextResponse.json( + { detail: 'Unable to connect to Operations platform.' }, + { status: 503 } + ); + } +} + +export async function POST(request: NextRequest) { + if (!hasValidOrigin(request)) { + return NextResponse.json({ detail: 'Invalid request origin.' }, { status: 403 }); + } + + const cookieStore = await cookies(); + const isProduction = process.env.NODE_ENV === 'production' && process.env.NEXT_PUBLIC_APP_ENV === 'production'; + const opsToken = isProduction + ? cookieStore.get('__Host-motorsignal_ops')?.value + : cookieStore.get('__Host-motorsignal_ops')?.value ?? + cookieStore.get('motorsignal_ops')?.value; + + if (!opsToken) { + return NextResponse.json( + { detail: 'Authentication required.' }, + { status: 401 } + ); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json( + { detail: 'Invalid request body.' }, + { status: 422 } + ); + } + + const baseUrl = process.env.MOTORSIGNAL_API_BASE_URL ?? 'https://api.motorsignal.app'; + try { + const response = await fetch(`${baseUrl}/api/v1/ops/support/tickets`, { + method: 'POST', + headers: { + authorization: `Bearer ${opsToken}`, + 'content-type': 'application/json', + accept: 'application/json', + }, + body: JSON.stringify(body), + cache: 'no-store', + }); + + if (response.status === 401) { + return NextResponse.json( + { detail: 'Your Operations session has expired.' }, + { status: 401 } + ); + } + if (response.status === 403) { + return NextResponse.json( + { detail: 'You do not have authority to create support tickets.' }, + { status: 403 } + ); + } + if (response.status === 422) { + const data = await response.json().catch(() => null); + return NextResponse.json( + { detail: data?.detail ?? 'Invalid support ticket fields.' }, + { status: 422 } + ); + } + if (!response.ok) { + return NextResponse.json( + { detail: 'Operations is temporarily unavailable.' }, + { status: response.status >= 500 ? response.status : 503 } + ); + } + + const data = await response.json(); + return NextResponse.json(data, { status: 201 }); + } catch { + return NextResponse.json( + { detail: 'Unable to connect to Operations platform.' }, + { status: 503 } + ); + } +} diff --git a/website/app/api/ops/users/[userId]/reset-password/route.ts b/website/app/api/ops/users/[userId]/reset-password/route.ts new file mode 100644 index 0000000..c62de1a --- /dev/null +++ b/website/app/api/ops/users/[userId]/reset-password/route.ts @@ -0,0 +1,96 @@ +import { cookies } from 'next/headers'; +import { NextRequest, NextResponse } from 'next/server'; +import { hasValidOrigin } from '../../../../../../lib/request-origin'; + +interface RouteContext { + params: Promise<{ + userId: string; + }>; +} + +export async function POST(request: NextRequest, context: RouteContext) { + if (!hasValidOrigin(request)) { + return NextResponse.json({ detail: 'Invalid request origin.' }, { status: 403 }); + } + + const cookieStore = await cookies(); + const isProduction = process.env.NODE_ENV === 'production' && process.env.NEXT_PUBLIC_APP_ENV === 'production'; + const opsToken = isProduction + ? cookieStore.get('__Host-motorsignal_ops')?.value + : cookieStore.get('__Host-motorsignal_ops')?.value ?? + cookieStore.get('motorsignal_ops')?.value; + + if (!opsToken) { + return NextResponse.json( + { detail: 'Authentication required.' }, + { status: 401 } + ); + } + + const { userId } = await context.params; + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json( + { detail: 'Invalid request body.' }, + { status: 422 } + ); + } + + const baseUrl = process.env.MOTORSIGNAL_API_BASE_URL ?? 'https://api.motorsignal.app'; + + try { + const response = await fetch(`${baseUrl}/api/v1/ops/users/${encodeURIComponent(userId)}/reset-password`, { + method: 'POST', + headers: { + authorization: `Bearer ${opsToken}`, + 'content-type': 'application/json', + accept: 'application/json', + }, + body: JSON.stringify(body), + cache: 'no-store', + }); + + if (response.status === 401) { + return NextResponse.json( + { detail: 'Your Operations session has expired.' }, + { status: 401 } + ); + } + if (response.status === 403) { + const data = await response.json().catch(() => null); + return NextResponse.json( + { detail: data?.detail ?? 'You do not have authority to reset user passwords.' }, + { status: 403 } + ); + } + if (response.status === 404) { + return NextResponse.json( + { detail: 'User not found.' }, + { status: 404 } + ); + } + if (response.status === 422) { + const data = await response.json().catch(() => null); + return NextResponse.json( + { detail: data?.detail ?? 'Invalid request fields.' }, + { status: 422 } + ); + } + if (!response.ok) { + return NextResponse.json( + { detail: 'Operations is temporarily unavailable.' }, + { status: response.status >= 500 ? response.status : 503 } + ); + } + + const data = await response.json(); + return NextResponse.json(data); + } catch { + return NextResponse.json( + { detail: 'Unable to connect to Operations platform.' }, + { status: 503 } + ); + } +} diff --git a/website/app/api/ops/users/[userId]/revoke-sessions/route.ts b/website/app/api/ops/users/[userId]/revoke-sessions/route.ts new file mode 100644 index 0000000..791561e --- /dev/null +++ b/website/app/api/ops/users/[userId]/revoke-sessions/route.ts @@ -0,0 +1,96 @@ +import { cookies } from 'next/headers'; +import { NextRequest, NextResponse } from 'next/server'; +import { hasValidOrigin } from '../../../../../../lib/request-origin'; + +interface RouteContext { + params: Promise<{ + userId: string; + }>; +} + +export async function POST(request: NextRequest, context: RouteContext) { + if (!hasValidOrigin(request)) { + return NextResponse.json({ detail: 'Invalid request origin.' }, { status: 403 }); + } + + const cookieStore = await cookies(); + const isProduction = process.env.NODE_ENV === 'production' && process.env.NEXT_PUBLIC_APP_ENV === 'production'; + const opsToken = isProduction + ? cookieStore.get('__Host-motorsignal_ops')?.value + : cookieStore.get('__Host-motorsignal_ops')?.value ?? + cookieStore.get('motorsignal_ops')?.value; + + if (!opsToken) { + return NextResponse.json( + { detail: 'Authentication required.' }, + { status: 401 } + ); + } + + const { userId } = await context.params; + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json( + { detail: 'Invalid request body.' }, + { status: 422 } + ); + } + + const baseUrl = process.env.MOTORSIGNAL_API_BASE_URL ?? 'https://api.motorsignal.app'; + + try { + const response = await fetch(`${baseUrl}/api/v1/ops/users/${encodeURIComponent(userId)}/revoke-sessions`, { + method: 'POST', + headers: { + authorization: `Bearer ${opsToken}`, + 'content-type': 'application/json', + accept: 'application/json', + }, + body: JSON.stringify(body), + cache: 'no-store', + }); + + if (response.status === 401) { + return NextResponse.json( + { detail: 'Your Operations session has expired.' }, + { status: 401 } + ); + } + if (response.status === 403) { + const data = await response.json().catch(() => null); + return NextResponse.json( + { detail: data?.detail ?? 'You do not have authority to revoke sessions.' }, + { status: 403 } + ); + } + if (response.status === 404) { + return NextResponse.json( + { detail: 'User not found.' }, + { status: 404 } + ); + } + if (response.status === 422) { + const data = await response.json().catch(() => null); + return NextResponse.json( + { detail: data?.detail ?? 'Invalid request fields.' }, + { status: 422 } + ); + } + if (!response.ok) { + return NextResponse.json( + { detail: 'Operations is temporarily unavailable.' }, + { status: response.status >= 500 ? response.status : 503 } + ); + } + + const data = await response.json(); + return NextResponse.json(data); + } catch { + return NextResponse.json( + { detail: 'Unable to connect to Operations platform.' }, + { status: 503 } + ); + } +} diff --git a/website/app/api/ops/users/[userId]/route.ts b/website/app/api/ops/users/[userId]/route.ts new file mode 100644 index 0000000..51b0965 --- /dev/null +++ b/website/app/api/ops/users/[userId]/route.ts @@ -0,0 +1,71 @@ +import { cookies } from 'next/headers'; +import { NextRequest, NextResponse } from 'next/server'; + +interface RouteContext { + params: Promise<{ + userId: string; + }>; +} + +export async function GET(request: NextRequest, context: RouteContext) { + const cookieStore = await cookies(); + const isProduction = process.env.NODE_ENV === 'production' && process.env.NEXT_PUBLIC_APP_ENV === 'production'; + const opsToken = isProduction + ? cookieStore.get('__Host-motorsignal_ops')?.value + : cookieStore.get('__Host-motorsignal_ops')?.value ?? + cookieStore.get('motorsignal_ops')?.value; + + if (!opsToken) { + return NextResponse.json( + { detail: 'Authentication required.' }, + { status: 401 } + ); + } + + const { userId } = await context.params; + const baseUrl = process.env.MOTORSIGNAL_API_BASE_URL ?? 'https://api.motorsignal.app'; + + try { + const response = await fetch(`${baseUrl}/api/v1/ops/users/${encodeURIComponent(userId)}`, { + method: 'GET', + headers: { + authorization: `Bearer ${opsToken}`, + accept: 'application/json', + }, + cache: 'no-store', + }); + + if (response.status === 401) { + return NextResponse.json( + { detail: 'Your Operations session has expired.' }, + { status: 401 } + ); + } + if (response.status === 403) { + return NextResponse.json( + { detail: 'You do not have authority to view this User.' }, + { status: 403 } + ); + } + if (response.status === 404) { + return NextResponse.json( + { detail: 'User not found.' }, + { status: 404 } + ); + } + if (!response.ok) { + return NextResponse.json( + { detail: 'Operations is temporarily unavailable.' }, + { status: response.status >= 500 ? response.status : 503 } + ); + } + + const data = await response.json(); + return NextResponse.json(data); + } catch { + return NextResponse.json( + { detail: 'Unable to connect to Operations platform.' }, + { status: 503 } + ); + } +} diff --git a/website/app/api/ops/users/[userId]/status/route.ts b/website/app/api/ops/users/[userId]/status/route.ts new file mode 100644 index 0000000..75ee24d --- /dev/null +++ b/website/app/api/ops/users/[userId]/status/route.ts @@ -0,0 +1,96 @@ +import { cookies } from 'next/headers'; +import { NextRequest, NextResponse } from 'next/server'; +import { hasValidOrigin } from '../../../../../../lib/request-origin'; + +interface RouteContext { + params: Promise<{ + userId: string; + }>; +} + +export async function POST(request: NextRequest, context: RouteContext) { + if (!hasValidOrigin(request)) { + return NextResponse.json({ detail: 'Invalid request origin.' }, { status: 403 }); + } + + const cookieStore = await cookies(); + const isProduction = process.env.NODE_ENV === 'production' && process.env.NEXT_PUBLIC_APP_ENV === 'production'; + const opsToken = isProduction + ? cookieStore.get('__Host-motorsignal_ops')?.value + : cookieStore.get('__Host-motorsignal_ops')?.value ?? + cookieStore.get('motorsignal_ops')?.value; + + if (!opsToken) { + return NextResponse.json( + { detail: 'Authentication required.' }, + { status: 401 } + ); + } + + const { userId } = await context.params; + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json( + { detail: 'Invalid request body.' }, + { status: 422 } + ); + } + + const baseUrl = process.env.MOTORSIGNAL_API_BASE_URL ?? 'https://api.motorsignal.app'; + + try { + const response = await fetch(`${baseUrl}/api/v1/ops/users/${encodeURIComponent(userId)}/status`, { + method: 'POST', + headers: { + authorization: `Bearer ${opsToken}`, + 'content-type': 'application/json', + accept: 'application/json', + }, + body: JSON.stringify(body), + cache: 'no-store', + }); + + if (response.status === 401) { + return NextResponse.json( + { detail: 'Your Operations session has expired.' }, + { status: 401 } + ); + } + if (response.status === 403) { + const data = await response.json().catch(() => null); + return NextResponse.json( + { detail: data?.detail ?? 'You do not have authority to update user status.' }, + { status: 403 } + ); + } + if (response.status === 404) { + return NextResponse.json( + { detail: 'User not found.' }, + { status: 404 } + ); + } + if (response.status === 422) { + const data = await response.json().catch(() => null); + return NextResponse.json( + { detail: data?.detail ?? 'Invalid user status fields.' }, + { status: 422 } + ); + } + if (!response.ok) { + return NextResponse.json( + { detail: 'Operations is temporarily unavailable.' }, + { status: response.status >= 500 ? response.status : 503 } + ); + } + + const data = await response.json(); + return NextResponse.json(data); + } catch { + return NextResponse.json( + { detail: 'Unable to connect to Operations platform.' }, + { status: 503 } + ); + } +} diff --git a/website/app/api/ops/users/route.ts b/website/app/api/ops/users/route.ts new file mode 100644 index 0000000..5b82673 --- /dev/null +++ b/website/app/api/ops/users/route.ts @@ -0,0 +1,71 @@ +import { cookies } from 'next/headers'; +import { NextRequest, NextResponse } from 'next/server'; + +export async function GET(request: NextRequest) { + const cookieStore = await cookies(); + const isProduction = process.env.NODE_ENV === 'production' && process.env.NEXT_PUBLIC_APP_ENV === 'production'; + const opsToken = isProduction + ? cookieStore.get('__Host-motorsignal_ops')?.value + : cookieStore.get('__Host-motorsignal_ops')?.value ?? + cookieStore.get('motorsignal_ops')?.value; + + if (!opsToken) { + return NextResponse.json( + { detail: 'Authentication required.' }, + { status: 401 } + ); + } + + const searchParams = request.nextUrl.searchParams; + const q = searchParams.get('q'); + const cursor = searchParams.get('cursor'); + const limit = searchParams.get('limit'); + const statusFilter = searchParams.get('status_filter'); + const verifiedFilter = searchParams.get('verified_filter'); + + const baseUrl = process.env.MOTORSIGNAL_API_BASE_URL ?? 'https://api.motorsignal.app'; + const targetUrl = new URL(`${baseUrl}/api/v1/ops/users`); + if (q) targetUrl.searchParams.set('q', q); + if (cursor) targetUrl.searchParams.set('cursor', cursor); + if (limit) targetUrl.searchParams.set('limit', limit); + if (statusFilter) targetUrl.searchParams.set('status_filter', statusFilter); + if (verifiedFilter) targetUrl.searchParams.set('verified_filter', verifiedFilter); + + try { + const response = await fetch(targetUrl.toString(), { + method: 'GET', + headers: { + authorization: `Bearer ${opsToken}`, + accept: 'application/json', + }, + cache: 'no-store', + }); + + if (response.status === 401) { + return NextResponse.json( + { detail: 'Your Operations session has expired.' }, + { status: 401 } + ); + } + if (response.status === 403) { + return NextResponse.json( + { detail: 'You do not have authority to view Users.' }, + { status: 403 } + ); + } + if (!response.ok) { + return NextResponse.json( + { detail: 'Operations is temporarily unavailable.' }, + { status: response.status >= 500 ? response.status : 503 } + ); + } + + const data = await response.json(); + return NextResponse.json(data); + } catch { + return NextResponse.json( + { detail: 'Unable to connect to Operations platform.' }, + { status: 503 } + ); + } +} diff --git a/website/components/ops/operations-audit.tsx b/website/components/ops/operations-audit.tsx index 94b1262..9f805cc 100644 --- a/website/components/ops/operations-audit.tsx +++ b/website/components/ops/operations-audit.tsx @@ -32,8 +32,8 @@ export function OperationsAudit({ lock
- - ACCESS RESTRICTED + + Access Restricted

Owner Authority Required @@ -44,7 +44,7 @@ export function OperationsAudit({

Current Role: - + {role}
@@ -57,23 +57,23 @@ export function OperationsAudit({ {/* Header Row */}
- - GOVERNANCE & COMPLIANCE + + Governance & Compliance -

+

Audit Activity

-

+

Immutable operational and security activity log across platform boundaries.

{total !== undefined && (
- + Recorded Events - + {total.toLocaleString()}
@@ -81,31 +81,31 @@ export function OperationsAudit({
{/* Audit Activity Table Container */} -
+
- - + - - - - - @@ -169,16 +169,16 @@ export function OperationsAudit({ className="hover:bg-surface-container-high/40 transition-colors" > {/* TIME */} - {/* ACTION */} - {/* RESULT */} - {/* TARGET */} - {/* CASE */} - diff --git a/website/components/ops/operations-cms.tsx b/website/components/ops/operations-cms.tsx new file mode 100644 index 0000000..d25e3b4 --- /dev/null +++ b/website/components/ops/operations-cms.tsx @@ -0,0 +1,1611 @@ +'use client'; + +import React, { useState, useEffect, useCallback } from 'react'; +import { + OperationsRole, + CmsDocumentSummary, + CmsDocumentDetail, + CmsRevisionItem, +} from './types'; + +interface OperationsCmsProps { + role: OperationsRole; +} + +const DOCUMENT_SECTIONS = [ + { slug: 'homepage_hero', label: 'Homepage Hero', icon: 'splitscreen', desc: 'Hero headline, copy, and primary CTA' }, + { slug: 'features', label: 'Key Features', icon: 'featured_play_list', desc: '20Hz telemetry & sensor fusion cards' }, + { slug: 'screenshots', label: 'Screenshots', icon: 'screenshot_monitor', desc: 'App gallery visuals and captions' }, + { slug: 'faqs', label: 'FAQs', icon: 'help_outline', desc: 'Mounting & offline recording guidelines' }, + { slug: 'download_links', label: 'Download Links', icon: 'download', desc: 'APK, Play Store, and build versions' }, + { slug: 'changelog', label: 'Changelog', icon: 'history', desc: 'Release versions, dates, and notes' }, + { slug: 'policy_pages', label: 'Legal & Policies', icon: 'policy', desc: 'Privacy policy & terms of service' }, +] as const; + +export function OperationsCms({ role: _role }: OperationsCmsProps) { + // Document list & selection state + const [documents, setDocuments] = useState([]); + const [selectedSlug, setSelectedSlug] = useState('homepage_hero'); + const [activeDoc, setActiveDoc] = useState(null); + const [isLoadingList, setIsLoadingList] = useState(true); + const [isLoadingDetail, setIsLoadingDetail] = useState(false); + const [isRefreshing, setIsRefreshing] = useState(false); + const [errorBanner, setErrorBanner] = useState(null); + const [successBanner, setSuccessBanner] = useState(null); + + // Editor form state + const [editMode, setEditMode] = useState<'structured' | 'json'>('structured'); + const [workingContent, setWorkingContent] = useState>({}); + const [rawJsonText, setRawJsonText] = useState('{}'); + const [jsonError, setJsonError] = useState(null); + const [changeSummary, setChangeSummary] = useState('Content updated'); + const [isSubmitting, setIsSubmitting] = useState(false); + + // Preview modal state + const [isPreviewOpen, setIsPreviewOpen] = useState(false); + + // Revision history modal & rollback state + const [isHistoryOpen, setIsHistoryOpen] = useState(false); + const [selectedRevisionForRollback, setSelectedRevisionForRollback] = useState(null); + const [rollbackReason, setRollbackReason] = useState(''); + const [isRollingBack, setIsRollingBack] = useState(false); + const [rollbackError, setRollbackError] = useState(null); + + // Fetch document list + const fetchDocuments = useCallback(async (isManual = false) => { + if (isManual) setIsRefreshing(true); + else setIsLoadingList(true); + setErrorBanner(null); + + try { + const response = await fetch('/api/ops/cms/documents', { + method: 'GET', + headers: { accept: 'application/json' }, + cache: 'no-store', + }); + + if (response.status === 401) { + setErrorBanner('Your Operations session has expired.'); + return; + } + if (response.status === 403) { + setErrorBanner('You do not have authority to view website content.'); + return; + } + if (!response.ok) { + setErrorBanner('Failed to load CMS documents from platform.'); + return; + } + + const data = await response.json(); + if (Array.isArray(data)) { + setDocuments(data); + } + } catch { + setErrorBanner('Network error connecting to Operations CMS service.'); + } finally { + setIsLoadingList(false); + setIsRefreshing(false); + } + }, []); + + // Fetch single document detail + const fetchDocumentDetail = useCallback(async (slug: string) => { + setIsLoadingDetail(true); + setErrorBanner(null); + setSuccessBanner(null); + + try { + const response = await fetch(`/api/ops/cms/documents/${encodeURIComponent(slug)}`, { + method: 'GET', + headers: { accept: 'application/json' }, + cache: 'no-store', + }); + + if (response.status === 401) { + setErrorBanner('Your Operations session has expired.'); + return; + } + if (response.status === 403) { + setErrorBanner('You do not have authority to view this content document.'); + return; + } + if (response.status === 404) { + setErrorBanner('CMS document not found.'); + return; + } + if (!response.ok) { + setErrorBanner('Failed to load content details.'); + return; + } + + const data: CmsDocumentDetail = await response.json(); + setActiveDoc(data); + + // Initialize working content from draft if present, else published + const initialContent = data.draft ? JSON.parse(JSON.stringify(data.draft)) : JSON.parse(JSON.stringify(data.published)); + setWorkingContent(initialContent); + setRawJsonText(JSON.stringify(initialContent, null, 2)); + setJsonError(null); + setChangeSummary('Content updated'); + } catch { + setErrorBanner('Network error loading document detail.'); + } finally { + setIsLoadingDetail(false); + } + }, []); + + // Initial load + useEffect(() => { + void fetchDocuments(); + }, [fetchDocuments]); + + // Load detail whenever selectedSlug changes + useEffect(() => { + if (selectedSlug) { + void fetchDocumentDetail(selectedSlug); + } + }, [selectedSlug, fetchDocumentDetail]); + + // Update working content and sync JSON string + const updateContent = (updater: (prev: Record) => Record) => { + setWorkingContent((prev) => { + const next = updater(prev); + setRawJsonText(JSON.stringify(next, null, 2)); + setJsonError(null); + return next; + }); + }; + + // Handle raw JSON textarea changes + const handleJsonChange = (e: React.ChangeEvent) => { + const text = e.target.value; + setRawJsonText(text); + try { + const parsed = JSON.parse(text); + if (typeof parsed === 'object' && parsed !== null) { + setWorkingContent(parsed); + setJsonError(null); + } else { + setJsonError('JSON root must be an object.'); + } + } catch (err: any) { + setJsonError(err.message ?? 'Invalid JSON syntax'); + } + }; + + // Save draft or publish live + const handleSaveOrPublish = async (action: 'draft' | 'publish') => { + if (jsonError) { + setErrorBanner('Please fix JSON syntax errors before submitting.'); + return; + } + + setIsSubmitting(true); + setErrorBanner(null); + setSuccessBanner(null); + + try { + const response = await fetch(`/api/ops/cms/documents/${encodeURIComponent(selectedSlug)}`, { + method: 'PUT', + headers: { + 'content-type': 'application/json', + accept: 'application/json', + }, + body: JSON.stringify({ + action, + content: workingContent, + change_summary: changeSummary.trim() || (action === 'draft' ? 'Draft saved' : 'Published live'), + }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => null); + setErrorBanner(errorData?.detail ?? `Failed to ${action} changes.`); + return; + } + + const updatedDoc: CmsDocumentDetail = await response.json(); + setActiveDoc(updatedDoc); + setSuccessBanner( + action === 'publish' + ? 'Content successfully published live!' + : 'Draft successfully saved! Pending review.' + ); + + // Re-fetch summaries to update "has_draft" badges + void fetchDocuments(false); + } catch { + setErrorBanner(`Network error attempting to ${action} changes.`); + } finally { + setIsSubmitting(false); + } + }; + + // Rollback to revision + const handleRollbackSubmit = async () => { + if (!selectedRevisionForRollback || rollbackReason.trim().length < 3) { + setRollbackError('Rollback reason must be at least 3 characters.'); + return; + } + + setIsRollingBack(true); + setRollbackError(null); + + try { + const response = await fetch( + `/api/ops/cms/documents/${encodeURIComponent(selectedSlug)}/rollback`, + { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json', + }, + body: JSON.stringify({ + revision_id: selectedRevisionForRollback.revision_id, + reason: rollbackReason.trim(), + }), + } + ); + + if (!response.ok) { + const errorData = await response.json().catch(() => null); + setRollbackError(errorData?.detail ?? 'Failed to restore revision.'); + return; + } + + const updatedDoc: CmsDocumentDetail = await response.json(); + setActiveDoc(updatedDoc); + const initialContent = updatedDoc.published; + setWorkingContent(initialContent); + setRawJsonText(JSON.stringify(initialContent, null, 2)); + + setSelectedRevisionForRollback(null); + setRollbackReason(''); + setIsHistoryOpen(false); + setSuccessBanner(`Successfully restored revision ${selectedRevisionForRollback.revision_id}`); + + // Re-fetch summaries + void fetchDocuments(false); + } catch { + setRollbackError('Network error during rollback.'); + } finally { + setIsRollingBack(false); + } + }; + + // Keyboard accessibility for modals + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + if (selectedRevisionForRollback) { + setSelectedRevisionForRollback(null); + } else if (isHistoryOpen) { + setIsHistoryOpen(false); + } else if (isPreviewOpen) { + setIsPreviewOpen(false); + } + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [selectedRevisionForRollback, isHistoryOpen, isPreviewOpen]); + + const hasDraftPending = activeDoc ? activeDoc.draft !== null : false; + + return ( +
+ {/* Header Bar */} +
+
+ + Content Management System + +
+ + edit_note + +

+ Website Content CMS +

+
+

+ Manage public website copy, hero positioning, feature lists, download links, and legal policies. +

+
+ + {/* Refresh & Quick Actions */} +
+ +
+
+ + {/* Banners */} + {errorBanner && ( +
+ + error + +
{errorBanner}
+ +
+ )} + + {successBanner && ( +
+ + check_circle + +
{successBanner}
+ +
+ )} + + {/* Main Grid: Document Navigation + Editor */} +
+ {/* Document Navigation Tabs (Sidebar on Desktop, Horizontal Scroller on Mobile) */} + + + {/* Main Editor Body */} +
+ {isLoadingDetail ? ( +
+ + progress_activity + + Loading content document... +
+ ) : activeDoc ? ( +
+ {/* Document Status Header */} +
+
+
+

+ {activeDoc.title} +

+ {hasDraftPending ? ( + + Draft Pending + + ) : ( + + Published + + )} +
+

+ {activeDoc.description} +

+
+ + Updated: {new Date(activeDoc.updated_at).toLocaleString()} + + • + By: {activeDoc.updated_by} + • + Revisions: {activeDoc.revisions?.length ?? 0} +
+
+ + {/* View/Edit Mode Toggles */} +
+ + +
+
+ + {/* Action Toolbar */} +
+
+ + + +
+ +
+ + + +
+
+ + {/* Change Summary Field */} +
+ + setChangeSummary(e.target.value)} + placeholder="e.g. Updated 20Hz sensor copy for iOS 18 release" + className="w-full px-3 py-2 rounded-lg bg-surface-container-low border border-outline-variant/30 text-on-surface text-xs placeholder:text-outline focus:outline-none focus:border-primary-container" + /> +
+ + {/* Editor Mode: Structured Form vs Raw JSON */} + {editMode === 'json' ? ( +
+
+ Direct Document Payload (JSON) + {jsonError && ( + {jsonError} + )} +
+
- TIME +
+ Time - ACTION + + Action - RESULT + + Result - ACTOR + + Actor - TARGET + + Target - CASE + + Case
+ +
- {event.action} + {event.action} {event.reason_code && ( - + reason: {event.reason_code} )} @@ -186,9 +186,9 @@ export function OperationsAudit({
+ {/* ACTOR (Current Operations Identity) */} - +
- + {event.actor_identity} {event.actor_role && ( - + Role: {event.actor_role} )} @@ -215,12 +215,12 @@ export function OperationsAudit({
+ {event.target_resource || '--'} + {event.case_reference || '--'}