iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article

Building a School Management System

に公開

Introduction

I welcome comments or edit requests via GitHub for any corrections or additions.

This is a story about how I solved inconveniences at my school by building my own system.

Main Topic

I am currently attending a correspondence school.
You can graduate after being enrolled for at least 3 years, completing 74 credits, and participating in a total of 30 hours of special activities (events organized by the school).
At such a school, I had to check with teachers every single time to find out how many credits I had completed or how many more hours of special activities I needed to attend.
It was extremely tedious for both students and teachers.
So, with the "if it doesn't exist, build it" spirit, I created an internal school system.

Specifications

For now, I implemented the features with the following specifications:

  • Prevent external access
  • Teachers can edit all data
  • Students can only edit their own data
  • The number of completed credits and special activity hours are calculated automatically
  • Data does not disappear even after a reload

For the programming stack, I adopted Firebase and Nextjs.
Although I don't expect it to be necessary, I deployed it on Cloudflare for DDoS protection.

Prevent external access

Since every student in my school is assigned a Google account with a private domain, I decided to filter access using those email addresses.

Teachers can edit all data

Controlled through Cloud Firestore security rules.

rules_version = '2';
service cloud.firestore {
  match /databases/{db}/documents {
   match /users/{userId} {
      allow read, update, delete: if request.auth != null && request.auth.uid == userId || get(/databases/$(db)/documents/users/$(request.auth.uid)).data.admin == true;
      allow create: if request.auth != null;
    }
  }
}

I set the admin field to true for administrators (teachers).

Students can only edit their own data

Using Cloud Firestore security rules, I ensure that only documents matching the user's uid can be viewed.

The number of completed credits and special activity hours are calculated automatically

Data is retrieved from Cloud Firestore and calculated on the frontend.

Data does not disappear even after a reload

Persisted using recoil-persist.

import { atom } from 'recoil';
import { recoilPersist } from 'recoil-persist';

const { persistAtom } = recoilPersist();
export const userState = atom<{
    id: string;
    uid: string;
    displayName: string;
    email: string;
    admin: boolean;
    login: boolean;
    activeTime: string;
}>({
    key: 'user',
    default: {
        id: '',
        uid: '',
        displayName: '',
        email: '',
        admin: false,
        login: false,
        activeTime: '',
    },
    effects_UNSTABLE: [persistAtom],
    dangerouslyAllowMutability: true,
});

Implementation Showcase

Login

Login is handled via Google accounts.
User information is automatically saved to Firestore upon registration.
To prevent information from being displayed to the wrong user due to Cloudflare's caching, a token is added to the URL.
External users are automatically deleted.

