@hed-hog/contact 0.0.294 → 0.0.296

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 (57) hide show
  1. package/dist/person/dto/account.dto.d.ts +28 -0
  2. package/dist/person/dto/account.dto.d.ts.map +1 -0
  3. package/dist/person/dto/account.dto.js +123 -0
  4. package/dist/person/dto/account.dto.js.map +1 -0
  5. package/dist/person/dto/activity.dto.d.ts +15 -0
  6. package/dist/person/dto/activity.dto.d.ts.map +1 -0
  7. package/dist/person/dto/activity.dto.js +65 -0
  8. package/dist/person/dto/activity.dto.js.map +1 -0
  9. package/dist/person/dto/dashboard-query.dto.d.ts +9 -0
  10. package/dist/person/dto/dashboard-query.dto.d.ts.map +1 -0
  11. package/dist/person/dto/dashboard-query.dto.js +40 -0
  12. package/dist/person/dto/dashboard-query.dto.js.map +1 -0
  13. package/dist/person/dto/followup-query.dto.d.ts +10 -0
  14. package/dist/person/dto/followup-query.dto.d.ts.map +1 -0
  15. package/dist/person/dto/followup-query.dto.js +45 -0
  16. package/dist/person/dto/followup-query.dto.js.map +1 -0
  17. package/dist/person/dto/reports-query.dto.d.ts +8 -0
  18. package/dist/person/dto/reports-query.dto.d.ts.map +1 -0
  19. package/dist/person/dto/reports-query.dto.js +33 -0
  20. package/dist/person/dto/reports-query.dto.js.map +1 -0
  21. package/dist/person/person.controller.d.ts +266 -5
  22. package/dist/person/person.controller.d.ts.map +1 -1
  23. package/dist/person/person.controller.js +164 -6
  24. package/dist/person/person.controller.js.map +1 -1
  25. package/dist/person/person.service.d.ts +295 -5
  26. package/dist/person/person.service.d.ts.map +1 -1
  27. package/dist/person/person.service.js +1752 -27
  28. package/dist/person/person.service.js.map +1 -1
  29. package/dist/person-relation-type/person-relation-type.controller.d.ts +2 -2
  30. package/dist/person-relation-type/person-relation-type.service.d.ts +2 -2
  31. package/hedhog/data/route.yaml +68 -19
  32. package/hedhog/frontend/app/_lib/crm-sections.tsx.ejs +9 -9
  33. package/hedhog/frontend/app/accounts/_components/account-form-sheet.tsx.ejs +573 -477
  34. package/hedhog/frontend/app/accounts/_components/account-types.ts.ejs +9 -6
  35. package/hedhog/frontend/app/accounts/page.tsx.ejs +970 -892
  36. package/hedhog/frontend/app/activities/_components/activity-detail-sheet.tsx.ejs +240 -0
  37. package/hedhog/frontend/app/activities/_components/activity-types.ts.ejs +66 -0
  38. package/hedhog/frontend/app/activities/page.tsx.ejs +460 -812
  39. package/hedhog/frontend/app/dashboard/_components/dashboard-types.ts.ejs +70 -0
  40. package/hedhog/frontend/app/dashboard/page.tsx.ejs +639 -491
  41. package/hedhog/frontend/app/follow-ups/page.tsx.ejs +785 -696
  42. package/hedhog/frontend/app/person/_components/person-interaction-dialog.tsx.ejs +10 -12
  43. package/hedhog/frontend/app/reports/_components/report-types.ts.ejs +84 -0
  44. package/hedhog/frontend/app/reports/page.tsx.ejs +1196 -15
  45. package/hedhog/frontend/messages/en.json +242 -38
  46. package/hedhog/frontend/messages/pt.json +242 -38
  47. package/hedhog/table/crm_activity.yaml +68 -0
  48. package/hedhog/table/crm_stage_history.yaml +34 -0
  49. package/hedhog/table/person_company.yaml +27 -5
  50. package/package.json +9 -9
  51. package/src/person/dto/account.dto.ts +100 -0
  52. package/src/person/dto/activity.dto.ts +54 -0
  53. package/src/person/dto/dashboard-query.dto.ts +25 -0
  54. package/src/person/dto/followup-query.dto.ts +25 -0
  55. package/src/person/dto/reports-query.dto.ts +25 -0
  56. package/src/person/person.controller.ts +176 -43
  57. package/src/person/person.service.ts +4825 -2226
@@ -18,6 +18,7 @@ const api_pagination_1 = require("@hed-hog/api-pagination");
18
18
  const api_prisma_1 = require("@hed-hog/api-prisma");
19
19
  const core_1 = require("@hed-hog/core");
20
20
  const common_1 = require("@nestjs/common");
21
+ const account_dto_1 = require("./dto/account.dto");
21
22
  const create_interaction_dto_1 = require("./dto/create-interaction.dto");
22
23
  const CONTACT_ALLOW_COMPANY_REGISTRATION_SETTING = 'contact-allow-company-registration';
23
24
  const CONTACT_OWNER_ROLE_SLUG = 'owner-contact';
@@ -38,6 +39,31 @@ const DEAL_VALUE_METADATA_KEY = 'deal_value';
38
39
  const TAGS_METADATA_KEY = 'tags';
39
40
  const LAST_INTERACTION_AT_METADATA_KEY = 'last_interaction_at';
40
41
  const INTERACTIONS_METADATA_KEY = 'interactions';
