Skip to content

Commit

Permalink
Remix projects: collaborators (#4923)
Browse files Browse the repository at this point in the history
* add project_collaborators table

* add liveblocks api client

* update collaborators when adding myself to collabs

* udpate schema

* split base url var

* port over initials function

* get and update collaborators

* add route to update collaborators

* show collaborators avatars

* fix position

* use brackets

* mustenv

* keys

* no defaults

* no need to transact

* add comment

* test get collaborators

* test updateCollaborators

* give postgres some time to breathe

* try forcing sort

* add comment

* maybe update
  • Loading branch information
ruggi authored Feb 20, 2024
1 parent f817830 commit c49b703
Show file tree
Hide file tree
Showing 21 changed files with 539 additions and 76 deletions.
5 changes: 5 additions & 0 deletions .github/workflows/pull-requests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -518,6 +518,11 @@ jobs:
- name: Test
env:
DATABASE_URL: postgres://postgres:password@localhost:54322/utopia-test?sslmode=disable
APP_ENV: "test"
CORS_ORIGIN: "*"
BACKEND_URL: ""
REACT_APP_EDITOR_URL: ""
LIVEBLOCKS_SECRET_KEY: "secret"
run: |
cd utopia-remix && ./run-integration-tests-ci.sh
Expand Down
12 changes: 11 additions & 1 deletion editor/src/components/editor/server.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { UTOPIA_BACKEND } from '../../common/env-vars'
import { isBackendBFF, UTOPIA_BACKEND, UTOPIA_BACKEND_BASE_URL } from '../../common/env-vars'
import {
assetURL,
getLoginState,
Expand Down Expand Up @@ -491,3 +491,13 @@ export async function downloadAssetsFromProject(
return Promise.all(allPromises)
}
}

export async function updateCollaborators(projectId: string) {
if (isBackendBFF()) {
await fetch(UTOPIA_BACKEND_BASE_URL + `internal/projects/${projectId}/collaborators`, {
method: 'POST',
credentials: 'include',
mode: MODE,
})
}
}
17 changes: 16 additions & 1 deletion editor/src/core/commenting/comment-hooks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { modify, toFirst } from '../shared/optics/optic-utilities'
import { filtered, fromObjectField, traverseArray } from '../shared/optics/optic-creators'
import { foldEither } from '../shared/either'
import { isCanvasThreadMetadata, liveblocksThreadMetadataToUtopia } from './comment-types'
import { updateCollaborators } from '../../components/editor/server'

export function useCanvasCommentThreadAndLocation(comment: CommentId): {
location: CanvasPoint | null
Expand Down Expand Up @@ -209,6 +210,12 @@ export function useAddMyselfToCollaborators() {
'useAddMyselfToCollaborators loginState',
)

const projectId = useEditorState(
Substores.restOfEditor,
(store) => store.editor.id,
'useAddMyselfToCollaborators projectId',
)

const addMyselfToCollaborators = useMutation(
({ storage, self }) => {
if (!isLoggedIn(loginState)) {
Expand All @@ -230,13 +237,21 @@ export function useAddMyselfToCollaborators() {
[loginState],
)

const maybeUpdateCollaborators = React.useCallback(() => {
if (!isLoggedIn(loginState) || projectId == null) {
return
}
void updateCollaborators(projectId)
}, [projectId, loginState])

const collabs = useStorage((store) => store.collaborators)

React.useEffect(() => {
if (collabs != null) {
addMyselfToCollaborators()
}
}, [addMyselfToCollaborators, collabs])
maybeUpdateCollaborators()
}, [addMyselfToCollaborators, collabs, projectId, maybeUpdateCollaborators])
}

export function useCollaborators() {
Expand Down
12 changes: 12 additions & 0 deletions server/migrations/007.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
CREATE TABLE project_collaborators(
id serial,
project_id character varying NOT NULL REFERENCES project_i_d(proj_id) ON DELETE CASCADE,
user_id character varying NOT NULL REFERENCES user_details(user_id) ON DELETE CASCADE,
created_at timestamp with time zone NOT NULL DEFAULT NOW()
);

CREATE INDEX "idx_project_collaborators_project_id" ON "public"."project_collaborators"(project_id);

ALTER TABLE ONLY "public"."project_collaborators"
ADD CONSTRAINT "unique_project_collaborator_project_id_user_id" UNIQUE ("project_id", "user_id");

1 change: 1 addition & 0 deletions server/src/Utopia/Web/Database/Migrations.hs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ migrateDatabase verbose includeInitial pool = withResource pool $ \connection ->
, MigrationFile "004.sql" "./migrations/004.sql"
, MigrationFile "005.sql" "./migrations/005.sql"
, MigrationFile "006.sql" "./migrations/006.sql"
, MigrationFile "007.sql" "./migrations/007.sql"
]
let initialMigrationCommand = if includeInitial
then [MigrationFile "initial.sql" "./migrations/initial.sql"]
Expand Down
1 change: 1 addition & 0 deletions utopia-remix/.env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ BACKEND_URL="http://127.0.0.1:8001"
CORS_ORIGIN="http://localhost:8000"
DATABASE_URL="postgres://<username>:postgres@localhost:5432/utopia"
REACT_APP_EDITOR_URL="http://localhost:8000"
LIVEBLOCKS_SECRET_KEY="<secret>"
41 changes: 41 additions & 0 deletions utopia-remix/app/clients/liveblocks.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import urlJoin from 'url-join'
import { ServerEnvironment } from '../env.server'
import { Method } from '../util/methods.server'
import { Collaborator } from '../types'

const BASE_URL = 'https://api.liveblocks.io/v2'

async function makeRequest<T>(method: Method, path: string): Promise<T> {
const url = urlJoin(BASE_URL, path)
const resp = await fetch(url, {
method: method,
headers: {
Authorization: `Bearer ${ServerEnvironment.LiveblocksSecretKey}`,
},
})
return resp.json()
}

function roomIdFromProjectId(projectId: string): string {
return `project-room-${projectId}`
}

export interface RoomStorage {
data: {
collaborators: RoomCollaborators
}
}

export interface RoomCollaborators {
data: { [userId: string]: { data: Collaborator } }
}

async function getRoomStorage(projectId: string): Promise<RoomStorage> {
const roomId = roomIdFromProjectId(projectId)
return makeRequest('GET', `/rooms/${roomId}/storage`)
}

// REST API docs: https://liveblocks.io/docs/api-reference/rest-api-endpoints
export const LiveblocksAPI = {
getRoomStorage,
}
16 changes: 13 additions & 3 deletions utopia-remix/app/env.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@ declare global {
}

export const ServerEnvironment = {
environment: process.env.APP_ENV,
environment: mustEnv('APP_ENV'),
// The URL of the actual backend server in the form <scheme>://<host>:<port>
BackendURL: process.env.BACKEND_URL ?? '',
BackendURL: mustEnv('BACKEND_URL'),
// the CORS allowed origin for incoming requests
CORSOrigin: process.env.CORS_ORIGIN ?? '',
CORSOrigin: mustEnv('CORS_ORIGIN'),
// the Liveblocks secret key
LiveblocksSecretKey: mustEnv('LIVEBLOCKS_SECRET_KEY'),
}

export type BrowserEnvironment = {
Expand All @@ -21,3 +23,11 @@ export type BrowserEnvironment = {
export const BrowserEnvironment: BrowserEnvironment = {
EDITOR_URL: process.env.REACT_APP_EDITOR_URL,
}

function mustEnv(key: string): string {
const value = process.env[key]
if (value == null) {
throw new Error(`missing required environment variable ${key}`)
}
return value
}
182 changes: 182 additions & 0 deletions utopia-remix/app/models/projectCollaborators.server.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import { LiveblocksAPI, RoomCollaborators, RoomStorage } from '../clients/liveblocks.server'
import { prisma } from '../db.server'
import {
createTestProject,
createTestProjectCollaborator,
createTestUser,
truncateTables,
} from '../test-util'
import { getCollaborators, updateCollaborators } from './projectCollaborators.server'

describe('projectCollaborators model', () => {
afterEach(async () => {
// cleanup
await truncateTables([
prisma.projectCollaborator,
prisma.projectID,
prisma.project,
prisma.userDetails,
])
})

describe('getCollaborators', () => {
beforeEach(async () => {
await createTestUser(prisma, { id: 'bob' })
await createTestUser(prisma, { id: 'alice' })
await createTestUser(prisma, { id: 'wendy' })
await createTestProject(prisma, { id: 'one', ownerId: 'bob' })
await createTestProject(prisma, { id: 'two', ownerId: 'bob' })
await createTestProject(prisma, { id: 'three', ownerId: 'alice' })
await createTestProject(prisma, { id: 'four', ownerId: 'bob' })
await createTestProject(prisma, { id: 'five', ownerId: 'bob' })
await createTestProjectCollaborator(prisma, { projectId: 'one', userId: 'bob' })
await createTestProjectCollaborator(prisma, { projectId: 'two', userId: 'bob' })
await createTestProjectCollaborator(prisma, { projectId: 'two', userId: 'wendy' })
await createTestProjectCollaborator(prisma, { projectId: 'three', userId: 'alice' })
await createTestProjectCollaborator(prisma, { projectId: 'three', userId: 'bob' })
await createTestProjectCollaborator(prisma, { projectId: 'five', userId: 'alice' })
await createTestProjectCollaborator(prisma, { projectId: 'five', userId: 'wendy' })
})
it('returns an empty object if no ids are passed', async () => {
const got = await getCollaborators({ ids: [], userId: 'bob' })
expect(got).toEqual({})
})
it("returns an empty object if ids don't match the given user id", async () => {
let got = await getCollaborators({ ids: ['one', 'two'], userId: 'alice' })
expect(got).toEqual({})
got = await getCollaborators({ ids: ['one', 'two'], userId: 'NOBODY' })
expect(got).toEqual({})
})
it('returns the collaborator details by project id', async () => {
const ids = ['one', 'two', 'four', 'five']
const got = await getCollaborators({ ids: ids, userId: 'bob' })
expect(Object.keys(got)).toEqual(ids)
expect(got['one'].map((c) => c.id)).toEqual(['bob'])
expect(got['two'].map((c) => c.id)).toEqual(['bob', 'wendy'])
expect(got['four'].map((c) => c.id)).toEqual([])
expect(got['five'].map((c) => c.id)).toEqual(['alice', 'wendy'])
})
it('ignores mismatching projects', async () => {
const ids = ['one', 'two', 'three']
const got = await getCollaborators({ ids: ids, userId: 'bob' })
expect(Object.keys(got)).toEqual(['one', 'two'])
expect(got['one'].map((c) => c.id)).toEqual(['bob'])
expect(got['two'].map((c) => c.id)).toEqual(['bob', 'wendy'])
})
})

describe('updateCollaborators', () => {
beforeEach(async () => {
await createTestUser(prisma, { id: 'bob' })
await createTestUser(prisma, { id: 'alice' })
await createTestUser(prisma, { id: 'wendy' })
await createTestProject(prisma, { id: 'one', ownerId: 'bob' })
await createTestProject(prisma, { id: 'two', ownerId: 'bob' })
await createTestProject(prisma, { id: 'three', ownerId: 'alice' })
await createTestProject(prisma, { id: 'four', ownerId: 'bob' })
await createTestProject(prisma, { id: 'five', ownerId: 'bob' })
await createTestProject(prisma, { id: 'six', ownerId: 'bob' })
await createTestProjectCollaborator(prisma, { projectId: 'one', userId: 'bob' })
await createTestProjectCollaborator(prisma, { projectId: 'two', userId: 'bob' })
await createTestProjectCollaborator(prisma, { projectId: 'two', userId: 'wendy' })
await createTestProjectCollaborator(prisma, { projectId: 'three', userId: 'alice' })
await createTestProjectCollaborator(prisma, { projectId: 'three', userId: 'bob' })
await createTestProjectCollaborator(prisma, { projectId: 'five', userId: 'alice' })
await createTestProjectCollaborator(prisma, { projectId: 'five', userId: 'wendy' })
})

const mockGetRoomStorage = (collabs: RoomCollaborators) => async (): Promise<RoomStorage> => {
return {
data: {
collaborators: collabs,
},
}
}

describe('when the project has collaborators', () => {
describe('when the room storage has existing users', () => {
it('updates the collaborators with the data from the room storage', async () => {
LiveblocksAPI.getRoomStorage = mockGetRoomStorage({
data: {
alice: { data: { id: 'alice', name: 'Alice Alisson', avatar: 'alice.png' } },
},
})
await updateCollaborators({ id: 'one' })

const got = await prisma.projectCollaborator.findMany({ where: { project_id: 'one' } })
expect(got.map((c) => c.user_id)).toEqual(['bob', 'alice'])
})
})
describe('when the room storage has duplicate users', () => {
it('only adds the missing ones', async () => {
LiveblocksAPI.getRoomStorage = mockGetRoomStorage({
data: {
alice: { data: { id: 'alice', name: 'Alice Alisson', avatar: 'alice.png' } },
bob: { data: { id: 'bob', name: 'Bob Bobson', avatar: 'bob.png' } },
},
})
await updateCollaborators({ id: 'one' })

const got = await prisma.projectCollaborator.findMany({
where: { project_id: 'one' },
})
expect(got.map((c) => c.user_id)).toEqual(['bob', 'alice'])
})
})
describe('when the room storage has non-existing users', () => {
it('updates the collaborators with only the existing users', async () => {
LiveblocksAPI.getRoomStorage = mockGetRoomStorage({
data: {
alice: {
data: {
id: 'alice',
name: 'Alice Alisson',
avatar: 'alice.png',
},
},
johndoe: {
data: {
id: 'johndoe',
name: 'John Doe',
avatar: 'johndoe.png',
},
},
},
})
await updateCollaborators({ id: 'one' })

const got = await prisma.projectCollaborator.findMany({ where: { project_id: 'one' } })
expect(got.map((c) => c.user_id)).toEqual(['bob', 'alice'])
})
})
})

describe('when the project has no collaborators', () => {
it('adds the collaborators with the data from the room storage', async () => {
LiveblocksAPI.getRoomStorage = mockGetRoomStorage({
data: {
alice: { data: { id: 'alice', name: 'Alice Alisson', avatar: 'alice.png' } },
johndoe: { data: { id: 'johndoe', name: 'John Doe', avatar: 'johndoe.png' } },
bob: { data: { id: 'bob', name: 'Bob Bobson', avatar: 'bob.png' } },
},
})
await updateCollaborators({ id: 'six' })

const got = await prisma.projectCollaborator.findMany({ where: { project_id: 'six' } })
expect(got.map((c) => c.user_id)).toEqual(['bob', 'alice'])
})
})

describe('when the project does not exist', () => {
it('errors', async () => {
LiveblocksAPI.getRoomStorage = mockGetRoomStorage({
data: {
alice: { data: { id: 'alice', name: 'Alice Alisson', avatar: 'alice.png' } },
},
})
const fn = async () => updateCollaborators({ id: 'unknown' })
await expect(fn).rejects.toThrow()
})
})
})
})
Loading

0 comments on commit c49b703

Please sign in to comment.