Naalya Handbook

Templates

Admin-authored Tiptap document templates for lesson planning — school-owned or platform built-ins, validated against a single editor schema.

A template is a pre-built Tiptap document a teacher instantiates instead of starting blank — "Term Scheme of Work", "Standard Lesson Plan". Two kinds share one table, split by a single column: school templates are ordinary tenant rows, while platform built-ins have schoolId IS NULL, are visible to every school, and are managed only by platform operators. That one nullable column does the work a whole second table would otherwise do.

ColumnNotes
schoolId (nullable)NULL = platform built-in, visible everywhere
typeSCHEME_OF_WORK or LESSON_PLAN — must match the document being created from it
statusdraft / published — only published templates can be instantiated
content / contentHtmlThe Tiptap doc + server-rendered HTML projection

One editor schema, everywhere

editor/editor-schema.ts is the single source of truth for what any document in the lesson-planning family may contain:

  • EDITOR_EXTENSIONS = Tiptap StarterKit (paragraphs, headings, lists, blockquote, code block, link, underline, bold/italic/strike…) plus non-resizable tables.
  • The allowed node and mark lists are derived from those extensions via getSchema(EDITOR_EXTENSIONS) — not hand-maintained. Add an extension and the allowlist grows with it; there is no second list to forget.

editor/tiptap-content.ts then enforces it on every write, in template, scheme, and lesson-plan services alike:

  • validateTiptapContent: root must be type: 'doc', the doc must fit 1 MiB (MAX_CONTENT_BYTES), and a full tree walk rejects any node or mark type outside the allowlist — an unknown type is a 400, not a silently-stored surprise.
  • renderContentHtml renders the HTML projection server-side with the exact same extensions (@tiptap/static-renderer) — which is why contentHtml can be trusted as a faithful, XSS-bounded rendering of the JSON.
  • EMPTY_TIPTAP_DOC ({ type: 'doc', content: [{ type: 'paragraph' }] }) is the canonical blank.

Keep the backend schema and the EH toolbar in lockstep

The Education Hub editor's toolbar must offer exactly what EDITOR_EXTENSIONS allows — the schema file's own comment says so. Add a Tiptap extension on one side without the other and users either lose a button or get their content rejected at save.

Instantiation

resolveForInstantiation(templateId, expectedType) is the one door consumers use (Lesson Planning is the only caller today). Three checks: the template must be visible (own school, or a built-in), published (drafts can't be instantiated), and type-matched (a scheme can't start from a lesson-plan template). The consumer then copies content + contentHtml and stamps sourceTemplateId — a copy, not a reference, so documents keep their provenance and their content even if the template is later edited or soft-deleted.

Two controllers, two audiences

ControllerWhoScope
templatesStaff — writes need MANAGE on Resource.TEMPLATE; reads carry no decorator (visibility is filtered in the service)School templates + published built-ins
platform/templatesPlatform admins (class-level MANAGE TEMPLATE)Built-ins only (schoolId IS NULL)

Read visibility is graded in the service: managers see their school's templates in any status plus published built-ins; non-managers see published only; a "drafts" query from a non-manager returns an empty page rather than a 403. The manager check builds the CASL ability on demand (hasManage) precisely because the read routes carry no @RequirePermissions.

Built-ins 404 through the school controller

School-side mutations resolve the row via the tenant-scoped repository, and a schoolId IS NULL row is invisible to it — so editing a built-in through templates/:id is a 404, by design. Built-ins are mutated only via platform/templates, which pins schoolId: IsNull() and bypasses tenant context.

Where to go next

On this page