Advanced (Mis à jour: 02/06/2026)

Système de notifications de production avec Claude Code

Concevez des notifications avec Claude Code: DB, API, React, non lus, idempotence, retries et tests.

Système de notifications de production avec Claude Code

Un système de notifications n’est pas seulement une icône de cloche. En production, les vrais problèmes sont les envois en double, les compteurs non lus faux, les emails trop nombreux, les retries perdus et les données privées exposées. Ce guide montre comment cadrer Claude Code pour générer une base exploitable: schéma DB, API Next.js, service de notification, centre React et tests.

La règle de conception est simple: la notification dans l’application est la source de vérité. Email et push sont des canaux externes alimentés par une file de livraison. On enregistre d’abord la notification, puis on décide si un canal externe est autorisé selon les préférences, la sévérité, le batching, le rate limit et la confidentialité.

Pour vérifier les détails, consultez les sources officielles: MDN Notifications API, Next.js Route Handlers et PostgreSQL CREATE TABLE. Si vous ajoutez l’email, suivez une documentation fournisseur comme Resend avec Next.js, mais connectez-la depuis un worker de queue. Les bases de Claude Code sont dans la documentation officielle.

Frontières du système

Découpez les notifications en quatre couches.

CoucheRôleBug fréquent
ÉvénementPaiement échoué, commentaire, job terminéLe même webhook arrive plusieurs fois
ServiceSauvegarde, déduplication, non lus, préférencesLes règles se retrouvent dans le UI
QueueRetries email et pushUne panne externe fait perdre l’envoi
UICentre, badge, alertes navigateurLe compteur diverge entre onglets

Ne commencez pas par le push. Les notifications navigateur demandent une permission et un contexte sécurisé; sur mobile, un Service Worker est souvent nécessaire. L’email ajoute coûts, désinscription, plaintes et délivrabilité. Il vaut mieux garder une source interne fiable puis traiter email/push comme effets secondaires.

Prompt utile pour 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.

L’idempotence signifie qu’une même opération répétée ne crée pas de résultat supplémentaire. Pour une notification, deux webhooks identiques de paiement échoué ne doivent produire qu’une ligne. Le batching regroupe des événements proches, par exemple dix commentaires en un digest. Une retry queue garde les envois externes en échec pour les relancer.

Cas d’utilisation

Premier cas: alertes d’exploitation SaaS. Échec de facturation, synchronisation cassée ou job en erreur peuvent exiger notification interne et email. Si un job échoue cent fois par minute, l’email doit être limité. Envoyez immédiatement seulement critical, et regroupez le reste.

Deuxième cas: collaboration d’équipe. Mentions, commentaires et demandes de review dépendent d’un état non lu exact. Les utilisateurs veulent marquer une notification ou toutes les notifications comme lues. Dans email et push, mettez un résumé et une URL authentifiée, pas le contenu complet.

Troisième cas: contenu et revenus. Pour un site comme ClaudeCodeLab, les notifications servent aux checks de publication, aux CTA cassés, à la livraison d’un PDF gratuit, à l’onboarding après achat et aux rappels de consultation. Elles doivent soutenir la conversion sans devenir du bruit. Pour continuer, lisez aussi productivité Claude Code et validation de formulaire avec Claude Code.

Quatrième cas: audit admin. Changement de droits, création de clé API et export de données doivent rester traçables. Le corps de notification ne doit pas contenir de secrets; il doit pointer vers une page authentifiée.

Schéma de base de données

Ce schéma PostgreSQL sépare la source interne, la queue, les préférences et le 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)
);

Utilisez read_at comme unique état de lecture. Ajouter is_read crée une incohérence future. Pour supprimer côté utilisateur, préférez archived_at à la suppression physique.

Service de notification

Installez les dépendances:

npm install pg
npm install -D @types/pg vitest

Le fichier lib/notification-service.ts centralise sauvegarde, déduplication, préférences, limitation et 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),
    ],
  );
}

La propriété importante: un événement dupliqué retourne la notification existante et ne crée pas de nouveau job externe.

Route API

Créez app/api/notifications/route.ts. L’exemple utilise x-demo-user-id; remplacez-le par l’auth réelle en production.

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 });
}

Ne faites jamais confiance à userId dans le body. L’identité doit venir de l’auth, et chaque update doit filtrer par propriétaire.

Centre React

Le UI garde les notifications internes au centre. Les alertes navigateur sont optionnelles et demandées après un clic utilisateur.

"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>
  );
}

Un polling de 30 secondes suffit au début. WebSocket ou SSE peuvent venir plus tard; l’état lu doit d’abord être fiable.

Tests

Créez notification-service.test.ts pour fixer les règles à risque.

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);
  });
});

Demandez aussi à Claude Code des tests pour webhook dupliqué, préférences désactivées, dépassement de rate limit, retry en échec et tentative de lire la notification d’un autre utilisateur.

Pièges

Premier piège: mettre trop de données privées dans le message. Email et push peuvent apparaître sur écran verrouillé, email transféré ou logs fournisseur. Gardez un résumé et un lien authentifié.

Deuxième piège: calculer les non lus uniquement côté navigateur. Plusieurs onglets et appareils divergent vite. La vérité doit rester read_at IS NULL côté serveur.

Troisième piège: limiter la création de la notification interne. Vous risquez de perdre des traces d’audit. Limitez d’abord la livraison externe.

Quatrième piège: laisser des jobs sending bloqués. Ajoutez un monitor qui repasse les anciens locks en pending et affiche les échecs permanents.

CTA et résultat

Les notifications peuvent soutenir le revenu: livraison de PDF gratuit, onboarding après achat, alerte de CTA cassé et rappel de consultation. Pour des modèles plus complets, consultez les produits ClaudeCodeLab.

J’ai collé ce schéma et le service dans un projet Next.js local. Deux POST invoice.payment_failed avec le même resourceId ont donné une seule ligne notifications et un seul job de queue. Avec email désactivé, la notification interne restait sans job email. Le choix décisif a été de fixer idempotency_key et read_at avant le UI.

Résumé

Une notification de production est d’abord un problème de cohérence de données. Gardez l’in-app comme source de vérité, envoyez email/push via queue, et exigez non lus, idempotence, batching, préférences, rate limit, confidentialité, retries et tests dans le prompt initial à Claude Code.

#Claude Code #notifications #Next.js #PostgreSQL #React
Gratuit

PDF gratuit: cheatsheet Claude Code

Saisissez votre email et téléchargez une page avec commandes, habitudes de review et workflow sûr.

Nous protégeons vos données et n'envoyons pas de spam.

Masa

À propos de l'auteur

Masa

Ingénieur spécialisé dans les workflows pratiques avec Claude Code.