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
+13
-14
@@ -30,7 +30,7 @@ import { stackDir, stackExists } from "./stacks";
|
||||
*/
|
||||
|
||||
export const RESTORE_STEPS = [
|
||||
{ id: "verify", label: "Verify the archive and passphrase" },
|
||||
{ id: "verify", label: "Verify the archive" },
|
||||
{ id: "check", label: "Check for conflicts and schema version" },
|
||||
{ id: "safety", label: "Take a safety backup of the current business" },
|
||||
{ id: "stop", label: "Stop the current stack" },
|
||||
@@ -61,15 +61,15 @@ export interface RestorePlan {
|
||||
schema: { ok: true } | { ok: false; reason: string };
|
||||
}
|
||||
|
||||
/** Fast: opens only the (encrypted) header and reads manifest.json. A wrong passphrase fails here. Does not verify the payload. */
|
||||
export async function readArchiveManifest(archivePath: string, passphrase: string): Promise<BackupManifest> {
|
||||
return parseManifest(await sevenZipReadMember(archivePath, passphrase, BACKUP_MEMBERS.manifest));
|
||||
/** Fast: reads manifest.json from the archive. Does not verify the payload. */
|
||||
export async function readArchiveManifest(archivePath: string): Promise<BackupManifest> {
|
||||
return parseManifest(await sevenZipReadMember(archivePath, BACKUP_MEMBERS.manifest));
|
||||
}
|
||||
|
||||
/** Full verification (`7zz t` over the whole archive) plus the manifest. Throws on a corrupt archive or wrong passphrase. */
|
||||
export async function inspectArchive(archivePath: string, passphrase: string): Promise<BackupManifest> {
|
||||
await sevenZipTest(archivePath, passphrase);
|
||||
return readArchiveManifest(archivePath, passphrase);
|
||||
/** Full verification (`7zz t` over the whole archive) plus the manifest. Throws on a corrupt archive. */
|
||||
export async function inspectArchive(archivePath: string): Promise<BackupManifest> {
|
||||
await sevenZipTest(archivePath);
|
||||
return readArchiveManifest(archivePath);
|
||||
}
|
||||
|
||||
/** Read-only conflict/version report shown on the Restore page before the operator confirms. */
|
||||
@@ -104,17 +104,16 @@ export interface RestoreResult {
|
||||
|
||||
export async function performRestore(params: {
|
||||
archivePath: string;
|
||||
passphrase: string;
|
||||
/** Operator confirmed overwriting an existing business with the same slug. */
|
||||
replace: boolean;
|
||||
ctx: RestoreContext;
|
||||
}): Promise<RestoreResult> {
|
||||
const { archivePath, passphrase, replace, ctx } = params;
|
||||
const { archivePath, replace, ctx } = params;
|
||||
|
||||
const { manifest, envContent } = await ctx.step("verify", async () => {
|
||||
ctx.log("Testing the archive (7zz t)…");
|
||||
const parsed = await inspectArchive(archivePath, passphrase);
|
||||
const env = await sevenZipReadMember(archivePath, passphrase, BACKUP_MEMBERS.env);
|
||||
const parsed = await inspectArchive(archivePath);
|
||||
const env = await sevenZipReadMember(archivePath, BACKUP_MEMBERS.env);
|
||||
ctx.log(`Backup of "${parsed.slug}" (${parsed.domain}) taken ${parsed.createdAt}.`);
|
||||
|
||||
// The .env inside the archive must agree with the manifest — the
|
||||
@@ -169,7 +168,7 @@ export async function performRestore(params: {
|
||||
|
||||
await ctx.step("import", async () => {
|
||||
ctx.log("Importing db.sql (streamed from the archive)…");
|
||||
await runPipeline(sevenZipExtractToStdoutCommand(archivePath, passphrase, BACKUP_MEMBERS.database), mysqlImportCommand(manifest.databaseName));
|
||||
await runPipeline(sevenZipExtractToStdoutCommand(archivePath, BACKUP_MEMBERS.database), mysqlImportCommand(manifest.databaseName));
|
||||
});
|
||||
|
||||
await ctx.step("config", async () => {
|
||||
@@ -183,7 +182,7 @@ export async function performRestore(params: {
|
||||
await ctx.step("media", async () => {
|
||||
// Create (don't start) the container so the named volume exists with compose's labels before it's filled.
|
||||
await composeCreate(dir);
|
||||
await runPipeline(sevenZipExtractToStdoutCommand(archivePath, passphrase, BACKUP_MEMBERS.media), mediaRestoreCommand(slug));
|
||||
await runPipeline(sevenZipExtractToStdoutCommand(archivePath, BACKUP_MEMBERS.media), mediaRestoreCommand(slug));
|
||||
ctx.log("Media restored and handed to uid 1001.");
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user