Examples

Mini CRM

Build a lightweight CRM with contacts, deals, pipeline stages, and activity tracking
Prerequisite: Complete the Get Started guide first.

To build this feature, copy the first prompt, paste it into Claude Code (or Codex, or any AI coding tool), let it finish, then run the next prompt.

A mini CRM is what any sales-driven business uses to manage relationships and track deals through a pipeline. Think agencies, freelancers, SaaS companies, real estate, or any business that needs to follow up with leads and close deals.

By the end of this example you will have:

  • Contacts — full CRUD for people and companies with tags and notes
  • Deals — opportunities with pipeline stages, values, and expected close dates
  • Activity log — calls, emails, meetings, and notes linked to contacts or deals
  • Pipeline dashboard — visual pipeline summary with deal values per stage
  • Realtime — all changes sync instantly across connected browsers

Database Schema and Permissions

We are building a mini CRM app on top of this template. Create the database
schema and add the permissions we need.

Database (via Supabase MCP):

Create four tables:

1. `contacts` table — id (uuid, default gen_random_uuid(), primary key),
   team_id (uuid, references teams(id) on delete cascade, not null),
   name (text, not null), email (text, nullable), phone (text, nullable),
   company (text, nullable),
   type (text, check type in ('lead', 'prospect', 'customer', 'partner'),
   default 'lead', not null),
   tags (text[], default '{}', not null),
   notes (text, nullable),
   owner_id (uuid, references profiles(id) on delete set null, nullable),
   created_by (uuid, references profiles(id), not null),
   created_at (timestamptz, default now()),
   updated_at (timestamptz, default now()).

2. `deals` table — id (uuid, default gen_random_uuid(), primary key),
   team_id (uuid, references teams(id) on delete cascade, not null),
   contact_id (uuid, references contacts(id) on delete cascade, not null),
   title (text, not null),
   value (numeric(12,2), nullable),
   stage (text, check stage in ('qualification', 'proposal', 'negotiation',
   'closed_won', 'closed_lost'), default 'qualification', not null),
   priority (text, check priority in ('low', 'normal', 'high'), default
   'normal', not null),
   expected_close_date (date, nullable),
   closed_date (date, nullable),
   owner_id (uuid, references profiles(id) on delete set null, nullable),
   notes (text, nullable),
   created_by (uuid, references profiles(id), not null),
   created_at (timestamptz, default now()),
   updated_at (timestamptz, default now()).

3. `activities` table — id (uuid, default gen_random_uuid(), primary key),
   team_id (uuid, references teams(id) on delete cascade, not null),
   contact_id (uuid, references contacts(id) on delete cascade, nullable),
   deal_id (uuid, references deals(id) on delete cascade, nullable),
   type (text, check type in ('call', 'email', 'meeting', 'note', 'task'),
   not null),
   subject (text, not null),
   description (text, nullable),
   activity_date (timestamptz, default now(), not null),
   created_by (uuid, references profiles(id), not null),
   created_at (timestamptz, default now()).

4. `deal_notes` table — id (uuid, default gen_random_uuid(), primary key),
   deal_id (uuid, references deals(id) on delete cascade, not null),
   user_id (uuid, references profiles(id), not null),
   content (text, not null),
   created_at (timestamptz, default now()).

Enable RLS on all four tables. Create policies that use the existing
`is_team_member()` function to scope access. For `contacts` and `deals`,
the policy should check that the row's `team_id` matches a team the user
belongs to. For `activities`, check team_id directly. For `deal_notes`,
join through the `deals` table to check team membership.

Only create a SELECT policy for team members — follow the
`announcements_team_member_read` pattern in
`supabase/migrations/00001_initial_schema.sql`. Writes go through
service-role server routes, so no insert/update/delete RLS policies are
needed. Permission checks live in server routes via `authUser(event, "key")`.

After creating the tables, add these permissions to `shared/permissions.ts`:

```
"contacts.view": ["owner", "admin", "member"],
"contacts.create": ["owner", "admin", "member"],
"contacts.update": ["owner", "admin", "member"],
"contacts.delete": ["owner", "admin"],
"deals.view": ["owner", "admin", "member"],
"deals.create": ["owner", "admin", "member"],
"deals.update": ["owner", "admin", "member"],
"deals.delete": ["owner", "admin"],
"activities.view": ["owner", "admin", "member"],
"activities.create": ["owner", "admin", "member"],
```

