Naalya Handbook

Lesson Planning

Schemes of work and weekly lesson plans — Tiptap documents scoped from the parent scheme, draft/publish only, teaching-relationship access.

Teachers author a scheme of work per subject-stream-term — the term's roadmap — then hang weekly lesson plans off it. Both are rich-text Tiptap documents (Tiptap is the JSON-based editor format; see Templates for the schema) stored twice on the row: the editable content jsonb and a server-rendered contentHtml. Two statuses only, draft and published — there is no approval workflow, just a visibility switch.

Creating a scheme

SchemeOfWorkService.create walks a chain that's mostly about deriving scope rather than trusting it:

  1. Author gateassertCanAuthor: the caller must be a subject-teacher of that subject in that stream. Teaching is authorization; there's no admin bypass on writes.
  2. Scope derivation — the termId resolves the campus (term → cay → campusId), then the standard year gates: assertNotLocked + assertTermMatchesYear.
  3. Uniqueness — a partial-unique index allows one live scheme per (subject, stream, term); a second create is a 409. ("Live" = not soft-deleted, so deleting genuinely frees the slot.)
  4. Content source — the request supplies either templateId or content, never both. The template path calls templateService.resolveForInstantiation(templateId, TemplateType.SCHEME_OF_WORK) — published and type-matched or it throws — then copies content + contentHtml and stamps sourceTemplateId for provenance. The direct path validates the doc against the editor allowlist and renders the HTML. Neither given → EMPTY_TIPTAP_DOC.

Every scheme is born draft.

Lesson plans are scheme-first

A lesson plan is created under a scheme, and its scope — subject, stream, term, campus, campusAcademicYearId — is stamped from the parent scheme, never read from the client; a request whose campusAcademicYearId disagrees with the scheme's is rejected. What the client actually owns: weekNumber, title, optional plannedDate, and the document (same template-or-content rule). This is the same "derive, don't trust" move CBT makes with campusId — the parent is the source of truth for where a child lives.

Invariants that hold everywhere

  • contentHtml is regenerated on every content write — the server is the single writer of the HTML projection, so it can never drift from the JSON.
  • All mutations gate on the scheme's year being current and unlocked (assertSchemeYearIsCurrent via getOwnedScheme) — last year's plans are read-only history once the year locks.
  • Publish/unpublish are idempotent — they set/clear publishedAt, and re-publishing a published scheme is a no-op, not an error.
  • A scheme with lesson plans attached can't be deleted (409) — remove the plans first; deletes are soft.
  • duplicate copies a scheme into another stream (same subject + term) that the caller teaches; the target slot must be empty, and the copy starts as draft — a term's scheme fans out to sibling streams without re-typing.

Who sees what

Routes carry only @RequireUserType(STAFF) — no @RequirePermissions. LessonPlanningAccessService does the work, and the read side is a three-branch union:

ReaderSees
The authoring subject-teacherTheir own subject-stream pairs, any status
A class-teacher of the streamThat stream's documents, published only
Anyone with scoped SCHEME_OF_WORK / LESSON_PLAN read permissionPublished only — the ability is built on demand (hasScopedRead), since the routes themselves carry no permission decorator

List queries compose the same branches — and a "drafts only" filter quietly drops the two published-only branches, so drafts are visible strictly to their authors.

Audit snapshots exclude the document

lesson-planning.snapshots.ts allowlists scalar fields and records only contentBytes (the JSON's byte length) — full Tiptap docs never enter the audit trail, keeping audit rows small and the trail readable. Fed to @Audit via afterFrom + loadBefore over each service's findForAudit. Follow the same pattern for any new document-bearing resource.

Endpoints

ControllerRoutes
schemes-of-workPOST /, GET / (cursor-paginated, searches title), GET /:id, PATCH /:id, POST /:id/publish, POST /:id/unpublish, POST /:id/duplicate, DELETE /:id
lesson-plansSame shape minus duplicate

Where to go next

On this page