-
Notifications
You must be signed in to change notification settings - Fork 4
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Feature/meditor 896 show collaborators for given document #63
base: main
Are you sure you want to change the base?
Changes from all commits
50eee30
cf19c48
1fbc884
97c6f5c
8728a72
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,23 @@ | ||
import type { User } from 'auth/types' | ||
import type { Collaborator } from './types' | ||
|
||
export function adaptUserToCollaborator( | ||
user: User, | ||
privileges: Collaborator['privileges'], | ||
hasBeenActive: Collaborator['hasBeenActive'], | ||
isActive: Collaborator['isActive'] | ||
): Collaborator { | ||
const { firstName, lastName, uid } = user | ||
const [firstNameInitial] = firstName.split('') | ||
const [lastNameInitial] = lastName.split('') | ||
|
||
return { | ||
firstName, | ||
hasBeenActive, | ||
initials: `${firstNameInitial}${lastNameInitial}`, | ||
isActive, | ||
lastName, | ||
privileges, | ||
uid, | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,94 @@ | ||
import type { Collaborator } from './types' | ||
import type { Db } from 'mongodb' | ||
import getDb, { makeSafeObjectIDs } from '../lib/mongodb' | ||
import { ErrorCode, HttpException } from 'utils/errors' | ||
|
||
class CollaboratorsDb { | ||
#COLLECTION = 'Collaborators' | ||
#db: Db | ||
// Use a short duration so that collaborators are removed quickly when they close the page. | ||
#durationSeconds = 10 | ||
#indexName = 'active_collaborators' | ||
|
||
async connect(connectDb: () => Promise<Db>) { | ||
if (!this.#db) { | ||
this.#db = await connectDb() | ||
} | ||
} | ||
|
||
/** | ||
* Collaborator documents are set to expire with a TTL index. This means that MongoDB handles removing stale collaborators. To stay unexpired, collaborator records must be updated more frequently than the TTL. | ||
* In MongoDB ^4.12.1, creating an index requires that the collection already exist. Unlike other update operations, this does not happen automatically. | ||
*/ | ||
async maybeCreateTTLIndex() { | ||
const indices = await this.#db.collection(this.#COLLECTION).indexes() | ||
const index = indices.find(index => { | ||
return index.name === this.#indexName | ||
}) | ||
|
||
if (!index) { | ||
await this.#db.collection(this.#COLLECTION).createIndex( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This TTL index is created only if it doesn't already exist. It is based on the |
||
{ updatedAt: 1 }, | ||
{ | ||
expireAfterSeconds: this.#durationSeconds, | ||
name: this.#indexName, | ||
} | ||
) | ||
} | ||
} | ||
|
||
async upsertDocumentCollaborator( | ||
collaborator: Collaborator, | ||
documentTitle: string, | ||
modelName: string | ||
) { | ||
const query = { uid: collaborator.uid } | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Since we query on the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed in 97c6f5c. |
||
const operation = { | ||
$set: { | ||
documentModelTitle: `${modelName}_${documentTitle}`, | ||
firstName: collaborator.firstName, | ||
hasBeenActive: collaborator.hasBeenActive, | ||
initials: collaborator.initials, | ||
isActive: collaborator.isActive, | ||
lastName: collaborator.lastName, | ||
uid: collaborator.uid, | ||
updatedAt: new Date(), | ||
}, | ||
} | ||
|
||
const { modifiedCount, upsertedCount } = await this.#db | ||
.collection(this.#COLLECTION) | ||
.updateOne(query, operation, { upsert: true }) | ||
|
||
if (!(modifiedCount || upsertedCount)) { | ||
throw new HttpException( | ||
ErrorCode.InternalServerError, | ||
'Failed to update the collaborator.' | ||
) | ||
} | ||
|
||
await this.maybeCreateTTLIndex() | ||
return await this.getDocumentCollaborators(documentTitle, modelName) | ||
} | ||
|
||
async getDocumentCollaborators(documentTitle: string, modelName: string) { | ||
const query = { documentModelTitle: `${modelName}_${documentTitle}` } | ||
|
||
const results = await this.#db | ||
.collection(this.#COLLECTION) | ||
.find(query) | ||
.toArray() | ||
|
||
return makeSafeObjectIDs(results) ?? [] | ||
} | ||
} | ||
|
||
const db = new CollaboratorsDb() | ||
|
||
async function getCollaboratorsDb() { | ||
await db.connect(getDb) | ||
|
||
return db | ||
} | ||
|
||
export { getCollaboratorsDb } |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,56 @@ | ||
import type { APIError, ErrorData } from '../declarations' | ||
import { ErrorCode, HttpException } from '../utils/errors' | ||
import type { Collaborator } from './types' | ||
|
||
export async function updateCollaborators( | ||
collaborator: Collaborator, | ||
documentTitle: string, | ||
modelName: string | ||
): Promise<ErrorData<Collaborator[]>> { | ||
try { | ||
const response = await fetch( | ||
`/meditor/api/collaboration/models/${encodeURIComponent( | ||
modelName | ||
)}/documents/${encodeURIComponent(documentTitle)}`, | ||
{ body: JSON.stringify(collaborator), method: 'POST' } | ||
) | ||
|
||
if (!response.ok) { | ||
const { error }: APIError = await response.json() | ||
|
||
throw new HttpException(ErrorCode.InternalServerError, error) | ||
} | ||
|
||
const collaborators = await response.json() | ||
|
||
return [null, collaborators] | ||
} catch (error) { | ||
return [error, null] | ||
} | ||
} | ||
|
||
export async function getCollaborators( | ||
documentTitle: string, | ||
modelName: string | ||
): Promise<ErrorData<Collaborator[]>> { | ||
try { | ||
const response = await fetch( | ||
`/meditor/api/collaboration/models/${encodeURIComponent( | ||
modelName | ||
)}/documents/${encodeURIComponent(documentTitle)}`, | ||
{ method: 'GET' } | ||
) | ||
|
||
if (!response.ok) { | ||
const { error }: APIError = await response.json() | ||
|
||
throw new HttpException(ErrorCode.InternalServerError, error) | ||
} | ||
|
||
const collaborators = await response.json() | ||
|
||
return [null, collaborators] | ||
} catch (error) { | ||
return [error, null] | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
import type { User } from 'auth/types' | ||
import type { Collaborator } from './types' | ||
|
||
export function filterActiveUser(collaborators: Collaborator[], activeUser: User) { | ||
return ( | ||
collaborators?.filter(collaborator => collaborator.uid !== activeUser.uid) ?? | ||
[] | ||
) | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
import { z } from 'zod' | ||
|
||
const collaborationInputSchema = z.object({ | ||
firstName: z.string().min(1), | ||
hasBeenActive: z.boolean(), | ||
initials: z.string().length(2), | ||
isActive: z.boolean(), | ||
lastName: z.string().min(1), | ||
privileges: z.array(z.string()), | ||
uid: z.string().min(2), | ||
}) | ||
|
||
export { collaborationInputSchema } |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,56 @@ | ||
import type { ErrorData } from 'declarations' | ||
import { parseZodAsErrorData } from 'utils/errors' | ||
import { getCollaboratorsDb } from './db' | ||
import { collaborationInputSchema } from './schema' | ||
import type { Collaborator } from './types' | ||
|
||
export async function getDocumentCollaborators( | ||
documentTitle: string, | ||
modelName: string | ||
): Promise<ErrorData<Collaborator[]>> { | ||
try { | ||
const collaboratorsDb = await getCollaboratorsDb() | ||
|
||
const results = await collaboratorsDb.getDocumentCollaborators( | ||
documentTitle, | ||
modelName | ||
) | ||
|
||
return [null, results] | ||
} catch (error) { | ||
return [error, null] | ||
} | ||
} | ||
|
||
export async function setDocumentCollaborator( | ||
collaborator: Collaborator, | ||
documentTitle: string, | ||
modelName: string | ||
): Promise<ErrorData<Collaborator[]>> { | ||
try { | ||
const [parsingError, parsedCollaborator] = parseZodAsErrorData<Collaborator>( | ||
collaborationInputSchema, | ||
collaborator | ||
) | ||
|
||
if (parsingError) { | ||
throw parsingError | ||
} | ||
|
||
// This condition is not an error, just business logic: if the user doesn't have edit permissions for the document they are not a collaborator. | ||
if (!parsedCollaborator.privileges.includes('edit')) { | ||
return await getDocumentCollaborators(documentTitle, modelName) | ||
} | ||
const collaboratorsDb = await getCollaboratorsDb() | ||
|
||
const results = await collaboratorsDb.upsertDocumentCollaborator( | ||
parsedCollaborator, | ||
documentTitle, | ||
modelName | ||
) | ||
|
||
return [null, results] | ||
} catch (error) { | ||
return [error, null] | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
import type { User } from 'auth/types' | ||
|
||
export type Collaborator = { | ||
firstName: User['firstName'] | ||
hasBeenActive: boolean | ||
initials: string | ||
isActive: boolean | ||
lastName: User['lastName'] | ||
privileges: string[] | ||
uid: User['uid'] | ||
} | ||
|
||
export type UserActivation = { hasBeenActive: boolean; isActive: boolean } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Receives a user and returns a collaborator, which is similar to a user, but has additional fields specifically for the collaboration feature.