193 lines
8.0 KiB
JavaScript
193 lines
8.0 KiB
JavaScript
import { createAdminCustomer } from 'pages/api/doormileApi';
|
||
|
||
// ==============================|| Doormile AI — write actions ||============================== //
|
||
//
|
||
// The FIRST write capability in the assistant. Read the rules before adding
|
||
// another one.
|
||
//
|
||
// The contract (assistant/CLAUDE.md §4): an intent never mutates. It returns a
|
||
// PROPOSAL — the exact payload it would submit — and nothing reaches the API
|
||
// until the operator presses Create in the panel. Parsing, validation and
|
||
// execution are separated here so the proposal can be built, shown and
|
||
// discarded without any possibility of a request going out.
|
||
//
|
||
// Customer creation was chosen as the first write deliberately: it needs two
|
||
// required fields where an order needs fourteen, it has no CityGate pincode
|
||
// gate, no geocoding and no delivery slot, and a wrong record is an edit
|
||
// rather than a rider dispatched to the wrong address.
|
||
|
||
// "create a customer", "add new client" — an explicit verb + noun. Deliberately
|
||
// narrow: nothing here should fire on a question that merely mentions customers.
|
||
export const CREATE_CUSTOMER_TRIGGER = /\b(?:create|add|register|new)\s+(?:a\s+|an\s+|the\s+)?(?:new\s+)?(?:customer|client)\b/i;
|
||
|
||
// Words that are part of the instruction rather than the person's name.
|
||
const FILLER =
|
||
/\b(?:create|add|register|new|a|an|the|customer|client|named|called|with|phone|number|mobile|contact|no|email|id|please)\b/gi;
|
||
|
||
// Pulls what it can out of free text. Anything it can't find stays undefined
|
||
// and is asked for — never guessed.
|
||
export const parseCustomerDraft = (text) => {
|
||
const raw = String(text || '');
|
||
|
||
const email = (raw.match(/[\w.+-]+@[\w-]+\.[\w.]{2,}/) || [])[0];
|
||
|
||
// Exactly ten digits, standalone. A longer run is not a phone number and
|
||
// must not be silently truncated into one.
|
||
const phone = (raw.match(/(?<!\d)(\d{10})(?!\d)/) || [])[1];
|
||
|
||
// Whatever is left after removing the instruction, the phone and the email
|
||
// is the person's name.
|
||
let nameArea = raw;
|
||
if (email) nameArea = nameArea.replace(email, ' ');
|
||
if (phone) nameArea = nameArea.replace(phone, ' ');
|
||
const nameWords = nameArea
|
||
.replace(FILLER, ' ')
|
||
.replace(/[^A-Za-z .'-]/g, ' ')
|
||
.split(/\s+/)
|
||
.filter((w) => w.length > 1);
|
||
|
||
return {
|
||
firstname: nameWords[0],
|
||
lastname: nameWords.slice(1).join(' ') || undefined,
|
||
phone,
|
||
email
|
||
};
|
||
};
|
||
|
||
// Mirrors createCustomer.js's own checks — a name, and a phone of exactly ten
|
||
// digits. If the form would refuse it, the assistant refuses it too, rather
|
||
// than letting the server decide.
|
||
//
|
||
// No tenant check: a customer record carries no tenantid, and the documented
|
||
// POST body doesn't take one. The page's own `isStaffLogin && !tid` guard
|
||
// exists because its dropdown sends a speculative tenantid; the assistant
|
||
// doesn't send one at all.
|
||
export const validateCustomerDraft = (draft) => {
|
||
const missing = [];
|
||
if (!draft.firstname) missing.push('the customer’s name');
|
||
if (!draft.phone || !/^\d{10}$/.test(String(draft.phone))) missing.push('a 10-digit mobile number');
|
||
return { ok: missing.length === 0, missing };
|
||
};
|
||
|
||
// The exact body that will be POSTed.
|
||
//
|
||
// Documented body for POST /admin/tenantcustomers is
|
||
// { firstname, lastname, phone, email } — and a customer record carries NO
|
||
// tenantid, which is why the tenant field was removed. A real
|
||
// GET /admin/customers response does carry address, doorno, landmark, suburb,
|
||
// city, state, postcode, latitude and longitude, so those are sent on a
|
||
// best-effort basis: empty strings are dropped rather than sent as noise, and
|
||
// if the server ignores the rest nothing breaks.
|
||
const clean = (obj) => Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined && v !== null && v !== ''));
|
||
|
||
export const buildCustomerPayload = (draft) =>
|
||
clean({
|
||
firstname: draft.firstname,
|
||
lastname: draft.lastname || '',
|
||
phone: draft.phone,
|
||
email: draft.email || '',
|
||
address: draft.address,
|
||
doorno: draft.doorno,
|
||
landmark: draft.landmark,
|
||
suburb: draft.suburb,
|
||
city: draft.city,
|
||
state: draft.state,
|
||
postcode: draft.postcode,
|
||
latitude: draft.latitude,
|
||
longitude: draft.longitude
|
||
});
|
||
|
||
// The ONLY function in the assistant that mutates anything. Called exclusively
|
||
// from the panel's confirm handler — never from an intent's run().
|
||
//
|
||
// ---- Which endpoint, and why -----------------------------------------------
|
||
//
|
||
// Writes to POST /admin/customers — the same resource the Customers page
|
||
// reads, so a created customer shows up there immediately.
|
||
//
|
||
// Note for anyone reading express-console-api.md: that document lists
|
||
// /admin/customers as GET + PATCH only, and describes a customer there as
|
||
// something created by a booking (POST /admin/expressbooking documents
|
||
// `customer_phone` as "creates a Guest customer if unknown"). This call was
|
||
// pointed here deliberately anyway. If the route turns out not to exist the
|
||
// server answers 404/405 and the assistant shows that verbatim alongside the
|
||
// endpoint name, so the failure is loud rather than silent.
|
||
//
|
||
// The previous target was POST /admin/tenantcustomers, which succeeded but
|
||
// wrote to a different store with its own id sequence — a customer created
|
||
// that way came back as id 2 while the Customers list ran to id 44, and was
|
||
// invisible on that page. That is the bug this change fixes.
|
||
//
|
||
// The payload already mirrors this resource's own GET shape (firstname,
|
||
// lastname, phone, email, address, doorno, landmark, suburb, city, state,
|
||
// postcode, latitude, longitude), so nothing else had to move.
|
||
export const executeCreateCustomer = async (payload) => {
|
||
const started = Date.now();
|
||
const call = {
|
||
name: 'createAdminCustomer',
|
||
target: 'POST /admin/customers',
|
||
stats: Object.keys(payload).join(', ')
|
||
};
|
||
|
||
try {
|
||
const res = await createAdminCustomer(payload);
|
||
const duration = `${Date.now() - started}ms`;
|
||
|
||
// doormileApi mutations return the full envelope, so a `success: false`
|
||
// arrives as a resolved promise, not a rejection.
|
||
if (res && res.success === false) {
|
||
return {
|
||
ok: false,
|
||
message: res.message || 'The server rejected the customer.',
|
||
sourceCalls: [{ ...call, duration, status: 'error', errorMessage: res.message || 'Rejected' }]
|
||
};
|
||
}
|
||
|
||
const created = res?.data || res;
|
||
return {
|
||
ok: true,
|
||
id: created?.appcustomerid ?? created?.customerid ?? created?.id,
|
||
created,
|
||
message: 'Customer created.',
|
||
sourceCalls: [{ ...call, duration, status: 'complete', stats: `created id ${created?.appcustomerid ?? created?.id ?? '—'}` }]
|
||
};
|
||
} catch (err) {
|
||
// An HTTP failure used to throw straight past this function, and the panel
|
||
// printed a generic "could not be created" with the status thrown away —
|
||
// which is the one detail needed to tell "the route does not exist" apart
|
||
// from "the body was wrong". Report the status, the server's own message,
|
||
// and name the endpoint.
|
||
const duration = `${Date.now() - started}ms`;
|
||
const status = err.response?.status;
|
||
const serverMessage = err.response?.data?.message || err.response?.data?.error;
|
||
|
||
let message;
|
||
if (status === 404 || status === 405) {
|
||
// express-console-api.md documents /admin/customers as GET + PATCH only.
|
||
// If the server agrees, say so plainly rather than implying the operator
|
||
// typed something wrong.
|
||
message =
|
||
`POST /admin/customers returned ${status} — that route does not exist on the server. ` +
|
||
'A customer there is created by a booking, or via POST /admin/tenantcustomers.';
|
||
} else if (status) {
|
||
message = `POST /admin/customers returned ${status}${serverMessage ? ` — ${serverMessage}` : ''}. Nothing was saved.`;
|
||
} else {
|
||
message = `${err.message || 'The request failed'} — the server could not be reached. Nothing was saved.`;
|
||
}
|
||
|
||
return {
|
||
ok: false,
|
||
status,
|
||
message,
|
||
sourceCalls: [
|
||
{
|
||
...call,
|
||
duration,
|
||
status: 'error',
|
||
errorMessage: `${status || 'network'}${serverMessage ? ` · ${serverMessage}` : ''}`
|
||
}
|
||
]
|
||
};
|
||
}
|
||
};
|