Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c2e13863e | |||
| 5024cc4896 |
@@ -23,7 +23,6 @@
|
|||||||
"node-cron": "^3.0.3",
|
"node-cron": "^3.0.3",
|
||||||
"nodemailer": "^6.9.16",
|
"nodemailer": "^6.9.16",
|
||||||
"stripe": "^22.0.0",
|
"stripe": "^22.0.0",
|
||||||
"telnyx": "^1.23.0",
|
|
||||||
|
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -195,11 +195,10 @@ describe("POST /clients", () => {
|
|||||||
expect(insertedValues[0]!.name).toBe("Charlie");
|
expect(insertedValues[0]!.name).toBe("Charlie");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("creates a client with name and email", async () => {
|
it("creates a client with only required name field", async () => {
|
||||||
const res = await jsonRequest("POST", "/clients", { name: "Dana", email: "dana@example.com" });
|
const res = await jsonRequest("POST", "/clients", { name: "Dana" });
|
||||||
expect(res.status).toBe(201);
|
expect(res.status).toBe(201);
|
||||||
expect(insertedValues[0]!.name).toBe("Dana");
|
expect(insertedValues[0]!.name).toBe("Dana");
|
||||||
expect(insertedValues[0]!.email).toBe("dana@example.com");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects empty name", async () => {
|
it("rejects empty name", async () => {
|
||||||
|
|||||||
@@ -204,11 +204,15 @@ export async function initAuth(): Promise<void> {
|
|||||||
const userInfoUrl = discovery.userinfo_endpoint;
|
const userInfoUrl = discovery.userinfo_endpoint;
|
||||||
if (authzUrl && tokenUrl && userInfoUrl) {
|
if (authzUrl && tokenUrl && userInfoUrl) {
|
||||||
const authzUrlObj = new URL(authzUrl);
|
const authzUrlObj = new URL(authzUrl);
|
||||||
// Only validate authorizationUrl hostname against issuer — token/userinfo
|
const tokenUrlObj = new URL(tokenUrl);
|
||||||
// may legitimately use internal hostnames (OIDC_INTERNAL_BASE) for server-to-server calls.
|
const userInfoUrlObj = new URL(userInfoUrl);
|
||||||
if (authzUrlObj.hostname !== issuerHostname) {
|
if (
|
||||||
|
authzUrlObj.hostname !== issuerHostname ||
|
||||||
|
tokenUrlObj.hostname !== issuerHostname ||
|
||||||
|
userInfoUrlObj.hostname !== issuerHostname
|
||||||
|
) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`[FATAL] OIDC discovery URL hostname mismatch: expected '${issuerHostname}' but got '${authzUrlObj.hostname}'. This may indicate a man-in-the-middle attack.`
|
`[FATAL] OIDC discovery URL hostname mismatch: expected '${issuerHostname}' but got '${authzUrlObj.hostname}', '${tokenUrlObj.hostname}', or '${userInfoUrlObj.hostname}'. This may indicate a man-in-the-middle attack.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
oidcConfig = {
|
oidcConfig = {
|
||||||
|
|||||||
@@ -338,35 +338,44 @@ async function sendConfirmationEmail(
|
|||||||
db: ReturnType<typeof getDb>,
|
db: ReturnType<typeof getDb>,
|
||||||
appt: typeof appointments.$inferSelect
|
appt: typeof appointments.$inferSelect
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const [row] = await db
|
const [client] = await db
|
||||||
.select({
|
.select({ name: clients.name, email: clients.email, emailOptOut: clients.emailOptOut })
|
||||||
clientName: clients.name,
|
.from(clients)
|
||||||
clientEmail: clients.email,
|
.where(eq(clients.id, appt.clientId))
|
||||||
clientEmailOptOut: clients.emailOptOut,
|
|
||||||
petName: pets.name,
|
|
||||||
serviceName: services.name,
|
|
||||||
groomerName: staff.name,
|
|
||||||
})
|
|
||||||
.from(appointments)
|
|
||||||
.innerJoin(clients, eq(clients.id, appointments.clientId))
|
|
||||||
.innerJoin(pets, eq(pets.id, appointments.petId))
|
|
||||||
.innerJoin(services, eq(services.id, appointments.serviceId))
|
|
||||||
.leftJoin(staff, eq(staff.id, appointments.staffId))
|
|
||||||
.where(eq(appointments.id, appt.id))
|
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!row) return;
|
if (!client || !client.email || client.emailOptOut) return;
|
||||||
const { clientName, clientEmail, clientEmailOptOut, petName, serviceName, groomerName } = row;
|
|
||||||
|
|
||||||
if (!clientEmail || clientEmailOptOut) return;
|
const [pet] = await db
|
||||||
if (!petName || !serviceName) return;
|
.select({ name: pets.name })
|
||||||
|
.from(pets)
|
||||||
|
.where(eq(pets.id, appt.petId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const [service] = await db
|
||||||
|
.select({ name: services.name })
|
||||||
|
.from(services)
|
||||||
|
.where(eq(services.id, appt.serviceId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
let groomerName: string | null = null;
|
||||||
|
if (appt.staffId) {
|
||||||
|
const [groomer] = await db
|
||||||
|
.select({ name: staff.name })
|
||||||
|
.from(staff)
|
||||||
|
.where(eq(staff.id, appt.staffId))
|
||||||
|
.limit(1);
|
||||||
|
groomerName = groomer?.name ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pet || !service) return;
|
||||||
|
|
||||||
const sent = await sendEmail(
|
const sent = await sendEmail(
|
||||||
buildConfirmationEmail(clientEmail, {
|
buildConfirmationEmail(client.email, {
|
||||||
clientName,
|
clientName: client.name,
|
||||||
petName,
|
petName: pet.name,
|
||||||
serviceName,
|
serviceName: service.name,
|
||||||
groomerName: groomerName ?? null,
|
groomerName,
|
||||||
startTime: appt.startTime,
|
startTime: appt.startTime,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,12 +8,10 @@ export const clientsRouter = new Hono<AppEnv>();
|
|||||||
|
|
||||||
const createClientSchema = z.object({
|
const createClientSchema = z.object({
|
||||||
name: z.string().min(1).max(200),
|
name: z.string().min(1).max(200),
|
||||||
email: z.string().email(),
|
email: z.string().email().optional(),
|
||||||
phone: z.string().max(50).optional(),
|
phone: z.string().max(50).optional(),
|
||||||
address: z.string().max(500).optional(),
|
address: z.string().max(500).optional(),
|
||||||
notes: z.string().max(2000).optional(),
|
notes: z.string().max(2000).optional(),
|
||||||
smsOptIn: z.boolean().optional(),
|
|
||||||
smsConsentText: z.string().max(1000).optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
@@ -97,7 +95,6 @@ clientsRouter.post("/", zValidator("json", createClientSchema), async (c) => {
|
|||||||
// Update a client (including status changes)
|
// Update a client (including status changes)
|
||||||
const patchClientSchema = createClientSchema.partial().extend({
|
const patchClientSchema = createClientSchema.partial().extend({
|
||||||
status: z.enum(["active", "disabled"]).optional(),
|
status: z.enum(["active", "disabled"]).optional(),
|
||||||
smsOptOut: z.boolean().optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
clientsRouter.patch(
|
clientsRouter.patch(
|
||||||
@@ -110,19 +107,13 @@ clientsRouter.patch(
|
|||||||
|
|
||||||
const setValues: Record<string, unknown> = { ...body, updatedAt: now };
|
const setValues: Record<string, unknown> = { ...body, updatedAt: now };
|
||||||
|
|
||||||
|
// When disabling, set disabledAt; when re-enabling, clear it
|
||||||
if (body.status === "disabled") {
|
if (body.status === "disabled") {
|
||||||
setValues.disabledAt = now;
|
setValues.disabledAt = now;
|
||||||
} else if (body.status === "active") {
|
} else if (body.status === "active") {
|
||||||
setValues.disabledAt = null;
|
setValues.disabledAt = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (body.smsOptOut === true) {
|
|
||||||
setValues.smsOptIn = false;
|
|
||||||
setValues.smsOptOutDate = now;
|
|
||||||
delete setValues.smsOptOut;
|
|
||||||
}
|
|
||||||
delete setValues.smsOptOut;
|
|
||||||
|
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.update(clients)
|
.update(clients)
|
||||||
.set(setValues)
|
.set(setValues)
|
||||||
|
|||||||
@@ -18,10 +18,9 @@ import {
|
|||||||
buildReminderEmail,
|
buildReminderEmail,
|
||||||
sendEmail,
|
sendEmail,
|
||||||
} from "./email.js";
|
} from "./email.js";
|
||||||
import { smsSend } from "./sms.js";
|
|
||||||
|
|
||||||
const TCPA_OPT_OUT = "Reply STOP to opt out. Msg & data rates may apply.";
|
|
||||||
|
|
||||||
|
// How many hours before the appointment to send each reminder.
|
||||||
|
// Override via env: REMINDER_HOURS_EARLY (default 24) and REMINDER_HOURS_LATE (default 2).
|
||||||
function getReminderWindows(): { label: string; hours: number }[] {
|
function getReminderWindows(): { label: string; hours: number }[] {
|
||||||
const early = Number(process.env.REMINDER_HOURS_EARLY ?? 24);
|
const early = Number(process.env.REMINDER_HOURS_EARLY ?? 24);
|
||||||
const late = Number(process.env.REMINDER_HOURS_LATE ?? 2);
|
const late = Number(process.env.REMINDER_HOURS_LATE ?? 2);
|
||||||
@@ -31,14 +30,20 @@ function getReminderWindows(): { label: string; hours: number }[] {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Checks for upcoming appointments that need reminders and sends them.
|
||||||
|
// Runs every minute — idempotent via reminder_logs unique constraint.
|
||||||
export async function runReminderCheck(): Promise<void> {
|
export async function runReminderCheck(): Promise<void> {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
for (const window of getReminderWindows()) {
|
for (const window of getReminderWindows()) {
|
||||||
|
// Target window: appointments starting between (hours - 1) and hours from now.
|
||||||
|
// Running every minute means we check a 1-minute slice; the 1-hour window
|
||||||
|
// ensures we catch appointments that started between heartbeats.
|
||||||
const windowStart = new Date(now.getTime() + (window.hours - 1) * 3600_000);
|
const windowStart = new Date(now.getTime() + (window.hours - 1) * 3600_000);
|
||||||
const windowEnd = new Date(now.getTime() + window.hours * 3600_000);
|
const windowEnd = new Date(now.getTime() + window.hours * 3600_000);
|
||||||
|
|
||||||
|
// Find upcoming appointments in this time window that haven't been cancelled/completed
|
||||||
const upcoming = await db
|
const upcoming = await db
|
||||||
.select({
|
.select({
|
||||||
id: appointments.id,
|
id: appointments.id,
|
||||||
@@ -60,38 +65,23 @@ export async function runReminderCheck(): Promise<void> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
for (const appt of upcoming) {
|
for (const appt of upcoming) {
|
||||||
const [emailLog] = await db
|
// Check if reminder already sent (unique constraint prevents double-send)
|
||||||
|
const existing = await db
|
||||||
.select({ id: reminderLogs.id })
|
.select({ id: reminderLogs.id })
|
||||||
.from(reminderLogs)
|
.from(reminderLogs)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(reminderLogs.appointmentId, appt.id),
|
eq(reminderLogs.appointmentId, appt.id),
|
||||||
eq(reminderLogs.reminderType, window.label),
|
eq(reminderLogs.reminderType, window.label)
|
||||||
eq(reminderLogs.channel, "email")
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
const [smsLog] = await db
|
if (existing.length > 0) continue; // already sent
|
||||||
.select({ id: reminderLogs.id })
|
|
||||||
.from(reminderLogs)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(reminderLogs.appointmentId, appt.id),
|
|
||||||
eq(reminderLogs.reminderType, window.label),
|
|
||||||
eq(reminderLogs.channel, "sms")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
|
// Fetch related records for the email
|
||||||
const [client] = await db
|
const [client] = await db
|
||||||
.select({
|
.select({ name: clients.name, email: clients.email, emailOptOut: clients.emailOptOut })
|
||||||
name: clients.name,
|
|
||||||
email: clients.email,
|
|
||||||
emailOptOut: clients.emailOptOut,
|
|
||||||
smsOptIn: clients.smsOptIn,
|
|
||||||
phone: clients.phone,
|
|
||||||
})
|
|
||||||
.from(clients)
|
.from(clients)
|
||||||
.where(eq(clients.id, appt.clientId))
|
.where(eq(clients.id, appt.clientId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
@@ -122,6 +112,8 @@ export async function runReminderCheck(): Promise<void> {
|
|||||||
|
|
||||||
if (!pet || !service) continue;
|
if (!pet || !service) continue;
|
||||||
|
|
||||||
|
// Ensure the appointment has a confirmation token before sending the reminder.
|
||||||
|
// Generate one if it doesn't have one yet (e.g. pre-existing appointments).
|
||||||
let confirmationToken = appt.confirmationToken;
|
let confirmationToken = appt.confirmationToken;
|
||||||
if (!confirmationToken) {
|
if (!confirmationToken) {
|
||||||
confirmationToken = randomBytes(32).toString("hex");
|
confirmationToken = randomBytes(32).toString("hex");
|
||||||
@@ -131,59 +123,35 @@ export async function runReminderCheck(): Promise<void> {
|
|||||||
.where(eq(appointments.id, appt.id));
|
.where(eq(appointments.id, appt.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!emailLog) {
|
const sent = await sendEmail(
|
||||||
const sent = await sendEmail(
|
buildReminderEmail(
|
||||||
buildReminderEmail(
|
client.email,
|
||||||
client.email,
|
{
|
||||||
{
|
clientName: client.name,
|
||||||
clientName: client.name,
|
petName: pet.name,
|
||||||
petName: pet.name,
|
serviceName: service.name,
|
||||||
serviceName: service.name,
|
groomerName,
|
||||||
groomerName,
|
startTime: appt.startTime,
|
||||||
startTime: appt.startTime,
|
},
|
||||||
},
|
window.hours,
|
||||||
window.hours,
|
confirmationToken
|
||||||
confirmationToken
|
)
|
||||||
)
|
);
|
||||||
);
|
|
||||||
|
|
||||||
if (sent) {
|
if (sent) {
|
||||||
await db
|
// Record send — ignore conflicts (race condition between instances)
|
||||||
.insert(reminderLogs)
|
await db
|
||||||
.values({ appointmentId: appt.id, reminderType: window.label, channel: "email" })
|
.insert(reminderLogs)
|
||||||
.onConflictDoNothing();
|
.values({ appointmentId: appt.id, reminderType: window.label })
|
||||||
}
|
.onConflictDoNothing();
|
||||||
}
|
|
||||||
|
|
||||||
if (!smsLog && client.smsOptIn && client.phone) {
|
|
||||||
const apiUrl = process.env.API_URL ?? "http://localhost:3000";
|
|
||||||
const confirmUrl = `${apiUrl}/api/book/confirm/${confirmationToken}`;
|
|
||||||
const cancelUrl = `${apiUrl}/api/book/cancel/${confirmationToken}`;
|
|
||||||
const when = window.hours >= 24 ? "tomorrow" : `in ${window.hours} hours`;
|
|
||||||
const smsBody = [
|
|
||||||
`Hi ${client.name}, just a reminder: ${pet.name}'s grooming appointment is ${when}.`,
|
|
||||||
`Service: ${service.name}${groomerName ? ` with ${groomerName}` : ""}`,
|
|
||||||
`Confirm: ${confirmUrl}`,
|
|
||||||
`Cancel: ${cancelUrl}`,
|
|
||||||
TCPA_OPT_OUT,
|
|
||||||
].join(". ");
|
|
||||||
try {
|
|
||||||
const smsOk = await smsSend(client.phone, smsBody);
|
|
||||||
if (smsOk) {
|
|
||||||
await db
|
|
||||||
.insert(reminderLogs)
|
|
||||||
.values({ appointmentId: appt.id, reminderType: window.label, channel: "sms" })
|
|
||||||
.onConflictDoNothing();
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[reminders] SMS send failed:", err);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Starts the cron scheduler. Call once at server startup.
|
||||||
export function startReminderScheduler(): void {
|
export function startReminderScheduler(): void {
|
||||||
|
// Run every minute
|
||||||
cron.schedule("* * * * *", () => {
|
cron.schedule("* * * * *", () => {
|
||||||
runReminderCheck().catch((err) => {
|
runReminderCheck().catch((err) => {
|
||||||
console.error("[reminders] Error during reminder check:", err);
|
console.error("[reminders] Error during reminder check:", err);
|
||||||
@@ -195,6 +163,8 @@ export function startReminderScheduler(): void {
|
|||||||
console.log("[reminders] Reminder scheduler started");
|
console.log("[reminders] Reminder scheduler started");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deletes expired sessions from the database.
|
||||||
|
// Runs every minute alongside reminder checks.
|
||||||
export async function runSessionCleanup(): Promise<void> {
|
export async function runSessionCleanup(): Promise<void> {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|||||||
@@ -1,142 +0,0 @@
|
|||||||
import { Telnyx } from "telnyx";
|
|
||||||
import { createHmac } from "crypto";
|
|
||||||
|
|
||||||
export interface SmsProvider {
|
|
||||||
sendSms(to: string, body: string, mediaUrls?: string[]): Promise<{ messageId: string; status: string }>;
|
|
||||||
validateWebhookSignature(req: Request): boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TelnyxSmsResult {
|
|
||||||
message_id: string;
|
|
||||||
status: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createTelnyxClient(): Telnyx | null {
|
|
||||||
const apiKey = process.env.TELNYX_API_KEY;
|
|
||||||
if (!apiKey) return null;
|
|
||||||
return new Telnyx(apiKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
let _client: Telnyx | null | undefined;
|
|
||||||
|
|
||||||
function getClient(): Telnyx | null {
|
|
||||||
if (_client === undefined) _client = createTelnyxClient();
|
|
||||||
return _client;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFromNumber(): string | null {
|
|
||||||
return process.env.TELNYX_FROM_NUMBER ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isE164(phone: string): boolean {
|
|
||||||
return /^\+[1-9]\d{7,14}$/.test(phone);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function sendSms(
|
|
||||||
to: string,
|
|
||||||
body: string,
|
|
||||||
mediaUrls?: string[]
|
|
||||||
): Promise<{ messageId: string; status: string }> {
|
|
||||||
const client = getClient();
|
|
||||||
if (!client) throw new Error("Telnyx client not initialized. Set TELNYX_API_KEY.");
|
|
||||||
|
|
||||||
const from = getFromNumber();
|
|
||||||
if (!from) throw new Error("TELNYX_FROM_NUMBER is not set");
|
|
||||||
|
|
||||||
if (!isE164(to)) throw new Error(`Invalid recipient phone format: ${to}. Expected E.164.`);
|
|
||||||
if (!isE164(from)) throw new Error(`Invalid sender phone format: ${from}. Expected E.164.`);
|
|
||||||
|
|
||||||
const payload: Record<string, unknown> = {
|
|
||||||
from,
|
|
||||||
to,
|
|
||||||
body,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (mediaUrls && mediaUrls.length > 0) {
|
|
||||||
payload.media_urls = mediaUrls;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await client.messages.create(payload as Record<string, string | string[]>);
|
|
||||||
const smsResult = result.data as unknown as TelnyxSmsResult;
|
|
||||||
return {
|
|
||||||
messageId: smsResult.message_id,
|
|
||||||
status: smsResult.status,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export class TelnyxProvider implements SmsProvider {
|
|
||||||
async sendSms(
|
|
||||||
to: string,
|
|
||||||
body: string,
|
|
||||||
mediaUrls?: string[]
|
|
||||||
): Promise<{ messageId: string; status: string }> {
|
|
||||||
return sendSms(to, body, mediaUrls);
|
|
||||||
}
|
|
||||||
|
|
||||||
validateWebhookSignature(req: Request): boolean {
|
|
||||||
const secret = process.env.TELNYX_WEBHOOK_SECRET;
|
|
||||||
if (!secret) return false;
|
|
||||||
|
|
||||||
const signature = req.headers.get("telnyx-signature");
|
|
||||||
if (!signature) return false;
|
|
||||||
|
|
||||||
const payload = JSON.stringify(req.body);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const hmac = createHmac("sha256", secret);
|
|
||||||
const expected = `sha256=${hmac.update(payload).digest("hex")}`;
|
|
||||||
|
|
||||||
const sigBuf = Buffer.from(signature);
|
|
||||||
const expBuf = Buffer.from(expected);
|
|
||||||
|
|
||||||
if (sigBuf.length !== expBuf.length) return false;
|
|
||||||
|
|
||||||
let diff = 0;
|
|
||||||
for (let i = 0; i < sigBuf.length; i++) {
|
|
||||||
const sigByte = sigBuf[i] ?? 0;
|
|
||||||
const expByte = expBuf[i] ?? 0;
|
|
||||||
diff |= sigByte ^ expByte;
|
|
||||||
}
|
|
||||||
return diff === 0;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let _provider: SmsProvider | null | undefined;
|
|
||||||
|
|
||||||
export function createSmsProvider(): SmsProvider | null {
|
|
||||||
if (_provider === undefined) {
|
|
||||||
if (process.env.SMS_ENABLED !== "true") {
|
|
||||||
_provider = null;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
switch (process.env.SMS_PROVIDER) {
|
|
||||||
case "telnyx": {
|
|
||||||
const client = getClient();
|
|
||||||
if (!client) {
|
|
||||||
_provider = null;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
_provider = new TelnyxProvider();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
_provider = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return _provider;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function smsSend(
|
|
||||||
to: string,
|
|
||||||
body: string,
|
|
||||||
mediaUrls?: string[]
|
|
||||||
): Promise<boolean> {
|
|
||||||
const provider = createSmsProvider();
|
|
||||||
if (!provider) return false;
|
|
||||||
|
|
||||||
await provider.sendSms(to, body, mediaUrls);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
Vendored
-19
@@ -1,19 +0,0 @@
|
|||||||
declare module "telnyx" {
|
|
||||||
export interface MessageResult {
|
|
||||||
data: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MessagesCreateParams {
|
|
||||||
from: string;
|
|
||||||
to: string;
|
|
||||||
body: string;
|
|
||||||
media_urls?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Telnyx {
|
|
||||||
constructor(apiKey: string);
|
|
||||||
messages: {
|
|
||||||
create(params: Record<string, string | string[]>): Promise<MessageResult>;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
-- SMS opt-in fields for clients (idempotent)
|
|
||||||
ALTER TABLE "clients" ADD COLUMN IF NOT EXISTS "sms_opt_in" boolean NOT NULL DEFAULT false;
|
|
||||||
ALTER TABLE "clients" ADD COLUMN IF NOT EXISTS "sms_consent_date" timestamp;
|
|
||||||
ALTER TABLE "clients" ADD COLUMN IF NOT EXISTS "sms_opt_out_date" timestamp;
|
|
||||||
ALTER TABLE "clients" ADD COLUMN IF NOT EXISTS "sms_consent_text" text;
|
|
||||||
|
|
||||||
-- Add channel column to reminder_logs with default 'email' (idempotent)
|
|
||||||
ALTER TABLE "reminder_logs" ADD COLUMN IF NOT EXISTS "channel" text NOT NULL DEFAULT 'email';
|
|
||||||
|
|
||||||
-- Drop old unique constraints if they exist (idempotent)
|
|
||||||
ALTER TABLE "reminder_logs" DROP CONSTRAINT IF EXISTS "reminder_logs_appointment_id_reminder_type_key";
|
|
||||||
ALTER TABLE "reminder_logs" DROP CONSTRAINT IF EXISTS "reminder_logs_appointment_id_reminder_type_unique";
|
|
||||||
|
|
||||||
-- Add new unique constraint with channel
|
|
||||||
ALTER TABLE "reminder_logs" ADD CONSTRAINT "reminder_logs_appointment_id_reminder_type_channel_unique" UNIQUE ("appointment_id", "reminder_type", "channel");
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
-- Migration: 0029_db_indexes_constraints.sql
|
|
||||||
-- Add missing indexes on appointments, pets, clients tables and NOT NULL constraint on clients.email
|
|
||||||
|
|
||||||
-- Backfill NULL emails before setting NOT NULL
|
|
||||||
UPDATE clients SET email = concat('unknown-', id::text, '@placeholder.local') WHERE email IS NULL;
|
|
||||||
|
|
||||||
-- Add indexes on appointments table
|
|
||||||
CREATE INDEX idx_appointments_client_id ON appointments(client_id);
|
|
||||||
CREATE INDEX idx_appointments_staff_id ON appointments(staff_id);
|
|
||||||
CREATE INDEX idx_appointments_start_time ON appointments(start_time);
|
|
||||||
CREATE INDEX idx_appointments_status ON appointments(status);
|
|
||||||
|
|
||||||
-- Add index on pets table
|
|
||||||
CREATE INDEX idx_pets_client_id ON pets(client_id);
|
|
||||||
|
|
||||||
-- Add index on clients table
|
|
||||||
CREATE INDEX idx_clients_email ON clients(email);
|
|
||||||
|
|
||||||
-- Set NOT NULL on clients.email (after backfill)
|
|
||||||
ALTER TABLE clients ALTER COLUMN email SET NOT NULL;
|
|
||||||
@@ -197,13 +197,6 @@
|
|||||||
"when": 1775655267192,
|
"when": 1775655267192,
|
||||||
"tag": "0027_refunds",
|
"tag": "0027_refunds",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 28,
|
|
||||||
"version": "7",
|
|
||||||
"when": 1775741667192,
|
|
||||||
"tag": "0028_sms_reminders",
|
|
||||||
"breakpoints": true
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -71,10 +71,6 @@ export function buildClient(overrides: Partial<ClientRow> = {}): ClientRow {
|
|||||||
address: "1 Main St, Springfield, CA 90000",
|
address: "1 Main St, Springfield, CA 90000",
|
||||||
notes: null,
|
notes: null,
|
||||||
emailOptOut: false,
|
emailOptOut: false,
|
||||||
smsOptIn: false,
|
|
||||||
smsConsentDate: null,
|
|
||||||
smsOptOutDate: null,
|
|
||||||
smsConsentText: null,
|
|
||||||
stripeCustomerId: null,
|
stripeCustomerId: null,
|
||||||
status: "active",
|
status: "active",
|
||||||
disabledAt: null,
|
disabledAt: null,
|
||||||
|
|||||||
+37
-52
@@ -102,55 +102,43 @@ export const verification = pgTable("verification", {
|
|||||||
|
|
||||||
// ─── Tables ───────────────────────────────────────────────────────────────────
|
// ─── Tables ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const clients = pgTable(
|
export const clients = pgTable("clients", {
|
||||||
"clients",
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
{
|
name: text("name").notNull(),
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
email: text("email"),
|
||||||
name: text("name").notNull(),
|
phone: text("phone"),
|
||||||
email: text("email").notNull(),
|
address: text("address"),
|
||||||
phone: text("phone"),
|
notes: text("notes"),
|
||||||
address: text("address"),
|
emailOptOut: boolean("email_opt_out").notNull().default(false),
|
||||||
notes: text("notes"),
|
stripeCustomerId: text("stripe_customer_id"),
|
||||||
emailOptOut: boolean("email_opt_out").notNull().default(false),
|
status: clientStatusEnum("status").notNull().default("active"),
|
||||||
smsOptIn: boolean("sms_opt_in").notNull().default(false),
|
disabledAt: timestamp("disabled_at"),
|
||||||
smsConsentDate: timestamp("sms_consent_date"),
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
smsOptOutDate: timestamp("sms_opt_out_date"),
|
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||||
smsConsentText: text("sms_consent_text"),
|
});
|
||||||
stripeCustomerId: text("stripe_customer_id"),
|
|
||||||
status: clientStatusEnum("status").notNull().default("active"),
|
|
||||||
disabledAt: timestamp("disabled_at"),
|
|
||||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
|
||||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
|
||||||
},
|
|
||||||
(t) => [index("idx_clients_email").on(t.email)]
|
|
||||||
);
|
|
||||||
|
|
||||||
export const pets = pgTable(
|
export const pets = pgTable("pets", {
|
||||||
"pets",
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
{
|
clientId: uuid("client_id")
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
.notNull()
|
||||||
clientId: uuid("client_id")
|
.references(() => clients.id, { onDelete: "cascade" }),
|
||||||
.notNull()
|
name: text("name").notNull(),
|
||||||
.references(() => clients.id, { onDelete: "cascade" }),
|
species: text("species").notNull(),
|
||||||
name: text("name").notNull(),
|
breed: text("breed"),
|
||||||
species: text("species").notNull(),
|
weightKg: numeric("weight_kg", { precision: 5, scale: 2 }),
|
||||||
breed: text("breed"),
|
dateOfBirth: timestamp("date_of_birth"),
|
||||||
weightKg: numeric("weight_kg", { precision: 5, scale: 2 }),
|
healthAlerts: text("health_alerts"),
|
||||||
dateOfBirth: timestamp("date_of_birth"),
|
groomingNotes: text("grooming_notes"),
|
||||||
healthAlerts: text("health_alerts"),
|
cutStyle: text("cut_style"),
|
||||||
groomingNotes: text("grooming_notes"),
|
shampooPreference: text("shampoo_preference"),
|
||||||
cutStyle: text("cut_style"),
|
specialCareNotes: text("special_care_notes"),
|
||||||
shampooPreference: text("shampoo_preference"),
|
customFields: jsonb("custom_fields").$type<Record<string, string>>().notNull().default({}),
|
||||||
specialCareNotes: text("special_care_notes"),
|
photoKey: text("photo_key"),
|
||||||
customFields: jsonb("custom_fields").$type<Record<string, string>>().notNull().default({}),
|
photoUploadedAt: timestamp("photo_uploaded_at"),
|
||||||
photoKey: text("photo_key"),
|
image: text("image"),
|
||||||
photoUploadedAt: timestamp("photo_uploaded_at"),
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
image: text("image"),
|
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
});
|
||||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
|
||||||
},
|
|
||||||
(t) => [index("idx_pets_client_id").on(t.clientId)]
|
|
||||||
);
|
|
||||||
|
|
||||||
export const services = pgTable("services", {
|
export const services = pgTable("services", {
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
@@ -333,7 +321,6 @@ export const refunds = pgTable(
|
|||||||
|
|
||||||
// Tracks which reminder emails have been sent per appointment (prevents duplicates).
|
// Tracks which reminder emails have been sent per appointment (prevents duplicates).
|
||||||
// reminder_type values: "confirmation", "24h", "2h"
|
// reminder_type values: "confirmation", "24h", "2h"
|
||||||
// channel values: "email", "sms"
|
|
||||||
export const reminderLogs = pgTable(
|
export const reminderLogs = pgTable(
|
||||||
"reminder_logs",
|
"reminder_logs",
|
||||||
{
|
{
|
||||||
@@ -343,11 +330,9 @@ export const reminderLogs = pgTable(
|
|||||||
.references(() => appointments.id, { onDelete: "cascade" }),
|
.references(() => appointments.id, { onDelete: "cascade" }),
|
||||||
// "confirmation" | "24h" | "2h"
|
// "confirmation" | "24h" | "2h"
|
||||||
reminderType: text("reminder_type").notNull(),
|
reminderType: text("reminder_type").notNull(),
|
||||||
// "email" | "sms"
|
|
||||||
channel: text("channel").notNull().default("email"),
|
|
||||||
sentAt: timestamp("sent_at").notNull().defaultNow(),
|
sentAt: timestamp("sent_at").notNull().defaultNow(),
|
||||||
},
|
},
|
||||||
(t) => [unique().on(t.appointmentId, t.reminderType, t.channel)]
|
(t) => [unique().on(t.appointmentId, t.reminderType)]
|
||||||
);
|
);
|
||||||
|
|
||||||
// ─── Impersonation ──────────────────────────────────────────────────────────
|
// ─── Impersonation ──────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -398,8 +398,6 @@ async function seedKnownUsers() {
|
|||||||
id: ADMIN_STAFF_ID,
|
id: ADMIN_STAFF_ID,
|
||||||
name: adminName,
|
name: adminName,
|
||||||
email: adminEmail,
|
email: adminEmail,
|
||||||
oidcSub: adminEmail,
|
|
||||||
userId: adminEmail,
|
|
||||||
role: "manager",
|
role: "manager",
|
||||||
isSuperUser: true,
|
isSuperUser: true,
|
||||||
active: true,
|
active: true,
|
||||||
@@ -426,7 +424,6 @@ async function seedKnownUsers() {
|
|||||||
name: "UAT Super User",
|
name: "UAT Super User",
|
||||||
email: "uat-super@groombook.dev",
|
email: "uat-super@groombook.dev",
|
||||||
oidcSub: uatSuperOidcSub,
|
oidcSub: uatSuperOidcSub,
|
||||||
userId: uatSuperOidcSub,
|
|
||||||
role: "manager",
|
role: "manager",
|
||||||
isSuperUser: true,
|
isSuperUser: true,
|
||||||
active: true,
|
active: true,
|
||||||
@@ -453,7 +450,6 @@ async function seedKnownUsers() {
|
|||||||
name: "UAT Staff Groomer",
|
name: "UAT Staff Groomer",
|
||||||
email: "uat-groomer@groombook.dev",
|
email: "uat-groomer@groombook.dev",
|
||||||
oidcSub: uatStaffOidcSub,
|
oidcSub: uatStaffOidcSub,
|
||||||
userId: uatStaffOidcSub,
|
|
||||||
role: "groomer",
|
role: "groomer",
|
||||||
isSuperUser: false,
|
isSuperUser: false,
|
||||||
active: true,
|
active: true,
|
||||||
@@ -462,37 +458,6 @@ async function seedKnownUsers() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Staff: UAT Groomer Personas (SEED_UAT_GROOMER_EMAILS + SEED_UAT_GROOMER_NAMES) ──
|
|
||||||
const groomerEmails = process.env.SEED_UAT_GROOMER_EMAILS?.split(",").map((e) => e.trim()).filter(Boolean) ?? [];
|
|
||||||
const groomerNames = process.env.SEED_UAT_GROOMER_NAMES?.split(",").map((n) => n.trim()).filter(Boolean) ?? [];
|
|
||||||
const groomerCount = Math.min(groomerEmails.length, groomerNames.length);
|
|
||||||
for (let i = 0; i < groomerCount; i++) {
|
|
||||||
const email = groomerEmails[i]!;
|
|
||||||
const name = groomerNames[i]!;
|
|
||||||
// Use deterministic IDs in the 00000000-0000-0000-0000-000000000005+ range
|
|
||||||
const staffId = `00000000-0000-0000-0000-${String(5 + i).padStart(12, "0")}`;
|
|
||||||
const [existingGroomer] = await db
|
|
||||||
.select()
|
|
||||||
.from(schema.staff)
|
|
||||||
.where(eq(schema.staff.email, email))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (existingGroomer) {
|
|
||||||
console.log(`✓ Staff groomer '${existingGroomer.name}' already exists — skipping`);
|
|
||||||
} else {
|
|
||||||
await db.insert(schema.staff).values({
|
|
||||||
id: staffId,
|
|
||||||
name,
|
|
||||||
email,
|
|
||||||
oidcSub: email,
|
|
||||||
role: "groomer",
|
|
||||||
isSuperUser: false,
|
|
||||||
active: true,
|
|
||||||
});
|
|
||||||
console.log(`✓ Created staff groomer '${name}' (${email})`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Services: idempotent upsert using name as unique key ─────────────────────
|
// ── Services: idempotent upsert using name as unique key ─────────────────────
|
||||||
// UNIQUE constraint on services.name (migration 0020) must exist first.
|
// UNIQUE constraint on services.name (migration 0020) must exist first.
|
||||||
// Uses b0000001-... IDs to match main seed servicesDef for same-named services.
|
// Uses b0000001-... IDs to match main seed servicesDef for same-named services.
|
||||||
@@ -647,8 +612,6 @@ async function seed() {
|
|||||||
id: ADMIN_STAFF_ID,
|
id: ADMIN_STAFF_ID,
|
||||||
name: adminName,
|
name: adminName,
|
||||||
email: adminEmail,
|
email: adminEmail,
|
||||||
oidcSub: adminEmail,
|
|
||||||
userId: adminEmail,
|
|
||||||
role: "manager",
|
role: "manager",
|
||||||
isSuperUser: true,
|
isSuperUser: true,
|
||||||
active: true,
|
active: true,
|
||||||
@@ -660,31 +623,6 @@ async function seed() {
|
|||||||
console.log(`✓ Upserted admin staff '${adminName}' (${adminEmail})`);
|
console.log(`✓ Upserted admin staff '${adminName}' (${adminEmail})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── UAT Groomer Personas (SEED_UAT_GROOMER_EMAILS + SEED_UAT_GROOMER_NAMES) ──
|
|
||||||
const groomerEmails = process.env.SEED_UAT_GROOMER_EMAILS?.split(",").map((e) => e.trim()).filter(Boolean) ?? [];
|
|
||||||
const groomerNames = process.env.SEED_UAT_GROOMER_NAMES?.split(",").map((n) => n.trim()).filter(Boolean) ?? [];
|
|
||||||
const groomerCount = Math.min(groomerEmails.length, groomerNames.length);
|
|
||||||
for (let i = 0; i < groomerCount; i++) {
|
|
||||||
const email = groomerEmails[i]!;
|
|
||||||
const name = groomerNames[i]!;
|
|
||||||
const staffId = `00000000-0000-0000-0000-${String(5 + i).padStart(12, "0")}`;
|
|
||||||
await db.insert(schema.staff)
|
|
||||||
.values({
|
|
||||||
id: staffId,
|
|
||||||
name,
|
|
||||||
email,
|
|
||||||
oidcSub: email,
|
|
||||||
role: "groomer",
|
|
||||||
isSuperUser: false,
|
|
||||||
active: true,
|
|
||||||
})
|
|
||||||
.onConflictDoUpdate({
|
|
||||||
target: schema.staff.email,
|
|
||||||
set: { id: staffId, name, role: "groomer", isSuperUser: false, active: true },
|
|
||||||
});
|
|
||||||
console.log(`✓ Upserted groomer '${name}' (${email})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Services ──
|
// ── Services ──
|
||||||
// Upsert services using name as unique key. With deterministic IDs in
|
// Upsert services using name as unique key. With deterministic IDs in
|
||||||
// servicesDef and TRUNCATE clearing downstream tables first, this is
|
// servicesDef and TRUNCATE clearing downstream tables first, this is
|
||||||
|
|||||||
Generated
+53
-43
@@ -43,9 +43,6 @@ importers:
|
|||||||
stripe:
|
stripe:
|
||||||
specifier: ^22.0.0
|
specifier: ^22.0.0
|
||||||
version: 22.0.1(@types/node@22.19.15)
|
version: 22.0.1(@types/node@22.19.15)
|
||||||
telnyx:
|
|
||||||
specifier: ^1.23.0
|
|
||||||
version: 1.27.0
|
|
||||||
zod:
|
zod:
|
||||||
specifier: ^4.3.6
|
specifier: ^4.3.6
|
||||||
version: 4.3.6
|
version: 4.3.6
|
||||||
@@ -180,7 +177,7 @@ importers:
|
|||||||
version: 22.19.15
|
version: 22.19.15
|
||||||
drizzle-kit:
|
drizzle-kit:
|
||||||
specifier: ^0.30.4
|
specifier: ^0.30.4
|
||||||
version: 0.30.4
|
version: 0.30.6
|
||||||
tsx:
|
tsx:
|
||||||
specifier: ^4.19.0
|
specifier: ^4.19.0
|
||||||
version: 4.21.0
|
version: 4.21.0
|
||||||
@@ -1699,6 +1696,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==}
|
resolution: {integrity: sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
|
|
||||||
|
'@petamoriken/float16@3.9.3':
|
||||||
|
resolution: {integrity: sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==}
|
||||||
|
|
||||||
'@pkgjs/parseargs@0.11.0':
|
'@pkgjs/parseargs@0.11.0':
|
||||||
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
@@ -2830,8 +2830,8 @@ packages:
|
|||||||
dom-accessibility-api@0.6.3:
|
dom-accessibility-api@0.6.3:
|
||||||
resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
|
resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
|
||||||
|
|
||||||
drizzle-kit@0.30.4:
|
drizzle-kit@0.30.6:
|
||||||
resolution: {integrity: sha512-B2oJN5UkvwwNHscPWXDG5KqAixu7AUzZ3qbe++KU9SsQ+cZWR4DXEPYcvWplyFAno0dhRJECNEhNxiDmFaPGyQ==}
|
resolution: {integrity: sha512-U4wWit0fyZuGuP7iNmRleQyK2V8wCuv57vf5l3MnG4z4fzNTjY/U13M8owyQ5RavqvqxBifWORaR3wIUzlN64g==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
drizzle-orm@0.38.4:
|
drizzle-orm@0.38.4:
|
||||||
@@ -2955,6 +2955,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
|
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
|
||||||
engines: {node: '>=0.12'}
|
engines: {node: '>=0.12'}
|
||||||
|
|
||||||
|
env-paths@3.0.0:
|
||||||
|
resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==}
|
||||||
|
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||||
|
|
||||||
es-abstract@1.24.1:
|
es-abstract@1.24.1:
|
||||||
resolution: {integrity: sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==}
|
resolution: {integrity: sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -3158,6 +3162,11 @@ packages:
|
|||||||
functions-have-names@1.2.3:
|
functions-have-names@1.2.3:
|
||||||
resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
|
resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
|
||||||
|
|
||||||
|
gel@2.2.0:
|
||||||
|
resolution: {integrity: sha512-q0ma7z2swmoamHQusey8ayo8+ilVdzDt4WTxSPzq/yRqvucWRfymRVMvNgmSC0XK7eNjjEZEcplxpgaNojKdmQ==}
|
||||||
|
engines: {node: '>= 18.0.0'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
generator-function@2.0.1:
|
generator-function@2.0.1:
|
||||||
resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==}
|
resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -3425,6 +3434,10 @@ packages:
|
|||||||
isexe@2.0.0:
|
isexe@2.0.0:
|
||||||
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
||||||
|
|
||||||
|
isexe@3.1.5:
|
||||||
|
resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
istanbul-lib-coverage@3.2.2:
|
istanbul-lib-coverage@3.2.2:
|
||||||
resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
|
resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -3606,9 +3619,6 @@ packages:
|
|||||||
lodash.debounce@4.0.8:
|
lodash.debounce@4.0.8:
|
||||||
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
|
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
|
||||||
|
|
||||||
lodash.isplainobject@4.0.6:
|
|
||||||
resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
|
|
||||||
|
|
||||||
lodash.merge@4.6.2:
|
lodash.merge@4.6.2:
|
||||||
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
||||||
|
|
||||||
@@ -3841,10 +3851,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
qs@6.15.1:
|
|
||||||
resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==}
|
|
||||||
engines: {node: '>=0.6'}
|
|
||||||
|
|
||||||
randombytes@2.1.0:
|
randombytes@2.1.0:
|
||||||
resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==}
|
resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==}
|
||||||
|
|
||||||
@@ -4040,6 +4046,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
|
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
shell-quote@1.8.3:
|
||||||
|
resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
side-channel-list@1.0.0:
|
side-channel-list@1.0.0:
|
||||||
resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==}
|
resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -4178,10 +4188,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
|
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
telnyx@1.27.0:
|
|
||||||
resolution: {integrity: sha512-cVbP3jEW4TbmNL5U0UbZc3OkLg+6dHRnMYByYfJnrGw5ZRn0XKb17Hx3fLMWmGgRFow7eqVP4hlCogbIB6T3+w==}
|
|
||||||
engines: {node: ^6 || >=8}
|
|
||||||
|
|
||||||
temp-dir@2.0.0:
|
temp-dir@2.0.0:
|
||||||
resolution: {integrity: sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==}
|
resolution: {integrity: sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -4256,9 +4262,6 @@ packages:
|
|||||||
engines: {node: '>=18.0.0'}
|
engines: {node: '>=18.0.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
tweetnacl@1.0.3:
|
|
||||||
resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==}
|
|
||||||
|
|
||||||
type-check@0.4.0:
|
type-check@0.4.0:
|
||||||
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
@@ -4348,10 +4351,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
|
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
uuid@9.0.1:
|
|
||||||
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
|
|
||||||
hasBin: true
|
|
||||||
|
|
||||||
victory-vendor@37.3.6:
|
victory-vendor@37.3.6:
|
||||||
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
|
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
|
||||||
|
|
||||||
@@ -4488,6 +4487,11 @@ packages:
|
|||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
which@4.0.0:
|
||||||
|
resolution: {integrity: sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==}
|
||||||
|
engines: {node: ^16.13.0 || >=18.0.0}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
why-is-node-running@2.3.0:
|
why-is-node-running@2.3.0:
|
||||||
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
|
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -6219,6 +6223,8 @@ snapshots:
|
|||||||
|
|
||||||
'@opentelemetry/semantic-conventions@1.40.0': {}
|
'@opentelemetry/semantic-conventions@1.40.0': {}
|
||||||
|
|
||||||
|
'@petamoriken/float16@3.9.3': {}
|
||||||
|
|
||||||
'@pkgjs/parseargs@0.11.0':
|
'@pkgjs/parseargs@0.11.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -7414,12 +7420,13 @@ snapshots:
|
|||||||
|
|
||||||
dom-accessibility-api@0.6.3: {}
|
dom-accessibility-api@0.6.3: {}
|
||||||
|
|
||||||
drizzle-kit@0.30.4:
|
drizzle-kit@0.30.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@drizzle-team/brocli': 0.10.2
|
'@drizzle-team/brocli': 0.10.2
|
||||||
'@esbuild-kit/esm-loader': 2.6.5
|
'@esbuild-kit/esm-loader': 2.6.5
|
||||||
esbuild: 0.19.12
|
esbuild: 0.19.12
|
||||||
esbuild-register: 3.6.0(esbuild@0.19.12)
|
esbuild-register: 3.6.0(esbuild@0.19.12)
|
||||||
|
gel: 2.2.0
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
@@ -7456,6 +7463,8 @@ snapshots:
|
|||||||
|
|
||||||
entities@6.0.1: {}
|
entities@6.0.1: {}
|
||||||
|
|
||||||
|
env-paths@3.0.0: {}
|
||||||
|
|
||||||
es-abstract@1.24.1:
|
es-abstract@1.24.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
array-buffer-byte-length: 1.0.2
|
array-buffer-byte-length: 1.0.2
|
||||||
@@ -7817,6 +7826,17 @@ snapshots:
|
|||||||
|
|
||||||
functions-have-names@1.2.3: {}
|
functions-have-names@1.2.3: {}
|
||||||
|
|
||||||
|
gel@2.2.0:
|
||||||
|
dependencies:
|
||||||
|
'@petamoriken/float16': 3.9.3
|
||||||
|
debug: 4.4.3
|
||||||
|
env-paths: 3.0.0
|
||||||
|
semver: 7.7.4
|
||||||
|
shell-quote: 1.8.3
|
||||||
|
which: 4.0.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
generator-function@2.0.1: {}
|
generator-function@2.0.1: {}
|
||||||
|
|
||||||
gensync@1.0.0-beta.2: {}
|
gensync@1.0.0-beta.2: {}
|
||||||
@@ -8081,6 +8101,8 @@ snapshots:
|
|||||||
|
|
||||||
isexe@2.0.0: {}
|
isexe@2.0.0: {}
|
||||||
|
|
||||||
|
isexe@3.1.5: {}
|
||||||
|
|
||||||
istanbul-lib-coverage@3.2.2: {}
|
istanbul-lib-coverage@3.2.2: {}
|
||||||
|
|
||||||
istanbul-lib-report@3.0.1:
|
istanbul-lib-report@3.0.1:
|
||||||
@@ -8249,8 +8271,6 @@ snapshots:
|
|||||||
|
|
||||||
lodash.debounce@4.0.8: {}
|
lodash.debounce@4.0.8: {}
|
||||||
|
|
||||||
lodash.isplainobject@4.0.6: {}
|
|
||||||
|
|
||||||
lodash.merge@4.6.2: {}
|
lodash.merge@4.6.2: {}
|
||||||
|
|
||||||
lodash.sortby@4.7.0: {}
|
lodash.sortby@4.7.0: {}
|
||||||
@@ -8449,10 +8469,6 @@ snapshots:
|
|||||||
|
|
||||||
punycode@2.3.1: {}
|
punycode@2.3.1: {}
|
||||||
|
|
||||||
qs@6.15.1:
|
|
||||||
dependencies:
|
|
||||||
side-channel: 1.1.0
|
|
||||||
|
|
||||||
randombytes@2.1.0:
|
randombytes@2.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
safe-buffer: 5.2.1
|
safe-buffer: 5.2.1
|
||||||
@@ -8687,6 +8703,8 @@ snapshots:
|
|||||||
|
|
||||||
shebang-regex@3.0.0: {}
|
shebang-regex@3.0.0: {}
|
||||||
|
|
||||||
|
shell-quote@1.8.3: {}
|
||||||
|
|
||||||
side-channel-list@1.0.0:
|
side-channel-list@1.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
@@ -8840,14 +8858,6 @@ snapshots:
|
|||||||
|
|
||||||
tapable@2.3.0: {}
|
tapable@2.3.0: {}
|
||||||
|
|
||||||
telnyx@1.27.0:
|
|
||||||
dependencies:
|
|
||||||
lodash.isplainobject: 4.0.6
|
|
||||||
qs: 6.15.1
|
|
||||||
safe-buffer: 5.2.1
|
|
||||||
tweetnacl: 1.0.3
|
|
||||||
uuid: 9.0.1
|
|
||||||
|
|
||||||
temp-dir@2.0.0: {}
|
temp-dir@2.0.0: {}
|
||||||
|
|
||||||
tempy@0.6.0:
|
tempy@0.6.0:
|
||||||
@@ -8918,8 +8928,6 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
|
|
||||||
tweetnacl@1.0.3: {}
|
|
||||||
|
|
||||||
type-check@0.4.0:
|
type-check@0.4.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
prelude-ls: 1.2.1
|
prelude-ls: 1.2.1
|
||||||
@@ -9016,8 +9024,6 @@ snapshots:
|
|||||||
|
|
||||||
uuid@8.3.2: {}
|
uuid@8.3.2: {}
|
||||||
|
|
||||||
uuid@9.0.1: {}
|
|
||||||
|
|
||||||
victory-vendor@37.3.6:
|
victory-vendor@37.3.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/d3-array': 3.2.2
|
'@types/d3-array': 3.2.2
|
||||||
@@ -9195,6 +9201,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
isexe: 2.0.0
|
isexe: 2.0.0
|
||||||
|
|
||||||
|
which@4.0.0:
|
||||||
|
dependencies:
|
||||||
|
isexe: 3.1.5
|
||||||
|
|
||||||
why-is-node-running@2.3.0:
|
why-is-node-running@2.3.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
siginfo: 2.0.0
|
siginfo: 2.0.0
|
||||||
|
|||||||
Reference in New Issue
Block a user