@voyant-travel/charters 0.117.2

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.
Files changed (108) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +16 -0
  3. package/dist/adapters/index.d.ts +254 -0
  4. package/dist/adapters/index.d.ts.map +1 -0
  5. package/dist/adapters/index.js +16 -0
  6. package/dist/adapters/memoize.d.ts +28 -0
  7. package/dist/adapters/memoize.d.ts.map +1 -0
  8. package/dist/adapters/memoize.js +121 -0
  9. package/dist/adapters/mock.d.ts +50 -0
  10. package/dist/adapters/mock.d.ts.map +1 -0
  11. package/dist/adapters/mock.js +194 -0
  12. package/dist/adapters/registry.d.ts +24 -0
  13. package/dist/adapters/registry.d.ts.map +1 -0
  14. package/dist/adapters/registry.js +40 -0
  15. package/dist/booking-extension.d.ts +895 -0
  16. package/dist/booking-extension.d.ts.map +1 -0
  17. package/dist/booking-extension.js +339 -0
  18. package/dist/catalog-policy.d.ts +23 -0
  19. package/dist/catalog-policy.d.ts.map +1 -0
  20. package/dist/catalog-policy.js +400 -0
  21. package/dist/content-shape.d.ts +5 -0
  22. package/dist/content-shape.d.ts.map +1 -0
  23. package/dist/content-shape.js +13 -0
  24. package/dist/draft-shape.d.ts +29 -0
  25. package/dist/draft-shape.d.ts.map +1 -0
  26. package/dist/draft-shape.js +63 -0
  27. package/dist/index.d.ts +31 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +55 -0
  30. package/dist/lib/key.d.ts +22 -0
  31. package/dist/lib/key.d.ts.map +1 -0
  32. package/dist/lib/key.js +24 -0
  33. package/dist/routes-public.d.ts +785 -0
  34. package/dist/routes-public.d.ts.map +1 -0
  35. package/dist/routes-public.js +234 -0
  36. package/dist/routes.d.ts +1744 -0
  37. package/dist/routes.d.ts.map +1 -0
  38. package/dist/routes.js +543 -0
  39. package/dist/schema-core.d.ts +815 -0
  40. package/dist/schema-core.d.ts.map +1 -0
  41. package/dist/schema-core.js +98 -0
  42. package/dist/schema-itinerary.d.ts +239 -0
  43. package/dist/schema-itinerary.d.ts.map +1 -0
  44. package/dist/schema-itinerary.js +30 -0
  45. package/dist/schema-pricing.d.ts +385 -0
  46. package/dist/schema-pricing.d.ts.map +1 -0
  47. package/dist/schema-pricing.js +62 -0
  48. package/dist/schema-shared.d.ts +8 -0
  49. package/dist/schema-shared.d.ts.map +1 -0
  50. package/dist/schema-shared.js +37 -0
  51. package/dist/schema-sourced-content.d.ts +253 -0
  52. package/dist/schema-sourced-content.d.ts.map +1 -0
  53. package/dist/schema-sourced-content.js +44 -0
  54. package/dist/schema-yachts.d.ts +367 -0
  55. package/dist/schema-yachts.d.ts.map +1 -0
  56. package/dist/schema-yachts.js +30 -0
  57. package/dist/schema.d.ts +8 -0
  58. package/dist/schema.d.ts.map +1 -0
  59. package/dist/schema.js +7 -0
  60. package/dist/service-bookings-helpers.d.ts +20 -0
  61. package/dist/service-bookings-helpers.d.ts.map +1 -0
  62. package/dist/service-bookings-helpers.js +67 -0
  63. package/dist/service-bookings-local.d.ts +5 -0
  64. package/dist/service-bookings-local.d.ts.map +1 -0
  65. package/dist/service-bookings-local.js +177 -0
  66. package/dist/service-bookings-types.d.ts +88 -0
  67. package/dist/service-bookings-types.d.ts.map +1 -0
  68. package/dist/service-bookings-types.js +1 -0
  69. package/dist/service-bookings.d.ts +36 -0
  70. package/dist/service-bookings.d.ts.map +1 -0
  71. package/dist/service-bookings.js +267 -0
  72. package/dist/service-catalog-plane.d.ts +58 -0
  73. package/dist/service-catalog-plane.d.ts.map +1 -0
  74. package/dist/service-catalog-plane.js +145 -0
  75. package/dist/service-content-synthesizer.d.ts +42 -0
  76. package/dist/service-content-synthesizer.d.ts.map +1 -0
  77. package/dist/service-content-synthesizer.js +122 -0
  78. package/dist/service-content.d.ts +43 -0
  79. package/dist/service-content.d.ts.map +1 -0
  80. package/dist/service-content.js +248 -0
  81. package/dist/service-myba.d.ts +85 -0
  82. package/dist/service-myba.d.ts.map +1 -0
  83. package/dist/service-myba.js +88 -0
  84. package/dist/service-pricing.d.ts +64 -0
  85. package/dist/service-pricing.d.ts.map +1 -0
  86. package/dist/service-pricing.js +167 -0
  87. package/dist/service.d.ts +131 -0
  88. package/dist/service.d.ts.map +1 -0
  89. package/dist/service.js +279 -0
  90. package/dist/validation-core.d.ts +152 -0
  91. package/dist/validation-core.d.ts.map +1 -0
  92. package/dist/validation-core.js +66 -0
  93. package/dist/validation-itinerary.d.ts +43 -0
  94. package/dist/validation-itinerary.d.ts.map +1 -0
  95. package/dist/validation-itinerary.js +19 -0
  96. package/dist/validation-pricing.d.ts +103 -0
  97. package/dist/validation-pricing.d.ts.map +1 -0
  98. package/dist/validation-pricing.js +28 -0
  99. package/dist/validation-shared.d.ts +61 -0
  100. package/dist/validation-shared.d.ts.map +1 -0
  101. package/dist/validation-shared.js +60 -0
  102. package/dist/validation-yachts.d.ts +76 -0
  103. package/dist/validation-yachts.d.ts.map +1 -0
  104. package/dist/validation-yachts.js +36 -0
  105. package/dist/validation.d.ts +6 -0
  106. package/dist/validation.d.ts.map +1 -0
  107. package/dist/validation.js +5 -0
  108. package/package.json +116 -0
