Add lead company logos and refine Leads stats cards.
Leads can upload/change/remove a logo shown in the list; stats use fixed stage labels with accurate non-archived counts.
This commit is contained in:
@@ -5,6 +5,7 @@ import {
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import * as path from 'node:path';
|
||||
import type {
|
||||
ConvertLeadResult,
|
||||
LeadBoardColumnDto,
|
||||
@@ -20,8 +21,18 @@ import {
|
||||
elevated,
|
||||
} from '../common/record-scope';
|
||||
import { emptyToNull, normalizePhone } from '../contacts/normalize';
|
||||
import { LocalFileStorage } from '../storage/local-file.storage';
|
||||
import { LeadStagesService } from './lead-stages.service';
|
||||
|
||||
const LOGO_MAX_BYTES = 2 * 1024 * 1024;
|
||||
const LOGO_EXT: Record<string, string> = {
|
||||
'image/jpeg': 'jpg',
|
||||
'image/jpg': 'jpg',
|
||||
'image/png': 'png',
|
||||
'image/webp': 'webp',
|
||||
'image/gif': 'gif',
|
||||
};
|
||||
|
||||
function mobileKey(raw: string | null | undefined): string | null {
|
||||
const n = normalizePhone(raw);
|
||||
if (!n) return null;
|
||||
@@ -57,6 +68,7 @@ export class LeadsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly stages: LeadStagesService,
|
||||
private readonly files: LocalFileStorage,
|
||||
) {}
|
||||
|
||||
async list(
|
||||
@@ -994,8 +1006,72 @@ export class LeadsService {
|
||||
convertedAt: row.convertedAt?.toISOString() ?? null,
|
||||
archivedAt: row.archivedAt?.toISOString() ?? null,
|
||||
daysInStage,
|
||||
hasLogo: Boolean(row.logoStorageKey),
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async saveLogo(
|
||||
actor: Actor,
|
||||
id: string,
|
||||
file: Express.Multer.File | undefined,
|
||||
): Promise<LeadDto> {
|
||||
if (!file?.buffer?.length) {
|
||||
throw new BadRequestException('فایل تصویر ارسال نشده');
|
||||
}
|
||||
if (file.size > LOGO_MAX_BYTES) {
|
||||
throw new BadRequestException('حجم تصویر باید کمتر از ۲ مگابایت باشد');
|
||||
}
|
||||
const mime = (file.mimetype || '').toLowerCase();
|
||||
const ext = LOGO_EXT[mime];
|
||||
if (!ext) {
|
||||
throw new BadRequestException('فقط تصویر JPG، PNG، WebP یا GIF مجاز است');
|
||||
}
|
||||
const existing = await this.findAccessible(actor, id);
|
||||
const prevKey = existing.logoStorageKey;
|
||||
const storageKey = path
|
||||
.join(actor.organizationId, 'leads', id, `logo.${ext}`)
|
||||
.replace(/\\/g, '/');
|
||||
await this.files.save(storageKey, file.buffer);
|
||||
if (prevKey && prevKey !== storageKey) {
|
||||
await this.files.remove(prevKey);
|
||||
}
|
||||
const row = await this.prisma.lead.update({
|
||||
where: { id },
|
||||
data: { logoStorageKey: storageKey },
|
||||
include: leadInclude,
|
||||
});
|
||||
await this.addEvent(actor, id, 'note', 'لوگو بهروز شد', null);
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
async removeLogo(actor: Actor, id: string): Promise<LeadDto> {
|
||||
const existing = await this.findAccessible(actor, id);
|
||||
if (existing.logoStorageKey) {
|
||||
await this.files.remove(existing.logoStorageKey);
|
||||
}
|
||||
const row = await this.prisma.lead.update({
|
||||
where: { id },
|
||||
data: { logoStorageKey: null },
|
||||
include: leadInclude,
|
||||
});
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
async openLogo(actor: Actor, id: string) {
|
||||
const existing = await this.findAccessible(actor, id);
|
||||
if (!existing.logoStorageKey) {
|
||||
throw new NotFoundException('لوگو تنظیم نشده');
|
||||
}
|
||||
const storageKey = existing.logoStorageKey;
|
||||
const ext = path.extname(storageKey).slice(1).toLowerCase();
|
||||
const mime =
|
||||
Object.entries(LOGO_EXT).find(([, value]) => value === ext)?.[0] ??
|
||||
'image/jpeg';
|
||||
return {
|
||||
stream: this.files.openReadStream(storageKey),
|
||||
mimeType: mime,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user