Extension of the existing Session Support feature. The current 6 hardcoded issue types (
internet / camera / microphone / device / power / other) each carry a 4-step troubleshooting checklist and nothing else. This plan adds per-category custom metadata fields that admins configure and the user form renders dynamically, so support gets first-touch context without bloating the simple cases.
Goals
contextFields: Record<key,
value> block alongside the standard fields. Admin can read it at
a glance.Non-goals (v1)
errorCode only if
hasError === true”). That needs a rules engine. v1 is
unconditional rendering.key after submissions exist. key is the
stable identifier; renaming label is fine, renaming key
would orphan historical data.os is
Windows”). v1 admin can read contextFields on a single ticket;
no aggregate queries.SupportCategory (replaces the role of AttendanceGuidance)We rename and expand the existing AttendanceGuidance model.
It’s the per-category lookup; the only data on it today is
{issueType, steps, updatedBy, timestamps}. After this plan it’s
the full category definition.
{
_id : ObjectId
issueType : String (unique, kebab-case key — e.g. 'internet', 'stipend-issue')
label : String (display name — e.g. "Internet Problem")
shortLabel : String (one-word for compact UI — e.g. "Internet")
description : String (admin-only, optional)
iconKey : String (enum: 'wifi' | 'camera' | 'mic' | 'device' | 'power' | 'other' | 'generic')
steps : String[] (existing troubleshooting checklist)
fields : ContextField[]
isActive : Boolean (default true)
displayOrder : Number (admin can reorder categories)
createdBy : ObjectId(User)
createdAt : Date
updatedAt : Date
}
ContextField (subdocument on SupportCategory){
_id : ObjectId
key : String (machine-readable, immutable after creation;
used as the key in the ticket's contextFields map.
auto-derived from label if not provided.)
label : String (display name, editable)
type : 'text' | 'textarea' | 'number' | 'date' | 'boolean' | 'dropdown'
required : Boolean
placeholder : String (text/textarea only)
helpText : String (small grey text below the field, optional)
options : { value, label }[] (dropdown only, min 1)
displayOrder : Number
archived : Boolean (soft delete; preserved for historical tickets)
archivedAt : Date | null
}
Indexes:
{ isActive: 1, displayOrder: 1 } — user-facing list{ issueType: 1 } (unique) — admin lookupSupportRequest (additive){
…existing fields…
contextFields : Map<String, String | Number | Boolean | null> // NEW
}
Why Map and not a typed subdoc? Because the field set is dynamic
per category — typed subdocs would force an enum update every time
an admin adds a field. Mongoose Maps serialize cleanly to BSON and
let us filter by key at the controller layer.
Storage: contextFields.set('errorCode', 'E1234') →
{ errorCode: 'E1234' } in the BSON document.
User, FeatureFlag, Notification, AdminLog.| Type | UI widget | Validation | Stored as |
|---|---|---|---|
text |
single-line input | max length 200, trim non-empty if required | string |
textarea |
multi-line input (4 rows) | max length 2000, trim non-empty if required | string |
number |
number input | numeric, optional min/max (v2) | number |
date |
<input type="date"> |
ISO yyyy-mm-dd | string (ISO) |
boolean |
checkbox | always true/false | boolean |
dropdown |
<select> |
one of options[].value |
string |
A DynamicFieldInput component encapsulates the render + client-side
validation. The controller re-validates on submit (defence in depth).
POST /api/support/requests
{
issueType: 'device',
title: 'Device Failure — Unable to attend session', // optional; derived if blank
details: 'My laptop kept crashing when joining the call',
attemptedSteps: ['Restart the device once…', …],
documents: [ … ],
guidanceShownAt: '2026-06-10T07:03:11.000Z',
contextFields: {
os: 'Windows',
errorMessage: 'BSOD — driver_irql_not_less_or_equal',
hasAdapter: true,
purchasedOn: '2024-11-01',
}
}
Server-side flow on submit:
SupportCategory for issueType. If missing or
isActive: false → 400 INVALID_CATEGORY.options → 400 INVALID_FIELD_displayOrder etc. is
never stored on the ticket — only the values./admin/support/categoriesA new admin page. Sidebar gets a new “Categories” entry under the existing “Support” group.
┌─ Support / Categories ────────────────────────┐
│ [+ New Category] │
│ │
│ ┌─ Internet (internet) ─────────────────┐ │
│ │ Label: Internet Problem │ │
│ │ Description: … │ │
│ │ Steps: 4 │ │
│ │ Fields: 0 [Edit checklist] [Edit] │ │
│ │ Active ☑ Order ↑↓ [Archive] │ │
│ └──────────────────────────────────────┘ │
│ │
│ ┌─ Device (device) ─────────────────────┐ │
│ │ Fields (2) [+ Add field] │ │
│ │ • OS (dropdown, required) [↑↓] [×] │ │
│ │ • Error message (textarea, opt) [↑↓] [×] │
│ └──────────────────────────────────────┘ │
└──────────────────────────────────────────────┘
Same modal for both:
value + label rows, only for dropdown;
at least 1 required)Up/Down buttons (no drag-and-drop — the existing admin pattern is plain buttons, keeps the bundle small).
The submit wizard grows from 3 steps to 4:
We could keep 3 steps by folding attach-proof into step 3, but mobile UX benefits from a dedicated small step. Going with 4.
When the user picks a category in step 1, the page fetches the category’s full schema (steps + fields). Subsequent step transitions are instant (no re-fetch).
DynamicFieldInput renders the right widget per type. The component
is shared between the user form (write) and the admin ticket view
(read-only).
The admin ticket page (AdminSupportTicket.tsx) renders a new
“Provided context” section between the student’s message and the
follow-up thread. Layout:
┌─ Provided context ──────────────────────┐
│ OS: Windows │
│ Error message: BSOD — driver_… │
│ Has adapter: ✓ │
│ Purchased on: Nov 1, 2024 │
└────────────────────────────────────────┘
Empty state: hidden (the section only appears if contextFields
has at least one key).
If a field was archived after the ticket was submitted, the value
still renders, but with a small (archived) badge next to the
label so admins know the field is no longer in the schema.
Migration script (scripts/seedSupportCategories.ts, idempotent):
ISSUE_CONFIGS keys (internet,
camera, microphone, device, power, other):
SupportCategory with issueType: key,
label: ISSUE_CONFIGS[key].label, shortLabel: key,
steps: ISSUE_CONFIGS[key].steps, fields: [],
isActive: true.iconKey defaults to the issue type itself.Existing AttendanceGuidance documents: the controller keeps
reading from SupportCategory going forward. Old
AttendanceGuidance rows are left in place (read by no one) for
one release, then a separate cleanup script removes them in
v1.1.
SupportRequest documents: contextFields is missing
on the schema level. Mongoose defaults to {}. Admin ticket
view sees an empty map → section hidden. No data migration
needed.The new SupportCategory model is the source of truth. The
existing ISSUE_CONFIGS constant in SupportRequest.ts becomes a
one-time seed value, not a runtime lookup.
DynamicFieldInput validates before allowing submit.
Required-but-empty → field shows red border + inline error.contextFields but
no field is rendered for input on new submissions. Admin can still
read the historical value on the existing ticket.(not provided) annotation if absent.details.key: 'os' is renamed via direct DB intervention
to key: 'operatingSystem' → historical tickets still have
os: 'Windows', the admin view looks up the field by the
current key and won’t render. Solution: store the field’s
key snapshot on the value at submit time, so the admin view
can render “OS: Windows” using the field’s display label even
after the key is renamed. v1 simplification: just store
{key, label, value} triples in contextFields instead of a
bare map. Slightly more storage, much more robust. Going with
this.The {key, label, value} triple format is the chosen v1 storage
shape:
contextFields: { key: string, label: string, value: string | number | boolean | null }[]
authorize('admin', 'moderator').GET /api/support/troubleshoot/:issueType returns just
steps + fields (no admin-only metadata).backend/
├── models/
│ ├── AttendanceGuidance.ts (deprecated — read by no one after rollout)
│ └── SupportCategory.ts (new — replaces the above)
├── controllers/
│ └── supportController.ts (add: listCategories, upsertCategory, archiveCategory,
│ addContextField, updateContextField, archiveContextField;
│ modify: getTroubleshootSteps returns fields too;
│ modify: createSupportRequest validates + persists contextFields)
├── routes/
│ └── support.ts (add: /categories GET + admin POST/PATCH/DELETE;
│ add: /categories/:type/fields POST/PATCH/DELETE)
├── scripts/
│ └── seedSupportCategories.ts (new, idempotent — one-time run)
└── server.ts (no change)
frontend/src/
├── pages/
│ └── NewSupportRequestPage.tsx (extend step 3 with dynamic fields)
├── components/
│ ├── support/
│ │ ├── types.ts (add ContextField, FieldType, SupportCategory)
│ │ ├── api.ts (add category CRUD)
│ │ ├── DynamicFieldInput.tsx (new — renders + validates one field)
│ │ └── ContextFieldsDisplay.tsx (new — read-only rendering for admin view)
│ └── admin/
│ └── pages/
│ ├── AdminSupportCategories.tsx (new — schema editor)
│ └── AdminSupportTicket.tsx (extend — render ContextFieldsDisplay)
└── admin/components/layout/AdminSidebar.tsx (add 'Categories' under Support group)
SupportCategory.ts,
seedSupportCategories.ts. Run migration. Existing 6 categories
now live in the DB with empty fields: []. Behaviour for the
existing feature is identical to before./troubleshoot/:issueTypecontextFields on submitcontextFields on the ticketDynamicFieldInput component — single component handling
all 4 field typesContextFieldsDisplay component — read-only viewContextFieldsDisplayEstimated 1 working day end-to-end on top of the existing support feature.
Why not store schema on the ticket directly? Storing the full field definition on each ticket would mean every ticket ships a copy of the schema. A category change (rename label, add a new field) would never apply to historical tickets — but that means the admin view can never show “updated labels”. The “schema lives in one place, values live on the ticket” split is the same pattern the existing guidance editor uses.
Why a Map of {key, label, value} triples, not a flat map?
The triple format survives admin renames of the field’s label
without losing meaning. The cost is ~30% more storage per ticket,
which is fine for the expected volume (< 1k tickets/month).
Why open the issueType enum (no longer hardcoded)?
Admins will want to add new categories like “Stipend Issue” or
“Certificate Problem”. The hardcoded enum blocks this. Moving
the validation to a DB lookup is the natural extension and
matches the dynamic nature of the schema system.
Why 4 wizard steps instead of 3? The dynamic fields could overflow the “Describe” step on mobile. A dedicated “Provide details” step is the cleaner UX. Most users won’t notice the extra step count because step 1 is now strictly “pick a category” — no field render yet.
When v1 has been live for a month and admins have actually been configuring fields, v2 priorities would be:
errorCode only
when hasError === true”. Rules engine in the schema.os: Windows).errorMessage contains ‘driver_irql’”.