@@ -0,0 +1,167 @@
1
+ import { and, asc, eq, sql } from "drizzle-orm";
2
+ import { charterProducts, charterVoyages } from "./schema-core.js";
3
+ import { charterSuites } from "./schema-pricing.js";
4
+ // ---------- money helpers ----------
5
+ // All math is performed in integer cents to avoid float drift.
6
+ // Same approach the cruises module uses.
7
+ const CENTS_PER_UNIT = 100n;
8
+ function decimalStringToCents(s) {
9
+ const trimmed = s.trim();
10
+ if (!/^-?\d+(\.\d{1,2})?$/.test(trimmed)) {
11
+ throw new Error(`Invalid money string: ${s}`);
12
+ }
13
+ const negative = trimmed.startsWith("-");
14
+ const abs = negative ? trimmed.slice(1) : trimmed;
15
+ const parts = abs.split(".");
16
+ const whole = parts[0] ?? "0";
17
+ const frac = parts[1] ?? "";
18
+ const fracPadded = `${frac}00`.slice(0, 2);
19
+ const cents = BigInt(whole) * CENTS_PER_UNIT + BigInt(fracPadded);
20
+ return negative ? -cents : cents;
21
+ }
22
+ function centsToDecimalString(c) {
23
+ const negative = c < 0n;
24
+ const abs = negative ? -c : c;
25
+ const whole = abs / CENTS_PER_UNIT;
26
+ const frac = abs % CENTS_PER_UNIT;
27
+ const fracStr = frac.toString().padStart(2, "0");
28
+ return `${negative ? "-" : ""}${whole.toString()}.${fracStr}`;
29
+ }
30
+ function percentOf(cents, percentString) {
31
+ const trimmed = percentString.trim();
32
+ if (!/^-?\d+(\.\d{1,2})?$/.test(trimmed)) {
33
+ throw new Error(`Invalid percent string: ${percentString}`);
34
+ }
35
+ const parts = trimmed.split(".");
36
+ const whole = parts[0] ?? "0";
37
+ const frac = parts[1] ?? "";
38
+ const fracPadded = `${frac}00`.slice(0, 2);
39
+ // percent * 100 → integer basis points; multiply cents, divide by 10000
40
+ const basisPoints = BigInt(whole) * 100n + BigInt(fracPadded);
41
+ return (cents * basisPoints) / 10000n;
42
+ }
43
+ // ---------- currency resolution (pure) ----------
44
+ /**
45
+ * Per-currency price lookup against a `Record<currency, amount>` map.
46
+ * Returns `null` when the entity isn't priced in that currency. Callers
47
+ * decide whether that's an error (quote composition) or a soft miss
48
+ * (browse aggregates).
49
+ */
50
+ function priceForCurrency(map, currency) {
51
+ return map[currency] ?? null;
52
+ }
53
+ function listPriceCurrencies(map) {
54
+ return Object.keys(map).filter((code) => map[code] != null && map[code] !== "");
55
+ }
56
+ export function composePerSuiteQuote(input) {
57
+ const suitePrice = priceForCurrency(input.suite.pricesByCurrency, input.currency);
58
+ if (!suitePrice) {
59
+ throw new Error(`Suite ${input.suite.id} has no published price in ${input.currency}; available currencies: ${listPriceCurrencies(input.suite.pricesByCurrency).join(", ") || "none"}`);
60
+ }
61
+ const portFee = priceForCurrency(input.suite.portFeesByCurrency, input.currency);
62
+ const suiteCents = decimalStringToCents(suitePrice);
63
+ const portFeeCents = portFee ? decimalStringToCents(portFee) : 0n;
64
+ const totalCents = suiteCents + portFeeCents;
65
+ return {
66
+ mode: "per_suite",
67
+ voyageId: input.voyageId,
68
+ suiteId: input.suite.id,
69
+ suiteName: input.suite.suiteName,
70
+ currency: input.currency,
71
+ suitePrice: centsToDecimalString(suiteCents),
72
+ portFee: portFee ? centsToDecimalString(portFeeCents) : null,
73
+ total: centsToDecimalString(totalCents),
74
+ };
75
+ }
76
+ export function composeWholeYachtQuote(input) {
77
+ const charterFee = priceForCurrency(input.voyage.wholeYachtPricesByCurrency, input.currency);
78
+ if (!charterFee) {
79
+ throw new Error(`Voyage ${input.voyage.id} has no published whole-yacht price in ${input.currency}; available currencies: ${listPriceCurrencies(input.voyage.wholeYachtPricesByCurrency).join(", ") || "none"}`);
80
+ }
81
+ const apaPercent = input.voyage.apaPercentOverride ?? input.productDefaultApaPercent;
82
+ if (!apaPercent) {
83
+ throw new Error(`Voyage ${input.voyage.id} has no APA percent set (neither voyage override nor product default). Whole-yacht charters require an APA.`);
84
+ }
85
+ const charterFeeCents = decimalStringToCents(charterFee);
86
+ const apaAmountCents = percentOf(charterFeeCents, apaPercent);
87
+ const totalCents = charterFeeCents + apaAmountCents;
88
+ return {
89
+ mode: "whole_yacht",
90
+ voyageId: input.voyage.id,
91
+ currency: input.currency,
92
+ charterFee: centsToDecimalString(charterFeeCents),
93
+ apaPercent,
94
+ apaAmount: centsToDecimalString(apaAmountCents),
95
+ total: centsToDecimalString(totalCents),
96
+ };
97
+ }
98
+ // ---------- standalone APA helper ----------
99
+ /**
100
+ * Compute APA amount from a charter fee and a percent. Useful for finance-side
101
+ * recalculation without needing to re-quote the whole voyage.
102
+ */
103
+ export function computeApaAmount(charterFee, apaPercent) {
104
+ const cents = percentOf(decimalStringToCents(charterFee), apaPercent);
105
+ return centsToDecimalString(cents);
106
+ }
107
+ // ---------- DB-bound service ----------
108
+ export const pricingService = {
109
+ async quotePerSuite(db, args) {
110
+ const [suite] = await db
111
+ .select()
112
+ .from(charterSuites)
113
+ .where(eq(charterSuites.id, args.suiteId))
114
+ .limit(1);
115
+ if (!suite)
116
+ throw new Error(`Charter suite ${args.suiteId} not found`);
117
+ return composePerSuiteQuote({
118
+ voyageId: suite.voyageId,
119
+ suite,
120
+ currency: args.currency,
121
+ });
122
+ },
123
+ async quoteWholeYacht(db, args) {
124
+ const [voyage] = await db
125
+ .select()
126
+ .from(charterVoyages)
127
+ .where(eq(charterVoyages.id, args.voyageId))
128
+ .limit(1);
129
+ if (!voyage)
130
+ throw new Error(`Charter voyage ${args.voyageId} not found`);
131
+ const [product] = await db
132
+ .select({ defaultApaPercent: charterProducts.defaultApaPercent })
133
+ .from(charterProducts)
134
+ .where(eq(charterProducts.id, voyage.productId))
135
+ .limit(1);
136
+ return composeWholeYachtQuote({
137
+ voyage,
138
+ productDefaultApaPercent: product?.defaultApaPercent ?? null,
139
+ currency: args.currency,
140
+ });
141
+ },
142
+ /**
143
+ * Lowest published per-suite price for a voyage, in the requested currency.
144
+ * Returns `null` when no available suite has that currency. The previous
145
+ * `lowestSuitePriceUSD` shape was renamed and generalized as part of #355
146
+ * (browse-currency policy is a deployment choice, not a hardcoded USD).
147
+ */
148
+ async lowestSuitePriceForCurrency(db, voyageId, currency) {
149
+ const [row] = await db
150
+ .select({
151
+ suiteId: charterSuites.id,
152
+ price: sql `(${charterSuites.pricesByCurrency} ->> ${currency})`,
153
+ })
154
+ .from(charterSuites)
155
+ .where(and(eq(charterSuites.voyageId, voyageId),
156
+ // agent-quality: raw-sql reviewed -- owner: charters; dynamic SQL interpolation uses Drizzle parameter binding or vetted SQL identifiers.
157
+ sql `${charterSuites.availability} <> 'sold_out'`,
158
+ // agent-quality: raw-sql reviewed -- owner: charters; dynamic SQL interpolation uses Drizzle parameter binding or vetted SQL identifiers.
159
+ sql `(${charterSuites.pricesByCurrency} ? ${currency})`))
160
+ // agent-quality: raw-sql reviewed -- owner: charters; dynamic SQL interpolation uses Drizzle parameter binding or vetted SQL identifiers.
161
+ .orderBy(asc(sql `((${charterSuites.pricesByCurrency} ->> ${currency})::numeric)`))
162
+ .limit(1);
163
+ if (!row?.price)
164
+ return null;
165
+ return { suiteId: row.suiteId, price: row.price };
166
+ },
167
+ };
@@ -0,0 +1,131 @@
1
+ import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
2
+ import { type CharterProduct, type CharterVoyage } from "./schema-core.js";
3
+ import { type CharterScheduleDay } from "./schema-itinerary.js";
4
+ import { type CharterSuite } from "./schema-pricing.js";
5
+ import { type CharterYacht } from "./schema-yachts.js";
6
+ import type { InsertProduct, InsertVoyage, ProductListQuery, UpdateProduct, UpdateVoyage, VoyageListQuery } from "./validation-core.js";
7
+ import type { ReplaceVoyageSchedule } from "./validation-itinerary.js";
8
+ import type { ReplaceVoyageSuites } from "./validation-pricing.js";
9
+ import type { InsertYacht, UpdateYacht, YachtListQuery } from "./validation-yachts.js";
10
+ export declare const chartersService: {
11
+ listProducts(db: PostgresJsDatabase, query: ProductListQuery): Promise<{
12
+ data: {
13
+ id: string;
14
+ slug: string;
15
+ name: string;
16
+ lineSupplierId: string | null;
17
+ defaultYachtId: string | null;
18
+ description: string | null;
19
+ shortDescription: string | null;
20
+ heroImageUrl: string | null;
21
+ mapImageUrl: string | null;
22
+ regions: string[] | null;
23
+ themes: string[] | null;
24
+ status: "live" | "draft" | "awaiting_review" | "archived";
25
+ defaultBookingModes: ("per_suite" | "whole_yacht")[] | null;
26
+ defaultMybaTemplateId: string | null;
27
+ defaultApaPercent: string | null;
28
+ lowestPriceCachedAmount: string | null;
29
+ lowestPriceCachedCurrency: string | null;
30
+ earliestVoyageCached: string | null;
31
+ latestVoyageCached: string | null;
32
+ externalRefs: Record<string, string> | null;
33
+ createdAt: Date;
34
+ updatedAt: Date;
35
+ }[];
36
+ total: number;
37
+ limit: number;
38
+ offset: number;
39
+ }>;
40
+ getProductById(db: PostgresJsDatabase, id: string, options?: {
41
+ withVoyages?: boolean;
42
+ withYacht?: boolean;
43
+ }): Promise<(CharterProduct & {
44
+ voyages?: CharterVoyage[];
45
+ yacht?: CharterYacht | null;
46
+ }) | null>;
47
+ createProduct(db: PostgresJsDatabase, data: InsertProduct): Promise<CharterProduct>;
48
+ updateProduct(db: PostgresJsDatabase, id: string, data: UpdateProduct): Promise<CharterProduct | null>;
49
+ archiveProduct(db: PostgresJsDatabase, id: string): Promise<CharterProduct | null>;
50
+ recomputeProductAggregates(db: PostgresJsDatabase, productId: string, options?: {
51
+ browseCurrency?: string;
52
+ }): Promise<CharterProduct | null>;
53
+ listVoyages(db: PostgresJsDatabase, query: VoyageListQuery): Promise<{
54
+ data: {
55
+ id: string;
56
+ productId: string;
57
+ yachtId: string;
58
+ voyageCode: string;
59
+ name: string | null;
60
+ embarkPortFacilityId: string | null;
61
+ embarkPortName: string | null;
62
+ disembarkPortFacilityId: string | null;
63
+ disembarkPortName: string | null;
64
+ departureDate: string;
65
+ returnDate: string;
66
+ nights: number;
67
+ bookingModes: ("per_suite" | "whole_yacht")[];
68
+ appointmentOnly: boolean;
69
+ wholeYachtPricesByCurrency: Record<string, string>;
70
+ apaPercentOverride: string | null;
71
+ mybaTemplateIdOverride: string | null;
72
+ charterAreaOverride: string | null;
73
+ salesStatus: "open" | "on_request" | "wait_list" | "sold_out" | "closed";
74
+ availabilityNote: string | null;
75
+ externalRefs: Record<string, string> | null;
76
+ lastSyncedAt: Date | null;
77
+ createdAt: Date;
78
+ updatedAt: Date;
79
+ }[];
80
+ total: number;
81
+ limit: number;
82
+ offset: number;
83
+ }>;
84
+ getVoyageById(db: PostgresJsDatabase, id: string, options?: {
85
+ withSuites?: boolean;
86
+ withSchedule?: boolean;
87
+ }): Promise<(CharterVoyage & {
88
+ suites?: CharterSuite[];
89
+ schedule?: CharterScheduleDay[];
90
+ }) | null>;
91
+ upsertVoyage(db: PostgresJsDatabase, data: InsertVoyage): Promise<CharterVoyage>;
92
+ updateVoyage(db: PostgresJsDatabase, id: string, data: UpdateVoyage): Promise<CharterVoyage | null>;
93
+ replaceVoyageSuites(db: PostgresJsDatabase, payload: ReplaceVoyageSuites): Promise<CharterSuite[]>;
94
+ replaceVoyageSchedule(db: PostgresJsDatabase, payload: ReplaceVoyageSchedule): Promise<CharterScheduleDay[]>;
95
+ listYachts(db: PostgresJsDatabase, query: YachtListQuery): Promise<{
96
+ data: {
97
+ id: string;
98
+ lineSupplierId: string | null;
99
+ name: string;
100
+ slug: string;
101
+ yachtClass: "luxury_motor" | "luxury_sailing" | "expedition" | "small_cruise";
102
+ capacityGuests: number | null;
103
+ capacityCrew: number | null;
104
+ lengthMeters: string | null;
105
+ yearBuilt: number | null;
106
+ yearRefurbished: number | null;
107
+ imo: string | null;
108
+ description: string | null;
109
+ gallery: string[] | null;
110
+ amenities: Record<string, unknown> | null;
111
+ crewBios: {
112
+ role: string;
113
+ name: string;
114
+ bio?: string;
115
+ photoUrl?: string;
116
+ }[] | null;
117
+ defaultCharterAreas: string[] | null;
118
+ externalRefs: Record<string, string> | null;
119
+ isActive: boolean;
120
+ createdAt: Date;
121
+ updatedAt: Date;
122
+ }[];
123
+ total: number;
124
+ limit: number;
125
+ offset: number;
126
+ }>;
127
+ getYachtById(db: PostgresJsDatabase, id: string): Promise<CharterYacht | null>;
128
+ createYacht(db: PostgresJsDatabase, data: InsertYacht): Promise<CharterYacht>;
129
+ updateYacht(db: PostgresJsDatabase, id: string, data: UpdateYacht): Promise<CharterYacht | null>;
130
+ };
131
+ //# sourceMappingURL=service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../src/service.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAA;AAEjE,OAAO,EACL,KAAK,cAAc,EACnB,KAAK,aAAa,EAKnB,MAAM,kBAAkB,CAAA;AACzB,OAAO,EAAE,KAAK,kBAAkB,EAAuB,MAAM,uBAAuB,CAAA;AACpF,OAAO,EAAE,KAAK,YAAY,EAAiB,MAAM,qBAAqB,CAAA;AACtE,OAAO,EAAE,KAAK,YAAY,EAAiB,MAAM,oBAAoB,CAAA;AACrE,OAAO,KAAK,EACV,aAAa,EACb,YAAY,EACZ,gBAAgB,EAChB,aAAa,EACb,YAAY,EACZ,eAAe,EAChB,MAAM,sBAAsB,CAAA;AAC7B,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,2BAA2B,CAAA;AACtE,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,yBAAyB,CAAA;AAClE,OAAO,KAAK,EAAE,WAAW,EAAE,WAAW,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAA;AAQtF,eAAO,MAAM,eAAe;qBAGH,kBAAkB,SAAS,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;uBAgC5D,kBAAkB,MAClB,MAAM,YACD;QAAE,WAAW,CAAC,EAAE,OAAO,CAAC;QAAC,SAAS,CAAC,EAAE,OAAO,CAAA;KAAE,GACtD,OAAO,CAAC,CAAC,cAAc,GAAG;QAAE,OAAO,CAAC,EAAE,aAAa,EAAE,CAAC;QAAC,KAAK,CAAC,EAAE,YAAY,GAAG,IAAI,CAAA;KAAE,CAAC,GAAG,IAAI,CAAC;sBAyBxE,kBAAkB,QAAQ,aAAa,GAAG,OAAO,CAAC,cAAc,CAAC;sBAUnF,kBAAkB,MAClB,MAAM,QACJ,aAAa,GAClB,OAAO,CAAC,cAAc,GAAG,IAAI,CAAC;uBASR,kBAAkB,MAAM,MAAM,GAAG,OAAO,CAAC,cAAc,GAAG,IAAI,CAAC;mCAUlF,kBAAkB,aACX,MAAM,YACR;QAAE,cAAc,CAAC,EAAE,MAAM,CAAA;KAAE,GACnC,OAAO,CAAC,cAAc,GAAG,IAAI,CAAC;oBAiDX,kBAAkB,SAAS,eAAe;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;sBA8B1D,kBAAkB,MAClB,MAAM,YACD;QAAE,UAAU,CAAC,EAAE,OAAO,CAAC;QAAC,YAAY,CAAC,EAAE,OAAO,CAAA;KAAE,GACxD,OAAO,CACN,CAAC,aAAa,GAAG;QACf,MAAM,CAAC,EAAE,YAAY,EAAE,CAAA;QACvB,QAAQ,CAAC,EAAE,kBAAkB,EAAE,CAAA;KAChC,CAAC,GACF,IAAI,CACP;qBA0BsB,kBAAkB,QAAQ,YAAY,GAAG,OAAO,CAAC,aAAa,CAAC;qBAgChF,kBAAkB,MAClB,MAAM,QACJ,YAAY,GACjB,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC;4BAU1B,kBAAkB,WACb,mBAAmB,GAC3B,OAAO,CAAC,YAAY,EAAE,CAAC;8BAapB,kBAAkB,WACb,qBAAqB,GAC7B,OAAO,CAAC,kBAAkB,EAAE,CAAC;mBAcX,kBAAkB,SAAS,cAAc;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;qBAwBvC,kBAAkB,MAAM,MAAM,GAAG,OAAO,CAAC,YAAY,GAAG,IAAI,CAAC;oBAK9D,kBAAkB,QAAQ,WAAW,GAAG,OAAO,CAAC,YAAY,CAAC;oBAO7E,kBAAkB,MAClB,MAAM,QACJ,WAAW,GAChB,OAAO,CAAC,YAAY,GAAG,IAAI,CAAC;CAQhC,CAAA"}
@@ -0,0 +1,279 @@
1
+ import { and, asc, count, desc, eq, gte, ilike, lte, or, sql } from "drizzle-orm";
2
+ import { charterProducts, charterVoyages, } from "./schema-core.js";
3
+ import { charterScheduleDays } from "./schema-itinerary.js";
4
+ import { charterSuites } from "./schema-pricing.js";
5
+ import { charterYachts } from "./schema-yachts.js";
6
+ const setUpdated = { updatedAt: new Date() };
7
+ function paginate(query) {
8
+ return { limit: query.limit, offset: query.offset };
9
+ }
10
+ export const chartersService = {
11
+ // ---------- products ----------
12
+ async listProducts(db, query) {
13
+ const conditions = [];
14
+ if (query.status)
15
+ conditions.push(eq(charterProducts.status, query.status));
16
+ if (query.lineSupplierId)
17
+ conditions.push(eq(charterProducts.lineSupplierId, query.lineSupplierId));
18
+ if (query.region) {
19
+ // agent-quality: raw-sql reviewed -- owner: charters; dynamic SQL interpolation uses Drizzle parameter binding or vetted SQL identifiers.
20
+ conditions.push(sql `${charterProducts.regions} @> ${JSON.stringify([query.region])}::jsonb`);
21
+ }
22
+ if (query.search) {
23
+ const term = `%${query.search}%`;
24
+ conditions.push(or(ilike(charterProducts.name, term), ilike(charterProducts.description, term)));
25
+ }
26
+ const where = conditions.length > 0 ? and(...conditions) : undefined;
27
+ const { limit, offset } = paginate(query);
28
+ const [rows, totalRows] = await Promise.all([
29
+ db
30
+ .select()
31
+ .from(charterProducts)
32
+ .where(where)
33
+ .orderBy(desc(charterProducts.createdAt))
34
+ .limit(limit)
35
+ .offset(offset),
36
+ db.select({ value: count() }).from(charterProducts).where(where),
37
+ ]);
38
+ return { data: rows, total: totalRows[0]?.value ?? 0, limit, offset };
39
+ },
40
+ async getProductById(db, id, options = {}) {
41
+ const [row] = await db.select().from(charterProducts).where(eq(charterProducts.id, id)).limit(1);
42
+ if (!row)
43
+ return null;
44
+ const out = {
45
+ ...row,
46
+ };
47
+ if (options.withVoyages) {
48
+ out.voyages = await db
49
+ .select()
50
+ .from(charterVoyages)
51
+ .where(eq(charterVoyages.productId, id))
52
+ .orderBy(asc(charterVoyages.departureDate));
53
+ }
54
+ if (options.withYacht && row.defaultYachtId) {
55
+ const [yacht] = await db
56
+ .select()
57
+ .from(charterYachts)
58
+ .where(eq(charterYachts.id, row.defaultYachtId))
59
+ .limit(1);
60
+ out.yacht = yacht ?? null;
61
+ }
62
+ return out;
63
+ },
64
+ async createProduct(db, data) {
65
+ const [row] = await db
66
+ .insert(charterProducts)
67
+ .values(data)
68
+ .returning();
69
+ if (!row)
70
+ throw new Error("Failed to create charter product");
71
+ return row;
72
+ },
73
+ async updateProduct(db, id, data) {
74
+ const [row] = await db
75
+ .update(charterProducts)
76
+ .set({ ...data, ...setUpdated })
77
+ .where(eq(charterProducts.id, id))
78
+ .returning();
79
+ return row ?? null;
80
+ },
81
+ async archiveProduct(db, id) {
82
+ const [row] = await db
83
+ .update(charterProducts)
84
+ .set({ status: "archived", ...setUpdated })
85
+ .where(eq(charterProducts.id, id))
86
+ .returning();
87
+ return row ?? null;
88
+ },
89
+ async recomputeProductAggregates(db, productId, options = {}) {
90
+ // Lowest suite price across this product's voyages × suites, in the
91
+ // deployment's chosen browse currency. Defaults to "USD" so existing
92
+ // callers and storefronts that haven't picked a browse currency yet
93
+ // keep their previous behavior; pass `browseCurrency` explicitly to
94
+ // override (e.g. EUR-first European catalog).
95
+ const browseCurrency = options.browseCurrency ?? "USD";
96
+ const [priceAgg] = await db
97
+ .select({
98
+ lowest: sql `MIN(((${charterSuites.pricesByCurrency} ->> ${browseCurrency})::numeric))::text`,
99
+ })
100
+ .from(charterSuites)
101
+ .innerJoin(charterVoyages, eq(charterSuites.voyageId, charterVoyages.id))
102
+ .where(and(eq(charterVoyages.productId, productId),
103
+ // agent-quality: raw-sql reviewed -- owner: charters; dynamic SQL interpolation uses Drizzle parameter binding or vetted SQL identifiers.
104
+ sql `${charterSuites.availability} <> 'sold_out'`,
105
+ // agent-quality: raw-sql reviewed -- owner: charters; dynamic SQL interpolation uses Drizzle parameter binding or vetted SQL identifiers.
106
+ sql `(${charterSuites.pricesByCurrency} ? ${browseCurrency})`));
107
+ const [dateAgg] = await db
108
+ .select({
109
+ earliest: sql `MIN(${charterVoyages.departureDate})`,
110
+ latest: sql `MAX(${charterVoyages.departureDate})`,
111
+ })
112
+ .from(charterVoyages)
113
+ .where(eq(charterVoyages.productId, productId));
114
+ const [row] = await db
115
+ .update(charterProducts)
116
+ .set({
117
+ lowestPriceCachedAmount: priceAgg?.lowest ?? null,
118
+ lowestPriceCachedCurrency: priceAgg?.lowest ? browseCurrency : null,
119
+ earliestVoyageCached: dateAgg?.earliest ?? null,
120
+ latestVoyageCached: dateAgg?.latest ?? null,
121
+ ...setUpdated,
122
+ })
123
+ .where(eq(charterProducts.id, productId))
124
+ .returning();
125
+ return row ?? null;
126
+ },
127
+ // ---------- voyages ----------
128
+ async listVoyages(db, query) {
129
+ const conditions = [];
130
+ if (query.productId)
131
+ conditions.push(eq(charterVoyages.productId, query.productId));
132
+ if (query.yachtId)
133
+ conditions.push(eq(charterVoyages.yachtId, query.yachtId));
134
+ if (query.salesStatus)
135
+ conditions.push(eq(charterVoyages.salesStatus, query.salesStatus));
136
+ if (query.dateFrom)
137
+ conditions.push(gte(charterVoyages.departureDate, query.dateFrom));
138
+ if (query.dateTo)
139
+ conditions.push(lte(charterVoyages.departureDate, query.dateTo));
140
+ if (query.bookingMode) {
141
+ conditions.push(
142
+ // agent-quality: raw-sql reviewed -- owner: charters; dynamic SQL interpolation uses Drizzle parameter binding or vetted SQL identifiers.
143
+ sql `${charterVoyages.bookingModes} @> ${JSON.stringify([query.bookingMode])}::jsonb`);
144
+ }
145
+ const where = conditions.length > 0 ? and(...conditions) : undefined;
146
+ const { limit, offset } = paginate(query);
147
+ const [rows, totalRows] = await Promise.all([
148
+ db
149
+ .select()
150
+ .from(charterVoyages)
151
+ .where(where)
152
+ .orderBy(asc(charterVoyages.departureDate))
153
+ .limit(limit)
154
+ .offset(offset),
155
+ db.select({ value: count() }).from(charterVoyages).where(where),
156
+ ]);
157
+ return { data: rows, total: totalRows[0]?.value ?? 0, limit, offset };
158
+ },
159
+ async getVoyageById(db, id, options = {}) {
160
+ const [row] = await db.select().from(charterVoyages).where(eq(charterVoyages.id, id)).limit(1);
161
+ if (!row)
162
+ return null;
163
+ const out = { ...row };
164
+ if (options.withSuites) {
165
+ out.suites = await db
166
+ .select()
167
+ .from(charterSuites)
168
+ .where(eq(charterSuites.voyageId, id))
169
+ .orderBy(asc(charterSuites.suiteCode));
170
+ }
171
+ if (options.withSchedule) {
172
+ out.schedule = await db
173
+ .select()
174
+ .from(charterScheduleDays)
175
+ .where(eq(charterScheduleDays.voyageId, id))
176
+ .orderBy(asc(charterScheduleDays.dayNumber));
177
+ }
178
+ return out;
179
+ },
180
+ async upsertVoyage(db, data) {
181
+ const [existing] = await db
182
+ .select()
183
+ .from(charterVoyages)
184
+ .where(and(eq(charterVoyages.productId, data.productId), eq(charterVoyages.departureDate, data.departureDate), eq(charterVoyages.yachtId, data.yachtId)))
185
+ .limit(1);
186
+ if (existing) {
187
+ const [row] = await db
188
+ .update(charterVoyages)
189
+ .set({ ...data, ...setUpdated, lastSyncedAt: new Date() })
190
+ .where(eq(charterVoyages.id, existing.id))
191
+ .returning();
192
+ if (!row)
193
+ throw new Error("Failed to update voyage");
194
+ return row;
195
+ }
196
+ const [row] = await db
197
+ .insert(charterVoyages)
198
+ .values({ ...data, lastSyncedAt: new Date() })
199
+ .returning();
200
+ if (!row)
201
+ throw new Error("Failed to insert voyage");
202
+ return row;
203
+ },
204
+ async updateVoyage(db, id, data) {
205
+ const [row] = await db
206
+ .update(charterVoyages)
207
+ .set({ ...data, ...setUpdated })
208
+ .where(eq(charterVoyages.id, id))
209
+ .returning();
210
+ return row ?? null;
211
+ },
212
+ async replaceVoyageSuites(db, payload) {
213
+ return db.transaction(async (tx) => {
214
+ await tx.delete(charterSuites).where(eq(charterSuites.voyageId, payload.voyageId));
215
+ if (payload.suites.length === 0)
216
+ return [];
217
+ const inserted = await tx
218
+ .insert(charterSuites)
219
+ .values(payload.suites.map((s) => ({ ...s, voyageId: payload.voyageId })))
220
+ .returning();
221
+ return inserted;
222
+ });
223
+ },
224
+ async replaceVoyageSchedule(db, payload) {
225
+ return db.transaction(async (tx) => {
226
+ await tx.delete(charterScheduleDays).where(eq(charterScheduleDays.voyageId, payload.voyageId));
227
+ if (payload.days.length === 0)
228
+ return [];
229
+ const inserted = await tx
230
+ .insert(charterScheduleDays)
231
+ .values(payload.days.map((d) => ({ ...d, voyageId: payload.voyageId })))
232
+ .returning();
233
+ return inserted;
234
+ });
235
+ },
236
+ // ---------- yachts ----------
237
+ async listYachts(db, query) {
238
+ const conditions = [];
239
+ if (query.lineSupplierId)
240
+ conditions.push(eq(charterYachts.lineSupplierId, query.lineSupplierId));
241
+ if (query.yachtClass)
242
+ conditions.push(eq(charterYachts.yachtClass, query.yachtClass));
243
+ if (typeof query.isActive === "boolean")
244
+ conditions.push(eq(charterYachts.isActive, query.isActive));
245
+ if (query.search)
246
+ conditions.push(ilike(charterYachts.name, `%${query.search}%`));
247
+ const where = conditions.length > 0 ? and(...conditions) : undefined;
248
+ const { limit, offset } = paginate(query);
249
+ const [rows, totalRows] = await Promise.all([
250
+ db
251
+ .select()
252
+ .from(charterYachts)
253
+ .where(where)
254
+ .orderBy(asc(charterYachts.name))
255
+ .limit(limit)
256
+ .offset(offset),
257
+ db.select({ value: count() }).from(charterYachts).where(where),
258
+ ]);
259
+ return { data: rows, total: totalRows[0]?.value ?? 0, limit, offset };
260
+ },
261
+ async getYachtById(db, id) {
262
+ const [row] = await db.select().from(charterYachts).where(eq(charterYachts.id, id)).limit(1);
263
+ return row ?? null;
264
+ },
265
+ async createYacht(db, data) {
266
+ const [row] = await db.insert(charterYachts).values(data).returning();
267
+ if (!row)
268
+ throw new Error("Failed to create yacht");
269
+ return row;
270
+ },
271
+ async updateYacht(db, id, data) {
272
+ const [row] = await db
273
+ .update(charterYachts)
274
+ .set({ ...data, ...setUpdated })
275
+ .where(eq(charterYachts.id, id))
276
+ .returning();
277
+ return row ?? null;
278
+ },
279
+ };