diff --git a/.changeset/add-space-type.md b/.changeset/add-space-type.md new file mode 100644 index 00000000..f8ad13d3 --- /dev/null +++ b/.changeset/add-space-type.md @@ -0,0 +1,16 @@ +--- +"@graphprotocol/hypergraph": patch +"@graphprotocol/hypergraph-react": patch +--- + +Add `type` field to `PublicSpace` type returned by `Space.findManyPublic()` and `usePublicSpaces()`. The type is either `"PERSONAL"` or `"DAO"`. + +Add `spaceType` filter option to `Space.findManyPublic()` and `usePublicSpaces()` to filter spaces by type. Example usage: + +```typescript +// Filter for DAO spaces only +const { data } = usePublicSpaces({ filter: { spaceType: 'DAO' } }); + +// Combine with existing filters +const { data } = usePublicSpaces({ filter: { editorId: 'xxx', spaceType: 'PERSONAL' } }); +``` diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 742604a0..921b695b 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -5,7 +5,10 @@ "Bash(pnpm typecheck:*)", "Bash(pnpm check:*)", "Bash(pnpm --filter events test:script:*)", - "Bash(pnpm test:*)" + "Bash(pnpm test:*)", + "Bash(pnpm vitest:*)", + "Bash(pnpm changeset:*)", + "Bash(npx tsc:*)" ], "deny": [], "ask": [] diff --git a/packages/hypergraph/src/space/find-many-public.ts b/packages/hypergraph/src/space/find-many-public.ts index 1acb7315..ecaebf87 100644 --- a/packages/hypergraph/src/space/find-many-public.ts +++ b/packages/hypergraph/src/space/find-many-public.ts @@ -3,9 +3,11 @@ import { Config } from '@graphprotocol/hypergraph'; import * as Either from 'effect/Either'; import * as EffectSchema from 'effect/Schema'; import { request } from 'graphql-request'; +import { parseGeoId } from '../utils/geo-id.js'; const spaceFields = ` id + type page { name relationsList(filter: { @@ -29,32 +31,13 @@ const spaceFields = ` } `; -const spacesQueryDocument = ` -query spaces { - spaces { - ${spaceFields} - } -} -`; +export const SpaceTypeSchema = EffectSchema.Union(EffectSchema.Literal('PERSONAL'), EffectSchema.Literal('DAO')); -const memberSpacesQueryDocument = ` -query memberSpaces($accountId: UUID!) { - spaces(filter: {members: {some: {memberSpaceId: {is: $accountId}}}}) { - ${spaceFields} - } -} -`; - -const editorSpacesQueryDocument = ` -query editorSpaces($accountId: UUID!) { - spaces(filter: {editors: {some: {memberSpaceId: {is: $accountId}}}}) { - ${spaceFields} - } -} -`; +export type SpaceType = typeof SpaceTypeSchema.Type; export const PublicSpaceSchema = EffectSchema.Struct({ id: EffectSchema.String, + type: SpaceTypeSchema, name: EffectSchema.String, avatar: EffectSchema.optional(EffectSchema.String), editorIds: EffectSchema.Array(EffectSchema.String), @@ -66,6 +49,7 @@ export type PublicSpace = typeof PublicSpaceSchema.Type; type SpacesQueryResult = { spaces?: { id: string; + type: 'PERSONAL' | 'DAO'; page: { name?: string | null; relationsList?: { @@ -86,10 +70,6 @@ type SpacesQueryResult = { }[]; }; -type SpacesQueryVariables = { - accountId: string; -}; - type SpaceQueryEntry = NonNullable[number]; const decodeSpace = EffectSchema.decodeUnknownEither(PublicSpaceSchema); @@ -120,6 +100,7 @@ export const parseSpacesQueryResult = (queryResult: SpacesQueryResult) => { for (const space of spaces) { const rawSpace: Record = { id: space.id, + type: space.type, name: space.page?.name ?? undefined, avatar: getAvatarFromSpace(space), editorIds: getEditorIdsFromSpace(space), @@ -139,14 +120,63 @@ export const parseSpacesQueryResult = (queryResult: SpacesQueryResult) => { }; export type FindManyPublicFilter = - | Readonly<{ memberId: string; editorId?: never }> - | Readonly<{ editorId: string; memberId?: never }> - | Readonly<{ memberId?: undefined; editorId?: undefined }>; + | Readonly<{ memberId: string; editorId?: never; spaceType?: SpaceType }> + | Readonly<{ editorId: string; memberId?: never; spaceType?: SpaceType }> + | Readonly<{ memberId?: undefined; editorId?: undefined; spaceType?: SpaceType }>; export type FindManyPublicParams = Readonly<{ filter?: FindManyPublicFilter; }>; +const validateSpaceType = (spaceType: SpaceType): SpaceType => { + const result = EffectSchema.decodeUnknownEither(SpaceTypeSchema)(spaceType); + if (Either.isLeft(result)) { + throw new Error(`Invalid spaceType: ${spaceType}. Must be 'PERSONAL' or 'DAO'.`); + } + return result.right; +}; + +export const buildFilterString = (filter?: FindManyPublicFilter): string | undefined => { + const conditions: string[] = []; + + if (filter?.memberId) { + // Validate memberId is a valid GeoId to prevent injection attacks + const validatedMemberId = parseGeoId(filter.memberId); + conditions.push(`members: {some: {memberSpaceId: {is: "${validatedMemberId}"}}}`); + } + + if (filter?.editorId) { + // Validate editorId is a valid GeoId to prevent injection attacks + const validatedEditorId = parseGeoId(filter.editorId); + conditions.push(`editors: {some: {memberSpaceId: {is: "${validatedEditorId}"}}}`); + } + + if (filter?.spaceType) { + // Validate spaceType at runtime to ensure it's a valid value + const validatedSpaceType = validateSpaceType(filter.spaceType); + conditions.push(`type: {is: ${validatedSpaceType}}`); + } + + if (conditions.length === 0) { + return undefined; + } + + return `filter: {${conditions.join(', ')}}`; +}; + +export const buildSpacesQuery = (filter?: FindManyPublicFilter): string => { + const filterString = buildFilterString(filter); + const filterClause = filterString ? `(${filterString})` : ''; + + return ` +query spaces { + spaces${filterClause} { + ${spaceFields} + } +} +`; +}; + export const findManyPublic = async (params?: FindManyPublicParams) => { const filter = params?.filter; const memberId = filter?.memberId; @@ -157,21 +187,7 @@ export const findManyPublic = async (params?: FindManyPublicParams) => { } const endpoint = `${Config.getApiOrigin()}/v2/graphql`; - - if (memberId) { - const queryResult = await request(endpoint, memberSpacesQueryDocument, { - accountId: memberId, - }); - return parseSpacesQueryResult(queryResult); - } - - if (editorId) { - const queryResult = await request(endpoint, editorSpacesQueryDocument, { - accountId: editorId, - }); - return parseSpacesQueryResult(queryResult); - } - - const queryResult = await request(endpoint, spacesQueryDocument); + const queryDocument = buildSpacesQuery(filter); + const queryResult = await request(endpoint, queryDocument); return parseSpacesQueryResult(queryResult); }; diff --git a/packages/hypergraph/test/space/find-many-public.test.ts b/packages/hypergraph/test/space/find-many-public.test.ts index 72173012..29004744 100644 --- a/packages/hypergraph/test/space/find-many-public.test.ts +++ b/packages/hypergraph/test/space/find-many-public.test.ts @@ -1,14 +1,16 @@ import { describe, expect, it } from 'vitest'; -import { parseSpacesQueryResult } from '../../src/space/find-many-public.js'; +import { buildFilterString, buildSpacesQuery, parseSpacesQueryResult } from '../../src/space/find-many-public.js'; const buildQuerySpace = ({ id = 'space-id', + type = 'PERSONAL', name = 'Space name', avatar, editorsList = [], membersList = [], }: { id?: string; + type?: 'PERSONAL' | 'DAO'; name?: string | null; avatar?: string | null; editorsList?: { memberSpaceId: string }[]; @@ -16,6 +18,7 @@ const buildQuerySpace = ({ } = {}) => { return { id, + type, page: { name, relationsList: @@ -51,6 +54,7 @@ describe('parseSpacesQueryResult', () => { expect(data).toEqual([ { id: 'space-1', + type: 'PERSONAL', name: 'Space 1', avatar: 'https://example.com/avatar.png', editorIds: [], @@ -68,6 +72,7 @@ describe('parseSpacesQueryResult', () => { expect(data).toEqual([ { id: 'space-2', + type: 'PERSONAL', name: 'Space 2', editorIds: [], memberIds: [], @@ -75,6 +80,22 @@ describe('parseSpacesQueryResult', () => { ]); }); + it('parses DAO type', () => { + const { data } = parseSpacesQueryResult({ + spaces: [buildQuerySpace({ id: 'space-dao', type: 'DAO', name: 'DAO Space' })], + }); + + expect(data).toEqual([ + { + id: 'space-dao', + type: 'DAO', + name: 'DAO Space', + editorIds: [], + memberIds: [], + }, + ]); + }); + it('filters invalid data', () => { const { data, invalidSpaces } = parseSpacesQueryResult({ spaces: [ @@ -86,6 +107,7 @@ describe('parseSpacesQueryResult', () => { expect(data).toEqual([ { id: 'space-valid', + type: 'PERSONAL', name: 'Space valid', avatar: 'https://example.com/a.png', editorIds: [], @@ -111,6 +133,7 @@ describe('parseSpacesQueryResult', () => { expect(data).toEqual([ { id: 'space-with-members', + type: 'PERSONAL', name: 'Space with members', editorIds: ['editor-1', 'editor-2'], memberIds: ['member-1'], @@ -118,3 +141,109 @@ describe('parseSpacesQueryResult', () => { ]); }); }); + +describe('buildFilterString', () => { + it('returns undefined when no filter is provided', () => { + expect(buildFilterString()).toBeUndefined(); + expect(buildFilterString({})).toBeUndefined(); + }); + + it('builds filter string with memberId', () => { + const result = buildFilterString({ memberId: '1e5e39daa00d4fd8b53b98095337112f' }); + expect(result).toBe('filter: {members: {some: {memberSpaceId: {is: "1e5e39daa00d4fd8b53b98095337112f"}}}}'); + }); + + it('builds filter string with editorId', () => { + const result = buildFilterString({ editorId: '1e5e39daa00d4fd8b53b98095337112f' }); + expect(result).toBe('filter: {editors: {some: {memberSpaceId: {is: "1e5e39daa00d4fd8b53b98095337112f"}}}}'); + }); + + it('builds filter string with spaceType PERSONAL', () => { + const result = buildFilterString({ spaceType: 'PERSONAL' }); + expect(result).toBe('filter: {type: {is: PERSONAL}}'); + }); + + it('builds filter string with spaceType DAO', () => { + const result = buildFilterString({ spaceType: 'DAO' }); + expect(result).toBe('filter: {type: {is: DAO}}'); + }); + + it('builds filter string with memberId and spaceType', () => { + const result = buildFilterString({ memberId: '1e5e39daa00d4fd8b53b98095337112f', spaceType: 'PERSONAL' }); + expect(result).toBe( + 'filter: {members: {some: {memberSpaceId: {is: "1e5e39daa00d4fd8b53b98095337112f"}}}, type: {is: PERSONAL}}', + ); + }); + + it('builds filter string with editorId and spaceType', () => { + const result = buildFilterString({ editorId: '1e5e39daa00d4fd8b53b98095337112f', spaceType: 'DAO' }); + expect(result).toBe( + 'filter: {editors: {some: {memberSpaceId: {is: "1e5e39daa00d4fd8b53b98095337112f"}}}, type: {is: DAO}}', + ); + }); + + it('normalizes UUID with dashes to dashless format', () => { + const result = buildFilterString({ memberId: '1e5e39da-a00d-4fd8-b53b-98095337112f' }); + expect(result).toBe('filter: {members: {some: {memberSpaceId: {is: "1e5e39daa00d4fd8b53b98095337112f"}}}}'); + }); + + it('throws error for invalid memberId', () => { + expect(() => buildFilterString({ memberId: 'invalid-id' })).toThrow('Invalid Geo ID'); + }); + + it('throws error for invalid editorId', () => { + expect(() => buildFilterString({ editorId: 'invalid"; DROP TABLE spaces; --' })).toThrow('Invalid Geo ID'); + }); + + it('throws error for invalid spaceType', () => { + // @ts-expect-error - testing runtime validation with invalid value + expect(() => buildFilterString({ spaceType: 'INVALID' })).toThrow( + "Invalid spaceType: INVALID. Must be 'PERSONAL' or 'DAO'.", + ); + }); +}); + +describe('buildSpacesQuery', () => { + it('builds query without filter', () => { + const query = buildSpacesQuery(); + expect(query).toContain('query spaces {'); + // Check that the top-level spaces query doesn't have a filter (spaces { not spaces(filter:) + expect(query).toMatch(/spaces\s*\{/); + expect(query).not.toMatch(/spaces\s*\(filter:/); + }); + + it('builds query with memberId filter', () => { + const query = buildSpacesQuery({ memberId: '1e5e39daa00d4fd8b53b98095337112f' }); + expect(query).toContain( + 'spaces(filter: {members: {some: {memberSpaceId: {is: "1e5e39daa00d4fd8b53b98095337112f"}}}})', + ); + }); + + it('builds query with editorId filter', () => { + const query = buildSpacesQuery({ editorId: '1e5e39daa00d4fd8b53b98095337112f' }); + expect(query).toContain( + 'spaces(filter: {editors: {some: {memberSpaceId: {is: "1e5e39daa00d4fd8b53b98095337112f"}}}})', + ); + }); + + it('builds query with spaceType filter only', () => { + const query = buildSpacesQuery({ spaceType: 'DAO' }); + expect(query).toContain('spaces(filter: {type: {is: DAO}})'); + }); + + it('builds query with combined filters', () => { + const query = buildSpacesQuery({ memberId: '1e5e39daa00d4fd8b53b98095337112f', spaceType: 'PERSONAL' }); + expect(query).toContain( + 'spaces(filter: {members: {some: {memberSpaceId: {is: "1e5e39daa00d4fd8b53b98095337112f"}}}, type: {is: PERSONAL}})', + ); + }); + + it('includes required space fields in query', () => { + const query = buildSpacesQuery(); + expect(query).toContain('id'); + expect(query).toContain('type'); + expect(query).toContain('page {'); + expect(query).toContain('editorsList {'); + expect(query).toContain('membersList {'); + }); +});