302 lines
8.4 KiB
TypeScript
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,
|
|
};
|
|
}
|