Merge branch 'main' of https://gitea.frontieralgorithmics.de/zaid.marzguioui/myeasycms-v2
Some checks failed
Workflow / ʦ TypeScript (push) Failing after 5m40s
Workflow / ⚫️ Test (push) Has been skipped

This commit is contained in:
Zaid Marzguioui
2026-04-03 18:42:10 +02:00
24 changed files with 4372 additions and 153 deletions

View File

@@ -0,0 +1,99 @@
import 'server-only';
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@kit/supabase/database';
interface CommunicationListOptions {
type?: string;
direction?: string;
search?: string;
page?: number;
pageSize?: number;
}
interface CreateCommunicationInput {
accountId: string;
entityId: string;
type: string;
direction?: string;
subject?: string;
body?: string;
emailTo?: string;
emailCc?: string;
attachmentPaths?: string[];
}
export function createBookingCommunicationService(
client: SupabaseClient<Database>,
) {
return new BookingCommunicationService(client);
}
class BookingCommunicationService {
constructor(private readonly client: SupabaseClient<Database>) {}
async list(
bookingId: string,
accountId: string,
opts?: CommunicationListOptions,
) {
let query = (this.client.from as CallableFunction)('module_communications')
.select('*', { count: 'exact' })
.eq('module', 'bookings')
.eq('entity_id', bookingId)
.eq('account_id', accountId)
.order('created_at', { ascending: false });
if (opts?.type) query = query.eq('type', opts.type);
if (opts?.direction) query = query.eq('direction', opts.direction);
if (opts?.search) {
const escaped = opts.search.replace(/[%_\\]/g, '\\$&');
query = query.or(`subject.ilike.%${escaped}%,body.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 create(input: CreateCommunicationInput, userId: string) {
const { data, error } = await (this.client.from as CallableFunction)(
'module_communications',
)
.insert({
account_id: input.accountId,
module: 'bookings',
entity_id: input.entityId,
type: input.type,
direction: input.direction ?? 'internal',
subject: input.subject ?? null,
body: input.body ?? null,
email_to: input.emailTo ?? null,
email_cc: input.emailCc ?? null,
attachment_paths: input.attachmentPaths ?? null,
created_by: userId,
})
.select(
'id, account_id, module, entity_id, type, direction, subject, email_to, created_at, created_by',
)
.single();
if (error) throw error;
return data;
}
async delete(communicationId: string) {
const { error } = await (this.client.from as CallableFunction)(
'module_communications',
)
.delete()
.eq('id', communicationId)
.eq('module', 'bookings');
if (error) throw error;
}
}

View File

@@ -0,0 +1,76 @@
import 'server-only';
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@kit/supabase/database';
export function createBookingExportService(client: SupabaseClient<Database>) {
return new BookingExportService(client);
}
class BookingExportService {
constructor(private readonly client: SupabaseClient<Database>) {}
async exportBookingsCsv(
accountId: string,
filters?: { status?: string },
): Promise<string> {
let query = this.client
.from('bookings')
.select('*, rooms(room_number, name), guests(first_name, last_name)')
.eq('account_id', accountId)
.order('check_in', { ascending: false });
if (filters?.status) {
query = query.eq('status', filters.status as any);
}
const { data: bookings, error } = await query;
if (error) throw error;
if (!bookings?.length) return '';
const headers = ['Zimmer', 'Gast', 'Anreise', 'Abreise', 'Status', 'Preis'];
const rows = bookings.map((b) => {
const room = (b as any).rooms;
const guest = (b as any).guests;
const roomLabel = room
? `${room.room_number}${room.name ? ` (${room.name})` : ''}`
: '';
const guestLabel = guest ? `${guest.first_name} ${guest.last_name}` : '';
return [
roomLabel,
guestLabel,
b.check_in ?? '',
b.check_out ?? '',
b.status,
b.total_price?.toString() ?? '0',
]
.map((v) => `"${String(v).replace(/"/g, '""')}"`)
.join(';');
});
return [headers.join(';'), ...rows].join('\n');
}
async exportGuestsCsv(accountId: string): Promise<string> {
const { data: guests, error } = await this.client
.from('guests')
.select('*')
.eq('account_id', accountId)
.order('last_name');
if (error) throw error;
if (!guests?.length) return '';
const headers = ['Vorname', 'Nachname', 'E-Mail', 'Telefon', 'Ort'];
const rows = guests.map((g) =>
[g.first_name, g.last_name, g.email ?? '', g.phone ?? '', g.city ?? '']
.map((v) => `"${String(v).replace(/"/g, '""')}"`)
.join(';'),
);
return [headers.join(';'), ...rows].join('\n');
}
}

