@antelopejs/interface-dms 0.7.1 → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +70 -68
  3. package/dist/auth/db/models/sessions.model.d.ts +1 -0
  4. package/dist/auth/db/models/userExternalIdentities.model.d.ts +1 -0
  5. package/dist/auth/db/models/users.model.d.ts +1 -0
  6. package/dist/base/block-registry/index.d.ts +1 -0
  7. package/dist/base/block-registry/index.js +1 -0
  8. package/dist/base/block-registry/index.js.map +1 -1
  9. package/dist/base/block-registry/presentation.d.ts +21 -0
  10. package/dist/base/block-registry/presentation.js +108 -0
  11. package/dist/base/block-registry/presentation.js.map +1 -0
  12. package/dist/base/block-types.d.ts +1 -0
  13. package/dist/base/block-types.js +1 -0
  14. package/dist/base/block-types.js.map +1 -1
  15. package/dist/base/live-toggle.d.ts +2 -2
  16. package/dist/base/period-selector.d.ts +2 -2
  17. package/dist/base/placeholder.js +2 -2
  18. package/dist/base/placeholder.js.map +1 -1
  19. package/dist/base/table-view/schema.d.ts +4 -4
  20. package/dist/db/internal/exportJobs.model.d.ts +1 -0
  21. package/dist/db/internal/inviteResolutions.model.d.ts +1 -0
  22. package/dist/db/internal/tenantLifecycle.model.d.ts +1 -0
  23. package/dist/db/models/roles.model.d.ts +1 -0
  24. package/dist/db/models/tenantMembers.model.d.ts +1 -0
  25. package/dist/db/models/tenants.model.d.ts +1 -0
  26. package/dist/db/models/user_invites.model.d.ts +1 -0
  27. package/docs/.navigation.yml +2 -0
  28. package/docs/1.core/.navigation.yml +2 -0
  29. package/docs/1.core/1.introduction.md +169 -0
  30. package/docs/1.core/10.layout-banners.md +133 -0
  31. package/docs/1.core/2.pages-and-modules.md +649 -0
  32. package/docs/1.core/3.components.md +287 -0
  33. package/docs/1.core/4.permissions.md +279 -0
  34. package/docs/1.core/5.tenancy.md +358 -0
  35. package/docs/1.core/6.invites.md +239 -0
  36. package/docs/1.core/7.hooks.md +354 -0
  37. package/docs/1.core/8.realtime.md +147 -0
  38. package/docs/1.core/9.quick-actions.md +115 -0
  39. package/docs/2.auth/.navigation.yml +2 -0
  40. package/docs/2.auth/1.introduction.md +75 -0
  41. package/docs/2.auth/2.authentication.md +222 -0
  42. package/docs/2.auth/3.tokens-and-sessions.md +352 -0
  43. package/docs/2.auth/4.users.md +256 -0
  44. package/docs/2.auth/5.external-identities.md +122 -0
  45. package/docs/3.base/.navigation.yml +2 -0
  46. package/docs/3.base/1.introduction.md +202 -0
  47. package/docs/3.base/10.display-blocks.md +136 -0
  48. package/docs/3.base/2.types.md +402 -0
  49. package/docs/3.base/3.forms.md +349 -0
  50. package/docs/3.base/4.tables.md +608 -0
  51. package/docs/3.base/5.charts-and-widgets.md +466 -0
  52. package/docs/3.base/6.layout.md +437 -0
  53. package/docs/3.base/8.data-types.md +361 -0
  54. package/docs/3.base/9.helpers.md +414 -0
  55. package/docs/4.notifications/.navigation.yml +2 -0
  56. package/docs/4.notifications/1.introduction.md +81 -0
  57. package/docs/4.notifications/2.categories-and-subjects.md +214 -0
  58. package/docs/4.notifications/3.sending.md +285 -0
  59. package/docs/5.html-render/.navigation.yml +2 -0
  60. package/docs/5.html-render/1.introduction.md +386 -0
  61. package/docs/5.html-render/2.built-in-emails.md +157 -0
  62. package/docs/images/emails-admin-invite.png +0 -0
  63. package/docs/images/emails-change-notice.png +0 -0
  64. package/docs/images/emails-change-verification.png +0 -0
  65. package/docs/images/emails-digest-logo-dark.png +0 -0
  66. package/docs/images/emails-digest-logo-light.png +0 -0
  67. package/docs/images/emails-digest.png +0 -0
  68. package/docs/images/emails-export-partial.png +0 -0
  69. package/docs/images/emails-export-ready.png +0 -0
  70. package/docs/images/emails-reset-password.png +0 -0
  71. package/docs/images/emails-two-factor.png +0 -0
  72. package/docs/images/emails-user-validation.png +0 -0
  73. package/docs/index.md +109 -0
  74. package/docs/tsconfig.json +11 -0
  75. package/package.json +25 -18
