Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
-- Replaces rentalOpportunity.footer.unsubscribeAndEmailSettings with two separate keys:
-- unsubscribeFromAll (one-click public unsubscribe link)
-- manageSubscriptions (sign-in link to manage notification preferences)
-- Only targets global translations (jurisdiction_id IS NULL).

UPDATE translations
SET translations = jsonb_set(
translations,
'{rentalOpportunity,footer}',
COALESCE(translations->'rentalOpportunity'->'footer', '{}'::jsonb) || '{"unsubscribeFromAll": "Unsubscribe from all", "manageSubscriptions": "Manage subscriptions"}'::jsonb
)
WHERE language = 'en' AND jurisdiction_id IS NULL;

UPDATE translations
SET translations = jsonb_set(
translations,
'{rentalOpportunity,footer}',
COALESCE(translations->'rentalOpportunity'->'footer', '{}'::jsonb) || '{"unsubscribeFromAll": "Cancelar todas las suscripciones", "manageSubscriptions": "Gestionar suscripciones"}'::jsonb
)
WHERE language = 'es' AND jurisdiction_id IS NULL;

UPDATE translations
SET translations = jsonb_set(
translations,
'{rentalOpportunity,footer}',
COALESCE(translations->'rentalOpportunity'->'footer', '{}'::jsonb) || '{"unsubscribeFromAll": "Hủy đăng ký tất cả", "manageSubscriptions": "Quản lý đăng ký"}'::jsonb
)
WHERE language = 'vi' AND jurisdiction_id IS NULL;

UPDATE translations
SET translations = jsonb_set(
translations,
'{rentalOpportunity,footer}',
COALESCE(translations->'rentalOpportunity'->'footer', '{}'::jsonb) || '{"unsubscribeFromAll": "取消所有订阅", "manageSubscriptions": "管理订阅"}'::jsonb
)
WHERE language = 'zh' AND jurisdiction_id IS NULL;

UPDATE translations
SET translations = jsonb_set(
translations,
'{rentalOpportunity,footer}',
COALESCE(translations->'rentalOpportunity'->'footer', '{}'::jsonb) || '{"unsubscribeFromAll": "I-unsubscribe sa lahat", "manageSubscriptions": "Pamahalaan ang mga subscription"}'::jsonb
)
WHERE language = 'tl' AND jurisdiction_id IS NULL;

UPDATE translations
SET translations = jsonb_set(
translations,
'{rentalOpportunity,footer}',
COALESCE(translations->'rentalOpportunity'->'footer', '{}'::jsonb) || '{"unsubscribeFromAll": "সমস্ত থেকে আনসাবস্ক্রাইব করুন", "manageSubscriptions": "সাবস্ক্রিপশন পরিচালনা করুন"}'::jsonb
)
WHERE language = 'bn' AND jurisdiction_id IS NULL;

UPDATE translations
SET translations = jsonb_set(
translations,
'{rentalOpportunity,footer}',
COALESCE(translations->'rentalOpportunity'->'footer', '{}'::jsonb) || '{"unsubscribeFromAll": "إلغاء الاشتراك من الكل", "manageSubscriptions": "إدارة الاشتراكات"}'::jsonb
)
WHERE language = 'ar' AND jurisdiction_id IS NULL;

UPDATE translations
SET translations = jsonb_set(
translations,
'{rentalOpportunity,footer}',
COALESCE(translations->'rentalOpportunity'->'footer', '{}'::jsonb) || '{"unsubscribeFromAll": "모든 구독 취소", "manageSubscriptions": "구독 관리"}'::jsonb
)
WHERE language = 'ko' AND jurisdiction_id IS NULL;

UPDATE translations
SET translations = jsonb_set(
translations,
'{rentalOpportunity,footer}',
COALESCE(translations->'rentalOpportunity'->'footer', '{}'::jsonb) || '{"unsubscribeFromAll": "Դադարեցնել բոլոր բաժանորդագրությունները", "manageSubscriptions": "Կառավարել բաժանորդագրությունները"}'::jsonb
)
WHERE language = 'hy' AND jurisdiction_id IS NULL;

UPDATE translations
SET translations = jsonb_set(
translations,
'{rentalOpportunity,footer}',
COALESCE(translations->'rentalOpportunity'->'footer', '{}'::jsonb) || '{"unsubscribeFromAll": "لغو اشتراک از همه", "manageSubscriptions": "مدیریت اشتراک‌ها"}'::jsonb
)
WHERE language = 'fa' AND jurisdiction_id IS NULL;