View File

@@ -0,0 +1,156 @@
import 'server-only';
import type { SupabaseClient } from '@supabase/supabase-js';
import { getLogger } from '@kit/shared/logger';
import type { Database } from '@kit/supabase/database';
const NAMESPACE = 'booking-notification';
const MODULE = 'bookings';
interface NotificationRule {
id: string;
channel: 'in_app' | 'email' | 'both';
recipient_type: string;
recipient_config: Record<string, unknown>;
subject_template: string | null;
message_template: string;
}
export function createBookingNotificationService(
client: SupabaseClient<Database>,
) {
return {
async enqueue(
accountId: string,
triggerEvent: string,
entityId: string,
context: Record<string, unknown>,
) {
await (client.rpc as CallableFunction)('enqueue_module_notification', {
p_account_id: accountId,
p_module: MODULE,
p_trigger_event: triggerEvent,
p_entity_id: entityId,
p_context: context,
});
},
async processPending(): Promise<{ processed: number; sent: number }> {
const logger = await getLogger();
const { data: pending, error } = await (client.from as CallableFunction)(
'pending_module_notifications',
)
.select('*')
.eq('module', MODULE)
.is('processed_at', null)
.order('created_at')
.limit(100);
if (error || !pending?.length) return { processed: 0, sent: 0 };
let sent = 0;
for (const n of pending as Array<{
id: number;
account_id: string;
trigger_event: string;
context: Record<string, unknown>;
}>) {
try {
sent += await this.dispatch(
n.account_id,
n.trigger_event,
n.context ?? {},
);
} catch (e) {
logger.error(
{ name: NAMESPACE, id: n.id, error: e },
'Dispatch failed',
);
}
await (client.from as CallableFunction)('pending_module_notifications')
.update({ processed_at: new Date().toISOString() })
.eq('id', n.id);
}
logger.info(
{ name: NAMESPACE, processed: pending.length, sent },
'Batch processed',
);
return { processed: pending.length, sent };
},
async dispatch(
accountId: string,
triggerEvent: string,
context: Record<string, unknown>,
): Promise<number> {
const { data: rules } = await (client.from as CallableFunction)(
'module_notification_rules',
)
.select('*')
.eq('account_id', accountId)
.eq('module', MODULE)
.eq('trigger_event', triggerEvent)
.eq('is_active', true);
if (!rules?.length) return 0;
let sent = 0;
for (const rule of rules as NotificationRule[]) {
const message = renderTemplate(rule.message_template, context);
if (rule.channel === 'in_app' || rule.channel === 'both') {
const { createNotificationsApi } =
await import('@kit/notifications/api');
const api = createNotificationsApi(client);
await api.createNotification({
account_id: accountId,
body: message,
type: 'info',
channel: 'in_app',
});
sent++;
}
if (rule.channel === 'email' || rule.channel === 'both') {
const subject = rule.subject_template
? renderTemplate(rule.subject_template, context)
: triggerEvent;
const email = context.email as string | undefined;
if (email) {
const { getMailer } = await import('@kit/mailers');
const mailer = await getMailer();
await mailer.sendEmail({
to: email,
from: process.env.EMAIL_SENDER ?? 'noreply@example.com',
subject,
html: `<div style="font-family:sans-serif;max-width:600px;margin:0 auto">${message}</div>`,
});
sent++;
}
}
}
return sent;
},
};
}
function 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;
}

View File

