From c1c4ec798831f16663dd2ac439056d6ccf90ad83 Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Thu, 22 Jan 2026 16:26:30 +0200 Subject: [PATCH 1/3] feat(profile): add Workspace Photos GraphQL API endpoints - Add userWorkspacePhotos query for fetching user's workspace photos - Add addUserWorkspacePhoto mutation with 5 photo limit - Add deleteUserWorkspacePhoto mutation - Add reorderUserWorkspacePhotos mutation - Add WorkspacePhoto upload preset to cloudinary - Register UserWorkspacePhoto in graphorm Co-Authored-By: Claude Opus 4.5 --- __tests__/userWorkspacePhoto.ts | 232 ++++++++++++++++++++++++ src/common/cloudinary.ts | 1 + src/common/schema/userWorkspacePhoto.ts | 20 ++ src/graphorm/index.ts | 8 + src/graphql.ts | 3 + src/schema/userWorkspacePhoto.ts | 207 +++++++++++++++++++++ 6 files changed, 471 insertions(+) create mode 100644 __tests__/userWorkspacePhoto.ts create mode 100644 src/common/schema/userWorkspacePhoto.ts create mode 100644 src/schema/userWorkspacePhoto.ts diff --git a/__tests__/userWorkspacePhoto.ts b/__tests__/userWorkspacePhoto.ts new file mode 100644 index 0000000000..1eb0dd5db2 --- /dev/null +++ b/__tests__/userWorkspacePhoto.ts @@ -0,0 +1,232 @@ +import { DataSource } from 'typeorm'; +import createOrGetConnection from '../src/db'; +import { + disposeGraphQLTesting, + GraphQLTestClient, + GraphQLTestingState, + initializeGraphQLTesting, + MockContext, + saveFixtures, +} from './helpers'; +import { User } from '../src/entity/user/User'; +import { usersFixture } from './fixture/user'; +import { UserWorkspacePhoto } from '../src/entity/user/UserWorkspacePhoto'; + +let con: DataSource; +let state: GraphQLTestingState; +let client: GraphQLTestClient; +let loggedUser: string | null = null; + +beforeAll(async () => { + con = await createOrGetConnection(); + state = await initializeGraphQLTesting( + () => new MockContext(con, loggedUser), + ); + client = state.client; +}); + +afterAll(() => disposeGraphQLTesting(state)); + +beforeEach(async () => { + loggedUser = null; + await saveFixtures(con, User, usersFixture); +}); + +describe('query userWorkspacePhotos', () => { + const QUERY = ` + query UserWorkspacePhotos($userId: ID!) { + userWorkspacePhotos(userId: $userId) { + edges { + node { + id + image + position + } + } + } + } + `; + + it('should return empty list for user with no photos', async () => { + const res = await client.query(QUERY, { variables: { userId: '1' } }); + expect(res.data.userWorkspacePhotos.edges).toEqual([]); + }); + + it('should return photos ordered by position', async () => { + await con.getRepository(UserWorkspacePhoto).save([ + { userId: '1', image: 'https://example.com/photo1.jpg', position: 1 }, + { userId: '1', image: 'https://example.com/photo2.jpg', position: 0 }, + ]); + + const res = await client.query(QUERY, { variables: { userId: '1' } }); + expect(res.data.userWorkspacePhotos.edges).toHaveLength(2); + expect(res.data.userWorkspacePhotos.edges[0].node.image).toBe( + 'https://example.com/photo2.jpg', + ); + expect(res.data.userWorkspacePhotos.edges[1].node.image).toBe( + 'https://example.com/photo1.jpg', + ); + }); +}); + +describe('mutation addUserWorkspacePhoto', () => { + const MUTATION = ` + mutation AddUserWorkspacePhoto($input: AddUserWorkspacePhotoInput!) { + addUserWorkspacePhoto(input: $input) { + id + image + position + } + } + `; + + it('should require authentication', async () => { + const res = await client.mutate(MUTATION, { + variables: { input: { image: 'https://example.com/photo.jpg' } }, + }); + expect(res.errors?.[0]?.extensions?.code).toBe('UNAUTHENTICATED'); + }); + + it('should create workspace photo', async () => { + loggedUser = '1'; + const res = await client.mutate(MUTATION, { + variables: { input: { image: 'https://example.com/photo.jpg' } }, + }); + + expect(res.errors).toBeUndefined(); + expect(res.data.addUserWorkspacePhoto).toMatchObject({ + image: 'https://example.com/photo.jpg', + }); + }); + + it('should enforce maximum of 5 photos', async () => { + loggedUser = '1'; + await con.getRepository(UserWorkspacePhoto).save([ + { userId: '1', image: 'https://example.com/photo1.jpg', position: 0 }, + { userId: '1', image: 'https://example.com/photo2.jpg', position: 1 }, + { userId: '1', image: 'https://example.com/photo3.jpg', position: 2 }, + { userId: '1', image: 'https://example.com/photo4.jpg', position: 3 }, + { userId: '1', image: 'https://example.com/photo5.jpg', position: 4 }, + ]); + + const res = await client.mutate(MUTATION, { + variables: { input: { image: 'https://example.com/photo6.jpg' } }, + }); + + expect(res.errors?.[0]?.message).toBe( + 'Maximum of 5 workspace photos allowed', + ); + }); +}); + +describe('mutation deleteUserWorkspacePhoto', () => { + const MUTATION = ` + mutation DeleteUserWorkspacePhoto($id: ID!) { + deleteUserWorkspacePhoto(id: $id) { + _ + } + } + `; + + it('should require authentication', async () => { + const res = await client.mutate(MUTATION, { + variables: { id: '00000000-0000-0000-0000-000000000000' }, + }); + expect(res.errors?.[0]?.extensions?.code).toBe('UNAUTHENTICATED'); + }); + + it('should delete photo', async () => { + loggedUser = '1'; + const photo = await con.getRepository(UserWorkspacePhoto).save({ + userId: '1', + image: 'https://example.com/photo.jpg', + position: 0, + }); + + await client.mutate(MUTATION, { variables: { id: photo.id } }); + + const deleted = await con + .getRepository(UserWorkspacePhoto) + .findOneBy({ id: photo.id }); + expect(deleted).toBeNull(); + }); + + it('should not delete other user photo', async () => { + loggedUser = '1'; + const photo = await con.getRepository(UserWorkspacePhoto).save({ + userId: '2', + image: 'https://example.com/photo.jpg', + position: 0, + }); + + await client.mutate(MUTATION, { variables: { id: photo.id } }); + + const notDeleted = await con + .getRepository(UserWorkspacePhoto) + .findOneBy({ id: photo.id }); + expect(notDeleted).not.toBeNull(); + }); +}); + +describe('mutation reorderUserWorkspacePhotos', () => { + const MUTATION = ` + mutation ReorderUserWorkspacePhotos($items: [ReorderUserWorkspacePhotoInput!]!) { + reorderUserWorkspacePhotos(items: $items) { + id + position + } + } + `; + + it('should require authentication', async () => { + const res = await client.mutate(MUTATION, { + variables: { items: [] }, + }); + expect(res.errors?.[0]?.extensions?.code).toBe('UNAUTHENTICATED'); + }); + + it('should update positions', async () => { + loggedUser = '1'; + const [item1, item2] = await con.getRepository(UserWorkspacePhoto).save([ + { userId: '1', image: 'https://example.com/photo1.jpg', position: 0 }, + { userId: '1', image: 'https://example.com/photo2.jpg', position: 1 }, + ]); + + const res = await client.mutate(MUTATION, { + variables: { + items: [ + { id: item1.id, position: 1 }, + { id: item2.id, position: 0 }, + ], + }, + }); + + const reordered = res.data.reorderUserWorkspacePhotos; + expect( + reordered.find((i: { id: string }) => i.id === item1.id).position, + ).toBe(1); + expect( + reordered.find((i: { id: string }) => i.id === item2.id).position, + ).toBe(0); + }); + + it('should not reorder other user photos', async () => { + loggedUser = '1'; + const otherUserPhoto = await con.getRepository(UserWorkspacePhoto).save({ + userId: '2', + image: 'https://example.com/photo.jpg', + position: 0, + }); + + await client.mutate(MUTATION, { + variables: { + items: [{ id: otherUserPhoto.id, position: 5 }], + }, + }); + + const notUpdated = await con + .getRepository(UserWorkspacePhoto) + .findOneBy({ id: otherUserPhoto.id }); + expect(notUpdated?.position).toBe(0); + }); +}); diff --git a/src/common/cloudinary.ts b/src/common/cloudinary.ts index e5a519e6d1..5d6c1f2423 100644 --- a/src/common/cloudinary.ts +++ b/src/common/cloudinary.ts @@ -35,6 +35,7 @@ export enum UploadPreset { TopReaderBadge = 'top_reader_badge', Organization = 'organization', ToolIcon = 'tool_icon', + WorkspacePhoto = 'workspace_photo', } interface OptionalProps { diff --git a/src/common/schema/userWorkspacePhoto.ts b/src/common/schema/userWorkspacePhoto.ts new file mode 100644 index 0000000000..bde19e675f --- /dev/null +++ b/src/common/schema/userWorkspacePhoto.ts @@ -0,0 +1,20 @@ +import z from 'zod'; + +export const addUserWorkspacePhotoSchema = z.object({ + image: z.url(), +}); + +export type AddUserWorkspacePhotoInput = z.infer< + typeof addUserWorkspacePhotoSchema +>; + +export const reorderUserWorkspacePhotoSchema = z.array( + z.object({ + id: z.uuid(), + position: z.number().int().min(0), + }), +); + +export type ReorderUserWorkspacePhotoInput = z.infer< + typeof reorderUserWorkspacePhotoSchema +>[number]; diff --git a/src/graphorm/index.ts b/src/graphorm/index.ts index 177a613b4f..0f3538d1ab 100644 --- a/src/graphorm/index.ts +++ b/src/graphorm/index.ts @@ -2178,6 +2178,14 @@ const obj = new GraphORM({ }, }, }, + UserWorkspacePhoto: { + requiredColumns: ['id', 'userId', 'image'], + fields: { + createdAt: { + transform: transformDate, + }, + }, + }, }); export default obj; diff --git a/src/graphql.ts b/src/graphql.ts index b613f18f36..6bf31d1c05 100644 --- a/src/graphql.ts +++ b/src/graphql.ts @@ -36,6 +36,7 @@ import * as profile from './schema/profile'; import * as userStack from './schema/userStack'; import * as userHotTake from './schema/userHotTake'; import * as userTool from './schema/userTool'; +import * as userWorkspacePhoto from './schema/userWorkspacePhoto'; import { makeExecutableSchema } from '@graphql-tools/schema'; import { rateLimitTypeDefs, @@ -88,6 +89,7 @@ export const schema = urlDirective.transformer( userStack.typeDefs, userHotTake.typeDefs, userTool.typeDefs, + userWorkspacePhoto.typeDefs, ], resolvers: merge( common.resolvers, @@ -123,6 +125,7 @@ export const schema = urlDirective.transformer( userStack.resolvers, userHotTake.resolvers, userTool.resolvers, + userWorkspacePhoto.resolvers, ), }), ), diff --git a/src/schema/userWorkspacePhoto.ts b/src/schema/userWorkspacePhoto.ts new file mode 100644 index 0000000000..f43dda6e58 --- /dev/null +++ b/src/schema/userWorkspacePhoto.ts @@ -0,0 +1,207 @@ +import { IResolvers } from '@graphql-tools/utils'; +import { traceResolvers } from './trace'; +import { AuthContext, BaseContext, Context } from '../Context'; +import graphorm from '../graphorm'; +import { offsetPageGenerator, GQLEmptyResponse } from './common'; +import { UserWorkspacePhoto } from '../entity/user/UserWorkspacePhoto'; +import { ValidationError } from 'apollo-server-errors'; +import { + addUserWorkspacePhotoSchema, + reorderUserWorkspacePhotoSchema, + type AddUserWorkspacePhotoInput, + type ReorderUserWorkspacePhotoInput, +} from '../common/schema/userWorkspacePhoto'; +import { NEW_ITEM_POSITION } from '../common/constants'; + +interface GQLUserWorkspacePhoto { + id: string; + userId: string; + image: string; + position: number; + createdAt: Date; +} + +const MAX_WORKSPACE_PHOTOS = 5; + +export const typeDefs = /* GraphQL */ ` + type UserWorkspacePhoto { + id: ID! + image: String! + position: Int! + createdAt: DateTime! + } + + type UserWorkspacePhotoEdge { + node: UserWorkspacePhoto! + cursor: String! + } + + type UserWorkspacePhotoConnection { + pageInfo: PageInfo! + edges: [UserWorkspacePhotoEdge!]! + } + + input AddUserWorkspacePhotoInput { + image: String! + } + + input ReorderUserWorkspacePhotoInput { + id: ID! + position: Int! + } + + extend type Query { + """ + Get a user's workspace photos + """ + userWorkspacePhotos( + userId: ID! + first: Int + after: String + ): UserWorkspacePhotoConnection! + } + + extend type Mutation { + """ + Add a workspace photo to the user's profile (max 5) + """ + addUserWorkspacePhoto( + input: AddUserWorkspacePhotoInput! + ): UserWorkspacePhoto! @auth + + """ + Delete a user's workspace photo + """ + deleteUserWorkspacePhoto(id: ID!): EmptyResponse! @auth + + """ + Reorder user's workspace photos + """ + reorderUserWorkspacePhotos( + items: [ReorderUserWorkspacePhotoInput!]! + ): [UserWorkspacePhoto!]! @auth + } +`; + +export const resolvers: IResolvers = traceResolvers< + unknown, + BaseContext +>({ + Query: { + userWorkspacePhotos: async ( + _, + args: { userId: string; first?: number; after?: string }, + ctx: Context, + info, + ) => { + const pageGenerator = offsetPageGenerator(50, 100); + const page = pageGenerator.connArgsToPage({ + first: args.first, + after: args.after, + }); + + return graphorm.queryPaginated( + ctx, + info, + (nodeSize) => pageGenerator.hasPreviousPage(page, nodeSize), + (nodeSize) => pageGenerator.hasNextPage(page, nodeSize), + (node, index) => + pageGenerator.nodeToCursor(page, { first: args.first }, node, index), + (builder) => { + builder.queryBuilder + .where(`"${builder.alias}"."userId" = :userId`, { + userId: args.userId, + }) + .orderBy(`"${builder.alias}"."position"`, 'ASC') + .addOrderBy(`"${builder.alias}"."createdAt"`, 'ASC') + .limit(page.limit) + .offset(page.offset); + return builder; + }, + undefined, + true, + ); + }, + }, + + Mutation: { + addUserWorkspacePhoto: async ( + _, + args: { input: AddUserWorkspacePhotoInput }, + ctx: AuthContext, + info, + ) => { + const input = addUserWorkspacePhotoSchema.parse(args.input); + + const count = await ctx.con.getRepository(UserWorkspacePhoto).count({ + where: { userId: ctx.userId }, + }); + + if (count >= MAX_WORKSPACE_PHOTOS) { + throw new ValidationError( + `Maximum of ${MAX_WORKSPACE_PHOTOS} workspace photos allowed`, + ); + } + + const photo = ctx.con.getRepository(UserWorkspacePhoto).create({ + userId: ctx.userId, + image: input.image, + position: NEW_ITEM_POSITION, + }); + + await ctx.con.getRepository(UserWorkspacePhoto).save(photo); + + return graphorm.queryOneOrFail(ctx, info, (builder) => { + builder.queryBuilder.where(`"${builder.alias}"."id" = :id`, { + id: photo.id, + }); + return builder; + }); + }, + + deleteUserWorkspacePhoto: async ( + _, + args: { id: string }, + ctx: AuthContext, + ): Promise => { + await ctx.con + .getRepository(UserWorkspacePhoto) + .delete({ id: args.id, userId: ctx.userId }); + + return { _: true }; + }, + + reorderUserWorkspacePhotos: async ( + _, + args: { items: ReorderUserWorkspacePhotoInput[] }, + ctx: AuthContext, + info, + ) => { + const items = reorderUserWorkspacePhotoSchema.parse(args.items); + const ids = items.map((i) => i.id); + + const whenClauses = items + .map((item) => `WHEN id = '${item.id}' THEN ${item.position}`) + .join(' '); + + await ctx.con + .getRepository(UserWorkspacePhoto) + .createQueryBuilder() + .update() + .set({ position: () => `CASE ${whenClauses} ELSE position END` }) + .where('id IN (:...ids)', { ids }) + .andWhere('"userId" = :userId', { userId: ctx.userId }) + .execute(); + + return graphorm.query(ctx, info, (builder) => { + builder.queryBuilder + .where(`"${builder.alias}"."id" IN (:...ids)`, { ids }) + .andWhere(`"${builder.alias}"."userId" = :userId`, { + userId: ctx.userId, + }) + .orderBy(`"${builder.alias}"."position"`, 'ASC'); + return builder; + }); + }, + }, +}); From a46c190056ae29288ad329243593530f4e03557d Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Fri, 23 Jan 2026 09:24:29 +0200 Subject: [PATCH 2/3] fix: tests --- __tests__/userWorkspacePhoto.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/__tests__/userWorkspacePhoto.ts b/__tests__/userWorkspacePhoto.ts index c86b7af750..272c64b2a5 100644 --- a/__tests__/userWorkspacePhoto.ts +++ b/__tests__/userWorkspacePhoto.ts @@ -144,7 +144,6 @@ describe('mutation deleteUserWorkspacePhoto', () => { } `; - it('should delete photo and associated ContentImage', async () => { it('should require authentication', async () => { const res = await client.mutate(MUTATION, { variables: { id: '00000000-0000-0000-0000-000000000000' }, @@ -235,3 +234,4 @@ describe('mutation reorderUserWorkspacePhotos', () => { ).toBe(0); }); }); + From 6fd8154e96482199389312e3978973075cf84d94 Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Fri, 23 Jan 2026 09:29:10 +0200 Subject: [PATCH 3/3] fix: lint --- __tests__/userWorkspacePhoto.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/__tests__/userWorkspacePhoto.ts b/__tests__/userWorkspacePhoto.ts index 272c64b2a5..ecad3ba39e 100644 --- a/__tests__/userWorkspacePhoto.ts +++ b/__tests__/userWorkspacePhoto.ts @@ -234,4 +234,3 @@ describe('mutation reorderUserWorkspacePhotos', () => { ).toBe(0); }); }); -