Skip to content

Commit 61bcf41

Browse files
authored
Merge pull request #39 from geobrowser/invite-account
Invite account
2 parents a550cca + 2b1ec80 commit 61bcf41

File tree

13 files changed

+386
-73
lines changed

13 files changed

+386
-73
lines changed
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import type { SpaceEvent } from 'graph-framework';
2+
3+
export function DebugSpaceEvents({ events }: { events: SpaceEvent[] }) {
4+
return (
5+
<ul className="text-xs">
6+
{events.map((event) => {
7+
return (
8+
<li key={event.transaction.id} className="border border-gray-300">
9+
<pre>{JSON.stringify(event, null, 2)}</pre>
10+
</li>
11+
);
12+
})}
13+
</ul>
14+
);
15+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import type { SpaceState } from 'graph-framework';
2+
3+
export function DebugSpaceState(props: { state: SpaceState | undefined }) {
4+
return (
5+
<div className="text-xs">
6+
<pre>{JSON.stringify(props, null, 2)}</pre>
7+
</div>
8+
);
9+
}

apps/events/src/routes/playground.tsx

Lines changed: 136 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,42 @@
1+
import { DebugSpaceEvents } from '@/components/debug-space-events';
2+
import { DebugSpaceState } from '@/components/debug-space-state';
13
import { Button } from '@/components/ui/button';
24
import { assertExhaustive } from '@/lib/assertExhaustive';
35
import { createFileRoute } from '@tanstack/react-router';
4-
import { Effect } from 'effect';
6+
import { Effect, Exit } from 'effect';
57
import * as Schema from 'effect/Schema';
6-
import type { EventMessage, RequestListSpaces, RequestSubscribeToSpace } from 'graph-framework';
7-
import { ResponseMessage, createSpace } from 'graph-framework';
8+
import type {
9+
EventMessage,
10+
RequestListInvitations,
11+
RequestListSpaces,
12+
RequestSubscribeToSpace,
13+
SpaceEvent,
14+
SpaceState,
15+
} from 'graph-framework';
16+
import { ResponseMessage, applyEvent, createInvitation, createSpace } from 'graph-framework';
817
import { useEffect, useState } from 'react';
918

19+
const availableAccounts = [
20+
{
21+
accountId: '0262701b2eb1b6b37ad03e24445dfcad1b91309199e43017b657ce2604417c12f5',
22+
signaturePrivateKey: '88bb6f20de8dc1787c722dc847f4cf3d00285b8955445f23c483d1237fe85366',
23+
},
24+
{
25+
accountId: '03bf5d2a1badf15387b08a007d1a9a13a9bfd6e1c56f681e251514d9ba10b57462',
26+
signaturePrivateKey: '1eee32d3bc202dcb5d17c3b1454fb541d2290cb941860735408f1bfe39e7bc15',
27+
},
28+
{
29+
accountId: '0351460706cf386282d9b6ebee2ccdcb9ba61194fd024345e53037f3036242e6a2',
30+
signaturePrivateKey: '434518a2c9a665a7c20da086232c818b6c1592e2edfeecab29a40cf5925ca8fe',
31+
},
32+
];
33+
34+
type SpaceStorageEntry = {
35+
id: string;
36+
events: SpaceEvent[];
37+
state: SpaceState | undefined;
38+
};
39+
1040
const decodeResponseMessage = Schema.decodeUnknownEither(ResponseMessage);
1141

1242
export const Route = createFileRoute('/playground')({
@@ -15,32 +45,73 @@ export const Route = createFileRoute('/playground')({
1545

1646
const App = ({ accountId, signaturePrivateKey }: { accountId: string; signaturePrivateKey: string }) => {
1747
const [websocketConnection, setWebsocketConnection] = useState<WebSocket>();
18-
const [spaces, setSpaces] = useState<{ id: string }[]>([]);
48+
const [spaces, setSpaces] = useState<SpaceStorageEntry[]>([]);
1949

2050
useEffect(() => {
2151
// temporary until we have a way to create accounts and authenticate them
2252
const websocketConnection = new WebSocket(`ws://localhost:3030/?accountId=${accountId}`);
2353
setWebsocketConnection(websocketConnection);
2454

25-
const onMessage = (event: MessageEvent) => {
55+
const onMessage = async (event: MessageEvent) => {
2656
console.log('message received', event.data);
2757
const data = JSON.parse(event.data);
2858
const message = decodeResponseMessage(data);
2959
if (message._tag === 'Right') {
3060
const response = message.right;
3161
switch (response.type) {
3262
case 'list-spaces': {
33-
setSpaces(response.spaces.map((space) => ({ id: space.id })));
63+
setSpaces((existingSpaces) => {
64+
return response.spaces.map((space) => {
65+
const existingSpace = existingSpaces.find((s) => s.id === space.id);
66+
return { id: space.id, events: existingSpace?.events ?? [], state: existingSpace?.state };
67+
});
68+
});
69+
// fetch all spaces (for debugging purposes)
70+
for (const space of response.spaces) {
71+
const message: RequestSubscribeToSpace = { type: 'subscribe-space', id: space.id };
72+
websocketConnection?.send(JSON.stringify(message));
73+
}
3474
break;
3575
}
3676
case 'space': {
37-
console.log('space', response);
77+
let state: SpaceState | undefined = undefined;
78+
79+
// TODO fix typing
80+
for (const event of response.events) {
81+
if (state === undefined) {
82+
const applyEventResult = await Effect.runPromiseExit(applyEvent({ event }));
83+
if (Exit.isSuccess(applyEventResult)) {
84+
state = applyEventResult.value;
85+
}
86+
} else {
87+
const applyEventResult = await Effect.runPromiseExit(applyEvent({ event, state }));
88+
if (Exit.isSuccess(applyEventResult)) {
89+
state = applyEventResult.value;
90+
}
91+
}
92+
}
93+
94+
const newState = state as SpaceState;
95+
96+
setSpaces((spaces) =>
97+
spaces.map((space) => {
98+
if (space.id === response.id) {
99+
// TODO fix readonly type issue
100+
return { ...space, events: response.events as SpaceEvent[], state: newState };
101+
}
102+
return space;
103+
}),
104+
);
38105
break;
39106
}
40107
case 'event': {
41108
console.log('event', response);
42109
break;
43110
}
111+
case 'list-invitations': {
112+
console.log('list-invitations', response);
113+
break;
114+
}
44115
default:
45116
assertExhaustive(response);
46117
}
@@ -86,7 +157,7 @@ const App = ({ accountId, signaturePrivateKey }: { accountId: string; signatureP
86157
},
87158
}),
88159
);
89-
const message: EventMessage = { type: 'event', event: spaceEvent };
160+
const message: EventMessage = { type: 'event', event: spaceEvent, spaceId: spaceEvent.transaction.id };
90161
websocketConnection?.send(JSON.stringify(message));
91162
}}
92163
>
@@ -101,13 +172,22 @@ const App = ({ accountId, signaturePrivateKey }: { accountId: string; signatureP
101172
>
102173
List Spaces
103174
</Button>
175+
176+
<Button
177+
onClick={() => {
178+
const message: RequestListInvitations = { type: 'list-invitations' };
179+
websocketConnection?.send(JSON.stringify(message));
180+
}}
181+
>
182+
List Invitations
183+
</Button>
104184
</div>
105185
<h2>Spaces</h2>
106186
<ul>
107187
{spaces.map((space) => {
108188
return (
109189
<li key={space.id}>
110-
<h3>{space.id}</h3>
190+
<h3>Space id: {space.id}</h3>
111191
<Button
112192
onClick={() => {
113193
const message: RequestSubscribeToSpace = { type: 'subscribe-space', id: space.id };
@@ -116,6 +196,47 @@ const App = ({ accountId, signaturePrivateKey }: { accountId: string; signatureP
116196
>
117197
Get data and subscribe to Space
118198
</Button>
199+
<br />
200+
{availableAccounts.map((invitee) => {
201+
return (
202+
<Button
203+
key={invitee.accountId}
204+
onClick={async () => {
205+
if (!space.state) {
206+
console.error('No state found for space');
207+
return;
208+
}
209+
const spaceEvent = await Effect.runPromiseExit(
210+
createInvitation({
211+
author: {
212+
signaturePublicKey: accountId,
213+
encryptionPublicKey: 'TODO',
214+
signaturePrivateKey,
215+
},
216+
previousEventHash: space.state.lastEventHash,
217+
invitee: {
218+
signaturePublicKey: invitee.accountId,
219+
encryptionPublicKey: 'TODO',
220+
},
221+
}),
222+
);
223+
if (Exit.isFailure(spaceEvent)) {
224+
console.error('Failed to create invitation', spaceEvent);
225+
return;
226+
}
227+
const message: EventMessage = { type: 'event', event: spaceEvent.value, spaceId: space.id };
228+
websocketConnection?.send(JSON.stringify(message));
229+
}}
230+
>
231+
Invite {invitee.accountId.substring(0, 4)}
232+
</Button>
233+
);
234+
})}
235+
<h3>State</h3>
236+
<DebugSpaceState state={space.state} />
237+
<h3>Events</h3>
238+
<DebugSpaceEvents events={space.events} />
239+
<hr />
119240
</li>
120241
);
121242
})}
@@ -132,33 +253,24 @@ export const ChooseAccount = () => {
132253
<h1>Choose account</h1>
133254
<Button
134255
onClick={() => {
135-
setAccount({
136-
accountId: '0262701b2eb1b6b37ad03e24445dfcad1b91309199e43017b657ce2604417c12f5',
137-
signaturePrivateKey: '88bb6f20de8dc1787c722dc847f4cf3d00285b8955445f23c483d1237fe85366',
138-
});
256+
setAccount(availableAccounts[0]);
139257
}}
140258
>
141-
`abc`
259+
{availableAccounts[0].accountId.substring(0, 4)}
142260
</Button>
143261
<Button
144262
onClick={() => {
145-
setAccount({
146-
accountId: '03bf5d2a1badf15387b08a007d1a9a13a9bfd6e1c56f681e251514d9ba10b57462',
147-
signaturePrivateKey: '1eee32d3bc202dcb5d17c3b1454fb541d2290cb941860735408f1bfe39e7bc15',
148-
});
263+
setAccount(availableAccounts[1]);
149264
}}
150265
>
151-
`cde`
266+
{availableAccounts[1].accountId.substring(0, 4)}
152267
</Button>
153268
<Button
154269
onClick={() => {
155-
setAccount({
156-
accountId: '0351460706cf386282d9b6ebee2ccdcb9ba61194fd024345e53037f3036242e6a2',
157-
signaturePrivateKey: '434518a2c9a665a7c20da086232c818b6c1592e2edfeecab29a40cf5925ca8fe',
158-
});
270+
setAccount(availableAccounts[2]);
159271
}}
160272
>
161-
`def`
273+
{availableAccounts[2].accountId.substring(0, 4)}
162274
</Button>
163275
Account: {account?.accountId ? account.accountId : 'none'}
164276
<hr />
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
-- CreateTable
2+
CREATE TABLE "Invitation" (
3+
"id" TEXT NOT NULL PRIMARY KEY,
4+
"spaceId" TEXT NOT NULL,
5+
"accountId" TEXT NOT NULL,
6+
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
7+
CONSTRAINT "Invitation_spaceId_fkey" FOREIGN KEY ("spaceId") REFERENCES "Space" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
8+
CONSTRAINT "Invitation_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "Account" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
9+
);

apps/server/prisma/schema.prisma

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,23 @@ model SpaceEvent {
2323
}
2424

2525
model Space {
26-
id String @id
27-
events SpaceEvent[]
28-
members Account[]
26+
id String @id
27+
events SpaceEvent[]
28+
members Account[]
29+
invitations Invitation[]
2930
}
3031

3132
model Account {
32-
id String @id
33-
spaces Space[]
33+
id String @id
34+
spaces Space[]
35+
invitations Invitation[]
36+
}
37+
38+
model Invitation {
39+
id String @id
40+
space Space @relation(fields: [spaceId], references: [id])
41+
spaceId String
42+
account Account @relation(fields: [accountId], references: [id])
43+
accountId String
44+
createdAt DateTime @default(now())
3445
}

apps/server/src/handlers/applySpaceEvent.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,11 @@ type Params = {
1010
};
1111

1212
export async function applySpaceEvent({ accountId, spaceId, event }: Params) {
13-
return await prisma.$transaction(async (transaction) => {
14-
if (event.transaction.type === 'create-space') {
15-
throw new Error('applySpaceEvent does not support create-space events.');
16-
}
13+
if (event.transaction.type === 'create-space') {
14+
throw new Error('applySpaceEvent does not support create-space events.');
15+
}
1716

17+
return await prisma.$transaction(async (transaction) => {
1818
// verify that the account is a member of the space
1919
// TODO verify that the account is a admin of the space
2020
await transaction.space.findUniqueOrThrow({
@@ -26,11 +26,22 @@ export async function applySpaceEvent({ accountId, spaceId, event }: Params) {
2626
orderBy: { counter: 'desc' },
2727
});
2828

29-
const result = await Effect.runPromiseExit(applyEvent({ event }));
29+
const result = await Effect.runPromiseExit(applyEvent({ event, state: JSON.parse(lastEvent.state) }));
3030
if (Exit.isFailure(result)) {
31+
console.log('Failed to apply event', result);
3132
throw new Error('Invalid event');
3233
}
3334

35+
if (event.transaction.type === 'create-invitation') {
36+
await transaction.invitation.create({
37+
data: {
38+
id: event.transaction.id,
39+
spaceId,
40+
accountId: event.transaction.signaturePublicKey,
41+
},
42+
});
43+
}
44+
3445
return await transaction.spaceEvent.create({
3546
data: {
3647
spaceId,
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { Schema } from 'effect';
2+
import { SpaceState } from 'graph-framework-space-events';
3+
import { prisma } from '../prisma.js';
4+
5+
type Params = {
6+
accountId: string;
7+
};
8+
9+
const decodeSpaceState = Schema.decodeUnknownEither(SpaceState);
10+
11+
export const listInvitations = async ({ accountId }: Params) => {
12+
const result = await prisma.invitation.findMany({
13+
where: {
14+
accountId,
15+
},
16+
include: {
17+
space: {
18+
include: {
19+
events: {
20+
orderBy: {
21+
counter: 'asc',
22+
},
23+
take: 1,
24+
},
25+
},
26+
},
27+
},
28+
});
29+
30+
return result
31+
.map((invitation) => {
32+
const result = decodeSpaceState(JSON.parse(invitation.space.events[0].state));
33+
if (result._tag === 'Right') {
34+
const state = result.right;
35+
return {
36+
id: invitation.id,
37+
previousEventHash: state.lastEventHash,
38+
spaceId: invitation.spaceId,
39+
};
40+
}
41+
console.error('Invalid space state from the DB', result.left);
42+
return null;
43+
})
44+
.filter((invitation) => invitation !== null);
45+
};

0 commit comments

Comments
 (0)