create-davepi-app 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/README.md +58 -0
  2. package/bin/index.js +458 -0
  3. package/bin/sync-templates.js +42 -0
  4. package/package.json +36 -0
  5. package/templates/_shared/.github/workflows/client-gen.yml +65 -0
  6. package/templates/_shared/.github/workflows/deploy.yml +70 -0
  7. package/templates/_shared/.github/workflows/migrate.yml +66 -0
  8. package/templates/_shared/.github/workflows/test.yml +49 -0
  9. package/templates/_shared/agent.md +480 -0
  10. package/templates/_shared/tests/smoke.test.js +67 -0
  11. package/templates/b2b-saas/README.md +57 -0
  12. package/templates/b2b-saas/schema/versions/v1/billingEvent.js +46 -0
  13. package/templates/b2b-saas/schema/versions/v1/invite.js +30 -0
  14. package/templates/b2b-saas/schema/versions/v1/org.js +23 -0
  15. package/templates/b2b-saas/schema/versions/v1/workspace.js +13 -0
  16. package/templates/b2b-saas/seed.js +98 -0
  17. package/templates/blank/README.md +42 -0
  18. package/templates/blank/schema/versions/v1/note.js +10 -0
  19. package/templates/blank/seed.js +86 -0
  20. package/templates/content/README.md +58 -0
  21. package/templates/content/schema/versions/v1/article.js +77 -0
  22. package/templates/content/schema/versions/v1/category.js +30 -0
  23. package/templates/content/seed.js +97 -0
  24. package/templates/crm/README.md +59 -0
  25. package/templates/crm/schema/versions/v1/account.js +22 -0
  26. package/templates/crm/schema/versions/v1/activity.js +20 -0
  27. package/templates/crm/schema/versions/v1/contact.js +28 -0
  28. package/templates/crm/schema/versions/v1/deal.js +72 -0
  29. package/templates/crm/seed.js +124 -0
  30. package/templates/ticketing/README.md +46 -0
  31. package/templates/ticketing/schema/versions/v1/comment.js +26 -0
  32. package/templates/ticketing/schema/versions/v1/ticket.js +65 -0
  33. package/templates/ticketing/seed.js +97 -0