Also wire the new tables into the baked-in AI chat and activity log per
CLAUDE.md conventions:

- Add `COMMENT ON COLUMN` on every non-obvious column — one short technical
  sentence. Mention format or business rules the DB does not enforce.
- `select enable_activity_log('<table>');` for each mutation-bearing table.
- Grant chat read access on each team-scoped table:
  ```
  grant select on <table> to chat_reader;
  create policy "<table>_select_chat" on <table>
    for select to chat_reader using (team_id = current_chat_team());
  ```
  Skip tables without a `team_id` column (scope them through a parent).
- Register each table in `tablePermissions` in `shared/permissions.ts`
  using the permission keys above.
- Add filter entries in `app/components/activity/List.vue` (`tableItems`)
  for each new table.

Regenerate the TypeScript types via Supabase MCP and save them to
`shared/types/database.types.ts`.
Adding the public API later? If you plan to add the Public API plugin later, its page will guide you through adding the required permissions. You don't need to add them now.

Contacts

Build the contacts module — server routes and a full UI page.

Server routes (all use `authUser` with the appropriate permission):

- `GET /api/teams/[teamId]/contacts` — uses
  `authUser(event, "contacts.view")`. Returns all contacts for the team,
  ordered by name asc. Include a count of deals per contact and the owner's
  name by joining. Support optional query params: `?type=` to filter by
  contact type, `?q=` to search by name, email, or company (use ILIKE).

- `POST /api/teams/[teamId]/contacts` — uses
  `authUser(event, "contacts.create")`. Reads { name, email, phone, company,
  type, tags, notes, owner_id } from the body. Validates that name is
  required. Sets team_id from the auth context and created_by from the
  authenticated user (user.sub).

- `PATCH /api/teams/[teamId]/contacts/[contactId]` — uses
  `authUser(event, "contacts.update")`. Updates whichever fields are provided
  in the body. Validates that the contact belongs to the team before updating.

- `DELETE /api/teams/[teamId]/contacts/[contactId]` — uses
  `authUser(event, "contacts.delete")`. Validates that the contact belongs to
  the team before deleting.

UI:

Create `app/pages/app/contacts/index.vue` — a contact list page as a top-level
route (under `/app`, sibling of other features), wrapped in a `UDashboardPanel`:

- `UDashboardNavbar` in the header: title "Contacts",
  `UDashboardSidebarCollapse` on the left. On the right, a "New Contact"
  button wrapped in `CanAccess permission="contacts.create"`.

- `UDashboardToolbar` below the navbar: on the left, a search input
  (`UInput` with search icon) that filters contacts as the user types
  (debounced). On the right, a type filter using `USelect` with options:
  All, Lead, Prospect, Customer, Partner.

- The body shows a table of contacts with columns: name, company, email,
  phone, type badge (color-coded — lead = "info", prospect = "warning",
  customer = "success", partner = "neutral"), deal count, and owner name.
  Use `USkeleton` for loading state on initial load. Show an empty state
  with an icon and message when there are no contacts.

- "New Contact" button opens a `UModal` with a form: name (required), email,
  phone, company, type (select), tags (comma-separated text input), owner
  (select from team members fetched on mount — use placeholder "Unassigned",
  do NOT use an empty-string value), notes. Use Zod for validation.
  Show loading on submit. Show success toast on creation.

- Clicking a contact row opens a `USlideover` for editing. Show all fields
  in a form at the top. Below the form, show two sections:
  1. "Deals" — a list of deals linked to this contact (title, stage badge,
     value). Clicking a deal navigates to the deals page.
  2. "Recent Activity" — the last 5 activities for this contact (type icon,
     subject, date). Fetched from
     `GET /api/teams/{teamId}/activities?contact_id={contactId}`.
  Include a delete button wrapped in
  `CanAccess permission="contacts.delete"` with a confirmation modal.

- After creating, editing, or deleting a contact, refresh the contact list.

Sidebar navigation:

Add a "Contacts" link to the top navigation group in
`app/components/layout/sidebar/Links.vue`, between "Dashboard" and
"Settings". Use the icon `i-solar-users-group-two-rounded-bold-duotone`.

Deals

Build the deals module — this is the core sales pipeline feature.

Server routes (all use `authUser` with the appropriate permission):

- `GET /api/teams/[teamId]/deals` — uses `authUser(event, "deals.view")`.
  Returns all deals for the team with the contact name and owner name
  joined in. Order by created_at desc. Support optional query params:
  `?stage=` to filter by stage, `?owner_id=` to filter by owner UUID.

