Skip to content

Commit 5b22a3f

Browse files
authored
Nik/spaces (#582)
1 parent 003a6cf commit 5b22a3f

4 files changed

Lines changed: 211 additions & 47 deletions

File tree

.changeset/add-space-type.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
"@graphprotocol/hypergraph": patch
3+
"@graphprotocol/hypergraph-react": patch
4+
---
5+
6+
Add `type` field to `PublicSpace` type returned by `Space.findManyPublic()` and `usePublicSpaces()`. The type is either `"PERSONAL"` or `"DAO"`.
7+
8+
Add `spaceType` filter option to `Space.findManyPublic()` and `usePublicSpaces()` to filter spaces by type. Example usage:
9+
10+
```typescript
11+
// Filter for DAO spaces only
12+
const { data } = usePublicSpaces({ filter: { spaceType: 'DAO' } });
13+
14+
// Combine with existing filters
15+
const { data } = usePublicSpaces({ filter: { editorId: 'xxx', spaceType: 'PERSONAL' } });
16+
```

.claude/settings.local.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@
55
"Bash(pnpm typecheck:*)",
66
"Bash(pnpm check:*)",
77
"Bash(pnpm --filter events test:script:*)",
8-
"Bash(pnpm test:*)"
8+
"Bash(pnpm test:*)",
9+
"Bash(pnpm vitest:*)",
10+
"Bash(pnpm changeset:*)",
11+
"Bash(npx tsc:*)"
912
],
1013
"deny": [],
1114
"ask": []

packages/hypergraph/src/space/find-many-public.ts

Lines changed: 61 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@ import { Config } from '@graphprotocol/hypergraph';
33
import * as Either from 'effect/Either';
44
import * as EffectSchema from 'effect/Schema';
55
import { request } from 'graphql-request';
6+
import { parseGeoId } from '../utils/geo-id.js';
67

78
const spaceFields = `
89
id
10+
type
911
page {
1012
name
1113
relationsList(filter: {
@@ -29,32 +31,13 @@ const spaceFields = `
2931
}
3032
`;
3133

32-
const spacesQueryDocument = `
33-
query spaces {
34-
spaces {
35-
${spaceFields}
36-
}
37-
}
38-
`;
34+
export const SpaceTypeSchema = EffectSchema.Union(EffectSchema.Literal('PERSONAL'), EffectSchema.Literal('DAO'));
3935

40-
const memberSpacesQueryDocument = `
41-
query memberSpaces($accountId: UUID!) {
42-
spaces(filter: {members: {some: {memberSpaceId: {is: $accountId}}}}) {
43-
${spaceFields}
44-
}
45-
}
46-
`;
47-
48-
const editorSpacesQueryDocument = `
49-
query editorSpaces($accountId: UUID!) {
50-
spaces(filter: {editors: {some: {memberSpaceId: {is: $accountId}}}}) {
51-
${spaceFields}
52-
}
53-
}
54-
`;
36+
export type SpaceType = typeof SpaceTypeSchema.Type;
5537

5638
export const PublicSpaceSchema = EffectSchema.Struct({
5739
id: EffectSchema.String,
40+
type: SpaceTypeSchema,
5841
name: EffectSchema.String,
5942
avatar: EffectSchema.optional(EffectSchema.String),
6043
editorIds: EffectSchema.Array(EffectSchema.String),
@@ -66,6 +49,7 @@ export type PublicSpace = typeof PublicSpaceSchema.Type;
6649
type SpacesQueryResult = {
6750
spaces?: {
6851
id: string;
52+
type: 'PERSONAL' | 'DAO';
6953
page: {
7054
name?: string | null;
7155
relationsList?: {
@@ -86,10 +70,6 @@ type SpacesQueryResult = {
8670
}[];
8771
};
8872

89-
type SpacesQueryVariables = {
90-
accountId: string;
91-
};
92-
9373
type SpaceQueryEntry = NonNullable<SpacesQueryResult['spaces']>[number];
9474

9575
const decodeSpace = EffectSchema.decodeUnknownEither(PublicSpaceSchema);
@@ -120,6 +100,7 @@ export const parseSpacesQueryResult = (queryResult: SpacesQueryResult) => {
120100
for (const space of spaces) {
121101
const rawSpace: Record<string, unknown> = {
122102
id: space.id,
103+
type: space.type,
123104
name: space.page?.name ?? undefined,
124105
avatar: getAvatarFromSpace(space),
125106
editorIds: getEditorIdsFromSpace(space),
@@ -139,14 +120,63 @@ export const parseSpacesQueryResult = (queryResult: SpacesQueryResult) => {
139120
};
140121

141122
export type FindManyPublicFilter =
142-
| Readonly<{ memberId: string; editorId?: never }>
143-
| Readonly<{ editorId: string; memberId?: never }>
144-
| Readonly<{ memberId?: undefined; editorId?: undefined }>;
123+
| Readonly<{ memberId: string; editorId?: never; spaceType?: SpaceType }>
124+
| Readonly<{ editorId: string; memberId?: never; spaceType?: SpaceType }>
125+
| Readonly<{ memberId?: undefined; editorId?: undefined; spaceType?: SpaceType }>;
145126

146127
export type FindManyPublicParams = Readonly<{
147128
filter?: FindManyPublicFilter;
148129
}>;
149130

131+
const validateSpaceType = (spaceType: SpaceType): SpaceType => {
132+
const result = EffectSchema.decodeUnknownEither(SpaceTypeSchema)(spaceType);
133+
if (Either.isLeft(result)) {
134+
throw new Error(`Invalid spaceType: ${spaceType}. Must be 'PERSONAL' or 'DAO'.`);
135+
}
136+
return result.right;
137+
};
138+
139+
export const buildFilterString = (filter?: FindManyPublicFilter): string | undefined => {
140+
const conditions: string[] = [];
141+
142+
if (filter?.memberId) {
143+
// Validate memberId is a valid GeoId to prevent injection attacks
144+
const validatedMemberId = parseGeoId(filter.memberId);
145+
conditions.push(`members: {some: {memberSpaceId: {is: "${validatedMemberId}"}}}`);
146+
}
147+
148+
if (filter?.editorId) {
149+
// Validate editorId is a valid GeoId to prevent injection attacks
150+
const validatedEditorId = parseGeoId(filter.editorId);
151+
conditions.push(`editors: {some: {memberSpaceId: {is: "${validatedEditorId}"}}}`);
152+
}
153+
154+
if (filter?.spaceType) {
155+
// Validate spaceType at runtime to ensure it's a valid value
156+
const validatedSpaceType = validateSpaceType(filter.spaceType);
157+
conditions.push(`type: {is: ${validatedSpaceType}}`);
158+
}
159+
160+
if (conditions.length === 0) {
161+
return undefined;
162+
}
163+
164+
return `filter: {${conditions.join(', ')}}`;
165+
};
166+
167+
export const buildSpacesQuery = (filter?: FindManyPublicFilter): string => {
168+
const filterString = buildFilterString(filter);
169+
const filterClause = filterString ? `(${filterString})` : '';
170+
171+
return `
172+
query spaces {
173+
spaces${filterClause} {
174+
${spaceFields}
175+
}
176+
}
177+
`;
178+
};
179+
150180
export const findManyPublic = async (params?: FindManyPublicParams) => {
151181
const filter = params?.filter;
152182
const memberId = filter?.memberId;
@@ -157,21 +187,7 @@ export const findManyPublic = async (params?: FindManyPublicParams) => {
157187
}
158188

159189
const endpoint = `${Config.getApiOrigin()}/v2/graphql`;
160-
161-
if (memberId) {
162-
const queryResult = await request<SpacesQueryResult, SpacesQueryVariables>(endpoint, memberSpacesQueryDocument, {
163-
accountId: memberId,
164-
});
165-
return parseSpacesQueryResult(queryResult);
166-
}
167-
168-
if (editorId) {
169-
const queryResult = await request<SpacesQueryResult, SpacesQueryVariables>(endpoint, editorSpacesQueryDocument, {
170-
accountId: editorId,
171-
});
172-
return parseSpacesQueryResult(queryResult);
173-
}
174-
175-
const queryResult = await request<SpacesQueryResult>(endpoint, spacesQueryDocument);
190+
const queryDocument = buildSpacesQuery(filter);
191+
const queryResult = await request<SpacesQueryResult>(endpoint, queryDocument);
176192
return parseSpacesQueryResult(queryResult);
177193
};

packages/hypergraph/test/space/find-many-public.test.ts

Lines changed: 130 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,24 @@
11
import { describe, expect, it } from 'vitest';
2-
import { parseSpacesQueryResult } from '../../src/space/find-many-public.js';
2+
import { buildFilterString, buildSpacesQuery, parseSpacesQueryResult } from '../../src/space/find-many-public.js';
33

44
const buildQuerySpace = ({
55
id = 'space-id',
6+
type = 'PERSONAL',
67
name = 'Space name',
78
avatar,
89
editorsList = [],
910
membersList = [],
1011
}: {
1112
id?: string;
13+
type?: 'PERSONAL' | 'DAO';
1214
name?: string | null;
1315
avatar?: string | null;
1416
editorsList?: { memberSpaceId: string }[];
1517
membersList?: { memberSpaceId: string }[];
1618
} = {}) => {
1719
return {
1820
id,
21+
type,
1922
page: {
2023
name,
2124
relationsList:
@@ -51,6 +54,7 @@ describe('parseSpacesQueryResult', () => {
5154
expect(data).toEqual([
5255
{
5356
id: 'space-1',
57+
type: 'PERSONAL',
5458
name: 'Space 1',
5559
avatar: 'https://example.com/avatar.png',
5660
editorIds: [],
@@ -68,13 +72,30 @@ describe('parseSpacesQueryResult', () => {
6872
expect(data).toEqual([
6973
{
7074
id: 'space-2',
75+
type: 'PERSONAL',
7176
name: 'Space 2',
7277
editorIds: [],
7378
memberIds: [],
7479
},
7580
]);
7681
});
7782

83+
it('parses DAO type', () => {
84+
const { data } = parseSpacesQueryResult({
85+
spaces: [buildQuerySpace({ id: 'space-dao', type: 'DAO', name: 'DAO Space' })],
86+
});
87+
88+
expect(data).toEqual([
89+
{
90+
id: 'space-dao',
91+
type: 'DAO',
92+
name: 'DAO Space',
93+
editorIds: [],
94+
memberIds: [],
95+
},
96+
]);
97+
});
98+
7899
it('filters invalid data', () => {
79100
const { data, invalidSpaces } = parseSpacesQueryResult({
80101
spaces: [
@@ -86,6 +107,7 @@ describe('parseSpacesQueryResult', () => {
86107
expect(data).toEqual([
87108
{
88109
id: 'space-valid',
110+
type: 'PERSONAL',
89111
name: 'Space valid',
90112
avatar: 'https://example.com/a.png',
91113
editorIds: [],
@@ -111,10 +133,117 @@ describe('parseSpacesQueryResult', () => {
111133
expect(data).toEqual([
112134
{
113135
id: 'space-with-members',
136+
type: 'PERSONAL',
114137
name: 'Space with members',
115138
editorIds: ['editor-1', 'editor-2'],
116139
memberIds: ['member-1'],
117140
},
118141
]);
119142
});
120143
});
144+
145+
describe('buildFilterString', () => {
146+
it('returns undefined when no filter is provided', () => {
147+
expect(buildFilterString()).toBeUndefined();
148+
expect(buildFilterString({})).toBeUndefined();
149+
});
150+
151+
it('builds filter string with memberId', () => {
152+
const result = buildFilterString({ memberId: '1e5e39daa00d4fd8b53b98095337112f' });
153+
expect(result).toBe('filter: {members: {some: {memberSpaceId: {is: "1e5e39daa00d4fd8b53b98095337112f"}}}}');
154+
});
155+
156+
it('builds filter string with editorId', () => {
157+
const result = buildFilterString({ editorId: '1e5e39daa00d4fd8b53b98095337112f' });
158+
expect(result).toBe('filter: {editors: {some: {memberSpaceId: {is: "1e5e39daa00d4fd8b53b98095337112f"}}}}');
159+
});
160+
161+
it('builds filter string with spaceType PERSONAL', () => {
162+
const result = buildFilterString({ spaceType: 'PERSONAL' });
163+
expect(result).toBe('filter: {type: {is: PERSONAL}}');
164+
});
165+
166+
it('builds filter string with spaceType DAO', () => {
167+
const result = buildFilterString({ spaceType: 'DAO' });
168+
expect(result).toBe('filter: {type: {is: DAO}}');
169+
});
170+
171+
it('builds filter string with memberId and spaceType', () => {
172+
const result = buildFilterString({ memberId: '1e5e39daa00d4fd8b53b98095337112f', spaceType: 'PERSONAL' });
173+
expect(result).toBe(
174+
'filter: {members: {some: {memberSpaceId: {is: "1e5e39daa00d4fd8b53b98095337112f"}}}, type: {is: PERSONAL}}',
175+
);
176+
});
177+
178+
it('builds filter string with editorId and spaceType', () => {
179+
const result = buildFilterString({ editorId: '1e5e39daa00d4fd8b53b98095337112f', spaceType: 'DAO' });
180+
expect(result).toBe(
181+
'filter: {editors: {some: {memberSpaceId: {is: "1e5e39daa00d4fd8b53b98095337112f"}}}, type: {is: DAO}}',
182+
);
183+
});
184+
185+
it('normalizes UUID with dashes to dashless format', () => {
186+
const result = buildFilterString({ memberId: '1e5e39da-a00d-4fd8-b53b-98095337112f' });
187+
expect(result).toBe('filter: {members: {some: {memberSpaceId: {is: "1e5e39daa00d4fd8b53b98095337112f"}}}}');
188+
});
189+
190+
it('throws error for invalid memberId', () => {
191+
expect(() => buildFilterString({ memberId: 'invalid-id' })).toThrow('Invalid Geo ID');
192+
});
193+
194+
it('throws error for invalid editorId', () => {
195+
expect(() => buildFilterString({ editorId: 'invalid"; DROP TABLE spaces; --' })).toThrow('Invalid Geo ID');
196+
});
197+
198+
it('throws error for invalid spaceType', () => {
199+
// @ts-expect-error - testing runtime validation with invalid value
200+
expect(() => buildFilterString({ spaceType: 'INVALID' })).toThrow(
201+
"Invalid spaceType: INVALID. Must be 'PERSONAL' or 'DAO'.",
202+
);
203+
});
204+
});
205+
206+
describe('buildSpacesQuery', () => {
207+
it('builds query without filter', () => {
208+
const query = buildSpacesQuery();
209+
expect(query).toContain('query spaces {');
210+
// Check that the top-level spaces query doesn't have a filter (spaces { not spaces(filter:)
211+
expect(query).toMatch(/spaces\s*\{/);
212+
expect(query).not.toMatch(/spaces\s*\(filter:/);
213+
});
214+
215+
it('builds query with memberId filter', () => {
216+
const query = buildSpacesQuery({ memberId: '1e5e39daa00d4fd8b53b98095337112f' });
217+
expect(query).toContain(
218+
'spaces(filter: {members: {some: {memberSpaceId: {is: "1e5e39daa00d4fd8b53b98095337112f"}}}})',
219+
);
220+
});
221+
222+
it('builds query with editorId filter', () => {
223+
const query = buildSpacesQuery({ editorId: '1e5e39daa00d4fd8b53b98095337112f' });
224+
expect(query).toContain(
225+
'spaces(filter: {editors: {some: {memberSpaceId: {is: "1e5e39daa00d4fd8b53b98095337112f"}}}})',
226+
);
227+
});
228+
229+
it('builds query with spaceType filter only', () => {
230+
const query = buildSpacesQuery({ spaceType: 'DAO' });
231+
expect(query).toContain('spaces(filter: {type: {is: DAO}})');
232+
});
233+
234+
it('builds query with combined filters', () => {
235+
const query = buildSpacesQuery({ memberId: '1e5e39daa00d4fd8b53b98095337112f', spaceType: 'PERSONAL' });
236+
expect(query).toContain(
237+
'spaces(filter: {members: {some: {memberSpaceId: {is: "1e5e39daa00d4fd8b53b98095337112f"}}}, type: {is: PERSONAL}})',
238+
);
239+
});
240+
241+
it('includes required space fields in query', () => {
242+
const query = buildSpacesQuery();
243+
expect(query).toContain('id');
244+
expect(query).toContain('type');
245+
expect(query).toContain('page {');
246+
expect(query).toContain('editorsList {');
247+
expect(query).toContain('membersList {');
248+
});
249+
});

0 commit comments

Comments
 (0)