edits for trenders attachments & claims
This commit is contained in:
@@ -56,8 +56,8 @@ RUN npm ci --only=production && \
|
||||
# Copy built application
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
# Change ownership of all files to the nodejs user
|
||||
RUN chown -R expressjs:nodejs /app
|
||||
# Ensure uploads directory exists and is owned by app user
|
||||
RUN mkdir -p /app/uploads /app/uploads/tenders && chown -R expressjs:nodejs /app
|
||||
|
||||
# Switch to non-root user
|
||||
USER expressjs
|
||||
|
||||
22
backend/prisma/add-expense-mark-paid-permission.sql
Normal file
22
backend/prisma/add-expense-mark-paid-permission.sql
Normal file
@@ -0,0 +1,22 @@
|
||||
-- Add 'mark-as-paid' action to department_expense_claims permission for positions
|
||||
-- that already have the 'approve' action on it.
|
||||
-- This is intended for rolling out the new "mark expense claim as paid" feature
|
||||
-- to managers/accountants who currently approve claims, without granting it to everyone.
|
||||
--
|
||||
-- Safe to run multiple times: only updates rows that don't already have the action.
|
||||
--
|
||||
-- Run on server:
|
||||
-- docker-compose exec -T postgres psql -U postgres -d mind14_crm -f - < backend/prisma/add-expense-mark-paid-permission.sql
|
||||
-- Or from backend:
|
||||
-- npx prisma db execute --file prisma/add-expense-mark-paid-permission.sql
|
||||
|
||||
UPDATE position_permissions
|
||||
SET
|
||||
actions = actions || '["mark-as-paid"]'::jsonb,
|
||||
"updatedAt" = NOW()
|
||||
WHERE module = 'department_expense_claims'
|
||||
AND resource = '*'
|
||||
AND NOT (actions @> '["mark-as-paid"]'::jsonb)
|
||||
AND NOT (actions @> '["*"]'::jsonb)
|
||||
AND NOT (actions @> '["all"]'::jsonb)
|
||||
AND actions @> '["approve"]'::jsonb;
|
||||
@@ -525,6 +525,8 @@ model ExpenseClaim {
|
||||
rejectedReason String?
|
||||
approvalNote String?
|
||||
|
||||
isPaid Boolean @default(false)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import dotenv from 'dotenv';
|
||||
import path from 'path';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
@@ -33,10 +34,10 @@ export const config = {
|
||||
},
|
||||
|
||||
upload: {
|
||||
maxFileSize: parseInt(process.env.MAX_FILE_SIZE || '10485760', 10), // 10MB
|
||||
path: process.env.UPLOAD_PATH || './uploads',
|
||||
maxFileSize: parseInt(process.env.MAX_FILE_SIZE || '104857600', 10),
|
||||
path: process.env.UPLOAD_PATH || path.resolve(process.cwd(), 'uploads'),
|
||||
},
|
||||
|
||||
|
||||
pagination: {
|
||||
defaultPageSize: parseInt(process.env.DEFAULT_PAGE_SIZE || '20', 10),
|
||||
maxPageSize: parseInt(process.env.MAX_PAGE_SIZE || '100', 10),
|
||||
|
||||
@@ -18,6 +18,21 @@ if (!fs.existsSync(expenseClaimsUploadDir)) {
|
||||
const expenseClaimStorage = multer.diskStorage({
|
||||
destination: (_req, _file, cb) => cb(null, expenseClaimsUploadDir),
|
||||
filename: (_req, file, cb) => {
|
||||
// Browsers send filenames in multipart/form-data as raw UTF-8 bytes,
|
||||
// but multer/busboy decode them as latin1 by default. For Arabic
|
||||
// (or any non-ASCII) filenames this produces mojibake like "ÙÙŠÙØ§...".
|
||||
// Reverse the misinterpretation: take the latin1 string back to bytes,
|
||||
// then decode as UTF-8. The service reads `decodedOriginalName` when
|
||||
// it persists the attachment to the DB.
|
||||
try {
|
||||
(file as any).decodedOriginalName = Buffer.from(
|
||||
file.originalname,
|
||||
'latin1'
|
||||
).toString('utf8');
|
||||
} catch {
|
||||
(file as any).decodedOriginalName = file.originalname;
|
||||
}
|
||||
|
||||
const safeName = (file.originalname || 'file').replace(/[^a-zA-Z0-9.-]/g, '_');
|
||||
cb(null, `${crypto.randomUUID()}-${safeName}`);
|
||||
},
|
||||
@@ -105,11 +120,12 @@ router.get('/portal/expense-claims', portalController.getMyExpenseClaims);
|
||||
router.post(
|
||||
'/portal/expense-claims',
|
||||
(req, res, next) => {
|
||||
expenseClaimUpload.single('attachment')(req, res, (error: any) => {
|
||||
// Accept up to 10 files under the form field "attachments".
|
||||
expenseClaimUpload.array('attachments', 10)(req, res, (error: any) => {
|
||||
if (error) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: error.message || 'تعذر رفع المرفق',
|
||||
message: error.message || 'تعذر رفع المرفقات',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -136,6 +152,12 @@ router.post(
|
||||
portalController.rejectManagedExpenseClaim
|
||||
);
|
||||
|
||||
router.patch(
|
||||
'/portal/managed-expense-claims/:id/paid',
|
||||
authorize('department_expense_claims', '*', 'mark-as-paid'),
|
||||
portalController.markExpenseClaimPaid
|
||||
);
|
||||
|
||||
// ========== EMPLOYEES ==========
|
||||
|
||||
router.get('/employees', authorize('hr', 'employees', 'read'), hrController.findAllEmployees);
|
||||
|
||||
@@ -239,11 +239,13 @@ export class PortalController {
|
||||
body.items = JSON.parse(body.items);
|
||||
}
|
||||
|
||||
const files = (req.files as Express.Multer.File[] | undefined) || [];
|
||||
|
||||
const data = await portalService.submitExpenseClaim(
|
||||
req.user?.employeeId,
|
||||
body,
|
||||
req.user!.id,
|
||||
req.file as any
|
||||
files
|
||||
);
|
||||
|
||||
res
|
||||
@@ -292,7 +294,8 @@ export class PortalController {
|
||||
async getManagedExpenseClaims(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const status = req.query.status as string | undefined;
|
||||
const data = await portalService.getManagedExpenseClaims(req.user?.employeeId, status);
|
||||
const search = req.query.search as string | undefined;
|
||||
const data = await portalService.getManagedExpenseClaims(req.user?.employeeId, status, search);
|
||||
res.json(ResponseFormatter.success(data));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -331,6 +334,28 @@ export class PortalController {
|
||||
}
|
||||
}
|
||||
|
||||
async markExpenseClaimPaid(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const isPaid = Boolean(req.body?.isPaid);
|
||||
const data = await portalService.markExpenseClaimPaid(
|
||||
req.user?.employeeId,
|
||||
req.params.id,
|
||||
isPaid,
|
||||
req.user!.id
|
||||
);
|
||||
res.json(
|
||||
ResponseFormatter.success(
|
||||
data,
|
||||
isPaid
|
||||
? 'تم تعليم كشف المصاريف كمقبوض - Expense claim marked as paid'
|
||||
: 'تم إلغاء تعليم القبض - Paid mark removed'
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async getMyAttendance(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const month = req.query.month ? parseInt(req.query.month as string) : undefined;
|
||||
|
||||
@@ -4,6 +4,32 @@ import { hrService } from './hr.service';
|
||||
import { notificationsService } from '../notifications/notifications.service';
|
||||
import path from 'path';
|
||||
|
||||
// Pattern that indicates a UTF-8 string was misinterpreted as latin1
|
||||
// (the legacy multer/busboy default). A UTF-8 2-byte sequence is a starter
|
||||
// byte in 0xC2-0xDF followed by a continuation byte in 0x80-0xBF, which is
|
||||
// exactly what we look for here. Properly-stored Arabic text has code
|
||||
// points U+0600-U+06FF and won't match this pattern, so the check is safe.
|
||||
const MOJIBAKE_PATTERN = /[\u00C2-\u00DF][\u0080-\u00BF]/;
|
||||
|
||||
/**
|
||||
* Heuristically repair a string that was stored after multer interpreted
|
||||
* the file's UTF-8 filename bytes as latin1. Returns the input unchanged
|
||||
* if it doesn't look like mojibake or if re-decoding would lose data.
|
||||
*/
|
||||
function repairFilenameEncoding(value: string | null | undefined): string {
|
||||
if (!value) return value ?? '';
|
||||
if (!MOJIBAKE_PATTERN.test(value)) return value;
|
||||
try {
|
||||
const decoded = Buffer.from(value, 'latin1').toString('utf8');
|
||||
// If the re-decode produced replacement chars, the original wasn't
|
||||
// actually mojibake — bail out and keep the existing value.
|
||||
if (decoded.includes('\uFFFD')) return value;
|
||||
return decoded;
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
class PortalService {
|
||||
private requireEmployeeId(employeeId: string | undefined): string {
|
||||
if (!employeeId) {
|
||||
@@ -261,7 +287,13 @@ class PortalService {
|
||||
|
||||
return claims.map((claim) => ({
|
||||
...claim,
|
||||
attachments: attachments.filter((attachment) => attachment.entityId === claim.id),
|
||||
attachments: attachments
|
||||
.filter((attachment) => attachment.entityId === claim.id)
|
||||
.map((attachment) => ({
|
||||
...attachment,
|
||||
// Repair mojibake in records uploaded before the multer fix.
|
||||
originalName: repairFilenameEncoding(attachment.originalName),
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -274,7 +306,12 @@ async getExpenseClaimAttachmentFile(attachmentId: string) {
|
||||
throw new AppError(404, 'الملف غير موجود');
|
||||
}
|
||||
|
||||
return attachment;
|
||||
// Repair mojibake so the Content-Disposition filename* the controller
|
||||
// generates uses the real Arabic name when opening/downloading.
|
||||
return {
|
||||
...attachment,
|
||||
originalName: repairFilenameEncoding(attachment.originalName),
|
||||
};
|
||||
}
|
||||
|
||||
async getManagedOvertimeRequests(employeeId: string | undefined) {
|
||||
@@ -591,7 +628,7 @@ async submitExpenseClaim(
|
||||
description?: string;
|
||||
},
|
||||
userId: string,
|
||||
file?: Express.Multer.File
|
||||
files?: Express.Multer.File[]
|
||||
) {
|
||||
|
||||
const empId = this.requireEmployeeId(employeeId);
|
||||
@@ -643,22 +680,24 @@ async submitExpenseClaim(
|
||||
},
|
||||
});
|
||||
|
||||
if (file) {
|
||||
const fileName = path.basename(file.path);
|
||||
|
||||
await prisma.attachment.create({
|
||||
data: {
|
||||
entityType: 'EXPENSE_CLAIM',
|
||||
entityId: claim.id,
|
||||
fileName,
|
||||
originalName: (file as any).decodedOriginalName || file.originalname,
|
||||
mimeType: file.mimetype,
|
||||
size: file.size,
|
||||
path: file.path,
|
||||
category: 'EXPENSE_CLAIM_ATTACHMENT',
|
||||
uploadedBy: userId,
|
||||
},
|
||||
});
|
||||
if (files && files.length > 0) {
|
||||
await Promise.all(
|
||||
files.map((file) =>
|
||||
prisma.attachment.create({
|
||||
data: {
|
||||
entityType: 'EXPENSE_CLAIM',
|
||||
entityId: claim.id,
|
||||
fileName: path.basename(file.path),
|
||||
originalName: (file as any).decodedOriginalName || file.originalname,
|
||||
mimeType: file.mimetype,
|
||||
size: file.size,
|
||||
path: file.path,
|
||||
category: 'EXPENSE_CLAIM_ATTACHMENT',
|
||||
uploadedBy: userId,
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
const employeeFullName = `${claim.employee.firstName} ${claim.employee.lastName}`;
|
||||
|
||||
@@ -687,7 +726,11 @@ const [claimWithAttachments] = await this.attachExpenseClaimFiles([claim]);
|
||||
return claimWithAttachments;
|
||||
}
|
||||
|
||||
async getManagedExpenseClaims(employeeId: string | undefined, status?: string) {
|
||||
async getManagedExpenseClaims(
|
||||
employeeId: string | undefined,
|
||||
status?: string,
|
||||
search?: string,
|
||||
) {
|
||||
this.requireEmployeeId(employeeId);
|
||||
|
||||
const where: any = {};
|
||||
@@ -696,6 +739,19 @@ return claimWithAttachments;
|
||||
where.status = status;
|
||||
}
|
||||
|
||||
const trimmedSearch = search?.trim();
|
||||
if (trimmedSearch) {
|
||||
where.employee = {
|
||||
OR: [
|
||||
{ firstName: { contains: trimmedSearch, mode: 'insensitive' } },
|
||||
{ lastName: { contains: trimmedSearch, mode: 'insensitive' } },
|
||||
{ firstNameAr: { contains: trimmedSearch, mode: 'insensitive' } },
|
||||
{ lastNameAr: { contains: trimmedSearch, mode: 'insensitive' } },
|
||||
{ uniqueEmployeeId: { contains: trimmedSearch, mode: 'insensitive' } },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const claims = await prisma.expenseClaim.findMany({
|
||||
where,
|
||||
include: {
|
||||
@@ -843,6 +899,66 @@ return claimWithAttachments;
|
||||
return claim;
|
||||
}
|
||||
|
||||
async markExpenseClaimPaid(
|
||||
managerEmployeeId: string | undefined,
|
||||
claimId: string,
|
||||
isPaid: boolean,
|
||||
userId: string
|
||||
) {
|
||||
this.requireEmployeeId(managerEmployeeId);
|
||||
|
||||
const existing = await prisma.expenseClaim.findUnique({
|
||||
where: { id: claimId },
|
||||
select: { id: true, status: true, isPaid: true, claimNumber: true },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new AppError(404, 'كشف المصاريف غير موجود - Expense claim not found');
|
||||
}
|
||||
|
||||
if (existing.status !== 'APPROVED') {
|
||||
throw new AppError(
|
||||
400,
|
||||
'يمكن تعليم القبض فقط على الكشوف المعتمدة - Only approved claims can be marked as paid'
|
||||
);
|
||||
}
|
||||
|
||||
if (existing.isPaid === isPaid) {
|
||||
// Idempotent: no change needed.
|
||||
const claim = await prisma.expenseClaim.findUnique({
|
||||
where: { id: claimId },
|
||||
include: {
|
||||
employee: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
uniqueEmployeeId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return claim;
|
||||
}
|
||||
|
||||
const claim = await prisma.expenseClaim.update({
|
||||
where: { id: claimId },
|
||||
data: { isPaid },
|
||||
include: {
|
||||
employee: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
uniqueEmployeeId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return claim;
|
||||
}
|
||||
|
||||
async getMyAttendance(employeeId: string | undefined, month?: number, year?: number) {
|
||||
const empId = this.requireEmployeeId(employeeId);
|
||||
const now = new Date();
|
||||
|
||||
@@ -250,8 +250,12 @@ export class TendersController {
|
||||
|
||||
const fs = require('fs')
|
||||
if (!fs.existsSync(file)) {
|
||||
console.error('[tenders.viewAttachment] Resolved path missing at send time', {
|
||||
attachmentId: req.params.attachmentId,
|
||||
resolvedPath: file,
|
||||
})
|
||||
return res.status(404).json(
|
||||
ResponseFormatter.error('File not found', 'الملف غير موجود')
|
||||
ResponseFormatter.error('File not found - الملف غير موجود', 'FILE_NOT_FOUND')
|
||||
)
|
||||
}
|
||||
|
||||
@@ -259,7 +263,7 @@ export class TendersController {
|
||||
return res.sendFile(path.resolve(file))
|
||||
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
console.error('[tenders.viewAttachment]', error)
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { AuditLogger } from '../../shared/utils/auditLogger';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import path from 'path';
|
||||
import fs from 'fs'
|
||||
import { config } from '../../config';
|
||||
|
||||
|
||||
const TENDER_SOURCE_VALUES = [
|
||||
@@ -747,7 +748,27 @@ private getEffectiveTenderStatus(tender: {
|
||||
) {
|
||||
const tender = await prisma.tender.findUnique({ where: { id: tenderId } });
|
||||
if (!tender) throw new AppError(404, 'Tender not found');
|
||||
const fileName = path.basename(file.path);
|
||||
|
||||
const absolutePath = path.resolve(file.path);
|
||||
const fileName = path.basename(absolutePath);
|
||||
|
||||
// Verify multer actually wrote the file to disk before recording it.
|
||||
if (!fs.existsSync(absolutePath)) {
|
||||
console.error('[tenders.uploadTenderAttachment] Multer reported a file but it does not exist on disk', {
|
||||
tenderId,
|
||||
multerPath: file.path,
|
||||
resolvedPath: absolutePath,
|
||||
size: file.size,
|
||||
});
|
||||
throw new AppError(500, 'File upload failed - فشل رفع الملف');
|
||||
}
|
||||
|
||||
console.log('[tenders.uploadTenderAttachment] File saved', {
|
||||
tenderId,
|
||||
path: absolutePath,
|
||||
size: file.size,
|
||||
});
|
||||
|
||||
const attachment = await prisma.attachment.create({
|
||||
data: {
|
||||
entityType: 'TENDER',
|
||||
@@ -757,11 +778,12 @@ private getEffectiveTenderStatus(tender: {
|
||||
originalName: file.originalname,
|
||||
mimeType: file.mimetype,
|
||||
size: file.size,
|
||||
path: file.path,
|
||||
path: absolutePath,
|
||||
category: category || 'ANNOUNCEMENT',
|
||||
uploadedBy: userId,
|
||||
},
|
||||
});
|
||||
|
||||
await AuditLogger.log({
|
||||
entityType: 'TENDER',
|
||||
entityId: tenderId,
|
||||
@@ -769,6 +791,7 @@ private getEffectiveTenderStatus(tender: {
|
||||
userId,
|
||||
changes: { attachmentUploaded: attachment.id },
|
||||
});
|
||||
|
||||
return attachment;
|
||||
}
|
||||
|
||||
@@ -782,8 +805,29 @@ private getEffectiveTenderStatus(tender: {
|
||||
where: { id: directiveId },
|
||||
select: { id: true, tenderId: true },
|
||||
});
|
||||
|
||||
if (!directive) throw new AppError(404, 'Directive not found');
|
||||
const fileName = path.basename(file.path);
|
||||
|
||||
const absolutePath = path.resolve(file.path);
|
||||
const fileName = path.basename(absolutePath);
|
||||
|
||||
// Verify multer actually wrote the file to disk before recording it.
|
||||
if (!fs.existsSync(absolutePath)) {
|
||||
console.error('[tenders.uploadDirectiveAttachment] Multer reported a file but it does not exist on disk', {
|
||||
directiveId,
|
||||
multerPath: file.path,
|
||||
resolvedPath: absolutePath,
|
||||
size: file.size,
|
||||
});
|
||||
throw new AppError(500, 'File upload failed - فشل رفع الملف');
|
||||
}
|
||||
|
||||
console.log('[tenders.uploadDirectiveAttachment] File saved', {
|
||||
directiveId,
|
||||
path: absolutePath,
|
||||
size: file.size,
|
||||
});
|
||||
|
||||
const attachment = await prisma.attachment.create({
|
||||
data: {
|
||||
entityType: 'TENDER_DIRECTIVE',
|
||||
@@ -794,11 +838,12 @@ private getEffectiveTenderStatus(tender: {
|
||||
originalName: file.originalname,
|
||||
mimeType: file.mimetype,
|
||||
size: file.size,
|
||||
path: file.path,
|
||||
path: absolutePath,
|
||||
category: category || 'TASK_FILE',
|
||||
uploadedBy: userId,
|
||||
},
|
||||
});
|
||||
|
||||
await AuditLogger.log({
|
||||
entityType: 'TENDER_DIRECTIVE',
|
||||
entityId: directiveId,
|
||||
@@ -806,17 +851,62 @@ private getEffectiveTenderStatus(tender: {
|
||||
userId,
|
||||
changes: { attachmentUploaded: attachment.id },
|
||||
});
|
||||
|
||||
return attachment;
|
||||
}
|
||||
|
||||
async getAttachmentFile(attachmentId: string): Promise<string> {
|
||||
async getAttachmentFile(attachmentId: string): Promise<string> {
|
||||
const attachment = await prisma.attachment.findUnique({
|
||||
where: { id: attachmentId },
|
||||
})
|
||||
});
|
||||
|
||||
if (!attachment) throw new AppError(404, 'File not found')
|
||||
if (!attachment) throw new AppError(404, 'File not found');
|
||||
|
||||
return attachment.path
|
||||
// Try multiple candidate locations for the file (in order of preference).
|
||||
// This makes the system resilient to path changes between deploys (e.g.
|
||||
// when an old DB row has a stale absolute path).
|
||||
const candidates = [
|
||||
attachment.path,
|
||||
path.join(config.upload.path, 'tenders', attachment.fileName),
|
||||
path.join(config.upload.path, attachment.fileName),
|
||||
path.join(process.cwd(), 'uploads', 'tenders', attachment.fileName),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.map((p) => path.resolve(String(p)));
|
||||
|
||||
const existingPath = candidates.find((p) => fs.existsSync(p));
|
||||
|
||||
if (!existingPath) {
|
||||
console.error('[tenders.getAttachmentFile] File not found on disk', {
|
||||
attachmentId,
|
||||
storedPath: attachment.path,
|
||||
fileName: attachment.fileName,
|
||||
uploadConfigPath: config.upload.path,
|
||||
triedCandidates: candidates,
|
||||
});
|
||||
throw new AppError(404, 'File not found - الملف غير موجود');
|
||||
}
|
||||
|
||||
// Self-healing: if the file lives at a path other than what's stored,
|
||||
// update the DB so future lookups are direct.
|
||||
if (existingPath !== attachment.path) {
|
||||
console.warn('[tenders.getAttachmentFile] Stored path was stale, updating', {
|
||||
attachmentId,
|
||||
oldPath: attachment.path,
|
||||
newPath: existingPath,
|
||||
});
|
||||
try {
|
||||
await prisma.attachment.update({
|
||||
where: { id: attachmentId },
|
||||
data: { path: existingPath },
|
||||
});
|
||||
} catch (err) {
|
||||
// Non-fatal: we still have the resolved path to serve from.
|
||||
console.error('[tenders.getAttachmentFile] Failed to update stale path', err);
|
||||
}
|
||||
}
|
||||
|
||||
return existingPath;
|
||||
}
|
||||
|
||||
async deleteAttachment(attachmentId: string): Promise<void> {
|
||||
@@ -826,12 +916,10 @@ private getEffectiveTenderStatus(tender: {
|
||||
|
||||
if (!attachment) throw new AppError(404, 'File not found')
|
||||
|
||||
// حذف من الديسك
|
||||
if (attachment.path && fs.existsSync(attachment.path)) {
|
||||
fs.unlinkSync(attachment.path)
|
||||
}
|
||||
|
||||
// حذف من DB
|
||||
await prisma.attachment.delete({
|
||||
where: { id: attachmentId },
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user