From 1d3588a69d4349e5dbb5359bfef90e798b185da7 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Tue, 27 Oct 2026 21:30:45 +0330 Subject: [PATCH] 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 --- .../migration.sql | 32 +++ apps/api/prisma/schema.prisma | 23 ++ .../src/activities/activities.controller.ts | 50 ++++ apps/api/src/activities/activities.module.ts | 3 +- apps/api/src/activities/activities.service.ts | 138 ++++++++++- apps/api/test/promote-from-prior.test.ts | 2 + apps/web/src/lib/form-file-compress.ts | 2 + apps/web/src/views/ActivityDetailView.vue | 59 ++++- apps/web/src/views/ActivityFormView.vue | 218 +++++++++++++++++- packages/shared/src/index.ts | 10 + 10 files changed, 526 insertions(+), 11 deletions(-) create mode 100644 apps/api/prisma/migrations/20261027180000_activity_images/migration.sql diff --git a/apps/api/prisma/migrations/20261027180000_activity_images/migration.sql b/apps/api/prisma/migrations/20261027180000_activity_images/migration.sql new file mode 100644 index 0000000..dd20347 --- /dev/null +++ b/apps/api/prisma/migrations/20261027180000_activity_images/migration.sql @@ -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; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index d60140e..a66ab65 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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 diff --git a/apps/api/src/activities/activities.controller.ts b/apps/api/src/activities/activities.controller.ts index 38696cb..b8fd2a5 100644 --- a/apps/api/src/activities/activities.controller.ts +++ b/apps/api/src/activities/activities.controller.ts @@ -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 { + 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); diff --git a/apps/api/src/activities/activities.module.ts b/apps/api/src/activities/activities.module.ts index fb69c95..d825a9d 100644 --- a/apps/api/src/activities/activities.module.ts +++ b/apps/api/src/activities/activities.module.ts @@ -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, diff --git a/apps/api/src/activities/activities.service.ts b/apps/api/src/activities/activities.service.ts index 887ec8a..3e30b0f 100644 --- a/apps/api/src/activities/activities.service.ts +++ b/apps/api/src/activities/activities.service.ts @@ -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 { @@ -382,7 +398,19 @@ export class ActivitiesService { ): Promise { 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 { 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 { + 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 { + 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; 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(); diff --git a/apps/api/test/promote-from-prior.test.ts b/apps/api/test/promote-from-prior.test.ts index 0aae331..d45691a 100644 --- a/apps/api/test/promote-from-prior.test.ts +++ b/apps/api/test/promote-from-prior.test.ts @@ -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); diff --git a/apps/web/src/lib/form-file-compress.ts b/apps/web/src/lib/form-file-compress.ts index 43e8bb9..9bd3957 100644 --- a/apps/web/src/lib/form-file-compress.ts +++ b/apps/web/src/lib/form-file-compress.ts @@ -97,6 +97,7 @@ export async function prepareFormFieldFile(file: File): Promise<{ mime: string; size: number; dataUrl: string; + file: File; }> { let prepared = file; if (file.type.startsWith('image/')) { @@ -115,6 +116,7 @@ export async function prepareFormFieldFile(file: File): Promise<{ mime: prepared.type || file.type || 'application/octet-stream', size: prepared.size, dataUrl, + file: prepared, }; } diff --git a/apps/web/src/views/ActivityDetailView.vue b/apps/web/src/views/ActivityDetailView.vue index 80953ba..9c7fd94 100644 --- a/apps/web/src/views/ActivityDetailView.vue +++ b/apps/web/src/views/ActivityDetailView.vue @@ -1,5 +1,5 @@