Files
sexy/packages/backend/src/graphql/resolvers/users.ts
Sebastian Krüger 670c18bcb7 feat: refactor role system to is_admin flag, add Badge component, fix native dialogs
- Separate admin identity from role: viewer|model + is_admin boolean flag
- DB migration 0001_is_admin: adds column, migrates former admin role users
- Update ACL helpers, auth session, GraphQL types and all resolvers
- Admin layout guard and header links check is_admin instead of role
- Admin users table: show Admin badge next to name, remove admin from role select
- Admin user edit page: is_admin checkbox toggle
- Install shadcn Badge component; use in admin users table
- Fix duplicate photo keys in adminGetUser resolver
- Replace confirm() in /me recordings with Dialog component

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 16:14:00 +01:00

233 lines
7.2 KiB
TypeScript

import { GraphQLError } from "graphql";
import { builder } from "../builder";
import { CurrentUserType, UserType, AdminUserListType, AdminUserDetailType } from "../types/index";
import { users, user_photos, files } from "../../db/schema/index";
import { eq, ilike, or, count, and } from "drizzle-orm";
import { requireAdmin } from "../../lib/acl";
builder.queryField("me", (t) =>
t.field({
type: CurrentUserType,
nullable: true,
resolve: async (_root, _args, ctx) => {
if (!ctx.currentUser) return null;
const user = await ctx.db
.select()
.from(users)
.where(eq(users.id, ctx.currentUser.id))
.limit(1);
return user[0] || null;
},
}),
);
builder.queryField("userProfile", (t) =>
t.field({
type: UserType,
nullable: true,
args: {
id: t.arg.string({ required: true }),
},
resolve: async (_root, args, ctx) => {
const user = await ctx.db.select().from(users).where(eq(users.id, args.id)).limit(1);
return user[0] || null;
},
}),
);
builder.mutationField("updateProfile", (t) =>
t.field({
type: CurrentUserType,
nullable: true,
args: {
firstName: t.arg.string(),
lastName: t.arg.string(),
artistName: t.arg.string(),
description: t.arg.string(),
tags: t.arg.stringList(),
},
resolve: async (_root, args, ctx) => {
if (!ctx.currentUser) throw new GraphQLError("Unauthorized");
const updates: Record<string, unknown> = { date_updated: new Date() };
if (args.firstName !== undefined && args.firstName !== null)
updates.first_name = args.firstName;
if (args.lastName !== undefined && args.lastName !== null) updates.last_name = args.lastName;
if (args.artistName !== undefined && args.artistName !== null)
updates.artist_name = args.artistName;
if (args.description !== undefined && args.description !== null)
updates.description = args.description;
if (args.tags !== undefined && args.tags !== null) updates.tags = args.tags;
await ctx.db
.update(users)
.set(updates as any)
.where(eq(users.id, ctx.currentUser.id));
const updated = await ctx.db
.select()
.from(users)
.where(eq(users.id, ctx.currentUser.id))
.limit(1);
return updated[0] || null;
},
}),
);
// ─── Admin queries & mutations ────────────────────────────────────────────────
builder.queryField("adminListUsers", (t) =>
t.field({
type: AdminUserListType,
args: {
role: t.arg.string(),
search: t.arg.string(),
limit: t.arg.int(),
offset: t.arg.int(),
},
resolve: async (_root, args, ctx) => {
requireAdmin(ctx);
const limit = args.limit ?? 50;
const offset = args.offset ?? 0;
let query = ctx.db.select().from(users);
let countQuery = ctx.db.select({ total: count() }).from(users);
const conditions: any[] = [];
if (args.role) {
conditions.push(eq(users.role, args.role as any));
}
if (args.search) {
const pattern = `%${args.search}%`;
conditions.push(or(ilike(users.email, pattern), ilike(users.artist_name, pattern)));
}
if (conditions.length > 0) {
const where = conditions.length === 1 ? conditions[0] : and(...conditions);
query = (query as any).where(where);
countQuery = (countQuery as any).where(where);
}
const [items, totalRows] = await Promise.all([
(query as any).limit(limit).offset(offset),
countQuery,
]);
return { items, total: totalRows[0]?.total ?? 0 };
},
}),
);
builder.mutationField("adminUpdateUser", (t) =>
t.field({
type: UserType,
nullable: true,
args: {
userId: t.arg.string({ required: true }),
role: t.arg.string(),
isAdmin: t.arg.boolean(),
firstName: t.arg.string(),
lastName: t.arg.string(),
artistName: t.arg.string(),
avatarId: t.arg.string(),
bannerId: t.arg.string(),
},
resolve: async (_root, args, ctx) => {
requireAdmin(ctx);
const updates: Record<string, unknown> = { date_updated: new Date() };
if (args.role !== undefined && args.role !== null) updates.role = args.role as any;
if (args.isAdmin !== undefined && args.isAdmin !== null) updates.is_admin = args.isAdmin;
if (args.firstName !== undefined && args.firstName !== null)
updates.first_name = args.firstName;
if (args.lastName !== undefined && args.lastName !== null) updates.last_name = args.lastName;
if (args.artistName !== undefined && args.artistName !== null)
updates.artist_name = args.artistName;
if (args.avatarId !== undefined && args.avatarId !== null) updates.avatar = args.avatarId;
if (args.bannerId !== undefined && args.bannerId !== null) updates.banner = args.bannerId;
const updated = await ctx.db
.update(users)
.set(updates as any)
.where(eq(users.id, args.userId))
.returning();
return updated[0] || null;
},
}),
);
builder.mutationField("adminDeleteUser", (t) =>
t.field({
type: "Boolean",
args: {
userId: t.arg.string({ required: true }),
},
resolve: async (_root, args, ctx) => {
requireAdmin(ctx);
if (args.userId === ctx.currentUser!.id) throw new GraphQLError("Cannot delete yourself");
await ctx.db.delete(users).where(eq(users.id, args.userId));
return true;
},
}),
);
builder.queryField("adminGetUser", (t) =>
t.field({
type: AdminUserDetailType,
nullable: true,
args: {
userId: t.arg.string({ required: true }),
},
resolve: async (_root, args, ctx) => {
requireAdmin(ctx);
const user = await ctx.db.select().from(users).where(eq(users.id, args.userId)).limit(1);
if (!user[0]) return null;
const photoRows = await ctx.db
.select({ id: files.id, filename: files.filename })
.from(user_photos)
.leftJoin(files, eq(user_photos.file_id, files.id))
.where(eq(user_photos.user_id, args.userId))
.orderBy(user_photos.sort);
const seen = new Set<string>();
const photos = photoRows
.filter((p: any) => p.id && !seen.has(p.id) && seen.add(p.id))
.map((p: any) => ({ id: p.id, filename: p.filename }));
return { ...user[0], photos };
},
}),
);
builder.mutationField("adminAddUserPhoto", (t) =>
t.field({
type: "Boolean",
args: {
userId: t.arg.string({ required: true }),
fileId: t.arg.string({ required: true }),
},
resolve: async (_root, args, ctx) => {
requireAdmin(ctx);
await ctx.db.insert(user_photos).values({ user_id: args.userId, file_id: args.fileId });
return true;
},
}),
);
builder.mutationField("adminRemoveUserPhoto", (t) =>
t.field({
type: "Boolean",
args: {
userId: t.arg.string({ required: true }),
fileId: t.arg.string({ required: true }),
},
resolve: async (_root, args, ctx) => {
requireAdmin(ctx);
await ctx.db
.delete(user_photos)
.where(and(eq(user_photos.user_id, args.userId), eq(user_photos.file_id, args.fileId)));
return true;
},
}),
);