Merge pull request #28246 from overleaf/td-ts-project-dashboard-jsdoc

Working JSDoc type annotations on project list controller

GitOrigin-RevId: b26833affb0fc2ecd38e869c2523e914eabe6548
This commit is contained in:
Tim Down
2025-09-08 10:10:06 +01:00
committed by Copybot
parent 009bc4463d
commit 36cbe840dd
15 changed files with 258 additions and 90 deletions

View File

@@ -1,3 +1,4 @@
// @ts-check
const { ForbiddenError, UserNotFoundError } = require('../Errors/Errors')
const {
getUserCapabilities,

View File

@@ -1,3 +1,6 @@
// @ts-check
/** @type {import('./types').PrivilegeLevelsType} */
const PrivilegeLevels = {
NONE: false,
READ_ONLY: 'readOnly',

View File

@@ -1,3 +1,5 @@
// @ts-check
/**
* Note:
* It used to be that `project.publicAccessLevel` could be set to `private`,
@@ -9,6 +11,8 @@
* `publicAccessLevel` to the legacy values, there are projects in the system
* that already have those values set.
*/
/** @type {import('./types').PublicAccessLevelsType} */
module.exports = {
READ_ONLY: 'readOnly', // LEGACY
READ_AND_WRITE: 'readAndWrite', // LEGACY

View File

@@ -1,3 +1,6 @@
// @ts-check
/** @type {import('./types').SourcesType} */
module.exports = {
INVITE: 'invite',
TOKEN: 'token',

View File

@@ -0,0 +1,28 @@
type ValueOf<T> = T[keyof T]
export const SourcesType = {
INVITE: 'invite',
TOKEN: 'token',
OWNER: 'owner',
} as const
export type Source = ValueOf<typeof SourcesType>
export const PrivilegeLevelsType = {
NONE: false,
READ_ONLY: 'readOnly',
READ_AND_WRITE: 'readAndWrite',
REVIEW: 'reviewer',
OWNER: 'owner',
} as const
export type PrivilegeLevel = ValueOf<typeof PrivilegeLevelsType>
export const PublicAccessLevelsType = {
READ_ONLY: 'readOnly', // LEGACY
READ_AND_WRITE: 'readAndWrite', // LEGACY
PRIVATE: 'private',
TOKEN_BASED: 'tokenBased',
} as const
export type PublicAccessLevel = ValueOf<typeof PublicAccessLevelsType>

View File

@@ -12,18 +12,20 @@ const ProjectEditorHandler = require('../Project/ProjectEditorHandler')
const Sources = require('../Authorization/Sources')
const PrivilegeLevels = require('../Authorization/PrivilegeLevels')
/** @import { PrivilegeLevel, Source, PublicAccessLevel } from "../Authorization/types" */
/**
* @typedef ProjectMember
* @property {string} id
* @property {typeof PrivilegeLevels[keyof PrivilegeLevels]} privilegeLevel
* @property {typeof Sources[keyof Sources]} source
* @property {PrivilegeLevel} privilegeLevel
* @property {Source} source
* @property {boolean} [pendingEditor]
* @property {boolean} [pendingReviewer]
*/
/**
* @typedef LoadedProjectMember
* @property {typeof PrivilegeLevels[keyof PrivilegeLevels]} privilegeLevel
* @property {PrivilegeLevel} privilegeLevel
* @property {{_id: ObjectId, email: string, features: any, first_name: string, last_name: string, signUpDate: Date}} user
* @property {boolean} [pendingEditor]
* @property {boolean} [pendingReviewer]
@@ -34,11 +36,11 @@ class ProjectAccess {
/** @type {ProjectMember[]} */
#members
/** @type {typeof PublicAccessLevels[keyof PublicAccessLevels]} */
/** @type {PublicAccessLevel} */
#publicAccessLevel
/**
* @param {{ owner_ref: ObjectId; collaberator_refs: ObjectId[]; readOnly_refs: ObjectId[]; tokenAccessReadAndWrite_refs: ObjectId[]; tokenAccessReadOnly_refs: ObjectId[]; publicAccesLevel: typeof PublicAccessLevels[keyof PublicAccessLevels]; pendingEditor_refs: ObjectId[]; reviewer_refs: ObjectId[]; pendingReviewer_refs: ObjectId[]; }} project
* @param {{ owner_ref: ObjectId; collaberator_refs: ObjectId[]; readOnly_refs: ObjectId[]; tokenAccessReadAndWrite_refs: ObjectId[]; tokenAccessReadOnly_refs: ObjectId[]; publicAccesLevel: PublicAccessLevel; pendingEditor_refs: ObjectId[]; reviewer_refs: ObjectId[]; pendingReviewer_refs: ObjectId[]; }} project
*/
constructor(project) {
this.#members = _getMemberIdsWithPrivilegeLevelsFromFields(
@@ -99,7 +101,7 @@ class ProjectAccess {
}
/**
* @return {typeof PublicAccessLevels[keyof PublicAccessLevels]}
* @return {PublicAccessLevel}
*/
publicAccessLevel() {
return this.#publicAccessLevel
@@ -121,7 +123,7 @@ class ProjectAccess {
/**
* @param {string | ObjectId} userId
* @return {typeof PrivilegeLevels[keyof PrivilegeLevels]}
* @return {PrivilegeLevel}
*/
privilegeLevelForUser(userId) {
if (!userId) return PrivilegeLevels.NONE
@@ -427,7 +429,7 @@ async function userIsReadWriteTokenMember(userId, projectId) {
* @param {ObjectId[]} readOnlyIds
* @param {ObjectId[]} tokenAccessIds
* @param {ObjectId[]} tokenAccessReadOnlyIds
* @param {typeof PublicAccessLevels[keyof PublicAccessLevels]} publicAccessLevel
* @param {PublicAccessLevel} publicAccessLevel
* @param {ObjectId[]} pendingEditorIds
* @param {ObjectId[]} reviewerIds
* @param {ObjectId[]} pendingReviewerIds

View File

@@ -1,4 +1,4 @@
// ts-check
// @ts-check
import _ from 'lodash'
import Metrics from '@overleaf/metrics'
@@ -31,11 +31,19 @@ import PermissionsManager from '../Authorization/PermissionsManager.js'
import AnalyticsManager from '../Analytics/AnalyticsManager.js'
/**
* @import { GetProjectsRequest, GetProjectsResponse, AllUsersProjects, MongoProject } from "./types"
* @import { ProjectApi, Filters, Page, Sort } from "../../../../types/project/dashboard/api"
* @import { Tag } from "../Tags/types"
* @import { GetProjectsRequest, GetProjectsResponse, AllUsersProjects, MongoProject, FormattedProject, MongoTag } from "./types"
* @import { Project, ProjectApi, ProjectAccessLevel, Filters, Page, Sort, UserRef } from "../../../../types/project/dashboard/api"
* @import { Affiliation } from "../../../../types/affiliation"
* @import { Source } from "../Authorization/types"
*/
/**
* @param {Affiliation} affiliation
* @param session
* @param linkedInstitutionIds
* @returns {boolean}
* @private
*/
const _ssoAvailable = (affiliation, session, linkedInstitutionIds) => {
if (!affiliation.institution) return false
@@ -55,6 +63,10 @@ const _ssoAvailable = (affiliation, session, linkedInstitutionIds) => {
return false
}
/**
* @param {Affiliation[]} affiliations
* @returns {Array<{ name: string, url: string }>}
*/
const _buildPortalTemplatesList = affiliations => {
if (affiliations == null) {
affiliations = []
@@ -64,7 +76,7 @@ const _buildPortalTemplatesList = affiliations => {
const uniqueAffiliations = _.uniqBy(affiliations, 'institution.id')
for (const aff of uniqueAffiliations) {
const hasSlug = aff.portal?.slug
const hasTemplates = aff.portal?.templates_count > 0
const hasTemplates = (aff.portal?.templates_count || 0) > 0
if (hasSlug && hasTemplates) {
const portalPath = aff.institution.isUniversity ? '/edu/' : '/org/'
@@ -198,14 +210,25 @@ async function projectListPage(req, res, next) {
logger.err({ err: error, userId }, 'Failed to load the active survey')
}
if (user && UserPrimaryEmailCheckHandler.requiresPrimaryEmailCheck(user)) {
if (
user &&
UserPrimaryEmailCheckHandler.requiresPrimaryEmailCheck({
email: user.email,
emails: user.emails,
lastPrimaryEmailCheck: user.lastPrimaryEmailCheck,
signUpDate: user.signUpDate,
})
) {
return res.redirect('/user/emails/primary-email-check')
}
}
const tags = await TagsHandler.promises.getAllTags(userId)
let userEmailsData = { list: [], allInReconfirmNotificationPeriods: [] }
/** @type {{ list: any[], allInReconfirmNotificationPeriods?: any[], error?: any }} */
let userEmailsData = {
list: [],
}
try {
const fullEmails = await UserGetter.promises.getUserFullEmails(userId)
@@ -226,7 +249,7 @@ async function projectListPage(req, res, next) {
allInReconfirmNotificationPeriods,
}
} catch (error) {
userEmailsData = error
userEmailsData.error = error
}
}
} catch (error) {
@@ -526,7 +549,7 @@ async function getProjectsJson(req, res) {
* @param {Filters} filters
* @param {Sort} sort
* @param {Page} page
* @returns {Promise<{totalSize: number, projects: ProjectApi[]}>}
* @returns {Promise<{totalSize: number, projects: Project[]}>}
* @private
*/
async function _getProjects(
@@ -535,16 +558,15 @@ async function _getProjects(
sort = { by: 'lastUpdated', order: 'desc' },
page = { size: 20 }
) {
const [
/** @type {AllUsersProjects} **/ allProjects,
/** @type {Tag[]} **/ tags,
] = await Promise.all([
/** @type {[AllUsersProjects, MongoTag[]]} */
const results = await Promise.all([
ProjectGetter.promises.findAllUsersProjects(
userId,
'name lastUpdated lastUpdatedBy publicAccesLevel archived trashed owner_ref tokens'
),
TagsHandler.promises.getAllTags(userId),
])
const [allProjects, tags] = results
const formattedProjects = _formatProjects(allProjects, userId)
const filteredProjects = _applyFilters(
formattedProjects,
@@ -554,18 +576,18 @@ async function _getProjects(
)
const pagedProjects = _sortAndPaginate(filteredProjects, sort, page)
await _injectProjectUsers(pagedProjects)
const projects = await _injectProjectUsers(pagedProjects)
return {
totalSize: filteredProjects.length,
projects: pagedProjects,
projects,
}
}
/**
* @param {AllUsersProjects} projects
* @param {string} userId
* @returns {Project[]}
* @returns {FormattedProject[]}
* @private
*/
function _formatProjects(projects, userId) {
@@ -578,7 +600,7 @@ function _formatProjects(projects, userId) {
tokenReadOnly,
} = projects
const formattedProjects = /** @type {Project[]} **/ []
const formattedProjects = /** @type {FormattedProject[]} **/ []
for (const project of owned) {
formattedProjects.push(
_formatProjectInfo(project, 'owner', Sources.OWNER, userId)
@@ -622,11 +644,11 @@ function _formatProjects(projects, userId) {
}
/**
* @param {Project[]} projects
* @param {Tag[]} tags
* @param {FormattedProject[]} projects
* @param {MongoTag[]} tags
* @param {Filters} filters
* @param {string} userId
* @returns {Project[]}
* @returns {FormattedProject[]}
* @private
*/
function _applyFilters(projects, tags, filters, userId) {
@@ -637,10 +659,10 @@ function _applyFilters(projects, tags, filters, userId) {
}
/**
* @param {Project[]} projects
* @param {FormattedProject[]} projects
* @param {Sort} sort
* @param {Page} page
* @returns {Project[]}
* @returns {FormattedProject[]}
* @private
*/
function _sortAndPaginate(projects, sort, page) {
@@ -661,38 +683,35 @@ function _sortAndPaginate(projects, sort, page) {
/**
* @param {MongoProject} project
* @param {string} accessLevel
* @param {'owner' | 'invite' | 'token'} source
* @param {ProjectAccessLevel} accessLevel
* @param {Source} source
* @param {string} userId
* @returns {object}
* @returns {FormattedProject}
* @private
*/
function _formatProjectInfo(project, accessLevel, source, userId) {
const archived = ProjectHelper.isArchived(project, userId)
// If a project is simultaneously trashed and archived, we will consider it archived but not trashed.
const trashed = ProjectHelper.isTrashed(project, userId) && !archived
const readOnlyTokenAccess =
accessLevel === PrivilegeLevels.READ_ONLY && source === Sources.TOKEN
const model = {
return {
id: project._id.toString(),
name: project.name,
owner_ref: project.owner_ref,
owner_ref: readOnlyTokenAccess ? null : project.owner_ref,
lastUpdated: project.lastUpdated,
lastUpdatedBy: project.lastUpdatedBy,
lastUpdatedBy: readOnlyTokenAccess ? null : project.lastUpdatedBy,
accessLevel,
source,
archived,
trashed,
}
if (accessLevel === PrivilegeLevels.READ_ONLY && source === Sources.TOKEN) {
model.owner_ref = null
model.lastUpdatedBy = null
}
return model
}
/**
* @param {Project[]} projects
* @returns {Promise<void>}
* @param {FormattedProject[]} projects
* @returns {Promise<Project[]>}
* @private
*/
async function _injectProjectUsers(projects) {
@@ -711,6 +730,7 @@ async function _injectProjectUsers(projects) {
last_name: 1,
email: 1,
}
/** @type {Record<string, UserRef>} */
const users = {}
for (const user of await UserGetter.promises.getUsers(userIds, projection)) {
const userId = user._id.toString()
@@ -721,21 +741,30 @@ async function _injectProjectUsers(projects) {
lastName: user.last_name,
}
}
for (const project of projects) {
if (project.owner_ref != null) {
project.owner = users[project.owner_ref.toString()]
}
if (project.lastUpdatedBy != null) {
project.lastUpdatedBy = users[project.lastUpdatedBy.toString()] || null
}
delete project.owner_ref
}
return projects.map(project => ({
id: project.id,
name: project.name,
archived: project.archived,
trashed: project.trashed,
accessLevel: project.accessLevel,
source: project.source,
lastUpdated: project.lastUpdated.toISOString(),
lastUpdatedBy:
project.lastUpdatedBy == null
? null
: users[project.lastUpdatedBy.toString()] || null,
owner:
project.owner_ref == null
? undefined
: users[project.owner_ref.toString()],
owner_ref: undefined,
}))
}
/**
* @param {any} project
* @param {Tag[]} tags
* @param {MongoTag[]} tags
* @param {Filters} filters
* @private
*/
@@ -777,14 +806,14 @@ function _matchesFilters(project, tags, filters) {
* @private
*/
function _hasActiveFilter(filters) {
return (
return Boolean(
filters.ownedByUser ||
filters.sharedWithUser ||
filters.archived ||
filters.trashed ||
filters.tag === null ||
filters.tag?.length ||
filters.search?.length
filters.sharedWithUser ||
filters.archived ||
filters.trashed ||
filters.tag === null ||
filters.tag?.length ||
filters.search?.length
)
}

View File

@@ -2,8 +2,11 @@ import express from 'express'
import {
GetProjectsRequestBody,
GetProjectsResponseBody,
ProjectAccessLevel,
UserRef,
} from '../../../../types/project/dashboard/api'
import { ObjectId } from 'mongodb-legacy'
import { Source } from '../Authorization/types'
export type GetProjectsRequest = express.Request<
unknown,
@@ -30,10 +33,31 @@ export type MongoProject = {
}[]
}
export type MongoTag = {
user_id: string
name: string
color?: string | null
project_ids?: string[]
}
export type AllUsersProjects = {
owned: MongoProject[]
readAndWrite: MongoProject[]
readOnly: MongoProject[]
tokenReadAndWrite: MongoProject[]
tokenReadOnly: MongoProject[]
review: MongoProject[]
}
export type FormattedProject = {
id: string
name: string
owner_ref?: string | null
owner?
lastUpdated: Date
lastUpdatedBy: string | null | UserRef
archived: boolean
trashed: boolean
accessLevel: ProjectAccessLevel
source: Source
}

View File

@@ -377,17 +377,19 @@ const decorateFullEmails = (
return emailsData
}
UserGetter.promises = promisifyAll(UserGetter, {
without: [
'getSsoUsersAtInstitution',
'getUserFullEmails',
'getUserFeatures',
'getWritefullData',
],
})
UserGetter.promises.getUserFullEmails = getUserFullEmails
UserGetter.promises.getSsoUsersAtInstitution = getSsoUsersAtInstitution
UserGetter.promises.getUserFeatures = getUserFeatures
UserGetter.promises.getWritefullData = getWritefullData
UserGetter.promises = {
...promisifyAll(UserGetter, {
without: [
'getSsoUsersAtInstitution',
'getUserFullEmails',
'getUserFeatures',
'getWritefullData',
],
}),
getUserFullEmails,
getSsoUsersAtInstitution,
getUserFeatures,
getWritefullData,
}
module.exports = UserGetter

View File

@@ -298,19 +298,25 @@ describe('ProjectListController', function () {
describe('projectListPage', function () {
beforeEach(function (ctx) {
ctx.projects = [
{ _id: 1, lastUpdated: 1, owner_ref: 'user-1' },
{ _id: 1, lastUpdated: new Date(1), owner_ref: 'user-1' },
{
_id: 2,
lastUpdated: 2,
lastUpdated: new Date(2),
owner_ref: 'user-2',
lastUpdatedBy: 'user-1',
},
]
ctx.readAndWrite = [{ _id: 5, lastUpdated: 5, owner_ref: 'user-1' }]
ctx.readOnly = [{ _id: 3, lastUpdated: 3, owner_ref: 'user-1' }]
ctx.tokenReadAndWrite = [{ _id: 6, lastUpdated: 5, owner_ref: 'user-4' }]
ctx.tokenReadOnly = [{ _id: 7, lastUpdated: 4, owner_ref: 'user-5' }]
ctx.review = [{ _id: 8, lastUpdated: 4, owner_ref: 'user-6' }]
ctx.readAndWrite = [
{ _id: 5, lastUpdated: new Date(5), owner_ref: 'user-1' },
]
ctx.readOnly = [{ _id: 3, lastUpdated: new Date(3), owner_ref: 'user-1' }]
ctx.tokenReadAndWrite = [
{ _id: 6, lastUpdated: new Date(5), owner_ref: 'user-4' },
]
ctx.tokenReadOnly = [
{ _id: 7, lastUpdated: new Date(4), owner_ref: 'user-5' },
]
ctx.review = [{ _id: 8, lastUpdated: new Date(4), owner_ref: 'user-6' }]
ctx.allProjects = {
owned: ctx.projects,
readAndWrite: ctx.readAndWrite,
@@ -854,17 +860,21 @@ describe('ProjectListController', function () {
describe('projectListReactPage with duplicate projects', function () {
beforeEach(function (ctx) {
ctx.projects = [
{ _id: 1, lastUpdated: 1, owner_ref: 'user-1' },
{ _id: 2, lastUpdated: 2, owner_ref: 'user-2' },
{ _id: 1, lastUpdated: new Date(1), owner_ref: 'user-1' },
{ _id: 2, lastUpdated: new Date(2), owner_ref: 'user-2' },
]
ctx.readAndWrite = [
{ _id: 5, lastUpdated: new Date(5), owner_ref: 'user-1' },
]
ctx.readOnly = [{ _id: 3, lastUpdated: new Date(3), owner_ref: 'user-1' }]
ctx.tokenReadAndWrite = [
{ _id: 6, lastUpdated: new Date(5), owner_ref: 'user-4' },
]
ctx.readAndWrite = [{ _id: 5, lastUpdated: 5, owner_ref: 'user-1' }]
ctx.readOnly = [{ _id: 3, lastUpdated: 3, owner_ref: 'user-1' }]
ctx.tokenReadAndWrite = [{ _id: 6, lastUpdated: 5, owner_ref: 'user-4' }]
ctx.tokenReadOnly = [
{ _id: 6, lastUpdated: 5, owner_ref: 'user-4' }, // Also in tokenReadAndWrite
{ _id: 7, lastUpdated: 4, owner_ref: 'user-5' },
{ _id: 6, lastUpdated: new Date(5), owner_ref: 'user-4' }, // Also in tokenReadAndWrite
{ _id: 7, lastUpdated: new Date(4), owner_ref: 'user-5' },
]
ctx.review = [{ _id: 8, lastUpdated: 5, owner_ref: 'user-6' }]
ctx.review = [{ _id: 8, lastUpdated: new Date(5), owner_ref: 'user-6' }]
ctx.allProjects = {
owned: ctx.projects,
readAndWrite: ctx.readAndWrite,

View File

@@ -9,6 +9,7 @@
"scripts/**/*",
"test/acceptance/**/*",
"test/smoke/**/*",
"test/unit/**/*"
"test/unit/**/*",
"types/backend/**/*"
]
}

View File

@@ -0,0 +1,14 @@
import 'express'
import OAuth2Server from '@node-oauth/oauth2-server'
import type SessionData from 'express-session'
// Add properties to Express's Request object that are defined in JS middleware
// or controllers and expected to be present in controllers.
declare module 'express' {
// eslint-disable-next-line no-unused-vars
interface Request {
session: SessionData
userRestrictions?: Set
oauth_user?: OAuth2Server.User
}
}

View File

@@ -0,0 +1,29 @@
import 'express-session'
// Add properties to Express's SessionData object that are expected to be
// present in controllers.
declare module 'express-session' {
// eslint-disable-next-line no-unused-vars
interface SessionData {
postCheckoutRedirect?: string
postLoginRedirect?: string
postOnboardingRedirect?: string
sharedProjectData?: any
templateData?: any
saml?: {
reconfirmed?: boolean
linked?: {
universityName?: string
providerName?: string
}
linkedGroup?: any
requestedEmail?: string
emailNonCanonical?: string
institutionEmail?: string
registerIntercept?: boolean
error?: any
}
samlBeta?: boolean
// Add further properties as needed
}
}

10
services/web/types/backend/i18next.d.ts vendored Normal file
View File

@@ -0,0 +1,10 @@
import 'i18next'
// Add our custom translate function from Translations.js into the i18next i18n
// object type definition
declare module 'i18next' {
// eslint-disable-next-line no-unused-vars
interface i18n {
translate(key: string, vars?: Record<string, any>, components?: any): string
}
}

View File

@@ -1,5 +1,6 @@
import { SortingOrder } from '../../sorting-order'
import { MergeAndOverride } from '../../utils'
import { Source } from '../../../app/src/Features/Authorization/types'
export type Page = {
size: number
@@ -33,6 +34,13 @@ export type UserRef = {
lastName: string
}
export type ProjectAccessLevel =
| 'owner'
| 'readWrite'
| 'readOnly'
| 'readAndWrite'
| 'review'
export type ProjectApi = {
id: string
name: string
@@ -41,8 +49,8 @@ export type ProjectApi = {
lastUpdatedBy: UserRef | null
archived: boolean
trashed: boolean
accessLevel: 'owner' | 'readWrite' | 'readOnly' | 'readAndWrite'
source: 'owner' | 'invite' | 'token'
accessLevel: ProjectAccessLevel
source: Source
}
export type Project = MergeAndOverride<