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);
|
markAsRead(notification.id);
|
||||||
}
|
}
|
||||||
closeDropdown();
|
closeDropdown();
|
||||||
if (notification.link) {
|
// Always go to notifications page with the notification ID to expand it
|
||||||
// Use goto for internal links, window.location for external
|
await goto(`/notifications?id=${notification.id}`);
|
||||||
if (notification.link.startsWith('/')) {
|
|
||||||
await goto(notification.link);
|
|
||||||
} else if (notification.link.startsWith('http')) {
|
|
||||||
window.location.href = notification.link;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTypeIcon(type: Notification['type']) {
|
function getTypeIcon(type: Notification['type']) {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { fail } from '@sveltejs/kit';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
import { testSmtpConnection, sendTemplatedEmail } from '$lib/server/email';
|
import { testSmtpConnection, sendTemplatedEmail } from '$lib/server/email';
|
||||||
import { testS3Connection, clearS3ClientCache } from '$lib/server/storage';
|
import { testS3Connection, clearS3ClientCache } from '$lib/server/storage';
|
||||||
|
import { supabaseAdmin } from '$lib/server/supabase';
|
||||||
import * as poste from '$lib/server/poste';
|
import * as poste from '$lib/server/poste';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals }) => {
|
export const load: PageServerLoad = async ({ locals }) => {
|
||||||
|
|
@ -205,15 +206,31 @@ export const actions: Actions = {
|
||||||
// Update app settings
|
// Update app settings
|
||||||
updateSettings: async ({ request, locals }) => {
|
updateSettings: async ({ request, locals }) => {
|
||||||
const { member } = await locals.safeGetSession();
|
const { member } = await locals.safeGetSession();
|
||||||
|
|
||||||
|
if (!member || member.role !== 'admin') {
|
||||||
|
return fail(403, { error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const category = formData.get('category') as string;
|
const category = formData.get('category') as string;
|
||||||
|
|
||||||
|
if (!category) {
|
||||||
|
return fail(400, { error: 'Category is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
// Get existing boolean settings for this category to handle unchecked checkboxes
|
// Get existing boolean settings for this category to handle unchecked checkboxes
|
||||||
const { data: existingSettings } = await locals.supabase
|
// Use supabaseAdmin to bypass RLS
|
||||||
|
const { data: existingSettings, error: fetchError } = await supabaseAdmin
|
||||||
.from('app_settings')
|
.from('app_settings')
|
||||||
.select('setting_key, setting_type')
|
.select('setting_key, setting_type')
|
||||||
.eq('category', category);
|
.eq('category', category);
|
||||||
|
|
||||||
|
if (fetchError) {
|
||||||
|
console.error('Error fetching existing settings:', fetchError);
|
||||||
|
return fail(500, { error: 'Failed to load existing settings' });
|
||||||
|
}
|
||||||
|
|
||||||
const existingBooleanKeys = new Set(
|
const existingBooleanKeys = new Set(
|
||||||
(existingSettings || [])
|
(existingSettings || [])
|
||||||
.filter(s => s.setting_type === 'boolean')
|
.filter(s => s.setting_type === 'boolean')
|
||||||
|
|
@ -250,10 +267,10 @@ export const actions: Actions = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update or insert each setting
|
// Update or insert each setting using supabaseAdmin (bypasses RLS)
|
||||||
for (const setting of settingsToUpdate) {
|
for (const setting of settingsToUpdate) {
|
||||||
// Try to update first
|
// Try to update first
|
||||||
const { data: existing } = await locals.supabase
|
const { data: existing } = await supabaseAdmin
|
||||||
.from('app_settings')
|
.from('app_settings')
|
||||||
.select('id')
|
.select('id')
|
||||||
.eq('category', category)
|
.eq('category', category)
|
||||||
|
|
@ -262,18 +279,22 @@ export const actions: Actions = {
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
// Update existing
|
// Update existing
|
||||||
await locals.supabase
|
const { error: updateError } = await supabaseAdmin
|
||||||
.from('app_settings')
|
.from('app_settings')
|
||||||
.update({
|
.update({
|
||||||
setting_value: setting.type === 'boolean' ? setting.value : JSON.stringify(setting.value),
|
setting_value: setting.type === 'boolean' ? setting.value : JSON.stringify(setting.value),
|
||||||
updated_at: new Date().toISOString(),
|
updated_at: new Date().toISOString(),
|
||||||
updated_by: member?.id
|
updated_by: member.id
|
||||||
})
|
})
|
||||||
.eq('category', category)
|
.eq('category', category)
|
||||||
.eq('setting_key', setting.key);
|
.eq('setting_key', setting.key);
|
||||||
|
|
||||||
|
if (updateError) {
|
||||||
|
console.error('Error updating setting:', setting.key, updateError);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Insert new setting
|
// Insert new setting
|
||||||
await locals.supabase
|
const { error: insertError } = await supabaseAdmin
|
||||||
.from('app_settings')
|
.from('app_settings')
|
||||||
.insert({
|
.insert({
|
||||||
category,
|
category,
|
||||||
|
|
@ -282,8 +303,12 @@ export const actions: Actions = {
|
||||||
setting_type: setting.type,
|
setting_type: setting.type,
|
||||||
display_name: setting.key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()),
|
display_name: setting.key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()),
|
||||||
updated_at: new Date().toISOString(),
|
updated_at: new Date().toISOString(),
|
||||||
updated_by: member?.id
|
updated_by: member.id
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (insertError) {
|
||||||
|
console.error('Error inserting setting:', setting.key, insertError);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -293,6 +318,10 @@ export const actions: Actions = {
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: 'Settings updated successfully!' };
|
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
|
// Test SMTP connection
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals }) => {
|
export const load: PageServerLoad = async ({ locals, url }) => {
|
||||||
const { member } = await locals.safeGetSession();
|
const { member } = await locals.safeGetSession();
|
||||||
|
|
||||||
if (!member) {
|
if (!member) {
|
||||||
return { notifications: [] };
|
return { notifications: [], expandedId: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data: notifications } = await locals.supabase
|
const { data: notifications } = await locals.supabase
|
||||||
|
|
@ -13,7 +13,11 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||||
.eq('member_id', member.id)
|
.eq('member_id', member.id)
|
||||||
.order('created_at', { ascending: false });
|
.order('created_at', { ascending: false });
|
||||||
|
|
||||||
|
// Get the ID from query param to auto-expand that notification
|
||||||
|
const expandedId = url.searchParams.get('id');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
notifications: notifications || []
|
notifications: notifications || [],
|
||||||
|
expandedId
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<script lang="ts">
|
<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 { goto } from '$app/navigation';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
interface Notification {
|
interface Notification {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -15,6 +16,36 @@
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
let notifications = $state<Notification[]>(data.notifications);
|
let notifications = $state<Notification[]>(data.notifications);
|
||||||
let filter = $state<'all' | 'unread'>('all');
|
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(
|
const filteredNotifications = $derived(
|
||||||
filter === 'unread'
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
|
|
@ -179,9 +202,14 @@
|
||||||
{#each filteredNotifications as notification}
|
{#each filteredNotifications as notification}
|
||||||
{@const Icon = getTypeIcon(notification.type)}
|
{@const Icon = getTypeIcon(notification.type)}
|
||||||
{@const badge = getTypeBadge(notification.type)}
|
{@const badge = getTypeBadge(notification.type)}
|
||||||
|
{@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'}"
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
onclick={() => handleNotificationClick(notification)}
|
onclick={() => toggleExpand(notification.id)}
|
||||||
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'}"
|
class="w-full p-4 text-left"
|
||||||
>
|
>
|
||||||
<div class="flex items-start gap-4">
|
<div class="flex items-start gap-4">
|
||||||
<div class="flex-shrink-0 rounded-full p-3 {getTypeColor(notification.type)}">
|
<div class="flex-shrink-0 rounded-full p-3 {getTypeColor(notification.type)}">
|
||||||
|
|
@ -198,23 +226,50 @@
|
||||||
<span class="h-2 w-2 rounded-full bg-monaco-600"></span>
|
<span class="h-2 w-2 rounded-full bg-monaco-600"></span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<h3 class="text-base font-semibold text-slate-900 {notification.read_at ? '' : 'text-slate-900'}">
|
<h3 class="text-base font-semibold text-slate-900">
|
||||||
{notification.title}
|
{notification.title}
|
||||||
</h3>
|
</h3>
|
||||||
|
{#if !isExpanded}
|
||||||
<p class="mt-1 text-sm text-slate-600 line-clamp-2">
|
<p class="mt-1 text-sm text-slate-600 line-clamp-2">
|
||||||
{notification.message}
|
{notification.message}
|
||||||
</p>
|
</p>
|
||||||
|
{/if}
|
||||||
<p class="mt-2 text-xs text-slate-400">
|
<p class="mt-2 text-xs text-slate-400">
|
||||||
{formatDate(notification.created_at)}
|
{formatDate(notification.created_at)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{#if notification.link}
|
<div class="flex-shrink-0 text-slate-400">
|
||||||
<ChevronRight class="h-5 w-5 flex-shrink-0 text-slate-400" />
|
{#if isExpanded}
|
||||||
|
<ChevronUp class="h-5 w-5" />
|
||||||
|
{:else}
|
||||||
|
<ChevronDown class="h-5 w-5" />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</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}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue