feat(admin): consentement aux annonces et désabonnement sans connexion
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 5m58s
CI / Deploy production (on server) (push) Successful in 26s

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:
Antigravity
2026-09-06 09:23:10 +00:00
parent 6fa12f6e13
commit 360e58e473
33 changed files with 1036 additions and 9 deletions

View 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 }
}