Skip to content

Commit f05f00b

Browse files
committed
Add team-based chat routing and auto-assignment
- Add Team and TeamMember models for organizing agents into teams - Implement assignment strategies: round-robin, load-balanced, manual queue - Add Teams API with CRUD endpoints and role-based access control - Add "Transfer" step type in conversation flows to route to specific teams - Update transfers view with team filtering and per-team queue counts - Create Teams management page under Settings - Agents can pick transfers from teams they belong to
1 parent e24c255 commit f05f00b

File tree

16 files changed

+2059
-86
lines changed

16 files changed

+2059
-86
lines changed

cmd/server/main.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,16 @@ func setupRoutes(g *fastglue.Fastglue, app *handlers.App, lo logf.Logger, basePa
409409
g.PUT("/api/chatbot/transfers/{id}/resume", app.ResumeFromTransfer)
410410
g.PUT("/api/chatbot/transfers/{id}/assign", app.AssignAgentTransfer)
411411

412+
// Teams (admin/manager - access control in handler)
413+
g.GET("/api/teams", app.ListTeams)
414+
g.POST("/api/teams", app.CreateTeam)
415+
g.GET("/api/teams/{id}", app.GetTeam)
416+
g.PUT("/api/teams/{id}", app.UpdateTeam)
417+
g.DELETE("/api/teams/{id}", app.DeleteTeam)
418+
g.GET("/api/teams/{id}/members", app.ListTeamMembers)
419+
g.POST("/api/teams/{id}/members", app.AddTeamMember)
420+
g.DELETE("/api/teams/{id}/members/{user_id}", app.RemoveTeamMember)
421+
412422
// Canned Responses
413423
g.GET("/api/canned-responses", app.ListCannedResponses)
414424
g.POST("/api/canned-responses", app.CreateCannedResponse)

frontend/src/components/layout/AppLayout.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,7 @@ const allNavItems = [
186186
{ name: 'General', path: '/settings', icon: Settings },
187187
{ name: 'Accounts', path: '/settings/accounts', icon: Users },
188188
{ name: 'Canned Responses', path: '/settings/canned-responses', icon: MessageSquareText },
189+
{ name: 'Teams', path: '/settings/teams', icon: Users },
189190
{ name: 'Users', path: '/settings/users', icon: Users, roles: ['admin'] },
190191
{ name: 'API Keys', path: '/settings/api-keys', icon: Key, roles: ['admin'] },
191192
{ name: 'Webhooks', path: '/settings/webhooks', icon: Webhook, roles: ['admin'] },

frontend/src/router/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,12 @@ const router = createRouter({
148148
component: () => import('@/views/settings/UsersView.vue'),
149149
meta: { roles: ['admin'] }
150150
},
151+
{
152+
path: 'settings/teams',
153+
name: 'teams',
154+
component: () => import('@/views/settings/TeamsView.vue'),
155+
meta: { roles: ['admin', 'manager'] }
156+
},
151157
{
152158
path: 'settings/api-keys',
153159
name: 'api-keys',

frontend/src/services/api.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,54 @@ export interface WebhookEvent {
303303
description: string
304304
}
305305

306+
export interface Team {
307+
id: string
308+
name: string
309+
description: string
310+
assignment_strategy: 'round_robin' | 'load_balanced' | 'manual'
311+
is_active: boolean
312+
member_count: number
313+
created_at: string
314+
updated_at: string
315+
}
316+
317+
export interface TeamMember {
318+
id: string
319+
team_id: string
320+
user_id: string
321+
role: 'manager' | 'agent'
322+
last_assigned_at: string | null
323+
user: {
324+
id: string
325+
full_name: string
326+
email: string
327+
is_available: boolean
328+
}
329+
}
330+
331+
export const teamsService = {
332+
list: () => api.get<{ teams: Team[] }>('/teams'),
333+
get: (id: string) => api.get<{ team: Team }>(`/teams/${id}`),
334+
create: (data: {
335+
name: string
336+
description?: string
337+
assignment_strategy?: 'round_robin' | 'load_balanced' | 'manual'
338+
}) => api.post<{ team: Team }>('/teams', data),
339+
update: (id: string, data: {
340+
name?: string
341+
description?: string
342+
assignment_strategy?: 'round_robin' | 'load_balanced' | 'manual'
343+
is_active?: boolean
344+
}) => api.put<{ team: Team }>(`/teams/${id}`, data),
345+
delete: (id: string) => api.delete(`/teams/${id}`),
346+
// Members
347+
listMembers: (teamId: string) => api.get<{ members: TeamMember[] }>(`/teams/${teamId}/members`),
348+
addMember: (teamId: string, data: { user_id: string; role?: 'manager' | 'agent' }) =>
349+
api.post<{ member: TeamMember }>(`/teams/${teamId}/members`, data),
350+
removeMember: (teamId: string, userId: string) =>
351+
api.delete(`/teams/${teamId}/members/${userId}`)
352+
}
353+
306354
export const webhooksService = {
307355
list: () => api.get<{ webhooks: Webhook[]; available_events: WebhookEvent[] }>('/webhooks'),
308356
get: (id: string) => api.get<Webhook>(`/webhooks/${id}`),

frontend/src/stores/teams.ts

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import { defineStore } from 'pinia'
2+
import { ref } from 'vue'
3+
import { teamsService, type Team, type TeamMember } from '@/services/api'
4+
5+
export interface CreateTeamData {
6+
name: string
7+
description?: string
8+
assignment_strategy?: 'round_robin' | 'load_balanced' | 'manual'
9+
}
10+
11+
export interface UpdateTeamData {
12+
name?: string
13+
description?: string
14+
assignment_strategy?: 'round_robin' | 'load_balanced' | 'manual'
15+
is_active?: boolean
16+
}
17+
18+
export const useTeamsStore = defineStore('teams', () => {
19+
const teams = ref<Team[]>([])
20+
const loading = ref(false)
21+
const error = ref<string | null>(null)
22+
23+
async function fetchTeams(): Promise<void> {
24+
loading.value = true
25+
error.value = null
26+
try {
27+
const response = await teamsService.list()
28+
teams.value = response.data.data.teams || []
29+
} catch (err: any) {
30+
error.value = err.response?.data?.message || 'Failed to fetch teams'
31+
throw err
32+
} finally {
33+
loading.value = false
34+
}
35+
}
36+
37+
async function createTeam(data: CreateTeamData): Promise<Team> {
38+
loading.value = true
39+
error.value = null
40+
try {
41+
const response = await teamsService.create(data)
42+
const newTeam = response.data.data.team
43+
teams.value.unshift(newTeam)
44+
return newTeam
45+
} catch (err: any) {
46+
error.value = err.response?.data?.message || 'Failed to create team'
47+
throw err
48+
} finally {
49+
loading.value = false
50+
}
51+
}
52+
53+
async function updateTeam(id: string, data: UpdateTeamData): Promise<Team> {
54+
loading.value = true
55+
error.value = null
56+
try {
57+
const response = await teamsService.update(id, data)
58+
const updatedTeam = response.data.data.team
59+
const index = teams.value.findIndex(t => t.id === id)
60+
if (index !== -1) {
61+
teams.value[index] = updatedTeam
62+
}
63+
return updatedTeam
64+
} catch (err: any) {
65+
error.value = err.response?.data?.message || 'Failed to update team'
66+
throw err
67+
} finally {
68+
loading.value = false
69+
}
70+
}
71+
72+
async function deleteTeam(id: string): Promise<void> {
73+
loading.value = true
74+
error.value = null
75+
try {
76+
await teamsService.delete(id)
77+
teams.value = teams.value.filter(t => t.id !== id)
78+
} catch (err: any) {
79+
error.value = err.response?.data?.message || 'Failed to delete team'
80+
throw err
81+
} finally {
82+
loading.value = false
83+
}
84+
}
85+
86+
async function fetchTeamMembers(teamId: string): Promise<TeamMember[]> {
87+
try {
88+
const response = await teamsService.listMembers(teamId)
89+
return response.data.data.members || []
90+
} catch (err: any) {
91+
error.value = err.response?.data?.message || 'Failed to fetch team members'
92+
throw err
93+
}
94+
}
95+
96+
async function addTeamMember(teamId: string, userId: string, role: 'manager' | 'agent' = 'agent'): Promise<TeamMember> {
97+
try {
98+
const response = await teamsService.addMember(teamId, { user_id: userId, role })
99+
// Update member count
100+
const team = teams.value.find(t => t.id === teamId)
101+
if (team) {
102+
team.member_count = (team.member_count || 0) + 1
103+
}
104+
return response.data.data.member
105+
} catch (err: any) {
106+
error.value = err.response?.data?.message || 'Failed to add team member'
107+
throw err
108+
}
109+
}
110+
111+
async function removeTeamMember(teamId: string, userId: string): Promise<void> {
112+
try {
113+
await teamsService.removeMember(teamId, userId)
114+
// Update member count
115+
const team = teams.value.find(t => t.id === teamId)
116+
if (team && team.member_count > 0) {
117+
team.member_count = team.member_count - 1
118+
}
119+
} catch (err: any) {
120+
error.value = err.response?.data?.message || 'Failed to remove team member'
121+
throw err
122+
}
123+
}
124+
125+
function getTeamById(id: string): Team | undefined {
126+
return teams.value.find(t => t.id === id)
127+
}
128+
129+
return {
130+
teams,
131+
loading,
132+
error,
133+
fetchTeams,
134+
createTeam,
135+
updateTeam,
136+
deleteTeam,
137+
fetchTeamMembers,
138+
addTeamMember,
139+
removeTeamMember,
140+
getTeamById
141+
}
142+
})

frontend/src/stores/transfers.ts

Lines changed: 43 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ export interface AgentTransfer {
1212
source: 'manual' | 'flow' | 'keyword'
1313
agent_id?: string
1414
agent_name?: string
15+
team_id?: string
16+
team_name?: string
1517
transferred_by?: string
1618
transferred_by_name?: string
1719
notes?: string
@@ -23,9 +25,16 @@ export interface AgentTransfer {
2325

2426
export const useTransfersStore = defineStore('transfers', () => {
2527
const transfers = ref<AgentTransfer[]>([])
26-
const queueCount = ref(0)
28+
const generalQueueCount = ref(0)
29+
const teamQueueCounts = ref<Record<string, number>>({})
2730
const isLoading = ref(false)
2831

32+
// Total queue count (general + all teams)
33+
const queueCount = computed(() => {
34+
const teamTotal = Object.values(teamQueueCounts.value).reduce((sum, count) => sum + count, 0)
35+
return generalQueueCount.value + teamTotal
36+
})
37+
2938
const activeTransfers = computed(() =>
3039
transfers.value.filter(t => t.status === 'active')
3140
)
@@ -54,8 +63,9 @@ export const useTransfersStore = defineStore('transfers', () => {
5463
const data = response.data.data || response.data
5564
console.log('Parsed transfers data:', data)
5665
transfers.value = data.transfers || []
57-
queueCount.value = data.queue_count ?? 0
58-
console.log('Queue count:', queueCount.value, 'Transfers:', transfers.value.length)
66+
generalQueueCount.value = data.general_queue_count ?? 0
67+
teamQueueCounts.value = data.team_queue_counts ?? {}
68+
console.log('General queue:', generalQueueCount.value, 'Team queues:', teamQueueCounts.value, 'Transfers:', transfers.value.length)
5969
} catch (error) {
6070
console.error('Failed to fetch transfers:', error)
6171
} finally {
@@ -69,7 +79,11 @@ export const useTransfersStore = defineStore('transfers', () => {
6979
if (!exists) {
7080
transfers.value.unshift(transfer)
7181
if (!transfer.agent_id) {
72-
queueCount.value++
82+
if (transfer.team_id) {
83+
teamQueueCounts.value[transfer.team_id] = (teamQueueCounts.value[transfer.team_id] || 0) + 1
84+
} else {
85+
generalQueueCount.value++
86+
}
7387
}
7488
console.log('Transfer added to store:', transfer.id, 'Total:', transfers.value.length, 'Queue count:', queueCount.value)
7589
} else {
@@ -86,15 +100,30 @@ export const useTransfersStore = defineStore('transfers', () => {
86100
// Update queue count if assignment changed
87101
if (updates.agent_id !== undefined) {
88102
if (!oldTransfer.agent_id && updates.agent_id) {
89-
queueCount.value = Math.max(0, queueCount.value - 1)
103+
// Was unassigned, now assigned - decrease queue count
104+
if (oldTransfer.team_id) {
105+
teamQueueCounts.value[oldTransfer.team_id] = Math.max(0, (teamQueueCounts.value[oldTransfer.team_id] || 0) - 1)
106+
} else {
107+
generalQueueCount.value = Math.max(0, generalQueueCount.value - 1)
108+
}
90109
} else if (oldTransfer.agent_id && !updates.agent_id) {
91-
queueCount.value++
110+
// Was assigned, now unassigned - increase queue count
111+
const teamId = updates.team_id ?? oldTransfer.team_id
112+
if (teamId) {
113+
teamQueueCounts.value[teamId] = (teamQueueCounts.value[teamId] || 0) + 1
114+
} else {
115+
generalQueueCount.value++
116+
}
92117
}
93118
}
94119

95120
// Update queue count if status changed to resumed
96121
if (updates.status === 'resumed' && oldTransfer.status === 'active' && !oldTransfer.agent_id) {
97-
queueCount.value = Math.max(0, queueCount.value - 1)
122+
if (oldTransfer.team_id) {
123+
teamQueueCounts.value[oldTransfer.team_id] = Math.max(0, (teamQueueCounts.value[oldTransfer.team_id] || 0) - 1)
124+
} else {
125+
generalQueueCount.value = Math.max(0, generalQueueCount.value - 1)
126+
}
98127
}
99128
}
100129
}
@@ -104,7 +133,11 @@ export const useTransfersStore = defineStore('transfers', () => {
104133
if (index !== -1) {
105134
const transfer = transfers.value[index]
106135
if (transfer.status === 'active' && !transfer.agent_id) {
107-
queueCount.value = Math.max(0, queueCount.value - 1)
136+
if (transfer.team_id) {
137+
teamQueueCounts.value[transfer.team_id] = Math.max(0, (teamQueueCounts.value[transfer.team_id] || 0) - 1)
138+
} else {
139+
generalQueueCount.value = Math.max(0, generalQueueCount.value - 1)
140+
}
108141
}
109142
transfers.value.splice(index, 1)
110143
}
@@ -113,6 +146,8 @@ export const useTransfersStore = defineStore('transfers', () => {
113146
return {
114147
transfers,
115148
queueCount,
149+
generalQueueCount,
150+
teamQueueCounts,
116151
isLoading,
117152
activeTransfers,
118153
myTransfers,

0 commit comments

Comments
 (0)