1import { capture } from "@/telemetry";
2import { __ } from "@/translation";
3import type {
4 DocumentResource,
5 EditContactState,
6 Error,
7 ListResource,
8 NewContactState,
9 Resource,
10} from "@/types";
11import { Contact, ODTicket } from "@/types/doctypes";
12import { getErrorMessage, validateEmailWithZod } from "@/utils";
13import {
14 call,
15 createDocumentResource,
16 createListResource,
17 createResource,
18 toast,
19} from "frappe-ui";
20import { useOnboarding } from "@framework/ui";
21import { computed, ComputedRef, h, markRaw, reactive, ref, watch } from "vue";
22import { useRouter } from "vue-router";
23
24export type TextInputTypes =
25 | "date"
26 | "datetime-local"
27 | "email"
28 | "file"
29 | "month"
30 | "number"
31 | "password"
32 | "search"
33 | "tel"
34 | "text"
35 | "time"
36 | "url"
37 | "week"
38 | "range";
39
40type FieldType =
41 | TextInputTypes
42 | "file"
43 | "Link"
44 | "autocomplete"
45 | "email_ids"
46 | "phone_nos"
47 | "timezone";
48
49type AutocompleteOption = string;
50
51type ContactInfoResource = ReturnType<typeof createResource>;
52
53interface ContactBundle {
54 doc: DocumentResource<Contact>;
55 info: ContactInfoResource;
56}
57
58// Local Cache to store contact document + info resources to avoid multiple DB calls in a single session.
59const contactCache: Record<string, ContactBundle> = {};
60
61function sortByPrimary<T extends { isPrimary: boolean }>(arr: T[]): T[] {
62 return [...arr].sort((a, b) => Number(b.isPrimary) - Number(a.isPrimary));
63}
64
65type EmailRow = { email_id: string; isPrimary: boolean };
66type PhoneRow = { phone: string; isPrimary: boolean };
67
68// The single mapping from a Contact document to form-state rows. Kept in one
69// place so the hydrate watch, resetState, and the dirty check can never drift.
70function emailRowsFromDoc(doc?: Contact): EmailRow[] {
71 return sortByPrimary(
72 (doc?.email_ids || []).map((e: any) => ({
73 email_id: e.email_id,
74 isPrimary: Boolean(e.is_primary),
75 }))
76 );
77}
78
79function phoneRowsFromDoc(doc?: Contact): PhoneRow[] {
80 return sortByPrimary(
81 (doc?.phone_nos || []).map((p: any) => ({
82 phone: p.phone,
83 isPrimary: Boolean(p.is_primary_phone || p.is_primary_mobile_no),
84 }))
85 );
86}
87
88function customerLinksFromDoc(doc?: Contact): string[] {
89 return (doc?.links || []).map((l: any) => l.link_name as string);
90}
91
92let entryKeyCounter = 0;
93export const nextEntryKey = () => ++entryKeyCounter;
94
95const withKeys = <T>(rows: T[]) =>
96 rows.map((row) => ({ ...row, key: nextEntryKey() }));
97
98// Hydrate the edit-form state from a Contact document.
99function applyDocToState(state: EditContactState, doc?: Contact): void {
100 state.firstName = doc?.first_name || "";
101 state.lastName = doc?.last_name || "";
102 state.image = doc?.image || "";
103 state.emails = withKeys(emailRowsFromDoc(doc));
104 state.phones = withKeys(phoneRowsFromDoc(doc));
105 state.customers = customerLinksFromDoc(doc);
106}
107
108export function useContact(name: string) {
109 const router = useRouter();
110 const { doc, info: contactInfoResource } = findContact();
111 const { state, resetState } = useContactState(false, doc);
112
113 async function handleDelete({
114 deleteLinkedTickets,
115 }: {
116 deleteLinkedTickets: boolean;
117 }) {
118 try {
119 await call("opendesk.api.contact.delete_contact", {
120 name,
121 delete_tickets: deleteLinkedTickets,
122 });
123 router.push({ name: "ContactList" });
124 } catch (err) {
125 getErrorMessage(err as Error, true);
126 throw err;
127 }
128 }
129 watch(
130 () => doc.doc,
131 (data) => {
132 if (!data) return;
133 applyDocToState(state, data);
134 },
135 { immediate: true }
136 );
137
138 watch(
139 () => contactInfoResource.data,
140 (data) => {
141 state.timezone = data?.timezone || "";
142 },
143 { immediate: true, deep: true }
144 );
145
146 const isContactInfoChanged = computed(() => {
147 return (
148 state.firstName !== (doc.doc?.first_name || "") ||
149 state.image !== (doc.doc?.image || "")
150 );
151 });
152
153 const isContactDirty = computed(() => {
154 const currentEmails = sortByPrimary(
155 state.emails.map((e) => ({
156 email_id: e.email_id,
157 isPrimary: e.isPrimary,
158 }))
159 );
160 const currentPhones = sortByPrimary(
161 state.phones
162 .filter((p) => p.phone)
163 .map((p) => ({ phone: p.phone, isPrimary: p.isPrimary }))
164 );
165 const savedTimezone = contactInfoResource.data?.timezone || "";
166 return (
167 state.firstName !== (doc.doc?.first_name || "") ||
168 state.lastName !== (doc.doc?.last_name || "") ||
169 state.image !== (doc.doc?.image || "") ||
170 state.timezone !== savedTimezone ||
171 JSON.stringify(currentEmails) !==
172 JSON.stringify(emailRowsFromDoc(doc.doc)) ||
173 JSON.stringify(currentPhones) !==
174 JSON.stringify(phoneRowsFromDoc(doc.doc))
175 );
176 });
177
178 // Selecting a customer to link counts as an unsaved change too.
179 const isDirty = computed(
180 () => isContactDirty.value || Boolean(state.customer)
181 );
182
183 function findContact(): ContactBundle {
184 if (contactCache.hasOwnProperty(name)) return contactCache[name];
185 const bundle: ContactBundle = {
186 doc: createDocumentResource({ doctype: "Contact", name }),
187 info: createResource({
188 url: "opendesk.api.contact.get_contact_info",
189 method: "GET",
190 params: { name },
191 auto: true,
192 }),
193 };
194 contactCache[name] = bundle;
195 return bundle;
196 }
197
198 function parseContactData() {
199 return {
200 first_name: state.firstName,
201 last_name: state.lastName,
202 image: state.image,
203 email_ids: state.emails
204 .filter((e) => e.email_id)
205 .map((e) => ({
206 email_id: e.email_id,
207 is_primary: e.isPrimary,
208 })),
209 phone_nos: state.phones
210 .filter((p) => p.phone)
211 .map((p) => ({
212 phone: p.phone,
213 is_primary: p.isPrimary,
214 })),
215 timezone: state.timezone,
216 };
217 }
218
219 const editContactResource = createResource({
220 url: "opendesk.api.contact.edit_contact",
221 method: "POST",
222 validate(data: { name: string; doc: ReturnType<typeof parseContactData> }) {
223 if (!data.doc.first_name?.trim()) {
224 throw new Error(__("First name is required"));
225 }
226 if (!data.doc.email_ids.length) {
227 throw new Error(__("At least one email address is required"));
228 }
229 for (const e of data.doc.email_ids) {
230 if (!validateEmailWithZod(e.email_id)) {
231 throw new Error(__("Invalid email address: {0}", [e.email_id]));
232 }
233 }
234 if (!data.doc.email_ids.some((e) => e.is_primary)) {
235 throw new Error(
236 __("At least one email address must be marked as primary")
237 );
238 }
239 },
240 onSuccess() {
241 resetState();
242 toast.success(__("Contact updated"));
243 },
244 });
245
246 // Links this contact to the selected customer
247 const linkCustomerResource = createResource({
248 url: "run_doc_method",
249 makeParams: () => ({
250 dt: "OD Customer",
251 dn: state.customer,
252 method: "add_contacts",
253 args: { contacts: JSON.stringify([name]), role: "OD Customer" },
254 }),
255 });
256
257 return {
258 doc,
259 contactInfoResource,
260 state,
261 resetState,
262 isContactInfoChanged,
263 isContactDirty,
264 isDirty,
265 parseContactData,
266 editContactResource,
267 linkCustomerResource,
268 handleDelete,
269 };
270}
271
272export function useNewContact() {
273 const { state, resetState } = useContactState(true);
274 const fieldConfig = getContactFieldConfig(true);
275 const router = useRouter();
276
277 const addContactResource = createResource({
278 url: "opendesk.api.contact.create_contact",
279 method: "POST",
280 validate: (data: { doc: ReturnType<typeof parseContactData> }) => {
281 if (!data.doc.first_name) {
282 throw new Error(__("First name is required"));
283 }
284 if (!data.doc.email) {
285 throw new Error(__("Email address is required"));
286 }
287 if (!validateEmailWithZod(data.doc.email)) {
288 throw new Error(__("Invalid email address"));
289 }
290 },
291 onSuccess: (name: string) => {
292 if (state.invite) capture("contact_created_with_invite");
293 router.push({ name: "Contact", params: { id: name } });
294 toast.success(
295 state.invite ? __("Contact created and invited") : __("Contact created")
296 );
297 resetState();
298 },
299 onError: (err: unknown) => {
300 getErrorMessage(err as Error, true);
301 },
302 });
303
304 function parseContactData() {
305 return {
306 first_name: state.firstName,
307 last_name: state.lastName,
308 image: state.image,
309 email: state.email,
310 phone: state.phone,
311 customer: state.customer,
312 timezone: state.timezone,
313 };
314 }
315
316 function addContact() {
317 return addContactResource.submit({
318 doc: parseContactData(),
319 invite: state.invite,
320 });
321 }
322
323 return {
324 state,
325 fieldConfig,
326 addContact,
327 isLoading: computed(() => addContactResource.loading),
328 reset: resetState,
329 };
330}
331
332// Function overloads to provide conditonal types for new vs existing contact states.
333export function useContactState(newDoc: true): {
334 state: NewContactState;
335 resetState: () => void;
336};
337export function useContactState(
338 newDoc: false,
339 doc: DocumentResource<Contact>
340): {
341 state: EditContactState;
342 resetState: () => void;
343};
344export function useContactState(
345 newDoc: boolean = false,
346 doc?: DocumentResource<Contact>
347) {
348 if (newDoc) {
349 const state = reactive<NewContactState>({
350 firstName: "",
351 lastName: "",
352 image: "",
353 email: "",
354 phone: "",
355 timezone: "",
356 customer: "",
357 invite: true,
358 });
359
360 function resetState() {
361 state.firstName = "";
362 state.lastName = "";
363 state.image = "";
364 state.email = "";
365 state.phone = "";
366 state.timezone = "";
367 state.customer = "";
368 // back to the initial state: the dialog opens with the invite on
369 state.invite = true;
370 }
371
372 return { state, resetState };
373 } else {
374 const state = reactive<EditContactState>({
375 firstName: "",
376 lastName: "",
377 image: "",
378 emails: [],
379 phones: [],
380 customers: [],
381 customer: "",
382 timezone: "",
383 });
384
385 function resetState() {
386 const data = doc?.doc;
387 applyDocToState(state, data);
388 state.timezone = data?.timezone || "";
389 state.customer = "";
390 }
391
392 return { state, resetState };
393 }
394}
395
396interface FieldConfig {
397 key: string;
398 type: FieldType;
399 label: string;
400 required?: boolean;
401 placeholder?: string;
402 description?: string;
403 prefix?: ReturnType<typeof markRaw> | ReturnType<typeof h>;
404 doctype?: string;
405 options?: AutocompleteOption[];
406}
407
408type FieldConfigRow = FieldConfig | FieldConfig[];
409
410function getContactFieldConfig(newDoc: boolean = false): FieldConfigRow[] {
411 const baseFields: FieldConfigRow[] = [
412 {
413 key: "image",
414 type: "file",
415 label: __("Profile Picture"),
416 required: false,
417 placeholder: __("Upload profile picture"),
418 },
419 [
420 {
421 key: "firstName",
422 type: "text",
423 label: __("First Name"),
424 required: true,
425 placeholder: __("John"),
426 },
427 {
428 key: "lastName",
429 type: "text",
430 label: __("Last Name"),
431 placeholder: __("Doe"),
432 },
433 ],
434 ];
435
436 const emailField: FieldConfig = newDoc
437 ? {
438 key: "email",
439 type: "email",
440 label: __("Email"),
441 required: true,
442 placeholder: __("john.doe@example.com"),
443 }
444 : {
445 key: "emails",
446 type: "email_ids",
447 label: __("Emails"),
448 };
449
450 const phoneField: FieldConfig = newDoc
451 ? {
452 key: "phone",
453 type: "tel",
454 label: __("Phone"),
455 placeholder: __("123-456-7890"),
456 }
457 : {
458 key: "phones",
459 type: "phone_nos",
460 label: __("Phone Numbers"),
461 };
462
463 const restFields: FieldConfigRow[] = [
464 {
465 key: "customer",
466 type: "Link",
467 label: __("Customer"),
468 placeholder: __("Select Customer"),
469 doctype: "OD Customer",
470 },
471 ];
472
473 return [...baseFields, emailField, phoneField, ...restFields];
474}
475
476export function useContactInvite() {
477 const isLoading = ref(false);
478 // @ts-expect-error
479 const { updateOnboardingStep } = useOnboarding("opendesk") ?? {};
480 async function resendInvite(
481 invitationName: string,
482 status: string | undefined,
483 contactName: string,
484 email: string | undefined
485 ): Promise<void> {
486 if (status === "Expired") {
487 return inviteAsUser(contactName, email);
488 }
489 try {
490 isLoading.value = true;
491 await call("frappe.core.api.user_invitation.resend_invitation", {
492 name: invitationName,
493 app_name: "opendesk",
494 });
495 toast.success(__("Invitation email resent successfully"));
496 updateOnboardingStep?.("add_invite_contact");
497 } catch (error: unknown) {
498 isLoading.value = false;
499 const parser = new DOMParser();
500 const doc = parser.parseFromString(
501 (error as Error).messages?.[0] || (error as Error).message,
502 "text/html"
503 );
504
505 const errMsg = doc.body.innerText;
506 toast.error(errMsg);
507 } finally {
508 isLoading.value = false;
509 }
510 }
511 async function inviteAsUser(
512 contactName: string,
513 email: string | undefined
514 ): Promise<void> {
515 if (!email) {
516 toast.error(__("Contact has no email address to invite"));
517 return;
518 }
519 try {
520 isLoading.value = true;
521 await call("frappe.core.api.user_invitation.invite_by_email", {
522 emails: email,
523 roles: ["OD Customer"],
524 redirect_to_path: "/opendesk",
525 app_name: "opendesk",
526 contact: contactName,
527 });
528 toast.success(__("Invitation sent"));
529 updateOnboardingStep?.("add_invite_contact");
530 } catch (error: unknown) {
531 const parser = new DOMParser();
532 const doc = parser.parseFromString(
533 (error as Error).messages?.[0] || (error as Error).message,
534 "text/html"
535 );
536 const errMsg = doc.body.innerText;
537 toast.error(errMsg);
538 } finally {
539 isLoading.value = false;
540 }
541 }
542 return { resendInvite, inviteAsUser, isLoading };
543}
544
545export function useContactResetPassword(getUser: () => string | undefined) {
546 const isLoading = ref(false);
547 async function resetPassword(): Promise<void> {
548 const user = getUser();
549 if (!user) {
550 toast.error(__("No system user is linked with this contact"));
551 return;
552 }
553 try {
554 isLoading.value = true;
555 await call("frappe.core.doctype.user.user.reset_password", {
556 user,
557 });
558 toast.success(__("Reset password email sent successfully"));
559 } catch (error: unknown) {
560 const parser = new DOMParser();
561 const doc = parser.parseFromString(
562 (error as Error).messages?.[0] || (error as Error).message,
563 "text/html"
564 );
565
566 const errMsg = doc.body.innerText;
567 toast.error(errMsg);
568 } finally {
569 isLoading.value = false;
570 }
571 }
572 return { resetPassword, isLoading };
573}
574
575interface ContactFeedbackChartData {
576 average: number;
577 total: number;
578 data: Record<number, number>;
579}
580
581// key of feedback feedbackListResource which has type of ListResource<ODTicket> is feedbackListResource, key of count resource is Resource<number>
582interface ContactFeedback {
583 feedbackListResource: ListResource<ODTicket>;
584 feedbackCount: ReturnType<typeof createResource>;
585 chartData: Resource<ContactFeedbackChartData>;
586 loading: ComputedRef<boolean>;
587}
588
589// Cache to avoid re-creating feedback resources for the same contact in a session
590const contactFeedbackCache: Record<string, ContactFeedback> = {};
591
592export function useContactFeedback(name: string): ContactFeedback {
593 if (contactFeedbackCache[name]) return contactFeedbackCache[name];
594
595 const feedbackListResource: ListResource<ODTicket> = createListResource({
596 doctype: "OD Ticket",
597 cache: [name, "FeedbackList"],
598 fields: [
599 "name",
600 "subject",
601 "feedback",
602 "feedback_rating",
603 "feedback_extra",
604 "creation",
605 "modified",
606 ],
607 filters: { contact: name, feedback_rating: ["is", "set"] },
608 orderBy: "creation desc",
609 auto: true,
610 });
611
612 const feedbackCount = createResource({
613 url: "frappe.client.get_count",
614 makeParams: () => ({
615 doctype: "OD Ticket",
616 filters: {
617 contact: name,
618 feedback_rating: ["is", "set"],
619 },
620 }),
621 auto: true,
622 });
623
624 const chartData = createResource({
625 url: "opendesk.api.ticket_stats.get_feedback_received",
626 makeParams: () => ({
627 scope: "contact",
628 value: name,
629 }),
630 auto: true,
631 });
632
633 const result: ContactFeedback = {
634 feedbackListResource,
635 feedbackCount,
636 chartData,
637 loading: computed(
638 () =>
639 feedbackCount.loading ||
640 feedbackListResource.loading ||
641 chartData.loading
642 ),
643 };
644
645 contactFeedbackCache[name] = result;
646 return result;
647}
648