feat: complete tenant member management (CRUD + delete tenant)
Backend: add 5 missing endpoints to TenantController: - DELETE /tenants/:id (deprovision schema + cleanup) - GET /tenants/:id/members (query tenant schema users) - PATCH /tenants/:id/members/:memberId (change role) - DELETE /tenants/:id/members/:memberId (remove member) - PUT /tenants/:id (alias for frontend compatibility) Frontend: add member actions to tenant detail page: - Role column changed to dropdown selector - Added remove member button with confirmation - Added updateMember and removeMember mutations Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
bc7e32061a
commit
51b348e609
|
|
@ -317,6 +317,26 @@ export default function TenantDetailPage() {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const updateMemberMutation = useMutation({
|
||||||
|
mutationFn: ({ memberId, ...body }: { memberId: string; role?: string; status?: string }) =>
|
||||||
|
apiClient(`/api/v1/admin/tenants/${id}/members/${memberId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body,
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: [...queryKeys.tenants.detail(id), 'members'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const removeMemberMutation = useMutation({
|
||||||
|
mutationFn: (memberId: string) =>
|
||||||
|
apiClient(`/api/v1/admin/tenants/${id}/members/${memberId}`, { method: 'DELETE' }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: [...queryKeys.tenants.detail(id), 'members'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.tenants.detail(id) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Helpers --------------------------------------------------------------
|
// Helpers --------------------------------------------------------------
|
||||||
const startEditing = useCallback(() => {
|
const startEditing = useCallback(() => {
|
||||||
if (!tenant) return;
|
if (!tenant) return;
|
||||||
|
|
@ -609,7 +629,8 @@ export default function TenantDetailPage() {
|
||||||
<th className="text-left px-3 py-2 font-medium">{t('detail.membersTable.name')}</th>
|
<th className="text-left px-3 py-2 font-medium">{t('detail.membersTable.name')}</th>
|
||||||
<th className="text-left px-3 py-2 font-medium">{t('detail.membersTable.email')}</th>
|
<th className="text-left px-3 py-2 font-medium">{t('detail.membersTable.email')}</th>
|
||||||
<th className="text-left px-3 py-2 font-medium">{t('detail.membersTable.role')}</th>
|
<th className="text-left px-3 py-2 font-medium">{t('detail.membersTable.role')}</th>
|
||||||
<th className="text-right px-3 py-2 font-medium">{t('detail.membersTable.joined')}</th>
|
<th className="text-left px-3 py-2 font-medium">{t('detail.membersTable.joined')}</th>
|
||||||
|
<th className="text-right px-3 py-2 font-medium">{t('detail.invitesTable.actions')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|
@ -623,11 +644,38 @@ export default function TenantDetailPage() {
|
||||||
{member.email}
|
{member.email}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
<RoleBadge role={member.role} />
|
<select
|
||||||
|
value={member.role}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateMemberMutation.mutate({
|
||||||
|
memberId: member.id,
|
||||||
|
role: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={updateMemberMutation.isPending}
|
||||||
|
className="px-2 py-1 text-xs rounded-md border border-input bg-background"
|
||||||
|
>
|
||||||
|
<option value="admin">admin</option>
|
||||||
|
<option value="operator">operator</option>
|
||||||
|
<option value="viewer">viewer</option>
|
||||||
|
</select>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-right text-muted-foreground text-xs">
|
<td className="px-3 py-2 text-muted-foreground text-xs">
|
||||||
{formatRelative(member.joinedAt)}
|
{formatRelative(member.joinedAt)}
|
||||||
</td>
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm(`Remove ${member.name} from this tenant?`)) {
|
||||||
|
removeMemberMutation.mutate(member.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={removeMemberMutation.isPending}
|
||||||
|
className="px-2 py-1 text-xs text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{tc('remove')}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import {
|
||||||
Post,
|
Post,
|
||||||
Delete,
|
Delete,
|
||||||
Patch,
|
Patch,
|
||||||
|
Put,
|
||||||
Param,
|
Param,
|
||||||
Body,
|
Body,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
|
|
@ -12,10 +13,11 @@ import {
|
||||||
Logger,
|
Logger,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository, DataSource } from 'typeorm';
|
||||||
import { RolesGuard, Roles } from '@it0/common';
|
import { RolesGuard, Roles } from '@it0/common';
|
||||||
import { TenantProvisioningService } from '@it0/database';
|
import { TenantProvisioningService } from '@it0/database';
|
||||||
import { Tenant } from '../../../domain/entities/tenant.entity';
|
import { Tenant } from '../../../domain/entities/tenant.entity';
|
||||||
|
import { TenantInvite } from '../../../domain/entities/tenant-invite.entity';
|
||||||
import { AuthService } from '../../../application/services/auth.service';
|
import { AuthService } from '../../../application/services/auth.service';
|
||||||
import * as crypto from 'crypto';
|
import * as crypto from 'crypto';
|
||||||
|
|
||||||
|
|
@ -28,19 +30,50 @@ export class TenantController {
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(Tenant)
|
@InjectRepository(Tenant)
|
||||||
private readonly tenantRepository: Repository<Tenant>,
|
private readonly tenantRepository: Repository<Tenant>,
|
||||||
|
@InjectRepository(TenantInvite)
|
||||||
|
private readonly inviteRepository: Repository<TenantInvite>,
|
||||||
private readonly tenantProvisioningService: TenantProvisioningService,
|
private readonly tenantProvisioningService: TenantProvisioningService,
|
||||||
private readonly authService: AuthService,
|
private readonly authService: AuthService,
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private toDto(t: Tenant) {
|
/* ---- Helpers ---- */
|
||||||
|
|
||||||
|
private parseRole(roles: unknown): string {
|
||||||
|
if (Array.isArray(roles)) return roles[0] ?? 'viewer';
|
||||||
|
if (typeof roles === 'string' && roles.startsWith('{')) {
|
||||||
|
return roles.slice(1, -1).split(',')[0] || 'viewer';
|
||||||
|
}
|
||||||
|
return 'viewer';
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getMemberCount(slug: string): Promise<number> {
|
||||||
|
const schemaName = `it0_t_${slug}`;
|
||||||
|
const qr = this.dataSource.createQueryRunner();
|
||||||
|
await qr.connect();
|
||||||
|
try {
|
||||||
|
await qr.query(`SET search_path TO "${schemaName}", public`);
|
||||||
|
const result = await qr.query(`SELECT COUNT(*)::int AS count FROM users`);
|
||||||
|
return result[0]?.count ?? 0;
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
} finally {
|
||||||
|
await qr.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDto(t: Tenant, memberCount?: number) {
|
||||||
|
const count = memberCount ?? 0;
|
||||||
return {
|
return {
|
||||||
id: t.id,
|
id: t.id,
|
||||||
name: t.name,
|
name: t.name,
|
||||||
slug: t.slug,
|
slug: t.slug,
|
||||||
|
schemaName: `it0_t_${t.slug}`,
|
||||||
plan: t.plan,
|
plan: t.plan,
|
||||||
status: t.status,
|
status: t.status,
|
||||||
adminEmail: t.adminEmail,
|
adminEmail: t.adminEmail,
|
||||||
userCount: 0,
|
userCount: count,
|
||||||
|
memberCount: count,
|
||||||
quota: {
|
quota: {
|
||||||
maxServers: t.maxServers,
|
maxServers: t.maxServers,
|
||||||
maxUsers: t.maxUsers,
|
maxUsers: t.maxUsers,
|
||||||
|
|
@ -52,9 +85,18 @@ export class TenantController {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async findTenantOrFail(id: string): Promise<Tenant> {
|
||||||
|
const tenant = await this.tenantRepository.findOne({ where: { id } });
|
||||||
|
if (!tenant) {
|
||||||
|
throw new NotFoundException(`Tenant with id "${id}" not found`);
|
||||||
|
}
|
||||||
|
return tenant;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Tenant CRUD ---- */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/v1/admin/tenants
|
* GET /api/v1/admin/tenants
|
||||||
* Returns all tenants from the public schema's tenants table.
|
|
||||||
*/
|
*/
|
||||||
@Get()
|
@Get()
|
||||||
async listTenants() {
|
async listTenants() {
|
||||||
|
|
@ -62,22 +104,8 @@ export class TenantController {
|
||||||
return tenants.map((t) => this.toDto(t));
|
return tenants.map((t) => this.toDto(t));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/v1/admin/tenants/:id
|
|
||||||
* Returns a single tenant by ID.
|
|
||||||
*/
|
|
||||||
@Get(':id')
|
|
||||||
async getTenant(@Param('id') id: string) {
|
|
||||||
const tenant = await this.tenantRepository.findOne({ where: { id } });
|
|
||||||
if (!tenant) {
|
|
||||||
throw new NotFoundException(`Tenant with id "${id}" not found`);
|
|
||||||
}
|
|
||||||
return this.toDto(tenant);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/v1/admin/tenants
|
* POST /api/v1/admin/tenants
|
||||||
* Creates a new tenant and provisions its database schema.
|
|
||||||
*/
|
*/
|
||||||
@Post()
|
@Post()
|
||||||
async createTenant(
|
async createTenant(
|
||||||
|
|
@ -108,7 +136,6 @@ export class TenantController {
|
||||||
|
|
||||||
const savedTenant = await this.tenantRepository.save(tenant);
|
const savedTenant = await this.tenantRepository.save(tenant);
|
||||||
|
|
||||||
// Provision the tenant's database schema
|
|
||||||
try {
|
try {
|
||||||
await this.tenantProvisioningService.provisionTenant(savedTenant.slug);
|
await this.tenantProvisioningService.provisionTenant(savedTenant.slug);
|
||||||
this.logger.log(`Schema provisioned for tenant "${savedTenant.slug}"`);
|
this.logger.log(`Schema provisioned for tenant "${savedTenant.slug}"`);
|
||||||
|
|
@ -117,51 +144,156 @@ export class TenantController {
|
||||||
`Failed to provision schema for tenant "${savedTenant.slug}":`,
|
`Failed to provision schema for tenant "${savedTenant.slug}":`,
|
||||||
err,
|
err,
|
||||||
);
|
);
|
||||||
// The tenant record is already saved; schema provisioning can be retried
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.toDto(savedTenant);
|
return this.toDto(savedTenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- Member Management ---- */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PATCH /api/v1/admin/tenants/:id
|
* GET /api/v1/admin/tenants/:id/members
|
||||||
* Updates tenant metadata.
|
|
||||||
*/
|
*/
|
||||||
@Patch(':id')
|
@Get(':id/members')
|
||||||
async updateTenant(@Param('id') id: string, @Body() body: any) {
|
async listMembers(@Param('id') id: string) {
|
||||||
const tenant = await this.tenantRepository.findOne({ where: { id } });
|
const tenant = await this.findTenantOrFail(id);
|
||||||
if (!tenant) {
|
const schemaName = `it0_t_${tenant.slug}`;
|
||||||
throw new NotFoundException(`Tenant with id "${id}" not found`);
|
const qr = this.dataSource.createQueryRunner();
|
||||||
|
await qr.connect();
|
||||||
|
try {
|
||||||
|
await qr.query(`SET search_path TO "${schemaName}", public`);
|
||||||
|
const rows = await qr.query(
|
||||||
|
`SELECT id, email, name, roles, is_active, created_at FROM users ORDER BY created_at ASC`,
|
||||||
|
);
|
||||||
|
const data = rows.map((row: any) => ({
|
||||||
|
id: row.id,
|
||||||
|
email: row.email,
|
||||||
|
name: row.name,
|
||||||
|
role: this.parseRole(row.roles),
|
||||||
|
joinedAt: row.created_at,
|
||||||
|
}));
|
||||||
|
return { data, total: data.length };
|
||||||
|
} finally {
|
||||||
|
await qr.release();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (body.name !== undefined) tenant.name = body.name;
|
/**
|
||||||
if (body.plan !== undefined) tenant.plan = body.plan;
|
* PATCH /api/v1/admin/tenants/:id/members/:memberId
|
||||||
if (body.status !== undefined) tenant.status = body.status;
|
*/
|
||||||
if (body.adminEmail !== undefined) tenant.adminEmail = body.adminEmail;
|
@Patch(':id/members/:memberId')
|
||||||
|
async updateMember(
|
||||||
|
@Param('id') tenantId: string,
|
||||||
|
@Param('memberId') memberId: string,
|
||||||
|
@Body() body: { role?: string; status?: string },
|
||||||
|
) {
|
||||||
|
const tenant = await this.findTenantOrFail(tenantId);
|
||||||
|
const schemaName = `it0_t_${tenant.slug}`;
|
||||||
|
const qr = this.dataSource.createQueryRunner();
|
||||||
|
await qr.connect();
|
||||||
|
try {
|
||||||
|
await qr.query(`SET search_path TO "${schemaName}", public`);
|
||||||
|
|
||||||
// Support nested quota object from frontend
|
const existing = await qr.query(
|
||||||
if (body.quota) {
|
`SELECT id FROM users WHERE id = $1`,
|
||||||
if (body.quota.maxServers !== undefined) tenant.maxServers = body.quota.maxServers;
|
[memberId],
|
||||||
if (body.quota.maxUsers !== undefined) tenant.maxUsers = body.quota.maxUsers;
|
);
|
||||||
if (body.quota.maxStandingOrders !== undefined) tenant.maxStandingOrders = body.quota.maxStandingOrders;
|
if (existing.length === 0) {
|
||||||
if (body.quota.maxAgentTokensPerMonth !== undefined) tenant.maxAgentTokensPerMonth = body.quota.maxAgentTokensPerMonth;
|
throw new NotFoundException(`Member "${memberId}" not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates: string[] = [];
|
||||||
|
const params: any[] = [];
|
||||||
|
let idx = 1;
|
||||||
|
|
||||||
|
if (body.role) {
|
||||||
|
updates.push(`roles = $${idx}`);
|
||||||
|
params.push([body.role]);
|
||||||
|
idx++;
|
||||||
|
}
|
||||||
|
if (body.status !== undefined) {
|
||||||
|
updates.push(`is_active = $${idx}`);
|
||||||
|
params.push(body.status === 'active');
|
||||||
|
idx++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.length > 0) {
|
||||||
|
updates.push('updated_at = NOW()');
|
||||||
|
params.push(memberId);
|
||||||
|
await qr.query(
|
||||||
|
`UPDATE users SET ${updates.join(', ')} WHERE id = $${idx}`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await qr.query(
|
||||||
|
`SELECT id, email, name, roles, is_active, created_at FROM users WHERE id = $1`,
|
||||||
|
[memberId],
|
||||||
|
);
|
||||||
|
const row = updated[0];
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
email: row.email,
|
||||||
|
name: row.name,
|
||||||
|
role: this.parseRole(row.roles),
|
||||||
|
joinedAt: row.created_at,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
await qr.release();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Also support flat fields
|
/**
|
||||||
if (body.maxServers !== undefined) tenant.maxServers = body.maxServers;
|
* DELETE /api/v1/admin/tenants/:id/members/:memberId
|
||||||
if (body.maxUsers !== undefined) tenant.maxUsers = body.maxUsers;
|
*/
|
||||||
if (body.maxStandingOrders !== undefined) tenant.maxStandingOrders = body.maxStandingOrders;
|
@Delete(':id/members/:memberId')
|
||||||
if (body.maxAgentTokensPerMonth !== undefined) tenant.maxAgentTokensPerMonth = body.maxAgentTokensPerMonth;
|
async removeMember(
|
||||||
|
@Param('id') tenantId: string,
|
||||||
|
@Param('memberId') memberId: string,
|
||||||
|
) {
|
||||||
|
const tenant = await this.findTenantOrFail(tenantId);
|
||||||
|
const schemaName = `it0_t_${tenant.slug}`;
|
||||||
|
const qr = this.dataSource.createQueryRunner();
|
||||||
|
await qr.connect();
|
||||||
|
try {
|
||||||
|
await qr.query(`SET search_path TO "${schemaName}", public`);
|
||||||
|
|
||||||
const saved = await this.tenantRepository.save(tenant);
|
const existing = await qr.query(
|
||||||
return this.toDto(saved);
|
`SELECT id FROM users WHERE id = $1`,
|
||||||
|
[memberId],
|
||||||
|
);
|
||||||
|
if (existing.length === 0) {
|
||||||
|
throw new NotFoundException(`Member "${memberId}" not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await qr.query(`DELETE FROM users WHERE id = $1`, [memberId]);
|
||||||
|
return { success: true };
|
||||||
|
} finally {
|
||||||
|
await qr.release();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- Invitation Management ---- */
|
/* ---- Invitation Management ---- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/admin/tenants/:id/invites
|
||||||
|
*/
|
||||||
|
@Get(':id/invites')
|
||||||
|
async listInvites(@Param('id') tenantId: string) {
|
||||||
|
const invites = await this.authService.listInvites(tenantId);
|
||||||
|
return invites.map((inv) => ({
|
||||||
|
id: inv.id,
|
||||||
|
email: inv.email,
|
||||||
|
role: inv.role,
|
||||||
|
status: inv.status,
|
||||||
|
expiresAt: inv.expiresAt,
|
||||||
|
invitedBy: inv.invitedBy,
|
||||||
|
createdAt: inv.createdAt,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/v1/admin/tenants/:id/invites
|
* POST /api/v1/admin/tenants/:id/invites
|
||||||
* Send an invitation to join this tenant.
|
|
||||||
*/
|
*/
|
||||||
@Post(':id/invites')
|
@Post(':id/invites')
|
||||||
async createInvite(
|
async createInvite(
|
||||||
|
|
@ -186,27 +318,8 @@ export class TenantController {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/v1/admin/tenants/:id/invites
|
|
||||||
* List all invitations for this tenant.
|
|
||||||
*/
|
|
||||||
@Get(':id/invites')
|
|
||||||
async listInvites(@Param('id') tenantId: string) {
|
|
||||||
const invites = await this.authService.listInvites(tenantId);
|
|
||||||
return invites.map((inv) => ({
|
|
||||||
id: inv.id,
|
|
||||||
email: inv.email,
|
|
||||||
role: inv.role,
|
|
||||||
status: inv.status,
|
|
||||||
expiresAt: inv.expiresAt,
|
|
||||||
invitedBy: inv.invitedBy,
|
|
||||||
createdAt: inv.createdAt,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DELETE /api/v1/admin/tenants/:id/invites/:inviteId
|
* DELETE /api/v1/admin/tenants/:id/invites/:inviteId
|
||||||
* Revoke a pending invitation.
|
|
||||||
*/
|
*/
|
||||||
@Delete(':id/invites/:inviteId')
|
@Delete(':id/invites/:inviteId')
|
||||||
async revokeInvite(
|
async revokeInvite(
|
||||||
|
|
@ -216,4 +329,78 @@ export class TenantController {
|
||||||
await this.authService.revokeInvite(inviteId, tenantId);
|
await this.authService.revokeInvite(inviteId, tenantId);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- Single Tenant Operations (less-specific routes last) ---- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/admin/tenants/:id
|
||||||
|
*/
|
||||||
|
@Get(':id')
|
||||||
|
async getTenant(@Param('id') id: string) {
|
||||||
|
const tenant = await this.findTenantOrFail(id);
|
||||||
|
const memberCount = await this.getMemberCount(tenant.slug);
|
||||||
|
return this.toDto(tenant, memberCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/v1/admin/tenants/:id
|
||||||
|
*/
|
||||||
|
@Patch(':id')
|
||||||
|
async updateTenant(@Param('id') id: string, @Body() body: any) {
|
||||||
|
const tenant = await this.findTenantOrFail(id);
|
||||||
|
|
||||||
|
if (body.name !== undefined) tenant.name = body.name;
|
||||||
|
if (body.plan !== undefined) tenant.plan = body.plan;
|
||||||
|
if (body.status !== undefined) tenant.status = body.status;
|
||||||
|
if (body.adminEmail !== undefined) tenant.adminEmail = body.adminEmail;
|
||||||
|
|
||||||
|
// Support nested quota object from frontend
|
||||||
|
if (body.quota) {
|
||||||
|
if (body.quota.maxServers !== undefined) tenant.maxServers = body.quota.maxServers;
|
||||||
|
if (body.quota.maxUsers !== undefined) tenant.maxUsers = body.quota.maxUsers;
|
||||||
|
if (body.quota.maxStandingOrders !== undefined) tenant.maxStandingOrders = body.quota.maxStandingOrders;
|
||||||
|
if (body.quota.maxAgentTokensPerMonth !== undefined) tenant.maxAgentTokensPerMonth = body.quota.maxAgentTokensPerMonth;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also support flat fields
|
||||||
|
if (body.maxServers !== undefined) tenant.maxServers = body.maxServers;
|
||||||
|
if (body.maxUsers !== undefined) tenant.maxUsers = body.maxUsers;
|
||||||
|
if (body.maxStandingOrders !== undefined) tenant.maxStandingOrders = body.maxStandingOrders;
|
||||||
|
if (body.maxAgentTokensPerMonth !== undefined) tenant.maxAgentTokensPerMonth = body.maxAgentTokensPerMonth;
|
||||||
|
|
||||||
|
const saved = await this.tenantRepository.save(tenant);
|
||||||
|
return this.toDto(saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/v1/admin/tenants/:id (alias for PATCH — frontend detail page uses PUT)
|
||||||
|
*/
|
||||||
|
@Put(':id')
|
||||||
|
async updateTenantPut(@Param('id') id: string, @Body() body: any) {
|
||||||
|
return this.updateTenant(id, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/v1/admin/tenants/:id
|
||||||
|
*/
|
||||||
|
@Delete(':id')
|
||||||
|
async deleteTenant(@Param('id') id: string) {
|
||||||
|
const tenant = await this.findTenantOrFail(id);
|
||||||
|
|
||||||
|
// 1. Drop the tenant schema (all data CASCADE)
|
||||||
|
try {
|
||||||
|
await this.tenantProvisioningService.deprovisionTenant(tenant.slug);
|
||||||
|
this.logger.log(`Schema deprovisioned for tenant "${tenant.slug}"`);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(`Failed to deprovision schema for "${tenant.slug}":`, err);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Delete all invites for this tenant
|
||||||
|
await this.inviteRepository.delete({ tenantId: id });
|
||||||
|
|
||||||
|
// 3. Delete the tenant record
|
||||||
|
await this.tenantRepository.remove(tenant);
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue