@striae-org/striae 4.2.0 → 4.2.1
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/LICENSE +1 -1
- package/app/components/actions/case-manage.ts +50 -17
- package/app/components/audit/viewer/audit-entries-list.tsx +5 -2
- package/app/components/audit/viewer/use-audit-viewer-data.ts +6 -3
- package/app/components/audit/viewer/use-audit-viewer-export.ts +1 -1
- package/app/components/canvas/confirmation/confirmation.tsx +6 -2
- package/app/components/colors/colors.module.css +4 -3
- package/app/components/navbar/navbar.tsx +34 -9
- package/app/components/sidebar/cases/case-sidebar.tsx +44 -70
- package/app/components/sidebar/cases/cases-modal.tsx +76 -35
- package/app/components/sidebar/cases/cases.module.css +20 -0
- package/app/components/sidebar/files/files-modal.tsx +37 -39
- package/app/components/sidebar/notes/addl-notes-modal.tsx +82 -0
- package/app/components/sidebar/notes/{notes-sidebar.tsx → notes-editor-form.tsx} +37 -74
- package/app/components/sidebar/notes/notes-editor-modal.tsx +5 -7
- package/app/components/sidebar/notes/notes.module.css +27 -11
- package/app/components/sidebar/sidebar-container.tsx +1 -0
- package/app/components/sidebar/sidebar.tsx +3 -0
- package/app/{tailwind.css → global.css} +1 -3
- package/app/hooks/useOverlayDismiss.ts +6 -4
- package/app/root.tsx +1 -1
- package/app/routes/striae/striae.tsx +6 -0
- package/app/services/audit/audit.service.ts +2 -2
- package/app/services/audit/builders/audit-event-builders-case-file.ts +1 -1
- package/app/types/audit.ts +1 -0
- package/app/utils/data/confirmation-summary/summary-core.ts +279 -0
- package/app/utils/data/data-operations.ts +17 -861
- package/app/utils/data/index.ts +11 -1
- package/app/utils/data/operations/batch-operations.ts +113 -0
- package/app/utils/data/operations/case-operations.ts +168 -0
- package/app/utils/data/operations/confirmation-summary-operations.ts +301 -0
- package/app/utils/data/operations/file-annotation-operations.ts +196 -0
- package/app/utils/data/operations/index.ts +7 -0
- package/app/utils/data/operations/signing-operations.ts +225 -0
- package/app/utils/data/operations/types.ts +42 -0
- package/app/utils/data/operations/validation-operations.ts +48 -0
- package/app/utils/forensics/export-verification.ts +40 -111
- package/functions/api/_shared/firebase-auth.ts +2 -7
- package/functions/api/image/[[path]].ts +20 -23
- package/functions/api/pdf/[[path]].ts +27 -8
- package/package.json +5 -10
- package/scripts/deploy-primershear-emails.sh +1 -1
- 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/src/image-worker.example.ts +16 -5
- package/workers/image-worker/wrangler.jsonc.example +1 -1
- package/workers/keys-worker/package.json +1 -1
- package/workers/keys-worker/wrangler.jsonc.example +1 -1
- package/workers/pdf-worker/package.json +1 -1
- package/workers/pdf-worker/src/formats/format-striae.ts +1 -7
- package/workers/pdf-worker/src/pdf-worker.example.ts +37 -58
- package/workers/pdf-worker/src/report-types.ts +3 -3
- package/workers/pdf-worker/wrangler.jsonc.example +1 -1
- package/workers/user-worker/package.json +1 -1
- package/workers/user-worker/src/user-worker.example.ts +17 -0
- package/workers/user-worker/wrangler.jsonc.example +1 -1
- package/wrangler.toml.example +1 -1
- package/NOTICE +0 -13
- package/app/components/sidebar/notes/notes-modal.tsx +0 -52
- package/postcss.config.js +0 -6
- package/tailwind.config.ts +0 -22
package/app/utils/data/index.ts
CHANGED
|
@@ -1,2 +1,12 @@
|
|
|
1
|
-
export * from './
|
|
1
|
+
export * from './operations';
|
|
2
2
|
export * from './permissions';
|
|
3
|
+
|
|
4
|
+
export {
|
|
5
|
+
getConfirmationSummaryTelemetry,
|
|
6
|
+
resetConfirmationSummaryTelemetry,
|
|
7
|
+
type CaseConfirmationSummary,
|
|
8
|
+
type ConfirmationSummaryEnsureOptions,
|
|
9
|
+
type ConfirmationSummaryTelemetry,
|
|
10
|
+
type FileConfirmationSummary,
|
|
11
|
+
type UserConfirmationSummaryDocument
|
|
12
|
+
} from './confirmation-summary/summary-core';
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type { User } from 'firebase/auth';
|
|
2
|
+
|
|
3
|
+
import { canModifyCase, validateUserSession } from '../permissions';
|
|
4
|
+
import { getCaseData, updateCaseData } from './case-operations';
|
|
5
|
+
import { getFileAnnotations, saveFileAnnotations } from './file-annotation-operations';
|
|
6
|
+
import type { BatchUpdateResult, DataOperationOptions, FileUpdate } from './types';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Update multiple files with annotation data in a single operation.
|
|
10
|
+
*/
|
|
11
|
+
export const batchUpdateFiles = async (
|
|
12
|
+
user: User,
|
|
13
|
+
caseNumber: string,
|
|
14
|
+
updates: FileUpdate[],
|
|
15
|
+
options: DataOperationOptions = {}
|
|
16
|
+
): Promise<BatchUpdateResult> => {
|
|
17
|
+
const result: BatchUpdateResult = {
|
|
18
|
+
successful: [],
|
|
19
|
+
failed: []
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const sessionValidation = await validateUserSession(user);
|
|
24
|
+
if (!sessionValidation.valid) {
|
|
25
|
+
throw new Error(`Session validation failed: ${sessionValidation.reason}`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const modifyCheck = await canModifyCase(user, caseNumber);
|
|
29
|
+
if (!modifyCheck.allowed) {
|
|
30
|
+
throw new Error(`Batch update denied: ${modifyCheck.reason}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const perFileOptions: DataOperationOptions = {
|
|
34
|
+
...options,
|
|
35
|
+
skipValidation: true
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
for (const update of updates) {
|
|
39
|
+
try {
|
|
40
|
+
await saveFileAnnotations(user, caseNumber, update.fileId, update.annotations, perFileOptions);
|
|
41
|
+
result.successful.push(update.fileId);
|
|
42
|
+
} catch (error) {
|
|
43
|
+
result.failed.push({
|
|
44
|
+
fileId: update.fileId,
|
|
45
|
+
error: error instanceof Error ? error.message : 'Unknown error'
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return result;
|
|
51
|
+
} catch (error) {
|
|
52
|
+
for (const update of updates) {
|
|
53
|
+
result.failed.push({
|
|
54
|
+
fileId: update.fileId,
|
|
55
|
+
error: error instanceof Error ? error.message : 'Batch operation failed'
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Duplicate case data from one case to another (for case renaming operations).
|
|
64
|
+
*/
|
|
65
|
+
export const duplicateCaseData = async (
|
|
66
|
+
user: User,
|
|
67
|
+
fromCaseNumber: string,
|
|
68
|
+
toCaseNumber: string,
|
|
69
|
+
options: { skipDestinationCheck?: boolean } = {}
|
|
70
|
+
): Promise<void> => {
|
|
71
|
+
try {
|
|
72
|
+
if (!options.skipDestinationCheck) {
|
|
73
|
+
const accessResult = await canModifyCase(user, toCaseNumber);
|
|
74
|
+
if (!accessResult.allowed) {
|
|
75
|
+
throw new Error(`User does not have permission to create or modify case ${toCaseNumber}: ${accessResult.reason || 'Access denied'}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const sourceCaseData = await getCaseData(user, fromCaseNumber);
|
|
80
|
+
if (!sourceCaseData) {
|
|
81
|
+
throw new Error(`Source case ${fromCaseNumber} not found`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const newCaseData = {
|
|
85
|
+
...sourceCaseData,
|
|
86
|
+
caseNumber: toCaseNumber,
|
|
87
|
+
updatedAt: new Date().toISOString()
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
await updateCaseData(user, toCaseNumber, newCaseData);
|
|
91
|
+
|
|
92
|
+
if (sourceCaseData.files && sourceCaseData.files.length > 0) {
|
|
93
|
+
const updates: FileUpdate[] = [];
|
|
94
|
+
|
|
95
|
+
for (const file of sourceCaseData.files) {
|
|
96
|
+
const annotations = await getFileAnnotations(user, fromCaseNumber, file.id);
|
|
97
|
+
if (annotations) {
|
|
98
|
+
updates.push({
|
|
99
|
+
fileId: file.id,
|
|
100
|
+
annotations
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (updates.length > 0) {
|
|
106
|
+
await batchUpdateFiles(user, toCaseNumber, updates);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
} catch (error) {
|
|
110
|
+
console.error(`Error duplicating case data from ${fromCaseNumber} to ${toCaseNumber}:`, error);
|
|
111
|
+
throw error;
|
|
112
|
+
}
|
|
113
|
+
};
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import type { User } from 'firebase/auth';
|
|
2
|
+
import type { CaseData } from '~/types';
|
|
3
|
+
|
|
4
|
+
import { fetchDataApi } from '../../api';
|
|
5
|
+
import { canAccessCase, canModifyCase, validateUserSession } from '../permissions';
|
|
6
|
+
import type { DataOperationOptions } from './types';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Get case data from R2 storage with validation and error handling.
|
|
10
|
+
*/
|
|
11
|
+
export const getCaseData = async (
|
|
12
|
+
user: User,
|
|
13
|
+
caseNumber: string,
|
|
14
|
+
options: DataOperationOptions = {}
|
|
15
|
+
): Promise<CaseData | null> => {
|
|
16
|
+
try {
|
|
17
|
+
const sessionValidation = await validateUserSession(user);
|
|
18
|
+
if (!sessionValidation.valid) {
|
|
19
|
+
throw new Error(`Session validation failed: ${sessionValidation.reason}`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (options.skipValidation !== true) {
|
|
23
|
+
const accessCheck = await canAccessCase(user, caseNumber);
|
|
24
|
+
if (!accessCheck.allowed) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (!caseNumber || typeof caseNumber !== 'string' || caseNumber.trim() === '') {
|
|
30
|
+
throw new Error('Invalid case number provided');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const response = await fetchDataApi(
|
|
34
|
+
user,
|
|
35
|
+
`/${encodeURIComponent(user.uid)}/${encodeURIComponent(caseNumber)}/data.json`,
|
|
36
|
+
{
|
|
37
|
+
method: 'GET'
|
|
38
|
+
}
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
if (response.status === 404) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (!response.ok) {
|
|
46
|
+
throw new Error(`Failed to fetch case data: ${response.status} ${response.statusText}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const caseData = await response.json() as CaseData;
|
|
50
|
+
return caseData;
|
|
51
|
+
} catch (error) {
|
|
52
|
+
console.error(`Error fetching case data for ${caseNumber}:`, error);
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Update case data in R2 storage with validation and timestamps.
|
|
59
|
+
*/
|
|
60
|
+
export const updateCaseData = async (
|
|
61
|
+
user: User,
|
|
62
|
+
caseNumber: string,
|
|
63
|
+
caseData: CaseData,
|
|
64
|
+
options: DataOperationOptions = {}
|
|
65
|
+
): Promise<void> => {
|
|
66
|
+
try {
|
|
67
|
+
const sessionValidation = await validateUserSession(user);
|
|
68
|
+
if (!sessionValidation.valid) {
|
|
69
|
+
throw new Error(`Session validation failed: ${sessionValidation.reason}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const modifyCheck = await canModifyCase(user, caseNumber);
|
|
73
|
+
if (!modifyCheck.allowed) {
|
|
74
|
+
throw new Error(`Modification denied: ${modifyCheck.reason}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (!caseNumber || typeof caseNumber !== 'string') {
|
|
78
|
+
throw new Error('Invalid case number provided');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (!caseData || typeof caseData !== 'object') {
|
|
82
|
+
throw new Error('Invalid case data provided');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const dataToSave = options.includeTimestamp !== false
|
|
86
|
+
? {
|
|
87
|
+
...caseData,
|
|
88
|
+
updatedAt: new Date().toISOString()
|
|
89
|
+
}
|
|
90
|
+
: caseData;
|
|
91
|
+
|
|
92
|
+
const response = await fetchDataApi(
|
|
93
|
+
user,
|
|
94
|
+
`/${encodeURIComponent(user.uid)}/${encodeURIComponent(caseNumber)}/data.json`,
|
|
95
|
+
{
|
|
96
|
+
method: 'PUT',
|
|
97
|
+
headers: {
|
|
98
|
+
'Content-Type': 'application/json'
|
|
99
|
+
},
|
|
100
|
+
body: JSON.stringify(dataToSave)
|
|
101
|
+
}
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
if (!response.ok) {
|
|
105
|
+
throw new Error(`Failed to update case data: ${response.status} ${response.statusText}`);
|
|
106
|
+
}
|
|
107
|
+
} catch (error) {
|
|
108
|
+
console.error(`Error updating case data for ${caseNumber}:`, error);
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Delete case data from R2 storage with validation.
|
|
115
|
+
*/
|
|
116
|
+
export const deleteCaseData = async (
|
|
117
|
+
user: User,
|
|
118
|
+
caseNumber: string,
|
|
119
|
+
options: DataOperationOptions = {}
|
|
120
|
+
): Promise<void> => {
|
|
121
|
+
try {
|
|
122
|
+
const sessionValidation = await validateUserSession(user);
|
|
123
|
+
if (!sessionValidation.valid) {
|
|
124
|
+
throw new Error(`Session validation failed: ${sessionValidation.reason}`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (options.skipValidation !== true) {
|
|
128
|
+
const modifyCheck = await canModifyCase(user, caseNumber);
|
|
129
|
+
if (!modifyCheck.allowed) {
|
|
130
|
+
throw new Error(`Delete denied: ${modifyCheck.reason}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const response = await fetchDataApi(
|
|
135
|
+
user,
|
|
136
|
+
`/${encodeURIComponent(user.uid)}/${encodeURIComponent(caseNumber)}/data.json`,
|
|
137
|
+
{
|
|
138
|
+
method: 'DELETE'
|
|
139
|
+
}
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
if (!response.ok && response.status !== 404) {
|
|
143
|
+
throw new Error(`Failed to delete case data: ${response.status} ${response.statusText}`);
|
|
144
|
+
}
|
|
145
|
+
} catch (error) {
|
|
146
|
+
console.error(`Error deleting case data for ${caseNumber}:`, error);
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Check if a case exists in storage.
|
|
153
|
+
*/
|
|
154
|
+
export const caseExists = async (
|
|
155
|
+
user: User,
|
|
156
|
+
caseNumber: string
|
|
157
|
+
): Promise<boolean> => {
|
|
158
|
+
try {
|
|
159
|
+
const caseData = await getCaseData(user, caseNumber);
|
|
160
|
+
return caseData !== null;
|
|
161
|
+
} catch (error) {
|
|
162
|
+
if (error instanceof Error && error.message.includes('Access denied')) {
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
console.error(`Error checking case existence for ${caseNumber}:`, error);
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
};
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import type { User } from 'firebase/auth';
|
|
2
|
+
import type { AnnotationData } from '~/types';
|
|
3
|
+
|
|
4
|
+
import { fetchDataApi } from '../../api';
|
|
5
|
+
import { canAccessCase, validateUserSession } from '../permissions';
|
|
6
|
+
import {
|
|
7
|
+
DEFAULT_CONFIRMATION_SUMMARY_MAX_AGE_MS,
|
|
8
|
+
buildConfirmationSummaryPath,
|
|
9
|
+
computeCaseConfirmationAggregate,
|
|
10
|
+
getIsoNow,
|
|
11
|
+
isStaleTimestamp,
|
|
12
|
+
normalizeConfirmationSummaryDocument,
|
|
13
|
+
toFileConfirmationSummary,
|
|
14
|
+
trackCaseHit,
|
|
15
|
+
trackCaseMiss,
|
|
16
|
+
trackEnsureCall,
|
|
17
|
+
trackForceRefreshCall,
|
|
18
|
+
trackMissingFileRefresh,
|
|
19
|
+
trackRefreshedFileEntry,
|
|
20
|
+
trackRemovedFileEntry,
|
|
21
|
+
trackStaleCaseRefresh,
|
|
22
|
+
trackStaleFileRefresh,
|
|
23
|
+
trackSummaryWrite,
|
|
24
|
+
type CaseConfirmationSummary,
|
|
25
|
+
type ConfirmationSummaryEnsureOptions,
|
|
26
|
+
type FileConfirmationSummary,
|
|
27
|
+
type UserConfirmationSummaryDocument
|
|
28
|
+
} from '../confirmation-summary/summary-core';
|
|
29
|
+
|
|
30
|
+
async function saveConfirmationSummaryDocument(
|
|
31
|
+
user: User,
|
|
32
|
+
summary: UserConfirmationSummaryDocument
|
|
33
|
+
): Promise<void> {
|
|
34
|
+
const response = await fetchDataApi(user, buildConfirmationSummaryPath(user), {
|
|
35
|
+
method: 'PUT',
|
|
36
|
+
headers: {
|
|
37
|
+
'Content-Type': 'application/json'
|
|
38
|
+
},
|
|
39
|
+
body: JSON.stringify(summary)
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
if (!response.ok) {
|
|
43
|
+
throw new Error(`Failed to save confirmation summary: ${response.status} ${response.statusText}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function getFileAnnotationsForSummary(
|
|
48
|
+
user: User,
|
|
49
|
+
caseNumber: string,
|
|
50
|
+
fileId: string
|
|
51
|
+
): Promise<AnnotationData | null> {
|
|
52
|
+
try {
|
|
53
|
+
const response = await fetchDataApi(
|
|
54
|
+
user,
|
|
55
|
+
`/${encodeURIComponent(user.uid)}/${encodeURIComponent(caseNumber)}/${encodeURIComponent(fileId)}/data.json`,
|
|
56
|
+
{
|
|
57
|
+
method: 'GET'
|
|
58
|
+
}
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
if (response.status === 404) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (!response.ok) {
|
|
66
|
+
throw new Error(`Failed to fetch file annotations: ${response.status} ${response.statusText}`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return await response.json() as AnnotationData;
|
|
70
|
+
} catch (error) {
|
|
71
|
+
console.error(`Error fetching annotations for ${caseNumber}/${fileId}:`, error);
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export const getConfirmationSummaryDocument = async (
|
|
77
|
+
user: User
|
|
78
|
+
): Promise<UserConfirmationSummaryDocument> => {
|
|
79
|
+
const sessionValidation = await validateUserSession(user);
|
|
80
|
+
if (!sessionValidation.valid) {
|
|
81
|
+
throw new Error(`Session validation failed: ${sessionValidation.reason}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const response = await fetchDataApi(user, buildConfirmationSummaryPath(user), {
|
|
85
|
+
method: 'GET'
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
if (!response.ok) {
|
|
89
|
+
throw new Error(`Failed to fetch confirmation summary: ${response.status} ${response.statusText}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const payload = await response.json().catch(() => null) as unknown;
|
|
93
|
+
return normalizeConfirmationSummaryDocument(payload);
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export const getCaseConfirmationSummary = async (
|
|
97
|
+
user: User,
|
|
98
|
+
caseNumber: string
|
|
99
|
+
): Promise<CaseConfirmationSummary | null> => {
|
|
100
|
+
const summary = await getConfirmationSummaryDocument(user);
|
|
101
|
+
return summary.cases[caseNumber] ?? null;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export const ensureCaseConfirmationSummary = async (
|
|
105
|
+
user: User,
|
|
106
|
+
caseNumber: string,
|
|
107
|
+
files: Array<{ id: string }>,
|
|
108
|
+
options: ConfirmationSummaryEnsureOptions = {}
|
|
109
|
+
): Promise<CaseConfirmationSummary> => {
|
|
110
|
+
trackEnsureCall();
|
|
111
|
+
|
|
112
|
+
const sessionValidation = await validateUserSession(user);
|
|
113
|
+
if (!sessionValidation.valid) {
|
|
114
|
+
throw new Error(`Session validation failed: ${sessionValidation.reason}`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const accessCheck = await canAccessCase(user, caseNumber);
|
|
118
|
+
if (!accessCheck.allowed) {
|
|
119
|
+
throw new Error(`Access denied: ${accessCheck.reason}`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const summary = await getConfirmationSummaryDocument(user);
|
|
123
|
+
const existingCase = summary.cases[caseNumber];
|
|
124
|
+
const filesById: Record<string, FileConfirmationSummary> = existingCase ? { ...existingCase.filesById } : {};
|
|
125
|
+
const fileIds = new Set(files.map((file) => file.id));
|
|
126
|
+
const maxAgeMs =
|
|
127
|
+
typeof options.maxAgeMs === 'number' && Number.isFinite(options.maxAgeMs) && options.maxAgeMs > 0
|
|
128
|
+
? options.maxAgeMs
|
|
129
|
+
: DEFAULT_CONFIRMATION_SUMMARY_MAX_AGE_MS;
|
|
130
|
+
const caseIsStale =
|
|
131
|
+
options.forceRefresh === true ||
|
|
132
|
+
!existingCase ||
|
|
133
|
+
isStaleTimestamp(existingCase.updatedAt, maxAgeMs);
|
|
134
|
+
|
|
135
|
+
if (!existingCase) {
|
|
136
|
+
trackCaseMiss();
|
|
137
|
+
} else {
|
|
138
|
+
trackCaseHit();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (options.forceRefresh === true) {
|
|
142
|
+
trackForceRefreshCall();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (caseIsStale) {
|
|
146
|
+
trackStaleCaseRefresh();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
let changed = !existingCase;
|
|
150
|
+
|
|
151
|
+
for (const fileId of Object.keys(filesById)) {
|
|
152
|
+
if (!fileIds.has(fileId)) {
|
|
153
|
+
delete filesById[fileId];
|
|
154
|
+
trackRemovedFileEntry();
|
|
155
|
+
changed = true;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const filesToRefresh = files
|
|
160
|
+
.map((file) => {
|
|
161
|
+
const existingFileSummary = filesById[file.id];
|
|
162
|
+
if (!existingFileSummary) {
|
|
163
|
+
return {
|
|
164
|
+
fileId: file.id,
|
|
165
|
+
reason: 'missing' as const
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (caseIsStale) {
|
|
170
|
+
return {
|
|
171
|
+
fileId: file.id,
|
|
172
|
+
reason: 'stale' as const
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (isStaleTimestamp(existingFileSummary.updatedAt, maxAgeMs)) {
|
|
177
|
+
return {
|
|
178
|
+
fileId: file.id,
|
|
179
|
+
reason: 'stale' as const
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return null;
|
|
184
|
+
})
|
|
185
|
+
.filter((entry): entry is { fileId: string; reason: 'missing' | 'stale' } => entry !== null);
|
|
186
|
+
|
|
187
|
+
for (const entry of filesToRefresh) {
|
|
188
|
+
if (entry.reason === 'missing') {
|
|
189
|
+
trackMissingFileRefresh();
|
|
190
|
+
} else {
|
|
191
|
+
trackStaleFileRefresh();
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (filesToRefresh.length > 0) {
|
|
196
|
+
const refreshedFiles = await Promise.all(
|
|
197
|
+
filesToRefresh.map(async (entry) => {
|
|
198
|
+
const annotations = await getFileAnnotationsForSummary(user, caseNumber, entry.fileId);
|
|
199
|
+
return {
|
|
200
|
+
fileId: entry.fileId,
|
|
201
|
+
summary: toFileConfirmationSummary(annotations)
|
|
202
|
+
};
|
|
203
|
+
})
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
for (const refreshedFile of refreshedFiles) {
|
|
207
|
+
filesById[refreshedFile.fileId] = refreshedFile.summary;
|
|
208
|
+
trackRefreshedFileEntry();
|
|
209
|
+
changed = true;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const aggregate = computeCaseConfirmationAggregate(filesById);
|
|
214
|
+
const updatedCaseSummary: CaseConfirmationSummary = {
|
|
215
|
+
includeConfirmation: aggregate.includeConfirmation,
|
|
216
|
+
isConfirmed: aggregate.isConfirmed,
|
|
217
|
+
updatedAt: getIsoNow(),
|
|
218
|
+
filesById
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
const aggregateChanged =
|
|
222
|
+
!existingCase ||
|
|
223
|
+
existingCase.includeConfirmation !== updatedCaseSummary.includeConfirmation ||
|
|
224
|
+
existingCase.isConfirmed !== updatedCaseSummary.isConfirmed;
|
|
225
|
+
|
|
226
|
+
if (changed || aggregateChanged || caseIsStale) {
|
|
227
|
+
summary.updatedAt = getIsoNow();
|
|
228
|
+
summary.cases[caseNumber] = updatedCaseSummary;
|
|
229
|
+
await saveConfirmationSummaryDocument(user, summary);
|
|
230
|
+
trackSummaryWrite();
|
|
231
|
+
return updatedCaseSummary;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return existingCase as CaseConfirmationSummary;
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
export const upsertFileConfirmationSummary = async (
|
|
238
|
+
user: User,
|
|
239
|
+
caseNumber: string,
|
|
240
|
+
fileId: string,
|
|
241
|
+
annotationData: AnnotationData | null
|
|
242
|
+
): Promise<void> => {
|
|
243
|
+
const summary = await getConfirmationSummaryDocument(user);
|
|
244
|
+
const caseSummary = summary.cases[caseNumber] ?? {
|
|
245
|
+
includeConfirmation: false,
|
|
246
|
+
isConfirmed: false,
|
|
247
|
+
updatedAt: getIsoNow(),
|
|
248
|
+
filesById: {}
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
caseSummary.filesById[fileId] = toFileConfirmationSummary(annotationData);
|
|
252
|
+
|
|
253
|
+
const aggregate = computeCaseConfirmationAggregate(caseSummary.filesById);
|
|
254
|
+
caseSummary.includeConfirmation = aggregate.includeConfirmation;
|
|
255
|
+
caseSummary.isConfirmed = aggregate.isConfirmed;
|
|
256
|
+
caseSummary.updatedAt = getIsoNow();
|
|
257
|
+
|
|
258
|
+
summary.cases[caseNumber] = caseSummary;
|
|
259
|
+
summary.updatedAt = getIsoNow();
|
|
260
|
+
|
|
261
|
+
await saveConfirmationSummaryDocument(user, summary);
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
export const removeFileConfirmationSummary = async (
|
|
265
|
+
user: User,
|
|
266
|
+
caseNumber: string,
|
|
267
|
+
fileId: string
|
|
268
|
+
): Promise<void> => {
|
|
269
|
+
const summary = await getConfirmationSummaryDocument(user);
|
|
270
|
+
const caseSummary = summary.cases[caseNumber];
|
|
271
|
+
if (!caseSummary || !caseSummary.filesById[fileId]) {
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
delete caseSummary.filesById[fileId];
|
|
276
|
+
|
|
277
|
+
const aggregate = computeCaseConfirmationAggregate(caseSummary.filesById);
|
|
278
|
+
caseSummary.includeConfirmation = aggregate.includeConfirmation;
|
|
279
|
+
caseSummary.isConfirmed = aggregate.isConfirmed;
|
|
280
|
+
caseSummary.updatedAt = getIsoNow();
|
|
281
|
+
|
|
282
|
+
summary.cases[caseNumber] = caseSummary;
|
|
283
|
+
summary.updatedAt = getIsoNow();
|
|
284
|
+
|
|
285
|
+
await saveConfirmationSummaryDocument(user, summary);
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
export const removeCaseConfirmationSummary = async (
|
|
289
|
+
user: User,
|
|
290
|
+
caseNumber: string
|
|
291
|
+
): Promise<void> => {
|
|
292
|
+
const summary = await getConfirmationSummaryDocument(user);
|
|
293
|
+
if (!summary.cases[caseNumber]) {
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
delete summary.cases[caseNumber];
|
|
298
|
+
summary.updatedAt = getIsoNow();
|
|
299
|
+
|
|
300
|
+
await saveConfirmationSummaryDocument(user, summary);
|
|
301
|
+
};
|