@@ -0,0 +1,414 @@
1
+ ---
2
+ title: "Helpers"
3
+ description: "A few small surfaces sit beside the components and support them: the `@Searchable` decorator that tells a list route which columns free-text search covers, the export-job runner behind every long download, the…"
4
+ ---
5
+
6
+ # Helpers
7
+
8
+ ## Overview
9
+
10
+ A few small surfaces sit beside the components and support them: the `@Searchable` decorator that tells a list route which columns free-text search covers, the export-job runner behind every long download, the tenant-export archive built on top of it, and two helper modules — file references and archive-mode defaults — that the table view uses internally and that are available when you build equivalent routes yourself.
11
+
12
+ ```typescript
13
+ import { Searchable, SearchableMeta } from "@antelopejs/interface-dms/base/searchable";
14
+ import { runExportJob } from "@antelopejs/interface-dms/base/export-jobs";
15
+ import { collectFileRefs } from "@antelopejs/interface-dms/base/helpers/file-refs";
16
+ import { applyArchiveModeDefaultRules } from "@antelopejs/interface-dms/base/helpers/archive-mode-helpers";
17
+ import { startTenantExportJob } from "@antelopejs/interface-dms/base/tenant-export-archive";
18
+ ```
19
+
20
+ `Searchable`, `SearchableMeta` and everything in `export-jobs` and `tenant-export-archive` are also exported from the package root, `@antelopejs/interface-dms/base`. The two `helpers/*` modules are not in that barrel and are imported from their own subpath.
21
+
22
+ ## Make Columns Searchable
23
+
24
+ `@Searchable(compareMode?)` is a property decorator on a data controller field. It records the field, and the compare mode used to match it, on the controller's `SearchableMeta`. The list route reads that record when the request carries a `search` query parameter and ORs one condition per recorded field into the query. A field does not have to be `filterable` to be searched: it is matched with its data-api filter when it has one, and otherwise with the filter of its `@Column` type. A controller with no searchable field ignores the parameter.
25
+
26
+ ```typescript
27
+ import { Listable } from "@antelopejs/interface-data-api/metadata";
28
+ import { DefaultDataTypes } from "@antelopejs/interface-dms/base/data-types";
29
+ import { Searchable } from "@antelopejs/interface-dms/base/searchable";
30
+ import {
31
+ Column,
32
+ Exported,
33
+ } from "@antelopejs/interface-dms/base/table-view";
34
+
35
+ export class customerDataAPI extends DataController(/* … */) {
36
+ @Searchable()
37
+ @Listable()
38
+ @Exported()
39
+ @Column({ name: "Name", type: new DefaultDataTypes.StringType() })
40
+ declare name: string;
41
+
42
+ @Searchable("is")
43
+ @Listable()
44
+ @Column({ name: "Reference", type: new DefaultDataTypes.StringType() })
45
+ declare reference: string;
46
+ }
47
+ ```
48
+
49
+ | Parameter | Description |
50
+ | --- | --- |
51
+ | `compareMode` | Id of a registered `DataCompareMode` used to match this field. Defaults to `"contains"`. |
52
+
53
+ The id is the one the mode was registered under with `@RegisterDataCompareMode` — `"contains"`, `"is"`, `"array_contains_string"` and the rest of the catalogue are listed in [Data Types](/docs/interfaces/base/data-types).
54
+
55
+ ### SearchableMeta
56
+
57
+ `SearchableMeta` is the metadata object the decorator writes to. Read it with `GetMetadata(controller, SearchableMeta)` from `@antelopejs/interface-core`.
58
+
59
+ | Member | Description |
60
+ | --- | --- |
61
+ | `searchableFields` | `Record<string, string>` mapping field name to compare-mode id. |
62
+ | `setSearchableField(key, compareMode)` | Records one field. Called by the decorator. |
63
+ | `getSearchableFields()` | Returns the same record. |
64
+
65
+ ## Run an Export Job
66
+
67
+ `runExportJob` starts a background job, hands you a ticket immediately, and leaves the generation running. The pattern is three routes: one that starts the job, one that reports its progress, one that streams the finished file. The table view's export action is built on exactly these three, and any route of your own can reuse them.
68
+
69
+ ```typescript
70
+ import fs from "node:fs";
71
+ import {
72
+ Context,
73
+ Get,
74
+ Parameter,
75
+ type RequestContext,
76
+ } from "@antelopejs/interface-api";
77
+ import {
78
+ PageController,
79
+ pagesCategory,
80
+ RegisterPage,
81
+ } from "@antelopejs/interface-dms/page";
82
+ import { AuthUser } from "@antelopejs/interface-dms/auth";
83
+ import type { User } from "@antelopejs/interface-dms/auth/db";
84
+ import {
85
+ downloadExportJob,
86
+ getExportJobStatus,
87
+ runExportJob,
88
+ } from "@antelopejs/interface-dms/base/export-jobs";
89
+
90
+ const SCOPE = "customer-report";
91
+
92
+ @RegisterPage()
93
+ export class ReportPage extends PageController("report", {
94
+ displayName: "Reports",
95
+ icon: "i-ph-file-csv",
96
+ category: pagesCategory,
97
+ }) {
98
+ @Get("/report/start")
99
+ start(@Context() ctx: RequestContext, @AuthUser() user: User) {
100
+ return runExportJob<{ year: number }>({
101
+ ctx,
102
+ user,
103
+ scope: SCOPE,
104
+ context: { year: new Date().getFullYear() },
105
+ filename: "customers",
106
+ extension: "csv",
107
+ contentType: "text/csv; charset=utf-8",
108
+ generate: async ({ localPath, context, reportProgress }) => {
109
+ fs.writeFileSync(localPath, "id,name\n");
110
+ // … append rows, calling reportProgress(0-99) as you go
111
+ await reportProgress(50);
112
+ },
113
+ });
114
+ }
115
+
116
+ @Get("/report/status/:jobId")
117
+ status(
118
+ @Context() ctx: RequestContext,
119
+ @Parameter("jobId", "param") jobId: string,
120
+ @AuthUser() user: User,
121
+ ) {
122
+ return getExportJobStatus(ctx, user, jobId, { scope: SCOPE });
123
+ }
124
+
125
+ @Get("/report/download/:jobId")
126
+ download(
127
+ @Context() ctx: RequestContext,
128
+ @Parameter("jobId", "param") jobId: string,
129
+ @AuthUser() user: User,
130
+ ) {
131
+ return downloadExportJob(ctx, user, jobId, { scope: SCOPE });
132
+ }
133
+ }
134
+ ```
135
+
136
+ ### RunExportJobOptions
137
+
138
+ ```typescript
139
+ function runExportJob<TContext = unknown>(
140
+ opts: RunExportJobOptions<TContext>,
141
+ ): Promise<ExportJobTicket>;
142
+ ```
143
+
144
+ | Option | Type | Description |
145
+ | --- | --- | --- |
146
+ | `ctx` | `RequestContext` | The request. The job is stored in the schema of the tenant the request resolves to. Required. |
147
+ | `user` | `User` | The owner of the job. Required; a missing user fails with `401`. |
148
+ | `scope` | `string` | Free-form identifier for the origin of the job, checked again on status and download when you pass it there. Required. |
149
+ | `context` | `TContext` | Scope-specific payload stored with the job and handed back to `generate` and to `isOwnedBy`. |
150
+ | `filename` | `string` | Base name of the produced file, without extension or timestamp. Required. |
151
+ | `extension` | `string` | File extension. Required. |
152
+ | `contentType` | `string` | MIME type used when uploading and when streaming the file back. Required. |
153
+ | `delivery` | `string` | Delivery mode id. Defaults to `DEFAULT_DELIVERY`; an unknown id fails with `400`. |
154
+ | `deliveryPath` | `string` | Client-side path a delivered link points at. Required by email delivery. |
155
+ | `retainUntilExpiry` | `boolean` | Keep the record and its stored file after a download, until the stale-export sweep expires them. |
156
+ | `maxExecutionTimeMs` | `number` | Abort budget for `generate`. Defaults to 10 minutes. |
157
+ | `generate` | `(ctx: ExportJobGenerateContext<TContext>) => Promise<ExportJobResultSummary \| void>` | Writes the file. Required. |
158
+
159
+ `runExportJob` creates the job record, then starts `generate` without awaiting it and returns the ticket. When `generate` resolves, the file at `localPath` is uploaded to file storage and the job is marked completed; when it throws or the abort budget expires, the scratch file is removed and the job is marked failed.
160
+
161
+ A generator that assembled its output from several sources reports what was missing by returning a summary instead of nothing:
162
+
163
+ ```typescript
164
+ interface ExportJobResultSummary {
165
+ partial: boolean;
166
+ failures: ExportJobFailure[];
167
+ }
168
+
169
+ interface ExportJobFailure {
170
+ source: string;
171
+ error: string;
172
+ }
173
+ ```
174
+
175
+ The summary is stored on the job and surfaced on its status, so a consumer can tell a complete export from one that succeeded with holes. `source` names the part that failed — a contributing module id, typically.
176
+
177
+ By default a job is deleted once it has been downloaded. Set `retainUntilExpiry` for a scope whose jobs outlive their first download: one that surfaces a job history, or one whose result is reached through a delivered link.
178
+
179
+ | `ExportJobTicket` field | Type | Description |
180
+ | --- | --- | --- |
181
+ | `jobId` | `string` | Identifier to poll and download with. |
182
+ | `extension` | `string` | Echo of the requested extension. |
183
+ | `filename` | `string` | Echo of the requested base name. |
184
+
185
+ ### ExportJobGenerateContext
186
+
187
+ | Field | Type | Description |
188
+ | --- | --- | --- |
189
+ | `jobId` | `string` | Identifier of this job. |
190
+ | `tenantId` | `string` | Tenant the job belongs to. |
191
+ | `localPath` | `string` | Path your generator writes the file to. |
192
+ | `context` | `TContext` | The payload passed as `context` to `runExportJob`. |
193
+ | `reportProgress` | `(progress: number) => Promise<void>` | Records progress. The value is floored and clamped to `0`–`99`; repeating the last value writes nothing. Throws once the job is aborted. |
194
+ | `signal` | `AbortSignal` | Aborted when the job exceeds `maxExecutionTimeMs`. |
195
+
196
+ ### Status and Download
197
+
198
+ ```typescript
199
+ function getExportJobStatus<TContext = unknown>(
200
+ ctx: RequestContext,
201
+ user: User,
202
+ jobId: string,
203
+ access?: ExportJobAccessOptions<TContext>,
204
+ ): Promise<ExportJobStatus>;
205
+
206
+ function downloadExportJob<TContext = unknown>(
207
+ ctx: RequestContext,
208
+ user: User,
209
+ jobId: string,
210
+ access?: ExportJobAccessOptions<TContext>,
211
+ ): Promise<void>;
212
+ ```
213
+
214
+ `ExportJobStatus` carries `status: ExportStatus`, `progress: number`, and the optional `error`, `filename`, `extension`, `result` and `expiresAt`. `ExportStatus` is the `pending` / `completed` / `failed` enum from [Shared Types](/docs/interfaces/base/types). `result` is the `ExportJobResultSummary` the generator returned, present once the job completed; `expiresAt` is when the sweep will drop the record and its stored file.
215
+
216
+ `getExportJobExpiry(record)` computes that moment from a record directly — `updatedAt` plus `EXPORT_TTL_MS`, which is one day.
217
+
218
+ `downloadExportJob` requires a completed job with a stored result — anything else fails with `400` or `500`. It sets `Content-Disposition` (with a filename built by `buildExportFilename`), `Content-Length` when the size is known, and `Access-Control-Expose-Headers`, then pipes the stored object to the response. The function returns as soon as the pipe is set up rather than awaiting it; once the response stream closes, the stored object and the job record are both deleted.
219
+
220
+ `loadExportJobForUser(ctx, user, jobId, access?)` is the shared step underneath both: it resolves the tenant, loads the record, runs the access checks, and returns the model, the record and the parsed context. Use it when you need the record itself rather than its status.
221
+
222
+ `downloadExportJob` deletes the stored object and the job record once the response stream closes — unless the job was started with `retainUntilExpiry`, in which case both survive until the sweep.
223
+
224
+ ### List a Caller's Jobs
225
+
226
+ ```typescript
227
+ function listExportJobs(
228
+ ctx: RequestContext,
229
+ user: User,
230
+ options?: ListExportJobsOptions,
231
+ ): Promise<ExportJobSummary[]>;
232
+ ```
233
+
234
+ Returns the caller's own jobs, most recent first — the history a scope such as a tenant data export surfaces next to its request action. `ListExportJobsOptions` narrows it: `scope` restricts to one scope, `limit` caps the count and defaults to 20.
235
+
236
+ `ExportJobSummary` extends `ExportJobStatus` with `jobId`, `scope` and `createdAt`. Listing is scoped to the caller, so it needs no access options: another user's jobs are never returned.
237
+
238
+ ### Access Checks
239
+
240
+ | Option | Type | Description |
241
+ | --- | --- | --- |
242
+ | `scope` | `string` | When set, the job's stored `scope` must match. |
243
+ | `isOwnedBy` | `(record, context, user) => boolean` | Extra ACL evaluated after the ownership and scope checks. |
244
+
245
+ `ExportJobOwnership<TContext>` carries only `isOwnedBy`; `ExportJobAccessOptions<TContext>` extends it with `scope`. The job's `userId` is always compared against the caller regardless of what you pass, and every failed check answers `403`.
246
+
247
+ ### Formats and Delivery
248
+
249
+ An export format is an `Exporter`, and a delivery mode is a `Deliverer`. Both are looked up by id.
250
+
251
+ ```typescript
252
+ import {
253
+ DEFAULT_DELIVERY,
254
+ DEFAULT_EXPORT_FORMAT,
255
+ getDeliverer,
256
+ getExporter,
257
+ } from "@antelopejs/interface-dms/base/export-jobs";
258
+
259
+ const exporter = getExporter(DEFAULT_EXPORT_FORMAT); // "csv"
260
+ const writer = exporter.createWriter(localPath);
261
+ writer.writeHeaders(["id", "name"]);
262
+ writer.appendRow(["id", "name"], { id: "1", name: "Ada" });
263
+ writer.close();
264
+ ```
265
+
266
+ | Symbol | Shape | Description |
267
+ | --- | --- | --- |
268
+ | `Exporter` | `{ extension, contentType, createWriter(filePath): ExportWriter }` | One output format. |
269
+ | `ExportWriter` | `{ writeHeaders(headers), appendRow(headers, row), close() }` | Incremental writer for one file. |
270
+ | `Deliverer` | `{ validate?(options: ExportJobDeliveryOptions): void; deliver(exportRecord, resultPath): Promise<void> }` | One delivery mode. `validate` runs when the job is created, so an unusable delivery fails fast rather than after generation. |
271
+ | `ExportJobDeliveryOptions` | `{ delivery: string; deliveryPath?: string }` | What `validate` is given. |
272
+ | `getExporter(format)` | `Exporter` | Throws `400` for an unknown format. |
273
+ | `getDeliverer(delivery)` | `Deliverer` | Throws `400` for an unknown delivery mode. |
274
+ | `DEFAULT_EXPORT_FORMAT` | `"csv"` | The format `csv` writes a BOM-prefixed comma-separated file; array values are joined with `;` and dates written as ISO strings. |
275
+ | `DEFAULT_DELIVERY` | `"download"` | Keeps the result in storage for the download route. |
276
+ | `EMAIL_DELIVERY` | `"email"` | Mails the recipient a link instead. Requires `deliveryPath`: the link is that path with `?exportJob=<jobId>` appended. |
277
+ | `EXPORT_JOB_QUERY_PARAM` | `"exportJob"` | The query parameter carrying the job id on a delivered link. Read it on the target page to resume the download. |
278
+ | `EXPORT_TTL_MS` | one day | How long a job and its stored file survive before the sweep drops them. |
279
+
280
+ `buildExportFilename(base, extension)` produces the name sent to the browser: the base and an ISO timestamp, both stripped of anything outside `[a-zA-Z0-9._-]`, joined by `-` and suffixed with the extension.
281
+
282
+ ### Storage and Maintenance
283
+
284
+ | Function | Signature | Description |
285
+ | --- | --- | --- |
286
+ | `getExportLocalScratchPath` | `(exportId, extension) => string` | Path in the OS temp directory a job writes to. |
287
+ | `uploadExportToStorage` | `(localPath, tenantId, exportId, extension, contentType) => Promise<string>` | Uploads the scratch file through a presigned URL, deletes it locally, and returns the resource key. |
288
+ | `streamExportFromStorage` | `(resourceKey) => Promise<StreamedExport>` | Opens a read stream. `StreamedExport` is `{ body: ReadableStream<Uint8Array>; size: number \| undefined }`. |
289
+ | `deleteExportFromStorage` | `(resourceKey) => Promise<void>` | Removes a stored result, ignoring failures. |
290
+ | `sweepStaleExportsAllTenants` | `(ttlMs) => Promise<void>` | Deletes every job whose `updatedAt` is older than `ttlMs`, in every tenant, along with its stored file. |
291
+ | `deleteAllExportsForTenant` | `(tenantId) => Promise<void>` | Deletes every job of one tenant and its stored files. |
292
+
293
+ ## Export a Tenant's Data
294
+
295
+ `startTenantExportJob` runs the tenant data export as a background job: it asks every registered contributor for its slice, assembles the answers into one ZIP, and hands you the same ticket `runExportJob` does.
296
+
297
+ ```typescript
298
+ import { Context, Get, type RequestContext } from "@antelopejs/interface-api";
299
+ import { AuthTenantOwner } from "@antelopejs/interface-dms/guards";
300
+ import {
301
+ PageController,
302
+ RegisterPage,
303
+ settingsCategory,
304
+ } from "@antelopejs/interface-dms/page";
305
+ import type { User } from "@antelopejs/interface-dms/auth/db";
306
+ import {
307
+ EMAIL_DELIVERY,
308
+ listExportJobs,
309
+ } from "@antelopejs/interface-dms/base/export-jobs";
310
+ import { startTenantExportJob } from "@antelopejs/interface-dms/base/tenant-export-archive";
311
+
312
+ @RegisterPage()
313
+ export class ExportsPage extends PageController("exports", {
314
+ displayName: "Exports",
315
+ icon: "i-ph-archive",
316
+ category: settingsCategory,
317
+ }) {
318
+ @Get("/exports/start")
319
+ start(@Context() ctx: RequestContext, @AuthTenantOwner() user: User) {
320
+ return startTenantExportJob({
321
+ ctx,
322
+ user,
323
+ scope: "tenant-export",
324
+ filename: "tenant-data",
325
+ delivery: EMAIL_DELIVERY,
326
+ deliveryPath: "/settings/exports",
327
+ });
328
+ }
329
+
330
+ @Get("/exports/history")
331
+ history(@Context() ctx: RequestContext, @AuthTenantOwner() user: User) {
332
+ return listExportJobs(ctx, user, { scope: "tenant-export" });
333
+ }
334
+ }
335
+ ```
336
+
337
+ `StartTenantExportJobOptions` is `ctx`, `user`, `scope`, `filename`, and the optional `delivery`, `deliveryPath` and `maxExecutionTimeMs` — the same fields `runExportJob` takes, minus the ones the archive fixes itself: the extension is `TENANT_EXPORT_EXTENSION` (`"zip"`), the content type `TENANT_EXPORT_CONTENT_TYPE` (`"application/zip"`), and `retainUntilExpiry` is always on, since the archive is reachable from a delivered link and from the job history long after the first download.
338
+
339
+ `buildTenantExportArchive(options)` is the step underneath, for a caller that runs the archive outside a job. Its `BuildTenantExportArchiveOptions` are `tenantId`, the `localPath` to write the ZIP to, an `AbortSignal`, and an optional `reportProgress`; it resolves to the `ExportJobResultSummary` describing which contributors failed.
340
+
341
+ Contributing to the archive is the other side of this: a module registers a handler on the `TENANT_DATA_EXPORT` hook of the [Core](/docs/interfaces/core/introduction) domain, and that hook's page covers the archive sink, the manifest and what a failing contributor does to the result.
342
+
343
+ ## Track File References
344
+
345
+ A `file` or `image` column stores keys into file storage, not the bytes. The helpers in `helpers/file-refs` compare or remap references in the supplied records. They do not authorize access, validate upload provenance, or determine whether another record still references an object. Use the [native save integration](/docs/components/file-storage#custom-save-routes) for promotion and cleanup in custom write routes.
346
+
347
+ ```typescript
348
+ import {
349
+ collectFileRefs,
350
+ dedupeRefs,
351
+ diffRemovedRefs,
352
+ type FileRef,
353
+ hasFileColumns,
354
+ remapRecordFileKeys,
355
+ } from "@antelopejs/interface-dms/base/helpers/file-refs";
356
+ import { GetMetadata } from "@antelopejs/interface-core";
357
+ import { TableViewMeta } from "@antelopejs/interface-dms/base/table-view";
358
+
359
+ const { columns } = GetMetadata(customerDataAPI, TableViewMeta);
360
+
361
+ if (hasFileColumns(columns)) {
362
+ const removed: FileRef[] = diffRemovedRefs(
363
+ collectFileRefs(columns, rowBeforeEdit),
364
+ collectFileRefs(columns, rowAfterEdit),
365
+ );
366
+ // Other records may still reference these keys.
367
+ }
368
+ ```
369
+
370
+ The three functions that inspect columns take the column map first. An entry only needs a `type`, so `TableViewMeta.columns` is passed straight in; columns whose data type is neither `file` nor `image` are skipped. `dedupeRefs` and `diffRemovedRefs` work on the resulting ref lists alone.
371
+
372
+ | Function | Signature | Description |
373
+ | --- | --- | --- |
374
+ | `hasFileColumns` | `(columns) => boolean` | Whether any column's data type is `file` or `image`. |
375
+ | `collectFileRefs` | `(columns, row) => FileRef[]` | Every key the row holds in its file and image columns. Nested objects and arrays are walked; an `image` value contributes its `key` property, a `file` value contributes the string itself. |
376
+ | `dedupeRefs` | `(refs) => FileRef[]` | Drops duplicates, comparing storage and key together. |
377
+ | `diffRemovedRefs` | `(before, after) => FileRef[]` | Deduplicated refs present in `before` and absent from `after`. |
378
+ | `remapRecordFileKeys` | `(columns, record, mapKey) => Promise<Record<string, unknown>>` | Returns a copy of the record with every key in a file or image column replaced by `await mapKey(key, storage)`, preserving the surrounding shape. |
379
+
380
+ `FileRef` is `{ key: string; storage?: string }`. The `storage` is read from the data type's `storage` option when it declares one, and left undefined otherwise, so a ref always carries the storage its key belongs to.
381
+
382
+ ## Archive-Mode Defaults
383
+
384
+ `applyArchiveModeDefaultRules(rowActions, archiveField)` derives the row-action rules that make archive mode coherent: archived rows offer restore and delete, live rows offer edit, duplicate and archive. `TableView` calls it for you when `archiveMode` is set; call it directly only when you assemble `TableViewRowActionOptions` yourself.
385
+
386
+ ```typescript
387
+ import { applyArchiveModeDefaultRules } from "@antelopejs/interface-dms/base/helpers/archive-mode-helpers";
388
+ import { startTenantExportJob } from "@antelopejs/interface-dms/base/tenant-export-archive";
389
+
390
+ const rowActions = applyArchiveModeDefaultRules(
391
+ { edit: true, delete: true, archive: true, restore: true },
392
+ "archived",
393
+ );
394
+ ```
395
+
396
+ | Action | Default rule |
397
+ | --- | --- |
398
+ | `edit` | `{ field: archiveField, notEquals: true }` |
399
+ | `duplicate` | `{ field: archiveField, notEquals: true }` |
400
+ | `archive` | `{ field: archiveField, notEquals: true }` |
401
+ | `restore` | `{ field: archiveField, equals: true }` |
402
+ | `delete` | `{ field: archiveField, equals: true }` |
403
+
404
+ The function returns a new options object and never rewrites a decision you made explicitly:
405
+
406
+ | Value you passed | Result |
407
+ | --- | --- |
408
+ | Nothing for that action | `{ isEnabled: true, rule: <default> }` |
409
+ | `true` | `{ isEnabled: true, rule: <default> }` |
410
+ | `false`, or a config with `isEnabled: false` | Left as is; no rule added. |
411
+ | A config without a `rule` | The config, plus the default rule. |
412
+ | A config with a `rule` | Left as is. |
413
+
414
+ Passing `undefined` as `rowActions` yields an object carrying only the five defaults. The rule shapes themselves are documented in [Shared Types](/docs/interfaces/base/types), and how the table view consumes them in [Tables](/docs/interfaces/base/tables).
@@ -0,0 +1,2 @@
1
+ title: Notifications
2
+ icon: i-lucide-bell
@@ -0,0 +1,81 @@
1
+ ---
2
+ title: "Introduction"
3
+ description: "How backend code puts a message in a user's notification list: categories, subjects and sending."
4
+ ---
5
+
6
+ # Introduction
7
+
8
+ ## Overview
9
+
10
+ The `notifications` domain of `@antelopejs/interface-dms` is how backend code puts a message in a user's notification list. It has two halves: a taxonomy you declare once, and a builder you use every time you send.
11
+
12
+ The taxonomy is two levels deep. A **category** groups related notifications, a **subject** sits under a category and names one kind of message, and every notification is filed under a subject. Users manage what they receive per subject from their notification settings, so the subject you pick decides how the message is grouped for the user and whether it is delivered at all.
13
+
14
+ Both levels are declared at import time: `NotificationCategory()` first, then the `NotificationSubject()` calls that sit under it. Sending is a fluent chain: `Notification()` opens a builder, the setters fill in the icon, title, description and subject, and `.build()` returns an immutable `SendableNotification` you dispatch to one user, a list of users, the holders of a set of roles, or everyone.
15
+
16
+ See the [notification bell before and after reading](/docs/auth-and-tenancy/authentication#read-notifications) and the [category and subject preferences](/docs/auth-and-tenancy/authentication#notification-preferences) for real dashboard examples. The demonstration sends to one disposable account and checks a second session of that account; it does not broadcast or send email.
17
+
18
+ ## Key Features
19
+
20
+ - **Two-level taxonomy** — Categories group subjects, and subjects classify notifications; the `categoryId:subjectId` pair is the key a user's preferences are stored under.
21
+ - **Declarative registration** — `NotificationCategory()` and `NotificationSubject()` register at import time and return plain info objects you keep as constants and pass around.
22
+ - **Built-in system subjects** — `GeneralSubject`, `SecuritySubject`, `AccountSubject`, `CollaborationSubject` and `AutomationSubject` cover the common platform messages without declaring anything.
23
+ - **Type-checked builder** — The builder tracks which fields have been supplied in its type parameter, so `.build()` does not compile until icon, title, description and subject are all set.
24
+ - **Four dispatch targets** — Send to one user, a list of users, every user holding at least one of a set of roles, or every user.
25
+ - **Shared or individual read state** — A multi-recipient send can give each recipient their own read state, or group the copies so one recipient reading it clears it for all of them.
26
+ - **Preference-aware delivery** — Sends are silently skipped for users who turned the subject off, and `togglePermission` marks the subjects users may not turn off.
27
+
28
+ ## Dependencies
29
+
30
+ This interface relies on the following AntelopeJS packages:
31
+
32
+ - [@antelopejs/interface-core](https://github.com/AntelopeJS/interface-core) - Interface function and registration proxies used to reach the DMS implementation.
33
+
34
+ The implementation lives in the DMS module, so the interface only works in a project that loads `@antelopejs/dms`. Sending to users and roles uses the DMS user and role identifiers described in the DMS auth and tenancy guide.
35
+
36
+ ## Quick Start
37
+
38
+ The following example declares a category with one subject and sends a notification against it.
39
+
40
+ ```typescript
41
+ import {
42
+ Notification,
43
+ NotificationCategory,
44
+ NotificationSubject,
45
+ } from "@antelopejs/interface-dms/notifications";
46
+
47
+ // Declared at import time — the category first, then its subjects
48
+ export const shopCategory = NotificationCategory("shop", {
49
+ labelKey: "shop.notifications.categories.shop",
50
+ descriptionKey: "shop.notifications.categories.shop_desc",
51
+ icon: "i-ph-shopping-cart",
52
+ });
53
+
54
+ export const orderSubject = NotificationSubject("orders", {
55
+ category: shopCategory,
56
+ labelKey: "shop.notifications.subjects.orders",
57
+ togglePermission: "default",
58
+ });
59
+
60
+ export async function notifyOrderShipped(
61
+ userId: string,
62
+ orderRef: string,
63
+ ): Promise<void> {
64
+ await Notification()
65
+ .icon("i-ph-truck")
66
+ .title("$shop.notifications.order_shipped.title")
67
+ .description("$shop.notifications.order_shipped.description")
68
+ .params({ orderRef })
69
+ .subject(orderSubject)
70
+ .linkTo("/shop/orders")
71
+ .build()
72
+ .toUser(userId);
73
+ }
74
+ ```
75
+
76
+ The user sees the notification in the dashboard, and finds an `orders` toggle under a `shop` group in their notification settings.
77
+
78
+ ## Documentation Sections
79
+
80
+ - [Categories and Subjects](/docs/interfaces/notifications/categories-and-subjects) - Declare categories and subjects, control what users may toggle, and use the built-in system subjects
81
+ - [Sending Notifications](/docs/interfaces/notifications/sending) - Build a notification, dispatch it to users, roles or everyone, and understand what gates delivery