Skip to content

Commit 4bc024b

Browse files
authored
feat(mass-mail): make announcements opt-in, and stoppable at any time (#261)
1 parent 7cb261a commit 4bc024b

38 files changed

Lines changed: 10052 additions & 31 deletions

apps/community/app/(board)/notifications/preferences/page.tsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
import type { Metadata } from 'next'
22
import { notFound } from 'next/navigation'
33

4-
import { NotificationPreferencesForm } from '@/components/account/notification-forms'
4+
import {
5+
AnnouncementsOptInForm,
6+
NotificationPreferencesForm,
7+
} from '@/components/account/notification-forms'
58
import { PushDeviceForm } from '@/components/account/push-device-form'
69
import { BoardNotice } from '@/components/shell/board-notice'
710
import { PanelPage } from '@/components/shell/panel-page'
11+
import { getContainer } from '@/server/container'
812
import { getActor } from '@/server/context'
913
import { getTranslator, tr } from '@/server/i18n'
1014
import { audiencesForActor } from '@/server/notification-audience'
@@ -34,6 +38,9 @@ export default async function NotificationPreferencesPage({
3438
await Promise.all(audiences.map((audience) => service.preferences(userId, audience)))
3539
).flat()
3640

41+
const { memberSettings } = getContainer()
42+
const settings = memberSettings === null ? null : await memberSettings.read(userId)
43+
3744
const push = await pushAvailability()
3845
const view = buildPreferencesView(rows, await getTranslator())
3946
const notice = notificationNotice(query, await getTranslator())
@@ -60,6 +67,13 @@ export default async function NotificationPreferencesPage({
6067
push={push.enabled}
6168
copy={notificationFormsCopy(await getTranslator())}
6269
/>
70+
71+
{settings !== null && (
72+
<AnnouncementsOptInForm
73+
optedIn={settings.massMailOptInAt !== null}
74+
copy={notificationFormsCopy(await getTranslator())}
75+
/>
76+
)}
6377
</PanelPage>
6478
)
6579
}

apps/community/app/(board)/unsubscribe/page.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,11 @@ export default async function UnsubscribePage({
6060
<UnsubscribeConfirmForm
6161
token={token}
6262
description={
63-
claim.scope === 'email'
64-
? await tr('page.unsubscribe.emailScope')
65-
: await tr('page.unsubscribe.targetScope')
63+
claim.scope === 'mass-mail'
64+
? await tr('page.unsubscribe.massMailScope')
65+
: claim.scope === 'email'
66+
? await tr('page.unsubscribe.emailScope')
67+
: await tr('page.unsubscribe.targetScope')
6668
}
6769
copy={followFormCopy(translator)}
6870
/>

apps/community/app/admin/users/mail/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ export default async function AdminMassMailPage() {
5858
<p>{await tr('page.before-press-it')}</p>
5959
<ul className="flex list-disc flex-col gap-1 pl-4">
6060
<li>{translator.t('adminUsers.massMailQueued')}</li>
61-
<li>{translator.t('adminUsers.massMailNoUnsubscribe')}</li>
61+
<li>{translator.t('adminUsers.massMailOptIn')}</li>
6262
<li>{translator.t('adminUsers.massMailContinues')}</li>
6363
<li>{await tr('page.email-cannot-be-unsent')}</li>
6464
</ul>

apps/community/src/components/account/notification-forms.tsx

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { EMPTY_STATE } from '@/server/auth-form-state'
66
import {
77
markAllNotificationsReadAction,
88
markNotificationReadAction,
9+
saveMassMailOptInAction,
910
saveNotificationPreferencesAction,
1011
} from '@/server/notification-actions'
1112

@@ -155,3 +156,47 @@ export function NotificationPreferencesForm({
155156
</form>
156157
)
157158
}
159+
160+
export function AnnouncementsOptInForm({ optedIn, copy }: { optedIn: boolean; copy: Copy }) {
161+
const [state, action] = useActionState(saveMassMailOptInAction, EMPTY_STATE)
162+
163+
return (
164+
<form
165+
action={action}
166+
className="flex flex-col gap-4 rounded-lg border border-border bg-card p-5"
167+
>
168+
<FormError message={state.error} />
169+
170+
<fieldset className="flex flex-col gap-3">
171+
<legend className="text-sm font-medium">
172+
{fromCopy(copy, 'accountForm.announcements.legend')}
173+
</legend>
174+
175+
<label className="flex items-start gap-3 text-sm">
176+
<input
177+
type="checkbox"
178+
name="announcements"
179+
defaultChecked={optedIn}
180+
className={`mt-1 ${CHECKBOX}`}
181+
/>
182+
<span>
183+
<span className="font-medium">{fromCopy(copy, 'accountForm.announcements.title')}</span>
184+
<span className="block text-muted-foreground">
185+
{fromCopy(copy, 'accountForm.announcements.description')}
186+
</span>
187+
</span>
188+
</label>
189+
</fieldset>
190+
191+
<p className="text-xs text-muted-foreground">
192+
{fromCopy(copy, 'accountForm.announcements.blurb')}
193+
</p>
194+
195+
<div>
196+
<button type="submit" className={BUTTON}>
197+
{fromCopy(copy, 'accountForm.announcements.submit')}
198+
</button>
199+
</div>
200+
</form>
201+
)
202+
}

apps/community/src/components/auth/register-form.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,24 @@ export function RegisterForm({
105105
</>
106106
)}
107107

108+
<label className="flex items-start gap-2 text-sm text-muted-foreground">
109+
<input
110+
type="checkbox"
111+
name="announcements"
112+
value="1"
113+
defaultChecked={state.values?.announcements === '1'}
114+
className="mt-0.5 size-4 rounded border-input accent-primary"
115+
/>
116+
<span>
117+
<span className="block text-foreground">
118+
{fromCopy(copy, 'authForm.register.announcements')}
119+
</span>
120+
<span className="block text-xs">
121+
{fromCopy(copy, 'authForm.register.announcementsHint')}
122+
</span>
123+
</span>
124+
</label>
125+
108126
{terms !== null && (
109127
<label className="flex items-start gap-2 text-sm text-muted-foreground">
110128
<input

apps/community/src/server/auth-actions.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,26 @@ describe('registerAction', () => {
229229
expect(state.values?.terms).toBeUndefined()
230230
})
231231

232+
it('records consent when the announcements box is ticked, and not otherwise', async () => {
233+
const consent: Array<{ userId: number; optIn: boolean }> = []
234+
;(getContainer() as unknown as Record<string, unknown>).memberSettings = {
235+
saveMassMailOptIn: async (input: { userId: number; optIn: boolean }) => {
236+
consent.push(input)
237+
},
238+
}
239+
240+
await redirectOf(registerAction(EMPTY_STATE, form({ ...CREDS, announcements: '1' })))
241+
expect(consent).toEqual([{ userId: expect.any(Number), optIn: true }])
242+
243+
await redirectOf(
244+
registerAction(
245+
EMPTY_STATE,
246+
form({ ...CREDS, username: 'quiet', email: 'quiet@example.com' }),
247+
),
248+
)
249+
expect(consent).toHaveLength(1)
250+
})
251+
232252
it('spends no allowance on a form a person simply got wrong', async () => {
233253
await registerAction(EMPTY_STATE, form({ ...CREDS, terms: '' }))
234254

apps/community/src/server/auth-actions.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,13 @@ export async function registerAction(_prev: FormState, form: FormData): Promise<
9393
const email = field(form, 'email')
9494
const password = field(form, 'password')
9595
const accepted = field(form, 'terms') !== ''
96-
const values = { username, email, ...(accepted ? { terms: '1' } : {}) }
96+
const announcements = field(form, 'announcements') !== ''
97+
const values = {
98+
username,
99+
email,
100+
...(accepted ? { terms: '1' } : {}),
101+
...(announcements ? { announcements: '1' } : {}),
102+
}
97103

98104
const identity = await configuredIdentity()
99105

@@ -142,6 +148,11 @@ export async function registerAction(_prev: FormState, form: FormData): Promise<
142148

143149
if (fields !== null) await fields.applyRegistration(result.account.id, fieldValues)
144150

151+
const { memberSettings } = getContainer()
152+
if (announcements && memberSettings !== null) {
153+
await memberSettings.saveMassMailOptIn({ userId: result.account.id, optIn: true })
154+
}
155+
145156
if (result.verificationToken !== undefined) {
146157
verification = {
147158
token: result.verificationToken,

apps/community/src/server/notification-actions.test.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ vi.mock('./context', () => ({ getActor: async () => actorRef.current }))
3434
const {
3535
markAllNotificationsReadAction,
3636
markNotificationReadAction,
37+
saveMassMailOptInAction,
3738
saveNotificationPreferencesAction,
3839
} = await import('./notification-actions')
3940
const { EMPTY_STATE } = await import('./auth-form-state')
@@ -139,7 +140,16 @@ class FakeNotifications implements NotificationRepository {
139140
}
140141
}
141142

143+
class FakeMemberSettings {
144+
readonly consent: Array<{ userId: number; optIn: boolean }> = []
145+
146+
async saveMassMailOptIn(input: { userId: number; optIn: boolean }) {
147+
this.consent.push(input)
148+
}
149+
}
150+
142151
let notifications: FakeNotifications
152+
let memberSettings: FakeMemberSettings
143153

144154
async function actorFor(groupId: number, userId: number | null): Promise<Actor> {
145155
const source = new InMemoryAuthorizationSource(SEED_BOARD)
@@ -175,8 +185,9 @@ async function run(
175185

176186
beforeEach(async () => {
177187
notifications = new FakeNotifications()
188+
memberSettings = new FakeMemberSettings()
178189
actorRef.current = await actorFor(SEED_GROUP.registered, 7)
179-
installTestContainer({ container: { notifications } })
190+
installTestContainer({ container: { notifications, memberSettings } })
180191
})
181192

182193
describe('marking read', () => {
@@ -272,3 +283,34 @@ describe('saving preferences', () => {
272283
expect(result.redirectedTo).toBe('/notifications/preferences?saved=1')
273284
})
274285
})
286+
287+
describe('the announcements opt-in', () => {
288+
it('records consent for the signed-in member, never for the id in the form', async () => {
289+
const result = await run(
290+
saveMassMailOptInAction,
291+
form([
292+
['announcements', '1'],
293+
['userId', '1'],
294+
]),
295+
)
296+
297+
expect(result.redirectedTo).toBe('/notifications/preferences?saved=announcements')
298+
expect(memberSettings.consent).toEqual([{ userId: 7, optIn: true }])
299+
})
300+
301+
it('reads an unticked box as consent withdrawn', async () => {
302+
const result = await run(saveMassMailOptInAction, form([]))
303+
304+
expect(result.redirectedTo).toBe('/notifications/preferences?saved=announcements')
305+
expect(memberSettings.consent).toEqual([{ userId: 7, optIn: false }])
306+
})
307+
308+
it('refuses a visitor who is not signed in', async () => {
309+
actorRef.current = await actorFor(SEED_GROUP.guest, null)
310+
311+
const result = await run(saveMassMailOptInAction, form([['announcements', '1']]))
312+
313+
expect(result.error).toBeDefined()
314+
expect(memberSettings.consent).toEqual([])
315+
})
316+
})

apps/community/src/server/notification-actions.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { msg } from '@meith/i18n'
77
import type { NotificationService } from '@meith/notifications'
88

99
import type { FormState } from './auth-form-state'
10+
import { getContainer } from './container'
1011
import { getActor } from './context'
1112
import { formStateReporter } from './form-state-reporter'
1213
import { positiveInt } from './form-values'
@@ -62,6 +63,30 @@ export async function markAllNotificationsReadAction(
6263
redirect('/notifications?read=all')
6364
}
6465

66+
export async function saveMassMailOptInAction(
67+
_prev: FormState,
68+
form: FormData,
69+
): Promise<FormState> {
70+
try {
71+
const actor = await getActor()
72+
if (actor.userId === null) throw new ForbiddenError(msg('error.app.must-logged'))
73+
74+
const { memberSettings } = getContainer()
75+
if (memberSettings === null) {
76+
throw new ForbiddenError(msg('error.app.board-running-in-memory-sample-data-9'))
77+
}
78+
79+
await memberSettings.saveMassMailOptIn({
80+
userId: actor.userId,
81+
optIn: form.get('announcements') !== null,
82+
})
83+
} catch (err) {
84+
return toFormState(err)
85+
}
86+
87+
redirect('/notifications/preferences?saved=announcements')
88+
}
89+
6590
export async function saveNotificationPreferencesAction(
6691
_prev: FormState,
6792
form: FormData,

apps/community/src/server/profile-fields.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ class FakeSettings implements MemberSettingsRepository {
107107
website: null,
108108
bio: null,
109109
displayGroupId: null,
110+
massMailOptInAt: null,
110111
}
111112
async read() {
112113
return this.row
@@ -117,6 +118,7 @@ class FakeSettings implements MemberSettingsRepository {
117118
async saveDisplayGroup() {}
118119
async saveProfile() {}
119120
async saveOptions() {}
121+
async saveMassMailOptIn() {}
120122
async adoptEmail() {
121123
return true
122124
}

0 commit comments

Comments
 (0)