@@ -0,0 +1,97 @@
1
+ 'use strict';
2
+ require('dotenv').config();
3
+
4
+ const port = process.env.API_PORT || 5050;
5
+ const base = `http://127.0.0.1:${port}`;
6
+ const DEMO_EMAIL = 'demo@example.com';
7
+ const DEMO_PASSWORD = 'demo-password!';
8
+
9
+ async function fetchJson(path, opts = {}) {
10
+ const res = await fetch(base + path, {
11
+ ...opts,
12
+ headers: { 'Content-Type': 'application/json', ...(opts.headers || {}) },
13
+ });
14
+ const text = await res.text();
15
+ let body;
16
+ try { body = text ? JSON.parse(text) : null; } catch { body = text; }
17
+ return { status: res.status, body };
18
+ }
19
+
20
+ async function ensureDemoUser() {
21
+ let r = await fetchJson('/login', {
22
+ method: 'POST',
23
+ body: JSON.stringify({ email: DEMO_EMAIL, password: DEMO_PASSWORD }),
24
+ });
25
+ if (r.status === 200) return r.body.accessToken;
26
+ r = await fetchJson('/register', {
27
+ method: 'POST',
28
+ body: JSON.stringify({
29
+ first_name: 'Demo', last_name: 'User',
30
+ email: DEMO_EMAIL, password: DEMO_PASSWORD,
31
+ }),
32
+ });
33
+ if (r.status !== 201) throw new Error(`register: ${r.status} ${JSON.stringify(r.body)}`);
34
+ return r.body.accessToken;
35
+ }
36
+
37
+ async function post(path, body, auth) {
38
+ const r = await fetchJson(path, { method: 'POST', headers: auth, body: JSON.stringify(body) });
39
+ if (r.status !== 201) throw new Error(`${path}: ${r.status} ${JSON.stringify(r.body)}`);
40
+ return r.body;
41
+ }
42
+
43
+ async function put(path, body, auth) {
44
+ const r = await fetchJson(path, { method: 'PUT', headers: auth, body: JSON.stringify(body) });
45
+ if (r.status !== 200) throw new Error(`${path}: ${r.status} ${JSON.stringify(r.body)}`);
46
+ return r.body;
47
+ }
48
+
49
+ async function main() {
50
+ const token = await ensureDemoUser();
51
+ const auth = { Authorization: `Bearer ${token}` };
52
+
53
+ const eng = await post('/api/v1/category', {
54
+ name: 'Engineering',
55
+ description: 'Posts about how we build the product.',
56
+ }, auth);
57
+ const launches = await post('/api/v1/category', {
58
+ name: 'Launches',
59
+ description: 'Product announcements and changelogs.',
60
+ }, auth);
61
+
62
+ const a1 = await post('/api/v1/article', {
63
+ title: 'How we ship',
64
+ body: 'A short post about our weekly cadence.',
65
+ excerpt: 'Once a week, end-to-end.',
66
+ categoryId: eng._id,
67
+ tags: ['process', 'team'],
68
+ authorName: 'Demo',
69
+ }, auth);
70
+ await put(`/api/v1/article/${a1._id}`, { status: 'review' }, auth);
71
+ await put(`/api/v1/article/${a1._id}`, {
72
+ status: 'published',
73
+ publishedAt: new Date(),
74
+ }, auth);
75
+
76
+ await post('/api/v1/article', {
77
+ title: 'Draft: notes for a Q2 retrospective',
78
+ body: 'TBD.',
79
+ categoryId: eng._id,
80
+ }, auth);
81
+
82
+ await post('/api/v1/article', {
83
+ title: 'Launch: scaffolder + templates',
84
+ body: 'Run `npx create-davepi-app my-app --template crm` and you\'re running.',
85
+ categoryId: launches._id,
86
+ tags: ['launch'],
87
+ authorName: 'Demo',
88
+ }, auth);
89
+
90
+ process.stdout.write(`Seeded 2 categories, 3 articles (1 published, 2 draft) as ${DEMO_EMAIL}.\n`);
91
+ process.stdout.write(`Sign in with: ${DEMO_EMAIL} / ${DEMO_PASSWORD}\n`);
92
+ }
93
+
94
+ main().catch((err) => {
95
+ process.stderr.write(`\nSeed failed: ${err.message}\n`);
96
+ process.exit(1);
97
+ });
@@ -0,0 +1,59 @@
1
+ # CRM template
2
+
3
+ A minimal sales CRM. Demonstrates relations, state machines, computed fields, full-text search, file uploads, and aggregations — all features you'll learn by reading the schema files.
4
+
5
+ ## Resources
6
+
7
+ | Resource | Purpose |
8
+ |----------|---------|
9
+ | `account` | Companies you sell to. `name` and `description` are full-text searchable. Carries an optional logo (image, ≤2MB, public). Has `contacts` (hasMany), `deals` (hasMany), and `primaryContact` (hasOne where `isPrimary: true`). |
10
+ | `contact` | People at an account. `parentAccountId` joins to `account`. `fullName` is a computed field. |
11
+ | `deal` | Opportunity in flight. `stage` is a state machine: `lead → qualified → proposal → negotiation → won` (or `lost` from any earlier stage; `lost` can be re-opened). |
12
+ | `activity` | Touchpoints (call / email / meeting / note). Optionally tied to a contact or a deal. |
13
+
14
+ ## Aggregations
15
+
16
+ - `deal.pipelineByStage` — total amount + count grouped by deal stage. Cached 30s.
17
+ - `deal.wonByMonth` — won-deal totals grouped by close month. Cached 60s.
18
+
19
+ ## Worked example
20
+
21
+ ```bash
22
+ TOKEN=$(curl -s -X POST http://localhost:5050/login \
23
+ -H 'Content-Type: application/json' \
24
+ -d '{"email":"a@b.com","password":"pw12345!"}' | jq -r .accessToken)
25
+
26
+ # 1. Create an account
27
+ ACCT=$(curl -s -X POST http://localhost:5050/api/v1/account \
28
+ -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
29
+ -d '{"name":"Acme","industry":"manufacturing","employees":250}' | jq -r ._id)
30
+
31
+ # 2. Add a contact
32
+ curl -s -X POST http://localhost:5050/api/v1/contact \
33
+ -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
34
+ -d "{\"parentAccountId\":\"$ACCT\",\"firstName\":\"Jane\",\"lastName\":\"Doe\",\"email\":\"jane@acme.com\",\"isPrimary\":true}"
35
+
36
+ # 3. Create a deal — initial state stamped to "lead"
37
+ DEAL=$(curl -s -X POST http://localhost:5050/api/v1/deal \
38
+ -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
39
+ -d "{\"parentAccountId\":\"$ACCT\",\"title\":\"Q1 expansion\",\"amount\":50000}" | jq -r ._id)
40
+
41
+ # 4. Move it forward
42
+ curl -s -X PUT "http://localhost:5050/api/v1/deal/$DEAL" \
43
+ -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
44
+ -d '{"stage":"qualified"}'
45
+
46
+ # 5. Get the deal with its account populated
47
+ curl "http://localhost:5050/api/v1/deal/$DEAL?__include=account" \
48
+ -H "Authorization: Bearer $TOKEN" | jq
49
+
50
+ # 6. Pipeline view
51
+ curl http://localhost:5050/api/v1/deal/aggregations/pipelineByStage \
52
+ -H "Authorization: Bearer $TOKEN" | jq
53
+ ```
54
+
55
+ ## With Claude Code
56
+
57
+ Open the project, the `.mcp.json` is already configured. Try:
58
+
59
+ > Add a `lostReason` field to deal that's only populated when stage is `lost`, and an aggregation that groups `lost` deals by reason.
@@ -0,0 +1,22 @@
1
+ module.exports = {
2
+ path: 'account',
3
+ collection: 'account',
4
+ fields: [
5
+ { name: 'userId', type: String, required: true },
6
+ { name: 'name', type: String, required: true, searchable: true, searchWeight: 5 },
7
+ { name: 'industry', type: String },
8
+ { name: 'website', type: String },
9
+ { name: 'description', type: String, searchable: true },
10
+ { name: 'employees', type: Number },
11
+ { name: 'logo', type: 'File', file: { maxBytes: 2 * 1024 * 1024, accept: ['image/*'], access: 'public' } },
12
+ ],
13
+ relations: {
14
+ contacts: { hasMany: 'contact', foreignKey: 'parentAccountId' },
15
+ deals: { hasMany: 'deal', foreignKey: 'parentAccountId' },
16
+ primaryContact: {
17
+ hasOne: 'contact',
18
+ foreignKey: 'parentAccountId',
19
+ where: { isPrimary: true },
20
+ },
21
+ },
22
+ };
@@ -0,0 +1,20 @@
1
+ module.exports = {
2
+ path: 'activity',
3
+ collection: 'activity',
4
+ fields: [
5
+ { name: 'userId', type: String, required: true },
6
+ { name: 'type', type: String, required: true }, // call | email | meeting | note
7
+ { name: 'subject', type: String, required: true, searchable: true },
8
+ { name: 'body', type: String, searchable: true },
9
+ { name: 'occurredAt', type: Date, default: Date.now },
10
+ // Optional pointers into the parent record. An activity is
11
+ // typically attached to either a contact OR a deal — both are
12
+ // optional so a free-form note is also valid.
13
+ { name: 'contactId', type: String },
14
+ { name: 'dealId', type: String },
15
+ ],
16
+ relations: {
17
+ contact: { belongsTo: 'contact', localKey: 'contactId' },
18
+ deal: { belongsTo: 'deal', localKey: 'dealId' },
19
+ },
20
+ };
@@ -0,0 +1,28 @@
1
+ module.exports = {
2
+ path: 'contact',
3
+ collection: 'contact',
4
+ fields: [
5
+ { name: 'userId', type: String, required: true },
6
+ // NB: not `accountId` — the framework auto-stamps `accountId` on
7
+ // every record from the JWT user_id (legacy quirk), so a manual
8
+ // foreign key needs a different name. parentAccountId is what we
9
+ // use to point at the parent account.
10
+ { name: 'parentAccountId', type: String, required: true },
11
+ { name: 'firstName', type: String, required: true, searchable: true },
12
+ { name: 'lastName', type: String, required: true, searchable: true, searchWeight: 3 },
13
+ {
14
+ name: 'fullName',
15
+ type: String,
16
+ computed: (r) => [r.firstName, r.lastName].filter(Boolean).join(' '),
17
+ description: 'First and last name joined.',
18
+ },
19
+ { name: 'email', type: String, searchable: true },
20
+ { name: 'phone', type: String },
21
+ { name: 'role', type: String },
22
+ { name: 'isPrimary', type: Boolean, default: false },
23
+ ],
24
+ relations: {
25
+ account: { belongsTo: 'account', localKey: 'parentAccountId' },
26
+ activities: { hasMany: 'activity', foreignKey: 'contactId' },
27
+ },
28
+ };
@@ -0,0 +1,72 @@
1
+ module.exports = {
2
+ path: 'deal',
3
+ collection: 'deal',
4
+ fields: [
5
+ { name: 'userId', type: String, required: true },
6
+ { name: 'parentAccountId', type: String, required: true },
7
+ { name: 'title', type: String, required: true, searchable: true, searchWeight: 5 },
8
+ { name: 'amount', type: Number, required: true },
9
+ { name: 'currency', type: String, default: 'USD' },
10
+ { name: 'expectedCloseAt', type: Date },
11
+ { name: 'closedAt', type: Date },
12
+ {
13
+ // The classic CRM funnel as a state machine. The framework
14
+ // rejects undeclared transitions and surfaces the available
15
+ // next states on every read so the admin SPA renders the
16
+ // right action buttons automatically.
17
+ name: 'stage',
18
+ type: String,
19
+ stateMachine: {
20
+ initial: 'lead',
21
+ states: ['lead', 'qualified', 'proposal', 'negotiation', 'won', 'lost'],
22
+ transitions: {
23
+ lead: ['qualified', 'lost'],
24
+ qualified: ['proposal', 'lost'],
25
+ proposal: ['negotiation', 'won', 'lost'],
26
+ negotiation: ['won', 'lost'],
27
+ won: [],
28
+ lost: ['lead'],
29
+ },
30
+ },
31
+ },
32
+ ],
33
+ relations: {
34
+ account: { belongsTo: 'account', localKey: 'parentAccountId' },
35
+ activities: { hasMany: 'activity', foreignKey: 'dealId' },
36
+ },
37
+ aggregations: [
38
+ {
39
+ name: 'pipelineByStage',
40
+ description: 'Total amount and count grouped by deal stage.',
41
+ pipeline: [
42
+ { $group: { _id: '$stage', total: { $sum: '$amount' }, count: { $sum: 1 } } },
43
+ { $sort: { total: -1 } },
44
+ ],
45
+ cache: { ttlSeconds: 30 },
46
+ },
47
+ {
48
+ name: 'wonByMonth',
49
+ description: 'Sum of won-deal amounts grouped by close month.',
50
+ // `closedAt` is optional, so $year/$month would throw on rows
51
+ // where the field is null or missing. The $type filter keeps
52
+ // only documents whose closedAt is actually a Date — anything
53
+ // else (null, missing, accidental string) is excluded before
54
+ // it reaches the date operators.
55
+ pipeline: [
56
+ { $match: { stage: 'won', closedAt: { $type: 'date' } } },
57
+ {
58
+ $group: {
59
+ _id: {
60
+ year: { $year: '$closedAt' },
61
+ month: { $month: '$closedAt' },
62
+ },
63
+ total: { $sum: '$amount' },
64
+ count: { $sum: 1 },
65
+ },
66
+ },
67
+ { $sort: { '_id.year': 1, '_id.month': 1 } },
68
+ ],
69
+ cache: { ttlSeconds: 60 },
70
+ },
71
+ ],
72
+ };
@@ -0,0 +1,124 @@
1
+ 'use strict';
2
+ require('dotenv').config();
3
+
4
+ const port = process.env.API_PORT || 5050;
5
+ const base = `http://127.0.0.1:${port}`;
6
+ const DEMO_EMAIL = 'demo@example.com';
7
+ const DEMO_PASSWORD = 'demo-password!';
8
+
9
+ async function fetchJson(path, opts = {}) {
10
+ const res = await fetch(base + path, {
11
+ ...opts,
12
+ headers: { 'Content-Type': 'application/json', ...(opts.headers || {}) },
13
+ });
14
+ const text = await res.text();
15
+ let body;
16
+ try { body = text ? JSON.parse(text) : null; } catch { body = text; }
17
+ return { status: res.status, body };
18
+ }
19
+
20
+ async function ensureDemoUser() {
21
+ let r = await fetchJson('/login', {
22
+ method: 'POST',
23
+ body: JSON.stringify({ email: DEMO_EMAIL, password: DEMO_PASSWORD }),
24
+ });
25
+ if (r.status === 200) return r.body.accessToken;
26
+ r = await fetchJson('/register', {
27
+ method: 'POST',
28
+ body: JSON.stringify({
29
+ first_name: 'Demo',
30
+ last_name: 'User',
31
+ email: DEMO_EMAIL,
32
+ password: DEMO_PASSWORD,
33
+ }),
34
+ });
35
+ if (r.status !== 201) throw new Error(`register: ${r.status} ${JSON.stringify(r.body)}`);
36
+ return r.body.accessToken;
37
+ }
38
+
39
+ async function post(path, body, auth) {
40
+ const r = await fetchJson(path, { method: 'POST', headers: auth, body: JSON.stringify(body) });
41
+ if (r.status !== 201) throw new Error(`${path}: ${r.status} ${JSON.stringify(r.body)}`);
42
+ return r.body;
43
+ }
44
+
45
+ async function put(path, body, auth) {
46
+ const r = await fetchJson(path, { method: 'PUT', headers: auth, body: JSON.stringify(body) });
47
+ if (r.status !== 200) throw new Error(`${path}: ${r.status} ${JSON.stringify(r.body)}`);
48
+ return r.body;
49
+ }
50
+
51
+ async function main() {
52
+ const token = await ensureDemoUser();
53
+ const auth = { Authorization: `Bearer ${token}` };
54
+
55
+ const acme = await post('/api/v1/account', {
56
+ name: 'Acme Industrial',
57
+ industry: 'manufacturing',
58
+ employees: 250,
59
+ description: 'Long-running industrial customer; multi-site rollout.',
60
+ }, auth);
61
+
62
+ const globex = await post('/api/v1/account', {
63
+ name: 'Globex Logistics',
64
+ industry: 'transport',
65
+ employees: 1200,
66
+ }, auth);
67
+
68
+ await post('/api/v1/contact', {
69
+ parentAccountId: acme._id,
70
+ firstName: 'Jane',
71
+ lastName: 'Doe',
72
+ email: 'jane@acme.example',
73
+ role: 'CTO',
74
+ isPrimary: true,
75
+ }, auth);
76
+ await post('/api/v1/contact', {
77
+ parentAccountId: acme._id,
78
+ firstName: 'John',
79
+ lastName: 'Smith',
80
+ email: 'john@acme.example',
81
+ role: 'VP Eng',
82
+ }, auth);
83
+ await post('/api/v1/contact', {
84
+ parentAccountId: globex._id,
85
+ firstName: 'Maria',
86
+ lastName: 'Lopez',
87
+ email: 'maria@globex.example',
88
+ role: 'CIO',
89
+ isPrimary: true,
90
+ }, auth);
91
+
92
+ const dealA = await post('/api/v1/deal', {
93
+ parentAccountId: acme._id,
94
+ title: 'Q1 expansion',
95
+ amount: 50000,
96
+ expectedCloseAt: new Date(Date.now() + 30 * 24 * 3600e3),
97
+ }, auth);
98
+ await put(`/api/v1/deal/${dealA._id}`, { stage: 'qualified' }, auth);
99
+ await put(`/api/v1/deal/${dealA._id}`, { stage: 'proposal' }, auth);
100
+
101
+ const dealB = await post('/api/v1/deal', {
102
+ parentAccountId: globex._id,
103
+ title: 'EU rollout',
104
+ amount: 120000,
105
+ }, auth);
106
+ await put(`/api/v1/deal/${dealB._id}`, { stage: 'qualified' }, auth);
107
+
108
+ const dealClosed = await post('/api/v1/deal', {
109
+ parentAccountId: acme._id,
110
+ title: 'Renewal 2024',
111
+ amount: 75000,
112
+ }, auth);
113
+ await put(`/api/v1/deal/${dealClosed._id}`, { stage: 'qualified' }, auth);
114
+ await put(`/api/v1/deal/${dealClosed._id}`, { stage: 'proposal' }, auth);
115
+ await put(`/api/v1/deal/${dealClosed._id}`, { stage: 'won', closedAt: new Date() }, auth);
116
+
117
+ process.stdout.write(`Seeded 2 accounts, 3 contacts, 3 deals as ${DEMO_EMAIL}.\n`);
118
+ process.stdout.write(`Sign in with: ${DEMO_EMAIL} / ${DEMO_PASSWORD}\n`);
119
+ }
120
+
121
+ main().catch((err) => {
122
+ process.stderr.write(`\nSeed failed: ${err.message}\n`);
123
+ process.exit(1);
124
+ });
@@ -0,0 +1,46 @@
1
+ # Ticketing template
2
+
3
+ A help-desk skeleton with **two state machines on one schema** (status + priority), full-text search, an internal-only comment field gated by ACL, and aggregations for triage views.
4
+
5
+ ## Resources
6
+
7
+ | Resource | Purpose |
8
+ |----------|---------|
9
+ | `ticket` | The work item. `status` flows `open → in_progress → resolved → closed` (with reopen). `priority` flows `low ↔ normal ↔ high ↔ urgent` (no skipping levels). |
10
+ | `comment` | Replies on a ticket. `internal` is a flag gated by field-level ACL (`read: ['staff','admin']`) — non-staff callers don't see the flag. Note: this hides the **flag**, not the comment body. To make staff-only notes truly invisible to customers (no body, no metadata), model them as a separate resource so they never appear in `list` queries. |
11
+
12
+ ## Aggregations
13
+
14
+ - `ticket.byStatus` — counts grouped by status. Cached 15s.
15
+ - `ticket.urgentOpen` — currently-burning tickets, newest first. Capped at 50.
16
+
17
+ ## Worked example
18
+
19
+ ```bash
20
+ TOKEN=$(curl -s -X POST http://localhost:5050/login \
21
+ -H 'Content-Type: application/json' \
22
+ -d '{"email":"a@b.com","password":"pw12345!"}' | jq -r .accessToken)
23
+
24
+ # Create a ticket — initial status = "open", initial priority = "normal"
25
+ T=$(curl -s -X POST http://localhost:5050/api/v1/ticket \
26
+ -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
27
+ -d '{"title":"Login broken","body":"500 on /login","reporterId":"u1"}' | jq -r ._id)
28
+
29
+ # Take it
30
+ curl -s -X PUT "http://localhost:5050/api/v1/ticket/$T" \
31
+ -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
32
+ -d '{"status":"in_progress","assigneeId":"u2"}'
33
+
34
+ # Try to skip stages — rejected with 400 INVALID_TRANSITION
35
+ curl -s -X PUT "http://localhost:5050/api/v1/ticket/$T" \
36
+ -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
37
+ -d '{"priority":"urgent"}' # normal → urgent isn't allowed; must go via high
38
+
39
+ # Triage view
40
+ curl http://localhost:5050/api/v1/ticket/aggregations/byStatus \
41
+ -H "Authorization: Bearer $TOKEN" | jq
42
+ ```
43
+
44
+ ## With Claude Code
45
+
46
+ > Add an SLA computed field to ticket: number of minutes since `createdAt` if status isn't `resolved`/`closed`, else 0.
@@ -0,0 +1,26 @@
1
+ module.exports = {
2
+ path: 'comment',
3
+ collection: 'comment',
4
+ fields: [
5
+ { name: 'userId', type: String, required: true },
6
+ { name: 'ticketId', type: String, required: true },
7
+ { name: 'body', type: String, required: true, searchable: true },
8
+ {
9
+ name: 'internal',
10
+ type: Boolean,
11
+ default: false,
12
+ // The `internal` flag itself is hidden from callers without
13
+ // staff/admin roles via field-level ACL. NB: this hides the
14
+ // FLAG, not the comment body — field-level ACL only strips
15
+ // the named field. To make staff-only notes truly private to
16
+ // customers, model them as a separate resource so list/get
17
+ // queries never return them, or apply a list-time filter in
18
+ // a custom route. See the README walkthrough.
19
+ acl: { read: ['staff', 'admin'] },
20
+ },
21
+ { name: 'authorName', type: String },
22
+ ],
23
+ relations: {
24
+ ticket: { belongsTo: 'ticket', localKey: 'ticketId' },
25
+ },
26
+ };
@@ -0,0 +1,65 @@
1
+ module.exports = {
2
+ path: 'ticket',
3
+ collection: 'ticket',
4
+ fields: [
5
+ { name: 'userId', type: String, required: true },
6
+ { name: 'title', type: String, required: true, searchable: true, searchWeight: 5 },
7
+ { name: 'body', type: String, searchable: true },
8
+ {
9
+ name: 'priority',
10
+ type: String,
11
+ stateMachine: {
12
+ // priority is also a state machine — escalation has rules.
13
+ initial: 'normal',
14
+ states: ['low', 'normal', 'high', 'urgent'],
15
+ transitions: {
16
+ low: ['normal'],
17
+ normal: ['low', 'high'],
18
+ high: ['normal', 'urgent'],
19
+ urgent: ['high'],
20
+ },
21
+ },
22
+ },
23
+ {
24
+ name: 'status',
25
+ type: String,
26
+ stateMachine: {
27
+ initial: 'open',
28
+ states: ['open', 'in_progress', 'resolved', 'closed', 'reopened'],
29
+ transitions: {
30
+ open: ['in_progress', 'closed'],
31
+ in_progress: ['resolved', 'open'],
32
+ resolved: ['closed', 'reopened'],
33
+ closed: ['reopened'],
34
+ reopened: ['in_progress', 'closed'],
35
+ },
36
+ },
37
+ },
38
+ { name: 'assigneeId', type: String },
39
+ { name: 'reporterId', type: String, required: true },
40
+ { name: 'resolvedAt', type: Date },
41
+ ],
42
+ relations: {
43
+ comments: { hasMany: 'comment', foreignKey: 'ticketId' },
44
+ },
45
+ aggregations: [
46
+ {
47
+ name: 'byStatus',
48
+ description: 'Ticket count grouped by current status.',
49
+ pipeline: [
50
+ { $group: { _id: '$status', count: { $sum: 1 } } },
51
+ { $sort: { count: -1 } },
52
+ ],
53
+ cache: { ttlSeconds: 15 },
54
+ },
55
+ {
56
+ name: 'urgentOpen',
57
+ description: 'Open or in-progress urgent tickets, newest first.',
58
+ pipeline: [
59
+ { $match: { priority: 'urgent', status: { $in: ['open', 'in_progress'] } } },
60
+ { $sort: { createdAt: -1 } },
61
+ ],
62
+ maxResults: 50,
63
+ },
64
+ ],
65
+ };
@@ -0,0 +1,97 @@
1
+ 'use strict';
2
+ require('dotenv').config();
3
+
4
+ const port = process.env.API_PORT || 5050;
5
+ const base = `http://127.0.0.1:${port}`;
6
+ const DEMO_EMAIL = 'demo@example.com';
7
+ const DEMO_PASSWORD = 'demo-password!';
8
+
9
+ async function fetchJson(path, opts = {}) {
10
+ const res = await fetch(base + path, {
11
+ ...opts,
12
+ headers: { 'Content-Type': 'application/json', ...(opts.headers || {}) },
13
+ });
14
+ const text = await res.text();
15
+ let body;
16
+ try { body = text ? JSON.parse(text) : null; } catch { body = text; }
17
+ return { status: res.status, body };
18
+ }
19
+
20
+ async function ensureDemoUser() {
21
+ let r = await fetchJson('/login', {
22
+ method: 'POST',
23
+ body: JSON.stringify({ email: DEMO_EMAIL, password: DEMO_PASSWORD }),
24
+ });
25
+ if (r.status === 200) return r.body.accessToken;
26
+ r = await fetchJson('/register', {
27
+ method: 'POST',
28
+ body: JSON.stringify({
29
+ first_name: 'Demo', last_name: 'User',
30
+ email: DEMO_EMAIL, password: DEMO_PASSWORD,
31
+ }),
32
+ });
33
+ if (r.status !== 201) throw new Error(`register: ${r.status} ${JSON.stringify(r.body)}`);
34
+ return r.body.accessToken;
35
+ }
36
+
37
+ async function post(path, body, auth) {
38
+ const r = await fetchJson(path, { method: 'POST', headers: auth, body: JSON.stringify(body) });
39
+ if (r.status !== 201) throw new Error(`${path}: ${r.status} ${JSON.stringify(r.body)}`);
40
+ return r.body;
41
+ }
42
+
43
+ async function put(path, body, auth) {
44
+ const r = await fetchJson(path, { method: 'PUT', headers: auth, body: JSON.stringify(body) });
45
+ if (r.status !== 200) throw new Error(`${path}: ${r.status} ${JSON.stringify(r.body)}`);
46
+ return r.body;
47
+ }
48
+
49
+ async function main() {
50
+ const token = await ensureDemoUser();
51
+ const auth = { Authorization: `Bearer ${token}` };
52
+
53
+ // Mix of states + priorities so the byStatus / urgentOpen
54
+ // aggregations have something interesting to return.
55
+ const open = await post('/api/v1/ticket', {
56
+ title: 'Login broken on mobile Safari',
57
+ body: 'Steps: open / on iOS 17, tap login, get blank page.',
58
+ reporterId: 'demo',
59
+ }, auth);
60
+
61
+ const inProgress = await post('/api/v1/ticket', {
62
+ title: 'Slow query on /accounts list',
63
+ body: 'p95 hit 3s after the last deploy.',
64
+ reporterId: 'demo',
65
+ }, auth);
66
+ await put(`/api/v1/ticket/${inProgress._id}`, { status: 'in_progress', priority: 'high' }, auth);
67
+
68
+ const urgent = await post('/api/v1/ticket', {
69
+ title: 'Outage: webhook delivery failing',
70
+ body: 'Stripe webhooks bouncing 500 since 14:00 UTC.',
71
+ reporterId: 'demo',
72
+ }, auth);
73
+ await put(`/api/v1/ticket/${urgent._id}`, { priority: 'high' }, auth);
74
+ await put(`/api/v1/ticket/${urgent._id}`, { priority: 'urgent', status: 'in_progress' }, auth);
75
+
76
+ const resolved = await post('/api/v1/ticket', {
77
+ title: 'Typo in welcome email',
78
+ body: 'Says "Welome".',
79
+ reporterId: 'demo',
80
+ }, auth);
81
+ await put(`/api/v1/ticket/${resolved._id}`, { status: 'in_progress' }, auth);
82
+ await put(`/api/v1/ticket/${resolved._id}`, { status: 'resolved', resolvedAt: new Date() }, auth);
83
+
84
+ await post('/api/v1/comment', {
85
+ ticketId: inProgress._id,
86
+ body: 'Looks like the new index isn\'t being used. Investigating.',
87
+ authorName: 'Demo',
88
+ }, auth);
89
+
90
+ process.stdout.write(`Seeded 4 tickets across all states + 1 comment as ${DEMO_EMAIL}.\n`);
91
+ process.stdout.write(`Sign in with: ${DEMO_EMAIL} / ${DEMO_PASSWORD}\n`);
92
+ }
93
+
94
+ main().catch((err) => {
95
+ process.stderr.write(`\nSeed failed: ${err.message}\n`);
96
+ process.exit(1);
97
+ });