@hed-hog/contact 0.0.293 → 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 +5 -5
  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
@@ -10,13 +10,34 @@ import {
10
10
  NotFoundException,
11
11
  forwardRef,
12
12
  } from '@nestjs/common';
13
+ import {
14
+ ACCOUNT_LIFECYCLE_STAGES,
15
+ type AccountLifecycleStage,
16
+ type CreateAccountDTO,
17
+ type UpdateAccountDTO,
18
+ } from './dto/account.dto';
19
+ import {
20
+ type ActivityListQueryDTO,
21
+ type CrmActivityPriority,
22
+ type CrmActivitySourceKind,
23
+ type CrmActivityStatus,
24
+ type CrmActivityType,
25
+ } from './dto/activity.dto';
13
26
  import { CreateFollowupDTO } from './dto/create-followup.dto';
14
27
  import {
15
28
  CreateInteractionDTO,
16
29
  PersonInteractionTypeDTO,
17
30
  } from './dto/create-interaction.dto';
18
31
  import { CreateDTO } from './dto/create.dto';
32
+ import {
33
+ type CrmDashboardPeriod,
34
+ type DashboardQueryDTO,
35
+ } from './dto/dashboard-query.dto';
19
36
  import { CheckPersonDuplicatesQueryDTO } from './dto/duplicates-query.dto';
37
+ import {
38
+ FollowupListQueryDTO,
39
+ FollowupStatsQueryDTO,
40
+ } from './dto/followup-query.dto';
20
41
  import { MergePersonDTO } from './dto/merge.dto';
21
42
  import { UpdateLifecycleStageDTO } from './dto/update-lifecycle-stage.dto';
22
43
 
@@ -59,6 +80,162 @@ type PersonInteractionRecord = {
59
80
  user_name: string | null;
60
81
  };
61
82
 
83
+ type FollowupStatus = 'today' | 'upcoming' | 'overdue';
84
+
85
+ type FollowupListParams = PaginationDTO &
86
+ FollowupListQueryDTO & {
87
+ page?: number;
88
+ pageSize?: number;
89
+ };
90
+
91
+ type FollowupListItem = {
92
+ person: any;
93
+ next_action_at: string;
94
+ last_interaction_at: string | null;
95
+ status: FollowupStatus;
96
+ };
97
+
98
+ type AccountListParams = Omit<PaginationDTO, 'sortField' | 'sortOrder'> & {
99
+ search?: string;
100
+ status?: 'all' | 'active' | 'inactive';
101
+ lifecycle_stage?: 'all' | AccountLifecycleStage;
102
+ sortField?: 'name' | 'created_at';
103
+ sortOrder?: 'asc' | 'desc';
104
+ page?: number;
105
+ pageSize?: number;
106
+ };
107
+
108
+ type AccountListItem = {
109
+ id: number;
110
+ name: string;
111
+ trade_name: string | null;
112
+ status: 'active' | 'inactive';
113
+ industry: string | null;
114
+ website: string | null;
115
+ email: string | null;
116
+ phone: string | null;
117
+ owner_user_id: number | null;
118
+ owner_user: { id: number; name: string } | null;
119
+ annual_revenue: number | null;
120
+ employee_count: number | null;
121
+ lifecycle_stage: AccountLifecycleStage | null;
122
+ city: string | null;
123
+ state: string | null;
124
+ created_at: string;
125
+ last_interaction_at: string | null;
126
+ };
127
+
128
+ type CrmActivityListParams = Omit<PaginationDTO, 'sortField' | 'sortOrder'> &
129
+ ActivityListQueryDTO & {
130
+ page?: number;
131
+ pageSize?: number;
132
+ };
133
+
134
+ type CrmActivityUserSummary = {
135
+ id: number;
136
+ name: string;
137
+ };
138
+
139
+ type CrmActivityPersonSummary = {
140
+ id: number;
141
+ name: string;
142
+ type: 'individual' | 'company';
143
+ status: 'active' | 'inactive';
144
+ trade_name: string | null;
145
+ };
146
+
147
+ type CrmActivityListItem = {
148
+ id: number;
149
+ person_id: number;
150
+ person: CrmActivityPersonSummary;
151
+ owner_user_id: number | null;
152
+ owner_user: CrmActivityUserSummary | null;
153
+ type: CrmActivityType;
154
+ subject: string;
155
+ notes: string | null;
156
+ due_at: string;
157
+ completed_at: string | null;
158
+ created_at: string;
159
+ priority: CrmActivityPriority;
160
+ status: CrmActivityStatus;
161
+ };
162
+
163
+ type CrmActivityDetail = CrmActivityListItem & {
164
+ source_kind: CrmActivitySourceKind;
165
+ created_by_user_id: number | null;
166
+ created_by_user: CrmActivityUserSummary | null;
167
+ completed_by_user_id: number | null;
168
+ completed_by_user: CrmActivityUserSummary | null;
169
+ };
170
+
171
+ const CRM_DASHBOARD_STAGE_ORDER = [
172
+ 'new',
173
+ 'contacted',
174
+ 'qualified',
175
+ 'proposal',
176
+ 'negotiation',
177
+ 'customer',
178
+ 'lost',
179
+ ] as const;
180
+
181
+ const CRM_DASHBOARD_SOURCE_ORDER = [
182
+ 'website',
183
+ 'referral',
184
+ 'social',
185
+ 'inbound',
186
+ 'outbound',
187
+ 'other',
188
+ ] as const;
189
+
190
+ type CrmDashboardStageKey = (typeof CRM_DASHBOARD_STAGE_ORDER)[number];
191
+ type CrmDashboardSourceKey = (typeof CRM_DASHBOARD_SOURCE_ORDER)[number];
192
+
193
+ type DashboardListPersonItem = {
194
+ id: number;
195
+ name: string;
196
+ trade_name: string | null;
197
+ owner_user: { id: number; name: string } | null;
198
+ source: CrmDashboardSourceKey;
199
+ lifecycle_stage: CrmDashboardStageKey;
200
+ next_action_at?: string;
201
+ created_at?: string;
202
+ };
203
+
204
+ type DashboardOwnerPerformanceItem = {
205
+ owner_user_id: number;
206
+ owner_name: string;
207
+ leads: number;
208
+ customers: number;
209
+ pipeline_value: number;
210
+ };
211
+
212
+ type DashboardBucket<T extends string> = {
213
+ key: T;
214
+ total: number;
215
+ };
216
+
217
+ type DashboardPayload = {
218
+ kpis: {
219
+ total_leads: number;
220
+ qualified: number;
221
+ proposal: number;
222
+ customers: number;
223
+ lost: number;
224
+ unassigned: number;
225
+ overdue: number;
226
+ next_actions: number;
227
+ };
228
+ charts: {
229
+ stage: Array<DashboardBucket<CrmDashboardStageKey>>;
230
+ source: Array<DashboardBucket<CrmDashboardSourceKey>>;
231
+ owner_performance: DashboardOwnerPerformanceItem[];
232
+ };
233
+ lists: {
234
+ next_actions: DashboardListPersonItem[];
235
+ unattended: DashboardListPersonItem[];
236
+ };
237
+ };
238
+
62
239
  @Injectable()
