@shopickup/adapters-foxpost 0.0.1

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 (47) hide show
  1. package/README.md +48 -0
  2. package/dist/capabilities/index.d.ts +9 -0
  3. package/dist/capabilities/index.d.ts.map +1 -0
  4. package/dist/capabilities/index.js +8 -0
  5. package/dist/capabilities/label.d.ts +27 -0
  6. package/dist/capabilities/label.d.ts.map +1 -0
  7. package/dist/capabilities/label.js +370 -0
  8. package/dist/capabilities/parcels.d.ts +21 -0
  9. package/dist/capabilities/parcels.d.ts.map +1 -0
  10. package/dist/capabilities/parcels.js +233 -0
  11. package/dist/capabilities/pickup-points.d.ts +38 -0
  12. package/dist/capabilities/pickup-points.d.ts.map +1 -0
  13. package/dist/capabilities/pickup-points.js +225 -0
  14. package/dist/capabilities/track.d.ts +16 -0
  15. package/dist/capabilities/track.d.ts.map +1 -0
  16. package/dist/capabilities/track.js +99 -0
  17. package/dist/client/index.d.ts +17 -0
  18. package/dist/client/index.d.ts.map +1 -0
  19. package/dist/client/index.js +30 -0
  20. package/dist/errors.d.ts +34 -0
  21. package/dist/errors.d.ts.map +1 -0
  22. package/dist/errors.js +165 -0
  23. package/dist/index.d.ts +119 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +151 -0
  26. package/dist/mappers/index.d.ts +108 -0
  27. package/dist/mappers/index.d.ts.map +1 -0
  28. package/dist/mappers/index.js +270 -0
  29. package/dist/mappers/trackStatus.d.ts +58 -0
  30. package/dist/mappers/trackStatus.d.ts.map +1 -0
  31. package/dist/mappers/trackStatus.js +290 -0
  32. package/dist/types/generated.d.ts +177 -0
  33. package/dist/types/generated.d.ts.map +1 -0
  34. package/dist/types/generated.js +9 -0
  35. package/dist/types/index.d.ts +7 -0
  36. package/dist/types/index.d.ts.map +1 -0
  37. package/dist/types/index.js +6 -0
  38. package/dist/utils/httpUtils.d.ts +18 -0
  39. package/dist/utils/httpUtils.d.ts.map +1 -0
  40. package/dist/utils/httpUtils.js +33 -0
  41. package/dist/utils/resolveBaseUrl.d.ts +23 -0
  42. package/dist/utils/resolveBaseUrl.d.ts.map +1 -0
  43. package/dist/utils/resolveBaseUrl.js +19 -0
  44. package/dist/validation.d.ts +1723 -0
  45. package/dist/validation.d.ts.map +1 -0
  46. package/dist/validation.js +799 -0
  47. package/package.json +68 -0
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Foxpost Adapter: Parcel Creation Capabilities
3
+ * Handles CREATE_PARCEL and CREATE_PARCELS operations
4
+ */
5
+ import { CarrierError, serializeForLog, errorToLog } from "@shopickup/core";
6
+ import { mapParcelToFoxpostRequest, } from '../mappers/index.js';
7
+ import { translateFoxpostError, sanitizeResponseForLog } from '../errors.js';
8
+ import { safeValidateCreateParcelRequest, safeValidateCreateParcelsRequest, safeValidateFoxpostCreateResponse } from '../validation.js';
9
+ import { buildFoxpostHeaders } from '../utils/httpUtils.js';
10
+ /**
11
+ * Create a single parcel in Foxpost
12
+ * Delegates to createParcels to reuse batching logic
13
+ */
14
+ export async function createParcel(req, ctx, createParcelsImpl) {
15
+ // Validate request format and credentials
16
+ const validated = safeValidateCreateParcelRequest(req);
17
+ if (!validated.success) {
18
+ throw new CarrierError(`Invalid request: ${validated.error.message}`, "Validation", { raw: serializeForLog(validated.error) });
19
+ }
20
+ const batchReq = {
21
+ parcels: [req.parcel],
22
+ credentials: req.credentials,
23
+ options: req.options,
24
+ };
25
+ const response = await createParcelsImpl(batchReq, ctx);
26
+ // Expect CreateParcelsResponse. Validate shape and return the first result.
27
+ if (!response || !Array.isArray(response.results)) {
28
+ // Defensive: unexpected shape from createParcels
29
+ throw new CarrierError("Unexpected response shape from createParcels", "Transient", { raw: serializeForLog(response) });
30
+ }
31
+ const results = response.results;
32
+ if (results.length === 0) {
33
+ throw new CarrierError("createParcels returned an empty results array", "Transient", { raw: serializeForLog(response) });
34
+ }
35
+ // Return the first parcel result, but attach rawCarrierResponse for batch-level context
36
+ const result = results[0];
37
+ return {
38
+ ...result,
39
+ rawCarrierResponse: response.rawCarrierResponse,
40
+ };
41
+ }
42
+ /**
43
+ * Create multiple parcels in one call
44
+ * Maps canonical Parcel array to Foxpost CreateParcelRequest and calls the
45
+ * Foxpost batch endpoint which accepts an array. Returns per-item CarrierResource
46
+ * so callers can handle partial failures.
47
+ *
48
+ * @returns CreateParcelsResponse with summary and per-item results
49
+ */
50
+ export async function createParcels(req, ctx, resolveBaseUrl) {
51
+ try {
52
+ // Validate request format and credentials
53
+ const validated = safeValidateCreateParcelsRequest(req);
54
+ if (!validated.success) {
55
+ throw new CarrierError(`Invalid request: ${validated.error.message}`, "Validation", { raw: validated.error });
56
+ }
57
+ if (!ctx.http) {
58
+ throw new CarrierError("HTTP client not provided in context", "Permanent");
59
+ }
60
+ if (!Array.isArray(req.parcels) || req.parcels.length === 0) {
61
+ return {
62
+ results: [],
63
+ successCount: 0,
64
+ failureCount: 0,
65
+ totalCount: 0,
66
+ allSucceeded: false,
67
+ allFailed: false,
68
+ someFailed: false,
69
+ summary: "No parcels to process",
70
+ };
71
+ }
72
+ // For simplicity require uniform test-mode and credentials across the batch
73
+ const baseUrl = resolveBaseUrl(validated.data.options);
74
+ const useTestApi = validated.data.options?.useTestApi ?? false;
75
+ const isWeb = !useTestApi;
76
+ // Validate and map each canonical parcel to Foxpost request format
77
+ // mapParcelToFoxpostRequest returns strongly-typed FoxCreateParcelRequestItem
78
+ const foxpostRequestsWithValidation = req.parcels.map((parcel, idx) => {
79
+ // Map canonical parcel to Foxpost CreateParcelRequest format
80
+ const foxpostRequest = mapParcelToFoxpostRequest(parcel);
81
+ // Validate the mapped request shape before sending to Foxpost API
82
+ try {
83
+ // Ensure required fields are present for the delivery type
84
+ if (!foxpostRequest.recipientName || !foxpostRequest.recipientEmail || !foxpostRequest.recipientPhone) {
85
+ throw new Error('Missing required recipient fields (name, email, phone)');
86
+ }
87
+ // For HOME delivery, validate address fields are all present
88
+ if (foxpostRequest.recipientCity || foxpostRequest.recipientZip || foxpostRequest.recipientAddress) {
89
+ if (!(foxpostRequest.recipientCity && foxpostRequest.recipientZip && foxpostRequest.recipientAddress)) {
90
+ throw new Error('Address fields must be either all present (HD) or all absent (APM)');
91
+ }
92
+ }
93
+ // For APM delivery, validate destination is present
94
+ if (!foxpostRequest.recipientCity && !foxpostRequest.recipientZip && !foxpostRequest.recipientAddress) {
95
+ if (!foxpostRequest.destination) {
96
+ throw new Error('APM parcel must have destination field set');
97
+ }
98
+ }
99
+ }
100
+ catch (validationErr) {
101
+ throw new CarrierError(`Invalid carrier payload for parcel ${idx}: ${validationErr.message}`, "Validation", { raw: serializeForLog({ parcelIdx: idx, parcelId: parcel.id }) });
102
+ }
103
+ return foxpostRequest;
104
+ });
105
+ ctx.logger?.debug("Foxpost: Creating parcels batch", {
106
+ count: req.parcels.length,
107
+ testMode: useTestApi,
108
+ });
109
+ const httpResponse = await ctx.http.post(`${baseUrl}/api/parcel?isWeb=${isWeb}&isRedirect=false`, foxpostRequestsWithValidation, {
110
+ headers: buildFoxpostHeaders(validated.data.credentials),
111
+ });
112
+ // Extract body from normalized HttpResponse and validate shape
113
+ const carrierRespBody = httpResponse.body;
114
+ // Validate the response shape
115
+ const responseValidation = safeValidateFoxpostCreateResponse(carrierRespBody);
116
+ if (!responseValidation.success) {
117
+ ctx.logger?.warn("Foxpost: Response validation failed", {
118
+ errors: serializeForLog(responseValidation.error.issues)
119
+ });
120
+ // Continue anyway - be lenient with response shape
121
+ }
122
+ if (!carrierRespBody || !Array.isArray(carrierRespBody.parcels)) {
123
+ throw new CarrierError("Invalid response from Foxpost", "Transient", { raw: serializeForLog(sanitizeResponseForLog(httpResponse)) });
124
+ }
125
+ const response = carrierRespBody;
126
+ // Check if response indicates an overall validation failure
127
+ if (response.valid === false && response.errors && Array.isArray(response.errors)) {
128
+ const firstError = response.errors[0];
129
+ const errorCode = firstError?.message || "VALIDATION_ERROR";
130
+ const errorField = firstError?.field || "unknown";
131
+ throw new CarrierError(`Validation error: ${errorCode} (field: ${errorField})`, "Validation", {
132
+ carrierCode: errorCode,
133
+ raw: serializeForLog(response)
134
+ });
135
+ }
136
+ // Map carrier response array -> CarrierResource[]
137
+ const results = (response.parcels || []).map((p, idx) => {
138
+ // Check for parcel-level validation errors
139
+ if (p.errors && Array.isArray(p.errors) && p.errors.length > 0) {
140
+ const errors = p.errors.map((err) => ({
141
+ field: err.field,
142
+ code: err.message, // Foxpost returns error code in 'message' field
143
+ message: `${err.field ? `Field '${err.field}': ` : ''}${err.message}`,
144
+ }));
145
+ ctx.logger?.warn("Foxpost: Parcel validation errors", {
146
+ parcelIdx: idx,
147
+ errorCount: errors.length,
148
+ errorSummary: errors.map(e => `${e.field || 'unknown'}: ${e.code}`),
149
+ refCode: p.refCode,
150
+ errors: serializeForLog(errors),
151
+ });
152
+ const rawParcel = serializeForLog(p);
153
+ rawParcel.errors = errors;
154
+ const failedResource = {
155
+ carrierId: undefined,
156
+ status: "failed",
157
+ raw: rawParcel,
158
+ errors,
159
+ };
160
+ return failedResource;
161
+ }
162
+ // Check for successful barcode assignment (try multiple field names)
163
+ const carrierId = p.clFoxId || p.barcode || p.newBarcode;
164
+ if (!carrierId) {
165
+ ctx.logger?.warn("Foxpost: Parcel created returned no barcode", {
166
+ parcelIdx: idx,
167
+ refCode: p.refCode,
168
+ availableFields: Object.keys(p).join(', '),
169
+ });
170
+ const failedResource = {
171
+ carrierId: undefined,
172
+ status: "failed",
173
+ raw: serializeForLog(p),
174
+ errors: [{
175
+ field: "clFoxId/barcode",
176
+ message: "No barcode assigned by carrier",
177
+ code: "NO_BARCODE_ASSIGNED",
178
+ }],
179
+ };
180
+ return failedResource;
181
+ }
182
+ // Success - parcel was created with barcode
183
+ return {
184
+ carrierId,
185
+ status: "created",
186
+ raw: serializeForLog(p),
187
+ };
188
+ });
189
+ // Calculate summary statistics
190
+ const successCount = results.filter(r => r.status === 'created').length;
191
+ const failureCount = results.filter(r => r.status === 'failed').length;
192
+ const totalCount = results.length;
193
+ // Determine summary text
194
+ let summary;
195
+ if (failureCount === 0) {
196
+ summary = `All ${totalCount} parcels created successfully`;
197
+ }
198
+ else if (successCount === 0) {
199
+ summary = `All ${totalCount} parcels failed`;
200
+ }
201
+ else {
202
+ summary = `Mixed results: ${successCount} succeeded, ${failureCount} failed`;
203
+ }
204
+ ctx.logger?.info("Foxpost: Parcels creation finished", {
205
+ count: results.length,
206
+ testMode: useTestApi,
207
+ summary,
208
+ successCount,
209
+ failureCount,
210
+ });
211
+ // Return strongly-typed response with full carrier response for debugging
212
+ return {
213
+ results,
214
+ successCount,
215
+ failureCount,
216
+ totalCount,
217
+ allSucceeded: failureCount === 0 && totalCount > 0,
218
+ allFailed: successCount === 0 && totalCount > 0,
219
+ someFailed: successCount > 0 && failureCount > 0,
220
+ summary,
221
+ rawCarrierResponse: serializeForLog(sanitizeResponseForLog(httpResponse)),
222
+ };
223
+ }
224
+ catch (error) {
225
+ ctx.logger?.error("Foxpost: Error creating parcels batch", {
226
+ error: errorToLog(error),
227
+ });
228
+ if (error instanceof CarrierError) {
229
+ throw error;
230
+ }
231
+ throw translateFoxpostError(error);
232
+ }
233
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Foxpost Pickup Points Capability
3
+ *
4
+ * Fetches and normalizes the list of Foxpost APMs (Automated Parcel Machines) and pickup points
5
+ * from the public JSON feed at https://cdn.foxpost.hu/foxplus.json
6
+ *
7
+ * The feed is updated hourly by Foxpost and contains all active pickup points with their
8
+ * details, opening hours, payment options, and services.
9
+ */
10
+ import type { FetchPickupPointsRequest, FetchPickupPointsResponse, AdapterContext } from "@shopickup/core";
11
+ /**
12
+ * Fetch pickup points (APMs) from Foxpost
13
+ *
14
+ * Fetches the public JSON feed from https://cdn.foxpost.hu/foxplus.json
15
+ * and normalizes each APM entry to the canonical PickupPoint format.
16
+ *
17
+ * Note on logging: By default, raw APM responses are logged as summaries only
18
+ * to avoid polluting logs with hundreds of pickup point entries.
19
+ * To customize logging behavior, pass loggingOptions in the adapter context:
20
+ * {
21
+ * loggingOptions: {
22
+ * logRawResponse: true, // Log full response
23
+ * logRawResponse: false, // Skip logging raw entirely
24
+ * logRawResponse: 'summary', // Log only summary (default)
25
+ * maxArrayItems: 50, // Increase items shown in arrays
26
+ * silentOperations: ['fetchPickupPoints'] // Suppress all logging
27
+ * }
28
+ * }
29
+ *
30
+ * When using the withOperationName wrapper from @shopickup/core, the operation name
31
+ * is automatically injected and logging control is applied automatically.
32
+ *
33
+ * @param req Request with optional filters and credentials (not used for public feed)
34
+ * @param ctx Adapter context with HTTP client (operationName set by wrapper or manually)
35
+ * @returns FetchPickupPointsResponse with normalized pickup points
36
+ */
37
+ export declare function fetchPickupPoints(req: FetchPickupPointsRequest, ctx: AdapterContext): Promise<FetchPickupPointsResponse>;
38
+ //# sourceMappingURL=pickup-points.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pickup-points.d.ts","sourceRoot":"","sources":["../../src/capabilities/pickup-points.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EACV,wBAAwB,EACxB,yBAAyB,EAEzB,cAAc,EAEf,MAAM,iBAAiB,CAAC;AA4GzB;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,wBAAsB,iBAAiB,CACrC,GAAG,EAAE,wBAAwB,EAC7B,GAAG,EAAE,cAAc,GAClB,OAAO,CAAC,yBAAyB,CAAC,CA8IpC"}
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Foxpost Pickup Points Capability
3
+ *
4
+ * Fetches and normalizes the list of Foxpost APMs (Automated Parcel Machines) and pickup points
5
+ * from the public JSON feed at https://cdn.foxpost.hu/foxplus.json
6
+ *
7
+ * The feed is updated hourly by Foxpost and contains all active pickup points with their
8
+ * details, opening hours, payment options, and services.
9
+ */
10
+ import { CarrierError, safeLog, createLogEntry } from "@shopickup/core";
11
+ import { safeValidateFetchPickupPointsRequest, safeValidateFoxpostApmEntry, } from "../validation.js";
12
+ /**
13
+ * Normalize a Foxpost APM entry to canonical PickupPoint
14
+ */
15
+ function mapFoxpostApmToPickupPoint(apm) {
16
+ // Determine primary ID: operator_id if present and non-empty, otherwise place_id
17
+ const operatorId = apm.operator_id?.trim();
18
+ const placeId = apm.place_id?.toString().trim();
19
+ const id = operatorId || placeId || `fallback-${Math.random().toString(36).slice(2, 9)}`;
20
+ const providerId = operatorId ? placeId : operatorId;
21
+ // Parse coordinates (Zod already coerced them to numbers in validation)
22
+ let latitude = apm.geolat;
23
+ let longitude = apm.geolng;
24
+ // Determine allowed services from allowed2 field
25
+ // "ALL" = both pickup and dropoff
26
+ // "B2C" = typically both pickup and dropoff for B2C
27
+ // "C2C" = consumer-to-consumer (no dropoff)
28
+ let pickupAllowed = true; // default to true as most APMs support pickup
29
+ let dropoffAllowed = true; // default to true as most APMs support dropoff
30
+ if (apm.allowed2 === "C2C") {
31
+ // C2C typically means dropoff only (no pickup)
32
+ dropoffAllowed = true;
33
+ pickupAllowed = false;
34
+ }
35
+ else if (apm.allowed2 === "B2C") {
36
+ // B2C allows both
37
+ dropoffAllowed = true;
38
+ pickupAllowed = true;
39
+ }
40
+ else if (apm.allowed2 === "ALL") {
41
+ // ALL allows both
42
+ dropoffAllowed = true;
43
+ pickupAllowed = true;
44
+ }
45
+ // Build payment options array
46
+ const paymentOptions = [];
47
+ if (apm.cardPayment) {
48
+ paymentOptions.push("card");
49
+ }
50
+ if (apm.cashPayment) {
51
+ paymentOptions.push("cash");
52
+ }
53
+ // Include unified paymentOptions if available
54
+ if (apm.paymentOptions && Array.isArray(apm.paymentOptions)) {
55
+ paymentOptions.push(...apm.paymentOptions.filter((p) => !paymentOptions.includes(p)));
56
+ }
57
+ // Build address string if not provided
58
+ let address = apm.address;
59
+ if (!address && (apm.street || apm.city || apm.zip)) {
60
+ const parts = [];
61
+ if (apm.zip)
62
+ parts.push(apm.zip);
63
+ if (apm.city)
64
+ parts.push(apm.city);
65
+ if (apm.street)
66
+ parts.push(apm.street);
67
+ address = parts.join(", ");
68
+ }
69
+ // Collect all carrier-specific fields in metadata
70
+ const metadata = {
71
+ depot: apm.depot,
72
+ load: apm.load,
73
+ apmType: apm.apmType,
74
+ substitutes: apm.substitutes,
75
+ variant: apm.variant,
76
+ fillEmptyList: apm.fillEmptyList,
77
+ ssapt: apm.ssapt,
78
+ sdapt: apm.sdapt,
79
+ };
80
+ // Remove undefined keys from metadata
81
+ const cleanedMetadata = Object.fromEntries(Object.entries(metadata).filter(([, value]) => value !== undefined));
82
+ return {
83
+ id,
84
+ providerId: providerId || undefined,
85
+ name: apm.name,
86
+ country: apm.country?.toLowerCase(),
87
+ postalCode: apm.zip,
88
+ city: apm.city,
89
+ street: apm.street,
90
+ address,
91
+ findme: apm.findme,
92
+ latitude,
93
+ longitude,
94
+ openingHours: apm.open,
95
+ dropoffAllowed,
96
+ pickupAllowed,
97
+ isOutdoor: apm.isOutdoor,
98
+ paymentOptions: paymentOptions.length > 0 ? paymentOptions : undefined,
99
+ contact: undefined, // Foxpost doesn't provide contact info in feed
100
+ metadata: Object.keys(cleanedMetadata).length > 0 ? cleanedMetadata : undefined,
101
+ raw: apm,
102
+ };
103
+ }
104
+ /**
105
+ * Fetch pickup points (APMs) from Foxpost
106
+ *
107
+ * Fetches the public JSON feed from https://cdn.foxpost.hu/foxplus.json
108
+ * and normalizes each APM entry to the canonical PickupPoint format.
109
+ *
110
+ * Note on logging: By default, raw APM responses are logged as summaries only
111
+ * to avoid polluting logs with hundreds of pickup point entries.
112
+ * To customize logging behavior, pass loggingOptions in the adapter context:
113
+ * {
114
+ * loggingOptions: {
115
+ * logRawResponse: true, // Log full response
116
+ * logRawResponse: false, // Skip logging raw entirely
117
+ * logRawResponse: 'summary', // Log only summary (default)
118
+ * maxArrayItems: 50, // Increase items shown in arrays
119
+ * silentOperations: ['fetchPickupPoints'] // Suppress all logging
120
+ * }
121
+ * }
122
+ *
123
+ * When using the withOperationName wrapper from @shopickup/core, the operation name
124
+ * is automatically injected and logging control is applied automatically.
125
+ *
126
+ * @param req Request with optional filters and credentials (not used for public feed)
127
+ * @param ctx Adapter context with HTTP client (operationName set by wrapper or manually)
128
+ * @returns FetchPickupPointsResponse with normalized pickup points
129
+ */
130
+ export async function fetchPickupPoints(req, ctx) {
131
+ if (!ctx.http) {
132
+ throw new CarrierError("HTTP client not provided in adapter context", "Permanent", { raw: "Missing ctx.http" });
133
+ }
134
+ const feedUrl = "https://cdn.foxpost.hu/foxplus.json";
135
+ try {
136
+ const validatedReq = safeValidateFetchPickupPointsRequest(req);
137
+ if (!validatedReq.success) {
138
+ throw new CarrierError(`Invalid request: ${validatedReq.error.message}`, "Validation", { raw: validatedReq.error.issues });
139
+ }
140
+ // Normalize namespaced options into a flat internal shape for adapter logic.
141
+ const internalOptions = {
142
+ country: validatedReq.data.options?.foxpost?.country,
143
+ bbox: validatedReq.data.options?.foxpost?.bbox,
144
+ };
145
+ // Fetch the public JSON feed (no authentication needed)
146
+ // operationName is already set by withOperationName wrapper
147
+ safeLog(ctx.logger, 'debug', 'Fetching Foxpost APM feed', { url: feedUrl }, ctx);
148
+ const response = await ctx.http.get(feedUrl);
149
+ // Extract body from normalized HttpResponse
150
+ const apmData = response.body;
151
+ // Validate response is an array
152
+ if (!Array.isArray(apmData)) {
153
+ throw new Error("Expected array of APMs from Foxpost feed, got: " + typeof apmData);
154
+ }
155
+ safeLog(ctx.logger, 'debug', 'Fetched Foxpost APM feed', createLogEntry({ url: feedUrl }, apmData, ctx), ctx);
156
+ // Map each entry to PickupPoint with per-entry validation
157
+ let points = apmData.map((entry) => {
158
+ try {
159
+ // Validate and coerce entry using Zod schema
160
+ const validation = safeValidateFoxpostApmEntry(entry);
161
+ if (!validation.success) {
162
+ ctx.logger?.warn("Skipping invalid APM entry", {
163
+ errors: validation.error.issues,
164
+ entry: entry instanceof Object ? JSON.stringify(entry).substring(0, 100) : entry
165
+ });
166
+ return null;
167
+ }
168
+ // Map validated entry to PickupPoint
169
+ return mapFoxpostApmToPickupPoint(validation.data);
170
+ }
171
+ catch (err) {
172
+ ctx.logger?.warn("Failed to map APM entry", { error: String(err), entry: entry instanceof Object ? JSON.stringify(entry).substring(0, 100) : entry });
173
+ // Skip entries that can't be mapped
174
+ return null;
175
+ }
176
+ }).filter((p) => p !== null);
177
+ if (internalOptions.country) {
178
+ const countryFilter = internalOptions.country.toLowerCase();
179
+ points = points.filter((p) => p.country?.toLowerCase() === countryFilter);
180
+ }
181
+ if (internalOptions.bbox) {
182
+ const { north, south, east, west } = internalOptions.bbox;
183
+ points = points.filter((p) => {
184
+ if (p.latitude === undefined || p.longitude === undefined) {
185
+ return false;
186
+ }
187
+ return (p.latitude <= north &&
188
+ p.latitude >= south &&
189
+ p.longitude <= east &&
190
+ p.longitude >= west);
191
+ });
192
+ }
193
+ safeLog(ctx.logger, 'info', 'Successfully fetched and mapped Foxpost APMs', {
194
+ total: apmData.length,
195
+ succeeded: points.length,
196
+ failed: apmData.length - points.length,
197
+ appliedFilters: {
198
+ country: internalOptions.country,
199
+ bbox: internalOptions.bbox ? true : false,
200
+ },
201
+ }, ctx);
202
+ return {
203
+ points,
204
+ summary: {
205
+ totalCount: points.length,
206
+ updatedAt: new Date().toISOString(),
207
+ },
208
+ rawCarrierResponse: apmData,
209
+ };
210
+ }
211
+ catch (err) {
212
+ const errorMessage = err instanceof Error ? err.message : String(err);
213
+ ctx.logger?.error("Failed to fetch Foxpost APM feed", { error: errorMessage, url: feedUrl });
214
+ // Categorize error for retry logic
215
+ let category = "Transient";
216
+ let details = { raw: err };
217
+ // If it's a network error or 5xx, it's transient
218
+ // If it's 4xx or parsing error, it's permanent
219
+ if (err instanceof Error && err.message.includes("Expected array")) {
220
+ category = "Permanent";
221
+ details.reason = "Invalid response format";
222
+ }
223
+ throw new CarrierError(`Failed to fetch Foxpost APM feed: ${errorMessage}`, category, details);
224
+ }
225
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Foxpost Adapter: Parcel Tracking Capability
3
+ * Handles TRACK operation
4
+ */
5
+ import type { AdapterContext, TrackingRequest, TrackingUpdate } from "@shopickup/core";
6
+ import type { ResolveBaseUrl } from '../utils/resolveBaseUrl.js';
7
+ /**
8
+ * Track a parcel by its clFoxId or uniqueBarcode using the GET /api/tracking/{barcode} endpoint
9
+ *
10
+ * Returns normalized tracking information with all available traces in reverse chronological order
11
+ *
12
+ * To use test API, pass in request as:
13
+ * { trackingNumber: barcode, credentials: {...}, options?: { useTestApi: true } }
14
+ */
15
+ export declare function track(req: TrackingRequest, ctx: AdapterContext, resolveBaseUrl: ResolveBaseUrl): Promise<TrackingUpdate>;
16
+ //# sourceMappingURL=track.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"track.d.ts","sourceRoot":"","sources":["../../src/capabilities/track.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,KAAK,EACV,cAAc,EACd,eAAe,EACf,cAAc,EACf,MAAM,iBAAiB,CAAC;AASzB,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAC;AAEjE;;;;;;;GAOG;AACH,wBAAsB,KAAK,CACzB,GAAG,EAAE,eAAe,EACpB,GAAG,EAAE,cAAc,EACnB,cAAc,EAAE,cAAc,GAC7B,OAAO,CAAC,cAAc,CAAC,CAoHzB"}
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Foxpost Adapter: Parcel Tracking Capability
3
+ * Handles TRACK operation
4
+ */
5
+ import { CarrierError, serializeForLog, errorToLog } from "@shopickup/core";
6
+ import { mapFoxpostTraceToCanonical, } from '../mappers/index.js';
7
+ import { translateFoxpostError } from '../errors.js';
8
+ import { safeValidateTrackingRequest, safeValidateFoxpostTracking } from '../validation.js';
9
+ import { buildFoxpostHeaders } from '../utils/httpUtils.js';
10
+ /**
11
+ * Track a parcel by its clFoxId or uniqueBarcode using the GET /api/tracking/{barcode} endpoint
12
+ *
13
+ * Returns normalized tracking information with all available traces in reverse chronological order
14
+ *
15
+ * To use test API, pass in request as:
16
+ * { trackingNumber: barcode, credentials: {...}, options?: { useTestApi: true } }
17
+ */
18
+ export async function track(req, ctx, resolveBaseUrl) {
19
+ try {
20
+ // Validate request format and credentials
21
+ const validated = safeValidateTrackingRequest(req);
22
+ if (!validated.success) {
23
+ throw new CarrierError(`Invalid request: ${validated.error.message}`, "Validation", { raw: serializeForLog(validated.error) });
24
+ }
25
+ if (!ctx.http) {
26
+ throw new CarrierError("HTTP client not provided in context", "Permanent");
27
+ }
28
+ // Extract useTestApi from validated request (per-call test mode selection)
29
+ const useTestApi = validated.data.options?.useTestApi ?? false;
30
+ const baseUrl = resolveBaseUrl(validated.data.options);
31
+ // Extract strongly-typed credentials from validated request
32
+ const trackingNumber = validated.data.trackingNumber;
33
+ ctx.logger?.debug("Foxpost: Tracking parcel", {
34
+ trackingNumber,
35
+ testMode: useTestApi,
36
+ });
37
+ // Get tracking history via /api/tracking/{barcode} endpoint with proper typing
38
+ const url = `${baseUrl}/api/tracking/${trackingNumber}`;
39
+ const httpResponse = await ctx.http.get(url, {
40
+ headers: buildFoxpostHeaders(validated.data.credentials),
41
+ });
42
+ // Extract body from normalized HttpResponse
43
+ const response = httpResponse.body;
44
+ // Foxpost returns an empty body for missing tracking numbers on some environments.
45
+ // Treat that as a not-found / validation-style miss instead of trying to validate it.
46
+ if (response == null || (typeof response === 'string' && response.trim() === '')) {
47
+ throw new CarrierError(`No tracking information found for ${trackingNumber}`, 'NotFound', { raw: serializeForLog({ status: httpResponse.status, body: response }) });
48
+ }
49
+ // Validate response against Zod schema
50
+ const responseValidation = safeValidateFoxpostTracking(response);
51
+ if (!responseValidation.success) {
52
+ throw new CarrierError(`Invalid tracking response: ${responseValidation.error.message}`, "Validation", { raw: serializeForLog(responseValidation.error) });
53
+ }
54
+ const validatedResponse = responseValidation.data;
55
+ // Validate clFox is present
56
+ if (!validatedResponse.clFox) {
57
+ throw new CarrierError(`No tracking information found for ${trackingNumber}`, "NotFound");
58
+ }
59
+ // Validate traces array exists
60
+ if (!Array.isArray(validatedResponse.traces)) {
61
+ throw new CarrierError(`Invalid tracking response: traces array missing for ${trackingNumber}`, "Transient", { raw: serializeForLog(validatedResponse) });
62
+ }
63
+ // Convert Foxpost traces to canonical TrackingEvents
64
+ // Traces arrive in reverse chronological order (latest first), but we want them chronological for the response
65
+ const events = validatedResponse.traces
66
+ .map(mapFoxpostTraceToCanonical)
67
+ .reverse(); // Reverse to get chronological order
68
+ // Current status is from the latest trace (which is first in the API response)
69
+ const currentStatus = validatedResponse.traces.length > 0
70
+ ? mapFoxpostTraceToCanonical(validatedResponse.traces[0]).status
71
+ : "PENDING";
72
+ ctx.logger?.info("Foxpost: Tracking retrieved", {
73
+ trackingNumber,
74
+ clFox: validatedResponse.clFox,
75
+ status: currentStatus,
76
+ events: events.length,
77
+ parcelType: validatedResponse.parcelType,
78
+ sendType: validatedResponse.sendType,
79
+ testMode: useTestApi,
80
+ });
81
+ return {
82
+ trackingNumber,
83
+ events,
84
+ status: currentStatus,
85
+ lastUpdate: events.length > 0 ? events[events.length - 1].timestamp : null,
86
+ rawCarrierResponse: validatedResponse,
87
+ };
88
+ }
89
+ catch (error) {
90
+ if (error instanceof CarrierError) {
91
+ throw error;
92
+ }
93
+ ctx.logger?.error("Foxpost: Error tracking parcel", {
94
+ trackingNumber: req.trackingNumber,
95
+ error: errorToLog(error),
96
+ });
97
+ throw translateFoxpostError(error);
98
+ }
99
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Foxpost HTTP Client Utilities
3
+ * Thin utilities for building Foxpost API requests
4
+ */
5
+ import type { FoxpostCredentials } from '../validation.js';
6
+ /**
7
+ * Build standard Foxpost auth headers
8
+ * Requires both Basic auth (username:password) and API key
9
+ * Uses capitalized "Api-key" header (Foxpost recommended format)
10
+ */
11
+ export declare function buildFoxpostHeaders(credentials: FoxpostCredentials): Record<string, string>;
12
+ /**
13
+ * Build Foxpost headers for binary responses (e.g., PDF)
14
+ * Uses capitalized "Api-key" header (Foxpost recommended format)
15
+ */
16
+ export declare function buildFoxpostBinaryHeaders(credentials: FoxpostCredentials): Record<string, string>;
17
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/client/index.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AAE3D;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,WAAW,EAAE,kBAAkB,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAU3F;AAED;;;GAGG;AACH,wBAAgB,yBAAyB,CAAC,WAAW,EAAE,kBAAkB,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CASjG"}
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Foxpost HTTP Client Utilities
3
+ * Thin utilities for building Foxpost API requests
4
+ */
5
+ /**
6
+ * Build standard Foxpost auth headers
7
+ * Requires both Basic auth (username:password) and API key
8
+ * Uses capitalized "Api-key" header (Foxpost recommended format)
9
+ */
10
+ export function buildFoxpostHeaders(credentials) {
11
+ const { apiKey, basicUsername, basicPassword } = credentials;
12
+ const basicAuth = Buffer.from(`${basicUsername}:${basicPassword}`).toString("base64");
13
+ return {
14
+ "Content-Type": "application/json",
15
+ "Authorization": `Basic ${basicAuth}`,
16
+ ...(apiKey && { "Api-key": apiKey }),
17
+ };
18
+ }
19
+ /**
20
+ * Build Foxpost headers for binary responses (e.g., PDF)
21
+ * Uses capitalized "Api-key" header (Foxpost recommended format)
22
+ */
23
+ export function buildFoxpostBinaryHeaders(credentials) {
24
+ const { apiKey, basicUsername, basicPassword } = credentials;
25
+ const basicAuth = Buffer.from(`${basicUsername}:${basicPassword}`).toString("base64");
26
+ return {
27
+ "Authorization": `Basic ${basicAuth}`,
28
+ ...(apiKey && { "Api-key": apiKey }),
29
+ };
30
+ }