diff --git a/packages/backend/src/graphql/resolvers/users.ts b/packages/backend/src/graphql/resolvers/users.ts index 71e182a..20b89b8 100644 --- a/packages/backend/src/graphql/resolvers/users.ts +++ b/packages/backend/src/graphql/resolvers/users.ts @@ -1,7 +1,7 @@ import { GraphQLError } from "graphql"; import { builder } from "../builder"; -import { CurrentUserType, UserType, AdminUserListType } from "../types/index"; -import { users } from "../../db/schema/index"; +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 { requireRole } from "../../lib/acl"; @@ -129,6 +129,8 @@ builder.mutationField("adminUpdateUser", (t) => 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) => { requireRole(ctx, "admin"); @@ -140,6 +142,8 @@ builder.mutationField("adminUpdateUser", (t) => 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) @@ -166,3 +170,60 @@ builder.mutationField("adminDeleteUser", (t) => }, }), ); + +builder.queryField("adminGetUser", (t) => + t.field({ + type: AdminUserDetailType, + nullable: true, + args: { + userId: t.arg.string({ required: true }), + }, + resolve: async (_root, args, ctx) => { + requireRole(ctx, "admin"); + 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); + return { + ...user[0], + photos: photoRows.map((p: any) => ({ id: p.id, filename: p.filename })), + }; + }, + }), +); + +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) => { + requireRole(ctx, "admin"); + 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) => { + requireRole(ctx, "admin"); + await ctx.db + .delete(user_photos) + .where(and(eq(user_photos.user_id, args.userId), eq(user_photos.file_id, args.fileId))); + return true; + }, + }), +); diff --git a/packages/backend/src/graphql/types/index.ts b/packages/backend/src/graphql/types/index.ts index 34df3d9..d3c5a1a 100644 --- a/packages/backend/src/graphql/types/index.ts +++ b/packages/backend/src/graphql/types/index.ts @@ -24,6 +24,8 @@ import type { UserGamification, Achievement, } from "@sexy.pivoine.art/types"; + +type AdminUserDetail = User & { photos: ModelPhoto[] }; import { builder } from "../builder"; export const FileType = builder.objectRef("File").implement({ @@ -343,3 +345,24 @@ export const AdminUserListType = builder total: t.exposeInt("total"), }), }); + +export const AdminUserDetailType = builder + .objectRef("AdminUserDetail") + .implement({ + fields: (t) => ({ + id: t.exposeString("id"), + email: t.exposeString("email"), + first_name: t.exposeString("first_name", { nullable: true }), + last_name: t.exposeString("last_name", { nullable: true }), + artist_name: t.exposeString("artist_name", { nullable: true }), + slug: t.exposeString("slug", { nullable: true }), + description: t.exposeString("description", { nullable: true }), + tags: t.exposeStringList("tags", { nullable: true }), + role: t.exposeString("role"), + avatar: t.exposeString("avatar", { nullable: true }), + banner: t.exposeString("banner", { nullable: true }), + email_verified: t.exposeBoolean("email_verified"), + date_created: t.expose("date_created", { type: "DateTime" }), + photos: t.expose("photos", { type: [ModelPhotoType] }), + }), + }); diff --git a/packages/frontend/src/lib/services.ts b/packages/frontend/src/lib/services.ts index 117b502..5f6814f 100644 --- a/packages/frontend/src/lib/services.ts +++ b/packages/frontend/src/lib/services.ts @@ -1055,6 +1055,8 @@ const ADMIN_UPDATE_USER_MUTATION = gql` $firstName: String $lastName: String $artistName: String + $avatarId: String + $bannerId: String ) { adminUpdateUser( userId: $userId @@ -1062,6 +1064,8 @@ const ADMIN_UPDATE_USER_MUTATION = gql` firstName: $firstName lastName: $lastName artistName: $artistName + avatarId: $avatarId + bannerId: $bannerId ) { id email @@ -1070,6 +1074,7 @@ const ADMIN_UPDATE_USER_MUTATION = gql` artist_name role avatar + banner date_created } } @@ -1081,6 +1086,8 @@ export async function adminUpdateUser(input: { firstName?: string; lastName?: string; artistName?: string; + avatarId?: string; + bannerId?: string; }) { return loggedApiCall( "adminUpdateUser", @@ -1111,6 +1118,66 @@ export async function adminDeleteUser(userId: string) { ); } +const ADMIN_GET_USER_QUERY = gql` + query AdminGetUser($userId: String!) { + adminGetUser(userId: $userId) { + id + email + first_name + last_name + artist_name + slug + role + avatar + banner + description + tags + email_verified + date_created + photos { + id + filename + } + } + } +`; + +export async function adminGetUser(userId: string, token?: string) { + return loggedApiCall( + "adminGetUser", + async () => { + const client = token ? getAuthClient(token) : getGraphQLClient(); + const data = await client.request<{ adminGetUser: any }>(ADMIN_GET_USER_QUERY, { userId }); + return data.adminGetUser; + }, + { userId }, + ); +} + +const ADMIN_ADD_USER_PHOTO_MUTATION = gql` + mutation AdminAddUserPhoto($userId: String!, $fileId: String!) { + adminAddUserPhoto(userId: $userId, fileId: $fileId) + } +`; + +export async function adminAddUserPhoto(userId: string, fileId: string) { + return loggedApiCall("adminAddUserPhoto", async () => { + await getGraphQLClient().request(ADMIN_ADD_USER_PHOTO_MUTATION, { userId, fileId }); + }); +} + +const ADMIN_REMOVE_USER_PHOTO_MUTATION = gql` + mutation AdminRemoveUserPhoto($userId: String!, $fileId: String!) { + adminRemoveUserPhoto(userId: $userId, fileId: $fileId) + } +`; + +export async function adminRemoveUserPhoto(userId: string, fileId: string) { + return loggedApiCall("adminRemoveUserPhoto", async () => { + await getGraphQLClient().request(ADMIN_REMOVE_USER_PHOTO_MUTATION, { userId, fileId }); + }); +} + // ─── Admin: Videos ──────────────────────────────────────────────────────────── const ADMIN_LIST_VIDEOS_QUERY = gql` diff --git a/packages/frontend/src/routes/admin/users/+page.svelte b/packages/frontend/src/routes/admin/users/+page.svelte index 0979a09..83397ca 100644 --- a/packages/frontend/src/routes/admin/users/+page.svelte +++ b/packages/frontend/src/routes/admin/users/+page.svelte @@ -160,15 +160,20 @@ {formatDate(user.date_created)} - +
+ + +
{/each} diff --git a/packages/frontend/src/routes/admin/users/[id]/+page.server.ts b/packages/frontend/src/routes/admin/users/[id]/+page.server.ts new file mode 100644 index 0000000..068038d --- /dev/null +++ b/packages/frontend/src/routes/admin/users/[id]/+page.server.ts @@ -0,0 +1,9 @@ +import { adminGetUser } from "$lib/services"; +import { error } from "@sveltejs/kit"; + +export async function load({ params, cookies }) { + const token = cookies.get("session_token") || ""; + const user = await adminGetUser(params.id, token).catch(() => null); + if (!user) throw error(404, "User not found"); + return { user }; +} diff --git a/packages/frontend/src/routes/admin/users/[id]/+page.svelte b/packages/frontend/src/routes/admin/users/[id]/+page.svelte new file mode 100644 index 0000000..e6033da --- /dev/null +++ b/packages/frontend/src/routes/admin/users/[id]/+page.svelte @@ -0,0 +1,190 @@ + + +
+
+ +
+

{data.user.artist_name || data.user.email}

+

{data.user.email} · {data.user.role}

+
+
+ +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ + +
+ + {#if avatarId} + + {/if} + +
+ + +
+ + {#if bannerId} + + {/if} + +
+ +
+ +
+ + +
+ + + {#if data.user.photos && data.user.photos.length > 0} +
+ {#each data.user.photos as photo (photo.id)} +
+ + +
+ {/each} +
+ {:else} +

No photos yet.

+ {/if} + + +
+
+