feat(admin): consentement aux annonces et désabonnement sans connexion
Les comptes déjà créés ne sont pas inscrits. Le choix est facultatif à l’inscription et dans les paramètres. Un lien public demande confirmation avant d’arrêter les actualités, sans toucher aux messages de compte.
This commit is contained in:
205
memento-note/lib/marketing/preference.ts
Normal file
205
memento-note/lib/marketing/preference.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import prisma from '@/lib/prisma'
|
||||
import { MARKETING_CONSENT_VERSION, marketingConsentText } from '@/lib/marketing/consent-copy'
|
||||
import { normalizeMarketingEmail } from '@/lib/marketing/normalize-email'
|
||||
|
||||
export const MARKETING_BLOCKING_REASONS = ['COMPLAINT', 'HARD_BOUNCE', 'INVALID'] as const
|
||||
|
||||
export type MarketingSource =
|
||||
| 'settings'
|
||||
| 'register'
|
||||
| 'unsubscribe'
|
||||
| 'unsubscribe-one-click'
|
||||
| 'complaint'
|
||||
| 'bounce'
|
||||
|
||||
export type MarketingPreferenceView = {
|
||||
optedIn: boolean
|
||||
emailNormalized: string
|
||||
consentVersion: string | null
|
||||
updatedAt: Date | null
|
||||
blocked: boolean
|
||||
blockReason: string | null
|
||||
}
|
||||
|
||||
type RequestMeta = {
|
||||
ip?: string | null
|
||||
userAgent?: string | null
|
||||
}
|
||||
|
||||
export async function getMarketingPreferenceView(userId: string): Promise<MarketingPreferenceView> {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
email: true,
|
||||
marketingPreference: true,
|
||||
},
|
||||
})
|
||||
const emailNormalized = normalizeMarketingEmail(user?.email || '')
|
||||
const suppression = emailNormalized
|
||||
? await prisma.emailSuppression.findUnique({ where: { emailNormalized } })
|
||||
: null
|
||||
const blocked = Boolean(
|
||||
suppression && MARKETING_BLOCKING_REASONS.includes(suppression.reason as (typeof MARKETING_BLOCKING_REASONS)[number]),
|
||||
)
|
||||
return {
|
||||
optedIn: user?.marketingPreference?.optedIn === true && !suppression,
|
||||
emailNormalized,
|
||||
consentVersion: user?.marketingPreference?.consentVersion ?? null,
|
||||
updatedAt: user?.marketingPreference?.updatedAt ?? null,
|
||||
blocked,
|
||||
blockReason: blocked ? suppression?.reason ?? null : null,
|
||||
}
|
||||
}
|
||||
|
||||
export async function canReceiveMarketing(opts: {
|
||||
userId?: string | null
|
||||
email: string
|
||||
requireVerified?: boolean
|
||||
}): Promise<{ allowed: boolean; reason: string }> {
|
||||
const emailNormalized = normalizeMarketingEmail(opts.email)
|
||||
if (!emailNormalized) return { allowed: false, reason: 'invalid_email' }
|
||||
|
||||
const suppression = await prisma.emailSuppression.findUnique({ where: { emailNormalized } })
|
||||
if (suppression) return { allowed: false, reason: `suppressed:${suppression.reason}` }
|
||||
|
||||
const user = opts.userId
|
||||
? await prisma.user.findUnique({
|
||||
where: { id: opts.userId },
|
||||
select: {
|
||||
emailVerified: true,
|
||||
marketingPreference: { select: { optedIn: true, emailNormalized: true } },
|
||||
},
|
||||
})
|
||||
: await prisma.user.findUnique({
|
||||
where: { email: emailNormalized },
|
||||
select: {
|
||||
emailVerified: true,
|
||||
marketingPreference: { select: { optedIn: true, emailNormalized: true } },
|
||||
},
|
||||
})
|
||||
|
||||
if (!user) return { allowed: false, reason: 'no_account' }
|
||||
if (opts.requireVerified !== false && !user.emailVerified) {
|
||||
return { allowed: false, reason: 'unverified' }
|
||||
}
|
||||
if (!user.marketingPreference?.optedIn) return { allowed: false, reason: 'not_opted_in' }
|
||||
return { allowed: true, reason: 'ok' }
|
||||
}
|
||||
|
||||
export async function setMarketingOptIn(opts: {
|
||||
userId: string
|
||||
optedIn: boolean
|
||||
source: MarketingSource
|
||||
language?: string | null
|
||||
meta?: RequestMeta
|
||||
}): Promise<{ ok: true } | { ok: false; error: 'blocked' | 'missing_user' }> {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: opts.userId },
|
||||
select: { id: true, email: true },
|
||||
})
|
||||
if (!user) return { ok: false, error: 'missing_user' }
|
||||
|
||||
const emailNormalized = normalizeMarketingEmail(user.email)
|
||||
const existingSuppression = await prisma.emailSuppression.findUnique({
|
||||
where: { emailNormalized },
|
||||
})
|
||||
if (
|
||||
opts.optedIn &&
|
||||
existingSuppression &&
|
||||
MARKETING_BLOCKING_REASONS.includes(existingSuppression.reason as (typeof MARKETING_BLOCKING_REASONS)[number])
|
||||
) {
|
||||
return { ok: false, error: 'blocked' }
|
||||
}
|
||||
|
||||
const consentText = opts.optedIn ? marketingConsentText(opts.language) : null
|
||||
const consentVersion = opts.optedIn ? MARKETING_CONSENT_VERSION : null
|
||||
|
||||
const preference = await prisma.marketingPreference.upsert({
|
||||
where: { userId: user.id },
|
||||
create: {
|
||||
userId: user.id,
|
||||
emailNormalized,
|
||||
optedIn: opts.optedIn,
|
||||
preferredLanguage: opts.language || null,
|
||||
consentVersion,
|
||||
consentText,
|
||||
source: opts.source,
|
||||
},
|
||||
update: {
|
||||
emailNormalized,
|
||||
optedIn: opts.optedIn,
|
||||
preferredLanguage: opts.language || undefined,
|
||||
consentVersion: opts.optedIn ? consentVersion : undefined,
|
||||
consentText: opts.optedIn ? consentText : undefined,
|
||||
source: opts.source,
|
||||
},
|
||||
})
|
||||
|
||||
await prisma.marketingPreferenceEvent.create({
|
||||
data: {
|
||||
preferenceId: preference.id,
|
||||
userId: user.id,
|
||||
emailNormalized,
|
||||
optedIn: opts.optedIn,
|
||||
source: opts.source,
|
||||
consentVersion,
|
||||
consentText,
|
||||
ip: opts.meta?.ip || null,
|
||||
userAgent: opts.meta?.userAgent || null,
|
||||
},
|
||||
})
|
||||
|
||||
if (opts.optedIn) {
|
||||
if (existingSuppression?.reason === 'UNSUBSCRIBE') {
|
||||
await prisma.emailSuppression.delete({ where: { emailNormalized } })
|
||||
}
|
||||
} else {
|
||||
await prisma.emailSuppression.upsert({
|
||||
where: { emailNormalized },
|
||||
create: {
|
||||
emailNormalized,
|
||||
reason: 'UNSUBSCRIBE',
|
||||
source: opts.source,
|
||||
userId: user.id,
|
||||
},
|
||||
update: {
|
||||
reason: existingSuppression && MARKETING_BLOCKING_REASONS.includes(existingSuppression.reason as (typeof MARKETING_BLOCKING_REASONS)[number])
|
||||
? existingSuppression.reason
|
||||
: 'UNSUBSCRIBE',
|
||||
source: opts.source,
|
||||
userId: user.id,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: opts.optedIn ? 'marketing.opt_in' : 'marketing.opt_out',
|
||||
resource: 'MarketingPreference',
|
||||
metadata: { source: opts.source, consentVersion },
|
||||
ip: opts.meta?.ip || null,
|
||||
userAgent: opts.meta?.userAgent || null,
|
||||
},
|
||||
}).catch(() => {})
|
||||
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
export async function unsubscribeByUserId(opts: {
|
||||
userId: string
|
||||
source: MarketingSource
|
||||
meta?: RequestMeta
|
||||
}): Promise<{ ok: true; already: boolean } | { ok: false }> {
|
||||
const view = await getMarketingPreferenceView(opts.userId)
|
||||
if (!view.emailNormalized) return { ok: false }
|
||||
const already = !view.optedIn
|
||||
const result = await setMarketingOptIn({
|
||||
userId: opts.userId,
|
||||
optedIn: false,
|
||||
source: opts.source,
|
||||
meta: opts.meta,
|
||||
})
|
||||
if (!result.ok) return { ok: false }
|
||||
return { ok: true, already }
|
||||
}
|
||||
Reference in New Issue
Block a user