Skip to content

Commit 59dd3dc

Browse files
committed
fix: make conversation last seen timestamp and unread counts per agent, right now its global.
adds new table `conversation_last_seen` Migrations for v0.8.5 Remove col `assignee_last_seen_at` from schema
1 parent b6622e7 commit 59dd3dc

File tree

7 files changed

+81
-47
lines changed

7 files changed

+81
-47
lines changed

cmd/conversation.go

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ type createConversationRequest struct {
5656
func handleGetAllConversations(r *fastglue.Request) error {
5757
var (
5858
app = r.Context.(*App)
59+
user = r.RequestCtx.UserValue("user").(amodels.User)
5960
order = string(r.RequestCtx.QueryArgs().Peek("order"))
6061
orderBy = string(r.RequestCtx.QueryArgs().Peek("order_by"))
6162
page, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page")))
@@ -64,7 +65,7 @@ func handleGetAllConversations(r *fastglue.Request) error {
6465
total = 0
6566
)
6667

67-
conversations, err := app.conversation.GetAllConversationsList(order, orderBy, filters, page, pageSize)
68+
conversations, err := app.conversation.GetAllConversationsList(user.ID, order, orderBy, filters, page, pageSize)
6869
if err != nil {
6970
return sendErrorEnvelope(r, err)
7071
}
@@ -94,7 +95,7 @@ func handleGetAssignedConversations(r *fastglue.Request) error {
9495
pageSize, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page_size")))
9596
total = 0
9697
)
97-
conversations, err := app.conversation.GetAssignedConversationsList(user.ID, order, orderBy, filters, page, pageSize)
98+
conversations, err := app.conversation.GetAssignedConversationsList(user.ID, user.ID, order, orderBy, filters, page, pageSize)
9899
if err != nil {
99100
return sendErrorEnvelope(r, err)
100101
}
@@ -115,6 +116,7 @@ func handleGetAssignedConversations(r *fastglue.Request) error {
115116
func handleGetUnassignedConversations(r *fastglue.Request) error {
116117
var (
117118
app = r.Context.(*App)
119+
user = r.RequestCtx.UserValue("user").(amodels.User)
118120
order = string(r.RequestCtx.QueryArgs().Peek("order"))
119121
orderBy = string(r.RequestCtx.QueryArgs().Peek("order_by"))
120122
filters = string(r.RequestCtx.QueryArgs().Peek("filters"))
@@ -123,7 +125,7 @@ func handleGetUnassignedConversations(r *fastglue.Request) error {
123125
total = 0
124126
)
125127

126-
conversations, err := app.conversation.GetUnassignedConversationsList(order, orderBy, filters, page, pageSize)
128+
conversations, err := app.conversation.GetUnassignedConversationsList(user.ID, order, orderBy, filters, page, pageSize)
127129
if err != nil {
128130
return sendErrorEnvelope(r, err)
129131
}
@@ -194,7 +196,7 @@ func handleGetViewConversations(r *fastglue.Request) error {
194196
return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.Ts("globals.messages.denied", "name", "{globals.terms.permission}"), nil, envelope.PermissionError)
195197
}
196198

197-
conversations, err := app.conversation.GetViewConversationsList(user.ID, user.Teams.IDs(), lists, order, orderBy, string(view.Filters), page, pageSize)
199+
conversations, err := app.conversation.GetViewConversationsList(user.ID, user.ID, user.Teams.IDs(), lists, order, orderBy, string(view.Filters), page, pageSize)
198200
if err != nil {
199201
return sendErrorEnvelope(r, err)
200202
}
@@ -239,7 +241,7 @@ func handleGetTeamUnassignedConversations(r *fastglue.Request) error {
239241
return sendErrorEnvelope(r, envelope.NewError(envelope.PermissionError, app.i18n.T("conversation.notMemberOfTeam"), nil))
240242
}
241243

242-
conversations, err := app.conversation.GetTeamUnassignedConversationsList(teamID, order, orderBy, filters, page, pageSize)
244+
conversations, err := app.conversation.GetTeamUnassignedConversationsList(auser.ID, teamID, order, orderBy, filters, page, pageSize)
243245
if err != nil {
244246
return sendErrorEnvelope(r, err)
245247
}
@@ -279,7 +281,7 @@ func handleGetConversation(r *fastglue.Request) error {
279281
return r.SendEnvelope(conv)
280282
}
281283

282-
// handleUpdateConversationAssigneeLastSeen updates the assignee's last seen timestamp for a conversation.
284+
// handleUpdateConversationAssigneeLastSeen updates the current user's last seen timestamp for a conversation.
283285
func handleUpdateConversationAssigneeLastSeen(r *fastglue.Request) error {
284286
var (
285287
app = r.Context.(*App)
@@ -294,7 +296,8 @@ func handleUpdateConversationAssigneeLastSeen(r *fastglue.Request) error {
294296
if err != nil {
295297
return sendErrorEnvelope(r, err)
296298
}
297-
if err = app.conversation.UpdateConversationAssigneeLastSeen(uuid); err != nil {
299+
300+
if err = app.conversation.UpdateUserLastSeen(uuid, auser.ID); err != nil {
298301
return sendErrorEnvelope(r, err)
299302
}
300303
return r.SendEnvelope(true)

cmd/upgrade.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ var migList = []migFunc{
3636
{"v0.6.0", migrations.V0_6_0},
3737
{"v0.7.0", migrations.V0_7_0},
3838
{"v0.7.4", migrations.V0_7_4},
39+
{"v0.8.5", migrations.V0_8_5},
3940
}
4041

4142
// upgrade upgrades the database to the current version by running SQL migration files

internal/conversation/conversation.go

Lines changed: 21 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,7 @@ type queries struct {
206206
UpdateConversationFirstReplyAt *sqlx.Stmt `query:"update-conversation-first-reply-at"`
207207
UpdateConversationLastReplyAt *sqlx.Stmt `query:"update-conversation-last-reply-at"`
208208
UpdateConversationWaitingSince *sqlx.Stmt `query:"update-conversation-waiting-since"`
209-
UpdateConversationAssigneeLastSeen *sqlx.Stmt `query:"update-conversation-assignee-last-seen"`
209+
UpsertUserLastSeen *sqlx.Stmt `query:"upsert-user-last-seen"`
210210
UpdateConversationAssignedUser *sqlx.Stmt `query:"update-conversation-assigned-user"`
211211
UpdateConversationAssignedTeam *sqlx.Stmt `query:"update-conversation-assigned-team"`
212212
UpdateConversationCustomAttributes *sqlx.Stmt `query:"update-conversation-custom-attributes"`
@@ -300,15 +300,12 @@ func (c *Manager) GetConversationsCreatedAfter(time time.Time) ([]models.Convers
300300
return conversations, nil
301301
}
302302

303-
// UpdateConversationAssigneeLastSeen updates the last seen timestamp of assignee.
304-
func (c *Manager) UpdateConversationAssigneeLastSeen(uuid string) error {
305-
if _, err := c.q.UpdateConversationAssigneeLastSeen.Exec(uuid); err != nil {
306-
c.lo.Error("error updating conversation", "error", err)
303+
// UpdateUserLastSeen updates the last seen timestamp for a specific user on a conversation.
304+
func (c *Manager) UpdateUserLastSeen(uuid string, userID int) error {
305+
if _, err := c.q.UpsertUserLastSeen.Exec(userID, uuid); err != nil {
306+
c.lo.Error("error upserting user last seen", "user_id", userID, "conversation_uuid", uuid, "error", err)
307307
return envelope.NewError(envelope.GeneralError, c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.conversation}"), nil)
308308
}
309-
310-
// Broadcast the property update to all subscribers.
311-
c.BroadcastConversationUpdate(uuid, "assignee_last_seen_at", time.Now().Format(time.RFC3339))
312309
return nil
313310
}
314311

@@ -348,35 +345,36 @@ func (c *Manager) GetConversationUUID(id int) (string, error) {
348345
}
349346

350347
// GetAllConversationsList retrieves all conversations with optional filtering, ordering, and pagination.
351-
func (c *Manager) GetAllConversationsList(order, orderBy, filters string, page, pageSize int) ([]models.ConversationListItem, error) {
352-
return c.GetConversations(0, []int{}, []string{models.AllConversations}, order, orderBy, filters, page, pageSize)
348+
func (c *Manager) GetAllConversationsList(viewingUserID int, order, orderBy, filters string, page, pageSize int) ([]models.ConversationListItem, error) {
349+
return c.GetConversations(viewingUserID, 0, []int{}, []string{models.AllConversations}, order, orderBy, filters, page, pageSize)
353350
}
354351

355352
// GetAssignedConversationsList retrieves conversations assigned to a specific user with optional filtering, ordering, and pagination.
356-
func (c *Manager) GetAssignedConversationsList(userID int, order, orderBy, filters string, page, pageSize int) ([]models.ConversationListItem, error) {
357-
return c.GetConversations(userID, []int{}, []string{models.AssignedConversations}, order, orderBy, filters, page, pageSize)
353+
func (c *Manager) GetAssignedConversationsList(viewingUserID, userID int, order, orderBy, filters string, page, pageSize int) ([]models.ConversationListItem, error) {
354+
return c.GetConversations(viewingUserID, userID, []int{}, []string{models.AssignedConversations}, order, orderBy, filters, page, pageSize)
358355
}
359356

360357
// GetUnassignedConversationsList retrieves conversations assigned to a team the user is part of with optional filtering, ordering, and pagination.
361-
func (c *Manager) GetUnassignedConversationsList(order, orderBy, filters string, page, pageSize int) ([]models.ConversationListItem, error) {
362-
return c.GetConversations(0, []int{}, []string{models.UnassignedConversations}, order, orderBy, filters, page, pageSize)
358+
func (c *Manager) GetUnassignedConversationsList(viewingUserID int, order, orderBy, filters string, page, pageSize int) ([]models.ConversationListItem, error) {
359+
return c.GetConversations(viewingUserID, 0, []int{}, []string{models.UnassignedConversations}, order, orderBy, filters, page, pageSize)
363360
}
364361

365362
// GetTeamUnassignedConversationsList retrieves conversations assigned to a team with optional filtering, ordering, and pagination.
366-
func (c *Manager) GetTeamUnassignedConversationsList(teamID int, order, orderBy, filters string, page, pageSize int) ([]models.ConversationListItem, error) {
367-
return c.GetConversations(0, []int{teamID}, []string{models.TeamUnassignedConversations}, order, orderBy, filters, page, pageSize)
363+
func (c *Manager) GetTeamUnassignedConversationsList(viewingUserID, teamID int, order, orderBy, filters string, page, pageSize int) ([]models.ConversationListItem, error) {
364+
return c.GetConversations(viewingUserID, 0, []int{teamID}, []string{models.TeamUnassignedConversations}, order, orderBy, filters, page, pageSize)
368365
}
369366

370-
func (c *Manager) GetViewConversationsList(userID int, teamIDs []int, listType []string, order, orderBy, filters string, page, pageSize int) ([]models.ConversationListItem, error) {
371-
return c.GetConversations(userID, teamIDs, listType, order, orderBy, filters, page, pageSize)
367+
func (c *Manager) GetViewConversationsList(viewingUserID, userID int, teamIDs []int, listType []string, order, orderBy, filters string, page, pageSize int) ([]models.ConversationListItem, error) {
368+
return c.GetConversations(viewingUserID, userID, teamIDs, listType, order, orderBy, filters, page, pageSize)
372369
}
373370

374371
// GetConversations retrieves conversations list based on user ID, type, and optional filtering, ordering, and pagination.
375-
func (c *Manager) GetConversations(userID int, teamIDs []int, listTypes []string, order, orderBy, filters string, page, pageSize int) ([]models.ConversationListItem, error) {
372+
// viewingUserID is used to calculate per-agent unread counts.
373+
func (c *Manager) GetConversations(viewingUserID, userID int, teamIDs []int, listTypes []string, order, orderBy, filters string, page, pageSize int) ([]models.ConversationListItem, error) {
376374
var conversations = make([]models.ConversationListItem, 0)
377375

378376
// Make the query.
379-
query, qArgs, err := c.makeConversationsListQuery(userID, teamIDs, listTypes, c.q.GetConversations, order, orderBy, page, pageSize, filters)
377+
query, qArgs, err := c.makeConversationsListQuery(viewingUserID, userID, teamIDs, listTypes, c.q.GetConversations, order, orderBy, page, pageSize, filters)
380378
if err != nil {
381379
c.lo.Error("error making conversations query", "error", err)
382380
return conversations, envelope.NewError(envelope.GeneralError, c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.conversation}"), nil)
@@ -1068,8 +1066,9 @@ func (c *Manager) getConversationTags(uuid string) ([]string, error) {
10681066
}
10691067

10701068
// makeConversationsListQuery prepares a SQL query string for conversations list
1071-
func (c *Manager) makeConversationsListQuery(userID int, teamIDs []int, listTypes []string, baseQuery, order, orderBy string, page, pageSize int, filtersJSON string) (string, []interface{}, error) {
1072-
var qArgs []interface{}
1069+
// viewingUserID is used as $1 for per-agent unread count calculation
1070+
func (c *Manager) makeConversationsListQuery(viewingUserID, userID int, teamIDs []int, listTypes []string, baseQuery, order, orderBy string, page, pageSize int, filtersJSON string) (string, []interface{}, error) {
1071+
qArgs := []any{viewingUserID}
10731072

10741073
// Set defaults
10751074
if orderBy == "" {

internal/conversation/models/models.go

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,6 @@ type ConversationListItem struct {
6060
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
6161
UUID string `db:"uuid" json:"uuid"`
6262
WaitingSince null.Time `db:"waiting_since" json:"waiting_since"`
63-
AssigneeLastSeenAt null.Time `db:"assignee_last_seen_at" json:"assignee_last_seen_at"`
6463
Contact ConversationListContact `db:"contact" json:"contact"`
6564
InboxChannel string `db:"inbox_channel" json:"inbox_channel"`
6665
InboxName string `db:"inbox_name" json:"inbox_name"`
@@ -111,7 +110,6 @@ type Conversation struct {
111110
LastReplyAt null.Time `db:"last_reply_at" json:"last_reply_at"`
112111
AssignedUserID null.Int `db:"assigned_user_id" json:"assigned_user_id"`
113112
AssignedTeamID null.Int `db:"assigned_team_id" json:"assigned_team_id"`
114-
AssigneeLastSeenAt null.Time `db:"assignee_last_seen_at" json:"assignee_last_seen_at"`
115113
WaitingSince null.Time `db:"waiting_since" json:"waiting_since"`
116114
Subject null.String `db:"subject" json:"subject"`
117115
InboxMail string `db:"inbox_mail" json:"inbox_mail"`

internal/conversation/queries.sql

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -29,19 +29,19 @@ VALUES(
2929
RETURNING id, uuid;
3030

3131
-- name: get-conversations
32+
-- $1 = viewing user ID for per-agent unread count
3233
SELECT
3334
COUNT(*) OVER() as total,
3435
conversations.id,
3536
conversations.created_at,
3637
conversations.updated_at,
3738
conversations.uuid,
3839
conversations.waiting_since,
39-
conversations.assignee_last_seen_at,
4040
users.created_at as "contact.created_at",
4141
users.updated_at as "contact.updated_at",
4242
users.first_name as "contact.first_name",
4343
users.last_name as "contact.last_name",
44-
users.avatar_url as "contact.avatar_url",
44+
users.avatar_url as "contact.avatar_url",
4545
inboxes.channel as inbox_channel,
4646
inboxes.name as inbox_name,
4747
conversations.sla_policy_id,
@@ -57,9 +57,13 @@ SELECT
5757
(
5858
SELECT CASE WHEN COUNT(*) > 9 THEN 10 ELSE COUNT(*) END
5959
FROM (
60-
SELECT 1 FROM conversation_messages
61-
WHERE conversation_id = conversations.id
62-
AND created_at > conversations.assignee_last_seen_at
60+
SELECT 1 FROM conversation_messages
61+
WHERE conversation_id = conversations.id
62+
AND created_at > COALESCE(
63+
(SELECT last_seen_at FROM conversation_last_seen
64+
WHERE conversation_id = conversations.id AND user_id = $1),
65+
'1970-01-01'::TIMESTAMPTZ
66+
)
6367
LIMIT 10
6468
) t
6569
) as unread_message_count,
@@ -99,7 +103,6 @@ SELECT
99103
c.closed_at,
100104
c.resolved_at,
101105
c.inbox_id,
102-
c.assignee_last_seen_at,
103106
inb.name as inbox_name,
104107
COALESCE(inb.from, '') as inbox_mail,
105108
COALESCE(inb.channel::TEXT, '') as inbox_channel,
@@ -207,8 +210,6 @@ SELECT uuid from conversations where id = $1;
207210
-- name: update-conversation-assigned-user
208211
UPDATE conversations
209212
SET assigned_user_id = $2,
210-
-- Reset assignee_last_seen_at when assigned to a new user.
211-
assignee_last_seen_at = NULL,
212213
updated_at = NOW()
213214
WHERE uuid = $1;
214215

@@ -236,11 +237,11 @@ SET priority_id = (SELECT id FROM conversation_priorities WHERE name = $2),
236237
updated_at = NOW()
237238
WHERE uuid = $1;
238239

239-
-- name: update-conversation-assignee-last-seen
240-
UPDATE conversations
241-
SET assignee_last_seen_at = NOW(),
242-
updated_at = NOW()
243-
WHERE uuid = $1;
240+
-- name: upsert-user-last-seen
241+
INSERT INTO conversation_last_seen (user_id, conversation_id, last_seen_at)
242+
VALUES ($1, (SELECT id FROM conversations WHERE uuid = $2), NOW())
243+
ON CONFLICT (conversation_id, user_id)
244+
DO UPDATE SET last_seen_at = NOW(), updated_at = NOW();
244245

245246
-- name: update-conversation-last-message
246247
UPDATE conversations SET last_message = $3, last_message_sender = $4, last_message_at = $5, updated_at = NOW() WHERE CASE
@@ -351,9 +352,8 @@ WHERE uuid = $1;
351352

352353
-- name: remove-conversation-assignee
353354
UPDATE conversations
354-
SET
355+
SET
355356
assigned_user_id = CASE WHEN $2 = 'user' THEN NULL ELSE assigned_user_id END,
356-
assignee_last_seen_at = CASE WHEN $2 = 'user' THEN NULL ELSE assignee_last_seen_at END,
357357
assigned_team_id = CASE WHEN $2 = 'team' THEN NULL ELSE assigned_team_id END,
358358
updated_at = NOW()
359359
WHERE uuid = $1;

internal/migrations/v0.8.5.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package migrations
2+
3+
import (
4+
"github.com/jmoiron/sqlx"
5+
"github.com/knadh/koanf/v2"
6+
"github.com/knadh/stuffbin"
7+
)
8+
9+
func V0_8_5(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error {
10+
_, err := db.Exec(`
11+
CREATE TABLE IF NOT EXISTS conversation_last_seen (
12+
id BIGSERIAL PRIMARY KEY,
13+
created_at TIMESTAMPTZ DEFAULT NOW(),
14+
updated_at TIMESTAMPTZ DEFAULT NOW(),
15+
user_id BIGINT REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE NOT NULL,
16+
conversation_id BIGINT REFERENCES conversations(id) ON DELETE CASCADE ON UPDATE CASCADE NOT NULL,
17+
last_seen_at TIMESTAMPTZ DEFAULT NOW() NOT NULL
18+
);
19+
CREATE UNIQUE INDEX IF NOT EXISTS index_unique_conversation_last_seen
20+
ON conversation_last_seen (conversation_id, user_id);
21+
`)
22+
return err
23+
}

schema.sql

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,6 @@ CREATE TABLE conversations (
229229

230230
meta JSONB DEFAULT '{}'::jsonb NOT NULL,
231231
custom_attributes JSONB DEFAULT '{}'::jsonb NOT NULL,
232-
assignee_last_seen_at TIMESTAMPTZ DEFAULT NOW(),
233232
first_reply_at TIMESTAMPTZ NULL,
234233
last_reply_at TIMESTAMPTZ NULL,
235234
closed_at TIMESTAMPTZ NULL,
@@ -327,6 +326,17 @@ CREATE TABLE conversation_participants (
327326
);
328327
CREATE UNIQUE INDEX index_unique_conversation_participants_on_conversation_id_and_user_id ON conversation_participants (conversation_id, user_id);
329328

329+
DROP TABLE IF EXISTS conversation_last_seen CASCADE;
330+
CREATE TABLE conversation_last_seen (
331+
id BIGSERIAL PRIMARY KEY,
332+
created_at TIMESTAMPTZ DEFAULT NOW(),
333+
updated_at TIMESTAMPTZ DEFAULT NOW(),
334+
user_id BIGINT REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE NOT NULL,
335+
conversation_id BIGINT REFERENCES conversations(id) ON DELETE CASCADE ON UPDATE CASCADE NOT NULL,
336+
last_seen_at TIMESTAMPTZ DEFAULT NOW() NOT NULL
337+
);
338+
CREATE UNIQUE INDEX index_unique_conversation_last_seen ON conversation_last_seen (conversation_id, user_id);
339+
330340
DROP TABLE IF EXISTS media CASCADE;
331341
CREATE TABLE media (
332342
id SERIAL PRIMARY KEY,

0 commit comments

Comments
 (0)