import { createTenantCustomer } 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(/(? 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/tenantcustomers. This is now settled by evidence, not // by reading the docs: // // POST /admin/customers → 405 Method Not Allowed (confirmed live) // // 405 is the unambiguous answer: the route exists, and POST is not among its // methods. express-console-api.md lists /admin/customers as GET + PATCH only, // and the server agrees. It was pointed there briefly on explicit instruction; // the live 405 settled it. // // The consequence, which the assistant states in its success message rather // than leaving the operator to discover: a customer created here does NOT // appear on the Customers page, because that page reads GET /admin/customers. // On that resource a customer comes into existence as a side effect of a // booking — POST /admin/expressbooking documents `customer_phone` as "creates a // Guest customer if unknown". A B2C customer is, by design, someone who has // ordered. // // To make created customers visible on that page, one of these has to happen: // • the Customers page reads /admin/tenantcustomers (tried once, reverted — // it changes what that page means, and its edit dialog would then PATCH a // different store by id), or // • the backend adds POST /admin/customers. // // The payload keeps the address fields. The documented body is // { firstname, lastname, phone, email }; the rest are sent best-effort and // ignored if unsupported. export const executeCreateCustomer = async (payload) => { const started = Date.now(); const call = { name: 'createTenantCustomer', target: 'POST /admin/tenantcustomers', stats: Object.keys(payload).join(', ') }; try { const res = await createTenantCustomer(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`; // doormileAxios rejects with the response BODY, not the axios error, so // `err.response` is undefined here — the status arrives as `err.httpStatus`. const status = err.httpStatus ?? err.response?.status; const serverMessage = err.message || err.error; let message; if (status === 405) { // 405 is "the route exists but not this method" — a different fact from // 404, and worth stating precisely so nobody re-tries the same call. message = 'POST /admin/tenantcustomers returned 405 — this endpoint does not accept a create. Nothing was saved.'; } else if (status === 404) { message = 'POST /admin/tenantcustomers returned 404 — that route is not on the server. Nothing was saved.'; } else if (status === 400 || status === 422) { message = `The server rejected the details${serverMessage ? ` — ${serverMessage}` : ''}. Nothing was saved.`; } else if (status) { message = `POST /admin/tenantcustomers 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, // The server's own reason, unformatted, for callers that word the // message themselves (the home page's plain-language toasts). serverMessage, message, sourceCalls: [ { ...call, duration, status: 'error', errorMessage: `${status || 'network'}${serverMessage ? ` · ${serverMessage}` : ''}` } ] }; } };