@@ -0,0 +1,99 @@
import 'server-only';
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@kit/supabase/database';
interface CommunicationListOptions {
type?: string;
direction?: string;
search?: string;
page?: number;
pageSize?: number;
}
interface CreateCommunicationInput {
accountId: string;
entityId: string;
type: string;
direction?: string;
subject?: string;
body?: string;
emailTo?: string;
emailCc?: string;
attachmentPaths?: string[];
}
export function createCourseCommunicationService(
client: SupabaseClient<Database>,
) {
return new CourseCommunicationService(client);
}
class CourseCommunicationService {
constructor(private readonly client: SupabaseClient<Database>) {}
async list(
courseId: string,
accountId: string,
opts?: CommunicationListOptions,
) {
let query = (this.client.from as CallableFunction)('module_communications')
.select('*', { count: 'exact' })
.eq('module', 'courses')
.eq('entity_id', courseId)
.eq('account_id', accountId)
.order('created_at', { ascending: false });
if (opts?.type) query = query.eq('type', opts.type);
if (opts?.direction) query = query.eq('direction', opts.direction);
if (opts?.search) {
const escaped = opts.search.replace(/[%_\\]/g, '\\$&');
query = query.or(`subject.ilike.%${escaped}%,body.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 create(input: CreateCommunicationInput, userId: string) {
const { data, error } = await (this.client.from as CallableFunction)(
'module_communications',
)
.insert({
account_id: input.accountId,
module: 'courses',
entity_id: input.entityId,
type: input.type,
direction: input.direction ?? 'internal',
subject: input.subject ?? null,
body: input.body ?? null,
email_to: input.emailTo ?? null,
email_cc: input.emailCc ?? null,
attachment_paths: input.attachmentPaths ?? null,
created_by: userId,
})
.select(
'id, account_id, module, entity_id, type, direction, subject, email_to, created_at, created_by',
)
.single();
if (error) throw error;
return data;
}
async delete(communicationId: string) {
const { error } = await (this.client.from as CallableFunction)(
'module_communications',
)
.delete()
.eq('id', communicationId)
.eq('module', 'courses');
if (error) throw error;
}
}

View File

@@ -0,0 +1,94 @@
import 'server-only';
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@kit/supabase/database';
export function createCourseExportService(client: SupabaseClient<Database>) {
return new CourseExportService(client);
}
class CourseExportService {
constructor(private readonly client: SupabaseClient<Database>) {}
async exportParticipantsCsv(courseId: string): Promise<string> {
const { data: participants, error } = await this.client
.from('course_participants')
.select('*')
.eq('course_id', courseId)
.order('last_name');
if (error) throw error;
if (!participants?.length) return '';
const headers = [
'Vorname',
'Nachname',
'E-Mail',
'Telefon',
'Status',
'Anmeldedatum',
];
const rows = participants.map((p) =>
[
p.first_name,
p.last_name,
p.email ?? '',
p.phone ?? '',
p.status,
p.enrolled_at ?? '',
]
.map((v) => `"${String(v).replace(/"/g, '""')}"`)
.join(';'),
);
return [headers.join(';'), ...rows].join('\n');
}
async exportCoursesCsv(
accountId: string,
filters?: { status?: string },
): Promise<string> {
let query = this.client
.from('courses')
.select('*')
.eq('account_id', accountId)
.order('start_date', { ascending: false });
if (filters?.status) {
query = query.eq('status', filters.status as any);
}
const { data: courses, error } = await query;
if (error) throw error;
if (!courses?.length) return '';
const headers = [
'Kursnr.',
'Name',
'Status',
'Startdatum',
'Enddatum',
'Gebuhr',
'Kapazitat',
'Min. Teilnehmer',
];
const rows = courses.map((c) =>
[
c.course_number ?? '',
c.name,
c.status,
c.start_date ?? '',
c.end_date ?? '',
c.fee?.toString() ?? '0',
c.capacity?.toString() ?? '',
c.min_participants?.toString() ?? '',
]
.map((v) => `"${String(v).replace(/"/g, '""')}"`)
.join(';'),
);
return [headers.join(';'), ...rows].join('\n');
}
}

View File

