# How to Add a Help Desk to Your App (Copy-Paste Prompts Included)

- Tool: App Features
- Last updated: July 2026

## TL;DR

An in-app help desk needs a ticket submission form, a per-ticket conversation thread with Supabase Realtime, push notifications when an agent replies (Firebase Cloud Messaging), and a screenshot attachment pipeline via Supabase Storage. With FlutterFlow you can build the user-facing side in 4–7 hours. Running cost is $0/month for up to 100 users — Supabase free tier, FCM free, and Resend's 3,000-email/month free tier cover it.

## What an In-App Help Desk Actually Is

An in-app help desk lets users submit support tickets, track their status, and exchange messages with a support agent — all without leaving the mobile app or switching to email. Each ticket is a persistent thread: the user writes the first message (with an optional screenshot), the agent replies from a web panel or separate admin screen, and both sides see the conversation in real time. The three hard parts are screenshot attachment (image_picker → Supabase Storage), agent-reply push notifications (Supabase trigger → Edge Function → FCM), and RLS that guarantees users can only ever read their own tickets. Everything else — the form, the list, the chat-style thread — is straightforward Flutter UI.

## Anatomy of the Feature

Six components working together. FlutterFlow handles the visual ones well; the attachment upload and push notification pipeline need custom action blocks.

- **Ticket submission form** (ui): Flutter Form with TextFormField widgets for subject and description (minimum 20 characters validated), an ImagePicker button using the image_picker package, and a Submit button that disables after the first tap to prevent duplicate submissions.
- **Supabase Storage attachment upload** (backend): The image_picker result is uploaded to a Supabase Storage bucket named 'support-attachments' in the path user_id/filename. Returns a signed URL that is stored in the support_tickets row. The bucket policy restricts writes to the authenticated user's own folder.
- **Ticket list screen** (ui): Flutter ListView of support_tickets filtered by the current user's id, sorted by updated_at DESC. Each row shows subject, a Chip widget for status (orange = open, blue = in_progress, green = resolved), and the timestamp of the last message.
- **Conversation thread** (ui): Flutter ListView.builder of ticket_messages sorted by created_at ASC. User messages are right-aligned with a blue bubble, agent messages left-aligned with a grey bubble — mirrors iMessage layout using Flutter Align and Container widgets. Each message shows sender name and a relative timestamp.
- **Supabase Realtime thread subscription** (backend): A Supabase Realtime channel per ticket: supabase.channel('ticket-[ticketId]').on('postgres_changes', event INSERT on ticket_messages, filter ticket_id=eq.[ticketId], callback) rebuilds the message list when an agent posts. No polling needed.
- **Admin notification pipeline** (service): A Supabase Edge Function triggered on ticket INSERT sends a Slack Incoming Webhook alert to the support team and an email confirmation to the user via Resend (free tier: 3,000 emails/month). Agent replies trigger a Postgres trigger that calls the Edge Function via pg_net, which sends an FCM push notification to the mobile user's device using their stored FCM token.

## Data model

Two main tables plus a token store. Run this in the Supabase SQL Editor (Dashboard → SQL Editor → New query):

```sql
create table public.support_tickets (
  id uuid primary key default gen_random_uuid(),
  user_id uuid references auth.users(id) on delete cascade not null,
  subject text not null,
  description text not null,
  status text not null default 'open'
    check (status in ('open', 'in_progress', 'resolved', 'closed')),
  attachment_url text,
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now()
);

create table public.ticket_messages (
  id uuid primary key default gen_random_uuid(),
  ticket_id uuid references public.support_tickets(id) on delete cascade not null,
  sender_id uuid references auth.users(id) on delete cascade not null,
  sender_role text not null check (sender_role in ('user', 'agent')),
  body text not null,
  created_at timestamptz not null default now()
);

create table public.user_fcm_tokens (
  user_id uuid references auth.users(id) on delete cascade primary key,
  token text not null,
  updated_at timestamptz not null default now()
);

-- Indexes
create index support_tickets_user_updated_idx
  on public.support_tickets (user_id, updated_at desc);
create index ticket_messages_ticket_created_idx
  on public.ticket_messages (ticket_id, created_at asc);

-- RLS
alter table public.support_tickets enable row level security;
alter table public.ticket_messages enable row level security;
alter table public.user_fcm_tokens enable row level security;

create policy "Users can view own tickets"
  on public.support_tickets for select
  using (auth.uid() = user_id);

create policy "Users can insert own tickets"
  on public.support_tickets for insert
  with check (auth.uid() = user_id);

create policy "Users can update own tickets"
  on public.support_tickets for update
  using (auth.uid() = user_id);

create policy "Users can view messages on own tickets"
  on public.ticket_messages for select
  using (
    EXISTS (
      SELECT 1 FROM public.support_tickets
      WHERE id = ticket_id AND user_id = auth.uid()
    )
    OR sender_id = auth.uid()
  );

create policy "Users can insert own messages"
  on public.ticket_messages for insert
  with check (auth.uid() = sender_id AND sender_role = 'user');

create policy "Users can manage own FCM token"
  on public.user_fcm_tokens for all
  using (auth.uid() = user_id)
  with check (auth.uid() = user_id);

-- Auto-update support_tickets.updated_at when a message is added
create or replace function public.touch_ticket_on_message()
returns trigger language plpgsql security definer as $$
begin
  update public.support_tickets
  set updated_at = now()
  where id = new.ticket_id;
  return new;
end;
$$;

create trigger ticket_messages_touch_ticket
  after insert on public.ticket_messages
  for each row execute procedure public.touch_ticket_on_message();
```

