Skip to content
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

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.11.0] - 2024-09-19

### Added

- Added feature for seeing other collaborators on a given document.

## [1.10.0] - 2024-06-04

### Added
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "meditor",
"version": "1.10.0",
"version": "1.11.0",
"description": "mEditor, the model editor",
"directories": {
"example": "examples",
Expand Down
1 change: 0 additions & 1 deletion packages/app/auth/db.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import type { Db } from 'mongodb'
import type { Document } from '../documents/types'
import getDb, { makeSafeObjectIDs } from '../lib/mongodb'
import type { Model, ModelWithWorkflow } from '../models/types'
import type { UserContactInformation } from './types'
Expand Down
23 changes: 23 additions & 0 deletions packages/app/collaboration/adapters.ts
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(
Copy link
Contributor Author

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.

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,
}
}
94 changes: 94 additions & 0 deletions packages/app/collaboration/db.ts
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(
Copy link
Contributor Author

Choose a reason for hiding this comment

The 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 field, which is a BSON ISODate.

{ updatedAt: 1 },
{
expireAfterSeconds: this.#durationSeconds,
name: this.#indexName,
}
)
}
}

async upsertDocumentCollaborator(
collaborator: Collaborator,
documentTitle: string,
modelName: string
) {
const query = { uid: collaborator.uid }
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we query on the uid as our PK, each user may only be collaborating on one document at a time, which is recorded in documentModelTitle. If users have multiple document open, and each is sending an HTTP request to the API, I could see this getting weird. I should probably add (to the document page) something to check which tab is active...

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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 }
56 changes: 56 additions & 0 deletions packages/app/collaboration/http.ts
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]
}
}
9 changes: 9 additions & 0 deletions packages/app/collaboration/lib.ts
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) ??
[]
)
}
13 changes: 13 additions & 0 deletions packages/app/collaboration/schema.ts
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 }
56 changes: 56 additions & 0 deletions packages/app/collaboration/service.ts
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]
}
}
13 changes: 13 additions & 0 deletions packages/app/collaboration/types.ts
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 }
50 changes: 49 additions & 1 deletion packages/app/components/document/document-header.module.css
Original file line number Diff line number Diff line change
@@ -1,10 +1,23 @@
.header {
display: flex;
flex-wrap: wrap;
}

.titleDescription {
flex: 1 1 auto;
}

.title {
align-items: center;
display: flex;
font-size: 24px;
font-weight: 500;
}

.title h2 {
margin-block: 0;
}

.title span {
box-shadow: 0 2px 5px 0 rgba(0, 0, 0, 0.26);
font-size: 0.7em;
Expand All @@ -17,12 +30,47 @@

.description {
color: rgba(0, 0, 0, 0.54);
flex: 1 0 100%;
margin-top: 5px;
}

.subheader {
.collaborators {
align-items: center;
block-size: 4em;
display: flex;
flex-wrap: nowrap;
flex: 0 1 auto;
gap: 1em;
justify-content: flex-start;
max-inline-size: 23rem;
}

.collaborator {
align-items: center;
block-size: 3em;
border-radius: 50%;
border: 0.25em solid #e0e0e0;
display: flex;
flex: 0 0 auto;
font-size: 1.25rem;
font-weight: 500;
inline-size: 3em;
justify-content: center;
letter-spacing: 0.125rem;
}

.collaborator[data-has-been-active='true'] {
border-color: #6c757d;
}

.collaborator[data-is-active='true'] {
border-color: #007bff;
}

.subheader {
align-items: center;
display: flex;
flex: 1 0 100%;
margin-top: 20px;
}

Expand Down
Loading
Loading