<- All projects

homework-app (legacy)

A shared homework tracking website for schools and classes. Includes journals, a social feed, and scheduled notification reminders.

Next.jsTypeScriptTailwind CSSReactExpressMongoDBRedisRabbitMQRustMeilisearchMinIODocker
7 min readSource Live

Yes I know I'm a hypocrite. My philosophy is as simple as possible, but because I can and for experience, I want to use and practice different services.

What it is

A homework tracker for a class group, with journals, a social feed, and web push reminders that fire before things are due. Students post homework, teachers post announcements, everyone can comment, and the browser tells you about it without a refresh.

Live at homework.solartuff.co.id.

The repo is a monorepo of three deployable services and six backing services, wired together with Docker Compose. One LICENSE at the root covers all of it (GPL-3.0-or-later). That is a deliberate choice, not an accident of copy-pasting.

Architecture

ServiceTech
frontendNext.js 16, React 19, Tailwind 4, TypeScript
apiExpress 5, TypeScript, Mongoose, Zod
worker-rustRust, Tokio, Lapin, web-push
mongoMongoDB, replica set rs0
redisRedis, pub/sub and cache
rabbitmqRabbitMQ, job fanout
meilisearchMeilisearch, full text search over posts
minioS3-compatible object storage for uploads
cloudflaredCloudflare Tunnel, the only public ingress

The API is a plain layered Express app: routes -> middlewares -> controllers -> services, with Mongoose models at the bottom. Twelve routers, eleven models, and a validate.middleware wrapping the Zod schemas in src/schemas.

The replica set is not optional

Mongo runs as a single node replica set, initiated by a one-shot mongo-init-replica job on boot. That is not ceremony: creating a reply, joining a group, and changing a relationship each need to touch more than one collection atomically.

TypeScript
const session = await mongoose.startSession();
await session.withTransaction(async () => {
  // reply + vote counters, group membership + group counters
});

Standalone mongod cannot do that. A lot of self-hosted setups skip the replica set and then quietly lose data on a partial write.

Real-time is SSE, not WebSockets

Every write that should show up live does three things: save to Mongo, publish to a Redis channel, and return. It never pushes to the client directly.

TypeScript
await redisClient.publish('homework-updates', JSON.stringify({ action: 'create', payload: homework }));

The API holds a plain array of open text/event-stream responses, and every instance subscribes to the same three channels (homework-updates, post-events, journal-events) then fans out:

TypeScript
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders();

The browser side is a one-liner per view:

TSX
const eventSource = new EventSource(`${process.env.NEXT_PUBLIC_API_URL}/events`);

The reason to go through Redis is that in-memory clients arrays are per-process. Publishing to a channel means a write handled by replica A still reaches a browser connected to replica B. SSE also reconnects on its own and needs no library, no upgrade handshake, and no sticky sessions.

Auth is one JWT, verified twice

NextAuth v4 with the MongoDB adapter owns sessions in the frontend. The API does not re-implement login — it reads the same signed token with getToken() from next-auth/jwt, using the same AUTH_SECRET:

TypeScript
const token = await getToken({ req, secret: process.env.AUTH_SECRET, raw: true });
const decoded = await getToken({ req, secret: process.env.AUTH_SECRET });

if (decoded && decoded.sub) {
  req.user = { sub: decoded.sub, role: decoded.role || 'member' };
  return next();
}

protect and isAdmin are then ordinary middleware over the role claim. No second token format, no shared session table, and revoking a user is a matter of the existing admin tooling.

The worker: scheduling and push

Notification scheduling is the one part of this app that is genuinely awkward in Node, so it lives in Rust. Six queues and two exchanges:

Rust
pub(crate) struct Queues;
impl Queues {
    pub(crate) const HOMEWORK_CREATED: &'static str = "homework_created_queue";
    pub(crate) const PREFERENCE_CHANGED: &'static str = "preference_changed_queue";
    pub(crate) const HOMEWORK_DELETED: &'static str = "homework_deleted_queue";
    pub(crate) const HOMEWORK_DUE_NOTIFICATION: &'static str = "homework_due_notification_queue";
    pub(crate) const POST_FANOUT: &'static str = "post_fanout_queue";
    pub(crate) const POST_NOTIFICATION: &'static str = "post_notification_queue";
}

The API publishes and moves on. The worker consumes, and because queues are durable and messages are persistent, a worker restart during a burst of homework posts does not lose the reminders.

Scheduling is a materialized schedule, not a cron per item

Creating a homework item does not start a timer. It writes one ScheduledNotification document per subscribed user, with a deterministic id:

Rust
pub fn generate_job_id(homework_id: &str, pref_id: &str) -> String {
    format!("{}-{}", homework_id, pref_id)
}

homeworkId-prefId is unique, which is what makes the hard parts idempotent. Deleting the homework deletes those rows and cancels the jobs. Changing a notification preference fans out to every future homework for that user and reschedules. No timers to leak, nothing to reconcile on restart — the schedule is just rows, and an hourly node-cron job in the API sweeps the ones already sent.

VAPID signing

Push goes through web-push with isahc, and OpenSSL is built vendored so the container needs no system libssl:

Rust
let mut sig_builder = VapidSignatureBuilder::from_base64(&private_key, &subscription_info)?;
sig_builder.add_claim("sub", subject.as_str());
let signature = sig_builder.build()?;

Posts are indexed into Meilisearch rather than pattern-matched out of Mongo. Searchable on title and content, filterable on author, homework, parent, sortable by createdAt.

Index config is code, not a UI click, which means a fresh environment indexes correctly on first boot. When the index and the database drift, one command repairs it:

Shell
npm run make-admin --prefix api   # promote a user to admin
npm run sync:search --prefix api  # reindex Meilisearch from scratch

The grouper is a genetic algorithm

The seating-chart tool is a real GA, running in the browser, with the knobs exposed in a settings panel:

TypeScript
export type GAConfig = {
    popSize: number;
    mutationRate: number;
    elitismCount?: number;
    maxInstantGens?: number;
    visualDelayMs?: number;
    createInitialPop: (popSize: number) => number[][];
    calculateFitness: (genome: number[]) => number;
    mutate: (genome: number[], mutationRate: number) => number[];
    crossover: (p1: number[], p2: number[]) => number[];
};

The problem is NP-hard in practice — partitioning thirty students into tables so that friends sit together and enemies do not has no formula, so it gets evolved. Elitism keeps the top genomes, parents are drawn from the better half, and calculateFitness scores a layout on the constraints.

The first version froze the tab: thousands of generations ran in one tick and the UI died. The fix was two modes. runVisual steps a generation every visualDelayMs so you can watch the seating rearrange itself, and an instant mode batches up to maxInstantGens per frame for when you just want the answer. Same algorithm, same result, different pacing.

Markdown, end to end

Journals and posts are authored in a Monaco-backed editor and rendered by a hand-built pipeline rather than a WYSIWYG:

TSX
<ReactMarkdown
  remarkPlugins={[remarkGfm, remarkMath, remarkBreaks, remarkDirective]}
  rehypePlugins={[
    rehypeKatex, rehypeSlug, rehypeAutolinkHeadings,
    rehypeExternalLinks, rehypeRaw,
  ]}
>

rehype-katex renders the maths, rehype-slug plus rehype-autolink-headings give every section an anchor and a permalink, and rehype-raw means pasted HTML survives the round trip.

Code fences are the part I care about most, because syntax highlighting is most of what makes a code-heavy post readable. Prism with vscDarkPlus and oneLight, swapped off next-themes, a copy button, and the language label resolved from a generated GitHub Linguist extension map — so ts shows as TypeScript and not as "ts".

TSX
const match = /language-(\w+)/.exec(className);
const language = match ? match[1] : 'plaintext';
const languageName = Linguist.get(language);

The map is generated, not hand-maintained, which is the only reason it stays correct as languages are added.

Uploads

Files go to MinIO through the minio client and are served from storage.solartuff.co.id. The bucket and its public read policy are created by a minio-setup one-shot job, so a blank volume still comes up working.

Development and production are different files

docker-compose.dev.yml runs the six backing services with ports published to the host, so the API and frontend run on the host with fast reload. docker-compose.yml runs all eleven services on an internal app_net bridge, where nothing is published at all:

Shell
docker compose -f docker-compose.dev.yml up -d   # infra only, ports open
cd api && npm install && npm run dev
cd frontend && npm install && npm run dev
Shell
docker compose up -d                          # everything, no public ports

The only thing crossing the boundary is cloudflared, so the host needs no open ports and no certificates. The tradeoff is that the tunnel is now a single point of failure for the whole app — if the tunnel container dies, the app is gone, regardless of how healthy everything else is.

By the numbers

Frontend TS/TSX files116
Frontend lines~10,100
API TS files54
API lines~3,200
Worker Rust files9
Worker Rust lines~780
Mongoose models11
API routers12
Frontend pages21
RabbitMQ queues6
Backing services6
Compose services11
Commits53
EditionsTypeScript 6, Rust 2024

What I'd do differently

Socket.IO is a dependency and never used. It is in api/package.json from an earlier design. The real-time layer ended up as hand-rolled SSE on top of Redis pub/sub, which is the right call, so the dependency should go.

There is no test suite. npm test in the API is still the npm init stub. The scheduling logic in scheduler.rs — preference offsets, cancelled jobs, the homeworkId-prefId idempotency key — is exactly the kind of code that should have unit tests, and it is the code I trust least.

Notifications are one collection that gets swept hourly. It works, and the sweep is cheap, but a Mongo TTL index on sendAt would let the database do the deleting instead of a cron job that has to be deployed and monitored.

The design has no token layer, and it shows. Every color is a raw Tailwind palette step picked at the call site — neutral-400 for one muted label, neutral-500 for the next, neutral-600 for a third, with gray- and zinc- mixed in next to neutral- for no reason. All eleven of the eleven neutral steps are in use. Dark mode is a hand-rolled @custom-variant plus dark: utilities repeated across 44 components, and :root defines exactly two variables, neither with a dark counterpart. There is no single place to change the palette, which is what "coherent" would have meant.