From 1a05eee46d98179010ed90eacf79162570e98345 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Wed, 11 Nov 2026 15:56:36 +0330 Subject: [PATCH] feat(activities): let creators lock edit and delete to themselves Add editPolicy (default creator-only), enforce it in the API, surface clear permission messages, and drop the redundant edit menu now that detail is editable. Co-authored-by: Cursor --- .../migration.sql | 5 + apps/api/prisma/schema.prisma | 8 ++ apps/api/src/activities/activities.service.ts | 61 ++++++++++ apps/api/src/activities/dto.ts | 8 ++ .../activities/ActivityQuickView.vue | 29 ++++- apps/web/src/lib/activity-access.ts | 23 ++++ apps/web/src/views/ActivitiesView.vue | 104 +++++++++++------- apps/web/src/views/ActivityDetailView.vue | 58 +++++++--- apps/web/src/views/ActivityFormView.vue | 27 +++++ packages/shared/src/index.ts | 13 +++ 10 files changed, 279 insertions(+), 57 deletions(-) create mode 100644 apps/api/prisma/migrations/20261111160000_activity_edit_policy/migration.sql create mode 100644 apps/web/src/lib/activity-access.ts diff --git a/apps/api/prisma/migrations/20261111160000_activity_edit_policy/migration.sql b/apps/api/prisma/migrations/20261111160000_activity_edit_policy/migration.sql new file mode 100644 index 0000000..a975083 --- /dev/null +++ b/apps/api/prisma/migrations/20261111160000_activity_edit_policy/migration.sql @@ -0,0 +1,5 @@ +-- CreateEnum +CREATE TYPE "ActivityEditPolicy" AS ENUM ('creator', 'assignees'); + +-- AlterTable +ALTER TABLE "Activity" ADD COLUMN "editPolicy" "ActivityEditPolicy" NOT NULL DEFAULT 'creator'; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 05ebe03..420f54e 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -38,6 +38,12 @@ enum ActivityCompletionMode { all } +/// چه کسانی می‌توانند فعالیت را ویرایش/حذف کنند (علاوه بر owner/admin) +enum ActivityEditPolicy { + creator + assignees +} + enum ActivityPriority { low medium @@ -892,6 +898,8 @@ model Activity { completionMode ActivityCompletionMode @default(any) /// مسئولانی که سهم خود را در حالت all ثبت کرده‌اند completedByIds String[] @default([]) + /// creator = فقط ایجادکننده؛ assignees = ایجادکننده + همهٔ مسئولان + editPolicy ActivityEditPolicy @default(creator) tags String[] @default([]) personId String? person Person? @relation(fields: [personId], references: [id], onDelete: SetNull) diff --git a/apps/api/src/activities/activities.service.ts b/apps/api/src/activities/activities.service.ts index 4c235cc..0be8694 100644 --- a/apps/api/src/activities/activities.service.ts +++ b/apps/api/src/activities/activities.service.ts @@ -68,6 +68,21 @@ function canForceClose( return actor.role === 'owner' || actor.role === 'admin' || actor.userId === createdById; } +function canMutateActivity( + actor: { userId: string; role: string }, + row: { + createdById?: string | null; + editPolicy?: 'creator' | 'assignees' | null; + assigneeId?: string | null; + assignees?: Array<{ id: string }>; + }, +): boolean { + if (actor.role === 'owner' || actor.role === 'admin') return true; + if (row.createdById && actor.userId === row.createdById) return true; + if ((row.editPolicy ?? 'creator') !== 'assignees') return false; + return assigneeIdList(row).includes(actor.userId); +} + function reconcileAllShares(assigneeIds: string[], completedByIds: string[]) { const kept = [...new Set(completedByIds.filter((id) => assigneeIds.includes(id)))]; const done = assigneeIds.length > 0 && assigneeIds.every((id) => kept.includes(id)); @@ -737,6 +752,7 @@ export class ActivitiesService { : undefined, completionMode: dto.completionMode === 'all' ? 'all' : 'any', completedByIds: [], + editPolicy: dto.editPolicy === 'assignees' ? 'assignees' : 'creator', createdById: userId, }, include, @@ -813,10 +829,15 @@ export class ActivitiesService { participantIds: true, completionMode: true, completedByIds: true, + editPolicy: true, + createdById: true, assigneeId: true, assignees: { select: { id: true } }, }, }); + if (!existing || !canMutateActivity(actor, existing)) { + throw new ForbiddenException('شما مجوز ویرایش این فعالیت را ندارید'); + } const data: Prisma.ActivityUncheckedUpdateInput = {}; if (dto.title !== undefined) data.title = dto.title.trim(); if ('boardId' in dto) { @@ -932,6 +953,9 @@ export class ActivitiesService { const modeChanged = dto.completionMode === 'any' || dto.completionMode === 'all'; const nextMode = modeChanged ? dto.completionMode! : (existing?.completionMode ?? 'any'); if (modeChanged) data.completionMode = nextMode; + if (dto.editPolicy === 'creator' || dto.editPolicy === 'assignees') { + data.editPolicy = dto.editPolicy; + } const prevAssigneeIds = assigneeIdList(existing ?? {}); const nextIds = nextAssigneeIds ?? prevAssigneeIds; const explicitDone = dto.status === 'done'; @@ -1105,6 +1129,18 @@ export class ActivitiesService { actor: ActivityActor, ): Promise { await this.ensureExists(organizationId, id, actor); + const existing = await this.prisma.activity.findFirst({ + where: { id, organizationId }, + select: { + createdById: true, + editPolicy: true, + assigneeId: true, + assignees: { select: { id: true } }, + }, + }); + if (!existing || !canMutateActivity(actor, existing)) { + throw new ForbiddenException('شما مجوز حذف این فعالیت را ندارید'); + } const images = await this.prisma.activityImage.findMany({ where: { activityId: id, organizationId }, select: { storageKey: true, sizeBytes: true }, @@ -1125,6 +1161,18 @@ export class ActivitiesService { actor: ActivityActor, ): Promise { await this.ensureExists(organizationId, activityId, actor); + const gate = await this.prisma.activity.findFirst({ + where: { id: activityId, organizationId }, + select: { + createdById: true, + editPolicy: true, + assigneeId: true, + assignees: { select: { id: true } }, + }, + }); + if (!gate || !canMutateActivity(actor, gate)) { + throw new ForbiddenException('شما مجوز ویرایش این فعالیت را ندارید'); + } if (!file?.buffer?.length) { throw new BadRequestException('فایلی ارسال نشده است'); } @@ -1219,6 +1267,18 @@ export class ActivitiesService { actor: ActivityActor, ): Promise { await this.ensureExists(organizationId, activityId, actor); + const gate = await this.prisma.activity.findFirst({ + where: { id: activityId, organizationId }, + select: { + createdById: true, + editPolicy: true, + assigneeId: true, + assignees: { select: { id: true } }, + }, + }); + if (!gate || !canMutateActivity(actor, gate)) { + throw new ForbiddenException('شما مجوز ویرایش این فعالیت را ندارید'); + } const row = await this.prisma.activityImage.findFirst({ where: { id: imageId, activityId, organizationId }, }); @@ -1570,6 +1630,7 @@ export class ActivitiesService { completedAt: row.completedAt?.toISOString() ?? null, completionMode: row.completionMode ?? 'any', completedByIds: row.completedByIds ?? [], + editPolicy: row.editPolicy ?? 'creator', importSource: row.importSource ?? 'manual', promotedFromPriorAt: row.promotedFromPriorAt?.toISOString() ?? null, tags: row.tags ?? [], diff --git a/apps/api/src/activities/dto.ts b/apps/api/src/activities/dto.ts index 0d80d7b..43027e0 100644 --- a/apps/api/src/activities/dto.ts +++ b/apps/api/src/activities/dto.ts @@ -388,6 +388,10 @@ export class CreateActivityDto { @IsOptional() @IsIn(['any', 'all']) completionMode?: 'any' | 'all'; + + @IsOptional() + @IsIn(['creator', 'assignees']) + editPolicy?: 'creator' | 'assignees'; } export class UpdateActivityDto { @@ -492,4 +496,8 @@ export class UpdateActivityDto { @IsOptional() @IsIn(['any', 'all']) completionMode?: 'any' | 'all'; + + @IsOptional() + @IsIn(['creator', 'assignees']) + editPolicy?: 'creator' | 'assignees'; } diff --git a/apps/web/src/components/activities/ActivityQuickView.vue b/apps/web/src/components/activities/ActivityQuickView.vue index 1840011..242fdb3 100644 --- a/apps/web/src/components/activities/ActivityQuickView.vue +++ b/apps/web/src/components/activities/ActivityQuickView.vue @@ -29,6 +29,7 @@ import { startOfLocalDay, } from '@/lib/jalali'; import { useAuthStore } from '@/stores/auth'; +import { canMutateActivity } from '@/lib/activity-access'; import ActivityCommentSheet from '@/components/activities/ActivityCommentSheet.vue'; import JalaliDateInput from '@/components/JalaliDateInput.vue'; @@ -200,6 +201,7 @@ function clearNoteMedia() { const isClosed = computed( () => activity.value?.status === 'done' || activity.value?.status === 'canceled', ); +const canMutate = computed(() => canMutateActivity(activity.value, auth.user)); const needsEveryone = computed( () => activity.value?.completionMode === 'all' && (activity.value.assignees?.length ?? 0) > 1, ); @@ -407,6 +409,10 @@ async function runAction() { error.value = ''; try { if (isClosed.value) { + if (!canMutate.value) { + error.value = 'شما مجوز ویرایش این فعالیت را ندارید.'; + return; + } await api.patch(`/activities/${id}`, { status: 'open' }); await load(id); emit('changed'); @@ -525,7 +531,13 @@ function openFull() { یادداشت - + @@ -630,12 +642,16 @@ function openFull() { - + تعویق @@ -644,12 +660,17 @@ function openFull() { v-model="customDue" mode="datetime" placeholder="تاریخ و ساعت" + :disabled="!canMutate" /> - + {{ actionLabel }} مشاهدهٔ کامل diff --git a/apps/web/src/lib/activity-access.ts b/apps/web/src/lib/activity-access.ts new file mode 100644 index 0000000..505ed7a --- /dev/null +++ b/apps/web/src/lib/activity-access.ts @@ -0,0 +1,23 @@ +import type { ActivityDto, ActivityEditPolicy } from '@mento/shared'; + +type ActivityAccessUser = { + id?: string | null; + role?: string | null; +} | null | undefined; + +/** ویرایش/حذف فیلدهای فعالیت — انجام/جابه‌جایی ستون جداست */ +export function canMutateActivity( + activity: Pick< + ActivityDto, + 'editPolicy' | 'createdBy' | 'assignee' | 'assignees' + > | null | undefined, + user: ActivityAccessUser, +): boolean { + if (!activity || !user?.id) return false; + if (user.role === 'owner' || user.role === 'admin') return true; + if (activity.createdBy?.id === user.id) return true; + const policy: ActivityEditPolicy = activity.editPolicy ?? 'creator'; + if (policy !== 'assignees') return false; + if (activity.assignee?.id === user.id) return true; + return (activity.assignees ?? []).some((a) => a.id === user.id); +} diff --git a/apps/web/src/views/ActivitiesView.vue b/apps/web/src/views/ActivitiesView.vue index a1ff255..1fbe86f 100644 --- a/apps/web/src/views/ActivitiesView.vue +++ b/apps/web/src/views/ActivitiesView.vue @@ -52,7 +52,10 @@ import type { } from '@mento/shared'; import { brand } from '@mento/shared'; import { useAuthStore } from '@/stores/auth'; +import { canMutateActivity } from '@/lib/activity-access'; import { canManageSettings, canViewAllRecords } from '@/lib/roles'; +import { apiError } from '@/lib/routines'; +import { message } from 'ant-design-vue'; import { PAGE_SIZE_OPTIONS, usePageSize } from '@/lib/pageSize'; import { registerTableColumns, useTableColumns } from '@/lib/tableColumns'; import TableColumnsMenu from '@/components/TableColumnsMenu.vue'; @@ -446,23 +449,11 @@ function deskDetailQuery(columnStageId?: string): Record { return query; } -function activityRoute(id: string, edit = false, columnStageId?: string) { - if (!edit) { - return { - name: 'activity-detail' as const, - params: { id }, - query: deskDetailQuery(columnStageId), - }; - } - const query: Record = {}; - for (const key of ['board', 'view', 'desk', 'filter', 'stage', 'q', 'typeDefId', 'status', 'assigneeId', 'title', 'customer', 'due'] as const) { - const value = route.query[key]; - if (typeof value === 'string' && value) query[key] = value; - } +function activityRoute(id: string, columnStageId?: string) { return { - name: 'activity-edit' as const, + name: 'activity-detail' as const, params: { id }, - query, + query: deskDetailQuery(columnStageId), }; } @@ -938,10 +929,27 @@ function clearSelect() { selectedIds.value = new Set(); } +function canDeleteActivity(item: ActivityDto | null | undefined) { + return canMutateActivity(item, auth.user); +} + +function activityById(id: string) { + return ( + items.value.find((row) => row.id === id) || + (boardMenuActivity.value?.id === id ? boardMenuActivity.value : null) + ); +} + function askDeleteActivity(id: string, title?: string) { menuId.value = null; boardMenuId.value = null; + const target = activityById(id); boardMenuActivity.value = null; + if (target && !canDeleteActivity(target)) { + message.warning('شما مجوز حذف این فعالیت را ندارید.'); + error.value = 'شما مجوز حذف این فعالیت را ندارید.'; + return; + } deleteSingleTitle.value = title?.trim() || activityTitle(id); deleteActivityIds.value = [id]; } @@ -951,8 +959,24 @@ function askBulkDelete() { menuId.value = null; boardMenuId.value = null; boardMenuActivity.value = null; + const selected = [...selectedIds.value]; + const allowed = selected.filter((id) => { + const row = activityById(id); + return !row || canDeleteActivity(row); + }); + const blocked = selected.length - allowed.length; + if (!allowed.length) { + message.warning('شما مجوز حذف این فعالیت‌ها را ندارید.'); + error.value = 'شما مجوز حذف این فعالیت‌ها را ندارید.'; + return; + } + if (blocked > 0) { + message.warning( + `${toFaDigits(blocked)} مورد به‌خاطر نبود مجوز حذف کنار گذاشته شد.`, + ); + } deleteSingleTitle.value = ''; - deleteActivityIds.value = [...selectedIds.value]; + deleteActivityIds.value = allowed; } async function confirmDeleteActivities() { @@ -962,16 +986,24 @@ async function confirmDeleteActivities() { error.value = ''; try { const results = await Promise.allSettled(ids.map((id) => api.delete(`/activities/${id}`))); - const failed = results.filter((r) => r.status === 'rejected').length; + const failed = results.filter((r) => r.status === 'rejected'); deleteActivityIds.value = []; clearSelect(); kanbanReload.value += 1; await refreshAll(); - if (failed) { + if (failed.length) { + const first = failed[0]; + const detail = + first && first.status === 'rejected' + ? apiError(first.reason, '') + : ''; error.value = - failed === ids.length - ? 'حذف فعالیت‌ها ناموفق بود.' - : `${toFaDigits(failed)} مورد حذف نشد.`; + failed.length === ids.length + ? detail || 'شما مجوز حذف این فعالیت را ندارید.' + : detail + ? `${toFaDigits(failed.length)} مورد حذف نشد: ${detail}` + : `${toFaDigits(failed.length)} مورد حذف نشد.`; + message.error(error.value); } } finally { activityDeleting.value = false; @@ -1776,16 +1808,18 @@ onUnmounted(() => { مشاهده - - ویرایش - - @@ -1866,16 +1900,18 @@ onUnmounted(() => { مشاهده - - ویرایش - - @@ -1922,18 +1958,11 @@ onUnmounted(() => { مشاهده - - ویرایش - - - diff --git a/apps/web/src/views/ActivityFormView.vue b/apps/web/src/views/ActivityFormView.vue index 8add5e6..0fc3642 100644 --- a/apps/web/src/views/ActivityFormView.vue +++ b/apps/web/src/views/ActivityFormView.vue @@ -130,6 +130,7 @@ const leadId = ref(''); const dealId = ref(''); const assigneeIds = ref([]); const completionMode = ref<'any' | 'all'>('any'); +const editPolicy = ref<'creator' | 'assignees'>('creator'); const isDone = ref(false); const needsNote = ref(false); const reminderAt = ref(''); @@ -442,6 +443,7 @@ function resetForm() { dealId.value = ''; assigneeIds.value = auth.user?.id ? [auth.user.id] : []; completionMode.value = 'any'; + editPolicy.value = 'creator'; pickedRoleIds.value = []; pickedTeamIds.value = []; isDone.value = false; @@ -531,6 +533,7 @@ async function load() { ? [auth.user.id] : []; completionMode.value = data.completionMode === 'all' ? 'all' : 'any'; + editPolicy.value = data.editPolicy === 'assignees' ? 'assignees' : 'creator'; refreshPickedGroups(); isDone.value = data.status === 'done'; await loadSavedPhotos(data); @@ -619,6 +622,7 @@ function payload(): CreateActivityRequest { assigneeIds: [...assigneeIds.value], assigneeId: assigneeIds.value[0] || null, completionMode: assigneeIds.value.length > 1 ? completionMode.value : 'any', + editPolicy: editPolicy.value, }; } @@ -1332,6 +1336,29 @@ watch([id, isNew], () => void load(), { immediate: true }); +
+
+ +
+ + +
+ + {{ + editPolicy === 'assignees' + ? 'مسئولان هم می‌توانند این فعالیت را ویرایش یا حذف کنند.' + : 'فقط ایجادکننده می‌تواند این فعالیت را ویرایش یا حذف کند.' + }} + +
+
+
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 1cac0b7..4f503b8 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -2124,6 +2124,14 @@ export interface ActivityImageDto { export type ActivityCompletionMode = "any" | "all"; +/** چه کسانی می‌توانند فعالیت را ویرایش یا حذف کنند (علاوه بر owner/admin) */ +export type ActivityEditPolicy = "creator" | "assignees"; + +export const ACTIVITY_EDIT_POLICY_LABELS: Record = { + creator: "فقط ایجادکننده", + assignees: "همهٔ مسئولان", +}; + export interface ActivityDto { id: string; type: ActivityType; @@ -2145,6 +2153,8 @@ export interface ActivityDto { completionMode?: ActivityCompletionMode; /** شناسهٔ مسئولانی که سهم خود را در حالت all ثبت کرده‌اند */ completedByIds?: string[]; + /** پیش‌فرض creator؛ assignees یعنی همهٔ مسئولان هم می‌توانند ویرایش/حذف کنند */ + editPolicy?: ActivityEditPolicy; /** manual یا didar؛ برای جدا کردن سابقهٔ قبلی */ importSource?: string; promotedFromPriorAt?: string | null; @@ -2204,6 +2214,8 @@ export interface CreateActivityRequest { assigneeIds?: string[]; /** پیش‌فرض any. فقط وقتی چند مسئول دارد معنی دارد */ completionMode?: ActivityCompletionMode; + /** پیش‌فرض creator */ + editPolicy?: ActivityEditPolicy; } export interface UpdateActivityRequest { @@ -2229,6 +2241,7 @@ export interface UpdateActivityRequest { assigneeId?: string | null; assigneeIds?: string[]; completionMode?: ActivityCompletionMode; + editPolicy?: ActivityEditPolicy; } export interface ActivitySummaryDto {