@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.
- package/LICENSE +201 -0
- package/README.md +16 -0
- package/dist/adapters/index.d.ts +254 -0
- package/dist/adapters/index.d.ts.map +1 -0
- package/dist/adapters/index.js +16 -0
- package/dist/adapters/memoize.d.ts +28 -0
- package/dist/adapters/memoize.d.ts.map +1 -0
- package/dist/adapters/memoize.js +121 -0
- package/dist/adapters/mock.d.ts +50 -0
- package/dist/adapters/mock.d.ts.map +1 -0
- package/dist/adapters/mock.js +194 -0
- package/dist/adapters/registry.d.ts +24 -0
- package/dist/adapters/registry.d.ts.map +1 -0
- package/dist/adapters/registry.js +40 -0
- package/dist/booking-extension.d.ts +895 -0
- package/dist/booking-extension.d.ts.map +1 -0
- package/dist/booking-extension.js +339 -0
- package/dist/catalog-policy.d.ts +23 -0
- package/dist/catalog-policy.d.ts.map +1 -0
- package/dist/catalog-policy.js +400 -0
- package/dist/content-shape.d.ts +5 -0
- package/dist/content-shape.d.ts.map +1 -0
- package/dist/content-shape.js +13 -0
- package/dist/draft-shape.d.ts +29 -0
- package/dist/draft-shape.d.ts.map +1 -0
- package/dist/draft-shape.js +63 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +55 -0
- package/dist/lib/key.d.ts +22 -0
- package/dist/lib/key.d.ts.map +1 -0
- package/dist/lib/key.js +24 -0
- package/dist/routes-public.d.ts +785 -0
- package/dist/routes-public.d.ts.map +1 -0
- package/dist/routes-public.js +234 -0
- package/dist/routes.d.ts +1744 -0
- package/dist/routes.d.ts.map +1 -0
- package/dist/routes.js +543 -0
- package/dist/schema-core.d.ts +815 -0
- package/dist/schema-core.d.ts.map +1 -0
- package/dist/schema-core.js +98 -0
- package/dist/schema-itinerary.d.ts +239 -0
- package/dist/schema-itinerary.d.ts.map +1 -0
- package/dist/schema-itinerary.js +30 -0
- package/dist/schema-pricing.d.ts +385 -0
- package/dist/schema-pricing.d.ts.map +1 -0
- package/dist/schema-pricing.js +62 -0
- package/dist/schema-shared.d.ts +8 -0
- package/dist/schema-shared.d.ts.map +1 -0
- package/dist/schema-shared.js +37 -0
- package/dist/schema-sourced-content.d.ts +253 -0
- package/dist/schema-sourced-content.d.ts.map +1 -0
- package/dist/schema-sourced-content.js +44 -0
- package/dist/schema-yachts.d.ts +367 -0
- package/dist/schema-yachts.d.ts.map +1 -0
- package/dist/schema-yachts.js +30 -0
- package/dist/schema.d.ts +8 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +7 -0
- package/dist/service-bookings-helpers.d.ts +20 -0
- package/dist/service-bookings-helpers.d.ts.map +1 -0
- package/dist/service-bookings-helpers.js +67 -0
- package/dist/service-bookings-local.d.ts +5 -0
- package/dist/service-bookings-local.d.ts.map +1 -0
- package/dist/service-bookings-local.js +177 -0
- package/dist/service-bookings-types.d.ts +88 -0
- package/dist/service-bookings-types.d.ts.map +1 -0
- package/dist/service-bookings-types.js +1 -0
- package/dist/service-bookings.d.ts +36 -0
- package/dist/service-bookings.d.ts.map +1 -0
- package/dist/service-bookings.js +267 -0
- package/dist/service-catalog-plane.d.ts +58 -0
- package/dist/service-catalog-plane.d.ts.map +1 -0
- package/dist/service-catalog-plane.js +145 -0
- package/dist/service-content-synthesizer.d.ts +42 -0
- package/dist/service-content-synthesizer.d.ts.map +1 -0
- package/dist/service-content-synthesizer.js +122 -0
- package/dist/service-content.d.ts +43 -0
- package/dist/service-content.d.ts.map +1 -0
- package/dist/service-content.js +248 -0
- package/dist/service-myba.d.ts +85 -0
- package/dist/service-myba.d.ts.map +1 -0
- package/dist/service-myba.js +88 -0
- package/dist/service-pricing.d.ts +64 -0
- package/dist/service-pricing.d.ts.map +1 -0
- package/dist/service-pricing.js +167 -0
- package/dist/service.d.ts +131 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/service.js +279 -0
- package/dist/validation-core.d.ts +152 -0
- package/dist/validation-core.d.ts.map +1 -0
- package/dist/validation-core.js +66 -0
- package/dist/validation-itinerary.d.ts +43 -0
- package/dist/validation-itinerary.d.ts.map +1 -0
- package/dist/validation-itinerary.js +19 -0
- package/dist/validation-pricing.d.ts +103 -0
- package/dist/validation-pricing.d.ts.map +1 -0
- package/dist/validation-pricing.js +28 -0
- package/dist/validation-shared.d.ts +61 -0
- package/dist/validation-shared.d.ts.map +1 -0
- package/dist/validation-shared.js +60 -0
- package/dist/validation-yachts.d.ts +76 -0
- package/dist/validation-yachts.d.ts.map +1 -0
- package/dist/validation-yachts.js +36 -0
- package/dist/validation.d.ts +6 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +5 -0
- 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"}
|
package/dist/service.js
ADDED
|
@@ -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
|
+
};
|