Files
Crussell/frontend/src/routes/admin/notifications/+page.svelte
T
popertots ae8735ba2f Close refund system and gate raw-PAN card entry
Refund system (Round 3 fixes + follow-up + alignment):
- Serialize cancellation refunds against the manual handler via
  per-payment advisory locks taken before the prior-refunds read
  (pg_advisory_xact_lock, ascending, same crussell:refund: key space)
- Aggregate pending cancellation refunds into ONE Square refund per
  charge (stable charge-level -square-agg key); atomic group UPDATE
  keeps crash-retry amounts identical for Square key-dedup
- Persist paymentID-square-amount idempotency keys on cancellation
  refunds; scheduler reads the stored key (legacy fallback for old rows)
- Add sweep-pending-square-refunds cron (*/5, concurrency 1) with
  refund_attempts cap; sweep retries stale manual pending refunds with
  each row's own stored idempotency key
- Reconcile at Square (GET /v2/refunds ListPaymentRefunds) before every
  terminal failed transition: tri-state result leaves rows pending on
  reconcile error instead of false-failing; PAYMENT_ALREADY_REFUNDED
  resolves to completed
- Move over-refund guard inside the lock, counting completed + pending
  (excluding failed); ErrRefundDeclined distinguishes definitive vs
  ambiguous outcomes
- forgiveFees now executes a real full refund (forceFullRefund override)
  with admin_forgiven_fees reason threaded to Square
- Surface failed card refunds in the admin notification centre
  (refund_failed enum, RETURNING-id pre-pass inserts, NOT EXISTS dedup)
- Dedup double-cancel refund inserts via ON CONFLICT (idempotency_key)
  DO NOTHING without consuming refundRemaining

Frontend:
- Remove all raw-PAN card entry: zero card_number/card_cvc/new_card_token
  in request bodies; gate new-card entry behind CardEntryUnavailable
  notice + newCardDisabled prop across all 8 flows
- Delete hand-rolled CardInput.svelte; keep CardSelection saved-card UI
  and CardEntryUnavailable fallback
- Update cancellation-policy page to in-person cash pickup wording

Tests:
- Rewrite the two amount-blind dedup tests to assert real money movement
  (single call, aggregated amount, shared refund ID)
- Add coverage: manual refund vs cancellation serialization (concurrent
  goroutines), reconcile error vs no-match branches, stale manual retry,
  forgive-fees real refund row + reason, double-cancel dedup, mock refund
  key dedup, ListPaymentRefunds filtering
- Fix time-dependent booking flakes with fixtures.NextWorkingDayAt
- 25/25 packages pass; -race clean on payments/square/db/jobs/bookings
2026-08-22 00:34:49 +01:00

