1import { Inject, Injectable } from "@nestjs/common";
2import bcrypt from "bcryptjs";
3import { PrismaService } from "../../common/prisma.service.js";
4import { ApiError } from "../../common/errors.js";
5import { parsePagination } from "../../lib/pagination.js";
6import { actorLevel } from "../../lib/authorize.js";
7import { pageActivity } from "../../lib/activity.js";
8
9const PUBLIC_USER = {
10 id: true, email: true, name: true, displayName: true, avatarUrl: true,
11 emailVerification: true, emailVerifiedAt: true, status: true, locale: true,
12 timezone: true, theme: true, lastLoginAt: true, createdAt: true
13} as const;
14
15@Injectable()
16export class UsersService {
17 constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
18
19 async getMe(userId: string) {
20 const user = await this.prisma.user.findUnique({
21 where: { id: userId },
22 select: { ...PUBLIC_USER, memberships: { select: { role: true, workspace: { select: { id: true, name: true, slug: true, isPersonal: true } } } } }
23 });
24 if (!user) throw ApiError.notFound();
25 return {
26 user,
27 workspaces: user.memberships.map((m) => ({
28 id: m.workspace.id,
29 name: m.workspace.name,
30 slug: m.workspace.slug,
31 isPersonal: m.workspace.isPersonal,
32 role: m.role
33 }))
34 };
35 }
36
37 async updateProfile(userId: string, input: {
38 name?: string; displayName?: string; email?: string; password?: string;
39 currentPassword?: string; avatarUrl?: string | null; locale?: string;
40 timezone?: string; theme?: "light" | "dark" | "system";
41 }) {
42 const user = await this.prisma.user.findUnique({ where: { id: userId } });
43 if (!user) throw ApiError.notFound();
44
45 if (input.password) {
46 if (!input.currentPassword) {
47 throw ApiError.validation("currentPassword is required to change the password.", [
48 { field: "currentPassword", issue: "required" }
49 ]);
50 }
51 if (!user.passwordHash || !(await bcrypt.compare(input.currentPassword, user.passwordHash))) {
52 throw ApiError.validation("Current password is incorrect.", [
53 { field: "currentPassword", issue: "incorrect" }
54 ]);
55 }
56 }
57
58 if (input.email && input.email.toLowerCase() !== user.email) {
59 const taken = await this.prisma.user.findUnique({ where: { email: input.email.toLowerCase() } });
60 if (taken) {
61 throw new ApiError("CONFLICT", 409, "Email already in use.", [{ field: "email", issue: "already_exists" }]);
62 }
63 }
64
65 const updated = await this.prisma.user.update({
66 where: { id: userId },
67 data: {
68 ...(input.name !== undefined ? { name: input.name } : {}),
69 ...(input.displayName !== undefined ? { displayName: input.displayName } : {}),
70 ...(input.email !== undefined ? { email: input.email.toLowerCase(), emailVerifiedAt: null, emailVerification: "UNVERIFIED" } : {}),
71 ...(input.password !== undefined ? { passwordHash: await bcrypt.hash(input.password, 12) } : {}),
72 ...(input.avatarUrl !== undefined ? { avatarUrl: input.avatarUrl } : {}),
73 ...(input.locale !== undefined ? { locale: input.locale } : {}),
74 ...(input.timezone !== undefined ? { timezone: input.timezone } : {}),
75 ...(input.theme !== undefined ? { theme: input.theme } : {})
76 },
77 select: PUBLIC_USER
78 });
79 return { user: updated };
80 }
81
82
83 async listForWorkspace(workspaceId: string, query: { search?: string; cursor?: string; limit?: string }) {
84 const { cursor, limit } = parsePagination(query);
85 const members = await this.prisma.workspaceMember.findMany({
86 where: {
87 workspaceId,
88 ...(cursor ? { id: { gt: cursor.id } } : {}),
89 ...(query.search
90 ? {
91 user: {
92 OR: [
93 { email: { contains: query.search, mode: "insensitive" } },
94 { name: { contains: query.search, mode: "insensitive" } }
95 ]
96 }
97 }
98 : {})
99 },
100 include: { user: { select: PUBLIC_USER } },
101 orderBy: { id: "asc" },
102 take: limit + 1
103 });
104 const page = members.slice(0, limit);
105 const next = members.length > limit ? page[page.length - 1] : undefined;
106 return {
107 data: page.map((m) => ({
108 user: m.user,
109 role: m.role,
110 joinedAt: m.joinedAt
111 })),
112 nextCursor: next ? Buffer.from(JSON.stringify({ id: next.id, position: next.id })).toString("base64url") : null
113 };
114 }
115
116
117
118
121 async recordRecentItem(userId: string, input: { itemType: "page" | "database" | "whiteboard" | "task"; refId: string }) {
122 if (input.itemType === "page") await this.assertViewablePage(userId, input.refId);
123 await this.prisma.userRecentItem.upsert({
124 where: {
125 userId_itemType_refId: { userId, itemType: input.itemType, refId: input.refId }
126 },
127 update: { at: new Date() },
128 create: { userId, itemType: input.itemType, refId: input.refId, at: new Date() }
129 });
130
131 const keep = await this.prisma.userRecentItem.findMany({
132 where: { userId },
133 orderBy: { at: "desc" },
134 take: 50,
135 select: { id: true }
136 });
137 if (keep.length < 50) return { recorded: true };
138 const keepIds = keep.map((r) => r.id);
139 await this.prisma.userRecentItem.deleteMany({ where: { userId, id: { notIn: keepIds } } });
140 return { recorded: true };
141 }
142
143
144 async listRecentItems(userId: string) {
145 const rows = await this.prisma.userRecentItem.findMany({
146 where: { userId },
147 orderBy: { at: "desc" },
148 take: 50
149 });
150 return { data: rows };
151 }
152
153
156 private async assertViewablePage(userId: string, refId: string) {
157 const page = await this.prisma.page.findUnique({ where: { id: refId }, select: { workspaceId: true } });
158 if (!page) throw ApiError.notFound();
159 const level = await actorLevel(this.prisma, { workspaceId: page.workspaceId, userId });
160 if (level === "NONE") throw ApiError.notFound();
161 }
162
163
164
165 async createSavedSearch(userId: string, input: { name: string; workspaceId?: string | null; query: unknown }) {
166 const row = await this.prisma.savedSearch.create({
167 data: { userId, name: input.name, workspaceId: input.workspaceId ?? null, queryJson: input.query as object }
168 });
169 return row;
170 }
171
172 async listSavedSearches(userId: string) {
173 const rows = await this.prisma.savedSearch.findMany({ where: { userId }, orderBy: { updatedAt: "desc" } });
174 return { data: rows };
175 }
176
177
178 async getSavedSearch(userId: string, id: string) {
179 const row = await this.prisma.savedSearch.findFirst({ where: { id, userId } });
180 if (!row) throw ApiError.notFound();
181 return row;
182 }
183
184 async updateSavedSearch(userId: string, id: string, input: { name?: string; workspaceId?: string | null; query?: unknown }) {
185 await this.getSavedSearch(userId, id);
186 const row = await this.prisma.savedSearch.update({
187 where: { id },
188 data: {
189 ...(input.name !== undefined ? { name: input.name } : {}),
190 ...(input.workspaceId !== undefined ? { workspaceId: input.workspaceId } : {}),
191 ...(input.query !== undefined ? { queryJson: input.query as object } : {})
192 }
193 });
194 return row;
195 }
196
197 async deleteSavedSearch(userId: string, id: string) {
198 await this.getSavedSearch(userId, id);
199 await this.prisma.savedSearch.delete({ where: { id } });
200 return { deleted: true };
201 }
202
203
204
205
210 async listMyActivity(userId: string, query: { cursor?: string; limit?: string }) {
211 const memberships = await this.prisma.workspaceMember.findMany({
212 where: { userId },
213 select: { workspaceId: true }
214 });
215 const wsIds = memberships.map((m) => m.workspaceId);
216 const where: Record<string, unknown> = {
217 OR: [
218 { actorId: userId },
219 ...(wsIds.length ? [{ workspaceId: { in: wsIds } }] : [])
220 ]
221 };
222 return pageActivity(this.prisma, where, query);
223 }
224}