Add search and event filters to admin email logs.

Let admins find logs by recipient or subject and narrow results by event on the Email Logs tab.
This commit is contained in:
Michilis
2026-06-04 23:35:53 +00:00
parent ecd2a7d009
commit 69768077e5
3 changed files with 65 additions and 5 deletions

View File

@@ -1,6 +1,6 @@
import { Hono } from 'hono';
import { db, dbGet, dbAll, emailTemplates, emailLogs, events, tickets } from '../db/index.js';
import { eq, desc, and, sql } from 'drizzle-orm';
import { eq, desc, and, or, sql } from 'drizzle-orm';
import { requireAuth } from '../lib/auth.js';
import { getNow, generateId } from '../lib/utils.js';
import emailService from '../lib/email.js';
@@ -287,6 +287,7 @@ emailsRouter.post('/preview', requireAuth(['admin', 'organizer']), async (c) =>
emailsRouter.get('/logs', requireAuth(['admin', 'organizer']), async (c) => {
const eventId = c.req.query('eventId');
const status = c.req.query('status');
const search = c.req.query('search');
const limit = parseInt(c.req.query('limit') || '50');
const offset = parseInt(c.req.query('offset') || '0');
@@ -299,6 +300,14 @@ emailsRouter.get('/logs', requireAuth(['admin', 'organizer']), async (c) => {
if (status) {
conditions.push(eq((emailLogs as any).status, status));
}
if (search && search.trim()) {
const term = `%${search.trim().toLowerCase()}%`;
conditions.push(or(
sql`LOWER(${(emailLogs as any).recipientEmail}) LIKE ${term}`,
sql`LOWER(COALESCE(${(emailLogs as any).recipientName}, '')) LIKE ${term}`,
sql`LOWER(${(emailLogs as any).subject}) LIKE ${term}`,
));
}
if (conditions.length > 0) {
query = query.where(and(...conditions));

View File

@@ -22,6 +22,7 @@ import {
ChevronRightIcon,
XMarkIcon,
ArrowPathIcon,
MagnifyingGlassIcon,
} from '@heroicons/react/24/outline';
import toast from 'react-hot-toast';
import clsx from 'clsx';
@@ -55,6 +56,9 @@ export default function AdminEmailsPage() {
const [logsOffset, setLogsOffset] = useState(0);
const [logsTotal, setLogsTotal] = useState(0);
const [logsSubTab, setLogsSubTab] = useState<'all' | 'failed'>('all');
const [logsSearch, setLogsSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [logsEventFilter, setLogsEventFilter] = useState('');
const [resendingLogId, setResendingLogId] = useState<string | null>(null);
const [selectedLog, setSelectedLog] = useState<EmailLog | null>(null);
@@ -214,11 +218,20 @@ export default function AdminEmailsPage() {
}
};
useEffect(() => {
const handle = setTimeout(() => setDebouncedSearch(logsSearch), 300);
return () => clearTimeout(handle);
}, [logsSearch]);
useEffect(() => {
setLogsOffset(0);
}, [debouncedSearch, logsEventFilter]);
useEffect(() => {
if (activeTab === 'logs') {
loadLogs();
}
}, [activeTab, logsOffset, logsSubTab]);
}, [activeTab, logsOffset, logsSubTab, debouncedSearch, logsEventFilter]);
const loadData = async () => {
try {
@@ -241,6 +254,8 @@ export default function AdminEmailsPage() {
limit: 20,
offset: logsOffset,
...(logsSubTab === 'failed' ? { status: 'failed' } : {}),
...(debouncedSearch.trim() ? { search: debouncedSearch.trim() } : {}),
...(logsEventFilter ? { eventId: logsEventFilter } : {}),
});
setLogs(res.logs);
setLogsTotal(res.pagination.total);
@@ -757,6 +772,41 @@ export default function AdminEmailsPage() {
</nav>
</div>
{/* Filters: search + event */}
<div className="flex flex-col md:flex-row gap-3 mb-4">
<div className="relative flex-1">
<MagnifyingGlassIcon className="w-5 h-5 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none" />
<input
type="text"
value={logsSearch}
onChange={(e) => setLogsSearch(e.target.value)}
placeholder="Search by recipient or subject..."
className="w-full pl-10 pr-10 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
/>
{logsSearch && (
<button
onClick={() => setLogsSearch('')}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 hover:bg-gray-100 rounded-btn"
title="Clear search"
>
<XMarkIcon className="w-4 h-4 text-gray-400" />
</button>
)}
</div>
<select
value={logsEventFilter}
onChange={(e) => setLogsEventFilter(e.target.value)}
className="w-full md:w-64 px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
>
<option value="">All events</option>
{events.map((event) => (
<option key={event.id} value={event.id}>
{event.title}
</option>
))}
</select>
</div>
{/* Desktop: Table */}
<Card className="overflow-hidden hidden md:block">
<div className="overflow-x-auto">
@@ -772,7 +822,7 @@ export default function AdminEmailsPage() {
</thead>
<tbody className="divide-y divide-secondary-light-gray">
{logs.length === 0 ? (
<tr><td colSpan={5} className="px-4 py-12 text-center text-gray-500 text-sm">{logsSubTab === 'failed' ? 'No failed emails' : 'No emails sent yet'}</td></tr>
<tr><td colSpan={5} className="px-4 py-12 text-center text-gray-500 text-sm">{(debouncedSearch.trim() || logsEventFilter) ? 'No emails match your filters' : logsSubTab === 'failed' ? 'No failed emails' : 'No emails sent yet'}</td></tr>
) : (
logs.map((log) => (
<tr key={log.id} className="hover:bg-gray-50">
@@ -829,7 +879,7 @@ export default function AdminEmailsPage() {
{/* Mobile: Card List */}
<div className="md:hidden space-y-2">
{logs.length === 0 ? (
<div className="text-center py-10 text-gray-500 text-sm">{logsSubTab === 'failed' ? 'No failed emails' : 'No emails sent yet'}</div>
<div className="text-center py-10 text-gray-500 text-sm">{(debouncedSearch.trim() || logsEventFilter) ? 'No emails match your filters' : logsSubTab === 'failed' ? 'No failed emails' : 'No emails sent yet'}</div>
) : (
logs.map((log) => (
<Card key={log.id} className="p-3" onClick={() => setSelectedLog(log)}>

View File

@@ -496,10 +496,11 @@ export const emailsApi = {
}),
// Logs
getLogs: (params?: { eventId?: string; status?: string; limit?: number; offset?: number }) => {
getLogs: (params?: { eventId?: string; status?: string; search?: string; limit?: number; offset?: number }) => {
const query = new URLSearchParams();
if (params?.eventId) query.set('eventId', params.eventId);
if (params?.status) query.set('status', params.status);
if (params?.search) query.set('search', params.search);
if (params?.limit) query.set('limit', params.limit.toString());
if (params?.offset) query.set('offset', params.offset.toString());
return fetchApi<{ logs: EmailLog[]; pagination: Pagination }>(`/api/emails/logs?${query}`);