Fix admin settings 502 and improve notifications UX
Build and Push Docker Image / build (push) Successful in 1m47s Details

- Use supabaseAdmin for admin settings operations (bypasses RLS completely)
- Add proper error handling to updateSettings action
- Update notifications to be expandable with full message display
- Clicking notifications in dropdown now goes to /notifications?id=X
- Auto-scroll and expand notification when opening from dropdown link

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Matt 2026-01-26 17:26:17 +01:00
parent 4e3cf89f62
commit 9b119302d3
4 changed files with 210 additions and 128 deletions

View File

@ -90,14 +90,8 @@
markAsRead(notification.id);
}
closeDropdown();
if (notification.link) {
// Use goto for internal links, window.location for external
if (notification.link.startsWith('/')) {
await goto(notification.link);
} else if (notification.link.startsWith('http')) {
window.location.href = notification.link;
}
}
// Always go to notifications page with the notification ID to expand it
await goto(`/notifications?id=${notification.id}`);
}
function getTypeIcon(type: Notification['type']) {

View File

@ -2,6 +2,7 @@ import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { testSmtpConnection, sendTemplatedEmail } from '$lib/server/email';
import { testS3Connection, clearS3ClientCache } from '$lib/server/storage';
import { supabaseAdmin } from '$lib/server/supabase';
import * as poste from '$lib/server/poste';
export const load: PageServerLoad = async ({ locals }) => {
@ -205,94 +206,122 @@ export const actions: Actions = {
// Update app settings
updateSettings: async ({ request, locals }) => {
const { member } = await locals.safeGetSession();
if (!member || member.role !== 'admin') {
return fail(403, { error: 'Unauthorized' });
}
const formData = await request.formData();
const category = formData.get('category') as string;
// Get existing boolean settings for this category to handle unchecked checkboxes
const { data: existingSettings } = await locals.supabase
.from('app_settings')
.select('setting_key, setting_type')
.eq('category', category);
const existingBooleanKeys = new Set(
(existingSettings || [])
.filter(s => s.setting_type === 'boolean')
.map(s => s.setting_key)
);
// Get all settings from form data
const settingsToUpdate: Array<{ key: string; value: any; type: string }> = [];
const processedKeys = new Set<string>();
for (const [key, value] of formData.entries()) {
if (key !== 'category' && key.startsWith('setting_')) {
const settingKey = key.replace('setting_', '');
processedKeys.add(settingKey);
// Handle checkbox values - they come as 'on' when checked
const isCheckbox = value === 'on' || existingBooleanKeys.has(settingKey);
settingsToUpdate.push({
key: settingKey,
value: isCheckbox ? (value === 'on' || value === 'true') : value,
type: isCheckbox ? 'boolean' : 'text'
});
}
if (!category) {
return fail(400, { error: 'Category is required' });
}
// Handle unchecked checkboxes - they don't send any value
// For any existing boolean setting NOT in the form data, set to false
for (const booleanKey of existingBooleanKeys) {
if (!processedKeys.has(booleanKey)) {
settingsToUpdate.push({
key: booleanKey,
value: false,
type: 'boolean'
});
}
}
// Update or insert each setting
for (const setting of settingsToUpdate) {
// Try to update first
const { data: existing } = await locals.supabase
try {
// Get existing boolean settings for this category to handle unchecked checkboxes
// Use supabaseAdmin to bypass RLS
const { data: existingSettings, error: fetchError } = await supabaseAdmin
.from('app_settings')
.select('id')
.eq('category', category)
.eq('setting_key', setting.key)
.single();
.select('setting_key, setting_type')
.eq('category', category);
if (existing) {
// Update existing
await locals.supabase
.from('app_settings')
.update({
setting_value: setting.type === 'boolean' ? setting.value : JSON.stringify(setting.value),
updated_at: new Date().toISOString(),
updated_by: member?.id
})
.eq('category', category)
.eq('setting_key', setting.key);
} else {
// Insert new setting
await locals.supabase
.from('app_settings')
.insert({
category,
setting_key: setting.key,
setting_value: setting.type === 'boolean' ? setting.value : JSON.stringify(setting.value),
setting_type: setting.type,
display_name: setting.key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()),
updated_at: new Date().toISOString(),
updated_by: member?.id
});
if (fetchError) {
console.error('Error fetching existing settings:', fetchError);
return fail(500, { error: 'Failed to load existing settings' });
}
}
// Clear caches if storage settings were updated
if (category === 'storage') {
clearS3ClientCache();
}
const existingBooleanKeys = new Set(
(existingSettings || [])
.filter(s => s.setting_type === 'boolean')
.map(s => s.setting_key)
);
return { success: 'Settings updated successfully!' };
// Get all settings from form data
const settingsToUpdate: Array<{ key: string; value: any; type: string }> = [];
const processedKeys = new Set<string>();
for (const [key, value] of formData.entries()) {
if (key !== 'category' && key.startsWith('setting_')) {
const settingKey = key.replace('setting_', '');
processedKeys.add(settingKey);
// Handle checkbox values - they come as 'on' when checked
const isCheckbox = value === 'on' || existingBooleanKeys.has(settingKey);
settingsToUpdate.push({
key: settingKey,
value: isCheckbox ? (value === 'on' || value === 'true') : value,
type: isCheckbox ? 'boolean' : 'text'
});
}
}
// Handle unchecked checkboxes - they don't send any value
// For any existing boolean setting NOT in the form data, set to false
for (const booleanKey of existingBooleanKeys) {
if (!processedKeys.has(booleanKey)) {
settingsToUpdate.push({
key: booleanKey,
value: false,
type: 'boolean'
});
}
}
// Update or insert each setting using supabaseAdmin (bypasses RLS)
for (const setting of settingsToUpdate) {
// Try to update first
const { data: existing } = await supabaseAdmin
.from('app_settings')
.select('id')
.eq('category', category)
.eq('setting_key', setting.key)
.single();
if (existing) {
// Update existing
const { error: updateError } = await supabaseAdmin
.from('app_settings')
.update({
setting_value: setting.type === 'boolean' ? setting.value : JSON.stringify(setting.value),
updated_at: new Date().toISOString(),
updated_by: member.id
})
.eq('category', category)
.eq('setting_key', setting.key);
if (updateError) {
console.error('Error updating setting:', setting.key, updateError);
}
} else {
// Insert new setting
const { error: insertError } = await supabaseAdmin
.from('app_settings')
.insert({
category,
setting_key: setting.key,
setting_value: setting.type === 'boolean' ? setting.value : JSON.stringify(setting.value),
setting_type: setting.type,
display_name: setting.key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()),
updated_at: new Date().toISOString(),
updated_by: member.id
});
if (insertError) {
console.error('Error inserting setting:', setting.key, insertError);
}
}
}
// Clear caches if storage settings were updated
if (category === 'storage') {
clearS3ClientCache();
}
return { success: 'Settings updated successfully!' };
} catch (err) {
console.error('Unexpected error updating settings:', err);
return fail(500, { error: 'An unexpected error occurred while saving settings' });
}
},
// Test SMTP connection

View File

@ -1,10 +1,10 @@
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
export const load: PageServerLoad = async ({ locals, url }) => {
const { member } = await locals.safeGetSession();
if (!member) {
return { notifications: [] };
return { notifications: [], expandedId: null };
}
const { data: notifications } = await locals.supabase
@ -13,7 +13,11 @@ export const load: PageServerLoad = async ({ locals }) => {
.eq('member_id', member.id)
.order('created_at', { ascending: false });
// Get the ID from query param to auto-expand that notification
const expandedId = url.searchParams.get('id');
return {
notifications: notifications || []
notifications: notifications || [],
expandedId
};
};

View File

@ -1,6 +1,7 @@
<script lang="ts">
import { Bell, Calendar, CreditCard, Users, Megaphone, Settings, Check, ChevronRight, Trash2 } from 'lucide-svelte';
import { Bell, Calendar, CreditCard, Users, Megaphone, Settings, Check, ChevronDown, ChevronUp, ExternalLink } from 'lucide-svelte';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
interface Notification {
id: string;
@ -15,6 +16,36 @@
let { data } = $props();
let notifications = $state<Notification[]>(data.notifications);
let filter = $state<'all' | 'unread'>('all');
let expandedId = $state<string | null>(data.expandedId);
// Auto-expand and scroll to notification if ID is in URL
onMount(() => {
if (data.expandedId) {
const notification = notifications.find(n => n.id === data.expandedId);
if (notification && !notification.read_at) {
markAsRead(notification.id);
}
// Scroll to the notification
setTimeout(() => {
const element = document.getElementById(`notification-${data.expandedId}`);
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, 100);
}
});
function toggleExpand(id: string) {
if (expandedId === id) {
expandedId = null;
} else {
expandedId = id;
const notification = notifications.find(n => n.id === id);
if (notification && !notification.read_at) {
markAsRead(id);
}
}
}
const filteredNotifications = $derived(
filter === 'unread'
@ -107,14 +138,6 @@
}
}
async function handleNotificationClick(notification: Notification) {
if (!notification.read_at) {
await markAsRead(notification.id);
}
if (notification.link) {
goto(notification.link);
}
}
</script>
<svelte:head>
@ -179,42 +202,74 @@
{#each filteredNotifications as notification}
{@const Icon = getTypeIcon(notification.type)}
{@const badge = getTypeBadge(notification.type)}
<button
onclick={() => handleNotificationClick(notification)}
class="w-full rounded-xl border bg-white p-4 text-left transition-all hover:shadow-md {notification.read_at ? 'border-slate-200' : 'border-monaco-200 bg-monaco-50/30'}"
{@const isExpanded = expandedId === notification.id}
<div
id="notification-{notification.id}"
class="rounded-xl border bg-white transition-all {notification.read_at ? 'border-slate-200' : 'border-monaco-200 bg-monaco-50/30'} {isExpanded ? 'shadow-lg ring-2 ring-monaco-500/20' : 'hover:shadow-md'}"
>
<div class="flex items-start gap-4">
<div class="flex-shrink-0 rounded-full p-3 {getTypeColor(notification.type)}">
<Icon class="h-5 w-5" />
</div>
<div class="min-w-0 flex-1">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 mb-1">
<span class="rounded-full px-2 py-0.5 text-xs font-medium {badge.class}">
{badge.text}
</span>
{#if !notification.read_at}
<span class="h-2 w-2 rounded-full bg-monaco-600"></span>
<button
onclick={() => toggleExpand(notification.id)}
class="w-full p-4 text-left"
>
<div class="flex items-start gap-4">
<div class="flex-shrink-0 rounded-full p-3 {getTypeColor(notification.type)}">
<Icon class="h-5 w-5" />
</div>
<div class="min-w-0 flex-1">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 mb-1">
<span class="rounded-full px-2 py-0.5 text-xs font-medium {badge.class}">
{badge.text}
</span>
{#if !notification.read_at}
<span class="h-2 w-2 rounded-full bg-monaco-600"></span>
{/if}
</div>
<h3 class="text-base font-semibold text-slate-900">
{notification.title}
</h3>
{#if !isExpanded}
<p class="mt-1 text-sm text-slate-600 line-clamp-2">
{notification.message}
</p>
{/if}
<p class="mt-2 text-xs text-slate-400">
{formatDate(notification.created_at)}
</p>
</div>
<div class="flex-shrink-0 text-slate-400">
{#if isExpanded}
<ChevronUp class="h-5 w-5" />
{:else}
<ChevronDown class="h-5 w-5" />
{/if}
</div>
<h3 class="text-base font-semibold text-slate-900 {notification.read_at ? '' : 'text-slate-900'}">
{notification.title}
</h3>
<p class="mt-1 text-sm text-slate-600 line-clamp-2">
{notification.message}
</p>
<p class="mt-2 text-xs text-slate-400">
{formatDate(notification.created_at)}
</p>
</div>
{#if notification.link}
<ChevronRight class="h-5 w-5 flex-shrink-0 text-slate-400" />
{/if}
</div>
</div>
</div>
</button>
</button>
<!-- Expanded content -->
{#if isExpanded}
<div class="border-t border-slate-100 px-4 pb-4 pt-3 ml-16">
<div class="prose prose-sm prose-slate max-w-none">
<p class="text-slate-700 whitespace-pre-wrap">{notification.message}</p>
</div>
{#if notification.link}
<div class="mt-4">
<a
href={notification.link}
class="inline-flex items-center gap-2 rounded-lg bg-monaco-600 px-4 py-2 text-sm font-medium text-white hover:bg-monaco-700 transition-colors"
>
<ExternalLink class="h-4 w-4" />
View Details
</a>
</div>
{/if}
</div>
{/if}
</div>
{/each}
</div>
{/if}