80 lines
3.5 KiB
TypeScript
80 lines
3.5 KiB
TypeScript
// What PUT /api/orders/:uuid forwards to the CMS: the fields of the checkout's steps, and nothing else.
|
|
// Step 1 (pages/checkout/1.vue) sends { data: { email, acceptedTermsAndConditionsAt } }, step 2 (pages/checkout/2.vue) sends
|
|
// { data: { invoiceAddress, deliveryAddress, invoiceAddressStructured, deliveryAddressStructured, delivery } }.
|
|
// The CMS checks the values and stays the authority (libreshop/cms src/checkout/customer-update.ts). This check is defence in depth:
|
|
// a body with any other field is answered 400 without calling the CMS, in the format of the CMS's own answer.
|
|
// Pure: no Nuxt, Nitro or h3 imports, tested in tests/unit/customerUpdate.test.ts.
|
|
import type { ShopError } from "./cmsError";
|
|
|
|
/** The order fields a customer may set, in the order of the checkout's steps. */
|
|
export const CUSTOMER_UPDATE_FIELDS = [
|
|
"email",
|
|
"acceptedTermsAndConditionsAt",
|
|
"invoiceAddress",
|
|
"deliveryAddress",
|
|
"invoiceAddressStructured",
|
|
"deliveryAddressStructured",
|
|
"delivery"
|
|
] as const;
|
|
|
|
export type CustomerUpdateField = (typeof CUSTOMER_UPDATE_FIELDS)[number];
|
|
|
|
/** The fields to forward, with the values the browser sent: the CMS checks them. */
|
|
export type CustomerUpdate = Partial<Record<CustomerUpdateField, unknown>>;
|
|
|
|
/**
|
|
* unknown: the names of the rejected fields, such as "data.paymentAuthorised", or "email" for a field sent beside data.
|
|
* errors: one message per rejected field or malformed part, in the CMS's format "name: reason".
|
|
*/
|
|
export type PickedCustomerUpdate = { ok: true; data: CustomerUpdate } | { ok: false; unknown: string[]; errors: string[] };
|
|
|
|
export const INVALID_ORDER_UPDATE = "Invalid order update";
|
|
|
|
// A field name is chosen by the client and ends up in the answer, so a long one is cut.
|
|
const MAX_NAME_LENGTH = 64;
|
|
|
|
const isObject = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
|
|
const isCustomerUpdateField = (key: string): key is CustomerUpdateField => (CUSTOMER_UPDATE_FIELDS as readonly string[]).includes(key);
|
|
|
|
const fieldName = (key: string): string => (key.length > MAX_NAME_LENGTH ? `${key.slice(0, MAX_NAME_LENGTH)}…` : key);
|
|
|
|
/** Picks the fields a customer may set from the body { data: { … } }. Any other field, or another shape, rejects the whole body. */
|
|
export const pickCustomerUpdate = (body: unknown): PickedCustomerUpdate => {
|
|
if (!isObject(body)) {
|
|
return { ok: false, unknown: [], errors: ["body: must be an object of the form { data: { … } }"] };
|
|
}
|
|
|
|
const unknown: string[] = [];
|
|
const errors: string[] = [];
|
|
for (const key of Object.keys(body)) {
|
|
if (key === "data") continue;
|
|
unknown.push(fieldName(key));
|
|
errors.push(`${fieldName(key)}: not accepted, the fields belong in data`);
|
|
}
|
|
|
|
const fields = body.data;
|
|
const data: CustomerUpdate = {};
|
|
if (!isObject(fields)) {
|
|
errors.push(fields === undefined ? "data: missing" : "data: must be an object");
|
|
} else {
|
|
for (const key of Object.keys(fields)) {
|
|
if (isCustomerUpdateField(key)) {
|
|
data[key] = fields[key];
|
|
} else {
|
|
unknown.push(`data.${fieldName(key)}`);
|
|
errors.push(`data.${fieldName(key)}: not a field the customer may set`);
|
|
}
|
|
}
|
|
}
|
|
|
|
return errors.length > 0 ? { ok: false, unknown, errors } : { ok: true, data };
|
|
};
|
|
|
|
/** The 400 for a rejected body: the shape the shop answers for the CMS's own 400 (cmsError.ts). */
|
|
export const invalidOrderUpdate = (errors: string[]): ShopError => ({
|
|
statusCode: 400,
|
|
statusMessage: INVALID_ORDER_UPDATE,
|
|
data: { message: INVALID_ORDER_UPDATE, errors }
|
|
});
|