Skip to content

Commit bd9c36d

Browse files
authored
Chore/file manager and esigner bug fixes (#729)
* 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 * 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 * 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 * 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 * 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 * 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) * fix(file-manager,esigner): display user-friendly file type labels (DOCX, XLSX, PDF) instead of raw MIME - Add getMimeTypeDisplayLabel() in both apps: map long MIME types to short labels (XLSX, DOCX, PPTX, PDF) with fallback for unknown types - File Manager files/[id]: use for Type in subtitle and Details - eSigner files/[id]: use for Type in preview area and File Information * fix: rename and delete sync * 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. * 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. * fix: prevent users from calling there files by the soft delete term * fix: add id to afterUpdate
1 parent 2ca826c commit bd9c36d

15 files changed

Lines changed: 498 additions & 138 deletions

File tree

platforms/dreamsync-api/src/web3adapter/watchers/subscriber.ts

Lines changed: 156 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -177,18 +177,14 @@ export class PostgresSubscriber implements EntitySubscriberInterface {
177177

178178
/**
179179
* Called after entity update.
180+
* NOTE: We pass metadata to handleChangeWithReload so the entity reload happens
181+
* AFTER the transaction commits (inside setTimeout), avoiding stale reads.
180182
*/
181183
async afterUpdate(event: UpdateEvent<any>) {
182-
// For updates, we need to reload the full entity since event.entity only contains changed fields
183-
let entity = event.entity;
184-
185184
// Try different ways to get the entity ID
186185
let entityId = event.entity?.id || event.databaseEntity?.id;
187186

188187
if (!entityId && event.entity) {
189-
// If we have the entity but no ID, try to extract it from the entity object
190-
const entityKeys = Object.keys(event.entity);
191-
192188
// Look for common ID field names
193189
entityId = event.entity.id || event.entity.Id || event.entity.ID || event.entity._id;
194190
}
@@ -215,39 +211,27 @@ export class PostgresSubscriber implements EntitySubscriberInterface {
215211
}
216212
}
217213

218-
if (entityId) {
219-
// Reload the full entity from the database
220-
const repository = AppDataSource.getRepository(event.metadata.target);
221-
const entityName = typeof event.metadata.target === 'function'
222-
? event.metadata.target.name
223-
: event.metadata.target;
224-
225-
const fullEntity = await repository.findOne({
226-
where: { id: entityId },
227-
relations: this.getRelationsForEntity(entityName)
228-
});
229-
230-
if (fullEntity) {
231-
entity = (await this.enrichEntity(
232-
fullEntity,
233-
event.metadata.tableName,
234-
event.metadata.target
235-
)) as ObjectLiteral;
236-
237-
// Special handling for Message entities to ensure complete data
238-
if (event.metadata.tableName === "messages" && entity) {
239-
entity = await this.enrichMessageEntity(entity);
240-
}
241-
}
214+
if (!entityId) {
215+
console.warn(`⚠️ afterUpdate: Could not determine entity ID for ${event.metadata.tableName}`);
216+
return;
242217
}
243218

244-
this.handleChange(
245-
// @ts-ignore
246-
entity ?? event.entityId,
247-
event.metadata.tableName.endsWith("s")
248-
? event.metadata.tableName
249-
: event.metadata.tableName + "s"
250-
);
219+
const entityName = typeof event.metadata.target === 'function'
220+
? event.metadata.target.name
221+
: event.metadata.target;
222+
223+
const tableName = event.metadata.tableName.endsWith("s")
224+
? event.metadata.tableName
225+
: event.metadata.tableName + "s";
226+
227+
// Pass reload metadata instead of entity - actual DB read happens in setTimeout
228+
this.handleChangeWithReload({
229+
entityId,
230+
tableName,
231+
relations: this.getRelationsForEntity(entityName),
232+
tableTarget: event.metadata.target,
233+
rawTableName: event.metadata.tableName,
234+
});
251235
}
252236

253237
/**
@@ -270,6 +254,139 @@ export class PostgresSubscriber implements EntitySubscriberInterface {
270254
);
271255
}
272256

257+
/**
258+
* Handle update changes by reloading entity AFTER transaction commits.
259+
* This avoids stale reads that occur when findOne runs inside the same transaction.
260+
*/
261+
private async handleChangeWithReload(params: {
262+
entityId: string;
263+
tableName: string;
264+
relations: string[];
265+
tableTarget: any;
266+
rawTableName: string;
267+
}): Promise<void> {
268+
const { entityId, tableName, relations, tableTarget, rawTableName } = params;
269+
270+
console.log(`🔍 handleChangeWithReload called for: ${tableName}, entityId: ${entityId}`);
271+
272+
// Check if this operation should be processed
273+
if (!shouldProcessWebhook(entityId, tableName)) {
274+
console.log(`⏭️ Skipping webhook for ${tableName}:${entityId} - not from ConsentService (protected entity)`);
275+
return;
276+
}
277+
278+
// Handle junction table changes
279+
// @ts-ignore
280+
const junctionInfo = JUNCTION_TABLE_MAP[tableName];
281+
if (junctionInfo) {
282+
// Junction tables need to load the parent entity, not the junction record
283+
// This is handled separately in handleJunctionTableChange
284+
return;
285+
}
286+
287+
// Add debouncing for group entities
288+
if (tableName === "groups") {
289+
const debounceKey = `group-reload:${entityId}`;
290+
291+
if (this.junctionTableDebounceMap.has(debounceKey)) {
292+
clearTimeout(this.junctionTableDebounceMap.get(debounceKey)!);
293+
}
294+
295+
const timeoutId = setTimeout(async () => {
296+
try {
297+
await this.executeReloadAndSend(params);
298+
this.junctionTableDebounceMap.delete(debounceKey);
299+
} catch (error) {
300+
console.error("Error in group reload timeout:", error);
301+
this.junctionTableDebounceMap.delete(debounceKey);
302+
}
303+
}, 3_000);
304+
305+
this.junctionTableDebounceMap.set(debounceKey, timeoutId);
306+
return;
307+
}
308+
309+
// For other entities (including wishlists), use a small delay to ensure transaction commit
310+
// Wishlists sync quickly (50ms), other tables use standard delay
311+
const delayMs = tableName.toLowerCase() === "wishlists" ? 50 : 3_000;
312+
313+
setTimeout(async () => {
314+
try {
315+
await this.executeReloadAndSend(params);
316+
} catch (error) {
317+
console.error(`❌ Error in handleChangeWithReload setTimeout for ${tableName}:`, error);
318+
}
319+
}, delayMs);
320+
}
321+
322+
/**
323+
* Execute the entity reload and send webhook - called from within setTimeout
324+
* when transaction has definitely committed.
325+
*/
326+
private async executeReloadAndSend(params: {
327+
entityId: string;
328+
tableName: string;
329+
relations: string[];
330+
tableTarget: any;
331+
rawTableName: string;
332+
}): Promise<void> {
333+
const { entityId, tableName, relations, tableTarget, rawTableName } = params;
334+
335+
// NOW reload entity - transaction has committed, data is fresh
336+
const repository = AppDataSource.getRepository(tableTarget);
337+
let entity = await repository.findOne({
338+
where: { id: entityId },
339+
relations: relations.length > 0 ? relations : undefined
340+
});
341+
342+
if (!entity) {
343+
console.warn(`⚠️ executeReloadAndSend: Entity ${entityId} not found after reload`);
344+
return;
345+
}
346+
347+
// Enrich entity with additional relations
348+
entity = (await this.enrichEntity(
349+
entity,
350+
rawTableName,
351+
tableTarget
352+
)) as ObjectLiteral;
353+
354+
// Special handling for Message entities
355+
if (rawTableName === "messages" && entity) {
356+
entity = await this.enrichMessageEntity(entity);
357+
}
358+
359+
// For Message entities, only process system messages
360+
const data = this.entityToPlain(entity);
361+
if (tableName === "messages") {
362+
const isSystemMessage = data.text && data.text.includes('$$system-message$$');
363+
if (!isSystemMessage) {
364+
return;
365+
}
366+
}
367+
368+
if (!data.id) {
369+
return;
370+
}
371+
372+
let globalId = await this.adapter.mappingDb.getGlobalId(entityId);
373+
globalId = globalId ?? "";
374+
375+
if (this.adapter.lockedIds.includes(globalId)) {
376+
return;
377+
}
378+
379+
if (this.adapter.lockedIds.includes(entityId)) {
380+
return;
381+
}
382+
383+
console.log(`📤 Sending webhook for ${tableName}:${entityId}`);
384+
await this.adapter.handleChange({
385+
data,
386+
tableName: tableName.toLowerCase(),
387+
});
388+
}
389+
273390
/**
274391
* Handle entity changes and send to web3adapter
275392
*/
@@ -490,6 +607,8 @@ export class PostgresSubscriber implements EntitySubscriberInterface {
490607
return ["participants", "admins", "members"];
491608
case "Message":
492609
return ["group", "sender"];
610+
case "Wishlist":
611+
return ["user"];
493612
default:
494613
return [];
495614
}

platforms/esigner-api/src/controllers/FileController.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Request, Response } from "express";
2-
import { FileService } from "../services/FileService";
2+
import { FileService, ReservedFileNameError } from "../services/FileService";
33
import multer from "multer";
44

