refactor(api): extract user procedures
This commit is contained in:
@@ -0,0 +1,499 @@
|
||||
import { Prisma } from "@capakraken/db";
|
||||
import { PermissionOverrides, SystemRole, resolvePermissions } from "@capakraken/shared/types";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
import { findUniqueOrThrow } from "../db/helpers.js";
|
||||
import { createAuditEntry } from "../lib/audit.js";
|
||||
import type { TRPCContext } from "../trpc.js";
|
||||
|
||||
export const CreateUserInputSchema = z.object({
|
||||
email: z.string().email(),
|
||||
name: z.string().min(1),
|
||||
systemRole: z.nativeEnum(SystemRole).default(SystemRole.USER),
|
||||
password: z.string().min(8),
|
||||
});
|
||||
|
||||
export const SetUserPasswordInputSchema = z.object({
|
||||
userId: z.string(),
|
||||
password: z.string().min(8, "Password must be at least 8 characters"),
|
||||
});
|
||||
|
||||
export const UpdateUserRoleInputSchema = z.object({
|
||||
id: z.string(),
|
||||
systemRole: z.nativeEnum(SystemRole),
|
||||
});
|
||||
|
||||
export const UpdateUserNameInputSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string().min(1, "Name is required").max(200),
|
||||
});
|
||||
|
||||
export const LinkUserResourceInputSchema = z.object({
|
||||
userId: z.string(),
|
||||
resourceId: z.string().nullable(),
|
||||
});
|
||||
|
||||
export const SetUserPermissionsInputSchema = z.object({
|
||||
userId: z.string(),
|
||||
overrides: z
|
||||
.object({
|
||||
granted: z.array(z.string()).optional(),
|
||||
denied: z.array(z.string()).optional(),
|
||||
chapterIds: z.array(z.string()).optional(),
|
||||
})
|
||||
.nullable(),
|
||||
});
|
||||
|
||||
export const UserIdInputSchema = z.object({
|
||||
userId: z.string(),
|
||||
});
|
||||
|
||||
type UserReadContext = Pick<TRPCContext, "db" | "dbUser">;
|
||||
type UserMutationContext = UserReadContext;
|
||||
|
||||
function withAuditUser(userId: string | undefined) {
|
||||
return userId ? { userId } : {};
|
||||
}
|
||||
|
||||
export async function listAssignableUsers(ctx: UserReadContext) {
|
||||
return ctx.db.user.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
}
|
||||
|
||||
export async function listUsers(ctx: UserReadContext) {
|
||||
return ctx.db.user.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
systemRole: true,
|
||||
createdAt: true,
|
||||
lastLoginAt: true,
|
||||
lastActiveAt: true,
|
||||
permissionOverrides: true,
|
||||
totpEnabled: true,
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
}
|
||||
|
||||
export async function countActiveUsers(ctx: UserReadContext) {
|
||||
const fiveMinAgo = new Date(Date.now() - 5 * 60 * 1000);
|
||||
const count = await ctx.db.user.count({
|
||||
where: { lastActiveAt: { gte: fiveMinAgo } },
|
||||
});
|
||||
return { count };
|
||||
}
|
||||
|
||||
export async function getCurrentUserProfile(ctx: UserReadContext) {
|
||||
return findUniqueOrThrow(
|
||||
ctx.db.user.findUnique({
|
||||
where: { id: ctx.dbUser!.id },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
systemRole: true,
|
||||
permissionOverrides: true,
|
||||
createdAt: true,
|
||||
},
|
||||
}),
|
||||
"User",
|
||||
);
|
||||
}
|
||||
|
||||
export async function createUser(
|
||||
ctx: UserMutationContext,
|
||||
input: z.infer<typeof CreateUserInputSchema>,
|
||||
) {
|
||||
const existing = await ctx.db.user.findUnique({ where: { email: input.email } });
|
||||
if (existing) {
|
||||
throw new TRPCError({ code: "CONFLICT", message: "User with this email already exists" });
|
||||
}
|
||||
|
||||
const { hash } = await import("@node-rs/argon2");
|
||||
const passwordHash = await hash(input.password);
|
||||
|
||||
const user = await ctx.db.user.create({
|
||||
data: {
|
||||
email: input.email,
|
||||
name: input.name,
|
||||
systemRole: input.systemRole,
|
||||
passwordHash,
|
||||
},
|
||||
select: { id: true, name: true, email: true, systemRole: true },
|
||||
});
|
||||
|
||||
const matchingResource = await ctx.db.resource.findFirst({
|
||||
where: { email: input.email, userId: null },
|
||||
select: { id: true },
|
||||
});
|
||||
if (matchingResource) {
|
||||
await ctx.db.resource.update({
|
||||
where: { id: matchingResource.id },
|
||||
data: { userId: user.id },
|
||||
});
|
||||
}
|
||||
|
||||
void createAuditEntry({
|
||||
db: ctx.db,
|
||||
entityType: "User",
|
||||
entityId: user.id,
|
||||
entityName: `${user.name} (${user.email})`,
|
||||
action: "CREATE",
|
||||
...withAuditUser(ctx.dbUser?.id),
|
||||
after: user as unknown as Record<string, unknown>,
|
||||
source: "ui",
|
||||
});
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function setUserPassword(
|
||||
ctx: UserMutationContext,
|
||||
input: z.infer<typeof SetUserPasswordInputSchema>,
|
||||
) {
|
||||
const user = await findUniqueOrThrow(
|
||||
ctx.db.user.findUnique({
|
||||
where: { id: input.userId },
|
||||
select: { id: true, name: true, email: true },
|
||||
}),
|
||||
"User",
|
||||
);
|
||||
|
||||
const { hash } = await import("@node-rs/argon2");
|
||||
const passwordHash = await hash(input.password);
|
||||
|
||||
await ctx.db.user.update({
|
||||
where: { id: input.userId },
|
||||
data: { passwordHash },
|
||||
});
|
||||
|
||||
void createAuditEntry({
|
||||
db: ctx.db,
|
||||
entityType: "User",
|
||||
entityId: user.id,
|
||||
entityName: `${user.name} (${user.email})`,
|
||||
action: "UPDATE",
|
||||
...withAuditUser(ctx.dbUser?.id),
|
||||
source: "ui",
|
||||
summary: "Password reset by admin",
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function updateUserRole(
|
||||
ctx: UserMutationContext,
|
||||
input: z.infer<typeof UpdateUserRoleInputSchema>,
|
||||
) {
|
||||
const before = await findUniqueOrThrow(
|
||||
ctx.db.user.findUnique({
|
||||
where: { id: input.id },
|
||||
select: { id: true, name: true, email: true, systemRole: true },
|
||||
}),
|
||||
"User",
|
||||
);
|
||||
|
||||
const updated = await ctx.db.user.update({
|
||||
where: { id: input.id },
|
||||
data: { systemRole: input.systemRole },
|
||||
select: { id: true, name: true, email: true, systemRole: true },
|
||||
});
|
||||
|
||||
void createAuditEntry({
|
||||
db: ctx.db,
|
||||
entityType: "User",
|
||||
entityId: updated.id,
|
||||
entityName: `${updated.name} (${updated.email})`,
|
||||
action: "UPDATE",
|
||||
...withAuditUser(ctx.dbUser?.id),
|
||||
before: before as unknown as Record<string, unknown>,
|
||||
after: updated as unknown as Record<string, unknown>,
|
||||
source: "ui",
|
||||
summary: `Changed role from ${before.systemRole} to ${updated.systemRole}`,
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function updateUserName(
|
||||
ctx: UserMutationContext,
|
||||
input: z.infer<typeof UpdateUserNameInputSchema>,
|
||||
) {
|
||||
const before = await findUniqueOrThrow(
|
||||
ctx.db.user.findUnique({
|
||||
where: { id: input.id },
|
||||
select: { id: true, name: true, email: true },
|
||||
}),
|
||||
"User",
|
||||
);
|
||||
|
||||
const updated = await ctx.db.user.update({
|
||||
where: { id: input.id },
|
||||
data: { name: input.name },
|
||||
select: { id: true, name: true, email: true },
|
||||
});
|
||||
|
||||
void createAuditEntry({
|
||||
db: ctx.db,
|
||||
entityType: "User",
|
||||
entityId: updated.id,
|
||||
entityName: `${updated.name} (${updated.email})`,
|
||||
action: "UPDATE",
|
||||
...withAuditUser(ctx.dbUser?.id),
|
||||
before: before as unknown as Record<string, unknown>,
|
||||
after: updated as unknown as Record<string, unknown>,
|
||||
source: "ui",
|
||||
summary: `Changed name from "${before.name}" to "${updated.name}"`,
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function linkUserResource(
|
||||
ctx: UserMutationContext,
|
||||
input: z.infer<typeof LinkUserResourceInputSchema>,
|
||||
) {
|
||||
await findUniqueOrThrow(
|
||||
ctx.db.user.findUnique({
|
||||
where: { id: input.userId },
|
||||
select: { id: true },
|
||||
}),
|
||||
"User",
|
||||
);
|
||||
|
||||
if (input.resourceId) {
|
||||
const resource = await findUniqueOrThrow(
|
||||
ctx.db.resource.findUnique({
|
||||
where: { id: input.resourceId },
|
||||
select: { id: true, userId: true },
|
||||
}),
|
||||
"Resource",
|
||||
);
|
||||
|
||||
if (resource.userId && resource.userId !== input.userId) {
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: "Resource is already linked to another user",
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.db.resource.updateMany({
|
||||
where: {
|
||||
userId: input.userId,
|
||||
NOT: { id: input.resourceId },
|
||||
},
|
||||
data: { userId: null },
|
||||
});
|
||||
|
||||
const linkResult = await ctx.db.resource.updateMany({
|
||||
where: {
|
||||
id: input.resourceId,
|
||||
OR: [
|
||||
{ userId: null },
|
||||
{ userId: input.userId },
|
||||
],
|
||||
},
|
||||
data: { userId: input.userId },
|
||||
});
|
||||
|
||||
if (linkResult.count !== 1) {
|
||||
const [userStillExists, resourceStillExists] = await Promise.all([
|
||||
ctx.db.user.findUnique({
|
||||
where: { id: input.userId },
|
||||
select: { id: true },
|
||||
}),
|
||||
ctx.db.resource.findUnique({
|
||||
where: { id: input.resourceId },
|
||||
select: { id: true, userId: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!userStillExists) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
if (!resourceStillExists) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Resource not found",
|
||||
});
|
||||
}
|
||||
|
||||
if (resourceStillExists.userId && resourceStillExists.userId !== input.userId) {
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: "Resource is already linked to another user",
|
||||
});
|
||||
}
|
||||
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: "Resource link changed during update. Please retry.",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await ctx.db.resource.updateMany({
|
||||
where: { userId: input.userId },
|
||||
data: { userId: null },
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function autoLinkUsersByEmail(ctx: UserMutationContext) {
|
||||
const unlinkedUsers = await ctx.db.user.findMany({
|
||||
where: { resource: null },
|
||||
select: { id: true, email: true },
|
||||
});
|
||||
|
||||
let linked = 0;
|
||||
for (const user of unlinkedUsers) {
|
||||
const resource = await ctx.db.resource.findFirst({
|
||||
where: { email: user.email, userId: null },
|
||||
select: { id: true },
|
||||
});
|
||||
if (resource) {
|
||||
await ctx.db.resource.update({
|
||||
where: { id: resource.id },
|
||||
data: { userId: user.id },
|
||||
});
|
||||
linked++;
|
||||
}
|
||||
}
|
||||
|
||||
return { linked, checked: unlinkedUsers.length };
|
||||
}
|
||||
|
||||
export async function setUserPermissions(
|
||||
ctx: UserMutationContext,
|
||||
input: z.infer<typeof SetUserPermissionsInputSchema>,
|
||||
) {
|
||||
const before = await findUniqueOrThrow(
|
||||
ctx.db.user.findUnique({
|
||||
where: { id: input.userId },
|
||||
select: { id: true, name: true, email: true, permissionOverrides: true },
|
||||
}),
|
||||
"User",
|
||||
);
|
||||
|
||||
const user = await ctx.db.user.update({
|
||||
where: { id: input.userId },
|
||||
data: { permissionOverrides: input.overrides ?? Prisma.DbNull },
|
||||
});
|
||||
|
||||
void createAuditEntry({
|
||||
db: ctx.db,
|
||||
entityType: "User",
|
||||
entityId: input.userId,
|
||||
entityName: `${before.name} (${before.email})`,
|
||||
action: "UPDATE",
|
||||
...withAuditUser(ctx.dbUser?.id),
|
||||
before: { permissionOverrides: before.permissionOverrides } as unknown as Record<string, unknown>,
|
||||
after: { permissionOverrides: input.overrides } as unknown as Record<string, unknown>,
|
||||
source: "ui",
|
||||
summary: input.overrides
|
||||
? `Set permission overrides (granted: ${input.overrides.granted?.length ?? 0}, denied: ${input.overrides.denied?.length ?? 0})`
|
||||
: "Cleared permission overrides",
|
||||
});
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function resetUserPermissions(
|
||||
ctx: UserMutationContext,
|
||||
input: z.infer<typeof UserIdInputSchema>,
|
||||
) {
|
||||
const before = await findUniqueOrThrow(
|
||||
ctx.db.user.findUnique({
|
||||
where: { id: input.userId },
|
||||
select: { id: true, name: true, email: true, permissionOverrides: true },
|
||||
}),
|
||||
"User",
|
||||
);
|
||||
|
||||
const updated = await ctx.db.user.update({
|
||||
where: { id: input.userId },
|
||||
data: { permissionOverrides: Prisma.DbNull },
|
||||
});
|
||||
|
||||
void createAuditEntry({
|
||||
db: ctx.db,
|
||||
entityType: "User",
|
||||
entityId: input.userId,
|
||||
entityName: `${before.name} (${before.email})`,
|
||||
action: "UPDATE",
|
||||
...withAuditUser(ctx.dbUser?.id),
|
||||
before: { permissionOverrides: before.permissionOverrides } as unknown as Record<string, unknown>,
|
||||
after: { permissionOverrides: null } as unknown as Record<string, unknown>,
|
||||
source: "ui",
|
||||
summary: "Reset permission overrides to role defaults",
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function getEffectiveUserPermissions(
|
||||
ctx: UserMutationContext,
|
||||
input: z.infer<typeof UserIdInputSchema>,
|
||||
) {
|
||||
const user = await findUniqueOrThrow(
|
||||
ctx.db.user.findUnique({
|
||||
where: { id: input.userId },
|
||||
select: { systemRole: true, permissionOverrides: true },
|
||||
}),
|
||||
"User",
|
||||
);
|
||||
const permissions = resolvePermissions(
|
||||
user.systemRole as SystemRole,
|
||||
user.permissionOverrides as PermissionOverrides | null,
|
||||
);
|
||||
|
||||
return {
|
||||
systemRole: user.systemRole,
|
||||
effectivePermissions: Array.from(permissions),
|
||||
overrides: user.permissionOverrides as PermissionOverrides | null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function disableTotp(
|
||||
ctx: UserMutationContext,
|
||||
input: z.infer<typeof UserIdInputSchema>,
|
||||
) {
|
||||
const user = await findUniqueOrThrow(
|
||||
ctx.db.user.findUnique({
|
||||
where: { id: input.userId },
|
||||
select: { id: true, name: true, email: true, totpEnabled: true },
|
||||
}),
|
||||
"User",
|
||||
);
|
||||
|
||||
await ctx.db.user.update({
|
||||
where: { id: input.userId },
|
||||
data: { totpEnabled: false, totpSecret: null },
|
||||
});
|
||||
|
||||
void createAuditEntry({
|
||||
db: ctx.db,
|
||||
entityType: "User",
|
||||
entityId: user.id,
|
||||
entityName: `${user.name} (${user.email})`,
|
||||
action: "UPDATE",
|
||||
...withAuditUser(ctx.dbUser?.id),
|
||||
source: "ui",
|
||||
summary: "Disabled TOTP MFA (admin override)",
|
||||
});
|
||||
|
||||
return { disabled: true };
|
||||
}
|
||||
Reference in New Issue
Block a user