diff --git a/src/lib/components/DeleteModal.svelte b/src/lib/components/DeleteModal.svelte new file mode 100644 index 0000000..3c346e8 --- /dev/null +++ b/src/lib/components/DeleteModal.svelte @@ -0,0 +1,115 @@ + + +{#if open && target} + +{/if} + + diff --git a/src/lib/components/EditReservationModal.svelte b/src/lib/components/EditReservationModal.svelte new file mode 100644 index 0000000..52068ef --- /dev/null +++ b/src/lib/components/EditReservationModal.svelte @@ -0,0 +1,177 @@ + + +{#if open && reservation} + +{/if} + + diff --git a/src/lib/components/EditTourModal.svelte b/src/lib/components/EditTourModal.svelte new file mode 100644 index 0000000..e0c0fc3 --- /dev/null +++ b/src/lib/components/EditTourModal.svelte @@ -0,0 +1,162 @@ + + +{#if open && booking} + +{/if} + + diff --git a/src/lib/components/RentalCard.svelte b/src/lib/components/RentalCard.svelte index e025869..77e7423 100644 --- a/src/lib/components/RentalCard.svelte +++ b/src/lib/components/RentalCard.svelte @@ -7,18 +7,19 @@ rental, loading = false, onEdit, + onDelete, onClose }: { rental: any; loading?: boolean; onEdit: (id: number) => void; + onDelete: (id: number, label: string) => void; onClose: (id: number) => void; } = $props(); const customer = $derived(rental.customer as {name?: string, hotel?: string}); const items = $derived(rental.items as Array<{name: string, quantity?: number, code?: string}>); const pricing = $derived(rental.pricing as {type?: string}); - const guideName = $derived(rental.guideName as string | null); let elapsedTime = $state(''); let interval: ReturnType | null = null; @@ -34,89 +35,106 @@ onMount(() => { updateElapsed(); - interval = setInterval(updateElapsed, 60000); + interval = setInterval(updateElapsed, 30000); }); onDestroy(() => { if (interval) clearInterval(interval); }); - - const accentColor = $derived(pricing?.type === 'hourly' ? 'var(--md-sys-color-tertiary)' : 'var(--md-sys-color-secondary)'); -
-
+ diff --git a/src/lib/components/ReservationCard.svelte b/src/lib/components/ReservationCard.svelte index 447f36f..2ffa539 100644 --- a/src/lib/components/ReservationCard.svelte +++ b/src/lib/components/ReservationCard.svelte @@ -5,109 +5,133 @@ let { reservation, loading = false, - onCancel, + onEdit, + onDelete, onStartRental }: { reservation: any; loading?: boolean; - onCancel: (id: number) => void; + onEdit: (reservation: any) => void; + onDelete: (id: number, label: string) => void; onStartRental: (reservation: any) => void; } = $props(); const customer = $derived(reservation.customer as {name?: string, hotel?: string} | null); const items = $derived(reservation.items as Array<{name: string, quantity?: number, code?: string}>); + const expired = $derived(new Date(reservation.reservedUntil) < new Date()); function formatDate(date: string | Date): string { return new Date(date).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); } - - const expired = $derived(new Date(reservation.reservedUntil) < new Date()); -
-
+ diff --git a/src/lib/sections/PreviousRentalsTab.svelte b/src/lib/sections/PreviousRentalsTab.svelte new file mode 100644 index 0000000..b82b1d2 --- /dev/null +++ b/src/lib/sections/PreviousRentalsTab.svelte @@ -0,0 +1,40 @@ + + +{#if rentals.length === 0} +
+ history +

No previous rentals

+
+{:else} +
+ {#each rentals as rental (rental.id)} + + {/each} +
+{/if} + + diff --git a/src/lib/sections/ReservationsTab.svelte b/src/lib/sections/ReservationsTab.svelte new file mode 100644 index 0000000..b07b8fe --- /dev/null +++ b/src/lib/sections/ReservationsTab.svelte @@ -0,0 +1,127 @@ + + +{#if reservations.length === 0} +
+ event +

No active reservations

+
+{:else} +
+ {#each reservations as reservation (reservation.id)} + onPromptDelete('reservation', id, label)} + onStartRental={handleStartRental} + /> + {/each} +
+{/if} + + + + + + diff --git a/src/lib/sections/StoreSalesTab.svelte b/src/lib/sections/StoreSalesTab.svelte new file mode 100644 index 0000000..38ac679 --- /dev/null +++ b/src/lib/sections/StoreSalesTab.svelte @@ -0,0 +1,121 @@ + + +
+ {#if storeSales.length === 0} +
+ storefront +

No sales this shift

+
+ {:else} +
+ {#each storeSales as sale (sale.id)} +
+
+ {sale.productName} + x{sale.quantity} @ ${(sale.unitPrice / 100).toFixed(2)} +
+
+ ${(sale.total / 100).toFixed(2)} + +
+
+ {/each} +
+ Total + ${(salesTotalCents / 100).toFixed(2)} +
+
+ {/if} +
+ + + + diff --git a/src/lib/sections/ToursTab.svelte b/src/lib/sections/ToursTab.svelte new file mode 100644 index 0000000..485289b --- /dev/null +++ b/src/lib/sections/ToursTab.svelte @@ -0,0 +1,146 @@ + + +{#if tourBookings.length === 0} +
+ tour +

No active tour bookings

+
+{:else} +
+ {#each tourBookings as booking (booking.id)} + onPromptDelete('tourBooking', id, label)} + onClose={openClose} + /> + {/each} +
+{/if} + + + + + + + + diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index 4d325eb..eb2ff7c 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -1,6 +1,12 @@ import bcrypt from 'bcryptjs'; -import { randomBytes, scryptSync, timingSafeEqual } from 'crypto'; +import { createHmac, randomBytes, scryptSync, timingSafeEqual } from 'crypto'; import { logger } from '$lib/server/logger'; +import { db } from '$lib/server/db'; +import { rateLimits } from '$lib/server/db/schema'; +import { eq, lt } from 'drizzle-orm'; +import { sql } from 'drizzle-orm'; +import { env } from '$env/dynamic/private'; +import type { Cookies } from '@sveltejs/kit'; const SALT_ROUNDS = 10; const SCRYPT_KEYLEN = 32; @@ -23,7 +29,6 @@ export async function verifyPasscode(passcode: string, stored: string): Promise< if (stored.startsWith('$2a$') || stored.startsWith('$2b$')) { return bcrypt.compare(passcode, stored); } - // Reject plaintext-stored passcodes — they must be rehashed logger.warn('Rejected login attempt against unhashed passcode'); return false; } @@ -37,54 +42,74 @@ export function generateSessionToken(): string { return randomBytes(32).toString('hex'); } -const attempts = new Map(); - const MAX_ATTEMPTS = 20; const WINDOW_MS = 15 * 60 * 1000; -const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; - -// Periodically purge expired entries to prevent unbounded memory growth -setInterval(() => { - const now = Date.now(); - for (const [key, entry] of attempts) { - if (now >= entry.resetAt) { - attempts.delete(key); - } + +let cleanupCounter = 0; +const CLEANUP_EVERY_N = 20; + +export async function checkRateLimit(key: string): Promise<{ allowed: boolean; retryAfterSeconds?: number }> { + const now = new Date(); + const resetAt = new Date(now.getTime() + WINDOW_MS); + + if (++cleanupCounter >= CLEANUP_EVERY_N) { + cleanupCounter = 0; + db.delete(rateLimits).where(lt(rateLimits.resetAt, now)).execute().catch(() => {}); } -}, CLEANUP_INTERVAL_MS).unref(); - -export function checkRateLimit(key: string): { allowed: boolean; retryAfterSeconds?: number } { - const now = Date.now(); - pruneExpiredEntries(now); - const entry = attempts.get(key); - - if (entry && now < entry.resetAt) { - if (entry.count >= MAX_ATTEMPTS) { - const retryAfterSeconds = Math.ceil((entry.resetAt - now) / 1000); - return { allowed: false, retryAfterSeconds }; - } - entry.count++; - return { allowed: true }; + + const result = await db + .insert(rateLimits) + .values({ key, count: 1, resetAt }) + .onConflictDoUpdate({ + target: rateLimits.key, + set: { + count: sql`CASE WHEN ${rateLimits.resetAt} < ${now} THEN 1 ELSE ${rateLimits.count} + 1 END`, + resetAt: sql`CASE WHEN ${rateLimits.resetAt} < ${now} THEN ${resetAt} ELSE ${rateLimits.resetAt} END` + } + }) + .returning({ count: rateLimits.count, resetAt: rateLimits.resetAt }); + + const row = result[0]; + if (row.count > MAX_ATTEMPTS) { + const retryAfterSeconds = Math.ceil((row.resetAt.getTime() - now.getTime()) / 1000); + return { allowed: false, retryAfterSeconds }; } - attempts.set(key, { count: 1, resetAt: now + WINDOW_MS }); return { allowed: true }; } -export function clearRateLimit(key: string): void { - attempts.delete(key); +export async function clearRateLimit(key: string): Promise { + await db.delete(rateLimits).where(eq(rateLimits.key, key)); } -// Prune expired entries periodically to prevent memory leaks -let lastPrune = 0; -const PRUNE_INTERVAL_MS = 60 * 1000; - -function pruneExpiredEntries(now: number): void { - if (now - lastPrune < PRUNE_INTERVAL_MS) return; - lastPrune = now; - for (const [key, entry] of attempts) { - if (now >= entry.resetAt) { - attempts.delete(key); - } - } +function getCookieSecret(): string { + const secret = env.COOKIE_SECRET; + if (!secret) throw new Error('COOKIE_SECRET environment variable is required'); + return secret; +} + +export function signCookieValue(value: string): string { + const hmac = createHmac('sha256', getCookieSecret()).update(value).digest('hex'); + return `${value}.${hmac}`; +} + +export function verifyCookieValue(signed: string): string | null { + const dot = signed.lastIndexOf('.'); + if (dot === -1) return null; + const value = signed.slice(0, dot); + const sig = signed.slice(dot + 1); + const expected = createHmac('sha256', getCookieSecret()).update(value).digest('hex'); + if (sig.length !== expected.length) return null; + if (!timingSafeEqual(Buffer.from(sig, 'hex'), Buffer.from(expected, 'hex'))) return null; + return value; +} + +export function getVerifiedOperatorId(cookies: Cookies): number | null { + const raw = cookies.get('operatorId'); + if (!raw) return null; + const value = verifyCookieValue(raw); + if (!value) return null; + const id = parseInt(value); + if (isNaN(id)) return null; + return id; } diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index 52bcf98..b07a692 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -203,6 +203,12 @@ export const payments = pgTable('payments', { index('idx_payments_rental').on(table.rentalId) ]); +export const rateLimits = pgTable('rate_limits', { + key: text('key').primaryKey(), + count: integer('count').notNull().default(1), + resetAt: timestamp('reset_at', { withTimezone: true }).notNull() +}); + export const appSettings = pgTable('app_settings', { id: serial('id').primaryKey(), key: text('key').unique().notNull(), diff --git a/src/lib/services/api.ts b/src/lib/services/api.ts new file mode 100644 index 0000000..6f08d5e --- /dev/null +++ b/src/lib/services/api.ts @@ -0,0 +1,54 @@ +export interface ApiError { + message: string; + status: number; + data?: any; +} + +async function handleResponse(res: Response) { + if (res.ok) return res; + let body: any = {}; + try { + body = await res.json(); + } catch { + body = { message: res.statusText }; + } + const err: ApiError = { + message: body.error || body.message || res.statusText, + status: res.status, + data: body + }; + throw err; +} + +export async function apiPost(url: string, body: any) { + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + return handleResponse(res); +} + +export async function apiPatch(url: string, body: any) { + const res = await fetch(url, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + return handleResponse(res); +} + +export async function apiDelete(url: string, body?: any) { + const res = await fetch(url, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: body ? JSON.stringify(body) : undefined + }); + return handleResponse(res); +} + +export async function apiGet(url: string, params?: Record) { + const query = params ? '?' + new URLSearchParams(params).toString() : ''; + const res = await fetch(url + query); + return handleResponse(res); +} diff --git a/src/lib/services/guide.service.ts b/src/lib/services/guide.service.ts new file mode 100644 index 0000000..3867dd8 --- /dev/null +++ b/src/lib/services/guide.service.ts @@ -0,0 +1,10 @@ +import { apiPost } from './api'; + +export async function verifyGuidePin(guideId: number, passcode: string): Promise { + try { + await apiPost('/api/guides/verify', { guideId, passcode }); + return true; + } catch { + return false; + } +} diff --git a/src/lib/services/rental.service.ts b/src/lib/services/rental.service.ts new file mode 100644 index 0000000..bf80a2b --- /dev/null +++ b/src/lib/services/rental.service.ts @@ -0,0 +1,56 @@ +import { apiPost, apiPatch, apiDelete, type ApiError } from './api'; + +export async function createRental(payload: any) { + try { + const res = await apiPost('/api/rentals', payload); + return res.json(); + } catch (err: any) { + if (err.status === 409 && err.data?.error === 'reservation_conflict') { + const conflictErr: ApiError & { conflicts: any[] } = { + ...err, + conflicts: err.data.conflicts || [] + }; + throw conflictErr; + } + throw err; + } +} + +export async function createRentalWithOverride( + payload: any, + overrideReservationIds: number[], + operatorPasscode: string, + overrideOperatorId: number +) { + const res = await apiPost('/api/rentals', { + ...payload, + overrideReservationIds, + operatorPasscode, + overrideOperatorId + }); + return res.json(); +} + +export async function closeRental(id: number, returnData: any, currentShiftId: number) { + const res = await apiPatch('/api/rentals', { + action: 'close', + id, + currentShiftId, + returnData + }); + return res.json(); +} + +export async function editRental(id: number, data: any) { + const res = await apiPatch('/api/rentals', { + action: 'edit', + id, + ...data + }); + return res.json(); +} + +export async function deleteRental(id: number, passcode: string) { + const res = await apiDelete('/api/rentals', { id, passcode }); + return res.json(); +} diff --git a/src/lib/services/reservation.service.ts b/src/lib/services/reservation.service.ts new file mode 100644 index 0000000..81f57b4 --- /dev/null +++ b/src/lib/services/reservation.service.ts @@ -0,0 +1,77 @@ +import { apiPost, apiPatch } from './api'; + +export async function createReservation(payload: any) { + const res = await apiPost('/api/reservations', payload); + return res.json(); +} + +export async function editReservation(id: number, data: any) { + const res = await apiPatch('/api/reservations', { + action: 'edit', + id, + ...data + }); + return res.json(); +} + +export async function cancelReservation(id: number, passcode: string) { + const res = await apiPatch('/api/reservations', { + action: 'cancel', + id, + passcode + }); + return res.json(); +} + +export function mapReservationToPrefill(reservation: any, products: any[]) { + const items = reservation.items as Array<{ + type: string; + itemId?: number; + categoryId?: number; + code?: string; + name: string; + quantity?: number; + }>; + const customer = reservation.customer as { + name?: string; + hotel?: string; + phone?: string; + } | null; + + const trackedItems: Record = {}; + const genericItems: Record = {}; + + for (const item of items) { + if (item.type === 'tracked' && item.categoryId && item.itemId) { + if (!trackedItems[item.categoryId]) trackedItems[item.categoryId] = []; + trackedItems[item.categoryId].push(item.itemId); + } else if (item.type === 'generic' && item.categoryId) { + genericItems[item.categoryId] = true; + } + } + + const categoryIds = new Set( + [...Object.keys(trackedItems), ...Object.keys(genericItems)].map(Number) + ); + let matchedProductId: number | undefined; + for (const product of products) { + const equipment = product.equipment as Array<{ categoryId: number }> | null; + if (equipment) { + const productCategoryIds = new Set(equipment.map((e: any) => e.categoryId)); + if ([...categoryIds].every(id => productCategoryIds.has(id))) { + matchedProductId = product.id; + break; + } + } + } + + return { + productId: matchedProductId, + customerName: customer?.name || '', + customerHotel: customer?.hotel || '', + customerPhone: customer?.phone || '', + guideId: reservation.guideId || null, + trackedItems, + genericItems + }; +} diff --git a/src/lib/services/shift.service.ts b/src/lib/services/shift.service.ts new file mode 100644 index 0000000..2ff4726 --- /dev/null +++ b/src/lib/services/shift.service.ts @@ -0,0 +1,38 @@ +import { apiGet, apiPost } from './api'; + +export async function getShiftSummary(shiftId: number) { + const res = await apiGet('/api/shifts/summary', { shiftId: String(shiftId) }); + return res.json(); +} + +export async function getClosingChecklist() { + const res = await apiGet('/api/closing-checklist'); + const data = await res.json(); + return data.filter((item: any) => item.isActive !== false); +} + +export async function endShift() { + const res = await fetch('/api/shifts/close', { method: 'POST' }); + const contentType = res.headers.get('content-type') || ''; + + if (contentType.includes('application/json')) { + const data = await res.json(); + if (data.url) { + window.open(data.url, '_blank'); + } + return data; + } + + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + const disposition = res.headers.get('content-disposition') || ''; + const match = disposition.match(/filename="?([^"]+)"?/); + a.download = match?.[1] || 'shift-report.xlsx'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + return { success: true }; +} diff --git a/src/lib/services/store-sale.service.ts b/src/lib/services/store-sale.service.ts new file mode 100644 index 0000000..770a3c2 --- /dev/null +++ b/src/lib/services/store-sale.service.ts @@ -0,0 +1,11 @@ +import { apiPost, apiDelete } from './api'; + +export async function createStoreSale(shiftId: number, productId: number, quantity: number) { + const res = await apiPost('/api/store-sales', { shiftId, productId, quantity }); + return res.json(); +} + +export async function deleteStoreSale(id: number, passcode: string) { + const res = await apiDelete('/api/store-sales', { id, passcode }); + return res.json(); +} diff --git a/src/lib/services/tour.service.ts b/src/lib/services/tour.service.ts new file mode 100644 index 0000000..39add87 --- /dev/null +++ b/src/lib/services/tour.service.ts @@ -0,0 +1,29 @@ +import { apiPost, apiPatch, apiDelete } from './api'; + +export async function createTourBooking(payload: any) { + const res = await apiPost('/api/tour-bookings', payload); + return res.json(); +} + +export async function editTourBooking(id: number, data: any) { + const res = await apiPatch('/api/tour-bookings', { + action: 'edit', + id, + ...data + }); + return res.json(); +} + +export async function closeTourBooking(id: number, cost: number) { + const res = await apiPatch('/api/tour-bookings', { + action: 'close', + id, + cost + }); + return res.json(); +} + +export async function deleteTourBooking(id: number, passcode: string) { + const res = await apiDelete('/api/tour-bookings', { id, passcode }); + return res.json(); +} diff --git a/src/lib/stores/toast.ts b/src/lib/stores/toast.ts new file mode 100644 index 0000000..8c74db3 --- /dev/null +++ b/src/lib/stores/toast.ts @@ -0,0 +1,25 @@ +import { writable } from 'svelte/store'; + +interface ToastState { + message: string; + variant: 'success' | 'error' | 'warning' | 'info'; + visible: boolean; +} + +function createToastStore() { + const { subscribe, set } = writable({ + message: '', + variant: 'success', + visible: false + }); + + return { + subscribe, + success: (message: string) => set({ message, variant: 'success', visible: true }), + error: (message: string) => set({ message, variant: 'error', visible: true }), + warning: (message: string) => set({ message, variant: 'warning', visible: true }), + dismiss: () => set({ message: '', variant: 'success', visible: false }) + }; +} + +export const toastStore = createToastStore(); diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts index 86f42e6..f6804d4 100644 --- a/src/routes/+layout.server.ts +++ b/src/routes/+layout.server.ts @@ -1,18 +1,13 @@ import { db } from '$lib/server/db'; import { operators, shifts } from '$lib/server/db/schema'; import { eq, and, isNull } from 'drizzle-orm'; +import { getVerifiedOperatorId } from '$lib/server/auth'; import type { LayoutServerLoad } from './$types'; export const load: LayoutServerLoad = async ({ cookies }) => { - const operatorIdStr = cookies.get('operatorId'); + const operatorId = getVerifiedOperatorId(cookies); - if (!operatorIdStr) { - return { operator: null, shift: null }; - } - - const operatorId = parseInt(operatorIdStr); - if (isNaN(operatorId)) { - cookies.delete('operatorId', { path: '/' }); + if (!operatorId) { return { operator: null, shift: null }; } diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts index 65a8feb..3547ecb 100644 --- a/src/routes/+page.server.ts +++ b/src/routes/+page.server.ts @@ -2,6 +2,7 @@ import type { PageServerLoad } from './$types'; import { db } from '$lib/server/db'; import { rentalProducts, rentals, trackedItems, productTypes, guides, storeProducts, shifts, operators, tourAgencyProducts, tourBookings, reservations, storeSales } from '$lib/server/db/schema'; import { eq, and, isNull, ne, desc } from 'drizzle-orm'; +import { getVerifiedOperatorId } from '$lib/server/auth'; import type { Rental } from '$lib/server/db/schema'; const PREVIOUS_RENTALS_LIMIT = 50; @@ -18,7 +19,7 @@ export const load: PageServerLoad = async ({ cookies }) => { db.select().from(reservations).where(eq(reservations.status, 'active')) ]); - const operatorIdStr = cookies.get('operatorId'); + const operatorId = getVerifiedOperatorId(cookies); let activeRentals: Rental[] = []; let previousShiftRentals: Rental[] = []; let shiftStoreSales: Array<{ @@ -48,8 +49,7 @@ export const load: PageServerLoad = async ({ cookies }) => { productName: string | null; }> = []; - if (operatorIdStr) { - const operatorId = parseInt(operatorIdStr); + if (operatorId) { const [currentShift] = await db .select() .from(shifts) diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 1d9d03d..5fe7f1a 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,4076 +1,263 @@ - - - - -{#if $shiftStore.isLoggedIn} -
- -
-
- point_of_sale -

Rental Manager

-
-
- {#if error} -
- error - {error} -
- {/if} - themeStore.toggle()} aria-label="Toggle dark mode"> - {resolvedIsDark(currentTheme) ? 'light_mode' : 'dark_mode'} - -
- person - {$shiftStore.operator?.name} -
- - logout - End Shift - -
-
- -
- -
- { showForm = true; error = ''; fromReservationId = null; }}> - add - New Rental - - - event - New Reservation - - {#if data.storeProducts.length > 0} - { showStoreSaleModal = true; saleError = ''; }}> - shopping_cart - Store Sale - - {/if} - {#if data.tourProducts.length > 0} - - tour - Tour Booking - - {/if} -
-
- pending - {data.rentals.length} Active -
- -
-
- - -
-
- schedule -

Active Rentals

- {data.rentals.length} -
- - {#if data.rentals.length === 0} -
- event_available -

No active rentals

-

Click "New Rental" to get started

-
- {:else} -
- {#each data.rentals as rental} - {@const customer = rental.customer as {name?: string, hotel?: string}} - {@const items = rental.items as Array<{name: string, quantity?: number, code?: string}>} - {@const pricing = rental.pricing as {type?: string}} - - {/each} -
- {/if} -
- - - {#if data.reservations.length > 0} -
-
- event -

Reservations

- {data.reservations.length} -
- -
- {#each data.reservations as reservation} - {@const resCustomer = reservation.customer as {name?: string, hotel?: string} | null} - {@const resItems = reservation.items as Array<{name: string, quantity?: number, code?: string}>} - {@const expired = isReservationExpired(reservation.reservedUntil)} - - {/each} -
-
- {/if} - - - {#if data.tourBookings.length > 0} -
-
- tour -

Tour Bookings

- {data.tourBookings.length} -
- -
- {#each data.tourBookings as booking} - - {/each} -
-
- {/if} - - - {#if data.storeSales.length > 0} -
-
- shopping_cart -

Store Sales

- {data.storeSales.length} -
- -
- {#each data.storeSales as sale} -
-
- {sale.productName || 'Product'} - - {sale.quantity} x ${(sale.unitPrice / 100).toFixed(2)} - -
- ${(sale.total / 100).toFixed(2)} - promptDelete('storeSale', sale.id, sale.productName || 'Sale')} disabled={loading} aria-label="Delete sale"> - delete - -
- {/each} -
-
- {/if} - - - {#if data.previousShiftRentals.length > 0} -
- - - {#if showPreviousRentals} - - {/if} -
- {/if} -
- - - {#if showForm} - +{#if shiftState?.isLoggedIn} + headerError = ''} + /> + + 0} + hasTourProducts={data.tourProducts.length > 0} + onNewRental={handleNewRental} + onNewReservation={handleNewReservation} + onStoreSale={handleStoreSale} + onTourBooking={handleTourBooking} + /> + + + +
+ {#if activeTab === 'active'} + + {:else if activeTab === 'reservations'} + + {:else if activeTab === 'tours'} + + {:else if activeTab === 'sales'} + + {:else if activeTab === 'history'} + {/if} -
+ + + + + {:else} - -{/if} - - -{#if showShiftSummary && shiftSummary} - -{/if} - - -{#if showStoreSaleModal} - -{/if} - - -{#if showTourBookingModal} - -{/if} - - -{#if showCloseTourModal && selectedTourBookingToClose} - -{/if} - - - - { closeRentalModalOpen = false; }} -/> - - { editRentalModalOpen = false; }} - onVerifyPin={verifyGuidePin} -/> - - -{#if showConflictModal} - -{/if} - - -{#if showReservationModal} - -{/if} - - - - -{#if showDeleteModal && deleteTarget} - -{/if} - - -{#if showEditReservationModal && editingReservation} - -{/if} - - -{#if showEditTourModal && editingTourBooking} - {/if} diff --git a/src/routes/api/admin/login/+server.ts b/src/routes/api/admin/login/+server.ts index b12ddad..bef358c 100644 --- a/src/routes/api/admin/login/+server.ts +++ b/src/routes/api/admin/login/+server.ts @@ -10,7 +10,7 @@ const SESSION_MAX_AGE = 60 * 60 * 24; export const POST: RequestHandler = async ({ request, cookies, getClientAddress }) => { const clientIp = getClientAddress(); - const rateCheck = checkRateLimit(`admin-login:${clientIp}`); + const rateCheck = await checkRateLimit(`admin-login:${clientIp}`); if (!rateCheck.allowed) { return json( { error: 'Too many login attempts. Try again later.' }, @@ -48,7 +48,7 @@ export const POST: RequestHandler = async ({ request, cookies, getClientAddress return json({ error: 'Invalid credentials' }, { status: 401 }); } - clearRateLimit(`admin-login:${clientIp}`); + await clearRateLimit(`admin-login:${clientIp}`); const token = generateSessionToken(); const expiresAt = new Date(Date.now() + SESSION_MAX_AGE * 1000); diff --git a/src/routes/api/guides/verify/+server.ts b/src/routes/api/guides/verify/+server.ts index 77a9acb..5991955 100644 --- a/src/routes/api/guides/verify/+server.ts +++ b/src/routes/api/guides/verify/+server.ts @@ -7,7 +7,7 @@ import type { RequestHandler } from './$types'; export const POST: RequestHandler = async ({ request, getClientAddress }) => { const clientIp = getClientAddress(); - const rateCheck = checkRateLimit(`guide-verify:${clientIp}`); + const rateCheck = await checkRateLimit(`guide-verify:${clientIp}`); if (!rateCheck.allowed) { return json( { error: 'Too many attempts. Try again later.' }, @@ -34,7 +34,7 @@ export const POST: RequestHandler = async ({ request, getClientAddress }) => { return json({ error: 'Invalid credentials' }, { status: 401 }); } - clearRateLimit(`guide-verify:${clientIp}`); + await clearRateLimit(`guide-verify:${clientIp}`); return json({ success: true, guide }); }; diff --git a/src/routes/api/operators/verify/+server.ts b/src/routes/api/operators/verify/+server.ts index a91af0b..3544894 100644 --- a/src/routes/api/operators/verify/+server.ts +++ b/src/routes/api/operators/verify/+server.ts @@ -7,7 +7,7 @@ import type { RequestHandler } from './$types'; export const POST: RequestHandler = async ({ request, getClientAddress }) => { const clientIp = getClientAddress(); - const rateCheck = checkRateLimit(`operator-verify:${clientIp}`); + const rateCheck = await checkRateLimit(`operator-verify:${clientIp}`); if (!rateCheck.allowed) { return json( { error: 'Too many attempts. Try again later.' }, @@ -44,7 +44,7 @@ export const POST: RequestHandler = async ({ request, getClientAddress }) => { return json({ error: 'Invalid credentials' }, { status: 401 }); } - clearRateLimit(`operator-verify:${clientIp}`); + await clearRateLimit(`operator-verify:${clientIp}`); return json({ success: true }); }; diff --git a/src/routes/api/shifts/close/+server.ts b/src/routes/api/shifts/close/+server.ts index bdae5f2..9f162ed 100644 --- a/src/routes/api/shifts/close/+server.ts +++ b/src/routes/api/shifts/close/+server.ts @@ -2,6 +2,7 @@ import { db } from '$lib/server/db'; import { shifts, rentals, operators, storeSales, storeProducts, tourBookings, tourAgencyProducts } from '$lib/server/db/schema'; import { eq, and, isNull } from 'drizzle-orm'; import { logger } from '$lib/server/logger'; +import { getVerifiedOperatorId } from '$lib/server/auth'; import type { RequestHandler } from './$types'; import * as XLSX from 'xlsx'; import { isGoogleConnected, createSpreadsheet } from '$lib/server/google-sheets'; @@ -51,13 +52,11 @@ interface Pricing { } export const POST: RequestHandler = async ({ cookies }) => { - const operatorIdStr = cookies.get('operatorId'); - if (!operatorIdStr) { + const operatorId = getVerifiedOperatorId(cookies); + if (!operatorId) { return new Response(JSON.stringify({ error: 'Not logged in' }), { status: 401 }); } - const operatorId = parseInt(operatorIdStr); - // Fetch operator and active shift in parallel const [operatorResult, shiftResult] = await Promise.all([ db.select().from(operators).where(eq(operators.id, operatorId)), diff --git a/src/routes/api/shifts/end/+server.ts b/src/routes/api/shifts/end/+server.ts index 4708384..c9eef29 100644 --- a/src/routes/api/shifts/end/+server.ts +++ b/src/routes/api/shifts/end/+server.ts @@ -3,15 +3,15 @@ import { db } from '$lib/server/db'; import { shifts, rentals, operators } from '$lib/server/db/schema'; import { eq, and, isNull } from 'drizzle-orm'; import { sql } from 'drizzle-orm'; +import { getVerifiedOperatorId } from '$lib/server/auth'; import type { RequestHandler } from './$types'; export const POST: RequestHandler = async ({ request, cookies }) => { - const operatorIdStr = cookies.get('operatorId'); - if (!operatorIdStr) { + const operatorId = getVerifiedOperatorId(cookies); + if (!operatorId) { return json({ error: 'Not logged in' }, { status: 401 }); } - const operatorId = parseInt(operatorIdStr); const [operator] = await db.select({ id: operators.id }).from(operators) .where(and(eq(operators.id, operatorId), eq(operators.isActive, true))); if (!operator) { diff --git a/src/routes/api/shifts/start/+server.ts b/src/routes/api/shifts/start/+server.ts index ea76dcb..4815753 100644 --- a/src/routes/api/shifts/start/+server.ts +++ b/src/routes/api/shifts/start/+server.ts @@ -2,13 +2,13 @@ import { json } from '@sveltejs/kit'; import { db } from '$lib/server/db'; import { operators, shifts } from '$lib/server/db/schema'; import { eq, and, isNull } from 'drizzle-orm'; -import { verifyPasscode, checkRateLimit, clearRateLimit, logAuthFailure } from '$lib/server/auth'; +import { verifyPasscode, checkRateLimit, clearRateLimit, logAuthFailure, signCookieValue } from '$lib/server/auth'; import { dev } from '$app/environment'; import type { RequestHandler } from './$types'; export const POST: RequestHandler = async ({ request, cookies, getClientAddress }) => { const clientIp = getClientAddress(); - const rateCheck = checkRateLimit(`shift-start:${clientIp}`); + const rateCheck = await checkRateLimit(`shift-start:${clientIp}`); if (!rateCheck.allowed) { return json( { error: 'Too many login attempts. Try again later.' }, @@ -38,7 +38,7 @@ export const POST: RequestHandler = async ({ request, cookies, getClientAddress return json({ error: 'Invalid credentials' }, { status: 401 }); } - clearRateLimit(`shift-start:${clientIp}`); + await clearRateLimit(`shift-start:${clientIp}`); const [existingShift] = await db .select() @@ -56,7 +56,7 @@ export const POST: RequestHandler = async ({ request, cookies, getClientAddress shift = newShift; } - cookies.set('operatorId', String(operatorId), { + cookies.set('operatorId', signCookieValue(String(operatorId)), { path: '/', httpOnly: true, sameSite: 'lax', diff --git a/src/routes/api/store-sales/+server.ts b/src/routes/api/store-sales/+server.ts index 38e9e0f..1ff6e35 100644 --- a/src/routes/api/store-sales/+server.ts +++ b/src/routes/api/store-sales/+server.ts @@ -2,15 +2,15 @@ import { json } from '@sveltejs/kit'; import { db } from '$lib/server/db'; import { storeSales, storeProducts, shifts, operators } from '$lib/server/db/schema'; import { eq, and, isNull } from 'drizzle-orm'; +import { getVerifiedOperatorId } from '$lib/server/auth'; import type { RequestHandler } from './$types'; export const POST: RequestHandler = async ({ request, cookies }) => { - const operatorIdStr = cookies.get('operatorId'); - if (!operatorIdStr) { + const operatorId = getVerifiedOperatorId(cookies); + if (!operatorId) { return json({ error: 'Not logged in' }, { status: 401 }); } - const operatorId = parseInt(operatorIdStr); const [operator] = await db.select({ id: operators.id }).from(operators) .where(and(eq(operators.id, operatorId), eq(operators.isActive, true))); if (!operator) { @@ -87,12 +87,11 @@ export const POST: RequestHandler = async ({ request, cookies }) => { }; export const GET: RequestHandler = async ({ url, cookies }) => { - const operatorIdStr = cookies.get('operatorId'); - if (!operatorIdStr) { + const operatorId = getVerifiedOperatorId(cookies); + if (!operatorId) { return json({ error: 'Not logged in' }, { status: 401 }); } - const operatorId = parseInt(operatorIdStr); const [operator] = await db.select({ id: operators.id }).from(operators) .where(and(eq(operators.id, operatorId), eq(operators.isActive, true))); if (!operator) {