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:
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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<ActivityStageCountsDto> {
|
||||
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;
|
||||
|
||||
@@ -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<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);
|
||||
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<ActivityBoardDto> {
|
||||
async createBoard(
|
||||
organizationId: string,
|
||||
name: string,
|
||||
memberIds?: string[],
|
||||
): Promise<ActivityBoardDto> {
|
||||
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<ActivityBoardDto> {
|
||||
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<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> {
|
||||
const board = await this.getBoard(organizationId, id);
|
||||
@@ -213,8 +291,14 @@ export class ActivityStagesService {
|
||||
});
|
||||
}
|
||||
|
||||
async list(organizationId: string, boardId?: string | null): Promise<ActivityStageDto[]> {
|
||||
return (await this.rows(organizationId, boardId)).map((r) => this.toDto(r));
|
||||
async list(
|
||||
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));
|
||||
}
|
||||
|
||||
/** اولین مرحلهٔ هر نوع = مقصد فعالیتهای بدون مرحله یا با مرحلهٔ ناهمنوع */
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -170,7 +170,8 @@ const board = computed<string>(() => {
|
||||
});
|
||||
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<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() {
|
||||
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<ActivityBoardDto>('/activities/boards', { name });
|
||||
const { data: created } = await api.post<ActivityBoardDto>('/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(() => {
|
||||
<p v-if="boardModal.mode === 'create'" class="board-form-hint">
|
||||
گروه جدید با ستونهای پیشفرض ساخته میشود و بعد میتوانید ستونهایش را تغییر دهید.
|
||||
</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
|
||||
v-if="boardModal.mode === 'edit' && currentBoard && !currentBoard.isDefault"
|
||||
variant="ghost"
|
||||
@@ -1890,6 +1988,31 @@ h1 {
|
||||
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 {
|
||||
justify-self: start;
|
||||
color: var(--danger);
|
||||
|
||||
@@ -1584,14 +1584,18 @@ export interface ActivityBoardDto {
|
||||
name: string;
|
||||
isDefault: boolean;
|
||||
position: number;
|
||||
/** خالی = گروه برای همهٔ اعضای محیط باز است */
|
||||
memberIds: string[];
|
||||
}
|
||||
|
||||
export interface CreateActivityBoardRequest {
|
||||
name: string;
|
||||
memberIds?: string[];
|
||||
}
|
||||
|
||||
export interface UpdateActivityBoardRequest {
|
||||
name?: string;
|
||||
memberIds?: string[];
|
||||
}
|
||||
|
||||
export interface ActivityStageDto {
|
||||
|
||||
Reference in New Issue
Block a user