CODEX // oaoisme wiki

§ unswayed-backend · Admin panel & RBAC (Phase 12)

Admin newsletters (Phase 15, UN-155)

updated 2026-06-22

Admin newsletters

Phase 15 · UN-155 · ADR-0044. Admins can author newsletters to distribute to platform users. This is full CRUD at /api/admin/newsletters, living in the admin catalog module (src/admin/catalog/) right next to the admin blog CRUD it's modelled on.

History: UN-155 was split out of UN-152, which originally bundled "admin newsletter + blog." The blog half already ships (see the admin blog CRUD); UN-152 is now blog-only, and UN-155 is this newsletter slice.

What it is

A new Newsletter Prisma model and a controller/service pair that mirror the admin blog module almost exactly:

Field Type Notes
name string required
image string required — a URL / storage reference, not an uploaded file
header string required
sub_header string? optional
description text required
tags string[] (JSON) required, non-empty
total_views int system-managed, defaults 0, never a client input
admin_id FK → admins server-stamped from the session; nullable + SET NULL

How it works

The controller (AdminNewslettersController, @Controller('admin/newsletters')) is guarded exactly like the rest of the back office: AdminAuthGuard + PermissionsGuard, per-verb @RequirePermissions, and @Audited('newsletter') so every mutation lands an AdminAuditLog row.

Verb Route Permission
GET /api/admin/newsletters any of show/create/edit/delete-newsletter
GET /api/admin/newsletters/:id any of show/create/edit/delete-newsletter
POST /api/admin/newsletters create-newsletter
PATCH /api/admin/newsletters/:id edit-newsletter
DELETE /api/admin/newsletters/:id delete-newsletter

The four permissions (show/create/edit/delete-newsletter) were added to the single permission catalogue (src/admin/rbac/permission.catalog.ts) — the same list that drives both the typed @RequirePermissions guard and the sync:permissions / seed:admin seeders, so there's no seeder-vs-guard drift. A Super Admin bypasses all of them by role name.

AdminNewslettersService follows the admin-blog discipline: standard repo pagination (?limit default 10 / max 100, newest first), an INT4-range-guarded findRow that 404s Newsletter not found. for missing / non-integer / out-of-range ids, setAuditBefore on update/delete so the interceptor records the prior row, and "only posted fields are written" on update (an omitted sub_header is left unchanged). create stamps adminId from the authenticated admin and lets total_views default to 0.

Why it's simpler than the blog

The admin blog uploads a thumbnail file to Cloudinary, with upload-first / compensate-on-failure / delete-old-after choreography. The newsletter's image is just a string (a URL or storage reference, per the ticket), so the newsletter service has no FileInterceptor, no MediaStoragePort, and no compensation logiccreate/update/delete are plain Prisma writes. tags is stored as a JSON string array (the EmployerJob.skills precedent).

Gotchas

  • admin_id is nullable + SET NULL. The ticket calls it "required," and the service always stamps it on create — but the column is nullable so a newsletter survives its author admin's deletion (the Blog.createdBy precedent). An orphaned newsletter serializes author: null.
  • total_views is never an input. It is not in the DTO; the global whitelist ValidationPipe would strip it anyway. It defaults to 0 and (for now) stays there — view counting is not part of UN-155.
  • POST is a JSON body, not multipart (because image is a string). Send Content-Type: application/json.
  • Out of scope (UN-155): newsletter publishing, scheduling, and distribution. This is authoring/CRUD only.