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:
saravanakumardb1 2026-03-19 23:49:41 -07:00
parent 797f5e4318
commit 33e5fd70ce
4 changed files with 805 additions and 0 deletions

View 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;
}
}

View 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;
}
);
}

View 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);
});
});

View 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
};