feat: admin notification system with priority ordering, bell icon, and /notifications page

Two-tier notification system: new_booking (all public bookings) + pending_booking (notes/today).
Priority-sorted queue, unread count polling, enriched responses with user_name/booking_start_time.
Fix critical bug: edit_requested cleanup was broken (wrong reason string in 3 handlers).
Add 15 new tests covering priority ordering, enrichment, and notification creation flows.
Update Admin Manual, Technical Manual, and gap backlog docs.
This commit is contained in:
2026-05-16 23:41:18 +01:00
parent c3501ae89a
commit 7fc58f58d9
19 changed files with 1608 additions and 106 deletions
@@ -0,0 +1,428 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { fly } from 'svelte/transition';
import { cubicOut } from 'svelte/easing';
import { goto } from '$app/navigation';
import { browser } from '$app/environment';
import { authStore } from '$lib/stores/auth.svelte';
import { Skeleton } from '$lib/components/ui/skeleton';
import { Button } from '$lib/components/ui/button';
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 { toast } from 'svelte-sonner';
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;
}
let notifications = $state<Notification[]>([]);
let loading = $state(true);
let error = $state(false);
let page = $state(1);
let perPage = $state(20);
let total = $state(0);
let includeAcknowledged = $state(false);
let showApprovalModal = $state(false);
let selectedBooking = $state<any>(null);
let showBookingModal = $state(false);
let showUserModal = $state(false);
let selectedUserId = $state<string | null>(null);
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 Requested',
new_booking: 'New Booking Received',
cancelled_booking: 'Booking Cancelled',
late_cancellation: 'Late Cancellation (< 24h)',
no_deposit: 'Deposit Issue',
deposit_paid: 'Deposit Payment Received',
affiliate_claim: 'Affiliate Referral Claimed',
'1_month_no_pay': 'No Payments in 1 Month',
'1_week_no_pay': 'No Payments in 1 Week'
};
function hasAction(reason: string): string | null {
switch (reason) {
case 'pending_booking':
return 'approve';
case 'edit_request':
case 'edit_requested':
case 'new_booking':
return 'view';
case 'late_cancellation':
case 'no_deposit':
case '1_week_no_pay':
case '1_month_no_pay':
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 fetch(`/api/admin/notifications?${params}`, {
headers: { Authorization: `Bearer ${authStore.currentToken}` }
});
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<any> {
const response = await fetch(`/api/admin/bookings/${bookingId}`, {
headers: { Authorization: `Bearer ${authStore.currentToken}` }
});
if (!response.ok) return null;
return response.json();
}
async function handleAction(notification: Notification) {
if (!notification.acknowledged_at) {
await fetch(`/api/admin/notifications/${notification.id}/acknowledge`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
}
});
}
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 === 'see_user' && notification.user_id) {
selectedUserId = notification.user_id;
showUserModal = true;
}
}
async function handleAcknowledge(notification: Notification) {
try {
const response = await fetch(`/api/admin/notifications/${notification.id}/acknowledge`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
}
});
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 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 Date(iso);
const now = new Date();
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 Date(iso);
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
const bookingDay = new Date(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);
}
return parts.join(' — ');
}
const totalPages = Math.ceil(total / perPage);
onMount(() => {
if (pageState === 'authorized') {
fetchNotifications();
}
});
</script>
{#if pageState === 'loading'}
<div class="mx-auto max-w-3xl space-y-4 p-6 pt-24">
<Skeleton class="h-8 w-48" />
{#each Array(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">
<input
type="checkbox"
checked={includeAcknowledged}
onchange={toggleView}
class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary"
/>
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">
{#each notifications as n (n.id)}
<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'
: '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}
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}
{#if showBookingModal && selectedBooking}
<BookingModal bind:open={showBookingModal} bookingId={selectedBooking.id} />
{/if}
{#if showUserModal && selectedUserId}
<UserModal bind:open={showUserModal} userId={selectedUserId} />
{/if}