auto mail generation

This commit is contained in:
2026-09-29 16:48:10 +05:30
parent f1299fd053
commit e3fc144d05
12 changed files with 880 additions and 298 deletions

View File

@@ -163,7 +163,17 @@ interface RequestOptions {
signal?: AbortSignal;
}
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
/**
* One request, answered with the WHOLE envelope.
*
* `request` below is this plus "take the payload out", which is what nearly
* every caller wants. A handful need a field that sits BESIDE the payload —
* `invited` on onboarding is the one this was extracted for — and the only way
* to read one used to be `requestEnvelope`, which does none of the checking: no
* 401 handling, no `status: false`, no thrown `FiestaError`. So a caller that
* wanted one extra field had to give up all the error handling to get it.
*/
async function send<T>(path: string, options: RequestOptions = {}): Promise<FiestaEnvelope<T>> {
const { method = 'GET', params, body, signal } = options;
// There is exactly one path out of this function and it goes to `fetch`.
@@ -223,10 +233,19 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
);
}
// Most handlers put the payload in `details`, but a handful answer with
// `data` instead — `products/getallproducts` and `products/create` among the
// ones the console calls (`productController.go:400,206`). Reading only
// `details` handed those two callers `undefined` with no error anywhere.
return envelope;
}
/**
* The payload, unwrapped.
*
* Most handlers put it in `details`, but a handful answer with `data` instead —
* `products/getallproducts` and `products/create` among the ones the console
* calls (`productController.go:400,206`). Reading only `details` handed those
* two callers `undefined` with no error anywhere.
*/
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
const envelope = await send<T>(path, options);
return (envelope.details ?? envelope.data) as T;
}
@@ -288,6 +307,16 @@ export const api = {
post: <T>(path: string, body?: unknown, params?: Record<string, QueryValue>) =>
request<T>(path, { method: 'POST', body, params }),
/**
* A POST whose answer carries something beside the payload.
*
* Same checking as `post` — a failure still throws a `FiestaError` — so a
* caller reading one extra envelope field does not give up the error handling
* to get at it. `tenants/createtenantuser` needs `invited`.
*/
postEnvelope: <T>(path: string, body?: unknown, params?: Record<string, QueryValue>) =>
send<T>(path, { method: 'POST', body, params }),
put: <T>(path: string, body?: unknown, params?: Record<string, QueryValue>) =>
request<T>(path, { method: 'PUT', body, params }),

View File

@@ -37,6 +37,24 @@ export interface CreateStaffRequest {
status?: string;
}
/**
* What happened to somebody's first-password invitation.
*
* Reported beside the person rather than folded into success or failure: they are
* hired either way, and an unsent invitation is a task — resend, or correct the
* address — not a hire to retry.
*/
export interface InviteOutcome {
sent: boolean;
/** Why not — an unconfigured mail host, a rejecting relay. Absent when it sent. */
reason?: string;
}
export interface CreateStaffResult {
person: StaffInfo;
invite: InviteOutcome;
}
export interface UpdateStaffRequest {
userid: number;
firstname?: string;
@@ -85,7 +103,42 @@ export const staffApi = {
unassign: (body: { tenantid: number; userid: number }) =>
api.put<unknown>(`${WEB}/tenants/assignstaff`, { ...body, unassign: true }),
create: (body: CreateStaffRequest) => api.post<StaffInfo>(`${WEB}/users/create`, body),
/**
* Hires somebody, and emails them their way in.
*
* The account is created with NO password — nothing on this path sets one —
* and since the sign-in screen stopped offering to set a first password, the
* emailed link is the only way in. So the outcome of that email comes back
* beside the person, and the screen shows it: an invitation that did not send
* means somebody has been added to the directory who cannot sign in, and
* nothing else in the product would ever mention it.
*/
create: (body: CreateStaffRequest): Promise<CreateStaffResult> =>
api.postEnvelope<StaffInfo>(`${WEB}/users/create`, body).then((envelope) => ({
person: (envelope.details ?? {}) as StaffInfo,
invite: {
// Absent means a backend that predates the invitation — read as "not
// sent", so a half-deployed pair cannot claim an email that never left.
sent: envelope.invited === true,
...(envelope.invitereason ? { reason: envelope.invitereason } : {}),
},
})),
/**
* Sends somebody's first-password link again.
*
* By `userid`, not by tenant: the owner's invitation is reachable by tenantid
* because a business has one owner, but staff and branch logins are many, so
* an operator chasing one names the person.
*
* The backend refuses anybody who already has a password and says to send them
* to the sign-in page instead. That refusal is the point — an endpoint that
* re-issues a working password link for any account on request is a password
* reset, and nothing here verifies identity well enough to have one. It arrives
* as a `FiestaError` and is shown as written.
*/
resendInvite: (userid: number) =>
api.post<unknown>(`${WEB}/tenants/resendinvite`, { userid }),
/**
* Update a person.

View File

@@ -2,6 +2,7 @@
import { api, WEB } from './client';
import type { AppLocation } from './deliveries';
import type { InviteOutcome } from './people';
import type { TenantInfo, TenantLocation } from './types';
/** Everything the tenant-onboarding form collects. */
@@ -101,6 +102,20 @@ export interface CreateBranchRequest {
operatorid?: number;
}
/**
* A commissioned branch, and what happened to its operator's invitation.
*
* The branch exists either way. A login that was not emailed is a task for
* whoever commissioned it — resend, or fix the address — and not a branch to
* create again.
*/
export interface CreateBranchResult {
branch: TenantLocation;
invite: InviteOutcome;
/** The login the branch spawned, for a resend. 0 when a person was placed. */
operatorUserid: number;
}
export interface TenantListQuery {
pageno?: number;
pagesize?: number;
@@ -176,9 +191,24 @@ export const tenantsApi = {
* created row, and the new `locationid` is what a QR code and every
* follow-up write need. `createlocation` answers 201 with a message and no
* `details` at all.
*
* `invite` reports the spawned login's first-password email. It is `sent:
* false` with NO reason when an `operatorid` was named — nothing was created,
* so there was nothing to send, and that is not a failure to report.
*/
createBranch: (body: CreateBranchRequest) =>
api.post<TenantLocation>(`${WEB}/tenants/createtenantlocation`, body),
createBranch: (body: CreateBranchRequest): Promise<CreateBranchResult> =>
api
.postEnvelope<TenantLocation>(`${WEB}/tenants/createtenantlocation`, body)
.then((envelope) => ({
branch: (envelope.details ?? {}) as TenantLocation,
invite: {
sent: envelope.invited === true,
...(envelope.invitereason ? { reason: envelope.invitereason } : {}),
},
// The spawned login, so a failed invitation can be resent without
// hunting for the row by eye. 0 when an existing person was placed.
operatorUserid: envelope.inviteuserid ?? 0,
})),
updateBranch: (body: Partial<TenantLocation> & { locationid: number }) =>
api.put<TenantLocation>(`${WEB}/tenants/updatelocation`, body),

View File

@@ -54,6 +54,26 @@ export interface FiestaEnvelope<T> {
* a number Fiesta has already worked out.
*/
pricedetails?: { orderamount?: number; totaltaxamount?: number };
/**
* Whether a newly created account was emailed its first-password link.
* On the three endpoints that create one: `users/create`,
* `tenants/createstaff` and `tenants/createtenantlocation`.
*
* Beside `details` rather than inside it because it is not a fact about the
* person or the branch — those exist either way. It is what happened to a
* separate side effect, and the only one an operator can act on.
*/
invited?: boolean;
/** Why it did not send. Absent when it did. */
invitereason?: string;
/**
* Who to resend to, from `tenants/createtenantlocation` only.
*
* `details` there is the branch row, and the login the branch spawned lives in
* `app_users` — so this is the only way to name it. 0 when an existing person
* was placed and no account was created.
*/
inviteuserid?: number;
}
/* ────────────────────────────────────────────────────────────────────────────
@@ -947,6 +967,19 @@ export interface StaffInfo {
locationid?: number;
locationname?: string;
status?: string;
/**
* Whether they have ever chosen a password.
*
* Every person here is created with an empty one and emailed a link to set it.
* Until that link is used they are in this list, in every branch picker, and
* cannot sign in — and `status` does not say so, because an Active account with
* no password is refused at the login screen like any other.
*
* So `false` is the row that needs an action: a lost invitation, waiting to be
* sent again. Optional because a backend that predates the column sends
* nothing, and the directory then simply does not make the claim.
*/
issetup?: boolean;
}
/**