update the new school year model

This commit is contained in:
root
2026-06-07 20:01:56 -04:00
parent 8e79201a3c
commit b6513ab22f
9 changed files with 304 additions and 107 deletions
+35 -3
View File
@@ -11,6 +11,25 @@ export function setStoredToken(token: string | null): void {
else localStorage.removeItem(TOKEN_KEY)
}
export function decodeJwtPayload<T = unknown>(token: string): T | null {
try {
const payload = token.split('.')[1]
if (!payload) return null
const base64 = payload.replace(/-/g, '+').replace(/_/g, '/')
const json = decodeURIComponent(
atob(base64)
.split('')
.map((char) => `%${char.charCodeAt(0).toString(16).padStart(2, '0')}`)
.join(''),
)
return JSON.parse(json) as T
} catch {
return null
}
}
export class ApiHttpError extends Error {
readonly status: number
readonly body: unknown
@@ -30,24 +49,36 @@ export async function apiFetch<T>(
): Promise<T> {
const attachAuth = opts.attachAuth !== false
const headers = new Headers(init.headers)
if (!headers.has('Accept')) headers.set('Accept', 'application/json')
if (!headers.has('Accept')) {
headers.set('Accept', 'application/json')
}
const token = getStoredToken()
if (attachAuth && token && !headers.has('Authorization')) {
headers.set('Authorization', `Bearer ${token}`)
}
const url = apiUrl(path)
let res: Response
try {
res = await fetch(url, { ...init, headers })
res = await fetch(url, {
...init,
headers,
})
} catch (e) {
const hint = import.meta.env.DEV
? ' Start the API and ensure Vite can reach it (see vite.config.ts; default proxy is http://localhost:8080 unless VITE_PROXY_API is set).'
? ' Start the API and ensure Vite can reach it. Default proxy is http://localhost:8000 unless VITE_PROXY_API or VITE_API_ORIGIN is set.'
: ''
const reason = e instanceof Error ? e.message : 'Failed to fetch'
throw new ApiHttpError(`Network error: ${reason}.${hint}`, 0, null)
}
const body: unknown = await res.json().catch(() => null)
if (!res.ok) {
@@ -55,6 +86,7 @@ export async function apiFetch<T>(
typeof body === 'object' && body !== null && 'message' in body
? String((body as { message?: unknown }).message ?? '')
: ''
throw new ApiHttpError(msg || `HTTP ${res.status}`, res.status, body)
}
+125 -18
View File
@@ -23,7 +23,6 @@ import type {
GradingOverviewResponse,
IpBansResponse,
LandingPageResponse,
LoginFailure,
LoginResponse,
LoginSuccess,
LateSlipLogsResponse,
@@ -97,30 +96,136 @@ import type {
FamilySearchResponse,
} from './types'
type LoginApiObject = Record<string, unknown>
function isObject(value: unknown): value is LoginApiObject {
return value !== null && typeof value === 'object' && !Array.isArray(value)
}
function stringValue(value: unknown): string | null {
return typeof value === 'string' && value.trim() !== '' ? value.trim() : null
}
function numberValue(value: unknown): number {
if (typeof value === 'number' && Number.isFinite(value)) return value
if (typeof value === 'string' && value.trim() !== '' && Number.isFinite(Number(value))) {
return Number(value)
}
return 0
}
function rolesToMap(value: unknown): Record<string, boolean> {
if (Array.isArray(value)) {
return Object.fromEntries(
value
.map((role) => String(role ?? '').trim())
.filter(Boolean)
.map((role) => [role, true]),
)
}
if (isObject(value)) {
return Object.fromEntries(
Object.entries(value)
.filter(([role, enabled]) => role.trim() !== '' && Boolean(enabled))
.map(([role]) => [role, true]),
)
}
return {}
}
function normalizeLoginSuccess(body: unknown): LoginSuccess | null {
if (!isObject(body)) return null
const data = isObject(body.data) ? body.data : null
const jwt = data && isObject(data.jwt) ? data.jwt : null
const sanctum = data && isObject(data.sanctum) ? data.sanctum : null
const token =
stringValue(body.token) ??
stringValue(body.access_token) ??
(data ? stringValue(data.token) : null) ??
(data ? stringValue(data.access_token) : null) ??
(jwt ? stringValue(jwt.access_token) : null) ??
(sanctum ? stringValue(sanctum.access_token) : null)
const rawUser =
(isObject(body.user) ? body.user : null) ??
(data && isObject(data.user) ? data.user : null)
if (!token || !rawUser) return null
const firstName = stringValue(rawUser.firstname) ?? stringValue(rawUser.first_name) ?? ''
const lastName = stringValue(rawUser.lastname) ?? stringValue(rawUser.last_name) ?? ''
const name =
stringValue(rawUser.name) ??
[firstName, lastName].filter(Boolean).join(' ').trim() ??
stringValue(rawUser.email) ??
'User'
return {
status: true,
token,
user: {
id: numberValue(rawUser.id),
name,
roles: rolesToMap(rawUser.roles),
class_section_id:
rawUser.class_section_id === null || rawUser.class_section_id === undefined
? null
: numberValue(rawUser.class_section_id),
class_section_name: stringValue(rawUser.class_section_name),
},
}
}
export async function loginRequest(
email: string,
password: string,
): Promise<LoginResponse> {
const body = await apiFetch<unknown>(
'/api/v1/auth/login',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
},
{ attachAuth: false },
)
const loginPaths = ['/api/v1/auth/login', '/api/v1/login', '/api/login']
let body: unknown = null
let lastError: unknown = null
if (!body || typeof body !== 'object' || !('status' in body)) {
throw new Error('Invalid login response from API.')
for (const path of loginPaths) {
try {
body = await apiFetch<unknown>(
path,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
},
{ attachAuth: false },
)
lastError = null
break
} catch (error) {
lastError = error
if (!(error instanceof ApiHttpError) || error.status !== 404) {
throw error
}
}
}
if ((body as { status?: boolean }).status === false) {
const fail = body as LoginFailure
return { status: false, message: fail.message }
if (lastError) throw lastError
if (isObject(body)) {
const explicitFailure =
body.status === false ||
body.success === false ||
String(body.result ?? '').toLowerCase() === 'false'
if (explicitFailure) {
return { status: false, message: stringValue(body.message) ?? 'Login failed.' }
}
}
return body as LoginSuccess
const normalized = normalizeLoginSuccess(body)
if (normalized) return normalized
throw new Error('Invalid login response from API. Expected a token and user object.')
}
export async function fetchDashboardRoute(): Promise<ApiEnvelope<DashboardPayload>> {
@@ -915,8 +1020,9 @@ export async function notifyTeacherSubmissions(payload: {
)
}
export async function fetchSubjectCurriculum(): Promise<SubjectCurriculumResponse> {
return apiFetch<SubjectCurriculumResponse>('/api/v1/subjects/curriculum')
export async function fetchSubjectCurriculum(schoolYear?: string | null): Promise<SubjectCurriculumResponse> {
const query = schoolYear ? `?school_year=${encodeURIComponent(schoolYear)}` : ''
return apiFetch<SubjectCurriculumResponse>(`/api/v1/subjects/curriculum${query}`)
}
export async function createSubjectCurriculum(payload: {
@@ -925,6 +1031,7 @@ export async function createSubjectCurriculum(payload: {
unit_number?: number | null
unit_title?: string | null
chapter_name: string
school_year?: string | null
}): Promise<ApiEnvelope<{ entry?: unknown }>> {
return apiFetch<ApiEnvelope<{ entry?: unknown }>>('/api/v1/subjects/curriculum', {
method: 'POST',
+3
View File
@@ -979,6 +979,7 @@ export type SubjectCurriculumEntryRow = {
unit_number?: number | string | null
unit_title?: string | null
chapter_name: string
school_year?: string | null
}
export type SubjectCurriculumResponse = {
@@ -986,6 +987,8 @@ export type SubjectCurriculumResponse = {
classes: SubjectCurriculumClassRow[]
entries: SubjectCurriculumEntryRow[]
subject_labels: Record<string, string>
school_year?: string | null
school_years?: string[]
}
export type PublicCompetitionRow = {