488 lines
10 KiB
JavaScript
488 lines
10 KiB
JavaScript
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 = null
|
|
|
|
export class ApiError extends Error {
|
|
constructor(message, status = 0, data = null) {
|
|
super(message)
|
|
this.name = 'ApiError'
|
|
this.status = status
|
|
this.data = data
|
|
}
|
|
}
|
|
|
|
function buildUrl(path) {
|
|
return API_BASE_URL ? `${API_BASE_URL}${path}` : path
|
|
}
|
|
|
|
function parseStoredSession(rawValue) {
|
|
if (!rawValue) return null
|
|
|
|
try {
|
|
return JSON.parse(rawValue)
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function dispatchSessionChange(session) {
|
|
if (typeof window === 'undefined') return
|
|
|
|
window.dispatchEvent(new CustomEvent(SESSION_CHANGE_EVENT, {
|
|
detail: session,
|
|
}))
|
|
}
|
|
|
|
export function getStoredSession() {
|
|
if (typeof window === 'undefined') return null
|
|
|
|
return parseStoredSession(window.localStorage.getItem(SESSION_STORAGE_KEY))
|
|
}
|
|
|
|
export function saveSession(session) {
|
|
if (typeof window === 'undefined') return session
|
|
|
|
window.localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(session))
|
|
dispatchSessionChange(session)
|
|
return session
|
|
}
|
|
|
|
export function clearSession() {
|
|
if (typeof window === 'undefined') return
|
|
|
|
window.localStorage.removeItem(SESSION_STORAGE_KEY)
|
|
dispatchSessionChange(null)
|
|
}
|
|
|
|
export function subscribeToSessionChanges(listener) {
|
|
if (typeof window === 'undefined') return () => {}
|
|
|
|
function handleCustomEvent(event) {
|
|
listener(event.detail ?? null)
|
|
}
|
|
|
|
function handleStorageEvent(event) {
|
|
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) {
|
|
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, fallbackMessage) {
|
|
if (!data) return fallbackMessage
|
|
|
|
if (typeof data === 'string' && data.trim()) {
|
|
return data
|
|
}
|
|
|
|
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.join(' ')
|
|
}
|
|
}
|
|
|
|
if (typeof data.title === 'string' && data.title.trim()) {
|
|
return data.title
|
|
}
|
|
|
|
return fallbackMessage
|
|
}
|
|
|
|
async function performRefresh(session) {
|
|
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,
|
|
)
|
|
}
|
|
|
|
return saveSession({
|
|
accessToken: data.accessToken,
|
|
refreshToken: data.refreshToken,
|
|
user: data.user ?? session.user ?? null,
|
|
})
|
|
}
|
|
|
|
async function refreshSession() {
|
|
if (!refreshPromise) {
|
|
refreshPromise = performRefresh(getStoredSession())
|
|
.finally(() => {
|
|
refreshPromise = null
|
|
})
|
|
}
|
|
|
|
return refreshPromise
|
|
}
|
|
|
|
async function requestJson(path, options = {}) {
|
|
const {
|
|
method = 'GET',
|
|
body,
|
|
headers = {},
|
|
skipAuth = false,
|
|
retryOnAuthFailure = true,
|
|
} = options
|
|
|
|
const session = getStoredSession()
|
|
const requestHeaders = {
|
|
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(path, { ...options, retryOnAuthFailure: false })
|
|
}
|
|
|
|
if (!response.ok) {
|
|
throw new ApiError(
|
|
extractErrorMessage(data, `${method} ${path} failed with status ${response.status}.`),
|
|
response.status,
|
|
data,
|
|
)
|
|
}
|
|
|
|
return data
|
|
}
|
|
|
|
export const authApi = {
|
|
async register(payload) {
|
|
return requestJson('/api/auth/register', {
|
|
method: 'POST',
|
|
body: payload,
|
|
skipAuth: true,
|
|
})
|
|
},
|
|
|
|
async login(payload) {
|
|
const data = await requestJson('/api/auth/login', {
|
|
method: 'POST',
|
|
body: payload,
|
|
skipAuth: true,
|
|
})
|
|
|
|
saveSession({
|
|
accessToken: data.accessToken,
|
|
refreshToken: data.refreshToken,
|
|
user: data.user ?? null,
|
|
})
|
|
|
|
return data
|
|
},
|
|
|
|
async logout() {
|
|
try {
|
|
await requestJson('/api/auth/logout', {
|
|
method: 'POST',
|
|
})
|
|
} finally {
|
|
clearSession()
|
|
}
|
|
},
|
|
}
|
|
|
|
export const profileApi = {
|
|
getProfile() {
|
|
return requestJson('/api/profile')
|
|
},
|
|
|
|
getProtectedData() {
|
|
return requestJson('/api/profile/data')
|
|
},
|
|
|
|
updateProfile(payload) {
|
|
return requestJson('/api/profile', {
|
|
method: 'PUT',
|
|
body: payload,
|
|
})
|
|
},
|
|
}
|
|
|
|
export const locationsApi = {
|
|
getLocations() {
|
|
return requestJson('/api/locations')
|
|
},
|
|
|
|
getLocationHistory(id) {
|
|
return requestJson(`/api/locations/${id}/history`)
|
|
},
|
|
|
|
createLocation(payload) {
|
|
return requestJson('/api/locations', {
|
|
method: 'POST',
|
|
body: payload,
|
|
})
|
|
},
|
|
|
|
updateLocation(id, payload) {
|
|
return requestJson(`/api/locations/${id}`, {
|
|
method: 'PUT',
|
|
body: payload,
|
|
})
|
|
},
|
|
|
|
deleteLocation(id) {
|
|
return requestJson(`/api/locations/${id}`, {
|
|
method: 'DELETE',
|
|
})
|
|
},
|
|
}
|
|
|
|
export const inventoryApi = {
|
|
getInventoryItems() {
|
|
return requestJson('/api/inventoryitems')
|
|
},
|
|
|
|
getInventoryItem(id) {
|
|
return requestJson(`/api/inventoryitems/${id}`)
|
|
},
|
|
|
|
createInventoryItem(payload) {
|
|
return requestJson('/api/inventoryitems', {
|
|
method: 'POST',
|
|
body: payload,
|
|
})
|
|
},
|
|
|
|
updateInventoryItem(id, payload) {
|
|
return requestJson(`/api/inventoryitems/${id}`, {
|
|
method: 'PUT',
|
|
body: payload,
|
|
})
|
|
},
|
|
|
|
deleteInventoryItem(id) {
|
|
return requestJson(`/api/inventoryitems/${id}`, {
|
|
method: 'DELETE',
|
|
})
|
|
},
|
|
}
|
|
|
|
export const searchApi = {
|
|
searchLocations(query) {
|
|
return requestJson(`/api/search/locations?q=${encodeURIComponent(query)}`)
|
|
},
|
|
|
|
searchItems(query) {
|
|
return requestJson(`/api/search/items?q=${encodeURIComponent(query)}`)
|
|
},
|
|
}
|
|
|
|
export const householdsApi = {
|
|
getHouseholds() {
|
|
return requestJson('/api/households')
|
|
},
|
|
|
|
getHouseholdHistory(id) {
|
|
return requestJson(`/api/households/${id}/history`)
|
|
},
|
|
|
|
createHousehold(payload) {
|
|
return requestJson('/api/households', {
|
|
method: 'POST',
|
|
body: payload,
|
|
})
|
|
},
|
|
|
|
updateHousehold(id, payload) {
|
|
return requestJson(`/api/households/${id}`, {
|
|
method: 'PUT',
|
|
body: payload,
|
|
})
|
|
},
|
|
|
|
inviteHouseholdMember(id, payload) {
|
|
return requestJson(`/api/households/${id}/invite`, {
|
|
method: 'POST',
|
|
body: payload,
|
|
})
|
|
},
|
|
|
|
leaveHousehold(id) {
|
|
return requestJson(`/api/households/${id}/leave`, {
|
|
method: 'DELETE',
|
|
})
|
|
},
|
|
}
|
|
|
|
export const usersApi = {
|
|
getUsers() {
|
|
return requestJson('/api/users')
|
|
},
|
|
|
|
getUser(id) {
|
|
return requestJson(`/api/users/${id}`)
|
|
},
|
|
|
|
updateUser(id, payload) {
|
|
return requestJson(`/api/users/${id}`, {
|
|
method: 'PUT',
|
|
body: payload,
|
|
})
|
|
},
|
|
}
|
|
|
|
export const shoppingListsApi = {
|
|
getShoppingLists() {
|
|
return requestJson('/api/shoppinglists')
|
|
},
|
|
|
|
getShoppingList(id) {
|
|
return requestJson(`/api/shoppinglists/${id}`)
|
|
},
|
|
|
|
createShoppingList(payload) {
|
|
return requestJson('/api/shoppinglists', {
|
|
method: 'POST',
|
|
body: payload,
|
|
})
|
|
},
|
|
|
|
updateShoppingList(id, payload) {
|
|
return requestJson(`/api/shoppinglists/${id}`, {
|
|
method: 'PUT',
|
|
body: payload,
|
|
})
|
|
},
|
|
|
|
deleteShoppingList(id) {
|
|
return requestJson(`/api/shoppinglists/${id}`, {
|
|
method: 'DELETE',
|
|
})
|
|
},
|
|
}
|
|
|
|
export const mealPlannersApi = {
|
|
getMealPlanners() {
|
|
return requestJson('/api/mealplanners')
|
|
},
|
|
|
|
getMealPlanner(id) {
|
|
return requestJson(`/api/mealplanners/${id}`)
|
|
},
|
|
|
|
createMealPlanner(payload) {
|
|
return requestJson('/api/mealplanners', {
|
|
method: 'POST',
|
|
body: payload,
|
|
})
|
|
},
|
|
|
|
updateMealPlanner(id, payload) {
|
|
return requestJson(`/api/mealplanners/${id}`, {
|
|
method: 'PUT',
|
|
body: payload,
|
|
})
|
|
},
|
|
|
|
deleteMealPlanner(id) {
|
|
return requestJson(`/api/mealplanners/${id}`, {
|
|
method: 'DELETE',
|
|
})
|
|
},
|
|
}
|