Skip to content

Commit b54d541

Browse files
authored
Merge pull request #3285 from bluewave-labs/hp-feb-9-add-approval-workflows-for-evidence
Add approval workflow for evidence uploads
2 parents 9b1af08 + 7ddcdec commit b54d541

File tree

72 files changed

+7425
-886
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

72 files changed

+7425
-886
lines changed
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/**
2+
* Custom events for file-related actions.
3+
* Used to communicate between components that don't have direct parent-child relationship.
4+
*/
5+
6+
export const FILE_EVENTS = {
7+
APPROVAL_STATUS_CHANGED: 'file:approvalStatusChanged',
8+
} as const;
9+
10+
/**
11+
* Dispatch a file approval status changed event.
12+
* This can be listened to by components that need to refresh file data.
13+
*/
14+
export function dispatchFileApprovalChanged(detail?: { fileId?: number; status?: string }) {
15+
window.dispatchEvent(new CustomEvent(FILE_EVENTS.APPROVAL_STATUS_CHANGED, { detail }));
16+
}
17+
18+
/**
19+
* Subscribe to file approval status changed events.
20+
* Returns a cleanup function to remove the listener.
21+
*/
22+
export function onFileApprovalChanged(callback: (detail?: { fileId?: number; status?: string }) => void): () => void {
23+
const handler = (event: Event) => {
24+
const customEvent = event as CustomEvent<{ fileId?: number; status?: string }>;
25+
callback(customEvent.detail);
26+
};
27+
window.addEventListener(FILE_EVENTS.APPROVAL_STATUS_CHANGED, handler);
28+
return () => window.removeEventListener(FILE_EVENTS.APPROVAL_STATUS_CHANGED, handler);
29+
}

Clients/src/application/hooks/useFileColumnVisibility.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import { useState, useCallback, useEffect, useMemo } from "react";
1414
*/
1515
export type FileColumn =
1616
| "file"
17-
| "project_name"
1817
| "upload_date"
1918
| "uploader"
2019
| "source"
@@ -39,7 +38,6 @@ export interface ColumnConfig {
3938
*/
4039
export const DEFAULT_COLUMNS: ColumnConfig[] = [
4140
{ key: "file", label: "File", defaultVisible: true, alwaysVisible: true },
42-
{ key: "project_name", label: "Project name", defaultVisible: true },
4341
{ key: "upload_date", label: "Upload date", defaultVisible: true },
4442
{ key: "uploader", label: "Uploader", defaultVisible: true },
4543
{ key: "source", label: "Source", defaultVisible: true },
@@ -48,7 +46,7 @@ export const DEFAULT_COLUMNS: ColumnConfig[] = [
4846
{ key: "action", label: "Action", defaultVisible: true, alwaysVisible: true },
4947
];
5048

51-
const SCHEMA_VERSION = 2;
49+
const SCHEMA_VERSION = 3;
5250
const STORAGE_KEY = "verifywise:file-column-visibility";
5351
const VERSION_KEY = "verifywise:file-column-visibility-version";
5452

Clients/src/application/hooks/useFolderFiles.ts

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -79,30 +79,24 @@ export function useFolderFiles(
7979
try {
8080
setLoading(true);
8181
setError(null);
82-
// Clear files immediately to prevent flash of old content
83-
setFiles([]);
8482

8583
let filesData: IFileWithFolders[];
8684

8785
if (folder === "all") {
88-
// Fetch all files from file manager
8986
const rawFiles = await getUserFilesMetaData();
9087
const transformedFiles = transformFilesData(rawFiles);
9188
filesData = transformToFileWithFolders(transformedFiles);
9289
setAllFiles(filesData);
9390
} else if (folder === "uncategorized") {
94-
// Fetch files not in any folder
9591
filesData = await getUncategorizedFiles();
9692
} else {
97-
// Fetch files in specific folder
9893
filesData = await getFilesInFolder(folder);
9994
}
10095

10196
setFiles(filesData);
10297
} catch (err) {
10398
console.error("Error fetching files:", err);
10499
setError("Failed to load files");
105-
setFiles([]);
106100
} finally {
107101
setLoading(false);
108102
}
@@ -198,10 +192,7 @@ export function useFolderFiles(
198192
}, []);
199193

200194
// Load files when selected folder changes
201-
// Set loading immediately when folder changes to prevent flash
202195
useEffect(() => {
203-
setLoading(true);
204-
setFiles([]);
205196
refreshFiles(selectedFolder);
206197
}, [selectedFolder]); // Don't include refreshFiles to avoid double-calls
207198

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/**
2+
* Hook to check if the current user has Admin role
3+
*
4+
* This is used for UI purposes only (disabling buttons, hiding elements).
5+
* The actual authorization is enforced on the backend.
6+
*/
7+
8+
import { useSelector } from "react-redux";
9+
import { extractUserToken } from "../tools/extractToken";
10+
11+
/**
12+
* Returns true if the current user has the Admin role
13+
*
14+
* Note: This is for UI display purposes only.
15+
* Backend enforces actual authorization.
16+
*/
17+
export const useIsAdmin = (): boolean => {
18+
const authToken = useSelector(
19+
(state: { auth: { authToken: string } }) => state.auth.authToken
20+
);
21+
22+
if (!authToken) {
23+
return false;
24+
}
25+
26+
const user = extractUserToken(authToken);
27+
return user?.roleName === "Admin";
28+
};
29+
30+
/**
31+
* Returns the current user's role name
32+
*
33+
* Note: This is for UI display purposes only.
34+
* Backend enforces actual authorization.
35+
*/
36+
export const useUserRole = (): string | null => {
37+
const authToken = useSelector(
38+
(state: { auth: { authToken: string } }) => state.auth.authToken
39+
);
40+
41+
if (!authToken) {
42+
return null;
43+
}
44+
45+
const user = extractUserToken(authToken);
46+
return user?.roleName || null;
47+
};

Clients/src/application/hooks/useNotifications.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -488,6 +488,31 @@ export const useNotifications = (options: UseNotificationsOptions = {}): UseNoti
488488
};
489489
}, [connect, disconnect]);
490490

