import { extractPlainText, hashContent } from './content/index.js';
import { applyFieldAccess } from './security/access.js';
import { sanitizeHtml } from './security/sanitize.js';
import { stripInternalDataKeys } from './security/internal-keys.js';
import { getActuateConfig } from './config/runtime.js';
import { validateRelations } from './fields/relations.js';
import { invalidationTagsForDocumentWrite, invalidationTagsForGlobalWrite } from './cache/http.js';
import { notifySitemapOnPublish } from './seo/sitemap-notify.js';
import { getSeoOverrides, putSeoOverrides, resolveSeoModuleSettings, } from './seo/config-store.js';
function prisma(ctx) {
    return ctx.db;
}
/**
 * Fire-and-forget webhook dispatch. Errors are logged but never thrown — the
 * caller is expected to have already committed the underlying database change.
 *
 * MUST be called AFTER the surrounding transaction commits — never inside a
 * `db.$transaction` callback. A rolled-back transaction must not dispatch
 * webhooks for a doc that no longer exists.
 */
async function fireWebhook(event, data) {
    try {
        const { dispatch } = await import('./webhooks/index.js');
        await dispatch(event, data);
    }
    catch (err) {
        if (process.env.NODE_ENV !== 'test') {
            console.error(`[actuate] Webhook dispatch failed for ${event}:`, err instanceof Error ? err.message : err);
        }
    }
}
async function firePluginHooks(event, doc, ctx) {
    try {
        const config = getActuateConfig();
        const hooks = (config?._pluginHooks ?? []);
        const matching = hooks.filter((h) => h?.event === event);
        for (const hook of matching) {
            try {
                await hook.handler(doc);
            }
            catch (err) {
                console.error(`[actuate] Plugin hook "${event}" error:`, err);
            }
        }
        if (doc.data && typeof doc.data === 'object') {
            const docData = doc.data;
            const hasAiUpdates = docData._aiScore !== undefined || docData._embedding !== undefined;
            if (hasAiUpdates && doc.id) {
                const db = prisma(ctx);
                await db.document
                    .update({
                    where: { id: doc.id },
                    data: { data: docData },
                })
                    .catch((err) => {
                    console.error(`[actuate] Failed to persist plugin hook data:`, err);
                });
            }
        }
    }
    catch (err) {
        console.error(`[actuate] Plugin hooks dispatch failed:`, err);
    }
}
/**
 * Dispatch `cache.onInvalidate` for the given tag set, swallowing any
 * failure. Cache invalidation is best-effort — the underlying DB write
 * has already committed, so a webhook revalidation failure must NOT
 * surface to the caller (otherwise we'd appear to have failed a write
 * that actually succeeded).
 */
async function fireCacheInvalidate(tags) {
    const onInvalidate = getActuateConfig()?.cache?.onInvalidate;
    if (!onInvalidate || tags.length === 0)
        return;
    try {
        await onInvalidate(tags);
    }
    catch (err) {
        if (process.env.NODE_ENV !== 'test') {
            console.error('[actuate] cache.onInvalidate threw — cache may be stale until TTL:', err instanceof Error ? err.message : err);
        }
    }
}
async function flushEffects(effects, ctx) {
    for (const effect of effects) {
        if (effect.kind === 'webhook') {
            await fireWebhook(effect.event, effect.payload);
        }
        else if (effect.kind === 'plugin-hook') {
            await firePluginHooks(effect.event, effect.doc, ctx);
        }
        else if (effect.kind === 'cache-invalidate') {
            await fireCacheInvalidate(effect.tags);
        }
        else {
            await fireSitemapNotify(ctx);
        }
    }
}
/** Sitemap cache tag — invalidated on any public-content change. */
const SITEMAP_CACHE_TAG = 'sitemap';
/**
 * Roles permitted to publish content. AUTHOR (and lower) may save drafts but
 * never transition a document into PUBLISHED — on either the create or the
 * update path. Finer-grained control belongs in a dedicated publish endpoint
 * with its own access check.
 */
const PUBLISH_ROLES = new Set(['ADMIN', 'EDITOR']);
/**
 * Best-effort sitemap regeneration + (production-gated) search-engine ping
 * fired after public content changes. Reads the shared SEO module settings so
 * the toggles in Settings > SEO / SEO Technical govern behaviour. Never throws
 * — the content write has already committed.
 */
