Skip to content
Merged
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
3 changes: 3 additions & 0 deletions app/core/entity/TeamMember.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ interface TeamMemberData extends EntityData {
teamMemberId: string;
teamId: string;
userId: string;
role: string;
}

export type CreateTeamMemberData = Omit<EasyData<TeamMemberData, 'teamMemberId'>, 'id'>;
Expand All @@ -13,12 +14,14 @@ export class TeamMember extends Entity {
teamMemberId: string;
teamId: string;
userId: string;
role: string;

constructor(data: TeamMemberData) {
super(data);
this.teamMemberId = data.teamMemberId;
this.teamId = data.teamId;
this.userId = data.userId;
this.role = data.role || 'member';
}

static create(data: CreateTeamMemberData): TeamMember {
Expand Down
2 changes: 2 additions & 0 deletions app/core/service/OrgService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export class OrgService extends AbstractService {
const teamMember = TeamMember.create({
teamId: developersTeam.teamId,
userId: cmd.creatorUserId,
role: 'owner',
});
await this.orgRepository.createOrgCascade(org, developersTeam, ownerMember, teamMember);

Expand Down Expand Up @@ -109,6 +110,7 @@ export class OrgService extends AbstractService {
const teamMember = TeamMember.create({
teamId: developersTeam.teamId,
userId,
role: 'member',
});
await this.teamRepository.addMember(teamMember);
}
Expand Down
22 changes: 17 additions & 5 deletions app/core/service/TeamService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export class TeamService extends AbstractService {
private readonly teamRepository: TeamRepository;


async createTeam(orgId: string, name: string, description?: string): Promise<Team> {
async createTeam(orgId: string, name: string, description?: string, creatorUserId?: string): Promise<Team> {
const existing = await this.teamRepository.findTeam(orgId, name);
if (existing) {
throw new ForbiddenError(`Team "${name}" already exists`);
Expand All @@ -35,7 +35,14 @@ export class TeamService extends AbstractService {
description,
});
await this.teamRepository.saveTeam(team);
this.logger.info('[TeamService:createTeam] teamId: %s, orgId: %s, name: %s', team.teamId, orgId, name);

// Auto-add creator as team owner
if (creatorUserId) {
const member = TeamMember.create({ teamId: team.teamId, userId: creatorUserId, role: 'owner' });
await this.teamRepository.addMember(member);
}

this.logger.info('[TeamService:createTeam] teamId: %s, orgId: %s, name: %s, creator: %s', team.teamId, orgId, name, creatorUserId);
return team;
}

Expand All @@ -52,7 +59,7 @@ export class TeamService extends AbstractService {
this.logger.info('[TeamService:removeTeam] teamId: %s', teamId);
}

async addMember(teamId: string, userId: string): Promise<TeamMember> {
async addMember(teamId: string, userId: string, role: 'owner' | 'member' = 'member'): Promise<TeamMember> {
const team = await this.teamRepository.findTeamByTeamId(teamId);
if (!team) {
throw new NotFoundError('Team not found');
Expand All @@ -70,12 +77,17 @@ export class TeamService extends AbstractService {

const existing = await this.teamRepository.findMember(teamId, userId);
if (existing) {
// Update role if changed
if (existing.role !== role) {
existing.role = role;
await this.teamRepository.addMember(existing);
}
Comment thread
elrrrrrrr marked this conversation as resolved.
return existing;
}

const member = TeamMember.create({ teamId, userId });
const member = TeamMember.create({ teamId, userId, role });
await this.teamRepository.addMember(member);
this.logger.info('[TeamService:addMember] teamId: %s, userId: %s', teamId, userId);
this.logger.info('[TeamService:addMember] teamId: %s, userId: %s, role: %s', teamId, userId, role);
return member;
}

Expand Down
4 changes: 2 additions & 2 deletions app/port/controller/OrgController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ export class OrgController extends AbstractController {
if (!targetUser) {
throw new NotFoundError(`User "${username}" not found`);
}
const teams = await this.teamRepository.listTeamsByUserIdAndOrgId(targetUser.userId, org.orgId);
return teams.map(t => ({ name: t.name, description: t.description }));
const teamResults = await this.teamRepository.listTeamsByUserIdAndOrgId(targetUser.userId, org.orgId);
return teamResults.map(t => ({ name: t.team.name, description: t.team.description, role: t.role }));
}
}
99 changes: 94 additions & 5 deletions app/port/controller/TeamController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,11 @@ import {
HTTPParam,
Inject,
} from '@eggjs/tegg';
import { NotFoundError, UnprocessableEntityError } from 'egg-errors';
import { NotFoundError, ForbiddenError, UnprocessableEntityError } from 'egg-errors';
import { AbstractController } from './AbstractController';
import { OrgService } from '../../core/service/OrgService';
import { TeamService } from '../../core/service/TeamService';
import { OrgRepository } from '../../repository/OrgRepository';
import { TeamRepository } from '../../repository/TeamRepository';
import { getScopeAndName } from '../../common/PackageUtil';

Expand All @@ -23,6 +24,9 @@ export class TeamController extends AbstractController {
@Inject()
private readonly teamService: TeamService;

@Inject()
private readonly orgRepository: OrgRepository;

@Inject()
private readonly teamRepository: TeamRepository;

Expand Down Expand Up @@ -58,12 +62,44 @@ export class TeamController extends AbstractController {
}

private async requireTeamWriteAccess(ctx: EggContext, orgName: string, teamName: string) {
const { org, authorizedUser } = await this.requireOrgWriteAccess(ctx, orgName);
const authorizedUser = await this.userRoleManager.requiredAuthorizedUser(ctx, 'setting');
const isAdmin = await this.userRoleManager.isAdmin(ctx);

let org;
if (this.isAllowScopeOrg(orgName)) {
org = await this.orgService.ensureOrgForScope(`@${orgName}`);
} else {
org = await this.orgService.findOrgByName(orgName);
if (!org) {
throw new NotFoundError(`Org "${orgName}" not found`);
}
}

const team = await this.teamRepository.findTeam(org.orgId, teamName);
if (!team) {
throw new NotFoundError(`Team "${teamName}" not found`);
}
return { org, team, authorizedUser };

// Admin always has access
if (isAdmin) {
return { org, team, authorizedUser };
}

// Org owner has access
if (!this.isAllowScopeOrg(orgName)) {
const orgMember = await this.orgRepository.findMember(org.orgId, authorizedUser.userId);
if (orgMember && orgMember.role === 'owner') {
return { org, team, authorizedUser };
}
}

// Team owner has access
const teamMember = await this.teamRepository.findMember(team.teamId, authorizedUser.userId);
if (teamMember && teamMember.role === 'owner') {
return { org, team, authorizedUser };
}

throw new ForbiddenError('Only team owner or admin can perform this action');
}

// --- Team CRUD ---
Expand All @@ -75,12 +111,12 @@ export class TeamController extends AbstractController {
})
async createTeam(@Context() ctx: EggContext, @HTTPParam() orgName: string,
@HTTPBody() body: { name: string; description?: string }) {
const { org } = await this.requireOrgWriteAccess(ctx, orgName);
const { org, authorizedUser } = await this.requireOrgWriteAccess(ctx, orgName);

if (!body.name) {
throw new UnprocessableEntityError('name is required');
}
await this.teamService.createTeam(org.orgId, body.name, body.description);
await this.teamService.createTeam(org.orgId, body.name, body.description, authorizedUser.userId);
return { ok: true };
}

Expand Down Expand Up @@ -138,6 +174,7 @@ export class TeamController extends AbstractController {
// --- Team Members (npm uses "user") ---

// npm team ls @scope:team → GET /-/team/:orgName/:teamName/user
// npm compatible: returns string array ["user1", "user2"]
@HTTPMethod({
path: '/-/team/:orgName/:teamName/user',
method: HTTPMethodEnum.GET,
Expand All @@ -158,6 +195,57 @@ export class TeamController extends AbstractController {
return users.map(u => u.displayName);
}

// Private API: GET /-/team/:orgName/:teamName/member
// Returns [{user, role}] with team member role info
@HTTPMethod({
path: '/-/team/:orgName/:teamName/member',
method: HTTPMethodEnum.GET,
})
async listTeamMembersWithRole(@Context() ctx: EggContext, @HTTPParam() orgName: string,
@HTTPParam() teamName: string) {
await this.userRoleManager.requiredAuthorizedUser(ctx, 'read');
const org = await this.findOrg(orgName);
if (!org) {
throw new NotFoundError(`Org "${orgName}" not found`);
}
const team = await this.teamRepository.findTeam(org.orgId, teamName);
if (!team) {
throw new NotFoundError(`Team "${teamName}" not found`);
}
const members = await this.teamService.listMembers(team.teamId);
const users = await this.userRepository.findUsersByUserIds(members.map(m => m.userId));
const userMap = new Map(users.map(u => [ u.userId, u ]));
return members.map(m => ({
user: userMap.get(m.userId)?.displayName ?? '',
role: m.role,
}));
}

// Private API: PATCH /-/team/:orgName/:teamName/member/:username
// Update team member role
@HTTPMethod({
path: '/-/team/:orgName/:teamName/member/:username',
method: HTTPMethodEnum.PATCH,
})
async updateTeamMemberRole(@Context() ctx: EggContext, @HTTPParam() orgName: string,
@HTTPParam() teamName: string, @HTTPParam() username: string,
@HTTPBody() body: { role: string }) {
const { team } = await this.requireTeamWriteAccess(ctx, orgName, teamName);
if (!body.role || (body.role !== 'owner' && body.role !== 'member')) {
throw new UnprocessableEntityError('role is required and must be "owner" or "member"');
}
const targetUser = await this.userRepository.findUserByName(username);
if (!targetUser) {
throw new NotFoundError(`User "${username}" not found`);
}
const member = await this.teamRepository.findMember(team.teamId, targetUser.userId);
if (!member) {
throw new NotFoundError(`User "${username}" is not a member of this team`);
}
await this.teamService.addMember(team.teamId, targetUser.userId, body.role as 'owner' | 'member');
return { ok: true };
}

// npm team add <user> @scope:team → PUT /-/team/:orgName/:teamName/user
@HTTPMethod({
path: '/-/team/:orgName/:teamName/user',
Expand Down Expand Up @@ -262,4 +350,5 @@ export class TeamController extends AbstractController {
await this.teamService.revokePackageAccess(team.teamId, pkg.packageId);
return { ok: true };
}

}
15 changes: 11 additions & 4 deletions app/repository/TeamRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,16 +67,19 @@ export class TeamRepository extends AbstractRepository {
return models.map(model => ModelConvertor.convertModelToEntity(model, Team));
}

async listTeamsByUserIdAndOrgId(userId: string, orgId: string): Promise<Team[]> {
async listTeamsByUserIdAndOrgId(userId: string, orgId: string): Promise<{ team: Team; role: string }[]> {
const orgTeams = await this.Team.find({ orgId });
if (orgTeams.length === 0) return [];
const orgTeamIds = orgTeams.map(t => t.teamId);
const memberModels = await this.TeamMember.find({ userId, teamId: { $in: orgTeamIds } });
if (memberModels.length === 0) return [];
const memberTeamIds = new Set(memberModels.map(m => m.teamId));
const memberRoleMap = new Map(memberModels.map(m => [ m.teamId, m.role || 'member' ]));
return orgTeams
.filter(t => memberTeamIds.has(t.teamId))
.map(model => ModelConvertor.convertModelToEntity(model, Team));
.filter(t => memberRoleMap.has(t.teamId))
.map(model => ({
team: ModelConvertor.convertModelToEntity(model, Team),
role: memberRoleMap.get(model.teamId) || 'member',
}));
}

// --- TeamMember ---
Expand All @@ -89,6 +92,10 @@ export class TeamRepository extends AbstractRepository {

async addMember(member: TeamMember): Promise<void> {
if (member.id) {
const model = await this.TeamMember.findOne({ id: member.id });
if (model) {
await ModelConvertor.saveEntityToModel(member, model);
}
return;
}
await ModelConvertor.convertEntityToModel(member, this.TeamMember);
Expand Down
3 changes: 3 additions & 0 deletions app/repository/model/TeamMember.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,7 @@ export class TeamMember extends Bone {

@Attribute(DataTypes.STRING(24))
userId: string;

@Attribute(DataTypes.STRING(20))
role: string;
}
Loading
Loading