/** * TASK-0133 — org MEMBERS tab (list/search, role change, suspend/unsuspend). * * Suspend = `User.status SUSPENDED` (the SCIM deactivation semantics — login * + in-flight sessions blocked by the AuthGuard status re-check). The * org-membership row is KEPT (re-suspension restores access; memberships and * data are preserved — the "deactivation, never deletion" invariant). * * Role change rules: an ORG OWNER can never be demoted while the LAST owner * (demoting the last owner would strand the org — 409). Role is capped at * ADMIN (the owner is special: `Organization.ownerId` + role OWNER). */ import { Inject, Injectable } from "@nestjs/common"; import type { Request } from "express"; import { ApiError } from "../../common/errors.js"; import { PrismaService } from "../../common/prisma.service.js"; import { AuditService } from "../../common/audit.service.js"; import { requireUser } from "../../common/types.js"; import { assertOrgAdmin } from "./guards.js"; export type OrgMemberRole = "OWNER" | "ADMIN" | "MEMBER" | "GUEST"; @Injectable() export class OrgMembersService { constructor( @Inject(PrismaService) private readonly prisma: PrismaService, @Inject(AuditService) private readonly audit: AuditService ) {} /** `GET /orgs/:orgId/admin/members?q=` — search by name/email. */ async list(orgId: string, q: string | undefined, req: Request) { const userId = requireUser(req); await assertOrgAdmin(this.prisma, orgId, userId); const like = q?.trim() ? `%${q.trim().toLowerCase()}%` : undefined; const members = await this.prisma.organizationMember.findMany({ where: { organizationId: orgId, ...(like ? { user: { OR: [{ name: { contains: q!.trim(), mode: "insensitive" } }, { email: { contains: q!.trim(), mode: "insensitive" } }] } } : {}) }, include: { user: { select: { id: true, name: true, email: true, status: true, avatarUrl: true, lastLoginAt: true } } }, orderBy: [{ role: "asc" }, { createdAt: "asc" }] }); // Last-active: the user's most recent ACTIVE session (any workspace). const userMap = new Map(members.map((m) => [m.userId, m])); const sessions = await this.prisma.session.findMany({ where: { userId: { in: [...userMap.keys()] }, status: "ACTIVE" }, select: { userId: true, createdAt: true, expiresAt: true }, orderBy: { createdAt: "desc" } }); const lastActive = new Map(); for (const s of sessions) if (!lastActive.has(s.userId)) lastActive.set(s.userId, s.createdAt); return { data: members.map((m) => ({ userId: m.userId, name: m.user.name, email: m.user.email, role: m.role, status: m.user.status, avatarUrl: m.user.avatarUrl, lastLoginAt: m.user.lastLoginAt, lastActiveAt: lastActive.get(m.userId) ?? null, joinedAt: m.createdAt })) }; } /** `PATCH /orgs/:orgId/admin/members/:userId` — role and/or suspend. */ async update(orgId: string, targetUserId: string, body: { role?: OrgMemberRole; suspend?: boolean }, req: Request) { const actorId = requireUser(req); await assertOrgAdmin(this.prisma, orgId, actorId); const target = await this.prisma.organizationMember.findUnique({ where: { organizationId_userId: { organizationId: orgId, userId: targetUserId } } }); if (!target) throw ApiError.notFound("Organization member not found."); const changes: Record = {}; if (body.role !== undefined && body.role !== target.role) { if (body.role === "OWNER") { throw ApiError.forbidden("Ownership cannot be granted via the members tab — use transfer.", [ { reason: "ownership_transfer_required" } ]); } // body.role is already narrowed away from "OWNER" above (ownership is // not grantable here) — so this is the "demoting an OWNER" check. if (target.role === "OWNER") { const owners = await this.prisma.organizationMember.count({ where: { organizationId: orgId, role: "OWNER" } }); if (owners <= 1) { throw ApiError.conflict("Cannot demote the last organization owner.", [{ reason: "last_org_owner" }]); } } changes.role = { from: target.role, to: body.role }; await this.prisma.organizationMember.update({ where: { id: target.id }, data: { role: body.role } }); } if (body.suspend !== undefined) { const user = await this.prisma.user.findUnique({ where: { id: targetUserId }, select: { status: true } }); if (!user) throw ApiError.notFound("User not found."); if (body.suspend === true && user.status !== "SUSPENDED") { changes.status = { from: user.status, to: "SUSPENDED" }; await this.prisma.user.update({ where: { id: targetUserId }, data: { status: "SUSPENDED" } }); } else if (body.suspend === false && user.status === "SUSPENDED") { changes.status = { from: "SUSPENDED", to: "ACTIVE" }; await this.prisma.user.update({ where: { id: targetUserId }, data: { status: "ACTIVE" } }); } } if (Object.keys(changes).length > 0) { await this.audit.audit(req, req.auth, { action: "org.member_updated", resourceType: "organization_member", resourceId: targetUserId, metadata: { orgId, changes } }); } return { ok: true, changes }; } }