Masters-four-Tab-OpenAI / frontend /src /utils /customerMemory.ts
Pete Dunn
Tighten Rapid Router validation and memory flows
5a58109
Raw
History Blame Contribute Delete
8.1 kB
export type SmartCustomerProfile = {
companyName?: string;
contactName?: string;
email?: string;
phone?: string;
street?: string;
suite?: string;
city?: string;
state?: string;
zip?: string;
updatedAt?: string;
};
export type ResumeWorkCard = {
id: string;
title: string;
subtitle?: string;
source: "rapid_router" | "pots_estimator" | "knowledgebase";
actionCommand: string;
updatedAt: string;
};
type CarryoverPayload = {
summary?: string;
updatedAt: string;
payload: Record<string, unknown>;
};
type CustomerMemoryState = {
profile?: SmartCustomerProfile;
cards?: ResumeWorkCard[];
carryover?: {
potsEstimator?: CarryoverPayload;
rapidRouterDraft?: CarryoverPayload;
};
};
const CUSTOMER_MEMORY_NAMESPACE = "masters_toolkit_customer_memory_v2";
const CUSTOMER_MEMORY_SCOPE_FALLBACK = "anonymous";
const MAX_CARD_COUNT = 8;
const MAX_TEXT_LEN = 320;
let activeCustomerMemoryScope = CUSTOMER_MEMORY_SCOPE_FALLBACK;
const volatileStateByScope = new Map<string, CustomerMemoryState>();
function cleanText(value: unknown): string {
return String(value || "")
.replace(/\s+/g, " ")
.trim()
.slice(0, MAX_TEXT_LEN);
}
function cleanStateCode(value: unknown): string {
return cleanText(value).toUpperCase().slice(0, 2);
}
function cleanZip(value: unknown): string {
return String(value || "").replace(/[^0-9]/g, "").slice(0, 5);
}
function nowIso(): string {
return new Date().toISOString();
}
function normalizeScope(value: unknown): string {
const raw = String(value || "").trim();
if (!raw) return CUSTOMER_MEMORY_SCOPE_FALLBACK;
return raw.includes("@") ? raw.toLowerCase() : raw;
}
function getScopedStorageKey(): string {
return `${CUSTOMER_MEMORY_NAMESPACE}:${activeCustomerMemoryScope}`;
}
export function setCustomerMemoryScope(scope: unknown): string {
activeCustomerMemoryScope = normalizeScope(scope);
return activeCustomerMemoryScope;
}
export function getCustomerMemoryScope(): string {
return activeCustomerMemoryScope;
}
function getScopedVolatileState(): CustomerMemoryState {
return volatileStateByScope.get(activeCustomerMemoryScope) || {};
}
function setScopedVolatileState(next: CustomerMemoryState): void {
volatileStateByScope.set(activeCustomerMemoryScope, next);
}
function readScopedState(): CustomerMemoryState {
const persistent = readPersistentRaw();
const volatile = getScopedVolatileState();
return {
...persistent,
...volatile,
profile: volatile.profile || persistent.profile,
carryover: {
...(persistent.carryover || {}),
...(volatile.carryover || {}),
},
cards: Array.isArray(volatile.cards) ? volatile.cards : persistent.cards,
};
}
function readPersistentRaw(): CustomerMemoryState {
if (typeof window === "undefined") return {};
try {
const raw = window.localStorage.getItem(getScopedStorageKey());
if (!raw) return {};
const parsed = JSON.parse(raw) as CustomerMemoryState;
return parsed && typeof parsed === "object" ? parsed : {};
} catch {
return {};
}
}
function writePersistentRaw(next: CustomerMemoryState): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(getScopedStorageKey(), JSON.stringify(next));
} catch {
// ignore storage failures
}
}
function writeScopedState(next: CustomerMemoryState): void {
setScopedVolatileState(next);
writePersistentRaw(next);
}
export function getSmartProfile(): SmartCustomerProfile | null {
const raw = readScopedState().profile;
if (!raw || typeof raw !== "object") return null;
const normalized: SmartCustomerProfile = {
companyName: cleanText(raw.companyName),
contactName: cleanText(raw.contactName),
email: cleanText(raw.email),
phone: cleanText(raw.phone),
street: cleanText(raw.street),
suite: cleanText(raw.suite),
city: cleanText(raw.city),
state: cleanStateCode(raw.state),
zip: cleanZip(raw.zip),
updatedAt: cleanText(raw.updatedAt || ""),
};
const hasAny = Object.entries(normalized).some(([k, v]) => k !== "updatedAt" && Boolean(v));
return hasAny ? normalized : null;
}
export function mergeSmartProfile(patch: Partial<SmartCustomerProfile>): SmartCustomerProfile {
const existing = getSmartProfile() || {};
const merged: SmartCustomerProfile = {
companyName: cleanText(patch.companyName || existing.companyName),
contactName: cleanText(patch.contactName || existing.contactName),
email: cleanText(patch.email || existing.email),
phone: cleanText(patch.phone || existing.phone),
street: cleanText(patch.street || existing.street),
suite: cleanText(patch.suite || existing.suite),
city: cleanText(patch.city || existing.city),
state: cleanStateCode(patch.state || existing.state),
zip: cleanZip(patch.zip || existing.zip),
updatedAt: nowIso(),
};
const state = readScopedState();
writeScopedState({ ...state, profile: merged });
return merged;
}
export function listResumeWorkCards(): ResumeWorkCard[] {
const cards = readScopedState().cards;
if (!Array.isArray(cards)) return [];
return cards
.filter((card) => card && typeof card === "object")
.map((card) => ({
id: cleanText(card.id),
title: cleanText(card.title),
subtitle: cleanText(card.subtitle || ""),
source:
card.source === "rapid_router" || card.source === "pots_estimator" || card.source === "knowledgebase"
? card.source
: "knowledgebase",
actionCommand: cleanText(card.actionCommand),
updatedAt: cleanText(card.updatedAt || ""),
}))
.filter((card) => card.id && card.title && card.actionCommand)
.sort((a, b) => String(b.updatedAt || "").localeCompare(String(a.updatedAt || "")));
}
export function upsertResumeWorkCard(
card: Omit<ResumeWorkCard, "updatedAt"> & { updatedAt?: string }
): ResumeWorkCard[] {
const safeCard: ResumeWorkCard = {
id: cleanText(card.id),
title: cleanText(card.title),
subtitle: cleanText(card.subtitle || ""),
source: card.source,
actionCommand: cleanText(card.actionCommand),
updatedAt: cleanText(card.updatedAt || nowIso()),
};
if (!safeCard.id || !safeCard.title || !safeCard.actionCommand) {
return listResumeWorkCards();
}
const current = listResumeWorkCards().filter((item) => item.id !== safeCard.id);
const next = [safeCard, ...current].slice(0, MAX_CARD_COUNT);
const state = readScopedState();
writeScopedState({ ...state, cards: next });
return next;
}
export function setPotsEstimatorCarryover(payload: Record<string, unknown>, summary = ""): void {
const state = readScopedState();
writeScopedState({
...state,
carryover: {
...(state.carryover || {}),
potsEstimator: {
payload,
summary: cleanText(summary),
updatedAt: nowIso(),
},
},
});
}
export function getPotsEstimatorCarryover(): CarryoverPayload | null {
const carry = readScopedState().carryover?.potsEstimator;
if (!carry || typeof carry !== "object" || !carry.payload || typeof carry.payload !== "object") return null;
return {
payload: carry.payload,
summary: cleanText(carry.summary || ""),
updatedAt: cleanText(carry.updatedAt || ""),
};
}
export function setRapidRouterDraftCarryover(payload: Record<string, unknown>, summary = ""): void {
const state = readScopedState();
writeScopedState({
...state,
carryover: {
...(state.carryover || {}),
rapidRouterDraft: {
payload,
summary: cleanText(summary),
updatedAt: nowIso(),
},
},
});
}
export function getRapidRouterDraftCarryover(): CarryoverPayload | null {
const carry = readScopedState().carryover?.rapidRouterDraft;
if (!carry || typeof carry !== "object" || !carry.payload || typeof carry.payload !== "object") return null;
return {
payload: carry.payload,
summary: cleanText(carry.summary || ""),
updatedAt: cleanText(carry.updatedAt || ""),
};
}
export function __resetCustomerMemoryForTests(): void {
volatileStateByScope.clear();
}