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