Events visual changes and payload mapping correction
This commit is contained in:
parent
ff864a965d
commit
926c332c7e
|
|
@ -13,6 +13,120 @@ const DLT_VARIABLE_OPTIONS = [
|
|||
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_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) {
|
||||
return `${slug}:${index}`;
|
||||
|
|
@ -105,8 +219,10 @@ export default function Events() {
|
|||
const [events, setEvents] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [newLabel, setNewLabel] = useState('');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [addingEvent, setAddingEvent] = useState(false);
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [expandedGroups, setExpandedGroups] = useState(DEFAULT_EXPANDED_GROUPS);
|
||||
const [genState, setGenState] = useState({});
|
||||
const [variants, setVariants] = useState({});
|
||||
const [variantDrafts, setVariantDrafts] = useState({});
|
||||
|
|
@ -350,6 +466,13 @@ export default function Events() {
|
|||
});
|
||||
}
|
||||
|
||||
function toggleGroup(groupId) {
|
||||
setExpandedGroups((current) => ({
|
||||
...current,
|
||||
[groupId]: !current[groupId],
|
||||
}));
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
|
|
@ -358,19 +481,55 @@ export default function Events() {
|
|||
);
|
||||
}
|
||||
|
||||
const groupedEvents = buildGroupedEvents(events, searchTerm);
|
||||
const totalVisibleEvents = groupedEvents.reduce((count, group) => count + group.events.length, 0);
|
||||
|
||||
return (
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
{showAddForm ? 'Cancel' : '+ Add Event'}
|
||||
</button>
|
||||
<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
|
||||
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"
|
||||
>
|
||||
{showAddForm ? 'Cancel' : '+ Add Event'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!readyToGenerate && (
|
||||
|
|
@ -406,8 +565,56 @@ export default function Events() {
|
|||
</form>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
{events.map((event) => {
|
||||
{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">
|
||||
{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 eventVariants = variants[event.slug] || [];
|
||||
|
||||
|
|
@ -430,7 +637,6 @@ export default function Events() {
|
|||
)}
|
||||
<div>
|
||||
<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>
|
||||
|
||||
|
|
@ -695,7 +901,14 @@ export default function Events() {
|
|||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,102 @@
|
|||
const DEFAULT_EVENTS = [
|
||||
{ slug: 'placed', label: 'Placed', isDefault: true },
|
||||
{ slug: 'confirmed', label: 'Confirmed', isDefault: true },
|
||||
{ slug: 'dp_assigned', label: 'DP Assigned', isDefault: true },
|
||||
{ slug: 'pack', label: 'Pack', isDefault: true },
|
||||
{ slug: 'cancelled', label: 'Cancelled', isDefault: true },
|
||||
{ slug: 'delivery_done', label: 'Delivery Done', isDefault: true },
|
||||
const OMS_EVENT_SLUGS = [
|
||||
'assigning_dp',
|
||||
'assigning_return_dp',
|
||||
'bag_confirmed',
|
||||
'bag_invoiced',
|
||||
'bag_lost',
|
||||
'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;
|
||||
|
|
|
|||
|
|
@ -155,6 +155,21 @@ function normalizeText(value) {
|
|||
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) {
|
||||
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) {
|
||||
return body?.payload?.shipment && typeof body.payload.shipment === 'object'
|
||||
? body.payload.shipment
|
||||
|
|
@ -189,24 +240,69 @@ function getShipmentPayload(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) {
|
||||
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) {
|
||||
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;
|
||||
|
||||
function normalizeRenderableValue(value) {
|
||||
if (typeof value === 'string') return value.replace(/\s+/g, ' ').trim();
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
|
||||
if (typeof value === 'boolean') return value ? 'true' : 'false';
|
||||
return '';
|
||||
return normalizeScalarText(value).replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
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) {
|
||||
|
|
@ -255,51 +351,97 @@ function indexShipmentValues(value, pathParts = [], valueIndex = new Map()) {
|
|||
function buildShipmentValueIndex(shipment) {
|
||||
const valueIndex = indexShipmentValues(shipment);
|
||||
const firstBag = shipment?.bags?.[0] || {};
|
||||
const primaryTrackingUrl = normalizeRenderableValue(
|
||||
shipment?.delivery_partner_details?.track_url
|
||||
|| shipment?.meta?.tracking_url
|
||||
|| firstBag?.meta?.tracking_url
|
||||
|| shipment?.affiliate_details?.shipment_meta?.tracking_url
|
||||
|| shipment?.article_details?.dp_details?.track_url
|
||||
const customerName = splitFullName(
|
||||
firstNonEmptyText(
|
||||
shipment?.user?.first_name && shipment?.user?.last_name
|
||||
? `${shipment.user.first_name} ${shipment.user.last_name}`
|
||||
: '',
|
||||
shipment?.delivery_address?.name,
|
||||
shipment?.delivery_address?.contact_person,
|
||||
shipment?.billing_address?.name,
|
||||
shipment?.billing_address?.contact_person
|
||||
)
|
||||
);
|
||||
const primaryAwbNumber = normalizeRenderableValue(
|
||||
shipment?.delivery_partner_details?.awb_no
|
||||
|| shipment?.meta?.awb_number
|
||||
|| shipment?.article_details?.dp_details?.awb_no
|
||||
const primaryTrackingUrl = firstNonEmptyText(
|
||||
shipment?.delivery_partner_details?.track_url,
|
||||
shipment?.meta?.tracking_url,
|
||||
firstBag?.meta?.tracking_url,
|
||||
shipment?.affiliate_details?.shipment_meta?.tracking_url,
|
||||
shipment?.article_details?.dp_details?.track_url
|
||||
);
|
||||
const primaryCourierName = normalizeRenderableValue(
|
||||
shipment?.delivery_partner_details?.display_name
|
||||
|| shipment?.delivery_partner_details?.name
|
||||
|| shipment?.meta?.courier_partner_name
|
||||
|| shipment?.meta?.dp_name
|
||||
|| firstBag?.meta?.dp_name
|
||||
const primaryAwbNumber = firstNonEmptyText(
|
||||
shipment?.delivery_partner_details?.awb_no,
|
||||
shipment?.meta?.awb_number,
|
||||
shipment?.article_details?.dp_details?.awb_no,
|
||||
firstBag?.meta?.awb_number,
|
||||
firstBag?.current_operational_status?.delivery_awb_number
|
||||
);
|
||||
const brandName = normalizeRenderableValue(
|
||||
shipment?.bags?.[0]?.brand?.brand_name
|
||||
|| shipment?.bags?.[0]?.item?.attributes?.brand_name
|
||||
|| shipment?.affiliate_details?.company_affiliate_tag
|
||||
const primaryCourierName = firstNonEmptyText(
|
||||
shipment?.delivery_partner_details?.display_name,
|
||||
shipment?.delivery_partner_details?.name,
|
||||
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, 'lastName', normalizeRenderableValue(shipment?.user?.last_name));
|
||||
setValueIndexEntry(valueIndex, 'fullName', normalizeRenderableValue(
|
||||
`${normalizeRenderableValue(shipment?.user?.first_name)} ${normalizeRenderableValue(shipment?.user?.last_name)}`.trim()
|
||||
));
|
||||
setValueIndexEntry(valueIndex, 'phone', normalizeRenderableValue(shipment?.user?.mobile || shipment?.delivery_address?.phone));
|
||||
setValueIndexEntry(valueIndex, 'mobile', normalizeRenderableValue(shipment?.user?.mobile || shipment?.delivery_address?.phone));
|
||||
setValueIndexEntry(valueIndex, 'toNumber', normalizeRenderableValue(shipment?.user?.mobile || shipment?.delivery_address?.phone));
|
||||
setValueIndexEntry(valueIndex, 'firstName', resolvedFirstName);
|
||||
setValueIndexEntry(valueIndex, 'lastName', resolvedLastName);
|
||||
setValueIndexEntry(valueIndex, 'fullName', resolvedFullName);
|
||||
setValueIndexEntry(valueIndex, 'customerFirstName', resolvedFirstName);
|
||||
setValueIndexEntry(valueIndex, 'customerLastName', resolvedLastName);
|
||||
setValueIndexEntry(valueIndex, 'customerName', resolvedFullName);
|
||||
setValueIndexEntry(valueIndex, 'phone', toNumber);
|
||||
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, 'shipmentId', normalizeRenderableValue(shipment?.shipment_id));
|
||||
setValueIndexEntry(valueIndex, 'event', normalizeRenderableValue(shipment?.status));
|
||||
setValueIndexEntry(valueIndex, 'status', normalizeRenderableValue(shipment?.status));
|
||||
setValueIndexEntry(valueIndex, 'eventDisplayName', normalizeRenderableValue(shipment?.shipment_status?.display_name));
|
||||
setValueIndexEntry(valueIndex, 'displayName', normalizeRenderableValue(shipment?.shipment_status?.display_name));
|
||||
setValueIndexEntry(valueIndex, 'orderNumber', normalizeRenderableValue(shipment?.order_id));
|
||||
setValueIndexEntry(valueIndex, 'shipmentId', shipmentId);
|
||||
setValueIndexEntry(valueIndex, 'event', eventKey);
|
||||
setValueIndexEntry(valueIndex, 'status', eventKey);
|
||||
setValueIndexEntry(valueIndex, 'eventDisplayName', eventDisplayName);
|
||||
setValueIndexEntry(valueIndex, 'displayName', eventDisplayName);
|
||||
setValueIndexEntry(valueIndex, 'brandName', brandName);
|
||||
setValueIndexEntry(valueIndex, 'trackingUrl', primaryTrackingUrl);
|
||||
setValueIndexEntry(valueIndex, 'trackUrl', primaryTrackingUrl);
|
||||
setValueIndexEntry(valueIndex, 'trackingLink', primaryTrackingUrl);
|
||||
setValueIndexEntry(valueIndex, 'url', primaryTrackingUrl);
|
||||
setValueIndexEntry(valueIndex, 'awbNo', primaryAwbNumber);
|
||||
setValueIndexEntry(valueIndex, 'awbNumber', primaryAwbNumber);
|
||||
setValueIndexEntry(valueIndex, 'awb', primaryAwbNumber);
|
||||
setValueIndexEntry(valueIndex, 'dpName', primaryCourierName);
|
||||
setValueIndexEntry(valueIndex, 'courierName', 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 (!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 (!event) return res.status(400).json({ error: 'payload.shipment.status is required' });
|
||||
if (!toNumber) return res.status(400).json({ error: 'payload.shipment.user.mobile is required' });
|
||||
if (!brandName) {
|
||||
return res.status(400).json({
|
||||
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);
|
||||
if (!business) {
|
||||
|
|
@ -740,7 +915,7 @@ router.post('/resolve-template', async (req, res) => {
|
|||
|
||||
const eventSlug = slugify(event);
|
||||
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)) {
|
||||
return res.status(404).json({ error: 'Whitelisted template not found' });
|
||||
|
|
@ -763,6 +938,7 @@ router.post('/resolve-template', async (req, res) => {
|
|||
businessId: business.businessId,
|
||||
brandName,
|
||||
event: eventSlug,
|
||||
matchedTemplateEvent: matchedSlug || eventSlug,
|
||||
templateId: normalizeText(tmpl.templateId),
|
||||
template: tmpl.selectedTemplate,
|
||||
content: resolvedTemplate,
|
||||
|
|
@ -1030,7 +1206,7 @@ router.get('/:businessId/global-sms/active', async (req, res) => {
|
|||
router.get('/:businessId/events', async (req, res) => {
|
||||
try {
|
||||
const data = await fetchJSON(businessRoot(getCompanyId(req), req.params.businessId), 'events');
|
||||
res.json(data || { events: DEFAULT_EVENTS });
|
||||
res.json(mergeDefaultEvents(data || {}));
|
||||
} catch (err) {
|
||||
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' });
|
||||
|
||||
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);
|
||||
|
||||
if (data.events.some(e => e.slug === slug)) {
|
||||
|
|
@ -1064,7 +1240,7 @@ router.delete('/:businessId/events/:slug', async (req, res) => {
|
|||
try {
|
||||
const { businessId, slug } = req.params;
|
||||
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);
|
||||
|
||||
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.' });
|
||||
}
|
||||
|
||||
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);
|
||||
if (!event) return res.status(404).json({ error: 'Event not found' });
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user