feat(quotes): admin decline-on-behalf with optional reason

Add a "Decline on behalf" action mirroring accept-on-behalf, for when a
customer says no by phone/email. Flips a draft/sent/expired quote to
declined, stamps declined_at, closes the public response window, and
invalidates outstanding accept/decline tokens so the emailed link can't
toggle it back. Optional free-text reason persisted to a new
quotes.decline_reason column (migration 115) and shown on the quote
detail page. Hard-delete intentionally not included.
This commit is contained in:
Luca
2026-06-02 13:47:31 +02:00
parent 621ce942b5
commit 8d14441df4
7 changed files with 166 additions and 1 deletions
@@ -0,0 +1,31 @@
/**
* Migration: admin decline-quote reason.
*
* Background: admins can now decline a quote on the customer's behalf
* ("customer told us by phone they're not going ahead") instead of
* waiting for the public response link. This stores an optional free-text
* reason alongside the existing `declined_at` timestamp so the quote
* detail page can show WHY it was declined.
*
* Nullable, no default — existing declined rows simply carry no reason,
* which is exactly how customer-side declines already look. No behaviour
* change on upgrade.
*
* Idempotent: guarded by hasColumn so a re-run is a no-op.
*/
exports.up = async function(knex) {
if (!(await knex.schema.hasTable('quotes'))) return;
if (await knex.schema.hasColumn('quotes', 'decline_reason')) return;
await knex.schema.alterTable('quotes', (table) => {
table.text('decline_reason');
});
};
exports.down = async function(knex) {
if (!(await knex.schema.hasTable('quotes'))) return;
if (!(await knex.schema.hasColumn('quotes', 'decline_reason'))) return;
await knex.schema.alterTable('quotes', (table) => {
table.dropColumn('decline_reason');
});
};