/**
 * Forms entries service — submissions inbox.
 *
 * Submissions live in the dedicated `FormSubmission` table. This module owns
 * querying/filtering, the read/star/archive workflow, per-form + aggregate
 * counts, and rendering an entry against the schema version it was captured
 * with (so historical entries never break when a form's live schema changes).
 */
import { getDB } from '../db.js';
import { logEvent } from '../security/audit.js';
import { getSchemaVersion } from './service.js';
import { dispatchFormWebhooks } from './webhooks.js';
import { decryptSubmissionData } from './encryption.js';
function rawDb() {
    return getDB();
}
function toISO(value) {
    if (value instanceof Date)
        return value.toISOString();
    if (typeof value === 'string')
        return value;
    return new Date().toISOString();
}
function toISONullable(value) {
    if (!value)
        return null;
    return toISO(value);
}
function asRecord(value) {
    return value && typeof value === 'object' && !Array.isArray(value)
        ? value
        : {};
}
// ─── Normalization ─────────────────────────────────────────────────────
function parseFile(row) {
    return {
        id: String(row.id),
        submissionId: String(row.submissionId),
        fieldKey: String(row.fieldKey),
        mediaAssetId: row.mediaAssetId ?? null,
        fileName: String(row.fileName),
        mimeType: String(row.mimeType),
        sizeBytes: Number(row.sizeBytes),
        storageKey: String(row.storageKey),
        publicUrl: row.publicUrl ?? null,
        secureDownloadUrl: row.secureDownloadUrl ?? null,
        createdAt: toISO(row.createdAt),
    };
}
/**
 * Convert a raw submission row into the typed shape. Prefers the persisted
 * sender columns (set by the new pipeline) and falls back to deriving them
 * from `data` so legacy/seed rows still display a name/email/preview.
 */
export function normalizeEntry(row, formMeta) {
    const data = asRecord(row.data);
    const senderName = row.senderName ?? deriveName(data);
    const senderEmail = row.senderEmail ?? deriveEmail(data);
    const senderPhone = row.senderPhone ?? derivePhone(data);
    const preview = row.preview ?? derivePreview(data);
    return {
        id: String(row.id),
        entryNumber: row.entryNumber ?? null,
        formId: String(row.formId),
        formName: formMeta?.name,
        formSlug: formMeta?.slug,
        schemaVersionId: row.schemaVersionId ?? null,
        data,
        normalizedData: row.normalizedData ? asRecord(row.normalizedData) : null,
        senderName: senderName || null,
        senderEmail: senderEmail || null,
        senderPhone: senderPhone || null,
        preview: preview || null,
        unread: Boolean(row.unread),
        starred: Boolean(row.starred),
        archived: Boolean(row.archived),
        spamStatus: (row.spamStatus ?? 'clean'),
        ipAddressHash: row.ipAddressHash ?? null,
        userAgent: row.userAgent ?? null,
        referrer: row.referrer ?? null,
        pageUrl: row.pageUrl ?? null,
        utmSource: row.utmSource ?? null,
        utmMedium: row.utmMedium ?? null,
        utmCampaign: row.utmCampaign ?? null,
        submittedAt: toISO(row.submittedAt),
        readAt: toISONullable(row.readAt),
        archivedAt: toISONullable(row.archivedAt),
        createdAt: toISO(row.createdAt),
        updatedAt: toISO(row.updatedAt),
        files: Array.isArray(row.files) ? row.files.map(parseFile) : undefined,
    };
}
/**
 * Decrypt any field values that were stored encrypted at rest (PII). Applied on
 * every read path so callers always see plaintext; safe on plaintext rows.
 */