async function fireSitemapNotify(ctx) {
    try {
        const overrides = await getSeoOverrides(ctx.db);
        const settings = resolveSeoModuleSettings(overrides);
        if (!settings.sitemapEnabled || !settings.autoRegenerateSitemapOnPublish)
            return;
        const cfg = getActuateConfig();
        const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ??
            cfg?.seo?.siteUrl ??
            null;
        const result = await notifySitemapOnPublish({
            settings,
            siteUrl,
            lastGeneratedAt: settings.sitemapLastGeneratedAt,
        });
        // Cheap + un-debounced: refresh the sitemap cache on every change so the
        // next request rebuilds with the latest public content.
        if (result.invalidateCache) {
            await fireCacheInvalidate([SITEMAP_CACHE_TAG]);
        }
        // Expensive + debounced: only persist the regeneration timestamp once per
        // window so bursts of publishes don't hammer the single `__seo_config__`
        // row. The Settings/Technical UI reads this for "last generated".
        if (result.regenerated) {
            const next = {
                ...(overrides ?? {}),
                module: {
                    ...(overrides?.module ?? {}),
                    sitemapLastGeneratedAt: new Date().toISOString(),
                },
            };
            await putSeoOverrides(ctx.db, next, ctx.userId);
        }
    }
    catch (err) {
        if (process.env.NODE_ENV !== 'test') {
            console.error('[actuate] sitemap notify failed — sitemap may be stale until next write:', err instanceof Error ? err.message : err);
        }
    }
}
function getCollectionFields(collection) {
    const config = getActuateConfig()?.collections?.[collection];
    return config?.fields ?? null;
}
async function checkCollectionAccess(collection, operation, ctx, doc) {
    const collectionConfig = getActuateConfig()?.collections?.[collection];
    const accessFn = collectionConfig?.access?.[operation];
    if (!accessFn)
        return;
    const user = ctx.userId ? { id: ctx.userId, role: ctx.role } : null;
    const allowed = await accessFn({ user, doc });
    if (!allowed) {
        throw new Error(`Access denied: cannot ${operation} in collection "${collection}"`);
    }
}
/**
 * Public wrapper around the internal per-collection access gate. Lets API
 * handlers that don't go through the standard CRUD actions (e.g. the read-only
 * SEO analysis endpoints) still enforce a collection's `access[operation]`
 * rule. Throws `Access denied: …` when the rule rejects the user.
 */
export async function assertCollectionAccess(collection, operation, ctx, doc) {
    await checkCollectionAccess(collection, operation, ctx, doc);
}
function sanitizeDocumentData(data, collection) {
    const config = getActuateConfig()?.collections?.[collection];
    const fields = config?.fields ?? {};
    const result = { ...data };
    for (const [key, value] of Object.entries(result)) {
        if (typeof value !== 'string')
            continue;
        const fieldDef = fields[key];
        if (fieldDef?.type === 'richText') {
            result[key] = sanitizeHtml(value);
        }
    }
    return result;
}
async function computeTextFields(data) {
    const serialized = JSON.stringify(data);
    const plainText = extractPlainText(serialized);
    const contentHash = await hashContent(serialized);
    return { plainText, contentHash };
}
/**
 * Build a {@link RelationLookup} backed by the active Prisma client. The
 * lookup batches by collection and filters out soft-deleted docs so a
 * relation that points at a tombstoned record is treated as broken.
 *
 * Exported so the API layer (resolve, list, get) can reuse it without
 * pulling in the entire actions module.
 */
