diff --git a/services/platform-service/src/modules/jarvis-teams/jarvis-teams.test.ts b/services/platform-service/src/modules/jarvis-teams/jarvis-teams.test.ts new file mode 100644 index 00000000..c95bb260 --- /dev/null +++ b/services/platform-service/src/modules/jarvis-teams/jarvis-teams.test.ts @@ -0,0 +1,242 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { + createTeam, + getTeam, + getTeamsForUser, + updateTeam, + deleteTeam, + addMember, + getTeamMembers, + getMember, + updateMemberRole, + acceptInvite, + removeMember, + shareAgent, + unshareAgent, + getTeamAnalytics, + resetStores, +} from './repository.js'; + +beforeEach(() => { + resetStores(); +}); + +// ── Team CRUD ─────────────────────────────────────────────── + +describe('createTeam', () => { + it('creates a team with unique id', () => { + const team = createTeam({ name: 'Acme Corp', ownerId: 'user_1' }); + expect(team.id).toMatch(/^team_/); + expect(team.name).toBe('Acme Corp'); + expect(team.ownerId).toBe('user_1'); + expect(team.productId).toBe('jarvisjr'); + }); + + it('defaults to starter plan with 5 members', () => { + const team = createTeam({ name: 'Small Team', ownerId: 'user_1' }); + expect(team.plan).toBe('starter'); + expect(team.maxMembers).toBe(5); + }); + + it('business plan allows 25 members', () => { + const team = createTeam({ name: 'Biz', ownerId: 'user_1', plan: 'business' }); + expect(team.maxMembers).toBe(25); + }); + + it('enterprise plan allows 100 members and enables SSO', () => { + const team = createTeam({ name: 'Big Co', ownerId: 'user_1', plan: 'enterprise' }); + expect(team.maxMembers).toBe(100); + expect(team.settings.ssoEnabled).toBe(true); + }); + + it('auto-adds owner as active member', () => { + const team = createTeam({ name: 'Test', ownerId: 'user_1' }); + const members = getTeamMembers(team.teamId); + expect(members).toHaveLength(1); + expect(members[0].role).toBe('owner'); + expect(members[0].status).toBe('active'); + }); +}); + +describe('getTeam', () => { + it('returns team by id', () => { + const team = createTeam({ name: 'Test', ownerId: 'user_1' }); + expect(getTeam(team.teamId)?.name).toBe('Test'); + }); + + it('returns undefined for non-existent team', () => { + expect(getTeam('non_existent')).toBeUndefined(); + }); +}); + +describe('getTeamsForUser', () => { + it('returns teams where user is active member', () => { + createTeam({ name: 'Team A', ownerId: 'user_1' }); + createTeam({ name: 'Team B', ownerId: 'user_2' }); + const teams = getTeamsForUser('user_1'); + expect(teams).toHaveLength(1); + expect(teams[0].name).toBe('Team A'); + }); +}); + +describe('updateTeam', () => { + it('updates team name', () => { + const team = createTeam({ name: 'Old', ownerId: 'user_1' }); + const updated = updateTeam(team.teamId, { name: 'New' }); + expect(updated?.name).toBe('New'); + }); + + it('returns undefined for non-existent team', () => { + expect(updateTeam('nope', { name: 'X' })).toBeUndefined(); + }); +}); + +describe('deleteTeam', () => { + it('deletes team and its members', () => { + const team = createTeam({ name: 'Test', ownerId: 'user_1' }); + expect(deleteTeam(team.teamId)).toBe(true); + expect(getTeam(team.teamId)).toBeUndefined(); + expect(getTeamMembers(team.teamId)).toHaveLength(0); + }); + + it('returns false for non-existent team', () => { + expect(deleteTeam('nope')).toBe(false); + }); +}); + +// ── Member Management ─────────────────────────────────────── + +describe('addMember', () => { + it('adds invited member', () => { + const team = createTeam({ name: 'Test', ownerId: 'user_1' }); + const member = addMember({ + teamId: team.teamId, + userId: 'user_2', + email: 'alice@acme.com', + displayName: 'Alice', + role: 'member', + invitedBy: 'user_1', + }); + expect(member.status).toBe('invited'); + expect(member.role).toBe('member'); + }); +}); + +describe('acceptInvite', () => { + it('activates invited member', () => { + const team = createTeam({ name: 'Test', ownerId: 'user_1' }); + const member = addMember({ + teamId: team.teamId, + userId: 'user_2', + email: 'bob@acme.com', + displayName: 'Bob', + role: 'member', + invitedBy: 'user_1', + }); + const accepted = acceptInvite(member.id); + expect(accepted?.status).toBe('active'); + expect(accepted?.joinedAt).toBeTruthy(); + }); + + it('returns undefined for already active member', () => { + const team = createTeam({ name: 'Test', ownerId: 'user_1' }); + const members = getTeamMembers(team.teamId); + expect(acceptInvite(members[0].id)).toBeUndefined(); // owner is already active + }); +}); + +describe('updateMemberRole', () => { + it('promotes member to manager', () => { + const team = createTeam({ name: 'Test', ownerId: 'user_1' }); + const member = addMember({ + teamId: team.teamId, + userId: 'user_2', + email: 'a@b.com', + displayName: 'A', + role: 'member', + invitedBy: 'user_1', + }); + const updated = updateMemberRole(member.id, 'manager'); + expect(updated?.role).toBe('manager'); + }); + + it('cannot change owner role', () => { + const team = createTeam({ name: 'Test', ownerId: 'user_1' }); + const owner = getTeamMembers(team.teamId)[0]; + expect(updateMemberRole(owner.id, 'member')).toBeUndefined(); + }); +}); + +describe('removeMember', () => { + it('removes non-owner member', () => { + const team = createTeam({ name: 'Test', ownerId: 'user_1' }); + const member = addMember({ + teamId: team.teamId, + userId: 'user_2', + email: 'a@b.com', + displayName: 'A', + role: 'member', + invitedBy: 'user_1', + }); + expect(removeMember(member.id)).toBe(true); + expect(getMember(member.id)).toBeUndefined(); + }); + + it('cannot remove owner', () => { + const team = createTeam({ name: 'Test', ownerId: 'user_1' }); + const owner = getTeamMembers(team.teamId)[0]; + expect(removeMember(owner.id)).toBe(false); + }); +}); + +// ── Shared Agents ─────────────────────────────────────────── + +describe('shareAgent', () => { + it('adds agent to shared list', () => { + const team = createTeam({ name: 'Test', ownerId: 'user_1' }); + const updated = shareAgent(team.teamId, 'agent_123'); + expect(updated?.sharedAgentIds).toContain('agent_123'); + }); + + it('does not duplicate agent', () => { + const team = createTeam({ name: 'Test', ownerId: 'user_1' }); + shareAgent(team.teamId, 'agent_123'); + const updated = shareAgent(team.teamId, 'agent_123'); + expect(updated?.sharedAgentIds.filter(id => id === 'agent_123')).toHaveLength(1); + }); +}); + +describe('unshareAgent', () => { + it('removes agent from shared list', () => { + const team = createTeam({ name: 'Test', ownerId: 'user_1' }); + shareAgent(team.teamId, 'agent_123'); + const updated = unshareAgent(team.teamId, 'agent_123'); + expect(updated?.sharedAgentIds).not.toContain('agent_123'); + }); +}); + +// ── Analytics ─────────────────────────────────────────────── + +describe('getTeamAnalytics', () => { + it('returns analytics stub for team', () => { + const team = createTeam({ name: 'Test', ownerId: 'user_1' }); + addMember({ + teamId: team.teamId, + userId: 'user_2', + email: 'a@b.com', + displayName: 'A', + role: 'member', + invitedBy: 'user_1', + }); + // Accept the invite so they count as active + const members = getTeamMembers(team.teamId); + const invited = members.find(m => m.status === 'invited'); + if (invited) acceptInvite(invited.id); + + const analytics = getTeamAnalytics(team.teamId, '2026-03'); + expect(analytics.teamId).toBe(team.teamId); + expect(analytics.period).toBe('2026-03'); + expect(analytics.activeMembers).toBe(2); + expect(analytics.memberBreakdown).toHaveLength(2); + }); +}); diff --git a/services/platform-service/src/modules/jarvis-teams/repository.ts b/services/platform-service/src/modules/jarvis-teams/repository.ts new file mode 100644 index 00000000..1fdb1a7d --- /dev/null +++ b/services/platform-service/src/modules/jarvis-teams/repository.ts @@ -0,0 +1,222 @@ +/** + * JarvisJr Teams — repository for team CRUD and member management. + * In-memory store for now; will migrate to Cosmos containers. + */ + +import crypto from 'node:crypto'; +import type { Team, TeamMember, TeamAnalytics } from './types.js'; + +// ── In-Memory Stores ──────────────────────────────────────── + +const teams = new Map(); +const members = new Map(); + +// ── Team CRUD ─────────────────────────────────────────────── + +export function createTeam(input: { + name: string; + ownerId: string; + plan?: 'starter' | 'business' | 'enterprise'; +}): Team { + const now = new Date().toISOString(); + const teamId = `team_${crypto.randomUUID()}`; + + const maxMembers = input.plan === 'enterprise' ? 100 : input.plan === 'business' ? 25 : 5; + + const team: Team = { + id: teamId, + productId: 'jarvisjr', + teamId, + name: input.name, + ownerId: input.ownerId, + plan: input.plan ?? 'starter', + maxMembers, + sharedAgentIds: [], + settings: { + requireApprovalForAgents: false, + analyticsVisible: 'managers', + ssoEnabled: input.plan === 'enterprise', + }, + createdAt: now, + updatedAt: now, + }; + + teams.set(teamId, team); + + // Auto-add owner as member + addMember({ + teamId, + userId: input.ownerId, + email: `${input.ownerId}@team.local`, + displayName: 'Team Owner', + role: 'owner', + invitedBy: input.ownerId, + }); + + return team; +} + +export function getTeam(teamId: string): Team | undefined { + return teams.get(teamId); +} + +export function getTeamsForUser(userId: string): Team[] { + const memberTeamIds = new Set(); + for (const m of members.values()) { + if (m.userId === userId && m.status === 'active') { + memberTeamIds.add(m.teamId); + } + } + return Array.from(teams.values()).filter(t => memberTeamIds.has(t.teamId)); +} + +export function updateTeam( + teamId: string, + updates: Partial> +): Team | undefined { + const team = teams.get(teamId); + if (!team) return undefined; + + const updated: Team = { + ...team, + ...updates, + settings: updates.settings ? { ...team.settings, ...updates.settings } : team.settings, + updatedAt: new Date().toISOString(), + }; + teams.set(teamId, updated); + return updated; +} + +export function deleteTeam(teamId: string): boolean { + // Remove all members + for (const [id, m] of members.entries()) { + if (m.teamId === teamId) members.delete(id); + } + return teams.delete(teamId); +} + +// ── Member Management ─────────────────────────────────────── + +export function addMember(input: { + teamId: string; + userId: string; + email: string; + displayName: string; + role: 'owner' | 'manager' | 'member'; + invitedBy: string; +}): TeamMember { + const now = new Date().toISOString(); + const id = `member_${crypto.randomUUID()}`; + + const member: TeamMember = { + id, + productId: 'jarvisjr', + teamId: input.teamId, + userId: input.userId, + email: input.email, + displayName: input.displayName, + role: input.role, + status: input.role === 'owner' ? 'active' : 'invited', + joinedAt: input.role === 'owner' ? now : null, + invitedAt: now, + invitedBy: input.invitedBy, + }; + + members.set(id, member); + return member; +} + +export function getTeamMembers(teamId: string): TeamMember[] { + return Array.from(members.values()).filter(m => m.teamId === teamId); +} + +export function getMember(memberId: string): TeamMember | undefined { + return members.get(memberId); +} + +export function updateMemberRole( + memberId: string, + role: 'manager' | 'member' +): TeamMember | undefined { + const member = members.get(memberId); + if (!member || member.role === 'owner') return undefined; + const updated = { ...member, role }; + members.set(memberId, updated); + return updated; +} + +export function acceptInvite(memberId: string): TeamMember | undefined { + const member = members.get(memberId); + if (!member || member.status !== 'invited') return undefined; + const updated: TeamMember = { + ...member, + status: 'active', + joinedAt: new Date().toISOString(), + }; + members.set(memberId, updated); + return updated; +} + +export function removeMember(memberId: string): boolean { + const member = members.get(memberId); + if (!member || member.role === 'owner') return false; + return members.delete(memberId); +} + +// ── Shared Agents ─────────────────────────────────────────── + +export function shareAgent(teamId: string, agentId: string): Team | undefined { + const team = teams.get(teamId); + if (!team) return undefined; + if (team.sharedAgentIds.includes(agentId)) return team; + + const updated: Team = { + ...team, + sharedAgentIds: [...team.sharedAgentIds, agentId], + updatedAt: new Date().toISOString(), + }; + teams.set(teamId, updated); + return updated; +} + +export function unshareAgent(teamId: string, agentId: string): Team | undefined { + const team = teams.get(teamId); + if (!team) return undefined; + + const updated: Team = { + ...team, + sharedAgentIds: team.sharedAgentIds.filter(id => id !== agentId), + updatedAt: new Date().toISOString(), + }; + teams.set(teamId, updated); + return updated; +} + +// ── Analytics ─────────────────────────────────────────────── + +export function getTeamAnalytics(teamId: string, period: string): TeamAnalytics { + const teamMembers = getTeamMembers(teamId).filter(m => m.status === 'active'); + + // Stub: in production, query jarvis_sessions container + return { + teamId, + period, + totalSessions: 0, + totalMinutes: 0, + activeMembers: teamMembers.length, + topAgents: [], + memberBreakdown: teamMembers.map(m => ({ + userId: m.userId, + displayName: m.displayName, + sessions: 0, + minutes: 0, + })), + }; +} + +// ── Reset (for tests) ────────────────────────────────────── + +export function resetStores(): void { + teams.clear(); + members.clear(); +} diff --git a/services/platform-service/src/modules/jarvis-teams/types.ts b/services/platform-service/src/modules/jarvis-teams/types.ts new file mode 100644 index 00000000..b50922ee --- /dev/null +++ b/services/platform-service/src/modules/jarvis-teams/types.ts @@ -0,0 +1,108 @@ +/** + * JarvisJr Teams — types and Zod schemas for enterprise team management. + * Partition key: /teamId for team docs, /userId for member docs. + */ + +import { z } from 'zod'; + +// ── Team ──────────────────────────────────────────────────── + +export const TeamSchema = z.object({ + id: z.string(), + productId: z.string().default('jarvisjr'), + teamId: z.string(), + name: z.string().min(1).max(100), + ownerId: z.string(), + plan: z.enum(['starter', 'business', 'enterprise']).default('starter'), + maxMembers: z.number().int().min(1).default(5), + sharedAgentIds: z.array(z.string()).default([]), + settings: z + .object({ + requireApprovalForAgents: z.boolean().default(false), + analyticsVisible: z.enum(['owner', 'managers', 'all']).default('managers'), + ssoEnabled: z.boolean().default(false), + }) + .default({}), + createdAt: z.string(), + updatedAt: z.string(), +}); + +export type Team = z.infer; + +// ── Team Member ───────────────────────────────────────────── + +export const TeamMemberSchema = z.object({ + id: z.string(), + productId: z.string().default('jarvisjr'), + teamId: z.string(), + userId: z.string(), + email: z.string().email(), + displayName: z.string(), + role: z.enum(['owner', 'manager', 'member']), + status: z.enum(['active', 'invited', 'suspended']).default('active'), + joinedAt: z.string().nullable().default(null), + invitedAt: z.string(), + invitedBy: z.string(), +}); + +export type TeamMember = z.infer; + +// ── Team Analytics ────────────────────────────────────────── + +export const TeamAnalyticsSchema = z.object({ + teamId: z.string(), + period: z.string(), // e.g. "2026-03" + totalSessions: z.number().int().default(0), + totalMinutes: z.number().default(0), + activeMembers: z.number().int().default(0), + topAgents: z + .array( + z.object({ + agentName: z.string(), + sessions: z.number().int(), + }) + ) + .default([]), + memberBreakdown: z + .array( + z.object({ + userId: z.string(), + displayName: z.string(), + sessions: z.number().int(), + minutes: z.number(), + }) + ) + .default([]), +}); + +export type TeamAnalytics = z.infer; + +// ── Request/Response Schemas ──────────────────────────────── + +export const CreateTeamSchema = z.object({ + name: z.string().min(1).max(100), + plan: z.enum(['starter', 'business', 'enterprise']).optional(), +}); + +export const InviteMemberSchema = z.object({ + email: z.string().email(), + displayName: z.string().min(1), + role: z.enum(['manager', 'member']).default('member'), +}); + +export const UpdateMemberRoleSchema = z.object({ + role: z.enum(['manager', 'member']), +}); + +export const ShareAgentSchema = z.object({ + agentId: z.string(), +}); + +export const TeamIdParamSchema = z.object({ + teamId: z.string(), +}); + +export const MemberIdParamSchema = z.object({ + teamId: z.string(), + memberId: z.string(), +});