549 lines
21 KiB
TypeScript
549 lines
21 KiB
TypeScript
import * as plugins from '../../plugins.ts';
|
|
import * as interfaces from '../../../ts_interfaces/index.ts';
|
|
import type { OpsServer } from '../classes.opsserver.ts';
|
|
import { requireValidIdentity } from '../helpers/guards.ts';
|
|
import { Organization, OrganizationMember, User } from '../../models/index.ts';
|
|
import { PermissionService } from '../../services/permission.service.ts';
|
|
import { AuditService } from '../../services/audit.service.ts';
|
|
|
|
export class OrganizationHandler {
|
|
public typedrouter = new plugins.typedrequest.TypedRouter();
|
|
private permissionService = new PermissionService();
|
|
|
|
constructor(private opsServerRef: OpsServer) {
|
|
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
|
this.registerHandlers();
|
|
}
|
|
|
|
/**
|
|
* Helper to resolve organization by ID or name
|
|
*/
|
|
private async resolveOrganization(idOrName: string): Promise<Organization | null> {
|
|
return idOrName.startsWith('Organization:')
|
|
? await Organization.findById(idOrName)
|
|
: await Organization.findByName(idOrName);
|
|
}
|
|
|
|
private registerHandlers(): void {
|
|
// Get Organizations
|
|
this.typedrouter.addTypedHandler(
|
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetOrganizations>(
|
|
'getOrganizations',
|
|
async (dataArg) => {
|
|
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
|
|
|
try {
|
|
const userId = dataArg.identity.userId;
|
|
let organizations: Organization[];
|
|
|
|
if (dataArg.identity.isSystemAdmin) {
|
|
organizations = await Organization.getInstances({});
|
|
} else {
|
|
const memberships = await OrganizationMember.getUserOrganizations(userId);
|
|
const orgs: Organization[] = [];
|
|
for (const m of memberships) {
|
|
const org = await Organization.findById(m.organizationId);
|
|
if (org) orgs.push(org);
|
|
}
|
|
organizations = orgs;
|
|
}
|
|
|
|
return {
|
|
organizations: organizations.map((org) => ({
|
|
id: org.id,
|
|
name: org.name,
|
|
displayName: org.displayName,
|
|
description: org.description,
|
|
avatarUrl: org.avatarUrl,
|
|
website: org.website,
|
|
isPublic: org.isPublic,
|
|
memberCount: org.memberCount,
|
|
plan: (org as any).plan || 'free',
|
|
usedStorageBytes: org.usedStorageBytes || 0,
|
|
storageQuotaBytes: (org as any).storageQuotaBytes || 0,
|
|
createdAt: org.createdAt instanceof Date
|
|
? org.createdAt.toISOString()
|
|
: String(org.createdAt),
|
|
})),
|
|
};
|
|
} catch (error) {
|
|
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
|
throw new plugins.typedrequest.TypedResponseError('Failed to list organizations');
|
|
}
|
|
},
|
|
),
|
|
);
|
|
|
|
// Get Organization
|
|
this.typedrouter.addTypedHandler(
|
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetOrganization>(
|
|
'getOrganization',
|
|
async (dataArg) => {
|
|
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
|
|
|
try {
|
|
const org = await this.resolveOrganization(dataArg.organizationId);
|
|
if (!org) {
|
|
throw new plugins.typedrequest.TypedResponseError('Organization not found');
|
|
}
|
|
|
|
// Check access - public orgs visible to all, private requires membership
|
|
if (!org.isPublic) {
|
|
const isMember = await OrganizationMember.findMembership(
|
|
org.id,
|
|
dataArg.identity.userId,
|
|
);
|
|
if (!isMember && !dataArg.identity.isSystemAdmin) {
|
|
throw new plugins.typedrequest.TypedResponseError('Access denied');
|
|
}
|
|
}
|
|
|
|
const orgData: interfaces.data.IOrganizationDetail = {
|
|
id: org.id,
|
|
name: org.name,
|
|
displayName: org.displayName,
|
|
description: org.description,
|
|
avatarUrl: org.avatarUrl,
|
|
website: org.website,
|
|
isPublic: org.isPublic,
|
|
memberCount: org.memberCount,
|
|
plan: (org as any).plan || 'free',
|
|
usedStorageBytes: org.usedStorageBytes || 0,
|
|
storageQuotaBytes: (org as any).storageQuotaBytes || 0,
|
|
createdAt: org.createdAt instanceof Date
|
|
? org.createdAt.toISOString()
|
|
: String(org.createdAt),
|
|
};
|
|
|
|
// Include settings for admins
|
|
if (dataArg.identity.isSystemAdmin && org.settings) {
|
|
orgData.settings = org.settings as any;
|
|
}
|
|
|
|
return { organization: orgData };
|
|
} catch (error) {
|
|
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
|
throw new plugins.typedrequest.TypedResponseError('Failed to get organization');
|
|
}
|
|
},
|
|
),
|
|
);
|
|
|
|
// Create Organization
|
|
this.typedrouter.addTypedHandler(
|
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateOrganization>(
|
|
'createOrganization',
|
|
async (dataArg) => {
|
|
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
|
|
|
try {
|
|
const { name, displayName, description, isPublic } = dataArg;
|
|
|
|
if (!name) {
|
|
throw new plugins.typedrequest.TypedResponseError('Organization name is required');
|
|
}
|
|
|
|
// Validate name format
|
|
if (!/^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/.test(name)) {
|
|
throw new plugins.typedrequest.TypedResponseError(
|
|
'Name must be lowercase alphanumeric with optional hyphens and dots',
|
|
);
|
|
}
|
|
|
|
// Check uniqueness
|
|
const existing = await Organization.findByName(name);
|
|
if (existing) {
|
|
throw new plugins.typedrequest.TypedResponseError('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 = dataArg.identity.userId;
|
|
|
|
await org.save();
|
|
|
|
// Add creator as owner
|
|
const membership = new OrganizationMember();
|
|
membership.id = await OrganizationMember.getNewId();
|
|
membership.organizationId = org.id;
|
|
membership.userId = dataArg.identity.userId;
|
|
membership.role = 'owner';
|
|
membership.invitedBy = dataArg.identity.userId;
|
|
membership.joinedAt = new Date();
|
|
|
|
await membership.save();
|
|
|
|
// Audit log
|
|
await AuditService.withContext({
|
|
actorId: dataArg.identity.userId,
|
|
actorType: 'user',
|
|
}).logOrganizationCreated(org.id, org.name);
|
|
|
|
return {
|
|
organization: {
|
|
id: org.id,
|
|
name: org.name,
|
|
displayName: org.displayName,
|
|
description: org.description,
|
|
isPublic: org.isPublic,
|
|
memberCount: org.memberCount,
|
|
plan: (org as any).plan || 'free',
|
|
usedStorageBytes: org.usedStorageBytes || 0,
|
|
storageQuotaBytes: (org as any).storageQuotaBytes || 0,
|
|
createdAt: org.createdAt instanceof Date
|
|
? org.createdAt.toISOString()
|
|
: String(org.createdAt),
|
|
},
|
|
};
|
|
} catch (error) {
|
|
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
|
throw new plugins.typedrequest.TypedResponseError('Failed to create organization');
|
|
}
|
|
},
|
|
),
|
|
);
|
|
|
|
// Update Organization
|
|
this.typedrouter.addTypedHandler(
|
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateOrganization>(
|
|
'updateOrganization',
|
|
async (dataArg) => {
|
|
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
|
|
|
try {
|
|
const org = await this.resolveOrganization(dataArg.organizationId);
|
|
if (!org) {
|
|
throw new plugins.typedrequest.TypedResponseError('Organization not found');
|
|
}
|
|
|
|
// Check admin permission
|
|
const canManage = await this.permissionService.canManageOrganization(
|
|
dataArg.identity.userId,
|
|
org.id,
|
|
);
|
|
if (!canManage) {
|
|
throw new plugins.typedrequest.TypedResponseError('Admin access required');
|
|
}
|
|
|
|
if (dataArg.displayName !== undefined) org.displayName = dataArg.displayName;
|
|
if (dataArg.description !== undefined) org.description = dataArg.description;
|
|
if (dataArg.avatarUrl !== undefined) org.avatarUrl = dataArg.avatarUrl;
|
|
if (dataArg.website !== undefined) org.website = dataArg.website;
|
|
if (dataArg.isPublic !== undefined) org.isPublic = dataArg.isPublic;
|
|
|
|
// Only system admins can change settings
|
|
if (dataArg.settings && dataArg.identity.isSystemAdmin) {
|
|
org.settings = { ...org.settings, ...dataArg.settings } as any;
|
|
}
|
|
|
|
await org.save();
|
|
|
|
return {
|
|
organization: {
|
|
id: org.id,
|
|
name: org.name,
|
|
displayName: org.displayName,
|
|
description: org.description,
|
|
avatarUrl: org.avatarUrl,
|
|
website: org.website,
|
|
isPublic: org.isPublic,
|
|
memberCount: org.memberCount,
|
|
plan: (org as any).plan || 'free',
|
|
usedStorageBytes: org.usedStorageBytes || 0,
|
|
storageQuotaBytes: (org as any).storageQuotaBytes || 0,
|
|
createdAt: org.createdAt instanceof Date
|
|
? org.createdAt.toISOString()
|
|
: String(org.createdAt),
|
|
},
|
|
};
|
|
} catch (error) {
|
|
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
|
throw new plugins.typedrequest.TypedResponseError('Failed to update organization');
|
|
}
|
|
},
|
|
),
|
|
);
|
|
|
|
// Delete Organization
|
|
this.typedrouter.addTypedHandler(
|
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteOrganization>(
|
|
'deleteOrganization',
|
|
async (dataArg) => {
|
|
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
|
|
|
try {
|
|
const org = await this.resolveOrganization(dataArg.organizationId);
|
|
if (!org) {
|
|
throw new plugins.typedrequest.TypedResponseError('Organization not found');
|
|
}
|
|
|
|
// Only owners and system admins can delete
|
|
const membership = await OrganizationMember.findMembership(
|
|
org.id,
|
|
dataArg.identity.userId,
|
|
);
|
|
if (membership?.role !== 'owner' && !dataArg.identity.isSystemAdmin) {
|
|
throw new plugins.typedrequest.TypedResponseError('Owner access required');
|
|
}
|
|
|
|
await org.delete();
|
|
|
|
return { message: 'Organization deleted successfully' };
|
|
} catch (error) {
|
|
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
|
throw new plugins.typedrequest.TypedResponseError('Failed to delete organization');
|
|
}
|
|
},
|
|
),
|
|
);
|
|
|
|
// Get Organization Members
|
|
this.typedrouter.addTypedHandler(
|
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetOrganizationMembers>(
|
|
'getOrganizationMembers',
|
|
async (dataArg) => {
|
|
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
|
|
|
try {
|
|
const org = await this.resolveOrganization(dataArg.organizationId);
|
|
if (!org) {
|
|
throw new plugins.typedrequest.TypedResponseError('Organization not found');
|
|
}
|
|
|
|
// Check membership
|
|
const isMember = await OrganizationMember.findMembership(
|
|
org.id,
|
|
dataArg.identity.userId,
|
|
);
|
|
if (!isMember && !dataArg.identity.isSystemAdmin) {
|
|
throw new plugins.typedrequest.TypedResponseError('Access denied');
|
|
}
|
|
|
|
const members = await OrganizationMember.getOrgMembers(org.id);
|
|
|
|
const membersWithUsers = await Promise.all(
|
|
members.map(async (m) => {
|
|
const user = await User.findById(m.userId);
|
|
return {
|
|
userId: m.userId,
|
|
role: m.role as interfaces.data.TOrganizationRole,
|
|
addedAt: m.joinedAt instanceof Date
|
|
? m.joinedAt.toISOString()
|
|
: String(m.joinedAt),
|
|
user: user
|
|
? {
|
|
username: user.username,
|
|
displayName: user.displayName,
|
|
avatarUrl: user.avatarUrl,
|
|
}
|
|
: null,
|
|
};
|
|
}),
|
|
);
|
|
|
|
return { members: membersWithUsers };
|
|
} catch (error) {
|
|
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
|
throw new plugins.typedrequest.TypedResponseError('Failed to list members');
|
|
}
|
|
},
|
|
),
|
|
);
|
|
|
|
// Add Organization Member
|
|
this.typedrouter.addTypedHandler(
|
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_AddOrganizationMember>(
|
|
'addOrganizationMember',
|
|
async (dataArg) => {
|
|
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
|
|
|
try {
|
|
const org = await this.resolveOrganization(dataArg.organizationId);
|
|
if (!org) {
|
|
throw new plugins.typedrequest.TypedResponseError('Organization not found');
|
|
}
|
|
|
|
// Check admin permission
|
|
const canManage = await this.permissionService.canManageOrganization(
|
|
dataArg.identity.userId,
|
|
org.id,
|
|
);
|
|
if (!canManage) {
|
|
throw new plugins.typedrequest.TypedResponseError('Admin access required');
|
|
}
|
|
|
|
const { userId, role } = dataArg;
|
|
|
|
if (!userId || !role) {
|
|
throw new plugins.typedrequest.TypedResponseError('userId and role are required');
|
|
}
|
|
|
|
if (!['owner', 'admin', 'member'].includes(role)) {
|
|
throw new plugins.typedrequest.TypedResponseError('Invalid role');
|
|
}
|
|
|
|
// Check user exists
|
|
const user = await User.findById(userId);
|
|
if (!user) {
|
|
throw new plugins.typedrequest.TypedResponseError('User not found');
|
|
}
|
|
|
|
// Check if already a member
|
|
const existing = await OrganizationMember.findMembership(org.id, userId);
|
|
if (existing) {
|
|
throw new plugins.typedrequest.TypedResponseError('User is already a member');
|
|
}
|
|
|
|
// Add member
|
|
const membership = new OrganizationMember();
|
|
membership.id = await OrganizationMember.getNewId();
|
|
membership.organizationId = org.id;
|
|
membership.userId = userId;
|
|
membership.role = role;
|
|
membership.invitedBy = dataArg.identity.userId;
|
|
membership.joinedAt = new Date();
|
|
|
|
await membership.save();
|
|
|
|
// Update member count
|
|
org.memberCount += 1;
|
|
await org.save();
|
|
|
|
return {
|
|
member: {
|
|
userId: membership.userId,
|
|
role: membership.role as interfaces.data.TOrganizationRole,
|
|
addedAt: membership.joinedAt instanceof Date
|
|
? membership.joinedAt.toISOString()
|
|
: String(membership.joinedAt),
|
|
},
|
|
};
|
|
} catch (error) {
|
|
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
|
throw new plugins.typedrequest.TypedResponseError('Failed to add member');
|
|
}
|
|
},
|
|
),
|
|
);
|
|
|
|
// Update Organization Member
|
|
this.typedrouter.addTypedHandler(
|
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateOrganizationMember>(
|
|
'updateOrganizationMember',
|
|
async (dataArg) => {
|
|
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
|
|
|
try {
|
|
const org = await this.resolveOrganization(dataArg.organizationId);
|
|
if (!org) {
|
|
throw new plugins.typedrequest.TypedResponseError('Organization not found');
|
|
}
|
|
|
|
// Check admin permission
|
|
const canManage = await this.permissionService.canManageOrganization(
|
|
dataArg.identity.userId,
|
|
org.id,
|
|
);
|
|
if (!canManage) {
|
|
throw new plugins.typedrequest.TypedResponseError('Admin access required');
|
|
}
|
|
|
|
const { userId, role } = dataArg;
|
|
|
|
if (!role || !['owner', 'admin', 'member'].includes(role)) {
|
|
throw new plugins.typedrequest.TypedResponseError('Valid role is required');
|
|
}
|
|
|
|
const membership = await OrganizationMember.findMembership(org.id, userId);
|
|
if (!membership) {
|
|
throw new plugins.typedrequest.TypedResponseError('Member not found');
|
|
}
|
|
|
|
// Cannot change last owner
|
|
if (membership.role === 'owner' && role !== 'owner') {
|
|
const members = await OrganizationMember.getOrgMembers(org.id);
|
|
const ownerCount = members.filter((m) => m.role === 'owner').length;
|
|
if (ownerCount <= 1) {
|
|
throw new plugins.typedrequest.TypedResponseError('Cannot remove the last owner');
|
|
}
|
|
}
|
|
|
|
membership.role = role;
|
|
await membership.save();
|
|
|
|
return {
|
|
member: {
|
|
userId: membership.userId,
|
|
role: membership.role as interfaces.data.TOrganizationRole,
|
|
},
|
|
};
|
|
} catch (error) {
|
|
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
|
throw new plugins.typedrequest.TypedResponseError('Failed to update member');
|
|
}
|
|
},
|
|
),
|
|
);
|
|
|
|
// Remove Organization Member
|
|
this.typedrouter.addTypedHandler(
|
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RemoveOrganizationMember>(
|
|
'removeOrganizationMember',
|
|
async (dataArg) => {
|
|
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
|
|
|
|
try {
|
|
const org = await this.resolveOrganization(dataArg.organizationId);
|
|
if (!org) {
|
|
throw new plugins.typedrequest.TypedResponseError('Organization not found');
|
|
}
|
|
|
|
// Users can remove themselves, admins can remove others
|
|
if (dataArg.userId !== dataArg.identity.userId) {
|
|
const canManage = await this.permissionService.canManageOrganization(
|
|
dataArg.identity.userId,
|
|
org.id,
|
|
);
|
|
if (!canManage) {
|
|
throw new plugins.typedrequest.TypedResponseError('Admin access required');
|
|
}
|
|
}
|
|
|
|
const membership = await OrganizationMember.findMembership(org.id, dataArg.userId);
|
|
if (!membership) {
|
|
throw new plugins.typedrequest.TypedResponseError('Member not found');
|
|
}
|
|
|
|
// Cannot remove last owner
|
|
if (membership.role === 'owner') {
|
|
const members = await OrganizationMember.getOrgMembers(org.id);
|
|
const ownerCount = members.filter((m) => m.role === 'owner').length;
|
|
if (ownerCount <= 1) {
|
|
throw new plugins.typedrequest.TypedResponseError('Cannot remove the last owner');
|
|
}
|
|
}
|
|
|
|
await membership.delete();
|
|
|
|
// Update member count
|
|
org.memberCount = Math.max(0, org.memberCount - 1);
|
|
await org.save();
|
|
|
|
return { message: 'Member removed successfully' };
|
|
} catch (error) {
|
|
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
|
|
throw new plugins.typedrequest.TypedResponseError('Failed to remove member');
|
|
}
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|