import { Injectable } from '@nestjs/common' import { isEmpty, isNil, pick } from 'lodash' import { classToPlain } from 'class-transformer' import { addDays, addMinutes, differenceInDays, format, startOfMinute } from 'date-fns' import { Appointment, AppointmentState, Patient, User } from '../models' import { HttpService } from '../services/http' import { BaseRepository } from './base.repository' const GET = ` query appointment($id: uuid) { appointment(where: {id: {_eq: $id}}) { id patient_id user_id state canceled_at reschedule_credit confirmed_at appointment_id_rescheduled video_session patient_video_token user_video_token patient_arrived_at scheduled_to timezone is_return } } ` const ATTRIBUTES = ` id assistant_user_id patient_id user_id state canceled_at consultation_finished_at consultation_started_at finished_at created_at scheduled_to updated_at confirmed_at appointment_id_rescheduled ` const INSERT = ` mutation insertAppointment($appointment: appointment_insert_input!) { insert_appointment_one(object: $appointment) { ${ATTRIBUTES} } } ` const UPDATE = ` mutation updateAppointment($id: uuid!, $canceled_at: timestamptz, $state: String!, $cancellation_reason: String, $cancellation_reason_comment: String) { update_appointment_by_pk(pk_columns: {id: $id}, _set: {canceled_at: $canceled_at, state: $state, cancellation_reason: $cancellation_reason, cancellation_reason_comment: $cancellation_reason_comment}) { id } } ` const REALLOCATE = ` mutation updateAppointment($id: uuid!, $user_id: String!, $scheduled_to: timestamptz!) { update_appointment_by_pk(pk_columns: {id: $id}, _set: {user_id: $user_id, scheduled_to: $scheduled_to}) { id } } ` const FIND_SCHEDULED_APPOINTMENT = ` query appointment($patientId: String!) { appointment(where: {patient_id: {_eq: $patientId}, state: {_in: ["blocked", "scheduled"]}}) { id user_id } } ` const FIND_SCHEDULED_APPOINTMENT_BY_USER_ID_AND_DATE = ` query scheduledAppointment($patientId: String!, $userId: String!, $scheduledTo: timestamptz!) { appointment_aggregate(where: {user_id: {_eq: $userId}, patient_id: {_eq: $patientId}, scheduled_to: {_gte: $scheduledTo}}) { aggregate { count } } } ` const GET_LAST_APPOINTMENT = ` query getLastAppointment($patientId: String!) { appointment(where: {patient_id: {_eq: $patientId}}, order_by: {created_at: desc}, limit: 1) { id state canceled_at scheduled_to reschedule_credit appointment_id_rescheduled user_id } } ` const GET_LAST_VALID_USED_APPOINTMENT_ID = (searchByUser: boolean) => ` query lastFinishedAppointment($patientId: String!${searchByUser ? ', $userId: String' : ''}) { appointment(where: {state: {_nin: ["cancelled", "scheduled", "blocked", "user_waiting", "patient_waiting"]}, patient_id: {_eq: $patientId}${ searchByUser ? `, user_id: {_in: [$userId]}` : '' }}, order_by: {scheduled_to: desc}, limit: 1) { id user_id patient_id is_return } } ` const GET_COUNT_BY_PATIENT = ` query appointments_count_by_patient_id($patientId: String!) { appointment_aggregate(where: {patient_id: {_eq: $patientId}}) { aggregate { count } } } ` const GET_COUNT_USED_APPOINTMENT = (...data) => { const init = data[1] const end = data[2] const withPeriod = !isEmpty(init) && !isEmpty(end) return (() => ` query appointments_count_by_patient($patientId: String!${ withPeriod ? ', $initDate: timestamptz, $endDate: timestamptz' : '' }) { appointment_aggregate(where: {patient_id: {_eq: $patientId}${ withPeriod ? ', created_at: {_gte: $initDate, _lte: $endDate}' : '' }, canceled_at: {_is_null: true}, state: {_nin: ["scheduled"]}}) { aggregate { count } } } `)() } const DELETE = ` mutation deleteAppointment($id: uuid!) { delete_book(where: {id: {_eq: $id}}) { affected_rows } } ` const FIND_THAT_STARTS_AT = ` query appointmentsThatStartsAt($gte: timestamptz!, $lt: timestamptz!, $state: [String!]!) { appointment(where: {scheduled_to: {_gte: $gte, _lt: $lt}, state: {_in: $state }}) { id patient_id user_id scheduled_to timezone user { id name country_code } patient { country_code name device_id email } } } ` const FIND_APPOINTMENTS_WITH_NUTRI_NO_ARRIVED = ` query appointmentsWithNutriNoArrived($scheduledTo: timestamptz!) { appointment(where: {state: {_eq: "patient_waiting"}, scheduled_to: {_lte: $scheduledTo}}) { id } } ` const APPOINTMENTS_COUNT_WITH_SUFFIX = (beginDate: Date, endDate: Date, isGeneral = true) => { const queries = [] let newBeginDate = new Date(beginDate.getTime()) const userFilterQueryString = isGeneral ? 'user: {role: {_in: $roles}, country_code: {_eq: $countryCode}}' : 'user_id: {_eq: $userId }' while (newBeginDate <= endDate) { const beginDateStart = newBeginDate.toISOString() const beginDateEnd = addDays(newBeginDate, 1).toISOString() queries.push(` scheduled_${newBeginDate.getTime()}: appointment_aggregate(where: {${userFilterQueryString}, scheduled_to: {_gte: "${beginDateStart}", _lte: "${beginDateEnd}"}, _or: [{cancellation_reason: {_is_null: true}}, {cancellation_reason: {_neq: "patient_rescheduled"}}]}) { aggregate { count } } performed_${newBeginDate.getTime()}: appointment_aggregate(where: {${userFilterQueryString}, state: {_in: ["food_plan_pending","food_plan_in_construction","finished"]}, scheduled_to: {_gte: "${beginDateStart}", _lte: "${beginDateEnd}"}}) { aggregate { count } } cancelled_by_patient_${newBeginDate.getTime()}: appointment_aggregate(where: {${userFilterQueryString}, state: {_in: ["cancelled"]}, cancellation_reason: {_eq: "patient_cancelled"}, scheduled_to: {_gte: "${beginDateStart}", _lte: "${beginDateEnd}"}}) { aggregate { count } } cancelled_by_nutri_${newBeginDate.getTime()}: appointment_aggregate(where: {${userFilterQueryString}, state: {_in: ["cancelled", "blocked"]}, cancellation_reason: {_nin: ["patient_cancelled", "patient_rescheduled"]}, scheduled_to: {_gte: "${beginDateStart}", _lte: "${beginDateEnd}"}}) { aggregate { count } }`) newBeginDate = addDays(newBeginDate, 1) } const userFilterArgsString = isGeneral ? '$roles: [roles_enum!]!, $countryCode: String!' : '$userId: String!' return `query countAppointments(${userFilterArgsString}) { ${queries.join('\n')} }` } const GENERAL_RATINGS_COUNT = ` query appointmentRatings($beginDate: timestamptz!, $endDate: timestamptz!, $roles: [roles_enum!]!, $countryCode: String!) { rating: appointment_aggregate(where: {user: {role: {_in: $roles}, country_code: {_eq: $countryCode}}, scheduled_to: {_gte: $beginDate, _lte: $endDate}, rating: {_gte: 0}}) { aggregate { count avg { rating } } } } ` const USER_RATINGS_COUNT = ` query appointmentRatings($beginDate: timestamptz!, $endDate: timestamptz!, $userId: String!) { rating: appointment_aggregate(where: {user_id: {_eq: $userId}, scheduled_to: {_gte: $beginDate, _lte: $endDate}, rating: {_gte: 0}}) { aggregate { count avg { rating } } } } ` const GENERAL_APPOINTMENT = ` query appointmentDelayed($beginDate: timestamptz!, $endDate: timestamptz!, $roles: [roles_enum!]!, $countryCode: String!) { appointment(where: {user: {role: {_in: $roles}, country_code: {_eq: $countryCode}}, scheduled_to: {_gte: $beginDate, _lte: $endDate}, state: {_nin: ["cancelled", "scheduled", "user_waiting"]}}) { consultation_started_at scheduled_to } } ` const USER_APPOINTMENT = ` query appointmentDelayed($beginDate: timestamptz!, $endDate: timestamptz!, $userId: String!) { appointment(where: {user_id: {_eq: $userId}, scheduled_to: {_gte: $beginDate, _lte: $endDate}, state: {_nin: ["cancelled", "scheduled", "user_waiting", "blocked"]}}) { consultation_started_at scheduled_to } } ` const GENERAL_APPOINTMENTS_BY_PATIENTS_COUNT_SQL = ( startDate: string, endDate: string, countryCode: string, roles: Array, ) => ` select SUM(case when is_return = false then 1 else 0 end) is_first, SUM(case when is_return = true then 1 else 0 end) is_return from ( select ap.patient_id, case when count(distinct b.id) > 0 then true else false end is_return from appointment as ap join users u on ap.user_id = u.id and u.role in (${roles.map(role => `'${role}'`).join(',')}) and u.country_code = '${countryCode}' left join appointment b on ap.patient_id = b.patient_id and b.state <> 'cancelled' and b.state <> 'blocked' and b.scheduled_to < '${startDate}' where ap.scheduled_to between '${startDate}' and '${endDate}' and ap.state in ('food_plan_pending', 'food_plan_in_construction', 'finished') group by ap.id ) as app ` const USER_APPOINTMENTS_BY_PATIENTS_COUNT = (startDate: string, endDate: string, userId: string) => ` select SUM(case when is_return = false then 1 else 0 end) is_first, SUM(case when is_return = true then 1 else 0 end) is_return from ( select ap.patient_id, case when count(distinct b.id) > 0 then true else false end is_return from appointment as ap left join appointment b on ap.patient_id = b.patient_id and b.state <> 'cancelled' and b.state <> 'blocked' and b.scheduled_to < '${startDate}' where ap.scheduled_to between '${startDate}' and '${endDate}' and ap.state in ('food_plan_pending', 'food_plan_in_construction', 'finished') and ap.user_id = '${userId}' group by ap.id ) as app ` const GET_SCHEDULED_APPOINTMENTS_WITH_CANCELLED_SUBSCRIPTION = ` select a.* from appointment a inner join patients p on a.patient_id = p.id and p.collaborator = false and p.internal_collaborator = false inner join subscription s on p.subscription_id = s.id and s.cancellation_at is not null where a.state = 'scheduled' ` const GENERAL_APPOINTMENTS_WITH_DURATION = ` query generalAppointmentsDuration($beginDate: timestamptz!, $endDate: timestamptz!, $userId: String, $roles: [roles_enum!]!, $countryCode: String!) { appointment(where: {user: {role: {_in: $roles}, country_code: {_eq: $countryCode}}, scheduled_to: {_gte: $beginDate, _lte: $endDate}, consultation_finished_at: {_is_null:false}}){ consultation_duration_in_seconds } } ` const USER_APPOINTMENTS_WITH_DURATION_AND_FEEDBACK = ` query userAppointmentsRated($beginDate: timestamptz!, $endDate: timestamptz!, $userId: String) { appointment(where: {user_id: {_eq: $userId}, scheduled_to: {_gte: $beginDate, _lte: $endDate}, consultation_finished_at: {_is_null:false}}){ consultation_duration_in_seconds rating feedback patient { name } } } ` const FIND_USER_DELAYED_FOOD_PLAN = ` query userDelayedsFoodPlan($endDate: timestamptz!, $userId: String) { appointment(where: {user_id: {_eq: $userId}, scheduled_to: {_lte: $endDate}, state: {_in: ["food_plan_pending", "food_plan_in_construction"]}}){ id patient_id user_id state consultation_finished_at consultation_started_at finished_at scheduled_to confirmed_at appointment_id_rescheduled rating feedback patient { name } } } ` const FIND_USER_APPOINTMENTS_DATE = ` query findUserAppointmentsByDate($userId: String!, $beginDate: timestamptz!, $endDate: timestamptz!) { appointment(where: {user_id: {_eq: $userId}, scheduled_to: {_gte: $beginDate, _lte: $endDate}}) { id patient_id user_id scheduled_to state } } ` const FIND_SCHEDULED_APPOINTMENT_BY_PATIENT = ` query scheduledAppointmentsByPatient($patient_id: String!) { appointment(where: {patient_id: {_eq: $patient_id}, state: {_in: ["scheduled"]}}) { id } } ` export type COUNT_APPOINTMENTS = { [key: string]: number } export type TOTAL_RATINGS = { total: number average: number } export type COUNT_APPOINTMENTS_TYPES = { first: number return: number } @Injectable() export class AppointmentRepository extends BaseRepository { constructor(protected readonly httpService: HttpService) { super(httpService, 'appointment') } async create(appointment: Appointment): Promise { const qv = { appointment: classToPlain(appointment) } const { data } = await this.fetchGraphQL({ body: { query: INSERT, variables: qv }, }) return data.insert_appointment_one.id } async get(id: string): Promise { const { data } = await this.fetchGraphQL({ body: { query: GET, variables: { id } }, }) return isEmpty(data.appointment) ? null : Appointment.toInstance(data.appointment[0]) } async update(appointment: Appointment): Promise { await this.fetchGraphQL({ body: { query: UPDATE, variables: pick(classToPlain(appointment), [ 'id', 'state', 'canceled_at', 'cancellation_reason', 'cancellation_reason_comment', ]), }, }) } async reallocate(appointment: Appointment): Promise { await this.fetchGraphQL({ body: { query: REALLOCATE, variables: pick(classToPlain(appointment), ['id', 'user_id', 'scheduled_to']), }, }) } async hasScheduledAppointment(patientId: string): Promise { const { data } = await this.fetchGraphQL({ body: { query: FIND_SCHEDULED_APPOINTMENT, variables: { patientId } }, }) return !isEmpty(data.appointment) } async getScheduledAppointmentsWithCancelledSubscription(): Promise { const data = await this.fetchSQL(GET_SCHEDULED_APPOINTMENTS_WITH_CANCELLED_SUBSCRIPTION) return data.map(value => Appointment.toInstance(value)) } async getLastAppointment(patientId: string): Promise { const { data } = await this.fetchGraphQL({ body: { query: GET_LAST_APPOINTMENT, variables: { patientId } }, }) return isEmpty(data.appointment) ? null : Appointment.toInstance(data.appointment[0]) } async getLastValidUsedAppointment(patientId: string, userId?: string): Promise { const { data } = await this.fetchGraphQL({ body: { query: GET_LAST_VALID_USED_APPOINTMENT_ID(!isNil(userId)), variables: { patientId, userId }, }, }) return !isEmpty(data?.appointment) ? Appointment.toInstance(data.appointment[0]) : null } async getCountUsedAppointments(patientId: string, initDate?: Date, endDate?: Date): Promise { const { data } = await this.fetchGraphQL({ body: { query: GET_COUNT_USED_APPOINTMENT`${initDate}${endDate}`, variables: { patientId, initDate, endDate }, }, }) return data?.appointment_aggregate?.aggregate?.count || 0 } async getCountByPatient(patientId: string): Promise { const { data } = await this.fetchGraphQL({ body: { query: GET_COUNT_BY_PATIENT, variables: { patientId } }, }) return data?.appointment_aggregate?.aggregate?.count || 0 } async delete(id: string): Promise { await this.fetchGraphQL({ body: { query: DELETE, variables: { id }, }, }) } async findThatStartsAtHour({ date, windowInMinutes, state, }: { date: Date windowInMinutes: number state: AppointmentState[] }): Promise< Array< Appointment & { patient: Patient } & { user: User } > > { const { data } = await this.fetchGraphQL({ body: { query: FIND_THAT_STARTS_AT, variables: { gte: startOfMinute(date).toISOString(), lt: startOfMinute(addMinutes(date, windowInMinutes)).toISOString(), state, }, }, }) return !isEmpty(data.appointment) ? data.appointment.map(value => Appointment.toInstance(value)) : [] } async findAppointmentsWithNutriNoArrived(scheduledTo: Date): Promise { const { data } = await this.fetchGraphQL({ body: { query: FIND_APPOINTMENTS_WITH_NUTRI_NO_ARRIVED, variables: { scheduledTo }, }, }) return !isEmpty(data.appointment) ? data.appointment.map(value => Appointment.toInstance(value)) : [] } async findScheduledAppointmentsByPatient(patientId: string): Promise { const { data } = await this.fetchGraphQL({ body: { query: FIND_SCHEDULED_APPOINTMENT_BY_PATIENT, variables: { patient_id: patientId }, }, }) return !isEmpty(data.appointment) ? data.appointment.map((value: Appointment) => Appointment.toInstance(value)) : [] } async getGeneralAppointmentsCount( beginDate: Date, endDate: Date, countryCode: string, roles: string[], ): Promise { const { data } = await this.fetchGraphQL({ body: { query: APPOINTMENTS_COUNT_WITH_SUFFIX(beginDate, endDate, true), variables: { roles, countryCode }, }, }) return (Object.keys(data) || []).reduce( (previous, current) => ({ ...previous, [current]: data[current].aggregate?.count || 0 }), {}, ) } async getUserAppointmentsCount(beginDate: Date, endDate: Date, userId: string): Promise { const { data } = await this.fetchGraphQL({ body: { query: APPOINTMENTS_COUNT_WITH_SUFFIX(beginDate, endDate, false), variables: { userId }, }, }) return (Object.keys(data) || []).reduce( (previous, current) => ({ ...previous, [current]: data[current].aggregate?.count || 0 }), {}, ) } async getGeneralTotalRatings( beginDate: Date, endDate: Date, countryCode: string, roles: string[], ): Promise { const { data } = await this.fetchGraphQL({ body: { query: GENERAL_RATINGS_COUNT, variables: { beginDate, endDate, roles, countryCode }, }, }) return { total: data?.rating?.aggregate?.count || 0, average: data?.rating?.aggregate?.avg?.rating || 0, } } async getGeneralAppointmentsWithDuration( beginDate: Date, endDate: Date, countryCode: string, roles: string[], ): Promise> { const { data } = await this.fetchGraphQL({ body: { query: GENERAL_APPOINTMENTS_WITH_DURATION, variables: { beginDate, endDate, roles, countryCode }, }, }) return data?.appointment?.map((row: Appointment) => Appointment.toInstance(row)) || [] } async getUserTotalRatings(beginDate: Date, endDate: Date, userId: string): Promise { const { data } = await this.fetchGraphQL({ body: { query: USER_RATINGS_COUNT, variables: { beginDate, endDate, userId }, }, }) return { total: data?.rating?.aggregate?.count || 0, average: data?.rating?.aggregate?.avg?.rating || 0, } } async getCountOfAppointmentsWithDelayedPatient( beginDate: Date, endDate: Date, countryCode: string, roles: string[], ): Promise { const { data } = await this.fetchGraphQL({ body: { query: GENERAL_APPOINTMENT, variables: { beginDate, endDate, roles, countryCode }, }, }) return ( data?.appointment ?.map((row: Appointment) => Appointment.toInstance(row)) .filter((appointment: Appointment) => appointment.consultation_started_at > appointment.scheduled_to) || [] ).length } async getUserCountOfAppointmentsWithDelayedPatient( beginDate: Date, endDate: Date, userId: string, minutesDelay = 5, ): Promise { const { data } = await this.fetchGraphQL({ body: { query: USER_APPOINTMENT, variables: { beginDate, endDate, userId }, }, }) return ( data?.appointment ?.map((row: Appointment) => Appointment.toInstance(row)) .filter((appointment: Appointment) => appointment.isDelayed(minutesDelay)) || [] ).length } async getGeneralTotalAppointmentsByType( beginDate: Date, endDate: Date, countryCode: string, roles: string[], ): Promise { const result = await this.fetchSQL( GENERAL_APPOINTMENTS_BY_PATIENTS_COUNT_SQL(beginDate.toISOString(), endDate.toISOString(), countryCode, roles), ) return { first: +result?.[0].is_first || 0, return: +result?.[0].is_return || 0 } } async getUserTotalAppointmentsByType( beginDate: Date, endDate: Date, userId: string, ): Promise { const result = await this.fetchSQL( USER_APPOINTMENTS_BY_PATIENTS_COUNT(beginDate.toISOString(), endDate.toISOString(), userId), ) return { first: +result?.[0].is_first || 0, return: +result?.[0].is_return || 0 } } async findUserAppointmentsWithDuration(beginDate: Date, endDate: Date, userId: string): Promise { const { data } = await this.fetchGraphQL({ body: { query: USER_APPOINTMENTS_WITH_DURATION_AND_FEEDBACK, variables: { beginDate, endDate, userId }, }, }) return data?.appointment?.map(row => Appointment.toInstance(row)) || [] } async findDelayedsFoodPlan(endDate: Date, userId: string): Promise { const { data } = await this.fetchGraphQL({ body: { query: FIND_USER_DELAYED_FOOD_PLAN, variables: { endDate, userId }, }, }) return ( data?.appointment ?.map(row => Appointment.toInstance(row)) .filter(appointment => this.checkIfFoodPlanIsThreeDaysLate(appointment, endDate)) || [] ) } async findUserAppointments(beginDate: Date, endDate: Date, userId: string): Promise { const { data } = await this.fetchGraphQL({ body: { query: FIND_USER_APPOINTMENTS_DATE, variables: { beginDate, endDate, userId }, }, }) return data?.appointment?.map(row => Appointment.toInstance(row)) || [] } async existsScheduledAppointmentBy(patientId: string, userId: string, scheduledTo = new Date()): Promise { const { data } = await this.fetchGraphQL({ body: { query: FIND_SCHEDULED_APPOINTMENT_BY_USER_ID_AND_DATE, variables: { patientId, userId, scheduledTo: scheduledTo.toISOString() }, }, }) return (data?.appointment_aggregate?.aggregate?.count ?? 0) > 0 } private checkIfFoodPlanIsThreeDaysLate = (appointment: Appointment, date: Date): boolean => { const baseDate = appointment.consultation_finished_at const dayWeek = Number(format(date, 'i')) const numberDays = [1, 2, 3, 7].includes(dayWeek) ? 5 : dayWeek === 6 ? 4 : 3 return differenceInDays(date, baseDate) >= numberDays } }