160 book appointment confirm appointment requests (#179)

* Extension of tenant schema

* Notification service

* Notification endpoints

* Format errors

* Update src/lib/server/db/tenant-schema.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/lib/server/services/notification-service.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Explicit where clause in delete notification

* Update tenant-migrations/0008_neat_swarm.sql

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Sort notifivcations by creation date. Do not explicitly check for existance in mark as read.

* Fixed tests

* Mail templates appointment cancellation

* Appointment cancellation notification texts

* Delete appointment by staff

* Deletion endpoint for staff

* My Appointments endpoint for clients

* Delete appointment by client

* Copilot review fixes

* Fixes after notification changes and updates.

* Fixed limiting errors

* Rejection email template

* Send out request, confirmed notifications, send out confirmed, denied emails.

* Fix test

* MIgration files

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Hendrik
2026-01-22 11:39:12 +01:00
committed by GitHub
co-authored by Copilot
parent e9bd83bcd2
commit a64afbd297
11 changed files with 1129 additions and 1 deletions
+5
View File
@@ -940,6 +940,11 @@
"introduction": "Ihr Termin wurde angefragt. Sie werden benachrichtigt, sobald der Termin bestätigt wurde.",
"reason": "Sie erhalten diese E-Mail, weil jemand mit Ihrer E-Mail Adresse einen Termin bei angefragt hat."
},
"appointmentRejected": {
"subject": "Ihre Anfrage für {channel} bei {tenant} wurde abgelehnt",
"introduction": "Ihr Termin wurde abgelehnt.",
"reason": "Sie erhalten diese E-Mail, weil jemand mit Ihrer E-Mail Adresse einen Termin bei angefragt hat."
},
"confirmation": {
"subject": "E-Mail Adresse bestätigen",
"introduction": "willkommen bei unserem Terminbuchungsportal. Bitte bestätige Deine E-Mail Adresse.",
+5
View File
@@ -944,6 +944,11 @@
"action": "Cancel appointment",
"reason": "You are receiving this email because someone booked an appointment with your e-mail address."
},
"appointmentRejected": {
"subject": "Your request for {channel} at {tenant} was rejected",
"introduction": "Your appointment was rejected.",
"reason": "You are receiving this email because someone requested an appointment with your e-mail address."
},
"appointmentRequest": {
"subject": "{channel} with {tenant} requested",
"introduction": "your appointment was requested. You will be notified once it is confirmed.",
+61
View File
@@ -0,0 +1,61 @@
<script lang="ts">
import { m } from "$i18n/messages";
import { setLocale } from "$i18n/runtime";
import type { SupportedLocale } from "$lib/const/locales";
import type { SelectTenant } from "$lib/server/db/central-schema";
import { type SelectAppointment } from "$lib/server/db/tenant-schema";
import type { SelectClient } from "$lib/server/email/email-service";
import EmailHeadline from "./components/EmailHeadline.svelte";
import EmailLayout from "./components/EmailLayout.svelte";
import EmailText from "./components/EmailText.svelte";
import { renderAppointmentDate, renderAppointmentTime } from "./utils";
let {
locale,
user,
tenant,
channel,
appointment,
address,
}: {
locale: SupportedLocale;
user: SelectClient;
tenant: SelectTenant;
channel: string;
appointment: SelectAppointment & { agentName: string };
address: {
street: string;
number: string;
additionalAddressInfo?: string;
zip: string;
city: string;
};
} = $props();
setLocale(locale);
</script>
<EmailLayout>
<EmailText variant="md">{m["emails.greeting"]({ name: user.email })}</EmailText>
<EmailText variant="md">
{m["emails.appointmentRejected.introduction"]()}
</EmailText>
<EmailHeadline>{channel}</EmailHeadline>
<EmailText variant="md">
{appointment.agentName}<br />
{renderAppointmentDate(appointment.appointmentDate, locale)}<br />
{renderAppointmentTime(appointment.appointmentDate, locale)}
{m["emails.oclock"]()}
</EmailText>
<EmailHeadline>{tenant.longName}</EmailHeadline>
<EmailText variant="md">
{address.street}
{address.number}<br />
{#if address.additionalAddressInfo}{address.additionalAddressInfo}<br />{/if}
{address.zip}
{address.city}
</EmailText>
<EmailText variant="md" color="text-light">
{m["emails.appointmentRejected.reason"]()}
</EmailText>
</EmailLayout>
+5 -1
View File
@@ -26,7 +26,11 @@ export const appointmentStatusEnum = pgEnum("appointment_status", [
"NO_SHOW",
]);
export const notificationTypes = ["APPOINTMENT_CONFIRMED", "APPOINTMENT_CANCELLED"] as const;
export const notificationTypes = [
"APPOINTMENT_CONFIRMED",
"APPOINTMENT_CANCELLED",
"APPOINTMENT_REQUESTED",
] as const;
export type NotificationType = (typeof notificationTypes)[number];
export const notificationTypeEnum = pgEnum("notification_type", notificationTypes);
+45
View File
@@ -14,6 +14,7 @@ import { setLocale } from "$i18n/runtime";
import { m } from "$i18n/messages";
import { render } from "svelte/server";
import AppointmentBooked from "$lib/emails/AppointmentBooked.svelte";
import AppointmentRejected from "$lib/emails/AppointmentRejected.svelte";
import { htmlToText, renderOutputToHtml } from "$lib/emails/utils";
import { AgentService } from "../services/agent-service";
import { TenantService } from "../db/tenant-service";
@@ -232,6 +233,50 @@ const getAddressFromTenant = async (tenantId: string) => {
};
};
/**
* Send appointment rejection email for newly created appointments
* @param {SelectClient | SelectUser} user - Database user object or client data
* @param {SelectTenant} tenant - Tenant information for branding
* @param {SelectAppointment} appointment - Appointment details
* @param {string} [channelTitle] - Optional channel title/name
* @param {string} [cancelUrl] - Optional URL to cancel appointment
* @throws {Error} When email sending fails
* @returns {Promise<void>}
*/
export async function sendAppointmentRejectedEmail(
user: SelectClient | SelectUser,
tenant: SelectTenant,
appointment: SelectAppointment,
channelTitle?: string,
): Promise<void> {
// Create recipient directly for SelectClient type, use helper for SelectUser
// Set language
const agentService = await AgentService.forTenant(tenant.id);
const agent = await agentService.getAgentById(appointment.agentId);
const { recipient, locale } = await getRecipient(user);
// Generate email
const subject = m["emails.appointmentRejected.subject"]({
channel: channelTitle || appointment.channelId,
tenant: tenant.longName,
});
const emailRender = render(AppointmentRejected, {
props: {
locale,
channel: channelTitle || appointment.channelId,
user,
tenant,
appointment: { ...appointment, agentName: agent?.name ?? "---" },
address: await getAddressFromTenant(tenant.id),
},
});
const html = renderOutputToHtml(emailRender);
const text = htmlToText(html);
await sendEmail(recipient, subject, html, text);
}
/**
* Send appointment confirmation email for newly created appointments
* @param {SelectClient | SelectUser} user - Database user object or client data
@@ -25,6 +25,16 @@ vi.mock("../challenge-throttle", () => ({
},
}));
vi.mock("../notification-service", () => ({
NotificationService: {
forTenant: vi.fn().mockResolvedValue({
sendAppointmentConfirmationEmail: vi.fn().mockResolvedValue(undefined),
sendAppointmentCancellationEmail: vi.fn().mockResolvedValue(undefined),
createNotification: vi.fn().mockResolvedValue(undefined),
}),
},
}));
const mockAppointment = {
id: "appointment-123",
tunnelId: "tunnel-123",
@@ -11,6 +11,7 @@ import {
sendAppointmentRequestEmail,
sendAppointmentCancelledEmail,
getChannelTitle,
sendAppointmentRejectedEmail,
} from "../email/email-service";
import { TenantAdminService } from "./tenant-admin-service";
import { NotificationService } from "./notification-service";
@@ -127,6 +128,8 @@ export class AppointmentService {
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", {
@@ -206,9 +209,77 @@ export class AppointmentService {
});
}
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) {
this.sendAppointmentNotification(
row.id,
row.channelId,
clientEmail,
clientLanguage || "de",
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("Confirming appointment by ID", { appointmentId: id, tenantId: this.tenantId });
@@ -522,6 +593,17 @@ export class AppointmentService {
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");
}
@@ -19,6 +19,7 @@ import {
getChannelTitle,
} from "$lib/server/email/email-service";
import { TenantAdminService } from "$lib/server/services/tenant-admin-service";
import { NotificationService } from "$lib/server/services/notification-service";
const requestSchema = z.object({
emailHash: z.string(),
@@ -316,6 +317,15 @@ export const POST: RequestHandler = async ({ request, params }) => {
);
// Send appropriate email based on whether confirmation is required
if (requiresConfirmation) {
// send notification to tenant staff about new appointment request
const notificationService = NotificationService.forTenant(tenantId);
(await notificationService).createNotification({
type: "APPOINTMENT_REQUESTED",
channelId: validatedData.channelId,
metaData: { appointmentId: result.id },
});
}
if (validatedData.clientEmail) {
// Create client data object from request
const clientData = {
@@ -0,0 +1 @@
ALTER TYPE "public"."notification_type" ADD VALUE 'APPOINTMENT_REQUESTED';
+898
View File
@@ -0,0 +1,898 @@
{
"id": "a189259c-e77c-4561-8a6c-c2f2fe5c9eb4",
"prevId": "b141a31e-20fd-4247-bb21-e403af1809bc",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.agent": {
"name": "agent",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"descriptions": {
"name": "descriptions",
"type": "json",
"primaryKey": false,
"notNull": true
},
"image": {
"name": "image",
"type": "varchar(250000)",
"primaryKey": false,
"notNull": false
},
"archived": {
"name": "archived",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.agent_absence": {
"name": "agent_absence",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"agent_id": {
"name": "agent_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"start_date": {
"name": "start_date",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"end_date": {
"name": "end_date",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"absence_type": {
"name": "absence_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "''"
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"agent_absence_agent_id_agent_id_fk": {
"name": "agent_absence_agent_id_agent_id_fk",
"tableFrom": "agent_absence",
"tableTo": "agent",
"columnsFrom": ["agent_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.appointment": {
"name": "appointment",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"tunnel_id": {
"name": "tunnel_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"channel_id": {
"name": "channel_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"agent_id": {
"name": "agent_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"appointment_date": {
"name": "appointment_date",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"duration": {
"name": "duration",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"expiry_date": {
"name": "expiry_date",
"type": "date",
"primaryKey": false,
"notNull": false
},
"status": {
"name": "status",
"type": "appointment_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"encrypted_data": {
"name": "encrypted_data",
"type": "text",
"primaryKey": false,
"notNull": false
},
"data_key": {
"name": "data_key",
"type": "text",
"primaryKey": false,
"notNull": false
},
"encrypted_payload": {
"name": "encrypted_payload",
"type": "text",
"primaryKey": false,
"notNull": false
},
"iv": {
"name": "iv",
"type": "text",
"primaryKey": false,
"notNull": false
},
"auth_tag": {
"name": "auth_tag",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"appointment_tunnel_id_client_appointment_tunnel_id_fk": {
"name": "appointment_tunnel_id_client_appointment_tunnel_id_fk",
"tableFrom": "appointment",
"tableTo": "client_appointment_tunnel",
"columnsFrom": ["tunnel_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
},
"appointment_channel_id_channel_id_fk": {
"name": "appointment_channel_id_channel_id_fk",
"tableFrom": "appointment",
"tableTo": "channel",
"columnsFrom": ["channel_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
},
"appointment_agent_id_agent_id_fk": {
"name": "appointment_agent_id_agent_id_fk",
"tableFrom": "appointment",
"tableTo": "agent",
"columnsFrom": ["agent_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.appointment_key_share": {
"name": "appointment_key_share",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"appointment_id": {
"name": "appointment_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"encrypted_key": {
"name": "encrypted_key",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"appointment_key_share_appointment_id_appointment_id_fk": {
"name": "appointment_key_share_appointment_id_appointment_id_fk",
"tableFrom": "appointment_key_share",
"tableTo": "appointment",
"columnsFrom": ["appointment_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.auth_challenge": {
"name": "auth_challenge",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"challenge": {
"name": "challenge",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_hash": {
"name": "email_hash",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"consumed": {
"name": "consumed",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.channel": {
"name": "channel",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"names": {
"name": "names",
"type": "json",
"primaryKey": false,
"notNull": true
},
"color": {
"name": "color",
"type": "text",
"primaryKey": false,
"notNull": false
},
"paused": {
"name": "paused",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"descriptions": {
"name": "descriptions",
"type": "json",
"primaryKey": false,
"notNull": true
},
"is_public": {
"name": "is_public",
"type": "boolean",
"primaryKey": false,
"notNull": false
},
"requires_confirmation": {
"name": "requires_confirmation",
"type": "boolean",
"primaryKey": false,
"notNull": false
},
"archived": {
"name": "archived",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.channel_agent": {
"name": "channel_agent",
"schema": "",
"columns": {
"channel_id": {
"name": "channel_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"agent_id": {
"name": "agent_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"channel_agent_channel_id_channel_id_fk": {
"name": "channel_agent_channel_id_channel_id_fk",
"tableFrom": "channel_agent",
"tableTo": "channel",
"columnsFrom": ["channel_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
},
"channel_agent_agent_id_agent_id_fk": {
"name": "channel_agent_agent_id_agent_id_fk",
"tableFrom": "channel_agent",
"tableTo": "agent",
"columnsFrom": ["agent_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.channel_slot_template": {
"name": "channel_slot_template",
"schema": "",
"columns": {
"channel_id": {
"name": "channel_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"slot_template_id": {
"name": "slot_template_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"channel_slot_template_channel_id_channel_id_fk": {
"name": "channel_slot_template_channel_id_channel_id_fk",
"tableFrom": "channel_slot_template",
"tableTo": "channel",
"columnsFrom": ["channel_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
},
"channel_slot_template_slot_template_id_slotTemplate_id_fk": {
"name": "channel_slot_template_slot_template_id_slotTemplate_id_fk",
"tableFrom": "channel_slot_template",
"tableTo": "slotTemplate",
"columnsFrom": ["slot_template_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.channel_staff": {
"name": "channel_staff",
"schema": "",
"columns": {
"channel_id": {
"name": "channel_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"staff_id": {
"name": "staff_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"channel_staff_channel_id_channel_id_fk": {
"name": "channel_staff_channel_id_channel_id_fk",
"tableFrom": "channel_staff",
"tableTo": "channel",
"columnsFrom": ["channel_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.client_appointment_tunnel": {
"name": "client_appointment_tunnel",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"email_hash": {
"name": "email_hash",
"type": "text",
"primaryKey": false,
"notNull": true
},
"client_public_key": {
"name": "client_public_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"private_key_share": {
"name": "private_key_share",
"type": "text",
"primaryKey": false,
"notNull": true
},
"client_key_share": {
"name": "client_key_share",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"client_appointment_tunnel_email_hash_unique": {
"name": "client_appointment_tunnel_email_hash_unique",
"nullsNotDistinct": false,
"columns": ["email_hash"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.client_pin_reset_token": {
"name": "client_pin_reset_token",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"token": {
"name": "token",
"type": "uuid",
"primaryKey": false,
"notNull": true,
"default": "gen_random_uuid()"
},
"email_hash": {
"name": "email_hash",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"used": {
"name": "used",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"client_pin_reset_token_token_unique": {
"name": "client_pin_reset_token_token_unique",
"nullsNotDistinct": false,
"columns": ["token"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.client_tunnel_staff_key_share": {
"name": "client_tunnel_staff_key_share",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"tunnel_id": {
"name": "tunnel_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"encrypted_tunnel_key": {
"name": "encrypted_tunnel_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"client_tunnel_staff_key_share_tunnel_id_client_appointment_tunnel_id_fk": {
"name": "client_tunnel_staff_key_share_tunnel_id_client_appointment_tunnel_id_fk",
"tableFrom": "client_tunnel_staff_key_share",
"tableTo": "client_appointment_tunnel",
"columnsFrom": ["tunnel_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.notification": {
"name": "notification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"staff_id": {
"name": "staff_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "notification_type",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'APPOINTMENT_CONFIRMED'"
},
"meta_data": {
"name": "meta_data",
"type": "json",
"primaryKey": false,
"notNull": false
},
"is_read": {
"name": "is_read",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.slotTemplate": {
"name": "slotTemplate",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"weekdays": {
"name": "weekdays",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"from": {
"name": "from",
"type": "time",
"primaryKey": false,
"notNull": true
},
"to": {
"name": "to",
"type": "time",
"primaryKey": false,
"notNull": true
},
"duration": {
"name": "duration",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.staff_crypto": {
"name": "staff_crypto",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"public_key": {
"name": "public_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"private_key_share": {
"name": "private_key_share",
"type": "text",
"primaryKey": false,
"notNull": true
},
"passkey_id": {
"name": "passkey_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"is_active": {
"name": "is_active",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"public.appointment_status": {
"name": "appointment_status",
"schema": "public",
"values": ["NEW", "CONFIRMED", "HELD", "REJECTED", "NO_SHOW"]
},
"public.notification_type": {
"name": "notification_type",
"schema": "public",
"values": ["APPOINTMENT_CONFIRMED", "APPOINTMENT_CANCELLED", "APPOINTMENT_REQUESTED"]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+7
View File
@@ -85,6 +85,13 @@
"when": 1768926072442,
"tag": "0011_kind_starbolt",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1769078158060,
"tag": "0012_military_puppet_master",
"breakpoints": true
}
]
}