/**
 * Per-user notifications driven by collaboration events.
 *
 * Owns the pure notification logic: kind discriminator, payload validation,
 * permission gates, and a storage-agnostic CRUD surface. The HTTP layer
 * (in `api/handlers.ts`) is a thin wrapper that handles auth + JSON
 * envelope; the comments hook layer (slice 6, `notification-triggers.ts`)
 * calls these helpers when a comment lifecycle event fires.
 *
 * Storage shape — see `packages/cms-core/prisma/cms-schema.prisma`, model
 * `DocumentNotification`. We require only a minimal Prisma surface so the
 * helpers can be tested against an in-memory fake.
 *
 * ## Discriminated payloads
 *
 * The `payload` Json column varies by `kind`. The TypeScript types here
 * are the source of truth for each variant; the service helpers narrow
 * by `kind` so callers can't accidentally insert a malformed payload.
 *
 * ## Mentions
 *
 * Slice 6 ships a deliberately simple mention parser: `@<userId>` tokens
 * in a comment body trigger a `comment_mention` notification. A richer
 * `@username` parser that resolves against the user table is a Phase 4
 * follow-up — the storage shape already supports it.
 */
/** Hard cap on returned notifications per `listForUser` call. */
export const MAX_NOTIFICATION_LIST_PAGE_SIZE = 200;
/** Default page size when the caller does not request one. */
export const DEFAULT_NOTIFICATION_LIST_PAGE_SIZE = 50;
export function toNotificationDTO(row) {
    // The payload comes back as `unknown` from Prisma's Json column; we
    // re-tag it with the `kind` discriminator so the DTO is strictly typed
    // on the way out. Storage-level validation in `createNotification`
    // guarantees the payload matches the kind.
    const payload = {
        kind: row.kind,
        ...(row.payload ?? {}),
    };
    return {
        id: row.id,
        userId: row.userId,
        kind: row.kind,
        documentId: row.documentId,
        commentId: row.commentId,
        payload,
        readAt: row.readAt ? row.readAt.toISOString() : null,
        createdAt: row.createdAt.toISOString(),
    };
}
const KIND_GUARDS = {
    comment_reply: true,
    comment_resolved: true,
    comment_mention: true,
};
export function isNotificationKind(value) {
    return typeof value === 'string' && Object.prototype.hasOwnProperty.call(KIND_GUARDS, value);
}
function validatePayload(payload) {
    if (!payload || typeof payload !== 'object') {
        return { code: 'validation', message: 'payload must be an object' };
    }
    if (!isNotificationKind(payload.kind)) {
        return { code: 'validation', message: `unknown kind "${String(payload.kind)}"` };
    }
    if (payload.kind === 'comment_reply' || payload.kind === 'comment_mention') {
        if (typeof payload.excerpt !== 'string') {
            return { code: 'validation', message: 'excerpt is required' };
        }
    }
    if (payload.kind === 'comment_resolved') {
        if (typeof payload.rootExcerpt !== 'string') {
            return { code: 'validation', message: 'rootExcerpt is required' };
        }
    }
    return null;
}
/**
 * Insert a new notification. The payload's `kind` field is the source
 * of truth; `kind` on the DB row is derived from it so callers can't
 * drift them apart.
 */
export async function createNotification(db, input) {
    if (!input.userId || typeof input.userId !== 'string') {
        return { ok: false, error: { code: 'validation', message: 'userId is required' } };
    }
    const validationError = validatePayload(input.payload);
    if (validationError)
        return { ok: false, error: validationError };
    // Strip the kind tag from the stored JSON — it's already in the
    // dedicated column.
    const { kind, ...rest } = input.payload;
    const row = await db.documentNotification.create({
        data: {
            userId: input.userId,
            kind,
            documentId: input.documentId ?? null,
            commentId: input.commentId ?? null,
            payload: rest,
            readAt: null,
        },
    });
    return { ok: true, value: toNotificationDTO(row) };
}
/** Resolve a notification by id, only if it belongs to `userId`. */
async function loadOwned(db, id, userId) {
    const row = await db.documentNotification.findUnique({ where: { id } });
    if (!row)
        return { ok: false, error: { code: 'not_found', message: 'Notification not found' } };
    if (row.userId !== userId) {
        return { ok: false, error: { code: 'forbidden', message: 'Not your notification' } };
    }
    return { ok: true, value: row };
}
/**
 * List notifications for a user, newest first. By default returns only
 * unread; pass `includeRead: true` for the full feed (the bell dropdown
 * uses both depending on the tab the user is on).
 */
