Aplikasi yang baik tidak hanya mengirim notifikasi, tetapi juga menghormati pilihan pengguna tentang notifikasi apa yang ingin diterima dan lewat kanal apa. Tanpa kontrol tersebut, pengguna cepat lelah dan menandai email aplikasi Anda sebagai spam — dan satu tombol “report spam” dari pengguna cukup untuk merusak reputasi pengiriman seluruh sistem. Pada chapter ini kita membangun fondasi sistem preferensi notifikasi: memisahkan tipe notifikasi dari kanal pengiriman, menerapkan aturan khusus untuk alert keamanan, memilih antara kirim langsung dan digest, serta menangani permintaan berhenti berlangganan. Seluruh logika diuji pada SQLite :memory: sehingga dapat dijalankan tanpa layanan eksternal.

Memisahkan Tipe Notifikasi dan Kanal #

Sistem preferensi yang matang memisahkan dua sumbu yang sering dicampur: tipe notifikasi (isi pesan) dan kanal (media pengiriman). Tipe menjawab pertanyaan “notifikasi tentang apa”, sedangkan kanal menjawab “dikirim lewat mana”. Pengguna mungkin ingin invoice lewat email tetapi promo tidak sama sekali, atau alert keamanan lewat SMS karena email jarang dibuka. Tabel berikut memetakan tipe yang akan kita pakai sepanjang chapter:

Tipe notifikasiContoh isiDefault
security_alertLogin dari perangkat baru, password digantiSelalu aktif
order_updatePesanan dikirim, invoice tersediaAktif
marketingNewsletter, promo, diskonNonaktif

Kanal yang umum adalah email, sms, dan push. Dengan kombinasi kedua sumbu tersebut, satu pengguna memiliki satu preferensi per pasangan tipe-kanal: tujuh tipe dikali tiga kanal menghasilkan dua puluh satu kemungkinan yang masing-masing bisa aktif atau nonaktif. Model sederhana inilah yang menjadi dasar tabel database pada bagian berikutnya.

Menyimpan Preferensi per Pengguna #

Preferensi disimpan pada satu tabel dengan primary key gabungan user_id, notification_type, dan channel — pasangan ketiganya unik sehingga tidak mungkin ada dua baris preferensi yang bertentangan untuk kombinasi yang sama:

sql
CREATE TABLE notification_preferences (
    user_id TEXT NOT NULL,
    notification_type TEXT NOT NULL,
    channel TEXT NOT NULL,
    enabled INTEGER NOT NULL,
    PRIMARY KEY (user_id, notification_type, channel)
);

Program berikut membuat tabel tersebut, mengisi preferensi dua pengguna, lalu menampilkan matriksnya. Nilai enabled disimpan sebagai integer 1/0 karena SQLite tidak memiliki tipe boolean terpisah:

php
<?php
$pdo = new PDO("sqlite::memory:");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);

$pdo->exec("CREATE TABLE notification_preferences (
    user_id TEXT NOT NULL,
    notification_type TEXT NOT NULL,
    channel TEXT NOT NULL,
    enabled INTEGER NOT NULL,
    PRIMARY KEY (user_id, notification_type, channel)
)");

$stmt = $pdo->prepare(
    "INSERT INTO notification_preferences (user_id, notification_type, channel, enabled)
     VALUES (:user_id, :type, :channel, :enabled)"
);
$rows = [
    ["budi", "order_update", "email", 1],
    ["budi", "marketing", "email", 0],
    ["siti", "order_update", "email", 1],
    ["siti", "marketing", "email", 1],
    ["siti", "order_update", "push", 0],
];
foreach ($rows as $row) {
    $stmt->execute([
        ":user_id" => $row[0],
        ":type" => $row[1],
        ":channel" => $row[2],
        ":enabled" => $row[3],
    ]);
}

foreach ($pdo->query("SELECT user_id, notification_type, channel, enabled FROM notification_preferences ORDER BY user_id, notification_type, channel") as $row) {
    $status = ((int) $row["enabled"]) === 1 ? "aktif" : "nonaktif";
    echo "{$row['user_id']} / {$row['notification_type']} / {$row['channel']} -> {$status}\n";
}

Output yang Dihasilkan:

text
budi / marketing / email -> nonaktif
budi / order_update / email -> aktif
siti / marketing / email -> aktif
siti / order_update / email -> aktif
siti / order_update / push -> nonaktif

Dua hal perlu diperhatikan dari program di atas. Pertama, PDO SQLite mengembalikan nilai kolom integer sebagai string, sehingga baris (int) $row["enabled"] wajib mengonversi sebelum dibandingkan dengan === 1. Kedua, matriks hasil query hanya memuat baris yang eksplisit diisi — Budi tidak punya baris untuk push, dan itu bukan kecelakaan: baris yang hilang berarti “pakai default”, bukan “nonaktif”. Interpretasi baris hilang inilah yang diatur oleh function keputusan berikutnya.

