From 35330d34b1bbbea4142c887055285258143a3a7a Mon Sep 17 00:00:00 2001 From: MohamedAliSmk Date: Wed, 3 Jun 2026 13:03:29 +0300 Subject: [PATCH 1/2] feat(customer-search): enhance customer search functionality and session management - Added resetState method to customerSearch store to clear customer data upon user session cleanup. - Integrated customer search store reset in the cleanupUserSession function to ensure no residual data is left for new users. - Updated Login and POSSale components to load all customers and reset state on user login. Co-authored-by: Cursor --- POS/src/pages/Login.vue | 3 ++ POS/src/pages/POSSale.vue | 2 + POS/src/stores/customerSearch.js | 48 +++++++++++++------- POS/src/utils/sessionCleanup.js | 11 +++++ pos_next/api/customers.py | 77 +++++++++++++++++++++++++++++++- pos_next/api/test_customers.py | 60 ++++++++++++++++++++++++- 6 files changed, 182 insertions(+), 19 deletions(-) diff --git a/POS/src/pages/Login.vue b/POS/src/pages/Login.vue index 7fb275cf8..235dfff15 100644 --- a/POS/src/pages/Login.vue +++ b/POS/src/pages/Login.vue @@ -106,6 +106,7 @@ import { useSessionLock } from "../composables/useSessionLock" import { cleanupUserSession } from "../utils/sessionCleanup" import { ensureCSRFToken } from "../utils/csrf" import { offlineWorker } from "../utils/offline/workerClient" +import { useCustomerSearchStore } from "@/stores/customerSearch" import { logger } from "@/utils/logger" const log = logger.create("Login") @@ -157,6 +158,8 @@ watch( () => session.isLoggedIn, async (isLoggedIn) => { if (isLoggedIn) { + customerSearchStore.resetState() + // Initialize CSRF token after successful login try { log.info("User logged in, initializing CSRF token...") diff --git a/POS/src/pages/POSSale.vue b/POS/src/pages/POSSale.vue index 1bb9ad508..431ff350f 100644 --- a/POS/src/pages/POSSale.vue +++ b/POS/src/pages/POSSale.vue @@ -1450,6 +1450,7 @@ onMounted(async () => { const backgroundOps = Promise.allSettled([ cartStore.setDefaultCustomer(), + customerSearchStore.loadAllCustomers(shiftStore.profileName, true), offlineStore.isOffline ? offlineStore.checkOfflineCacheAvailability() : offlineStore.preloadDataForOffline(shiftStore.currentProfile), @@ -1786,6 +1787,7 @@ async function handleShiftOpened() { const backgroundOps = Promise.allSettled([ cartStore.setDefaultCustomer(), + customerSearchStore.loadAllCustomers(shiftStore.profileName, true), offlineStore.isOffline ? offlineStore.checkOfflineCacheAvailability() : offlineStore.preloadDataForOffline(shiftStore.currentProfile), diff --git a/POS/src/stores/customerSearch.js b/POS/src/stores/customerSearch.js index 1a1db1ce7..fb9f55178 100644 --- a/POS/src/stores/customerSearch.js +++ b/POS/src/stores/customerSearch.js @@ -196,6 +196,15 @@ export const useCustomerSearchStore = defineStore("customerSearch", () => { return recs }) + function resetState() { + allCustomers.value = [] + searchTerm.value = "" + selectedIndex.value = -1 + serverDataFresh = false + searchIndex.value.clear() + resultCache.value.clear() + } + // Actions async function loadAllCustomers(posProfile, forceReload = false) { if (!posProfile) { @@ -209,20 +218,23 @@ export const useCustomerSearchStore = defineStore("customerSearch", () => { loading.value = true try { - // Step 1: Load from IndexedDB cache (instant display) - const cachedCustomers = await offlineWorker.searchCachedCustomers( - "", - 0, - ) + const isFullSync = forceReload || !localStorage.getItem(CUSTOMERS_SYNC_KEY) - if (cachedCustomers && cachedCustomers.length > 0) { - allCustomers.value = cachedCustomers - log.debug(`Loaded ${cachedCustomers.length} customers from cache`) + if (isFullSync) { + await offlineWorker.clearCustomersCache() + allCustomers.value = [] + } else if (!forceReload) { + // Delta sync: show cached list while fetching updates + const cachedCustomers = await offlineWorker.searchCachedCustomers("", 0) + if (cachedCustomers?.length > 0) { + allCustomers.value = cachedCustomers + log.debug(`Loaded ${cachedCustomers.length} customers from cache`) + } } - // Step 2: If online, fetch delta from server + // Fetch from server (full list or delta) if (!isOffline()) { - const lastSync = forceReload ? null : localStorage.getItem(CUSTOMERS_SYNC_KEY) + const lastSync = isFullSync ? null : localStorage.getItem(CUSTOMERS_SYNC_KEY) const response = await call("pos_next.api.customers.get_customers", { pos_profile: posProfile, @@ -233,26 +245,29 @@ export const useCustomerSearchStore = defineStore("customerSearch", () => { }) const delta = response?.message || response || [] - if (delta.length > 0) { + if (isFullSync) { + const active = delta.filter((c) => !c.disabled) + allCustomers.value = active + if (active.length) { + await offlineWorker.cacheCustomers(active) + } + log.debug(`Full sync: ${active.length} customers from server`) + } else if (delta.length > 0) { const active = delta.filter((c) => !c.disabled) const disabled = delta.filter((c) => c.disabled) - // Merge active customers into memory const existingMap = new Map(allCustomers.value.map((c) => [c.name, c])) for (const c of active) { existingMap.set(c.name, c) } - // Remove disabled customers from memory for (const c of disabled) { existingMap.delete(c.name) } allCustomers.value = Array.from(existingMap.values()) - // Persist active to IndexedDB if (active.length) { await offlineWorker.cacheCustomers(active) } - // Remove disabled from IndexedDB if (disabled.length) { await offlineWorker.deleteCustomers(disabled.map((c) => c.name)) } @@ -263,12 +278,10 @@ export const useCustomerSearchStore = defineStore("customerSearch", () => { serverDataFresh = true localStorage.setItem(CUSTOMERS_SYNC_KEY, new Date().toISOString()) } else if (allCustomers.value.length === 0) { - // Offline and cache is empty log.warn("Offline mode: No cached customers available") allCustomers.value = [] } - // Clear caches when new data is loaded searchIndex.value.clear() resultCache.value.clear() } catch (error) { @@ -410,6 +423,7 @@ export const useCustomerSearchStore = defineStore("customerSearch", () => { recommendations, // Actions + resetState, loadAllCustomers, addCustomerToCache, setSearchTerm, diff --git a/POS/src/utils/sessionCleanup.js b/POS/src/utils/sessionCleanup.js index f065d336b..d72bd89f5 100644 --- a/POS/src/utils/sessionCleanup.js +++ b/POS/src/utils/sessionCleanup.js @@ -1,7 +1,9 @@ import { clearAllDrafts } from "@/utils/draftManager" import { clearAllOfflineReceiptPayloads } from "@/utils/offline/offlineReceiptCache" +import { offlineWorker } from "@/utils/offline/workerClient" import { usePOSCartStore } from "@/stores/posCart" import { usePOSUIStore } from "@/stores/posUI" +import { useCustomerSearchStore } from "@/stores/customerSearch" import { useSessionLock } from "@/composables/useSessionLock" import { shiftState } from "@/composables/useShift" @@ -67,4 +69,13 @@ export async function cleanupUserSession() { } catch (error) { console.error("Failed to clear draft invoices:", error) } + + // 5. Clear cached customers so the next user never sees the previous user's list + try { + await offlineWorker.clearCustomersCache() + } catch (error) { + console.error("Failed to clear customers cache:", error) + } + + useCustomerSearchStore().resetState() } diff --git a/pos_next/api/customers.py b/pos_next/api/customers.py index 08f96d084..d325aac98 100644 --- a/pos_next/api/customers.py +++ b/pos_next/api/customers.py @@ -6,6 +6,66 @@ import frappe from frappe import _ +CASHIER_ROLE = "POSNext Cashier" +UNRESTRICTED_CUSTOMER_ROLES = frozenset( + { + "Nexus POS Manager", + "Sales Manager", + "Sales Master Manager", + "System Manager", + } +) + + +def should_restrict_customers_for_user(user=None): + """Cashiers only see the POS profile default customer and customers they created.""" + user = user or frappe.session.user + if not user or user in frappe.STANDARD_USERS: + return False + + roles = set(frappe.get_roles(user)) + if roles & UNRESTRICTED_CUSTOMER_ROLES: + return False + + return CASHIER_ROLE in roles + + +def get_allowed_customer_names(pos_profile=None, user=None): + """Default POS profile customer plus customers owned by the current user.""" + user = user or frappe.session.user + allowed = set() + + if pos_profile: + default_customer = frappe.db.get_value("POS Profile", pos_profile, "customer") + if default_customer: + allowed.add(default_customer) + + owned = frappe.get_all("Customer", filters={"owner": user}, pluck="name") + allowed.update(owned or []) + + return allowed + + +def _apply_cashier_customer_scope(filters, pos_profile=None): + if not should_restrict_customers_for_user(): + return filters + + allowed = get_allowed_customer_names(pos_profile) + if not allowed: + filters["name"] = ["in", ["__no_customer__"]] + else: + filters["name"] = ["in", list(allowed)] + + return filters + + +def _assert_customer_access(customer, pos_profile=None): + if not customer or not should_restrict_customers_for_user(): + return + + if customer not in get_allowed_customer_names(pos_profile): + frappe.throw(_("You do not have permission to access this customer"), frappe.PermissionError) + @frappe.whitelist() def get_customers(search_term="", pos_profile=None, limit=20, modified_since=None): @@ -45,6 +105,8 @@ def get_customers(search_term="", pos_profile=None, limit=20, modified_since=Non # Full fetch: only active customers filters["disabled"] = 0 + filters = _apply_cashier_customer_scope(filters, pos_profile) + search_term = (search_term or "").strip() if search_term: like_term = f"%{search_term}%" @@ -55,7 +117,13 @@ def get_customers(search_term="", pos_profile=None, limit=20, modified_since=Non ["Customer", "email_id", "like", like_term], ] - customer_limit = limit if limit not in (None, 0) else frappe.db.count("Customer", filters) + if limit not in (None, 0): + customer_limit = limit + elif should_restrict_customers_for_user(): + customer_limit = len(get_allowed_customer_names(pos_profile)) + else: + customer_limit = frappe.db.count("Customer", filters) + result = frappe.get_all( "Customer", filters=filters, @@ -267,4 +335,11 @@ def get_customer_details(customer): if not customer: frappe.throw(_("Customer is required")) + pos_profile = None + form_dict = getattr(frappe.local, "form_dict", None) + if form_dict: + pos_profile = form_dict.get("pos_profile") + + _assert_customer_access(customer, pos_profile) + return frappe.get_cached_doc("Customer", customer).as_dict() diff --git a/pos_next/api/test_customers.py b/pos_next/api/test_customers.py index fe955b77e..d867dc432 100644 --- a/pos_next/api/test_customers.py +++ b/pos_next/api/test_customers.py @@ -7,16 +7,21 @@ from pos_next.api.customers import ( _get_customer_assignment_context, create_customer, + get_allowed_customer_names, get_customers, get_default_loyalty_program_from_settings, + should_restrict_customers_for_user, ) class TestCustomersAPI(unittest.TestCase): + @patch("pos_next.api.customers.should_restrict_customers_for_user", return_value=False) @patch("pos_next.api.customers.frappe.logger") @patch("pos_next.api.customers.frappe.get_all") @patch("pos_next.api.customers.frappe.db") - def test_get_customers_applies_search_term_filters(self, mock_db, mock_get_all, mock_logger): + def test_get_customers_applies_search_term_filters( + self, mock_db, mock_get_all, mock_logger, _mock_restrict + ): mock_logger.return_value = Mock() mock_get_all.return_value = [] @@ -100,3 +105,56 @@ def test_create_customer_uses_pos_profile_for_loyalty_assignment( mock_get_loyalty.assert_called_once_with(company=None, pos_profile="POS-A") customer_doc.insert.assert_called_once_with() self.assertEqual(result["loyalty_program"], "LOYALTY-A") + + @patch("pos_next.api.customers.frappe.get_roles", return_value=["POSNext Cashier"]) + def test_should_restrict_customers_for_cashier(self, _mock_roles): + self.assertTrue(should_restrict_customers_for_user("cashier@example.com")) + + @patch("pos_next.api.customers.frappe.get_roles", return_value=["Nexus POS Manager"]) + def test_should_not_restrict_customers_for_manager(self, _mock_roles): + self.assertFalse(should_restrict_customers_for_user("manager@example.com")) + + @patch("pos_next.api.customers.frappe.get_all", return_value=["CUST-OWNED"]) + @patch( + "pos_next.api.customers.frappe.db.get_value", + return_value="CUST-DEFAULT", + ) + def test_get_allowed_customer_names_includes_profile_default_and_owned( + self, mock_get_value, mock_get_all + ): + allowed = get_allowed_customer_names("POS-A", user="cashier@example.com") + + self.assertEqual(allowed, {"CUST-DEFAULT", "CUST-OWNED"}) + mock_get_value.assert_called_once_with("POS Profile", "POS-A", "customer") + mock_get_all.assert_called_once_with( + "Customer", filters={"owner": "cashier@example.com"}, pluck="name" + ) + + @patch("pos_next.api.customers.should_restrict_customers_for_user", return_value=True) + @patch( + "pos_next.api.customers.get_allowed_customer_names", + return_value={"CUST-DEFAULT", "CUST-OWNED"}, + ) + @patch("pos_next.api.customers.frappe.logger") + @patch("pos_next.api.customers.frappe.get_all") + @patch("pos_next.api.customers.frappe.db") + def test_get_customers_scopes_cashier_to_allowed_names( + self, + mock_db, + mock_get_all, + mock_logger, + _mock_allowed, + _mock_restrict, + ): + mock_logger.return_value = Mock() + mock_get_all.return_value = [] + + get_customers(pos_profile="POS-A", limit=10) + + kwargs = mock_get_all.call_args.kwargs + self.assertEqual(kwargs["filters"]["disabled"], 0) + self.assertEqual(kwargs["filters"]["name"][0], "in") + self.assertCountEqual( + kwargs["filters"]["name"][1], + ["CUST-DEFAULT", "CUST-OWNED"], + ) From 7d18833dd86bc86419c8ff7943ca82c10e16e2cb Mon Sep 17 00:00:00 2001 From: MohamedAliSmk Date: Wed, 3 Jun 2026 14:00:16 +0300 Subject: [PATCH 2/2] fix: enhance pip-audit step in linter workflow with retry logic and timeout --- .github/workflows/linter.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml index ce3efbf89..315551f25 100644 --- a/.github/workflows/linter.yml +++ b/.github/workflows/linter.yml @@ -55,6 +55,7 @@ jobs: ${{ runner.os }}- - name: Install and run pip-audit + timeout-minutes: 10 run: | python -m venv .audit-venv . .audit-venv/bin/activate @@ -64,4 +65,12 @@ jobs: pip install "erpnext @ git+https://github.com/frappe/erpnext.git@version-15" cd ${GITHUB_WORKSPACE} pip install --no-deps . - pip-audit --desc on + audit_ok=1 + for i in {1..3}; do + if pip-audit --desc on; then audit_ok=0; break; fi + if [ "$i" -lt 3 ]; then sleep $((2**i)); fi + done + if [ "$audit_ok" -ne 0 ]; then + echo "Vulnerability check failed - PyPI service may be unavailable" + exit 1 + fi