fix(workflows): defer quote.accepted/declined emit until the 15-min response window locks
The customer's accept/decline can be toggled for crm_quotes_accept_window_minutes
(default 15) before it locks, and the public page promises exactly that. But the
booking workflow fired on the FIRST accept click and immediately converted the
quote (status -> 'converted'), so a decline within the window was rejected
('Quote cannot be responded to in status converted') — the grace period was dead
on arrival.
recordResponse / adminAcceptQuote now DEFER the workflow emit while the toggle
window is open; the new scheduler sweep finalizeQuoteResponses fires the FINAL
status once response_locked_at passes (idempotent via the new
quotes.workflow_response_emitted_at column, migration 149). A response recorded
with the window already closed (0-min window, or admin decline which locks
immediately) still emits inline. So toggling accept->decline->accept inside the
window converts at most once, for the final state, after the customer's grace
period — and a plain decline never converts.
Trade-off: with the hourly CRM scheduler, the booking flow now starts up to ~1h
after the window locks instead of instantly. Acceptable — the flow gates on admin
review anyway, and the alternative (graph-level wait) wouldn't reach already-
enabled built-ins (admin_toggled_at blocks re-seed).
Adds a finalize sweep test (deferred while open, fires + stamps once locked,
idempotent).
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Migration 149: defer the quote.accepted/declined workflow emit past the
|
||||
* 15-min response window.
|
||||
*
|
||||
* A customer's accept/decline can be toggled for crm_quotes_accept_window_minutes
|
||||
* (default 15) before it locks. The booking workflow used to fire on the FIRST
|
||||
* click and immediately convert the quote (status -> 'converted'), which made the
|
||||
* quote un-declinable inside that window — defeating the grace period the public
|
||||
* page promises ("you can change your answer within 15 minutes").
|
||||
*
|
||||
* The fix moves the response emit to AFTER the window locks: the scheduler sweeps
|
||||
* locked-but-not-yet-emitted responses and fires quote.<final status> once. This
|
||||
* column is the idempotency marker so each response is emitted exactly once,
|
||||
* regardless of how many times the customer toggled inside the window.
|
||||
*/
|
||||
exports.up = async function (knex) {
|
||||
if (!(await knex.schema.hasTable('quotes'))) return;
|
||||
if (!(await knex.schema.hasColumn('quotes', 'workflow_response_emitted_at'))) {
|
||||
await knex.schema.alterTable('quotes', (t) => {
|
||||
t.timestamp('workflow_response_emitted_at');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function (knex) {
|
||||
if (!(await knex.schema.hasTable('quotes'))) return;
|
||||
if (await knex.schema.hasColumn('quotes', 'workflow_response_emitted_at')) {
|
||||
await knex.schema.alterTable('quotes', (t) => t.dropColumn('workflow_response_emitted_at'));
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user