Appearance
Seed & queries
Empty tables are boring. Let's put a tiny world in there and ask the questions a CRM UI asks.
bash
cd examples/crm
sqlite3 crm.db < schema.sql
sqlite3 crm.db < seed.sqlOr from the repo root - same idea. queries.sql has the snippets below ready to .read.
Seed shape (what's in there)
- 2 users (owners)
- 3 companies
- 5 contacts
- a handful of deals across stages
- a few activities (notes / calls)
Open with headers for sanity:
sql
.headers on
.mode column
SELECT id, name, email FROM users;
SELECT id, name FROM companies;Query: company with its contacts
Screen: company detail page.
sql
SELECT
co.id AS company_id,
co.name AS company_name,
c.id AS contact_id,
c.name AS contact_name,
c.email,
c.title
FROM companies AS co
LEFT JOIN contacts AS c
ON c.company_id = co.id
AND c.deleted_at IS NULL
WHERE co.id = 1
AND co.deleted_at IS NULL
ORDER BY c.name;Flat rows → your API groups them into { company, contacts: […] }.
Query: deal pipeline by stage
Screen: kanban / board.
sql
SELECT
stage,
COUNT(*) AS deal_count,
SUM(amount) AS total_amount
FROM deals
WHERE deleted_at IS NULL
AND stage NOT IN ('won', 'lost') -- open pipeline only
GROUP BY stage
ORDER BY
CASE stage
WHEN 'lead' THEN 1
WHEN 'qualified' THEN 2
WHEN 'proposal' THEN 3
WHEN 'negotiation' THEN 4
ELSE 5
END;Comments for the code above:
- aggregate for the column headers ("3 deals · $120k")
CASEgives pipeline order instead of alphabetical stage names- closed stages filtered out for the "open board" view
Cards inside a column:
sql
SELECT id, title, amount, owner_id, company_id
FROM deals
WHERE deleted_at IS NULL
AND stage = 'negotiation'
ORDER BY updated_at DESC;Query: contact timeline
Screen: contact detail → activity feed.
sql
SELECT
a.id,
a.kind,
a.body,
a.created_at,
u.name AS author
FROM activities AS a
JOIN users AS u ON u.id = a.user_id
WHERE a.contact_id = 1
AND a.deleted_at IS NULL
ORDER BY a.created_at DESC
LIMIT 50;Query: my open deals
Screen: "My pipeline" for the logged-in user.
sql
SELECT
d.id,
d.title,
d.stage,
d.amount,
co.name AS company_name
FROM deals AS d
JOIN companies AS co ON co.id = d.company_id
WHERE d.owner_id = 1
AND d.deleted_at IS NULL
AND d.stage NOT IN ('won', 'lost')
ORDER BY d.updated_at DESC;Query: search contacts
Screen: topbar search / list filter.
sql
SELECT c.id, c.name, c.email, co.name AS company_name
FROM contacts AS c
LEFT JOIN companies AS co ON co.id = c.company_id
WHERE c.deleted_at IS NULL
AND (
c.name LIKE '%' || :q || '%'
OR c.email LIKE '%' || :q || '%'
)
ORDER BY c.name
LIMIT 20 OFFSET 0;In the sqlite3 CLI you can't use named :q binds the same way - swap in a literal while practicing:
sql
AND (c.name LIKE '%ada%' OR c.email LIKE '%ada%')In app code, always bind parameters - never string-concat user search into SQL.
Try it
bash
sqlite3 examples/crm/crm.db < examples/crm/queries.sqlNext: patterns that make the UI feel like a product - CRM UI patterns.