Agent writes (admin panel inserting messages with sender_role = 'agent') must use the Supabase service_role key in the Edge Function — the RLS policy restricts direct agent inserts from the client.

## Build paths

### Flutterflow — fit 4/10, 4–7 hours

FlutterFlow handles the ticket form, list screen, and thread layout visually via its drag-and-drop builder and Supabase Backend Queries; Realtime stream actions keep the thread live without polling.

1. In FlutterFlow, add a new page 'SubmitTicket': drag a Form widget onto the canvas, add TextFormFields for subject and description, then add an image picker button using the Action editor → Upload Media → Supabase Storage
2. Wire the form submit to a Supabase Insert row action on support_tickets; add a Snackbar success action after insert and navigate to the ticket list page
3. Create a 'MyTickets' page with a ListView connected to a Supabase Backend Query on support_tickets filtered by current user id, sorted by updated_at DESC; add a Chip widget for status with conditional color styling
4. Create a 'TicketThread' page with a ListView.builder backend query on ticket_messages filtered by ticket_id; add a Realtime Stream action so new agent replies appear instantly; add a text field and send button at the bottom that inserts a new ticket_messages row
5. In FlutterFlow → Settings → App Details → iOS Permissions, add NSPhotoLibraryUsageDescription and NSCameraUsageDescription permission strings
6. For FCM push notifications on agent reply, create a Supabase Edge Function in your project that accepts a payload and calls the FCM HTTP v1 API; the Postgres trigger fires it automatically when an agent inserts a message

Limitations:

