Files
appointment-booking-software/src/lib/server/services/appointment-service.ts
T
2026-07-23 17:42:25 +02:00

1261 lines
40 KiB
TypeScript

import { ERRORS } from "$lib/errors";
import logger from "$lib/logger";
import type { AppointmentResponse } from "$lib/types/appointment";
import {
and,
asc,
eq,
gt,
gte,
lt,
lte,
ne,
or,
sql,
type ExtractTablesWithRelations,
} from "drizzle-orm";
import { timingSafeEqual } from "node:crypto";
import { centralDb, getTenantDb } from "../db";
import * as centralSchema from "../db/central-schema";
import * as tenantSchema from "../db/tenant-schema";
import { type SelectAppointment } from "../db/tenant-schema";
import { TenantService } from "../db/tenant-service";
import {
getChannelTitle,
sendAppointmentCancelledEmail,
sendAppointmentCreatedEmail,
sendAppointmentRejectedEmail,
sendAppointmentRequestEmail,
} from "../email/email-service";
import { ConflictError, InternalError, NotFoundError, ValidationError } from "../utils/errors";
import { challengeStore } from "./challenge-store";
import { challengeThrottleService } from "./challenge-throttle";
import { NotificationService } from "./notification-service";
import { TenantAdminService } from "./tenant-admin-service";
import type { PgTransaction } from "drizzle-orm/pg-core";
import type { PostgresJsQueryResultHKT } from "drizzle-orm/postgres-js";
export interface ClientTunnelData {
tunnelId: string;
channelId: string;
agentId: string;
appointmentDate: string;
appointmentTimeZone: string;
duration: number;
emailHash: string;
clientEmail?: string;
clientLanguage?: string;
clientPublicKey: string;
privateKeyShare: string;
encryptedAppointment: {
encryptedPayload: string;
iv: string;
authTag: string;
};
staffKeyShares: {
userId: string;
encryptedTunnelKey: string;
}[];
clientEncryptedTunnelKey: string;
}
export interface ClientTunnelResponse {
id: string;
emailHash: string;
clientPublicKey: string;
currentStaffEncryptedTunnelKey?: string;
createdAt?: string;
updatedAt?: string;
}
export class AppointmentService {
#db: Awaited<ReturnType<typeof getTenantDb>> | null = null;
private constructor(public readonly tenantId: string) {}
/**
* Create an appointment service for a specific tenant
* @param tenantId The ID of the tenant
* @returns new AppointmentService instance
*/
static async forTenant(tenantId: string) {
const log = logger.setContext("AppointmentService");
log.debug("Creating appointment service for tenant", { tenantId });
try {
const service = new AppointmentService(tenantId);
service.#db = await getTenantDb(tenantId);
log.debug("Appointment service created successfully", { tenantId });
return service;
} catch (error) {
log.error("Failed to create appointment service", { tenantId, error: String(error) });
throw error;
}
}
async cleanupExpiredAppointments(tenantId: string): Promise<void> {
const log = logger.setContext("AppointmentService");
const db = await this.getDb();
const tenant = new TenantService(tenantId);
const config = await tenant.getConfig();
const cutOffDays = typeof config.autoDeleteDays === "number" ? config.autoDeleteDays : 90;
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - cutOffDays);
const deletedAppointments = await db
.delete(tenantSchema.appointment)
.where(lt(tenantSchema.appointment.appointmentDate, cutoffDate))
.returning({ id: tenantSchema.appointment.id });
log.info("Expired appointments cleaned up", {
tenantId: this.tenantId,
deletedCount: deletedAppointments.length,
cutoffDate: cutoffDate.toISOString(),
});
}
async hasAppointments(): Promise<boolean> {
const log = logger.setContext("AppointmentService");
log.debug("Checking if tenant has appointments", { tenantId: this.tenantId });
const db = await this.getDb();
const result = await db.select().from(tenantSchema.appointment).limit(1);
return result.length > 0;
}
private async ensureAgentIsAvailableForSlot(
tx: PgTransaction<
PostgresJsQueryResultHKT,
typeof tenantSchema,
ExtractTablesWithRelations<typeof tenantSchema>
>,
params: {
agentId: string;
appointmentDate: string;
duration: number;
},
): Promise<void> {
const log = logger.setContext("AppointmentService");
// Lock the agent row to prevent concurrent modifications, additional calls will be serialised
const [lockedAgent] = await tx
.select({ id: tenantSchema.agent.id })
.from(tenantSchema.agent)
.where(eq(tenantSchema.agent.id, params.agentId))
.for("update");
if (!lockedAgent) {
throw new NotFoundError("Agent not found");
}
const slotStart = new Date(params.appointmentDate);
if (Number.isNaN(slotStart.getTime())) {
throw new ValidationError("Invalid appointment date");
}
const slotEnd = new Date(slotStart.getTime() + params.duration * 60_000);
const overlappingAppointment = await tx
.select({ id: tenantSchema.appointment.id })
.from(tenantSchema.appointment)
.where(
and(
eq(tenantSchema.appointment.agentId, params.agentId),
ne(tenantSchema.appointment.status, "REJECTED"),
lt(tenantSchema.appointment.appointmentDate, slotEnd),
sql`${tenantSchema.appointment.appointmentDate} + (${tenantSchema.appointment.duration} * interval '1 minute') > ${slotStart.toISOString()}::timestamp`,
),
)
.limit(1);
if (overlappingAppointment.length > 0) {
log.warn("Appointment creation blocked due to overlapping appointment", {
tenantId: this.tenantId,
agentId: params.agentId,
appointmentDate: params.appointmentDate,
});
throw new ConflictError(ERRORS.APPOINTMENTS.AGENT_NOT_AVAILABLE);
}
const withinSchedule = (slot: Date) => {
const iso = slot.toISOString();
// EXTRACT(DOW) -> 0 = Sunday … 6 = Saturday; remap to 0 = Monday … 6 = Sunday
const dayIndex = sql`((EXTRACT(DOW FROM ${iso}::timestamptz)::int + 6) % 7)`;
return and(
// weekday bit is set in the bitmap
sql`(${tenantSchema.agentAbsence.weekdays} & (1 << ${dayIndex})) <> 0`,
// time-of-day within [from, to]
sql`${iso}::timestamptz::time >= ${tenantSchema.agentAbsence.from}::time`,
sql`${iso}::timestamptz::time <= ${tenantSchema.agentAbsence.to}::time`,
);
};
const overlappingAbsence = await tx
.select({ id: tenantSchema.agentAbsence.id })
.from(tenantSchema.agentAbsence)
.where(
or(
and(
eq(tenantSchema.agentAbsence.type, "ONE_TIME"),
eq(tenantSchema.agentAbsence.agentId, params.agentId),
lt(tenantSchema.agentAbsence.startDate, slotEnd),
gt(tenantSchema.agentAbsence.endDate, slotStart),
),
and(
eq(tenantSchema.agentAbsence.type, "RECURRING"),
eq(tenantSchema.agentAbsence.agentId, params.agentId),
lt(tenantSchema.agentAbsence.startDate, slotEnd),
gt(tenantSchema.agentAbsence.endDate, slotStart),
withinSchedule(slotStart),
withinSchedule(slotEnd),
),
),
)
.limit(1);
if (overlappingAbsence.length > 0) {
log.warn("Appointment creation blocked due to overlapping absence", {
tenantId: this.tenantId,
agentId: params.agentId,
appointmentDate: params.appointmentDate,
});
throw new ConflictError(ERRORS.APPOINTMENTS.AGENT_NOT_AVAILABLE);
}
}
/**
* Send appointment notification email to client
* @param appointmentId - The appointment ID
* @param channelId - The channel ID
* @param clientEmail - Client's email address
* @param clientLanguage - Client's preferred language
* @param requiresConfirmation - Whether the appointment requires staff confirmation
*/
async sendAppointmentNotification(
appointmentId: string,
channelId: string,
clientEmail: string,
clientLanguage: string,
requiresConfirmation: boolean,
): Promise<void> {
const log = logger.setContext("AppointmentService");
log.debug("Sending appointment notification", {
appointmentId,
clientEmail,
requiresConfirmation,
tenantId: this.tenantId,
});
try {
// Get tenant information
const tenantService = await TenantAdminService.getTenantById(this.tenantId);
const tenant = tenantService.tenantData;
if (!tenant) {
log.warn("Cannot send email: Tenant not found", { tenantId: this.tenantId });
return;
}
// Get full appointment data
const appointment = await this.getAppointmentById(appointmentId);
// Create client data object
const clientData = {
email: clientEmail,
language: clientLanguage,
};
// Get channel title for the email
const channelTitle = await getChannelTitle(this.tenantId, channelId, clientLanguage);
// Send appropriate email based on whether confirmation is required
if (requiresConfirmation) {
await sendAppointmentRequestEmail(clientData, tenant, appointment, channelTitle);
log.info("Appointment request email sent", {
appointmentId: appointment.id,
tenantId: this.tenantId,
});
} else if (appointment.status === "REJECTED") {
await sendAppointmentRejectedEmail(clientData, tenant, appointment, channelTitle);
} else {
await sendAppointmentCreatedEmail(clientData, tenant, appointment, channelTitle);
log.info("Appointment confirmation email sent", {
appointmentId: appointment.id,
tenantId: this.tenantId,
});
}
} catch (error) {
log.error("Failed to send appointment notification email", {
appointmentId,
tenantId: this.tenantId,
error: String(error),
});
// Don't throw - email failure shouldn't fail the appointment creation
}
}
public async getAppointmentById(id: string): Promise<SelectAppointment> {
const log = logger.setContext("AppointmentService");
log.debug("Fetching appointment by ID", { appointmentId: id, tenantId: this.tenantId });
const db = await this.getDb();
const result = await db
.select()
.from(tenantSchema.appointment)
.where(eq(tenantSchema.appointment.id, id))
.limit(1);
if (result.length === 0) {
log.warn("Appointment not found", { appointmentId: id, tenantId: this.tenantId });
throw new ValidationError("Appointment not found");
}
const row = result[0];
return row;
}
public async confirmAppointment(
id: string,
clientEmail?: string,
clientLanguage?: string,
): Promise<SelectAppointment> {
const log = logger.setContext("AppointmentService");
log.debug("Confirming appointment by ID", { appointmentId: id, tenantId: this.tenantId });
const db = await this.getDb();
const result = await db
.update(tenantSchema.appointment)
.set({ status: "CONFIRMED" })
.where(and(eq(tenantSchema.appointment.id, id), eq(tenantSchema.appointment.status, "NEW")))
.returning();
if (result.length === 0) {
log.warn("Appointment not found or in wrong state", {
appointmentId: id,
state: result[0] ? result[0].status : undefined,
tenantId: this.tenantId,
});
throw new ValidationError("Appointment not found or in wrong state");
}
const row = result[0];
// Send confirmation email to client if email is provided (async, don't wait)
if (clientEmail) {
this.sendAppointmentNotification(
row.id,
row.channelId,
clientEmail,
clientLanguage || "en",
false,
).catch((error) => {
log.error("Failed to send appointment confirmation email", {
appointmentId: row.id,
error: String(error),
});
});
}
const notificationService = await NotificationService.forTenant(this.tenantId);
await notificationService.createNotification({
channelId: row.channelId,
type: "APPOINTMENT_CONFIRMED",
metaData: {
appointmentId: row.id,
},
});
return row;
}
public async denyAppointment(
id: string,
clientEmail?: string,
clientLanguage?: string,
): Promise<void> {
const log = logger.setContext("AppointmentService");
log.debug("Denying appointment by ID", { appointmentId: id, tenantId: this.tenantId });
const db = await this.getDb();
const result = await db
.update(tenantSchema.appointment)
.set({ status: "REJECTED" })
.where(and(eq(tenantSchema.appointment.id, id), eq(tenantSchema.appointment.status, "NEW")))
.returning();
if (result.length === 0) {
log.warn("Appointment not found or in wrong state", {
appointmentId: id,
state: result[0] ? result[0].status : undefined,
tenantId: this.tenantId,
});
throw new ValidationError("Appointment not found or in wrong state");
}
const row = result[0];
// Send rejection email to client if email is provided (async, don't wait)
if (clientEmail) {
await this.sendAppointmentNotification(
row.id,
row.channelId,
clientEmail,
clientLanguage || "en",
false,
).catch((error) => {
log.error("Failed to send appointment rejection email", {
appointmentId: row.id,
error: String(error),
});
});
}
db.delete(tenantSchema.appointment)
.where(eq(tenantSchema.appointment.id, id))
.then(() => {
log.debug("Appointment deleted after rejection", {
appointmentId: id,
tenantId: this.tenantId,
});
})
.catch((error) => {
log.error("Failed to delete appointment after rejection", {
appointmentId: id,
error: String(error),
tenantId: this.tenantId,
});
});
}
public async cancelAppointment(id: string): Promise<SelectAppointment> {
const log = logger.setContext("AppointmentService");
log.debug("Cancelling appointment by ID", { appointmentId: id, tenantId: this.tenantId });
const db = await this.getDb();
const result = await db
.update(tenantSchema.appointment)
.set({ status: "REJECTED" })
.where(eq(tenantSchema.appointment.id, id))
.returning();
if (result.length === 0) {
log.warn("Appointment not found or in wrong state", {
appointmentId: id,
state: result[0] ? result[0].status : undefined,
tenantId: this.tenantId,
});
throw new ValidationError("Appointment not found or in wrong state");
}
const row = result[0];
return row;
}
public async deleteAppointment(id: string): Promise<boolean> {
const log = logger.setContext("AppointmentService");
log.debug("Deleting appointment by ID", { appointmentId: id, tenantId: this.tenantId });
const db = await this.getDb();
const result = await db
.delete(tenantSchema.appointment)
.where(eq(tenantSchema.appointment.id, id))
.returning();
if (result.length === 0) {
log.debug("Appointment not found for deletion", {
appointmentId: id,
tenantId: this.tenantId,
});
return false;
}
log.debug("Appointment deleted successfully", { appointmentId: id, tenantId: this.tenantId });
return true;
}
/**
* Delete appointment by staff member
* This will:
* 1. Remove the appointment from the database
* 2. Send cancellation email to the client
* 3. Create notifications for all staff members in the channel
* @param appointmentId - The appointment ID
* @param clientEmail - The client's email address
* @param clientLanguage - The client's preferred language (optional, defaults to "de")
* @returns Promise<void>
*/
public async deleteAppointmentByStaff(
appointmentId: string,
clientEmail: string | undefined,
clientLanguage: string = "de",
): Promise<void> {
const log = logger.setContext("AppointmentService");
log.debug("Deleting appointment by staff", {
appointmentId,
clientEmail,
tenantId: this.tenantId,
});
const db = await this.getDb();
// Get appointment details before deletion
const appointmentResult = await db
.select()
.from(tenantSchema.appointment)
.where(eq(tenantSchema.appointment.id, appointmentId))
.limit(1);
if (appointmentResult.length === 0) {
log.warn("Appointment not found for deletion", {
appointmentId,
tenantId: this.tenantId,
});
throw new NotFoundError("Appointment not found");
}
const appointment = appointmentResult[0];
const channelId = appointment.channelId;
// Delete the appointment
await db.delete(tenantSchema.appointment).where(eq(tenantSchema.appointment.id, appointmentId));
log.debug("Appointment deleted from database", { appointmentId, tenantId: this.tenantId });
// Get tenant and channel information for email and notifications
const tenantService = await TenantAdminService.getTenantById(this.tenantId);
const tenant = tenantService.tenantData;
if (!tenant) {
log.error("Tenant not found", { tenantId: this.tenantId });
throw new InternalError("Tenant not found");
}
// Get channel title
const channelTitle = await getChannelTitle(this.tenantId, channelId, clientLanguage);
// Send cancellation email to client (async, don't wait)
if (clientEmail) {
const clientData = {
email: clientEmail,
language: clientLanguage,
};
sendAppointmentCancelledEmail(clientData, tenant, appointment, channelTitle).catch(
(error) => {
log.error("Failed to send appointment cancellation email", {
appointmentId,
clientEmail,
error: String(error),
});
},
);
}
// Create notifications for all staff members in the channel
const notificationService = await NotificationService.forTenant(this.tenantId);
// Create notifications (async, don't wait)
notificationService
.createNotification({
channelId,
type: "APPOINTMENT_CANCELLED",
metaData: {
appointmentId,
},
})
.catch((error) => {
log.error("Failed to create channel notifications", {
appointmentId,
channelId,
error: String(error),
});
});
log.info("Appointment deleted by staff successfully", {
appointmentId,
channelId,
tenantId: this.tenantId,
});
}
/**
* Get all client tunnels for the tenant
*/
public async getClientTunnels(staffUserId?: string): Promise<ClientTunnelResponse[]> {
const log = logger.setContext("AppointmentService");
log.debug("Fetching client tunnels", { tenantId: this.tenantId, staffUserId });
const db = await this.getDb();
const tunnels = await db
.select({
id: tenantSchema.clientAppointmentTunnel.id,
emailHash: tenantSchema.clientAppointmentTunnel.emailHash,
clientPublicKey: tenantSchema.clientAppointmentTunnel.clientPublicKey,
createdAt: tenantSchema.clientAppointmentTunnel.createdAt,
updatedAt: tenantSchema.clientAppointmentTunnel.updatedAt,
})
.from(tenantSchema.clientAppointmentTunnel)
.orderBy(tenantSchema.clientAppointmentTunnel.createdAt);
let encryptedTunnelKeyByTunnelId = new Map<string, string>();
if (staffUserId) {
const staffKeyShares = await db
.select({
tunnelId: tenantSchema.clientTunnelStaffKeyShare.tunnelId,
encryptedTunnelKey: tenantSchema.clientTunnelStaffKeyShare.encryptedTunnelKey,
})
.from(tenantSchema.clientTunnelStaffKeyShare)
.where(eq(tenantSchema.clientTunnelStaffKeyShare.userId, staffUserId));
encryptedTunnelKeyByTunnelId = new Map(
staffKeyShares.map((share) => [share.tunnelId, share.encryptedTunnelKey]),
);
}
log.debug("Client tunnels retrieved successfully", {
tenantId: this.tenantId,
tunnelCount: tunnels.length,
});
return tunnels.map((tunnel) => ({
id: tunnel.id,
emailHash: tunnel.emailHash,
clientPublicKey: tunnel.clientPublicKey,
currentStaffEncryptedTunnelKey: encryptedTunnelKeyByTunnelId.get(tunnel.id),
createdAt: tunnel.createdAt?.toISOString(),
updatedAt: tunnel.updatedAt?.toISOString(),
}));
}
/**
* Add appointment to existing client tunnel
*/
public async addAppointmentToTunnel(
appointmentData: {
emailHash: string;
tunnelId: string;
channelId: string;
agentId: string;
appointmentDate: string;
appointmentTimeZone: string;
duration: number;
clientEmail: string;
clientLanguage?: string;
encryptedAppointment: {
encryptedPayload: string;
iv: string;
authTag: string;
};
incomingTunnelId?: string;
},
staffCreated = false,
): Promise<AppointmentResponse> {
const log = logger.setContext("AppointmentService");
log.debug("Adding appointment to existing tunnel", {
tenantId: this.tenantId,
tunnelId: appointmentData.tunnelId,
appointmentDate: appointmentData.appointmentDate,
});
const db = await this.getDb();
// Check if tunnel exists and belongs to client
const tunnelResult = await db
.select({ id: tenantSchema.clientAppointmentTunnel.id })
.from(tenantSchema.clientAppointmentTunnel)
.where(eq(tenantSchema.clientAppointmentTunnel.emailHash, appointmentData.emailHash))
.limit(1);
if (tunnelResult.length === 0 || tunnelResult[0].id !== appointmentData.incomingTunnelId) {
log.warn("Client tunnel not found", {
tenantId: this.tenantId,
tunnelId: appointmentData.tunnelId,
emailHashPrefix: appointmentData.emailHash.slice(0, 8),
});
throw new NotFoundError("Tunnel not found or access denied");
}
// Get channel configuration to determine initial status
const channelResult = await db
.select({ requiresConfirmation: tenantSchema.channel.requiresConfirmation })
.from(tenantSchema.channel)
.where(
and(
eq(tenantSchema.channel.id, appointmentData.channelId),
eq(tenantSchema.channel.isPublic, true),
),
)
.limit(1);
if (channelResult.length === 0) {
throw new NotFoundError("Active channel not found");
}
let result:
| {
id: string;
appointmentDate: Date;
status: "NEW" | "CONFIRMED" | "HELD" | "REJECTED" | "NO_SHOW";
}
| undefined;
await db.transaction(async (tx) => {
await this.ensureAgentIsAvailableForSlot(tx, {
agentId: appointmentData.agentId,
appointmentDate: appointmentData.appointmentDate,
duration: appointmentData.duration,
});
const initialStatus =
channelResult[0].requiresConfirmation && !staffCreated ? "NEW" : "CONFIRMED";
// Create encrypted appointment
const [appointmentResult] = await tx
.insert(tenantSchema.appointment)
.values({
tunnelId: appointmentData.tunnelId,
channelId: appointmentData.channelId,
agentId: appointmentData.agentId,
appointmentDate: new Date(appointmentData.appointmentDate),
timezone: appointmentData.appointmentTimeZone,
duration: appointmentData.duration,
encryptedPayload: appointmentData.encryptedAppointment.encryptedPayload,
iv: appointmentData.encryptedAppointment.iv,
authTag: appointmentData.encryptedAppointment.authTag,
status: initialStatus,
})
.returning({
id: tenantSchema.appointment.id,
appointmentDate: tenantSchema.appointment.appointmentDate,
status: tenantSchema.appointment.status,
});
result = appointmentResult;
});
if (!result) {
throw new InternalError("Failed to create appointment");
}
const requiresConfirmation = staffCreated
? false
: channelResult[0].requiresConfirmation || false;
const response: AppointmentResponse = {
id: result.id,
appointmentDate: result.appointmentDate.toISOString(),
appointmentTimeZone: appointmentData.appointmentTimeZone,
status: result.status,
requiresConfirmation,
};
log.debug("Successfully added appointment to tunnel", {
tenantId: this.tenantId,
tunnelId: appointmentData.tunnelId,
appointmentId: result.id,
});
return response;
}
/**
* Create a new client tunnel with their first appointment
*/
public async createNewClientWithAppointment(
clientData: ClientTunnelData,
staffCreated = false,
): Promise<AppointmentResponse> {
const log = logger.setContext("AppointmentService");
log.debug("Creating new client appointment tunnel", {
tenantId: this.tenantId,
tunnelId: clientData.tunnelId,
appointmentDate: clientData.appointmentDate,
});
// Check if there are any authorized users (ACCESS_GRANTED) in this tenant
const authorizedUsers = await centralDb
.select({ count: centralSchema.user.id })
.from(centralSchema.user)
.where(
and(
eq(centralSchema.user.tenantId, this.tenantId),
eq(centralSchema.user.confirmationState, "ACCESS_GRANTED"),
),
)
.limit(1);
if (authorizedUsers.length === 0) {
log.warn("Client creation blocked: No authorized users in tenant", {
tenantId: this.tenantId,
tunnelId: clientData.tunnelId,
});
throw new ConflictError(
"Cannot create client appointments: No authorized users found in tenant. At least one user must have ACCESS_GRANTED status.",
);
}
const db = await this.getDb();
// Check if client already exists
const existingTunnel = await db
.select({ id: tenantSchema.clientAppointmentTunnel.id })
.from(tenantSchema.clientAppointmentTunnel)
.where(eq(tenantSchema.clientAppointmentTunnel.emailHash, clientData.emailHash))
.limit(1);
if (existingTunnel.length > 0) {
log.warn("Client creation failed: Email already registered", {
tenantId: this.tenantId,
emailHashPrefix: clientData.emailHash.slice(0, 8),
});
throw new ConflictError(
"This email address is already registered. Please use the login option to book additional appointments.",
);
}
// Transactional: Create tunnel and appointment
const result = await db.transaction(async (tx) => {
// 0. Ensure the agent is available. Needs to be part of transaction to avoid race conditions with concurrent appointment creation.
await this.ensureAgentIsAvailableForSlot(tx, {
agentId: clientData.agentId,
appointmentDate: clientData.appointmentDate,
duration: clientData.duration,
});
// 1. Create client appointment tunnel
const tunnelResult = await tx
.insert(tenantSchema.clientAppointmentTunnel)
.values({
id: clientData.tunnelId,
emailHash: clientData.emailHash,
clientPublicKey: clientData.clientPublicKey,
privateKeyShare: clientData.privateKeyShare,
clientEncryptedTunnelKey: clientData.clientEncryptedTunnelKey,
})
.returning({ id: tenantSchema.clientAppointmentTunnel.id });
if (tunnelResult.length === 0) {
throw new InternalError("Failed to create client appointment tunnel");
}
// 2. Store staff key shares for the tunnel
if (clientData.staffKeyShares.length > 0) {
await tx.insert(tenantSchema.clientTunnelStaffKeyShare).values(
clientData.staffKeyShares.map((share) => ({
tunnelId: clientData.tunnelId,
userId: share.userId,
encryptedTunnelKey: share.encryptedTunnelKey,
// TODO: passkeyId
})),
);
}
// 3. Get channel configuration to determine initial status
const channelResult = await tx
.select({ requiresConfirmation: tenantSchema.channel.requiresConfirmation })
.from(tenantSchema.channel)
.where(
and(
eq(tenantSchema.channel.id, clientData.channelId),
eq(tenantSchema.channel.archived, false),
),
)
.limit(1);
if (channelResult.length === 0) {
throw new NotFoundError("Channel not found");
}
const initialStatus =
channelResult[0].requiresConfirmation && !staffCreated ? "NEW" : "CONFIRMED";
const requiresConfirmation = staffCreated
? false
: channelResult[0].requiresConfirmation || false;
// 4. Create encrypted appointment
const appointmentResult = await tx
.insert(tenantSchema.appointment)
.values({
tunnelId: clientData.tunnelId,
channelId: clientData.channelId,
agentId: clientData.agentId,
appointmentDate: new Date(clientData.appointmentDate),
timezone: clientData.appointmentTimeZone,
duration: clientData.duration,
encryptedPayload: clientData.encryptedAppointment.encryptedPayload,
iv: clientData.encryptedAppointment.iv,
authTag: clientData.encryptedAppointment.authTag,
status: initialStatus,
})
.returning({
id: tenantSchema.appointment.id,
appointmentDate: tenantSchema.appointment.appointmentDate,
timezone: tenantSchema.appointment.timezone,
status: tenantSchema.appointment.status,
tunnelId: tenantSchema.appointment.tunnelId,
channelId: tenantSchema.appointment.channelId,
agentId: tenantSchema.appointment.agentId,
encryptedPayload: tenantSchema.appointment.encryptedPayload,
iv: tenantSchema.appointment.iv,
authTag: tenantSchema.appointment.authTag,
createdAt: tenantSchema.appointment.createdAt,
updatedAt: tenantSchema.appointment.updatedAt,
});
if (initialStatus === "NEW") {
const notificationService = await NotificationService.forTenant(this.tenantId);
await notificationService.createNotification({
channelId: clientData.channelId,
type: "APPOINTMENT_REQUESTED",
metaData: {
appointmentId: appointmentResult[0].id,
},
});
}
if (appointmentResult.length === 0) {
throw new InternalError("Failed to create appointment");
}
return { appointment: appointmentResult[0], requiresConfirmation };
});
const response: AppointmentResponse = {
id: result.appointment.id,
appointmentDate: result.appointment.appointmentDate.toISOString(),
appointmentTimeZone: result.appointment.timezone,
status: result.appointment.status,
requiresConfirmation: result.requiresConfirmation,
};
log.debug("Successfully created new client appointment tunnel", {
tenantId: this.tenantId,
tunnelId: clientData.tunnelId,
appointmentId: result.appointment.id,
staffSharesCount: clientData.staffKeyShares.length,
});
// Send email notification to client (async, don't wait)
if (clientData.clientEmail) {
this.sendAppointmentNotification(
result.appointment.id,
clientData.channelId,
clientData.clientEmail,
clientData.clientLanguage || "de",
result.requiresConfirmation,
).catch((error) => {
log.error("Failed to send appointment notification", {
tunnelId: clientData.tunnelId,
appointmentId: result.appointment.id,
error: String(error),
});
});
}
return response;
}
/**
* Return appointments for a specific agent from a given date
* @param agentId
* @param from
* @returns
*/
public async getAppointmentsForAgent(agentId: string, from: Date) {
const log = logger.setContext("AppointmentService");
log.debug("Fetching appointments for agent", {
agentId,
startDate: from.toISOString(),
tenantId: this.tenantId,
});
const db = await this.getDb();
const result = await db
.select()
.from(tenantSchema.appointment)
.where(
and(
gte(tenantSchema.appointment.appointmentDate, from),
eq(tenantSchema.appointment.agentId, agentId),
),
)
.orderBy(asc(tenantSchema.appointment.appointmentDate));
return result;
}
/**
* Return appointments for a specific channel from a given date
* @param channelId
* @param from
* @returns
*/
public async getAppointmentsForChannel(channelId: string, from: Date) {
const log = logger.setContext("AppointmentService");
log.debug("Fetching appointments for channel", {
channelId,
startDate: from.toISOString(),
tenantId: this.tenantId,
});
const db = await this.getDb();
const result = await db
.select()
.from(tenantSchema.appointment)
.where(
and(
gte(tenantSchema.appointment.appointmentDate, from),
eq(tenantSchema.appointment.channelId, channelId),
),
)
.orderBy(asc(tenantSchema.appointment.appointmentDate));
return result;
}
public async getAppointmentsByTimeRange(
startDate: Date,
endDate: Date,
): Promise<SelectAppointment[]> {
const log = logger.setContext("AppointmentService");
log.debug("Fetching appointments by time range", {
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
tenantId: this.tenantId,
});
const db = await this.getDb();
const result = await db
.select()
.from(tenantSchema.appointment)
.where(
and(
gte(tenantSchema.appointment.appointmentDate, startDate),
lte(tenantSchema.appointment.appointmentDate, endDate),
),
)
.orderBy(asc(tenantSchema.appointment.appointmentDate));
log.debug("Appointments retrieved successfully", {
count: result.length,
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
tenantId: this.tenantId,
});
return result;
}
/**
* Get future appointments for a client by tunnel ID
*/
public async getFutureAppointmentsByTunnelId(tunnelId: string): Promise<
Array<{
id: string;
appointmentDate: string;
status: string;
channelId: string;
encryptedPayload: string;
iv: string;
authTag: string;
}>
> {
const log = logger.setContext("AppointmentService");
log.debug("Fetching future appointments for client", {
tenantId: this.tenantId,
tunnelId,
});
const db = await this.getDb();
// Get all future appointments for this tunnel
const now = new Date();
const appointments = await db
.select({
id: tenantSchema.appointment.id,
appointmentDate: tenantSchema.appointment.appointmentDate,
status: tenantSchema.appointment.status,
channelId: tenantSchema.appointment.channelId,
encryptedPayload: tenantSchema.appointment.encryptedPayload,
iv: tenantSchema.appointment.iv,
authTag: tenantSchema.appointment.authTag,
})
.from(tenantSchema.appointment)
.where(
and(
eq(tenantSchema.appointment.tunnelId, tunnelId),
gte(tenantSchema.appointment.appointmentDate, now),
),
)
.orderBy(asc(tenantSchema.appointment.appointmentDate));
log.debug("Future appointments retrieved", {
tenantId: this.tenantId,
tunnelId,
count: appointments.length,
});
return appointments.map((apt) => ({
id: apt.id,
appointmentDate: apt.appointmentDate.toISOString(),
status: apt.status,
channelId: apt.channelId,
encryptedPayload: apt.encryptedPayload || "",
iv: apt.iv || "",
authTag: apt.authTag || "",
}));
}
/**
* Delete appointment by client with challenge-response authentication
* This verifies that the client knows their PIN before allowing deletion
*/
public async deleteAppointmentByClient(
appointmentId: string,
emailHash: string,
challengeId: string,
challengeResponse: string,
): Promise<void> {
const log = logger.setContext("AppointmentService");
log.debug("Client deleting appointment with authentication", {
tenantId: this.tenantId,
appointmentId,
});
// 1. Verify challenge-response
const storedChallenge = await challengeStore.consume(challengeId, this.tenantId);
if (!storedChallenge) {
log.warn("Challenge not found or expired", {
tenantId: this.tenantId,
challengeId,
});
throw new NotFoundError("Challenge not found or expired");
}
// Verify that the challenge belongs to this email
if (storedChallenge.emailHash !== emailHash) {
log.warn("Challenge does not belong to this client", {
tenantId: this.tenantId,
challengeId,
emailHashPrefix: emailHash.slice(0, 8),
});
throw new ValidationError("Invalid authentication");
}
// Validate challenge response using constant-time comparison
const expectedChallenge = storedChallenge.challenge;
const challengeBuffer = Buffer.from(challengeResponse, "base64");
const expectedBuffer = Buffer.from(expectedChallenge, "base64");
if (
challengeBuffer.length !== expectedBuffer.length ||
!timingSafeEqual(challengeBuffer, expectedBuffer)
) {
log.warn("Challenge response mismatch", {
tenantId: this.tenantId,
challengeId,
emailHashPrefix: emailHash.slice(0, 8),
});
// Record failed attempt for throttling
await challengeThrottleService.recordFailedAttempt(emailHash, "pin");
throw new ValidationError("Invalid challenge response");
}
// Clear throttle on successful verification
await challengeThrottleService.clearThrottle(emailHash, "pin");
const db = await this.getDb();
// 2. Get appointment and verify it belongs to this client's tunnel
const appointmentResult = await db
.select({
id: tenantSchema.appointment.id,
tunnelId: tenantSchema.appointment.tunnelId,
appointmentDate: tenantSchema.appointment.appointmentDate,
channelId: tenantSchema.appointment.channelId,
})
.from(tenantSchema.appointment)
.where(eq(tenantSchema.appointment.id, appointmentId))
.limit(1);
if (appointmentResult.length === 0) {
log.warn("Appointment not found", { appointmentId, tenantId: this.tenantId });
throw new NotFoundError("Appointment not found");
}
const appointment = appointmentResult[0];
// 3. Verify the appointment belongs to this client's tunnel
const tunnelResult = await db
.select({
id: tenantSchema.clientAppointmentTunnel.id,
emailHash: tenantSchema.clientAppointmentTunnel.emailHash,
})
.from(tenantSchema.clientAppointmentTunnel)
.where(eq(tenantSchema.clientAppointmentTunnel.id, appointment.tunnelId))
.limit(1);
if (tunnelResult.length === 0) {
log.warn("Client tunnel not found", {
tunnelId: appointment.tunnelId,
tenantId: this.tenantId,
});
throw new NotFoundError("Client not found");
}
const tunnel = tunnelResult[0];
if (tunnel.emailHash !== emailHash) {
log.warn("Appointment does not belong to this client", {
appointmentId,
tenantId: this.tenantId,
emailHashPrefix: emailHash.slice(0, 8),
});
throw new ValidationError("Appointment does not belong to this client");
}
// 4. Delete the appointment
await db.delete(tenantSchema.appointment).where(eq(tenantSchema.appointment.id, appointmentId));
log.debug("Appointment deleted successfully by client", {
tenantId: this.tenantId,
appointmentId,
emailHashPrefix: emailHash.slice(0, 8),
});
const notificationService = await NotificationService.forTenant(this.tenantId);
await notificationService.createNotification({
channelId: appointment.channelId,
type: "APPOINTMENT_CANCELLED",
metaData: {
appointmentId,
},
});
log.debug("Staff notification sent for client-initiated deletion", {
tenantId: this.tenantId,
appointmentId,
channelId: appointment.channelId,
});
}
/**
* Get the tenant's database connection (cached)
*/
private async getDb() {
if (!this.#db) {
this.#db = await getTenantDb(this.tenantId);
}
return this.#db;
}
}