fastdata
Examples

Ringly knowledge base

The knowledge base of the Ringly example: a 15-table SaaS voice-call database on Postgres, with metrics, rules and verified examples.

knowledge/INDEX.md

Knowledge base index — Ringly

Ringly sells a cloud phone system (calls + SMS) to support and sales teams. Customers are accounts; their staff who log in are users (seats); the people they call are contacts.

Sources

  • sources/ringly/ — PostgreSQL, schema public, 15 tables. See _source.md for dialect notes.

Where to look

Question aboutStart with
revenue, MRR, ARR, plans, seats, churnmetrics/mrr.md, tables subscriptions, plans, accounts
invoices, payments, overduetable invoices
calls, answer rate, missed calls, wait time, talk timemetrics/call-kpis.md, table calls
SMStable messages
product usage, logins, feature adoptiontable events
customer satisfaction, NPSmetrics/nps.md, table nps_responses
support load, ticketstable support_tickets
CRM / helpdesk integrationstable integrations
who works where, teamstables users, teams, team_members

Also: glossary.md (business terms), rules.md (personal data, default filters), examples/.

Table map

plans ← subscriptions → accounts ← users, teams, phone_numbers, contacts, calls, messages, events, integrations, support_tickets, nps_responses, invoices. team_members links teams and users. calls and messages reference phone_numbers, users (nullable) and contacts (nullable).

knowledge/glossary.md

Glossary

Customer / account

A company paying for Ringly: a row in accounts. "Customers" means accounts unless the question is clearly about an account's own callers (then contacts).

Active customer

accounts.status = 'active'. Trials (status = 'trial') are not customers yet.

Churned customer

accounts.status = 'churned'; the churn date is accounts.churned_at.

MRR / ARR

Monthly recurring revenue: sum of subscriptions.mrr_usd for status = 'active' subscriptions. ARR = MRR × 12. See metrics/mrr.md. Trialing subscriptions have MRR 0.

Seats

subscriptions.seats (paid seats). Not the same as the number of rows in users, which can include invited and deactivated people.

Agent

A user with role = 'agent'. "Staff" or "users" means all roles.

Answer rate, missed calls, wait time, talk time, AHT

Call KPIs, defined in metrics/call-kpis.md. "Missed calls" in everyday language means inbound calls that were not answered: status missed, voicemail or abandoned.

NPS

Net Promoter Score from nps_responses, see metrics/nps.md.

Region

accounts.country is ISO alpha-2. "Europe" = every country except US. "DACH" = DE, AT, CH.

knowledge/rules.md

Rules

Personal data

  • users.email, users.full_name, contacts.full_name, contacts.phone_e164 and messages.body are personal data. Do not select them unless the user explicitly asks for names or contact details of specific people. Use ids or account names in aggregates.
  • Never select messages.body for analysis; count messages instead.

Default filters

  • Revenue and MRR use active subscriptions only (subscriptions.status = 'active').
  • Call KPIs about "our customers' support quality" use inbound calls (direction = 'inbound').
  • Exclude churned accounts from "current" questions; include them for historical ones.

Dates

  • All timestamps are timestamptz in UTC. "Last week" means the previous Monday–Sunday, "last month" the previous full calendar month. Use date_trunc.
  • Money is USD.
knowledge/metrics/call-kpis.md

Call KPIs

All from calls. Unless the question says otherwise, use inbound calls only.

KPIDefinition
Answer rateanswered inbound calls ÷ all inbound calls
Missed callsinbound calls with status missed, voicemail or abandoned
Abandon rateabandoned ÷ all inbound calls
Average wait timeAVG(wait_seconds) over inbound calls
Average talk timeAVG(talk_seconds) over answered calls
AHT (average handle time)same as average talk time here (no after-call work tracked)
Call costSUM(cost_usd): Ringly's carrier cost, not customer revenue
SELECT a.name AS account,
       COUNT(*) AS inbound_calls,
       COUNT(*) FILTER (WHERE c.status = 'answered') AS answered,
       ROUND(100.0 * COUNT(*) FILTER (WHERE c.status = 'answered') / COUNT(*), 1) AS answer_rate_pct,
       ROUND(AVG(c.wait_seconds)) AS avg_wait_s
FROM calls c
JOIN accounts a ON a.id = c.account_id
WHERE c.direction = 'inbound'
GROUP BY a.name
ORDER BY answer_rate_pct;
knowledge/metrics/mrr.md

