1
13import { Inject, Injectable } from "@nestjs/common";
14import type { Request } from "express";
15import { ApiError } from "../../common/errors.js";
16import { PrismaService } from "../../common/prisma.service.js";
17import { AuditService } from "../../common/audit.service.js";
18import { requireUser } from "../../common/types.js";
19import { assertOrgAdmin } from "./guards.js";
20
21export type OrgMemberRole = "OWNER" | "ADMIN" | "MEMBER" | "GUEST";
22
23@Injectable()
24export class OrgMembersService {
25 constructor(
26 @Inject(PrismaService) private readonly prisma: PrismaService,
27 @Inject(AuditService) private readonly audit: AuditService
28 ) {}
29
30
31 async list(orgId: string, q: string | undefined, req: Request) {
32 const userId = requireUser(req);
33 await assertOrgAdmin(this.prisma, orgId, userId);
34 const like = q?.trim() ? `%${q.trim().toLowerCase()}%` : undefined;
35 const members = await this.prisma.organizationMember.findMany({
36 where: {
37 organizationId: orgId,
38 ...(like
39 ? {
40 user: {
41 OR: [{ name: { contains: q!.trim(), mode: "insensitive" } }, { email: { contains: q!.trim(), mode: "insensitive" } }]
42 }
43 }
44 : {})
45 },
46 include: {
47 user: { select: { id: true, name: true, email: true, status: true, avatarUrl: true, lastLoginAt: true } }
48 },
49 orderBy: [{ role: "asc" }, { createdAt: "asc" }]
50 });
51
52 const userMap = new Map(members.map((m) => [m.userId, m]));
53 const sessions = await this.prisma.session.findMany({
54 where: { userId: { in: [...userMap.keys()] }, status: "ACTIVE" },
55 select: { userId: true, createdAt: true, expiresAt: true },
56 orderBy: { createdAt: "desc" }
57 });
58 const lastActive = new Map<string, Date>();
59 for (const s of sessions) if (!lastActive.has(s.userId)) lastActive.set(s.userId, s.createdAt);
60 return {
61 data: members.map((m) => ({
62 userId: m.userId,
63 name: m.user.name,
64 email: m.user.email,
65 role: m.role,
66 status: m.user.status,
67 avatarUrl: m.user.avatarUrl,
68 lastLoginAt: m.user.lastLoginAt,
69 lastActiveAt: lastActive.get(m.userId) ?? null,
70 joinedAt: m.createdAt
71 }))
72 };
73 }
74
75
76 async update(orgId: string, targetUserId: string, body: { role?: OrgMemberRole; suspend?: boolean }, req: Request) {
77 const actorId = requireUser(req);
78 await assertOrgAdmin(this.prisma, orgId, actorId);
79 const target = await this.prisma.organizationMember.findUnique({
80 where: { organizationId_userId: { organizationId: orgId, userId: targetUserId } }
81 });
82 if (!target) throw ApiError.notFound("Organization member not found.");
83
84 const changes: Record<string, { from: unknown; to: unknown }> = {};
85 if (body.role !== undefined && body.role !== target.role) {
86 if (body.role === "OWNER") {
87 throw ApiError.forbidden("Ownership cannot be granted via the members tab — use transfer.", [
88 { reason: "ownership_transfer_required" }
89 ]);
90 }
91
92
93 if (target.role === "OWNER") {
94 const owners = await this.prisma.organizationMember.count({
95 where: { organizationId: orgId, role: "OWNER" }
96 });
97 if (owners <= 1) {
98 throw ApiError.conflict("Cannot demote the last organization owner.", [{ reason: "last_org_owner" }]);
99 }
100 }
101 changes.role = { from: target.role, to: body.role };
102 await this.prisma.organizationMember.update({
103 where: { id: target.id },
104 data: { role: body.role }
105 });
106 }
107 if (body.suspend !== undefined) {
108 const user = await this.prisma.user.findUnique({ where: { id: targetUserId }, select: { status: true } });
109 if (!user) throw ApiError.notFound("User not found.");
110 if (body.suspend === true && user.status !== "SUSPENDED") {
111 changes.status = { from: user.status, to: "SUSPENDED" };
112 await this.prisma.user.update({ where: { id: targetUserId }, data: { status: "SUSPENDED" } });
113 } else if (body.suspend === false && user.status === "SUSPENDED") {
114 changes.status = { from: "SUSPENDED", to: "ACTIVE" };
115 await this.prisma.user.update({ where: { id: targetUserId }, data: { status: "ACTIVE" } });
116 }
117 }
118 if (Object.keys(changes).length > 0) {
119 await this.audit.audit(req, req.auth, {
120 action: "org.member_updated",
121 resourceType: "organization_member",
122 resourceId: targetUserId,
123 metadata: { orgId, changes }
124 });
125 }
126 return { ok: true, changes };
127 }
128}
129