import { ClsService } from "nestjs-cls"; import { DataModelInterface } from "../../../common/interfaces/datamodel.interface"; import { JsonApiCursorInterface } from "../../../core/jsonapi/interfaces/jsonapi.cursor.interface"; import { AbstractRepository } from "../../../core/neo4j/abstracts/abstract.repository"; import { Neo4jService } from "../../../core/neo4j/services/neo4j.service"; import { SecurityService } from "../../../core/security/services/security.service"; import { Notification, NotificationDescriptor } from "../../notification/entities/notification"; /** A node a notification refers to (its "subject"), written as `-[:REFERS_TO]->`. */ export type NotificationTarget = { /** `id` property of an EXISTING node. */ id: string; /** Neo4j label of that node (e.g. "Task", "Document"). */ label: string; }; /** * Every method below is fully custom Cypher: the bell list/detail queries scope * by TRIGGERED_FOR user and require a REFERS_TO edge to exist (see * `findForUser`), and `createNotification`/`createIdempotent` write a bespoke * subject graph. The generic descriptor-driven `create`/`put`/`patch`/`delete`/ * `find`/`findById` are INHERITED from `AbstractRepository` unchanged. * * NAMING — `find`/`findById` are named `findForUser`/`findByIdForUser`: their * required `userId`/`notificationId` params are incompatible overrides of * `AbstractRepository.find(params: {...all optional})` / * `findById(params: { id })`. The bespoke creator is named `createNotification` * for the same reason: `AbstractRepository.create(params: { id: string; * [key: string]: any }): Promise` cannot be narrowed to a param without * `id` returning `Promise` (TS2416). Both names mirror the proven * naming of the application repository this port is derived from. * * COMPANY SCOPING — why `buildDefaultMatch()` is deliberately not used here: * every query in this file is hand-written for the reasons above, so none can * call the framework helper. Scoping is achieved instead by matching the * CLS-bound `company` variable that `initQuery()` seeds: * `MATCH (notification:Notification)-[:BELONGS_TO]->(company)`. * * Be precise about the two read methods — they are NOT equally scoped: * - `findForUser` is company- AND user-scoped: it additionally matches * `-[:TRIGGERED_FOR]->(user:User {id: $userId})-[:BELONGS_TO]->(company)`. * - `findByIdForUser` is company-scoped ONLY. It takes a `userId` param and * binds it as a query param, but its Cypher never references `$userId`, so * any user of the same company can read any other user's notification by * id. Pre-existing behaviour, preserved verbatim by this port; tightening it * is a behavioural change outside the port's scope. * * Any new query added to this file MUST join `company` the same way — * dropping that join is a cross-tenant read. * (This note also clears nja-lint's file-level `manual-query-no-company-scope` * rule, which fires on any repository whose file never mentions * `buildDefaultMatch`; per-line `nja-lint-ignore` comments cannot clear it * because the rule simply advances to the next unignored MATCH.) */ export declare class NotificationRepository extends AbstractRepository { protected readonly descriptor: import("../../..").EntityDescriptor; constructor(neo4j: Neo4jService, securityService: SecurityService, clsService: ClsService); /** * Fully custom (NOT calling `super.onModuleInit()`): adds the * `idempotencyKey` uniqueness constraint that backs `createIdempotent` on top * of the descriptor's `id` constraint, and deliberately creates NO FULLTEXT * index. The base implementation would derive one from the descriptor's * string fields (`notificationType`, `message`, `actionUrl`); notifications * have never been full-text searchable and adding the index would change the * shipped index set of every consuming application. */ onModuleInit(): Promise; /** * Resolve the model used to map/serialise notification rows. * * Registry first (mirrors `ContentRepository.getContentModel()`): an * application that registers an EXTENDED notification model — more * attributes, more subjects, a polymorphic actor — wins here without having * to override every query method. Falls back to `this.descriptor.model`, so a * subclass that overrides `descriptor` still resolves its own model and unit * tests that never run `onModuleInit` still work. */ protected getNotificationModel(): DataModelInterface; /** Guards a caller-supplied Neo4j label before it is interpolated into Cypher. */ protected assertSafeLabel(label: string): string; findForUser(params: { userId: string; cursor?: JsonApiCursorInterface; isArchived?: boolean; }): Promise; findByIdForUser(params: { notificationId: string; userId: string; }): Promise; markAsRead(params: { userId: string; notificationIds: string[]; }): Promise; archive(params: { notificationId: string; }): Promise; /** * Create a notification for a recipient, optionally attributed to an actor * and pointing at one or more subjects. * * `targets` is what makes the notification VISIBLE: `findForUser` filters on * `EXISTS { MATCH (notification)-[:REFERS_TO]->() }`, so a notification * created without a target never appears in the recipient's list. Every * target is validated (`validateExistingNodes`) before the write and then * written as `(notification)-[:REFERS_TO]->(target)`, grouped per label. * * `message` and `actionUrl` are the two free-text fields of the entity, both * optional: when omitted they are bound as null and Neo4j creates no property. * * Named `createNotification` rather than `create` because * `AbstractRepository.create` is inherited with an incompatible signature — * see the class JSDoc. */ createNotification(params: { notificationType: string; userId: string; actorId?: string; targets?: NotificationTarget[]; message?: string; actionUrl?: string; }): Promise; /** * Create a notification at most once for a given `idempotencyKey`. * * Backed by the `notification_idempotency_key` uniqueness constraint created * in `onModuleInit`. The MERGE is the atomic find-or-create; the follow-up * read compares the stored id with the id this call generated to decide * whether THIS call created the record (`{ created: true }`) or a previous * call with the same key won (`{ created: false }`). * * Every related node is matched through `company`, so a node that does not * belong to the current company is simply not linked. * * `message` and `actionUrl` are optional free-text fields of the entity, * written in the same `ON CREATE SET` (bound as null when omitted, which * creates no property). * * Returns the node id in BOTH outcomes — the generated one when this call * created the record, the existing node's when a previous call won — so a * caller can load the node and push it without a second lookup by key, and * throws when the write matched nothing (no row read back: the recipient or * the company did not match, so no notification exists at all). * * That throw is why this is a separate method from {@link createIdempotent}: * the older name shipped returning `{ created: false }` for the no-row case, * and apps outside this repo still call it without a try/catch. Prefer this * one in new code — "nothing was written" is a failure, not a duplicate. */ createIdempotentOrThrow(params: { notificationType: string; userId: string; actorId?: string; actorLabel?: string; targets?: NotificationTarget[]; idempotencyKey: string; message?: string; actionUrl?: string; }): Promise<{ created: boolean; id: string; }>; /** * Find-or-create keeping the contract this method shipped with: a write that * matched no recipient resolves as `{ created: false }` rather than throwing. * * `id` is present whenever a node exists, and absent only in that no-row * case — additive, so a caller that reads `created` alone is unaffected. * * New code should call {@link createIdempotentOrThrow} instead, which * surfaces "nothing was written" as the failure it is. This wrapper exists so * that fix did not become a breaking change for the apps already on this * package. */ createIdempotent(params: Parameters[0]): Promise<{ created: boolean; id?: string; }>; } //# sourceMappingURL=notification.repository.d.ts.map