Initial state for GitNexus analysis
This commit is contained in:
34
packages/features/newsletter/package.json
Normal file
34
packages/features/newsletter/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "@kit/newsletter",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"*": [
|
||||
"src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"exports": {
|
||||
"./api": "./src/server/api.ts",
|
||||
"./schema/*": "./src/schema/*.ts",
|
||||
"./components": "./src/components/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "git clean -xdf .turbo node_modules",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kit/next": "workspace:*",
|
||||
"@kit/shared": "workspace:*",
|
||||
"@kit/supabase": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@kit/ui": "workspace:*",
|
||||
"@supabase/supabase-js": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
"next": "catalog:",
|
||||
"next-safe-action": "catalog:",
|
||||
"react": "catalog:",
|
||||
"zod": "catalog:"
|
||||
}
|
||||
}
|
||||
1
packages/features/newsletter/src/components/index.ts
Normal file
1
packages/features/newsletter/src/components/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export {};
|
||||
32
packages/features/newsletter/src/schema/newsletter.schema.ts
Normal file
32
packages/features/newsletter/src/schema/newsletter.schema.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const NewsletterStatusEnum = z.enum(['draft', 'scheduled', 'sending', 'sent', 'failed']);
|
||||
|
||||
export const CreateNewsletterSchema = z.object({
|
||||
accountId: z.string().uuid(),
|
||||
templateId: z.string().uuid().optional(),
|
||||
subject: z.string().min(1).max(256),
|
||||
bodyHtml: z.string().min(1),
|
||||
bodyText: z.string().optional(),
|
||||
scheduledAt: z.string().optional(),
|
||||
});
|
||||
export type CreateNewsletterInput = z.infer<typeof CreateNewsletterSchema>;
|
||||
|
||||
export const CreateTemplateSchema = z.object({
|
||||
accountId: z.string().uuid(),
|
||||
name: z.string().min(1),
|
||||
subject: z.string().min(1),
|
||||
bodyHtml: z.string().min(1),
|
||||
bodyText: z.string().optional(),
|
||||
variables: z.array(z.string()).default([]),
|
||||
});
|
||||
|
||||
export const SelectRecipientsSchema = z.object({
|
||||
newsletterId: z.string().uuid(),
|
||||
memberFilter: z.object({
|
||||
status: z.array(z.string()).optional(),
|
||||
duesCategoryId: z.string().uuid().optional(),
|
||||
hasEmail: z.boolean().default(true),
|
||||
}).optional(),
|
||||
manualEmails: z.array(z.string().email()).optional(),
|
||||
});
|
||||
158
packages/features/newsletter/src/server/api.ts
Normal file
158
packages/features/newsletter/src/server/api.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import type { Database } from '@kit/supabase/database';
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import type { CreateNewsletterInput } 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) {
|
||||
const { data, error } = await client.from('newsletter_templates').select('*')
|
||||
.eq('account_id', accountId).order('name');
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
},
|
||||
|
||||
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) {
|
||||
const { data, error } = await client.from('newsletters').select('*')
|
||||
.eq('account_id', accountId).order('created_at', { ascending: false });
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
},
|
||||
|
||||
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 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,
|
||||
};
|
||||
}
|
||||
6
packages/features/newsletter/tsconfig.json
Normal file
6
packages/features/newsletter/tsconfig.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "@kit/tsconfig/base.json",
|
||||
"compilerOptions": { "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" },
|
||||
"include": ["*.ts", "*.tsx", "src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user