From e71b680f278b83a99c94732980c7d27711c057f4 Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Thu, 14 Nov 2024 18:01:24 +0100 Subject: [PATCH 1/3] implement create-invitation --- .../src/components/debug-space-events.tsx | 15 ++ .../src/components/debug-space-state.tsx | 9 ++ apps/events/src/routes/playground.tsx | 140 +++++++++++++++--- apps/server/src/handlers/applySpaceEvent.ts | 11 +- apps/server/src/index.ts | 11 +- .../graph-framework-messages/src/types.ts | 1 + .../src/apply-event.test.ts | 34 ++--- .../src/apply-event.ts | 9 ++ .../src/create-invitation.test.ts | 70 +++++++-- .../src/create-invitation.ts | 19 ++- 10 files changed, 253 insertions(+), 66 deletions(-) create mode 100644 apps/events/src/components/debug-space-events.tsx create mode 100644 apps/events/src/components/debug-space-state.tsx diff --git a/apps/events/src/components/debug-space-events.tsx b/apps/events/src/components/debug-space-events.tsx new file mode 100644 index 00000000..6be09304 --- /dev/null +++ b/apps/events/src/components/debug-space-events.tsx @@ -0,0 +1,15 @@ +import type { SpaceEvent } from 'graph-framework'; + +export function DebugSpaceEvents({ events }: { events: SpaceEvent[] }) { + return ( + + ); +} diff --git a/apps/events/src/components/debug-space-state.tsx b/apps/events/src/components/debug-space-state.tsx new file mode 100644 index 00000000..9a62c213 --- /dev/null +++ b/apps/events/src/components/debug-space-state.tsx @@ -0,0 +1,9 @@ +import type { SpaceState } from 'graph-framework'; + +export function DebugSpaceState(props: { state: SpaceState | undefined }) { + return ( +
+
{JSON.stringify(props, null, 2)}
+
+ ); +} diff --git a/apps/events/src/routes/playground.tsx b/apps/events/src/routes/playground.tsx index f82e394f..706f6763 100644 --- a/apps/events/src/routes/playground.tsx +++ b/apps/events/src/routes/playground.tsx @@ -1,12 +1,35 @@ +import { DebugSpaceEvents } from '@/components/debug-space-events'; +import { DebugSpaceState } from '@/components/debug-space-state'; import { Button } from '@/components/ui/button'; import { assertExhaustive } from '@/lib/assertExhaustive'; import { createFileRoute } from '@tanstack/react-router'; -import { Effect } from 'effect'; +import { Effect, Exit } from 'effect'; import * as Schema from 'effect/Schema'; -import type { EventMessage, RequestListSpaces, RequestSubscribeToSpace } from 'graph-framework'; -import { ResponseMessage, createSpace } from 'graph-framework'; +import type { EventMessage, RequestListSpaces, RequestSubscribeToSpace, SpaceEvent, SpaceState } from 'graph-framework'; +import { ResponseMessage, applyEvent, createInvitation, createSpace } from 'graph-framework'; import { useEffect, useState } from 'react'; +const availableAccounts = [ + { + accountId: '0262701b2eb1b6b37ad03e24445dfcad1b91309199e43017b657ce2604417c12f5', + signaturePrivateKey: '88bb6f20de8dc1787c722dc847f4cf3d00285b8955445f23c483d1237fe85366', + }, + { + accountId: '03bf5d2a1badf15387b08a007d1a9a13a9bfd6e1c56f681e251514d9ba10b57462', + signaturePrivateKey: '1eee32d3bc202dcb5d17c3b1454fb541d2290cb941860735408f1bfe39e7bc15', + }, + { + accountId: '0351460706cf386282d9b6ebee2ccdcb9ba61194fd024345e53037f3036242e6a2', + signaturePrivateKey: '434518a2c9a665a7c20da086232c818b6c1592e2edfeecab29a40cf5925ca8fe', + }, +]; + +type SpaceStorageEntry = { + id: string; + events: SpaceEvent[]; + state: SpaceState | undefined; +}; + const decodeResponseMessage = Schema.decodeUnknownEither(ResponseMessage); export const Route = createFileRoute('/playground')({ @@ -15,14 +38,14 @@ export const Route = createFileRoute('/playground')({ const App = ({ accountId, signaturePrivateKey }: { accountId: string; signaturePrivateKey: string }) => { const [websocketConnection, setWebsocketConnection] = useState(); - const [spaces, setSpaces] = useState<{ id: string }[]>([]); + const [spaces, setSpaces] = useState([]); useEffect(() => { // temporary until we have a way to create accounts and authenticate them const websocketConnection = new WebSocket(`ws://localhost:3030/?accountId=${accountId}`); setWebsocketConnection(websocketConnection); - const onMessage = (event: MessageEvent) => { + const onMessage = async (event: MessageEvent) => { console.log('message received', event.data); const data = JSON.parse(event.data); const message = decodeResponseMessage(data); @@ -30,11 +53,48 @@ const App = ({ accountId, signaturePrivateKey }: { accountId: string; signatureP const response = message.right; switch (response.type) { case 'list-spaces': { - setSpaces(response.spaces.map((space) => ({ id: space.id }))); + setSpaces((existingSpaces) => { + return response.spaces.map((space) => { + const existingSpace = existingSpaces.find((s) => s.id === space.id); + return { id: space.id, events: existingSpace?.events ?? [], state: existingSpace?.state }; + }); + }); + // fetch all spaces (for debugging purposes) + for (const space of response.spaces) { + const message: RequestSubscribeToSpace = { type: 'subscribe-space', id: space.id }; + websocketConnection?.send(JSON.stringify(message)); + } break; } case 'space': { - console.log('space', response); + let state: SpaceState | undefined = undefined; + + // TODO fix typing + for (const event of response.events) { + if (state === undefined) { + const applyEventResult = await Effect.runPromiseExit(applyEvent({ event })); + if (Exit.isSuccess(applyEventResult)) { + state = applyEventResult.value; + } + } else { + const applyEventResult = await Effect.runPromiseExit(applyEvent({ event, state })); + if (Exit.isSuccess(applyEventResult)) { + state = applyEventResult.value; + } + } + } + + const newState = state as SpaceState; + + setSpaces((spaces) => + spaces.map((space) => { + if (space.id === response.id) { + // TODO fix readonly type issue + return { ...space, events: response.events as SpaceEvent[], state: newState }; + } + return space; + }), + ); break; } case 'event': { @@ -86,7 +146,7 @@ const App = ({ accountId, signaturePrivateKey }: { accountId: string; signatureP }, }), ); - const message: EventMessage = { type: 'event', event: spaceEvent }; + const message: EventMessage = { type: 'event', event: spaceEvent, spaceId: spaceEvent.transaction.id }; websocketConnection?.send(JSON.stringify(message)); }} > @@ -107,7 +167,7 @@ const App = ({ accountId, signaturePrivateKey }: { accountId: string; signatureP {spaces.map((space) => { return (
  • -

    {space.id}

    +

    Space id: {space.id}

    +
    + {availableAccounts.map((invitee) => { + return ( + + ); + })} +

    State

    + +

    Events

    + +
  • ); })} @@ -132,33 +233,24 @@ export const ChooseAccount = () => {

    Choose account

    Account: {account?.accountId ? account.accountId : 'none'}
    diff --git a/apps/server/src/handlers/applySpaceEvent.ts b/apps/server/src/handlers/applySpaceEvent.ts index 4dfec7fa..4eb2c558 100644 --- a/apps/server/src/handlers/applySpaceEvent.ts +++ b/apps/server/src/handlers/applySpaceEvent.ts @@ -10,11 +10,11 @@ type Params = { }; export async function applySpaceEvent({ accountId, spaceId, event }: Params) { - return await prisma.$transaction(async (transaction) => { - if (event.transaction.type === 'create-space') { - throw new Error('applySpaceEvent does not support create-space events.'); - } + if (event.transaction.type === 'create-space') { + throw new Error('applySpaceEvent does not support create-space events.'); + } + return await prisma.$transaction(async (transaction) => { // verify that the account is a member of the space // TODO verify that the account is a admin of the space await transaction.space.findUniqueOrThrow({ @@ -26,8 +26,9 @@ export async function applySpaceEvent({ accountId, spaceId, event }: Params) { orderBy: { counter: 'desc' }, }); - const result = await Effect.runPromiseExit(applyEvent({ event })); + const result = await Effect.runPromiseExit(applyEvent({ event, state: JSON.parse(lastEvent.state) })); if (Exit.isFailure(result)) { + console.log('Failed to apply event', result); throw new Error('Invalid event'); } diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index ae101014..719d8e8e 100755 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -1,13 +1,14 @@ import cors from 'cors'; import 'dotenv/config'; -import { parse } from 'node:url'; import { Effect, Exit, Schema } from 'effect'; import express from 'express'; import type { ResponseListSpaces, ResponseSpace } from 'graph-framework-messages'; import { RequestMessage } from 'graph-framework-messages'; import { type CreateSpaceEvent, applyEvent } from 'graph-framework-space-events'; +import { parse } from 'node:url'; import type WebSocket from 'ws'; import { WebSocketServer } from 'ws'; +import { applySpaceEvent } from './handlers/applySpaceEvent.js'; import { createSpace } from './handlers/createSpace.js'; import { getSpace } from './handlers/getSpace.js'; import { listSpaces } from './handlers/listSpaces.js'; @@ -88,6 +89,14 @@ webSocketServer.on('connection', async (webSocket: WebSocket, request: Request) break; } case 'create-invitation': { + await applySpaceEvent({ accountId, spaceId: data.spaceId, event: data.event }); + const spaceWithEvents = await getSpace({ accountId, spaceId: data.spaceId }); + const outgoingMessage: ResponseSpace = { + type: 'space', + id: data.spaceId, + events: spaceWithEvents.events.map((wrapper) => JSON.parse(wrapper.event)), + }; + webSocket.send(JSON.stringify(outgoingMessage)); break; } } diff --git a/packages/graph-framework-messages/src/types.ts b/packages/graph-framework-messages/src/types.ts index 150c318e..c4172bc9 100644 --- a/packages/graph-framework-messages/src/types.ts +++ b/packages/graph-framework-messages/src/types.ts @@ -3,6 +3,7 @@ import { SpaceEvent } from 'graph-framework-space-events'; export const EventMessage = Schema.Struct({ type: Schema.Literal('event'), + spaceId: Schema.String, event: SpaceEvent, }); diff --git a/packages/graph-framework-space-events/src/apply-event.test.ts b/packages/graph-framework-space-events/src/apply-event.test.ts index 19a9d9bf..0b50873d 100644 --- a/packages/graph-framework-space-events/src/apply-event.test.ts +++ b/packages/graph-framework-space-events/src/apply-event.test.ts @@ -7,13 +7,19 @@ import { createInvitation } from './create-invitation.js'; import { createSpace } from './create-space.js'; import { InvalidEventError, VerifySignatureError } from './types.js'; -it('should fail in case of an invalid signature', async () => { - const author = { - signaturePublicKey: '03594161eed61407084114a142d1ce05ef4c5a5279479fdd73a2b16944fbff003b', - signaturePrivateKey: '76b78f644c19d6133018a97a3bc2d5038be0af5a2858b9e640ff3e2f2db63a0b', - encryptionPublicKey: 'encryption', - }; +const author = { + signaturePublicKey: '03594161eed61407084114a142d1ce05ef4c5a5279479fdd73a2b16944fbff003b', + signaturePrivateKey: '76b78f644c19d6133018a97a3bc2d5038be0af5a2858b9e640ff3e2f2db63a0b', + encryptionPublicKey: 'encryption', +}; + +const invitee = { + signaturePublicKey: '03bf5d2a1badf15387b08a007d1a9a13a9bfd6e1c56f681e251514d9ba10b57462', + signaturePrivateKey: '1eee32d3bc202dcb5d17c3b1454fb541d2290cb941860735408f1bfe39e7bc15', + encryptionPublicKey: 'encryption', +}; +it('should fail in case of an invalid signature', async () => { const result = await Effect.runPromiseExit( Effect.gen(function* () { const spaceEvent = yield* createSpace({ author }); @@ -37,18 +43,12 @@ it('should fail in case of an invalid signature', async () => { }); it('should fail in case state is not provided for an event other than createSpace', async () => { - const author = { - signaturePublicKey: '03594161eed61407084114a142d1ce05ef4c5a5279479fdd73a2b16944fbff003b', - signaturePrivateKey: '76b78f644c19d6133018a97a3bc2d5038be0af5a2858b9e640ff3e2f2db63a0b', - encryptionPublicKey: 'encryption', - }; - const result = await Effect.runPromiseExit( Effect.gen(function* () { const spaceEvent = yield* createSpace({ author }); const state = yield* applyEvent({ event: spaceEvent }); - const spaceEvent2 = yield* createInvitation({ author, id: state.id, previousEventHash: state.lastEventHash }); + const spaceEvent2 = yield* createInvitation({ author, previousEventHash: state.lastEventHash, invitee }); return yield* applyEvent({ event: spaceEvent2 }); }), ); @@ -63,12 +63,6 @@ it('should fail in case state is not provided for an event other than createSpac }); it('should fail in case of an event is applied that is not based on the previous event', async () => { - const author = { - signaturePublicKey: '03594161eed61407084114a142d1ce05ef4c5a5279479fdd73a2b16944fbff003b', - signaturePrivateKey: '76b78f644c19d6133018a97a3bc2d5038be0af5a2858b9e640ff3e2f2db63a0b', - encryptionPublicKey: 'encryption', - }; - const result = await Effect.runPromiseExit( Effect.gen(function* () { const spaceEvent = yield* createSpace({ author }); @@ -77,7 +71,7 @@ it('should fail in case of an event is applied that is not based on the previous const spaceEvent2 = yield* createSpace({ author }); const state2 = yield* applyEvent({ state, event: spaceEvent2 }); - const spaceEvent3 = yield* createInvitation({ author, id: state.id, previousEventHash: state.lastEventHash }); + const spaceEvent3 = yield* createInvitation({ author, previousEventHash: state.lastEventHash, invitee }); return yield* applyEvent({ state: state2, event: spaceEvent3 }); }), ); diff --git a/packages/graph-framework-space-events/src/apply-event.ts b/packages/graph-framework-space-events/src/apply-event.ts index 8c899503..df2310d0 100644 --- a/packages/graph-framework-space-events/src/apply-event.ts +++ b/packages/graph-framework-space-events/src/apply-event.ts @@ -70,6 +70,15 @@ export const applyEvent = ({ members = {}; invitations = {}; } else if (event.transaction.type === 'create-invitation') { + if (members[event.transaction.signaturePublicKey] !== undefined) { + return Effect.fail(new InvalidEventError()); + } + for (const invitation of Object.values(invitations)) { + if (invitation.signaturePublicKey === event.transaction.signaturePublicKey) { + return Effect.fail(new InvalidEventError()); + } + } + invitations[event.transaction.id] = { signaturePublicKey: event.transaction.signaturePublicKey, encryptionPublicKey: event.transaction.encryptionPublicKey, diff --git a/packages/graph-framework-space-events/src/create-invitation.test.ts b/packages/graph-framework-space-events/src/create-invitation.test.ts index e983cc8a..07e1760d 100644 --- a/packages/graph-framework-space-events/src/create-invitation.test.ts +++ b/packages/graph-framework-space-events/src/create-invitation.test.ts @@ -1,22 +1,32 @@ import { expect, it } from 'vitest'; -import { Effect } from 'effect'; +import { Effect, Exit } from 'effect'; import { applyEvent } from './apply-event.js'; import { createInvitation } from './create-invitation.js'; import { createSpace } from './create-space.js'; -it('should create an invitation', async () => { - const author = { - signaturePublicKey: '03594161eed61407084114a142d1ce05ef4c5a5279479fdd73a2b16944fbff003b', - signaturePrivateKey: '76b78f644c19d6133018a97a3bc2d5038be0af5a2858b9e640ff3e2f2db63a0b', - encryptionPublicKey: 'encryption', - }; +const author = { + signaturePublicKey: '03594161eed61407084114a142d1ce05ef4c5a5279479fdd73a2b16944fbff003b', + signaturePrivateKey: '76b78f644c19d6133018a97a3bc2d5038be0af5a2858b9e640ff3e2f2db63a0b', + encryptionPublicKey: 'encryption', +}; + +const invitee = { + signaturePublicKey: '03bf5d2a1badf15387b08a007d1a9a13a9bfd6e1c56f681e251514d9ba10b57462', + signaturePrivateKey: '1eee32d3bc202dcb5d17c3b1454fb541d2290cb941860735408f1bfe39e7bc15', + encryptionPublicKey: 'encryption', +}; +it('should create an invitation', async () => { const { spaceEvent2, state2 } = await Effect.runPromise( Effect.gen(function* () { const spaceEvent = yield* createSpace({ author }); const state = yield* applyEvent({ event: spaceEvent }); - const spaceEvent2 = yield* createInvitation({ author, id: state.id, previousEventHash: state.lastEventHash }); + const spaceEvent2 = yield* createInvitation({ + author, + previousEventHash: state.lastEventHash, + invitee, + }); const state2 = yield* applyEvent({ state, event: spaceEvent2 }); return { state2, @@ -28,8 +38,8 @@ it('should create an invitation', async () => { expect(state2.id).toBeTypeOf('string'); expect(state2.invitations).toEqual({ [spaceEvent2.transaction.id]: { - signaturePublicKey: '', - encryptionPublicKey: '', + signaturePublicKey: '03bf5d2a1badf15387b08a007d1a9a13a9bfd6e1c56f681e251514d9ba10b57462', + encryptionPublicKey: 'encryption', }, }); expect(state2.members).toEqual({ @@ -42,3 +52,43 @@ it('should create an invitation', async () => { expect(state2.removedMembers).toEqual({}); expect(state2.lastEventHash).toBeTypeOf('string'); }); + +it('should fail to invite the account twice', async () => { + const result = await Effect.runPromiseExit( + Effect.gen(function* () { + const spaceEvent = yield* createSpace({ author }); + const state = yield* applyEvent({ event: spaceEvent }); + const spaceEvent2 = yield* createInvitation({ + author, + previousEventHash: state.lastEventHash, + invitee, + }); + const state2 = yield* applyEvent({ state, event: spaceEvent2 }); + const spaceEvent3 = yield* createInvitation({ + author, + previousEventHash: state.lastEventHash, + invitee, + }); + return yield* applyEvent({ state: state2, event: spaceEvent3 }); + }), + ); + + expect(Exit.isFailure(result)).toBe(true); +}); + +it('should fail to invite an account that is already a member', async () => { + const result = await Effect.runPromiseExit( + Effect.gen(function* () { + const spaceEvent = yield* createSpace({ author }); + const state = yield* applyEvent({ event: spaceEvent }); + const spaceEvent2 = yield* createInvitation({ + author, + previousEventHash: state.lastEventHash, + invitee: author, // inviting the author + }); + yield* applyEvent({ state, event: spaceEvent2 }); + }), + ); + + expect(Exit.isFailure(result)).toBe(true); +}); diff --git a/packages/graph-framework-space-events/src/create-invitation.ts b/packages/graph-framework-space-events/src/create-invitation.ts index c6c5519a..8f59ae38 100644 --- a/packages/graph-framework-space-events/src/create-invitation.ts +++ b/packages/graph-framework-space-events/src/create-invitation.ts @@ -1,22 +1,29 @@ import { secp256k1 } from '@noble/curves/secp256k1'; import { Effect } from 'effect'; -import { canonicalize, stringToUint8Array } from 'graph-framework-utils'; +import { canonicalize, generateId, stringToUint8Array } from 'graph-framework-utils'; import type { Author, SpaceEvent } from './types.js'; type Params = { author: Author; - id: string; previousEventHash: string; + invitee: { + signaturePublicKey: string; + encryptionPublicKey: string; + }; }; -export const createInvitation = ({ author, id, previousEventHash }: Params): Effect.Effect => { +export const createInvitation = ({ + author, + previousEventHash, + invitee, +}: Params): Effect.Effect => { const transaction = { + id: generateId(), type: 'create-invitation' as const, - id, ciphertext: '', nonce: '', - signaturePublicKey: '', - encryptionPublicKey: '', + signaturePublicKey: invitee.signaturePublicKey, + encryptionPublicKey: invitee.encryptionPublicKey, previousEventHash, }; const encodedTransaction = stringToUint8Array(canonicalize(transaction)); From eefd0812d94bd7387ab0161af6b7c6c8c2a8b7f2 Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Thu, 14 Nov 2024 18:22:24 +0100 Subject: [PATCH 2/3] list invitations --- apps/events/src/routes/playground.tsx | 22 +++++++++++- .../migration.sql | 9 +++++ apps/server/prisma/schema.prisma | 21 ++++++++--- apps/server/src/handlers/applySpaceEvent.ts | 10 ++++++ apps/server/src/handlers/listInvitations.ts | 35 +++++++++++++++++++ apps/server/src/index.ts | 11 ++++-- .../graph-framework-messages/src/types.ts | 28 +++++++++++++-- 7 files changed, 126 insertions(+), 10 deletions(-) create mode 100644 apps/server/prisma/migrations/20241114170708_add_invitation/migration.sql create mode 100644 apps/server/src/handlers/listInvitations.ts diff --git a/apps/events/src/routes/playground.tsx b/apps/events/src/routes/playground.tsx index 706f6763..eb2b79a0 100644 --- a/apps/events/src/routes/playground.tsx +++ b/apps/events/src/routes/playground.tsx @@ -5,7 +5,14 @@ import { assertExhaustive } from '@/lib/assertExhaustive'; import { createFileRoute } from '@tanstack/react-router'; import { Effect, Exit } from 'effect'; import * as Schema from 'effect/Schema'; -import type { EventMessage, RequestListSpaces, RequestSubscribeToSpace, SpaceEvent, SpaceState } from 'graph-framework'; +import type { + EventMessage, + RequestListInvitations, + RequestListSpaces, + RequestSubscribeToSpace, + SpaceEvent, + SpaceState, +} from 'graph-framework'; import { ResponseMessage, applyEvent, createInvitation, createSpace } from 'graph-framework'; import { useEffect, useState } from 'react'; @@ -101,6 +108,10 @@ const App = ({ accountId, signaturePrivateKey }: { accountId: string; signatureP console.log('event', response); break; } + case 'list-invitations': { + console.log('list-invitations', response); + break; + } default: assertExhaustive(response); } @@ -161,6 +172,15 @@ const App = ({ accountId, signaturePrivateKey }: { accountId: string; signatureP > List Spaces + +

    Spaces

      diff --git a/apps/server/prisma/migrations/20241114170708_add_invitation/migration.sql b/apps/server/prisma/migrations/20241114170708_add_invitation/migration.sql new file mode 100644 index 00000000..51e2d5e1 --- /dev/null +++ b/apps/server/prisma/migrations/20241114170708_add_invitation/migration.sql @@ -0,0 +1,9 @@ +-- CreateTable +CREATE TABLE "Invitation" ( + "id" TEXT NOT NULL PRIMARY KEY, + "spaceId" TEXT NOT NULL, + "accountId" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "Invitation_spaceId_fkey" FOREIGN KEY ("spaceId") REFERENCES "Space" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "Invitation_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "Account" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); diff --git a/apps/server/prisma/schema.prisma b/apps/server/prisma/schema.prisma index 5d29b900..bd2a4ebe 100644 --- a/apps/server/prisma/schema.prisma +++ b/apps/server/prisma/schema.prisma @@ -23,12 +23,23 @@ model SpaceEvent { } model Space { - id String @id - events SpaceEvent[] - members Account[] + id String @id + events SpaceEvent[] + members Account[] + invitations Invitation[] } model Account { - id String @id - spaces Space[] + id String @id + spaces Space[] + invitations Invitation[] +} + +model Invitation { + id String @id + space Space @relation(fields: [spaceId], references: [id]) + spaceId String + account Account @relation(fields: [accountId], references: [id]) + accountId String + createdAt DateTime @default(now()) } diff --git a/apps/server/src/handlers/applySpaceEvent.ts b/apps/server/src/handlers/applySpaceEvent.ts index 4eb2c558..3a4fc542 100644 --- a/apps/server/src/handlers/applySpaceEvent.ts +++ b/apps/server/src/handlers/applySpaceEvent.ts @@ -32,6 +32,16 @@ export async function applySpaceEvent({ accountId, spaceId, event }: Params) { throw new Error('Invalid event'); } + if (event.transaction.type === 'create-invitation') { + await transaction.invitation.create({ + data: { + id: event.transaction.id, + spaceId, + accountId: event.transaction.signaturePublicKey, + }, + }); + } + return await transaction.spaceEvent.create({ data: { spaceId, diff --git a/apps/server/src/handlers/listInvitations.ts b/apps/server/src/handlers/listInvitations.ts new file mode 100644 index 00000000..e92a9ff0 --- /dev/null +++ b/apps/server/src/handlers/listInvitations.ts @@ -0,0 +1,35 @@ +import type { SpaceState } from 'graph-framework-space-events'; +import { prisma } from '../prisma.js'; + +type Params = { + accountId: string; +}; + +export const listInvitations = async ({ accountId }: Params) => { + const result = await prisma.invitation.findMany({ + where: { + accountId, + }, + include: { + space: { + include: { + events: { + orderBy: { + counter: 'asc', + }, + take: 1, + }, + }, + }, + }, + }); + + return result.map((invitation) => { + const state = JSON.parse(invitation.space.events[0].state) as SpaceState; + return { + id: invitation.id, + previousEventHash: state.lastEventHash, + spaceId: invitation.spaceId, + }; + }); +}; diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 719d8e8e..5627da16 100755 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -1,16 +1,17 @@ import cors from 'cors'; import 'dotenv/config'; +import { parse } from 'node:url'; import { Effect, Exit, Schema } from 'effect'; import express from 'express'; -import type { ResponseListSpaces, ResponseSpace } from 'graph-framework-messages'; +import type { ResponseListInvitations, ResponseListSpaces, ResponseSpace } from 'graph-framework-messages'; import { RequestMessage } from 'graph-framework-messages'; import { type CreateSpaceEvent, applyEvent } from 'graph-framework-space-events'; -import { parse } from 'node:url'; import type WebSocket from 'ws'; import { WebSocketServer } from 'ws'; import { applySpaceEvent } from './handlers/applySpaceEvent.js'; import { createSpace } from './handlers/createSpace.js'; import { getSpace } from './handlers/getSpace.js'; +import { listInvitations } from './handlers/listInvitations.js'; import { listSpaces } from './handlers/listSpaces.js'; import { tmpInitAccount } from './handlers/tmpInitAccount.js'; import { assertExhaustive } from './utils/assertExhaustive.js'; @@ -68,6 +69,12 @@ webSocketServer.on('connection', async (webSocket: WebSocket, request: Request) webSocket.send(JSON.stringify(outgoingMessage)); break; } + case 'list-invitations': { + const invitations = await listInvitations({ accountId }); + const outgoingMessage: ResponseListInvitations = { type: 'list-invitations', invitations: invitations }; + webSocket.send(JSON.stringify(outgoingMessage)); + break; + } case 'event': { switch (data.event.transaction.type) { case 'create-space': { diff --git a/packages/graph-framework-messages/src/types.ts b/packages/graph-framework-messages/src/types.ts index c4172bc9..82553440 100644 --- a/packages/graph-framework-messages/src/types.ts +++ b/packages/graph-framework-messages/src/types.ts @@ -22,7 +22,18 @@ export const RequestListSpaces = Schema.Struct({ export type RequestListSpaces = Schema.Schema.Type; -export const RequestMessage = Schema.Union(EventMessage, RequestSubscribeToSpace, RequestListSpaces); +export const RequestListInvitations = Schema.Struct({ + type: Schema.Literal('list-invitations'), +}); + +export type RequestListInvitations = Schema.Schema.Type; + +export const RequestMessage = Schema.Union( + EventMessage, + RequestSubscribeToSpace, + RequestListSpaces, + RequestListInvitations, +); export type RequestMessage = Schema.Schema.Type; @@ -37,6 +48,19 @@ export const ResponseListSpaces = Schema.Struct({ export type ResponseListSpaces = Schema.Schema.Type; +export const ResponseListInvitations = Schema.Struct({ + type: Schema.Literal('list-invitations'), + invitations: Schema.Array( + Schema.Struct({ + id: Schema.String, + previousEventHash: Schema.String, + spaceId: Schema.String, + }), + ), +}); + +export type ResponseListInvitations = Schema.Schema.Type; + export const ResponseSpace = Schema.Struct({ type: Schema.Literal('space'), id: Schema.String, @@ -45,6 +69,6 @@ export const ResponseSpace = Schema.Struct({ export type ResponseSpace = Schema.Schema.Type; -export const ResponseMessage = Schema.Union(EventMessage, ResponseListSpaces, ResponseSpace); +export const ResponseMessage = Schema.Union(EventMessage, ResponseListSpaces, ResponseListInvitations, ResponseSpace); export type ResponseMessage = Schema.Schema.Type; From 2b1ec805f1b4b981bd5e46de10d604fc777fcf86 Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Fri, 15 Nov 2024 15:28:03 +0100 Subject: [PATCH 3/3] improve invitation fetching --- apps/server/src/handlers/listInvitations.ts | 28 ++++++++++++++------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/apps/server/src/handlers/listInvitations.ts b/apps/server/src/handlers/listInvitations.ts index e92a9ff0..e4b17bf5 100644 --- a/apps/server/src/handlers/listInvitations.ts +++ b/apps/server/src/handlers/listInvitations.ts @@ -1,10 +1,13 @@ -import type { SpaceState } from 'graph-framework-space-events'; +import { Schema } from 'effect'; +import { SpaceState } from 'graph-framework-space-events'; import { prisma } from '../prisma.js'; type Params = { accountId: string; }; +const decodeSpaceState = Schema.decodeUnknownEither(SpaceState); + export const listInvitations = async ({ accountId }: Params) => { const result = await prisma.invitation.findMany({ where: { @@ -24,12 +27,19 @@ export const listInvitations = async ({ accountId }: Params) => { }, }); - return result.map((invitation) => { - const state = JSON.parse(invitation.space.events[0].state) as SpaceState; - return { - id: invitation.id, - previousEventHash: state.lastEventHash, - spaceId: invitation.spaceId, - }; - }); + return result + .map((invitation) => { + const result = decodeSpaceState(JSON.parse(invitation.space.events[0].state)); + if (result._tag === 'Right') { + const state = result.right; + return { + id: invitation.id, + previousEventHash: state.lastEventHash, + spaceId: invitation.spaceId, + }; + } + console.error('Invalid space state from the DB', result.left); + return null; + }) + .filter((invitation) => invitation !== null); };