Add notifications system, fix button href, admin settings and welcome email
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m55s

- Fix admin settings 502 error by adding INSERT/UPDATE/DELETE grants
- Fix Button component to render <a> when href prop is provided
- Add welcome email for admin created during initial setup
- Add in-app notifications system with NotificationCenter component
- Add notifications table with RLS policies and welcome trigger
- Add API endpoints for fetching and marking notifications as read

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-26 16:04:27 +01:00
parent 2451582dc6
commit 274b13fe1e
8 changed files with 462 additions and 22 deletions

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { Bell, Search, Menu, User, Settings, LogOut, ChevronDown } from 'lucide-svelte';
import { Search, Menu, User, Settings, LogOut, ChevronDown } from 'lucide-svelte';
import type { MemberWithDues } from '$lib/types/database';
import NotificationCenter from './NotificationCenter.svelte';
interface Props {
member: MemberWithDues | null;
@@ -57,16 +58,7 @@
</button>
<!-- Notifications -->
<button
class="relative rounded-lg p-2 text-slate-500 hover:bg-slate-100 active:bg-slate-200 transition-colors min-h-[44px] min-w-[44px]"
aria-label="Notifications"
>
<Bell class="h-5 w-5" />
<!-- Notification badge -->
<span
class="absolute right-1 top-1 flex h-2 w-2 items-center justify-center rounded-full bg-monaco-600"
></span>
</button>
<NotificationCenter />
<!-- User menu (desktop) -->
{#if member}

View File

@@ -0,0 +1,245 @@
<script lang="ts">
import { Bell, Check, CheckCheck, X, Calendar, CreditCard, Users, Megaphone, Settings } from 'lucide-svelte';
import { onMount } from 'svelte';
interface Notification {
id: string;
type: 'welcome' | 'event' | 'payment' | 'membership' | 'system' | 'announcement';
title: string;
message: string;
link: string | null;
read_at: string | null;
created_at: string;
}
let isOpen = $state(false);
let notifications = $state<Notification[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
const unreadCount = $derived(notifications.filter(n => !n.read_at).length);
function toggleDropdown() {
isOpen = !isOpen;
if (isOpen && notifications.length === 0) {
loadNotifications();
}
}
function closeDropdown() {
isOpen = false;
}
function handleClickOutside(event: MouseEvent) {
const target = event.target as HTMLElement;
if (!target.closest('.notification-center')) {
closeDropdown();
}
}
async function loadNotifications() {
loading = true;
error = null;
try {
const response = await fetch('/api/notifications');
if (!response.ok) {
throw new Error('Failed to load notifications');
}
const data = await response.json();
notifications = data.notifications || [];
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load notifications';
} finally {
loading = false;
}
}
async function markAsRead(id: string) {
try {
const response = await fetch(`/api/notifications/${id}/read`, {
method: 'POST'
});
if (response.ok) {
notifications = notifications.map(n =>
n.id === id ? { ...n, read_at: new Date().toISOString() } : n
);
}
} catch (e) {
console.error('Failed to mark notification as read:', e);
}
}
async function markAllAsRead() {
try {
const response = await fetch('/api/notifications/read-all', {
method: 'POST'
});
if (response.ok) {
const now = new Date().toISOString();
notifications = notifications.map(n => ({ ...n, read_at: n.read_at || now }));
}
} catch (e) {
console.error('Failed to mark all notifications as read:', e);
}
}
function handleNotificationClick(notification: Notification) {
if (!notification.read_at) {
markAsRead(notification.id);
}
if (notification.link) {
window.location.href = notification.link;
}
closeDropdown();
}
function getTypeIcon(type: Notification['type']) {
switch (type) {
case 'welcome': return Users;
case 'event': return Calendar;
case 'payment': return CreditCard;
case 'membership': return Users;
case 'system': return Settings;
case 'announcement': return Megaphone;
default: return Bell;
}
}
function getTypeColor(type: Notification['type']) {
switch (type) {
case 'welcome': return 'text-green-600 bg-green-100';
case 'event': return 'text-blue-600 bg-blue-100';
case 'payment': return 'text-amber-600 bg-amber-100';
case 'membership': return 'text-purple-600 bg-purple-100';
case 'system': return 'text-slate-600 bg-slate-100';
case 'announcement': return 'text-monaco-600 bg-monaco-100';
default: return 'text-slate-600 bg-slate-100';
}
}
function formatTime(dateString: string) {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
}
onMount(() => {
loadNotifications();
});
</script>
<svelte:window onclick={handleClickOutside} />
<div class="notification-center relative">
<!-- Bell Button -->
<button
onclick={toggleDropdown}
class="relative rounded-lg p-2 text-slate-500 hover:bg-slate-100 active:bg-slate-200 transition-colors min-h-[44px] min-w-[44px]"
aria-label="Notifications"
aria-expanded={isOpen}
aria-haspopup="true"
>
<Bell class="h-5 w-5" />
{#if unreadCount > 0}
<span
class="absolute right-1 top-1 flex h-4 w-4 items-center justify-center rounded-full bg-monaco-600 text-[10px] font-medium text-white"
>
{unreadCount > 9 ? '9+' : unreadCount}
</span>
{/if}
</button>
<!-- Dropdown -->
{#if isOpen}
<div
class="absolute right-0 top-full mt-2 w-80 sm:w-96 origin-top-right rounded-xl border border-slate-200 bg-white shadow-lg ring-1 ring-black/5 z-50"
role="menu"
>
<!-- Header -->
<div class="flex items-center justify-between border-b border-slate-100 px-4 py-3">
<h3 class="font-semibold text-slate-900">Notifications</h3>
{#if unreadCount > 0}
<button
onclick={markAllAsRead}
class="flex items-center gap-1 text-xs text-monaco-600 hover:text-monaco-700 transition-colors"
>
<CheckCheck class="h-3.5 w-3.5" />
Mark all read
</button>
{/if}
</div>
<!-- Notifications List -->
<div class="max-h-96 overflow-y-auto">
{#if loading}
<div class="flex items-center justify-center py-8">
<div class="h-6 w-6 animate-spin rounded-full border-2 border-monaco-600 border-t-transparent"></div>
</div>
{:else if error}
<div class="px-4 py-8 text-center">
<p class="text-sm text-red-600">{error}</p>
<button
onclick={loadNotifications}
class="mt-2 text-sm text-monaco-600 hover:text-monaco-700"
>
Try again
</button>
</div>
{:else if notifications.length === 0}
<div class="px-4 py-8 text-center">
<Bell class="mx-auto h-8 w-8 text-slate-300" />
<p class="mt-2 text-sm text-slate-500">No notifications yet</p>
</div>
{:else}
{#each notifications as notification}
{@const Icon = getTypeIcon(notification.type)}
<button
onclick={() => handleNotificationClick(notification)}
class="flex w-full items-start gap-3 px-4 py-3 text-left hover:bg-slate-50 transition-colors {notification.read_at ? 'opacity-60' : ''}"
role="menuitem"
>
<div class="flex-shrink-0 rounded-full p-2 {getTypeColor(notification.type)}">
<Icon class="h-4 w-4" />
</div>
<div class="min-w-0 flex-1">
<div class="flex items-start justify-between gap-2">
<p class="text-sm font-medium text-slate-900 {notification.read_at ? '' : 'font-semibold'}">
{notification.title}
</p>
{#if !notification.read_at}
<span class="flex-shrink-0 h-2 w-2 rounded-full bg-monaco-600"></span>
{/if}
</div>
<p class="mt-0.5 text-xs text-slate-600 line-clamp-2">{notification.message}</p>
<p class="mt-1 text-xs text-slate-400">{formatTime(notification.created_at)}</p>
</div>
</button>
{/each}
{/if}
</div>
<!-- Footer -->
{#if notifications.length > 0}
<div class="border-t border-slate-100 px-4 py-2">
<a
href="/notifications"
onclick={closeDropdown}
class="block text-center text-sm text-monaco-600 hover:text-monaco-700 transition-colors py-1"
>
View all notifications
</a>
</div>
{/if}
</div>
{/if}
</div>

View File

@@ -50,16 +50,27 @@
size = 'default',
disabled = false,
type = 'button',
href,
children,
...restProps
}: ButtonProps & { children?: import('svelte').Snippet } = $props();
}: ButtonProps & { children?: import('svelte').Snippet; href?: string } = $props();
</script>
<button
{type}
{disabled}
class={cn(buttonVariants({ variant, size }), className)}
{...restProps}
>
{@render children?.()}
</button>
{#if href}
<a
{href}
class={cn(buttonVariants({ variant, size }), className)}
{...restProps}
>
{@render children?.()}
</a>
{:else}
<button
{type}
{disabled}
class={cn(buttonVariants({ variant, size }), className)}
{...restProps}
>
{@render children?.()}
</button>
{/if}

View File

@@ -0,0 +1,25 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ locals }) => {
const { session, user } = await locals.safeGetSession();
if (!session || !user) {
return json({ error: 'Not authenticated' }, { status: 401 });
}
// Fetch notifications for the current user
const { data: notifications, error } = await locals.supabase
.from('notifications')
.select('*')
.eq('member_id', user.id)
.order('created_at', { ascending: false })
.limit(50);
if (error) {
console.error('Failed to fetch notifications:', error);
return json({ error: 'Failed to fetch notifications' }, { status: 500 });
}
return json({ notifications: notifications || [] });
};

View File

@@ -0,0 +1,30 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ locals, params }) => {
const { session, user } = await locals.safeGetSession();
if (!session || !user) {
return json({ error: 'Not authenticated' }, { status: 401 });
}
const notificationId = params.id;
if (!notificationId) {
return json({ error: 'Notification ID required' }, { status: 400 });
}
// Mark notification as read (RLS ensures user can only update their own)
const { error } = await locals.supabase
.from('notifications')
.update({ read_at: new Date().toISOString() })
.eq('id', notificationId)
.eq('member_id', user.id);
if (error) {
console.error('Failed to mark notification as read:', error);
return json({ error: 'Failed to update notification' }, { status: 500 });
}
return json({ success: true });
};

View File

@@ -0,0 +1,24 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ locals }) => {
const { session, user } = await locals.safeGetSession();
if (!session || !user) {
return json({ error: 'Not authenticated' }, { status: 401 });
}
// Mark all unread notifications as read for the current user
const { error } = await locals.supabase
.from('notifications')
.update({ read_at: new Date().toISOString() })
.eq('member_id', user.id)
.is('read_at', null);
if (error) {
console.error('Failed to mark all notifications as read:', error);
return json({ error: 'Failed to update notifications' }, { status: 500 });
}
return json({ success: true });
};

View File

@@ -1,6 +1,7 @@
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { supabaseAdmin } from '$lib/server/supabase';
import { sendEmail, wrapInMonacoTemplate } from '$lib/server/email';
export const load: PageServerLoad = async () => {
// Check if any users exist in the system
@@ -203,6 +204,51 @@ export const actions: Actions = {
});
}
// Send welcome email to admin
try {
const portalUrl = url.origin;
const welcomeContent = `
<p style="margin: 0 0 16px 0; color: #334155; line-height: 1.6;">
Welcome, ${firstName}!
</p>
<p style="margin: 0 0 16px 0; color: #334155; line-height: 1.6;">
Congratulations! You've successfully set up the Monaco USA Portal as the founding administrator.
</p>
<p style="margin: 0 0 16px 0; color: #334155; line-height: 1.6;">
As the admin, you can now:
</p>
<ul style="margin: 0 0 20px 0; padding-left: 24px; color: #334155; line-height: 1.8;">
<li>Invite new members to join the portal</li>
<li>Manage membership statuses and types</li>
<li>Create and manage events</li>
<li>Configure organization settings</li>
<li>Set up email and storage integrations</li>
</ul>
<div style="text-align: center; margin: 24px 0;">
<a href="${portalUrl}/login" style="display: inline-block; background: #CE1126; color: white; text-decoration: none; padding: 12px 32px; border-radius: 8px; font-weight: 600;">Sign In to Portal</a>
</div>
<p style="margin: 20px 0 0 0; color: #64748b; font-size: 14px;">
If you have any questions, please reach out to the development team.
</p>
`;
await sendEmail({
to: email,
subject: 'Welcome to Monaco USA Portal - Admin Setup Complete',
html: wrapInMonacoTemplate({
title: 'Welcome, Administrator!',
content: welcomeContent
}),
recipientId: authData.user.id,
recipientName: `${firstName} ${lastName}`,
emailType: 'welcome',
sentBy: 'system'
});
} catch (emailError) {
console.error('Failed to send admin welcome email:', emailError);
// Non-critical - continue anyway since the account was created successfully
}
// Success - redirect to login
throw redirect(303, '/login?setup=complete');
}