import { z } from "zod";
import { BLOCK_TYPES } from "../../constants.js";
/**
* The REST block type, case-tolerant on input.
*
* The DB/REST wire enum is UPPERCASE (`BLOCK_TYPES`), but the web editor's
* codec is lowercase (`file`, `heading_1`, …). The realtime gateway already
* reconciles the two at its boundary (`prisma-store.ts` `codecToDbType` /
* `dbToCodecType`); the REST path now does the same so a block created via
* `POST /pages/:id/blocks` with the codec case stores and returns the DB
* case. UPPERCASE input is unchanged (backward compatible — existing
* integration tests send and expect `PARAGRAPH`/`FILE`/…).
*/
const blockTypeSchema = z
.string()
.min(1)
.transform((t) => t.toUpperCase())
.pipe(z.enum(BLOCK_TYPES));
/**
* Block content: the canonical shape is an object (e.g. `{ text }`), but the
* web InlineEditor round-trips content as an HTML STRING (its MVP storage
* model — see the API's own `blockText` in modules/ai/prompts.ts, which
* documents both shapes must stay readable). Accepting a string here is what
* lets the editor's `PATCH /blocks/:id` and batch `op:"update"` saves persist
* web-typed text instead of 400ing.
*/
const blockContentSchema = z.union([z.string(), z.record(z.unknown())]);
export const CreateBlockDto = z.object({
type: blockTypeSchema,
// The web editor stores text as an HTML STRING (the InlineEditor storage
// model) — accept string OR record here, exactly like UpdateBlockDto. A
// record-only schema 400'd every web paste/import (content: "
…
").
content: blockContentSchema.default({}),
properties: z.record(z.unknown()).default({}),
parentId: z.string().min(1).nullable().optional(),
afterBlockId: z.string().min(1).optional(),
beforeBlockId: z.string().min(1).optional(),
position: z.string().min(1).optional(),
/**
* Optional client-chosen block id (the web paste path generates
* `blk_`-prefixed ids so nested pastes can reference their parent by id
* in ONE batch). Uniqueness is enforced by the DB primary key — a
* collision aborts the whole batch (no partial application). Omitted →
* the server assigns a cuid (today's behavior).
*/
id: z.string().min(1).max(128).optional()
});
export type CreateBlockInput = z.infer;
export const DuplicateBlockDto = z.object({
parentId: z.string().min(1).nullable().optional(),
afterBlockId: z.string().min(1).optional()
});
export type DuplicateBlockInput = z.infer;
export const UpdateBlockDto = z.object({
type: blockTypeSchema.optional(),
content: blockContentSchema.optional(),
properties: z.record(z.unknown()).optional(),
archived: z.boolean().optional(),
/**
* TASK-0123: optional optimistic-concurrency base. When present the write
* only applies if the block is still at exactly this version; otherwise a
* 409 CONFLICT `{ reason: "stale_version", actualVersion }` is returned.
* Omitted → the historical unconditional increment (unchanged behavior).
*/
baseVersion: z.number().int().positive().optional()
});
export type UpdateBlockInput = z.infer;
export const MoveBlockDto = z.object({
parentId: z.string().min(1).nullable().optional(),
afterBlockId: z.string().min(1).optional(),
beforeBlockId: z.string().min(1).optional(),
position: z.string().min(1).optional()
});
export type MoveBlockInput = z.infer;
const BatchUpsertOp = z.object({
op: z.literal("upsert"),
block: CreateBlockDto
});
const BatchDeleteOp = z.object({
op: z.literal("delete"),
blockId: z.string().min(1)
});
const BatchMoveOp = z.object({
op: z.literal("move"),
blockId: z.string().min(1),
move: MoveBlockDto
});
/**
* The web editor's debounced save (components/editor/block-save-queue.ts)
* flushes `op:"update"` ops — one per typed block — to this endpoint. The
* union used to omit it, so EVERY batched text save 400'd ("Invalid
* discriminator value") and the queue silently re-queued forever. The patch
* is the web's `BlockPatch` (content may be an HTML string — the InlineEditor
* storage model; type conversions clear properties, mirroring PATCH).
* `passthrough()` (rather than stripping unknown keys) so the client's
* field set is never silently dropped.
*/
const BatchUpdateOp = z.object({
op: z.literal("update"),
blockId: z.string().min(1),
patch: z
.object({
type: blockTypeSchema.optional(),
content: blockContentSchema.optional(),
properties: z.record(z.unknown()).optional(),
parentId: z.string().min(1).nullable().optional(),
position: z.string().min(1).optional()
})
.passthrough()
});
export const BatchBlocksDto = z.object({
pageId: z.string().min(1),
// The `pages.version` the client last read (the Phase-4 409-rebase path,
// now also the TASK-0092 flush serialization): a stale baseVersion → 409
// CONFLICT carrying the NEW version ("flush wins; REST sees fresh state
// on retry"). Omitted (legacy clients / import / AI / automation) → the
// write proceeds, still advancing the version so a concurrent realtime
// flush's CAS fails and rebases over the REST write (never clobbers it).
baseVersion: z.number().int().min(1).optional(),
ops: z.array(
z.discriminatedUnion("op", [BatchUpsertOp, BatchUpdateOp, BatchDeleteOp, BatchMoveOp])
).min(1).max(200)
});
export type BatchBlocksInput = z.infer;
export const ListBlocksQuery = z.object({
parentId: z.string().nullable().optional(),
depth: z.union([z.enum(["1", "all"]), z.coerce.number().int().min(1)]).optional()
});
export type ListBlocksInput = z.infer;