update the new school year model
This commit is contained in:
+35
-3
@@ -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
@@ -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',
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user