diff --git a/code/client/src/services/workspace/workspaceService.ts b/code/client/src/services/workspace/workspaceService.ts index 6aa1a17c..fff86bee 100644 --- a/code/client/src/services/workspace/workspaceService.ts +++ b/code/client/src/services/workspace/workspaceService.ts @@ -26,13 +26,17 @@ function workspaceService(http: HttpCommunication, errorHandler: ErrorHandler) { } async function addWorkspaceMember(id: string, email: string): Promise { - validateEmail(email); - return errorHandler(async () => await http.post(`/workspaces/${id}/members`, { email })); + return errorHandler(async () => { + validateEmail(email); + await http.post(`/workspaces/${id}/members`, { email }); + }); } async function removeWorkspaceMember(id: string, email: string): Promise { - validateEmail(email); - return errorHandler(async () => await http.delete(`/workspaces/${id}/members`, { email })); + return errorHandler(async () => { + validateEmail(email); + await http.delete(`/workspaces/${id}/members`, { email }); + }); } async function getWorkspaces() { diff --git a/code/client/src/ui/hooks/useAuthRedirect.ts b/code/client/src/ui/hooks/useAuthRedirect.ts new file mode 100644 index 00000000..6c590d45 --- /dev/null +++ b/code/client/src/ui/hooks/useAuthRedirect.ts @@ -0,0 +1,19 @@ +import { useAuth } from '@/contexts/auth/useAuth'; +import { useNavigate } from 'react-router-dom'; +import useError from '@/contexts/error/useError'; +import { useEffect } from 'react'; + +function useAuthRedirect() { + const { isLoggedIn } = useAuth(); + const navigate = useNavigate(); + const { publishError } = useError(); + + useEffect(() => { + if (!isLoggedIn) { + publishError(Error('You need to be logged in to access this page')); + navigate('/login'); + } + }, [isLoggedIn, navigate, publishError]); +} + +export default useAuthRedirect; diff --git a/code/client/src/ui/pages/recent/Recent.tsx b/code/client/src/ui/pages/recent/Recent.tsx index 0dfc9540..d0305227 100644 --- a/code/client/src/ui/pages/recent/Recent.tsx +++ b/code/client/src/ui/pages/recent/Recent.tsx @@ -5,6 +5,7 @@ import { Link } from 'react-router-dom'; import { formatTimePassed } from '@/utils/utils'; import { useCommunication } from '@/contexts/communication/useCommunication'; import useError from '@/contexts/error/useError'; +import useAuthRedirect from '@ui/hooks/useAuthRedirect'; import './Recent.scss'; function Recent() { @@ -13,6 +14,8 @@ function Recent() { const { publishError } = useError(); const { loading, startLoading, stopLoading, spinner } = useLoading(); + useAuthRedirect(); + useEffect(() => { async function fetchRecentDocuments() { try { diff --git a/code/client/src/ui/pages/workspaces/Workspaces.tsx b/code/client/src/ui/pages/workspaces/Workspaces.tsx index 81bfd85b..1c6dd6a5 100644 --- a/code/client/src/ui/pages/workspaces/Workspaces.tsx +++ b/code/client/src/ui/pages/workspaces/Workspaces.tsx @@ -6,6 +6,7 @@ import { MdDelete } from 'react-icons/md'; import { useEffect, useState } from 'react'; import { sortWorkspaces } from '@domain/workspaces/utils'; import { useCommunication } from '@/contexts/communication/useCommunication'; +import useAuthRedirect from '@ui/hooks/useAuthRedirect'; import './Workspaces.scss'; function Workspaces() { @@ -14,6 +15,8 @@ function Workspaces() { const [rows, setRows] = useState(workspaces); const { socket } = useCommunication(); + useAuthRedirect(); + useEffect(() => { socket.connect(); return () => socket.disconnect(); @@ -25,7 +28,7 @@ function Workspaces() { return (
-

Workspaces

+

My Workspaces

0} diff --git a/code/server/sql/create_tables.sql b/code/server/sql/create_tables.sql index 44187369..78d296eb 100644 --- a/code/server/sql/create_tables.sql +++ b/code/server/sql/create_tables.sql @@ -11,8 +11,7 @@ CREATE TABLE IF NOT EXISTS workspace ( id CHAR(16) PRIMARY KEY DEFAULT encode(gen_random_bytes(8), 'hex'), name TEXT NOT NULL, "isPrivate" BOOLEAN NOT NULL DEFAULT false, - "createdAt" TIMESTAMP NOT NULL DEFAULT now(), - members TEXT[] NOT NULL DEFAULT '{}'::TEXT[] -- references "user"(email) + "createdAt" TIMESTAMP NOT NULL DEFAULT now() ); -- Create resource table @@ -24,7 +23,7 @@ CREATE TABLE IF NOT EXISTS resource ( "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), parent CHAR(16) DEFAULT NULL REFERENCES resource(id) ON DELETE CASCADE, - children CHAR(16)[] NOT NULL DEFAULT '{}'::CHAR(16)[] -- references resource(id) + children CHAR(16)[] NOT NULL DEFAULT '{}'::CHAR(16)[] ); -- Create user table @@ -35,7 +34,14 @@ CREATE TABLE IF NOT EXISTS "user" ( "createdAt" TIMESTAMP NOT NULL DEFAULT now() ); --- Trigger functions +CREATE TABLE IF NOT EXISTS workspace_member ( + wid CHAR(16) NOT NULL REFERENCES workspace(id) ON DELETE CASCADE, + uid CHAR(28) NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + PRIMARY KEY (wid, uid) +); + + +-- Triggers -- Resource is deleted -> Remove self from parent's children array create or replace function on_child_removed() returns trigger as $$ diff --git a/code/server/sql/drop_tables.sql b/code/server/sql/drop_tables.sql index c3d129ea..4a3a1f2e 100644 --- a/code/server/sql/drop_tables.sql +++ b/code/server/sql/drop_tables.sql @@ -1,6 +1,9 @@ -begin ; - drop table if exists "user"; - drop table if exists resource cascade; - drop table if exists workspace cascade; - drop type if exists resource_type cascade; -commit ; \ No newline at end of file +begin; + +drop table if exists workspace_member cascade; +drop table if exists resource cascade; +drop table if exists workspace cascade; +drop table if exists "user" cascade; +drop type if exists resource_type cascade; + +commit; \ No newline at end of file diff --git a/code/server/src/databases/memory/MemoryResourcesDB.ts b/code/server/src/databases/memory/MemoryResourcesDB.ts index 9d5984d7..5fbf0ed2 100644 --- a/code/server/src/databases/memory/MemoryResourcesDB.ts +++ b/code/server/src/databases/memory/MemoryResourcesDB.ts @@ -74,9 +74,9 @@ export class MemoryResourcesDB implements ResourcesRepository { throw new NotFoundError(`Resource not found`); } - async getRecentDocuments(email: string): Promise { + async getRecentDocuments(userId: string): Promise { return Object.values(Memory.workspaces) - .filter(workspace => workspace.members.includes(email)) + .filter(workspace => workspace.members.includes(userId)) .flatMap(workspace => Object.values(workspace.resources)) .filter(resource => resource.type === ResourceType.DOCUMENT) .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()) diff --git a/code/server/src/databases/memory/MemoryWorkspacesDB.ts b/code/server/src/databases/memory/MemoryWorkspacesDB.ts index 3b8834c1..c433dab4 100644 --- a/code/server/src/databases/memory/MemoryWorkspacesDB.ts +++ b/code/server/src/databases/memory/MemoryWorkspacesDB.ts @@ -32,14 +32,17 @@ export class MemoryWorkspacesDB implements WorkspacesRepository { async getWorkspaces(email?: string): Promise { return Object.values(Memory.workspaces) .filter(workspace => (email ? workspace.members.includes(email) : !workspace.isPrivate)) - .map(props => omit(props, ['resources'])); + .map(props => { + const w = omit(props, ['resources']); + return { ...w, members: w.members?.map(id => Memory.users[id].email) || [] }; + }); } async getWorkspace(id: string): Promise { const workspace = Memory.workspaces[id]; if (!workspace) throw new NotFoundError(`Workspace not found`); const resources = Object.values(workspace.resources); - return { ...workspace, resources }; + return { ...workspace, resources, members: workspace.members.map(id => Memory.users[id].email) }; } async getResources(wid: string): Promise { @@ -80,6 +83,10 @@ export class MemoryWorkspacesDB implements WorkspacesRepository { .filter(workspace => (query ? workspace.name.toLowerCase().includes(query.toLowerCase()) : true)) // search by name .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) // sort results by creation date (newest first) .slice(skip, skip + limit) // paginate results - .map(workspace => omit(workspace, ['resources'])); // convert to WorkspaceMeta + .map(workspace => { + // convert to WorkspaceMeta + const w = omit(workspace, ['resources']); + return { ...w, members: w.members?.map(id => Memory.users[id].email) || [] }; + }); } } diff --git a/code/server/src/databases/postgres/PostgresResourcesDB.ts b/code/server/src/databases/postgres/PostgresResourcesDB.ts index c796a549..5de87c9d 100644 --- a/code/server/src/databases/postgres/PostgresResourcesDB.ts +++ b/code/server/src/databases/postgres/PostgresResourcesDB.ts @@ -45,19 +45,19 @@ export class PostgresResourcesDB implements ResourcesRepository { if (isEmpty(results)) throw new NotFoundError('Resource not found'); } - async getRecentDocuments(email: string): Promise { + async getRecentDocuments(userId: string): Promise { const results = await sql` - select row_to_json(t) as resource - from ( - select * - from resource - where type = 'D' and workspace in ( - select id from workspace where ${email} = any(members) - ) - order by "updatedAt" desc - limit 10 - ) as t - `; + select row_to_json(t) as resource + from ( + select * + from resource + where type = 'D' and workspace in ( + select workspace_id from workspace_member where user_id = ${userId} + ) + order by "updatedAt" desc + limit 10 + ) as t + `; return results.map(r => r.resource); } } diff --git a/code/server/src/databases/postgres/PostgresWorkspacesDB.ts b/code/server/src/databases/postgres/PostgresWorkspacesDB.ts index 7eee4d3a..3c05bcbf 100644 --- a/code/server/src/databases/postgres/PostgresWorkspacesDB.ts +++ b/code/server/src/databases/postgres/PostgresWorkspacesDB.ts @@ -9,8 +9,8 @@ import { SearchParams } from '@src/utils/searchParams'; export class PostgresWorkspacesDB implements WorkspacesRepository { async createWorkspace(name: string, isPrivate: boolean): Promise { const results = await sql` - insert into workspace (name, "isPrivate") - values (${name}, ${isPrivate}) + insert into workspace (name, "isPrivate") + values (${name}, ${isPrivate}) returning id `; if (isEmpty(results)) throw new Error('Workspace not created'); @@ -19,91 +19,129 @@ export class PostgresWorkspacesDB implements WorkspacesRepository { async getWorkspaces(email?: string): Promise { const results = await sql` - select row_to_json(t) as workspace - from ( - select * - from workspace - where ${email ? sql`${email} = any(members)` : sql`"isPrivate" = false`} - group by id - order by "createdAt" desc - ) as t + select w.id, w.name, w."createdAt", w."isPrivate", array_agg(u.email) as members + from workspace w + left join workspace_member wm on w.id = wm.wid + left join "user" u on wm.uid = u.id + where ${ + email + ? sql`exists + (select 1 + from workspace_member + where wid = w.id and uid = (select id from "user" where email = ${email}))` + : sql`w."isPrivate" = false` + } + group by w.id, w."createdAt" + order by w."createdAt" desc `; - return results.map(r => r.workspace); + return results.map(r => ({ + id: r.id, + name: r.name, + createdAt: r.createdAt, + isPrivate: r.isPrivate, + members: r.members, + })); } async getWorkspace(id: string): Promise { - const results: Workspace[] = await sql` - select * - from workspace - where id = ${id} + const results = await sql` + select w.id, w.name, w."createdAt", w."isPrivate", array_agg(u.email) as members + from workspace w + left join workspace_member wm on w.id = wm.wid + left join "user" u on wm.uid = u.id + where w.id = ${id} + group by w.id `; + if (isEmpty(results)) throw new NotFoundError(`Workspace not found`); - return results[0]; + + const workspaceMeta: WorkspaceMeta = { + id: results[0].id, + name: results[0].name, + createdAt: results[0].createdAt, + isPrivate: results[0].isPrivate, + members: results[0].members, + }; + + const resources: Resource[] = await this.getResources(id); + return { + ...workspaceMeta, + resources, + }; } async getResources(wid: string): Promise { return ( await sql` - select row_to_json(t) as resources - from ( - select * - from resource - where workspace = ${wid} - group by id - order by "updatedAt" desc - ) as t + select row_to_json(t) as resources + from ( + select * + from resource + where workspace = ${wid} + group by id + order by "updatedAt" desc + ) as t ` ).map(r => r.resources); } async updateWorkspace(id: string, newProps: Partial): Promise { const results = await sql` - update workspace - set ${sql(newProps)} - where id = ${id} - returning id + update workspace + set ${sql(newProps)} + where id = ${id} + returning id `; if (isEmpty(results)) throw new NotFoundError(`Workspace not found`); } async deleteWorkspace(id: string): Promise { const results = await sql` - delete from workspace where id = ${id} - returning id + delete from workspace where id = ${id} + returning id `; if (isEmpty(results)) throw new NotFoundError(`Workspace not found`); } - async addWorkspaceMember(wid: string, email: string): Promise { - const result = await sql` - update workspace - set members = array_append(members, ${email}::text) - where id = ${wid} and not ${email} = any(members) - returning members + async addWorkspaceMember(wid: string, userId: string): Promise { + await sql` + insert into workspace_member (wid, uid) + values (${wid}, ${userId}) + on conflict do nothing `; - if (isEmpty(result)) throw new NotFoundError(`Workspace not found`); - return result[0].members; + return this.getWorkspaceMembers(wid); } - async removeWorkspaceMember(wid: string, email: string): Promise { - const result = await sql` - update workspace - set members = array_remove(members, ${email}::text) - where id = ${wid} - returning members + async removeWorkspaceMember(wid: string, userId: string): Promise { + await sql` + delete from workspace_member + where wid = ${wid} and uid = ${userId} `; - if (isEmpty(result)) throw new NotFoundError(`Workspace not found`); - return result[0].members; + return this.getWorkspaceMembers(wid); } async searchWorkspaces(searchParams: SearchParams, email?: string): Promise { const { query, skip, limit } = searchParams; return sql` - select * - from workspace - where ("isPrivate" = false or ${email || ''} = any(members)) and name ilike ${'%' + query + '%'} - order by "createdAt" desc + select w.* + from workspace w + left join workspace_member wm on w.id = wm.wid + left join "user" u on wm.uid = u.id + where (w."isPrivate" = false or ${email ? sql`u.email = ${email}` : sql`true`}) + and w.name ilike ${'%' + query + '%'} + group by w.id, w."createdAt" + order by w."createdAt" desc offset ${skip} limit ${limit} `; } + + private async getWorkspaceMembers(wid: string): Promise { + const result = await sql` + select array_agg(u.email) as members + from workspace_member wm + join "user" u on wm.uid = u.id + where wm.wid = ${wid} + `; + return result[0].members; + } } diff --git a/code/server/src/databases/types.ts b/code/server/src/databases/types.ts index 6751cfa2..922152a1 100644 --- a/code/server/src/databases/types.ts +++ b/code/server/src/databases/types.ts @@ -69,10 +69,11 @@ export interface ResourcesRepository { * @param id */ deleteResource: (id: string) => Promise; - /** Get resources recently edited by a user - * @param email + /** + * Get resources recently edited by a user + * @param userId */ - getRecentDocuments: (email: string) => Promise; + getRecentDocuments: (userId: string) => Promise; } export interface WorkspacesRepository { @@ -112,9 +113,9 @@ export interface WorkspacesRepository { /** * Add a member to a workspace, returning the current list of members * @param wid - * @param email + * @param userId */ - addWorkspaceMember: (wid: string, email: string) => Promise; + addWorkspaceMember: (wid: string, userId: string) => Promise; /** * Remove a member from a workspace, returning the current list of members * @param wid diff --git a/code/server/src/services/ResourcesService.ts b/code/server/src/services/ResourcesService.ts index a1b2986f..1391449c 100644 --- a/code/server/src/services/ResourcesService.ts +++ b/code/server/src/services/ResourcesService.ts @@ -51,6 +51,7 @@ export class ResourcesService { async getRecentDocuments(email: string): Promise { validateEmail(email); - return this.databases.resources.getRecentDocuments(email); + const user = await this.databases.users.getUserByEmail(email); + return this.databases.resources.getRecentDocuments(user.id); } } diff --git a/code/server/src/services/WorkspacesService.ts b/code/server/src/services/WorkspacesService.ts index db24c7fd..cc232ed2 100644 --- a/code/server/src/services/WorkspacesService.ts +++ b/code/server/src/services/WorkspacesService.ts @@ -50,15 +50,15 @@ export class WorkspacesService { async addWorkspaceMember(wid: string, email: string): Promise { validateId(wid); validateEmail(email); - await this.databases.users.getUserByEmail(email); // ensure user exists - return await this.databases.workspaces.addWorkspaceMember(wid, email); + const user = await this.databases.users.getUserByEmail(email); + return await this.databases.workspaces.addWorkspaceMember(wid, user.id); } async removeWorkspaceMember(wid: string, email: string): Promise { validateId(wid); validateEmail(email); - await this.databases.users.getUserByEmail(email); // ensure user exists - return await this.databases.workspaces.removeWorkspaceMember(wid, email); + const user = await this.databases.users.getUserByEmail(email); + return await this.databases.workspaces.removeWorkspaceMember(wid, user.id); } async searchWorkspaces(searchParams: SearchParams, email?: string) { diff --git a/code/server/test/documents/commits.test.ts b/code/server/test/documents/commits.test.ts index be8e9a25..a9843576 100644 --- a/code/server/test/documents/commits.test.ts +++ b/code/server/test/documents/commits.test.ts @@ -1,13 +1,12 @@ -import { TestDatabases } from '../../src/databases/TestDatabases'; import { Services } from '../../src/services/Services'; import { DocumentResource } from '@notespace/shared/src/workspace/types/resource'; import { DeleteOperation } from '@notespace/shared/src/document/types/operations'; -import { createTestCommit } from '../utils'; +import { createTestCommit, testServices } from '../utils'; let services: Services; beforeEach(() => { - services = new Services(new TestDatabases()); + services = testServices(); }); describe('Commit operations', () => { diff --git a/code/server/test/documents/documents.test.ts b/code/server/test/documents/documents.test.ts index d37c2ecc..db38614f 100644 --- a/code/server/test/documents/documents.test.ts +++ b/code/server/test/documents/documents.test.ts @@ -1,13 +1,12 @@ -import { TestDatabases } from '../../src/databases/TestDatabases'; import { Services } from '../../src/services/Services'; import { DocumentResource, ResourceType } from '@notespace/shared/src/workspace/types/resource'; import { InsertOperation } from '@notespace/shared/src/document/types/operations'; -import { createTestUserAndWorkspace } from '../utils'; +import { createTestUserAndWorkspace, testServices } from '../utils'; let services: Services; beforeEach(() => { - services = new Services(new TestDatabases()); + services = testServices(); }); describe('Document operations', () => { diff --git a/code/server/test/resources/resources.test.ts b/code/server/test/resources/resources.test.ts index 7dadcc1f..cb6d9d53 100644 --- a/code/server/test/resources/resources.test.ts +++ b/code/server/test/resources/resources.test.ts @@ -1,12 +1,11 @@ -import { TestDatabases } from '../../src/databases/TestDatabases'; import { Services } from '../../src/services/Services'; import { ResourceType } from '@notespace/shared/src/workspace/types/resource'; -import { excludeRoot } from '../utils'; +import { excludeRoot, testServices } from '../utils'; let services: Services; beforeEach(() => { - services = new Services(new TestDatabases()); + services = testServices(); }); describe('Resource operations', () => { diff --git a/code/server/test/users/users.test.ts b/code/server/test/users/users.test.ts index 6c7ecfec..15cd8fd6 100644 --- a/code/server/test/users/users.test.ts +++ b/code/server/test/users/users.test.ts @@ -1,12 +1,12 @@ -import { TestDatabases } from '../../src/databases/TestDatabases'; import { Services } from '../../src/services/Services'; import { User } from '@notespace/shared/src/users/types'; import { getRandomId } from '../../src/services/utils'; +import { testServices } from '../utils'; let services: Services; beforeEach(() => { - services = new Services(new TestDatabases()); + services = testServices(); }); describe('User operations', () => { diff --git a/code/server/test/utils.ts b/code/server/test/utils.ts index 69016f58..e79d21a4 100644 --- a/code/server/test/utils.ts +++ b/code/server/test/utils.ts @@ -3,6 +3,11 @@ import { Services } from '../src/services/Services'; import { Resource, ResourceType } from '@notespace/shared/src/workspace/types/resource'; import { InsertOperation } from '@notespace/shared/src/document/types/operations'; import { Author } from '@notespace/shared/src/document/types/commits'; +import { TestDatabases } from '../src/databases/TestDatabases'; + +export function testServices(): Services { + return new Services(new TestDatabases()); +} export async function createTestUserAndWorkspace(services: Services) { const userId = getRandomId(); diff --git a/code/server/test/workspaces/members.test.ts b/code/server/test/workspaces/members.test.ts index 5c4ea3cf..086de07a 100644 --- a/code/server/test/workspaces/members.test.ts +++ b/code/server/test/workspaces/members.test.ts @@ -1,11 +1,10 @@ -import { TestDatabases } from '../../src/databases/TestDatabases'; import { Services } from '../../src/services/Services'; -import { createTestUserAndWorkspace } from '../utils'; +import { createTestUserAndWorkspace, testServices } from '../utils'; let services: Services; beforeEach(() => { - services = new Services(new TestDatabases()); + services = testServices(); }); describe('Workspace members operations', () => { diff --git a/code/server/test/workspaces/tree.test.ts b/code/server/test/workspaces/tree.test.ts index 5148f28d..d26dcd56 100644 --- a/code/server/test/workspaces/tree.test.ts +++ b/code/server/test/workspaces/tree.test.ts @@ -1,12 +1,11 @@ -import { TestDatabases } from '../../src/databases/TestDatabases'; import { Services } from '../../src/services/Services'; import { ResourceType } from '@notespace/shared/src/workspace/types/resource'; -import { excludeRoot } from '../utils'; +import { excludeRoot, testServices } from '../utils'; let services: Services; beforeEach(() => { - services = new Services(new TestDatabases()); + services = testServices(); }); describe('Workspace tree operations', () => { diff --git a/code/server/test/workspaces/workspaces.test.ts b/code/server/test/workspaces/workspaces.test.ts index 53049c4b..d7776266 100644 --- a/code/server/test/workspaces/workspaces.test.ts +++ b/code/server/test/workspaces/workspaces.test.ts @@ -1,11 +1,11 @@ -import { TestDatabases } from '../../src/databases/TestDatabases'; import { Services } from '../../src/services/Services'; import { WorkspaceMeta } from '@notespace/shared/src/workspace/types/workspace'; +import { testServices } from '../utils'; let services: Services; beforeEach(() => { - services = new Services(new TestDatabases()); + services = testServices(); }); describe('Workspace operations', () => { diff --git a/code/shared/src/workspace/types/workspace.ts b/code/shared/src/workspace/types/workspace.ts index ae0913ce..8296c16b 100644 --- a/code/shared/src/workspace/types/workspace.ts +++ b/code/shared/src/workspace/types/workspace.ts @@ -8,9 +8,8 @@ export type WorkspaceMeta = { isPrivate: boolean; }; -export type Workspace = Omit & { +export type Workspace = WorkspaceMeta & { resources: Resource[]; - members: string[]; }; export interface WorkspaceInputModel {