Merge branch 'main' of https://gitea.frontieralgorithmics.de/zaid.marzguioui/myeasycms-v2
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user