@hed-hog/contact 0.0.294 → 0.0.295

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 (46) 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/person.controller.d.ts +204 -0
  18. package/dist/person/person.controller.d.ts.map +1 -1
  19. package/dist/person/person.controller.js +138 -0
  20. package/dist/person/person.controller.js.map +1 -1
  21. package/dist/person/person.service.d.ts +234 -0
  22. package/dist/person/person.service.d.ts.map +1 -1
  23. package/dist/person/person.service.js +1367 -0
  24. package/dist/person/person.service.js.map +1 -1
  25. package/hedhog/data/menu.yaml +163 -163
  26. package/hedhog/data/route.yaml +41 -0
  27. package/hedhog/frontend/app/accounts/_components/account-form-sheet.tsx.ejs +210 -114
  28. package/hedhog/frontend/app/accounts/_components/account-types.ts.ejs +3 -0
  29. package/hedhog/frontend/app/accounts/page.tsx.ejs +323 -245
  30. package/hedhog/frontend/app/activities/_components/activity-detail-sheet.tsx.ejs +240 -0
  31. package/hedhog/frontend/app/activities/_components/activity-types.ts.ejs +66 -0
  32. package/hedhog/frontend/app/activities/page.tsx.ejs +165 -517
  33. package/hedhog/frontend/app/dashboard/_components/dashboard-types.ts.ejs +70 -0
  34. package/hedhog/frontend/app/dashboard/page.tsx.ejs +504 -356
  35. package/hedhog/frontend/app/follow-ups/page.tsx.ejs +242 -153
  36. package/hedhog/frontend/messages/en.json +91 -6
  37. package/hedhog/frontend/messages/pt.json +91 -6
  38. package/hedhog/table/crm_activity.yaml +68 -0
  39. package/hedhog/table/person_company.yaml +22 -0
  40. package/package.json +4 -4
  41. package/src/person/dto/account.dto.ts +100 -0
  42. package/src/person/dto/activity.dto.ts +54 -0
  43. package/src/person/dto/dashboard-query.dto.ts +25 -0
  44. package/src/person/dto/followup-query.dto.ts +25 -0
  45. package/src/person/person.controller.ts +116 -0
  46. package/src/person/person.service.ts +2139 -77
