diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index ab7f077..46afdbf 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -42,4 +42,4 @@ jobs: - name: Run isolated API and Operations unit tests run: | - pytest tests/test_ops_support.py tests/test_ops_incidents.py tests/test_legacy_admin_retirement.py -v + pytest tests/test_ops_support.py tests/test_ops_incidents.py tests/test_legacy_admin_retirement.py tests/test_ops_actions.py -v diff --git a/.github/workflows/website-ci.yml b/.github/workflows/website-ci.yml index ac5f14b..9681b4a 100644 --- a/.github/workflows/website-ci.yml +++ b/.github/workflows/website-ci.yml @@ -40,6 +40,9 @@ jobs: MOTORSIGNAL_GIT_SHA: ${{ github.sha }} run: npm run build + - name: Run React component tests + run: npm run test:components + - name: Run Founder Portal tests run: node tests/founder-portal.test.cjs diff --git a/backend/motorsignal_api/alembic/versions/f1a2b3c4d5e7_add_ops_actions.py b/backend/motorsignal_api/alembic/versions/f1a2b3c4d5e7_add_ops_actions.py new file mode 100644 index 0000000..2ef45be --- /dev/null +++ b/backend/motorsignal_api/alembic/versions/f1a2b3c4d5e7_add_ops_actions.py @@ -0,0 +1,50 @@ +"""add ops actions + +Revision ID: f1a2b3c4d5e7 +Revises: f1a2b3c4d5e6 +Create Date: 2026-09-20 +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID + +# revision identifiers, used by Alembic. +revision = 'f1a2b3c4d5e7' +down_revision = 'f1a2b3c4d5e6' +branch_labels = None +depends_on = None + +def upgrade() -> None: + op.create_table( + 'ops_actions', + sa.Column('id', UUID(as_uuid=True), primary_key=True), + sa.Column('action_type', sa.String(255), nullable=False), + sa.Column('requested_by_id', UUID(as_uuid=True), sa.ForeignKey('ops.operators.id', ondelete='RESTRICT'), nullable=False), + sa.Column('request_timestamp', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column('start_timestamp', sa.DateTime(timezone=True), nullable=True), + sa.Column('completion_timestamp', sa.DateTime(timezone=True), nullable=True), + sa.Column('state', sa.String(50), server_default='queued', nullable=False), + sa.Column('status_message', sa.Text(), nullable=True), + sa.Column('deployed_revision', sa.String(255), nullable=True), + sa.Column('target_revision', sa.String(255), nullable=True), + sa.Column('preflight_result', sa.Text(), nullable=True), + sa.Column('health_result', sa.Text(), nullable=True), + sa.Column('rollback_status', sa.Text(), nullable=True), + sa.Column('failure_code', sa.String(255), nullable=True), + sa.Column('idempotency_key', sa.String(255), nullable=True), + schema='ops' + ) + # Ensure idempotency_key is unique to prevent duplicate processing + op.create_index( + 'ix_ops_actions_idempotency_key', + 'ops_actions', + ['requested_by_id', 'action_type', 'idempotency_key'], + unique=True, + schema='ops', + postgresql_where=sa.text('idempotency_key IS NOT NULL') + ) + +def downgrade() -> None: + op.drop_index('ix_ops_actions_idempotency_key', table_name='ops_actions', schema='ops') + op.drop_table('ops_actions', schema='ops') \ No newline at end of file diff --git a/backend/motorsignal_api/app/main.py b/backend/motorsignal_api/app/main.py index 7fbff69..2120775 100644 --- a/backend/motorsignal_api/app/main.py +++ b/backend/motorsignal_api/app/main.py @@ -10,7 +10,7 @@ from typing import Literal from email_validator import EmailNotValidError, validate_email from fastapi import Depends, FastAPI, Header, HTTPException, Query, Request, status -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, field_validator from sqlalchemy import func, or_, select, text from sqlalchemy.orm import Session as DbSession @@ -29,6 +29,7 @@ from auth import ( ) from db import check_database, engine from models import ( + OpsAction, AuditEvent, AuthRateLimit, EmailVerificationToken, @@ -1078,6 +1079,30 @@ class OpsSupportDiagnosticBundle(BaseModel): submitted_at: str + +class OpsActionCreate(BaseModel): + model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) + action_type: Literal["deploy_website", "restart_website", "restart_api", "reboot_server"] + idempotency_key: str | None = Field(default=None, min_length=8, max_length=128) + confirmation_text: str | None = Field(default=None, max_length=64) + +class OpsActionRead(BaseModel): + id: uuid.UUID + action_type: str + requested_by_id: uuid.UUID + request_timestamp: datetime + start_timestamp: datetime | None + completion_timestamp: datetime | None + state: str + status_message: str | None + deployed_revision: str | None + target_revision: str | None + preflight_result: str | None + health_result: str | None + rollback_status: str | None + failure_code: str | None + idempotency_key: str | None + class OpsSupportTicketCreate(BaseModel): model_config = ConfigDict(extra="forbid") ticket_type: str = Field(default="general") @@ -1087,6 +1112,22 @@ class OpsSupportTicketCreate(BaseModel): severity: str = Field(default="medium") diagnostic_bundle: OpsSupportDiagnosticBundle | dict | None = None + @field_validator("reporter_email") + @classmethod + def validate_reporter_email(cls, v: str) -> str: + s = v.strip() + if len(s) < 3: + raise ValueError("Reporter email must not be empty or whitespace only.") + return s + + @field_validator("subject", "description") + @classmethod + def validate_non_whitespace(cls, v: str) -> str: + s = v.strip() + if not s: + raise ValueError("Field cannot be empty or contain only whitespace.") + return s + class OpsSupportTicketPatch(BaseModel): model_config = ConfigDict(extra="forbid") @@ -2425,6 +2466,54 @@ def ops_get_support_ticket( } + +@app.post("/api/v1/ops/actions", response_model=OpsActionRead) +def ops_create_action( + req: OpsActionCreate, + db: DbSession = Depends(get_db), + auth: tuple[Operator, OpsSession] = Depends(require_ops_roles("owner")), +): + operator, session = auth + + if req.action_type in ("reboot_server", "restart_api"): + expected = "REBOOT MOTORSIGNAL SERVER" if req.action_type == "reboot_server" else "RESTART API" + if req.confirmation_text != expected: + raise HTTPException(status_code=422, detail="Invalid confirmation text.") + + # We explicitly implement a disabled control plane per the instructions + # Check control-plane availability before performing any idempotency lookup. + audit = OpsAuditEvent( + action="action_rejected", + actor_identity=operator.identity, + actor_role=operator.role, + target_resource="unavailable_control_plane", + result="denied", + metadata_json={"action_type": req.action_type, "status": "rejected_unavailable"}, + ) + db.add(audit) + db.commit() + raise HTTPException(status_code=503, detail="Action control plane is currently unconfigured. Missing ops-action-helper.sh.") + +@app.get("/api/v1/ops/actions", response_model=list[OpsActionRead]) +def ops_list_actions( + db: DbSession = Depends(get_db), + auth: tuple[Operator, OpsSession] = Depends(require_ops_roles("operator", "owner")), + limit: int = Query(default=50, ge=1, le=100), + offset: int = Query(default=0, ge=0), +): + return db.scalars(select(OpsAction).order_by(OpsAction.request_timestamp.desc(), OpsAction.id.desc()).offset(offset).limit(limit)).all() + +@app.get("/api/v1/ops/actions/{action_id}", response_model=OpsActionRead) +def ops_get_action( + action_id: uuid.UUID, + db: DbSession = Depends(get_db), + auth: tuple[Operator, OpsSession] = Depends(require_ops_roles("operator", "owner")), +): + action = db.get(OpsAction, action_id) + if not action: + raise HTTPException(status_code=404, detail="Action not found") + return action + @app.post("/api/v1/ops/support/tickets", status_code=201) def ops_create_support_ticket( request: OpsSupportTicketCreate, diff --git a/backend/motorsignal_api/app/models.py b/backend/motorsignal_api/app/models.py index 39c0052..f15eebe 100644 --- a/backend/motorsignal_api/app/models.py +++ b/backend/motorsignal_api/app/models.py @@ -1,7 +1,7 @@ import uuid from datetime import datetime -from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, String +from sqlalchemy import JSON, Index, Boolean, DateTime, ForeignKey, String, func, Text, text from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship @@ -378,3 +378,31 @@ class OpsIncidentNote(Base): body: Mapped[str] = mapped_column(String, nullable=False) incident: Mapped["OpsIncident"] = relationship(back_populates="notes") + +class OpsAction(Base): + __tablename__ = "ops_actions" + __table_args__ = ( + Index( + "ix_ops_actions_idempotency_key", + "requested_by_id", "action_type", "idempotency_key", + unique=True, + postgresql_where=text("idempotency_key IS NOT NULL"), + ), + {"schema": "ops"}, + ) + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + action_type: Mapped[str] = mapped_column(String(255), nullable=False) + requested_by_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("ops.operators.id", ondelete="RESTRICT"), nullable=False) + request_timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) + start_timestamp: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + completion_timestamp: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + state: Mapped[str] = mapped_column(String(50), server_default="queued", nullable=False) + status_message: Mapped[str | None] = mapped_column(Text, nullable=True) + deployed_revision: Mapped[str | None] = mapped_column(String(255), nullable=True) + target_revision: Mapped[str | None] = mapped_column(String(255), nullable=True) + preflight_result: Mapped[str | None] = mapped_column(Text, nullable=True) + health_result: Mapped[str | None] = mapped_column(Text, nullable=True) + rollback_status: Mapped[str | None] = mapped_column(Text, nullable=True) + failure_code: Mapped[str | None] = mapped_column(String(255), nullable=True) + idempotency_key: Mapped[str | None] = mapped_column(String(255), nullable=True) diff --git a/backend/motorsignal_api/tests/test_ops_actions.py b/backend/motorsignal_api/tests/test_ops_actions.py new file mode 100644 index 0000000..4944a3d --- /dev/null +++ b/backend/motorsignal_api/tests/test_ops_actions.py @@ -0,0 +1,296 @@ +import hashlib +import os +import sys +import unittest +import uuid +from datetime import datetime, timedelta, timezone +from time import sleep + +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 +from models import Base, Operator, OpsAuditEvent, OpsSession, OpsAction + +def hash_token(raw_token: str) -> str: + return hashlib.sha256(raw_token.encode()).hexdigest() + +class TestOpsActions(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.db_path = os.path.join(ROOT, "tests", "test_ops_actions.db") + cls.ops_db_path = os.path.join(ROOT, "tests", "test_ops_actions_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(OpsAction).delete() + db.query(OpsSession).delete() + db.query(Operator).delete() + db.commit() + + self.op_id = uuid.uuid4() + self.op_identity = "owner@motorsignal.internal" + self.op_token = "valid-owner-token-123" + op = Operator( + id=self.op_id, + identity=self.op_identity, + role="owner", + 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), + ) + + self.expired_owner_token = "expired-owner-token-123" + op_session_expired = OpsSession( + operator_id=self.op_id, + session_token_hash=hash_token(self.expired_owner_token), + expires_at=datetime.now(timezone.utc) - timedelta(hours=8), + created_at=datetime.now(timezone.utc) - timedelta(hours=16), + ) + + self.operator_id = uuid.uuid4() + self.operator_identity = "operator@motorsignal.internal" + self.operator_token = "valid-operator-token-123" + op_reg = Operator( + id=self.operator_id, + identity=self.operator_identity, + role="operator", + status="active", + created_at=datetime.now(timezone.utc), + ) + op_session_reg = OpsSession( + operator_id=self.operator_id, + session_token_hash=hash_token(self.operator_token), + expires_at=datetime.now(timezone.utc) + timedelta(hours=8), + created_at=datetime.now(timezone.utc), + ) + + db.add(op) + db.add(op_session) + db.add(op_session_expired) + db.add(op_reg) + db.add(op_session_reg) + db.commit() + + self.owner_headers = {"Authorization": f"Bearer {self.op_token}"} + self.expired_owner_headers = {"Authorization": f"Bearer {self.expired_owner_token}"} + self.operator_headers = {"Authorization": f"Bearer {self.operator_token}"} + + def test_unauthenticated_rejection(self): + resp = self.client.post("/api/v1/ops/actions", json={"action_type": "deploy_website"}) + self.assertEqual(resp.status_code, 401) + + def test_expired_session_rejection(self): + resp = self.client.post("/api/v1/ops/actions", json={"action_type": "deploy_website"}, headers=self.expired_owner_headers) + self.assertEqual(resp.status_code, 401) + + def test_operator_rejection(self): + resp = self.client.post("/api/v1/ops/actions", json={"action_type": "deploy_website"}, headers=self.operator_headers) + self.assertEqual(resp.status_code, 403) + + def test_owner_access(self): + resp = self.client.get("/api/v1/ops/actions", headers=self.owner_headers) + self.assertEqual(resp.status_code, 200) + + def test_unknown_action_rejection(self): + resp = self.client.post("/api/v1/ops/actions", json={"action_type": "invalid_type"}, headers=self.owner_headers) + self.assertEqual(resp.status_code, 422) + + def test_unexpected_field_rejection(self): + resp = self.client.post( + "/api/v1/ops/actions", + json={"action_type": "deploy_website", "extra_field": "test"}, + headers=self.owner_headers + ) + self.assertEqual(resp.status_code, 422) + + def test_idempotency_key_bounds(self): + # Too short + resp = self.client.post("/api/v1/ops/actions", json={"action_type": "deploy_website", "idempotency_key": "1234567"}, headers=self.owner_headers) + self.assertEqual(resp.status_code, 422) + # Too long + resp = self.client.post("/api/v1/ops/actions", json={"action_type": "deploy_website", "idempotency_key": "a" * 129}, headers=self.owner_headers) + self.assertEqual(resp.status_code, 422) + + def test_confirmation_text_bounds(self): + resp = self.client.post("/api/v1/ops/actions", json={"action_type": "restart_api", "confirmation_text": "a" * 65}, headers=self.owner_headers) + self.assertEqual(resp.status_code, 422) + + def test_exact_api_restart_confirmation(self): + resp = self.client.post("/api/v1/ops/actions", json={"action_type": "restart_api"}, headers=self.owner_headers) + self.assertEqual(resp.status_code, 422) + resp = self.client.post("/api/v1/ops/actions", json={"action_type": "restart_api", "confirmation_text": "RESTART API"}, headers=self.owner_headers) + self.assertEqual(resp.status_code, 503) + + def test_exact_reboot_confirmation(self): + resp = self.client.post("/api/v1/ops/actions", json={"action_type": "reboot_server"}, headers=self.owner_headers) + self.assertEqual(resp.status_code, 422) + resp = self.client.post("/api/v1/ops/actions", json={"action_type": "reboot_server", "confirmation_text": "REBOOT MOTORSIGNAL SERVER"}, headers=self.owner_headers) + self.assertEqual(resp.status_code, 503) + + def test_unavailable_control_plane_all_actions(self): + actions_with_confirm = [ + ("deploy_website", None), + ("restart_website", None), + ("restart_api", "RESTART API"), + ("reboot_server", "REBOOT MOTORSIGNAL SERVER"), + ] + + for act, conf in actions_with_confirm: + with DbSession(self.engine) as db: + db.query(OpsAuditEvent).delete() + db.commit() + + payload = {"action_type": act} + if conf: + payload["confirmation_text"] = conf + + resp = self.client.post("/api/v1/ops/actions", json=payload, headers=self.owner_headers) + self.assertEqual(resp.status_code, 503) + self.assertIn("Missing ops-action-helper.sh", resp.json()["detail"]) + + # Verify no OpsAction row is created + with DbSession(self.engine) as db: + count = db.scalar(select(main.func.count(OpsAction.id))) + self.assertEqual(count, 0) + + # Verify audit event is created with result="denied" (never successful) + with DbSession(self.engine) as db: + audit = db.query(OpsAuditEvent).filter_by(action="action_rejected").first() + self.assertIsNotNone(audit) + self.assertEqual(audit.actor_role, "owner") + self.assertEqual(audit.actor_identity, self.op_identity) + self.assertEqual(audit.result, "denied") + self.assertNotEqual(audit.result, "success") + self.assertEqual(audit.metadata_json.get("action_type"), act) + # Ensure no sensitive confirmation text/idempotency keys in audit + self.assertNotIn("idempotency_key", audit.metadata_json) + self.assertNotIn("confirmation_text", audit.metadata_json) + + def test_idempotent_no_action_created(self): + """ + Documenting that while the control plane is disabled, unavailable requests + create audit events but DO NOT create an idempotent action result. + Full idempotent action replay belongs to the configured executor milestone. + """ + key = "idemp-key-test-1" + resp = self.client.post("/api/v1/ops/actions", json={"action_type": "deploy_website", "idempotency_key": key}, headers=self.owner_headers) + self.assertEqual(resp.status_code, 503) + + # Another request with the same key + resp2 = self.client.post("/api/v1/ops/actions", json={"action_type": "deploy_website", "idempotency_key": key}, headers=self.owner_headers) + self.assertEqual(resp2.status_code, 503) + + with DbSession(self.engine) as db: + count = db.scalar(select(main.func.count(OpsAction.id))) + self.assertEqual(count, 0) + audits = db.query(OpsAuditEvent).filter_by(action="action_rejected").all() + self.assertGreaterEqual(len(audits), 2) + for a in audits: + self.assertEqual(a.result, "denied") + self.assertNotEqual(a.result, "success") + + def test_action_detail_404(self): + resp = self.client.get(f"/api/v1/ops/actions/{uuid.uuid4()}", headers=self.owner_headers) + self.assertEqual(resp.status_code, 404) + + def test_sanitized_response(self): + resp = self.client.post("/api/v1/ops/actions", json={"action_type": "deploy_website"}, headers=self.owner_headers) + self.assertEqual(resp.status_code, 503) + self.assertNotIn("var", resp.json()["detail"]) + self.assertNotIn("opt", resp.json()["detail"]) + + def test_pagination_bounds(self): + resp = self.client.get("/api/v1/ops/actions?limit=0", headers=self.owner_headers) + self.assertEqual(resp.status_code, 422) + resp = self.client.get("/api/v1/ops/actions?limit=101", headers=self.owner_headers) + self.assertEqual(resp.status_code, 422) + resp = self.client.get("/api/v1/ops/actions?offset=-1", headers=self.owner_headers) + self.assertEqual(resp.status_code, 422) + + def test_deterministic_action_listing_ordering(self): + with DbSession(self.engine) as db: + t1 = datetime.now(timezone.utc) + t2 = t1 + timedelta(seconds=1) + + id1 = uuid.uuid4() + id2 = uuid.uuid4() + id3 = uuid.uuid4() + + # Action 1 and 2 have identical timestamp. Ordering must resolve by id DESC + db.add(OpsAction(id=id1, action_type="deploy_website", requested_by_id=self.op_id, state="queued", request_timestamp=t1)) + db.add(OpsAction(id=id2, action_type="restart_website", requested_by_id=self.op_id, state="queued", request_timestamp=t1)) + + # Action 3 is later + db.add(OpsAction(id=id3, action_type="restart_api", requested_by_id=self.op_id, state="queued", request_timestamp=t2)) + db.commit() + + resp = self.client.get("/api/v1/ops/actions?limit=5", headers=self.owner_headers) + self.assertEqual(resp.status_code, 200) + data = resp.json() + + # Most recent first + self.assertEqual(data[0]["id"], str(id3)) + + # Then stable secondary sort by id DESC + if str(id1) > str(id2): + self.assertEqual(data[1]["id"], str(id1)) + self.assertEqual(data[2]["id"], str(id2)) + else: + self.assertEqual(data[1]["id"], str(id2)) + self.assertEqual(data[2]["id"], str(id1)) + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/backend/motorsignal_api/tests/test_ops_support.py b/backend/motorsignal_api/tests/test_ops_support.py index 99b3e8a..9327412 100644 --- a/backend/motorsignal_api/tests/test_ops_support.py +++ b/backend/motorsignal_api/tests/test_ops_support.py @@ -271,6 +271,70 @@ class TestOpsSupport(unittest.TestCase): ) self.assertEqual(resp.status_code, 404) + def test_create_ticket_rejects_whitespace_only_subject(self): + payload = { + "ticket_type": "bug_report", + "reporter_email": "testrider@example.com", + "subject": " ", + "description": "Valid description for the ticket.", + "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, 422) + + def test_create_ticket_rejects_whitespace_only_description(self): + payload = { + "ticket_type": "bug_report", + "reporter_email": "testrider@example.com", + "subject": "Valid subject", + "description": " \t \n ", + "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, 422) + + def test_create_ticket_rejects_whitespace_only_reporter_email(self): + payload = { + "ticket_type": "bug_report", + "reporter_email": " ", + "subject": "Valid subject", + "description": "Valid description", + "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, 422) + + def test_create_ticket_trims_surrounding_whitespace(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. ", + "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.assertEqual(data["reporter_email"], "testrider@example.com") + self.assertEqual(data["subject"], "Bluetooth disconnects after 10 mins") + self.assertEqual(data["description"], "App drops connection to OBD reader after 10 minutes.") + if __name__ == "__main__": unittest.main() diff --git a/website/components/ops/operations-support.tsx b/website/components/ops/operations-support.tsx index 25d53ff..c4923d6 100644 --- a/website/components/ops/operations-support.tsx +++ b/website/components/ops/operations-support.tsx @@ -10,6 +10,7 @@ import { SupportTicketStatus, SupportDiagnosticBundle, } from './types'; +import { SupportReportModal } from './support-report-modal'; interface OperationsSupportProps { role: OperationsRole; @@ -22,6 +23,7 @@ export function OperationsSupport({ role: _role }: OperationsSupportProps) { const [totalCount, setTotalCount] = useState(null); const [isLoading, setIsLoading] = useState(true); const [isRefreshing, setIsRefreshing] = useState(false); + const [isModalOpen, setIsModalOpen] = useState(false); const [errorMessage, setErrorMessage] = useState(null); // Tab & Search filters @@ -800,6 +802,14 @@ export function OperationsSupport({ role: _role }: OperationsSupportProps) { // Otherwise, render Ticket Inbox (List view) return (
+ setIsModalOpen(false)} + onSuccess={(ticket: SupportTicketSummary) => { + fetchTickets(true); + handleSelectTicket(ticket.id); + }} + /> {/* Top Title Bar */}
@@ -815,9 +825,18 @@ export function OperationsSupport({ role: _role }: OperationsSupportProps) {
{/* Manual Refresh Only */} - + +
{/* Filter Tabs & Search Bar */} @@ -980,7 +1000,7 @@ export function OperationsSupport({ role: _role }: OperationsSupportProps) { Bundle ) : ( - — + — )} {t.notes_count ?? 0} diff --git a/website/components/ops/support-report-modal.tsx b/website/components/ops/support-report-modal.tsx new file mode 100644 index 0000000..fe09b15 --- /dev/null +++ b/website/components/ops/support-report-modal.tsx @@ -0,0 +1,220 @@ +'use client'; + +import React, { useState, useEffect, useRef } from 'react'; +import { SupportTicketSummary } from './types'; + +interface SupportReportModalProps { + isOpen: boolean; + onClose: () => void; + onSuccess: (ticket: SupportTicketSummary) => void; +} + +export function SupportReportModal({ isOpen, onClose, onSuccess }: SupportReportModalProps) { + const [ticketType, setTicketType] = useState('general'); + const [reporterEmail, setReporterEmail] = useState(''); + const [subject, setSubject] = useState(''); + const [description, setDescription] = useState(''); + const [severity, setSeverity] = useState('medium'); + + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + + const modalRef = useRef(null); + const firstInputRef = useRef(null); + + useEffect(() => { + if (isOpen) { + setTicketType('general'); + setReporterEmail(''); + setSubject(''); + setDescription(''); + setSeverity('medium'); + setError(null); + + // Focus first input after render + setTimeout(() => firstInputRef.current?.focus(), 100); + } + }, [isOpen]); + + // Keyboard accessibility + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (!isOpen) return; + if (e.key === 'Escape') { + onClose(); + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [isOpen, onClose]); + + if (!isOpen) return null; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (isSubmitting) return; + + const trimmedEmail = reporterEmail.trim(); + const trimmedSubject = subject.trim(); + const trimmedDescription = description.trim(); + + if (!trimmedSubject) { + setError('Subject cannot be empty or contain only whitespace.'); + return; + } + if (!trimmedDescription) { + setError('Description cannot be empty or contain only whitespace.'); + return; + } + + setIsSubmitting(true); + setError(null); + try { + const resp = await fetch('/api/ops/support/tickets', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + ticket_type: ticketType, + reporter_email: trimmedEmail, + subject: trimmedSubject, + description: trimmedDescription, + severity, + }) + }); + + const data = await resp.json().catch(() => null); + if (resp.ok) { + onSuccess(data); + onClose(); + } else { + setError(data?.detail || 'Failed to submit report'); + } + } catch { + setError('Network error during submission'); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+
+
+ + +
+ +
+ {error && ( +
+ {error} +
+ )} + +
+ + setReporterEmail(e.target.value)} + className="w-full px-3 py-2 bg-surface-container-lowest border border-outline-variant/50 rounded-lg text-on-surface focus:outline-none focus:ring-2 focus:ring-primary/50" + placeholder="user@example.com" + /> +
+ +
+ + +
+ +
+ + +
+ +
+ + setSubject(e.target.value)} + className="w-full px-3 py-2 bg-surface-container-lowest border border-outline-variant/50 rounded-lg text-on-surface focus:outline-none focus:ring-2 focus:ring-primary/50" + placeholder="Brief summary" + /> +
+ +
+ +