Membangun sistem notifikasi produksi dengan Claude Code
Bangun notifikasi dengan Claude Code: DB, API, React, unread state, idempotency, retry queue, dan test.
Sistem notifikasi bukan hanya ikon lonceng. Di produksi, masalah yang sering muncul adalah pengiriman ganda, jumlah unread yang salah, email berlebihan, retry yang hilang, dan data privat yang terbuka di pesan eksternal. Panduan ini menunjukkan cara memberi batasan yang jelas ke Claude Code agar menghasilkan fondasi yang bisa dioperasikan: schema DB, API Next.js, service, notification center React, dan test.
Prinsip utamanya: notifikasi di dalam aplikasi adalah sumber kebenaran. Email dan push adalah channel eksternal yang dikirim dari delivery queue. Simpan dulu record di database, lalu tentukan apakah email/push perlu dibuat berdasarkan preference, severity, batching, rate limit, dan privacy.
Untuk referensi resmi, gunakan MDN Notifications API, Next.js Route Handlers, dan PostgreSQL CREATE TABLE. Jika menghubungkan email, ikuti panduan resmi seperti Resend dengan Next.js, tetapi kirim dari worker queue, bukan langsung dari request API. Dasar Claude Code ada di dokumentasi resmi.
Batas sistem
Bagi sistem menjadi empat lapisan.
| Lapisan | Tanggung jawab | Kegagalan umum |
|---|---|---|
| Event source | Payment failed, komentar, job selesai | Webhook yang sama datang berkali-kali |
| Service | Simpan, dedupe, unread, preference, limit | Aturan tersebar di UI |
| Queue | Retry email dan push | Gangguan provider menghilangkan delivery |
| UI | Center, badge, browser alert | Count berbeda antar tab dan device |
Jangan mulai dari push. Browser notification membutuhkan permission dan secure context; di mobile sering perlu Service Worker. Email punya biaya, unsubscribe, complaint, dan deliverability. Karena itu, buat record in-app yang kuat terlebih dahulu, lalu perlakukan email/push sebagai efek samping yang dikontrol.
Prompt untuk Claude Code:
Build a notification system for Next.js App Router and PostgreSQL.
In-app notifications are the source of truth. Email and push only go into a delivery queue.
Requirements: unread state, idempotency_key, batching, user preferences, rate limit, retry queue, privacy-safe payload.
Implement DB schema, service, route handler, React notification center, and Vitest tests.
Idempotency berarti operasi yang sama bisa dijalankan dua kali tanpa membuat hasil ganda. Dalam notifikasi, webhook payment yang sama hanya boleh membuat satu notifikasi. Batching berarti mengelompokkan event yang ramai, misalnya sepuluh komentar menjadi satu digest. Retry queue menyimpan pengiriman eksternal yang gagal agar bisa dicoba lagi.
Use case
Use case pertama adalah alert operasional SaaS. Billing gagal, sync gagal, atau background job error mungkin perlu in-app dan email. Jika satu job gagal seratus kali dalam satu menit, jangan kirim seratus email. Kirim critical langsung, dan jadikan sisanya digest.
Use case kedua adalah kolaborasi tim. Mention, komentar, dan permintaan review membutuhkan unread state yang akurat. User mengharapkan mark read, mark all read, dan badge yang stabil. Pada email/push, jangan masukkan komentar penuh; cukup summary pendek dan URL yang membutuhkan login.
Use case ketiga adalah operasi konten dan revenue. Di situs seperti ClaudeCodeLab, notifikasi bisa dipakai untuk checklist publish, CTA rusak, pengiriman PDF gratis, onboarding setelah pembelian, dan reminder konsultasi. Untuk topik terkait, baca tips produktivitas Claude Code dan validasi form dengan Claude Code.
Use case keempat adalah audit admin. Perubahan permission, pembuatan API key, dan export data harus bisa ditelusuri. Isi notifikasi jangan memuat secret; arahkan ke halaman audit yang terautentikasi.
Schema database
Schema PostgreSQL ini memisahkan sumber in-app, delivery queue, preference, dan rate limit.
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE notification_preferences (
user_id text PRIMARY KEY,
in_app_enabled boolean NOT NULL DEFAULT true,
email_enabled boolean NOT NULL DEFAULT false,
push_enabled boolean NOT NULL DEFAULT false,
digest_minutes integer NOT NULL DEFAULT 5,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE notifications (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id text NOT NULL,
event_name text NOT NULL,
title text NOT NULL,
body text NOT NULL,
target_url text,
severity text NOT NULL DEFAULT 'info',
data jsonb NOT NULL DEFAULT '{}'::jsonb,
idempotency_key text,
batch_key text,
read_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX notifications_user_idempotency_unique
ON notifications (user_id, idempotency_key)
WHERE idempotency_key IS NOT NULL;
CREATE INDEX notifications_user_created_idx
ON notifications (user_id, created_at DESC);
CREATE INDEX notifications_user_unread_idx
ON notifications (user_id, created_at DESC)
WHERE read_at IS NULL;
CREATE TABLE notification_delivery_queue (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
notification_id uuid NOT NULL REFERENCES notifications(id) ON DELETE CASCADE,
channel text NOT NULL CHECK (channel IN ('email', 'push')),
status text NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'sending', 'sent', 'failed')),
attempts integer NOT NULL DEFAULT 0,
max_attempts integer NOT NULL DEFAULT 5,
available_at timestamptz NOT NULL DEFAULT now(),
locked_at timestamptz,
last_error text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX notification_delivery_queue_pick_idx
ON notification_delivery_queue (status, available_at, created_at);
CREATE TABLE notification_rate_limits (
user_id text NOT NULL,
channel text NOT NULL,
window_start timestamptz NOT NULL,
count integer NOT NULL DEFAULT 0,
PRIMARY KEY (user_id, channel, window_start)
);
Gunakan read_at sebagai satu-satunya status baca. Jika juga menyimpan is_read, keduanya akan berbeda suatu saat. Untuk delete oleh user, archived_at biasanya lebih aman daripada delete fisik.
Notification service
Install package:
npm install pg
npm install -D @types/pg vitest
lib/notification-service.ts menangani penyimpanan, dedupe, preference, rate limit, dan retry queue.
import { Pool, type PoolClient } from "pg";
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
export type NotificationSeverity = "info" | "success" | "warning" | "critical";
export type ExternalChannel = "email" | "push";
export type NotificationRow = {
id: string;
user_id: string;
event_name: string;
title: string;
body: string;
target_url: string | null;
severity: NotificationSeverity;
data: Record<string, unknown>;
idempotency_key: string | null;
batch_key: string | null;
read_at: string | null;
created_at: string;
};
type Preferences = {
in_app_enabled: boolean;
email_enabled: boolean;
push_enabled: boolean;
digest_minutes: number;
};
export type CreateNotificationInput = {
userId: string;
eventName: string;
title: string;
body: string;
targetUrl?: string;
severity?: NotificationSeverity;
data?: Record<string, unknown>;
resourceId?: string;
idempotencyKey?: string;
batchKey?: string;
};
export function makeIdempotencyKey(input: {
userId: string;
eventName: string;
resourceId?: string;
}) {
if (!input.resourceId) return null;
return `${input.userId}:${input.eventName}:${input.resourceId}`;
}
export function shouldQueueExternal(input: {
channel: ExternalChannel;
severity: NotificationSeverity;
preferences: Pick<Preferences, "email_enabled" | "push_enabled">;
}) {
if (input.channel === "email" && !input.preferences.email_enabled) return false;
if (input.channel === "push" && !input.preferences.push_enabled) return false;
return input.severity === "warning" || input.severity === "critical";
}
async function ensurePreferences(client: PoolClient, userId: string) {
const result = await client.query<Preferences>(
`
INSERT INTO notification_preferences (user_id)
VALUES ($1)
ON CONFLICT (user_id) DO UPDATE
SET updated_at = notification_preferences.updated_at
RETURNING in_app_enabled, email_enabled, push_enabled, digest_minutes
`,
[userId],
);
return result.rows[0];
}
async function consumeRateLimit(
client: PoolClient,
userId: string,
channel: ExternalChannel,
limitPerMinute: number,
) {
const result = await client.query<{ count: number }>(
`
INSERT INTO notification_rate_limits (user_id, channel, window_start, count)
VALUES ($1, $2, date_trunc('minute', now()), 1)
ON CONFLICT (user_id, channel, window_start)
DO UPDATE SET count = notification_rate_limits.count + 1
RETURNING count
`,
[userId, channel],
);
return result.rows[0].count <= limitPerMinute;
}
async function queueDelivery(
client: PoolClient,
notificationId: string,
channel: ExternalChannel,
availableAt: Date,
) {
await client.query(
`
INSERT INTO notification_delivery_queue (notification_id, channel, available_at)
VALUES ($1, $2, $3)
`,
[notificationId, channel, availableAt],
);
}
export async function createNotification(input: CreateNotificationInput) {
const client = await pool.connect();
const severity = input.severity ?? "info";
const idempotencyKey =
input.idempotencyKey ??
makeIdempotencyKey({
userId: input.userId,
eventName: input.eventName,
resourceId: input.resourceId,
});
try {
await client.query("BEGIN");
const preferences = await ensurePreferences(client, input.userId);
if (!preferences.in_app_enabled) {
await client.query("COMMIT");
return null;
}
const result = await client.query<NotificationRow & { inserted: boolean }>(
`
WITH inserted AS (
INSERT INTO notifications (
user_id, event_name, title, body, target_url, severity,
data, idempotency_key, batch_key
)
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $9)
ON CONFLICT (user_id, idempotency_key)
WHERE idempotency_key IS NOT NULL
DO NOTHING
RETURNING *, true AS inserted
)
SELECT * FROM inserted
UNION ALL
SELECT *, false AS inserted
FROM notifications
WHERE user_id = $1
AND idempotency_key = $8
AND $8 IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM inserted)
LIMIT 1
`,
[
input.userId,
input.eventName,
input.title,
input.body,
input.targetUrl ?? null,
severity,
JSON.stringify(input.data ?? {}),
idempotencyKey,
input.batchKey ?? null,
],
);
const notification = result.rows[0];
if (!notification) {
throw new Error("Notification insert failed");
}
if (notification.inserted) {
for (const channel of ["email", "push"] as const) {
const allowedByPreference = shouldQueueExternal({
channel,
severity,
preferences,
});
if (!allowedByPreference) continue;
const allowedByRateLimit = await consumeRateLimit(
client,
input.userId,
channel,
channel === "email" ? 5 : 20,
);
if (!allowedByRateLimit) continue;
const delayMs = input.batchKey ? preferences.digest_minutes * 60_000 : 0;
await queueDelivery(
client,
notification.id,
channel,
new Date(Date.now() + delayMs),
);
}
}
await client.query("COMMIT");
return notification;
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}
export async function listNotifications(userId: string, unreadOnly = false, limit = 30) {
const result = await pool.query<NotificationRow>(
`
SELECT *
FROM notifications
WHERE user_id = $1
AND ($2::boolean = false OR read_at IS NULL)
ORDER BY created_at DESC
LIMIT $3
`,
[userId, unreadOnly, Math.min(limit, 100)],
);
return result.rows;
}
export async function markNotificationRead(userId: string, notificationId: string) {
const result = await pool.query<NotificationRow>(
`
UPDATE notifications
SET read_at = COALESCE(read_at, now()), updated_at = now()
WHERE user_id = $1 AND id = $2
RETURNING *
`,
[userId, notificationId],
);
return result.rows[0] ?? null;
}
export async function markAllNotificationsRead(userId: string) {
const result = await pool.query(
`
UPDATE notifications
SET read_at = COALESCE(read_at, now()), updated_at = now()
WHERE user_id = $1 AND read_at IS NULL
`,
[userId],
);
return result.rowCount ?? 0;
}
export type DeliveryJob = {
id: string;
notification_id: string;
channel: ExternalChannel;
attempts: number;
max_attempts: number;
};
export async function claimNextDeliveryJob() {
const result = await pool.query<DeliveryJob>(
`
UPDATE notification_delivery_queue
SET status = 'sending', attempts = attempts + 1, locked_at = now(), updated_at = now()
WHERE id = (
SELECT id
FROM notification_delivery_queue
WHERE status = 'pending' AND available_at <= now()
ORDER BY available_at ASC, created_at ASC
FOR UPDATE SKIP LOCKED
LIMIT 1
)
RETURNING id, notification_id, channel, attempts, max_attempts
`,
);
return result.rows[0] ?? null;
}
export async function finishDeliveryJob(job: DeliveryJob, error?: unknown) {
if (!error) {
await pool.query(
"UPDATE notification_delivery_queue SET status = 'sent', updated_at = now() WHERE id = $1",
[job.id],
);
return;
}
const retryDelayMs = Math.min(30 * 60_000, 60_000 * 2 ** job.attempts);
const failedPermanently = job.attempts >= job.max_attempts;
await pool.query(
`
UPDATE notification_delivery_queue
SET status = $2,
available_at = $3,
last_error = $4,
updated_at = now()
WHERE id = $1
`,
[
job.id,
failedPermanently ? "failed" : "pending",
new Date(Date.now() + retryDelayMs),
error instanceof Error ? error.message : String(error),
],
);
}
Perilaku pentingnya: event duplikat mengembalikan notifikasi yang sudah ada dan tidak membuat external job baru.
API Route
Buat app/api/notifications/route.ts. Contoh memakai x-demo-user-id; di produksi ganti dengan auth session.
import { NextRequest, NextResponse } from "next/server";
import {
createNotification,
listNotifications,
markAllNotificationsRead,
markNotificationRead,
} from "@/lib/notification-service";
export const runtime = "nodejs";
function requireDemoUserId(request: NextRequest) {
const userId = request.headers.get("x-demo-user-id");
if (!userId) {
throw new Error("Missing x-demo-user-id header. Replace this with real auth.");
}
return userId;
}
export async function GET(request: NextRequest) {
try {
const userId = requireDemoUserId(request);
const unreadOnly = request.nextUrl.searchParams.get("unread") === "1";
const notifications = await listNotifications(userId, unreadOnly);
return NextResponse.json({ notifications });
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 401 },
);
}
}
export async function POST(request: NextRequest) {
try {
const userId = requireDemoUserId(request);
const body = await request.json();
const notification = await createNotification({
userId,
eventName: String(body.eventName),
title: String(body.title),
body: String(body.body),
targetUrl: body.targetUrl ? String(body.targetUrl) : undefined,
severity: body.severity,
resourceId: body.resourceId ? String(body.resourceId) : undefined,
batchKey: body.batchKey ? String(body.batchKey) : undefined,
data: typeof body.data === "object" && body.data ? body.data : {},
});
return NextResponse.json({ notification }, { status: 201 });
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 400 },
);
}
}
export async function PATCH(request: NextRequest) {
const userId = requireDemoUserId(request);
const body = await request.json().catch(() => ({}));
if (body.notificationId) {
const notification = await markNotificationRead(userId, String(body.notificationId));
return NextResponse.json({ notification });
}
const updated = await markAllNotificationsRead(userId);
return NextResponse.json({ updated });
}
Jangan percaya userId dari body. Notifikasi berisi state pribadi, jadi owner harus datang dari auth dan update harus difilter di SQL.
React notification center
UI tetap menjadikan in-app center sebagai utama. Desktop browser alert hanya diminta setelah klik user.
"use client";
import { useEffect, useMemo, useState } from "react";
type AppNotification = {
id: string;
title: string;
body: string;
target_url: string | null;
severity: "info" | "success" | "warning" | "critical";
read_at: string | null;
created_at: string;
};
const demoHeaders = {
"x-demo-user-id": "demo-user-1",
};
export function NotificationCenter() {
const [items, setItems] = useState<AppNotification[]>([]);
const [open, setOpen] = useState(false);
const [desktopEnabled, setDesktopEnabled] = useState(false);
const unreadCount = useMemo(
() => items.filter((item) => item.read_at === null).length,
[items],
);
async function loadNotifications() {
const response = await fetch("/api/notifications", {
headers: demoHeaders,
cache: "no-store",
});
if (!response.ok) return;
const data = (await response.json()) as { notifications: AppNotification[] };
setItems(data.notifications);
}
async function markRead(notificationId: string) {
await fetch("/api/notifications", {
method: "PATCH",
headers: {
...demoHeaders,
"content-type": "application/json",
},
body: JSON.stringify({ notificationId }),
});
setItems((current) =>
current.map((item) =>
item.id === notificationId
? { ...item, read_at: item.read_at ?? new Date().toISOString() }
: item,
),
);
}
async function markAllRead() {
await fetch("/api/notifications", {
method: "PATCH",
headers: {
...demoHeaders,
"content-type": "application/json",
},
body: JSON.stringify({ all: true }),
});
const now = new Date().toISOString();
setItems((current) => current.map((item) => ({ ...item, read_at: item.read_at ?? now })));
}
async function enableDesktopNotifications() {
if (!("Notification" in window)) return;
const permission = await Notification.requestPermission();
setDesktopEnabled(permission === "granted");
}
useEffect(() => {
loadNotifications();
const timer = window.setInterval(loadNotifications, 30_000);
return () => window.clearInterval(timer);
}, []);
useEffect(() => {
if (!desktopEnabled || document.visibilityState === "visible") return;
const newest = items.find((item) => item.read_at === null);
if (!newest) return;
new Notification(newest.title, {
body: newest.body,
tag: newest.id,
});
}, [desktopEnabled, items]);
return (
<div className="notification-center">
<button type="button" onClick={() => setOpen((value) => !value)}>
Notifications
{unreadCount > 0 ? <span aria-label={`${unreadCount} unread`}>{unreadCount}</span> : null}
</button>
{open ? (
<section aria-label="Notification center">
<div>
<button type="button" onClick={markAllRead} disabled={unreadCount === 0}>
Mark all read
</button>
<button type="button" onClick={enableDesktopNotifications}>
Enable desktop alerts
</button>
</div>
{items.length === 0 ? (
<p>No notifications.</p>
) : (
<ul>
{items.map((item) => (
<li key={item.id} data-unread={item.read_at === null}>
<strong>{item.title}</strong>
<p>{item.body}</p>
<small>{new Date(item.created_at).toLocaleString()}</small>
<div>
{item.target_url ? <a href={item.target_url}>Open</a> : null}
{item.read_at === null ? (
<button type="button" onClick={() => markRead(item.id)}>
Mark read
</button>
) : null}
</div>
</li>
))}
</ul>
)}
</section>
) : null}
</div>
);
}
Polling 30 detik cukup untuk versi pertama. WebSocket atau SSE bisa ditambahkan nanti; stabilitas unread state lebih penting.
Test
notification-service.test.ts mengunci aturan yang mencegah incident.
import { describe, expect, it } from "vitest";
import { makeIdempotencyKey, shouldQueueExternal } from "./notification-service";
describe("notification policy", () => {
it("builds a stable idempotency key from user, event, and resource", () => {
expect(
makeIdempotencyKey({
userId: "user_1",
eventName: "invoice.payment_failed",
resourceId: "invoice_9",
}),
).toBe("user_1:invoice.payment_failed:invoice_9");
});
it("does not build an idempotency key without a resource id", () => {
expect(
makeIdempotencyKey({
userId: "user_1",
eventName: "system.notice",
}),
).toBeNull();
});
it("queues email only when preferences and severity allow it", () => {
expect(
shouldQueueExternal({
channel: "email",
severity: "warning",
preferences: { email_enabled: true, push_enabled: false },
}),
).toBe(true);
expect(
shouldQueueExternal({
channel: "email",
severity: "info",
preferences: { email_enabled: true, push_enabled: false },
}),
).toBe(false);
expect(
shouldQueueExternal({
channel: "push",
severity: "critical",
preferences: { email_enabled: true, push_enabled: false },
}),
).toBe(false);
});
});
Minta Claude Code menambah test untuk webhook duplikat, preference off, rate limit overflow, queue failure, dan percobaan menandai notifikasi milik user lain sebagai read.
Jebakan umum
Jebakan pertama: memasukkan terlalu banyak data privat di body. Email dan push bisa muncul di lock screen, email forward, atau log provider. Gunakan summary pendek dan link yang membutuhkan login.
Jebakan kedua: menghitung unread hanya di browser. Banyak tab dan device akan membuat count berbeda. Server truth harus read_at IS NULL.
Jebakan ketiga: membatasi pembuatan record in-app. Audit penting bisa hilang. Batasi external delivery terlebih dahulu.
Jebakan keempat: job sending tertahan selamanya. Buat monitor yang mengembalikan lock lama ke pending dan menampilkan failure permanen.
CTA dan hasil uji
Notifikasi bisa membantu revenue flow: pengiriman PDF gratis, onboarding setelah pembelian, alert CTA rusak, dan reminder konsultasi. Untuk template yang lebih lengkap, lihat produk ClaudeCodeLab.
Saya menempelkan schema dan service ini ke project Next.js lokal, lalu mengirim invoice.payment_failed dua kali dengan resourceId yang sama. Tabel notifications tetap satu baris dan queue hanya mendapat job pertama. Saat email preference dimatikan, notifikasi in-app tetap ada tanpa job email. Keputusan paling penting adalah menentukan idempotency_key dan read_at sebelum membuat UI.
Ringkasan
Notifikasi produksi adalah masalah konsistensi data sebelum menjadi masalah UI. Jadikan in-app notification sebagai source of truth, kirim email/push lewat queue, dan minta Claude Code menangani unread state, idempotency, batching, preferences, rate limit, privacy, retry, dan tests sejak prompt pertama.
PDF gratis: cheatsheet Claude Code
Masukkan email dan unduh satu halaman berisi command, kebiasaan review, dan workflow aman.
Kami menjaga datamu dan tidak mengirim spam.
Tentang penulis
Masa
Engineer yang berfokus pada workflow Claude Code praktis dan adopsi tim.
Artikel terkait
Permission receipt Claude Code: mencatat scope, bukti, dan rollback
Pola permission receipt untuk Claude Code: aksi yang diizinkan, batas approval, command verifikasi, rollback, dan cek CTA revenue.
Agent Harness Aman untuk Claude Code dan Codex: Permission, Verifikasi, dan Rollback
Rancang Agent Harness praktis untuk Claude Code dan Codex dengan policy, plan, verification, dan recovery layer.
Subagent Claude Code: panduan praktis untuk delegasi artikel dan kode
Panduan subagent Claude Code untuk membagi pekerjaan artikel dan kode: aturan delegasi, prompt, risiko, dan checklist.