-- Remove the old combined key from all languages
UPDATE translations
SET translations = jsonb_set(
translations,
'{rentalOpportunity,footer}',
(COALESCE(translations->'rentalOpportunity'->'footer', '{}'::jsonb) - 'unsubscribeAndEmailSettings')
)
WHERE jurisdiction_id IS NULL
AND translations->'rentalOpportunity'->'footer' ? 'unsubscribeAndEmailSettings';
36 changes: 20 additions & 16 deletions api/prisma/seed-helpers/translation-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,8 +226,8 @@ const translations = (
},
footer: {
accessibleMarketingFlyer: 'Volante de marketing accesible',
unsubscribeAndEmailSettings:
'Cancelar suscripción y gestionar configuración de correo electrónico',
unsubscribeFromAll: 'Cancelar todas las suscripciones',
manageSubscriptions: 'Gestionar suscripciones',
},
},
};
Expand Down Expand Up @@ -325,7 +325,8 @@ const translations = (
},
footer: {
accessibleMarketingFlyer: 'Tờ rơi tiếp thị có thể truy cập',
unsubscribeAndEmailSettings: 'Hủy đăng ký và quản lý cài đặt email',
unsubscribeFromAll: 'Hủy đăng ký tất cả',
manageSubscriptions: 'Quản lý đăng ký',
},
},
};
Expand Down Expand Up @@ -420,7 +421,8 @@ const translations = (
},
footer: {
accessibleMarketingFlyer: '无障碍营销传单',
unsubscribeAndEmailSettings: '取消订阅并管理电子邮件设置',
unsubscribeFromAll: '取消所有订阅',
manageSubscriptions: '管理订阅',
},
},
};
Expand Down Expand Up @@ -519,8 +521,8 @@ const translations = (
},
footer: {
accessibleMarketingFlyer: 'Naa-access na flyer sa marketing',
unsubscribeAndEmailSettings:
'Mag-unsubscribe at pamahalaan ang mga settings ng email',
unsubscribeFromAll: 'I-unsubscribe sa lahat',
manageSubscriptions: 'Pamahalaan ang mga subscription',
},
},
};
Expand Down Expand Up @@ -618,8 +620,8 @@ const translations = (
},
footer: {
accessibleMarketingFlyer: 'অ্যাক্সেসযোগ্য মার্কেটিং ফ্লায়ার',
unsubscribeAndEmailSettings:
'আনসাবস্ক্রাইব করুন এবং ইমেইল সেটিংস পরিচালনা করুন',
unsubscribeFromAll: 'সমস্ত থেকে আনসাবস্ক্রাইব করুন',
manageSubscriptions: 'সাবস্ক্রিপশন পরিচালনা করুন',
},
},
};
Expand Down Expand Up @@ -714,8 +716,8 @@ const translations = (
},
footer: {
accessibleMarketingFlyer: 'نشرة تسويقية ميسّرة',
unsubscribeAndEmailSettings:
'إلغاء الاشتراك وإدارة إعدادات البريد الإلكتروني',
unsubscribeFromAll: 'إلغاء الاشتراك من الكل',
manageSubscriptions: 'إدارة الاشتراكات',
},
},
};
Expand Down Expand Up @@ -811,7 +813,8 @@ const translations = (
},
footer: {
accessibleMarketingFlyer: '접근 가능한 마케팅 전단지',
unsubscribeAndEmailSettings: '구독 취소 및 이메일 설정 관리',
unsubscribeFromAll: '모든 구독 취소',
manageSubscriptions: '구독 관리',
},
},
};
Expand Down Expand Up @@ -909,8 +912,8 @@ const translations = (
},
footer: {
accessibleMarketingFlyer: 'Հասանելի մարքեթինգային թռուցիկ',
unsubscribeAndEmailSettings:
'Դադարեցնել բաժանորդագրությունը և կառավարել էլ. փոստի կարգավորումները',
unsubscribeFromAll: 'Դադարեցնել բոլոր բաժանորդագրությունները',
manageSubscriptions: 'Կառավարել բաժանորդագրությունները',
},
},
};
Expand Down Expand Up @@ -996,7 +999,8 @@ const translations = (
},
footer: {
accessibleMarketingFlyer: 'بروشور بازاریابی قابل دسترس',
unsubscribeAndEmailSettings: 'لغو اشتراک و مدیریت تنظیمات ایمیل',
unsubscribeFromAll: 'لغو اشتراک از همه',
manageSubscriptions: 'مدیریت اشتراک‌ها',
},
},
};
Expand Down Expand Up @@ -1356,8 +1360,8 @@ const translations = (
},
footer: {
accessibleMarketingFlyer: 'Accessible marketing flyer',
unsubscribeAndEmailSettings:
'Unsubscribe and manage email settings',
unsubscribeFromAll: 'Unsubscribe from all',
manageSubscriptions: 'Manage subscriptions',
},
},
advocateApproved: {
Expand Down
13 changes: 13 additions & 0 deletions api/src/controllers/user.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ import { AdvocateUserUpdate } from '../dtos/users/advocate-user-update.dto';
import { AdvocateUserCsvExporterService } from '../services/advocate-user-csv-export.service';
import { AdvocateUserAccept } from '../dtos/users/advocate-user-accept.dto';
import { UserNotificationPreferences } from '../dtos/users/user-notification-preferences.dto';
import { UnsubscribeAllDto } from '../dtos/users/unsubscribe-all.dto';

@Controller('user')
@ApiTags('user')
Expand Down Expand Up @@ -414,6 +415,18 @@ export class UserController {
return await this.userService.update(dto, req);
}

@Put('/unsubscribe-from-all')
@ApiOperation({
summary: 'Unsubscribe from all notifications',
operationId: 'unsubscribeFromAll',
})
@ApiOkResponse({ type: SuccessDTO })
async unsubscribeFromAll(
@Body() dto: UnsubscribeAllDto,
): Promise<SuccessDTO> {
return await this.userService.unsubscribeAll(dto);
}

@Put('/preferences')
@ApiOperation({
summary: 'Update user notification preferences',
Expand Down
18 changes: 18 additions & 0 deletions api/src/dtos/users/unsubscribe-all.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { ApiProperty } from '@nestjs/swagger';
import { Expose } from 'class-transformer';
import { IsNotEmpty, IsString } from 'class-validator';
import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum';

export class UnsubscribeAllDto {
@Expose()
@IsString({ groups: [ValidationsGroupsEnum.default] })
@IsNotEmpty({ groups: [ValidationsGroupsEnum.default] })
@ApiProperty()
email: string;

@Expose()
@IsString({ groups: [ValidationsGroupsEnum.default] })
@IsNotEmpty({ groups: [ValidationsGroupsEnum.default] })
@ApiProperty()
sig: string;
}
70 changes: 54 additions & 16 deletions api/src/services/aws-ses.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ import {
export class AwsSesService extends EmailProvider {
sesClient: SESv2Client;

constructor(private readonly configService: ConfigService) {
// SES bulk send limit per API call.
static readonly MAX_EMAIL_TO_SEND_IN_BULK = 50;

constructor(configService: ConfigService) {
super();
const config: SESv2ClientConfig = {
maxAttempts: configService.get<number>('EMAIL_MAX_RETRIES', 3),
Expand All @@ -41,24 +44,59 @@ export class AwsSesService extends EmailProvider {
}

if (isMultipleRecipients) {
const MAX_EMAIL_TO_SEND_IN_BULK = 50;
// SES inline templates (TemplateContent) do not support per-recipient variable
// substitution via ReplacementTemplateData — that only works with stored named
// templates. When perRecipientData is present, substitute __variable__ placeholders
// manually per user and send in concurrent batches of MAX_EMAIL_TO_SEND_IN_BULK.
if (input.perRecipientData?.length) {
for (
let i = 0;
i < to.length;
i += AwsSesService.MAX_EMAIL_TO_SEND_IN_BULK
) {
const batch = to.slice(
i,
i + AwsSesService.MAX_EMAIL_TO_SEND_IN_BULK,
);
await Promise.all(
batch.map((email, j) => {
const data = input.perRecipientData[i + j];
const resolvedBody = data
? Object.entries(data).reduce(
(html, [key, value]) => html.replaceAll(`-${key}-`, value),
body,
)
: body;
return this.send({
...input,
to: email,
body: resolvedBody,
perRecipientData: undefined,
});
}),
);
}
return;
}

const emailsChunked = to.reduce((accum, curr, i) => {
const chunk = Math.floor(i / MAX_EMAIL_TO_SEND_IN_BULK);
accum[chunk] = [].concat(accum[chunk] || [], curr);
return accum;
}, []);
for (const emailList of emailsChunked) {
const chunked: string[][] = [];
for (
let i = 0;
i < to.length;
i += AwsSesService.MAX_EMAIL_TO_SEND_IN_BULK
) {
chunked.push(to.slice(i, i + AwsSesService.MAX_EMAIL_TO_SEND_IN_BULK));
}

for (const chunk of chunked) {
try {
const commandInput: SendBulkEmailRequest = {
FromEmailAddress: from,
BulkEmailEntries: emailList.map((email) => {
return {
Destination: {
ToAddresses: [email],
},
};
}),
BulkEmailEntries: chunk.map((email) => ({
Destination: {
ToAddresses: [email],
},
})),
DefaultContent: {
Template: {
TemplateContent: {
Expand All @@ -83,7 +121,7 @@ export class AwsSesService extends EmailProvider {
}
} catch (e) {
console.log(e);
console.error(`Failed to send emails to ${emailList.toString()}`);
console.error(`Failed to send emails to ${chunk.toString()}`);
}
}
} else {
Expand Down
9 changes: 9 additions & 0 deletions api/src/services/email-provider.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,15 @@ export type SendEmailInput = {
subject: string;
body: string;
attachment?: EmailAttachmentData;
// Per-recipient template substitution data, parallel to `to[]` when sending to multiple recipients.
// Allows a bulk send to carry per-user values (e.g. a signed unsubscribe URL) without one API
// call per recipient. Each key maps to a \{{key}} escape in the Handlebars template, which outputs
// the literal {{key}} placeholder into the rendered HTML for providers to substitute at send time.
// SES: string-replaces {{key}} placeholders per user, sends 50 concurrent individual calls
// (inline TemplateContent doesn't support ReplacementTemplateData bulk substitution).
// SendGrid: uses personalizations with bare key substitutions — the SDK wraps keys in {{}} via
// wrapSubstitutions — up to 1000 recipients per API call.
perRecipientData?: Record<string, string>[];
};

export abstract class EmailProvider {
Expand Down
Loading
Loading