530 lines
16 KiB
Svelte
Raw 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.
<script lang="ts">
import { SvelteDate } from 'svelte/reactivity';
import { onMount } from 'svelte';
import { fly } from 'svelte/transition';
import { cubicOut } from 'svelte/easing';
import { browser } from '$app/environment';
import { authStore } from '$lib/stores/auth.svelte';
import { apiFetch } from '$lib/utils/api';
import { Skeleton } from '$lib/components/ui/skeleton';
import { Button } from '$lib/components/ui/button';
import { range } from '$lib/utils/format';
import { Checkbox } from '$lib/components/ui/checkbox';
import ApprovalModal from '$lib/components/admin/ApprovalModal.svelte';
import BookingModal from '$lib/components/admin/BookingModal.svelte';
import UserModal from '$lib/components/admin/UserModal.svelte';
import EditRequestModal from '$lib/components/admin/EditRequestModal.svelte';
import { toast } from 'svelte-sonner';
import type { Booking } from '$lib/types/booking';
interface Notification {
id: number;
reason: string;
booking_id?: string;
user_id?: string;
user_name?: string;
booking_start_time?: string;
acknowledged_at?: string;
created_at: string;
}
interface ServiceItem {
id: string;
name: string;
price: number;
duration_minutes: number;
}
interface EditRequest {
id: string;
booking_id: string;
requested_by: string;
requested_at: string;
notes: string | null;
original: {
start_time: string;
end_time: string;
services: ServiceItem[];
notes: string;
};
proposed: {
start_time: string | null;
end_time: string | null;
services: ServiceItem[];
notes: string | null;
};
user: {
id: string;
full_name: string;
email: string;
phone: string;
};
}
let notifications = $state<Notification[]>([]);
let loading = $state(true);
let error = $state(false);
let page = $state(1);
const perPage = $state(20);
let total = $state(0);
let includeAcknowledged = $state(false);
let showApprovalModal = $state(false);
let selectedBooking = $state<Booking | null>(null);
let showBookingModal = $state(false);
let showUserModal = $state(false);
let selectedUserId = $state<string | null>(null);
let showEditRequestModal = $state(false);
let selectedEditRequest = $state<EditRequest | null>(null);
function openBookingModal(bookingId: string) {
selectedBooking = { id: bookingId } as Booking;
showBookingModal = true;
}
let pageState = $state<'loading' | 'authorized' | 'unauthorized'>('loading');
$effect(() => {
if (!browser) return;
if (authStore.isLoading) {
pageState = 'loading';
return;
}
if (!authStore.isAuthenticated || authStore.currentUser?.role !== 'admin') {
pageState = 'unauthorized';
return;
}
pageState = 'authorized';
});
const reasonLabels: Record<string, string> = {
pending_booking: 'Booking Pending Approval',
edit_request: 'Customer Requested Booking Change',
edit_requested: 'Booking Edit/Reschedule Requested',
new_booking: 'New Booking Received',
cancelled_booking: 'Booking Cancelled',
late_cancellation: 'Late Cancellation (< 24h)',
deposit_paid: 'Deposit Payment Received',
deposit_not_paid_by_deadline: 'Deposit Deadline Passed',
affiliate_claim: 'Affiliate Referral Claimed',
'1_month_no_pay': 'No Payments in 1 Month',
'1_week_no_pay': 'No Payments in 1 Week',
refund_failed: 'Card refund failed — arrange in-person pickup'
};
function hasAction(reason: string): string | null {
switch (reason) {
case 'pending_booking':
return 'approve';
case 'edit_request':
case 'new_booking':
return 'view';
case 'edit_requested':
return 'edit_approve';
case 'late_cancellation':
case 'deposit_not_paid_by_deadline':
case '1_week_no_pay':
case '1_month_no_pay':
case 'refund_failed':
return 'see_user';
default:
return null;
}
}
async function fetchNotifications() {
loading = true;
error = false;
try {
const params = new URLSearchParams({
page: page.toString(),
per_page: perPage.toString(),
include_acknowledged: includeAcknowledged.toString()
});
const response = await apiFetch(`/api/admin/notifications?${params}`);
if (!response.ok) {
throw new Error(`Failed: ${response.status}`);
}
const data = await response.json();
notifications = data.notifications;
total = data.total;
} catch {
error = true;
} finally {
loading = false;
}
}
async function fetchBookingDetails(bookingId: string): Promise<Booking | null> {
const response = await apiFetch(`/api/admin/bookings/${bookingId}`);
if (!response.ok) return null;
return response.json();
}
async function handleAction(notification: Notification) {
if (!notification.acknowledged_at) {
await apiFetch(`/api/admin/notifications/${notification.id}/acknowledge`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
}
const action = hasAction(notification.reason);
if (action === 'approve' && notification.booking_id) {
const booking = await fetchBookingDetails(notification.booking_id);
if (booking) {
selectedBooking = booking;
showApprovalModal = true;
} else {
toast.error('Could not load booking details');
}
} else if (action === 'view' && notification.booking_id) {
const booking = await fetchBookingDetails(notification.booking_id);
if (booking) {
selectedBooking = booking;
showBookingModal = true;
} else {
toast.error('Could not load booking details');
}
} else if (action === 'edit_approve' && notification.booking_id) {
try {
const response = await apiFetch(
`/api/admin/bookings/${notification.booking_id}/edit-request`
);
if (response.ok) {
const data = await response.json();
selectedEditRequest = data.edit_request;
showEditRequestModal = true;
} else if (response.status === 404) {
toast.error('This edit/reschedule request has already been processed');
} else {
toast.error('Could not load edit/reschedule request details');
}
} catch (err) {
console.error('Error fetching edit request:', err);
toast.error('Network error loading edit/reschedule request');
}
} else if (action === 'see_user' && notification.user_id) {
selectedUserId = notification.user_id;
showUserModal = true;
}
}
async function handleAcknowledge(notification: Notification) {
try {
const response = await apiFetch(`/api/admin/notifications/${notification.id}/acknowledge`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
if (!response.ok) {
toast.error('Failed to acknowledge notification');
return;
}
notifications = notifications.filter((n) => n.id !== notification.id);
total = Math.max(0, total - 1);
if (notifications.length === 0 && page > 1) {
page--;
await fetchNotifications();
}
} catch {
toast.error('Network error');
}
}
function handleApproved() {
showApprovalModal = false;
selectedBooking = null;
fetchNotifications();
}
function handleEditRequestAction() {
showEditRequestModal = false;
selectedEditRequest = null;
fetchNotifications();
}
function toggleView() {
includeAcknowledged = !includeAcknowledged;
page = 1;
fetchNotifications();
}
function prevPage() {
if (page > 1) {
page--;
fetchNotifications();
}
}
function nextPage() {
if (page * perPage < total) {
page++;
fetchNotifications();
}
}
function formatRelative(iso: string): string {
const d = new SvelteDate(iso);
const now = new SvelteDate();
const diffMs = now.getTime() - d.getTime();
const diffMin = Math.floor(diffMs / 60000);
const diffHr = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHr / 24);
const diffWeek = Math.floor(diffDay / 7);
if (diffMin < 1) return 'Just now';
if (diffMin < 60) return `${diffMin}m ago`;
if (diffHr < 24) return `${diffHr}h ago`;
if (diffDay < 7) return `${diffDay}d ago`;
if (diffWeek < 5) return `${diffWeek}w ago`;
return d.toLocaleDateString('en-GB', {
day: 'numeric',
month: 'short',
year: d.getFullYear() !== now.getFullYear() ? 'numeric' : undefined
});
}
function formatBookingDate(iso: string): string {
const d = new SvelteDate(iso);
const now = new SvelteDate();
const today = new SvelteDate(now.getFullYear(), now.getMonth(), now.getDate());
const tomorrow = new SvelteDate(today);
tomorrow.setDate(tomorrow.getDate() + 1);
const bookingDay = new SvelteDate(d.getFullYear(), d.getMonth(), d.getDate());
const diffDays = Math.round((bookingDay.getTime() - today.getTime()) / 86400000);
if (diffDays === 0) return 'Today';
if (diffDays === 1) return 'Tomorrow';
if (diffDays === -1) return 'Yesterday';
if (diffDays > 1 && diffDays <= 6) {
return d.toLocaleDateString('en-GB', { weekday: 'long' });
}
if (diffDays > 6 && diffDays <= 13) {
return 'Next ' + d.toLocaleDateString('en-GB', { weekday: 'long' });
}
return d.toLocaleDateString('en-GB', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: d.getFullYear() !== now.getFullYear() ? 'numeric' : undefined
});
}
function getNotificationTitle(n: Notification): string {
const base = reasonLabels[n.reason] || n.reason;
if (n.booking_start_time) {
return `${base}${formatBookingDate(n.booking_start_time)}`;
}
return base;
}
function getNotificationSubtitle(n: Notification): string {
const parts = [formatRelative(n.created_at)];
if (n.user_name) {
parts.push(n.user_name);
{
/* TODO: add formerly name when previous name data is available */
}
}
return parts.join(' — ');
}
const totalPages = $derived(Math.ceil(total / perPage));
onMount(() => {
if (pageState === 'authorized') {
fetchNotifications();
}
});
</script>
<svelte:head>
<script>
(function () {
// Pre-hydration auth guard: reads localStorage directly because the Svelte
// authStore hasn't initialized yet at this point (async+$state). This runs
// synchronously in <svelte:head> before any rendering, preventing a flash
// of protected content. The authStore handles post-hydration auth.
try {
var token = localStorage.getItem('authToken');
if (!token) {
window.location.replace('/login');
return;
}
var payload = JSON.parse(atob(token.split('.')[1]));
if (payload.exp * 1000 <= Date.now()) {
window.location.replace('/login');
return;
}
if (payload.role !== 'admin') {
window.location.replace('/');
}
} catch (e) {
window.location.replace('/login');
}
})();
</script>
</svelte:head>
{#if pageState === 'loading'}
<div class="mx-auto max-w-3xl space-y-4 p-6 pt-24">
<Skeleton class="h-8 w-48" />
{#each range(5) as i (i)}
<Skeleton class="h-24 w-full" />
{/each}
</div>
{:else if pageState === 'unauthorized'}
<div class="flex min-h-screen items-center justify-center p-4">
<div class="max-w-md text-center">
<svg
class="mx-auto mb-4 h-16 w-16 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<h2 class="mb-2 text-xl font-semibold text-gray-900">Coming Soon</h2>
<p class="text-gray-600">
Notifications are available for admin accounts. This feature will be enabled for all users
before launch.
</p>
</div>
</div>
{:else if error}
<div class="flex min-h-screen items-center justify-center p-4">
<div class="max-w-md text-center">
<h2 class="mb-2 text-xl font-semibold text-gray-900">Unable to Load Notifications</h2>
<p class="mb-4 text-gray-600">Something went wrong. Please try again.</p>
<Button onclick={fetchNotifications}>Retry</Button>
</div>
</div>
{:else}
<div class="mx-auto max-w-3xl p-6 pt-24">
<div class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-semibold text-gray-900">Notifications</h1>
<label class="flex cursor-pointer items-center gap-2 text-sm text-gray-600">
<Checkbox checked={includeAcknowledged} onchange={toggleView} />
Show acknowledged
</label>
</div>
{#if notifications.length === 0}
<div class="rounded-lg border border-gray-200 bg-gray-50 py-16 text-center">
<svg
class="mx-auto mb-3 h-12 w-12 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
/>
</svg>
<p class="text-gray-500">
{includeAcknowledged ? 'No notifications yet' : 'No unread notifications'}
</p>
</div>
{:else}
<div class="space-y-3 {loading ? 'opacity-60' : ''}">
{#each notifications as n (n.id)}
{@const actionable =
!n.acknowledged_at &&
(hasAction(n.reason) === 'approve' || hasAction(n.reason) === 'edit_approve')}
<div
transition:fly={{ x: 100, duration: 250, easing: cubicOut }}
class="rounded-lg border p-4 transition-colors {n.acknowledged_at
? 'border-gray-200 bg-gray-50'
: actionable
? 'border-amber-300 bg-amber-50/50'
: 'border-gray-300 bg-white'}"
>
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
{#if !n.acknowledged_at}
<span class="h-2 w-2 shrink-0 rounded-full bg-primary"></span>
{/if}
<h3 class="font-medium text-gray-900">{getNotificationTitle(n)}</h3>
</div>
<p class="mt-1 text-sm text-gray-500">{getNotificationSubtitle(n)}</p>
</div>
<div class="flex shrink-0 items-center gap-2 sm:flex-col sm:items-end">
{#if hasAction(n.reason)}
<Button size="sm" onclick={() => handleAction(n)}>
{#if hasAction(n.reason) === 'approve'}
Approve Booking
{:else if hasAction(n.reason) === 'see_user'}
See User
{:else if hasAction(n.reason) === 'edit_approve'}
Review Change
{:else}
See Booking
{/if}
</Button>
{/if}
{#if !n.acknowledged_at}
<Button size="sm" variant="outline" onclick={() => handleAcknowledge(n)}>
Acknowledge
</Button>
{/if}
</div>
</div>
</div>
{/each}
</div>
{#if totalPages > 1}
<div class="mt-6 flex items-center justify-between">
<p class="text-sm text-gray-500">
{(page - 1) * perPage + 1}{Math.min(page * perPage, total)} of {total}
</p>
<div class="flex gap-2">
<Button size="sm" variant="outline" onclick={prevPage} disabled={page <= 1}>
Previous
</Button>
<Button
size="sm"
variant="outline"
onclick={nextPage}
disabled={page * perPage >= total}
>
Next
</Button>
</div>
</div>
{/if}
{/if}
</div>
{/if}
{#if showApprovalModal && selectedBooking}
<ApprovalModal
bind:open={showApprovalModal}
booking={selectedBooking}
onApproved={handleApproved}
/>
{/if}
<BookingModal bind:open={showBookingModal} bookingId={selectedBooking?.id ?? ''} />
<UserModal bind:open={showUserModal} userId={selectedUserId ?? ''} {openBookingModal} />
{#if showEditRequestModal && selectedEditRequest}
<EditRequestModal
bind:open={showEditRequestModal}
editRequest={selectedEditRequest}
onApproved={handleEditRequestAction}
onDenied={handleEditRequestAction}
/>
{/if}