From f9298e510b71cc60476f5b815b05b765f94143ab Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 13:00:27 +0000 Subject: [PATCH] feat(team): predictive search for users to add to a team GET /team/:teamId/members/candidates?q= returns up to 10 users whose username contains q (3-50 chars), excluding current members. Admins and the team's leads only, session-only. For a team lead it returns plain users only, matching what a lead can add, so it does not expose admins or other leads. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_0121r5gnmvth6hy4tPpNT8sM --- app/controllers/team.controller.js | 38 ++++++++++++ app/routes/team.routes.js | 18 +++++- swagger/swagger.json | 92 ++++++++++++++++++++++++++++++ test/team-members.tests.js | 72 +++++++++++++++++++++++ 4 files changed, 219 insertions(+), 1 deletion(-) diff --git a/app/controllers/team.controller.js b/app/controllers/team.controller.js index 61a9a45..6cf5898 100644 --- a/app/controllers/team.controller.js +++ b/app/controllers/team.controller.js @@ -253,3 +253,41 @@ exports.removeMember = async (req, res) => { return handleError(err, res); } }; + +const CANDIDATE_LIMIT = 10; + +exports.searchMemberCandidates = async (req, res) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(422).json({ errors: errors.array() }); + } + const { teamId } = req.params; + // The validator limits q to [A-Za-z0-9-_], none of which is a regex metacharacter, so it + // can go into the pattern as is. Usernames are stored lowercase. + const term = req.query.q.trim().toLowerCase(); + + try { + if (!authMiddleware.hasTeamLeadAccess(req.user, teamId)) { + throw new ForbiddenError('Forbidden. You do not have permission to add members to this team.'); + } + const team = await Team.findById(teamId).select('_id').lean(); + if (!team) { + throw new NotFoundError(`Team not found with id ${teamId}`); + } + const filter = { + username: new RegExp(term), + teams: { $ne: teamId }, + }; + // Same rule as addMember: only an admin can add team leads or admins. + if (req.user.role !== 'admin') { + filter.role = 'user'; + } + const candidates = await User.find(filter, '_id username role') + .sort('username') + .limit(CANDIDATE_LIMIT) + .lean(); + return res.status(200).send(candidates); + } catch (err) { + return handleError(err, res); + } +}; diff --git a/app/routes/team.routes.js b/app/routes/team.routes.js index ba801ca..9765dd7 100644 --- a/app/routes/team.routes.js +++ b/app/routes/team.routes.js @@ -1,4 +1,4 @@ -const { check, param } = require('express-validator'); +const { check, param, query } = require('express-validator'); const teamController = require('../controllers/team.controller.js'); const authMiddleware = require('../utils/auth-middleware.js'); @@ -52,6 +52,22 @@ module.exports = (app, path) => { param('teamId').isMongoId(), ], teamController.getMembers); + // Predictive search for the "add member" box. Deliberately narrow, because it lets a team + // lead look up accounts they otherwise cannot list: it needs at least three characters of + // the username, returns a handful of matches, and (for a team lead) only users they could + // add anyway - never admins, team leads, or people already in the team. + app.get(`${path}/team/:teamId/members/candidates`, authMiddleware.preventTokenAuth, [ + param('teamId').isMongoId(), + query('q') + .exists({ checkFalsy: true }) + .isString() + .trim() + .isLength({ min: 3, max: 50 }) + .withMessage('q must be between 3 and 50 characters.') + .matches(/^[A-Za-z0-9-_]+$/) + .withMessage('q must only contain letters, numbers, hyphens or underscores.'), + ], teamController.searchMemberCandidates); + app.post(`${path}/team/:teamId/members`, authMiddleware.preventTokenAuth, [ param('teamId').isMongoId(), check('username') diff --git a/swagger/swagger.json b/swagger/swagger.json index 41d6475..428c5ea 100644 --- a/swagger/swagger.json +++ b/swagger/swagger.json @@ -485,6 +485,98 @@ } } }, + "/team/{teamId}/members/candidates": { + "parameters": [ + { + "name": "teamId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "get": { + "tags": [ + "Team" + ], + "summary": "Search for users that can be added to a team", + "description": "Session authentication only - API tokens (x-api-key) are rejected on this endpoint with a 403. Callable by an admin or a team lead of this team. Powers the type-ahead on the add-member box. Matches part of the username (case-insensitive), returns at most 10 users ordered by username, and leaves out users who are already in the team. For a team lead only users with the 'user' role are returned, since those are the only ones a lead can add; an admin also sees team leads and admins.", + "parameters": [ + { + "name": "q", + "in": "query", + "required": true, + "description": "Part of the username: 3 to 50 letters, numbers, hyphens or underscores.", + "schema": { + "type": "string", + "minLength": 3, + "maxLength": 50 + } + } + ], + "responses": { + "200": { + "description": "The matching users (possibly none).", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "properties": { + "_id": { + "type": "string" + }, + "username": { + "type": "string" + }, + "role": { + "type": "string", + "enum": [ + "admin", + "user", + "team_lead" + ] + } + } + } + } + } + } + }, + "403": { + "description": "Caller is not an admin or a team lead of this team", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "404": { + "description": "Team not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + }, + "422": { + "description": "Validation error (q missing, too short or with unsupported characters)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DefaultResponse" + } + } + } + } + } + } + }, "/team/{teamId}/members/{userId}": { "parameters": [ { diff --git a/test/team-members.tests.js b/test/team-members.tests.js index 36af544..8ae9ee7 100644 --- a/test/team-members.tests.js +++ b/test/team-members.tests.js @@ -133,6 +133,78 @@ describe('Team members, last login and build trigger Tests', () => { }); }); + describe('GET /team/:teamId/members/candidates', () => { + const search = (agent, teamId, q) => agent + .get(`${baseUrl}team/${teamId}/members/candidates`) + .query({ q }); + + it('finds users to add by part of their username, case-insensitively', async () => { + const res = await search(leadAgent, team._id, 'CAND').expect(200); + res.body.map((u) => u.username).should.eql([candidate.username]); + Object.keys(res.body[0]).sort().should.eql(['_id', 'role', 'username']); + }); + + it('matches in the middle of a username, not only the start', async () => { + const res = await search(leadAgent, team._id, 'tm-out').expect(200); + res.body.map((u) => u.username).should.eql([outsider.username]); + }); + + it('offers a team lead only plain users who are not already in the team', async () => { + const res = await search(leadAgent, team._id, `${PREFIX}`).expect(200); + const names = res.body.map((u) => u.username); + names.should.containDeep([candidate.username, outsider.username]); + // already a member / a team lead of this team / a team lead elsewhere + names.should.not.containEql(member.username); + names.should.not.containEql(lead.username); + names.should.not.containEql(teamLead2.username); + names.should.not.containEql(otherLead.username); + res.body.forEach((u) => u.role.should.equal('user')); + }); + + it('also offers team leads to an admin, who may add them', async () => { + const res = await search(adminAgent, team._id, `${PREFIX}`).expect(200); + const names = res.body.map((u) => u.username); + names.should.containDeep([candidate.username, outsider.username, otherLead.username]); + names.should.not.containEql(member.username); + }); + + it('returns an empty list when nothing matches', async () => { + const res = await search(leadAgent, team._id, 'zzzzzz-no-such-user').expect(200); + res.body.should.eql([]); + }); + + it('caps the number of suggestions', async () => { + const bulk = await Promise.all(Array.from({ length: 12 }, (_, i) => createUser(`bulk-${String(i).padStart(2, '0')}`, 'user'))); + try { + const res = await search(leadAgent, team._id, 'tm-bulk').expect(200); + res.body.should.have.length(10); + } finally { + await User.deleteMany({ _id: { $in: bulk.map((u) => u._id) } }); + } + }); + + it('respond with 422 for fewer than three characters, or characters outside a username', async () => { + await search(leadAgent, team._id, 'ca').expect(422); + await search(leadAgent, team._id, '').expect(422); + await search(leadAgent, team._id, 'a.*').expect(422); + await leadAgent.get(`${baseUrl}team/${team._id}/members/candidates`).expect(422); + }); + + it('respond with 403 for a plain member and for a lead of another team', async () => { + await search(memberAgent, team._id, 'cand').expect(403); + await search(leadAgent, otherTeam._id, 'cand').expect(403); + }); + + it('respond with 403 when called with an API token, and 404 for an unknown team', async () => { + await request(app) + .get(`${baseUrl}team/${team._id}/members/candidates`) + .query({ q: 'cand' }) + .set('x-api-key', leadToken) + .expect(403); + await search(adminAgent, '507f1f77bcf86cd799439011', 'cand').expect(404); + }); + }); + describe('POST /team/:teamId/members', () => { it('lets a team lead add a user by username (case-insensitively)', async () => { const res = await leadAgent