export function buildRelationLookup(db) {
    return {
        fetchByIds: async (collections, ids) => {
            const result = new Map();
            if (ids.length === 0 || collections.length === 0)
                return result;
            const rows = await db.document.findMany({
                where: {
                    id: { in: ids },
                    collection: { in: collections },
                    deletedAt: null,
                },
                select: {
                    id: true,
                    collection: true,
                    title: true,
                    slug: true,
                    status: true,
                    data: true,
                },
            });
            for (const row of rows) {
                result.set(row.id, {
                    id: row.id,
                    collection: row.collection,
                    title: row.title,
                    slug: row.slug,
                    status: row.status,
                    data: row.data && typeof row.data === 'object' ? row.data : null,
                });
            }
            return result;
        },
    };
}
/** Create a new document in a collection. */
export async function createDocument(collection, data, ctx) {
    await checkCollectionAccess(collection, 'create', ctx);
    const db = prisma(ctx);
    const user = { id: ctx.userId, role: ctx.role };
    // Strip top-level page builder envelope (`layout` / `pageSettings`) and
    // merge it back into `data` as `_layout` / `_pageSettings` so the storage
    // shape is single-sourced. Both wire shapes are accepted for backwards
    // compatibility — top-level wins when both are present.
    const { envelope, rest } = extractPageBuilderEnvelope(data);
    const fields = getCollectionFields(collection);
    const accessFiltered = fields ? await applyFieldAccess('write', fields, rest, user) : rest;
    const sanitizedData = sanitizeDocumentData(accessFiltered, collection);
    const filteredData = lowerPageBuilderEnvelope(sanitizedData, envelope);
    // Verify any relation fields point at real, non-deleted documents BEFORE
    // we open the transaction. Doing this inside the txn would block the
    // write while we run extra SELECTs, so we accept a tiny race here (a
    // referenced doc could be deleted between validation and create) in
    // exchange for a much shorter critical section.
    if (fields) {
        await validateRelations(fields, filteredData, buildRelationLookup(db));
    }
    const { plainText, contentHash } = await computeTextFields(filteredData);
    // Honor a requested status on create so an agent/SDK can create-and-publish
    // in one call. Previously this was hard-coded to DRAFT, silently dropping a
    // requested `status: 'PUBLISHED'`. Only DRAFT and PUBLISHED are valid at
    // create time — scheduling/archiving requires the dedicated fields the
    // update path manages (scheduledAt, etc.), so route those through update.
    const requestedStatus = typeof filteredData.status === 'string' ? filteredData.status : undefined;
    if (requestedStatus && requestedStatus !== 'DRAFT' && requestedStatus !== 'PUBLISHED') {
        throw new Error(`Cannot create a document with status "${requestedStatus}". Create it as DRAFT or PUBLISHED, then change status via update.`);
    }
    const publishingNow = requestedStatus === 'PUBLISHED';
    if (publishingNow && !PUBLISH_ROLES.has(ctx.role)) {
        throw new Error('Insufficient permissions to publish — only EDITOR or ADMIN may publish.');
    }
    const status = publishingNow ? 'PUBLISHED' : 'DRAFT';
    const pendingEffects = [];
    const doc = await db.$transaction(async (tx) => {
        const created = await tx.document.create({
            data: {
                collection,
                title: typeof filteredData.title === 'string' ? filteredData.title : null,
                slug: typeof filteredData.slug === 'string' ? filteredData.slug : null,
                data: filteredData,
                status,
                locale: ctx.locale ?? null,
                createdById: ctx.userId,
                updatedById: ctx.userId,
                publishedAt: publishingNow ? new Date() : null,
                plainText,
                contentHash,
            },
        });
        await tx.version.create({
            data: {
                documentId: created.id,
                data: filteredData,
                changedById: ctx.userId,
                changeType: 'CREATE',
            },
        });
        pendingEffects.push({
            kind: 'webhook',
            event: 'document.created',
            payload: {
                id: created.id,
                collection,
                title: created.title,
                slug: created.slug,
                status: created.status,
            },
        });
        // A document created directly as PUBLISHED also emits a publish event so
        // downstream subscribers (and the sitemap) react the same as a draft→publish
        // transition through update.
        if (publishingNow) {
            pendingEffects.push({
                kind: 'webhook',
                event: 'document.published',
                payload: {
                    id: created.id,
                    collection,
                    title: created.title,
                    slug: created.slug,
                    status: created.status,
                },
            });
            pendingEffects.push({ kind: 'sitemap-notify', reason: 'publish' });
        }
        pendingEffects.push({ kind: 'plugin-hook', event: 'afterCreate', doc: created });
        pendingEffects.push({
            kind: 'cache-invalidate',
            tags: invalidationTagsForDocumentWrite(collection, created.id),
        });
        return created;
    });
    await flushEffects(pendingEffects, ctx);
    return doc;
}
/** Update an existing document. Merges incoming data with existing to prevent field loss. */
export async function updateDocument(collection, id, data, ctx) {
    await checkCollectionAccess(collection, 'update', ctx);
    const db = prisma(ctx);
    const user = { id: ctx.userId, role: ctx.role };
    const existing = await db.document.findFirst({
        where: { id, collection, deletedAt: null },
    });
    if (!existing) {
        throw new Error(`Document "${id}" not found in collection "${collection}"`);
    }
    const existingData = existing.data && typeof existing.data === 'object'
        ? existing.data
        : {};
    // Strip top-level page builder envelope before merge so we don't end up
    // with a duplicate `layout` user field shadowing `_layout`. See
    // {@link extractPageBuilderEnvelope} for the wire-vs-storage explanation.
    const { envelope, rest } = extractPageBuilderEnvelope(data);
    const mergedData = { ...existingData, ...rest };
    const fields = getCollectionFields(collection);
    const accessFiltered = fields
        ? await applyFieldAccess('write', fields, mergedData, user)
        : mergedData;
    const sanitizedData = sanitizeDocumentData(accessFiltered, collection);
    const filteredData = lowerPageBuilderEnvelope(sanitizedData, envelope);
    // Only re-validate relations whose value actually changed — most updates
    // are partial (status flip, slug rename) and re-running every lookup on
    // every save is wasteful. We compare the incoming `rest` against the
    // existing data and only validate fields the caller touched.
    if (fields) {
        const touchedFields = {};
        const touchedData = {};
        for (const [key, def] of Object.entries(fields)) {
            if (def.type !== 'relationship' && def.type !== 'array')
                continue;
            if (Object.prototype.hasOwnProperty.call(rest, key)) {
                touchedFields[key] = def;
                touchedData[key] = filteredData[key];
            }
        }
        if (Object.keys(touchedFields).length > 0) {
            await validateRelations(touchedFields, touchedData, buildRelationLookup(db));
        }
    }
    const { plainText, contentHash } = await computeTextFields(filteredData);
    const requestedStatus = typeof filteredData.status === 'string' ? filteredData.status : undefined;
    // Only EDITOR+ can change status to PUBLISHED. Authors can save drafts but
    // can never publish through the document update path. (Use a dedicated
    // publish endpoint with its own access check for finer-grained control.)
    let status = requestedStatus;
    if (status === 'PUBLISHED' && existing.status !== 'PUBLISHED' && !PUBLISH_ROLES.has(ctx.role)) {
        throw new Error('Insufficient permissions to publish — only EDITOR or ADMIN may publish.');
    }
    // Preserve the original publishedAt when the status was already PUBLISHED;
    // only stamp publishedAt on the transition into PUBLISHED.
    const isPublishingNow = status === 'PUBLISHED' && existing.status !== 'PUBLISHED';
    // Capture side-effects to fire AFTER the transaction commits so we never
    // dispatch a webhook for a rolled-back write. Reuses the module-level
    // `PendingEffect` type so newer effect kinds (e.g. cache-invalidate)
    // are accepted here automatically.
    const pendingEffects = [];
    const updated = await db.$transaction(async (tx) => {
        const result = await tx.document.update({
            where: { id },
            data: {
                title: typeof filteredData.title === 'string' ? filteredData.title : undefined,
                slug: typeof filteredData.slug === 'string' ? filteredData.slug : undefined,
                data: filteredData,
                updatedById: ctx.userId,
                plainText,
                contentHash,
                ...(status ? { status } : {}),
                ...(isPublishingNow ? { publishedAt: new Date() } : {}),
            },
        });
        await tx.version.create({
            data: {
                documentId: id,
                data: filteredData,
                changedById: ctx.userId,
                changeType: 'UPDATE',
            },
        });
        const webhookEvent = isPublishingNow ? 'document.published' : 'document.updated';
        pendingEffects.push({
            kind: 'webhook',
            event: webhookEvent,
            payload: {
                id,
                collection,
                title: result.title,
                slug: result.slug,
                status: status ?? existing.status,
            },
        });
        pendingEffects.push({ kind: 'plugin-hook', event: 'afterUpdate', doc: result });
        pendingEffects.push({
            kind: 'cache-invalidate',
            tags: invalidationTagsForDocumentWrite(collection, id),
        });
        // Sitemap upkeep: a change is sitemap-relevant when the document enters,
        // stays in, or leaves the PUBLISHED state. Pure DRAFT→DRAFT edits never
        // touch the public sitemap, so don't enqueue (avoids needless pings).
        const wasPublished = existing.status === 'PUBLISHED';
        const isPublished = (status ?? existing.status) === 'PUBLISHED';
        if (isPublishingNow) {
            pendingEffects.push({ kind: 'sitemap-notify', reason: 'publish' });
        }
        else if (wasPublished && !isPublished) {
            pendingEffects.push({ kind: 'sitemap-notify', reason: 'unpublish' });
        }
        else if (wasPublished && isPublished) {
            pendingEffects.push({ kind: 'sitemap-notify', reason: 'update' });
        }
        return result;
    });
    await flushEffects(pendingEffects, ctx);
    return updated;
}
/**
 * Update SEO metadata on a document through the data layer.
 *
 * SEO fields (`canonical`, `og*`, `focusKeyword`, `structuredData`, `noindex`,
 * …) are a cross-cutting convention that collections are not required to
 * declare as fields. Unlike {@link updateDocument} this therefore does NOT run
 * field-access stripping — that strips any key without a matching field
 * definition and would silently drop these undeclared SEO keys. It still:
 *   - enforces the collection's `update` access rule (no cross-collection edits),
 *   - snapshots a `Version` row (audit trail),
 *   - recomputes `plainText`/`contentHash` (search index stays consistent),
 *   - fires the `afterUpdate` + cache-invalidate effects.
 *
 * `seoFields` MUST be pre-validated and length-bounded by the caller (the API
 * layer enforces type + length); this function trusts the shapes it receives.
 */
