feat(jarvis-teams): enterprise teams module — team CRUD, member management, shared agents, analytics (23 tests)
This commit is contained in:
parent
d0cb3a2238
commit
66d6aa7b5b
@ -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);
|
||||
});
|
||||
});
|
||||
222
services/platform-service/src/modules/jarvis-teams/repository.ts
Normal file
222
services/platform-service/src/modules/jarvis-teams/repository.ts
Normal 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();
|
||||
}
|
||||
108
services/platform-service/src/modules/jarvis-teams/types.ts
Normal file
108
services/platform-service/src/modules/jarvis-teams/types.ts
Normal 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(),
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user