From 8bc9ffc126925731569d42ff054d4ee127d2dfda Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 23 Jan 2026 17:44:48 +0000 Subject: [PATCH 1/2] feat(gear): implement GraphQL API endpoints for user gear - Add userGear query with pagination support - Add autocompleteGear query for gear suggestions - Add addUserGear mutation with find-or-create dataset pattern - Add deleteUserGear mutation for removing gear items - Add reorderUserGear mutation for position management - Add Zod validation schemas for inputs - Add findOrCreateDatasetGear helper with name normalization - Add graphorm mappings for UserGear and DatasetGear entities - Add comprehensive test coverage Implements ENG-391 --- __tests__/userGear.ts | 329 ++++++++++++++++++++++++++++++++++ src/common/datasetGear.ts | 34 ++++ src/common/schema/userGear.ts | 15 ++ src/graphorm/index.ts | 23 +++ src/graphql.ts | 3 + src/schema/userGear.ts | 251 ++++++++++++++++++++++++++ 6 files changed, 655 insertions(+) create mode 100644 __tests__/userGear.ts create mode 100644 src/common/datasetGear.ts create mode 100644 src/common/schema/userGear.ts create mode 100644 src/schema/userGear.ts diff --git a/__tests__/userGear.ts b/__tests__/userGear.ts new file mode 100644 index 0000000000..8b9707a1d5 --- /dev/null +++ b/__tests__/userGear.ts @@ -0,0 +1,329 @@ +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 { DatasetGear } from '../src/entity/dataset/DatasetGear'; +import { UserGear } from '../src/entity/user/UserGear'; + +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 userGear', () => { + const QUERY = ` + query UserGear($userId: ID!) { + userGear(userId: $userId) { + edges { + node { + id + position + gear { + id + name + } + } + } + } + } + `; + + it('should return empty list for user with no gear', async () => { + const res = await client.query(QUERY, { variables: { userId: '1' } }); + expect(res.data.userGear.edges).toEqual([]); + }); + + it('should return gear ordered by position', async () => { + const gear1 = await con.getRepository(DatasetGear).save({ + name: 'MacBook Pro', + nameNormalized: 'macbookpro', + }); + const gear2 = await con.getRepository(DatasetGear).save({ + name: 'Keyboard', + nameNormalized: 'keyboard', + }); + + await con.getRepository(UserGear).save([ + { userId: '1', gearId: gear1.id, position: 1 }, + { userId: '1', gearId: gear2.id, position: 0 }, + ]); + + const res = await client.query(QUERY, { variables: { userId: '1' } }); + expect(res.data.userGear.edges).toHaveLength(2); + expect(res.data.userGear.edges[0].node.gear.name).toBe('Keyboard'); + expect(res.data.userGear.edges[1].node.gear.name).toBe('MacBook Pro'); + }); +}); + +describe('query autocompleteGear', () => { + const QUERY = ` + query AutocompleteGear($query: String!) { + autocompleteGear(query: $query) { + id + name + } + } + `; + + it('should return matching gear', async () => { + await con.getRepository(DatasetGear).save([ + { name: 'MacBook Pro', nameNormalized: 'macbookpro' }, + { name: 'MacBook Air', nameNormalized: 'macbookair' }, + { name: 'Keyboard', nameNormalized: 'keyboard' }, + ]); + + const res = await client.query(QUERY, { variables: { query: 'macbook' } }); + expect(res.data.autocompleteGear).toHaveLength(2); + }); + + it('should return empty for no matches', async () => { + const res = await client.query(QUERY, { variables: { query: 'xyz' } }); + expect(res.data.autocompleteGear).toEqual([]); + }); + + it('should return exact match first when searching', async () => { + await con.getRepository(DatasetGear).save([ + { name: 'Monitor Stand', nameNormalized: 'monitorstand' }, + { name: 'Monitor', nameNormalized: 'monitor' }, + { name: 'Monitor Arm', nameNormalized: 'monitorarm' }, + ]); + + const res = await client.query(QUERY, { variables: { query: 'monitor' } }); + const names = res.data.autocompleteGear.map( + (g: { name: string }) => g.name, + ); + expect(names).toContain('Monitor'); + // Exact match should be first + expect(names[0]).toBe('Monitor'); + }); +}); + +describe('mutation addUserGear', () => { + const MUTATION = ` + mutation AddUserGear($input: AddUserGearInput!) { + addUserGear(input: $input) { + id + gear { + name + } + } + } + `; + + it('should require authentication', async () => { + const res = await client.mutate(MUTATION, { + variables: { input: { name: 'MacBook Pro' } }, + }); + expect(res.errors?.[0]?.extensions?.code).toBe('UNAUTHENTICATED'); + }); + + it('should create gear and dataset entry', async () => { + loggedUser = '1'; + const res = await client.mutate(MUTATION, { + variables: { + input: { + name: 'MacBook Pro', + }, + }, + }); + + expect(res.data.addUserGear.gear.name).toBe('MacBook Pro'); + + const dataset = await con + .getRepository(DatasetGear) + .findOneBy({ nameNormalized: 'macbookpro' }); + expect(dataset).not.toBeNull(); + }); + + it('should reuse existing dataset entry', async () => { + loggedUser = '1'; + await con.getRepository(DatasetGear).save({ + name: 'Keyboard', + nameNormalized: 'keyboard', + }); + + await client.mutate(MUTATION, { + variables: { input: { name: 'Keyboard' } }, + }); + + const count = await con.getRepository(DatasetGear).countBy({ + nameNormalized: 'keyboard', + }); + expect(count).toBe(1); + }); + + it('should prevent duplicate gear', async () => { + loggedUser = '1'; + await client.mutate(MUTATION, { + variables: { input: { name: 'Monitor' } }, + }); + + const res = await client.mutate(MUTATION, { + variables: { input: { name: 'Monitor' } }, + }); + + expect(res.errors?.[0]?.message).toBe( + 'Gear already exists in your profile', + ); + }); +}); + +describe('mutation deleteUserGear', () => { + const MUTATION = ` + mutation DeleteUserGear($id: ID!) { + deleteUserGear(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 gear', async () => { + loggedUser = '1'; + const gear = await con.getRepository(DatasetGear).save({ + name: 'Webcam', + nameNormalized: 'webcam', + }); + const userGear = await con.getRepository(UserGear).save({ + userId: '1', + gearId: gear.id, + position: 0, + }); + + await client.mutate(MUTATION, { variables: { id: userGear.id } }); + + const deleted = await con + .getRepository(UserGear) + .findOneBy({ id: userGear.id }); + expect(deleted).toBeNull(); + }); + + it('should not delete another user gear', async () => { + loggedUser = '1'; + const gear = await con.getRepository(DatasetGear).save({ + name: 'Mouse', + nameNormalized: 'mouse', + }); + const userGear = await con.getRepository(UserGear).save({ + userId: '2', // Different user + gearId: gear.id, + position: 0, + }); + + await client.mutate(MUTATION, { variables: { id: userGear.id } }); + + // Should still exist because it belongs to user 2 + const notDeleted = await con + .getRepository(UserGear) + .findOneBy({ id: userGear.id }); + expect(notDeleted).not.toBeNull(); + }); +}); + +describe('mutation reorderUserGear', () => { + const MUTATION = ` + mutation ReorderUserGear($items: [ReorderUserGearInput!]!) { + reorderUserGear(items: $items) { + id + position + } + } + `; + + it('should require authentication', async () => { + const res = await client.mutate(MUTATION, { + variables: { + items: [{ id: '00000000-0000-0000-0000-000000000000', position: 0 }], + }, + }); + expect(res.errors?.[0]?.extensions?.code).toBe('UNAUTHENTICATED'); + }); + + it('should update positions', async () => { + loggedUser = '1'; + const gear1 = await con.getRepository(DatasetGear).save({ + name: 'Desk', + nameNormalized: 'desk', + }); + const gear2 = await con.getRepository(DatasetGear).save({ + name: 'Chair', + nameNormalized: 'chair', + }); + + const [item1, item2] = await con.getRepository(UserGear).save([ + { userId: '1', gearId: gear1.id, position: 0 }, + { userId: '1', gearId: gear2.id, position: 1 }, + ]); + + const res = await client.mutate(MUTATION, { + variables: { + items: [ + { id: item1.id, position: 1 }, + { id: item2.id, position: 0 }, + ], + }, + }); + + const reordered = res.data.reorderUserGear; + 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 another user gear', async () => { + loggedUser = '1'; + const gear = await con.getRepository(DatasetGear).save({ + name: 'Headphones', + nameNormalized: 'headphones', + }); + const userGear = await con.getRepository(UserGear).save({ + userId: '2', // Different user + gearId: gear.id, + position: 0, + }); + + await client.mutate(MUTATION, { + variables: { + items: [{ id: userGear.id, position: 5 }], + }, + }); + + // Position should still be 0 because it belongs to user 2 + const notReordered = await con + .getRepository(UserGear) + .findOneBy({ id: userGear.id }); + expect(notReordered?.position).toBe(0); + }); +}); diff --git a/src/common/datasetGear.ts b/src/common/datasetGear.ts new file mode 100644 index 0000000000..a7031000c4 --- /dev/null +++ b/src/common/datasetGear.ts @@ -0,0 +1,34 @@ +import type { DataSource } from 'typeorm'; +import { DatasetGear } from '../entity/dataset/DatasetGear'; + +const normalizeName = (name: string): string => + name + .toLowerCase() + .trim() + .replace(/\./g, 'dot') + .replace(/\+/g, 'plus') + .replace(/#/g, 'sharp') + .replace(/&/g, 'and') + .replace(/\s+/g, ''); + +export const findOrCreateDatasetGear = async ( + con: DataSource, + name: string, +): Promise => { + const nameNormalized = normalizeName(name); + const repo = con.getRepository(DatasetGear); + + let gear = await repo.findOne({ + where: { nameNormalized }, + }); + + if (!gear) { + gear = repo.create({ + name: name.trim(), + nameNormalized, + }); + await repo.save(gear); + } + + return gear; +}; diff --git a/src/common/schema/userGear.ts b/src/common/schema/userGear.ts new file mode 100644 index 0000000000..4061290129 --- /dev/null +++ b/src/common/schema/userGear.ts @@ -0,0 +1,15 @@ +import z from 'zod'; + +export const addUserGearSchema = z.object({ + name: z.string().min(1).max(255), +}); + +export const reorderUserGearItemSchema = z.object({ + id: z.uuid(), + position: z.number().int().min(0), +}); + +export const reorderUserGearSchema = z.array(reorderUserGearItemSchema).min(1); + +export type AddUserGearInput = z.infer; +export type ReorderUserGearInput = z.infer; diff --git a/src/graphorm/index.ts b/src/graphorm/index.ts index d0d1fb51b2..9e17d7c909 100644 --- a/src/graphorm/index.ts +++ b/src/graphorm/index.ts @@ -2244,6 +2244,29 @@ const obj = new GraphORM({ }, }, }, + DatasetGear: { + requiredColumns: ['id', 'name'], + fields: { + createdAt: { + transform: transformDate, + }, + }, + }, + UserGear: { + requiredColumns: ['id', 'userId', 'gearId'], + fields: { + gear: { + relation: { + isMany: false, + childColumn: 'id', + parentColumn: 'gearId', + }, + }, + createdAt: { + transform: transformDate, + }, + }, + }, }); export default obj; diff --git a/src/graphql.ts b/src/graphql.ts index 6bf31d1c05..04ed9df253 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 userGear from './schema/userGear'; import * as userWorkspacePhoto from './schema/userWorkspacePhoto'; import { makeExecutableSchema } from '@graphql-tools/schema'; import { @@ -89,6 +90,7 @@ export const schema = urlDirective.transformer( userStack.typeDefs, userHotTake.typeDefs, userTool.typeDefs, + userGear.typeDefs, userWorkspacePhoto.typeDefs, ], resolvers: merge( @@ -125,6 +127,7 @@ export const schema = urlDirective.transformer( userStack.resolvers, userHotTake.resolvers, userTool.resolvers, + userGear.resolvers, userWorkspacePhoto.resolvers, ), }), diff --git a/src/schema/userGear.ts b/src/schema/userGear.ts new file mode 100644 index 0000000000..dc2de0f0ec --- /dev/null +++ b/src/schema/userGear.ts @@ -0,0 +1,251 @@ +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 { UserGear } from '../entity/user/UserGear'; +import { ValidationError } from 'apollo-server-errors'; +import { + addUserGearSchema, + reorderUserGearSchema, + type AddUserGearInput, + type ReorderUserGearInput, +} from '../common/schema/userGear'; +import { findOrCreateDatasetGear } from '../common/datasetGear'; +import { NEW_ITEM_POSITION } from '../common/constants'; + +interface GQLUserGear { + id: string; + userId: string; + gearId: string; + position: number; + createdAt: Date; +} + +export const typeDefs = /* GraphQL */ ` + type UserGear { + id: ID! + gear: DatasetGear! + position: Int! + createdAt: DateTime! + } + + type UserGearEdge { + node: UserGear! + cursor: String! + } + + type UserGearConnection { + pageInfo: PageInfo! + edges: [UserGearEdge!]! + } + + type DatasetGear { + id: ID! + name: String! + } + + input AddUserGearInput { + name: String! + } + + input ReorderUserGearInput { + id: ID! + position: Int! + } + + extend type Query { + """ + Get a user's gear + """ + userGear(userId: ID!, first: Int, after: String): UserGearConnection! + + """ + Autocomplete gear from dataset + """ + autocompleteGear(query: String!): [DatasetGear!]! + } + + extend type Mutation { + """ + Add gear to the user's profile (find-or-create in dataset) + """ + addUserGear(input: AddUserGearInput!): UserGear! @auth + + """ + Delete a user's gear + """ + deleteUserGear(id: ID!): EmptyResponse! @auth + + """ + Reorder user's gear + """ + reorderUserGear(items: [ReorderUserGearInput!]!): [UserGear!]! @auth + } +`; + +export const resolvers: IResolvers = traceResolvers< + unknown, + BaseContext +>({ + Query: { + userGear: 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, + ); + }, + + autocompleteGear: async ( + _, + args: { query: string }, + ctx: Context, + ) => { + const query = args.query?.trim().toLowerCase(); + if (!query || query.length < 1) { + return []; + } + + const normalizedQuery = query + .replace(/\./g, 'dot') + .replace(/\+/g, 'plus') + .replace(/#/g, 'sharp') + .replace(/&/g, 'and') + .replace(/\s+/g, ''); + + const { DatasetGear } = await import('../entity/dataset/DatasetGear'); + const { queryReadReplica } = await import('../common/queryReadReplica'); + + return queryReadReplica(ctx.con, ({ queryRunner }) => + queryRunner.manager + .getRepository(DatasetGear) + .createQueryBuilder('dg') + .where('dg."nameNormalized" LIKE :query', { + query: `%${normalizedQuery}%`, + }) + .setParameter('exactQuery', normalizedQuery) + // Prioritize: exact match first, then shorter names, then alphabetically + .orderBy( + `CASE WHEN dg."nameNormalized" = :exactQuery THEN 0 ELSE 1 END`, + 'ASC', + ) + .addOrderBy('LENGTH(dg."name")', 'ASC') + .addOrderBy('dg."name"', 'ASC') + .limit(10) + .getMany(), + ); + }, + }, + + Mutation: { + addUserGear: async ( + _, + args: { input: AddUserGearInput }, + ctx: AuthContext, + info, + ) => { + const input = addUserGearSchema.parse(args.input); + + const datasetGear = await findOrCreateDatasetGear(ctx.con, input.name); + + const existing = await ctx.con.getRepository(UserGear).findOne({ + where: { + userId: ctx.userId, + gearId: datasetGear.id, + }, + }); + + if (existing) { + throw new ValidationError('Gear already exists in your profile'); + } + + const userGear = ctx.con.getRepository(UserGear).create({ + userId: ctx.userId, + gearId: datasetGear.id, + position: NEW_ITEM_POSITION, + }); + + await ctx.con.getRepository(UserGear).save(userGear); + + return graphorm.queryOneOrFail(ctx, info, (builder) => { + builder.queryBuilder.where(`"${builder.alias}"."id" = :id`, { + id: userGear.id, + }); + return builder; + }); + }, + + deleteUserGear: async ( + _, + args: { id: string }, + ctx: AuthContext, + ): Promise => { + await ctx.con + .getRepository(UserGear) + .delete({ id: args.id, userId: ctx.userId }); + + return { _: true }; + }, + + reorderUserGear: async ( + _, + args: { items: ReorderUserGearInput[] }, + ctx: AuthContext, + info, + ) => { + const items = reorderUserGearSchema.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(UserGear) + .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 bd23ab8e4d86a61099d1b2102ca8e8d060752edb Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Fri, 23 Jan 2026 18:09:44 +0000 Subject: [PATCH 2/2] refactor: rename userGear to gear and move autocomplete to autocompletes schema - Rename all files from userGear to gear (schema, tests, common) - Update GraphQL types: UserGear -> Gear, UserGearConnection -> GearConnection - Update all queries and mutations to use gear naming - Move autocompleteGear from gear schema to autocompletes schema - Simplify datasetGear normalization (remove & to and replacement) - Update graphorm mapping from UserGear to Gear - Update all imports and references across the codebase Co-authored-by: Chris Bongers --- __tests__/{userGear.ts => gear.ts} | 79 +++++---------------- src/common/datasetGear.ts | 1 - src/common/schema/autocompletes.ts | 7 ++ src/common/schema/gear.ts | 15 ++++ src/common/schema/userGear.ts | 15 ---- src/graphorm/index.ts | 2 +- src/graphql.ts | 6 +- src/schema/autocompletes.ts | 49 +++++++++++++ src/schema/{userGear.ts => gear.ts} | 105 ++++++++-------------------- 9 files changed, 123 insertions(+), 156 deletions(-) rename __tests__/{userGear.ts => gear.ts} (74%) create mode 100644 src/common/schema/gear.ts delete mode 100644 src/common/schema/userGear.ts rename src/schema/{userGear.ts => gear.ts} (61%) diff --git a/__tests__/userGear.ts b/__tests__/gear.ts similarity index 74% rename from __tests__/userGear.ts rename to __tests__/gear.ts index 8b9707a1d5..bfa180038e 100644 --- a/__tests__/userGear.ts +++ b/__tests__/gear.ts @@ -33,10 +33,10 @@ beforeEach(async () => { await saveFixtures(con, User, usersFixture); }); -describe('query userGear', () => { +describe('query gear', () => { const QUERY = ` - query UserGear($userId: ID!) { - userGear(userId: $userId) { + query Gear($userId: ID!) { + gear(userId: $userId) { edges { node { id @@ -53,7 +53,7 @@ describe('query userGear', () => { it('should return empty list for user with no gear', async () => { const res = await client.query(QUERY, { variables: { userId: '1' } }); - expect(res.data.userGear.edges).toEqual([]); + expect(res.data.gear.edges).toEqual([]); }); it('should return gear ordered by position', async () => { @@ -72,59 +72,16 @@ describe('query userGear', () => { ]); const res = await client.query(QUERY, { variables: { userId: '1' } }); - expect(res.data.userGear.edges).toHaveLength(2); - expect(res.data.userGear.edges[0].node.gear.name).toBe('Keyboard'); - expect(res.data.userGear.edges[1].node.gear.name).toBe('MacBook Pro'); + expect(res.data.gear.edges).toHaveLength(2); + expect(res.data.gear.edges[0].node.gear.name).toBe('Keyboard'); + expect(res.data.gear.edges[1].node.gear.name).toBe('MacBook Pro'); }); }); -describe('query autocompleteGear', () => { - const QUERY = ` - query AutocompleteGear($query: String!) { - autocompleteGear(query: $query) { - id - name - } - } - `; - - it('should return matching gear', async () => { - await con.getRepository(DatasetGear).save([ - { name: 'MacBook Pro', nameNormalized: 'macbookpro' }, - { name: 'MacBook Air', nameNormalized: 'macbookair' }, - { name: 'Keyboard', nameNormalized: 'keyboard' }, - ]); - - const res = await client.query(QUERY, { variables: { query: 'macbook' } }); - expect(res.data.autocompleteGear).toHaveLength(2); - }); - - it('should return empty for no matches', async () => { - const res = await client.query(QUERY, { variables: { query: 'xyz' } }); - expect(res.data.autocompleteGear).toEqual([]); - }); - - it('should return exact match first when searching', async () => { - await con.getRepository(DatasetGear).save([ - { name: 'Monitor Stand', nameNormalized: 'monitorstand' }, - { name: 'Monitor', nameNormalized: 'monitor' }, - { name: 'Monitor Arm', nameNormalized: 'monitorarm' }, - ]); - - const res = await client.query(QUERY, { variables: { query: 'monitor' } }); - const names = res.data.autocompleteGear.map( - (g: { name: string }) => g.name, - ); - expect(names).toContain('Monitor'); - // Exact match should be first - expect(names[0]).toBe('Monitor'); - }); -}); - -describe('mutation addUserGear', () => { +describe('mutation addGear', () => { const MUTATION = ` - mutation AddUserGear($input: AddUserGearInput!) { - addUserGear(input: $input) { + mutation AddGear($input: AddGearInput!) { + addGear(input: $input) { id gear { name @@ -150,7 +107,7 @@ describe('mutation addUserGear', () => { }, }); - expect(res.data.addUserGear.gear.name).toBe('MacBook Pro'); + expect(res.data.addGear.gear.name).toBe('MacBook Pro'); const dataset = await con .getRepository(DatasetGear) @@ -191,10 +148,10 @@ describe('mutation addUserGear', () => { }); }); -describe('mutation deleteUserGear', () => { +describe('mutation deleteGear', () => { const MUTATION = ` - mutation DeleteUserGear($id: ID!) { - deleteUserGear(id: $id) { + mutation DeleteGear($id: ID!) { + deleteGear(id: $id) { _ } } @@ -249,10 +206,10 @@ describe('mutation deleteUserGear', () => { }); }); -describe('mutation reorderUserGear', () => { +describe('mutation reorderGear', () => { const MUTATION = ` - mutation ReorderUserGear($items: [ReorderUserGearInput!]!) { - reorderUserGear(items: $items) { + mutation ReorderGear($items: [ReorderGearInput!]!) { + reorderGear(items: $items) { id position } @@ -293,7 +250,7 @@ describe('mutation reorderUserGear', () => { }, }); - const reordered = res.data.reorderUserGear; + const reordered = res.data.reorderGear; expect( reordered.find((i: { id: string }) => i.id === item1.id).position, ).toBe(1); diff --git a/src/common/datasetGear.ts b/src/common/datasetGear.ts index a7031000c4..61427bfed7 100644 --- a/src/common/datasetGear.ts +++ b/src/common/datasetGear.ts @@ -8,7 +8,6 @@ const normalizeName = (name: string): string => .replace(/\./g, 'dot') .replace(/\+/g, 'plus') .replace(/#/g, 'sharp') - .replace(/&/g, 'and') .replace(/\s+/g, ''); export const findOrCreateDatasetGear = async ( diff --git a/src/common/schema/autocompletes.ts b/src/common/schema/autocompletes.ts index 415dfa53c6..4654447806 100644 --- a/src/common/schema/autocompletes.ts +++ b/src/common/schema/autocompletes.ts @@ -43,3 +43,10 @@ export const autocompleteGithubRepositorySchema = z.object({ query: z.string().trim().min(1).max(100), limit: z.number().min(1).max(20).default(10), }); + +export const autocompleteGearSchema = z.object({ + query: z + .string() + .min(1) + .transform((v) => v.trim().toLowerCase()), +}); diff --git a/src/common/schema/gear.ts b/src/common/schema/gear.ts new file mode 100644 index 0000000000..966fcaad36 --- /dev/null +++ b/src/common/schema/gear.ts @@ -0,0 +1,15 @@ +import z from 'zod'; + +export const addGearSchema = z.object({ + name: z.string().min(1).max(255), +}); + +export const reorderGearItemSchema = z.object({ + id: z.uuid(), + position: z.number().int().min(0), +}); + +export const reorderGearSchema = z.array(reorderGearItemSchema).min(1); + +export type AddGearInput = z.infer; +export type ReorderGearInput = z.infer; diff --git a/src/common/schema/userGear.ts b/src/common/schema/userGear.ts deleted file mode 100644 index 4061290129..0000000000 --- a/src/common/schema/userGear.ts +++ /dev/null @@ -1,15 +0,0 @@ -import z from 'zod'; - -export const addUserGearSchema = z.object({ - name: z.string().min(1).max(255), -}); - -export const reorderUserGearItemSchema = z.object({ - id: z.uuid(), - position: z.number().int().min(0), -}); - -export const reorderUserGearSchema = z.array(reorderUserGearItemSchema).min(1); - -export type AddUserGearInput = z.infer; -export type ReorderUserGearInput = z.infer; diff --git a/src/graphorm/index.ts b/src/graphorm/index.ts index 9e17d7c909..40792a28c0 100644 --- a/src/graphorm/index.ts +++ b/src/graphorm/index.ts @@ -2252,7 +2252,7 @@ const obj = new GraphORM({ }, }, }, - UserGear: { + Gear: { requiredColumns: ['id', 'userId', 'gearId'], fields: { gear: { diff --git a/src/graphql.ts b/src/graphql.ts index 04ed9df253..a84b243814 100644 --- a/src/graphql.ts +++ b/src/graphql.ts @@ -36,7 +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 userGear from './schema/userGear'; +import * as gear from './schema/gear'; import * as userWorkspacePhoto from './schema/userWorkspacePhoto'; import { makeExecutableSchema } from '@graphql-tools/schema'; import { @@ -90,7 +90,7 @@ export const schema = urlDirective.transformer( userStack.typeDefs, userHotTake.typeDefs, userTool.typeDefs, - userGear.typeDefs, + gear.typeDefs, userWorkspacePhoto.typeDefs, ], resolvers: merge( @@ -127,7 +127,7 @@ export const schema = urlDirective.transformer( userStack.resolvers, userHotTake.resolvers, userTool.resolvers, - userGear.resolvers, + gear.resolvers, userWorkspacePhoto.resolvers, ), }), diff --git a/src/schema/autocompletes.ts b/src/schema/autocompletes.ts index bfc36da6ed..0826630f61 100644 --- a/src/schema/autocompletes.ts +++ b/src/schema/autocompletes.ts @@ -12,6 +12,7 @@ import { autocompleteLocationSchema, autocompleteSchema, autocompleteToolsSchema, + autocompleteGearSchema, LocationDataset, } from '../common/schema/autocompletes'; import { DatasetTool } from '../entity/dataset/DatasetTool'; @@ -65,6 +66,11 @@ export const typeDefs = /* GraphQL */ ` faviconUrl: String } + type DatasetGear { + id: ID! + name: String! + } + type GitHubRepository { id: ID! owner: String! @@ -109,6 +115,12 @@ export const typeDefs = /* GraphQL */ ` query: String! limit: Int = 10 ): [GitHubRepository]! @auth @cacheControl(maxAge: 3600) + + """ + Autocomplete gear from dataset + """ + autocompleteGear(query: String!): [DatasetGear!]! + @cacheControl(maxAge: 3600) } `; @@ -308,5 +320,42 @@ export const resolvers = traceResolvers({ return []; } }, + autocompleteGear: async ( + _, + args: { query: string }, + ctx: AuthContext, + ) => { + const result = autocompleteGearSchema.safeParse(args); + if (!result.success) { + return []; + } + + const normalizedQuery = result.data.query + .replace(/\./g, 'dot') + .replace(/\+/g, 'plus') + .replace(/#/g, 'sharp') + .replace(/\s+/g, ''); + + const { DatasetGear } = await import('../entity/dataset/DatasetGear'); + + return queryReadReplica(ctx.con, ({ queryRunner }) => + queryRunner.manager + .getRepository(DatasetGear) + .createQueryBuilder('dg') + .where('dg."nameNormalized" LIKE :query', { + query: `%${normalizedQuery}%`, + }) + .setParameter('exactQuery', normalizedQuery) + // Prioritize: exact match first, then shorter names, then alphabetically + .orderBy( + `CASE WHEN dg."nameNormalized" = :exactQuery THEN 0 ELSE 1 END`, + 'ASC', + ) + .addOrderBy('LENGTH(dg."name")', 'ASC') + .addOrderBy('dg."name"', 'ASC') + .limit(10) + .getMany(), + ); + }, }, }); diff --git a/src/schema/userGear.ts b/src/schema/gear.ts similarity index 61% rename from src/schema/userGear.ts rename to src/schema/gear.ts index dc2de0f0ec..ed09e2b9e8 100644 --- a/src/schema/userGear.ts +++ b/src/schema/gear.ts @@ -6,15 +6,15 @@ import { offsetPageGenerator, GQLEmptyResponse } from './common'; import { UserGear } from '../entity/user/UserGear'; import { ValidationError } from 'apollo-server-errors'; import { - addUserGearSchema, - reorderUserGearSchema, - type AddUserGearInput, - type ReorderUserGearInput, -} from '../common/schema/userGear'; + addGearSchema, + reorderGearSchema, + type AddGearInput, + type ReorderGearInput, +} from '../common/schema/gear'; import { findOrCreateDatasetGear } from '../common/datasetGear'; import { NEW_ITEM_POSITION } from '../common/constants'; -interface GQLUserGear { +interface GQLGear { id: string; userId: string; gearId: string; @@ -23,21 +23,21 @@ interface GQLUserGear { } export const typeDefs = /* GraphQL */ ` - type UserGear { + type Gear { id: ID! gear: DatasetGear! position: Int! createdAt: DateTime! } - type UserGearEdge { - node: UserGear! + type GearEdge { + node: Gear! cursor: String! } - type UserGearConnection { + type GearConnection { pageInfo: PageInfo! - edges: [UserGearEdge!]! + edges: [GearEdge!]! } type DatasetGear { @@ -45,11 +45,11 @@ export const typeDefs = /* GraphQL */ ` name: String! } - input AddUserGearInput { + input AddGearInput { name: String! } - input ReorderUserGearInput { + input ReorderGearInput { id: ID! position: Int! } @@ -58,29 +58,24 @@ export const typeDefs = /* GraphQL */ ` """ Get a user's gear """ - userGear(userId: ID!, first: Int, after: String): UserGearConnection! - - """ - Autocomplete gear from dataset - """ - autocompleteGear(query: String!): [DatasetGear!]! + gear(userId: ID!, first: Int, after: String): GearConnection! } extend type Mutation { """ Add gear to the user's profile (find-or-create in dataset) """ - addUserGear(input: AddUserGearInput!): UserGear! @auth + addGear(input: AddGearInput!): Gear! @auth """ Delete a user's gear """ - deleteUserGear(id: ID!): EmptyResponse! @auth + deleteGear(id: ID!): EmptyResponse! @auth """ Reorder user's gear """ - reorderUserGear(items: [ReorderUserGearInput!]!): [UserGear!]! @auth + reorderGear(items: [ReorderGearInput!]!): [Gear!]! @auth } `; @@ -89,19 +84,19 @@ export const resolvers: IResolvers = traceResolvers< BaseContext >({ Query: { - userGear: async ( + gear: async ( _, args: { userId: string; first?: number; after?: string }, ctx: Context, info, ) => { - const pageGenerator = offsetPageGenerator(50, 100); + const pageGenerator = offsetPageGenerator(50, 100); const page = pageGenerator.connArgsToPage({ first: args.first, after: args.after, }); - return graphorm.queryPaginated( + return graphorm.queryPaginated( ctx, info, (nodeSize) => pageGenerator.hasPreviousPage(page, nodeSize), @@ -123,56 +118,16 @@ export const resolvers: IResolvers = traceResolvers< true, ); }, - - autocompleteGear: async ( - _, - args: { query: string }, - ctx: Context, - ) => { - const query = args.query?.trim().toLowerCase(); - if (!query || query.length < 1) { - return []; - } - - const normalizedQuery = query - .replace(/\./g, 'dot') - .replace(/\+/g, 'plus') - .replace(/#/g, 'sharp') - .replace(/&/g, 'and') - .replace(/\s+/g, ''); - - const { DatasetGear } = await import('../entity/dataset/DatasetGear'); - const { queryReadReplica } = await import('../common/queryReadReplica'); - - return queryReadReplica(ctx.con, ({ queryRunner }) => - queryRunner.manager - .getRepository(DatasetGear) - .createQueryBuilder('dg') - .where('dg."nameNormalized" LIKE :query', { - query: `%${normalizedQuery}%`, - }) - .setParameter('exactQuery', normalizedQuery) - // Prioritize: exact match first, then shorter names, then alphabetically - .orderBy( - `CASE WHEN dg."nameNormalized" = :exactQuery THEN 0 ELSE 1 END`, - 'ASC', - ) - .addOrderBy('LENGTH(dg."name")', 'ASC') - .addOrderBy('dg."name"', 'ASC') - .limit(10) - .getMany(), - ); - }, }, Mutation: { - addUserGear: async ( + addGear: async ( _, - args: { input: AddUserGearInput }, + args: { input: AddGearInput }, ctx: AuthContext, info, ) => { - const input = addUserGearSchema.parse(args.input); + const input = addGearSchema.parse(args.input); const datasetGear = await findOrCreateDatasetGear(ctx.con, input.name); @@ -187,23 +142,23 @@ export const resolvers: IResolvers = traceResolvers< throw new ValidationError('Gear already exists in your profile'); } - const userGear = ctx.con.getRepository(UserGear).create({ + const gear = ctx.con.getRepository(UserGear).create({ userId: ctx.userId, gearId: datasetGear.id, position: NEW_ITEM_POSITION, }); - await ctx.con.getRepository(UserGear).save(userGear); + await ctx.con.getRepository(UserGear).save(gear); return graphorm.queryOneOrFail(ctx, info, (builder) => { builder.queryBuilder.where(`"${builder.alias}"."id" = :id`, { - id: userGear.id, + id: gear.id, }); return builder; }); }, - deleteUserGear: async ( + deleteGear: async ( _, args: { id: string }, ctx: AuthContext, @@ -215,13 +170,13 @@ export const resolvers: IResolvers = traceResolvers< return { _: true }; }, - reorderUserGear: async ( + reorderGear: async ( _, - args: { items: ReorderUserGearInput[] }, + args: { items: ReorderGearInput[] }, ctx: AuthContext, info, ) => { - const items = reorderUserGearSchema.parse(args.items); + const items = reorderGearSchema.parse(args.items); const ids = items.map((i) => i.id); const whenClauses = items