@managemint-solutions/sdk 0.55.0 → 0.64.0

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 (56) hide show
  1. package/README.md +209 -7
  2. package/dist/audit/document-changes.d.ts +39 -0
  3. package/dist/audit/document-changes.js +332 -0
  4. package/dist/audit/index.d.ts +50 -2
  5. package/dist/audit/index.js +87 -44
  6. package/dist/client.d.ts +6 -0
  7. package/dist/client.js +9 -0
  8. package/dist/clients/dto.d.ts +18 -3
  9. package/dist/clients/dto.js +72 -2
  10. package/dist/clients/index.js +16 -0
  11. package/dist/documents/content.d.ts +39 -0
  12. package/dist/documents/content.js +443 -0
  13. package/dist/documents/document-templates.d.ts +37 -0
  14. package/dist/documents/document-templates.js +143 -0
  15. package/dist/documents/documents.d.ts +154 -0
  16. package/dist/documents/documents.js +330 -0
  17. package/dist/documents/dto.d.ts +125 -0
  18. package/dist/documents/dto.js +530 -0
  19. package/dist/documents/index.d.ts +4 -0
  20. package/dist/documents/index.js +24 -0
  21. package/dist/documents/render/html.d.ts +64 -0
  22. package/dist/documents/render/html.js +399 -0
  23. package/dist/documents/render/index.d.ts +2 -0
  24. package/dist/documents/render/index.js +23 -0
  25. package/dist/documents/render/values.d.ts +86 -0
  26. package/dist/documents/render/values.js +142 -0
  27. package/dist/index.d.ts +8 -1
  28. package/dist/index.js +27 -3
  29. package/dist/leads/index.js +1 -3
  30. package/dist/notes/index.d.ts +2 -2
  31. package/dist/notes/index.js +6 -10
  32. package/dist/notifications/index.d.ts +6 -0
  33. package/dist/notifications/index.js +16 -0
  34. package/dist/organization-payment-methods/dto.d.ts +10 -0
  35. package/dist/organization-payment-methods/dto.js +56 -0
  36. package/dist/organization-payment-methods/index.d.ts +8 -1
  37. package/dist/organization-payment-methods/index.js +23 -0
  38. package/dist/payments/index.d.ts +67 -0
  39. package/dist/payments/index.js +137 -0
  40. package/dist/permissions/dto.d.ts +4 -0
  41. package/dist/permissions/dto.js +20 -8
  42. package/dist/service-client/index.d.ts +35 -0
  43. package/dist/service-client/index.js +64 -0
  44. package/dist/staff-client.d.ts +10 -0
  45. package/dist/staff-client.js +15 -2
  46. package/dist/supporting-files/index.d.ts +12 -4
  47. package/dist/supporting-files/index.js +25 -25
  48. package/dist/supporting-files/paths.d.ts +2 -0
  49. package/dist/supporting-files/paths.js +3 -1
  50. package/dist/validators.d.ts +6 -0
  51. package/dist/validators.js +14 -1
  52. package/dist/visibility/dto.d.ts +15 -0
  53. package/dist/visibility/dto.js +68 -0
  54. package/dist/visibility/index.d.ts +59 -0
  55. package/dist/visibility/index.js +165 -0
  56. package/package.json +6 -2
