GRO-598/GRO-194 Phase 1.1: SMS schema - add consent fields to clients, channel to reminderLogs, E.164 phone validation
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -4,12 +4,35 @@ import { z } from "zod/v3";
|
|||||||
import { and, eq, exists, getDb, or, clients, appointments } from "@groombook/db";
|
import { and, eq, exists, getDb, or, clients, appointments } from "@groombook/db";
|
||||||
import type { AppEnv } from "../middleware/rbac.js";
|
import type { AppEnv } from "../middleware/rbac.js";
|
||||||
|
|
||||||
|
function normalizeE164(phone: string): string | null {
|
||||||
|
const digits = phone.replace(/\D/g, "");
|
||||||
|
if (digits.length === 10) return `+1${digits}`;
|
||||||
|
if (digits.length === 11 && digits.startsWith("1")) return `+${digits}`;
|
||||||
|
if (digits.length > 11 && digits.startsWith("1")) return `+${digits.slice(0, 11)}`;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function e164String() {
|
||||||
|
return z.string().transform((v, ctx) => {
|
||||||
|
if (!v) return v as unknown as undefined;
|
||||||
|
const normalized = normalizeE164(v);
|
||||||
|
if (!normalized) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Invalid phone number. Must be a valid E.164 number (e.g. +12125551234).",
|
||||||
|
});
|
||||||
|
return z.NEVER;
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export const clientsRouter = new Hono<AppEnv>();
|
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().optional(),
|
||||||
phone: z.string().max(50).optional(),
|
phone: e164String().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(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
ALTER TABLE "reminder_logs" DROP CONSTRAINT "reminder_logs_appointment_id_reminder_type_unique";--> statement-breakpoint
|
||||||
|
ALTER TABLE "business_settings" ADD COLUMN "logo_key" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "clients" ADD COLUMN "sms_opt_in" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "clients" ADD COLUMN "sms_consent_date" timestamp;--> statement-breakpoint
|
||||||
|
ALTER TABLE "clients" ADD COLUMN "sms_opt_out_date" timestamp;--> statement-breakpoint
|
||||||
|
ALTER TABLE "clients" ADD COLUMN "sms_consent_text" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "pets" ADD COLUMN "image" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "reminder_logs" ADD COLUMN "channel" text DEFAULT 'email' NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "reminder_logs" ADD CONSTRAINT "reminder_logs_appointment_id_reminder_type_channel_unique" UNIQUE("appointment_id","reminder_type","channel");
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -183,6 +183,13 @@
|
|||||||
"when": 1775482467192,
|
"when": 1775482467192,
|
||||||
"tag": "0025_rate_limit",
|
"tag": "0025_rate_limit",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 26,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1776035812477,
|
||||||
|
"tag": "0026_boring_storm",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
status: "active",
|
status: "active",
|
||||||
disabledAt: null,
|
disabledAt: null,
|
||||||
createdAt: new Date("2025-01-01T00:00:00Z"),
|
createdAt: new Date("2025-01-01T00:00:00Z"),
|
||||||
|
|||||||
@@ -109,8 +109,11 @@ export const clients = pgTable("clients", {
|
|||||||
phone: text("phone"),
|
phone: text("phone"),
|
||||||
address: text("address"),
|
address: text("address"),
|
||||||
notes: text("notes"),
|
notes: text("notes"),
|
||||||
// Set to true if the client has opted out of email reminders/notifications
|
|
||||||
emailOptOut: boolean("email_opt_out").notNull().default(false),
|
emailOptOut: boolean("email_opt_out").notNull().default(false),
|
||||||
|
smsOptIn: boolean("sms_opt_in").notNull().default(false),
|
||||||
|
smsConsentDate: timestamp("sms_consent_date"),
|
||||||
|
smsOptOutDate: timestamp("sms_opt_out_date"),
|
||||||
|
smsConsentText: text("sms_consent_text"),
|
||||||
status: clientStatusEnum("status").notNull().default("active"),
|
status: clientStatusEnum("status").notNull().default("active"),
|
||||||
disabledAt: timestamp("disabled_at"),
|
disabledAt: timestamp("disabled_at"),
|
||||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
@@ -305,11 +308,11 @@ export const reminderLogs = pgTable(
|
|||||||
appointmentId: uuid("appointment_id")
|
appointmentId: uuid("appointment_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => appointments.id, { onDelete: "cascade" }),
|
.references(() => appointments.id, { onDelete: "cascade" }),
|
||||||
// "confirmation" | "24h" | "2h"
|
|
||||||
reminderType: text("reminder_type").notNull(),
|
reminderType: text("reminder_type").notNull(),
|
||||||
|
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 ──────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user