Skip to content

Commit c49b703

Browse files
authored
Remix projects: collaborators (#4923)
* 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
1 parent f817830 commit c49b703

21 files changed

+539
-76
lines changed

.github/workflows/pull-requests.yml

+5
Original file line numberDiff line numberDiff line change
@@ -518,6 +518,11 @@ jobs:
518518
- name: Test
519519
env:
520520
DATABASE_URL: postgres://postgres:password@localhost:54322/utopia-test?sslmode=disable
521+
APP_ENV: "test"
522+
CORS_ORIGIN: "*"
523+
BACKEND_URL: ""
524+
REACT_APP_EDITOR_URL: ""
525+
LIVEBLOCKS_SECRET_KEY: "secret"
521526
run: |
522527
cd utopia-remix && ./run-integration-tests-ci.sh
523528

editor/src/components/editor/server.ts

+11-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { UTOPIA_BACKEND } from '../../common/env-vars'
1+
import { isBackendBFF, UTOPIA_BACKEND, UTOPIA_BACKEND_BASE_URL } from '../../common/env-vars'
22
import {
33
assetURL,
44
getLoginState,
@@ -491,3 +491,13 @@ export async function downloadAssetsFromProject(
491491
return Promise.all(allPromises)
492492
}
493493
}
494+
495+
export async function updateCollaborators(projectId: string) {
496+
if (isBackendBFF()) {
497+
await fetch(UTOPIA_BACKEND_BASE_URL + `internal/projects/${projectId}/collaborators`, {
498+
method: 'POST',
499+
credentials: 'include',
500+
mode: MODE,
501+
})
502+
}
503+
}

editor/src/core/commenting/comment-hooks.tsx

+16-1
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import { modify, toFirst } from '../shared/optics/optic-utilities'
3939
import { filtered, fromObjectField, traverseArray } from '../shared/optics/optic-creators'
4040
import { foldEither } from '../shared/either'
4141
import { isCanvasThreadMetadata, liveblocksThreadMetadataToUtopia } from './comment-types'
42+
import { updateCollaborators } from '../../components/editor/server'
4243

4344
export function useCanvasCommentThreadAndLocation(comment: CommentId): {
4445
location: CanvasPoint | null
@@ -209,6 +210,12 @@ export function useAddMyselfToCollaborators() {
209210
'useAddMyselfToCollaborators loginState',
210211
)
211212

213+
const projectId = useEditorState(
214+
Substores.restOfEditor,
215+
(store) => store.editor.id,
216+
'useAddMyselfToCollaborators projectId',
217+
)
218+
212219
const addMyselfToCollaborators = useMutation(
213220
({ storage, self }) => {
214221
if (!isLoggedIn(loginState)) {
@@ -230,13 +237,21 @@ export function useAddMyselfToCollaborators() {
230237
[loginState],
231238
)
232239

240+
const maybeUpdateCollaborators = React.useCallback(() => {
241+
if (!isLoggedIn(loginState) || projectId == null) {
242+
return
243+
}
244+
void updateCollaborators(projectId)
245+
}, [projectId, loginState])
246+
233247
const collabs = useStorage((store) => store.collaborators)
234248

235249
React.useEffect(() => {
236250
if (collabs != null) {
237251
addMyselfToCollaborators()
238252
}
239-
}, [addMyselfToCollaborators, collabs])
253+
maybeUpdateCollaborators()
254+
}, [addMyselfToCollaborators, collabs, projectId, maybeUpdateCollaborators])
240255
}
241256

242257
export function useCollaborators() {

server/migrations/007.sql

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
CREATE TABLE project_collaborators(
2+
id serial,
3+
project_id character varying NOT NULL REFERENCES project_i_d(proj_id) ON DELETE CASCADE,
4+
user_id character varying NOT NULL REFERENCES user_details(user_id) ON DELETE CASCADE,
5+
created_at timestamp with time zone NOT NULL DEFAULT NOW()
6+
);
7+
8+
CREATE INDEX "idx_project_collaborators_project_id" ON "public"."project_collaborators"(project_id);
9+
10+
ALTER TABLE ONLY "public"."project_collaborators"
11+
ADD CONSTRAINT "unique_project_collaborator_project_id_user_id" UNIQUE ("project_id", "user_id");
12+

server/src/Utopia/Web/Database/Migrations.hs

+1
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ migrateDatabase verbose includeInitial pool = withResource pool $ \connection ->
2828
, MigrationFile "004.sql" "./migrations/004.sql"
2929
, MigrationFile "005.sql" "./migrations/005.sql"
3030
, MigrationFile "006.sql" "./migrations/006.sql"
31+
, MigrationFile "007.sql" "./migrations/007.sql"
3132
]
3233
let initialMigrationCommand = if includeInitial
3334
then [MigrationFile "initial.sql" "./migrations/initial.sql"]

utopia-remix/.env.sample

+1
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ BACKEND_URL="http://127.0.0.1:8001"
33
CORS_ORIGIN="http://localhost:8000"
44
DATABASE_URL="postgres://<username>:postgres@localhost:5432/utopia"
55
REACT_APP_EDITOR_URL="http://localhost:8000"
6+
LIVEBLOCKS_SECRET_KEY="<secret>"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import urlJoin from 'url-join'
2+
import { ServerEnvironment } from '../env.server'
3+
import { Method } from '../util/methods.server'
4+
import { Collaborator } from '../types'
5+
6+
const BASE_URL = 'https://api.liveblocks.io/v2'
7+
8+
async function makeRequest<T>(method: Method, path: string): Promise<T> {
9+
const url = urlJoin(BASE_URL, path)
10+
const resp = await fetch(url, {
11+
method: method,
12+
headers: {
13+
Authorization: `Bearer ${ServerEnvironment.LiveblocksSecretKey}`,
14+
},
15+
})
16+
return resp.json()
17+
}
18+
19+
function roomIdFromProjectId(projectId: string): string {
20+
return `project-room-${projectId}`
21+
}
22+
23+
export interface RoomStorage {
24+
data: {
25+
collaborators: RoomCollaborators
26+
}
27+
}
28+
29+
export interface RoomCollaborators {
30+
data: { [userId: string]: { data: Collaborator } }
31+
}
32+
33+
async function getRoomStorage(projectId: string): Promise<RoomStorage> {
34+
const roomId = roomIdFromProjectId(projectId)
35+
return makeRequest('GET', `/rooms/${roomId}/storage`)
36+
}
37+
38+
// REST API docs: https://liveblocks.io/docs/api-reference/rest-api-endpoints
39+
export const LiveblocksAPI = {
40+
getRoomStorage,
41+
}

utopia-remix/app/env.server.ts

+13-3
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,13 @@ declare global {
77
}
88

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

1719
export type BrowserEnvironment = {
@@ -21,3 +23,11 @@ export type BrowserEnvironment = {
2123
export const BrowserEnvironment: BrowserEnvironment = {
2224
EDITOR_URL: process.env.REACT_APP_EDITOR_URL,
2325
}
26+
27+
function mustEnv(key: string): string {
28+
const value = process.env[key]
29+
if (value == null) {
30+
throw new Error(`missing required environment variable ${key}`)
31+
}
32+
return value
33+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
import { LiveblocksAPI, RoomCollaborators, RoomStorage } from '../clients/liveblocks.server'
2+
import { prisma } from '../db.server'
3+
import {
4+
createTestProject,
5+
createTestProjectCollaborator,
6+
createTestUser,
7+
truncateTables,
8+
} from '../test-util'
9+
import { getCollaborators, updateCollaborators } from './projectCollaborators.server'
10+
11+
describe('projectCollaborators model', () => {
12+
afterEach(async () => {
13+
// cleanup
14+
await truncateTables([
15+
prisma.projectCollaborator,
16+
prisma.projectID,
17+
prisma.project,
18+
prisma.userDetails,
19+
])
20+
})
21+
22+
describe('getCollaborators', () => {
23+
beforeEach(async () => {
24+
await createTestUser(prisma, { id: 'bob' })
25+
await createTestUser(prisma, { id: 'alice' })
26+
await createTestUser(prisma, { id: 'wendy' })
27+
await createTestProject(prisma, { id: 'one', ownerId: 'bob' })
28+
await createTestProject(prisma, { id: 'two', ownerId: 'bob' })
29+
await createTestProject(prisma, { id: 'three', ownerId: 'alice' })
30+
await createTestProject(prisma, { id: 'four', ownerId: 'bob' })
31+
await createTestProject(prisma, { id: 'five', ownerId: 'bob' })
32+
await createTestProjectCollaborator(prisma, { projectId: 'one', userId: 'bob' })
33+
await createTestProjectCollaborator(prisma, { projectId: 'two', userId: 'bob' })
34+
await createTestProjectCollaborator(prisma, { projectId: 'two', userId: 'wendy' })
35+
await createTestProjectCollaborator(prisma, { projectId: 'three', userId: 'alice' })
36+
await createTestProjectCollaborator(prisma, { projectId: 'three', userId: 'bob' })
37+
await createTestProjectCollaborator(prisma, { projectId: 'five', userId: 'alice' })
38+
await createTestProjectCollaborator(prisma, { projectId: 'five', userId: 'wendy' })
39+
})
40+
it('returns an empty object if no ids are passed', async () => {
41+
const got = await getCollaborators({ ids: [], userId: 'bob' })
42+
expect(got).toEqual({})
43+
})
44+
it("returns an empty object if ids don't match the given user id", async () => {
45+
let got = await getCollaborators({ ids: ['one', 'two'], userId: 'alice' })
46+
expect(got).toEqual({})
47+
got = await getCollaborators({ ids: ['one', 'two'], userId: 'NOBODY' })
48+
expect(got).toEqual({})
49+
})
50+
it('returns the collaborator details by project id', async () => {
51+
const ids = ['one', 'two', 'four', 'five']
52+
const got = await getCollaborators({ ids: ids, userId: 'bob' })
53+
expect(Object.keys(got)).toEqual(ids)
54+
expect(got['one'].map((c) => c.id)).toEqual(['bob'])
55+
expect(got['two'].map((c) => c.id)).toEqual(['bob', 'wendy'])
56+
expect(got['four'].map((c) => c.id)).toEqual([])
57+
expect(got['five'].map((c) => c.id)).toEqual(['alice', 'wendy'])
58+
})
59+
it('ignores mismatching projects', async () => {
60+
const ids = ['one', 'two', 'three']
61+
const got = await getCollaborators({ ids: ids, userId: 'bob' })
62+
expect(Object.keys(got)).toEqual(['one', 'two'])
63+
expect(got['one'].map((c) => c.id)).toEqual(['bob'])
64+
expect(got['two'].map((c) => c.id)).toEqual(['bob', 'wendy'])
65+
})
66+
})
67+
68+
describe('updateCollaborators', () => {
69+
beforeEach(async () => {
70+
await createTestUser(prisma, { id: 'bob' })
71+
await createTestUser(prisma, { id: 'alice' })
72+
await createTestUser(prisma, { id: 'wendy' })
73+
await createTestProject(prisma, { id: 'one', ownerId: 'bob' })
74+
await createTestProject(prisma, { id: 'two', ownerId: 'bob' })
75+
await createTestProject(prisma, { id: 'three', ownerId: 'alice' })
76+
await createTestProject(prisma, { id: 'four', ownerId: 'bob' })
77+
await createTestProject(prisma, { id: 'five', ownerId: 'bob' })
78+
await createTestProject(prisma, { id: 'six', ownerId: 'bob' })
79+
await createTestProjectCollaborator(prisma, { projectId: 'one', userId: 'bob' })
80+
await createTestProjectCollaborator(prisma, { projectId: 'two', userId: 'bob' })
81+
await createTestProjectCollaborator(prisma, { projectId: 'two', userId: 'wendy' })
82+
await createTestProjectCollaborator(prisma, { projectId: 'three', userId: 'alice' })
83+
await createTestProjectCollaborator(prisma, { projectId: 'three', userId: 'bob' })
84+
await createTestProjectCollaborator(prisma, { projectId: 'five', userId: 'alice' })
85+
await createTestProjectCollaborator(prisma, { projectId: 'five', userId: 'wendy' })
86+
})
87+
88+
const mockGetRoomStorage = (collabs: RoomCollaborators) => async (): Promise<RoomStorage> => {
89+
return {
90+
data: {
91+
collaborators: collabs,
92+
},
93+
}
94+
}
95+
96+
describe('when the project has collaborators', () => {
97+
describe('when the room storage has existing users', () => {
98+
it('updates the collaborators with the data from the room storage', async () => {
99+
LiveblocksAPI.getRoomStorage = mockGetRoomStorage({
100+
data: {
101+
alice: { data: { id: 'alice', name: 'Alice Alisson', avatar: 'alice.png' } },
102+
},
103+
})
104+
await updateCollaborators({ id: 'one' })
105+
106+
const got = await prisma.projectCollaborator.findMany({ where: { project_id: 'one' } })
107+
expect(got.map((c) => c.user_id)).toEqual(['bob', 'alice'])
108+
})
109+
})
110+
describe('when the room storage has duplicate users', () => {
111+
it('only adds the missing ones', async () => {
112+
LiveblocksAPI.getRoomStorage = mockGetRoomStorage({
113+
data: {
114+
alice: { data: { id: 'alice', name: 'Alice Alisson', avatar: 'alice.png' } },
115+
bob: { data: { id: 'bob', name: 'Bob Bobson', avatar: 'bob.png' } },
116+
},
117+
})
118+
await updateCollaborators({ id: 'one' })
119+
120+
const got = await prisma.projectCollaborator.findMany({
121+
where: { project_id: 'one' },
122+
})
123+
expect(got.map((c) => c.user_id)).toEqual(['bob', 'alice'])
124+
})
125+
})
126+
describe('when the room storage has non-existing users', () => {
127+
it('updates the collaborators with only the existing users', async () => {
128+
LiveblocksAPI.getRoomStorage = mockGetRoomStorage({
129+
data: {
130+
alice: {
131+
data: {
132+
id: 'alice',
133+
name: 'Alice Alisson',
134+
avatar: 'alice.png',
135+
},
136+
},
137+
johndoe: {
138+
data: {
139+
id: 'johndoe',
140+
name: 'John Doe',
141+
avatar: 'johndoe.png',
142+
},
143+
},
144+
},
145+
})
146+
await updateCollaborators({ id: 'one' })
147+
148+
const got = await prisma.projectCollaborator.findMany({ where: { project_id: 'one' } })
149+
expect(got.map((c) => c.user_id)).toEqual(['bob', 'alice'])
150+
})
151+
})
152+
})
153+
154+
describe('when the project has no collaborators', () => {
155+
it('adds the collaborators with the data from the room storage', async () => {
156+
LiveblocksAPI.getRoomStorage = mockGetRoomStorage({
157+
data: {
158+
alice: { data: { id: 'alice', name: 'Alice Alisson', avatar: 'alice.png' } },
159+
johndoe: { data: { id: 'johndoe', name: 'John Doe', avatar: 'johndoe.png' } },
160+
bob: { data: { id: 'bob', name: 'Bob Bobson', avatar: 'bob.png' } },
161+
},
162+
})
163+
await updateCollaborators({ id: 'six' })
164+
165+
const got = await prisma.projectCollaborator.findMany({ where: { project_id: 'six' } })
166+
expect(got.map((c) => c.user_id)).toEqual(['bob', 'alice'])
167+
})
168+
})
169+
170+
describe('when the project does not exist', () => {
171+
it('errors', async () => {
172+
LiveblocksAPI.getRoomStorage = mockGetRoomStorage({
173+
data: {
174+
alice: { data: { id: 'alice', name: 'Alice Alisson', avatar: 'alice.png' } },
175+
},
176+
})
177+
const fn = async () => updateCollaborators({ id: 'unknown' })
178+
await expect(fn).rejects.toThrow()
179+
})
180+
})
181+
})
182+
})

0 commit comments

Comments
 (0)