diff --git a/services/platform-service/src/modules/tenants/repository.ts b/services/platform-service/src/modules/tenants/repository.ts new file mode 100644 index 00000000..4dbc3084 --- /dev/null +++ b/services/platform-service/src/modules/tenants/repository.ts @@ -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 { + 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 { + 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 { + 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({ query, parameters }).fetchAll(); + return resources[0] ?? null; +} + +export async function updateTenant( + id: string, + productId: string, + updates: Partial +): Promise { + 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({ 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({ query, parameters }).fetchAll(); + + return { tenants: resources, total }; +} + +export async function deleteTenant(id: string, productId: string): Promise { + 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 { + 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 { + 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({ 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({ 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({ query, parameters }) + .fetchAll(); + + return { members: resources, total }; +} + +export async function updateMemberRole( + tenantId: string, + userId: string, + role: MemberRole +): Promise { + 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 { + 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 { + 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({ query, parameters }).fetchAll(); + return resources[0] ?? 0; +} + +// ============================================================================= +// Tenant Invites +// ============================================================================= + +export async function createInvite(doc: TenantInviteDoc): Promise { + 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 { + 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({ query, parameters }) + .fetchAll(); + return resources[0] ?? null; +} + +export async function listPendingInvites(tenantId: string): Promise { + 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({ query, parameters }) + .fetchAll(); + return resources; +} + +export async function markInviteAccepted( + inviteId: string, + tenantId: string +): Promise { + 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; + } +} diff --git a/services/platform-service/src/modules/tenants/routes.ts b/services/platform-service/src/modules/tenants/routes.ts new file mode 100644 index 00000000..310fc2a0 --- /dev/null +++ b/services/platform-service/src/modules/tenants/routes.ts @@ -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 { + // ── 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; + } + ); +} diff --git a/services/platform-service/src/modules/tenants/tenants.test.ts b/services/platform-service/src/modules/tenants/tenants.test.ts new file mode 100644 index 00000000..b8d92475 --- /dev/null +++ b/services/platform-service/src/modules/tenants/tenants.test.ts @@ -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); + }); +}); diff --git a/services/platform-service/src/modules/tenants/types.ts b/services/platform-service/src/modules/tenants/types.ts new file mode 100644 index 00000000..f08103ca --- /dev/null +++ b/services/platform-service/src/modules/tenants/types.ts @@ -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; + /** 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; +export type UpdateTenantInput = z.infer; +export type InviteMemberInput = z.infer; +export type UpdateMemberRoleInput = z.infer; + +// ── Plan Limits ────────────────────────────────────────────────── + +export const PLAN_LIMITS: Record = { + 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 +};