refactor: remove obsolete member management API module
This commit is contained in:
@@ -10,7 +10,8 @@
|
||||
}
|
||||
},
|
||||
"exports": {
|
||||
"./api": "./src/server/api.ts",
|
||||
"./services": "./src/server/services/index.ts",
|
||||
"./services/*": "./src/server/services/*.ts",
|
||||
"./schema/*": "./src/schema/*.ts",
|
||||
"./components": "./src/components/index.ts",
|
||||
"./actions/*": "./src/server/actions/*.ts",
|
||||
@@ -22,7 +23,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hookform/resolvers": "catalog:",
|
||||
"@kit/mailers": "workspace:*",
|
||||
"@kit/next": "workspace:*",
|
||||
"@kit/notifications": "workspace:*",
|
||||
"@kit/shared": "workspace:*",
|
||||
"@kit/supabase": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
export { CreateMemberForm } from './create-member-form';
|
||||
export { EditMemberForm } from './edit-member-form';
|
||||
export { MembersDataTable } from './members-data-table';
|
||||
export { MemberDetailView } from './member-detail-view';
|
||||
export { ApplicationWorkflow } from './application-workflow';
|
||||
export { DuesCategoryManager } from './dues-category-manager';
|
||||
export { MandateManager } from './mandate-manager';
|
||||
export { MemberImportWizard } from './member-import-wizard';
|
||||
|
||||
// New v2 components
|
||||
export { MemberAvatar } from './member-avatar';
|
||||
export { MemberStatsBar } from './member-stats-bar';
|
||||
export { MembersListView } from './members-list-view';
|
||||
export { MemberDetailTabs } from './member-detail-tabs';
|
||||
export { MemberCreateWizard } from './member-create-wizard';
|
||||
export { MemberCommandPalette } from './member-command-palette';
|
||||
export { TagsManager } from './tags-manager';
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,299 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
|
||||
import { Download } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import { formatDate } from '@kit/shared/dates';
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { useFileDownloadAction } from '@kit/ui/use-file-download-action';
|
||||
|
||||
import { STATUS_LABELS, getMemberStatusColor } from '../lib/member-utils';
|
||||
import {
|
||||
exportMembers,
|
||||
exportMembersExcel,
|
||||
} from '../server/actions/member-actions';
|
||||
|
||||
interface MembersDataTableProps {
|
||||
data: Array<Record<string, unknown>>;
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
account: string;
|
||||
accountId: string;
|
||||
duesCategories: Array<{ id: string; name: string }>;
|
||||
}
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: '', label: 'Alle' },
|
||||
{ value: 'active', label: 'Aktiv' },
|
||||
{ value: 'inactive', label: 'Inaktiv' },
|
||||
{ value: 'pending', label: 'Ausstehend' },
|
||||
{ value: 'resigned', label: 'Ausgetreten' },
|
||||
] as const;
|
||||
|
||||
export function MembersDataTable({
|
||||
data,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
account,
|
||||
accountId,
|
||||
duesCategories: _duesCategories,
|
||||
}: MembersDataTableProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const currentSearch = searchParams.get('search') ?? '';
|
||||
const currentStatus = searchParams.get('status') ?? '';
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
search: currentSearch,
|
||||
},
|
||||
});
|
||||
|
||||
const updateParams = useCallback(
|
||||
(updates: Record<string, string>) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (value) {
|
||||
params.set(key, value);
|
||||
} else {
|
||||
params.delete(key);
|
||||
}
|
||||
}
|
||||
// Reset to page 1 on filter change
|
||||
if (!('page' in updates)) {
|
||||
params.delete('page');
|
||||
}
|
||||
router.push(`?${params.toString()}`);
|
||||
},
|
||||
[router, searchParams],
|
||||
);
|
||||
|
||||
const handleSearch = useCallback(
|
||||
(e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const search = form.getValues('search');
|
||||
updateParams({ search });
|
||||
},
|
||||
[form, updateParams],
|
||||
);
|
||||
|
||||
const handleStatusChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
updateParams({ status: e.target.value });
|
||||
},
|
||||
[updateParams],
|
||||
);
|
||||
|
||||
const handlePageChange = useCallback(
|
||||
(newPage: number) => {
|
||||
updateParams({ page: String(newPage) });
|
||||
},
|
||||
[updateParams],
|
||||
);
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(memberId: string) => {
|
||||
router.push(`/home/${account}/members-cms/${memberId}`);
|
||||
},
|
||||
[router, account],
|
||||
);
|
||||
|
||||
const { execute: execCsvExport, isPending: isCsvExporting } =
|
||||
useFileDownloadAction(exportMembers, {
|
||||
successMessage: 'CSV-Export heruntergeladen',
|
||||
errorMessage: 'CSV-Export fehlgeschlagen',
|
||||
});
|
||||
|
||||
const { execute: execExcelExport, isPending: isExcelExporting } =
|
||||
useFileDownloadAction(exportMembersExcel, {
|
||||
successMessage: 'Excel-Export heruntergeladen',
|
||||
errorMessage: 'Excel-Export fehlgeschlagen',
|
||||
});
|
||||
|
||||
const isExporting = isCsvExporting || isExcelExporting;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<form onSubmit={handleSearch} className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Mitglied suchen..."
|
||||
className="w-64"
|
||||
data-test="members-search-input"
|
||||
{...form.register('search')}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
data-test="members-search-btn"
|
||||
>
|
||||
Suchen
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<select
|
||||
value={currentStatus}
|
||||
onChange={handleStatusChange}
|
||||
data-test="members-status-filter"
|
||||
className="border-input bg-background flex h-9 rounded-md border px-3 py-1 text-sm shadow-sm"
|
||||
>
|
||||
{STATUS_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isExporting}
|
||||
onClick={() =>
|
||||
execCsvExport({
|
||||
accountId,
|
||||
status: currentStatus || undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Download className="mr-1 h-4 w-4" />
|
||||
CSV
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isExporting}
|
||||
onClick={() =>
|
||||
execExcelExport({
|
||||
accountId,
|
||||
status: currentStatus || undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Download className="mr-1 h-4 w-4" />
|
||||
Excel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
data-test="members-new-btn"
|
||||
onClick={() => router.push(`/home/${account}/members-cms/new`)}
|
||||
>
|
||||
Neues Mitglied
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-muted/50 border-b">
|
||||
<th scope="col" className="px-4 py-3 text-left font-medium">
|
||||
Nr
|
||||
</th>
|
||||
<th scope="col" className="px-4 py-3 text-left font-medium">
|
||||
Name
|
||||
</th>
|
||||
<th scope="col" className="px-4 py-3 text-left font-medium">
|
||||
E-Mail
|
||||
</th>
|
||||
<th scope="col" className="px-4 py-3 text-left font-medium">
|
||||
Ort
|
||||
</th>
|
||||
<th scope="col" className="px-4 py-3 text-left font-medium">
|
||||
Status
|
||||
</th>
|
||||
<th scope="col" className="px-4 py-3 text-left font-medium">
|
||||
Eintritt
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={6}
|
||||
className="text-muted-foreground px-4 py-8 text-center"
|
||||
>
|
||||
Keine Mitglieder gefunden.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
data.map((member) => {
|
||||
const memberId = String(member.id ?? '');
|
||||
const status = String(member.status ?? 'active');
|
||||
return (
|
||||
<tr
|
||||
key={memberId}
|
||||
onClick={() => handleRowClick(memberId)}
|
||||
className="hover:bg-muted/50 cursor-pointer border-b transition-colors"
|
||||
>
|
||||
<td className="px-4 py-3 font-mono text-xs">
|
||||
{String(member.member_number ?? '—')}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{String(member.last_name ?? '')},{' '}
|
||||
{String(member.first_name ?? '')}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-4 py-3">
|
||||
{String(member.email ?? '—')}
|
||||
</td>
|
||||
<td className="px-4 py-3">{String(member.city ?? '—')}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant={getMemberStatusColor(status)}>
|
||||
{STATUS_LABELS[status] ?? status}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="text-muted-foreground px-4 py-3">
|
||||
{formatDate(member.entry_date as string)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{total} Mitglied{total !== 1 ? 'er' : ''} insgesamt
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page <= 1}
|
||||
onClick={() => handlePageChange(page - 1)}
|
||||
>
|
||||
← Zurück
|
||||
</Button>
|
||||
<span className="text-sm">
|
||||
Seite {page} von {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => handlePageChange(page + 1)}
|
||||
>
|
||||
Weiter →
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -36,6 +36,11 @@ interface MembersListViewProps {
|
||||
accountId: string;
|
||||
duesCategories: Array<{ id: string; name: string }>;
|
||||
departments: Array<{ id: string; name: string; memberCount: number }>;
|
||||
tags?: Array<{ id: string; name: string; color: string }>;
|
||||
memberTags?: Record<
|
||||
string,
|
||||
Array<{ id: string; name: string; color: string }>
|
||||
>;
|
||||
}
|
||||
|
||||
export function MembersListView({
|
||||
@@ -47,6 +52,8 @@ export function MembersListView({
|
||||
accountId,
|
||||
duesCategories,
|
||||
departments,
|
||||
tags = [],
|
||||
memberTags = {},
|
||||
}: MembersListViewProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -67,6 +74,7 @@ export function MembersListView({
|
||||
isHonorary: Boolean(m.is_honorary),
|
||||
isFoundingMember: Boolean(m.is_founding_member),
|
||||
isYouth: Boolean(m.is_youth),
|
||||
tags: memberTags[String(m.id)] ?? [],
|
||||
}));
|
||||
|
||||
const columns = createMembersColumns({
|
||||
@@ -120,6 +128,7 @@ export function MembersListView({
|
||||
selectedIds={selectedIds}
|
||||
departments={departments}
|
||||
duesCategories={duesCategories}
|
||||
tags={tags}
|
||||
/>
|
||||
|
||||
{/* Table or empty state */}
|
||||
|
||||
@@ -17,6 +17,12 @@ import {
|
||||
import { STATUS_LABELS, getMemberStatusColor } from '../lib/member-utils';
|
||||
import { MemberAvatar } from './member-avatar';
|
||||
|
||||
export interface MemberTag {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface MemberRow {
|
||||
id: string;
|
||||
firstName: string;
|
||||
@@ -31,6 +37,7 @@ export interface MemberRow {
|
||||
isHonorary: boolean;
|
||||
isFoundingMember: boolean;
|
||||
isYouth: boolean;
|
||||
tags: MemberTag[];
|
||||
}
|
||||
|
||||
interface ColumnOptions {
|
||||
@@ -211,6 +218,36 @@ export function createMembersColumns({
|
||||
size: 80,
|
||||
},
|
||||
|
||||
// Tags
|
||||
{
|
||||
id: 'tags',
|
||||
header: 'Tags',
|
||||
cell: ({ row }) => {
|
||||
const m = row.original;
|
||||
if (!m.tags || m.tags.length === 0) return null;
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{m.tags.slice(0, 3).map((tag) => (
|
||||
<Badge
|
||||
key={tag.id}
|
||||
variant="outline"
|
||||
className="border-0 px-1.5 py-0 text-[10px] whitespace-nowrap text-white"
|
||||
style={{ backgroundColor: tag.color }}
|
||||
>
|
||||
{tag.name}
|
||||
</Badge>
|
||||
))}
|
||||
{m.tags.length > 3 && (
|
||||
<Badge variant="secondary" className="px-1.5 py-0 text-[10px]">
|
||||
+{m.tags.length - 3}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
size: 160,
|
||||
},
|
||||
|
||||
// Row actions
|
||||
{
|
||||
id: 'actions',
|
||||
|
||||
@@ -59,6 +59,7 @@ interface MembersToolbarProps {
|
||||
selectedIds: string[];
|
||||
departments: Array<{ id: string; name: string; memberCount: number }>;
|
||||
duesCategories: Array<{ id: string; name: string }>;
|
||||
tags?: Array<{ id: string; name: string; color: string }>;
|
||||
}
|
||||
|
||||
export function MembersToolbar({
|
||||
@@ -72,6 +73,7 @@ export function MembersToolbar({
|
||||
selectedIds,
|
||||
departments,
|
||||
duesCategories,
|
||||
tags = [],
|
||||
}: MembersToolbarProps) {
|
||||
const router = useRouter();
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Plus, Trash2, Pencil } from 'lucide-react';
|
||||
import { useAction } from 'next-safe-action/hooks';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Label } from '@kit/ui/label';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
import { Textarea } from '@kit/ui/textarea';
|
||||
|
||||
import { createTag, deleteTag, updateTag } from '../server/actions/tag-actions';
|
||||
|
||||
interface TagsManagerProps {
|
||||
tags: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
description: string | null;
|
||||
sort_order: number;
|
||||
}>;
|
||||
accountId: string;
|
||||
}
|
||||
|
||||
interface TagFormValues {
|
||||
name: string;
|
||||
color: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export function TagsManager({ tags, accountId }: TagsManagerProps) {
|
||||
const router = useRouter();
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
|
||||
const form = useForm<TagFormValues>({
|
||||
defaultValues: { name: '', color: '#6B7280', description: '' },
|
||||
});
|
||||
|
||||
const { execute: executeCreate, isPending: isCreating } = useAction(
|
||||
createTag,
|
||||
{
|
||||
onSuccess: ({ data }) => {
|
||||
if (data?.success) {
|
||||
toast.success('Tag erstellt');
|
||||
form.reset();
|
||||
setShowForm(false);
|
||||
router.refresh();
|
||||
}
|
||||
},
|
||||
onError: () => toast.error('Fehler beim Erstellen'),
|
||||
},
|
||||
);
|
||||
|
||||
const { execute: executeUpdate } = useAction(updateTag, {
|
||||
onSuccess: ({ data }) => {
|
||||
if (data?.success) {
|
||||
toast.success('Tag aktualisiert');
|
||||
setEditingId(null);
|
||||
form.reset();
|
||||
router.refresh();
|
||||
}
|
||||
},
|
||||
onError: () => toast.error('Fehler beim Aktualisieren'),
|
||||
});
|
||||
|
||||
const { execute: executeDelete } = useAction(deleteTag, {
|
||||
onSuccess: ({ data }) => {
|
||||
if (data?.success) {
|
||||
toast.success('Tag gelöscht');
|
||||
router.refresh();
|
||||
}
|
||||
},
|
||||
onError: () => toast.error('Fehler beim Löschen'),
|
||||
});
|
||||
|
||||
const handleSubmit = form.handleSubmit((values) => {
|
||||
if (editingId) {
|
||||
executeUpdate({
|
||||
tagId: editingId,
|
||||
name: values.name,
|
||||
color: values.color,
|
||||
description: values.description || undefined,
|
||||
});
|
||||
} else {
|
||||
executeCreate({
|
||||
accountId,
|
||||
name: values.name,
|
||||
color: values.color,
|
||||
description: values.description || undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const startEditing = (tag: TagsManagerProps['tags'][number]) => {
|
||||
setEditingId(tag.id);
|
||||
setShowForm(true);
|
||||
form.setValue('name', tag.name);
|
||||
form.setValue('color', tag.color);
|
||||
form.setValue('description', tag.description ?? '');
|
||||
};
|
||||
|
||||
const cancelForm = () => {
|
||||
setShowForm(false);
|
||||
setEditingId(null);
|
||||
form.reset();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{tags.length} {tags.length === 1 ? 'Tag' : 'Tags'} erstellt
|
||||
</p>
|
||||
|
||||
{!showForm && (
|
||||
<Button size="sm" onClick={() => setShowForm(true)}>
|
||||
<Plus className="mr-1.5 size-4" />
|
||||
Neues Tag
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">
|
||||
{editingId ? 'Tag bearbeiten' : 'Neues Tag erstellen'}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="tag-name">Name</Label>
|
||||
<Input
|
||||
id="tag-name"
|
||||
placeholder="z.B. Vorstand-Kandidat"
|
||||
{...form.register('name', { required: true })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="tag-color">Farbe</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
id="tag-color"
|
||||
className="h-9 w-12 cursor-pointer rounded border p-0.5"
|
||||
{...form.register('color')}
|
||||
/>
|
||||
<Input
|
||||
className="flex-1"
|
||||
placeholder="#6B7280"
|
||||
{...form.register('color')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="tag-description">Beschreibung (optional)</Label>
|
||||
<Textarea
|
||||
id="tag-description"
|
||||
placeholder="Beschreibung des Tags..."
|
||||
rows={2}
|
||||
{...form.register('description')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-muted-foreground text-xs">
|
||||
Vorschau:{' '}
|
||||
<Badge
|
||||
className="text-white"
|
||||
style={{ backgroundColor: form.watch('color') }}
|
||||
>
|
||||
{form.watch('name') || 'Tag-Name'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={cancelForm}
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" size="sm" disabled={isCreating}>
|
||||
{editingId ? 'Speichern' : 'Erstellen'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{tags.length === 0 && !showForm ? (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
Keine Tags vorhanden. Erstellen Sie Ihr erstes Tag.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{tags.map((tag) => (
|
||||
<Card key={tag.id}>
|
||||
<CardContent className="flex items-center justify-between px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className="size-4 rounded"
|
||||
style={{ backgroundColor: tag.color }}
|
||||
/>
|
||||
<div>
|
||||
<span className="font-medium">{tag.name}</span>
|
||||
{tag.description && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{tag.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
onClick={() => startEditing(tag)}
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:text-destructive size-8"
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(`Tag "${tag.name}" wirklich löschen?`)
|
||||
) {
|
||||
executeDelete({ tagId: tag.id });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
123
packages/features/member-management/src/lib/errors.ts
Normal file
123
packages/features/member-management/src/lib/errors.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Standardized error codes and domain error classes
|
||||
* for the member management module.
|
||||
*/
|
||||
|
||||
export const MemberErrorCodes = {
|
||||
NOT_FOUND: 'MEMBER_NOT_FOUND',
|
||||
DUPLICATE: 'MEMBER_DUPLICATE',
|
||||
INVALID_STATUS_TRANSITION: 'MEMBER_INVALID_STATUS_TRANSITION',
|
||||
CONCURRENCY_CONFLICT: 'MEMBER_CONCURRENCY_CONFLICT',
|
||||
MERGE_CONFLICT: 'MEMBER_MERGE_CONFLICT',
|
||||
IMPORT_VALIDATION_FAILED: 'MEMBER_IMPORT_VALIDATION_FAILED',
|
||||
PERMISSION_DENIED: 'MEMBER_PERMISSION_DENIED',
|
||||
RATE_LIMITED: 'MEMBER_RATE_LIMITED',
|
||||
INVALID_IBAN: 'MEMBER_INVALID_IBAN',
|
||||
APPLICATION_NOT_REVIEWABLE: 'MEMBER_APPLICATION_NOT_REVIEWABLE',
|
||||
} as const;
|
||||
|
||||
export type MemberErrorCode =
|
||||
(typeof MemberErrorCodes)[keyof typeof MemberErrorCodes];
|
||||
|
||||
/**
|
||||
* Base domain error for member management operations.
|
||||
*/
|
||||
export class MemberDomainError extends Error {
|
||||
readonly code: MemberErrorCode;
|
||||
readonly statusCode: number;
|
||||
readonly details?: Record<string, unknown>;
|
||||
|
||||
constructor(
|
||||
code: MemberErrorCode,
|
||||
message: string,
|
||||
statusCode = 400,
|
||||
details?: Record<string, unknown>,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'MemberDomainError';
|
||||
this.code = code;
|
||||
this.statusCode = statusCode;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
export class MemberNotFoundError extends MemberDomainError {
|
||||
constructor(memberId: string) {
|
||||
super(
|
||||
MemberErrorCodes.NOT_FOUND,
|
||||
`Mitglied ${memberId} nicht gefunden`,
|
||||
404,
|
||||
{ memberId },
|
||||
);
|
||||
this.name = 'MemberNotFoundError';
|
||||
}
|
||||
}
|
||||
|
||||
export class DuplicateMemberError extends MemberDomainError {
|
||||
constructor(
|
||||
duplicates: Array<{ id: string; name: string; memberNumber?: string }>,
|
||||
) {
|
||||
super(MemberErrorCodes.DUPLICATE, 'Mögliche Duplikate gefunden', 409, {
|
||||
duplicates,
|
||||
});
|
||||
this.name = 'DuplicateMemberError';
|
||||
}
|
||||
}
|
||||
|
||||
export class InvalidStatusTransitionError extends MemberDomainError {
|
||||
constructor(from: string, to: string, validTargets: string[]) {
|
||||
super(
|
||||
MemberErrorCodes.INVALID_STATUS_TRANSITION,
|
||||
`Ungültiger Statuswechsel: ${from} → ${to}. Erlaubt: ${validTargets.join(', ') || 'keine'}`,
|
||||
422,
|
||||
{ from, to, validTargets },
|
||||
);
|
||||
this.name = 'InvalidStatusTransitionError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ConcurrencyConflictError extends MemberDomainError {
|
||||
constructor() {
|
||||
super(
|
||||
MemberErrorCodes.CONCURRENCY_CONFLICT,
|
||||
'Dieser Datensatz wurde zwischenzeitlich von einem anderen Benutzer geändert. Bitte laden Sie die Seite neu.',
|
||||
409,
|
||||
);
|
||||
this.name = 'ConcurrencyConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export class MergeConflictError extends MemberDomainError {
|
||||
constructor(
|
||||
conflicts: Record<string, { primary: unknown; secondary: unknown }>,
|
||||
) {
|
||||
super(
|
||||
MemberErrorCodes.MERGE_CONFLICT,
|
||||
'Konflikte beim Zusammenführen der Mitgliedsdaten',
|
||||
409,
|
||||
{ conflicts },
|
||||
);
|
||||
this.name = 'MergeConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ImportValidationError extends MemberDomainError {
|
||||
constructor(errors: Array<{ row: number; field: string; message: string }>) {
|
||||
super(
|
||||
MemberErrorCodes.IMPORT_VALIDATION_FAILED,
|
||||
`${errors.length} Validierungsfehler beim Import`,
|
||||
422,
|
||||
{ errors },
|
||||
);
|
||||
this.name = 'ImportValidationError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error is a MemberDomainError.
|
||||
*/
|
||||
export function isMemberDomainError(
|
||||
error: unknown,
|
||||
): error is MemberDomainError {
|
||||
return error instanceof MemberDomainError;
|
||||
}
|
||||
139
packages/features/member-management/src/lib/status-machine.ts
Normal file
139
packages/features/member-management/src/lib/status-machine.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import type { MembershipStatus } from '../schema/member.schema';
|
||||
|
||||
/**
|
||||
* Member status state machine.
|
||||
*
|
||||
* Defines valid transitions between membership statuses and their
|
||||
* side effects. Enforced in updateMember and bulkUpdateStatus.
|
||||
*/
|
||||
|
||||
type StatusTransition = {
|
||||
/** Fields to set automatically when this transition occurs */
|
||||
sideEffects?: Partial<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
const TRANSITIONS: Record<
|
||||
MembershipStatus,
|
||||
Partial<Record<MembershipStatus, StatusTransition>>
|
||||
> = {
|
||||
pending: {
|
||||
active: {},
|
||||
inactive: {},
|
||||
resigned: {
|
||||
sideEffects: { exit_date: () => todayISO() },
|
||||
},
|
||||
excluded: {
|
||||
sideEffects: { exit_date: () => todayISO() },
|
||||
},
|
||||
},
|
||||
active: {
|
||||
inactive: {},
|
||||
resigned: {
|
||||
sideEffects: { exit_date: () => todayISO() },
|
||||
},
|
||||
excluded: {
|
||||
sideEffects: { exit_date: () => todayISO() },
|
||||
},
|
||||
deceased: {
|
||||
sideEffects: {
|
||||
exit_date: () => todayISO(),
|
||||
is_archived: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
inactive: {
|
||||
active: {
|
||||
sideEffects: { exit_date: null, exit_reason: null },
|
||||
},
|
||||
resigned: {
|
||||
sideEffects: { exit_date: () => todayISO() },
|
||||
},
|
||||
excluded: {
|
||||
sideEffects: { exit_date: () => todayISO() },
|
||||
},
|
||||
deceased: {
|
||||
sideEffects: {
|
||||
exit_date: () => todayISO(),
|
||||
is_archived: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
resigned: {
|
||||
active: {
|
||||
sideEffects: { exit_date: null, exit_reason: null },
|
||||
},
|
||||
},
|
||||
excluded: {
|
||||
active: {
|
||||
sideEffects: { exit_date: null, exit_reason: null },
|
||||
},
|
||||
},
|
||||
// Terminal state — no transitions out
|
||||
deceased: {},
|
||||
};
|
||||
|
||||
function todayISO(): string {
|
||||
return new Date().toISOString().split('T')[0]!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a status transition is valid.
|
||||
*/
|
||||
export function canTransition(
|
||||
from: MembershipStatus,
|
||||
to: MembershipStatus,
|
||||
): boolean {
|
||||
if (from === to) return true; // no-op is always valid
|
||||
return to in (TRANSITIONS[from] ?? {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all valid target statuses from a given status.
|
||||
*/
|
||||
export function getValidTransitions(
|
||||
from: MembershipStatus,
|
||||
): MembershipStatus[] {
|
||||
return Object.keys(TRANSITIONS[from] ?? {}) as MembershipStatus[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the side effects for a transition.
|
||||
* Returns an object of field→value pairs to apply alongside the status change.
|
||||
* Function values should be called to get the actual value.
|
||||
*/
|
||||
export function getTransitionSideEffects(
|
||||
from: MembershipStatus,
|
||||
to: MembershipStatus,
|
||||
): Record<string, unknown> {
|
||||
if (from === to) return {};
|
||||
|
||||
const transition = TRANSITIONS[from]?.[to];
|
||||
if (!transition?.sideEffects) return {};
|
||||
|
||||
const result: Record<string, unknown> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(transition.sideEffects)) {
|
||||
result[key] = typeof value === 'function' ? value() : value;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a status transition and return side effects.
|
||||
* Throws if the transition is invalid.
|
||||
*/
|
||||
export function validateTransition(
|
||||
from: MembershipStatus,
|
||||
to: MembershipStatus,
|
||||
): Record<string, unknown> {
|
||||
if (from === to) return {};
|
||||
|
||||
if (!canTransition(from, to)) {
|
||||
throw new Error(
|
||||
`Ungültiger Statuswechsel: ${from} → ${to}. Erlaubte Übergänge: ${getValidTransitions(from).join(', ') || 'keine'}`,
|
||||
);
|
||||
}
|
||||
|
||||
return getTransitionSideEffects(from, to);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const CommunicationTypeEnum = z.enum([
|
||||
'email',
|
||||
'phone',
|
||||
'letter',
|
||||
'meeting',
|
||||
'note',
|
||||
'sms',
|
||||
]);
|
||||
export type CommunicationType = z.infer<typeof CommunicationTypeEnum>;
|
||||
|
||||
export const CommunicationDirectionEnum = z.enum([
|
||||
'inbound',
|
||||
'outbound',
|
||||
'internal',
|
||||
]);
|
||||
export type CommunicationDirection = z.infer<typeof CommunicationDirectionEnum>;
|
||||
|
||||
export const CreateCommunicationSchema = z
|
||||
.object({
|
||||
memberId: z.string().uuid(),
|
||||
accountId: z.string().uuid(),
|
||||
type: CommunicationTypeEnum,
|
||||
direction: CommunicationDirectionEnum.default('outbound'),
|
||||
subject: z.string().max(500).optional(),
|
||||
body: z.string().max(50000).optional(),
|
||||
emailTo: z.string().email().optional().or(z.literal('')),
|
||||
emailCc: z.string().max(1000).optional(),
|
||||
emailMessageId: z.string().max(256).optional(),
|
||||
attachmentPaths: z.array(z.string().max(512)).max(10).optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
// Email type requires a recipient
|
||||
if (
|
||||
data.type === 'email' &&
|
||||
(!data.emailTo || data.emailTo.trim() === '')
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'E-Mail-Empfänger ist für den Typ "E-Mail" erforderlich',
|
||||
path: ['emailTo'],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type CreateCommunicationInput = z.infer<
|
||||
typeof CreateCommunicationSchema
|
||||
>;
|
||||
|
||||
export const CommunicationListFiltersSchema = z.object({
|
||||
memberId: z.string().uuid(),
|
||||
accountId: z.string().uuid(),
|
||||
type: CommunicationTypeEnum.optional(),
|
||||
direction: CommunicationDirectionEnum.optional(),
|
||||
search: z.string().max(256).optional(),
|
||||
page: z.number().int().min(1).default(1),
|
||||
pageSize: z.number().int().min(1).max(100).default(25),
|
||||
});
|
||||
|
||||
export type CommunicationListFilters = z.infer<
|
||||
typeof CommunicationListFiltersSchema
|
||||
>;
|
||||
|
||||
export const DeleteCommunicationSchema = z.object({
|
||||
communicationId: z.string().uuid(),
|
||||
accountId: z.string().uuid(),
|
||||
});
|
||||
@@ -17,66 +17,140 @@ export const SepaMandateStatusEnum = z.enum([
|
||||
'expired',
|
||||
]);
|
||||
|
||||
export const CreateMemberSchema = z.object({
|
||||
accountId: z.string().uuid(),
|
||||
memberNumber: z.string().optional(),
|
||||
firstName: z.string().min(1).max(128),
|
||||
lastName: z.string().min(1).max(128),
|
||||
dateOfBirth: z.string().optional(),
|
||||
gender: z.enum(['male', 'female', 'diverse']).optional(),
|
||||
title: z.string().max(32).optional(),
|
||||
email: z.string().email().optional().or(z.literal('')),
|
||||
phone: z.string().max(32).optional(),
|
||||
mobile: z.string().max(32).optional(),
|
||||
street: z.string().max(256).optional(),
|
||||
houseNumber: z.string().max(16).optional(),
|
||||
postalCode: z.string().max(10).optional(),
|
||||
city: z.string().max(128).optional(),
|
||||
country: z.string().max(2).default('DE'),
|
||||
status: MembershipStatusEnum.default('active'),
|
||||
entryDate: z.string().default(() => new Date().toISOString().split('T')[0]!),
|
||||
duesCategoryId: z.string().uuid().optional(),
|
||||
iban: z.string().max(34).optional(),
|
||||
bic: z.string().max(11).optional(),
|
||||
accountHolder: z.string().max(128).optional(),
|
||||
gdprConsent: z.boolean().default(false),
|
||||
notes: z.string().optional(),
|
||||
// New optional fields
|
||||
salutation: z.string().optional(),
|
||||
street2: z.string().optional(),
|
||||
phone2: z.string().optional(),
|
||||
fax: z.string().optional(),
|
||||
birthplace: z.string().optional(),
|
||||
birthCountry: z.string().default('DE'),
|
||||
isHonorary: z.boolean().default(false),
|
||||
isFoundingMember: z.boolean().default(false),
|
||||
isYouth: z.boolean().default(false),
|
||||
isRetiree: z.boolean().default(false),
|
||||
isProbationary: z.boolean().default(false),
|
||||
isTransferred: z.boolean().default(false),
|
||||
exitDate: z.string().optional(),
|
||||
exitReason: z.string().optional(),
|
||||
guardianName: z.string().optional(),
|
||||
guardianPhone: z.string().optional(),
|
||||
guardianEmail: z.string().optional(),
|
||||
duesYear: z.number().int().optional(),
|
||||
duesPaid: z.boolean().default(false),
|
||||
additionalFees: z.number().default(0),
|
||||
exemptionType: z.string().optional(),
|
||||
exemptionReason: z.string().optional(),
|
||||
exemptionAmount: z.number().optional(),
|
||||
gdprNewsletter: z.boolean().default(false),
|
||||
gdprInternet: z.boolean().default(false),
|
||||
gdprPrint: z.boolean().default(false),
|
||||
gdprBirthdayInfo: z.boolean().default(false),
|
||||
sepaMandateReference: z.string().optional(),
|
||||
});
|
||||
// --- Shared validators ---
|
||||
|
||||
/** IBAN validation with mod-97 checksum (ISO 13616) */
|
||||
export function validateIban(iban: string): boolean {
|
||||
const cleaned = iban.replace(/\s/g, '').toUpperCase();
|
||||
if (!/^[A-Z]{2}\d{2}[A-Z0-9]{4,30}$/.test(cleaned)) return false;
|
||||
|
||||
const rearranged = cleaned.slice(4) + cleaned.slice(0, 4);
|
||||
let numStr = '';
|
||||
for (const char of rearranged) {
|
||||
const code = char.charCodeAt(0);
|
||||
numStr += code >= 65 && code <= 90 ? (code - 55).toString() : char;
|
||||
}
|
||||
|
||||
let remainder = 0;
|
||||
for (const digit of numStr) {
|
||||
remainder = (remainder * 10 + parseInt(digit, 10)) % 97;
|
||||
}
|
||||
return remainder === 1;
|
||||
}
|
||||
|
||||
const ibanSchema = z
|
||||
.string()
|
||||
.max(34)
|
||||
.optional()
|
||||
.refine((v) => !v || v.trim() === '' || validateIban(v), {
|
||||
message: 'Ungültige IBAN (Prüfsumme fehlerhaft)',
|
||||
});
|
||||
|
||||
const dateNotFutureSchema = (fieldName: string) =>
|
||||
z
|
||||
.string()
|
||||
.optional()
|
||||
.refine((v) => !v || new Date(v) <= new Date(), {
|
||||
message: `${fieldName} darf nicht in der Zukunft liegen`,
|
||||
});
|
||||
|
||||
// --- Main schemas ---
|
||||
|
||||
export const CreateMemberSchema = z
|
||||
.object({
|
||||
accountId: z.string().uuid(),
|
||||
memberNumber: z.string().optional(),
|
||||
firstName: z.string().min(1).max(128),
|
||||
lastName: z.string().min(1).max(128),
|
||||
dateOfBirth: dateNotFutureSchema('Geburtsdatum'),
|
||||
gender: z.enum(['male', 'female', 'diverse']).optional(),
|
||||
title: z.string().max(32).optional(),
|
||||
email: z.string().email().optional().or(z.literal('')),
|
||||
phone: z.string().max(32).optional(),
|
||||
mobile: z.string().max(32).optional(),
|
||||
street: z.string().max(256).optional(),
|
||||
houseNumber: z.string().max(16).optional(),
|
||||
postalCode: z.string().max(10).optional(),
|
||||
city: z.string().max(128).optional(),
|
||||
country: z.string().max(2).default('DE'),
|
||||
status: MembershipStatusEnum.default('active'),
|
||||
entryDate: z
|
||||
.string()
|
||||
.default(() => new Date().toISOString().split('T')[0]!),
|
||||
duesCategoryId: z.string().uuid().optional(),
|
||||
iban: ibanSchema,
|
||||
bic: z.string().max(11).optional(),
|
||||
accountHolder: z.string().max(128).optional(),
|
||||
gdprConsent: z.boolean().default(false),
|
||||
notes: z.string().optional(),
|
||||
salutation: z.string().optional(),
|
||||
street2: z.string().optional(),
|
||||
phone2: z.string().optional(),
|
||||
fax: z.string().optional(),
|
||||
birthplace: z.string().optional(),
|
||||
birthCountry: z.string().default('DE'),
|
||||
isHonorary: z.boolean().default(false),
|
||||
isFoundingMember: z.boolean().default(false),
|
||||
isYouth: z.boolean().default(false),
|
||||
isRetiree: z.boolean().default(false),
|
||||
isProbationary: z.boolean().default(false),
|
||||
isTransferred: z.boolean().default(false),
|
||||
exitDate: z.string().optional(),
|
||||
exitReason: z.string().optional(),
|
||||
guardianName: z.string().optional(),
|
||||
guardianPhone: z.string().optional(),
|
||||
guardianEmail: z.string().optional(),
|
||||
duesYear: z.number().int().optional(),
|
||||
duesPaid: z.boolean().default(false),
|
||||
additionalFees: z.number().default(0),
|
||||
exemptionType: z.string().optional(),
|
||||
exemptionReason: z.string().optional(),
|
||||
exemptionAmount: z.number().optional(),
|
||||
gdprNewsletter: z.boolean().default(false),
|
||||
gdprInternet: z.boolean().default(false),
|
||||
gdprPrint: z.boolean().default(false),
|
||||
gdprBirthdayInfo: z.boolean().default(false),
|
||||
sepaMandateReference: z.string().optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
// Cross-field: exit_date must be after entry_date
|
||||
if (data.exitDate && data.entryDate && data.exitDate < data.entryDate) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Austrittsdatum muss nach dem Eintrittsdatum liegen',
|
||||
path: ['exitDate'],
|
||||
});
|
||||
}
|
||||
|
||||
// Cross-field: entry_date must be after date_of_birth
|
||||
if (
|
||||
data.dateOfBirth &&
|
||||
data.entryDate &&
|
||||
data.entryDate < data.dateOfBirth
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Eintrittsdatum muss nach dem Geburtsdatum liegen',
|
||||
path: ['entryDate'],
|
||||
});
|
||||
}
|
||||
|
||||
// Cross-field: youth members should have guardian info
|
||||
if (data.isYouth && !data.guardianName) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Jugendmitglieder benötigen einen Erziehungsberechtigten',
|
||||
path: ['guardianName'],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type CreateMemberInput = z.infer<typeof CreateMemberSchema>;
|
||||
|
||||
export const UpdateMemberSchema = CreateMemberSchema.partial().extend({
|
||||
memberId: z.string().uuid(),
|
||||
isArchived: z.boolean().optional(),
|
||||
version: z.number().int().optional(),
|
||||
});
|
||||
|
||||
export type UpdateMemberInput = z.infer<typeof UpdateMemberSchema>;
|
||||
@@ -128,7 +202,13 @@ export const CreateSepaMandateSchema = z.object({
|
||||
memberId: z.string().uuid(),
|
||||
accountId: z.string().uuid(),
|
||||
mandateReference: z.string().min(1),
|
||||
iban: z.string().min(15).max(34),
|
||||
iban: z
|
||||
.string()
|
||||
.min(15)
|
||||
.max(34)
|
||||
.refine((v) => validateIban(v), {
|
||||
message: 'Ungültige IBAN (Prüfsumme fehlerhaft)',
|
||||
}),
|
||||
bic: z.string().optional(),
|
||||
accountHolder: z.string().min(1),
|
||||
mandateDate: z.string(),
|
||||
@@ -149,7 +229,14 @@ export type UpdateDuesCategoryInput = z.infer<typeof UpdateDuesCategorySchema>;
|
||||
|
||||
export const UpdateMandateSchema = z.object({
|
||||
mandateId: z.string().uuid(),
|
||||
iban: z.string().min(15).max(34).optional(),
|
||||
iban: z
|
||||
.string()
|
||||
.min(15)
|
||||
.max(34)
|
||||
.refine((v) => validateIban(v), {
|
||||
message: 'Ungültige IBAN (Prüfsumme fehlerhaft)',
|
||||
})
|
||||
.optional(),
|
||||
bic: z.string().optional(),
|
||||
accountHolder: z.string().optional(),
|
||||
sequence: z.enum(['FRST', 'RCUR', 'FNAL', 'OOFF']).optional(),
|
||||
@@ -191,6 +278,7 @@ export const MemberSearchFiltersSchema = z.object({
|
||||
search: z.string().optional(),
|
||||
status: z.array(MembershipStatusEnum).optional(),
|
||||
departmentIds: z.array(z.string().uuid()).optional(),
|
||||
tagIds: z.array(z.string().uuid()).optional(),
|
||||
duesCategoryId: z.string().uuid().optional(),
|
||||
flags: z
|
||||
.array(
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const TriggerEventEnum = z.enum([
|
||||
'application.submitted',
|
||||
'application.approved',
|
||||
'application.rejected',
|
||||
'member.created',
|
||||
'member.status_changed',
|
||||
'member.birthday',
|
||||
'member.anniversary',
|
||||
'dues.unpaid',
|
||||
'mandate.revoked',
|
||||
]);
|
||||
|
||||
export const NotificationChannelEnum = z.enum(['in_app', 'email', 'both']);
|
||||
|
||||
export const RecipientTypeEnum = z.enum([
|
||||
'admin',
|
||||
'member',
|
||||
'specific_user',
|
||||
'role_holder',
|
||||
]);
|
||||
|
||||
export const CreateNotificationRuleSchema = z.object({
|
||||
accountId: z.string().uuid(),
|
||||
triggerEvent: TriggerEventEnum,
|
||||
channel: NotificationChannelEnum.default('in_app'),
|
||||
recipientType: RecipientTypeEnum,
|
||||
recipientConfig: z.record(z.string(), z.unknown()).default({}),
|
||||
subjectTemplate: z.string().max(256).optional(),
|
||||
messageTemplate: z.string().min(1).max(2000),
|
||||
isActive: z.boolean().default(true),
|
||||
});
|
||||
|
||||
export type CreateNotificationRuleInput = z.infer<
|
||||
typeof CreateNotificationRuleSchema
|
||||
>;
|
||||
|
||||
export const UpdateNotificationRuleSchema = z.object({
|
||||
ruleId: z.string().uuid(),
|
||||
triggerEvent: TriggerEventEnum.optional(),
|
||||
channel: NotificationChannelEnum.optional(),
|
||||
recipientType: RecipientTypeEnum.optional(),
|
||||
recipientConfig: z.record(z.string(), z.unknown()).optional(),
|
||||
subjectTemplate: z.string().max(256).optional(),
|
||||
messageTemplate: z.string().min(1).max(2000).optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const DeleteNotificationRuleSchema = z.object({
|
||||
ruleId: z.string().uuid(),
|
||||
});
|
||||
|
||||
export const ListNotificationRulesSchema = z.object({
|
||||
accountId: z.string().uuid(),
|
||||
});
|
||||
|
||||
// Scheduled jobs
|
||||
export const JobTypeEnum = z.enum([
|
||||
'birthday_notification',
|
||||
'anniversary_notification',
|
||||
'dues_reminder',
|
||||
'data_quality_check',
|
||||
'gdpr_retention_check',
|
||||
]);
|
||||
|
||||
export const ConfigureScheduledJobSchema = z.object({
|
||||
accountId: z.string().uuid(),
|
||||
jobType: JobTypeEnum,
|
||||
isEnabled: z.boolean(),
|
||||
config: z.record(z.string(), z.unknown()).default({}),
|
||||
});
|
||||
|
||||
export const ListScheduledJobsSchema = z.object({
|
||||
accountId: z.string().uuid(),
|
||||
});
|
||||
41
packages/features/member-management/src/schema/tag.schema.ts
Normal file
41
packages/features/member-management/src/schema/tag.schema.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const hexColorRegex = /^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/;
|
||||
|
||||
export const CreateTagSchema = z.object({
|
||||
accountId: z.string().uuid(),
|
||||
name: z.string().min(1).max(64),
|
||||
color: z
|
||||
.string()
|
||||
.regex(hexColorRegex, 'Ungültiger Hex-Farbcode')
|
||||
.default('#6B7280'),
|
||||
description: z.string().max(256).optional(),
|
||||
});
|
||||
export type CreateTagInput = z.infer<typeof CreateTagSchema>;
|
||||
|
||||
export const UpdateTagSchema = z.object({
|
||||
tagId: z.string().uuid(),
|
||||
name: z.string().min(1).max(64).optional(),
|
||||
color: z.string().regex(hexColorRegex, 'Ungültiger Hex-Farbcode').optional(),
|
||||
description: z.string().max(256).optional(),
|
||||
sortOrder: z.number().int().min(0).optional(),
|
||||
});
|
||||
export type UpdateTagInput = z.infer<typeof UpdateTagSchema>;
|
||||
|
||||
export const DeleteTagSchema = z.object({
|
||||
tagId: z.string().uuid(),
|
||||
});
|
||||
|
||||
export const AssignTagSchema = z.object({
|
||||
memberId: z.string().uuid(),
|
||||
tagId: z.string().uuid(),
|
||||
});
|
||||
|
||||
export const BulkAssignTagSchema = z.object({
|
||||
memberIds: z.array(z.string().uuid()).min(1),
|
||||
tagId: z.string().uuid(),
|
||||
});
|
||||
|
||||
export const ListTagsSchema = z.object({
|
||||
accountId: z.string().uuid(),
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
'use server';
|
||||
|
||||
import { authActionClient } from '@kit/next/safe-action';
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import {
|
||||
CommunicationListFiltersSchema,
|
||||
CreateCommunicationSchema,
|
||||
DeleteCommunicationSchema,
|
||||
} from '../../schema/communication.schema';
|
||||
import { createMemberServices } from '../services';
|
||||
|
||||
export const listCommunications = authActionClient
|
||||
.inputSchema(CommunicationListFiltersSchema)
|
||||
.action(async ({ parsedInput: input }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const { communication } = createMemberServices(client);
|
||||
|
||||
const result = await communication.list(input);
|
||||
return { success: true, data: result };
|
||||
});
|
||||
|
||||
export const createCommunication = authActionClient
|
||||
.inputSchema(CreateCommunicationSchema)
|
||||
.action(async ({ parsedInput: input, ctx }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
const { communication } = createMemberServices(client);
|
||||
const userId = ctx.user.id;
|
||||
|
||||
logger.info(
|
||||
{
|
||||
name: 'communication.create',
|
||||
memberId: input.memberId,
|
||||
type: input.type,
|
||||
},
|
||||
'Logging communication...',
|
||||
);
|
||||
|
||||
const result = await communication.create(input, userId);
|
||||
|
||||
logger.info(
|
||||
{ name: 'communication.create', communicationId: result.id },
|
||||
'Communication logged',
|
||||
);
|
||||
|
||||
return { success: true, data: result };
|
||||
});
|
||||
|
||||
export const deleteCommunication = authActionClient
|
||||
.inputSchema(DeleteCommunicationSchema)
|
||||
.action(async ({ parsedInput: input }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
const { communication } = createMemberServices(client);
|
||||
|
||||
logger.info(
|
||||
{ name: 'communication.delete', communicationId: input.communicationId },
|
||||
'Deleting communication...',
|
||||
);
|
||||
|
||||
await communication.delete(input.communicationId, input.accountId);
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import { authActionClient } from '@kit/next/safe-action';
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import { DuplicateMemberError, isMemberDomainError } from '../../lib/errors';
|
||||
import {
|
||||
CreateMemberSchema,
|
||||
UpdateMemberSchema,
|
||||
@@ -24,40 +25,43 @@ import {
|
||||
BulkArchiveSchema,
|
||||
QuickSearchSchema,
|
||||
} from '../../schema/member.schema';
|
||||
import { createMemberManagementApi } from '../api';
|
||||
import { createMemberServices } from '../services';
|
||||
|
||||
// --- Member CRUD (via MemberMutationService) ---
|
||||
|
||||
export const createMember = authActionClient
|
||||
.inputSchema(CreateMemberSchema)
|
||||
.action(async ({ parsedInput: input, ctx }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
const api = createMemberManagementApi(client);
|
||||
const { mutation } = createMemberServices(client);
|
||||
const userId = ctx.user.id;
|
||||
|
||||
// Check for duplicates before creating
|
||||
const duplicates = await api.checkDuplicate(
|
||||
input.accountId,
|
||||
input.firstName,
|
||||
input.lastName,
|
||||
input.dateOfBirth,
|
||||
);
|
||||
|
||||
if (duplicates.length > 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Mögliche Duplikate gefunden',
|
||||
validationErrors: duplicates.map((d: Record<string, unknown>) => ({
|
||||
field: 'name',
|
||||
message: `${d.first_name} ${d.last_name}${d.member_number ? ` (Nr. ${d.member_number})` : ''}`,
|
||||
id: String(d.id),
|
||||
})),
|
||||
};
|
||||
try {
|
||||
logger.info({ name: 'member.create' }, 'Creating member...');
|
||||
const result = await mutation.create(input, userId);
|
||||
logger.info({ name: 'member.create' }, 'Member created');
|
||||
return { success: true, data: result };
|
||||
} catch (e) {
|
||||
if (e instanceof DuplicateMemberError) {
|
||||
return {
|
||||
success: false,
|
||||
error: e.message,
|
||||
validationErrors: (
|
||||
e.details?.duplicates as Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
memberNumber?: string;
|
||||
}>
|
||||
)?.map((d) => ({
|
||||
field: 'name',
|
||||
message: `${d.name}${d.memberNumber ? ` (Nr. ${d.memberNumber})` : ''}`,
|
||||
id: d.id,
|
||||
})),
|
||||
};
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
logger.info({ name: 'member.create' }, 'Creating member...');
|
||||
const result = await api.createMember(input, userId);
|
||||
logger.info({ name: 'member.create' }, 'Member created');
|
||||
return { success: true, data: result };
|
||||
});
|
||||
|
||||
export const updateMember = authActionClient
|
||||
@@ -65,11 +69,11 @@ export const updateMember = authActionClient
|
||||
.action(async ({ parsedInput: input, ctx }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
const api = createMemberManagementApi(client);
|
||||
const { mutation } = createMemberServices(client);
|
||||
const userId = ctx.user.id;
|
||||
|
||||
logger.info({ name: 'member.update' }, 'Updating member...');
|
||||
const result = await api.updateMember(input, userId);
|
||||
const result = await mutation.update(input, userId);
|
||||
logger.info({ name: 'member.update' }, 'Member updated');
|
||||
return { success: true, data: result };
|
||||
});
|
||||
@@ -81,17 +85,19 @@ export const deleteMember = authActionClient
|
||||
accountId: z.string().uuid(),
|
||||
}),
|
||||
)
|
||||
.action(async ({ parsedInput: input, ctx }) => {
|
||||
.action(async ({ parsedInput: input }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
const api = createMemberManagementApi(client);
|
||||
const { mutation } = createMemberServices(client);
|
||||
|
||||
logger.info({ name: 'member.delete' }, 'Deleting member...');
|
||||
const result = await api.deleteMember(input.memberId);
|
||||
await mutation.softDelete(input.memberId);
|
||||
logger.info({ name: 'member.delete' }, 'Member deleted');
|
||||
return { success: true, data: result };
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// --- Application Workflow (via MemberWorkflowService) ---
|
||||
|
||||
export const approveApplication = authActionClient
|
||||
.inputSchema(
|
||||
z.object({
|
||||
@@ -102,14 +108,17 @@ export const approveApplication = authActionClient
|
||||
.action(async ({ parsedInput: input, ctx }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
const api = createMemberManagementApi(client);
|
||||
const { workflow } = createMemberServices(client);
|
||||
const userId = ctx.user.id;
|
||||
|
||||
logger.info(
|
||||
{ name: 'member.approveApplication' },
|
||||
'Approving application...',
|
||||
);
|
||||
const result = await api.approveApplication(input.applicationId, userId);
|
||||
const result = await workflow.approveApplication(
|
||||
input.applicationId,
|
||||
userId,
|
||||
);
|
||||
logger.info({ name: 'member.approveApplication' }, 'Application approved');
|
||||
return { success: true, data: result };
|
||||
});
|
||||
@@ -119,12 +128,13 @@ export const rejectApplication = authActionClient
|
||||
.action(async ({ parsedInput: input, ctx }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
const api = createMemberManagementApi(client);
|
||||
const { workflow } = createMemberServices(client);
|
||||
|
||||
logger.info(
|
||||
{ name: 'members.reject-application' },
|
||||
'Rejecting application...',
|
||||
);
|
||||
await api.rejectApplication(
|
||||
await workflow.rejectApplication(
|
||||
input.applicationId,
|
||||
ctx.user.id,
|
||||
input.reviewNotes,
|
||||
@@ -132,12 +142,14 @@ export const rejectApplication = authActionClient
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// --- Organization (via MemberOrganizationService) ---
|
||||
|
||||
export const createDuesCategory = authActionClient
|
||||
.inputSchema(CreateDuesCategorySchema)
|
||||
.action(async ({ parsedInput: input }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const api = createMemberManagementApi(client);
|
||||
const data = await api.createDuesCategory(input);
|
||||
const { organization } = createMemberServices(client);
|
||||
const data = await organization.createDuesCategory(input);
|
||||
return { success: true, data };
|
||||
});
|
||||
|
||||
@@ -145,8 +157,8 @@ export const deleteDuesCategory = authActionClient
|
||||
.inputSchema(z.object({ categoryId: z.string().uuid() }))
|
||||
.action(async ({ parsedInput: input }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const api = createMemberManagementApi(client);
|
||||
await api.deleteDuesCategory(input.categoryId);
|
||||
const { organization } = createMemberServices(client);
|
||||
await organization.deleteDuesCategory(input.categoryId);
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
@@ -154,8 +166,8 @@ export const createDepartment = authActionClient
|
||||
.inputSchema(CreateDepartmentSchema)
|
||||
.action(async ({ parsedInput: input }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const api = createMemberManagementApi(client);
|
||||
const data = await api.createDepartment(input);
|
||||
const { organization } = createMemberServices(client);
|
||||
const data = await organization.createDepartment(input);
|
||||
return { success: true, data };
|
||||
});
|
||||
|
||||
@@ -163,8 +175,8 @@ export const createMemberRole = authActionClient
|
||||
.inputSchema(CreateMemberRoleSchema)
|
||||
.action(async ({ parsedInput: input }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const api = createMemberManagementApi(client);
|
||||
const data = await api.createMemberRole(input);
|
||||
const { organization } = createMemberServices(client);
|
||||
const data = await organization.createMemberRole(input);
|
||||
return { success: true, data };
|
||||
});
|
||||
|
||||
@@ -172,8 +184,8 @@ export const deleteMemberRole = authActionClient
|
||||
.inputSchema(z.object({ roleId: z.string().uuid() }))
|
||||
.action(async ({ parsedInput: input }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const api = createMemberManagementApi(client);
|
||||
await api.deleteMemberRole(input.roleId);
|
||||
const { organization } = createMemberServices(client);
|
||||
await organization.deleteMemberRole(input.roleId);
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
@@ -181,8 +193,8 @@ export const createMemberHonor = authActionClient
|
||||
.inputSchema(CreateMemberHonorSchema)
|
||||
.action(async ({ parsedInput: input }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const api = createMemberManagementApi(client);
|
||||
const data = await api.createMemberHonor(input);
|
||||
const { organization } = createMemberServices(client);
|
||||
const data = await organization.createMemberHonor(input);
|
||||
return { success: true, data };
|
||||
});
|
||||
|
||||
@@ -190,8 +202,8 @@ export const deleteMemberHonor = authActionClient
|
||||
.inputSchema(z.object({ honorId: z.string().uuid() }))
|
||||
.action(async ({ parsedInput: input }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const api = createMemberManagementApi(client);
|
||||
await api.deleteMemberHonor(input.honorId);
|
||||
const { organization } = createMemberServices(client);
|
||||
await organization.deleteMemberHonor(input.honorId);
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
@@ -199,8 +211,8 @@ export const createMandate = authActionClient
|
||||
.inputSchema(CreateSepaMandateSchema)
|
||||
.action(async ({ parsedInput: input }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const api = createMemberManagementApi(client);
|
||||
const data = await api.createMandate(input);
|
||||
const { organization } = createMemberServices(client);
|
||||
const data = await organization.createMandate(input);
|
||||
return { success: true, data };
|
||||
});
|
||||
|
||||
@@ -208,18 +220,17 @@ export const revokeMandate = authActionClient
|
||||
.inputSchema(z.object({ mandateId: z.string().uuid() }))
|
||||
.action(async ({ parsedInput: input }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const api = createMemberManagementApi(client);
|
||||
await api.revokeMandate(input.mandateId);
|
||||
const { organization } = createMemberServices(client);
|
||||
await organization.revokeMandate(input.mandateId);
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// Gap 1: Update operations
|
||||
export const updateDuesCategory = authActionClient
|
||||
.inputSchema(UpdateDuesCategorySchema)
|
||||
.action(async ({ parsedInput: input }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const api = createMemberManagementApi(client);
|
||||
const data = await api.updateDuesCategory(input);
|
||||
const { organization } = createMemberServices(client);
|
||||
const data = await organization.updateDuesCategory(input);
|
||||
return { success: true, data };
|
||||
});
|
||||
|
||||
@@ -227,18 +238,19 @@ export const updateMandate = authActionClient
|
||||
.inputSchema(UpdateMandateSchema)
|
||||
.action(async ({ parsedInput: input }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const api = createMemberManagementApi(client);
|
||||
const data = await api.updateMandate(input);
|
||||
const { organization } = createMemberServices(client);
|
||||
const data = await organization.updateMandate(input);
|
||||
return { success: true, data };
|
||||
});
|
||||
|
||||
// Gap 2: Export
|
||||
// --- Export (stays on api.ts — export logic not in services) ---
|
||||
|
||||
export const exportMembers = authActionClient
|
||||
.inputSchema(ExportMembersSchema)
|
||||
.action(async ({ parsedInput: input }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const api = createMemberManagementApi(client);
|
||||
const csv = await api.exportMembersCsv(input.accountId, {
|
||||
const { export: exportService } = createMemberServices(client);
|
||||
const csv = await exportService.exportCsv(input.accountId, {
|
||||
status: input.status,
|
||||
});
|
||||
return {
|
||||
@@ -251,32 +263,12 @@ export const exportMembers = authActionClient
|
||||
};
|
||||
});
|
||||
|
||||
// Gap 5: Department assignments
|
||||
export const assignDepartment = authActionClient
|
||||
.inputSchema(AssignDepartmentSchema)
|
||||
.action(async ({ parsedInput: input }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const api = createMemberManagementApi(client);
|
||||
await api.assignDepartment(input.memberId, input.departmentId);
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
export const removeDepartment = authActionClient
|
||||
.inputSchema(AssignDepartmentSchema)
|
||||
.action(async ({ parsedInput: input }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const api = createMemberManagementApi(client);
|
||||
await api.removeDepartment(input.memberId, input.departmentId);
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// Gap 2: Excel export
|
||||
export const exportMembersExcel = authActionClient
|
||||
.inputSchema(ExportMembersSchema)
|
||||
.action(async ({ parsedInput: input }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const api = createMemberManagementApi(client);
|
||||
const buffer = await api.exportMembersExcel(input.accountId, {
|
||||
const { export: exportService } = createMemberServices(client);
|
||||
const buffer = await exportService.exportExcel(input.accountId, {
|
||||
status: input.status,
|
||||
});
|
||||
return {
|
||||
@@ -290,7 +282,28 @@ export const exportMembersExcel = authActionClient
|
||||
};
|
||||
});
|
||||
|
||||
// Gap 6: Member card PDF generation
|
||||
// --- Department assignments (via MemberOrganizationService) ---
|
||||
|
||||
export const assignDepartment = authActionClient
|
||||
.inputSchema(AssignDepartmentSchema)
|
||||
.action(async ({ parsedInput: input }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const { organization } = createMemberServices(client);
|
||||
await organization.assignDepartment(input.memberId, input.departmentId);
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
export const removeDepartment = authActionClient
|
||||
.inputSchema(AssignDepartmentSchema)
|
||||
.action(async ({ parsedInput: input }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const { organization } = createMemberServices(client);
|
||||
await organization.removeDepartment(input.memberId, input.departmentId);
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// --- Member cards (uses separate card generator service) ---
|
||||
|
||||
export const generateMemberCards = authActionClient
|
||||
.inputSchema(
|
||||
z.object({
|
||||
@@ -301,7 +314,6 @@ export const generateMemberCards = authActionClient
|
||||
)
|
||||
.action(async ({ parsedInput: input }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const _api = createMemberManagementApi(client);
|
||||
|
||||
let query = client
|
||||
.from('members')
|
||||
@@ -337,7 +349,8 @@ export const generateMemberCards = authActionClient
|
||||
};
|
||||
});
|
||||
|
||||
// Portal Invitations
|
||||
// --- Portal (via MemberWorkflowService) ---
|
||||
|
||||
export const inviteMemberToPortal = authActionClient
|
||||
.inputSchema(
|
||||
z.object({
|
||||
@@ -349,18 +362,18 @@ export const inviteMemberToPortal = authActionClient
|
||||
.action(async ({ parsedInput: input, ctx }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
const api = createMemberManagementApi(client);
|
||||
const { workflow } = createMemberServices(client);
|
||||
|
||||
logger.info(
|
||||
{ name: 'portal.invite', memberId: input.memberId },
|
||||
'Sending portal invitation...',
|
||||
);
|
||||
|
||||
const invitation = await api.inviteMemberToPortal(input, ctx.user.id);
|
||||
const invitation = await workflow.inviteMemberToPortal({
|
||||
...input,
|
||||
userId: ctx.user.id,
|
||||
});
|
||||
|
||||
// Create auth user for the member if not exists
|
||||
// In production: send invitation email with the token link
|
||||
// For now: create the user directly via admin API
|
||||
logger.info(
|
||||
{ name: 'portal.invite', invitationId: invitation.id },
|
||||
'Invitation created',
|
||||
@@ -373,8 +386,8 @@ export const revokePortalInvitation = authActionClient
|
||||
.inputSchema(z.object({ invitationId: z.string().uuid() }))
|
||||
.action(async ({ parsedInput: input }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const api = createMemberManagementApi(client);
|
||||
await api.revokePortalInvitation(input.invitationId);
|
||||
const { workflow } = createMemberServices(client);
|
||||
await workflow.revokePortalInvitation(input.invitationId);
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
@@ -385,13 +398,13 @@ export const bulkUpdateStatus = authActionClient
|
||||
.action(async ({ parsedInput: input, ctx }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
const api = createMemberManagementApi(client);
|
||||
const { mutation } = createMemberServices(client);
|
||||
|
||||
logger.info(
|
||||
{ name: 'member.bulkStatus', count: input.memberIds.length },
|
||||
`Bulk updating status to ${input.status}...`,
|
||||
);
|
||||
await api.bulkUpdateStatus(input.memberIds, input.status, ctx.user.id);
|
||||
await mutation.bulkUpdateStatus(input.memberIds, input.status, ctx.user.id);
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
@@ -400,13 +413,16 @@ export const bulkAssignDepartment = authActionClient
|
||||
.action(async ({ parsedInput: input }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
const api = createMemberManagementApi(client);
|
||||
const { organization } = createMemberServices(client);
|
||||
|
||||
logger.info(
|
||||
{ name: 'member.bulkDepartment', count: input.memberIds.length },
|
||||
'Bulk assigning department...',
|
||||
);
|
||||
await api.bulkAssignDepartment(input.memberIds, input.departmentId);
|
||||
await organization.bulkAssignDepartment(
|
||||
input.memberIds,
|
||||
input.departmentId,
|
||||
);
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
@@ -415,22 +431,24 @@ export const bulkArchiveMembers = authActionClient
|
||||
.action(async ({ parsedInput: input, ctx }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
const api = createMemberManagementApi(client);
|
||||
const { mutation } = createMemberServices(client);
|
||||
|
||||
logger.info(
|
||||
{ name: 'member.bulkArchive', count: input.memberIds.length },
|
||||
'Bulk archiving members...',
|
||||
);
|
||||
await api.bulkArchiveMembers(input.memberIds, ctx.user.id);
|
||||
await mutation.archive(input.memberIds, ctx.user.id);
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// --- Query (via MemberQueryService) ---
|
||||
|
||||
export const quickSearchMembers = authActionClient
|
||||
.inputSchema(QuickSearchSchema)
|
||||
.action(async ({ parsedInput: input }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const api = createMemberManagementApi(client);
|
||||
const results = await api.quickSearchMembers(
|
||||
const { query } = createMemberServices(client);
|
||||
const results = await query.quickSearch(
|
||||
input.accountId,
|
||||
input.query,
|
||||
input.limit,
|
||||
@@ -442,7 +460,7 @@ export const getNextMemberNumber = authActionClient
|
||||
.inputSchema(z.object({ accountId: z.string().uuid() }))
|
||||
.action(async ({ parsedInput: input }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const api = createMemberManagementApi(client);
|
||||
const next = await api.getNextMemberNumber(input.accountId);
|
||||
const { query } = createMemberServices(client);
|
||||
const next = await query.getNextMemberNumber(input.accountId);
|
||||
return { success: true, data: next };
|
||||
});
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
'use server';
|
||||
|
||||
import { authActionClient } from '@kit/next/safe-action';
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import {
|
||||
ConfigureScheduledJobSchema,
|
||||
CreateNotificationRuleSchema,
|
||||
DeleteNotificationRuleSchema,
|
||||
ListNotificationRulesSchema,
|
||||
ListScheduledJobsSchema,
|
||||
UpdateNotificationRuleSchema,
|
||||
} from '../../schema/notification-rule.schema';
|
||||
|
||||
// --- Notification Rules CRUD ---
|
||||
|
||||
export const listNotificationRules = authActionClient
|
||||
.inputSchema(ListNotificationRulesSchema)
|
||||
.action(async ({ parsedInput: input }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data, error } = await (client.from as any)(
|
||||
'member_notification_rules',
|
||||
)
|
||||
.select('*')
|
||||
.eq('account_id', input.accountId)
|
||||
.order('trigger_event');
|
||||
|
||||
if (error) throw error;
|
||||
return { success: true, data: data ?? [] };
|
||||
});
|
||||
|
||||
export const createNotificationRule = authActionClient
|
||||
.inputSchema(CreateNotificationRuleSchema)
|
||||
.action(async ({ parsedInput: input }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
|
||||
logger.info(
|
||||
{ name: 'notification-rule.create', event: input.triggerEvent },
|
||||
'Creating notification rule...',
|
||||
);
|
||||
|
||||
const { data, error } = await (client.from as any)(
|
||||
'member_notification_rules',
|
||||
)
|
||||
.insert({
|
||||
account_id: input.accountId,
|
||||
trigger_event: input.triggerEvent,
|
||||
channel: input.channel,
|
||||
recipient_type: input.recipientType,
|
||||
recipient_config: input.recipientConfig,
|
||||
subject_template: input.subjectTemplate ?? null,
|
||||
message_template: input.messageTemplate,
|
||||
is_active: input.isActive,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return { success: true, data };
|
||||
});
|
||||
|
||||
export const updateNotificationRule = authActionClient
|
||||
.inputSchema(UpdateNotificationRuleSchema)
|
||||
.action(async ({ parsedInput: input }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const updateData: Record<string, unknown> = {};
|
||||
|
||||
if (input.triggerEvent !== undefined)
|
||||
updateData.trigger_event = input.triggerEvent;
|
||||
if (input.channel !== undefined) updateData.channel = input.channel;
|
||||
if (input.recipientType !== undefined)
|
||||
updateData.recipient_type = input.recipientType;
|
||||
if (input.recipientConfig !== undefined)
|
||||
updateData.recipient_config = input.recipientConfig;
|
||||
if (input.subjectTemplate !== undefined)
|
||||
updateData.subject_template = input.subjectTemplate;
|
||||
if (input.messageTemplate !== undefined)
|
||||
updateData.message_template = input.messageTemplate;
|
||||
if (input.isActive !== undefined) updateData.is_active = input.isActive;
|
||||
|
||||
const { data, error } = await (client.from as any)(
|
||||
'member_notification_rules',
|
||||
)
|
||||
.update(updateData)
|
||||
.eq('id', input.ruleId)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return { success: true, data };
|
||||
});
|
||||
|
||||
export const deleteNotificationRule = authActionClient
|
||||
.inputSchema(DeleteNotificationRuleSchema)
|
||||
.action(async ({ parsedInput: input }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { error } = await (client.from as any)('member_notification_rules')
|
||||
.delete()
|
||||
.eq('id', input.ruleId);
|
||||
|
||||
if (error) throw error;
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// --- Scheduled Jobs ---
|
||||
|
||||
export const listScheduledJobs = authActionClient
|
||||
.inputSchema(ListScheduledJobsSchema)
|
||||
.action(async ({ parsedInput: input }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data, error } = await (client.from as any)('scheduled_job_configs')
|
||||
.select('*')
|
||||
.eq('account_id', input.accountId)
|
||||
.order('job_type');
|
||||
|
||||
if (error) throw error;
|
||||
return { success: true, data: data ?? [] };
|
||||
});
|
||||
|
||||
export const configureScheduledJob = authActionClient
|
||||
.inputSchema(ConfigureScheduledJobSchema)
|
||||
.action(async ({ parsedInput: input }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
|
||||
logger.info(
|
||||
{
|
||||
name: 'scheduled-job.configure',
|
||||
jobType: input.jobType,
|
||||
enabled: input.isEnabled,
|
||||
},
|
||||
'Configuring scheduled job...',
|
||||
);
|
||||
|
||||
const { data, error } = await (client.from as any)('scheduled_job_configs')
|
||||
.upsert(
|
||||
{
|
||||
account_id: input.accountId,
|
||||
job_type: input.jobType,
|
||||
is_enabled: input.isEnabled,
|
||||
config: input.config,
|
||||
},
|
||||
{ onConflict: 'account_id,job_type' },
|
||||
)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return { success: true, data };
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
'use server';
|
||||
|
||||
import { authActionClient } from '@kit/next/safe-action';
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import {
|
||||
AssignTagSchema,
|
||||
BulkAssignTagSchema,
|
||||
CreateTagSchema,
|
||||
DeleteTagSchema,
|
||||
ListTagsSchema,
|
||||
UpdateTagSchema,
|
||||
} from '../../schema/tag.schema';
|
||||
|
||||
export const listTags = authActionClient
|
||||
.inputSchema(ListTagsSchema)
|
||||
.action(async ({ parsedInput: input }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data, error } = await (client.from as any)('member_tags')
|
||||
.select('*')
|
||||
.eq('account_id', input.accountId)
|
||||
.order('sort_order');
|
||||
|
||||
if (error) throw error;
|
||||
return { success: true, data: data ?? [] };
|
||||
});
|
||||
|
||||
export const createTag = authActionClient
|
||||
.inputSchema(CreateTagSchema)
|
||||
.action(async ({ parsedInput: input }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
|
||||
logger.info({ name: 'tag.create', tag: input.name }, 'Creating tag...');
|
||||
|
||||
const { data, error } = await (client.from as any)('member_tags')
|
||||
.insert({
|
||||
account_id: input.accountId,
|
||||
name: input.name,
|
||||
color: input.color,
|
||||
description: input.description ?? null,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return { success: true, data };
|
||||
});
|
||||
|
||||
export const updateTag = authActionClient
|
||||
.inputSchema(UpdateTagSchema)
|
||||
.action(async ({ parsedInput: input }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
|
||||
logger.info({ name: 'tag.update', tagId: input.tagId }, 'Updating tag...');
|
||||
|
||||
const updateData: Record<string, unknown> = {};
|
||||
|
||||
if (input.name !== undefined) updateData.name = input.name;
|
||||
if (input.color !== undefined) updateData.color = input.color;
|
||||
if (input.description !== undefined)
|
||||
updateData.description = input.description;
|
||||
if (input.sortOrder !== undefined) updateData.sort_order = input.sortOrder;
|
||||
|
||||
const { data, error } = await (client.from as any)('member_tags')
|
||||
.update(updateData)
|
||||
.eq('id', input.tagId)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return { success: true, data };
|
||||
});
|
||||
|
||||
export const deleteTag = authActionClient
|
||||
.inputSchema(DeleteTagSchema)
|
||||
.action(async ({ parsedInput: input }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
|
||||
logger.info({ name: 'tag.delete', tagId: input.tagId }, 'Deleting tag...');
|
||||
|
||||
const { error } = await (client.from as any)('member_tags')
|
||||
.delete()
|
||||
.eq('id', input.tagId);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
logger.info({ name: 'tag.delete', tagId: input.tagId }, 'Tag deleted');
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
export const assignTag = authActionClient
|
||||
.inputSchema(AssignTagSchema)
|
||||
.action(async ({ parsedInput: input, ctx }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { error } = await (client.from as any)(
|
||||
'member_tag_assignments',
|
||||
).upsert(
|
||||
{
|
||||
member_id: input.memberId,
|
||||
tag_id: input.tagId,
|
||||
assigned_by: ctx.user.id,
|
||||
},
|
||||
{ onConflict: 'member_id,tag_id' },
|
||||
);
|
||||
|
||||
if (error) throw error;
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
export const removeTag = authActionClient
|
||||
.inputSchema(AssignTagSchema)
|
||||
.action(async ({ parsedInput: input }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { error } = await (client.from as any)('member_tag_assignments')
|
||||
.delete()
|
||||
.eq('member_id', input.memberId)
|
||||
.eq('tag_id', input.tagId);
|
||||
|
||||
if (error) throw error;
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
export const bulkAssignTag = authActionClient
|
||||
.inputSchema(BulkAssignTagSchema)
|
||||
.action(async ({ parsedInput: input, ctx }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const rows = input.memberIds.map((memberId) => ({
|
||||
member_id: memberId,
|
||||
tag_id: input.tagId,
|
||||
assigned_by: ctx.user.id,
|
||||
}));
|
||||
|
||||
const { error } = await (client.from as any)(
|
||||
'member_tag_assignments',
|
||||
).upsert(rows, { onConflict: 'member_id,tag_id' });
|
||||
|
||||
if (error) throw error;
|
||||
return { success: true };
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
||||
import 'server-only';
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import type { Database } from '@kit/supabase/database';
|
||||
|
||||
import { createMemberCommunicationService } from './member-communication.service';
|
||||
import { createMemberExportService } from './member-export.service';
|
||||
import { createMemberMutationService } from './member-mutation.service';
|
||||
import { createMemberNotificationService } from './member-notification.service';
|
||||
import { createMemberOrganizationService } from './member-organization.service';
|
||||
import { createMemberQueryService } from './member-query.service';
|
||||
import { createMemberWorkflowService } from './member-workflow.service';
|
||||
|
||||
export {
|
||||
createMemberCommunicationService,
|
||||
createMemberExportService,
|
||||
createMemberMutationService,
|
||||
createMemberNotificationService,
|
||||
createMemberOrganizationService,
|
||||
createMemberQueryService,
|
||||
createMemberWorkflowService,
|
||||
};
|
||||
|
||||
/**
|
||||
* Convenience factory that creates all member services at once.
|
||||
* Use when a server action or route handler needs multiple services.
|
||||
*/
|
||||
export function createMemberServices(client: SupabaseClient<Database>) {
|
||||
return {
|
||||
query: createMemberQueryService(client),
|
||||
mutation: createMemberMutationService(client),
|
||||
workflow: createMemberWorkflowService(client),
|
||||
organization: createMemberOrganizationService(client),
|
||||
export: createMemberExportService(client),
|
||||
communication: createMemberCommunicationService(client),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import 'server-only';
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import type { Database } from '@kit/supabase/database';
|
||||
|
||||
import type {
|
||||
CommunicationListFilters,
|
||||
CreateCommunicationInput,
|
||||
} from '../../schema/communication.schema';
|
||||
|
||||
export function createMemberCommunicationService(
|
||||
client: SupabaseClient<Database>,
|
||||
) {
|
||||
return new MemberCommunicationService(client);
|
||||
}
|
||||
|
||||
class MemberCommunicationService {
|
||||
constructor(private readonly client: SupabaseClient<Database>) {}
|
||||
|
||||
async list(filters: CommunicationListFilters) {
|
||||
let query = (this.client.from as any)('member_communications')
|
||||
.select('*', { count: 'exact' })
|
||||
.eq('member_id', filters.memberId)
|
||||
.eq('account_id', filters.accountId)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (filters.type) query = query.eq('type', filters.type);
|
||||
if (filters.direction) query = query.eq('direction', filters.direction);
|
||||
if (filters.search) {
|
||||
const escaped = filters.search.replace(/[%_\\]/g, '\\$&');
|
||||
query = query.or(`subject.ilike.%${escaped}%,body.ilike.%${escaped}%`);
|
||||
}
|
||||
|
||||
const page = filters.page ?? 1;
|
||||
const pageSize = filters.pageSize ?? 25;
|
||||
query = query.range((page - 1) * pageSize, page * pageSize - 1);
|
||||
|
||||
const { data, error, count } = await query;
|
||||
if (error) throw error;
|
||||
return { data: data ?? [], total: count ?? 0, page, pageSize };
|
||||
}
|
||||
|
||||
async create(input: CreateCommunicationInput, userId: string) {
|
||||
const { data, error } = await (this.client.from as any)(
|
||||
'member_communications',
|
||||
)
|
||||
.insert({
|
||||
member_id: input.memberId,
|
||||
account_id: input.accountId,
|
||||
type: input.type,
|
||||
direction: input.direction,
|
||||
subject: input.subject ?? null,
|
||||
body: input.body ?? null,
|
||||
email_to: input.emailTo ?? null,
|
||||
email_cc: input.emailCc ?? null,
|
||||
email_message_id: input.emailMessageId ?? null,
|
||||
attachment_paths: input.attachmentPaths ?? null,
|
||||
created_by: userId,
|
||||
})
|
||||
.select(
|
||||
'id, member_id, account_id, type, direction, subject, email_to, created_at, created_by',
|
||||
)
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
|
||||
async delete(communicationId: string, accountId: string) {
|
||||
const { error } = await (this.client.rpc as any)(
|
||||
'delete_member_communication',
|
||||
{
|
||||
p_communication_id: communicationId,
|
||||
p_account_id: accountId,
|
||||
},
|
||||
);
|
||||
if (error) throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import 'server-only';
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import type { Database } from '@kit/supabase/database';
|
||||
|
||||
export function createMemberExportService(client: SupabaseClient<Database>) {
|
||||
return new MemberExportService(client);
|
||||
}
|
||||
|
||||
class MemberExportService {
|
||||
constructor(private readonly client: SupabaseClient<Database>) {}
|
||||
|
||||
async exportCsv(
|
||||
accountId: string,
|
||||
filters?: { status?: string },
|
||||
): Promise<string> {
|
||||
const members = await this.fetchMembers(accountId, filters);
|
||||
if (members.length === 0) return '';
|
||||
|
||||
const headers = [
|
||||
'Mitgliedsnr.',
|
||||
'Anrede',
|
||||
'Vorname',
|
||||
'Nachname',
|
||||
'Geburtsdatum',
|
||||
'E-Mail',
|
||||
'Telefon',
|
||||
'Mobil',
|
||||
'Straße',
|
||||
'Hausnummer',
|
||||
'PLZ',
|
||||
'Ort',
|
||||
'Status',
|
||||
'Eintrittsdatum',
|
||||
'IBAN',
|
||||
'BIC',
|
||||
'Kontoinhaber',
|
||||
];
|
||||
|
||||
const rows = members.map((m) =>
|
||||
[
|
||||
m.member_number ?? '',
|
||||
m.salutation ?? '',
|
||||
m.first_name,
|
||||
m.last_name,
|
||||
m.date_of_birth ?? '',
|
||||
m.email ?? '',
|
||||
m.phone ?? '',
|
||||
m.mobile ?? '',
|
||||
m.street ?? '',
|
||||
m.house_number ?? '',
|
||||
m.postal_code ?? '',
|
||||
m.city ?? '',
|
||||
m.status,
|
||||
m.entry_date ?? '',
|
||||
m.iban ?? '',
|
||||
m.bic ?? '',
|
||||
m.account_holder ?? '',
|
||||
]
|
||||
.map((v) => `"${String(v).replace(/"/g, '""')}"`)
|
||||
.join(';'),
|
||||
);
|
||||
|
||||
return [headers.join(';'), ...rows].join('\n');
|
||||
}
|
||||
|
||||
async exportExcel(
|
||||
accountId: string,
|
||||
filters?: { status?: string },
|
||||
): Promise<Buffer> {
|
||||
const members = await this.fetchMembers(accountId, filters);
|
||||
|
||||
const ExcelJS = (await import('exceljs')).default;
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const sheet = workbook.addWorksheet('Mitglieder');
|
||||
|
||||
sheet.columns = [
|
||||
{ header: 'Mitgliedsnr.', key: 'member_number', width: 15 },
|
||||
{ header: 'Anrede', key: 'salutation', width: 10 },
|
||||
{ header: 'Vorname', key: 'first_name', width: 20 },
|
||||
{ header: 'Nachname', key: 'last_name', width: 20 },
|
||||
{ header: 'Geburtsdatum', key: 'date_of_birth', width: 15 },
|
||||
{ header: 'E-Mail', key: 'email', width: 30 },
|
||||
{ header: 'Telefon', key: 'phone', width: 18 },
|
||||
{ header: 'Mobil', key: 'mobile', width: 18 },
|
||||
{ header: 'Straße', key: 'street', width: 25 },
|
||||
{ header: 'Hausnummer', key: 'house_number', width: 12 },
|
||||
{ header: 'PLZ', key: 'postal_code', width: 10 },
|
||||
{ header: 'Ort', key: 'city', width: 20 },
|
||||
{ header: 'Status', key: 'status', width: 12 },
|
||||
{ header: 'Eintrittsdatum', key: 'entry_date', width: 15 },
|
||||
{ header: 'IBAN', key: 'iban', width: 30 },
|
||||
{ header: 'BIC', key: 'bic', width: 15 },
|
||||
{ header: 'Kontoinhaber', key: 'account_holder', width: 25 },
|
||||
];
|
||||
|
||||
sheet.getRow(1).font = { bold: true };
|
||||
sheet.getRow(1).fill = {
|
||||
type: 'pattern',
|
||||
pattern: 'solid',
|
||||
fgColor: { argb: 'FFE8F5E9' },
|
||||
};
|
||||
|
||||
for (const m of members) {
|
||||
sheet.addRow(m);
|
||||
}
|
||||
|
||||
const buffer = await workbook.xlsx.writeBuffer();
|
||||
return Buffer.from(buffer);
|
||||
}
|
||||
|
||||
private async fetchMembers(accountId: string, filters?: { status?: string }) {
|
||||
let query = this.client
|
||||
.from('members')
|
||||
.select('*')
|
||||
.eq('account_id', accountId)
|
||||
.order('last_name');
|
||||
|
||||
if (filters?.status) {
|
||||
query = query.eq('status', filters.status as any);
|
||||
}
|
||||
|
||||
const { data, error } = await query;
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
import 'server-only';
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import { todayISO } from '@kit/shared/dates';
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import type { Database } from '@kit/supabase/database';
|
||||
|
||||
import {
|
||||
ConcurrencyConflictError,
|
||||
DuplicateMemberError,
|
||||
} from '../../lib/errors';
|
||||
import {
|
||||
getTransitionSideEffects,
|
||||
validateTransition,
|
||||
} from '../../lib/status-machine';
|
||||
import type {
|
||||
CreateMemberInput,
|
||||
MembershipStatus,
|
||||
UpdateMemberInput,
|
||||
} from '../../schema/member.schema';
|
||||
|
||||
export function createMemberMutationService(client: SupabaseClient<Database>) {
|
||||
return new MemberMutationService(client);
|
||||
}
|
||||
|
||||
class MemberMutationService {
|
||||
private readonly namespace = 'member-mutation';
|
||||
|
||||
constructor(private readonly client: SupabaseClient<Database>) {}
|
||||
|
||||
async create(input: CreateMemberInput, userId: string) {
|
||||
const logger = await getLogger();
|
||||
|
||||
// Check for duplicates
|
||||
const { data: dupes } = await (this.client.rpc as any)(
|
||||
'check_duplicate_member',
|
||||
{
|
||||
p_account_id: input.accountId,
|
||||
p_first_name: input.firstName,
|
||||
p_last_name: input.lastName,
|
||||
p_date_of_birth: input.dateOfBirth ?? null,
|
||||
},
|
||||
);
|
||||
|
||||
if (dupes && dupes.length > 0) {
|
||||
throw new DuplicateMemberError(
|
||||
dupes.map((d: any) => ({
|
||||
id: d.id,
|
||||
name: `${d.first_name} ${d.last_name}`,
|
||||
memberNumber: d.member_number,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
logger.info({ namespace: this.namespace }, 'Creating member...');
|
||||
|
||||
const { data, error } = await this.client
|
||||
.from('members')
|
||||
.insert({
|
||||
account_id: input.accountId,
|
||||
member_number: input.memberNumber,
|
||||
first_name: input.firstName,
|
||||
last_name: input.lastName,
|
||||
date_of_birth: input.dateOfBirth,
|
||||
gender: input.gender,
|
||||
title: input.title,
|
||||
email: input.email,
|
||||
phone: input.phone,
|
||||
mobile: input.mobile,
|
||||
street: input.street,
|
||||
house_number: input.houseNumber,
|
||||
postal_code: input.postalCode,
|
||||
city: input.city,
|
||||
country: input.country,
|
||||
status: input.status,
|
||||
entry_date: input.entryDate,
|
||||
dues_category_id: input.duesCategoryId,
|
||||
iban: input.iban,
|
||||
bic: input.bic,
|
||||
account_holder: input.accountHolder,
|
||||
sepa_mandate_reference: input.sepaMandateReference,
|
||||
gdpr_consent: input.gdprConsent,
|
||||
gdpr_consent_date: input.gdprConsent ? new Date().toISOString() : null,
|
||||
notes: input.notes,
|
||||
salutation: input.salutation,
|
||||
street2: input.street2,
|
||||
phone2: input.phone2,
|
||||
fax: input.fax,
|
||||
birthplace: input.birthplace,
|
||||
birth_country: input.birthCountry,
|
||||
is_honorary: input.isHonorary,
|
||||
is_founding_member: input.isFoundingMember,
|
||||
is_youth: input.isYouth,
|
||||
is_retiree: input.isRetiree,
|
||||
is_probationary: input.isProbationary,
|
||||
is_transferred: input.isTransferred,
|
||||
guardian_name: input.guardianName,
|
||||
guardian_phone: input.guardianPhone,
|
||||
guardian_email: input.guardianEmail,
|
||||
dues_year: input.duesYear,
|
||||
dues_paid: input.duesPaid,
|
||||
additional_fees: input.additionalFees,
|
||||
exemption_type: input.exemptionType,
|
||||
exemption_reason: input.exemptionReason,
|
||||
exemption_amount: input.exemptionAmount,
|
||||
gdpr_newsletter: input.gdprNewsletter,
|
||||
gdpr_internet: input.gdprInternet,
|
||||
gdpr_print: input.gdprPrint,
|
||||
gdpr_birthday_info: input.gdprBirthdayInfo,
|
||||
created_by: userId,
|
||||
updated_by: userId,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// Create SEPA mandate if bank data provided
|
||||
if (input.iban && input.iban.trim()) {
|
||||
await this.createMandateForMember(data.id, input, data.member_number);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
{ namespace: this.namespace, memberId: data.id },
|
||||
'Member created',
|
||||
);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
async update(input: UpdateMemberInput, userId: string) {
|
||||
const updateData: Record<string, unknown> = { updated_by: userId };
|
||||
|
||||
// Map all camelCase fields to snake_case
|
||||
const fieldMap: Record<string, string> = {
|
||||
firstName: 'first_name',
|
||||
lastName: 'last_name',
|
||||
email: 'email',
|
||||
phone: 'phone',
|
||||
mobile: 'mobile',
|
||||
street: 'street',
|
||||
houseNumber: 'house_number',
|
||||
postalCode: 'postal_code',
|
||||
city: 'city',
|
||||
status: 'status',
|
||||
duesCategoryId: 'dues_category_id',
|
||||
iban: 'iban',
|
||||
bic: 'bic',
|
||||
accountHolder: 'account_holder',
|
||||
notes: 'notes',
|
||||
isArchived: 'is_archived',
|
||||
salutation: 'salutation',
|
||||
street2: 'street2',
|
||||
phone2: 'phone2',
|
||||
fax: 'fax',
|
||||
birthplace: 'birthplace',
|
||||
birthCountry: 'birth_country',
|
||||
title: 'title',
|
||||
dateOfBirth: 'date_of_birth',
|
||||
gender: 'gender',
|
||||
country: 'country',
|
||||
entryDate: 'entry_date',
|
||||
exitDate: 'exit_date',
|
||||
exitReason: 'exit_reason',
|
||||
isHonorary: 'is_honorary',
|
||||
isFoundingMember: 'is_founding_member',
|
||||
isYouth: 'is_youth',
|
||||
isRetiree: 'is_retiree',
|
||||
isProbationary: 'is_probationary',
|
||||
isTransferred: 'is_transferred',
|
||||
guardianName: 'guardian_name',
|
||||
guardianPhone: 'guardian_phone',
|
||||
guardianEmail: 'guardian_email',
|
||||
duesYear: 'dues_year',
|
||||
duesPaid: 'dues_paid',
|
||||
additionalFees: 'additional_fees',
|
||||
exemptionType: 'exemption_type',
|
||||
exemptionReason: 'exemption_reason',
|
||||
exemptionAmount: 'exemption_amount',
|
||||
gdprConsent: 'gdpr_consent',
|
||||
gdprNewsletter: 'gdpr_newsletter',
|
||||
gdprInternet: 'gdpr_internet',
|
||||
gdprPrint: 'gdpr_print',
|
||||
gdprBirthdayInfo: 'gdpr_birthday_info',
|
||||
sepaMandateReference: 'sepa_mandate_reference',
|
||||
};
|
||||
|
||||
for (const [camel, snake] of Object.entries(fieldMap)) {
|
||||
const value = (input as Record<string, unknown>)[camel];
|
||||
if (value !== undefined) {
|
||||
updateData[snake] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate status transition if status is being changed
|
||||
if (input.status !== undefined) {
|
||||
const { data: current } = await this.client
|
||||
.from('members')
|
||||
.select('status')
|
||||
.eq('id', input.memberId)
|
||||
.single();
|
||||
|
||||
if (current && current.status !== input.status) {
|
||||
const sideEffects = validateTransition(
|
||||
current.status as MembershipStatus,
|
||||
input.status as MembershipStatus,
|
||||
);
|
||||
|
||||
Object.assign(updateData, sideEffects);
|
||||
}
|
||||
}
|
||||
|
||||
let query = this.client
|
||||
.from('members')
|
||||
.update(updateData)
|
||||
.eq('id', input.memberId);
|
||||
|
||||
// Optimistic locking
|
||||
if (input.version !== undefined) {
|
||||
query = query.eq('version' as any, input.version);
|
||||
}
|
||||
|
||||
const { data, error } = await query.select().single();
|
||||
|
||||
if (error) {
|
||||
if (error.code === 'PGRST116' && input.version !== undefined) {
|
||||
throw new ConcurrencyConflictError();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
async softDelete(memberId: string) {
|
||||
const { error } = await this.client
|
||||
.from('members')
|
||||
.update({ status: 'resigned', exit_date: todayISO() })
|
||||
.eq('id', memberId);
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
async archive(memberIds: string[], userId: string) {
|
||||
const { error } = await this.client
|
||||
.from('members')
|
||||
.update({ is_archived: true, updated_by: userId })
|
||||
.in('id', memberIds);
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
async bulkUpdateStatus(
|
||||
memberIds: string[],
|
||||
targetStatus: MembershipStatus,
|
||||
userId: string,
|
||||
) {
|
||||
// Fetch current statuses to validate transitions
|
||||
const { data: members, error: fetchError } = await this.client
|
||||
.from('members')
|
||||
.select('id, status')
|
||||
.in('id', memberIds);
|
||||
if (fetchError) throw fetchError;
|
||||
|
||||
const validIds: string[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const member of members ?? []) {
|
||||
try {
|
||||
validateTransition(member.status as MembershipStatus, targetStatus);
|
||||
validIds.push(member.id);
|
||||
} catch (e) {
|
||||
errors.push(
|
||||
`${member.id}: ${e instanceof Error ? e.message : 'Ungültiger Statuswechsel'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (validIds.length === 0 && errors.length > 0) {
|
||||
throw new Error(`Kein Mitglied konnte aktualisiert werden: ${errors[0]}`);
|
||||
}
|
||||
|
||||
// Group by source status for correct side effects
|
||||
const bySourceStatus = new Map<string, string[]>();
|
||||
|
||||
for (const member of members ?? []) {
|
||||
if (!validIds.includes(member.id)) continue;
|
||||
const group = bySourceStatus.get(member.status) ?? [];
|
||||
group.push(member.id);
|
||||
bySourceStatus.set(member.status, group);
|
||||
}
|
||||
|
||||
for (const [sourceStatus, ids] of bySourceStatus) {
|
||||
const sideEffects = getTransitionSideEffects(
|
||||
sourceStatus as MembershipStatus,
|
||||
targetStatus,
|
||||
);
|
||||
|
||||
const { error } = await this.client
|
||||
.from('members')
|
||||
.update({
|
||||
status: targetStatus as any,
|
||||
updated_by: userId,
|
||||
...sideEffects,
|
||||
})
|
||||
.in('id', ids);
|
||||
if (error) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async createMandateForMember(
|
||||
memberId: string,
|
||||
input: CreateMemberInput,
|
||||
memberNumber: string | null,
|
||||
) {
|
||||
const logger = await getLogger();
|
||||
|
||||
const { data: mandate, error: mandateError } = await this.client
|
||||
.from('sepa_mandates')
|
||||
.insert({
|
||||
member_id: memberId,
|
||||
account_id: input.accountId,
|
||||
mandate_reference:
|
||||
input.sepaMandateReference || `M-${memberNumber || memberId}`,
|
||||
iban: input.iban!,
|
||||
bic: input.bic ?? null,
|
||||
account_holder:
|
||||
input.accountHolder || `${input.firstName} ${input.lastName}`,
|
||||
mandate_date: new Date().toISOString().split('T')[0]!,
|
||||
status: 'active',
|
||||
sequence: 'FRST',
|
||||
is_primary: true,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (mandateError) {
|
||||
logger.error(
|
||||
{ error: mandateError, memberId, namespace: this.namespace },
|
||||
'Failed to create SEPA mandate during member creation',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (mandate) {
|
||||
await this.client
|
||||
.from('members')
|
||||
.update({ primary_mandate_id: mandate.id } as any)
|
||||
.eq('id', memberId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,516 @@
|
||||
import 'server-only';
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import type { Database } from '@kit/supabase/database';
|
||||
|
||||
interface NotificationRule {
|
||||
id: string;
|
||||
account_id: string;
|
||||
trigger_event: string;
|
||||
channel: 'in_app' | 'email' | 'both';
|
||||
recipient_type: string;
|
||||
recipient_config: Record<string, unknown>;
|
||||
subject_template: string | null;
|
||||
message_template: string;
|
||||
}
|
||||
|
||||
interface PendingNotification {
|
||||
id: number;
|
||||
account_id: string;
|
||||
trigger_event: string;
|
||||
member_id: string | null;
|
||||
context: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface JobRunResult {
|
||||
processed: number;
|
||||
notifications: number;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export function createMemberNotificationService(
|
||||
client: SupabaseClient<Database>,
|
||||
) {
|
||||
return new MemberNotificationService(client);
|
||||
}
|
||||
|
||||
class MemberNotificationService {
|
||||
private readonly namespace = 'member-notification';
|
||||
|
||||
constructor(private readonly client: SupabaseClient<Database>) {}
|
||||
|
||||
/**
|
||||
* Process all pending notifications in the queue.
|
||||
* Called by the cron route.
|
||||
*/
|
||||
async processPendingNotifications(): Promise<{
|
||||
processed: number;
|
||||
sent: number;
|
||||
}> {
|
||||
const logger = await getLogger();
|
||||
|
||||
// Fetch unprocessed notifications (limit batch size)
|
||||
const { data: pending, error } = await (this.client.from as any)(
|
||||
'pending_member_notifications',
|
||||
)
|
||||
.select('*')
|
||||
.is('processed_at', null)
|
||||
.order('created_at')
|
||||
.limit(100);
|
||||
|
||||
if (error) {
|
||||
logger.error(
|
||||
{ namespace: this.namespace, error },
|
||||
'Failed to fetch pending notifications',
|
||||
);
|
||||
return { processed: 0, sent: 0 };
|
||||
}
|
||||
|
||||
if (!pending || pending.length === 0) {
|
||||
return { processed: 0, sent: 0 };
|
||||
}
|
||||
|
||||
let sent = 0;
|
||||
|
||||
for (const notification of pending as PendingNotification[]) {
|
||||
try {
|
||||
const dispatched = await this.dispatchForEvent(
|
||||
notification.account_id,
|
||||
notification.trigger_event,
|
||||
notification.member_id,
|
||||
notification.context,
|
||||
);
|
||||
sent += dispatched;
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
{
|
||||
namespace: this.namespace,
|
||||
notificationId: notification.id,
|
||||
error: e,
|
||||
},
|
||||
'Failed to dispatch notification',
|
||||
);
|
||||
}
|
||||
|
||||
// Mark as processed regardless of success/failure
|
||||
await (this.client.from as any)('pending_member_notifications')
|
||||
.update({ processed_at: new Date().toISOString() })
|
||||
.eq('id', notification.id);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
{ namespace: this.namespace, processed: pending.length, sent },
|
||||
'Pending notifications processed',
|
||||
);
|
||||
|
||||
return { processed: pending.length, sent };
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch notifications for a specific event.
|
||||
* Looks up matching rules and sends in-app + email as configured.
|
||||
*/
|
||||
async dispatchForEvent(
|
||||
accountId: string,
|
||||
triggerEvent: string,
|
||||
memberId: string | null,
|
||||
context: Record<string, unknown>,
|
||||
): Promise<number> {
|
||||
const logger = await getLogger();
|
||||
|
||||
// Find matching active rules
|
||||
const { data: rules, error } = await (this.client.from as any)(
|
||||
'member_notification_rules',
|
||||
)
|
||||
.select('*')
|
||||
.eq('account_id', accountId)
|
||||
.eq('trigger_event', triggerEvent)
|
||||
.eq('is_active', true);
|
||||
|
||||
if (error || !rules || rules.length === 0) return 0;
|
||||
|
||||
let sent = 0;
|
||||
|
||||
for (const rule of rules as NotificationRule[]) {
|
||||
try {
|
||||
const message = this.renderTemplate(rule.message_template, context);
|
||||
const subject = rule.subject_template
|
||||
? this.renderTemplate(rule.subject_template, context)
|
||||
: undefined;
|
||||
|
||||
// In-app notification
|
||||
if (rule.channel === 'in_app' || rule.channel === 'both') {
|
||||
await this.sendInAppNotification(accountId, message, rule);
|
||||
sent++;
|
||||
}
|
||||
|
||||
// Email notification
|
||||
if (rule.channel === 'email' || rule.channel === 'both') {
|
||||
const recipientEmail = await this.resolveRecipientEmail(
|
||||
accountId,
|
||||
memberId,
|
||||
rule,
|
||||
);
|
||||
|
||||
if (recipientEmail) {
|
||||
await this.sendEmailNotification(
|
||||
recipientEmail,
|
||||
subject ?? triggerEvent,
|
||||
message,
|
||||
);
|
||||
sent++;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
{ namespace: this.namespace, ruleId: rule.id, error: e },
|
||||
'Failed to dispatch notification for rule',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return sent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run all due scheduled jobs for a specific account.
|
||||
*/
|
||||
async runScheduledJobs(accountId: string): Promise<JobRunResult> {
|
||||
const logger = await getLogger();
|
||||
const result: JobRunResult = { processed: 0, notifications: 0, errors: [] };
|
||||
|
||||
// Fetch due jobs
|
||||
const { data: jobs, error } = await (this.client.from as any)(
|
||||
'scheduled_job_configs',
|
||||
)
|
||||
.select('*')
|
||||
.eq('account_id', accountId)
|
||||
.eq('is_enabled', true)
|
||||
.or(`next_run_at.is.null,next_run_at.lte.${new Date().toISOString()}`);
|
||||
|
||||
if (error || !jobs || jobs.length === 0) return result;
|
||||
|
||||
for (const job of jobs) {
|
||||
const runId = await this.startJobRun(job.id);
|
||||
|
||||
try {
|
||||
const jobResult = await this.executeJob(
|
||||
accountId,
|
||||
job.job_type,
|
||||
job.config ?? {},
|
||||
);
|
||||
result.processed++;
|
||||
result.notifications += jobResult.notifications;
|
||||
|
||||
await this.completeJobRun(runId, 'completed', jobResult);
|
||||
await this.updateJobSchedule(job.id);
|
||||
} catch (e) {
|
||||
const errorMsg = e instanceof Error ? e.message : 'Unknown error';
|
||||
result.errors.push(`${job.job_type}: ${errorMsg}`);
|
||||
|
||||
logger.error(
|
||||
{ namespace: this.namespace, jobType: job.job_type, error: e },
|
||||
'Scheduled job failed',
|
||||
);
|
||||
|
||||
await this.completeJobRun(runId, 'failed', { error: errorMsg });
|
||||
await this.updateJobSchedule(job.id);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// --- Private helpers ---
|
||||
|
||||
private async executeJob(
|
||||
accountId: string,
|
||||
jobType: string,
|
||||
config: Record<string, unknown>,
|
||||
): Promise<{ notifications: number }> {
|
||||
switch (jobType) {
|
||||
case 'birthday_notification':
|
||||
return this.runBirthdayJob(accountId, config);
|
||||
case 'anniversary_notification':
|
||||
return this.runAnniversaryJob(accountId, config);
|
||||
case 'dues_reminder':
|
||||
return this.runDuesReminderJob(accountId);
|
||||
case 'data_quality_check':
|
||||
return this.runDataQualityJob(accountId);
|
||||
case 'gdpr_retention_check':
|
||||
return this.runGdprRetentionJob();
|
||||
default:
|
||||
throw new Error(`Unknown job type: ${jobType}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async runBirthdayJob(
|
||||
accountId: string,
|
||||
config: Record<string, unknown>,
|
||||
): Promise<{ notifications: number }> {
|
||||
const daysBefore = (config.days_before as number) ?? 7;
|
||||
const targetDate = new Date();
|
||||
targetDate.setDate(targetDate.getDate() + daysBefore);
|
||||
|
||||
const month = targetDate.getMonth() + 1;
|
||||
const day = targetDate.getDate();
|
||||
|
||||
const { data: members } = await this.client
|
||||
.from('members')
|
||||
.select('id, first_name, last_name, date_of_birth')
|
||||
.eq('account_id', accountId)
|
||||
.eq('status', 'active')
|
||||
.eq('is_archived', false)
|
||||
.not('date_of_birth', 'is', null);
|
||||
|
||||
let count = 0;
|
||||
|
||||
for (const m of members ?? []) {
|
||||
if (!m.date_of_birth) continue;
|
||||
const dob = new Date(m.date_of_birth);
|
||||
if (dob.getMonth() + 1 === month && dob.getDate() === day) {
|
||||
const age = targetDate.getFullYear() - dob.getFullYear();
|
||||
await this.dispatchForEvent(accountId, 'member.birthday', m.id, {
|
||||
first_name: m.first_name,
|
||||
last_name: m.last_name,
|
||||
age,
|
||||
birthday_date: m.date_of_birth,
|
||||
});
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
return { notifications: count };
|
||||
}
|
||||
|
||||
private async runAnniversaryJob(
|
||||
accountId: string,
|
||||
config: Record<string, unknown>,
|
||||
): Promise<{ notifications: number }> {
|
||||
const daysBefore = (config.days_before as number) ?? 7;
|
||||
const milestoneYears = (config.milestone_years as number[]) ?? [
|
||||
5, 10, 25, 50,
|
||||
];
|
||||
const targetDate = new Date();
|
||||
targetDate.setDate(targetDate.getDate() + daysBefore);
|
||||
|
||||
const { data: members } = await this.client
|
||||
.from('members')
|
||||
.select('id, first_name, last_name, entry_date')
|
||||
.eq('account_id', accountId)
|
||||
.eq('status', 'active')
|
||||
.eq('is_archived', false)
|
||||
.not('entry_date', 'is', null);
|
||||
|
||||
let count = 0;
|
||||
|
||||
for (const m of members ?? []) {
|
||||
if (!m.entry_date) continue;
|
||||
const entry = new Date(m.entry_date);
|
||||
const years = targetDate.getFullYear() - entry.getFullYear();
|
||||
|
||||
if (
|
||||
milestoneYears.includes(years) &&
|
||||
entry.getMonth() === targetDate.getMonth() &&
|
||||
entry.getDate() === targetDate.getDate()
|
||||
) {
|
||||
await this.dispatchForEvent(accountId, 'member.anniversary', m.id, {
|
||||
first_name: m.first_name,
|
||||
last_name: m.last_name,
|
||||
years,
|
||||
entry_date: m.entry_date,
|
||||
});
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
return { notifications: count };
|
||||
}
|
||||
|
||||
private async runDuesReminderJob(
|
||||
accountId: string,
|
||||
): Promise<{ notifications: number }> {
|
||||
const { data: members } = await this.client
|
||||
.from('members')
|
||||
.select('id, first_name, last_name, email')
|
||||
.eq('account_id', accountId)
|
||||
.eq('status', 'active')
|
||||
.eq('is_archived', false)
|
||||
.eq('dues_paid', false);
|
||||
|
||||
let count = 0;
|
||||
|
||||
for (const m of members ?? []) {
|
||||
await this.dispatchForEvent(accountId, 'dues.unpaid', m.id, {
|
||||
first_name: m.first_name,
|
||||
last_name: m.last_name,
|
||||
email: m.email,
|
||||
});
|
||||
count++;
|
||||
}
|
||||
|
||||
return { notifications: count };
|
||||
}
|
||||
|
||||
private async runDataQualityJob(
|
||||
accountId: string,
|
||||
): Promise<{ notifications: number }> {
|
||||
const { count } = await this.client
|
||||
.from('members')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('account_id', accountId)
|
||||
.eq('status', 'active')
|
||||
.eq('is_archived', false)
|
||||
.or('email.is.null,email.eq.,data_reconciliation_needed.eq.true');
|
||||
|
||||
if (count && count > 0) {
|
||||
await this.sendInAppNotification(
|
||||
accountId,
|
||||
`${count} Mitglieder mit fehlenden oder ungültigen Daten gefunden. Bitte überprüfen.`,
|
||||
{ recipient_type: 'admin' } as NotificationRule,
|
||||
);
|
||||
return { notifications: 1 };
|
||||
}
|
||||
|
||||
return { notifications: 0 };
|
||||
}
|
||||
|
||||
private async runGdprRetentionJob(): Promise<{ notifications: number }> {
|
||||
const { data, error } = await (this.client.rpc as any)(
|
||||
'enforce_gdpr_retention_policies',
|
||||
);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
const anonymized = typeof data === 'number' ? data : 0;
|
||||
return { notifications: anonymized };
|
||||
}
|
||||
|
||||
private renderTemplate(
|
||||
template: string,
|
||||
context: Record<string, unknown>,
|
||||
): string {
|
||||
let result = template;
|
||||
|
||||
for (const [key, value] of Object.entries(context)) {
|
||||
result = result.replace(
|
||||
new RegExp(`\\{\\{${key}\\}\\}`, 'g'),
|
||||
String(value ?? ''),
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async sendInAppNotification(
|
||||
accountId: string,
|
||||
body: string,
|
||||
rule: Pick<NotificationRule, 'recipient_type'>,
|
||||
): Promise<void> {
|
||||
// Use the existing notifications API to create in-app notifications
|
||||
const { createNotificationsApi } = await import('@kit/notifications/api');
|
||||
const notificationsApi = createNotificationsApi(this.client);
|
||||
|
||||
await notificationsApi.createNotification({
|
||||
account_id: accountId,
|
||||
body,
|
||||
type: 'info',
|
||||
channel: 'in_app',
|
||||
});
|
||||
}
|
||||
|
||||
private async resolveRecipientEmail(
|
||||
accountId: string,
|
||||
memberId: string | null,
|
||||
rule: NotificationRule,
|
||||
): Promise<string | null> {
|
||||
if (rule.recipient_type === 'member' && memberId) {
|
||||
const { data } = await this.client
|
||||
.from('members')
|
||||
.select('email')
|
||||
.eq('id', memberId)
|
||||
.single();
|
||||
return data?.email ?? null;
|
||||
}
|
||||
|
||||
if (
|
||||
rule.recipient_type === 'specific_user' &&
|
||||
rule.recipient_config.email
|
||||
) {
|
||||
return String(rule.recipient_config.email);
|
||||
}
|
||||
|
||||
// For 'admin' type: get account owner email
|
||||
if (rule.recipient_type === 'admin') {
|
||||
const { data } = await this.client
|
||||
.from('accounts_memberships')
|
||||
.select('user_id, account_role')
|
||||
.eq('account_id', accountId)
|
||||
.eq('account_role', 'owner')
|
||||
.limit(1)
|
||||
.single();
|
||||
|
||||
if (data?.user_id) {
|
||||
const { data: user } = await this.client.auth.admin.getUserById(
|
||||
data.user_id,
|
||||
);
|
||||
return user?.user?.email ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async sendEmailNotification(
|
||||
to: string,
|
||||
subject: string,
|
||||
body: string,
|
||||
): Promise<void> {
|
||||
const { getMailer } = await import('@kit/mailers');
|
||||
const mailer = await getMailer();
|
||||
|
||||
await mailer.sendEmail({
|
||||
to,
|
||||
from: process.env.EMAIL_SENDER ?? 'noreply@example.com',
|
||||
subject,
|
||||
html: `<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">${body}</div>`,
|
||||
});
|
||||
}
|
||||
|
||||
private async startJobRun(jobConfigId: string): Promise<string> {
|
||||
const { data } = await (this.client.from as any)('scheduled_job_runs')
|
||||
.insert({ job_config_id: jobConfigId, status: 'running' })
|
||||
.select('id')
|
||||
.single();
|
||||
return data?.id;
|
||||
}
|
||||
|
||||
private async completeJobRun(
|
||||
runId: string,
|
||||
status: 'completed' | 'failed',
|
||||
result: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
await (this.client.from as any)('scheduled_job_runs')
|
||||
.update({
|
||||
status,
|
||||
result,
|
||||
completed_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', runId);
|
||||
}
|
||||
|
||||
private async updateJobSchedule(jobConfigId: string): Promise<void> {
|
||||
// Next run: 1 day from now (daily jobs)
|
||||
const nextRun = new Date();
|
||||
nextRun.setDate(nextRun.getDate() + 1);
|
||||
nextRun.setHours(8, 0, 0, 0); // 8:00 AM
|
||||
|
||||
await (this.client.from as any)('scheduled_job_configs')
|
||||
.update({
|
||||
last_run_at: new Date().toISOString(),
|
||||
next_run_at: nextRun.toISOString(),
|
||||
})
|
||||
.eq('id', jobConfigId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
import 'server-only';
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import type { Database } from '@kit/supabase/database';
|
||||
|
||||
export function createMemberOrganizationService(
|
||||
client: SupabaseClient<Database>,
|
||||
) {
|
||||
return new MemberOrganizationService(client);
|
||||
}
|
||||
|
||||
class MemberOrganizationService {
|
||||
private readonly namespace = 'member-organization';
|
||||
|
||||
constructor(private readonly client: SupabaseClient<Database>) {}
|
||||
|
||||
// --- Departments ---
|
||||
|
||||
async listDepartments(accountId: string) {
|
||||
const { data, error } = await this.client
|
||||
.from('member_departments')
|
||||
.select('*')
|
||||
.eq('account_id', accountId)
|
||||
.order('sort_order');
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
}
|
||||
|
||||
async listDepartmentsWithCounts(accountId: string) {
|
||||
const { data: departments, error: deptError } = await this.client
|
||||
.from('member_departments')
|
||||
.select('*')
|
||||
.eq('account_id', accountId)
|
||||
.order('sort_order');
|
||||
if (deptError) throw deptError;
|
||||
|
||||
const deptIds = (departments ?? []).map((d) => d.id);
|
||||
|
||||
if (deptIds.length === 0) return [];
|
||||
|
||||
const { data: assignments, error: assignError } = await this.client
|
||||
.from('member_department_assignments')
|
||||
.select('department_id')
|
||||
.in('department_id', deptIds);
|
||||
if (assignError) throw assignError;
|
||||
|
||||
const counts = new Map<string, number>();
|
||||
|
||||
for (const a of assignments ?? []) {
|
||||
counts.set(a.department_id, (counts.get(a.department_id) ?? 0) + 1);
|
||||
}
|
||||
|
||||
return (departments ?? []).map((d) => ({
|
||||
...d,
|
||||
memberCount: counts.get(d.id) ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
async createDepartment(input: {
|
||||
accountId: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
}) {
|
||||
const logger = await getLogger();
|
||||
logger.info(
|
||||
{ namespace: this.namespace, name: input.name },
|
||||
'Creating department...',
|
||||
);
|
||||
|
||||
const { data, error } = await this.client
|
||||
.from('member_departments')
|
||||
.insert({
|
||||
account_id: input.accountId,
|
||||
name: input.name,
|
||||
description: input.description ?? null,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
|
||||
async deleteDepartment(departmentId: string) {
|
||||
const { error } = await this.client
|
||||
.from('member_departments')
|
||||
.delete()
|
||||
.eq('id', departmentId);
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
async assignDepartment(memberId: string, departmentId: string) {
|
||||
const { error } = await this.client
|
||||
.from('member_department_assignments')
|
||||
.upsert(
|
||||
{ member_id: memberId, department_id: departmentId },
|
||||
{ onConflict: 'member_id,department_id' },
|
||||
);
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
async removeDepartment(memberId: string, departmentId: string) {
|
||||
const { error } = await this.client
|
||||
.from('member_department_assignments')
|
||||
.delete()
|
||||
.eq('member_id', memberId)
|
||||
.eq('department_id', departmentId);
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
async getDepartmentAssignments(memberId: string) {
|
||||
const { data, error } = await this.client
|
||||
.from('member_department_assignments')
|
||||
.select('department_id, member_departments(id, name)')
|
||||
.eq('member_id', memberId);
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
}
|
||||
|
||||
async bulkAssignDepartment(memberIds: string[], departmentId: string) {
|
||||
const rows = memberIds.map((memberId) => ({
|
||||
member_id: memberId,
|
||||
department_id: departmentId,
|
||||
}));
|
||||
const { error } = await this.client
|
||||
.from('member_department_assignments')
|
||||
.upsert(rows, { onConflict: 'member_id,department_id' });
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
// --- Roles (Board positions / Funktionen) ---
|
||||
|
||||
async listMemberRoles(memberId: string) {
|
||||
const { data, error } = await this.client
|
||||
.from('member_roles')
|
||||
.select('*')
|
||||
.eq('member_id', memberId)
|
||||
.order('from_date', { ascending: false });
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
}
|
||||
|
||||
async createMemberRole(input: {
|
||||
memberId: string;
|
||||
accountId: string;
|
||||
roleName: string;
|
||||
fromDate?: string;
|
||||
untilDate?: string;
|
||||
}) {
|
||||
const { data, error } = await this.client
|
||||
.from('member_roles')
|
||||
.insert({
|
||||
member_id: input.memberId,
|
||||
account_id: input.accountId,
|
||||
role_name: input.roleName,
|
||||
from_date: input.fromDate ?? null,
|
||||
until_date: input.untilDate ?? null,
|
||||
is_active: true,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
|
||||
async deleteMemberRole(roleId: string) {
|
||||
const { error } = await this.client
|
||||
.from('member_roles')
|
||||
.delete()
|
||||
.eq('id', roleId);
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
// --- Honors (Awards / Ehrungen) ---
|
||||
|
||||
async listMemberHonors(memberId: string) {
|
||||
const { data, error } = await this.client
|
||||
.from('member_honors')
|
||||
.select('*')
|
||||
.eq('member_id', memberId)
|
||||
.order('honor_date', { ascending: false });
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
}
|
||||
|
||||
async createMemberHonor(input: {
|
||||
memberId: string;
|
||||
accountId: string;
|
||||
honorName: string;
|
||||
honorDate?: string;
|
||||
description?: string;
|
||||
}) {
|
||||
const { data, error } = await this.client
|
||||
.from('member_honors')
|
||||
.insert({
|
||||
member_id: input.memberId,
|
||||
account_id: input.accountId,
|
||||
honor_name: input.honorName,
|
||||
honor_date: input.honorDate ?? null,
|
||||
description: input.description ?? null,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
|
||||
async deleteMemberHonor(honorId: string) {
|
||||
const { error } = await this.client
|
||||
.from('member_honors')
|
||||
.delete()
|
||||
.eq('id', honorId);
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
// --- Dues Categories ---
|
||||
|
||||
async listDuesCategories(accountId: string) {
|
||||
const { data, error } = await this.client
|
||||
.from('dues_categories')
|
||||
.select('*')
|
||||
.eq('account_id', accountId)
|
||||
.order('sort_order');
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
}
|
||||
|
||||
async createDuesCategory(input: {
|
||||
accountId: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
amount: number;
|
||||
interval?: string;
|
||||
isDefault?: boolean;
|
||||
isYouth?: boolean;
|
||||
isExit?: boolean;
|
||||
}) {
|
||||
const { data, error } = await this.client
|
||||
.from('dues_categories')
|
||||
.insert({
|
||||
account_id: input.accountId,
|
||||
name: input.name,
|
||||
description: input.description ?? null,
|
||||
amount: input.amount,
|
||||
interval: (input.interval ?? 'yearly') as any,
|
||||
is_default: input.isDefault ?? false,
|
||||
is_youth: input.isYouth ?? false,
|
||||
is_exit: input.isExit ?? false,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
|
||||
async updateDuesCategory(input: {
|
||||
categoryId: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
amount?: number;
|
||||
interval?: string;
|
||||
isDefault?: boolean;
|
||||
}) {
|
||||
const updateData: Record<string, unknown> = {};
|
||||
|
||||
if (input.name !== undefined) updateData.name = input.name;
|
||||
if (input.description !== undefined)
|
||||
updateData.description = input.description;
|
||||
if (input.amount !== undefined) updateData.amount = input.amount;
|
||||
if (input.interval !== undefined) updateData.interval = input.interval;
|
||||
if (input.isDefault !== undefined) updateData.is_default = input.isDefault;
|
||||
|
||||
const { data, error } = await this.client
|
||||
.from('dues_categories')
|
||||
.update(updateData)
|
||||
.eq('id', input.categoryId)
|
||||
.select()
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
|
||||
async deleteDuesCategory(categoryId: string) {
|
||||
const { error } = await this.client
|
||||
.from('dues_categories')
|
||||
.delete()
|
||||
.eq('id', categoryId);
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
// --- SEPA Mandates ---
|
||||
|
||||
async listMandates(memberId: string) {
|
||||
const { data, error } = await this.client
|
||||
.from('sepa_mandates')
|
||||
.select('*')
|
||||
.eq('member_id', memberId)
|
||||
.order('created_at', { ascending: false });
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
}
|
||||
|
||||
async createMandate(input: {
|
||||
memberId: string;
|
||||
accountId: string;
|
||||
mandateReference: string;
|
||||
iban: string;
|
||||
bic?: string;
|
||||
accountHolder: string;
|
||||
mandateDate: string;
|
||||
sequence?: string;
|
||||
}) {
|
||||
const logger = await getLogger();
|
||||
logger.info(
|
||||
{ namespace: this.namespace, memberId: input.memberId },
|
||||
'Creating SEPA mandate...',
|
||||
);
|
||||
|
||||
const { data, error } = await this.client
|
||||
.from('sepa_mandates')
|
||||
.insert({
|
||||
member_id: input.memberId,
|
||||
account_id: input.accountId,
|
||||
mandate_reference: input.mandateReference,
|
||||
iban: input.iban,
|
||||
bic: input.bic ?? null,
|
||||
account_holder: input.accountHolder,
|
||||
mandate_date: input.mandateDate,
|
||||
sequence: (input.sequence ?? 'RCUR') as any,
|
||||
is_primary: true,
|
||||
status: 'active',
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
|
||||
async updateMandate(input: {
|
||||
mandateId: string;
|
||||
iban?: string;
|
||||
bic?: string;
|
||||
accountHolder?: string;
|
||||
sequence?: string;
|
||||
}) {
|
||||
const updateData: Record<string, unknown> = {};
|
||||
|
||||
if (input.iban !== undefined) updateData.iban = input.iban;
|
||||
if (input.bic !== undefined) updateData.bic = input.bic;
|
||||
if (input.accountHolder !== undefined)
|
||||
updateData.account_holder = input.accountHolder;
|
||||
if (input.sequence !== undefined) updateData.sequence = input.sequence;
|
||||
|
||||
const { data, error } = await this.client
|
||||
.from('sepa_mandates')
|
||||
.update(updateData)
|
||||
.eq('id', input.mandateId)
|
||||
.select()
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
|
||||
async revokeMandate(mandateId: string) {
|
||||
const { error } = await this.client
|
||||
.from('sepa_mandates')
|
||||
.update({ status: 'revoked' as any })
|
||||
.eq('id', mandateId);
|
||||
if (error) throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import 'server-only';
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import type { Database } from '@kit/supabase/database';
|
||||
|
||||
import type { MemberSearchFilters } from '../../schema/member.schema';
|
||||
|
||||
export function createMemberQueryService(client: SupabaseClient<Database>) {
|
||||
return new MemberQueryService(client);
|
||||
}
|
||||
|
||||
class MemberQueryService {
|
||||
private readonly namespace = 'member-query';
|
||||
|
||||
constructor(private readonly client: SupabaseClient<Database>) {}
|
||||
|
||||
async list(
|
||||
accountId: string,
|
||||
opts?: {
|
||||
status?: string;
|
||||
search?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
excludeArchived?: boolean;
|
||||
},
|
||||
) {
|
||||
let query = this.client
|
||||
.from('members')
|
||||
.select('*', { count: 'exact' })
|
||||
.eq('account_id', accountId)
|
||||
.order('last_name')
|
||||
.order('first_name');
|
||||
|
||||
// Opt-in archived filtering (matches original api.ts behavior)
|
||||
if (opts?.excludeArchived) {
|
||||
query = query.eq('is_archived', false);
|
||||
}
|
||||
|
||||
if (opts?.status) {
|
||||
query = query.eq(
|
||||
'status',
|
||||
opts.status as Database['public']['Enums']['membership_status'],
|
||||
);
|
||||
}
|
||||
|
||||
if (opts?.search) {
|
||||
const escaped = opts.search.replace(/[%_\\]/g, '\\$&');
|
||||
query = query.or(
|
||||
`last_name.ilike.%${escaped}%,first_name.ilike.%${escaped}%,email.ilike.%${escaped}%,member_number.ilike.%${escaped}%`,
|
||||
);
|
||||
}
|
||||
|
||||
const page = opts?.page ?? 1;
|
||||
const pageSize = opts?.pageSize ?? 25;
|
||||
query = query.range((page - 1) * pageSize, page * pageSize - 1);
|
||||
|
||||
const { data, error, count } = await query;
|
||||
if (error) throw error;
|
||||
return { data: data ?? [], total: count ?? 0, page, pageSize };
|
||||
}
|
||||
|
||||
async getById(accountId: string, memberId: string) {
|
||||
const { data, error } = await this.client
|
||||
.from('members')
|
||||
.select('*')
|
||||
.eq('id', memberId)
|
||||
.eq('account_id', accountId)
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
const logger = await getLogger();
|
||||
logger.warn(
|
||||
{ namespace: this.namespace, memberId, accountId, error },
|
||||
'Member lookup failed',
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
async search(filters: MemberSearchFilters) {
|
||||
const {
|
||||
accountId,
|
||||
search,
|
||||
status,
|
||||
departmentIds,
|
||||
tagIds,
|
||||
duesCategoryId,
|
||||
flags,
|
||||
entryDateFrom,
|
||||
entryDateTo,
|
||||
hasEmail,
|
||||
sortBy,
|
||||
sortDirection,
|
||||
page,
|
||||
pageSize,
|
||||
} = filters;
|
||||
|
||||
let query = this.client
|
||||
.from('members')
|
||||
.select('*', { count: 'exact' })
|
||||
.eq('account_id', accountId)
|
||||
.eq('is_archived', false);
|
||||
|
||||
if (status && status.length > 0) {
|
||||
query = query.in('status', status);
|
||||
}
|
||||
|
||||
if (search) {
|
||||
const escaped = search.replace(/[%_\\]/g, '\\$&');
|
||||
query = query.or(
|
||||
`last_name.ilike.%${escaped}%,first_name.ilike.%${escaped}%,email.ilike.%${escaped}%,member_number.ilike.%${escaped}%,city.ilike.%${escaped}%`,
|
||||
);
|
||||
}
|
||||
|
||||
if (duesCategoryId) {
|
||||
query = query.eq('dues_category_id', duesCategoryId);
|
||||
}
|
||||
|
||||
if (flags && flags.length > 0) {
|
||||
for (const flag of flags) {
|
||||
const col = `is_${flag === 'founding' ? 'founding_member' : flag}`;
|
||||
query = query.eq(col, true);
|
||||
}
|
||||
}
|
||||
|
||||
if (entryDateFrom) query = query.gte('entry_date', entryDateFrom);
|
||||
if (entryDateTo) query = query.lte('entry_date', entryDateTo);
|
||||
|
||||
if (hasEmail === true) {
|
||||
query = query.not('email', 'is', null).neq('email', '');
|
||||
} else if (hasEmail === false) {
|
||||
query = query.or('email.is.null,email.eq.');
|
||||
}
|
||||
|
||||
// Department filter via subquery
|
||||
if (departmentIds && departmentIds.length > 0) {
|
||||
const { data: deptMemberIds } = await this.client
|
||||
.from('member_department_assignments')
|
||||
.select('member_id')
|
||||
.in('department_id', departmentIds);
|
||||
const ids = (deptMemberIds ?? []).map((d) => d.member_id);
|
||||
|
||||
if (ids.length === 0) {
|
||||
return { data: [], total: 0, page, pageSize };
|
||||
}
|
||||
|
||||
query = query.in('id', ids);
|
||||
}
|
||||
|
||||
// Tag filter via subquery (same pattern as departments)
|
||||
if (tagIds && tagIds.length > 0) {
|
||||
const { data: tagMemberIds } = await (this.client.from as any)(
|
||||
'member_tag_assignments',
|
||||
)
|
||||
.select('member_id')
|
||||
.in('tag_id', tagIds);
|
||||
const ids = (tagMemberIds ?? []).map((d: any) => d.member_id);
|
||||
|
||||
if (ids.length === 0) {
|
||||
return { data: [], total: 0, page, pageSize };
|
||||
}
|
||||
|
||||
query = query.in('id', ids);
|
||||
}
|
||||
|
||||
// Sorting
|
||||
const ascending = sortDirection === 'asc';
|
||||
const sortColumn =
|
||||
sortBy === 'first_name'
|
||||
? 'first_name'
|
||||
: sortBy === 'entry_date'
|
||||
? 'entry_date'
|
||||
: sortBy === 'member_number'
|
||||
? 'member_number'
|
||||
: sortBy === 'city'
|
||||
? 'city'
|
||||
: sortBy === 'status'
|
||||
? 'status'
|
||||
: 'last_name';
|
||||
|
||||
query = query.order(sortColumn, { ascending });
|
||||
if (sortColumn !== 'first_name') {
|
||||
query = query.order('first_name', { ascending: true });
|
||||
}
|
||||
|
||||
query = query.range((page - 1) * pageSize, page * pageSize - 1);
|
||||
|
||||
const { data, error, count } = await query;
|
||||
if (error) throw error;
|
||||
return { data: data ?? [], total: count ?? 0, page, pageSize };
|
||||
}
|
||||
|
||||
async quickSearch(accountId: string, searchQuery: string, limit = 8) {
|
||||
const escaped = searchQuery.replace(/[%_\\]/g, '\\$&');
|
||||
const { data, error } = await this.client
|
||||
.from('members')
|
||||
.select('id, first_name, last_name, email, member_number, status')
|
||||
.eq('account_id', accountId)
|
||||
.eq('is_archived', false)
|
||||
.or(
|
||||
`last_name.ilike.%${escaped}%,first_name.ilike.%${escaped}%,email.ilike.%${escaped}%,member_number.ilike.%${escaped}%`,
|
||||
)
|
||||
.order('last_name')
|
||||
.limit(limit);
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
}
|
||||
|
||||
async getStatistics(accountId: string) {
|
||||
const { data, error } = await this.client
|
||||
.from('members')
|
||||
.select('status', { count: 'exact' })
|
||||
.eq('account_id', accountId)
|
||||
.eq('is_archived', false);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
const counts: Record<string, number> = {};
|
||||
for (const row of data ?? []) {
|
||||
counts[row.status] = (counts[row.status] ?? 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
async getQuickStats(accountId: string) {
|
||||
const { data, error } = await (this.client.rpc as any)(
|
||||
'get_member_quick_stats',
|
||||
{ p_account_id: accountId },
|
||||
);
|
||||
if (error) throw error;
|
||||
return data?.[0] ?? data;
|
||||
}
|
||||
|
||||
async getNextMemberNumber(accountId: string) {
|
||||
const { data, error } = await (this.client.rpc as any)(
|
||||
'get_next_member_number',
|
||||
{ p_account_id: accountId },
|
||||
);
|
||||
if (error) throw error;
|
||||
return String(data ?? '0001');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import 'server-only';
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import type { Database } from '@kit/supabase/database';
|
||||
|
||||
export function createMemberWorkflowService(client: SupabaseClient<Database>) {
|
||||
return new MemberWorkflowService(client);
|
||||
}
|
||||
|
||||
class MemberWorkflowService {
|
||||
private readonly namespace = 'member-workflow';
|
||||
|
||||
constructor(private readonly client: SupabaseClient<Database>) {}
|
||||
|
||||
async approveApplication(applicationId: string, userId: string) {
|
||||
const logger = await getLogger();
|
||||
|
||||
logger.info(
|
||||
{ namespace: this.namespace, applicationId },
|
||||
'Approving application...',
|
||||
);
|
||||
|
||||
// Atomic RPC: validates status, creates member, updates application
|
||||
const { data: memberId, error } = await (this.client.rpc as any)(
|
||||
'approve_application',
|
||||
{
|
||||
p_application_id: applicationId,
|
||||
p_user_id: userId,
|
||||
},
|
||||
);
|
||||
if (error) throw error;
|
||||
|
||||
// Fetch the created member to return full data
|
||||
const { data: member, error: fetchError } = await this.client
|
||||
.from('members')
|
||||
.select('*')
|
||||
.eq('id', memberId)
|
||||
.single();
|
||||
if (fetchError) throw fetchError;
|
||||
|
||||
logger.info(
|
||||
{ namespace: this.namespace, applicationId, memberId: member.id },
|
||||
'Application approved, member created',
|
||||
);
|
||||
|
||||
return member;
|
||||
}
|
||||
|
||||
async rejectApplication(
|
||||
applicationId: string,
|
||||
userId: string,
|
||||
reviewNotes?: string,
|
||||
) {
|
||||
const logger = await getLogger();
|
||||
|
||||
logger.info(
|
||||
{ namespace: this.namespace, applicationId },
|
||||
'Rejecting application...',
|
||||
);
|
||||
|
||||
const { error } = await (this.client.rpc as any)('reject_application', {
|
||||
p_application_id: applicationId,
|
||||
p_user_id: userId,
|
||||
p_review_notes: reviewNotes ?? null,
|
||||
});
|
||||
if (error) throw error;
|
||||
|
||||
logger.info(
|
||||
{ namespace: this.namespace, applicationId },
|
||||
'Application rejected',
|
||||
);
|
||||
}
|
||||
|
||||
async listApplications(accountId: string, status?: string) {
|
||||
let query = this.client
|
||||
.from('membership_applications')
|
||||
.select('*')
|
||||
.eq('account_id', accountId)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (status) {
|
||||
query = query.eq(
|
||||
'status',
|
||||
status as Database['public']['Enums']['application_status'],
|
||||
);
|
||||
}
|
||||
|
||||
const { data, error } = await query;
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
}
|
||||
|
||||
async inviteMemberToPortal(input: {
|
||||
memberId: string;
|
||||
accountId: string;
|
||||
email: string;
|
||||
userId: string;
|
||||
}) {
|
||||
const logger = await getLogger();
|
||||
|
||||
logger.info(
|
||||
{ namespace: this.namespace, memberId: input.memberId },
|
||||
'Sending portal invitation...',
|
||||
);
|
||||
|
||||
const token = crypto.randomUUID();
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setDate(expiresAt.getDate() + 7);
|
||||
|
||||
const { data, error } = await this.client
|
||||
.from('member_portal_invitations')
|
||||
.insert({
|
||||
account_id: input.accountId,
|
||||
member_id: input.memberId,
|
||||
email: input.email,
|
||||
invite_token: token,
|
||||
status: 'pending',
|
||||
invited_by: input.userId,
|
||||
expires_at: expiresAt.toISOString(),
|
||||
} as any)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
logger.info(
|
||||
{ namespace: this.namespace, invitationId: data?.id },
|
||||
'Portal invitation created',
|
||||
);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
async listPortalInvitations(accountId: string) {
|
||||
const { data, error } = await this.client
|
||||
.from('member_portal_invitations')
|
||||
.select('*')
|
||||
.eq('account_id', accountId)
|
||||
.order('created_at', { ascending: false });
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
}
|
||||
|
||||
async revokePortalInvitation(invitationId: string) {
|
||||
const { error } = await this.client
|
||||
.from('member_portal_invitations')
|
||||
.update({ status: 'revoked' as any })
|
||||
.eq('id', invitationId);
|
||||
if (error) throw error;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user