Spec: F58 — Internal Support Ticketing System
Status: 🚧 Partial implementation; API and web surfaces exist, but notification, tenant-boundary, browser, and environment proof gaps remain
Current truth (2026-07-16): The former spec-only audit was stale.
backend/apps/support/,/api/support/tickets/, authenticated frontend routes, and backend tests are present. This does not establish browser execution, live notification delivery, production behavior, or a tenant-isolated staff queue. See the implementation audit and Contract Graph gaps.
Current repository truth: this document is the product proposal and target contract, not shipped evidence. The checked acceptance criteria below describe the intended build. See the current implementation audit and
contracts/features/F58.cuefor the explicit implementation gap.
1. Problem & CEO Job
Job: Trust the signal.
When a pulse will not submit, the Brief will not generate, or an approval is stuck, the CEO has no channel to tell Lumi. There is no path from “something is wrong” to “Lumi fixed it” beyond struggling silently or emailing someone they hope still works here.
This spec builds Lumi’s support channel: users submit issues; Lumi staff triage, respond, and resolve them; users receive status updates. It is not a tenant workflow.
2. User Stories & Acceptance Criteria
User — Submit Ticket
- As a Lumi user (any role), I want to submit a support ticket with a subject and description, so that the Lumi team knows what’s broken.
- Given any org member, when I navigate to
/support/new, then I see a form with: subject, description (textarea), category dropdown, priority selector, optional file attachment. (SupportCreatePage.tsx) - Given the form, when I submit with a non-empty subject and description, then a Ticket is created with status
openand I’m redirected to/support/<ticket_id>. - Given the form, when I submit with an empty subject, then I see a validation error and nothing is created.
- Given the form, when I click “Attach file”, then a file picker opens; supported types: images, PDFs, plain text (max 10 MB per file, max 3 files per ticket). Backend:
POST /attach/enforces 3-file cumulative limit, 10 MB viaUPLOAD_MAX_SIZE.
- Given any org member, when I navigate to
User — View My Tickets
- As a user, I want to see all my tickets in one list, so that I can track what I’ve reported.
- Given
/support, the page shows a list of my tickets (created by me) ordered by most recent update. (SupportListPage.tsx) - Each ticket row shows: id, subject, status badge, priority badge, category, last updated timestamp.
- Clicking a ticket row navigates to
/support/<ticket_id>.
- Given
User — Ticket Detail & Messages
- As a user, I want to see the full conversation on a ticket, so that I know what the Lumi team has said and can reply.
- Given
/support/<ticket_id>, the page shows: subject, status banner, description (original), and a chronological message thread. (SupportDetailPage.tsx) - Each message shows: author name, timestamp, body, and attachments if any.
- Messages from Lumi staff are visually distinct (badge “Lumi Team” with brand color).
- Given the ticket is not closed, I can add a reply via a textarea at the bottom of the thread.
- Given a reply, when I submit, then the message is appended, the ticket’s
updated_atrefreshes, and the status changes towaiting_on_staff(auto-reopens if previouslywaiting_on_user).
- Given
User — Close Ticket
- As a user, I want to close my own ticket when the issue is resolved, so that the Lumi team knows I’m satisfied.
- Given an open or in-progress ticket, a “Close ticket” button is visible to the ticket creator.
- Given a click on “Close ticket”, then status changes to
closedand a confirmation toast appears.
Staff — Ticket Dashboard
- As a Lumi staff member (
is_staff=True), I want a dashboard of all tickets, so that I can triage and resolve issues.- Given
/hq/support(inside the HQ console, staff-gated by HqLayout), the staff dashboard shows all tickets across all users, filterable by status (open, in_progress, resolved, closed), priority, and category. (pages/hq/HqSupportPage.tsx; legacy/support/manageredirects here) - The dashboard shows a summary bar: open count, in-progress count, unresolved older than 48h. (Added 2026-06-14)
- Clicking a ticket navigates to
/support/<ticket_id>with full staff controls. - Non-staff users on
/hq/supportare redirected out by HqLayout (/work/today); themanageAPI returns 403.
- Given
Staff — Assign & Update Status
- As a Lumi staff member, I want to assign tickets to myself and update their status, so that the team knows who owns each issue.
- Given
/support/<ticket_id>, a staff-only panel shows: assignee dropdown, status dropdown, priority dropdown. (SupportDetailPage.tsxstaff controls panel, added 2026-06-14) - Given “Assign to me” click, the ticket’s
assigned_tois set to the current staff user. - Status transitions:
open→in_progress→resolved→closed. A ticket can be reopened fromresolvedtoin_progress. - Given status changes to
resolved, the ticket creator’s next visit shows a “Was this resolved?” feedback prompt (thumbs up/down).
- Given
Staff — Internal Notes
- As a Lumi staff member, I want to add internal notes visible only to staff, so that we can discuss the issue without confusing the user.
- Given
/support/<ticket_id>, a staff-only “Internal note” button toggles a separate textarea. (Added 2026-06-14) - Internal notes are rendered in the thread with a ”🔒 Internal” badge and are invisible to the ticket creator.
- Internal notes do not change the ticket’s
waiting_onstate.
- Given
Notifications
- As a user, I want to be notified when my ticket status changes or a staff member replies, so that I don’t have to keep checking.
- Given a staff reply or status change on my ticket, a push notification is sent (F36) and a bell badge increments.
- Given a staff reply, an email notification is sent to the ticket creator’s registered email (via F35 email infrastructure).
- The notification includes the ticket subject and a deep link to
/support/<ticket_id>.
3. Scope Boundaries
- In:
- Ticket CRUD for users (create, view own, reply, close).
- Staff dashboard with full ticket management.
- Assignee system (staff self-assign).
- Status workflow (open → in_progress → resolved → closed).
- Internal notes (staff-only).
- File attachments on tickets (via F34 upload infra).
- Email + push notifications on ticket updates.
- Ticket feedback prompt on resolve (thumbs up/down).
- Both en + vi localization for all UI.
- Out:
- SLA or escalation rules.
- CSAT surveys beyond thumbs up/down.
- Knowledge base / FAQ (separate future feature).
- Public-facing status page.
- Bulk ticket operations.
- Third-party helpdesk integration (Zendesk, Freshdesk, etc.).
- Per-org ticket visibility (tickets belong to individual users, not orgs).
- Chat/copilot for support (ticket-based async only).
4. Data Model
4.1 Ticket
class Ticket(UUIDModel):
TICKET_STATUS_CHOICES = [
("open", "Open"),
("in_progress", "In Progress"),
("waiting_on_staff", "Waiting on Staff"),
("waiting_on_user", "Waiting on User"),
("resolved", "Resolved"),
("closed", "Closed"),
]
PRIORITY_CHOICES = [
("low", "Low"),
("medium", "Medium"),
("high", "High"),
("critical", "Critical"),
]
CATEGORY_CHOICES = [
("bug", "Bug / Error"),
("feature_request", "Feature Request"),
("billing", "Billing / Payment"),
("account", "Account / Login"),
("integration", "Integration / Connector"),
("other", "Other"),
]
subject = models.CharField(max_length=200)
description = models.TextField()
category = models.CharField(max_length=30, choices=CATEGORY_CHOICES, default="other")
priority = models.CharField(max_length=20, choices=PRIORITY_CHOICES, default="medium")
status = models.CharField(max_length=20, choices=TICKET_STATUS_CHOICES, default="open")
created_by = models.ForeignKey(User, on_delete=CASCADE, related_name="tickets")
assigned_to = models.ForeignKey(User, on_delete=SET_NULL, null=True, blank=True, related_name="assigned_tickets")
# Feedback on resolve
resolved_satisfied = models.BooleanField(null=True, blank=True) # True = thumbs up
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "support_tickets"
ordering = ["-updated_at"]
4.2 TicketMessage
class TicketMessage(UUIDModel):
ticket = models.ForeignKey(Ticket, on_delete=CASCADE, related_name="messages")
author = models.ForeignKey(User, on_delete=CASCADE)
body = models.TextField()
is_internal = models.BooleanField(default=False) # Staff-only internal note
attachments = models.ManyToManyField("uploads.Upload", blank=True) # F34
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "support_ticket_messages"
ordering = ["created_at"]
4.3 No Organization FK
Tickets are cross-tenant. They belong to individual users, not organizations. The created_by FK is on accounts.User, not OrganizationMember, so the user retains access to their tickets even if they leave an org or have multiple orgs.
5. API Contract
| Method | Path | Auth | Request | Response | Errors |
|---|---|---|---|---|---|
| GET | /api/support/tickets/ | any auth | pagination, filters (status, priority, category) | list of user’s tickets | — |
| POST | /api/support/tickets/ | any auth | {subject, description, category, priority} | created ticket | 400 if subject empty |
| GET | /api/support/tickets/<id>/ | creator or staff | — | ticket detail with messages | 404 if not found, 403 if not creator/staff |
| POST | /api/support/tickets/<id>/messages/ | creator or staff | {body, is_internal} (staff only for is_internal) | created message | 403 if non-staff sets is_internal |
| PATCH | /api/support/tickets/<id>/ | staff only | {status?, priority?, assigned_to?} | updated ticket | 403 for non-staff; 400 invalid transition or unknown assignee |
| POST | /api/support/tickets/<id>/close/ | creator | — | status → closed | 403 if not creator |
| POST | /api/support/tickets/<id>/feedback/ | creator | {satisfied: bool} | sets resolved_satisfied | 400 if ticket not resolved |
| GET | /api/support/tickets/manage/ | staff only | pagination, filters (status, priority, category, assignee) | all tickets | 403 if non-staff |
The shipped management read is contract-owned as F58.AC002.MANAGE_API, and the ticket detail read
is contract-owned as F58.AC001.DETAIL_API. Its staff queue remains
subject to the unresolved tenant-boundary gap recorded in the implementation audit and Contract
Graph; this ownership entry does not imply tenant isolation or runtime execution evidence.
The exact POST /api/support/tickets/ creation surface is owned by TicketViewSet.create with
collected proof F58.AC001.HAPPY; guarded backend execution remains explicit in F58.GAP006.
The exact POST /api/support/tickets/{id}/attach/ surface is owned by TicketViewSet.attach
with collected proof F58.AC003.ATTACH_HAPPY; guarded backend execution remains explicit in
F58.GAP007.
The exact POST /api/support/tickets/{id}/close/ surface is owned by TicketViewSet.close with
collected proof F58.AC001.CLOSE_HAPPY; guarded backend execution remains explicit in F58.GAP008.
The exact POST /api/support/tickets/{id}/feedback/ surface is owned by TicketViewSet.feedback
with collected proof F58.AC001.FEEDBACK_HAPPY; guarded backend execution remains explicit in
F58.GAP009.
The exact POST /api/support/tickets/{id}/messages/ surface is owned by TicketViewSet.add_message
with collected proof F58.AC001.MESSAGE_HAPPY; guarded backend execution remains explicit in
F58.GAP010.
The exact POST /api/support/tickets/{id}/reopen/ surface is owned by TicketViewSet.reopen with
collected proof F58.AC002.REOPEN_HAPPY; guarded backend execution remains explicit in F58.GAP011.
Tenant scoping: Tickets are NOT tenant-scoped. They are user-scoped. A user sees only their own tickets. Staff (is_staff=True) see all tickets.
6. UI / UX
6.1 Support Entry Points
- Sidebar — A “Support” link in the Admin nav group (always visible to all roles) or a dedicated entry in Settings.
- Global nav — A ”?” icon or “Help” dropdown in the top bar with “Submit a ticket” option.
6.2 My Tickets Page (/support)
┌─ Support ─────────────────────────────────────────────────┐
│ │
│ [Submit New Ticket] │
│ │
│ My Tickets │
│ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ #T-042 │🔴 Can't submit pulse today │ 🟢 │ │
│ │ │ Bug High 2 hours ago │ Open │ │
│ ├──────────────────────────────────────────────────────┤ │
│ │ #T-041 │🟡 Brief not generating on time │ 🟡 │ │
│ │ │ Bug Medium yesterday │ In..│ │
│ ├──────────────────────────────────────────────────────┤ │
│ │ #T-039 │✅ Add Telegram connector │ ✅ │ │
│ │ │ Featur Low 3 days ago │ Clo..│ │
│ └──────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────┘
6.3 Ticket Detail (/support/<id>)
┌─ Ticket #T-042 ──────────────────────────────────────────┐
│ │
│ 🔴 Open · 🐛 Bug · High Priority │
│ │
│ Can't submit pulse today │
│ ───────────────────────────────────────────── │
│ I've been trying to submit my pulse since 8 AM but │
│ the form keeps showing a blank page after I click │
│ "Submit". I've tried on Chrome and Safari. │
│ │
│ Attachments: screenshot-1.png │
│ │
│ ─── Replies ─── │
│ │
│ [Lumi Team] Hi, thanks for reporting! Can you check │
│ if your browser console shows any errors? │
│ 10 minutes ago │
│ │
│ You: I checked — here's what I see: [screenshot] │
│ 5 minutes ago │
│ │
│ ┌────────────────────────────────────────────────────┐ │
│ │ Type your reply here... [Send] │ │
│ └────────────────────────────────────────────────────┘ │
│ │
│ [Close Ticket] │
└──────────────────────────────────────────────────────────┘
6.4 Staff Dashboard (/hq/support)
┌─ Support Dashboard ───────────────────────────────────────┐
│ │
│ 🟢 12 Open 🟡 8 In Progress 🔴 3 Unresolved >48h │
│ │
│ [All] [Open] [In Progress] [Resolved] [Closed] │
│ [All Priorities] [All Categories] │
│ │
│ ┌────┬────────────┬──────────┬────────┬────────┬──────┐ │
│ │ ID │ Subject │ User │ Status │ Pri │ Age │ │
│ ├────┼────────────┼──────────┼────────┼────────┼──────┤ │
│ │042 │ Can't sub..│ ceo@d.. │ 🟢open │ 🔴high │ 2h │ │
│ │041 │ Brief not..│ admin@..│ 🟡in.. │ 🟡med │ 26h │ │
│ └────┴────────────┴──────────┴────────┴────────┴──────┘ │
└──────────────────────────────────────────────────────────┘
6.5 i18n
New namespace: support. All keys in both en/support.json and vi/support.json.
Page heading keys:
| Key | en | vi |
|---|---|---|
support.title | Support | Hỗ trợ |
support.new_ticket | Submit new ticket | Gửi yêu cầu mới |
support.my_tickets | My tickets | Yêu cầu của tôi |
support.dashboard_title | Support Dashboard | Bảng điều khiển hỗ trợ |
Form keys:
| Key | en | vi |
|---|---|---|
support.subject | Subject | Tiêu đề |
support.subject_placeholder | Briefly describe your issue | Mô tả ngắn gọn vấn đề |
support.description | Description | Mô tả chi tiết |
support.description_placeholder | Tell us more about what’s happening… | Hãy cho chúng tôi biết thêm chi tiết… |
support.category | Category | Danh mục |
support.priority | Priority | Mức độ ưu tiên |
support.attach_file | Attach file | Đính kèm tệp |
support.attach_file_hint | Max 10 MB per file, max 3 files | Tối đa 10 MB mỗi tệp, tối đa 3 tệp |
support.submit | Submit ticket | Gửi yêu cầu |
support.submitting | Submitting… | Đang gửi… |
Status badges:
| Key | en | vi |
|---|---|---|
support.status_open | Open | Đang mở |
support.status_in_progress | In progress | Đang xử lý |
support.status_waiting_staff | Waiting on staff | Chờ nhân viên |
support.status_waiting_user | Waiting on you | Chờ bạn |
support.status_resolved | Resolved | Đã giải quyết |
support.status_closed | Closed | Đã đóng |
Category labels:
| Key | en | vi |
|---|---|---|
support.category_bug | Bug / Error | Lỗi |
support.category_feature_request | Feature Request | Đề xuất tính năng |
support.category_billing | Billing / Payment | Thanh toán |
support.category_account | Account / Login | Tài khoản / Đăng nhập |
support.category_integration | Integration / Connector | Tích hợp |
support.category_other | Other | Khác |
Priority labels:
| Key | en | vi |
|---|---|---|
support.priority_low | Low | Thấp |
support.priority_medium | Medium | Trung bình |
support.priority_high | High | Cao |
support.priority_critical | Critical | Nghiêm trọng |
Thread/reply keys:
| Key | en | vi |
|---|---|---|
support.reply_placeholder | Type your reply here… | Nhập phản hồi của bạn… |
support.send | Send | Gửi |
support.close_ticket | Close ticket | Đóng yêu cầu |
support.reopen_ticket | Reopen ticket | Mở lại yêu cầu |
support.internal_note | Add internal note (staff only) | Thêm ghi chú nội bộ (chỉ nhân viên) |
support.internal_note_badge | 🔒 Internal | 🔒 Nội bộ |
support.lumi_team_badge | Lumi Team | Lumi Team |
support.you_label | You | Bạn |
support.just_now | Just now | Vừa xong |
support.time_ago | {time} ago | {time} trước |
Staff dashboard keys:
| Key | en | vi |
|---|---|---|
support.staff_assign_to_me | Assign to me | Gán cho tôi |
support.staff_unassigned | Unassigned | Chưa gán |
support.staff_assigned_to | Assigned to {name} | Đã gán cho {name} |
support.staff_summary_open | {count} Open | {count} Đang mở |
support.staff_summary_in_progress | {count} In progress | {count} Đang xử lý |
support.staff_unresolved_48h | {count} Unresolved >48h | {count} Chưa giải quyết >48h |
support.staff_filter_all | All | Tất cả |
support.staff_filter_open | Open | Đang mở |
support.staff_filter_in_progress | In progress | Đang xử lý |
support.staff_filter_resolved | Resolved | Đã giải quyết |
support.staff_filter_closed | Closed | Đã đóng |
Feedback keys:
| Key | en | vi |
|---|---|---|
support.feedback_title | Was this resolved? | Vấn đề đã được giải quyết? |
support.feedback_thumbs_up | Yes | Có |
support.feedback_thumbs_down | Not really | Chưa |
Notification keys:
| Key | en | vi |
|---|---|---|
support.notification_reply | New reply on your ticket: {subject} | Phản hồi mới cho yêu cầu: {subject} |
support.notification_status_change | Ticket status updated: {subject} → {status} | Trạng thái yêu cầu đã cập nhật: {subject} → {status} |
support.notification_push_reply | {staff_name} replied to your ticket | {staff_name} đã phản hồi yêu cầu của bạn |
support.notification_push_status | Ticket {id} is now {status} | Yêu cầu {id} hiện {status} |
Validation/error keys:
| Key | en | vi |
|---|---|---|
support.error_subject_required | Subject is required | Vui lòng nhập tiêu đề |
support.error_description_required | Description is required | Vui lòng nhập mô tả |
support.error_file_too_large | File must be under 10 MB | Tệp phải dưới 10 MB |
support.error_too_many_files | Maximum 3 files allowed | Tối đa 3 tệp |
support.error_ticket_not_found | Ticket not found | Không tìm thấy yêu cầu |
support.error_cannot_reply_closed | Cannot reply to a closed ticket | Không thể phản hồi yêu cầu đã đóng |
support.error_feedback_not_resolved | Feedback is only available on resolved tickets | Phản hồi chỉ khả dụng cho yêu cầu đã giải quyết |
7. Test Plan
Coverage note (2026-06-14): Support tests live in
backend/tests/test_support.pywith classesTestTicketCreate,TestTicketListScoping,TestTicketDetail,TestStaffOnly,TestMessages,TestInternalNotes,TestClosePermission,TestStatusWorkflow,TestFeedback,TestTicketIdFormat,TestStaffReopenTicket,TestAttachmentLimits. Rows below use original per-case naming; the file is authoritative.
| # | Case | Type | Where |
|---|---|---|---|
| 1 | Create ticket with valid data returns 201 | backend | tests/test_support.py::TestTicketCreate |
| 2 | Create ticket with empty subject returns 400 | backend | tests/test_support.py |
| 3 | Create ticket with all optional category+priority fields returns 201 | backend | tests/test_support.py |
| 4 | User sees only their own tickets | backend | tests/test_support.py::TestTicketListScoping |
| 5 | Staff sees all tickets | backend | tests/test_support.py |
| 6 | Non-staff gets 403 on staff dashboard endpoint | backend | tests/test_support.py::TestStaffOnly |
| 7 | Close ticket by non-creator returns 403 | backend | tests/test_support.py::TestClosePermission |
| 8 | Internal note invisible to non-staff | backend | tests/test_support.py::TestInternalNotes |
| 9 | Staff creates internal note on another staff’s ticket — visible to all staff | backend | tests/test_support.py |
| 10 | Status transition: open → in_progress → resolved → closed | backend | tests/test_support.py::TestStatusWorkflow |
| 11 | Status transition: closed → reopened → in_progress (staff only) | backend | tests/test_support.py |
| 12 | Invalid status transition returns 400 | backend | tests/test_support.py |
| 13 | Attachments on ticket messages (F34 integration) | backend | tests/test_support.py |
| 14 | Attachments: too many files (>3) returns 400 | backend | tests/test_support.py::TestAttachmentLimits |
| 15 | Submit ticket → user sees confirmation and detail page | e2e | support-ticketing.spec.ts |
| 16 | Staff dashboard → filter by status, priority, and category | e2e | support-ticketing.spec.ts |
| 17 | Staff assigns ticket to self → assigned_to updated | e2e | support-ticketing.spec.ts |
| 18 | User closes own ticket → status = closed | e2e | support-ticketing.spec.ts |
| 19 | User replies to own ticket → status changes to waiting_on_staff | e2e | support-ticketing.spec.ts |
| 25 | Support list page loads without error | e2e | support-ticketing.spec.ts |
| 26 | Create ticket form opens and can be filled | e2e | support-ticketing.spec.ts |
| 27 | Support list empty state or ticket items visible | e2e | support-ticketing.spec.ts |
| 28 | Sidebar has Support link in Admin group | e2e | support-ticketing.spec.ts |
| 29 | Back navigation from create ticket goes to list | e2e | support-ticketing.spec.ts |
| 20 | Staff adds internal note — invisible to ticket creator via API | backend | tests/test_support.py |
| 21 | Feedback prompt: resolved ticket shows thumbs up/down | backend | tests/test_support.py |
| 22 | Email notification sent on staff reply (F35 integration) | backend | tests/test_support.py |
| 23 | Push notification sent on status change (F36 integration) | backend | tests/test_support.py |
| 24 | Cross-tenant isolation: user sees only own tickets | backend | tests/test_multitenancy.py |
8. Current Behavior
Partial implementation. apps/support/ contains Ticket + TicketMessage models, TicketViewSet, staff gates, display IDs, F34 attachment M2M, reopen and feedback endpoints. Frontend support pages and routes exist. The current audit does not claim a separate TicketMessageViewSet, live notification dispatch, tenant-isolated staff access, browser execution, or production behavior.
9. Error States
| # | Scenario | User-visible | Internal | Log | Retry |
|---|---|---|---|---|---|
| 1 | File too large (>10 MB) | Field validation: “File exceeds 10 MB limit.” | Validation error, file discarded | INFO “support_file_too_large” | Re-upload with smaller file |
| 2 | Too many files (>3) | Field validation: “Maximum 3 files per ticket.” | Validation error, extra files discarded | INFO “support_too_many_files” | Re-upload with fewer files |
| 3 | Invalid file type | Field validation: “Unsupported file type. Accepted: images, PDF, text.” | Validation error, file discarded | INFO “support_invalid_file_type” | Re-upload with correct type |
| 4 | Ticket not found | 404 | 404 response, logged | WARNING “support_ticket_not_found” | n/a |
| 5 | Non-staff accesses /hq/support | redirected to /work/today by HqLayout; manage API 403 | Logged for audit | INFO “support_staff_access_denied” | n/a |
| 6 | Email notification fails (F35) | No visible change — message saved | Email error logged | ERROR “support_email_failed” | Retry once after 5 min |
| 7 | Invalid status transition | 400: “Transition from {current} to {target} is not allowed.” | Validation error | WARNING “support_invalid_transition” | User selects valid transition |
| 8 | F34 upload fails | Toast: “File upload failed. Message saved without attachment.” | Upload error | WARNING “support_upload_failed” | Re-upload in reply |
| 9 | Non-creator tries to close ticket | 403 | Permission denied | INFO “support_close_denied” | n/a |
| 10 | Feedback submitted for non-resolved ticket | 400: “Feedback is only available for resolved tickets.” | Validation error | WARNING “support_feedback_invalid_state” | Wait until resolved |
| 11 | Ticket creator deleted — ticket still visible to staff | Ticket shows “Deleted user” as creator | Preserve creator_email, FK = NULL | INFO “support_creator_deleted” | n/a |
| 12 | Staff assignee removed from staff — auto-unassigned | Staff sees unassigned ticket in dashboard | Assignee set to NULL | INFO “support_assignee_demoted” | Staff reassigns |
| 13 | Non-staff sets is_internal=true on message | 403. is_internal silently forced to false. | Message saved without internal flag. | DEBUG “support_internal_note_denied” | n/a |
| 14 | Staff assigns ticket to non-staff user | 400: “Assignee must be a staff member.” | assigned_to unchanged. | INFO “support_assign_non_staff” | n/a |
| 15 | Reply on closed ticket | 400: “Cannot reply to a closed ticket.” Re-open to reply. | Message not created. | INFO “support_reply_closed” | n/a |
10. Diagrams & Docs Touched
backend/DIAGRAMS.md— add SupportTicket model + status state machine.GLOSSARY.md— entry for “Ticket”, “Staff”, “Internal Note”.
11. Implementation Audit
Edge Cases Catalog (formerly §9, 2026-06-14; 17 items): ticket creator account deletion (creator_email preserved), staff member loses is_staff (assigned tickets auto-unassigned), multiple close/reopen cycles (feedback prompt resets), race condition on simultaneous staff+user reply (last-write-wins), file upload failure (message saved without attachment), email notification failure (message saved, retry once after 5m), wrong category (staff can change), long ticket thread (paginated 20/page), anonymous user 401 redirect, weekend reply auto-status, notification infra down (ticket saved regardless), internal note does not change waiting state, staff user creates own ticket (normal ticket not auto-assigned), message with no body but attachment (valid), XSS sanitization via markdown sanitizer (F28), multiple staff assign (last-write-wins), status transition rules backend-enforced. Full catalog in git history.
| # | Claim | Status | Evidence |
|---|---|---|---|
| 1 | Ticket + TicketMessage models | ✅ match | backend/apps/support/models.py |
| 2 | backend/apps/support/ app (migrations, views, serializers, services, urls) | ✅ match | backend/apps/support/ (full app) |
| 3 | TicketViewSet only; message creation via add_message @action(url_path="messages") on TicketViewSet (no separate TicketMessageViewSet exists) | ✅ match | backend/apps/support/views.py line 124 |
| 4 | Staff permission check (User.is_staff gate) | ✅ match | Inline in backend/apps/support/views.py — no standalone permissions.py exists in the support app; is_staff checks are in TicketViewSet.get_queryset(), update(), add_message(), attach_file(), reopen(), and close() |
| 5 | File attachments via F34 (Ticket.attachments M2M → uploads.Upload) | ✅ match | backend/apps/support/serializers.py; models.py |
| 6 | Frontend pages + routes (/support, /hq/support) | ✅ match | Registry E2E ✅; frontend/src/pages/Support*, frontend/src/pages/hq/HqSupportPage.tsx |
| 7 | support i18n namespace (all 60+ locales) | ✅ match | frontend/src/i18n/locales/*/support.json |
| 8 | display_id field (T-{id}) via _next_display_id() | ✅ match | BL 2026-06-14; TestTicketIdFormat |
| 9 | Attachment limits (10 MB, 3 files) + POST /attach/ enforcement | ✅ match | BL 2026-06-14; UPLOAD_MAX_SIZE (F34) |
| 10 | Ticket feedback endpoint (POST /api/support/tickets/<id>/feedback/) | ✅ match | BL 2026-06-14; TestFeedback |
| 11 | Staff re-open closed tickets (POST /reopen/; VALID_TRANSITIONS["closed"]) | ✅ match | BL 2026-06-14; TestStaffReopenTicket |
11.1 Key Design Constraints
| Constraint | Detail |
|---|---|
| Tickets are user-scoped, NOT org-scoped | created_by FK on User, not OrganizationMember. User retains tickets across orgs |
| Staff dashboard is is_staff gated | Same pattern as F43 HQ |
| Attachments reuse F34 infrastructure | Pre-signed S3 uploads — no separate upload endpoint |
| Internal notes are invisible to non-staff | Backend enforces: is_internal=True messages filtered out in user’s ticket detail response |
| Notifications are best-effort | Ticket saved regardless of notification delivery — never block ticket creation on notification send |
| Status transitions are backend-enforced | Frontend only shows valid transitions — backend validates regardless |
12. Open Questions / Decisions
-
Display ID format. Auto-increment
T-{id}vs UUID for ticket list. Decision: UI showsT-{id}, PK stays UUID. -
File storage for attachments. Reuse F34 (pre-signed S3 uploads) or accept direct uploads. Decision: reuse F34 infrastructure — consistent, already audited.
-
Ticket deletion. Should users be able to delete their own tickets? Decision: no. Tickets are immutable audit records once created. Users can close them.
-
Staff flag vs dedicated permission model. Use Django’s built-in
User.is_staffor create a separatesupport_staffgroup. Decision: useis_stafffor v1. It’s already available, tested in HQ routes. A dedicated support role can be added later if needed.
13. Bug / Change Log
2026-07-02 — §5/§11 audit drift: PATCH gate, TicketMessageViewSet (docs-only)
Type: Documentation
Summary:
- §5 PATCH row: auth column corrected “creator or staff” → “staff only”; errors column corrected “403 if non-staff sets assigned_to” → “403 for non-staff (blanket)”. Verified:
partial_updateinbackend/apps/support/views.py(~L87–88) returns 403 for all non-staff unconditionally — not just whenassigned_tois set. - §11 row 3: removed false
TicketMessageViewSetclaim; corrected toTicketViewSetonly with message creation viaadd_message @action(url_path="messages")at views.py line 124. NoTicketMessageViewSetclass exists in the file. - No code changes.
2026-07-02 — Fix stale path: backend/apps/support/permissions.py (batch 3)
Type: Documentation
Summary:
- §11 row 4 referenced
backend/apps/support/permissions.pyas the evidence for theUser.is_staffpermission gate. This file does not exist —ls backend/apps/support/confirms nopermissions.py. Theis_staffchecks are inline inbackend/apps/support/views.py(multiple methods inTicketViewSetandTicketMessageViewSet). - Updated the evidence cell to point at
backend/apps/support/views.pywith the specific method list. - No code changes.
2026-06-14 — Spec restructured to TEMPLATE.md canonical order (§1–§13)
Type: Documentation Change
Summary:
- §9 Edge Cases (17 items, 9.1–9.17) absorbed into §11 Implementation Audit EC note.
- §10 Error States → §9, §11 Diagrams & Docs Touched → §10, §12 Implementation Audit → §11 (canonical 4-col table, 11 rows, all ✅ match).
- §12.1 Key Design Constraints (h2 subheading, non-standard) →
### 11.1 Key Design Constraints(h3 under §11). - §13 Open Questions → §12, §14 Bug / Change Log → §13.
Reason:
- Align with TEMPLATE.md canonical section order per SP fleet restructuring pass.
Impact:
- Spec authors and future implementation agents.
2026-06-13 — Spec refinement: numbered edge cases, detailed error matrix, expanded test plan
Type: Documentation Change
Summary:
- Added §9 with 9 numbered edge cases (9.1–9.9) covering: account deletion, staff revocation, multiple close/reopen cycles, race conditions on reply, file upload failure, email notification failure, wrong category, long threads, and unauthenticated access.
- Replaced §10 error states with 12-row detailed matrix (user-visible/internal/log/retry), adding coverage for file types, feedback state validation, creator deletion, and staff demotion.
- Expanded §7 test plan from 11 to 24 test cases, adding coverage for optional fields, cross-staff notes, reopen transitions, attachment limits, feedback prompt, email/push notifications, and cross-tenant isolation.
- Updated spec status from draft to agreed.
Reason:
- Spec needed hardening per implementation audit. Edge cases were absent, test plan was thin, error states lacked structured detail.
Impact:
- Spec authors and future implementation agents.
Tests:
- Spec-only change — no code tests affected.
2026-06-14 — Confirmed no backend support app exists; updated §12
Type: Documentation Change
Summary:
- Updated §12 Implementation Audit with concrete scan results: no
apps/support/directory, nosupportin INSTALLED_APPS, no Ticket/TicketMessage models found. - No AC, API, or model changes needed — spec already thorough from round 1 hardening.
Reason:
- Per T4-G round 2 task: verify support app backend completeness. Found empty — documented for implementation agents.
Impact:
- Implementation agents must create
apps/support/from scratch per §4 data model. Tenant isolation checks (TestSupportIsolation) will pass only after the model exists.
Tests:
- Spec-only change — no code tests affected.
2026-06-14 — Attachment limits, reopen action, all rework items closed
Type: Feature
Summary:
- Added
Ticket.attachments = ManyToManyField("uploads.Upload", ...)+ migration0002_add_ticket_attachments. - Added
POST /api/support/tickets/<id>/attach/action: enforces cumulative ≤3 file limit, requiresstatus=completed, returns 400 on violation. - Added
POST /api/support/tickets/<id>/reopen/action: staff-only, ticket must beclosed, transitions toopen. TicketDetailSerializernow includesattachmentsas embedded upload summaries (id, filename, public_url, content_type, byte_size).- All 4 rework items struck — display_id and feedback were already implemented; attachment limits and reopen are new.
Tests:
TestAttachmentLimits(5 tests): single attach, too-many-in-one-request, cumulative limit exceeded, pending upload rejected, non-creator-non-staff blocked.TestStaffReopenTicket(3 tests): staff reopen success, non-staff 403, non-closed 400.