ConusAI vendor v0.1.0-beta.114
ci / gate (push) Waiting to run

This commit is contained in:
ConusAI vendor v0.1.0-beta.114
2026-11-14 09:56:45 +00:00
commit 1ce8d23c35
867 changed files with 122104 additions and 0 deletions
+106
View File
@@ -0,0 +1,106 @@
// Simulated adapter dispatch; these fixtures do not prove gateway or partition isolation.
// The module on its own, wired the way apps/web wires a request -- tenancy on the in-memory data
// source, the inventory module, and the Inventory port bound to the real generated client over the
// business-services fake of @conusai-kit/testing -- so its procedures are tested whether or not this
// app turned the module on.
import { businessClient } from "@conusai-kit/business"
import { type Actor, buildRegistry, Container, createCaller, type TransportCaller } from "@conusai-kit/core"
import { createMemoryDataSource, DATA_SOURCE, entitiesOf, SYSTEM_DATA } from "@conusai-kit/data"
import { createTenancyHooks, TENANCY, tenancyModule, users } from "@conusai-kit/tenancy"
import { type FakeBusinessServices, fakeBusinessServices } from "@conusai-kit/testing"
import { createClient } from "@conusai-sdk/inventory/client"
import { simulatedBusinessAdmission } from "../../../test/helpers/business-admission.js"
import { sdkInventoryService } from "../adapters/service.js"
import { INVENTORY_SERVICE } from "../core/ports.js"
import { inventoryModule } from "../module.js"
/** The platform project of the fake deployment. */
export const BINDING_ID = "01900000-0000-7000-8000-000000000001"
export interface Tenant {
readonly owner: Actor
readonly call: TransportCaller
readonly orgSlug: string
readonly orgId: string
readonly workspaceSlug: string
readonly workspaceId: string
readonly projectSlug: string
readonly projectId: string
readonly slugs: { org: string; workspace: string; project: string }
}
export function inventoryApp(options: Parameters<typeof fakeBusinessServices>[0] = {}) {
const fake: FakeBusinessServices = fakeBusinessServices(options)
const client = businessClient({ apiUrl: fake.apiUrl, token: fake.token, orgSlug: fake.orgSlug }, createClient)
client.setConfig({ fetch: fake.fetch as typeof fetch })
const registry = buildRegistry([tenancyModule, inventoryModule])
const entities = entitiesOf(registry, "postgres")
const data = createMemoryDataSource({ entities })
const root = new Container()
root.value(DATA_SOURCE, data)
root.value(SYSTEM_DATA, data)
root.value(INVENTORY_SERVICE, sdkInventoryService({ client, orgSlug: fake.orgSlug, bindingId: BINDING_ID }))
const caller = (actor: Actor): TransportCaller => {
const tenancy = createTenancyHooks({ data, registry })
const container = root.child()
container.value(TENANCY, tenancy)
return createCaller({ registry, container, tenancy, serviceReadiness: simulatedBusinessAdmission }, { actor })
}
const signUp = async (email: string): Promise<Actor> => {
const user = await data.global(users).insert({
issuer: "https://id.example.test",
subject: email,
email,
displayName: email.split("@")[0] ?? email,
locale: "en",
})
return { userId: user.id }
}
/** An organization with one workspace and one project, owned by a new user. */
const tenant = async (slug: string): Promise<Tenant> => {
const owner = await signUp(`owner@${slug}.test`)
const call = caller(owner)
const org = (await call.callByName("tenancy.org.create", { slug, name: slug })) as { orgId: string; slug: string }
const workspace = (await call.callByName("tenancy.workspace.create", {
org: slug,
slug: `ws-${slug}`,
name: `Workspace ${slug}`,
visibility: "org",
})) as { workspaceId: string; slug: string }
const project = (await call.callByName("tenancy.project.create", {
org: slug,
workspace: workspace.slug,
slug: `p-${slug}`,
name: `Project ${slug}`,
moduleKey: "inventory",
})) as { projectId: string; slug: string }
return {
owner,
call,
orgSlug: org.slug,
orgId: org.orgId,
workspaceSlug: workspace.slug,
workspaceId: workspace.workspaceId,
projectSlug: project.slug,
projectId: project.projectId,
slugs: { org: org.slug, workspace: workspace.slug, project: project.slug },
}
}
/** A second user who joins `t`'s organization with the org role `role`. */
const member = async (t: Tenant, email: string, role: "viewer" | "member"): Promise<TransportCaller> => {
const invite = (await t.call.callByName("tenancy.member.invite", { org: t.orgSlug, email, role })) as {
token: string
}
const actor = await signUp(email)
const call = caller(actor)
await call.callByName("tenancy.invitation.accept", { org: t.orgSlug, token: invite.token })
return call
}
return { fake, registry, data, caller, signUp, tenant, member }
}
+166
View File
@@ -0,0 +1,166 @@
// The inventory use cases over a recording port: what each asks the service, with no client and no
// network.
import type { ProjectScope } from "@conusai-kit/core"
import { describe, expect, it } from "vitest"
import type { ReservationView } from "../contract/index.js"
import {
createItem,
listItems,
listProjectReservations,
projectReference,
releaseStock,
requestKey,
reserveStock,
} from "../core/inventory.js"
import { type InventoryService, inventoryReadsOf } from "../core/ports.js"
const scope: ProjectScope = {
level: "project",
actor: { userId: "u1" },
orgId: "o1",
workspaceId: "w1",
projectId: "01900000-0000-7000-8000-000000000003",
}
const reservation: ReservationView = {
reservationId: "r1",
itemId: "i1",
locationId: "l1",
quantity: 2,
outstanding: 2,
released: 0,
issued: 0,
status: "open",
createdAt: "2026-11-02T00:00:00.000Z",
}
function recording(
reference = projectReference("01900000-0000-7000-8000-000000000001", "01900000-0000-7000-8000-000000000003"),
) {
const asked: Array<[string, unknown]> = []
const service: InventoryService = {
bindingId: "01900000-0000-7000-8000-000000000001",
listItems: async (query) => {
asked.push(["listItems", query])
return { rows: [] }
},
getItem: async () => {
throw new Error("not used")
},
availability: async () => {
throw new Error("not used")
},
listReservations: async (query) => {
asked.push(["listReservations", query])
return { rows: [] }
},
getReservation: async (id) => {
asked.push(["getReservation", id])
return { reservation, reference }
},
createItem: async (input) => {
asked.push(["createItem", input])
return { itemId: "i1", description: null, active: true, ...input }
},
reserve: async (input) => {
asked.push(["reserve", input])
return reservation
},
release: async (input) => {
asked.push(["release", input])
return { ...reservation, outstanding: 0, released: 2, status: "closed" }
},
}
return { service, asked }
}
describe("the project reference", () => {
it("names the immutable app binding and resolved project, in the service's reference grammar", () => {
const reference = projectReference("01900000-0000-7000-8000-000000000001", "0b5c8e9e-1f2a-4c55-9a77-3f1e2d4c5b6a")
expect(reference).toBe(
"conusai://app-partitions/01900000-0000-7000-8000-000000000001/projects/0b5c8e9e-1f2a-4c55-9a77-3f1e2d4c5b6a",
)
expect(reference).toMatch(/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,199}$/)
})
it("keys a request by the project, so one project's request id never answers another's", () => {
expect(requestKey({ projectId: "01900000-0000-7000-8000-000000000003" }, "r")).not.toBe(
requestKey({ projectId: "01900000-0000-7000-8000-000000000004" }, "r"),
)
})
})
describe("the inventory use cases", () => {
it("list items with a trimmed search and the module's page size", async () => {
const { service, asked } = recording()
await listItems(service, { q: " bolt " })
await listItems(service, { q: " ", after: "x", limit: 5 })
expect(asked).toEqual([
["listItems", { q: "bolt", limit: 50 }],
["listItems", { after: "x", limit: 5 }],
])
})
it("create an item without an empty description", async () => {
const { service, asked } = recording()
await createItem(service, { sku: "B", name: " Bolt ", unit: "pcs", description: " " })
expect(asked).toEqual([["createItem", { sku: "B", name: "Bolt", unit: "pcs" }]])
})
it("list and reserve against the project's reference with the project's request key", async () => {
const { service, asked } = recording()
await listProjectReservations(service, scope, { status: "open" })
await reserveStock(service, scope, { itemId: "i1", locationId: "l1", quantity: 2, requestId: "q1" })
expect(asked).toEqual([
[
"listReservations",
{
reference:
"conusai://app-partitions/01900000-0000-7000-8000-000000000001/projects/01900000-0000-7000-8000-000000000003",
status: "open",
limit: 50,
},
],
[
"reserve",
{
itemId: "i1",
locationId: "l1",
quantity: 2,
reference:
"conusai://app-partitions/01900000-0000-7000-8000-000000000001/projects/01900000-0000-7000-8000-000000000003",
key: requestKey(scope, "q1"),
},
],
])
})
it("release a reservation of the project, and answer another project's as not found without releasing it", async () => {
const mine = recording()
expect(await releaseStock(mine.service, scope, { reservationId: "r1", requestId: "q2" })).toMatchObject({
status: "closed",
})
expect(mine.asked.at(-1)).toEqual(["release", { reservationId: "r1", key: requestKey(scope, "q2") }])
const theirs = recording(
projectReference("01900000-0000-7000-8000-000000000001", "01900000-0000-7000-8000-000000000004"),
)
await expect(releaseStock(theirs.service, scope, { reservationId: "r1", requestId: "q3" })).rejects.toMatchObject({
problem: { status: 404 },
})
expect(theirs.asked.map(([method]) => method)).toEqual(["getReservation"])
})
it("hand a read handler a view without a single write", () => {
const view = inventoryReadsOf(recording().service) as unknown as Record<string, unknown>
expect(Object.keys(view).sort()).toEqual([
"availability",
"bindingId",
"getItem",
"getReservation",
"listItems",
"listReservations",
])
})
})
+80
View File
@@ -0,0 +1,80 @@
// Every string a page of the module shows is a message in English and in Lithuanian, the Lithuanian
// plural forms are real ones, and no component of the module carries a string of its own.
import { readdirSync, readFileSync } from "node:fs"
import { join } from "node:path"
import { buildRegistry } from "@conusai-kit/core"
import { createI18n, lintUiStrings } from "@conusai-kit/i18n"
import { tenancyModule } from "@conusai-kit/tenancy"
import { describe, expect, it } from "vitest"
import { inventoryModule } from "../module.js"
import { itemFormLabels, itemListLabels, reservationListLabels, reserveFormLabels, stockLabels } from "../ui/labels.js"
// The messages as an app that turned the module on has them: from the manifest, flattened by the kit.
const catalogues = buildRegistry([tenancyModule, inventoryModule]).catalogues
const en = catalogues.en?.inventory ?? {}
const lt = catalogues.lt?.inventory ?? {}
function keysUsed(): string[] {
const asked: string[] = []
const t = (key: string) => {
asked.push(key)
return key
}
itemFormLabels(t, {})
itemListLabels(t)
stockLabels(t, "pcs", 2)
reserveFormLabels(t, {})
reservationListLabels(t, {})
return asked
}
describe("the inventory catalogues", () => {
it("hold the same keys in English and in Lithuanian, none empty", () => {
expect(Object.keys(lt).sort()).toEqual(Object.keys(en).sort())
for (const [key, text] of [...Object.entries(en), ...Object.entries(lt)]) expect(text.trim(), key).not.toBe("")
})
it("hold every message the labels ask for, and none that nothing asks for", () => {
const asked = keysUsed()
for (const key of asked) {
expect(en[key as keyof typeof en], `en is missing inventory.${key}`).toBeTruthy()
expect(lt[key as keyof typeof lt], `lt is missing inventory.${key}`).toBeTruthy()
}
// The page and the navigation ask for these directly.
const direct = new Set(["title", "intro", "items.title", "item.back"])
expect(Object.keys(en).filter((key) => !asked.includes(key) && !direct.has(key))).toEqual([])
})
it("answer the navigation label the manifest names", () => {
expect(inventoryModule.ui?.nav?.map((item) => item.label)).toEqual(["inventory.title"])
})
it("count locations with the Lithuanian one, few and other forms", () => {
const count = (locale: "en" | "lt", n: number) =>
createI18n(locale, { en: { inventory: en }, lt: { inventory: lt } }).message("inventory.item.locations", {
count: n,
})
expect(count("lt", 1)).toBe("1 vieta su atsargomis")
expect(count("lt", 3)).toBe("3 vietos su atsargomis")
expect(count("lt", 10)).toBe("10 vietų su atsargomis")
expect(count("lt", 21)).toBe("21 vieta su atsargomis")
expect(count("en", 1)).toBe("1 location with stock")
expect(count("en", 2)).toBe("2 locations with stock")
})
})
describe("the components", () => {
const dir = new URL("../ui/", import.meta.url).pathname
const sources = readdirSync(dir)
.filter((name) => name.endsWith(".tsx"))
.map((name) => ({ name, text: readFileSync(join(dir, name), "utf8") }))
it("carry no hard-coded user-facing string and no colour literal", () => {
expect(sources.length).toBeGreaterThanOrEqual(5)
for (const { name, text } of sources) {
expect(lintUiStrings(text, name), name).toEqual([])
expect(text, name).not.toMatch(/#[0-9a-fA-F]{3,8}\b|\brgb\(|\bhsl\(|\boklch\(/)
}
})
})
+39
View File
@@ -0,0 +1,39 @@
// What the generated tenant-isolation suite (test/isolation.test.ts) needs of this module, from this
// folder: one fixture per tenant-scoped procedure, each called as a member of one organization with
// another's slugs, which the resolver must refuse before the Inventory service is reached.
//
// The module keeps no row of its own, so it seeds nothing: what a fixture names is the other tenant's
// project, and an id is any well-formed one (the service is never asked). The suite reads this file by
// its place whether or not the module is on, and checks only the procedures the app registers, so
// while the module is off (the template's default) it brings no fixture; `procedures.test.ts` runs the
// same fixtures against the module on its own, on or off.
import type { TransportCaller } from "@conusai-kit/core"
import type { IsolationFixture, IsolationTenant } from "@conusai-kit/testing"
import { businessModulesOn } from "../../index.js"
const SOMEWHERE = "00000000-0000-4000-8000-000000000001"
const inProjectOf = (t: IsolationTenant) => ({ org: t.orgSlug, workspace: t.workspaceSlug, project: t.projectSlug })
export const inventoryIsolationFixtures: Record<string, IsolationFixture> = {
"inventory.item.list": { input: (t) => inProjectOf(t) },
"inventory.item.create": { input: (t) => ({ ...inProjectOf(t), sku: "INTRUDER-1", name: "Intruder", unit: "pcs" }) },
"inventory.item.availability": { input: (t) => ({ ...inProjectOf(t), itemId: SOMEWHERE }) },
"inventory.reservation.list": { input: (t) => inProjectOf(t) },
"inventory.stock.reserve": {
input: (t) => ({ ...inProjectOf(t), itemId: SOMEWHERE, locationId: SOMEWHERE, quantity: 1, requestId: SOMEWHERE }),
},
"inventory.stock.release": { input: (t) => ({ ...inProjectOf(t), reservationId: SOMEWHERE, requestId: SOMEWHERE }) },
}
/** The shape the suite reads (`modules/notes/__tests__/isolation.ts` names it), so no other module is imported. */
export const isolation: {
seed(
call: TransportCaller,
inProject: { org: string; workspace: string; project: string },
): Promise<Record<string, string>>
fixtures: Record<string, IsolationFixture>
} = {
seed: async () => ({}),
fixtures: businessModulesOn.includes("inventory") ? inventoryIsolationFixtures : {},
}
@@ -0,0 +1,240 @@
// The inventory procedures end to end: the caller, the tenancy resolver, the module and the real
// generated client against the business-services fake -- what a member sees, what the service gets,
// and each refusal as the kit's app error (`businessAppError`) a page or a form shows.
import { businessClient } from "@conusai-kit/business"
import type { AppError } from "@conusai-kit/core"
import { entitiesOf } from "@conusai-kit/data"
import { TENANCY_ALSO_CHECK, tenancyIsolationExempt, tenancyIsolationFixtures } from "@conusai-kit/tenancy/testing"
import { type IsolationTenant, runIsolation, snapshotOf } from "@conusai-kit/testing"
import { inventoryCreateLocation, inventoryRecordMovement } from "@conusai-sdk/inventory"
import { createClient } from "@conusai-sdk/inventory/client"
import { describe, expect, it } from "vitest"
import { projectReference } from "../core/inventory.js"
import { BINDING_ID, inventoryApp } from "./app.js"
import { inventoryIsolationFixtures } from "./isolation.js"
const refusal = async (promise: Promise<unknown>): Promise<AppError> => {
try {
await promise
} catch (error) {
return error as AppError
}
throw new Error("the call was not refused")
}
const bolt = { sku: "BOLT-8", name: "Bolt M8", unit: "pcs" }
/** Stock as the service receives it: a new location, then a receipt into it; answers the location's id. */
const receive = async (app: ReturnType<typeof inventoryApp>, itemId: string, quantity: number): Promise<string> => {
const client = businessClient(
{ apiUrl: app.fake.apiUrl, token: app.fake.token, orgSlug: app.fake.orgSlug },
createClient,
)
client.setConfig({ fetch: app.fake.fetch as typeof fetch })
const path = { org_slug: app.fake.orgSlug }
const code = `STORE-${app.fake.inventory.locations.size + 1}`
const location = await inventoryCreateLocation({ client, path, body: { code, name: "Store" } })
const locationId = location.data?.id as string
const receipt = await inventoryRecordMovement({
client,
path,
body: {
kind: "receipt",
item_id: itemId,
quantity: BigInt(quantity),
to_location_id: locationId,
idempotency_key: `receipt:${locationId}`,
},
})
expect(receipt.response?.status).toBe(201)
return locationId
}
describe("the inventory procedures", () => {
it("create an item, list it, and read its stock", async () => {
const app = inventoryApp()
const t = await app.tenant("alpha")
const created = (await t.call.callByName("inventory.item.create", { ...t.slugs, ...bolt })) as { itemId: string }
expect(created).toMatchObject({ sku: "BOLT-8", name: "Bolt M8", unit: "pcs", active: true, description: null })
const listed = (await t.call.callByName("inventory.item.list", t.slugs)) as {
rows: { itemId: string }[]
after?: string
}
expect(listed.rows.map((row) => row.itemId)).toEqual([created.itemId])
expect(listed.after).toBeUndefined()
await receive(app, created.itemId, 12)
const stock = (await t.call.callByName("inventory.item.availability", { ...t.slugs, itemId: created.itemId })) as {
availability: { onHand: number; available: number; locations: unknown[] }
}
expect(stock.availability).toMatchObject({ onHand: 12, available: 12 })
expect(stock.availability.locations).toHaveLength(1)
})
it("reserve against the project's reference, list the project's reservations, and release them", async () => {
const app = inventoryApp()
const t = await app.tenant("alpha")
const { itemId } = (await t.call.callByName("inventory.item.create", { ...t.slugs, ...bolt })) as { itemId: string }
const locationId = await receive(app, itemId, 10)
const requestId = crypto.randomUUID()
const reserve = { ...t.slugs, itemId, locationId, quantity: 3, requestId }
const reserved = (await t.call.callByName("inventory.stock.reserve", reserve)) as { reservationId: string }
expect(reserved).toMatchObject({ quantity: 3, outstanding: 3, status: "open" })
// A retried request reserves once.
expect(await t.call.callByName("inventory.stock.reserve", reserve)).toEqual(reserved)
expect(app.fake.inventory.reservations.size).toBe(1)
const held = [...app.fake.inventory.reservations.values()][0]
expect(held?.reference).toBe(projectReference(BINDING_ID, t.projectId))
expect(held?.reference).toBe(`conusai://app-partitions/${BINDING_ID}/projects/${t.projectId}`)
const listed = (await t.call.callByName("inventory.reservation.list", { ...t.slugs, status: "open" })) as {
rows: unknown[]
}
expect(listed.rows).toEqual([reserved])
const released = await t.call.callByName("inventory.stock.release", {
...t.slugs,
reservationId: reserved.reservationId,
requestId: crypto.randomUUID(),
})
expect(released).toMatchObject({ outstanding: 0, released: 3, status: "closed" })
})
it("keep one project's reservations from another project of the same organization", async () => {
const app = inventoryApp()
const t = await app.tenant("alpha")
const other = (await t.call.callByName("tenancy.project.create", {
org: t.orgSlug,
workspace: t.workspaceSlug,
slug: "other",
name: "Other",
moduleKey: "inventory",
})) as { slug: string }
const otherSlugs = { ...t.slugs, project: other.slug }
const { itemId } = (await t.call.callByName("inventory.item.create", { ...t.slugs, ...bolt })) as { itemId: string }
const locationId = await receive(app, itemId, 10)
const mine = (await t.call.callByName("inventory.stock.reserve", {
...t.slugs,
itemId,
locationId,
quantity: 1,
requestId: crypto.randomUUID(),
})) as { reservationId: string }
expect(((await t.call.callByName("inventory.reservation.list", otherSlugs)) as { rows: unknown[] }).rows).toEqual(
[],
)
const refused = await refusal(
t.call.callByName("inventory.stock.release", {
...otherSlugs,
reservationId: mine.reservationId,
requestId: crypto.randomUUID(),
}),
)
expect(refused.problem).toMatchObject({ status: 404, code: "not_found" })
expect(app.fake.inventory.reservations.get(mine.reservationId)?.outstanding).toBe(1)
})
it("answer the service's refusals with the kit's codes, carrying none of the service's detail", async () => {
const app = inventoryApp()
const t = await app.tenant("alpha")
await t.call.callByName("inventory.item.create", { ...t.slugs, ...bolt })
const taken = await refusal(t.call.callByName("inventory.item.create", { ...t.slugs, ...bolt }))
expect(taken.problem).toMatchObject({ status: 409, code: "business_duplicate", title: "This already exists." })
expect(taken.problem.detail).toBeUndefined()
const { itemId } = (await t.call.callByName("inventory.item.create", { ...t.slugs, ...bolt, sku: "NUT-8" })) as {
itemId: string
}
const locationId = await receive(app, itemId, 1)
const short = await refusal(
t.call.callByName("inventory.stock.reserve", {
...t.slugs,
itemId,
locationId,
quantity: 2,
requestId: crypto.randomUUID(),
}),
)
expect(short.problem).toMatchObject({ status: 409, code: "business_insufficient_stock" })
})
it("answer a disabled service, a missing scope and a silent service with the kit's codes", async () => {
const app = inventoryApp({ inventory: false })
const t = await app.tenant("alpha")
const disabled = await refusal(t.call.callByName("inventory.item.list", t.slugs))
expect(disabled.problem).toMatchObject({ status: 404, code: "service_not_enabled" })
app.fake.enable("inventory")
app.fake.revoke("inventory.items:write")
const scoped = await refusal(t.call.callByName("inventory.item.create", { ...t.slugs, ...bolt }))
expect(scoped.problem).toMatchObject({ status: 403, code: "service_permission_missing" })
expect(scoped.problem.detail).not.toContain("inventory.items:write")
app.fake.refuseNext("network")
const silent = await refusal(t.call.callByName("inventory.item.list", t.slugs))
expect(silent.problem).toMatchObject({ status: 503, code: "business_unavailable" })
expect(JSON.stringify([disabled.problem, scoped.problem, silent.problem])).not.toContain(app.fake.token)
})
it("let a viewer read and refuse their write before the service is called", async () => {
const app = inventoryApp()
const t = await app.tenant("alpha")
const viewer = await app.member(t, "viewer@alpha.test", "viewer")
expect(await viewer.callByName("inventory.item.list", t.slugs)).toEqual({ rows: [] })
const before = app.fake.calls.length
// A member without the permission is answered as a non-member is (kit-core's caller, spec §3.3).
const refused = await refusal(viewer.callByName("inventory.item.create", { ...t.slugs, ...bolt }))
expect(refused.problem.status).toBe(404)
const reserve = await refusal(
viewer.callByName("inventory.stock.reserve", {
...t.slugs,
itemId: crypto.randomUUID(),
locationId: crypto.randomUUID(),
quantity: 1,
requestId: crypto.randomUUID(),
}),
)
expect(reserve.problem.status).toBe(404)
expect(app.fake.calls.length).toBe(before)
})
it("refuse a body outside the contract before the service is called", async () => {
const app = inventoryApp()
const t = await app.tenant("alpha")
const refused = await refusal(t.call.callByName("inventory.item.create", { ...t.slugs, ...bolt, unit: "Pieces" }))
expect(refused.problem.status).toBe(422)
expect(app.fake.calls).toEqual([])
})
})
describe("the inventory module's tenant isolation", () => {
it("refuses every procedure called with another organization's slugs, and never reaches the service", async () => {
const app = inventoryApp()
const tenant = async (slug: string): Promise<IsolationTenant> => {
const t = await app.tenant(slug)
return {
orgSlug: t.orgSlug,
workspaceSlug: t.workspaceSlug,
projectSlug: t.projectSlug,
orgId: t.orgId,
workspaceId: t.workspaceId,
projectId: t.projectId,
userId: t.owner.userId,
email: `owner@${slug}.test`,
extra: {},
}
}
const a = await tenant("alpha")
const b = await tenant("bravo")
const result = await runIsolation({
registry: app.registry,
seed: async () => ({ a, b }),
caller: (actor) => app.caller(actor),
snapshot: () => snapshotOf(app.data, entitiesOf(app.registry, "postgres"), { outbox: app.data.outbox }),
fixtures: { ...tenancyIsolationFixtures(), ...inventoryIsolationFixtures },
alsoCheck: [...TENANCY_ALSO_CHECK],
exempt: tenancyIsolationExempt,
})
expect(result.failures).toEqual([])
for (const name of Object.keys(inventoryIsolationFixtures)) expect(result.passed, name).toContain(name)
expect(app.fake.calls).toEqual([])
})
})
@@ -0,0 +1,36 @@
// Prepared request binding is required; injected test ports preserve kind capabilities.
import { AppError, Container } from "@conusai-kit/core"
import { describe, expect, it } from "vitest"
import { inventoryServiceOf } from "../adapters/service.js"
import { INVENTORY_SERVICE, type InventoryService } from "../core/ports.js"
const service = {
bindingId: "01900000-0000-7000-8000-000000000001",
createItem: async () => {
throw new Error("not called")
},
} as unknown as InventoryService
describe("inventoryServiceOf", () => {
it("hands a read handler the bound service's reads only, and an effect handler all of it", () => {
const root = new Container()
root.value(INVENTORY_SERVICE, service)
const read = inventoryServiceOf(root.restrict("read"), "read") as unknown as Record<string, unknown>
expect(read.createItem).toBeUndefined()
expect(read.bindingId).toBe("01900000-0000-7000-8000-000000000001")
expect(inventoryServiceOf(root.restrict("effect"), "effect").createItem).toBe(service.createItem)
})
it("refuses a request without a prepared partition binding", () => {
let thrown: unknown
try {
inventoryServiceOf(new Container(), "read")
} catch (error) {
thrown = error
}
expect(thrown).toBeInstanceOf(AppError)
expect((thrown as AppError).problem).toMatchObject({ status: 409, code: "service_partition_missing" })
expect((thrown as AppError).problem.detail).not.toContain("token")
})
})
+203
View File
@@ -0,0 +1,203 @@
// The pages and components, rendered on the server the way the shell renders them: the platform's
// refusals are the page's own states, every control has a name, every table a caption and scoped
// headers.
import { AppError, buildRegistry, Container, type TransportCaller } from "@conusai-kit/core"
import { builtInCatalogues, createI18n, uiLabels } from "@conusai-kit/i18n"
import { tenancyModule } from "@conusai-kit/tenancy"
import { createDataClient, DataProvider, type PermissionKey, PermissionsProvider } from "@conusai-kit/ui/data"
import { UiLabelsProvider } from "@conusai-kit/ui/primitives"
import type { ReactNode } from "react"
import { renderToStaticMarkup as renderMarkup } from "react-dom/server"
import { describe, expect, it, vi } from "vitest"
import { modulePages } from "../../pages.js"
import { inventoryModule } from "../module.js"
import { inventoryPage } from "../ui/pages.js"
const catalogues = buildRegistry([tenancyModule, inventoryModule]).catalogues
const i18n = createI18n("en", {
en: { inventory: catalogues.en?.inventory ?? {}, app: { failed: "That did not work: {detail}" } },
})
const slugs = { org: "acme", workspace: "ws", project: "p" }
const ITEM = "0b5c8e9e-1f2a-4c55-9a77-3f1e2d4c5b6a"
const LOCATION = "7d1e2f3a-0000-4000-8000-000000000001"
const EVERYTHING: readonly PermissionKey[] = ["inventory.item.read", "inventory.item.write", "inventory.stock.reserve"]
/** Rendered as the shell renders a page: a data client, and the caller's permissions in the page's scope. */
const kitStrings = uiLabels(createI18n("en", builtInCatalogues))
const renderToStaticMarkup = (node: React.ReactNode) =>
renderMarkup(<UiLabelsProvider labels={kitStrings}>{node}</UiLabelsProvider>)
const inProvider = (node: ReactNode, permissions: readonly PermissionKey[] = EVERYTHING) =>
renderToStaticMarkup(
<DataProvider client={createDataClient({ fetch: async () => new Response(null, { status: 501 }) })}>
<PermissionsProvider scope={slugs} seed={{ scope: slugs, permissions, revision: "1" }}>
{node}
</PermissionsProvider>
</DataProvider>,
)
/** A caller that answers each procedure from `answers`, or throws what is there. */
const callerOf = (answers: Record<string, unknown>): TransportCaller =>
({
call: async (contract: { name: string }) => {
const answer = answers[contract.name]
if (answer instanceof Error) throw answer
return answer
},
}) as unknown as TransportCaller
const load = (segments: string[], answers: Record<string, unknown>, reader = i18n) =>
inventoryPage.load({
slugs,
segments,
search: {},
caller: callerOf(answers),
i18n: reader,
container: new Container(),
})
/** The page as the shell renders it: the loader's data through JSON, as the browser receives it, then the view. */
const render = async (segments: string[], answers: Record<string, unknown>, permissions?: readonly PermissionKey[]) => {
const data = await load(segments, answers)
expect(JSON.parse(JSON.stringify(data))).toEqual(data)
const { View } = inventoryPage
return inProvider(
<View slugs={slugs} segments={segments} locale="en" data={JSON.parse(JSON.stringify(data))} />,
permissions,
)
}
const platformRefusal = (status: number, code: string, detail: string) =>
new AppError({ type: `/problems/${code}`, title: "Refused", status, code, detail })
describe("the inventory page", () => {
it("lists the items and this project's reservations", async () => {
const html = await render([], {
"inventory.item.list": {
rows: [{ itemId: ITEM, sku: "BOLT-8", name: "Bolt M8", unit: "pcs", description: null, active: true }],
},
"inventory.reservation.list": {
rows: [
{
reservationId: "r1",
itemId: ITEM,
locationId: LOCATION,
quantity: 3,
outstanding: 3,
released: 0,
issued: 0,
status: "open",
createdAt: "2026-11-02T00:00:00.000Z",
},
],
},
})
expect(html).toContain(">Inventory</h1>")
const link = html.match(/<a\b[^>]*>BOLT-8<\/a>/)?.[0] ?? ""
expect(link).toContain('data-slot="row-link"')
expect(link.match(/class="([^"]*)"/)?.[1]?.split(/\s+/)).toContain("kit-row-link")
expect(link).toContain(`href="/acme/ws/p/inventory/${ITEM}"`)
expect(link).toContain('aria-label="Stock of BOLT-8"')
expect(html).toContain('aria-label="Items"')
expect(html).toContain('aria-label="Open reservations of this project"')
expect(html).toContain("BOLT-8 Bolt M8")
expect(html).toContain("SKU")
expect(html).toContain('role="search"')
})
it("shows an item's stock by location, and the form that reserves it for whoever may", async () => {
const answers = {
"inventory.item.availability": {
item: { itemId: ITEM, sku: "BOLT-8", name: "Bolt M8", unit: "pcs", description: null, active: true },
availability: {
itemId: ITEM,
unit: "pcs",
onHand: 10,
reserved: 3,
available: 7,
locations: [{ locationId: LOCATION, onHand: 10, reserved: 3, available: 7 }],
},
},
}
const html = await render([ITEM], answers)
expect(html).toContain("Counted in pcs")
expect(html).toContain("1 location with stock")
expect(html).toContain('aria-label="Stock by location"')
expect(html).toContain("7d1e2f3a")
expect(html).toContain("Reserve for this project")
expect(html).toContain("7d1e2f3a (7)")
const reader = await render([ITEM], answers, ["inventory.item.read"])
expect(reader).not.toContain("Reserve for this project")
})
it("offers the item form only to a member who may write items", async () => {
const answers = { "inventory.item.list": { rows: [] }, "inventory.reservation.list": { rows: [] } }
expect(await render([], answers)).toContain("New item")
const reader = await render([], answers, ["inventory.item.read"])
expect(reader).not.toContain("New item")
expect(reader).toContain("No items yet.")
})
it("shows the kit's not-enabled state when the organization has not enabled the service", async () => {
const refused = platformRefusal(404, "service_not_enabled", "Service admission refused: not_enabled.")
const html = await render([], { "inventory.item.list": refused, "inventory.reservation.list": refused })
expect(html).toContain('data-state="service_not_enabled"')
expect(html).toContain("This service is not enabled for your organization.")
expect(html).not.toContain('role="grid"')
})
it("tells a Lithuanian reader in the kit's Lithuanian sentence", async () => {
const lt = createI18n("lt", { lt: { inventory: catalogues.lt?.inventory ?? {} } })
const refused = platformRefusal(404, "service_not_enabled", "Service admission refused: not_enabled.")
const data = await load([], { "inventory.item.list": refused, "inventory.reservation.list": refused }, lt)
expect(data).toMatchObject({
kind: "state",
notice: { code: "service_not_enabled", text: "Ši paslauga jūsų organizacijai neįjungta." },
})
const answers = { "inventory.item.list": { rows: [] }, "inventory.reservation.list": { rows: [] } }
const list = await load([], answers, lt)
expect(list.kind === "list" && list.itemForm.refusals.business_duplicate).toBe("Toks įrašas jau yra.")
expect(list.kind === "list" && list.reservationList.refusals.business_reservation_state).toBe(
"Šio rezervavimo dabartinėje būsenoje pakeisti negalima.",
)
})
it("shows a missing permission and a silent service in the kit's words", async () => {
const scoped = platformRefusal(403, "service_permission_missing", "Service admission refused: permission_missing.")
expect(await render([ITEM], { "inventory.item.availability": scoped })).toContain(
"This app is not allowed to use this service. Ask an administrator to grant it.",
)
const silent = platformRefusal(503, "business_unavailable", "The service did not answer. Try again later.")
const html = await render([ITEM], { "inventory.item.availability": silent })
expect(html).toContain('data-state="business_unavailable"')
expect(html).toContain("The service did not answer. Try again later.")
})
it("leaves any other refusal, and a path it does not have, to the shell", async () => {
await expect(
render([ITEM], {
"inventory.item.availability": new AppError({
type: "/problems/not_found",
title: "Not found",
status: 404,
code: "not_found",
}),
}),
).rejects.toMatchObject({
problem: { code: "not_found" },
})
await expect(render(["not-an-id"], {})).rejects.toMatchObject({ problem: { status: 404 } })
await expect(render([ITEM, "more"], {})).rejects.toMatchObject({ problem: { status: 404 } })
})
it("is listed for the shell", async () => {
await expect(modulePages.inventory?.()).resolves.toBe(inventoryPage)
})
})
// Explicit synthetic admission: module-local fixtures exercise the selected module.
vi.mock("../../inclusion.json", () => ({
default: { schemaVersion: 1, selected: ["inventory"], everAdopted: ["inventory"] },
}))
+79
View File
@@ -0,0 +1,79 @@
// The handlers of the module's procedures: each takes the service its kind may use and calls one use
// case. No rule lives here.
import { implement } from "@conusai-kit/core"
import {
itemAvailability,
itemCreate,
itemList,
reservationList,
stockRelease,
stockReserve,
} from "../contract/index.js"
import {
itemAvailability as availabilityOf,
createItem,
listItems,
listProjectReservations,
releaseStock,
reserveStock,
} from "../core/inventory.js"
import { inventoryServiceOf } from "./service.js"
const cursorOf = (input: { after?: string | undefined; limit?: number | undefined }) => ({
...(input.after === undefined ? {} : { after: input.after }),
...(input.limit === undefined ? {} : { limit: input.limit }),
})
export const itemListImpl = implement(itemList, ({ input, ctx }) =>
listItems(inventoryServiceOf(ctx.container, ctx.kind), {
...(input.q === undefined ? {} : { q: input.q }),
...cursorOf(input),
}),
)
export const itemCreateImpl = implement(itemCreate, ({ input, ctx }) =>
createItem(inventoryServiceOf(ctx.container, "effect"), {
sku: input.sku,
name: input.name,
unit: input.unit,
...(input.description === undefined ? {} : { description: input.description }),
}),
)
export const itemAvailabilityImpl = implement(itemAvailability, ({ input, ctx }) =>
availabilityOf(inventoryServiceOf(ctx.container, ctx.kind), input.itemId),
)
export const reservationListImpl = implement(reservationList, ({ input, ctx }) =>
listProjectReservations(inventoryServiceOf(ctx.container, ctx.kind), ctx.scope, {
...(input.status === undefined ? {} : { status: input.status }),
...cursorOf(input),
}),
)
export const stockReserveImpl = implement(stockReserve, ({ input, ctx }) =>
reserveStock(inventoryServiceOf(ctx.container, "effect"), ctx.scope, {
itemId: input.itemId,
locationId: input.locationId,
quantity: input.quantity,
requestId: input.requestId,
}),
)
export const stockReleaseImpl = implement(stockRelease, ({ input, ctx }) =>
releaseStock(inventoryServiceOf(ctx.container, "effect"), ctx.scope, {
reservationId: input.reservationId,
...(input.quantity === undefined ? {} : { quantity: input.quantity }),
requestId: input.requestId,
}),
)
export const INVENTORY_PROCEDURES = [
itemListImpl,
itemCreateImpl,
itemAvailabilityImpl,
reservationListImpl,
stockReserveImpl,
stockReleaseImpl,
]
+170
View File
@@ -0,0 +1,170 @@
// Generated SDK adapter. Production dispatch requires a server-prepared request binding.
import { businessAppError, type Client, requestBusinessBinding, toBusinessError } from "@conusai-kit/business"
import type { Container, ProcedureKind } from "@conusai-kit/core"
import {
type Availability,
type Item,
inventoryCreateItem,
inventoryCreateReservation,
inventoryGetItem,
inventoryGetItemAvailability,
inventoryGetReservation,
inventoryListItems,
inventoryListReservations,
inventoryReleaseReservation,
type Reservation,
} from "@conusai-sdk/inventory"
import { createClient } from "@conusai-sdk/inventory/client"
import type { AvailabilityView, ItemView, ReservationView } from "../contract/index.js"
import { INVENTORY_SERVICE, type InventoryReads, type InventoryService, inventoryReadsOf } from "../core/ports.js"
/**
* An int64 the service answered, as the exact number a view carries over JSON. The generated client
* types every int64 as `bigint` (a service may send any i64); a quantity beyond 2^53 cannot be shown
* exactly, so it is refused rather than rounded.
*/
function exact(value: bigint, field: string): number {
if (value > BigInt(Number.MAX_SAFE_INTEGER) || value < BigInt(Number.MIN_SAFE_INTEGER))
throw new RangeError(`${field} is outside the range a number holds exactly`)
return Number(value)
}
const itemView = (item: Item): ItemView => ({
itemId: item.id,
sku: item.sku,
name: item.name,
unit: item.unit,
description: item.description ?? null,
active: item.active,
})
const availabilityView = (availability: Availability): AvailabilityView => ({
itemId: availability.item_id,
unit: availability.unit,
onHand: exact(availability.on_hand, "on_hand"),
reserved: exact(availability.reserved, "reserved"),
available: exact(availability.available, "available"),
locations: availability.locations.map((level) => ({
locationId: level.location_id,
onHand: exact(level.on_hand, "on_hand"),
reserved: exact(level.reserved, "reserved"),
available: exact(level.available, "available"),
})),
})
const reservationView = (reservation: Reservation): ReservationView => ({
reservationId: reservation.id,
itemId: reservation.item_id,
locationId: reservation.location_id,
quantity: exact(reservation.quantity, "quantity"),
outstanding: exact(reservation.outstanding, "outstanding"),
released: exact(reservation.released, "released"),
issued: exact(reservation.issued, "issued"),
status: reservation.status,
createdAt: reservation.created_at,
})
/** What a generated operation answered: its data, or the kit's app error for the refusal (`businessAppError`). */
async function answer<T>(
call: Promise<{ data?: T; error?: unknown; response?: Response }>,
): Promise<Exclude<T, undefined>> {
let result: { data?: T; error?: unknown; response?: Response }
try {
result = await call
} catch (error) {
throw businessAppError(toBusinessError(error, undefined))
}
if (result.error !== undefined || result.data === undefined) {
throw businessAppError(toBusinessError(result.error, result.response))
}
return result.data as Exclude<T, undefined>
}
/** The port over one client, in one organization of one platform project. */
export function sdkInventoryService(binding: { client: Client; orgSlug: string; bindingId: string }): InventoryService {
const { client } = binding
const org = { org_slug: binding.orgSlug }
return {
bindingId: binding.bindingId,
async listItems(query) {
const page = await answer(
inventoryListItems({ client, path: org, query: { ...query, limit: BigInt(query.limit) } }),
)
return { rows: page.data.map(itemView), ...(page.next_after ? { after: page.next_after } : {}) }
},
async getItem(itemId) {
return itemView(await answer(inventoryGetItem({ client, path: { ...org, item_id: itemId } })))
},
async availability(itemId) {
return availabilityView(await answer(inventoryGetItemAvailability({ client, path: { ...org, item_id: itemId } })))
},
async listReservations(query) {
const page = await answer(
inventoryListReservations({ client, path: org, query: { ...query, limit: BigInt(query.limit) } }),
)
return { rows: page.data.map(reservationView), ...(page.next_after ? { after: page.next_after } : {}) }
},
async getReservation(reservationId) {
const reservation = await answer(
inventoryGetReservation({ client, path: { ...org, reservation_id: reservationId } }),
)
return { reservation: reservationView(reservation), reference: reservation.reference }
},
async createItem(input) {
return itemView(await answer(inventoryCreateItem({ client, path: org, body: input })))
},
async reserve(input) {
const outcome = await answer(
inventoryCreateReservation({
client,
path: org,
body: {
item_id: input.itemId,
location_id: input.locationId,
quantity: BigInt(input.quantity),
reference: input.reference,
idempotency_key: input.key,
},
}),
)
return reservationView(outcome.reservation)
},
async release(input) {
const outcome = await answer(
inventoryReleaseReservation({
client,
path: { ...org, reservation_id: input.reservationId },
body: {
idempotency_key: input.key,
...(input.quantity === undefined ? {} : { quantity: BigInt(input.quantity) }),
},
}),
)
return reservationView(outcome.reservation)
},
}
}
/**
* The service a handler of `kind` may use: the bound one (the token's view for the kind applies), or
* the current verified request's, narrowed to its reads for a `read` or `write` handler as the token would.
* A deployment without the contract is kit-core's `service_configuration_missing` refusal.
*/
export function inventoryServiceOf(container: Container, kind: "effect"): InventoryService
export function inventoryServiceOf(container: Container, kind: ProcedureKind): InventoryReads
export function inventoryServiceOf(container: Container, kind: ProcedureKind): InventoryReads {
if (container.has(INVENTORY_SERVICE)) return container.resolve(INVENTORY_SERVICE) as InventoryReads
let service: InventoryService
try {
const binding = requestBusinessBinding(container)
service = sdkInventoryService({
client: binding.client(createClient),
orgSlug: binding.orgSlug,
bindingId: binding.bindingId,
})
} catch (error) {
throw businessAppError(error)
}
return kind === "effect" ? service : inventoryReadsOf(service)
}
+14
View File
@@ -0,0 +1,14 @@
// The module's names, in a file with no import: a browser bundle can take them without the schemas.
/** The platform service this module is a client of (B-wave contract §3.14). */
export const INVENTORY_SERVICE_NAME = "inventory"
/** The app permissions of the module, by what they let a member do. */
export const INVENTORY_PERMISSIONS = {
read: "inventory.item.read",
write: "inventory.item.write",
reserve: "inventory.stock.reserve",
} as const
/** The most rows one page of a list asks the service for. */
export const PAGE_LIMIT = 50
+20
View File
@@ -0,0 +1,20 @@
// The public API of the inventory module: what a page, another module and the OpenAPI document may
// import. Nothing here reaches core/, adapters/ or a service client.
export { INVENTORY_PERMISSIONS, INVENTORY_SERVICE_NAME, PAGE_LIMIT } from "./constants.js"
export {
type AvailabilityView,
availabilityViewSchema,
type ItemView,
itemAvailability,
itemCreate,
itemList,
itemViewSchema,
type ReservationView,
reservationList,
reservationViewSchema,
type StockLevelView,
stockLevelViewSchema,
stockRelease,
stockReserve,
} from "./procedures.js"
+135
View File
@@ -0,0 +1,135 @@
// The procedure contracts of the inventory module: schemas and names, no handler and no port.
//
// The module is a client of the platform's Inventory service (B-wave contract §3.14): the rows live
// there, in the deployment's organization, and every procedure here is one service call. A read
// reads; a change is an `effect` (it calls an external service and writes nothing of this app's own).
// Stock is reserved for the project the procedure runs in: the service holds it against the
// project's reference, and a project lists and releases its own reservations only.
import { procedure } from "@conusai-kit/core"
import { z } from "zod"
import { INVENTORY_PERMISSIONS } from "./constants.js"
const inProject = { org: z.string(), workspace: z.string(), project: z.string() }
const id = z.string().uuid()
/** What a form generates once per submission, so a retried request reserves once. */
const requestId = z.string().uuid()
const cursor = { after: id.optional(), limit: z.number().int().min(1).max(200).optional() }
export const itemViewSchema = z.object({
itemId: z.string(),
sku: z.string(),
name: z.string(),
unit: z.string(),
description: z.string().nullable(),
/** An archived item takes no reservation. */
active: z.boolean(),
})
export type ItemView = z.output<typeof itemViewSchema>
export const stockLevelViewSchema = z.object({
locationId: z.string(),
onHand: z.number().int(),
reserved: z.number().int(),
available: z.number().int(),
})
export type StockLevelView = z.output<typeof stockLevelViewSchema>
export const availabilityViewSchema = z.object({
itemId: z.string(),
unit: z.string(),
onHand: z.number().int(),
reserved: z.number().int(),
available: z.number().int(),
/** Every location with a balance, in the service's location order. */
locations: z.array(stockLevelViewSchema),
})
export type AvailabilityView = z.output<typeof availabilityViewSchema>
export const reservationViewSchema = z.object({
reservationId: z.string(),
itemId: z.string(),
locationId: z.string(),
quantity: z.number().int(),
outstanding: z.number().int(),
released: z.number().int(),
issued: z.number().int(),
status: z.enum(["open", "closed"]),
createdAt: z.string(),
})
export type ReservationView = z.output<typeof reservationViewSchema>
/** A page of a list: `after` is what the next page is asked with, absent on the last. */
const pageOf = <T extends z.ZodType>(rows: T) => z.object({ rows: z.array(rows), after: z.string().optional() })
export const itemList = procedure("inventory.item.list")
.kind("read")
.scope("project")
.permission(INVENTORY_PERMISSIONS.read)
.input(z.object({ ...inProject, q: z.string().max(200).optional(), ...cursor }))
.output(pageOf(itemViewSchema))
.contract()
export const itemCreate = procedure("inventory.item.create")
.kind("effect")
.scope("project")
.permission(INVENTORY_PERMISSIONS.write)
.input(
z.object({
...inProject,
sku: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/),
name: z.string().trim().min(1).max(200),
unit: z.string().regex(/^[a-z][a-z0-9_]{0,15}$/),
description: z.string().max(1000).optional(),
}),
)
.output(itemViewSchema)
.contract()
export const itemAvailability = procedure("inventory.item.availability")
.kind("read")
.scope("project")
.permission(INVENTORY_PERMISSIONS.read)
.input(z.object({ ...inProject, itemId: id }))
.output(z.object({ item: itemViewSchema, availability: availabilityViewSchema }))
.contract()
export const reservationList = procedure("inventory.reservation.list")
.kind("read")
.scope("project")
.permission(INVENTORY_PERMISSIONS.read)
.input(z.object({ ...inProject, status: z.enum(["open", "closed"]).optional(), ...cursor }))
.output(pageOf(reservationViewSchema))
.contract()
export const stockReserve = procedure("inventory.stock.reserve")
.kind("effect")
.scope("project")
.permission(INVENTORY_PERMISSIONS.reserve)
.input(
z.object({
...inProject,
itemId: id,
locationId: id,
quantity: z.number().int().min(1).max(1_000_000_000),
requestId,
}),
)
.output(reservationViewSchema)
.contract()
export const stockRelease = procedure("inventory.stock.release")
.kind("effect")
.scope("project")
.permission(INVENTORY_PERMISSIONS.reserve)
.input(
z.object({
...inProject,
reservationId: id,
/** Everything still held when absent. */
quantity: z.number().int().min(1).max(1_000_000_000).optional(),
requestId,
}),
)
.output(reservationViewSchema)
.contract()
+92
View File
@@ -0,0 +1,92 @@
// What the inventory module does, as pure TypeScript over the port in ports.ts.
//
// Items belong to the credential-bound app tenant and environment. Reservations
// additionally belong to the resolved app project; another project's reservation
// is not found. Platform project IDs never substitute for immutable binding IDs.
import { projectRef } from "@conusai-kit/business"
import { notFound, type ProjectScope } from "@conusai-kit/core"
import { type AvailabilityView, type ItemView, PAGE_LIMIT, type ReservationView } from "../contract/index.js"
import type { InventoryReads, InventoryService, Page } from "./ports.js"
/** Canonical partition and resolved-project reference, shared with the service parser. */
export const projectReference = (bindingId: string, projectId: string): string => projectRef(bindingId, projectId)
/** The service's idempotency key of a request: one project's request id never answers another's. */
export const requestKey = (scope: Pick<ProjectScope, "projectId">, requestId: string): string =>
`app-project:${scope.projectId}:${requestId}`
export function listItems(
reads: InventoryReads,
query: { q?: string; after?: string; limit?: number },
): Promise<Page<ItemView>> {
const q = query.q?.trim()
return reads.listItems({
...(q ? { q } : {}),
...(query.after === undefined ? {} : { after: query.after }),
limit: query.limit ?? PAGE_LIMIT,
})
}
export function createItem(
service: InventoryService,
input: { sku: string; name: string; unit: string; description?: string },
): Promise<ItemView> {
const description = input.description?.trim()
return service.createItem({
sku: input.sku,
name: input.name.trim(),
unit: input.unit,
...(description ? { description } : {}),
})
}
export async function itemAvailability(
reads: InventoryReads,
itemId: string,
): Promise<{ item: ItemView; availability: AvailabilityView }> {
const [item, availability] = await Promise.all([reads.getItem(itemId), reads.availability(itemId)])
return { item, availability }
}
export function listProjectReservations(
reads: InventoryReads,
scope: ProjectScope,
query: { status?: "open" | "closed"; after?: string; limit?: number },
): Promise<Page<ReservationView>> {
return reads.listReservations({
reference: projectReference(reads.bindingId, scope.projectId),
...(query.status === undefined ? {} : { status: query.status }),
...(query.after === undefined ? {} : { after: query.after }),
limit: query.limit ?? PAGE_LIMIT,
})
}
export function reserveStock(
service: InventoryService,
scope: ProjectScope,
input: { itemId: string; locationId: string; quantity: number; requestId: string },
): Promise<ReservationView> {
return service.reserve({
itemId: input.itemId,
locationId: input.locationId,
quantity: input.quantity,
reference: projectReference(service.bindingId, scope.projectId),
key: requestKey(scope, input.requestId),
})
}
export async function releaseStock(
service: InventoryService,
scope: ProjectScope,
input: { reservationId: string; quantity?: number; requestId: string },
): Promise<ReservationView> {
const { reference } = await service.getReservation(input.reservationId)
// Another project's reservation is not found, never forbidden: its existence does not leak.
if (reference !== projectReference(service.bindingId, scope.projectId)) throw notFound("no such reservation")
return service.release({
reservationId: input.reservationId,
...(input.quantity === undefined ? {} : { quantity: input.quantity }),
key: requestKey(scope, input.requestId),
})
}
+61
View File
@@ -0,0 +1,61 @@
// The port the inventory module's use cases are written against: the platform's Inventory service,
// in this module's own words. Core imports no client: adapters/service.ts is the one place that knows
// `@conusai-sdk/inventory` and `@conusai-kit/business` exist, and it turns every refusal into the
// kit's app errors (`businessAppError` of
// `@conusai-kit/business`) before core sees it.
import { token } from "@conusai-kit/core"
import type { AvailabilityView, ItemView, ReservationView } from "../contract/index.js"
/** One page of a service list: `after` asks for the next, absent on the last. */
export interface Page<T> {
readonly rows: T[]
readonly after?: string
}
/** What a `read` procedure may ask the service: nothing that changes a row. */
export interface InventoryReads {
/** The immutable app/environment binding UUID for project references. */
readonly bindingId: string
listItems(query: { q?: string; after?: string; limit: number }): Promise<Page<ItemView>>
getItem(itemId: string): Promise<ItemView>
availability(itemId: string): Promise<AvailabilityView>
listReservations(query: {
reference: string
status?: "open" | "closed"
after?: string
limit: number
}): Promise<Page<ReservationView>>
getReservation(reservationId: string): Promise<{ reservation: ReservationView; reference: string }>
}
export interface InventoryService extends InventoryReads {
createItem(input: { sku: string; name: string; unit: string; description?: string }): Promise<ItemView>
reserve(input: {
itemId: string
locationId: string
quantity: number
reference: string
key: string
}): Promise<ReservationView>
release(input: { reservationId: string; quantity?: number; key: string }): Promise<ReservationView>
}
/** The read view a `read` or `write` handler receives: the kind is a capability, not a label. */
export const inventoryReadsOf = (service: InventoryReads): InventoryReads => ({
bindingId: service.bindingId,
listItems: (query) => service.listItems(query),
getItem: (itemId) => service.getItem(itemId),
availability: (itemId) => service.availability(itemId),
listReservations: (query) => service.listReservations(query),
getReservation: (reservationId) => service.getReservation(reservationId),
})
/**
* Bound by a composition root or a test that brings its own client (another organization, the
* business-services fake). With nothing bound, the module calls the service of this deployment
* (adapters/service.ts).
*/
export const INVENTORY_SERVICE = token<InventoryService>("inventory.service", {
views: { read: inventoryReadsOf, write: inventoryReadsOf },
})
+63
View File
@@ -0,0 +1,63 @@
// English messages of the inventory module. Every string a page of this module shows is a key here and
// in lt.ts; `__tests__/i18n.test.ts` fails when the two differ.
export const en = {
title: "Inventory",
intro:
"Items and stock from the platform's Inventory service. The items are your organization's catalogue, shared by every project; the reservations below are this project's.",
items: {
title: "Items",
caption: "Items",
empty: "No items yet.",
sku: "SKU",
name: "Name",
unit: "Unit",
active: "Active",
archived: "Archived",
open: "Stock of {name}",
search: "Find an item",
},
form: {
heading: "New item",
sku: "SKU",
skuHint: "Letters, digits, dots, dashes and underscores; unique in the organization.",
name: "Name",
unit: "Unit",
unitHint: "Lower-case, such as pcs, kg or m2. It cannot be changed later.",
description: "Description",
submit: "Add item",
busy: "Adding",
},
item: {
back: "All items",
unit: "Counted in {unit}",
onHand: "On hand",
reserved: "Reserved",
available: "Available",
locations: "{count, plural, one {# location} other {# locations}} with stock",
locationsCaption: "Stock by location",
location: "Location",
none: "There is no stock of this item anywhere.",
},
reserve: {
heading: "Reserve for this project",
location: "From location",
quantity: "Quantity",
submit: "Reserve",
busy: "Reserving",
nothing: "Nothing is available to reserve.",
},
reservations: {
title: "This project's reservations",
caption: "Open reservations of this project",
empty: "This project holds no stock.",
item: "Item",
quantity: "Reserved",
outstanding: "Still held",
actions: "Actions",
release: "Release the stock held of {item}",
releaseShort: "Release",
releasing: "Releasing",
releaseConfirm: "The stock goes back to the location, for anyone to reserve.",
},
}
+4
View File
@@ -0,0 +1,4 @@
// The inventory module's messages, as the TypeScript objects its manifest declares (`i18n: { en, lt }`).
export { en } from "./en.js"
export { lt } from "./lt.js"
+67
View File
@@ -0,0 +1,67 @@
// Lietuviški inventory modulio pranešimai. Kiekvienas puslapio tekstas yra raktas čia ir en.ts faile;
// `__tests__/i18n.test.ts` žlunga, kai jie skiriasi.
import type { en } from "./en.js"
type Messages<T> = { [K in keyof T]: T[K] extends string ? string : Messages<T[K]> }
export const lt: Messages<typeof en> = {
title: "Atsargos",
intro:
"Prekės ir atsargos iš platformos atsargų paslaugos. Prekės yra jūsų organizacijos katalogas, bendras visiems projektams; žemiau pateikti rezervavimai priklauso šiam projektui.",
items: {
title: "Prekės",
caption: "Prekės",
empty: "Prekių dar nėra.",
sku: "Prekės kodas",
name: "Pavadinimas",
unit: "Matas",
active: "Aktyvi",
archived: "Archyvuota",
open: "{name} atsargos",
search: "Rasti prekę",
},
form: {
heading: "Nauja prekė",
sku: "Prekės kodas",
skuHint: "Raidės, skaitmenys, taškai, brūkšneliai ir pabraukimai; unikalus organizacijoje.",
name: "Pavadinimas",
unit: "Matas",
unitHint: "Mažosiomis raidėmis, pvz., pcs, kg arba m2. Vėliau jo pakeisti negalima.",
description: "Aprašymas",
submit: "Pridėti prekę",
busy: "Pridedama",
},
item: {
back: "Visos prekės",
unit: "Skaičiuojama: {unit}",
onHand: "Turima",
reserved: "Rezervuota",
available: "Laisva",
locations: "{count, plural, one {# vieta} few {# vietos} other {# vietų}} su atsargomis",
locationsCaption: "Atsargos pagal vietą",
location: "Vieta",
none: "Šios prekės atsargų niekur nėra.",
},
reserve: {
heading: "Rezervuoti šiam projektui",
location: "Iš vietos",
quantity: "Kiekis",
submit: "Rezervuoti",
busy: "Rezervuojama",
nothing: "Nėra ką rezervuoti.",
},
reservations: {
title: "Šio projekto rezervavimai",
caption: "Atviri šio projekto rezervavimai",
empty: "Šis projektas atsargų nelaiko.",
item: "Prekė",
quantity: "Rezervuota",
outstanding: "Dar laikoma",
actions: "Veiksmai",
release: "Atleisti laikomas {item} atsargas",
releaseShort: "Atleisti",
releasing: "Atleidžiama",
releaseConfirm: "Atsargos grįžta į vietą, ir jas galės rezervuoti bet kas.",
},
}
+8
View File
@@ -0,0 +1,8 @@
// The inventory module's public entry: its manifest, its contract, and the port an app may bind
// (a test's fake, or a client for another organization). A page imports `./ui`.
export { sdkInventoryService } from "./adapters/service.js"
export * from "./contract/index.js"
export { projectReference } from "./core/inventory.js"
export { INVENTORY_SERVICE, type InventoryReads, type InventoryService } from "./core/ports.js"
export { inventoryModule } from "./module.js"
+51
View File
@@ -0,0 +1,51 @@
// The inventory module's manifest: a client of the platform's Inventory business service (B-wave
// contract §3.14, docs/business-services.md). It keeps no table of its own -- the items, the stock
// and the reservations are the service's, in the deployment's organization -- so it has procedures,
// permissions, pages and messages, and no repository, migration, job or event.
//
// The template ships it off: `kit module:on inventory` adds it to the list in modules/index.ts and
// asks the deployment token for the service scopes it needs (`inventory.items:read|write`,
// `inventory.stock:read`, `inventory.reservations:read|write`) in conusai.json.
import { defineModule } from "@conusai-kit/core"
import { INVENTORY_PROCEDURES } from "./adapters/procedures.js"
import { en, lt } from "./i18n/index.js"
export const inventoryModule = defineModule({
id: "inventory",
// A business module is its service, which this app reaches on these profiles (test/module-profiles.test.ts).
profiles: ["postgres", "mariadb", "mongodb"],
version: "0.1.0",
kit: "^0.1.0",
requires: ["tenancy"],
permissions: {
"inventory.item.read": "viewer",
"inventory.item.write": "editor",
"inventory.stock.reserve": "editor",
},
procedures: INVENTORY_PROCEDURES,
serviceRequirements: [
{
transport: "business",
service: "inventory",
apiRange: "^1.2.0",
profiles: ["postgres", "mariadb", "mongodb"],
operations: {
"inventory.item.list": ["inventory.items:read"],
"inventory.item.create": ["inventory.items:write"],
"inventory.item.availability": ["inventory.items:read", "inventory.stock:read"],
"inventory.reservation.list": ["inventory.reservations:read"],
"inventory.stock.reserve": ["inventory.reservations:write"],
"inventory.stock.release": ["inventory.reservations:read", "inventory.reservations:write"],
},
configuration: [],
dependencies: [],
dataClasses: ["operational"],
partitionRequired: true,
remediation: "/docs/business-services.md",
},
],
// `inventory` is the segment the web shell's module mount routes to `inventoryPage` (ui/pages.tsx).
ui: { nav: [{ scope: "project", label: "inventory.title", href: "inventory", permission: "inventory.item.read" }] },
i18n: { en, lt },
})
+17
View File
@@ -0,0 +1,17 @@
// The inventory module's pages and components. `inventoryPage` is what the web shell mounts; the rest
// is exported for an app that composes its own page from the parts.
export { ItemForm, type ItemFormLabels } from "./item-form.js"
export { ItemList, type ItemListLabels } from "./item-list.js"
export {
itemFormLabels,
itemListLabels,
reservationListLabels,
reserveFormLabels,
stockLabels,
type Translate,
} from "./labels.js"
export { type InventoryPage, inventoryPage } from "./pages.js"
export { ReservationList, type ReservationListLabels } from "./reservation-list.js"
export { ReserveForm, type ReserveFormLabels, type ReserveOption } from "./reserve-form.js"
export { Stock, type StockLabels } from "./stock.js"
+82
View File
@@ -0,0 +1,82 @@
"use client"
// Adds an item to the organization's catalogue, then opens its stock page. The service checks the SKU
// and the unit again and answers a taken SKU as a duplicate, shown here in the kit's words for that refusal.
import type { ProjectSlugs } from "@conusai-kit/ui"
import { describeError, useWrite } from "@conusai-kit/ui/data"
import {
FormLayout,
type FormValues,
PageSection,
serviceNotice,
TextField,
usePageNavigation,
} from "@conusai-kit/ui/patterns"
import type { ReactElement } from "react"
export interface ItemFormLabels {
readonly heading: string
readonly sku: string
readonly skuHint: string
readonly name: string
readonly unit: string
readonly unitHint: string
readonly description: string
readonly submit: string
readonly busy: string
/** The text of each business refusal by problem code (`businessRefusalNotices` of `@conusai-kit/business/refusals`). */
readonly refusals: Readonly<Record<string, string>>
}
export function ItemForm({
slugs,
listHref,
labels,
}: {
slugs: ProjectSlugs
listHref: string
labels: ItemFormLabels
}): ReactElement {
const navigation = usePageNavigation()
// No live collection follows a service's rows, so the write resolves with the answer.
const create = useWrite<Record<string, unknown>, { itemId: string }>("inventory.item.create")
const submit = async ({ sku, name, unit, description = "" }: FormValues): Promise<void> => {
const trimmed = description.trim()
const created = await create.mutate({ ...slugs, sku, name, unit, ...(trimmed ? { description: trimmed } : {}) })
navigation.navigate(`${listHref}/${created.itemId}`)
}
return (
<PageSection title={labels.heading}>
<FormLayout
submitLabel={labels.submit}
busyLabel={labels.busy}
onSubmit={submit}
describeError={(error) => serviceNotice(error, labels.refusals)?.text ?? describeError(error)}
>
<TextField
name="sku"
label={labels.sku}
description={labels.skuHint}
errorMessage={labels.skuHint}
required
maxLength={64}
pattern="[A-Za-z0-9][A-Za-z0-9._\-]*"
/>
<TextField name="name" label={labels.name} required maxLength={200} />
<TextField
name="unit"
label={labels.unit}
description={labels.unitHint}
errorMessage={labels.unitHint}
required
maxLength={16}
pattern="[a-z][a-z0-9_]*"
/>
<TextField multiline name="description" label={labels.description} rows={3} maxLength={1000} />
</FormLayout>
</PageSection>
)
}
+65
View File
@@ -0,0 +1,65 @@
"use client"
// The organization's items, a page at a time. The server renders the first page (`seed`); a search and
// "Show more" are reads made on a person's action, through the page's data client.
import type { ProjectSlugs } from "@conusai-kit/ui"
import { useDataClient } from "@conusai-kit/ui/data"
import { type ResourceColumn, ResourceList, useResourceList } from "@conusai-kit/ui/patterns"
import type { ReactElement } from "react"
import type { ItemView } from "../contract/index.js"
export interface ItemListLabels {
readonly caption: string
readonly empty: string
readonly sku: string
readonly name: string
readonly unit: string
readonly active: string
readonly archived: string
/** `Stock of {name}`: the link's accessible name. */
readonly open: string
readonly search: string
}
type Page = { rows: ItemView[]; after?: string | undefined }
export function ItemList({
slugs,
seed,
listHref,
labels,
}: {
slugs: ProjectSlugs
seed: Page
listHref: string
labels: ItemListLabels
}): ReactElement {
const client = useDataClient()
const { list } = useResourceList<ItemView>({
seed,
rowId: (item) => item.itemId,
searchLabel: labels.search,
read: async ({ q, after }) => {
const input = { ...slugs, ...(q ? { q } : {}), ...(after ? { after } : {}) }
return (await client.call<Page>("inventory.item.list", input)).value
},
})
const columns: ResourceColumn<ItemView>[] = [
{ id: "sku", header: labels.sku },
{ id: "name", header: labels.name, wrap: true, minWidth: "10rem" },
{ id: "unit", header: labels.unit },
{ id: "active", header: labels.active, cell: (item) => (item.active ? labels.active : labels.archived) },
]
return (
<ResourceList
{...list}
label={labels.caption}
columns={columns}
rowHref={(item) => `${listHref}/${item.itemId}`}
rowLinkLabel={(item) => labels.open.replace("{name}", item.sku)}
rowLabel={(item) => item.sku}
empty={{ title: labels.empty }}
/>
)
}
+88
View File
@@ -0,0 +1,88 @@
// The labels each component takes, built from a message lookup. Every key this module's pages use is
// named here or in pages.tsx, so `__tests__/i18n.test.ts` can run these builders against the
// catalogues and fail on a key either language lacks.
//
// `t` takes a key relative to the module (`form.submit`); the page binds it to
// `i18n.message("inventory." + key)`. `refusals` is the kit's text for each business refusal in the reader's
// language (`businessRefusalNotices`), which a form or a list shows when the service refuses.
import type { ItemFormLabels } from "./item-form.js"
import type { ItemListLabels } from "./item-list.js"
import type { ReservationListLabels } from "./reservation-list.js"
import type { ReserveFormLabels } from "./reserve-form.js"
import type { StockLabels } from "./stock.js"
export type Translate = (key: string, values?: Record<string, string | number>) => string
export type Refusals = Readonly<Record<string, string>>
export function itemFormLabels(t: Translate, refusals: Refusals): ItemFormLabels {
return {
heading: t("form.heading"),
sku: t("form.sku"),
skuHint: t("form.skuHint"),
name: t("form.name"),
unit: t("form.unit"),
unitHint: t("form.unitHint"),
description: t("form.description"),
submit: t("form.submit"),
busy: t("form.busy"),
refusals,
}
}
export function itemListLabels(t: Translate): ItemListLabels {
return {
caption: t("items.caption"),
empty: t("items.empty"),
sku: t("items.sku"),
name: t("items.name"),
unit: t("items.unit"),
active: t("items.active"),
archived: t("items.archived"),
// The placeholders are the component's to fill, so they are passed through untouched.
open: t("items.open", { name: "{name}" }),
search: t("items.search"),
}
}
export function stockLabels(t: Translate, unit: string, locations: number): StockLabels {
return {
unit: t("item.unit", { unit }),
locations: t("item.locations", { count: locations }),
onHand: t("item.onHand"),
reserved: t("item.reserved"),
available: t("item.available"),
caption: t("item.locationsCaption"),
location: t("item.location"),
none: t("item.none"),
}
}
export function reserveFormLabels(t: Translate, refusals: Refusals): ReserveFormLabels {
return {
heading: t("reserve.heading"),
location: t("reserve.location"),
quantity: t("reserve.quantity"),
submit: t("reserve.submit"),
busy: t("reserve.busy"),
nothing: t("reserve.nothing"),
refusals,
}
}
export function reservationListLabels(t: Translate, refusals: Refusals): ReservationListLabels {
return {
title: t("reservations.title"),
caption: t("reservations.caption"),
empty: t("reservations.empty"),
item: t("reservations.item"),
quantity: t("reservations.quantity"),
outstanding: t("reservations.outstanding"),
actions: t("reservations.actions"),
release: t("reservations.release", { item: "{item}" }),
releaseShort: t("reservations.releaseShort"),
releasing: t("reservations.releasing"),
releaseConfirm: t("reservations.releaseConfirm"),
refusals,
}
}
+200
View File
@@ -0,0 +1,200 @@
// The inventory module's pages, on the shell's F10 contract (docs/reference/app-kit.md): a loader the web
// shell runs on the server under `/<org>/<workspace>/<project>/inventory`, and the view of what it returned.
//
// /inventory the organization's items, a form that adds one, and this project's reservations
// /inventory/<itemId> one item's stock by location, and the form that reserves some for this project
//
// Every read is a procedure through the same caller the API uses, and every label is translated in the
// loader. A refusal that is the platform's -- the service is not enabled for the organization, the
// deployment lacks a scope, the service did not answer -- is the page's own state, not an error page, in the
// kit's words for it (`businessServiceNotices` of `@conusai-kit/business/refusals`).
import { businessRefusalNotices, businessServiceNotices } from "@conusai-kit/business/refusals"
import { type Container, notFound, type TransportCaller } from "@conusai-kit/core"
import type { I18n } from "@conusai-kit/i18n"
import { type ProjectPage, type ProjectPageViewProps, projectPage } from "@conusai-kit/ui"
import { Can } from "@conusai-kit/ui/data"
import {
DetailLayout,
Page,
PageHeader,
PageSection,
type ServiceNotice,
ServiceStatePage,
serviceNotice,
} from "@conusai-kit/ui/patterns"
import type { ReactElement } from "react"
import {
type AvailabilityView,
type ItemView,
itemAvailability,
itemList,
type ReservationView,
reservationList,
} from "../contract/index.js"
import { ItemForm, type ItemFormLabels } from "./item-form.js"
import { ItemList, type ItemListLabels } from "./item-list.js"
import {
itemFormLabels,
itemListLabels,
reservationListLabels,
reserveFormLabels,
stockLabels,
type Translate,
} from "./labels.js"
import { ReservationList, type ReservationListLabels } from "./reservation-list.js"
import { ReserveForm, type ReserveFormLabels } from "./reserve-form.js"
import { Stock, type StockLabels } from "./stock.js"
/** The items, the form that adds one, and this project's open reservations. */
export interface InventoryListData {
readonly kind: "list"
readonly title: string
readonly intro: string
readonly itemsHeading: string
readonly items: { rows: ItemView[]; after?: string | undefined }
readonly itemForm: ItemFormLabels
readonly itemList: ItemListLabels
readonly reservations: { rows: ReservationView[]; after?: string | undefined }
readonly reservationList: ReservationListLabels
/** Each reserved item as a person reads it, by item id. */
readonly itemLabel: Readonly<Record<string, string>>
}
/** One item's stock, and the form that reserves it when the item is active. */
export interface InventoryItemData {
readonly kind: "item"
readonly title: string
readonly back: string
readonly heading: string
readonly description?: string
readonly itemId: string
readonly active: boolean
readonly availability: AvailabilityView
readonly stock: StockLabels
readonly reserve: ReserveFormLabels
}
/** The service cannot answer: the notice in place of the page. */
export interface InventoryStateData {
readonly kind: "state"
readonly title: string
readonly notice: ServiceNotice
}
export type InventoryPageData = InventoryListData | InventoryItemData | InventoryStateData
export type InventoryPage = ProjectPage<TransportCaller, I18n, Container, InventoryPageData>
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
/** A location as a person reads it: the service knows locations by id, so the id's first block. */
const locationName = (locationId: string): string => locationId.slice(0, 8)
export const inventoryPage: InventoryPage = projectPage({
async load({ slugs, segments, caller, i18n }): Promise<InventoryPageData> {
const t: Translate = (key, values) => i18n.message(`inventory.${key}`, values)
const title = t("title")
const refusals = businessRefusalNotices(i18n.locale)
const [itemId, ...rest] = segments
if (rest.length > 0 || (itemId !== undefined && !UUID.test(itemId))) throw notFound("no such page")
try {
if (itemId === undefined) {
const [items, reservations] = await Promise.all([
caller.call(itemList, slugs),
caller.call(reservationList, { ...slugs, status: "open" }),
])
return {
kind: "list",
title,
intro: t("intro"),
itemsHeading: t("items.title"),
items,
itemForm: itemFormLabels(t, refusals),
itemList: itemListLabels(t),
reservations,
reservationList: reservationListLabels(t, refusals),
itemLabel: Object.fromEntries(items.rows.map((item) => [item.itemId, `${item.sku} ${item.name}`])),
}
}
const { item, availability } = await caller.call(itemAvailability, { ...slugs, itemId })
return {
kind: "item",
title,
back: t("item.back"),
heading: `${item.sku} ${item.name}`,
...(item.description ? { description: item.description } : {}),
itemId: item.itemId,
active: item.active,
availability,
stock: stockLabels(t, availability.unit, availability.locations.length),
reserve: reserveFormLabels(t, refusals),
}
} catch (error) {
const notice = serviceNotice(error, businessServiceNotices(i18n.locale))
if (!notice) throw error
return { kind: "state", title, notice }
}
},
View({ slugs, data }: ProjectPageViewProps<InventoryPageData>): ReactElement {
const listHref = `/${slugs.org}/${slugs.workspace}/${slugs.project}/inventory`
if (data.kind === "state") return <ServiceStatePage title={data.title} notice={data.notice} />
if (data.kind === "list") {
return (
<Page>
<PageHeader title={data.title} description={data.intro} />
<DetailLayout
aside={
<Can permission="inventory.item.write">
<ItemForm slugs={slugs} listHref={listHref} labels={data.itemForm} />
</Can>
}
>
<PageSection title={data.itemsHeading}>
<ItemList slugs={slugs} seed={data.items} listHref={listHref} labels={data.itemList} />
</PageSection>
<ReservationList
slugs={slugs}
seed={data.reservations}
itemLabel={data.itemLabel}
listHref={listHref}
labels={data.reservationList}
/>
</DetailLayout>
</Page>
)
}
return (
<Page>
<PageHeader
title={data.heading}
description={data.description}
breadcrumb={<a href={listHref}>{data.back}</a>}
/>
<DetailLayout
aside={
data.active ? (
<Can permission="inventory.stock.reserve">
<ReserveForm
slugs={slugs}
itemId={data.itemId}
options={data.availability.locations.map((level) => ({
locationId: level.locationId,
label: `${locationName(level.locationId)} (${level.available})`,
available: level.available,
}))}
labels={data.reserve}
/>
</Can>
) : undefined
}
>
<Stock availability={data.availability} locationName={locationName} labels={data.stock} />
</DetailLayout>
</Page>
)
},
})
+100
View File
@@ -0,0 +1,100 @@
"use client"
// The stock this project holds: its open reservations, read again after a release. Releasing is for
// whoever holds `inventory.stock.reserve`; the procedure checks again, and a reservation of another
// project is not found there.
import type { ProjectSlugs } from "@conusai-kit/ui"
import { Can, describeError, useRead, useWrite } from "@conusai-kit/ui/data"
import { ConfirmDialog, PageSection, type ResourceColumn, ResourceList, serviceNotice } from "@conusai-kit/ui/patterns"
import { Button, useUiLabels } from "@conusai-kit/ui/primitives"
import type { ReactElement } from "react"
import type { ReservationView } from "../contract/index.js"
export interface ReservationListLabels {
readonly title: string
readonly caption: string
readonly empty: string
readonly item: string
readonly quantity: string
readonly outstanding: string
readonly actions: string
/** `Release the stock held of {item}`: the button's accessible name. */
readonly release: string
readonly releaseShort: string
readonly releasing: string
/** What releasing does to the stock held. */
readonly releaseConfirm: string
/** The text of each business refusal by problem code (`businessRefusalNotices` of `@conusai-kit/business/refusals`). */
readonly refusals: Readonly<Record<string, string>>
}
type Page = { rows: ReservationView[]; after?: string | undefined }
export function ReservationList({
slugs,
seed,
itemLabel,
listHref,
labels,
}: {
slugs: ProjectSlugs
seed: Page
/** What a person reads for an item, by id, for the items the page knows; the id otherwise. */
itemLabel: Readonly<Record<string, string>>
listHref: string
labels: ReservationListLabels
}): ReactElement {
const list = useRead<Page>("inventory.reservation.list", { ...slugs, status: "open" }, { initial: seed })
const release = useWrite<Record<string, unknown>, ReservationView>("inventory.stock.release")
const failed = useUiLabels()?.failed ?? "{detail}"
const nameOf = (row: ReservationView): string => itemLabel[row.itemId] ?? row.itemId
const describe = (error: unknown): string => serviceNotice(error, labels.refusals)?.text ?? describeError(error)
const columns: ResourceColumn<ReservationView>[] = [
{ id: "itemId", header: labels.item, wrap: true, cell: nameOf },
{ id: "quantity", header: labels.quantity, align: "end" },
{ id: "outstanding", header: labels.outstanding, align: "end" },
{
id: "actions",
header: labels.actions,
cell: (row) => {
const name = labels.release.replace("{item}", nameOf(row))
return (
<Can permission="inventory.stock.reserve">
<ConfirmDialog
trigger={
<Button variant="outline" size="sm" aria-label={name}>
{labels.releaseShort}
</Button>
}
title={name}
description={labels.releaseConfirm}
confirmLabel={labels.releaseShort}
busyLabel={labels.releasing}
describeError={describe}
onConfirm={async () => {
await release.mutate({ ...slugs, reservationId: row.reservationId, requestId: crypto.randomUUID() })
await list.refetch()
}}
/>
</Can>
)
},
},
]
return (
<PageSection title={labels.title}>
<ResourceList
label={labels.caption}
columns={columns}
data={list.data?.rows ?? []}
rowId={(row) => row.reservationId}
rowHref={(row) => `${listHref}/${row.itemId}`}
rowLabel={nameOf}
empty={{ title: labels.empty }}
{...(list.error === undefined ? {} : { error: failed.replace("{detail}", describe(list.error)) })}
onRetry={() => void list.refetch()}
/>
</PageSection>
)
}
+84
View File
@@ -0,0 +1,84 @@
"use client"
// Reserves stock of one item for this project, from one location that has some available. The request
// id is minted once per submission, so a retried request reserves once; the service answers a
// quantity it cannot hold as `insufficient_stock`, shown here in the kit's words for that refusal.
import type { ProjectSlugs } from "@conusai-kit/ui"
import { describeError, useWrite } from "@conusai-kit/ui/data"
import {
FormLayout,
type FormValues,
PageSection,
SelectField,
serviceNotice,
TextField,
usePageNavigation,
} from "@conusai-kit/ui/patterns"
import { type ReactElement, useState } from "react"
export interface ReserveFormLabels {
readonly heading: string
readonly location: string
readonly quantity: string
readonly submit: string
readonly busy: string
readonly nothing: string
/** The text of each business refusal by problem code (`businessRefusalNotices` of `@conusai-kit/business/refusals`). */
readonly refusals: Readonly<Record<string, string>>
}
export interface ReserveOption {
readonly locationId: string
/** What a person reads for the location, with what is available there. */
readonly label: string
readonly available: number
}
export function ReserveForm({
slugs,
itemId,
options,
labels,
}: {
slugs: ProjectSlugs
itemId: string
options: readonly ReserveOption[]
labels: ReserveFormLabels
}): ReactElement {
const navigation = usePageNavigation()
const reserve = useWrite<Record<string, unknown>, unknown>("inventory.stock.reserve")
const [requestId, setRequestId] = useState(() => crypto.randomUUID())
const open = options.filter((option) => option.available > 0)
const submit = async ({ locationId, quantity }: FormValues): Promise<void> => {
await reserve.mutate({ ...slugs, itemId, locationId, quantity: Number(quantity), requestId })
setRequestId(crypto.randomUUID())
// The page is rendered on the server: a reload shows the new balance and the reservation.
await navigation.refresh()
}
return (
<PageSection title={labels.heading} variant="card">
{open.length === 0 ? (
<p>{labels.nothing}</p>
) : (
<FormLayout
submitLabel={labels.submit}
busyLabel={labels.busy}
onSubmit={submit}
describeError={(error) => serviceNotice(error, labels.refusals)?.text ?? describeError(error)}
>
<SelectField
name="locationId"
label={labels.location}
required
defaultValue={open[0]?.locationId}
options={open.map((option) => ({ value: option.locationId, label: option.label }))}
/>
<TextField name="quantity" label={labels.quantity} type="number" required min={1} step={1} defaultValue={1} />
</FormLayout>
)}
</PageSection>
)
}
+59
View File
@@ -0,0 +1,59 @@
// An item's stock: the totals and one row per location that holds any. Part of a page's view (F10): it
// holds no state, and every string is passed in.
import { FactList, PageSection, type ResourceColumn, ResourceList } from "@conusai-kit/ui/patterns"
import type { ReactElement } from "react"
import type { AvailabilityView } from "../contract/index.js"
export interface StockLabels {
/** `Counted in {unit}`, already filled. */
readonly unit: string
/** How many locations hold stock, already filled with the plural form. */
readonly locations: string
readonly onHand: string
readonly reserved: string
readonly available: string
readonly caption: string
readonly location: string
readonly none: string
}
type Level = AvailabilityView["locations"][number]
export function Stock({
availability,
locationName,
labels,
}: {
availability: AvailabilityView
/** The location's name for a person; the service knows locations by id. */
locationName: (locationId: string) => string
labels: StockLabels
}): ReactElement {
const columns: ResourceColumn<Level>[] = [
{ id: "locationId", header: labels.location, cell: (level) => locationName(level.locationId) },
{ id: "onHand", header: labels.onHand, align: "end" },
{ id: "reserved", header: labels.reserved, align: "end" },
{ id: "available", header: labels.available, align: "end" },
]
return (
<PageSection title={labels.caption} description={labels.unit}>
<FactList
facts={[
{ label: labels.onHand, value: availability.onHand },
{ label: labels.reserved, value: availability.reserved },
{ label: labels.available, value: availability.available },
]}
/>
<ResourceList
label={labels.caption}
columns={columns}
data={availability.locations}
rowId={(level) => level.locationId}
count={labels.locations}
rowLabel={(level) => locationName(level.locationId)}
empty={{ title: labels.none }}
/>
</PageSection>
)
}