@managemint-solutions/sdk 0.56.0 → 0.65.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.
- package/README.md +209 -7
- package/dist/audit/document-changes.d.ts +39 -0
- package/dist/audit/document-changes.js +332 -0
- package/dist/audit/index.d.ts +50 -2
- package/dist/audit/index.js +87 -44
- package/dist/client.d.ts +6 -0
- package/dist/client.js +9 -0
- package/dist/clients/dto.d.ts +18 -3
- package/dist/clients/dto.js +72 -2
- package/dist/clients/index.js +16 -0
- package/dist/configs/dto.js +4 -12
- package/dist/documents/content.d.ts +39 -0
- package/dist/documents/content.js +443 -0
- package/dist/documents/document-templates.d.ts +37 -0
- package/dist/documents/document-templates.js +143 -0
- package/dist/documents/documents.d.ts +154 -0
- package/dist/documents/documents.js +330 -0
- package/dist/documents/dto.d.ts +138 -0
- package/dist/documents/dto.js +594 -0
- package/dist/documents/index.d.ts +4 -0
- package/dist/documents/index.js +24 -0
- package/dist/documents/render/html.d.ts +64 -0
- package/dist/documents/render/html.js +413 -0
- package/dist/documents/render/index.d.ts +2 -0
- package/dist/documents/render/index.js +23 -0
- package/dist/documents/render/values.d.ts +86 -0
- package/dist/documents/render/values.js +142 -0
- package/dist/feature-requests/dto.js +4 -13
- package/dist/index.d.ts +8 -1
- package/dist/index.js +31 -3
- package/dist/leads/index.js +1 -3
- package/dist/notifications/index.d.ts +6 -0
- package/dist/notifications/index.js +16 -0
- package/dist/organization-payment-methods/dto.d.ts +10 -0
- package/dist/organization-payment-methods/dto.js +56 -0
- package/dist/organization-payment-methods/index.d.ts +8 -1
- package/dist/organization-payment-methods/index.js +23 -0
- package/dist/payments/index.d.ts +67 -0
- package/dist/payments/index.js +137 -0
- package/dist/permissions/dto.d.ts +4 -0
- package/dist/permissions/dto.js +20 -8
- package/dist/service-client/index.d.ts +44 -0
- package/dist/service-client/index.js +77 -0
- package/dist/staff-client.d.ts +3 -0
- package/dist/staff-client.js +5 -0
- package/dist/supporting-files/index.js +4 -6
- package/dist/supporting-files/paths.d.ts +2 -0
- package/dist/supporting-files/paths.js +3 -1
- package/dist/validators.d.ts +12 -0
- package/dist/validators.js +23 -1
- package/dist/visibility/dto.d.ts +15 -0
- package/dist/visibility/dto.js +68 -0
- package/dist/visibility/index.d.ts +59 -0
- package/dist/visibility/index.js +165 -0
- package/package.json +6 -2
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import type { MmsList } from '@managemint-solutions/entities/common';
|
|
2
|
+
import type { DocumentBlock, DocumentEmail, DocumentEntity, DocumentImageUploadTarget, DocumentSend, DocumentSummary, DocumentVariable, DocumentVariableValues } from '@managemint-solutions/entities/documents';
|
|
3
|
+
import { DocumentStatus, type DocumentRepeat, type DocumentVisibility } from '@managemint-solutions/entities/documents/enum';
|
|
4
|
+
import { EntityAuditTrail, type AuditResource, type ResourceActor } from '../audit';
|
|
5
|
+
import type { TypedSupabaseClient } from '../client';
|
|
6
|
+
import type { GetDocumentsDto } from './dto';
|
|
7
|
+
/**
|
|
8
|
+
* The columns the api writes on a document. Issuing, numbering, the totals and the frozen
|
|
9
|
+
* system values are the api's to work out; the sdk stores what it is given.
|
|
10
|
+
*/
|
|
11
|
+
type DocumentRow = {
|
|
12
|
+
template_id: string | null;
|
|
13
|
+
client_id: string | null;
|
|
14
|
+
category: string;
|
|
15
|
+
title: string;
|
|
16
|
+
status: DocumentStatus;
|
|
17
|
+
number_prefix: string;
|
|
18
|
+
number_sequence: number | null;
|
|
19
|
+
number: string | null;
|
|
20
|
+
currency: string | null;
|
|
21
|
+
variables: DocumentVariable[];
|
|
22
|
+
variable_values: DocumentVariableValues;
|
|
23
|
+
blocks: DocumentBlock[];
|
|
24
|
+
visibility: DocumentVisibility;
|
|
25
|
+
subtotal_minor: number;
|
|
26
|
+
discount_minor: number;
|
|
27
|
+
tax_minor: number;
|
|
28
|
+
total_minor: number;
|
|
29
|
+
/** ISO timestamps, and the users.mms_id of who acted. */
|
|
30
|
+
issued_at: string | null;
|
|
31
|
+
issued_by: string | null;
|
|
32
|
+
voided_at: string | null;
|
|
33
|
+
voided_by: string | null;
|
|
34
|
+
void_reason: string | null;
|
|
35
|
+
/** A recurring parent's schedule (dates YYYY-MM-DD), a scheduled draft's issue date and email. */
|
|
36
|
+
repeat: DocumentRepeat | null;
|
|
37
|
+
repeat_next_at: string | null;
|
|
38
|
+
repeat_until: string | null;
|
|
39
|
+
repeat_paused: boolean;
|
|
40
|
+
/** The parent a draft was made from - written by the cron only (members hold no grant on it). */
|
|
41
|
+
repeat_from: string | null;
|
|
42
|
+
issue_at: string | null;
|
|
43
|
+
email: DocumentEmail | null;
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* A document as the sdk reads it. `my_role` (what the caller may do with it), `payments` (read
|
|
47
|
+
* beside it through `payments.list` - they have no foreign key to embed through) and
|
|
48
|
+
* `pay_now_available` are the api's to add.
|
|
49
|
+
*/
|
|
50
|
+
export type DocumentRecord = Omit<DocumentEntity, 'my_role' | 'payments' | 'pay_now_available' | 'payment_status' | 'issued_count'>;
|
|
51
|
+
/** A list row as the sdk reads it; `payment_status` is the api's to add, from the page's payments. */
|
|
52
|
+
export type DocumentListRecord = Omit<DocumentSummary, 'payment_status'>;
|
|
53
|
+
/** What a send's audit event records. */
|
|
54
|
+
export type DocumentSendEvent = Pick<DocumentSend, 'recipients' | 'cc' | 'subject' | 'pay_now'>;
|
|
55
|
+
/**
|
|
56
|
+
* A new draft: everything but the status, the number and the issue and void columns, which a
|
|
57
|
+
* draft has none of. The schedule columns are optional (a plain draft has none); `repeat_from` is
|
|
58
|
+
* the cron's, for the drafts it makes.
|
|
59
|
+
*/
|
|
60
|
+
export type CreateDocumentRow = Omit<DocumentRow, 'status' | 'number_sequence' | 'number' | 'issued_at' | 'issued_by' | 'voided_at' | 'voided_by' | 'void_reason' | keyof DocumentScheduleRow> & Partial<DocumentScheduleRow>;
|
|
61
|
+
type DocumentScheduleRow = Pick<DocumentRow, 'repeat' | 'repeat_next_at' | 'repeat_until' | 'repeat_paused' | 'repeat_from' | 'issue_at' | 'email'>;
|
|
62
|
+
/**
|
|
63
|
+
* A draft save, an issue or a void: whichever columns it changes - never the visibility, which
|
|
64
|
+
* members hold no grant on after create (the visibility route changes it on the service role).
|
|
65
|
+
*/
|
|
66
|
+
export type UpdateDocumentRow = Partial<Omit<DocumentRow, 'visibility' | 'repeat_from'>>;
|
|
67
|
+
/**
|
|
68
|
+
* Documents: a copy of a template's blocks and variables plus the values they were filled with.
|
|
69
|
+
* A draft is edited whole; issuing and voiding are status moves the api makes through `update`.
|
|
70
|
+
* The drafts-only and issued-only rules are the api's - the sdk's part is that every status move,
|
|
71
|
+
* and the delete, is a compare-and-set, so two callers can never both act on the same state.
|
|
72
|
+
*/
|
|
73
|
+
export declare class DocumentsResource {
|
|
74
|
+
private readonly supabase;
|
|
75
|
+
private readonly auth;
|
|
76
|
+
private readonly auditLog;
|
|
77
|
+
private readonly table;
|
|
78
|
+
/** This record's history - its own rows. */
|
|
79
|
+
readonly audit: EntityAuditTrail;
|
|
80
|
+
constructor(supabase: TypedSupabaseClient, auth: ResourceActor, auditLog: AuditResource);
|
|
81
|
+
/**
|
|
82
|
+
* Newest first; `search` matches the title and the number. Recurring parents are a list of
|
|
83
|
+
* their own: `recurring` lists them alone, and every other list leaves them out. `ids` keeps
|
|
84
|
+
* the list to (`in`) or away from (`not_in`) the documents named - the api's payment filter
|
|
85
|
+
* passes the paid ones - and `payable` to issued documents with a total above zero. The
|
|
86
|
+
* DTO's `payment` itself is the api's to turn into those two; the list ignores it.
|
|
87
|
+
*/
|
|
88
|
+
list(query: GetDocumentsDto & {
|
|
89
|
+
organization_id?: string | null;
|
|
90
|
+
ids?: {
|
|
91
|
+
in: string[];
|
|
92
|
+
} | {
|
|
93
|
+
not_in: string[];
|
|
94
|
+
};
|
|
95
|
+
payable?: boolean;
|
|
96
|
+
}): Promise<MmsList<DocumentListRecord>>;
|
|
97
|
+
getById(documentId: string): Promise<DocumentRecord>;
|
|
98
|
+
/** A new draft. A client or template of another organization is refused by the foreign keys (409). */
|
|
99
|
+
create(row: CreateDocumentRow): Promise<DocumentRecord>;
|
|
100
|
+
/**
|
|
101
|
+
* Writes `row` only while the document is still `expected` - the status the api read and
|
|
102
|
+
* decided on: a draft save and an issue expect a draft, a void an issued document. Resolves to
|
|
103
|
+
* null when the document has moved on since, so the caller can say so instead of overwriting.
|
|
104
|
+
* An issue whose number another document took first throws 409 with `code` 23505.
|
|
105
|
+
*/
|
|
106
|
+
update(documentId: string, expected: DocumentStatus, row: UpdateDocumentRow): Promise<DocumentRecord | null>;
|
|
107
|
+
/** How many drafts a recurring parent has made - the documents with it as `repeat_from`. */
|
|
108
|
+
countCopies(parentId: string): Promise<number>;
|
|
109
|
+
/**
|
|
110
|
+
* Files a send on the document's history: an audit event of its own (`SEND`), not a change to
|
|
111
|
+
* the row, holding what was sent as set by it. Like every audit write it never throws.
|
|
112
|
+
*/
|
|
113
|
+
recordSend(documentId: string, send: DocumentSendEvent): Promise<void>;
|
|
114
|
+
/** The emails a document went out in, newest first - its `SEND` events. */
|
|
115
|
+
sends(documentId: string): Promise<DocumentSend[]>;
|
|
116
|
+
/**
|
|
117
|
+
* How many emails the organization has sent since `since` (ISO), across every document - on
|
|
118
|
+
* the service client's organization scope, so private documents count too. The daily cap.
|
|
119
|
+
*/
|
|
120
|
+
countSendsSince(since: string): Promise<number>;
|
|
121
|
+
/**
|
|
122
|
+
* Deletes a draft: a compare-and-set on `draft`, so an issue that lands first wins and the
|
|
123
|
+
* issued document stays. False when the document is no longer a draft - it is voided instead.
|
|
124
|
+
*/
|
|
125
|
+
remove(documentId: string): Promise<boolean>;
|
|
126
|
+
/**
|
|
127
|
+
* The sequence the next document numbered with `prefix` takes: the organization's highest for
|
|
128
|
+
* that prefix, plus one (1 for the first). Two issues at once can pick the same one; the unique
|
|
129
|
+
* index refuses the second with a 409 (`code` 23505) and the api retries.
|
|
130
|
+
*/
|
|
131
|
+
nextSequence(prefix: string): Promise<number>;
|
|
132
|
+
/**
|
|
133
|
+
* Where the browser puts an image block's file: a signed upload URL (two hours, one PUT) into
|
|
134
|
+
* the supporting-files bucket under `<organization>/documents/images/`, and the `path` the
|
|
135
|
+
* block stores. The object is named afresh each time, never after the caller's file, and is
|
|
136
|
+
* never replaced, so an issued document's images cannot change under it. Storage checks the
|
|
137
|
+
* bucket's INSERT policy as the caller when the URL is minted.
|
|
138
|
+
*/
|
|
139
|
+
createImageUploadUrl(fileName: string): Promise<DocumentImageUploadTarget>;
|
|
140
|
+
/**
|
|
141
|
+
* Signed URLs for image blocks' paths, by path, for the HTML the api renders - one Storage call
|
|
142
|
+
* however many there are. The paths are inside the caller's organization's folder, so a path
|
|
143
|
+
* naming anything else signs nothing. A path with no object behind it is left out.
|
|
144
|
+
*/
|
|
145
|
+
imageUrls(paths: string[]): Promise<Record<string, string>>;
|
|
146
|
+
/**
|
|
147
|
+
* `repeat_from` as the parent's identity (`{ mms_id, title }`) rather than its id, in one read for
|
|
148
|
+
* all the rows. PostgREST embeds a table's many-to-one relationship to itself only through a SQL
|
|
149
|
+
* function, so the parents are read beside the rows instead. A parent the reader cannot see
|
|
150
|
+
* (or since deleted) reads as null.
|
|
151
|
+
*/
|
|
152
|
+
private withParents;
|
|
153
|
+
}
|
|
154
|
+
export {};
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DocumentsResource = void 0;
|
|
4
|
+
const node_crypto_1 = require("node:crypto");
|
|
5
|
+
const enum_1 = require("@managemint-solutions/entities/documents/enum");
|
|
6
|
+
const enum_2 = require("@managemint-solutions/entities/audit-trail/enum");
|
|
7
|
+
const audit_1 = require("../audit");
|
|
8
|
+
const errors_1 = require("../errors");
|
|
9
|
+
const paths_1 = require("../supporting-files/paths");
|
|
10
|
+
const tenancy_1 = require("../tenancy");
|
|
11
|
+
const transforms_1 = require("../transforms");
|
|
12
|
+
const content_1 = require("./content");
|
|
13
|
+
// The public contract is the shared DocumentSummary / DocumentEntity from
|
|
14
|
+
// @managemint-solutions/entities; the casts at the returns declare the shape the select strings
|
|
15
|
+
// below produce. The client and the template are embedded through the composite same-organization
|
|
16
|
+
// keys, named, since a client_profiles view carries no organization_id to match them on.
|
|
17
|
+
const DOCUMENT_LIST_SELECT = `
|
|
18
|
+
mms_id,
|
|
19
|
+
created_at,
|
|
20
|
+
title,
|
|
21
|
+
category,
|
|
22
|
+
status,
|
|
23
|
+
number,
|
|
24
|
+
client:clients!documents_client_fkey ( mms_id, name ),
|
|
25
|
+
currency,
|
|
26
|
+
total_minor,
|
|
27
|
+
issued_at,
|
|
28
|
+
repeat,
|
|
29
|
+
repeat_next_at,
|
|
30
|
+
repeat_until,
|
|
31
|
+
repeat_paused,
|
|
32
|
+
repeat_from,
|
|
33
|
+
issue_at,
|
|
34
|
+
visibility
|
|
35
|
+
`;
|
|
36
|
+
const DOCUMENT_DETAIL_SELECT = `
|
|
37
|
+
mms_id,
|
|
38
|
+
created_at,
|
|
39
|
+
created_by:user_profiles!created_by ${transforms_1.USER_PROFILE_EMBED},
|
|
40
|
+
updated_at,
|
|
41
|
+
last_updated_by:user_profiles!last_updated_by ${transforms_1.USER_PROFILE_EMBED},
|
|
42
|
+
template:document_templates!documents_template_fkey ( mms_id, name ),
|
|
43
|
+
client:clients!documents_client_fkey ( mms_id, name ),
|
|
44
|
+
category,
|
|
45
|
+
title,
|
|
46
|
+
status,
|
|
47
|
+
number_prefix,
|
|
48
|
+
number,
|
|
49
|
+
currency,
|
|
50
|
+
variables,
|
|
51
|
+
variable_values,
|
|
52
|
+
blocks,
|
|
53
|
+
blocks_version,
|
|
54
|
+
subtotal_minor,
|
|
55
|
+
discount_minor,
|
|
56
|
+
tax_minor,
|
|
57
|
+
total_minor,
|
|
58
|
+
issued_at,
|
|
59
|
+
issued_by:user_profiles!issued_by ${transforms_1.USER_PROFILE_EMBED},
|
|
60
|
+
voided_at,
|
|
61
|
+
voided_by:user_profiles!voided_by ${transforms_1.USER_PROFILE_EMBED},
|
|
62
|
+
void_reason,
|
|
63
|
+
repeat,
|
|
64
|
+
repeat_next_at,
|
|
65
|
+
repeat_until,
|
|
66
|
+
repeat_paused,
|
|
67
|
+
repeat_from,
|
|
68
|
+
issue_at,
|
|
69
|
+
email,
|
|
70
|
+
visibility
|
|
71
|
+
`;
|
|
72
|
+
/** How long a signed image URL in rendered HTML stays valid - long enough to keep a preview open. */
|
|
73
|
+
const IMAGE_URL_TTL_SECONDS = 3600;
|
|
74
|
+
/**
|
|
75
|
+
* Documents: a copy of a template's blocks and variables plus the values they were filled with.
|
|
76
|
+
* A draft is edited whole; issuing and voiding are status moves the api makes through `update`.
|
|
77
|
+
* The drafts-only and issued-only rules are the api's - the sdk's part is that every status move,
|
|
78
|
+
* and the delete, is a compare-and-set, so two callers can never both act on the same state.
|
|
79
|
+
*/
|
|
80
|
+
class DocumentsResource {
|
|
81
|
+
supabase;
|
|
82
|
+
auth;
|
|
83
|
+
auditLog;
|
|
84
|
+
table;
|
|
85
|
+
/** This record's history - its own rows. */
|
|
86
|
+
audit;
|
|
87
|
+
constructor(supabase,
|
|
88
|
+
// A tenant, staff, or - on the service client's organization scope - the system, for the
|
|
89
|
+
// public pay page, the Paystack webhook and the daily send cap.
|
|
90
|
+
auth, auditLog) {
|
|
91
|
+
this.supabase = supabase;
|
|
92
|
+
this.auth = auth;
|
|
93
|
+
this.auditLog = auditLog;
|
|
94
|
+
this.table = (0, audit_1.auditedTable)(supabase, auth, auditLog, {
|
|
95
|
+
table: 'documents',
|
|
96
|
+
entityType: enum_2.AuditTrailEntityType.DOCUMENTS,
|
|
97
|
+
documentContent: true,
|
|
98
|
+
});
|
|
99
|
+
this.audit = new audit_1.EntityAuditTrail(supabase, auth, enum_2.AuditTrailEntityType.DOCUMENTS);
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Newest first; `search` matches the title and the number. Recurring parents are a list of
|
|
103
|
+
* their own: `recurring` lists them alone, and every other list leaves them out. `ids` keeps
|
|
104
|
+
* the list to (`in`) or away from (`not_in`) the documents named - the api's payment filter
|
|
105
|
+
* passes the paid ones - and `payable` to issued documents with a total above zero. The
|
|
106
|
+
* DTO's `payment` itself is the api's to turn into those two; the list ignores it.
|
|
107
|
+
*/
|
|
108
|
+
async list(query) {
|
|
109
|
+
const page = query.page;
|
|
110
|
+
const pageSize = query.page_size;
|
|
111
|
+
const offset = (page - 1) * pageSize;
|
|
112
|
+
const trimmedSearch = query.search?.trim();
|
|
113
|
+
// `order`/`range` return `this`, so the conditional filters below reassign onto the builder.
|
|
114
|
+
let documentsQuery = (0, tenancy_1.scopeToOrganization)(this.supabase
|
|
115
|
+
.from('documents')
|
|
116
|
+
.select((0, tenancy_1.withOrganization)(DOCUMENT_LIST_SELECT, this.auth), { count: 'estimated' }), this.auth)
|
|
117
|
+
.order('created_at', { ascending: false })
|
|
118
|
+
.range(offset, offset + pageSize - 1);
|
|
119
|
+
if (query.organization_id) {
|
|
120
|
+
documentsQuery = documentsQuery.eq('organization_id', query.organization_id);
|
|
121
|
+
}
|
|
122
|
+
if (query.status) {
|
|
123
|
+
documentsQuery = documentsQuery.eq('status', query.status);
|
|
124
|
+
}
|
|
125
|
+
if (query.category) {
|
|
126
|
+
documentsQuery = documentsQuery.eq('category', query.category);
|
|
127
|
+
}
|
|
128
|
+
if (query.client_id) {
|
|
129
|
+
documentsQuery = documentsQuery.eq('client_id', query.client_id);
|
|
130
|
+
}
|
|
131
|
+
if (query.template_id) {
|
|
132
|
+
documentsQuery = documentsQuery.eq('template_id', query.template_id);
|
|
133
|
+
}
|
|
134
|
+
documentsQuery = query.recurring
|
|
135
|
+
? documentsQuery.not('repeat', 'is', null)
|
|
136
|
+
: documentsQuery.is('repeat', null);
|
|
137
|
+
if (query.repeat_from) {
|
|
138
|
+
documentsQuery = documentsQuery.eq('repeat_from', query.repeat_from);
|
|
139
|
+
}
|
|
140
|
+
if (query.ids && 'in' in query.ids) {
|
|
141
|
+
documentsQuery = documentsQuery.in('mms_id', query.ids.in);
|
|
142
|
+
}
|
|
143
|
+
else if (query.ids && query.ids.not_in.length > 0) {
|
|
144
|
+
documentsQuery = documentsQuery.not('mms_id', 'in', `(${query.ids.not_in.join(',')})`);
|
|
145
|
+
}
|
|
146
|
+
if (query.payable) {
|
|
147
|
+
documentsQuery = documentsQuery.eq('status', enum_1.DocumentStatus.ISSUED).gt('total_minor', 0);
|
|
148
|
+
}
|
|
149
|
+
if (trimmedSearch) {
|
|
150
|
+
documentsQuery = documentsQuery.or((0, transforms_1.orIlike)(['title', 'number'], trimmedSearch));
|
|
151
|
+
}
|
|
152
|
+
const { data, error, count } = await documentsQuery;
|
|
153
|
+
if (error)
|
|
154
|
+
throw (0, errors_1.mapPostgrestError)(error);
|
|
155
|
+
const rows = await this.withParents((data ?? []));
|
|
156
|
+
return (0, transforms_1.toMmsList)(rows, page, pageSize, count);
|
|
157
|
+
}
|
|
158
|
+
async getById(documentId) {
|
|
159
|
+
const { data, error } = await (0, tenancy_1.scopeToOrganization)(this.supabase.from('documents').select((0, tenancy_1.withOrganization)(DOCUMENT_DETAIL_SELECT, this.auth)), this.auth)
|
|
160
|
+
.eq('mms_id', documentId)
|
|
161
|
+
.single();
|
|
162
|
+
if (error)
|
|
163
|
+
throw (0, errors_1.mapPostgrestError)(error);
|
|
164
|
+
return (await this.withParents([data]))[0];
|
|
165
|
+
}
|
|
166
|
+
/** A new draft. A client or template of another organization is refused by the foreign keys (409). */
|
|
167
|
+
async create(row) {
|
|
168
|
+
const created = await this.table.insert({
|
|
169
|
+
...row,
|
|
170
|
+
organization_id: (0, tenancy_1.requireOrganization)(this.auth),
|
|
171
|
+
created_by: this.auth.mms_id,
|
|
172
|
+
created_at: new Date().toISOString(),
|
|
173
|
+
});
|
|
174
|
+
return this.getById(created.mms_id);
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Writes `row` only while the document is still `expected` - the status the api read and
|
|
178
|
+
* decided on: a draft save and an issue expect a draft, a void an issued document. Resolves to
|
|
179
|
+
* null when the document has moved on since, so the caller can say so instead of overwriting.
|
|
180
|
+
* An issue whose number another document took first throws 409 with `code` 23505.
|
|
181
|
+
*/
|
|
182
|
+
async update(documentId, expected, row) {
|
|
183
|
+
const updated = await this.table.updateWhere(documentId, (query) => query.eq('status', expected), {
|
|
184
|
+
...row,
|
|
185
|
+
last_updated_by: this.auth.mms_id,
|
|
186
|
+
updated_at: new Date().toISOString(),
|
|
187
|
+
});
|
|
188
|
+
return updated ? this.getById(documentId) : null;
|
|
189
|
+
}
|
|
190
|
+
/** How many drafts a recurring parent has made - the documents with it as `repeat_from`. */
|
|
191
|
+
async countCopies(parentId) {
|
|
192
|
+
const { error, count } = await (0, tenancy_1.scopeToOrganization)(this.supabase.from('documents').select('mms_id', { count: 'exact', head: true }), this.auth).eq('repeat_from', parentId);
|
|
193
|
+
if (error)
|
|
194
|
+
throw (0, errors_1.mapPostgrestError)(error);
|
|
195
|
+
return count ?? 0;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Files a send on the document's history: an audit event of its own (`SEND`), not a change to
|
|
199
|
+
* the row, holding what was sent as set by it. Like every audit write it never throws.
|
|
200
|
+
*/
|
|
201
|
+
async recordSend(documentId, send) {
|
|
202
|
+
await this.auditLog.record({
|
|
203
|
+
action: enum_2.AuditTrailAction.SEND,
|
|
204
|
+
entityType: enum_2.AuditTrailEntityType.DOCUMENTS,
|
|
205
|
+
entityId: documentId,
|
|
206
|
+
changes: Object.fromEntries(Object.entries(send).map(([key, value]) => [key, { old: null, new: value }])),
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
/** The emails a document went out in, newest first - its `SEND` events. */
|
|
210
|
+
async sends(documentId) {
|
|
211
|
+
const { data, error } = await (0, tenancy_1.scopeToOrganization)(this.supabase
|
|
212
|
+
.from('audit_logs')
|
|
213
|
+
.select(`mms_id, created_at, created_by:user_profiles!created_by ${transforms_1.USER_PROFILE_EMBED}, changes`), this.auth)
|
|
214
|
+
.eq('entity_type', enum_2.AuditTrailEntityType.DOCUMENTS)
|
|
215
|
+
.eq('entity_id', documentId)
|
|
216
|
+
.eq('action', enum_2.AuditTrailAction.SEND)
|
|
217
|
+
.order('created_at', { ascending: false });
|
|
218
|
+
if (error)
|
|
219
|
+
throw (0, errors_1.mapPostgrestError)(error);
|
|
220
|
+
return (data ?? []).map(({ changes, ...send }) => ({
|
|
221
|
+
...send,
|
|
222
|
+
recipients: (changes.recipients?.new ?? []),
|
|
223
|
+
cc: (changes.cc?.new ?? []),
|
|
224
|
+
subject: (changes.subject?.new ?? ''),
|
|
225
|
+
pay_now: changes.pay_now?.new === true,
|
|
226
|
+
}));
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* How many emails the organization has sent since `since` (ISO), across every document - on
|
|
230
|
+
* the service client's organization scope, so private documents count too. The daily cap.
|
|
231
|
+
*/
|
|
232
|
+
async countSendsSince(since) {
|
|
233
|
+
const { error, count } = await this.supabase
|
|
234
|
+
.from('audit_logs')
|
|
235
|
+
.select('mms_id', { count: 'exact', head: true })
|
|
236
|
+
.eq('organization_id', (0, tenancy_1.requireOrganization)(this.auth))
|
|
237
|
+
.eq('entity_type', enum_2.AuditTrailEntityType.DOCUMENTS)
|
|
238
|
+
.eq('action', enum_2.AuditTrailAction.SEND)
|
|
239
|
+
.gte('created_at', since);
|
|
240
|
+
if (error)
|
|
241
|
+
throw (0, errors_1.mapPostgrestError)(error);
|
|
242
|
+
return count ?? 0;
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Deletes a draft: a compare-and-set on `draft`, so an issue that lands first wins and the
|
|
246
|
+
* issued document stays. False when the document is no longer a draft - it is voided instead.
|
|
247
|
+
*/
|
|
248
|
+
async remove(documentId) {
|
|
249
|
+
const deleted = await this.table.removeWhere(documentId, (query) => query.eq('status', enum_1.DocumentStatus.DRAFT));
|
|
250
|
+
return deleted !== null;
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* The sequence the next document numbered with `prefix` takes: the organization's highest for
|
|
254
|
+
* that prefix, plus one (1 for the first). Two issues at once can pick the same one; the unique
|
|
255
|
+
* index refuses the second with a 409 (`code` 23505) and the api retries.
|
|
256
|
+
*/
|
|
257
|
+
async nextSequence(prefix) {
|
|
258
|
+
const organizationId = (0, tenancy_1.requireOrganization)(this.auth);
|
|
259
|
+
const { data, error } = await this.supabase
|
|
260
|
+
.from('documents')
|
|
261
|
+
.select('number_sequence')
|
|
262
|
+
.eq('organization_id', organizationId)
|
|
263
|
+
.eq('number_prefix', prefix)
|
|
264
|
+
// Not just for the meaning: a descending order puts nulls first, and this is also the
|
|
265
|
+
// partial unique index's own predicate, so the read is one index probe.
|
|
266
|
+
.not('number_sequence', 'is', null)
|
|
267
|
+
.order('number_sequence', { ascending: false })
|
|
268
|
+
.limit(1)
|
|
269
|
+
.maybeSingle();
|
|
270
|
+
if (error)
|
|
271
|
+
throw (0, errors_1.mapPostgrestError)(error);
|
|
272
|
+
return (data?.number_sequence ?? 0) + 1;
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Where the browser puts an image block's file: a signed upload URL (two hours, one PUT) into
|
|
276
|
+
* the supporting-files bucket under `<organization>/documents/images/`, and the `path` the
|
|
277
|
+
* block stores. The object is named afresh each time, never after the caller's file, and is
|
|
278
|
+
* never replaced, so an issued document's images cannot change under it. Storage checks the
|
|
279
|
+
* bucket's INSERT policy as the caller when the URL is minted.
|
|
280
|
+
*/
|
|
281
|
+
async createImageUploadUrl(fileName) {
|
|
282
|
+
const organizationId = (0, tenancy_1.requireOrganization)(this.auth);
|
|
283
|
+
const extension = (0, paths_1.splitFileName)((0, paths_1.sanitizeFileName)(fileName)).extension.toLowerCase();
|
|
284
|
+
if (!content_1.IMAGE_EXTENSIONS.has(extension)) {
|
|
285
|
+
throw new errors_1.SupabaseClientError({
|
|
286
|
+
status: 400,
|
|
287
|
+
error: 'Storage Error',
|
|
288
|
+
message: `Unsupported image type: ${fileName}. Use ${[...content_1.IMAGE_EXTENSIONS].join(', ')}`,
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
const key = (0, paths_1.storagePath)(organizationId, 'documents', 'images', (0, node_crypto_1.randomUUID)(), extension);
|
|
292
|
+
const { data, error } = await this.supabase.storage.from(paths_1.SUPPORTING_FILES_BUCKET).createSignedUploadUrl(key);
|
|
293
|
+
if (error)
|
|
294
|
+
throw (0, errors_1.mapStorageError)(error, `Failed to prepare upload: ${fileName}`);
|
|
295
|
+
return { path: key.slice(organizationId.length + 1), url: data.signedUrl };
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Signed URLs for image blocks' paths, by path, for the HTML the api renders - one Storage call
|
|
299
|
+
* however many there are. The paths are inside the caller's organization's folder, so a path
|
|
300
|
+
* naming anything else signs nothing. A path with no object behind it is left out.
|
|
301
|
+
*/
|
|
302
|
+
async imageUrls(paths) {
|
|
303
|
+
if (paths.length === 0)
|
|
304
|
+
return {};
|
|
305
|
+
const organizationId = (0, tenancy_1.requireOrganization)(this.auth);
|
|
306
|
+
const { data, error } = await this.supabase.storage
|
|
307
|
+
.from(paths_1.SUPPORTING_FILES_BUCKET)
|
|
308
|
+
.createSignedUrls(paths.map((path) => `${organizationId}/${path}`), IMAGE_URL_TTL_SECONDS);
|
|
309
|
+
if (error)
|
|
310
|
+
throw (0, errors_1.mapStorageError)(error);
|
|
311
|
+
return Object.fromEntries(data.flatMap(({ path, signedUrl }) => path && signedUrl ? [[path.slice(organizationId.length + 1), signedUrl]] : []));
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* `repeat_from` as the parent's identity (`{ mms_id, title }`) rather than its id, in one read for
|
|
315
|
+
* all the rows. PostgREST embeds a table's many-to-one relationship to itself only through a SQL
|
|
316
|
+
* function, so the parents are read beside the rows instead. A parent the reader cannot see
|
|
317
|
+
* (or since deleted) reads as null.
|
|
318
|
+
*/
|
|
319
|
+
async withParents(rows) {
|
|
320
|
+
const ids = [...new Set(rows.map((row) => row.repeat_from).filter((id) => typeof id === 'string'))];
|
|
321
|
+
if (ids.length === 0)
|
|
322
|
+
return rows.map((row) => ({ ...row, repeat_from: null }));
|
|
323
|
+
const { data, error } = await this.supabase.from('documents').select('mms_id, title').in('mms_id', ids);
|
|
324
|
+
if (error)
|
|
325
|
+
throw (0, errors_1.mapPostgrestError)(error);
|
|
326
|
+
const parents = new Map((data ?? []).map((parent) => [parent.mms_id, parent]));
|
|
327
|
+
return rows.map((row) => ({ ...row, repeat_from: parents.get(row.repeat_from) ?? null }));
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
exports.DocumentsResource = DocumentsResource;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import type { DocumentBlock, DocumentEmail, DocumentVariable, DocumentVariableValues } from '@managemint-solutions/entities/documents';
|
|
2
|
+
import type { CatalogueTemplateSlugDto as CatalogueTemplateSlugDtoType, CopyCatalogueTemplateDto as CopyCatalogueTemplateDtoType, CreateDocumentDto as CreateDocumentDtoType, CreateDocumentTemplateDto as CreateDocumentTemplateDtoType, DocumentIdDto as DocumentIdDtoType, DocumentImageNameDto as DocumentImageNameDtoType, DocumentImageUrlsDto as DocumentImageUrlsDtoType, DocumentPayQueryDto as DocumentPayQueryDtoType, DocumentPayTokenDto as DocumentPayTokenDtoType, DocumentPaymentIdDto as DocumentPaymentIdDtoType, DocumentTemplateIdDto as DocumentTemplateIdDtoType, GetCatalogueTemplatesDto as GetCatalogueTemplatesDtoType, GetDocumentsDto as GetDocumentsDtoType, GetDocumentTemplatesDto as GetDocumentTemplatesDtoType, PayDocumentDto as PayDocumentDtoType, RecordDocumentPaymentDto as RecordDocumentPaymentDtoType, ReassignPrivateDocumentsDto as ReassignPrivateDocumentsDtoType, SendDocumentDto as SendDocumentDtoType, UpdateDocumentDto as UpdateDocumentDtoType, UpdateDocumentTemplateDto as UpdateDocumentTemplateDtoType, VoidDocumentDto as VoidDocumentDtoType } from '@managemint-solutions/entities/documents/dto';
|
|
3
|
+
import { DocumentPaymentFilter, DocumentRepeat, DocumentStatus, DocumentVisibility } from '@managemint-solutions/entities/documents/enum';
|
|
4
|
+
export declare class GetDocumentTemplatesDto implements GetDocumentTemplatesDtoType {
|
|
5
|
+
readonly search?: string | null;
|
|
6
|
+
readonly category?: string | null;
|
|
7
|
+
readonly archive: boolean;
|
|
8
|
+
readonly page: number;
|
|
9
|
+
readonly page_size: number;
|
|
10
|
+
}
|
|
11
|
+
export declare class DocumentTemplateIdDto implements DocumentTemplateIdDtoType {
|
|
12
|
+
readonly templateId: string;
|
|
13
|
+
}
|
|
14
|
+
/** Saving replaces the whole template. Its visibility changes only through the visibility route. */
|
|
15
|
+
export declare class UpdateDocumentTemplateDto implements UpdateDocumentTemplateDtoType {
|
|
16
|
+
readonly name: string;
|
|
17
|
+
readonly category?: string;
|
|
18
|
+
readonly number_prefix?: string;
|
|
19
|
+
readonly variables: DocumentVariable[];
|
|
20
|
+
readonly blocks: DocumentBlock[];
|
|
21
|
+
}
|
|
22
|
+
/** A new template: what a save holds, plus who sees it (omitted: the organization). */
|
|
23
|
+
export declare class CreateDocumentTemplateDto extends UpdateDocumentTemplateDto implements CreateDocumentTemplateDtoType {
|
|
24
|
+
readonly visibility?: DocumentVisibility;
|
|
25
|
+
}
|
|
26
|
+
export declare class GetDocumentsDto implements GetDocumentsDtoType {
|
|
27
|
+
readonly search?: string | null;
|
|
28
|
+
readonly category?: string | null;
|
|
29
|
+
readonly status?: DocumentStatus | null;
|
|
30
|
+
readonly client_id?: string | null;
|
|
31
|
+
readonly template_id?: string | null;
|
|
32
|
+
readonly recurring?: boolean;
|
|
33
|
+
readonly repeat_from?: string | null;
|
|
34
|
+
readonly payment?: DocumentPaymentFilter | null;
|
|
35
|
+
readonly page: number;
|
|
36
|
+
readonly page_size: number;
|
|
37
|
+
}
|
|
38
|
+
export declare class DocumentIdDto implements DocumentIdDtoType {
|
|
39
|
+
readonly documentId: string;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* A recurring parent's schedule and a draft's issue date, on create and on a draft save. Each is
|
|
43
|
+
* checked alone here; how they go together (a parent needs its first date, never an issue date)
|
|
44
|
+
* is the api's.
|
|
45
|
+
*/
|
|
46
|
+
declare class DocumentScheduleDto {
|
|
47
|
+
readonly repeat?: DocumentRepeat | null;
|
|
48
|
+
readonly repeat_next_at?: string | null;
|
|
49
|
+
readonly repeat_until?: string | null;
|
|
50
|
+
readonly repeat_paused?: boolean;
|
|
51
|
+
readonly issue_at?: string | null;
|
|
52
|
+
readonly email?: DocumentEmail | null;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* A new draft. Its values cannot be checked here - with a template, the variables they fill are
|
|
56
|
+
* the template's - so the api checks the draft it composes with `documentProblem`.
|
|
57
|
+
*/
|
|
58
|
+
export declare class CreateDocumentDto extends DocumentScheduleDto implements CreateDocumentDtoType {
|
|
59
|
+
readonly template_id?: string | null;
|
|
60
|
+
readonly client_id?: string | null;
|
|
61
|
+
readonly title?: string;
|
|
62
|
+
readonly currency?: string | null;
|
|
63
|
+
readonly variable_values?: DocumentVariableValues;
|
|
64
|
+
readonly visibility?: DocumentVisibility;
|
|
65
|
+
}
|
|
66
|
+
/** A whole draft, under the same content rules as a template, plus its values and currency. */
|
|
67
|
+
export declare class UpdateDocumentDto extends DocumentScheduleDto implements UpdateDocumentDtoType {
|
|
68
|
+
readonly client_id: string | null;
|
|
69
|
+
readonly title: string;
|
|
70
|
+
readonly category: string;
|
|
71
|
+
readonly number_prefix: string;
|
|
72
|
+
readonly currency: string | null;
|
|
73
|
+
readonly variables: DocumentVariable[];
|
|
74
|
+
readonly variable_values: DocumentVariableValues;
|
|
75
|
+
readonly blocks: DocumentBlock[];
|
|
76
|
+
}
|
|
77
|
+
export declare class VoidDocumentDto implements VoidDocumentDtoType {
|
|
78
|
+
readonly reason: string;
|
|
79
|
+
}
|
|
80
|
+
export declare class DocumentImageNameDto implements DocumentImageNameDtoType {
|
|
81
|
+
readonly name: string;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Image paths to sign for reading, as a query (`?paths=a&paths=b`; one `paths` arrives as a plain
|
|
85
|
+
* string and is taken as a list of one). Each must be one `createImageUploadUrl` handed out
|
|
86
|
+
* (`documents/images/<uuid>.<ext>`); `imageUrls` then signs it under the caller's own
|
|
87
|
+
* organization, so nothing outside that folder can be named.
|
|
88
|
+
*/
|
|
89
|
+
export declare class DocumentImageUrlsDto implements DocumentImageUrlsDtoType {
|
|
90
|
+
readonly paths: string[];
|
|
91
|
+
}
|
|
92
|
+
/** The owner's move of one person's private templates and documents to another; v4 user ids. */
|
|
93
|
+
export declare class ReassignPrivateDocumentsDto implements ReassignPrivateDocumentsDtoType {
|
|
94
|
+
readonly from_user_id: string;
|
|
95
|
+
readonly to_user_id: string;
|
|
96
|
+
}
|
|
97
|
+
/** An issued document's email. Who may send it, and whether Pay now may go on it, is the api's. */
|
|
98
|
+
export declare class SendDocumentDto implements SendDocumentDtoType {
|
|
99
|
+
readonly to: string[];
|
|
100
|
+
readonly cc?: string[];
|
|
101
|
+
readonly subject: string;
|
|
102
|
+
readonly message: string;
|
|
103
|
+
readonly pay_now: boolean;
|
|
104
|
+
}
|
|
105
|
+
/** A public pay link's token. Only its shape is checked here; the api checks the signature. */
|
|
106
|
+
export declare class DocumentPayTokenDto implements DocumentPayTokenDtoType {
|
|
107
|
+
readonly token: string;
|
|
108
|
+
}
|
|
109
|
+
/** Paystack's return to the pay page: its reference - letters, digits and - . = only, as Paystack allows. */
|
|
110
|
+
export declare class DocumentPayQueryDto implements DocumentPayQueryDtoType {
|
|
111
|
+
readonly reference?: string;
|
|
112
|
+
}
|
|
113
|
+
export declare class PayDocumentDto implements PayDocumentDtoType {
|
|
114
|
+
readonly email: string;
|
|
115
|
+
}
|
|
116
|
+
/** A document marked paid by hand: the date the money arrived, and the bank reference if any. */
|
|
117
|
+
export declare class RecordDocumentPaymentDto implements RecordDocumentPaymentDtoType {
|
|
118
|
+
readonly paid_at: string;
|
|
119
|
+
readonly reference?: string | null;
|
|
120
|
+
}
|
|
121
|
+
export declare class DocumentPaymentIdDto implements DocumentPaymentIdDtoType {
|
|
122
|
+
readonly documentId: string;
|
|
123
|
+
readonly paymentId: string;
|
|
124
|
+
}
|
|
125
|
+
export declare class GetCatalogueTemplatesDto implements GetCatalogueTemplatesDtoType {
|
|
126
|
+
readonly search?: string | null;
|
|
127
|
+
readonly category?: string | null;
|
|
128
|
+
}
|
|
129
|
+
export declare class CatalogueTemplateSlugDto implements CatalogueTemplateSlugDtoType {
|
|
130
|
+
readonly slug: string;
|
|
131
|
+
}
|
|
132
|
+
/** The copy's own name and prefix are optional and held to a template save's rules when given. */
|
|
133
|
+
export declare class CopyCatalogueTemplateDto implements CopyCatalogueTemplateDtoType {
|
|
134
|
+
readonly slug: string;
|
|
135
|
+
readonly name?: string;
|
|
136
|
+
readonly number_prefix?: string;
|
|
137
|
+
}
|
|
138
|
+
export {};
|