diff --git a/apps/api/v2/package.json b/apps/api/v2/package.json index 1c43ded342..c3c8f6c663 100644 --- a/apps/api/v2/package.json +++ b/apps/api/v2/package.json @@ -38,7 +38,7 @@ "@axiomhq/winston": "^1.2.0", "@calcom/platform-constants": "*", "@calcom/platform-enums": "*", - "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.340", + "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.344", "@calcom/platform-types": "*", "@calcom/platform-utils": "*", "@calcom/prisma": "*", diff --git a/packages/features/bookings/lib/BookingEmailSmsHandler.ts b/packages/features/bookings/lib/BookingEmailSmsHandler.ts new file mode 100644 index 0000000000..6184b71daf --- /dev/null +++ b/packages/features/bookings/lib/BookingEmailSmsHandler.ts @@ -0,0 +1,325 @@ +import { default as cloneDeep } from "lodash/cloneDeep"; +import type { Logger } from "tslog"; + +import dayjs from "@calcom/dayjs"; +import { + allowDisablingHostConfirmationEmails, + allowDisablingAttendeeConfirmationEmails, +} from "@calcom/ee/workflows/lib/allowDisablingStandardEmails"; +import type { Workflow as WorkflowType } from "@calcom/ee/workflows/lib/types"; +import { + sendRoundRobinRescheduledEmailsAndSMS, + sendRoundRobinScheduledEmailsAndSMS, + sendRoundRobinCancelledEmailsAndSMS, + sendRescheduledEmailsAndSMS, + sendScheduledEmailsAndSMS, + sendOrganizerRequestEmail, + sendAttendeeRequestEmailAndSMS, +} from "@calcom/emails"; +import type { BookingType } from "@calcom/features/bookings/lib/handleNewBooking/originalRescheduledBookingUtils"; +import type { EventNameObjectType } from "@calcom/lib/event"; +import { getPiiFreeCalendarEvent } from "@calcom/lib/piiFreeData"; +import { safeStringify } from "@calcom/lib/safeStringify"; +import { getTranslation } from "@calcom/lib/server/i18n"; +import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat"; +import type { DestinationCalendar, Prisma, User } from "@calcom/prisma/client"; +import type { SchedulingType } from "@calcom/prisma/enums"; +import type { EventTypeMetadata } from "@calcom/prisma/zod-utils"; +import type { AdditionalInformation, CalendarEvent, Person } from "@calcom/types/Calendar"; + +export const BookingActionMap = { + confirmed: "BOOKING_CONFIRMED", + rescheduled: "BOOKING_RESCHEDULED", + requested: "BOOKING_REQUESTED", +} as const; + +type EmailAndSmsPayload = { + evt: CalendarEvent; + eventType: { + metadata?: EventTypeMetadata; + schedulingType: SchedulingType | null; + }; +}; + +type RescheduleEmailAndSmsPayload = EmailAndSmsPayload & { + rescheduleReason?: string; + additionalInformation: AdditionalInformation; + additionalNotes: string | null | undefined; + iCalUID: string; + users: (Pick & { + destinationCalendar: DestinationCalendar | null; + isFixed?: boolean; + })[]; + changedOrganizer?: boolean; + isRescheduledByBooker: boolean; + originalRescheduledBooking: NonNullable; +}; + +type ConfirmedEmailAndSmsPayload = EmailAndSmsPayload & { + workflows: WorkflowType[]; + eventNameObject: EventNameObjectType; + additionalInformation: AdditionalInformation; + additionalNotes: string | null | undefined; + customInputs: Prisma.JsonObject | null | undefined; +}; + +type RequestedEmailAndSmsPayload = EmailAndSmsPayload & { + attendees?: Person[]; + additionalNotes?: string | null; +}; + +type RescheduledSideEffectsPayload = { + action: typeof BookingActionMap.rescheduled; + data: RescheduleEmailAndSmsPayload; +}; + +type ConfirmedSideEffectsPayload = { + action: typeof BookingActionMap.confirmed; + data: ConfirmedEmailAndSmsPayload; +}; + +type RequestedSideEffectsPayload = { + action: typeof BookingActionMap.requested; + data: RequestedEmailAndSmsPayload; +}; + +export type EmailsAndSmsSideEffectsPayload = + | RescheduledSideEffectsPayload + | RequestedSideEffectsPayload + | ConfirmedSideEffectsPayload; + +export interface IBookingEmailSmsHandler { + logger: Logger; +} + +export class BookingEmailSmsHandler { + private readonly log: Logger; + + constructor(dependencies: IBookingEmailSmsHandler) { + this.log = dependencies.logger.getSubLogger({ prefix: ["BookingEmailSmsHandler"] }); + } + + public async send(payload: EmailsAndSmsSideEffectsPayload) { + const { action, data } = payload; + + if (action === BookingActionMap.rescheduled) { + if (data.eventType.schedulingType === "ROUND_ROBIN") return this._handleRoundRobinRescheduled(data); + return this._handleRescheduled(data); + } + + if (action === BookingActionMap.confirmed) return this._handleConfirmed(data); + if (action === BookingActionMap.requested) return this._handleRequested(data); + + this.log.warn("Unknown email/SMS action requested.", { action }); + } + + /** + * Handles notifications for a RESCHEDULED booking. + */ + private async _handleRescheduled(data: RescheduleEmailAndSmsPayload) { + const { + evt, + eventType: { metadata }, + rescheduleReason, + additionalNotes, + additionalInformation, + } = data; + + await sendRescheduledEmailsAndSMS( + { + ...evt, + additionalInformation, + additionalNotes, + cancellationReason: `$RCH$${rescheduleReason || ""}`, + }, + metadata + ); + } + + /** + * Handles notifications for a RESCHEDULED RR booking. + */ + private async _handleRoundRobinRescheduled(data: RescheduleEmailAndSmsPayload) { + const { + evt, + eventType: { metadata }, + originalRescheduledBooking, + rescheduleReason, + additionalNotes, + changedOrganizer, + additionalInformation, + users, + isRescheduledByBooker, + iCalUID, + } = data; + const copyEvent = cloneDeep(evt); + const copyEventAdditionalInfo = { + ...copyEvent, + additionalInformation, + additionalNotes, + cancellationReason: `$RCH$${rescheduleReason || ""}`, + }; + const cancelledRRHostEvt = cloneDeep(copyEventAdditionalInfo); + this.log.debug("Emails: Sending rescheduled emails for booking confirmation"); + + const originalBookingMemberEmails: Person[] = []; + + for (const user of originalRescheduledBooking.attendees) { + const translate = await getTranslation(user.locale ?? "en", "common"); + originalBookingMemberEmails.push({ + name: user.name, + email: user.email, + timeZone: user.timeZone, + phoneNumber: user.phoneNumber, + language: { translate, locale: user.locale ?? "en" }, + }); + } + if (originalRescheduledBooking.user) { + const translate = await getTranslation(originalRescheduledBooking.user.locale ?? "en", "common"); + const originalOrganizer = originalRescheduledBooking.user; + + originalBookingMemberEmails.push({ + ...originalRescheduledBooking.user, + username: originalRescheduledBooking.user.username ?? undefined, + timeFormat: getTimeFormatStringFromUserTimeFormat(originalRescheduledBooking.user.timeFormat), + name: originalRescheduledBooking.user.name || "", + language: { translate, locale: originalRescheduledBooking.user.locale ?? "en" }, + }); + + if (changedOrganizer) { + cancelledRRHostEvt.title = originalRescheduledBooking.title; + cancelledRRHostEvt.startTime = + dayjs(originalRescheduledBooking?.startTime).utc().format() || copyEventAdditionalInfo.startTime; + cancelledRRHostEvt.endTime = + dayjs(originalRescheduledBooking?.endTime).utc().format() || copyEventAdditionalInfo.endTime; + cancelledRRHostEvt.organizer = { + email: originalOrganizer.email, + name: originalOrganizer.name || "", + timeZone: originalOrganizer.timeZone, + language: { translate, locale: originalOrganizer.locale || "en" }, + }; + } + } + + const newBookingMemberEmails: Person[] = [ + ...(copyEvent.team?.members || []), + copyEvent.organizer, + ...copyEvent.attendees, + ]; + + const matchOriginalMemberWithNewMember = (originalMember: Person, newMember: Person) => + originalMember.email === newMember.email; + + const newBookedMembers = newBookingMemberEmails.filter( + (member) => !originalBookingMemberEmails.some((om) => matchOriginalMemberWithNewMember(om, member)) + ); + const cancelledMembers = originalBookingMemberEmails.filter( + (member) => !newBookingMemberEmails.some((nm) => matchOriginalMemberWithNewMember(member, nm)) + ); + const rescheduledMembers = newBookingMemberEmails.filter((member) => + originalBookingMemberEmails.some((om) => matchOriginalMemberWithNewMember(om, member)) + ); + + const reassignedTo = users.find( + (user) => !user.isFixed && newBookedMembers.some((member) => member.email === user.email) + ); + + try { + await Promise.all([ + sendRoundRobinRescheduledEmailsAndSMS( + { ...copyEventAdditionalInfo, iCalUID }, + rescheduledMembers, + metadata + ), + sendRoundRobinScheduledEmailsAndSMS({ + calEvent: copyEventAdditionalInfo, + members: newBookedMembers, + eventTypeMetadata: metadata, + }), + sendRoundRobinCancelledEmailsAndSMS( + cancelledRRHostEvt, + cancelledMembers, + metadata, + reassignedTo + ? { + name: reassignedTo.name, + email: reassignedTo.email, + ...(isRescheduledByBooker && { reason: "Booker Rescheduled" }), + } + : undefined + ), + ]); + } catch (err) { + this.log.error("Failed to send rescheduled round robin event related emails", err); + } + } + + /** + * Handles notifications for a newly CONFIRMED booking. + */ + private async _handleConfirmed(data: ConfirmedEmailAndSmsPayload) { + const { + evt, + eventType: { metadata }, + workflows, + eventNameObject, + additionalInformation, + additionalNotes, + customInputs, + } = data; + + let isHostConfirmationEmailsDisabled = metadata?.disableStandardEmails?.confirmation?.host || false; + if (isHostConfirmationEmailsDisabled) { + isHostConfirmationEmailsDisabled = allowDisablingHostConfirmationEmails(workflows); + } + + let isAttendeeConfirmationEmailDisabled = + metadata?.disableStandardEmails?.confirmation?.attendee || false; + if (isAttendeeConfirmationEmailDisabled) { + isAttendeeConfirmationEmailDisabled = allowDisablingAttendeeConfirmationEmails(workflows); + } + + try { + await sendScheduledEmailsAndSMS( + { ...evt, additionalInformation, additionalNotes, customInputs }, + eventNameObject, + isHostConfirmationEmailsDisabled, + isAttendeeConfirmationEmailDisabled, + metadata + ); + } catch (err) { + this.log.error("Failed to send scheduled event related emails", err); + } + } + + /** + * Handles notifications when a booking REQUEST is made (requires confirmation). + */ + private async _handleRequested(data: RequestedEmailAndSmsPayload) { + const { + evt, + eventType: { metadata }, + attendees, + additionalNotes, + } = data; + if (!attendees?.length) { + this.log.error("Requested action called without attendee details."); + return; + } + this.log.debug( + "Action: BOOKING_REQUESTED. Sending request emails.", + safeStringify({ calEvent: getPiiFreeCalendarEvent(evt) }) + ); + + const eventWithNotes = { ...evt, additionalNotes }; + + try { + await Promise.all([ + sendOrganizerRequestEmail(eventWithNotes, metadata), + sendAttendeeRequestEmailAndSMS(eventWithNotes, attendees[0], metadata), + ]); + } catch (err) { + this.log.error("Failed to send requested event related emails", err); + } + } +} diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 92f5a6a187..ff70a46359 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -1,6 +1,5 @@ import type { DestinationCalendar, User } from "@prisma/client"; // eslint-disable-next-line no-restricted-imports -import { cloneDeep } from "lodash"; import short, { uuid } from "short-uuid"; import { v5 as uuidv5 } from "uuid"; @@ -14,15 +13,6 @@ import { import { getAppFromSlug } from "@calcom/app-store/utils"; import dayjs from "@calcom/dayjs"; import { scheduleMandatoryReminder } from "@calcom/ee/workflows/lib/reminders/scheduleMandatoryReminder"; -import { - sendAttendeeRequestEmailAndSMS, - sendOrganizerRequestEmail, - sendRescheduledEmailsAndSMS, - sendRoundRobinCancelledEmailsAndSMS, - sendRoundRobinRescheduledEmailsAndSMS, - sendRoundRobinScheduledEmailsAndSMS, - sendScheduledEmailsAndSMS, -} from "@calcom/emails"; import getICalUID from "@calcom/emails/lib/getICalUID"; import { CalendarEventBuilder } from "@calcom/features/CalendarEventBuilder"; import type { BookingDataSchemaGetter } from "@calcom/features/bookings/lib/dto/types"; @@ -32,10 +22,6 @@ import { handleWebhookTrigger } from "@calcom/features/bookings/lib/handleWebhoo import { isEventTypeLoggingEnabled } from "@calcom/features/bookings/lib/isEventTypeLoggingEnabled"; import type { CacheService } from "@calcom/features/calendar-cache/lib/getShouldServeCache"; import AssignmentReasonRecorder from "@calcom/features/ee/round-robin/assignmentReason/AssignmentReasonRecorder"; -import { - allowDisablingAttendeeConfirmationEmails, - allowDisablingHostConfirmationEmails, -} from "@calcom/features/ee/workflows/lib/allowDisablingStandardEmails"; import type { FeaturesRepository } from "@calcom/features/flags/features.repository"; import { getFullName } from "@calcom/features/form-builder/utils"; import { UsersRepository } from "@calcom/features/users/users.repository"; @@ -101,12 +87,12 @@ import type { AppsStatus, CalendarEvent, CalEventResponses, - Person, } from "@calcom/types/Calendar"; import type { CredentialForCalendarService } from "@calcom/types/Credential"; import type { EventResult, PartialReference } from "@calcom/types/EventManager"; import type { EventPayloadType, EventTypeInfo } from "../../webhooks/lib/sendPayload"; +import { BookingActionMap, BookingEmailSmsHandler } from "./BookingEmailSmsHandler"; import { getAllCredentialsIncludeServiceAccountKey } from "./getAllCredentialsForUsersOnEvent/getAllCredentials"; import { refreshCredentials } from "./getAllCredentialsForUsersOnEvent/refreshCredentials"; import getBookingDataSchema from "./getBookingDataSchema"; @@ -503,6 +489,7 @@ async function handler( }); const loggerWithEventDetails = createLoggerWithEventDetails(eventTypeId, reqBody.user, eventTypeSlug); + const emailsAndSmsHandler = new BookingEmailSmsHandler({ logger: loggerWithEventDetails }); await checkIfBookerEmailIsBlocked({ loggedInUserId: userId, bookerEmail }); @@ -996,7 +983,7 @@ async function handler( // Filter out host groups that have no hosts in them const nonEmptyHostGroups = Object.fromEntries( - Object.entries(hostGroups).filter(([groupId, hosts]) => hosts.length > 0) + Object.entries(hostGroups).filter(([_groupId, hosts]) => hosts.length > 0) ); // If there are RR hosts, we need to find a lucky user if ( @@ -1660,7 +1647,7 @@ async function handler( let videoCallUrl; - //this is the actual rescheduling logic + // this is the actual rescheduling logic if (!eventType.seatsPerTimeSlot && originalRescheduledBooking?.uid) { log.silly("Rescheduling booking", originalRescheduledBooking.uid); // cancel workflow reminders from previous rescheduled booking @@ -1845,133 +1832,22 @@ async function handler( evt.appsStatus = handleAppsStatus(results, booking, reqAppsStatus); - if (noEmail !== true && isConfirmedByDefault) { - const copyEvent = cloneDeep(evt); - const copyEventAdditionalInfo = { - ...copyEvent, - additionalInformation: metadata, - additionalNotes, // Resets back to the additionalNote input and not the override value - cancellationReason: `$RCH$${rescheduleReason ? rescheduleReason : ""}`, // Removable code prefix to differentiate cancellation from rescheduling for email - }; - const cancelledRRHostEvt = cloneDeep(copyEventAdditionalInfo); - loggerWithEventDetails.debug("Emails: Sending rescheduled emails for booking confirmation"); - - /* - handle emails for round robin - - if booked rr host is the same, then rescheduling email - - if new rr host is booked, then cancellation email to old host and confirmation email to new host - */ - if (eventType.schedulingType === SchedulingType.ROUND_ROBIN) { - const originalBookingMemberEmails: Person[] = []; - - for (const user of originalRescheduledBooking.attendees) { - const translate = await getTranslation(user.locale ?? "en", "common"); - originalBookingMemberEmails.push({ - name: user.name, - email: user.email, - timeZone: user.timeZone, - phoneNumber: user.phoneNumber, - language: { translate, locale: user.locale ?? "en" }, - }); - } - if (originalRescheduledBooking.user) { - const translate = await getTranslation(originalRescheduledBooking.user.locale ?? "en", "common"); - const originalOrganizer = originalRescheduledBooking.user; - - originalBookingMemberEmails.push({ - ...originalRescheduledBooking.user, - username: originalRescheduledBooking.user.username ?? undefined, - timeFormat: getTimeFormatStringFromUserTimeFormat(originalRescheduledBooking.user.timeFormat), - name: originalRescheduledBooking.user.name || "", - language: { translate, locale: originalRescheduledBooking.user.locale ?? "en" }, - }); - - if (changedOrganizer) { - cancelledRRHostEvt.title = originalRescheduledBooking.title; - cancelledRRHostEvt.startTime = - dayjs(originalRescheduledBooking?.startTime).utc().format() || - copyEventAdditionalInfo.startTime; - cancelledRRHostEvt.endTime = - dayjs(originalRescheduledBooking?.endTime).utc().format() || copyEventAdditionalInfo.endTime; - cancelledRRHostEvt.organizer = { - email: originalOrganizer.email, - name: originalOrganizer.name || "", - timeZone: originalOrganizer.timeZone, - language: { translate, locale: originalOrganizer.locale || "en" }, - }; - } - } - - const newBookingMemberEmails: Person[] = - copyEvent.team?.members - .map((member) => member) - .concat(copyEvent.organizer) - .concat(copyEvent.attendees) || []; - - const matchOriginalMemberWithNewMember = (originalMember: Person, newMember: Person) => { - return originalMember.email === newMember.email; - }; - - // scheduled Emails - const newBookedMembers = newBookingMemberEmails.filter( - (member) => - !originalBookingMemberEmails.find((originalMember) => - matchOriginalMemberWithNewMember(originalMember, member) - ) - ); - // cancelled Emails - const cancelledMembers = originalBookingMemberEmails.filter( - (member) => - !newBookingMemberEmails.find((newMember) => matchOriginalMemberWithNewMember(member, newMember)) - ); - // rescheduled Emails - const rescheduledMembers = newBookingMemberEmails.filter((member) => - originalBookingMemberEmails.find((orignalMember) => - matchOriginalMemberWithNewMember(orignalMember, member) - ) - ); - - if (!isDryRun) { - sendRoundRobinRescheduledEmailsAndSMS( - { ...copyEventAdditionalInfo, iCalUID }, - rescheduledMembers, - eventType.metadata - ); - sendRoundRobinScheduledEmailsAndSMS({ - calEvent: copyEventAdditionalInfo, - members: newBookedMembers, - eventTypeMetadata: eventType.metadata, - }); - const reassignedTo = users.find( - (user) => !user.isFixed && newBookedMembers.some((member) => member.email === user.email) - ); - sendRoundRobinCancelledEmailsAndSMS( - cancelledRRHostEvt, - cancelledMembers, - eventType.metadata, - !!reassignedTo - ? { - name: reassignedTo.name, - email: reassignedTo.email, - ...(reqBody.rescheduledBy === bookerEmail && { reason: "Booker Rescheduled" }), - } - : undefined - ); - } - } else { - if (!isDryRun) { - // send normal rescheduled emails (non round robin event, where organizers stay the same) - await sendRescheduledEmailsAndSMS( - { - ...copyEvent, - additionalInformation: metadata, - additionalNotes, // Resets back to the additionalNote input and not the override value - cancellationReason: `$RCH$${rescheduleReason ? rescheduleReason : ""}`, // Removable code prefix to differentiate cancellation from rescheduling for email - }, - eventType?.metadata - ); - } - } + if (!noEmail && isConfirmedByDefault && !isDryRun) { + await emailsAndSmsHandler.send({ + action: BookingActionMap.rescheduled, + data: { + evt, + eventType, + additionalInformation: metadata, + additionalNotes, + iCalUID, + originalRescheduledBooking, + rescheduleReason, + isRescheduledByBooker: reqBody.rescheduledBy === bookerEmail, + users, + changedOrganizer, + }, + }); } // If it's not a reschedule, doesn't require confirmation and there's no price, // Create a booking @@ -2076,43 +1952,23 @@ async function handler( }); } } - if (noEmail !== true) { - let isHostConfirmationEmailsDisabled = false; - let isAttendeeConfirmationEmailDisabled = false; - - isHostConfirmationEmailsDisabled = - eventType.metadata?.disableStandardEmails?.confirmation?.host || false; - isAttendeeConfirmationEmailDisabled = - eventType.metadata?.disableStandardEmails?.confirmation?.attendee || false; - - if (isHostConfirmationEmailsDisabled) { - isHostConfirmationEmailsDisabled = allowDisablingHostConfirmationEmails(workflows); - } - - if (isAttendeeConfirmationEmailDisabled) { - isAttendeeConfirmationEmailDisabled = allowDisablingAttendeeConfirmationEmails(workflows); - } - - loggerWithEventDetails.debug( - "Emails: Sending scheduled emails for booking confirmation", - safeStringify({ - calEvent: getPiiFreeCalendarEvent(evt), - }) - ); - + if (!noEmail) { if (!isDryRun && !(eventType.seatsPerTimeSlot && rescheduleUid)) { - await sendScheduledEmailsAndSMS( - { - ...evt, + await emailsAndSmsHandler.send({ + action: BookingActionMap.confirmed, + data: { + eventType: { + metadata: eventType.metadata, + schedulingType: eventType.schedulingType, + }, + eventNameObject, + workflows, + evt, additionalInformation, additionalNotes, customInputs, }, - eventNameObject, - isHostConfirmationEmailsDisabled, - isAttendeeConfirmationEmailDisabled, - eventType.metadata - ); + }); } } } @@ -2142,8 +1998,10 @@ async function handler( }) ); if (!isDryRun) { - await sendOrganizerRequestEmail({ ...evt, additionalNotes }, eventType.metadata); - await sendAttendeeRequestEmailAndSMS({ ...evt, additionalNotes }, attendeesList[0], eventType.metadata); + await emailsAndSmsHandler.send({ + action: BookingActionMap.requested, + data: { evt, attendees: attendeesList, eventType, additionalNotes }, + }); } } diff --git a/yarn.lock b/yarn.lock index ab7de38173..76696ea05d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2708,7 +2708,7 @@ __metadata: "@axiomhq/winston": ^1.2.0 "@calcom/platform-constants": "*" "@calcom/platform-enums": "*" - "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.340" + "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.343" "@calcom/platform-types": "*" "@calcom/platform-utils": "*" "@calcom/prisma": "*" @@ -3767,13 +3767,13 @@ __metadata: languageName: unknown linkType: soft -"@calcom/platform-libraries@npm:@calcom/platform-libraries@0.0.340": - version: 0.0.340 - resolution: "@calcom/platform-libraries@npm:0.0.340" +"@calcom/platform-libraries@npm:@calcom/platform-libraries@0.0.343": + version: 0.0.343 + resolution: "@calcom/platform-libraries@npm:0.0.343" dependencies: "@calcom/features": "*" "@calcom/lib": "*" - checksum: 19c2f125fc6b3074314365a19894e86a709243ccf29a5315468ff5e8f9767a4b2cc618cd5e4797c37f1e46c8a4f6937b3063b85b5f26157ba65d84ab664d7298 + checksum: 332910a95336b720999e24c6eaf1e7780a507347e4610dbbeff023d69edc2a47f83f4c3014763fbcd23bb92a34d9a61c83d400c71d4501466b5b5f36add4173d languageName: node linkType: hard