@striae-org/striae 6.1.3 → 6.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/app/components/actions/case-manage/operations.ts +17 -0
  2. package/app/components/auth/mfa-enrollment.tsx +10 -8
  3. package/app/components/auth/mfa-verification.tsx +10 -10
  4. package/app/components/navbar/case-modals/all-cases-modal.tsx +93 -109
  5. package/app/components/sidebar/cases/case-sidebar.tsx +40 -22
  6. package/app/components/sidebar/files/files-modal.tsx +5 -0
  7. package/app/components/sidebar/sidebar-container.tsx +2 -0
  8. package/app/components/sidebar/sidebar.tsx +4 -0
  9. package/app/components/user/mfa-phone-update.tsx +11 -9
  10. package/app/components/user/mfa-totp-section.tsx +8 -7
  11. package/app/root.tsx +1 -0
  12. package/app/routes/auth/{login.example.tsx → login.tsx} +18 -18
  13. package/app/routes/striae/striae.tsx +82 -46
  14. package/app/utils/data/confirmation-summary/summary-core.ts +6 -0
  15. package/app/utils/data/operations/confirmation-summary-operations.ts +3 -1
  16. package/app/utils/data/permissions.ts +29 -4
  17. package/package.json +138 -140
  18. package/public/_headers +11 -1
  19. package/public/robots.txt +2 -0
  20. package/scripts/deploy-config/modules/scaffolding.sh +0 -17
  21. package/worker-configuration.d.ts +2 -2
  22. package/workers/audit-worker/package.json +1 -1
  23. package/workers/audit-worker/wrangler.jsonc.example +1 -1
  24. package/workers/data-worker/package.json +1 -1
  25. package/workers/data-worker/wrangler.jsonc.example +1 -1
  26. package/workers/image-worker/package.json +1 -1
  27. package/workers/image-worker/wrangler.jsonc.example +1 -1
  28. package/workers/pdf-worker/package.json +1 -1
  29. package/workers/pdf-worker/wrangler.jsonc.example +1 -1
  30. package/workers/user-worker/package.json +1 -1
  31. package/workers/user-worker/wrangler.jsonc.example +1 -1
  32. package/wrangler.toml.example +1 -1
  33. /package/app/routes/auth/{login.module.example.css → login.module.css} +0 -0
@@ -5,6 +5,7 @@ import styles from './sidebar.module.css';
5
5
  import { CaseSidebar } from './cases/case-sidebar';
6
6
  import { Toast } from '../toast/toast';
7
7
  import { type FileData } from '~/types';
8
+ import { type UserConfirmationSummaryDocument } from '~/utils/data';
8
9
 