export async function listForUser(db, filter) {
    if (!filter.userId) {
        return { ok: false, error: { code: 'validation', message: 'userId is required' } };
    }
    const take = clampPageSize(filter.pageSize);
    const where = { userId: filter.userId };
    if (!filter.includeRead)
        where.readAt = null;
    const rows = await db.documentNotification.findMany({
        where,
        orderBy: { createdAt: 'desc' },
        take,
    });
    return { ok: true, value: rows.map(toNotificationDTO) };
}
/** Count unread notifications for the bell badge. */
export async function unreadCount(db, userId) {
    if (!userId) {
        return { ok: false, error: { code: 'validation', message: 'userId is required' } };
    }
    const count = await db.documentNotification.count({
        where: { userId, readAt: null },
    });
    return { ok: true, value: count };
}
/** Mark a single notification read (idempotent). */
export async function markRead(db, id, actor) {
    if (!id) {
        return { ok: false, error: { code: 'validation', message: 'id is required' } };
    }
    const loaded = await loadOwned(db, id, actor.userId);
    if (!loaded.ok)
        return loaded;
    if (loaded.value.readAt) {
        return { ok: true, value: toNotificationDTO(loaded.value) };
    }
    const updated = await db.documentNotification.update({
        where: { id },
        data: { readAt: new Date() },
    });
    return { ok: true, value: toNotificationDTO(updated) };
}
/** Mark every unread notification for the user as read. */
export async function markAllRead(db, userId) {
    if (!userId) {
        return { ok: false, error: { code: 'validation', message: 'userId is required' } };
    }
    const out = await db.documentNotification.updateMany({
        where: { userId, readAt: null },
        data: { readAt: new Date() },
    });
    return { ok: true, value: { count: out.count } };
}
function clampPageSize(requested) {
    if (typeof requested !== 'number' || !Number.isFinite(requested) || requested <= 0) {
        return DEFAULT_NOTIFICATION_LIST_PAGE_SIZE;
    }
    return Math.min(Math.max(1, Math.floor(requested)), MAX_NOTIFICATION_LIST_PAGE_SIZE);
}
/**
 * Cheap excerpt builder for notification payloads. Markdown is kept as-is;
 * we just trim whitespace and clip to 120 chars so the bell can render
 * a single-line preview.
 */
export function excerptForNotification(body, max = 120) {
    const cleaned = body.replace(/\s+/g, ' ').trim();
    if (cleaned.length <= max)
        return cleaned;
    return `${cleaned.slice(0, max - 1)}…`;
}
/**
 * Extract `@<userId>` mentions from a comment body. We intentionally
 * match the *user id* form here (`@user_abc123`) — `@username`
 * resolution against the User table is a separate slice that can
 * land later on top of this same wire format.
 *
 * Returns a deduplicated array of mentioned user ids; the caller filters
 * out the actor before issuing notifications.
 *
 * Lock-step contract with the admin UI
 * ────────────────────────────────────
 * The mention picker in `cms-admin/src/lib/mention-picker.ts`
 * (`detectMentionContext`) must agree with this regex on what counts
 * as a mention, or the picker's intuition ("this isn't a mention so
 * I won't show a dropdown") will diverge from the server's
 * notification routing ("this IS a mention, sending one"). Two
 * properties must hold:
 *
 *   1. The `@` is at the start of the body OR preceded by whitespace
 *      / newline (so `foo@bar` is an email, NOT a mention — picker
 *      review on PR #159).
 *   2. The token charset is `[a-zA-Z0-9_-]` with a 3–64 char length.
 *      The picker enforces the upper bound; the lower bound exists
 *      to keep accidental `@` typos (`@a`, `@an`) from generating
 *      notifications.
 */
const MENTION_REGEX = /(^|[\s\n\r\t])@([a-zA-Z0-9_-]{3,64})\b/g;
export function extractMentionedUserIds(body) {
    if (typeof body !== 'string' || body.length === 0)
        return [];
    const seen = new Set();
    // `lastIndex` rewinding — we use a capturing group for the
    // boundary which advances past it, but the next `@` could
    // immediately follow. The default global regex advance handles
    // this correctly because `lastIndex` lands AFTER the full match,
    // and the boundary character is consumed exactly once.
    let match;
    const re = new RegExp(MENTION_REGEX.source, 'g');
    while ((match = re.exec(body)) !== null) {
        seen.add(match[2]);
    }
    return Array.from(seen);
}
//# sourceMappingURL=notifications.js.map