feat(jarvis-teams): enterprise teams module — team CRUD, member management, shared agents, analytics (23 tests)

This commit is contained in:
saravanakumardb1 2026-03-01 16:48:26 -08:00
parent d0cb3a2238
commit 66d6aa7b5b
3 changed files with 572 additions and 0 deletions

View File

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

View File

@ -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<string, Team>();
const members = new Map<string, TeamMember>();
// ── 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<string>();
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<Pick<Team, 'name' | 'settings'>>
): 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();
}

View File

@ -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<typeof TeamSchema>;
// ── 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<typeof TeamMemberSchema>;
// ── 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<typeof TeamAnalyticsSchema>;
// ── 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(),
});