TL;DR
The one-paragraph version before you dive in.
Paste the starter prompt into Lovable Agent Mode and get a working support ticket system: customer submission portal, agent inbox with status workflow, threaded messages with internal notes, requester-isolation RLS so customers never see each other's tickets, sequential ticket numbers, and email notifications via Resend. Full build takes roughly one day. Expected credit burn: 100-180 on a Pro $25/mo plan.
Setup checklist
Complete these steps in Lovable before pasting the starter prompt — takes about 5 minutes.
Cloud tab settings
Database
Stores 6 tables: profiles, categories, tickets, ticket_messages, attachments, sla_policies. Sequential ticket numbers via a Postgres sequence.
- 1Click the + button next to Preview in the top toolbar to open the Cloud tab
- 2Click Database — you'll see an empty Table Editor
- 3Leave it empty — the starter prompt creates all six tables with migrations including the tickets_seq sequence
Auth
Email+password for both customers and agents. Agent role stored in JWT app_metadata so agents can be distinguished from customers without a DB query.
- 1Cloud tab → Users & Auth
- 2Under Sign-in methods, enable Email (already on by default)
- 3Leave 'Allow new users to sign up' ON so customers can self-register
- 4After your first agent account is created, open that user's row and edit app_metadata to {"role": "agent"}
Storage
ticket-attachments bucket scoped by ticket_id. Customers and agents on their own tickets can upload and download; no cross-ticket access.
- 1Cloud tab → Storage
- 2Click Create bucket, name it 'ticket-attachments', leave Public OFF
- 3The starter prompt adds the requester-OR-agent RLS policy automatically
Edge Functions
inbound-email parses incoming support emails into tickets; notify-on-update sends Resend notifications on status changes and replies.
- 1No manual setup needed before running the starter prompt
- 2After the starter finishes, confirm inbound-email and notify-on-update appear in Cloud tab → Edge Functions
- 3Add RESEND_API_KEY to Cloud tab → Secrets before testing email notifications
Secrets (Cloud tab → Secrets)
RESEND_API_KEYPurpose: Sends ticket acknowledgment emails to customers and new-ticket/reply notifications to agents
Where to get it: Go to https://resend.com/api-keys — create a key with 'Sending access'
POSTMARK_SERVER_TOKENPurpose: Only needed if you want inbound email (customers emailing support@ to create tickets). Postmark's inbound parser handles threaded replies far better than a custom regex.
Where to get it: https://account.postmarkapp.com — Servers → your server → API tokens → Server API token
ANTHROPIC_API_KEYPurpose: Optional: Claude Haiku 4.5 for AI triage — classifies priority and drafts a suggested reply on each new ticket
Where to get it: https://console.anthropic.com/settings/keys — create a new key
Preflight checklist
- You're in a fresh Lovable project (Vite + React + TypeScript + Tailwind + shadcn/ui already scaffolded)
- You're on Pro $25/mo — full chain burns 100-180 credits and Free caps at ~30/mo
- You have a test customer account and a test agent account ready to verify requester isolation
- RESEND_API_KEY added to Cloud tab → Secrets before testing notifications
- If using inbound email: Postmark account set up with an inbound domain configured before the inbound-email follow-up
The starter prompt — paste this first
Copy this. Paste it into Lovable Agent Mode (the default chat at the bottom-left of the editor). Hit send.
Build a customer support ticket system. Stack: React + Vite + TypeScript + Tailwind + shadcn/ui (already scaffolded), React Router v6 for routes, Supabase JS client against Lovable Cloud.
## Database schema (one migration)
Create six tables in the public schema:
1. `profiles` — id uuid PK references auth.users.id, full_name text, email text not null, role text default 'customer' check in ('customer','agent','admin'), created_at timestamptz default now(). RLS: own read/write; agents read all.
2. `categories` — id uuid PK, slug text unique not null, name text not null, default_assignee_id uuid references auth.users.id. RLS: public-read (customer ticket form needs it).
3. `tickets` — id uuid PK default gen_random_uuid(), ticket_number int unique not null default nextval('tickets_seq'), requester_id uuid not null references auth.users.id, assignee_id uuid references auth.users.id, category_id uuid references categories(id), subject text not null check (length(subject) BETWEEN 3 AND 200), status text not null default 'open' check in ('open','pending','resolved','closed'), priority text default 'normal' check in ('low','normal','high','urgent'), source text check in ('web','email','api'), created_at timestamptz default now(), resolved_at timestamptz, first_response_at timestamptz. RLS: SELECT `USING (requester_id = auth.uid() OR is_agent())`; INSERT authenticated (requester_id auto = auth.uid()); UPDATE assignee or is_agent().
4. `ticket_messages` — id uuid PK, ticket_id uuid not null references tickets(id) on delete cascade, sender_id uuid not null references auth.users.id, body text not null, is_internal_note bool default false (agents only see when true), source text check in ('web','email','api'), created_at timestamptz default now(). RLS: SELECT — requester sees non-internal messages for own tickets; agent sees all; INSERT — requester for own tickets, agent for any.
5. `attachments` — id uuid PK, ticket_id uuid references tickets(id), message_id uuid references ticket_messages(id), storage_path text not null, uploaded_by uuid references auth.users.id, content_type text, size_bytes int, created_at timestamptz default now(). RLS: same as parent ticket (requester_id match OR is_agent()).
6. `sla_policies` — id uuid PK, priority text not null, first_response_minutes int, resolution_minutes int, business_hours_only bool default true. RLS: public-read.
## Required sequence and functions
In the migration, BEFORE the tables:
`CREATE SEQUENCE IF NOT EXISTS tickets_seq START 1000;`
Create two SECURITY DEFINER plpgsql functions:
A. `has_role(check_role text) RETURNS boolean LANGUAGE plpgsql SECURITY DEFINER STABLE` — reads `(auth.jwt() -> 'app_metadata' ->> 'role') = check_role`.
B. `is_agent() RETURNS boolean LANGUAGE plpgsql SECURITY DEFINER STABLE` — returns `(auth.jwt() -> 'app_metadata' ->> 'role') IN ('agent', 'admin')`.
RLS policies for tickets: SELECT `USING (requester_id = auth.uid() OR is_agent())`. This is non-negotiable — omitting `OR is_agent()` breaks the agent queue.
RLS for ticket_messages SELECT: `USING ((EXISTS (SELECT 1 FROM tickets t WHERE t.id = ticket_id AND t.requester_id = auth.uid()) AND is_internal_note = false) OR is_agent())`.
Storage bucket `ticket-attachments` policy: `bucket_id = 'ticket-attachments' AND ((storage.foldername(name))[1] IN (SELECT id::text FROM tickets WHERE requester_id = auth.uid() OR is_agent()))`.
Seed: 4 categories (Billing / Technical / Account / General) + 3 SLA policies (normal=480 first_response / high=120 / urgent=60 minutes).
## Two layouts
`src/layouts/PublicLayout.tsx` — header with logo + 'Submit a ticket' CTA + sign in / user dropdown. Footer with support contact info. For /support routes.
`src/layouts/AgentLayout.tsx` — fixed left sidebar (240px) with nav: Inbox, My Tickets, All Tickets (search), Reports + agent identity at bottom. For /agent routes.
## Pages
Create routes in src/App.tsx:
- `/login` and `/signup` — standard auth forms
- `/support` (SupportPortal.tsx) — customer landing: grid of 'View my tickets' card + 'Submit new ticket' card + status indicator
- `/support/new` (NewTicket.tsx) — ticket form: category dropdown, subject text input, body textarea (min 20 chars), optional file attach (max 3 files, 10MB each); on submit creates ticket + first ticket_messages row
- `/support/:ticketNumber` (CustomerTicketView.tsx) — customer view of their own ticket; shows public messages only (is_internal_note=false); reply box at bottom; shows SLA expected response time from sla_policies
- `/agent` (AgentInbox.tsx) — list of open tickets sorted by SLA breach proximity (most-overdue first); each row is AgentTicketRow with SLAPill
- `/agent/queue` (AgentQueue.tsx) — full ticket list with filter by status / assignee / category / priority
- `/agent/tickets/:ticketNumber` (AgentTicketView.tsx) — full ticket thread including internal notes; MessageComposer with internal-note toggle; status dropdown; priority dropdown; assign to dropdown; SLA breach info
- `/agent/reports` (AgentReports.tsx) — basic metrics: open count, avg first response time, resolution rate by category
## Components
Build:
- `AuthGuard` — wraps /support/:ticketNumber and /support/new; redirects to /login if no session
- `AgentGate` — wraps all /agent routes; checks is_agent() via JWT app_metadata; redirects customers to /support
- `CustomerTicketCard` — compact ticket row for customer list: ticket_number + subject + status chip + age
- `AgentTicketRow` — compact row for agent queue: ticket_number + subject + requester email + category + priority badge + SLA pill
- `TicketDetail` — thread view showing messages with is_internal_note=true rendered with yellow tint and 'Internal' label (agent only)
- `MessageComposer` — textarea + internal-note toggle (agents only) + file attach + submit; agents see canned-response dropdown placeholder
- `StatusChip` — colored pill: blue open / amber pending / emerald resolved / gray closed
- `PriorityBadge` — gray low / blue normal / amber high / rose urgent
- `SLAPill` — shows 'X min to first response' in blue, 'Overdue by X min' in rose for breached tickets
## Edge Functions
Scaffold `supabase/functions/notify-on-update/index.ts` — accepts {ticket_id, event_type: 'new_ticket'|'new_reply'|'status_changed', sender_id}. Looks up ticket + requester email + assignee email + category. Sends Resend email to: requester on 'new_reply' or 'status_changed' (if sender is agent); assignee or unassigned-pool alert on 'new_ticket'. Use RESEND_API_KEY from secrets. Return 200 on success.
Scaffold `supabase/functions/inbound-email/index.ts` — stub that accepts a Postmark inbound JSON payload. Parse From, Subject, TextBody. Check for [#NNNN] in Subject — if found, look up ticket by ticket_number and INSERT a ticket_messages row (source='email'). If no ticket number, create a new ticket (source='email') with subject from email Subject. Return 200.
## Styling
Light + dark mode via shadcn ThemeProvider. Primary color blue-600 (calm, support-brand feel). StatusChip colors as above. SLAPill turns rose-600 when first_response_at IS NULL AND created_at < now() - interval 'first_response_minutes minutes' (from sla_policies). Internal notes: bg-yellow-50 dark:bg-yellow-950 with 'Internal note' label. Compact table density: 40px rows.
Generate migration first, then layouts, then pages in the order above, then Edge Functions. Stop after each major section.What this prompt generates
- Creates a SQL migration with 6 tables, tickets_seq sequence, is_agent() and has_role() SECURITY DEFINER functions, and requester-OR-agent RLS on tickets
- Scaffolds PublicLayout and AgentLayout plus 8 page components wired to React Router
- Builds AgentGate, CustomerTicketCard, AgentTicketRow, TicketDetail, MessageComposer, SLAPill, and other components
- Scaffolds notify-on-update Edge Function (Resend) and inbound-email stub (Postmark)
- Seeds 4 categories and 3 SLA policies so the system is ready for first ticket
Paste into: Lovable Agent Mode (the default chat at the bottom-left of the editor)
Expected output
What Lovable will generate after the starter prompt runs successfully.
Files
supabase/migrations/0001_support_schema.sql6 tables + tickets_seq + is_agent() + has_role() SECURITY DEFINER functions + RLS + Storage policy + seed
src/layouts/PublicLayout.tsxCustomer-facing header for /support routes
src/layouts/AgentLayout.tsxAgent sidebar with Inbox/Queue/Reports navigation
src/components/AuthGuard.tsxRedirects unauthenticated users to /login
src/components/AgentGate.tsxChecks JWT app_metadata.role IN (agent, admin); redirects customers to /support
src/components/CustomerTicketCard.tsxCompact ticket row for customer portal
src/components/AgentTicketRow.tsxCompact queue row with SLA pill and priority badge
src/components/TicketDetail.tsxThread view with internal note visual distinction
src/components/MessageComposer.tsxReply box with internal-note toggle and file attach
src/components/StatusChip.tsxColor-coded ticket status pill
src/components/PriorityBadge.tsxColor-coded priority indicator
src/components/SLAPill.tsxTime-to-first-response pill that turns rose when breached
src/pages/SupportPortal.tsxCustomer landing with view-tickets and new-ticket cards
src/pages/NewTicket.tsxTicket submission form with category, subject, body, file attach
src/pages/CustomerTicketView.tsxCustomer view of own ticket with public messages only
src/pages/AgentInbox.tsxOpen tickets sorted by SLA breach proximity
src/pages/AgentQueue.tsxFull ticket list with status/assignee/category filters
src/pages/AgentTicketView.tsxFull ticket thread with internal notes and agent actions
src/pages/AgentReports.tsxBasic metrics: open count, avg response, resolution rate
supabase/functions/notify-on-update/index.tsSends Resend emails on new ticket, reply, and status change
supabase/functions/inbound-email/index.tsPostmark inbound webhook parser — creates or threads tickets from email
Routes
Sign in page for customers and agents
Registration page for new customers
Customer portal landing with ticket list
New ticket submission form
Customer view of own ticket thread
Agent inbox sorted by SLA priority
Full ticket queue with filters
Full agent view with internal notes and controls
Basic support metrics dashboard
DB Tables
tickets| Column | Type |
|---|---|
| id | uuid primary key |
| ticket_number | int unique default nextval('tickets_seq') |
| requester_id | uuid not null references auth.users.id |
| assignee_id | uuid references auth.users.id |
| status | text default 'open' |
| priority | text default 'normal' |
| first_response_at | timestamptz |
RLS: SELECT USING (requester_id = auth.uid() OR is_agent()) — both clauses required
ticket_messages| Column | Type |
|---|---|
| id | uuid primary key |
| ticket_id | uuid not null references tickets(id) on delete cascade |
| sender_id | uuid not null references auth.users.id |
| is_internal_note | bool default false |
| source | text check in ('web','email','api') |
RLS: Customer sees only is_internal_note=false for own tickets; agent sees all messages on all tickets
profiles| Column | Type |
|---|---|
| id | uuid primary key references auth.users.id |
| role | text default 'customer' |
RLS: Own read/write; agents read all profiles
categories| Column | Type |
|---|---|
| id | uuid primary key |
| name | text not null |
| default_assignee_id | uuid references auth.users.id |
RLS: Public-read — customer ticket form needs to populate the category dropdown
attachments| Column | Type |
|---|---|
| id | uuid primary key |
| ticket_id | uuid references tickets(id) |
| storage_path | text not null format: ${ticket_id}/${uuid}-${filename} |
RLS: Same access as parent ticket — requester or agent only
sla_policies| Column | Type |
|---|---|
| id | uuid primary key |
| priority | text not null |
| first_response_minutes | int |
| resolution_minutes | int |
RLS: Public-read — customers can see expected response times for their priority
Components
AgentGateJWT role check — redirects non-agents from /agent routes to /support
TicketDetailThread view with yellow-tinted internal notes visible to agents only
MessageComposerReply textarea with internal-note toggle and file attachment
SLAPillTime-remaining display that turns rose when first response is overdue
StatusChipColor-coded status pill for both customer and agent views
Follow-up prompts
Paste these into Agent Mode one by one, in order, after the starter prompt finishes.
Lock down requester-isolation RLS
Verified requester isolation — customers cannot read other customers' tickets or internal agent notes
Audit requester isolation to confirm customers cannot see each other's tickets.
Open two browser windows in incognito mode. Create Customer A account and submit a ticket. Create Customer B account and submit a different ticket.
Sign in as Customer A: the /support page should show exactly 1 ticket (their own). Navigate to /support/${Customer B's ticket number} — should return a not-found or access-denied error (the ticket query returns 0 rows via RLS).
If Customer A can see Customer B's ticket, fix the tickets SELECT policy:
DROP POLICY IF EXISTS tickets_requester_or_agent ON tickets;
CREATE POLICY tickets_requester_or_agent ON tickets
FOR SELECT TO authenticated
USING (requester_id = auth.uid() OR is_agent());
Also confirm the ticket_messages SELECT policy filters internal notes for customers:
DROP POLICY IF EXISTS messages_requester_or_agent ON ticket_messages;
CREATE POLICY messages_requester_or_agent ON ticket_messages
FOR SELECT USING (
(EXISTS (SELECT 1 FROM tickets t WHERE t.id = ticket_id AND t.requester_id = auth.uid()) AND is_internal_note = false)
OR is_agent()
);
Run the two-account test again after every policy change.When to use: Immediately after the starter prompt — this is a privacy-critical check before any real customer uses the system
Wire Resend outbound notifications
Automated email notifications for new tickets, agent replies, and status changes via Resend
Wire Resend email notifications for ticket lifecycle events.
Add a Postgres trigger on tickets INSERT that fires notify-on-update with event_type='new_ticket'. Add a trigger on ticket_messages INSERT that fires notify-on-update with event_type='new_reply'. Add a trigger on tickets UPDATE (when status changes) that fires notify-on-update with event_type='status_changed'.
Use pg_net.http_post to call the Edge Function from the trigger. Pass ticket_id and sender_id in the JSON body.
In notify-on-update/index.ts:
- Fetch the ticket with requester's email and assignee's email
- On new_ticket: send Resend email to assignee (or a support alias if unassigned) with ticket subject + link to /agent/tickets/${ticket_number}; send ack to requester with 'We received your request [#${ticket_number}]' and a link to /support/${ticket_number}
- On new_reply (from agent): send to requester with agent's message preview
- On status_changed to 'resolved': send to requester with resolution message
Add RESEND_API_KEY to Cloud tab → Secrets before testing.When to use: Once ticket creation and messaging work — before first real customer
Add inbound email via Postmark
Email-in: customers reply to notifications by email and the reply is threaded into the correct ticket
Wire inbound email so customers can reply by replying to the notification email.
In Postmark Dashboard: Servers → your server → Inbound → set the Inbound Hook URL to https://[your-deployed-url]/api/inbound-email (or the Edge Function URL). Enable the inbound domain (e.g., reply.yourdomain.com).
Update notify-on-update to prefix the email subject with [#${ticket_number}]: e.g., 'Re: Login issue [#1042]'. This is how inbound-email identifies which ticket a reply belongs to.
In inbound-email/index.ts, complete the Postmark webhook handler:
- Parse the JSON body: From, Subject, TextBody (strip signatures), Attachments
- Try to match ticket: const match = subject.match(/\[#(\d+)\]/); if match, look up ticket by ticket_number
- If found: INSERT ticket_messages with sender_id looked up by From email, body=TextBody (truncated to 5000 chars), source='email'
- If no match: INSERT new ticket with subject=Subject, requester_id looked up or created by From email, first ticket_messages row with body=TextBody
- Set first_response_at on tickets if the message sender is_agent() and first_response_at IS NULL
- Return 200 (Postmark retries on anything else)When to use: When customers prefer emailing support@ over logging into the portal — very common for B2B customers
Add SLA tracking and breach alerts
SLA breach detection with per-ticket due time display and proactive 15-minute agent alerts for overdue tickets
Add SLA breach detection and proactive alerts for agents.
Create a Postgres view v_tickets_with_sla that joins tickets with sla_policies on priority and computes:
- minutes_to_first_response = EXTRACT(EPOCH FROM (first_response_at - created_at)) / 60 (null if not yet responded)
- first_response_due_at = created_at + first_response_minutes * interval '1 minute'
- first_response_breached = first_response_at IS NULL AND now() > first_response_due_at
- resolution_due_at = created_at + resolution_minutes * interval '1 minute'
- resolution_breached = status NOT IN ('resolved','closed') AND now() > resolution_due_at
Update SLAPill to read from this view — show 'Respond by HH:MM' in blue, 'Overdue by X min' in rose.
Create a check-sla-breach Edge Function. Schedule it via pg_cron every 15 minutes:
SELECT cron.schedule('sla_breach_check', '*/15 * * * *', $$SELECT net.http_post(url:='YOUR_FUNCTION_URL', body:='{}')$$);
The function queries v_tickets_with_sla WHERE first_response_breached=true AND status='open' and sends a Resend digest to the assignee (or unassigned-pool alias) listing each overdue ticket.When to use: Once you commit to published response times with your customers
Add canned responses for agents
Canned response library with variable substitution so agents reply consistently to common questions
Add a canned-response library for common agent replies.
Create a canned_responses table: id uuid PK, title text not null, body text not null (supports {{ticket.subject}} and {{requester.name}} placeholders), created_by uuid references auth.users.id, is_global bool default true, created_at timestamptz. RLS: agents read-all; creator updates own.
In MessageComposer, add a Combobox below the reply textarea labeled 'Insert canned response'. When an agent selects an item, insert the canned response body into the textarea and replace {{ticket.subject}} with the current ticket's subject and {{requester.name}} with the requester's full_name from profiles.
Add a canned_responses management page at /agent/settings/canned-responses — table list of all global responses + the agent's own, with Add / Edit / Delete. Global responses managed by admins only (has_role('admin') check on INSERT/UPDATE/DELETE).When to use: Once you notice agents writing similar replies repeatedly — usually apparent after 50+ tickets
Add AI triage with Claude Haiku 4.5
Claude Haiku 4.5 classifies priority and category on each new ticket and drafts a suggested reply for agents
Wire AI triage on new ticket creation.
Create an ai-triage Edge Function. Add ANTHROPIC_API_KEY to Cloud tab → Secrets.
The function accepts {ticket_id}. It fetches the ticket subject and first message body, then calls Claude Haiku 4.5 via the Anthropic API:
- Model: claude-haiku-4-5
- System prompt: 'You are a support ticket classifier for [your product]. Given a ticket, return JSON with: category (one of Billing/Technical/Account/General), priority (low/normal/high/urgent), and draft_reply (max 150 words, friendly and specific to the issue).'
- User message: Subject: {subject}\nBody: {body}
Parse the JSON response. Update the ticket's category_id and priority in the DB (status stays 'open'). Store draft_reply in a ticket_metadata jsonb column on tickets.
In AgentTicketView, show the AI-suggested priority and draft reply in a dismissible banner at the top: 'AI suggests: Priority=High, Category=Technical. Draft reply below.' Agent clicks 'Use this reply' to copy it into MessageComposer, or dismisses the banner.When to use: When ticket volume is high enough that agents spend more time routing than responding — typically 50+ tickets/day
Add CSAT survey after resolution
Post-resolution CSAT email survey with 5-star rating and rolling score in agent reports
Add a customer satisfaction survey sent on ticket resolution.
Create a csat_responses table: id uuid PK, ticket_id uuid references tickets(id), requester_id uuid references auth.users.id, rating int check in (1,2,3,4,5), comment text, submitted_at timestamptz. RLS: requester writes own (once); agents read all.
Add a Postgres trigger on tickets UPDATE when status changes to 'resolved' (NEW.status = 'resolved' AND OLD.status != 'resolved'). Trigger fires notify-on-update with event_type='csat_request'.
In notify-on-update, on csat_request: send Resend email with subject 'How did we do? [#${ticket_number}]' and a link to /support/${ticket_number}/feedback.
Create /support/:ticketNumber/feedback page (no auth required — use the ticket_number as a simple token). Show 5-star rating input + optional comment. Submit inserts csat_responses. After submit: 'Thank you for your feedback.'
In AgentReports, add a rolling 30-day CSAT score card: AVG(rating) across all resolved tickets in the period, with trend.When to use: Once the basic resolution workflow is stable — a few weeks post-launch
Common errors
Real error strings you'll see. Find yours, paste the fix prompt.
Customer A sees Customer B's tickets in /supportYour tickets SELECT policy is too permissive — either USING (true) which leaks everything, or the policy is missing entirely. This is the most dangerous RLS bug in a ticket system because conversations often contain sensitive customer information.
Drop existing tickets SELECT policy. Recreate: CREATE POLICY tickets_requester_or_agent ON tickets FOR SELECT TO authenticated USING (requester_id = auth.uid() OR is_agent()). Confirm is_agent() is a SECURITY DEFINER plpgsql function reading JWT app_metadata.role. Test with two customer accounts in incognito — Customer A must see ONLY their own tickets. This test is mandatory before any customer uses the system.
Agent's /agent/inbox is empty — no tickets appearYour tickets SELECT policy uses USING (requester_id = auth.uid()) only, missing the OR is_agent() clause. Agents are not requesters — with this policy they see zero rows, which is correct customer isolation but breaks the agent queue entirely.
Same fix as above — the policy must allow both paths: USING (requester_id = auth.uid() OR is_agent()). Confirm the agent user's JWT app_metadata is {"role": "agent"}: Cloud tab → Users & Auth → click the agent user row → edit app_metadata. Also confirm is_agent() returns true for role='admin' as well, so admin users can also work the queue.duplicate key value violates unique constraint "tickets_ticket_number_key"Your migration scaffolded ticket_number as a plain int without the sequence, so every INSERT tries to use the same default value (usually 1) and collides.
SQL Editor: CREATE SEQUENCE IF NOT EXISTS tickets_seq START 1000; ALTER TABLE tickets ALTER COLUMN ticket_number SET DEFAULT nextval('tickets_seq'). Then verify: INSERT INTO tickets (requester_id, subject, status) VALUES (auth.uid(), 'Test', 'open') RETURNING ticket_number — should return 1000, then 1001, etc. Never generate ticket numbers in JavaScript — the sequence is the single source of truth.Internal agent notes appear on the customer's /support/:ticketNumber pageYour ticket_messages SELECT policy grants customers access to all messages for their ticket without filtering is_internal_note=false — internal notes are visible to customers.
Drop ticket_messages SELECT policy. Recreate: CREATE POLICY messages_requester_or_agent ON ticket_messages FOR SELECT USING ((EXISTS (SELECT 1 FROM tickets t WHERE t.id = ticket_id AND t.requester_id = auth.uid()) AND is_internal_note = false) OR is_agent()). Test: as a customer, sign in and view your ticket — the yellow-tinted internal note must not appear. As an agent, sign in and view the same ticket — the internal note must appear with the 'Internal' label.
Inbound email creates a new ticket for every customer reply instead of threadingYour inbound-email Edge Function parses From and Subject but doesn't check for the [#NNNN] ticket number pattern in the Subject field — so each email is treated as a new ticket.
In inbound-email function, after parsing subject: const match = subject.match(/\[#(\d+)\]/); if (match) { const ticketNumber = parseInt(match[1]); const { data: ticket } = await supabase.from('tickets').select('id').eq('ticket_number', ticketNumber).single(); if (ticket) { await supabase.from('ticket_messages').insert({ ticket_id: ticket.id, sender_id: senderId, body: textBody, source: 'email' }); return new Response('OK'); } } // else fall through to create new ticket. Also confirm notify-on-update prefixes reply subjects with [#${ticket_number}] so customer replies thread correctly.Attachment upload returns 403 even for the ticket's own requesterYour ticket-attachments Storage policy checks bucket_id but doesn't use the folder prefix pattern — or your uploader is writing to a path that doesn't start with the ticket_id as the first folder segment.
Confirm the storage.objects policy: bucket_id = 'ticket-attachments' AND ((storage.foldername(name))[1] IN (SELECT id::text FROM tickets WHERE requester_id = auth.uid() OR is_agent())). In MessageComposer, confirm the upload path is exactly ${ticketId}/${crypto.randomUUID()}-${file.name} — the first segment must be the ticket UUID string, not the ticket number.Cost reality
What this build actually costs — no surprises on your card.
Recommended Lovable plan
Pro $25/mo. Most credits go into RLS iteration (requester isolation) and the inbound email parsing logic. Free plan's ~30 monthly cap won't cover the full chain.
Monthly run cost breakdown
~100-180 credits (starter ~50-70, follow-ups 1-4 ~220, follow-ups 5-7 add ~170 more if all done — most teams ship after follow-up #4) total| Item | Cost |
|---|---|
| Lovable Pro Drop to Free plan after build — the system runs on Cloud | $25/mo |
| Supabase / Lovable Cloud 500MB DB handles thousands of tickets easily; email volume, not DB, is the scaling concern | $0/mo at MVP |
| Resend Free tier 3K emails/mo covers low-volume support. At 100 tickets/day × 3 emails each = 9K/mo → Resend Pro ~$20/mo | $0-20/mo |
| Postmark (inbound email) 1K free inbound messages, then $1.50/1K. Essential for email-in threading — do not roll your own parser | $15/mo |
| Claude Haiku 4.5 (AI triage) Haiku is $1/Mtok input; a 200-token triage prompt on 1K tickets/mo = ~$0.20. Negligible at support scale. | ~$0-5/mo |
| Custom domain Via Cloud tab → Publish → custom domain on Pro plan | $10-15/yr |
Scaling notes: Email volume is the first cost that scales: Resend's 3K free emails cover about 1K tickets/month with ack + reply + resolution emails. Once you hit 100+ tickets/day, budget $20/mo for Resend Pro. DB capacity is rarely the constraint — 500MB handles well over 100K tickets with message threads. The most likely operational issue at scale is moderating the inbound-email parser for unusual email clients and signatures; Postmark's inbound parser handles this far better than a DIY regex.
Production checklist
Steps to take before you share the URL with real users.
Requester Isolation (before first real customer)
Run the two-customer incognito test
Create two test customer accounts. Customer A submits a ticket. Sign in as Customer B and navigate to Customer A's ticket URL. Must return not-found or zero rows — never the ticket content.
Test agent visibility separately
Sign in as an agent. Confirm the /agent/inbox shows ALL open tickets from both test customers. If empty, the is_agent() SECURITY DEFINER function or the OR is_agent() clause in the policy is missing.
Domain & Email
Connect custom domain
Click Publish → Settings → Custom domain. Add CNAME record. SSL auto-provisions. Update Resend sending domain DNS (SPF + DKIM) for email deliverability.
Set up Postmark inbound domain
Postmark Dashboard → Inbound → set MX record for reply.yourdomain.com → point inbound hook to your deployed inbound-email Edge Function URL
Sequential Numbers
Verify tickets_seq is attached to tickets table
SQL Editor: SELECT column_default FROM information_schema.columns WHERE table_name='tickets' AND column_name='ticket_number'. Should show nextval('tickets_seq'::regclass) — not a hardcoded default.
Monitoring
Check Cloud tab → Logs for failed notify-on-update calls
Cloud tab → Logs → Edge Functions. Failed notifications mean customers don't receive acknowledgments and may submit duplicate tickets.
Monitor Postmark inbound delivery logs weekly
Postmark Dashboard → Activity → Inbound. Check for 4xx responses — those are unthreaded emails that created noise tickets.
Frequently asked questions
Why do I need OR is_agent() in the tickets RLS policy?
Because agents are not requesters. The tickets table stores requester_id as the customer who submitted the ticket. When an agent signs in, auth.uid() returns the agent's user ID — which does not match any requester_id (agents don't submit tickets as customers). Without OR is_agent(), the SELECT policy USING (requester_id = auth.uid()) returns zero rows for every agent because no ticket has the agent as the requester. The fix is a SECURITY DEFINER plpgsql function that reads JWT app_metadata.role and returns true for 'agent' and 'admin' — then the combined USING (requester_id = auth.uid() OR is_agent()) gives customers their own tickets and agents all tickets.
Can I really replace Zendesk with a Lovable build?
For 1-5 agents and under 100 tickets/day: yes, with this kit you can match 80% of what Zendesk offers at a fraction of the cost. What you get: ticket submission portal, threaded messages, internal notes, status workflow, SLA tracking, email in/out, basic AI triage, and a reports dashboard. What Zendesk has that this kit does not: omnichannel (SMS, Twitter/X, Messenger), advanced automation rules engine, skill-based routing, brand-specific portals, SOC2 Type II certification, and native phone support. For a lean B2B SaaS team or a solo founder, this kit is genuinely sufficient.
How do customers reply via email without logging in?
The inbound email follow-up covers this. When notify-on-update sends a ticket reply notification, it prefixes the subject with [#1042] (the ticket number). When the customer clicks Reply in their email client, their reply goes to your Postmark inbound address (e.g., support@reply.yourdomain.com). Postmark parses the email and calls your inbound-email Edge Function with the raw message. The function finds the [#1042] in the subject, looks up ticket #1042, and inserts the email body as a new ticket_messages row. The customer never has to log in to reply — they just hit Reply like any normal email thread.
What's the best free email service for this — Resend or Postmark?
For outbound only (notifications, acknowledgments): Resend. 3K free emails per month, excellent deliverability, simple API. For inbound parsing (customers replying by email): Postmark. Postmark's inbound email parser handles Reply-To headers, threaded conversation detection, signature stripping, and MIME-encoded attachments far better than a DIY regex parser. They offer 1K free inbound messages per month. Use both: Resend for outbound, Postmark for inbound. The total cost is $0 at low volume and about $15-35/mo once you scale past free tiers.
Should I add AI triage from day one?
Not from day one — add it once you have at least 20 tickets so you can validate the AI's classifications against real data. Claude Haiku 4.5 classifies tickets well for common categories (Billing, Technical, Account) but needs prompt tuning for your specific product context. The AI triage follow-up (follow-up #6) takes about 80 credits to wire correctly. Until then, manual triage is fine and you'll learn what classification rules make sense for your support volume before baking them into a prompt.
How do I migrate existing Zendesk tickets to this system?
Export your Zendesk data via Zendesk Admin → Reporting → Data Export (XML or JSON). Write a one-time import script (a Supabase Edge Function works well) that reads the export and inserts rows into tickets and ticket_messages. Key mappings: Zendesk ticket ID maps to a new ticket_number (start your tickets_seq above your highest Zendesk ID to avoid collisions), Zendesk requester email maps to auth.users/profiles by email, Zendesk comments map to ticket_messages with is_internal_note for Zendesk private comments. Test with a small subset first. Note that Zendesk attachments are separate — you'll need to download and re-upload each one to your ticket-attachments Storage bucket.
When does it make sense to keep Zendesk instead of building?
If your build outgrows this prompt kit and you need custom architecture, RapidDev builds production-grade Lovable apps at $13K-$25K — book a free 30-minute consultation at rapidevelopers.com.
Need a production-grade version?
RapidDev builds production-grade Lovable apps at $13K–$25K. Book a free 30-minute consultation.
Book a free consultation30-min call. No commitment.