Skip to content

Commit e47d884

Browse files
committed
feat(reminders): per-agent, per-conversation follow-up reminders
Port of v1.0.3 commit 29281d4 to v2. See that commit for full design notes. v2-specific differences: - Migration is v2.2.25 (vs v1.0.8 on v1.0.3). - ReplyBoxContent override path is also wired (overrides/features/...). - Frontend imports use @Shared-UI / @main aliases instead of @/.
1 parent 4af27eb commit e47d884

15 files changed

Lines changed: 778 additions & 3 deletions

File tree

cmd/handlers.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,12 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
148148
g.PUT("/api/v1/conversations/{uuid}/last-seen", perm(handleUpdateConversationAssigneeLastSeen, "conversations:read"))
149149
g.PUT("/api/v1/conversations/{uuid}/mark-unread", perm(handleMarkConversationAsUnread, "conversations:read"))
150150
g.POST("/api/v1/conversations/{uuid}/tags", perm(handleUpdateConversationtags, "conversations:update_tags"))
151+
// Personal reminders — per-agent, per-conversation. No dedicated permission;
152+
// any agent who can read the conversation can set themselves a follow-up.
153+
g.GET("/api/v1/conversations/{uuid}/reminders", perm(handleListConversationReminders, "conversations:read"))
154+
g.POST("/api/v1/conversations/{uuid}/reminders", perm(handleCreateReminder, "conversations:read"))
155+
g.GET("/api/v1/reminders", auth(handleListMyReminders))
156+
g.DELETE("/api/v1/reminders/{id}", auth(handleDeleteReminder))
151157
g.GET("/api/v1/conversations/{uuid}/page-visits", perm(handleGetContactPageVisits, "conversations:read"))
152158
g.GET("/api/v1/conversations/{cuuid}/messages/{uuid}", perm(handleGetMessage, "messages:read"))
153159
g.GET("/api/v1/conversations/{uuid}/messages", perm(handleGetMessages, "messages:read"))