LoginComponent
import Button from '@mui/material/Button';
import {
    deleteUser,
    GoogleAuthProvider,
    signInWithPopup,
    signOut,
} from 'firebase/auth';
import {
    doc,
    getDoc,
    getFirestore,
    setDoc,
} from 'firebase/firestore';
import { NextRouter, useRouter } from 'next/router';
import { useAuthState } from 'react-firebase-hooks/auth';
import { SetterOrUpdater, useSetRecoilState } from 'recoil';
import { takingClassState } from '../atoms/takingClassState';
import { userState } from '../atoms/userState';
import { auth } from '../modules/FirebaseApp';
const Login = ({
    buttonCss,
    provider,
    setUserAtom,
    logOut,
    router,
}: {
    buttonCss?: string;
    provider: GoogleAuthProvider;
    setUserAtom: SetterOrUpdater<{
        id: string;
        uid: string;
        displayName: string;
        email: string;
        admin: boolean;
        login: boolean;
        activeTime: string;
    }>;
    logOut: (delet: boolean) => Promise<void>;
    router: NextRouter;
}) => {
    // Hashing function
    const cryptoText = async (text: string) => {
        const msgUint8 = new TextEncoder().encode(text);
        const hashBuffer = await crypto.subtle.digest(
            'SHA-256',
            msgUint8,
        );
        const hashArray = Array.from(new Uint8Array(hashBuffer));
        const hashHex = hashArray
            .map((b) => b.toString(16).padStart(2, '0'))
            .join('');
        return hashHex;
    };

    return (
        <Button
            className={buttonCss}
            variant="outlined"
            onClick={() => {
                // Login
                signInWithPopup(auth, provider).then(
                    async (result) => {
                        // Get user information
                        const user = result.user;
                        /**
                         * Student accounts are in the format XXYYY Name
                         * (XX is the year of entry, YYY is the number)
                         */
                        const idAndName = user.displayName?.match(
                            /([0-9]+)([^0-9]+)/,
                        );

                        const db = getFirestore();
                        const dbRef = doc(db, `users`, user.uid);
                        const querySnapshot = await getDoc(dbRef);
                        // Check if user information exists in the database
                        if (querySnapshot.exists()) {
                            // If it exists in the database, retrieve the database info
                            const data = querySnapshot.data();
                            setUserAtom({
                                displayName: data.userName,
                                email: data.email,
                                id: data.userId,
                                uid: user?.uid,
                                admin: data.admin,
                                login: true,
                                activeTime: data?.activeTime || '0',
                            });
                        } else if (idAndName) {
                            // If not in the database, register user information in the database
                            setUserAtom({
                                displayName: idAndName[2],
                                email: user?.email || '',
                                id: idAndName[1],
                                uid: user?.uid,
                                admin: false,
                                login: true,
                                activeTime: '0',
                            });
                            await setDoc(dbRef, {
                                userName: idAndName[2],
                                email: user?.email || '',
                                userId: idAndName[1],
                                admin: false,
                                activeTime: '0',
                                takingClass: '',
                                year: `${idAndName[1][0]}${idAndName[1][1]}`,
                                uid: user?.uid || '',
                            });
                        } else if (
                            /^.*?@(std\.)?hoge\.ac\.jp/.test(
                                user?.email || '',
                            )
                        ) {
                            // Doesn't match any condition but is a school member, so temporary registration
                            setUserAtom({
                                displayName: user?.displayName || '',
                                email: user?.email || '',
                                id: '0',
                                uid: user?.uid,
                                admin: false,
                                login: true,
                                activeTime: '0',
                            });
                        } else {
                            // Deny login because they are an external user
                            alert(
                                'Please login with your school account',
                            );
                            logOut(true).then(() => {
                                router.replace(`/`);
                                router.reload();
                            });
                        }
                        cryptoText(user.uid).then((uid) => {
                            router.replace(`/?token=${uid}`);
                        });
                    },
                );
            }}>
            Login
        </Button>
    );
};
export const LoginComponent = ({
    buttonCss,
}: {
    buttonCss?: string;
}) => {
    const provider = new GoogleAuthProvider();
    // Get login status
    const [user, loading, _error] = useAuthState(auth);
    const userUid = user?.uid || '';
    const setUserAtom = useSetRecoilState(userState);
    const setTakingClass = useSetRecoilState(takingClassState);
    const router = useRouter();
    const logOut = async (delet: boolean) => {
        if (delet && auth.currentUser) {
            deleteUser(auth.currentUser);
        }
        signOut(auth);
        setUserAtom({
            displayName: '',
            email: '',
            id: '',
            admin: false,
            uid: '',
            login: false,
            activeTime: '0',
        });
        setTakingClass({
            value: [],
            indexs: [],
            count: 0,
        });
    };

    if (loading) {
        return <div>Loading...</div>;
    }
    if (!user) {
        return (
            <Login
                buttonCss={buttonCss}
                provider={provider}
                setUserAtom={setUserAtom}
                logOut={logOut}
                router={router}
            />
        );
    }
    return (
        <>
            <Button
                className={buttonCss}
                variant="outlined"
                onClick={() => {
                    logOut(false).then(() => {
                        router.replace(`/`);
                    });
                }}>
                Logout
            </Button>
        </>
    );
};

Subject Management

Subjects are managed internally as follows.

