From bb7dc26cb62998c34f26f5812709cde39cb046a4 Mon Sep 17 00:00:00 2001 From: Merul Dhiman Date: Wed, 28 Jan 2026 00:37:31 +0530 Subject: [PATCH 01/12] fix(file-manager-api): prevent file deletion when file has signature containers - FileService.deleteFile: check signature_containers count before delete; throw if file is part of any signing container - FileController.deleteFile: return 409 with code FILE_HAS_SIGNATURES when deletion is blocked so clients can show a clear message --- .../file-manager-api/src/controllers/FileController.ts | 6 ++++++ platforms/file-manager-api/src/services/FileService.ts | 9 +++++++++ 2 files changed, 15 insertions(+) diff --git a/platforms/file-manager-api/src/controllers/FileController.ts b/platforms/file-manager-api/src/controllers/FileController.ts index 6b0f3afa0..3913d4e39 100644 --- a/platforms/file-manager-api/src/controllers/FileController.ts +++ b/platforms/file-manager-api/src/controllers/FileController.ts @@ -475,6 +475,12 @@ export class FileController { res.json({ message: "File deleted successfully" }); } catch (error) { + if (error instanceof Error && error.message.includes("signing containers")) { + return res.status(409).json({ + error: error.message, + code: "FILE_HAS_SIGNATURES", + }); + } console.error("Error deleting file:", error); res.status(500).json({ error: "Failed to delete file" }); } diff --git a/platforms/file-manager-api/src/services/FileService.ts b/platforms/file-manager-api/src/services/FileService.ts index 84a022a3b..598e99b50 100644 --- a/platforms/file-manager-api/src/services/FileService.ts +++ b/platforms/file-manager-api/src/services/FileService.ts @@ -237,6 +237,15 @@ export class FileService { return false; } + const signatureCount = await this.signatureRepository.count({ + where: { fileId: id }, + }); + if (signatureCount > 0) { + throw new Error( + "File cannot be deleted because it is part of one or more signing containers", + ); + } + // Delete all access records await this.fileAccessRepository.delete({ fileId: id }); From b599cbc4f4ad4e6970e5da858c81bb0db1bfb08c Mon Sep 17 00:00:00 2001 From: Merul Dhiman Date: Wed, 28 Jan 2026 00:37:42 +0530 Subject: [PATCH 02/12] fix(file-manager): show clear message when file cannot be deleted (used in signing) - On delete error, check for 409 and FILE_HAS_SIGNATURES or 'signing container' - Show specific toast: file cannot be deleted because it has been used in a signing container --- .../src/routes/(protected)/files/+page.svelte | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/platforms/file-manager/src/routes/(protected)/files/+page.svelte b/platforms/file-manager/src/routes/(protected)/files/+page.svelte index eef74c976..c69c6b785 100644 --- a/platforms/file-manager/src/routes/(protected)/files/+page.svelte +++ b/platforms/file-manager/src/routes/(protected)/files/+page.svelte @@ -393,11 +393,23 @@ itemToDelete = null; await loadFiles(); await fetchFolderTree(); - } catch (error) { + } catch (error: any) { console.error("Failed to delete:", error); - toast.error( - `Failed to delete ${itemType === "file" ? "file" : "folder"}`, - ); + const data = error.response?.data; + if ( + itemType === "file" && + error.response?.status === 409 && + (data?.code === "FILE_HAS_SIGNATURES" || + data?.error?.toLowerCase?.().includes("signing container")) + ) { + toast.error( + "This file cannot be deleted because it has been used in a signing container.", + ); + } else { + toast.error( + `Failed to delete ${itemType === "file" ? "file" : "folder"}`, + ); + } } finally { isLoading = false; } From f903b96283a9a750c17baf978c3c8b0954b8be79 Mon Sep 17 00:00:00 2001 From: Merul Dhiman Date: Wed, 28 Jan 2026 00:38:17 +0530 Subject: [PATCH 03/12] fix(esigner-api): sync file name and displayName from webhook so renames in File Manager appear in eSigner - Update existing file from repo so entity is mutable - Apply name, displayName, description, mimeType, size, md5Hash only when present in payload so renames and partial syncs propagate correctly --- .../src/controllers/WebhookController.ts | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/platforms/esigner-api/src/controllers/WebhookController.ts b/platforms/esigner-api/src/controllers/WebhookController.ts index f868da9f8..4ea2fc5b1 100644 --- a/platforms/esigner-api/src/controllers/WebhookController.ts +++ b/platforms/esigner-api/src/controllers/WebhookController.ts @@ -276,21 +276,29 @@ export class WebhookController { } if (localId) { - // Update existing file - const file = await this.fileService.getFileById(localId); + // Update existing file – apply name/displayName so renames in File Manager sync to eSigner + const file = await this.fileRepository.findOne({ + where: { id: localId }, + }); if (!file) { console.error("File not found for localId:", localId); return res.status(500).send(); } - file.name = local.data.name as string; - file.displayName = local.data.displayName as string | null; - file.description = local.data.description as string | null; - file.mimeType = local.data.mimeType as string; - file.size = local.data.size as number; - file.md5Hash = local.data.md5Hash as string; + if (local.data.name !== undefined) + file.name = local.data.name as string; + if (local.data.displayName !== undefined) + file.displayName = local.data.displayName as string | null; + if (local.data.description !== undefined) + file.description = local.data.description as string | null; + if (local.data.mimeType !== undefined) + file.mimeType = local.data.mimeType as string; + if (local.data.size !== undefined) + file.size = local.data.size as number; + if (local.data.md5Hash !== undefined) + file.md5Hash = local.data.md5Hash as string; file.ownerId = owner.id; - + // Decode base64 data if provided if (local.data.data && typeof local.data.data === "string") { file.data = Buffer.from(local.data.data, "base64"); From 9cb1daac3207540418f89588224266901aee04d3 Mon Sep 17 00:00:00 2001 From: Merul Dhiman Date: Wed, 28 Jan 2026 00:38:49 +0530 Subject: [PATCH 04/12] fix(file-manager): persist folder and view in URL so refresh keeps current location - Add updateUrlFromState() and call it from navigateToFolder and switchView - Use goto(..., { replaceState: true }) with view and folderId query params - Existing onMount already reads view and folderId from URL so refresh restores state --- .../src/routes/(protected)/files/+page.svelte | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/platforms/file-manager/src/routes/(protected)/files/+page.svelte b/platforms/file-manager/src/routes/(protected)/files/+page.svelte index c69c6b785..4888980b2 100644 --- a/platforms/file-manager/src/routes/(protected)/files/+page.svelte +++ b/platforms/file-manager/src/routes/(protected)/files/+page.svelte @@ -705,8 +705,17 @@ } } + function updateUrlFromState() { + const params = new URLSearchParams(); + if (currentView === "shared") params.set("view", "shared"); + if (currentFolderId) params.set("folderId", currentFolderId); + const query = params.toString(); + goto(query ? `/files?${query}` : "/files", { replaceState: true }); + } + async function navigateToFolder(folderId: string | null) { currentFolderId = folderId; + updateUrlFromState(); await loadFiles(); await updateBreadcrumbs(); } @@ -715,6 +724,7 @@ if (currentView === view) return; // Don't reload if already on this view currentView = view; currentFolderId = null; // Reset to root when switching views + updateUrlFromState(); await loadFiles(); // Reload files when switching views await updateBreadcrumbs(); // Update breadcrumbs with correct root name } From 5be1aa73e168acc6e180e903a3589ca44c287d98 Mon Sep 17 00:00:00 2001 From: Merul Dhiman Date: Wed, 28 Jan 2026 00:39:06 +0530 Subject: [PATCH 05/12] fix(file-manager-api): preserve file folder when webhook payload has no folderId - When updating existing file, set file.folderId only if local.data.folderId is present in the payload - Prevents eSigner webhooks (no folder concept) from overwriting folderId and moving deeply nested files to root when signed --- .../src/controllers/WebhookController.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/platforms/file-manager-api/src/controllers/WebhookController.ts b/platforms/file-manager-api/src/controllers/WebhookController.ts index f190a411e..5d5391323 100644 --- a/platforms/file-manager-api/src/controllers/WebhookController.ts +++ b/platforms/file-manager-api/src/controllers/WebhookController.ts @@ -291,7 +291,8 @@ export class WebhookController { } if (localId) { - // Update existing file + // Update existing file – only set folderId when payload carries it (e.g. from File Manager). + // When payload has no folderId (e.g. from eSigner), preserve existing folder so nested files don't move to root. const file = await this.fileRepository.findOne({ where: { id: localId }, }); @@ -307,8 +308,10 @@ export class WebhookController { file.size = local.data.size as number; file.md5Hash = local.data.md5Hash as string; file.ownerId = owner.id; - file.folderId = folderId; - + if (local.data.folderId !== undefined) { + file.folderId = folderId; + } + // Decode base64 data if provided if (local.data.data && typeof local.data.data === "string") { file.data = Buffer.from(local.data.data, "base64"); From 23be15885edac182e807709ccb5d9857573fdf4b Mon Sep 17 00:00:00 2001 From: Merul Dhiman Date: Wed, 28 Jan 2026 00:39:59 +0530 Subject: [PATCH 06/12] fix(esigner-api,esigner): show only explicit signing containers in eSigner list, not all files from File Manager - esigner-api: getDocumentsWithStatus(userId, listMode). list=containers (default): only files with at least one signee; list=all: all owner/invited files - FileController.getFiles: read query param list=all and pass listMode - esigner new container page: load selectable files via GET /api/files?list=all into local state so picker shows any file; main list unchanged (containers only) --- .../src/controllers/FileController.ts | 4 +++- .../esigner-api/src/services/FileService.ts | 12 ++++++++--- .../routes/(protected)/files/new/+page.svelte | 20 +++++++++++++------ 3 files changed, 26 insertions(+), 10 deletions(-) diff --git a/platforms/esigner-api/src/controllers/FileController.ts b/platforms/esigner-api/src/controllers/FileController.ts index 92c246538..d733d52d1 100644 --- a/platforms/esigner-api/src/controllers/FileController.ts +++ b/platforms/esigner-api/src/controllers/FileController.ts @@ -61,7 +61,9 @@ export class FileController { return res.status(401).json({ error: "Authentication required" }); } - const documents = await this.fileService.getDocumentsWithStatus(req.user.id); + const list = req.query.list as string | undefined; + const listMode = list === "all" ? "all" : "containers"; + const documents = await this.fileService.getDocumentsWithStatus(req.user.id, listMode); res.json(documents); } catch (error) { console.error("Error getting documents:", error); diff --git a/platforms/esigner-api/src/services/FileService.ts b/platforms/esigner-api/src/services/FileService.ts index c0aa15b9b..2d42df6bd 100644 --- a/platforms/esigner-api/src/services/FileService.ts +++ b/platforms/esigner-api/src/services/FileService.ts @@ -100,9 +100,9 @@ export class FileService { return allFiles; } - async getDocumentsWithStatus(userId: string) { + async getDocumentsWithStatus(userId: string, listMode: 'containers' | 'all' = 'containers') { const files = await this.getUserFiles(userId); - + // Ensure we have all relations loaded const filesWithRelations = await Promise.all( files.map(async (file) => { @@ -117,7 +117,13 @@ export class FileService { }) ); - return filesWithRelations.map(file => { + // When listing only containers, exclude files that were never used as a signing container (no signees). + // This prevents File Manager uploads from appearing as draft containers in eSigner. + const toList = listMode === 'containers' + ? filesWithRelations.filter((f) => (f.signees?.length ?? 0) > 0) + : filesWithRelations; + + return toList.map(file => { const totalSignees = file.signees?.length || 0; const signedCount = file.signees?.filter(s => s.status === 'signed').length || 0; const pendingCount = file.signees?.filter(s => s.status === 'pending').length || 0; diff --git a/platforms/esigner/src/routes/(protected)/files/new/+page.svelte b/platforms/esigner/src/routes/(protected)/files/new/+page.svelte index 5d69c9006..f5e4ec402 100644 --- a/platforms/esigner/src/routes/(protected)/files/new/+page.svelte +++ b/platforms/esigner/src/routes/(protected)/files/new/+page.svelte @@ -2,7 +2,7 @@ import { onMount } from 'svelte'; import { goto } from '$app/navigation'; import { isAuthenticated } from '$lib/stores/auth'; - import { files, fetchFiles, uploadFile } from '$lib/stores/files'; + import { uploadFile } from '$lib/stores/files'; import { apiClient } from '$lib/utils/axios'; import { inviteSignees } from '$lib/stores/invitations'; @@ -18,6 +18,8 @@ let currentUserId = $state(null); let displayName = $state(''); let description = $state(''); + // All files available to select for a new container (includes File Manager–only uploads) + let selectableFiles = $state([]); onMount(async () => { isAuthenticated.subscribe((auth) => { @@ -25,7 +27,7 @@ goto('/auth'); } }); - + // Get current user ID from API try { const response = await apiClient.get('/api/users'); @@ -33,8 +35,14 @@ } catch (err) { console.error('Failed to get current user:', err); } - - fetchFiles(); + + // Load all files for picker (list=all) so user can select any file, including those not yet used as containers + try { + const res = await apiClient.get('/api/files', { params: { list: 'all' } }); + selectableFiles = res.data ?? []; + } catch (err) { + console.error('Failed to load selectable files:', err); + } }); async function handleFileUpload(file: File) { @@ -278,11 +286,11 @@

