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:
@@ -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');
|
||||
});
|
||||
};
|
||||
@@ -105,6 +105,7 @@ function transformQuote(q) {
|
||||
responseLockedAt: q.response_locked_at,
|
||||
acceptedAt: q.accepted_at,
|
||||
declinedAt: q.declined_at,
|
||||
declineReason: q.decline_reason ?? null,
|
||||
convertedEventId: q.converted_event_id,
|
||||
// Migration 130 lineage. Null until quoteService.createFromQuote
|
||||
// sets it. Surfaced so QuoteDetailPage can render a "Linked
|
||||
@@ -450,6 +451,24 @@ router.post(
|
||||
})
|
||||
);
|
||||
|
||||
// Admin "decline on behalf" — flips a draft/sent/expired quote to
|
||||
// `declined` without the customer's public link. For "they said no by
|
||||
// phone" workflows. Optional free-text reason persisted on the row.
|
||||
router.post(
|
||||
'/:id/decline',
|
||||
requirePermission('quotes.manage'),
|
||||
[
|
||||
param('id').isInt({ min: 1 }),
|
||||
body('reason').optional({ values: 'falsy' }).isString().isLength({ max: 5000 }),
|
||||
],
|
||||
handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
const id = parseInt(req.params.id, 10);
|
||||
const result = await quoteService.adminDeclineQuote(id, req.admin.id, req.body.reason);
|
||||
return successResponse(res, result, 200, 'Quote declined');
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:id/convert',
|
||||
requirePermission('quotes.manage'),
|
||||
|
||||
@@ -39,6 +39,7 @@ const { buildIssuerBlock, buildRecipientBlock } = require('./_renderContext');
|
||||
const pdfService = require('./pdfService');
|
||||
const emailProcessor = require('./emailProcessor');
|
||||
const { getFrontendBaseUrl } = require('../utils/frontendUrl');
|
||||
const { hasColumnCached } = require('../utils/schemaCache');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
@@ -1173,6 +1174,69 @@ async function adminAcceptQuote(id, adminId) {
|
||||
return { status: 'accepted', lockedAt: responseLockedAt };
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin "decline on behalf of customer" — records the quote as
|
||||
* `declined` directly, bypassing the public token + response window.
|
||||
* Used when the customer says no by phone/email and the admin wants the
|
||||
* pipeline reflected without asking them to click the decline link.
|
||||
*
|
||||
* Mirrors adminAcceptQuote's guards: refuses quotes that are already
|
||||
* terminal (`accepted`, `declined`, `converted`) — those would overwrite
|
||||
* history. Allowed from `draft` / `sent` / `expired`.
|
||||
*
|
||||
* `reason` is optional free text persisted to `quotes.decline_reason`
|
||||
* (migration 115) and surfaced on the quote detail page.
|
||||
*
|
||||
* Any outstanding accept/decline tokens are invalidated so the customer
|
||||
* can't flip the quote back to accepted via a still-live emailed link.
|
||||
*/
|
||||
async function adminDeclineQuote(id, adminId, reason = null) {
|
||||
const quote = await db('quotes').where({ id }).first();
|
||||
if (!quote) throw new AppError('Quote not found', 404);
|
||||
if (quote.status === 'declined') {
|
||||
throw new AppError('Quote already declined', 409, 'QUOTE_ALREADY_DECLINED');
|
||||
}
|
||||
if (quote.status === 'accepted') {
|
||||
throw new AppError('Quote already accepted; duplicate it to start a fresh round.', 409, 'QUOTE_ALREADY_ACCEPTED');
|
||||
}
|
||||
if (quote.status === 'converted') {
|
||||
throw new AppError('Quote already converted to an event/invoice', 409, 'QUOTE_CONVERTED');
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const cleanReason = typeof reason === 'string' && reason.trim() ? reason.trim().slice(0, 5000) : null;
|
||||
const hasReasonColumn = await hasColumnCached('quotes', 'decline_reason');
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
const updates = {
|
||||
status: 'declined',
|
||||
responded_at: quote.responded_at || now,
|
||||
// Close the public response window immediately so a customer link
|
||||
// can't toggle the quote afterwards (recordResponse rejects once
|
||||
// now > response_locked_at).
|
||||
response_locked_at: now,
|
||||
declined_at: now,
|
||||
accepted_at: null,
|
||||
updated_at: now,
|
||||
};
|
||||
if (hasReasonColumn) updates.decline_reason = cleanReason;
|
||||
await trx('quotes').where({ id }).update(updates);
|
||||
|
||||
// Burn any unused tokens for this quote — defense in depth alongside
|
||||
// the closed response window above.
|
||||
await trx('quote_action_tokens')
|
||||
.where({ quote_id: id })
|
||||
.whereNull('used_at')
|
||||
.update({ used_at: now, used_action: 'declined' });
|
||||
});
|
||||
|
||||
try {
|
||||
await logActivity('quote_declined_by_admin', { quoteId: id, reason: cleanReason }, null, `admin:${adminId}`);
|
||||
} catch (_) {}
|
||||
|
||||
return { status: 'declined', declinedAt: now };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an accepted quote to an event + scheduled invoices.
|
||||
* Wraps everything in a transaction so a half-finished conversion
|
||||
@@ -1786,6 +1850,7 @@ module.exports = {
|
||||
duplicateQuote,
|
||||
recordResponse,
|
||||
adminAcceptQuote,
|
||||
adminDeclineQuote,
|
||||
convertToEvent,
|
||||
convertToInvoiceOnly,
|
||||
|
||||
|
||||
Reference in New Issue
Block a user