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:
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
Reference in New Issue
Block a user