§ unswayed-backend
Admin panel extras (Phase 19)
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-materialsand 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.