@@ -0,0 +1,116 @@
|
||||
// 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 { notificationLabels } from "../adapters/notifications.js"
|
||||
import { feedbackLabels } from "../guides/feedback-labels.js" // kit:guides
|
||||
import { attachmentLabels, deleteLabels, resourceLabels } 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
|
||||
}
|
||||
resourceLabels(t)
|
||||
attachmentLabels(t)
|
||||
deleteLabels(t)
|
||||
notificationLabels(t, "Launch")
|
||||
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(25)
|
||||
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([
|
||||
"workspace.title",
|
||||
"workspace.intro",
|
||||
"workspace.filter",
|
||||
"workspace.apply",
|
||||
"workspace.clear",
|
||||
"workspace.noProjects",
|
||||
"workspace.empty",
|
||||
"workspace.selected",
|
||||
"title",
|
||||
"intro",
|
||||
"detail.back",
|
||||
"detail.changed",
|
||||
"detail.edit",
|
||||
])
|
||||
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", "notes.workspace.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\(/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
// 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) }) },
|
||||
// The live collection's read-back: another tenant's note id answers nothing.
|
||||
"notes.note.getMany": { input: (t) => ({ ...inProjectOf(t), ids: [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 }),
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,176 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// A module event delivered through the transactional outbox, end to end: `notes.NoteCreated` is written by
|
||||
// the transaction that writes the note, the relay hands it to the notes module's subscriber in the relay's
|
||||
// own transaction (`NOTIFICATIONS_REPOS`), and the subscriber tells the project's other members through the
|
||||
// notifications kit. What is proven here: one inbox row per record and recipient, also when a delivery is
|
||||
// repeated (the record-derived key), never for the author, and none when the note's transaction rolls back.
|
||||
// The relay's own exactly-once claim (Postgres-family profiles) is proven in kit-jobs-pgboss, not here.
|
||||
|
||||
import type { EventContext, TenantScope } from "@conusai-kit/core"
|
||||
import { systemActor } from "@conusai-kit/core/worker"
|
||||
import type { DataSource } from "@conusai-kit/data"
|
||||
import { FILES_SCOPE } from "@conusai-kit/files"
|
||||
import { NOTIFICATIONS_REPOS } from "@conusai-kit/notifications"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { makeNotesApp, type NotesApp, type SeededProject } from "../../../test/support/notes-app.js"
|
||||
import { notesDeps } from "../adapters/procedures.js"
|
||||
import { createNote } from "../core/notes.js"
|
||||
import { type NoteView, notesModule } from "../index.js"
|
||||
|
||||
interface Inbox {
|
||||
items: Array<{ code: string; category: string; key: string; title: string | null; href: string | null }>
|
||||
}
|
||||
|
||||
/** A restricted project: its creator is its first member, and the second person is added as an editor. */
|
||||
async function restrictedProject(app: NotesApp) {
|
||||
const seeded = await app.seedProject("acme", "owner@acme.test")
|
||||
await seeded.call.callByName("tenancy.project.create", {
|
||||
org: seeded.slugs.org,
|
||||
workspace: seeded.slugs.workspace,
|
||||
slug: "launch",
|
||||
name: "Launch",
|
||||
moduleKey: "notes",
|
||||
visibility: "restricted",
|
||||
})
|
||||
const slugs = { ...seeded.slugs, project: "launch" }
|
||||
const member = await app.addMember(seeded, "member@acme.test", "member")
|
||||
await seeded.call.callByName("tenancy.project.set-member", {
|
||||
...slugs,
|
||||
userId: member.actor.userId,
|
||||
roleKey: "editor",
|
||||
})
|
||||
return { seeded, slugs, member }
|
||||
}
|
||||
|
||||
/** The notes module's entries of a person's inbox; the kit's own (an organization ready, an invitation accepted) are not. */
|
||||
const inboxOf = async (call: SeededProject["call"], org: string) => {
|
||||
const inbox = (await call.callByName("notifications.inbox.list", { org })) as Inbox
|
||||
return { items: inbox.items.filter((item) => item.key.startsWith("notes:")) }
|
||||
}
|
||||
|
||||
describe("notes.NoteCreated, through the outbox, to the project's other members", () => {
|
||||
it("gives the project's other member one inbox row with a link to the note, and the author none", async () => {
|
||||
const app = makeNotesApp()
|
||||
const { seeded, slugs, member } = await restrictedProject(app)
|
||||
const note = (await member.call.callByName("notes.note.create", {
|
||||
...slugs,
|
||||
title: "Kick-off",
|
||||
body: "",
|
||||
})) as NoteView
|
||||
|
||||
// Nothing until the relay runs: the event is a record of the note's transaction, not a side effect of the request.
|
||||
expect((await inboxOf(seeded.call, slugs.org)).items).toEqual([])
|
||||
await app.deliver()
|
||||
|
||||
expect((await inboxOf(seeded.call, slugs.org)).items).toEqual([
|
||||
expect.objectContaining({
|
||||
category: "notice",
|
||||
code: "custom",
|
||||
key: `notes:${note.noteId}:created`,
|
||||
title: "New note in Launch",
|
||||
href: `/${slugs.org}/${slugs.workspace}/${slugs.project}/notes/${note.noteId}`,
|
||||
}),
|
||||
])
|
||||
expect((await inboxOf(member.call, slugs.org)).items).toEqual([])
|
||||
|
||||
// The record is published: a second pass delivers nothing, and the inbox still holds one row.
|
||||
expect(await app.deliver()).toBe(0)
|
||||
expect((await inboxOf(seeded.call, slugs.org)).items).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("is the same notification when the relay delivers a record again after a crash", async () => {
|
||||
const app = makeNotesApp()
|
||||
const { seeded, slugs, member } = await restrictedProject(app)
|
||||
await member.call.callByName("notes.note.create", { ...slugs, title: "Kick-off", body: "" })
|
||||
const record = (await app.data.outbox.pending(10)).find((entry) => entry.name === "notes.NoteCreated")
|
||||
if (!record) throw new Error("expected the note's outbox record")
|
||||
const scope: TenantScope = { level: "org", actor: systemActor("app.relay"), orgId: record.orgId }
|
||||
const subscribers = app.registry.events["notes.NoteCreated"]?.subscribers ?? []
|
||||
const deliverOnce = async () => {
|
||||
for (const subscriber of subscribers) {
|
||||
const container = app.root.child()
|
||||
container.value(FILES_SCOPE, scope)
|
||||
container.value(NOTIFICATIONS_REPOS, app.data.forScope(scope))
|
||||
const ctx: EventContext = { container, correlationId: record.id }
|
||||
await subscriber.handler({ name: record.name, payload: record.payload }, ctx)
|
||||
}
|
||||
}
|
||||
await deliverOnce()
|
||||
await deliverOnce()
|
||||
expect((await inboxOf(seeded.call, slugs.org)).items).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("tells the other member, not the author, when the project's creator writes the note", async () => {
|
||||
const app = makeNotesApp()
|
||||
const { seeded, slugs, member } = await restrictedProject(app)
|
||||
await seeded.call.callByName("notes.note.create", { ...slugs, title: "Plan", body: "" })
|
||||
await app.deliver()
|
||||
expect((await inboxOf(seeded.call, slugs.org)).items).toEqual([])
|
||||
expect((await inboxOf(member.call, slugs.org)).items).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("writes the title in the recipient's own language", async () => {
|
||||
const app = makeNotesApp()
|
||||
const { seeded, slugs, member } = await restrictedProject(app)
|
||||
await seeded.call.callByName("tenancy.me.update", { locale: "lt" })
|
||||
await member.call.callByName("notes.note.create", { ...slugs, title: "Planas", body: "" })
|
||||
await app.deliver()
|
||||
expect((await inboxOf(seeded.call, slugs.org)).items.map((item) => item.title)).toEqual([
|
||||
"Naujas užrašas projekte „Launch“",
|
||||
])
|
||||
})
|
||||
|
||||
it("tells nobody in a project without named members, where the whole workspace would be noise", async () => {
|
||||
const app = makeNotesApp()
|
||||
const seeded = await app.seedProject("acme", "owner@acme.test")
|
||||
const member = await app.addMember(seeded, "member@acme.test", "member")
|
||||
await member.call.callByName("notes.note.create", { ...seeded.slugs, title: "Open", body: "" })
|
||||
await app.deliver()
|
||||
expect((await inboxOf(seeded.call, seeded.slugs.org)).items).toEqual([])
|
||||
})
|
||||
|
||||
it("publishes nothing when the note's transaction rolls back, so nobody is told about a note that is not there", async () => {
|
||||
const app = makeNotesApp()
|
||||
const { seeded, slugs, member } = await restrictedProject(app)
|
||||
await member.call.callByName("notes.note.create", { ...slugs, title: "Kept", body: "" })
|
||||
const [kept] = await app.data.outbox.pending(500).then((all) => all.filter((r) => r.name === "notes.NoteCreated"))
|
||||
if (!kept) throw new Error("expected the note's outbox record")
|
||||
await app.deliver()
|
||||
const { noteId, orgId, workspaceId, projectId } = kept.payload as {
|
||||
noteId: string
|
||||
orgId: string
|
||||
workspaceId: string
|
||||
projectId: string
|
||||
}
|
||||
const scope = { level: "project" as const, actor: member.actor, orgId, workspaceId, projectId }
|
||||
// The commit fails after the use case wrote the row and the record: both go, or neither does.
|
||||
const failing: DataSource = {
|
||||
...app.data,
|
||||
unitOfWork: {
|
||||
run: (s, fn) =>
|
||||
app.data.unitOfWork.run(s, async (tx) => {
|
||||
await fn(tx)
|
||||
throw new Error("the commit failed")
|
||||
}),
|
||||
},
|
||||
}
|
||||
await expect(
|
||||
createNote({ ...notesDeps(app.root), data: failing }, scope, { title: "Ghost", body: "" }),
|
||||
).rejects.toThrow("the commit failed")
|
||||
expect(await app.data.outbox.pending(500)).toEqual([])
|
||||
expect(await app.deliver()).toBe(0)
|
||||
expect((await inboxOf(seeded.call, slugs.org)).items.map((item) => item.key)).toEqual([`notes:${noteId}:created`])
|
||||
const titles = ((await member.call.callByName("notes.note.list", slugs)) as { notes: NoteView[] }).notes
|
||||
expect(titles.map((n) => n.title)).toEqual(["Kept"])
|
||||
})
|
||||
|
||||
it("has one subscription per module and event, because the relay claims a delivery per record and module", () => {
|
||||
// A second subscription of one module to one event would be claimed by the first and never run.
|
||||
const app = makeNotesApp()
|
||||
for (const [name, entry] of Object.entries(app.registry.events)) {
|
||||
const modules = entry.subscribers.map((subscriber) => subscriber.module)
|
||||
expect(modules, name).toEqual([...new Set(modules)])
|
||||
}
|
||||
expect(notesModule.requires).toContain("notifications")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
// The notes page on F10: the worked example. Its loader reads through the caller and returns plain data
|
||||
// with every label translated; the browser receives that data as JSON, so it must survive the round trip.
|
||||
|
||||
import { notFound as notFoundError } from "@conusai-kit/core"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { notesPage } from "../ui/pages.js"
|
||||
|
||||
const slugs = { org: "acme", workspace: "general", project: "demo" }
|
||||
const i18n = {
|
||||
locale: "lt",
|
||||
message: (key: string, values?: Record<string, string | number>) =>
|
||||
`${key}${values ? ` ${JSON.stringify(values)}` : ""}`,
|
||||
date: (date: Date) => date.toISOString().slice(0, 10),
|
||||
}
|
||||
const load = (segments: readonly string[], call: (contract: unknown, input: unknown) => Promise<unknown>) =>
|
||||
notesPage.load({
|
||||
slugs,
|
||||
segments,
|
||||
search: {},
|
||||
caller: { call } as never,
|
||||
i18n: i18n as never,
|
||||
container: {} as never,
|
||||
})
|
||||
|
||||
const note = {
|
||||
noteId: "note-1",
|
||||
version: 3,
|
||||
title: "Roles",
|
||||
body: "text",
|
||||
attachmentCount: 0,
|
||||
createdAt: "2026-11-02T10:00:00.000Z",
|
||||
updatedAt: "2026-11-02T10:00:00.000Z",
|
||||
}
|
||||
|
||||
describe("the notes page's loader", () => {
|
||||
it("returns the list, its first page and its labels as plain data", async () => {
|
||||
const call = vi.fn(async () => ({ notes: [note] }))
|
||||
const data = await load([], call)
|
||||
expect(call.mock.calls[0]?.[1 as never]).toEqual(slugs)
|
||||
expect(data).toMatchObject({ kind: "list", intro: "notes.intro", notes: [note] })
|
||||
if (data.kind !== "list") throw new Error("not the list")
|
||||
expect(data.labels.title).toBe("notes.title")
|
||||
expect(JSON.parse(JSON.stringify(data))).toEqual(data)
|
||||
})
|
||||
|
||||
it("returns one note with its attachments as the live list's seed and its date already formatted", async () => {
|
||||
const call = vi.fn(async () => ({
|
||||
note,
|
||||
attachments: [
|
||||
{ attachmentId: "a1", fileId: "f1", filename: "a.pdf", contentType: "application/pdf", size: 2048 },
|
||||
],
|
||||
attachmentsEnabled: true,
|
||||
}))
|
||||
const data = await load(["note-1"], call)
|
||||
expect(call.mock.calls[0]?.[1 as never]).toEqual({ ...slugs, noteId: "note-1" })
|
||||
if (data.kind !== "note") throw new Error("not the note")
|
||||
expect(data.changed).toContain("2026-11-02")
|
||||
// The rows are the procedure's own: the live list reads the same shape again on a change.
|
||||
expect(data.attachments[0]).toMatchObject({ attachmentId: "a1", size: 2048 })
|
||||
// The count and the size keep their placeholders for the browser, which knows the live numbers.
|
||||
expect(data.attachmentLabels.count).toBe("notes.attachments.count")
|
||||
expect(data.attachmentLabels.sizeKb).toBe("notes.attachments.sizeKb")
|
||||
expect(data.note).toEqual({ noteId: "note-1", title: "Roles", body: "text" })
|
||||
expect(JSON.parse(JSON.stringify(data))).toEqual(data)
|
||||
})
|
||||
|
||||
it("answers a path it does not know as not found, without reading", async () => {
|
||||
const call = vi.fn()
|
||||
await expect(load(["note-1", "extra"], call)).rejects.toMatchObject({ status: 404 })
|
||||
expect(call).not.toHaveBeenCalled()
|
||||
expect(notFoundError("no such page").status).toBe(404)
|
||||
})
|
||||
|
||||
it("hands a procedure's refusal on, for the shell to answer", async () => {
|
||||
await expect(
|
||||
load(["note-2"], async () => {
|
||||
throw notFoundError("no such note")
|
||||
}),
|
||||
).rejects.toMatchObject({ status: 404 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,142 @@
|
||||
// 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 { builtInCatalogues, createI18n, uiLabels } from "@conusai-kit/i18n"
|
||||
import { createDataClient, DataProvider } from "@conusai-kit/ui/data"
|
||||
import { FrameProvider, SidePanelProvider } from "@conusai-kit/ui/patterns"
|
||||
import { UiLabelsProvider } from "@conusai-kit/ui/primitives"
|
||||
import { renderToStaticMarkup as renderMarkup } 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, DeleteNote, deleteLabels, NotesResource, resourceLabels } 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 kitStrings = uiLabels(createI18n("en", builtInCatalogues))
|
||||
const renderToStaticMarkup = (node: React.ReactNode) =>
|
||||
renderMarkup(<UiLabelsProvider labels={kitStrings}>{node}</UiLabelsProvider>)
|
||||
|
||||
const inProvider = (node: React.ReactNode) =>
|
||||
renderToStaticMarkup(
|
||||
<DataProvider client={createDataClient({ fetch: async () => new Response(null, { status: 501 }) })}>
|
||||
{node}
|
||||
</DataProvider>,
|
||||
)
|
||||
|
||||
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 notes resource", () => {
|
||||
const render = (notes: readonly NoteView[], permissions: readonly string[]) =>
|
||||
inProvider(
|
||||
<FrameProvider
|
||||
router={{
|
||||
pathname: "/acme/ws/p/notes",
|
||||
search: new URLSearchParams(),
|
||||
refresh: async () => undefined,
|
||||
navigate: () => undefined,
|
||||
}}
|
||||
permissions={permissions}
|
||||
>
|
||||
<SidePanelProvider>
|
||||
<NotesResource slugs={slugs} seed={notes} labels={resourceLabels(t)} intro="Short notes" locale="en" />
|
||||
</SidePanelProvider>
|
||||
</FrameProvider>,
|
||||
)
|
||||
const writer = ["notes.note.read", "notes.note.write", "notes.note.delete"]
|
||||
const html = render([note(), note({ noteId: "n2", title: "Retro" })], writer)
|
||||
|
||||
it("is a named table with a header for every column and one for each row", () => {
|
||||
expect(html).toContain('aria-label="note"')
|
||||
expect(html).toContain("Kick-off")
|
||||
expect(html).toContain("Retro")
|
||||
expect(html).toContain('data-guide="notes.list"')
|
||||
})
|
||||
|
||||
it("offers a new note to whoever may write one, and only to them", () => {
|
||||
expect(html).toContain("New note")
|
||||
expect(html).toContain('data-guide="notes.new"')
|
||||
expect(render([note()], ["notes.note.read"])).not.toContain("New note")
|
||||
})
|
||||
|
||||
it("says so in words when there is nothing to list", () => {
|
||||
expect(render([], writer)).toContain("No notes yet.")
|
||||
})
|
||||
})
|
||||
|
||||
describe("the attachments", () => {
|
||||
const render = (enabled: boolean, seed: Parameters<typeof Attachments>[0]["seed"]) =>
|
||||
inProvider(
|
||||
<Attachments
|
||||
slugs={slugs}
|
||||
noteId="n1"
|
||||
seed={seed}
|
||||
enabled={enabled}
|
||||
accept={["text/plain", "application/pdf"]}
|
||||
labels={attachmentLabels(t)}
|
||||
locale="en"
|
||||
/>,
|
||||
)
|
||||
const rows = [
|
||||
{
|
||||
attachmentId: "a1",
|
||||
fileId: "f1",
|
||||
filename: "minutes.txt",
|
||||
contentType: "text/plain",
|
||||
size: 2048,
|
||||
attachedAt: "2026-04-02T08:00:00.000Z",
|
||||
},
|
||||
]
|
||||
|
||||
it("lists each file in a captioned table and names each remove button by its file", () => {
|
||||
const html = render(true, rows)
|
||||
expect(html).toContain('aria-label="Attachments"')
|
||||
expect(html).toContain("minutes.txt")
|
||||
expect(html).toContain("2 KB")
|
||||
expect(html).toContain('aria-label="Remove minutes.txt"')
|
||||
expect(html).toContain('href="/api/v1/files.file.download?org=acme&workspace=ws&project=p&fileId=f1"')
|
||||
expect(html).toContain(">Download minutes.txt</a>")
|
||||
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).toMatch(new RegExp(`<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)} />,
|
||||
)
|
||||
expect(html).toContain("Delete note")
|
||||
expect(html).not.toContain("Yes, delete the note")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
// The workspace page's strings as one record (F10): `load` hands over the message of a count only the browser
|
||||
// knows with its placeholder in, and the view's `formatMessage` picks the language's plural form.
|
||||
|
||||
import { Container } from "@conusai-kit/core"
|
||||
import { createI18n } from "@conusai-kit/i18n"
|
||||
import { formatMessage } from "@conusai-kit/i18n/format"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { en } from "../i18n/en.js"
|
||||
import { lt } from "../i18n/lt.js"
|
||||
import { notesWorkspacePage, WORKSPACE_LABEL_KEYS } from "../ui/workspace-page.js"
|
||||
|
||||
const slugs = { org: "acme", workspace: "main" }
|
||||
const projects = [
|
||||
{ slug: "alpha", name: "Alpha" },
|
||||
{ slug: "beta", name: "Beta" },
|
||||
]
|
||||
const load = (locale: "en" | "lt", search: Record<string, string> = {}) =>
|
||||
notesWorkspacePage.load({
|
||||
slugs,
|
||||
segments: [],
|
||||
search,
|
||||
caller: { call: async () => ({ projects }) } as never,
|
||||
i18n: createI18n(locale, {
|
||||
en: { notes: { ...en, "workspace.selected": en.workspace.selected } },
|
||||
lt: { notes: { ...lt, "workspace.selected": lt.workspace.selected } },
|
||||
} as never),
|
||||
container: new Container(),
|
||||
})
|
||||
|
||||
describe("the workspace page's labels", () => {
|
||||
it("cross the loader as plain strings, with the count's placeholder still in", async () => {
|
||||
const data = await load("en")
|
||||
expect(Object.keys(data.labels)).toEqual([...WORKSPACE_LABEL_KEYS])
|
||||
expect(JSON.parse(JSON.stringify(data))).toEqual(data)
|
||||
expect(data.labels.selected).toBe(en.workspace.selected)
|
||||
})
|
||||
|
||||
it("filter on the server by `search`", async () => {
|
||||
const data = await load("en", { q: " BET " })
|
||||
expect(data.projects.map((project) => project.slug)).toEqual(["beta"])
|
||||
expect(data.query).toBe(" BET ")
|
||||
})
|
||||
|
||||
it.each([
|
||||
[0, "0 projects selected"],
|
||||
[1, "1 project selected"],
|
||||
[2, "2 projects selected"],
|
||||
])("read %d in English", (count, text) => {
|
||||
expect(formatMessage(en.workspace.selected, { count }, "en")).toBe(text)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[1, "Pasirinktas 1 projektas"],
|
||||
[21, "Pasirinktas 21 projektas"],
|
||||
[3, "Pasirinkti 3 projektai"],
|
||||
[10, "Pasirinkta 10 projektų"],
|
||||
[0, "Pasirinkta 0 projektų"],
|
||||
])("read %d in Lithuanian", (count, text) => {
|
||||
expect(formatMessage(lt.workspace.selected, { count }, "lt")).toBe(text)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,84 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// A module event, delivered: `notes.NoteCreated` tells the project's other members that a note was added.
|
||||
//
|
||||
// The event is a record of the transaction that wrote the note (core/notes.ts), so a note that rolled back
|
||||
// tells nobody. The relay delivers at least once per subscriber. On the Postgres, MariaDB and MongoDB
|
||||
// profiles it hands the record to this subscriber inside its own transaction, bound as
|
||||
// `NOTIFICATIONS_REPOS` (JOB_REPOS), and records the delivery in that transaction too, so the inbox rows and
|
||||
// the claim commit together: exactly once. On Convex, delivery is at least once only; there, and after any
|
||||
// repeat, the record-derived `key` makes a second delivery find the row and add nothing.
|
||||
// The inbox, its read state and its rendering are the notifications kit's; a producer supplies only the
|
||||
// recipient, the occurrence, the text and a link inside the organization.
|
||||
//
|
||||
// Who is told: the project's named members (`project_memberships`, which a restricted project always has,
|
||||
// its creator first), never the author. A project open to the whole workspace has none, and telling every
|
||||
// member of the workspace about every note would be noise.
|
||||
|
||||
import { type EventContext, internal } from "@conusai-kit/core"
|
||||
import { DATA_SOURCE } from "@conusai-kit/data"
|
||||
import { FILES_REGISTRY, FILES_SCOPE } from "@conusai-kit/files"
|
||||
import { createI18n, moduleCatalogues, negotiateLocale } from "@conusai-kit/i18n"
|
||||
import { deliverNotification, NOTIFICATIONS_REPOS } from "@conusai-kit/notifications"
|
||||
import { organizations, projectMemberships, projects, users, workspaces } from "@conusai-kit/tenancy"
|
||||
|
||||
type NoteCreated = { orgId: string; workspaceId: string; projectId: string; noteId: string; createdBy: string }
|
||||
|
||||
/** A message lookup relative to the module (`notification.created`), as the pages' label builders take. */
|
||||
export type Translate = (key: string, values?: Record<string, string | number>) => string
|
||||
|
||||
/** The texts of the notification, named here so `__tests__/i18n.test.ts` checks them in both languages. */
|
||||
export function notificationLabels(t: Translate, project: string) {
|
||||
return { created: t("notification.created", { project }) }
|
||||
}
|
||||
|
||||
export async function notifyNoteCreated(event: { name: string; payload: NoteCreated }, ctx: EventContext) {
|
||||
const { payload } = event
|
||||
const tx = ctx.container.resolve(NOTIFICATIONS_REPOS)
|
||||
if (tx.scope.orgId !== payload.orgId) {
|
||||
throw internal(`a notes event of organization ${payload.orgId} was delivered in ${tx.scope.orgId}`)
|
||||
}
|
||||
|
||||
const recipients: string[] = []
|
||||
for (let cursor: string | undefined; ; ) {
|
||||
const page = await tx.of(projectMemberships).list({
|
||||
where: [{ field: "projectId", op: "eq", value: payload.projectId }],
|
||||
...(cursor === undefined ? {} : { cursor }),
|
||||
})
|
||||
for (const row of page.items) if (row.userId !== payload.createdBy) recipients.push(row.userId)
|
||||
if (!page.hasMore || page.cursor === undefined) break
|
||||
cursor = page.cursor
|
||||
}
|
||||
if (recipients.length === 0) return
|
||||
|
||||
// The link names the project by its slugs. A project is a workspace's row, so it is read in the
|
||||
// workspace's scope, as the relay's system actor: a read, outside the delivery's transaction.
|
||||
const relay = ctx.container.resolve(FILES_SCOPE)
|
||||
const inWorkspace = {
|
||||
level: "workspace" as const,
|
||||
actor: relay.actor,
|
||||
orgId: payload.orgId,
|
||||
workspaceId: payload.workspaceId,
|
||||
}
|
||||
const [org, workspace, project] = await Promise.all([
|
||||
tx.global(organizations).require(payload.orgId),
|
||||
tx.of(workspaces).require(payload.workspaceId),
|
||||
ctx.container.resolve(DATA_SOURCE).forScope(inWorkspace).of(projects).require(payload.projectId),
|
||||
])
|
||||
const href = `/${org.slug}/${workspace.slug}/${project.slug}/notes/${payload.noteId}`
|
||||
const catalogues = moduleCatalogues(ctx.container.resolve(FILES_REGISTRY))
|
||||
|
||||
for (const recipientId of recipients) {
|
||||
const person = await tx.global(users).get(recipientId)
|
||||
const i18n = createI18n(negotiateLocale({ ...(person ? { userLocale: person.locale } : {}) }), catalogues)
|
||||
const labels = notificationLabels((key, values) => i18n.message(`notes.${key}`, values), project.name)
|
||||
// `custom` is the kit's code for an app's own copy, which it stores as written: in the recipient's language.
|
||||
await deliverNotification(tx, {
|
||||
recipientId,
|
||||
key: `notes:${payload.noteId}:created`,
|
||||
category: "notice",
|
||||
code: "custom",
|
||||
title: labels.created,
|
||||
href,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// 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,
|
||||
noteGetMany,
|
||||
noteList,
|
||||
noteUpdate,
|
||||
} from "../contract/index.js"
|
||||
import {
|
||||
addAttachment,
|
||||
createNote,
|
||||
deleteNote,
|
||||
getManyNotes,
|
||||
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 noteGetManyImpl = implement(noteGetMany, async ({ input, ctx }) => ({
|
||||
items: await getManyNotes(notesDeps(ctx.container), ctx.scope, input.ids),
|
||||
}))
|
||||
|
||||
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,
|
||||
noteGetManyImpl,
|
||||
noteGetImpl,
|
||||
noteCreateImpl,
|
||||
noteUpdateImpl,
|
||||
noteDeleteImpl,
|
||||
attachmentAddImpl,
|
||||
attachmentRemoveImpl,
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
// The live collections of the notes module (umbrella TS6.1). The live server announces a change of
|
||||
// `notes_note` as `{ entity: "notes.note", id }`, ids only; the browser reads the note back through
|
||||
// `notes.note.getMany`, so the procedure's permission and the repository's tenant wall decide what a
|
||||
// member sees. `pnpm kit check` refuses a collection without its batch-get procedure.
|
||||
|
||||
import type { ModuleCollection } from "@conusai-kit/core"
|
||||
|
||||
export const NOTE_COLLECTION = {
|
||||
entity: "notes.note",
|
||||
table: "notes_note",
|
||||
key: "noteId",
|
||||
scopeLevel: "project",
|
||||
read: "notes.note.read",
|
||||
list: "notes.note.list",
|
||||
getMany: "notes.note.getMany",
|
||||
} as const satisfies ModuleCollection
|
||||
|
||||
export const NOTES_COLLECTIONS: readonly ModuleCollection[] = [NOTE_COLLECTION]
|
||||
@@ -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
|
||||
@@ -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])
|
||||
@@ -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]
|
||||
@@ -0,0 +1,39 @@
|
||||
// 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 { NOTE_COLLECTION, NOTES_COLLECTIONS } from "./collections.js"
|
||||
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,
|
||||
noteGetMany,
|
||||
noteList,
|
||||
noteUpdate,
|
||||
noteViewSchema,
|
||||
} from "./procedures.js"
|
||||
@@ -0,0 +1,119 @@
|
||||
// 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()
|
||||
|
||||
/**
|
||||
* The batch-get of the live collection `notes.note` (TS6.1): the live stream announces ids only, and
|
||||
* the browser reads up to 100 changed notes back here, under the same permission as the list. A note
|
||||
* that is not answered is gone for this caller.
|
||||
*/
|
||||
export const noteGetMany = procedure("notes.note.getMany")
|
||||
.kind("read")
|
||||
.scope("project")
|
||||
.permission("notes.note.read")
|
||||
.input(z.object({ ...inProject, ids: z.array(z.string().min(1).max(64)).min(1).max(100) }))
|
||||
// `{ items }`, the batch-get answer of the collection wire (final-wave contract C-05).
|
||||
.output(z.object({ items: z.array(noteViewSchema) }))
|
||||
.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()
|
||||
@@ -0,0 +1,332 @@
|
||||
// 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 { Repos } from "@conusai-kit/data"
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A change of a note's attachments is a change of the note: its attachment count is part of the note's
|
||||
* view, and the live stream announces rows of `notes_note`, so the note's version moves with each link
|
||||
* and unlink and every live reader of the note reads it again. A note deleted meanwhile has nothing to
|
||||
* announce.
|
||||
*/
|
||||
export async function touchNote(tx: Repos, noteId: string): Promise<void> {
|
||||
const note = await tx.of(notes).get(noteId)
|
||||
if (note) await tx.of(notes).update(note.id, {}, note.version)
|
||||
}
|
||||
|
||||
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 }),
|
||||
})
|
||||
return {
|
||||
notes: await withCounts(deps, scope, page.items),
|
||||
...(page.cursor === undefined ? {} : { cursor: page.cursor }),
|
||||
hasMore: page.hasMore,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The attachment counts of a set of notes in one read over the note index, not one count per note
|
||||
* (up to 200 queries for one list).
|
||||
*/
|
||||
async function withCounts(deps: NotesDeps, scope: NoteScope, rows: readonly NoteRow[]): Promise<NoteView[]> {
|
||||
const counts = new Map<string, number>()
|
||||
if (rows.length > 0) {
|
||||
let linksCursor: string | undefined
|
||||
do {
|
||||
const attachmentsPage = await deps.data
|
||||
.forScope(scope)
|
||||
.of(attachments)
|
||||
.list({
|
||||
where: [{ field: "noteId", op: "in", values: rows.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 rows.map((row) => noteView(row, counts.get(row.id) ?? 0))
|
||||
}
|
||||
|
||||
/**
|
||||
* The read-back of the live collection (`notes.note`, TS6.1): the notes with these ids that exist in
|
||||
* this project. A missing id -- deleted, another tenant's, never written -- is simply absent, which
|
||||
* the browser treats as a delete (live-protocol.md §5).
|
||||
*/
|
||||
export async function getManyNotes(deps: NotesDeps, scope: NoteScope, noteIds: readonly string[]): Promise<NoteView[]> {
|
||||
const page = await deps.data
|
||||
.forScope(scope)
|
||||
.of(notes)
|
||||
.list({ where: [{ field: "id", op: "in", values: [...new Set(noteIds)] }], limit: MAX_BATCH })
|
||||
return withCounts(deps, scope, page.items)
|
||||
}
|
||||
|
||||
/** The most ids one batch-get reads (contract C-05). */
|
||||
export const MAX_BATCH = 100
|
||||
|
||||
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 touchNote(tx, link.noteId)
|
||||
await tx.publish(attachmentRemoved, {
|
||||
...ids(scope),
|
||||
noteId: link.noteId,
|
||||
attachmentId: link.id,
|
||||
fileId: link.fileId,
|
||||
removedBy: scope.actor.userId,
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
@@ -0,0 +1,169 @@
|
||||
// What the platform counts once per change.
|
||||
//
|
||||
// The audit event and the usage fact hang off the outbox record the changing transaction wrote, so a
|
||||
// note created by a retried request is one record, and one whose process died before the relay ran
|
||||
// still is, because the record committed with the row. The relay delivers a record at least once per
|
||||
// subscriber (templates/app/docs/domain-start.md, §5). The platform's audit and usage APIs are HTTP and
|
||||
// cannot join the delivery's transaction: each call carries a key derived from the record, so a delivery
|
||||
// the relay repeats 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"
|
||||
import { touchNote } from "./notes.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 (`auditTarget` of @conusai-kit/host/http): 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)
|
||||
await touchNote(tx, link.noteId)
|
||||
}
|
||||
if (!page.hasMore) break
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client"
|
||||
|
||||
import { Button } from "@conusai-kit/ui/primitives"
|
||||
|
||||
// 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 {
|
||||
const coordinator = useSurfaceCoordinator<SurfaceCoordinator>()
|
||||
const served = useSyncExternalStore(
|
||||
coordinator ? (listener) => coordinator.subscribe(listener) : never,
|
||||
() => coordinator?.serves("guides.start") ?? false,
|
||||
() => false,
|
||||
)
|
||||
const [unavailable, setUnavailable] = useState(false)
|
||||
const start = async () => {
|
||||
if (!coordinator || !served) return
|
||||
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" disabled={!coordinator || !served} onClick={() => void start()}>
|
||||
{labels.open}
|
||||
</Button>{" "}
|
||||
<span role="status">{unavailable ? labels.unavailable : ""}</span>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
@@ -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") }
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
// 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). It starts from Help or the setup checklist, so it never interrupts
|
||||
// another task or mistakes a workspace module route for a project. Its steps point at the anchors a `ResourcePage` marks for its resource
|
||||
// (`notes.new`, `notes.list`, from ui/notes-resource.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: { manual: true },
|
||||
frequency: "once",
|
||||
steps: [
|
||||
{
|
||||
id: "new",
|
||||
type: "tooltip",
|
||||
target: { anchor: "notes.new" },
|
||||
title: t.newTitle,
|
||||
body: t.newBody,
|
||||
advance: { on: "next_button" },
|
||||
},
|
||||
{
|
||||
id: "list",
|
||||
type: "tooltip",
|
||||
target: { anchor: "notes.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" } },
|
||||
],
|
||||
})
|
||||
@@ -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])]
|
||||
@@ -0,0 +1,50 @@
|
||||
// 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(
|
||||
"A note opens beside the list: a title, some text, then Save. Notes belong to this project.",
|
||||
"Pastaba atsiveria šalia sąrašo: pavadinimas, tekstas, tada Išsaugoti. Pastabos priklauso šiam projektui.",
|
||||
),
|
||||
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ą."),
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// 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.",
|
||||
workspace: {
|
||||
title: "Notes in this workspace",
|
||||
intro: "Every project of this workspace. Open one to read its notes.",
|
||||
filter: "Project name",
|
||||
apply: "Filter",
|
||||
clear: "Clear filter",
|
||||
noProjects: "No projects yet.",
|
||||
empty: "No project matches.",
|
||||
selected: "{count, plural, one {# project selected} other {# projects selected}}",
|
||||
},
|
||||
list: {
|
||||
one: "note",
|
||||
new: "New note",
|
||||
empty: "No notes yet.",
|
||||
note: "Note",
|
||||
title: "Title",
|
||||
body: "Text",
|
||||
attachments: "Attachments",
|
||||
updated: "Updated",
|
||||
},
|
||||
detail: {
|
||||
back: "All notes",
|
||||
changed: "Last changed {date}",
|
||||
edit: "Edit note",
|
||||
},
|
||||
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",
|
||||
removeConfirm: "The file stays in the project; only its link to this note goes.",
|
||||
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",
|
||||
},
|
||||
// The inbox entry the project's other members get for a new note (adapters/notifications.ts).
|
||||
notification: {
|
||||
created: "New note in {project}",
|
||||
},
|
||||
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
|
||||
@@ -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"
|
||||
@@ -0,0 +1,73 @@
|
||||
// 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.",
|
||||
workspace: {
|
||||
title: "Užrašai šioje darbo srityje",
|
||||
intro: "Visi šios darbo srities projektai. Atidarykite projektą ir skaitykite jo užrašus.",
|
||||
filter: "Projekto pavadinimas",
|
||||
apply: "Filtruoti",
|
||||
clear: "Išvalyti filtrą",
|
||||
noProjects: "Projektų dar nėra.",
|
||||
empty: "Tokio projekto nėra.",
|
||||
selected:
|
||||
"{count, plural, one {Pasirinktas # projektas} few {Pasirinkti # projektai} many {Pasirinkta # projekto} other {Pasirinkta # projektų}}",
|
||||
},
|
||||
list: {
|
||||
one: "užrašas",
|
||||
new: "Naujas užrašas",
|
||||
empty: "Užrašų dar nėra.",
|
||||
note: "Užrašas",
|
||||
title: "Pavadinimas",
|
||||
body: "Tekstas",
|
||||
attachments: "Priedai",
|
||||
updated: "Atnaujinta",
|
||||
},
|
||||
detail: {
|
||||
back: "Visi užrašai",
|
||||
changed: "Paskutinį kartą pakeista {date}",
|
||||
edit: "Redaguoti užrašą",
|
||||
},
|
||||
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",
|
||||
removeConfirm: "Failas lieka projekte; pašalinamas tik jo ryšys su šia pastaba.",
|
||||
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",
|
||||
},
|
||||
notification: {
|
||||
created: "Naujas užrašas projekte „{project}“",
|
||||
},
|
||||
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
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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);
|
||||
@@ -0,0 +1,118 @@
|
||||
// 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, delivered at least once
|
||||
// with a key derived from the record, so the platform counts each once;
|
||||
// - a module event delivered to people: `notes.NoteCreated`, written by the note's own transaction and
|
||||
// run by the worker's outbox relay, tells a project's other named members through the notifications
|
||||
// kit's inbox (adapters/notifications.ts), in each recipient's language: exactly once on the
|
||||
// Postgres-family relay, which writes it in the delivery's own transaction, and one row per
|
||||
// record-derived key everywhere;
|
||||
// - 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 { notifyNoteCreated } from "./adapters/notifications.js"
|
||||
import { NOTE_PROCEDURES } from "./adapters/procedures.js"
|
||||
import {
|
||||
ATTACHMENT_ADDED,
|
||||
ATTACHMENT_REMOVED,
|
||||
attachmentAdded,
|
||||
attachmentRemoved,
|
||||
NOTE_CREATED,
|
||||
NOTE_DELETED,
|
||||
NOTE_UPDATED,
|
||||
NOTES_COLLECTIONS,
|
||||
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", "notifications"],
|
||||
permissions: {
|
||||
"notes.note.read": "viewer",
|
||||
"notes.note.write": "editor",
|
||||
"notes.note.delete": "editor",
|
||||
"notes.attachment.write": "editor",
|
||||
},
|
||||
procedures: NOTE_PROCEDURES,
|
||||
// The note list is live (TS6.1): a change of notes_note reaches every member's open page.
|
||||
collections: [...NOTES_COLLECTIONS],
|
||||
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: [
|
||||
// One subscription per event: the relay records a delivery per (record, module), so a second
|
||||
// subscription of this module to the same event would never run. The audit event and the usage fact
|
||||
// first, then the project's other members are told (adapters/notifications.ts).
|
||||
subscribe(noteCreated, async (event, ctx) => {
|
||||
await onNoteCreated(event, ctx)
|
||||
await notifyNoteCreated(event, ctx)
|
||||
}),
|
||||
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).
|
||||
// The workspace entry is linked above every page of the workspace, to `workspacePages` of modules/pages.ts.
|
||||
ui: {
|
||||
nav: [
|
||||
{ scope: "project", label: "notes.title", href: "notes", permission: "notes.note.read" },
|
||||
{
|
||||
scope: "workspace",
|
||||
label: "notes.workspace.title",
|
||||
href: "notes",
|
||||
permission: "notes.note.read",
|
||||
icon: "file",
|
||||
},
|
||||
],
|
||||
},
|
||||
// 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/
|
||||
})
|
||||
@@ -0,0 +1,183 @@
|
||||
"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.
|
||||
//
|
||||
// The list is live: the page's view hands over the attachments it rendered as the seed, and this reads
|
||||
// them again through `notes.note.get` whenever the live stream announces a change of the note -- a link
|
||||
// or an unlink moves the note's version (core/notes.ts `touchNote`), whoever made it. A write resolves
|
||||
// once the stream has seen it, so the list already shows it; nothing reloads the page.
|
||||
|
||||
import { formatMessage } from "@conusai-kit/i18n/format"
|
||||
import { downloadHref, type ProjectSlugs } from "@conusai-kit/ui"
|
||||
import { useRead, useUpload, useWrite } from "@conusai-kit/ui/data"
|
||||
import {
|
||||
ConfirmDialog,
|
||||
FormLayout,
|
||||
PageSection,
|
||||
type ResourceColumn,
|
||||
ResourceList,
|
||||
TextField,
|
||||
} from "@conusai-kit/ui/patterns"
|
||||
import { Button } from "@conusai-kit/ui/primitives"
|
||||
import type { ReactElement } from "react"
|
||||
import { type AttachmentView, NOTE_COLLECTION } from "../contract/index.js"
|
||||
|
||||
export interface AttachmentRow {
|
||||
readonly attachmentId: string
|
||||
readonly fileId: string
|
||||
readonly filename: string
|
||||
readonly contentType: string
|
||||
/** Already formatted from the `sizeKb` message. */
|
||||
readonly size: string
|
||||
}
|
||||
|
||||
/** A size in whole kilobytes, at least one: the number the `sizeKb` message is filled with. */
|
||||
export const kilobytes = (bytes: number): number => Math.max(1, Math.ceil(bytes / 1024))
|
||||
|
||||
export interface AttachmentLabels {
|
||||
readonly title: string
|
||||
/** `{count, plural, ...}`, with its placeholder left in: only the browser knows the live count. */
|
||||
readonly count: string
|
||||
readonly empty: string
|
||||
readonly file: string
|
||||
readonly type: string
|
||||
readonly sizeHeading: string
|
||||
/** `{size} KB`, with its placeholder left in. */
|
||||
readonly sizeKb: 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
|
||||
/** What removing it does to the file. */
|
||||
readonly removeConfirm: 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
|
||||
}
|
||||
|
||||
export function Attachments({
|
||||
slugs,
|
||||
noteId,
|
||||
seed,
|
||||
enabled,
|
||||
accept,
|
||||
labels,
|
||||
locale,
|
||||
}: {
|
||||
slugs: ProjectSlugs
|
||||
noteId: string
|
||||
/** The attachments the server rendered: shown at once, then kept current. */
|
||||
seed: readonly AttachmentView[]
|
||||
enabled: boolean
|
||||
accept: readonly string[]
|
||||
labels: AttachmentLabels
|
||||
/** The locale the page was rendered in, for the count's plural and the sizes. */
|
||||
locale: string
|
||||
}): ReactElement {
|
||||
const upload = useUpload()
|
||||
const add = useWrite<ProjectSlugs & { noteId: string; fileId: string }, unknown>("notes.attachment.add", {
|
||||
org: slugs.org,
|
||||
})
|
||||
const removal = useWrite<ProjectSlugs & { noteId: string; attachmentId: string }, unknown>(
|
||||
"notes.attachment.remove",
|
||||
{ org: slugs.org },
|
||||
)
|
||||
const { data } = useRead<{ attachments: readonly AttachmentView[] }>(
|
||||
"notes.note.get",
|
||||
{ ...slugs, noteId },
|
||||
{
|
||||
live: {
|
||||
org: slugs.org,
|
||||
topic: { ws: slugs.workspace, project: slugs.project },
|
||||
entities: [NOTE_COLLECTION.entity],
|
||||
},
|
||||
initial: { attachments: seed },
|
||||
},
|
||||
)
|
||||
const rows: AttachmentRow[] = (data?.attachments ?? seed).map((attachment) => ({
|
||||
attachmentId: attachment.attachmentId,
|
||||
fileId: attachment.fileId,
|
||||
filename: attachment.filename,
|
||||
contentType: attachment.contentType,
|
||||
size: formatMessage(labels.sizeKb, { size: kilobytes(attachment.size) }, locale),
|
||||
}))
|
||||
|
||||
const attach = async (_: unknown, form: FormData): Promise<void> => {
|
||||
const file = form.get("file")
|
||||
if (!(file instanceof File) || file.size === 0) return
|
||||
const stored = await upload(slugs, file)
|
||||
await add.mutate({ ...slugs, noteId, fileId: stored.fileId })
|
||||
}
|
||||
|
||||
const columns: ResourceColumn<AttachmentRow>[] = [
|
||||
{ id: "filename", header: labels.file, wrap: true },
|
||||
{ id: "contentType", header: labels.type },
|
||||
{ id: "size", header: labels.sizeHeading, align: "end" },
|
||||
{
|
||||
id: "actions",
|
||||
header: labels.actions,
|
||||
cell: (row) => {
|
||||
const name = labels.remove.replace("{filename}", row.filename)
|
||||
return (
|
||||
<>
|
||||
<a href={downloadHref("files.file.download", { ...slugs, fileId: row.fileId })}>
|
||||
{labels.download.replace("{filename}", row.filename)}
|
||||
</a>{" "}
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button variant="outline" size="sm" aria-label={name}>
|
||||
{labels.removeShort}
|
||||
</Button>
|
||||
}
|
||||
title={name}
|
||||
description={labels.removeConfirm}
|
||||
confirmLabel={labels.removeShort}
|
||||
onConfirm={async () => {
|
||||
await removal.mutate({ ...slugs, noteId, attachmentId: row.attachmentId })
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<PageSection title={labels.title}>
|
||||
<ResourceList
|
||||
label={labels.title}
|
||||
columns={columns}
|
||||
data={rows}
|
||||
rowId={(row) => row.attachmentId}
|
||||
count={formatMessage(labels.count, { count: rows.length }, locale)}
|
||||
rowLabel={(row) => row.filename}
|
||||
empty={{ title: labels.empty }}
|
||||
/>
|
||||
{enabled ? (
|
||||
<FormLayout submitLabel={labels.uploadSubmit} busyLabel={labels.uploadBusy} onSubmit={attach}>
|
||||
<TextField
|
||||
name="file"
|
||||
type="file"
|
||||
label={labels.uploadLabel}
|
||||
description={labels.uploadHint}
|
||||
accept={accept.join(",")}
|
||||
required
|
||||
/>
|
||||
</FormLayout>
|
||||
) : (
|
||||
<p role="status">{labels.off}</p>
|
||||
)}
|
||||
</PageSection>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client"
|
||||
|
||||
// Deleting a note, in two steps. The attached files are not deleted with the note (they are the
|
||||
// project's); the confirmation says so.
|
||||
|
||||
import type { ProjectSlugs } from "@conusai-kit/ui"
|
||||
import { useWrite } from "@conusai-kit/ui/data"
|
||||
import { ConfirmDialog, usePageNavigation } from "@conusai-kit/ui/patterns"
|
||||
import { Button } from "@conusai-kit/ui/primitives"
|
||||
import type { ReactElement } from "react"
|
||||
|
||||
export interface DeleteNoteLabels {
|
||||
readonly button: string
|
||||
readonly confirm: string
|
||||
readonly yes: string
|
||||
readonly no: 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 navigation = usePageNavigation()
|
||||
const deletion = useWrite<Record<string, unknown>, unknown>("notes.note.delete", { org: slugs.org })
|
||||
return (
|
||||
<ConfirmDialog
|
||||
trigger={<Button variant="outline">{labels.button}</Button>}
|
||||
title={labels.button}
|
||||
description={labels.confirm}
|
||||
confirmLabel={labels.yes}
|
||||
cancelLabel={labels.no}
|
||||
onConfirm={async () => {
|
||||
await deletion.mutate({ ...slugs, noteId })
|
||||
navigation.navigate(listHref)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// 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, kilobytes } from "./attachments.js"
|
||||
export { DeleteNote, type DeleteNoteLabels } from "./delete-note.js"
|
||||
export {
|
||||
attachmentLabels,
|
||||
deleteLabels,
|
||||
type NotesResourceLabels,
|
||||
resourceLabels,
|
||||
type Translate,
|
||||
} from "./labels.js"
|
||||
export { useLiveNotes } from "./live-notes.js"
|
||||
export { type NotesProps, NotesResource } from "./notes-resource.js"
|
||||
export { type NotesPage, notesPage } from "./pages.js"
|
||||
export { ProjectPicker, type ProjectPickerProps } from "./project-picker.js"
|
||||
export {
|
||||
type NotesWorkspaceData,
|
||||
type NotesWorkspacePage,
|
||||
notesWorkspacePage,
|
||||
WORKSPACE_LABEL_KEYS,
|
||||
} from "./workspace-page.js"
|
||||
@@ -0,0 +1,61 @@
|
||||
// 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)`.
|
||||
|
||||
import type { AttachmentLabels } from "./attachments.js"
|
||||
import type { DeleteNoteLabels } from "./delete-note.js"
|
||||
|
||||
export type Translate = (key: string, values?: Record<string, string | number>) => string
|
||||
|
||||
export function resourceLabels(t: Translate) {
|
||||
return {
|
||||
title: t("title"),
|
||||
/** A note: the table's name. */
|
||||
one: t("list.one"),
|
||||
/** The create action, a whole message: a language agrees the adjective with the noun. */
|
||||
new: t("list.new"),
|
||||
empty: t("list.empty"),
|
||||
note: t("list.note"),
|
||||
titleField: t("list.title"),
|
||||
body: t("list.body"),
|
||||
attachments: t("list.attachments"),
|
||||
updated: t("list.updated"),
|
||||
}
|
||||
}
|
||||
|
||||
export type NotesResourceLabels = ReturnType<typeof resourceLabels>
|
||||
|
||||
/** `count` and `sizeKb` keep their placeholders: the list is live, so the browser fills them in. */
|
||||
export function attachmentLabels(t: Translate): AttachmentLabels {
|
||||
return {
|
||||
title: t("attachments.title"),
|
||||
count: t("attachments.count"),
|
||||
empty: t("attachments.empty"),
|
||||
file: t("attachments.file"),
|
||||
type: t("attachments.type"),
|
||||
sizeHeading: t("attachments.size"),
|
||||
sizeKb: t("attachments.sizeKb"),
|
||||
actions: t("attachments.actions"),
|
||||
download: t("attachments.download", { filename: "{filename}" }),
|
||||
remove: t("attachments.remove", { filename: "{filename}" }),
|
||||
removeShort: t("attachments.removeShort"),
|
||||
removeConfirm: t("attachments.removeConfirm"),
|
||||
off: t("attachments.off"),
|
||||
uploadLabel: t("upload.label"),
|
||||
uploadHint: t("upload.hint"),
|
||||
uploadSubmit: t("upload.submit"),
|
||||
uploadBusy: t("upload.busy"),
|
||||
}
|
||||
}
|
||||
|
||||
export function deleteLabels(t: Translate): DeleteNoteLabels {
|
||||
return {
|
||||
button: t("delete.button"),
|
||||
confirm: t("delete.confirm"),
|
||||
yes: t("delete.yes"),
|
||||
no: t("delete.no"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client"
|
||||
|
||||
// The project's notes, live (umbrella TS6.4 and TS6.6): the page's view renders the first page and hands it
|
||||
// over as the seed, so the page arrives complete; this keeps it current through the organization's live stream
|
||||
// -- a note another member adds, renames or deletes appears without a reload. The rows come from
|
||||
// `notes.note.getMany`, under the same permission as the list, so the stream itself never carries a note's
|
||||
// contents.
|
||||
|
||||
import type { ProjectSlugs } from "@conusai-kit/ui"
|
||||
import { liveCollection, useDataClient, useLive } from "@conusai-kit/ui/data"
|
||||
// Its live sync stops when no live query reads it any more (TanStack DB's collection GC).
|
||||
import { useState } from "react"
|
||||
import { NOTE_COLLECTION, type NoteView } from "../contract/index.js"
|
||||
|
||||
export function useLiveNotes(slugs: ProjectSlugs, seed: readonly NoteView[]): readonly NoteView[] {
|
||||
const client = useDataClient()
|
||||
// One collection for the life of the page: a page of another project mounts its own, and the seed is the
|
||||
// first render's.
|
||||
const [notes] = useState(() =>
|
||||
liveCollection<NoteView>({
|
||||
client,
|
||||
org: slugs.org,
|
||||
topic: { ws: slugs.workspace, project: slugs.project },
|
||||
entity: NOTE_COLLECTION.entity,
|
||||
key: NOTE_COLLECTION.key,
|
||||
version: "version",
|
||||
seed,
|
||||
list: {
|
||||
procedure: NOTE_COLLECTION.list,
|
||||
input: { ...slugs },
|
||||
rows: (out) => (out as { notes: NoteView[] }).notes,
|
||||
},
|
||||
getMany: {
|
||||
procedure: NOTE_COLLECTION.getMany,
|
||||
input: { ...slugs },
|
||||
rows: (out) => (out as { items: NoteView[] }).items,
|
||||
},
|
||||
}),
|
||||
)
|
||||
// Newest first, as the list procedure orders them, through the query operators (umbrella §7.2).
|
||||
const { data, isReady } = useLive(notes, (q, rows) =>
|
||||
q.from({ note: rows }).orderBy(({ note }: { note: NoteView }) => note.createdAt, "desc"),
|
||||
)
|
||||
// Until the collection is ready the server's rows are the answer, so the first paint matches it.
|
||||
return isReady ? data : seed
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
"use client"
|
||||
|
||||
// The project's notes as one record type: the list, a new note and an edit in the right panel, all from this
|
||||
// declaration. Attachments need a page, so the panel offers it ("Open in full"). The rows are live (live-notes.ts);
|
||||
// a write resolves once the stream has seen it, so the list shows it the moment the panel closes.
|
||||
|
||||
import { emitAppEvent, type ProjectSlugs } from "@conusai-kit/ui"
|
||||
import { useDataClient, useWrite } from "@conusai-kit/ui/data"
|
||||
import { defineResource, ResourcePage } from "@conusai-kit/ui/patterns"
|
||||
import { type ReactElement, type ReactNode, useRef } from "react"
|
||||
import { z } from "zod"
|
||||
import type { NoteView } from "../contract/index.js"
|
||||
import type { NotesResourceLabels } from "./labels.js"
|
||||
import { useLiveNotes } from "./live-notes.js"
|
||||
|
||||
const schema = z.object({ title: z.string().min(1).max(200), body: z.string().max(20_000) })
|
||||
|
||||
export interface NotesProps {
|
||||
readonly slugs: ProjectSlugs
|
||||
/** The first page, rendered on the server. */
|
||||
readonly seed: readonly NoteView[]
|
||||
readonly labels: NotesResourceLabels
|
||||
readonly intro: ReactNode
|
||||
/** The locale the server formatted dates in, so the rows read as they were rendered. */
|
||||
readonly locale: string
|
||||
}
|
||||
|
||||
export function NotesResource({ slugs, seed, labels, intro, locale }: NotesProps): ReactElement {
|
||||
const client = useDataClient()
|
||||
const rows = useLiveNotes(slugs, seed)
|
||||
const create = useWrite<Record<string, unknown>, NoteView>("notes.note.create", { org: slugs.org })
|
||||
const update = useWrite<Record<string, unknown>, NoteView>("notes.note.update", { org: slugs.org })
|
||||
const remove = useWrite<Record<string, unknown>, unknown>("notes.note.delete", { org: slugs.org })
|
||||
// A save carries the version the panel read, so a second editor's is a 409 and never a silent overwrite.
|
||||
const read = useRef(new Map<string, number>())
|
||||
for (const { noteId, version } of rows) if (!read.current.has(noteId)) read.current.set(noteId, version)
|
||||
const day = new Intl.DateTimeFormat(locale).format
|
||||
const resource = defineResource<NoteView, z.output<typeof schema>>({
|
||||
id: "notes",
|
||||
label: labels.one,
|
||||
newLabel: labels.new,
|
||||
columns: [
|
||||
{ id: "title", header: labels.note, wrap: true, minWidth: "10rem", allowsSorting: true },
|
||||
{ id: "attachmentCount", header: labels.attachments, align: "end" },
|
||||
{ id: "updatedAt", header: labels.updated, allowsSorting: true, cell: (n) => day(new Date(n.updatedAt)) },
|
||||
],
|
||||
rowId: (note) => note.noteId,
|
||||
source: { rows },
|
||||
async get(noteId) {
|
||||
const { value } = await client.call<{ note: NoteView }>("notes.note.get", { ...slugs, noteId })
|
||||
read.current.set(noteId, value.note.version)
|
||||
return value.note
|
||||
},
|
||||
schema,
|
||||
fields: [
|
||||
{ name: "title", label: labels.titleField, kind: "text" },
|
||||
{ name: "body", label: labels.body, kind: "textarea" },
|
||||
],
|
||||
async create(input) {
|
||||
const note = await create.mutate({ ...slugs, ...input })
|
||||
await emitAppEvent("note_created") // what an in-app guide's checklist task listens for
|
||||
return note
|
||||
},
|
||||
update: (id, input) => update.mutate({ ...slugs, noteId: id, ...input, expectedVersion: read.current.get(id) }),
|
||||
remove: (noteId) => remove.mutate({ ...slugs, noteId }).then(() => undefined),
|
||||
href: (n) => `/${slugs.org}/${slugs.workspace}/${slugs.project}/notes/${n.noteId}`,
|
||||
permissions: { write: "notes.note.write", remove: "notes.note.delete" },
|
||||
})
|
||||
return <ResourcePage resource={resource} title={labels.title} description={intro} empty={{ title: labels.empty }} />
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
// The notes module's page, on the shell's F10 contract (docs/reference/app-kit.md): a loader the web
|
||||
// shell runs on the server under `/<org>/<workspace>/<project>/notes`, and the view of what it returned.
|
||||
//
|
||||
// /notes the list, on `ResourcePage`: a note opens, and a new one starts, in the right panel
|
||||
// /notes/<noteId> one note in full: its text, its attachments, deleting it
|
||||
//
|
||||
// Every read is a procedure through the same caller the API uses (spec §6.2: one API path), and every
|
||||
// label is translated here, on the server: the view receives strings and rows, never a caller or a
|
||||
// catalogue. Every write is a client component that calls a procedure through the shell's transport.
|
||||
// This is the worked example the other modules' pages follow (apps/web/ROUTES.md).
|
||||
|
||||
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 { Page, PageHeader, PageSection } from "@conusai-kit/ui/patterns"
|
||||
import { Button } from "@conusai-kit/ui/primitives"
|
||||
import type { ReactElement } from "react"
|
||||
import { ATTACHMENT_CONTENT_TYPES, type AttachmentView, type NoteView, noteGet, noteList } from "../contract/index.js"
|
||||
import { FeedbackButton } from "../guides/feedback-button.js" // kit:guides
|
||||
import { type FeedbackButtonLabels, feedbackLabels } from "../guides/feedback-labels.js" // kit:guides
|
||||
import { type AttachmentLabels, Attachments } from "./attachments.js"
|
||||
import { DeleteNote, type DeleteNoteLabels } from "./delete-note.js"
|
||||
import { attachmentLabels, deleteLabels, type NotesResourceLabels, resourceLabels } from "./labels.js"
|
||||
import { NotesResource } from "./notes-resource.js"
|
||||
|
||||
/** The list: its labels and the first page of notes, the resource's seed. */
|
||||
export interface NotesListData {
|
||||
readonly kind: "list"
|
||||
readonly intro: string
|
||||
readonly feedback: FeedbackButtonLabels // kit:guides
|
||||
readonly labels: NotesResourceLabels
|
||||
readonly notes: readonly NoteView[]
|
||||
}
|
||||
|
||||
/** One note, with its attachments as the live list's seed. */
|
||||
export interface NoteDetailData {
|
||||
readonly kind: "note"
|
||||
readonly back: string
|
||||
readonly changed: string
|
||||
readonly note: { readonly noteId: string; readonly title: string; readonly body: string }
|
||||
/** What the link to the note's panel on the list says. */
|
||||
readonly edit: string
|
||||
readonly attachments: readonly AttachmentView[]
|
||||
readonly attachmentsEnabled: boolean
|
||||
readonly attachmentLabels: AttachmentLabels
|
||||
readonly remove: DeleteNoteLabels
|
||||
}
|
||||
|
||||
export type NotesPageData = NotesListData | NoteDetailData
|
||||
|
||||
export type NotesPage = ProjectPage<TransportCaller, I18n, Container, NotesPageData>
|
||||
|
||||
const listHrefOf = ({ org, workspace, project }: ProjectPageViewProps<unknown>["slugs"]): string =>
|
||||
`/${org}/${workspace}/${project}/notes`
|
||||
|
||||
export const notesPage: NotesPage = projectPage({
|
||||
async load({ slugs, segments, caller, i18n }): Promise<NotesPageData> {
|
||||
const t = (key: string, values?: Record<string, string | number>) => i18n.message(`notes.${key}`, values)
|
||||
|
||||
if (segments.length === 0) {
|
||||
const { notes } = await caller.call(noteList, slugs)
|
||||
return {
|
||||
kind: "list",
|
||||
intro: t("intro"),
|
||||
feedback: feedbackLabels(t), // kit:guides
|
||||
labels: resourceLabels(t),
|
||||
notes,
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
kind: "note",
|
||||
back: t("detail.back"),
|
||||
changed: t("detail.changed", { date: i18n.date(new Date(note.updatedAt)) }),
|
||||
note: { noteId: note.noteId, title: note.title, body: note.body },
|
||||
edit: t("detail.edit"),
|
||||
attachments,
|
||||
attachmentsEnabled,
|
||||
attachmentLabels: attachmentLabels(t),
|
||||
remove: deleteLabels(t),
|
||||
}
|
||||
},
|
||||
|
||||
View({ slugs, locale, data }: ProjectPageViewProps<NotesPageData>): ReactElement {
|
||||
const listHref = listHrefOf(slugs)
|
||||
if (data.kind === "list") {
|
||||
return (
|
||||
<NotesResource
|
||||
slugs={slugs}
|
||||
seed={data.notes}
|
||||
labels={data.labels}
|
||||
locale={locale}
|
||||
intro={
|
||||
<>
|
||||
{data.intro}
|
||||
<FeedbackButton labels={data.feedback} /* kit:guides */ />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
const { note } = data
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader
|
||||
title={note.title}
|
||||
description={data.changed}
|
||||
breadcrumb={<a href={listHref}>{data.back}</a>}
|
||||
actions={
|
||||
<>
|
||||
<Button asChild variant="outline">
|
||||
<a href={`${listHref}?record=${note.noteId}`}>{data.edit}</a>
|
||||
</Button>
|
||||
<DeleteNote slugs={slugs} noteId={note.noteId} listHref={listHref} labels={data.remove} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<PageSection>
|
||||
<p>{note.body}</p>
|
||||
</PageSection>
|
||||
<Attachments
|
||||
slugs={slugs}
|
||||
noteId={note.noteId}
|
||||
seed={data.attachments}
|
||||
enabled={data.attachmentsEnabled}
|
||||
accept={ATTACHMENT_CONTENT_TYPES}
|
||||
labels={data.attachmentLabels}
|
||||
locale={locale}
|
||||
/>
|
||||
</Page>
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client"
|
||||
|
||||
// The workspace page's projects, with a count of those ticked. The count exists only in the browser, so the
|
||||
// loader cannot translate it: the page hands over the message with its placeholder left in, and this island
|
||||
// fills it with `formatMessage`, which brings the language's plural rules and no catalogue (F10, decision 2).
|
||||
|
||||
import { formatMessage } from "@conusai-kit/i18n/format"
|
||||
import { ResourceList } from "@conusai-kit/ui/patterns"
|
||||
import { useUiLabel } from "@conusai-kit/ui/primitives"
|
||||
import { type ReactElement, useState } from "react"
|
||||
|
||||
export interface ProjectPickerProps {
|
||||
readonly label: string
|
||||
readonly column: string
|
||||
readonly empty: string
|
||||
readonly projects: readonly { readonly slug: string; readonly name: string; readonly href: string }[]
|
||||
/** `{count, plural, ...}`, in the person's language, not yet filled in. */
|
||||
readonly selected: string
|
||||
readonly locale: string
|
||||
}
|
||||
|
||||
export function ProjectPicker({ projects, selected, locale, label, column, empty }: ProjectPickerProps): ReactElement {
|
||||
const [picked, setPicked] = useState<ReadonlySet<string>>(new Set())
|
||||
const selectAll = useUiLabel("selectAll")
|
||||
return (
|
||||
<ResourceList
|
||||
label={label}
|
||||
columns={[{ id: "name", header: column, wrap: true }]}
|
||||
data={projects}
|
||||
rowId={(project) => project.slug}
|
||||
rowHref={(project) => project.href}
|
||||
rowLabel={(project) => project.name}
|
||||
selectionMode="multiple"
|
||||
selectionLabel={selectAll}
|
||||
rowSelectionLabel={(project) => project.name}
|
||||
selectedKeys={picked}
|
||||
onSelectionChange={setPicked}
|
||||
count={formatMessage(selected, { count: picked.size }, locale)}
|
||||
empty={{ title: empty }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
// A page above any project, on the shell's F10 contract: `/<org>/<workspace>/modules/notes`. It lists the
|
||||
// workspace's projects, each linked to its notes, and `?q=` filters them on the server, so the first byte
|
||||
// already holds the result and a filter is a link anyone can copy.
|
||||
//
|
||||
// The shell answers 403 for `permission` before `load` runs; the rest is a `ProjectPage` with two slugs. This is
|
||||
// the worked example for a catalogue, a list or a setting that belongs to a workspace, not to a project, and for
|
||||
// a page's strings as one record, with a count only the browser knows formatted there (project-picker.tsx).
|
||||
|
||||
import type { Container, TransportCaller } from "@conusai-kit/core"
|
||||
import type { I18n } from "@conusai-kit/i18n"
|
||||
import { projectList } from "@conusai-kit/tenancy"
|
||||
import { type WorkspacePage, workspacePage } from "@conusai-kit/ui"
|
||||
import { FilterBar, Page, PageHeader, PageState, TextField } from "@conusai-kit/ui/patterns"
|
||||
import { Button } from "@conusai-kit/ui/primitives"
|
||||
import { ProjectPicker } from "./project-picker.js"
|
||||
|
||||
/** Every string of the page, as one record. `selected` keeps its placeholder: only the browser knows the count. */
|
||||
export const WORKSPACE_LABEL_KEYS = [
|
||||
"title",
|
||||
"intro",
|
||||
"filter",
|
||||
"apply",
|
||||
"clear",
|
||||
"empty",
|
||||
"noProjects",
|
||||
"selected",
|
||||
] as const
|
||||
|
||||
export interface NotesWorkspaceData {
|
||||
readonly labels: Readonly<Record<(typeof WORKSPACE_LABEL_KEYS)[number], string>>
|
||||
readonly query: string
|
||||
readonly projects: readonly { readonly slug: string; readonly name: string }[]
|
||||
}
|
||||
|
||||
export type NotesWorkspacePage = WorkspacePage<TransportCaller, I18n, Container, NotesWorkspaceData>
|
||||
|
||||
export const notesWorkspacePage: NotesWorkspacePage = workspacePage({
|
||||
permission: "notes.note.read",
|
||||
async load({ slugs, search, caller, i18n }): Promise<NotesWorkspaceData> {
|
||||
const query = search.q ?? ""
|
||||
const wanted = query.trim().toLowerCase()
|
||||
const { projects } = await caller.call(projectList, slugs)
|
||||
return {
|
||||
labels: Object.fromEntries(
|
||||
WORKSPACE_LABEL_KEYS.map((key) => [key, i18n.message(`notes.workspace.${key}`)]),
|
||||
) as NotesWorkspaceData["labels"],
|
||||
query,
|
||||
projects: projects
|
||||
.filter((project) => project.name.toLowerCase().includes(wanted))
|
||||
.map(({ slug, name }) => ({ slug, name })),
|
||||
}
|
||||
},
|
||||
View({ slugs, locale, data }) {
|
||||
const { labels } = data
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader title={labels.title} description={labels.intro} />
|
||||
<FilterBar
|
||||
method="get"
|
||||
actions={
|
||||
<>
|
||||
<Button type="submit" variant="secondary">
|
||||
{labels.apply}
|
||||
</Button>
|
||||
{data.query.trim() && data.projects.length > 0 && (
|
||||
<Button asChild variant="link">
|
||||
<a href={`/${slugs.org}/${slugs.workspace}/modules/notes`}>{labels.clear}</a>
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<TextField
|
||||
name="q"
|
||||
label={labels.filter}
|
||||
placeholder={labels.filter}
|
||||
type="search"
|
||||
defaultValue={data.query}
|
||||
/>
|
||||
</FilterBar>
|
||||
{data.projects.length === 0 ? (
|
||||
<PageState
|
||||
title={data.query.trim() ? labels.empty : labels.noProjects}
|
||||
action={
|
||||
data.query.trim() ? (
|
||||
<Button asChild variant="outline">
|
||||
<a href={`/${slugs.org}/${slugs.workspace}/modules/notes`}>{labels.clear}</a>
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ProjectPicker
|
||||
label={labels.title}
|
||||
column={labels.filter}
|
||||
empty={labels.noProjects}
|
||||
projects={data.projects.map((project) => ({
|
||||
...project,
|
||||
href: `/${slugs.org}/${slugs.workspace}/${project.slug}/notes`,
|
||||
}))}
|
||||
selected={labels.selected}
|
||||
locale={locale}
|
||||
/>
|
||||
)}
|
||||
</Page>
|
||||
)
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user