// Subject information
// value: Subject Name:Credits
// label: Subject name displayed to the user
const japanese = [
    {
        value: '国語総合:4',
        label: '国語総合',
    },
    {
        value: '国語表現:3',
        label: '国語表現',
    },
    {
        value: '現代文A:2',
        label: '現代文A',
    },
    {
        value: '古典A:2',
        label: '古典A',
    },
];
const english = [
    {
        value: 'コミュニケーション英語1:3',
        label: 'コミュニケーション英語1',
    },
    {
        value: 'コミュニケーション英語2:4',
        label: 'コミュニケーション英語2',
    },
    {
        value: '英語表現1:2',
        label: '英語表現1',
    },
    {
        value: '英語表現2:4',
        label: '英語表現2',
    },
];
export const subjects = [...japanese, ...english].map(
    (item, index) => {
        return { ...item, index };
    },
);
SelectComponent
import Select from 'react-select';
import { useRecoilState } from 'recoil';
import { takingClassState } from '../../atoms/takingClassState';

const SlectComponent = ({
    subjects,
}: {
    subjects: {
        value: string;
        label: string;
        index: number;
    }[];
}) => {
    const [takingClass, setTakingClass] = useRecoilState(
        takingClassState,
    );
    return (
        <Select
            className="text-black"
            // Load data
            defaultValue={takingClass.indexs.map((n) => subjects[n])}
            onChange={(e) => {
                // Subjects are managed internally in the format "Subject Name:Credits"
                const value = e.map((v) => v.value.split(':'));
                const indexs = e.map((v) => v.index);
                // Calculate the total number of credits
                let count = 0;
                value.forEach((v) => {
                    count += Number(v[1]);
                });
                setTakingClass({
                    value,
                    indexs,
                    count,
                });
            }}
            options={subjects}
            isClearable={true}
            isSearchable={true}
            isMulti={true}
        />
    );
};
export default SlectComponent;

Caching Function

A simple caching function. When using it, search with a filter.

import { useRecoilState } from 'recoil';
import { cacheState } from '../../atoms/cacheState';
import { userState } from '../atoms/userState';

const [cache, setCache] = useRecoilState(cacheState);
const user = useRecoilValue(userState);

// Retrieve cache
const getCache = cache.filter((n) => {
    return n.id === user.uid;
});

// Save to cache
setCache([
    ...cache,
    {
        id: user.uid,
        data: user,
    },
]);
cacheState
import { atom } from 'recoil';
import { recoilPersist } from 'recoil-persist';

const { persistAtom } = recoilPersist();
export const cacheState = atom<{
        id: string;
        data: any;
    }[]>(
{
    key: 'cache',
    default: [],
    effects_UNSTABLE: [persistAtom],
    dangerouslyAllowMutability: true,
});

Data Saving and Loading

Subject information is stored in an atom called takingClassState.
In takingClassState, a sequence of numbers like 0,1,2 is stored as a string.
These represent the indices of the subject list array, which are used to reconstruct the data.

DataSaveAndLoad
import { Button } from '@mui/material';
import {
    doc,
    getDoc,
    getFirestore,
    setDoc,
    updateDoc,
} from 'firebase/firestore';
import { SetterOrUpdater, useRecoilState } from 'recoil';
import { cacheState } from '../../atoms/cacheState';
import loadTakingClass from '../../lib/loadTakingClass';

