From 63a1803adcfec73d00648b13fff40d2900f1d1a8 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Wed, 28 Oct 2026 20:24:40 +0330 Subject: [PATCH] feat(activities): let a group show and limit its members Empty membership stays open to the whole workspace; picking people restricts who can see that activity group. Co-authored-by: Cursor --- .../migration.sql | 12 ++ apps/api/prisma/schema.prisma | 13 ++ .../src/activities/activities.controller.ts | 6 +- apps/api/src/activities/activities.service.ts | 4 + .../src/activities/activity-stages.service.ts | 112 +++++++++++++-- apps/api/src/activities/dto.ts | 10 ++ apps/web/src/views/ActivitiesView.vue | 133 +++++++++++++++++- packages/shared/src/index.ts | 4 + 8 files changed, 272 insertions(+), 22 deletions(-) create mode 100644 apps/api/prisma/migrations/20261028170000_activity_board_members/migration.sql diff --git a/apps/api/prisma/migrations/20261028170000_activity_board_members/migration.sql b/apps/api/prisma/migrations/20261028170000_activity_board_members/migration.sql new file mode 100644 index 0000000..da13379 --- /dev/null +++ b/apps/api/prisma/migrations/20261028170000_activity_board_members/migration.sql @@ -0,0 +1,12 @@ +CREATE TABLE "ActivityBoardMember" ( + "boardId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + + CONSTRAINT "ActivityBoardMember_pkey" PRIMARY KEY ("boardId","userId") +); + +CREATE INDEX "ActivityBoardMember_userId_idx" ON "ActivityBoardMember"("userId"); + +ALTER TABLE "ActivityBoardMember" ADD CONSTRAINT "ActivityBoardMember_boardId_fkey" FOREIGN KEY ("boardId") REFERENCES "ActivityBoard"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "ActivityBoardMember" ADD CONSTRAINT "ActivityBoardMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 8be016b..2f16c6b 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -311,6 +311,7 @@ model User { tablePreferences UserTablePreference[] assignedProjectCards ProjectCard[] @relation("ProjectCardAssignees") teamMemberships TeamMember[] + activityBoardMembers ActivityBoardMember[] reservationAssignments ReservationAppointment[] @relation("ReservationAssignee") assignedLeads Lead[] @relation("LeadAssignee") createdLeads Lead[] @relation("LeadCreator") @@ -766,6 +767,7 @@ model ActivityBoard { position Int @default(0) stages ActivityStage[] activities Activity[] + members ActivityBoardMember[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -773,6 +775,17 @@ model ActivityBoard { @@index([organizationId, position]) } +/// عضو گروه فعالیت؛ اگر گروهی هیچ عضوی نداشته باشد برای همهٔ محیط باز است +model ActivityBoardMember { + boardId String + board ActivityBoard @relation(fields: [boardId], references: [id], onDelete: Cascade) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@id([boardId, userId]) + @@index([userId]) +} + model ActivityStage { id String @id @default(cuid()) organizationId String diff --git a/apps/api/src/activities/activities.controller.ts b/apps/api/src/activities/activities.controller.ts index b8fd2a5..424bce2 100644 --- a/apps/api/src/activities/activities.controller.ts +++ b/apps/api/src/activities/activities.controller.ts @@ -72,14 +72,14 @@ export class ActivitiesController { @Get('boards') listBoards(@CurrentUser() user: Actor) { - return this.stagesService.listBoards(user.organizationId); + return this.stagesService.listBoards(user.organizationId, user); } @Post('boards') @UseGuards(RolesGuard) @Roles('owner', 'admin') createBoard(@CurrentUser() user: Actor, @Body() dto: CreateActivityBoardDto) { - return this.stagesService.createBoard(user.organizationId, dto.name); + return this.stagesService.createBoard(user.organizationId, dto.name, dto.memberIds); } @Patch('boards/:id') @@ -103,7 +103,7 @@ export class ActivitiesController { @Get('stages') listStages(@CurrentUser() user: Actor, @Query() query: ListActivityStagesQueryDto) { - return this.stagesService.list(user.organizationId, query.boardId); + return this.stagesService.list(user.organizationId, query.boardId, user); } @Post('stages') diff --git a/apps/api/src/activities/activities.service.ts b/apps/api/src/activities/activities.service.ts index 3e30b0f..2d757f2 100644 --- a/apps/api/src/activities/activities.service.ts +++ b/apps/api/src/activities/activities.service.ts @@ -277,6 +277,7 @@ export class ActivitiesService { const boardCtx = await this.stages.boardContext(organizationId); if (facets?.boardId) { const boardId = await this.stages.resolveBoardId(organizationId, facets.boardId); + await this.stages.assertCanAccessBoard(organizationId, boardId, actor); (where.AND as Prisma.ActivityWhereInput[]).push( this.stages.boardWhere(boardId, boardCtx.defaultBoardId), ); @@ -320,6 +321,7 @@ export class ActivitiesService { facets?: ListFacets, ): Promise { const boardId = await this.stages.resolveBoardId(organizationId, facets?.boardId); + await this.stages.assertCanAccessBoard(organizationId, boardId, actor); const defaultBoard = await this.stages.ensureDefaultBoard(organizationId); const rows = await this.stages.rows(organizationId, boardId); const defaults = this.stages.defaultsOf(rows); @@ -451,6 +453,7 @@ export class ActivitiesService { : requestedBoardId ? await this.stages.resolveBoardId(organizationId, requestedBoardId) : null; + if (boardId) await this.stages.assertCanAccessBoard(organizationId, boardId, linkActor); const resolved = await this.types.resolve(organizationId, { typeDefId: dto.typeDefId, type: dto.type, @@ -571,6 +574,7 @@ export class ActivitiesService { select: { boardId: true }, }); const defaultBoard = await this.stages.ensureDefaultBoard(organizationId); + await this.stages.assertCanAccessBoard(organizationId, nextBoardId, actor); if (nextBoardId !== (current?.boardId ?? defaultBoard.id)) { // ستون قبلی مال گروه دیگر است؛ فعالیت به ستون اولِ هم‌وضعیت گروه جدید می‌رود data.boardId = nextBoardId; diff --git a/apps/api/src/activities/activity-stages.service.ts b/apps/api/src/activities/activity-stages.service.ts index 7cf77cc..bef0361 100644 --- a/apps/api/src/activities/activity-stages.service.ts +++ b/apps/api/src/activities/activity-stages.service.ts @@ -1,4 +1,9 @@ -import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; import type { ActivityBoardDto, ActivityStageColor, @@ -34,8 +39,23 @@ type BoardRow = { name: string; isDefault: boolean; position: number; + members: Array<{ userId: string }>; }; +function toBoardDto(row: BoardRow): ActivityBoardDto { + return { + id: row.id, + name: row.name, + isDefault: row.isDefault, + position: row.position, + memberIds: row.members.map((member) => member.userId), + }; +} + +function managesAllBoards(role: string) { + return role === 'owner' || role === 'admin'; +} + const STAGE_SELECT = { id: true, boardId: true, @@ -50,6 +70,7 @@ const BOARD_SELECT = { name: true, isDefault: true, position: true, + members: { select: { userId: true } }, } satisfies Prisma.ActivityBoardSelect; export type StageDefaults = Record; @@ -109,14 +130,36 @@ export class ActivityStagesService { }); } - async listBoards(organizationId: string): Promise { + async listBoards( + organizationId: string, + actor?: { userId: string; role: string }, + ): Promise { await this.ensureDefaultBoard(organizationId); const rows = await this.prisma.activityBoard.findMany({ where: { organizationId }, orderBy: [{ isDefault: 'desc' }, { position: 'asc' }, { createdAt: 'asc' }], select: BOARD_SELECT, }); - return rows.map((b) => ({ ...b })); + const boards = rows.map(toBoardDto); + if (!actor || managesAllBoards(actor.role)) return boards; + return boards.filter( + (board) => board.memberIds.length === 0 || board.memberIds.includes(actor.userId), + ); + } + + /** گروه بدون عضو برای همه باز است؛ مالک و مدیر همهٔ گروه‌ها را می‌بینند */ + async assertCanAccessBoard( + organizationId: string, + boardId: string, + actor: { userId: string; role: string }, + ) { + if (managesAllBoards(actor.role)) return; + const members = await this.prisma.activityBoardMember.findMany({ + where: { boardId, board: { organizationId } }, + select: { userId: true }, + }); + if (!members.length || members.some((member) => member.userId === actor.userId)) return; + throw new ForbiddenException('به این گروه فعالیت دسترسی ندارید'); } /** شناسهٔ گروه معتبر؛ خالی = گروه پیش‌فرض */ @@ -131,7 +174,11 @@ export class ActivityStagesService { return board.id; } - async createBoard(organizationId: string, name: string): Promise { + async createBoard( + organizationId: string, + name: string, + memberIds?: string[], + ): Promise { await this.ensureDefaultBoard(organizationId); const max = await this.prisma.activityBoard.aggregate({ where: { organizationId }, @@ -147,7 +194,8 @@ export class ActivityStagesService { select: BOARD_SELECT, }); await this.ensureBoardStages(organizationId, board.id); - return { ...board }; + if (memberIds !== undefined) await this.replaceMembers(organizationId, board.id, memberIds); + return this.getBoardDto(organizationId, board.id); } catch (e) { throw this.mapBoardError(e); } @@ -156,21 +204,51 @@ export class ActivityStagesService { async updateBoard( organizationId: string, id: string, - dto: { name?: string }, + dto: { name?: string; memberIds?: string[] }, ): Promise { await this.getBoard(organizationId, id); try { - const board = await this.prisma.activityBoard.update({ - where: { id }, - data: dto.name != null ? { name: this.cleanBoardName(dto.name) } : {}, - select: BOARD_SELECT, - }); - return { ...board }; + if (dto.name != null) { + await this.prisma.activityBoard.update({ + where: { id }, + data: { name: this.cleanBoardName(dto.name) }, + }); + } + if (dto.memberIds !== undefined) await this.replaceMembers(organizationId, id, dto.memberIds); + return this.getBoardDto(organizationId, id); } catch (e) { throw this.mapBoardError(e); } } + private async replaceMembers(organizationId: string, boardId: string, memberIds: string[]) { + const ids = [...new Set(memberIds.map((id) => id.trim()).filter(Boolean))]; + if (ids.length) { + const found = await this.prisma.membership.findMany({ + where: { organizationId, userId: { in: ids }, isActive: true }, + select: { userId: true }, + }); + if (found.length !== ids.length) { + throw new BadRequestException('بعضی از اعضا در این محیط نیستند'); + } + } + await this.prisma.activityBoardMember.deleteMany({ where: { boardId } }); + if (ids.length) { + await this.prisma.activityBoardMember.createMany({ + data: ids.map((userId) => ({ boardId, userId })), + }); + } + } + + private async getBoardDto(organizationId: string, id: string): Promise { + const board = await this.prisma.activityBoard.findFirst({ + where: { id, organizationId }, + select: BOARD_SELECT, + }); + if (!board) throw new NotFoundException('گروه فعالیت پیدا نشد'); + return toBoardDto(board); + } + /** فعالیت‌های گروه حذف‌شده به گروه پیش‌فرض (ستون اولِ هم‌وضعیت) منتقل می‌شوند */ async removeBoard(organizationId: string, id: string): Promise { const board = await this.getBoard(organizationId, id); @@ -213,8 +291,14 @@ export class ActivityStagesService { }); } - async list(organizationId: string, boardId?: string | null): Promise { - return (await this.rows(organizationId, boardId)).map((r) => this.toDto(r)); + async list( + organizationId: string, + boardId?: string | null, + actor?: { userId: string; role: string }, + ): Promise { + const resolved = await this.resolveBoardId(organizationId, boardId); + if (actor) await this.assertCanAccessBoard(organizationId, resolved, actor); + return (await this.rows(organizationId, resolved)).map((r) => this.toDto(r)); } /** اولین مرحلهٔ هر نوع = مقصد فعالیت‌های بدون مرحله یا با مرحلهٔ ناهم‌نوع */ diff --git a/apps/api/src/activities/dto.ts b/apps/api/src/activities/dto.ts index 6395dae..38a8d2b 100644 --- a/apps/api/src/activities/dto.ts +++ b/apps/api/src/activities/dto.ts @@ -131,6 +131,11 @@ export class CreateActivityBoardDto { @MinLength(2) @MaxLength(80) name!: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + memberIds?: string[]; } export class UpdateActivityBoardDto { @@ -139,6 +144,11 @@ export class UpdateActivityBoardDto { @MinLength(2) @MaxLength(80) name?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + memberIds?: string[]; } export class ListActivityStagesQueryDto { diff --git a/apps/web/src/views/ActivitiesView.vue b/apps/web/src/views/ActivitiesView.vue index b440165..39ad5bc 100644 --- a/apps/web/src/views/ActivitiesView.vue +++ b/apps/web/src/views/ActivitiesView.vue @@ -170,7 +170,8 @@ const board = computed(() => { }); const currentBoard = computed(() => boards.value.find((b) => b.id === board.value) ?? null); const boardMenuOpen = ref(false); -const boardModal = ref<{ mode: 'create' | 'edit'; name: string } | null>(null); +const boardModal = ref<{ mode: 'create' | 'edit'; name: string; memberIds: string[] } | null>(null); +const boardMemberQuery = ref(''); const boardSaving = ref(false); const boardDeleteOpen = ref(false); @@ -410,13 +411,76 @@ function setBoard(next: string) { }); } +const activeBoardUsers = computed(() => users.value.filter((user) => user.isActive !== false)); + +const boardMembersOpenToAll = computed(() => { + const ids = activeBoardUsers.value.map((user) => user.id); + const selected = boardModal.value?.memberIds ?? []; + return ids.length > 0 && ids.every((id) => selected.includes(id)); +}); + +const filteredBoardUsers = computed(() => { + const query = boardMemberQuery.value.trim().replace(/ي/g, 'ی').replace(/ك/g, 'ک'); + const selected = new Set(boardModal.value?.memberIds ?? []); + const list = activeBoardUsers.value.filter((user) => { + if (!query) return true; + return user.fullName.replace(/ي/g, 'ی').replace(/ك/g, 'ک').includes(query); + }); + return list.slice().sort((a, b) => Number(selected.has(b.id)) - Number(selected.has(a.id))); +}); + +function showExistingBoardMembers() { + const modal = boardModal.value; + if (!modal || modal.mode !== 'edit') return; + const saved = currentBoard.value?.memberIds ?? []; + modal.memberIds = saved.length + ? [...saved] + : activeBoardUsers.value.map((user) => user.id); +} + +async function ensureBoardUsers() { + if (!users.value.length) { + try { + const { data: rows } = await api.get('/users'); + users.value = rows.filter((user) => user.isActive !== false); + } catch { + users.value = []; + } + } + showExistingBoardMembers(); +} + +function memberIdsToSave(selected: string[]) { + const all = activeBoardUsers.value.map((user) => user.id); + if (all.length && selected.length === all.length && all.every((id) => selected.includes(id))) { + return []; + } + return selected; +} + function openCreateBoard() { - boardModal.value = { mode: 'create', name: '' }; + boardMemberQuery.value = ''; + boardModal.value = { mode: 'create', name: '', memberIds: [] }; + void ensureBoardUsers(); } function openEditBoard() { if (!currentBoard.value) return; - boardModal.value = { mode: 'edit', name: currentBoard.value.name }; + boardMemberQuery.value = ''; + boardModal.value = { + mode: 'edit', + name: currentBoard.value.name, + memberIds: [...(currentBoard.value.memberIds ?? [])], + }; + void ensureBoardUsers(); +} + +function toggleBoardMember(userId: string) { + const modal = boardModal.value; + if (!modal) return; + modal.memberIds = modal.memberIds.includes(userId) + ? modal.memberIds.filter((id) => id !== userId) + : [...modal.memberIds, userId]; } async function saveBoard() { @@ -427,12 +491,18 @@ async function saveBoard() { error.value = ''; try { if (modal.mode === 'create') { - const { data: created } = await api.post('/activities/boards', { name }); + const { data: created } = await api.post('/activities/boards', { + name, + memberIds: memberIdsToSave(modal.memberIds), + }); await loadBoards(); boardModal.value = null; setBoard(created.id); } else if (currentBoard.value) { - await api.patch(`/activities/boards/${currentBoard.value.id}`, { name }); + await api.patch(`/activities/boards/${currentBoard.value.id}`, { + name, + memberIds: memberIdsToSave(modal.memberIds), + }); await loadBoards(); boardModal.value = null; } @@ -1620,6 +1690,34 @@ onUnmounted(() => {

گروه جدید با ستون‌های پیش‌فرض ساخته می‌شود و بعد می‌توانید ستون‌هایش را تغییر دهید.

+ + +

+ {{ + boardMembersOpenToAll + ? `اعضای فعلی همین ${toFaDigits(boardModal.memberIds.length)} نفر هستند و گروه برای همهٔ محیط باز است. با برداشتن تیک، فقط افراد انتخاب‌شده آن را می‌بینند.` + : boardModal.memberIds.length + ? `${toFaDigits(boardModal.memberIds.length)} نفر انتخاب شده. فقط همین افراد این گروه را می‌بینند.` + : 'اگر کسی را انتخاب نکنید، این گروه برای همهٔ اعضای محیط دیده می‌شود.' + }} +

+
    +
  • عضوی پیدا نشد.
  • +
  • + +
  • +