@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.
- package/dist/person/dto/account.dto.d.ts +28 -0
- package/dist/person/dto/account.dto.d.ts.map +1 -0
- package/dist/person/dto/account.dto.js +123 -0
- package/dist/person/dto/account.dto.js.map +1 -0
- package/dist/person/dto/activity.dto.d.ts +15 -0
- package/dist/person/dto/activity.dto.d.ts.map +1 -0
- package/dist/person/dto/activity.dto.js +65 -0
- package/dist/person/dto/activity.dto.js.map +1 -0
- package/dist/person/dto/dashboard-query.dto.d.ts +9 -0
- package/dist/person/dto/dashboard-query.dto.d.ts.map +1 -0
- package/dist/person/dto/dashboard-query.dto.js +40 -0
- package/dist/person/dto/dashboard-query.dto.js.map +1 -0
- package/dist/person/dto/followup-query.dto.d.ts +10 -0
- package/dist/person/dto/followup-query.dto.d.ts.map +1 -0
- package/dist/person/dto/followup-query.dto.js +45 -0
- package/dist/person/dto/followup-query.dto.js.map +1 -0
- package/dist/person/person.controller.d.ts +204 -0
- package/dist/person/person.controller.d.ts.map +1 -1
- package/dist/person/person.controller.js +138 -0
- package/dist/person/person.controller.js.map +1 -1
- package/dist/person/person.service.d.ts +234 -0
- package/dist/person/person.service.d.ts.map +1 -1
- package/dist/person/person.service.js +1367 -0
- package/dist/person/person.service.js.map +1 -1
- package/hedhog/data/menu.yaml +163 -163
- package/hedhog/data/route.yaml +41 -0
- package/hedhog/frontend/app/accounts/_components/account-form-sheet.tsx.ejs +210 -114
- package/hedhog/frontend/app/accounts/_components/account-types.ts.ejs +3 -0
- package/hedhog/frontend/app/accounts/page.tsx.ejs +323 -245
- package/hedhog/frontend/app/activities/_components/activity-detail-sheet.tsx.ejs +240 -0
- package/hedhog/frontend/app/activities/_components/activity-types.ts.ejs +66 -0
- package/hedhog/frontend/app/activities/page.tsx.ejs +165 -517
- package/hedhog/frontend/app/dashboard/_components/dashboard-types.ts.ejs +70 -0
- package/hedhog/frontend/app/dashboard/page.tsx.ejs +504 -356
- package/hedhog/frontend/app/follow-ups/page.tsx.ejs +242 -153
- package/hedhog/frontend/messages/en.json +91 -6
- package/hedhog/frontend/messages/pt.json +91 -6
- package/hedhog/table/crm_activity.yaml +68 -0
- package/hedhog/table/person_company.yaml +22 -0
- package/package.json +5 -5
- package/src/person/dto/account.dto.ts +100 -0
- package/src/person/dto/activity.dto.ts +54 -0
- package/src/person/dto/dashboard-query.dto.ts +25 -0
- package/src/person/dto/followup-query.dto.ts +25 -0
- package/src/person/person.controller.ts +116 -0
- 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
|
|
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
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
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
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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 (
|
|
528
|
-
|
|
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
|
|
534
|
-
|
|
535
|
-
|
|
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
|
-
|
|
541
|
-
|
|
542
|
-
|
|
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
|
-
|
|
545
|
-
|
|
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
|
-
|
|
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
|
|
556
|
-
|
|
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
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
908
|
+
if (!allowCompanyRegistration) {
|
|
909
|
+
return {
|
|
910
|
+
total: 0,
|
|
911
|
+
active: 0,
|
|
912
|
+
customers: 0,
|
|
913
|
+
prospects: 0,
|
|
914
|
+
};
|
|
915
|
+
}
|
|
570
916
|
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
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
|
-
|
|
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
|
-
|
|
581
|
-
|
|
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
|
-
|
|
984
|
+
return {
|
|
985
|
+
success: true,
|
|
986
|
+
id: person.id,
|
|
987
|
+
};
|
|
988
|
+
});
|
|
586
989
|
}
|
|
587
990
|
|
|
588
|
-
async
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
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
|
-
|
|
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
|
-
'
|
|
1039
|
+
'deleteItemsRequired',
|
|
601
1040
|
locale,
|
|
602
|
-
'
|
|
1041
|
+
'You must select at least one item to delete.',
|
|
603
1042
|
),
|
|
604
1043
|
);
|
|
605
1044
|
}
|
|
606
1045
|
|
|
607
|
-
await this.prismaService
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
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
|
}
|