Sistema de notificacoes em producao com Claude Code
Crie notificacoes com Claude Code: DB, API, React, nao lidas, idempotencia, retry queue e testes.
Um sistema de notificacoes nao e apenas um icone de sino. Em producao, os problemas reais sao envio duplicado, contador de nao lidas errado, excesso de emails, retries perdidos e dados privados em mensagens externas. Este guia mostra como orientar o Claude Code para gerar uma base operavel: schema de banco, API Next.js, servico, notification center em React e testes.
A regra principal e: notificacoes dentro do app sao a fonte da verdade. Email e push sao canais externos alimentados por uma fila de entrega. Primeiro salvamos a notificacao; depois preferencias, severidade, batching, rate limit e privacidade decidem se um job externo deve ser criado.
Use documentacao oficial para conferir os pontos criticos: MDN Notifications API, Next.js Route Handlers e PostgreSQL CREATE TABLE. Se conectar email, siga uma fonte oficial como Resend com Next.js, mas envie a partir de um worker da fila. A base do Claude Code esta na documentacao oficial.
Limite do sistema
Separe o sistema em quatro camadas.
| Camada | Responsabilidade | Falha comum |
|---|---|---|
| Evento | Pagamento falhou, comentario, job finalizado | O mesmo webhook chega duas vezes |
| Servico | Salvar, deduplicar, nao lidas, preferencias | Regras ficam espalhadas no UI |
| Fila | Retries de email e push | Falha externa perde a entrega |
| UI | Centro, badge, alerta do navegador | Contador diverge entre abas |
Nao comece pelo push. Notificacoes do navegador exigem permissao e contexto seguro; em mobile pode ser necessario Service Worker. Email tem custo, descadastro, reclamacoes e entregabilidade. Primeiro garanta o registro interno e trate email/push como efeitos colaterais controlados.
Prompt para o 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.
Idempotencia significa que repetir a mesma operacao nao cria resultados extras. O mesmo webhook de pagamento deve gerar uma unica notificacao. Batching agrupa eventos ruidosos, como dez comentarios em um digest. Retry queue guarda envios externos com falha para tentar novamente.
Casos de uso
Primeiro caso: alertas operacionais de SaaS. Falha de cobranca, sync quebrado e job com erro podem exigir notificacao interna e email. Se um job falha cem vezes por minuto, nao envie cem emails. Use entrega imediata para critical e resumo para o restante.
Segundo caso: colaboracao de equipe. Mentions, comentarios e pedidos de review precisam de estado de nao lida confiavel. O usuario espera marcar uma ou todas como lidas. Em email e push, envie resumo e URL autenticada, nao o conteudo completo.
Terceiro caso: operacao de conteudo e receita. Em um site como ClaudeCodeLab, notificacoes podem cobrir checklist de publicacao, CTA quebrado, entrega de PDF gratuito, onboarding apos compra e lembrete de consultoria. Para continuar, leia dicas de produtividade Claude Code e validacao de formularios com Claude Code.
Quarto caso: auditoria administrativa. Mudanca de permissao, criacao de API key e exportacao de dados devem ser rastreaveis. O corpo deve evitar segredos e apontar para uma pagina autenticada.
Schema do banco
Este schema PostgreSQL separa fonte interna, fila, preferencias e 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)
);
Use apenas read_at para o estado lido. Manter tambem is_read cria divergencia. Para remocao pelo usuario, prefira archived_at a delete fisico.
Servico de notificacoes
Instale os pacotes:
npm install pg
npm install -D @types/pg vitest
lib/notification-service.ts centraliza persistencia, deduplicacao, preferencias, rate limit e 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),
],
);
}
O comportamento essencial: evento duplicado retorna a notificacao existente e nao cria novo job externo.
API Route
Crie app/api/notifications/route.ts. O exemplo usa x-demo-user-id; em producao substitua por auth real.
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 });
}
Nunca confie em userId vindo do body. O dono da notificacao deve vir da camada de autenticacao, e o SQL deve filtrar por ele.
Notification Center em React
O UI usa notificacoes internas como principal. Alertas do navegador so sao ativados apos clique do usuario.
"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 de 30 segundos basta para a primeira versao. WebSocket ou SSE podem vir depois; primeiro garanta o estado de leitura.
Testes
notification-service.test.ts fixa regras que evitam incidentes.
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);
});
});
Peça ao Claude Code para adicionar testes de webhook duplicado, preferencias desligadas, limite excedido, falha de fila e tentativa de marcar notificacao de outro usuario como lida.
Armadilhas
Primeira armadilha: colocar dados privados no corpo. Email e push podem aparecer em tela bloqueada, emails encaminhados e logs do fornecedor. Use resumo curto e link autenticado.
Segunda: calcular nao lidas so no navegador. Abas e dispositivos se desencontram. A verdade deve ser read_at IS NULL.
Terceira: aplicar rate limit antes de criar o registro interno. Voce pode perder auditoria. Limite a entrega externa primeiro.
Quarta: deixar jobs em sending para sempre. Um monitor deve retornar locks antigos para pending e mostrar falhas permanentes.
CTA e resultado pratico
Notificacoes ajudam receita quando guiam entrega de PDF gratuito, onboarding apos compra, CTA quebrado e lembretes de consultoria. Para modelos completos, veja produtos ClaudeCodeLab.
Testei o schema e o servico em um projeto Next.js local. Dois POSTs invoice.payment_failed com o mesmo resourceId deixaram notifications com uma linha e criaram apenas o primeiro job de fila. Com email desativado, ficou apenas a notificacao interna. Definir idempotency_key e read_at antes do UI foi a decisao mais importante.
Resumo
Notificacoes de producao sao primeiro consistencia de dados e depois interface. Use in-app como fonte da verdade, envie email/push por fila e peca ao Claude Code unread state, idempotencia, batching, preferencias, rate limit, privacidade, retries e testes desde o primeiro prompt.
PDF grátis: cheatsheet do Claude Code
Informe seu e-mail e baixe uma página com comandos, hábitos de revisão e workflows seguros.
Cuidamos dos seus dados e não enviamos spam.
Sobre o autor
Masa
Engenheiro focado em workflows práticos com Claude Code.
Artigos relacionados
Permission receipt no Claude Code: escopo, prova e rollback
Padrão de permission receipt para Claude Code: ações permitidas, limites de aprovação, comandos de prova, rollback e CTA de receita.
Agent Harness seguro para Claude Code e Codex: permissoes, verificacao e rollback
Monte uma base segura para agentes com Claude Code e Codex usando politicas, plano, verificacao e recuperacao.
Subagentes no Claude Code: guia prático para delegar trabalho com segurança
Guia prático de subagentes no Claude Code para dividir artigos e código: regras, prompts, riscos e checklist.