feat(routines): let each person create a routine for themselves

Open the new-routine action to every member, while keeping assignment to other people and teams limited to managers.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-11-04 19:34:36 +03:30
co-authored by Cursor
parent 5b18749005
commit 78a7d6a68a
6 changed files with 97 additions and 23 deletions
+1
View File
@@ -185,6 +185,7 @@ export function toRoutineDto(
entityLink: normalizeEntityLink(row.entityLink),
reminderMinutesBefore: row.reminderMinutesBefore,
isActive: row.isActive,
createdById: row.createdById,
recurrenceLabel: describeRecurrence(rule),
executionRate: execution.total ? Math.round((execution.done / execution.total) * 100) : null,
executionTotal: execution.total,
+28 -13
View File
@@ -200,8 +200,7 @@ export class RoutinesService {
async create(actor: Actor, dto: SaveRoutineDto): Promise<RoutineDto> {
const scope = await this.scope(actor);
assertManager(scope);
const data = await this.buildData(actor, scope, dto);
const data = await this.buildData(actor, scope, this.ownRoutineInput(actor, scope, dto));
const row = await this.prisma.routine.create({
data: {
...data,
@@ -216,10 +215,9 @@ export class RoutinesService {
async update(actor: Actor, id: string, dto: SaveRoutineDto): Promise<RoutineDto> {
const scope = await this.scope(actor);
assertManager(scope);
const existing = await this.findRoutine(actor.organizationId, id);
this.assertCanManageRoutine(scope, existing);
const data = await this.buildData(actor, scope, dto);
this.assertCanWriteRoutine(scope, existing);
const data = await this.buildData(actor, scope, this.ownRoutineInput(actor, scope, dto));
const row = await this.prisma.routine.update({
where: { id },
data: data as Prisma.RoutineUncheckedUpdateInput,
@@ -232,9 +230,8 @@ export class RoutinesService {
async toggle(actor: Actor, id: string, isActive: boolean): Promise<RoutineDto> {
const scope = await this.scope(actor);
assertManager(scope);
const existing = await this.findRoutine(actor.organizationId, id);
this.assertCanManageRoutine(scope, existing);
this.assertCanWriteRoutine(scope, existing);
const row = await this.prisma.routine.update({
where: { id },
data: { isActive },
@@ -251,9 +248,8 @@ export class RoutinesService {
dto: ChangeRoutineAssigneeDto,
): Promise<RoutineDto> {
const scope = await this.scope(actor);
assertManager(scope);
const existing = await this.findRoutine(actor.organizationId, id);
this.assertCanManageRoutine(scope, existing);
this.assertCanWriteRoutine(scope, existing);
const target = await this.resolveTarget(actor, scope, dto.assigneeId, dto.teamId);
await this.cancelTodayPending(id, 'تغییر مسئول روتین');
const row = await this.prisma.routine.update({
@@ -268,9 +264,8 @@ export class RoutinesService {
async duplicate(actor: Actor, id: string): Promise<RoutineDto> {
const scope = await this.scope(actor);
assertManager(scope);
const src = await this.findRoutine(actor.organizationId, id);
this.assertCanManageRoutine(scope, src);
this.assertCanWriteRoutine(scope, src);
const {
id: _id,
createdAt: _c,
@@ -295,9 +290,8 @@ export class RoutinesService {
async remove(actor: Actor, id: string) {
const scope = await this.scope(actor);
assertManager(scope);
const existing = await this.findRoutine(actor.organizationId, id);
this.assertCanManageRoutine(scope, existing);
this.assertCanWriteRoutine(scope, existing);
await this.prisma.routine.update({
where: { id },
data: { deletedAt: new Date(), isActive: false },
@@ -345,6 +339,27 @@ export class RoutinesService {
return row;
}
/** عضو عادی فقط روتینی را می‌نویسد که خودش برای خودش ساخته است. */
private ownRoutineInput(actor: Actor, scope: RoutineScope, dto: SaveRoutineDto): SaveRoutineDto {
if (scope.isManager) return dto;
if (dto.teamId || (dto.assigneeId && dto.assigneeId !== actor.userId)) {
throw new ForbiddenException('فقط می‌توانید برای خودتان روتین بسازید');
}
return { ...dto, assigneeId: actor.userId, teamId: null };
}
private assertCanWriteRoutine(
scope: RoutineScope,
row: { assigneeId: string | null; teamId: string | null; createdById: string | null },
) {
if (scope.isManager) {
this.assertCanManageRoutine(scope, row);
return;
}
if (row.createdById === scope.userId && row.assigneeId === scope.userId && !row.teamId) return;
throw new ForbiddenException('فقط روتینی که برای خودتان ساخته‌اید را می‌توانید تغییر دهید');
}
private assertCanManageRoutine(
scope: RoutineScope,
row: { assigneeId: string | null; teamId: string | null; createdById: string | null },
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, inject, onMounted, reactive, ref } from 'vue';
import { computed, inject, onMounted, reactive, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import type { ActivityPriority, RoutineDto, RoutineEntityLink, SaveRoutineRequest } from '@mento/shared';
import { ROUTINE_ENTITY_LINK_LABELS, ROUTINE_WEEKDAY_LABELS } from '@mento/shared';
@@ -18,10 +18,15 @@ import {
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import { PRIORITY_LABELS, ROUTINES_CTX, apiError, formatDayNumeric, todayKey } from '@/lib/routines';
import { useAuthStore } from '@/stores/auth';
const shell = inject(ROUTINES_CTX)!;
const auth = useAuthStore();
const route = useRoute();
const router = useRouter();
const isManager = computed(() => !!shell.context.value?.isManager);
const selfId = computed(() => auth.user?.id ?? '');
const backTo = computed(() => (isManager.value ? '/routines/manage' : '/routines/list'));
const id = computed(() => (typeof route.params.id === 'string' ? route.params.id : null));
const isEdit = computed(() => !!id.value);
@@ -60,6 +65,8 @@ const recurrence = ref<RecurrenceValue>({
const loading = ref(false);
const saving = ref(false);
const error = ref('');
const loaded = ref<RoutineDto | null>(null);
const foreign = ref(false);
const targetOptions = [
{ value: 'user' as const, label: 'یک کاربر' },
@@ -111,10 +118,14 @@ function fill(r: RoutineDto) {
}
onMounted(async () => {
if (!id.value) return;
if (!id.value) {
if (selfId.value) form.assigneeId = selfId.value;
return;
}
loading.value = true;
try {
const { data } = await api.get<RoutineDto>(`/routines/${id.value}`);
loaded.value = data;
fill(data);
} catch (e) {
error.value = apiError(e, 'روتین پیدا نشد.');
@@ -123,6 +134,23 @@ onMounted(async () => {
}
});
watch(
[() => shell.context.value, loaded, selfId],
() => {
const ctx = shell.context.value;
const data = loaded.value;
if (!ctx || !data || ctx.isManager) {
foreign.value = false;
return;
}
foreign.value = data.createdById !== selfId.value;
if (foreign.value) {
error.value = 'این روتین را مدیر تعریف کرده و فقط خودش می‌تواند تغییرش دهد.';
}
},
{ immediate: true },
);
const users = computed(() => shell.context.value?.users ?? []);
const teams = computed(() => shell.context.value?.teams ?? []);
const categories = computed(() => shell.context.value?.categories ?? []);
@@ -169,6 +197,11 @@ const summary = computed(() => {
});
function validate(): string | null {
if (!isManager.value) {
form.target = 'user';
form.assigneeId = selfId.value;
form.teamId = '';
}
if (form.title.trim().length < 2) return 'عنوان روتین را وارد کنید.';
if (form.target === 'user' && !form.assigneeId) return 'مسئول روتین را انتخاب کنید.';
if (form.target === 'team' && !form.teamId) return 'تیم را انتخاب کنید.';
@@ -220,7 +253,7 @@ async function save() {
if (isEdit.value) await api.patch(`/routines/${id.value}`, body);
else await api.post('/routines', body);
shell.notifyChanged();
void router.push('/routines/manage');
void router.push(backTo.value);
} catch (e) {
error.value = apiError(e, 'ذخیره روتین ممکن نشد.');
} finally {
@@ -234,10 +267,11 @@ async function save() {
<UiPageHeader
:title="isEdit ? 'ویرایش روتین' : 'روتین جدید'"
lead="کاری که باید به‌صورت منظم انجام شود؛ هر روزِ تکرار، یک کار برای مسئول ساخته می‌شود."
back-to="/routines/manage"
:back-to="backTo"
/>
<div v-if="loading" class="state">در حال بارگذاری…</div>
<p v-else-if="foreign" class="error">{{ error }}</p>
<form v-else class="grid" @submit.prevent="save">
<div class="col">
@@ -268,7 +302,8 @@ async function save() {
<UiCard>
<h3 class="sec-title">مسئول</h3>
<div class="fields">
<p v-if="!isManager" class="self-note">این روتین فقط برای خودتان ساخته می‌شود.</p>
<div v-else class="fields">
<UiSegmented v-model="form.target" :options="targetOptions" />
<UiField v-if="form.target === 'user'" label="کاربر" required>
<UiSelect v-model="form.assigneeId">
@@ -508,6 +543,13 @@ async function save() {
gap: var(--sp-2);
}
.self-note {
margin: 0;
font-size: var(--fs-sm);
color: var(--muted);
line-height: 1.7;
}
.error {
margin: 0;
}
@@ -1,9 +1,11 @@
<script setup lang="ts">
import { computed, inject, onMounted, ref, watch } from 'vue';
import { RouterLink } from 'vue-router';
import type { RoutineDto } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
import { UiBadge, UiEmptyState, UiSearch } from '@/components/ui';
import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth';
import { toFaDigits } from '@/lib/jalali';
import {
PRIORITY_LABELS,
@@ -16,6 +18,8 @@ import {
} from '@/lib/routines';
const shell = inject(ROUTINES_CTX)!;
const auth = useAuthStore();
const meId = computed(() => auth.user?.id ?? '');
const items = ref<RoutineDto[]>([]);
const loading = ref(true);
@@ -48,7 +52,7 @@ const filtered = computed(() => {
<template>
<div class="rlist">
<div class="toolbar">
<p class="lead">روتین‌هایی که به شما یا تیم شما سپرده شده‌اند. کارهای هر روز از روی این‌ها ساخته می‌شوند.</p>
<p class="lead">روتین‌های شما. کارهای هر روز از روی این‌ها ساخته می‌شوند. با «روتین جدید» می‌توانید برای خودتان برنامه تکراری بسازید.</p>
<UiSearch v-model="q" placeholder="جستجوی روتین…" class="search" />
</div>
@@ -58,8 +62,8 @@ const filtered = computed(() => {
<UiEmptyState
v-else-if="!filtered.length"
icon="refresh"
:title="q ? 'روتینی با این عنوان پیدا نشد' : 'هنوز روتینی برای شما تعریف نشده'"
description="روتین‌ها توسط مدیر تیم تعریف می‌شوند و هر روز به‌صورت خودکار در «کارهای امروز» ظاهر می‌شوند."
:title="q ? 'روتینی با این عنوان پیدا نشد' : 'هنوز روتینی برای شما نیست'"
description="با دکمه «روتین جدید» یک برنامه تکراری برای خودتان بسازید. روتینی که مدیر برای شما تعریف کرده هم اینجا دیده می‌شود."
/>
<div v-else class="cards">
@@ -90,6 +94,9 @@ const filtered = computed(() => {
<small class="since">
از {{ formatDayNumeric(r.startDate) }}{{ r.endDate ? ` تا ${formatDayNumeric(r.endDate)}` : '' }}
</small>
<RouterLink v-if="r.createdById === meId" :to="`/routines/manage/${r.id}/edit`" class="edit">
ویرایش
</RouterLink>
</footer>
</article>
</div>
@@ -239,6 +246,13 @@ const filtered = computed(() => {
font-size: var(--fs-xs);
}
.edit {
justify-self: start;
font-size: var(--fs-sm);
font-weight: var(--fw-medium);
color: var(--accent);
}
.error {
margin: 0;
}
@@ -77,7 +77,7 @@ const tabs: Tab[] = [
const visibleTabs = computed(() => tabs.filter((t) => !t.manager || isManager.value));
const managerOnlyRoutes = new Set(['routines-team', 'routines-manage', 'routine-new', 'routine-edit']);
const managerOnlyRoutes = new Set(['routines-team', 'routines-manage']);
watch(
[() => route.name, context],
@@ -106,7 +106,7 @@ const hideHeader = computed(() => route.name === 'routine-new' || route.name ===
<RouterLink to="/routines/settings" class="icon-link" title="تنظیمات یادآوری و اعلان‌ها">
<UiIcon name="bell" :size="17" />
</RouterLink>
<UiButton v-if="isManager" variant="outline" @click="router.push('/routines/manage/new')">
<UiButton variant="outline" @click="router.push('/routines/manage/new')">
<template #icon><UiIcon name="refresh" :size="16" /></template>
روتین جدید
</UiButton>
+2
View File
@@ -6261,6 +6261,8 @@ export interface RoutineDto {
entityLink: RoutineEntityLink;
reminderMinutesBefore: number | null;
isActive: boolean;
/** سازندهٔ روتین؛ عضو عادی فقط روتین خودش را ویرایش می‌کند */
createdById: string | null;
recurrenceLabel: string;
/** درصد اجرا در ۳۰ روز اخیر */
executionRate: number | null;