@htlkg/data 0.0.19 → 0.0.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,145 @@
1
+ /**
2
+ * useReservations Hook
3
+ *
4
+ * Vue composable for fetching and managing reservation data with reactive state.
5
+ * Provides loading states, error handling, pagination, and refetch capabilities.
6
+ */
7
+
8
+ import type { Ref, ComputedRef } from "vue";
9
+ import type { Reservation } from "../queries/reservations";
10
+ import { createDataHook, type BaseHookOptions } from "./createDataHook";
11
+
12
+ export interface UseReservationsOptions extends BaseHookOptions {
13
+ /** Filter by brand ID */
14
+ brandId?: string;
15
+ /** Filter by start date (check-in date >= startDate) */
16
+ startDate?: string;
17
+ /** Filter by end date (check-in date <= endDate) */
18
+ endDate?: string;
19
+ /** Filter by reservation status */
20
+ status?: Reservation["status"];
21
+ /** Filter by contact/visit ID */
22
+ contactId?: string;
23
+ /** Pagination token for fetching next page */
24
+ nextToken?: string;
25
+ }
26
+
27
+ export interface UseReservationsReturn {
28
+ /** Reactive array of reservations */
29
+ reservations: Ref<Reservation[]>;
30
+ /** Computed array of confirmed reservations */
31
+ confirmedReservations: ComputedRef<Reservation[]>;
32
+ /** Computed array of active reservations (confirmed or checked_in) */
33
+ activeReservations: ComputedRef<Reservation[]>;
34
+ /** Loading state */
35
+ loading: Ref<boolean>;
36
+ /** Error state */
37
+ error: Ref<Error | null>;
38
+ /** Refetch reservations */
39
+ refetch: () => Promise<void>;
40
+ }
41
+
42
+ /**
43
+ * Build filter from hook options
44
+ */
45
+ function buildFilter(options: UseReservationsOptions): any {
46
+ const conditions: any[] = [];
47
+
48
+ if (options.brandId) {
49
+ conditions.push({ brandId: { eq: options.brandId } });
50
+ }
51
+
52
+ if (options.status) {
53
+ conditions.push({ status: { eq: options.status } });
54
+ }
55
+
56
+ if (options.contactId) {
57
+ conditions.push({ visitId: { eq: options.contactId } });
58
+ }
59
+
60
+ if (options.startDate) {
61
+ conditions.push({ checkIn: { ge: options.startDate } });
62
+ }
63
+
64
+ if (options.endDate) {
65
+ conditions.push({ checkIn: { le: options.endDate } });
66
+ }
67
+
68
+ if (options.filter) {
69
+ conditions.push(options.filter);
70
+ }
71
+
72
+ if (conditions.length === 0) {
73
+ return undefined;
74
+ }
75
+
76
+ if (conditions.length === 1) {
77
+ return conditions[0];
78
+ }
79
+
80
+ return { and: conditions };
81
+ }
82
+
83
+ /**
84
+ * Internal hook created by factory
85
+ */
86
+ const useReservationsInternal = createDataHook<
87
+ Reservation,
88
+ UseReservationsOptions,
89
+ { confirmedReservations: Reservation[]; activeReservations: Reservation[] }
90
+ >({
91
+ model: "Reservation",
92
+ dataPropertyName: "reservations",
93
+ buildFilter,
94
+ computedProperties: {
95
+ confirmedReservations: (reservations) =>
96
+ reservations.filter((r) => r.status === "confirmed"),
97
+ activeReservations: (reservations) =>
98
+ reservations.filter((r) => r.status === "confirmed" || r.status === "checked_in"),
99
+ },
100
+ });
101
+
102
+ /**
103
+ * Composable for fetching and managing reservations
104
+ *
105
+ * @example
106
+ * ```typescript
107
+ * import { useReservations } from '@htlkg/data/hooks';
108
+ *
109
+ * const { reservations, loading, error, refetch } = useReservations({
110
+ * brandId: 'brand-123',
111
+ * startDate: '2026-01-01',
112
+ * endDate: '2026-01-31',
113
+ * status: 'confirmed'
114
+ * });
115
+ * ```
116
+ *
117
+ * @example With contact filter
118
+ * ```typescript
119
+ * const { reservations, loading } = useReservations({
120
+ * contactId: 'contact-123',
121
+ * limit: 50
122
+ * });
123
+ * ```
124
+ *
125
+ * @example With computed properties
126
+ * ```typescript
127
+ * const { reservations, activeReservations, confirmedReservations } = useReservations({
128
+ * brandId: 'brand-123'
129
+ * });
130
+ *
131
+ * // activeReservations includes confirmed + checked_in
132
+ * // confirmedReservations includes only confirmed
133
+ * ```
134
+ */
135
+ export function useReservations(options: UseReservationsOptions = {}): UseReservationsReturn {
136
+ const result = useReservationsInternal(options);
137
+ return {
138
+ reservations: result.reservations as Ref<Reservation[]>,
139
+ confirmedReservations: result.confirmedReservations as ComputedRef<Reservation[]>,
140
+ activeReservations: result.activeReservations as ComputedRef<Reservation[]>,
141
+ loading: result.loading,
142
+ error: result.error,
143
+ refetch: result.refetch,
144
+ };
145
+ }
@@ -61,3 +61,17 @@ export {
61
61
  initializeSystemSettings,
62
62
  type UpdateSystemSettingsInput,
63
63
  } from "./systemSettings";