- image_picker Supabase Storage upload requires a custom Dart action block — the built-in upload action may not correctly place the file in the user_id/* path required by the bucket RLS policy
- The FCM push notification pipeline (Postgres trigger → Edge Function → FCM) cannot be generated by FlutterFlow; it must be written and deployed manually in Supabase
- Admin agent panel for responding to tickets must be built separately — either in a second FlutterFlow project (restricted by role) or a web tool like Lovable

### Lovable — fit 2/10, 6–9 hours

Lovable's best contribution here is the admin-side web help desk panel — agents viewing and replying to tickets in a browser. Pair it with FlutterFlow for the mobile user-facing side.

1. Create a new Lovable project for the admin panel; connect Lovable Cloud to provision Supabase with the same project URL your Flutter app uses
2. Paste the prompt below; Lovable will generate a tickets list page, a thread view, and a reply form wired to Supabase via the service_role key in an Edge Function
3. Add a role check in the admin panel: only users with role = 'agent' in user_roles can see the tickets list; test by logging in as a regular user
4. Publish the admin panel to a separate URL (e.g. admin.yourdomain.com); share it only with your support team

Starter prompt:

```
Build an admin help desk panel for a mobile support ticket system. Supabase tables: support_tickets (id, user_id, subject, description, status, attachment_url, created_at, updated_at) and ticket_messages (id, ticket_id, sender_id, sender_role ['user'|'agent'], body, created_at). Admin panel pages: 1) Ticket list — show all tickets sorted by updated_at DESC, status badge with color (orange=open, blue=in_progress, green=resolved), filter by status, click to open thread. 2) Ticket thread — show all messages in chronological order, user messages right-aligned, agent messages left-aligned. Reply form at bottom that inserts a new ticket_messages row with sender_role='agent' using the Supabase service_role key via a Supabase Edge Function (never expose service_role to the browser). After agent reply, update support_tickets.status to 'in_progress' if it was 'open'. Add a role guard: only show this panel to users whose role in user_roles table equals 'agent'. Include an attachment preview if attachment_url is present on the ticket.
```

Limitations:

- Lovable cannot build the Flutter mobile app experience; this path only covers the agent-facing admin web panel
- Combining Lovable (admin web) with FlutterFlow (mobile user) adds coordination overhead — they must share the same Supabase project and agree on table schemas
- Image attachments uploaded from the Flutter app must be in a Supabase Storage bucket that the Lovable admin panel's Edge Function has service_role access to read

### Custom — fit 5/10, 1–2 weeks

Custom development is the right path when you have more than two concurrent support agents, need SLA timers, ticket routing, or want to integrate with Intercom or Zendesk instead of building your own.

1. Design the ticket routing model: agent assignment table, queue logic (round-robin or skill-based), and SLA timer (pg_cron escalation job)
2. Build the agent dashboard as a Next.js web app with TanStack Table for ticket queue, real-time Supabase subscription for new tickets, and a rich text reply editor with canned response library
3. Integrate Intercom JavaScript SDK or Zendesk Widget as alternatives to the custom thread UI — both provide chat interfaces that replace the in-app thread while keeping your ticket data in Supabase
4. Add multilingual support: store a preferred_language field on the user, auto-translate incoming messages with DeepL API before agents see them

Limitations:

- Intercom Starter costs approximately $39/seat/month — for 3 agents that is $117/month versus ~$25/month for a self-built Supabase solution
- Building SLA timers, routing logic, and a canned response library from scratch adds 1–2 weeks to the timeline even for experienced developers

## Gotchas

- **Screenshot attachment silently returns 403** — The AI creates the 'support-attachments' Supabase Storage bucket but generates no RLS policy for it, or generates one that only checks bucket_id without verifying the folder path. Authenticated uploads return 403 with no error message surfaced in the Flutter UI. Fix: In Supabase Dashboard → Storage → Policies, add an INSERT policy with the condition: bucket_id = 'support-attachments' AND auth.uid()::text = (storage.foldername(name))[1]. This ensures each user can only write to their own subfolder. Test by uploading a file from a signed-in user before wiring the Flutter form.
- **Agent replies never trigger push notifications** — The Edge Function that sends FCM push is described in the prompt and even generated by the AI, but there is no Postgres trigger that actually invokes it when an agent posts a message. The ticket_messages table gets the row; the user's phone gets nothing. Fix: Create a Postgres trigger on ticket_messages: AFTER INSERT WHEN (new.sender_role = 'agent'), call the Edge Function via pg_net.http_post() passing the ticket owner's FCM token retrieved from user_fcm_tokens. Verify the trigger exists in Supabase Dashboard → Database → Triggers before testing end-to-end.
- **All ticket thread messages appear in every open thread** — The Supabase Realtime subscription on ticket_messages is set up without a filter. Every message insert — across every ticket — broadcasts to every open thread screen. Users see other people's messages if they have two threads open simultaneously. Fix: Add the filter parameter to the channel subscription: supabase.channel('thread-[ticketId]').on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'ticket_messages', filter: 'ticket_id=eq.[ticketId]' }, callback). The ticketId must be the specific UUID of the ticket currently being viewed.
- **Ticket status badge does not update after agent changes status** — The ticket list screen performs a one-time fetch on page load. When an agent changes a ticket from 'open' to 'in_progress', the user still sees the old orange badge until they leave and re-enter the screen. Fix: Add a Realtime subscription on support_tickets filtered by user_id = auth.uid(). When a status UPDATE event fires, rebuild the ticket list. In FlutterFlow, use a Realtime Stream backend query on the ticket list page rather than a one-time fetch query.
- **iOS build crashes on first attachment attempt** — Flutter's image_picker package requires NSPhotoLibraryUsageDescription and NSCameraUsageDescription keys in Info.plist. If these are absent, iOS terminates the app immediately when the user taps the attachment button. FlutterFlow does not add these by default. Fix: In FlutterFlow, go to Settings → App Details → iOS Permissions and add both keys with descriptive strings: 'Used to attach screenshots to your support ticket.' For custom Flutter builds, add the keys directly to ios/Runner/Info.plist in Xcode. Vague strings like 'Camera access' may cause App Store review rejection.

## Best practices

- Store FCM tokens in a user_fcm_tokens table and refresh them on every app start — tokens expire and rotating them prevents missed push notifications
- Filter the Realtime ticket_messages subscription by ticket_id on every thread screen — missing this filter broadcasts every message in your system to every open screen
- Add a NOT NULL CHECK (length(description) >= 20) constraint on support_tickets so the database rejects empty reports that the mobile form validator misses
- Use service_role key only inside Supabase Edge Functions — never ship it in the Flutter app bundle; agent actions that bypass RLS belong in the Edge Function layer
- Set support_tickets.updated_at via a Postgres trigger on ticket_messages INSERT rather than trusting the Flutter client to send the correct timestamp
- Design the attachment path as user_id/ticket_id/filename so Storage RLS can verify ownership without an extra database lookup
- Send the email confirmation via Resend inside the same Edge Function that notifies Slack — one trigger, two notifications, and the user has a paper trail immediately

## Frequently asked questions

### Can I add a FAQ search before the ticket form to reduce ticket volume?

Yes, and it is worth doing. Add a search field above the 'Submit a ticket' button that queries a knowledge_base table in Supabase with a full-text search (Postgres tsvector). Show the top 3 matching articles in a list. Only if the user taps 'None of these helped' does the ticket form appear. Teams that add this step typically see 20–40% fewer tickets submitted.

### How do I let users attach screenshots to their tickets?

Use Flutter's image_picker package. On form submit, upload the picked image to a Supabase Storage bucket called 'support-attachments' in a path matching user_id/filename. Store the returned signed URL in the support_tickets row. The bucket RLS policy must restrict writes to auth.uid()::text matching the first path segment, otherwise any authenticated user can overwrite any file.

### Can I set up automatic email confirmation when a ticket is created?

Yes. Create a Supabase Edge Function triggered by a Postgres AFTER INSERT trigger on support_tickets. Inside the Edge Function, call the Resend API (free up to 3,000 emails/month) with the ticket subject and a confirmation message. The email goes to the address from auth.users. This takes about 30 minutes to set up and gives users immediate confirmation that their report was received.

### How do I make sure only the ticket owner can see their support thread?

Add RLS policies on both tables. On support_tickets: SELECT using auth.uid() = user_id. On ticket_messages: SELECT using EXISTS (SELECT 1 FROM support_tickets WHERE id = ticket_id AND user_id = auth.uid()). This way even if someone guesses a ticket UUID, the database returns no rows. Test by querying from a different user's session in the Supabase SQL Editor.

### Can I assign tickets to specific support agents?

Add an assigned_agent_id column (uuid FK to auth.users) to support_tickets. Build an agent admin panel (Lovable or a separate Next.js page) that shows unassigned tickets and allows an agent to claim or assign them. Add a check_constraint that only users with role = 'agent' in user_roles can be assigned. This is the first feature that makes custom development worth considering over FlutterFlow.

### How do I add ticket priority levels?

Add a priority column with CHECK (priority IN ('low', 'medium', 'urgent')) to support_tickets. Let users select priority on the submission form, or derive it automatically: if the user's account age is less than 7 days, default to 'medium'; if they describe a billing issue (keyword match), set 'urgent'. Reflect priority in the ticket list with a color-coded indicator alongside the status badge.

### Can the help desk integrate with Intercom or Zendesk instead of being built custom?

Yes, both offer mobile SDKs. Intercom's Flutter package (intercom_flutter) replaces the entire ticket UI with their managed chat widget. Zendesk provides zendesk_messaging for Flutter. The trade-off: you get a mature, feature-rich support platform immediately (typing indicators, bots, CSAT ratings) at approximately $39/seat/month for Intercom Starter, versus a self-built solution at ~$25/month total for Supabase Pro.

### How do I track average response time across all tickets?

Add a Supabase view: SELECT AVG(EXTRACT(EPOCH FROM (first_reply_at - created_at)) / 3600.0) as avg_hours_to_first_reply FROM (SELECT t.created_at, MIN(m.created_at) as first_reply_at FROM support_tickets t LEFT JOIN ticket_messages m ON m.ticket_id = t.id AND m.sender_role = 'agent' GROUP BY t.id) sub. Display this in your admin panel. For per-agent stats, filter by assigned_agent_id once that column exists.

---

Source: https://www.rapidevelopers.com/app-features/help-desk
© RapidDev — https://www.rapidevelopers.com/app-features/help-desk
