> ## Documentation Index
> Fetch the complete documentation index at: https://docs.qirtaas.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Draft and publish documents

> Give documents a draft/published lifecycle with two pointers: readers see a stable published version while authors edit a private draft, and publishing is one atomic swap.

The editor autosaves continuously — every keystroke lands in the stored
document. That's exactly right for personal notes, but wrong for published
content: once readers can see a document, an author reopening it would be
live-editing in front of their audience, typos and half-finished thoughts
included.

This tutorial adds a **draft/published lifecycle** on top of the
[creation](/tutorials/document-creation) and
[access-control](/tutorials/access-control) flows. The pattern — taken from
how production hosts run Qirtaas — is that draft and published are **two
documents, not a flag**. Your table keeps two pointers:

```
lessons(…, qirtaas_doc_id, draft_doc_id)
```

* **`qirtaas_doc_id`** — the published document readers see. `""` until the
  first publish, and the **only** pointer your read gate ever signs.
* **`draft_doc_id`** — the work-in-progress the author edits. `""` when there
  is no draft.

Publishing promotes the draft pointer into the published slot — a pointer
swap in your database, no content copied. Revising clones the published
document and edits the clone. Autosave stays on the whole time; it just
writes to a document nobody else can read.

The example continues the course platform from
[access control](/tutorials/access-control): teachers author and revise
lesson documents, enrolled students only ever read the published version.

## 1. The publication state

Everything the UI needs is derivable from the two pointers — don't store a
separate status column that can drift:

```ts theme={null}
type PublicationState = "draft" | "revising" | "published";

function publicationState(lesson: Lesson): PublicationState {
  if (!lesson.qirtaas_doc_id) return "draft"; // never published
  if (lesson.draft_doc_id) return "revising"; // published, edits in progress
  return "published"; // published and clean
}
```

| State       | `qirtaas_doc_id` | `draft_doc_id` | Readers see       |
| ----------- | ---------------- | -------------- | ----------------- |
| `draft`     | `""`             | set            | nothing           |
| `revising`  | set              | set            | the published doc |
| `published` | set              | `""`           | the published doc |

## 2. Create as a draft

Authoring is the [creation flow](/tutorials/document-creation) unchanged —
mount `<QirtaasEditor>` without a `documentId`, and when `onDocumentCreated`
fires, record the new id. The only difference: store it in **`draft_doc_id`**,
so the lesson starts private.

```tsx title="src/ComposeLesson.tsx" theme={null}
import { QirtaasEditor } from "@qirtaas/react";
import { getToken } from "./qirtaas";

export function ComposeLesson({ lessonId }: { lessonId: number }) {
  async function onCreated(docId: string) {
    await fetch(`/api/lessons/${lessonId}/draft`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ draft_doc_id: docId }),
    });
  }

  return (
    <div style={{ height: "70vh" }}>
      <QirtaasEditor getToken={getToken} locale="ar" onDocumentCreated={onCreated} />
    </div>
  );
}
```

Because `qirtaas_doc_id` is still `""`, the signature endpoint from the
access-control tutorial has nothing it will sign — an unpublished draft is
unreadable by construction, with no extra checks to write.

## 3. Publish — promote the pointer

Publishing moves the draft pointer into the published slot in one
transaction. If the lesson was already published, the old published document
is now an **orphan** — return its id so the frontend can delete it over the
embed channel (your backend records pointers; it never brokers document
lifecycle).

<CodeGroup>
  ```ts Node (Express) theme={null}
  app.post("/api/lessons/:id/publish", requireTeacher, async (req, res) => {
    const lesson = await db.lessons.findUnique({
      where: { id: Number(req.params.id) },
    });
    if (!lesson) return res.status(404).json({ error: "Lesson not found" });
    if (!lesson.draft_doc_id)
      return res.status(400).json({ error: "no_draft_to_publish" });

    const orphanDocId = lesson.qirtaas_doc_id || null;
    const updated = await db.lessons.update({
      where: { id: lesson.id },
      data: { qirtaas_doc_id: lesson.draft_doc_id, draft_doc_id: "" },
    });
    res.json({ lesson: updated, orphan_doc_id: orphanDocId });
  });
  ```

  ```python Python (FastAPI) theme={null}
  @app.post("/api/lessons/{lesson_id}/publish")
  async def publish_lesson(lesson_id: int, user=Depends(require_teacher)):
      lesson = await db.lessons.find_first(id=lesson_id)
      if lesson is None:
          raise HTTPException(404, "Lesson not found")
      if not lesson.draft_doc_id:
          raise HTTPException(400, "no_draft_to_publish")

      orphan_doc_id = lesson.qirtaas_doc_id or None
      async with db.transaction():
          lesson.qirtaas_doc_id = lesson.draft_doc_id
          lesson.draft_doc_id = ""
          await lesson.save()
      return {"lesson": lesson, "orphan_doc_id": orphan_doc_id}
  ```
</CodeGroup>

The frontend calls publish, then cleans up the orphan best-effort — a failed
delete leaves an unreachable document, not a broken lesson:

```tsx theme={null}
import { qirtaas } from "./qirtaas";

async function publish(lessonId: number) {
  const res = await fetch(`/api/lessons/${lessonId}/publish`, { method: "POST" });
  if (!res.ok) throw new Error(`publish failed (${res.status})`);
  const { lesson, orphan_doc_id } = await res.json();
  if (orphan_doc_id) await qirtaas.deleteDocument(orphan_doc_id).catch(() => {});
  return lesson;
}
```

