feat(opsserver,web): replace the Angular UI and REST management layer with a TypedRequest-based ops server and bundled web frontend
This commit is contained in:
548
ts/opsserver/handlers/organization.handler.ts
Normal file
548
ts/opsserver/handlers/organization.handler.ts
Normal file
@@ -0,0 +1,548 @@
|
||||
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');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user