async function decryptEntry(entry) {
    const data = (await decryptSubmissionData(entry.data)) ?? entry.data;
    const normalizedData = entry.normalizedData
        ? ((await decryptSubmissionData(entry.normalizedData)) ?? entry.normalizedData)
        : entry.normalizedData;
    return { ...entry, data, normalizedData };
}
function deriveName(data) {
    for (const key of ['name', 'fullName', 'full_name', 'first_name', 'firstName']) {
        const v = data[key];
        if (typeof v === 'string' && v.trim())
            return v.trim();
    }
    return '';
}
function deriveEmail(data) {
    for (const key of ['email', 'emailAddress', 'email_address']) {
        const v = data[key];
        if (typeof v === 'string' && v.trim())
            return v.trim();
    }
    return '';
}
function derivePhone(data) {
    for (const key of ['phone', 'phoneNumber', 'phone_number', 'tel']) {
        const v = data[key];
        if (typeof v === 'string' && v.trim())
            return v.trim();
    }
    return '';
}
function derivePreview(data) {
    for (const key of ['message', 'body', 'comments', 'notes', 'description']) {
        const v = data[key];
        if (typeof v === 'string' && v.trim())
            return v.trim().slice(0, 200);
    }
    // Fall back to the first non-empty string value.
    for (const v of Object.values(data)) {
        if (typeof v === 'string' && v.trim())
            return v.trim().slice(0, 200);
    }
    return '';
}
// ─── Where builder ─────────────────────────────────────────────────────
function startOfWeek() {
    const d = new Date();
    d.setHours(0, 0, 0, 0);
    d.setDate(d.getDate() - 6);
    return d;
}
function buildWhere(params) {
    const where = {};
    if (params.formId)
        where.formId = params.formId;
    // Archived: excluded by default; only included when explicitly requested.
    where.archived = params.archived === true ? true : false;
    // Spam: hidden by default; a specific spamStatus filter overrides.
    if (params.spamStatus)
        where.spamStatus = params.spamStatus;
    else
        where.spamStatus = { not: 'spam' };
    if (params.unread === true)
        where.unread = true;
    if (params.starred === true)
        where.starred = true;
    const submittedAt = {};
    if (params.thisWeek)
        submittedAt.gte = startOfWeek();
    if (params.dateFrom)
        submittedAt.gte = new Date(params.dateFrom);
    if (params.dateTo)
        submittedAt.lte = new Date(params.dateTo);
    if (Object.keys(submittedAt).length > 0)
        where.submittedAt = submittedAt;
    if (params.search?.trim()) {
        const q = params.search.trim();
        where.OR = [
            { senderName: { contains: q, mode: 'insensitive' } },
            { senderEmail: { contains: q, mode: 'insensitive' } },
            { senderPhone: { contains: q, mode: 'insensitive' } },
            { preview: { contains: q, mode: 'insensitive' } },
            { id: q },
        ];
    }
    return where;
}
async function attachFormMeta(rows) {
    const ids = [...new Set(rows.map((r) => r.formId))];
    const map = new Map();
    if (ids.length === 0)
        return map;
    const forms = await rawDb().document.findMany({
        where: { id: { in: ids }, collection: 'forms' },
        select: { id: true, data: true, title: true, slug: true },
    });
    for (const f of forms) {
        const data = asRecord(f.data);
        map.set(f.id, {
            name: String(data.name ?? f.title ?? 'Untitled form'),
            slug: String(data.slug ?? f.slug ?? ''),
        });
    }
    return map;
}
export async function listEntries(params = {}) {
    const db = rawDb();
    const where = buildWhere(params);
    const page = Math.max(1, params.page ?? 1);
    const pageSize = Math.min(100, Math.max(1, params.pageSize ?? 25));
    const sortBy = params.sortBy === 'submittedAt' || !params.sortBy ? 'submittedAt' : params.sortBy;
    const sortDirection = params.sortDirection === 'asc' ? 'asc' : 'desc';
    const [rows, total] = await Promise.all([
        db.formSubmission.findMany({
            where,
            orderBy: { [sortBy]: sortDirection },
            skip: (page - 1) * pageSize,
            take: pageSize,
        }),
        db.formSubmission.count({ where }),
    ]);
    const meta = await attachFormMeta(rows);
    return {
        entries: await Promise.all(rows.map((r) => decryptEntry(normalizeEntry(r, meta.get(r.formId))))),
        total,
        page,
        pageSize,
    };
}
export async function getEntryById(id) {
    const db = rawDb();
    const row = await db.formSubmission.findUnique({
        where: { id },
        include: { files: true },
    });
    if (!row)
        return null;
    const meta = await attachFormMeta([row]);
    return decryptEntry(normalizeEntry(row, meta.get(row.formId)));
}
/**
 * Render an entry alongside the field schema it was captured against. Falls
 * back to the form's current schema when the submission predates versioning.
 */
