@managemint-solutions/sdk 0.42.0 → 0.44.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 +4 -1
- package/dist/additional-details/index.js +7 -2
- package/dist/client.d.ts +2 -0
- package/dist/client.js +4 -0
- package/dist/clients/index.js +4 -2
- package/dist/index.d.ts +2 -1
- package/dist/index.js +10 -1
- package/dist/leads/dto.d.ts +35 -0
- package/dist/leads/dto.js +209 -0
- package/dist/leads/index.d.ts +54 -0
- package/dist/leads/index.js +413 -0
- package/dist/leads/won.d.ts +7 -0
- package/dist/leads/won.js +26 -0
- package/dist/notes/index.js +7 -2
- package/dist/permissions/dto.d.ts +4 -0
- package/dist/permissions/dto.js +20 -0
- package/dist/projects/index.js +4 -2
- package/dist/service-client/index.d.ts +7 -0
- package/dist/service-client/index.js +12 -0
- package/dist/statuses/index.d.ts +8 -1
- package/dist/statuses/index.js +26 -1
- package/dist/supporting-files/index.js +7 -2
- package/dist/tasks/dto.d.ts +10 -1
- package/dist/tasks/dto.js +29 -1
- package/dist/tasks/index.d.ts +15 -1
- package/dist/tasks/index.js +156 -14
- package/package.json +2 -2
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
14
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
|
+
};
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
exports.LeadsResource = void 0;
|
|
18
|
+
const enum_1 = require("@managemint-solutions/entities/leads/enum");
|
|
19
|
+
const enum_2 = require("@managemint-solutions/entities/additional-details/enum");
|
|
20
|
+
const enum_3 = require("@managemint-solutions/entities/notes/enum");
|
|
21
|
+
const enum_4 = require("@managemint-solutions/entities/status/enum");
|
|
22
|
+
const enum_5 = require("@managemint-solutions/entities/supporting-files/enum");
|
|
23
|
+
const enum_6 = require("@managemint-solutions/entities/audit-trail/enum");
|
|
24
|
+
const audit_1 = require("../audit");
|
|
25
|
+
const errors_1 = require("../errors");
|
|
26
|
+
const paths_1 = require("../supporting-files/paths");
|
|
27
|
+
const transforms_1 = require("../transforms");
|
|
28
|
+
const won_1 = require("./won");
|
|
29
|
+
__exportStar(require("./dto"), exports);
|
|
30
|
+
// The public contract is the shared LeadSummary / LeadEntity from @managemint-solutions/entities;
|
|
31
|
+
// the casts at the returns declare the shape the select strings below produce (the columns are
|
|
32
|
+
// defined in the api's supabase/migrations).
|
|
33
|
+
const LEAD_LIST_SELECT = `
|
|
34
|
+
mms_id,
|
|
35
|
+
created_at,
|
|
36
|
+
name,
|
|
37
|
+
contact_person,
|
|
38
|
+
email,
|
|
39
|
+
status:statuses!status_id (
|
|
40
|
+
mms_id,
|
|
41
|
+
colour,
|
|
42
|
+
label,
|
|
43
|
+
is_complete,
|
|
44
|
+
is_fail,
|
|
45
|
+
is_triage,
|
|
46
|
+
is_won
|
|
47
|
+
),
|
|
48
|
+
assigned_to:user_profiles!assigned_to_id (*),
|
|
49
|
+
estimated_value_minor,
|
|
50
|
+
follow_up_date,
|
|
51
|
+
created_by:user_profiles!created_by (*)
|
|
52
|
+
`;
|
|
53
|
+
const LEAD_DETAIL_SELECT = `
|
|
54
|
+
mms_id,
|
|
55
|
+
created_at,
|
|
56
|
+
name,
|
|
57
|
+
contact_person,
|
|
58
|
+
email,
|
|
59
|
+
phone,
|
|
60
|
+
status:statuses!status_id (
|
|
61
|
+
mms_id,
|
|
62
|
+
label,
|
|
63
|
+
colour,
|
|
64
|
+
is_complete,
|
|
65
|
+
is_fail,
|
|
66
|
+
is_triage,
|
|
67
|
+
is_won
|
|
68
|
+
),
|
|
69
|
+
assigned_to:user_profiles!assigned_to_id ${transforms_1.USER_PROFILE_EMBED},
|
|
70
|
+
source,
|
|
71
|
+
estimated_value_minor,
|
|
72
|
+
follow_up_date,
|
|
73
|
+
lost_reason,
|
|
74
|
+
converted_client:client_profiles!converted_client_id (*),
|
|
75
|
+
converted_at,
|
|
76
|
+
updated_at,
|
|
77
|
+
last_updated_by:user_profiles!last_updated_by ${transforms_1.USER_PROFILE_EMBED},
|
|
78
|
+
created_by:user_profiles!created_by ${transforms_1.USER_PROFILE_EMBED},
|
|
79
|
+
archived,
|
|
80
|
+
archived_at,
|
|
81
|
+
archived_by:user_profiles!archived_by ${transforms_1.USER_PROFILE_EMBED},
|
|
82
|
+
unarchived_at,
|
|
83
|
+
unarchived_by:user_profiles!unarchived_by ${transforms_1.USER_PROFILE_EMBED},
|
|
84
|
+
deleted,
|
|
85
|
+
deleted_at,
|
|
86
|
+
deleted_by:user_profiles!deleted_by ${transforms_1.USER_PROFILE_EMBED},
|
|
87
|
+
restored_at,
|
|
88
|
+
restored_by:user_profiles!restored_by ${transforms_1.USER_PROFILE_EMBED}
|
|
89
|
+
`;
|
|
90
|
+
// What `convert` reads of the lead: what the client and its additional details are made from,
|
|
91
|
+
// the two flags that refuse it, and the organization's currency for the estimated value.
|
|
92
|
+
const LEAD_CONVERT_SELECT = 'name, email, phone, contact_person, source, estimated_value_minor, deleted, converted_client_id, organization:organizations!organization_id ( currency )';
|
|
93
|
+
/** How a source reads in the "Lead source" additional detail the convert writes. */
|
|
94
|
+
const LEAD_SOURCE_LABELS = {
|
|
95
|
+
[enum_1.LeadSource.REFERRAL]: 'Referral',
|
|
96
|
+
[enum_1.LeadSource.WEBSITE]: 'Website',
|
|
97
|
+
[enum_1.LeadSource.SOCIAL_MEDIA]: 'Social media',
|
|
98
|
+
[enum_1.LeadSource.EVENT]: 'Event',
|
|
99
|
+
[enum_1.LeadSource.COLD_OUTREACH]: 'Cold outreach',
|
|
100
|
+
[enum_1.LeadSource.EXISTING_CLIENT]: 'Existing client',
|
|
101
|
+
[enum_1.LeadSource.OTHER]: 'Other',
|
|
102
|
+
};
|
|
103
|
+
/** Storage bucket every supporting file lives in (as in SupportingFilesResource). */
|
|
104
|
+
const SUPPORTING_FILES_BUCKET = 'supporting-files';
|
|
105
|
+
const refuse = (status, message) => new errors_1.SupabaseClientError({ status, error: 'Database Error', message });
|
|
106
|
+
class LeadsResource {
|
|
107
|
+
supabase;
|
|
108
|
+
auth;
|
|
109
|
+
audit;
|
|
110
|
+
additionalDetails;
|
|
111
|
+
table;
|
|
112
|
+
constructor(supabase, auth, audit, additionalDetails) {
|
|
113
|
+
this.supabase = supabase;
|
|
114
|
+
this.auth = auth;
|
|
115
|
+
this.audit = audit;
|
|
116
|
+
this.additionalDetails = additionalDetails;
|
|
117
|
+
this.table = (0, audit_1.auditedTable)(supabase, auth, audit, {
|
|
118
|
+
table: 'leads',
|
|
119
|
+
entityType: enum_6.AuditTrailEntityType.LEADS,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
async list(query) {
|
|
123
|
+
const page = query.page;
|
|
124
|
+
const pageSize = query.page_size;
|
|
125
|
+
const offset = (page - 1) * pageSize;
|
|
126
|
+
const trimmedSearch = query.search?.trim();
|
|
127
|
+
// `order`/`range` return `this`, so the builder stays a filter builder and the conditional
|
|
128
|
+
// filters below can be reassigned onto it without a cast.
|
|
129
|
+
let leadsQuery = this.supabase
|
|
130
|
+
.from('leads')
|
|
131
|
+
.select(LEAD_LIST_SELECT, { count: 'estimated' })
|
|
132
|
+
.eq('organization_id', this.auth.organization_id)
|
|
133
|
+
.eq('archived', query.archive)
|
|
134
|
+
.eq('deleted', false)
|
|
135
|
+
.order('created_at', { ascending: false })
|
|
136
|
+
.range(offset, offset + pageSize - 1);
|
|
137
|
+
if (trimmedSearch) {
|
|
138
|
+
leadsQuery = leadsQuery.or((0, transforms_1.orIlike)(['name', 'contact_person', 'email'], trimmedSearch));
|
|
139
|
+
}
|
|
140
|
+
if (query.assigned_to_id) {
|
|
141
|
+
leadsQuery = leadsQuery.eq('assigned_to_id', query.assigned_to_id);
|
|
142
|
+
}
|
|
143
|
+
if (query.created_after) {
|
|
144
|
+
leadsQuery = leadsQuery.gte('created_at', query.created_after);
|
|
145
|
+
}
|
|
146
|
+
if (query.created_before) {
|
|
147
|
+
leadsQuery = leadsQuery.lte('created_at', query.created_before);
|
|
148
|
+
}
|
|
149
|
+
if (query.status_id_in && query.status_id_in.length > 0) {
|
|
150
|
+
leadsQuery = leadsQuery.in('status_id', query.status_id_in);
|
|
151
|
+
}
|
|
152
|
+
// "No status OR not one of these", as tasks: a lead with no status is not won, so it stays in
|
|
153
|
+
// the open view. The ids are `@IsUUID('7')`-validated by the DTO, so interpolating them is
|
|
154
|
+
// safe.
|
|
155
|
+
if (query.status_id_not_in && query.status_id_not_in.length > 0) {
|
|
156
|
+
leadsQuery = leadsQuery.or(`status_id.is.null,status_id.not.in.(${query.status_id_not_in.join(',')})`);
|
|
157
|
+
}
|
|
158
|
+
const { data, error, count } = await leadsQuery;
|
|
159
|
+
if (error)
|
|
160
|
+
throw (0, errors_1.mapPostgrestError)(error);
|
|
161
|
+
return (0, transforms_1.toMmsList)(data, page, pageSize, count);
|
|
162
|
+
}
|
|
163
|
+
async getById(leadId) {
|
|
164
|
+
const { data, error } = await this.supabase
|
|
165
|
+
.from('leads')
|
|
166
|
+
.select(LEAD_DETAIL_SELECT)
|
|
167
|
+
.eq('organization_id', this.auth.organization_id)
|
|
168
|
+
.eq('mms_id', leadId)
|
|
169
|
+
.single();
|
|
170
|
+
if (error)
|
|
171
|
+
throw (0, errors_1.mapPostgrestError)(error);
|
|
172
|
+
// A soft-deleted lead keeps its audit trail but gives up its details. The client it became
|
|
173
|
+
// stays, so a converted lead can still point at it.
|
|
174
|
+
if (data.deleted) {
|
|
175
|
+
return {
|
|
176
|
+
...data,
|
|
177
|
+
name: '',
|
|
178
|
+
contact_person: null,
|
|
179
|
+
email: '',
|
|
180
|
+
phone: '',
|
|
181
|
+
status: null,
|
|
182
|
+
assigned_to: null,
|
|
183
|
+
source: null,
|
|
184
|
+
estimated_value_minor: null,
|
|
185
|
+
follow_up_date: null,
|
|
186
|
+
lost_reason: null,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
return data;
|
|
190
|
+
}
|
|
191
|
+
async create(input) {
|
|
192
|
+
const lostReason = await this.lostReasonFor(input.status_id ?? null, input.lost_reason);
|
|
193
|
+
const row = await this.table.insert({
|
|
194
|
+
...input,
|
|
195
|
+
// Omitted means the creator owns the lead; an explicit null leaves it unassigned.
|
|
196
|
+
assigned_to_id: input.assigned_to_id === undefined ? this.auth.mms_id : input.assigned_to_id,
|
|
197
|
+
lost_reason: lostReason,
|
|
198
|
+
organization_id: this.auth.organization_id,
|
|
199
|
+
created_by: this.auth.mms_id,
|
|
200
|
+
created_at: new Date().toISOString(),
|
|
201
|
+
});
|
|
202
|
+
return this.getById(row.mms_id);
|
|
203
|
+
}
|
|
204
|
+
async update(leadId, input) {
|
|
205
|
+
// A Won lead is final. An update that leaves the status out keeps the lead's own, and the
|
|
206
|
+
// rules are checked against that one.
|
|
207
|
+
const { data, error } = await this.supabase
|
|
208
|
+
.from('leads')
|
|
209
|
+
.select('status_id, converted_client_id')
|
|
210
|
+
.eq('organization_id', this.auth.organization_id)
|
|
211
|
+
.eq('mms_id', leadId)
|
|
212
|
+
.single();
|
|
213
|
+
if (error)
|
|
214
|
+
throw (0, errors_1.mapPostgrestError)(error);
|
|
215
|
+
const current = data;
|
|
216
|
+
if (current.converted_client_id) {
|
|
217
|
+
throw refuse(400, 'This lead is Won and can no longer be changed');
|
|
218
|
+
}
|
|
219
|
+
const statusId = input.status_id === undefined ? current.status_id : input.status_id;
|
|
220
|
+
const lostReason = await this.lostReasonFor(statusId, input.lost_reason);
|
|
221
|
+
await this.table.update(leadId, {
|
|
222
|
+
...input,
|
|
223
|
+
lost_reason: lostReason,
|
|
224
|
+
last_updated_by: this.auth.mms_id,
|
|
225
|
+
updated_at: new Date().toISOString(),
|
|
226
|
+
});
|
|
227
|
+
return this.getById(leadId);
|
|
228
|
+
}
|
|
229
|
+
async archive(leadId) {
|
|
230
|
+
await (0, won_1.refuseWonLead)(this.supabase, this.auth.organization_id, leadId);
|
|
231
|
+
await this.table.transition(leadId, enum_6.AuditTrailAction.ARCHIVE, (0, transforms_1.archivedPatch)(this.auth.mms_id));
|
|
232
|
+
return this.getById(leadId);
|
|
233
|
+
}
|
|
234
|
+
async unarchive(leadId) {
|
|
235
|
+
await (0, won_1.refuseWonLead)(this.supabase, this.auth.organization_id, leadId);
|
|
236
|
+
await this.table.transition(leadId, enum_6.AuditTrailAction.UNARCHIVE, (0, transforms_1.unarchivedPatch)(this.auth.mms_id));
|
|
237
|
+
return this.getById(leadId);
|
|
238
|
+
}
|
|
239
|
+
async restore(leadId) {
|
|
240
|
+
await this.table.transition(leadId, enum_6.AuditTrailAction.RESTORE, (0, transforms_1.restoredPatch)(this.auth.mms_id));
|
|
241
|
+
return this.getById(leadId);
|
|
242
|
+
}
|
|
243
|
+
/** Soft delete - the row stays, flagged `deleted`, and `restore()` brings it back. A Won lead cannot be deleted. */
|
|
244
|
+
async remove(leadId) {
|
|
245
|
+
await (0, won_1.refuseWonLead)(this.supabase, this.auth.organization_id, leadId);
|
|
246
|
+
await this.table.transition(leadId, enum_6.AuditTrailAction.DELETE, (0, transforms_1.deletedPatch)(this.auth.mms_id));
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Turns the lead into a client: the client is created from the lead's name, email and phone
|
|
250
|
+
* with the status the caller picked, the lead's contact person, source and estimated value
|
|
251
|
+
* become additional details on it, the lead's notes, additional details and files move to it,
|
|
252
|
+
* and the lead moves to Won with `converted_client_id` and `converted_at` set. It is not
|
|
253
|
+
* deleted - it stays as a record of the deal, and from then on it cannot be changed
|
|
254
|
+
* (`refuseWonLead`). The list hides Won leads by default.
|
|
255
|
+
*
|
|
256
|
+
* There is no transaction across these writes (PostgREST and Storage cannot share one). A
|
|
257
|
+
* failure after the client is created leaves that client and a lead that is still not
|
|
258
|
+
* converted, holding whatever had not moved yet. Running the convert again is safe: step (a)
|
|
259
|
+
* only refuses a lead that finished converting, and steps (d) and (e) only touch rows still on
|
|
260
|
+
* the lead, so nothing is moved twice - but it makes a second client, and the first one (with
|
|
261
|
+
* whatever had already moved to it) is left for someone to tidy up or delete.
|
|
262
|
+
*
|
|
263
|
+
* Converting needs only `update_leads`: `clients` is the service-role clients resource
|
|
264
|
+
* acting for the caller (`forOrganization(org, user).clientsOnBehalfOfUser()`), so the
|
|
265
|
+
* client is created, and read back, without the caller holding any clients permission.
|
|
266
|
+
*/
|
|
267
|
+
async convert(leadId, input, clients) {
|
|
268
|
+
// (a) The lead, in this organization, not deleted and not converted already.
|
|
269
|
+
const { data, error } = await this.supabase
|
|
270
|
+
.from('leads')
|
|
271
|
+
.select(LEAD_CONVERT_SELECT)
|
|
272
|
+
.eq('organization_id', this.auth.organization_id)
|
|
273
|
+
.eq('mms_id', leadId)
|
|
274
|
+
.maybeSingle();
|
|
275
|
+
if (error)
|
|
276
|
+
throw (0, errors_1.mapPostgrestError)(error);
|
|
277
|
+
const lead = data;
|
|
278
|
+
if (lead?.converted_client_id) {
|
|
279
|
+
throw refuse(400, 'This lead has already been converted');
|
|
280
|
+
}
|
|
281
|
+
if (!lead || lead.deleted) {
|
|
282
|
+
throw refuse(404, 'Lead not found');
|
|
283
|
+
}
|
|
284
|
+
// (b) The client, service-role for the caller, audited as their INSERT.
|
|
285
|
+
const client = await clients.create({
|
|
286
|
+
name: lead.name,
|
|
287
|
+
email: lead.email,
|
|
288
|
+
phone: lead.phone,
|
|
289
|
+
status_id: input.client_status_id,
|
|
290
|
+
contract_client: false,
|
|
291
|
+
contracted_hours: null,
|
|
292
|
+
});
|
|
293
|
+
const clientId = client.mms_id;
|
|
294
|
+
// (c) The lead fields a client has no column for, as additional details - only those set.
|
|
295
|
+
const details = [];
|
|
296
|
+
if (lead.contact_person) {
|
|
297
|
+
details.push({ key: 'Contact person', value: lead.contact_person });
|
|
298
|
+
}
|
|
299
|
+
if (lead.source) {
|
|
300
|
+
details.push({ key: 'Lead source', value: LEAD_SOURCE_LABELS[lead.source] ?? lead.source });
|
|
301
|
+
}
|
|
302
|
+
if (lead.estimated_value_minor !== null) {
|
|
303
|
+
const amount = (lead.estimated_value_minor / 100).toFixed(2);
|
|
304
|
+
details.push({
|
|
305
|
+
key: 'Estimated value',
|
|
306
|
+
value: lead.organization?.currency ? `${amount} ${lead.organization.currency}` : amount,
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
for (const detail of details) {
|
|
310
|
+
await this.additionalDetails.create(enum_2.AdditionalDetailsEntityTypes.CLIENT, clientId, {
|
|
311
|
+
key: detail.key,
|
|
312
|
+
value: detail.value,
|
|
313
|
+
type: enum_2.AdditionalDetailsTypes.Text,
|
|
314
|
+
copyable: false,
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
// (d) The lead's notes and additional details now hang off the client.
|
|
318
|
+
const moves = [
|
|
319
|
+
['notes', enum_3.NotesEntityTypes.LEAD, enum_3.NotesEntityTypes.CLIENT],
|
|
320
|
+
['additional_details', enum_2.AdditionalDetailsEntityTypes.LEAD, enum_2.AdditionalDetailsEntityTypes.CLIENT],
|
|
321
|
+
];
|
|
322
|
+
for (const [table, fromType, toType] of moves) {
|
|
323
|
+
const { error: moveError } = await this.supabase
|
|
324
|
+
.from(table)
|
|
325
|
+
.update({ entity_type: toType, entity_id: clientId })
|
|
326
|
+
.eq('organization_id', this.auth.organization_id)
|
|
327
|
+
.eq('entity_type', fromType)
|
|
328
|
+
.eq('entity_id', leadId);
|
|
329
|
+
if (moveError)
|
|
330
|
+
throw (0, errors_1.mapPostgrestError)(moveError);
|
|
331
|
+
}
|
|
332
|
+
// (e) Each supporting file: the object moves to the client's folder, then its row follows.
|
|
333
|
+
// An object already missing from storage is skipped - there is nothing to move - and its row
|
|
334
|
+
// stays on the lead.
|
|
335
|
+
const { data: files, error: filesError } = await this.supabase
|
|
336
|
+
.from('supporting_files')
|
|
337
|
+
.select('mms_id, name, extension')
|
|
338
|
+
.eq('organization_id', this.auth.organization_id)
|
|
339
|
+
.eq('entity_type', enum_5.SupportingFilesEntityTypes.LEAD)
|
|
340
|
+
.eq('entity_id', leadId);
|
|
341
|
+
if (filesError)
|
|
342
|
+
throw (0, errors_1.mapPostgrestError)(filesError);
|
|
343
|
+
for (const file of files) {
|
|
344
|
+
const { error: storageError } = await this.supabase.storage
|
|
345
|
+
.from(SUPPORTING_FILES_BUCKET)
|
|
346
|
+
.move((0, paths_1.storagePath)(this.auth.organization_id, enum_5.SupportingFilesEntityTypes.LEAD, leadId, file.name, file.extension), (0, paths_1.storagePath)(this.auth.organization_id, enum_5.SupportingFilesEntityTypes.CLIENT, clientId, file.name, file.extension));
|
|
347
|
+
if (storageError) {
|
|
348
|
+
if (storageError.status === 404 || storageError.statusCode === '404')
|
|
349
|
+
continue;
|
|
350
|
+
throw (0, errors_1.mapStorageError)(storageError);
|
|
351
|
+
}
|
|
352
|
+
const { error: rowError } = await this.supabase
|
|
353
|
+
.from('supporting_files')
|
|
354
|
+
.update({ entity_type: enum_5.SupportingFilesEntityTypes.CLIENT, entity_id: clientId })
|
|
355
|
+
.eq('organization_id', this.auth.organization_id)
|
|
356
|
+
.eq('mms_id', file.mms_id);
|
|
357
|
+
if (rowError)
|
|
358
|
+
throw (0, errors_1.mapPostgrestError)(rowError);
|
|
359
|
+
}
|
|
360
|
+
// (f) The lead moves to its organization's Won status (`is_won`, one per organization, when
|
|
361
|
+
// it has one) and is linked to the client it became, audited as an UPDATE. It is not deleted.
|
|
362
|
+
const { data: won, error: wonError } = await this.supabase
|
|
363
|
+
.from('statuses')
|
|
364
|
+
.select('mms_id')
|
|
365
|
+
.eq('organization_id', this.auth.organization_id)
|
|
366
|
+
.eq('is_won', true)
|
|
367
|
+
.maybeSingle();
|
|
368
|
+
if (wonError)
|
|
369
|
+
throw (0, errors_1.mapPostgrestError)(wonError);
|
|
370
|
+
await this.table.update(leadId, {
|
|
371
|
+
...(won && { status_id: won.mms_id, lost_reason: null }),
|
|
372
|
+
converted_client_id: clientId,
|
|
373
|
+
converted_at: new Date().toISOString(),
|
|
374
|
+
last_updated_by: this.auth.mms_id,
|
|
375
|
+
updated_at: new Date().toISOString(),
|
|
376
|
+
});
|
|
377
|
+
// (g) The client as the clients resource reads it.
|
|
378
|
+
return clients.getById(clientId);
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* The two status rules, checked before anything is written, and the lost reason the write
|
|
382
|
+
* stores. A lead reaches its Won status (`is_won`) only by converting, so choosing it is
|
|
383
|
+
* refused (a converted lead is not updated at all). A fail status needs a lost reason; any
|
|
384
|
+
* other status, or none, clears it.
|
|
385
|
+
*/
|
|
386
|
+
async lostReasonFor(statusId, lostReason) {
|
|
387
|
+
if (!statusId)
|
|
388
|
+
return null;
|
|
389
|
+
const { data, error } = await this.supabase
|
|
390
|
+
.from('statuses')
|
|
391
|
+
.select('is_fail, is_won')
|
|
392
|
+
.eq('organization_id', this.auth.organization_id)
|
|
393
|
+
.eq('entity_type', enum_4.StatusEntityTypes.LEAD)
|
|
394
|
+
.eq('mms_id', statusId)
|
|
395
|
+
.maybeSingle();
|
|
396
|
+
if (error)
|
|
397
|
+
throw (0, errors_1.mapPostgrestError)(error);
|
|
398
|
+
// A status that is not one of this organization's lead statuses is not a fail status; the
|
|
399
|
+
// leads status trigger refuses it on the write.
|
|
400
|
+
const status = data;
|
|
401
|
+
if (status?.is_won) {
|
|
402
|
+
throw refuse(400, 'Convert the lead to a client to mark it Won');
|
|
403
|
+
}
|
|
404
|
+
if (!status?.is_fail)
|
|
405
|
+
return null;
|
|
406
|
+
const reason = lostReason?.trim();
|
|
407
|
+
if (!reason) {
|
|
408
|
+
throw refuse(400, 'Give a reason when marking a lead as lost');
|
|
409
|
+
}
|
|
410
|
+
return reason;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
exports.LeadsResource = LeadsResource;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { TypedSupabaseClient } from '../client';
|
|
2
|
+
/**
|
|
3
|
+
* A converted lead is Won and final: its fields, its lifecycle and what hangs off it (notes,
|
|
4
|
+
* additional details, supporting files) no longer change. Throws 400 when the lead is converted;
|
|
5
|
+
* a missing lead is left to the write that follows.
|
|
6
|
+
*/
|
|
7
|
+
export declare function refuseWonLead(supabase: TypedSupabaseClient, organizationId: string, leadId: string): Promise<void>;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.refuseWonLead = refuseWonLead;
|
|
4
|
+
const errors_1 = require("../errors");
|
|
5
|
+
/**
|
|
6
|
+
* A converted lead is Won and final: its fields, its lifecycle and what hangs off it (notes,
|
|
7
|
+
* additional details, supporting files) no longer change. Throws 400 when the lead is converted;
|
|
8
|
+
* a missing lead is left to the write that follows.
|
|
9
|
+
*/
|
|
10
|
+
async function refuseWonLead(supabase, organizationId, leadId) {
|
|
11
|
+
const { data, error } = await supabase
|
|
12
|
+
.from('leads')
|
|
13
|
+
.select('converted_client_id')
|
|
14
|
+
.eq('organization_id', organizationId)
|
|
15
|
+
.eq('mms_id', leadId)
|
|
16
|
+
.maybeSingle();
|
|
17
|
+
if (error)
|
|
18
|
+
throw (0, errors_1.mapPostgrestError)(error);
|
|
19
|
+
if (data?.converted_client_id) {
|
|
20
|
+
throw new errors_1.SupabaseClientError({
|
|
21
|
+
status: 400,
|
|
22
|
+
error: 'Database Error',
|
|
23
|
+
message: 'This lead is Won and can no longer be changed',
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
}
|
package/dist/notes/index.js
CHANGED
|
@@ -15,9 +15,11 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
|
15
15
|
};
|
|
16
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
17
|
exports.NotesResource = void 0;
|
|
18
|
-
const enum_1 = require("@managemint-solutions/entities/
|
|
18
|
+
const enum_1 = require("@managemint-solutions/entities/notes/enum");
|
|
19
|
+
const enum_2 = require("@managemint-solutions/entities/audit-trail/enum");
|
|
19
20
|
const audit_1 = require("../audit");
|
|
20
21
|
const errors_1 = require("../errors");
|
|
22
|
+
const won_1 = require("../leads/won");
|
|
21
23
|
__exportStar(require("./dto"), exports);
|
|
22
24
|
// The public contract is the shared NoteEntity from @managemint-solutions/entities; the casts at
|
|
23
25
|
// the returns declare the shape the select string below produces (the columns are defined in the
|
|
@@ -32,7 +34,7 @@ class NotesResource {
|
|
|
32
34
|
this.auth = auth;
|
|
33
35
|
this.table = (0, audit_1.auditedTable)(supabase, auth, audit, {
|
|
34
36
|
table: 'notes',
|
|
35
|
-
entityType:
|
|
37
|
+
entityType: enum_2.AuditTrailEntityType.NOTES,
|
|
36
38
|
});
|
|
37
39
|
}
|
|
38
40
|
async getById(noteId) {
|
|
@@ -59,6 +61,9 @@ class NotesResource {
|
|
|
59
61
|
return data;
|
|
60
62
|
}
|
|
61
63
|
async create(entity, entityId, input) {
|
|
64
|
+
if (entity === enum_1.NotesEntityTypes.LEAD) {
|
|
65
|
+
await (0, won_1.refuseWonLead)(this.supabase, this.auth.organization_id, entityId);
|
|
66
|
+
}
|
|
62
67
|
const row = await this.table.insert({
|
|
63
68
|
content: input.content,
|
|
64
69
|
entity_type: entity,
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import type { UpdatePermissionsDto as UpdatePermissionsDtoType } from '@managemint-solutions/entities/users/permissions/dto';
|
|
2
2
|
export declare class UpdatePermissionsDto implements UpdatePermissionsDtoType {
|
|
3
|
+
readonly create_leads: boolean;
|
|
4
|
+
readonly read_leads: boolean;
|
|
5
|
+
readonly update_leads: boolean;
|
|
6
|
+
readonly delete_leads: boolean;
|
|
3
7
|
readonly create_clients: boolean;
|
|
4
8
|
readonly read_clients: boolean;
|
|
5
9
|
readonly update_clients: boolean;
|
package/dist/permissions/dto.js
CHANGED
|
@@ -14,6 +14,10 @@ const class_validator_1 = require("class-validator");
|
|
|
14
14
|
class UpdatePermissionsDto {
|
|
15
15
|
// @IsBoolean({ message: 'View Dashboard must be a boolean' })
|
|
16
16
|
// readonly view_dashboard!: boolean;
|
|
17
|
+
create_leads;
|
|
18
|
+
read_leads;
|
|
19
|
+
update_leads;
|
|
20
|
+
delete_leads;
|
|
17
21
|
create_clients;
|
|
18
22
|
read_clients;
|
|
19
23
|
update_clients;
|
|
@@ -108,6 +112,22 @@ class UpdatePermissionsDto {
|
|
|
108
112
|
read_logs;
|
|
109
113
|
}
|
|
110
114
|
exports.UpdatePermissionsDto = UpdatePermissionsDto;
|
|
115
|
+
__decorate([
|
|
116
|
+
(0, class_validator_1.IsBoolean)({ message: 'Create Leads must be a boolean' }),
|
|
117
|
+
__metadata("design:type", Boolean)
|
|
118
|
+
], UpdatePermissionsDto.prototype, "create_leads", void 0);
|
|
119
|
+
__decorate([
|
|
120
|
+
(0, class_validator_1.IsBoolean)({ message: 'Read Leads must be a boolean' }),
|
|
121
|
+
__metadata("design:type", Boolean)
|
|
122
|
+
], UpdatePermissionsDto.prototype, "read_leads", void 0);
|
|
123
|
+
__decorate([
|
|
124
|
+
(0, class_validator_1.IsBoolean)({ message: 'Update Leads must be a boolean' }),
|
|
125
|
+
__metadata("design:type", Boolean)
|
|
126
|
+
], UpdatePermissionsDto.prototype, "update_leads", void 0);
|
|
127
|
+
__decorate([
|
|
128
|
+
(0, class_validator_1.IsBoolean)({ message: 'Delete Leads must be a boolean' }),
|
|
129
|
+
__metadata("design:type", Boolean)
|
|
130
|
+
], UpdatePermissionsDto.prototype, "delete_leads", void 0);
|
|
111
131
|
__decorate([
|
|
112
132
|
(0, class_validator_1.IsBoolean)({ message: 'Create Clients must be a boolean' }),
|
|
113
133
|
__metadata("design:type", Boolean)
|
package/dist/projects/index.js
CHANGED
|
@@ -35,7 +35,8 @@ const PROJECT_LIST_SELECT = `
|
|
|
35
35
|
label,
|
|
36
36
|
is_complete,
|
|
37
37
|
is_fail,
|
|
38
|
-
is_triage
|
|
38
|
+
is_triage,
|
|
39
|
+
is_won
|
|
39
40
|
),
|
|
40
41
|
created_by:user_profiles!created_by (*),
|
|
41
42
|
manager:user_profiles!manager_id (*),
|
|
@@ -52,7 +53,8 @@ const PROJECT_DETAIL_SELECT = `
|
|
|
52
53
|
colour,
|
|
53
54
|
is_complete,
|
|
54
55
|
is_fail,
|
|
55
|
-
is_triage
|
|
56
|
+
is_triage,
|
|
57
|
+
is_won
|
|
56
58
|
),
|
|
57
59
|
has_budget,
|
|
58
60
|
start_date,
|
|
@@ -2,6 +2,7 @@ import { AuditResource, type AuditActor, type AuditedTable, type AuditedTableOpt
|
|
|
2
2
|
import type { TypedSupabaseClient } from '../client';
|
|
3
3
|
import { BillingResource } from '../billing';
|
|
4
4
|
import { InvoicesResource } from '../billing/invoices';
|
|
5
|
+
import { ClientsResource } from '../clients';
|
|
5
6
|
import { ConfigsResource } from '../configs';
|
|
6
7
|
import { FeatureRequestsResource } from '../feature-requests';
|
|
7
8
|
import { LeaveEntitlementsResource, LeaveRequestsResource, LeaveTypesResource } from '../leave';
|
|
@@ -45,6 +46,12 @@ export declare class SupabaseOrganizationScope {
|
|
|
45
46
|
* subscriptions and both webhook handler services.
|
|
46
47
|
*/
|
|
47
48
|
table(options: AuditedTableOptions): AuditedTable;
|
|
49
|
+
/**
|
|
50
|
+
* Clients written service-role on a user's behalf: converting a lead needs only
|
|
51
|
+
* `update_leads`, so the client it becomes is created here rather than through the caller's
|
|
52
|
+
* own client, and is still attributed to that user. The scope must carry the user.
|
|
53
|
+
*/
|
|
54
|
+
clientsOnBehalfOfUser(): ClientsResource;
|
|
48
55
|
}
|
|
49
56
|
/**
|
|
50
57
|
* The client for work that has no caller and no RLS context to run under: inbound webhooks
|
|
@@ -6,6 +6,7 @@ const audit_1 = require("../audit");
|
|
|
6
6
|
const enum_1 = require("@managemint-solutions/entities/audit-trail/enum");
|
|
7
7
|
const billing_1 = require("../billing");
|
|
8
8
|
const invoices_1 = require("../billing/invoices");
|
|
9
|
+
const clients_1 = require("../clients");
|
|
9
10
|
const configs_1 = require("../configs");
|
|
10
11
|
const feature_requests_1 = require("../feature-requests");
|
|
11
12
|
const leave_1 = require("../leave");
|
|
@@ -60,6 +61,17 @@ class SupabaseOrganizationScope {
|
|
|
60
61
|
table(options) {
|
|
61
62
|
return (0, audit_1.auditedTable)(this.supabase, this.actor, this.audit, options);
|
|
62
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* Clients written service-role on a user's behalf: converting a lead needs only
|
|
66
|
+
* `update_leads`, so the client it becomes is created here rather than through the caller's
|
|
67
|
+
* own client, and is still attributed to that user. The scope must carry the user.
|
|
68
|
+
*/
|
|
69
|
+
clientsOnBehalfOfUser() {
|
|
70
|
+
const { mms_id, organization_id } = this.actor;
|
|
71
|
+
if (!mms_id)
|
|
72
|
+
throw new Error('clientsOnBehalfOfUser needs a user actor');
|
|
73
|
+
return new clients_1.ClientsResource(this.supabase, { mms_id, organization_id }, this.audit);
|
|
74
|
+
}
|
|
63
75
|
}
|
|
64
76
|
exports.SupabaseOrganizationScope = SupabaseOrganizationScope;
|
|
65
77
|
/**
|
package/dist/statuses/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { StatusEntity } from '@managemint-solutions/entities/status';
|
|
2
2
|
import type { AddStatusDto, UpdateStatusDto } from '@managemint-solutions/entities/status/dto';
|
|
3
|
-
import { StatusEntityTypes } from '@managemint-solutions/entities/status/enum';
|
|
3
|
+
import type { StatusEntityTypes } from '@managemint-solutions/entities/status/enum';
|
|
4
4
|
import type { AuthContext } from '../auth';
|
|
5
5
|
import { type AuditResource } from '../audit';
|
|
6
6
|
import type { TypedSupabaseClient } from '../client';
|
|
@@ -13,5 +13,12 @@ export declare class StatusesResource {
|
|
|
13
13
|
list(entity: StatusEntityTypes): Promise<StatusEntity[]>;
|
|
14
14
|
create(input: AddStatusDto): Promise<StatusEntity>;
|
|
15
15
|
update(statusId: string, input: UpdateStatusDto): Promise<StatusEntity>;
|
|
16
|
+
/** The Won status cannot be deleted. Every other status can. */
|
|
16
17
|
remove(statusId: string): Promise<void>;
|
|
18
|
+
/**
|
|
19
|
+
* Whether the status is this organization's Won lead status (`is_won`), where converting a
|
|
20
|
+
* lead moves it. Won is fixed: it cannot be edited or deleted. Every other status - a lead
|
|
21
|
+
* status the organization marks complete included - is ordinary.
|
|
22
|
+
*/
|
|
23
|
+
private isWon;
|
|
17
24
|
}
|
package/dist/statuses/index.js
CHANGED
|
@@ -19,10 +19,11 @@ const enum_1 = require("@managemint-solutions/entities/audit-trail/enum");
|
|
|
19
19
|
const audit_1 = require("../audit");
|
|
20
20
|
const errors_1 = require("../errors");
|
|
21
21
|
__exportStar(require("./dto"), exports);
|
|
22
|
+
const refuseWon = (message) => new errors_1.SupabaseClientError({ status: 400, error: 'Database Error', message });
|
|
22
23
|
// The public contract is the shared StatusEntity from @managemint-solutions/entities; the
|
|
23
24
|
// `as StatusEntity` casts declare the shape the select string below produces (the columns are
|
|
24
25
|
// defined in the api's supabase/migrations).
|
|
25
|
-
const STATUS_SELECT = 'mms_id, label, colour, is_complete, is_fail, is_triage';
|
|
26
|
+
const STATUS_SELECT = 'mms_id, label, colour, is_complete, is_fail, is_triage, is_won';
|
|
26
27
|
// The audited write returns the whole row; the wire shape is the STATUS_SELECT columns.
|
|
27
28
|
const toStatus = (row) => ({
|
|
28
29
|
mms_id: row.mms_id,
|
|
@@ -31,6 +32,7 @@ const toStatus = (row) => ({
|
|
|
31
32
|
is_complete: row.is_complete,
|
|
32
33
|
is_fail: row.is_fail,
|
|
33
34
|
is_triage: row.is_triage,
|
|
35
|
+
is_won: row.is_won,
|
|
34
36
|
});
|
|
35
37
|
class StatusesResource {
|
|
36
38
|
supabase;
|
|
@@ -66,6 +68,9 @@ class StatusesResource {
|
|
|
66
68
|
return toStatus(row);
|
|
67
69
|
}
|
|
68
70
|
async update(statusId, input) {
|
|
71
|
+
if (await this.isWon(statusId)) {
|
|
72
|
+
throw refuseWon('The Won status is fixed and cannot be changed');
|
|
73
|
+
}
|
|
69
74
|
const row = await this.table.update(statusId, {
|
|
70
75
|
label: input.label,
|
|
71
76
|
colour: input.colour,
|
|
@@ -75,8 +80,28 @@ class StatusesResource {
|
|
|
75
80
|
});
|
|
76
81
|
return toStatus(row);
|
|
77
82
|
}
|
|
83
|
+
/** The Won status cannot be deleted. Every other status can. */
|
|
78
84
|
async remove(statusId) {
|
|
85
|
+
if (await this.isWon(statusId)) {
|
|
86
|
+
throw refuseWon('The Won status is fixed and cannot be deleted');
|
|
87
|
+
}
|
|
79
88
|
await this.table.remove(statusId);
|
|
80
89
|
}
|
|
90
|
+
/**
|
|
91
|
+
* Whether the status is this organization's Won lead status (`is_won`), where converting a
|
|
92
|
+
* lead moves it. Won is fixed: it cannot be edited or deleted. Every other status - a lead
|
|
93
|
+
* status the organization marks complete included - is ordinary.
|
|
94
|
+
*/
|
|
95
|
+
async isWon(statusId) {
|
|
96
|
+
const { data, error } = await this.supabase
|
|
97
|
+
.from('statuses')
|
|
98
|
+
.select('is_won')
|
|
99
|
+
.eq('organization_id', this.auth.organization_id)
|
|
100
|
+
.eq('mms_id', statusId)
|
|
101
|
+
.maybeSingle();
|
|
102
|
+
if (error)
|
|
103
|
+
throw (0, errors_1.mapPostgrestError)(error);
|
|
104
|
+
return !!data?.is_won;
|
|
105
|
+
}
|
|
81
106
|
}
|
|
82
107
|
exports.StatusesResource = StatusesResource;
|