feat(organization): add organization rename redirects and redirect management endpoints

This commit is contained in:
2026-03-20 17:07:12 +00:00
parent c60a0ed536
commit 8cb5e4fa96
10 changed files with 224 additions and 8 deletions

View File

@@ -2,7 +2,7 @@ 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 { Organization, OrganizationMember, OrgRedirect, User } from '../../models/index.ts';
import { PermissionService } from '../../services/permission.service.ts';
import { AuditService } from '../../services/audit.service.ts';
@@ -19,9 +19,18 @@ export class OrganizationHandler {
* 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);
if (idOrName.startsWith('Organization:')) {
return await Organization.findById(idOrName);
}
// Try direct name lookup first
const org = await Organization.findByName(idOrName);
if (org) return org;
// Check redirects
const redirect = await OrgRedirect.findByName(idOrName);
if (redirect) {
return await Organization.findById(redirect.organizationId);
}
return null;
}
private registerHandlers(): void {
@@ -232,6 +241,36 @@ export class OrganizationHandler {
throw new plugins.typedrequest.TypedResponseError('Admin access required');
}
// Handle rename
if (dataArg.name && dataArg.name !== org.name) {
const newName = dataArg.name;
// Validate name format
if (!/^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/.test(newName)) {
throw new plugins.typedrequest.TypedResponseError(
'Name must be lowercase alphanumeric with optional hyphens and dots',
);
}
// Check new name not taken by another org
const existingOrg = await Organization.findByName(newName);
if (existingOrg && existingOrg.id !== org.id) {
throw new plugins.typedrequest.TypedResponseError('Organization name already taken');
}
// Check new name not taken by a redirect pointing elsewhere
const existingRedirect = await OrgRedirect.findByName(newName);
if (existingRedirect && existingRedirect.organizationId !== org.id) {
throw new plugins.typedrequest.TypedResponseError(
'Name is reserved as a redirect for another organization',
);
}
// If new name is one of our own redirects, delete that redirect
if (existingRedirect && existingRedirect.organizationId === org.id) {
await existingRedirect.delete();
}
// Create redirect from old name
await OrgRedirect.create(org.name, org.id);
org.name = newName;
}
if (dataArg.displayName !== undefined) org.displayName = dataArg.displayName;
if (dataArg.description !== undefined) org.description = dataArg.description;
if (dataArg.avatarUrl !== undefined) org.avatarUrl = dataArg.avatarUrl;
@@ -544,5 +583,69 @@ export class OrganizationHandler {
},
),
);
// Get Org Redirects
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetOrgRedirects>(
'getOrgRedirects',
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');
}
const redirects = await OrgRedirect.getByOrgId(org.id);
return {
redirects: redirects.map((r) => ({
id: r.id,
oldName: r.oldName,
organizationId: r.organizationId,
createdAt: r.createdAt instanceof Date
? r.createdAt.toISOString()
: String(r.createdAt),
})),
};
} catch (error) {
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
throw new plugins.typedrequest.TypedResponseError('Failed to get redirects');
}
},
),
);
// Delete Org Redirect
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteOrgRedirect>(
'deleteOrgRedirect',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
try {
const redirect = await OrgRedirect.findById(dataArg.redirectId);
if (!redirect) {
throw new plugins.typedrequest.TypedResponseError('Redirect not found');
}
// Check permission on the org
const canManage = await this.permissionService.canManageOrganization(
dataArg.identity.userId,
redirect.organizationId,
);
if (!canManage && !dataArg.identity.isSystemAdmin) {
throw new plugins.typedrequest.TypedResponseError('Admin access required');
}
await redirect.delete();
return { message: 'Redirect deleted successfully' };
} catch (error) {
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
throw new plugins.typedrequest.TypedResponseError('Failed to delete redirect');
}
},
),
);
}
}