From 68fcece74316ea9c5da11bfe2e45c79adb81afc7 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Thu, 12 Nov 2026 09:23:10 +0330 Subject: [PATCH] fix(activities): keep comment and postpone open for assignees under creator-only edit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allow dueAt/description patches for collaborators when editPolicy is creator-only, and show the no-edit warning only when they try to change locked fields—not as a permanent banner. Co-authored-by: Cursor --- apps/api/src/activities/activities.service.ts | 30 ++++++++++++- .../activities/ActivityQuickView.vue | 13 +++--- apps/web/src/lib/activity-access.ts | 32 +++++++++++--- apps/web/src/views/ActivityDetailView.vue | 43 ++++++++++++++----- 4 files changed, 95 insertions(+), 23 deletions(-) diff --git a/apps/api/src/activities/activities.service.ts b/apps/api/src/activities/activities.service.ts index 8b8d406..9cd2382 100644 --- a/apps/api/src/activities/activities.service.ts +++ b/apps/api/src/activities/activities.service.ts @@ -83,6 +83,26 @@ function canMutateActivity( return assigneeIdList(row).includes(actor.userId); } +/** ثبت نظر / تعویق — برای همهٔ مسئولان، حتی وقتی ویرایش فقط با ایجادکننده است */ +function canCollaborateActivity( + actor: { userId: string; role: string }, + row: { + createdById?: string | 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; + return assigneeIdList(row).includes(actor.userId); +} + +/** فقط dueAt / description → تعویق و ثبت نظر */ +function isCollaboratorPatch(dto: UpdateActivityRequest): boolean { + const keys = Object.keys(dto); + return keys.length > 0 && keys.every((key) => key === 'dueAt' || key === 'description'); +} + 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)); @@ -836,7 +856,15 @@ export class ActivitiesService { assignees: { select: { id: true } }, }, }); - if (!existing || !canMutateActivity(actor, existing)) { + if (!existing) { + throw new ForbiddenException('شما مجوز ویرایش این فعالیت را ندارید'); + } + const collabOnly = isCollaboratorPatch(dto); + if (collabOnly) { + if (!canCollaborateActivity(actor, existing)) { + throw new ForbiddenException('شما مجوز ثبت نظر یا تعویق این فعالیت را ندارید'); + } + } else if (!canMutateActivity(actor, existing)) { throw new ForbiddenException('شما مجوز ویرایش این فعالیت را ندارید'); } const data: Prisma.ActivityUncheckedUpdateInput = {}; diff --git a/apps/web/src/components/activities/ActivityQuickView.vue b/apps/web/src/components/activities/ActivityQuickView.vue index 242fdb3..56073a1 100644 --- a/apps/web/src/components/activities/ActivityQuickView.vue +++ b/apps/web/src/components/activities/ActivityQuickView.vue @@ -29,7 +29,7 @@ import { startOfLocalDay, } from '@/lib/jalali'; import { useAuthStore } from '@/stores/auth'; -import { canMutateActivity } from '@/lib/activity-access'; +import { canCollaborateOnActivity, canMutateActivity } from '@/lib/activity-access'; import ActivityCommentSheet from '@/components/activities/ActivityCommentSheet.vue'; import JalaliDateInput from '@/components/JalaliDateInput.vue'; @@ -202,6 +202,9 @@ const isClosed = computed( () => activity.value?.status === 'done' || activity.value?.status === 'canceled', ); const canMutate = computed(() => canMutateActivity(activity.value, auth.user)); +const canCollaborate = computed(() => + canCollaborateOnActivity(activity.value, auth.user), +); const needsEveryone = computed( () => activity.value?.completionMode === 'all' && (activity.value.assignees?.length ?? 0) > 1, ); @@ -535,7 +538,7 @@ function openFull() { type="primary" shape="circle" aria-label="یادداشت جدید" - :disabled="!canMutate" + :disabled="!canCollaborate" @click="commentOpen = true" > @@ -642,14 +645,14 @@ function openFull() { تعویق @@ -660,7 +663,7 @@ function openFull() { v-model="customDue" mode="datetime" placeholder="تاریخ و ساعت" - :disabled="!canMutate" + :disabled="!canCollaborate" /> diff --git a/apps/web/src/lib/activity-access.ts b/apps/web/src/lib/activity-access.ts index 505ed7a..9c9714a 100644 --- a/apps/web/src/lib/activity-access.ts +++ b/apps/web/src/lib/activity-access.ts @@ -5,12 +5,22 @@ type ActivityAccessUser = { role?: string | null; } | null | undefined; +type ActivityAccessRow = Pick< + ActivityDto, + 'editPolicy' | 'createdBy' | 'assignee' | 'assignees' +> | null | undefined; + +function isAssignee( + activity: NonNullable, + userId: string, +): boolean { + if (activity.assignee?.id === userId) return true; + return (activity.assignees ?? []).some((a) => a.id === userId); +} + /** ویرایش/حذف فیلدهای فعالیت — انجام/جابه‌جایی ستون جداست */ export function canMutateActivity( - activity: Pick< - ActivityDto, - 'editPolicy' | 'createdBy' | 'assignee' | 'assignees' - > | null | undefined, + activity: ActivityAccessRow, user: ActivityAccessUser, ): boolean { if (!activity || !user?.id) return false; @@ -18,6 +28,16 @@ export function canMutateActivity( 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); + return isAssignee(activity, user.id); +} + +/** ثبت نظر و تعویق برای همهٔ مسئولان (حتی وقتی ویرایش فقط با ایجادکننده است) */ +export function canCollaborateOnActivity( + activity: ActivityAccessRow, + 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; + return isAssignee(activity, user.id); } diff --git a/apps/web/src/views/ActivityDetailView.vue b/apps/web/src/views/ActivityDetailView.vue index 28dddec..95ca303 100644 --- a/apps/web/src/views/ActivityDetailView.vue +++ b/apps/web/src/views/ActivityDetailView.vue @@ -29,7 +29,7 @@ import type { TeamDto, } from '@mento/shared'; import { useAuthStore } from '@/stores/auth'; -import { canMutateActivity } from '@/lib/activity-access'; +import { canCollaborateOnActivity, canMutateActivity } from '@/lib/activity-access'; import { canAssignToOthers } from '@/lib/assign-access'; import { apiError } from '@/lib/routines'; import { activityTypeView, typeColorStyle, useActivityTypes } from '@/lib/activity-types'; @@ -84,7 +84,20 @@ const saving = ref(false); const error = ref(''); const activity = ref(null); const canMutate = computed(() => canMutateActivity(activity.value, auth.user)); +const canCollaborate = computed(() => + canCollaborateOnActivity(activity.value, auth.user), +); const editLocked = computed(() => saving.value || !canMutate.value); +const collabLocked = computed(() => saving.value || !canCollaborate.value); + +function warnNoMutatePermission() { + error.value = 'شما مجوز ویرایش یا حذف این فعالیت را ندارید.'; +} + +/** کنترل‌های disabled کلیک نمی‌گیرند؛ روی والد اخطار می‌دهیم */ +function onLockedEditAttempt() { + if (!canMutate.value && !saving.value) warnNoMutatePermission(); +} const canReassign = computed( () => canMutate.value && canAssignToOthers(auth.user, 'activities'), ); @@ -436,7 +449,10 @@ function clearNoteEdit() { function canEditNote(entry: ActivityThreadEntry): boolean { return Boolean( - canMutate.value && entry.dateLabel && meName.value && entry.author === meName.value, + canCollaborate.value && + entry.dateLabel && + meName.value && + entry.author === meName.value, ); } @@ -632,6 +648,10 @@ function onBeforeUnload(event: BeforeUnloadEvent) { async function toggleChecklistItem(key: string, itemId: string, done: boolean) { const a = activity.value; if (!a || fieldsBusy.value) return; + if (!canMutate.value) { + warnNoMutatePermission(); + return; + } const list = (a.fields?.[key] as ActivityChecklistItem[] | undefined) ?? []; const nextFields = { ...(a.fields ?? {}), @@ -839,6 +859,10 @@ async function complete(kind: 'share' | 'force' | 'undo' = 'share') { async function setCompletionMode(mode: 'any' | 'all') { if (!id.value || activity.value?.completionMode === mode) return; + if (!canMutate.value) { + warnNoMutatePermission(); + return; + } saving.value = true; error.value = ''; try { @@ -856,7 +880,7 @@ async function setCompletionMode(mode: 'any' | 'all') { async function reopen() { if (!id.value || !activity.value || activity.value.status === 'open') return; if (!canMutate.value) { - error.value = 'شما مجوز ویرایش این فعالیت را ندارید.'; + warnNoMutatePermission(); return; } saving.value = true; @@ -1062,7 +1086,7 @@ onUnmounted(() => {
-

+

{ :disabled="editLocked" />

-

- شما مجوز ویرایش یا حذف این فعالیت را ندارید. -

{{ subtitle }} @@ -1106,7 +1127,7 @@ onUnmounted(() => { />

-
+
@@ -1646,7 +1667,7 @@ onUnmounted(() => { v-if="isDone || activity.status === 'canceled'" class="act primary" type="button" - :disabled="editLocked" + :disabled="saving" @click="reopen" > @@ -1675,7 +1696,7 @@ onUnmounted(() => {