diff --git a/__tests__/gear.ts b/__tests__/gear.ts new file mode 100644 index 0000000000..bfa180038e --- /dev/null +++ b/__tests__/gear.ts @@ -0,0 +1,286 @@ +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 gear', () => { + const QUERY = ` + query Gear($userId: ID!) { + gear(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.gear.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.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('mutation addGear', () => { + const MUTATION = ` + mutation AddGear($input: AddGearInput!) { + addGear(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.addGear.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 deleteGear', () => { + const MUTATION = ` + mutation DeleteGear($id: ID!) { + deleteGear(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 reorderGear', () => { + const MUTATION = ` + mutation ReorderGear($items: [ReorderGearInput!]!) { + reorderGear(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.reorderGear; + 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..61427bfed7 --- /dev/null +++ b/src/common/datasetGear.ts @@ -0,0 +1,33 @@ +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(/\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/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/graphorm/index.ts b/src/graphorm/index.ts index d0d1fb51b2..40792a28c0 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, + }, + }, + }, + Gear: { + 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..a84b243814 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 gear from './schema/gear'; 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, + gear.typeDefs, userWorkspacePhoto.typeDefs, ], resolvers: merge( @@ -125,6 +127,7 @@ export const schema = urlDirective.transformer( userStack.resolvers, userHotTake.resolvers, userTool.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/gear.ts b/src/schema/gear.ts new file mode 100644 index 0000000000..ed09e2b9e8 --- /dev/null +++ b/src/schema/gear.ts @@ -0,0 +1,206 @@ +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 { + addGearSchema, + reorderGearSchema, + type AddGearInput, + type ReorderGearInput, +} from '../common/schema/gear'; +import { findOrCreateDatasetGear } from '../common/datasetGear'; +import { NEW_ITEM_POSITION } from '../common/constants'; + +interface GQLGear { + id: string; + userId: string; + gearId: string; + position: number; + createdAt: Date; +} + +export const typeDefs = /* GraphQL */ ` + type Gear { + id: ID! + gear: DatasetGear! + position: Int! + createdAt: DateTime! + } + + type GearEdge { + node: Gear! + cursor: String! + } + + type GearConnection { + pageInfo: PageInfo! + edges: [GearEdge!]! + } + + type DatasetGear { + id: ID! + name: String! + } + + input AddGearInput { + name: String! + } + + input ReorderGearInput { + id: ID! + position: Int! + } + + extend type Query { + """ + Get a user's gear + """ + gear(userId: ID!, first: Int, after: String): GearConnection! + } + + extend type Mutation { + """ + Add gear to the user's profile (find-or-create in dataset) + """ + addGear(input: AddGearInput!): Gear! @auth + + """ + Delete a user's gear + """ + deleteGear(id: ID!): EmptyResponse! @auth + + """ + Reorder user's gear + """ + reorderGear(items: [ReorderGearInput!]!): [Gear!]! @auth + } +`; + +export const resolvers: IResolvers = traceResolvers< + unknown, + BaseContext +>({ + Query: { + gear: 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: { + addGear: async ( + _, + args: { input: AddGearInput }, + ctx: AuthContext, + info, + ) => { + const input = addGearSchema.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 gear = ctx.con.getRepository(UserGear).create({ + userId: ctx.userId, + gearId: datasetGear.id, + position: NEW_ITEM_POSITION, + }); + + await ctx.con.getRepository(UserGear).save(gear); + + return graphorm.queryOneOrFail(ctx, info, (builder) => { + builder.queryBuilder.where(`"${builder.alias}"."id" = :id`, { + id: gear.id, + }); + return builder; + }); + }, + + deleteGear: async ( + _, + args: { id: string }, + ctx: AuthContext, + ): Promise => { + await ctx.con + .getRepository(UserGear) + .delete({ id: args.id, userId: ctx.userId }); + + return { _: true }; + }, + + reorderGear: async ( + _, + args: { items: ReorderGearInput[] }, + ctx: AuthContext, + info, + ) => { + const items = reorderGearSchema.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; + }); + }, + }, +});