Add account hierarchy framework with migrations, RLS policies, and UI components
This commit is contained in:
@@ -1,14 +1,24 @@
|
||||
'use client';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useAction } from 'next-safe-action/hooks';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useAction } from 'next-safe-action/hooks';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@kit/ui/form';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@kit/ui/form';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
|
||||
import { CreateNewsletterSchema } from '../schema/newsletter.schema';
|
||||
import { createNewsletter } from '../server/actions/newsletter-actions';
|
||||
|
||||
@@ -44,55 +54,98 @@ export function CreateNewsletterForm({ accountId, account }: Props) {
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit((data) => execute(data))} className="space-y-6">
|
||||
<form
|
||||
onSubmit={form.handleSubmit((data) => execute(data))}
|
||||
className="space-y-6"
|
||||
>
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Newsletter-Inhalt</CardTitle></CardHeader>
|
||||
<CardHeader>
|
||||
<CardTitle>Newsletter-Inhalt</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<FormField control={form.control} name="subject" render={({ field }) => (
|
||||
<FormItem><FormLabel>Betreff *</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
|
||||
)} />
|
||||
<FormField control={form.control} name="bodyHtml" render={({ field }) => (
|
||||
<FormItem><FormLabel>Inhalt (HTML) *</FormLabel><FormControl>
|
||||
<textarea
|
||||
{...field}
|
||||
rows={12}
|
||||
className="flex min-h-[200px] w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm"
|
||||
placeholder="<h1>Hallo!</h1><p>Ihr Newsletter-Inhalt...</p>"
|
||||
/>
|
||||
</FormControl><FormMessage /></FormItem>
|
||||
)} />
|
||||
<FormField control={form.control} name="bodyText" render={({ field }) => (
|
||||
<FormItem><FormLabel>Nur-Text-Version (optional)</FormLabel><FormControl>
|
||||
<textarea
|
||||
{...field}
|
||||
rows={4}
|
||||
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
placeholder="Nur-Text-Fallback für E-Mail-Clients ohne HTML-Unterstützung"
|
||||
/>
|
||||
</FormControl><FormMessage /></FormItem>
|
||||
)} />
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="subject"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Betreff *</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="bodyHtml"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Inhalt (HTML) *</FormLabel>
|
||||
<FormControl>
|
||||
<textarea
|
||||
{...field}
|
||||
rows={12}
|
||||
className="border-input bg-background flex min-h-[200px] w-full rounded-md border px-3 py-2 font-mono text-sm"
|
||||
placeholder="<h1>Hallo!</h1><p>Ihr Newsletter-Inhalt...</p>"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="bodyText"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Nur-Text-Version (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<textarea
|
||||
{...field}
|
||||
rows={4}
|
||||
className="border-input bg-background flex min-h-[80px] w-full rounded-md border px-3 py-2 text-sm"
|
||||
placeholder="Nur-Text-Fallback für E-Mail-Clients ohne HTML-Unterstützung"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Zeitplan</CardTitle></CardHeader>
|
||||
<CardHeader>
|
||||
<CardTitle>Zeitplan</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<FormField control={form.control} name="scheduledAt" render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Geplanter Versand (optional)</FormLabel>
|
||||
<FormControl><Input type="datetime-local" {...field} /></FormControl>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Leer lassen, um den Newsletter als Entwurf zu speichern.
|
||||
</p>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)} />
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="scheduledAt"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Geplanter Versand (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="datetime-local" {...field} />
|
||||
</FormControl>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Leer lassen, um den Newsletter als Entwurf zu speichern.
|
||||
</p>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => router.back()}>Abbrechen</Button>
|
||||
<Button type="submit" disabled={isPending}>{isPending ? 'Wird erstellt...' : 'Newsletter erstellen'}</Button>
|
||||
<Button type="button" variant="outline" onClick={() => router.back()}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? 'Wird erstellt...' : 'Newsletter erstellen'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const NewsletterStatusEnum = z.enum(['draft', 'scheduled', 'sending', 'sent', 'failed']);
|
||||
export const NewsletterStatusEnum = z.enum([
|
||||
'draft',
|
||||
'scheduled',
|
||||
'sending',
|
||||
'sent',
|
||||
'failed',
|
||||
]);
|
||||
|
||||
export const CreateNewsletterSchema = z.object({
|
||||
accountId: z.string().uuid(),
|
||||
@@ -23,10 +29,12 @@ export const CreateTemplateSchema = z.object({
|
||||
|
||||
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(),
|
||||
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(),
|
||||
});
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
'use server';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import { authActionClient } from '@kit/next/safe-action';
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import {
|
||||
CreateNewsletterSchema,
|
||||
CreateTemplateSchema,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Database } from '@kit/supabase/database';
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import type { Database } from '@kit/supabase/database';
|
||||
|
||||
import type { CreateNewsletterInput } from '../schema/newsletter.schema';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
@@ -9,7 +10,10 @@ import type { CreateNewsletterInput } from '../schema/newsletter.schema';
|
||||
* Template variable substitution.
|
||||
* Replaces {{variable}} placeholders with actual values.
|
||||
*/
|
||||
function substituteVariables(template: string, variables: Record<string, string>): string {
|
||||
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);
|
||||
@@ -23,53 +27,96 @@ export function createNewsletterApi(client: SupabaseClient<Database>) {
|
||||
return {
|
||||
// --- Templates ---
|
||||
async listTemplates(accountId: string) {
|
||||
const { data, error } = await client.from('newsletter_templates').select('*')
|
||||
.eq('account_id', accountId).order('name');
|
||||
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();
|
||||
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 });
|
||||
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();
|
||||
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();
|
||||
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', '');
|
||||
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'][]);
|
||||
query = query.in(
|
||||
'status',
|
||||
filter.status as Database['public']['Enums']['membership_status'][],
|
||||
);
|
||||
}
|
||||
|
||||
const { data: members, error } = await query;
|
||||
@@ -84,19 +131,27 @@ export function createNewsletterApi(client: SupabaseClient<Database>) {
|
||||
}));
|
||||
|
||||
if (recipients.length > 0) {
|
||||
const { error: insertError } = await client.from('newsletter_recipients').insert(recipients);
|
||||
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);
|
||||
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');
|
||||
const { data, error } = await client
|
||||
.from('newsletter_recipients')
|
||||
.select('*')
|
||||
.eq('newsletter_id', newsletterId)
|
||||
.order('name');
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
},
|
||||
@@ -112,7 +167,10 @@ export function createNewsletterApi(client: SupabaseClient<Database>) {
|
||||
const pending = recipients.filter((r: any) => r.status === 'pending');
|
||||
|
||||
// Mark as sending
|
||||
await client.from('newsletters').update({ status: 'sending' }).eq('id', newsletterId);
|
||||
await client
|
||||
.from('newsletters')
|
||||
.update({ status: 'sending' })
|
||||
.eq('id', newsletterId);
|
||||
|
||||
let sentCount = 0;
|
||||
let failedCount = 0;
|
||||
@@ -129,25 +187,37 @@ export function createNewsletterApi(client: SupabaseClient<Database>) {
|
||||
// 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);
|
||||
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);
|
||||
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);
|
||||
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 };
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user