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,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Smoke tests for the project's schema files.
|
|
3
|
+
*
|
|
4
|
+
* Validates each file under `schema/versions/v1/` parses cleanly,
|
|
5
|
+
* exports a `path` + `fields` shape, and includes the framework's
|
|
6
|
+
* required `userId` tenant column. Doesn't need MongoDB — this is
|
|
7
|
+
* a fast guard against typos that would otherwise only surface
|
|
8
|
+
* when the server boots in production.
|
|
9
|
+
*
|
|
10
|
+
* Uses node:test (built-in, no extra deps). Add your own
|
|
11
|
+
* integration tests alongside this file as the project grows.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
const test = require('node:test');
|
|
17
|
+
const assert = require('node:assert/strict');
|
|
18
|
+
const fs = require('node:fs');
|
|
19
|
+
const path = require('node:path');
|
|
20
|
+
|
|
21
|
+
const schemaDir = path.join(__dirname, '..', 'schema', 'versions', 'v1');
|
|
22
|
+
const files = fs.existsSync(schemaDir)
|
|
23
|
+
? fs.readdirSync(schemaDir).filter((f) => f.endsWith('.js'))
|
|
24
|
+
: [];
|
|
25
|
+
|
|
26
|
+
test('schema directory exists and contains at least one schema file', () => {
|
|
27
|
+
assert.ok(
|
|
28
|
+
fs.existsSync(schemaDir),
|
|
29
|
+
'expected ./schema/versions/v1/ to exist'
|
|
30
|
+
);
|
|
31
|
+
assert.ok(
|
|
32
|
+
files.length > 0,
|
|
33
|
+
'expected at least one schema file under ./schema/versions/v1/'
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
for (const file of files) {
|
|
38
|
+
test(`${file}: exports a valid schema shape`, () => {
|
|
39
|
+
// require() will throw on a syntax error or a missing CommonJS
|
|
40
|
+
// export — both of which we want to catch in CI before deploy.
|
|
41
|
+
const schema = require(path.join(schemaDir, file));
|
|
42
|
+
|
|
43
|
+
assert.equal(typeof schema, 'object', `${file} must export an object`);
|
|
44
|
+
assert.equal(
|
|
45
|
+
typeof schema.path,
|
|
46
|
+
'string',
|
|
47
|
+
`${file}: schema.path must be a string`
|
|
48
|
+
);
|
|
49
|
+
assert.ok(schema.path.length > 0, `${file}: schema.path must be non-empty`);
|
|
50
|
+
assert.ok(Array.isArray(schema.fields), `${file}: schema.fields must be an array`);
|
|
51
|
+
assert.ok(
|
|
52
|
+
schema.fields.length > 0,
|
|
53
|
+
`${file}: schema.fields must have at least one entry`
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
const userIdField = schema.fields.find((f) => f && f.name === 'userId');
|
|
57
|
+
assert.ok(
|
|
58
|
+
userIdField,
|
|
59
|
+
`${file}: every schema must declare a 'userId' field — the framework stamps it as the tenant column`
|
|
60
|
+
);
|
|
61
|
+
assert.equal(
|
|
62
|
+
userIdField.required,
|
|
63
|
+
true,
|
|
64
|
+
`${file}: 'userId' must be required: true`
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# B2B SaaS template
|
|
2
|
+
|
|
3
|
+
Multi-tenant SaaS skeleton: orgs, workspaces, invitations with a state machine, and a billing-event log with monthly aggregations. The patterns here scale to a real product — invite flow, plan/seat tracking, billing-event audit, computed slug.
|
|
4
|
+
|
|
5
|
+
## Resources
|
|
6
|
+
|
|
7
|
+
| Resource | Purpose |
|
|
8
|
+
|----------|---------|
|
|
9
|
+
| `org` | The customer entity. `slug` is computed from `name`. `plan` and `seats` track the subscription. Has `workspaces` (hasMany) and `invites` (hasMany). |
|
|
10
|
+
| `workspace` | A scoped area inside an org. Connects via `org` (belongsTo `orgId`). |
|
|
11
|
+
| `invite` | Pending invitations. `status` flows `pending → accepted / declined / revoked / expired`. `expired` can re-enter `pending` if the org reissues. |
|
|
12
|
+
| `billingEvent` | Append-only ledger (upgrade / downgrade / invoice / refund / usage) referencing an org. Aggregations: `byOrg` (total per org), `monthlyRecurring` (per-month totals). |
|
|
13
|
+
|
|
14
|
+
## Worked example
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
TOKEN=$(curl -s -X POST http://localhost:5050/login \
|
|
18
|
+
-H 'Content-Type: application/json' \
|
|
19
|
+
-d '{"email":"a@b.com","password":"pw12345!"}' | jq -r .accessToken)
|
|
20
|
+
|
|
21
|
+
# Create an org
|
|
22
|
+
ORG=$(curl -s -X POST http://localhost:5050/api/v1/org \
|
|
23
|
+
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
|
24
|
+
-d '{"name":"Acme Co","plan":"starter","seats":10}' | jq -r ._id)
|
|
25
|
+
|
|
26
|
+
# Add a workspace inside it
|
|
27
|
+
curl -s -X POST http://localhost:5050/api/v1/workspace \
|
|
28
|
+
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
|
29
|
+
-d "{\"orgId\":\"$ORG\",\"name\":\"Engineering\"}"
|
|
30
|
+
|
|
31
|
+
# Invite someone — initial status = "pending"
|
|
32
|
+
INV=$(curl -s -X POST http://localhost:5050/api/v1/invite \
|
|
33
|
+
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
|
34
|
+
-d "{\"orgId\":\"$ORG\",\"email\":\"jane@acme.com\",\"role\":\"admin\"}" | jq -r ._id)
|
|
35
|
+
|
|
36
|
+
# Accept
|
|
37
|
+
curl -s -X PUT "http://localhost:5050/api/v1/invite/$INV" \
|
|
38
|
+
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
|
39
|
+
-d "{\"status\":\"accepted\",\"acceptedAt\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}"
|
|
40
|
+
|
|
41
|
+
# Log a plan upgrade
|
|
42
|
+
curl -s -X POST http://localhost:5050/api/v1/billingEvent \
|
|
43
|
+
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
|
44
|
+
-d "{\"orgId\":\"$ORG\",\"kind\":\"upgrade\",\"amount\":99,\"externalRef\":\"ch_abc\"}"
|
|
45
|
+
|
|
46
|
+
# Aggregations
|
|
47
|
+
curl http://localhost:5050/api/v1/billingEvent/aggregations/byOrg \
|
|
48
|
+
-H "Authorization: Bearer $TOKEN" | jq
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## With Claude Code
|
|
52
|
+
|
|
53
|
+
> Add a `member` resource that joins users to orgs (with role: 'owner' | 'admin' | 'member'), and a `members` hasMany relation on org.
|
|
54
|
+
|
|
55
|
+
## Pair with Idempotency-Key
|
|
56
|
+
|
|
57
|
+
The invite-create endpoint is the obvious place — sending the same key + body twice returns the original invite instead of creating a duplicate. See [Idempotency keys](https://docs.davepi.dev/features/idempotency/).
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
module.exports = {
|
|
2
|
+
path: 'billingEvent',
|
|
3
|
+
collection: 'billing_event',
|
|
4
|
+
fields: [
|
|
5
|
+
{ name: 'userId', type: String, required: true },
|
|
6
|
+
{ name: 'orgId', type: String, required: true },
|
|
7
|
+
{ name: 'kind', type: String, required: true }, // upgrade | downgrade | invoice | refund | usage
|
|
8
|
+
{ name: 'amount', type: Number },
|
|
9
|
+
{ name: 'currency', type: String, default: 'USD' },
|
|
10
|
+
{ name: 'externalRef', type: String }, // Stripe charge id, etc.
|
|
11
|
+
{ name: 'occurredAt', type: Date, default: Date.now },
|
|
12
|
+
],
|
|
13
|
+
relations: {
|
|
14
|
+
org: { belongsTo: 'org', localKey: 'orgId' },
|
|
15
|
+
},
|
|
16
|
+
aggregations: [
|
|
17
|
+
{
|
|
18
|
+
name: 'byOrg',
|
|
19
|
+
description: 'Total billing amount per org for the authenticated tenant.',
|
|
20
|
+
pipeline: [
|
|
21
|
+
{ $match: { amount: { $type: 'number' } } },
|
|
22
|
+
{ $group: { _id: '$orgId', total: { $sum: '$amount' }, count: { $sum: 1 } } },
|
|
23
|
+
{ $sort: { total: -1 } },
|
|
24
|
+
],
|
|
25
|
+
cache: { ttlSeconds: 60 },
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
name: 'monthlyRecurring',
|
|
29
|
+
description: 'Billing-event totals grouped by month.',
|
|
30
|
+
pipeline: [
|
|
31
|
+
{ $match: { occurredAt: { $type: 'date' }, amount: { $type: 'number' } } },
|
|
32
|
+
{
|
|
33
|
+
$group: {
|
|
34
|
+
_id: {
|
|
35
|
+
year: { $year: '$occurredAt' },
|
|
36
|
+
month: { $month: '$occurredAt' },
|
|
37
|
+
},
|
|
38
|
+
total: { $sum: '$amount' },
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
{ $sort: { '_id.year': 1, '_id.month': 1 } },
|
|
42
|
+
],
|
|
43
|
+
cache: { ttlSeconds: 60 },
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
module.exports = {
|
|
2
|
+
path: 'invite',
|
|
3
|
+
collection: 'invite',
|
|
4
|
+
fields: [
|
|
5
|
+
{ name: 'userId', type: String, required: true },
|
|
6
|
+
{ name: 'orgId', type: String, required: true },
|
|
7
|
+
{ name: 'email', type: String, required: true },
|
|
8
|
+
{ name: 'role', type: String, default: 'member' },
|
|
9
|
+
{
|
|
10
|
+
name: 'status',
|
|
11
|
+
type: String,
|
|
12
|
+
stateMachine: {
|
|
13
|
+
initial: 'pending',
|
|
14
|
+
states: ['pending', 'accepted', 'declined', 'revoked', 'expired'],
|
|
15
|
+
transitions: {
|
|
16
|
+
pending: ['accepted', 'declined', 'revoked', 'expired'],
|
|
17
|
+
accepted: [],
|
|
18
|
+
declined: [],
|
|
19
|
+
revoked: [],
|
|
20
|
+
expired: ['pending'],
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
{ name: 'expiresAt', type: Date },
|
|
25
|
+
{ name: 'acceptedAt', type: Date },
|
|
26
|
+
],
|
|
27
|
+
relations: {
|
|
28
|
+
org: { belongsTo: 'org', localKey: 'orgId' },
|
|
29
|
+
},
|
|
30
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module.exports = {
|
|
2
|
+
path: 'org',
|
|
3
|
+
collection: 'org',
|
|
4
|
+
fields: [
|
|
5
|
+
{ name: 'userId', type: String, required: true },
|
|
6
|
+
{ name: 'name', type: String, required: true, searchable: true, searchWeight: 5 },
|
|
7
|
+
{
|
|
8
|
+
name: 'slug',
|
|
9
|
+
type: String,
|
|
10
|
+
computed: (r) =>
|
|
11
|
+
String(r.name || '')
|
|
12
|
+
.toLowerCase()
|
|
13
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
14
|
+
.replace(/^-|-$/g, ''),
|
|
15
|
+
},
|
|
16
|
+
{ name: 'plan', type: String, default: 'trial' }, // trial | starter | growth | enterprise
|
|
17
|
+
{ name: 'seats', type: Number, default: 5 },
|
|
18
|
+
],
|
|
19
|
+
relations: {
|
|
20
|
+
workspaces: { hasMany: 'workspace', foreignKey: 'orgId' },
|
|
21
|
+
invites: { hasMany: 'invite', foreignKey: 'orgId' },
|
|
22
|
+
},
|
|
23
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
module.exports = {
|
|
2
|
+
path: 'workspace',
|
|
3
|
+
collection: 'workspace',
|
|
4
|
+
fields: [
|
|
5
|
+
{ name: 'userId', type: String, required: true },
|
|
6
|
+
{ name: 'orgId', type: String, required: true },
|
|
7
|
+
{ name: 'name', type: String, required: true, searchable: true },
|
|
8
|
+
{ name: 'description', type: String, searchable: true },
|
|
9
|
+
],
|
|
10
|
+
relations: {
|
|
11
|
+
org: { belongsTo: 'org', localKey: 'orgId' },
|
|
12
|
+
},
|
|
13
|
+
};
|
|
@@ -0,0 +1,98 @@
|
|
|
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 acme = await post('/api/v1/org', {
|
|
54
|
+
name: 'Acme Co',
|
|
55
|
+
plan: 'starter',
|
|
56
|
+
seats: 10,
|
|
57
|
+
}, auth);
|
|
58
|
+
const globex = await post('/api/v1/org', {
|
|
59
|
+
name: 'Globex',
|
|
60
|
+
plan: 'enterprise',
|
|
61
|
+
seats: 200,
|
|
62
|
+
}, auth);
|
|
63
|
+
|
|
64
|
+
await post('/api/v1/workspace', { orgId: acme._id, name: 'Engineering' }, auth);
|
|
65
|
+
await post('/api/v1/workspace', { orgId: acme._id, name: 'Marketing' }, auth);
|
|
66
|
+
await post('/api/v1/workspace', { orgId: globex._id, name: 'EU Region' }, auth);
|
|
67
|
+
|
|
68
|
+
const inv = await post('/api/v1/invite', {
|
|
69
|
+
orgId: acme._id,
|
|
70
|
+
email: 'jane@acme.example',
|
|
71
|
+
role: 'admin',
|
|
72
|
+
}, auth);
|
|
73
|
+
await put(`/api/v1/invite/${inv._id}`, { status: 'accepted', acceptedAt: new Date() }, auth);
|
|
74
|
+
|
|
75
|
+
await post('/api/v1/invite', {
|
|
76
|
+
orgId: globex._id,
|
|
77
|
+
email: 'bob@globex.example',
|
|
78
|
+
role: 'member',
|
|
79
|
+
}, auth);
|
|
80
|
+
|
|
81
|
+
await post('/api/v1/billingEvent', {
|
|
82
|
+
orgId: acme._id, kind: 'upgrade', amount: 99, externalRef: 'ch_seed_1',
|
|
83
|
+
}, auth);
|
|
84
|
+
await post('/api/v1/billingEvent', {
|
|
85
|
+
orgId: globex._id, kind: 'invoice', amount: 4990, externalRef: 'ch_seed_2',
|
|
86
|
+
}, auth);
|
|
87
|
+
await post('/api/v1/billingEvent', {
|
|
88
|
+
orgId: globex._id, kind: 'usage', amount: 220, externalRef: 'usage_seed_1',
|
|
89
|
+
}, auth);
|
|
90
|
+
|
|
91
|
+
process.stdout.write(`Seeded 2 orgs, 3 workspaces, 2 invites, 3 billing events as ${DEMO_EMAIL}.\n`);
|
|
92
|
+
process.stdout.write(`Sign in with: ${DEMO_EMAIL} / ${DEMO_PASSWORD}\n`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
main().catch((err) => {
|
|
96
|
+
process.stderr.write(`\nSeed failed: ${err.message}\n`);
|
|
97
|
+
process.exit(1);
|
|
98
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Blank template
|
|
2
|
+
|
|
3
|
+
The minimal starter — one resource (`note`) with full-text search, ready to demo CRUD and the admin SPA without telling you what to model.
|
|
4
|
+
|
|
5
|
+
## Resources
|
|
6
|
+
|
|
7
|
+
| Resource | Purpose |
|
|
8
|
+
|----------|---------|
|
|
9
|
+
| `note` | A single text record with `title`, `body`, `pinned`. Both text fields are `searchable` so `__q` works out of the box. |
|
|
10
|
+
|
|
11
|
+
## Try it
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
# After `npx create-davepi-app my-app --template blank`:
|
|
15
|
+
cd my-app
|
|
16
|
+
npm install
|
|
17
|
+
npm start
|
|
18
|
+
|
|
19
|
+
# In another terminal:
|
|
20
|
+
curl -X POST http://localhost:5050/register \
|
|
21
|
+
-H 'Content-Type: application/json' \
|
|
22
|
+
-d '{"first_name":"A","last_name":"B","email":"a@b.com","password":"pw12345!"}'
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Save the returned `accessToken`, then:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
TOKEN=...
|
|
29
|
+
curl -X POST http://localhost:5050/api/v1/note \
|
|
30
|
+
-H "Authorization: Bearer $TOKEN" \
|
|
31
|
+
-H 'Content-Type: application/json' \
|
|
32
|
+
-d '{"title":"Hello","body":"World"}'
|
|
33
|
+
|
|
34
|
+
curl 'http://localhost:5050/api/v1/note?__q=hello' \
|
|
35
|
+
-H "Authorization: Bearer $TOKEN"
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Where to go next
|
|
39
|
+
|
|
40
|
+
- Add another resource: drop a file in `schema/versions/v1/`. Hot-reload picks it up.
|
|
41
|
+
- Wire Claude Code: open the project in your editor, the `.mcp.json` is already configured.
|
|
42
|
+
- Generate a typed client: `npx davepi gen-client --out client/davepi.ts`.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
module.exports = {
|
|
2
|
+
path: 'note',
|
|
3
|
+
collection: 'note',
|
|
4
|
+
fields: [
|
|
5
|
+
{ name: 'userId', type: String, required: true },
|
|
6
|
+
{ name: 'title', type: String, required: true, searchable: true },
|
|
7
|
+
{ name: 'body', type: String, searchable: true },
|
|
8
|
+
{ name: 'pinned', type: Boolean, default: false },
|
|
9
|
+
],
|
|
10
|
+
};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Seed sample data for the `blank` template.
|
|
3
|
+
*
|
|
4
|
+
* Run: `npm run seed` (after `npm install` and `docker compose up -d`)
|
|
5
|
+
*
|
|
6
|
+
* The script boots an HTTP client against the configured API_PORT,
|
|
7
|
+
* registers (or logs in) a demo user, and POSTs a handful of notes
|
|
8
|
+
* so the admin SPA / MCP surface have something to look at.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
require('dotenv').config();
|
|
14
|
+
|
|
15
|
+
const port = process.env.API_PORT || 5050;
|
|
16
|
+
const base = `http://127.0.0.1:${port}`;
|
|
17
|
+
|
|
18
|
+
const DEMO_EMAIL = 'demo@example.com';
|
|
19
|
+
const DEMO_PASSWORD = 'demo-password!';
|
|
20
|
+
|
|
21
|
+
async function fetchJson(path, opts = {}) {
|
|
22
|
+
const res = await fetch(base + path, {
|
|
23
|
+
...opts,
|
|
24
|
+
headers: { 'Content-Type': 'application/json', ...(opts.headers || {}) },
|
|
25
|
+
});
|
|
26
|
+
const text = await res.text();
|
|
27
|
+
let body;
|
|
28
|
+
try { body = text ? JSON.parse(text) : null; } catch { body = text; }
|
|
29
|
+
return { status: res.status, body };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function ensureDemoUser() {
|
|
33
|
+
// Try to log in first; fall back to register on 400 (no such user).
|
|
34
|
+
let r = await fetchJson('/login', {
|
|
35
|
+
method: 'POST',
|
|
36
|
+
body: JSON.stringify({ email: DEMO_EMAIL, password: DEMO_PASSWORD }),
|
|
37
|
+
});
|
|
38
|
+
if (r.status === 200) return r.body.accessToken;
|
|
39
|
+
r = await fetchJson('/register', {
|
|
40
|
+
method: 'POST',
|
|
41
|
+
body: JSON.stringify({
|
|
42
|
+
first_name: 'Demo',
|
|
43
|
+
last_name: 'User',
|
|
44
|
+
email: DEMO_EMAIL,
|
|
45
|
+
password: DEMO_PASSWORD,
|
|
46
|
+
}),
|
|
47
|
+
});
|
|
48
|
+
if (r.status !== 201) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
`Failed to register demo user: ${r.status} ${JSON.stringify(r.body)}`
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
return r.body.accessToken;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function main() {
|
|
57
|
+
const token = await ensureDemoUser();
|
|
58
|
+
const auth = { Authorization: `Bearer ${token}` };
|
|
59
|
+
|
|
60
|
+
const seed = [
|
|
61
|
+
{ title: 'Pinned: ship the launch post', body: 'Hero, video, comparison page.', pinned: true },
|
|
62
|
+
{ title: 'Reach out to early users', body: 'Three people from the waitlist.' },
|
|
63
|
+
{ title: 'Refactor the auth middleware', body: 'See PR #47 for context.' },
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
for (const note of seed) {
|
|
67
|
+
const r = await fetchJson('/api/v1/note', {
|
|
68
|
+
method: 'POST',
|
|
69
|
+
headers: auth,
|
|
70
|
+
body: JSON.stringify(note),
|
|
71
|
+
});
|
|
72
|
+
if (r.status !== 201) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
`Failed to create note: ${r.status} ${JSON.stringify(r.body)}`
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
process.stdout.write(` ✓ ${note.title}\n`);
|
|
78
|
+
}
|
|
79
|
+
process.stdout.write(`\nSeeded ${seed.length} notes as ${DEMO_EMAIL}.\n`);
|
|
80
|
+
process.stdout.write(`Sign in with: ${DEMO_EMAIL} / ${DEMO_PASSWORD}\n`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
main().catch((err) => {
|
|
84
|
+
process.stderr.write(`\nSeed failed: ${err.message}\n`);
|
|
85
|
+
process.exit(1);
|
|
86
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Content template
|
|
2
|
+
|
|
3
|
+
A blog / CMS skeleton with editorial workflow, slug computation, hero image upload, and category aggregations.
|
|
4
|
+
|
|
5
|
+
## Resources
|
|
6
|
+
|
|
7
|
+
| Resource | Purpose |
|
|
8
|
+
|----------|---------|
|
|
9
|
+
| `article` | The post. `slug` is computed from `title`. `status` flows `draft → review → published → archived` (any state can return to `draft`). `heroImage` is a public file ≤5MB. `publishedAt` is set on the client side when transitioning to `published` (see the example below). |
|
|
10
|
+
| `category` | Taxonomy. `slug` is computed from `name`. `name` is unique. Relates back to articles via `category.articles` (hasMany). |
|
|
11
|
+
|
|
12
|
+
## Aggregations
|
|
13
|
+
|
|
14
|
+
- `article.byStatus` — count per status (draft / review / published / archived). Cached 60s.
|
|
15
|
+
- `article.byCategory` — published articles grouped by `categoryId`. Cached 60s.
|
|
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 category
|
|
25
|
+
CAT=$(curl -s -X POST http://localhost:5050/api/v1/category \
|
|
26
|
+
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
|
27
|
+
-d '{"name":"Engineering","description":"Behind the scenes"}' | jq -r ._id)
|
|
28
|
+
|
|
29
|
+
# Create an article — initial status = "draft"
|
|
30
|
+
A=$(curl -s -X POST http://localhost:5050/api/v1/article \
|
|
31
|
+
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
|
32
|
+
-d "{\"title\":\"How we ship\",\"body\":\"Once a week.\",\"categoryId\":\"$CAT\",\"tags\":[\"process\"]}")
|
|
33
|
+
echo "$A" | jq '{_id, slug, status, availableTransitions}'
|
|
34
|
+
|
|
35
|
+
ID=$(echo "$A" | jq -r ._id)
|
|
36
|
+
|
|
37
|
+
# draft → review
|
|
38
|
+
curl -s -X PUT "http://localhost:5050/api/v1/article/$ID" \
|
|
39
|
+
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
|
40
|
+
-d '{"status":"review"}'
|
|
41
|
+
|
|
42
|
+
# review → published, stamping publishedAt at the same time
|
|
43
|
+
curl -s -X PUT "http://localhost:5050/api/v1/article/$ID" \
|
|
44
|
+
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
|
45
|
+
-d "{\"status\":\"published\",\"publishedAt\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}"
|
|
46
|
+
|
|
47
|
+
curl "http://localhost:5050/api/v1/article/$ID" \
|
|
48
|
+
-H "Authorization: Bearer $TOKEN" | jq '{title, slug, status, publishedAt}'
|
|
49
|
+
|
|
50
|
+
# Upload a hero image
|
|
51
|
+
curl -X POST "http://localhost:5050/api/v1/article/$ID/heroImage" \
|
|
52
|
+
-H "Authorization: Bearer $TOKEN" \
|
|
53
|
+
-F "file=@./hero.jpg"
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## With Claude Code
|
|
57
|
+
|
|
58
|
+
> Add a `readingTimeMinutes` computed field to article: estimated reading time based on `body` length at 200 words per minute.
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
module.exports = {
|
|
2
|
+
path: 'article',
|
|
3
|
+
collection: 'article',
|
|
4
|
+
fields: [
|
|
5
|
+
{ name: 'userId', type: String, required: true },
|
|
6
|
+
{ name: 'title', type: String, required: true, searchable: true, searchWeight: 5 },
|
|
7
|
+
{
|
|
8
|
+
name: 'slug',
|
|
9
|
+
type: String,
|
|
10
|
+
computed: (r) =>
|
|
11
|
+
String(r.title || '')
|
|
12
|
+
.toLowerCase()
|
|
13
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
14
|
+
.replace(/^-|-$/g, ''),
|
|
15
|
+
description: 'URL-safe form of title, derived.',
|
|
16
|
+
},
|
|
17
|
+
{ name: 'body', type: String, searchable: true },
|
|
18
|
+
{ name: 'excerpt', type: String },
|
|
19
|
+
{ name: 'categoryId', type: String },
|
|
20
|
+
{ name: 'tags', type: [String] },
|
|
21
|
+
{ name: 'authorName', type: String },
|
|
22
|
+
{ name: 'publishedAt', type: Date },
|
|
23
|
+
{
|
|
24
|
+
name: 'heroImage',
|
|
25
|
+
type: 'File',
|
|
26
|
+
file: {
|
|
27
|
+
maxBytes: 5 * 1024 * 1024,
|
|
28
|
+
accept: ['image/jpeg', 'image/png', 'image/webp'],
|
|
29
|
+
access: 'public',
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: 'status',
|
|
34
|
+
type: String,
|
|
35
|
+
stateMachine: {
|
|
36
|
+
initial: 'draft',
|
|
37
|
+
states: ['draft', 'review', 'published', 'archived'],
|
|
38
|
+
transitions: {
|
|
39
|
+
draft: ['review', 'archived'],
|
|
40
|
+
review: ['published', 'draft'],
|
|
41
|
+
published: ['archived', 'draft'],
|
|
42
|
+
archived: ['draft'],
|
|
43
|
+
},
|
|
44
|
+
// onEnter hooks run best-effort with `(record, { user, from,
|
|
45
|
+
// to })`. They're side-effect channels — notifications,
|
|
46
|
+
// webhooks, downstream events — not write-back. To stamp
|
|
47
|
+
// `publishedAt` automatically, send it on the PUT that
|
|
48
|
+
// transitions to `published` (the framework's audit log
|
|
49
|
+
// captures the timestamp regardless).
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
relations: {
|
|
54
|
+
category: { belongsTo: 'category', localKey: 'categoryId' },
|
|
55
|
+
},
|
|
56
|
+
aggregations: [
|
|
57
|
+
{
|
|
58
|
+
name: 'byStatus',
|
|
59
|
+
description: 'Article count grouped by current status.',
|
|
60
|
+
pipeline: [
|
|
61
|
+
{ $group: { _id: '$status', count: { $sum: 1 } } },
|
|
62
|
+
{ $sort: { count: -1 } },
|
|
63
|
+
],
|
|
64
|
+
cache: { ttlSeconds: 60 },
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
name: 'byCategory',
|
|
68
|
+
description: 'Published article count grouped by categoryId.',
|
|
69
|
+
pipeline: [
|
|
70
|
+
{ $match: { status: 'published' } },
|
|
71
|
+
{ $group: { _id: '$categoryId', count: { $sum: 1 } } },
|
|
72
|
+
{ $sort: { count: -1 } },
|
|
73
|
+
],
|
|
74
|
+
cache: { ttlSeconds: 60 },
|
|
75
|
+
},
|
|
76
|
+
],
|
|
77
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
module.exports = {
|
|
2
|
+
path: 'category',
|
|
3
|
+
collection: 'category',
|
|
4
|
+
fields: [
|
|
5
|
+
{ name: 'userId', type: String, required: true },
|
|
6
|
+
// Per-tenant unique (see compositeIndex below) — NOT globally
|
|
7
|
+
// unique. Two different users can each have a "Engineering"
|
|
8
|
+
// category without colliding.
|
|
9
|
+
{ name: 'name', type: String, required: true, searchable: true },
|
|
10
|
+
{
|
|
11
|
+
name: 'slug',
|
|
12
|
+
type: String,
|
|
13
|
+
computed: (r) =>
|
|
14
|
+
String(r.name || '')
|
|
15
|
+
.toLowerCase()
|
|
16
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
17
|
+
.replace(/^-|-$/g, ''),
|
|
18
|
+
description: 'URL-safe form of name, derived.',
|
|
19
|
+
},
|
|
20
|
+
{ name: 'description', type: String, searchable: true },
|
|
21
|
+
],
|
|
22
|
+
// Tenant-scoped uniqueness on `name`. dAvePi scopes every read /
|
|
23
|
+
// write by userId, so this index enforces "no duplicate name
|
|
24
|
+
// within one user's categories" without blocking other users
|
|
25
|
+
// from using the same string.
|
|
26
|
+
compositeIndex: [{ userId: 1, name: 1 }],
|
|
27
|
+
relations: {
|
|
28
|
+
articles: { hasMany: 'article', foreignKey: 'categoryId' },
|
|
29
|
+
},
|
|
30
|
+
};
|