From 71be610753ff8912bfa80d846f40b1b2fd3e9b6a Mon Sep 17 00:00:00 2001 From: hailin Date: Tue, 30 Jun 2026 18:29:21 +0800 Subject: [PATCH] docs: add stage 1 implementation plan --- ...06-30-stage-1-ticket-workflow-solutions.md | 1222 +++++++++++++++++ 1 file changed, 1222 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-30-stage-1-ticket-workflow-solutions.md diff --git a/docs/superpowers/plans/2026-06-30-stage-1-ticket-workflow-solutions.md b/docs/superpowers/plans/2026-06-30-stage-1-ticket-workflow-solutions.md new file mode 100644 index 0000000..baffff3 --- /dev/null +++ b/docs/superpowers/plans/2026-06-30-stage-1-ticket-workflow-solutions.md @@ -0,0 +1,1222 @@ +# Stage 1 Ticket Workflow And Reusable Solutions Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement the first business loop: users can create, search, update, comment on, resolve, export, and link tickets to reusable solutions. + +**Architecture:** Add Django apps for `tickets` and `solutions`, keeping status transition rules in service modules and write history in `TicketEvent` plus operation audit logs. Expose Django Ninja APIs with schema objects per business action. Extend the Vite app with a small client-side router, typed API helpers, and ticket/solution pages built from shared design-system primitives. + +**Tech Stack:** Django 5, Django Ninja, pytest, SQLite test database, React 19, Vite, TypeScript, Tailwind CSS, lucide-react. + +--- + +## Files To Create Or Modify + +```text +backend/ + config/settings/base.py + config/urls.py + apps/tickets/ + __init__.py + apps.py + models.py + schemas.py + services.py + api.py + migrations/__init__.py + tests/ + __init__.py + test_ticket_api.py + test_ticket_status.py + test_ticket_export.py + apps/solutions/ + __init__.py + apps.py + models.py + schemas.py + api.py + migrations/__init__.py + tests/ + __init__.py + test_solution_api.py +frontend/ + src/app/App.tsx + src/app/router.tsx + src/app/layout/AppShell.tsx + src/app/layout/Sidebar.tsx + src/lib/api.ts + src/lib/format.ts + src/components/ui/input.tsx + src/components/ui/textarea.tsx + src/components/ui/select.tsx + src/components/app/AppEmptyState.tsx + src/components/app/AppField.tsx + src/features/tickets/ + api.ts + types.ts + TicketListPage.tsx + TicketCreatePage.tsx + TicketDetailPage.tsx + TicketStatusTimeline.tsx + TicketComments.tsx + src/features/solutions/ + api.ts + types.ts + SolutionListPage.tsx + SolutionLinkPanel.tsx +docs/development/local-setup.md +``` + +## Task 1: Add Ticket Domain Models And Status Service + +**Files:** +- Modify: `backend/config/settings/base.py` +- Create: `backend/apps/tickets/apps.py` +- Create: `backend/apps/tickets/models.py` +- Create: `backend/apps/tickets/services.py` +- Create: `backend/apps/tickets/migrations/__init__.py` +- Test: `backend/apps/tickets/tests/test_ticket_status.py` + +- [ ] **Step 1: Add the failing status transition tests** + +Create `backend/apps/tickets/tests/test_ticket_status.py`: + +```python +import pytest + +from apps.tickets.services import TicketStatusError, validate_status_transition + + +def test_pending_ticket_can_move_to_in_progress(): + validate_status_transition("pending", "in_progress") + + +def test_resolved_ticket_cannot_move_back_to_in_progress(): + with pytest.raises(TicketStatusError, match="resolved -> in_progress"): + validate_status_transition("resolved", "in_progress") +``` + +- [ ] **Step 2: Run the status tests to verify RED** + +Run: + +```bash +cd backend +python -m pytest apps/tickets/tests/test_ticket_status.py -v +``` + +Expected: FAIL because `apps.tickets` does not exist. + +- [ ] **Step 3: Register the tickets app** + +Modify `backend/config/settings/base.py`, adding `"apps.tickets"` after `"apps.system"` in `INSTALLED_APPS`. + +- [ ] **Step 4: Create ticket app config** + +Create `backend/apps/tickets/apps.py`: + +```python +from django.apps import AppConfig + + +class TicketsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.tickets" +``` + +- [ ] **Step 5: Create ticket models** + +Create `backend/apps/tickets/models.py`: + +```python +from django.conf import settings +from django.db import models + + +class TicketStatus(models.TextChoices): + PENDING_INFO = "pending_info", "Pending info" + PENDING = "pending", "Pending" + IN_PROGRESS = "in_progress", "In progress" + PENDING_CONFIRM = "pending_confirm", "Pending confirm" + RESOLVED = "resolved", "Resolved" + CLOSED = "closed", "Closed" + + +class TicketPriority(models.TextChoices): + LOW = "low", "Low" + MEDIUM = "medium", "Medium" + HIGH = "high", "High" + URGENT = "urgent", "Urgent" + + +class TicketCategory(models.Model): + parent = models.ForeignKey("self", null=True, blank=True, on_delete=models.SET_NULL) + name = models.CharField(max_length=120) + code = models.CharField(max_length=80, unique=True) + description = models.TextField(blank=True) + enabled = models.BooleanField(default=True) + sort_order = models.PositiveIntegerField(default=0) + + class Meta: + ordering = ["sort_order", "name"] + + def __str__(self) -> str: + return self.name + + +class Ticket(models.Model): + ticket_no = models.CharField(max_length=32, unique=True) + title = models.CharField(max_length=200) + description = models.TextField() + category = models.ForeignKey(TicketCategory, null=True, blank=True, on_delete=models.SET_NULL) + status = models.CharField( + max_length=32, + choices=TicketStatus.choices, + default=TicketStatus.PENDING, + ) + priority = models.CharField( + max_length=20, + choices=TicketPriority.choices, + default=TicketPriority.MEDIUM, + ) + impact_scope = models.CharField(max_length=80, blank=True) + environment = models.CharField(max_length=80, blank=True) + related_system = models.CharField(max_length=120, blank=True) + requester = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="requested_tickets", + ) + assignee = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="assigned_tickets", + ) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="created_tickets", + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + resolved_at = models.DateTimeField(null=True, blank=True) + closed_at = models.DateTimeField(null=True, blank=True) + + class Meta: + ordering = ["-created_at"] + indexes = [ + models.Index(fields=["ticket_no"]), + models.Index(fields=["status", "priority"]), + models.Index(fields=["created_at"]), + ] + + def __str__(self) -> str: + return f"{self.ticket_no} {self.title}" + + +class TicketComment(models.Model): + ticket = models.ForeignKey(Ticket, on_delete=models.CASCADE, related_name="comments") + content = models.TextField() + comment_type = models.CharField(max_length=40, default="user") + visibility = models.CharField(max_length=40, default="internal") + created_by = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["created_at"] + + +class TicketAttachment(models.Model): + ticket = models.ForeignKey(Ticket, on_delete=models.CASCADE, related_name="attachments") + file_name = models.CharField(max_length=255) + file_type = models.CharField(max_length=80, blank=True) + mime_type = models.CharField(max_length=120, blank=True) + file_size = models.PositiveBigIntegerField(default=0) + storage_path = models.CharField(max_length=500) + ocr_text = models.TextField(blank=True) + parsed_text = models.TextField(blank=True) + created_by = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL) + created_at = models.DateTimeField(auto_now_add=True) + + +class TicketEvent(models.Model): + ticket = models.ForeignKey(Ticket, on_delete=models.CASCADE, related_name="events") + event_type = models.CharField(max_length=80) + from_value = models.JSONField(default=dict, blank=True) + to_value = models.JSONField(default=dict, blank=True) + operator = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL) + remark = models.TextField(blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["created_at"] +``` + +- [ ] **Step 6: Create status transition service** + +Create `backend/apps/tickets/services.py`: + +```python +from django.utils import timezone + +from apps.audit.models import OperationAuditLog +from apps.tickets.models import Ticket, TicketEvent + + +class TicketStatusError(ValueError): + pass + + +ALLOWED_TRANSITIONS: dict[str, set[str]] = { + "pending_info": {"pending", "closed"}, + "pending": {"in_progress", "closed"}, + "in_progress": {"pending_confirm", "resolved", "closed"}, + "pending_confirm": {"resolved", "in_progress", "closed"}, + "resolved": {"closed"}, + "closed": set(), +} + + +def validate_status_transition(from_status: str, to_status: str) -> None: + if to_status not in ALLOWED_TRANSITIONS.get(from_status, set()): + raise TicketStatusError(f"Invalid ticket status transition: {from_status} -> {to_status}") + + +def transition_ticket(ticket: Ticket, to_status: str, remark: str = "", operator=None) -> Ticket: + from_status = ticket.status + validate_status_transition(from_status, to_status) + ticket.status = to_status + if to_status == "resolved": + ticket.resolved_at = timezone.now() + if to_status == "closed": + ticket.closed_at = timezone.now() + ticket.save(update_fields=["status", "resolved_at", "closed_at", "updated_at"]) + TicketEvent.objects.create( + ticket=ticket, + event_type="status_change", + from_value={"status": from_status}, + to_value={"status": to_status}, + operator=operator, + remark=remark, + ) + OperationAuditLog.objects.create( + user=operator, + module="tickets", + action="status_transition", + target_type="ticket", + target_id=str(ticket.id), + result="success", + request_snapshot={"from": from_status, "to": to_status, "remark": remark}, + ) + return ticket +``` + +- [ ] **Step 7: Generate migrations and run status tests** + +Run: + +```bash +cd backend +$env:DJANGO_SETTINGS_MODULE="config.settings.test" +python manage.py makemigrations tickets +python -m pytest apps/tickets/tests/test_ticket_status.py -v +``` + +Expected: PASS. + +- [ ] **Step 8: Commit** + +```bash +git add backend/config/settings/base.py backend/apps/tickets +git commit -m "feat: add ticket domain models" +``` + +## Task 2: Add Ticket CRUD And Status APIs + +**Files:** +- Create: `backend/apps/tickets/schemas.py` +- Create: `backend/apps/tickets/api.py` +- Modify: `backend/config/urls.py` +- Test: `backend/apps/tickets/tests/test_ticket_api.py` + +- [ ] **Step 1: Add failing API tests** + +Create `backend/apps/tickets/tests/test_ticket_api.py`: + +```python +from apps.tickets.models import Ticket, TicketEvent + + +def test_create_ticket_returns_ticket_detail(client, db): + response = client.post( + "/api/tickets", + data={ + "title": "Payment callback fails", + "description": "Callback returns HTTP 500 in production", + "priority": "high", + "impact_scope": "payment users", + "environment": "production", + "related_system": "payment", + }, + content_type="application/json", + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["ticket_no"].startswith("TK") + assert payload["title"] == "Payment callback fails" + assert payload["status"] == "pending" + assert TicketEvent.objects.filter(ticket_id=payload["id"], event_type="created").exists() + + +def test_list_tickets_filters_by_status(client, db): + Ticket.objects.create(ticket_no="TK202606300001", title="A", description="A", status="pending") + Ticket.objects.create(ticket_no="TK202606300002", title="B", description="B", status="resolved") + + response = client.get("/api/tickets?status=pending") + + assert response.status_code == 200 + payload = response.json() + assert len(payload) == 1 + assert payload[0]["ticket_no"] == "TK202606300001" + + +def test_transition_ticket_status_creates_event(client, db): + ticket = Ticket.objects.create(ticket_no="TK202606300003", title="A", description="A", status="pending") + + response = client.post( + f"/api/tickets/{ticket.id}/status", + data={"status": "in_progress", "remark": "Start handling"}, + content_type="application/json", + ) + + assert response.status_code == 200 + ticket.refresh_from_db() + assert ticket.status == "in_progress" + assert ticket.events.filter(event_type="status_change").exists() +``` + +- [ ] **Step 2: Run tests to verify RED** + +Run: + +```bash +cd backend +python -m pytest apps/tickets/tests/test_ticket_api.py -v +``` + +Expected: FAIL because ticket API is not routed. + +- [ ] **Step 3: Create ticket schemas** + +Create `backend/apps/tickets/schemas.py`: + +```python +from datetime import datetime + +from ninja import Schema + + +class TicketIn(Schema): + title: str + description: str + priority: str = "medium" + impact_scope: str = "" + environment: str = "" + related_system: str = "" + assignee_id: int | None = None + + +class TicketUpdateIn(Schema): + title: str | None = None + description: str | None = None + priority: str | None = None + impact_scope: str | None = None + environment: str | None = None + related_system: str | None = None + assignee_id: int | None = None + + +class TicketStatusIn(Schema): + status: str + remark: str = "" + + +class TicketOut(Schema): + id: int + ticket_no: str + title: str + description: str + status: str + priority: str + impact_scope: str + environment: str + related_system: str + assignee_id: int | None + created_at: datetime + updated_at: datetime + + +class TicketEventOut(Schema): + id: int + event_type: str + from_value: dict + to_value: dict + remark: str + created_at: datetime +``` + +- [ ] **Step 4: Create ticket API** + +Create `backend/apps/tickets/api.py`: + +```python +from django.contrib.auth import get_user_model +from django.db.models import Q +from django.utils import timezone +from ninja import Router + +from apps.tickets.models import Ticket, TicketEvent +from apps.tickets.schemas import TicketIn, TicketOut, TicketStatusIn, TicketUpdateIn +from apps.tickets.services import transition_ticket + +router = Router(tags=["tickets"]) + + +def next_ticket_no() -> str: + prefix = timezone.localdate().strftime("TK%Y%m%d") + count = Ticket.objects.filter(ticket_no__startswith=prefix).count() + 1 + return f"{prefix}{count:04d}" + + +@router.post("", response=TicketOut) +def create_ticket(request, payload: TicketIn): + assignee = None + if payload.assignee_id: + assignee = get_user_model().objects.filter(id=payload.assignee_id).first() + ticket = Ticket.objects.create( + ticket_no=next_ticket_no(), + title=payload.title, + description=payload.description, + priority=payload.priority, + impact_scope=payload.impact_scope, + environment=payload.environment, + related_system=payload.related_system, + assignee=assignee, + created_by=request.user if request.user.is_authenticated else None, + ) + TicketEvent.objects.create(ticket=ticket, event_type="created", to_value={"status": ticket.status}) + return ticket + + +@router.get("", response=list[TicketOut]) +def list_tickets(request, status: str | None = None, q: str | None = None): + queryset = Ticket.objects.all() + if status: + queryset = queryset.filter(status=status) + if q: + queryset = queryset.filter(Q(ticket_no__icontains=q) | Q(title__icontains=q)) + return queryset[:100] + + +@router.get("/{ticket_id}", response=TicketOut) +def get_ticket(request, ticket_id: int): + return Ticket.objects.get(id=ticket_id) + + +@router.patch("/{ticket_id}", response=TicketOut) +def update_ticket(request, ticket_id: int, payload: TicketUpdateIn): + ticket = Ticket.objects.get(id=ticket_id) + before = { + "title": ticket.title, + "priority": ticket.priority, + "impact_scope": ticket.impact_scope, + "environment": ticket.environment, + "related_system": ticket.related_system, + } + for field, value in payload.dict(exclude_unset=True).items(): + if field == "assignee_id": + user = get_user_model().objects.filter(id=value).first() if value else None + ticket.assignee = user + else: + setattr(ticket, field, value) + ticket.save() + TicketEvent.objects.create( + ticket=ticket, + event_type="updated", + from_value=before, + to_value=payload.dict(exclude_unset=True), + operator=request.user if request.user.is_authenticated else None, + ) + return ticket + + +@router.post("/{ticket_id}/status", response=TicketOut) +def update_ticket_status(request, ticket_id: int, payload: TicketStatusIn): + ticket = Ticket.objects.get(id=ticket_id) + return transition_ticket( + ticket, + payload.status, + payload.remark, + request.user if request.user.is_authenticated else None, + ) +``` + +- [ ] **Step 5: Route ticket API** + +Modify `backend/config/urls.py`: + +```python +from apps.tickets.api import router as tickets_router + +api.add_router("/tickets", tickets_router) +``` + +- [ ] **Step 6: Run API tests** + +Run: + +```bash +cd backend +python -m pytest apps/tickets/tests/test_ticket_api.py -v +``` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add backend/apps/tickets backend/config/urls.py +git commit -m "feat: add ticket workflow APIs" +``` + +## Task 3: Add Comments, Attachments, Export, And Audit + +**Files:** +- Modify: `backend/apps/tickets/schemas.py` +- Modify: `backend/apps/tickets/api.py` +- Test: `backend/apps/tickets/tests/test_ticket_export.py` + +- [ ] **Step 1: Add failing tests** + +Create `backend/apps/tickets/tests/test_ticket_export.py`: + +```python +from apps.audit.models import OperationAuditLog +from apps.tickets.models import Ticket + + +def test_add_comment_to_ticket(client, db): + ticket = Ticket.objects.create(ticket_no="TK202606300010", title="A", description="A") + + response = client.post( + f"/api/tickets/{ticket.id}/comments", + data={"content": "Checked logs", "comment_type": "internal_note", "visibility": "internal"}, + content_type="application/json", + ) + + assert response.status_code == 200 + assert response.json()["content"] == "Checked logs" + assert ticket.comments.count() == 1 + + +def test_add_attachment_to_ticket(client, db): + ticket = Ticket.objects.create(ticket_no="TK202606300011", title="A", description="A") + + response = client.post( + f"/api/tickets/{ticket.id}/attachments", + data={ + "file_name": "error.log", + "mime_type": "text/plain", + "file_size": 123, + "storage_path": "tickets/TK202606300011/error.log", + }, + content_type="application/json", + ) + + assert response.status_code == 200 + assert response.json()["file_name"] == "error.log" + + +def test_export_tickets_returns_csv_and_audit_log(client, db): + Ticket.objects.create(ticket_no="TK202606300012", title="Export me", description="A") + + response = client.get("/api/tickets/export") + + assert response.status_code == 200 + assert response["content-type"].startswith("text/csv") + assert "TK202606300012" in response.content.decode() + assert OperationAuditLog.objects.filter(module="tickets", action="export").exists() +``` + +- [ ] **Step 2: Run tests to verify RED** + +Run: + +```bash +cd backend +python -m pytest apps/tickets/tests/test_ticket_export.py -v +``` + +Expected: FAIL because routes do not exist. + +- [ ] **Step 3: Extend schemas** + +Append to `backend/apps/tickets/schemas.py`: + +```python +class TicketCommentIn(Schema): + content: str + comment_type: str = "user" + visibility: str = "internal" + + +class TicketCommentOut(Schema): + id: int + content: str + comment_type: str + visibility: str + created_at: datetime + + +class TicketAttachmentIn(Schema): + file_name: str + file_type: str = "" + mime_type: str = "" + file_size: int = 0 + storage_path: str + + +class TicketAttachmentOut(Schema): + id: int + file_name: str + file_type: str + mime_type: str + file_size: int + storage_path: str + created_at: datetime +``` + +- [ ] **Step 4: Add comment, attachment, and export endpoints** + +Extend `backend/apps/tickets/api.py` with: + +```python +import csv +from django.http import HttpResponse + +from apps.audit.models import OperationAuditLog +from apps.tickets.models import TicketAttachment, TicketComment +from apps.tickets.schemas import ( + TicketAttachmentIn, + TicketAttachmentOut, + TicketCommentIn, + TicketCommentOut, +) + + +@router.post("/{ticket_id}/comments", response=TicketCommentOut) +def add_ticket_comment(request, ticket_id: int, payload: TicketCommentIn): + ticket = Ticket.objects.get(id=ticket_id) + comment = TicketComment.objects.create( + ticket=ticket, + content=payload.content, + comment_type=payload.comment_type, + visibility=payload.visibility, + created_by=request.user if request.user.is_authenticated else None, + ) + TicketEvent.objects.create( + ticket=ticket, + event_type="commented", + to_value={"comment_id": comment.id, "comment_type": comment.comment_type}, + operator=request.user if request.user.is_authenticated else None, + ) + return comment + + +@router.post("/{ticket_id}/attachments", response=TicketAttachmentOut) +def add_ticket_attachment(request, ticket_id: int, payload: TicketAttachmentIn): + ticket = Ticket.objects.get(id=ticket_id) + attachment = TicketAttachment.objects.create(ticket=ticket, **payload.dict()) + TicketEvent.objects.create( + ticket=ticket, + event_type="attachment_added", + to_value={"attachment_id": attachment.id, "file_name": attachment.file_name}, + ) + return attachment + + +@router.get("/export") +def export_tickets(request): + response = HttpResponse(content_type="text/csv") + response["Content-Disposition"] = 'attachment; filename="tickets.csv"' + writer = csv.writer(response) + writer.writerow(["ticket_no", "title", "status", "priority", "created_at"]) + for ticket in Ticket.objects.all().order_by("ticket_no"): + writer.writerow([ticket.ticket_no, ticket.title, ticket.status, ticket.priority, ticket.created_at.isoformat()]) + OperationAuditLog.objects.create( + user=request.user if request.user.is_authenticated else None, + module="tickets", + action="export", + target_type="ticket", + result="success", + request_snapshot={}, + ) + return response +``` + +- [ ] **Step 5: Run tests** + +Run: + +```bash +cd backend +python -m pytest apps/tickets/tests/test_ticket_export.py -v +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add backend/apps/tickets +git commit -m "feat: add ticket comments attachments and export" +``` + +## Task 4: Add Reusable Solutions And Ticket Links + +**Files:** +- Modify: `backend/config/settings/base.py` +- Modify: `backend/config/urls.py` +- Create: `backend/apps/solutions/apps.py` +- Create: `backend/apps/solutions/models.py` +- Create: `backend/apps/solutions/schemas.py` +- Create: `backend/apps/solutions/api.py` +- Create: `backend/apps/solutions/migrations/__init__.py` +- Test: `backend/apps/solutions/tests/test_solution_api.py` + +- [ ] **Step 1: Add failing solution API tests** + +Create `backend/apps/solutions/tests/test_solution_api.py`: + +```python +from apps.tickets.models import Ticket + + +def test_create_and_list_solution(client, db): + response = client.post( + "/api/solutions", + data={ + "title": "Restart callback worker", + "problem_signature": "HTTP 500 from callback", + "root_cause": "Worker connection pool stale", + "solution": "Restart callback worker and verify queue depth.", + "status": "published", + }, + content_type="application/json", + ) + + assert response.status_code == 200 + solution_id = response.json()["id"] + + response = client.get("/api/solutions") + assert response.status_code == 200 + assert response.json()[0]["id"] == solution_id + + +def test_link_solution_to_ticket_increments_usage(client, db): + ticket = Ticket.objects.create(ticket_no="TK202606300020", title="A", description="A") + solution = client.post( + "/api/solutions", + data={"title": "S", "problem_signature": "P", "root_cause": "R", "solution": "Do S"}, + content_type="application/json", + ).json() + + response = client.post( + f"/api/solutions/{solution['id']}/link-ticket", + data={"ticket_id": ticket.id, "link_type": "adopted"}, + content_type="application/json", + ) + + assert response.status_code == 200 + assert response.json()["link_type"] == "adopted" +``` + +- [ ] **Step 2: Run tests to verify RED** + +Run: + +```bash +cd backend +python -m pytest apps/solutions/tests/test_solution_api.py -v +``` + +Expected: FAIL because `apps.solutions` does not exist. + +- [ ] **Step 3: Register solutions app** + +Modify `backend/config/settings/base.py`, adding `"apps.solutions"` after `"apps.tickets"`. + +- [ ] **Step 4: Create solution models** + +Create `backend/apps/solutions/models.py`: + +```python +from django.conf import settings +from django.db import models + +from apps.tickets.models import Ticket, TicketCategory + + +class Solution(models.Model): + title = models.CharField(max_length=200) + problem_signature = models.TextField() + related_system = models.CharField(max_length=120, blank=True) + environment_scope = models.CharField(max_length=120, blank=True) + category = models.ForeignKey(TicketCategory, null=True, blank=True, on_delete=models.SET_NULL) + root_cause = models.TextField() + solution = models.TextField() + risk_note = models.TextField(blank=True) + status = models.CharField(max_length=40, default="draft") + usage_count = models.PositiveIntegerField(default=0) + created_by = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["-updated_at"] + + def __str__(self) -> str: + return self.title + + +class TicketResolution(models.Model): + ticket = models.OneToOneField(Ticket, on_delete=models.CASCADE, related_name="resolution") + resolution_type = models.CharField(max_length=80, default="resolved") + root_cause = models.TextField(blank=True) + solution_text = models.TextField() + linked_solution = models.ForeignKey(Solution, null=True, blank=True, on_delete=models.SET_NULL) + confirmed_by = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + +class TicketSolutionLink(models.Model): + ticket = models.ForeignKey(Ticket, on_delete=models.CASCADE, related_name="solution_links") + solution = models.ForeignKey(Solution, on_delete=models.CASCADE, related_name="ticket_links") + link_type = models.CharField(max_length=40, default="referenced") + agent_run_id = models.BigIntegerField(null=True, blank=True) + created_by = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + unique_together = [("ticket", "solution", "link_type")] +``` + +- [ ] **Step 5: Create solution schemas and API** + +Create `backend/apps/solutions/schemas.py` and `backend/apps/solutions/api.py` with create/list/detail/link endpoints using the fields in the tests. Link endpoint must increment `Solution.usage_count` when `link_type == "adopted"`. + +- [ ] **Step 6: Route solution API** + +Modify `backend/config/urls.py`: + +```python +from apps.solutions.api import router as solutions_router + +api.add_router("/solutions", solutions_router) +``` + +- [ ] **Step 7: Generate migrations and run tests** + +Run: + +```bash +cd backend +$env:DJANGO_SETTINGS_MODULE="config.settings.test" +python manage.py makemigrations solutions +python -m pytest apps/solutions/tests/test_solution_api.py -v +``` + +Expected: PASS. + +- [ ] **Step 8: Commit** + +```bash +git add backend/apps/solutions backend/config/settings/base.py backend/config/urls.py +git commit -m "feat: add reusable solution library" +``` + +## Task 5: Add Frontend API Client And Routing + +**Files:** +- Modify: `frontend/src/app/App.tsx` +- Modify: `frontend/src/app/router.tsx` +- Modify: `frontend/src/app/layout/AppShell.tsx` +- Modify: `frontend/src/app/layout/Sidebar.tsx` +- Create: `frontend/src/lib/api.ts` +- Create: `frontend/src/lib/format.ts` + +- [ ] **Step 1: Create API helper** + +Create `frontend/src/lib/api.ts`: + +```typescript +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8000/api' + +export async function apiRequest(path: string, init?: RequestInit): Promise { + const response = await fetch(`${API_BASE_URL}${path}`, { + headers: { + 'content-type': 'application/json', + ...init?.headers + }, + ...init + }) + + if (!response.ok) { + throw new Error(`API request failed: ${response.status}`) + } + + return response.json() as Promise +} +``` + +- [ ] **Step 2: Create date formatting helper** + +Create `frontend/src/lib/format.ts`: + +```typescript +export function formatDateTime(value: string) { + return new Intl.DateTimeFormat('zh-CN', { + dateStyle: 'medium', + timeStyle: 'short' + }).format(new Date(value)) +} +``` + +- [ ] **Step 3: Replace router with hash-based routes** + +Modify `frontend/src/app/router.tsx` so it uses `window.location.hash` and supports `/tickets`, `/tickets/new`, `/tickets/:id`, and `/solutions`. Use React state and `hashchange` listener only; do not add React Router in this stage. + +- [ ] **Step 4: Update shell navigation** + +Modify `frontend/src/app/layout/Sidebar.tsx` to show navigation links for Tickets, Create Ticket, Solutions, and Design System. Use existing layout and no one-off colors. + +- [ ] **Step 5: Run frontend checks** + +Run: + +```bash +cd frontend +npm run typecheck +npm run build +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add frontend/src/app frontend/src/lib +git commit -m "feat: add frontend app routing" +``` + +## Task 6: Add Ticket Frontend Pages + +**Files:** +- Create: `frontend/src/components/ui/input.tsx` +- Create: `frontend/src/components/ui/textarea.tsx` +- Create: `frontend/src/components/ui/select.tsx` +- Create: `frontend/src/components/app/AppEmptyState.tsx` +- Create: `frontend/src/components/app/AppField.tsx` +- Create: `frontend/src/features/tickets/api.ts` +- Create: `frontend/src/features/tickets/types.ts` +- Create: `frontend/src/features/tickets/TicketListPage.tsx` +- Create: `frontend/src/features/tickets/TicketCreatePage.tsx` +- Create: `frontend/src/features/tickets/TicketDetailPage.tsx` +- Create: `frontend/src/features/tickets/TicketStatusTimeline.tsx` +- Create: `frontend/src/features/tickets/TicketComments.tsx` + +- [ ] **Step 1: Create ticket types** + +Create `frontend/src/features/tickets/types.ts` matching backend `TicketOut`, comment, attachment, and event schemas. + +- [ ] **Step 2: Create ticket API wrappers** + +Create `frontend/src/features/tickets/api.ts` with `listTickets`, `createTicket`, `getTicket`, `transitionTicket`, and `addTicketComment` using `apiRequest`. + +- [ ] **Step 3: Create form primitives** + +Create simple `Input`, `Textarea`, and `Select` components that use design tokens, fixed heights, and no raw color values. + +- [ ] **Step 4: Create ticket list page** + +Create `TicketListPage` with search input, status filter, create action, and a dense table-like list showing ticket number, title, status badge, priority badge, system, and created time. + +- [ ] **Step 5: Create ticket create page** + +Create `TicketCreatePage` with controlled form fields for title, description, priority, impact scope, environment, and related system. On submit call `createTicket` and navigate to `#/tickets/`. + +- [ ] **Step 6: Create ticket detail page** + +Create `TicketDetailPage` showing ticket fields, status transition buttons, event timeline, and comments. Keep AI-specific panels out of Stage 1. + +- [ ] **Step 7: Run frontend checks** + +Run: + +```bash +cd frontend +npm run typecheck +npm run build +``` + +Expected: PASS. + +- [ ] **Step 8: Commit** + +```bash +git add frontend/src/components frontend/src/features/tickets frontend/src/app +git commit -m "feat: add ticket workflow pages" +``` + +## Task 7: Add Solutions Frontend Pages + +**Files:** +- Create: `frontend/src/features/solutions/types.ts` +- Create: `frontend/src/features/solutions/api.ts` +- Create: `frontend/src/features/solutions/SolutionListPage.tsx` +- Create: `frontend/src/features/solutions/SolutionLinkPanel.tsx` +- Modify: `frontend/src/features/tickets/TicketDetailPage.tsx` + +- [ ] **Step 1: Create solution types and API wrappers** + +Create TypeScript types matching solution backend schemas and wrappers for list/create/link. + +- [ ] **Step 2: Create solution list page** + +Create `SolutionListPage` with published/draft badges, usage count, title, problem signature, root cause, and solution text preview. + +- [ ] **Step 3: Create solution link panel** + +Create `SolutionLinkPanel` that lists solutions and lets the user link one to the current ticket as `referenced` or `adopted`. + +- [ ] **Step 4: Add panel to ticket detail** + +Modify `TicketDetailPage` to render `SolutionLinkPanel` below comments. + +- [ ] **Step 5: Run frontend checks** + +Run: + +```bash +cd frontend +npm run typecheck +npm run build +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add frontend/src/features/solutions frontend/src/features/tickets/TicketDetailPage.tsx +git commit -m "feat: add solution library pages" +``` + +## Task 8: Final Stage 1 Verification And Documentation + +**Files:** +- Modify: `docs/development/local-setup.md` +- Modify only if verification exposes an issue. + +- [ ] **Step 1: Document Stage 1 APIs** + +Update `docs/development/local-setup.md` with: + +```markdown +## Stage 1 API Smoke Tests + +- Tickets: `GET http://localhost:8000/api/tickets` +- Ticket export: `GET http://localhost:8000/api/tickets/export` +- Solutions: `GET http://localhost:8000/api/solutions` +``` + +- [ ] **Step 2: Run backend tests** + +Run: + +```bash +cd backend +python -m pytest -v +``` + +Expected: all backend tests pass. + +- [ ] **Step 3: Run backend lint** + +Run: + +```bash +cd backend +python -m ruff check . +``` + +Expected: no lint errors. + +- [ ] **Step 4: Run frontend checks** + +Run: + +```bash +cd frontend +npm run typecheck +npm run build +``` + +Expected: both commands pass. + +- [ ] **Step 5: Run compose config verification** + +Run: + +```bash +cd infra +docker compose --env-file ../.env.example config +``` + +Expected: valid config is printed without errors. + +- [ ] **Step 6: Commit verification fixes** + +If documentation or fixes changed files: + +```bash +git add docs/development/local-setup.md +git commit -m "docs: document stage 1 local smoke tests" +``` + +## Self-Review Notes + +Spec coverage: + +- Covers ticket lifecycle, comments, attachments metadata, events, export, reusable solutions, ticket-resolution link data, and solution linking. +- Defers real object upload to MinIO, AI recommendations, dashboards, and historical migration to later stages. +- Frontend uses existing design tokens and product badges; no one-off page color system is introduced. + +Placeholder scan: + +- No `TBD`, `TODO`, or open-ended implementation placeholders are required for a worker to start. +- Task 4 and frontend tasks intentionally specify file responsibilities and exact required behavior while leaving straightforward component layout implementation to the worker. + +Type consistency: + +- Backend status values match `AppStatusBadge`. +- Backend priority values match `AppPriorityBadge`. +- `TicketOut`, `TicketCommentOut`, and solution response names are the source for frontend TypeScript types.