Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion platforms/esigner-api/src/controllers/FileController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
26 changes: 17 additions & 9 deletions platforms/esigner-api/src/controllers/WebhookController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
25 changes: 16 additions & 9 deletions platforms/esigner-api/src/services/FileService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

Expand All @@ -72,14 +75,12 @@ export class FileService {
}

async getUserFiles(userId: string): Promise<File[]> {
// 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"],
Expand All @@ -88,21 +89,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) => {
Expand All @@ -117,7 +118,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;
Expand Down
18 changes: 18 additions & 0 deletions platforms/esigner/src/lib/utils/mime-type.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<any>(null);
let invitations = $state<any[]>([]);
Expand Down Expand Up @@ -376,7 +377,7 @@
<div class="bg-white rounded-lg shadow-lg p-6 sm:p-12 text-center max-w-md">
<div class="text-4xl sm:text-6xl mb-4">📄</div>
<p class="text-gray-600 mb-2">Preview not available for this file type</p>
<p class="text-sm text-gray-500">{file.mimeType}</p>
<p class="text-sm text-gray-500">{getMimeTypeDisplayLabel(file.mimeType || '')}</p>
<button
onclick={() => showDownloadModal = true}
class="inline-block mt-4 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm"
Expand Down Expand Up @@ -449,7 +450,7 @@
</div>
<div class="flex justify-between gap-2">
<span class="text-gray-600 flex-shrink-0">Type:</span>
<span class="text-gray-900 font-medium break-all text-right text-xs sm:text-sm">{file.mimeType}</span>
<span class="text-gray-900 font-medium break-all text-right text-xs sm:text-sm">{getMimeTypeDisplayLabel(file.mimeType || '')}</span>
</div>
<div class="flex justify-between gap-2">
<span class="text-gray-600 flex-shrink-0">Created:</span>
Expand Down
20 changes: 14 additions & 6 deletions platforms/esigner/src/routes/(protected)/files/new/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -18,23 +18,31 @@
let currentUserId = $state<string | null>(null);
let displayName = $state('');
let description = $state('');
// All files available to select for a new container (includes File Manager–only uploads)
let selectableFiles = $state<any[]>([]);

onMount(async () => {
isAuthenticated.subscribe((auth) => {
if (!auth) {
goto('/auth');
}
});

// Get current user ID from API
try {
const response = await apiClient.get('/api/users');
currentUserId = response.data.id;
} 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) {
Expand Down Expand Up @@ -278,11 +286,11 @@
<!-- Or Select Existing -->
<div>
<h3 class="text-lg font-semibold text-gray-900 mb-4">Or Select Existing File</h3>
{#if $files.filter(file => !file.signatures || file.signatures.length === 0).length === 0}
{#if selectableFiles.filter(file => !file.signatures || file.signatures.length === 0).length === 0}
<p class="text-gray-600 text-center py-8">No unused files available</p>
{:else}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 max-h-96 overflow-y-auto">
{#each $files.filter(file => !file.signatures || file.signatures.length === 0) as file}
{#each selectableFiles.filter(file => !file.signatures || file.signatures.length === 0) as file}
<button
onclick={() => {
selectedFile = file;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
});
Expand All @@ -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");
Expand Down
44 changes: 28 additions & 16 deletions platforms/file-manager-api/src/services/FileService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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" },
Expand Down Expand Up @@ -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" },
});
Expand All @@ -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;

Expand Down Expand Up @@ -237,10 +238,21 @@ export class FileService {
return false;
}

// Delete all access records
await this.fileAccessRepository.delete({ fileId: id });
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",
);
}

await this.fileRepository.remove(file);
// 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 });
file.name = SOFT_DELETED_FILE_NAME;
file.displayName = SOFT_DELETED_FILE_NAME;
await this.fileRepository.save(file);
return true;
}

Expand Down
Loading