const NotAdminComponent = ({
    user,
    takingClass,
    setTakingClass,
}: {
    user: {
        id: string;
        uid: string;
        displayName: string;
        email: string;
        admin: boolean;
        login: boolean;
    };
    takingClass: {
        value: string[][];
        indexs: number[];
        count: number;
    };
    setTakingClass: SetterOrUpdater<{
        value: string[][];
        indexs: number[];
        count: number;
    }>;
}) => {
    // Use cache to avoid hitting Firebase's free tier limits
    const [cache, setCache] = useRecoilState(cacheState);
    return (
        <>
            <div className="flex">
                <div className="w-32">
                    <Button
                        className="w-full"
                        variant="outlined"
                        onClick={async () => {
                            const getCache = cache.filter((n) => {
                                return n.id === user.uid;
                            });
                            const takingClassData =
                                takingClass.indexs.length > 0
                                    ? takingClass.indexs.join(',')
                                    : '';

                            if (!user.admin) {
                                // If there is no cache or the cache is old, retrieve data from the database
                                if (
                                    getCache.length === 0 ||
                                    getCache[0].data.takingClass !==
                                        takingClassData
                                ) {
                                    const db = getFirestore();
                                    const dbRef = doc(
                                        db,
                                        `users`,
                                        user.uid,
                                    );
                                    const querySnapshot = await getDoc(
                                        dbRef,
                                    );
                                    if (querySnapshot.exists()) {
                                        await updateDoc(dbRef, {
                                            takingClass: takingClassData,
                                        });
                                    } else {
                                        // If no data exists in the database, create the data
                                        await setDoc(
                                            dbRef,
                                            {
                                                userName:
                                                    user?.displayName ||
                                                    '',
                                                userId:
                                                    user?.id || '',
                                                email:
                                                    user?.email || '',
                                                takingClass: takingClassData,
                                                admin:
                                                    user?.admin ||
                                                    false,
                                                year: `${user?.id[0]}${user?.id[1]}`,
                                                uid: user?.uid || '',
                                                activeTime: 0,
                                            },
                                            { merge: true },
                                        );
                                    }
                                    const getCache = [
                                        cache.filter((n) => {
                                            return n.id === user.uid;
                                        }),
                                        cache.filter((n) => {
                                            return n.id !== user.uid;
                                        }),
                                    ];
                                    // Update cache
                                    if (getCache[0].length !== 0) {
                                        getCache[0][0].data.takingClass = takingClassData;
                                        setCache([
                                            ...getCache[0],
                                            ...getCache[1],
                                        ]);
                                    } else {
                                        // If there is no cache, create the cache
                                        setCache([
                                            ...cache,
                                            {
                                                id: user.uid,
                                                data: {
                                                    ...user,
                                                    takingClass: takingClassData,
                                                },
                                            },
                                        ]);
                                    }
                                }
                            }
                        }}>
                        Save
                    </Button>
                </div>
                <div className="w-32">
                    <Button
                        className="w-full"
                        variant="outlined"
                        onClick={async () => {
                            if (!user.admin) {
                                // Retrieve cache
                                const getCache = cache.filter((n) => {
                                    return n.id === user.uid;
                                });
                                if (getCache.length !== 0) {
                                    // If cache exists, retrieve data from the cache
                                    if (
                                        getCache[0].data?.takingClass
                                    ) {
                                        loadTakingClass({
                                            setTakingClass,
                                            takingClass:
                                                getCache[0].data
                                                    .takingClass,
                                        });
                                    }
                                } else {
                                    // If there is no cache, retrieve data from the database
                                    const data = await getDoc(
                                        doc(
                                            getFirestore(),
                                            'users',
                                            user.uid,
                                        ),
                                    );
                                    if (data.exists()) {
                                        const user = data.data();
                                        if (user.takingClass) {
                                            // Create cache
                                            setCache([
                                                ...cache,
                                                {
                                                    id: user.uid,
                                                    data: user,
                                                },
                                            ]);
                                            // Retrieve data
                                            loadTakingClass({
                                                setTakingClass,
                                                takingClass:
                                                    user.takingClass,
                                            });
                                        }
                                    }
                                }
                            }
                        }}>
                        Load
                    </Button>
                </div>
            </div>
        </>
    );
};
export default NotAdminComponent;

Implementing AppCheck

AppCheck is a feature provided by Firebase to enhance application security.

The following code is used for setup:

import { initializeApp } from 'firebase/app';
import {
    getToken,
    initializeAppCheck,
    ReCaptchaV3Provider,
} from 'firebase/app-check';
import { getAuth } from 'firebase/auth';
const firebaseConfig = {
    ...
};
export const app = initializeApp(firebaseConfig);

export const auth = getAuth(app);
declare global {
    var FIREBASE_APPCHECK_DEBUG_TOKEN: boolean | string | undefined;
}
if (typeof document !== 'undefined') {
    if (process.env.NODE_ENV === 'development') {
        window.self.FIREBASE_APPCHECK_DEBUG_TOKEN = true;
    }
    const appCheck = initializeAppCheck(app, {
        provider: new ReCaptchaV3Provider(
            process.env.NEXT_PUBLIC_RECAPTCHAV3_SITE_KEY || '',
        ),
        isTokenAutoRefreshEnabled: true,
    });
    getToken(appCheck)
        .then(() => {
            console.log('AppCheck:Success');
        })
        .catch((error) => {
            console.log(error.message);
        });
}

