2 Commits

17 changed files with 1063 additions and 127 deletions
@@ -0,0 +1,175 @@
-- Permissions UI cleanup — 2026-05-28
--
-- WHAT THIS DOES
-- Rewrites permissions.name and permissions.category for clarity.
-- Consolidates 17 categories down to 10. Renames ambiguous entries so
-- the User Management UI reads cleanly. Does NOT touch permissions.code,
-- so every existing route gate (backend requirePermission and frontend
-- Protected page=) and every row in user_permissions keeps working
-- without any code change or remapping.
--
-- WHAT THIS DOES *NOT* DO
-- No DROP/DELETE of any permission (except in the optional block at the
-- very bottom — commented out by default). No changes to permissions.code.
-- No INSERT of new permissions. No changes to other tables.
--
-- SAFETY
-- Wrapped in a transaction. Run end-to-end; if any row count is wrong,
-- ROLLBACK and inspect. The auth middleware caches the user's loaded
-- permissions for 60s — after this runs, names refresh on next cache
-- miss for the admin UI. user_permissions joins by id, so granted
-- permissions remain granted across renames.
BEGIN;
------------------------------------------------------------------------
-- 1. Category consolidation
------------------------------------------------------------------------
-- 1a. Orphan "Pages" (just the Settings page) → Pages/Settings
UPDATE permissions SET category = 'Pages/Settings'
WHERE code = 'access:settings';
-- 1b. Existing settings:* tab-access permissions stay under a renamed
-- "Settings Tabs" category (was "Settings") so it reads less ambiguously
-- next to "Pages/Settings" and "Write Actions".
UPDATE permissions SET category = 'Settings Tabs'
WHERE category = 'Settings';
-- 1c. Dashboard widget perms keep their codes but get a clearer category name.
UPDATE permissions SET category = 'Dashboard Widgets'
WHERE category = 'Dashboard Components';
-- 1d. Collapse the 7 single-member "new permission" categories
-- (Imports, Data, AI, Templates, Images, Audit, ACOT, Dashboard
-- [the dashboard-server one — distinct from Pages/Dashboard])
-- into a single "Write Actions" category.
UPDATE permissions SET category = 'Write Actions'
WHERE category IN ('Imports', 'Data', 'AI', 'Templates',
'Images', 'Audit', 'ACOT', 'Dashboard');
-- 1e. The "Admin" category (just Show Debug) stays as-is — single member
-- but conceptually distinct enough that bucketing under Write Actions
-- would muddy the meaning. Leaving it alone.
------------------------------------------------------------------------
-- 2. Name renames for clarity
------------------------------------------------------------------------
-- 2a. Distinguish "page that lets you do X" from "permission to do X"
-- by suffixing the page-access permissions with " (page)" where the
-- name otherwise collides with the corresponding write permission.
UPDATE permissions SET name = 'Import Products (page)'
WHERE code = 'access:import';
UPDATE permissions SET name = 'Product Editor (page)'
WHERE code = 'access:product_editor';
UPDATE permissions SET name = 'Bulk Edit (page)'
WHERE code = 'access:bulk_edit';
-- 2b. Settings tab-access perms get a uniform "Settings: X Tab" name so
-- they sort together and read as "what you're seeing access to" not
-- "the feature itself."
UPDATE permissions SET name = 'Settings: Data Management Tab'
WHERE code = 'settings:data_management';
UPDATE permissions SET name = 'Settings: Reusable Images Tab'
WHERE code = 'settings:library_management';
UPDATE permissions SET name = 'Settings: AI Prompts Tab'
WHERE code = 'settings:prompt_management';
UPDATE permissions SET name = 'Settings: Templates Tab'
WHERE code = 'settings:templates';
UPDATE permissions SET name = 'Settings: User Management Tab'
WHERE code = 'settings:user_management';
UPDATE permissions SET name = 'Settings: Audit Log Tab'
WHERE code = 'settings:audit_log';
UPDATE permissions SET name = 'Settings: Global Tab'
WHERE code = 'settings:global';
UPDATE permissions SET name = 'Settings: Products Tab'
WHERE code = 'settings:products';
UPDATE permissions SET name = 'Settings: Vendors Tab'
WHERE code = 'settings:vendors';
-- 2c. Write-action perms get verb-leading names so it's obvious what
-- granting them actually allows.
UPDATE permissions
SET name = 'Product Import: Upload & Submit',
description = 'Allows POST/PUT/DELETE on /api/import — image uploads, '
|| 'product submission, deletions, and generate-upc. Does NOT '
|| 'grant access to the Import Products page (access:import).'
WHERE code = 'product_import';
UPDATE permissions
SET name = 'Data Management: Run Operations',
description = 'Allows POST/PUT/DELETE on /api/csv — CSV operations, '
|| 'full updates, full resets. Does NOT grant access to the '
|| 'Data Management settings tab (settings:data_management).'
WHERE code = 'data_management';
UPDATE permissions
SET name = 'Reusable Images: Upload & Delete',
description = 'Allows uploads and deletions on /api/reusable-images. '
|| 'Distinct from product_import (which gates uploads inside '
|| 'the product import flow).'
WHERE code = 'image_admin';
UPDATE permissions
SET name = 'Templates: Create & Edit',
description = 'Allows POST/PUT/DELETE on /api/templates.'
WHERE code = 'templates_write';
UPDATE permissions
SET name = 'AI: Edit Prompts & Validation',
description = 'Allows write access to /api/ai-prompts and /api/ai-validation.'
WHERE code = 'ai_admin';
UPDATE permissions
SET name = 'Klaviyo: Clear Cache',
description = 'Allows POST /api/klaviyo/events/clearCache.'
WHERE code = 'klaviyo_admin';
UPDATE permissions
SET name = 'Meta: Mutate Campaigns',
description = 'Allows PATCH/POST on /api/meta/campaigns/*.'
WHERE code = 'meta_write';
------------------------------------------------------------------------
-- 3. Verification — should all return non-zero
------------------------------------------------------------------------
-- Uncomment to inspect before commit:
-- SELECT category, COUNT(*) FROM permissions GROUP BY category ORDER BY category;
-- SELECT code, name, category FROM permissions
-- WHERE category IN ('Write Actions', 'Settings Tabs', 'Pages/Settings')
-- ORDER BY category, name;
COMMIT;
------------------------------------------------------------------------
-- 4. OPTIONAL — drop unused "Reserved for future" codes
------------------------------------------------------------------------
-- These five codes are referenced only in their own description ("Reserved
-- for…") and appear in NO route gate, NO Protected page=, and NO frontend
-- permissions.includes() check. Verified 2026-05-28.
--
-- Run this block separately if you want to drop them. user_permissions has
-- ON DELETE CASCADE on permission_id is NOT configured (only on user_id),
-- so we must clear user_permissions rows first.
--
-- BEGIN;
-- DELETE FROM user_permissions
-- WHERE permission_id IN (SELECT id FROM permissions
-- WHERE code IN ('klaviyo_write', 'google_write',
-- 'typeform_write', 'acot_admin',
-- 'audit_read'));
-- DELETE FROM permissions
-- WHERE code IN ('klaviyo_write', 'google_write', 'typeform_write',
-- 'acot_admin', 'audit_read');
-- COMMIT;
+10 -1
View File
@@ -66,7 +66,16 @@ export function authenticate({ pool, secret = process.env.JWT_SECRET, kioskIps =
const kioskIpSet = parseKioskIps(kioskIps);
return async function authenticateMiddleware(req, res, next) {
if (kioskIpSet.size > 0 && kioskIpSet.has(req.ip)) {
// Kiosk IP bypass ONLY when no Authorization header was provided. A real
// user on the same network (e.g. logged-in staff sharing the office NAT)
// must keep their actual identity and permissions — otherwise this bypass
// silently downgrades them to the permissionless kiosk user and they get
// 403 on every gated route.
if (
kioskIpSet.size > 0 &&
kioskIpSet.has(req.ip) &&
!req.headers.authorization
) {
req.user = {
id: 'kiosk',
username: 'kiosk',
@@ -159,6 +159,25 @@ describe('authenticate middleware', () => {
expect(req.user.is_kiosk).toBeUndefined();
});
it('does NOT bypass when a Bearer token is present, even from a kiosk IP', async () => {
// A real user logged in from the same NAT'd network as the kiosk must
// keep their actual identity — otherwise the bypass silently strips
// their permissions and they 403 on gated routes.
const pool = makeFakePool({ 1: activeUser }, { 1: ['product_import'] });
const mw = authenticate({ pool, secret: SECRET, kioskIps: '203.0.113.7' });
const req = {
headers: { authorization: `Bearer ${validToken}` },
ip: '203.0.113.7',
};
const res = makeRes();
const next = vi.fn();
await mw(req, res, next);
expect(next).toHaveBeenCalledOnce();
expect(req.user.id).toBe(1);
expect(req.user.is_kiosk).toBeUndefined();
expect(req.user.permissions).toEqual(['product_import']);
});
it('does not bypass when KIOSK_IPS is empty, even if req.ip is undefined', async () => {
const pool = makeFakePool({ 1: activeUser });
const mw = authenticate({ pool, secret: SECRET, kioskIps: '' });
+125
View File
@@ -0,0 +1,125 @@
// apiv2-bridge — a SIDE-SERVICE that lets external apps post to the legacy PHP
// `/apiv2/*` API without a browser cookie.
//
// This is an ISOLATED, ADDITIVE router. It does not touch, wrap, or sit in the
// path of any existing flow:
// - The browser frontend keeps hitting Caddy's `/apiv2` -> PHP directly with
// its own session cookie. Unchanged.
// - This router is mounted at `/api/apiv2-bridge` (a deliberately distinct
// prefix that cannot be confused with the real `/apiv2`). It inherits the
// same user-JWT auth every `/api/*` route already enforces — the same token
// the product-import skill already sends.
//
// Flow: external app --(user JWT)--> /api/apiv2-bridge/<subpath>
// -> acotBackendSession swaps in a server-held PHP cookie
// -> POST backend.acherryontop.com/apiv2/<subpath>
// -> PHP JSON response relayed back verbatim.
//
// It is a constrained passthrough: only the known product/category/PO write
// surfaces are allowed, so a valid token can't be used to proxy arbitrary
// backend paths.
import express from 'express';
import { apiv2Request, checkSession, isHtmlResponse } from '../utils/acotBackendSession.js';
import { logger } from '../../shared/logging/logger.js';
const router = express.Router();
// Allowed `/apiv2/<...>` path prefixes. Covers product edits/imports, category
// creation, and purchase-order creation — the surfaces the skills need.
const ALLOWED_PREFIXES = ['product/', 'prod_cat/', 'po/'];
function isAllowed(subpath) {
return ALLOWED_PREFIXES.some((prefix) => subpath === prefix.slice(0, -1) || subpath.startsWith(prefix));
}
// Health/credential check: forces a fresh login and reports the result so an
// operator can validate ACOT_BACKEND_USERID/PIN without sending a real payload.
router.get('/_health', async (_req, res) => {
try {
const status = await checkSession();
res.json({ ok: status.authenticated, ...status });
} catch (err) {
logger.error({ err }, 'apiv2-bridge health check failed');
res.status(502).json({ ok: false, error: err.message });
}
});
// Reconstruct an outbound body + content-type from the already-parsed Express
// request. The global json/urlencoded parsers have consumed the raw stream, so
// we re-serialize from req.body to match what the caller sent.
function serializeBody(req) {
const incoming = (req.headers['content-type'] || '').toLowerCase();
if (incoming.includes('application/json')) {
return { contentType: 'application/json; charset=utf-8', body: JSON.stringify(req.body ?? {}) };
}
if (incoming.includes('application/x-www-form-urlencoded')) {
// The real payloads use flat string fields (e.g. `products`/`items` are
// already JSON strings), which round-trip cleanly through URLSearchParams.
const params = new URLSearchParams();
for (const [key, value] of Object.entries(req.body ?? {})) {
params.append(key, typeof value === 'string' ? value : JSON.stringify(value));
}
return { contentType: 'application/x-www-form-urlencoded; charset=utf-8', body: params.toString() };
}
// No/own content-type: forward as-is (string or undefined).
return { contentType: incoming || undefined, body: req.body };
}
// Single passthrough handler for every allowed apiv2 subpath. The wildcard
// captures everything after the mount point (e.g. `product/setup_new`).
router.all('/*', async (req, res, next) => {
// `req.params[0]` is the wildcard capture; strip any leading slash.
const subpath = String(req.params[0] || '').replace(/^\/+/, '');
if (!subpath || subpath.startsWith('_')) return next();
if (!isAllowed(subpath)) {
return res.status(403).json({
error: 'Path not allowed through the apiv2 bridge',
allowed: ALLOWED_PREFIXES,
});
}
// Preserve the original query string (e.g. `?use_test_data_source=1`).
const qIndex = req.originalUrl.indexOf('?');
const query = qIndex === -1 ? '' : req.originalUrl.slice(qIndex + 1);
const { contentType, body } = serializeBody(req);
try {
const upstream = await apiv2Request({
method: req.method,
path: `/apiv2/${subpath}`,
query,
contentType,
body,
});
// If PHP still served the HTML login page (re-login inside apiv2Request
// couldn't recover the session), don't relay it as a misleading 200 — the
// caller would have to sniff HTML to notice. Surface it as a clear 502.
if (isHtmlResponse(upstream.data)) {
logger.error({ subpath, status: upstream.status }, 'apiv2-bridge got HTML (PHP session unavailable)');
return res.status(502).json({
error: 'Backend session unavailable',
message: 'The PHP backend returned its login page — the service account session could not be established. Check /api/apiv2-bridge/_health.',
});
}
logger.info(
{ user: req.user?.username, method: req.method, subpath, status: upstream.status },
'apiv2-bridge relayed request'
);
res.status(upstream.status).type(upstream.contentType).send(upstream.data);
} catch (err) {
logger.error({ err, subpath }, 'apiv2-bridge relay failed');
res.status(502).json({ error: 'Upstream backend request failed', message: err.message });
}
});
export default router;
+5
View File
@@ -37,6 +37,7 @@ import productEditorAuditLogRouter from './routes/product-editor-audit-log.js';
import newsletterRouter from './routes/newsletter.js';
import linesAggregateRouter from './routes/linesAggregate.js';
import repeatOrdersRouter from './routes/repeat-orders.js';
import apiv2BridgeRouter from './routes/apiv2-bridge.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -142,6 +143,10 @@ async function startServer() {
app.use('/api/newsletter', newsletterRouter);
app.use('/api/lines-aggregate', linesAggregateRouter);
app.use('/api/repeat-orders', repeatOrdersRouter);
// Side-service: lets external apps (import / product-edit skills) post to the
// legacy PHP /apiv2 API without a browser cookie. Isolated, additive router;
// inherits the same user-JWT auth applied to /api above. See apiv2-bridge.js.
app.use('/api/apiv2-bridge', apiv2BridgeRouter);
app.get('/health', (req, res) => {
res.json({
@@ -0,0 +1,285 @@
// Server-side session manager for the legacy PHP backend (backend.acherryontop.com).
//
// WHY THIS EXISTS
// ---------------
// The PHP `/apiv2/*` write endpoints (product edit, image_changes, taxonomy
// set, setup_new, prod_cat/new, po/new, po/add_products) authenticate via a
// PHP session cookie. In the browser the frontend gets that for free with
// `credentials: 'include'` because the user is logged into
// backend.acherryontop.com in the same browser — that flow is unchanged and
// this module does NOT touch it.
//
// This module backs a separate SIDE-SERVICE (see routes/apiv2-bridge.js) that
// lets EXTERNAL apps (the product-import skill, a future product-edit skill)
// post to the PHP API without a browser cookie. The inventory-server holds the
// PHP session itself: it logs in once with a SERVICE account, caches every
// cookie the backend hands back, and replays them on outbound `/apiv2/*`
// requests. Callers authenticate to the inventory-server with the normal user
// JWT; this PHP-session layer is invisible to them.
//
// LOGIN FLOW (discovered from the live login page)
// ------------------------------------------------
// 1. GET /login -> sets affinity/session cookies + embeds a
// hidden `anti_csrf` token in the form HTML.
// 2. POST /login/login -> { anti_csrf, userid, pin, after_login:'',
// Submit:'Submit' } with the cookies from (1).
// On success the backend rotates/sets the
// authenticated session cookie.
//
// EXPIRY DETECTION
// ----------------
// Unauthenticated `/apiv2/*` calls return HTTP 200 with an HTML body (the login
// page), NOT a 401. So we sniff the response body the same way the frontend
// does (isHtmlResponse) and treat an HTML payload as "session dead" -> re-login
// once and retry.
import axios from 'axios';
import { logger } from '../../shared/logging/logger.js';
const BASE_URL = (process.env.ACOT_BACKEND_URL || 'https://backend.acherryontop.com').replace(/\/$/, '');
const USERID = process.env.ACOT_BACKEND_USERID;
const PIN = process.env.ACOT_BACKEND_PIN;
const USER_AGENT = 'inventory-server/apiv2-bridge';
// Single host => a simple name->value map is a sufficient cookie jar. We do NOT
// hardcode any cookie name: the backend sets a load-balancer affinity cookie
// (`S=...`) AND the real session cookie, and both must be replayed for the
// session to stick. Capturing every Set-Cookie generically handles both.
let cookieJar = new Map();
// Mutex: collapse concurrent login attempts into one in-flight promise so a
// burst of bridged requests doesn't trigger N parallel logins.
let loginInFlight = null;
function cookieHeader() {
return Array.from(cookieJar.entries())
.map(([name, value]) => `${name}=${value}`)
.join('; ');
}
function storeSetCookies(setCookieHeaders) {
if (!setCookieHeaders) return;
const list = Array.isArray(setCookieHeaders) ? setCookieHeaders : [setCookieHeaders];
for (const raw of list) {
const first = String(raw).split(';')[0];
const eq = first.indexOf('=');
if (eq === -1) continue;
const name = first.slice(0, eq).trim();
const value = first.slice(eq + 1).trim();
if (!name) continue;
// A `name=deleted`/empty value means the backend is clearing the cookie.
if (value === '' || value.toLowerCase() === 'deleted') {
cookieJar.delete(name);
} else {
cookieJar.set(name, value);
}
}
}
export function isHtmlResponse(payload) {
if (typeof payload !== 'string') return false;
const trimmed = payload.trim().toLowerCase();
return trimmed.startsWith('<!doctype html') || trimmed.startsWith('<html');
}
// Find the `name=` of the first <input> whose `type=` matches, tolerant of
// attribute order and quote style.
function inputNameByType(html, type) {
const tags = String(html).match(/<input\b[^>]*>/gi) || [];
const typeRe = new RegExp(`type=['"]${type}['"]`, 'i');
for (const tag of tags) {
if (typeRe.test(tag)) {
const nm = /name=['"]([^'"]+)['"]/i.exec(tag);
if (nm) return nm[1];
}
}
return null;
}
function hasPasswordField(html) {
return /<input\b[^>]*type=['"]password['"]/i.test(String(html));
}
async function performLogin() {
if (!USERID || !PIN) {
throw new Error(
'ACOT backend service credentials are not configured (set ACOT_BACKEND_USERID and ACOT_BACKEND_PIN)'
);
}
// Fresh jar for a clean login.
cookieJar = new Map();
// Step 1: GET the login page for cookies + the anti-CSRF token. The login is
// IP-based and serves DIFFERENT forms per network (office: userid/pin; outside:
// username/password), so we parse the served form rather than hardcoding field
// names — the credential pair maps to whatever identifier/secret fields appear.
const getRes = await axios.get(`${BASE_URL}/login`, {
maxRedirects: 0,
validateStatus: () => true,
headers: { 'User-Agent': USER_AGENT },
responseType: 'text',
transformResponse: (d) => d,
});
storeSetCookies(getRes.headers['set-cookie']);
const page = getRes.data || '';
const csrfMatch = /name=['"]anti_csrf['"][^>]*value=['"]([^'"]+)['"]/i.exec(page);
if (!csrfMatch) {
throw new Error('Could not locate anti_csrf token on the backend login page');
}
const antiCsrf = csrfMatch[1];
const actionMatch = /<form\b[^>]*action=['"]([^'"]+)['"]/i.exec(page);
const action = actionMatch ? actionMatch[1] : '/login/login';
const loginUrl = action.startsWith('http') ? action : `${BASE_URL}${action}`;
// Identifier field is the first text/email input; secret is the password input.
const idField = inputNameByType(page, 'text') || inputNameByType(page, 'email') || 'username';
const secretField = inputNameByType(page, 'password') || 'password';
// Step 2: POST credentials with the cookies from step 1.
const form = new URLSearchParams();
form.append('anti_csrf', antiCsrf);
form.append(idField, USERID);
form.append(secretField, PIN);
form.append('after_login', '');
form.append('Submit', 'Submit');
const postRes = await axios.post(loginUrl, form.toString(), {
maxRedirects: 0,
validateStatus: () => true,
headers: {
'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8',
'User-Agent': USER_AGENT,
Cookie: cookieHeader(),
},
responseType: 'text',
transformResponse: (d) => d,
});
storeSetCookies(postRes.headers['set-cookie']);
// Success heuristic: a failed login re-renders the login form (a page that
// still has a password field). A redirect, or any non-form response, is
// treated as success here — but the authoritative check is the /apiv2 probe in
// checkSession(), since the form may differ across deployments.
const redirected = postRes.status >= 300 && postRes.status < 400;
if (!redirected && hasPasswordField(postRes.data)) {
throw new Error('Backend login failed — credentials rejected (login form re-served)');
}
logger.info(
{ status: postRes.status, idField, secretField, cookies: cookieJar.size },
'acot backend session established'
);
}
async function ensureSession() {
if (cookieJar.size > 0) return;
if (!loginInFlight) {
loginInFlight = performLogin().finally(() => {
loginInFlight = null;
});
}
await loginInFlight;
}
async function forceRelogin() {
cookieJar = new Map();
if (!loginInFlight) {
loginInFlight = performLogin().finally(() => {
loginInFlight = null;
});
}
await loginInFlight;
}
/**
* Make an authenticated request to a PHP `/apiv2/...` path, transparently
* handling login and a single re-login-on-expiry retry.
*
* @param {object} opts
* @param {string} opts.method HTTP method (GET/POST/...).
* @param {string} opts.path Path beginning with `/apiv2/...`.
* @param {string} [opts.query] Raw query string (without leading `?`).
* @param {string} [opts.contentType] Outbound Content-Type header.
* @param {string|Buffer} [opts.body] Serialized request body.
* @returns {Promise<{status:number, contentType:string, data:string}>}
*/
export async function apiv2Request({ method, path, query, contentType, body }) {
await ensureSession();
const url = `${BASE_URL}${path}${query ? `?${query}` : ''}`;
const send = () =>
axios.request({
url,
method,
data: body,
maxRedirects: 0,
validateStatus: () => true,
responseType: 'text',
transformResponse: (d) => d,
headers: {
'User-Agent': USER_AGENT,
Cookie: cookieHeader(),
...(contentType ? { 'Content-Type': contentType } : {}),
},
});
let res = await send();
// Session expired / not actually authenticated -> the backend serves the
// HTML login page. Re-login once and retry.
if (isHtmlResponse(res.data)) {
logger.warn({ path }, 'acot apiv2 returned HTML (session expired) — re-logging in');
await forceRelogin();
res = await send();
}
return {
status: res.status,
contentType: res.headers['content-type'] || 'application/json',
data: typeof res.data === 'string' ? res.data : JSON.stringify(res.data),
};
}
/** Force a fresh login, then PROVE the session by probing `/apiv2`. A login
* that merely "doesn't show a login form" is not proof — the only reliable
* signal is that `/apiv2` answers with JSON rather than the HTML login page.
* The probe is a no-op: an empty product edit (`products=[]`) edits nothing.
* Reports cookie NAMES only (never values). Used by the bridge `_health` route. */
export async function checkSession() {
let loginError = null;
try {
await forceRelogin();
} catch (err) {
loginError = err.message;
}
let probe;
try {
probe = await apiv2Request({
method: 'POST',
path: '/apiv2/product/edit',
contentType: 'application/x-www-form-urlencoded; charset=utf-8',
body: 'products=%5B%5D', // products=[]
});
} catch (err) {
return {
authenticated: false,
error: `apiv2 probe failed: ${err.message}`,
loginError,
cookieNames: Array.from(cookieJar.keys()),
};
}
const servedLoginPage = isHtmlResponse(probe.data);
return {
authenticated: !servedLoginPage,
probeStatus: probe.status,
cookieNames: Array.from(cookieJar.keys()),
...(loginError ? { loginError } : {}),
sample: servedLoginPage
? '(received HTML login page — PHP rejected the session)'
: String(probe.data).slice(0, 160),
};
}
@@ -37,7 +37,7 @@ const sublinesCache = new Map<string, Promise<LineOption[]>>();
function fetchLinesCached(companyId: string, signal?: AbortSignal): Promise<LineOption[]> {
const cached = linesCache.get(companyId);
if (cached) return cached;
const p = axios
const p = apiClient
.get(`/api/import/product-lines/${companyId}`, { signal })
.then((res) => res.data as LineOption[])
.catch(() => {
@@ -51,7 +51,7 @@ function fetchLinesCached(companyId: string, signal?: AbortSignal): Promise<Line
function fetchSublinesCached(lineId: string, signal?: AbortSignal): Promise<LineOption[]> {
const cached = sublinesCache.get(lineId);
if (cached) return cached;
const p = axios
const p = apiClient
.get(`/api/import/sublines/${lineId}`, { signal })
.then((res) => res.data as LineOption[])
.catch(() => {
@@ -275,7 +275,7 @@ export function ProductEditForm({
originalImagesRef.current = initialImages;
} else {
setIsLoadingImages(true);
axios
apiClient
.get(`/api/import/product-images/${product.pid}`, { signal })
.then((res) => {
setProductImages(res.data);
@@ -285,7 +285,7 @@ export function ProductEditForm({
.finally(() => setIsLoadingImages(false));
}
axios
apiClient
.get(`/api/import/product-categories/${product.pid}`, { signal })
.then((res) => {
const cats: string[] = [];
@@ -607,14 +607,21 @@ const MatchColumnsStepComponent = <T extends string>({
headerValues,
onContinue,
onBack,
initialGlobalSelections
initialGlobalSelections,
initialColumns
}: MatchColumnsProps<T>): JSX.Element => {
const { fields, autoMapHeaders, autoMapSelectValues, autoMapDistance, translations } = useRsi<T>()
const queryClient = useQueryClient()
const [isLoading, setIsLoading] = useState(false)
const [isRefreshing, setIsRefreshing] = useState(false)
const [columns, setColumns] = useState<Columns<T>>(() => {
// Restoring from a previous visit (back-nav): reuse the prior mappings verbatim
// instead of re-deriving empty columns + auto-mapping.
if (initialColumns && initialColumns.length > 0) {
return initialColumns;
}
// Helper function to check if a column is completely empty
const isColumnEmpty = (columnIndex: number) => {
return data.every(row => {
@@ -622,14 +629,14 @@ const MatchColumnsStepComponent = <T extends string>({
return value === undefined || value === null || (typeof value === 'string' && value.trim() === '');
});
};
// Do not remove spread, it indexes empty array elements, otherwise map() skips over them
const allColumns = ([...headerValues] as string[]).map((value, index) => ({
type: ColumnType.empty as ColumnType.empty,
index,
header: value ?? ""
const allColumns = ([...headerValues] as string[]).map((value, index) => ({
type: ColumnType.empty as ColumnType.empty,
index,
header: value ?? ""
}));
// Filter out completely empty columns
return allColumns.filter(col => !isColumnEmpty(col.index)) as Columns<T>;
})
@@ -637,7 +644,10 @@ const MatchColumnsStepComponent = <T extends string>({
const [showAllColumns, setShowAllColumns] = useState(true)
const [expandedValues, setExpandedValues] = useState<number[]>([])
const [userCollapsedColumns, setUserCollapsedColumns] = useState<number[]>([])
const hasAutoMappedRef = useRef(false)
// When restoring prior columns, suppress the initial header auto-map so it doesn't
// overwrite the restored mappings (the ref guards within a mount; seeding it true
// covers the fresh-mount-on-back-nav case).
const hasAutoMappedRef = useRef(!!(initialColumns && initialColumns.length > 0))
// Toggle with immediate visual feedback
const toggleValueMappingOptimized = useCallback((columnIndex: number) => {
@@ -6,6 +6,12 @@ export type MatchColumnsProps<T extends string> = {
onContinue: (data: any[], rawData: RawData[], columns: Columns<T>, globalSelections?: GlobalSelections, useNewValidation?: boolean) => void
onBack?: () => void
initialGlobalSelections?: GlobalSelections
/**
* Previously-matched columns to restore when navigating back to this step.
* When provided, columns seed from these instead of re-deriving + auto-mapping,
* so the user's mappings/value-mappings/ignored/AI-supplemental state survive back-nav.
*/
initialColumns?: Columns<T>
}
export type GlobalSelections = {
@@ -8,13 +8,14 @@ import { mapWorkbook } from "../utils/mapWorkbook"
import { ValidationStep } from "./ValidationStep"
import { ImageUploadStep } from "./ImageUploadStep/ImageUploadStep"
import { MatchColumnsStep } from "./MatchColumnsStep/MatchColumnsStep"
import type { GlobalSelections } from "./MatchColumnsStep/types"
import type { GlobalSelections, Columns } from "./MatchColumnsStep/types"
import { exceedsMaxRecords } from "../utils/exceedsMaxRecords"
import { useRsi } from "../hooks/useRsi"
import type { RawData, Data } from "../types"
import { Progress } from "@/components/ui/progress"
import { useToast } from "@/hooks/use-toast"
import { addErrorsAndRunHooks } from "./ValidationStep/utils/dataMutations"
import { computeMappingSignature, type MappingSignature } from "./ValidationStep/utils/mappingSignature"
import { useValidationStore } from "./ValidationStep/store/validationStore"
import { useImportSession } from "@/contexts/ImportSessionContext"
import type { ImportSession } from "@/types/importSession"
@@ -52,6 +53,7 @@ export type StepState =
data: any[]
globalSelections?: GlobalSelections
isFromScratch?: boolean
mappingSignature?: MappingSignature
}
| {
type: StepType.validateDataNew
@@ -59,6 +61,7 @@ export type StepState =
file?: File
globalSelections?: GlobalSelections
isFromScratch?: boolean
mappingSignature?: MappingSignature
}
| {
type: StepType.imageUpload
@@ -129,6 +132,11 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
: undefined
)
// Keep the user's matched columns so navigating back to Match Columns restores them
// (UploadFlow never unmounts during step nav, so this survives back-nav — same pattern
// as persistedGlobalSelections above).
const [persistedColumns, setPersistedColumns] = useState<Columns<string> | undefined>(undefined)
// Import session context for session restoration
const { loadSession, setGlobalSelections: setSessionGlobalSelections } = useImportSession()
@@ -254,30 +262,36 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
data={state.data}
headerValues={state.headerValues}
initialGlobalSelections={persistedGlobalSelections}
initialColumns={persistedColumns}
onContinue={async (values, rawData, columns, globalSelections, useNewValidation) => {
try {
const data = await matchColumnsStepHook(values, rawData, columns)
const dataWithMeta = await addErrorsAndRunHooks(data, fields, rowHook, tableHook, undefined, { costIsTotalCost: globalSelections?.costIsTotalCost })
// Apply global selections to each row of data if they exist
const dataWithGlobalSelections = globalSelections
? dataWithMeta.map((row: Data<string> & { __index?: string }) => {
const newRow = { ...row } as any;
if (globalSelections.supplier) newRow.supplier = globalSelections.supplier;
if (globalSelections.company) newRow.company = globalSelections.company;
if (globalSelections.line) newRow.line = globalSelections.line;
if (globalSelections.subline) newRow.subline = globalSelections.subline;
return newRow;
})
: dataWithMeta;
// Apply global selections to each row of data if they exist, and stamp a
// stable positional id (__sourceRow) so the Validation store can align these
// freshly-mapped rows with previously-edited rows on back-nav re-entry.
const dataWithGlobalSelections = dataWithMeta.map(
(row: Data<string> & { __index?: string }, rowIndex: number) => {
const newRow = { ...row } as any;
newRow.__sourceRow = rowIndex;
if (globalSelections?.supplier) newRow.supplier = globalSelections.supplier;
if (globalSelections?.company) newRow.company = globalSelections.company;
if (globalSelections?.line) newRow.line = globalSelections.line;
if (globalSelections?.subline) newRow.subline = globalSelections.subline;
return newRow;
},
);
setPersistedGlobalSelections(globalSelections)
setPersistedColumns(columns)
// Route to new or old validation step based on user choice
onNext({
type: useNewValidation ? StepType.validateDataNew : StepType.validateData,
data: dataWithGlobalSelections,
globalSelections,
mappingSignature: computeMappingSignature(columns, globalSelections),
})
} catch (e) {
errorToast((e as Error).message)
@@ -293,6 +307,7 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
<ValidationStep
initialData={state.data}
file={uploadedFile || new File([], "empty.xlsx")}
mappingSignature={state.mappingSignature}
onBack={() => {
// If we started from scratch, we need to go back to the upload step
if (state.isFromScratch) {
@@ -924,7 +924,7 @@ const CellWrapper = memo(({
{/* Copy-down button - appears on hover, positioned to avoid error icons */}
{showCopyDownButton && (
<TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
@@ -1707,6 +1707,10 @@ const PriceColumnHeader = memo(({ fieldKey, label, isRequired, pinButton }: Pric
const isMsrp = fieldKey === 'msrp';
// Cost Each offers a per-row "divide by Min Qty" action that operates on the current
// row selection only — subscribe to the selection count to enable/disable it.
const selectedCount = useValidationStore((state) => (isMsrp ? 0 : state.selectedRows.size));
// Determine the source field
const sourceField = isMsrp ? 'cost_each' : 'msrp';
const tooltipText = isMsrp
@@ -1817,6 +1821,40 @@ const PriceColumnHeader = memo(({ fieldKey, label, isRequired, pinButton }: Pric
}
}, [fieldKey, sourceField, label]);
/**
* Divide cost_each by Min Qty (qty_per_unit) for the currently-SELECTED rows only.
* For cases where only some rows list a pack total but are sold individually.
* Mirrors the global "divide by min qty" math in dataMutations.ts.
*/
const handleDivideSelectedByMinQty = useCallback(() => {
const { selectedRows } = useValidationStore.getState();
const updatedIndices: number[] = [];
useValidationStore.setState((draft) => {
draft.rows.forEach((row, index) => {
if (!selectedRows.has(row.__index)) return;
const cost = parseFloat(String(row.cost_each ?? ''));
const qty = parseInt(String(row.qty_per_unit ?? ''), 10);
if (!isNaN(cost) && qty > 0) {
draft.rows[index].cost_each = (cost / qty).toFixed(2);
updatedIndices.push(index);
}
});
});
if (updatedIndices.length > 0) {
const { clearFieldError } = useValidationStore.getState();
updatedIndices.forEach((rowIndex) => {
clearFieldError(rowIndex, 'cost_each');
});
toast.success(`Divided ${updatedIndices.length} ${label} value${updatedIndices.length === 1 ? '' : 's'} by Min Qty`);
} else {
toast.error('No selected rows had both a cost and a Min Qty > 0');
}
setIsPopoverOpen(false);
}, [label]);
return (
<div
className="flex items-center gap-1 min-w-0 w-full group relative"
@@ -1831,7 +1869,7 @@ const PriceColumnHeader = memo(({ fieldKey, label, isRequired, pinButton }: Pric
)}
{/* Button group: pin button always visible when pinned, action buttons only on hover */}
<div className="absolute right-0 top-1/2 -translate-y-1/2 flex items-center gap-1">
{(isHovered || isPopoverOpen) && hasFillableCells && (
{(isHovered || isPopoverOpen) && (hasFillableCells || (!isMsrp && selectedCount > 0)) && (
isMsrp ? (
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
<TooltipProvider>
@@ -1905,29 +1943,64 @@ const PriceColumnHeader = memo(({ fieldKey, label, isRequired, pinButton }: Pric
</PopoverContent>
</Popover>
) : (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<button
type="button"
onClick={(e) => e.stopPropagation()}
className={cn(
'flex items-center gap-0.5',
'rounded border border-input bg-background p-1 shadow-sm',
'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
)}
>
<Calculator className="h-3 w-3" />
</button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>Calculate Cost Each</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<PopoverContent className="w-60 p-3" align="end">
<div className="space-y-3">
<p className="text-xs font-medium text-muted-foreground">
Calculate Cost Each
</p>
<Button
size="sm"
variant="outline"
className="w-full h-7 text-xs"
disabled={!hasFillableCells}
onClick={() => {
handleCalculateCostEach();
setIsPopoverOpen(false);
}}
className={cn(
'flex items-center gap-0.5',
'rounded border border-input bg-background p-1 shadow-sm',
'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
)}
>
<Calculator className="h-3 w-3" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>{tooltipText}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
Fill from MSRP ÷ 2
</Button>
<div className="border-t pt-3 space-y-1.5">
<Button
size="sm"
className="w-full h-7 text-xs"
disabled={selectedCount === 0}
onClick={handleDivideSelectedByMinQty}
>
Divide selected by Min Qty
</Button>
<p className="text-xs text-muted-foreground">
{selectedCount > 0
? `Divides cost ÷ Min Qty for ${selectedCount} selected row${selectedCount === 1 ? '' : 's'}.`
: 'Select rows first to divide their cost by Min Qty.'}
</p>
</div>
</div>
</PopoverContent>
</Popover>
)
)}
{pinButton}
@@ -8,7 +8,7 @@
* 4. Renders the ValidationContainer once initialized
*/
import { useEffect, useRef, useDeferredValue, useMemo } from 'react';
import { useEffect, useRef, useDeferredValue } from 'react';
import { apiFetch } from '@/utils/api';
import { useQuery } from '@tanstack/react-query';
import { useValidationStore } from './store/validationStore';
@@ -22,39 +22,10 @@ import { useProductLines } from './hooks/useProductLines';
import { useAutoInlineAiValidation } from './hooks/useAutoInlineAiValidation';
import { BASE_IMPORT_FIELDS } from '../../config';
import config from '@/config';
import type { ValidationStepProps } from './store/types';
import { diffMappingSignatures } from './utils/mappingSignature';
import type { ValidationStepProps, RowData } from './store/types';
import type { Field, SelectOption } from '../../types';
/**
* Create a fingerprint of the data to detect changes.
* This is used to determine if we need to re-initialize the store
* when navigating back to this step with potentially modified data.
*/
const createDataFingerprint = (data: Record<string, unknown>[]): string => {
// Sample key fields that are likely to change when user modifies data in previous steps
const keyFields = ['supplier', 'company', 'line', 'subline', 'name', 'upc', 'item_number'];
// Create a simple hash from first few rows + last row + count
const sampleSize = Math.min(3, data.length);
const samples: string[] = [];
// First few rows
for (let i = 0; i < sampleSize; i++) {
const row = data[i];
const values = keyFields.map(k => String(row[k] ?? '')).join('|');
samples.push(values);
}
// Last row (if different from samples)
if (data.length > sampleSize) {
const lastRow = data[data.length - 1];
const values = keyFields.map(k => String(lastRow[k] ?? '')).join('|');
samples.push(values);
}
return `${data.length}:${samples.join(';;')}`;
};
/**
* Fetch field options from the API
*/
@@ -122,6 +93,7 @@ export const ValidationStep = ({
onBack,
onNext,
isFromScratch,
mappingSignature,
}: ValidationStepProps) => {
const initPhase = useInitPhase();
const isReady = useIsReady();
@@ -136,7 +108,6 @@ export const ValidationStep = ({
const templatesLoadedRef = useRef(false);
const upcValidationStartedRef = useRef(false);
const fieldValidationStartedRef = useRef(false);
const lastDataFingerprintRef = useRef<string | null>(null);
// Debug logging
console.log('[ValidationStep] Render - initPhase:', initPhase, 'isReady:', isReady);
@@ -146,6 +117,8 @@ export const ValidationStep = ({
const setFields = useValidationStore((state) => state.setFields);
const setFieldOptionsLoaded = useValidationStore((state) => state.setFieldOptionsLoaded);
const setInitPhase = useValidationStore((state) => state.setInitPhase);
const setMappingSignature = useValidationStore((state) => state.setMappingSignature);
const reconcileMappedData = useValidationStore((state) => state.reconcileMappedData);
// Initialization hooks
const { loadTemplates } = useTemplateManagement();
@@ -164,59 +137,78 @@ export const ValidationStep = ({
retry: 2,
});
// Create a fingerprint of the incoming data to detect changes
const dataFingerprint = useMemo(() => createDataFingerprint(initialData), [initialData]);
// Initialize store with data
// Initialize / reconcile store with data.
//
// Three cases, decided once per mount (refs are fresh because this component unmounts
// whenever another step is shown):
// A. Fresh store (not yet ready) -> full initialize() + phase chain
// B. Ready store, mapping unchanged -> skip everything (preserve all edits + UPC results)
// C. Ready store, mapping changed -> merge changed fields only, then re-run
// field validation (and UPC only if the
// supplier / UPC column mapping changed)
useEffect(() => {
console.log('[ValidationStep] Init effect running, initStartedRef:', initStartedRef.current, 'initPhase:', initPhase);
console.log('[ValidationStep] Data fingerprint:', dataFingerprint, 'Last fingerprint:', lastDataFingerprintRef.current);
// Check if data has changed since last initialization
const dataHasChanged = lastDataFingerprintRef.current !== null && lastDataFingerprintRef.current !== dataFingerprint;
if (dataHasChanged) {
console.log('[ValidationStep] Data has changed - forcing re-initialization');
// Reset all refs to allow re-initialization
initStartedRef.current = false;
templatesLoadedRef.current = false;
upcValidationStartedRef.current = false;
fieldValidationStartedRef.current = false;
}
// Skip if already initialized (check both ref AND store state)
// The ref prevents double-init within the same mount cycle
// Checking initPhase handles StrictMode remounts where store was initialized but ref persisted
if (initStartedRef.current && initPhase !== 'idle') {
console.log('[ValidationStep] Skipping init - already initialized');
if (initStartedRef.current) {
return;
}
// IMPORTANT: Skip initialization if we're returning to an already-ready store
// with the SAME data. This happens when navigating back from ImageUploadStep.
// We compare fingerprints to detect if the data has actually changed.
if (initPhase === 'ready' && !dataHasChanged && lastDataFingerprintRef.current === dataFingerprint) {
console.log('[ValidationStep] Skipping init - returning to already-ready store with same data');
const store = useValidationStore.getState();
const storeReady = store.initPhase === 'ready';
const incomingSig = mappingSignature ?? null;
// --- Case A: fresh store -> full initialization ---
if (!storeReady) {
initStartedRef.current = true;
console.log('[ValidationStep] Case A - full init with', initialData.length, 'rows');
const rowData = initialData.map((row, index) => ({
...row,
__index: row.__index || `row-${index}-${store.rows.length}`,
}));
initialize(rowData, BASE_IMPORT_FIELDS as unknown as Field<string>[], file);
setMappingSignature(incomingSig);
return;
}
// Returning to an already-ready store.
initStartedRef.current = true;
lastDataFingerprintRef.current = dataFingerprint;
console.log('[ValidationStep] Starting initialization with', initialData.length, 'rows');
// No signature (e.g. returning from ImageUpload, or restored/from-scratch flows) ->
// nothing about the mapping could have changed; keep the store as-is.
const diff = incomingSig
? diffMappingSignatures(store.mappingSignature, incomingSig)
: null;
// Convert initialData to RowData format
const rowData = initialData.map((row, index) => ({
...row,
__index: row.__index || `row-${index}-${Date.now()}`,
}));
// --- Case B: nothing relevant changed -> preserve everything ---
if (!diff || diff.equal) {
console.log('[ValidationStep] Case B - returning with unchanged mapping, preserving edits');
return;
}
// Start with base fields
console.log('[ValidationStep] Calling initialize()');
initialize(rowData, BASE_IMPORT_FIELDS as unknown as Field<string>[], file);
console.log('[ValidationStep] initialize() called');
}, [initialData, file, initialize, initPhase, dataFingerprint]);
// --- Case C: mapping changed -> merge changed fields, re-validate selectively ---
console.log('[ValidationStep] Case C - mapping changed', diff);
reconcileMappedData(initialData as RowData[], diff.changedFieldKeys, diff.clearedFieldKeys);
setMappingSignature(incomingSig);
// Re-run the relevant phase of the validation chain. The existing phase-chain effects
// pick these transitions up (their refs are fresh on this mount):
// - UPC inputs (supplier / upc column) changed -> re-run UPC + item-number, then fields
// - otherwise -> only re-run field validation (no UPC network calls)
if (diff.upcAffected) {
store.setInitialUpcValidationDone(false);
setInitPhase('validating-upcs');
} else {
setInitPhase('validating-fields');
}
}, [
initialData,
file,
initialize,
reconcileMappedData,
setMappingSignature,
setInitPhase,
mappingSignature,
]);
// Update fields when options are loaded
// CRITICAL: Check store state (not ref) because initialize() resets the store
@@ -6,6 +6,7 @@
*/
import type { Field, SelectOption, ErrorLevel } from '../../../types';
import type { MappingSignature } from '../utils/mappingSignature';
// =============================================================================
// Core Data Types
@@ -22,6 +23,7 @@ export interface RowData {
__corrected?: Record<string, unknown>; // AI-corrected values
__changes?: Record<string, boolean>; // Fields changed by AI
__aiSupplemental?: Record<string, string>; // AI supplemental columns from MatchColumnsStep (header -> value)
__sourceRow?: number; // Stable positional id from the raw spreadsheet (for back-nav merge)
// Standard fields (from config.ts)
supplier?: string;
@@ -395,6 +397,8 @@ export interface ValidationState {
// === Initialization ===
initPhase: InitPhase;
/** Mapping signature that produced the current rows (for back-nav merge decisions) */
mappingSignature: MappingSignature | null;
// === AI Validation ===
aiValidation: AiValidationState;
@@ -419,6 +423,17 @@ export interface ValidationActions {
initialize: (data: RowData[], fields: Field<string>[], file?: File) => Promise<void>;
setFields: (fields: Field<string>[]) => void;
setFieldOptionsLoaded: (loaded: boolean) => void;
setMappingSignature: (signature: MappingSignature | null) => void;
/**
* Merge freshly-mapped rows into the existing edited rows on back-nav re-entry.
* Only `changedFieldKeys` are overwritten (from the matching __sourceRow), and
* `clearedFieldKeys` are removed; all other fields keep their edits.
*/
reconcileMappedData: (
freshRows: RowData[],
changedFieldKeys: string[],
clearedFieldKeys: string[],
) => void;
// === Row Operations ===
updateCell: (rowIndex: number, field: string, value: unknown) => void;
@@ -538,4 +553,10 @@ export interface ValidationStepProps {
onBack?: () => void;
onNext?: (data: CleanRowData[]) => void;
isFromScratch?: boolean;
/**
* Signature describing how columns were mapped. Used on back-nav re-entry to decide
* whether to preserve edits (merge changed fields only) vs. fully re-initialize, and
* whether UPC validation needs to re-run. Undefined for from-scratch / restored flows.
*/
mappingSignature?: MappingSignature;
}
@@ -118,6 +118,7 @@ const getInitialState = (): ValidationState => ({
// Initialization
initPhase: 'idle',
mappingSignature: null,
// AI Validation
aiValidation: {
@@ -207,6 +208,67 @@ export const useValidationStore = create<ValidationStore>()(
});
},
setMappingSignature: (signature) => {
set((state) => {
state.mappingSignature = signature;
});
},
/**
* Merge freshly-mapped rows into the existing (edited) rows when returning from
* Match Columns. Aligns by __sourceRow so edits, selection, and __index are
* preserved; only fields whose mapping changed are overwritten, and fields that
* became unmapped are cleared. Rows deleted in Validation are not re-added.
*/
reconcileMappedData: (freshRows: RowData[], changedFieldKeys: string[], clearedFieldKeys: string[]) => {
if (changedFieldKeys.length === 0 && clearedFieldKeys.length === 0) return;
set((state) => {
// Price fields are stripped of $/commas on ingestion; mirror that here so
// re-pulled values match how they were cleaned during initialize().
const priceFieldKeys = new Set(
state.fields
.filter((f) => f.fieldType.type === 'input' && 'price' in f.fieldType && f.fieldType.price)
.map((f) => f.key),
);
const cleanValue = (key: string, value: unknown) =>
priceFieldKeys.has(key) && typeof value === 'string' && value !== ''
? stripPriceFormatting(value)
: value;
// Index fresh rows by their stable source-row id.
const freshBySource = new Map<number, RowData>();
for (const row of freshRows) {
if (typeof row.__sourceRow === 'number') {
freshBySource.set(row.__sourceRow, row);
}
}
state.rows.forEach((row, idx) => {
const fresh = typeof row.__sourceRow === 'number' ? freshBySource.get(row.__sourceRow) : undefined;
// Overwrite changed fields from the freshly-mapped source row.
if (fresh) {
for (const key of changedFieldKeys) {
const value = cleanValue(key, fresh[key]);
row[key] = value;
if (state.originalRows[idx]) {
state.originalRows[idx][key] = value;
}
}
}
// Clear fields that are no longer mapped.
for (const key of clearedFieldKeys) {
row[key] = undefined;
if (state.originalRows[idx]) {
state.originalRows[idx][key] = undefined;
}
}
});
});
},
// =========================================================================
// Row Operations
// =========================================================================
@@ -0,0 +1,139 @@
/**
* Mapping signature utilities
*
* When the user navigates back to Match Columns and forward again, we need to know
* *what* about the mapping actually changed so the Validation store can:
* - preserve edits for fields whose source did not change,
* - overwrite only the fields whose source did change,
* - re-run UPC validation / item-number generation only when the supplier or the
* UPC column mapping changed.
*
* A "signature" is a compact, comparable description of how every field is sourced.
*/
import { ColumnType, type Columns, type GlobalSelections } from '../../MatchColumnsStep/types';
export interface MappingSignature {
/** field key -> a string describing where that field's value comes from */
perField: Record<string, string>;
/** resolved supplier (global selection), surfaced for UPC re-run gating */
supplier: string;
}
/**
* Encode a single column's contribution to the signature.
* matchedOptions are folded in so re-mapping a select value counts as a change.
*/
const encodeColumn = (column: Columns<string>[number], costIsTotalCost?: boolean): string => {
let sig = `col:${column.index}:${column.type}`;
if ('matchedOptions' in column && Array.isArray(column.matchedOptions)) {
const opts = column.matchedOptions
.map((o) => `${o.entry ?? ''}=>${o.value ?? ''}`)
.join('|');
sig += `:opts(${opts})`;
}
// cost_each is post-processed by the "divide by min qty" flag, so the resulting
// value differs even when the source column is identical — fold the flag in.
if ('value' in column && column.value === 'cost_each' && costIsTotalCost) {
sig += ':total';
}
return sig;
};
/**
* Build a signature from the current columns + global selections.
*/
export const computeMappingSignature = (
columns: Columns<string>,
globalSelections?: GlobalSelections,
): MappingSignature => {
const perField: Record<string, string> = {};
for (const column of columns) {
if (
column.type === ColumnType.empty ||
column.type === ColumnType.ignored ||
column.type === ColumnType.aiSupplemental
) {
continue;
}
if ('value' in column) {
perField[column.value] = encodeColumn(column, globalSelections?.costIsTotalCost);
}
}
// Global selections act as the source for these fields when not column-mapped.
const globalKeys: (keyof GlobalSelections)[] = ['supplier', 'company', 'line', 'subline'];
for (const key of globalKeys) {
const val = globalSelections?.[key];
if (val && !perField[key]) {
perField[key] = `global:${String(val)}`;
}
}
return {
perField,
supplier: String(globalSelections?.supplier ?? ''),
};
};
export interface MappingDiff {
/** true when nothing relevant changed (safe to skip re-init entirely) */
equal: boolean;
/** fields whose source changed (or were newly mapped) — values should be re-pulled */
changedFieldKeys: string[];
/** fields that were mapped before but are now unmapped — values should be cleared */
clearedFieldKeys: string[];
/** whether UPC validation / item-number generation needs to re-run */
upcAffected: boolean;
}
const UPC_INPUT_FIELDS = ['supplier', 'upc', 'barcode'];
/**
* Compare two signatures and describe what changed.
*/
export const diffMappingSignatures = (
prev: MappingSignature | null | undefined,
next: MappingSignature,
): MappingDiff => {
// No previous signature => treat everything as changed (first-time init handles this
// path separately, but be safe).
if (!prev) {
return {
equal: false,
changedFieldKeys: Object.keys(next.perField),
clearedFieldKeys: [],
upcAffected: true,
};
}
const changedFieldKeys: string[] = [];
const clearedFieldKeys: string[] = [];
const allKeys = new Set([...Object.keys(prev.perField), ...Object.keys(next.perField)]);
for (const key of allKeys) {
const before = prev.perField[key];
const after = next.perField[key];
if (before === after) continue;
if (after === undefined) {
clearedFieldKeys.push(key);
} else {
changedFieldKeys.push(key);
}
}
const upcAffected = [...changedFieldKeys, ...clearedFieldKeys].some((k) =>
UPC_INPUT_FIELDS.includes(k),
);
return {
equal: changedFieldKeys.length === 0 && clearedFieldKeys.length === 0,
changedFieldKeys,
clearedFieldKeys,
upcAffected,
};
};
+4 -4
View File
@@ -109,7 +109,7 @@ export default function BulkEdit() {
// Load field options on mount (but don't auto-load products)
useEffect(() => {
axios
apiClient
.get("/api/import/field-options")
.then((res) => setFieldOptions(res.data))
.catch((err) => {
@@ -127,7 +127,7 @@ export default function BulkEdit() {
setSublineOptions([]);
if (!lineCompany) return;
setIsLoadingLines(true);
axios
apiClient
.get(`/api/import/product-lines/${lineCompany}`)
.then((res) => setLineOptions(res.data))
.catch(() => setLineOptions([]))
@@ -140,7 +140,7 @@ export default function BulkEdit() {
setSublineOptions([]);
if (!lineLine) return;
setIsLoadingSublines(true);
axios
apiClient
.get(`/api/import/sublines/${lineLine}`)
.then((res) => setSublineOptions(res.data))
.catch(() => setSublineOptions([]))
@@ -303,7 +303,7 @@ export default function BulkEdit() {
if (pidsNeedingImages.length === 0) return;
pidsNeedingImages.forEach((pid) => {
axios
apiClient
.get(`/api/import/product-images/${pid}`)
.then((res) => {
const images = res.data;
File diff suppressed because one or more lines are too long