create-substrat 0.0.0 → 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/LICENSE +661 -0
- package/README.md +2 -2
- package/index.js +209 -32
- package/package.json +5 -4
- package/template/.claude/skills/substrat/SKILL.md +13 -0
- package/template/.cursor/commands/new-vertical.md +7 -0
- package/template/.cursor/rules/substrat.mdc +11 -0
- package/template/.opencode/command/new-vertical.md +9 -0
- package/template/.substrat/playbook.md +310 -0
- package/template/AGENTS.md +116 -0
- package/template/CLAUDE.md +8 -0
- package/template/src/manifest.ts +48 -0
- package/template/src/migrations.ts +39 -0
- package/template/src/module.ts +297 -0
- package/template/src/seed.ts +265 -0
- package/template/src/server.ts +145 -0
- package/template/test/scenario.test.ts +249 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# Building on Substrat — agent instructions
|
|
2
|
+
|
|
3
|
+
This project is a **Substrat vertical**: a multi-tenant business app built on the
|
|
4
|
+
Substrat kernel and its engines. This file is the always-on constitution — the rules
|
|
5
|
+
that hold no matter what you touch. It is read by every AI tool (Claude Code, Cursor,
|
|
6
|
+
opencode); do not duplicate it into tool-specific config.
|
|
7
|
+
|
|
8
|
+
The full build flow — interview, coverage map, scaffold, run, checkpoints — is a
|
|
9
|
+
**playbook**, not always-on context. Invoke it when you start or extend a vertical:
|
|
10
|
+
|
|
11
|
+
- **Claude Code**: `/substrat`
|
|
12
|
+
- **Cursor / opencode**: the `new-vertical` command, or read [`.substrat/playbook.md`](.substrat/playbook.md)
|
|
13
|
+
|
|
14
|
+
Read the playbook before scaffolding. This file is what a session already mid-build
|
|
15
|
+
must never violate.
|
|
16
|
+
|
|
17
|
+
## The mental model
|
|
18
|
+
|
|
19
|
+
Three layers. You only own the third.
|
|
20
|
+
|
|
21
|
+
1. **Kernel — free, always.** Tenancy (one scope = one isolated database; there is no
|
|
22
|
+
cross-tenant API), permissions (roles, grants, and a proof path for every decision),
|
|
23
|
+
events + audit (every mutation emits a kernel-stamped event you cannot mislabel),
|
|
24
|
+
migrations (journaled per module, applied lazily per scope).
|
|
25
|
+
2. **Engines — compose or feed.** Headless, own invariants that cannot be violated
|
|
26
|
+
(state machines that can't skip states, append-only entries). You either **compose**
|
|
27
|
+
an engine (import it; its in-scope functions run in *your* transaction) or **feed** it
|
|
28
|
+
(emit a fat event; it consumes — no import). Engines never import each other. Read an
|
|
29
|
+
engine's real surface from `node_modules/@substrat-run/engine-*/dist/index.d.ts` —
|
|
30
|
+
never guess at it.
|
|
31
|
+
3. **Your vertical — everything a user touches.** Vocabulary, price list, extra fields,
|
|
32
|
+
roles, screens. If your core noun isn't something an engine already owns, this is most
|
|
33
|
+
of the app — a normal, supported outcome.
|
|
34
|
+
|
|
35
|
+
## Project layout
|
|
36
|
+
|
|
37
|
+
The linter and tests expect this shape. `manifest`/`migrations`/`module` are **module
|
|
38
|
+
code** (the rules below bind them); `seed`/`server` are **harness** (exempt).
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
src/manifest.ts moduleManifest.parse({…}) + PERM consts ← module code
|
|
42
|
+
src/migrations.ts the SqlMigration[] ← module code
|
|
43
|
+
src/module.ts imports both; operations + registration ← module code
|
|
44
|
+
src/seed.ts host, tenants, roles, grants, seed world ← harness
|
|
45
|
+
src/server.ts thin wrapper, one route per operation ← harness
|
|
46
|
+
test/scenario.test.ts the scenario — including the denials
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## The rules (non-negotiable)
|
|
50
|
+
|
|
51
|
+
**Module code** = everything reachable from a `ModuleRegistration` (operations,
|
|
52
|
+
consumers). Rules 1–4 are enforced mechanically by `boundary-lint`.
|
|
53
|
+
|
|
54
|
+
1. **Data access is `ctx.sql` only.** Never import `better-sqlite3`, an adapter, or
|
|
55
|
+
`node:*` in module code.
|
|
56
|
+
2. **No `fetch` / network in module code.** It would hold the scope's transaction open on
|
|
57
|
+
a third party. The sanctioned path is a **connector**: emit a fat event, register a
|
|
58
|
+
handler that runs outside the transaction. An integration is never impossible because
|
|
59
|
+
of this rule — it has an answer.
|
|
60
|
+
3. **Never write `_substrat_*` tables.** Reads are fine (timelines are projections);
|
|
61
|
+
writes forge the audit spine.
|
|
62
|
+
4. **Another module's tables are private.** Never `SELECT` from `workorder_*` etc. — use
|
|
63
|
+
the engine's exported in-scope functions. This is the rule with no runtime equivalent:
|
|
64
|
+
the shortcut *works* and silently welds you to an engine's private schema forever. Need
|
|
65
|
+
extra data on an engine entity? Add **your own side table keyed by the engine's id** —
|
|
66
|
+
never a column upstream.
|
|
67
|
+
5. **Every operation checks a permission first.** `assertAllowed(await ctx.check(PERM))`
|
|
68
|
+
is the first line.
|
|
69
|
+
6. **Every mutation emits a fat event** — a consumer must never need a cross-module read.
|
|
70
|
+
7. **Never fork an engine.** Extend by composition. If you must fork, the engine drew its
|
|
71
|
+
line wrong — that's design feedback, not a coding problem.
|
|
72
|
+
8. **IDs are `ulid()`. Money is strings** via `@substrat-run/contracts` helpers
|
|
73
|
+
(`moneyOf`, `mulMoney`, `addDecimal`, `compareDecimal`) — never floats.
|
|
74
|
+
9. **Web-standard APIs always** — `globalThis.crypto`, `TextEncoder`, `URL`. Never
|
|
75
|
+
hand-roll a hash to dodge an import ban.
|
|
76
|
+
10. **Parse, don't trust.** Zod at every boundary — but import `z` from
|
|
77
|
+
`@substrat-run/contracts`, **never from `zod`**. Zod schemas don't compose across
|
|
78
|
+
copies or majors; composing a contracts schema into one built from a separate `zod`
|
|
79
|
+
fails at *runtime* (`expected a Zod schema`) with an error pointing nowhere near the
|
|
80
|
+
cause.
|
|
81
|
+
|
|
82
|
+
## Declare every link edge
|
|
83
|
+
|
|
84
|
+
`entityRelations` in the manifest must declare every edge you traverse — both your own
|
|
85
|
+
(`bike → customer`) and the ones an engine makes on your behalf (`workorder → bike`). The
|
|
86
|
+
adapter **rejects** a `ctx.link` for an undeclared edge, so a missing one fails loudly.
|
|
87
|
+
This is also what lets a portal permission-walk reach the owner.
|
|
88
|
+
|
|
89
|
+
## The gates — run them, believe them
|
|
90
|
+
|
|
91
|
+
```sh
|
|
92
|
+
npm test # the scenario, including the denials
|
|
93
|
+
npx @substrat-run/boundary-lint # the layer rules (1–4)
|
|
94
|
+
npm run typecheck
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
`boundary-lint` exits non-zero if it *couldn't do its job* (no module code found, no
|
|
98
|
+
engines resolvable) — a pass that checked nothing is worse than no linter. Never wave that
|
|
99
|
+
through; fix the setup until it can see your code.
|
|
100
|
+
|
|
101
|
+
A green scenario test does **not** mean the app works: the test calls operations directly
|
|
102
|
+
and never exercises `server.ts`, its routes, or the principal picker. Before calling a
|
|
103
|
+
vertical done, boot the server and drive the real flow over HTTP as two personas — one who
|
|
104
|
+
should succeed and one who should be denied — and confirm the denial arrives as a denial
|
|
105
|
+
(not a generic error).
|
|
106
|
+
|
|
107
|
+
## Two human checkpoints — you may never self-approve
|
|
108
|
+
|
|
109
|
+
Present these and stop:
|
|
110
|
+
|
|
111
|
+
1. **Migration diff** — every new `SqlMigration`, verbatim. Migrations are append-only
|
|
112
|
+
forever once shipped, so this is the last cheap moment to change your mind.
|
|
113
|
+
2. **Permission diff** — a table: key → description → which roles hold it → why. Walk the
|
|
114
|
+
reviewer through it in their own vocabulary until they can answer *who can now see the
|
|
115
|
+
money, and who can see other tenants' data?* A permission diff nobody understands is
|
|
116
|
+
theater — it reproduces the exact failure Substrat exists to prevent.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
@AGENTS.md
|
|
2
|
+
|
|
3
|
+
<!--
|
|
4
|
+
Claude Code reads CLAUDE.md, not AGENTS.md. This one-line @-import pulls the shared
|
|
5
|
+
constitution in verbatim, so there is a single source of truth every tool reads.
|
|
6
|
+
Add Claude-only notes below this line if you ever need them; keep the shared rules in
|
|
7
|
+
AGENTS.md so Cursor and opencode see them too.
|
|
8
|
+
-->
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { moduleManifest, permissionKey } from '@substrat-run/contracts';
|
|
2
|
+
|
|
3
|
+
// ============================================================================
|
|
4
|
+
// The vertical's MANIFEST — the reviewable contract the kernel reads at
|
|
5
|
+
// registration. A minimal bike-repair shop composed onto two engines:
|
|
6
|
+
//
|
|
7
|
+
// engine-workorder — the repair's state machine and append-only lines
|
|
8
|
+
// engine-invoicing — turns a completed repair into an invoice basis, by EVENT
|
|
9
|
+
//
|
|
10
|
+
// This vertical owns only vocabulary (customers, bikes, a price list) and the
|
|
11
|
+
// PRICING MOMENT. Every invariant that matters — a repair can't skip states,
|
|
12
|
+
// a completed repair is immutable, every mutation emits an event — lives in the
|
|
13
|
+
// engine. Read CLAUDE.md / AGENTS.md before you touch it.
|
|
14
|
+
// ============================================================================
|
|
15
|
+
|
|
16
|
+
/** The vertical's own permission keys (the engines declare their own). */
|
|
17
|
+
export const SHOP_PERM = {
|
|
18
|
+
customerManage: permissionKey.parse('customer:manage'),
|
|
19
|
+
bikeManage: permissionKey.parse('bike:manage'),
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const bikeShopManifest = moduleManifest.parse({
|
|
23
|
+
id: 'bikeshop',
|
|
24
|
+
version: '0.0.1',
|
|
25
|
+
kernelContract: '^0.0.1',
|
|
26
|
+
permissions: [
|
|
27
|
+
{ key: 'customer:manage', description: 'Manage customers and the workshop price list' },
|
|
28
|
+
{ key: 'bike:manage', description: "Register and manage customers' bikes" },
|
|
29
|
+
],
|
|
30
|
+
// The vertical emits and consumes no events of its own: the invoicing engine
|
|
31
|
+
// consumes the WORKORDER engine's `workorder.completed` directly (star
|
|
32
|
+
// topology — engines cooperate by event, never by import).
|
|
33
|
+
events: { emits: [], consumes: [] },
|
|
34
|
+
migrations: { journalDir: './migrations', compatibleFrom: '0.0.1' },
|
|
35
|
+
attachmentTargets: [
|
|
36
|
+
{ entityType: 'customer', readPermission: 'customer:manage' },
|
|
37
|
+
{ entityType: 'bike', readPermission: 'bike:manage' },
|
|
38
|
+
],
|
|
39
|
+
// The portal permission walk is workorder → bike → customer. The engine links
|
|
40
|
+
// workorder → <facility ref>; in this vertical that facility ref IS a bike, so
|
|
41
|
+
// the vertical declares BOTH of its own edges. An entity-narrowed
|
|
42
|
+
// `workorder:read` grant on a customer then resolves all the way up.
|
|
43
|
+
entityRelations: [
|
|
44
|
+
{ entityType: 'bike', parentType: 'customer' },
|
|
45
|
+
{ entityType: 'workorder', parentType: 'bike' },
|
|
46
|
+
],
|
|
47
|
+
entitlementKey: 'bikeshop',
|
|
48
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { SqlMigration } from '@substrat-run/kernel';
|
|
2
|
+
|
|
3
|
+
// ============================================================================
|
|
4
|
+
// The vertical's OWN tables, prefixed `shop_` so they can never collide with an
|
|
5
|
+
// engine's. Ids are TEXT (ULIDs), timestamps ISO-8601 TEXT, money/decimals TEXT
|
|
6
|
+
// — never a float, never a native DATE. Migrations are append-only and ordered:
|
|
7
|
+
// once a version has shipped, you add a new one, you never edit it.
|
|
8
|
+
// ============================================================================
|
|
9
|
+
|
|
10
|
+
export const bikeShopMigrations: SqlMigration[] = [
|
|
11
|
+
{
|
|
12
|
+
version: '0001-init',
|
|
13
|
+
sql: `
|
|
14
|
+
CREATE TABLE shop_customers (
|
|
15
|
+
id TEXT PRIMARY KEY,
|
|
16
|
+
number TEXT NOT NULL UNIQUE,
|
|
17
|
+
name TEXT NOT NULL,
|
|
18
|
+
phone TEXT,
|
|
19
|
+
created_at TEXT NOT NULL
|
|
20
|
+
);
|
|
21
|
+
CREATE TABLE shop_bikes (
|
|
22
|
+
id TEXT PRIMARY KEY,
|
|
23
|
+
customer_id TEXT NOT NULL REFERENCES shop_customers(id),
|
|
24
|
+
label TEXT NOT NULL,
|
|
25
|
+
frame_no TEXT,
|
|
26
|
+
created_at TEXT NOT NULL
|
|
27
|
+
);
|
|
28
|
+
CREATE TABLE shop_price_list (
|
|
29
|
+
article TEXT PRIMARY KEY,
|
|
30
|
+
description TEXT NOT NULL,
|
|
31
|
+
unit TEXT NOT NULL,
|
|
32
|
+
price_amount TEXT NOT NULL,
|
|
33
|
+
currency TEXT NOT NULL DEFAULT 'SEK',
|
|
34
|
+
min_qty TEXT,
|
|
35
|
+
internal INTEGER NOT NULL DEFAULT 0
|
|
36
|
+
);
|
|
37
|
+
`,
|
|
38
|
+
},
|
|
39
|
+
];
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import {
|
|
2
|
+
addDecimal,
|
|
3
|
+
compareDecimal,
|
|
4
|
+
moneyOf,
|
|
5
|
+
mulMoney,
|
|
6
|
+
z,
|
|
7
|
+
type EntityRef,
|
|
8
|
+
type Money,
|
|
9
|
+
} from '@substrat-run/contracts';
|
|
10
|
+
import {
|
|
11
|
+
assertAllowed,
|
|
12
|
+
ulid,
|
|
13
|
+
type ModuleRegistration,
|
|
14
|
+
type OperationHandler,
|
|
15
|
+
} from '@substrat-run/kernel';
|
|
16
|
+
import {
|
|
17
|
+
closeWorkOrder,
|
|
18
|
+
completeWorkOrder,
|
|
19
|
+
createWorkOrder,
|
|
20
|
+
getReportedLines,
|
|
21
|
+
listOrders,
|
|
22
|
+
PERM as WO,
|
|
23
|
+
type BillableLine,
|
|
24
|
+
type WorkOrder,
|
|
25
|
+
} from '@substrat-run/engine-workorder';
|
|
26
|
+
import { bikeShopManifest, SHOP_PERM } from './manifest.js';
|
|
27
|
+
import { bikeShopMigrations } from './migrations.js';
|
|
28
|
+
|
|
29
|
+
// ============================================================================
|
|
30
|
+
// The bike-shop operations. Each is either:
|
|
31
|
+
// - a thin custodian of the vertical's own tables (customers, bikes, prices),
|
|
32
|
+
// OR
|
|
33
|
+
// - a COMPOSITION that wraps an engine's in-scope function inside the same
|
|
34
|
+
// transaction and adds the vertical's policy (the pricing moment).
|
|
35
|
+
//
|
|
36
|
+
// Every operation's FIRST line is the permission check. Data access is
|
|
37
|
+
// `ctx.sql` only. No `fetch`, no `node:*`, no other engine's tables.
|
|
38
|
+
// ============================================================================
|
|
39
|
+
|
|
40
|
+
export interface CustomerRow {
|
|
41
|
+
id: string;
|
|
42
|
+
number: string;
|
|
43
|
+
name: string;
|
|
44
|
+
phone: string | null;
|
|
45
|
+
created_at: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface BikeRow {
|
|
49
|
+
id: string;
|
|
50
|
+
customer_id: string;
|
|
51
|
+
label: string;
|
|
52
|
+
frame_no: string | null;
|
|
53
|
+
created_at: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface PriceRow {
|
|
57
|
+
article: string;
|
|
58
|
+
description: string;
|
|
59
|
+
unit: string;
|
|
60
|
+
price_amount: string;
|
|
61
|
+
currency: string;
|
|
62
|
+
min_qty: string | null;
|
|
63
|
+
internal: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const createCustomerOp: OperationHandler<
|
|
67
|
+
{ number: string; name: string; phone?: string },
|
|
68
|
+
CustomerRow
|
|
69
|
+
> = async (ctx, input) => {
|
|
70
|
+
assertAllowed(await ctx.check(SHOP_PERM.customerManage));
|
|
71
|
+
const id = ulid();
|
|
72
|
+
ctx.sql.exec(
|
|
73
|
+
`INSERT INTO shop_customers (id, number, name, phone, created_at) VALUES (?, ?, ?, ?, ?)`,
|
|
74
|
+
[id, input.number, input.name, input.phone ?? null, new Date().toISOString()],
|
|
75
|
+
);
|
|
76
|
+
return ctx.sql.query<CustomerRow>('SELECT * FROM shop_customers WHERE id = ?', [id])[0]!;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const listCustomersOp: OperationHandler<undefined, (CustomerRow & { bikes: BikeRow[] })[]> = async (
|
|
80
|
+
ctx,
|
|
81
|
+
) => {
|
|
82
|
+
assertAllowed(await ctx.check(SHOP_PERM.customerManage));
|
|
83
|
+
const customers = ctx.sql.query<CustomerRow>('SELECT * FROM shop_customers ORDER BY number');
|
|
84
|
+
return customers.map((c) => ({
|
|
85
|
+
...c,
|
|
86
|
+
bikes: ctx.sql.query<BikeRow>('SELECT * FROM shop_bikes WHERE customer_id = ? ORDER BY label', [
|
|
87
|
+
c.id,
|
|
88
|
+
]),
|
|
89
|
+
}));
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const registerBikeOp: OperationHandler<
|
|
93
|
+
{ customerId: string; label: string; frameNo?: string },
|
|
94
|
+
BikeRow
|
|
95
|
+
> = async (ctx, input) => {
|
|
96
|
+
assertAllowed(await ctx.check(SHOP_PERM.bikeManage));
|
|
97
|
+
const customer = ctx.sql.query<CustomerRow>('SELECT * FROM shop_customers WHERE id = ?', [
|
|
98
|
+
input.customerId,
|
|
99
|
+
])[0];
|
|
100
|
+
if (!customer) throw new Error(`customer not found: ${input.customerId}`);
|
|
101
|
+
const id = ulid();
|
|
102
|
+
ctx.sql.exec(
|
|
103
|
+
`INSERT INTO shop_bikes (id, customer_id, label, frame_no, created_at) VALUES (?, ?, ?, ?, ?)`,
|
|
104
|
+
[id, customer.id, input.label, input.frameNo ?? null, new Date().toISOString()],
|
|
105
|
+
);
|
|
106
|
+
// Record the bike → customer edge the manifest declared, so the portal walk
|
|
107
|
+
// (workorder → bike → customer) can resolve an entity-narrowed grant.
|
|
108
|
+
ctx.link({ entityType: 'bike', entityId: id }, { entityType: 'customer', entityId: customer.id });
|
|
109
|
+
return ctx.sql.query<BikeRow>('SELECT * FROM shop_bikes WHERE id = ?', [id])[0]!;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const upsertPriceOp: OperationHandler<
|
|
113
|
+
{
|
|
114
|
+
article: string;
|
|
115
|
+
description: string;
|
|
116
|
+
unit: string;
|
|
117
|
+
priceAmount: string;
|
|
118
|
+
currency?: string;
|
|
119
|
+
minQty?: string;
|
|
120
|
+
internal?: boolean;
|
|
121
|
+
},
|
|
122
|
+
PriceRow
|
|
123
|
+
> = async (ctx, input) => {
|
|
124
|
+
assertAllowed(await ctx.check(SHOP_PERM.customerManage));
|
|
125
|
+
ctx.sql.exec(
|
|
126
|
+
`INSERT OR REPLACE INTO shop_price_list
|
|
127
|
+
(article, description, unit, price_amount, currency, min_qty, internal)
|
|
128
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
129
|
+
[
|
|
130
|
+
input.article,
|
|
131
|
+
input.description,
|
|
132
|
+
input.unit,
|
|
133
|
+
input.priceAmount,
|
|
134
|
+
input.currency ?? 'SEK',
|
|
135
|
+
input.minQty ?? null,
|
|
136
|
+
input.internal ? 1 : 0,
|
|
137
|
+
],
|
|
138
|
+
);
|
|
139
|
+
return ctx.sql.query<PriceRow>('SELECT * FROM shop_price_list WHERE article = ?', [
|
|
140
|
+
input.article,
|
|
141
|
+
])[0]!;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const priceListOp: OperationHandler<undefined, PriceRow[]> = async (ctx) => {
|
|
145
|
+
assertAllowed(await ctx.check(SHOP_PERM.customerManage));
|
|
146
|
+
return ctx.sql.query<PriceRow>('SELECT * FROM shop_price_list ORDER BY article');
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Open a repair: a vertical operation that composes the engine's `createWorkOrder`.
|
|
151
|
+
* The vertical resolves its own vocabulary (a bike, its owner) into the engine's
|
|
152
|
+
* `facility`/`customer` refs; the engine owns the number, the state, the event.
|
|
153
|
+
*/
|
|
154
|
+
const createRepairOp: OperationHandler<
|
|
155
|
+
{ bikeId: string; kind: string; title: string; description?: string },
|
|
156
|
+
WorkOrder
|
|
157
|
+
> = async (ctx, input) => {
|
|
158
|
+
assertAllowed(await ctx.check(WO.create));
|
|
159
|
+
const bike = ctx.sql.query<BikeRow>('SELECT * FROM shop_bikes WHERE id = ?', [input.bikeId])[0];
|
|
160
|
+
if (!bike) throw new Error(`bike not found: ${input.bikeId}`);
|
|
161
|
+
return createWorkOrder(ctx, {
|
|
162
|
+
facility: { entityType: 'bike', entityId: bike.id },
|
|
163
|
+
customer: { entityType: 'customer', entityId: bike.customer_id },
|
|
164
|
+
kind: input.kind,
|
|
165
|
+
title: input.title,
|
|
166
|
+
...(input.description !== undefined ? { description: input.description } : {}),
|
|
167
|
+
});
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* THE PRICING MOMENT — the whole reason a vertical exists. Read the engine's
|
|
172
|
+
* reported time and material, price them against the vertical's OWN price list
|
|
173
|
+
* (labor bills at least its minimum quantity; internal articles are dropped),
|
|
174
|
+
* then hand the priced lines back to the engine's `completeWorkOrder`. One
|
|
175
|
+
* transaction: the engine's invariant stays intact and pricing is 100% vertical.
|
|
176
|
+
* The engine's `workorder.completed` event carries these lines, and the
|
|
177
|
+
* invoicing engine consumes it — no import between the two.
|
|
178
|
+
*/
|
|
179
|
+
const completeRepairOp: OperationHandler<
|
|
180
|
+
{ orderId: string },
|
|
181
|
+
{ order: WorkOrder; billable: BillableLine[]; total: Money }
|
|
182
|
+
> = async (ctx, input) => {
|
|
183
|
+
assertAllowed(await ctx.check(WO.complete));
|
|
184
|
+
const reported = getReportedLines(ctx, input.orderId);
|
|
185
|
+
const prices = new Map<string, PriceRow>(
|
|
186
|
+
ctx.sql.query<PriceRow>('SELECT * FROM shop_price_list').map((p) => [p.article, p]),
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
const billable: BillableLine[] = [];
|
|
190
|
+
|
|
191
|
+
// Labor: sum reported hours, then bill at least the minimum quantity.
|
|
192
|
+
const laborPrice = prices.get('labor');
|
|
193
|
+
const reportedHours = reported.time.reduce((sum, t) => addDecimal(sum, t.hours), '0');
|
|
194
|
+
if (laborPrice && compareDecimal(reportedHours, '0') > 0) {
|
|
195
|
+
const minQty = laborPrice.min_qty ?? '0';
|
|
196
|
+
const qty = compareDecimal(reportedHours, minQty) >= 0 ? reportedHours : minQty;
|
|
197
|
+
const unitPrice = moneyOf(laborPrice.price_amount, laborPrice.currency);
|
|
198
|
+
billable.push({
|
|
199
|
+
article: 'labor',
|
|
200
|
+
description: laborPrice.description,
|
|
201
|
+
qty,
|
|
202
|
+
unit: laborPrice.unit,
|
|
203
|
+
unitPrice,
|
|
204
|
+
lineTotal: mulMoney(qty, unitPrice),
|
|
205
|
+
sourceType: 'time',
|
|
206
|
+
sourceId: input.orderId,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Parts: one billable line per reported material; internal articles dropped.
|
|
211
|
+
for (const m of reported.material) {
|
|
212
|
+
const price = prices.get(m.article);
|
|
213
|
+
if (!price) throw new Error(`no price for article: ${m.article}`);
|
|
214
|
+
if (price.internal) continue;
|
|
215
|
+
const unitPrice = moneyOf(price.price_amount, price.currency);
|
|
216
|
+
billable.push({
|
|
217
|
+
article: m.article,
|
|
218
|
+
description: price.description,
|
|
219
|
+
qty: m.qty,
|
|
220
|
+
unit: price.unit,
|
|
221
|
+
unitPrice,
|
|
222
|
+
lineTotal: mulMoney(m.qty, unitPrice),
|
|
223
|
+
sourceType: 'material',
|
|
224
|
+
sourceId: m.id,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const result = completeWorkOrder(ctx, { orderId: input.orderId, billable });
|
|
229
|
+
return { order: result.order, billable, total: result.total };
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Pickup: hand the bike back (completed → closed). A thin composition of the
|
|
234
|
+
* engine's in-scope `closeWorkOrder`; the vertical owns the vocabulary
|
|
235
|
+
* ("pickup"), the engine owns the transition.
|
|
236
|
+
*/
|
|
237
|
+
const closeRepairOp: OperationHandler<{ orderId: string }, WorkOrder> = async (ctx, input) => {
|
|
238
|
+
assertAllowed(await ctx.check(WO.close));
|
|
239
|
+
return closeWorkOrder(ctx, { orderId: input.orderId });
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* The customer PORTAL listing. A proof walk: no blanket `workorder:read`, one
|
|
244
|
+
* PER-ENTITY check per repair. A portal customer holds an entity-narrowed grant
|
|
245
|
+
* on their own customer record, so the walk workorder → bike → customer lets
|
|
246
|
+
* them through for their own repairs and no one else's.
|
|
247
|
+
*/
|
|
248
|
+
const portalRepairsOp: OperationHandler<undefined, WorkOrder[]> = async (ctx) => {
|
|
249
|
+
const visible: WorkOrder[] = [];
|
|
250
|
+
for (const order of listOrders(ctx)) {
|
|
251
|
+
const decision = await ctx.check(WO.read, { entityType: 'workorder', entityId: order.id });
|
|
252
|
+
if (decision.allowed) visible.push(order);
|
|
253
|
+
}
|
|
254
|
+
return visible;
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const timelineInput = z.object({
|
|
258
|
+
entityType: z.string().min(1),
|
|
259
|
+
entityId: z.string().min(1),
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* An entity's event timeline, read straight off the spine (a read of `_substrat_*`
|
|
264
|
+
* for a projection is allowed; writing it is not). Gated by a per-entity
|
|
265
|
+
* `workorder:read` check, so it obeys the same walk as the portal.
|
|
266
|
+
*/
|
|
267
|
+
const timelineOp: OperationHandler<
|
|
268
|
+
z.infer<typeof timelineInput>,
|
|
269
|
+
{ type: string; occurred_at: string; actor: string }[]
|
|
270
|
+
> = async (ctx, rawInput) => {
|
|
271
|
+
const entity: EntityRef = timelineInput.parse(rawInput);
|
|
272
|
+
assertAllowed(await ctx.check(WO.read, entity));
|
|
273
|
+
// Append order is authoritative — rowid, not ULID (ids minted in the same
|
|
274
|
+
// millisecond are not mutually ordered).
|
|
275
|
+
return ctx.sql.query(
|
|
276
|
+
`SELECT type, occurred_at, actor FROM _substrat_outbox
|
|
277
|
+
WHERE entity_type = ? AND entity_id = ? ORDER BY rowid`,
|
|
278
|
+
[entity.entityType, entity.entityId],
|
|
279
|
+
);
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
export const bikeShopModule: ModuleRegistration = {
|
|
283
|
+
manifest: bikeShopManifest,
|
|
284
|
+
migrations: bikeShopMigrations,
|
|
285
|
+
operations: {
|
|
286
|
+
'shop/create-customer': createCustomerOp as never,
|
|
287
|
+
'shop/list-customers': listCustomersOp as never,
|
|
288
|
+
'shop/register-bike': registerBikeOp as never,
|
|
289
|
+
'shop/upsert-price': upsertPriceOp as never,
|
|
290
|
+
'shop/price-list': priceListOp as never,
|
|
291
|
+
'shop/create-repair': createRepairOp as never,
|
|
292
|
+
'shop/complete-repair': completeRepairOp as never,
|
|
293
|
+
'shop/close-repair': closeRepairOp as never,
|
|
294
|
+
'shop/portal-repairs': portalRepairsOp as never,
|
|
295
|
+
'shop/timeline': timelineOp as never,
|
|
296
|
+
},
|
|
297
|
+
};
|