ti2-bokun 1.0.6 → 1.0.13

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ti2-bokun",
3
- "version": "1.0.6",
3
+ "version": "1.0.13",
4
4
  "description": "Bokun's TI2 Plugin",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -53,6 +53,6 @@
53
53
  "jest-diff": "^27.4.2",
54
54
  "jest-environment-node": "27.4",
55
55
  "jest-util": "27.4",
56
- "ti2": "latest"
56
+ "ti2": "file:../ti2"
57
57
  }
58
58
  }
@@ -2,6 +2,70 @@ const { makeExecutableSchema } = require('@graphql-tools/schema');
2
2
  const R = require('ramda');
3
3
  const { graphql } = require('graphql');
4
4
 
5
+ // Flatten currencies across every activityPriceCatalog on a product so we
6
+ // don't miss currencies only offered by non-primary catalogs.
7
+ const getCatalogCurrencies = o => R.chain(
8
+ c => R.propOr([], 'currencies', c),
9
+ R.pathOr([], ['activityPriceCatalogs'], o),
10
+ );
11
+
12
+ const REQUIRED_CUSTOMER_FIELDS_TYPE_DEF = `
13
+ extend type Query {
14
+ requiredCustomerFields: [String]
15
+ }
16
+ `;
17
+
18
+ const stripGraphqlComments = source => source
19
+ .split('\n')
20
+ .map(line => line.replace(/#.*$/, ''))
21
+ .join('\n');
22
+
23
+ const hasRequiredCustomerFieldsDefinition = typeDef => (
24
+ typeof typeDef === 'string'
25
+ && /requiredCustomerFields\s*:\s*\[?\s*String/.test(stripGraphqlComments(typeDef))
26
+ );
27
+
28
+ /**
29
+ * `extend type Query` is only valid when the schema defines (or already extends) `Query`.
30
+ * Skip injection for SDL documents with no `Query` type to avoid invalid schemas.
31
+ */
32
+ const typeDefsStringDefinesQuery = source => {
33
+ if (typeof source !== 'string') return false;
34
+ const stripped = stripGraphqlComments(source);
35
+ return /(^|[^\w.])(extend\s+type|type)\s+Query[\s\n]*\{/.test(stripped);
36
+ };
37
+
38
+ const hasQueryableRootType = typeDefs => {
39
+ if (typeof typeDefs === 'string') {
40
+ return typeDefsStringDefinesQuery(typeDefs);
41
+ }
42
+ if (Array.isArray(typeDefs)) {
43
+ return typeDefs.some(
44
+ t => typeof t === 'string' && typeDefsStringDefinesQuery(t),
45
+ );
46
+ }
47
+ return false;
48
+ };
49
+
50
+ const withRequiredCustomerFieldsTypeDef = typeDefs => {
51
+ if (!hasQueryableRootType(typeDefs)) {
52
+ return typeDefs;
53
+ }
54
+ if (typeof typeDefs === 'string') {
55
+ return hasRequiredCustomerFieldsDefinition(typeDefs)
56
+ ? typeDefs
57
+ : `${typeDefs}\n${REQUIRED_CUSTOMER_FIELDS_TYPE_DEF}`;
58
+ }
59
+
60
+ if (Array.isArray(typeDefs)) {
61
+ return typeDefs.some(hasRequiredCustomerFieldsDefinition)
62
+ ? typeDefs
63
+ : [...typeDefs, REQUIRED_CUSTOMER_FIELDS_TYPE_DEF];
64
+ }
65
+
66
+ return typeDefs;
67
+ };
68
+
5
69
  const resolvers = {
6
70
  Query: {
7
71
  productId: o => {
@@ -10,10 +74,17 @@ const resolvers = {
10
74
  },
11
75
  productName: o => R.path(['title'], o) || R.path(['name'], o) || R.prop('internalName', o),
12
76
  availableCurrencies: o => {
13
- const currencies = R.path(['currencies'], o);
14
- return currencies || ['USD', 'EUR', 'GBP', 'ISK'];
77
+ const codes = getCatalogCurrencies(o).map(c => c.currency).filter(Boolean);
78
+ return codes.length ? [...new Set(codes)] : ['USD', 'EUR', 'GBP', 'ISK'];
79
+ },
80
+ requiredCustomerFields: o => {
81
+ const fields = R.pathOr([], ['requiredCustomerFields'], o);
82
+ return [...new Set((Array.isArray(fields) ? fields : []).filter(Boolean))];
83
+ },
84
+ defaultCurrency: o => {
85
+ const def = getCatalogCurrencies(o).find(c => c.default);
86
+ return (def && def.currency) || R.path(['defaultCurrency'], o) || 'USD';
15
87
  },
16
- defaultCurrency: o => R.path(['defaultCurrency'], o) || 'USD',
17
88
  options: root => {
18
89
  const products = R.propOr([], 'products', root);
19
90
  const options = R.propOr([], 'options', root);
@@ -130,7 +201,7 @@ const resolvers = {
130
201
 
131
202
  const translateProduct = async ({ rootValue, typeDefs, query }) => {
132
203
  const schema = makeExecutableSchema({
133
- typeDefs,
204
+ typeDefs: withRequiredCustomerFieldsTypeDef(typeDefs),
134
205
  resolvers,
135
206
  });
136
207
  const retVal = await graphql({
@@ -0,0 +1,80 @@
1
+ const crypto = require('crypto');
2
+ const moment = require('moment');
3
+
4
+ /**
5
+ * Build full request URL and the path to use for signature (path the server sees).
6
+ * If baseUrl is https://api.bokun.io/rest-v2, pathForSigning must be
7
+ * /rest-v2/activity.json/... so the signature matches.
8
+ */
9
+ const buildRequestUrlAndPath = (baseUrl, path) => {
10
+ const pathPart = path.startsWith('/') ? path : `/${path}`;
11
+ const url = `${baseUrl.replace(/\/$/, '')}${pathPart}`;
12
+ let pathForSigning = pathPart;
13
+ try {
14
+ const u = new URL(url);
15
+ pathForSigning = u.pathname + u.search;
16
+ } catch (_) {
17
+ // fallback: use pathPart if URL parse fails
18
+ }
19
+ return { url, pathForSigning };
20
+ };
21
+
22
+ /**
23
+ * Bókun signature per https://bokun.dev/booking-api-rest/vU6sCfxwYdJWd1QAcLt12i/configuring-the-platform-for-api-usage-and-authentication/sFiGRpo4detkmrZPcWtQPj
24
+ * String to sign = date + accessKey + HTTP method (uppercase) + path (including
25
+ * query string, no scheme/host). Then HMAC-SHA1 with secret key, then Base64.
26
+ */
27
+ const generateBokunSignature = ({
28
+ secretKey,
29
+ accessKey,
30
+ date,
31
+ method,
32
+ path,
33
+ }) => {
34
+ const stringToSign = `${date}${accessKey}${method.toUpperCase()}${path}`;
35
+ return crypto
36
+ .createHmac('sha1', secretKey)
37
+ .update(stringToSign)
38
+ .digest('base64');
39
+ };
40
+
41
+ const getHeaders = ({
42
+ accessKey,
43
+ secretKey,
44
+ method,
45
+ path,
46
+ }) => {
47
+ if (typeof secretKey !== 'string' || !secretKey) {
48
+ throw new Error(
49
+ 'Bokun API secretKey is required. Configure accessKey and secretKey in the integration token '
50
+ + 'or set ti2_bokun_accessKey and ti2_bokun_secretKey.',
51
+ );
52
+ }
53
+ if (typeof accessKey !== 'string' || !accessKey) {
54
+ throw new Error(
55
+ 'Bokun API accessKey is required. Configure accessKey and secretKey in the integration token '
56
+ + 'or set ti2_bokun_accessKey and ti2_bokun_secretKey.',
57
+ );
58
+ }
59
+ const date = moment.utc().format('YYYY-MM-DD HH:mm:ss');
60
+ const signature = generateBokunSignature({
61
+ secretKey,
62
+ accessKey,
63
+ date,
64
+ method,
65
+ path,
66
+ });
67
+
68
+ return {
69
+ 'X-Bokun-Date': date,
70
+ 'X-Bokun-AccessKey': accessKey,
71
+ 'X-Bokun-Signature': signature,
72
+ 'Content-Type': 'application/json;charset=UTF-8',
73
+ };
74
+ };
75
+
76
+ module.exports = {
77
+ buildRequestUrlAndPath,
78
+ generateBokunSignature,
79
+ getHeaders,
80
+ };
@@ -0,0 +1,211 @@
1
+ const moment = require('moment');
2
+ const { extractQuestionSpecs, toAnswerValues } = require('./customFields');
3
+ const { BOKUN_PASSENGER_FIELD_TO_UI_ID } = require('./customerFieldConstants');
4
+
5
+ const TRAVEL_DATE_FORMAT = 'YYYY-MM-DD';
6
+
7
+ const toDisplayTitle = raw => String(raw || '')
8
+ .trim()
9
+ .split(/[_\s-]+/)
10
+ .filter(Boolean)
11
+ .map(part => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
12
+ .join(' ');
13
+
14
+ const toFieldId = field => {
15
+ if (field == null) return '';
16
+ if (typeof field === 'string') return field.trim();
17
+ if (typeof field === 'object') return String(field.id || field.field || '').trim();
18
+ return '';
19
+ };
20
+
21
+ /**
22
+ * Apply Bokun's per-passenger contact-info requirements (productData.passengerFields)
23
+ * to the UI fields list. Every field is annotated with `visiblePerBooking: true`
24
+ * and defaults of `visiblePerParticipant: false` / `requiredPerParticipant: false`.
25
+ * Fields listed in Bokun's `passengerFields` are additionally tagged with
26
+ * `visiblePerParticipant: true` and `requiredPerParticipant: <required>` so the
27
+ * UI can distinguish which contact fields must be collected for each passenger.
28
+ * `visiblePerBooking` is always true so the lead passenger continues to enter
29
+ * the field at the booking root — `passengerFields` is additive, not exclusive.
30
+ *
31
+ * Unrecognised codes are logged once via `console.warn` and otherwise
32
+ * ignored, matching the rest of the plugin's best-effort enrichment style.
33
+ */
34
+ const applyPassengerFieldsToUiFields = (leadPassengerContactInfo, passengerContactInfo) => {
35
+ const leadPassengerFields = Array.isArray(leadPassengerContactInfo)
36
+ ? leadPassengerContactInfo
37
+ : [leadPassengerContactInfo];
38
+ const otherPassengerFields = Array.isArray(passengerContactInfo) ? passengerContactInfo : [passengerContactInfo];
39
+
40
+ const leadPassengerFieldIds = new Set(leadPassengerFields.map(toFieldId).filter(Boolean));
41
+ const flagsByUiId = {};
42
+ const passthroughFieldMetaById = {};
43
+ otherPassengerFields.forEach(entry => {
44
+ if (!entry || typeof entry !== 'object') return;
45
+ const rawCode = typeof entry.field === 'string' ? entry.field.trim() : '';
46
+ const uiId = BOKUN_PASSENGER_FIELD_TO_UI_ID[rawCode] || rawCode;
47
+ const requiredPerParticipant = Boolean(entry.required);
48
+
49
+ const existingFlags = flagsByUiId[uiId] || {
50
+ visiblePerParticipant: false,
51
+ requiredPerParticipant: false,
52
+ };
53
+ flagsByUiId[uiId] = {
54
+ visiblePerParticipant: true,
55
+ requiredPerParticipant: existingFlags.requiredPerParticipant || requiredPerParticipant,
56
+ };
57
+
58
+ if (!leadPassengerFieldIds.has(uiId) && !passthroughFieldMetaById[uiId]) {
59
+ passthroughFieldMetaById[uiId] = {
60
+ id: uiId,
61
+ title: toDisplayTitle(entry.field || rawCode),
62
+ type: 'short',
63
+ visiblePerBooking: true,
64
+ requiredPerBooking: false,
65
+ };
66
+ }
67
+ });
68
+
69
+ const annotatedBaseFields = leadPassengerFields.map(entry => {
70
+ const rawCode = typeof entry.field === 'string' ? entry.field.trim() : '';
71
+ const uiId = BOKUN_PASSENGER_FIELD_TO_UI_ID[rawCode] || rawCode;
72
+ const requiredPerBooking = Boolean(entry.required);
73
+ const perParticipantFlags = flagsByUiId[uiId] || {
74
+ visiblePerBooking: true,
75
+ visiblePerParticipant: false,
76
+ requiredPerParticipant: false,
77
+ };
78
+ return {
79
+ id: uiId,
80
+ title: toDisplayTitle(uiId),
81
+ type: 'short',
82
+ visiblePerBooking: true,
83
+ requiredPerBooking: requiredPerBooking || perParticipantFlags.requiredPerParticipant,
84
+ ...perParticipantFlags,
85
+ };
86
+ });
87
+
88
+ const passthroughFields = Object.values(passthroughFieldMetaById).map(field => ({
89
+ ...field,
90
+ visiblePerBooking: true,
91
+ requiredPerBooking: field.requiredPerBooking || Boolean(flagsByUiId[field.id]?.requiredPerParticipant),
92
+ ...flagsByUiId[field.id],
93
+ }));
94
+
95
+ return [...annotatedBaseFields, ...passthroughFields];
96
+ };
97
+
98
+ /**
99
+ * Validate that all server-required questions have non-empty values in the
100
+ * main-contact details. Other answer collections (passenger/activity) are
101
+ * intentionally not consulted here so a passenger's `firstName` cannot
102
+ * satisfy a main-contact requirement.
103
+ */
104
+ const validateRequiredContactFields = ({
105
+ requiredQuestions = [],
106
+ mainContactDetails = [],
107
+ sendNotificationToMainContact,
108
+ }) => {
109
+ const answerValuesByQuestionId = {};
110
+ (Array.isArray(mainContactDetails) ? mainContactDetails : []).forEach(answer => {
111
+ if (!answer || typeof answer.questionId !== 'string') return;
112
+ const values = toAnswerValues(answer)
113
+ .map(value => (value == null ? '' : String(value).trim()))
114
+ .filter(Boolean);
115
+ if (values.length === 0) return;
116
+ answerValuesByQuestionId[answer.questionId] = values;
117
+ });
118
+
119
+ return requiredQuestions.reduce((error, question) => {
120
+ if (error) return error;
121
+
122
+ const questionId = question?.questionId;
123
+ if (!questionId) return null;
124
+ const label = question?.label || questionId;
125
+
126
+ if (questionId === 'sendNotificationToMainContact') {
127
+ return typeof sendNotificationToMainContact === 'boolean'
128
+ ? null
129
+ : 'Please specify whether to send notification to the main contact.';
130
+ }
131
+
132
+ const value = (answerValuesByQuestionId[questionId] || [])[0] || '';
133
+ if (value !== '') return null;
134
+
135
+ if (questionId === 'nationality') return 'Please select a country.';
136
+ if (questionId === 'phoneNumber') return 'Please enter a valid phone number.';
137
+ if (questionId === 'email') return 'Please enter a valid email address.';
138
+ return `${label} is required.`;
139
+ }, null);
140
+ };
141
+
142
+ /**
143
+ * Reformat a date-ish value to Bokun's travel-date format (YYYY-MM-DD).
144
+ * Strict-only parsing: tries the caller-supplied `inputDateFormat`, then
145
+ * ISO 8601. Anything else passes through unchanged so we never silently
146
+ * transform unrelated strings (e.g. "123" → today) that happen to be
147
+ * accepted by moment's loose parser.
148
+ */
149
+ const formatToTravelDate = (value, inputDateFormat) => {
150
+ if (value == null) return value;
151
+ const raw = String(value).trim();
152
+ if (raw === '') return raw;
153
+
154
+ if (inputDateFormat) {
155
+ const parsed = moment(raw, inputDateFormat, true);
156
+ if (parsed.isValid()) return parsed.format(TRAVEL_DATE_FORMAT);
157
+ }
158
+
159
+ const parsedIso = moment(raw, moment.ISO_8601, true);
160
+ if (parsedIso.isValid()) return parsedIso.format(TRAVEL_DATE_FORMAT);
161
+
162
+ return value;
163
+ };
164
+
165
+ const normalizeCustomFieldValueForBooking = ({ field, value, inputDateFormat }) => {
166
+ if (String(field?.type || '').toLowerCase() !== 'date') return value;
167
+
168
+ const normalize = raw => {
169
+ if (raw == null) return raw;
170
+ if (Array.isArray(raw)) return raw.map(normalize);
171
+ if (typeof raw === 'object') {
172
+ if (raw.value != null) return { ...raw, value: formatToTravelDate(raw.value, inputDateFormat) };
173
+ if (raw.id != null) return { ...raw, id: formatToTravelDate(raw.id, inputDateFormat) };
174
+ return raw;
175
+ }
176
+ return formatToTravelDate(raw, inputDateFormat);
177
+ };
178
+
179
+ return normalize(value);
180
+ };
181
+
182
+ /**
183
+ * Pull required-question specs out of a Bokun checkout-options response.
184
+ * Prefer the documented shape `options[].questions` so nested structures
185
+ * (e.g. passenger answer trees) are not mis-read as main-contact questions.
186
+ * Falls back to a deep walk when that shape is empty or missing.
187
+ */
188
+ const extractRequiredQuestions = optionsData => {
189
+ const root = optionsData && typeof optionsData === 'object' ? optionsData : {};
190
+ const optionGroups = Array.isArray(root.options) ? root.options : [];
191
+ const fromOptions = optionGroups.flatMap(group => {
192
+ if (!group || typeof group !== 'object') return [];
193
+ const { questions } = group;
194
+ return Array.isArray(questions) ? questions : [];
195
+ });
196
+ const requiredFromOptions = fromOptions.filter(q => q && q.required);
197
+ if (requiredFromOptions.length > 0) {
198
+ return requiredFromOptions;
199
+ }
200
+ return extractQuestionSpecs(optionsData).filter(q => q && q.required);
201
+ };
202
+
203
+ module.exports = {
204
+ TRAVEL_DATE_FORMAT,
205
+ BOKUN_PASSENGER_FIELD_TO_UI_ID,
206
+ applyPassengerFieldsToUiFields,
207
+ validateRequiredContactFields,
208
+ formatToTravelDate,
209
+ normalizeCustomFieldValueForBooking,
210
+ extractRequiredQuestions,
211
+ };