@@ -0,0 +1,156 @@
import 'server-only';
import type { SupabaseClient } from '@supabase/supabase-js';
import { getLogger } from '@kit/shared/logger';
import type { Database } from '@kit/supabase/database';
const NAMESPACE = 'course-notification';
const MODULE = 'courses';
interface NotificationRule {
id: string;
channel: 'in_app' | 'email' | 'both';
recipient_type: string;
recipient_config: Record<string, unknown>;
subject_template: string | null;
message_template: string;
}
export function createCourseNotificationService(
client: SupabaseClient<Database>,
) {
return {
async enqueue(
accountId: string,
triggerEvent: string,
entityId: string,
context: Record<string, unknown>,
) {
await (client.rpc as CallableFunction)('enqueue_module_notification', {
p_account_id: accountId,
p_module: MODULE,
p_trigger_event: triggerEvent,
p_entity_id: entityId,
p_context: context,
});
},
async processPending(): Promise<{ processed: number; sent: number }> {
const logger = await getLogger();
const { data: pending, error } = await (client.from as CallableFunction)(
'pending_module_notifications',
)
.select('*')
.eq('module', MODULE)
.is('processed_at', null)
.order('created_at')
.limit(100);
if (error || !pending?.length) return { processed: 0, sent: 0 };
let sent = 0;
for (const n of pending as Array<{
id: number;
account_id: string;
trigger_event: string;
context: Record<string, unknown>;
}>) {
try {
sent += await this.dispatch(
n.account_id,
n.trigger_event,
n.context ?? {},
);
} catch (e) {
logger.error(
{ name: NAMESPACE, id: n.id, error: e },
'Dispatch failed',
);
}
await (client.from as CallableFunction)('pending_module_notifications')
.update({ processed_at: new Date().toISOString() })
.eq('id', n.id);
}
logger.info(
{ name: NAMESPACE, processed: pending.length, sent },
'Batch processed',
);
return { processed: pending.length, sent };
},
async dispatch(
accountId: string,
triggerEvent: string,
context: Record<string, unknown>,
): Promise<number> {
const { data: rules } = await (client.from as CallableFunction)(
'module_notification_rules',
)
.select('*')
.eq('account_id', accountId)
.eq('module', MODULE)
.eq('trigger_event', triggerEvent)
.eq('is_active', true);
if (!rules?.length) return 0;
let sent = 0;
for (const rule of rules as NotificationRule[]) {
const message = renderTemplate(rule.message_template, context);
if (rule.channel === 'in_app' || rule.channel === 'both') {
const { createNotificationsApi } =
await import('@kit/notifications/api');
const api = createNotificationsApi(client);
await api.createNotification({
account_id: accountId,
body: message,
type: 'info',
channel: 'in_app',
});
sent++;
}
if (rule.channel === 'email' || rule.channel === 'both') {
const subject = rule.subject_template
? renderTemplate(rule.subject_template, context)
: triggerEvent;
const email = context.email as string | undefined;
if (email) {
const { getMailer } = await import('@kit/mailers');
const mailer = await getMailer();
await mailer.sendEmail({
to: email,
from: process.env.EMAIL_SENDER ?? 'noreply@example.com',
subject,
html: `<div style="font-family:sans-serif;max-width:600px;margin:0 auto">${message}</div>`,
});
sent++;
}
}
}
return sent;
},
};
}
function 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;
}

View File

@@ -0,0 +1,99 @@
import 'server-only';
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@kit/supabase/database';
interface CommunicationListOptions {
type?: string;
direction?: string;
search?: string;
page?: number;
pageSize?: number;
}
interface CreateCommunicationInput {
accountId: string;
entityId: string;
type: string;
direction?: string;
subject?: string;
body?: string;
emailTo?: string;
emailCc?: string;
attachmentPaths?: string[];
}
export function createEventCommunicationService(
client: SupabaseClient<Database>,
) {
return new EventCommunicationService(client);
}
class EventCommunicationService {
constructor(private readonly client: SupabaseClient<Database>) {}
async list(
eventId: string,
accountId: string,
opts?: CommunicationListOptions,
) {
let query = (this.client.from as CallableFunction)('module_communications')
.select('*', { count: 'exact' })
.eq('module', 'events')
.eq('entity_id', eventId)
.eq('account_id', accountId)
.order('created_at', { ascending: false });
if (opts?.type) query = query.eq('type', opts.type);
if (opts?.direction) query = query.eq('direction', opts.direction);
if (opts?.search) {
const escaped = opts.search.replace(/[%_\\]/g, '\\$&');
query = query.or(`subject.ilike.%${escaped}%,body.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 create(input: CreateCommunicationInput, userId: string) {
const { data, error } = await (this.client.from as CallableFunction)(
'module_communications',
)
.insert({
account_id: input.accountId,
module: 'events',
entity_id: input.entityId,
type: input.type,
direction: input.direction ?? 'internal',
subject: input.subject ?? null,
body: input.body ?? null,
email_to: input.emailTo ?? null,
email_cc: input.emailCc ?? null,
attachment_paths: input.attachmentPaths ?? null,
created_by: userId,
})
.select(
'id, account_id, module, entity_id, type, direction, subject, email_to, created_at, created_by',
)
.single();
if (error) throw error;
return data;
}
async delete(communicationId: string) {
const { error } = await (this.client.from as CallableFunction)(
'module_communications',
)
.delete()
.eq('id', communicationId)
.eq('module', 'events');
if (error) throw error;
}
}

View File

@@ -0,0 +1,84 @@
import 'server-only';
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@kit/supabase/database';
export function createEventExportService(client: SupabaseClient<Database>) {
return new EventExportService(client);
}
class EventExportService {
constructor(private readonly client: SupabaseClient<Database>) {}
async exportRegistrationsCsv(eventId: string): Promise<string> {
const { data: registrations, error } = await this.client
.from('event_registrations')
.select('*')
.eq('event_id', eventId)
.order('last_name');
if (error) throw error;
if (!registrations?.length) return '';
const headers = [
'Vorname',
'Nachname',
'E-Mail',
'Telefon',
'Geburtsdatum',
'Status',
'Anmeldedatum',
];
const rows = registrations.map((r) =>
[
r.first_name,
r.last_name,
r.email ?? '',
r.phone ?? '',
r.date_of_birth ?? '',
r.status,
r.created_at ?? '',
]
.map((v) => `"${String(v).replace(/"/g, '""')}"`)
.join(';'),
);
return [headers.join(';'), ...rows].join('\n');
}
async exportEventsCsv(
accountId: string,
filters?: { status?: string },
): Promise<string> {
let query = this.client
.from('events')
.select('*')
.eq('account_id', accountId)
.order('event_date', { ascending: false });
if (filters?.status) {
query = query.eq('status', filters.status as any);
}
const { data: events, error } = await query;
if (error) throw error;
if (!events?.length) return '';
const headers = ['Name', 'Status', 'Datum', 'Ort', 'Kapazitat'];
const rows = events.map((e) =>
[
e.name,
e.status,
e.event_date ?? '',
e.location ?? '',
e.capacity?.toString() ?? '',
]
.map((v) => `"${String(v).replace(/"/g, '""')}"`)
.join(';'),
);
return [headers.join(';'), ...rows].join('\n');
}
}

