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:
@@ -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>
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user