Fix admin settings 502 and improve notifications UX
Build and Push Docker Image / build (push) Successful in 1m47s
Details
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:
parent
4e3cf89f62
commit
9b119302d3
|
|
@ -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']) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Reference in New Issue