63
240
  export class PersonService {
64
241
  constructor(
@@ -112,6 +289,139 @@ export class PersonService {
112
289
  };
113
290
  }
114
291
 
292
+ async getDashboard(
293
+ query: DashboardQueryDTO,
294
+ locale: string,
295
+ ): Promise<DashboardPayload> {
296
+ const allowCompanyRegistration = await this.isCompanyRegistrationAllowed();
297
+ const ownerUserId = this.coerceNumber(query.owner_user_id);
298
+ const ranges = this.resolveDashboardRanges(query, locale);
299
+
300
+ const where: Prisma.personWhereInput = {};
301
+
302
+ if (!allowCompanyRegistration) {
303
+ where.type = 'individual';
304
+ }
305
+
306
+ if (ownerUserId > 0) {
307
+ where.AND = [
308
+ {
309
+ person_metadata: {
310
+ some: {
311
+ key: OWNER_USER_METADATA_KEY,
312
+ value: ownerUserId as any,
313
+ },
314
+ },
315
+ },
316
+ ];
317
+ }
318
+
319
+ const people = await this.prismaService.person.findMany({
320
+ where,
321
+ select: {
322
+ id: true,
323
+ name: true,
324
+ type: true,
325
+ status: true,
326
+ avatar_id: true,
327
+ created_at: true,
328
+ updated_at: true,
329
+ person_metadata: true,
330
+ },
331
+ orderBy: {
332
+ created_at: 'desc',
333
+ },
334
+ });
335
+
336
+ const enriched = await this.enrichPeople(people as any[], allowCompanyRegistration);
337
+ const createdScoped = enriched.filter((person) =>
338
+ this.isDateWithinRange(person.created_at, ranges.created),
339
+ );
340
+ const nextActionScoped = enriched.filter(
341
+ (person) =>
342
+ !!person.next_action_at &&
343
+ this.isDateWithinRange(person.next_action_at, ranges.operational),
344
+ );
345
+
346
+ const kpis = {
347
+ total_leads: createdScoped.length,
348
+ qualified: createdScoped.filter((person) =>
349
+ ['qualified', 'proposal', 'negotiation', 'customer'].includes(
350
+ person.lifecycle_stage ?? 'new',
351
+ ),
352
+ ).length,
353
+ proposal: createdScoped.filter(
354
+ (person) => person.lifecycle_stage === 'proposal',
355
+ ).length,
356
+ customers: createdScoped.filter(
357
+ (person) => person.lifecycle_stage === 'customer',
358
+ ).length,
359
+ lost: createdScoped.filter((person) => person.lifecycle_stage === 'lost')
360
+ .length,
361
+ unassigned: createdScoped.filter((person) => !person.owner_user_id).length,
362
+ overdue: nextActionScoped.filter((person) => {
363
+ const nextActionAt = this.parseDateOrNull(person.next_action_at);
364
+ return !!nextActionAt && nextActionAt.getTime() < Date.now();
365
+ }).length,
366
+ next_actions: nextActionScoped.length,
367
+ };
368
+
369
+ const charts = {
370
+ stage: CRM_DASHBOARD_STAGE_ORDER.map((key) => ({
371
+ key,
372
+ total: createdScoped.filter(
373
+ (person) => (person.lifecycle_stage ?? 'new') === key,
374
+ ).length,
375
+ })),
376
+ source: CRM_DASHBOARD_SOURCE_ORDER.map((key) => ({
377
+ key,
378
+ total: createdScoped.filter(
379
+ (person) => (person.source ?? 'other') === key,
380
+ ).length,
381
+ })),
382
+ owner_performance: this.buildDashboardOwnerPerformance(createdScoped),
383
+ };
384
+
385
+ const lists = {
386
+ next_actions: [...nextActionScoped]
387
+ .sort(
388
+ (left, right) =>
389
+ new Date(left.next_action_at ?? 0).getTime() -
390
+ new Date(right.next_action_at ?? 0).getTime(),
391
+ )
392
+ .slice(0, 5)
393
+ .map((person) =>
394
+ this.mapDashboardListItem(person, {
395
+ includeCreatedAt: false,
396
+ includeNextActionAt: true,
397
+ }),
398
+ ),
399
+ unattended: [...createdScoped]
400
+ .filter(
401
+ (person) =>
402
+ !person.owner_user_id || (person.lifecycle_stage ?? 'new') === 'new',
403
+ )
404
+ .sort(
405
+ (left, right) =>
406
+ new Date(right.created_at ?? 0).getTime() -
407
+ new Date(left.created_at ?? 0).getTime(),
408
+ )
409
+ .slice(0, 5)
410
+ .map((person) =>
411
+ this.mapDashboardListItem(person, {
412
+ includeCreatedAt: true,
413
+ includeNextActionAt: false,
414
+ }),
415
+ ),
416
+ };
417
+
418
+ return {
419
+ kpis,
420
+ charts,
421
+ lists,
422
+ };
423
+ }
424
+
115
425
  async getOwnerOptions(currentUserId?: number) {
116
426
  const where: Prisma.userWhereInput = {
117
427
  OR: [
@@ -501,114 +811,826 @@ export class PersonService {
501
811
  };
502
812
  }
503
813
 
504
- async get(locale: string, id: number) {
814
+ async listAccounts(paginationParams: AccountListParams) {
505
815
  const allowCompanyRegistration = await this.isCompanyRegistrationAllowed();
816
+ const page = Math.max(Number(paginationParams.page || 1), 1);
817
+ const pageSize = Math.max(Number(paginationParams.pageSize || 12), 1);
818
+ const skip = (page - 1) * pageSize;
506
819
 
507
- const person = await this.prismaService.person.findUnique({
508
- where: { id },
509
- include: {
510
- person_address: {
511
- include: {
512
- address: true,
513
- },
514
- },
515
- contact: true,
516
- document: true,
517
- person_metadata: true,
518
- },
820
+ if (!allowCompanyRegistration) {
821
+ return this.createEmptyAccountPagination(page, pageSize);
822
+ }
823
+
824
+ const search = this.normalizeTextOrNull(paginationParams.search);
825
+ const filters = this.buildAccountSqlFilters({
826
+ search,
827
+ status: paginationParams.status,
828
+ lifecycleStage: paginationParams.lifecycle_stage,
519
829
  });
830
+ const orderBy = this.getAccountOrderBySql(
831
+ paginationParams.sortField,
832
+ paginationParams.sortOrder,
833
+ );
520
834
 
521
- if (!person) {
522
- throw new BadRequestException(
523
- getLocaleText('personNotFound', locale, `Person with ID ${id} not found`),
524
- );
525
- }
835
+ const totalRows = await this.prismaService.$queryRaw<Array<{ total: unknown }>>(
836
+ Prisma.sql`
837
+ SELECT COUNT(*) AS total
838
+ FROM person p
839
+ INNER JOIN person_company pc ON pc.id = p.id
840
+ WHERE p.type = 'company'
841
+ ${filters}
842
+ `,
843
+ );
844
+ const total = this.coerceCount(totalRows[0]?.total);
526
845
 
527
- if (!allowCompanyRegistration && person.type === 'company') {
528
- throw new NotFoundException(
529
- getLocaleText('personNotFound', locale, `Person with ID ${id} not found`),
530
- );
846
+ if (total === 0) {
847
+ return this.createEmptyAccountPagination(page, pageSize);
531
848
  }
532
849
 
533
- const [normalized] = await this.enrichPeople(
534
- [person as any],
535
- allowCompanyRegistration,
850
+ const rows = await this.prismaService.$queryRaw<Array<{ person_id: number }>>(
851
+ Prisma.sql`
852
+ SELECT p.id AS person_id
853
+ FROM person p
854
+ INNER JOIN person_company pc ON pc.id = p.id
855
+ WHERE p.type = 'company'
856
+ ${filters}
857
+ ORDER BY ${orderBy}, p.id ASC
858
+ LIMIT ${pageSize}
859
+ OFFSET ${skip}
860
+ `,
536
861
  );
537
- return normalized;
538
- }
539
862
 
540
- async listInteractions(id: number, locale: string) {
541
- const person = await this.ensurePersonAccessible(id, locale);
542
- const metadata = await this.prismaService.person_metadata.findFirst({
863
+ const personIds = rows.map((row) => row.person_id).filter((id) => id > 0);
864
+ if (personIds.length === 0) {
865
+ return this.createEmptyAccountPagination(page, pageSize);
866
+ }
867
+
868
+ const people = await this.loadAccountPeopleByIds(personIds);
869
+ const companies = await this.prismaService.person_company.findMany({
543
870
  where: {
544
- person_id: person.id,
545
- key: INTERACTIONS_METADATA_KEY,
546
- },
547
- select: {
548
- value: true,
871
+ id: {
872
+ in: personIds,
873
+ },
549
874
  },
550
875
  });
876
+ const personById = new Map(people.map((item) => [item.id, item]));
877
+ const companyById = new Map(companies.map((item) => [item.id, item]));
551
878
 
552
- return this.metadataToInteractions(metadata?.value);
879
+ const data = rows
880
+ .map((row) => {
881
+ const person = personById.get(row.person_id);
882
+ const company = companyById.get(row.person_id);
883
+
884
+ if (!person || !company) {
885
+ return null;
886
+ }
887
+
888
+ return this.mapAccountFromPerson(person, company);
889
+ })
890
+ .filter((item): item is AccountListItem => item != null);
891
+
892
+ const lastPage = Math.max(1, Math.ceil(total / pageSize));
893
+
894
+ return {
895
+ total,
896
+ lastPage,
897
+ page,
898
+ pageSize,
899
+ prev: page > 1 ? page - 1 : null,
900
+ next: page < lastPage ? page + 1 : null,
901
+ data,
902
+ };
553
903
  }
554
904
 
555
- async createInteraction(
556
- id: number,
557
- data: CreateInteractionDTO,
558
- locale: string,
559
- user: { id?: number; name?: string | null },
560
- ) {
561
- const person = await this.ensurePersonAccessible(id, locale);
562
- const interaction = this.buildInteractionRecord(data, user);
905
+ async getAccountStats() {
906
+ const allowCompanyRegistration = await this.isCompanyRegistrationAllowed();
563
907
 
564
- await this.prismaService.$transaction(async (tx) => {
565
- const currentInteractions = await this.loadInteractionsFromTx(tx, person.id);
566
- const nextInteractions = this.sortInteractions([
567
- interaction,
568
- ...currentInteractions,
569
- ]);
908
+ if (!allowCompanyRegistration) {
909
+ return {
910
+ total: 0,
911
+ active: 0,
912
+ customers: 0,
913
+ prospects: 0,
914
+ };
915
+ }
570
916
 
571
- await this.upsertMetadataValue(
572
- tx,
573
- person.id,
574
- INTERACTIONS_METADATA_KEY,
575
- nextInteractions,
917
+ const rows = await this.prismaService.$queryRaw<
918
+ Array<{
919
+ total: unknown;
920
+ active: unknown;
921
+ customers: unknown;
922
+ prospects: unknown;
923
+ }>
924
+ >(
925
+ Prisma.sql`
926
+ SELECT
927
+ COUNT(*) AS total,
928
+ COUNT(*) FILTER (WHERE p.status = 'active') AS active,
929
+ COUNT(*) FILTER (WHERE pc.account_lifecycle_stage = 'customer') AS customers,
930
+ COUNT(*) FILTER (WHERE pc.account_lifecycle_stage = 'prospect') AS prospects
931
+ FROM person p
932
+ INNER JOIN person_company pc ON pc.id = p.id
933
+ WHERE p.type = 'company'
934
+ `,
935
+ );
936
+
937
+ return {
938
+ total: this.coerceCount(rows[0]?.total),
939
+ active: this.coerceCount(rows[0]?.active),
940
+ customers: this.coerceCount(rows[0]?.customers),
941
+ prospects: this.coerceCount(rows[0]?.prospects),
942
+ };
943
+ }
944
+
945
+ async createAccount(data: CreateAccountDTO, locale: string) {
946
+ await this.ensureCompanyRegistrationAllowed({
947
+ nextType: 'company',
948
+ locale,
949
+ });
950
+
951
+ const normalizedName = this.normalizeTextOrNull(data.name);
952
+ if (!normalizedName) {
953
+ throw new BadRequestException(
954
+ getLocaleText('validation.nameMustBeString', locale, 'Name is required.'),
576
955
  );
577
- await this.upsertMetadataValue(
956
+ }
957
+
958
+ return this.prismaService.$transaction(async (tx) => {
959
+ const person = await tx.person.create({
960
+ data: {
961
+ name: normalizedName,
962
+ type: 'company',
963
+ status: data.status,
964
+ },
965
+ });
966
+
967
+ await this.syncPersonSubtypeData(
578
968
  tx,
579
969
  person.id,
580
- LAST_INTERACTION_AT_METADATA_KEY,
581
- interaction.created_at,
970
+ null,
971
+ {
972
+ ...data,
973
+ type: 'company',
974
+ name: normalizedName,
975
+ },
976
+ locale,
582
977
  );
583
- });
978
+ await this.syncPersonMetadata(tx, person.id, {
979
+ owner_user_id: data.owner_user_id,
980
+ });
981
+ await this.upsertPrimaryAccountContact(tx, person.id, 'EMAIL', data.email);
982
+ await this.upsertPrimaryAccountContact(tx, person.id, 'PHONE', data.phone);
584
983
 
585
- return interaction;
984
+ return {
985
+ success: true,
986
+ id: person.id,
987
+ };
988
+ });
586
989
  }
587
990
 
588
- async scheduleFollowup(
589
- id: number,
590
- data: CreateFollowupDTO,
591
- locale: string,
592
- user: { id?: number; name?: string | null },
593
- ) {
594
- const person = await this.ensurePersonAccessible(id, locale);
595
- const normalizedNextActionAt = this.normalizeDateTimeOrNull(data.next_action_at);
991
+ async updateAccount(id: number, data: UpdateAccountDTO, locale: string) {
992
+ await this.ensureCompanyRegistrationAllowed({
993
+ currentType: 'company',
994
+ nextType: 'company',
995
+ locale,
996
+ });
596
997
 
597
- if (!normalizedNextActionAt) {
998
+ const person = await this.ensureCompanyAccountAccessible(id, locale);
999
+ const nextName = this.normalizeTextOrNull(data.name) ?? person.name;
1000
+
1001
+ return this.prismaService.$transaction(async (tx) => {
1002
+ await tx.person.update({
1003
+ where: { id },
1004
+ data: {
1005
+ name: nextName,
1006
+ type: 'company',
1007
+ status: data.status ?? person.status,
1008
+ },
1009
+ });
1010
+
1011
+ await this.syncPersonSubtypeData(
1012
+ tx,
1013
+ id,
1014
+ 'company',
1015
+ {
1016
+ ...data,
1017
+ type: 'company',
1018
+ name: nextName,
1019
+ },
1020
+ locale,
1021
+ );
1022
+ await this.syncPersonMetadata(tx, id, {
1023
+ owner_user_id: data.owner_user_id,
1024
+ });
1025
+ await this.upsertPrimaryAccountContact(tx, id, 'EMAIL', data.email);
1026
+ await this.upsertPrimaryAccountContact(tx, id, 'PHONE', data.phone);
1027
+
1028
+ return {
1029
+ success: true,
1030
+ id,
1031
+ };
1032
+ });
1033
+ }
1034
+
1035
+ async deleteAccounts({ ids }: DeleteDTO, locale: string) {
1036
+ if (ids == undefined || ids == null) {
598
1037
  throw new BadRequestException(
599
1038
  getLocaleText(
600
- 'validation.dateMustBeString',
1039
+ 'deleteItemsRequired',
601
1040
  locale,
602
- 'next_action_at must be a valid datetime.',
1041
+ 'You must select at least one item to delete.',
603
1042
  ),
604
1043
  );
605
1044
  }
606
1045
 
607
- await this.prismaService.$transaction(async (tx) => {
608
- await this.upsertMetadataValue(
609
- tx,
610
- person.id,
611
- NEXT_ACTION_AT_METADATA_KEY,
1046
+ const companies = await this.prismaService.person.findMany({
1047
+ where: {
1048
+ id: {
1049
+ in: ids,
1050
+ },
1051
+ },
1052
+ select: {
1053
+ id: true,
1054
+ type: true,
1055
+ },
1056
+ });
1057
+
1058
+ const existingIds = companies.map((item) => item.id);
1059
+ const missingIds = ids.filter((itemId) => !existingIds.includes(itemId));
1060
+ if (missingIds.length > 0) {
1061
+ throw new BadRequestException(
1062
+ getLocaleText(
1063
+ 'personNotFound',
1064
+ locale,
1065
+ `Person(s) with ID(s) ${missingIds.join(', ')} not found.`,
1066
+ ),
1067
+ );
1068
+ }
1069
+
1070
+ const invalidIds = companies
1071
+ .filter((item) => item.type !== 'company')
1072
+ .map((item) => item.id);
1073
+ if (invalidIds.length > 0) {
1074
+ throw new BadRequestException(
1075
+ getLocaleText(
1076
+ 'companyRelationInvalidTarget',
1077
+ locale,
1078
+ 'Only company records can be managed as accounts.',
1079
+ ),
1080
+ );
1081
+ }
1082
+
1083
+ return this.delete({ ids }, locale);
1084
+ }
1085
+
1086
+ async listActivities(paginationParams: CrmActivityListParams) {
1087
+ const allowCompanyRegistration = await this.isCompanyRegistrationAllowed();
1088
+ const page = Math.max(Number(paginationParams.page || 1), 1);
1089
+ const pageSize = Math.max(Number(paginationParams.pageSize || 12), 1);
1090
+ const skip = (page - 1) * pageSize;
1091
+ const filters = this.buildCrmActivitySqlFilters({
1092
+ allowCompanyRegistration,
1093
+ search: this.normalizeTextOrNull(paginationParams.search),
1094
+ status: paginationParams.status,
1095
+ type: paginationParams.type,
1096
+ priority: paginationParams.priority,
1097
+ });
1098
+
1099
+ const totalRows = await this.prismaService.$queryRaw<Array<{ total: unknown }>>(
1100
+ Prisma.sql`
1101
+ SELECT COUNT(*) AS total
1102
+ FROM crm_activity a
1103
+ INNER JOIN person p ON p.id = a.person_id
1104
+ LEFT JOIN "user" owner_user ON owner_user.id = a.owner_user_id
1105
+ WHERE 1 = 1
1106
+ ${filters}
1107
+ `,
1108
+ );
1109
+ const total = this.coerceCount(totalRows[0]?.total);
1110
+
1111
+ if (total === 0) {
1112
+ return this.createEmptyCrmActivityPagination(page, pageSize);
1113
+ }
1114
+
1115
+ const rows = await this.prismaService.$queryRaw<Array<Record<string, unknown>>>(
1116
+ Prisma.sql`
1117
+ SELECT
1118
+ a.id,
1119
+ a.person_id,
1120
+ a.owner_user_id,
1121
+ a.type,
1122
+ a.subject,
1123
+ a.notes,
1124
+ a.due_at,
1125
+ a.completed_at,
1126
+ a.created_at,
1127
+ a.priority,
1128
+ p.name AS person_name,
1129
+ p.type AS person_type,
1130
+ p.status AS person_status,
1131
+ pc.trade_name AS person_trade_name,
1132
+ owner_user.name AS owner_user_name
1133
+ FROM crm_activity a
1134
+ INNER JOIN person p ON p.id = a.person_id
1135
+ LEFT JOIN person_company pc ON pc.id = p.id
1136
+ LEFT JOIN "user" owner_user ON owner_user.id = a.owner_user_id
1137
+ WHERE 1 = 1
1138
+ ${filters}
1139
+ ORDER BY a.due_at ASC, a.id ASC
1140
+ LIMIT ${pageSize}
1141
+ OFFSET ${skip}
1142
+ `,
1143
+ );
1144
+
1145
+ const data = rows.map((row) => this.mapCrmActivityListRow(row));
1146
+ const lastPage = Math.max(1, Math.ceil(total / pageSize));
1147
+
1148
+ return {
1149
+ total,
1150
+ lastPage,
1151
+ page,
1152
+ pageSize,
1153
+ prev: page > 1 ? page - 1 : null,
1154
+ next: page < lastPage ? page + 1 : null,
1155
+ data,
1156
+ };
1157
+ }
1158
+
1159
+ async getActivityStats() {
1160
+ const allowCompanyRegistration = await this.isCompanyRegistrationAllowed();
1161
+ const visibilityFilter = allowCompanyRegistration
1162
+ ? Prisma.empty
1163
+ : Prisma.sql`AND p.type = 'individual'`;
1164
+
1165
+ const rows = await this.prismaService.$queryRaw<
1166
+ Array<{
1167
+ total: unknown;
1168
+ pending: unknown;
1169
+ overdue: unknown;
1170
+ completed: unknown;
1171
+ }>
1172
+ >(
1173
+ Prisma.sql`
1174
+ SELECT
1175
+ COUNT(*) AS total,
1176
+ COUNT(*) FILTER (
1177
+ WHERE a.completed_at IS NULL
1178
+ AND a.due_at >= NOW()
1179
+ ) AS pending,
1180
+ COUNT(*) FILTER (
1181
+ WHERE a.completed_at IS NULL
1182
+ AND a.due_at < NOW()
1183
+ ) AS overdue,
1184
+ COUNT(*) FILTER (
1185
+ WHERE a.completed_at IS NOT NULL
1186
+ ) AS completed
1187
+ FROM crm_activity a
1188
+ INNER JOIN person p ON p.id = a.person_id
1189
+ WHERE 1 = 1
1190
+ ${visibilityFilter}
1191
+ `,
1192
+ );
1193
+
1194
+ return {
1195
+ total: this.coerceCount(rows[0]?.total),
1196
+ pending: this.coerceCount(rows[0]?.pending),
1197
+ overdue: this.coerceCount(rows[0]?.overdue),
1198
+ completed: this.coerceCount(rows[0]?.completed),
1199
+ };
1200
+ }
1201
+
1202
+ async getActivity(id: number, locale: string): Promise<CrmActivityDetail> {
1203
+ const allowCompanyRegistration = await this.isCompanyRegistrationAllowed();
1204
+ const visibilityFilter = allowCompanyRegistration
1205
+ ? Prisma.empty
1206
+ : Prisma.sql`AND p.type = 'individual'`;
1207
+
1208
+ const rows = await this.prismaService.$queryRaw<Array<Record<string, unknown>>>(
1209
+ Prisma.sql`
1210
+ SELECT
1211
+ a.id,
1212
+ a.person_id,
1213
+ a.owner_user_id,
1214
+ a.created_by_user_id,
1215
+ a.completed_by_user_id,
1216
+ a.type,
1217
+ a.subject,
1218
+ a.notes,
1219
+ a.due_at,
1220
+ a.completed_at,
1221
+ a.created_at,
1222
+ a.priority,
1223
+ a.source_kind,
1224
+ p.name AS person_name,
1225
+ p.type AS person_type,
1226
+ p.status AS person_status,
1227
+ pc.trade_name AS person_trade_name,
1228
+ owner_user.name AS owner_user_name,
1229
+ created_by_user.name AS created_by_user_name,
1230
+ completed_by_user.name AS completed_by_user_name
1231
+ FROM crm_activity a
1232
+ INNER JOIN person p ON p.id = a.person_id
1233
+ LEFT JOIN person_company pc ON pc.id = p.id
1234
+ LEFT JOIN "user" owner_user ON owner_user.id = a.owner_user_id
1235
+ LEFT JOIN "user" created_by_user ON created_by_user.id = a.created_by_user_id
1236
+ LEFT JOIN "user" completed_by_user ON completed_by_user.id = a.completed_by_user_id
1237
+ WHERE a.id = ${id}
1238
+ ${visibilityFilter}
1239
+ LIMIT 1
1240
+ `,
1241
+ );
1242
+
1243
+ const row = rows[0];
1244
+ if (!row) {
1245
+ throw new NotFoundException(
1246
+ getLocaleText(
1247
+ 'personNotFound',
1248
+ locale,
1249
+ `Activity with ID ${id} not found`,
1250
+ ),
1251
+ );
1252
+ }
1253
+
1254
+ return this.mapCrmActivityDetailRow(row);
1255
+ }
1256
+
1257
+ async completeActivity(
1258
+ id: number,
1259
+ locale: string,
1260
+ user: { id?: number; name?: string | null },
1261
+ ) {
1262
+ const actorUserId = Number(user?.id || 0) || null;
1263
+ const allowCompanyRegistration = await this.isCompanyRegistrationAllowed();
1264
+ const visibilityFilter = allowCompanyRegistration
1265
+ ? Prisma.empty
1266
+ : Prisma.sql`AND p.type = 'individual'`;
1267
+
1268
+ return this.prismaService.$transaction(async (tx) => {
1269
+ const rows = (await tx.$queryRaw(
1270
+ Prisma.sql`
1271
+ SELECT
1272
+ a.id,
1273
+ a.person_id,
1274
+ a.completed_at,
1275
+ a.source_kind
1276
+ FROM crm_activity a
1277
+ INNER JOIN person p ON p.id = a.person_id
1278
+ WHERE a.id = ${id}
1279
+ ${visibilityFilter}
1280
+ LIMIT 1
1281
+ `,
1282
+ )) as Array<{
1283
+ id: number;
1284
+ person_id: number;
1285
+ completed_at: Date | null;
1286
+ source_kind: CrmActivitySourceKind;
1287
+ }>;
1288
+ const activity = rows[0];
1289
+
1290
+ if (!activity) {
1291
+ throw new NotFoundException(
1292
+ getLocaleText(
1293
+ 'personNotFound',
1294
+ locale,
1295
+ `Activity with ID ${id} not found`,
1296
+ ),
1297
+ );
1298
+ }
1299
+
1300
+ if (activity.completed_at) {
1301
+ return {
1302
+ success: true,
1303
+ completed_at: activity.completed_at.toISOString(),
1304
+ };
1305
+ }
1306
+
1307
+ const completedAt = new Date();
1308
+
1309
+ await tx.$executeRaw(
1310
+ Prisma.sql`
1311
+ UPDATE crm_activity
1312
+ SET
1313
+ completed_at = CAST(${completedAt.toISOString()} AS TIMESTAMPTZ),
1314
+ completed_by_user_id = ${actorUserId},
1315
+ updated_at = NOW()
1316
+ WHERE id = ${id}
1317
+ `,
1318
+ );
1319
+
1320
+ if (activity.source_kind === 'followup') {
1321
+ await this.upsertMetadataValue(
1322
+ tx,
1323
+ activity.person_id,
1324
+ NEXT_ACTION_AT_METADATA_KEY,
1325
+ null,
1326
+ );
1327
+ }
1328
+
1329
+ return {
1330
+ success: true,
1331
+ completed_at: completedAt.toISOString(),
1332
+ };
1333
+ });
1334
+ }
1335
+
1336
+ async listFollowups(
1337
+ paginationParams: FollowupListParams,
1338
+ _currentUserId?: number,
1339
+ ) {
1340
+ const allowCompanyRegistration = await this.isCompanyRegistrationAllowed();
1341
+ const page = Math.max(Number(paginationParams.page || 1), 1);
1342
+ const pageSize = Math.max(Number(paginationParams.pageSize || 12), 1);
1343
+ const skip = (page - 1) * pageSize;
1344
+ const searchPersonIds = await this.findFollowupSearchPersonIds(
1345
+ paginationParams.search,
1346
+ allowCompanyRegistration,
1347
+ );
1348
+
1349
+ if (searchPersonIds && searchPersonIds.length === 0) {
1350
+ return this.createEmptyFollowupPagination(page, pageSize);
1351
+ }
1352
+
1353
+ const filters = this.buildFollowupSqlFilters({
1354
+ allowCompanyRegistration,
1355
+ searchPersonIds,
1356
+ status: paginationParams.status,
1357
+ dateFrom: paginationParams.date_from,
1358
+ dateTo: paginationParams.date_to,
1359
+ });
1360
+ const followupTimestampSql = this.getFollowupTimestampSql();
1361
+ const totalRows = await this.prismaService.$queryRaw<Array<{ total: unknown }>>(
1362
+ Prisma.sql`
1363
+ SELECT COUNT(*) AS total
1364
+ FROM person p
1365
+ INNER JOIN person_metadata pm_next
1366
+ ON pm_next.person_id = p.id
1367
+ AND pm_next.key = ${NEXT_ACTION_AT_METADATA_KEY}
1368
+ WHERE 1 = 1
1369
+ ${filters}
1370
+ `,
1371
+ );
1372
+ const total = this.coerceCount(totalRows[0]?.total);
1373
+
1374
+ if (total === 0) {
1375
+ return this.createEmptyFollowupPagination(page, pageSize);
1376
+ }
1377
+
1378
+ const rows = await this.prismaService.$queryRaw<Array<{ person_id: number }>>(
1379
+ Prisma.sql`
1380
+ SELECT p.id AS person_id
1381
+ FROM person p
1382
+ INNER JOIN person_metadata pm_next
1383
+ ON pm_next.person_id = p.id
1384
+ AND pm_next.key = ${NEXT_ACTION_AT_METADATA_KEY}
1385
+ WHERE 1 = 1
1386
+ ${filters}
1387
+ ORDER BY ${followupTimestampSql} ASC, p.id ASC
1388
+ LIMIT ${pageSize}
1389
+ OFFSET ${skip}
1390
+ `,
1391
+ );
1392
+
1393
+ const personIds = rows.map((row) => row.person_id).filter((id) => id > 0);
1394
+ const people =
1395
+ personIds.length > 0
1396
+ ? await this.prismaService.person.findMany({
1397
+ where: {
1398
+ id: {
1399
+ in: personIds,
1400
+ },
1401
+ },
1402
+ include: {
1403
+ person_address: {
1404
+ include: {
1405
+ address: true,
1406
+ },
1407
+ },
1408
+ contact: true,
1409
+ document: true,
1410
+ person_metadata: true,
1411
+ },
1412
+ })
1413
+ : [];
1414
+
1415
+ const enrichedPeople = await this.enrichPeople(
1416
+ people as any[],
1417
+ allowCompanyRegistration,
1418
+ );
1419
+ const personById = new Map(
1420
+ enrichedPeople.map((person) => [person.id, person]),
1421
+ );
1422
+ const data = rows
1423
+ .map((row) => {
1424
+ const person = personById.get(row.person_id);
1425
+ const nextActionAt = this.normalizeDateTimeOrNull(person?.next_action_at);
1426
+
1427
+ if (!person || !nextActionAt) {
1428
+ return null;
1429
+ }
1430
+
1431
+ return {
1432
+ person,
1433
+ next_action_at: nextActionAt,
1434
+ last_interaction_at:
1435
+ this.normalizeDateTimeOrNull(person.last_interaction_at) ?? null,
1436
+ status: this.getFollowupStatus(nextActionAt),
1437
+ } as FollowupListItem;
1438
+ })
1439
+ .filter((item): item is FollowupListItem => item != null);
1440
+
1441
+ const lastPage = Math.max(1, Math.ceil(total / pageSize));
1442
+
1443
+ return {
1444
+ total,
1445
+ lastPage,
1446
+ page,
1447
+ pageSize,
1448
+ prev: page > 1 ? page - 1 : null,
1449
+ next: page < lastPage ? page + 1 : null,
1450
+ data,
1451
+ };
1452
+ }
1453
+
1454
+ async getFollowupStats(
1455
+ query: FollowupStatsQueryDTO,
1456
+ _currentUserId?: number,
1457
+ ) {
1458
+ const allowCompanyRegistration = await this.isCompanyRegistrationAllowed();
1459
+ const searchPersonIds = await this.findFollowupSearchPersonIds(
1460
+ query.search,
1461
+ allowCompanyRegistration,
1462
+ );
1463
+
1464
+ if (searchPersonIds && searchPersonIds.length === 0) {
1465
+ return {
1466
+ total: 0,
1467
+ today: 0,
1468
+ overdue: 0,
1469
+ upcoming: 0,
1470
+ };
1471
+ }
1472
+
1473
+ const filters = this.buildFollowupSqlFilters({
1474
+ allowCompanyRegistration,
1475
+ searchPersonIds,
1476
+ });
1477
+ const followupTimestampSql = this.getFollowupTimestampSql();
1478
+ const { todayStartIso, tomorrowStartIso } =
1479
+ this.getFollowupDayBoundaryIsoStrings();
1480
+ const rows = await this.prismaService.$queryRaw<
1481
+ Array<{
1482
+ total: unknown;
1483
+ today: unknown;
1484
+ overdue: unknown;
1485
+ upcoming: unknown;
1486
+ }>
1487
+ >(
1488
+ Prisma.sql`
1489
+ SELECT
1490
+ COUNT(*) AS total,
1491
+ COUNT(*) FILTER (
1492
+ WHERE ${followupTimestampSql} >= CAST(${todayStartIso} AS TIMESTAMPTZ)
1493
+ AND ${followupTimestampSql} < CAST(${tomorrowStartIso} AS TIMESTAMPTZ)
1494
+ ) AS today,
1495
+ COUNT(*) FILTER (
1496
+ WHERE ${followupTimestampSql} < CAST(${todayStartIso} AS TIMESTAMPTZ)
1497
+ ) AS overdue,
1498
+ COUNT(*) FILTER (
1499
+ WHERE ${followupTimestampSql} >= CAST(${tomorrowStartIso} AS TIMESTAMPTZ)
1500
+ ) AS upcoming
1501
+ FROM person p
1502
+ INNER JOIN person_metadata pm_next
1503
+ ON pm_next.person_id = p.id
1504
+ AND pm_next.key = ${NEXT_ACTION_AT_METADATA_KEY}
1505
+ WHERE 1 = 1
1506
+ ${filters}
1507
+ `,
1508
+ );
1509
+
1510
+ return {
1511
+ total: this.coerceCount(rows[0]?.total),
1512
+ today: this.coerceCount(rows[0]?.today),
1513
+ overdue: this.coerceCount(rows[0]?.overdue),
1514
+ upcoming: this.coerceCount(rows[0]?.upcoming),
1515
+ };
1516
+ }
1517
+
1518
+ async get(locale: string, id: number) {
1519
+ const allowCompanyRegistration = await this.isCompanyRegistrationAllowed();
1520
+
1521
+ const person = await this.prismaService.person.findUnique({
1522
+ where: { id },
1523
+ include: {
1524
+ person_address: {
1525
+ include: {
1526
+ address: true,
1527
+ },
1528
+ },
1529
+ contact: true,
1530
+ document: true,
1531
+ person_metadata: true,
1532
+ },
1533
+ });
1534
+
1535
+ if (!person) {
1536
+ throw new BadRequestException(
1537
+ getLocaleText('personNotFound', locale, `Person with ID ${id} not found`),
1538
+ );
1539
+ }
1540
+
1541
+ if (!allowCompanyRegistration && person.type === 'company') {
1542
+ throw new NotFoundException(
1543
+ getLocaleText('personNotFound', locale, `Person with ID ${id} not found`),
1544
+ );
1545
+ }
1546
+
1547
+ const [normalized] = await this.enrichPeople(
1548
+ [person as any],
1549
+ allowCompanyRegistration,
1550
+ );
1551
+ return normalized;
1552
+ }
1553
+
1554
+ async listInteractions(id: number, locale: string) {
1555
+ const person = await this.ensurePersonAccessible(id, locale);
1556
+ const metadata = await this.prismaService.person_metadata.findFirst({
1557
+ where: {
1558
+ person_id: person.id,
1559
+ key: INTERACTIONS_METADATA_KEY,
1560
+ },
1561
+ select: {
1562
+ value: true,
1563
+ },
1564
+ });
1565
+
1566
+ return this.metadataToInteractions(metadata?.value);
1567
+ }
1568
+
1569
+ async createInteraction(
1570
+ id: number,
1571
+ data: CreateInteractionDTO,
1572
+ locale: string,
1573
+ user: { id?: number; name?: string | null },
1574
+ ) {
1575
+ const person = await this.ensurePersonAccessible(id, locale);
1576
+ const interaction = this.buildInteractionRecord(data, user);
1577
+ const ownerUserId = await this.getPersonOwnerUserId(person.id);
1578
+
1579
+ await this.prismaService.$transaction(async (tx) => {
1580
+ const currentInteractions = await this.loadInteractionsFromTx(tx, person.id);
1581
+ const nextInteractions = this.sortInteractions([
1582
+ interaction,
1583
+ ...currentInteractions,
1584
+ ]);
1585
+
1586
+ await this.upsertMetadataValue(
1587
+ tx,
1588
+ person.id,
1589
+ INTERACTIONS_METADATA_KEY,
1590
+ nextInteractions,
1591
+ );
1592
+ await this.upsertMetadataValue(
1593
+ tx,
1594
+ person.id,
1595
+ LAST_INTERACTION_AT_METADATA_KEY,
1596
+ interaction.created_at,
1597
+ );
1598
+ await this.createCompletedInteractionActivity(tx, {
1599
+ personId: person.id,
1600
+ ownerUserId,
1601
+ interaction,
1602
+ actorUserId: Number(user?.id || 0) || null,
1603
+ });
1604
+ });
1605
+
1606
+ return interaction;
1607
+ }
1608
+
1609
+ async scheduleFollowup(
1610
+ id: number,
1611
+ data: CreateFollowupDTO,
1612
+ locale: string,
1613
+ user: { id?: number; name?: string | null },
1614
+ ) {
1615
+ const person = await this.ensurePersonAccessible(id, locale);
1616
+ const normalizedNextActionAt = this.normalizeDateTimeOrNull(data.next_action_at);
1617
+ const ownerUserId = await this.getPersonOwnerUserId(person.id);
1618
+
1619
+ if (!normalizedNextActionAt) {
1620
+ throw new BadRequestException(
1621
+ getLocaleText(
1622
+ 'validation.dateMustBeString',
1623
+ locale,
1624
+ 'next_action_at must be a valid datetime.',
1625
+ ),
1626
+ );
1627
+ }
1628
+
1629
+ await this.prismaService.$transaction(async (tx) => {
1630
+ await this.upsertMetadataValue(
1631
+ tx,
1632
+ person.id,
1633
+ NEXT_ACTION_AT_METADATA_KEY,
612
1634
  normalizedNextActionAt,
613
1635
  );
614
1636
 
@@ -640,6 +1662,14 @@ export class PersonService {
640
1662
  interaction.created_at,
641
1663
  );
642
1664
  }
1665
+
1666
+ await this.upsertFollowupActivity(tx, {
1667
+ personId: person.id,
1668
+ ownerUserId,
1669
+ dueAt: normalizedNextActionAt,
1670
+ notes: data.notes,
1671
+ actorUserId: Number(user?.id || 0) || null,
1672
+ });
643
1673
  });
644
1674
 
645
1675
  return {
@@ -861,6 +1891,185 @@ export class PersonService {
861
1891
  ]);
862
1892
  }
863
1893
 
1894
+ private async findFollowupSearchPersonIds(
1895
+ search: string | undefined,
1896
+ allowCompanyRegistration: boolean,
1897
+ ) {
1898
+ const normalizedSearch = this.normalizeTextOrNull(search);
1899
+
1900
+ if (!normalizedSearch) {
1901
+ return null;
1902
+ }
1903
+
1904
+ const where: Prisma.personWhereInput = {
1905
+ OR: await this.buildSearchFilters(normalizedSearch),
1906
+ };
1907
+
1908
+ if (!allowCompanyRegistration) {
1909
+ where.type = 'individual';
1910
+ }
1911
+
1912
+ const people = await this.prismaService.person.findMany({
1913
+ where,
1914
+ select: {
1915
+ id: true,
1916
+ },
1917
+ });
1918
+
1919
+ return people.map((person) => person.id).filter((id) => id > 0);
1920
+ }
1921
+
1922
+ private buildFollowupSqlFilters({
1923
+ allowCompanyRegistration,
1924
+ searchPersonIds,
1925
+ status,
1926
+ dateFrom,
1927
+ dateTo,
1928
+ }: {
1929
+ allowCompanyRegistration: boolean;
1930
+ searchPersonIds?: number[] | null;
1931
+ status?: 'all' | FollowupStatus;
1932
+ dateFrom?: string;
1933
+ dateTo?: string;
1934
+ }) {
1935
+ const filters: Prisma.Sql[] = [];
1936
+ const followupTimestampSql = this.getFollowupTimestampSql();
1937
+
1938
+ if (!allowCompanyRegistration) {
1939
+ filters.push(Prisma.sql`AND p.type = 'individual'`);
1940
+ }
1941
+
1942
+ if (searchPersonIds && searchPersonIds.length > 0) {
1943
+ filters.push(
1944
+ Prisma.sql`AND p.id IN (${Prisma.join(searchPersonIds)})`,
1945
+ );
1946
+ }
1947
+
1948
+ const { todayStartIso, tomorrowStartIso } =
1949
+ this.getFollowupDayBoundaryIsoStrings();
1950
+
1951
+ if (status === 'overdue') {
1952
+ filters.push(
1953
+ Prisma.sql`AND ${followupTimestampSql} < CAST(${todayStartIso} AS TIMESTAMPTZ)`,
1954
+ );
1955
+ } else if (status === 'today') {
1956
+ filters.push(
1957
+ Prisma.sql`AND ${followupTimestampSql} >= CAST(${todayStartIso} AS TIMESTAMPTZ)`,
1958
+ );
1959
+ filters.push(
1960
+ Prisma.sql`AND ${followupTimestampSql} < CAST(${tomorrowStartIso} AS TIMESTAMPTZ)`,
1961
+ );
1962
+ } else if (status === 'upcoming') {
1963
+ filters.push(
1964
+ Prisma.sql`AND ${followupTimestampSql} >= CAST(${tomorrowStartIso} AS TIMESTAMPTZ)`,
1965
+ );
1966
+ }
1967
+
1968
+ const dateFromIso = this.normalizeDateOnlyBoundary(dateFrom, 'start');
1969
+ if (dateFromIso) {
1970
+ filters.push(
1971
+ Prisma.sql`AND ${followupTimestampSql} >= CAST(${dateFromIso} AS TIMESTAMPTZ)`,
1972
+ );
1973
+ }
1974
+
1975
+ const dateToIso = this.normalizeDateOnlyBoundary(dateTo, 'endExclusive');
1976
+ if (dateToIso) {
1977
+ filters.push(
1978
+ Prisma.sql`AND ${followupTimestampSql} < CAST(${dateToIso} AS TIMESTAMPTZ)`,
1979
+ );
1980
+ }
1981
+
1982
+ return filters.length > 0
1983
+ ? Prisma.join(filters, '\n')
1984
+ : Prisma.empty;
1985
+ }
1986
+
1987
+ private createEmptyFollowupPagination(page: number, pageSize: number) {
1988
+ return {
1989
+ total: 0,
1990
+ lastPage: 1,
1991
+ page,
1992
+ pageSize,
1993
+ prev: page > 1 ? page - 1 : null,
1994
+ next: null,
1995
+ data: [] as FollowupListItem[],
1996
+ };
1997
+ }
1998
+
1999
+ private getFollowupTimestampSql() {
2000
+ return Prisma.sql`CAST(TRIM(BOTH '"' FROM pm_next.value::text) AS TIMESTAMPTZ)`;
2001
+ }
2002
+
2003
+ private getFollowupDayBoundaryDates(reference = new Date()) {
2004
+ const todayStart = new Date(
2005
+ reference.getFullYear(),
2006
+ reference.getMonth(),
2007
+ reference.getDate(),
2008
+ );
2009
+ const tomorrowStart = new Date(todayStart);
2010
+ tomorrowStart.setDate(tomorrowStart.getDate() + 1);
2011
+
2012
+ return {
2013
+ todayStart,
2014
+ tomorrowStart,
2015
+ };
2016
+ }
2017
+
2018
+ private getFollowupDayBoundaryIsoStrings(reference = new Date()) {
2019
+ const { todayStart, tomorrowStart } =
2020
+ this.getFollowupDayBoundaryDates(reference);
2021
+
2022
+ return {
2023
+ todayStartIso: todayStart.toISOString(),
2024
+ tomorrowStartIso: tomorrowStart.toISOString(),
2025
+ };
2026
+ }
2027
+
2028
+ private normalizeDateOnlyBoundary(
2029
+ value: string | undefined,
2030
+ mode: 'start' | 'endExclusive',
2031
+ ) {
2032
+ if (!value) {
2033
+ return null;
2034
+ }
2035
+
2036
+ const parsed = new Date(`${value}T00:00:00`);
2037
+
2038
+ if (Number.isNaN(parsed.getTime())) {
2039
+ return null;
2040
+ }
2041
+
2042
+ if (mode === 'endExclusive') {
2043
+ parsed.setDate(parsed.getDate() + 1);
2044
+ }
2045
+
2046
+ return parsed.toISOString();
2047
+ }
2048
+
2049
+ private getFollowupStatus(nextActionAt: string): FollowupStatus {
2050
+ const parsed = new Date(nextActionAt);
2051
+ const { todayStart, tomorrowStart } = this.getFollowupDayBoundaryDates();
2052
+
2053
+ if (parsed < todayStart) {
2054
+ return 'overdue';
2055
+ }
2056
+
2057
+ if (parsed < tomorrowStart) {
2058
+ return 'today';
2059
+ }
2060
+
2061
+ return 'upcoming';
2062
+ }
2063
+
2064
+ private coerceCount(value: unknown) {
2065
+ if (typeof value === 'bigint') {
2066
+ return Number(value);
2067
+ }
2068
+
2069
+ const parsed = Number(value);
2070
+ return Number.isFinite(parsed) ? parsed : 0;
2071
+ }
2072
+
864
2073
  async openPublicAvatar(locale: string, fileId: number, res: any) {
865
2074
  const personWithAvatar = await this.prismaService.person.findFirst({
866
2075
  where: {
@@ -885,7 +2094,179 @@ export class PersonService {
885
2094
  'Cache-Control': 'public, max-age=3600',
886
2095
  });
887
2096
 
888
- res.send(buffer);
2097
+ res.send(buffer);
2098
+ }
2099
+
2100
+ private resolveDashboardRanges(
2101
+ query: DashboardQueryDTO,
2102
+ locale: string,
2103
+ ) {
2104
+ const period: CrmDashboardPeriod = query.period ?? '30d';
2105
+ const now = new Date();
2106
+
2107
+ if (period === 'custom') {
2108
+ if (!query.date_from || !query.date_to) {
2109
+ throw new BadRequestException(
2110
+ getLocaleText(
2111
+ 'validation.dateMustBeString',
2112
+ locale,
2113
+ 'date_from and date_to are required when period is custom.',
2114
+ ),
2115
+ );
2116
+ }
2117
+
2118
+ const start = this.startOfDay(this.parseDateOrThrow(query.date_from, locale));
2119
+ const end = this.endOfDay(this.parseDateOrThrow(query.date_to, locale));
2120
+
2121
+ if (start.getTime() > end.getTime()) {
2122
+ throw new BadRequestException(
2123
+ getLocaleText(
2124
+ 'validation.dateMustBeString',
2125
+ locale,
2126
+ 'date_from must be less than or equal to date_to.',
2127
+ ),
2128
+ );
2129
+ }
2130
+
2131
+ return {
2132
+ created: { start, end },
2133
+ operational: { start, end },
2134
+ };
2135
+ }
2136
+
2137
+ const dayCount = Number(period.replace('d', ''));
2138
+ const createdStart = this.startOfDay(this.addDays(now, -(dayCount - 1)));
2139
+ const createdEnd = this.endOfDay(now);
2140
+ const operationalStart = this.startOfDay(this.addDays(now, -(dayCount - 1)));
2141
+ const operationalEnd = this.endOfDay(this.addDays(now, dayCount - 1));
2142
+
2143
+ return {
2144
+ created: {
2145
+ start: createdStart,
2146
+ end: createdEnd,
2147
+ },
2148
+ operational: {
2149
+ start: operationalStart,
2150
+ end: operationalEnd,
2151
+ },
2152
+ };
2153
+ }
2154
+
2155
+ private buildDashboardOwnerPerformance(people: any[]): DashboardOwnerPerformanceItem[] {
2156
+ const byOwnerId = new Map<number, DashboardOwnerPerformanceItem>();
2157
+
2158
+ for (const person of people) {
2159
+ const ownerUserId = this.coerceNumber(person.owner_user_id);
2160
+ if (ownerUserId <= 0) {
2161
+ continue;
2162
+ }
2163
+
2164
+ const current =
2165
+ byOwnerId.get(ownerUserId) ??
2166
+ {
2167
+ owner_user_id: ownerUserId,
2168
+ owner_name: person.owner_user?.name || `#${ownerUserId}`,
2169
+ leads: 0,
2170
+ customers: 0,
2171
+ pipeline_value: 0,
2172
+ };
2173
+
2174
+ current.leads += 1;
2175
+ if (person.lifecycle_stage === 'customer') {
2176
+ current.customers += 1;
2177
+ }
2178
+ if (person.lifecycle_stage !== 'lost') {
2179
+ current.pipeline_value += this.coerceNumber(person.deal_value);
2180
+ }
2181
+
2182
+ byOwnerId.set(ownerUserId, current);
2183
+ }
2184
+
2185
+ return Array.from(byOwnerId.values()).sort((left, right) =>
2186
+ left.owner_name.localeCompare(right.owner_name),
2187
+ );
2188
+ }
2189
+
2190
+ private mapDashboardListItem(
2191
+ person: any,
2192
+ {
2193
+ includeCreatedAt,
2194
+ includeNextActionAt,
2195
+ }: {
2196
+ includeCreatedAt: boolean;
2197
+ includeNextActionAt: boolean;
2198
+ },
2199
+ ): DashboardListPersonItem {
2200
+ const source = (person.source ?? 'other') as CrmDashboardSourceKey;
2201
+ const lifecycleStage = (person.lifecycle_stage ?? 'new') as CrmDashboardStageKey;
2202
+
2203
+ return {
2204
+ id: person.id,
2205
+ name: person.name,
2206
+ trade_name: person.trade_name ?? null,
2207
+ owner_user: person.owner_user ?? null,
2208
+ source,
2209
+ lifecycle_stage: lifecycleStage,
2210
+ ...(includeNextActionAt && person.next_action_at
2211
+ ? { next_action_at: person.next_action_at }
2212
+ : {}),
2213
+ ...(includeCreatedAt && person.created_at
2214
+ ? {
2215
+ created_at:
2216
+ this.normalizeDateTimeOrNull(person.created_at) ?? person.created_at,
2217
+ }
2218
+ : {}),
2219
+ };
2220
+ }
2221
+
2222
+ private isDateWithinRange(
2223
+ value: unknown,
2224
+ range: { start: Date; end: Date },
2225
+ ) {
2226
+ const parsed = this.parseDateOrNull(value);
2227
+ if (!parsed) {
2228
+ return false;
2229
+ }
2230
+
2231
+ return (
2232
+ parsed.getTime() >= range.start.getTime() &&
2233
+ parsed.getTime() <= range.end.getTime()
2234
+ );
2235
+ }
2236
+
2237
+ private parseDateOrThrow(value: string, locale: string) {
2238
+ const parsed = /^\d{4}-\d{2}-\d{2}$/.test(value)
2239
+ ? this.parseDateOrNull(`${value}T00:00:00`)
2240
+ : this.parseDateOrNull(value);
2241
+ if (!parsed) {
2242
+ throw new BadRequestException(
2243
+ getLocaleText(
2244
+ 'validation.dateMustBeString',
2245
+ locale,
2246
+ `Invalid date value: ${value}`,
2247
+ ),
2248
+ );
2249
+ }
2250
+
2251
+ return parsed;
2252
+ }
2253
+
2254
+ private addDays(date: Date, amount: number) {
2255
+ const next = new Date(date);
2256
+ next.setDate(next.getDate() + amount);
2257
+ return next;
2258
+ }
2259
+
2260
+ private startOfDay(date: Date) {
2261
+ const next = new Date(date);
2262
+ next.setHours(0, 0, 0, 0);
2263
+ return next;
2264
+ }
2265
+
2266
+ private endOfDay(date: Date) {
2267
+ const next = new Date(date);
2268
+ next.setHours(23, 59, 59, 999);
2269
+ return next;
889
2270
  }
890
2271
 
891
2272
  private async enrichPeople(people: any[], allowCompanyRegistration = true) {
@@ -1276,6 +2657,15 @@ export class PersonService {
1276
2657
  create: {
1277
2658
  id: personId,
1278
2659
  trade_name: this.normalizeTextOrNull(data.trade_name),
2660
+ industry: this.normalizeTextOrNull(data.industry),
2661
+ website: this.normalizeTextOrNull(data.website),
2662
+ annual_revenue: this.normalizeDecimalOrNull(data.annual_revenue),
2663
+ employee_count: this.normalizeIntegerOrNull(data.employee_count),
2664
+ account_lifecycle_stage: this.normalizeAccountLifecycleStage(
2665
+ data.lifecycle_stage,
2666
+ ),
2667
+ city: this.normalizeTextOrNull(data.city),
2668
+ state: this.normalizeStateOrNull(data.state),
1279
2669
  foundation_date: this.parseDateOrNull(data.foundation_date),
1280
2670
  legal_nature: this.normalizeTextOrNull(data.legal_nature),
1281
2671
  headquarter_id: normalizedHeadquarterId > 0 ? normalizedHeadquarterId : null,
@@ -1285,6 +2675,34 @@ export class PersonService {
1285
2675
  data.trade_name === undefined
1286
2676
  ? undefined
1287
2677
  : this.normalizeTextOrNull(data.trade_name),
2678
+ industry:
2679
+ data.industry === undefined
2680
+ ? undefined
2681
+ : this.normalizeTextOrNull(data.industry),
2682
+ website:
2683
+ data.website === undefined
2684
+ ? undefined
2685
+ : this.normalizeTextOrNull(data.website),
2686
+ annual_revenue:
2687
+ data.annual_revenue === undefined
2688
+ ? undefined
2689
+ : this.normalizeDecimalOrNull(data.annual_revenue),
2690
+ employee_count:
2691
+ data.employee_count === undefined
2692
+ ? undefined
2693
+ : this.normalizeIntegerOrNull(data.employee_count),
2694
+ account_lifecycle_stage:
2695
+ data.lifecycle_stage === undefined
2696
+ ? undefined
2697
+ : this.normalizeAccountLifecycleStage(data.lifecycle_stage),
2698
+ city:
2699
+ data.city === undefined
2700
+ ? undefined
2701
+ : this.normalizeTextOrNull(data.city),
2702
+ state:
2703
+ data.state === undefined
2704
+ ? undefined
2705
+ : this.normalizeStateOrNull(data.state),
1288
2706
  foundation_date:
1289
2707
  data.foundation_date === undefined
1290
2708
  ? undefined
@@ -1963,6 +3381,24 @@ export class PersonService {
1963
3381
  return Number.isNaN(date.getTime()) ? null : date;
1964
3382
  }
1965
3383
 
3384
+ private normalizeDecimalOrNull(value: unknown): Prisma.Decimal | null {
3385
+ if (value == null || value === '') {
3386
+ return null;
3387
+ }
3388
+
3389
+ const parsed = Number(value);
3390
+ return Number.isFinite(parsed) ? new Prisma.Decimal(parsed) : null;
3391
+ }
3392
+
3393
+ private normalizeIntegerOrNull(value: unknown): number | null {
3394
+ if (value == null || value === '') {
3395
+ return null;
3396
+ }
3397
+
3398
+ const parsed = Number(value);
3399
+ return Number.isInteger(parsed) ? parsed : null;
3400
+ }
3401
+
1966
3402
  private normalizeTextOrNull(value: unknown): string | null {
1967
3403
  if (typeof value !== 'string') {
1968
3404
  return value == null ? null : String(value);
@@ -1983,6 +3419,25 @@ export class PersonService {
1983
3419
  return Number.isFinite(parsed) ? parsed : 0;
1984
3420
  }
1985
3421
 
3422
+ private normalizeStateOrNull(value: unknown): string | null {
3423
+ const normalized = this.normalizeTextOrNull(value)?.toUpperCase() ?? null;
3424
+ return normalized ? normalized.slice(0, 2) : null;
3425
+ }
3426
+
3427
+ private normalizeAccountLifecycleStage(
3428
+ value: unknown,
3429
+ ): AccountLifecycleStage | null {
3430
+ const normalized = this.normalizeTextOrNull(value);
3431
+
3432
+ if (!normalized) {
3433
+ return null;
3434
+ }
3435
+
3436
+ return ACCOUNT_LIFECYCLE_STAGES.includes(normalized as AccountLifecycleStage)
3437
+ ? (normalized as AccountLifecycleStage)
3438
+ : null;
3439
+ }
3440
+
1986
3441
  private resolveRequestedOwnerUserId(
1987
3442
  ownerUserId: string | number | undefined,
1988
3443
  mine: string | boolean | undefined,
@@ -2024,6 +3479,367 @@ export class PersonService {
2024
3479
  return person;
2025
3480
  }
2026
3481
 
3482
+ private async getPersonOwnerUserId(personId: number) {
3483
+ const metadata = await this.prismaService.person_metadata.findFirst({
3484
+ where: {
3485
+ person_id: personId,
3486
+ key: OWNER_USER_METADATA_KEY,
3487
+ },
3488
+ select: {
3489
+ value: true,
3490
+ },
3491
+ });
3492
+
3493
+ const ownerUserId = this.metadataToNumber(metadata?.value);
3494
+ return ownerUserId && ownerUserId > 0 ? ownerUserId : null;
3495
+ }
3496
+
3497
+ private async upsertFollowupActivity(
3498
+ tx: any,
3499
+ {
3500
+ personId,
3501
+ ownerUserId,
3502
+ dueAt,
3503
+ notes,
3504
+ actorUserId,
3505
+ }: {
3506
+ personId: number;
3507
+ ownerUserId: number | null;
3508
+ dueAt: string;
3509
+ notes?: string | null;
3510
+ actorUserId: number | null;
3511
+ },
3512
+ ) {
3513
+ const existingRows = (await tx.$queryRaw(
3514
+ Prisma.sql`
3515
+ SELECT id
3516
+ FROM crm_activity
3517
+ WHERE person_id = ${personId}
3518
+ AND source_kind = 'followup'
3519
+ AND completed_at IS NULL
3520
+ ORDER BY id DESC
3521
+ LIMIT 1
3522
+ `,
3523
+ )) as Array<{ id: number }>;
3524
+ const existing = existingRows[0];
3525
+
3526
+ const normalizedNotes = this.normalizeTextOrNull(notes);
3527
+
3528
+ if (existing) {
3529
+ await tx.$executeRaw(
3530
+ Prisma.sql`
3531
+ UPDATE crm_activity
3532
+ SET
3533
+ owner_user_id = ${ownerUserId},
3534
+ type = CAST(${'task'} AS crm_activity_type_enum),
3535
+ subject = ${this.getFollowupActivitySubject()},
3536
+ notes = ${normalizedNotes},
3537
+ due_at = CAST(${dueAt} AS TIMESTAMPTZ),
3538
+ priority = CAST(${'medium'} AS crm_activity_priority_enum),
3539
+ updated_at = NOW()
3540
+ WHERE id = ${existing.id}
3541
+ `,
3542
+ );
3543
+ return;
3544
+ }
3545
+
3546
+ await tx.$executeRaw(
3547
+ Prisma.sql`
3548
+ INSERT INTO crm_activity (
3549
+ person_id,
3550
+ owner_user_id,
3551
+ created_by_user_id,
3552
+ type,
3553
+ subject,
3554
+ notes,
3555
+ due_at,
3556
+ priority,
3557
+ source_kind,
3558
+ created_at,
3559
+ updated_at
3560
+ )
3561
+ VALUES (
3562
+ ${personId},
3563
+ ${ownerUserId},
3564
+ ${actorUserId},
3565
+ CAST(${'task'} AS crm_activity_type_enum),
3566
+ ${this.getFollowupActivitySubject()},
3567
+ ${normalizedNotes},
3568
+ CAST(${dueAt} AS TIMESTAMPTZ),
3569
+ CAST(${'medium'} AS crm_activity_priority_enum),
3570
+ CAST(${'followup'} AS crm_activity_source_kind_enum),
3571
+ NOW(),
3572
+ NOW()
3573
+ )
3574
+ `,
3575
+ );
3576
+ }
3577
+
3578
+ private async createCompletedInteractionActivity(
3579
+ tx: any,
3580
+ {
3581
+ personId,
3582
+ ownerUserId,
3583
+ interaction,
3584
+ actorUserId,
3585
+ }: {
3586
+ personId: number;
3587
+ ownerUserId: number | null;
3588
+ interaction: PersonInteractionRecord;
3589
+ actorUserId: number | null;
3590
+ },
3591
+ ) {
3592
+ const completedAt = new Date(interaction.created_at);
3593
+
3594
+ await tx.$executeRaw(
3595
+ Prisma.sql`
3596
+ INSERT INTO crm_activity (
3597
+ person_id,
3598
+ owner_user_id,
3599
+ created_by_user_id,
3600
+ completed_by_user_id,
3601
+ type,
3602
+ subject,
3603
+ notes,
3604
+ due_at,
3605
+ completed_at,
3606
+ priority,
3607
+ source_kind,
3608
+ created_at,
3609
+ updated_at
3610
+ )
3611
+ VALUES (
3612
+ ${personId},
3613
+ ${ownerUserId},
3614
+ ${actorUserId},
3615
+ ${actorUserId},
3616
+ CAST(${interaction.type} AS crm_activity_type_enum),
3617
+ ${this.getInteractionActivitySubject(interaction.type)},
3618
+ ${this.normalizeTextOrNull(interaction.notes)},
3619
+ CAST(${interaction.created_at} AS TIMESTAMPTZ),
3620
+ CAST(${interaction.created_at} AS TIMESTAMPTZ),
3621
+ CAST(${'medium'} AS crm_activity_priority_enum),
3622
+ CAST(${'interaction'} AS crm_activity_source_kind_enum),
3623
+ CAST(${interaction.created_at} AS TIMESTAMPTZ),
3624
+ NOW()
3625
+ )
3626
+ `,
3627
+ );
3628
+ }
3629
+
3630
+ private getFollowupActivitySubject() {
3631
+ return 'Follow-up';
3632
+ }
3633
+
3634
+ private getInteractionActivitySubject(type: PersonInteractionRecord['type']) {
3635
+ switch (type) {
3636
+ case PersonInteractionTypeDTO.CALL:
3637
+ return 'Call';
3638
+ case PersonInteractionTypeDTO.EMAIL:
3639
+ return 'Email';
3640
+ case PersonInteractionTypeDTO.WHATSAPP:
3641
+ return 'WhatsApp';
3642
+ case PersonInteractionTypeDTO.MEETING:
3643
+ return 'Meeting';
3644
+ case PersonInteractionTypeDTO.NOTE:
3645
+ default:
3646
+ return 'Note';
3647
+ }
3648
+ }
3649
+
3650
+ private async ensureCompanyAccountAccessible(id: number, locale: string) {
3651
+ const allowCompanyRegistration = await this.isCompanyRegistrationAllowed();
3652
+
3653
+ if (!allowCompanyRegistration) {
3654
+ throw new NotFoundException(
3655
+ getLocaleText('personNotFound', locale, `Person with ID ${id} not found`),
3656
+ );
3657
+ }
3658
+
3659
+ const person = await this.prismaService.person.findUnique({
3660
+ where: { id },
3661
+ select: {
3662
+ id: true,
3663
+ name: true,
3664
+ status: true,
3665
+ type: true,
3666
+ },
3667
+ });
3668
+
3669
+ if (!person || person.type !== 'company') {
3670
+ throw new BadRequestException(
3671
+ getLocaleText('personNotFound', locale, `Person with ID ${id} not found`),
3672
+ );
3673
+ }
3674
+
3675
+ return person;
3676
+ }
3677
+
3678
+ private async loadAccountPeopleByIds(personIds: number[]) {
3679
+ if (personIds.length === 0) {
3680
+ return [];
3681
+ }
3682
+
3683
+ const people = await this.prismaService.person.findMany({
3684
+ where: {
3685
+ id: {
3686
+ in: personIds,
3687
+ },
3688
+ },
3689
+ include: {
3690
+ contact: {
3691
+ include: {
3692
+ contact_type: true,
3693
+ },
3694
+ },
3695
+ person_metadata: true,
3696
+ },
3697
+ });
3698
+
3699
+ return this.enrichPeople(people as any[], true);
3700
+ }
3701
+
3702
+ private mapAccountFromPerson(person: any, company: any): AccountListItem {
3703
+ return {
3704
+ id: person.id,
3705
+ name: person.name,
3706
+ trade_name: company.trade_name ?? null,
3707
+ status: person.status,
3708
+ industry: company.industry ?? null,
3709
+ website: company.website ?? null,
3710
+ email: this.getPrimaryAccountContactValue(person.contact, ['EMAIL']),
3711
+ phone: this.getPrimaryAccountContactValue(person.contact, [
3712
+ 'PHONE',
3713
+ 'MOBILE',
3714
+ 'WHATSAPP',
3715
+ ]),
3716
+ owner_user_id: person.owner_user_id ?? null,
3717
+ owner_user: person.owner_user ?? null,
3718
+ annual_revenue:
3719
+ company.annual_revenue == null ? null : Number(company.annual_revenue),
3720
+ employee_count: company.employee_count ?? null,
3721
+ lifecycle_stage: company.account_lifecycle_stage ?? null,
3722
+ city: company.city ?? null,
3723
+ state: company.state ?? null,
3724
+ created_at: person.created_at?.toISOString?.() ?? String(person.created_at),
3725
+ last_interaction_at:
3726
+ this.normalizeDateTimeOrNull(person.last_interaction_at) ?? null,
3727
+ };
3728
+ }
3729
+
3730
+ private getPrimaryAccountContactValue(
3731
+ contacts: any[] | undefined,
3732
+ codes: string[],
3733
+ ): string | null {
3734
+ const normalizedCodes = new Set(codes.map((code) => code.toUpperCase()));
3735
+ const items = Array.isArray(contacts)
3736
+ ? contacts.filter((contact) =>
3737
+ normalizedCodes.has(
3738
+ String(contact?.contact_type?.code || '').toUpperCase(),
3739
+ ),
3740
+ )
3741
+ : [];
3742
+
3743
+ const primary = items.find((contact) => contact?.is_primary);
3744
+ const fallback = items[0];
3745
+ return this.normalizeTextOrNull(primary?.value ?? fallback?.value);
3746
+ }
3747
+
3748
+ private async upsertPrimaryAccountContact(
3749
+ tx: any,
3750
+ personId: number,
3751
+ code: 'EMAIL' | 'PHONE',
3752
+ value: unknown,
3753
+ ) {
3754
+ const normalizedValue = this.normalizeTextOrNull(value);
3755
+ const allowedCodes =
3756
+ code === 'PHONE' ? ['PHONE', 'MOBILE', 'WHATSAPP'] : ['EMAIL'];
3757
+
3758
+ const type = await tx.contact_type.findFirst({
3759
+ where: {
3760
+ code: {
3761
+ equals: code,
3762
+ mode: 'insensitive',
3763
+ },
3764
+ },
3765
+ select: {
3766
+ id: true,
3767
+ },
3768
+ });
3769
+
3770
+ if (!type) {
3771
+ return;
3772
+ }
3773
+
3774
+ const existingContacts = await tx.contact.findMany({
3775
+ where: {
3776
+ person_id: personId,
3777
+ contact_type: {
3778
+ code: {
3779
+ in: allowedCodes,
3780
+ },
3781
+ },
3782
+ },
3783
+ orderBy: [{ is_primary: 'desc' }, { id: 'asc' }],
3784
+ select: {
3785
+ id: true,
3786
+ is_primary: true,
3787
+ },
3788
+ });
3789
+
3790
+ if (!normalizedValue) {
3791
+ const contactToDelete = existingContacts[0];
3792
+ if (contactToDelete) {
3793
+ await tx.contact.delete({
3794
+ where: {
3795
+ id: contactToDelete.id,
3796
+ },
3797
+ });
3798
+ }
3799
+ return;
3800
+ }
3801
+
3802
+ const primaryContact = existingContacts[0];
3803
+ if (primaryContact) {
3804
+ await tx.contact.update({
3805
+ where: {
3806
+ id: primaryContact.id,
3807
+ },
3808
+ data: {
3809
+ value: normalizedValue,
3810
+ is_primary: true,
3811
+ contact_type_id: type.id,
3812
+ },
3813
+ });
3814
+
3815
+ const secondaryContacts = existingContacts
3816
+ .slice(1)
3817
+ .filter((contact) => contact.is_primary);
3818
+ if (secondaryContacts.length > 0) {
3819
+ await tx.contact.updateMany({
3820
+ where: {
3821
+ id: {
3822
+ in: secondaryContacts.map((contact) => contact.id),
3823
+ },
3824
+ },
3825
+ data: {
3826
+ is_primary: false,
3827
+ },
3828
+ });
3829
+ }
3830
+ return;
3831
+ }
3832
+
3833
+ await tx.contact.create({
3834
+ data: {
3835
+ person_id: personId,
3836
+ contact_type_id: type.id,
3837
+ value: normalizedValue,
3838
+ is_primary: true,
3839
+ },
3840
+ });
3841
+ }
3842
+
2027
3843
  private async loadInteractionsFromTx(tx: any, personId: number) {
2028
3844
  const metadata = await tx.person_metadata.findFirst({
2029
3845
  where: {
@@ -2124,6 +3940,252 @@ export class PersonService {
2124
3940
  return filters;
2125
3941
  }
2126
3942
 
3943
+ private buildAccountSqlFilters({
3944
+ search,
3945
+ status,
3946
+ lifecycleStage,
3947
+ }: {
3948
+ search?: string | null;
3949
+ status?: 'all' | string;
3950
+ lifecycleStage?: 'all' | AccountLifecycleStage;
3951
+ }) {
3952
+ const filters: Prisma.Sql[] = [];
3953
+
3954
+ if (status && status !== 'all') {
3955
+ filters.push(Prisma.sql`AND p.status = ${status}`);
3956
+ }
3957
+
3958
+ if (lifecycleStage && lifecycleStage !== 'all') {
3959
+ filters.push(
3960
+ Prisma.sql`AND pc.account_lifecycle_stage = CAST(${lifecycleStage} AS person_company_account_lifecycle_stage_enum)`,
3961
+ );
3962
+ }
3963
+
3964
+ if (search) {
3965
+ const searchLike = `%${search}%`;
3966
+ const normalizedDigits = this.normalizeDigits(search);
3967
+ const digitsLike = `%${normalizedDigits}%`;
3968
+
3969
+ filters.push(
3970
+ Prisma.sql`
3971
+ AND (
3972
+ p.name ILIKE ${searchLike}
3973
+ OR COALESCE(pc.trade_name, '') ILIKE ${searchLike}
3974
+ OR COALESCE(pc.city, '') ILIKE ${searchLike}
3975
+ OR COALESCE(pc.state, '') ILIKE ${searchLike}
3976
+ OR EXISTS (
3977
+ SELECT 1
3978
+ FROM contact c
3979
+ INNER JOIN contact_type ct ON ct.id = c.contact_type_id
3980
+ WHERE c.person_id = p.id
3981
+ AND UPPER(ct.code) = 'EMAIL'
3982
+ AND c.value ILIKE ${searchLike}
3983
+ )
3984
+ ${
3985
+ normalizedDigits.length > 0
3986
+ ? Prisma.sql`
3987
+ OR EXISTS (
3988
+ SELECT 1
3989
+ FROM contact c
3990
+ INNER JOIN contact_type ct ON ct.id = c.contact_type_id
3991
+ WHERE c.person_id = p.id
3992
+ AND UPPER(ct.code) IN ('PHONE', 'MOBILE', 'WHATSAPP')
3993
+ AND regexp_replace(COALESCE(c.value, ''), '[^0-9]+', '', 'g') ILIKE ${digitsLike}
3994
+ )
3995
+ `
3996
+ : Prisma.empty
3997
+ }
3998
+ )
3999
+ `,
4000
+ );
4001
+ }
4002
+
4003
+ return filters.length > 0 ? Prisma.join(filters, '\n') : Prisma.empty;
4004
+ }
4005
+
4006
+ private getAccountOrderBySql(
4007
+ sortField?: 'name' | 'created_at',
4008
+ sortOrder?: 'asc' | 'desc',
4009
+ ) {
4010
+ const normalizedSortField = sortField === 'created_at' ? 'created_at' : 'name';
4011
+ const normalizedSortOrder = sortOrder === 'desc' ? 'DESC' : 'ASC';
4012
+
4013
+ if (normalizedSortField === 'created_at') {
4014
+ return normalizedSortOrder === 'DESC'
4015
+ ? Prisma.sql`p.created_at DESC`
4016
+ : Prisma.sql`p.created_at ASC`;
4017
+ }
4018
+
4019
+ return normalizedSortOrder === 'DESC'
4020
+ ? Prisma.sql`LOWER(p.name) DESC`
4021
+ : Prisma.sql`LOWER(p.name) ASC`;
4022
+ }
4023
+
4024
+ private createEmptyAccountPagination(page: number, pageSize: number) {
4025
+ return {
4026
+ total: 0,
4027
+ lastPage: 1,
4028
+ page,
4029
+ pageSize,
4030
+ prev: page > 1 ? page - 1 : null,
4031
+ next: null,
4032
+ data: [] as AccountListItem[],
4033
+ };
4034
+ }
4035
+
4036
+ private buildCrmActivitySqlFilters({
4037
+ allowCompanyRegistration,
4038
+ search,
4039
+ status,
4040
+ type,
4041
+ priority,
4042
+ }: {
4043
+ allowCompanyRegistration: boolean;
4044
+ search?: string | null;
4045
+ status?: 'all' | CrmActivityStatus;
4046
+ type?: 'all' | CrmActivityType;
4047
+ priority?: 'all' | CrmActivityPriority;
4048
+ }) {
4049
+ const filters: Prisma.Sql[] = [];
4050
+
4051
+ if (!allowCompanyRegistration) {
4052
+ filters.push(Prisma.sql`AND p.type = 'individual'`);
4053
+ }
4054
+
4055
+ if (status === 'pending') {
4056
+ filters.push(
4057
+ Prisma.sql`AND a.completed_at IS NULL AND a.due_at >= NOW()`,
4058
+ );
4059
+ } else if (status === 'overdue') {
4060
+ filters.push(
4061
+ Prisma.sql`AND a.completed_at IS NULL AND a.due_at < NOW()`,
4062
+ );
4063
+ } else if (status === 'completed') {
4064
+ filters.push(Prisma.sql`AND a.completed_at IS NOT NULL`);
4065
+ }
4066
+
4067
+ if (type && type !== 'all') {
4068
+ filters.push(Prisma.sql`AND a.type = CAST(${type} AS crm_activity_type_enum)`);
4069
+ }
4070
+
4071
+ if (priority && priority !== 'all') {
4072
+ filters.push(
4073
+ Prisma.sql`AND a.priority = CAST(${priority} AS crm_activity_priority_enum)`,
4074
+ );
4075
+ }
4076
+
4077
+ if (search) {
4078
+ const searchLike = `%${search}%`;
4079
+ filters.push(
4080
+ Prisma.sql`
4081
+ AND (
4082
+ a.subject ILIKE ${searchLike}
4083
+ OR COALESCE(a.notes, '') ILIKE ${searchLike}
4084
+ OR p.name ILIKE ${searchLike}
4085
+ OR COALESCE(owner_user.name, '') ILIKE ${searchLike}
4086
+ )
4087
+ `,
4088
+ );
4089
+ }
4090
+
4091
+ return filters.length > 0 ? Prisma.join(filters, '\n') : Prisma.empty;
4092
+ }
4093
+
4094
+ private createEmptyCrmActivityPagination(page: number, pageSize: number) {
4095
+ return {
4096
+ total: 0,
4097
+ lastPage: 1,
4098
+ page,
4099
+ pageSize,
4100
+ prev: page > 1 ? page - 1 : null,
4101
+ next: null,
4102
+ data: [] as CrmActivityListItem[],
4103
+ };
4104
+ }
4105
+
4106
+ private mapCrmActivityListRow(row: Record<string, unknown>): CrmActivityListItem {
4107
+ const completedAt = this.normalizeDateTimeOrNull(row.completed_at);
4108
+ const dueAt = this.normalizeDateTimeOrNull(row.due_at) ?? new Date().toISOString();
4109
+ const ownerUserId = this.coerceNumber(row.owner_user_id) || null;
4110
+
4111
+ return {
4112
+ id: this.coerceNumber(row.id),
4113
+ person_id: this.coerceNumber(row.person_id),
4114
+ person: {
4115
+ id: this.coerceNumber(row.person_id),
4116
+ name: this.normalizeTextOrNull(row.person_name) || `#${row.person_id}`,
4117
+ type:
4118
+ this.normalizeTextOrNull(row.person_type) === 'company'
4119
+ ? 'company'
4120
+ : 'individual',
4121
+ status:
4122
+ this.normalizeTextOrNull(row.person_status) === 'inactive'
4123
+ ? 'inactive'
4124
+ : 'active',
4125
+ trade_name: this.normalizeTextOrNull(row.person_trade_name),
4126
+ },
4127
+ owner_user_id: ownerUserId,
4128
+ owner_user: ownerUserId
4129
+ ? {
4130
+ id: ownerUserId,
4131
+ name:
4132
+ this.normalizeTextOrNull(row.owner_user_name) || `#${ownerUserId}`,
4133
+ }
4134
+ : null,
4135
+ type: (this.normalizeTextOrNull(row.type) || 'task') as CrmActivityType,
4136
+ subject: this.normalizeTextOrNull(row.subject) || 'Activity',
4137
+ notes: this.normalizeTextOrNull(row.notes),
4138
+ due_at: dueAt,
4139
+ completed_at: completedAt,
4140
+ created_at:
4141
+ this.normalizeDateTimeOrNull(row.created_at) ?? new Date().toISOString(),
4142
+ priority:
4143
+ (this.normalizeTextOrNull(row.priority) || 'medium') as CrmActivityPriority,
4144
+ status: this.getCrmActivityStatus(dueAt, completedAt),
4145
+ };
4146
+ }
4147
+
4148
+ private mapCrmActivityDetailRow(row: Record<string, unknown>): CrmActivityDetail {
4149
+ const base = this.mapCrmActivityListRow(row);
4150
+ const createdByUserId = this.coerceNumber(row.created_by_user_id) || null;
4151
+ const completedByUserId = this.coerceNumber(row.completed_by_user_id) || null;
4152
+
4153
+ return {
4154
+ ...base,
4155
+ source_kind:
4156
+ (this.normalizeTextOrNull(row.source_kind) || 'manual') as CrmActivitySourceKind,
4157
+ created_by_user_id: createdByUserId,
4158
+ created_by_user: createdByUserId
4159
+ ? {
4160
+ id: createdByUserId,
4161
+ name:
4162
+ this.normalizeTextOrNull(row.created_by_user_name) ||
4163
+ `#${createdByUserId}`,
4164
+ }
4165
+ : null,
4166
+ completed_by_user_id: completedByUserId,
4167
+ completed_by_user: completedByUserId
4168
+ ? {
4169
+ id: completedByUserId,
4170
+ name:
4171
+ this.normalizeTextOrNull(row.completed_by_user_name) ||
4172
+ `#${completedByUserId}`,
4173
+ }
4174
+ : null,
4175
+ };
4176
+ }
4177
+
4178
+ private getCrmActivityStatus(
4179
+ dueAt: string,
4180
+ completedAt: string | null,
4181
+ ): CrmActivityStatus {
4182
+ if (completedAt) {
4183
+ return 'completed';
4184
+ }
4185
+
4186
+ return new Date(dueAt) < new Date() ? 'overdue' : 'pending';
4187
+ }
4188
+
2127
4189
  private normalizeDigits(value: string) {
2128
4190
  return value.replace(/\D/g, '');
2129
4191
  }