<Warning>
  Don't skip the orphan cleanup. Every orphan is a full document that still
  counts against the identity's document quota — let them accumulate across
  many publish cycles and you'll start hitting rate limits and document caps
  on an identity that looks nearly empty in your own tables. "Best-effort"
  means a single failed delete is tolerable, not that deleting is optional.
</Warning>

## 4. Revise — clone, then edit the clone

To edit a published lesson without readers watching, clone the published
document with the client's
[`duplicateDocument`](/sdk/client#client-methods) and register the clone as
the working draft. Students keep reading the stable published version;
publishing later promotes the clone via step 3.

The draft endpoint accepts the clone's id — and rejects a second concurrent
draft with a `409`, returning the existing one instead:

<CodeGroup>
  ```ts Node (Express) theme={null}
  app.post("/api/lessons/:id/draft", requireTeacher, async (req, res) => {
    const lesson = await db.lessons.findUnique({
      where: { id: Number(req.params.id) },
    });
    if (!lesson) return res.status(404).json({ error: "Lesson not found" });

    const draftId = String(req.body.draft_doc_id ?? "").trim();
    if (!draftId)
      return res.status(400).json({ error: "draft_doc_id is required" });
    if (lesson.draft_doc_id)
      // A draft already exists — return it rather than losing it to the clone.
      return res.status(409).json({ error: "draft_already_exists", lesson });

    const updated = await db.lessons.update({
      where: { id: lesson.id },
      data: { draft_doc_id: draftId },
    });
    res.json({ lesson: updated });
  });
  ```

  ```python Python (FastAPI) theme={null}
  @app.post("/api/lessons/{lesson_id}/draft")
  async def start_draft(lesson_id: int, body: dict, user=Depends(require_teacher)):
      lesson = await db.lessons.find_first(id=lesson_id)
      if lesson is None:
          raise HTTPException(404, "Lesson not found")

      draft_id = str(body.get("draft_doc_id") or "").strip()
      if not draft_id:
          raise HTTPException(400, "draft_doc_id is required")
      if lesson.draft_doc_id:
          # A draft already exists — return it rather than losing it to the clone.
          return JSONResponse(
              {"error": "draft_already_exists", "lesson": lesson.dict()},
              status_code=409,
          )

      lesson.draft_doc_id = draft_id
      await lesson.save()
      return {"lesson": lesson}
  ```
</CodeGroup>

On the frontend, "Revise" is clone → register → open the editor on the
draft. On a `409`, the clone you just made is redundant — delete it and open
the draft that already exists:

```tsx theme={null}
import { qirtaas, getToken } from "./qirtaas";

async function startRevision(lesson: Lesson): Promise<string> {
  const { id: draftId } = await qirtaas.duplicateDocument(lesson.qirtaas_doc_id);

  const res = await fetch(`/api/lessons/${lesson.id}/draft`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ draft_doc_id: draftId }),
  });

  if (res.status === 409) {
    // Someone got there first — use their draft, discard our clone.
    await qirtaas.deleteDocument(draftId).catch(() => {});
    const { lesson: current } = await res.json();
    return current.draft_doc_id;
  }
  if (!res.ok) throw new Error(`draft registration failed (${res.status})`);
  return draftId;
}

// …then mount the editor on the returned id:
// <QirtaasEditor key={draftId} documentId={draftId} getToken={getToken} />
```

## 5. The read gate never signs a draft

This is the security core of the pattern, and it costs nothing: the signature
endpoint from [access control](/tutorials/access-control) already signs
`lesson.qirtaas_doc_id` and nothing else. Keep it that way and drafts are
unreadable to students **by construction** — there is no "is this a draft?"
check to forget, because the draft pointer simply never reaches the signer.

Two adjacent rules complete the gate:

* **Public lists exclude unpublished rows** — filter out lessons where
  `qirtaas_doc_id = ""` for non-teachers, so a never-published draft doesn't
  appear as an empty shell.
* **Detail views 404 unpublished lessons for non-teachers** — don't reveal
  that a draft exists.

The author, meanwhile, needs no signature at all: their embed token covers
their own documents, so the editor opens the draft directly.

## 6. Discard a draft

To abandon a revision, clear the pointer and let the frontend delete the
document — the mirror image of publishing:

```ts Node (Express) theme={null}
app.delete("/api/lessons/:id/draft", requireTeacher, async (req, res) => {
  const lesson = await db.lessons.findUnique({
    where: { id: Number(req.params.id) },
  });
  if (!lesson?.draft_doc_id)
    return res.status(404).json({ error: "No draft to discard" });

  const orphanDocId = lesson.draft_doc_id;
  await db.lessons.update({
    where: { id: lesson.id },
    data: { draft_doc_id: "" },
  });
  res.json({ orphan_doc_id: orphanDocId });
});
```

A `revising` lesson drops back to `published`; a never-published `draft`
lesson goes back to having no document at all.

## Where you are

Lessons now carry a full lifecycle — authored privately, published
atomically, revised without readers noticing — and your backend still only
stores two pointers per row. The division of labor from the earlier
tutorials held throughout: document lifecycle (create, clone, delete) runs on
the frontend over the embed channel; your backend records pointers and
decides what to sign.

<CardGroup cols={2}>
  <Card title="Access control" icon="lock" href="/tutorials/access-control">
    The signature flow this read gate builds on.
  </Card>

  <Card title="Client API" icon="code" href="/sdk/client">
    `duplicateDocument`, `deleteDocument`, and friends.
  </Card>
</CardGroup>
