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,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
+ };