Events visual changes and payload mapping correction

This commit is contained in:
Ritul Jadhav 2026-03-30 17:30:12 +05:30
parent ff864a965d
commit 926c332c7e
3 changed files with 547 additions and 66 deletions

View File

@ -13,6 +13,120 @@ const DLT_VARIABLE_OPTIONS = [
const DLT_TOKEN_SET = new Set(DLT_VARIABLE_OPTIONS.map((option) => option.token)); const DLT_TOKEN_SET = new Set(DLT_VARIABLE_OPTIONS.map((option) => option.token));
const DLT_TOKEN_REGEX = /\{#(?:var|numeric|url|cbn)#\}/g; const DLT_TOKEN_REGEX = /\{#(?:var|numeric|url|cbn)#\}/g;
const DLT_TOKEN_LIKE_REGEX = /\{#[^{}]*#\}/g; const DLT_TOKEN_LIKE_REGEX = /\{#[^{}]*#\}/g;
const DELIVERY_EVENT_SLUGS = new Set([
'out_for_pickup',
'bag_picked',
'bag_reached_drop_point',
'in_transit',
'out_for_delivery',
'delivery_attempt_failed',
'delivery_done',
'handed_over_to_customer',
'bag_lost',
]);
const CANCELLATION_EVENT_SLUGS = new Set([
'bag_not_confirmed',
'cancelled_at_dp',
'cancelled_customer',
'cancelled_failed_at_dp',
'cancelled_fynd',
'rejected_by_customer',
]);
const REFUND_EVENT_SLUGS = new Set([
'credit_note_generated',
'partial_refund_completed',
'refund_acknowledged',
'refund_approved',
'refund_completed',
'refund_failed',
'refund_initiated',
'refund_on_hold',
'refund_pending',
'refund_pending_for_approval',
'refund_retry',
]);
const RETURN_EVENT_SLUGS = new Set([
'assigning_return_dp',
'internal_return_dp_reassign',
'deadstock_defective',
'deadstock_defective_lost',
]);
const EVENT_GROUPS = [
{
id: 'fulfillment',
label: 'Order & Fulfillment',
description: 'Core order confirmation, allocation, packing, and dispatch readiness stages.',
defaultExpanded: true,
},
{
id: 'delivery',
label: 'Delivery Journey',
description: 'Courier pickup, in-transit updates, and final handover milestones.',
defaultExpanded: true,
},
{
id: 'cancellations',
label: 'Cancellations & Rejections',
description: 'Customer, merchant, and delivery-partner driven cancellations and rejections.',
defaultExpanded: false,
},
{
id: 'returns',
label: 'Returns',
description: 'Return initiation, pickup, transit, and merchant-side return handling.',
defaultExpanded: false,
},
{
id: 'refunds',
label: 'Refunds',
description: 'Refund processing and credit-note states across payment flows.',
defaultExpanded: false,
},
{
id: 'rto',
label: 'RTO',
description: 'Return-to-origin movement and completion states after failed delivery.',
defaultExpanded: false,
},
{
id: 'custom',
label: 'Custom Events',
description: 'Business-specific events you added manually for your own messaging flows.',
defaultExpanded: false,
},
];
const DEFAULT_EXPANDED_GROUPS = EVENT_GROUPS.reduce((acc, group) => {
acc[group.id] = group.defaultExpanded;
return acc;
}, {});
function getEventGroupId(event) {
const slug = String(event?.slug || '');
if (!event?.isDefault) return 'custom';
if (slug.startsWith('rto_') || slug === 'return_to_origin') return 'rto';
if (slug.startsWith('return_') || RETURN_EVENT_SLUGS.has(slug)) return 'returns';
if (slug.startsWith('refund_') || REFUND_EVENT_SLUGS.has(slug)) return 'refunds';
if (CANCELLATION_EVENT_SLUGS.has(slug)) return 'cancellations';
if (DELIVERY_EVENT_SLUGS.has(slug)) return 'delivery';
return 'fulfillment';
}
function matchesEventSearch(event, searchTerm) {
const query = String(searchTerm || '').trim().toLowerCase();
if (!query) return true;
return [event?.label, event?.slug]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(query));
}
function buildGroupedEvents(events, searchTerm) {
return EVENT_GROUPS.map((group) => ({
...group,
events: events.filter((event) => getEventGroupId(event) === group.id && matchesEventSearch(event, searchTerm)),
})).filter((group) => group.events.length > 0);
}
function getVariantKey(slug, index) { function getVariantKey(slug, index) {
return `${slug}:${index}`; return `${slug}:${index}`;
@ -105,8 +219,10 @@ export default function Events() {
const [events, setEvents] = useState([]); const [events, setEvents] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [newLabel, setNewLabel] = useState(''); const [newLabel, setNewLabel] = useState('');
const [searchTerm, setSearchTerm] = useState('');
const [addingEvent, setAddingEvent] = useState(false); const [addingEvent, setAddingEvent] = useState(false);
const [showAddForm, setShowAddForm] = useState(false); const [showAddForm, setShowAddForm] = useState(false);
const [expandedGroups, setExpandedGroups] = useState(DEFAULT_EXPANDED_GROUPS);
const [genState, setGenState] = useState({}); const [genState, setGenState] = useState({});
const [variants, setVariants] = useState({}); const [variants, setVariants] = useState({});
const [variantDrafts, setVariantDrafts] = useState({}); const [variantDrafts, setVariantDrafts] = useState({});
@ -350,6 +466,13 @@ export default function Events() {
}); });
} }
function toggleGroup(groupId) {
setExpandedGroups((current) => ({
...current,
[groupId]: !current[groupId],
}));
}
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center h-64"> <div className="flex items-center justify-center h-64">
@ -358,13 +481,47 @@ export default function Events() {
); );
} }
const groupedEvents = buildGroupedEvents(events, searchTerm);
const totalVisibleEvents = groupedEvents.reduce((count, group) => count + group.events.length, 0);
return ( return (
<div className="max-w-4xl mx-auto"> <div className="max-w-4xl mx-auto">
<div className="flex items-center justify-between pb-5 mb-6 border-b border-gray-200"> <div className="flex flex-col gap-4 pb-5 mb-6 border-b border-gray-200">
<div> <div>
<h1 className="text-2xl font-bold text-gray-900 tracking-tight">Events</h1> <h1 className="text-2xl font-bold text-gray-900 tracking-tight">Events</h1>
<p className="text-sm text-gray-500 mt-1 font-medium">Generate SMS templates for each order event.</p> <p className="text-sm text-gray-500 mt-1 font-medium">Generate SMS templates for each order event.</p>
</div> </div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="relative flex-1 sm:max-w-md">
<span className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-4 text-gray-400">
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="m21 21-4.35-4.35m1.85-5.15a7 7 0 1 1-14 0 7 7 0 0 1 14 0Z" />
</svg>
</span>
<input
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search events"
className="w-full rounded-xl border border-gray-300 bg-white py-3 pl-11 pr-10 text-sm font-medium text-gray-900 placeholder-gray-400 shadow-sm transition focus:border-indigo-300 focus:outline-none focus:ring-2 focus:ring-indigo-100"
/>
{searchTerm && (
<button
type="button"
onClick={() => setSearchTerm('')}
className="absolute inset-y-0 right-0 flex items-center pr-4 text-gray-400 hover:text-gray-600"
aria-label="Clear search"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
<div className="flex items-center gap-3">
<span className="inline-flex items-center rounded-full border border-indigo-100 bg-indigo-50 px-3 py-2 text-xs font-semibold text-indigo-700">
{totalVisibleEvents} visible
</span>
<button <button
onClick={() => setShowAddForm((visible) => !visible)} onClick={() => setShowAddForm((visible) => !visible)}
className="px-4 py-2 rounded-lg bg-white border border-gray-300 shadow-sm text-sm text-gray-700 font-semibold hover:bg-gray-50 transition" className="px-4 py-2 rounded-lg bg-white border border-gray-300 shadow-sm text-sm text-gray-700 font-semibold hover:bg-gray-50 transition"
@ -372,6 +529,8 @@ export default function Events() {
{showAddForm ? 'Cancel' : '+ Add Event'} {showAddForm ? 'Cancel' : '+ Add Event'}
</button> </button>
</div> </div>
</div>
</div>
{!readyToGenerate && ( {!readyToGenerate && (
<div className="mb-6 px-4 py-3 rounded-lg bg-amber-50 border border-amber-200 text-amber-800 text-sm font-medium flex items-center gap-2"> <div className="mb-6 px-4 py-3 rounded-lg bg-amber-50 border border-amber-200 text-amber-800 text-sm font-medium flex items-center gap-2">
@ -406,8 +565,56 @@ export default function Events() {
</form> </form>
)} )}
{groupedEvents.length === 0 ? (
<div className="rounded-2xl border border-dashed border-gray-300 bg-white px-6 py-12 text-center shadow-sm">
<p className="text-base font-semibold text-gray-900">No events match your search.</p>
<p className="mt-2 text-sm text-gray-500">Try a different keyword or clear the search to see the full lifecycle list.</p>
</div>
) : (
<div className="space-y-4"> <div className="space-y-4">
{events.map((event) => { {groupedEvents.map((group) => {
const isExpanded = searchTerm.trim() ? true : !!expandedGroups[group.id];
return (
<section key={group.id} className="overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm">
<button
type="button"
onClick={() => toggleGroup(group.id)}
className="flex w-full items-start justify-between gap-4 px-6 py-5 text-left transition hover:bg-gray-50"
>
<div className="flex min-w-0 items-start gap-4">
<div className={`mt-1 h-3 w-3 rounded-full shadow-sm ${
group.id === 'fulfillment' ? 'bg-indigo-500' :
group.id === 'delivery' ? 'bg-sky-500' :
group.id === 'cancellations' ? 'bg-rose-500' :
group.id === 'returns' ? 'bg-amber-500' :
group.id === 'refunds' ? 'bg-emerald-500' :
group.id === 'rto' ? 'bg-fuchsia-500' :
'bg-gray-500'
}`} />
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-lg font-bold tracking-tight text-gray-900">{group.label}</h2>
<span className="rounded-full border border-gray-200 bg-gray-50 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-wider text-gray-500">
{group.events.length} events
</span>
</div>
<p className="mt-1 text-sm font-medium text-gray-500">{group.description}</p>
</div>
</div>
<span className={`mt-1 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-gray-200 bg-white text-gray-500 transition ${
isExpanded ? 'rotate-180' : ''
}`}>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="m19 9-7 7-7-7" />
</svg>
</span>
</button>
{isExpanded && (
<div className="border-t border-gray-100 bg-gray-50/50 px-4 py-4 sm:px-6">
<div className="space-y-4">
{group.events.map((event) => {
const state = genState[event.slug] || 'idle'; const state = genState[event.slug] || 'idle';
const eventVariants = variants[event.slug] || []; const eventVariants = variants[event.slug] || [];
@ -430,7 +637,6 @@ export default function Events() {
)} )}
<div> <div>
<h3 className="text-base font-bold text-gray-900 tracking-tight">{event.label}</h3> <h3 className="text-base font-bold text-gray-900 tracking-tight">{event.label}</h3>
<p className="text-xs text-gray-500 font-mono mt-0.5">{event.slug}</p>
</div> </div>
</div> </div>
@ -697,5 +903,12 @@ export default function Events() {
})} })}
</div> </div>
</div> </div>
)}
</section>
);
})}
</div>
)}
</div>
); );
} }