export async function getEntryWithSchema(id) {
    const entry = await getEntryById(id);
    if (!entry)
        return null;
    let fields = [];
    if (entry.schemaVersionId) {
        const version = await getSchemaVersion(entry.schemaVersionId);
        if (version)
            fields = version.fields;
    }
    if (fields.length === 0) {
        const form = await rawDb().document.findFirst({
            where: { id: entry.formId, collection: 'forms' },
            select: { data: true },
        });
        const data = asRecord(form?.data);
        fields = Array.isArray(data.fields) ? data.fields : [];
    }
    return { entry, fields };
}
// ─── Export ────────────────────────────────────────────────────────────
/** Upper bound on rows per export to stay within function memory/time limits. */
const EXPORT_MAX = 5000;
function csvCell(value) {
    let str;
    if (value == null)
        str = '';
    else if (Array.isArray(value))
        str = value.map((v) => String(v)).join('; ');
    else if (typeof value === 'object')
        str = JSON.stringify(value);
    else
        str = String(value);
    // Quote when the cell contains a delimiter, quote, or newline; escape quotes
    // by doubling. Guard against CSV/formula injection in spreadsheet apps by
    // prefixing a leading =/+/-/@ with a single quote.
    if (/^[=+\-@]/.test(str))
        str = `'${str}`;
    if (/[",\r\n]/.test(str))
        str = `"${str.replace(/"/g, '""')}"`;
    return str;
}
/**
 * Build a CSV export of a form's submissions. Values are decrypted (PII) and
 * include the schema field columns plus core metadata. Bounded by `EXPORT_MAX`.
 * Returns `null` when the form does not exist.
 */
export async function exportFormEntries(formId) {
    const db = rawDb();
    const formDoc = await db.document.findFirst({
        where: { id: formId, collection: 'forms' },
        select: { data: true, title: true, slug: true },
    });
    if (!formDoc)
        return null;
    const formData = asRecord(formDoc.data);
    const fields = Array.isArray(formData.fields) ? formData.fields : [];
    const formName = String(formData.name ?? formDoc.title ?? 'Untitled form');
    const formSlug = String(formData.slug ?? formDoc.slug ?? '');
    const rows = await db.formSubmission.findMany({
        where: { formId },
        orderBy: { submittedAt: 'desc' },
        take: EXPORT_MAX,
    });
    const entries = await Promise.all(rows.map((r) => decryptEntry(normalizeEntry(r, { name: formName, slug: formSlug }))));
    const header = [
        'Entry #',
        'Submitted At',
        'Status',
        'Name',
        'Email',
        'Phone',
        ...fields.map((f) => f.label || f.key),
    ];
    const lines = [header.map(csvCell).join(',')];
    for (const entry of entries) {
        const row = [
            entry.entryNumber ?? '',
            entry.submittedAt,
            entry.spamStatus,
            entry.senderName ?? '',
            entry.senderEmail ?? '',
            entry.senderPhone ?? '',
            ...fields.map((f) => entry.data[f.key]),
        ];
        lines.push(row.map(csvCell).join(','));
    }
    return { csv: lines.join('\r\n'), count: entries.length, formName, formSlug };
}
// ─── Counts ──────────────────────────────────────────────────────────
export async function getEntryCounts(params = {}) {
    const db = rawDb();
    // Count against the same base scope (form filter + non-spam, non-archived)
    // but ignore the read/starred/this-week toggles so the chips show totals.
    const base = buildWhere({
        formId: params.formId,
        archived: params.archived,
        spamStatus: params.spamStatus,
    });
    const [total, unread, starred, thisWeek, byFormGroups] = await Promise.all([
        db.formSubmission.count({ where: base }),
        db.formSubmission.count({ where: { ...base, unread: true } }),
        db.formSubmission.count({ where: { ...base, starred: true } }),
        db.formSubmission.count({ where: { ...base, submittedAt: { gte: startOfWeek() } } }),
        db.formSubmission.groupBy({
            by: ['formId'],
            where: { ...base, unread: true },
            _count: { _all: true },
        }),
    ]);
    const byForm = {};
    for (const g of byFormGroups)
        byForm[g.formId] = g._count._all;
    return { total, unread, starred, thisWeek, byForm };
}
function patchToData(patch) {
    const data = {};
    if (patch.unread !== undefined) {
        data.unread = patch.unread;
        data.readAt = patch.unread ? null : new Date();
    }
    if (patch.starred !== undefined)
        data.starred = patch.starred;
    if (patch.archived !== undefined) {
        data.archived = patch.archived;
        data.archivedAt = patch.archived ? new Date() : null;
    }
    return data;
}
async function auditEntryChange(ctx, id, patch) {
    const events = [];
    if (patch.unread === false)
        events.push('form.entry.read');
    if (patch.unread === true)
        events.push('form.entry.unread');
    if (patch.starred === true)
        events.push('form.entry.starred');
    if (patch.starred === false)
        events.push('form.entry.unstarred');
    if (patch.archived === true)
        events.push('form.entry.archived');
    if (patch.archived === false)
        events.push('form.entry.unarchived');
    await Promise.all(events.map((event) => logEvent({ event, userId: ctx.userId, details: { entryId: id } })));
}
export async function updateEntry(ctx, id, patch) {
    const data = patchToData(patch);
    if (Object.keys(data).length === 0)
        return getEntryById(id);
    await rawDb().formSubmission.update({ where: { id }, data });
    await auditEntryChange(ctx, id, patch);
    const updated = await getEntryById(id);
    // Fire webhooks only on the meaningful transition (turning the flag on), so
    // re-saving an already-starred entry doesn't spam subscribers. Non-blocking.
    if (updated && (patch.starred === true || patch.archived === true)) {
        const event = patch.starred === true ? 'form.entry.starred' : 'form.entry.archived';
        void dispatchFormWebhooks(updated.formId, {
            event,
            formId: updated.formId,
            formSlug: updated.formSlug,
            submissionId: updated.id,
            entryNumber: updated.entryNumber ?? null,
            data: updated.data,
        }).catch(() => { });
    }
    return updated;
}
export function markEntryRead(ctx, id) {
    return updateEntry(ctx, id, { unread: false });
}
export function markEntryUnread(ctx, id) {
    return updateEntry(ctx, id, { unread: true });
}
export function starEntry(ctx, id) {
    return updateEntry(ctx, id, { starred: true });
}
export function unstarEntry(ctx, id) {
    return updateEntry(ctx, id, { starred: false });
}
export function archiveEntry(ctx, id) {
    return updateEntry(ctx, id, { archived: true });
}
export function unarchiveEntry(ctx, id) {
    return updateEntry(ctx, id, { archived: false });
}
export async function bulkUpdateEntries(ctx, ids, patch) {
    if (ids.length === 0)
        return 0;
    const data = patchToData(patch);
    if (Object.keys(data).length === 0)
        return 0;
    const result = await rawDb().formSubmission.updateMany({ where: { id: { in: ids } }, data });
    await logEvent({
        event: 'form.entries.bulk_updated',
        userId: ctx.userId,
        details: { count: ids.length, patch },
    });
    return result.count ?? ids.length;
}
export async function deleteEntry(ctx, id) {
    await rawDb().formSubmission.delete({ where: { id } });
    await logEvent({ event: 'form.entry.deleted', userId: ctx.userId, details: { entryId: id } });
}
//# sourceMappingURL=entries.js.map