Security Alert Selalu Aktif #

Function shouldSend() menjadi satu-satunya pintu keputusan sebelum notifikasi dikirim. Function tersebut memeriksa baris preferensi pengguna, tetapi dengan satu pengecualian yang ditempatkan paling awal: alert keamanan lewat email selalu dikirim apa pun isi database:

php
<?php
$pdo = new PDO("sqlite::memory:");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

$pdo->exec("CREATE TABLE notification_preferences (
    user_id TEXT NOT NULL,
    notification_type TEXT NOT NULL,
    channel TEXT NOT NULL,
    enabled INTEGER NOT NULL,
    PRIMARY KEY (user_id, notification_type, channel)
)");
$pdo->exec("INSERT INTO notification_preferences VALUES ('budi', 'security_alert', 'email', 0)");

function shouldSend(PDO $pdo, string $userId, string $type, string $channel): bool
{
    if ($type === "security_alert" && $channel === "email") {
        return true;
    }

    $defaults = [
        "order_update" => true,
        "marketing" => false,
        "security_alert" => true,
    ];

    $stmt = $pdo->prepare(
        "SELECT enabled FROM notification_preferences
         WHERE user_id = :user_id AND notification_type = :type AND channel = :channel"
    );
    $stmt->execute([":user_id" => $userId, ":type" => $type, ":channel" => $channel]);
    $row = $stmt->fetchColumn();

    if ($row === false) {
        return $defaults[$type] ?? false;
    }

    return ((int) $row) === 1;
}

$kasus = [
    ["budi", "security_alert", "email"],
    ["budi", "marketing", "email"],
    ["budi", "order_update", "email"],
    ["siti", "marketing", "email"],
];
foreach ($kasus as [$userId, $type, $channel]) {
    $kirim = shouldSend($pdo, $userId, $type, $channel) ? "kirim" : "lewati";
    echo "{$userId} / {$type} / {$channel} -> {$kirim}\n";
}

Output yang Dihasilkan:

text
budi / security_alert / email -> kirim
budi / marketing / email -> lewati
budi / order_update / email -> kirim
siti / marketing / email -> lewati

Alur keputusannya bertingkat seperti berikut: pengecualian security alert dievaluasi paling awal, lalu query mencari baris preferensi eksplisit, dan baris yang tidak ada jatuh ke default per tipe. Pada hasil eksekusi terlihat buktinya — Budi “mematikan” security alert pada database (baris dengan enabled = 0), tetapi keputusan tetap kirim; sebaliknya, marketing tanpa baris punya jatuh ke default false bagi kedua pengguna. Menariknya, hasil baris pertama dan terakhir identik meski alasannya berbeda: yang satu dipaksa oleh pengecualian, yang lain tidak punya baris dan default marketing memang nonaktif.

Preferensi di Dispatcher: Immediate versus Digest #

Preferensi menentukan “apakah boleh kirim”, tetiap sistem notifikasi juga perlu memutuskan “kapan mengirim”. Notifikasi transaksional harus segera (immediate), sedangkan notifikasi berisi rangkuman cocok dikumpulkan menjadi satu email digest harian agar kotak surat pengguna tidak banjir. Dispatcher berikut menggabungkan kedua keputusan sekaligus — memfilter berdasarkan preferensi, lalu memisahkan jalur immediate dan digest:

php
<?php
$notifications = [
    ["user" => "budi", "type" => "order_update", "mode" => "immediate", "subject" => "Pesanan #1042 dikirim"],
    ["user" => "budi", "type" => "marketing", "mode" => "immediate", "subject" => "Promo Akhir Pekan"],
    ["user" => "siti", "type" => "order_update", "mode" => "digest", "subject" => "Pesanan #1043 dikirim"],
    ["user" => "siti", "type" => "security_alert", "mode" => "immediate", "subject" => "Login dari perangkat baru"],
];

$preferensi = [
    "budi" => ["order_update" => true, "marketing" => false],
    "siti" => ["order_update" => true, "marketing" => true, "security_alert" => true],
];

$kirimSekarang = [];
$digestPerUser = [];

foreach ($notifications as $n) {
    $diizinkan = $n["type"] === "security_alert"
        || ($preferensi[$n["user"]][$n["type"]] ?? false);

    if (!$diizinkan) {
        continue;
    }

    if ($n["mode"] === "digest") {
        $digestPerUser[$n["user"]][] = $n["subject"];
    } else {
        $kirimSekarang[] = "{$n['user']}: {$n['subject']}";
    }
}

echo "Kirim segera:\n";
foreach ($kirimSekarang as $item) {
    echo "  {$item}\n";
}

echo "Digest harian:\n";
foreach ($digestPerUser as $user => $items) {
    echo "  {$user}: " . implode(" | ", $items) . "\n";
}

Output yang Dihasilkan:

text
Kirim segera:
  budi: Pesanan #1042 dikirim
  siti: Login dari perangkat baru
