Skip to main content
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 and 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: 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:
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
}
Stateqirtaas_doc_iddraft_doc_idReaders see
draft""setnothing
revisingsetsetthe published doc
publishedset""the published doc

2. Create as a draft

Authoring is the creation flow 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.
src/ComposeLesson.tsx
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).
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 });
});
The frontend calls publish, then cleans up the orphan best-effort — a failed delete leaves an unreachable document, not a broken lesson:
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;
}
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.

4. Revise — clone, then edit the clone

To edit a published lesson without readers watching, clone the published document with the client’s duplicateDocument 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:
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 });
});
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:
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 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:
Node (Express)
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.

Access control

The signature flow this read gate builds on.

Client API

duplicateDocument, deleteDocument, and friends.