Files
registry/ts/api/handlers/organization.api.ts

495 lines
15 KiB
TypeScript
Raw Normal View History

/**
* Organization API handlers
*/
import type { IApiContext, IApiResponse } from '../router.ts';
import { PermissionService } from '../../services/permission.service.ts';
import { AuditService } from '../../services/audit.service.ts';
import { Organization, OrganizationMember, User } from '../../models/index.ts';
import type { TOrganizationRole } from '../../interfaces/auth.interfaces.ts';
export class OrganizationApi {
private permissionService: PermissionService;
constructor(permissionService: PermissionService) {
this.permissionService = permissionService;
}
/**
* GET /api/v1/organizations
*/
public async list(ctx: IApiContext): Promise<IApiResponse> {
if (!ctx.actor?.userId) {
return { status: 401, body: { error: 'Authentication required' } };
}
try {
// System admins see all orgs, others see only their orgs
let organizations: Organization[];
if (ctx.actor.user?.isSystemAdmin) {
organizations = await Organization.getInstances({});
} else {
organizations = await OrganizationMember.getUserOrganizations(ctx.actor.userId);
}
return {
status: 200,
body: {
organizations: organizations.map((org) => ({
id: org.id,
name: org.name,
displayName: org.displayName,
description: org.description,
avatarUrl: org.avatarUrl,
isPublic: org.isPublic,
memberCount: org.memberCount,
createdAt: org.createdAt,
})),
},
};
} catch (error) {
console.error('[OrganizationApi] List error:', error);
return { status: 500, body: { error: 'Failed to list organizations' } };
}
}
/**
* GET /api/v1/organizations/:id
*/
public async get(ctx: IApiContext): Promise<IApiResponse> {
const { id } = ctx.params;
try {
const org = await Organization.findById(id);
if (!org) {
return { status: 404, body: { error: 'Organization not found' } };
}
// Check access - public orgs are visible to all authenticated users
if (!org.isPublic && ctx.actor?.userId) {
const isMember = await OrganizationMember.findMembership(id, ctx.actor.userId);
if (!isMember && !ctx.actor.user?.isSystemAdmin) {
return { status: 403, body: { error: 'Access denied' } };
}
}
return {
status: 200,
body: {
id: org.id,
name: org.name,
displayName: org.displayName,
description: org.description,
avatarUrl: org.avatarUrl,
website: org.website,
isPublic: org.isPublic,
memberCount: org.memberCount,
settings: ctx.actor?.user?.isSystemAdmin ? org.settings : undefined,
usedStorageBytes: org.usedStorageBytes,
createdAt: org.createdAt,
},
};
} catch (error) {
console.error('[OrganizationApi] Get error:', error);
return { status: 500, body: { error: 'Failed to get organization' } };
}
}
/**
* POST /api/v1/organizations
*/
public async create(ctx: IApiContext): Promise<IApiResponse> {
if (!ctx.actor?.userId) {
return { status: 401, body: { error: 'Authentication required' } };
}
try {
const body = await ctx.request.json();
const { name, displayName, description, isPublic } = body;
if (!name) {
return { status: 400, body: { error: 'Organization name is required' } };
}
// Validate name format
if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(name)) {
return {
status: 400,
body: { error: 'Name must be lowercase alphanumeric with optional hyphens' },
};
}
// Check if name is taken
const existing = await Organization.findByName(name);
if (existing) {
return { status: 409, body: { error: 'Organization name already taken' } };
}
// Create organization
const org = new Organization();
org.id = await Organization.getNewId();
org.name = name;
org.displayName = displayName || name;
org.description = description;
org.isPublic = isPublic ?? false;
org.memberCount = 1;
org.createdAt = new Date();
org.createdById = ctx.actor.userId;
await org.save();
// Add creator as owner
const membership = new OrganizationMember();
membership.id = await OrganizationMember.getNewId();
membership.organizationId = org.id;
membership.userId = ctx.actor.userId;
membership.role = 'owner';
membership.addedById = ctx.actor.userId;
membership.addedAt = new Date();
await membership.save();
// Audit log
await AuditService.withContext({
actorId: ctx.actor.userId,
actorType: 'user',
actorIp: ctx.ip,
}).logOrganizationCreated(org.id, org.name);
return {
status: 201,
body: {
id: org.id,
name: org.name,
displayName: org.displayName,
description: org.description,
isPublic: org.isPublic,
createdAt: org.createdAt,
},
};
} catch (error) {
console.error('[OrganizationApi] Create error:', error);
return { status: 500, body: { error: 'Failed to create organization' } };
}
}
/**
* PUT /api/v1/organizations/:id
*/
public async update(ctx: IApiContext): Promise<IApiResponse> {
if (!ctx.actor?.userId) {
return { status: 401, body: { error: 'Authentication required' } };
}
const { id } = ctx.params;
// Check admin permission
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, id);
if (!canManage) {
return { status: 403, body: { error: 'Admin access required' } };
}
try {
const org = await Organization.findById(id);
if (!org) {
return { status: 404, body: { error: 'Organization not found' } };
}
const body = await ctx.request.json();
const { displayName, description, avatarUrl, website, isPublic, settings } = body;
if (displayName !== undefined) org.displayName = displayName;
if (description !== undefined) org.description = description;
if (avatarUrl !== undefined) org.avatarUrl = avatarUrl;
if (website !== undefined) org.website = website;
if (isPublic !== undefined) org.isPublic = isPublic;
// Only system admins can change settings
if (settings && ctx.actor.user?.isSystemAdmin) {
org.settings = { ...org.settings, ...settings };
}
await org.save();
return {
status: 200,
body: {
id: org.id,
name: org.name,
displayName: org.displayName,
description: org.description,
avatarUrl: org.avatarUrl,
website: org.website,
isPublic: org.isPublic,
},
};
} catch (error) {
console.error('[OrganizationApi] Update error:', error);
return { status: 500, body: { error: 'Failed to update organization' } };
}
}
/**
* DELETE /api/v1/organizations/:id
*/
public async delete(ctx: IApiContext): Promise<IApiResponse> {
if (!ctx.actor?.userId) {
return { status: 401, body: { error: 'Authentication required' } };
}
const { id } = ctx.params;
// Only owners and system admins can delete
const membership = await OrganizationMember.findMembership(id, ctx.actor.userId);
if (membership?.role !== 'owner' && !ctx.actor.user?.isSystemAdmin) {
return { status: 403, body: { error: 'Owner access required' } };
}
try {
const org = await Organization.findById(id);
if (!org) {
return { status: 404, body: { error: 'Organization not found' } };
}
// TODO: Check for packages, repositories before deletion
// For now, just delete the organization and memberships
await org.delete();
return {
status: 200,
body: { message: 'Organization deleted successfully' },
};
} catch (error) {
console.error('[OrganizationApi] Delete error:', error);
return { status: 500, body: { error: 'Failed to delete organization' } };
}
}
/**
* GET /api/v1/organizations/:id/members
*/
public async listMembers(ctx: IApiContext): Promise<IApiResponse> {
if (!ctx.actor?.userId) {
return { status: 401, body: { error: 'Authentication required' } };
}
const { id } = ctx.params;
// Check membership
const isMember = await OrganizationMember.findMembership(id, ctx.actor.userId);
if (!isMember && !ctx.actor.user?.isSystemAdmin) {
return { status: 403, body: { error: 'Access denied' } };
}
try {
const members = await OrganizationMember.getOrgMembers(id);
// Fetch user details
const membersWithUsers = await Promise.all(
members.map(async (m) => {
const user = await User.findById(m.userId);
return {
userId: m.userId,
role: m.role,
addedAt: m.addedAt,
user: user
? {
username: user.username,
displayName: user.displayName,
avatarUrl: user.avatarUrl,
}
: null,
};
})
);
return {
status: 200,
body: { members: membersWithUsers },
};
} catch (error) {
console.error('[OrganizationApi] List members error:', error);
return { status: 500, body: { error: 'Failed to list members' } };
}
}
/**
* POST /api/v1/organizations/:id/members
*/
public async addMember(ctx: IApiContext): Promise<IApiResponse> {
if (!ctx.actor?.userId) {
return { status: 401, body: { error: 'Authentication required' } };
}
const { id } = ctx.params;
// Check admin permission
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, id);
if (!canManage) {
return { status: 403, body: { error: 'Admin access required' } };
}
try {
const body = await ctx.request.json();
const { userId, role } = body as { userId: string; role: TOrganizationRole };
if (!userId || !role) {
return { status: 400, body: { error: 'userId and role are required' } };
}
if (!['owner', 'admin', 'member'].includes(role)) {
return { status: 400, body: { error: 'Invalid role' } };
}
// Check user exists
const user = await User.findById(userId);
if (!user) {
return { status: 404, body: { error: 'User not found' } };
}
// Check if already a member
const existing = await OrganizationMember.findMembership(id, userId);
if (existing) {
return { status: 409, body: { error: 'User is already a member' } };
}
// Add member
const membership = new OrganizationMember();
membership.id = await OrganizationMember.getNewId();
membership.organizationId = id;
membership.userId = userId;
membership.role = role;
membership.addedById = ctx.actor.userId;
membership.addedAt = new Date();
await membership.save();
// Update member count
const org = await Organization.findById(id);
if (org) {
org.memberCount += 1;
await org.save();
}
return {
status: 201,
body: {
userId: membership.userId,
role: membership.role,
addedAt: membership.addedAt,
},
};
} catch (error) {
console.error('[OrganizationApi] Add member error:', error);
return { status: 500, body: { error: 'Failed to add member' } };
}
}
/**
* PUT /api/v1/organizations/:id/members/:userId
*/
public async updateMember(ctx: IApiContext): Promise<IApiResponse> {
if (!ctx.actor?.userId) {
return { status: 401, body: { error: 'Authentication required' } };
}
const { id, userId } = ctx.params;
// Check admin permission
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, id);
if (!canManage) {
return { status: 403, body: { error: 'Admin access required' } };
}
try {
const body = await ctx.request.json();
const { role } = body as { role: TOrganizationRole };
if (!role || !['owner', 'admin', 'member'].includes(role)) {
return { status: 400, body: { error: 'Valid role is required' } };
}
const membership = await OrganizationMember.findMembership(id, userId);
if (!membership) {
return { status: 404, body: { error: 'Member not found' } };
}
// Cannot change last owner
if (membership.role === 'owner' && role !== 'owner') {
const owners = await OrganizationMember.getOrgMembers(id);
const ownerCount = owners.filter((m) => m.role === 'owner').length;
if (ownerCount <= 1) {
return { status: 400, body: { error: 'Cannot remove the last owner' } };
}
}
membership.role = role;
await membership.save();
return {
status: 200,
body: {
userId: membership.userId,
role: membership.role,
},
};
} catch (error) {
console.error('[OrganizationApi] Update member error:', error);
return { status: 500, body: { error: 'Failed to update member' } };
}
}
/**
* DELETE /api/v1/organizations/:id/members/:userId
*/
public async removeMember(ctx: IApiContext): Promise<IApiResponse> {
if (!ctx.actor?.userId) {
return { status: 401, body: { error: 'Authentication required' } };
}
const { id, userId } = ctx.params;
// Users can remove themselves, admins can remove others
if (userId !== ctx.actor.userId) {
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, id);
if (!canManage) {
return { status: 403, body: { error: 'Admin access required' } };
}
}
try {
const membership = await OrganizationMember.findMembership(id, userId);
if (!membership) {
return { status: 404, body: { error: 'Member not found' } };
}
// Cannot remove last owner
if (membership.role === 'owner') {
const owners = await OrganizationMember.getOrgMembers(id);
const ownerCount = owners.filter((m) => m.role === 'owner').length;
if (ownerCount <= 1) {
return { status: 400, body: { error: 'Cannot remove the last owner' } };
}
}
await membership.delete();
// Update member count
const org = await Organization.findById(id);
if (org) {
org.memberCount = Math.max(0, org.memberCount - 1);
await org.save();
}
return {
status: 200,
body: { message: 'Member removed successfully' },
};
} catch (error) {
console.error('[OrganizationApi] Remove member error:', error);
return { status: 500, body: { error: 'Failed to remove member' } };
}
}
}