export async function updateDocumentSeoFields(collection, id, seoFields, ctx) {
    await checkCollectionAccess(collection, 'update', ctx);
    const db = prisma(ctx);
    const existing = await db.document.findFirst({
        where: { id, collection, deletedAt: null },
    });
    if (!existing) {
        throw new Error(`Document "${id}" not found in collection "${collection}"`);
    }
    const existingData = existing.data && typeof existing.data === 'object'
        ? existing.data
        : {};
    const nextData = { ...existingData, ...seoFields };
    const { plainText, contentHash } = await computeTextFields(nextData);
    const pendingEffects = [];
    const updated = await db.$transaction(async (tx) => {
        const result = await tx.document.update({
            where: { id },
            data: { data: nextData, updatedById: ctx.userId, plainText, contentHash },
        });
        await tx.version.create({
            data: {
                documentId: id,
                data: nextData,
                changedById: ctx.userId,
                changeType: 'UPDATE',
            },
        });
        pendingEffects.push({ kind: 'plugin-hook', event: 'afterUpdate', doc: result });
        pendingEffects.push({
            kind: 'cache-invalidate',
            tags: invalidationTagsForDocumentWrite(collection, id),
        });
        // SEO field edits (e.g. toggling `noindex`, changing canonical) change
        // sitemap inclusion for already-public docs, so refresh the sitemap.
        if (existing.status === 'PUBLISHED') {
            pendingEffects.push({ kind: 'sitemap-notify', reason: 'update' });
        }
        return result;
    });
    await flushEffects(pendingEffects, ctx);
    return updated;
}
/** Soft-delete a document. Creates a version snapshot before deletion. */
export async function deleteDocument(collection, id, ctx) {
    await checkCollectionAccess(collection, 'delete', ctx);
    const db = prisma(ctx);
    const existing = await db.document.findFirst({
        where: { id, collection, deletedAt: null },
    });
    if (!existing) {
        throw new Error(`Document "${id}" not found in collection "${collection}"`);
    }
    await db.$transaction(async (tx) => {
        await tx.version.create({
            data: {
                documentId: id,
                data: existing.data ?? {},
                changedById: ctx.userId,
                changeType: 'DELETE',
            },
        });
        await tx.document.update({
            where: { id },
            data: { deletedAt: new Date() },
        });
    });
    const deleteEffects = [
        {
            kind: 'webhook',
            event: 'document.deleted',
            payload: { id, collection, title: existing.title, slug: existing.slug },
        },
        {
            kind: 'cache-invalidate',
            tags: invalidationTagsForDocumentWrite(collection, id),
        },
    ];
    // Removing public content changes the sitemap; drafts were never in it.
    if (existing.status === 'PUBLISHED') {
        deleteEffects.push({ kind: 'sitemap-notify', reason: 'delete' });
    }
    await flushEffects(deleteEffects, ctx);
}
function stripInternalKeys(data) {
    return stripInternalDataKeys(data);
}
/**
 * Page Builder envelope: the admin UI sends `layout` and `pageSettings` at
 * the top level of the document, but the database stores them inside
 * `data._layout` and `data._pageSettings` so they don't collide with
 * user-defined fields. These two helpers normalise between the wire shape
 * and the storage shape so both forms are accepted on input and the wire
 * shape is consistent on output.
 *
 * Wire shape:
 *   { id, title, slug, status, data: {...}, layout?, pageSettings? }
 *
 * Storage shape:
 *   data = { ...userFields, _layout?, _pageSettings? }
 */
