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.
- package/README.md +58 -0
- package/bin/index.js +458 -0
- package/bin/sync-templates.js +42 -0
- package/package.json +36 -0
- package/templates/_shared/.github/workflows/client-gen.yml +65 -0
- package/templates/_shared/.github/workflows/deploy.yml +70 -0
- package/templates/_shared/.github/workflows/migrate.yml +66 -0
- package/templates/_shared/.github/workflows/test.yml +49 -0
- package/templates/_shared/agent.md +480 -0
- package/templates/_shared/tests/smoke.test.js +67 -0
- package/templates/b2b-saas/README.md +57 -0
- package/templates/b2b-saas/schema/versions/v1/billingEvent.js +46 -0
- package/templates/b2b-saas/schema/versions/v1/invite.js +30 -0
- package/templates/b2b-saas/schema/versions/v1/org.js +23 -0
- package/templates/b2b-saas/schema/versions/v1/workspace.js +13 -0
- package/templates/b2b-saas/seed.js +98 -0
- package/templates/blank/README.md +42 -0
- package/templates/blank/schema/versions/v1/note.js +10 -0
- package/templates/blank/seed.js +86 -0
- package/templates/content/README.md +58 -0
- package/templates/content/schema/versions/v1/article.js +77 -0
- package/templates/content/schema/versions/v1/category.js +30 -0
- package/templates/content/seed.js +97 -0
- package/templates/crm/README.md +59 -0
- package/templates/crm/schema/versions/v1/account.js +22 -0
- package/templates/crm/schema/versions/v1/activity.js +20 -0
- package/templates/crm/schema/versions/v1/contact.js +28 -0
- package/templates/crm/schema/versions/v1/deal.js +72 -0
- package/templates/crm/seed.js +124 -0
- package/templates/ticketing/README.md +46 -0
- package/templates/ticketing/schema/versions/v1/comment.js +26 -0
- package/templates/ticketing/schema/versions/v1/ticket.js +65 -0
- 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
|
+
});
|