@@ -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,
|
||||
]
|
||||
Reference in New Issue
Block a user