refactor: remove obsolete member management API module
Some checks failed
Workflow / ʦ TypeScript (pull_request) Failing after 5m57s
Workflow / ⚫️ Test (pull_request) Has been skipped

This commit is contained in:
T. Zehetbauer
2026-04-03 14:08:31 +02:00
parent 124c6a632a
commit 5c5aaabae5
132 changed files with 10107 additions and 3442 deletions

View File

@@ -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:*",

View File

@@ -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';

View File

@@ -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>
);
}

View File

@@ -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 */}

View File

@@ -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',

View File

@@ -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();

View File

@@ -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>
);
}

View 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;
}

View 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);
}

View File

@@ -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(),
});

View File

@@ -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(

View File

@@ -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(),
});

View 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(),
});

View File

@@ -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 };
});

View File

@@ -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 };
});

View File

@@ -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 };
});

View File

@@ -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

View File

@@ -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),
};
}

View File

@@ -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;
}
}

View File

@@ -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 ?? [];
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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');
}
}

View File

@@ -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;
}
}