import { Inject, Injectable } from "@nestjs/common"; import bcrypt from "bcryptjs"; import { PrismaService } from "../../common/prisma.service.js"; import { ApiError } from "../../common/errors.js"; import { parsePagination } from "../../lib/pagination.js"; import { actorLevel } from "../../lib/authorize.js"; import { pageActivity } from "../../lib/activity.js"; const PUBLIC_USER = { id: true, email: true, name: true, displayName: true, avatarUrl: true, emailVerification: true, emailVerifiedAt: true, status: true, locale: true, timezone: true, theme: true, lastLoginAt: true, createdAt: true } as const; @Injectable() export class UsersService { constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} async getMe(userId: string) { const user = await this.prisma.user.findUnique({ where: { id: userId }, select: { ...PUBLIC_USER, memberships: { select: { role: true, workspace: { select: { id: true, name: true, slug: true, isPersonal: true } } } } } }); if (!user) throw ApiError.notFound(); return { user, workspaces: user.memberships.map((m) => ({ id: m.workspace.id, name: m.workspace.name, slug: m.workspace.slug, isPersonal: m.workspace.isPersonal, role: m.role })) }; } async updateProfile(userId: string, input: { name?: string; displayName?: string; email?: string; password?: string; currentPassword?: string; avatarUrl?: string | null; locale?: string; timezone?: string; theme?: "light" | "dark" | "system"; }) { const user = await this.prisma.user.findUnique({ where: { id: userId } }); if (!user) throw ApiError.notFound(); if (input.password) { if (!input.currentPassword) { throw ApiError.validation("currentPassword is required to change the password.", [ { field: "currentPassword", issue: "required" } ]); } if (!user.passwordHash || !(await bcrypt.compare(input.currentPassword, user.passwordHash))) { throw ApiError.validation("Current password is incorrect.", [ { field: "currentPassword", issue: "incorrect" } ]); } } if (input.email && input.email.toLowerCase() !== user.email) { const taken = await this.prisma.user.findUnique({ where: { email: input.email.toLowerCase() } }); if (taken) { throw new ApiError("CONFLICT", 409, "Email already in use.", [{ field: "email", issue: "already_exists" }]); } } const updated = await this.prisma.user.update({ where: { id: userId }, data: { ...(input.name !== undefined ? { name: input.name } : {}), ...(input.displayName !== undefined ? { displayName: input.displayName } : {}), ...(input.email !== undefined ? { email: input.email.toLowerCase(), emailVerifiedAt: null, emailVerification: "UNVERIFIED" } : {}), ...(input.password !== undefined ? { passwordHash: await bcrypt.hash(input.password, 12) } : {}), ...(input.avatarUrl !== undefined ? { avatarUrl: input.avatarUrl } : {}), ...(input.locale !== undefined ? { locale: input.locale } : {}), ...(input.timezone !== undefined ? { timezone: input.timezone } : {}), ...(input.theme !== undefined ? { theme: input.theme } : {}) }, select: PUBLIC_USER }); return { user: updated }; } /** Workspace member listing (workspace-scoped, requires membership). */ async listForWorkspace(workspaceId: string, query: { search?: string; cursor?: string; limit?: string }) { const { cursor, limit } = parsePagination(query); const members = await this.prisma.workspaceMember.findMany({ where: { workspaceId, ...(cursor ? { id: { gt: cursor.id } } : {}), ...(query.search ? { user: { OR: [ { email: { contains: query.search, mode: "insensitive" } }, { name: { contains: query.search, mode: "insensitive" } } ] } } : {}) }, include: { user: { select: PUBLIC_USER } }, orderBy: { id: "asc" }, take: limit + 1 }); const page = members.slice(0, limit); const next = members.length > limit ? page[page.length - 1] : undefined; return { data: page.map((m) => ({ user: m.user, role: m.role, joinedAt: m.joinedAt })), nextCursor: next ? Buffer.from(JSON.stringify({ id: next.id, position: next.id })).toString("base64url") : null }; } // ---------- T-153: recently-viewed items (per-user, cap 50) ---------- /** Record a view: upsert on (userId, itemType, refId), bump `at`. * The ref must exist and be viewable — a 404 for an unknown/forbidden id * (no-leak: a page in a workspace the caller cannot view is "not found"). */ async recordRecentItem(userId: string, input: { itemType: "page" | "database" | "whiteboard" | "task"; refId: string }) { if (input.itemType === "page") await this.assertViewablePage(userId, input.refId); await this.prisma.userRecentItem.upsert({ where: { userId_itemType_refId: { userId, itemType: input.itemType, refId: input.refId } }, update: { at: new Date() }, create: { userId, itemType: input.itemType, refId: input.refId, at: new Date() } }); // Keep the list bounded: drop rows beyond the cap for this user. const keep = await this.prisma.userRecentItem.findMany({ where: { userId }, orderBy: { at: "desc" }, take: 50, select: { id: true } }); if (keep.length < 50) return { recorded: true }; const keepIds = keep.map((r) => r.id); await this.prisma.userRecentItem.deleteMany({ where: { userId, id: { notIn: keepIds } } }); return { recorded: true }; } /** The user's recent items, newest first (cap 50). */ async listRecentItems(userId: string) { const rows = await this.prisma.userRecentItem.findMany({ where: { userId }, orderBy: { at: "desc" }, take: 50 }); return { data: rows }; } /** A "page" recent ref must be a page the caller can VIEW — otherwise a * non-member could record (and later re-read) ids of pages they cannot * open. 404 (no-leak) for unknown or invisible ids. */ private async assertViewablePage(userId: string, refId: string) { const page = await this.prisma.page.findUnique({ where: { id: refId }, select: { workspaceId: true } }); if (!page) throw ApiError.notFound(); const level = await actorLevel(this.prisma, { workspaceId: page.workspaceId, userId }); if (level === "NONE") throw ApiError.notFound(); } // ---------- T-154: saved searches (per-user) ---------- async createSavedSearch(userId: string, input: { name: string; workspaceId?: string | null; query: unknown }) { const row = await this.prisma.savedSearch.create({ data: { userId, name: input.name, workspaceId: input.workspaceId ?? null, queryJson: input.query as object } }); return row; } async listSavedSearches(userId: string) { const rows = await this.prisma.savedSearch.findMany({ where: { userId }, orderBy: { updatedAt: "desc" } }); return { data: rows }; } /** Read one saved search — cross-user id → 404 (no-leak). */ async getSavedSearch(userId: string, id: string) { const row = await this.prisma.savedSearch.findFirst({ where: { id, userId } }); if (!row) throw ApiError.notFound(); return row; } async updateSavedSearch(userId: string, id: string, input: { name?: string; workspaceId?: string | null; query?: unknown }) { await this.getSavedSearch(userId, id); const row = await this.prisma.savedSearch.update({ where: { id }, data: { ...(input.name !== undefined ? { name: input.name } : {}), ...(input.workspaceId !== undefined ? { workspaceId: input.workspaceId } : {}), ...(input.query !== undefined ? { queryJson: input.query as object } : {}) } }); return row; } async deleteSavedSearch(userId: string, id: string) { await this.getSavedSearch(userId, id); await this.prisma.savedSearch.delete({ where: { id } }); return { deleted: true }; } // ---------- T-158: activity feed (permission-scoped audit projection) ---------- /** * The caller's activity: audit rows they ACTED on (any workspace, incl. * org-level rows) + rows in workspaces they belong to (the spaces they can * see). Newest-first, keyset-paginated (shared lib `activity.ts`). */ async listMyActivity(userId: string, query: { cursor?: string; limit?: string }) { const memberships = await this.prisma.workspaceMember.findMany({ where: { userId }, select: { workspaceId: true } }); const wsIds = memberships.map((m) => m.workspaceId); const where: Record = { OR: [ { actorId: userId }, ...(wsIds.length ? [{ workspaceId: { in: wsIds } }] : []) ] }; return pageActivity(this.prisma, where, query); } }