42
+ const CRM_DASHBOARD_STAGE_ORDER = [
43
+ 'new',
44
+ 'contacted',
45
+ 'qualified',
46
+ 'proposal',
47
+ 'negotiation',
48
+ 'customer',
49
+ 'lost',
50
+ ];
51
+ const CRM_DASHBOARD_SOURCE_ORDER = [
52
+ 'website',
53
+ 'referral',
54
+ 'social',
55
+ 'inbound',
56
+ 'outbound',
57
+ 'other',
58
+ ];
59
+ const CRM_REPORT_ACTIVITY_ORDER = [
60
+ 'call',
61
+ 'email',
62
+ 'meeting',
63
+ 'whatsapp',
64
+ 'task',
65
+ 'note',
66
+ ];
41
67
  let PersonService = class PersonService {
42
68
  constructor(prismaService, paginationService, fileService, settingService) {
43
69
  this.prismaService = prismaService;
@@ -80,6 +106,334 @@ let PersonService = class PersonService {
80
106
  inactive,
81
107
  };
82
108
  }
109
+ async getDashboard(query, locale) {
110
+ const allowCompanyRegistration = await this.isCompanyRegistrationAllowed();
111
+ const ownerUserId = this.coerceNumber(query.owner_user_id);
112
+ const ranges = this.resolveDashboardRanges(query, locale);
113
+ const where = {};
114
+ if (!allowCompanyRegistration) {
115
+ where.type = 'individual';
116
+ }
117
+ if (ownerUserId > 0) {
118
+ where.AND = [
119
+ {
120
+ person_metadata: {
121
+ some: {
122
+ key: OWNER_USER_METADATA_KEY,
123
+ value: ownerUserId,
124
+ },
125
+ },
126
+ },
127
+ ];
128
+ }
129
+ const people = await this.prismaService.person.findMany({
130
+ where,
131
+ select: {
132
+ id: true,
133
+ name: true,
134
+ type: true,
135
+ status: true,
136
+ avatar_id: true,
137
+ created_at: true,
138
+ updated_at: true,
139
+ person_metadata: true,
140
+ },
141
+ orderBy: {
142
+ created_at: 'desc',
143
+ },
144
+ });
145
+ const enriched = await this.enrichPeople(people, allowCompanyRegistration);
146
+ const createdScoped = enriched.filter((person) => this.isDateWithinRange(person.created_at, ranges.created));
147
+ const nextActionScoped = enriched.filter((person) => !!person.next_action_at &&
148
+ this.isDateWithinRange(person.next_action_at, ranges.operational));
149
+ const kpis = {
150
+ total_leads: createdScoped.length,
151
+ qualified: createdScoped.filter((person) => {
152
+ var _a;
153
+ return ['qualified', 'proposal', 'negotiation', 'customer'].includes((_a = person.lifecycle_stage) !== null && _a !== void 0 ? _a : 'new');
154
+ }).length,
155
+ proposal: createdScoped.filter((person) => person.lifecycle_stage === 'proposal').length,
156
+ customers: createdScoped.filter((person) => person.lifecycle_stage === 'customer').length,
157
+ lost: createdScoped.filter((person) => person.lifecycle_stage === 'lost')
158
+ .length,
159
+ unassigned: createdScoped.filter((person) => !person.owner_user_id).length,
160
+ overdue: nextActionScoped.filter((person) => {
161
+ const nextActionAt = this.parseDateOrNull(person.next_action_at);
162
+ return !!nextActionAt && nextActionAt.getTime() < Date.now();
163
+ }).length,
164
+ next_actions: nextActionScoped.length,
165
+ };
166
+ const charts = {
167
+ stage: CRM_DASHBOARD_STAGE_ORDER.map((key) => ({
168
+ key,
169
+ total: createdScoped.filter((person) => { var _a; return ((_a = person.lifecycle_stage) !== null && _a !== void 0 ? _a : 'new') === key; }).length,
170
+ })),
171
+ source: CRM_DASHBOARD_SOURCE_ORDER.map((key) => ({
172
+ key,
173
+ total: createdScoped.filter((person) => { var _a; return ((_a = person.source) !== null && _a !== void 0 ? _a : 'other') === key; }).length,
174
+ })),
175
+ owner_performance: this.buildDashboardOwnerPerformance(createdScoped),
176
+ };
177
+ const lists = {
178
+ next_actions: [...nextActionScoped]
179
+ .sort((left, right) => {
180
+ var _a, _b;
181
+ return new Date((_a = left.next_action_at) !== null && _a !== void 0 ? _a : 0).getTime() -
182
+ new Date((_b = right.next_action_at) !== null && _b !== void 0 ? _b : 0).getTime();
183
+ })
184
+ .slice(0, 5)
185
+ .map((person) => this.mapDashboardListItem(person, {
186
+ includeCreatedAt: false,
187
+ includeNextActionAt: true,
188
+ })),
189
+ unattended: [...createdScoped]
190
+ .filter((person) => { var _a; return !person.owner_user_id || ((_a = person.lifecycle_stage) !== null && _a !== void 0 ? _a : 'new') === 'new'; })
191
+ .sort((left, right) => {
192
+ var _a, _b;
193
+ return new Date((_a = right.created_at) !== null && _a !== void 0 ? _a : 0).getTime() -
194
+ new Date((_b = left.created_at) !== null && _b !== void 0 ? _b : 0).getTime();
195
+ })
196
+ .slice(0, 5)
197
+ .map((person) => this.mapDashboardListItem(person, {
198
+ includeCreatedAt: true,
199
+ includeNextActionAt: false,
200
+ })),
201
+ };
202
+ return {
203
+ kpis,
204
+ charts,
205
+ lists,
206
+ };
207
+ }
208
+ async getReports(query, locale) {
209
+ var _a, _b;
210
+ const allowCompanyRegistration = await this.isCompanyRegistrationAllowed();
211
+ const { start, end } = this.resolveReportsRange(query, locale);
212
+ const startIso = start.toISOString();
213
+ const endIso = end.toISOString();
214
+ const createdPeople = await this.prismaService.person.findMany({
215
+ where: Object.assign({ created_at: {
216
+ gte: start,
217
+ lte: end,
218
+ } }, (allowCompanyRegistration ? {} : { type: 'individual' })),
219
+ select: {
220
+ id: true,
221
+ name: true,
222
+ type: true,
223
+ status: true,
224
+ avatar_id: true,
225
+ created_at: true,
226
+ updated_at: true,
227
+ person_metadata: true,
228
+ },
229
+ });
230
+ const enrichedCreatedPeople = await this.enrichPeople(createdPeople, allowCompanyRegistration);
231
+ const visibilityFilter = allowCompanyRegistration
232
+ ? api_prisma_1.Prisma.empty
233
+ : api_prisma_1.Prisma.sql `AND p.type = 'individual'`;
234
+ const stageRows = await this.prismaService.$queryRaw(api_prisma_1.Prisma.sql `
235
+ SELECT
236
+ sh.to_stage,
237
+ sh.from_stage,
238
+ sh.changed_at,
239
+ sh.changed_by_user_id
240
+ FROM crm_stage_history sh
241
+ INNER JOIN person p ON p.id = sh.person_id
242
+ WHERE 1 = 1
243
+ ${visibilityFilter}
244
+ AND sh.changed_at >= CAST(${startIso} AS TIMESTAMPTZ)
245
+ AND sh.changed_at <= CAST(${endIso} AS TIMESTAMPTZ)
246
+ `);
247
+ const activityRows = await this.prismaService.$queryRaw(api_prisma_1.Prisma.sql `
248
+ SELECT
249
+ a.type,
250
+ a.source_kind,
251
+ a.created_at,
252
+ a.completed_at,
253
+ a.owner_user_id,
254
+ a.created_by_user_id,
255
+ a.completed_by_user_id
256
+ FROM crm_activity a
257
+ INNER JOIN person p ON p.id = a.person_id
258
+ WHERE 1 = 1
259
+ ${visibilityFilter}
260
+ AND (
261
+ (
262
+ a.created_at >= CAST(${startIso} AS TIMESTAMPTZ)
263
+ AND a.created_at <= CAST(${endIso} AS TIMESTAMPTZ)
264
+ )
265
+ OR (
266
+ a.source_kind = 'followup'
267
+ AND a.completed_at IS NOT NULL
268
+ AND a.completed_at >= CAST(${startIso} AS TIMESTAMPTZ)
269
+ AND a.completed_at <= CAST(${endIso} AS TIMESTAMPTZ)
270
+ )
271
+ )
272
+ `);
273
+ const summary = {
274
+ new_leads: enrichedCreatedPeople.length,
275
+ qualified_moves: 0,
276
+ customer_moves: 0,
277
+ lost_moves: 0,
278
+ interactions: 0,
279
+ followups_completed: 0,
280
+ conversion_rate: 0,
281
+ };
282
+ const sourceTotals = new Map(CRM_DASHBOARD_SOURCE_ORDER.map((key) => [key, 0]));
283
+ const currentStageTotals = new Map(CRM_DASHBOARD_STAGE_ORDER.map((key) => [key, 0]));
284
+ const activityTypeTotals = new Map(CRM_REPORT_ACTIVITY_ORDER.map((key) => [key, 0]));
285
+ const timelineByPeriod = new Map();
286
+ const ownersById = new Map();
287
+ for (const person of enrichedCreatedPeople) {
288
+ const source = ((_a = person.source) !== null && _a !== void 0 ? _a : 'other');
289
+ const lifecycleStage = ((_b = person.lifecycle_stage) !== null && _b !== void 0 ? _b : 'new');
290
+ sourceTotals.set(source, (sourceTotals.get(source) || 0) + 1);
291
+ currentStageTotals.set(lifecycleStage, (currentStageTotals.get(lifecycleStage) || 0) + 1);
292
+ const period = this.getReportPeriodKey(person.created_at, query.group_by);
293
+ if (!period) {
294
+ continue;
295
+ }
296
+ const bucket = this.getOrCreateReportTimelineBucket(timelineByPeriod, period);
297
+ bucket.new_leads += 1;
298
+ }
299
+ for (const row of stageRows) {
300
+ if (row.from_stage === row.to_stage) {
301
+ continue;
302
+ }
303
+ const period = this.getReportPeriodKey(row.changed_at, query.group_by);
304
+ if (!period) {
305
+ continue;
306
+ }
307
+ const bucket = this.getOrCreateReportTimelineBucket(timelineByPeriod, period);
308
+ const ownerUserId = this.coerceNumber(row.changed_by_user_id);
309
+ if (row.to_stage === 'qualified') {
310
+ summary.qualified_moves += 1;
311
+ bucket.qualified_moves += 1;
312
+ }
313
+ if (row.to_stage === 'customer') {
314
+ summary.customer_moves += 1;
315
+ bucket.customer_moves += 1;
316
+ if (ownerUserId > 0) {
317
+ const current = ownersById.get(ownerUserId) ||
318
+ {
319
+ owner_user_id: ownerUserId,
320
+ owner_name: `#${ownerUserId}`,
321
+ interactions: 0,
322
+ followups_completed: 0,
323
+ customer_moves: 0,
324
+ };
325
+ current.customer_moves += 1;
326
+ ownersById.set(ownerUserId, current);
327
+ }
328
+ }
329
+ if (row.to_stage === 'lost') {
330
+ summary.lost_moves += 1;
331
+ bucket.lost_moves += 1;
332
+ }
333
+ }
334
+ for (const row of activityRows) {
335
+ const createdInRange = this.isDateWithinRange(row.created_at, { start, end });
336
+ const completedInRange = row.completed_at != null && this.isDateWithinRange(row.completed_at, { start, end });
337
+ if (createdInRange && CRM_REPORT_ACTIVITY_ORDER.includes(row.type)) {
338
+ const type = row.type;
339
+ activityTypeTotals.set(type, (activityTypeTotals.get(type) || 0) + 1);
340
+ }
341
+ if (createdInRange && row.source_kind === 'interaction') {
342
+ summary.interactions += 1;
343
+ const period = this.getReportPeriodKey(row.created_at, query.group_by);
344
+ if (period) {
345
+ const bucket = this.getOrCreateReportTimelineBucket(timelineByPeriod, period);
346
+ bucket.interactions += 1;
347
+ }
348
+ const ownerUserId = this.coerceNumber(row.owner_user_id) ||
349
+ this.coerceNumber(row.completed_by_user_id) ||
350
+ this.coerceNumber(row.created_by_user_id);
351
+ if (ownerUserId > 0) {
352
+ const current = ownersById.get(ownerUserId) ||
353
+ {
354
+ owner_user_id: ownerUserId,
355
+ owner_name: `#${ownerUserId}`,
356
+ interactions: 0,
357
+ followups_completed: 0,
358
+ customer_moves: 0,
359
+ };
360
+ current.interactions += 1;
361
+ ownersById.set(ownerUserId, current);
362
+ }
363
+ }
364
+ if (completedInRange && row.source_kind === 'followup') {
365
+ summary.followups_completed += 1;
366
+ const period = this.getReportPeriodKey(row.completed_at, query.group_by);
367
+ if (period) {
368
+ const bucket = this.getOrCreateReportTimelineBucket(timelineByPeriod, period);
369
+ bucket.followups_completed += 1;
370
+ }
371
+ const ownerUserId = this.coerceNumber(row.completed_by_user_id) ||
372
+ this.coerceNumber(row.owner_user_id) ||
373
+ this.coerceNumber(row.created_by_user_id);
374
+ if (ownerUserId > 0) {
375
+ const current = ownersById.get(ownerUserId) ||
376
+ {
377
+ owner_user_id: ownerUserId,
378
+ owner_name: `#${ownerUserId}`,
379
+ interactions: 0,
380
+ followups_completed: 0,
381
+ customer_moves: 0,
382
+ };
383
+ current.followups_completed += 1;
384
+ ownersById.set(ownerUserId, current);
385
+ }
386
+ }
387
+ }
388
+ summary.conversion_rate =
389
+ summary.new_leads > 0 ? summary.customer_moves / summary.new_leads : 0;
390
+ const ownerIds = Array.from(ownersById.keys());
391
+ if (ownerIds.length > 0) {
392
+ const owners = await this.prismaService.user.findMany({
393
+ where: {
394
+ id: {
395
+ in: ownerIds,
396
+ },
397
+ },
398
+ select: {
399
+ id: true,
400
+ name: true,
401
+ },
402
+ });
403
+ for (const owner of owners) {
404
+ const current = ownersById.get(owner.id);
405
+ if (current) {
406
+ current.owner_name = owner.name || `#${owner.id}`;
407
+ }
408
+ }
409
+ }
410
+ const timeline = Array.from(timelineByPeriod.keys())
411
+ .sort((left, right) => left.localeCompare(right))
412
+ .map((period) => {
413
+ const bucket = timelineByPeriod.get(period);
414
+ return Object.assign(Object.assign({}, bucket), { conversion_rate: bucket.new_leads > 0 ? bucket.customer_moves / bucket.new_leads : 0 });
415
+ });
416
+ return {
417
+ summary,
418
+ timeline,
419
+ breakdowns: {
420
+ source: CRM_DASHBOARD_SOURCE_ORDER.map((key) => ({
421
+ key,
422
+ total: sourceTotals.get(key) || 0,
423
+ })),
424
+ current_stage: CRM_DASHBOARD_STAGE_ORDER.map((key) => ({
425
+ key,
426
+ total: currentStageTotals.get(key) || 0,
427
+ })),
428
+ activity_type: CRM_REPORT_ACTIVITY_ORDER.map((key) => ({
429
+ key,
430
+ total: activityTypeTotals.get(key) || 0,
431
+ })),
432
+ },
433
+ owners: Array.from(ownersById.values()).sort((left, right) => left.owner_name.localeCompare(right.owner_name)),
434
+ table: timeline,
435
+ };
436
+ }
83
437
  async getOwnerOptions(currentUserId) {
84
438
  const where = {
85
439
  OR: [
@@ -164,13 +518,13 @@ let PersonService = class PersonService {
164
518
  const excludedPersonFilter = excludedPersonId > 0
165
519
  ? api_prisma_1.Prisma.sql ` AND c.person_id <> ${excludedPersonId}`
166
520
  : api_prisma_1.Prisma.empty;
167
- const phoneRows = await this.prismaService.$queryRaw(api_prisma_1.Prisma.sql `
168
- SELECT DISTINCT c.person_id
169
- FROM contact c
170
- JOIN contact_type ct ON ct.id = c.contact_type_id
171
- WHERE UPPER(ct.code) IN ('PHONE', 'MOBILE', 'WHATSAPP')
172
- AND regexp_replace(COALESCE(c.value, ''), '[^0-9]+', '', 'g') = ${normalizedPhone}
173
- ${excludedPersonFilter}
521
+ const phoneRows = await this.prismaService.$queryRaw(api_prisma_1.Prisma.sql `
522
+ SELECT DISTINCT c.person_id
523
+ FROM contact c
524
+ JOIN contact_type ct ON ct.id = c.contact_type_id
525
+ WHERE UPPER(ct.code) IN ('PHONE', 'MOBILE', 'WHATSAPP')
526
+ AND regexp_replace(COALESCE(c.value, ''), '[^0-9]+', '', 'g') = ${normalizedPhone}
527
+ ${excludedPersonFilter}
174
528
  `);
175
529
  for (const row of phoneRows) {
176
530
  this.addDuplicateReason(reasonsByPersonId, row.person_id, 'phone');
@@ -183,12 +537,12 @@ let PersonService = class PersonService {
183
537
  const documentTypeFilter = documentTypeId > 0
184
538
  ? api_prisma_1.Prisma.sql ` AND d.document_type_id = ${documentTypeId}`
185
539
  : api_prisma_1.Prisma.empty;
186
- const documentRows = await this.prismaService.$queryRaw(api_prisma_1.Prisma.sql `
187
- SELECT DISTINCT d.person_id
188
- FROM document d
189
- WHERE regexp_replace(COALESCE(d.value, ''), '[^0-9]+', '', 'g') = ${normalizedDocument}
190
- ${excludedPersonFilter}
191
- ${documentTypeFilter}
540
+ const documentRows = await this.prismaService.$queryRaw(api_prisma_1.Prisma.sql `
541
+ SELECT DISTINCT d.person_id
542
+ FROM document d
543
+ WHERE regexp_replace(COALESCE(d.value, ''), '[^0-9]+', '', 'g') = ${normalizedDocument}
544
+ ${excludedPersonFilter}
545
+ ${documentTypeFilter}
192
546
  `);
193
547
  for (const row of documentRows) {
194
548
  this.addDuplicateReason(reasonsByPersonId, row.person_id, 'document');
@@ -347,6 +701,517 @@ let PersonService = class PersonService {
347
701
  const enriched = await this.enrichPeople(result.data, allowCompanyRegistration);
348
702
  return Object.assign(Object.assign({}, result), { data: enriched });
349
703
  }
704
+ async listAccounts(paginationParams) {
705
+ var _a;
706
+ const allowCompanyRegistration = await this.isCompanyRegistrationAllowed();
707
+ const page = Math.max(Number(paginationParams.page || 1), 1);
708
+ const pageSize = Math.max(Number(paginationParams.pageSize || 12), 1);
709
+ const skip = (page - 1) * pageSize;
710
+ if (!allowCompanyRegistration) {
711
+ return this.createEmptyAccountPagination(page, pageSize);
712
+ }
713
+ const search = this.normalizeTextOrNull(paginationParams.search);
714
+ const filters = this.buildAccountSqlFilters({
715
+ search,
716
+ status: paginationParams.status,
717
+ lifecycleStage: paginationParams.lifecycle_stage,
718
+ });
719
+ const orderBy = this.getAccountOrderBySql(paginationParams.sortField, paginationParams.sortOrder);
720
+ const totalRows = await this.prismaService.$queryRaw(api_prisma_1.Prisma.sql `
721
+ SELECT COUNT(*) AS total
722
+ FROM person p
723
+ INNER JOIN person_company pc ON pc.id = p.id
724
+ WHERE p.type = 'company'
725
+ ${filters}
726
+ `);
727
+ const total = this.coerceCount((_a = totalRows[0]) === null || _a === void 0 ? void 0 : _a.total);
728
+ if (total === 0) {
729
+ return this.createEmptyAccountPagination(page, pageSize);
730
+ }
731
+ const rows = await this.prismaService.$queryRaw(api_prisma_1.Prisma.sql `
732
+ SELECT p.id AS person_id
733
+ FROM person p
734
+ INNER JOIN person_company pc ON pc.id = p.id
735
+ WHERE p.type = 'company'
736
+ ${filters}
737
+ ORDER BY ${orderBy}, p.id ASC
738
+ LIMIT ${pageSize}
739
+ OFFSET ${skip}
740
+ `);
741
+ const personIds = rows.map((row) => row.person_id).filter((id) => id > 0);
742
+ if (personIds.length === 0) {
743
+ return this.createEmptyAccountPagination(page, pageSize);
744
+ }
745
+ const people = await this.loadAccountPeopleByIds(personIds);
746
+ const companies = await this.prismaService.person_company.findMany({
747
+ where: {
748
+ id: {
749
+ in: personIds,
750
+ },
751
+ },
752
+ });
753
+ const personById = new Map(people.map((item) => [item.id, item]));
754
+ const companyById = new Map(companies.map((item) => [item.id, item]));
755
+ const data = rows
756
+ .map((row) => {
757
+ const person = personById.get(row.person_id);
758
+ const company = companyById.get(row.person_id);
759
+ if (!person || !company) {
760
+ return null;
761
+ }
762
+ return this.mapAccountFromPerson(person, company);
763
+ })
764
+ .filter((item) => item != null);
765
+ const lastPage = Math.max(1, Math.ceil(total / pageSize));
766
+ return {
767
+ total,
768
+ lastPage,
769
+ page,
770
+ pageSize,
771
+ prev: page > 1 ? page - 1 : null,
772
+ next: page < lastPage ? page + 1 : null,
773
+ data,
774
+ };
775
+ }
776
+ async getAccountStats() {
777
+ var _a, _b, _c, _d;
778
+ const allowCompanyRegistration = await this.isCompanyRegistrationAllowed();
779
+ if (!allowCompanyRegistration) {
780
+ return {
781
+ total: 0,
782
+ active: 0,
783
+ customers: 0,
784
+ prospects: 0,
785
+ };
786
+ }
787
+ const rows = await this.prismaService.$queryRaw(api_prisma_1.Prisma.sql `
788
+ SELECT
789
+ COUNT(*) AS total,
790
+ COUNT(*) FILTER (WHERE p.status = 'active') AS active,
791
+ COUNT(*) FILTER (WHERE pc.account_lifecycle_stage = 'customer') AS customers,
792
+ COUNT(*) FILTER (WHERE pc.account_lifecycle_stage = 'prospect') AS prospects
793
+ FROM person p
794
+ INNER JOIN person_company pc ON pc.id = p.id
795
+ WHERE p.type = 'company'
796
+ `);
797
+ return {
798
+ total: this.coerceCount((_a = rows[0]) === null || _a === void 0 ? void 0 : _a.total),
799
+ active: this.coerceCount((_b = rows[0]) === null || _b === void 0 ? void 0 : _b.active),
800
+ customers: this.coerceCount((_c = rows[0]) === null || _c === void 0 ? void 0 : _c.customers),
801
+ prospects: this.coerceCount((_d = rows[0]) === null || _d === void 0 ? void 0 : _d.prospects),
802
+ };
803
+ }
804
+ async createAccount(data, locale) {
805
+ await this.ensureCompanyRegistrationAllowed({
806
+ nextType: 'company',
807
+ locale,
808
+ });
809
+ const normalizedName = this.normalizeTextOrNull(data.name);
810
+ if (!normalizedName) {
811
+ throw new common_1.BadRequestException((0, api_locale_1.getLocaleText)('validation.nameMustBeString', locale, 'Name is required.'));
812
+ }
813
+ return this.prismaService.$transaction(async (tx) => {
814
+ const person = await tx.person.create({
815
+ data: {
816
+ name: normalizedName,
817
+ type: 'company',
818
+ status: data.status,
819
+ },
820
+ });
821
+ await this.syncPersonSubtypeData(tx, person.id, null, Object.assign(Object.assign({}, data), { type: 'company', name: normalizedName }), locale);
822
+ await this.syncPersonMetadata(tx, person.id, {
823
+ owner_user_id: data.owner_user_id,
824
+ });
825
+ await this.upsertPrimaryAccountContact(tx, person.id, 'EMAIL', data.email);
826
+ await this.upsertPrimaryAccountContact(tx, person.id, 'PHONE', data.phone);
827
+ return {
828
+ success: true,
829
+ id: person.id,
830
+ };
831
+ });
832
+ }
833
+ async updateAccount(id, data, locale) {
834
+ var _a;
835
+ await this.ensureCompanyRegistrationAllowed({
836
+ currentType: 'company',
837
+ nextType: 'company',
838
+ locale,
839
+ });
840
+ const person = await this.ensureCompanyAccountAccessible(id, locale);
841
+ const nextName = (_a = this.normalizeTextOrNull(data.name)) !== null && _a !== void 0 ? _a : person.name;
842
+ return this.prismaService.$transaction(async (tx) => {
843
+ var _a;
844
+ await tx.person.update({
845
+ where: { id },
846
+ data: {
847
+ name: nextName,
848
+ type: 'company',
849
+ status: (_a = data.status) !== null && _a !== void 0 ? _a : person.status,
850
+ },
851
+ });
852
+ await this.syncPersonSubtypeData(tx, id, 'company', Object.assign(Object.assign({}, data), { type: 'company', name: nextName }), locale);
853
+ await this.syncPersonMetadata(tx, id, {
854
+ owner_user_id: data.owner_user_id,
855
+ });
856
+ await this.upsertPrimaryAccountContact(tx, id, 'EMAIL', data.email);
857
+ await this.upsertPrimaryAccountContact(tx, id, 'PHONE', data.phone);
858
+ return {
859
+ success: true,
860
+ id,
861
+ };
862
+ });
863
+ }
864
+ async deleteAccounts({ ids }, locale) {
865
+ if (ids == undefined || ids == null) {
866
+ throw new common_1.BadRequestException((0, api_locale_1.getLocaleText)('deleteItemsRequired', locale, 'You must select at least one item to delete.'));
867
+ }
868
+ const companies = await this.prismaService.person.findMany({
869
+ where: {
870
+ id: {
871
+ in: ids,
872
+ },
873
+ },
874
+ select: {
875
+ id: true,
876
+ type: true,
877
+ },
878
+ });
879
+ const existingIds = companies.map((item) => item.id);
880
+ const missingIds = ids.filter((itemId) => !existingIds.includes(itemId));
881
+ if (missingIds.length > 0) {
882
+ throw new common_1.BadRequestException((0, api_locale_1.getLocaleText)('personNotFound', locale, `Person(s) with ID(s) ${missingIds.join(', ')} not found.`));
883
+ }
884
+ const invalidIds = companies
885
+ .filter((item) => item.type !== 'company')
886
+ .map((item) => item.id);
887
+ if (invalidIds.length > 0) {
888
+ throw new common_1.BadRequestException((0, api_locale_1.getLocaleText)('companyRelationInvalidTarget', locale, 'Only company records can be managed as accounts.'));
889
+ }
890
+ return this.delete({ ids }, locale);
891
+ }
892
+ async listActivities(paginationParams) {
893
+ var _a;
894
+ const allowCompanyRegistration = await this.isCompanyRegistrationAllowed();
895
+ const page = Math.max(Number(paginationParams.page || 1), 1);
896
+ const pageSize = Math.max(Number(paginationParams.pageSize || 12), 1);
897
+ const skip = (page - 1) * pageSize;
898
+ const filters = this.buildCrmActivitySqlFilters({
899
+ allowCompanyRegistration,
900
+ search: this.normalizeTextOrNull(paginationParams.search),
901
+ status: paginationParams.status,
902
+ type: paginationParams.type,
903
+ priority: paginationParams.priority,
904
+ });
905
+ const totalRows = await this.prismaService.$queryRaw(api_prisma_1.Prisma.sql `
906
+ SELECT COUNT(*) AS total
907
+ FROM crm_activity a
908
+ INNER JOIN person p ON p.id = a.person_id
909
+ LEFT JOIN "user" owner_user ON owner_user.id = a.owner_user_id
910
+ WHERE 1 = 1
911
+ ${filters}
912
+ `);
913
+ const total = this.coerceCount((_a = totalRows[0]) === null || _a === void 0 ? void 0 : _a.total);
914
+ if (total === 0) {
915
+ return this.createEmptyCrmActivityPagination(page, pageSize);
916
+ }
917
+ const rows = await this.prismaService.$queryRaw(api_prisma_1.Prisma.sql `
918
+ SELECT
919
+ a.id,
920
+ a.person_id,
921
+ a.owner_user_id,
922
+ a.type,
923
+ a.subject,
924
+ a.notes,
925
+ a.due_at,
926
+ a.completed_at,
927
+ a.created_at,
928
+ a.priority,
929
+ p.name AS person_name,
930
+ p.type AS person_type,
931
+ p.status AS person_status,
932
+ pc.trade_name AS person_trade_name,
933
+ owner_user.name AS owner_user_name
934
+ FROM crm_activity a
935
+ INNER JOIN person p ON p.id = a.person_id
936
+ LEFT JOIN person_company pc ON pc.id = p.id
937
+ LEFT JOIN "user" owner_user ON owner_user.id = a.owner_user_id
938
+ WHERE 1 = 1
939
+ ${filters}
940
+ ORDER BY a.due_at ASC, a.id ASC
941
+ LIMIT ${pageSize}
942
+ OFFSET ${skip}
943
+ `);
944
+ const data = rows.map((row) => this.mapCrmActivityListRow(row));
945
+ const lastPage = Math.max(1, Math.ceil(total / pageSize));
946
+ return {
947
+ total,
948
+ lastPage,
949
+ page,
950
+ pageSize,
951
+ prev: page > 1 ? page - 1 : null,
952
+ next: page < lastPage ? page + 1 : null,
953
+ data,
954
+ };
955
+ }
956
+ async getActivityStats() {
957
+ var _a, _b, _c, _d;
958
+ const allowCompanyRegistration = await this.isCompanyRegistrationAllowed();
959
+ const visibilityFilter = allowCompanyRegistration
960
+ ? api_prisma_1.Prisma.empty
961
+ : api_prisma_1.Prisma.sql `AND p.type = 'individual'`;
962
+ const rows = await this.prismaService.$queryRaw(api_prisma_1.Prisma.sql `
963
+ SELECT
964
+ COUNT(*) AS total,
965
+ COUNT(*) FILTER (
966
+ WHERE a.completed_at IS NULL
967
+ AND a.due_at >= NOW()
968
+ ) AS pending,
969
+ COUNT(*) FILTER (
970
+ WHERE a.completed_at IS NULL
971
+ AND a.due_at < NOW()
972
+ ) AS overdue,
973
+ COUNT(*) FILTER (
974
+ WHERE a.completed_at IS NOT NULL
975
+ ) AS completed
976
+ FROM crm_activity a
977
+ INNER JOIN person p ON p.id = a.person_id
978
+ WHERE 1 = 1
979
+ ${visibilityFilter}
980
+ `);
981
+ return {
982
+ total: this.coerceCount((_a = rows[0]) === null || _a === void 0 ? void 0 : _a.total),
983
+ pending: this.coerceCount((_b = rows[0]) === null || _b === void 0 ? void 0 : _b.pending),
984
+ overdue: this.coerceCount((_c = rows[0]) === null || _c === void 0 ? void 0 : _c.overdue),
985
+ completed: this.coerceCount((_d = rows[0]) === null || _d === void 0 ? void 0 : _d.completed),
986
+ };
987
+ }
988
+ async getActivity(id, locale) {
989
+ const allowCompanyRegistration = await this.isCompanyRegistrationAllowed();
990
+ const visibilityFilter = allowCompanyRegistration
991
+ ? api_prisma_1.Prisma.empty
992
+ : api_prisma_1.Prisma.sql `AND p.type = 'individual'`;
993
+ const rows = await this.prismaService.$queryRaw(api_prisma_1.Prisma.sql `
994
+ SELECT
995
+ a.id,
996
+ a.person_id,
997
+ a.owner_user_id,
998
+ a.created_by_user_id,
999
+ a.completed_by_user_id,
1000
+ a.type,
1001
+ a.subject,
1002
+ a.notes,
1003
+ a.due_at,
1004
+ a.completed_at,
1005
+ a.created_at,
1006
+ a.priority,
1007
+ a.source_kind,
1008
+ p.name AS person_name,
1009
+ p.type AS person_type,
1010
+ p.status AS person_status,
1011
+ pc.trade_name AS person_trade_name,
1012
+ owner_user.name AS owner_user_name,
1013
+ created_by_user.name AS created_by_user_name,
1014
+ completed_by_user.name AS completed_by_user_name
1015
+ FROM crm_activity a
1016
+ INNER JOIN person p ON p.id = a.person_id
1017
+ LEFT JOIN person_company pc ON pc.id = p.id
1018
+ LEFT JOIN "user" owner_user ON owner_user.id = a.owner_user_id
1019
+ LEFT JOIN "user" created_by_user ON created_by_user.id = a.created_by_user_id
1020
+ LEFT JOIN "user" completed_by_user ON completed_by_user.id = a.completed_by_user_id
1021
+ WHERE a.id = ${id}
1022
+ ${visibilityFilter}
1023
+ LIMIT 1
1024
+ `);
1025
+ const row = rows[0];
1026
+ if (!row) {
1027
+ throw new common_1.NotFoundException((0, api_locale_1.getLocaleText)('personNotFound', locale, `Activity with ID ${id} not found`));
1028
+ }
1029
+ return this.mapCrmActivityDetailRow(row);
1030
+ }
1031
+ async completeActivity(id, locale, user) {
1032
+ const actorUserId = Number((user === null || user === void 0 ? void 0 : user.id) || 0) || null;
1033
+ const allowCompanyRegistration = await this.isCompanyRegistrationAllowed();
1034
+ const visibilityFilter = allowCompanyRegistration
1035
+ ? api_prisma_1.Prisma.empty
1036
+ : api_prisma_1.Prisma.sql `AND p.type = 'individual'`;
1037
+ return this.prismaService.$transaction(async (tx) => {
1038
+ const rows = (await tx.$queryRaw(api_prisma_1.Prisma.sql `
1039
+ SELECT
1040
+ a.id,
1041
+ a.person_id,
1042
+ a.completed_at,
1043
+ a.source_kind
1044
+ FROM crm_activity a
1045
+ INNER JOIN person p ON p.id = a.person_id
1046
+ WHERE a.id = ${id}
1047
+ ${visibilityFilter}
1048
+ LIMIT 1
1049
+ `));
1050
+ const activity = rows[0];
1051
+ if (!activity) {
1052
+ throw new common_1.NotFoundException((0, api_locale_1.getLocaleText)('personNotFound', locale, `Activity with ID ${id} not found`));
1053
+ }
1054
+ if (activity.completed_at) {
1055
+ return {
1056
+ success: true,
1057
+ completed_at: activity.completed_at.toISOString(),
1058
+ };
1059
+ }
1060
+ const completedAt = new Date();
1061
+ await tx.$executeRaw(api_prisma_1.Prisma.sql `
1062
+ UPDATE crm_activity
1063
+ SET
1064
+ completed_at = CAST(${completedAt.toISOString()} AS TIMESTAMPTZ),
1065
+ completed_by_user_id = ${actorUserId},
1066
+ updated_at = NOW()
1067
+ WHERE id = ${id}
1068
+ `);
1069
+ if (activity.source_kind === 'followup') {
1070
+ await this.upsertMetadataValue(tx, activity.person_id, NEXT_ACTION_AT_METADATA_KEY, null);
1071
+ }
1072
+ return {
1073
+ success: true,
1074
+ completed_at: completedAt.toISOString(),
1075
+ };
1076
+ });
1077
+ }
1078
+ async listFollowups(paginationParams, _currentUserId) {
1079
+ var _a;
1080
+ const allowCompanyRegistration = await this.isCompanyRegistrationAllowed();
1081
+ const page = Math.max(Number(paginationParams.page || 1), 1);
1082
+ const pageSize = Math.max(Number(paginationParams.pageSize || 12), 1);
1083
+ const skip = (page - 1) * pageSize;
1084
+ const searchPersonIds = await this.findFollowupSearchPersonIds(paginationParams.search, allowCompanyRegistration);
1085
+ if (searchPersonIds && searchPersonIds.length === 0) {
1086
+ return this.createEmptyFollowupPagination(page, pageSize);
1087
+ }
1088
+ const filters = this.buildFollowupSqlFilters({
1089
+ allowCompanyRegistration,
1090
+ searchPersonIds,
1091
+ status: paginationParams.status,
1092
+ dateFrom: paginationParams.date_from,
1093
+ dateTo: paginationParams.date_to,
1094
+ });
1095
+ const followupTimestampSql = this.getFollowupTimestampSql();
1096
+ const totalRows = await this.prismaService.$queryRaw(api_prisma_1.Prisma.sql `
1097
+ SELECT COUNT(*) AS total
1098
+ FROM person p
1099
+ INNER JOIN person_metadata pm_next
1100
+ ON pm_next.person_id = p.id
1101
+ AND pm_next.key = ${NEXT_ACTION_AT_METADATA_KEY}
1102
+ WHERE 1 = 1
1103
+ ${filters}
1104
+ `);
1105
+ const total = this.coerceCount((_a = totalRows[0]) === null || _a === void 0 ? void 0 : _a.total);
1106
+ if (total === 0) {
1107
+ return this.createEmptyFollowupPagination(page, pageSize);
1108
+ }
1109
+ const rows = await this.prismaService.$queryRaw(api_prisma_1.Prisma.sql `
1110
+ SELECT p.id AS person_id
1111
+ FROM person p
1112
+ INNER JOIN person_metadata pm_next
1113
+ ON pm_next.person_id = p.id
1114
+ AND pm_next.key = ${NEXT_ACTION_AT_METADATA_KEY}
1115
+ WHERE 1 = 1
1116
+ ${filters}
1117
+ ORDER BY ${followupTimestampSql} ASC, p.id ASC
1118
+ LIMIT ${pageSize}
1119
+ OFFSET ${skip}
1120
+ `);
1121
+ const personIds = rows.map((row) => row.person_id).filter((id) => id > 0);
1122
+ const people = personIds.length > 0
1123
+ ? await this.prismaService.person.findMany({
1124
+ where: {
1125
+ id: {
1126
+ in: personIds,
1127
+ },
1128
+ },
1129
+ include: {
1130
+ person_address: {
1131
+ include: {
1132
+ address: true,
1133
+ },
1134
+ },
1135
+ contact: true,
1136
+ document: true,
1137
+ person_metadata: true,
1138
+ },
1139
+ })
1140
+ : [];
1141
+ const enrichedPeople = await this.enrichPeople(people, allowCompanyRegistration);
1142
+ const personById = new Map(enrichedPeople.map((person) => [person.id, person]));
1143
+ const data = rows
1144
+ .map((row) => {
1145
+ var _a;
1146
+ const person = personById.get(row.person_id);
1147
+ const nextActionAt = this.normalizeDateTimeOrNull(person === null || person === void 0 ? void 0 : person.next_action_at);
1148
+ if (!person || !nextActionAt) {
1149
+ return null;
1150
+ }
1151
+ return {
1152
+ person,
1153
+ next_action_at: nextActionAt,
1154
+ last_interaction_at: (_a = this.normalizeDateTimeOrNull(person.last_interaction_at)) !== null && _a !== void 0 ? _a : null,
1155
+ status: this.getFollowupStatus(nextActionAt),
1156
+ };
1157
+ })
1158
+ .filter((item) => item != null);
1159
+ const lastPage = Math.max(1, Math.ceil(total / pageSize));
1160
+ return {
1161
+ total,
1162
+ lastPage,
1163
+ page,
1164
+ pageSize,
1165
+ prev: page > 1 ? page - 1 : null,
1166
+ next: page < lastPage ? page + 1 : null,
1167
+ data,
1168
+ };
1169
+ }
1170
+ async getFollowupStats(query, _currentUserId) {
1171
+ var _a, _b, _c, _d;
1172
+ const allowCompanyRegistration = await this.isCompanyRegistrationAllowed();
1173
+ const searchPersonIds = await this.findFollowupSearchPersonIds(query.search, allowCompanyRegistration);
1174
+ if (searchPersonIds && searchPersonIds.length === 0) {
1175
+ return {
1176
+ total: 0,
1177
+ today: 0,
1178
+ overdue: 0,
1179
+ upcoming: 0,
1180
+ };
1181
+ }
1182
+ const filters = this.buildFollowupSqlFilters({
1183
+ allowCompanyRegistration,
1184
+ searchPersonIds,
1185
+ });
1186
+ const followupTimestampSql = this.getFollowupTimestampSql();
1187
+ const { todayStartIso, tomorrowStartIso } = this.getFollowupDayBoundaryIsoStrings();
1188
+ const rows = await this.prismaService.$queryRaw(api_prisma_1.Prisma.sql `
1189
+ SELECT
1190
+ COUNT(*) AS total,
1191
+ COUNT(*) FILTER (
1192
+ WHERE ${followupTimestampSql} >= CAST(${todayStartIso} AS TIMESTAMPTZ)
1193
+ AND ${followupTimestampSql} < CAST(${tomorrowStartIso} AS TIMESTAMPTZ)
1194
+ ) AS today,
1195
+ COUNT(*) FILTER (
1196
+ WHERE ${followupTimestampSql} < CAST(${todayStartIso} AS TIMESTAMPTZ)
1197
+ ) AS overdue,
1198
+ COUNT(*) FILTER (
1199
+ WHERE ${followupTimestampSql} >= CAST(${tomorrowStartIso} AS TIMESTAMPTZ)
1200
+ ) AS upcoming
1201
+ FROM person p
1202
+ INNER JOIN person_metadata pm_next
1203
+ ON pm_next.person_id = p.id
1204
+ AND pm_next.key = ${NEXT_ACTION_AT_METADATA_KEY}
1205
+ WHERE 1 = 1
1206
+ ${filters}
1207
+ `);
1208
+ return {
1209
+ total: this.coerceCount((_a = rows[0]) === null || _a === void 0 ? void 0 : _a.total),
1210
+ today: this.coerceCount((_b = rows[0]) === null || _b === void 0 ? void 0 : _b.today),
1211
+ overdue: this.coerceCount((_c = rows[0]) === null || _c === void 0 ? void 0 : _c.overdue),
1212
+ upcoming: this.coerceCount((_d = rows[0]) === null || _d === void 0 ? void 0 : _d.upcoming),
1213
+ };
1214
+ }
350
1215
  async get(locale, id) {
351
1216
  const allowCompanyRegistration = await this.isCompanyRegistrationAllowed();
352
1217
  const person = await this.prismaService.person.findUnique({
@@ -387,6 +1252,7 @@ let PersonService = class PersonService {
387
1252
  async createInteraction(id, data, locale, user) {
388
1253
  const person = await this.ensurePersonAccessible(id, locale);
389
1254
  const interaction = this.buildInteractionRecord(data, user);
1255
+ const ownerUserId = await this.getPersonOwnerUserId(person.id);
390
1256
  await this.prismaService.$transaction(async (tx) => {
391
1257
  const currentInteractions = await this.loadInteractionsFromTx(tx, person.id);
392
1258
  const nextInteractions = this.sortInteractions([
@@ -395,12 +1261,19 @@ let PersonService = class PersonService {
395
1261
  ]);
396
1262
  await this.upsertMetadataValue(tx, person.id, INTERACTIONS_METADATA_KEY, nextInteractions);
397
1263
  await this.upsertMetadataValue(tx, person.id, LAST_INTERACTION_AT_METADATA_KEY, interaction.created_at);
1264
+ await this.createCompletedInteractionActivity(tx, {
1265
+ personId: person.id,
1266
+ ownerUserId,
1267
+ interaction,
1268
+ actorUserId: Number((user === null || user === void 0 ? void 0 : user.id) || 0) || null,
1269
+ });
398
1270
  });
399
1271
  return interaction;
400
1272
  }
401
1273
  async scheduleFollowup(id, data, locale, user) {
402
1274
  const person = await this.ensurePersonAccessible(id, locale);
403
1275
  const normalizedNextActionAt = this.normalizeDateTimeOrNull(data.next_action_at);
1276
+ const ownerUserId = await this.getPersonOwnerUserId(person.id);
404
1277
  if (!normalizedNextActionAt) {
405
1278
  throw new common_1.BadRequestException((0, api_locale_1.getLocaleText)('validation.dateMustBeString', locale, 'next_action_at must be a valid datetime.'));
406
1279
  }
@@ -420,16 +1293,34 @@ let PersonService = class PersonService {
420
1293
  await this.upsertMetadataValue(tx, person.id, INTERACTIONS_METADATA_KEY, nextInteractions);
421
1294
  await this.upsertMetadataValue(tx, person.id, LAST_INTERACTION_AT_METADATA_KEY, interaction.created_at);
422
1295
  }
1296
+ await this.upsertFollowupActivity(tx, {
1297
+ personId: person.id,
1298
+ ownerUserId,
1299
+ dueAt: normalizedNextActionAt,
1300
+ notes: data.notes,
1301
+ actorUserId: Number((user === null || user === void 0 ? void 0 : user.id) || 0) || null,
1302
+ });
423
1303
  });
424
1304
  return {
425
1305
  success: true,
426
1306
  next_action_at: normalizedNextActionAt,
427
1307
  };
428
1308
  }
429
- async updateLifecycleStage(id, data, locale) {
1309
+ async updateLifecycleStage(id, data, locale, user) {
430
1310
  const person = await this.ensurePersonAccessible(id, locale);
1311
+ const currentLifecycleStage = await this.getPersonLifecycleStage(person.id);
1312
+ const nextLifecycleStage = this.normalizeTextOrNull(data.lifecycle_stage);
1313
+ const actorUserId = this.coerceNumber(user === null || user === void 0 ? void 0 : user.id) || null;
431
1314
  await this.prismaService.$transaction(async (tx) => {
432
1315
  await this.upsertMetadataValue(tx, person.id, LIFECYCLE_STAGE_METADATA_KEY, data.lifecycle_stage);
1316
+ if (nextLifecycleStage && currentLifecycleStage !== nextLifecycleStage) {
1317
+ await this.registerStageTransition(tx, {
1318
+ personId: person.id,
1319
+ fromStage: currentLifecycleStage,
1320
+ toStage: nextLifecycleStage,
1321
+ changedByUserId: actorUserId,
1322
+ });
1323
+ }
433
1324
  });
434
1325
  return {
435
1326
  success: true,
@@ -469,7 +1360,7 @@ let PersonService = class PersonService {
469
1360
  return person;
470
1361
  });
471
1362
  }
472
- async update(id, data, locale) {
1363
+ async update(id, data, locale, user) {
473
1364
  const allowCompanyRegistration = await this.isCompanyRegistrationAllowed();
474
1365
  const person = await this.prismaService.person.findUnique({ where: { id } });
475
1366
  if (!person) {
@@ -483,6 +1374,8 @@ let PersonService = class PersonService {
483
1374
  nextType: data.type,
484
1375
  locale,
485
1376
  });
1377
+ const currentLifecycleStage = await this.getPersonLifecycleStage(id);
1378
+ const nextLifecycleStage = this.normalizeTextOrNull(data.lifecycle_stage);
486
1379
  const incomingContacts = Array.isArray(data.contacts) ? data.contacts : [];
487
1380
  const incomingAddresses = Array.isArray(data.addresses) ? data.addresses : [];
488
1381
  const incomingDocuments = Array.isArray(data.documents) ? data.documents : [];
@@ -521,6 +1414,14 @@ let PersonService = class PersonService {
521
1414
  await this.syncContacts(tx, id, incomingContacts);
522
1415
  await this.syncAddresses(tx, id, incomingAddresses, locale);
523
1416
  await this.syncDocuments(tx, id, incomingDocuments);
1417
+ if (nextLifecycleStage && currentLifecycleStage !== nextLifecycleStage) {
1418
+ await this.registerStageTransition(tx, {
1419
+ personId: id,
1420
+ fromStage: currentLifecycleStage,
1421
+ toStage: nextLifecycleStage,
1422
+ changedByUserId: this.coerceNumber(user === null || user === void 0 ? void 0 : user.id) || null,
1423
+ });
1424
+ }
524
1425
  return { success: true };
525
1426
  })
526
1427
  .then(async (result) => {
@@ -592,6 +1493,118 @@ let PersonService = class PersonService {
592
1493
  }),
593
1494
  ]);
594
1495
  }
1496
+ async findFollowupSearchPersonIds(search, allowCompanyRegistration) {
1497
+ const normalizedSearch = this.normalizeTextOrNull(search);
1498
+ if (!normalizedSearch) {
1499
+ return null;
1500
+ }
1501
+ const where = {
1502
+ OR: await this.buildSearchFilters(normalizedSearch),
1503
+ };
1504
+ if (!allowCompanyRegistration) {
1505
+ where.type = 'individual';
1506
+ }
1507
+ const people = await this.prismaService.person.findMany({
1508
+ where,
1509
+ select: {
1510
+ id: true,
1511
+ },
1512
+ });
1513
+ return people.map((person) => person.id).filter((id) => id > 0);
1514
+ }
1515
+ buildFollowupSqlFilters({ allowCompanyRegistration, searchPersonIds, status, dateFrom, dateTo, }) {
1516
+ const filters = [];
1517
+ const followupTimestampSql = this.getFollowupTimestampSql();
1518
+ if (!allowCompanyRegistration) {
1519
+ filters.push(api_prisma_1.Prisma.sql `AND p.type = 'individual'`);
1520
+ }
1521
+ if (searchPersonIds && searchPersonIds.length > 0) {
1522
+ filters.push(api_prisma_1.Prisma.sql `AND p.id IN (${api_prisma_1.Prisma.join(searchPersonIds)})`);
1523
+ }
1524
+ const { todayStartIso, tomorrowStartIso } = this.getFollowupDayBoundaryIsoStrings();
1525
+ if (status === 'overdue') {
1526
+ filters.push(api_prisma_1.Prisma.sql `AND ${followupTimestampSql} < CAST(${todayStartIso} AS TIMESTAMPTZ)`);
1527
+ }
1528
+ else if (status === 'today') {
1529
+ filters.push(api_prisma_1.Prisma.sql `AND ${followupTimestampSql} >= CAST(${todayStartIso} AS TIMESTAMPTZ)`);
1530
+ filters.push(api_prisma_1.Prisma.sql `AND ${followupTimestampSql} < CAST(${tomorrowStartIso} AS TIMESTAMPTZ)`);
1531
+ }
1532
+ else if (status === 'upcoming') {
1533
+ filters.push(api_prisma_1.Prisma.sql `AND ${followupTimestampSql} >= CAST(${tomorrowStartIso} AS TIMESTAMPTZ)`);
1534
+ }
1535
+ const dateFromIso = this.normalizeDateOnlyBoundary(dateFrom, 'start');
1536
+ if (dateFromIso) {
1537
+ filters.push(api_prisma_1.Prisma.sql `AND ${followupTimestampSql} >= CAST(${dateFromIso} AS TIMESTAMPTZ)`);
1538
+ }
1539
+ const dateToIso = this.normalizeDateOnlyBoundary(dateTo, 'endExclusive');
1540
+ if (dateToIso) {
1541
+ filters.push(api_prisma_1.Prisma.sql `AND ${followupTimestampSql} < CAST(${dateToIso} AS TIMESTAMPTZ)`);
1542
+ }
1543
+ return filters.length > 0
1544
+ ? api_prisma_1.Prisma.join(filters, '\n')
1545
+ : api_prisma_1.Prisma.empty;
1546
+ }
1547
+ createEmptyFollowupPagination(page, pageSize) {
1548
+ return {
1549
+ total: 0,
1550
+ lastPage: 1,
1551
+ page,
1552
+ pageSize,
1553
+ prev: page > 1 ? page - 1 : null,
1554
+ next: null,
1555
+ data: [],
1556
+ };
1557
+ }
1558
+ getFollowupTimestampSql() {
1559
+ return api_prisma_1.Prisma.sql `CAST(TRIM(BOTH '"' FROM pm_next.value::text) AS TIMESTAMPTZ)`;
1560
+ }
1561
+ getFollowupDayBoundaryDates(reference = new Date()) {
1562
+ const todayStart = new Date(reference.getFullYear(), reference.getMonth(), reference.getDate());
1563
+ const tomorrowStart = new Date(todayStart);
1564
+ tomorrowStart.setDate(tomorrowStart.getDate() + 1);
1565
+ return {
1566
+ todayStart,
1567
+ tomorrowStart,
1568
+ };
1569
+ }
1570
+ getFollowupDayBoundaryIsoStrings(reference = new Date()) {
1571
+ const { todayStart, tomorrowStart } = this.getFollowupDayBoundaryDates(reference);
1572
+ return {
1573
+ todayStartIso: todayStart.toISOString(),
1574
+ tomorrowStartIso: tomorrowStart.toISOString(),
1575
+ };
1576
+ }
1577
+ normalizeDateOnlyBoundary(value, mode) {
1578
+ if (!value) {
1579
+ return null;
1580
+ }
1581
+ const parsed = new Date(`${value}T00:00:00`);
1582
+ if (Number.isNaN(parsed.getTime())) {
1583
+ return null;
1584
+ }
1585
+ if (mode === 'endExclusive') {
1586
+ parsed.setDate(parsed.getDate() + 1);
1587
+ }
1588
+ return parsed.toISOString();
1589
+ }
1590
+ getFollowupStatus(nextActionAt) {
1591
+ const parsed = new Date(nextActionAt);
1592
+ const { todayStart, tomorrowStart } = this.getFollowupDayBoundaryDates();
1593
+ if (parsed < todayStart) {
1594
+ return 'overdue';
1595
+ }
1596
+ if (parsed < tomorrowStart) {
1597
+ return 'today';
1598
+ }
1599
+ return 'upcoming';
1600
+ }
1601
+ coerceCount(value) {
1602
+ if (typeof value === 'bigint') {
1603
+ return Number(value);
1604
+ }
1605
+ const parsed = Number(value);
1606
+ return Number.isFinite(parsed) ? parsed : 0;
1607
+ }
595
1608
  async openPublicAvatar(locale, fileId, res) {
596
1609
  const personWithAvatar = await this.prismaService.person.findFirst({
597
1610
  where: {
@@ -612,12 +1625,174 @@ let PersonService = class PersonService {
612
1625
  });
613
1626
  res.send(buffer);
614
1627
  }
1628
+ resolveDashboardRanges(query, locale) {
1629
+ var _a;
1630
+ const period = (_a = query.period) !== null && _a !== void 0 ? _a : '30d';
1631
+ const now = new Date();
1632
+ if (period === 'custom') {
1633
+ if (!query.date_from || !query.date_to) {
1634
+ throw new common_1.BadRequestException((0, api_locale_1.getLocaleText)('validation.dateMustBeString', locale, 'date_from and date_to are required when period is custom.'));
1635
+ }
1636
+ const start = this.startOfDay(this.parseDateOrThrow(query.date_from, locale));
1637
+ const end = this.endOfDay(this.parseDateOrThrow(query.date_to, locale));
1638
+ if (start.getTime() > end.getTime()) {
1639
+ throw new common_1.BadRequestException((0, api_locale_1.getLocaleText)('validation.dateMustBeString', locale, 'date_from must be less than or equal to date_to.'));
1640
+ }
1641
+ return {
1642
+ created: { start, end },
1643
+ operational: { start, end },
1644
+ };
1645
+ }
1646
+ const dayCount = Number(period.replace('d', ''));
1647
+ const createdStart = this.startOfDay(this.addDays(now, -(dayCount - 1)));
1648
+ const createdEnd = this.endOfDay(now);
1649
+ const operationalStart = this.startOfDay(this.addDays(now, -(dayCount - 1)));
1650
+ const operationalEnd = this.endOfDay(this.addDays(now, dayCount - 1));
1651
+ return {
1652
+ created: {
1653
+ start: createdStart,
1654
+ end: createdEnd,
1655
+ },
1656
+ operational: {
1657
+ start: operationalStart,
1658
+ end: operationalEnd,
1659
+ },
1660
+ };
1661
+ }
1662
+ resolveReportsRange(query, locale) {
1663
+ const start = this.startOfDay(this.parseDateOrThrow(query.date_from, locale));
1664
+ const end = this.endOfDay(this.parseDateOrThrow(query.date_to, locale));
1665
+ if (start.getTime() > end.getTime()) {
1666
+ throw new common_1.BadRequestException((0, api_locale_1.getLocaleText)('validation.dateMustBeString', locale, 'date_from must be less than or equal to date_to.'));
1667
+ }
1668
+ return { start, end };
1669
+ }
1670
+ getReportPeriodKey(dateValue, groupBy) {
1671
+ const date = this.parseDateOrNull(dateValue);
1672
+ if (!date) {
1673
+ return null;
1674
+ }
1675
+ if (groupBy === 'day') {
1676
+ return this.toDateKey(date);
1677
+ }
1678
+ if (groupBy === 'week') {
1679
+ return this.toDateKey(this.startOfWeek(date));
1680
+ }
1681
+ if (groupBy === 'month') {
1682
+ const year = date.getFullYear();
1683
+ const month = String(date.getMonth() + 1).padStart(2, '0');
1684
+ return `${year}-${month}`;
1685
+ }
1686
+ return String(date.getFullYear());
1687
+ }
1688
+ getOrCreateReportTimelineBucket(buckets, period) {
1689
+ const existing = buckets.get(period);
1690
+ if (existing) {
1691
+ return existing;
1692
+ }
1693
+ const next = {
1694
+ period,
1695
+ label: period,
1696
+ new_leads: 0,
1697
+ qualified_moves: 0,
1698
+ customer_moves: 0,
1699
+ lost_moves: 0,
1700
+ interactions: 0,
1701
+ followups_completed: 0,
1702
+ conversion_rate: 0,
1703
+ };
1704
+ buckets.set(period, next);
1705
+ return next;
1706
+ }
1707
+ startOfWeek(value) {
1708
+ const date = new Date(value);
1709
+ date.setHours(0, 0, 0, 0);
1710
+ const day = (date.getDay() + 6) % 7;
1711
+ date.setDate(date.getDate() - day);
1712
+ return date;
1713
+ }
1714
+ toDateKey(value) {
1715
+ const year = value.getFullYear();
1716
+ const month = String(value.getMonth() + 1).padStart(2, '0');
1717
+ const day = String(value.getDate()).padStart(2, '0');
1718
+ return `${year}-${month}-${day}`;
1719
+ }
1720
+ buildDashboardOwnerPerformance(people) {
1721
+ var _a, _b;
1722
+ const byOwnerId = new Map();
1723
+ for (const person of people) {
1724
+ const ownerUserId = this.coerceNumber(person.owner_user_id);
1725
+ if (ownerUserId <= 0) {
1726
+ continue;
1727
+ }
1728
+ const current = (_a = byOwnerId.get(ownerUserId)) !== null && _a !== void 0 ? _a : {
1729
+ owner_user_id: ownerUserId,
1730
+ owner_name: ((_b = person.owner_user) === null || _b === void 0 ? void 0 : _b.name) || `#${ownerUserId}`,
1731
+ leads: 0,
1732
+ customers: 0,
1733
+ pipeline_value: 0,
1734
+ };
1735
+ current.leads += 1;
1736
+ if (person.lifecycle_stage === 'customer') {
1737
+ current.customers += 1;
1738
+ }
1739
+ if (person.lifecycle_stage !== 'lost') {
1740
+ current.pipeline_value += this.coerceNumber(person.deal_value);
1741
+ }
1742
+ byOwnerId.set(ownerUserId, current);
1743
+ }
1744
+ return Array.from(byOwnerId.values()).sort((left, right) => left.owner_name.localeCompare(right.owner_name));
1745
+ }
1746
+ mapDashboardListItem(person, { includeCreatedAt, includeNextActionAt, }) {
1747
+ var _a, _b, _c, _d, _e;
1748
+ const source = ((_a = person.source) !== null && _a !== void 0 ? _a : 'other');
1749
+ const lifecycleStage = ((_b = person.lifecycle_stage) !== null && _b !== void 0 ? _b : 'new');
1750
+ return Object.assign(Object.assign({ id: person.id, name: person.name, trade_name: (_c = person.trade_name) !== null && _c !== void 0 ? _c : null, owner_user: (_d = person.owner_user) !== null && _d !== void 0 ? _d : null, source, lifecycle_stage: lifecycleStage }, (includeNextActionAt && person.next_action_at
1751
+ ? { next_action_at: person.next_action_at }
1752
+ : {})), (includeCreatedAt && person.created_at
1753
+ ? {
1754
+ created_at: (_e = this.normalizeDateTimeOrNull(person.created_at)) !== null && _e !== void 0 ? _e : person.created_at,
1755
+ }
1756
+ : {}));
1757
+ }
1758
+ isDateWithinRange(value, range) {
1759
+ const parsed = this.parseDateOrNull(value);
1760
+ if (!parsed) {
1761
+ return false;
1762
+ }
1763
+ return (parsed.getTime() >= range.start.getTime() &&
1764
+ parsed.getTime() <= range.end.getTime());
1765
+ }
1766
+ parseDateOrThrow(value, locale) {
1767
+ const parsed = /^\d{4}-\d{2}-\d{2}$/.test(value)
1768
+ ? this.parseDateOrNull(`${value}T00:00:00`)
1769
+ : this.parseDateOrNull(value);
1770
+ if (!parsed) {
1771
+ throw new common_1.BadRequestException((0, api_locale_1.getLocaleText)('validation.dateMustBeString', locale, `Invalid date value: ${value}`));
1772
+ }
1773
+ return parsed;
1774
+ }
1775
+ addDays(date, amount) {
1776
+ const next = new Date(date);
1777
+ next.setDate(next.getDate() + amount);
1778
+ return next;
1779
+ }
1780
+ startOfDay(date) {
1781
+ const next = new Date(date);
1782
+ next.setHours(0, 0, 0, 0);
1783
+ return next;
1784
+ }
1785
+ endOfDay(date) {
1786
+ const next = new Date(date);
1787
+ next.setHours(23, 59, 59, 999);
1788
+ return next;
1789
+ }
615
1790
  async enrichPeople(people, allowCompanyRegistration = true) {
616
1791
  if (people.length === 0) {
617
1792
  return [];
618
1793
  }
619
1794
  const personIds = people.map((person) => person.id);
620
- const [companies, individuals, companyBranches, employerMetadata] = await Promise.all([
1795
+ const [companies, individuals, companyBranches, employerMetadataRaw] = await Promise.all([
621
1796
  this.prismaService.person_company.findMany({
622
1797
  where: { id: { in: personIds } },
623
1798
  }),
@@ -637,8 +1812,9 @@ let PersonService = class PersonService {
637
1812
  person_id: true,
638
1813
  value: true,
639
1814
  },
640
- }).then((metadata) => (allowCompanyRegistration ? metadata : [])),
1815
+ }),
641
1816
  ]);
1817
+ const employerMetadata = allowCompanyRegistration ? employerMetadataRaw : [];
642
1818
  const companyById = new Map(companies.map((item) => [item.id, item]));
643
1819
  const individualById = new Map(individuals.map((item) => [item.id, item]));
644
1820
  const branchesByHeadquarterId = new Map();
@@ -863,6 +2039,47 @@ let PersonService = class PersonService {
863
2039
  })
864
2040
  .filter((item) => item != null));
865
2041
  }
2042
+ async getPersonLifecycleStage(personId) {
2043
+ var _a;
2044
+ const metadata = await this.prismaService.person_metadata.findFirst({
2045
+ where: {
2046
+ person_id: personId,
2047
+ key: LIFECYCLE_STAGE_METADATA_KEY,
2048
+ },
2049
+ select: {
2050
+ value: true,
2051
+ },
2052
+ });
2053
+ return (_a = this.metadataToString(metadata === null || metadata === void 0 ? void 0 : metadata.value)) !== null && _a !== void 0 ? _a : 'new';
2054
+ }
2055
+ async registerStageTransition(tx, { personId, fromStage, toStage, changedByUserId, }) {
2056
+ if (fromStage === toStage) {
2057
+ return;
2058
+ }
2059
+ const fromStageSql = fromStage
2060
+ ? api_prisma_1.Prisma.sql `CAST(${fromStage} AS crm_stage_history_from_stage_enum)`
2061
+ : api_prisma_1.Prisma.sql `NULL`;
2062
+ await tx.$executeRaw(api_prisma_1.Prisma.sql `
2063
+ INSERT INTO crm_stage_history (
2064
+ person_id,
2065
+ from_stage,
2066
+ to_stage,
2067
+ changed_by_user_id,
2068
+ changed_at,
2069
+ created_at,
2070
+ updated_at
2071
+ )
2072
+ VALUES (
2073
+ ${personId},
2074
+ ${fromStageSql},
2075
+ CAST(${toStage} AS crm_stage_history_to_stage_enum),
2076
+ ${changedByUserId},
2077
+ NOW(),
2078
+ NOW(),
2079
+ NOW()
2080
+ )
2081
+ `);
2082
+ }
866
2083
  async syncPersonSubtypeData(tx, personId, currentType, data, locale) {
867
2084
  var _a, _b, _c;
868
2085
  const targetType = (_a = data.type) !== null && _a !== void 0 ? _a : currentType;
@@ -917,6 +2134,13 @@ let PersonService = class PersonService {
917
2134
  create: {
918
2135
  id: personId,
919
2136
  trade_name: this.normalizeTextOrNull(data.trade_name),
2137
+ industry: this.normalizeTextOrNull(data.industry),
2138
+ website: this.normalizeTextOrNull(data.website),
2139
+ annual_revenue: this.normalizeDecimalOrNull(data.annual_revenue),
2140
+ employee_count: this.normalizeIntegerOrNull(data.employee_count),
2141
+ account_lifecycle_stage: this.normalizeAccountLifecycleStage(data.lifecycle_stage),
2142
+ city: this.normalizeTextOrNull(data.city),
2143
+ state: this.normalizeStateOrNull(data.state),
920
2144
  foundation_date: this.parseDateOrNull(data.foundation_date),
921
2145
  legal_nature: this.normalizeTextOrNull(data.legal_nature),
922
2146
  headquarter_id: normalizedHeadquarterId > 0 ? normalizedHeadquarterId : null,
@@ -925,6 +2149,27 @@ let PersonService = class PersonService {
925
2149
  trade_name: data.trade_name === undefined
926
2150
  ? undefined
927
2151
  : this.normalizeTextOrNull(data.trade_name),
2152
+ industry: data.industry === undefined
2153
+ ? undefined
2154
+ : this.normalizeTextOrNull(data.industry),
2155
+ website: data.website === undefined
2156
+ ? undefined
2157
+ : this.normalizeTextOrNull(data.website),
2158
+ annual_revenue: data.annual_revenue === undefined
2159
+ ? undefined
2160
+ : this.normalizeDecimalOrNull(data.annual_revenue),
2161
+ employee_count: data.employee_count === undefined
2162
+ ? undefined
2163
+ : this.normalizeIntegerOrNull(data.employee_count),
2164
+ account_lifecycle_stage: data.lifecycle_stage === undefined
2165
+ ? undefined
2166
+ : this.normalizeAccountLifecycleStage(data.lifecycle_stage),
2167
+ city: data.city === undefined
2168
+ ? undefined
2169
+ : this.normalizeTextOrNull(data.city),
2170
+ state: data.state === undefined
2171
+ ? undefined
2172
+ : this.normalizeStateOrNull(data.state),
928
2173
  foundation_date: data.foundation_date === undefined
929
2174
  ? undefined
930
2175
  : this.parseDateOrNull(data.foundation_date),
@@ -1394,6 +2639,20 @@ let PersonService = class PersonService {
1394
2639
  const date = value instanceof Date ? value : new Date(String(value));
1395
2640
  return Number.isNaN(date.getTime()) ? null : date;
1396
2641
  }
2642
+ normalizeDecimalOrNull(value) {
2643
+ if (value == null || value === '') {
2644
+ return null;
2645
+ }
2646
+ const parsed = Number(value);
2647
+ return Number.isFinite(parsed) ? new api_prisma_1.Prisma.Decimal(parsed) : null;
2648
+ }
2649
+ normalizeIntegerOrNull(value) {
2650
+ if (value == null || value === '') {
2651
+ return null;
2652
+ }
2653
+ const parsed = Number(value);
2654
+ return Number.isInteger(parsed) ? parsed : null;
2655
+ }
1397
2656
  normalizeTextOrNull(value) {
1398
2657
  if (typeof value !== 'string') {
1399
2658
  return value == null ? null : String(value);
@@ -1411,6 +2670,20 @@ let PersonService = class PersonService {
1411
2670
  const parsed = Number(value);
1412
2671
  return Number.isFinite(parsed) ? parsed : 0;
1413
2672
  }
2673
+ normalizeStateOrNull(value) {
2674
+ var _a, _b;
2675
+ const normalized = (_b = (_a = this.normalizeTextOrNull(value)) === null || _a === void 0 ? void 0 : _a.toUpperCase()) !== null && _b !== void 0 ? _b : null;
2676
+ return normalized ? normalized.slice(0, 2) : null;
2677
+ }
2678
+ normalizeAccountLifecycleStage(value) {
2679
+ const normalized = this.normalizeTextOrNull(value);
2680
+ if (!normalized) {
2681
+ return null;
2682
+ }
2683
+ return account_dto_1.ACCOUNT_LIFECYCLE_STAGES.includes(normalized)
2684
+ ? normalized
2685
+ : null;
2686
+ }
1414
2687
  resolveRequestedOwnerUserId(ownerUserId, mine, currentUserId) {
1415
2688
  if (mine === true ||
1416
2689
  mine === 'true' ||
@@ -1436,6 +2709,288 @@ let PersonService = class PersonService {
1436
2709
  }
1437
2710
  return person;
1438
2711
  }
2712
+ async getPersonOwnerUserId(personId) {
2713
+ const metadata = await this.prismaService.person_metadata.findFirst({
2714
+ where: {
2715
+ person_id: personId,
2716
+ key: OWNER_USER_METADATA_KEY,
2717
+ },
2718
+ select: {
2719
+ value: true,
2720
+ },
2721
+ });
2722
+ const ownerUserId = this.metadataToNumber(metadata === null || metadata === void 0 ? void 0 : metadata.value);
2723
+ return ownerUserId && ownerUserId > 0 ? ownerUserId : null;
2724
+ }
2725
+ async upsertFollowupActivity(tx, { personId, ownerUserId, dueAt, notes, actorUserId, }) {
2726
+ const existingRows = (await tx.$queryRaw(api_prisma_1.Prisma.sql `
2727
+ SELECT id
2728
+ FROM crm_activity
2729
+ WHERE person_id = ${personId}
2730
+ AND source_kind = 'followup'
2731
+ AND completed_at IS NULL
2732
+ ORDER BY id DESC
2733
+ LIMIT 1
2734
+ `));
2735
+ const existing = existingRows[0];
2736
+ const normalizedNotes = this.normalizeTextOrNull(notes);
2737
+ if (existing) {
2738
+ await tx.$executeRaw(api_prisma_1.Prisma.sql `
2739
+ UPDATE crm_activity
2740
+ SET
2741
+ owner_user_id = ${ownerUserId},
2742
+ type = CAST(${'task'} AS crm_activity_type_enum),
2743
+ subject = ${this.getFollowupActivitySubject()},
2744
+ notes = ${normalizedNotes},
2745
+ due_at = CAST(${dueAt} AS TIMESTAMPTZ),
2746
+ priority = CAST(${'medium'} AS crm_activity_priority_enum),
2747
+ updated_at = NOW()
2748
+ WHERE id = ${existing.id}
2749
+ `);
2750
+ return;
2751
+ }
2752
+ await tx.$executeRaw(api_prisma_1.Prisma.sql `
2753
+ INSERT INTO crm_activity (
2754
+ person_id,
2755
+ owner_user_id,
2756
+ created_by_user_id,
2757
+ type,
2758
+ subject,
2759
+ notes,
2760
+ due_at,
2761
+ priority,
2762
+ source_kind,
2763
+ created_at,
2764
+ updated_at
2765
+ )
2766
+ VALUES (
2767
+ ${personId},
2768
+ ${ownerUserId},
2769
+ ${actorUserId},
2770
+ CAST(${'task'} AS crm_activity_type_enum),
2771
+ ${this.getFollowupActivitySubject()},
2772
+ ${normalizedNotes},
2773
+ CAST(${dueAt} AS TIMESTAMPTZ),
2774
+ CAST(${'medium'} AS crm_activity_priority_enum),
2775
+ CAST(${'followup'} AS crm_activity_source_kind_enum),
2776
+ NOW(),
2777
+ NOW()
2778
+ )
2779
+ `);
2780
+ }
2781
+ async createCompletedInteractionActivity(tx, { personId, ownerUserId, interaction, actorUserId, }) {
2782
+ const completedAt = new Date(interaction.created_at);
2783
+ await tx.$executeRaw(api_prisma_1.Prisma.sql `
2784
+ INSERT INTO crm_activity (
2785
+ person_id,
2786
+ owner_user_id,
2787
+ created_by_user_id,
2788
+ completed_by_user_id,
2789
+ type,
2790
+ subject,
2791
+ notes,
2792
+ due_at,
2793
+ completed_at,
2794
+ priority,
2795
+ source_kind,
2796
+ created_at,
2797
+ updated_at
2798
+ )
2799
+ VALUES (
2800
+ ${personId},
2801
+ ${ownerUserId},
2802
+ ${actorUserId},
2803
+ ${actorUserId},
2804
+ CAST(${interaction.type} AS crm_activity_type_enum),
2805
+ ${this.getInteractionActivitySubject(interaction.type)},
2806
+ ${this.normalizeTextOrNull(interaction.notes)},
2807
+ CAST(${interaction.created_at} AS TIMESTAMPTZ),
2808
+ CAST(${interaction.created_at} AS TIMESTAMPTZ),
2809
+ CAST(${'medium'} AS crm_activity_priority_enum),
2810
+ CAST(${'interaction'} AS crm_activity_source_kind_enum),
2811
+ CAST(${interaction.created_at} AS TIMESTAMPTZ),
2812
+ NOW()
2813
+ )
2814
+ `);
2815
+ }
2816
+ getFollowupActivitySubject() {
2817
+ return 'Follow-up';
2818
+ }
2819
+ getInteractionActivitySubject(type) {
2820
+ switch (type) {
2821
+ case create_interaction_dto_1.PersonInteractionTypeDTO.CALL:
2822
+ return 'Call';
2823
+ case create_interaction_dto_1.PersonInteractionTypeDTO.EMAIL:
2824
+ return 'Email';
2825
+ case create_interaction_dto_1.PersonInteractionTypeDTO.WHATSAPP:
2826
+ return 'WhatsApp';
2827
+ case create_interaction_dto_1.PersonInteractionTypeDTO.MEETING:
2828
+ return 'Meeting';
2829
+ case create_interaction_dto_1.PersonInteractionTypeDTO.NOTE:
2830
+ default:
2831
+ return 'Note';
2832
+ }
2833
+ }
2834
+ async ensureCompanyAccountAccessible(id, locale) {
2835
+ const allowCompanyRegistration = await this.isCompanyRegistrationAllowed();
2836
+ if (!allowCompanyRegistration) {
2837
+ throw new common_1.NotFoundException((0, api_locale_1.getLocaleText)('personNotFound', locale, `Person with ID ${id} not found`));
2838
+ }
2839
+ const person = await this.prismaService.person.findUnique({
2840
+ where: { id },
2841
+ select: {
2842
+ id: true,
2843
+ name: true,
2844
+ status: true,
2845
+ type: true,
2846
+ },
2847
+ });
2848
+ if (!person || person.type !== 'company') {
2849
+ throw new common_1.BadRequestException((0, api_locale_1.getLocaleText)('personNotFound', locale, `Person with ID ${id} not found`));
2850
+ }
2851
+ return person;
2852
+ }
2853
+ async loadAccountPeopleByIds(personIds) {
2854
+ if (personIds.length === 0) {
2855
+ return [];
2856
+ }
2857
+ const people = await this.prismaService.person.findMany({
2858
+ where: {
2859
+ id: {
2860
+ in: personIds,
2861
+ },
2862
+ },
2863
+ include: {
2864
+ contact: {
2865
+ include: {
2866
+ contact_type: true,
2867
+ },
2868
+ },
2869
+ person_metadata: true,
2870
+ },
2871
+ });
2872
+ return this.enrichPeople(people, true);
2873
+ }
2874
+ mapAccountFromPerson(person, company) {
2875
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o;
2876
+ return {
2877
+ id: person.id,
2878
+ name: person.name,
2879
+ trade_name: (_a = company.trade_name) !== null && _a !== void 0 ? _a : null,
2880
+ status: person.status,
2881
+ industry: (_b = company.industry) !== null && _b !== void 0 ? _b : null,
2882
+ website: (_c = company.website) !== null && _c !== void 0 ? _c : null,
2883
+ email: this.getPrimaryAccountContactValue(person.contact, ['EMAIL']),
2884
+ phone: this.getPrimaryAccountContactValue(person.contact, [
2885
+ 'PHONE',
2886
+ 'MOBILE',
2887
+ 'WHATSAPP',
2888
+ ]),
2889
+ owner_user_id: (_d = person.owner_user_id) !== null && _d !== void 0 ? _d : null,
2890
+ owner_user: (_e = person.owner_user) !== null && _e !== void 0 ? _e : null,
2891
+ annual_revenue: company.annual_revenue == null ? null : Number(company.annual_revenue),
2892
+ employee_count: (_f = company.employee_count) !== null && _f !== void 0 ? _f : null,
2893
+ lifecycle_stage: (_g = company.account_lifecycle_stage) !== null && _g !== void 0 ? _g : null,
2894
+ city: (_h = company.city) !== null && _h !== void 0 ? _h : null,
2895
+ state: (_j = company.state) !== null && _j !== void 0 ? _j : null,
2896
+ created_at: (_m = (_l = (_k = person.created_at) === null || _k === void 0 ? void 0 : _k.toISOString) === null || _l === void 0 ? void 0 : _l.call(_k)) !== null && _m !== void 0 ? _m : String(person.created_at),
2897
+ last_interaction_at: (_o = this.normalizeDateTimeOrNull(person.last_interaction_at)) !== null && _o !== void 0 ? _o : null,
2898
+ };
2899
+ }
2900
+ getPrimaryAccountContactValue(contacts, codes) {
2901
+ var _a;
2902
+ const normalizedCodes = new Set(codes.map((code) => code.toUpperCase()));
2903
+ const items = Array.isArray(contacts)
2904
+ ? contacts.filter((contact) => {
2905
+ var _a;
2906
+ return normalizedCodes.has(String(((_a = contact === null || contact === void 0 ? void 0 : contact.contact_type) === null || _a === void 0 ? void 0 : _a.code) || '').toUpperCase());
2907
+ })
2908
+ : [];
2909
+ const primary = items.find((contact) => contact === null || contact === void 0 ? void 0 : contact.is_primary);
2910
+ const fallback = items[0];
2911
+ return this.normalizeTextOrNull((_a = primary === null || primary === void 0 ? void 0 : primary.value) !== null && _a !== void 0 ? _a : fallback === null || fallback === void 0 ? void 0 : fallback.value);
2912
+ }
2913
+ async upsertPrimaryAccountContact(tx, personId, code, value) {
2914
+ const normalizedValue = this.normalizeTextOrNull(value);
2915
+ const allowedCodes = code === 'PHONE' ? ['PHONE', 'MOBILE', 'WHATSAPP'] : ['EMAIL'];
2916
+ const type = await tx.contact_type.findFirst({
2917
+ where: {
2918
+ code: {
2919
+ equals: code,
2920
+ mode: 'insensitive',
2921
+ },
2922
+ },
2923
+ select: {
2924
+ id: true,
2925
+ },
2926
+ });
2927
+ if (!type) {
2928
+ return;
2929
+ }
2930
+ const existingContacts = await tx.contact.findMany({
2931
+ where: {
2932
+ person_id: personId,
2933
+ contact_type: {
2934
+ code: {
2935
+ in: allowedCodes,
2936
+ },
2937
+ },
2938
+ },
2939
+ orderBy: [{ is_primary: 'desc' }, { id: 'asc' }],
2940
+ select: {
2941
+ id: true,
2942
+ is_primary: true,
2943
+ },
2944
+ });
2945
+ if (!normalizedValue) {
2946
+ const contactToDelete = existingContacts[0];
2947
+ if (contactToDelete) {
2948
+ await tx.contact.delete({
2949
+ where: {
2950
+ id: contactToDelete.id,
2951
+ },
2952
+ });
2953
+ }
2954
+ return;
2955
+ }
2956
+ const primaryContact = existingContacts[0];
2957
+ if (primaryContact) {
2958
+ await tx.contact.update({
2959
+ where: {
2960
+ id: primaryContact.id,
2961
+ },
2962
+ data: {
2963
+ value: normalizedValue,
2964
+ is_primary: true,
2965
+ contact_type_id: type.id,
2966
+ },
2967
+ });
2968
+ const secondaryContacts = existingContacts
2969
+ .slice(1)
2970
+ .filter((contact) => contact.is_primary);
2971
+ if (secondaryContacts.length > 0) {
2972
+ await tx.contact.updateMany({
2973
+ where: {
2974
+ id: {
2975
+ in: secondaryContacts.map((contact) => contact.id),
2976
+ },
2977
+ },
2978
+ data: {
2979
+ is_primary: false,
2980
+ },
2981
+ });
2982
+ }
2983
+ return;
2984
+ }
2985
+ await tx.contact.create({
2986
+ data: {
2987
+ person_id: personId,
2988
+ contact_type_id: type.id,
2989
+ value: normalizedValue,
2990
+ is_primary: true,
2991
+ },
2992
+ });
2993
+ }
1439
2994
  async loadInteractionsFromTx(tx, personId) {
1440
2995
  const metadata = await tx.person_metadata.findFirst({
1441
2996
  where: {
@@ -1513,21 +3068,191 @@ let PersonService = class PersonService {
1513
3068
  }
1514
3069
  return filters;
1515
3070
  }
3071
+ buildAccountSqlFilters({ search, status, lifecycleStage, }) {
3072
+ const filters = [];
3073
+ if (status && status !== 'all') {
3074
+ filters.push(api_prisma_1.Prisma.sql `AND p.status = ${status}`);
3075
+ }
3076
+ if (lifecycleStage && lifecycleStage !== 'all') {
3077
+ filters.push(api_prisma_1.Prisma.sql `AND pc.account_lifecycle_stage = CAST(${lifecycleStage} AS person_company_account_lifecycle_stage_enum)`);
3078
+ }
3079
+ if (search) {
3080
+ const searchLike = `%${search}%`;
3081
+ const normalizedDigits = this.normalizeDigits(search);
3082
+ const digitsLike = `%${normalizedDigits}%`;
3083
+ filters.push(api_prisma_1.Prisma.sql `
3084
+ AND (
3085
+ p.name ILIKE ${searchLike}
3086
+ OR COALESCE(pc.trade_name, '') ILIKE ${searchLike}
3087
+ OR COALESCE(pc.city, '') ILIKE ${searchLike}
3088
+ OR COALESCE(pc.state, '') ILIKE ${searchLike}
3089
+ OR EXISTS (
3090
+ SELECT 1
3091
+ FROM contact c
3092
+ INNER JOIN contact_type ct ON ct.id = c.contact_type_id
3093
+ WHERE c.person_id = p.id
3094
+ AND UPPER(ct.code) = 'EMAIL'
3095
+ AND c.value ILIKE ${searchLike}
3096
+ )
3097
+ ${normalizedDigits.length > 0
3098
+ ? api_prisma_1.Prisma.sql `
3099
+ OR EXISTS (
3100
+ SELECT 1
3101
+ FROM contact c
3102
+ INNER JOIN contact_type ct ON ct.id = c.contact_type_id
3103
+ WHERE c.person_id = p.id
3104
+ AND UPPER(ct.code) IN ('PHONE', 'MOBILE', 'WHATSAPP')
3105
+ AND regexp_replace(COALESCE(c.value, ''), '[^0-9]+', '', 'g') ILIKE ${digitsLike}
3106
+ )
3107
+ `
3108
+ : api_prisma_1.Prisma.empty}
3109
+ )
3110
+ `);
3111
+ }
3112
+ return filters.length > 0 ? api_prisma_1.Prisma.join(filters, '\n') : api_prisma_1.Prisma.empty;
3113
+ }
3114
+ getAccountOrderBySql(sortField, sortOrder) {
3115
+ const normalizedSortField = sortField === 'created_at' ? 'created_at' : 'name';
3116
+ const normalizedSortOrder = sortOrder === 'desc' ? 'DESC' : 'ASC';
3117
+ if (normalizedSortField === 'created_at') {
3118
+ return normalizedSortOrder === 'DESC'
3119
+ ? api_prisma_1.Prisma.sql `p.created_at DESC`
3120
+ : api_prisma_1.Prisma.sql `p.created_at ASC`;
3121
+ }
3122
+ return normalizedSortOrder === 'DESC'
3123
+ ? api_prisma_1.Prisma.sql `LOWER(p.name) DESC`
3124
+ : api_prisma_1.Prisma.sql `LOWER(p.name) ASC`;
3125
+ }
3126
+ createEmptyAccountPagination(page, pageSize) {
3127
+ return {
3128
+ total: 0,
3129
+ lastPage: 1,
3130
+ page,
3131
+ pageSize,
3132
+ prev: page > 1 ? page - 1 : null,
3133
+ next: null,
3134
+ data: [],
3135
+ };
3136
+ }
3137
+ buildCrmActivitySqlFilters({ allowCompanyRegistration, search, status, type, priority, }) {
3138
+ const filters = [];
3139
+ if (!allowCompanyRegistration) {
3140
+ filters.push(api_prisma_1.Prisma.sql `AND p.type = 'individual'`);
3141
+ }
3142
+ if (status === 'pending') {
3143
+ filters.push(api_prisma_1.Prisma.sql `AND a.completed_at IS NULL AND a.due_at >= NOW()`);
3144
+ }
3145
+ else if (status === 'overdue') {
3146
+ filters.push(api_prisma_1.Prisma.sql `AND a.completed_at IS NULL AND a.due_at < NOW()`);
3147
+ }
3148
+ else if (status === 'completed') {
3149
+ filters.push(api_prisma_1.Prisma.sql `AND a.completed_at IS NOT NULL`);
3150
+ }
3151
+ if (type && type !== 'all') {
3152
+ filters.push(api_prisma_1.Prisma.sql `AND a.type = CAST(${type} AS crm_activity_type_enum)`);
3153
+ }
3154
+ if (priority && priority !== 'all') {
3155
+ filters.push(api_prisma_1.Prisma.sql `AND a.priority = CAST(${priority} AS crm_activity_priority_enum)`);
3156
+ }
3157
+ if (search) {
3158
+ const searchLike = `%${search}%`;
3159
+ filters.push(api_prisma_1.Prisma.sql `
3160
+ AND (
3161
+ a.subject ILIKE ${searchLike}
3162
+ OR COALESCE(a.notes, '') ILIKE ${searchLike}
3163
+ OR p.name ILIKE ${searchLike}
3164
+ OR COALESCE(owner_user.name, '') ILIKE ${searchLike}
3165
+ )
3166
+ `);
3167
+ }
3168
+ return filters.length > 0 ? api_prisma_1.Prisma.join(filters, '\n') : api_prisma_1.Prisma.empty;
3169
+ }
3170
+ createEmptyCrmActivityPagination(page, pageSize) {
3171
+ return {
3172
+ total: 0,
3173
+ lastPage: 1,
3174
+ page,
3175
+ pageSize,
3176
+ prev: page > 1 ? page - 1 : null,
3177
+ next: null,
3178
+ data: [],
3179
+ };
3180
+ }
3181
+ mapCrmActivityListRow(row) {
3182
+ var _a, _b;
3183
+ const completedAt = this.normalizeDateTimeOrNull(row.completed_at);
3184
+ const dueAt = (_a = this.normalizeDateTimeOrNull(row.due_at)) !== null && _a !== void 0 ? _a : new Date().toISOString();
3185
+ const ownerUserId = this.coerceNumber(row.owner_user_id) || null;
3186
+ return {
3187
+ id: this.coerceNumber(row.id),
3188
+ person_id: this.coerceNumber(row.person_id),
3189
+ person: {
3190
+ id: this.coerceNumber(row.person_id),
3191
+ name: this.normalizeTextOrNull(row.person_name) || `#${row.person_id}`,
3192
+ type: this.normalizeTextOrNull(row.person_type) === 'company'
3193
+ ? 'company'
3194
+ : 'individual',
3195
+ status: this.normalizeTextOrNull(row.person_status) === 'inactive'
3196
+ ? 'inactive'
3197
+ : 'active',
3198
+ trade_name: this.normalizeTextOrNull(row.person_trade_name),
3199
+ },
3200
+ owner_user_id: ownerUserId,
3201
+ owner_user: ownerUserId
3202
+ ? {
3203
+ id: ownerUserId,
3204
+ name: this.normalizeTextOrNull(row.owner_user_name) || `#${ownerUserId}`,
3205
+ }
3206
+ : null,
3207
+ type: (this.normalizeTextOrNull(row.type) || 'task'),
3208
+ subject: this.normalizeTextOrNull(row.subject) || 'Activity',
3209
+ notes: this.normalizeTextOrNull(row.notes),
3210
+ due_at: dueAt,
3211
+ completed_at: completedAt,
3212
+ created_at: (_b = this.normalizeDateTimeOrNull(row.created_at)) !== null && _b !== void 0 ? _b : new Date().toISOString(),
3213
+ priority: (this.normalizeTextOrNull(row.priority) || 'medium'),
3214
+ status: this.getCrmActivityStatus(dueAt, completedAt),
3215
+ };
3216
+ }
3217
+ mapCrmActivityDetailRow(row) {
3218
+ const base = this.mapCrmActivityListRow(row);
3219
+ const createdByUserId = this.coerceNumber(row.created_by_user_id) || null;
3220
+ const completedByUserId = this.coerceNumber(row.completed_by_user_id) || null;
3221
+ return Object.assign(Object.assign({}, base), { source_kind: (this.normalizeTextOrNull(row.source_kind) || 'manual'), created_by_user_id: createdByUserId, created_by_user: createdByUserId
3222
+ ? {
3223
+ id: createdByUserId,
3224
+ name: this.normalizeTextOrNull(row.created_by_user_name) ||
3225
+ `#${createdByUserId}`,
3226
+ }
3227
+ : null, completed_by_user_id: completedByUserId, completed_by_user: completedByUserId
3228
+ ? {
3229
+ id: completedByUserId,
3230
+ name: this.normalizeTextOrNull(row.completed_by_user_name) ||
3231
+ `#${completedByUserId}`,
3232
+ }
3233
+ : null });
3234
+ }
3235
+ getCrmActivityStatus(dueAt, completedAt) {
3236
+ if (completedAt) {
3237
+ return 'completed';
3238
+ }
3239
+ return new Date(dueAt) < new Date() ? 'overdue' : 'pending';
3240
+ }
1516
3241
  normalizeDigits(value) {
1517
3242
  return value.replace(/\D/g, '');
1518
3243
  }
1519
3244
  async findPersonIdsByNormalizedDigits(normalizedDigits) {
1520
3245
  const likeValue = `%${normalizedDigits}%`;
1521
- const rows = await this.prismaService.$queryRaw(api_prisma_1.Prisma.sql `
1522
- SELECT DISTINCT p.id
1523
- FROM person p
1524
- LEFT JOIN contact c ON c.person_id = p.id
1525
- LEFT JOIN document d ON d.person_id = p.id
1526
- LEFT JOIN person_address pa ON pa.person_id = p.id
1527
- LEFT JOIN address a ON a.id = pa.address_id
1528
- WHERE regexp_replace(COALESCE(c.value, ''), '[^0-9]+', '', 'g') ILIKE ${likeValue}
1529
- OR regexp_replace(COALESCE(d.value, ''), '[^0-9]+', '', 'g') ILIKE ${likeValue}
1530
- OR regexp_replace(COALESCE(a.postal_code, ''), '[^0-9]+', '', 'g') ILIKE ${likeValue}
3246
+ const rows = await this.prismaService.$queryRaw(api_prisma_1.Prisma.sql `
3247
+ SELECT DISTINCT p.id
3248
+ FROM person p
3249
+ LEFT JOIN contact c ON c.person_id = p.id
3250
+ LEFT JOIN document d ON d.person_id = p.id
3251
+ LEFT JOIN person_address pa ON pa.person_id = p.id
3252
+ LEFT JOIN address a ON a.id = pa.address_id
3253
+ WHERE regexp_replace(COALESCE(c.value, ''), '[^0-9]+', '', 'g') ILIKE ${likeValue}
3254
+ OR regexp_replace(COALESCE(d.value, ''), '[^0-9]+', '', 'g') ILIKE ${likeValue}
3255
+ OR regexp_replace(COALESCE(a.postal_code, ''), '[^0-9]+', '', 'g') ILIKE ${likeValue}
1531
3256
  `);
1532
3257
  return rows.map((row) => row.id);
1533
3258
  }