Skip to main content

05 / WORK / CASE STUDY

NKUST Alumni Association Platform

The official alumni site — member directory, alumni companies and job board. In development since 2024, now running on the university domain.

TYPE
FULL STACK
SCOPE
In production · full stack + ops
STACK
DJANGO · DRF · MYSQL · REDIS · REACT
LINKS
LIVE

01 / PROBLEM

The alumni association had no unified platform: rosters, partner companies, job posts and announcements were scattered and manually maintained. This system folds 'alumni self-service → admin review → public display' and company self-service job/product listings into one platform, plus public pages for distinguished alumni and the department.

02 / CONSTRAINTS

  • Public on a university domain, holding alumni PII under data-protection law — security expectations are high.
  • Single-server deployment (nginx + gunicorn), no orchestration; ops, security and UX audits all fall on one person.
  • The frontend is CRA (no SSR), yet an alumni site must be indexable by search engines.

03 / ARCHITECTURE

A Django 5 + DRF monolith (11 business apps, 39 models, 38 resource routes plus 118 custom actions) with a React 18 SPA. Authorization is deny-by-default — IsAuthenticated globally, public endpoints explicitly opted in; JWT rides in HttpOnly cookies; Redis caches hot queries.

A custom monitoring subsystem: middleware writes requests, errors and performance metrics into seven models, GeoIP tags origin countries, anomalies alert by email; logs rotate daily with gzip and a one-year retention — recording continuously since May 2025.

04 / RESPONSIBILITIES

Independent full-stack development and operations since 2024/08 (169 commits across both repos): data models, APIs, security hardening, the React frontend, deployment and monitoring.

05 / CHALLENGES → SOLUTIONS

CHALLENGE

A pre-launch security review surfaced serious flaws: privilege escalation via the registration API, serializer mass assignment, and member PII enumerable through sequential IDs.

SOLUTION

The root cause was global AllowAny with per-endpoint locking. Authorization was flipped to deny-by-default and a numbered program (SEC-001–030) fixed items one by one — field whitelists, owner filtering, sensitive-field redaction, uniform responses against enumeration. 29 fixes landed, backed by privilege/IDOR regression tests (7/7 passing).

CHALLENGE

A CRA SPA is hostile to crawlers by default, and off-the-shelf prerenderers shipped a puppeteer too old to use.

SOLUTION

Wrote a custom prerender script (puppeteer + Chrome for Testing): after build it renders 14 public routes back to static HTML, wired into postbuild; the backend serves a dynamic sitemap — fixing, along the way, a field bug that had it returning 500s.

CHALLENGE

No budget for an APM, yet a public site needs to know who is hitting it and what is slowing down.

SOLUTION

Built it in: middleware logs requests/errors/perf, GeoIP tags origins, scoped throttling (login 10/min, registration 10/hr) blocks abuse. The daily-rotated logs accumulating since May 2025 are direct evidence of real operation.

06 / SYSTEM FACTS

11

business apps

100+

API endpoints

30

audit findings (29 fixed)

43

Playwright E2E specs

07 / LESSONS

Start from default-deny. Global AllowAny with per-endpoint locking opened a whole attack surface — remediating before launch cost far more than doing it right from the start.

CRA plus a hand-rolled prerenderer works, but it is fragile — routes are maintained by hand and pinned to a Chrome build. Next time I would pick a framework with native SSR/SSG and delete the whole pipeline.

08 / SCREENS

All screens below show demo (mock) data — no real user data.

Alumni company search: keyword bar, industry filter chips, result count and a grid/list toggle above the company cards
Alumni company search. The industry chips come from the company_industry table rather than a hard-coded list; the card imagery is synthetic, generated for this demo run — real company photos stay in the production database.
The public job board in table view — position, company, posted date and deadline columns, with a table/card view switch above
The public job board. Its endpoint reads only active=True rows; the deadline is stored and rendered, yet no queryset filters on it — an expired post stays visible until a human unpublishes it. A gap in the design, stated as such.
The same job board at a 390px phone width: the table re-flows into single-column cards and the navigation collapses into a hamburger
The same page on a 390px phone, where the table becomes single-column cards. This is a full-page capture, so it runs far longer than one phone screen.
The admin job-management page: total and filtered counts, a publisher filter, and a cross-company table of postings with view, edit and delete actions
Admin job management: every company's postings in one table, with create, edit, unpublish and export. This path is gated by IsAdminOrSuperUser and lives on endpoints separate from the member path, where an alumnus can only touch their own company's posts.
The distinguished-alumni admin page: sort number, name, summary, a "show on site" switch, up/down reordering and edit/delete controls
Distinguished-alumni management. There is no nomination or review workflow in the code — staff create the rows directly, then is_featured and sort_order decide what the public page shows and in what order. Batch reordering runs as one bulk_update inside a transaction rather than a save() per row.
The same distinguished-alumni admin page on a 390px phone: each row becomes a label-and-value card, keeping the switch, reordering and edit/delete controls
The same admin table on a phone, re-laid out field by field with every control kept. The responsive work was not limited to the public pages — the back office got it too.

09 / ARCHITECTURE DIAGRAMS

Every diagram below is derived from the project's actual source; each node traces back to a real file. They are wide — scroll sideways, or click to open the full-size SVG.

Write-paths and public-visibility flowchart: the endpoints each of the three caller roles can reach, the privileged fields the serializers strip, and the exact filters that decide what an anonymous reader sees
Who may write what, and what the public endpoints actually return. The site's entire outward gate turns out to be a few booleans: active for articles and jobs, is_show for the directory, is_featured for distinguished alumni. The note bottom-right is the part worth reading — publish_at, expire_at and deadline are all stored and serialized, and no query uses any of them.
Sequence diagram of login and an authenticated call: nginx, the monitoring middleware IP block-gate, cookie-JWT authentication with its CSRF check, the DRF view, and the audit writes that go out on thread pools
One request from nginx through to its audit rows. The IP block-gate rejects before the view ever runs; the cookie path enforces CSRF while the legacy Bearer-header path skips it. The note at the bottom is an honest gap: every writer passes request_log=None, so the foreign key from query_logs and crud_logs back to request_logs is modelled and never populated.
Deployment and runtime architecture: nginx proxying to gunicorn (3 workers), the middleware chain in declaration order, the deny-by-default DRF layer and 11 Django apps, plus MySQL, Redis, GeoIP, rotating logs and the mail providers
The whole runtime of a single-server deployment, with the middleware in the order settings.py actually declares. The note bottom-right states the truth of the system: there is no broker — celery.py is a 0-byte file, and every async job runs on in-process ThreadPoolExecutors (4 for monitoring, 2 for the audit log, 5 for article images) plus a threading.Thread for mail. whitenoise sits in requirements.txt and was never wired into MIDDLEWARE.
Alumni account state diagram: three provisioning paths, login and logout, the password-reset code, deactivation and deletion, each transition labelled with its endpoint and permission
The account state machine, from three provisioning paths (self-registration, single admin create, Excel bulk import) through deactivation and deletion. Deactivating does more than flip is_active: inside transaction.atomic with select_for_update it blacklists every OutstandingToken the user holds. And on an existing account, exactly one endpoint may still change is_staff or is_superuser — the shape the security remediation left behind.
Core domain ER diagram drawn with the real MySQL table names — alumni, companies, products, job posts, articles and the audit log, with their relations, key columns and index names
The core domain, drawn with the real table and index names rather than model class names. It also draws one piece of history as-is: the product and picture apps each define a model called ProductImage, with differently shaped tables. Monitoring tables other than crud_logs are deliberately left out.

NEXT

NKUST Equipment Borrowing System