Files
hotelsync/src/lib/api.ts
HotelSync bc12f8aabc Fix WS offline on first login: sync token between api.ts and AuthContext
Two root causes fixed:
1. api.ts saveToken() updated only localStorage, not React state — WS kept
   reconnecting with the expired token. Now dispatches hotelsync:token-updated
   event; AuthContext listens and updates session state, triggering WS reconnect
   with the fresh token.
2. AuthContext mount effect could race with login(): if refresh failed while
   login() was concurrently setting a new token, catch() called setSession(null)
   and wiped the fresh session. Fixed with functional setSession updater that
   checks current state before clearing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 17:37:36 +03:00

352 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Room, Booking, HousekeepingTask, Channel, ChannelName, Hotel, User } from '../types'
// ── Base URL ────────────────────────────────────────────────────────────────
const BASE = (import.meta.env.VITE_API_URL as string | undefined) ?? 'https://api.hotelsync.ru'
// ── Errors ──────────────────────────────────────────────────────────────────
export class ApiError extends Error {
constructor(public status: number, message: string) {
super(message)
}
}
// ── Token helpers ────────────────────────────────────────────────────────────
function getToken(): string | null {
try {
const s = localStorage.getItem('hotelsync-session')
return s ? (JSON.parse(s) as { token: string }).token : null
} catch {
return null
}
}
function saveToken(token: string) {
try {
const s = localStorage.getItem('hotelsync-session')
if (!s) return
const parsed = JSON.parse(s) as Record<string, unknown>
parsed.token = token
localStorage.setItem('hotelsync-session', JSON.stringify(parsed))
// Notify AuthContext so React state stays in sync (WS reconnects with fresh token)
window.dispatchEvent(new CustomEvent('hotelsync:token-updated', { detail: { token } }))
} catch {
// ignore
}
}
// ── Snake ↔ camelCase transform ──────────────────────────────────────────────
function toCamel(s: string): string {
return s.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase())
}
function transformKeys(val: unknown): unknown {
if (Array.isArray(val)) return val.map(transformKeys)
if (val && typeof val === 'object' && !(val instanceof Date)) {
const result: Record<string, unknown> = {}
for (const [k, v] of Object.entries(val as Record<string, unknown>)) {
result[toCamel(k)] = transformKeys(v)
}
return result
}
return val
}
// ── Core request ─────────────────────────────────────────────────────────────
async function req<T>(
method: string,
path: string,
body?: unknown,
): Promise<T> {
const headers: Record<string, string> = {}
if (body !== undefined) headers['Content-Type'] = 'application/json'
const token = getToken()
if (token) headers['Authorization'] = `Bearer ${token}`
const doFetch = (t: string | null) =>
fetch(`${BASE}${path}`, {
method,
headers: t ? { ...headers, Authorization: `Bearer ${t}` } : headers,
credentials: 'include',
body: body !== undefined ? JSON.stringify(body) : undefined,
})
let res = await doFetch(token)
// Auto-refresh on 401
if (res.status === 401) {
const refreshRes = await fetch(`${BASE}/api/auth/refresh`, {
method: 'POST',
credentials: 'include',
})
if (refreshRes.ok) {
const { access_token } = (await refreshRes.json()) as { access_token: string }
saveToken(access_token)
res = await doFetch(access_token)
} else {
localStorage.removeItem('hotelsync-session')
window.location.href = '/login'
throw new ApiError(401, 'Session expired')
}
}
if (res.status === 204) return undefined as T
const data: unknown = await res.json()
if (!res.ok) {
const msg = (data as Record<string, string>)?.error ?? 'Request failed'
throw new ApiError(res.status, msg)
}
return transformKeys(data) as T
}
// ── Channel normalization ────────────────────────────────────────────────────
const CHANNEL_DISPLAY_NAMES: Record<string, string> = {
booking_com: 'Booking.com',
airbnb: 'Airbnb',
expedia: 'Expedia',
vrbo: 'VRBO',
yandex_travel: 'Яндекс Путешествия',
ostrovok: 'Островок',
sutochno: 'Суточно.ру',
onetwotrip: 'OneTwoTrip',
}
function normalizeChannel(raw: Record<string, unknown>): Channel {
const name = raw.name as ChannelName
return {
id: raw.id as string,
hotelId: (raw.hotelId as string) ?? '',
name,
displayName: CHANNEL_DISPLAY_NAMES[name as string] ?? String(name),
isEnabled: Boolean(raw.enabled),
lastSyncAt: (raw.lastSyncedAt as string | null) ?? null,
lastSyncStatus: raw.lastSyncedAt ? 'success' : 'idle',
bookingsImported: 0,
mappings: [],
}
}
// ── API methods ──────────────────────────────────────────────────────────────
export const api = {
// ── Auth ─────────────────────────────────────────────────────────────────
auth: {
login: (email: string, password: string) =>
req<{ access_token: string; user: User }>('POST', '/api/auth/login', { email, password }),
logout: () =>
req<void>('POST', '/api/auth/logout'),
me: () =>
req<User>('GET', '/api/auth/me'),
register: (data: {
hotelName: string
address?: string
contact: string
email: string
phone?: string
password: string
}) =>
req<{ ok: boolean; message: string }>('POST', '/api/auth/register', data),
forgotPassword: (email: string) =>
req<{ ok: boolean }>('POST', '/api/auth/forgot-password', { email }),
resetPassword: (token: string, password: string) =>
req<{ ok: boolean }>('POST', '/api/auth/reset-password', { token, password }),
resendConfirmation: (email: string) =>
req<{ ok: boolean }>('POST', '/api/auth/resend-confirmation', { email }),
},
// ── Rooms ─────────────────────────────────────────────────────────────────
rooms: {
list: (slug: string) =>
req<Room[]>('GET', `/api/hotels/${slug}/rooms`),
create: (slug: string, data: RoomPayload) =>
req<Room>('POST', `/api/hotels/${slug}/rooms`, toRoomPayload(data)),
update: (slug: string, id: string, data: Partial<RoomPayload>) =>
req<Room>('PATCH', `/api/hotels/${slug}/rooms/${id}`, toRoomPayload(data)),
delete: (slug: string, id: string) =>
req<void>('DELETE', `/api/hotels/${slug}/rooms/${id}`),
},
// ── Bookings ──────────────────────────────────────────────────────────────
bookings: {
list: (slug: string, params?: { start?: string; end?: string; status?: string }) => {
const qs = new URLSearchParams()
if (params?.start) qs.set('start', params.start)
if (params?.end) qs.set('end', params.end)
if (params?.status) qs.set('status', params.status)
const q = qs.toString()
return req<Booking[]>('GET', `/api/hotels/${slug}/bookings${q ? `?${q}` : ''}`)
},
create: (slug: string, data: BookingPayload) =>
req<Booking>('POST', `/api/hotels/${slug}/bookings`, toBookingPayload(data)),
update: (slug: string, id: string, data: Partial<BookingPayload>) =>
req<Booking>('PATCH', `/api/hotels/${slug}/bookings/${id}`, toBookingPayload(data)),
delete: (slug: string, id: string) =>
req<void>('DELETE', `/api/hotels/${slug}/bookings/${id}`),
},
// ── Housekeeping ──────────────────────────────────────────────────────────
housekeeping: {
list: (slug: string, params?: { date?: string; status?: string }) => {
const qs = new URLSearchParams()
if (params?.date) qs.set('date', params.date)
if (params?.status) qs.set('status', params.status)
const q = qs.toString()
return req<HousekeepingTask[]>('GET', `/api/hotels/${slug}/housekeeping${q ? `?${q}` : ''}`)
},
create: (slug: string, data: HkPayload) =>
req<HousekeepingTask>('POST', `/api/hotels/${slug}/housekeeping`, data),
update: (slug: string, id: string, data: Partial<HkPayload>) =>
req<HousekeepingTask>('PATCH', `/api/hotels/${slug}/housekeeping/${id}`, data),
delete: (slug: string, id: string) =>
req<void>('DELETE', `/api/hotels/${slug}/housekeeping/${id}`),
},
// ── Channels ──────────────────────────────────────────────────────────────
channels: {
list: async (slug: string): Promise<Channel[]> => {
const raw = await req<Record<string, unknown>[]>('GET', `/api/hotels/${slug}/channels`)
return raw.map(normalizeChannel)
},
update: async (slug: string, id: string, data: { enabled?: boolean; api_key?: string }): Promise<Channel> => {
const raw = await req<Record<string, unknown>>('PATCH', `/api/hotels/${slug}/channels/${id}`, data)
return normalizeChannel(raw)
},
sync: async (slug: string, id: string): Promise<{ channel: Channel; syncedBookings: number }> => {
const raw = await req<Record<string, unknown>>('POST', `/api/hotels/${slug}/channels/${id}/sync`)
return { channel: normalizeChannel(raw), syncedBookings: (raw.syncedBookings as number) ?? 0 }
},
},
// ── Users ─────────────────────────────────────────────────────────────────
users: {
list: (slug: string) =>
req<User[]>('GET', `/api/hotels/${slug}/users`),
create: (slug: string, data: { email: string; password: string; name: string; role: string }) =>
req<User>('POST', `/api/hotels/${slug}/users`, data),
update: (slug: string, id: string, data: Partial<{ name: string; email: string; password: string }>) =>
req<User>('PATCH', `/api/hotels/${slug}/users/${id}`, data),
delete: (slug: string, id: string) =>
req<void>('DELETE', `/api/hotels/${slug}/users/${id}`),
},
// ── Hotels ────────────────────────────────────────────────────────────────
hotels: {
get: (slug: string) =>
req<Hotel>('GET', `/api/hotels/${slug}`),
update: (slug: string, data: HotelPayload) =>
req<Hotel>('PATCH', `/api/hotels/${slug}`, toHotelPayload(data)),
},
}
// ── Payload types & converters ───────────────────────────────────────────────
export interface RoomPayload {
number?: string; type?: string; floor?: number
maxGuests?: number; baseRate?: number; status?: string
amenities?: string[]; name?: string; categoryId?: string
bedType?: string; beds?: unknown; housekeepingStatus?: string
sortOrder?: number; allowHourly?: boolean; hourlyRate?: number
extraPlace?: unknown; childPolicy?: unknown
description?: string; photos?: string[]
}
function toRoomPayload(r: Partial<RoomPayload>): Record<string, unknown> {
const out: Record<string, unknown> = {}
if (r.number !== undefined) out.number = r.number
if (r.type !== undefined) out.type = r.type
if (r.floor !== undefined) out.floor = r.floor
if (r.maxGuests !== undefined) out.max_guests = r.maxGuests
if (r.baseRate !== undefined) out.base_rate = r.baseRate
if (r.status !== undefined) out.status = r.status
if (r.amenities !== undefined) out.amenities = r.amenities
if (r.name !== undefined) out.name = r.name
if (r.categoryId !== undefined) out.category_id = r.categoryId
if (r.bedType !== undefined) out.bed_type = r.bedType
if (r.beds !== undefined) out.beds = r.beds
if (r.housekeepingStatus !== undefined) out.housekeeping_status = r.housekeepingStatus
if (r.sortOrder !== undefined) out.sort_order = r.sortOrder
if (r.allowHourly !== undefined) out.allow_hourly = r.allowHourly
if (r.hourlyRate !== undefined) out.hourly_rate = r.hourlyRate
if (r.extraPlace !== undefined) out.extra_place = r.extraPlace
if (r.childPolicy !== undefined) out.child_policy = r.childPolicy
if (r.description !== undefined) out.description = r.description
if (r.photos !== undefined) out.photos = r.photos
return out
}
export interface BookingPayload {
roomId?: string; guestName?: string; guestEmail?: string; guestPhone?: string
checkIn?: string; checkOut?: string; adults?: number; children?: number
status?: string; source?: string; totalAmount?: number; paidAmount?: number; notes?: string
}
function toBookingPayload(b: Partial<BookingPayload>): Record<string, unknown> {
const out: Record<string, unknown> = {}
if (b.roomId !== undefined) out.room_id = b.roomId
if (b.guestName !== undefined) out.guest_name = b.guestName
if (b.guestEmail !== undefined) out.guest_email = b.guestEmail
if (b.guestPhone !== undefined) out.guest_phone = b.guestPhone
if (b.checkIn !== undefined) out.check_in = b.checkIn
if (b.checkOut !== undefined) out.check_out = b.checkOut
if (b.adults !== undefined) out.adults = b.adults
if (b.children !== undefined) out.children = b.children
if (b.status !== undefined) out.status = b.status
if (b.source !== undefined) out.source = b.source
if (b.totalAmount !== undefined) out.total_amount = b.totalAmount
if (b.paidAmount !== undefined) out.paid_amount = b.paidAmount
if (b.notes !== undefined) out.notes = b.notes
return out
}
export interface HkPayload {
room_id?: string; type?: string; priority?: string
status?: string; assignee_id?: string; notes?: string; due_date?: string
}
export interface HotelPayload {
name?: string; address?: string; phone?: string
timezone?: string; currency?: string
checkInTime?: string; checkOutTime?: string
}
function toHotelPayload(h: HotelPayload): Record<string, unknown> {
const out: Record<string, unknown> = {}
if (h.name !== undefined) out.name = h.name
if (h.address !== undefined) out.address = h.address
if (h.phone !== undefined) out.phone = h.phone
if (h.timezone !== undefined) out.timezone = h.timezone
if (h.currency !== undefined) out.currency = h.currency
if (h.checkInTime !== undefined) out.check_in_time = h.checkInTime
if (h.checkOutTime !== undefined) out.check_out_time = h.checkOutTime
return out
}