MRR and ARR

MRR: sum of subscriptions.mrr_usd over subscriptions with status = 'active'. mrr_usd is already normalized to a month for annual plans and already multiplied by seats, so do not multiply by seats or plans.price_per_seat_usd again.

SELECT SUM(s.mrr_usd) AS mrr_usd, SUM(s.mrr_usd) * 12 AS arr_usd
FROM subscriptions s
WHERE s.status = 'active';

By plan:

SELECT p.name AS plan, p.billing_interval, COUNT(*) AS subscriptions, SUM(s.seats) AS seats,
       SUM(s.mrr_usd) AS mrr_usd
FROM subscriptions s
JOIN plans p ON p.id = s.plan_id
WHERE s.status = 'active'
GROUP BY p.name, p.billing_interval
ORDER BY mrr_usd DESC;

Churned MRR: mrr_usd of subscriptions with status = 'canceled', dated by ended_at.

knowledge/metrics/nps.md

NPS (Net Promoter Score)

From nps_responses.score (0–10): promoters 9–10, passives 7–8, detractors 0–6. NPS = % promoters − % detractors, a number from −100 to 100 (not a percentage).

SELECT COUNT(*) AS responses,
       ROUND(100.0 * COUNT(*) FILTER (WHERE score >= 9) / COUNT(*)
           - 100.0 * COUNT(*) FILTER (WHERE score <= 6) / COUNT(*)) AS nps
FROM nps_responses;
knowledge/examples/answer-rate-by-account.yaml
question: Which customers have the worst answer rate on inbound calls?
query: |
  SELECT a.name AS account, COUNT(*) AS inbound_calls,
         ROUND(100.0 * COUNT(*) FILTER (WHERE c.status = 'answered') / COUNT(*), 1) AS answer_rate_pct
  FROM calls c JOIN accounts a ON a.id = c.account_id
  WHERE c.direction = 'inbound'
  GROUP BY a.name
  ORDER BY answer_rate_pct, inbound_calls DESC
verified_by: demo
knowledge/examples/mrr-by-plan.yaml
question: What is our current MRR by plan?
query: |
  SELECT p.name AS plan, p.billing_interval, SUM(s.seats) AS seats, SUM(s.mrr_usd) AS mrr_usd
  FROM subscriptions s
  JOIN plans p ON p.id = s.plan_id
  WHERE s.status = 'active'
  GROUP BY p.name, p.billing_interval
  ORDER BY mrr_usd DESC
verified_by: demo
knowledge/examples/nps.yaml
question: What is our NPS and how many promoters and detractors do we have?
query: |
  SELECT COUNT(*) AS responses,
         COUNT(*) FILTER (WHERE score >= 9) AS promoters,
         COUNT(*) FILTER (WHERE score BETWEEN 7 AND 8) AS passives,
         COUNT(*) FILTER (WHERE score <= 6) AS detractors,
         ROUND(100.0 * COUNT(*) FILTER (WHERE score >= 9) / COUNT(*)
             - 100.0 * COUNT(*) FILTER (WHERE score <= 6) / COUNT(*)) AS nps
  FROM nps_responses
verified_by: demo
knowledge/examples/open-tickets.yaml
question: Which open support tickets are high priority or urgent?
query: |
  SELECT t.id, a.name AS account, t.subject, t.category, t.priority, t.status, t.created_at
  FROM support_tickets t JOIN accounts a ON a.id = t.account_id
  WHERE t.status IN ('open', 'pending') AND t.priority IN ('high', 'urgent')
  ORDER BY CASE t.priority WHEN 'urgent' THEN 0 ELSE 1 END, t.created_at
verified_by: demo
knowledge/sources/ringly/_source.md

Source: ringly

  • Engine: PostgreSQL 17, schema public. Read-only role fastdata_ro.
  • Use Postgres syntax: date_trunc('month', ts), now() - interval '30 days', COUNT(*) FILTER (WHERE ...), ILIKE, properties->>'provider' for jsonb.
  • Table and column comments are in the catalog: SELECT obj_description('calls'::regclass) / col_description('calls'::regclass, 6).
  • Data is a small test fixture (6–7 rows per table), current up to late October 2026.
knowledge/sources/ringly/tables/accounts.md

accounts

