Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 75 additions & 55 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
collection,
doc,
getDoc,
getDocs,
onSnapshot,
orderBy,
query,
Expand All @@ -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';
Expand Down Expand Up @@ -80,6 +83,7 @@ export default function App() {
const [inviteEmail, setInviteEmail] = useState('');
const [isInviting, setIsInviting] = useState(false);
const [uiFeedback, setUiFeedback] = useState<UiFeedback | null>(null);
const [unknownQueueWarning, setUnknownQueueWarning] = useState<string | null>(null);
const isOwner = role === 'owner';
const ownerLanguage: UiLanguage = householdData?.ownerLanguage ?? 'en';
const cookLanguage: UiLanguage = householdData?.cookLanguage ?? 'hi';
Expand All @@ -99,6 +103,7 @@ export default function App() {
setHouseholdId(null);
setHouseholdData(null);
setAccessRevoked(false);
setUnknownQueueWarning(null);
}
});

Expand Down Expand Up @@ -221,6 +226,7 @@ export default function App() {

const handleUnknownQueueLoaded = (items: UnknownIngredientQueueItem[]): void => {
setUnknownIngredientQueue(items);
setUnknownQueueWarning(null);
hasLoadedUnknownQueue = true;
markInitialViewReady();
};
Expand Down Expand Up @@ -256,6 +262,70 @@ export default function App() {
membershipProbeResult,
});

const probeUnknownQueuePlainRead = async (): Promise<UnknownQueueReadProbeResult> => {
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<typeof toFirestoreListenerErrorInfo>,
logLabel: 'unknown_queue_snapshot_failed' | 'unknown_queue_snapshot_fallback_failed',
error: unknown,
): Promise<void> => {
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;
Expand All @@ -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);
},
);
};
Expand All @@ -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) {
Expand Down Expand Up @@ -799,6 +818,7 @@ export default function App() {
onClearAnomaly={handleClearAnomaly}
logs={logs}
unknownIngredientQueue={unknownIngredientQueue}
unknownQueueWarning={unknownQueueWarning}
onPromoteUnknownIngredient={handlePromoteUnknownIngredient}
onDismissUnknownIngredient={handleDismissUnknownIngredient}
language={ownerLanguage}
Expand Down
4 changes: 3 additions & 1 deletion src/components/OwnerView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<OwnerTab>('meals');
const tabRefs = useRef<Record<OwnerTab, HTMLButtonElement | null>>({
meals: null,
Expand Down Expand Up @@ -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}
Expand Down
11 changes: 10 additions & 1 deletion src/components/Pantry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string>('');
const [newItemCategory, setNewItemCategory] = useState<PantryCategoryKey>('spices');
const [newItemQuantity, setNewItemQuantity] = useState<string>('');
Expand Down Expand Up @@ -108,6 +109,7 @@ export default function Pantry({ inventory, onAddInventoryItem, onUpdateInventor
queueTitle: 'अज्ञात सामग्री समीक्षा कतार',
queueHelper: 'कुक की नई अनमैच सामग्री रिक्वेस्ट यहां आएगी। पेंट्री में प्रमोट करें या खारिज करें।',
queueEmpty: 'समीक्षा के लिए कोई लंबित रिक्वेस्ट नहीं है।',
queueWarning: 'समीक्षा कतार अस्थायी रूप से उपलब्ध नहीं है। बाकी वर्कस्पेस सामान्य रूप से काम करता रहेगा।',
queueRequestedBy: 'रिक्वेस्ट',
queuePromote: 'प्रमोट करें',
queueDismiss: 'खारिज करें',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -412,6 +415,12 @@ export default function Pantry({ inventory, onAddInventoryItem, onUpdateInventor
<h3 className="text-lg font-semibold text-stone-900">{content.queueTitle}</h3>
<p className="text-sm text-stone-500">{content.queueHelper}</p>
</div>
{unknownQueueWarning ? (
<div className="mt-4 flex items-start gap-2 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
<AlertTriangle size={18} className="mt-0.5 shrink-0" />
<p>{unknownQueueWarning || content.queueWarning}</p>
</div>
) : null}
{openQueueItems.length === 0 ? (
<p className="mt-4 text-sm text-stone-500">{content.queueEmpty}</p>
) : (
Expand Down
61 changes: 61 additions & 0 deletions src/utils/unknownQueue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> | null {
if (typeof value !== 'object' || value === null) {
return null;
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading