Compare commits

..

14 Commits

Author SHA1 Message Date
groombook-engineer[bot] 2577e33c50 feat(GRO-653): add portal session middleware and server-side audit logging (#300)
* feat(GRO-653): add portal session middleware and server-side audit logging

- Add validatePortalSession middleware that reads X-Impersonation-Session-Id header,
  queries impersonationSessions, and sets portalClientId + portalSessionId on the context
- Add portalAudit middleware that logs all portal requests to impersonationAuditLogs table
- Apply both middlewares to the portalRouter
- Replace all getClientIdFromSession() calls with c.get("portalClientId")
- Remove getClientIdFromSession() helper and inline session checks in waitlist routes
- Consistent session.expiry > new Date() check across all routes

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(GRO-653): remove unused sessionId variable and and import

Fix lint errors flagged by QA:
- Remove unused `sessionId` variable from PATCH waitlist handler
- Remove unused `and` import from portal.ts

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Paperclip <noreply@paperclip.ing>

---------

Co-authored-by: Flea Flicker <fleaflicker@groombook.farh.net>
Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Flea Flicker <flea-flicker@groombook.ai>
2026-04-16 11:20:36 +00:00
groombook-cto[bot] b904418628 fix(GRO-640): replace N+1 queries in sendConfirmationEmail with single JOIN query
CTO approved: clean perf fix replacing 4 sequential DB queries with a single JOIN. QA approved.
2026-04-16 10:14:06 +00:00
groombook-cto[bot] 5ff54ce8f9 fix(GRO-689): only validate authorizationUrl hostname, add OIDC_INTERNAL_BASE in dev (#302)
fix(GRO-689): only validate authorizationUrl hostname, add OIDC_INTERNAL_BASE in dev
2026-04-16 05:18:58 +00:00
groombook-cto[bot] a2cfdfef74 Merge branch 'main' into fix/gro-689-oidc-hostname-validation 2026-04-16 05:15:28 +00:00
groombook-cto[bot] ab9384d38e feat(GRO-690): add groomer persona seed support via env vars (#303)
feat(GRO-690): add groomer persona seed support via env vars
2026-04-16 05:11:07 +00:00
groombook-cto[bot] 6ba6da08b2 Merge branch 'main' into fix/gro-689-oidc-hostname-validation 2026-04-16 05:08:23 +00:00
Flea Flicker 29a726fa3d feat(GRO-690): add groomer persona seed support via env vars
Extend seed.ts with SEED_UAT_GROOMER_EMAILS and SEED_UAT_GROOMER_NAMES
env vars for persistent groomer personas (sam@sarah). Works in both
SEED_KNOWN_USERS_ONLY=true and full seed modes.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-16 05:04:52 +00:00
Flea Flicker cdf4d6c4b1 fix(GRO-689): only validate authorizationUrl hostname, add OIDC_INTERNAL_BASE in dev
- Move hostname validation to run AFTER OIDC_INTERNAL_BASE replacement
  (was checking raw discovery URLs before replacement caused false positives)
- Only validate authorizationUrl hostname against issuer; token/userinfo
  are server-to-server and may legitimately use internal hostnames
- Infra: add OIDC_INTERNAL_BASE env var to dev overlay (was missing, matches UAT)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-16 04:55:17 +00:00
groombook-cto[bot] ffb3cd139a Merge pull request #296 from groombook/fix/gro-666-uat-seed-better-auth-user-id
fix(seed): populate userId for UAT staff and SEED_ADMIN_EMAIL staff
2026-04-16 04:17:14 +00:00
Flea Flicker 85cff19c59 fix(GRO-666): make migration 0028 idempotent to resolve E2E failure
- Add IF NOT EXISTS to all ADD COLUMN statements (schema already has these columns)
- Use DROP CONSTRAINT IF EXISTS for both possible auto-generated constraint names
- Idempotent: safe to re-run on databases that already have the schema changes

cc @cpfarhood

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-15 15:25:20 +00:00
Flea Flicker 376180ab9d fix: make email required in createClientSchema to match NOT NULL column
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 10:52:45 +00:00
Flea Flicker da16ac8ac2 Add missing DB indexes, NOT NULL on clients.email, and S3 error handling
- Add 4 indexes on appointments: client_id, staff_id, start_time, status
- Add index on pets.client_id
- Add index on clients.email
- Change clients.email to NOT NULL with backfill migration
- Wrap S3 deleteObject calls in try/catch in pets photo endpoints
- Update POST /clients test to include required email field

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-15 10:09:57 +00:00
Flea Flicker 16dd513521 fix(seed): populate userId for UAT staff and SEED_ADMIN_EMAIL staff
GRO-666: resolveStaffMiddleware returns 403 for UAT users because
staff records have NULL userId after seed. This change populates
userId (and oidcSub) for all staff created via seedKnownUsers()
and the main seed path using the same value as the OIDC sub.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-15 09:37:51 +00:00
Flea Flicker 66a6659ccd feat(GRO-600): extend reminder scheduler to send SMS alongside email
- Add SMS opt-in fields to clients schema (smsOptIn, smsConsentDate, smsOptOutDate, smsConsentText)
- Add channel column to reminderLogs with per-channel idempotency
- Create SMS service with Telnyx SDK integration and E.164 validation
- Update reminders service to conditionally send SMS to opted-in clients
- Add TCPA opt-out text to SMS reminders
- Graceful degradation: catch SMS errors without blocking email
- Fix: use clients.phone instead of non-existent clients.phoneE164
- Update clients route to expose SMS fields in API
- Add telnyx dependency to API package
- Create database migration 0028_sms_reminders

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-15 09:23:24 +00:00
15 changed files with 477 additions and 175 deletions
+1
View File
@@ -23,6 +23,7 @@
"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"
}, },
+3 -2
View File
@@ -195,10 +195,11 @@ describe("POST /clients", () => {
expect(insertedValues[0]!.name).toBe("Charlie"); expect(insertedValues[0]!.name).toBe("Charlie");
}); });
it("creates a client with only required name field", async () => { it("creates a client with name and email", async () => {
const res = await jsonRequest("POST", "/clients", { name: "Dana" }); const res = await jsonRequest("POST", "/clients", { name: "Dana", email: "dana@example.com" });
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 () => {
+4 -8
View File
@@ -204,15 +204,11 @@ 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);
const tokenUrlObj = new URL(tokenUrl); // Only validate authorizationUrl hostname against issuer — token/userinfo
const userInfoUrlObj = new URL(userInfoUrl); // may legitimately use internal hostnames (OIDC_INTERNAL_BASE) for server-to-server calls.
if ( if (authzUrlObj.hostname !== issuerHostname) {
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}', '${tokenUrlObj.hostname}', or '${userInfoUrlObj.hostname}'. This may indicate a man-in-the-middle attack.` `[FATAL] OIDC discovery URL hostname mismatch: expected '${issuerHostname}' but got '${authzUrlObj.hostname}'. This may indicate a man-in-the-middle attack.`
); );
} }
oidcConfig = { oidcConfig = {
+24 -33
View File
@@ -338,44 +338,35 @@ async function sendConfirmationEmail(
db: ReturnType<typeof getDb>, db: ReturnType<typeof getDb>,
appt: typeof appointments.$inferSelect appt: typeof appointments.$inferSelect
): Promise<void> { ): Promise<void> {
const [client] = await db const [row] = await db
.select({ name: clients.name, email: clients.email, emailOptOut: clients.emailOptOut }) .select({
.from(clients) clientName: clients.name,
.where(eq(clients.id, appt.clientId)) clientEmail: clients.email,
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 (!client || !client.email || client.emailOptOut) return; if (!row) return;
const { clientName, clientEmail, clientEmailOptOut, petName, serviceName, groomerName } = row;
const [pet] = await db if (!clientEmail || clientEmailOptOut) return;
.select({ name: pets.name }) if (!petName || !serviceName) return;
.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(client.email, { buildConfirmationEmail(clientEmail, {
clientName: client.name, clientName,
petName: pet.name, petName,
serviceName: service.name, serviceName,
groomerName, groomerName: groomerName ?? null,
startTime: appt.startTime, startTime: appt.startTime,
}) })
); );
+11 -2
View File
@@ -8,10 +8,12 @@ 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().optional(), email: z.string().email(),
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(),
}); });
@@ -95,6 +97,7 @@ 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(
@@ -107,13 +110,19 @@ 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)
+70 -40
View File
@@ -18,9 +18,10 @@ 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);
@@ -30,20 +31,14 @@ 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,
@@ -65,23 +60,38 @@ export async function runReminderCheck(): Promise<void> {
); );
for (const appt of upcoming) { for (const appt of upcoming) {
// Check if reminder already sent (unique constraint prevents double-send) const [emailLog] = await db
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);
if (existing.length > 0) continue; // already sent const [smsLog] = await db
.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({ name: clients.name, email: clients.email, emailOptOut: clients.emailOptOut }) .select({
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);
@@ -112,8 +122,6 @@ 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");
@@ -123,35 +131,59 @@ export async function runReminderCheck(): Promise<void> {
.where(eq(appointments.id, appt.id)); .where(eq(appointments.id, appt.id));
} }
const sent = await sendEmail( if (!emailLog) {
buildReminderEmail( const sent = await sendEmail(
client.email, buildReminderEmail(
{ client.email,
clientName: client.name, {
petName: pet.name, clientName: client.name,
serviceName: service.name, petName: pet.name,
groomerName, serviceName: service.name,
startTime: appt.startTime, groomerName,
}, startTime: appt.startTime,
window.hours, },
confirmationToken window.hours,
) confirmationToken
); )
);
if (sent) { if (sent) {
// Record send — ignore conflicts (race condition between instances) await db
await db .insert(reminderLogs)
.insert(reminderLogs) .values({ appointmentId: appt.id, reminderType: window.label, channel: "email" })
.values({ appointmentId: appt.id, reminderType: window.label }) .onConflictDoNothing();
.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);
@@ -163,8 +195,6 @@ 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();
+142
View File
@@ -0,0 +1,142 @@
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;
}
+19
View File
@@ -0,0 +1,19 @@
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>;
};
}
}
@@ -0,0 +1,15 @@
-- 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");
@@ -0,0 +1,20 @@
-- 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,6 +197,13 @@
"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
} }
] ]
} }
+4
View File
@@ -71,6 +71,10 @@ 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,
+52 -37
View File
@@ -102,43 +102,55 @@ export const verification = pgTable("verification", {
// ─── Tables ─────────────────────────────────────────────────────────────────── // ─── Tables ───────────────────────────────────────────────────────────────────
export const clients = pgTable("clients", { export const clients = pgTable(
id: uuid("id").primaryKey().defaultRandom(), "clients",
name: text("name").notNull(), {
email: text("email"), id: uuid("id").primaryKey().defaultRandom(),
phone: text("phone"), name: text("name").notNull(),
address: text("address"), email: text("email").notNull(),
notes: text("notes"), phone: text("phone"),
emailOptOut: boolean("email_opt_out").notNull().default(false), address: text("address"),
stripeCustomerId: text("stripe_customer_id"), notes: text("notes"),
status: clientStatusEnum("status").notNull().default("active"), emailOptOut: boolean("email_opt_out").notNull().default(false),
disabledAt: timestamp("disabled_at"), smsOptIn: boolean("sms_opt_in").notNull().default(false),
createdAt: timestamp("created_at").notNull().defaultNow(), smsConsentDate: timestamp("sms_consent_date"),
updatedAt: timestamp("updated_at").notNull().defaultNow(), smsOptOutDate: timestamp("sms_opt_out_date"),
}); 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("pets", { export const pets = pgTable(
id: uuid("id").primaryKey().defaultRandom(), "pets",
clientId: uuid("client_id") {
.notNull() id: uuid("id").primaryKey().defaultRandom(),
.references(() => clients.id, { onDelete: "cascade" }), clientId: uuid("client_id")
name: text("name").notNull(), .notNull()
species: text("species").notNull(), .references(() => clients.id, { onDelete: "cascade" }),
breed: text("breed"), name: text("name").notNull(),
weightKg: numeric("weight_kg", { precision: 5, scale: 2 }), species: text("species").notNull(),
dateOfBirth: timestamp("date_of_birth"), breed: text("breed"),
healthAlerts: text("health_alerts"), weightKg: numeric("weight_kg", { precision: 5, scale: 2 }),
groomingNotes: text("grooming_notes"), dateOfBirth: timestamp("date_of_birth"),
cutStyle: text("cut_style"), healthAlerts: text("health_alerts"),
shampooPreference: text("shampoo_preference"), groomingNotes: text("grooming_notes"),
specialCareNotes: text("special_care_notes"), cutStyle: text("cut_style"),
customFields: jsonb("custom_fields").$type<Record<string, string>>().notNull().default({}), shampooPreference: text("shampoo_preference"),
photoKey: text("photo_key"), specialCareNotes: text("special_care_notes"),
photoUploadedAt: timestamp("photo_uploaded_at"), customFields: jsonb("custom_fields").$type<Record<string, string>>().notNull().default({}),
image: text("image"), photoKey: text("photo_key"),
createdAt: timestamp("created_at").notNull().defaultNow(), photoUploadedAt: timestamp("photo_uploaded_at"),
updatedAt: timestamp("updated_at").notNull().defaultNow(), image: text("image"),
}); 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(),
@@ -321,6 +333,7 @@ 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",
{ {
@@ -330,9 +343,11 @@ 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) => [unique().on(t.appointmentId, t.reminderType, t.channel)]
); );
// ─── Impersonation ────────────────────────────────────────────────────────── // ─── Impersonation ──────────────────────────────────────────────────────────
+62
View File
@@ -398,6 +398,8 @@ 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,
@@ -424,6 +426,7 @@ 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,
@@ -450,6 +453,7 @@ 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,
@@ -458,6 +462,37 @@ 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.
@@ -612,6 +647,8 @@ 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,
@@ -623,6 +660,31 @@ 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
+43 -53
View File
@@ -43,6 +43,9 @@ 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
@@ -177,7 +180,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.6 version: 0.30.4
tsx: tsx:
specifier: ^4.19.0 specifier: ^4.19.0
version: 4.21.0 version: 4.21.0
@@ -1696,9 +1699,6 @@ 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.6: drizzle-kit@0.30.4:
resolution: {integrity: sha512-U4wWit0fyZuGuP7iNmRleQyK2V8wCuv57vf5l3MnG4z4fzNTjY/U13M8owyQ5RavqvqxBifWORaR3wIUzlN64g==} resolution: {integrity: sha512-B2oJN5UkvwwNHscPWXDG5KqAixu7AUzZ3qbe++KU9SsQ+cZWR4DXEPYcvWplyFAno0dhRJECNEhNxiDmFaPGyQ==}
hasBin: true hasBin: true
drizzle-orm@0.38.4: drizzle-orm@0.38.4:
@@ -2955,10 +2955,6 @@ 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'}
@@ -3162,11 +3158,6 @@ 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'}
@@ -3434,10 +3425,6 @@ 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'}
@@ -3619,6 +3606,9 @@ 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==}
@@ -3851,6 +3841,10 @@ 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==}
@@ -4046,10 +4040,6 @@ 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'}
@@ -4188,6 +4178,10 @@ 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'}
@@ -4262,6 +4256,9 @@ 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'}
@@ -4351,6 +4348,10 @@ 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==}
@@ -4487,11 +4488,6 @@ 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'}
@@ -6223,8 +6219,6 @@ 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
@@ -7420,13 +7414,12 @@ snapshots:
dom-accessibility-api@0.6.3: {} dom-accessibility-api@0.6.3: {}
drizzle-kit@0.30.6: drizzle-kit@0.30.4:
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
@@ -7463,8 +7456,6 @@ 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
@@ -7826,17 +7817,6 @@ 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: {}
@@ -8101,8 +8081,6 @@ 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:
@@ -8271,6 +8249,8 @@ 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: {}
@@ -8469,6 +8449,10 @@ 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
@@ -8703,8 +8687,6 @@ 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
@@ -8858,6 +8840,14 @@ 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:
@@ -8928,6 +8918,8 @@ 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
@@ -9024,6 +9016,8 @@ 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
@@ -9201,10 +9195,6 @@ 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