Skip to content

Commit 25d5c8c

Browse files
committed
fix: harden unknown queue loading in owner view
1 parent 8782ccb commit 25d5c8c

File tree

4 files changed

+234
-6
lines changed

4 files changed

+234
-6
lines changed

src/App.tsx

Lines changed: 66 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,12 @@ import {
2727
import { upsertMealField } from './services/mealService';
2828
import { HouseholdData, resolveOrCreateHousehold } from './services/householdService';
2929
import { getAppCopy } from './i18n/copy';
30+
import {
31+
getUnknownQueueLoadErrorMessage,
32+
isFirestoreFailedPreconditionError,
33+
sortUnknownIngredientQueueItemsByCreatedAt,
34+
toFirestoreListenerErrorInfo,
35+
} from './utils/unknownQueue';
3036

3137
interface UiFeedback {
3238
kind: 'success' | 'error';
@@ -84,6 +90,7 @@ export default function App() {
8490
let mealsUnsub: Unsubscribe | null = null;
8591
let logsUnsub: Unsubscribe | null = null;
8692
let unknownQueueUnsub: Unsubscribe | null = null;
93+
let unknownQueueFallbackUnsub: Unsubscribe | null = null;
8794
let hasLoadedHousehold = false;
8895
let hasLoadedInventory = false;
8996
let hasLoadedMeals = false;
@@ -187,20 +194,70 @@ export default function App() {
187194
},
188195
);
189196

197+
const handleUnknownQueueLoaded = (items: UnknownIngredientQueueItem[]): void => {
198+
setUnknownIngredientQueue(items);
199+
hasLoadedUnknownQueue = true;
200+
markInitialViewReady();
201+
};
202+
203+
const subscribeUnknownQueueFallback = (): void => {
204+
if (unknownQueueFallbackUnsub !== null) {
205+
return;
206+
}
207+
208+
unknownQueueFallbackUnsub = onSnapshot(
209+
collection(db, `households/${resolved.householdId}/unknownIngredientQueue`),
210+
(snapshot) => {
211+
const queueItems = snapshot.docs.map((queueDoc) => ({
212+
id: queueDoc.id,
213+
...(queueDoc.data() as Omit<UnknownIngredientQueueItem, 'id'>),
214+
}));
215+
handleUnknownQueueLoaded(sortUnknownIngredientQueueItemsByCreatedAt(queueItems));
216+
},
217+
(error) => {
218+
const parsedError = toFirestoreListenerErrorInfo(error);
219+
console.error('unknown_queue_snapshot_fallback_failed', {
220+
error,
221+
householdId: resolved.householdId,
222+
code: parsedError.code,
223+
message: parsedError.message,
224+
});
225+
setUiFeedback({ kind: 'error', message: getUnknownQueueLoadErrorMessage(parsedError) });
226+
hasLoadedUnknownQueue = true;
227+
markInitialViewReady();
228+
},
229+
);
230+
};
231+
190232
unknownQueueUnsub = onSnapshot(
191233
query(collection(db, `households/${resolved.householdId}/unknownIngredientQueue`), orderBy('createdAt', 'desc')),
192234
(snapshot) => {
193-
const queueItems: UnknownIngredientQueueItem[] = snapshot.docs.map((queueDoc) => ({
235+
const queueItems = snapshot.docs.map((queueDoc) => ({
194236
id: queueDoc.id,
195237
...(queueDoc.data() as Omit<UnknownIngredientQueueItem, 'id'>),
196238
}));
197-
setUnknownIngredientQueue(queueItems);
198-
hasLoadedUnknownQueue = true;
199-
markInitialViewReady();
239+
handleUnknownQueueLoaded(queueItems);
200240
},
201241
(error) => {
202-
console.error('unknown_queue_snapshot_failed', { error, householdId: resolved.householdId });
203-
setUiFeedback({ kind: 'error', message: 'Failed to load unknown ingredient queue.' });
242+
const parsedError = toFirestoreListenerErrorInfo(error);
243+
console.error('unknown_queue_snapshot_failed', {
244+
error,
245+
householdId: resolved.householdId,
246+
code: parsedError.code,
247+
message: parsedError.message,
248+
});
249+
250+
if (isFirestoreFailedPreconditionError(parsedError)) {
251+
if (unknownQueueUnsub !== null) {
252+
unknownQueueUnsub();
253+
unknownQueueUnsub = null;
254+
}
255+
setUiFeedback({ kind: 'error', message: getUnknownQueueLoadErrorMessage(parsedError) });
256+
subscribeUnknownQueueFallback();
257+
return;
258+
}
259+
260+
setUiFeedback({ kind: 'error', message: getUnknownQueueLoadErrorMessage(parsedError) });
204261
hasLoadedUnknownQueue = true;
205262
markInitialViewReady();
206263
},
@@ -230,6 +287,9 @@ export default function App() {
230287
if (unknownQueueUnsub) {
231288
unknownQueueUnsub();
232289
}
290+
if (unknownQueueFallbackUnsub) {
291+
unknownQueueFallbackUnsub();
292+
}
233293
};
234294
}, [user]);
235295

src/utils/unknownQueue.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { UnknownIngredientQueueItem } from '../types';
2+
3+
export interface FirestoreListenerErrorInfo {
4+
code: string | null;
5+
message: string | null;
6+
name: string | null;
7+
}
8+
9+
function toRecord(value: unknown): Record<string, unknown> | null {
10+
if (typeof value !== 'object' || value === null) {
11+
return null;
12+
}
13+
return value as Record<string, unknown>;
14+
}
15+
16+
function toOptionalString(value: unknown): string | null {
17+
return typeof value === 'string' ? value : null;
18+
}
19+
20+
function toTimestampMs(value: string): number | null {
21+
const parsed = Date.parse(value);
22+
return Number.isNaN(parsed) ? null : parsed;
23+
}
24+
25+
export function toFirestoreListenerErrorInfo(error: unknown): FirestoreListenerErrorInfo {
26+
const record = toRecord(error);
27+
return {
28+
code: toOptionalString(record?.code),
29+
message: toOptionalString(record?.message),
30+
name: toOptionalString(record?.name),
31+
};
32+
}
33+
34+
export function isFirestoreFailedPreconditionError(error: FirestoreListenerErrorInfo): boolean {
35+
return error.code === 'failed-precondition';
36+
}
37+
38+
export function isFirestorePermissionDeniedError(error: FirestoreListenerErrorInfo): boolean {
39+
return error.code === 'permission-denied';
40+
}
41+
42+
export function getUnknownQueueLoadErrorMessage(error: FirestoreListenerErrorInfo): string {
43+
if (isFirestorePermissionDeniedError(error)) {
44+
return 'Unknown ingredient queue access denied. Deploy latest Firestore rules and retry.';
45+
}
46+
47+
if (isFirestoreFailedPreconditionError(error)) {
48+
return 'Unknown ingredient queue index is missing. Showing fallback order while index is provisioned.';
49+
}
50+
51+
return 'Failed to load unknown ingredient queue.';
52+
}
53+
54+
export function sortUnknownIngredientQueueItemsByCreatedAt(items: UnknownIngredientQueueItem[]): UnknownIngredientQueueItem[] {
55+
return [...items].sort((leftItem, rightItem) => {
56+
const rightTime = toTimestampMs(rightItem.createdAt);
57+
const leftTime = toTimestampMs(leftItem.createdAt);
58+
59+
if (rightTime !== null && leftTime !== null) {
60+
if (rightTime !== leftTime) {
61+
return rightTime - leftTime;
62+
}
63+
} else if (rightTime !== null && leftTime === null) {
64+
return 1;
65+
} else if (rightTime === null && leftTime !== null) {
66+
return -1;
67+
}
68+
69+
if (rightItem.createdAt !== leftItem.createdAt) {
70+
return rightItem.createdAt.localeCompare(leftItem.createdAt);
71+
}
72+
73+
return rightItem.id.localeCompare(leftItem.id);
74+
});
75+
}

test/rules/run.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,25 @@ async function testInvitedCookCanReadHouseholdInventoryAndLogs(testEnv: RulesTes
228228
await assertSucceeds(getDocs(collection(cookDb, 'households', householdId, 'logs')));
229229
}
230230

231+
async function testOwnerAndCookCanReadUnknownIngredientQueue(testEnv: RulesTestEnvironment): Promise<void> {
232+
await seedOwnerHousehold(testEnv, cookEmail);
233+
await seedUnknownQueueItem(testEnv);
234+
235+
const ownerDb = getAuthenticatedDb(testEnv, ownerUid, ownerEmail);
236+
const cookDb = getAuthenticatedDb(testEnv, cookUid, cookEmail);
237+
238+
await assertSucceeds(getDocs(collection(ownerDb, 'households', householdId, 'unknownIngredientQueue')));
239+
await assertSucceeds(getDocs(collection(cookDb, 'households', householdId, 'unknownIngredientQueue')));
240+
}
241+
242+
async function testNonMemberCannotReadUnknownIngredientQueue(testEnv: RulesTestEnvironment): Promise<void> {
243+
await seedOwnerHousehold(testEnv, cookEmail);
244+
await seedUnknownQueueItem(testEnv);
245+
246+
const intruderDb = getAuthenticatedDb(testEnv, intruderUid, 'intruder@example.com');
247+
await assertFails(getDocs(collection(intruderDb, 'households', householdId, 'unknownIngredientQueue')));
248+
}
249+
231250
async function testInvitedCookCannotWriteMealsOrDeleteInventory(testEnv: RulesTestEnvironment): Promise<void> {
232251
await seedOwnerHousehold(testEnv, cookEmail);
233252
await seedInventoryItem(testEnv);
@@ -488,6 +507,14 @@ async function runAllTests(testEnv: RulesTestEnvironment): Promise<void> {
488507
name: 'Invited cook can read household, inventory, and logs',
489508
run: testInvitedCookCanReadHouseholdInventoryAndLogs,
490509
},
510+
{
511+
name: 'Owner and invited cook can read unknown ingredient queue',
512+
run: testOwnerAndCookCanReadUnknownIngredientQueue,
513+
},
514+
{
515+
name: 'Non-member cannot read unknown ingredient queue',
516+
run: testNonMemberCannotReadUnknownIngredientQueue,
517+
},
491518
{
492519
name: 'Invited cook cannot write meals or delete inventory',
493520
run: testInvitedCookCannotWriteMealsOrDeleteInventory,

test/unit/run.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,13 @@ import { validateAiParseResult } from '../../src/services/aiValidation';
44
import { buildPantryLog } from '../../src/services/logService';
55
import { sanitizeFirestorePayload } from '../../src/utils/firestorePayload';
66
import { getIngredientNativeContextLabel, resolveIngredientVisual } from '../../src/utils/ingredientVisuals';
7+
import {
8+
getUnknownQueueLoadErrorMessage,
9+
isFirestoreFailedPreconditionError,
10+
isFirestorePermissionDeniedError,
11+
sortUnknownIngredientQueueItemsByCreatedAt,
12+
toFirestoreListenerErrorInfo,
13+
} from '../../src/utils/unknownQueue';
714
import {
815
getLocalizedCategoryName,
916
getPantryCategoryLabel,
@@ -333,6 +340,63 @@ function testIngredientVisualCategoryFallback(): void {
333340
assert.equal(visual.catalogMatch, undefined);
334341
}
335342

343+
function testUnknownQueueErrorParsingAndMessaging(): void {
344+
const permissionDenied = toFirestoreListenerErrorInfo({
345+
code: 'permission-denied',
346+
message: 'Missing or insufficient permissions.',
347+
name: 'FirebaseError',
348+
});
349+
assert.equal(isFirestorePermissionDeniedError(permissionDenied), true);
350+
assert.equal(getUnknownQueueLoadErrorMessage(permissionDenied), 'Unknown ingredient queue access denied. Deploy latest Firestore rules and retry.');
351+
352+
const failedPrecondition = toFirestoreListenerErrorInfo({
353+
code: 'failed-precondition',
354+
message: 'The query requires an index.',
355+
name: 'FirebaseError',
356+
});
357+
assert.equal(isFirestoreFailedPreconditionError(failedPrecondition), true);
358+
assert.equal(getUnknownQueueLoadErrorMessage(failedPrecondition), 'Unknown ingredient queue index is missing. Showing fallback order while index is provisioned.');
359+
360+
const unknownError = toFirestoreListenerErrorInfo(new Error('boom'));
361+
assert.equal(isFirestoreFailedPreconditionError(unknownError), false);
362+
assert.equal(isFirestorePermissionDeniedError(unknownError), false);
363+
assert.equal(getUnknownQueueLoadErrorMessage(unknownError), 'Failed to load unknown ingredient queue.');
364+
}
365+
366+
function testUnknownQueueFallbackSortOrder(): void {
367+
const sorted = sortUnknownIngredientQueueItemsByCreatedAt([
368+
{
369+
id: 'queue-2',
370+
name: 'A',
371+
category: 'spices',
372+
status: 'open',
373+
requestedStatus: 'low',
374+
createdAt: '2026-03-25T08:00:00.000Z',
375+
createdBy: 'cook',
376+
},
377+
{
378+
id: 'queue-1',
379+
name: 'B',
380+
category: 'staples',
381+
status: 'open',
382+
requestedStatus: 'low',
383+
createdAt: '2026-03-25T10:00:00.000Z',
384+
createdBy: 'owner',
385+
},
386+
{
387+
id: 'queue-3',
388+
name: 'C',
389+
category: 'veggies',
390+
status: 'open',
391+
requestedStatus: 'out',
392+
createdAt: 'invalid-date',
393+
createdBy: 'cook',
394+
},
395+
]);
396+
397+
assert.deepEqual(sorted.map((item) => item.id), ['queue-1', 'queue-2', 'queue-3']);
398+
}
399+
336400
function run(): void {
337401
testPantryCategoryNormalization();
338402
testPantryCategoryLabels();
@@ -353,6 +417,8 @@ function run(): void {
353417
testIngredientNativeContextLabel();
354418
testIngredientVisualExistingIconFallback();
355419
testIngredientVisualCategoryFallback();
420+
testUnknownQueueErrorParsingAndMessaging();
421+
testUnknownQueueFallbackSortOrder();
356422
console.log('All unit tests passed.');
357423
}
358424

0 commit comments

Comments
 (0)