7 review agents (pipeline run, self-review, codebase-context, frontend-placement, backend testing-gaps, Square-API, docs-parity) audited the SCA-primary work. ALL findings fixed, including every pre-existing red CI job: GDPR (HIGH): - anonymize_user() now scrubs admin_audit_log.target_user_id (mirrors delete_guest_user) so 2fa_fallback_charge rows (customer id + card_last4 PII) no longer survive registered-user account deletion; gdpr test added BACKEND TEST GAPS (all 10): - delivery-unavailable 503 branch: prod-tag predicate test + dev-variant marker - twoFactorFallbackEnabled alias/case/default matrix tests + exported wrapper - insertTwoFAFallbackAudit details-JSON shape + audit-row assertions for all 6 gate sites (booking/tip/gift-card/payment-method/terminal/till, both actors) - CreateTerminalPayment.VerificationToken: passthrough, too-long 400, 2FA-skip, token-less fallback + SCA-required (new terminal_sca_test.go) - isVerificationRequiredError at all 5 charge sites (402 + code:verification_required) - customer_initiated handler-level assertions (MIT false admin / CIT true customer) - Mock: ApprovePendingVerification, ChallengeResult auto/deny, _deny token suffix, parseVerifyToken unit tests FRONTEND SCA + Square-API (CRITICAL): - tokenizeSavedCardWithVerification reads result.token (the verified token) not result.verificationResult (deprecated verifyBuyer shape — saved-card SCA could never succeed in production before); parseTokenizeVerificationResult pure fn extracted + pinned in square.test.ts; 'verified' with no token proceeds tokenless - HIGH: saved-card idempotency key regenerated after a definitive 402 (fresh token under the same key = IDEMPOTENCY_KEY_REUSED dead-loop); kept on 503/cancelled - challenge-cancelled copy no longer promises a 2FA fallback the UI doesn't show; 'waiting for approval in your banking app' state on CIT surfaces - sca-unavailable demotion resets per attempt; card selection disabled mid-challenge; genuine saved-card declines no longer relabeled 'requires verification'; modal-close guard during processing; retry affordance standardized PIPELINE (every red job now green): - prod-tag build break fixed (shared square stub + test_helpers_test.go, prod-safe) - govulncheck: x/image 0.45.0 bumped (x/text resolved); go mod tidy clean - race: TestDeleteAccount_InvalidatesSquareCustomerCache made deterministic - DAV_ADMIN_PASSWORD placeholder in .env.example (compose config passes) - frontend: prettier 28 files, eslint, a11y 38 errors, knip (currentZIndex), deps in-range, audit vulns (nanoid/postcss) — all fixed; 67 vitest cases DOCS PARITY (6 DRIFTs + 5 GAPs): payments doc Ch4/Ch14/Appendix A, Technical Manual 2FA + counter-reset + payment sections, README test counts + SNAPSHOT_ENC_KEY, Feature Catalog, .env.example REQUIRE_2FA — SCA-primary/2FA-backup posture verified against code everywhere Verified: 26/26 dev + 24/24 prod packages, both vet tags, golangci-lint/staticcheck/ gosec 0 on both tags, gitleaks clean, 2,464 backend + 67 frontend tests.
291 lines
9.8 KiB
TypeScript
291 lines
9.8 KiB
TypeScript
import { CalendarDate } from '@internationalized/date';
|
|
import {
|
|
extractBookedSlots,
|
|
getLunchProtectionForSlots,
|
|
type LunchProtectionResult
|
|
} from '$lib/lunchProtection';
|
|
|
|
/**
|
|
* Returns the UTC datetime string with explicit +00:00 offset, suitable for
|
|
* sending to the backend for scheduling/booking purposes.
|
|
* This is functionally equivalent to `Date.toISOString()` except the suffix
|
|
* is `+00:00` instead of `Z`. Both are valid RFC3339, but Go's `time.Time`
|
|
* JSON unmarshalling prefers the explicit offset form when constructing
|
|
* wall-clock timestamps that should not be shifted by the backend's timezone.
|
|
* The DST correctness of the resulting datetime comes from the CalendarDate
|
|
* / getLocalTimeZone() path used to construct the Date object — not from
|
|
* this formatting function.
|
|
*/
|
|
export function formatLocalDateTime(date: Date): string {
|
|
const y = date.getUTCFullYear();
|
|
const m = String(date.getUTCMonth() + 1).padStart(2, '0');
|
|
const d = String(date.getUTCDate()).padStart(2, '0');
|
|
const h = String(date.getUTCHours()).padStart(2, '0');
|
|
const min = String(date.getUTCMinutes()).padStart(2, '0');
|
|
const s = String(date.getUTCSeconds()).padStart(2, '0');
|
|
return `${y}-${m}-${d}T${h}:${min}:${s}+00:00`;
|
|
}
|
|
|
|
/**
|
|
* Parses a UTC ISO datetime string (e.g. "2026-06-15T09:00:00Z" or
|
|
* "2026-06-15T09:00:00+00:00") from the backend and returns a Date
|
|
* whose getHours()/getMinutes() in the browser's local timezone
|
|
* reflect the local wall-clock time.
|
|
*
|
|
* The backend stores all times as UTC wall-clock values. This function
|
|
* uses the standard Date parser which correctly interprets the ISO
|
|
* string as UTC and applies the browser timezone for get*() accessors.
|
|
* Example: "2026-06-15T09:00:00Z" → getHours() returns 10 in BST.
|
|
*/
|
|
export function parseWallClockDate(iso: string): Date {
|
|
return new Date(iso);
|
|
}
|
|
|
|
/**
|
|
* Returns a CalendarDate representing today's date in the Europe/London timezone.
|
|
* This ensures "is today" comparisons are correct even during BST (British Summer Time)
|
|
* when there is a window between 00:00-01:00 BST where the UTC date differs from the
|
|
* London date. Avoids using browser-local time which can be wrong in that window.
|
|
*/
|
|
export function getLondonTodayCalendarDate(): CalendarDate {
|
|
const londonDateStr = new Date().toLocaleDateString('en-CA', { timeZone: 'Europe/London' });
|
|
const [y, m, d] = londonDateStr.split('-').map(Number);
|
|
return new CalendarDate(y, m, d);
|
|
}
|
|
|
|
export interface TimeSlotGroup {
|
|
type: 'available' | 'unavailable';
|
|
startTime: string;
|
|
endTime: string;
|
|
isGrouped?: boolean;
|
|
outOfHours?: boolean;
|
|
}
|
|
|
|
export interface DayHours {
|
|
isOpen: boolean;
|
|
startTime: string;
|
|
endTime: string;
|
|
}
|
|
|
|
export interface DayAvailability {
|
|
isOpen: boolean;
|
|
slots: Array<{ startTime: string; endTime: string }>;
|
|
}
|
|
|
|
export function formatTime(time: string): string {
|
|
const parts = time.split(':').map(Number);
|
|
const hours = parts[0];
|
|
const minutes = parts.length > 1 ? parts[1] : 0;
|
|
if (hours === 12 && minutes === 0) return 'Noon';
|
|
if (hours === 0 && minutes === 0) return 'Midnight';
|
|
const period = hours >= 12 ? 'PM' : 'AM';
|
|
const displayHours = hours % 12 || 12;
|
|
return `${displayHours}:${minutes.toString().padStart(2, '0')} ${period}`;
|
|
}
|
|
|
|
export function calculateEndTime(startTime: string, durationMinutes: number): string {
|
|
const [hours, minutes] = startTime.split(':').map(Number);
|
|
const totalMinutes = hours * 60 + minutes + durationMinutes;
|
|
return `${String(Math.floor(totalMinutes / 60)).padStart(2, '0')}:${String(totalMinutes % 60).padStart(2, '0')}`;
|
|
}
|
|
|
|
export function timeToMinutes(time: string): number {
|
|
const [hours, minutes] = time.split(':').map(Number);
|
|
return hours * 60 + minutes;
|
|
}
|
|
|
|
export function getDayWithOrdinal(date: CalendarDate): string {
|
|
const monthName = new Date(date.year, date.month - 1, date.day).toLocaleDateString('en-GB', {
|
|
month: 'long'
|
|
});
|
|
const day = date.day;
|
|
if (day > 3 && day < 21) return monthName + ' ' + day + 'th';
|
|
switch (day % 10) {
|
|
case 1:
|
|
return monthName + ' ' + day + 'st';
|
|
case 2:
|
|
return monthName + ' ' + day + 'nd';
|
|
case 3:
|
|
return monthName + ' ' + day + 'rd';
|
|
default:
|
|
return monthName + ' ' + day + 'th';
|
|
}
|
|
}
|
|
|
|
export function buildLunchProtection(
|
|
date: CalendarDate,
|
|
workingHours: Record<string, DayHours> | null,
|
|
availableHours: Record<string, DayAvailability> | null,
|
|
duration: number,
|
|
isAdmin: boolean
|
|
): Map<string, LunchProtectionResult> {
|
|
if (!workingHours || !availableHours) return new Map();
|
|
const dateStr = date.toString();
|
|
const dayWH = workingHours[dateStr];
|
|
const dayAH = availableHours[dateStr];
|
|
if (!dayWH?.isOpen || !dayAH?.slots) return new Map();
|
|
|
|
const existingBookings = extractBookedSlots(dayWH.startTime, dayWH.endTime, dayAH.slots);
|
|
return getLunchProtectionForSlots(
|
|
dayWH.startTime,
|
|
dayWH.endTime,
|
|
existingBookings,
|
|
duration,
|
|
15,
|
|
isAdmin
|
|
);
|
|
}
|
|
|
|
export function generateAvailableTimeSlots(
|
|
date: CalendarDate,
|
|
workingHours: Record<string, DayHours> | null,
|
|
availableHours: Record<string, DayAvailability> | null,
|
|
duration: number,
|
|
protection: Map<string, LunchProtectionResult>
|
|
): string[] {
|
|
if (!workingHours || !availableHours) return [];
|
|
const dateStr = date.toString();
|
|
const dayWH = workingHours[dateStr];
|
|
const dayAH = availableHours[dateStr];
|
|
if (!dayWH?.isOpen || !dayAH?.slots) {
|
|
return [];
|
|
}
|
|
|
|
const slots: string[] = [];
|
|
const todayCal = getLondonTodayCalendarDate();
|
|
const isToday = date.compare(todayCal) === 0;
|
|
|
|
for (const slot of dayAH.slots) {
|
|
const [startHour, startMinute] = slot.startTime.split(':').map(Number);
|
|
const [endHour, endMinute] = slot.endTime.split(':').map(Number);
|
|
let startTotalMinutes = Math.ceil((startHour * 60 + startMinute) / 15) * 15;
|
|
const endTotalMinutes = endHour * 60 + endMinute;
|
|
|
|
if (isToday) {
|
|
const now = new Date();
|
|
const londonTimeStr = now.toLocaleTimeString('en-GB', {
|
|
timeZone: 'Europe/London',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
hour12: false
|
|
});
|
|
const [londonHours, londonMinutes] = londonTimeStr.split(':').map(Number);
|
|
const currentMinutes = londonHours * 60 + londonMinutes;
|
|
const minimumStart = Math.ceil((currentMinutes + 60) / 15) * 15;
|
|
startTotalMinutes = Math.max(startTotalMinutes, minimumStart);
|
|
}
|
|
|
|
for (let minutes = startTotalMinutes; minutes < endTotalMinutes; minutes += 15) {
|
|
if (minutes + duration <= endTotalMinutes) {
|
|
const timeStr = `${String(Math.floor(minutes / 60)).padStart(2, '0')}:${String(minutes % 60).padStart(2, '0')}`;
|
|
const p = protection.get(timeStr);
|
|
if (!p || !p.isBlocked) {
|
|
slots.push(timeStr);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return slots;
|
|
}
|
|
|
|
export function generateGroupedTimeSlots(
|
|
date: CalendarDate,
|
|
workingHours: Record<string, DayHours> | null,
|
|
availableHours: Record<string, DayAvailability> | null,
|
|
duration: number,
|
|
protection: Map<string, LunchProtectionResult>
|
|
): TimeSlotGroup[] {
|
|
if (!workingHours) {
|
|
return [];
|
|
}
|
|
const dateStr = date.toString();
|
|
const dayWH = workingHours[dateStr];
|
|
if (!dayWH || !dayWH.isOpen) {
|
|
return [];
|
|
}
|
|
|
|
const grouped: TimeSlotGroup[] = [];
|
|
const [startHour, startMinute] = dayWH.startTime.split(':').map(Number);
|
|
const [endHour, endMinute] = dayWH.endTime.split(':').map(Number);
|
|
let startTotalMinutes = startHour * 60 + startMinute;
|
|
const endTotalMinutes = endHour * 60 + endMinute;
|
|
|
|
const todayCal = getLondonTodayCalendarDate();
|
|
if (date.compare(todayCal) === 0) {
|
|
const now = new Date();
|
|
const londonTimeStr = now.toLocaleTimeString('en-GB', {
|
|
timeZone: 'Europe/London',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
hour12: false
|
|
});
|
|
const [londonHours, londonMinutes] = londonTimeStr.split(':').map(Number);
|
|
const currentMinutes = londonHours * 60 + londonMinutes;
|
|
startTotalMinutes = Math.max(startTotalMinutes, Math.ceil((currentMinutes + 15) / 15) * 15);
|
|
}
|
|
|
|
const availableSlots = generateAvailableTimeSlots(
|
|
date,
|
|
workingHours,
|
|
availableHours,
|
|
duration,
|
|
protection
|
|
);
|
|
|
|
let currentUnavailableStart: string | null = null;
|
|
let lastAvailableEndTime: string | null = null;
|
|
|
|
for (let minutes = startTotalMinutes; minutes < endTotalMinutes; minutes += 15) {
|
|
const timeStr = `${String(Math.floor(minutes / 60)).padStart(2, '0')}:${String(minutes % 60).padStart(2, '0')}`;
|
|
const p = protection.get(timeStr);
|
|
const isAvailable = availableSlots.includes(timeStr) && !p?.isBlocked;
|
|
|
|
if (isAvailable) {
|
|
if (currentUnavailableStart !== null) {
|
|
const unavailStart = lastAvailableEndTime || currentUnavailableStart;
|
|
const prevParts = timeStr.split(':').map(Number);
|
|
const prevTotal = prevParts[0] * 60 + prevParts[1] - 15;
|
|
const groupEnd = `${String(Math.floor(prevTotal / 60)).padStart(2, '0')}:${String(prevTotal % 60).padStart(2, '0')}`;
|
|
if (timeToMinutes(unavailStart) < timeToMinutes(groupEnd)) {
|
|
grouped.push({
|
|
type: 'unavailable',
|
|
startTime: unavailStart,
|
|
endTime: groupEnd,
|
|
isGrouped: true
|
|
});
|
|
}
|
|
currentUnavailableStart = null;
|
|
}
|
|
const slotEnd = calculateEndTime(timeStr, duration);
|
|
lastAvailableEndTime = slotEnd;
|
|
grouped.push({ type: 'available', startTime: timeStr, endTime: slotEnd });
|
|
if (timeToMinutes(slotEnd) >= endTotalMinutes) break;
|
|
} else {
|
|
if (currentUnavailableStart === null) {
|
|
currentUnavailableStart = timeStr;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (currentUnavailableStart !== null) {
|
|
const lastAvail = grouped.filter((s) => s.type === 'available').pop();
|
|
const lastAvailEnd = lastAvail ? timeToMinutes(lastAvail.endTime) : 0;
|
|
const unavailStartMin = timeToMinutes(currentUnavailableStart);
|
|
if (unavailStartMin < endTotalMinutes && lastAvailEnd < endTotalMinutes) {
|
|
grouped.push({
|
|
type: 'unavailable',
|
|
startTime: lastAvail ? lastAvail.endTime : currentUnavailableStart,
|
|
endTime: (() => {
|
|
const p = dayWH.endTime.split(':');
|
|
return p.length < 2
|
|
? p[0].padStart(5, '0')
|
|
: `${p[0].padStart(2, '0')}:${p[1].padStart(2, '0')}`;
|
|
})(),
|
|
isGrouped: true
|
|
});
|
|
}
|
|
}
|
|
|
|
return grouped;
|
|
}
|