+ {/* Fixed to the viewport bottom (reusing the save bar's styling) so it can't scroll out
+ of view or slide under the sticky header — it carries the "was the client told?" info. */}
+ {message && (
+
- Squarespace (Code Block, Core plan or higher) and most sites — paste this
- auto-resizing snippet:
+ You don't need to understand the code below — just copy it and paste it into your
+ website builder. Ask whoever manages your website to help if you get stuck.
+
+ Both days are included — for one day off, pick the same day twice.
+ {blockStart && blockEnd && blockEnd < blockStart
+ ? ' The last day must be on or after the first day.'
+ : ''}
+
@@ -86,6 +105,7 @@ export default function App() {
Enter the email your sitter has on file and we'll send you a sign-in code.
setAuthed(true)} />
+ {contact}
);
}
@@ -109,6 +129,7 @@ export default function App() {
>
)}
+ {contact}
);
}
diff --git a/app/shared-ui/api.ts b/app/shared-ui/api.ts
index d721ad2..67a1dce 100644
--- a/app/shared-ui/api.ts
+++ b/app/shared-ui/api.ts
@@ -23,11 +23,13 @@ export type TenantConfig = {
maxHouseSitsPerDay: number | null;
maxStayNights: number | null;
timezone: string | null;
+ contactEmail: string | null;
+ contactPhone: string | null;
petTypes: string[];
services: ServiceConfig[];
};
-export type Pet = { id: string; name: string; petType: 'dog' | 'cat' };
+export type Pet = { id: string; name: string; petType: 'dog' | 'cat'; notes?: string | null };
export type MonthDay = {
date: string;
status: 'available' | 'partial' | 'unavailable';
@@ -54,6 +56,7 @@ export type Customer = {
id: string;
email: string;
name: string | null;
+ phone: string | null;
status: 'invited' | 'active';
invitedAt?: string | null;
pets: Pet[];
@@ -168,24 +171,31 @@ export const adminApi = {
request<{ customers: Customer[] }>(`/api/${slug}/admin/customers`, {
headers: authHeaders(token),
}),
- add: (slug: string, token: string, email: string, name: string) =>
+ add: (slug: string, token: string, email: string, name: string, phone: string) =>
request<{ id: string; status: string }>(`/api/${slug}/admin/customers`, {
method: 'POST',
headers: { ...jsonHeaders, ...authHeaders(token) },
- body: JSON.stringify({ email, name }),
+ body: JSON.stringify({ email, name, phone }),
}),
remove: (slug: string, token: string, id: string) =>
request(`/api/${slug}/admin/customers/${id}`, {
method: 'DELETE',
headers: authHeaders(token),
}),
- addPet: (slug: string, token: string, endUserId: string, name: string, petType: string) =>
+ addPet: (
+ slug: string,
+ token: string,
+ endUserId: string,
+ name: string,
+ petType: string,
+ notes: string,
+ ) =>
request<{ id: string; name: string; petType: string }>(
`/api/${slug}/admin/customers/${endUserId}/pets`,
{
method: 'POST',
headers: { ...jsonHeaders, ...authHeaders(token) },
- body: JSON.stringify({ name, petType }),
+ body: JSON.stringify({ name, petType, notes }),
},
),
removePet: (slug: string, token: string, endUserId: string, petId: string) =>
@@ -199,8 +209,13 @@ export const adminApi = {
request<{ bookings: AdminBooking[] }>(`/api/${slug}/admin/bookings`, {
headers: authHeaders(token),
}),
- setStatus: (slug: string, token: string, id: string, status: 'confirmed' | 'cancelled') =>
- request<{ status: string }>(`/api/${slug}/admin/bookings/${id}/status`, {
+ setStatus: (
+ slug: string,
+ token: string,
+ id: string,
+ status: 'confirmed' | 'declined' | 'cancelled',
+ ) =>
+ request<{ status: string; notified: boolean }>(`/api/${slug}/admin/bookings/${id}/status`, {
method: 'POST',
headers: { ...jsonHeaders, ...authHeaders(token) },
body: JSON.stringify({ status }),
diff --git a/migrations/0008_contact_and_notes.sql b/migrations/0008_contact_and_notes.sql
new file mode 100644
index 0000000..8e321c6
--- /dev/null
+++ b/migrations/0008_contact_and_notes.sql
@@ -0,0 +1,9 @@
+-- UX round: business contact info, client phone, pet care notes, and a distinct
+-- "declined" marker for booking requests (kept as a flag beside the Status enum —
+-- widening the Status CHECK would need a full table rebuild).
+-- ponytail: fold Declined into Status if BookingRequests ever needs a rebuild migration anyway.
+ALTER TABLE Tenants ADD COLUMN ContactEmail TEXT;
+ALTER TABLE Tenants ADD COLUMN ContactPhone TEXT;
+ALTER TABLE EndUsers ADD COLUMN Phone TEXT;
+ALTER TABLE EndUserPets ADD COLUMN Notes TEXT;
+ALTER TABLE BookingRequests ADD COLUMN Declined INTEGER NOT NULL DEFAULT 0;
diff --git a/server/__tests__/admin-bookings.test.ts b/server/__tests__/admin-bookings.test.ts
index 787af9e..2c937a0 100644
--- a/server/__tests__/admin-bookings.test.ts
+++ b/server/__tests__/admin-bookings.test.ts
@@ -62,7 +62,7 @@ describe('admin booking lifecycle', () => {
env,
);
expect(confirm.status).toBe(200);
- expect(await confirm.json()).toEqual({ status: 'confirmed' });
+ expect(await confirm.json()).toEqual({ status: 'confirmed', notified: false });
const list = (await (
await app.request(
@@ -74,6 +74,36 @@ describe('admin booking lifecycle', () => {
expect(list.bookings.find((b) => b.id === created.id)?.status).toBe('confirmed');
});
+ it('POST decline is pending-only, listed as declined, and distinct from cancelled', async () => {
+ const { env } = createTestEnv();
+ const created = (await (await bookBoarding(env, '2028-11-01', '2028-11-03')).json()) as {
+ id: string;
+ };
+ const auth = { ...(await adminHeaders(TENANT_A)), 'Content-Type': 'application/json' };
+ const setStatus = (status: string) =>
+ app.request(
+ `/api/sunny-paws/admin/bookings/${created.id}/status`,
+ { method: 'POST', headers: auth, body: JSON.stringify({ status }) },
+ env,
+ );
+
+ const decline = await setStatus('declined');
+ expect(decline.status).toBe(200);
+ expect(await decline.json()).toEqual({ status: 'declined', notified: false });
+
+ const list = (await (
+ await app.request(
+ '/api/sunny-paws/admin/bookings',
+ { headers: await adminHeaders(TENANT_A) },
+ env,
+ )
+ ).json()) as { bookings: { id: string; status: string }[] };
+ expect(list.bookings.find((b) => b.id === created.id)?.status).toBe('declined');
+
+ // Declining a non-pending row never matches: it's already terminal here.
+ expect((await setStatus('declined')).status).toBe(404);
+ });
+
it('POST cancel on a confirmed booking cancels it; further status changes 404 (terminal)', async () => {
const { env } = createTestEnv();
const created = (await (await bookBoarding(env, '2028-10-08', '2028-10-10')).json()) as {
@@ -90,7 +120,7 @@ describe('admin booking lifecycle', () => {
expect((await setStatus('confirmed')).status).toBe(200);
const cancel = await setStatus('cancelled');
expect(cancel.status).toBe(200);
- expect(await cancel.json()).toEqual({ status: 'cancelled' });
+ expect(await cancel.json()).toEqual({ status: 'cancelled', notified: false });
// Cancelled is terminal: even re-confirming the same row is rejected.
const again = await setStatus('confirmed');
diff --git a/server/__tests__/admin.test.ts b/server/__tests__/admin.test.ts
index 30837cb..ade5eb6 100644
--- a/server/__tests__/admin.test.ts
+++ b/server/__tests__/admin.test.ts
@@ -286,7 +286,38 @@ describe('tenant admin', () => {
expect(res.status).toBe(400);
});
- it('rejects duplicate durations within one service', async () => {
+ it('accepts two options sharing a duration but not a name, with distinct keys', async () => {
+ const { env } = createTestEnv();
+ const res = await app.request(
+ '/api/sunny-paws/admin/settings',
+ {
+ method: 'PUT',
+ headers: await auth(TENANT_A, true),
+ body: JSON.stringify({
+ services: [
+ {
+ type: 'checkin',
+ enabled: true,
+ options: [
+ { label: '30 minutes', durationMinutes: 30, rate: 18 },
+ { label: 'Puppy Check-in', durationMinutes: 30, rate: 22 },
+ ],
+ },
+ ],
+ }),
+ },
+ env,
+ );
+ expect(res.status).toBe(204);
+ const settings = (await (
+ await app.request('/api/sunny-paws/admin/settings', { headers: await auth(TENANT_A) }, env)
+ ).json()) as { services: { type: string; options: { optionKey: string; label: string }[] }[] };
+ const checkin = settings.services.find((s) => s.type === 'checkin')!;
+ expect(checkin.options.map((o) => o.label).sort()).toEqual(['30 minutes', 'Puppy Check-in']);
+ expect(new Set(checkin.options.map((o) => o.optionKey)).size).toBe(2);
+ });
+
+ it('rejects two options with the same name within one service', async () => {
const { env } = createTestEnv();
const res = await app.request(
'/api/sunny-paws/admin/settings',
@@ -300,7 +331,7 @@ describe('tenant admin', () => {
enabled: true,
options: [
{ label: '30 min', durationMinutes: 30, rate: 20 },
- { label: 'also 30', durationMinutes: 30, rate: 25 },
+ { label: '30 min', durationMinutes: 30, rate: 25 },
],
},
],
diff --git a/server/__tests__/availability.test.ts b/server/__tests__/availability.test.ts
index 247513d..bc14359 100644
--- a/server/__tests__/availability.test.ts
+++ b/server/__tests__/availability.test.ts
@@ -16,6 +16,8 @@ function tenant(over: Partial = {}): Tenant {
MaxHouseSitsPerDay: null,
MaxStayNights: null,
Timezone: null,
+ ContactEmail: null,
+ ContactPhone: null,
...over,
};
}
diff --git a/server/__tests__/booking-by-pets.test.ts b/server/__tests__/booking-by-pets.test.ts
index b112e9e..d574128 100644
--- a/server/__tests__/booking-by-pets.test.ts
+++ b/server/__tests__/booking-by-pets.test.ts
@@ -27,14 +27,16 @@ describe('booking by petIds', () => {
}),
});
expect(book.status).toBe(201);
+ const { id: bookedId } = (await book.json()) as { id: string };
const mine = (await (
await req(env, '/api/sunny-paws/bookings/mine', {
headers: { Authorization: `Bearer ${token}` },
})
).json()) as { bookings: { id: string; petCount: number; pets: string[] }[] };
- expect(mine.bookings[0].petCount).toBe(2);
- expect(mine.bookings[0].pets.sort()).toEqual(['Bella', 'Mochi']);
+ const created = mine.bookings.find((b) => b.id === bookedId)!;
+ expect(created.petCount).toBe(2);
+ expect(created.pets.sort()).toEqual(['Bella', 'Mochi']);
});
it('dedupes duplicate petIds in a booking', async () => {
@@ -52,14 +54,16 @@ describe('booking by petIds', () => {
}),
});
expect(book.status).toBe(201);
+ const { id: bookedId } = (await book.json()) as { id: string };
const mine = (await (
await req(env, '/api/sunny-paws/bookings/mine', {
headers: { Authorization: `Bearer ${token}` },
})
).json()) as { bookings: { id: string; petCount: number; pets: string[] }[] };
- expect(mine.bookings[0].petCount).toBe(1);
- expect(mine.bookings[0].pets).toEqual(['Bella']);
+ const created = mine.bookings.find((b) => b.id === bookedId)!;
+ expect(created.petCount).toBe(1);
+ expect(created.pets).toEqual(['Bella']);
});
it("rejects a pet the caller doesn't own", async () => {
diff --git a/server/__tests__/isolation.test.ts b/server/__tests__/isolation.test.ts
index 0f235ae..c45ff35 100644
--- a/server/__tests__/isolation.test.ts
+++ b/server/__tests__/isolation.test.ts
@@ -27,7 +27,14 @@ describe('tenant isolation', () => {
it('READ: a booking created under tenant A never appears in tenant B queries', async () => {
const { env } = createTestEnv();
- const userA = await insertInvitedCustomer(env.PAWBOOK_DB, TENANT_A, 'jess@example.com', null);
+ // A fresh email — 'jess@example.com' is the seeded demo customer and now comes with
+ // seeded bookings under BOTH tenants.
+ const userA = await insertInvitedCustomer(
+ env.PAWBOOK_DB,
+ TENANT_A,
+ 'iso-read@example.com',
+ null,
+ );
await insertBookingRequest(env.PAWBOOK_DB, TENANT_A, {
endUserId: userA.Id,
serviceType: 'boarding',
@@ -68,8 +75,18 @@ describe('tenant isolation', () => {
it('LIST: my-bookings under the other tenant is empty for the same email', async () => {
const { env } = createTestEnv();
- const userA = await insertInvitedCustomer(env.PAWBOOK_DB, TENANT_A, 'jess@example.com', null);
- const userB = await insertInvitedCustomer(env.PAWBOOK_DB, TENANT_B, 'jess@example.com', null);
+ const userA = await insertInvitedCustomer(
+ env.PAWBOOK_DB,
+ TENANT_A,
+ 'iso-list@example.com',
+ null,
+ );
+ const userB = await insertInvitedCustomer(
+ env.PAWBOOK_DB,
+ TENANT_B,
+ 'iso-list@example.com',
+ null,
+ );
await insertBookingRequest(env.PAWBOOK_DB, TENANT_A, {
endUserId: userA.Id,
serviceType: 'walk',
diff --git a/server/db/repo.ts b/server/db/repo.ts
index 20476b3..163a6ec 100644
--- a/server/db/repo.ts
+++ b/server/db/repo.ts
@@ -23,7 +23,7 @@ import { constantTimeEqual } from '../lib/timing';
*/
const TENANT_COLS =
- 'Id, Slug, DisplayName, AccentColor, MaxBoardingPets, MaxHouseSitsPerDay, MaxStayNights, Timezone';
+ 'Id, Slug, DisplayName, AccentColor, MaxBoardingPets, MaxHouseSitsPerDay, MaxStayNights, Timezone, ContactEmail, ContactPhone';
const BOOKING_COLS =
'Id, TenantId, EndUserId, ServiceType, StartDate, EndDate, StartTime, OptionKey, PetType, PetCount, EstCost, GCalEventId, Status, CreatedAt';
@@ -311,7 +311,7 @@ export async function listBookingsForUser(
): Promise {
const { results } = await db
.prepare(
- `SELECT ${BOOKING_COLS}
+ `SELECT ${BOOKING_COLS}, Declined
FROM BookingRequests
WHERE TenantId = ? AND EndUserId = ?
ORDER BY StartDate DESC`,
@@ -332,7 +332,8 @@ export async function listBookingsForTenant(
): Promise<(BookingRow & { Email: string | null; Name: string | null })[]> {
const { results } = await db
.prepare(
- `SELECT ${BOOKING_COLS_QUALIFIED}, EndUsers.Email AS Email, EndUsers.Name AS Name
+ `SELECT ${BOOKING_COLS_QUALIFIED}, BookingRequests.Declined AS Declined,
+ EndUsers.Email AS Email, EndUsers.Name AS Name
FROM BookingRequests
LEFT JOIN EndUsers ON EndUsers.Id = BookingRequests.EndUserId
AND EndUsers.TenantId = BookingRequests.TenantId
@@ -349,21 +350,52 @@ export async function listBookingsForTenant(
* 'blocked' rows aren't real bookings (never surfaced or manageable here), and 'cancelled' is
* terminal — once cancelled, no further transition matches. Confirming an already-confirmed row
* still matches (harmless no-op). Returns whether a row actually changed.
+ *
+ * 'declined' is a sitter's "no" to a still-pending request: stored as Status 'cancelled' with the
+ * Declined flag set (the Status CHECK can't grow a value without a table rebuild), and only valid
+ * from 'pending' — a confirmed booking is cancelled, never declined.
*/
export async function updateBookingStatus(
db: D1Database,
tenantId: string,
id: string,
- status: 'confirmed' | 'cancelled',
+ status: 'confirmed' | 'cancelled' | 'declined',
): Promise {
- const result = await db
+ const result =
+ status === 'declined'
+ ? await db
+ .prepare(
+ `UPDATE BookingRequests SET Status = 'cancelled', Declined = 1
+ WHERE TenantId = ? AND Id = ? AND ServiceType != 'blocked' AND Status = 'pending'`,
+ )
+ .bind(tenantId, id)
+ .run()
+ : await db
+ .prepare(
+ `UPDATE BookingRequests SET Status = ?
+ WHERE TenantId = ? AND Id = ? AND ServiceType != 'blocked' AND Status != 'cancelled'`,
+ )
+ .bind(status, tenantId, id)
+ .run();
+ return (result.meta as { changes?: number }).changes !== 0;
+}
+
+/** One booking joined with its customer's contact details — for status-change notifications. */
+export async function getBookingWithCustomer(
+ db: D1Database,
+ tenantId: string,
+ id: string,
+): Promise<(BookingRow & { Email: string | null; Name: string | null }) | null> {
+ return await db
.prepare(
- `UPDATE BookingRequests SET Status = ?
- WHERE TenantId = ? AND Id = ? AND ServiceType != 'blocked' AND Status != 'cancelled'`,
+ `SELECT ${BOOKING_COLS_QUALIFIED}, EndUsers.Email AS Email, EndUsers.Name AS Name
+ FROM BookingRequests
+ LEFT JOIN EndUsers ON EndUsers.Id = BookingRequests.EndUserId
+ AND EndUsers.TenantId = BookingRequests.TenantId
+ WHERE BookingRequests.TenantId = ? AND BookingRequests.Id = ?`,
)
- .bind(status, tenantId, id)
- .run();
- return (result.meta as { changes?: number }).changes !== 0;
+ .bind(tenantId, id)
+ .first();
}
export async function listProviderConnections(
@@ -401,12 +433,15 @@ export async function updateTenantSettings(
maxHouseSitsPerDay: number | null;
maxStayNights: number | null;
timezone: string | null;
+ contactEmail?: string | null;
+ contactPhone?: string | null;
},
): Promise {
await db
.prepare(
`UPDATE Tenants SET DisplayName = ?, AccentColor = ?, MaxBoardingPets = ?,
- MaxHouseSitsPerDay = ?, MaxStayNights = ?, Timezone = ? WHERE Id = ?`,
+ MaxHouseSitsPerDay = ?, MaxStayNights = ?, Timezone = ?,
+ ContactEmail = ?, ContactPhone = ? WHERE Id = ?`,
)
.bind(
settings.displayName,
@@ -415,6 +450,8 @@ export async function updateTenantSettings(
settings.maxHouseSitsPerDay,
settings.maxStayNights,
settings.timezone,
+ settings.contactEmail ?? null,
+ settings.contactPhone ?? null,
tenantId,
)
.run();
@@ -627,7 +664,7 @@ export async function getEndUserById(
.first();
}
-const ENDUSER_COLS = 'Id, TenantId, Email, Name, Status, InvitedAt';
+const ENDUSER_COLS = 'Id, TenantId, Email, Name, Phone, Status, InvitedAt';
export async function getEndUserByEmail(
db: D1Database,
@@ -645,6 +682,7 @@ export async function insertInvitedCustomer(
tenantId: string,
email: string,
name: string | null,
+ phone: string | null = null,
): Promise {
const existing = await getEndUserByEmail(db, tenantId, email);
if (existing) return existing; // idempotent — never downgrade an active customer to invited
@@ -652,16 +690,17 @@ export async function insertInvitedCustomer(
const invitedAt = new Date().toISOString();
await db
.prepare(
- `INSERT INTO EndUsers (Id, TenantId, Email, Name, Status, InvitedAt)
- VALUES (?, ?, ?, ?, 'invited', ?)`,
+ `INSERT INTO EndUsers (Id, TenantId, Email, Name, Phone, Status, InvitedAt)
+ VALUES (?, ?, ?, ?, ?, 'invited', ?)`,
)
- .bind(id, tenantId, email, name, invitedAt)
+ .bind(id, tenantId, email, name, phone, invitedAt)
.run();
return {
Id: id,
TenantId: tenantId,
Email: email,
Name: name,
+ Phone: phone,
Status: 'invited',
InvitedAt: invitedAt,
};
@@ -722,7 +761,7 @@ export async function listAllEndUserPetsByTenant(
): Promise {
const { results } = await db
.prepare(
- `SELECT Id, TenantId, EndUserId, Name, PetType, CreatedAt
+ `SELECT Id, TenantId, EndUserId, Name, PetType, Notes, CreatedAt
FROM EndUserPets WHERE TenantId = ? ORDER BY EndUserId, Name`,
)
.bind(tenantId)
@@ -737,7 +776,7 @@ export async function listEndUserPets(
): Promise {
const { results } = await db
.prepare(
- `SELECT Id, TenantId, EndUserId, Name, PetType, CreatedAt
+ `SELECT Id, TenantId, EndUserId, Name, PetType, Notes, CreatedAt
FROM EndUserPets WHERE TenantId = ? AND EndUserId = ? ORDER BY Name`,
)
.bind(tenantId, endUserId)
@@ -751,17 +790,18 @@ export async function addEndUserPet(
endUserId: string,
name: string,
petType: PetType,
+ notes: string | null = null,
): Promise {
const id = crypto.randomUUID();
await db
.prepare(
- `INSERT INTO EndUserPets (Id, TenantId, EndUserId, Name, PetType) VALUES (?, ?, ?, ?, ?)`,
+ `INSERT INTO EndUserPets (Id, TenantId, EndUserId, Name, PetType, Notes) VALUES (?, ?, ?, ?, ?, ?)`,
)
- .bind(id, tenantId, endUserId, name, petType)
+ .bind(id, tenantId, endUserId, name, petType, notes)
.run();
const row = await db
.prepare(
- `SELECT Id, TenantId, EndUserId, Name, PetType, CreatedAt FROM EndUserPets WHERE TenantId = ? AND Id = ?`,
+ `SELECT Id, TenantId, EndUserId, Name, PetType, Notes, CreatedAt FROM EndUserPets WHERE TenantId = ? AND Id = ?`,
)
.bind(tenantId, id)
.first();
diff --git a/server/lib/email.ts b/server/lib/email.ts
index 4f1db7c..a4332f6 100644
--- a/server/lib/email.ts
+++ b/server/lib/email.ts
@@ -44,6 +44,26 @@ export async function sendLoginCode(env: Env, to: string, code: string): Promise
});
}
+/**
+ * Tell the customer their request was confirmed/declined or their booking cancelled.
+ * Throws if email is not configured or Resend rejects the request.
+ */
+export async function sendBookingStatusEmail(
+ env: Env,
+ to: string,
+ displayName: string,
+ statusWord: 'confirmed' | 'declined' | 'cancelled',
+ whenText: string,
+): Promise {
+ if (!isEmailConfigured(env)) throw new Error('Email is not configured.');
+ await resendPost(env, {
+ to,
+ subject: `Your booking with ${displayName} was ${statusWord}`,
+ text: `${displayName} has ${statusWord} your booking (${whenText}).`,
+ html: `
${htmlEscape(displayName)} has ${statusWord} your booking (${htmlEscape(whenText)}).
`,
+ });
+}
+
/** Send a booking invite. Throws if email is not configured or Resend rejects the request. */
export async function sendInvite(
env: Env,
diff --git a/server/routes/admin.ts b/server/routes/admin.ts
index 2822a82..c76ef44 100644
--- a/server/routes/admin.ts
+++ b/server/routes/admin.ts
@@ -5,6 +5,7 @@ import {
clearProviderConnection,
countBookingPetRefs,
countBookingsForUser,
+ getBookingWithCustomer,
getEndUserById,
deleteBlockedRange,
deleteCustomer,
@@ -27,7 +28,7 @@ import {
updateBookingStatus,
updateTenantSettings,
} from '../db/repo';
-import { isEmailConfigured, sendInvite } from '../lib/email';
+import { isEmailConfigured, sendBookingStatusEmail, sendInvite } from '../lib/email';
import { buildAuthUrl, revokeToken } from '../lib/google-calendar';
import { adminAuth } from '../lib/middleware';
import { signState } from '../lib/oauth-state';
@@ -141,9 +142,23 @@ function resolveServiceOptions(
existingKeys: Set,
): { error: string } | { resolved: ResolvedOption[] } {
const resolved: ResolvedOption[] = [];
+ // Duplicate names are the only collision a sitter should ever have to fix by hand — keys are
+ // derived plumbing and are de-duped automatically below (two same-duration options are fine).
+ const seenLabels = new Set();
+ // Keys already claimed in this payload: preserved keys up front (order-independent), fresh
+ // derivations as they're assigned.
+ const usedKeys = new Set(
+ opts.map((o) => o.optionKey).filter((k): k is string => k !== undefined && existingKeys.has(k)),
+ );
for (const o of opts) {
const label = o.label?.trim();
- if (!label) return { error: `${serviceLabel}: every option needs a label.` };
+ if (!label) return { error: `${serviceLabel}: every option needs a name.` };
+ const labelKey = label.toLowerCase();
+ if (seenLabels.has(labelKey))
+ return {
+ error: `${serviceLabel}: two options are both named “${label}” — give each option a different name.`,
+ };
+ seenLabels.add(labelKey);
if (!isValidRate(o.rate)) return { error: 'Rates must be whole dollars ≥ 1.' };
const hasStart = o.startTime !== undefined && o.startTime !== null;
@@ -176,12 +191,18 @@ function resolveServiceOptions(
? `d${durationMinutes}`
: 'standard';
const preserveExisting = o.optionKey !== undefined && existingKeys.has(o.optionKey);
- const optionKey = preserveExisting ? (o.optionKey as string) : derivedKey;
// A label that's entirely punctuation/whitespace after the non-empty check above still
// slugifies to '' (e.g. "---") — treat that the same as no usable label. Only relevant when
// a fresh key is actually being derived; a preserved key is already known-valid.
if (windowed && !preserveExisting && derivedKey === '')
- return { error: `${serviceLabel}: that label has no usable letters or numbers.` };
+ return { error: `${serviceLabel}: that name has no usable letters or numbers.` };
+ let optionKey = preserveExisting ? (o.optionKey as string) : derivedKey;
+ if (!preserveExisting) {
+ // Two options may legitimately derive the same key (e.g. two 30-minute check-ins with
+ // different names/rates) — suffix until unique instead of bouncing the save back.
+ for (let n = 2; usedKeys.has(optionKey); n++) optionKey = `${derivedKey}-${n}`;
+ usedKeys.add(optionKey);
+ }
resolved.push({
optionKey,
@@ -193,13 +214,13 @@ function resolveServiceOptions(
capacity: o.capacity ?? null,
});
}
- if (meta.hasDuration) {
- const keys = resolved.map((o) => o.optionKey);
- if (new Set(keys).size !== keys.length)
- return {
- error: `${serviceLabel}: two options have the same name — use distinct labels for each service option.`,
- };
- }
+ // Backstop only: fresh keys are de-duped above, so this can fire only when two options in the
+ // payload name the SAME saved optionKey (a stale/duplicated client state).
+ const keys = resolved.map((o) => o.optionKey);
+ if (new Set(keys).size !== keys.length)
+ return {
+ error: `${serviceLabel}: two options point at the same saved option — reload the page and try again.`,
+ };
return { resolved };
}
@@ -248,6 +269,8 @@ type SettingsBody = {
maxHouseSitsPerDay?: number | null;
maxStayNights?: number | null;
timezone?: string | null;
+ contactEmail?: string | null;
+ contactPhone?: string | null;
petTypes?: string[];
services?: ServiceBody[];
};
@@ -259,7 +282,13 @@ type SettingsBody = {
*/
function patchNullable(
body: SettingsBody,
- key: 'maxBoardingPets' | 'maxHouseSitsPerDay' | 'maxStayNights' | 'timezone',
+ key:
+ | 'maxBoardingPets'
+ | 'maxHouseSitsPerDay'
+ | 'maxStayNights'
+ | 'timezone'
+ | 'contactEmail'
+ | 'contactPhone',
current: T | null,
): T | null {
return key in body ? ((body[key] as T | null | undefined) ?? null) : current;
@@ -285,6 +314,8 @@ export const adminRoutes = new Hono()
maxHouseSitsPerDay: tenant.MaxHouseSitsPerDay,
maxStayNights: tenant.MaxStayNights,
timezone: tenant.Timezone,
+ contactEmail: tenant.ContactEmail,
+ contactPhone: tenant.ContactPhone,
petTypes: PET_TYPES.map((pt) => ({
petType: pt,
enabled: petTypes.some((p) => p.PetType === pt && p.Enabled),
@@ -337,6 +368,11 @@ export const adminRoutes = new Hono()
);
const maxStayNights = patchNullable(body, 'maxStayNights', tenant.MaxStayNights);
const timezone = patchNullable(body, 'timezone', tenant.Timezone);
+ // Whitespace-only contact fields mean "cleared" — store NULL, not ''.
+ const rawContactEmail = patchNullable(body, 'contactEmail', tenant.ContactEmail);
+ const contactEmail = rawContactEmail?.trim() || null;
+ const rawContactPhone = patchNullable(body, 'contactPhone', tenant.ContactPhone);
+ const contactPhone = rawContactPhone?.trim() || null;
const petTypes = body.petTypes;
const services = body.services ?? [];
// Per-service PATCH semantics for questions/constraints (mirrors patchNullable above): a field
@@ -374,6 +410,10 @@ export const adminRoutes = new Hono()
400,
);
if (!isValidTimezone(timezone)) return c.json({ error: 'Unknown timezone.' }, 400);
+ if (contactEmail !== null && !EMAIL_RE.test(contactEmail))
+ return c.json({ error: 'Contact email must be a valid email address.' }, 400);
+ if (contactPhone !== null && contactPhone.length > 40)
+ return c.json({ error: 'Contact phone is too long.' }, 400);
if (petTypes !== undefined) {
if (!Array.isArray(petTypes) || !petTypes.every(isPetType))
return c.json({ error: 'Unknown pet type.' }, 400);
@@ -427,6 +467,8 @@ export const adminRoutes = new Hono()
maxHouseSitsPerDay,
maxStayNights,
timezone,
+ contactEmail,
+ contactPhone,
});
if (petTypes !== undefined) {
for (const pt of PET_TYPES)
@@ -489,7 +531,7 @@ export const adminRoutes = new Hono()
const start = typeof body.startDate === 'string' ? body.startDate : '';
const end = typeof body.endDate === 'string' ? body.endDate : '';
if (!isRealDate(start) || !isRealDate(end) || end <= start)
- return c.json({ error: 'Provide a valid range (end is exclusive).' }, 400);
+ return c.json({ error: 'Provide a valid date range.' }, 400);
const id = await insertBookingRequest(c.env.PAWBOOK_DB, tenant.Id, {
endUserId: null,
serviceType: 'blocked',
@@ -570,16 +612,20 @@ export const adminRoutes = new Hono()
listCustomers(c.env.PAWBOOK_DB, tenant.Id),
listAllEndUserPetsByTenant(c.env.PAWBOOK_DB, tenant.Id),
]);
- const byUser = new Map();
+ const byUser = new Map<
+ string,
+ { id: string; name: string; petType: string; notes: string | null }[]
+ >();
for (const p of allPets) {
const list = byUser.get(p.EndUserId) ?? [];
- list.push({ id: p.Id, name: p.Name, petType: p.PetType });
+ list.push({ id: p.Id, name: p.Name, petType: p.PetType, notes: p.Notes });
byUser.set(p.EndUserId, list);
}
const withPets = customers.map((u) => ({
id: u.Id,
email: u.Email,
name: u.Name,
+ phone: u.Phone,
status: u.Status,
invitedAt: u.InvitedAt,
pets: byUser.get(u.Id) ?? [],
@@ -590,14 +636,17 @@ export const adminRoutes = new Hono()
.post('/:slug/admin/customers', async (c) => {
const tenant = c.get('tenant');
const body = await c.req
- .json<{ email?: unknown; name?: unknown }>()
- .catch(() => ({}) as { email?: unknown; name?: unknown });
+ .json<{ email?: unknown; name?: unknown; phone?: unknown }>()
+ .catch(() => ({}) as { email?: unknown; name?: unknown; phone?: unknown });
const email = typeof body.email === 'string' ? body.email.trim().toLowerCase() : '';
const rawName = typeof body.name === 'string' ? body.name.trim() : '';
const name = rawName || null;
+ const rawPhone = typeof body.phone === 'string' ? body.phone.trim() : '';
+ const phone = rawPhone || null;
if (!EMAIL_RE.test(email)) return c.json({ error: 'Enter a valid email.' }, 400);
+ if (phone !== null && phone.length > 40) return c.json({ error: 'Phone is too long.' }, 400);
- const customer = await insertInvitedCustomer(c.env.PAWBOOK_DB, tenant.Id, email, name);
+ const customer = await insertInvitedCustomer(c.env.PAWBOOK_DB, tenant.Id, email, name, phone);
// Only send the invite for a freshly-invited customer — skip if the customer is already active
// (a re-POST of an existing active customer must not send a confusing "you're invited" email).
@@ -610,7 +659,13 @@ export const adminRoutes = new Hono()
}
}
return c.json(
- { id: customer.Id, email: customer.Email, name: customer.Name, status: customer.Status },
+ {
+ id: customer.Id,
+ email: customer.Email,
+ name: customer.Name,
+ phone: customer.Phone,
+ status: customer.Status,
+ },
201,
);
})
@@ -628,12 +683,15 @@ export const adminRoutes = new Hono()
const tenant = c.get('tenant');
const endUserId = c.req.param('id');
const body = await c.req
- .json<{ name?: unknown; petType?: unknown }>()
- .catch(() => ({}) as { name?: unknown; petType?: unknown });
+ .json<{ name?: unknown; petType?: unknown; notes?: unknown }>()
+ .catch(() => ({}) as { name?: unknown; petType?: unknown; notes?: unknown });
const name = typeof body.name === 'string' ? body.name.trim() : '';
const petType = body.petType;
+ const rawNotes = typeof body.notes === 'string' ? body.notes.trim() : '';
+ const notes = rawNotes || null;
if (!name) return c.json({ error: 'Enter a pet name.' }, 400);
if (!isPetType(petType)) return c.json({ error: 'Unknown pet type.' }, 400);
+ if (notes !== null && notes.length > 2000) return c.json({ error: 'Notes are too long.' }, 400);
// The customer id comes from the URL; confirm it belongs to this tenant before writing a pet
// under it (production D1 has foreign keys OFF, so nothing else stops a cross-tenant orphan).
if (!(await getEndUserById(c.env.PAWBOOK_DB, tenant.Id, endUserId)))
@@ -642,8 +700,8 @@ export const adminRoutes = new Hono()
(pt) => pt.PetType === petType && pt.Enabled,
);
if (!accepted) return c.json({ error: 'That pet type is not accepted.' }, 400);
- const pet = await addEndUserPet(c.env.PAWBOOK_DB, tenant.Id, endUserId, name, petType);
- return c.json({ id: pet.Id, name: pet.Name, petType: pet.PetType }, 201);
+ const pet = await addEndUserPet(c.env.PAWBOOK_DB, tenant.Id, endUserId, name, petType, notes);
+ return c.json({ id: pet.Id, name: pet.Name, petType: pet.PetType, notes: pet.Notes }, 201);
})
.delete('/:slug/admin/customers/:id/pets/:petId', async (c) => {
const tenant = c.get('tenant');
@@ -669,7 +727,7 @@ export const adminRoutes = new Hono()
optionKey: r.OptionKey,
petCount: r.PetCount,
estCost: r.EstCost,
- status: r.Status,
+ status: r.Declined ? 'declined' : r.Status,
createdAt: r.CreatedAt,
})),
});
@@ -679,8 +737,8 @@ export const adminRoutes = new Hono()
const tenant = c.get('tenant');
const body = await c.req.json<{ status?: unknown }>().catch(() => ({}) as { status?: unknown });
const status = body.status;
- if (status !== 'confirmed' && status !== 'cancelled')
- return c.json({ error: "Status must be 'confirmed' or 'cancelled'." }, 400);
+ if (status !== 'confirmed' && status !== 'cancelled' && status !== 'declined')
+ return c.json({ error: "Status must be 'confirmed', 'declined', or 'cancelled'." }, 400);
// ponytail: cancel leaves any synced GCal event in place; delete via GCalEventId if sitters complain
const updated = await updateBookingStatus(
c.env.PAWBOOK_DB,
@@ -689,5 +747,23 @@ export const adminRoutes = new Hono()
status,
);
if (!updated) return c.json({ error: 'Not found.' }, 404);
- return c.json({ status });
+
+ // Best-effort customer notification; `notified` lets the dashboard tell the sitter honestly
+ // whether the client heard about it (false when email isn't configured or the send failed).
+ let notified = false;
+ if (isEmailConfigured(c.env)) {
+ const booking = await getBookingWithCustomer(c.env.PAWBOOK_DB, tenant.Id, c.req.param('id'));
+ if (booking?.Email) {
+ const whenText = booking.EndDate
+ ? `${booking.StartDate} – ${booking.EndDate}`
+ : booking.StartDate;
+ try {
+ await sendBookingStatusEmail(c.env, booking.Email, tenant.DisplayName, status, whenText);
+ notified = true;
+ } catch {
+ /* status change stands; the dashboard reports the client was not emailed */
+ }
+ }
+ }
+ return c.json({ status, notified });
});
diff --git a/server/routes/bookings.ts b/server/routes/bookings.ts
index ba2bf74..5b07c65 100644
--- a/server/routes/bookings.ts
+++ b/server/routes/bookings.ts
@@ -227,7 +227,7 @@ export const bookingRoutes = new Hono()
petCount: r.PetCount,
pets: petsByBooking.get(r.Id) ?? [],
estCost: r.EstCost,
- status: r.Status,
+ status: r.Declined ? 'declined' : r.Status,
})),
});
});
diff --git a/server/routes/public.ts b/server/routes/public.ts
index 677897c..9dbe146 100644
--- a/server/routes/public.ts
+++ b/server/routes/public.ts
@@ -22,6 +22,8 @@ export const publicRoutes = new Hono()
maxHouseSitsPerDay: tenant.MaxHouseSitsPerDay,
maxStayNights: tenant.MaxStayNights,
timezone: tenant.Timezone,
+ contactEmail: tenant.ContactEmail,
+ contactPhone: tenant.ContactPhone,
petTypes: petTypes.filter((p) => p.Enabled).map((p) => p.PetType),
services: [...enabled].map((type) => {
const svc = services.find((s) => s.ServiceType === type)!;
diff --git a/server/types.ts b/server/types.ts
index 1172363..2f0ab11 100644
--- a/server/types.ts
+++ b/server/types.ts
@@ -12,6 +12,8 @@ export type Tenant = {
MaxHouseSitsPerDay: number | null; // null = unlimited
MaxStayNights: number | null; // null = unlimited
Timezone: string | null; // null = DEFAULT_TIMEZONE
+ ContactEmail: string | null; // shown to clients in the booking widget
+ ContactPhone: string | null; // shown to clients in the booking widget
};
export type TenantUser = {
@@ -57,6 +59,7 @@ export type EndUser = {
TenantId: string;
Email: string;
Name: string | null;
+ Phone: string | null;
Status: 'invited' | 'active';
InvitedAt: string | null;
};
@@ -67,6 +70,7 @@ export type EndUserPet = {
EndUserId: string;
Name: string;
PetType: 'dog' | 'cat';
+ Notes: string | null; // sitter's care notes (feeding, meds, temperament)
CreatedAt: string;
};
@@ -84,6 +88,9 @@ export type BookingRow = {
GCalEventId: string | null;
EstCost: number | null;
Status: 'pending' | 'confirmed' | 'cancelled';
+ // 1 = the cancellation was the sitter declining a pending request. Optional because only the
+ // booking-list queries select it; capacity/availability queries never need it.
+ Declined?: number;
CreatedAt: string;
};
diff --git a/sql/schema.sql b/sql/schema.sql
index dbe4139..7356949 100644
--- a/sql/schema.sql
+++ b/sql/schema.sql
@@ -11,6 +11,9 @@ CREATE TABLE IF NOT EXISTS Tenants (
MaxHouseSitsPerDay INTEGER,
MaxStayNights INTEGER,
Timezone TEXT,
+ -- Optional contact details shown to clients in the booking widget.
+ ContactEmail TEXT,
+ ContactPhone TEXT,
CreatedAt TEXT NOT NULL DEFAULT (datetime('now'))
);
@@ -73,6 +76,7 @@ CREATE TABLE IF NOT EXISTS EndUsers (
TenantId TEXT NOT NULL REFERENCES Tenants(Id),
Email TEXT NOT NULL,
Name TEXT,
+ Phone TEXT,
InvitedAt TEXT,
Status TEXT NOT NULL DEFAULT 'active' CHECK (Status IN ('invited', 'active')),
CreatedAt TEXT NOT NULL DEFAULT (datetime('now')),
@@ -107,6 +111,9 @@ CREATE TABLE IF NOT EXISTS BookingRequests (
EstCost INTEGER,
Answers TEXT NOT NULL DEFAULT '{}', -- JSON {questionId: answer}; questions defined on TenantServices
Status TEXT NOT NULL DEFAULT 'pending' CHECK (Status IN ('pending', 'confirmed', 'cancelled')),
+ -- 1 when a pending request was declined by the sitter (stored as Status 'cancelled' + this
+ -- flag; widening the CHECK above would require a table rebuild on existing databases).
+ Declined INTEGER NOT NULL DEFAULT 0,
CreatedAt TEXT NOT NULL DEFAULT (datetime('now'))
);
@@ -121,6 +128,7 @@ CREATE TABLE IF NOT EXISTS EndUserPets (
EndUserId TEXT NOT NULL REFERENCES EndUsers(Id),
Name TEXT NOT NULL,
PetType TEXT NOT NULL CHECK (PetType IN ('dog', 'cat')),
+ Notes TEXT, -- care notes the sitter keeps (feeding, meds, temperament)
CreatedAt TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_EndUserPets_Tenant_User ON EndUserPets (TenantId, EndUserId);
diff --git a/sql/seed.sql b/sql/seed.sql
index 822553c..7f5618c 100644
--- a/sql/seed.sql
+++ b/sql/seed.sql
@@ -65,27 +65,34 @@ INSERT OR REPLACE INTO TenantPetTypes (TenantId, PetType, Enabled) VALUES
-- Demo customers. Invite-only gating means /identify only succeeds for known customers, so the
-- demo widget (and the existing identify/booking tests) need a seeded, already-active customer.
-INSERT OR REPLACE INTO EndUsers (Id, TenantId, Email, Name, Status) VALUES
- ('eu_sp_jess', 'tnt_sunnypaws', 'jess@example.com', 'Jess Demo', 'active'),
- ('eu_ht_jess', 'tnt_happytails', 'jess@example.com', 'Jess Demo', 'active'),
- ('eu_pr_jess', 'tnt_pawsandrelax', 'jess@example.com', 'Jess Demo', 'active');
+INSERT OR REPLACE INTO EndUsers (Id, TenantId, Email, Name, Phone, Status) VALUES
+ ('eu_sp_jess', 'tnt_sunnypaws', 'jess@example.com', 'Jess Demo', '(555) 555-0142', 'active'),
+ ('eu_ht_jess', 'tnt_happytails', 'jess@example.com', 'Jess Demo', '(555) 555-0142', 'active'),
+ ('eu_pr_jess', 'tnt_pawsandrelax', 'jess@example.com', 'Jess Demo', NULL, 'active');
-- Demo pets (sitter-managed). Jess has two at Sunny Paws (dogs+cats), one at Happy Tails (dogs only).
-INSERT OR REPLACE INTO EndUserPets (Id, TenantId, EndUserId, Name, PetType) VALUES
- ('pet_sp_bella', 'tnt_sunnypaws', 'eu_sp_jess', 'Bella', 'dog'),
- ('pet_sp_mochi', 'tnt_sunnypaws', 'eu_sp_jess', 'Mochi', 'cat'),
- ('pet_ht_otis', 'tnt_happytails', 'eu_ht_jess', 'Otis', 'dog');
+INSERT OR REPLACE INTO EndUserPets (Id, TenantId, EndUserId, Name, PetType, Notes) VALUES
+ ('pet_sp_bella', 'tnt_sunnypaws', 'eu_sp_jess', 'Bella', 'dog', 'Allergic to chicken — no chicken treats. Pulls on the leash near squirrels.'),
+ ('pet_sp_mochi', 'tnt_sunnypaws', 'eu_sp_jess', 'Mochi', 'cat', NULL),
+ ('pet_ht_otis', 'tnt_happytails', 'eu_ht_jess', 'Otis', 'dog', 'Deaf in one ear; approach from the front.');
--- Existing bookings so availability looks real.
+-- Existing bookings so availability looks real, tied to the demo customer so the admin list
+-- never shows an anonymous "Unknown customer" row.
-- Sunny Paws (max 2 pets): June 20-25 already has 1 pet boarding -> 1 slot left.
-- Happy Tails (max 4 pets): June 20-25 has 2 pets boarding -> 2 slots left.
-- Both tenants blocked July 3-5 (exclusive end: blocked days are Jul 3 and Jul 4).
INSERT OR REPLACE INTO BookingRequests (Id, TenantId, EndUserId, ServiceType, StartDate, EndDate, PetCount, EstCost, Status) VALUES
- ('seed_sp_board1', 'tnt_sunnypaws', NULL, 'boarding', '2028-06-20', '2028-06-25', 1, 250, 'confirmed'),
+ ('seed_sp_board1', 'tnt_sunnypaws', 'eu_sp_jess', 'boarding', '2028-06-20', '2028-06-25', 1, 250, 'confirmed'),
('seed_sp_block1', 'tnt_sunnypaws', NULL, 'blocked', '2028-07-03', '2028-07-05', 1, NULL, 'confirmed'),
- ('seed_ht_board1', 'tnt_happytails', NULL, 'boarding', '2028-06-20', '2028-06-25', 2, 400, 'confirmed'),
+ ('seed_ht_board1', 'tnt_happytails', 'eu_ht_jess', 'boarding', '2028-06-20', '2028-06-25', 2, 400, 'confirmed'),
('seed_ht_block1', 'tnt_happytails', NULL, 'blocked', '2028-07-03', '2028-07-05', 1, NULL, 'confirmed');
+-- Pending requests so the admin "Needs your reply" list has real work in it on a fresh seed.
+INSERT OR REPLACE INTO BookingRequests (Id, TenantId, EndUserId, ServiceType, StartDate, EndDate, OptionKey, PetType, PetCount, StartTime, EstCost, Status) VALUES
+ ('seed_sp_pend1', 'tnt_sunnypaws', 'eu_sp_jess', 'walk', '2026-07-10', NULL, 'd30', 'dog', 1, '09:00', 20, 'pending'),
+ ('seed_sp_pend2', 'tnt_sunnypaws', 'eu_sp_jess', 'boarding', '2026-07-20', '2026-07-23', NULL, 'dog', 1, NULL, 150, 'pending'),
+ ('seed_ht_pend1', 'tnt_happytails', 'eu_ht_jess', 'walk', '2026-07-12', NULL, 'd60', 'dog', 1, '15:00', 40, 'pending');
+
INSERT OR REPLACE INTO ProviderConnections (Id, TenantId, Capability, Provider, Status) VALUES
('seed_sp_cal', 'tnt_sunnypaws', 'calendar', 'google-calendar', 'disconnected'),
('seed_sp_crm', 'tnt_sunnypaws', 'crm', 'notion', 'disconnected'),