- New /travelline route: accepts all NetUP polls, logs them to in-memory ring buffer (GET /travelline/_log to inspect), responds with active reservations in a generic PMS format - Migration 007: adds tl_token column to netup_settings - PATCH netup/settings now saves tl_token; GET returns it - TvWelcomePage: new TravelLine section with read-only API URL + copy button and token input field Setup in NetUP: Integration type = TravelLine, API URL = https://api.hotelsync.ru/travelline, Token = <tl_token> Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
375 lines
17 KiB
TypeScript
375 lines
17 KiB
TypeScript
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)),
|
||
},
|
||
|
||
// ── NetUP IPTV ────────────────────────────────────────────────────────────
|
||
netup: {
|
||
getSettings: (slug: string) =>
|
||
req<{ serverUrl: string; username: string; password: string; defaultLanguage: string; enabled: boolean; tlToken: string }>(
|
||
'GET', `/api/hotels/${slug}/netup/settings`),
|
||
|
||
saveSettings: (slug: string, data: { server_url: string; username: string; password?: string; default_language: string; enabled: boolean; tl_token: string }) =>
|
||
req<{ ok: boolean }>('PATCH', `/api/hotels/${slug}/netup/settings`, data),
|
||
|
||
testConnection: (slug: string) =>
|
||
req<{ ok: boolean; message: string }>('POST', `/api/hotels/${slug}/netup/test`),
|
||
|
||
getRoomMappings: (slug: string) =>
|
||
req<{ id: string; number: string; type: string; netupRoomNumber: string }[]>(
|
||
'GET', `/api/hotels/${slug}/netup/rooms`),
|
||
|
||
saveRoomMappings: (slug: string, mappings: { pms_room_id: string; netup_room_number: string }[]) =>
|
||
req<{ ok: boolean }>('POST', `/api/hotels/${slug}/netup/rooms`, { mappings }),
|
||
|
||
sendMessage: (slug: string, roomId: string, message: string, guestName?: string) =>
|
||
req<{ ok: boolean }>('POST', `/api/hotels/${slug}/netup/message`, { room_id: roomId, message, guest_name: guestName }),
|
||
},
|
||
}
|
||
|
||
// ── 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
|
||
}
|