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

- 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:
Matt 2026-01-26 16:04:27 +01:00
parent 2451582dc6
commit 274b13fe1e
8 changed files with 462 additions and 22 deletions

View File

@ -660,8 +660,8 @@ GRANT SELECT, INSERT, UPDATE, DELETE ON public.documents TO authenticated;
GRANT SELECT ON public.document_categories TO authenticated;
GRANT SELECT, INSERT, UPDATE, DELETE ON public.document_folders TO authenticated;
-- Settings (public settings viewable)
GRANT SELECT ON public.app_settings TO authenticated;
-- Settings (admin can manage, all authenticated can read)
GRANT SELECT, INSERT, UPDATE, DELETE ON public.app_settings TO authenticated;
-- Email (admin can manage templates, users can view own logs)
GRANT SELECT, UPDATE ON public.email_templates TO authenticated;
@ -1409,6 +1409,73 @@ ALTER TABLE public.members ADD COLUMN IF NOT EXISTS onboarding_completed_at TIME
CREATE INDEX IF NOT EXISTS idx_members_payment_deadline ON public.members(payment_deadline)
WHERE payment_deadline IS NOT NULL;
-- ============================================
-- MIGRATION 017: In-App Notifications
-- ============================================
-- In-app notifications table
CREATE TABLE public.notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
member_id UUID NOT NULL REFERENCES public.members(id) ON DELETE CASCADE,
type TEXT NOT NULL CHECK (type IN ('welcome', 'event', 'payment', 'membership', 'system', 'announcement')),
title TEXT NOT NULL,
message TEXT NOT NULL,
link TEXT,
read_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_notifications_member ON public.notifications(member_id);
CREATE INDEX idx_notifications_unread ON public.notifications(member_id) WHERE read_at IS NULL;
CREATE INDEX idx_notifications_created ON public.notifications(created_at DESC);
ALTER TABLE public.notifications ENABLE ROW LEVEL SECURITY;
-- Members can view their own notifications
CREATE POLICY "Members can view own notifications"
ON public.notifications FOR SELECT
TO authenticated
USING (member_id = auth.uid());
-- Members can update their own notifications (mark as read)
CREATE POLICY "Members can update own notifications"
ON public.notifications FOR UPDATE
TO authenticated
USING (member_id = auth.uid());
-- Admin can manage all notifications
CREATE POLICY "Admin can manage all notifications"
ON public.notifications FOR ALL
TO authenticated
USING (
EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role = 'admin')
);
-- Grant permissions
GRANT SELECT, UPDATE ON public.notifications TO authenticated;
GRANT ALL ON public.notifications TO service_role;
-- Trigger to create welcome notification for new members
CREATE OR REPLACE FUNCTION create_welcome_notification()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO public.notifications (member_id, type, title, message, link)
VALUES (
NEW.id,
'welcome',
'Welcome to Monaco USA!',
'Thank you for joining our community. Complete your profile and explore upcoming events.',
'/profile'
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE TRIGGER on_member_created_notification
AFTER INSERT ON public.members
FOR EACH ROW
EXECUTE FUNCTION create_welcome_notification();
-- ============================================
-- GRANT SERVICE_ROLE ACCESS TO ALL TABLES
-- ============================================

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,11 +50,21 @@
size = 'default',
disabled = false,
type = 'button',
href,
children,
...restProps
}: ButtonProps & { children?: import('svelte').Snippet } = $props();
}: ButtonProps & { children?: import('svelte').Snippet; href?: string } = $props();
</script>
{#if href}
<a
{href}
class={cn(buttonVariants({ variant, size }), className)}
{...restProps}
>
{@render children?.()}
</a>
{:else}
<button
{type}
{disabled}
@ -63,3 +73,4 @@
>
{@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');
}