View File

@ -1,10 +1,102 @@
const DEFAULT_EVENTS = [ const OMS_EVENT_SLUGS = [
{ slug: 'placed', label: 'Placed', isDefault: true }, 'assigning_dp',
{ slug: 'confirmed', label: 'Confirmed', isDefault: true }, 'assigning_return_dp',
{ slug: 'dp_assigned', label: 'DP Assigned', isDefault: true }, 'bag_confirmed',
{ slug: 'pack', label: 'Pack', isDefault: true }, 'bag_invoiced',
{ slug: 'cancelled', label: 'Cancelled', isDefault: true }, 'bag_lost',
{ slug: 'delivery_done', label: 'Delivery Done', isDefault: true }, 'bag_not_confirmed',
'bag_not_handed_over_to_dg',
'bag_not_picked',
'bag_packed',
'bag_picked',
'bag_reached_drop_point',
'cancelled_at_dp',
'cancelled_customer',
'cancelled_failed_at_dp',
'cancelled_fynd',
'credit_note_generated',
'deadstock_defective',
'deadstock_defective_lost',
'delivery_attempt_failed',
'delivery_done',
'dp_assigned',
'dp_not_assigned',
'handed_over_to_customer',
'handed_over_to_dg',
'internal_dp_reassign',
'internal_return_dp_reassign',
'in_transit',
'out_for_delivery',
'out_for_pickup',
'partial_refund_completed',
'payment_failed',
'payment_initiated',
'pending',
'placed',
'ready_for_dp_assignment',
'refund_acknowledged',
'refund_approved',
'refund_completed',
'refund_failed',
'refund_initiated',
'refund_on_hold',
'refund_pending',
'refund_pending_for_approval',
'refund_retry',
'rejected_by_customer',
'return_accepted',
'return_assigning_dp',
'return_bag_delivered',
'return_bag_in_transit',
'return_bag_lost',
'return_bag_not_delivered',
'return_bag_not_picked',
'return_bag_out_for_delivery',
'return_bag_picked',
'return_cancelled_at_dp',
'return_cancelled_failed_at_dp',
'return_dp_assigned',
'return_dp_assignment_requested',
'return_dp_cancelled',
'return_dp_not_assigned',
'return_dp_out_for_pickup',
'return_dp_qc_failed',
'return_dp_qc_passed',
'return_initiated',
'return_not_accepted',
'return_pre_qc',
'return_rejected_by_dp',
'return_request_cancelled',
'return_request_rejected_by_operation',
'return_to_origin',
'rto_bag_accepted',
'rto_bag_delivered',
'rto_bag_out_for_delivery',
'rto_initiated',
'rto_in_transit',
'store_reassigned',
'upcoming',
]; ];
const WORD_OVERRIDES = {
dp: 'DP',
dg: 'DG',
qc: 'QC',
rto: 'RTO',
};
function humanizeEventSlug(slug) {
return String(slug || '')
.split('_')
.filter(Boolean)
.map((part) => WORD_OVERRIDES[part] || part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
}
const DEFAULT_EVENTS = OMS_EVENT_SLUGS.map((slug) => ({
slug,
label: humanizeEventSlug(slug),
isDefault: true,
}));
module.exports = DEFAULT_EVENTS; module.exports = DEFAULT_EVENTS;

View File

@ -155,6 +155,21 @@ function normalizeText(value) {
return typeof value === 'string' ? value.trim() : ''; return typeof value === 'string' ? value.trim() : '';
} }
function normalizeScalarText(value) {
if (typeof value === 'string') return value.trim();
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
if (typeof value === 'boolean') return value ? 'true' : 'false';
return '';
}
function firstNonEmptyText(...values) {
for (const value of values) {
const normalized = normalizeScalarText(value);
if (normalized) return normalized;
}
return '';
}
function normalizeSenderId(value) { function normalizeSenderId(value) {
return normalizeText(value).toUpperCase(); return normalizeText(value).toUpperCase();
} }
@ -182,6 +197,42 @@ function normalizeProvider(provider = {}, fallbackUpdatedAt = null) {
}; };
} }
const LEGACY_DEFAULT_EVENT_SLUGS = new Set(['confirmed', 'pack', 'cancelled']);
const EVENT_TEMPLATE_FALLBACKS = {
bag_confirmed: ['confirmed'],
bag_packed: ['pack'],
bag_not_confirmed: ['cancelled'],
cancelled_customer: ['cancelled'],
cancelled_fynd: ['cancelled'],
cancelled_at_dp: ['cancelled'],
cancelled_failed_at_dp: ['cancelled'],
};
function mergeDefaultEvents(data = {}) {
const existingEvents = Array.isArray(data?.events) ? data.events : [];
const defaultEventBySlug = new Map(DEFAULT_EVENTS.map((event) => [event.slug, event]));
const existingEventBySlug = new Map(
existingEvents
.map((event) => ({ ...event, slug: normalizeText(event?.slug) }))
.filter((event) => event.slug)
.map((event) => [event.slug, event])
);
const mergedDefaults = DEFAULT_EVENTS.map((event) => {
const existing = existingEventBySlug.get(event.slug);
return existing
? { ...event, ...existing, slug: event.slug, label: existing.label || event.label, isDefault: true }
: { ...event };
});
const customEvents = existingEvents
.map((event) => ({ ...event, slug: normalizeText(event?.slug), label: normalizeText(event?.label) }))
.filter((event) => event.slug && !defaultEventBySlug.has(event.slug) && !LEGACY_DEFAULT_EVENT_SLUGS.has(event.slug))
.map((event) => ({ ...event, isDefault: false }));
return { events: [...mergedDefaults, ...customEvents] };
}
function getShipmentPayload(body) { function getShipmentPayload(body) {
return body?.payload?.shipment && typeof body.payload.shipment === 'object' return body?.payload?.shipment && typeof body.payload.shipment === 'object'
? body.payload.shipment ? body.payload.shipment
@ -189,24 +240,69 @@ function getShipmentPayload(body) {
} }
function getShipmentBrandName(body) { function getShipmentBrandName(body) {
return normalizeText(body?.payload?.shipment?.bags?.[0]?.brand?.brand_name); const shipment = getShipmentPayload(body);
return firstNonEmptyText(
shipment?.bags?.[0]?.brand?.brand_name,
shipment?.bags?.[0]?.item?.attributes?.brand_name,
shipment?.affiliate_details?.company_affiliate_tag
);
} }
function getShipmentEventKey(body) { function getShipmentEventKey(body) {
return normalizeText(body?.payload?.shipment?.status); const shipment = getShipmentPayload(body);
return firstNonEmptyText(
shipment?.status,
shipment?.shipment_status?.status,
shipment?.shipment_status?.current_shipment_status
);
} }
function getShipmentToNumber(body) { function getShipmentToNumber(body) {
return normalizeText(body?.payload?.shipment?.user?.mobile); const shipment = getShipmentPayload(body);
return firstNonEmptyText(
shipment?.user?.mobile,
shipment?.delivery_address?.phone,
shipment?.billing_address?.phone
);
} }
const DLT_PLACEHOLDER_REGEX = /\{#(?:var|numeric|url|cbn)#\}/g; const DLT_PLACEHOLDER_REGEX = /\{#(?:var|numeric|url|cbn)#\}/g;
function normalizeRenderableValue(value) { function normalizeRenderableValue(value) {
if (typeof value === 'string') return value.replace(/\s+/g, ' ').trim(); return normalizeScalarText(value).replace(/\s+/g, ' ').trim();
if (typeof value === 'number' && Number.isFinite(value)) return String(value); }
if (typeof value === 'boolean') return value ? 'true' : 'false';
return ''; function splitFullName(value) {
const fullName = normalizeRenderableValue(value);
if (!fullName) return { firstName: '', lastName: '', fullName: '' };
const parts = fullName.split(/\s+/).filter(Boolean);
return {
firstName: parts[0] || '',
lastName: parts.length > 1 ? parts.slice(1).join(' ') : '',
fullName,
};
}
function getEventLookupCandidates(eventSlug) {
const normalizedEventSlug = slugify(eventSlug || '');
const candidates = [
normalizedEventSlug,
...(EVENT_TEMPLATE_FALLBACKS[normalizedEventSlug] || []),
];
return [...new Set(candidates.filter(Boolean))];
}
async function resolveWhitelistedTemplate(folder, eventSlug) {
for (const candidate of getEventLookupCandidates(eventSlug)) {
const template = await fetchJSON(folder, candidate);
if (template && template.status === 'whitelisted' && normalizeText(template.selectedTemplate)) {
return { template, matchedSlug: candidate };
}
}
return { template: null, matchedSlug: '' };
} }
function toCamelCase(text) { function toCamelCase(text) {
@ -255,51 +351,97 @@ function indexShipmentValues(value, pathParts = [], valueIndex = new Map()) {
function buildShipmentValueIndex(shipment) { function buildShipmentValueIndex(shipment) {
const valueIndex = indexShipmentValues(shipment); const valueIndex = indexShipmentValues(shipment);
const firstBag = shipment?.bags?.[0] || {}; const firstBag = shipment?.bags?.[0] || {};
const primaryTrackingUrl = normalizeRenderableValue( const customerName = splitFullName(
shipment?.delivery_partner_details?.track_url firstNonEmptyText(
|| shipment?.meta?.tracking_url shipment?.user?.first_name && shipment?.user?.last_name
|| firstBag?.meta?.tracking_url ? `${shipment.user.first_name} ${shipment.user.last_name}`
|| shipment?.affiliate_details?.shipment_meta?.tracking_url : '',
|| shipment?.article_details?.dp_details?.track_url shipment?.delivery_address?.name,
shipment?.delivery_address?.contact_person,
shipment?.billing_address?.name,
shipment?.billing_address?.contact_person
)
); );
const primaryAwbNumber = normalizeRenderableValue( const primaryTrackingUrl = firstNonEmptyText(
shipment?.delivery_partner_details?.awb_no shipment?.delivery_partner_details?.track_url,
|| shipment?.meta?.awb_number shipment?.meta?.tracking_url,
|| shipment?.article_details?.dp_details?.awb_no firstBag?.meta?.tracking_url,
shipment?.affiliate_details?.shipment_meta?.tracking_url,
shipment?.article_details?.dp_details?.track_url
); );
const primaryCourierName = normalizeRenderableValue( const primaryAwbNumber = firstNonEmptyText(
shipment?.delivery_partner_details?.display_name shipment?.delivery_partner_details?.awb_no,
|| shipment?.delivery_partner_details?.name shipment?.meta?.awb_number,
|| shipment?.meta?.courier_partner_name shipment?.article_details?.dp_details?.awb_no,
|| shipment?.meta?.dp_name firstBag?.meta?.awb_number,
|| firstBag?.meta?.dp_name firstBag?.current_operational_status?.delivery_awb_number
); );
const brandName = normalizeRenderableValue( const primaryCourierName = firstNonEmptyText(
shipment?.bags?.[0]?.brand?.brand_name shipment?.delivery_partner_details?.display_name,
|| shipment?.bags?.[0]?.item?.attributes?.brand_name shipment?.delivery_partner_details?.name,
|| shipment?.affiliate_details?.company_affiliate_tag shipment?.meta?.courier_partner_name,
shipment?.meta?.dp_name,
firstBag?.meta?.dp_name,
shipment?.affiliate_details?.shipment_meta?.courier_partner_name
); );
const brandName = firstNonEmptyText(
shipment?.bags?.[0]?.brand?.brand_name,
shipment?.bags?.[0]?.item?.attributes?.brand_name,
shipment?.affiliate_details?.company_affiliate_tag
);
const toNumber = firstNonEmptyText(
shipment?.user?.mobile,
shipment?.delivery_address?.phone,
shipment?.billing_address?.phone
);
const eventKey = firstNonEmptyText(
shipment?.status,
shipment?.shipment_status?.status,
shipment?.shipment_status?.current_shipment_status
);
const eventDisplayName = firstNonEmptyText(
shipment?.shipment_status?.display_name,
shipment?.shipment_status?.current_shipment_status
);
const shipmentId = firstNonEmptyText(
shipment?.shipment_id,
shipment?.shipment_status?.shipment_id
);
const resolvedFullName = firstNonEmptyText(
shipment?.user?.first_name || shipment?.user?.last_name
? `${normalizeRenderableValue(shipment?.user?.first_name)} ${normalizeRenderableValue(shipment?.user?.last_name)}`.trim()
: '',
customerName.fullName
);
const resolvedFirstName = firstNonEmptyText(shipment?.user?.first_name, customerName.firstName);
const resolvedLastName = firstNonEmptyText(shipment?.user?.last_name, customerName.lastName);
setValueIndexEntry(valueIndex, 'firstName', normalizeRenderableValue(shipment?.user?.first_name)); setValueIndexEntry(valueIndex, 'firstName', resolvedFirstName);
setValueIndexEntry(valueIndex, 'lastName', normalizeRenderableValue(shipment?.user?.last_name)); setValueIndexEntry(valueIndex, 'lastName', resolvedLastName);
setValueIndexEntry(valueIndex, 'fullName', normalizeRenderableValue( setValueIndexEntry(valueIndex, 'fullName', resolvedFullName);
`${normalizeRenderableValue(shipment?.user?.first_name)} ${normalizeRenderableValue(shipment?.user?.last_name)}`.trim() setValueIndexEntry(valueIndex, 'customerFirstName', resolvedFirstName);
)); setValueIndexEntry(valueIndex, 'customerLastName', resolvedLastName);
setValueIndexEntry(valueIndex, 'phone', normalizeRenderableValue(shipment?.user?.mobile || shipment?.delivery_address?.phone)); setValueIndexEntry(valueIndex, 'customerName', resolvedFullName);
setValueIndexEntry(valueIndex, 'mobile', normalizeRenderableValue(shipment?.user?.mobile || shipment?.delivery_address?.phone)); setValueIndexEntry(valueIndex, 'phone', toNumber);
setValueIndexEntry(valueIndex, 'toNumber', normalizeRenderableValue(shipment?.user?.mobile || shipment?.delivery_address?.phone)); setValueIndexEntry(valueIndex, 'mobile', toNumber);
setValueIndexEntry(valueIndex, 'toNumber', toNumber);
setValueIndexEntry(valueIndex, 'customerPhone', toNumber);
setValueIndexEntry(valueIndex, 'customerMobile', toNumber);
setValueIndexEntry(valueIndex, 'orderId', normalizeRenderableValue(shipment?.order_id)); setValueIndexEntry(valueIndex, 'orderId', normalizeRenderableValue(shipment?.order_id));
setValueIndexEntry(valueIndex, 'shipmentId', normalizeRenderableValue(shipment?.shipment_id)); setValueIndexEntry(valueIndex, 'orderNumber', normalizeRenderableValue(shipment?.order_id));
setValueIndexEntry(valueIndex, 'event', normalizeRenderableValue(shipment?.status)); setValueIndexEntry(valueIndex, 'shipmentId', shipmentId);
setValueIndexEntry(valueIndex, 'status', normalizeRenderableValue(shipment?.status)); setValueIndexEntry(valueIndex, 'event', eventKey);
setValueIndexEntry(valueIndex, 'eventDisplayName', normalizeRenderableValue(shipment?.shipment_status?.display_name)); setValueIndexEntry(valueIndex, 'status', eventKey);
setValueIndexEntry(valueIndex, 'displayName', normalizeRenderableValue(shipment?.shipment_status?.display_name)); setValueIndexEntry(valueIndex, 'eventDisplayName', eventDisplayName);
setValueIndexEntry(valueIndex, 'displayName', eventDisplayName);
setValueIndexEntry(valueIndex, 'brandName', brandName); setValueIndexEntry(valueIndex, 'brandName', brandName);
setValueIndexEntry(valueIndex, 'trackingUrl', primaryTrackingUrl); setValueIndexEntry(valueIndex, 'trackingUrl', primaryTrackingUrl);
setValueIndexEntry(valueIndex, 'trackUrl', primaryTrackingUrl); setValueIndexEntry(valueIndex, 'trackUrl', primaryTrackingUrl);
setValueIndexEntry(valueIndex, 'trackingLink', primaryTrackingUrl);
setValueIndexEntry(valueIndex, 'url', primaryTrackingUrl); setValueIndexEntry(valueIndex, 'url', primaryTrackingUrl);
setValueIndexEntry(valueIndex, 'awbNo', primaryAwbNumber); setValueIndexEntry(valueIndex, 'awbNo', primaryAwbNumber);
setValueIndexEntry(valueIndex, 'awbNumber', primaryAwbNumber); setValueIndexEntry(valueIndex, 'awbNumber', primaryAwbNumber);
setValueIndexEntry(valueIndex, 'awb', primaryAwbNumber);
setValueIndexEntry(valueIndex, 'dpName', primaryCourierName); setValueIndexEntry(valueIndex, 'dpName', primaryCourierName);
setValueIndexEntry(valueIndex, 'courierName', primaryCourierName); setValueIndexEntry(valueIndex, 'courierName', primaryCourierName);
setValueIndexEntry(valueIndex, 'deliveryPartnerName', primaryCourierName); setValueIndexEntry(valueIndex, 'deliveryPartnerName', primaryCourierName);
@ -729,9 +871,42 @@ router.post('/resolve-template', async (req, res) => {
if (!companyId) return res.status(400).json({ error: 'companyId is required' }); if (!companyId) return res.status(400).json({ error: 'companyId is required' });
if (!shipment) return res.status(400).json({ error: 'payload.shipment is required' }); if (!shipment) return res.status(400).json({ error: 'payload.shipment is required' });
if (!brandName) return res.status(400).json({ error: 'payload.shipment.bags[0].brand.brand_name is required' }); if (!brandName) {
if (!event) return res.status(400).json({ error: 'payload.shipment.status is required' }); return res.status(400).json({
if (!toNumber) return res.status(400).json({ error: 'payload.shipment.user.mobile is required' }); error: 'A shipment brand name is required',
details: {
acceptedPaths: [
'payload.shipment.bags[0].brand.brand_name',
'payload.shipment.bags[0].item.attributes.brand_name',
'payload.shipment.affiliate_details.company_affiliate_tag',
],
},
});
}
if (!event) {
return res.status(400).json({
error: 'A shipment event status is required',
details: {
acceptedPaths: [
'payload.shipment.status',
'payload.shipment.shipment_status.status',
'payload.shipment.shipment_status.current_shipment_status',
],
},
});
}
if (!toNumber) {
return res.status(400).json({
error: 'A shipment phone number is required',
details: {
acceptedPaths: [
'payload.shipment.user.mobile',
'payload.shipment.delivery_address.phone',
'payload.shipment.billing_address.phone',
],
},
});
}
const business = await findBusinessByBrandName(companyId, brandName); const business = await findBusinessByBrandName(companyId, brandName);
if (!business) { if (!business) {
@ -740,7 +915,7 @@ router.post('/resolve-template', async (req, res) => {
const eventSlug = slugify(event); const eventSlug = slugify(event);
const folder = `${businessRoot(companyId, business.businessId)}/templates`; const folder = `${businessRoot(companyId, business.businessId)}/templates`;
const tmpl = await fetchJSON(folder, eventSlug); const { template: tmpl, matchedSlug } = await resolveWhitelistedTemplate(folder, eventSlug);
if (!tmpl || tmpl.status !== 'whitelisted' || !normalizeText(tmpl.selectedTemplate)) { if (!tmpl || tmpl.status !== 'whitelisted' || !normalizeText(tmpl.selectedTemplate)) {
return res.status(404).json({ error: 'Whitelisted template not found' }); return res.status(404).json({ error: 'Whitelisted template not found' });
@ -763,6 +938,7 @@ router.post('/resolve-template', async (req, res) => {
businessId: business.businessId, businessId: business.businessId,
brandName, brandName,
event: eventSlug, event: eventSlug,
matchedTemplateEvent: matchedSlug || eventSlug,
templateId: normalizeText(tmpl.templateId), templateId: normalizeText(tmpl.templateId),
template: tmpl.selectedTemplate, template: tmpl.selectedTemplate,
content: resolvedTemplate, content: resolvedTemplate,
@ -1030,7 +1206,7 @@ router.get('/:businessId/global-sms/active', async (req, res) => {
router.get('/:businessId/events', async (req, res) => { router.get('/:businessId/events', async (req, res) => {
try { try {
const data = await fetchJSON(businessRoot(getCompanyId(req), req.params.businessId), 'events'); const data = await fetchJSON(businessRoot(getCompanyId(req), req.params.businessId), 'events');
res.json(data || { events: DEFAULT_EVENTS }); res.json(mergeDefaultEvents(data || {}));
} catch (err) { } catch (err) {
res.status(500).json({ error: err.message }); res.status(500).json({ error: err.message });
} }
@ -1043,7 +1219,7 @@ router.post('/:businessId/events', async (req, res) => {
if (!label) return res.status(400).json({ error: 'label is required' }); if (!label) return res.status(400).json({ error: 'label is required' });
const bizRoot = businessRoot(getCompanyId(req), req.params.businessId); const bizRoot = businessRoot(getCompanyId(req), req.params.businessId);
const data = await fetchJSON(bizRoot, 'events') || { events: [...DEFAULT_EVENTS] }; const data = mergeDefaultEvents(await fetchJSON(bizRoot, 'events') || {});
const slug = slugify(label); const slug = slugify(label);
if (data.events.some(e => e.slug === slug)) { if (data.events.some(e => e.slug === slug)) {
@ -1064,7 +1240,7 @@ router.delete('/:businessId/events/:slug', async (req, res) => {
try { try {
const { businessId, slug } = req.params; const { businessId, slug } = req.params;
const bizRoot = businessRoot(getCompanyId(req), businessId); const bizRoot = businessRoot(getCompanyId(req), businessId);
const data = await fetchJSON(bizRoot, 'events') || { events: [...DEFAULT_EVENTS] }; const data = mergeDefaultEvents(await fetchJSON(bizRoot, 'events') || {});
const event = data.events.find(e => e.slug === slug); const event = data.events.find(e => e.slug === slug);
if (!event) return res.status(404).json({ error: 'Event not found' }); if (!event) return res.status(404).json({ error: 'Event not found' });
@ -1092,7 +1268,7 @@ router.post('/:businessId/events/:slug/generate', async (req, res) => {
return res.status(400).json({ error: 'A cURL profile must be configured and active before generating templates.' }); return res.status(400).json({ error: 'A cURL profile must be configured and active before generating templates.' });
} }
const eventsData = await fetchJSON(bizRoot, 'events') || { events: DEFAULT_EVENTS }; const eventsData = mergeDefaultEvents(await fetchJSON(bizRoot, 'events') || {});
const event = eventsData.events.find(e => e.slug === slug); const event = eventsData.events.find(e => e.slug === slug);
if (!event) return res.status(404).json({ error: 'Event not found' }); if (!event) return res.status(404).json({ error: 'Event not found' });