Add onboarding email campaign system with post-verification welcome email

Implements automated onboarding emails to encourage user engagement:
- Post-verification welcome email with 5 tips (sent after email verification)
- "No Residence" email (2+ days after registration with no property)
- "No Tasks" email (5+ days after first residence with no tasks)

Key features:
- Each onboarding email type sent only once per user (enforced by unique constraint)
- Email open tracking via tracking pixel endpoint
- Daily scheduled job at 10:00 AM UTC to process eligible users
- Admin panel UI for viewing sent emails, stats, and manual sending
- Admin can send any email type to users from the user detail Testing section

New files:
- internal/models/onboarding_email.go - Database model with tracking
- internal/services/onboarding_email_service.go - Business logic and eligibility queries
- internal/handlers/tracking_handler.go - Email open tracking endpoint
- internal/admin/handlers/onboarding_handler.go - Admin API endpoints
- admin/src/app/(dashboard)/onboarding-emails/ - Admin UI pages

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-12-08 14:36:50 -06:00
parent e152a6308a
commit 9761156597
17 changed files with 1707 additions and 18 deletions
@@ -0,0 +1,298 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { ColumnDef } from '@tanstack/react-table';
import Link from 'next/link';
import { MoreHorizontal, Mail, MailOpen, TrendingUp } from 'lucide-react';
import { onboardingEmailsApi } from '@/lib/api';
import type { OnboardingEmail } from '@/lib/api';
import { DataTable } from '@/components/data-table';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
const emailTypeLabels: Record<string, string> = {
no_residence: 'No Residence',
no_tasks: 'No Tasks',
};
const emailTypeColors: Record<string, 'default' | 'secondary' | 'outline'> = {
no_residence: 'default',
no_tasks: 'secondary',
};
const columns: ColumnDef<OnboardingEmail>[] = [
{
accessorKey: 'id',
header: 'ID',
cell: ({ row }) => (
<span className="font-mono text-sm">{row.original.id}</span>
),
},
{
accessorKey: 'user_name',
header: 'User',
cell: ({ row }) => (
<div>
<div className="font-medium">{row.original.user_name || 'Unknown'}</div>
<div className="text-sm text-muted-foreground">{row.original.user_email}</div>
</div>
),
},
{
accessorKey: 'email_type',
header: 'Type',
cell: ({ row }) => (
<Badge variant={emailTypeColors[row.original.email_type] || 'outline'}>
{emailTypeLabels[row.original.email_type] || row.original.email_type}
</Badge>
),
},
{
accessorKey: 'sent_at',
header: 'Sent',
cell: ({ row }) => {
const date = new Date(row.original.sent_at);
return (
<div>
<div>{date.toLocaleDateString()}</div>
<div className="text-sm text-muted-foreground">{date.toLocaleTimeString()}</div>
</div>
);
},
},
{
accessorKey: 'opened_at',
header: 'Opened',
cell: ({ row }) => {
if (row.original.opened_at) {
const date = new Date(row.original.opened_at);
return (
<div className="flex items-center gap-2">
<MailOpen className="h-4 w-4 text-green-500" />
<div>
<div>{date.toLocaleDateString()}</div>
<div className="text-sm text-muted-foreground">{date.toLocaleTimeString()}</div>
</div>
</div>
);
}
return (
<div className="flex items-center gap-2 text-muted-foreground">
<Mail className="h-4 w-4" />
<span>Not opened</span>
</div>
);
},
},
{
accessorKey: 'tracking_id',
header: 'Tracking ID',
cell: ({ row }) => (
<span className="font-mono text-xs text-muted-foreground">
{row.original.tracking_id.substring(0, 12)}...
</span>
),
},
{
id: 'actions',
cell: ({ row }) => {
const email = row.original;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem asChild>
<Link href={`/admin/users/${email.user_id}`}>View user</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
},
},
];
function StatsCards() {
const { data: stats, isLoading } = useQuery({
queryKey: ['onboarding-emails-stats'],
queryFn: () => onboardingEmailsApi.getStats(),
});
if (isLoading) {
return (
<div className="grid gap-4 md:grid-cols-3 lg:grid-cols-5">
{[...Array(5)].map((_, i) => (
<Card key={i}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Loading...</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">-</div>
</CardContent>
</Card>
))}
</div>
);
}
return (
<div className="grid gap-4 md:grid-cols-3 lg:grid-cols-5">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Sent</CardTitle>
<Mail className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats?.total_sent ?? 0}</div>
<p className="text-xs text-muted-foreground">
{stats?.total_opened ?? 0} opened
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Overall Open Rate</CardTitle>
<TrendingUp className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{stats?.overall_open_rate?.toFixed(1) ?? 0}%
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">No Residence</CardTitle>
<Badge variant="default" className="text-xs">Type 1</Badge>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats?.no_residence_total ?? 0}</div>
<p className="text-xs text-muted-foreground">
{stats?.no_residence_open_rate?.toFixed(1) ?? 0}% open rate
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">No Tasks</CardTitle>
<Badge variant="secondary" className="text-xs">Type 2</Badge>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats?.no_tasks_total ?? 0}</div>
<p className="text-xs text-muted-foreground">
{stats?.no_tasks_open_rate?.toFixed(1) ?? 0}% open rate
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Opened</CardTitle>
<MailOpen className="h-4 w-4 text-green-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats?.total_opened ?? 0}</div>
<p className="text-xs text-muted-foreground">
of {stats?.total_sent ?? 0} sent
</p>
</CardContent>
</Card>
</div>
);
}
export default function OnboardingEmailsPage() {
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [emailType, setEmailType] = useState<string>('all');
const [openedFilter, setOpenedFilter] = useState<string>('all');
const { data, isLoading } = useQuery({
queryKey: ['onboarding-emails', { page, page_size: pageSize, email_type: emailType, opened: openedFilter }],
queryFn: () => onboardingEmailsApi.list({
page,
page_size: pageSize,
email_type: emailType !== 'all' ? emailType : undefined,
opened: openedFilter !== 'all' ? openedFilter as 'true' | 'false' : undefined,
}),
});
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Onboarding Emails</h1>
<p className="text-muted-foreground">
Track automated onboarding email campaigns and open rates
</p>
</div>
<StatsCards />
<div className="flex gap-4">
<div className="w-48">
<Select value={emailType} onValueChange={(value) => { setEmailType(value); setPage(1); }}>
<SelectTrigger>
<SelectValue placeholder="Email type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Types</SelectItem>
<SelectItem value="no_residence">No Residence</SelectItem>
<SelectItem value="no_tasks">No Tasks</SelectItem>
</SelectContent>
</Select>
</div>
<div className="w-48">
<Select value={openedFilter} onValueChange={(value) => { setOpenedFilter(value); setPage(1); }}>
<SelectTrigger>
<SelectValue placeholder="Opened status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Statuses</SelectItem>
<SelectItem value="true">Opened</SelectItem>
<SelectItem value="false">Not Opened</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DataTable
columns={columns}
data={data?.data ?? []}
totalCount={data?.total ?? 0}
page={page}
pageSize={pageSize}
onPageChange={setPage}
onPageSizeChange={(size) => {
setPageSize(size);
setPage(1);
}}
isLoading={isLoading}
/>
</div>
);
}
@@ -4,10 +4,10 @@ import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useParams, useRouter } from 'next/navigation';
import Link from 'next/link';
import { ArrowLeft, Edit, Trash2, Bell, Mail } from 'lucide-react';
import { ArrowLeft, Edit, Trash2, Bell, Mail, MailCheck, Home, ClipboardList, Sparkles } from 'lucide-react';
import { toast } from 'sonner';
import { usersApi, notificationsApi, emailsApi, subscriptionsApi } from '@/lib/api';
import { usersApi, notificationsApi, emailsApi, subscriptionsApi, onboardingEmailsApi } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
@@ -116,6 +116,31 @@ export function UserDetailClient() {
},
});
const sendOnboardingEmailMutation = useMutation({
mutationFn: (emailType: 'no_residence' | 'no_tasks') => onboardingEmailsApi.send({
user_id: userId,
email_type: emailType,
}),
onSuccess: (data) => {
toast.success(`Onboarding email sent to ${data.email}`);
},
onError: (error: Error & { response?: { data?: { error?: string; details?: string } } }) => {
const errorMsg = error.response?.data?.error || error.response?.data?.details || error.message;
toast.error(`Failed to send onboarding email: ${errorMsg}`);
},
});
const sendPostVerificationEmailMutation = useMutation({
mutationFn: () => emailsApi.sendPostVerificationEmail({ user_id: userId }),
onSuccess: (data) => {
toast.success(`Post-verification email sent to ${data.to}`);
},
onError: (error: Error & { response?: { data?: { error?: string; details?: string } } }) => {
const errorMsg = error.response?.data?.error || error.response?.data?.details || error.message;
toast.error(`Failed to send email: ${errorMsg}`);
},
});
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
@@ -383,9 +408,10 @@ export function UserDetailClient() {
<Card className="md:col-span-2">
<CardHeader>
<CardTitle>Testing</CardTitle>
<CardDescription>Send test notifications and emails to this user</CardDescription>
<CardDescription>Send test notifications, emails, and onboarding campaigns to this user</CardDescription>
</CardHeader>
<CardContent className="flex gap-4">
<CardContent className="space-y-4">
<div className="flex gap-4 flex-wrap">
{/* Push Notification Dialog */}
<Dialog open={showPushDialog} onOpenChange={setShowPushDialog}>
<DialogTrigger asChild>
@@ -485,6 +511,55 @@ export function UserDetailClient() {
</DialogFooter>
</DialogContent>
</Dialog>
</div>
<Separator />
{/* Onboarding Emails */}
<div>
<Label className="text-base font-medium">Onboarding Emails</Label>
<p className="text-sm text-muted-foreground mb-3">
Send onboarding campaign emails to encourage user engagement
</p>
<div className="flex gap-4 flex-wrap">
<Button
variant="outline"
onClick={() => {
if (confirm('Send "Post-Verification" welcome email with tips to this user?')) {
sendPostVerificationEmailMutation.mutate();
}
}}
disabled={sendPostVerificationEmailMutation.isPending}
>
<Sparkles className="mr-2 h-4 w-4" />
{sendPostVerificationEmailMutation.isPending ? 'Sending...' : 'Welcome Tips Email'}
</Button>
<Button
variant="outline"
onClick={() => {
if (confirm('Send "No Residence" onboarding email to this user?')) {
sendOnboardingEmailMutation.mutate('no_residence');
}
}}
disabled={sendOnboardingEmailMutation.isPending}
>
<Home className="mr-2 h-4 w-4" />
{sendOnboardingEmailMutation.isPending ? 'Sending...' : 'No Residence Email'}
</Button>
<Button
variant="outline"
onClick={() => {
if (confirm('Send "No Tasks" onboarding email to this user?')) {
sendOnboardingEmailMutation.mutate('no_tasks');
}
}}
disabled={sendOnboardingEmailMutation.isPending}
>
<ClipboardList className="mr-2 h-4 w-4" />
{sendOnboardingEmailMutation.isPending ? 'Sending...' : 'No Tasks Email'}
</Button>
</div>
</div>
</CardContent>
</Card>
</div>
+2
View File
@@ -20,6 +20,7 @@ import {
Layers,
Sparkles,
Mail,
MailCheck,
Share2,
KeyRound,
Smartphone,
@@ -63,6 +64,7 @@ const menuItems = [
{ title: 'Document Images', url: '/admin/document-images', icon: ImagePlus },
{ title: 'Notifications', url: '/admin/notifications', icon: Bell },
{ title: 'Notification Prefs', url: '/admin/notification-prefs', icon: BellRing },
{ title: 'Onboarding Emails', url: '/admin/onboarding-emails', icon: MailCheck },
{ title: 'Devices', url: '/admin/devices', icon: Smartphone },
{ title: 'Subscriptions', url: '/admin/subscriptions', icon: CreditCard },
];
+100
View File
@@ -321,6 +321,11 @@ export const emailsApi = {
const response = await api.post('/emails/send-test', data);
return response.data;
},
sendPostVerificationEmail: async (data: { user_id: number }): Promise<{ message: string; to: string }> => {
const response = await api.post('/emails/send-post-verification', data);
return response.data;
},
};
// Subscriptions API
@@ -1499,4 +1504,99 @@ export const documentImagesApi = {
},
};
// Onboarding Email Types
export interface OnboardingEmail {
id: number;
user_id: number;
user_email?: string;
user_name?: string;
email_type: 'no_residence' | 'no_tasks';
sent_at: string;
opened_at?: string;
tracking_id: string;
created_at: string;
}
export interface OnboardingEmailListParams {
page?: number;
page_size?: number;
email_type?: string;
user_id?: number;
opened?: 'true' | 'false';
}
export interface OnboardingEmailListResponse {
data: OnboardingEmail[];
total: number;
page: number;
page_size: number;
total_pages: number;
}
export interface OnboardingEmailStats {
no_residence_total: number;
no_residence_opened: number;
no_residence_open_rate: number;
no_tasks_total: number;
no_tasks_opened: number;
no_tasks_open_rate: number;
total_sent: number;
total_opened: number;
overall_open_rate: number;
}
export interface OnboardingEmailsByUserResponse {
data: OnboardingEmail[];
user_id: number;
count: number;
}
export interface SendOnboardingEmailRequest {
user_id: number;
email_type: 'no_residence' | 'no_tasks';
}
export interface SendOnboardingEmailResponse {
message: string;
user_id: number;
email: string;
email_type: string;
}
// Onboarding Emails API
export const onboardingEmailsApi = {
list: async (params?: OnboardingEmailListParams): Promise<OnboardingEmailListResponse> => {
const response = await api.get<OnboardingEmailListResponse>('/onboarding-emails', { params });
return response.data;
},
get: async (id: number): Promise<OnboardingEmail> => {
const response = await api.get<OnboardingEmail>(`/onboarding-emails/${id}`);
return response.data;
},
getByUser: async (userId: number): Promise<OnboardingEmailsByUserResponse> => {
const response = await api.get<OnboardingEmailsByUserResponse>(`/onboarding-emails/user/${userId}`);
return response.data;
},
getStats: async (): Promise<OnboardingEmailStats> => {
const response = await api.get<OnboardingEmailStats>('/onboarding-emails/stats');
return response.data;
},
send: async (data: SendOnboardingEmailRequest): Promise<SendOnboardingEmailResponse> => {
const response = await api.post<SendOnboardingEmailResponse>('/onboarding-emails/send', data);
return response.data;
},
delete: async (id: number): Promise<void> => {
await api.delete(`/onboarding-emails/${id}`);
},
bulkDelete: async (ids: number[]): Promise<void> => {
await api.delete('/onboarding-emails/bulk', { data: { ids } });
},
};
export default api;