Introduction
Recently, in an interview, I presented my vision for an AI-powered School Management System (SMS). My focus was on how Large Language Models (LLMs) could transform user experience and automate workflows. But after the interview, I realized something crucial: AI is only as good as the foundation it’s built on. If your data, user flows, and backend are messy, your AI will be too—garbage in, garbage out.
So for my portfolio, I set out to build the SMS the right way, from the ground up. And make sure i have enough metadata from users, activities to feed my llms. Mind you this data could be from forms, user activity or even prompts and RAG.
Building a Strong Foundation
My Strong Foundation includes
- Database design: Sets the schema, what data do you have this leads to clear, normalized tables.
- REST APIs: Well-structured endpoints for every entity.
- CRUD Layer: Shared database logic that keeps routes clean and modular.
- User flows: Clean login, dashboard, and management screens. works well with good UI/UX team. (i didnt really think much about this to be honest)
Database design: Why an ERD (Entity Relationship Diagram) Matters
A solid ERD is the backbone of any complex System. It defines how users, classes, students, guardians, and other entities relate to each other. This structure ensures your data is consistent and easy to query—crucial for both traditional features and AI-powered insights.
Example: PostgreSQL Entities
# filepath: app/db/models.py
class User(Base):
__tablename__ = "users"
id = Column(UUID, primary_key=True, default=uuid.uuid4)
first_name = Column(String)
last_name = Column(String)
email = Column(String, unique=True, index=True)
role = Column(String)
password_hash = Column(String)
# ...other fields...
class Class(Base):
__tablename__ = "classes"
id = Column(UUID, primary_key=True, default=uuid.uuid4)
name = Column(String)
subject = Column(String)
teacher_id = Column(UUID, ForeignKey("users.id"))
# ...other fields...
Although I’ve designed a complex version, I decided to build out this lean prototype ERD first. It gives me a solid foundation to test features quickly, iterate fast, and make smarter architectural decisions once the system is in motion.
Basic ERD Example:
User (id, first_name, last_name, email, role, password_hash)
├── A user can be a teacher, student, admin, or guardian (via the `role` field)
|
├──< Class (id, name, subject, teacher_id → User.id)
| • Each class is taught by one teacher
|
├──< StudentClass (student_id → User.id, class_id → Class.id)
| • Many-to-many between students and classes
|
└──< GuardianStudent (guardian_id → User.id, student_id → User.id)
• Many-to-many between guardians and students
This can get more complex with more entities such as quizzes, leaderboards, peer budy concepts payment information and profiles. These will become clearer to me after some research and what type of AI models i might use. So stay tuned for my next Blog
REST API Design
Every main entity gets its own set of RESTful endpoints. This makes the system modular and easy to extend.
Example: Admin REST APIs
# filepath: app/api/admin.py
@router.get("/admin/users")
async def get_all_users():
# Returns all users
@router.post("/admin/users")
async def create_user(user: UserCreate):
# Creates a new user
@router.get("/admin/classes")
async def get_all_classes():
# Returns all classes
CRUD Layer
Instead of putting raw SQL or ORM queries in every route, we use crud.user.get_multi, crud.user.create, crud.user.count, etc
class CRUDUser(CRUDBase):
"""CRUD operations for User model."""
async def get_by_email(self, db: AsyncSession, *, email: str) -> Optional[User]:
"""Get user by email."""
result = await db.execute(select(User).where(User.email == email))
return result.scalar_one_or_none()
async def create(self, db: AsyncSession, *, obj_in: UserCreate) -> User:
"""Create a new user."""
# Check if user already exists
existing_user = await self.get_by_email(db, email=obj_in.email)
if existing_user:
raise UserAlreadyExistsException(obj_in.email)
Then in your FastAPi route you can do this
@router.post("/admin/users", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
async def create_user(
user_in: UserCreate,
db: AsyncSession = Depends(get_db),
current_user = Depends(require_admin)
):
"""
Create a new user (admin only).
"""
try:
user = await crud_user.create(db, obj_in=user_in)
return UserResponse.model_validate(user)
except UserAlreadyExistsException as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"User with email {e.email} already exists"
)
Reproducible Dev Setup: Demo Users + Automation
When you're working on a team or just revisiting your own code after a week you don't want to waste time trying to remember what the test accounts password was. I needed a way to spin up the app and instantly have working demo credentials for different roles (admin, teacher, student, guardian). Also for anyone looking to contribute to this project they can easily run using Docker from a shell script.
- Scripts for demo credentials: Now you could easily login and test dashboards and other functionalities. Hence easily contribute.
- Automated Scripts for user creation: For new contributors, the shell script automatically runs a demo user creation script and verifies everything works. It checks if the demo users already exist, creates any missing ones, and tests that all roles (admin, teacher, student, etc.) can log in successfully
Example: Smart Demo User Creation Script
# filepath: scripts/create_demo_users.py
async def create_demo_users():
# ...existing code...
existing_users, missing_users = await check_existing_users()
if not missing_users:
print("✓ All demo users already exist!")
return
# ...create only missing users...
Example: Automated Testing in start-dev.bat
echo Step 3: Testing existing demo credentials...
docker exec sms-app-1 python /app/scripts/test_demo_credentials.py
echo Step 4: Creating missing demo users...
docker exec sms-app-1 python /app/scripts/create_demo_users.py
echo Step 5: Testing all demo credentials again...
docker exec sms-app-1 python /app/scripts/test_demo_credentials.py
Phase 1 Complete: Ready for the Admin Dashboard
With everything in place— Docker, Database, REST APIs, and demo scripts. I’m ready to build out the admin dashboard and connect the UI to the backend.
Next Steps:
- Build out pages and dashboards
- Connect and test REST APIs from the frontend
- Architect the AI layer once the traditional user flow is solid.
Ai Helpers: The Frustration of Context-Less Coding
I started out using ChatGPT 4o and Claude 4.1(Copilot) for pair programming. But I quickly hit a wall. These tools are great at generating code, but they often lose context—forgetting what’s already been built, duplicating files, or overcomplicating simple tasks. I found myself trusting the AI too much, not double-checking, and getting lost in circular logic.
Quick Fix:
Most of the friction i had with aget mode was due to poor naming of my files. For example, I used app
for my backend and web
for my frontend. This led to confusion in Docker, scripts, and even in my own mental model.
Lesson: Use clear, conventional names like backend
and frontend
to avoid headaches. A clean skeleton goes a long way specially for your AI assistant. (Sadly i havent changed this yet)
Cleaning Up: A Collaborative Approach
I decided to start fresh. I cleaned all my Docker containers, revised my flow, and worked collaboratively with my AI assistant—but with me in control. We reviewed every file, every script, and every config. Here’s what worked:
Switched to Ask Mode
Agent mode is great, but it can go off the rails and leave you with alot of bugs. Switching to Ask Mode made me the human in the loop. I was able to review code and test them before i kept them. It also made me review my ideas more. I also realized that i needed to be strict and start applying BDD and TDD principles because this was generally what i was doing, but not the right way. (i know these are good principles, but i honestly wont have the time, juggling dissertation and job hunt. If you want to hire me heres my email. kevinocansey11@gmail.com)
Providing rest Api docs for quick Frontend creation
One bad habit i had was to send my copilot images of how i want my interface to look like. But now i try to focus on one page at a time and provide REST API docs for it to buld the frontend.
A prompt will be .
Using the Admin REST-API docs, scaffold the entire front-end skeleton (directory tree, empty TS stubs, and React-Router routes) for all Admin endpoints; when the skeleton is ready, let me know so we can review each page one by one.
And a response may be like this
web/src/components/admin/
├── dashboard/
│ ├── DashboardHome.tsx
│ ├── DashboardStats.tsx
│ ├── RecentActivity.tsx
│ └── SystemHealth.tsx
│
├── users/
│ ├── UsersListPage.tsx
│ ├── CreateUserModal.tsx
│ ├── EditUserModal.tsx
│ ├── UserCard.tsx
│ ├── UserFilters.tsx
│ └── BulkUserActions.tsx
│
├── classes/
│ ├── ClassesListPage.tsx
│ ├── CreateClassModal.tsx
│ ├── EditClassModal.tsx
│ └── ClassDetails.tsx
│
├── reports/
│ ├── ReportsPage.tsx
│ ├── UserReport.tsx
│ └── ClassReport.tsx
│
└── shared/
├── AdminLayout.tsx
├── DataTable.tsx
├── Modal.tsx
└── Pagination.tsx
And then i can take it page by page and review the code accordingly.
Pydantic
Pydantic is the most widely used data validation library for Python. Im fairly new to it as well, but i realized why its so powerful. It all starts with models like this.
from pydantic import BaseModel, EmailStr
class UserCreate(BaseModel):
first_name: str
last_name: str
email: EmailStr
password: str
Behind the scenes, this is doing strict validation. It makes sure your data is the right type before it ever reaches your logic. Email is actually a valid email, strings are strings, and if anything’s missing it logs an error.
Now imagine a route below responsible for retrieving all the users from your database.
@router.get("/users", response_model=PaginatedResponse)
async def get_users(
pagination: PaginationParams = Depends(get_pagination_params),
role: str = None,
db: AsyncSession = Depends(get_db),
current_user = Depends(require_admin)
):
"""Get all users with pagination and optional role filter."""
try:
# Get users
users = await crud.user.get_multi(
db,
skip=pagination.skip,
limit=pagination.limit,
role=role
)
# Get total count
total = await crud.user.count(db, role=role)
return PaginatedResponse(
items=[UserResponse.model_validate(user) for user in users],
total=total,
page=pagination.page,
size=pagination.size,
pages=(total + pagination.size - 1) // pagination.size
)
It expects a nice, predictable JSON list of users. But without validation, anything can slip through: missing fields, wrong types, or even legacy junk from the database possibly with messy joins, or weird types.
UserResponse.model_validate()
turns each one into a clean, fully validated Pydantic model, ready to be serialized into JSON.
So if something's off? It fails fast instead of sending corrupted or partial data to your frontend. And if it passes? You know you’re returning rock-solid responses
The JWT Authentication Challenge: A Real-World Problem
Situation
After setting up our React Router DOM properly and fixing the navbar architecture, we hit a critical issue: clicking "Sign In" did nothing. Users would enter credentials, the form would submit, but they'd remain stuck on the login page. This was puzzling because our backend was working perfectly.
Diagnosis
Step 1: Backend Investigation
We tested login via PowerShell:
$body = '{"email": "admin@school.edu", "password": "admin123"}'
$headers = @{ "Content-Type" = "application/json" }
$response = Invoke-WebRequest -Uri "http://localhost:8000/api/auth/login" -Method POST -Headers $headers -Body $body
$response.Content | ConvertFrom-Json
The backend was working perfectly, returning:
{
"access_token": "eyJhbG..."
}
Step 2: Frontend Flow Analysis
The problem was with my frontend:
// BROKEN: Looking for data.user.role that doesn't exist
localStorage.setItem('user_data', JSON.stringify(data.user));
setUser(data.user);
navigate('/');
Problem: Our backend only returns access_token
, not a user
object. The frontend was expecting data.user.role
but getting undefined
.
Step 3: The Fix
Instead of expecting user directly, its good pracice to decode the JWT
const tokenPayload = JSON.parse(atob(data.access_token.split('.')[1]));
const user = {
id: tokenPayload.sub,
email: tokenPayload.email,
role: tokenPayload.role,
first_name: tokenPayload.first_name || '',
last_name: tokenPayload.last_name || ''
};
This gives you access to the role and could redirect with navigate(
/${user.role});
Technical Learning: How JWT Works
Simple bug but it led me to a deeper understanding of how JWT tokens work:
JWT (JSON Web Token) is a three-part system:
- Header: Specifies the algorithm (e.g., HS256)
-
Payload: Contains user data (claims) like
sub
,email
,role
,exp
- Signature: Cryptographic proof of authenticity
Decoding Process:
// Split token: header.payload.signature
const parts = token.split('.');
const payload = JSON.parse(atob(parts[1])); // Base64 decode the payload
Security Benefits:
- Stateless: No server-side session storage needed
- Tamper-proof: Signature verification prevents modification
- Self-contained: All user info embedded in token
- Expiration: Built-in time-based security
Development vs Production Security:
- Dev: Same secret = predictable signatures (fine for testing)
- Prod: Random secret + proper expiration + token rotation
Final Architecture
Now our authentication flow works perfectly:
- Login → Backend validates credentials
- JWT Token → Contains all user information
- Frontend Decode → Extract user role from token
-
Smart Routing → Redirect to role-specific dashboard (
/admin
,/teacher
, etc.) - Persistent Sessions → Token stored locally for app reloads
Final Note
While this works, storing user data in localStorage isn't ideal for production apps. A more secure approach would be to store just the access_token (or better, use HttpOnly cookies).
Race Condition
At one point, my isAuthenticated logic was just a simple boolean. Either the user was logged in (true) or not (false).
This caused a race condition. On page reload, isAuthenticated would default to false before checking localStorage or decoding the JWT. So even though the user had a valid token, the app would instantly redirect them to /login
The fix is to add a 'null' state.
const [isAuthenticated, setIsAuthenticated] = useState<true | false | null>(null);
So now:
null = still checking (e.g. during initial load)
true = authenticated
false = definitely not authenticated
The SaaS Pivot: Decentralized, Multi-Tenant Architecture
As I built out the admin dashboard and core CRUD flows, I realized the real opportunity wasn’t just a school management system for one organization—it was a platform. What if any teacher, tutor, or school could sign up and run their own “mini-school” on my infrastructure?
This is the SaaS model powering giants like Google Classroom, Teachable, and Slack.
Vision: Decentralized, Multi-Tenant SaaS
Decentralized onboarding: Any teacher, tutor, or organization can sign up and manage their own classes, students, and guardians—no central admin required.
Tenant isolation: Every user, class, student, and payment is linked to a
tenant_id
(the teacher or organization), ensuring strict data separation.Flexible roles: Teachers create and manage their own classes, add students, communicate with guardians, and set quizzes. Guardians enroll and pay for classes, track progress, and communicate with teachers. Organizations can have their own admins and branding.
Platform admin: I (the platform owner) can see and support all tenants, but don’t micromanage their data.
How This Changes the Architecture
-
Backend: Every resource (user, class, payment, etc.) now includes a
tenant_id
. All queries are filtered by tenant, and the platform admin can view across tenants for analytics and support. - Frontend: On signup, users choose “I’m a teacher/tutor” or “I’m an organization.” After login, they only see and manage their own data.
- Payments: Integrated payment processing for subscriptions, class fees, etc.
- Notifications: Decentralized messaging for teachers, students, and guardians.
Next Steps in the Pivot
-
Refactor the database schema to include
tenant_id
in all tables and update relationships. -
Update the CRUD operations to filter by
tenant_id
and ensure data isolation. - Revise the authentication flow to support multi-tenancy, including tenant-specific roles and permissions.
- Redesign the frontend to allow users to choose their role during signup and manage their own classes/students.
- Implement payment processing for class enrollments and subscriptions.
- Enhance the admin dashboard to provide insights across tenants and support tenant management.
Final Thoughts
AI is only as good as your foundation.
By building a clean, well-structured SMS, I’ve set myself up for success—whether it’s traditional features or advanced AI integrations. The collaborative, step-by-step approach with my AI assistant kept things clean, focused, and definitely speedy.
After doing this, I realized just how important the fundamentals still are.
So in my next blog, I’ll be flexing my Data Structures and Algorithms skills, along with deeper Database techniques, to show how they still play a vital role before we move on to llm integration and architecting agentic workflows.
Top comments (0)