edits for trenders attachments & claims

This commit is contained in:
Aya
2026-05-19 11:41:44 +03:00
parent 7732a40726
commit 12c4ca8334
19 changed files with 583 additions and 105 deletions

View File

@@ -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

View 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;

View File

@@ -525,6 +525,8 @@ model ExpenseClaim {
rejectedReason String?
approvalNote String?
isPaid Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

View File

@@ -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),

View File

@@ -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);

View File

@@ -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;

View File

@@ -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();

View File

@@ -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)
}
}

View File

@@ -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 },
})