Built No-show flag and block: Two new numbers under Booking Rules (blank means off). A symbol appears on the clients list, client page, calendar entries and appointment pages. Online booking is refused at the block number, and customers are told at the details step. Staff can still book a blocked customer in, and an owner can reset someone with a two-tap button. The count resets when they attend, and matches the same email or phone, so a guest can’t dodge it with a new profile. Email rules: staff and owners get email only for customer cancellations, schedule changes and invitations. Routine events (new booking, reschedule) are push-only. Nobody is told about their own action. A barber reassignment sends one customer email instead of two. Reward expiry sends one warning. Email redesign: your brand colour, a details card, Add to calendar and View on map links, a preferences link, hidden preview text, and dark mode. Replies now go to the business’s own email. WhatsApp “due now” queue: a list in time order with a highlighted next one, an owner setting for how many hours ahead (12–72), and a once-a-day push telling each barber how many reminders are waiting. Monitoring: /api/health now reports whether notifications are actually going out. The worker resets stuck deliveries and alerts the owner on the first setup failure (such as a bad SMTP login). The app emails your operator address about server errors, and the manager pushes that address to each business. Google scope: now only calendar.app.created. Events go to a calendar the app creates in the staff member’s Google account, called “<Business> bookings”.
This commit is contained in:
116 files changed
+3089
-766
No files matched your search
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createAlertLimiter } from "../alert-limiter";
|
||||
|
||||
describe("createAlertLimiter", () => {
|
||||
const hour = 3_600_000;
|
||||
|
||||
it("sends the first alert and holds the same one back for the cooldown", () => {
|
||||
const limiter = createAlertLimiter({ cooldownMs: hour, maxPerDay: 20 });
|
||||
expect(limiter.shouldSend("boom", 0)).toBe(true);
|
||||
expect(limiter.shouldSend("boom", 30 * 60_000)).toBe(false);
|
||||
expect(limiter.shouldSend("boom", hour)).toBe(true);
|
||||
});
|
||||
|
||||
it("treats different errors independently", () => {
|
||||
const limiter = createAlertLimiter({ cooldownMs: hour, maxPerDay: 20 });
|
||||
expect(limiter.shouldSend("a", 0)).toBe(true);
|
||||
expect(limiter.shouldSend("b", 1)).toBe(true);
|
||||
});
|
||||
|
||||
it("caps how many alerts one day can produce, then starts over the next day", () => {
|
||||
const limiter = createAlertLimiter({ cooldownMs: 0, maxPerDay: 3 });
|
||||
expect([1, 2, 3, 4, 5].map((n) => limiter.shouldSend(`e${n}`, n))).toEqual([
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
]);
|
||||
expect(limiter.shouldSend("e6", 25 * hour)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
vi.mock("@/services/notifications/providers", () => ({ getEmailProvider: vi.fn() }));
|
||||
|
||||
import { buildErrorAlert, isIgnorableError } from "../report-error";
|
||||
|
||||
describe("isIgnorableError", () => {
|
||||
it("ignores redirects, not-found and dropped connections, but not real failures", () => {
|
||||
expect(isIgnorableError(new Error("NEXT_REDIRECT"))).toBe(true);
|
||||
expect(isIgnorableError(new Error("NEXT_NOT_FOUND"))).toBe(true);
|
||||
expect(isIgnorableError(new Error("socket hang up"))).toBe(true);
|
||||
expect(isIgnorableError(new Error("Cannot read properties of undefined"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildErrorAlert", () => {
|
||||
const request = { path: "/owner/calendar?token=secret-value&date=2026-10-20", method: "GET" };
|
||||
|
||||
it("names the business and route and never includes the query string", () => {
|
||||
const alert = buildErrorAlert({
|
||||
business: "Blake Heritage",
|
||||
error: new Error("db exploded"),
|
||||
request,
|
||||
});
|
||||
expect(alert.subject).toBe("[Blake Heritage] Server error on /owner/calendar");
|
||||
expect(alert.text).toContain("db exploded");
|
||||
expect(alert.text).not.toContain("secret-value");
|
||||
});
|
||||
|
||||
it("dedupes on the digest when there is one, otherwise on route and message", () => {
|
||||
const withDigest = Object.assign(new Error("x"), { digest: "abc123" });
|
||||
expect(buildErrorAlert({ business: "B", error: withDigest, request }).key).toBe("abc123");
|
||||
expect(buildErrorAlert({ business: "B", error: new Error("x"), request }).key).toBe(
|
||||
"/owner/calendar:x",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Keeps operator alert emails useful: the same problem is reported once per
|
||||
* cooldown (not once per request while it's broken), and a runaway failure
|
||||
* can't send more than a fixed number of emails a day. In-memory and
|
||||
* per-process, like the app's other rate limiter — a restart resets it, which
|
||||
* at worst re-sends one alert.
|
||||
*/
|
||||
|
||||
export interface AlertLimiterOptions {
|
||||
cooldownMs: number;
|
||||
maxPerDay: number;
|
||||
}
|
||||
|
||||
export function createAlertLimiter(options: AlertLimiterOptions) {
|
||||
const lastSent = new Map<string, number>();
|
||||
let dayStart = 0;
|
||||
let sentToday = 0;
|
||||
|
||||
return {
|
||||
/** True when an alert with this key should go out now — and records it as sent. */
|
||||
shouldSend(key: string, now: number = Date.now()): boolean {
|
||||
if (now - dayStart >= 24 * 3_600_000) {
|
||||
dayStart = now;
|
||||
sentToday = 0;
|
||||
}
|
||||
if (sentToday >= options.maxPerDay) return false;
|
||||
const previous = lastSent.get(key);
|
||||
if (previous !== undefined && now - previous < options.cooldownMs) return false;
|
||||
|
||||
lastSent.set(key, now);
|
||||
sentToday += 1;
|
||||
// Bound memory: drop entries that have long since stopped mattering.
|
||||
if (lastSent.size > 500) {
|
||||
for (const [k, t] of lastSent) if (now - t >= options.cooldownMs) lastSent.delete(k);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import "server-only";
|
||||
import { createAlertLimiter } from "./alert-limiter";
|
||||
import { getEmailProvider } from "@/services/notifications/providers";
|
||||
|
||||
/**
|
||||
* Server-side error reporting for Next's `onRequestError` hook (see
|
||||
* src/instrumentation.ts). Every error is logged with its digest — the id
|
||||
* the browser's error page can show — and, when OPERATOR_ALERT_EMAIL is set
|
||||
* for this deployment, emailed to the operator, de-duplicated so one broken
|
||||
* page produces one email an hour, not one per visitor.
|
||||
*
|
||||
* The email carries only: which business, the route, the method, the error
|
||||
* message and the first lines of its stack. Never request headers, cookies,
|
||||
* bodies or the query string, which can hold tokens and personal data.
|
||||
*/
|
||||
|
||||
const limiter = createAlertLimiter({ cooldownMs: 60 * 60_000, maxPerDay: 20 });
|
||||
|
||||
export interface ReportedRequest {
|
||||
path: string;
|
||||
method: string;
|
||||
}
|
||||
|
||||
/** Errors that are control flow or a visitor going away, not something to wake anyone for. */
|
||||
export function isIgnorableError(error: unknown): boolean {
|
||||
const text = error instanceof Error ? `${error.name} ${error.message}` : String(error);
|
||||
return /NEXT_REDIRECT|NEXT_NOT_FOUND|NEXT_HTTP_ERROR_FALLBACK|aborted|ECONNRESET|socket hang up/i.test(
|
||||
text,
|
||||
);
|
||||
}
|
||||
|
||||
export function buildErrorAlert(params: {
|
||||
business: string;
|
||||
error: unknown;
|
||||
request: ReportedRequest;
|
||||
routePath?: string;
|
||||
}): { subject: string; text: string; key: string } {
|
||||
const error = params.error instanceof Error ? params.error : new Error(String(params.error));
|
||||
const digestValue = (error as { digest?: unknown }).digest;
|
||||
const digest = typeof digestValue === "string" ? digestValue : undefined;
|
||||
const route = params.routePath ?? params.request.path.split("?")[0] ?? "";
|
||||
const stack = (error.stack ?? "").split("\n").slice(0, 12).join("\n");
|
||||
|
||||
return {
|
||||
subject: `[${params.business}] Server error on ${route}`,
|
||||
key: digest ?? `${route}:${error.message}`.slice(0, 200),
|
||||
text: [
|
||||
`A server error happened on ${params.business}.`,
|
||||
"",
|
||||
`Route: ${route}`,
|
||||
`Method: ${params.request.method}`,
|
||||
`Error: ${error.message}`,
|
||||
...(digest ? [`Digest: ${digest} (also shown to the visitor on the error page)`] : []),
|
||||
"",
|
||||
"Stack (first lines):",
|
||||
stack,
|
||||
"",
|
||||
"You'll get at most one email per hour for the same error.",
|
||||
].join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
export async function reportServerError(
|
||||
error: unknown,
|
||||
request: ReportedRequest,
|
||||
context: { routePath?: string },
|
||||
): Promise<void> {
|
||||
if (isIgnorableError(error)) return;
|
||||
|
||||
const business = process.env.BUSINESS_NAME?.trim() || process.env.DOMAIN?.trim() || "NextChair";
|
||||
const alert = buildErrorAlert({ business, error, request, routePath: context.routePath });
|
||||
console.error(
|
||||
`[server-error] ${alert.subject} — ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
|
||||
const to = process.env.OPERATOR_ALERT_EMAIL?.trim();
|
||||
if (!to || !limiter.shouldSend(alert.key)) return;
|
||||
|
||||
try {
|
||||
await getEmailProvider().sendEmail({
|
||||
to: { email: to },
|
||||
sender: { name: `${business} alerts` },
|
||||
subject: alert.subject,
|
||||
html: `<pre style="font:13px/1.5 monospace;white-space:pre-wrap;">${alert.text.replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" })[c]!)}</pre>`,
|
||||
text: alert.text,
|
||||
});
|
||||
} catch {
|
||||
// An alert that can't be sent must never turn into a second error.
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user