- `POST /api/teams/[teamId]/deals` — uses `authUser(event, "deals.create")`.
  Reads { title, contact_id, value, stage, priority, expected_close_date,
  owner_id, notes } from the body. Validates that title and contact_id are
  required. Sets team_id from auth context and created_by from the
  authenticated user (user.sub).

- `PATCH /api/teams/[teamId]/deals/[dealId]` — uses
  `authUser(event, "deals.update")`. Updates whichever fields are provided.
  If stage is being changed to "closed_won" or "closed_lost", automatically
  set closed_date to today. Validates that the deal belongs to the team.

- `DELETE /api/teams/[teamId]/deals/[dealId]` — uses
  `authUser(event, "deals.delete")`. Validates that the deal belongs to
  the team before deleting.

- `GET /api/teams/[teamId]/deals/[dealId]/notes` — uses
  `authUser(event, "deals.view")`. Returns all notes for the deal with
  the author's name joined in, ordered by created_at asc.

- `POST /api/teams/[teamId]/deals/[dealId]/notes` — uses
  `authUser(event, "deals.view")`. Any team member can add notes. Reads
  { content } from the body. Sets user_id from the authenticated user.

- `GET /api/teams/[teamId]/activities` — uses
  `authUser(event, "activities.view")`. Returns activities for the team,
  ordered by activity_date desc. Support optional query params:
  `?contact_id=`, `?deal_id=`, `?type=`. Include contact name and deal
  title joined in.

- `POST /api/teams/[teamId]/activities` — uses
  `authUser(event, "activities.create")`. Reads { contact_id, deal_id,
  type, subject, description, activity_date } from the body. Validates
  that type and subject are required, and at least one of contact_id or
  deal_id must be provided. Sets team_id from auth context and created_by
  from the authenticated user.

UI:

Create `app/pages/app/deals/index.vue` — the main deals page as a top-level
route (under `/app`, sibling of other features), wrapped in a `UDashboardPanel`:

- `UDashboardNavbar` in the header: title "Deals",
  `UDashboardSidebarCollapse` on the left. On the right, a "New Deal"
  button wrapped in `CanAccess permission="deals.create"`.

- `UDashboardToolbar` below the navbar: on the left, show pipeline summary
  (e.g. "3 Qualification, 2 Proposal, 1 Negotiation"). On the right,
  stage filter tabs using `USelect` with options: All, Qualification,
  Proposal, Negotiation, Closed Won, Closed Lost.

- Deal list in the body as a table with columns: title, contact name,
  owner name (or "Unassigned"), stage badge (color-coded — qualification =
  "info", proposal = "warning", negotiation = "neutral", closed_won =
  "success", closed_lost = "error"), priority badge (low = "neutral",
  normal = "info", high = "warning"), value (formatted as currency),
  and expected close date (formatted nicely).

- Each row has an inline stage dropdown that lets you change the stage
  directly from the table. Use `UDropdownMenu` with the stage options.
  Show per-row loading state while the stage is being updated.

- "New Deal" button opens a `UModal` with a form: title (required),
  contact (required — select from existing contacts fetched on mount),
  owner (optional — select from team members fetched on mount, use
  placeholder "Unassigned"), stage, priority, value, expected close date,
  notes. For the date field, use a `UPopover` with a `UCalendar` inside.
  Import `CalendarDate` from `@internationalized/date`. Use Zod for
  validation. Show loading on submit.

- Clicking a deal row opens a `USlideover` for the deal detail. Show all
  fields in an editable form at the top. Below the form, show two sections:
  1. "Notes" — a scrollable list of notes (author name, content, timestamp)
     with a text input at the bottom to add a new note.
  2. "Activity" — a timeline of activities linked to this deal (type icon,
     subject, description, date) with a "Log Activity" button that opens a
     small inline form (type select, subject, description).
  Include a delete button wrapped in `CanAccess permission="deals.delete"`
  with a confirmation modal.

- After creating, editing, or deleting a deal, refresh the deal list.

Sidebar navigation:

Add a "Deals" link to the top navigation group in
`app/components/layout/sidebar/Links.vue`, between "Contacts" and
"Settings". Use the icon `i-solar-dollar-bold-duotone`.

Dashboard and Realtime

Dashboard

Replace the placeholder dashboard with real CRM data and pipeline stats.

Server route:

