@striae-org/striae 4.2.0 → 4.3.0
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/case-modals/archive-case-modal.module.css +0 -76
- package/app/components/navbar/case-modals/archive-case-modal.tsx +9 -8
- package/app/components/navbar/case-modals/case-modal-shared.module.css +94 -0
- package/app/components/navbar/case-modals/delete-case-modal.module.css +9 -0
- package/app/components/navbar/case-modals/delete-case-modal.tsx +79 -0
- package/app/components/navbar/case-modals/open-case-modal.module.css +2 -1
- package/app/components/navbar/case-modals/rename-case-modal.module.css +0 -72
- package/app/components/navbar/case-modals/rename-case-modal.tsx +9 -8
- package/app/components/navbar/navbar.tsx +34 -9
- package/app/components/sidebar/cases/case-sidebar.tsx +93 -73
- package/app/components/sidebar/cases/cases-modal.module.css +312 -10
- package/app/components/sidebar/cases/cases-modal.tsx +737 -116
- package/app/components/sidebar/cases/cases.module.css +43 -0
- package/app/components/sidebar/files/delete-files-modal.module.css +26 -0
- package/app/components/sidebar/files/delete-files-modal.tsx +94 -0
- package/app/components/sidebar/files/files-modal.module.css +285 -44
- package/app/components/sidebar/files/files-modal.tsx +482 -177
- package/app/components/sidebar/notes/addl-notes-modal.tsx +82 -0
- package/app/components/sidebar/notes/class-details-fields.tsx +146 -0
- package/app/components/sidebar/notes/class-details-modal.tsx +147 -0
- package/app/components/sidebar/notes/class-details-sections.tsx +561 -0
- package/app/components/sidebar/notes/class-details-shared.ts +239 -0
- package/app/components/sidebar/notes/{notes-sidebar.tsx → notes-editor-form.tsx} +77 -76
- package/app/components/sidebar/notes/notes-editor-modal.tsx +5 -7
- package/app/components/sidebar/notes/notes.module.css +262 -14
- package/app/components/sidebar/notes/use-class-details-state.ts +371 -0
- package/app/components/sidebar/sidebar-container.tsx +2 -0
- package/app/components/sidebar/sidebar.tsx +15 -1
- package/app/{tailwind.css → global.css} +1 -3
- package/app/hooks/useCaseListPreferences.ts +99 -0
- package/app/hooks/useFileListPreferences.ts +106 -0
- package/app/hooks/useOverlayDismiss.ts +6 -4
- package/app/root.tsx +1 -1
- package/app/routes/striae/striae.tsx +7 -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/annotations.ts +48 -1
- package/app/types/audit.ts +1 -0
- package/app/utils/data/case-filters.ts +127 -0
- package/app/utils/data/confirmation-summary/summary-core.ts +295 -0
- package/app/utils/data/data-operations.ts +17 -861
- package/app/utils/data/file-filters.ts +201 -0
- 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 +23 -22
- package/functions/api/pdf/[[path]].ts +27 -8
- package/package.json +7 -13
- 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 +84 -124
- package/workers/pdf-worker/src/pdf-worker.example.ts +58 -61
- package/workers/pdf-worker/src/report-layout.ts +227 -0
- package/workers/pdf-worker/src/report-types.ts +23 -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/workers/pdf-worker/src/assets/icon-256.png +0 -0
- /package/workers/pdf-worker/src/assets/{generated-assets.ts → generated-assets.example.ts} +0 -0
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import type { FileData } from '~/types';
|
|
2
|
+
import type { FileConfirmationSummary } from '~/utils/data';
|
|
3
|
+
|
|
4
|
+
export type FilesModalSortBy = 'recent' | 'filename' | 'confirmation' | 'classType';
|
|
5
|
+
|
|
6
|
+
export type FilesModalConfirmationFilter =
|
|
7
|
+
| 'all'
|
|
8
|
+
| 'pending'
|
|
9
|
+
| 'confirmed'
|
|
10
|
+
| 'none-requested';
|
|
11
|
+
|
|
12
|
+
export type FilesModalClassTypeFilter =
|
|
13
|
+
| 'all'
|
|
14
|
+
| 'Bullet'
|
|
15
|
+
| 'Cartridge Case'
|
|
16
|
+
| 'Shotshell'
|
|
17
|
+
| 'Other';
|
|
18
|
+
|
|
19
|
+
export interface FilesModalPreferences {
|
|
20
|
+
sortBy: FilesModalSortBy;
|
|
21
|
+
confirmationFilter: FilesModalConfirmationFilter;
|
|
22
|
+
classTypeFilter: FilesModalClassTypeFilter;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type FileConfirmationById = Record<string, FileConfirmationSummary>;
|
|
26
|
+
|
|
27
|
+
const DEFAULT_CONFIRMATION_SUMMARY: FileConfirmationSummary = {
|
|
28
|
+
includeConfirmation: false,
|
|
29
|
+
isConfirmed: false,
|
|
30
|
+
updatedAt: '',
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function getFileConfirmationState(fileId: string, statusById: FileConfirmationById): FileConfirmationSummary {
|
|
34
|
+
return statusById[fileId] || DEFAULT_CONFIRMATION_SUMMARY;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function getConfirmationRank(summary: FileConfirmationSummary): number {
|
|
38
|
+
if (summary.includeConfirmation && !summary.isConfirmed) {
|
|
39
|
+
return 0;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (summary.includeConfirmation && summary.isConfirmed) {
|
|
43
|
+
return 1;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return 2;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function getClassTypeRank(classType: FileConfirmationSummary['classType']): number {
|
|
50
|
+
if (classType === 'Bullet') {
|
|
51
|
+
return 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (classType === 'Cartridge Case') {
|
|
55
|
+
return 1;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (classType === 'Shotshell') {
|
|
59
|
+
return 2;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (classType === 'Other') {
|
|
63
|
+
return 3;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return 4;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function parseTimestamp(value: string): number {
|
|
70
|
+
const parsed = Date.parse(value);
|
|
71
|
+
return Number.isNaN(parsed) ? 0 : parsed;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function matchesConfirmationFilter(
|
|
75
|
+
summary: FileConfirmationSummary,
|
|
76
|
+
confirmationFilter: FilesModalConfirmationFilter
|
|
77
|
+
): boolean {
|
|
78
|
+
if (confirmationFilter === 'all') {
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (confirmationFilter === 'pending') {
|
|
83
|
+
return summary.includeConfirmation && !summary.isConfirmed;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (confirmationFilter === 'confirmed') {
|
|
87
|
+
return summary.includeConfirmation && summary.isConfirmed;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return !summary.includeConfirmation;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function matchesClassTypeFilter(
|
|
94
|
+
summary: FileConfirmationSummary,
|
|
95
|
+
classTypeFilter: FilesModalClassTypeFilter
|
|
96
|
+
): boolean {
|
|
97
|
+
if (classTypeFilter === 'all') {
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (classTypeFilter === 'Other') {
|
|
102
|
+
// Treat legacy/unset class types as Other for filtering.
|
|
103
|
+
return summary.classType === 'Other' || !summary.classType;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return summary.classType === classTypeFilter;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function matchesSearch(file: FileData, query: string): boolean {
|
|
110
|
+
const normalized = query.trim().toLowerCase();
|
|
111
|
+
if (!normalized) {
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return file.originalFilename.toLowerCase().includes(normalized);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function filterFilesForModal(
|
|
119
|
+
files: FileData[],
|
|
120
|
+
preferences: FilesModalPreferences,
|
|
121
|
+
statusById: FileConfirmationById,
|
|
122
|
+
searchQuery: string
|
|
123
|
+
): FileData[] {
|
|
124
|
+
return files.filter((file) => {
|
|
125
|
+
const summary = getFileConfirmationState(file.id, statusById);
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
matchesSearch(file, searchQuery) &&
|
|
129
|
+
matchesConfirmationFilter(summary, preferences.confirmationFilter) &&
|
|
130
|
+
matchesClassTypeFilter(summary, preferences.classTypeFilter)
|
|
131
|
+
);
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function compareFileNames(a: string, b: string): number {
|
|
136
|
+
return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function sortFilesForModal(
|
|
140
|
+
files: FileData[],
|
|
141
|
+
sortBy: FilesModalSortBy,
|
|
142
|
+
statusById: FileConfirmationById
|
|
143
|
+
): FileData[] {
|
|
144
|
+
const next = [...files];
|
|
145
|
+
|
|
146
|
+
if (sortBy === 'recent') {
|
|
147
|
+
return next.sort((left, right) => {
|
|
148
|
+
const difference = parseTimestamp(right.uploadedAt) - parseTimestamp(left.uploadedAt);
|
|
149
|
+
if (difference !== 0) {
|
|
150
|
+
return difference;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return compareFileNames(left.originalFilename, right.originalFilename);
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (sortBy === 'filename') {
|
|
158
|
+
return next.sort((left, right) =>
|
|
159
|
+
compareFileNames(left.originalFilename, right.originalFilename)
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (sortBy === 'confirmation') {
|
|
164
|
+
return next.sort((left, right) => {
|
|
165
|
+
const leftSummary = getFileConfirmationState(left.id, statusById);
|
|
166
|
+
const rightSummary = getFileConfirmationState(right.id, statusById);
|
|
167
|
+
const difference = getConfirmationRank(leftSummary) - getConfirmationRank(rightSummary);
|
|
168
|
+
|
|
169
|
+
if (difference !== 0) {
|
|
170
|
+
return difference;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return compareFileNames(left.originalFilename, right.originalFilename);
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return next.sort((left, right) => {
|
|
178
|
+
const leftSummary = getFileConfirmationState(left.id, statusById);
|
|
179
|
+
const rightSummary = getFileConfirmationState(right.id, statusById);
|
|
180
|
+
const difference = getClassTypeRank(leftSummary.classType) - getClassTypeRank(rightSummary.classType);
|
|
181
|
+
|
|
182
|
+
if (difference !== 0) {
|
|
183
|
+
return difference;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return compareFileNames(left.originalFilename, right.originalFilename);
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function getFilesForModal(
|
|
191
|
+
files: FileData[],
|
|
192
|
+
preferences: FilesModalPreferences,
|
|
193
|
+
statusById: FileConfirmationById,
|
|
194
|
+
searchQuery: string
|
|
195
|
+
): FileData[] {
|
|
196
|
+
return sortFilesForModal(
|
|
197
|
+
filterFilesForModal(files, preferences, statusById, searchQuery),
|
|
198
|
+
preferences.sortBy,
|
|
199
|
+
statusById
|
|
200
|
+
);
|
|
201
|
+
}
|
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
|
+
};
|