| 1 | /**
|
|---|
| 2 | * SubscriberService — nieuwsbrief-abonnees per site (premium feature #1).
|
|---|
| 3 | *
|
|---|
| 4 | * Double opt-in als SMTP er is (status 'pending' → 'confirmed' via confirm-link),
|
|---|
| 5 | * anders single opt-in ('confirmed' meteen). Elke abonnee heeft een token dat zowel
|
|---|
| 6 | * de confirm- als de unsubscribe-link draagt. Hergebruikt door #2 (download-voor-
|
|---|
| 7 | * email) en #8 (notify-me) als gedeelde abonnee-opslag.
|
|---|
| 8 | */
|
|---|
| 9 |
|
|---|
| 10 | import crypto from 'crypto';
|
|---|
| 11 | import db from '../config/database.js';
|
|---|
| 12 | import { v4 as uuid } from 'uuid';
|
|---|
| 13 |
|
|---|
| 14 | const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|---|
| 15 |
|
|---|
| 16 | export function isValidEmail(email) {
|
|---|
| 17 | return typeof email === 'string' && email.length <= 254 && EMAIL_RE.test(email.trim());
|
|---|
| 18 | }
|
|---|
| 19 |
|
|---|
| 20 | function newToken() {
|
|---|
| 21 | return crypto.randomBytes(24).toString('hex');
|
|---|
| 22 | }
|
|---|
| 23 |
|
|---|
| 24 | /**
|
|---|
| 25 | * Voeg een abonnee toe (of heractiveer een uitgeschreven/bestaande).
|
|---|
| 26 | * @returns {{ok:boolean, status?:string, token?:string, created?:boolean, error?:string}}
|
|---|
| 27 | * status 'pending' → er moet nog bevestigd worden (stuur confirm-mail)
|
|---|
| 28 | * status 'confirmed'→ direct actief (single opt-in)
|
|---|
| 29 | */
|
|---|
| 30 | export function addSubscriber(siteId, email, source = 'widget', { doubleOptin = false } = {}) {
|
|---|
| 31 | email = (email || '').trim().toLowerCase();
|
|---|
| 32 | if (!siteId) return { ok: false, error: 'no_site' };
|
|---|
| 33 | if (!isValidEmail(email)) return { ok: false, error: 'invalid_email' };
|
|---|
| 34 |
|
|---|
| 35 | const existing = db.prepare('SELECT * FROM subscribers WHERE site_id = ? AND email = ?').get(siteId, email);
|
|---|
| 36 | const status = doubleOptin ? 'pending' : 'confirmed';
|
|---|
| 37 |
|
|---|
| 38 | if (existing) {
|
|---|
| 39 | // Al actief → niets te doen (idempotent, geen dubbele mail).
|
|---|
| 40 | if (existing.status === 'confirmed') return { ok: true, status: 'confirmed', token: existing.token, created: false };
|
|---|
| 41 | // Pending of uitgeschreven → opnieuw uitnodigen/activeren met een verse token.
|
|---|
| 42 | const token = newToken();
|
|---|
| 43 | db.prepare("UPDATE subscribers SET status = ?, token = ?, source = ?, confirmed_at = CASE WHEN ? = 'confirmed' THEN CURRENT_TIMESTAMP ELSE NULL END WHERE id = ?")
|
|---|
| 44 | .run(status, token, source, status, existing.id);
|
|---|
| 45 | return { ok: true, status, token, created: false };
|
|---|
| 46 | }
|
|---|
| 47 |
|
|---|
| 48 | const token = newToken();
|
|---|
| 49 | db.prepare(
|
|---|
| 50 | "INSERT INTO subscribers (id, site_id, email, status, source, token, confirmed_at) VALUES (?,?,?,?,?,?, CASE WHEN ? = 'confirmed' THEN CURRENT_TIMESTAMP ELSE NULL END)"
|
|---|
| 51 | ).run(uuid(), siteId, email, status, source, token, status);
|
|---|
| 52 | return { ok: true, status, token, created: true };
|
|---|
| 53 | }
|
|---|
| 54 |
|
|---|
| 55 | export function confirm(token) {
|
|---|
| 56 | if (!token) return false;
|
|---|
| 57 | const row = db.prepare('SELECT id FROM subscribers WHERE token = ?').get(token);
|
|---|
| 58 | if (!row) return false;
|
|---|
| 59 | db.prepare("UPDATE subscribers SET status = 'confirmed', confirmed_at = CURRENT_TIMESTAMP WHERE id = ?").run(row.id);
|
|---|
| 60 | return true;
|
|---|
| 61 | }
|
|---|
| 62 |
|
|---|
| 63 | export function unsubscribe(token) {
|
|---|
| 64 | if (!token) return false;
|
|---|
| 65 | const row = db.prepare('SELECT id FROM subscribers WHERE token = ?').get(token);
|
|---|
| 66 | if (!row) return false;
|
|---|
| 67 | db.prepare("UPDATE subscribers SET status = 'unsub' WHERE id = ?").run(row.id);
|
|---|
| 68 | return true;
|
|---|
| 69 | }
|
|---|
| 70 |
|
|---|
| 71 | /** Bevestigde abonnees (email + token) voor een site — voor het versturen.
|
|---|
| 72 | * Optioneel filteren op bron (bv. 'notify' voor show-aankondigingen). */
|
|---|
| 73 | export function confirmedFor(siteId, source) {
|
|---|
| 74 | if (source) {
|
|---|
| 75 | return db.prepare("SELECT email, token FROM subscribers WHERE site_id = ? AND status = 'confirmed' AND source = ?").all(siteId, source);
|
|---|
| 76 | }
|
|---|
| 77 | return db.prepare("SELECT email, token FROM subscribers WHERE site_id = ? AND status = 'confirmed'").all(siteId);
|
|---|
| 78 | }
|
|---|
| 79 |
|
|---|
| 80 | export function counts(siteId) {
|
|---|
| 81 | const c = (st) => db.prepare('SELECT COUNT(*) AS n FROM subscribers WHERE site_id = ? AND status = ?').get(siteId, st).n;
|
|---|
| 82 | return { confirmed: c('confirmed'), pending: c('pending'), unsub: c('unsub') };
|
|---|
| 83 | }
|
|---|