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 <cursoragent@cursor.com>
This commit is contained in:
2026-10-28 20:24:40 +03:30
co-authored by Cursor
parent 2cc2d358e4
commit 63a1803adc
8 changed files with 272 additions and 22 deletions
@@ -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;
+13
View File
@@ -311,6 +311,7 @@ model User {
tablePreferences UserTablePreference[] tablePreferences UserTablePreference[]
assignedProjectCards ProjectCard[] @relation("ProjectCardAssignees") assignedProjectCards ProjectCard[] @relation("ProjectCardAssignees")
teamMemberships TeamMember[] teamMemberships TeamMember[]
activityBoardMembers ActivityBoardMember[]
reservationAssignments ReservationAppointment[] @relation("ReservationAssignee") reservationAssignments ReservationAppointment[] @relation("ReservationAssignee")
assignedLeads Lead[] @relation("LeadAssignee") assignedLeads Lead[] @relation("LeadAssignee")
createdLeads Lead[] @relation("LeadCreator") createdLeads Lead[] @relation("LeadCreator")
@@ -766,6 +767,7 @@ model ActivityBoard {
position Int @default(0) position Int @default(0)
stages ActivityStage[] stages ActivityStage[]
activities Activity[] activities Activity[]
members ActivityBoardMember[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -773,6 +775,17 @@ model ActivityBoard {
@@index([organizationId, position]) @@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 { model ActivityStage {
id String @id @default(cuid()) id String @id @default(cuid())
organizationId String organizationId String
@@ -72,14 +72,14 @@ export class ActivitiesController {
@Get('boards') @Get('boards')
listBoards(@CurrentUser() user: Actor) { listBoards(@CurrentUser() user: Actor) {
return this.stagesService.listBoards(user.organizationId); return this.stagesService.listBoards(user.organizationId, user);
} }
@Post('boards') @Post('boards')
@UseGuards(RolesGuard) @UseGuards(RolesGuard)
@Roles('owner', 'admin') @Roles('owner', 'admin')
createBoard(@CurrentUser() user: Actor, @Body() dto: CreateActivityBoardDto) { 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') @Patch('boards/:id')
@@ -103,7 +103,7 @@ export class ActivitiesController {
@Get('stages') @Get('stages')
listStages(@CurrentUser() user: Actor, @Query() query: ListActivityStagesQueryDto) { 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') @Post('stages')
@@ -277,6 +277,7 @@ export class ActivitiesService {
const boardCtx = await this.stages.boardContext(organizationId); const boardCtx = await this.stages.boardContext(organizationId);
if (facets?.boardId) { if (facets?.boardId) {
const boardId = await this.stages.resolveBoardId(organizationId, facets.boardId); const boardId = await this.stages.resolveBoardId(organizationId, facets.boardId);
await this.stages.assertCanAccessBoard(organizationId, boardId, actor);
(where.AND as Prisma.ActivityWhereInput[]).push( (where.AND as Prisma.ActivityWhereInput[]).push(
this.stages.boardWhere(boardId, boardCtx.defaultBoardId), this.stages.boardWhere(boardId, boardCtx.defaultBoardId),
); );
@@ -320,6 +321,7 @@ export class ActivitiesService {
facets?: ListFacets, facets?: ListFacets,
): Promise<ActivityStageCountsDto> { ): Promise<ActivityStageCountsDto> {
const boardId = await this.stages.resolveBoardId(organizationId, facets?.boardId); const boardId = await this.stages.resolveBoardId(organizationId, facets?.boardId);
await this.stages.assertCanAccessBoard(organizationId, boardId, actor);
const defaultBoard = await this.stages.ensureDefaultBoard(organizationId); const defaultBoard = await this.stages.ensureDefaultBoard(organizationId);
const rows = await this.stages.rows(organizationId, boardId); const rows = await this.stages.rows(organizationId, boardId);
const defaults = this.stages.defaultsOf(rows); const defaults = this.stages.defaultsOf(rows);
@@ -451,6 +453,7 @@ export class ActivitiesService {
: requestedBoardId : requestedBoardId
? await this.stages.resolveBoardId(organizationId, requestedBoardId) ? await this.stages.resolveBoardId(organizationId, requestedBoardId)
: null; : null;
if (boardId) await this.stages.assertCanAccessBoard(organizationId, boardId, linkActor);
const resolved = await this.types.resolve(organizationId, { const resolved = await this.types.resolve(organizationId, {
typeDefId: dto.typeDefId, typeDefId: dto.typeDefId,
type: dto.type, type: dto.type,
@@ -571,6 +574,7 @@ export class ActivitiesService {
select: { boardId: true }, select: { boardId: true },
}); });
const defaultBoard = await this.stages.ensureDefaultBoard(organizationId); const defaultBoard = await this.stages.ensureDefaultBoard(organizationId);
await this.stages.assertCanAccessBoard(organizationId, nextBoardId, actor);
if (nextBoardId !== (current?.boardId ?? defaultBoard.id)) { if (nextBoardId !== (current?.boardId ?? defaultBoard.id)) {
// ستون قبلی مال گروه دیگر است؛ فعالیت به ستون اولِ هم‌وضعیت گروه جدید می‌رود // ستون قبلی مال گروه دیگر است؛ فعالیت به ستون اولِ هم‌وضعیت گروه جدید می‌رود
data.boardId = nextBoardId; data.boardId = nextBoardId;
@@ -1,4 +1,9 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type { import type {
ActivityBoardDto, ActivityBoardDto,
ActivityStageColor, ActivityStageColor,
@@ -34,8 +39,23 @@ type BoardRow = {
name: string; name: string;
isDefault: boolean; isDefault: boolean;
position: number; 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 = { const STAGE_SELECT = {
id: true, id: true,
boardId: true, boardId: true,
@@ -50,6 +70,7 @@ const BOARD_SELECT = {
name: true, name: true,
isDefault: true, isDefault: true,
position: true, position: true,
members: { select: { userId: true } },
} satisfies Prisma.ActivityBoardSelect; } satisfies Prisma.ActivityBoardSelect;
export type StageDefaults = Record<ActivityStatus, string | null>; export type StageDefaults = Record<ActivityStatus, string | null>;
@@ -109,14 +130,36 @@ export class ActivityStagesService {
}); });
} }
async listBoards(organizationId: string): Promise<ActivityBoardDto[]> { async listBoards(
organizationId: string,
actor?: { userId: string; role: string },
): Promise<ActivityBoardDto[]> {
await this.ensureDefaultBoard(organizationId); await this.ensureDefaultBoard(organizationId);
const rows = await this.prisma.activityBoard.findMany({ const rows = await this.prisma.activityBoard.findMany({
where: { organizationId }, where: { organizationId },
orderBy: [{ isDefault: 'desc' }, { position: 'asc' }, { createdAt: 'asc' }], orderBy: [{ isDefault: 'desc' }, { position: 'asc' }, { createdAt: 'asc' }],
select: BOARD_SELECT, 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; return board.id;
} }
async createBoard(organizationId: string, name: string): Promise<ActivityBoardDto> { async createBoard(
organizationId: string,
name: string,
memberIds?: string[],
): Promise<ActivityBoardDto> {
await this.ensureDefaultBoard(organizationId); await this.ensureDefaultBoard(organizationId);
const max = await this.prisma.activityBoard.aggregate({ const max = await this.prisma.activityBoard.aggregate({
where: { organizationId }, where: { organizationId },
@@ -147,7 +194,8 @@ export class ActivityStagesService {
select: BOARD_SELECT, select: BOARD_SELECT,
}); });
await this.ensureBoardStages(organizationId, board.id); 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) { } catch (e) {
throw this.mapBoardError(e); throw this.mapBoardError(e);
} }
@@ -156,21 +204,51 @@ export class ActivityStagesService {
async updateBoard( async updateBoard(
organizationId: string, organizationId: string,
id: string, id: string,
dto: { name?: string }, dto: { name?: string; memberIds?: string[] },
): Promise<ActivityBoardDto> { ): Promise<ActivityBoardDto> {
await this.getBoard(organizationId, id); await this.getBoard(organizationId, id);
try { try {
const board = await this.prisma.activityBoard.update({ if (dto.name != null) {
await this.prisma.activityBoard.update({
where: { id }, where: { id },
data: dto.name != null ? { name: this.cleanBoardName(dto.name) } : {}, data: { name: this.cleanBoardName(dto.name) },
select: BOARD_SELECT,
}); });
return { ...board }; }
if (dto.memberIds !== undefined) await this.replaceMembers(organizationId, id, dto.memberIds);
return this.getBoardDto(organizationId, id);
} catch (e) { } catch (e) {
throw this.mapBoardError(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<ActivityBoardDto> {
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<void> { async removeBoard(organizationId: string, id: string): Promise<void> {
const board = await this.getBoard(organizationId, id); const board = await this.getBoard(organizationId, id);
@@ -213,8 +291,14 @@ export class ActivityStagesService {
}); });
} }
async list(organizationId: string, boardId?: string | null): Promise<ActivityStageDto[]> { async list(
return (await this.rows(organizationId, boardId)).map((r) => this.toDto(r)); organizationId: string,
boardId?: string | null,
actor?: { userId: string; role: string },
): Promise<ActivityStageDto[]> {
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));
} }
/** اولین مرحلهٔ هر نوع = مقصد فعالیت‌های بدون مرحله یا با مرحلهٔ ناهم‌نوع */ /** اولین مرحلهٔ هر نوع = مقصد فعالیت‌های بدون مرحله یا با مرحلهٔ ناهم‌نوع */
+10
View File
@@ -131,6 +131,11 @@ export class CreateActivityBoardDto {
@MinLength(2) @MinLength(2)
@MaxLength(80) @MaxLength(80)
name!: string; name!: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
memberIds?: string[];
} }
export class UpdateActivityBoardDto { export class UpdateActivityBoardDto {
@@ -139,6 +144,11 @@ export class UpdateActivityBoardDto {
@MinLength(2) @MinLength(2)
@MaxLength(80) @MaxLength(80)
name?: string; name?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
memberIds?: string[];
} }
export class ListActivityStagesQueryDto { export class ListActivityStagesQueryDto {
+128 -5
View File
@@ -170,7 +170,8 @@ const board = computed<string>(() => {
}); });
const currentBoard = computed(() => boards.value.find((b) => b.id === board.value) ?? null); const currentBoard = computed(() => boards.value.find((b) => b.id === board.value) ?? null);
const boardMenuOpen = ref(false); 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 boardSaving = ref(false);
const boardDeleteOpen = 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<OrgUserDto[]>('/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() { function openCreateBoard() {
boardModal.value = { mode: 'create', name: '' }; boardMemberQuery.value = '';
boardModal.value = { mode: 'create', name: '', memberIds: [] };
void ensureBoardUsers();
} }
function openEditBoard() { function openEditBoard() {
if (!currentBoard.value) return; 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() { async function saveBoard() {
@@ -427,12 +491,18 @@ async function saveBoard() {
error.value = ''; error.value = '';
try { try {
if (modal.mode === 'create') { if (modal.mode === 'create') {
const { data: created } = await api.post<ActivityBoardDto>('/activities/boards', { name }); const { data: created } = await api.post<ActivityBoardDto>('/activities/boards', {
name,
memberIds: memberIdsToSave(modal.memberIds),
});
await loadBoards(); await loadBoards();
boardModal.value = null; boardModal.value = null;
setBoard(created.id); setBoard(created.id);
} else if (currentBoard.value) { } 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(); await loadBoards();
boardModal.value = null; boardModal.value = null;
} }
@@ -1620,6 +1690,34 @@ onUnmounted(() => {
<p v-if="boardModal.mode === 'create'" class="board-form-hint"> <p v-if="boardModal.mode === 'create'" class="board-form-hint">
گروه جدید با ستون‌های پیش‌فرض ساخته می‌شود و بعد می‌توانید ستون‌هایش را تغییر دهید. گروه جدید با ستون‌های پیش‌فرض ساخته می‌شود و بعد می‌توانید ستون‌هایش را تغییر دهید.
</p> </p>
<label class="board-form-label" for="activity-board-member-search">اعضا</label>
<UiInput
id="activity-board-member-search"
v-model="boardMemberQuery"
placeholder="جستجوی نام عضو…"
/>
<p class="board-form-hint">
{{
boardMembersOpenToAll
? `اعضای فعلی همین ${toFaDigits(boardModal.memberIds.length)} نفر هستند و گروه برای همهٔ محیط باز است. با برداشتن تیک، فقط افراد انتخاب‌شده آن را می‌بینند.`
: boardModal.memberIds.length
? `${toFaDigits(boardModal.memberIds.length)} نفر انتخاب شده. فقط همین افراد این گروه را می‌بینند.`
: 'اگر کسی را انتخاب نکنید، این گروه برای همهٔ اعضای محیط دیده می‌شود.'
}}
</p>
<ul class="board-members">
<li v-if="!filteredBoardUsers.length" class="board-form-hint">عضوی پیدا نشد.</li>
<li v-for="user in filteredBoardUsers" :key="user.id">
<label>
<input
type="checkbox"
:checked="boardModal.memberIds.includes(user.id)"
@change="toggleBoardMember(user.id)"
/>
<span>{{ user.fullName }}</span>
</label>
</li>
</ul>
<UiButton <UiButton
v-if="boardModal.mode === 'edit' && currentBoard && !currentBoard.isDefault" v-if="boardModal.mode === 'edit' && currentBoard && !currentBoard.isDefault"
variant="ghost" variant="ghost"
@@ -1890,6 +1988,31 @@ h1 {
line-height: 1.6; line-height: 1.6;
} }
.board-members {
list-style: none;
margin: 0;
padding: 0.25rem;
max-height: 14rem;
overflow: auto;
border: 1px solid #e2e8f0;
border-radius: var(--radius);
display: grid;
gap: 0.1rem;
}
.board-members label {
display: flex;
align-items: center;
gap: 0.45rem;
padding: 0.35rem 0.45rem;
border-radius: var(--radius);
cursor: pointer;
}
.board-members label:hover {
background: #f8fafc;
}
.board-delete { .board-delete {
justify-self: start; justify-self: start;
color: var(--danger); color: var(--danger);
+4
View File
@@ -1584,14 +1584,18 @@ export interface ActivityBoardDto {
name: string; name: string;
isDefault: boolean; isDefault: boolean;
position: number; position: number;
/** خالی = گروه برای همهٔ اعضای محیط باز است */
memberIds: string[];
} }
export interface CreateActivityBoardRequest { export interface CreateActivityBoardRequest {
name: string; name: string;
memberIds?: string[];
} }
export interface UpdateActivityBoardRequest { export interface UpdateActivityBoardRequest {
name?: string; name?: string;
memberIds?: string[];
} }
export interface ActivityStageDto { export interface ActivityStageDto {