64
+
65
+ // Reservation mutations
66
+ export {
67
+ createReservation,
68
+ updateReservation,
69
+ deleteReservation,
70
+ softDeleteReservation,
71
+ restoreReservation,
72
+ updateReservationStatus,
73
+ ReservationValidationError,
74
+ type CreateReservationInput,
75
+ type UpdateReservationInput,
76
+ type ReservationStatus,
77
+ } from "./reservations";
@@ -132,7 +132,7 @@ describe('ProductInstance Mutations', () => {
132
132
  accountId: 'account-789',
133
133
  enabled: true,
134
134
  version: '1.0.0',
135
- lastUpdated: mockTimestamp,
135
+ updatedAt: mockTimestamp,
136
136
  updatedBy: mockUserId,
137
137
  })
138
138
  );
@@ -183,7 +183,7 @@ describe('ProductInstance Mutations', () => {
183
183
  await createProductInstance(mockClient, validInput);
184
184
 
185
185
  const callArgs = mockCreate.mock.calls[0][0];
186
- expect(callArgs.lastUpdated).toBe(mockTimestamp);
186
+ expect(callArgs.updatedAt).toBe(mockTimestamp);
187
187
  });
188
188
 
189
189
  it('should use provided updatedBy if specified', async () => {
@@ -358,7 +358,7 @@ describe('ProductInstance Mutations', () => {
358
358
  await updateProductInstance(mockClient, validInput);
359
359
 
360
360
  const callArgs = mockUpdate.mock.calls[0][0];
361
- expect(callArgs.lastUpdated).toBe(mockTimestamp);
361
+ expect(callArgs.updatedAt).toBe(mockTimestamp);
362
362
  expect(callArgs.updatedBy).toBe(mockUserId);
363
363
  });
364
364
 
@@ -0,0 +1,459 @@
1
+ /**
2
+ * Reservation Mutation Tests
3
+ *
4
+ * Tests for reservation CRUD operations including date validation,
5
+ * status transitions, soft delete, and restoration.
6
+ */
7
+
8
+ import { describe, it, expect, vi, beforeEach } from "vitest";
9
+ import {
10
+ createReservation,
11
+ updateReservation,
12
+ softDeleteReservation,
13
+ restoreReservation,
14
+ deleteReservation,
15
+ updateReservationStatus,
16
+ ReservationValidationError,
17
+ type ReservationStatus,
18
+ } from "./reservations";
19
+
20
+ // Mock the systemSettings query functions
21
+ vi.mock("../queries/systemSettings", () => ({
22
+ checkRestoreEligibility: vi.fn((deletedAt: string | null, retentionDays: number) => {
23
+ if (!deletedAt) {
24
+ return { canRestore: false, daysRemaining: 0, daysExpired: 0 };
25
+ }
26
+ const deletedDate = new Date(deletedAt);
27
+ const now = new Date();
28
+ const diffMs = now.getTime() - deletedDate.getTime();
29
+ const diffDays = diffMs / (1000 * 60 * 60 * 24);
30
+
31
+ if (diffDays > retentionDays) {
32
+ return {
33
+ canRestore: false,
34
+ daysRemaining: 0,
35
+ daysExpired: Math.floor(diffDays - retentionDays),
36
+ };
37
+ }
38
+
39
+ return {
40
+ canRestore: true,
41
+ daysRemaining: Math.ceil(retentionDays - diffDays),
42
+ daysExpired: 0,
43
+ };
44
+ }),
45
+ DEFAULT_SOFT_DELETE_RETENTION_DAYS: 30,
46
+ }));
47
+
48
+ describe("Reservation Mutations", () => {
49
+ describe("createReservation", () => {
50
+ const validInput = {
51
+ brandId: "brand-123",
52
+ visitId: "visit-456",
53
+ confirmationCode: "ABC123",
54
+ checkIn: "2024-06-01",
55
+ checkOut: "2024-06-05",
56
+ status: "confirmed" as const,
57
+ source: "direct",
58
+ room: "101",
59
+ };
60
+
61
+ it("should create a reservation with valid dates", async () => {
62
+ const mockCreate = vi.fn().mockResolvedValue({
63
+ data: { id: "reservation-001", ...validInput },
64
+ errors: null,
65
+ });
66
+
67
+ const mockClient = {
68
+ models: {
69
+ Reservation: { create: mockCreate },
70
+ },
71
+ };
72
+
73
+ const result = await createReservation(mockClient, validInput);
74
+
75
+ expect(result).toBeTruthy();
76
+ expect(result?.id).toBe("reservation-001");
77
+ expect(mockCreate).toHaveBeenCalledTimes(1);
78
+ });
79
+
80
+ it("should calculate nights automatically when not provided", async () => {
81
+ const mockCreate = vi.fn().mockResolvedValue({
82
+ data: { id: "reservation-001" },
83
+ errors: null,
84
+ });
85
+
86
+ const mockClient = {
87
+ models: {
88
+ Reservation: { create: mockCreate },
89
+ },
90
+ };
91
+
92
+ await createReservation(mockClient, validInput);
93
+
94
+ const callArgs = mockCreate.mock.calls[0][0];
95
+ expect(callArgs.nights).toBe(4); // June 1 to June 5 = 4 nights
96
+ });
97
+
98
+ it("should throw ReservationValidationError for invalid check-in date", async () => {
99
+ const mockClient = {
100
+ models: {
101
+ Reservation: { create: vi.fn() },
102
+ },
103
+ };
104
+
105
+ const invalidInput = {
106
+ ...validInput,
107
+ checkIn: "invalid-date",
108
+ };
109
+
110
+ await expect(createReservation(mockClient, invalidInput)).rejects.toThrow(
111
+ ReservationValidationError
112
+ );
113
+ });
114
+
115
+ it("should throw ReservationValidationError when check-out is before check-in", async () => {
116
+ const mockClient = {
117
+ models: {
118
+ Reservation: { create: vi.fn() },
119
+ },
120
+ };
121
+
122
+ const invalidInput = {
123
+ ...validInput,
124
+ checkIn: "2024-06-05",
125
+ checkOut: "2024-06-01",
126
+ };
127
+
128
+ await expect(createReservation(mockClient, invalidInput)).rejects.toThrow(
129
+ "Check-out date must be after check-in date"
130
+ );
131
+ });
132
+
133
+ it("should throw ReservationValidationError for minimum stay violation", async () => {
134
+ const mockClient = {
135
+ models: {
136
+ Reservation: { create: vi.fn() },
137
+ },
138
+ };
139
+
140
+ const invalidInput = {
141
+ ...validInput,
142
+ checkIn: "2024-06-01T10:00:00",
143
+ checkOut: "2024-06-01T11:00:00", // Only 1 hour
144
+ };
145
+
146
+ await expect(createReservation(mockClient, invalidInput)).rejects.toThrow(
147
+ "Minimum stay is 4 hours"
148
+ );
149
+ });
150
+
151
+ it("should return null when GraphQL returns errors", async () => {
152
+ const mockCreate = vi.fn().mockResolvedValue({
153
+ data: null,
154
+ errors: [{ message: "Database error" }],
155
+ });
156
+
157
+ const mockClient = {
158
+ models: {
159
+ Reservation: { create: mockCreate },
160
+ },
161
+ };
162
+
163
+ const result = await createReservation(mockClient, validInput);
164
+
165
+ expect(result).toBeNull();
166
+ });
167
+ });
168
+
169
+ describe("updateReservation", () => {
170
+ it("should update a reservation without status change", async () => {
171
+ const mockUpdate = vi.fn().mockResolvedValue({
172
+ data: { id: "reservation-001", room: "202" },
173
+ errors: null,
174
+ });
175
+
176
+ const mockClient = {
177
+ models: {
178
+ Reservation: { update: mockUpdate },
179
+ },
180
+ };
181
+
182
+ const result = await updateReservation(mockClient, {
183
+ id: "reservation-001",
184
+ room: "202",
185
+ });
186
+
187
+ expect(result).toBeTruthy();
188
+ expect(mockUpdate).toHaveBeenCalledWith({ id: "reservation-001", room: "202" });
189
+ });
190
+
191
+ it("should validate status transition when status is being changed", async () => {
192
+ const mockGet = vi.fn().mockResolvedValue({
193
+ data: { id: "reservation-001", status: "checked_out" },
194
+ errors: null,
195
+ });
196
+
197
+ const mockClient = {
198
+ models: {
199
+ Reservation: {
200
+ get: mockGet,
201
+ update: vi.fn(),
202
+ },
203
+ },
204
+ };
205
+
206
+ // Try to change from checked_out to confirmed (not allowed)
207
+ await expect(
208
+ updateReservation(mockClient, {
209
+ id: "reservation-001",
210
+ status: "confirmed",
211
+ })
212
+ ).rejects.toThrow(ReservationValidationError);
213
+ });
214
+
215
+ it("should recalculate nights when dates are updated", async () => {
216
+ const mockUpdate = vi.fn().mockResolvedValue({
217
+ data: { id: "reservation-001" },
218
+ errors: null,
219
+ });
220
+
221
+ const mockClient = {
222
+ models: {
223
+ Reservation: { update: mockUpdate },
224
+ },
225
+ };
226
+
227
+ await updateReservation(mockClient, {
228
+ id: "reservation-001",
229
+ checkIn: "2024-06-01",
230
+ checkOut: "2024-06-10",
231
+ });
232
+
233
+ const callArgs = mockUpdate.mock.calls[0][0];
234
+ expect(callArgs.nights).toBe(9); // 9 nights
235
+ });
236
+ });
237
+
238
+ describe("updateReservationStatus", () => {
239
+ const statusTransitionTests: Array<{
240
+ from: ReservationStatus;
241
+ to: ReservationStatus;
242
+ allowed: boolean;
243
+ }> = [
244
+ { from: "confirmed", to: "checked_in", allowed: true },
245
+ { from: "confirmed", to: "cancelled", allowed: true },
246
+ { from: "confirmed", to: "no_show", allowed: true },
247
+ { from: "confirmed", to: "checked_out", allowed: false },
248
+ { from: "checked_in", to: "checked_out", allowed: true },
249
+ { from: "checked_in", to: "cancelled", allowed: true },
250
+ { from: "checked_in", to: "confirmed", allowed: false },
251
+ { from: "checked_out", to: "confirmed", allowed: false },
252
+ { from: "checked_out", to: "checked_in", allowed: false },
253
+ { from: "cancelled", to: "confirmed", allowed: false },
254
+ { from: "no_show", to: "confirmed", allowed: false },
255
+ ];
256
+
257
+ statusTransitionTests.forEach(({ from, to, allowed }) => {
258
+ it(`should ${allowed ? "allow" : "reject"} transition from '${from}' to '${to}'`, async () => {
259
+ const mockGet = vi.fn().mockResolvedValue({
260
+ data: { id: "reservation-001", status: from },
261
+ errors: null,
262
+ });
263
+ const mockUpdate = vi.fn().mockResolvedValue({
264
+ data: { id: "reservation-001", status: to },
265
+ errors: null,
266
+ });
267
+
268
+ const mockClient = {
269
+ models: {
270
+ Reservation: {
271
+ get: mockGet,
272
+ update: mockUpdate,
273
+ },
274
+ },
275
+ };
276
+
277
+ if (allowed) {
278
+ const result = await updateReservationStatus(mockClient, "reservation-001", to);
279
+ expect(result).toBeTruthy();
280
+ expect(mockUpdate).toHaveBeenCalledWith({ id: "reservation-001", status: to });
281
+ } else {
282
+ await expect(
283
+ updateReservationStatus(mockClient, "reservation-001", to)
284
+ ).rejects.toThrow(ReservationValidationError);
285
+ }
286
+ });
287
+ });
288
+
289
+ it("should return null when reservation not found", async () => {
290
+ const mockGet = vi.fn().mockResolvedValue({
291
+ data: null,
292
+ errors: [{ message: "Not found" }],
293
+ });
294
+
295
+ const mockClient = {
296
+ models: {
297
+ Reservation: { get: mockGet, update: vi.fn() },
298
+ },
299
+ };
300
+
301
+ const result = await updateReservationStatus(mockClient, "nonexistent", "checked_in");
302
+
303
+ expect(result).toBeNull();
304
+ });
305
+ });
306
+
307
+ describe("softDeleteReservation", () => {
308
+ it("should set deletedAt and deletedBy", async () => {
309
+ const mockUpdate = vi.fn().mockResolvedValue({
310
+ data: { id: "reservation-001" },
311
+ errors: null,
312
+ });
313
+
314
+ const mockClient = {
315
+ models: {
316
+ Reservation: { update: mockUpdate },
317
+ },
318
+ };
319
+
320
+ const result = await softDeleteReservation(
321
+ mockClient,
322
+ "reservation-001",
323
+ "admin@example.com"
324
+ );
325
+
326
+ expect(result).toBe(true);
327
+ const callArgs = mockUpdate.mock.calls[0][0];
328
+ expect(callArgs.id).toBe("reservation-001");
329
+ expect(callArgs.deletedAt).toBeDefined();
330
+ expect(callArgs.deletedBy).toBe("admin@example.com");
331
+ });
332
+
333
+ it("should return false on GraphQL error", async () => {
334
+ const mockUpdate = vi.fn().mockResolvedValue({
335
+ data: null,
336
+ errors: [{ message: "Error" }],
337
+ });
338
+
339
+ const mockClient = {
340
+ models: {
341
+ Reservation: { update: mockUpdate },
342
+ },
343
+ };
344
+
345
+ const result = await softDeleteReservation(
346
+ mockClient,
347
+ "reservation-001",
348
+ "admin@example.com"
349
+ );
350
+
351
+ expect(result).toBe(false);
352
+ });
353
+ });
354
+
355
+ describe("restoreReservation", () => {
356
+ it("should restore a recently deleted reservation", async () => {
357
+ const recentDeletedAt = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString(); // 5 days ago
358
+
359
+ const mockGet = vi.fn().mockResolvedValue({
360
+ data: { id: "reservation-001", deletedAt: recentDeletedAt },
361
+ errors: null,
362
+ });
363
+ const mockUpdate = vi.fn().mockResolvedValue({
364
+ data: { id: "reservation-001" },
365
+ errors: null,
366
+ });
367
+
368
+ const mockClient = {
369
+ models: {
370
+ Reservation: { get: mockGet, update: mockUpdate },
371
+ },
372
+ };
373
+
374
+ const result = await restoreReservation(mockClient, "reservation-001");
375
+
376
+ expect(result.success).toBe(true);
377
+ expect(mockUpdate).toHaveBeenCalledWith({
378
+ id: "reservation-001",
379
+ deletedAt: null,
380
+ deletedBy: null,
381
+ });
382
+ });
383
+
384
+ it("should reject restoration after retention period expires", async () => {
385
+ const oldDeletedAt = new Date(Date.now() - 60 * 24 * 60 * 60 * 1000).toISOString(); // 60 days ago
386
+
387
+ const mockGet = vi.fn().mockResolvedValue({
388
+ data: { id: "reservation-001", deletedAt: oldDeletedAt },
389
+ errors: null,
390
+ });
391
+
392
+ const mockClient = {
393
+ models: {
394
+ Reservation: { get: mockGet, update: vi.fn() },
395
+ },
396
+ };
397
+
398
+ const result = await restoreReservation(mockClient, "reservation-001", 30);
399
+
400
+ expect(result.success).toBe(false);
401
+ expect(result.error).toContain("Retention period");
402
+ });
403
+
404
+ it("should return error when reservation not found", async () => {
405
+ const mockGet = vi.fn().mockResolvedValue({
406
+ data: null,
407
+ errors: [{ message: "Not found" }],
408
+ });
409
+
410
+ const mockClient = {
411
+ models: {
412
+ Reservation: { get: mockGet, update: vi.fn() },
413
+ },
414
+ };
415
+
416
+ const result = await restoreReservation(mockClient, "nonexistent");
417
+
418
+ expect(result.success).toBe(false);
419
+ expect(result.error).toBe("Reservation not found");
420
+ });
421
+ });
422
+
423
+ describe("deleteReservation", () => {
424
+ it("should permanently delete a reservation", async () => {
425
+ const mockDelete = vi.fn().mockResolvedValue({
426
+ data: { id: "reservation-001" },
427
+ errors: null,
428
+ });
429
+
430
+ const mockClient = {
431
+ models: {
432
+ Reservation: { delete: mockDelete },
433
+ },
434
+ };
435
+
436
+ const result = await deleteReservation(mockClient, "reservation-001");
437
+
438
+ expect(result).toBe(true);
439
+ expect(mockDelete).toHaveBeenCalledWith({ id: "reservation-001" });
440
+ });
441
+
442
+ it("should return false on GraphQL error", async () => {
443
+ const mockDelete = vi.fn().mockResolvedValue({
444
+ data: null,
445
+ errors: [{ message: "Cannot delete" }],
446
+ });
447
+
448
+ const mockClient = {
449
+ models: {
450
+ Reservation: { delete: mockDelete },
451
+ },
452
+ };
453
+
454
+ const result = await deleteReservation(mockClient, "reservation-001");
455
+
456
+ expect(result).toBe(false);
457
+ });
458
+ });
459
+ });