9
10
  interface SidebarProps {
10
11
  user: User;
@@ -26,6 +27,7 @@ interface SidebarProps {
26
27
  isUploading?: boolean;
27
28
  onUploadStatusChange?: (isUploading: boolean) => void;
28
29
  onOpenCaseExport?: () => void;
30
+ initialConfirmationSummary?: UserConfirmationSummaryDocument;
29
31
  }
30
32
 
31
33
  export const Sidebar = ({
@@ -46,6 +48,7 @@ export const Sidebar = ({
46
48
  isUploading: initialIsUploading = false,
47
49
  onUploadStatusChange,
48
50
  onOpenCaseExport,
51
+ initialConfirmationSummary,
49
52
  }: SidebarProps) => {
50
53
  const [isUploading, setIsUploading] = useState(initialIsUploading);
51
54
  const [toastMessage, setToastMessage] = useState('');
@@ -97,6 +100,7 @@ export const Sidebar = ({
97
100
  onUploadStatusChange={handleUploadStatusChange}
98
101
  onUploadComplete={handleUploadComplete}
99
102
  onOpenCaseExport={onOpenCaseExport}
103
+ initialConfirmationSummary={initialConfirmationSummary}
100
104
  />
101
105
  <Toast
102
106
  message={toastMessage}
@@ -1,4 +1,4 @@
1
- import { useCallback, useEffect, useState } from 'react';
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
2
  import {
3
3
  EmailAuthProvider,
4
4
  getMultiFactorResolver,
@@ -56,7 +56,7 @@ export const MfaPhoneUpdateSection = ({
56
56
  const [mfaReauthVerificationCode, setMfaReauthVerificationCode] = useState('');
57
57
  const [isMfaReauthCodeSent, setIsMfaReauthCodeSent] = useState(false);
58
58
  const [isMfaReauthLoading, setIsMfaReauthLoading] = useState(false);
59
- const [recaptchaVerifier, setRecaptchaVerifier] = useState<RecaptchaVerifier | null>(null);
59
+ const recaptchaVerifierRef = useRef<RecaptchaVerifier | null>(null);
60
60
 
61
61
  const isMfaBusy = isMfaLoading || isMfaReauthLoading;
62
62
  const hasMfaPhoneInput = mfaPhoneInput.trim().length > 0;
@@ -125,7 +125,8 @@ export const MfaPhoneUpdateSection = ({
125
125
  return;
126
126
  }
127
127
 
128
- if (!recaptchaVerifier) {
128
+ const captchaVerifier = recaptchaVerifierRef.current;
129
+ if (!captchaVerifier) {
129
130
  setMfaError(getValidationError('MFA_RECAPTCHA_ERROR'));
130
131
  setMfaSuccess('');
131
132
  return;
@@ -144,7 +145,7 @@ export const MfaPhoneUpdateSection = ({
144
145
  };
145
146
 
146
147
  const phoneAuthProvider = new PhoneAuthProvider(auth);
147
- const verificationId = await phoneAuthProvider.verifyPhoneNumber(phoneInfoOptions, recaptchaVerifier);
148
+ const verificationId = await phoneAuthProvider.verifyPhoneNumber(phoneInfoOptions, captchaVerifier);
148
149
 
149
150
  setMfaVerificationId(verificationId);
150
151
  setIsMfaCodeSent(true);
@@ -208,7 +209,7 @@ export const MfaPhoneUpdateSection = ({
208
209
  const { message, data } = handleAuthError(err);
209
210
 
210
211
  if (data?.code === 'auth/multi-factor-auth-required') {
211
- if (!recaptchaVerifier) {
212
+ if (!recaptchaVerifierRef.current) {
212
213
  setMfaSuccess('');
213
214
  setMfaError(getValidationError('MFA_RECAPTCHA_ERROR'));
214
215
  return;
@@ -249,7 +250,8 @@ export const MfaPhoneUpdateSection = ({
249
250
  return;
250
251
  }
251
252
 
252
- if (!recaptchaVerifier) {
253
+ const captchaVerifier = recaptchaVerifierRef.current;
254
+ if (!captchaVerifier) {
253
255
  setMfaSuccess('');
254
256
  setMfaError(getValidationError('MFA_RECAPTCHA_ERROR'));
255
257
  return;
@@ -266,7 +268,7 @@ export const MfaPhoneUpdateSection = ({
266
268
  session: mfaReauthResolver.session,
267
269
  };
268
270
 
269
- const verificationId = await phoneAuthProvider.verifyPhoneNumber(phoneInfoOptions, recaptchaVerifier);
271
+ const verificationId = await phoneAuthProvider.verifyPhoneNumber(phoneInfoOptions, captchaVerifier);
270
272
  setMfaReauthVerificationId(verificationId);
271
273
  setMfaReauthVerificationCode('');
272
274
  setIsMfaReauthCodeSent(true);
@@ -502,11 +504,11 @@ export const MfaPhoneUpdateSection = ({
502
504
  },
503
505
  });
504
506
 
505
- setRecaptchaVerifier(verifier);
507
+ recaptchaVerifierRef.current = verifier;
506
508
 
507
509
  return () => {
508
510
  verifier.clear();
509
- setRecaptchaVerifier(null);
511
+ recaptchaVerifierRef.current = null;
510
512
  };
511
513
  }, [isOpen, user]);
512
514
 
@@ -1,4 +1,4 @@
1
- import { useState, useCallback, useEffect } from 'react';
1
+ import { useState, useCallback, useEffect, useRef } from 'react';
2
2
  import {
3
3
  EmailAuthProvider,
4
4
  getMultiFactorResolver,
@@ -49,7 +49,7 @@ export const MfaTotpSection = ({
49
49
  const [reauthVerificationCode, setReauthVerificationCode] = useState('');
50
50
  const [isReauthCodeSent, setIsReauthCodeSent] = useState(false);
51
51
  const [isReauthLoading, setIsReauthLoading] = useState(false);
52
- const [recaptchaVerifier, setRecaptchaVerifier] = useState<RecaptchaVerifier | null>(null);
52
+ const recaptchaVerifierRef = useRef<RecaptchaVerifier | null>(null);
53
53
 
54
54
  const isBusy = isLoading || isReauthLoading;
55
55
 
@@ -98,11 +98,11 @@ export const MfaTotpSection = ({
98
98
  setError(getValidationError('MFA_RECAPTCHA_EXPIRED'));
99
99
  },
100
100
  });
101
- setRecaptchaVerifier(verifier);
101
+ recaptchaVerifierRef.current = verifier;
102
102
 
103
103
  return () => {
104
104
  verifier.clear();
105
- setRecaptchaVerifier(null);
105
+ recaptchaVerifierRef.current = null;
106
106
  };
107
107
  }, [isOpen, user]);
108
108
 
@@ -168,7 +168,7 @@ export const MfaTotpSection = ({
168
168
  const { data, message } = handleAuthError(err);
169
169
 
170
170
  if (data?.code === 'auth/multi-factor-auth-required') {
171
- if (!recaptchaVerifier) {
171
+ if (!recaptchaVerifierRef.current) {
172
172
  setError(getValidationError('MFA_RECAPTCHA_ERROR'));
173
173
  return;
174
174
  }
@@ -199,7 +199,8 @@ export const MfaTotpSection = ({
199
199
  };
200
200
 
201
201
  const handleSendReauthCode = async () => {
202
- if (!reauthResolver || !reauthHint || !recaptchaVerifier) {
202
+ const captchaVerifier = recaptchaVerifierRef.current;
203
+ if (!reauthResolver || !reauthHint || !captchaVerifier) {
203
204
  setError(getValidationError('MFA_RECAPTCHA_ERROR'));
204
205
  return;
205
206
  }
@@ -211,7 +212,7 @@ export const MfaTotpSection = ({
211
212
  const phoneAuthProvider = new PhoneAuthProvider(auth);
212
213
  const verificationId = await phoneAuthProvider.verifyPhoneNumber(
213
214
  { multiFactorHint: reauthHint, session: reauthResolver.session },
214
- recaptchaVerifier
215
+ captchaVerifier
215
216
  );
216
217
  setReauthVerificationId(verificationId);
217
218
  setReauthVerificationCode('');
package/app/root.tsx CHANGED
@@ -64,6 +64,7 @@ export function Layout({ children }: { children: React.ReactNode }) {
64
64
  <head>
65
65
  <meta charSet="utf-8" />
66
66
  <meta name="viewport" content="width=device-width, initial-scale=1" />
67
+ <meta name="robots" content="noindex, nofollow" />
67
68
  <style dangerouslySetInnerHTML={{ __html: themeStyles }} />
68
69
  <Links />
69
70
  </head>
@@ -147,7 +147,24 @@ export const Login = () => {
147
147
  // On network/API errors, throw error to prevent login
148
148
  throw new Error('System error. Please try logging in at a later time.');
149
149
  }
150
- };
150
+ };
151
+
152
+ // Add proper sign out handling
153
+ const handleSignOut = async () => {
154
+ try {
155
+ await auth.signOut();
156
+ setUser(null);
157
+ setIsLoading(false);
158
+ setShowMfaEnrollment(false);
159
+ setShowMfaVerification(false);
160
+ setMfaResolver(null);
161
+ setIsWelcomeToastVisible(false);
162
+ setWelcomeToastType('success');
163
+ shouldShowWelcomeToastRef.current = false;
164
+ } catch (err) {
165
+ console.error('Sign out error:', err);
166
+ }
167
+ };
151
168
 
152
169
  useEffect(() => {
153
170
  const unsubscribe = onAuthStateChanged(auth, async (user) => {
@@ -436,23 +453,6 @@ export const Login = () => {
436
453
  }
437
454
  };
438
455
 
439
- // Add proper sign out handling
440
- const handleSignOut = async () => {
441
- try {
442
- await auth.signOut();
443
- setUser(null);
444
- setIsLoading(false);
445
- setShowMfaEnrollment(false);
446
- setShowMfaVerification(false);
447
- setMfaResolver(null);
448
- setIsWelcomeToastVisible(false);
449
- setWelcomeToastType('success');
450
- shouldShowWelcomeToastRef.current = false;
451
- } catch (err) {
452
- console.error('Sign out error:', err);
453
- }
454
- };
455
-
456
456
  // MFA handlers
457
457
  const handleMfaSuccess = () => {
458
458
  setShowMfaVerification(false);
@@ -10,7 +10,7 @@ import { ExportConfirmationsModal } from '~/components/navbar/case-modals/export
10
10
  import { Toolbar } from '~/components/toolbar/toolbar';
11
11
  import { Canvas } from '~/components/canvas/canvas';
12
12
  import { Toast, type ToastType } from '~/components/toast/toast';
13
- import { getImageUrl, fetchFiles, deleteFile } from '~/components/actions/image-manage';
13
+ import { getImageUrl, deleteFile } from '~/components/actions/image-manage';
14
14
  import { getNotes, saveNotes } from '~/components/actions/notes-manage';
15
15
  import { generatePDF } from '~/components/actions/generate-pdf';
16
16
  import { exportConfirmationData } from '~/components/actions/confirm-export';
@@ -20,9 +20,9 @@ import { NotesEditorModal } from '~/components/sidebar/notes/notes-editor-modal'
20
20
  import { UserAuditViewer } from '~/components/audit/user-audit-viewer';
21
21
  import { fetchUserApi } from '~/utils/api';
22
22
  import { type AnnotationData, type FileData, type ExportOptions } from '~/types';
23
- import { validateCaseNumber, renameCase, deleteCase, checkExistingCase, createNewCase, archiveCase, getCaseArchiveDetails } from '~/components/actions/case-manage';
23
+ import { validateCaseNumber, renameCase, deleteCase, checkExistingCase, createNewCase, archiveCase, deriveCaseArchiveDetails } from '~/components/actions/case-manage';
24
24
  import { checkReadOnlyCaseExists, deleteReadOnlyCase } from '~/components/actions/case-review';
25
- import { canCreateCase, getCaseConfirmationSummary } from '~/utils/data';
25
+ import { canCreateCase, getCaseConfirmationSummary, getCaseData, getConfirmationSummaryDocument, type UserConfirmationSummaryDocument } from '~/utils/data';
26
26
  import {
27
27
  resolveEarliestAnnotationTimestamp,
28
28
  CREATE_READ_ONLY_CASE_EXISTS_ERROR,
@@ -64,6 +64,7 @@ export const Striae = ({ user }: StriaePage) => {
64
64
  const [isUploading, setIsUploading] = useState(false);
65
65
  const [isReadOnlyCase, setIsReadOnlyCase] = useState(false);
66
66
  const [isReviewOnlyCase, setIsReviewOnlyCase] = useState(false);
67
+ const [initialConfirmationSummary, setInitialConfirmationSummary] = useState<UserConfirmationSummaryDocument | undefined>(undefined);
67
68
 
68
69
  // Annotation states
69
70
  const [activeAnnotations, setActiveAnnotations] = useState<Set<string>>(new Set());
@@ -189,41 +190,95 @@ export const Striae = ({ user }: StriaePage) => {
189
190
  setImageId(undefined);
190
191
  };
191
192
 
192
- // Check if current case is read-only when case changes
193
+ const showNotification = (
194
+ message: string,
195
+ type: ToastType = 'success',
196
+ duration = 4000
197
+ ) => {
198
+ setToastType(type);
199
+ setToastMessage(message);
200
+ setToastDuration(duration);
201
+ setShowToast(true);
202
+ };
203
+
204
+ const closeToast = () => {
205
+ setShowToast(false);
206
+ };
207
+
208
+ // Tracks whether the current case load was triggered by loadCaseIntoWorkspace.
209
+ // A ref (not state) so it can be read inside the metadata effect without
210
+ // becoming a dependency that would re-trigger the fetch on status changes.
211
+ const loadInitiatedRef = useRef(false);
212
+
213
+ // On case change: load case data, read-only status, archive details, and
214
+ // pre-fetch the confirmation summary — all in a single parallel batch to
215
+ // avoid redundant round-trips to the user and data workers.
193
216
  useEffect(() => {
194
- const checkReadOnlyStatus = async () => {
217
+ let isCancelled = false;
218
+
219
+ const loadCaseMetadata = async () => {
195
220
  if (!currentCase || !user?.uid) {
196
221
  setIsReadOnlyCase(false);
197
222
  setIsReviewOnlyCase(false);
223
+ setArchiveDetails({ archived: false });
224
+ setFiles([]);
225
+ setInitialConfirmationSummary(undefined);
198
226
  return;
199
227
  }
200
228
 
201
229
  try {
202
230
  // Imported review cases are tracked in the user's read-only case list.
203
231
  // This includes archived ZIP imports and distinguishes them from manually archived regular cases.
204
- const readOnlyCaseEntry = await checkReadOnlyCaseExists(user, currentCase);
205
- const details = await getCaseArchiveDetails(user, currentCase);
232
+ // Individual .catch(() => null) guards prevent a single failing call from aborting the batch.
233
+ const [readOnlyCaseEntry, caseData, summaryDoc] = await Promise.all([
234
+ checkReadOnlyCaseExists(user, currentCase).catch(() => null),
235
+ getCaseData(user, currentCase, { skipValidation: true }).catch(() => null),
236
+ getConfirmationSummaryDocument(user).catch(() => null),
237
+ ]);
238
+
239
+ if (isCancelled) return;
240
+
206
241
  const reviewOnly = Boolean(readOnlyCaseEntry);
242
+ const details = deriveCaseArchiveDetails(caseData);
207
243
  setIsReviewOnlyCase(reviewOnly);
208
244
  setIsReadOnlyCase(reviewOnly || details.archived);
209
245
  setArchiveDetails(details);
246
+ setFiles(caseData?.files ?? []);
247
+ setInitialConfirmationSummary(summaryDoc ?? undefined);
248
+ // Only show toast for loads triggered via loadCaseIntoWorkspace.
249
+ // Direct setCurrentCase calls (e.g. case creation) handle their own notifications.
250
+ if (loadInitiatedRef.current) {
251
+ showNotification(`Case ${currentCase} loaded successfully.`, 'success');
252
+ loadInitiatedRef.current = false;
253
+ }
210
254
  } catch (error) {
211
- console.error('Error checking read-only status:', error);
255
+ if (isCancelled) return;
256
+ console.error('Error loading case metadata:', error);
212
257
  setIsReadOnlyCase(false);
213
258
  setIsReviewOnlyCase(false);
214
259
  setArchiveDetails({ archived: false });
260
+ setFiles([]);
261
+ setInitialConfirmationSummary(undefined);
262
+ if (loadInitiatedRef.current) {
263
+ showNotification(`Failed to load case ${currentCase}. Please try again.`, 'error');
264
+ loadInitiatedRef.current = false;
265
+ }
215
266
  }
216
267
  };
217
268
 
218
- checkReadOnlyStatus();
269
+ void loadCaseMetadata();
270
+ return () => {
271
+ isCancelled = true;
272
+ };
219
273
  }, [currentCase, user]);
220
274
 
221
- // Disable box annotation mode when notes sidebar is opened
222
- useEffect(() => {
223
- if (showNotes && isBoxAnnotationMode) {
224
- setIsBoxAnnotationMode(false);
225
- }
226
- }, [showNotes, isBoxAnnotationMode]);
275
+ // Derived early so downstream handlers (handleToolSelect) can reference them.
276
+ const hasLoadedImage = !!(selectedImage && selectedImage !== '/clear.jpg' && imageLoaded);
277
+ const isCurrentImageConfirmed = hasLoadedImage && !!annotationData?.confirmationData;
278
+ // Derive the effective notes open state — notes can only be open when an image is loaded.
279
+ const effectiveShowNotes = showNotes && hasLoadedImage;
280
+ // Box annotation mode is mutually exclusive with the notes panel being open.
281
+ const effectiveIsBoxAnnotationMode = isBoxAnnotationMode && !effectiveShowNotes;
227
282
 
228
283
  // Handler for toolbar annotation selection
229
284
  const handleToolSelect = (toolId: string, active: boolean) => {
@@ -240,7 +295,7 @@ export const Striae = ({ user }: StriaePage) => {
240
295
 
241
296
  // Handle box annotation mode (prevent when notes are open, read-only, or confirmed)
242
297
  if (toolId === 'box') {
243
- setIsBoxAnnotationMode(active && !showNotes && !isReadOnlyCase && !annotationData?.confirmationData);
298
+ setIsBoxAnnotationMode(active && !effectiveShowNotes && !isReadOnlyCase && !annotationData?.confirmationData);
244
299
  }
245
300
  };
246
301
 
@@ -276,22 +331,6 @@ export const Striae = ({ user }: StriaePage) => {
276
331
  });
277
332
  };
278
333
 
279
- const showNotification = (
280
- message: string,
281
- type: ToastType = 'success',
282
- duration = 4000
283
- ) => {
284
- setToastType(type);
285
- setToastMessage(message);
286
- setToastDuration(duration);
287
- setShowToast(true);
288
- };
289
-
290
- // Close toast notification
291
- const closeToast = () => {
292
- setShowToast(false);
293
- };
294
-
295
334
  const handleExport = async (
296
335
  exportCaseNumber: string,
297
336
  designatedReviewerEmail?: string,
@@ -572,11 +611,14 @@ export const Striae = ({ user }: StriaePage) => {
572
611
  };
573
612
 
574
613
  const loadCaseIntoWorkspace = async (caseToLoad: string) => {
614
+ if (caseToLoad === currentCase) {
615
+ showNotification(`Case ${caseToLoad} is already loaded.`, 'success');
616
+ return;
617
+ }
618
+ loadInitiatedRef.current = true;
575
619
  setCurrentCase(caseToLoad);
576
620
  setShowNotes(false);
577
- const loadedFiles = await fetchFiles(user, caseToLoad, { skipValidation: true });
578
- setFiles(loadedFiles);
579
- showNotification(`Case ${caseToLoad} loaded successfully.`, 'success');
621
+ showNotification(`Loading case ${caseToLoad}...`, 'loading', 0);
580
622
  };
581
623
 
582
624
  const handleOpenCaseSubmit = async (nextCaseNumber: string) => {
@@ -742,15 +784,6 @@ export const Striae = ({ user }: StriaePage) => {
742
784
  }
743
785
  };
744
786
 
745
- const hasLoadedImage = !!(selectedImage && selectedImage !== '/clear.jpg' && imageLoaded);
746
- const isCurrentImageConfirmed = hasLoadedImage && !!annotationData?.confirmationData;
747
-
748
- useEffect(() => {
749
- if (showNotes && !hasLoadedImage) {
750
- setShowNotes(false);
751
- }
752
- }, [showNotes, hasLoadedImage]);
753
-
754
787
  // Automatic save handler for annotation updates
755
788
  const handleAnnotationUpdate = async (data: AnnotationData) => {
756
789
  if (annotationData?.confirmationData) {
@@ -871,6 +904,7 @@ export const Striae = ({ user }: StriaePage) => {
871
904
  confirmationSaveVersion={confirmationSaveVersion}
872
905
  isUploading={isUploading}
873
906
  onUploadStatusChange={setIsUploading}
907
+ initialConfirmationSummary={initialConfirmationSummary}
874
908
  />
875
909
  <main className={styles.mainContent}>
876
910
  <div className={styles.canvasArea}>
@@ -896,7 +930,7 @@ export const Striae = ({ user }: StriaePage) => {
896
930
  error={error ?? ''}
897
931
  activeAnnotations={activeAnnotations}
898
932
  annotationData={annotationData}
899
- isBoxAnnotationMode={isBoxAnnotationMode}
933
+ isBoxAnnotationMode={effectiveIsBoxAnnotationMode}
900
934
  boxAnnotationColor={boxAnnotationColor}
901
935
  onAnnotationUpdate={handleAnnotationUpdate}
902
936
  isReadOnly={isReadOnlyCase}
@@ -923,6 +957,7 @@ export const Striae = ({ user }: StriaePage) => {
923
957
  currentCase={currentCase || ''}
924
958
  user={user}
925
959
  confirmationSaveVersion={confirmationSaveVersion}
960
+ initialConfirmationSummary={initialConfirmationSummary}
926
961
  />
927
962
  <FilesModal
928
963
  isOpen={isFilesModalOpen}
@@ -936,9 +971,10 @@ export const Striae = ({ user }: StriaePage) => {
936
971
  isReadOnly={isReadOnlyCase}
937
972
  selectedFileId={imageId}
938
973
  confirmationSaveVersion={confirmationSaveVersion}
974
+ initialConfirmationSummary={initialConfirmationSummary}
939
975
  />
940
976
  <NotesEditorModal
941
- isOpen={showNotes}
977
+ isOpen={effectiveShowNotes}
942
978
  onClose={() => setShowNotes(false)}
943
979
  currentCase={currentCase}
944
980
  user={user}
@@ -26,6 +26,12 @@ export interface UserConfirmationSummaryDocument {
26
26
  export interface ConfirmationSummaryEnsureOptions {
27
27
  forceRefresh?: boolean;
28
28
  maxAgeMs?: number;
29
+ /**
30
+ * Pre-fetched summary document to use instead of fetching from the data worker.
31
+ * Useful when the document has already been fetched in a parallel request, such as
32
+ * during initial case load, to avoid a redundant round-trip.
33
+ */
34
+ prefetchedSummary?: UserConfirmationSummaryDocument;
29
35
  }
30
36
 
31
37
  export interface ConfirmationSummaryTelemetry {
@@ -131,7 +131,9 @@ export const ensureCaseConfirmationSummary = async (
131
131
  throw new Error(`Access denied: ${accessCheck.reason}`);
132
132
  }
133
133
 
134
- const summary = await getConfirmationSummaryDocument(user);
134
+ const summary = options.prefetchedSummary
135
+ ? structuredClone(options.prefetchedSummary)
136
+ : await getConfirmationSummaryDocument(user);
135
137
  const existingCase = summary.cases[caseNumber];
136
138
  const filesById: Record<string, FileConfirmationSummary> = existingCase ? { ...existingCase.filesById } : {};
137
139
  const fileIds = new Set(files.map((file) => file.id));
@@ -36,21 +36,43 @@ export interface NotesViewPermission {
36
36
  reason?: string; // Reason if notes cannot be opened
37
37
  }
38
38
 
39
+ const USER_DATA_CACHE_TTL_MS = 30_000;
40
+
41
+ interface UserDataCacheEntry {
42
+ data: UserData | null;
43
+ expiresAt: number;
44
+ }
45
+
46
+ const userDataCache = new Map<string, UserDataCacheEntry>();
47
+
48
+ function invalidateUserDataCache(uid: string): void {
49
+ userDataCache.delete(uid);
50
+ }
51
+
39
52
  /**
40
- * Get user data from KV store
53
+ * Get user data from KV store, with a 30-second in-memory cache to avoid
54
+ * redundant round-trips across the many callers within a single case-load sequence.
41
55
  */
42
56
  export const getUserData = async (user: User): Promise<UserData | null> => {
57
+ const cached = userDataCache.get(user.uid);
58
+ if (cached && Date.now() < cached.expiresAt) {
59
+ return cached.data;
60
+ }
61
+
43
62
  try {
44
63
  const response = await fetchUserApi(user, `/${encodeURIComponent(user.uid)}`, {
45
64
  method: 'GET',
46
65
  });
47
66
 
48
67
  if (response.ok) {
49
- return await response.json() as UserData;
68
+ const data = await response.json() as UserData;
69
+ userDataCache.set(user.uid, { data, expiresAt: Date.now() + USER_DATA_CACHE_TTL_MS });
70
+ return data;
50
71
  }
51
72
 
52
73
  if (response.status === 404) {
53
- return null; // User not found
74
+ userDataCache.set(user.uid, { data: null, expiresAt: Date.now() + USER_DATA_CACHE_TTL_MS });
75
+ return null;
54
76
  }
55
77
 
56
78
  const responseBody = await response.text().catch(() => '');
@@ -142,6 +164,7 @@ export const createUser = async (
142
164
  throw new Error(`Failed to create user data: ${response.status} ${response.statusText}`);
143
165
  }
144
166
 
167
+ invalidateUserDataCache(user.uid);
145
168
  return userData;
146
169
  } catch (error) {
147
170
  console.error('Error creating user data:', error);
@@ -300,7 +323,9 @@ export const updateUserData = async (user: User, updates: Partial<UserData>): Pr
300
323
  throw new Error(`Failed to update user data: ${response.status} - ${errorText}`);
301
324
  }
302
325
 
303
- return await response.json() as UserData;
326
+ const result = await response.json() as UserData;
327
+ invalidateUserDataCache(user.uid);
328
+ return result;
304
329
 
305
330
  } catch (error) {
306
331
  console.error('Error updating user data:', error);