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:
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user