feat(activities): attach compressed photos to activity notes

Photos on an activity are shrunk with the form-builder compressor and stored only when they fit under 200KB.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-27 21:30:45 +03:30
co-authored by Cursor
parent 9812e4e5ba
commit 1d3588a69d
10 changed files with 526 additions and 11 deletions
@@ -0,0 +1,32 @@
-- CreateTable
CREATE TABLE "ActivityImage" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"activityId" TEXT NOT NULL,
"fileName" TEXT NOT NULL,
"mimeType" TEXT NOT NULL,
"sizeBytes" INTEGER NOT NULL,
"storageKey" TEXT NOT NULL,
"uploadedById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ActivityImage_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "ActivityImage_activityId_createdAt_idx" ON "ActivityImage"("activityId", "createdAt");
-- CreateIndex
CREATE INDEX "ActivityImage_organizationId_idx" ON "ActivityImage"("organizationId");
-- CreateIndex
CREATE INDEX "ActivityImage_uploadedById_idx" ON "ActivityImage"("uploadedById");
-- AddForeignKey
ALTER TABLE "ActivityImage" ADD CONSTRAINT "ActivityImage_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ActivityImage" ADD CONSTRAINT "ActivityImage_activityId_fkey" FOREIGN KEY ("activityId") REFERENCES "Activity"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ActivityImage" ADD CONSTRAINT "ActivityImage_uploadedById_fkey" FOREIGN KEY ("uploadedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+23
View File
@@ -174,6 +174,7 @@ model Organization {
products Product[]
notifications Notification[]
noteAttachments NoteAttachment[]
activityImages ActivityImage[]
leadForms LeadForm[]
quotes Quote[]
invoices Invoice[]
@@ -293,6 +294,7 @@ model User {
notes Note[] @relation("NoteAuthor")
noteRevisions NoteRevision[] @relation("NoteRevisionEditor")
noteAttachments NoteAttachment[] @relation("NoteAttachmentUploader")
activityImages ActivityImage[] @relation("ActivityImageUploader")
notifications Notification[]
salesGoals SalesGoal[] @relation("SalesGoalUser")
commissionPercent Decimal @default(0) @db.Decimal(8, 2)
@@ -866,6 +868,7 @@ model Activity {
/// لید مرتبط (مثلاً فعالیت ساخته‌شده از ارسال فرم)
leadId String?
lead Lead? @relation(fields: [leadId], references: [id], onDelete: SetNull)
images ActivityImage[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -884,6 +887,26 @@ model Activity {
@@index([dealId])
}
/// عکس فشرده‌شدهٔ یادداشت فعالیت (حداکثر ۲۰۰ کیلوبایت)
model ActivityImage {
id String @id
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
activityId String
activity Activity @relation(fields: [activityId], references: [id], onDelete: Cascade)
fileName String
mimeType String
sizeBytes Int
storageKey String
uploadedById String?
uploadedBy User? @relation("ActivityImageUploader", fields: [uploadedById], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
@@index([activityId, createdAt])
@@index([organizationId])
@@index([uploadedById])
}
model Note {
id String @id @default(cuid())
organizationId String
@@ -309,6 +309,56 @@ export class ActivitiesController {
return this.activitiesService.get(user.organizationId, id, user);
}
@Post(':id/images')
@UseInterceptors(
FileInterceptor('file', {
storage: memoryStorage(),
limits: { fileSize: 2 * 1024 * 1024 },
}),
)
addImage(
@CurrentUser() user: Actor,
@Param('id') id: string,
@UploadedFile() file: Express.Multer.File,
) {
return this.activitiesService.addImage(
user.organizationId,
user.userId,
id,
file,
user,
);
}
@Get(':id/images/:imageId')
async downloadImage(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Param('imageId') imageId: string,
): Promise<StreamableFile> {
const { stream, fileName, mimeType } = await this.activitiesService.openImage(
user.organizationId,
id,
imageId,
user,
);
const encoded = encodeURIComponent(fileName);
return new StreamableFile(stream, {
type: mimeType,
disposition: `inline; filename="${fileName.replace(/"/g, '')}"; filename*=UTF-8''${encoded}`,
});
}
@Delete(':id/images/:imageId')
async removeImage(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Param('imageId') imageId: string,
) {
await this.activitiesService.removeImage(user.organizationId, id, imageId, user);
return { ok: true };
}
@Post(':id/promote-from-prior')
promoteFromPrior(@CurrentUser() user: Actor, @Param('id') id: string) {
return this.activitiesService.promoteFromPrior(user, id);
+2 -1
View File
@@ -7,9 +7,10 @@ import { ActivityTypesService } from './activity-types.service';
import { NotificationsModule } from '../notifications/notifications.module';
import { AutomationsModule } from '../automations/automations.module';
import { MessageAutomationsModule } from '../message-automations/message-automations.module';
import { StorageModule } from '../storage/storage.module';
@Module({
imports: [NotificationsModule, AutomationsModule, MessageAutomationsModule],
imports: [NotificationsModule, AutomationsModule, MessageAutomationsModule, StorageModule],
controllers: [ActivitiesController],
providers: [
ActivitiesService,
+137 -1
View File
@@ -8,6 +8,7 @@ import type {
ActivityDto,
ActivityFieldValues,
ActivityFilter,
ActivityImageDto,
ActivitySummaryDto,
ActivityStageCountsDto,
CreateActivityRequest,
@@ -15,6 +16,9 @@ import type {
UpdateActivityRequest,
} from '@mento/shared';
import { Prisma } from '@prisma/client';
import { randomUUID } from 'node:crypto';
import * as path from 'node:path';
import { LocalFileStorage } from '../storage/local-file.storage';
import { PrismaService } from '../prisma/prisma.service';
import { NotificationsService } from '../notifications/notifications.service';
import { AutomationsService } from '../automations/automations.service';
@@ -84,8 +88,19 @@ type ActivityRow = {
typeDef?: { id: string; name: string; icon: string; color: string } | null;
fields?: Prisma.JsonValue | null;
participantIds?: string[];
images?: Array<{
id: string;
fileName: string;
mimeType: string;
sizeBytes: number;
createdAt: Date;
}>;
};
const ACTIVITY_IMAGE_MAX_BYTES = 200 * 1024;
const ACTIVITY_IMAGE_MAX_COUNT = 8;
const ACTIVITY_IMAGE_MIME = new Set(['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/gif']);
const include = {
stage: { select: { id: true, kind: true, boardId: true } },
typeDef: { select: { id: true, name: true, icon: true, color: true } },
@@ -108,6 +123,7 @@ export class ActivitiesService {
private readonly stages: ActivityStagesService,
private readonly types: ActivityTypesService,
private readonly game: GamificationService,
private readonly files: LocalFileStorage,
) {}
private async ctx(organizationId: string): Promise<DtoContext> {
@@ -382,7 +398,19 @@ export class ActivitiesService {
): Promise<ActivityDto> {
const row = await this.prisma.activity.findFirst({
where: { id, organizationId, ...activityAccessWhere(actor) },
include,
include: {
...include,
images: {
select: {
id: true,
fileName: true,
mimeType: true,
sizeBytes: true,
createdAt: true,
},
orderBy: { createdAt: 'asc' },
},
},
});
if (!row) throw new NotFoundException('فعالیت پیدا نشد');
const dto = this.toDto(row, await this.ctx(organizationId));
@@ -701,7 +729,102 @@ export class ActivitiesService {
actor: ActivityActor,
): Promise<void> {
await this.ensureExists(organizationId, id, actor);
const images = await this.prisma.activityImage.findMany({
where: { activityId: id, organizationId },
select: { storageKey: true },
});
await this.prisma.activity.delete({ where: { id } });
for (const image of images) {
await this.files.remove(image.storageKey);
}
}
async addImage(
organizationId: string,
userId: string,
activityId: string,
file: Express.Multer.File,
actor: ActivityActor,
): Promise<ActivityImageDto> {
await this.ensureExists(organizationId, activityId, actor);
if (!file?.buffer?.length) {
throw new BadRequestException('فایلی ارسال نشده است');
}
const mimeType = (file.mimetype || '').toLowerCase();
if (!ACTIVITY_IMAGE_MIME.has(mimeType)) {
throw new BadRequestException('فقط عکس می‌توانید اضافه کنید.');
}
if (file.size > ACTIVITY_IMAGE_MAX_BYTES) {
throw new BadRequestException('حجم عکس باید زیر 200 کیلوبایت باشد');
}
const count = await this.prisma.activityImage.count({ where: { activityId } });
if (count >= ACTIVITY_IMAGE_MAX_COUNT) {
throw new BadRequestException(`حداکثر ${ACTIVITY_IMAGE_MAX_COUNT} عکس برای هر فعالیت مجاز است`);
}
const fileName = sanitizeActivityImageName(file.originalname);
const ext = path.extname(fileName).slice(0, 12) || '.jpg';
const imageId = randomUUID().replace(/-/g, '').slice(0, 24);
const storageKey = path
.join(organizationId, 'activities', activityId, `${imageId}${ext}`)
.replace(/\\/g, '/');
await this.files.save(storageKey, file.buffer);
try {
const row = await this.prisma.activityImage.create({
data: {
id: imageId,
organizationId,
activityId,
fileName,
mimeType: mimeType === 'image/jpg' ? 'image/jpeg' : mimeType,
sizeBytes: file.size,
storageKey,
uploadedById: userId,
},
});
return {
id: row.id,
fileName: row.fileName,
mimeType: row.mimeType,
sizeBytes: row.sizeBytes,
createdAt: row.createdAt.toISOString(),
};
} catch (err) {
await this.files.remove(storageKey);
throw err;
}
}
async removeImage(
organizationId: string,
activityId: string,
imageId: string,
actor: ActivityActor,
): Promise<void> {
await this.ensureExists(organizationId, activityId, actor);
const row = await this.prisma.activityImage.findFirst({
where: { id: imageId, activityId, organizationId },
});
if (!row) throw new NotFoundException('عکس پیدا نشد');
await this.files.remove(row.storageKey);
await this.prisma.activityImage.delete({ where: { id: row.id } });
}
async openImage(
organizationId: string,
activityId: string,
imageId: string,
actor: ActivityActor,
): Promise<{ stream: ReturnType<LocalFileStorage['openReadStream']>; fileName: string; mimeType: string }> {
await this.ensureExists(organizationId, activityId, actor);
const row = await this.prisma.activityImage.findFirst({
where: { id: imageId, activityId, organizationId },
});
if (!row) throw new NotFoundException('عکس پیدا نشد');
return {
stream: this.files.openReadStream(row.storageKey),
fileName: row.fileName,
mimeType: row.mimeType,
};
}
private buildWhere(
@@ -971,6 +1094,13 @@ export class ActivitiesService {
priority: row.priority,
title: row.title,
description: row.description,
images: row.images?.map((image) => ({
id: image.id,
fileName: image.fileName,
mimeType: image.mimeType,
sizeBytes: image.sizeBytes,
createdAt: image.createdAt.toISOString(),
})),
dueAt: row.dueAt?.toISOString() ?? null,
durationMinutes: row.durationMinutes,
reminderAt: row.reminderAt?.toISOString() ?? null,
@@ -1042,6 +1172,12 @@ function typesMatching(term: string): ActivityDto['type'][] {
);
}
function sanitizeActivityImageName(raw: string): string {
const base = path.basename(raw || 'photo.jpg').replace(/[<>:"/\\|?*\x00-\x1f]/g, '_');
const trimmed = base.trim() || 'photo.jpg';
return trimmed.slice(0, 180);
}
function emptyToNull(value?: string | null): string | null {
if (value == null) return null;
const trimmed = value.trim();
+2
View File
@@ -162,6 +162,7 @@ describe('promote from prior', () => {
} as never,
{ system: async () => ({}), toSummary: (row: unknown) => row } as never,
{} as never,
{} as never,
);
const dto = await service.promoteFromPrior(actor, 'act1');
@@ -191,6 +192,7 @@ describe('promote from prior', () => {
} as never,
{ system: async () => ({}), toSummary: (row: unknown) => row } as never,
{} as never,
{} as never,
);
await assert.rejects(service.promoteFromPrior({ ...actor, priorLens: 'off' }, 'act1'), ForbiddenException);