datewise/app/routes/app.bookings.$id.tsx
metatroncubeswdev c102594ca5 feat(security): log staff access to customers' personal data
DataAccessLog records bookings list/view/export and run-sheet views (actor, action,
target, time); pruned after 12 months by the worker; migration 0003.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:18:44 -04:00

155 lines
7.5 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router";
import { Form, useActionData, useLoaderData, useNavigation } from "react-router";
import prisma from "../db.server";
import { adminContext, formBool, formString } from "../lib/admin.server";
import { logAccess } from "../lib/audit.server";
import { METHOD_LABEL, STATUS_TONE, money, niceDate, slotLabel } from "../lib/format";
import { rescheduleBooking, rescheduleOptions } from "../services/bookings.server";
import type { Method } from "../engine/types";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const { cfg, actor } = await adminContext(request);
logAccess(cfg.shop.id, actor, "booking.view", params.id ?? null);
const b = await prisma.booking.findFirst({
where: { id: params.id, shopId: cfg.shop.id },
include: { events: { orderBy: { at: "desc" } }, reminderJobs: { orderBy: { sendAt: "asc" } } },
});
if (!b) throw new Response("Not found", { status: 404 });
const options = ["confirmed", "needs_review"].includes(b.status) ? await rescheduleOptions(cfg.shop.domain, b.id, { customer: false }) : [];
return {
b: {
...b,
startUtc: b.startUtc?.toISOString() ?? null,
createdAt: b.createdAt.toISOString(),
events: b.events.map((e) => ({ ...e, at: e.at.toISOString() })),
reminderJobs: b.reminderJobs.map((j) => ({ id: j.id, channel: j.channel, status: j.status, sendAt: j.sendAt.toISOString(), lastError: j.lastError })),
},
location: cfg.locations.find((l) => l.id === b.locationId)?.name ?? null,
options: options.slice(0, 400),
currency: cfg.shop.currency,
orderUrl: b.orderId ? `shopify://admin/orders/${b.orderId.split("/").pop()}` : null,
};
};
export const action = async ({ request, params }: ActionFunctionArgs) => {
const { cfg, actor } = await adminContext(request);
const fd = await request.formData();
const intent = formString(fd, "intent");
if (intent === "dismiss") {
await prisma.booking.updateMany({ where: { id: params.id, shopId: cfg.shop.id, status: "confirmed" }, data: { reviewReason: null } });
return { ok: true, message: "Marked as checked" };
}
const [locationId, method, date, slot] = formString(fd, "target").split("|");
if (!slot) return { ok: false, message: "Choose a new time" };
const r = await rescheduleBooking({
domain: cfg.shop.domain,
bookingId: params.id!,
sel: { locationId, method: method as Method, date, slot },
actor,
force: formBool(fd, "force"),
});
return r.ok ? { ok: true, message: "Booking moved. Reminders were re-planned." } : { ok: false, message: r.message };
};
export default function BookingDetail() {
const { b, location, options, currency, orderUrl } = useLoaderData<typeof loader>();
const result = useActionData<typeof action>();
const busy = useNavigation().state !== "idle";
const addr = (b.address ?? {}) as { line1?: string; city?: string; postal?: string };
const items = (b.items ?? []) as { title: string; quantity: number }[];
return (
<s-page heading={b.orderName ?? "Booking"}>
{orderUrl && <s-button slot="secondary-actions" href={orderUrl}>Open order</s-button>}
{result && <s-banner tone={result.ok ? "success" : "critical"}>{result.message}</s-banner>}
{b.status === "needs_review" && (
<s-banner tone="critical" heading="This order needs a time">
<s-paragraph>{b.reviewReason}. Pick a time below to confirm it, or tick "allow over capacity" if you can fit it in.</s-paragraph>
</s-banner>
)}
{b.status === "confirmed" && b.reviewReason && (
<s-banner tone="warning" heading="Check this order">
<s-stack gap="base">
<s-paragraph>{b.reviewReason}</s-paragraph>
<Form method="post">
<input type="hidden" name="intent" value="dismiss" />
<s-button type="submit">Mark as checked</s-button>
</Form>
</s-stack>
</s-banner>
)}
<s-section heading="Booking">
<s-grid gridTemplateColumns="repeat(auto-fit, minmax(180px, 1fr))" gap="base">
<Field label="Status"><s-badge tone={STATUS_TONE[b.status] ?? "neutral"}>{b.status.replace("_", " ")}</s-badge></Field>
<Field label="Method">{METHOD_LABEL[b.method]}</Field>
<Field label="Location">{location ?? "—"}</Field>
<Field label="Date">{niceDate(b.date, true)}</Field>
<Field label="Time">{b.method === "shipping" ? "Dispatch day" : slotLabel(b.slotStart, b.slotEnd)}</Field>
<Field label="Fee paid">{money(b.fee, currency)}</Field>
<Field label="Customer">{[b.customerName, b.customerEmail, b.customerPhone].filter(Boolean).join(" · ") || "—"}</Field>
<Field label="Address">{[addr.line1, addr.city, addr.postal].filter(Boolean).join(", ") || "—"}</Field>
<Field label="Items">{items.map((i) => `${i.quantity}× ${i.title}`).join(", ") || "—"}</Field>
<Field label="Rule version">v{b.configVersion}</Field>
</s-grid>
</s-section>
{options.length > 0 && (
<s-section heading={b.status === "needs_review" ? "Choose a time" : "Move to another time"}>
<Form method="post">
<s-stack gap="base">
<s-select label="New time" name="target">
{options.map((o) => (
<s-option key={`${o.date}|${o.slot}`} value={`${o.locationId}|${o.method}|${o.date}|${o.slot}`}>
{`${niceDate(o.date)} · ${o.method === "shipping" ? "dispatch" : slotLabel(o.start, o.end)} · ${o.full ? "full" : o.remaining == null ? "open" : `${o.remaining} left`}`}
</s-option>
))}
</s-select>
<s-checkbox name="force" label="Allow over capacity" details="Only for times marked full. Recorded in the history." />
<s-paragraph>The current place is released only if the new one saves. The customer isn't messaged automatically.</s-paragraph>
<s-button type="submit" variant="primary" loading={busy}>Save new time</s-button>
</s-stack>
</Form>
</s-section>
)}
<s-section heading="Reminders">
{b.reminderJobs.length === 0 ? (
<s-paragraph>No reminders planned.</s-paragraph>
) : (
<s-unordered-list>
{b.reminderJobs.map((j) => (
<s-list-item key={j.id}>
{j.channel} · {new Date(j.sendAt).toLocaleString("en-CA")} · {j.status}
{j.lastError ? ` (${j.lastError})` : ""}
</s-list-item>
))}
</s-unordered-list>
)}
</s-section>
<s-section heading="History">
<s-unordered-list>
{b.events.map((e) => (
<s-list-item key={e.id}>
{new Date(e.at).toLocaleString("en-CA")} · {e.actor} · {e.action}
{e.before ? ` · from ${describe(e.before)}` : ""}
{e.after ? ` · to ${describe(e.after)}` : ""}
</s-list-item>
))}
</s-unordered-list>
</s-section>
</s-page>
);
}
function describe(v: unknown) {
const o = v as Record<string, unknown>;
if (o.date) return `${o.date} ${o.slot ?? ""}`.trim() + (o.forced ? " (over capacity)" : "");
return Object.entries(o).map(([k, x]) => `${k}: ${String(x)}`).join(", ");
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<s-stack gap="small-300">
<s-text color="subdued">{label}</s-text>
<s-text>{children}</s-text>
</s-stack>
);
}