Update project files
This commit is contained in:
531
src/api/client.ts
Normal file
531
src/api/client.ts
Normal file
@@ -0,0 +1,531 @@
|
||||
import type {
|
||||
AuthCredentials,
|
||||
AuthResponse,
|
||||
AuthSession,
|
||||
EntityId,
|
||||
Household,
|
||||
HouseholdHistoryEntry,
|
||||
HouseholdInvitePayload,
|
||||
HouseholdPayload,
|
||||
InventoryItem,
|
||||
InventoryItemPayload,
|
||||
Location,
|
||||
LocationHistoryEntry,
|
||||
LocationPayload,
|
||||
MealPlanner,
|
||||
MealPlannerPayload,
|
||||
ProfileUpdatePayload,
|
||||
RegisterPayload,
|
||||
ShoppingList,
|
||||
ShoppingListPayload,
|
||||
User,
|
||||
} from '../types/models'
|
||||
|
||||
const SESSION_STORAGE_KEY = 'pantry-management-session'
|
||||
const SESSION_CHANGE_EVENT = 'pantry-management-session-change'
|
||||
const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? 'https://api.pantrymanager.kitchen/').trim().replace(/\/$/, '')
|
||||
|
||||
let refreshPromise: Promise<AuthSession> | null = null
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number
|
||||
data: unknown
|
||||
|
||||
constructor(message: string, status = 0, data: unknown = null) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
this.status = status
|
||||
this.data = data
|
||||
}
|
||||
}
|
||||
|
||||
interface RequestOptions {
|
||||
method?: string
|
||||
body?: unknown
|
||||
headers?: Record<string, string>
|
||||
skipAuth?: boolean
|
||||
retryOnAuthFailure?: boolean
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
function buildUrl(path: string): string {
|
||||
return API_BASE_URL ? `${API_BASE_URL}${path}` : path
|
||||
}
|
||||
|
||||
function parseStoredSession(rawValue: string | null): AuthSession | null {
|
||||
if (!rawValue) return null
|
||||
|
||||
try {
|
||||
return JSON.parse(rawValue) as AuthSession
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function dispatchSessionChange(session: AuthSession | null): void {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
window.dispatchEvent(new CustomEvent(SESSION_CHANGE_EVENT, {
|
||||
detail: session,
|
||||
}))
|
||||
}
|
||||
|
||||
export function getStoredSession(): AuthSession | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
|
||||
return parseStoredSession(window.localStorage.getItem(SESSION_STORAGE_KEY))
|
||||
}
|
||||
|
||||
export function saveSession(session: AuthSession): AuthSession {
|
||||
if (typeof window === 'undefined') return session
|
||||
|
||||
window.localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(session))
|
||||
dispatchSessionChange(session)
|
||||
return session
|
||||
}
|
||||
|
||||
export function clearSession(): void {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
window.localStorage.removeItem(SESSION_STORAGE_KEY)
|
||||
dispatchSessionChange(null)
|
||||
}
|
||||
|
||||
export function subscribeToSessionChanges(listener: (session: AuthSession | null) => void): () => void {
|
||||
if (typeof window === 'undefined') return () => {}
|
||||
|
||||
function handleCustomEvent(event: Event) {
|
||||
listener((event as CustomEvent<AuthSession | null>).detail ?? null)
|
||||
}
|
||||
|
||||
function handleStorageEvent(event: StorageEvent) {
|
||||
if (event.key !== SESSION_STORAGE_KEY) return
|
||||
listener(parseStoredSession(event.newValue))
|
||||
}
|
||||
|
||||
window.addEventListener(SESSION_CHANGE_EVENT, handleCustomEvent)
|
||||
window.addEventListener('storage', handleStorageEvent)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(SESSION_CHANGE_EVENT, handleCustomEvent)
|
||||
window.removeEventListener('storage', handleStorageEvent)
|
||||
}
|
||||
}
|
||||
|
||||
async function readResponse(response: Response): Promise<unknown> {
|
||||
const text = await response.text()
|
||||
|
||||
if (!text) return null
|
||||
|
||||
const contentType = response.headers.get('content-type') ?? ''
|
||||
|
||||
if (contentType.includes('json')) {
|
||||
try {
|
||||
return JSON.parse(text)
|
||||
} catch {
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(text)
|
||||
} catch {
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
function extractErrorMessage(data: unknown, fallbackMessage: string): string {
|
||||
if (!data) return fallbackMessage
|
||||
|
||||
if (typeof data === 'string' && data.trim()) {
|
||||
return data
|
||||
}
|
||||
|
||||
if (!isRecord(data)) {
|
||||
return fallbackMessage
|
||||
}
|
||||
|
||||
if (typeof data.message === 'string' && data.message.trim()) {
|
||||
return data.message
|
||||
}
|
||||
|
||||
if (typeof data.Message === 'string' && data.Message.trim()) {
|
||||
return data.Message
|
||||
}
|
||||
|
||||
if (typeof data.error === 'string' && data.error.trim()) {
|
||||
return data.error
|
||||
}
|
||||
|
||||
if (data.errors && typeof data.errors === 'object') {
|
||||
const messages = Object.values(data.errors)
|
||||
.flatMap(value => Array.isArray(value) ? value : [value])
|
||||
.filter(Boolean)
|
||||
|
||||
if (messages.length > 0) {
|
||||
return messages.map(String).join(' ')
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof data.title === 'string' && data.title.trim()) {
|
||||
return data.title
|
||||
}
|
||||
|
||||
return fallbackMessage
|
||||
}
|
||||
|
||||
async function performRefresh(session: AuthSession | null): Promise<AuthSession> {
|
||||
if (!session?.refreshToken) {
|
||||
clearSession()
|
||||
throw new ApiError('Your session expired. Sign in again.', 401)
|
||||
}
|
||||
|
||||
let response
|
||||
|
||||
try {
|
||||
response = await fetch(buildUrl('/api/auth/refresh-token'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
accessToken: session.accessToken,
|
||||
refreshToken: session.refreshToken,
|
||||
}),
|
||||
})
|
||||
} catch (error) {
|
||||
throw new ApiError(
|
||||
'Unable to refresh your session. Make sure the API is running and reachable.',
|
||||
0,
|
||||
error,
|
||||
)
|
||||
}
|
||||
|
||||
const data = await readResponse(response)
|
||||
|
||||
if (!response.ok) {
|
||||
clearSession()
|
||||
throw new ApiError(
|
||||
extractErrorMessage(data, 'Your session expired. Sign in again.'),
|
||||
response.status,
|
||||
data,
|
||||
)
|
||||
}
|
||||
|
||||
const refreshData = data as Partial<AuthResponse>
|
||||
|
||||
return saveSession({
|
||||
accessToken: refreshData.accessToken ?? '',
|
||||
refreshToken: refreshData.refreshToken ?? '',
|
||||
user: refreshData.user ?? session.user ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
async function refreshSession(): Promise<AuthSession> {
|
||||
if (!refreshPromise) {
|
||||
refreshPromise = performRefresh(getStoredSession())
|
||||
.finally(() => {
|
||||
refreshPromise = null
|
||||
})
|
||||
}
|
||||
|
||||
return refreshPromise
|
||||
}
|
||||
|
||||
async function requestJson<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
const {
|
||||
method = 'GET',
|
||||
body,
|
||||
headers = {},
|
||||
skipAuth = false,
|
||||
retryOnAuthFailure = true,
|
||||
} = options
|
||||
|
||||
const session = getStoredSession()
|
||||
const requestHeaders: Record<string, string> = {
|
||||
Accept: 'application/json',
|
||||
...headers,
|
||||
}
|
||||
|
||||
if (body !== undefined) {
|
||||
requestHeaders['Content-Type'] = 'application/json'
|
||||
}
|
||||
|
||||
if (!skipAuth && session?.accessToken) {
|
||||
requestHeaders.Authorization = `Bearer ${session.accessToken}`
|
||||
}
|
||||
|
||||
let response
|
||||
|
||||
try {
|
||||
response = await fetch(buildUrl(path), {
|
||||
method,
|
||||
headers: requestHeaders,
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
})
|
||||
} catch (error) {
|
||||
throw new ApiError(
|
||||
'Unable to reach the API. Make sure the backend is running and the certificate is trusted.',
|
||||
0,
|
||||
error,
|
||||
)
|
||||
}
|
||||
|
||||
const data = await readResponse(response)
|
||||
|
||||
if (response.status === 401 && !skipAuth && retryOnAuthFailure && session?.refreshToken) {
|
||||
await refreshSession()
|
||||
return requestJson<T>(path, { ...options, retryOnAuthFailure: false })
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ApiError(
|
||||
extractErrorMessage(data, `${method} ${path} failed with status ${response.status}.`),
|
||||
response.status,
|
||||
data,
|
||||
)
|
||||
}
|
||||
|
||||
return data as T
|
||||
}
|
||||
|
||||
export const authApi = {
|
||||
async register(payload: RegisterPayload): Promise<AuthResponse> {
|
||||
return requestJson<AuthResponse>('/api/auth/register', {
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
skipAuth: true,
|
||||
})
|
||||
},
|
||||
|
||||
async login(payload: AuthCredentials): Promise<AuthResponse> {
|
||||
const data = await requestJson<AuthResponse>('/api/auth/login', {
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
skipAuth: true,
|
||||
})
|
||||
|
||||
saveSession({
|
||||
accessToken: data.accessToken,
|
||||
refreshToken: data.refreshToken,
|
||||
user: data.user ?? null,
|
||||
})
|
||||
|
||||
return data
|
||||
},
|
||||
|
||||
async logout(): Promise<void> {
|
||||
try {
|
||||
await requestJson('/api/auth/logout', {
|
||||
method: 'POST',
|
||||
})
|
||||
} finally {
|
||||
clearSession()
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export const profileApi = {
|
||||
getProfile(): Promise<User> {
|
||||
return requestJson<User>('/api/profile')
|
||||
},
|
||||
|
||||
getProtectedData(): Promise<unknown> {
|
||||
return requestJson<unknown>('/api/profile/data')
|
||||
},
|
||||
|
||||
updateProfile(payload: ProfileUpdatePayload): Promise<User> {
|
||||
return requestJson<User>('/api/profile', {
|
||||
method: 'PUT',
|
||||
body: payload,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
export const locationsApi = {
|
||||
getLocations(): Promise<Location[]> {
|
||||
return requestJson<Location[]>('/api/locations')
|
||||
},
|
||||
|
||||
getLocationHistory(id: EntityId): Promise<LocationHistoryEntry[]> {
|
||||
return requestJson<LocationHistoryEntry[]>(`/api/locations/${id}/history`)
|
||||
},
|
||||
|
||||
createLocation(payload: LocationPayload): Promise<Location> {
|
||||
return requestJson<Location>('/api/locations', {
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
})
|
||||
},
|
||||
|
||||
updateLocation(id: EntityId, payload: LocationPayload): Promise<Location> {
|
||||
return requestJson<Location>(`/api/locations/${id}`, {
|
||||
method: 'PUT',
|
||||
body: payload,
|
||||
})
|
||||
},
|
||||
|
||||
deleteLocation(id: EntityId): Promise<void> {
|
||||
return requestJson<void>(`/api/locations/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
export const inventoryApi = {
|
||||
getInventoryItems(): Promise<InventoryItem[]> {
|
||||
return requestJson<InventoryItem[]>('/api/inventoryitems')
|
||||
},
|
||||
|
||||
getInventoryItem(id: EntityId): Promise<InventoryItem> {
|
||||
return requestJson<InventoryItem>(`/api/inventoryitems/${id}`)
|
||||
},
|
||||
|
||||
createInventoryItem(payload: InventoryItemPayload): Promise<InventoryItem> {
|
||||
return requestJson<InventoryItem>('/api/inventoryitems', {
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
})
|
||||
},
|
||||
|
||||
updateInventoryItem(id: EntityId, payload: InventoryItemPayload): Promise<InventoryItem> {
|
||||
return requestJson<InventoryItem>(`/api/inventoryitems/${id}`, {
|
||||
method: 'PUT',
|
||||
body: payload,
|
||||
})
|
||||
},
|
||||
|
||||
deleteInventoryItem(id: EntityId): Promise<void> {
|
||||
return requestJson<void>(`/api/inventoryitems/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
export const searchApi = {
|
||||
searchLocations(query: string): Promise<Location[]> {
|
||||
return requestJson<Location[]>(`/api/search/locations?q=${encodeURIComponent(query)}`)
|
||||
},
|
||||
|
||||
searchItems(query: string): Promise<InventoryItem[]> {
|
||||
return requestJson<InventoryItem[]>(`/api/search/items?q=${encodeURIComponent(query)}`)
|
||||
},
|
||||
}
|
||||
|
||||
export const householdsApi = {
|
||||
getHouseholds(): Promise<Household[]> {
|
||||
return requestJson<Household[]>('/api/households')
|
||||
},
|
||||
|
||||
getHouseholdHistory(id: EntityId): Promise<HouseholdHistoryEntry[]> {
|
||||
return requestJson<HouseholdHistoryEntry[]>(`/api/households/${id}/history`)
|
||||
},
|
||||
|
||||
createHousehold(payload: HouseholdPayload): Promise<Household> {
|
||||
return requestJson<Household>('/api/households', {
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
})
|
||||
},
|
||||
|
||||
updateHousehold(id: EntityId, payload: HouseholdPayload): Promise<Household> {
|
||||
return requestJson<Household>(`/api/households/${id}`, {
|
||||
method: 'PUT',
|
||||
body: payload,
|
||||
})
|
||||
},
|
||||
|
||||
inviteHouseholdMember(id: EntityId, payload: HouseholdInvitePayload): Promise<void> {
|
||||
return requestJson<void>(`/api/households/${id}/invite`, {
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
})
|
||||
},
|
||||
|
||||
leaveHousehold(id: EntityId): Promise<void> {
|
||||
return requestJson<void>(`/api/households/${id}/leave`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
export const usersApi = {
|
||||
getUsers(): Promise<User[]> {
|
||||
return requestJson<User[]>('/api/users')
|
||||
},
|
||||
|
||||
getUser(id: EntityId): Promise<User> {
|
||||
return requestJson<User>(`/api/users/${id}`)
|
||||
},
|
||||
|
||||
updateUser(id: EntityId, payload: Partial<RegisterPayload> & { roles?: string[] }): Promise<User> {
|
||||
return requestJson<User>(`/api/users/${id}`, {
|
||||
method: 'PUT',
|
||||
body: payload,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
export const shoppingListsApi = {
|
||||
getShoppingLists(): Promise<ShoppingList[]> {
|
||||
return requestJson<ShoppingList[]>('/api/shoppinglists')
|
||||
},
|
||||
|
||||
getShoppingList(id: EntityId): Promise<ShoppingList> {
|
||||
return requestJson<ShoppingList>(`/api/shoppinglists/${id}`)
|
||||
},
|
||||
|
||||
createShoppingList(payload: ShoppingListPayload): Promise<ShoppingList> {
|
||||
return requestJson<ShoppingList>('/api/shoppinglists', {
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
})
|
||||
},
|
||||
|
||||
updateShoppingList(id: EntityId, payload: ShoppingListPayload): Promise<ShoppingList> {
|
||||
return requestJson<ShoppingList>(`/api/shoppinglists/${id}`, {
|
||||
method: 'PUT',
|
||||
body: payload,
|
||||
})
|
||||
},
|
||||
|
||||
deleteShoppingList(id: EntityId): Promise<void> {
|
||||
return requestJson<void>(`/api/shoppinglists/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
export const mealPlannersApi = {
|
||||
getMealPlanners(): Promise<MealPlanner[]> {
|
||||
return requestJson<MealPlanner[]>('/api/mealplanners')
|
||||
},
|
||||
|
||||
getMealPlanner(id: EntityId): Promise<MealPlanner> {
|
||||
return requestJson<MealPlanner>(`/api/mealplanners/${id}`)
|
||||
},
|
||||
|
||||
createMealPlanner(payload: MealPlannerPayload): Promise<MealPlanner> {
|
||||
return requestJson<MealPlanner>('/api/mealplanners', {
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
})
|
||||
},
|
||||
|
||||
updateMealPlanner(id: EntityId, payload: MealPlannerPayload): Promise<MealPlanner> {
|
||||
return requestJson<MealPlanner>(`/api/mealplanners/${id}`, {
|
||||
method: 'PUT',
|
||||
body: payload,
|
||||
})
|
||||
},
|
||||
|
||||
deleteMealPlanner(id: EntityId): Promise<void> {
|
||||
return requestJson<void>(`/api/mealplanners/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user