Or Select Existing File

- {#if $files.filter(file => !file.signatures || file.signatures.length === 0).length === 0} + {#if selectableFiles.filter(file => !file.signatures || file.signatures.length === 0).length === 0}

No unused files available

{:else}
- {#each $files.filter(file => !file.signatures || file.signatures.length === 0) as file} + {#each selectableFiles.filter(file => !file.signatures || file.signatures.length === 0) as file}
Type: - {file.mimeType} + {getMimeTypeDisplayLabel(file.mimeType || '')}
Created: diff --git a/platforms/file-manager/src/lib/utils/mime-type.ts b/platforms/file-manager/src/lib/utils/mime-type.ts new file mode 100644 index 000000000..da64f9d95 --- /dev/null +++ b/platforms/file-manager/src/lib/utils/mime-type.ts @@ -0,0 +1,18 @@ +/** + * Returns a short, user-friendly label for a MIME type (e.g. DOCX, XLSX, PDF) + * instead of long strings like application/vnd.openxmlformats-officedocument.spreadsheetml.sheet + */ +export function getMimeTypeDisplayLabel(mimeType: string): string { + if (!mimeType || typeof mimeType !== "string") return mimeType ?? ""; + const m = mimeType.toLowerCase(); + // Office / common document types + if (m.includes("spreadsheetml.sheet") || m.includes("ms-excel")) return "XLSX"; + if (m.includes("wordprocessingml.document") || m.includes("msword")) return "DOCX"; + if (m.includes("presentationml") || m.includes("ms-powerpoint")) return "PPTX"; + if (m === "application/pdf") return "PDF"; + // Generic fallback: last meaningful part (e.g. "sheet", "pdf") + const parts = m.split(/[/+]/); + const last = parts[parts.length - 1]; + if (last && last !== "application" && last.length <= 20) return last.toUpperCase(); + return mimeType; +} diff --git a/platforms/file-manager/src/routes/(protected)/files/[id]/+page.svelte b/platforms/file-manager/src/routes/(protected)/files/[id]/+page.svelte index 0fb5826a7..cc18681a3 100644 --- a/platforms/file-manager/src/routes/(protected)/files/[id]/+page.svelte +++ b/platforms/file-manager/src/routes/(protected)/files/[id]/+page.svelte @@ -4,6 +4,7 @@ import { goto } from '$app/navigation'; import { isAuthenticated } from '$lib/stores/auth'; import { apiClient } from '$lib/utils/axios'; + import { getMimeTypeDisplayLabel } from '$lib/utils/mime-type'; import { PUBLIC_FILE_MANAGER_BASE_URL } from '$env/static/public'; import { toast } from '$lib/stores/toast'; import { fetchFileAccess, grantFileAccess, revokeFileAccess, fileAccess } from '$lib/stores/access'; @@ -367,7 +368,7 @@

{file.displayName || file.name}

-

Size: {formatFileSize(file.size)} • Type: {file.mimeType}

+

Size: {formatFileSize(file.size)} • Type: {getMimeTypeDisplayLabel(file.mimeType)}

{#if previewUrl} @@ -417,7 +418,7 @@
Type
-
{file.mimeType}
+
{getMimeTypeDisplayLabel(file.mimeType)}
Created
From ded4d61bdcd72379987772e92a45977201f508ce Mon Sep 17 00:00:00 2001 From: Merul Dhiman Date: Wed, 28 Jan 2026 01:12:24 +0530 Subject: [PATCH 08/12] fix: rename and delete sync --- .../esigner-api/src/services/FileService.ts | 13 +++---- .../src/services/FileService.ts | 35 ++++++++++--------- .../src/web3adapter/watchers/subscriber.ts | 5 ++- 3 files changed, 30 insertions(+), 23 deletions(-) diff --git a/platforms/esigner-api/src/services/FileService.ts b/platforms/esigner-api/src/services/FileService.ts index 2d42df6bd..8a224fcfc 100644 --- a/platforms/esigner-api/src/services/FileService.ts +++ b/platforms/esigner-api/src/services/FileService.ts @@ -4,6 +4,9 @@ import { FileSignee } from "../database/entities/FileSignee"; import { SignatureContainer } from "../database/entities/SignatureContainer"; import crypto from "crypto"; +/** Soft-deleted marker from File Manager (no delete webhook); hide these in eSigner. */ +const SOFT_DELETED_FILE_NAME = "[[deleted]]"; + export class FileService { private fileRepository = AppDataSource.getRepository(File); private fileSigneeRepository = AppDataSource.getRepository(FileSignee); @@ -49,7 +52,7 @@ export class FileService { relations: ["owner", "signees", "signees.user", "signatures", "signatures.user"], }); - if (!file) { + if (!file || file.name === SOFT_DELETED_FILE_NAME) { return null; } @@ -72,14 +75,12 @@ export class FileService { } async getUserFiles(userId: string): Promise { - // Get files owned by user const ownedFiles = await this.fileRepository.find({ where: { ownerId: userId }, relations: ["owner", "signees", "signees.user", "signatures", "signatures.user"], order: { createdAt: "DESC" }, }); - // Get files where user is invited const invitedFiles = await this.fileSigneeRepository.find({ where: { userId }, relations: ["file", "file.owner", "file.signees", "file.signees.user", "file.signatures", "file.signatures.user"], @@ -88,16 +89,16 @@ export class FileService { const invitedFileIds = new Set(invitedFiles.map(fs => fs.fileId)); const allFiles = [...ownedFiles]; - // Add invited files that aren't already in the list for (const fileSignee of invitedFiles) { if (!invitedFileIds.has(fileSignee.fileId) || !ownedFiles.find(f => f.id === fileSignee.fileId)) { - if (fileSignee.file) { + if (fileSignee.file && fileSignee.file.name !== SOFT_DELETED_FILE_NAME) { allFiles.push(fileSignee.file); } } } - return allFiles; + // Hide soft-deleted (File Manager delete workaround: name [[deleted]]) + return allFiles.filter((f) => f.name !== SOFT_DELETED_FILE_NAME); } async getDocumentsWithStatus(userId: string, listMode: 'containers' | 'all' = 'containers') { diff --git a/platforms/file-manager-api/src/services/FileService.ts b/platforms/file-manager-api/src/services/FileService.ts index 598e99b50..31fa6bb44 100644 --- a/platforms/file-manager-api/src/services/FileService.ts +++ b/platforms/file-manager-api/src/services/FileService.ts @@ -4,9 +4,12 @@ import { Folder } from "../database/entities/Folder"; import { FileAccess } from "../database/entities/FileAccess"; import { FolderAccess } from "../database/entities/FolderAccess"; import { SignatureContainer } from "../database/entities/SignatureContainer"; -import { In, IsNull } from "typeorm"; +import { In, IsNull, Not } from "typeorm"; import crypto from "crypto"; +/** Soft-delete marker: file is hidden and syncs to eSigner so they can hide it too (no delete webhook). */ +export const SOFT_DELETED_FILE_NAME = "[[deleted]]"; + export class FileService { private fileRepository = AppDataSource.getRepository(File); private fileAccessRepository = AppDataSource.getRepository(FileAccess); @@ -68,7 +71,7 @@ export class FileService { relations: ["owner", "folder", "signatures", "signatures.user", "tags"], }); - if (!file) { + if (!file || file.name === SOFT_DELETED_FILE_NAME) { return null; } @@ -103,22 +106,22 @@ export class FileService { let ownedFiles: File[]; if (folderId === null || folderId === undefined || folderId === 'null' || folderId === '') { - // Root level files (no folder) - folderId must be null - // Use IsNull() for proper NULL checking in TypeORM + // Root level files (no folder) - folderId must be null; exclude soft-deleted ownedFiles = await this.fileRepository.find({ where: { ownerId: userId, - folderId: IsNull() + folderId: IsNull(), + name: Not(SOFT_DELETED_FILE_NAME), }, relations: ["owner", "folder", "tags"], order: { createdAt: "DESC" }, }); } else { - // Files in specific folder ownedFiles = await this.fileRepository.find({ where: { ownerId: userId, - folderId: folderId + folderId: folderId, + name: Not(SOFT_DELETED_FILE_NAME), }, relations: ["owner", "folder", "tags"], order: { createdAt: "DESC" }, @@ -148,10 +151,8 @@ export class FileService { } if (directAccess || hasAccessViaParent) { - // User has access to this folder (directly or via parent), show only files directly in this folder - // This preserves the folder hierarchy - files in subfolders will show when viewing those subfolders folderAccessFiles = await this.fileRepository.find({ - where: { folderId }, + where: { folderId, name: Not(SOFT_DELETED_FILE_NAME) }, relations: ["owner", "folder", "tags"], order: { createdAt: "DESC" }, }); @@ -161,10 +162,10 @@ export class FileService { const ownedFileIds = new Set(ownedFiles.map(f => f.id)); const allFiles = [...ownedFiles]; - // Add accessed files that aren't already in the list and match folder filter + // Add accessed files that aren't already in the list and match folder filter (exclude soft-deleted) for (const fileAccess of accessedFiles) { - if (!fileAccess.file) continue; - + if (!fileAccess.file || fileAccess.file.name === SOFT_DELETED_FILE_NAME) continue; + // Skip if already in owned files if (ownedFileIds.has(fileAccess.fileId)) continue; @@ -246,10 +247,12 @@ export class FileService { ); } - // Delete all access records + // Soft-delete: set name so sync (update) propagates to eSigner; no delete webhook exists. + // File is hidden from lists and getFileById in both platforms. await this.fileAccessRepository.delete({ fileId: id }); - - await this.fileRepository.remove(file); + file.name = SOFT_DELETED_FILE_NAME; + file.displayName = SOFT_DELETED_FILE_NAME; + await this.fileRepository.save(file); return true; } diff --git a/platforms/file-manager-api/src/web3adapter/watchers/subscriber.ts b/platforms/file-manager-api/src/web3adapter/watchers/subscriber.ts index 96e0c568d..2bdf2385b 100644 --- a/platforms/file-manager-api/src/web3adapter/watchers/subscriber.ts +++ b/platforms/file-manager-api/src/web3adapter/watchers/subscriber.ts @@ -283,6 +283,9 @@ export class PostgresSubscriber implements EntitySubscriberInterface { this.pendingChanges.set(changeKey, Date.now()); + // Sync file renames/updates immediately; other tables keep 3s delay to batch rapid changes + const delayMs = tableName.toLowerCase() === "files" ? 0 : 3_000; + try { setTimeout(async () => { try { @@ -309,7 +312,7 @@ export class PostgresSubscriber implements EntitySubscriberInterface { } finally { this.pendingChanges.delete(changeKey); } - }, 3_000); + }, delayMs); } catch (error) { console.error(`❌ Error processing change for ${tableName}:`, error); this.pendingChanges.delete(changeKey); From 90484b35239437f82a37f6e7f45a1c6e28644d27 Mon Sep 17 00:00:00 2001 From: Merul Dhiman Date: Wed, 28 Jan 2026 01:26:11 +0530 Subject: [PATCH 09/12] fix(file-manager-api): resolve one-request delay in update sync The afterUpdate subscriber was experiencing stale reads because findOne ran inside the transaction before commit. This caused webhooks to send the previous state instead of the current state, resulting in eSigner always being one update behind File Manager. Changes: - Refactored afterUpdate to pass metadata (entityId, relations) instead of immediately loading the entity - Created handleChangeWithReload that does the findOne INSIDE setTimeout after the transaction has committed - Set 50ms delay for files (ensures commit) vs 3s for other tables - This ensures the DB read happens post-commit with fresh data Root cause: TypeORM afterUpdate fires before transaction commit. When findOne uses a different connection from the pool, it sees the last committed state, not the pending uncommitted changes. --- .../src/web3adapter/watchers/subscriber.ts | 167 +++++++++++++----- 1 file changed, 125 insertions(+), 42 deletions(-) diff --git a/platforms/file-manager-api/src/web3adapter/watchers/subscriber.ts b/platforms/file-manager-api/src/web3adapter/watchers/subscriber.ts index 2bdf2385b..ec2ca8268 100644 --- a/platforms/file-manager-api/src/web3adapter/watchers/subscriber.ts +++ b/platforms/file-manager-api/src/web3adapter/watchers/subscriber.ts @@ -184,61 +184,46 @@ export class PostgresSubscriber implements EntitySubscriberInterface { } async afterUpdate(event: UpdateEvent) { - // For updates, we need to reload the full entity since event.entity only contains changed fields - let entity = event.entity; + // For updates, we pass metadata to handleChange so the entity reload happens + // AFTER the transaction commits (inside setTimeout), avoiding stale reads // Try different ways to get the entity ID let entityId = event.entity?.id || event.databaseEntity?.id; if (!entityId && event.entity) { - // If we have the entity but no ID, try to extract it from the entity object - const entityKeys = Object.keys(event.entity); - // Look for common ID field names entityId = event.entity.id || event.entity.Id || event.entity.ID || event.entity._id; } - if (entityId) { - // Reload the full entity from the database - const repository = AppDataSource.getRepository(event.metadata.target); - - // Determine relations based on entity type - let relations: string[] = []; - if (event.metadata.tableName === "messages") { - relations = ["sender", "group", "group.members", "group.admins", "group.participants"]; - } else if (event.metadata.tableName === "groups") { - relations = ["members", "admins", "participants"]; - } else if (event.metadata.tableName === "files") { - relations = ["owner", "folder", "signatures", "tags"]; - } else if (event.metadata.tableName === "signature_containers") { - relations = ["file", "user"]; - } - - const fullEntity = await repository.findOne({ - where: { id: entityId }, - relations: relations.length > 0 ? relations : undefined - }); - - if (fullEntity) { - entity = (await this.enrichEntity( - fullEntity, - event.metadata.tableName, - event.metadata.target - )) as ObjectLiteral; - } + if (!entityId) { + console.warn(`⚠️ afterUpdate: Could not determine entity ID for ${event.metadata.tableName}`); + return; } - // Special handling for Message entities to ensure complete data - if (event.metadata.tableName === "messages" && entity) { - entity = await this.enrichMessageEntity(entity); + // Determine relations based on entity type + let relations: string[] = []; + if (event.metadata.tableName === "messages") { + relations = ["sender", "group", "group.members", "group.admins", "group.participants"]; + } else if (event.metadata.tableName === "groups") { + relations = ["members", "admins", "participants"]; + } else if (event.metadata.tableName === "files") { + relations = ["owner", "folder", "signatures", "tags"]; + } else if (event.metadata.tableName === "signature_containers") { + relations = ["file", "user"]; } - this.handleChange( - entity ?? event.entity, - event.metadata.tableName.endsWith("s") - ? event.metadata.tableName - : event.metadata.tableName + "s" - ); + const tableName = event.metadata.tableName.endsWith("s") + ? event.metadata.tableName + : event.metadata.tableName + "s"; + + // Pass reload metadata instead of entity - actual DB read happens in setTimeout + this.handleChangeWithReload({ + entityId, + tableName, + relations, + tableTarget: event.metadata.target, + rawTableName: event.metadata.tableName, + }); } async afterRemove(event: RemoveEvent) { @@ -256,6 +241,104 @@ export class PostgresSubscriber implements EntitySubscriberInterface { ); } + /** + * Handle update changes by reloading entity AFTER transaction commits. + * This avoids stale reads that occur when findOne runs inside the same transaction. + */ + private async handleChangeWithReload(params: { + entityId: string; + tableName: string; + relations: string[]; + tableTarget: any; + rawTableName: string; + }): Promise { + const { entityId, tableName, relations, tableTarget, rawTableName } = params; + + if (!entityId) { + return; + } + + // Check if there's a mapping for this table + const mapping = Object.values(this.adapter.mapping).find( + (m) => m.tableName === tableName.toLowerCase() + ); + + if (!mapping) { + return; + } + + const changeKey = `${tableName}:${entityId}`; + + if (this.pendingChanges.has(changeKey)) { + return; + } + + this.pendingChanges.set(changeKey, Date.now()); + + // Small delay to ensure transaction has committed before we read + // Files sync quickly (50ms), other tables batch changes (3s) + const delayMs = tableName.toLowerCase() === "files" ? 50 : 3_000; + + try { + setTimeout(async () => { + try { + // NOW reload entity - transaction has committed, data is fresh + const repository = AppDataSource.getRepository(tableTarget); + let entity = await repository.findOne({ + where: { id: entityId }, + relations: relations.length > 0 ? relations : undefined + }); + + if (!entity) { + console.warn(`⚠️ handleChangeWithReload: Entity ${entityId} not found after reload`); + return; + } + + // Enrich entity with additional relations + entity = (await this.enrichEntity( + entity, + rawTableName, + tableTarget + )) as ObjectLiteral; + + // Special handling for Message entities + if (rawTableName === "messages" && entity) { + entity = await this.enrichMessageEntity(entity); + } + + const data = this.entityToPlain(entity); + if (!data.id) { + return; + } + + let globalId = await this.adapter.mappingDb.getGlobalId(entityId); + globalId = globalId ?? ""; + + if (this.adapter.lockedIds.includes(globalId)) { + return; + } + + // Check if this entity was recently created by a webhook + if (this.adapter.lockedIds.includes(entityId)) { + return; + } + + const envelope = await this.adapter.handleChange({ + data, + tableName: tableName.toLowerCase(), + }); + } catch (error) { + console.error(`❌ Error in handleChangeWithReload setTimeout for ${tableName}:`, error); + } finally { + this.pendingChanges.delete(changeKey); + } + }, delayMs); + } catch (error) { + console.error(`❌ Error processing change with reload for ${tableName}:`, error); + this.pendingChanges.delete(changeKey); + } + } + private async handleChange(entity: any, tableName: string): Promise { if (!entity || !entity.id) { return; From 5e236de34014444ed4417e89855950d3fbb9d98c Mon Sep 17 00:00:00 2001 From: Merul Dhiman Date: Wed, 28 Jan 2026 01:29:49 +0530 Subject: [PATCH 10/12] fix(dreamsync-api): resolve one-request delay in wishlist sync Same root cause as file-manager-api: afterUpdate called findOne inside the transaction before commit, causing stale reads. eReputation received the previous wishlist state instead of the current one. Changes: - Refactored afterUpdate to pass metadata (entityId, relations) instead of immediately loading the entity - Created handleChangeWithReload and executeReloadAndSend methods that do the findOne INSIDE setTimeout after transaction commit - Added Wishlist to getRelationsForEntity with ["user"] relation - Wishlists sync with 50ms delay (ensures commit), groups keep 3s debounce This ensures wishlist updates sync to eReputation immediately with fresh, post-commit data. --- .../src/web3adapter/watchers/subscriber.ts | 193 ++++++++++++++---- 1 file changed, 156 insertions(+), 37 deletions(-) diff --git a/platforms/dreamsync-api/src/web3adapter/watchers/subscriber.ts b/platforms/dreamsync-api/src/web3adapter/watchers/subscriber.ts index 9a880ef3f..ff1261564 100644 --- a/platforms/dreamsync-api/src/web3adapter/watchers/subscriber.ts +++ b/platforms/dreamsync-api/src/web3adapter/watchers/subscriber.ts @@ -177,18 +177,14 @@ export class PostgresSubscriber implements EntitySubscriberInterface { /** * Called after entity update. + * NOTE: We pass metadata to handleChangeWithReload so the entity reload happens + * AFTER the transaction commits (inside setTimeout), avoiding stale reads. */ async afterUpdate(event: UpdateEvent) { - // For updates, we need to reload the full entity since event.entity only contains changed fields - let entity = event.entity; - // Try different ways to get the entity ID let entityId = event.entity?.id || event.databaseEntity?.id; if (!entityId && event.entity) { - // If we have the entity but no ID, try to extract it from the entity object - const entityKeys = Object.keys(event.entity); - // Look for common ID field names entityId = event.entity.id || event.entity.Id || event.entity.ID || event.entity._id; } @@ -215,39 +211,27 @@ export class PostgresSubscriber implements EntitySubscriberInterface { } } - if (entityId) { - // Reload the full entity from the database - const repository = AppDataSource.getRepository(event.metadata.target); - const entityName = typeof event.metadata.target === 'function' - ? event.metadata.target.name - : event.metadata.target; - - const fullEntity = await repository.findOne({ - where: { id: entityId }, - relations: this.getRelationsForEntity(entityName) - }); - - if (fullEntity) { - entity = (await this.enrichEntity( - fullEntity, - event.metadata.tableName, - event.metadata.target - )) as ObjectLiteral; - - // Special handling for Message entities to ensure complete data - if (event.metadata.tableName === "messages" && entity) { - entity = await this.enrichMessageEntity(entity); - } - } + if (!entityId) { + console.warn(`⚠️ afterUpdate: Could not determine entity ID for ${event.metadata.tableName}`); + return; } - this.handleChange( - // @ts-ignore - entity ?? event.entityId, - event.metadata.tableName.endsWith("s") - ? event.metadata.tableName - : event.metadata.tableName + "s" - ); + const entityName = typeof event.metadata.target === 'function' + ? event.metadata.target.name + : event.metadata.target; + + const tableName = event.metadata.tableName.endsWith("s") + ? event.metadata.tableName + : event.metadata.tableName + "s"; + + // Pass reload metadata instead of entity - actual DB read happens in setTimeout + this.handleChangeWithReload({ + entityId, + tableName, + relations: this.getRelationsForEntity(entityName), + tableTarget: event.metadata.target, + rawTableName: event.metadata.tableName, + }); } /** @@ -270,6 +254,139 @@ export class PostgresSubscriber implements EntitySubscriberInterface { ); } + /** + * Handle update changes by reloading entity AFTER transaction commits. + * This avoids stale reads that occur when findOne runs inside the same transaction. + */ + private async handleChangeWithReload(params: { + entityId: string; + tableName: string; + relations: string[]; + tableTarget: any; + rawTableName: string; + }): Promise { + const { entityId, tableName, relations, tableTarget, rawTableName } = params; + + console.log(`🔍 handleChangeWithReload called for: ${tableName}, entityId: ${entityId}`); + + // Check if this operation should be processed + if (!shouldProcessWebhook(entityId, tableName)) { + console.log(`⏭️ Skipping webhook for ${tableName}:${entityId} - not from ConsentService (protected entity)`); + return; + } + + // Handle junction table changes + // @ts-ignore + const junctionInfo = JUNCTION_TABLE_MAP[tableName]; + if (junctionInfo) { + // Junction tables need to load the parent entity, not the junction record + // This is handled separately in handleJunctionTableChange + return; + } + + // Add debouncing for group entities + if (tableName === "groups") { + const debounceKey = `group-reload:${entityId}`; + + if (this.junctionTableDebounceMap.has(debounceKey)) { + clearTimeout(this.junctionTableDebounceMap.get(debounceKey)!); + } + + const timeoutId = setTimeout(async () => { + try { + await this.executeReloadAndSend(params); + this.junctionTableDebounceMap.delete(debounceKey); + } catch (error) { + console.error("Error in group reload timeout:", error); + this.junctionTableDebounceMap.delete(debounceKey); + } + }, 3_000); + + this.junctionTableDebounceMap.set(debounceKey, timeoutId); + return; + } + + // For other entities (including wishlists), use a small delay to ensure transaction commit + // Wishlists sync quickly (50ms), other tables use standard delay + const delayMs = tableName.toLowerCase() === "wishlists" ? 50 : 3_000; + + setTimeout(async () => { + try { + await this.executeReloadAndSend(params); + } catch (error) { + console.error(`❌ Error in handleChangeWithReload setTimeout for ${tableName}:`, error); + } + }, delayMs); + } + + /** + * Execute the entity reload and send webhook - called from within setTimeout + * when transaction has definitely committed. + */ + private async executeReloadAndSend(params: { + entityId: string; + tableName: string; + relations: string[]; + tableTarget: any; + rawTableName: string; + }): Promise { + const { entityId, tableName, relations, tableTarget, rawTableName } = params; + + // NOW reload entity - transaction has committed, data is fresh + const repository = AppDataSource.getRepository(tableTarget); + let entity = await repository.findOne({ + where: { id: entityId }, + relations: relations.length > 0 ? relations : undefined + }); + + if (!entity) { + console.warn(`⚠️ executeReloadAndSend: Entity ${entityId} not found after reload`); + return; + } + + // Enrich entity with additional relations + entity = (await this.enrichEntity( + entity, + rawTableName, + tableTarget + )) as ObjectLiteral; + + // Special handling for Message entities + if (rawTableName === "messages" && entity) { + entity = await this.enrichMessageEntity(entity); + } + + // For Message entities, only process system messages + const data = this.entityToPlain(entity); + if (tableName === "messages") { + const isSystemMessage = data.text && data.text.includes('$$system-message$$'); + if (!isSystemMessage) { + return; + } + } + + if (!data.id) { + return; + } + + let globalId = await this.adapter.mappingDb.getGlobalId(entityId); + globalId = globalId ?? ""; + + if (this.adapter.lockedIds.includes(globalId)) { + return; + } + + if (this.adapter.lockedIds.includes(entityId)) { + return; + } + + console.log(`📤 Sending webhook for ${tableName}:${entityId}`); + await this.adapter.handleChange({ + data, + tableName: tableName.toLowerCase(), + }); + } + /** * Handle entity changes and send to web3adapter */ @@ -490,6 +607,8 @@ export class PostgresSubscriber implements EntitySubscriberInterface { return ["participants", "admins", "members"]; case "Message": return ["group", "sender"]; + case "Wishlist": + return ["user"]; default: return []; } From aeae3c86989cb496faccba68912cae6740dcdf02 Mon Sep 17 00:00:00 2001 From: Merul Dhiman Date: Wed, 28 Jan 2026 01:36:30 +0530 Subject: [PATCH 11/12] fix: prevent users from calling there files by the soft delete term --- .../src/controllers/FileController.ts | 5 ++- .../esigner-api/src/services/FileService.ts | 45 ++++++++++++++++++- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/platforms/esigner-api/src/controllers/FileController.ts b/platforms/esigner-api/src/controllers/FileController.ts index d733d52d1..9483322c6 100644 --- a/platforms/esigner-api/src/controllers/FileController.ts +++ b/platforms/esigner-api/src/controllers/FileController.ts @@ -1,5 +1,5 @@ import { Request, Response } from "express"; -import { FileService } from "../services/FileService"; +import { FileService, ReservedFileNameError } from "../services/FileService"; import multer from "multer"; const upload = multer({ @@ -49,6 +49,9 @@ export class FileController { createdAt: file.createdAt, }); } catch (error) { + if (error instanceof ReservedFileNameError) { + return res.status(400).json({ error: error.message }); + } console.error("Error uploading file:", error); res.status(500).json({ error: "Failed to upload file" }); } diff --git a/platforms/esigner-api/src/services/FileService.ts b/platforms/esigner-api/src/services/FileService.ts index 8a224fcfc..39b60b137 100644 --- a/platforms/esigner-api/src/services/FileService.ts +++ b/platforms/esigner-api/src/services/FileService.ts @@ -5,13 +5,32 @@ import { SignatureContainer } from "../database/entities/SignatureContainer"; import crypto from "crypto"; /** Soft-deleted marker from File Manager (no delete webhook); hide these in eSigner. */ -const SOFT_DELETED_FILE_NAME = "[[deleted]]"; +export const SOFT_DELETED_FILE_NAME = "[[deleted]]"; + +/** Thrown when name is the reserved soft-delete sentinel. */ +export class ReservedFileNameError extends Error { + constructor(name: string) { + super(`File name '${name}' is reserved and cannot be used for upload or rename.`); + this.name = "ReservedFileNameError"; + } +} export class FileService { private fileRepository = AppDataSource.getRepository(File); private fileSigneeRepository = AppDataSource.getRepository(FileSignee); private signatureRepository = AppDataSource.getRepository(SignatureContainer); + /** + * Validates that the given filename is not the reserved soft-delete sentinel. + * Call this at create/upload and rename entry points before persisting file.name. + * @throws ReservedFileNameError if name equals SOFT_DELETED_FILE_NAME + */ + validateFileName(name: string): void { + if (name === SOFT_DELETED_FILE_NAME) { + throw new ReservedFileNameError(name); + } + } + async calculateMD5(buffer: Buffer): Promise { return crypto.createHash('md5').update(buffer).digest('hex'); } @@ -25,6 +44,8 @@ export class FileService { displayName?: string, description?: string ): Promise { + this.validateFileName(name); + const md5Hash = await this.calculateMD5(data); const fileData: Partial = { @@ -202,6 +223,28 @@ export class FileService { return await this.fileRepository.save(file); } + /** + * Renames a file. Validates that the new name is not the reserved soft-delete sentinel. + * @throws ReservedFileNameError if newName equals SOFT_DELETED_FILE_NAME + */ + async renameFile(id: string, newName: string, userId: string): Promise { + this.validateFileName(newName); + + const file = await this.fileRepository.findOne({ + where: { id, ownerId: userId }, + }); + + if (!file) { + return null; + } + + file.name = newName; + if (file.displayName === null || file.displayName === file.name) { + file.displayName = newName; + } + return await this.fileRepository.save(file); + } + async deleteFile(id: string, userId: string): Promise { const file = await this.fileRepository.findOne({ where: { id, ownerId: userId }, From c6bac252eabc4b73027d0ac9e24fab5dbd6d1ae6 Mon Sep 17 00:00:00 2001 From: Merul Dhiman Date: Wed, 28 Jan 2026 01:40:57 +0530 Subject: [PATCH 12/12] fix: add id to afterUpdate --- .../file-manager-api/src/services/GroupService.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/platforms/file-manager-api/src/services/GroupService.ts b/platforms/file-manager-api/src/services/GroupService.ts index 75e0335e5..fae2391e1 100644 --- a/platforms/file-manager-api/src/services/GroupService.ts +++ b/platforms/file-manager-api/src/services/GroupService.ts @@ -215,12 +215,15 @@ export class GroupService { } async updateGroup(id: string, updateData: Partial): Promise { - await this.groupRepository.update(id, updateData); - const updatedGroup = await this.groupRepository.findOneBy({ id }); - if (!updatedGroup) { - throw new Error("Group not found after update"); + const group = await this.groupRepository.findOne({ + where: { id }, + relations: ["members", "admins", "participants"], + }); + if (!group) { + throw new Error("Group not found"); } - return updatedGroup; + Object.assign(group, updateData); + return await this.groupRepository.save(group); } async getUserGroups(userId: string): Promise {