View File

@@ -0,0 +1,156 @@
import 'server-only';
import type { SupabaseClient } from '@supabase/supabase-js';
import { getLogger } from '@kit/shared/logger';
import type { Database } from '@kit/supabase/database';
const NAMESPACE = 'event-notification';
const MODULE = 'events';
interface NotificationRule {
id: string;
channel: 'in_app' | 'email' | 'both';
recipient_type: string;
recipient_config: Record<string, unknown>;
subject_template: string | null;
message_template: string;
}
export function createEventNotificationService(
client: SupabaseClient<Database>,
) {
return {
async enqueue(
accountId: string,
triggerEvent: string,
entityId: string,
context: Record<string, unknown>,
) {
await (client.rpc as CallableFunction)('enqueue_module_notification', {
p_account_id: accountId,
p_module: MODULE,
p_trigger_event: triggerEvent,
p_entity_id: entityId,
p_context: context,
});
},
async processPending(): Promise<{ processed: number; sent: number }> {
const logger = await getLogger();
const { data: pending, error } = await (client.from as CallableFunction)(
'pending_module_notifications',
)
.select('*')
.eq('module', MODULE)
.is('processed_at', null)
.order('created_at')
.limit(100);
if (error || !pending?.length) return { processed: 0, sent: 0 };
let sent = 0;
for (const n of pending as Array<{
id: number;
account_id: string;
trigger_event: string;
context: Record<string, unknown>;
}>) {
try {
sent += await this.dispatch(
n.account_id,
n.trigger_event,
n.context ?? {},
);
} catch (e) {
logger.error(
{ name: NAMESPACE, id: n.id, error: e },
'Dispatch failed',
);
}
await (client.from as CallableFunction)('pending_module_notifications')
.update({ processed_at: new Date().toISOString() })
.eq('id', n.id);
}
logger.info(
{ name: NAMESPACE, processed: pending.length, sent },
'Batch processed',
);
return { processed: pending.length, sent };
},
async dispatch(
accountId: string,
triggerEvent: string,
context: Record<string, unknown>,
): Promise<number> {
const { data: rules } = await (client.from as CallableFunction)(
'module_notification_rules',
)
.select('*')
.eq('account_id', accountId)
.eq('module', MODULE)
.eq('trigger_event', triggerEvent)
.eq('is_active', true);
if (!rules?.length) return 0;
let sent = 0;
for (const rule of rules as NotificationRule[]) {
const message = renderTemplate(rule.message_template, context);
if (rule.channel === 'in_app' || rule.channel === 'both') {
const { createNotificationsApi } =
await import('@kit/notifications/api');
const api = createNotificationsApi(client);
await api.createNotification({
account_id: accountId,
body: message,
type: 'info',
channel: 'in_app',
});
sent++;
}
if (rule.channel === 'email' || rule.channel === 'both') {
const subject = rule.subject_template
? renderTemplate(rule.subject_template, context)
: triggerEvent;
const email = context.email as string | undefined;
if (email) {
const { getMailer } = await import('@kit/mailers');
const mailer = await getMailer();
await mailer.sendEmail({
to: email,
from: process.env.EMAIL_SENDER ?? 'noreply@example.com',
subject,
html: `<div style="font-family:sans-serif;max-width:600px;margin:0 auto">${message}</div>`,
});
sent++;
}
}
}
return sent;
},
};
}
function 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;
}

File diff suppressed because it is too large Load Diff