55
const upload = multer({
@@ -49,6 +49,9 @@ export class FileController {
4949
createdAt: file.createdAt,
5050
});
5151
} catch (error) {
52+
if (error instanceof ReservedFileNameError) {
53+
return res.status(400).json({ error: error.message });
54+
}
5255
console.error("Error uploading file:", error);
5356
res.status(500).json({ error: "Failed to upload file" });
5457
}
@@ -61,7 +64,9 @@ export class FileController {
6164
return res.status(401).json({ error: "Authentication required" });
6265
}
6366

64-
const documents = await this.fileService.getDocumentsWithStatus(req.user.id);
67+
const list = req.query.list as string | undefined;
68+
const listMode = list === "all" ? "all" : "containers";
69+
const documents = await this.fileService.getDocumentsWithStatus(req.user.id, listMode);
6570
res.json(documents);
6671
} catch (error) {
6772
console.error("Error getting documents:", error);

platforms/esigner-api/src/controllers/WebhookController.ts

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -276,21 +276,29 @@ export class WebhookController {
276276
}
277277

278278
if (localId) {
279-
// Update existing file
280-
const file = await this.fileService.getFileById(localId);
279+
// Update existing file – apply name/displayName so renames in File Manager sync to eSigner
280+
const file = await this.fileRepository.findOne({
281+
where: { id: localId },
282+
});
281283
if (!file) {
282284
console.error("File not found for localId:", localId);
283285
return res.status(500).send();
284286
}
285287

286-
file.name = local.data.name as string;
287-
file.displayName = local.data.displayName as string | null;
288-
file.description = local.data.description as string | null;
289-
file.mimeType = local.data.mimeType as string;
290-
file.size = local.data.size as number;
291-
file.md5Hash = local.data.md5Hash as string;
288+
if (local.data.name !== undefined)
289+
file.name = local.data.name as string;
290+
if (local.data.displayName !== undefined)
291+
file.displayName = local.data.displayName as string | null;
292+
if (local.data.description !== undefined)
293+
file.description = local.data.description as string | null;
294+
if (local.data.mimeType !== undefined)
295+
file.mimeType = local.data.mimeType as string;
296+
if (local.data.size !== undefined)
297+
file.size = local.data.size as number;
298+
if (local.data.md5Hash !== undefined)
299+
file.md5Hash = local.data.md5Hash as string;
292300
file.ownerId = owner.id;
293-
301+
294302
// Decode base64 data if provided
295303
if (local.data.data && typeof local.data.data === "string") {
296304
file.data = Buffer.from(local.data.data, "base64");

0 commit comments

Comments
 (0)