feat(tenants): add multi-tenant module — tenant CRUD, members, invites
- types.ts: TenantDoc, TenantMemberDoc, TenantInviteDoc + 4 Zod schemas - repository.ts: tenant CRUD, member CRUD, invite lifecycle, slug uniqueness - routes.ts: 10 endpoints (tenant CRUD, invite, accept, member role, remove) - tenants.test.ts: 16 schema + plan-limits tests - Plan limits: free(5), starter(25), pro(100), enterprise(10k) members - SHA-256 hashed invite tokens with 7-day expiry - Cosmos containers: tenants, tenant_members, tenant_invites
This commit is contained in:
parent
797f5e4318
commit
33e5fd70ce
265
services/platform-service/src/modules/tenants/repository.ts
Normal file
265
services/platform-service/src/modules/tenants/repository.ts
Normal file
@ -0,0 +1,265 @@
|
||||
/**
|
||||
* Multi-Tenant repository — Cosmos DB CRUD for tenants, members, and invites.
|
||||
* @module tenants/repository
|
||||
*/
|
||||
|
||||
import { getContainer } from '../../lib/cosmos.js';
|
||||
import type {
|
||||
TenantDoc,
|
||||
TenantMemberDoc,
|
||||
TenantInviteDoc,
|
||||
TenantStatus,
|
||||
MemberRole,
|
||||
} from './types.js';
|
||||
|
||||
// =============================================================================
|
||||
// Tenants
|
||||
// =============================================================================
|
||||
|
||||
export async function createTenant(doc: TenantDoc): Promise<TenantDoc> {
|
||||
const container = getContainer('tenants');
|
||||
const { resource } = await container.items.create(doc);
|
||||
if (!resource) throw new Error('Failed to create tenant');
|
||||
return resource as unknown as TenantDoc;
|
||||
}
|
||||
|
||||
export async function getTenant(id: string, productId: string): Promise<TenantDoc | null> {
|
||||
const container = getContainer('tenants');
|
||||
try {
|
||||
const { resource } = await container.item(id, productId).read();
|
||||
return resource as unknown as TenantDoc | null;
|
||||
} catch (err) {
|
||||
if ((err as { code?: number }).code === 404) return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTenantBySlug(productId: string, slug: string): Promise<TenantDoc | null> {
|
||||
const container = getContainer('tenants');
|
||||
const query = 'SELECT * FROM c WHERE c.productId = @productId AND c.slug = @slug';
|
||||
const parameters = [
|
||||
{ name: '@productId', value: productId },
|
||||
{ name: '@slug', value: slug },
|
||||
];
|
||||
const { resources } = await container.items.query<TenantDoc>({ query, parameters }).fetchAll();
|
||||
return resources[0] ?? null;
|
||||
}
|
||||
|
||||
export async function updateTenant(
|
||||
id: string,
|
||||
productId: string,
|
||||
updates: Partial<TenantDoc>
|
||||
): Promise<TenantDoc | null> {
|
||||
const existing = await getTenant(id, productId);
|
||||
if (!existing) return null;
|
||||
|
||||
const container = getContainer('tenants');
|
||||
const updated: TenantDoc = {
|
||||
...existing,
|
||||
...updates,
|
||||
id: existing.id,
|
||||
productId: existing.productId,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const { resource } = await container.items.upsert(updated);
|
||||
if (!resource) throw new Error('Failed to update tenant');
|
||||
return resource as unknown as TenantDoc;
|
||||
}
|
||||
|
||||
export async function listTenants(
|
||||
productId: string,
|
||||
options?: { status?: TenantStatus; ownerId?: string; limit?: number }
|
||||
): Promise<{ tenants: TenantDoc[]; total: number }> {
|
||||
const container = getContainer('tenants');
|
||||
|
||||
let query = 'SELECT * FROM c WHERE c.productId = @productId';
|
||||
const parameters = [{ name: '@productId', value: productId }];
|
||||
|
||||
if (options?.status) {
|
||||
query += ' AND c.status = @status';
|
||||
parameters.push({ name: '@status', value: options.status });
|
||||
}
|
||||
if (options?.ownerId) {
|
||||
query += ' AND c.ownerId = @ownerId';
|
||||
parameters.push({ name: '@ownerId', value: options.ownerId });
|
||||
}
|
||||
|
||||
query += ' ORDER BY c.createdAt DESC';
|
||||
|
||||
const countQuery = query.replace('SELECT *', 'SELECT VALUE COUNT(1)');
|
||||
const { resources: countResult } = await container.items
|
||||
.query<number>({ query: countQuery, parameters })
|
||||
.fetchAll();
|
||||
const total = countResult[0] ?? 0;
|
||||
|
||||
const safeLimit = Math.min(Math.max(options?.limit ?? 50, 1), 200);
|
||||
query += ` OFFSET 0 LIMIT ${safeLimit}`;
|
||||
|
||||
const { resources } = await container.items.query<TenantDoc>({ query, parameters }).fetchAll();
|
||||
|
||||
return { tenants: resources, total };
|
||||
}
|
||||
|
||||
export async function deleteTenant(id: string, productId: string): Promise<boolean> {
|
||||
const container = getContainer('tenants');
|
||||
try {
|
||||
await container.item(id, productId).delete();
|
||||
return true;
|
||||
} catch (err) {
|
||||
if ((err as { code?: number }).code === 404) return false;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tenant Members
|
||||
// =============================================================================
|
||||
|
||||
export async function addMember(doc: TenantMemberDoc): Promise<TenantMemberDoc> {
|
||||
const container = getContainer('tenant_members');
|
||||
const { resource } = await container.items.create(doc);
|
||||
if (!resource) throw new Error('Failed to add tenant member');
|
||||
return resource as unknown as TenantMemberDoc;
|
||||
}
|
||||
|
||||
export async function getMember(tenantId: string, userId: string): Promise<TenantMemberDoc | null> {
|
||||
const container = getContainer('tenant_members');
|
||||
const query = 'SELECT * FROM c WHERE c.tenantId = @tenantId AND c.userId = @userId';
|
||||
const parameters = [
|
||||
{ name: '@tenantId', value: tenantId },
|
||||
{ name: '@userId', value: userId },
|
||||
];
|
||||
const { resources } = await container.items
|
||||
.query<TenantMemberDoc>({ query, parameters })
|
||||
.fetchAll();
|
||||
return resources[0] ?? null;
|
||||
}
|
||||
|
||||
export async function listMembers(
|
||||
tenantId: string,
|
||||
options?: { role?: MemberRole; limit?: number }
|
||||
): Promise<{ members: TenantMemberDoc[]; total: number }> {
|
||||
const container = getContainer('tenant_members');
|
||||
|
||||
let query = 'SELECT * FROM c WHERE c.tenantId = @tenantId';
|
||||
const parameters = [{ name: '@tenantId', value: tenantId }];
|
||||
|
||||
if (options?.role) {
|
||||
query += ' AND c.role = @role';
|
||||
parameters.push({ name: '@role', value: options.role });
|
||||
}
|
||||
|
||||
query += ' ORDER BY c.joinedAt DESC';
|
||||
|
||||
const countQuery = query.replace('SELECT *', 'SELECT VALUE COUNT(1)');
|
||||
const { resources: countResult } = await container.items
|
||||
.query<number>({ query: countQuery, parameters })
|
||||
.fetchAll();
|
||||
const total = countResult[0] ?? 0;
|
||||
|
||||
const safeLimit = Math.min(Math.max(options?.limit ?? 50, 1), 200);
|
||||
query += ` OFFSET 0 LIMIT ${safeLimit}`;
|
||||
|
||||
const { resources } = await container.items
|
||||
.query<TenantMemberDoc>({ query, parameters })
|
||||
.fetchAll();
|
||||
|
||||
return { members: resources, total };
|
||||
}
|
||||
|
||||
export async function updateMemberRole(
|
||||
tenantId: string,
|
||||
userId: string,
|
||||
role: MemberRole
|
||||
): Promise<TenantMemberDoc | null> {
|
||||
const existing = await getMember(tenantId, userId);
|
||||
if (!existing) return null;
|
||||
|
||||
const container = getContainer('tenant_members');
|
||||
const updated: TenantMemberDoc = {
|
||||
...existing,
|
||||
role,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const { resource } = await container.items.upsert(updated);
|
||||
if (!resource) throw new Error('Failed to update member role');
|
||||
return resource as unknown as TenantMemberDoc;
|
||||
}
|
||||
|
||||
export async function removeMember(memberId: string, tenantId: string): Promise<boolean> {
|
||||
const container = getContainer('tenant_members');
|
||||
try {
|
||||
await container.item(memberId, tenantId).delete();
|
||||
return true;
|
||||
} catch (err) {
|
||||
if ((err as { code?: number }).code === 404) return false;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getMemberCount(tenantId: string): Promise<number> {
|
||||
const container = getContainer('tenant_members');
|
||||
const query = 'SELECT VALUE COUNT(1) FROM c WHERE c.tenantId = @tenantId';
|
||||
const parameters = [{ name: '@tenantId', value: tenantId }];
|
||||
const { resources } = await container.items.query<number>({ query, parameters }).fetchAll();
|
||||
return resources[0] ?? 0;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tenant Invites
|
||||
// =============================================================================
|
||||
|
||||
export async function createInvite(doc: TenantInviteDoc): Promise<TenantInviteDoc> {
|
||||
const container = getContainer('tenant_invites');
|
||||
const { resource } = await container.items.create(doc);
|
||||
if (!resource) throw new Error('Failed to create invite');
|
||||
return resource as unknown as TenantInviteDoc;
|
||||
}
|
||||
|
||||
export async function getInviteByTokenHash(
|
||||
tokenHash: string,
|
||||
tenantId: string
|
||||
): Promise<TenantInviteDoc | null> {
|
||||
const container = getContainer('tenant_invites');
|
||||
const query =
|
||||
'SELECT * FROM c WHERE c.tenantId = @tenantId AND c.tokenHash = @tokenHash AND c.acceptedAt = null';
|
||||
const parameters = [
|
||||
{ name: '@tenantId', value: tenantId },
|
||||
{ name: '@tokenHash', value: tokenHash },
|
||||
];
|
||||
const { resources } = await container.items
|
||||
.query<TenantInviteDoc>({ query, parameters })
|
||||
.fetchAll();
|
||||
return resources[0] ?? null;
|
||||
}
|
||||
|
||||
export async function listPendingInvites(tenantId: string): Promise<TenantInviteDoc[]> {
|
||||
const container = getContainer('tenant_invites');
|
||||
const query =
|
||||
'SELECT * FROM c WHERE c.tenantId = @tenantId AND c.acceptedAt = null ORDER BY c.createdAt DESC';
|
||||
const parameters = [{ name: '@tenantId', value: tenantId }];
|
||||
const { resources } = await container.items
|
||||
.query<TenantInviteDoc>({ query, parameters })
|
||||
.fetchAll();
|
||||
return resources;
|
||||
}
|
||||
|
||||
export async function markInviteAccepted(
|
||||
inviteId: string,
|
||||
tenantId: string
|
||||
): Promise<TenantInviteDoc | null> {
|
||||
const container = getContainer('tenant_invites');
|
||||
try {
|
||||
const { resource: existing } = await container.item(inviteId, tenantId).read();
|
||||
if (!existing) return null;
|
||||
const doc = existing as unknown as TenantInviteDoc;
|
||||
const updated = { ...doc, acceptedAt: new Date().toISOString() };
|
||||
const { resource } = await container.items.upsert(updated);
|
||||
return resource as unknown as TenantInviteDoc;
|
||||
} catch (err) {
|
||||
if ((err as { code?: number }).code === 404) return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
286
services/platform-service/src/modules/tenants/routes.ts
Normal file
286
services/platform-service/src/modules/tenants/routes.ts
Normal file
@ -0,0 +1,286 @@
|
||||
/**
|
||||
* Multi-Tenant routes — tenant CRUD, member management, invites.
|
||||
* @module tenants/routes
|
||||
*/
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import {
|
||||
UnauthorizedError,
|
||||
ForbiddenError,
|
||||
NotFoundError,
|
||||
BadRequestError,
|
||||
ConflictError,
|
||||
} from '../../lib/errors.js';
|
||||
import { getRequestProductId } from '../../lib/request-context.js';
|
||||
import {
|
||||
CreateTenantSchema,
|
||||
UpdateTenantSchema,
|
||||
InviteMemberSchema,
|
||||
UpdateMemberRoleSchema,
|
||||
PLAN_LIMITS,
|
||||
type TenantDoc,
|
||||
type TenantStatus,
|
||||
} from './types.js';
|
||||
import * as repo from './repository.js';
|
||||
|
||||
function requireAuth(req: { jwtPayload?: { sub: string } }): string {
|
||||
if (!req.jwtPayload?.sub) throw new UnauthorizedError('Authentication required');
|
||||
return req.jwtPayload.sub;
|
||||
}
|
||||
|
||||
function requireAdmin(req: { jwtPayload?: { sub: string; role?: string } }): string {
|
||||
const userId = requireAuth(req);
|
||||
if (req.jwtPayload?.role !== 'admin') throw new ForbiddenError('Admin access required');
|
||||
return userId;
|
||||
}
|
||||
|
||||
export async function tenantRoutes(app: FastifyInstance): Promise<void> {
|
||||
// ── Create tenant ──────────────────────────────────────────
|
||||
app.post('/tenants', async (req, reply) => {
|
||||
const userId = requireAuth(req);
|
||||
const productId = getRequestProductId(req);
|
||||
const input = CreateTenantSchema.parse(req.body);
|
||||
|
||||
// Check slug uniqueness
|
||||
const existing = await repo.getTenantBySlug(productId, input.slug);
|
||||
if (existing) {
|
||||
throw new ConflictError(`Tenant slug "${input.slug}" is already taken`);
|
||||
}
|
||||
|
||||
const limits = PLAN_LIMITS[input.plan];
|
||||
const now = new Date().toISOString();
|
||||
const doc: TenantDoc = {
|
||||
id: `ten_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
|
||||
productId,
|
||||
name: input.name,
|
||||
slug: input.slug,
|
||||
status: 'active',
|
||||
plan: input.plan,
|
||||
ownerId: userId,
|
||||
customDomain: input.customDomain ?? null,
|
||||
settings: {},
|
||||
maxMembers: limits.maxMembers,
|
||||
maxStorageBytes: limits.maxStorageBytes,
|
||||
storageUsedBytes: 0,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
const created = await repo.createTenant(doc);
|
||||
|
||||
// Auto-add creator as owner member
|
||||
await repo.addMember({
|
||||
id: `mem_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
|
||||
tenantId: created.id,
|
||||
productId,
|
||||
userId,
|
||||
role: 'owner',
|
||||
displayName: req.jwtPayload?.email ?? userId,
|
||||
invitedBy: userId,
|
||||
joinedAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
req.log.info({ tenantId: created.id, userId, slug: input.slug }, 'Tenant created');
|
||||
reply.status(201);
|
||||
return created;
|
||||
});
|
||||
|
||||
// ── List tenants ───────────────────────────────────────────
|
||||
app.get('/tenants', async req => {
|
||||
requireAdmin(req);
|
||||
const productId = getRequestProductId(req);
|
||||
const {
|
||||
status,
|
||||
ownerId,
|
||||
limit: limitStr,
|
||||
} = req.query as {
|
||||
status?: string;
|
||||
ownerId?: string;
|
||||
limit?: string;
|
||||
};
|
||||
const parsedLimit = limitStr ? parseInt(limitStr, 10) : 50;
|
||||
const safeLimit =
|
||||
Number.isFinite(parsedLimit) && parsedLimit > 0 ? Math.min(parsedLimit, 200) : 50;
|
||||
|
||||
return repo.listTenants(productId, {
|
||||
status: status as TenantStatus | undefined,
|
||||
ownerId,
|
||||
limit: safeLimit,
|
||||
});
|
||||
});
|
||||
|
||||
// ── Get tenant ─────────────────────────────────────────────
|
||||
app.get<{ Params: { id: string } }>('/tenants/:id', async req => {
|
||||
requireAuth(req);
|
||||
const productId = getRequestProductId(req);
|
||||
const tenant = await repo.getTenant(req.params.id, productId);
|
||||
if (!tenant) throw new NotFoundError('Tenant not found');
|
||||
return tenant;
|
||||
});
|
||||
|
||||
// ── Update tenant ──────────────────────────────────────────
|
||||
app.patch<{ Params: { id: string } }>('/tenants/:id', async req => {
|
||||
requireAdmin(req);
|
||||
const productId = getRequestProductId(req);
|
||||
const input = UpdateTenantSchema.parse(req.body);
|
||||
const updated = await repo.updateTenant(req.params.id, productId, input);
|
||||
if (!updated) throw new NotFoundError('Tenant not found');
|
||||
req.log.info({ tenantId: req.params.id }, 'Tenant updated');
|
||||
return updated;
|
||||
});
|
||||
|
||||
// ── Delete tenant ──────────────────────────────────────────
|
||||
app.delete<{ Params: { id: string } }>('/tenants/:id', async (req, reply) => {
|
||||
requireAdmin(req);
|
||||
const productId = getRequestProductId(req);
|
||||
const deleted = await repo.deleteTenant(req.params.id, productId);
|
||||
if (!deleted) throw new NotFoundError('Tenant not found');
|
||||
req.log.info({ tenantId: req.params.id }, 'Tenant deleted');
|
||||
reply.status(204);
|
||||
return;
|
||||
});
|
||||
|
||||
// ── List members ───────────────────────────────────────────
|
||||
app.get<{ Params: { tenantId: string } }>('/tenants/:tenantId/members', async req => {
|
||||
requireAuth(req);
|
||||
const { tenantId } = req.params;
|
||||
const { limit: limitStr } = req.query as { limit?: string };
|
||||
const parsedLimit = limitStr ? parseInt(limitStr, 10) : 50;
|
||||
const safeLimit =
|
||||
Number.isFinite(parsedLimit) && parsedLimit > 0 ? Math.min(parsedLimit, 200) : 50;
|
||||
return repo.listMembers(tenantId, { limit: safeLimit });
|
||||
});
|
||||
|
||||
// ── Invite member ──────────────────────────────────────────
|
||||
app.post<{ Params: { tenantId: string } }>('/tenants/:tenantId/invite', async (req, reply) => {
|
||||
const userId = requireAuth(req);
|
||||
const productId = getRequestProductId(req);
|
||||
const { tenantId } = req.params;
|
||||
const input = InviteMemberSchema.parse(req.body);
|
||||
|
||||
const tenant = await repo.getTenant(tenantId, productId);
|
||||
if (!tenant) throw new NotFoundError('Tenant not found');
|
||||
|
||||
// Check member limit
|
||||
const memberCount = await repo.getMemberCount(tenantId);
|
||||
if (memberCount >= tenant.maxMembers) {
|
||||
throw new BadRequestError(`Tenant has reached the maximum of ${tenant.maxMembers} members`);
|
||||
}
|
||||
|
||||
// Generate invite token
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setDate(expiresAt.getDate() + 7); // 7-day expiry
|
||||
|
||||
const invite = await repo.createInvite({
|
||||
id: `inv_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
|
||||
tenantId,
|
||||
productId,
|
||||
email: input.email,
|
||||
role: input.role,
|
||||
invitedBy: userId,
|
||||
tokenHash,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
acceptedAt: null,
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
req.log.info({ tenantId, email: input.email, inviteId: invite.id }, 'Member invited');
|
||||
reply.status(201);
|
||||
// Return token in response (would normally be emailed)
|
||||
return { invite, token };
|
||||
});
|
||||
|
||||
// ── List pending invites ───────────────────────────────────
|
||||
app.get<{ Params: { tenantId: string } }>('/tenants/:tenantId/invites', async req => {
|
||||
requireAuth(req);
|
||||
const { tenantId } = req.params;
|
||||
return { invites: await repo.listPendingInvites(tenantId) };
|
||||
});
|
||||
|
||||
// ── Accept invite ──────────────────────────────────────────
|
||||
app.post<{ Params: { tenantId: string } }>(
|
||||
'/tenants/:tenantId/accept-invite',
|
||||
async (req, reply) => {
|
||||
const userId = requireAuth(req);
|
||||
const productId = getRequestProductId(req);
|
||||
const { tenantId } = req.params;
|
||||
const { token } = req.body as { token?: string };
|
||||
|
||||
if (!token) throw new BadRequestError('Invite token is required');
|
||||
|
||||
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
|
||||
const invite = await repo.getInviteByTokenHash(tokenHash, tenantId);
|
||||
if (!invite) throw new NotFoundError('Invalid or expired invite');
|
||||
|
||||
if (new Date(invite.expiresAt) < new Date()) {
|
||||
throw new BadRequestError('Invite has expired');
|
||||
}
|
||||
|
||||
// Check not already a member
|
||||
const existingMember = await repo.getMember(tenantId, userId);
|
||||
if (existingMember) {
|
||||
throw new ConflictError('Already a member of this tenant');
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Add member
|
||||
const member = await repo.addMember({
|
||||
id: `mem_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
|
||||
tenantId,
|
||||
productId,
|
||||
userId,
|
||||
role: invite.role,
|
||||
displayName: req.jwtPayload?.email ?? userId,
|
||||
invitedBy: invite.invitedBy,
|
||||
joinedAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
// Mark invite as accepted
|
||||
await repo.markInviteAccepted(invite.id, tenantId);
|
||||
|
||||
req.log.info({ tenantId, userId, role: invite.role }, 'Invite accepted');
|
||||
reply.status(201);
|
||||
return member;
|
||||
}
|
||||
);
|
||||
|
||||
// ── Update member role ─────────────────────────────────────
|
||||
app.patch<{ Params: { tenantId: string; userId: string } }>(
|
||||
'/tenants/:tenantId/members/:userId',
|
||||
async req => {
|
||||
requireAdmin(req);
|
||||
const { tenantId, userId } = req.params;
|
||||
const input = UpdateMemberRoleSchema.parse(req.body);
|
||||
|
||||
const updated = await repo.updateMemberRole(tenantId, userId, input.role);
|
||||
if (!updated) throw new NotFoundError('Member not found');
|
||||
|
||||
req.log.info({ tenantId, userId, role: input.role }, 'Member role updated');
|
||||
return updated;
|
||||
}
|
||||
);
|
||||
|
||||
// ── Remove member ──────────────────────────────────────────
|
||||
app.delete<{ Params: { tenantId: string; memberId: string } }>(
|
||||
'/tenants/:tenantId/members/:memberId',
|
||||
async (req, reply) => {
|
||||
requireAdmin(req);
|
||||
const { tenantId, memberId } = req.params;
|
||||
|
||||
const removed = await repo.removeMember(memberId, tenantId);
|
||||
if (!removed) throw new NotFoundError('Member not found');
|
||||
|
||||
req.log.info({ tenantId, memberId }, 'Member removed');
|
||||
reply.status(204);
|
||||
return;
|
||||
}
|
||||
);
|
||||
}
|
||||
144
services/platform-service/src/modules/tenants/tenants.test.ts
Normal file
144
services/platform-service/src/modules/tenants/tenants.test.ts
Normal file
@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Multi-Tenant module — unit tests.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
CreateTenantSchema,
|
||||
UpdateTenantSchema,
|
||||
InviteMemberSchema,
|
||||
UpdateMemberRoleSchema,
|
||||
PLAN_LIMITS,
|
||||
} from './types.js';
|
||||
|
||||
describe('CreateTenantSchema', () => {
|
||||
it('validates minimal tenant', () => {
|
||||
const result = CreateTenantSchema.safeParse({
|
||||
name: 'Acme Corp',
|
||||
slug: 'acme-corp',
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.plan).toBe('free');
|
||||
}
|
||||
});
|
||||
|
||||
it('validates with all fields', () => {
|
||||
const result = CreateTenantSchema.safeParse({
|
||||
name: 'Enterprise Inc',
|
||||
slug: 'enterprise-inc',
|
||||
plan: 'enterprise',
|
||||
customDomain: 'app.enterprise.com',
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects invalid slug', () => {
|
||||
expect(CreateTenantSchema.safeParse({ name: 'Test', slug: 'Invalid Slug!' }).success).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects short name', () => {
|
||||
expect(CreateTenantSchema.safeParse({ name: 'A', slug: 'aa' }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects invalid plan', () => {
|
||||
expect(
|
||||
CreateTenantSchema.safeParse({ name: 'Test', slug: 'test', plan: 'ultra' }).success
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('UpdateTenantSchema', () => {
|
||||
it('validates partial update', () => {
|
||||
const result = UpdateTenantSchema.safeParse({
|
||||
name: 'New Name',
|
||||
status: 'suspended',
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('validates settings update', () => {
|
||||
const result = UpdateTenantSchema.safeParse({
|
||||
settings: { feature_x: true, max_items: 100 },
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects invalid status', () => {
|
||||
expect(UpdateTenantSchema.safeParse({ status: 'deleted' }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('validates maxMembers', () => {
|
||||
const result = UpdateTenantSchema.safeParse({ maxMembers: 500 });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects negative maxMembers', () => {
|
||||
expect(UpdateTenantSchema.safeParse({ maxMembers: 0 }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('InviteMemberSchema', () => {
|
||||
it('validates minimal invite', () => {
|
||||
const result = InviteMemberSchema.safeParse({ email: 'user@example.com' });
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.role).toBe('member');
|
||||
}
|
||||
});
|
||||
|
||||
it('validates with role and displayName', () => {
|
||||
const result = InviteMemberSchema.safeParse({
|
||||
email: 'admin@example.com',
|
||||
role: 'admin',
|
||||
displayName: 'Jane Admin',
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects invalid email', () => {
|
||||
expect(InviteMemberSchema.safeParse({ email: 'not-an-email' }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects owner role (cannot invite as owner)', () => {
|
||||
expect(InviteMemberSchema.safeParse({ email: 'test@example.com', role: 'owner' }).success).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('UpdateMemberRoleSchema', () => {
|
||||
it('validates role change', () => {
|
||||
expect(UpdateMemberRoleSchema.safeParse({ role: 'admin' }).success).toBe(true);
|
||||
expect(UpdateMemberRoleSchema.safeParse({ role: 'member' }).success).toBe(true);
|
||||
expect(UpdateMemberRoleSchema.safeParse({ role: 'viewer' }).success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects owner role (cannot be assigned)', () => {
|
||||
expect(UpdateMemberRoleSchema.safeParse({ role: 'owner' }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects invalid role', () => {
|
||||
expect(UpdateMemberRoleSchema.safeParse({ role: 'superadmin' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PLAN_LIMITS', () => {
|
||||
it('has correct free plan limits', () => {
|
||||
expect(PLAN_LIMITS.free.maxMembers).toBe(5);
|
||||
expect(PLAN_LIMITS.free.maxStorageBytes).toBe(1 * 1024 * 1024 * 1024);
|
||||
});
|
||||
|
||||
it('enterprise has highest limits', () => {
|
||||
expect(PLAN_LIMITS.enterprise.maxMembers).toBe(10000);
|
||||
expect(PLAN_LIMITS.enterprise.maxStorageBytes).toBeGreaterThan(PLAN_LIMITS.pro.maxStorageBytes);
|
||||
});
|
||||
|
||||
it('plans scale correctly', () => {
|
||||
expect(PLAN_LIMITS.starter.maxMembers).toBeGreaterThan(PLAN_LIMITS.free.maxMembers);
|
||||
expect(PLAN_LIMITS.pro.maxMembers).toBeGreaterThan(PLAN_LIMITS.starter.maxMembers);
|
||||
expect(PLAN_LIMITS.enterprise.maxMembers).toBeGreaterThan(PLAN_LIMITS.pro.maxMembers);
|
||||
});
|
||||
});
|
||||
110
services/platform-service/src/modules/tenants/types.ts
Normal file
110
services/platform-service/src/modules/tenants/types.ts
Normal file
@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Multi-Tenant module — types and schemas.
|
||||
* Manages tenant isolation, tenant-level settings, member management,
|
||||
* and cross-tenant resource sharing.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
// ── Tenant Types ──────────────────────────────────────────────────
|
||||
|
||||
export type TenantStatus = 'active' | 'suspended' | 'deactivated';
|
||||
export type TenantPlan = 'free' | 'starter' | 'pro' | 'enterprise';
|
||||
export type MemberRole = 'owner' | 'admin' | 'member' | 'viewer';
|
||||
|
||||
export interface TenantDoc {
|
||||
id: string;
|
||||
productId: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
status: TenantStatus;
|
||||
plan: TenantPlan;
|
||||
/** Owner userId */
|
||||
ownerId: string;
|
||||
/** Custom domain for white-label */
|
||||
customDomain: string | null;
|
||||
/** Tenant-level settings overrides */
|
||||
settings: Record<string, unknown>;
|
||||
/** Max members allowed by plan */
|
||||
maxMembers: number;
|
||||
/** Max storage in bytes */
|
||||
maxStorageBytes: number;
|
||||
/** Current storage used in bytes */
|
||||
storageUsedBytes: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface TenantMemberDoc {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
productId: string;
|
||||
userId: string;
|
||||
role: MemberRole;
|
||||
/** Display name within the tenant */
|
||||
displayName: string;
|
||||
invitedBy: string;
|
||||
joinedAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface TenantInviteDoc {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
productId: string;
|
||||
email: string;
|
||||
role: MemberRole;
|
||||
invitedBy: string;
|
||||
/** SHA-256 hash of invite token */
|
||||
tokenHash: string;
|
||||
expiresAt: string;
|
||||
acceptedAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// ── Schemas ──────────────────────────────────────────────────────
|
||||
|
||||
export const CreateTenantSchema = z.object({
|
||||
name: z.string().min(2).max(100),
|
||||
slug: z
|
||||
.string()
|
||||
.min(2)
|
||||
.max(64)
|
||||
.regex(/^[a-z0-9-]+$/, 'Slug must be lowercase alphanumeric with hyphens'),
|
||||
plan: z.enum(['free', 'starter', 'pro', 'enterprise']).default('free'),
|
||||
customDomain: z.string().max(253).nullable().optional(),
|
||||
});
|
||||
|
||||
export const UpdateTenantSchema = z.object({
|
||||
name: z.string().min(2).max(100).optional(),
|
||||
status: z.enum(['active', 'suspended', 'deactivated']).optional(),
|
||||
plan: z.enum(['free', 'starter', 'pro', 'enterprise']).optional(),
|
||||
customDomain: z.string().max(253).nullable().optional(),
|
||||
settings: z.record(z.unknown()).optional(),
|
||||
maxMembers: z.number().int().min(1).max(10000).optional(),
|
||||
maxStorageBytes: z.number().int().min(0).optional(),
|
||||
});
|
||||
|
||||
export const InviteMemberSchema = z.object({
|
||||
email: z.string().email(),
|
||||
role: z.enum(['admin', 'member', 'viewer']).default('member'),
|
||||
displayName: z.string().min(1).max(100).optional(),
|
||||
});
|
||||
|
||||
export const UpdateMemberRoleSchema = z.object({
|
||||
role: z.enum(['admin', 'member', 'viewer']),
|
||||
});
|
||||
|
||||
export type CreateTenantInput = z.infer<typeof CreateTenantSchema>;
|
||||
export type UpdateTenantInput = z.infer<typeof UpdateTenantSchema>;
|
||||
export type InviteMemberInput = z.infer<typeof InviteMemberSchema>;
|
||||
export type UpdateMemberRoleInput = z.infer<typeof UpdateMemberRoleSchema>;
|
||||
|
||||
// ── Plan Limits ──────────────────────────────────────────────────
|
||||
|
||||
export const PLAN_LIMITS: Record<TenantPlan, { maxMembers: number; maxStorageBytes: number }> = {
|
||||
free: { maxMembers: 5, maxStorageBytes: 1 * 1024 * 1024 * 1024 }, // 1 GB
|
||||
starter: { maxMembers: 25, maxStorageBytes: 10 * 1024 * 1024 * 1024 }, // 10 GB
|
||||
pro: { maxMembers: 100, maxStorageBytes: 100 * 1024 * 1024 * 1024 }, // 100 GB
|
||||
enterprise: { maxMembers: 10000, maxStorageBytes: 1024 * 1024 * 1024 * 1024 }, // 1 TB
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user