Create `GET /api/teams/[teamId]/stats` — uses `authUser(event, "team.view")`.
Returns a JSON object with:

- `contact_count` — total number of contacts for the team
- `open_deals` — count of deals not in "closed_won" or "closed_lost"
- `pipeline_value` — sum of values for open deals. Return 0 if none.
- `won_this_month` — sum of values for deals with stage "closed_won" where
  closed_date is in the current calendar month. Return 0 if none.
- `pipeline_by_stage` — array of { stage, count, total_value } for each
  active stage (not closed), ordered by the pipeline order
- `recent_activities` — the last 10 activities with contact name, deal
  title, type, subject, and activity_date joined in
- `upcoming_closes` — the next 5 deals by expected_close_date that are
  still open, with contact name and owner name joined in

All queries are scoped to the team's team_id.

UI:

Update `app/pages/app/index.vue` to show:

- A row of four stat cards at the top using a grid layout. Each card shows
  an icon, a label, and the value. Cards: "Total Contacts" (contact_count),
  "Open Deals" (open_deals), "Pipeline Value" (pipeline_value formatted as
  currency), "Won This Month" (won_this_month formatted as currency).
  Use `USkeleton` placeholders while loading.

- Below the stats, a pipeline summary bar: a horizontal segmented bar
  showing each stage as a colored segment proportional to its deal count.
  Below the bar, show the stage name, deal count, and total value for each
  stage. Use the same stage colors as the deal badges.

- Below the pipeline, a two-column layout:
  - Left column: "Upcoming Closes" — a list of the next 5 deals expected
    to close. Each item shows deal title, contact name, expected close
    date, value, and owner avatar or name. Clicking a deal navigates to
    the deals page. Show an empty state if no upcoming closes.
  - Right column: "Recent Activity" — a list of the last 10 activities.
    Each item shows an icon for the activity type, subject, contact/deal
    name, and timestamp. Show an empty state if none.

Remove any placeholder/scaffolding content that was in the dashboard before.
Keep the `UDashboardPanel` wrapper with navbar and sidebar collapse.

Realtime

Add Supabase Realtime sync for the new tables so changes appear instantly
across browser sessions.

Database migration (via Supabase MCP):

Enable realtime publication and full replica identity for the new tables:

```sql
ALTER PUBLICATION supabase_realtime ADD TABLE contacts, deals, activities, deal_notes;
ALTER TABLE contacts REPLICA IDENTITY FULL;
ALTER TABLE deals REPLICA IDENTITY FULL;
ALTER TABLE activities REPLICA IDENTITY FULL;
ALTER TABLE deal_notes REPLICA IDENTITY FULL;
```

Update `app/composables/useRealtime.ts`:

1. Add `"contacts"`, `"deals"`, `"activities"`, and `"deal_notes"` to the
   `RealtimeTable` union type.
2. In the `setup()` function, add `.on("postgres_changes", ...)` handlers
   for each new table, filtered by `team_id=eq.${teamId}` where the table
   has a team_id column.

Integration — use `onTableDebounced` from `useRealtime()` inline in each
page. Do NOT create separate `useRealtimeX` composable files:

- In `app/pages/app/contacts/index.vue`:
  `const { onTableDebounced } = useRealtime()`
  `onTableDebounced("contacts", () => refreshContacts())`
- In `app/pages/app/deals/index.vue`:
  `const { onTableDebounced } = useRealtime()`
  `onTableDebounced(["deals", "deal_notes"], () => refreshDeals())`
- In the dashboard stats page:
  `const { onTableDebounced } = useRealtime()`
  `onTableDebounced(["contacts", "deals", "activities"], () => refreshStats())`

What You Built

Starting from a template that handled auth, teams, roles, and permissions, you added:

  1. Contacts — a full CRM contact database with types, tags, and ownership
  2. Deals — a sales pipeline with stages, values, and close tracking
  3. Activities — call, email, meeting, and note logging linked to contacts and deals
  4. Dashboard — pipeline visualization, won revenue, and activity feed
  5. Realtime — all changes sync instantly across connected browsers

Every feature follows the same patterns: permission-gated server routes, team-scoped data, Nuxt UI components, and the conventions defined in CLAUDE.md.

What's Next

  • Public API — let external services sync contacts or push deals from web forms
  • AI Chat — the baked-in assistant is already wired to your new tables via tablePermissions. Try it with "What's our pipeline value?" or "Which deals close this week?"