Sistema de notificaciones en produccion con Claude Code
Crea notificaciones con Claude Code: DB, API, React, no leidos, idempotencia, reintentos y pruebas.
Un sistema de notificaciones no es solo un icono de campana. Los fallos reales aparecen con envios duplicados, contadores de no leidos incorrectos, demasiados emails, reintentos perdidos y datos privados en mensajes externos. Esta guia muestra como pedirle a Claude Code una base de produccion: modelo de datos, API, servicio, centro de notificaciones en React y pruebas.
La frontera importante es simple: la notificacion dentro de la app es la fuente de verdad. Email y push son canales externos que se alimentan desde una cola. Primero se guarda el registro, despues se decide si se envia fuera segun preferencias, severidad, lotes, limite de frecuencia y privacidad.
Para contrastar detalles usa la documentacion oficial: MDN Notifications API, Next.js Route Handlers y PostgreSQL CREATE TABLE. Si conectas email, revisa una guia oficial como Resend con Next.js, pero conecta el proveedor desde un worker de cola, no desde el request. La base de Claude Code esta en la documentacion oficial.
Limites del sistema
Divide el sistema en cuatro capas.
| Capa | Responsabilidad | Fallo habitual |
|---|---|---|
| Evento | Pago fallido, comentario, job terminado | El mismo webhook llega dos veces |
| Servicio | Guardar, deduplicar, no leidos, preferencias | Reglas repartidas en el UI |
| Cola | Reintentos de email y push | Un fallo externo pierde el aviso |
| UI | Centro, badge y alerta del navegador | Conteo distinto entre pestanas y dispositivos |
No empieces por push. Las notificaciones del navegador requieren permiso y contexto seguro; en movil muchas veces necesitas Service Worker. Email tiene costes, bajas, quejas y entregabilidad. Por eso conviene guardar primero la notificacion interna y tratar email/push como efectos secundarios controlados.
Prompt recomendado para 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 la misma operacion no crea resultados extra. En notificaciones, el mismo webhook de pago debe crear una sola notificacion. Batching significa agrupar eventos ruidosos, por ejemplo diez comentarios en un resumen. Retry queue es una cola durable que permite reintentar envios externos tras un fallo.
Casos de uso
Primer caso: alertas operativas de un SaaS. Fallos de cobro, sincronizacion o jobs pueden necesitar aviso interno y email. Si un job falla cien veces en un minuto, no envies cien correos. Usa entrega inmediata para critical y resumen para el resto.
Segundo caso: colaboracion de equipo. Menciones, comentarios y solicitudes de revision necesitan estado de no leido fiable. El usuario espera marcar una notificacion o todas como leidas. En email o push no incluyas el comentario completo; usa un resumen y una URL autenticada.
Tercer caso: operaciones de contenido e ingresos. En ClaudeCodeLab, una notificacion puede avisar de checklist de publicacion, CTA roto, entrega de PDF gratuito, onboarding tras compra o recordatorio de consulta. Las notificaciones deben ayudar a la conversion sin crear ruido. Puedes conectar este tema con tips de productividad de Claude Code y validacion de formularios con Claude Code.
Cuarto caso: auditoria administrativa. Cambios de permisos, creacion de API keys y exportaciones de datos deben poder revisarse despues. El cuerpo debe evitar datos sensibles y apuntar a un registro autenticado.
Esquema de base de datos
Este schema de PostgreSQL separa la fuente interna, la cola, las preferencias y el limite de frecuencia.
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)
);
Usa read_at como unica verdad del estado leido. Si tambien guardas is_read, tarde o temprano ambos campos se contradicen. Para borrado, suele ser mejor archived_at que eliminar fisicamente.
Servicio de notificaciones
Instala dependencias:
npm install pg
npm install -D @types/pg vitest
El archivo lib/notification-service.ts guarda, deduplica, aplica preferencias, limita canales externos y maneja la cola.
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),
],
);
}
Lo esencial es que un evento duplicado devuelve la notificacion existente y no crea nuevos trabajos externos.
API Route
Crea app/api/notifications/route.ts. El ejemplo usa x-demo-user-id para poder probarlo; en produccion debes reemplazarlo por sesion 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 });
}
No confies en userId enviado por el body. Las notificaciones contienen estado privado y cada update debe filtrar por propietario.
Centro de notificaciones React
El UI mantiene el centro interno como principal. La alerta del navegador solo se activa si el usuario da permiso con un click.
"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 cada 30 segundos es suficiente para la primera version. WebSocket o SSE pueden esperar; lo primero es que el estado leido sea correcto.
Pruebas
notification-service.test.ts fija las reglas que evitan 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);
});
});
Pidele a Claude Code pruebas de webhook duplicado, preferencias apagadas, exceso de limite, fallo de cola y ataque para marcar como leida una notificacion de otro usuario.
Errores comunes
El primer error es poner datos privados en el cuerpo. Email y push pueden aparecer en la pantalla bloqueada, en correos reenviados y en logs del proveedor. Usa resumen corto y enlace autenticado.
El segundo error es calcular no leidos solo en el navegador. Varias pestanas y dispositivos se desincronizan rapido. La verdad debe ser read_at IS NULL.
El tercer error es limitar la creacion del registro interno. Puedes perder auditoria. Limita la entrega externa y conserva el registro valido.
El cuarto error es dejar jobs en sending para siempre. Agrega un monitor que devuelva trabajos bloqueados antiguos a pending y muestre fallos permanentes.
CTA y resultado practico
Las notificaciones tambien apoyan ingresos: entrega de PDF gratuito, onboarding tras compra, aviso de CTA roto y recordatorios de consulta. Para plantillas mas completas revisa productos de ClaudeCodeLab.
Probe el schema y el servicio en un proyecto local de Next.js. Envie dos veces invoice.payment_failed con el mismo resourceId: notifications quedo con una fila y la cola solo tuvo el primer job. Con email desactivado, quedo el aviso interno sin trabajo de email. Definir idempotency_key y read_at antes del UI fue la decision mas importante.
Resumen
Una notificacion de produccion es primero consistencia de datos y despues interfaz. Usa la notificacion interna como fuente de verdad, manda email/push por cola y exige no leidos, idempotencia, batching, preferencias, rate limit, privacidad, reintentos y pruebas desde el primer prompt a Claude Code.
PDF gratis: cheatsheet de Claude Code
Introduce tu email y descarga una hoja con comandos, hábitos de revisión y flujos seguros.
Cuidamos tus datos y no enviamos spam.
Sobre el autor
Masa
Ingeniero enfocado en workflows prácticos con Claude Code.
Artículos relacionados
Permission receipt para Claude Code: alcance, prueba y rollback
Patrón de permission receipt para Claude Code: acciones permitidas, aprobación, pruebas, rollback y CTA de ingresos.
Agent Harness seguro para Claude Code y Codex: permisos, verificacion y rollback
Diseña un Agent Harness seguro para Claude Code y Codex con permisos, plan, verificaciones y rollback.
Subagentes de Claude Code: guía práctica para delegar trabajo de forma segura
Guía práctica de subagentes en Claude Code para dividir artículos y código: reglas, prompts, riesgos y checklist.