Add notifications system, fix button href, admin settings and welcome email
Build and Push Docker Image / build (push) Successful in 1m55s
Details
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:
parent
2451582dc6
commit
274b13fe1e
|
|
@ -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
|
||||
-- ============================================
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 || [] });
|
||||
};
|
||||
|
|
@ -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 });
|
||||
};
|
||||
|
|
@ -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 });
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue