feat(email): Suchleiste + erweiterte Filter im Email-Postfach
Variante B aus der Trade-off-Diskussion: Suchleiste über der Email-Liste
plus eine ausklappbare Box mit Detail-Filtern, alle AND-verknüpft.
Backend:
- EmailListOptions um search + 9 Detail-Filter erweitert (fromFilter,
toFilter, subjectFilter, bodyFilter, attachmentNameFilter,
hasAttachments, isRead, isStarred, receivedFrom, receivedTo)
- getCachedEmails baut die where-Klausel:
* `search` → OR über Subject/From-Address/From-Name/Body (Volltext-
Quicksearch)
* Feldspezifische Filter werden AND-verknüpft an die where gehängt;
From-/Body-Filter intern als kleine OR-Subqueries (Match in
Adresse ODER Name; Match in textBody ODER htmlBody)
- Controller-Parser akzeptiert die Filter als Query-Parameter
(parseBoolParam/parseDateParam tolerieren leere/invalide Werte)
Frontend:
- Suchleiste mit X-Button zum Leeren + Filter-Toggle mit Badge (zeigt
Anzahl aktiver Filter)
- Ausklappbare Filter-Box: Von, An, Betreff, Inhalt, Datum von/bis,
Anhang-Dateiname, Mit/Ohne Anhang, Gelesen-Status, Markiert-Status
- Filter-State fließt via useMemo + queryKey in den useQuery → React
Query macht automatisch ein Re-Fetch bei jeder Änderung
- "Alle zurücksetzen"-Button räumt komplett auf
- Nicht für TRASH-Folder eingeblendet (eigener Pfad ohne Filter-API)
Bewusst nicht gebaut: voller AND/OR-Builder mit Plus-Button und
Bool-Verschachtelung. Reale Such-Use-Cases im Email-Kontext sind
quasi immer AND-verknüpft; Bool-Builder bringt mehr Bedienprobleme
als Mehrwert.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -25,6 +25,19 @@ import {
|
||||
|
||||
// ==================== E-MAIL LIST ====================
|
||||
|
||||
// Hilfsfunktion: Query-Param zu boolean parsen ('true' / 'false' / fehlt).
|
||||
function parseBoolParam(v: unknown): boolean | undefined {
|
||||
if (v === 'true') return true;
|
||||
if (v === 'false') return false;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseDateParam(v: unknown): Date | undefined {
|
||||
if (typeof v !== 'string' || !v.trim()) return undefined;
|
||||
const d = new Date(v);
|
||||
return isNaN(d.getTime()) ? undefined : d;
|
||||
}
|
||||
|
||||
// E-Mails für einen Kunden abrufen
|
||||
export async function getEmailsForCustomer(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
@@ -42,6 +55,17 @@ export async function getEmailsForCustomer(req: AuthRequest, res: Response): Pro
|
||||
limit,
|
||||
offset,
|
||||
includeBody: false,
|
||||
search: typeof req.query.search === 'string' ? req.query.search : undefined,
|
||||
fromFilter: typeof req.query.fromFilter === 'string' ? req.query.fromFilter : undefined,
|
||||
toFilter: typeof req.query.toFilter === 'string' ? req.query.toFilter : undefined,
|
||||
subjectFilter: typeof req.query.subjectFilter === 'string' ? req.query.subjectFilter : undefined,
|
||||
bodyFilter: typeof req.query.bodyFilter === 'string' ? req.query.bodyFilter : undefined,
|
||||
attachmentNameFilter: typeof req.query.attachmentNameFilter === 'string' ? req.query.attachmentNameFilter : undefined,
|
||||
hasAttachments: parseBoolParam(req.query.hasAttachments),
|
||||
isRead: parseBoolParam(req.query.isRead),
|
||||
isStarred: parseBoolParam(req.query.isStarred),
|
||||
receivedFrom: parseDateParam(req.query.receivedFrom),
|
||||
receivedTo: parseDateParam(req.query.receivedTo),
|
||||
});
|
||||
|
||||
res.json({ success: true, data: emails } as ApiResponse);
|
||||
|
||||
@@ -49,6 +49,18 @@ export interface EmailListOptions {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
includeBody?: boolean;
|
||||
// Suche / Filter (alle AND-verknüpft)
|
||||
search?: string; // Volltextsuche über subject + from + body
|
||||
fromFilter?: string; // Absender enthält
|
||||
toFilter?: string; // Empfänger enthält
|
||||
subjectFilter?: string; // Subject enthält
|
||||
bodyFilter?: string; // Body enthält (text/html)
|
||||
attachmentNameFilter?: string; // Anhang-Dateiname enthält
|
||||
hasAttachments?: boolean; // Nur mit/ohne Anhang
|
||||
isRead?: boolean; // Gelesen-Status
|
||||
isStarred?: boolean; // Markiert-Status
|
||||
receivedFrom?: Date; // Empfangen ab
|
||||
receivedTo?: Date; // Empfangen bis
|
||||
}
|
||||
|
||||
// ==================== SYNC FUNCTIONS ====================
|
||||
@@ -273,6 +285,59 @@ export async function getCachedEmails(
|
||||
where.folder = EmailFolder.INBOX;
|
||||
}
|
||||
|
||||
// ===== Such-/Filter-Parameter =====
|
||||
// Volltext-Quicksearch: durchsucht parallel Subject, From-Address/Name und
|
||||
// Body. MariaDB `contains` ist case-insensitive bei utf8mb4_unicode_ci.
|
||||
if (options.search && options.search.trim()) {
|
||||
const q = options.search.trim();
|
||||
where.OR = [
|
||||
{ subject: { contains: q } },
|
||||
{ fromAddress: { contains: q } },
|
||||
{ fromName: { contains: q } },
|
||||
{ textBody: { contains: q } },
|
||||
];
|
||||
}
|
||||
|
||||
// Feldspezifische Filter (alle AND-verknüpft mit dem Rest)
|
||||
if (options.fromFilter?.trim()) {
|
||||
const q = options.fromFilter.trim();
|
||||
// Treffer in fromAddress ODER fromName – für den Nutzer ist „Von" beides
|
||||
where.AND = [
|
||||
...(Array.isArray(where.AND) ? where.AND : where.AND ? [where.AND] : []),
|
||||
{ OR: [{ fromAddress: { contains: q } }, { fromName: { contains: q } }] },
|
||||
];
|
||||
}
|
||||
if (options.toFilter?.trim()) {
|
||||
where.toAddresses = { contains: options.toFilter.trim() };
|
||||
}
|
||||
if (options.subjectFilter?.trim()) {
|
||||
where.subject = { contains: options.subjectFilter.trim() };
|
||||
}
|
||||
if (options.bodyFilter?.trim()) {
|
||||
const q = options.bodyFilter.trim();
|
||||
where.AND = [
|
||||
...(Array.isArray(where.AND) ? where.AND : where.AND ? [where.AND] : []),
|
||||
{ OR: [{ textBody: { contains: q } }, { htmlBody: { contains: q } }] },
|
||||
];
|
||||
}
|
||||
if (options.attachmentNameFilter?.trim()) {
|
||||
where.attachmentNames = { contains: options.attachmentNameFilter.trim() };
|
||||
}
|
||||
if (typeof options.hasAttachments === 'boolean') {
|
||||
where.hasAttachments = options.hasAttachments;
|
||||
}
|
||||
if (typeof options.isRead === 'boolean') {
|
||||
where.isRead = options.isRead;
|
||||
}
|
||||
if (typeof options.isStarred === 'boolean') {
|
||||
where.isStarred = options.isStarred;
|
||||
}
|
||||
if (options.receivedFrom || options.receivedTo) {
|
||||
where.receivedAt = {};
|
||||
if (options.receivedFrom) (where.receivedAt as Prisma.DateTimeFilter).gte = options.receivedFrom;
|
||||
if (options.receivedTo) (where.receivedAt as Prisma.DateTimeFilter).lte = options.receivedTo;
|
||||
}
|
||||
|
||||
// Body-Felder nur wenn explizit angefordert (spart Bandbreite)
|
||||
const select: Prisma.CachedEmailSelect = {
|
||||
id: true,
|
||||
|
||||
Reference in New Issue
Block a user