Digest harian:
  siti: Pesanan #1043 dikirim

Dispatcher menelusuri daftar notifikasi satu per satu: notifikasi yang tidak diizinkan (Promo untuk Budi) dihentikan oleh continue, notifikasi immediate menumpuk di $kirimSekarang, dan notifikasi digest dikelompokkan per pengguna pada $digestPerUser. Di production, notifikasi immediate berakhir sebagai job pada queue email dari chapter sebelumnya, sementara digest ditahan sampai scheduled task harian menjalankannya. Pola grouping foreach dengan penambahan array $digestPerUser[$n["user"]][] seperti di atas adalah bentuk paling sederhana dari pengelompokan per kunci.

Berhenti Berlangganan dengan Satu Klik #

Email marketing wajib menyediakan cara berhenti berlangganan yang mudah; di banyak yurisdiksi hal tersebut bahkan dituntut hukum. RFC 8058 mendefinisikan mekanisme one-click unsubscribe: email menyertakan header List-Unsubscribe berisi URL dan List-Unsubscribe-Post berisi penanda List-Unsubscribe=One-Click, sehingga tombol bawaan aplikasi email dapat mengirim permintaan POST satu klik tanpa membuka browser. Endpoint penerima kemudian memverifikasi token bertanda tangan sebelum menonaktifkan preferensi:

php
<?php
// Endpoint HTTPS nyata - membutuhkan web server dan permintaan POST dari
// penyedia email, jadi tidak dijalankan di lingkungan belajar ini.
header("Content-Type: text/plain");

$secret  = "YOUR_UNSUBSCRIBE_SECRET";
$payload = base64_decode($_POST["payload"] ?? "", true);
$token   = $_POST["token"] ?? "";

if ($payload === false || !hash_equals(hash_hmac("sha256", $payload, $secret), $token)) {
    http_response_code(401);
    echo "Token tidak valid\n";
    exit;
}

$userId = json_decode($payload, true)["user_id"] ?? "";
// ... set semua channel marketing untuk $userId menjadi nonaktif,
// lalu catat ke audit log.
echo "Berhasil berhenti berlangganan\n";

Endpoint memverifikasi token dengan hash_equals() — perbandingan constant-time yang kebal serangan timing — sebelum menyentuh database. Detail pembuatan token bertanda tangan HMAC dan header List-Unsubscribe dibedah tuntas pada chapter Email Security ; titik pentingnya di sini adalah keputusan bisnisnya: satu klik unsubscribe harus langsung mematikan seluruh kanal marketing, bukan sekadar satu kanal, karena itulah harapan pengguna saat mengeklik tombol tersebut.

Strategi Default dan Migrasi #

Sistem preferensi tidak berhenti di tabel pertama kali dibuat; pengguna baru mendaftar setiap hari dan tipe notifikasi baru ditambahkan seiring produk tumbuh. Strategi yang sehat adalah default eksplisit per tipe — transaksional aktif, marketing nonaktif — dan pembaruan menyelurh saat tipe baru lahir. Query berikut mengisi preferensi default untuk semua pengguna yang belum punya baris pada tipe-tipe tersebut:

sql
INSERT OR IGNORE INTO notification_preferences (user_id, notification_type, channel, enabled)
SELECT u.user_id, t.notification_type, 'email', t.enabled
FROM users u
CROSS JOIN (
    SELECT 'security_alert' AS notification_type, 1 AS enabled
    UNION ALL SELECT 'order_update', 1
    UNION ALL SELECT 'marketing', 0
) t;

Kata kunci OR IGNORE membuat query aman dijalankan berulang kali: baris yang sudah ada (primary key bentrok) dilewati diam-diam, dan hanya kombinasi pengguna-tipe-kanal yang belum terisi yang mendapat default. Pendekatan ini juga cocok untuk migrasi — ketika Anda meluncurkan tipe weekly_digest tahun depan, satu query serupa melengkapi matriks lama tanpa menyentuh pilihan pengguna yang sudah ada. Sebagai pelengkap, baris yang hilang tetap aman karena shouldSend() sudah memberi fallback default per tipe.

Ringkasan #

Sistem preferensi yang dihormati memiliki tiga lapisan: model data yang memisahkan tipe dari kanal, satu pintu keputusan dengan pengecualian security alert yang tidak bisa dimatikan, serta dispatcher yang membedakan immediate dari digest. Permintaan berhenti berlangganan ditangani lewat mekanisme RFC 8058 dengan token terverifikasi, sementara strategi default dan migrasi menjaga matriks preferensi tetap utuh seiring pertumbuhan produk. Dengan lapisan-lapisan tersebut, aplikasi Anda tahu kepada siapa boleh mengirim dan lewat kanal mana. Pertanyaan berikutnya menyangkut kepercayaan pada email itu sendiri — bagaimana memastikan email Anda tidak mudah dipalsukan dan isinya tidak bisa dimanipulasi? Jawabannya ada pada chapter Email Security .

Referensi #