Skip to content

Commit 3108677

Browse files
[dev] [Marfuen] mariano/email-unsubscribe (#1830)
* feat(email): add granular email unsubscribe preferences - Add emailPreferences JSON field to User model for granular control - Create unsubscribe preferences page with checkboxes for each email type - Add unsubscribe API routes (GET/POST) with secure token verification - Update all notification email templates to include unsubscribe links - Add unsubscribe checks to email sending functions - Create user settings page to re-subscribe from within app - Support per-email-type unsubscribe (policy, task reminders, weekly digest, unassigned items) - Use NEXT_PUBLIC_BETTER_AUTH_URL for unsubscribe links to support localhost/staging * refactor(unsubscribe): remove legacy unsubscribe API and integrate preferences handling * feat(user-settings): add user settings page for email notification preferences * chore(auth): add default email preferences to mock user --------- Co-authored-by: Mariano Fuentes <marfuen98@gmail.com>
1 parent 109132a commit 3108677

File tree

30 files changed

+1046
-26
lines changed

30 files changed

+1046
-26
lines changed

apps/app/src/app/(app)/[orgId]/people/all/actions/removeMember.ts

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@ import { z } from 'zod';
66
// Adjust safe-action import for colocalized structure
77
import { authActionClient } from '@/actions/safe-action';
88
import type { ActionResponse } from '@/actions/types';
9-
import { sendUnassignedItemsNotificationEmail, type UnassignedItem } from '@comp/email';
9+
import {
10+
isUserUnsubscribed,
11+
sendUnassignedItemsNotificationEmail,
12+
type UnassignedItem,
13+
} from '@comp/email';
1014

1115
const removeMemberSchema = z.object({
1216
memberId: z.string(),
@@ -252,15 +256,24 @@ export const removeMember = authActionClient
252256
const removedMemberName = targetMember.user.name || targetMember.user.email || 'Member';
253257

254258
if (owner) {
255-
// Send email to the org owner
256-
sendUnassignedItemsNotificationEmail({
257-
email: owner.user.email,
258-
userName: owner.user.name || owner.user.email || 'Owner',
259-
organizationName: organization.name,
260-
organizationId: ctx.session.activeOrganizationId,
261-
removedMemberName,
262-
unassignedItems,
263-
});
259+
// Check if owner is unsubscribed from unassigned items notifications
260+
const unsubscribed = await isUserUnsubscribed(
261+
db,
262+
owner.user.email,
263+
'unassignedItemsNotifications',
264+
);
265+
266+
if (!unsubscribed) {
267+
// Send email to the org owner
268+
sendUnassignedItemsNotificationEmail({
269+
email: owner.user.email,
270+
userName: owner.user.name || owner.user.email || 'Owner',
271+
organizationName: organization.name,
272+
organizationId: ctx.session.activeOrganizationId,
273+
removedMemberName,
274+
unassignedItems,
275+
});
276+
}
264277
}
265278
}
266279

apps/app/src/app/(app)/[orgId]/settings/layout.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ export default async function Layout({ children }: { children: React.ReactNode }
4040
path: `/${orgId}/settings/secrets`,
4141
label: 'Secrets',
4242
},
43+
{
44+
path: `/${orgId}/settings/user`,
45+
label: 'User Settings',
46+
},
4347
]}
4448
/>
4549
</Suspense>
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
'use server';
2+
3+
import { authActionClient } from '@/actions/safe-action';
4+
import { db } from '@db';
5+
import { revalidatePath } from 'next/cache';
6+
import { z } from 'zod';
7+
8+
const emailPreferencesSchema = z.object({
9+
preferences: z.object({
10+
policyNotifications: z.boolean(),
11+
taskReminders: z.boolean(),
12+
weeklyTaskDigest: z.boolean(),
13+
unassignedItemsNotifications: z.boolean(),
14+
}),
15+
});
16+
17+
export const updateEmailPreferencesAction = authActionClient
18+
.inputSchema(emailPreferencesSchema)
19+
.metadata({
20+
name: 'update-email-preferences',
21+
track: {
22+
event: 'update-email-preferences',
23+
description: 'Update Email Preferences',
24+
channel: 'server',
25+
},
26+
})
27+
.action(async ({ ctx, parsedInput }) => {
28+
const { user } = ctx;
29+
30+
if (!user?.email) {
31+
return {
32+
success: false,
33+
error: 'Not authorized',
34+
};
35+
}
36+
37+
try {
38+
const { preferences } = parsedInput;
39+
40+
// Check if all preferences are disabled
41+
const allUnsubscribed = Object.values(preferences).every((v) => v === false);
42+
43+
await db.user.update({
44+
where: { email: user.email },
45+
data: {
46+
emailPreferences: preferences,
47+
emailNotificationsUnsubscribed: allUnsubscribed,
48+
},
49+
});
50+
51+
// Revalidate the settings page
52+
if (ctx.session.activeOrganizationId) {
53+
revalidatePath(`/${ctx.session.activeOrganizationId}/settings/user`);
54+
}
55+
56+
return {
57+
success: true,
58+
};
59+
} catch (error) {
60+
console.error('Error updating email preferences:', error);
61+
return {
62+
success: false,
63+
error: 'Failed to update email preferences',
64+
};
65+
}
66+
});
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
'use client';
2+
3+
import { Button } from '@comp/ui/button';
4+
import {
5+
Card,
6+
CardContent,
7+
CardDescription,
8+
CardFooter,
9+
CardHeader,
10+
CardTitle,
11+
} from '@comp/ui/card';
12+
import { Checkbox } from '@comp/ui/checkbox';
13+
import { useAction } from 'next-safe-action/hooks';
14+
import { useState } from 'react';
15+
import { toast } from 'sonner';
16+
import { updateEmailPreferencesAction } from '../actions/update-email-preferences';
17+
18+
interface EmailPreferences {
19+
policyNotifications: boolean;
20+
taskReminders: boolean;
21+
weeklyTaskDigest: boolean;
22+
unassignedItemsNotifications: boolean;
23+
}
24+
25+
interface Props {
26+
initialPreferences: EmailPreferences;
27+
email: string;
28+
}
29+
30+
export function EmailNotificationPreferences({ initialPreferences, email }: Props) {
31+
// Normal logic: true = subscribed (checked), false = unsubscribed (unchecked)
32+
const [preferences, setPreferences] = useState<EmailPreferences>(initialPreferences);
33+
const [saving, setSaving] = useState(false);
34+
35+
const { execute } = useAction(updateEmailPreferencesAction, {
36+
onSuccess: () => {
37+
toast.success('Email preferences updated successfully');
38+
setSaving(false);
39+
},
40+
onError: ({ error }) => {
41+
toast.error(error.serverError || 'Failed to update preferences');
42+
setSaving(false);
43+
},
44+
});
45+
46+
const handleToggle = (key: keyof EmailPreferences, checked: boolean) => {
47+
setPreferences((prev) => ({
48+
...prev,
49+
[key]: checked,
50+
}));
51+
};
52+
53+
const handleSelectAll = () => {
54+
// If all are enabled (all true), disable all (set all to false)
55+
// If any are disabled (some false), enable all (set all to true)
56+
const allEnabled = Object.values(preferences).every((v) => v === true);
57+
setPreferences({
58+
policyNotifications: !allEnabled,
59+
taskReminders: !allEnabled,
60+
weeklyTaskDigest: !allEnabled,
61+
unassignedItemsNotifications: !allEnabled,
62+
});
63+
};
64+
65+
const handleSave = async () => {
66+
setSaving(true);
67+
execute({ preferences });
68+
};
69+
70+
// Check if all are disabled (all false)
71+
const allDisabled = Object.values(preferences).every((v) => v === false);
72+
73+
return (
74+
<Card>
75+
<CardHeader>
76+
<CardTitle>Email Notifications</CardTitle>
77+
<CardDescription>
78+
Manage which email notifications you receive at{' '}
79+
<span className="font-medium">{email}</span>. These preferences apply to all organizations
80+
you're a member of.
81+
</CardDescription>
82+
</CardHeader>
83+
<CardContent className="space-y-4">
84+
<div className="flex items-center justify-between border-b pb-4">
85+
<div>
86+
<label className="text-base font-medium text-foreground">Enable All</label>
87+
<p className="text-sm text-muted-foreground">Toggle all notifications</p>
88+
</div>
89+
<Button onClick={handleSelectAll} variant="outline" size="sm">
90+
{Object.values(preferences).every((v) => v === true) ? 'Disable All' : 'Enable All'}
91+
</Button>
92+
</div>
93+
94+
<div className="space-y-4">
95+
<label className="flex cursor-pointer items-start gap-4 rounded-lg border p-4 hover:bg-muted/50 transition-colors">
96+
<Checkbox
97+
checked={preferences.policyNotifications}
98+
onCheckedChange={(checked) => handleToggle('policyNotifications', checked === true)}
99+
className="mt-1 shrink-0"
100+
/>
101+
<div className="flex-1 min-w-0">
102+
<div className="font-medium text-foreground">Policy Notifications</div>
103+
<div className="text-sm text-muted-foreground">
104+
Receive emails when new policies are published or existing policies are updated
105+
</div>
106+
</div>
107+
</label>
108+
109+
<label className="flex cursor-pointer items-start gap-4 rounded-lg border p-4 hover:bg-muted/50 transition-colors">
110+
<Checkbox
111+
checked={preferences.taskReminders}
112+
onCheckedChange={(checked) => handleToggle('taskReminders', checked === true)}
113+
className="mt-1 shrink-0"
114+
/>
115+
<div className="flex-1 min-w-0">
116+
<div className="font-medium text-foreground">Task Reminders</div>
117+
<div className="text-sm text-muted-foreground">
118+
Receive reminders when tasks are due soon or overdue
119+
</div>
120+
</div>
121+
</label>
122+
123+
<label className="flex cursor-pointer items-start gap-4 rounded-lg border p-4 hover:bg-muted/50 transition-colors">
124+
<Checkbox
125+
checked={preferences.weeklyTaskDigest}
126+
onCheckedChange={(checked) => handleToggle('weeklyTaskDigest', checked === true)}
127+
className="mt-1 shrink-0"
128+
/>
129+
<div className="flex-1 min-w-0">
130+
<div className="font-medium text-foreground">Weekly Task Digest</div>
131+
<div className="text-sm text-muted-foreground">
132+
Receive a weekly summary of pending tasks
133+
</div>
134+
</div>
135+
</label>
136+
137+
<label className="flex cursor-pointer items-start gap-4 rounded-lg border p-4 hover:bg-muted/50 transition-colors">
138+
<Checkbox
139+
checked={preferences.unassignedItemsNotifications}
140+
onCheckedChange={(checked) =>
141+
handleToggle('unassignedItemsNotifications', checked === true)
142+
}
143+
className="mt-1 shrink-0"
144+
/>
145+
<div className="flex-1 min-w-0">
146+
<div className="font-medium text-foreground">Unassigned Items Notifications</div>
147+
<div className="text-sm text-muted-foreground">
148+
Receive notifications when items need reassignment after a member is removed
149+
</div>
150+
</div>
151+
</label>
152+
</div>
153+
</CardContent>
154+
<CardFooter className="flex justify-between">
155+
<div className="text-muted-foreground text-xs">
156+
You can also manage these preferences by clicking the unsubscribe link in any email
157+
notification.
158+
</div>
159+
<Button onClick={handleSave} disabled={saving}>
160+
{saving ? 'Saving...' : 'Save'}
161+
</Button>
162+
</CardFooter>
163+
</Card>
164+
);
165+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { auth } from '@/utils/auth';
2+
import { db } from '@db';
3+
import type { Metadata } from 'next';
4+
import { headers } from 'next/headers';
5+
import { EmailNotificationPreferences } from './components/EmailNotificationPreferences';
6+
7+
export default async function UserSettings() {
8+
const session = await auth.api.getSession({
9+
headers: await headers(),
10+
});
11+
12+
if (!session?.user?.email) {
13+
return null;
14+
}
15+
16+
const user = await db.user.findUnique({
17+
where: { email: session.user.email },
18+
select: {
19+
emailPreferences: true,
20+
emailNotificationsUnsubscribed: true,
21+
},
22+
});
23+
24+
const DEFAULT_PREFERENCES = {
25+
policyNotifications: true,
26+
taskReminders: true,
27+
weeklyTaskDigest: true,
28+
unassignedItemsNotifications: true,
29+
};
30+
31+
// If user has the old all-or-nothing unsubscribe flag, convert to preferences
32+
if (user?.emailNotificationsUnsubscribed) {
33+
const preferences = {
34+
policyNotifications: false,
35+
taskReminders: false,
36+
weeklyTaskDigest: false,
37+
unassignedItemsNotifications: false,
38+
};
39+
return (
40+
<div className="space-y-4">
41+
<EmailNotificationPreferences initialPreferences={preferences} email={session.user.email} />
42+
</div>
43+
);
44+
}
45+
46+
const preferences =
47+
user?.emailPreferences && typeof user.emailPreferences === 'object'
48+
? { ...DEFAULT_PREFERENCES, ...(user.emailPreferences as Record<string, boolean>) }
49+
: DEFAULT_PREFERENCES;
50+
51+
return (
52+
<div className="space-y-4">
53+
<EmailNotificationPreferences initialPreferences={preferences} email={session.user.email} />
54+
</div>
55+
);
56+
}
57+
58+
export async function generateMetadata(): Promise<Metadata> {
59+
return {
60+
title: 'User Settings',
61+
};
62+
}

0 commit comments

Comments
 (0)