package/README.md CHANGED
@@ -22,7 +22,9 @@ are peer dependencies - the request DTOs (`AddStatusDto`, `UpdateStatusDto`, `St
22
22
  decorators so consumers validate against
23
23
  the same rules the SDK's input types describe. `@nestjs/common` (^11) is an optional peer
24
24
  dependency, needed only for the `@managemint-solutions/sdk/nest` subpath, and `@upstash/redis`
25
- (^1.38) is an optional peer needed only for the `@managemint-solutions/sdk/cache` subpath.
25
+ (^1.38) is an optional peer needed only for the `@managemint-solutions/sdk/cache` subpath. The
26
+ `@managemint-solutions/sdk/documents` subpath (the document renderer) needs no peer at all and
27
+ runs in a browser - see "Rendering" under Documents.
26
28
 
27
29
  ## Usage
28
30
 
@@ -219,7 +221,7 @@ escape hatch for queries not yet covered by a resource.
219
221
 
220
222
  Every write a resource makes is logged to `audit_logs` by the SDK itself - there are no database
221
223
  triggers and no audit code in any feature. A write records one row carrying the action
222
- (`INSERT` / `UPDATE` / `ARCHIVE` / `UNARCHIVE` / `DELETE` / `RESTORE`), the entity type and id, who
224
+ (`INSERT` / `UPDATE` / `ARCHIVE` / `UNARCHIVE` / `DELETE` / `RESTORE`; `SEND` is the one event that is not a row change - `documents.recordSend`), the entity type and id, who
223
225
  did it, and a `changes` diff of the flat table row as real JSON (`{ field: { old, new } }`).
224
226
  Inserts and hard deletes record a whole-row snapshot instead, so the trail survives the row.
225
227
  Bookkeeping columns are never diffed (`id`, `mms_id`, `organization_id`, `created_at/by`,
@@ -236,7 +238,7 @@ The trail is read one record at a time. Each resource with a record page exposes
236
238
  ```ts
237
239
  const history = await supabase.clients.audit.list(clientId, { page: 1, page_size: 10 });
238
240
  // → MmsList<AuditTrailEntity>. Also on leads, projects, tasks, users, organizations,
239
- // timesheets, leaveRequests and leaveEntitlements.
241
+ // timesheets, leaveRequests, leaveEntitlements, documentTemplates and documents.
240
242
 
241
243
  const names = await supabase.audit.names({ statuses: [statusId], users: [userId] });
242
244
  // → { [mms_id]: 'In Progress', ... } - readable names for the ids a page of history holds.
@@ -292,7 +294,8 @@ organization with a Paystack customer, every organization whose leave year start
292
294
  up. The scope also carries `leaveTypes`, `leaveEntitlements` and `leaveRequests` for the leave-year
293
295
  rollover, whose writes are attributed to the system.
294
296
 
295
- `update` / `transition` / `remove` pre-read the flat row (by `mms_id`, and by `organization_id`
297
+ `update` / `transition` / `remove` (and the guarded `updateWhere` / `removeWhere`, which resolve to
298
+ `null` when their guard matched nothing and then audit nothing) pre-read the flat row (by `mms_id`, and by `organization_id`
296
299
  unless `scopeByOrganization: false` - the `organizations` table is its own tenant), so a missing
297
300
  row surfaces as the usual 406. The write itself still throws `SupabaseClientError` on failure;
298
301
  only the audit insert is swallowed.
@@ -349,14 +352,15 @@ export class AuthController {
349
352
 
350
353
  ManageMint Solutions employees sign in to the admin api with a token whose role is `staff`. Their
351
354
  client is a `SupabaseStaffClient`, which carries only what the admin api reads: `clients`, `leads`,
352
- `projects`, `tasks`, `users`, `organizations`, `staff`, `staffPermissions` and
353
- `userApiCredentials`. A resource not on it is not available to staff.
355
+ `projects`, `tasks`, `documentTemplates`, `documents`, `users`, `organizations`, `staff`,
356
+ `staffPermissions` and `userApiCredentials`. A resource not on it is not available to staff.
354
357
 
355
358
  - **Reads span every organization.** The shared resources drop their organization filter and the
356
359
  `staff` role's policies decide what comes back. The `clients`, `leads`, `projects`, `tasks` and
357
360
  `users` lists embed each row's `organization` (`{ mms_id, name }`) and take an `organization_id`
358
361
  filter through the staff query DTOs (`StaffGetClientsDto`, ...); a tenant's request and response
359
- are unchanged. `organizations.list` pages every organization (`search` over name, trading
362
+ are unchanged. The `documentTemplates` and `documents` lists do the same, though entities has
363
+ no staff query DTO for them yet. `organizations.list` pages every organization (`search` over name, trading
360
364
  name, email).
361
365
  - **Every write is refused** with a `403 Permission Error` before any statement runs, as is the
362
366
  tenant-only `organizations.get()`.
@@ -616,6 +620,204 @@ const year = await supabase.publicHolidays.list({ year: 2026 });
616
620
  const off = await supabase.publicHolidays.datesBetween('2026-09-21', '2026-09-25'); // Set of religion-neutral dates
617
621
  ```
618
622
 
623
+ ## Documents
624
+
625
+ Generic, block-based documents in the CRM module - invoices, quotes, contracts, letters:
626
+ `document_templates` and `documents`, one org-scoped audited resource each on the caller's client.
627
+ A template is an ordered list of typed blocks plus the variables they use; a document is a copy of
628
+ a template's blocks and variables plus the values filled in, so a later template edit never
629
+ changes it. Both are read and saved whole. Issuing and numbering are the api's; the sdk stores
630
+ rows, checks content and signs image uploads, and its `./documents` subpath (below) holds the
631
+ renderer, the totals and the system values that the api prints with and the portal previews with.
632
+
633
+ ```ts
634
+ import { DocumentStatus } from '@managemint-solutions/entities/documents/enum';
635
+ import { documentProblem } from '@managemint-solutions/sdk';
636
+
637
+ const page = await supabase.documentTemplates.list({ page: 1, page_size: 10, archive: false, category: 'Invoice' }); // search: name, category
638
+ const template = await supabase.documentTemplates.create({ name, category, number_prefix: 'INV-', variables, blocks });
639
+ await supabase.documentTemplates.update(template.mms_id, body); // replaces the whole template
640
+ await supabase.documentTemplates.archive(template.mms_id); // unarchive clears archived_at/by - no unarchive columns
641
+ await supabase.documentTemplates.remove(template.mms_id); // hard delete; its documents keep, template_id -> null
642
+
643
+ const documents = await supabase.documents.list({ page: 1, page_size: 10, status: DocumentStatus.DRAFT, client_id, template_id, category, search }); // search: title, number
644
+ const draft = await supabase.documents.create(row); // CreateDocumentRow: the draft the api composed, totals included
645
+ const saved = await supabase.documents.update(id, DocumentStatus.DRAFT, { ...body, ...totals }); // null = no longer a draft
646
+ const sequence = await supabase.documents.nextSequence('INV-'); // the organization's highest for the prefix + 1
647
+ const issued = await supabase.documents.update(id, DocumentStatus.DRAFT, {
648
+ status: DocumentStatus.ISSUED, number_sequence: sequence, number, variable_values: frozen, issued_at, issued_by, ...totals,
649
+ }); // 409 with code 23505: the number was taken - retry
650
+ const voided = await supabase.documents.update(id, DocumentStatus.ISSUED, { status: DocumentStatus.VOID, voided_at, voided_by, void_reason });
651
+ const removed = await supabase.documents.remove(id); // false = no longer a draft; nothing deleted
652
+ await supabase.documents.audit.list(id, query); // documentTemplates.audit too
653
+
654
+ const { path, url } = await supabase.documents.createImageUploadUrl('logo.png'); // PUT the bytes to url; the block stores path
655
+ const urls = await supabase.documents.imageUrls(paths); // { [path]: signed URL, one hour } for the renderer
656
+ ```
657
+
658
+ `update` is the one write after create: a compare-and-set (`updateWhere`) on the status the api
659
+ read and decided on, resolving to `null` when the document has moved on - a draft save and an
660
+ issue expect `draft`, a void expects `issued`. `remove` is the same on `draft` (`removeWhere`), so
661
+ an issue that lands first keeps its document and `remove` answers `false`. The history never copies
662
+ the whole `blocks` / `variables` JSON (saved whole each time): an update records what changed inside
663
+ them, and in the filled-in values - block by block, variable by variable, value by value
664
+ (`documentContent` on the audited table) - and a create or delete records only that `blocks` and
665
+ `variables` were set; the title, status, number and totals stay readable. The client and template embeds go through the
666
+ composite same-organization keys (`clients!documents_client_fkey`,
667
+ `document_templates!documents_template_fkey`), so a document pointing at another organization's
668
+ client or template is refused by the database with a 409.
669
+
670
+ **Content rules.** Blocks and variables are JSON with no SQL checks, so the DTOs
671
+ (`CreateDocumentTemplateDto`, `UpdateDocumentTemplateDto`, `UpdateDocumentDto`) are the gate, all through one set of checks in `src/documents/content.ts`.
672
+ Each refusal names where it is: `Block 2 (line items): Line 1: Quantity must be greater than 0`.
673
+
674
+ - Variables: `{ key, label, type, required, default_value }` exactly; keys lower snake_case, 1 to
675
+ 40 characters, unique, never a `DocumentSystemVariable`; a default that fits the type, or `null`.
676
+ - Blocks: each type's properties and nothing else; block ids unique. Quantity above 0, unit price
677
+ whole cents 0 or more, discount and tax 0 to 100. Lists may be empty, so a block saves before it
678
+ is filled in; a table needs a column, and every row one cell per column.
679
+ - Rich text: `doc`, `paragraph`, `heading` (1 to 3), `bulletList`, `orderedList`, `listItem`, `text`,
680
+ `hardBreak`, `variable`; marks `bold`, `italic`, `underline`, `link` (http, https or mailto). Each
681
+ node only where the editor puts it, lists at most four deep. Other attributes the editor writes
682
+ are stored and never rendered.
683
+ - References: every variable used - `{{key}}` in any plain-text string of a block, a variable node,
684
+ a line's `{ variable }` - is declared or a system variable. A quantity points at a number
685
+ variable, a unit price at a money variable.
686
+ - Header and footer: `{ id, type, left, right }`, plain text, either side may be empty. They
687
+ repeat on every PDF page, so a document has at most one of each, anywhere in the list (the
688
+ renderer takes them out of the flow). `{{page_number}}` and `{{page_count}}` work only there:
689
+ anywhere else - plain text, a variable node, a line's `{ variable }` - is refused with "Page number
690
+ and page count only work in a header or footer", and as values they are the system's.
691
+ - Values: declared keys only (system values are the api's to fill), each fitting its type - text,
692
+ a number, whole cents 0 or more, a `YYYY-MM-DD` date. A variable with no value is left out.
693
+ - Currency: three capitals. Required by `UpdateDocumentDto` when a line items block exists; on
694
+ `CreateDocumentDto` it is optional and only its format is checked.
695
+ - Images: an image block's `path` is `''` or one `createImageUploadUrl` handed out.
696
+
697
+ `CreateDocumentDto` cannot run the rules - with a template, the variables its values fill are the
698
+ template's - so the api runs `documentProblem(draft)` on the draft it composes, which returns the
699
+ same message the DTO would, or null. The checks find `{{key}}`s, and accept links, with the
700
+ renderer's own pattern and href rule, so what is checked is exactly what prints.
701
+
702
+ **Images** live in the supporting-files bucket at `<organization>/documents/images/<uuid>.<ext>`
703
+ (png, jpg, jpeg, gif), named afresh on every upload and never replaced, so an issued document's
704
+ images cannot change. The block stores the path inside the organization's folder; `imageUrls`
705
+ signs it back under the caller's organization.
706
+
707
+ ### Sending and payments
708
+
709
+ ```ts
710
+ await supabase.documents.recordSend(id, { recipients, cc, subject, pay_now }); // a SEND audit event - there is no sends table
711
+ await supabase.documents.sends(id); // DocumentSend[], newest first, read from those events
712
+ await supabase.payments.list(PaymentEntityType.DOCUMENTS, id); // Payment[], oldest first - read only for members
713
+
714
+ const scope = service.forOrganization(organizationId); // no caller: the webhook, the public pay page, the daily cap
715
+ await scope.payments.create(row); // 409 code 23505: the Paystack reference is recorded already
716
+ await scope.documents.countSendsSince(startOfDay); // every document of the organization, private ones too
717
+ await scope.documents.getById(id); // the pay page's read, on the system actor
718
+ await service.findDocumentOrganization(id); // string | null - unscoped, for a pay link the api has verified
719
+ await scope.paymentMethod.set({ paystack_subaccount_code, payout_business_name, payout_bank_name, payout_account_last4 });
720
+ ```
721
+
722
+ Recurring and scheduled documents: `documents.list({ recurring: true })` lists the parents alone
723
+ (every other list leaves them out), `list({ repeat_from })` one parent's drafts, `countCopies(id)` how
724
+ many it made; `repeat_from` reads as `{ mms_id, title }`, fetched beside the rows (PostgREST embeds a
725
+ self-relationship only through a SQL function). The cron's scans are
726
+ `service.documentsDueForDraft(date)` / `documentsDueToIssue(date)`; `service.notifications.existsFor(type,
727
+ recipientId, variables)` tells it whether a failure was already reported. The schedule fields
728
+ (`repeat`, `repeat_next_at`, `repeat_until`, `repeat_paused`, `issue_at`, `email`) ride on
729
+ `CreateDocumentDto` / `UpdateDocumentDto`; `email` is checked with `SendDocumentDto`'s rules.
730
+ Payments: `list` is paid rows only, `paidCounts(type, ids)` for a list page, `paidEntityIds(type)`
731
+ for the list's payment filter, `findOpen` / `findByReference` / `settle` / `removeOpen` for the one
732
+ open checkout per record, and `removeManual` for a payment marked by hand (EFT).
733
+
734
+ `payments` is one generic table of money received, keyed by `entity_type` + `entity_id` like
735
+ `visibility` (`PaymentDirection`, `PaymentEntityType`, `PaymentMethod` are entities enums). A
736
+ member reads a payment while its record is readable; only the service role writes. Each insert is
737
+ audited and filed under its record through those two columns. A send has no table: it is a
738
+ `SEND` event in the document's history (`AuditTrailAction.SEND`), which `sends` reads back and the
739
+ api's daily cap counts. `SendDocumentDto` takes 1 to 5 `to`, up to 5 `cc`, a subject (1 to 200
740
+ characters) and a message (up to 5000); `DocumentPayTokenDto` checks only the shape of a pay link's
741
+ token (`<document mms_id>.<43-character signature>`) - the api checks the signature.
742
+
743
+ ### Rendering - `@managemint-solutions/sdk/documents`
744
+
745
+ One renderer for the api's PDF and the portal's live preview, as pure functions: no Node built-in,
746
+ no Nest, no supabase-js, no DOM - only the shared entities - so it runs as well in a browser as in
747
+ the api. The caller loads the organization and client and signs the image URLs; nothing here does
748
+ I/O.
749
+
750
+ ```ts
751
+ import {
752
+ johannesburgDate, printsMoney, renderBand, renderDocument, startingValues, systemValues,
753
+ DOCUMENT_PAGE_MARGINS,
754
+ } from '@managemint-solutions/sdk/documents';
755
+
756
+ const values = {
757
+ ...draft.variable_values, // or startingValues(template.variables, sample) for a template
758
+ ...systemValues({
759
+ organization: { name, trading_name, address: [line1, line2, city, state, postCode, country] },
760
+ client, // { name, email, phone } or null
761
+ number: null, // until issued
762
+ issueDate: johannesburgDate(new Date()), // YYYY-MM-DD in Africa/Johannesburg
763
+ }),
764
+ };
765
+ const { html, header, footer } = renderDocument(
766
+ { title, number, status, currency, variables, values, blocks }, // status null = a template ("Template")
767
+ imageUrls, // { [block path]: signed URL }
768
+ );
769
+ // api: renderPdf(html, { headerTemplate: header && renderBand(header, 'top'), footerTemplate: renderBand(footer, 'bottom'), ... })
770
+ // portal: lay html out in pages (Paged.js); place the renderBand output on each page, writing the page counters in
771
+ ```
772
+
773
+ - `renderDocument(document: PrintableDocument, imageUrls: Record<string, string>): RenderedDocument`
774
+ - `html`: a self-contained page (its own `<style>`): the first page's letterhead, then the blocks.
775
+ Every text and value escaped; the rich-text whitelist only; money in the document currency,
776
+ dates as `1 November 2026`; a missing value as `[Label]` on a draft or template (`status` draft
777
+ or null), blank once issued; an image path missing from `imageUrls` as `[Image]` likewise.
778
+ - `header`: `{ left, right }` HTML from the header block, or null without one.
779
+ - `footer`: the footer block's, or the default (organization, title and number, "Page X of Y").
780
+ `{{page_number}}` / `{{page_count}}` are `<span class="pageNumber">` / `<span class="totalPages">`.
781
+ - `renderBand(band, 'top' | 'bottom'): string` - the band as an inline-styled block, for Chromium's
782
+ header/footer template or a page of the portal's preview.
783
+ - `fillText(text, document): string` - `{{key}}`s filled as plain text, for a stored email's
784
+ subject and message.
785
+ - `DOCUMENT_PAGE_MARGINS` - `{ top: 0.62, topWithHeader: 0.95, bottom: 0.7, side: 0.7 }` inches.
786
+ - `systemValues(inputs: SystemValueInputs): DocumentVariableValues`, `johannesburgDate(now: Date): string`.
787
+ - `documentTotals(blocks, values): DocumentTotals`, `printsMoney(blocks, variables): boolean`,
788
+ `startingValues(variables, given?)`.
789
+ - `escapeHtml`, `money(minor, currency)`, `formatDocumentDate('2026-11-01')`, `MONO_FONT`,
790
+ `SYSTEM_VARIABLES` (labels and types).
791
+
792
+ ## Visibility
793
+
794
+ Templates and documents carry a `visibility` (`DocumentVisibility`): `organization` is everyone in
795
+ it, `private` is the creator plus the people it is shared with - never the organization's owners
796
+ or admins, never staff. Who it is shared with lives in one generic table, `public.visibility`
797
+ (one row per person per record, keyed by `VisibilityEntityType` + record id, with a
798
+ `VisibilityRole` of read, write or admin), so another kind of record can be shared without a new
799
+ table. The database enforces what each role can see and change; members read only their own
800
+ rows, so the user client needs nothing new. The rows are written on the service client's
801
+ organization scope, after the api has checked the caller may manage the record and that it is in
802
+ the organization (`entity_id` has no foreign key):
803
+
804
+ ```ts
805
+ const record = { entityType: VisibilityEntityType.DOCUMENTS, entityId: documentId };
806
+ const shares = await scope.visibility.list(record); // [{ user: UserIdentity, role }] - GET .../visibility
807
+ await scope.visibility.setVisibility(record, body.visibility); // the record's own column - PUT .../visibility
808
+ await scope.visibility.replace(record, body.shares); // exactly these people - PUT .../visibility (UpdateVisibilityDto)
809
+ await scope.visibility.copy({ entityType: VisibilityEntityType.DOCUMENT_TEMPLATES, entityId: templateId }, record); // a draft from a private template
810
+ await scope.visibility.removeAll(record); // after the record is deleted - nothing else removes them
811
+ const moved = await scope.visibility.reassign(VisibilityEntityType.DOCUMENTS, fromUserId, toUserId); // the owner's reassign, per kind
812
+ ```
813
+
814
+ `replace` and `copy` are audited against the record (`related_entity_*`), so share changes show in
815
+ its history. `visibility` is set at create (`CreateDocumentTemplateDto`, `CreateDocumentDto`)
816
+ and is not part of a save: members hold no UPDATE grant on it or on `created_by`, so only the
817
+ visibility and reassign routes change them, on the service role (`setVisibility`, `reassign`). The reads return
818
+ `DocumentTemplateRecord` / `DocumentRecord` - the entities without `my_role`, which the api works
819
+ out from the caller's row and permissions.
820
+
619
821
  ## Errors
620
822
 
621
823
  Every error the SDK throws is a `SupabaseClientError` carrying the HTTP `status` to respond
@@ -0,0 +1,39 @@
1
+ import type { DocumentBlockChange, DocumentValueChange, DocumentVariableChange } from '@managemint-solutions/entities/documents';
2
+ import { type Json } from '../documents/render/values';
3
+ /** A text block's rich text as plain text: one line per paragraph, heading or list item, `{{key}}` per variable. */
4
+ export declare const richTextToPlain: (doc: unknown) => string;
5
+ /**
6
+ * Each block a save added, removed, moved or edited, in the order they now stand (a removed one
7
+ * where it stood). Blocks are matched by id. A block counts as moved only when it left the
8
+ * order the others kept - adding or removing one above it does not move it. A block whose JSON
9
+ * changed with no printable part changing (bold added to a word) is CHANGED with no parts.
10
+ */
11
+ export declare const blockChanges: (before: unknown[], after: unknown[]) => DocumentBlockChange[];
12
+ /** Each variable definition a save added, removed or edited, by key, in the order they now stand. */
13
+ export declare const variableChanges: (before: unknown[], after: unknown[]) => DocumentVariableChange[];
14
+ /**
15
+ * Each filled-in value a save changed, by key, labelled and typed by the definitions as they
16
+ * now stand (as they stood, for a variable since removed). A key no definition names - a system
17
+ * variable frozen at issue - has a null label and type.
18
+ */
19
+ export declare const valueChanges: (before: Json, after: Json, variablesBefore: unknown, variablesAfter: unknown) => DocumentValueChange[];
20
+ /** The columns of `document_templates` and `documents` recorded as what changed inside them. */
21
+ export declare const DOCUMENT_CONTENT_COLUMNS: readonly string[];
22
+ type Changes = Record<string, {
23
+ old: unknown;
24
+ new: unknown;
25
+ diff?: unknown[];
26
+ }>;
27
+ /**
28
+ * A template's or document's changes as they are stored. Its `blocks` and `variables` arrive
29
+ * whole on both sides of an update and leave as `'[redacted]'` with a `diff` of what moved inside
30
+ * them; a created or deleted row keeps the plain markers (the whole JSON is not copied). Its
31
+ * `variable_values` leave the same way with a values `diff`, labelled by `variables` (the row's
32
+ * definitions before and after the event). Its `email` keeps only the keys that changed. A column
33
+ * whose JSON changed with nothing printable changing (variables reordered) is dropped.
34
+ */
35
+ export declare const compactDocumentChanges: (changes: Changes, variables: {
36
+ old: unknown;
37
+ new: unknown;
38
+ }) => Changes;
39
+ export {};
@@ -0,0 +1,332 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.compactDocumentChanges = exports.DOCUMENT_CONTENT_COLUMNS = exports.valueChanges = exports.variableChanges = exports.blockChanges = exports.richTextToPlain = void 0;
4
+ const enum_1 = require("@managemint-solutions/entities/documents/enum");
5
+ const values_1 = require("../documents/render/values");
6
+ // What a template's or document's save changed inside its JSON columns, worked out from the
7
+ // whole values before and after: one entry per block, per variable definition and per filled-in
8
+ // value that moved, everything else left out. Text is kept whole, however long - the portal
9
+ // clips it. Pure functions of the two sides; `AuditResource.write` calls them as it writes the
10
+ // entry.
11
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
12
+ // ------------------------------------------------------------
13
+ // Rich text
14
+ // ------------------------------------------------------------
15
+ const textLines = (node) => {
16
+ if (!(0, values_1.isObject)(node))
17
+ return [];
18
+ const children = Array.isArray(node.content) ? node.content : [];
19
+ switch (node.type) {
20
+ case 'paragraph':
21
+ case 'heading':
22
+ return [children
23
+ .map((inline) => {
24
+ if (!(0, values_1.isObject)(inline))
25
+ return '';
26
+ if (inline.type === 'text')
27
+ return typeof inline.text === 'string' ? inline.text : '';
28
+ if (inline.type === 'variable')
29
+ return `{{${(0, values_1.isObject)(inline.attrs) ? String(inline.attrs.key) : ''}}}`;
30
+ if (inline.type === 'hardBreak')
31
+ return '\n';
32
+ return '';
33
+ })
34
+ .join('')];
35
+ case 'bulletList':
36
+ case 'orderedList': {
37
+ const start = (0, values_1.isObject)(node.attrs) && typeof node.attrs.start === 'number' ? node.attrs.start : 1;
38
+ return children.flatMap((item, index) => {
39
+ const marker = node.type === 'bulletList' ? '- ' : `${start + index}. `;
40
+ const lines = (0, values_1.isObject)(item) && Array.isArray(item.content) ? item.content.flatMap(textLines) : [];
41
+ return lines.map((line, at) => (at === 0 ? marker : ' ') + line);
42
+ });
43
+ }
44
+ default:
45
+ return children.flatMap(textLines);
46
+ }
47
+ };
48
+ /** A text block's rich text as plain text: one line per paragraph, heading or list item, `{{key}}` per variable. */
49
+ const richTextToPlain = (doc) => textLines(doc).join('\n');
50
+ exports.richTextToPlain = richTextToPlain;
51
+ // ------------------------------------------------------------
52
+ // Blocks
53
+ // ------------------------------------------------------------
54
+ /** A stored value as one side of a change: a line's variable amount reads as `{{key}}`. */
55
+ const leaf = (value) => {
56
+ if (value === null || value === undefined)
57
+ return null;
58
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean')
59
+ return value;
60
+ if ((0, values_1.isObject)(value) && typeof value.variable === 'string')
61
+ return `{{${value.variable}}}`;
62
+ return JSON.stringify(value);
63
+ };
64
+ /** A list entry's printable leaves, by where they sit inside it: a row's cells, a field's label and value. */
65
+ const entryLeaves = (entry) => {
66
+ if (Array.isArray(entry))
67
+ return entry.map((value, column) => ({ at: [column], value: leaf(value) }));
68
+ if ((0, values_1.isObject)(entry) && typeof entry.variable !== 'string') {
69
+ return Object.entries(entry).map(([key, value]) => ({ at: [key], value: leaf(value) }));
70
+ }
71
+ return [{ at: [], value: leaf(entry) }];
72
+ };
73
+ /** A block's printable properties: everything but its id and type, rich text as plain text. */
74
+ const propertiesOf = (block) => Object.entries(block)
75
+ .filter(([key]) => key !== 'id' && key !== 'type')
76
+ .map(([key, value]) => [key, key === 'content' && (0, values_1.isObject)(value) ? (0, exports.richTextToPlain)(value) : value]);
77
+ /** Every non-empty part a block holds, as one side of an added or removed block. */
78
+ const allParts = (block, side) => propertiesOf(block).flatMap(([key, value]) => {
79
+ const leaves = Array.isArray(value)
80
+ ? value.flatMap((entry, index) => entryLeaves(entry).map(({ at, value: part }) => ({ path: [key, index, ...at], value: part })))
81
+ : [{ path: [key], value: leaf(value) }];
82
+ return leaves
83
+ .filter(({ value: part }) => part !== null && part !== '')
84
+ .map(({ path, value: part }) => (side === 'new' ? { path, old: null, new: part } : { path, old: part, new: null }));
85
+ });
86
+ /**
87
+ * The index pairs of the longest common subsequence of two lists, compared by `key` - the
88
+ * entries that kept their relative order. Lists are capped (200 blocks, 500 rows), so the
89
+ * quadratic table stays small.
90
+ */
91
+ const commonOrder = (before, after, key) => {
92
+ const a = before.map(key);
93
+ const b = after.map(key);
94
+ const lengths = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
95
+ for (let i = a.length - 1; i >= 0; i--) {
96
+ for (let j = b.length - 1; j >= 0; j--) {
97
+ lengths[i][j] = a[i] === b[j] ? lengths[i + 1][j + 1] + 1 : Math.max(lengths[i + 1][j], lengths[i][j + 1]);
98
+ }
99
+ }
100
+ const pairs = [];
101
+ for (let i = 0, j = 0; i < a.length && j < b.length;) {
102
+ if (a[i] === b[j]) {
103
+ pairs.push([i, j]);
104
+ i++;
105
+ j++;
106
+ }
107
+ else if (lengths[i + 1][j] >= lengths[i][j + 1]) {
108
+ i++;
109
+ }
110
+ else {
111
+ j++;
112
+ }
113
+ }
114
+ return pairs;
115
+ };
116
+ /**
117
+ * What changed in one list property (fields, items, rows, signers, columns): entries that stayed
118
+ * put are matched first, so a row added in the middle reads as one added row rather than every
119
+ * row below it changing; the unmatched entries between two matches pair up in order as edits,
120
+ * and what is left over was added or removed.
121
+ */
122
+ const listParts = (key, before, after) => {
123
+ const parts = [];
124
+ const anchors = [...commonOrder(before, after, (entry) => JSON.stringify(entry)), [before.length, after.length]];
125
+ let i = 0;
126
+ let j = 0;
127
+ for (const [nextI, nextJ] of anchors) {
128
+ const paired = Math.min(nextI - i, nextJ - j);
129
+ for (let k = 0; k < paired; k++) {
130
+ const old = new Map(entryLeaves(before[i + k]).map(({ at, value }) => [JSON.stringify(at), { at, value }]));
131
+ const next = new Map(entryLeaves(after[j + k]).map(({ at, value }) => [JSON.stringify(at), { at, value }]));
132
+ for (const at of new Set([...old.keys(), ...next.keys()])) {
133
+ const was = old.get(at)?.value ?? null;
134
+ const now = next.get(at)?.value ?? null;
135
+ if (was === now)
136
+ continue;
137
+ parts.push({ path: [key, j + k, ...(old.get(at) ?? next.get(at)).at], old: was, new: now });
138
+ }
139
+ }
140
+ for (let k = i + paired; k < nextI; k++) {
141
+ for (const { at, value } of entryLeaves(before[k])) {
142
+ if (value !== null && value !== '')
143
+ parts.push({ path: [key, k, ...at], old: value, new: null });
144
+ }
145
+ }
146
+ for (let k = j + paired; k < nextJ; k++) {
147
+ for (const { at, value } of entryLeaves(after[k])) {
148
+ if (value !== null && value !== '')
149
+ parts.push({ path: [key, k, ...at], old: null, new: value });
150
+ }
151
+ }
152
+ i = nextI + 1;
153
+ j = nextJ + 1;
154
+ }
155
+ return parts;
156
+ };
157
+ const blockId = (block) => ((0, values_1.isObject)(block) ? String(block.id) : '');
158
+ /**
159
+ * Each block a save added, removed, moved or edited, in the order they now stand (a removed one
160
+ * where it stood). Blocks are matched by id. A block counts as moved only when it left the
161
+ * order the others kept - adding or removing one above it does not move it. A block whose JSON
162
+ * changed with no printable part changing (bold added to a word) is CHANGED with no parts.
163
+ */
164
+ const blockChanges = (before, after) => {
165
+ const blocksBefore = before.filter(values_1.isObject);
166
+ const blocksAfter = after.filter(values_1.isObject);
167
+ const at = new Map(blocksBefore.map((block, index) => [blockId(block), index]));
168
+ const atAfter = new Map(blocksAfter.map((block, index) => [blockId(block), index]));
169
+ const keptBefore = blocksBefore.filter((block) => atAfter.has(blockId(block)));
170
+ const keptAfter = blocksAfter.filter((block) => at.has(blockId(block)));
171
+ const stayed = new Set(commonOrder(keptBefore, keptAfter, blockId).map(([index]) => blockId(keptBefore[index])));
172
+ const changes = [];
173
+ for (const block of blocksBefore) {
174
+ if (atAfter.has(blockId(block)))
175
+ continue;
176
+ changes.push({
177
+ id: blockId(block),
178
+ type: block.type,
179
+ kind: enum_1.DocumentChangeKind.REMOVED,
180
+ from: at.get(blockId(block)) + 1,
181
+ to: null,
182
+ parts: allParts(block, 'old'),
183
+ });
184
+ }
185
+ for (const block of blocksAfter) {
186
+ const id = blockId(block);
187
+ const to = atAfter.get(id) + 1;
188
+ const index = at.get(id);
189
+ if (index === undefined) {
190
+ changes.push({ id, type: block.type, kind: enum_1.DocumentChangeKind.ADDED, from: null, to, parts: allParts(block, 'new') });
191
+ continue;
192
+ }
193
+ const earlier = blocksBefore[index];
194
+ const moved = !stayed.has(id);
195
+ if (!moved && same(earlier, block))
196
+ continue;
197
+ // The parts that differ between the two saves of the block; none when it only moved.
198
+ const old = new Map(propertiesOf(earlier));
199
+ const next = new Map(propertiesOf(block));
200
+ const parts = [...new Set([...old.keys(), ...next.keys()])].flatMap((key) => {
201
+ const was = old.get(key);
202
+ const now = next.get(key);
203
+ if (same(was ?? null, now ?? null))
204
+ return [];
205
+ if (Array.isArray(was) || Array.isArray(now)) {
206
+ return listParts(key, Array.isArray(was) ? was : [], Array.isArray(now) ? now : []);
207
+ }
208
+ return [{ path: [key], old: leaf(was), new: leaf(now) }];
209
+ });
210
+ changes.push({
211
+ id,
212
+ type: block.type,
213
+ kind: same(earlier, block) ? enum_1.DocumentChangeKind.MOVED : enum_1.DocumentChangeKind.CHANGED,
214
+ from: index + 1,
215
+ to,
216
+ parts,
217
+ });
218
+ }
219
+ return changes.sort((a, b) => (a.to ?? a.from ?? 0) - (b.to ?? b.from ?? 0));
220
+ };
221
+ exports.blockChanges = blockChanges;
222
+ // ------------------------------------------------------------
223
+ // Variables and values
224
+ // ------------------------------------------------------------
225
+ const VARIABLE_FIELDS = ['label', 'type', 'required', 'default_value'];
226
+ const byKey = (variables) => new Map(variables.filter(values_1.isObject).map((variable) => [String(variable.key), variable]));
227
+ /** Each variable definition a save added, removed or edited, by key, in the order they now stand. */
228
+ const variableChanges = (before, after) => {
229
+ const old = byKey(before);
230
+ const next = byKey(after);
231
+ const changes = [];
232
+ for (const key of new Set([...next.keys(), ...old.keys()])) {
233
+ const was = old.get(key);
234
+ const now = next.get(key);
235
+ const parts = VARIABLE_FIELDS.map((field) => ({ field, old: was ? leaf(was[field]) : null, new: now ? leaf(now[field]) : null }))
236
+ .filter((part) => part.old !== part.new);
237
+ if (parts.length === 0)
238
+ continue;
239
+ changes.push({
240
+ key,
241
+ label: String((now ?? was).label ?? key),
242
+ kind: !was ? enum_1.DocumentChangeKind.ADDED : !now ? enum_1.DocumentChangeKind.REMOVED : enum_1.DocumentChangeKind.CHANGED,
243
+ parts,
244
+ });
245
+ }
246
+ return changes;
247
+ };
248
+ exports.variableChanges = variableChanges;
249
+ const valueOf = (value) => typeof value === 'string' || typeof value === 'number' ? value : null;
250
+ /**
251
+ * Each filled-in value a save changed, by key, labelled and typed by the definitions as they
252
+ * now stand (as they stood, for a variable since removed). A key no definition names - a system
253
+ * variable frozen at issue - has a null label and type.
254
+ */
255
+ const valueChanges = (before, after, variablesBefore, variablesAfter) => {
256
+ const definitions = new Map([
257
+ ...byKey(Array.isArray(variablesBefore) ? variablesBefore : []),
258
+ ...byKey(Array.isArray(variablesAfter) ? variablesAfter : []),
259
+ ]);
260
+ return [...new Set([...Object.keys(after), ...Object.keys(before)])].flatMap((key) => {
261
+ const old = valueOf(before[key]);
262
+ const next = valueOf(after[key]);
263
+ if (old === next)
264
+ return [];
265
+ const definition = definitions.get(key);
266
+ return [{
267
+ key,
268
+ label: definition ? String(definition.label) : null,
269
+ type: definition ? definition.type : null,
270
+ old,
271
+ new: next,
272
+ }];
273
+ });
274
+ };
275
+ exports.valueChanges = valueChanges;
276
+ // ------------------------------------------------------------
277
+ // The entry
278
+ // ------------------------------------------------------------
279
+ /** The columns of `document_templates` and `documents` recorded as what changed inside them. */
280
+ exports.DOCUMENT_CONTENT_COLUMNS = ['blocks', 'variables', 'variable_values', 'email'];
281
+ const REDACTED = '[redacted]';
282
+ /** The marker for one side of a bulk column, keeping which side was empty. */
283
+ const marker = (value) => (value === null || value === undefined ? null : REDACTED);
284
+ /**
285
+ * A template's or document's changes as they are stored. Its `blocks` and `variables` arrive
286
+ * whole on both sides of an update and leave as `'[redacted]'` with a `diff` of what moved inside
287
+ * them; a created or deleted row keeps the plain markers (the whole JSON is not copied). Its
288
+ * `variable_values` leave the same way with a values `diff`, labelled by `variables` (the row's
289
+ * definitions before and after the event). Its `email` keeps only the keys that changed. A column
290
+ * whose JSON changed with nothing printable changing (variables reordered) is dropped.
291
+ */
292
+ const compactDocumentChanges = (changes, variables) => {
293
+ const out = { ...changes };
294
+ for (const column of ['blocks', 'variables']) {
295
+ const change = out[column];
296
+ if (!change)
297
+ continue;
298
+ if (!Array.isArray(change.old) || !Array.isArray(change.new)) {
299
+ out[column] = { old: marker(change.old), new: marker(change.new) };
300
+ continue;
301
+ }
302
+ const diff = column === 'blocks' ? (0, exports.blockChanges)(change.old, change.new) : (0, exports.variableChanges)(change.old, change.new);
303
+ if (diff.length === 0)
304
+ delete out[column];
305
+ else
306
+ out[column] = { old: REDACTED, new: REDACTED, diff };
307
+ }
308
+ const values = out.variable_values;
309
+ if (values && ((0, values_1.isObject)(values.old) || (0, values_1.isObject)(values.new))) {
310
+ const diff = (0, exports.valueChanges)((0, values_1.isObject)(values.old) ? values.old : {}, (0, values_1.isObject)(values.new) ? values.new : {}, variables.old, variables.new);
311
+ if (diff.length === 0)
312
+ delete out.variable_values;
313
+ else
314
+ out.variable_values = { old: marker(values.old), new: marker(values.new), diff };
315
+ }
316
+ const email = out.email;
317
+ if (email && (0, values_1.isObject)(email.old) && (0, values_1.isObject)(email.new)) {
318
+ const was = email.old;
319
+ const now = email.new;
320
+ const moved = [...new Set([...Object.keys(was), ...Object.keys(now)])].filter((key) => !same(was[key] ?? null, now[key] ?? null));
321
+ if (moved.length === 0)
322
+ delete out.email;
323
+ else {
324
+ out.email = {
325
+ old: Object.fromEntries(moved.map((key) => [key, was[key] ?? null])),
326
+ new: Object.fromEntries(moved.map((key) => [key, now[key] ?? null])),
327
+ };
328
+ }
329
+ }
330
+ return out;
331
+ };
332
+ exports.compactDocumentChanges = compactDocumentChanges;