Checking post edits on generated template
This commit is contained in:
parent
2f9f469be8
commit
82cc095b6e
|
|
@ -2,6 +2,81 @@ import { useState, useEffect, useCallback } from 'react';
|
|||
import { useParams } from 'react-router-dom';
|
||||
import apiClient from '../api/client';
|
||||
|
||||
const MAX_SMS_LENGTH = 160;
|
||||
const PLACEHOLDER_TOKEN = '{#var#}';
|
||||
const PLACEHOLDER_REGEX = /\{#var#\}/g;
|
||||
|
||||
function getVariantKey(slug, index) {
|
||||
return `${slug}:${index}`;
|
||||
}
|
||||
|
||||
function createVariantDraft(text = '') {
|
||||
return {
|
||||
originalText: text,
|
||||
currentText: text,
|
||||
validationStatus: 'idle',
|
||||
why: '',
|
||||
lastCheckedText: '',
|
||||
};
|
||||
}
|
||||
|
||||
function countPlaceholders(text = '') {
|
||||
return (String(text).match(PLACEHOLDER_REGEX) || []).length;
|
||||
}
|
||||
|
||||
function buildDraftsForVariants(slug, generatedVariants = []) {
|
||||
const nextDrafts = {};
|
||||
generatedVariants.forEach((variant, index) => {
|
||||
nextDrafts[getVariantKey(slug, index)] = createVariantDraft(variant);
|
||||
});
|
||||
return nextDrafts;
|
||||
}
|
||||
|
||||
function removeDraftsForSlug(drafts, slug) {
|
||||
const nextDrafts = { ...drafts };
|
||||
Object.keys(nextDrafts).forEach((key) => {
|
||||
if (key.startsWith(`${slug}:`)) delete nextDrafts[key];
|
||||
});
|
||||
return nextDrafts;
|
||||
}
|
||||
|
||||
function syncDraftsWithVariants(existingDrafts, variantsBySlug) {
|
||||
const nextDrafts = {};
|
||||
|
||||
Object.entries(variantsBySlug).forEach(([slug, generatedVariants]) => {
|
||||
generatedVariants.forEach((variant, index) => {
|
||||
const key = getVariantKey(slug, index);
|
||||
const existing = existingDrafts[key];
|
||||
nextDrafts[key] = existing && existing.originalText === variant
|
||||
? existing
|
||||
: createVariantDraft(variant);
|
||||
});
|
||||
});
|
||||
|
||||
return nextDrafts;
|
||||
}
|
||||
|
||||
function buildTemplateUiState(templates = []) {
|
||||
const nextVariants = {};
|
||||
const nextGenState = {};
|
||||
|
||||
templates.forEach((template) => {
|
||||
if (!template?.eventSlug) return;
|
||||
|
||||
if (template.selectedTemplate) {
|
||||
nextGenState[template.eventSlug] = 'selected';
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(template.generatedVariants) && template.generatedVariants.length > 0) {
|
||||
nextVariants[template.eventSlug] = template.generatedVariants;
|
||||
nextGenState[template.eventSlug] = 'done';
|
||||
}
|
||||
});
|
||||
|
||||
return { nextVariants, nextGenState };
|
||||
}
|
||||
|
||||
export default function Events() {
|
||||
const { businessId } = useParams();
|
||||
const [events, setEvents] = useState([]);
|
||||
|
|
@ -11,6 +86,7 @@ export default function Events() {
|
|||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [genState, setGenState] = useState({});
|
||||
const [variants, setVariants] = useState({});
|
||||
const [variantDrafts, setVariantDrafts] = useState({});
|
||||
const [selectingVariantKey, setSelectingVariantKey] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [readyToGenerate, setReadyToGenerate] = useState(false);
|
||||
|
|
@ -18,12 +94,20 @@ export default function Events() {
|
|||
const loadEvents = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [eventsRes, activeProfileRes] = await Promise.all([
|
||||
const [eventsRes, activeProfileRes, templatesRes] = await Promise.all([
|
||||
apiClient.get(`/api/businesses/${businessId}/events`),
|
||||
apiClient.get(`/api/businesses/${businessId}/global-sms/active`).catch(() => ({ data: {} })),
|
||||
apiClient.get(`/api/businesses/${businessId}/templates`).catch(() => ({ data: { templates: [] } })),
|
||||
]);
|
||||
|
||||
const templates = templatesRes.data.templates || [];
|
||||
const { nextVariants, nextGenState } = buildTemplateUiState(templates);
|
||||
|
||||
setEvents(eventsRes.data.events || []);
|
||||
setReadyToGenerate(!!activeProfileRes.data?.activeProfile?.rawCurl);
|
||||
setVariants(nextVariants);
|
||||
setGenState(nextGenState);
|
||||
setVariantDrafts((currentDrafts) => syncDraftsWithVariants(currentDrafts, nextVariants));
|
||||
} catch {
|
||||
setError('Failed to load events');
|
||||
} finally {
|
||||
|
|
@ -66,27 +150,82 @@ export default function Events() {
|
|||
setError('Configure and activate a cURL profile before generating templates.');
|
||||
return;
|
||||
}
|
||||
setGenState(s => ({ ...s, [slug]: 'loading' }));
|
||||
|
||||
setGenState((state) => ({ ...state, [slug]: 'loading' }));
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const res = await apiClient.post(`/api/businesses/${businessId}/events/${slug}/generate`);
|
||||
setVariants(v => ({ ...v, [slug]: res.data.variants }));
|
||||
setGenState(s => ({ ...s, [slug]: 'done' }));
|
||||
const generatedVariants = res.data.variants || [];
|
||||
|
||||
setVariants((currentVariants) => ({ ...currentVariants, [slug]: generatedVariants }));
|
||||
setVariantDrafts((currentDrafts) => ({
|
||||
...removeDraftsForSlug(currentDrafts, slug),
|
||||
...buildDraftsForVariants(slug, generatedVariants),
|
||||
}));
|
||||
setGenState((state) => ({ ...state, [slug]: 'done' }));
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Generation failed');
|
||||
setGenState(s => ({ ...s, [slug]: 'error' }));
|
||||
setGenState((state) => ({ ...state, [slug]: 'error' }));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleValidateEdit(slug, variantIndex) {
|
||||
const variantKey = getVariantKey(slug, variantIndex);
|
||||
const draft = variantDrafts[variantKey];
|
||||
const editedTemplate = draft?.currentText || '';
|
||||
|
||||
if (!editedTemplate) return;
|
||||
|
||||
setVariantDrafts((currentDrafts) => ({
|
||||
...currentDrafts,
|
||||
[variantKey]: {
|
||||
...(currentDrafts[variantKey] || createVariantDraft(editedTemplate)),
|
||||
validationStatus: 'checking',
|
||||
why: '',
|
||||
lastCheckedText: '',
|
||||
},
|
||||
}));
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const res = await apiClient.post(`/api/businesses/${businessId}/templates/${slug}/validate-edit`, {
|
||||
editedTemplate,
|
||||
});
|
||||
|
||||
setVariantDrafts((currentDrafts) => ({
|
||||
...currentDrafts,
|
||||
[variantKey]: {
|
||||
...(currentDrafts[variantKey] || createVariantDraft(editedTemplate)),
|
||||
validationStatus: res.data?.approved ? 'approved' : 'rejected',
|
||||
why: res.data?.why || '',
|
||||
lastCheckedText: editedTemplate,
|
||||
},
|
||||
}));
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Failed to validate edited template');
|
||||
setVariantDrafts((currentDrafts) => ({
|
||||
...currentDrafts,
|
||||
[variantKey]: {
|
||||
...(currentDrafts[variantKey] || createVariantDraft(editedTemplate)),
|
||||
validationStatus: 'idle',
|
||||
why: '',
|
||||
lastCheckedText: '',
|
||||
},
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSelect(slug, variant, variantIndex) {
|
||||
const variantKey = `${slug}:${variantIndex}`;
|
||||
const variantKey = getVariantKey(slug, variantIndex);
|
||||
setSelectingVariantKey(variantKey);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
await apiClient.post(`/api/businesses/${businessId}/templates/${slug}/select`, { selectedVariant: variant });
|
||||
// Clear variants display after selection
|
||||
setVariants(v => ({ ...v, [slug]: [] }));
|
||||
setGenState(s => ({ ...s, [slug]: 'selected' }));
|
||||
setVariants((currentVariants) => ({ ...currentVariants, [slug]: [] }));
|
||||
setVariantDrafts((currentDrafts) => removeDraftsForSlug(currentDrafts, slug));
|
||||
setGenState((state) => ({ ...state, [slug]: 'selected' }));
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Failed to select template');
|
||||
} finally {
|
||||
|
|
@ -94,6 +233,32 @@ export default function Events() {
|
|||
}
|
||||
}
|
||||
|
||||
function handleVariantChange(slug, variantIndex, nextText) {
|
||||
const variantKey = getVariantKey(slug, variantIndex);
|
||||
const originalText = variantDrafts[variantKey]?.originalText || variants[slug]?.[variantIndex] || '';
|
||||
|
||||
setVariantDrafts((currentDrafts) => ({
|
||||
...currentDrafts,
|
||||
[variantKey]: {
|
||||
originalText,
|
||||
currentText: nextText,
|
||||
validationStatus: 'idle',
|
||||
why: '',
|
||||
lastCheckedText: '',
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
function handleRevertVariant(slug, variantIndex) {
|
||||
const variantKey = getVariantKey(slug, variantIndex);
|
||||
const originalText = variantDrafts[variantKey]?.originalText || variants[slug]?.[variantIndex] || '';
|
||||
|
||||
setVariantDrafts((currentDrafts) => ({
|
||||
...currentDrafts,
|
||||
[variantKey]: createVariantDraft(originalText),
|
||||
}));
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
|
|
@ -104,21 +269,19 @@ export default function Events() {
|
|||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between 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(v => !v)}
|
||||
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>
|
||||
|
||||
{/* Generation readiness banner */}
|
||||
{!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">
|
||||
<span>⚠️</span>
|
||||
|
|
@ -133,12 +296,11 @@ export default function Events() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Event form */}
|
||||
{showAddForm && (
|
||||
<form onSubmit={handleAddEvent} className="mb-8 flex gap-3 p-5 rounded-xl bg-gray-50 border border-gray-200 shadow-sm">
|
||||
<input
|
||||
value={newLabel}
|
||||
onChange={e => setNewLabel(e.target.value)}
|
||||
onChange={(e) => setNewLabel(e.target.value)}
|
||||
placeholder="Event name (e.g. Return Initiated)"
|
||||
className="flex-1 px-4 py-2.5 rounded-lg bg-white border border-gray-300 text-gray-900 placeholder-gray-400 font-medium focus:outline-none focus:ring-2 focus:ring-indigo-600 text-sm shadow-sm"
|
||||
autoFocus
|
||||
|
|
@ -153,20 +315,18 @@ export default function Events() {
|
|||
</form>
|
||||
)}
|
||||
|
||||
{/* Events list */}
|
||||
<div className="space-y-4">
|
||||
{events.map(event => {
|
||||
{events.map((event) => {
|
||||
const state = genState[event.slug] || 'idle';
|
||||
const eventVariants = variants[event.slug] || [];
|
||||
|
||||
return (
|
||||
<div key={event.slug} className="rounded-xl bg-white border border-gray-200 shadow-sm overflow-hidden">
|
||||
{/* Event header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between px-6 py-5 gap-4">
|
||||
<div className="flex items-start gap-4">
|
||||
{event.isDefault ? (
|
||||
<div className="mt-0.5 w-6 h-6 rounded-full bg-gray-100 flex items-center justify-center border border-gray-200 shrink-0" title="Default event">
|
||||
<svg className="w-3.5 h-3.5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/></svg>
|
||||
<svg className="w-3.5 h-3.5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
|
|
@ -209,39 +369,168 @@ export default function Events() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Generated variants */}
|
||||
{eventVariants.length > 0 && (
|
||||
<div className="border-t border-gray-100 bg-gray-50/50 px-6 py-5 space-y-4">
|
||||
<p className="text-xs text-gray-500 font-bold uppercase tracking-wider">Pick a Variant</p>
|
||||
<p className="text-xs text-gray-500 font-bold uppercase tracking-wider">Review, edit, and choose a variant</p>
|
||||
<div className="grid gap-4">
|
||||
{eventVariants.map((v, i) => {
|
||||
const variantKey = `${event.slug}:${i}`;
|
||||
{eventVariants.map((variant, index) => {
|
||||
const variantKey = getVariantKey(event.slug, index);
|
||||
const draft = variantDrafts[variantKey] || createVariantDraft(variant);
|
||||
const currentText = draft.currentText;
|
||||
const originalText = draft.originalText;
|
||||
const validationStatus = draft.validationStatus;
|
||||
const currentMatchesCheckedText = draft.lastCheckedText === currentText;
|
||||
const isEdited = currentText !== originalText;
|
||||
const placeholderCount = countPlaceholders(currentText);
|
||||
const originalPlaceholderCount = countPlaceholders(originalText);
|
||||
const placeholderMismatch = placeholderCount !== originalPlaceholderCount;
|
||||
const tooLong = currentText.length > MAX_SMS_LENGTH;
|
||||
const isSelectingThis = selectingVariantKey === variantKey;
|
||||
const isSelectingAnotherVariant = !!selectingVariantKey
|
||||
&& selectingVariantKey !== variantKey
|
||||
&& selectingVariantKey.startsWith(`${event.slug}:`);
|
||||
const canRunCheck = isEdited && !tooLong && !placeholderMismatch && validationStatus !== 'checking';
|
||||
const canUseEdited = isEdited
|
||||
&& validationStatus === 'approved'
|
||||
&& currentMatchesCheckedText
|
||||
&& !tooLong
|
||||
&& !placeholderMismatch;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
key={variantKey}
|
||||
className={`rounded-xl border bg-white p-5 shadow-sm transition ${
|
||||
isSelectingThis
|
||||
? 'border-indigo-300 ring-2 ring-indigo-100'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<p className="text-sm text-gray-800 font-mono leading-relaxed">{v}</p>
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<span className={`text-xs font-semibold px-2.5 py-1 rounded-md ${v.length > 160 ? 'bg-red-50 text-red-700' : 'bg-gray-100 text-gray-600'}`}>
|
||||
{v.length} / 160
|
||||
<div className="flex flex-wrap items-center gap-2 mb-3">
|
||||
<span className={`text-[11px] font-bold px-2 py-1 rounded-full border ${
|
||||
isEdited
|
||||
? 'bg-amber-50 border-amber-200 text-amber-700'
|
||||
: 'bg-gray-50 border-gray-200 text-gray-600'
|
||||
}`}>
|
||||
{isEdited ? 'Edited Draft' : 'Original Draft'}
|
||||
</span>
|
||||
|
||||
{validationStatus === 'checking' && (
|
||||
<span className="text-[11px] font-bold px-2 py-1 rounded-full border bg-blue-50 border-blue-200 text-blue-700">
|
||||
Checking edit…
|
||||
</span>
|
||||
)}
|
||||
|
||||
{validationStatus === 'approved' && currentMatchesCheckedText && (
|
||||
<span className="text-[11px] font-bold px-2 py-1 rounded-full border bg-green-50 border-green-200 text-green-700">
|
||||
Edit passed check
|
||||
</span>
|
||||
)}
|
||||
|
||||
{validationStatus === 'rejected' && currentMatchesCheckedText && (
|
||||
<span className="text-[11px] font-bold px-2 py-1 rounded-full border bg-red-50 border-red-200 text-red-700">
|
||||
Needs changes
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
value={currentText}
|
||||
onChange={(e) => handleVariantChange(event.slug, index, e.target.value)}
|
||||
rows={4}
|
||||
className={`w-full rounded-xl border px-4 py-3 text-sm text-gray-800 font-mono leading-relaxed resize-y focus:outline-none focus:ring-2 ${
|
||||
isEdited
|
||||
? 'border-amber-200 bg-amber-50/40 focus:ring-amber-200 focus:border-amber-300'
|
||||
: 'border-gray-200 bg-gray-50 focus:ring-indigo-100 focus:border-indigo-300'
|
||||
}`}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 mt-3">
|
||||
<span className={`text-xs font-semibold px-2.5 py-1 rounded-md border ${
|
||||
tooLong
|
||||
? 'bg-red-50 border-red-200 text-red-700'
|
||||
: 'bg-gray-100 border-gray-200 text-gray-600'
|
||||
}`}>
|
||||
{currentText.length} / {MAX_SMS_LENGTH}
|
||||
</span>
|
||||
|
||||
{isEdited && (
|
||||
<button
|
||||
onClick={() => handleSelect(event.slug, v, i)}
|
||||
onClick={() => handleRevertVariant(event.slug, index)}
|
||||
className="text-xs px-3 py-2 rounded-md bg-white border border-gray-300 text-gray-700 font-semibold hover:bg-gray-50 transition"
|
||||
>
|
||||
Revert to original
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isEdited && (
|
||||
<div className="mt-3 rounded-lg border border-gray-200 bg-gray-50 px-4 py-3">
|
||||
<p className="text-[11px] font-bold uppercase tracking-wider text-gray-500 mb-2">Original generated version</p>
|
||||
<p className="text-sm text-gray-600 font-mono leading-relaxed">{originalText}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{placeholderMismatch && (
|
||||
<div className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||
Keep the same number of <span className="font-mono">{PLACEHOLDER_TOKEN}</span> placeholders.
|
||||
This draft has {placeholderCount}, while the generated version has {originalPlaceholderCount}.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tooLong && (
|
||||
<div className="mt-3 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
Shorten this template to {MAX_SMS_LENGTH} characters or less before checking or using the edited version.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{validationStatus === 'rejected' && currentMatchesCheckedText && draft.why && (
|
||||
<div className="mt-3 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
<span className="font-semibold">Why it did not pass:</span> {draft.why}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 mt-4">
|
||||
{!isEdited ? (
|
||||
<button
|
||||
onClick={() => handleSelect(event.slug, currentText, index)}
|
||||
disabled={isSelectingThis || isSelectingAnotherVariant}
|
||||
className="text-xs px-4 py-2 rounded-md bg-indigo-600 hover:bg-indigo-700 text-white font-bold transition disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isSelectingThis ? 'Selecting…' : 'Use this template'}
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleSelect(event.slug, originalText, index)}
|
||||
disabled={isSelectingThis || isSelectingAnotherVariant}
|
||||
className="text-xs px-4 py-2 rounded-md bg-white border border-gray-300 text-gray-700 font-bold hover:bg-gray-50 transition disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isSelectingThis ? 'Selecting…' : 'Use original'}
|
||||
</button>
|
||||
|
||||
{canUseEdited ? (
|
||||
<button
|
||||
onClick={() => handleSelect(event.slug, currentText, index)}
|
||||
disabled={isSelectingThis || isSelectingAnotherVariant}
|
||||
className="text-xs px-4 py-2 rounded-md bg-green-600 hover:bg-green-700 text-white font-bold transition disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isSelectingThis ? 'Selecting…' : 'Use edited version'}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleValidateEdit(event.slug, index)}
|
||||
disabled={!canRunCheck || isSelectingThis || isSelectingAnotherVariant}
|
||||
className="text-xs px-4 py-2 rounded-md bg-indigo-600 hover:bg-indigo-700 text-white font-bold transition disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
{validationStatus === 'checking'
|
||||
? 'Checking…'
|
||||
: validationStatus === 'rejected' && currentMatchesCheckedText
|
||||
? 'Check again'
|
||||
: 'Check edit'}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -200,6 +200,20 @@ function getShipmentToNumber(body) {
|
|||
return normalizeText(body?.payload?.shipment?.user?.mobile);
|
||||
}
|
||||
|
||||
function parseWorkflowPayload(data) {
|
||||
if (typeof data === 'string') {
|
||||
const trimmed = data.trim();
|
||||
if (!trimmed) return {};
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
return { status: trimmed };
|
||||
}
|
||||
}
|
||||
|
||||
return data && typeof data === 'object' ? data : {};
|
||||
}
|
||||
|
||||
async function sendResolveTemplateWorkflow({ template, toNumber, sourcePayload }) {
|
||||
const workflowUrl = normalizeText(process.env.WORKFLOW_URL_RESOLVE_TEMPLATE);
|
||||
if (!workflowUrl) {
|
||||
|
|
@ -230,6 +244,61 @@ async function sendResolveTemplateWorkflow({ template, toNumber, sourcePayload }
|
|||
};
|
||||
}
|
||||
|
||||
function normalizeEditedTemplateValidation(data) {
|
||||
const payload = parseWorkflowPayload(data);
|
||||
|
||||
let approved = null;
|
||||
if (typeof payload.approved === 'boolean') approved = payload.approved;
|
||||
if (approved === null && typeof payload.isApproved === 'boolean') approved = payload.isApproved;
|
||||
if (approved === null && typeof payload.valid === 'boolean') approved = payload.valid;
|
||||
if (approved === null && typeof payload.is_valid === 'boolean') approved = payload.is_valid;
|
||||
|
||||
if (approved === null) {
|
||||
const status = normalizeText(payload.status || payload.result || payload.decision).toLowerCase();
|
||||
if (['approved', 'pass', 'passed', 'valid', 'ok'].includes(status)) approved = true;
|
||||
if (['rejected', 'not_approved', 'failed', 'fail', 'invalid', 'needs_changes'].includes(status)) approved = false;
|
||||
}
|
||||
|
||||
if (approved === null) {
|
||||
throw createHttpError(502, 'Template edit validation workflow returned an unreadable response', {
|
||||
details: payload,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
approved,
|
||||
why: normalizeText(payload.why || payload.reason || payload.message || payload.feedback),
|
||||
workflowResult: payload,
|
||||
};
|
||||
}
|
||||
|
||||
async function validateEditedTemplateWorkflow(editedTemplate) {
|
||||
const workflowUrl = normalizeText(process.env.WORKFLOW_URL_TEMPLATE_EDIT_CHECK);
|
||||
if (!workflowUrl) {
|
||||
throw createHttpError(500, 'WORKFLOW_URL_TEMPLATE_EDIT_CHECK is not configured');
|
||||
}
|
||||
|
||||
const response = await axios.post(
|
||||
workflowUrl,
|
||||
{ editedTemplate },
|
||||
{
|
||||
timeout: 30000,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
validateStatus: () => true,
|
||||
}
|
||||
);
|
||||
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
throw createHttpError(
|
||||
502,
|
||||
`Template edit validation workflow failed with status ${response.status}`,
|
||||
{ details: response.data }
|
||||
);
|
||||
}
|
||||
|
||||
return normalizeEditedTemplateValidation(response.data);
|
||||
}
|
||||
|
||||
function getProviderPatch(input) {
|
||||
if (!input || typeof input !== 'object') return null;
|
||||
|
||||
|
|
@ -930,6 +999,26 @@ router.get('/:businessId/templates/:slug', async (req, res) => {
|
|||
}
|
||||
});
|
||||
|
||||
// POST /api/businesses/:businessId/templates/:slug/validate-edit
|
||||
router.post('/:businessId/templates/:slug/validate-edit', async (req, res) => {
|
||||
try {
|
||||
const { businessId, slug } = req.params;
|
||||
const editedTemplate = normalizeText(req.body?.editedTemplate);
|
||||
if (!editedTemplate) {
|
||||
return res.status(400).json({ error: 'editedTemplate is required' });
|
||||
}
|
||||
|
||||
const folder = `${businessRoot(getCompanyId(req), businessId)}/templates`;
|
||||
const tmpl = await fetchJSON(folder, slug);
|
||||
if (!tmpl) return res.status(404).json({ error: 'Template not found' });
|
||||
|
||||
const validation = await validateEditedTemplateWorkflow(editedTemplate);
|
||||
res.json(validation);
|
||||
} catch (err) {
|
||||
sendRouteError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/businesses/:businessId/templates/:slug/select
|
||||
router.post('/:businessId/templates/:slug/select', async (req, res) => {
|
||||
try {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user