function liftPageBuilderEnvelope(input) {
    if (!input.data || typeof input.data !== 'object')
        return input;
    const data = input.data;
    const out = { ...input };
    if (data._layout !== undefined) {
        out.layout = data._layout;
    }
    if (data._pageSettings !== undefined) {
        out.pageSettings = data._pageSettings;
    }
    return out;
}
function lowerPageBuilderEnvelope(data, envelope) {
    const out = { ...data };
    if (envelope.layout !== undefined) {
        out._layout = envelope.layout;
    }
    if (envelope.pageSettings !== undefined) {
        out._pageSettings = envelope.pageSettings;
    }
    return out;
}
/** Pull `layout` / `pageSettings` from a write payload, returning the
 * envelope and the cleaned `data` body separately. Callers can then merge
 * the envelope back into `data` with {@link lowerPageBuilderEnvelope}. */
function extractPageBuilderEnvelope(input) {
    const { layout, pageSettings, ...rest } = input;
    const envelope = {};
    if (layout !== undefined)
        envelope.layout = layout;
    if (pageSettings !== undefined)
        envelope.pageSettings = pageSettings;
    return { envelope, rest };
}
/** Retrieve a single document by ID. */
export async function getDocument(collection, id, ctx) {
    const db = prisma(ctx);
    const doc = await db.document.findFirst({
        where: { id, collection, deletedAt: null },
    });
    if (!doc)
        return null;
    if (doc.data && typeof doc.data === 'object') {
        const rawData = doc.data;
        let data = stripInternalKeys(rawData);
        const fields = getCollectionFields(collection);
        if (fields) {
            const user = { id: ctx.userId, role: ctx.role };
            data = await applyFieldAccess('read', fields, data, user);
        }
        // Lift _layout / _pageSettings to the wire envelope so the admin UI
        // and consumers see a single canonical shape: page builder data lives
        // at the top level of the document, not nested inside `data`.
        const out = { ...doc, data };
        if (rawData._layout !== undefined)
            out.layout = rawData._layout;
        if (rawData._pageSettings !== undefined)
            out.pageSettings = rawData._pageSettings;
        return out;
    }
    return doc;
}
/** List documents with pagination, sorting, and filtering. */
export async function listDocuments(options, ctx) {
    const db = prisma(ctx);
    const page = options.page ?? 1;
    const pageSize = options.pageSize ?? 20;
    const skip = (page - 1) * pageSize;
    const where = {
        collection: options.collection,
        deletedAt: null,
    };
    if (options.status) {
        where.status = options.status;
    }
    if (options.locale) {
        where.locale = options.locale;
    }
    if (options.folderId === 'none') {
        where.folderId = null;
    }
    else if (options.folderId) {
        where.folderId = options.folderId;
    }
    const ALLOWED_FILTERS = new Set([
        'status',
        'locale',
        'collection',
        'siteId',
        'templateId',
        'folderId',
    ]);
    if (options.filters) {
        for (const [key, value] of Object.entries(options.filters)) {
            if (ALLOWED_FILTERS.has(key) && typeof value !== 'object') {
                where[key] = value;
            }
        }
    }
    const ALLOWED_SORT = new Set(['createdAt', 'updatedAt', 'publishedAt', 'status', 'collection']);
    const orderBy = {};
    const sortField = ALLOWED_SORT.has(options.sort ?? '') ? options.sort : 'createdAt';
    orderBy[sortField] = options.order ?? 'desc';
    const [rawDocs, total] = await Promise.all([
        db.document.findMany({
            where,
            skip,
            take: pageSize,
            orderBy,
        }),
        db.document.count({ where }),
    ]);
    const fields = getCollectionFields(options.collection);
    let docs = rawDocs;
    const user = { id: ctx.userId, role: ctx.role };
    docs = await Promise.all(rawDocs.map(async (doc) => {
        if (doc.data && typeof doc.data === 'object') {
            const rawData = doc.data;
            let data = stripInternalKeys(rawData);
            if (fields) {
                data = await applyFieldAccess('read', fields, data, user);
            }
            // See `getDocument` — page builder envelope must be top-level on
            // the wire. Without this, list views can't show titles/slugs that
            // the page builder UI stores under `_pageSettings`.
            const out = { ...doc, data };
            if (rawData._layout !== undefined)
                out.layout = rawData._layout;
            if (rawData._pageSettings !== undefined)
                out.pageSettings = rawData._pageSettings;
            return out;
        }
        return doc;
    }));
    return {
        docs,
        total,
        page,
        pageSize,
        totalPages: Math.ceil(total / pageSize),
    };
}
/** Duplicate a document, resetting status to DRAFT and appending "(Copy)" to the title. */
export async function duplicateDocument(collection, sourceId, ctx) {
    const db = prisma(ctx);
    const source = await db.document.findFirst({
        where: { id: sourceId, collection, deletedAt: null },
    });
    if (!source) {
        throw new Error(`Source document "${sourceId}" not found in collection "${collection}"`);
    }
    const clonedData = { ...source.data };
    if (typeof clonedData.title === 'string') {
        clonedData.title += ' (Copy)';
    }
    else if (typeof clonedData.name === 'string') {
        clonedData.name += ' (Copy)';
    }
    if (typeof clonedData.slug === 'string') {
        clonedData.slug += '-copy';
    }
    const { plainText, contentHash } = await computeTextFields(clonedData);
    const pendingEffects = [];
    const doc = await db.$transaction(async (tx) => {
        const created = await tx.document.create({
            data: {
                collection,
                data: clonedData,
                status: 'DRAFT',
                locale: source.locale,
                createdById: ctx.userId,
                updatedById: ctx.userId,
                plainText,
                contentHash,
                siteId: source.siteId,
                templateId: source.templateId,
            },
        });
        await tx.version.create({
            data: {
                documentId: created.id,
                data: clonedData,
                changedById: ctx.userId,
                changeType: 'CREATE',
            },
        });
        pendingEffects.push({
            kind: 'webhook',
            event: 'document.created',
            payload: { id: created.id, collection, title: created.title, slug: created.slug },
        });
        pendingEffects.push({ kind: 'plugin-hook', event: 'afterCreate', doc: created });
        pendingEffects.push({
            kind: 'cache-invalidate',
            tags: invalidationTagsForDocumentWrite(collection, created.id),
        });
        return created;
    });
    await flushEffects(pendingEffects, ctx);
    return doc;
}
/** Retrieve a global's data. Globals are stored as single-row collections keyed by slug. */
export async function getGlobal(slug, ctx) {
    const db = prisma(ctx);
    const doc = await db.document.findFirst({
        where: { collection: slug, deletedAt: null },
    });
    if (!doc)
        return null;
    return doc.data && typeof doc.data === 'object' ? doc.data : {};
}
/** Update a global's data. Creates the document if it doesn't exist (upsert). */
export async function updateGlobal(slug, data, ctx) {
    const db = prisma(ctx);
    const { plainText, contentHash } = await computeTextFields(data);
    const existing = await db.document.findFirst({
        where: { collection: slug, deletedAt: null },
    });
    const result = existing
        ? await db.$transaction(async (tx) => {
            const updated = await tx.document.update({
                where: { id: existing.id },
                data: {
                    data,
                    updatedById: ctx.userId,
                    plainText,
                    contentHash,
                },
            });
            await tx.version.create({
                data: {
                    documentId: existing.id,
                    data,
                    changedById: ctx.userId,
                    changeType: 'UPDATE',
                },
            });
            return updated;
        })
        : await db.$transaction(async (tx) => {
            const created = await tx.document.create({
                data: {
                    collection: slug,
                    data,
                    status: 'DRAFT',
                    createdById: ctx.userId,
                    updatedById: ctx.userId,
                    plainText,
                    contentHash,
                },
            });
            await tx.version.create({
                data: {
                    documentId: created.id,
                    data,
                    changedById: ctx.userId,
                    changeType: 'CREATE',
                },
            });
            return created;
        });
    await flushEffects([{ kind: 'cache-invalidate', tags: invalidationTagsForGlobalWrite(slug) }], ctx);
    return result;
}
//# sourceMappingURL=actions.js.map