cmd/init.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import (
4949
"github.com/abhinavxd/libredesk/internal/search"
5050
"github.com/abhinavxd/libredesk/internal/setting"
5151
"github.com/abhinavxd/libredesk/internal/sla"
52+
"github.com/abhinavxd/libredesk/internal/reminder"
5253
"github.com/abhinavxd/libredesk/internal/tag"
5354
"github.com/abhinavxd/libredesk/internal/team"
5455
tmpl "github.com/abhinavxd/libredesk/internal/template"
@@ -320,6 +321,21 @@ func initConversations(
320321
return c
321322
}
322323

324+
// initReminder inits the personal-reminder manager.
325+
func initReminder(db *sqlx.DB, dispatcher *notifier.Dispatcher, i18n *i18n.I18n) *reminder.Manager {
326+
var lo = initLogger("reminder_manager")
327+
mgr, err := reminder.New(reminder.Opts{
328+
DB: db,
329+
Dispatcher: dispatcher,
330+
Lo: lo,
331+
I18n: i18n,
332+
})
333+
if err != nil {
334+
log.Fatalf("error initializing reminders: %v", err)
335+
}
336+
return mgr
337+
}
338+
323339
// initTag inits tag manager.
324340
func initTag(db *sqlx.DB, i18n *i18n.I18n) *tag.Manager {
325341
var lo = initLogger("tag_manager")

cmd/main.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import (
4646
"github.com/abhinavxd/libredesk/internal/rag"
4747
ragsync "github.com/abhinavxd/libredesk/internal/rag/sync"
4848
"github.com/abhinavxd/libredesk/internal/ratelimit"
49+
"github.com/abhinavxd/libredesk/internal/reminder"
4950
"github.com/abhinavxd/libredesk/internal/role"
5051
"github.com/abhinavxd/libredesk/internal/setting"
5152
"github.com/abhinavxd/libredesk/internal/tag"
@@ -134,6 +135,7 @@ type App struct {
134135
rateLimit *ratelimit.Limiter
135136
redis *redis.Client
136137
importer *importer.Importer
138+
reminder *reminder.Manager
137139

138140
// Global state that stores data on an available app update.
139141
update *AppUpdate
@@ -259,8 +261,9 @@ func main() {
259261
// RAG sync coordinator can hold a stable reference to it. Same
260262
// reasoning as aiMgr above.
261263
macroMgr = initMacro(db, i18n)
262-
ragMgr = initRAG(db, i18n, aiMgr, media)
263-
ragSyncMgr = initRAGSync(ragMgr, macroMgr)
264+
ragMgr = initRAG(db, i18n, aiMgr, media)
265+
ragSyncMgr = initRAGSync(ragMgr, macroMgr)
266+
reminderMgr = initReminder(db, notifDispatcher, i18n)
264267
)
265268

266269
wsHub.SetConversationStore(conversation)
@@ -298,6 +301,8 @@ func main() {
298301
go autoassigner.Run(ctx, autoAssignInterval)
299302
go conversation.Run(ctx, messageIncomingQWorkers, messageOutgoingQWorkers, messageOutgoingScanInterval)
300303
go conversation.RunUnsnoozer(ctx, unsnoozeInterval)
304+
// Personal-reminder firer. 30s cadence matches the unsnoozer for consistency.
305+
go reminderMgr.RunFirer(ctx, 30*time.Second)
301306
go conversation.RunContinuity(ctx)
302307
go webhook.Run(ctx)
303308
go notifier.Run(ctx)
@@ -350,6 +355,7 @@ func main() {
350355
search: initSearch(db, i18n),
351356
role: initRole(db, i18n),
352357
tag: initTag(db, i18n),
358+
reminder: reminderMgr,
353359
macro: macroMgr,
354360
ai: aiMgr,
355361
rag: ragMgr,

cmd/reminder.go

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
package main
2+
3+
import (
4+
"strconv"
5+
"time"
6+
7+
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
8+
"github.com/abhinavxd/libredesk/internal/envelope"
9+
"github.com/valyala/fasthttp"
10+
"github.com/zerodha/fastglue"
11+
)
12+
13+
// reminderCreateReq is the payload accepted by handleCreateReminder.
14+
type reminderCreateReq struct {
15+
// ISO 8601 / RFC 3339 timestamp. Must be in the future.
16+
RemindAt string `json:"remind_at"`
17+
Note string `json:"note"`
18+
}
19+
20+
// handleListConversationReminders returns the requesting agent's pending
21+
// reminders for a specific conversation (by UUID). Private to the agent —
22+
// each user only sees their own.
23+
func handleListConversationReminders(r *fastglue.Request) error {
24+
var (
25+
app = r.Context.(*App)
26+
user = r.RequestCtx.UserValue("user").(amodels.User)
27+
uuid = r.RequestCtx.UserValue("uuid").(string)
28+
)
29+
if uuid == "" {
30+
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`uuid`"), nil, envelope.InputError)
31+
}
32+
out, err := app.reminder.ListForConversation(user.ID, uuid)
33+
if err != nil {
34+
return sendErrorEnvelope(r, err)
35+
}
36+
return r.SendEnvelope(out)
37+
}
38+
39+
// handleListMyReminders returns every pending reminder owned by the
40+
// requesting agent across all conversations. Used by a future "My
41+
// reminders" sidebar view.
42+
func handleListMyReminders(r *fastglue.Request) error {
43+
var (
44+
app = r.Context.(*App)
45+
user = r.RequestCtx.UserValue("user").(amodels.User)
46+
)
47+
out, err := app.reminder.ListForUser(user.ID)
48+
if err != nil {
49+
return sendErrorEnvelope(r, err)
50+
}
51+
return r.SendEnvelope(out)
52+
}
53+
54+
// handleCreateReminder sets a personal reminder on a conversation.
55+
func handleCreateReminder(r *fastglue.Request) error {
56+
var (
57+
app = r.Context.(*App)
58+
user = r.RequestCtx.UserValue("user").(amodels.User)
59+
uuid = r.RequestCtx.UserValue("uuid").(string)
60+
req reminderCreateReq
61+
)
62+
if uuid == "" {
63+
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`uuid`"), nil, envelope.InputError)
64+
}
65+
if err := r.Decode(&req, "json"); err != nil {
66+
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), err.Error(), envelope.InputError)
67+
}
68+
if req.RemindAt == "" {
69+
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`remind_at`"), nil, envelope.InputError)
70+
}
71+
72+
// Parse remind_at; require strict RFC 3339 so we don't accept timezone-less
73+
// strings that would later get misinterpreted by the worker's NOW() compare.
74+
remindAt, err := time.Parse(time.RFC3339, req.RemindAt)
75+
if err != nil {
76+
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`remind_at`"), nil, envelope.InputError)
77+
}
78+
// Reject obviously stale picks — gives a 1-minute grace so a slow agent
79+
// can still set "in 1 minute" without server-clock skew kicking it back.
80+
if remindAt.Before(time.Now().Add(-1 * time.Minute)) {
81+
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`remind_at`"), nil, envelope.InputError)
82+
}
83+
if len(req.Note) > 500 {
84+
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`note`"), nil, envelope.InputError)
85+
}
86+
87+
convID, err := app.reminder.ResolveConversationIDByUUID(uuid)
88+
if err != nil {
89+
return r.SendErrorEnvelope(fasthttp.StatusNotFound, app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.conversation}"), nil, envelope.InputError)
90+
}
91+
92+
created, err := app.reminder.Create(user.ID, convID, remindAt, req.Note)
93+
if err != nil {
94+
return sendErrorEnvelope(r, err)
95+
}
96+
return r.SendEnvelope(created)
97+
}
98+
99+
// handleDeleteReminder removes a reminder. Only the owning user may delete
100+
// theirs — the query enforces user_id == caller.
101+
func handleDeleteReminder(r *fastglue.Request) error {
102+
var (
103+
app = r.Context.(*App)
104+
user = r.RequestCtx.UserValue("user").(amodels.User)
105+
)
106+
id, err := strconv.Atoi(r.RequestCtx.UserValue("id").(string))
107+
if err != nil || id <= 0 {
108+
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`id`"), nil, envelope.InputError)
109+
}
110+
if err := app.reminder.Delete(user.ID, id); err != nil {
111+
return sendErrorEnvelope(r, err)
112+
}
113+
return r.SendEnvelope(true)
114+
}

cmd/upgrade.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ var migList = []migFunc{
6868
{"v2.2.22", migrations.V2_2_22},
6969
{"v2.2.23", migrations.V2_2_23},
7070
{"v2.2.24", migrations.V2_2_24},
71+
{"v2.2.25", migrations.V2_2_25},
7172
}
7273

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

frontend/apps/main/src/api/index.js

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -698,6 +698,14 @@ const markAllNotificationsAsRead = () => http.put('/api/v1/notifications/read-al
698698
const deleteNotification = (id) => http.delete(`/api/v1/notifications/${id}`)
699699
const deleteAllNotifications = () => http.delete('/api/v1/notifications')
700700

701+
// Personal-reminder API. Reminders are private to the calling agent.
702+
const listConversationReminders = (uuid) =>
703+
http.get(`/api/v1/conversations/${uuid}/reminders`)
704+
const createReminder = (uuid, payload) =>
705+
http.post(`/api/v1/conversations/${uuid}/reminders`, payload)
706+
const listMyReminders = () => http.get('/api/v1/reminders')
707+
const deleteReminder = (id) => http.delete(`/api/v1/reminders/${id}`)
708+
701709
export default {
702710
login,
703711
deleteUser,
@@ -923,5 +931,9 @@ export default {
923931
deleteNotification,
924932
deleteAllNotifications,
925933
getContactPageVisits,
926-
getConversationByRef
934+
getConversationByRef,
935+
listConversationReminders,
936+
createReminder,
937+
listMyReminders,
938+
deleteReminder
927939
}

0 commit comments

Comments
 (0)