CODEX // oaoisme wiki

§ unswayed-backend

Admin panel extras (Phase 19)

updated 2026-06-25

Admin panel extras (Phase 19 — UN-162, UN-163)

Phase 19 adds two back-office features: Learn Materials (admin-managed learning content shown to applicants) and a Disputes & Feedback hub (users submit, admins respond/resolve). The theme of the phase is fitting new features onto the surfaces that already exist rather than inventing parallel ones — the design decisions are recorded in ADR-0049.


The surface decision

The Jira tickets wrote the admin endpoints as /api/v1/admin/... and the user endpoints as /api/v1/.... But the codebase already has a mature admin back office at /api/admin/* (the Phase-12 surface: a separate admins identity with its own JWT, an RBAC permission catalogue, an audit interceptor, and the standard {status,message,data} envelope), and a frozen public feedback collection at /api/feedback (§27). So Phase 19 puts everything on the existing surfaces:

  • Admin endpoints live on /api/admin/learn-materials, /api/admin/feedback, /api/admin/disputes — mirroring the admin-blog module exactly.
  • The applicant Learn read is /api/applicant/learn-materials and the dispute submit is /api/disputes — keeping each feature on one consistent (/api/*, snake_case) surface.

One correction worth knowing: the tickets say the created_by/responded_by columns are foreign keys "→ users". They're not — admin-authored content references the separate admins table (Int?, ON DELETE SET NULL), exactly like Blog.createdBy. The end user is a User (uuid); the admin is an Admin (int). They are different identities and must not be conflated.


UN-162 — Learn Materials

A LearnMaterial has a title, a short description (≤300 chars), a difficulty (Beginner|Intermediate|Advanced), an icon, a category, and an optional PDF. It starts unpublished; an admin must explicitly publish it before applicants can see it.

The admin CRUD (src/learn-materials/) is a faithful copy of the admin-blog pattern: the controller carries @UseGuards(AdminAuthGuard, PermissionsGuard), @UseInterceptors(AdminAuditInterceptor), and @Audited('learn_material'); each handler declares its @RequirePermissions(...) from the four new catalogue entries (show/create/edit/delete-learn-material); and created_by is stamped from the acting admin via @CurrentAdmin().

The PDF upload reuses the blog-thumbnail discipline. The multer fileSize limit is set just above the real 20 MB cap, so a 20–22 MB file still reaches the validator and gets a clean keyed-422 ({ errors: { pdf_doc: [...] } }), while anything larger is cut off by multer as a 413. The file is uploaded to storage before the DB write (with a compensating delete if the write fails), and on update the old asset is deleted only after a successful replace — so a failed operation never orphans an asset. PDFs must be uploaded with Cloudinary resource_type: 'raw', so the service passes resourceType: 'raw' explicitly.

The applicant read (GET /api/applicant/learn-materials) is a plain published-only query (isPublished: true) with category/difficulty filters and pagination.


UN-163 — Disputes & Feedback

Extend, don't duplicate

A Feedback model already existed (Phase 11, §27). UN-163's "feedback" wanted a status flow and an admin response. Rather than create a second table, the existing model was extended with nullable columns: user_role, category, status (default pending), admin_response, responded_by (→ admins), responded_at. Because every new column is nullable or defaulted, the frozen §27 write path keeps working untouched — it simply never sets them. The §27 GET /api/feedback (which returns a bare Laravel {data, links, meta} body) and its resource are left byte-for-byte unchanged; the admin read uses a separate AdminFeedbackResource that exposes the new fields. (rating already existed on the model, so it wasn't re-added.)

Submit, respond, notify

Users submit feedback through the (now extended) §27 POST /api/feedback — it gains an optional category and derives the submitter's user_role. Disputes are submitted through a new POST /api/disputes (open to both applicants and employers). Both submit endpoints are rate-limited to 5 per user per 24 hours using the Lenux @RateLimit guard (which was already proven reusable on non-Lenux end-user routes and returns a consistent 429 body).

Admins list/filter, read, and respond through /api/admin/{feedback,disputes}. The respond endpoint updates the response text and status atomically and then — best-effort, post-commit — notifies the submitter via the notification service (new feedback_response / dispute_response types). Disputes also have a separate PATCH /:id/priority.


A wiring bug the unit tests couldn't catch

When the three slices were integrated, the app failed to boot: the FeedbackModule had gained the new AdminFeedbackController (which uses the admin audit interceptor) but had not imported AdminCoreModule — the non-@Global module that provides the admin guards and the AdminAuditLogService. Nest couldn't resolve the dependency, so the whole graph failed to instantiate.

The instructive part is why the unit tests stayed green. The feedback module's spec only asserts the module's static decorator metadata via Reflect.getMetadata(...) — it never calls compile() on the DI graph. So a missing import is invisible to it. The defect only surfaced when the real AppModule was instantiated, which is exactly what the end-to-end test does at boot. The lesson: a metadata-only module spec verifies intent, not wiring — and cross-module DI wiring (especially imports of non-global modules) needs an e2e boot test to be trusted. The two sibling modules (learn-materials, disputes) had imported AdminCoreModule correctly; the fix was a one-line import on the feedback module.