Initialize AppCheck in _app.tsx.

_app.tsx
import '../modules/FirebaseApp';

Other Tips

Avoiding SSR

Since pages are created dynamically and SSR is not possible, I have disabled SSR.

const Layout = dynamic(() => import('../components/Layout'), {
    ssr: false,
});

This is the logic for when invalid data is entered while a teacher is searching for a student.

import TextField from '@mui/material/TextField';
import { useState } from 'react';
import { useRecoilState } from 'recoil';
import { isNumber } from 'umt/module/Math/isNumber';
import { studentState } from '../atoms/studentState';
export const SearchStudentComponent = ({
    children,
}: {
    children: React.ReactNode;
}) => {
    const [student, setStudent] = useRecoilState(studentState);
    const [error, setError] = useState('');
    return (
        <>
            <TextField
                type="text"
                label="Student ID Search"
                className="bg-white border-black border-b"
                onChange={(e) => {
                    if (
                        isNumber(e.target.value) &&
                        e.target.value.length <= 5
                    ) {
                        setStudent({
                            ...student,
                            id: e.target.value,
                        });
                    }
                    // Data check
                    if (e.target.validity.valid) {
                        setError('');
                    } else {
                        setError(e.target.validationMessage);
                    }
                }}
                // Limit the number of characters that can be entered
                inputProps={{ minLength: 5, maxLength: 5 }}
                value={student.id}
            />
            {/* Error check */}
            {error ? <div>{error}</div> : { children }}
        </>
    );
};

Behind-the-Scenes Development Stories

The "Entire Database Replacement" Incident

I wasted about two hours because of this bug.
I still haven't figured out the cause.
For some reason, Realtime Database had a bug where infinite loops occurred in the production environment, making it unusable.
There were articles saying that lowering the Node version to 12.X would solve it,
but since other libraries wouldn't run on such an old version, I had no choice but to switch to Cloud Firestore in a hurry.

The "Too Many Reads" Incident

On the first day of release, 10,000 reads occurred.
The daily limit is 20,000, so it was a close call.
Even though my school only has about 100 students, my poor implementation caused an abnormally high number of reads.
By introducing a caching feature, the number of reads decreased significantly (from approx. 10,000 to approx. 200).
Sloppy implementation is evil.
Don't write bad code.

The "Ridiculous Cause" AppCheck Incident

AppCheck authenticates with reCAPTCHA, but there was a bug where authentication failed with an error for some reason.
The cause was simply that the last character of the site key was missing.
So stupid.

The "Text content does not match server-rendered HTML" Incident

This was also a bug caused by my poor implementation.

Unhandled Runtime Error
Error: Text content does not match server-rendered HTML.

Translation: The HTML rendered on the server and the text content don't match, you dummy.

Since I was determining the login status on the frontend, the HTML changed upon rendering, triggering this error.
Apparently, it can be avoided using suppressHydrationWarning, but I couldn't pinpoint the exact location in the end.
I had to resolve it by disabling SSR. (I feel like I'm throwing away the benefits of Next.js, but it couldn't be helped.)

Summary

That's the story of how I built an internal school system.
I was able to build it in less than a day thanks to Firebase. (Truly grateful! 🙏)
At first, I implemented it without caching, but since there were more reads than expected, I quickly introduced caching.
Although the implementation is quite rough, the minimum required features are implemented, so I think it's okay.
(Since I'm the only one in the school, including the teachers, who can handle Next.js, maintenance is the next challenge.)

Tools and resources used:

Bonus

Using my own module, UMT

For this project, I only used the following feature.
UMT is a module I created myself.
It compiles features that might be used "every once in a while."

Numeric Validation

/* isNumber(string or number, whether to allow strings)
 * Returns true if it can be converted to a numeric type
 */
import { isNumber } from 'umt/module/Math/isNumber';
console.log(isNumber('123')); // true
console.log(isNumber('123a')); // false
console.log(isNumber('123', false)); // false

Discussion