Checking post edits on generated template

This commit is contained in:
Ritul Jadhav 2026-03-30 12:42:59 +05:30
parent 2f9f469be8
commit 82cc095b6e
2 changed files with 414 additions and 36 deletions

View File

@ -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>
);

View File

@ -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 {