Files
myeasycms-v2/packages/features/newsletter/src/server/api.ts
T. Zehetbauer 080ec1cb47
Some checks failed
Workflow / ʦ TypeScript (push) Failing after 4m52s
Workflow / ⚫️ Test (push) Has been skipped
feat: add delete functionality for leases, catch books, and permits; implement newsletter update feature
2026-04-01 17:53:39 +02:00

302 lines
8.4 KiB
TypeScript

import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@kit/supabase/database';
import type {
CreateNewsletterInput,
UpdateNewsletterInput,
} from '../schema/newsletter.schema';
/* eslint-disable @typescript-eslint/no-explicit-any */
/**
* Template variable substitution.
* Replaces {{variable}} placeholders with actual values.
*/
function substituteVariables(
template: string,
variables: Record<string, string>,
): string {
let result = template;
for (const [key, value] of Object.entries(variables)) {
result = result.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), value);
}
return result;
}
export function createNewsletterApi(client: SupabaseClient<Database>) {
const db = client;
return {
// --- Templates ---
async listTemplates(
accountId: string,
opts?: { search?: string; page?: number; pageSize?: number },
) {
const page = opts?.page ?? 1;
const pageSize = opts?.pageSize ?? 25;
let query = client
.from('newsletter_templates')
.select('*', { count: 'exact' })
.eq('account_id', accountId)
.order('name');
if (opts?.search) {
query = query.ilike('name', `%${opts.search}%`);
}
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,
totalPages: Math.ceil((count ?? 0) / pageSize),
};
},
async createTemplate(input: {
accountId: string;
name: string;
subject: string;
bodyHtml: string;
bodyText?: string;
variables?: string[];
}) {
const { data, error } = await client
.from('newsletter_templates')
.insert({
account_id: input.accountId,
name: input.name,
subject: input.subject,
body_html: input.bodyHtml,
body_text: input.bodyText,
variables: input.variables ?? [],
})
.select()
.single();
if (error) throw error;
return data;
},
// --- Newsletters ---
async listNewsletters(
accountId: string,
opts?: {
search?: string;
status?: string;
page?: number;
pageSize?: number;
},
) {
const page = opts?.page ?? 1;
const pageSize = opts?.pageSize ?? 25;
let query = client
.from('newsletters')
.select('*', { count: 'exact' })
.eq('account_id', accountId)
.order('created_at', { ascending: false });
if (opts?.search) {
query = query.ilike('subject', `%${opts.search}%`);
}
if (opts?.status) {
query = query.eq('status', opts.status);
}
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,
totalPages: Math.ceil((count ?? 0) / pageSize),
};
},
async createNewsletter(input: CreateNewsletterInput, userId: string) {
const { data, error } = await client
.from('newsletters')
.insert({
account_id: input.accountId,
template_id: input.templateId,
subject: input.subject,
body_html: input.bodyHtml,
body_text: input.bodyText,
status: input.scheduledAt ? 'scheduled' : 'draft',
scheduled_at: input.scheduledAt,
created_by: userId,
})
.select()
.single();
if (error) throw error;
return data;
},
async updateNewsletter(input: UpdateNewsletterInput) {
const update: Record<string, unknown> = {};
if (input.subject !== undefined) update.subject = input.subject;
if (input.bodyHtml !== undefined) update.body_html = input.bodyHtml;
if (input.bodyText !== undefined) update.body_text = input.bodyText;
if (input.templateId !== undefined)
update.template_id = input.templateId || null;
if (input.scheduledAt !== undefined)
update.scheduled_at = input.scheduledAt || null;
const { data, error } = await client
.from('newsletters')
.update(update)
.eq('id', input.newsletterId)
.select()
.single();
if (error) throw error;
return data;
},
async getNewsletter(newsletterId: string) {
const { data, error } = await client
.from('newsletters')
.select('*')
.eq('id', newsletterId)
.single();
if (error) throw error;
return data;
},
// --- Recipients ---
async addRecipientsFromMembers(
newsletterId: string,
accountId: string,
filter?: { status?: string[]; hasEmail?: boolean },
) {
let query = client
.from('members')
.select('id, first_name, last_name, email')
.eq('account_id', accountId)
.not('email', 'is', null)
.neq('email', '');
if (filter?.status && filter.status.length > 0) {
query = query.in(
'status',
filter.status as Database['public']['Enums']['membership_status'][],
);
}
const { data: members, error } = await query;
if (error) throw error;
const recipients = (members ?? []).map((m: any) => ({
newsletter_id: newsletterId,
member_id: m.id,
email: m.email,
name: `${m.first_name} ${m.last_name}`,
status: 'pending',
}));
if (recipients.length > 0) {
const { error: insertError } = await client
.from('newsletter_recipients')
.insert(recipients);
if (insertError) throw insertError;
}
// Update newsletter total
await client
.from('newsletters')
.update({ total_recipients: recipients.length })
.eq('id', newsletterId);
return recipients.length;
},
async getRecipients(newsletterId: string) {
const { data, error } = await client
.from('newsletter_recipients')
.select('*')
.eq('newsletter_id', newsletterId)
.order('name');
if (error) throw error;
return data ?? [];
},
/**
* Dispatch newsletter — sends to all pending recipients.
* Uses @kit/mailers under the hood. Rate-limited.
* This is a preview implementation; actual dispatch needs the mailer service.
*/
async dispatch(newsletterId: string) {
const newsletter = await this.getNewsletter(newsletterId);
const recipients = await this.getRecipients(newsletterId);
const pending = recipients.filter((r: any) => r.status === 'pending');
// Mark as sending
await client
.from('newsletters')
.update({ status: 'sending' })
.eq('id', newsletterId);
let sentCount = 0;
let failedCount = 0;
for (const recipient of pending) {
try {
// Substitute variables in the body
const personalizedHtml = substituteVariables(newsletter.body_html, {
first_name: (recipient.name ?? '').split(' ')[0] ?? '',
name: recipient.name ?? '',
email: recipient.email ?? '',
});
// TODO: Use @kit/mailers to actually send
// await mailer.send({ to: recipient.email, subject: newsletter.subject, html: personalizedHtml });
await client
.from('newsletter_recipients')
.update({
status: 'sent',
sent_at: new Date().toISOString(),
})
.eq('id', recipient.id);
sentCount++;
} catch (err) {
await client
.from('newsletter_recipients')
.update({
status: 'failed',
error_message:
err instanceof Error ? err.message : 'Unknown error',
})
.eq('id', recipient.id);
failedCount++;
}
}
// Update newsletter totals
await client
.from('newsletters')
.update({
status: failedCount === pending.length ? 'failed' : 'sent',
sent_at: new Date().toISOString(),
sent_count: sentCount,
failed_count: failedCount,
})
.eq('id', newsletterId);
return { sentCount, failedCount };
},
// Utility
substituteVariables,
};
}