ConusAI vendor v0.1.0-dev.20261027+9b1131fb
ci / gate (push) Waiting to run

This commit is contained in:
ConusAI vendor v0.1.0-dev.20261027+9b1131fb
2026-10-27 18:05:17 +00:00
commit 3102273ec7
538 changed files with 80203 additions and 0 deletions
+110
View File
@@ -0,0 +1,110 @@
// Every string a page of the module shows is a message in English and in Lithuanian (spec §10.4), 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 { describe, expect, it } from "vitest"
import { appModules } from "../../index.js"
import { feedbackLabels } from "../guides/feedback-labels.js" // kit:guides
import {
attachmentLabels,
createFormLabels,
deleteLabels,
editFormLabels,
listLabels,
sizeLabel,
} from "../ui/labels.js"
// The messages as the app has them: from the manifest, flattened and merged by the kit.
const catalogues = buildRegistry([...appModules]).catalogues
const en = catalogues.en?.notes ?? {}
const lt = catalogues.lt?.notes ?? {}
/** The keys a builder asks for, recorded by running it with a lookup that notes each one. */
function keysUsed(): string[] {
const asked: string[] = []
const t = (key: string) => {
asked.push(key)
return key
}
createFormLabels(t, "failed")
editFormLabels(t, "failed")
listLabels(t)
attachmentLabels(t, "failed", 2)
deleteLabels(t, "failed")
sizeLabel(t, 2048)
feedbackLabels(t) // kit:guides
return asked
}
describe("the notes catalogues", () => {
it("hold the same keys in English and in Lithuanian", () => {
expect(Object.keys(lt).sort()).toEqual(Object.keys(en).sort())
})
it("hold no empty message", () => {
for (const [key, text] of [...Object.entries(en), ...Object.entries(lt)]) expect(text.trim(), key).not.toBe("")
})
it("hold every message the components' labels ask for, in both languages", () => {
const asked = keysUsed()
expect(asked.length).toBeGreaterThan(30)
for (const key of asked) {
expect(en[key as keyof typeof en], `en is missing notes.${key}`).toBeTruthy()
expect(lt[key as keyof typeof lt], `lt is missing notes.${key}`).toBeTruthy()
}
})
it("hold no message nothing asks for, so a renamed label does not leave a dead string behind", () => {
// The pages and the navigation ask for these directly, not through a label builder.
const direct = new Set(["title", "intro", "detail.back", "detail.changed"])
const asked = new Set([...keysUsed(), ...direct])
expect(Object.keys(en).filter((key) => !asked.has(key))).toEqual([])
})
it("answer the navigation label the manifest names", () => {
const nav = appModules.find((module) => module.id === "notes")?.ui?.nav ?? []
expect(nav.map((item) => item.label)).toEqual(["notes.title"])
expect(en.title).toBeTruthy()
expect(lt.title).toBeTruthy()
})
})
describe("the Lithuanian plural forms", () => {
const count = (locale: "en" | "lt", n: number) =>
createI18n(locale, { en: { notes: en }, lt: { notes: lt } }).message("notes.attachments.count", { count: n })
it("select one, few and other the way Lithuanian counts", () => {
expect(count("lt", 1)).toBe("1 priedas")
expect(count("lt", 2)).toBe("2 priedai")
expect(count("lt", 9)).toBe("9 priedai")
expect(count("lt", 10)).toBe("10 priedų")
expect(count("lt", 21)).toBe("21 priedas")
})
it("keep English to its two forms", () => {
expect(count("en", 1)).toBe("1 attachment")
expect(count("en", 2)).toBe("2 attachments")
expect(lt["attachments.count"]).toContain("few {")
expect(en["attachments.count"]).not.toContain("few {")
})
})
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", () => {
expect(sources.length).toBeGreaterThanOrEqual(4)
for (const { name, text } of sources) expect(lintUiStrings(text, name), name).toEqual([])
})
it("carry no colour literal: the brand tokens are the only source of colour", () => {
for (const { name, text } of sources)
expect(text, name).not.toMatch(/#[0-9a-fA-F]{3,8}\b|\brgb\(|\bhsl\(|\boklch\(/)
})
})
+58
View File
@@ -0,0 +1,58 @@
// What the generated tenant-isolation suite (test/isolation.test.ts) needs of this module, from this
// folder: the rows it seeds in each tenant, and one fixture per tenant-scoped procedure. The suite
// finds this file by its place, so a module added with `pnpm new:module` has its fixtures read
// without a line outside its folder; a procedure with no fixture fails the suite by name.
//
// A fixture is built from the *other* tenant's ids: the call is made as a member of one
// organization with another's slugs and ids, and has to be refused -- for being another tenant's, not
// for naming nothing, which is why `seed` leaves a note, a file and a link in every tenant.
import type { TransportCaller } from "@conusai-kit/core"
import { type IsolationFixture, type IsolationTenant, uploadViaProcedures } from "@conusai-kit/testing"
/** What a module's isolation file exports: the shape the suite reads, so it needs no import of the suite. */
export interface ModuleIsolation {
seed(
call: TransportCaller,
inProject: { org: string; workspace: string; project: string },
): Promise<Record<string, string>>
fixtures: Record<string, IsolationFixture>
}
const NOWHERE = "00000000-0000-0000-0000-000000000000"
const inProjectOf = (t: IsolationTenant) => ({ org: t.orgSlug, workspace: t.workspaceSlug, project: t.projectSlug })
const noteOf = (t: IsolationTenant) => t.extra.noteId ?? NOWHERE
export const isolation: ModuleIsolation = {
async seed(call, inProject) {
const note = (await call.callByName("notes.note.create", { ...inProject, title: "Seed note", body: "Seed" })) as {
noteId: string
}
const file = await uploadViaProcedures(call, inProject, {
filename: "notes-seed.txt",
contentType: "text/plain",
bytes: new TextEncoder().encode("seed"),
})
const link = (await call.callByName("notes.attachment.add", {
...inProject,
noteId: note.noteId,
fileId: file.fileId,
})) as { attachmentId: string }
return { noteId: note.noteId, noteFileId: file.fileId, attachmentId: link.attachmentId }
},
fixtures: {
"notes.note.list": { input: (t) => inProjectOf(t) },
"notes.note.get": { input: (t) => ({ ...inProjectOf(t), noteId: noteOf(t) }) },
"notes.note.create": { input: (t) => ({ ...inProjectOf(t), title: "Intruder", body: "x" }) },
"notes.note.update": {
input: (t) => ({ ...inProjectOf(t), noteId: noteOf(t), title: "Intruder", body: "x", expectedVersion: 1 }),
},
"notes.note.delete": { input: (t) => ({ ...inProjectOf(t), noteId: noteOf(t) }) },
"notes.attachment.add": {
input: (t) => ({ ...inProjectOf(t), noteId: noteOf(t), fileId: t.extra.noteFileId ?? NOWHERE }),
},
"notes.attachment.remove": {
input: (t) => ({ ...inProjectOf(t), noteId: noteOf(t), attachmentId: t.extra.attachmentId ?? NOWHERE }),
},
},
}
+92
View File
@@ -0,0 +1,92 @@
// The module's migration is its own file and exactly `schemaStatements()` over its two entities
// (decision AT-D20: the entity definitions are the one source of a column, so a hand-written column
// cannot drift from the schema the module validates its rows with). A change to an entity changes
// this snapshot, which is the moment a customer must see a new migration in review. The MariaDB
// folder is the same tables in the other dialect, generated by `mysqlSchemaStatements` (spec §5.3:
// the dialect differences are explicit, one numbered folder per dialect), and the MongoDB folder is
// the same collections and indexes as declarative commands, generated by `mongoSchemaCommands`.
import { readFileSync } from "node:fs"
import { schemaStatements } from "@conusai-kit/data-kysely"
import { mysqlSchemaStatements } from "@conusai-kit/data-kysely/mysql"
import { MONGO_KERNEL_COMMANDS, mongoSchemaCommands } from "@conusai-kit/data-mongo"
import { describe, expect, it } from "vitest"
import { NOTES_ENTITIES, notesModule } from "../index.js"
/** The statements for these tables alone: `schemaStatements` leads with the kernel's own two. */
function tablesOf(entities: Parameters<typeof schemaStatements>[0]): string {
const kernel = schemaStatements([]).length
return `${schemaStatements(entities)
.slice(kernel)
.map((statement) => `${statement};`)
.join("\n\n")}\n`
}
/** The same for the MariaDB folder: `mysqlSchemaStatements` leads with the kernel's own two. */
function mysqlTablesOf(entities: Parameters<typeof mysqlSchemaStatements>[0]): string {
const kernel = mysqlSchemaStatements([]).length
return `${mysqlSchemaStatements(entities)
.slice(kernel)
.map((statement) => `${statement};`)
.join("\n\n")}\n`
}
/** The same for the MongoDB folder: `mongoSchemaCommands` leads with the kernel's own collections. */
function mongoCommandsOf(entities: Parameters<typeof mongoSchemaCommands>[0]): ReturnType<typeof mongoSchemaCommands> {
return mongoSchemaCommands(entities).slice(MONGO_KERNEL_COMMANDS.length)
}
describe("the migration of the notes module", () => {
it("is its own file, exactly its two tables", async () => {
await expect(tablesOf(NOTES_ENTITIES)).toMatchFileSnapshot("../migrations/0001_notes.sql")
})
it("is declared by the manifest, so the migrate job finds it, and every table is row-level secured", () => {
expect(notesModule.migrations).toEqual({
postgres: "migrations",
mariadb: "adapters/kysely/mysql/migrations",
mongodb: "adapters/mongo/migrations",
})
const sql = readFileSync(new URL("../migrations/0001_notes.sql", import.meta.url), "utf8")
for (const table of ["notes_note", "notes_attachment"]) {
expect(sql).toContain(`create table "${table}"`)
expect(sql).toContain(`alter table "${table}" force row level security`)
}
})
it("has the same two tables as MariaDB DDL, and no policies -- MariaDB has no row-level security", async () => {
const sql = readFileSync(new URL("../adapters/kysely/mysql/migrations/0001_notes.sql", import.meta.url), "utf8")
await expect(mysqlTablesOf(NOTES_ENTITIES)).toMatchFileSnapshot(
"../adapters/kysely/mysql/migrations/0001_notes.sql",
)
for (const table of ["notes_note", "notes_attachment"]) {
expect(sql).toContain(`create table \`${table}\``)
}
expect(sql).not.toContain("row level security")
})
it("has the same two collections as MongoDB commands, each index leading with org_id (AT3.2)", () => {
const file = new URL("../adapters/mongo/migrations/0001_notes.json", import.meta.url)
// The file is data (Biome keeps its JSON formatting canonical), so the pin is structural:
// exactly the commands `mongoSchemaCommands` generates for these entities, nothing else.
const commands = JSON.parse(readFileSync(file, "utf8")) as Array<Record<string, unknown>>
expect(commands).toEqual(mongoCommandsOf(NOTES_ENTITIES))
const created = commands
.filter((command) => "createCollection" in command)
.map((command) => command.createCollection)
expect(created).toEqual(["notes_attachment", "notes_note"])
// MongoDB has no row-level security: the wall is the repository predicate plus these
// org_id-leading indexes, and the contract suite of @conusai-kit/data-mongo is its proof.
const indexes = commands.filter((command) => "createIndex" in command)
for (const index of indexes) {
const keys = Object.keys((index.createIndex as { keys: Record<string, 1> }).keys)
expect(keys[0]).toBe("org_id")
}
// The validator is the entity's zod schema as $jsonSchema, carried by the file itself.
const note = commands.find((command) => command.createCollection === "notes_note") as {
validator: { $jsonSchema: { properties: Record<string, unknown>; required: string[] } }
}
expect(Object.keys(note.validator.$jsonSchema.properties)).toContain("org_id")
expect(note.validator.$jsonSchema.required).toContain("org_id")
})
})
+168
View File
@@ -0,0 +1,168 @@
// The components' markup, rendered on the server the way a page renders it: what a screen reader and
// a keyboard user get is in the HTML, so the accessibility basics are asserted there -- a name for
// every control, a caption and scoped headers for every table, a status that says what a colour would.
import { buildRegistry } from "@conusai-kit/core"
import { createI18n } from "@conusai-kit/i18n"
import { TransportProvider } from "@conusai-kit/ui"
import { renderToStaticMarkup } from "react-dom/server"
import { describe, expect, it } from "vitest"
import { appModules } from "../../index.js"
import type { NoteView } from "../contract/index.js"
import {
Attachments,
attachmentLabels,
createFormLabels,
DeleteNote,
deleteLabels,
editFormLabels,
listLabels,
NoteForm,
NoteList,
sizeLabel,
} from "../ui/index.js"
const catalogues = buildRegistry([...appModules]).catalogues
const i18n = createI18n("en", { en: { notes: catalogues.en?.notes ?? {} } })
const t = (key: string, values?: Record<string, string | number>) => i18n.message(`notes.${key}`, values)
const slugs = { org: "acme", workspace: "ws", project: "p" }
const failed = "That did not work: {detail}"
const inProvider = (node: React.ReactNode) =>
renderToStaticMarkup(
<TransportProvider call={async () => undefined} describe={() => "x"}>
{node}
</TransportProvider>,
)
const note = (over: Partial<NoteView> = {}): NoteView => ({
noteId: "n1",
title: "Kick-off",
body: "Agenda",
version: 3,
createdBy: "u1",
createdAt: "2026-04-01T08:00:00.000Z",
updatedAt: "2026-04-02T08:00:00.000Z",
attachmentCount: 2,
...over,
})
describe("the note list", () => {
const html = renderToStaticMarkup(
<NoteList
notes={[note(), note({ noteId: "n2", title: "Retro" })]}
hrefFor={(id) => `/acme/ws/p/notes/${id}`}
labels={listLabels(t)}
formatDate={(iso) => iso.slice(0, 10)}
/>,
)
it("is a table with a caption and a scoped header for every column", () => {
expect(html).toContain("<caption>Notes</caption>")
expect(html.match(/<th scope="col">/g)).toHaveLength(3)
expect(html).toContain('<th scope="row">')
})
it("names each link by its note, so a list of links is not a list of the same word", () => {
expect(html).toContain('aria-label="Open Kick-off"')
expect(html).toContain('aria-label="Open Retro"')
expect(html).toContain('href="/acme/ws/p/notes/n1"')
})
it("says so in words when there is nothing to list", () => {
const empty = renderToStaticMarkup(
<NoteList notes={[]} hrefFor={() => ""} labels={listLabels(t)} formatDate={() => ""} />,
)
expect(empty).toBe('<p data-guide="notes.note-list">No notes yet.</p>')
})
})
describe("the note form", () => {
const html = inProvider(
<NoteForm
slugs={slugs}
target={{ mode: "create", listHref: "/acme/ws/p/notes" }}
labels={createFormLabels(t, failed)}
/>,
)
it("labels every field", () => {
for (const name of ["title", "body"]) {
const id = new RegExp(`<(?:input|textarea) [^>]*\\bid="([^"]+)"[^>]*\\bname="${name}"`).exec(html)?.[1]
expect(id, name).toBeTruthy()
expect(html).toMatch(new RegExp(`<label for="${id}">`))
}
})
it("starts empty to create, and carries the note's words to edit", () => {
expect(html).toContain("New note")
const edit = inProvider(
<NoteForm
slugs={slugs}
target={{ mode: "edit", noteId: "n1", version: 3, title: "Kick-off", body: "Agenda" }}
labels={editFormLabels(t, failed)}
/>,
)
expect(edit).toContain("Edit note")
expect(edit).toContain('value="Kick-off"')
expect(edit).toContain(">Agenda</textarea>")
})
it("announces its outcome in a status region", () => {
expect(html).toContain('<p role="status">')
})
})
describe("the attachments", () => {
const render = (enabled: boolean, rows: Parameters<typeof Attachments>[0]["rows"]) =>
inProvider(
<Attachments
slugs={slugs}
noteId="n1"
rows={rows}
enabled={enabled}
accept={["text/plain", "application/pdf"]}
labels={attachmentLabels(t, failed, rows.length)}
/>,
)
const rows = [
{ attachmentId: "a1", fileId: "f1", filename: "minutes.txt", contentType: "text/plain", size: sizeLabel(t, 2048) },
]
it("lists each file in a captioned table and names each remove button by its file", () => {
const html = render(true, rows)
expect(html).toContain("<caption>Attachments</caption>")
expect(html).toContain('<th scope="row">minutes.txt</th>')
expect(html).toContain("2 KB")
expect(html).toContain('aria-label="Remove minutes.txt"')
expect(html).toContain('href="/api/v1/files.file.download?org=acme&amp;workspace=ws&amp;project=p&amp;fileId=f1"')
expect(html).toContain('aria-label="Download minutes.txt"')
expect(html).toContain("1 attachment")
})
it("labels its file input, describes it with the hint, and asks for the types it accepts", () => {
const html = render(true, [])
const id = /<input [^>]*\bid="([^"]+)"[^>]*\bname="file"/.exec(html)?.[1]
expect(id).toBeTruthy()
expect(html).toContain(`<label for="${id}">Attach a file</label>`)
expect(html).toContain("aria-describedby=")
expect(html).toContain('accept="text/plain,application/pdf"')
expect(html).toContain("Nothing is attached.")
})
it("offers no upload when the feature flag is off, and says why", () => {
const html = render(false, [])
expect(html).not.toContain('type="file"')
expect(html).toContain("Attachments are switched off for this project.")
})
})
describe("the delete button", () => {
it("asks first: the destructive button is not on the page until the person has said yes to a question", () => {
const html = inProvider(
<DeleteNote slugs={slugs} noteId="n1" listHref="/acme/ws/p/notes" labels={deleteLabels(t, failed)} />,
)
expect(html).toContain("Delete note")
expect(html).not.toContain("Yes, delete the note")
})
})
+15
View File
@@ -0,0 +1,15 @@
// The notes module on the Convex profile (spec §5.4). Nothing here re-implements the module:
// the manifest already declares the same `repositoryBindings(...NOTES_ENTITIES)` under the
// `convex` key (module.ts), and the generator (`pnpm convex:gen`) turns the module's procedure
// contracts into convex/generated/notes.ts and its entities into the tables of convex/schema.ts.
//
// What a module's adapters/convex/ folder is FOR, when it needs anything at all: the
// profile-specific pieces that cannot be derived from the contract -- a capability query that
// drops to a Convex index range or a vector index (spec §5.2), or a scheduled function only this
// module runs. Notes needs none of that, so this file is the proof: the module runs unchanged.
import { repositoryBindings } from "@conusai-kit/data"
import { NOTES_ENTITIES } from "../../contract/index.js"
/** The manifest's convex binding, kept next to the profile's other pieces for greppability. */
export const notesConvexBindings = repositoryBindings(...NOTES_ENTITIES)
@@ -0,0 +1,33 @@
create table `notes_attachment` (
`id` char(36) primary key,
`version` integer not null,
`created_at` datetime(6) not null,
`updated_at` datetime(6) not null,
`org_id` char(36) not null,
`workspace_id` char(36) not null,
`project_id` char(36) not null,
`note_id` char(36) not null,
`file_id` char(36) not null,
`attached_by` char(36) not null
) engine=InnoDB default charset=utf8mb4;
alter table `notes_attachment` add constraint `notes_attachment_org_id_workspace_id_project_id_note_id_f729c083` unique (`org_id`, `workspace_id`, `project_id`, `note_id`, `file_id`);
create index `notes_attachment_org_id_workspace_id_project_id_note_id_idx` on `notes_attachment` (`org_id`, `workspace_id`, `project_id`, `note_id`);
create index `notes_attachment_org_id_workspace_id_project_id_file_id_idx` on `notes_attachment` (`org_id`, `workspace_id`, `project_id`, `file_id`);
create table `notes_note` (
`id` char(36) primary key,
`version` integer not null,
`created_at` datetime(6) not null,
`updated_at` datetime(6) not null,
`org_id` char(36) not null,
`workspace_id` char(36) not null,
`project_id` char(36) not null,
`title` text not null,
`body` text not null,
`created_by` char(36) not null
) engine=InnoDB default charset=utf8mb4;
create index `notes_note_org_id_workspace_id_project_id_created_by_idx` on `notes_note` (`org_id`, `workspace_id`, `project_id`, `created_by`);
@@ -0,0 +1,177 @@
[
{
"createCollection": "notes_attachment",
"validator": {
"$jsonSchema": {
"bsonType": "object",
"required": [
"_id",
"version",
"created_at",
"updated_at",
"org_id",
"workspace_id",
"project_id",
"note_id",
"file_id",
"attached_by"
],
"properties": {
"_id": {
"bsonType": "string",
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
},
"version": {
"bsonType": "number"
},
"created_at": {
"bsonType": "string",
"pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$"
},
"updated_at": {
"bsonType": "string",
"pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$"
},
"org_id": {
"bsonType": "string",
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
},
"workspace_id": {
"bsonType": "string",
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
},
"project_id": {
"bsonType": "string",
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
},
"note_id": {
"bsonType": "string",
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
},
"file_id": {
"bsonType": "string",
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
},
"attached_by": {
"bsonType": "string",
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
}
},
"additionalProperties": false
}
}
},
{
"createIndex": {
"collection": "notes_attachment",
"keys": {
"org_id": 1,
"workspace_id": 1,
"project_id": 1,
"note_id": 1,
"file_id": 1
},
"name": "notes_attachment_org_id_workspace_id_project_id_note_id_file_id_key",
"unique": true
}
},
{
"createIndex": {
"collection": "notes_attachment",
"keys": {
"org_id": 1,
"workspace_id": 1,
"project_id": 1,
"note_id": 1
},
"name": "notes_attachment_org_id_workspace_id_project_id_note_id_idx",
"unique": false
}
},
{
"createIndex": {
"collection": "notes_attachment",
"keys": {
"org_id": 1,
"workspace_id": 1,
"project_id": 1,
"file_id": 1
},
"name": "notes_attachment_org_id_workspace_id_project_id_file_id_idx",
"unique": false
}
},
{
"createCollection": "notes_note",
"validator": {
"$jsonSchema": {
"bsonType": "object",
"required": [
"_id",
"version",
"created_at",
"updated_at",
"org_id",
"workspace_id",
"project_id",
"title",
"body",
"created_by"
],
"properties": {
"_id": {
"bsonType": "string",
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
},
"version": {
"bsonType": "number"
},
"created_at": {
"bsonType": "string",
"pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$"
},
"updated_at": {
"bsonType": "string",
"pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$"
},
"org_id": {
"bsonType": "string",
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
},
"workspace_id": {
"bsonType": "string",
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
},
"project_id": {
"bsonType": "string",
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
},
"title": {
"bsonType": "string"
},
"body": {
"bsonType": "string"
},
"created_by": {
"bsonType": "string",
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
}
},
"additionalProperties": false
}
}
},
{
"createIndex": {
"collection": "notes_note",
"keys": {
"org_id": 1,
"workspace_id": 1,
"project_id": 1,
"created_by": 1
},
"name": "notes_note_org_id_workspace_id_project_id_created_by_idx",
"unique": false
}
}
]
+80
View File
@@ -0,0 +1,80 @@
// The handlers of the module's procedures: each resolves its ports from the request's container and
// calls one use case. No rule lives here.
import type { Container } from "@conusai-kit/core"
import { implement } from "@conusai-kit/core"
import { DATA_SOURCE } from "@conusai-kit/data"
import { filesDeps } from "@conusai-kit/files"
import {
attachmentAdd,
attachmentRemove,
noteCreate,
noteDelete,
noteGet,
noteList,
noteUpdate,
} from "../contract/index.js"
import {
addAttachment,
createNote,
deleteNote,
getNote,
listNotes,
removeAttachment,
updateNote,
} from "../core/notes.js"
import { flagsOf, type NotesDeps } from "../core/ports.js"
/** The ports a handler needs: this module's data source, the files module's, and the flags. */
export function notesDeps(container: Container): NotesDeps {
return { data: container.resolve(DATA_SOURCE), files: filesDeps(container), flags: flagsOf(container) }
}
export const noteListImpl = implement(noteList, ({ input, ctx }) =>
listNotes(notesDeps(ctx.container), ctx.scope, {
...(input.cursor === undefined ? {} : { cursor: input.cursor }),
...(input.limit === undefined ? {} : { limit: input.limit }),
}),
)
export const noteGetImpl = implement(noteGet, ({ input, ctx }) =>
getNote(notesDeps(ctx.container), ctx.scope, input.noteId),
)
export const noteCreateImpl = implement(noteCreate, ({ input, ctx }) =>
createNote(notesDeps(ctx.container), ctx.scope, { title: input.title, body: input.body }),
)
export const noteUpdateImpl = implement(noteUpdate, ({ input, ctx }) =>
updateNote(notesDeps(ctx.container), ctx.scope, {
noteId: input.noteId,
title: input.title,
body: input.body,
expectedVersion: input.expectedVersion,
}),
)
export const noteDeleteImpl = implement(noteDelete, async ({ input, ctx }) => {
await deleteNote(notesDeps(ctx.container), ctx.scope, input.noteId)
})
export const attachmentAddImpl = implement(attachmentAdd, ({ input, ctx }) =>
addAttachment(notesDeps(ctx.container), ctx.scope, { noteId: input.noteId, fileId: input.fileId }),
)
export const attachmentRemoveImpl = implement(attachmentRemove, async ({ input, ctx }) => {
await removeAttachment(notesDeps(ctx.container), ctx.scope, {
noteId: input.noteId,
attachmentId: input.attachmentId,
})
})
export const NOTE_PROCEDURES = [
noteListImpl,
noteGetImpl,
noteCreateImpl,
noteUpdateImpl,
noteDeleteImpl,
attachmentAddImpl,
attachmentRemoveImpl,
]
+32
View File
@@ -0,0 +1,32 @@
// The module's closed vocabularies and names, in a file with no import: a browser bundle can take
// them without loading the data layer the schemas live beside.
/** The audit events this module writes (spec §9.4), declared in its manifest. */
export const NOTE_CREATED = "notes.note_created"
export const NOTE_UPDATED = "notes.note_updated"
export const NOTE_DELETED = "notes.note_deleted"
export const ATTACHMENT_ADDED = "notes.attachment_added"
export const ATTACHMENT_REMOVED = "notes.attachment_removed"
/** The usage meter: one fact per note created, metered against the organization that owns it. */
export const NOTES_CREATED_METER = "notes_created"
/**
* The module's feature flag and its default. An app or the platform's flag service may switch
* attachments off per organization or per deployment by binding `NOTES_FLAGS`; with nothing bound
* the default below is the answer, so a deployment that has no flag service still behaves.
*/
export const ATTACHMENTS_FLAG = "notes.attachments"
export const NOTES_FLAG_DEFAULTS = { [ATTACHMENTS_FLAG]: true } as const
/** The most attachments one note may carry: a note is a note, not a folder. */
export const MAX_ATTACHMENTS = 20
/** The content types an attachment may have. The server enforces them; the file picker only hints. */
export const ATTACHMENT_CONTENT_TYPES = [
"application/pdf",
"image/png",
"image/jpeg",
"image/webp",
"text/plain",
] as const
+38
View File
@@ -0,0 +1,38 @@
// The tables of the notes module. Two, both project-owned.
//
// `notes_note` is the note. `notes_attachment` links a note to a row of the files module: the file
// id is an opaque id and never a foreign key (spec §4.4), and the bytes, the size, the digest and
// the object in the Blob store stay the files module's, so there is one canonical copy of a file.
import { defineEntity, idSchema } from "@conusai-kit/data"
import { z } from "zod"
export const notes = defineEntity({
name: "note",
table: "notes_note",
tenancy: "project",
fields: {
title: z.string().min(1).max(200),
body: z.string().max(20_000),
createdBy: idSchema,
},
indexes: [["createdBy"]],
})
export const attachments = defineEntity({
name: "attachment",
table: "notes_attachment",
tenancy: "project",
fields: {
noteId: idSchema,
fileId: idSchema,
attachedBy: idSchema,
},
// A file is attached to a note once; attaching it again is the call it already answered.
unique: [["noteId", "fileId"]],
// The file's own index: deleting a file looks its attachments up by it.
indexes: [["noteId"], ["fileId"]],
})
/** Every table this module owns, in the order a migration creates them. */
export const NOTES_ENTITIES = Object.freeze([notes, attachments])
+43
View File
@@ -0,0 +1,43 @@
// The module's domain events (spec §8). Each is written to the outbox by the transaction that made
// the change, so a rolled-back write publishes nothing and the relay delivers it exactly once per
// subscriber. The audit event and the usage fact of this module hang off these records, not off
// the request that happened to run (core/subscribers.ts).
import { defineEvent } from "@conusai-kit/core"
import { z } from "zod"
const inProject = { orgId: z.string(), workspaceId: z.string(), projectId: z.string() }
export const noteCreated = defineEvent(
"notes.NoteCreated",
z.object({ ...inProject, noteId: z.string(), createdBy: z.string() }),
)
export const noteUpdated = defineEvent(
"notes.NoteUpdated",
z.object({ ...inProject, noteId: z.string(), version: z.number().int(), updatedBy: z.string() }),
)
export const noteDeleted = defineEvent(
"notes.NoteDeleted",
z.object({ ...inProject, noteId: z.string(), deletedBy: z.string(), attachmentCount: z.number().int() }),
)
export const attachmentAdded = defineEvent(
"notes.AttachmentAdded",
z.object({ ...inProject, noteId: z.string(), attachmentId: z.string(), fileId: z.string(), attachedBy: z.string() }),
)
export const attachmentRemoved = defineEvent(
"notes.AttachmentRemoved",
z.object({
...inProject,
noteId: z.string(),
attachmentId: z.string(),
fileId: z.string(),
removedBy: z.string(),
}),
)
/** Every event this module publishes, for the manifest. */
export const NOTES_EVENTS = [noteCreated, noteUpdated, noteDeleted, attachmentAdded, attachmentRemoved]
+37
View File
@@ -0,0 +1,37 @@
// The public API of the notes module: what a page, another module and the OpenAPI document may
// import. Nothing here reaches core/, adapters/ or a driver.
export {
ATTACHMENT_ADDED,
ATTACHMENT_CONTENT_TYPES,
ATTACHMENT_REMOVED,
ATTACHMENTS_FLAG,
MAX_ATTACHMENTS,
NOTE_CREATED,
NOTE_DELETED,
NOTE_UPDATED,
NOTES_CREATED_METER,
NOTES_FLAG_DEFAULTS,
} from "./constants.js"
export { attachments, NOTES_ENTITIES, notes } from "./entities.js"
export {
attachmentAdded,
attachmentRemoved,
NOTES_EVENTS,
noteCreated,
noteDeleted,
noteUpdated,
} from "./events.js"
export {
type AttachmentView,
attachmentAdd,
attachmentRemove,
attachmentViewSchema,
type NoteView,
noteCreate,
noteDelete,
noteGet,
noteList,
noteUpdate,
noteViewSchema,
} from "./procedures.js"
+105
View File
@@ -0,0 +1,105 @@
// The procedure contracts of the notes module: schemas and names, no handler and no port.
//
// An attachment is uploaded through the files module's own upload procedures (`files.file.beginUpload`,
// `files.file.putPart` and `files.file.completeUpload`: the bytes go to the platform's Blob store,
// which is where the usage fact and the audit event of an upload are recorded), and then linked to a
// note by `notes.attachment.add`. This module never takes bytes.
import { procedure } from "@conusai-kit/core"
import { z } from "zod"
const inProject = { org: z.string(), workspace: z.string(), project: z.string() }
const title = z.string().min(1).max(200)
const body = z.string().max(20_000)
export const noteViewSchema = z.object({
noteId: z.string(),
title: z.string(),
body: z.string(),
/** What `notes.note.update` is called with as `expectedVersion`. */
version: z.number().int(),
createdBy: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
attachmentCount: z.number().int(),
})
export type NoteView = z.output<typeof noteViewSchema>
export const attachmentViewSchema = z.object({
attachmentId: z.string(),
fileId: z.string(),
filename: z.string(),
contentType: z.string(),
size: z.number().int(),
attachedAt: z.string(),
})
export type AttachmentView = z.output<typeof attachmentViewSchema>
export const noteList = procedure("notes.note.list")
.kind("read")
.scope("project")
.permission("notes.note.read")
.tool()
.input(z.object({ ...inProject, cursor: z.string().optional(), limit: z.number().int().min(1).max(200).optional() }))
.output(z.object({ notes: z.array(noteViewSchema), cursor: z.string().optional(), hasMore: z.boolean() }))
.contract()
export const noteGet = procedure("notes.note.get")
.kind("read")
.scope("project")
.permission("notes.note.read")
.input(z.object({ ...inProject, noteId: z.string() }))
.output(
z.object({
note: noteViewSchema,
attachments: z.array(attachmentViewSchema),
/** The feature flag's answer for this project, so a page does not offer what a call would refuse. */
attachmentsEnabled: z.boolean(),
}),
)
.contract()
// No `.audit()` and no `.usage()` on the write contracts: this module's audit events and usage
// facts are written by the subscribers to its outbox records (core/subscribers.ts), which is the
// exactly-once path — the transport's own `.audit()`/`.usage()` recording would write each one a
// second time (and under a different customer key).
export const noteCreate = procedure("notes.note.create")
.kind("write")
.scope("project")
.permission("notes.note.write")
.input(z.object({ ...inProject, title, body }))
.output(noteViewSchema)
.contract()
export const noteUpdate = procedure("notes.note.update")
.kind("write")
.scope("project")
.permission("notes.note.write")
.input(z.object({ ...inProject, noteId: z.string(), title, body, expectedVersion: z.number().int().min(1) }))
.output(noteViewSchema)
.contract()
export const noteDelete = procedure("notes.note.delete")
.kind("write")
.scope("project")
.permission("notes.note.delete")
.input(z.object({ ...inProject, noteId: z.string() }))
.contract()
export const attachmentAdd = procedure("notes.attachment.add")
.kind("write")
.scope("project")
.permission("notes.attachment.write")
.input(z.object({ ...inProject, noteId: z.string(), fileId: z.string() }))
.output(attachmentViewSchema)
.contract()
export const attachmentRemove = procedure("notes.attachment.remove")
.kind("write")
.scope("project")
.permission("notes.attachment.write")
.input(z.object({ ...inProject, noteId: z.string(), attachmentId: z.string() }))
.contract()
+294
View File
@@ -0,0 +1,294 @@
// What the notes module does, as pure TypeScript over the ports in ports.ts. Every write is one
// transaction with the event that announces it, so the audit event and the usage fact of a change
// exist exactly when the change does (core/subscribers.ts reads the events).
//
// The two things this module does not own are the file and the permission to see it: an attachment
// is a row of the files module, reached through `getFile`, which answers NotFound for another
// tenant's file and for one that does not exist alike (spec §3.3).
import { conflict, forbidden, notFound, validationFailed } from "@conusai-kit/core"
import { type FileView, getFile } from "@conusai-kit/files"
import type { z } from "zod"
import {
ATTACHMENT_CONTENT_TYPES,
type AttachmentView,
attachmentAdded,
attachmentRemoved,
attachments,
MAX_ATTACHMENTS,
type NoteView,
noteCreated,
noteDeleted,
notes,
noteUpdated,
} from "../contract/index.js"
import { attachmentsEnabled, type NoteScope, type NotesDeps } from "./ports.js"
type NoteRow = z.output<typeof notes.schema>
const PAGE = 200
class AttachmentConcurrencyConflict extends Error {
constructor(readonly original: unknown) {
super("the note changed while another attachment was being added")
}
}
const ids = (scope: NoteScope) => ({
orgId: scope.orgId,
workspaceId: scope.workspaceId,
projectId: scope.projectId,
})
const noteView = (row: NoteRow, attachmentCount: number): NoteView => ({
noteId: row.id,
title: row.title,
body: row.body,
version: row.version,
createdBy: row.createdBy,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
attachmentCount,
})
function attachmentView(
row: { id: string; fileId: string; createdAt: string },
file: Pick<FileView, "filename" | "contentType" | "size">,
): AttachmentView {
return {
attachmentId: row.id,
fileId: row.fileId,
filename: file.filename,
contentType: file.contentType,
size: file.size,
attachedAt: row.createdAt,
}
}
export async function listNotes(
deps: NotesDeps,
scope: NoteScope,
options: { cursor?: string; limit?: number },
): Promise<{ notes: NoteView[]; cursor?: string; hasMore: boolean }> {
const repos = deps.data.forScope(scope)
const page = await repos.of(notes).list({
order: [{ field: "createdAt", direction: "desc" }],
...(options.limit === undefined ? {} : { limit: options.limit }),
...(options.cursor === undefined ? {} : { cursor: options.cursor }),
})
// The attachment counts of the page's notes in one read over the note index, not one count per
// note (up to 200 queries for one list).
const counts = new Map<string, number>()
if (page.items.length > 0) {
let linksCursor: string | undefined
do {
const attachmentsPage = await repos.of(attachments).list({
where: [{ field: "noteId", op: "in", values: page.items.map((row) => row.id) }],
limit: PAGE,
...(linksCursor === undefined ? {} : { cursor: linksCursor }),
})
for (const link of attachmentsPage.items) counts.set(link.noteId, (counts.get(link.noteId) ?? 0) + 1)
linksCursor = attachmentsPage.hasMore ? attachmentsPage.cursor : undefined
} while (linksCursor !== undefined)
}
return {
notes: page.items.map((row) => noteView(row, counts.get(row.id) ?? 0)),
...(page.cursor === undefined ? {} : { cursor: page.cursor }),
hasMore: page.hasMore,
}
}
async function attachmentRows(deps: NotesDeps, scope: NoteScope, noteId: string) {
const found = []
let cursor: string | undefined
do {
const page = await deps.data
.forScope(scope)
.of(attachments)
.list({
where: [{ field: "noteId", op: "eq", value: noteId }],
order: [{ field: "createdAt", direction: "asc" }],
limit: PAGE,
...(cursor === undefined ? {} : { cursor }),
})
found.push(...page.items)
cursor = page.hasMore ? page.cursor : undefined
} while (cursor !== undefined)
return found
}
export async function getNote(
deps: NotesDeps,
scope: NoteScope,
noteId: string,
): Promise<{ note: NoteView; attachments: AttachmentView[]; attachmentsEnabled: boolean }> {
// NotFound for another tenant's note and for one nobody wrote: the repository answers both the same.
const row = await deps.data.forScope(scope).of(notes).get(noteId)
if (!row) throw notFound("no such note")
const [links, enabled] = await Promise.all([attachmentRows(deps, scope, row.id), attachmentsEnabled(deps, scope)])
// The files are read together, not one after another: a note carries at most MAX_ATTACHMENTS, and
// a deleted one (before the relay removed its links) is not a reason to fail the page.
const files = await Promise.all(
links.map((link) =>
getFile(deps.files, scope, link.fileId).catch((error: unknown) => {
if ((error as { status?: number }).status === 404) return undefined
throw error
}),
),
)
const views: AttachmentView[] = []
for (const [at, link] of links.entries()) {
const file = files[at]
if (file) views.push(attachmentView(link, file))
}
return {
note: noteView(row, views.length),
attachments: views,
attachmentsEnabled: enabled,
}
}
export async function createNote(
deps: NotesDeps,
scope: NoteScope,
input: { title: string; body: string },
): Promise<NoteView> {
return deps.data.unitOfWork.run(scope, async (tx) => {
const row = await tx.of(notes).insert({ title: input.title, body: input.body, createdBy: scope.actor.userId })
await tx.publish(noteCreated, { ...ids(scope), noteId: row.id, createdBy: scope.actor.userId })
return noteView(row, 0)
})
}
export async function updateNote(
deps: NotesDeps,
scope: NoteScope,
input: { noteId: string; title: string; body: string; expectedVersion: number },
): Promise<NoteView> {
return deps.data.unitOfWork.run(scope, async (tx) => {
const current = await tx.of(notes).get(input.noteId)
if (!current) throw notFound("no such note")
// The repository answers 409 when the version is not the row's: two editors never overwrite each other silently.
const row = await tx.of(notes).update(current.id, { title: input.title, body: input.body }, input.expectedVersion)
await tx.publish(noteUpdated, {
...ids(scope),
noteId: row.id,
version: row.version,
updatedBy: scope.actor.userId,
})
const count = await tx.of(attachments).count({ where: [{ field: "noteId", op: "eq", value: row.id }] })
return noteView(row, count)
})
}
/**
* Deletes the note and its links. The files stay: they are the project's, they may be attached to
* another note, and removing one is `files.file.delete`'s decision, with its own permission.
*/
export async function deleteNote(deps: NotesDeps, scope: NoteScope, noteId: string): Promise<void> {
await deps.data.unitOfWork.run(scope, async (tx) => {
const row = await tx.of(notes).get(noteId)
if (!row) throw notFound("no such note")
let removed = 0
for (;;) {
const page = await tx.of(attachments).list({ where: [{ field: "noteId", op: "eq", value: row.id }], limit: PAGE })
for (const link of page.items) {
await tx.of(attachments).delete(link.id)
removed += 1
}
if (!page.hasMore) break
}
await tx.of(notes).delete(row.id)
await tx.publish(noteDeleted, {
...ids(scope),
noteId: row.id,
deletedBy: scope.actor.userId,
attachmentCount: removed,
})
})
}
/**
* Links an uploaded file to a note. Idempotent: attaching a file twice answers with the first link
* and publishes nothing, so a retried request audits once.
*/
export async function addAttachment(
deps: NotesDeps,
scope: NoteScope,
input: { noteId: string; fileId: string },
): Promise<AttachmentView> {
if (!(await attachmentsEnabled(deps, scope))) throw forbidden("attachments are switched off")
const file = await getFile(deps.files, scope, input.fileId)
// The files module accepts more than a note should carry (spreadsheets, say); this module's list is its own.
if (!(ATTACHMENT_CONTENT_TYPES as readonly string[]).includes(file.contentType)) {
throw validationFailed([{ path: "fileId", message: `${file.contentType} cannot be attached to a note` }])
}
// The count and insert are one transaction, but that alone does not serialize two transactions
// that attach different files to the same note. Optimistically update the note first: its
// expected-version write is the portable per-note lock across SQL, MongoDB and Convex. A loser
// retries the transaction and rechecks both idempotency and the quota against the winner's row.
for (let attempt = 0; ; attempt += 1) {
try {
return await deps.data.unitOfWork.run(scope, async (tx) => {
const noteRepo = tx.of(notes)
const note = await noteRepo.get(input.noteId)
if (!note) throw notFound("no such note")
const links = tx.of(attachments)
const existing = (
await links.list({
where: [
{ field: "noteId", op: "eq", value: note.id },
{ field: "fileId", op: "eq", value: file.fileId },
],
limit: 1,
})
).items[0]
if (existing) return attachmentView(existing, file)
try {
await noteRepo.update(note.id, {}, note.version)
} catch (error) {
if ((error as { status?: number }).status === 409) throw new AttachmentConcurrencyConflict(error)
throw error
}
if ((await links.count({ where: [{ field: "noteId", op: "eq", value: note.id }] })) >= MAX_ATTACHMENTS) {
throw conflict(`a note carries at most ${MAX_ATTACHMENTS} attachments`)
}
const link = await links.insert({ noteId: note.id, fileId: file.fileId, attachedBy: scope.actor.userId })
await tx.publish(attachmentAdded, {
...ids(scope),
noteId: note.id,
attachmentId: link.id,
fileId: file.fileId,
attachedBy: scope.actor.userId,
})
return attachmentView(link, file)
})
} catch (error) {
// A retry that loses the optimistic lock re-reads the winner's link or sees the full quota.
// Bound retries so sustained contention returns the repository's ordinary conflict response.
if (!(error instanceof AttachmentConcurrencyConflict)) throw error
if (attempt >= 7) throw error.original
}
}
}
export async function removeAttachment(
deps: NotesDeps,
scope: NoteScope,
input: { noteId: string; attachmentId: string },
): Promise<void> {
await deps.data.unitOfWork.run(scope, async (tx) => {
const link = await tx.of(attachments).get(input.attachmentId)
// The note id is part of the lookup, so a link of another note of this project is not found either.
if (!link || link.noteId !== input.noteId) throw notFound("no such attachment")
await tx.of(attachments).delete(link.id)
await tx.publish(attachmentRemoved, {
...ids(scope),
noteId: link.noteId,
attachmentId: link.id,
fileId: link.fileId,
removedBy: scope.actor.userId,
})
})
}
+39
View File
@@ -0,0 +1,39 @@
// The ports the notes module's use cases are written against, and the one it adds: the feature flags.
//
// The audit and usage sinks and the scope of an event delivery are the kernel's own tokens
// (`FILES_AUDIT`, `FILES_USAGE`, `FILES_SCOPE`): the worker binds them once for every module, so a
// module that audits and meters needs no binding of its own and no line in the worker.
import { type Container, type TenantScope, token } from "@conusai-kit/core"
import type { DataSource } from "@conusai-kit/data"
import type { FilesDeps } from "@conusai-kit/files"
import { ATTACHMENTS_FLAG, NOTES_FLAG_DEFAULTS } from "../contract/index.js"
/** A project scope: the only level a note can live at. */
export type NoteScope = TenantScope & { workspaceId: string; projectId: string }
export interface NotesDeps {
readonly data: DataSource
/** The files module's ports: an attachment is one of its files. */
readonly files: FilesDeps
readonly flags: NotesFlags
}
/** What a flag service answers for the project a call runs in. */
export interface NotesFlags {
enabled(flag: keyof typeof NOTES_FLAG_DEFAULTS, scope: NoteScope): boolean | Promise<boolean>
}
/**
* Bound by an app that evaluates flags per organization, project or user (the platform's flag
* service, through `@conusai-sdk/node-sdk/flags`). With nothing bound, a flag is its manifest default.
*/
export const NOTES_FLAGS = token<NotesFlags>("notes.flags")
export const defaultFlags: NotesFlags = { enabled: (flag) => NOTES_FLAG_DEFAULTS[flag] }
export const flagsOf = (container: Container): NotesFlags =>
container.has(NOTES_FLAGS) ? container.resolve(NOTES_FLAGS) : defaultFlags
export const attachmentsEnabled = async (deps: NotesDeps, scope: NoteScope): Promise<boolean> =>
deps.flags.enabled(ATTACHMENTS_FLAG, scope)
+165
View File
@@ -0,0 +1,165 @@
// What happens exactly once per change.
//
// The audit event and the usage fact hang off the outbox record the changing transaction wrote, and
// the relay delivers a record exactly once per subscriber, so a note created by a retried request
// is audited and metered once, and one whose process died before the relay ran still is, because
// the record committed with the row. The platform's audit and usage APIs are HTTP and cannot join
// the transaction: each call carries a key derived from the record, so a delivery the relay repeats
// after a crash is the same event to the platform and not a second one.
//
// Ids, counts and versions only: no title and no body in an audit event (spec §9.4).
import { type EventContext, internal } from "@conusai-kit/core"
import { DATA_SOURCE } from "@conusai-kit/data"
import { auditEventId, FILES_AUDIT, FILES_SCOPE, FILES_USAGE } from "@conusai-kit/files"
import {
ATTACHMENT_ADDED,
ATTACHMENT_REMOVED,
attachments,
NOTE_CREATED,
NOTE_DELETED,
NOTE_UPDATED,
NOTES_CREATED_METER,
} from "../contract/index.js"
interface InProject {
orgId: string
workspaceId: string
projectId: string
}
/**
* The organization a delivery belongs to comes from the container -- the scope the relay rebuilt
* from the outbox record's own `orgId` -- and is compared with the payload's: a record that named
* another tenant is refused here rather than written there.
*/
function assertSameOrg(ctx: EventContext, payload: InProject): void {
const ambient = ctx.container.resolve(FILES_SCOPE)
if (ambient.orgId !== payload.orgId) {
throw internal(`a notes event of organization ${payload.orgId} was delivered in ${ambient.orgId}`)
}
}
interface AuditShape<P extends InProject> {
action: string
actor: (payload: P) => string
target: (payload: P) => { id: string; type: string }
/** What makes two events of one action on one target two events: a version, a link's id. */
occurrence: (payload: P) => string
data: (payload: P) => Record<string, unknown>
}
/** A subscriber that writes one audit event per record. */
export function auditing<P extends InProject>(shape: AuditShape<P>) {
return async (event: { name: string; payload: P }, ctx: EventContext): Promise<void> => {
const { payload } = event
assertSameOrg(ctx, payload)
await ctx.container.resolve(FILES_AUDIT).log(shape.action, {
actor: { kind: "human", id: shape.actor(payload) },
target: shape.target(payload),
event_id: await auditEventId(shape.action, shape.occurrence(payload)),
data: { ...shape.data(payload), projectId: payload.projectId },
})
}
}
type Payloads = {
created: InProject & { noteId: string; createdBy: string }
updated: InProject & { noteId: string; version: number; updatedBy: string }
deleted: InProject & { noteId: string; deletedBy: string; attachmentCount: number }
added: InProject & { noteId: string; attachmentId: string; fileId: string; attachedBy: string }
removed: InProject & { noteId: string; attachmentId: string; fileId: string; removedBy: string }
}
// The organization has to be in the target or the in-app viewer could not show one
// organization's events without another's (apps/web/src/server/audit.ts): a resource target is
// `<org id>/<resource id>` here, the id being what an outbox payload carries; the transport
// writes the same rule with the slug it has, and the viewer matches either.
const note = (payload: InProject & { noteId: string }) => ({ id: `${payload.orgId}/${payload.noteId}`, type: "note" })
const auditCreated = auditing<Payloads["created"]>({
action: NOTE_CREATED,
actor: (p) => p.createdBy,
target: note,
occurrence: (p) => p.noteId,
data: () => ({}),
})
const auditUpdated = auditing<Payloads["updated"]>({
action: NOTE_UPDATED,
actor: (p) => p.updatedBy,
target: note,
occurrence: (p) => `${p.noteId}:${p.version}`,
data: (p) => ({ version: p.version }),
})
const auditDeleted = auditing<Payloads["deleted"]>({
action: NOTE_DELETED,
actor: (p) => p.deletedBy,
target: note,
occurrence: (p) => p.noteId,
data: (p) => ({ attachmentCount: p.attachmentCount }),
})
const auditAdded = auditing<Payloads["added"]>({
action: ATTACHMENT_ADDED,
actor: (p) => p.attachedBy,
target: note,
occurrence: (p) => p.attachmentId,
data: (p) => ({ attachmentId: p.attachmentId, fileId: p.fileId }),
})
const auditRemoved = auditing<Payloads["removed"]>({
action: ATTACHMENT_REMOVED,
actor: (p) => p.removedBy,
target: note,
occurrence: (p) => p.attachmentId,
data: (p) => ({ attachmentId: p.attachmentId, fileId: p.fileId }),
})
/** One audit event and one usage fact per note created. */
export async function onNoteCreated(event: { name: string; payload: Payloads["created"] }, ctx: EventContext) {
await auditCreated(event, ctx)
const usage = ctx.container.resolve(FILES_USAGE)
await usage.track({
customer: { externalId: event.payload.orgId },
meter: NOTES_CREATED_METER,
quantity: 1,
idempotencyKey: `${NOTES_CREATED_METER}.${event.payload.noteId}`,
})
// Awaited: a sink that sends per call is then durable before the relay marks the record published.
await usage.flush()
}
export const onNoteUpdated = auditUpdated
export const onNoteDeleted = auditDeleted
export const onAttachmentAdded = auditAdded
export const onAttachmentRemoved = auditRemoved
/**
* A file that stopped being stored takes its links with it, so a note never lists an attachment
* nobody can open. The scope is the delivery's own organization and a system actor with the two
* deeper ids of the record the files module wrote in that organization's transaction.
*/
export async function onFileDeleted(
event: { name: string; payload: InProject & { fileId: string } },
ctx: EventContext,
): Promise<void> {
const { payload } = event
assertSameOrg(ctx, payload)
const ambient = ctx.container.resolve(FILES_SCOPE)
const scope = {
level: "project" as const,
actor: ambient.actor,
orgId: ambient.orgId,
workspaceId: payload.workspaceId,
projectId: payload.projectId,
}
await ctx.container.resolve(DATA_SOURCE).unitOfWork.run(scope, async (tx) => {
for (;;) {
const page = await tx.of(attachments).list({ where: [{ field: "fileId", op: "eq", value: payload.fileId }] })
for (const link of page.items) await tx.of(attachments).delete(link.id)
if (!page.hasMore) break
}
})
}
+42
View File
@@ -0,0 +1,42 @@
"use client"
// The notes page's way into the feedback survey (feedback.guide.ts): a button that asks the guides
// module to start `notes.feedback` through the shared surface coordinator (`guides.start`, joint
// contract §12), the same command the support chat's help offers send. It shows only while the guides
// module serves that command, so an app without guides renders nothing here; `kit template:prune
// --without guides` removes it with this folder.
import type { SurfaceCoordinator } from "@conusai-kit/core"
import { useSurfaceCoordinator } from "@conusai-kit/ui"
import { type ReactElement, useState, useSyncExternalStore } from "react"
import type { FeedbackButtonLabels } from "./feedback-labels.js"
const never = () => () => undefined
export function FeedbackButton({ labels }: { labels: FeedbackButtonLabels }): ReactElement | null {
const coordinator = useSurfaceCoordinator<SurfaceCoordinator>()
const served = useSyncExternalStore(
coordinator ? (listener) => coordinator.subscribe(listener) : never,
() => coordinator?.serves("guides.start") ?? false,
() => false,
)
const [unavailable, setUnavailable] = useState(false)
if (!coordinator || !served) return null
const start = async () => {
const result = await coordinator.dispatch({
version: 1,
requestId: globalThis.crypto.randomUUID(),
type: "guides.start",
guideKey: "notes.feedback",
})
setUnavailable(result.status !== "ok")
}
return (
<p>
<button type="button" onClick={() => void start()}>
{labels.open}
</button>{" "}
<span role="status">{unavailable ? labels.unavailable : ""}</span>
</p>
)
}
+16
View File
@@ -0,0 +1,16 @@
// The feedback button's labels, resolved on the server: the notes page is a server component and
// cannot call a function of feedback-button.tsx, which is a client module (Next refuses it with
// "Attempted to call feedbackLabels() from the server"). `kit template:prune --without guides`
// removes it with this folder.
export interface FeedbackButtonLabels {
readonly open: string
/** Said when the survey cannot start: already answered, or not served to this person. */
readonly unavailable: string
}
type Translate = (key: string) => string
export function feedbackLabels(t: Translate): FeedbackButtonLabels {
return { open: t("feedback.open"), unavailable: t("feedback.unavailable") }
}
+69
View File
@@ -0,0 +1,69 @@
// The example code survey of the template (ADR-046): a short product-feedback questionnaire about
// notes, published at deploy with the module's other code guides. It starts only when the person asks
// for it -- the "Give feedback" button of the notes page (feedback-button.tsx) or the help offers of
// another module (the support chat lists it by its title) -- and runs once per person.
//
// Answers are stored per guide version on the platform and read in the console (Guides, the survey,
// Results); the free-text question shows a fixed hint not to enter personal data. The optional
// `screenshot` question takes up to three images or PDFs, each up to the project's survey file size
// (`max_file_size_mb: null`: data_policy.survey_file_max_mb, 10 MB unless configured), with the fixed
// hint that files must not contain personal data such as identity documents; the files go through
// this app's own server (guides.runtime.uploadFile) to the project's Blob store, and the console's
// Results list them. The project's guide settings must allow the `survey` kind (console, Guides,
// Settings) before it is served.
import { defineGuide } from "@conusai-kit/guides"
import { guideText } from "./messages.js"
const t = guideText.survey
export const feedbackSurvey = defineGuide({
key: "notes.feedback",
schema_version: 1,
kind: "survey",
default_locale: "en",
audience: { trait: { name: "role", in: ["owner", "admin", "member"] } },
trigger: { manual: true },
frequency: "once",
survey: {
title: t.title,
intro: t.intro,
questions: [
{
type: "rating",
id: "ease",
prompt: t.ease,
required: true,
scale: "1-5",
low_label: t.easeLow,
high_label: t.easeHigh,
},
{
type: "single_choice",
id: "use",
prompt: t.use,
required: false,
options: [
{ id: "meetings", label: t.useMeetings },
{ id: "ideas", label: t.useIdeas },
{ id: "tasks", label: t.useTasks },
],
allow_other: true,
},
{ type: "text", id: "improve", prompt: t.improve, required: false, multiline: true },
{
type: "file",
id: "screenshot",
prompt: t.screenshot,
required: false,
accept: ["image", "pdf"],
max_files: 3,
max_file_size_mb: null,
},
],
submit_label: null,
thank_you: t.thankYou,
allow_dismiss: true,
one_question_per_screen: true,
},
})
+50
View File
@@ -0,0 +1,50 @@
// The example code tour of the template (in-app guides spec §7.3): defined next to the module it
// explains, published to the deployment's environment by the deploy step, and run from memory with
// `pnpm dev` (the fake platform). Its steps point at the `data-guide` anchors of ui/pages.tsx and
// ui/note-form.tsx; `kit check` warns when one of them is removed.
//
// The audience is a trait the app sends (`role`, from the guides module): a viewer cannot add a note,
// so the tour is for the members who can. The platform compares only the traits the project's guide
// settings allow-list, so allow `role` there (console, Guides, Settings) before the tour shows.
import { defineGuide } from "@conusai-kit/guides"
import { guideText } from "./messages.js"
const t = guideText.tour
export const firstNoteTour = defineGuide({
key: "notes.first-note",
schema_version: 1,
kind: "tour",
default_locale: "en",
audience: { trait: { name: "role", in: ["owner", "admin", "member"] } },
trigger: { page: "/:org/:workspace/:project/notes" },
frequency: "once",
steps: [
{
id: "new",
type: "tooltip",
target: { anchor: "notes.new-note" },
title: t.newTitle,
body: t.newBody,
advance: { on: "next_button" },
},
{
id: "title",
type: "tooltip",
target: { anchor: "notes.note-title" },
title: t.titleTitle,
body: t.titleBody,
advance: { on: "next_button" },
},
{
id: "list",
type: "tooltip",
target: { anchor: "notes.note-list" },
title: t.listTitle,
body: t.listBody,
advance: { on: "next_button" },
},
{ id: "done", type: "modal", title: t.doneTitle, body: t.doneBody },
],
})
@@ -0,0 +1,29 @@
// The example checklist of the template: one task completes with the tour, one when the app reports
// `note_created` (the note form emits it, `emitAppEvent` of @conusai-kit/ui, once the note is saved),
// and one the person ticks.
import { defineGuide } from "@conusai-kit/guides"
import { guideText } from "./messages.js"
const t = guideText.checklist
export const gettingStartedChecklist = defineGuide({
key: "notes.getting-started",
schema_version: 1,
kind: "checklist",
default_locale: "en",
audience: { trait: { name: "role", in: ["owner", "admin", "member"] } },
trigger: { page: "/:org/:workspace/:project/notes" },
frequency: "until_completed",
title: t.title,
tasks: [
{
id: "tour",
title: t.tour,
guide: "notes.first-note",
condition: { type: "guide_completed", guide: "notes.first-note" },
},
{ id: "create", title: t.create, condition: { type: "event", name: "note_created" } },
{ id: "share", title: t.share, condition: { type: "manual" } },
],
})
+14
View File
@@ -0,0 +1,14 @@
// What the notes module contributes to the guides module: its code guides, published at deploy. The
// guides module is opt-in: `kit template:prune --without guides` removes this folder and the lines
// marked `kit:guides` that import it.
//
// A module contributes audience traits the same way, to `guidesTraits` ({ key, resolve }); the
// guides module itself contributes `role`, which the guides below use.
import { contribute } from "@conusai-kit/core"
import { guidesCode } from "@conusai-kit/guides"
import { feedbackSurvey } from "./feedback.guide.js"
import { firstNoteTour } from "./first-note.guide.js"
import { gettingStartedChecklist } from "./getting-started.guide.js"
export const NOTES_GUIDES = [contribute(guidesCode, [firstNoteTour, gettingStartedChecklist, feedbackSurvey])]
+55
View File
@@ -0,0 +1,55 @@
// The words of the notes module's guides, in both languages (spec §7.3: a guide's strings come from
// the module's catalogue). A guide's text is per locale, so each message here is one object of both.
const both = (en: string, lt: string) => ({ en, lt })
export const guideText = {
tour: {
newTitle: both("Write a note", "Parašykite pastabą"),
newBody: both(
"Give the note a title and some text, then add it. Notes belong to this project.",
"Suteikite pastabai pavadinimą ir tekstą, tada pridėkite. Pastabos priklauso šiam projektui.",
),
titleTitle: both("A short title", "Trumpas pavadinimas"),
titleBody: both(
"The title is what the list shows. Up to 200 characters.",
"Pavadinimą rodo sąrašas. Iki 200 simbolių.",
),
listTitle: both("Your notes", "Jūsų pastabos"),
listBody: both(
"Every note of the project is here. Open one to edit it or attach files.",
"Čia yra visos projekto pastabos. Atidarykite pastabą, kad ją redaguotumėte ar pridėtumėte failų.",
),
doneTitle: both("That is all", "Tai viskas"),
doneBody: both(
"You can run this tour again from the help menu.",
"Šį turą galite paleisti dar kartą iš pagalbos meniu.",
),
},
checklist: {
title: both("Get started with notes", "Pradėkite naudotis pastabomis"),
tour: both("Take the notes tour", "Peržiūrėkite pastabų turą"),
create: both("Add your first note", "Pridėkite pirmąją pastabą"),
share: both("Tell your team where the notes are", "Praneškite komandai, kur yra pastabos"),
},
survey: {
title: both("How are notes working for you?", "Kaip jums sekasi su pastabomis?"),
intro: both(
"Four short questions help us improve notes. It takes about a minute.",
"Keturi trumpi klausimai padės mums patobulinti pastabas. Tai užtruks apie minutę.",
),
ease: both("How easy are notes to use?", "Kiek lengva naudotis pastabomis?"),
easeLow: both("Very hard", "Labai sunku"),
easeHigh: both("Very easy", "Labai lengva"),
use: both("What do you mostly use notes for?", "Kam dažniausiai naudojate pastabas?"),
useMeetings: both("Meeting notes", "Susitikimų užrašams"),
useIdeas: both("Ideas", "Idėjoms"),
useTasks: both("Tasks and to-dos", "Užduotims"),
improve: both("What would make notes better for you?", "Kas padarytų pastabas jums geresnes?"),
screenshot: both(
"Attach a screenshot or a PDF of what got in your way, if you like.",
"Jei norite, pridėkite ekrano kopiją ar PDF failą su tuo, kas jums trukdė.",
),
thankYou: both("Thank you. We read every answer.", "Ačiū. Perskaitome kiekvieną atsakymą."),
},
}
+63
View File
@@ -0,0 +1,63 @@
// English messages of the notes module. Every string a page of this module shows is a key here and in
// lt.ts (spec §10.4); `__tests__/i18n.test.ts` fails when the two differ.
export const en = {
title: "Notes",
intro: "Short notes for this project, with files attached. Attached files are kept in the project's file storage.",
form: {
createHeading: "New note",
editHeading: "Edit note",
titleLabel: "Title",
bodyLabel: "Text",
create: "Add note",
creating: "Adding",
save: "Save changes",
saving: "Saving",
saved: "Saved.",
},
list: {
caption: "Notes",
empty: "No notes yet.",
note: "Note",
attachments: "Attachments",
updated: "Updated",
open: "Open {title}",
},
detail: {
back: "All notes",
changed: "Last changed {date}",
},
attachments: {
title: "Attachments",
count: "{count, plural, one {# attachment} other {# attachments}}",
empty: "Nothing is attached.",
file: "File",
type: "Type",
size: "Size",
sizeKb: "{size} KB",
actions: "Actions",
remove: "Remove {filename}",
removeShort: "Remove",
download: "Download {filename}",
off: "Attachments are switched off for this project.",
},
upload: {
label: "Attach a file",
hint: "PDF, PNG, JPEG, WebP or plain text, up to 50 MB.",
submit: "Attach",
busy: "Uploading",
done: "Attached.",
},
delete: {
button: "Delete note",
confirm: "Delete this note? Attached files stay in the project's files.",
yes: "Yes, delete the note",
no: "Keep the note",
},
// kit:guides:begin
feedback: {
open: "Give feedback",
unavailable: "The feedback survey is not available right now.",
},
// kit:guides:end
} as const
+5
View File
@@ -0,0 +1,5 @@
// The notes module's messages, as the TypeScript objects its manifest declares (`i18n: { en, lt }`).
// kit-core's registry carries them and kit-i18n merges them, so nothing outside the module lists them.
export { en } from "./en.js"
export { lt } from "./lt.js"
+67
View File
@@ -0,0 +1,67 @@
// Lietuviški notes 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: "Užrašai",
intro: "Trumpi šio projekto užrašai su prisegtais failais. Prisegti failai saugomi projekto failų saugykloje.",
form: {
createHeading: "Naujas užrašas",
editHeading: "Redaguoti užrašą",
titleLabel: "Pavadinimas",
bodyLabel: "Tekstas",
create: "Pridėti užrašą",
creating: "Pridedama",
save: "Išsaugoti pakeitimus",
saving: "Saugoma",
saved: "Išsaugota.",
},
list: {
caption: "Užrašai",
empty: "Užrašų dar nėra.",
note: "Užrašas",
attachments: "Priedai",
updated: "Atnaujinta",
open: "Atidaryti: {title}",
},
detail: {
back: "Visi užrašai",
changed: "Paskutinį kartą pakeista {date}",
},
attachments: {
title: "Priedai",
count: "{count, plural, one {# priedas} few {# priedai} other {# priedų}}",
empty: "Nieko neprisegta.",
file: "Failas",
type: "Tipas",
size: "Dydis",
sizeKb: "{size} KB",
actions: "Veiksmai",
remove: "Pašalinti: {filename}",
removeShort: "Pašalinti",
download: "Atsisiųsti: {filename}",
off: "Šiame projekte priedai išjungti.",
},
upload: {
label: "Prisegti failą",
hint: "PDF, PNG, JPEG, WebP arba paprastas tekstas, iki 50 MB.",
submit: "Prisegti",
busy: "Keliama",
done: "Prisegta.",
},
delete: {
button: "Ištrinti užrašą",
confirm: "Ištrinti šį užrašą? Prisegti failai lieka projekto failuose.",
yes: "Taip, ištrinti užrašą",
no: "Palikti užrašą",
},
// kit:guides:begin
feedback: {
open: "Pateikti atsiliepimą",
unavailable: "Atsiliepimų apklausa dabar nepasiekiama.",
},
// kit:guides:end
}
+6
View File
@@ -0,0 +1,6 @@
// The notes module's public entry: its manifest, its catalogues and the port an app may bind. A page
// imports `./ui`; another module imports `./contract`.
export * from "./contract/index.js"
export { NOTES_FLAGS, type NoteScope, type NotesDeps, type NotesFlags } from "./core/ports.js"
export { notesModule } from "./module.js"
+45
View File
@@ -0,0 +1,45 @@
create table "notes_attachment" (
"id" uuid primary key,
"version" integer not null,
"created_at" timestamptz not null,
"updated_at" timestamptz not null,
"org_id" uuid not null,
"workspace_id" uuid not null,
"project_id" uuid not null,
"note_id" uuid not null,
"file_id" uuid not null,
"attached_by" uuid not null
);
alter table "notes_attachment" add constraint "notes_attachment_org_id_workspace_id_project_id_note_id_file_id_key" unique nulls not distinct ("org_id", "workspace_id", "project_id", "note_id", "file_id");
create index "notes_attachment_org_id_workspace_id_project_id_note_id_idx" on "notes_attachment" ("org_id", "workspace_id", "project_id", "note_id");
create index "notes_attachment_org_id_workspace_id_project_id_file_id_idx" on "notes_attachment" ("org_id", "workspace_id", "project_id", "file_id");
create table "notes_note" (
"id" uuid primary key,
"version" integer not null,
"created_at" timestamptz not null,
"updated_at" timestamptz not null,
"org_id" uuid not null,
"workspace_id" uuid not null,
"project_id" uuid not null,
"title" text not null,
"body" text not null,
"created_by" uuid not null
);
create index "notes_note_org_id_workspace_id_project_id_created_by_idx" on "notes_note" ("org_id", "workspace_id", "project_id", "created_by");
alter table "notes_attachment" enable row level security;
alter table "notes_attachment" force row level security;
create policy "notes_attachment_tenant" on "notes_attachment" using ("org_id" = nullif(current_setting('app.org_id', true), '')::uuid) with check ("org_id" = nullif(current_setting('app.org_id', true), '')::uuid);
alter table "notes_note" enable row level security;
alter table "notes_note" force row level security;
create policy "notes_note_tenant" on "notes_note" using ("org_id" = nullif(current_setting('app.org_id', true), '')::uuid) with check ("org_id" = nullif(current_setting('app.org_id', true), '')::uuid);
+90
View File
@@ -0,0 +1,90 @@
// The notes module's manifest (spec §4.1, §16): the one place it registers anything.
//
// The example module of the template and the pattern a product's own follows. It is small on
// purpose and uses every seam a module has:
//
// - a contract, a core of pure use cases and a Kysely-bound repository (entities.ts, migrations/);
// - the kernel's files module for attachments: the browser uploads through the files procedures
// (`files.file.beginUpload`, `putPart`, `completeUpload`; the platform's Blob store; one audit
// event and one usage fact per upload, recorded by the files module) and links the file with
// `notes.attachment.add`;
// - audit events and a usage meter that hang off the module's own outbox events, exactly once;
// - a feature flag (`notes.attachments`), evaluated through a port;
// - messages in English and Lithuanian, a navigation entry and the pages (`ui/pages.tsx`), which
// the web shell mounts from `modules/index.ts` with no route file of its own.
import { defineModule, subscribe } from "@conusai-kit/core"
import { repositoryBindings } from "@conusai-kit/data"
import { fileDeleted } from "@conusai-kit/files"
import { NOTE_PROCEDURES } from "./adapters/procedures.js"
import {
ATTACHMENT_ADDED,
ATTACHMENT_REMOVED,
attachmentAdded,
attachmentRemoved,
NOTE_CREATED,
NOTE_DELETED,
NOTE_UPDATED,
NOTES_CREATED_METER,
NOTES_ENTITIES,
NOTES_EVENTS,
NOTES_FLAG_DEFAULTS,
noteCreated,
noteDeleted,
noteUpdated,
} from "./contract/index.js"
import {
onAttachmentAdded,
onAttachmentRemoved,
onFileDeleted,
onNoteCreated,
onNoteDeleted,
onNoteUpdated,
} from "./core/subscribers.js"
import { NOTES_GUIDES } from "./guides/index.js" // kit:guides
import { en, lt } from "./i18n/index.js"
const tables = repositoryBindings(...NOTES_ENTITIES)
export const notesModule = defineModule({
id: "notes",
version: "0.1.0",
kit: "^0.1.0",
requires: ["tenancy", "files"],
permissions: {
"notes.note.read": "viewer",
"notes.note.write": "editor",
"notes.note.delete": "editor",
"notes.attachment.write": "editor",
},
procedures: NOTE_PROCEDURES,
repositories: { postgres: tables, mariadb: tables, mongodb: tables, convex: tables },
// Forward-only migrations, one numbered file per change per dialect (spec §5.3: the dialect
// differences are explicit, not generated), run by the migrate job after the app's own.
migrations: {
postgres: "migrations",
mariadb: "adapters/kysely/mysql/migrations",
mongodb: "adapters/mongo/migrations",
},
events: {
publishes: NOTES_EVENTS,
subscribes: [
subscribe(noteCreated, onNoteCreated),
subscribe(noteUpdated, onNoteUpdated),
subscribe(noteDeleted, onNoteDeleted),
subscribe(attachmentAdded, onAttachmentAdded),
subscribe(attachmentRemoved, onAttachmentRemoved),
// A deleted file takes its links with it.
subscribe(fileDeleted, onFileDeleted),
],
},
audit: [NOTE_CREATED, NOTE_UPDATED, NOTE_DELETED, ATTACHMENT_ADDED, ATTACHMENT_REMOVED],
usage: { meters: [NOTES_CREATED_METER] },
flags: Object.fromEntries(Object.entries(NOTES_FLAG_DEFAULTS).map(([flag, value]) => [flag, { default: value }])),
// The project-level entry the org home lists next to each project; `notes` is the segment the
// web shell's module mount routes to `notesPages` (ui/pages.tsx).
ui: { nav: [{ scope: "project", label: "notes.title", href: "notes", permission: "notes.note.read" }] },
// Both languages, as objects: kit-i18n merges them from the registry (AT-D23).
i18n: { en, lt },
extends: [...NOTES_GUIDES], // kit:guides -- the code tour, checklist and survey of guides/
})
+162
View File
@@ -0,0 +1,162 @@
"use client"
// A note's attachments: the list with a remove button on each, and the upload.
//
// An attachment is two steps, both the API's own: the browser sends the file to the files module
// (the kit's `uploadFile`: `files.file.beginUpload`, one `files.file.putPart` per piece, and
// `files.file.completeUpload`, which answer before anything that follows an upload has run), and
// then links the stored file to the note (`notes.attachment.add`). A file uploaded and never linked
// is an ordinary file of the project.
import { downloadHref, type ProjectSlugs, uploadFile, useTransport } from "@conusai-kit/ui"
import { type FormEvent, type ReactElement, useId, useState } from "react"
export interface AttachmentRow {
readonly attachmentId: string
readonly fileId: string
readonly filename: string
readonly contentType: string
/** Already formatted: a size is a message of the module, not arithmetic of the component. */
readonly size: string
}
export interface AttachmentLabels {
readonly title: string
readonly count: string
readonly empty: string
readonly file: string
readonly type: string
readonly sizeHeading: string
readonly actions: string
/** `Remove {filename}`: the button's accessible name, with the placeholder still in it. */
readonly remove: string
/** The button's visible text, which its accessible name contains. */
readonly removeShort: string
/** `Download {filename}`: a same-origin, project-scoped file stream. */
readonly download: string
readonly off: string
readonly uploadLabel: string
readonly uploadHint: string
readonly uploadSubmit: string
readonly uploadBusy: string
readonly uploadDone: string
readonly failed: string
}
type State = { kind: "idle" | "busy" | "done" } | { kind: "failed"; detail: string }
export function Attachments({
slugs,
noteId,
rows,
enabled,
accept,
labels,
}: {
slugs: ProjectSlugs
noteId: string
rows: readonly AttachmentRow[]
enabled: boolean
accept: readonly string[]
labels: AttachmentLabels
}): ReactElement {
const transport = useTransport()
const { call, describe } = transport
const inputId = useId()
const hintId = useId()
const [state, setState] = useState<State>({ kind: "idle" })
const attach = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
event.preventDefault()
const form = event.currentTarget
const file = new FormData(form).get("file")
if (!(file instanceof File) || file.size === 0) return
setState({ kind: "busy" })
try {
const stored = await uploadFile(transport, slugs, file)
await call("notes.attachment.add", { ...slugs, noteId, fileId: stored.fileId })
form.reset()
setState({ kind: "done" })
window.location.reload()
} catch (error) {
setState({ kind: "failed", detail: describe(error) })
}
}
const remove = async (attachmentId: string): Promise<void> => {
setState({ kind: "busy" })
try {
await call("notes.attachment.remove", { ...slugs, noteId, attachmentId })
window.location.reload()
} catch (error) {
setState({ kind: "failed", detail: describe(error) })
}
}
return (
<section aria-labelledby={`${inputId}-heading`}>
<h2 id={`${inputId}-heading`}>{labels.title}</h2>
<p>{labels.count}</p>
{rows.length === 0 ? (
<p>{labels.empty}</p>
) : (
<table>
<caption>{labels.title}</caption>
<thead>
<tr>
<th scope="col">{labels.file}</th>
<th scope="col">{labels.type}</th>
<th scope="col">{labels.sizeHeading}</th>
<th scope="col">{labels.actions}</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.attachmentId}>
<th scope="row">{row.filename}</th>
<td>{row.contentType}</td>
<td>{row.size}</td>
<td>
<a
href={downloadHref("files.file.download", { ...slugs, fileId: row.fileId })}
aria-label={labels.download.replace("{filename}", row.filename)}
>
{labels.download.replace("{filename}", row.filename)}
</a>{" "}
<button
type="button"
disabled={state.kind === "busy"}
aria-label={labels.remove.replace("{filename}", row.filename)}
onClick={() => void remove(row.attachmentId)}
>
{labels.removeShort}
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
{enabled ? (
<form onSubmit={attach} aria-busy={state.kind === "busy"}>
<div className="kit-field">
<label htmlFor={inputId}>{labels.uploadLabel}</label>
<input id={inputId} name="file" type="file" accept={accept.join(",")} required aria-describedby={hintId} />
<p id={hintId}>{labels.uploadHint}</p>
</div>
<button type="submit" disabled={state.kind === "busy"}>
{state.kind === "busy" ? labels.uploadBusy : labels.uploadSubmit}
</button>
<p role="status">{state.kind === "done" ? labels.uploadDone : ""}</p>
</form>
) : (
<p role="status">{labels.off}</p>
)}
{state.kind === "failed" ? (
<p className="app-error" role="alert">
{labels.failed.replace("{detail}", state.detail)}
</p>
) : null}
</section>
)
}
+69
View File
@@ -0,0 +1,69 @@
"use client"
// Deleting a note, in two steps: the button asks, and a second press does it. The attached files are
// not deleted with the note (they are the project's); the confirmation says so.
import { type ProjectSlugs, useTransport } from "@conusai-kit/ui"
import { type ReactElement, useState } from "react"
export interface DeleteNoteLabels {
readonly button: string
readonly confirm: string
readonly yes: string
readonly no: string
readonly failed: string
}
export function DeleteNote({
slugs,
noteId,
listHref,
labels,
}: {
slugs: ProjectSlugs
noteId: string
/** Where to go once the note is gone. */
listHref: string
labels: DeleteNoteLabels
}): ReactElement {
const { call, describe } = useTransport()
const [state, setState] = useState<{ kind: "idle" | "asking" | "busy" } | { kind: "failed"; detail: string }>({
kind: "idle",
})
const remove = async (): Promise<void> => {
setState({ kind: "busy" })
try {
await call("notes.note.delete", { ...slugs, noteId })
window.location.assign(listHref)
} catch (error) {
setState({ kind: "failed", detail: describe(error) })
}
}
return (
<div>
{state.kind === "asking" || state.kind === "busy" ? (
<fieldset>
<legend>{labels.button}</legend>
<p>{labels.confirm}</p>
<button type="button" disabled={state.kind === "busy"} onClick={() => void remove()}>
{labels.yes}
</button>
<button type="button" disabled={state.kind === "busy"} onClick={() => setState({ kind: "idle" })}>
{labels.no}
</button>
</fieldset>
) : (
<button type="button" onClick={() => setState({ kind: "asking" })}>
{labels.button}
</button>
)}
{state.kind === "failed" ? (
<p className="app-error" role="alert">
{labels.failed.replace("{detail}", state.detail)}
</p>
) : null}
</div>
)
}
+17
View File
@@ -0,0 +1,17 @@
// The notes module's pages and components. `notesPage` is what the web shell mounts; the rest is
// exported for an app that composes its own page from the parts.
export { type AttachmentLabels, type AttachmentRow, Attachments } from "./attachments.js"
export { DeleteNote, type DeleteNoteLabels } from "./delete-note.js"
export {
attachmentLabels,
createFormLabels,
deleteLabels,
editFormLabels,
listLabels,
sizeLabel,
type Translate,
} from "./labels.js"
export { NoteForm, type NoteFormLabels, type NoteFormTarget } from "./note-form.js"
export { NoteList, type NoteListLabels } from "./note-list.js"
export { type NotesPage, notesPage } from "./pages.js"
+86
View File
@@ -0,0 +1,86 @@
// The labels each component takes, built from a message lookup. Every key this module's pages use is
// named here and nowhere else, so `__tests__/i18n.test.ts` can run these builders against the
// catalogues and fail on a key either language lacks -- the page itself holds no string.
//
// `t` takes a key relative to the module (`form.create`); the page binds it to
// `i18n.message("notes." + key)`. The one message that is not this module's own, the app's
// `app.failed`, is passed in already looked up.
import type { AttachmentLabels } from "./attachments.js"
import type { DeleteNoteLabels } from "./delete-note.js"
import type { NoteFormLabels } from "./note-form.js"
import type { NoteListLabels } from "./note-list.js"
export type Translate = (key: string, values?: Record<string, string | number>) => string
export function createFormLabels(t: Translate, failed: string): NoteFormLabels {
return {
heading: t("form.createHeading"),
title: t("form.titleLabel"),
body: t("form.bodyLabel"),
submit: t("form.create"),
busy: t("form.creating"),
saved: t("form.saved"),
failed,
}
}
export function editFormLabels(t: Translate, failed: string): NoteFormLabels {
return {
heading: t("form.editHeading"),
title: t("form.titleLabel"),
body: t("form.bodyLabel"),
submit: t("form.save"),
busy: t("form.saving"),
saved: t("form.saved"),
failed,
}
}
export function listLabels(t: Translate): NoteListLabels {
return {
caption: t("list.caption"),
empty: t("list.empty"),
note: t("list.note"),
attachments: t("list.attachments"),
updated: t("list.updated"),
// The `{title}` placeholder is the component's to fill, so it is passed through untouched.
open: t("list.open", { title: "{title}" }),
}
}
export function attachmentLabels(t: Translate, failed: string, count: number): AttachmentLabels {
return {
title: t("attachments.title"),
count: t("attachments.count", { count }),
empty: t("attachments.empty"),
file: t("attachments.file"),
type: t("attachments.type"),
sizeHeading: t("attachments.size"),
actions: t("attachments.actions"),
download: t("attachments.download", { filename: "{filename}" }),
remove: t("attachments.remove", { filename: "{filename}" }),
removeShort: t("attachments.removeShort"),
off: t("attachments.off"),
uploadLabel: t("upload.label"),
uploadHint: t("upload.hint"),
uploadSubmit: t("upload.submit"),
uploadBusy: t("upload.busy"),
uploadDone: t("upload.done"),
failed,
}
}
export function deleteLabels(t: Translate, failed: string): DeleteNoteLabels {
return {
button: t("delete.button"),
confirm: t("delete.confirm"),
yes: t("delete.yes"),
no: t("delete.no"),
failed,
}
}
/** A size as the message of the module: whole kilobytes, at least one. */
export const sizeLabel = (t: Translate, bytes: number): string =>
t("attachments.sizeKb", { size: Math.max(1, Math.ceil(bytes / 1024)) })
+112
View File
@@ -0,0 +1,112 @@
"use client"
// Creates a note, or saves a change to one. The same form for both: a note being edited carries the
// version it was read at, so a second editor's save is a 409 shown here and never a silent overwrite.
import { emitAppEvent, type ProjectSlugs, useTransport } from "@conusai-kit/ui"
import { type FormEvent, type ReactElement, useId, useState } from "react"
export interface NoteFormLabels {
readonly heading: string
readonly title: string
readonly body: string
readonly submit: string
readonly busy: string
readonly saved: string
/** The app's `app.failed` message, with `{detail}` still in it. */
readonly failed: string
}
export type NoteFormTarget =
| { readonly mode: "create"; readonly listHref: string }
| {
readonly mode: "edit"
readonly noteId: string
readonly version: number
readonly title: string
readonly body: string
}
export function NoteForm({
slugs,
target,
labels,
}: {
slugs: ProjectSlugs
target: NoteFormTarget
labels: NoteFormLabels
}): ReactElement {
const { call, describe } = useTransport()
const titleId = useId()
const bodyId = useId()
const [state, setState] = useState<{ kind: "idle" | "busy" | "saved" } | { kind: "failed"; detail: string }>({
kind: "idle",
})
const submit = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
event.preventDefault()
const form = new FormData(event.currentTarget)
const title = String(form.get("title") ?? "").trim()
const body = String(form.get("body") ?? "")
if (title.length === 0) return
setState({ kind: "busy" })
try {
if (target.mode === "create") {
const created = (await call("notes.note.create", { ...slugs, title, body })) as { noteId: string }
// For whatever listens in the browser (an in-app guide's checklist task); it waits for a
// listener that asked to store the event first, because the next line leaves the page.
await emitAppEvent("note_created")
window.location.assign(`${target.listHref}/${created.noteId}`)
return
}
await call("notes.note.update", {
...slugs,
noteId: target.noteId,
title,
body,
expectedVersion: target.version,
})
setState({ kind: "saved" })
// The page is rendered on the server: a reload shows the new version, which the next save needs.
window.location.reload()
} catch (error) {
setState({ kind: "failed", detail: describe(error) })
}
}
return (
<form onSubmit={submit} aria-busy={state.kind === "busy"}>
<h2>{labels.heading}</h2>
<div className="kit-field" data-guide="notes.note-title">
<label htmlFor={titleId}>{labels.title}</label>
<input
id={titleId}
name="title"
type="text"
required
maxLength={200}
defaultValue={target.mode === "edit" ? target.title : ""}
/>
</div>
<div className="kit-field">
<label htmlFor={bodyId}>{labels.body}</label>
<textarea
id={bodyId}
name="body"
rows={6}
maxLength={20000}
defaultValue={target.mode === "edit" ? target.body : ""}
/>
</div>
<button type="submit" disabled={state.kind === "busy"}>
{state.kind === "busy" ? labels.busy : labels.submit}
</button>
<p role="status">{state.kind === "saved" ? labels.saved : ""}</p>
{state.kind === "failed" ? (
<p className="app-error" role="alert">
{labels.failed.replace("{detail}", state.detail)}
</p>
) : null}
</form>
)
}
+56
View File
@@ -0,0 +1,56 @@
// The project's notes, one row each. A server component: it holds no state, and every string is passed in.
import type { ReactElement } from "react"
import type { NoteView } from "../contract/index.js"
export interface NoteListLabels {
readonly caption: string
readonly empty: string
readonly note: string
readonly attachments: string
readonly updated: string
/** `Open {title}`: the link's accessible name, so a list of links is not a list of the same word. */
readonly open: string
}
export function NoteList({
notes,
hrefFor,
labels,
formatDate,
}: {
notes: readonly NoteView[]
hrefFor: (noteId: string) => string
labels: NoteListLabels
formatDate: (iso: string) => string
}): ReactElement {
if (notes.length === 0) {
return <p data-guide="notes.note-list">{labels.empty}</p>
}
return (
// guide: The notes of this project
<table data-guide="notes.note-list">
<caption>{labels.caption}</caption>
<thead>
<tr>
<th scope="col">{labels.note}</th>
<th scope="col">{labels.attachments}</th>
<th scope="col">{labels.updated}</th>
</tr>
</thead>
<tbody>
{notes.map((note) => (
<tr key={note.noteId}>
<th scope="row">
<a href={hrefFor(note.noteId)} aria-label={labels.open.replace("{title}", note.title)}>
{note.title}
</a>
</th>
<td>{note.attachmentCount}</td>
<td>{formatDate(note.updatedAt)}</td>
</tr>
))}
</tbody>
</table>
)
}
+83
View File
@@ -0,0 +1,83 @@
// The notes module's pages, as the one server component the web shell mounts under
// `/<org>/<workspace>/<project>/notes` (apps/web's module mount reads it from `modules/index.ts`):
//
// /notes the list and the form that adds a note
// /notes/<noteId> one note: edit it, attach files, delete it
//
// Every read is a procedure through the same caller the API uses (spec §6.2: one API path); every
// write is a client component that calls a procedure through the shell's transport.
import { type Container, notFound, type TransportCaller } from "@conusai-kit/core"
import type { I18n } from "@conusai-kit/i18n"
import type { ProjectPage } from "@conusai-kit/ui"
import type { ReactElement } from "react"
import { ATTACHMENT_CONTENT_TYPES, noteGet, noteList } from "../contract/index.js"
import { FeedbackButton } from "../guides/feedback-button.js" // kit:guides
import { feedbackLabels } from "../guides/feedback-labels.js" // kit:guides
import { Attachments } from "./attachments.js"
import { DeleteNote } from "./delete-note.js"
import { attachmentLabels, createFormLabels, deleteLabels, editFormLabels, listLabels, sizeLabel } from "./labels.js"
import { NoteForm } from "./note-form.js"
import { NoteList } from "./note-list.js"
export type NotesPage = ProjectPage<TransportCaller, I18n, Container>
export const notesPage: NotesPage = async ({ slugs, segments, caller, i18n }): Promise<ReactElement> => {
const t = (key: string, values?: Record<string, string | number>) => i18n.message(`notes.${key}`, values)
const failed = i18n.message("app.failed")
const listHref = `/${slugs.org}/${slugs.workspace}/${slugs.project}/notes`
if (segments.length === 0) {
const { notes } = await caller.call(noteList, slugs)
return (
<>
<h1>{t("title")}</h1>
<p>{t("intro")}</p>
<FeedbackButton labels={feedbackLabels(t)} /* kit:guides */ />
{/* guide: The form that adds a note */}
<div data-guide="notes.new-note">
<NoteForm slugs={slugs} target={{ mode: "create", listHref }} labels={createFormLabels(t, failed)} />
</div>
<NoteList
notes={notes}
hrefFor={(noteId) => `${listHref}/${noteId}`}
labels={listLabels(t)}
formatDate={(iso) => i18n.date(new Date(iso))}
/>
</>
)
}
const [noteId, ...rest] = segments
if (noteId === undefined || rest.length > 0) throw notFound("no such page")
const { note, attachments, attachmentsEnabled } = await caller.call(noteGet, { ...slugs, noteId })
return (
<>
<p>
<a href={listHref}>{t("detail.back")}</a>
</p>
<h1>{note.title}</h1>
<p>{t("detail.changed", { date: i18n.date(new Date(note.updatedAt)) })}</p>
<NoteForm
slugs={slugs}
target={{ mode: "edit", noteId: note.noteId, version: note.version, title: note.title, body: note.body }}
labels={editFormLabels(t, failed)}
/>
<Attachments
slugs={slugs}
noteId={note.noteId}
rows={attachments.map((attachment) => ({
attachmentId: attachment.attachmentId,
fileId: attachment.fileId,
filename: attachment.filename,
contentType: attachment.contentType,
size: sizeLabel(t, attachment.size),
}))}
enabled={attachmentsEnabled}
accept={ATTACHMENT_CONTENT_TYPES}
labels={attachmentLabels(t, failed, attachments.length)}
/>
<DeleteNote slugs={slugs} noteId={note.noteId} listHref={listHref} labels={deleteLabels(t, failed)} />
</>
)
}