Rapor Biru LMS
superadmin@rapor-biru.devsuperadmin123Rapor Biru is an LMS I built solo for schools and training programs that are currently running their academic operations across a patchwork of Notion, Canvas, Coda, and Google Suite. The goal is to pull all of that into one place.
Everything is organized around a Program → Course → (Materials / Sessions / Assignments) hierarchy, with separated roles for students, instructors, and admins. Built with a handful of decisions run through the whole build, from invite-only onboarding with token blacklisting, shared CRUD forms that adapt to context, squeezing 100% deploy uptime out of a 2-core VPS with Docker Swarm in orther for them to come back to the same idea: Get Out Fast. The LMS should be scaffolding for learning, not something people have to tinker. That's also where the roadmap is headed: a careful, incremental move such as migrating to Go as backend for more concurrency, and CI/CD so deploys stop being the bottleneck.
The Problem
Most education institutions end up duct-taping together a toolchain that looks something like this:
| Need | Common tool used today |
|---|---|
| Course content / learning materials | Notion, Canvas |
| Assignments & submissions | Canvas, Notion |
| Attendance tracking | Google Sheets / Forms |
| Program & enrollment management | Coda, spreadsheets |
| Certificates of completion | Manual/ad-hoc |
Each of these tools is fine on its own, but the institution pays for it in integration tax, data scattered across silos, constant context-switching between apps, and no single source of truth for a student's academic record.
Rapor Biru's Bet
Is to replace that fragmented stack with one purpose-built system. We are starting with the workflows institutions live in every day (assignment submission and content delivery, which currently means replacing Canvas and Notion), then expanding from there.
Scope & Domain
The data model mirrors how an institution actually organizes itself:
Program (e.g. "Computer Science Cohort 2026")
├─ Courses (e.g. "Intro to Algorithms")
│ ├─ Learning Materials (video, pdf, article, slides)
│ ├─ Class Sessions (scheduled / ongoing / completed / cancelled)
│ │ └─ Class Attendance (present / absent / late / excused / unverified)
│ └─ Assignments
│ └─ Assignment Submissions (notSubmitted / submitted / graded)
├─ Program Enrollments (pending / enrolled / completed / dropped)
└─ Certificates (in progress) A few choices worth explaining:
Separate Profile, StudentProfile, and InstructorProfile
Instead of one bloated User table full of nullable fields for every role,
identity (auth, shared profile info) is split from role-specific academic data such as NIM,
GPA, and academic status for students; employee ID and specialization for instructors. Keeps User/Profile lean and lets each role's data evolve on its own.
Three roles, minimal surface area
student, instructor, admin. No attempt at a complex
permission matrix up front — these three map directly onto the real people using the system.
Soft deletes (deletedAt) across core entities
Users, programs, courses are get archived rather than deleted, so academic history stays intact. Once certificates and transcripts depend on historical enrollment and attendance data, we can't afford to actually delete anything.
Feature completion status (as of this write-up)
- ✅ Programs, courses, enrollments, attendance, assignments, submissions — done
- 🚧 Certificates — in progress
Key Decisions
Each one below follows the same shape: what constraint forced the decision, what I chose, what the alternative was, and what it cost.
Invite-only onboarding (no self-service signup)
| Constraint | Decision |
|---|---|
| Institutions need to control who joins. A public signup form invites spam accounts, identity ambiguity, or malicious account | No traditional "register with email + password" flow. An admin issues an invitation
with an inviteToken plus inviteTokenExpiresAt on the User record. Then the invited user uses that token to set a password and fill
in their profile for the first time. |
Security follow-through
Once the password is set, the invite token gets blacklisted immediately (token_blacklist table). Without that, a second person who gets hold of the same invite link, forwarded email,
shared link, whatever could overwrite the credentials of an account someone else already claimed.
Tradeoff
Admins are now a manual bottleneck for onboarding since no self-signup growth loop. I'm fine with that: schools and training cohorts already work from a closed, known roster of students and staff, so controlled onboarding is a feature here, not a limitation.
Implementation Walkthrough: Invite-Only Onboarding
1. Admin adds a user and issue an invite, don't set a password
UsersService.createWithInvite creates the account in an invited state with a time-boxed
token with no password yet, the user sets their own through the invite link.
// users.service.ts
async createWithInvite(dto: CreateUserDto) {
const inviteToken = crypto.randomUUID();
const inviteTokenExpiresAt = new Date(Date.now() + 48 * 60 * 60 * 1000); // 48h window
const user = await this.prisma.user.create({
data: {
...dto,
inviteToken,
inviteTokenExpiresAt,
status: 'invited',
},
});
return { user, inviteToken };
}Why: separating "account exists" from "account usable" avoids the classic
anti-pattern of admins setting temporary passwords which tend to be weak, reused, or shared
over chat. The 48h expiry on inviteTokenExpiresAt also caps how long an unclaimed invite
stays valid.
2. User sets their password to validate the invite token
AuthService.setPassword is the public endpoint the invite link points to, and it runs
three independent checks before activation.
// auth.service.ts
async setPassword(dto: SetPasswordDto) {
const user = await this.usersService.findByInviteToken(dto.inviteToken);
if (!user) throw new BadRequestException('Invalid invite token');
if (user.status !== 'invited')
throw new BadRequestException('User is not in invited state');
if (!user.inviteTokenExpiresAt || user.inviteTokenExpiresAt < new Date()) {
throw new BadRequestException('Invite token has expired');
}
const hashedPassword = await bcrypt.hash(dto.password, BCRYPT_COST);
await this.usersService.activateUser(user.id, hashedPassword);
return { message: 'Password set successfully. You can now log in.' };
}Why: each check fails for a different reason such as unknown token,
already-activated account (replay protection), expired window but they all return the same BadRequestException shape. The token is single-use: activateUser clears inviteToken/inviteTokenExpiresAt on success, so it can't be replayed even inside its validity window. Same BCRYPT_COST = 12 as the password-reset path, for consistency.
3. User completes their profile with idempotent upsert keyed on identity, not record ID
After first login, the user fills in their profile name, bio, etc via complete-profile. ProfilesService.upsertByUserId is used instead of a
plain create, because at this point the service genuinely doesn't know (and shouldn't care) whether
a blank profile row already exists.
// profiles.service.ts
async upsertByUserId(userId: string, dto: UpdateProfileDto): Promise<ResponseProfileDto> {
const result = await this.profilesRepository.upsertByUserId(userId, dto);
return this.toDto(result);
}
async findByUserId(userId: string): Promise<ResponseProfileDto> {
const profile = await this.profilesRepository.findByUserIdWithStudentProfile(userId);
return this.toDto(ensureFound(profile, `Profile for user ${userId} not found`));
}Why: keying the upsert on userId (a stable identity from the
JWT), not a profile record ID, removes a whole class of "create vs update" branching from the
controller while the frontend calls the same endpoint whether it's the user's first save or
their hundredth edit. toDto with excludeExtraneousValues also makes sure
the response only ever exposes the profile fields the frontend should see, no matter what's actually
in the row.
Context-aware shared CRUD components ("Get Out Fast" applied to UI)
| Constraint | Decision |
|---|---|
The same entity a ClassSession, a LearningMaterial can legitimately
get created from more than one place: a global resource pool, or from inside the specific
course/program it belongs to. A separate form for each entry point would mean more maintenance
and UX drift between them. | One shared component handles create/edit for each entity type, and auto-fills contextual fields based on where it's opened from. Create a class session from inside a program page, and it won't ask "which program?" that's already known, so the field is pre-filled and hidden. |
Why this matters for "Get Out Fast"
Every field a user doesn't have to fill is a few seconds saved and one less decision. Across hundreds of CRUD actions per term, that adds up and it ties directly into the time-to-complete-actions metric this whole project is judged on.
Tradeoff
The shared component has to handle multiple modes (context-prefilled vs standalone), so it carries more branching than a one-form-per-context approach would. I'd rather have that than duplicated forms those tend to drift out of sync and double the surface area every time a field changes.
Infrastructure: Docker Swarm to use the whole VPS
| Constraint | Decision |
|---|---|
| The VPS has 2 cores and 2GB RAM. A single Node.js process is single-threaded for CPU-bound work, so running just one instance leaves a core idle and gives every deployment a single point of failure. | Deploy the NestJS backend as a Docker Swarm stack with 3 replicas behind nginx, certbot handling TLS. Swarm's rolling-update model rolls deployments through replicas one at a time. |
Outcome
100% uptime through deployments because there's always at least one healthy replica serving traffic while another updates. Got there on modest hardware (2 cores/2GB) by squeezing more out of what's there instead of paying for bigger boxes.
Tradeoff
Three replicas on 2GB RAM is tight. It works for now because there's no real traffic yet. It buys 100% deploy uptime today, but it's a stopgap, not a long-term answer.
Planned NestJS → Go rewrite
| Constraint | Decision |
|---|---|
| NestJS/Node's single-threaded event loop puts a ceiling on CPU-bound and highly concurrent workloads. As the platform grows toward "as many users as possible," request concurrency and multi-core use become the limiting factor, not just RAM. | Rewrite the backend in Go (chi router, pgx, sqlc for typed queries), in phases, reusing the existing PostgreSQL schema so the database doesn't move. Phase 1 stays deliberately small to scaffold plus auth and users in order to prove out the pattern before porting the remaining ~10 modules one at a time. |
Why incremental
A full rewrite is too risky for a solo developer. Porting one module at a time, starting with auth/users since everything else depends on them, means the NestJS backend keeps running in production while the Go version gets built and verified module by module, with parity checks against the existing endpoints before each cutover.
Status
Still in planning/scaffolding, but nothing in code yet as of this writing.
Frontend: Carbon Design System
| Constraint | Decision |
|---|---|
| Solo developer, no design team. Building a custom design system from scratch is a big time sink for low payoff, especially for an internal/institutional tool where brand differentiation isn't the point. | Use IBM's Carbon Design System, which is opinionated, structurally consistent, accessible out of the box, and open source. |
Tradeoff
Less visual differentiation, less branding flexibility. That's fine, because the product's value is workflow consolidation, not visual identity, and Carbon cuts down on UI decisions I'd otherwise have to make myself. Get Out Fast, applied to building the thing, not just using it.
Biggest Operational Challenge: CI/CD Across Three Platforms
The infrastructure is spread across three independent platforms:
- VPS (Docker Swarm) for the backend
- Supabase for the PostgreSQL database
- Netlify for the frontend
Today, backend deployment is fully manual:
docker buildx build --platform linux/amd64 -t denidarta/rapor-biru-backend:latest --push ./crack-be-denidarta
ssh vps
docker pull denidarta/rapor-biru-backend:latest
export $(cat backend.env | xargs) && docker stack deploy -c docker-compose.yml raporThis is the biggest obstacle in solo development right now, not a code problem but a coordination one. Three platforms, independent release cadences, no shared pipeline, and every deploy is a manual, sequential, error-prone slog. Known gap, obvious candidate for GitHub Actions once the backend rewrite settles down enough to have a stable deployment target.
Success Metrics
The platform is pre-launch, so success right now is framed around two forward-looking signals rather than usage numbers:
- Fewer bugs, since the reliability bar has to clear before anyone trusts this with real academic data.
- Time to complete actions, which is the direct measure of "Get Out Fast." If a workflow that used to span three tools (post an assignment in Canvas, track submissions in a spreadsheet, announce it in Notion) now takes one action in Rapor Biru, that's the product doing its job.
Timeline & Roadmap
- ~3 months of solo work to get to the current MVP, and programs, courses, enrollments, attendance, assignments, and submissions are all done.
- In progress: Certificates, which are proof of completion and passing assignments, something students can actually point to and share (fitting, given "Rapor Biru" basically means flying colors).
- Planned:
- Go backend rewrite (phased, module-by-module, starting with auth + users)
- CI/CD automation across VPS / Supabase / Netlify
- Pilot launch with a real education institution
Summary
Rapor Biru pulls the fragmented academic stack, namely Notion, Canvas, Coda, Google Suite, into one LMS built around a Program → Course → (Materials / Sessions / Assignments) hierarchy, with separate roles for students, instructors, and admins. The decisions that shaped it, including invite-only onboarding with token blacklisting, shared CRUD components that adapt to context, and getting 100% deploy uptime out of a 2-core VPS via Docker Swarm, all trace back to the same idea: Get Out Fast. The tool should disappear into the background of learning, not become another thing people have to manage. The roadmap follows the same logic: an incremental move to a Go backend for more concurrency, and CI/CD to remove the project's biggest remaining operational drag.