diff --git a/src/App.tsx b/src/App.tsx index 642f9e1..48d7ab5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,7 @@ import { collection, doc, getDoc, + getDocs, onSnapshot, orderBy, query, @@ -31,10 +32,12 @@ import { getAppCopy } from './i18n/copy'; import firebaseConfig from '../firebase-applet-config.json'; import { buildUnknownQueueTargetFingerprint, + classifyUnknownQueueLoadFailure, classifyHouseholdMembershipProbe, - getUnknownQueueLoadErrorMessage, HouseholdMembershipProbeResult, isFirestoreFailedPreconditionError, + isFirestorePermissionDeniedError, + type UnknownQueueReadProbeResult, sortUnknownIngredientQueueItemsByCreatedAt, toFirestoreListenerErrorInfo, } from './utils/unknownQueue'; @@ -80,6 +83,7 @@ export default function App() { const [inviteEmail, setInviteEmail] = useState(''); const [isInviting, setIsInviting] = useState(false); const [uiFeedback, setUiFeedback] = useState(null); + const [unknownQueueWarning, setUnknownQueueWarning] = useState(null); const isOwner = role === 'owner'; const ownerLanguage: UiLanguage = householdData?.ownerLanguage ?? 'en'; const cookLanguage: UiLanguage = householdData?.cookLanguage ?? 'hi'; @@ -99,6 +103,7 @@ export default function App() { setHouseholdId(null); setHouseholdData(null); setAccessRevoked(false); + setUnknownQueueWarning(null); } }); @@ -221,6 +226,7 @@ export default function App() { const handleUnknownQueueLoaded = (items: UnknownIngredientQueueItem[]): void => { setUnknownIngredientQueue(items); + setUnknownQueueWarning(null); hasLoadedUnknownQueue = true; markInitialViewReady(); }; @@ -256,6 +262,70 @@ export default function App() { membershipProbeResult, }); + const probeUnknownQueuePlainRead = async (): Promise => { + try { + await getDocs(collection(db, `households/${resolved.householdId}/unknownIngredientQueue`)); + return 'succeeded'; + } catch (probeError) { + const parsedProbeError = toFirestoreListenerErrorInfo(probeError); + if (isFirestorePermissionDeniedError(parsedProbeError)) { + return 'permission-denied'; + } + + console.error('unknown_queue_plain_read_probe_failed', { + error: probeError, + householdId: resolved.householdId, + code: parsedProbeError.code, + message: parsedProbeError.message, + projectId: firebaseConfig.projectId, + databaseId: firebaseConfig.firestoreDatabaseId, + buildId: appBuildId, + uid: user.uid, + email: user.email ?? null, + path: unknownQueuePath, + targetFingerprint, + membershipProbeResult, + }); + return 'failed'; + } + }; + + const applyUnknownQueueFailure = async ( + parsedError: ReturnType, + logLabel: 'unknown_queue_snapshot_failed' | 'unknown_queue_snapshot_fallback_failed', + error: unknown, + ): Promise => { + const plainReadProbeResult = isFirestorePermissionDeniedError(parsedError) + ? await probeUnknownQueuePlainRead() + : 'not-run'; + const classification = classifyUnknownQueueLoadFailure({ + error: parsedError, + membershipProbeResult, + plainReadProbeResult, + }); + + console.error(logLabel, { + error, + householdId: resolved.householdId, + code: parsedError.code, + message: parsedError.message, + diagnosticKind: classification.diagnosticKind, + plainReadProbeResult, + projectId: firebaseConfig.projectId, + databaseId: firebaseConfig.firestoreDatabaseId, + buildId: appBuildId, + uid: user.uid, + email: user.email ?? null, + path: unknownQueuePath, + targetFingerprint, + membershipProbeResult, + }); + + setUnknownQueueWarning(appendBuildIdToDiagnosticMessage(classification.userMessage, appBuildId)); + hasLoadedUnknownQueue = true; + markInitialViewReady(); + }; + const subscribeUnknownQueueFallback = (): void => { if (unknownQueueFallbackUnsub !== null) { return; @@ -272,29 +342,7 @@ export default function App() { }, (error) => { const parsedError = toFirestoreListenerErrorInfo(error); - console.error('unknown_queue_snapshot_fallback_failed', { - error, - householdId: resolved.householdId, - code: parsedError.code, - message: parsedError.message, - projectId: firebaseConfig.projectId, - databaseId: firebaseConfig.firestoreDatabaseId, - buildId: appBuildId, - uid: user.uid, - email: user.email ?? null, - path: unknownQueuePath, - targetFingerprint, - membershipProbeResult, - }); - setUiFeedback({ - kind: 'error', - message: appendBuildIdToDiagnosticMessage( - getUnknownQueueLoadErrorMessage(parsedError, membershipProbeResult), - appBuildId, - ), - }); - hasLoadedUnknownQueue = true; - markInitialViewReady(); + void applyUnknownQueueFailure(parsedError, 'unknown_queue_snapshot_fallback_failed', error); }, ); }; @@ -310,46 +358,17 @@ export default function App() { }, (error) => { const parsedError = toFirestoreListenerErrorInfo(error); - console.error('unknown_queue_snapshot_failed', { - error, - householdId: resolved.householdId, - code: parsedError.code, - message: parsedError.message, - projectId: firebaseConfig.projectId, - databaseId: firebaseConfig.firestoreDatabaseId, - buildId: appBuildId, - uid: user.uid, - email: user.email ?? null, - path: unknownQueuePath, - targetFingerprint, - membershipProbeResult, - }); - if (isFirestoreFailedPreconditionError(parsedError)) { if (unknownQueueUnsub !== null) { unknownQueueUnsub(); unknownQueueUnsub = null; } - setUiFeedback({ - kind: 'error', - message: appendBuildIdToDiagnosticMessage( - getUnknownQueueLoadErrorMessage(parsedError, membershipProbeResult), - appBuildId, - ), - }); + setUnknownQueueWarning(appendBuildIdToDiagnosticMessage('Review queue order is temporarily unavailable.', appBuildId)); subscribeUnknownQueueFallback(); return; } - setUiFeedback({ - kind: 'error', - message: appendBuildIdToDiagnosticMessage( - getUnknownQueueLoadErrorMessage(parsedError, membershipProbeResult), - appBuildId, - ), - }); - hasLoadedUnknownQueue = true; - markInitialViewReady(); + void applyUnknownQueueFailure(parsedError, 'unknown_queue_snapshot_failed', error); }, ); } catch (error) { @@ -799,6 +818,7 @@ export default function App() { onClearAnomaly={handleClearAnomaly} logs={logs} unknownIngredientQueue={unknownIngredientQueue} + unknownQueueWarning={unknownQueueWarning} onPromoteUnknownIngredient={handlePromoteUnknownIngredient} onDismissUnknownIngredient={handleDismissUnknownIngredient} language={ownerLanguage} diff --git a/src/components/OwnerView.tsx b/src/components/OwnerView.tsx index 2aa54c6..8273f6e 100644 --- a/src/components/OwnerView.tsx +++ b/src/components/OwnerView.tsx @@ -28,12 +28,13 @@ interface Props { onClearAnomaly: (id: string) => void; logs: PantryLog[]; unknownIngredientQueue: UnknownIngredientQueueItem[]; + unknownQueueWarning: string | null; onPromoteUnknownIngredient: (queueItem: UnknownIngredientQueueItem) => void; onDismissUnknownIngredient: (queueItem: UnknownIngredientQueueItem) => void; language: UiLanguage; } -export default function OwnerView({ meals, onUpdateMeal, inventory, onAddInventoryItem, onUpdateInventory, onDeleteInventoryItem, onClearAnomaly, logs, unknownIngredientQueue, onPromoteUnknownIngredient, onDismissUnknownIngredient, language }: Props) { +export default function OwnerView({ meals, onUpdateMeal, inventory, onAddInventoryItem, onUpdateInventory, onDeleteInventoryItem, onClearAnomaly, logs, unknownIngredientQueue, unknownQueueWarning, onPromoteUnknownIngredient, onDismissUnknownIngredient, language }: Props) { const [activeTab, setActiveTab] = useState('meals'); const tabRefs = useRef>({ meals: null, @@ -109,6 +110,7 @@ export default function OwnerView({ meals, onUpdateMeal, inventory, onAddInvento onClearAnomaly={onClearAnomaly} logs={logs} unknownIngredientQueue={unknownIngredientQueue} + unknownQueueWarning={unknownQueueWarning} onPromoteUnknownIngredient={onPromoteUnknownIngredient} onDismissUnknownIngredient={onDismissUnknownIngredient} language={language} diff --git a/src/components/Pantry.tsx b/src/components/Pantry.tsx index 33afbbe..b25fa13 100644 --- a/src/components/Pantry.tsx +++ b/src/components/Pantry.tsx @@ -20,6 +20,7 @@ interface Props { onClearAnomaly: (id: string) => void; logs: PantryLog[]; unknownIngredientQueue: UnknownIngredientQueueItem[]; + unknownQueueWarning: string | null; onPromoteUnknownIngredient: (queueItem: UnknownIngredientQueueItem) => void; onDismissUnknownIngredient: (queueItem: UnknownIngredientQueueItem) => void; language: UiLanguage; @@ -71,7 +72,7 @@ function getRoleLabel(language: UiLanguage, role: Role): string { return role === 'owner' ? 'Owner' : 'Cook'; } -export default function Pantry({ inventory, onAddInventoryItem, onUpdateInventory, onDeleteInventoryItem, onClearAnomaly, logs, unknownIngredientQueue, onPromoteUnknownIngredient, onDismissUnknownIngredient, language }: Props) { +export default function Pantry({ inventory, onAddInventoryItem, onUpdateInventory, onDeleteInventoryItem, onClearAnomaly, logs, unknownIngredientQueue, unknownQueueWarning, onPromoteUnknownIngredient, onDismissUnknownIngredient, language }: Props) { const [newItemName, setNewItemName] = useState(''); const [newItemCategory, setNewItemCategory] = useState('spices'); const [newItemQuantity, setNewItemQuantity] = useState(''); @@ -108,6 +109,7 @@ export default function Pantry({ inventory, onAddInventoryItem, onUpdateInventor queueTitle: 'अज्ञात सामग्री समीक्षा कतार', queueHelper: 'कुक की नई अनमैच सामग्री रिक्वेस्ट यहां आएगी। पेंट्री में प्रमोट करें या खारिज करें।', queueEmpty: 'समीक्षा के लिए कोई लंबित रिक्वेस्ट नहीं है।', + queueWarning: 'समीक्षा कतार अस्थायी रूप से उपलब्ध नहीं है। बाकी वर्कस्पेस सामान्य रूप से काम करता रहेगा।', queueRequestedBy: 'रिक्वेस्ट', queuePromote: 'प्रमोट करें', queueDismiss: 'खारिज करें', @@ -144,6 +146,7 @@ export default function Pantry({ inventory, onAddInventoryItem, onUpdateInventor queueTitle: 'Unknown Ingredient Review Queue', queueHelper: 'New unmatched ingredient requests from cook are collected here for owner review.', queueEmpty: 'No pending unknown ingredient requests.', + queueWarning: 'Review queue is temporarily unavailable. The rest of the workspace is still usable.', queueRequestedBy: 'Requested', queuePromote: 'Promote', queueDismiss: 'Dismiss', @@ -412,6 +415,12 @@ export default function Pantry({ inventory, onAddInventoryItem, onUpdateInventor

{content.queueTitle}

{content.queueHelper}

+ {unknownQueueWarning ? ( +
+ +

{unknownQueueWarning || content.queueWarning}

+
+ ) : null} {openQueueItems.length === 0 ? (

{content.queueEmpty}

) : ( diff --git a/src/utils/unknownQueue.ts b/src/utils/unknownQueue.ts index a10d53d..76e9d2c 100644 --- a/src/utils/unknownQueue.ts +++ b/src/utils/unknownQueue.ts @@ -20,8 +20,21 @@ export interface HouseholdMembershipProbeInput { userUid: string; } +export type UnknownQueuePermissionDeniedKind = + | 'membership-mismatch' + | 'likely-live-rules-drift' + | 'query-specific-denial' + | 'unknown-permission-denial'; + +export type UnknownQueueReadProbeResult = 'succeeded' | 'permission-denied' | 'failed' | 'not-run'; + export type HouseholdMembershipProbeResult = 'owner' | 'cook' | 'non-member' | 'household-missing'; +export interface UnknownQueueLoadFailureClassification { + diagnosticKind: UnknownQueuePermissionDeniedKind | 'index-missing' | 'unknown-load-failure'; + userMessage: string; +} + function toRecord(value: unknown): Record | null { if (typeof value !== 'object' || value === null) { return null; @@ -99,6 +112,54 @@ export function getUnknownQueueLoadErrorMessage( return 'Failed to load unknown ingredient queue.'; } +export function classifyUnknownQueueLoadFailure(input: { + error: FirestoreListenerErrorInfo; + membershipProbeResult: HouseholdMembershipProbeResult | null; + plainReadProbeResult: UnknownQueueReadProbeResult; +}): UnknownQueueLoadFailureClassification { + const { error, membershipProbeResult, plainReadProbeResult } = input; + + if (isFirestorePermissionDeniedError(error)) { + if (membershipProbeResult === 'non-member' || membershipProbeResult === 'household-missing') { + return { + diagnosticKind: 'membership-mismatch', + userMessage: 'Review queue is unavailable for this account.', + }; + } + + if (plainReadProbeResult === 'succeeded') { + return { + diagnosticKind: 'query-specific-denial', + userMessage: 'Review queue is temporarily unavailable.', + }; + } + + if (plainReadProbeResult === 'permission-denied') { + return { + diagnosticKind: 'likely-live-rules-drift', + userMessage: 'Review queue is temporarily unavailable.', + }; + } + + return { + diagnosticKind: 'unknown-permission-denial', + userMessage: 'Review queue is temporarily unavailable.', + }; + } + + if (isFirestoreFailedPreconditionError(error)) { + return { + diagnosticKind: 'index-missing', + userMessage: 'Review queue order is temporarily unavailable.', + }; + } + + return { + diagnosticKind: 'unknown-load-failure', + userMessage: 'Review queue is temporarily unavailable.', + }; +} + export function sortUnknownIngredientQueueItemsByCreatedAt(items: UnknownIngredientQueueItem[]): UnknownIngredientQueueItem[] { return [...items].sort((leftItem, rightItem) => { const rightTime = toTimestampMs(rightItem.createdAt); diff --git a/test/unit/run.ts b/test/unit/run.ts index 878f6af..0c44723 100644 --- a/test/unit/run.ts +++ b/test/unit/run.ts @@ -6,6 +6,7 @@ import { sanitizeFirestorePayload } from '../../src/utils/firestorePayload'; import { getIngredientNativeContextLabel, resolveIngredientVisual } from '../../src/utils/ingredientVisuals'; import { buildUnknownQueueTargetFingerprint, + classifyUnknownQueueLoadFailure, classifyHouseholdMembershipProbe, getUnknownQueueLoadErrorMessage, isFirestoreFailedPreconditionError, @@ -357,6 +358,39 @@ function testUnknownQueueErrorParsingAndMessaging(): void { getUnknownQueueLoadErrorMessage(permissionDenied, 'non-member'), 'Unknown ingredient queue access denied. Household membership mismatch suspected.', ); + assert.deepEqual( + classifyUnknownQueueLoadFailure({ + error: permissionDenied, + membershipProbeResult: 'owner', + plainReadProbeResult: 'permission-denied', + }), + { + diagnosticKind: 'likely-live-rules-drift', + userMessage: 'Review queue is temporarily unavailable.', + }, + ); + assert.deepEqual( + classifyUnknownQueueLoadFailure({ + error: permissionDenied, + membershipProbeResult: 'owner', + plainReadProbeResult: 'succeeded', + }), + { + diagnosticKind: 'query-specific-denial', + userMessage: 'Review queue is temporarily unavailable.', + }, + ); + assert.deepEqual( + classifyUnknownQueueLoadFailure({ + error: permissionDenied, + membershipProbeResult: 'non-member', + plainReadProbeResult: 'not-run', + }), + { + diagnosticKind: 'membership-mismatch', + userMessage: 'Review queue is unavailable for this account.', + }, + ); const failedPrecondition = toFirestoreListenerErrorInfo({ code: 'failed-precondition', @@ -368,11 +402,33 @@ function testUnknownQueueErrorParsingAndMessaging(): void { getUnknownQueueLoadErrorMessage(failedPrecondition, 'owner'), 'Unknown ingredient queue index is missing. Showing fallback order while index is provisioned.', ); + assert.deepEqual( + classifyUnknownQueueLoadFailure({ + error: failedPrecondition, + membershipProbeResult: 'owner', + plainReadProbeResult: 'not-run', + }), + { + diagnosticKind: 'index-missing', + userMessage: 'Review queue order is temporarily unavailable.', + }, + ); const unknownError = toFirestoreListenerErrorInfo(new Error('boom')); assert.equal(isFirestoreFailedPreconditionError(unknownError), false); assert.equal(isFirestorePermissionDeniedError(unknownError), false); assert.equal(getUnknownQueueLoadErrorMessage(unknownError, null), 'Failed to load unknown ingredient queue.'); + assert.deepEqual( + classifyUnknownQueueLoadFailure({ + error: unknownError, + membershipProbeResult: null, + plainReadProbeResult: 'failed', + }), + { + diagnosticKind: 'unknown-load-failure', + userMessage: 'Review queue is temporarily unavailable.', + }, + ); } function testUnknownQueueFallbackSortOrder(): void {