Context
FDM needs an in-app helpdesk system to replace the current mailto:support@fdm.nl flow. This issue implements the core ticket lifecycle: users create tickets via the web UI, AI generates subject + priority, agents see and handle tickets in a shared inbox, users receive email notifications on replies.
Scope
In Scope
fdm-helpdesk package scaffold (monorepo workspace, tsdown build, Drizzle schema, migrations, vitest config)
- Database schema:
tickets, messages, agents, ticket_assignments, ticket_activity, ticket_views, tags, ticket_tags_map, saved_replies
- Ticket state machine: transition validation (open → in_progress → waiting_on_customer → resolved → closed)
- Core functions: createTicket, getTicket, updateTicketStatus, assignTicket, addMessage, closeTicket
- AI triage (Gemini): auto-generate subject + priority from message body, with fallback
- Ticket reference generation: nanoid-based
TK-XXXXXX with collision retry
- Agent management: register agent, list agents, basic CRUD
- Tags & saved replies: CRUD, tag assignment to tickets, saved reply variable substitution
- Shared inbox queries: getInbox, getMyTickets, getUnassignedTickets, searchTickets
- Collision detection: ticket_views table with last_viewed_at, is_typing indicator
- Outbound email notifications: Postmark transactional emails (new ticket confirmation to user, reply notification to user)
- Customer UI (
/support/*): ticket list, create ticket form, conversation view
- Agent UI (
/admin/support/*): shared inbox with tabs/filters, ticket detail with reply + internal notes + metadata sidebar
- Navigation: sidebar entry for customers ("Ondersteuning" with badge), admin sidebar for agents ("Inbox" with unassigned count)
- GDPR: soft-delete on messages, user deletion hook (anonymization)
- Testing: integration tests for ticket lifecycle, unit tests for state machine + triage
Acceptance Criteria
Technical Implementation
1. Package Scaffold
fdm-helpdesk/
├── src/
│ ├── index.ts
│ ├── db/
│ │ ├── schema-helpdesk.ts
│ │ └── migrations/
│ ├── ticket.ts
│ ├── ticket-transitions.ts
│ ├── ticket-ref.ts
│ ├── conversation.ts
│ ├── inbox.ts
│ ├── agent.ts
│ ├── tag.ts
│ ├── saved-reply.ts
│ ├── triage.ts
│ ├── error.ts (re-export from fdm-core)
│ └── id.ts (re-export from fdm-core)
├── package.json
├── tsdown.config.ts (mirror fdm-core, copy migrations in onSuccess)
├── tsconfig.json
├── tsconfig.build.json
├── vitest.config.ts
├── drizzle.config.ts
└── biome.json (extends root)
2. Key Schema Tables
Core tables:
tickets (ticket_id, ticket_ref, requester_id, subject, status, priority, farm_id, created, updated)
messages (message_id, ticket_id, sender_id, sender_type, body, is_internal, deleted_at, created)
agents (agent_id, principal_id, display_name, role, is_active, assignment_tiers, work_days, max_tickets)
ticket_assignments (assignment_id, ticket_id, agent_id, assigned_by, is_primary, assigned_at, unassigned_at)
ticket_activity (activity_id, ticket_id, actor_id, action, old_value, new_value, metadata, created)
ticket_views (ticket_id, agent_id, last_viewed_at, is_typing)
tags, ticket_tags_map, saved_replies
3. AI Triage (Gemini)
- Uses
@google/genai with structured output schema
- Model:
gemini-3.0-flash
- Generates: subject (max 80 chars, same language as message), priority (low/normal/high/urgent), reasoning
- Fallback on failure: first sentence as subject, "normal" priority
- Triage reasoning stored in
ticket_activity for agent context
- Requires
GEMINI_API_KEY environment variable
4. App Routes (fdm-app)
Customer routes:
| Route |
Purpose |
support.tsx |
Layout with back navigation |
support._index.tsx |
My tickets list (open/closed tabs) |
support.new.tsx |
Create ticket form |
support.$ticket_id.tsx |
Ticket conversation view |
Agent routes:
| Route |
Purpose |
admin.support.tsx |
Agent inbox layout (with access guard) |
admin.support._index.tsx |
Shared inbox (list + filters) |
admin.support.$ticket_id.tsx |
Ticket detail (conversation + sidebar) |
admin.support.settings.tsx |
Tags, saved replies, agent management |
4a. Agent Access Control (MVP)
The full role system (better-auth admin plugin with helpdeskAgent / helpdeskAdmin roles) is delivered in #603. For the MVP, the agents table is the access gate:
- Guard function:
requireAgent(fdm, principal_id) — queries the agents table for a row matching the current user's principal_id with is_active = true. Throws a 403 if not found.
- Route loaders: The
admin.support.tsx layout loader calls requireAgent(). All nested agent routes inherit this guard — no duplicate checks needed.
- Seeding agents: Provide a
seedAgent(fdm, principal_id, display_name) function that inserts a row into the agents table. During initial deployment, seed the first agent(s) via a migration or CLI script.
- Admin sidebar visibility: The sidebar "Inbox" item is only rendered when the user exists in the
agents table (checked via a lightweight loader or root context).
This gives a clean separation: This issue gates access by agent table membership, #603upgrades to proper role-based access via the admin plugin with a UI to assign agents.
5. UI Components (new components/blocks/support/)
| Component |
Description |
ticket-list.tsx |
Sortable list of ticket cards with status/priority |
ticket-card.tsx |
Single row: ref, subject, status, time, assigned |
ticket-detail.tsx |
Full conversation thread view |
message-bubble.tsx |
Single message with sender, time |
internal-note.tsx |
Agent-only message (amber background) |
reply-box.tsx |
Textarea with tabs (reply/note), send button |
ticket-sidebar.tsx |
Metadata panel: status, priority, tags, user info, activity |
priority-badge.tsx |
Colored dot + label |
status-badge.tsx |
Status chip with icon |
collision-banner.tsx |
"X is also viewing" warning bar |
saved-reply-picker.tsx |
Dropdown inserting templates into reply box |
tag-selector.tsx |
Autocomplete multi-select for tags |
ticket-create-form.tsx |
Create ticket form (textarea, farm selector) |
6. Email Templates
| Template |
Trigger |
Content |
ticket-confirmation.tsx |
User creates ticket |
"We hebben je bericht ontvangen (TK-XXXXXX)" |
ticket-reply.tsx |
Agent replies |
Agent's reply with link to view in app |
Uses existing BaseEmailLayout + Postmark transactional stream.
7. Navigation Changes
components/blocks/sidebar/support.tsx: Replace mailto: handler with <Link to="/support">. Add badge showing count of tickets with unread agent replies.
Admin sidebar: Add "Inbox" item with badge for unassigned ticket count (only visible to helpdeskAgent/helpdeskAdmin roles).
Testing Requirements
- Integration tests: Full ticket lifecycle (create → assign → reply → resolve → close)
- Unit tests: State machine transitions (valid + invalid), ticket-ref generation, triage fallback, saved reply variable substitution
- Coverage target: ≥80% line coverage for
fdm-helpdesk/src/
Dependencies
@google/genai (Gemini API)
@svenvw/fdm-core (existing: auth, DB connection, createId, handleError)
- Postmark (existing:
fdm-app/app/lib/email.server.ts)
- Environment:
GEMINI_API_KEY
Definition of Done
A user can submit a support request in-app, an agent can handle it from the inbox, and the user gets notified of the resolution — all without leaving the FDM platform.
Context
FDM needs an in-app helpdesk system to replace the current
mailto:support@fdm.nlflow. This issue implements the core ticket lifecycle: users create tickets via the web UI, AI generates subject + priority, agents see and handle tickets in a shared inbox, users receive email notifications on replies.Scope
In Scope
fdm-helpdeskpackage scaffold (monorepo workspace, tsdown build, Drizzle schema, migrations, vitest config)tickets,messages,agents,ticket_assignments,ticket_activity,ticket_views,tags,ticket_tags_map,saved_repliesTK-XXXXXXwith collision retry/support/*): ticket list, create ticket form, conversation view/admin/support/*): shared inbox with tabs/filters, ticket detail with reply + internal notes + metadata sidebarAcceptance Criteria
fdm-helpdeskpackage builds and publishes in monorepofdm-helpdeskschema/support/newwith a free-text messageTK-XXXXXXreference/supportwith status indicators/support/$ticket_idagentstable can access/admin/support/*routes (403 otherwise)/admin/support(shared inbox)Technical Implementation
1. Package Scaffold
2. Key Schema Tables
Core tables:
tickets(ticket_id, ticket_ref, requester_id, subject, status, priority, farm_id, created, updated)messages(message_id, ticket_id, sender_id, sender_type, body, is_internal, deleted_at, created)agents(agent_id, principal_id, display_name, role, is_active, assignment_tiers, work_days, max_tickets)ticket_assignments(assignment_id, ticket_id, agent_id, assigned_by, is_primary, assigned_at, unassigned_at)ticket_activity(activity_id, ticket_id, actor_id, action, old_value, new_value, metadata, created)ticket_views(ticket_id, agent_id, last_viewed_at, is_typing)tags,ticket_tags_map,saved_replies3. AI Triage (Gemini)
@google/genaiwith structured output schemagemini-3.0-flashticket_activityfor agent contextGEMINI_API_KEYenvironment variable4. App Routes (fdm-app)
Customer routes:
support.tsxsupport._index.tsxsupport.new.tsxsupport.$ticket_id.tsxAgent routes:
admin.support.tsxadmin.support._index.tsxadmin.support.$ticket_id.tsxadmin.support.settings.tsx4a. Agent Access Control (MVP)
The full role system (better-auth admin plugin with
helpdeskAgent/helpdeskAdminroles) is delivered in #603. For the MVP, theagentstable is the access gate:requireAgent(fdm, principal_id)— queries theagentstable for a row matching the current user'sprincipal_idwithis_active = true. Throws a 403 if not found.admin.support.tsxlayout loader callsrequireAgent(). All nested agent routes inherit this guard — no duplicate checks needed.seedAgent(fdm, principal_id, display_name)function that inserts a row into theagentstable. During initial deployment, seed the first agent(s) via a migration or CLI script.agentstable (checked via a lightweight loader or root context).This gives a clean separation: This issue gates access by agent table membership, #603upgrades to proper role-based access via the admin plugin with a UI to assign agents.
5. UI Components (new
components/blocks/support/)ticket-list.tsxticket-card.tsxticket-detail.tsxmessage-bubble.tsxinternal-note.tsxreply-box.tsxticket-sidebar.tsxpriority-badge.tsxstatus-badge.tsxcollision-banner.tsxsaved-reply-picker.tsxtag-selector.tsxticket-create-form.tsx6. Email Templates
ticket-confirmation.tsxticket-reply.tsxUses existing
BaseEmailLayout+ Postmark transactional stream.7. Navigation Changes
components/blocks/sidebar/support.tsx: Replacemailto:handler with<Link to="/support">. Add badge showing count of tickets with unread agent replies.Admin sidebar: Add "Inbox" item with badge for unassigned ticket count (only visible to
helpdeskAgent/helpdeskAdminroles).Testing Requirements
fdm-helpdesk/src/Dependencies
@google/genai(Gemini API)@svenvw/fdm-core(existing: auth, DB connection, createId, handleError)fdm-app/app/lib/email.server.ts)GEMINI_API_KEYDefinition of Done
A user can submit a support request in-app, an agent can handle it from the inbox, and the user gets notified of the resolution — all without leaving the FDM platform.