@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.
- package/README.md +48 -0
- package/dist/capabilities/index.d.ts +9 -0
- package/dist/capabilities/index.d.ts.map +1 -0
- package/dist/capabilities/index.js +8 -0
- package/dist/capabilities/label.d.ts +27 -0
- package/dist/capabilities/label.d.ts.map +1 -0
- package/dist/capabilities/label.js +370 -0
- package/dist/capabilities/parcels.d.ts +21 -0
- package/dist/capabilities/parcels.d.ts.map +1 -0
- package/dist/capabilities/parcels.js +233 -0
- package/dist/capabilities/pickup-points.d.ts +38 -0
- package/dist/capabilities/pickup-points.d.ts.map +1 -0
- package/dist/capabilities/pickup-points.js +225 -0
- package/dist/capabilities/track.d.ts +16 -0
- package/dist/capabilities/track.d.ts.map +1 -0
- package/dist/capabilities/track.js +99 -0
- package/dist/client/index.d.ts +17 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +30 -0
- package/dist/errors.d.ts +34 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +165 -0
- package/dist/index.d.ts +119 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +151 -0
- package/dist/mappers/index.d.ts +108 -0
- package/dist/mappers/index.d.ts.map +1 -0
- package/dist/mappers/index.js +270 -0
- package/dist/mappers/trackStatus.d.ts +58 -0
- package/dist/mappers/trackStatus.d.ts.map +1 -0
- package/dist/mappers/trackStatus.js +290 -0
- package/dist/types/generated.d.ts +177 -0
- package/dist/types/generated.d.ts.map +1 -0
- package/dist/types/generated.js +9 -0
- package/dist/types/index.d.ts +7 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +6 -0
- package/dist/utils/httpUtils.d.ts +18 -0
- package/dist/utils/httpUtils.d.ts.map +1 -0
- package/dist/utils/httpUtils.js +33 -0
- package/dist/utils/resolveBaseUrl.d.ts +23 -0
- package/dist/utils/resolveBaseUrl.d.ts.map +1 -0
- package/dist/utils/resolveBaseUrl.js +19 -0
- package/dist/validation.d.ts +1723 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +799 -0
- 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
|
+
}
|