Ringly customers (companies). Columns: id, name, industry, country (ISO alpha-2), size_band (1-10 | 11-50 | 51-200 | 201-1000 | 1000+), status (trial | active | churned), created_at (sign-up), churned_at (NULL unless churned). Referenced by almost every table through account_id.

knowledge/sources/ringly/tables/calls.md

calls

One row per phone call. Columns: id, account_id → accounts, phone_number_id → phone_numbers, user_id → users (NULL when no agent answered), contact_id → contacts (nullable), direction (inbound | outbound), status (answered | missed | voicemail | abandoned), started_at, answered_at (NULL unless answered), ended_at, wait_seconds, talk_seconds (0 unless answered), cost_usd (Ringly's carrier cost). KPIs: metrics/call-kpis.md. Duration of a call = talk_seconds, not ended_at − started_at.

knowledge/sources/ringly/tables/contacts.md

contacts

The people an account calls or texts (its own customers). All personal data. Columns: id, account_id → accounts, full_name, phone_e164, company (nullable), created_at.

knowledge/sources/ringly/tables/events.md

events

Product analytics events. Columns: id, account_id, user_id (NULL for system events), event_name, occurred_at, properties (jsonb). Event names: login, call_started, integration_connected, trial_started, report_exported, voicemail_played. Read properties with properties->>'key', e.g. properties->>'device'.

knowledge/sources/ringly/tables/integrations.md

integrations

Third-party apps connected to an account. Columns: id, account_id, provider (salesforce | hubspot | zendesk | slack), status (connected | error | disconnected), connected_at, disconnected_at. error = still installed but failing to sync.

knowledge/sources/ringly/tables/invoices.md

invoices

Columns: id, account_id → accounts, subscription_id → subscriptions, period_start, period_end (dates), amount_usd, status (open | paid | overdue | void), issued_at, paid_at. Annual plans are invoiced once for the whole year, so invoice amounts are not MRR. "Outstanding" = status open or overdue.

knowledge/sources/ringly/tables/messages.md

messages

SMS messages. Columns: id, account_id, phone_number_id, user_id (NULL for inbound), contact_id, direction (inbound | outbound), body (PII, never select for analysis), status (received for inbound; delivered | failed for outbound), sent_at. Delivery rate = delivered ÷ outbound.

knowledge/sources/ringly/tables/nps_responses.md

nps_responses

NPS survey answers. Columns: id, account_id, user_id, score (0–10), comment (nullable, free text), submitted_at. See metrics/nps.md.

knowledge/sources/ringly/tables/phone_numbers.md

phone_numbers

Numbers rented by accounts. Columns: id, account_id → accounts, e164 (e.g. +12125550101), country, number_type (local | toll_free | mobile), status (active | released), created_at, released_at.

knowledge/sources/ringly/tables/plans.md

plans

Price plans. price_per_seat_usd is per seat per month, also for annual plans. Columns: id, code (unique, e.g. pro_monthly), name (Starter | Professional | Enterprise | Basic), billing_interval (month | year), price_per_seat_usd, included_minutes (per seat per month), is_active (false = legacy plan still on old subscriptions).

knowledge/sources/ringly/tables/subscriptions.md

subscriptions

One row per subscription period of an account to a plan. Columns: id, account_id → accounts, plan_id → plans, seats, mrr_usd (monthly, already × seats), status (trialing | active | canceled), started_at, ended_at (NULL while running). Revenue questions: see metrics/mrr.md.

knowledge/sources/ringly/tables/support_tickets.md

support_tickets

Tickets accounts open with Ringly support. Columns: id, account_id, opened_by_user_id (nullable), subject, category (billing | call_quality | integration | onboarding | other), priority (low | normal | high | urgent), status (open | pending | solved), created_at, solved_at. "Open tickets" = status open or pending. Resolution time = solved_at − created_at.

knowledge/sources/ringly/tables/team_members.md

team_members

Many-to-many between teams and users. Columns: team_id → teams, user_id → users, joined_at. A user can be in several teams; count DISTINCT user_id when counting people.

knowledge/sources/ringly/tables/teams.md

teams

Call queues / groups inside an account. Columns: id, account_id → accounts, name, created_at. Members are in team_members.

knowledge/sources/ringly/tables/users.md

users

People who log in to Ringly. Columns: id, account_id → accounts, email (PII), full_name (PII), role (admin | supervisor | agent), status (invited | active | deactivated), created_at, last_login_at (NULL = never logged in).