@striae-org/striae 6.1.2 → 6.1.4
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.
- package/app/components/actions/case-import/storage-operations.ts +32 -2
- package/app/components/actions/case-manage/operations.ts +17 -0
- package/app/components/auth/mfa-enrollment.tsx +10 -8
- package/app/components/auth/mfa-verification.tsx +10 -10
- package/app/components/navbar/case-modals/all-cases-modal.tsx +93 -109
- package/app/components/sidebar/cases/case-sidebar.tsx +40 -22
- package/app/components/sidebar/files/files-modal.tsx +5 -0
- package/app/components/sidebar/sidebar-container.tsx +2 -0
- package/app/components/sidebar/sidebar.tsx +4 -0
- package/app/components/user/mfa-phone-update.tsx +11 -9
- package/app/components/user/mfa-totp-section.tsx +8 -7
- package/app/routes/auth/login.example.tsx +18 -18
- package/app/routes/striae/striae.tsx +82 -46
- package/app/utils/data/confirmation-summary/summary-core.ts +6 -0
- package/app/utils/data/operations/confirmation-summary-operations.ts +3 -1
- package/app/utils/data/permissions.ts +29 -4
- package/package.json +4 -4
- package/worker-configuration.d.ts +2 -2
- package/workers/audit-worker/package.json +1 -1
- package/workers/audit-worker/wrangler.jsonc.example +1 -1
- package/workers/data-worker/package.json +1 -1
- package/workers/data-worker/wrangler.jsonc.example +1 -1
- package/workers/image-worker/package.json +1 -1
- package/workers/image-worker/wrangler.jsonc.example +1 -1
- package/workers/pdf-worker/package.json +1 -1
- package/workers/pdf-worker/wrangler.jsonc.example +1 -1
- package/workers/user-worker/package.json +1 -1
- package/workers/user-worker/wrangler.jsonc.example +1 -1
- package/wrangler.toml.example +1 -1
|
@@ -16,6 +16,7 @@ import { deleteFile } from '~/components/actions/image-manage';
|
|
|
16
16
|
import {
|
|
17
17
|
ensureCaseConfirmationSummary,
|
|
18
18
|
type FileConfirmationSummary,
|
|
19
|
+
type UserConfirmationSummaryDocument,
|
|
19
20
|
} from '~/utils/data';
|
|
20
21
|
import { type FileData } from '~/types';
|
|
21
22
|
import { DeleteFilesModal } from './delete-files-modal';
|
|
@@ -31,6 +32,7 @@ interface FilesModalProps {
|
|
|
31
32
|
isReadOnly?: boolean;
|
|
32
33
|
selectedFileId?: string;
|
|
33
34
|
confirmationSaveVersion?: number;
|
|
35
|
+
initialConfirmationSummary?: UserConfirmationSummaryDocument;
|
|
34
36
|
}
|
|
35
37
|
|
|
36
38
|
interface ActionNotice {
|
|
@@ -99,6 +101,7 @@ export const FilesModal = ({
|
|
|
99
101
|
isReadOnly = false,
|
|
100
102
|
selectedFileId,
|
|
101
103
|
confirmationSaveVersion = 0,
|
|
104
|
+
initialConfirmationSummary,
|
|
102
105
|
}: FilesModalProps) => {
|
|
103
106
|
const { user } = useContext(AuthContext);
|
|
104
107
|
const [currentPage, setCurrentPage] = useState(0);
|
|
@@ -190,6 +193,7 @@ export const FilesModal = ({
|
|
|
190
193
|
|
|
191
194
|
const caseSummary = await ensureCaseConfirmationSummary(user, currentCase, files, {
|
|
192
195
|
forceRefresh: shouldForceItemTypeSummaryRefresh,
|
|
196
|
+
prefetchedSummary: confirmationSaveVersion === 0 ? initialConfirmationSummary : undefined,
|
|
193
197
|
}).catch((err) => {
|
|
194
198
|
console.error(`Error fetching confirmation summary for case ${currentCase}:`, err);
|
|
195
199
|
return null;
|
|
@@ -214,6 +218,7 @@ export const FilesModal = ({
|
|
|
214
218
|
user,
|
|
215
219
|
confirmationSaveVersion,
|
|
216
220
|
shouldForceItemTypeSummaryRefresh,
|
|
221
|
+
initialConfirmationSummary,
|
|
217
222
|
]);
|
|
218
223
|
|
|
219
224
|
const toggleDeleteSelection = (fileId: string) => {
|
|
@@ -6,6 +6,7 @@ import { Link } from 'react-router';
|
|
|
6
6
|
import { Sidebar } from './sidebar';
|
|
7
7
|
import type { User } from 'firebase/auth';
|
|
8
8
|
import { type FileData } from '~/types';
|
|
9
|
+
import { type UserConfirmationSummaryDocument } from '~/utils/data';
|
|
9
10
|
import styles from './sidebar.module.css';
|
|
10
11
|
import { getAppVersion } from '~/utils/common';
|
|
11
12
|
import { useOverlayDismiss } from '~/hooks/useOverlayDismiss';
|
|
@@ -30,6 +31,7 @@ interface SidebarContainerProps {
|
|
|
30
31
|
isUploading?: boolean;
|
|
31
32
|
onUploadStatusChange?: (isUploading: boolean) => void;
|
|
32
33
|
onOpenCaseExport?: () => void;
|
|
34
|
+
initialConfirmationSummary?: UserConfirmationSummaryDocument;
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
export const SidebarContainer: React.FC<SidebarContainerProps> = (props) => {
|
|
@@ -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
|
|
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
|
-
|
|
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,
|
|
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 (!
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
507
|
+
recaptchaVerifierRef.current = verifier;
|
|
506
508
|
|
|
507
509
|
return () => {
|
|
508
510
|
verifier.clear();
|
|
509
|
-
|
|
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
|
|
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
|
-
|
|
101
|
+
recaptchaVerifierRef.current = verifier;
|
|
102
102
|
|
|
103
103
|
return () => {
|
|
104
104
|
verifier.clear();
|
|
105
|
-
|
|
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 (!
|
|
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
|
-
|
|
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
|
-
|
|
215
|
+
captchaVerifier
|
|
215
216
|
);
|
|
216
217
|
setReauthVerificationId(verificationId);
|
|
217
218
|
setReauthVerificationCode('');
|
|
@@ -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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
205
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
269
|
+
void loadCaseMetadata();
|
|
270
|
+
return () => {
|
|
271
|
+
isCancelled = true;
|
|
272
|
+
};
|
|
219
273
|
}, [currentCase, user]);
|
|
220
274
|
|
|
221
|
-
//
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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 && !
|
|
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
|
-
|
|
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={
|
|
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={
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@striae-org/striae",
|
|
3
|
-
"version": "6.1.
|
|
3
|
+
"version": "6.1.4",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Striae is a specialized, cloud-native platform designed to streamline forensic firearms identification by providing an intuitive environment for digital comparison image annotation, authenticated confirmations, and automated report generation.",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -103,7 +103,7 @@
|
|
|
103
103
|
"dependencies": {
|
|
104
104
|
"@react-router/cloudflare": "^7.14.1",
|
|
105
105
|
"firebase": "^12.12.0",
|
|
106
|
-
"isbot": "^5.1.
|
|
106
|
+
"isbot": "^5.1.39",
|
|
107
107
|
"jszip": "^3.10.1",
|
|
108
108
|
"qrcode": "^1.5.4",
|
|
109
109
|
"react": "^19.2.5",
|
|
@@ -123,7 +123,7 @@
|
|
|
123
123
|
"eslint-plugin-import": "^2.32.0",
|
|
124
124
|
"eslint-plugin-jsx-a11y": "^6.10.2",
|
|
125
125
|
"eslint-plugin-react": "^7.37.5",
|
|
126
|
-
"eslint-plugin-react-hooks": "^7.0
|
|
126
|
+
"eslint-plugin-react-hooks": "^7.1.0",
|
|
127
127
|
"firebase-admin": "^13.8.0",
|
|
128
128
|
"modern-normalize": "^3.0.1",
|
|
129
129
|
"typescript": "^5.9.3",
|
|
@@ -132,7 +132,7 @@
|
|
|
132
132
|
"wrangler": "^4.83.0"
|
|
133
133
|
},
|
|
134
134
|
"overrides": {
|
|
135
|
-
"@tootallnate/once": "3.0.1"
|
|
135
|
+
"@tootallnate/once": "3.0.1"
|
|
136
136
|
},
|
|
137
137
|
"engines": {
|
|
138
138
|
"node": ">=20.19.0"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/* eslint-disable */
|
|
2
|
-
// Generated by Wrangler by running `wrangler types` (hash:
|
|
3
|
-
// Runtime types generated with workerd@1.20250823.0 2026-
|
|
2
|
+
// Generated by Wrangler by running `wrangler types` (hash: 57aa85f6e2227c762f9bb6f456f0a167)
|
|
3
|
+
// Runtime types generated with workerd@1.20250823.0 2026-04-17 nodejs_compat
|
|
4
4
|
declare namespace Cloudflare {
|
|
5
5
|
interface Env {
|
|
6
6
|
ACCOUNT_ID: string;
|