@@ -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,23 @@ 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
+ ];
41
59
  let PersonService = class PersonService {
42
60
  constructor(prismaService, paginationService, fileService, settingService) {
43
61
  this.prismaService = prismaService;
@@ -80,6 +98,105 @@ let PersonService = class PersonService {
80
98
  inactive,
81
99
  };
82
100
  }
101
+ async getDashboard(query, locale) {
102
+ const allowCompanyRegistration = await this.isCompanyRegistrationAllowed();
103
+ const ownerUserId = this.coerceNumber(query.owner_user_id);
104
+ const ranges = this.resolveDashboardRanges(query, locale);
105
+ const where = {};
106
+ if (!allowCompanyRegistration) {
107
+ where.type = 'individual';
108
+ }
109
+ if (ownerUserId > 0) {
110
+ where.AND = [
111
+ {
112
+ person_metadata: {
113
+ some: {
114
+ key: OWNER_USER_METADATA_KEY,
115
+ value: ownerUserId,
116
+ },
117
+ },
118
+ },
119
+ ];
120
+ }
121
+ const people = await this.prismaService.person.findMany({
122
+ where,
123
+ select: {
124
+ id: true,
125
+ name: true,
126
+ type: true,
127
+ status: true,
128
+ avatar_id: true,
129
+ created_at: true,
130
+ updated_at: true,
131
+ person_metadata: true,
132
+ },
133
+ orderBy: {
134
+ created_at: 'desc',
135
+ },
136
+ });
137
+ const enriched = await this.enrichPeople(people, allowCompanyRegistration);
138
+ const createdScoped = enriched.filter((person) => this.isDateWithinRange(person.created_at, ranges.created));
139
+ const nextActionScoped = enriched.filter((person) => !!person.next_action_at &&
140
+ this.isDateWithinRange(person.next_action_at, ranges.operational));
141
+ const kpis = {
142
+ total_leads: createdScoped.length,
143
+ qualified: createdScoped.filter((person) => {
144
+ var _a;
145
+ return ['qualified', 'proposal', 'negotiation', 'customer'].includes((_a = person.lifecycle_stage) !== null && _a !== void 0 ? _a : 'new');
146
+ }).length,
147
+ proposal: createdScoped.filter((person) => person.lifecycle_stage === 'proposal').length,
148
+ customers: createdScoped.filter((person) => person.lifecycle_stage === 'customer').length,
149
+ lost: createdScoped.filter((person) => person.lifecycle_stage === 'lost')
150
+ .length,
151
+ unassigned: createdScoped.filter((person) => !person.owner_user_id).length,
152
+ overdue: nextActionScoped.filter((person) => {
153
+ const nextActionAt = this.parseDateOrNull(person.next_action_at);
154
+ return !!nextActionAt && nextActionAt.getTime() < Date.now();
155
+ }).length,
156
+ next_actions: nextActionScoped.length,
157
+ };
158
+ const charts = {
159
+ stage: CRM_DASHBOARD_STAGE_ORDER.map((key) => ({
160
+ key,
161
+ total: createdScoped.filter((person) => { var _a; return ((_a = person.lifecycle_stage) !== null && _a !== void 0 ? _a : 'new') === key; }).length,
162
+ })),
163
+ source: CRM_DASHBOARD_SOURCE_ORDER.map((key) => ({
164
+ key,
165
+ total: createdScoped.filter((person) => { var _a; return ((_a = person.source) !== null && _a !== void 0 ? _a : 'other') === key; }).length,
166
+ })),
167
+ owner_performance: this.buildDashboardOwnerPerformance(createdScoped),
168
+ };
169
+ const lists = {
170
+ next_actions: [...nextActionScoped]
171
+ .sort((left, right) => {
172
+ var _a, _b;
173
+ return new Date((_a = left.next_action_at) !== null && _a !== void 0 ? _a : 0).getTime() -
174
+ new Date((_b = right.next_action_at) !== null && _b !== void 0 ? _b : 0).getTime();
175
+ })
176
+ .slice(0, 5)
177
+ .map((person) => this.mapDashboardListItem(person, {
178
+ includeCreatedAt: false,
179
+ includeNextActionAt: true,
180
+ })),
181
+ unattended: [...createdScoped]
182
+ .filter((person) => { var _a; return !person.owner_user_id || ((_a = person.lifecycle_stage) !== null && _a !== void 0 ? _a : 'new') === 'new'; })
183
+ .sort((left, right) => {
184
+ var _a, _b;
185
+ return new Date((_a = right.created_at) !== null && _a !== void 0 ? _a : 0).getTime() -
186
+ new Date((_b = left.created_at) !== null && _b !== void 0 ? _b : 0).getTime();
187
+ })
188
+ .slice(0, 5)
189
+ .map((person) => this.mapDashboardListItem(person, {
190
+ includeCreatedAt: true,
191
+ includeNextActionAt: false,
192
+ })),
193
+ };
194
+ return {
195
+ kpis,
196
+ charts,
197
+ lists,
198
+ };
199
+ }
83
200
  async getOwnerOptions(currentUserId) {
84
201
  const where = {
85
202
  OR: [
@@ -347,6 +464,517 @@ let PersonService = class PersonService {
347
464
  const enriched = await this.enrichPeople(result.data, allowCompanyRegistration);
348
465
  return Object.assign(Object.assign({}, result), { data: enriched });
349
466
  }
467
+ async listAccounts(paginationParams) {
468
+ var _a;
469
+ const allowCompanyRegistration = await this.isCompanyRegistrationAllowed();
470
+ const page = Math.max(Number(paginationParams.page || 1), 1);
471
+ const pageSize = Math.max(Number(paginationParams.pageSize || 12), 1);
472
+ const skip = (page - 1) * pageSize;
473
+ if (!allowCompanyRegistration) {
474
+ return this.createEmptyAccountPagination(page, pageSize);
475
+ }
476
+ const search = this.normalizeTextOrNull(paginationParams.search);
477
+ const filters = this.buildAccountSqlFilters({
478
+ search,
479
+ status: paginationParams.status,
480
+ lifecycleStage: paginationParams.lifecycle_stage,
481
+ });
482
+ const orderBy = this.getAccountOrderBySql(paginationParams.sortField, paginationParams.sortOrder);
483
+ const totalRows = await this.prismaService.$queryRaw(api_prisma_1.Prisma.sql `
484
+ SELECT COUNT(*) AS total
485
+ FROM person p
486
+ INNER JOIN person_company pc ON pc.id = p.id
487
+ WHERE p.type = 'company'
488
+ ${filters}
489
+ `);
490
+ const total = this.coerceCount((_a = totalRows[0]) === null || _a === void 0 ? void 0 : _a.total);
491
+ if (total === 0) {
492
+ return this.createEmptyAccountPagination(page, pageSize);
493
+ }
494
+ const rows = await this.prismaService.$queryRaw(api_prisma_1.Prisma.sql `
495
+ SELECT p.id AS person_id
496
+ FROM person p
497
+ INNER JOIN person_company pc ON pc.id = p.id
498
+ WHERE p.type = 'company'
499
+ ${filters}
500
+ ORDER BY ${orderBy}, p.id ASC
501
+ LIMIT ${pageSize}
502
+ OFFSET ${skip}
503
+ `);
504
+ const personIds = rows.map((row) => row.person_id).filter((id) => id > 0);
505
+ if (personIds.length === 0) {
506
+ return this.createEmptyAccountPagination(page, pageSize);
507
+ }
508
+ const people = await this.loadAccountPeopleByIds(personIds);
509
+ const companies = await this.prismaService.person_company.findMany({
510
+ where: {
511
+ id: {
512
+ in: personIds,
513
+ },
514
+ },
515
+ });
516
+ const personById = new Map(people.map((item) => [item.id, item]));
517
+ const companyById = new Map(companies.map((item) => [item.id, item]));
518
+ const data = rows
519
+ .map((row) => {
520
+ const person = personById.get(row.person_id);
521
+ const company = companyById.get(row.person_id);
522
+ if (!person || !company) {
523
+ return null;
524
+ }
525
+ return this.mapAccountFromPerson(person, company);
526
+ })
527
+ .filter((item) => item != null);
528
+ const lastPage = Math.max(1, Math.ceil(total / pageSize));
529
+ return {
530
+ total,
531
+ lastPage,
532
+ page,
533
+ pageSize,
534
+ prev: page > 1 ? page - 1 : null,
535
+ next: page < lastPage ? page + 1 : null,
536
+ data,
537
+ };
538
+ }
539
+ async getAccountStats() {
540
+ var _a, _b, _c, _d;
541
+ const allowCompanyRegistration = await this.isCompanyRegistrationAllowed();
542
+ if (!allowCompanyRegistration) {
543
+ return {
544
+ total: 0,
545
+ active: 0,
546
+ customers: 0,
547
+ prospects: 0,
548
+ };
549
+ }
550
+ const rows = await this.prismaService.$queryRaw(api_prisma_1.Prisma.sql `
551
+ SELECT
552
+ COUNT(*) AS total,
553
+ COUNT(*) FILTER (WHERE p.status = 'active') AS active,
554
+ COUNT(*) FILTER (WHERE pc.account_lifecycle_stage = 'customer') AS customers,
555
+ COUNT(*) FILTER (WHERE pc.account_lifecycle_stage = 'prospect') AS prospects
556
+ FROM person p
557
+ INNER JOIN person_company pc ON pc.id = p.id
558
+ WHERE p.type = 'company'
559
+ `);
560
+ return {
561
+ total: this.coerceCount((_a = rows[0]) === null || _a === void 0 ? void 0 : _a.total),
562
+ active: this.coerceCount((_b = rows[0]) === null || _b === void 0 ? void 0 : _b.active),
563
+ customers: this.coerceCount((_c = rows[0]) === null || _c === void 0 ? void 0 : _c.customers),
564
+ prospects: this.coerceCount((_d = rows[0]) === null || _d === void 0 ? void 0 : _d.prospects),
565
+ };
566
+ }
567
+ async createAccount(data, locale) {
568
+ await this.ensureCompanyRegistrationAllowed({
569
+ nextType: 'company',
570
+ locale,
571
+ });
572
+ const normalizedName = this.normalizeTextOrNull(data.name);
573
+ if (!normalizedName) {
574
+ throw new common_1.BadRequestException((0, api_locale_1.getLocaleText)('validation.nameMustBeString', locale, 'Name is required.'));
575
+ }
576
+ return this.prismaService.$transaction(async (tx) => {
577
+ const person = await tx.person.create({
578
+ data: {
579
+ name: normalizedName,
580
+ type: 'company',
581
+ status: data.status,
582
+ },
583
+ });
584
+ await this.syncPersonSubtypeData(tx, person.id, null, Object.assign(Object.assign({}, data), { type: 'company', name: normalizedName }), locale);
585
+ await this.syncPersonMetadata(tx, person.id, {
586
+ owner_user_id: data.owner_user_id,
587
+ });
588
+ await this.upsertPrimaryAccountContact(tx, person.id, 'EMAIL', data.email);
589
+ await this.upsertPrimaryAccountContact(tx, person.id, 'PHONE', data.phone);
590
+ return {
591
+ success: true,
592
+ id: person.id,
593
+ };
594
+ });
595
+ }
596
+ async updateAccount(id, data, locale) {
597
+ var _a;
598
+ await this.ensureCompanyRegistrationAllowed({
599
+ currentType: 'company',
600
+ nextType: 'company',
601
+ locale,
602
+ });
603
+ const person = await this.ensureCompanyAccountAccessible(id, locale);
604
+ const nextName = (_a = this.normalizeTextOrNull(data.name)) !== null && _a !== void 0 ? _a : person.name;
605
+ return this.prismaService.$transaction(async (tx) => {
606
+ var _a;
607
+ await tx.person.update({
608
+ where: { id },
609
+ data: {
610
+ name: nextName,
611
+ type: 'company',
612
+ status: (_a = data.status) !== null && _a !== void 0 ? _a : person.status,
613
+ },
614
+ });
615
+ await this.syncPersonSubtypeData(tx, id, 'company', Object.assign(Object.assign({}, data), { type: 'company', name: nextName }), locale);
616
+ await this.syncPersonMetadata(tx, id, {
617
+ owner_user_id: data.owner_user_id,
618
+ });
619
+ await this.upsertPrimaryAccountContact(tx, id, 'EMAIL', data.email);
620
+ await this.upsertPrimaryAccountContact(tx, id, 'PHONE', data.phone);
621
+ return {
622
+ success: true,
623
+ id,
624
+ };
625
+ });
626
+ }
627
+ async deleteAccounts({ ids }, locale) {
628
+ if (ids == undefined || ids == null) {
629
+ throw new common_1.BadRequestException((0, api_locale_1.getLocaleText)('deleteItemsRequired', locale, 'You must select at least one item to delete.'));
630
+ }
631
+ const companies = await this.prismaService.person.findMany({
632
+ where: {
633
+ id: {
634
+ in: ids,
635
+ },
636
+ },
637
+ select: {
638
+ id: true,
639
+ type: true,
640
+ },
641
+ });
642
+ const existingIds = companies.map((item) => item.id);
643
+ const missingIds = ids.filter((itemId) => !existingIds.includes(itemId));
644
+ if (missingIds.length > 0) {
645
+ throw new common_1.BadRequestException((0, api_locale_1.getLocaleText)('personNotFound', locale, `Person(s) with ID(s) ${missingIds.join(', ')} not found.`));
646
+ }
647
+ const invalidIds = companies
648
+ .filter((item) => item.type !== 'company')
649
+ .map((item) => item.id);
650
+ if (invalidIds.length > 0) {
651
+ throw new common_1.BadRequestException((0, api_locale_1.getLocaleText)('companyRelationInvalidTarget', locale, 'Only company records can be managed as accounts.'));
652
+ }
653
+ return this.delete({ ids }, locale);
654
+ }
655
+ async listActivities(paginationParams) {
656
+ var _a;
657
+ const allowCompanyRegistration = await this.isCompanyRegistrationAllowed();
658
+ const page = Math.max(Number(paginationParams.page || 1), 1);
659
+ const pageSize = Math.max(Number(paginationParams.pageSize || 12), 1);
660
+ const skip = (page - 1) * pageSize;
661
+ const filters = this.buildCrmActivitySqlFilters({
662
+ allowCompanyRegistration,
663
+ search: this.normalizeTextOrNull(paginationParams.search),
664
+ status: paginationParams.status,
665
+ type: paginationParams.type,
666
+ priority: paginationParams.priority,
667
+ });
668
+ const totalRows = await this.prismaService.$queryRaw(api_prisma_1.Prisma.sql `
669
+ SELECT COUNT(*) AS total
670
+ FROM crm_activity a
671
+ INNER JOIN person p ON p.id = a.person_id
672
+ LEFT JOIN "user" owner_user ON owner_user.id = a.owner_user_id
673
+ WHERE 1 = 1
674
+ ${filters}
675
+ `);
676
+ const total = this.coerceCount((_a = totalRows[0]) === null || _a === void 0 ? void 0 : _a.total);
677
+ if (total === 0) {
678
+ return this.createEmptyCrmActivityPagination(page, pageSize);
679
+ }
680
+ const rows = await this.prismaService.$queryRaw(api_prisma_1.Prisma.sql `
681
+ SELECT
682
+ a.id,
683
+ a.person_id,
684
+ a.owner_user_id,
685
+ a.type,
686
+ a.subject,
687
+ a.notes,
688
+ a.due_at,
689
+ a.completed_at,
690
+ a.created_at,
691
+ a.priority,
692
+ p.name AS person_name,
693
+ p.type AS person_type,
694
+ p.status AS person_status,
695
+ pc.trade_name AS person_trade_name,
696
+ owner_user.name AS owner_user_name
697
+ FROM crm_activity a
698
+ INNER JOIN person p ON p.id = a.person_id
699
+ LEFT JOIN person_company pc ON pc.id = p.id
700
+ LEFT JOIN "user" owner_user ON owner_user.id = a.owner_user_id
701
+ WHERE 1 = 1
702
+ ${filters}
703
+ ORDER BY a.due_at ASC, a.id ASC
704
+ LIMIT ${pageSize}
705
+ OFFSET ${skip}
706
+ `);
707
+ const data = rows.map((row) => this.mapCrmActivityListRow(row));
708
+ const lastPage = Math.max(1, Math.ceil(total / pageSize));
709
+ return {
710
+ total,
711
+ lastPage,
712
+ page,
713
+ pageSize,
714
+ prev: page > 1 ? page - 1 : null,
715
+ next: page < lastPage ? page + 1 : null,
716
+ data,
717
+ };
718
+ }
719
+ async getActivityStats() {
720
+ var _a, _b, _c, _d;
721
+ const allowCompanyRegistration = await this.isCompanyRegistrationAllowed();
722
+ const visibilityFilter = allowCompanyRegistration
723
+ ? api_prisma_1.Prisma.empty
724
+ : api_prisma_1.Prisma.sql `AND p.type = 'individual'`;
725
+ const rows = await this.prismaService.$queryRaw(api_prisma_1.Prisma.sql `
726
+ SELECT
727
+ COUNT(*) AS total,
728
+ COUNT(*) FILTER (
729
+ WHERE a.completed_at IS NULL
730
+ AND a.due_at >= NOW()
731
+ ) AS pending,
732
+ COUNT(*) FILTER (
733
+ WHERE a.completed_at IS NULL
734
+ AND a.due_at < NOW()
735
+ ) AS overdue,
736
+ COUNT(*) FILTER (
737
+ WHERE a.completed_at IS NOT NULL
738
+ ) AS completed
739
+ FROM crm_activity a
740
+ INNER JOIN person p ON p.id = a.person_id
741
+ WHERE 1 = 1
742
+ ${visibilityFilter}
743
+ `);
744
+ return {
745
+ total: this.coerceCount((_a = rows[0]) === null || _a === void 0 ? void 0 : _a.total),
746
+ pending: this.coerceCount((_b = rows[0]) === null || _b === void 0 ? void 0 : _b.pending),
747
+ overdue: this.coerceCount((_c = rows[0]) === null || _c === void 0 ? void 0 : _c.overdue),
748
+ completed: this.coerceCount((_d = rows[0]) === null || _d === void 0 ? void 0 : _d.completed),
749
+ };
750
+ }
751
+ async getActivity(id, locale) {
752
+ const allowCompanyRegistration = await this.isCompanyRegistrationAllowed();
753
+ const visibilityFilter = allowCompanyRegistration
754
+ ? api_prisma_1.Prisma.empty
755
+ : api_prisma_1.Prisma.sql `AND p.type = 'individual'`;
756
+ const rows = await this.prismaService.$queryRaw(api_prisma_1.Prisma.sql `
757
+ SELECT
758
+ a.id,
759
+ a.person_id,
760
+ a.owner_user_id,
761
+ a.created_by_user_id,
762
+ a.completed_by_user_id,
763
+ a.type,
764
+ a.subject,
765
+ a.notes,
766
+ a.due_at,
767
+ a.completed_at,
768
+ a.created_at,
769
+ a.priority,
770
+ a.source_kind,
771
+ p.name AS person_name,
772
+ p.type AS person_type,
773
+ p.status AS person_status,
774
+ pc.trade_name AS person_trade_name,
775
+ owner_user.name AS owner_user_name,
776
+ created_by_user.name AS created_by_user_name,
777
+ completed_by_user.name AS completed_by_user_name
778
+ FROM crm_activity a
779
+ INNER JOIN person p ON p.id = a.person_id
780
+ LEFT JOIN person_company pc ON pc.id = p.id
781
+ LEFT JOIN "user" owner_user ON owner_user.id = a.owner_user_id
782
+ LEFT JOIN "user" created_by_user ON created_by_user.id = a.created_by_user_id
783
+ LEFT JOIN "user" completed_by_user ON completed_by_user.id = a.completed_by_user_id
784
+ WHERE a.id = ${id}
785
+ ${visibilityFilter}
786
+ LIMIT 1
787
+ `);
788
+ const row = rows[0];
789
+ if (!row) {
790
+ throw new common_1.NotFoundException((0, api_locale_1.getLocaleText)('personNotFound', locale, `Activity with ID ${id} not found`));
791
+ }
792
+ return this.mapCrmActivityDetailRow(row);
793
+ }
794
+ async completeActivity(id, locale, user) {
795
+ const actorUserId = Number((user === null || user === void 0 ? void 0 : user.id) || 0) || null;
796
+ const allowCompanyRegistration = await this.isCompanyRegistrationAllowed();
797
+ const visibilityFilter = allowCompanyRegistration
798
+ ? api_prisma_1.Prisma.empty
799
+ : api_prisma_1.Prisma.sql `AND p.type = 'individual'`;
800
+ return this.prismaService.$transaction(async (tx) => {
801
+ const rows = (await tx.$queryRaw(api_prisma_1.Prisma.sql `
802
+ SELECT
803
+ a.id,
804
+ a.person_id,
805
+ a.completed_at,
806
+ a.source_kind
807
+ FROM crm_activity a
808
+ INNER JOIN person p ON p.id = a.person_id
809
+ WHERE a.id = ${id}
810
+ ${visibilityFilter}
811
+ LIMIT 1
812
+ `));
813
+ const activity = rows[0];
814
+ if (!activity) {
815
+ throw new common_1.NotFoundException((0, api_locale_1.getLocaleText)('personNotFound', locale, `Activity with ID ${id} not found`));
816
+ }
817
+ if (activity.completed_at) {
818
+ return {
819
+ success: true,
820
+ completed_at: activity.completed_at.toISOString(),
821
+ };
822
+ }
823
+ const completedAt = new Date();
824
+ await tx.$executeRaw(api_prisma_1.Prisma.sql `
825
+ UPDATE crm_activity
826
+ SET
827
+ completed_at = CAST(${completedAt.toISOString()} AS TIMESTAMPTZ),
828
+ completed_by_user_id = ${actorUserId},
829
+ updated_at = NOW()
830
+ WHERE id = ${id}
831
+ `);
832
+ if (activity.source_kind === 'followup') {
833
+ await this.upsertMetadataValue(tx, activity.person_id, NEXT_ACTION_AT_METADATA_KEY, null);
834
+ }
835
+ return {
836
+ success: true,
837
+ completed_at: completedAt.toISOString(),
838
+ };
839
+ });
840
+ }
841
+ async listFollowups(paginationParams, _currentUserId) {
842
+ var _a;
843
+ const allowCompanyRegistration = await this.isCompanyRegistrationAllowed();
844
+ const page = Math.max(Number(paginationParams.page || 1), 1);
845
+ const pageSize = Math.max(Number(paginationParams.pageSize || 12), 1);
846
+ const skip = (page - 1) * pageSize;
847
+ const searchPersonIds = await this.findFollowupSearchPersonIds(paginationParams.search, allowCompanyRegistration);
848
+ if (searchPersonIds && searchPersonIds.length === 0) {
849
+ return this.createEmptyFollowupPagination(page, pageSize);
850
+ }
851
+ const filters = this.buildFollowupSqlFilters({
852
+ allowCompanyRegistration,
853
+ searchPersonIds,
854
+ status: paginationParams.status,
855
+ dateFrom: paginationParams.date_from,
856
+ dateTo: paginationParams.date_to,
857
+ });
858
+ const followupTimestampSql = this.getFollowupTimestampSql();
859
+ const totalRows = await this.prismaService.$queryRaw(api_prisma_1.Prisma.sql `
860
+ SELECT COUNT(*) AS total
861
+ FROM person p
862
+ INNER JOIN person_metadata pm_next
863
+ ON pm_next.person_id = p.id
864
+ AND pm_next.key = ${NEXT_ACTION_AT_METADATA_KEY}
865
+ WHERE 1 = 1
866
+ ${filters}
867
+ `);
868
+ const total = this.coerceCount((_a = totalRows[0]) === null || _a === void 0 ? void 0 : _a.total);
869
+ if (total === 0) {
870
+ return this.createEmptyFollowupPagination(page, pageSize);
871
+ }
872
+ const rows = await this.prismaService.$queryRaw(api_prisma_1.Prisma.sql `
873
+ SELECT p.id AS person_id
874
+ FROM person p
875
+ INNER JOIN person_metadata pm_next
876
+ ON pm_next.person_id = p.id
877
+ AND pm_next.key = ${NEXT_ACTION_AT_METADATA_KEY}
878
+ WHERE 1 = 1
879
+ ${filters}
880
+ ORDER BY ${followupTimestampSql} ASC, p.id ASC
881
+ LIMIT ${pageSize}
882
+ OFFSET ${skip}
883
+ `);
884
+ const personIds = rows.map((row) => row.person_id).filter((id) => id > 0);
885
+ const people = personIds.length > 0
886
+ ? await this.prismaService.person.findMany({
887
+ where: {
888
+ id: {
889
+ in: personIds,
890
+ },
891
+ },
892
+ include: {
893
+ person_address: {
894
+ include: {
895
+ address: true,
896
+ },
897
+ },
898
+ contact: true,
899
+ document: true,
900
+ person_metadata: true,
901
+ },
902
+ })
903
+ : [];
904
+ const enrichedPeople = await this.enrichPeople(people, allowCompanyRegistration);
905
+ const personById = new Map(enrichedPeople.map((person) => [person.id, person]));
906
+ const data = rows
907
+ .map((row) => {
908
+ var _a;
909
+ const person = personById.get(row.person_id);
910
+ const nextActionAt = this.normalizeDateTimeOrNull(person === null || person === void 0 ? void 0 : person.next_action_at);
911
+ if (!person || !nextActionAt) {
912
+ return null;
913
+ }
914
+ return {
915
+ person,
916
+ next_action_at: nextActionAt,
917
+ last_interaction_at: (_a = this.normalizeDateTimeOrNull(person.last_interaction_at)) !== null && _a !== void 0 ? _a : null,
918
+ status: this.getFollowupStatus(nextActionAt),
919
+ };
920
+ })
921
+ .filter((item) => item != null);
922
+ const lastPage = Math.max(1, Math.ceil(total / pageSize));
923
+ return {
924
+ total,
925
+ lastPage,
926
+ page,
927
+ pageSize,
928
+ prev: page > 1 ? page - 1 : null,
929
+ next: page < lastPage ? page + 1 : null,
930
+ data,
931
+ };
932
+ }
933
+ async getFollowupStats(query, _currentUserId) {
934
+ var _a, _b, _c, _d;
935
+ const allowCompanyRegistration = await this.isCompanyRegistrationAllowed();
936
+ const searchPersonIds = await this.findFollowupSearchPersonIds(query.search, allowCompanyRegistration);
937
+ if (searchPersonIds && searchPersonIds.length === 0) {
938
+ return {
939
+ total: 0,
940
+ today: 0,
941
+ overdue: 0,
942
+ upcoming: 0,
943
+ };
944
+ }
945
+ const filters = this.buildFollowupSqlFilters({
946
+ allowCompanyRegistration,
947
+ searchPersonIds,
948
+ });
949
+ const followupTimestampSql = this.getFollowupTimestampSql();
950
+ const { todayStartIso, tomorrowStartIso } = this.getFollowupDayBoundaryIsoStrings();
951
+ const rows = await this.prismaService.$queryRaw(api_prisma_1.Prisma.sql `
952
+ SELECT
953
+ COUNT(*) AS total,
954
+ COUNT(*) FILTER (
955
+ WHERE ${followupTimestampSql} >= CAST(${todayStartIso} AS TIMESTAMPTZ)
956
+ AND ${followupTimestampSql} < CAST(${tomorrowStartIso} AS TIMESTAMPTZ)
957
+ ) AS today,
958
+ COUNT(*) FILTER (
959
+ WHERE ${followupTimestampSql} < CAST(${todayStartIso} AS TIMESTAMPTZ)
960
+ ) AS overdue,
961
+ COUNT(*) FILTER (
962
+ WHERE ${followupTimestampSql} >= CAST(${tomorrowStartIso} AS TIMESTAMPTZ)
963
+ ) AS upcoming
964
+ FROM person p
965
+ INNER JOIN person_metadata pm_next
966
+ ON pm_next.person_id = p.id
967
+ AND pm_next.key = ${NEXT_ACTION_AT_METADATA_KEY}
968
+ WHERE 1 = 1
969
+ ${filters}
970
+ `);
971
+ return {
972
+ total: this.coerceCount((_a = rows[0]) === null || _a === void 0 ? void 0 : _a.total),
973
+ today: this.coerceCount((_b = rows[0]) === null || _b === void 0 ? void 0 : _b.today),
974
+ overdue: this.coerceCount((_c = rows[0]) === null || _c === void 0 ? void 0 : _c.overdue),
975
+ upcoming: this.coerceCount((_d = rows[0]) === null || _d === void 0 ? void 0 : _d.upcoming),
976
+ };
977
+ }
350
978
  async get(locale, id) {
351
979
  const allowCompanyRegistration = await this.isCompanyRegistrationAllowed();
352
980
  const person = await this.prismaService.person.findUnique({
@@ -387,6 +1015,7 @@ let PersonService = class PersonService {
387
1015
  async createInteraction(id, data, locale, user) {
388
1016
  const person = await this.ensurePersonAccessible(id, locale);
389
1017
  const interaction = this.buildInteractionRecord(data, user);
1018
+ const ownerUserId = await this.getPersonOwnerUserId(person.id);
390
1019
  await this.prismaService.$transaction(async (tx) => {
391
1020
  const currentInteractions = await this.loadInteractionsFromTx(tx, person.id);
392
1021
  const nextInteractions = this.sortInteractions([
@@ -395,12 +1024,19 @@ let PersonService = class PersonService {
395
1024
  ]);
396
1025
  await this.upsertMetadataValue(tx, person.id, INTERACTIONS_METADATA_KEY, nextInteractions);
397
1026
  await this.upsertMetadataValue(tx, person.id, LAST_INTERACTION_AT_METADATA_KEY, interaction.created_at);
1027
+ await this.createCompletedInteractionActivity(tx, {
1028
+ personId: person.id,
1029
+ ownerUserId,
1030
+ interaction,
1031
+ actorUserId: Number((user === null || user === void 0 ? void 0 : user.id) || 0) || null,
1032
+ });
398
1033
  });
399
1034
  return interaction;
400
1035
  }
401
1036
  async scheduleFollowup(id, data, locale, user) {
402
1037
  const person = await this.ensurePersonAccessible(id, locale);
403
1038
  const normalizedNextActionAt = this.normalizeDateTimeOrNull(data.next_action_at);
1039
+ const ownerUserId = await this.getPersonOwnerUserId(person.id);
404
1040
  if (!normalizedNextActionAt) {
405
1041
  throw new common_1.BadRequestException((0, api_locale_1.getLocaleText)('validation.dateMustBeString', locale, 'next_action_at must be a valid datetime.'));
406
1042
  }
@@ -420,6 +1056,13 @@ let PersonService = class PersonService {
420
1056
  await this.upsertMetadataValue(tx, person.id, INTERACTIONS_METADATA_KEY, nextInteractions);
421
1057
  await this.upsertMetadataValue(tx, person.id, LAST_INTERACTION_AT_METADATA_KEY, interaction.created_at);
422
1058
  }
1059
+ await this.upsertFollowupActivity(tx, {
1060
+ personId: person.id,
1061
+ ownerUserId,
1062
+ dueAt: normalizedNextActionAt,
1063
+ notes: data.notes,
1064
+ actorUserId: Number((user === null || user === void 0 ? void 0 : user.id) || 0) || null,
1065
+ });
423
1066
  });
424
1067
  return {
425
1068
  success: true,
@@ -592,6 +1235,118 @@ let PersonService = class PersonService {
592
1235
  }),
593
1236
  ]);
594
1237
  }
1238
+ async findFollowupSearchPersonIds(search, allowCompanyRegistration) {
1239
+ const normalizedSearch = this.normalizeTextOrNull(search);
1240
+ if (!normalizedSearch) {
1241
+ return null;
1242
+ }
1243
+ const where = {
1244
+ OR: await this.buildSearchFilters(normalizedSearch),
1245
+ };
1246
+ if (!allowCompanyRegistration) {
1247
+ where.type = 'individual';
1248
+ }
1249
+ const people = await this.prismaService.person.findMany({
1250
+ where,
1251
+ select: {
1252
+ id: true,
1253
+ },
1254
+ });
1255
+ return people.map((person) => person.id).filter((id) => id > 0);
1256
+ }
1257
+ buildFollowupSqlFilters({ allowCompanyRegistration, searchPersonIds, status, dateFrom, dateTo, }) {
1258
+ const filters = [];
1259
+ const followupTimestampSql = this.getFollowupTimestampSql();
1260
+ if (!allowCompanyRegistration) {
1261
+ filters.push(api_prisma_1.Prisma.sql `AND p.type = 'individual'`);
1262
+ }
1263
+ if (searchPersonIds && searchPersonIds.length > 0) {
1264
+ filters.push(api_prisma_1.Prisma.sql `AND p.id IN (${api_prisma_1.Prisma.join(searchPersonIds)})`);
1265
+ }
1266
+ const { todayStartIso, tomorrowStartIso } = this.getFollowupDayBoundaryIsoStrings();
1267
+ if (status === 'overdue') {
1268
+ filters.push(api_prisma_1.Prisma.sql `AND ${followupTimestampSql} < CAST(${todayStartIso} AS TIMESTAMPTZ)`);
1269
+ }
1270
+ else if (status === 'today') {
1271
+ filters.push(api_prisma_1.Prisma.sql `AND ${followupTimestampSql} >= CAST(${todayStartIso} AS TIMESTAMPTZ)`);
1272
+ filters.push(api_prisma_1.Prisma.sql `AND ${followupTimestampSql} < CAST(${tomorrowStartIso} AS TIMESTAMPTZ)`);
1273
+ }
1274
+ else if (status === 'upcoming') {
1275
+ filters.push(api_prisma_1.Prisma.sql `AND ${followupTimestampSql} >= CAST(${tomorrowStartIso} AS TIMESTAMPTZ)`);
1276
+ }
1277
+ const dateFromIso = this.normalizeDateOnlyBoundary(dateFrom, 'start');
1278
+ if (dateFromIso) {
1279
+ filters.push(api_prisma_1.Prisma.sql `AND ${followupTimestampSql} >= CAST(${dateFromIso} AS TIMESTAMPTZ)`);
1280
+ }
1281
+ const dateToIso = this.normalizeDateOnlyBoundary(dateTo, 'endExclusive');
1282
+ if (dateToIso) {
1283
+ filters.push(api_prisma_1.Prisma.sql `AND ${followupTimestampSql} < CAST(${dateToIso} AS TIMESTAMPTZ)`);
1284
+ }
1285
+ return filters.length > 0
1286
+ ? api_prisma_1.Prisma.join(filters, '\n')
1287
+ : api_prisma_1.Prisma.empty;
1288
+ }
1289
+ createEmptyFollowupPagination(page, pageSize) {
1290
+ return {
1291
+ total: 0,
1292
+ lastPage: 1,
1293
+ page,
1294
+ pageSize,
1295
+ prev: page > 1 ? page - 1 : null,
1296
+ next: null,
1297
+ data: [],
1298
+ };
1299
+ }
1300
+ getFollowupTimestampSql() {
1301
+ return api_prisma_1.Prisma.sql `CAST(TRIM(BOTH '"' FROM pm_next.value::text) AS TIMESTAMPTZ)`;
1302
+ }
1303
+ getFollowupDayBoundaryDates(reference = new Date()) {
1304
+ const todayStart = new Date(reference.getFullYear(), reference.getMonth(), reference.getDate());
1305
+ const tomorrowStart = new Date(todayStart);
1306
+ tomorrowStart.setDate(tomorrowStart.getDate() + 1);
1307
+ return {
1308
+ todayStart,
1309
+ tomorrowStart,
1310
+ };
1311
+ }
1312
+ getFollowupDayBoundaryIsoStrings(reference = new Date()) {
1313
+ const { todayStart, tomorrowStart } = this.getFollowupDayBoundaryDates(reference);
1314
+ return {
1315
+ todayStartIso: todayStart.toISOString(),
1316
+ tomorrowStartIso: tomorrowStart.toISOString(),
1317
+ };
1318
+ }
1319
+ normalizeDateOnlyBoundary(value, mode) {
1320
+ if (!value) {
1321
+ return null;
1322
+ }
1323
+ const parsed = new Date(`${value}T00:00:00`);
1324
+ if (Number.isNaN(parsed.getTime())) {
1325
+ return null;
1326
+ }
1327
+ if (mode === 'endExclusive') {
1328
+ parsed.setDate(parsed.getDate() + 1);
1329
+ }
1330
+ return parsed.toISOString();
1331
+ }
1332
+ getFollowupStatus(nextActionAt) {
1333
+ const parsed = new Date(nextActionAt);
1334
+ const { todayStart, tomorrowStart } = this.getFollowupDayBoundaryDates();
1335
+ if (parsed < todayStart) {
1336
+ return 'overdue';
1337
+ }
1338
+ if (parsed < tomorrowStart) {
1339
+ return 'today';
1340
+ }
1341
+ return 'upcoming';
1342
+ }
1343
+ coerceCount(value) {
1344
+ if (typeof value === 'bigint') {
1345
+ return Number(value);
1346
+ }
1347
+ const parsed = Number(value);
1348
+ return Number.isFinite(parsed) ? parsed : 0;
1349
+ }
595
1350
  async openPublicAvatar(locale, fileId, res) {
596
1351
  const personWithAvatar = await this.prismaService.person.findFirst({
597
1352
  where: {
@@ -612,6 +1367,110 @@ let PersonService = class PersonService {
612
1367
  });
613
1368
  res.send(buffer);
614
1369
  }
1370
+ resolveDashboardRanges(query, locale) {
1371
+ var _a;
1372
+ const period = (_a = query.period) !== null && _a !== void 0 ? _a : '30d';
1373
+ const now = new Date();
1374
+ if (period === 'custom') {
1375
+ if (!query.date_from || !query.date_to) {
1376
+ throw new common_1.BadRequestException((0, api_locale_1.getLocaleText)('validation.dateMustBeString', locale, 'date_from and date_to are required when period is custom.'));
1377
+ }
1378
+ const start = this.startOfDay(this.parseDateOrThrow(query.date_from, locale));
1379
+ const end = this.endOfDay(this.parseDateOrThrow(query.date_to, locale));
1380
+ if (start.getTime() > end.getTime()) {
1381
+ throw new common_1.BadRequestException((0, api_locale_1.getLocaleText)('validation.dateMustBeString', locale, 'date_from must be less than or equal to date_to.'));
1382
+ }
1383
+ return {
1384
+ created: { start, end },
1385
+ operational: { start, end },
1386
+ };
1387
+ }
1388
+ const dayCount = Number(period.replace('d', ''));
1389
+ const createdStart = this.startOfDay(this.addDays(now, -(dayCount - 1)));
1390
+ const createdEnd = this.endOfDay(now);
1391
+ const operationalStart = this.startOfDay(this.addDays(now, -(dayCount - 1)));
1392
+ const operationalEnd = this.endOfDay(this.addDays(now, dayCount - 1));
1393
+ return {
1394
+ created: {
1395
+ start: createdStart,
1396
+ end: createdEnd,
1397
+ },
1398
+ operational: {
1399
+ start: operationalStart,
1400
+ end: operationalEnd,
1401
+ },
1402
+ };
1403
+ }
1404
+ buildDashboardOwnerPerformance(people) {
1405
+ var _a, _b;
1406
+ const byOwnerId = new Map();
1407
+ for (const person of people) {
1408
+ const ownerUserId = this.coerceNumber(person.owner_user_id);
1409
+ if (ownerUserId <= 0) {
1410
+ continue;
1411
+ }
1412
+ const current = (_a = byOwnerId.get(ownerUserId)) !== null && _a !== void 0 ? _a : {
1413
+ owner_user_id: ownerUserId,
1414
+ owner_name: ((_b = person.owner_user) === null || _b === void 0 ? void 0 : _b.name) || `#${ownerUserId}`,
1415
+ leads: 0,
1416
+ customers: 0,
1417
+ pipeline_value: 0,
1418
+ };
1419
+ current.leads += 1;
1420
+ if (person.lifecycle_stage === 'customer') {
1421
+ current.customers += 1;
1422
+ }
1423
+ if (person.lifecycle_stage !== 'lost') {
1424
+ current.pipeline_value += this.coerceNumber(person.deal_value);
1425
+ }
1426
+ byOwnerId.set(ownerUserId, current);
1427
+ }
1428
+ return Array.from(byOwnerId.values()).sort((left, right) => left.owner_name.localeCompare(right.owner_name));
1429
+ }
1430
+ mapDashboardListItem(person, { includeCreatedAt, includeNextActionAt, }) {
1431
+ var _a, _b, _c, _d, _e;
1432
+ const source = ((_a = person.source) !== null && _a !== void 0 ? _a : 'other');
1433
+ const lifecycleStage = ((_b = person.lifecycle_stage) !== null && _b !== void 0 ? _b : 'new');
1434
+ 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
1435
+ ? { next_action_at: person.next_action_at }
1436
+ : {})), (includeCreatedAt && person.created_at
1437
+ ? {
1438
+ created_at: (_e = this.normalizeDateTimeOrNull(person.created_at)) !== null && _e !== void 0 ? _e : person.created_at,
1439
+ }
1440
+ : {}));
1441
+ }
1442
+ isDateWithinRange(value, range) {
1443
+ const parsed = this.parseDateOrNull(value);
1444
+ if (!parsed) {
1445
+ return false;
1446
+ }
1447
+ return (parsed.getTime() >= range.start.getTime() &&
1448
+ parsed.getTime() <= range.end.getTime());
1449
+ }
1450
+ parseDateOrThrow(value, locale) {
1451
+ const parsed = /^\d{4}-\d{2}-\d{2}$/.test(value)
1452
+ ? this.parseDateOrNull(`${value}T00:00:00`)
1453
+ : this.parseDateOrNull(value);
1454
+ if (!parsed) {
1455
+ throw new common_1.BadRequestException((0, api_locale_1.getLocaleText)('validation.dateMustBeString', locale, `Invalid date value: ${value}`));
1456
+ }
1457
+ return parsed;
1458
+ }
1459
+ addDays(date, amount) {
1460
+ const next = new Date(date);
1461
+ next.setDate(next.getDate() + amount);
1462
+ return next;
1463
+ }
1464
+ startOfDay(date) {
1465
+ const next = new Date(date);
1466
+ next.setHours(0, 0, 0, 0);
1467
+ return next;
1468
+ }
1469
+ endOfDay(date) {
1470
+ const next = new Date(date);
1471
+ next.setHours(23, 59, 59, 999);
1472
+ return next;
1473
+ }
615
1474
  async enrichPeople(people, allowCompanyRegistration = true) {
616
1475
  if (people.length === 0) {
617
1476
  return [];
@@ -917,6 +1776,13 @@ let PersonService = class PersonService {
917
1776
  create: {
918
1777
  id: personId,
919
1778
  trade_name: this.normalizeTextOrNull(data.trade_name),
1779
+ industry: this.normalizeTextOrNull(data.industry),
1780
+ website: this.normalizeTextOrNull(data.website),
1781
+ annual_revenue: this.normalizeDecimalOrNull(data.annual_revenue),
1782
+ employee_count: this.normalizeIntegerOrNull(data.employee_count),
1783
+ account_lifecycle_stage: this.normalizeAccountLifecycleStage(data.lifecycle_stage),
1784
+ city: this.normalizeTextOrNull(data.city),
1785
+ state: this.normalizeStateOrNull(data.state),
920
1786
  foundation_date: this.parseDateOrNull(data.foundation_date),
921
1787
  legal_nature: this.normalizeTextOrNull(data.legal_nature),
922
1788
  headquarter_id: normalizedHeadquarterId > 0 ? normalizedHeadquarterId : null,
@@ -925,6 +1791,27 @@ let PersonService = class PersonService {
925
1791
  trade_name: data.trade_name === undefined
926
1792
  ? undefined
927
1793
  : this.normalizeTextOrNull(data.trade_name),
1794
+ industry: data.industry === undefined
1795
+ ? undefined
1796
+ : this.normalizeTextOrNull(data.industry),
1797
+ website: data.website === undefined
1798
+ ? undefined
1799
+ : this.normalizeTextOrNull(data.website),
1800
+ annual_revenue: data.annual_revenue === undefined
1801
+ ? undefined
1802
+ : this.normalizeDecimalOrNull(data.annual_revenue),
1803
+ employee_count: data.employee_count === undefined
1804
+ ? undefined
1805
+ : this.normalizeIntegerOrNull(data.employee_count),
1806
+ account_lifecycle_stage: data.lifecycle_stage === undefined
1807
+ ? undefined
1808
+ : this.normalizeAccountLifecycleStage(data.lifecycle_stage),
1809
+ city: data.city === undefined
1810
+ ? undefined
1811
+ : this.normalizeTextOrNull(data.city),
1812
+ state: data.state === undefined
1813
+ ? undefined
1814
+ : this.normalizeStateOrNull(data.state),
928
1815
  foundation_date: data.foundation_date === undefined
929
1816
  ? undefined
930
1817
  : this.parseDateOrNull(data.foundation_date),
@@ -1394,6 +2281,20 @@ let PersonService = class PersonService {
1394
2281
  const date = value instanceof Date ? value : new Date(String(value));
1395
2282
  return Number.isNaN(date.getTime()) ? null : date;
1396
2283
  }
2284
+ normalizeDecimalOrNull(value) {
2285
+ if (value == null || value === '') {
2286
+ return null;
2287
+ }
2288
+ const parsed = Number(value);
2289
+ return Number.isFinite(parsed) ? new api_prisma_1.Prisma.Decimal(parsed) : null;
2290
+ }
2291
+ normalizeIntegerOrNull(value) {
2292
+ if (value == null || value === '') {
2293
+ return null;
2294
+ }
2295
+ const parsed = Number(value);
2296
+ return Number.isInteger(parsed) ? parsed : null;
2297
+ }
1397
2298
  normalizeTextOrNull(value) {
1398
2299
  if (typeof value !== 'string') {
1399
2300
  return value == null ? null : String(value);
@@ -1411,6 +2312,20 @@ let PersonService = class PersonService {
1411
2312
  const parsed = Number(value);
1412
2313
  return Number.isFinite(parsed) ? parsed : 0;
1413
2314
  }
2315
+ normalizeStateOrNull(value) {
2316
+ var _a, _b;
2317
+ const normalized = (_b = (_a = this.normalizeTextOrNull(value)) === null || _a === void 0 ? void 0 : _a.toUpperCase()) !== null && _b !== void 0 ? _b : null;
2318
+ return normalized ? normalized.slice(0, 2) : null;
2319
+ }
2320
+ normalizeAccountLifecycleStage(value) {
2321
+ const normalized = this.normalizeTextOrNull(value);
2322
+ if (!normalized) {
2323
+ return null;
2324
+ }
2325
+ return account_dto_1.ACCOUNT_LIFECYCLE_STAGES.includes(normalized)
2326
+ ? normalized
2327
+ : null;
2328
+ }
1414
2329
  resolveRequestedOwnerUserId(ownerUserId, mine, currentUserId) {
1415
2330
  if (mine === true ||
1416
2331
  mine === 'true' ||
@@ -1436,6 +2351,288 @@ let PersonService = class PersonService {
1436
2351
  }
1437
2352
  return person;
1438
2353
  }
2354
+ async getPersonOwnerUserId(personId) {
2355
+ const metadata = await this.prismaService.person_metadata.findFirst({
2356
+ where: {
2357
+ person_id: personId,
2358
+ key: OWNER_USER_METADATA_KEY,
2359
+ },
2360
+ select: {
2361
+ value: true,
2362
+ },
2363
+ });
2364
+ const ownerUserId = this.metadataToNumber(metadata === null || metadata === void 0 ? void 0 : metadata.value);
2365
+ return ownerUserId && ownerUserId > 0 ? ownerUserId : null;
2366
+ }
2367
+ async upsertFollowupActivity(tx, { personId, ownerUserId, dueAt, notes, actorUserId, }) {
2368
+ const existingRows = (await tx.$queryRaw(api_prisma_1.Prisma.sql `
2369
+ SELECT id
2370
+ FROM crm_activity
2371
+ WHERE person_id = ${personId}
2372
+ AND source_kind = 'followup'
2373
+ AND completed_at IS NULL
2374
+ ORDER BY id DESC
2375
+ LIMIT 1
2376
+ `));
2377
+ const existing = existingRows[0];
2378
+ const normalizedNotes = this.normalizeTextOrNull(notes);
2379
+ if (existing) {
2380
+ await tx.$executeRaw(api_prisma_1.Prisma.sql `
2381
+ UPDATE crm_activity
2382
+ SET
2383
+ owner_user_id = ${ownerUserId},
2384
+ type = CAST(${'task'} AS crm_activity_type_enum),
2385
+ subject = ${this.getFollowupActivitySubject()},
2386
+ notes = ${normalizedNotes},
2387
+ due_at = CAST(${dueAt} AS TIMESTAMPTZ),
2388
+ priority = CAST(${'medium'} AS crm_activity_priority_enum),
2389
+ updated_at = NOW()
2390
+ WHERE id = ${existing.id}
2391
+ `);
2392
+ return;
2393
+ }
2394
+ await tx.$executeRaw(api_prisma_1.Prisma.sql `
2395
+ INSERT INTO crm_activity (
2396
+ person_id,
2397
+ owner_user_id,
2398
+ created_by_user_id,
2399
+ type,
2400
+ subject,
2401
+ notes,
2402
+ due_at,
2403
+ priority,
2404
+ source_kind,
2405
+ created_at,
2406
+ updated_at
2407
+ )
2408
+ VALUES (
2409
+ ${personId},
2410
+ ${ownerUserId},
2411
+ ${actorUserId},
2412
+ CAST(${'task'} AS crm_activity_type_enum),
2413
+ ${this.getFollowupActivitySubject()},
2414
+ ${normalizedNotes},
2415
+ CAST(${dueAt} AS TIMESTAMPTZ),
2416
+ CAST(${'medium'} AS crm_activity_priority_enum),
2417
+ CAST(${'followup'} AS crm_activity_source_kind_enum),
2418
+ NOW(),
2419
+ NOW()
2420
+ )
2421
+ `);
2422
+ }
2423
+ async createCompletedInteractionActivity(tx, { personId, ownerUserId, interaction, actorUserId, }) {
2424
+ const completedAt = new Date(interaction.created_at);
2425
+ await tx.$executeRaw(api_prisma_1.Prisma.sql `
2426
+ INSERT INTO crm_activity (
2427
+ person_id,
2428
+ owner_user_id,
2429
+ created_by_user_id,
2430
+ completed_by_user_id,
2431
+ type,
2432
+ subject,
2433
+ notes,
2434
+ due_at,
2435
+ completed_at,
2436
+ priority,
2437
+ source_kind,
2438
+ created_at,
2439
+ updated_at
2440
+ )
2441
+ VALUES (
2442
+ ${personId},
2443
+ ${ownerUserId},
2444
+ ${actorUserId},
2445
+ ${actorUserId},
2446
+ CAST(${interaction.type} AS crm_activity_type_enum),
2447
+ ${this.getInteractionActivitySubject(interaction.type)},
2448
+ ${this.normalizeTextOrNull(interaction.notes)},
2449
+ CAST(${interaction.created_at} AS TIMESTAMPTZ),
2450
+ CAST(${interaction.created_at} AS TIMESTAMPTZ),
2451
+ CAST(${'medium'} AS crm_activity_priority_enum),
2452
+ CAST(${'interaction'} AS crm_activity_source_kind_enum),
2453
+ CAST(${interaction.created_at} AS TIMESTAMPTZ),
2454
+ NOW()
2455
+ )
2456
+ `);
2457
+ }
2458
+ getFollowupActivitySubject() {
2459
+ return 'Follow-up';
2460
+ }
2461
+ getInteractionActivitySubject(type) {
2462
+ switch (type) {
2463
+ case create_interaction_dto_1.PersonInteractionTypeDTO.CALL:
2464
+ return 'Call';
2465
+ case create_interaction_dto_1.PersonInteractionTypeDTO.EMAIL:
2466
+ return 'Email';
2467
+ case create_interaction_dto_1.PersonInteractionTypeDTO.WHATSAPP:
2468
+ return 'WhatsApp';
2469
+ case create_interaction_dto_1.PersonInteractionTypeDTO.MEETING:
2470
+ return 'Meeting';
2471
+ case create_interaction_dto_1.PersonInteractionTypeDTO.NOTE:
2472
+ default:
2473
+ return 'Note';
2474
+ }
2475
+ }
2476
+ async ensureCompanyAccountAccessible(id, locale) {
2477
+ const allowCompanyRegistration = await this.isCompanyRegistrationAllowed();
2478
+ if (!allowCompanyRegistration) {
2479
+ throw new common_1.NotFoundException((0, api_locale_1.getLocaleText)('personNotFound', locale, `Person with ID ${id} not found`));
2480
+ }
2481
+ const person = await this.prismaService.person.findUnique({
2482
+ where: { id },
2483
+ select: {
2484
+ id: true,
2485
+ name: true,
2486
+ status: true,
2487
+ type: true,
2488
+ },
2489
+ });
2490
+ if (!person || person.type !== 'company') {
2491
+ throw new common_1.BadRequestException((0, api_locale_1.getLocaleText)('personNotFound', locale, `Person with ID ${id} not found`));
2492
+ }
2493
+ return person;
2494
+ }
2495
+ async loadAccountPeopleByIds(personIds) {
2496
+ if (personIds.length === 0) {
2497
+ return [];
2498
+ }
2499
+ const people = await this.prismaService.person.findMany({
2500
+ where: {
2501
+ id: {
2502
+ in: personIds,
2503
+ },
2504
+ },
2505
+ include: {
2506
+ contact: {
2507
+ include: {
2508
+ contact_type: true,
2509
+ },
2510
+ },
2511
+ person_metadata: true,
2512
+ },
2513
+ });
2514
+ return this.enrichPeople(people, true);
2515
+ }
2516
+ mapAccountFromPerson(person, company) {
2517
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o;
2518
+ return {
2519
+ id: person.id,
2520
+ name: person.name,
2521
+ trade_name: (_a = company.trade_name) !== null && _a !== void 0 ? _a : null,
2522
+ status: person.status,
2523
+ industry: (_b = company.industry) !== null && _b !== void 0 ? _b : null,
2524
+ website: (_c = company.website) !== null && _c !== void 0 ? _c : null,
2525
+ email: this.getPrimaryAccountContactValue(person.contact, ['EMAIL']),
2526
+ phone: this.getPrimaryAccountContactValue(person.contact, [
2527
+ 'PHONE',
2528
+ 'MOBILE',
2529
+ 'WHATSAPP',
2530
+ ]),
2531
+ owner_user_id: (_d = person.owner_user_id) !== null && _d !== void 0 ? _d : null,
2532
+ owner_user: (_e = person.owner_user) !== null && _e !== void 0 ? _e : null,
2533
+ annual_revenue: company.annual_revenue == null ? null : Number(company.annual_revenue),
2534
+ employee_count: (_f = company.employee_count) !== null && _f !== void 0 ? _f : null,
2535
+ lifecycle_stage: (_g = company.account_lifecycle_stage) !== null && _g !== void 0 ? _g : null,
2536
+ city: (_h = company.city) !== null && _h !== void 0 ? _h : null,
2537
+ state: (_j = company.state) !== null && _j !== void 0 ? _j : null,
2538
+ 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),
2539
+ last_interaction_at: (_o = this.normalizeDateTimeOrNull(person.last_interaction_at)) !== null && _o !== void 0 ? _o : null,
2540
+ };
2541
+ }
2542
+ getPrimaryAccountContactValue(contacts, codes) {
2543
+ var _a;
2544
+ const normalizedCodes = new Set(codes.map((code) => code.toUpperCase()));
2545
+ const items = Array.isArray(contacts)
2546
+ ? contacts.filter((contact) => {
2547
+ var _a;
2548
+ return normalizedCodes.has(String(((_a = contact === null || contact === void 0 ? void 0 : contact.contact_type) === null || _a === void 0 ? void 0 : _a.code) || '').toUpperCase());
2549
+ })
2550
+ : [];
2551
+ const primary = items.find((contact) => contact === null || contact === void 0 ? void 0 : contact.is_primary);
2552
+ const fallback = items[0];
2553
+ 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);
2554
+ }
2555
+ async upsertPrimaryAccountContact(tx, personId, code, value) {
2556
+ const normalizedValue = this.normalizeTextOrNull(value);
2557
+ const allowedCodes = code === 'PHONE' ? ['PHONE', 'MOBILE', 'WHATSAPP'] : ['EMAIL'];
2558
+ const type = await tx.contact_type.findFirst({
2559
+ where: {
2560
+ code: {
2561
+ equals: code,
2562
+ mode: 'insensitive',
2563
+ },
2564
+ },
2565
+ select: {
2566
+ id: true,
2567
+ },
2568
+ });
2569
+ if (!type) {
2570
+ return;
2571
+ }
2572
+ const existingContacts = await tx.contact.findMany({
2573
+ where: {
2574
+ person_id: personId,
2575
+ contact_type: {
2576
+ code: {
2577
+ in: allowedCodes,
2578
+ },
2579
+ },
2580
+ },
2581
+ orderBy: [{ is_primary: 'desc' }, { id: 'asc' }],
2582
+ select: {
2583
+ id: true,
2584
+ is_primary: true,
2585
+ },
2586
+ });
2587
+ if (!normalizedValue) {
2588
+ const contactToDelete = existingContacts[0];
2589
+ if (contactToDelete) {
2590
+ await tx.contact.delete({
2591
+ where: {
2592
+ id: contactToDelete.id,
2593
+ },
2594
+ });
2595
+ }
2596
+ return;
2597
+ }
2598
+ const primaryContact = existingContacts[0];
2599
+ if (primaryContact) {
2600
+ await tx.contact.update({
2601
+ where: {
2602
+ id: primaryContact.id,
2603
+ },
2604
+ data: {
2605
+ value: normalizedValue,
2606
+ is_primary: true,
2607
+ contact_type_id: type.id,
2608
+ },
2609
+ });
2610
+ const secondaryContacts = existingContacts
2611
+ .slice(1)
2612
+ .filter((contact) => contact.is_primary);
2613
+ if (secondaryContacts.length > 0) {
2614
+ await tx.contact.updateMany({
2615
+ where: {
2616
+ id: {
2617
+ in: secondaryContacts.map((contact) => contact.id),
2618
+ },
2619
+ },
2620
+ data: {
2621
+ is_primary: false,
2622
+ },
2623
+ });
2624
+ }
2625
+ return;
2626
+ }
2627
+ await tx.contact.create({
2628
+ data: {
2629
+ person_id: personId,
2630
+ contact_type_id: type.id,
2631
+ value: normalizedValue,
2632
+ is_primary: true,
2633
+ },
2634
+ });
2635
+ }
1439
2636
  async loadInteractionsFromTx(tx, personId) {
1440
2637
  const metadata = await tx.person_metadata.findFirst({
1441
2638
  where: {
@@ -1513,6 +2710,176 @@ let PersonService = class PersonService {
1513
2710
  }
1514
2711
  return filters;
1515
2712
  }
2713
+ buildAccountSqlFilters({ search, status, lifecycleStage, }) {
2714
+ const filters = [];
2715
+ if (status && status !== 'all') {
2716
+ filters.push(api_prisma_1.Prisma.sql `AND p.status = ${status}`);
2717
+ }
2718
+ if (lifecycleStage && lifecycleStage !== 'all') {
2719
+ filters.push(api_prisma_1.Prisma.sql `AND pc.account_lifecycle_stage = CAST(${lifecycleStage} AS person_company_account_lifecycle_stage_enum)`);
2720
+ }
2721
+ if (search) {
2722
+ const searchLike = `%${search}%`;
2723
+ const normalizedDigits = this.normalizeDigits(search);
2724
+ const digitsLike = `%${normalizedDigits}%`;
2725
+ filters.push(api_prisma_1.Prisma.sql `
2726
+ AND (
2727
+ p.name ILIKE ${searchLike}
2728
+ OR COALESCE(pc.trade_name, '') ILIKE ${searchLike}
2729
+ OR COALESCE(pc.city, '') ILIKE ${searchLike}
2730
+ OR COALESCE(pc.state, '') ILIKE ${searchLike}
2731
+ OR EXISTS (
2732
+ SELECT 1
2733
+ FROM contact c
2734
+ INNER JOIN contact_type ct ON ct.id = c.contact_type_id
2735
+ WHERE c.person_id = p.id
2736
+ AND UPPER(ct.code) = 'EMAIL'
2737
+ AND c.value ILIKE ${searchLike}
2738
+ )
2739
+ ${normalizedDigits.length > 0
2740
+ ? api_prisma_1.Prisma.sql `
2741
+ OR EXISTS (
2742
+ SELECT 1
2743
+ FROM contact c
2744
+ INNER JOIN contact_type ct ON ct.id = c.contact_type_id
2745
+ WHERE c.person_id = p.id
2746
+ AND UPPER(ct.code) IN ('PHONE', 'MOBILE', 'WHATSAPP')
2747
+ AND regexp_replace(COALESCE(c.value, ''), '[^0-9]+', '', 'g') ILIKE ${digitsLike}
2748
+ )
2749
+ `
2750
+ : api_prisma_1.Prisma.empty}
2751
+ )
2752
+ `);
2753
+ }
2754
+ return filters.length > 0 ? api_prisma_1.Prisma.join(filters, '\n') : api_prisma_1.Prisma.empty;
2755
+ }
2756
+ getAccountOrderBySql(sortField, sortOrder) {
2757
+ const normalizedSortField = sortField === 'created_at' ? 'created_at' : 'name';
2758
+ const normalizedSortOrder = sortOrder === 'desc' ? 'DESC' : 'ASC';
2759
+ if (normalizedSortField === 'created_at') {
2760
+ return normalizedSortOrder === 'DESC'
2761
+ ? api_prisma_1.Prisma.sql `p.created_at DESC`
2762
+ : api_prisma_1.Prisma.sql `p.created_at ASC`;
2763
+ }
2764
+ return normalizedSortOrder === 'DESC'
2765
+ ? api_prisma_1.Prisma.sql `LOWER(p.name) DESC`
2766
+ : api_prisma_1.Prisma.sql `LOWER(p.name) ASC`;
2767
+ }
2768
+ createEmptyAccountPagination(page, pageSize) {
2769
+ return {
2770
+ total: 0,
2771
+ lastPage: 1,
2772
+ page,
2773
+ pageSize,
2774
+ prev: page > 1 ? page - 1 : null,
2775
+ next: null,
2776
+ data: [],
2777
+ };
2778
+ }
2779
+ buildCrmActivitySqlFilters({ allowCompanyRegistration, search, status, type, priority, }) {
2780
+ const filters = [];
2781
+ if (!allowCompanyRegistration) {
2782
+ filters.push(api_prisma_1.Prisma.sql `AND p.type = 'individual'`);
2783
+ }
2784
+ if (status === 'pending') {
2785
+ filters.push(api_prisma_1.Prisma.sql `AND a.completed_at IS NULL AND a.due_at >= NOW()`);
2786
+ }
2787
+ else if (status === 'overdue') {
2788
+ filters.push(api_prisma_1.Prisma.sql `AND a.completed_at IS NULL AND a.due_at < NOW()`);
2789
+ }
2790
+ else if (status === 'completed') {
2791
+ filters.push(api_prisma_1.Prisma.sql `AND a.completed_at IS NOT NULL`);
2792
+ }
2793
+ if (type && type !== 'all') {
2794
+ filters.push(api_prisma_1.Prisma.sql `AND a.type = CAST(${type} AS crm_activity_type_enum)`);
2795
+ }
2796
+ if (priority && priority !== 'all') {
2797
+ filters.push(api_prisma_1.Prisma.sql `AND a.priority = CAST(${priority} AS crm_activity_priority_enum)`);
2798
+ }
2799
+ if (search) {
2800
+ const searchLike = `%${search}%`;
2801
+ filters.push(api_prisma_1.Prisma.sql `
2802
+ AND (
2803
+ a.subject ILIKE ${searchLike}
2804
+ OR COALESCE(a.notes, '') ILIKE ${searchLike}
2805
+ OR p.name ILIKE ${searchLike}
2806
+ OR COALESCE(owner_user.name, '') ILIKE ${searchLike}
2807
+ )
2808
+ `);
2809
+ }
2810
+ return filters.length > 0 ? api_prisma_1.Prisma.join(filters, '\n') : api_prisma_1.Prisma.empty;
2811
+ }
2812
+ createEmptyCrmActivityPagination(page, pageSize) {
2813
+ return {
2814
+ total: 0,
2815
+ lastPage: 1,
2816
+ page,
2817
+ pageSize,
2818
+ prev: page > 1 ? page - 1 : null,
2819
+ next: null,
2820
+ data: [],
2821
+ };
2822
+ }
2823
+ mapCrmActivityListRow(row) {
2824
+ var _a, _b;
2825
+ const completedAt = this.normalizeDateTimeOrNull(row.completed_at);
2826
+ const dueAt = (_a = this.normalizeDateTimeOrNull(row.due_at)) !== null && _a !== void 0 ? _a : new Date().toISOString();
2827
+ const ownerUserId = this.coerceNumber(row.owner_user_id) || null;
2828
+ return {
2829
+ id: this.coerceNumber(row.id),
2830
+ person_id: this.coerceNumber(row.person_id),
2831
+ person: {
2832
+ id: this.coerceNumber(row.person_id),
2833
+ name: this.normalizeTextOrNull(row.person_name) || `#${row.person_id}`,
2834
+ type: this.normalizeTextOrNull(row.person_type) === 'company'
2835
+ ? 'company'
2836
+ : 'individual',
2837
+ status: this.normalizeTextOrNull(row.person_status) === 'inactive'
2838
+ ? 'inactive'
2839
+ : 'active',
2840
+ trade_name: this.normalizeTextOrNull(row.person_trade_name),
2841
+ },
2842
+ owner_user_id: ownerUserId,
2843
+ owner_user: ownerUserId
2844
+ ? {
2845
+ id: ownerUserId,
2846
+ name: this.normalizeTextOrNull(row.owner_user_name) || `#${ownerUserId}`,
2847
+ }
2848
+ : null,
2849
+ type: (this.normalizeTextOrNull(row.type) || 'task'),
2850
+ subject: this.normalizeTextOrNull(row.subject) || 'Activity',
2851
+ notes: this.normalizeTextOrNull(row.notes),
2852
+ due_at: dueAt,
2853
+ completed_at: completedAt,
2854
+ created_at: (_b = this.normalizeDateTimeOrNull(row.created_at)) !== null && _b !== void 0 ? _b : new Date().toISOString(),
2855
+ priority: (this.normalizeTextOrNull(row.priority) || 'medium'),
2856
+ status: this.getCrmActivityStatus(dueAt, completedAt),
2857
+ };
2858
+ }
2859
+ mapCrmActivityDetailRow(row) {
2860
+ const base = this.mapCrmActivityListRow(row);
2861
+ const createdByUserId = this.coerceNumber(row.created_by_user_id) || null;
2862
+ const completedByUserId = this.coerceNumber(row.completed_by_user_id) || null;
2863
+ return Object.assign(Object.assign({}, base), { source_kind: (this.normalizeTextOrNull(row.source_kind) || 'manual'), created_by_user_id: createdByUserId, created_by_user: createdByUserId
2864
+ ? {
2865
+ id: createdByUserId,
2866
+ name: this.normalizeTextOrNull(row.created_by_user_name) ||
2867
+ `#${createdByUserId}`,
2868
+ }
2869
+ : null, completed_by_user_id: completedByUserId, completed_by_user: completedByUserId
2870
+ ? {
2871
+ id: completedByUserId,
2872
+ name: this.normalizeTextOrNull(row.completed_by_user_name) ||
2873
+ `#${completedByUserId}`,
2874
+ }
2875
+ : null });
2876
+ }
2877
+ getCrmActivityStatus(dueAt, completedAt) {
2878
+ if (completedAt) {
2879
+ return 'completed';
2880
+ }
2881
+ return new Date(dueAt) < new Date() ? 'overdue' : 'pending';
2882
+ }
1516
2883
  normalizeDigits(value) {
1517
2884
  return value.replace(/\D/g, '');
1518
2885
  }