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