491+
// Reconnect when tab becomes visible or network comes back online
492+
useEffect(() => {
493+
const handleVisibilityChange = () => {
494+
if (document.visibilityState === 'visible' && !isConnected && !isManuallyDisconnectedRef.current) {
495+
// Tab became visible and we're not connected - reconnect
496+
connect();
497+
}
498+
};
499+
500+
const handleOnline = () => {
501+
if (!isConnected && !isManuallyDisconnectedRef.current) {
502+
// Network came back - reconnect
503+
connect();
504+
}
505+
};
506+
507+
document.addEventListener('visibilitychange', handleVisibilityChange);
508+
window.addEventListener('online', handleOnline);
509+
510+
return () => {
511+
document.removeEventListener('visibilitychange', handleVisibilityChange);
512+
window.removeEventListener('online', handleOnline);
513+
};
514+
}, [connect, isConnected]);
515+
491516
// Fetch stored notifications on mount
492517
useEffect(() => {
493518
if (fetchOnMount && authToken) {

Clients/src/application/hooks/useUserFilesMetaData.ts

Lines changed: 62 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,21 @@
1111

1212
import { useState, useEffect, useCallback } from "react";
1313
import { FileModel } from "../../domain/models/Common/file/file.model";
14-
import { getUserFilesMetaData } from "../repository/file.repository";
14+
import { getUserFilesMetaData, getFilesWithMetadata } from "../repository/file.repository";
1515
import { transformFilesData } from "../utils/fileTransform.utils";
16+
import CustomException from "../../infrastructure/exceptions/customeException";
17+
18+
/**
19+
* Check if an error is a rate limit (429) or other critical API error
20+
* that should be shown to the user
21+
*/
22+
const isCriticalApiError = (error: unknown): boolean => {
23+
if (error instanceof CustomException) {
24+
// Rate limit (429), server errors (5xx), or forbidden (403)
25+
return error.status === 429 || error.status === 403 || (error.status && error.status >= 500);
26+
}
27+
return false;
28+
};
1629

1730
export const useUserFilesMetaData = () => {
1831
const [filesData, setFilesData] = useState<FileModel[]>([]);
@@ -28,25 +41,66 @@ export const useUserFilesMetaData = () => {
2841
try {
2942
setLoading(true);
3043
setError(null);
31-
const filesResponse = await getUserFilesMetaData({
32-
signal: abortController.signal,
33-
});
3444

35-
if (filesResponse && Array.isArray(filesResponse)) {
36-
setFilesData(transformFilesData(filesResponse));
45+
// Track errors from each endpoint
46+
let metadataError: unknown = null;
47+
let legacyError: unknown = null;
48+
49+
// First try to get files with full metadata (includes version, status, etc.)
50+
// Then also get legacy files from the old endpoint
51+
const [metadataResponse, legacyResponse] = await Promise.all([
52+
getFilesWithMetadata({ signal: abortController.signal }).catch((err) => {
53+
metadataError = err;
54+
return { files: [] };
55+
}),
56+
getUserFilesMetaData({ signal: abortController.signal }).catch((err) => {
57+
legacyError = err;
58+
return [];
59+
}),
60+
]);
61+
62+
// If both endpoints failed with critical errors, show the error to the user
63+
// Prioritize showing rate limit errors
64+
if (metadataError && legacyError) {
65+
const criticalError = isCriticalApiError(metadataError) ? metadataError :
66+
isCriticalApiError(legacyError) ? legacyError : null;
67+
if (criticalError) {
68+
throw criticalError;
69+
}
70+
}
71+
72+
// If one endpoint returned a rate limit error but the other succeeded, still show error
73+
if (isCriticalApiError(metadataError) || isCriticalApiError(legacyError)) {
74+
const criticalError = isCriticalApiError(metadataError) ? metadataError : legacyError;
75+
throw criticalError;
76+
}
77+
78+
// Combine files, preferring metadata-enriched files
79+
const metadataFilesMap = new Map(
80+
metadataResponse.files.map((f) => [String(f.id), f])
81+
);
82+
83+
// Add any legacy files not already in metadata response
84+
const legacyFilesNotInMetadata = legacyResponse.filter(
85+
(f) => !metadataFilesMap.has(String(f.id))
86+
);
87+
88+
const allFiles = [...metadataResponse.files, ...legacyFilesNotInMetadata];
89+
90+
if (allFiles.length > 0) {
91+
setFilesData(transformFilesData(allFiles));
3792
} else {
3893
setFilesData([]);
3994
}
4095
} catch (error: unknown) {
4196
// Check if the error is due to aborting the request
4297
if (error instanceof Error && error.name === "AbortError") {
43-
// Ignore abort errors
4498
return;
4599
}
46100
setError(
47101
error instanceof Error ? error : new Error("Unknown error occurred")
48102
);
49-
setFilesData([]);
103+
// Don't clear filesData on error - keep showing existing data
50104
} finally {
51105
setLoading(false);
52106
}

Clients/src/application/repository/approvalWorkflow.repository.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,27 @@ export async function deleteApprovalWorkflow({
5252
const response = await apiServices.delete(`/approval-workflows/${id}`);
5353
return response;
5454
}
55+
56+
/**
57+
* Get approval workflows filtered by entity type
58+
*
59+
* @param entityType - Entity type to filter by ('use_case', 'file')
60+
* @param signal - Optional abort signal for cancellation
61+
* @returns Promise<any> - List of workflows for the specified entity type
62+
*/
63+
export async function getApprovalWorkflowsByEntityType({
64+
entityType,
65+
signal,
66+
}: {
67+
entityType: 'use_case' | 'file';
68+
signal?: AbortSignal;
69+
}): Promise<any[]> {
70+
const response = await apiServices.get(`/approval-workflows?entity_type=${entityType}`, {
71+
signal,
72+
});
73+
// Filter by entity type if the backend doesn't support query param
74+
const workflows = response.data?.data || response.data || [];
75+
return Array.isArray(workflows)
76+
? workflows.filter((w: any) => w.entity_type === entityType)
77+
: [];
78+
}

0 commit comments

Comments
 (0)