← back to Marketing Command Center
MCC should-fix sweep: unify Kravet block (lib/kravet.js, server+client single source, caption/name-aware, button disabled on block), add CA §17529.5 subject/from screening, origin-absolute fetches in assets+CC panels, align OAUTH_REDIRECT_BASE default, add 5th LinkedIn template
9968bde2514a1d5e774ddd950411c81763234499 · 2026-06-14 19:46:21 -0700 · Steve Abrams
Files touched
A lib/kravet.jsM modules/assets/index.jsM modules/channels/index.jsM modules/constant-contact/index.jsM modules/linkedin/index.jsM public/panels/assets.jsM public/panels/constant-contact.js
Diff
commit 9968bde2514a1d5e774ddd950411c81763234499
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sun Jun 14 19:46:21 2026 -0700
MCC should-fix sweep: unify Kravet block (lib/kravet.js, server+client single source, caption/name-aware, button disabled on block), add CA §17529.5 subject/from screening, origin-absolute fetches in assets+CC panels, align OAUTH_REDIRECT_BASE default, add 5th LinkedIn template
---
lib/kravet.js | 39 ++++++++++++++++++++++
modules/assets/index.js | 15 +++++++--
modules/channels/index.js | 5 +--
modules/constant-contact/index.js | 56 ++++++++++++++++++++++++++++----
modules/linkedin/index.js | 1 +
public/panels/assets.js | 68 +++++++++++++++++++++++++++------------
public/panels/constant-contact.js | 6 ++--
7 files changed, 156 insertions(+), 34 deletions(-)
diff --git a/lib/kravet.js b/lib/kravet.js
new file mode 100644
index 0000000..c5286b6
--- /dev/null
+++ b/lib/kravet.js
@@ -0,0 +1,39 @@
+'use strict';
+// Canonical Kravet-family brand block — single source of truth.
+// Reposting brand-controlled (Kravet-umbrella) imagery under the DW account is
+// hard-blocked per Steve's standing rule. Both the server repost gate AND the
+// client repost modal read this list (the client fetches it via
+// GET /api/assets/repost-policy) so the two can never drift apart again.
+//
+// Keep this list in sync with the Kravet-family pricing rule in MEMORY.md
+// (kravet-lines-price-at-map).
+const KRAVET_BRANDS = [
+ 'kravet', 'lee jofa', 'lee jofa modern', 'groundworks', 'brunschwig',
+ 'cole & son', 'cole and son', 'gp & j baker', 'gp&j baker', 'g p & j baker',
+ 'colefax', 'clarke & clarke', 'clarke and clarke', 'clarke', 'mulberry',
+ 'threads', 'baker lifestyle', 'andrew martin', 'nicolette mayer', 'aerin',
+ 'barclay butera', 'thom filicia',
+];
+
+// One regex, built from the list. Spaces are matched loosely (any whitespace,
+// optional surrounding) and "&"/"and" are treated as interchangeable so handles
+// like @coleandson, @cole_and_son, "Cole & Son" all match.
+// Tokens are joined by ANY separator (space, _, -, .) or none, so handle forms
+// like "@cole_and_son", "@lee-jofa", "Cole & Son" and "coleandson" all match.
+const KRAVET_RE = new RegExp(
+ KRAVET_BRANDS
+ .map(b => b
+ .replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // escape regex metachars
+ .replace(/\s*(&|and)\s*/gi, '[\\s_.-]*(?:&|and)?[\\s_.-]*') // & / and / nothing
+ .replace(/\s+/g, '[\\s_.-]*')) // loose internal separators
+ .join('|'),
+ 'i',
+);
+
+// True if ANY of the supplied strings (handle, caption, asset name, tags…)
+// reference a brand-controlled line.
+function isKravet(...strings) {
+ return strings.some(s => s && KRAVET_RE.test(String(s)));
+}
+
+module.exports = { KRAVET_BRANDS, KRAVET_RE, isKravet };
diff --git a/modules/assets/index.js b/modules/assets/index.js
index 210b277..528968f 100644
--- a/modules/assets/index.js
+++ b/modules/assets/index.js
@@ -21,6 +21,7 @@ const fs = require('fs');
const path = require('path');
const express = require('express');
const { execFileSync } = require('child_process');
+const { KRAVET_RE, isKravet } = require('../../lib/kravet.js'); // canonical brand block
// Google Drive (info@ account) source — rclone remote is the configured `gdrive:`.
const GDRIVE_REMOTE = process.env.GDRIVE_REMOTE || 'gdrive:';
@@ -393,10 +394,13 @@ module.exports = {
// hard code-gates and STAGES a draft — it never publishes (publish needs the
// settlement image gate + Steve's approval + a connected Meta token).
const REPOSTS = path.join(DATA_DIR, 'reposts.json');
- const KRAVET_RE = /kravet|lee\s*jofa|groundworks|brunschwig|cole.*son|gp.*baker|colefax|clarke|mulberry|baker lifestyle|andrew martin|nicolette mayer|barclay butera|thom filicia/i;
const deWallpaper = s => String(s || '').replace(/\bwallpapers\b/gi, 'wallcoverings').replace(/\bwallpaper\b/gi, 'wallcovering');
function readReposts() { try { return JSON.parse(fs.readFileSync(REPOSTS, 'utf8')); } catch { return []; } }
+ // Expose the canonical brand-block pattern so the client modal enforces the
+ // exact same rule (no client/server regex drift).
+ router.get('/repost-policy', (_req, res) => res.json({ kravetPattern: KRAVET_RE.source, kravetFlags: KRAVET_RE.flags }));
+
router.post('/repost', (req, res) => {
const { id, caption, rightsConfirmed, sourceHandle, paid } = req.body || {};
const store = readStore();
@@ -407,8 +411,13 @@ module.exports = {
// resolve credit handle (explicit, else an @tag on the asset)
let handle = (sourceHandle || (asset.tags || []).find(t => /^@/.test(t)) || '').trim();
if (handle && !handle.startsWith('@')) handle = '@' + handle;
- // GATE 2 — brand-controlled source hard-block (Kravet family etc.)
- if (handle && KRAVET_RE.test(handle)) return res.status(403).json({ gate: 'blocked-vendor', error: `${handle} is brand-controlled (Kravet family) — reposting is blocked. Use a licensed brand asset instead.` });
+ // GATE 2 — brand-controlled source hard-block (Kravet family etc.).
+ // Checks the handle AND the caption, asset name, and tags — a Kravet
+ // product can't slip through just because the crediting handle isn't a
+ // Kravet handle (e.g. a designer reposting a Cole & Son install).
+ if (isKravet(handle, caption, asset.name, ...(asset.tags || []))) {
+ return res.status(403).json({ gate: 'blocked-vendor', error: `Brand-controlled (Kravet family) content detected — reposting is blocked. Use a licensed brand asset instead.` });
+ }
// GATE 3 — auto-credit required
if (!handle) return res.status(400).json({ gate: 'credit', error: 'No source @handle to credit — add the source handle (auto-credit is mandatory).' });
// build caption: "Wallpaper" word-ban + ensure credit + #ad if paid (GATE 4)
diff --git a/modules/channels/index.js b/modules/channels/index.js
index f46f6d9..1397cbb 100644
--- a/modules/channels/index.js
+++ b/modules/channels/index.js
@@ -22,8 +22,9 @@ const TOKENS = path.join(__dirname, '..', '..', 'data', 'channels-tokens.json');
const readTokens = () => { try { return JSON.parse(fs.readFileSync(TOKENS, 'utf8')); } catch { return {}; } };
const writeTokens = (o) => { fs.mkdirSync(path.dirname(TOKENS), { recursive: true }); fs.writeFileSync(TOKENS, JSON.stringify(o, null, 2)); };
// Public base for OAuth redirect URIs — register `${base}/api/channels/oauth/<platform>/callback`
-// in each app console. Defaults to the live host; override with OAUTH_REDIRECT_BASE.
-const redirectBase = () => env('OAUTH_REDIRECT_BASE') || 'https://marketing.designerwallcoverings.com';
+// in each app console. Defaults to the canonical live host (matches .env's
+// OAUTH_REDIRECT_BASE); override with OAUTH_REDIRECT_BASE.
+const redirectBase = () => env('OAUTH_REDIRECT_BASE') || 'https://marketing.agentabrams.com';
const redirectUri = (p) => `${redirectBase()}/api/channels/oauth/${p}/callback`;
// ── Self-serve setup (the Activate page) ──────────────────────────────────────
diff --git a/modules/constant-contact/index.js b/modules/constant-contact/index.js
index 4721413..5640704 100644
--- a/modules/constant-contact/index.js
+++ b/modules/constant-contact/index.js
@@ -112,6 +112,30 @@ function canSpamCheck(html = '') {
return { ok: missing.length === 0, hasUnsub, hasAddress, missing };
}
+// ── California §17529.5 / CAN-SPAM deceptive-subject + sender screen ───────────
+// §17529.5 makes it unlawful to send commercial email with a misleading subject
+// line or falsified/inaccurate header ("From") info. HARD flags block the send
+// (clearly deceptive); SOFT flags are advisory (spammy but not per-se unlawful).
+function subjectScreen(subject = '') {
+ const s = String(subject).trim();
+ const hard = [], soft = [];
+ if (!s) hard.push('empty subject line');
+ const letters = s.replace(/[^a-z]/gi, '');
+ if (letters.length >= 6 && letters === letters.toUpperCase()) hard.push('subject is ALL-CAPS (deceptive / shouting)');
+ if (/^\s*(re|fw|fwd)\s*:/i.test(s)) hard.push('fake reply/forward prefix (RE:/FW:) — deceptive header');
+ if (/\b(you(’|')?ve? (won|been selected)|congratulations[, ]+you|claim your (free )?(prize|gift|reward)|you are (a|the) winner)\b/i.test(s)) hard.push('deceptive prize/winner claim');
+ if (/!{2,}|\?!|!\?/.test(s)) soft.push('excessive punctuation');
+ if (/\b(free|act now|urgent|limited time|last chance|risk[\s-]?free|100%|guarantee(d)?|click here|buy now|order now)\b/i.test(s)) soft.push('spam-trigger wording');
+ if (/(\$\s?\d|\d+%\s*off)/.test(s)) soft.push('price/discount in subject line');
+ if (/[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}]/u.test(s)) soft.push('emoji in subject line');
+ return { ok: hard.length === 0, hard, soft, flags: [...hard, ...soft] };
+}
+function fromScreen(fromEmail = '') {
+ const e = String(fromEmail).trim();
+ const valid = /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(e);
+ return { ok: valid, fromEmail: e, reason: valid ? null : 'From email is missing or malformed — accurate sender info is required (CAN-SPAM / §17529.5)' };
+}
+
// ── MOCK DATA (realistic shapes, used whenever no access token) ────────────────
const MOCK = {
campaigns: [
@@ -258,13 +282,15 @@ function mount(router) {
if (missing.length) return res.status(400).json({ error: `missing fields: ${missing.join(', ')}` });
const compliance = canSpamCheck(html);
+ const subjectScan = subjectScreen(subject);
+ const fromScan = fromScreen(from);
if (!live()) {
const id = `mock-draft-${Date.now().toString(36)}`;
return res.json({
mock: true, ok: true, draft_id: id, status: 'DRAFT',
message: `Draft “${name}” saved (mock). Not sent.`,
- compliance,
+ compliance, subjectScan, fromScan,
});
}
@@ -286,7 +312,7 @@ function mount(router) {
draft_id: created.campaign_id || created.id,
status: created.current_status || 'DRAFT',
message: `Draft “${name}” created. Not sent.`,
- compliance,
+ compliance, subjectScan, fromScan,
});
}));
@@ -294,7 +320,7 @@ function mount(router) {
// unless CAN-SPAM fields are present. NEVER sends without confirm && !dryRun.
router.post('/schedule', guard(async (req, res) => {
const b = req.body || {};
- const { draft_id, scheduled_time, html } = b;
+ const { draft_id, scheduled_time, html, subject, from } = b;
const confirm = b.confirm === true;
const dryRun = b.dryRun !== false; // default true → dry-run unless explicitly false
@@ -302,11 +328,26 @@ function mount(router) {
// CAN-SPAM gate first — block before anything else if non-compliant.
const compliance = canSpamCheck(html || '');
+ const subjectScan = subjectScreen(subject || '');
+ const fromScan = fromScreen(from || '');
if (!compliance.ok) {
return res.status(422).json({
ok: false, blocked: true, reason: 'CAN-SPAM',
message: `Refusing to schedule: campaign HTML is missing ${compliance.missing.join(' + ')}. ${brand.compliance}`,
- compliance,
+ compliance, subjectScan, fromScan,
+ });
+ }
+
+ // California §17529.5 gate — block on a deceptive subject or bad "From".
+ // (Only enforced when subject/from are supplied; soft flags are advisory.)
+ const ca = [];
+ if (subject != null && !subjectScan.ok) ca.push(...subjectScan.hard);
+ if (from != null && !fromScan.ok) ca.push(fromScan.reason);
+ if (ca.length) {
+ return res.status(422).json({
+ ok: false, blocked: true, reason: 'CA-17529.5',
+ message: `Refusing to schedule (CA §17529.5): ${ca.join('; ')}. Fix the subject line / From address and re-submit.`,
+ compliance, subjectScan, fromScan,
});
}
@@ -321,11 +362,12 @@ function mount(router) {
// Dry-run (default): validate + echo what WOULD happen. No outbound call.
if (dryRun) {
+ const softWarn = subjectScan.soft.length ? ` Subject advisories: ${subjectScan.soft.join(', ')}.` : '';
return res.json({
ok: true, dryRun: true, wouldSchedule: true,
draft_id, scheduled_time: scheduled_time || 'now',
- message: `DRY RUN — would schedule draft ${draft_id} for ${scheduled_time || 'immediate'} send. Nothing was sent. Re-submit with dryRun:false to commit.`,
- compliance,
+ message: `DRY RUN — would schedule draft ${draft_id} for ${scheduled_time || 'immediate'} send. Nothing was sent. Re-submit with dryRun:false to commit.${softWarn}`,
+ compliance, subjectScan, fromScan,
});
}
@@ -406,4 +448,6 @@ module.exports = {
mount,
// exported for any internal reuse / testing
_canSpamCheck: canSpamCheck,
+ _subjectScreen: subjectScreen,
+ _fromScreen: fromScreen,
};
diff --git a/modules/linkedin/index.js b/modules/linkedin/index.js
index db386e3..6e7228c 100644
--- a/modules/linkedin/index.js
+++ b/modules/linkedin/index.js
@@ -26,6 +26,7 @@ const TEMPLATES = [
{ id: 'insight', name: 'Trade insight', body: 'One thing most {audience} get wrong about {topic}:\n\n{insight}\n\nHere’s how we approach it at Designer Wallcoverings.' },
{ id: 'feature', name: 'Product feature', body: 'Material spotlight: {product}.\n\nWhy it works: {benefit1}; {benefit2}; {benefit3}.\n\nSpecifying for a project? Let’s talk.' },
{ id: 'lesson', name: 'Lesson from an install', body: 'A lesson from a recent install:\n\n{lesson}\n\nSmall detail, big difference.' },
+ { id: 'testimonial', name: 'Specifier testimonial', body: '“{quote}”\n\nThat’s from {role} on {project}, after specifying {product}.\n\nNothing means more than a designer trusting us on a project. Thank you, {name}.' },
];
// Connected only when a token + an author/org URN are set (Phase 2).
diff --git a/public/panels/assets.js b/public/panels/assets.js
index c8b6714..fc6509c 100644
--- a/public/panels/assets.js
+++ b/public/panels/assets.js
@@ -6,6 +6,19 @@ window.MCC_PANELS['assets'] = {
init(root) {
const $ = s => root.querySelector(s);
const $$ = s => root.querySelectorAll(s);
+ // Origin-absolute fetch — avoids the "URL includes credentials" throw when
+ // the page is opened with embedded basic-auth creds (matches channels/linkedin).
+ const ORIGIN = location.origin;
+ const fetchO = (u, opts) => fetch((typeof u === 'string' && u[0] === '/') ? ORIGIN + u : u, Object.assign({ credentials: 'same-origin' }, opts));
+
+ // Canonical Kravet-family block — fetched from the server (/repost-policy) so
+ // the client modal enforces the EXACT same rule the server gate does. Baked
+ // fallback mirrors lib/kravet.js in case the policy fetch fails.
+ let KRAVET_RE = /kravet|lee[\s_.-]*jofa|groundworks|brunschwig|cole[\s_.-]*(?:&|and)?[\s_.-]*son|gp[\s_.-]*(?:&|and)?[\s_.-]*j?[\s_.-]*baker|colefax|clarke|mulberry|threads|baker[\s_.-]*lifestyle|andrew[\s_.-]*martin|nicolette[\s_.-]*mayer|aerin|barclay[\s_.-]*butera|thom[\s_.-]*filicia/i;
+ fetchO('/api/assets/repost-policy').then(r => r.json())
+ .then(p => { if (p && p.kravetPattern) KRAVET_RE = new RegExp(p.kravetPattern, p.kravetFlags || 'i'); })
+ .catch(() => { /* keep baked fallback */ });
+ const isKravetClient = (...ss) => ss.some(s => s && KRAVET_RE.test(String(s)));
const libGrid = $('#as-lib-grid');
const libEmpty = $('#as-lib-empty');
@@ -69,7 +82,7 @@ window.MCC_PANELS['assets'] = {
libGrid.querySelectorAll('[data-repost]').forEach(b => b.addEventListener('click', () => openRepost(b.dataset.repost)));
}
async function loadLibrary() {
- try { allAssets = (await (await fetch('/api/assets/list')).json()).assets || []; }
+ try { allAssets = (await (await fetchO('/api/assets/list')).json()).assets || []; }
catch (e) { setStatus('Could not load library: ' + e.message, true); }
renderLibrary();
}
@@ -106,7 +119,7 @@ window.MCC_PANELS['assets'] = {
async function del(id) {
try {
- const r = await fetch('/api/assets/' + encodeURIComponent(id), { method: 'DELETE' });
+ const r = await fetchO('/api/assets/' + encodeURIComponent(id), { method: 'DELETE' });
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || r.status);
loadLibrary();
} catch (e) { setStatus('Delete failed: ' + e.message, true); }
@@ -129,7 +142,7 @@ window.MCC_PANELS['assets'] = {
setStatus(`Uploading ${f.name}… (${ok + 1}/${files.length})`);
try {
const dataUrl = await readAsDataURL(f);
- const r = await fetch('/api/assets/upload', {
+ const r = await fetchO('/api/assets/upload', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: f.name.replace(/\.[^.]+$/, ''), dataUrl }),
});
@@ -152,7 +165,7 @@ window.MCC_PANELS['assets'] = {
const name = $('#as-url-name').value.trim();
if (!url) { setStatus('Paste an image URL first.', true); return; }
try {
- const r = await fetch('/api/assets/add-url', {
+ const r = await fetchO('/api/assets/add-url', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url, name }),
});
@@ -172,7 +185,7 @@ window.MCC_PANELS['assets'] = {
catStatus.textContent = 'Searching the live DW catalog…';
catGrid.innerHTML = '';
try {
- const r = await fetch('/api/assets/catalog?limit=80&q=' + encodeURIComponent(q));
+ const r = await fetchO('/api/assets/catalog?limit=80&q=' + encodeURIComponent(q));
const d = await r.json();
if (!r.ok) throw new Error(d.error || r.status);
catStatus.textContent = `${d.products.length} of ${d.total} match${d.total === 1 ? '' : 'es'}${d.cached ? ' · cached' : ''}. Click an image to save it to your library.`;
@@ -190,7 +203,7 @@ window.MCC_PANELS['assets'] = {
const url = card.dataset.url, name = card.dataset.name;
card.style.outline = '2px solid var(--gold)';
try {
- const r = await fetch('/api/assets/save-catalog', {
+ const r = await fetchO('/api/assets/save-catalog', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url, name }),
});
@@ -210,7 +223,7 @@ window.MCC_PANELS['assets'] = {
if (!path) { setStatus('Enter a Drive folder path first.', true); return; }
setStatus(`Importing images from “${path}”… (this can take a minute)`);
try {
- const r = await fetch('/api/assets/import-gdrive', {
+ const r = await fetchO('/api/assets/import-gdrive', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path, limit }),
});
@@ -225,7 +238,7 @@ window.MCC_PANELS['assets'] = {
const shareLink = $('#as-gd-link').value.trim();
if (!shareLink) { setStatus('Paste a Drive file share-link first.', true); return; }
try {
- const r = await fetch('/api/assets/add-drive', {
+ const r = await fetchO('/api/assets/add-drive', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ shareLink }),
});
@@ -242,7 +255,7 @@ window.MCC_PANELS['assets'] = {
if (!html) { setStatus('Paste campaign HTML first.', true); return; }
setStatus('Harvesting image URLs…');
try {
- const r = await fetch('/api/assets/harvest', {
+ const r = await fetchO('/api/assets/harvest', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ html }),
});
@@ -259,7 +272,7 @@ window.MCC_PANELS['assets'] = {
if (!url) { setStatus('Paste an Instagram post URL first.', true); return; }
setStatus('Fetching Instagram image…');
try {
- const r = await fetch('/api/assets/import-ig-url', {
+ const r = await fetchO('/api/assets/import-ig-url', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url }),
});
@@ -270,18 +283,17 @@ window.MCC_PANELS['assets'] = {
loadLibrary();
} catch (e) { setStatus('Could not grab IG image: ' + e.message, true); }
});
- // ── Vendor IG roster with regram status ─────────────────────────────────────
- const KRAVET = /kravet|lee jofa|groundworks|brunschwig|cole\s*&\s*son|gp\s*&?\s*j?\s*baker|colefax|clarke|mulberry|threads|baker lifestyle|andrew martin|nicolette mayer|aerin|barclay butera|thom filicia/i;
+ // ── Vendor IG roster with regram status (uses the canonical KRAVET_RE) ──────
function regramStatus(v) {
if (v.vendorCode === 'dw' || /our account/i.test(v.note || '')) return 'allow';
- if (KRAVET.test(v.brand || '') || KRAVET.test(v.vendorCode || '')) return 'block';
+ if (isKravetClient(v.brand, v.vendorCode)) return 'block';
return 'review';
}
const RG_COLOR = { allow: '#3a6b3a', review: '#8a6a2a', block: '#9a5a5a' };
async function loadVendorIG() {
const box = $('#as-ig-vendors'); if (!box) return;
let accounts = [];
- try { accounts = (await (await fetch('/api/vendors/accounts')).json()).accounts || []; }
+ try { accounts = (await (await fetchO('/api/vendors/accounts')).json()).accounts || []; }
catch { box.innerHTML = '<div class="muted" style="padding:12px">Could not load vendor roster.</div>'; return; }
const withIG = accounts.filter(a => a.hasIG && a.url);
$('#as-ig-vcount').textContent = withIG.length;
@@ -308,7 +320,7 @@ window.MCC_PANELS['assets'] = {
$('#as-cc-go').addEventListener('click', async () => {
setStatus('Importing from Constant Contact…');
try {
- const r = await fetch('/api/assets/import-cc-library', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
+ const r = await fetchO('/api/assets/import-cc-library', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
const d = await r.json();
if (!r.ok) { setStatus((d.needsToken ? '🔒 ' : '✗ ') + (d.error || r.status), true); return; }
setStatus(`Imported ${d.added} image(s) from Constant Contact.`);
@@ -317,7 +329,6 @@ window.MCC_PANELS['assets'] = {
});
// ── Repost (gated, stage-only) modal ────────────────────────────────────────
- const KRAVET_RE = /kravet|lee\s*jofa|groundworks|brunschwig|cole.*son|gp.*baker|colefax|clarke|mulberry|baker lifestyle|andrew martin|nicolette mayer|barclay butera|thom filicia/i;
const deWP = s => String(s || '').replace(/\bwallpapers\b/gi, 'wallcoverings').replace(/\bwallpaper\b/gi, 'wallcovering');
const modal = document.createElement('div');
modal.id = 'as-repost-modal';
@@ -357,6 +368,23 @@ window.MCC_PANELS['assets'] = {
rp('#rp-x').onclick = closeRepost;
modal.addEventListener('click', e => { if (e.target === modal) closeRepost(); });
let rpAsset = null;
+ // Re-evaluate the brand-block on every relevant change. Checks handle + caption
+ // + asset name + tags (same surface the server gate enforces) and DISABLES the
+ // Stage button when blocked — the block is enforced, not just advisory.
+ function recheckBlock() {
+ if (!rpAsset) return;
+ const handle = rp('#rp-handle').value.trim();
+ const caption = rp('#rp-caption').value;
+ const blocked = isKravetClient(handle, caption, rpAsset.name, ...(rpAsset.tags || []));
+ const stage = rp('#rp-stage');
+ stage.disabled = blocked;
+ stage.style.opacity = blocked ? '.5' : '';
+ stage.style.cursor = blocked ? 'not-allowed' : '';
+ stage.title = blocked ? 'Blocked — brand-controlled (Kravet family) content cannot be reposted' : '';
+ rp('#rp-warn').innerHTML = blocked
+ ? `<span style="color:#9a5a5a;font-weight:600;">⚠ Brand-controlled (Kravet family) — reposting is blocked.</span>`
+ : ((rpAsset.tags || []).includes('uncleared') ? 'Pulled image — confirm rights before reposting.' : '');
+ }
function openRepost(id) {
rpAsset = allAssets.find(a => a.id === id); if (!rpAsset) return;
const handle = (rpAsset.tags || []).find(t => /^@/.test(t)) || '';
@@ -366,11 +394,11 @@ window.MCC_PANELS['assets'] = {
rp('#rp-caption').value = deWP(`${rpAsset.name || ''} — available at Designer Wallcoverings.`);
rp('#rp-rights').checked = false; rp('#rp-paid').checked = false;
rp('#rp-msg').textContent = '';
- rp('#rp-warn').innerHTML = handle && KRAVET_RE.test(handle)
- ? `<span style="color:#9a5a5a;font-weight:600;">⚠ ${esc(handle)} is brand-controlled (Kravet family) — reposting is blocked.</span>`
- : ((rpAsset.tags || []).includes('uncleared') ? 'Pulled image — confirm rights before reposting.' : '');
+ recheckBlock();
modal.style.display = 'flex';
}
+ rp('#rp-handle').addEventListener('input', recheckBlock);
+ rp('#rp-caption').addEventListener('input', recheckBlock);
rp('#rp-stage').onclick = async () => {
const body = {
id: rpAsset && rpAsset.id,
@@ -381,7 +409,7 @@ window.MCC_PANELS['assets'] = {
};
rp('#rp-msg').textContent = 'Staging…';
try {
- const r = await fetch('/api/assets/repost', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
+ const r = await fetchO('/api/assets/repost', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
const d = await r.json();
if (!r.ok) { rp('#rp-msg').textContent = '✗ ' + (d.error || r.status); rp('#rp-msg').style.color = '#c0563f'; return; }
rp('#rp-msg').textContent = '✓ Staged (not posted). ' + (d.note || ''); rp('#rp-msg').style.color = 'var(--mut)';
diff --git a/public/panels/constant-contact.js b/public/panels/constant-contact.js
index e775821..8dc75ec 100644
--- a/public/panels/constant-contact.js
+++ b/public/panels/constant-contact.js
@@ -5,7 +5,7 @@ window.MCC_PANELS = window.MCC_PANELS || {};
window.MCC_PANELS['constant-contact'] = {
init(root) {
const $ = s => root.querySelector(s);
- const api = (p, opts) => fetch(`/api/constant-contact${p}`, opts).then(r => r.json());
+ const api = (p, opts) => fetch(`${location.origin}/api/constant-contact${p}`, Object.assign({ credentials: 'same-origin' }, opts)).then(r => r.json());
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c =>
({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
const pct = n => `${(Number(n || 0) * 100).toFixed(1)}%`;
@@ -131,7 +131,7 @@ window.MCC_PANELS['constant-contact'] = {
try {
dry = await api('/schedule', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ draft_id: lastDraftId, html, confirm: true, dryRun: true }),
+ body: JSON.stringify({ draft_id: lastDraftId, html, subject: field('subject'), from: field('from'), confirm: true, dryRun: true }),
});
} catch (e) { setMsg(`Error: ${e.message}`, true); return; }
@@ -153,7 +153,7 @@ window.MCC_PANELS['constant-contact'] = {
try {
const r = await api('/schedule', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ draft_id: lastDraftId, scheduled_time: when || undefined, html, confirm: true, dryRun: false }),
+ body: JSON.stringify({ draft_id: lastDraftId, scheduled_time: when || undefined, html, subject: field('subject'), from: field('from'), confirm: true, dryRun: false }),
});
markMock(r.mock); banner();
showCompliance(r.compliance);
← da76726 MCC officer-review fixes: fail-closed auth (remove hardcoded
·
back to Marketing Command Center
·
MCC polish: publish-time caveats for half-wired channels (Ti 535807b →