fix(activities): keep comment and postpone open for assignees under creator-only edit

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 <cursoragent@cursor.com>
This commit is contained in:
2026-11-12 09:23:10 +03:30
co-authored by Cursor
parent de77066cb0
commit 68fcece743
4 changed files with 95 additions and 23 deletions
+29 -1
View File
@@ -83,6 +83,26 @@ function canMutateActivity(
return assigneeIdList(row).includes(actor.userId); 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[]) { function reconcileAllShares(assigneeIds: string[], completedByIds: string[]) {
const kept = [...new Set(completedByIds.filter((id) => assigneeIds.includes(id)))]; const kept = [...new Set(completedByIds.filter((id) => assigneeIds.includes(id)))];
const done = assigneeIds.length > 0 && assigneeIds.every((id) => kept.includes(id)); const done = assigneeIds.length > 0 && assigneeIds.every((id) => kept.includes(id));
@@ -836,7 +856,15 @@ export class ActivitiesService {
assignees: { select: { id: true } }, 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('شما مجوز ویرایش این فعالیت را ندارید'); throw new ForbiddenException('شما مجوز ویرایش این فعالیت را ندارید');
} }
const data: Prisma.ActivityUncheckedUpdateInput = {}; const data: Prisma.ActivityUncheckedUpdateInput = {};
@@ -29,7 +29,7 @@ import {
startOfLocalDay, startOfLocalDay,
} from '@/lib/jalali'; } from '@/lib/jalali';
import { useAuthStore } from '@/stores/auth'; 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 ActivityCommentSheet from '@/components/activities/ActivityCommentSheet.vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue'; import JalaliDateInput from '@/components/JalaliDateInput.vue';
@@ -202,6 +202,9 @@ const isClosed = computed(
() => activity.value?.status === 'done' || activity.value?.status === 'canceled', () => activity.value?.status === 'done' || activity.value?.status === 'canceled',
); );
const canMutate = computed(() => canMutateActivity(activity.value, auth.user)); const canMutate = computed(() => canMutateActivity(activity.value, auth.user));
const canCollaborate = computed(() =>
canCollaborateOnActivity(activity.value, auth.user),
);
const needsEveryone = computed( const needsEveryone = computed(
() => activity.value?.completionMode === 'all' && (activity.value.assignees?.length ?? 0) > 1, () => activity.value?.completionMode === 'all' && (activity.value.assignees?.length ?? 0) > 1,
); );
@@ -535,7 +538,7 @@ function openFull() {
type="primary" type="primary"
shape="circle" shape="circle"
aria-label="یادداشت جدید" aria-label="یادداشت جدید"
:disabled="!canMutate" :disabled="!canCollaborate"
@click="commentOpen = true" @click="commentOpen = true"
> >
<template #icon><PlusOutlined /></template> <template #icon><PlusOutlined /></template>
@@ -642,14 +645,14 @@ function openFull() {
<a-flex align="center" :gap="8"> <a-flex align="center" :gap="8">
<a-select <a-select
v-model:value="postponeKey" v-model:value="postponeKey"
:disabled="saving || loading || isClosed || !canMutate" :disabled="saving || loading || isClosed || !canCollaborate"
:options="postponeChoices.map((item) => ({ value: item.key, label: item.label }))" :options="postponeChoices.map((item) => ({ value: item.key, label: item.label }))"
:dropdown-style="{ zIndex: 1200 }" :dropdown-style="{ zIndex: 1200 }"
style="flex: 1" style="flex: 1"
/> />
<a-button <a-button
type="primary" type="primary"
:disabled="saving || loading || isClosed || !canMutate || !postponeReady" :disabled="saving || loading || isClosed || !canCollaborate || !postponeReady"
@click="applyPostpone" @click="applyPostpone"
> >
تعویق تعویق
@@ -660,7 +663,7 @@ function openFull() {
v-model="customDue" v-model="customDue"
mode="datetime" mode="datetime"
placeholder="تاریخ و ساعت" placeholder="تاریخ و ساعت"
:disabled="!canMutate" :disabled="!canCollaborate"
/> />
</a-flex> </a-flex>
+26 -6
View File
@@ -5,12 +5,22 @@ type ActivityAccessUser = {
role?: string | null; role?: string | null;
} | null | undefined; } | null | undefined;
type ActivityAccessRow = Pick<
ActivityDto,
'editPolicy' | 'createdBy' | 'assignee' | 'assignees'
> | null | undefined;
function isAssignee(
activity: NonNullable<ActivityAccessRow>,
userId: string,
): boolean {
if (activity.assignee?.id === userId) return true;
return (activity.assignees ?? []).some((a) => a.id === userId);
}
/** ویرایش/حذف فیلدهای فعالیت — انجام/جابه‌جایی ستون جداست */ /** ویرایش/حذف فیلدهای فعالیت — انجام/جابه‌جایی ستون جداست */
export function canMutateActivity( export function canMutateActivity(
activity: Pick< activity: ActivityAccessRow,
ActivityDto,
'editPolicy' | 'createdBy' | 'assignee' | 'assignees'
> | null | undefined,
user: ActivityAccessUser, user: ActivityAccessUser,
): boolean { ): boolean {
if (!activity || !user?.id) return false; if (!activity || !user?.id) return false;
@@ -18,6 +28,16 @@ export function canMutateActivity(
if (activity.createdBy?.id === user.id) return true; if (activity.createdBy?.id === user.id) return true;
const policy: ActivityEditPolicy = activity.editPolicy ?? 'creator'; const policy: ActivityEditPolicy = activity.editPolicy ?? 'creator';
if (policy !== 'assignees') return false; if (policy !== 'assignees') return false;
if (activity.assignee?.id === user.id) return true; return isAssignee(activity, user.id);
return (activity.assignees ?? []).some((a) => a.id === 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);
} }
+32 -11
View File
@@ -29,7 +29,7 @@ import type {
TeamDto, TeamDto,
} from '@mento/shared'; } from '@mento/shared';
import { useAuthStore } from '@/stores/auth'; 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 { canAssignToOthers } from '@/lib/assign-access';
import { apiError } from '@/lib/routines'; import { apiError } from '@/lib/routines';
import { activityTypeView, typeColorStyle, useActivityTypes } from '@/lib/activity-types'; import { activityTypeView, typeColorStyle, useActivityTypes } from '@/lib/activity-types';
@@ -84,7 +84,20 @@ const saving = ref(false);
const error = ref(''); const error = ref('');
const activity = ref<ActivityDto | null>(null); const activity = ref<ActivityDto | null>(null);
const canMutate = computed(() => canMutateActivity(activity.value, auth.user)); const canMutate = computed(() => canMutateActivity(activity.value, auth.user));
const canCollaborate = computed(() =>
canCollaborateOnActivity(activity.value, auth.user),
);
const editLocked = computed(() => saving.value || !canMutate.value); 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( const canReassign = computed(
() => canMutate.value && canAssignToOthers(auth.user, 'activities'), () => canMutate.value && canAssignToOthers(auth.user, 'activities'),
); );
@@ -436,7 +449,10 @@ function clearNoteEdit() {
function canEditNote(entry: ActivityThreadEntry): boolean { function canEditNote(entry: ActivityThreadEntry): boolean {
return 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) { async function toggleChecklistItem(key: string, itemId: string, done: boolean) {
const a = activity.value; const a = activity.value;
if (!a || fieldsBusy.value) return; if (!a || fieldsBusy.value) return;
if (!canMutate.value) {
warnNoMutatePermission();
return;
}
const list = (a.fields?.[key] as ActivityChecklistItem[] | undefined) ?? []; const list = (a.fields?.[key] as ActivityChecklistItem[] | undefined) ?? [];
const nextFields = { const nextFields = {
...(a.fields ?? {}), ...(a.fields ?? {}),
@@ -839,6 +859,10 @@ async function complete(kind: 'share' | 'force' | 'undo' = 'share') {
async function setCompletionMode(mode: 'any' | 'all') { async function setCompletionMode(mode: 'any' | 'all') {
if (!id.value || activity.value?.completionMode === mode) return; if (!id.value || activity.value?.completionMode === mode) return;
if (!canMutate.value) {
warnNoMutatePermission();
return;
}
saving.value = true; saving.value = true;
error.value = ''; error.value = '';
try { try {
@@ -856,7 +880,7 @@ async function setCompletionMode(mode: 'any' | 'all') {
async function reopen() { async function reopen() {
if (!id.value || !activity.value || activity.value.status === 'open') return; if (!id.value || !activity.value || activity.value.status === 'open') return;
if (!canMutate.value) { if (!canMutate.value) {
error.value = 'شما مجوز ویرایش این فعالیت را ندارید.'; warnNoMutatePermission();
return; return;
} }
saving.value = true; saving.value = true;
@@ -1062,7 +1086,7 @@ onUnmounted(() => {
<article class="card"> <article class="card">
<header class="hero"> <header class="hero">
<div> <div>
<h2> <h2 @click.capture="onLockedEditAttempt">
<input <input
v-model="draftTitle" v-model="draftTitle"
class="title-input" class="title-input"
@@ -1072,9 +1096,6 @@ onUnmounted(() => {
:disabled="editLocked" :disabled="editLocked"
/> />
</h2> </h2>
<p v-if="!canMutate" class="error" role="status">
شما مجوز ویرایش یا حذف این فعالیت را ندارید.
</p>
<p v-if="subtitle" class="sub"> <p v-if="subtitle" class="sub">
<UiIcon name="building" :size="14" /> <UiIcon name="building" :size="14" />
{{ subtitle }} {{ subtitle }}
@@ -1106,7 +1127,7 @@ onUnmounted(() => {
/> />
</div> </div>
<dl class="meta"> <dl class="meta" @click.capture="onLockedEditAttempt">
<div> <div>
<dt> <dt>
<UiIcon :name="draftTypeView!.icon" :size="15" /> <UiIcon :name="draftTypeView!.icon" :size="15" />
@@ -1646,7 +1667,7 @@ onUnmounted(() => {
v-if="isDone || activity.status === 'canceled'" v-if="isDone || activity.status === 'canceled'"
class="act primary" class="act primary"
type="button" type="button"
:disabled="editLocked" :disabled="saving"
@click="reopen" @click="reopen"
> >
<UiIcon name="clock" :size="16" /> <UiIcon name="clock" :size="16" />
@@ -1675,7 +1696,7 @@ onUnmounted(() => {
<button <button
class="act ghost" class="act ghost"
type="button" type="button"
:disabled="editLocked || isDone" :disabled="collabLocked || isDone"
@click="postponeOpen = true" @click="postponeOpen = true"
> >
<UiIcon name="refresh" :size="16" /> <UiIcon name="refresh" :size="16" />
@@ -1684,7 +1705,7 @@ onUnmounted(() => {
<button <button
class="act ghost" class="act ghost"
type="button" type="button"
:disabled="editLocked" :disabled="collabLocked"
@click="commentOpen = true" @click="commentOpen = true"
> >
<UiIcon name="chat" :size="16" /> <UiIcon name="chat" :size="16" />