diff --git a/platforms/dreamsync-api/src/web3adapter/watchers/subscriber.ts b/platforms/dreamsync-api/src/web3adapter/watchers/subscriber.ts index 9a880ef3..ff126156 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 []; } diff --git a/platforms/esigner-api/src/controllers/FileController.ts b/platforms/esigner-api/src/controllers/FileController.ts index 92c24653..9483322c 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" }); } @@ -61,7 +64,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/controllers/WebhookController.ts b/platforms/esigner-api/src/controllers/WebhookController.ts index f868da9f..4ea2fc5b 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"); diff --git a/platforms/esigner-api/src/services/FileService.ts b/platforms/esigner-api/src/services/FileService.ts index c0aa15b9..39b60b13 100644 --- a/platforms/esigner-api/src/services/FileService.ts +++ b/platforms/esigner-api/src/services/FileService.ts @@ -4,11 +4,33 @@ 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. */ +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'); } @@ -22,6 +44,8 @@ export class FileService { displayName?: string, description?: string ): Promise { + this.validateFileName(name); + const md5Hash = await this.calculateMD5(data); const fileData: Partial = { @@ -49,7 +73,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 +96,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,21 +110,21 @@ 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) { + 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 +139,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; @@ -195,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 }, diff --git a/platforms/esigner/src/lib/utils/mime-type.ts b/platforms/esigner/src/lib/utils/mime-type.ts new file mode 100644 index 00000000..5417b63a --- /dev/null +++ b/platforms/esigner/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 + 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/esigner/src/routes/(protected)/files/[id]/+page.svelte b/platforms/esigner/src/routes/(protected)/files/[id]/+page.svelte index 2b1e7a42..7536b9ce 100644 --- a/platforms/esigner/src/routes/(protected)/files/[id]/+page.svelte +++ b/platforms/esigner/src/routes/(protected)/files/[id]/+page.svelte @@ -9,6 +9,7 @@ import { PUBLIC_ESIGNER_BASE_URL } from '$env/static/public'; import { toast } from '$lib/stores/toast'; import { isMobileDevice, getDeepLinkUrl } from '$lib/utils/mobile-detection'; + import { getMimeTypeDisplayLabel } from '$lib/utils/mime-type'; let file = $state(null); let invitations = $state([]); @@ -376,7 +377,7 @@
📄

Preview not available for this file type

-

{file.mimeType}

+

{getMimeTypeDisplayLabel(file.mimeType || '')}

Type: - {file.mimeType} + {getMimeTypeDisplayLabel(file.mimeType || '')}
Created: diff --git a/platforms/esigner/src/routes/(protected)/files/new/+page.svelte b/platforms/esigner/src/routes/(protected)/files/new/+page.svelte index 5d69c900..f5e4ec40 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}