feat: chat auto-archive owner digest notifications (#24643)

Depends on #24642

Adds per-owner digest notifications onto the chat auto-archive
subsystem.

Each tick's archived rows are grouped by owner, the top 25 titles per
owner are rendered into a new `Chats Auto-Archived` notification
template, and any remainder surfaces as `and N more`. Each digest is
per-tick, so users with large amounts of purgeable data may get multiple
notifications in sequence (one per user per tick).

The template body branches on `retention_days`: when retention is
disabled (`retention_days=0`), users are told archived chats are kept
indefinitely rather than falsely claiming imminent deletion.

### Changes
- migration `000XXX_chat_auto_archive_notification_template` adds new
notification template
- `dbpurge`: threads `notifications.Enqueuer` through `New`; and
enqueues notification message.
- `cli/server.go`: passes `options.NotificationsEnqueuer` into
`dbpurge.New`.
- `coderd/notifications/events.go`: new `TemplateChatAutoArchiveDigest`
UUID.
- `coderd/inboxnotifications.go`: inbox registration.
- Docs: adds a `Notifications` section to `chat-auto-archive.md`.

> 🤖
This commit is contained in:
Cian Johnston
2026-04-28 08:56:36 +01:00
committed by GitHub
parent bf66f63ac5
commit 70d6efa311
25 changed files with 1341 additions and 103 deletions
+1 -1
View File
@@ -1074,7 +1074,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
defer shutdownConns()
// Ensures that old database entries are cleaned up over time!
purger := dbpurge.New(ctx, logger.Named("dbpurge"), options.Database, options.DeploymentValues, options.PrometheusRegistry, &coderAPI.Auditor)
purger := dbpurge.New(ctx, logger.Named("dbpurge"), options.Database, options.DeploymentValues, options.PrometheusRegistry, &coderAPI.Auditor, dbpurge.WithNotificationsEnqueuer(options.NotificationsEnqueuer))
defer purger.Close()
// Updates workspace usage
+212 -73
View File
@@ -1,12 +1,18 @@
package dbpurge
import (
"cmp"
"context"
"errors"
"io"
"net/http"
"slices"
"strconv"
"sync/atomic"
"time"
"github.com/dustin/go-humanize"
"github.com/google/uuid"
"github.com/prometheus/client_golang/prometheus"
"golang.org/x/xerrors"
@@ -15,7 +21,9 @@ import (
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/notifications"
"github.com/coder/coder/v2/coderd/pproflabel"
"github.com/coder/coder/v2/coderd/util/slice"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/quartz"
)
@@ -41,6 +49,12 @@ const (
// chat_files rows carry bytea blobs.
chatsBatchSize = 1000
chatFilesBatchSize = 1000
// chatAutoArchiveDigestMaxChats bounds how many chat titles a
// single digest body lists. Past the cap, surplus titles are
// summarized as "...and N more". 25 is a readable email-friendly
// length; the cap is unrelated to chatAutoArchiveBatchSize, which
// bounds work per tick.
chatAutoArchiveDigestMaxChats = 25
)
// defaultChatAutoArchiveBatchSize bounds how many root chats one
@@ -62,12 +76,26 @@ func WithChatAutoArchiveBatchSize(n int32) Option {
return func(i *instance) { i.chatAutoArchiveBatchSize = n }
}
// WithNotificationsEnqueuer sets the enqueuer used for digest
// notifications. Defaults to notifications.NewNoopEnqueuer(). Panics
// if e is nil: a nil enqueuer would NPE on the first dispatch tick,
// and failing fast at option-apply time surfaces the misuse at
// startup rather than minutes later.
func WithNotificationsEnqueuer(e notifications.Enqueuer) Option {
if e == nil {
panic("developer error: WithNotificationsEnqueuer called with nil enqueuer")
}
return func(i *instance) { i.enqueuer = e }
}
// New creates a new periodically purging database instance.
// Callers must Close the returned instance.
//
// The auditor pointer is loaded on each dispatch tick so runtime
// entitlement changes (e.g. toggling the audit-log feature) take
// effect without restarting the process.
// effect without restarting the process. Notifications enqueuer
// defaults to no-op. Use WithNotificationsEnqueuer to pass a real
// one.
func New(ctx context.Context, logger slog.Logger, db database.Store, vals *codersdk.DeploymentValues, reg prometheus.Registerer, auditor *atomic.Pointer[audit.Auditor], opts ...Option) io.Closer {
closed := make(chan struct{})
@@ -107,6 +135,7 @@ func New(ctx context.Context, logger slog.Logger, db database.Store, vals *coder
vals: vals,
clk: quartz.NewReal(),
auditor: auditor,
enqueuer: notifications.NewNoopEnqueuer(),
iterationDuration: iterationDuration,
recordsPurged: recordsPurged,
chatAutoArchiveRecords: chatAutoArchiveRecords,
@@ -151,30 +180,29 @@ func New(ctx context.Context, logger slog.Logger, db database.Store, vals *coder
// purgeTick performs a single purge iteration. It returns an error if the
// purge fails.
func (i *instance) purgeTick(ctx context.Context, db database.Store, start time.Time) error {
// Read chat retention config outside the transaction to
// avoid poisoning the tx if the stored value is corrupt.
// A SQL-level cast error (e.g. non-numeric text) puts PG
// into error state, failing all subsequent queries in the
// same transaction.
chatRetentionDays, err := db.GetChatRetentionDays(ctx)
if err != nil {
i.logger.Warn(ctx, "failed to read chat retention config, skipping chat purge", slog.Error(err))
chatRetentionDays = 0
// Read chat configs outside the tx so a corrupt value can't
// poison subsequent queries. On error we log and stash, then
// run unrelated purges best-effort and skip only chat work;
// purgeTick returns chatConfigErr after the tx so the failed
// iteration is operator-visible via metric and logs.
chatRetentionDays, chatRetentionErr := db.GetChatRetentionDays(ctx)
if chatRetentionErr != nil {
i.logger.Error(ctx, "failed to read chat retention config: skipping chat purge and auto-archive this tick", slog.Error(chatRetentionErr))
}
// Same rationale as chat_retention_days: read outside the tx.
chatAutoArchiveDays, err := db.GetChatAutoArchiveDays(ctx, codersdk.DefaultChatAutoArchiveDays)
if err != nil {
i.logger.Warn(ctx, "failed to read chat auto-archive config, skipping auto-archive", slog.Error(err))
chatAutoArchiveDays = 0
chatAutoArchiveDays, chatAutoArchiveErr := db.GetChatAutoArchiveDays(ctx, codersdk.DefaultChatAutoArchiveDays)
if chatAutoArchiveErr != nil {
i.logger.Error(ctx, "failed to read chat auto-archive config: skipping chat purge and auto-archive this tick", slog.Error(chatAutoArchiveErr))
}
chatConfigErr := errors.Join(chatRetentionErr, chatAutoArchiveErr)
// Populated inside the tx; dispatched post-commit.
var archivedChats []database.AutoArchiveInactiveChatsRow
// Start a transaction to grab advisory lock, we don't want to run
// multiple purges at the same time (multiple replicas).
err = db.InTx(func(tx database.Store) error {
err := db.InTx(func(tx database.Store) error {
// Acquire a lock to ensure that only one instance of the
// purge is running at a time.
ok, err := tx.TryAcquireLock(ctx, database.LockIDDBPurge)
@@ -276,46 +304,11 @@ func (i *instance) purgeTick(ctx context.Context, db database.Store, start time.
}
}
// Chat retention is configured via site_configs. When
// enabled, old archived chats are deleted first, then
// orphaned chat files. Deleting a chat cascades to
// chat_file_links (removing references) but not to
// chat_files directly, so files from deleted chats
// become orphaned and are caught by DeleteOldChatFiles
// in the same tick.
var purgedChats int64
var purgedChatFiles int64
if chatRetentionDays > 0 {
chatRetention := time.Duration(chatRetentionDays) * 24 * time.Hour
deleteChatsBefore := start.Add(-chatRetention)
purgedChats, err = tx.DeleteOldChats(ctx, database.DeleteOldChatsParams{
BeforeTime: deleteChatsBefore,
LimitCount: chatsBatchSize,
})
var purgedChats, purgedChatFiles int64
if chatConfigErr == nil {
purgedChats, purgedChatFiles, archivedChats, err = i.purgeChatsInTx(ctx, tx, start, chatRetentionDays, chatAutoArchiveDays)
if err != nil {
return xerrors.Errorf("failed to delete old chats: %w", err)
}
purgedChatFiles, err = tx.DeleteOldChatFiles(ctx, database.DeleteOldChatFilesParams{
BeforeTime: deleteChatsBefore,
LimitCount: chatFilesBatchSize,
})
if err != nil {
return xerrors.Errorf("failed to delete old chat files: %w", err)
}
}
// Auto-archive runs after the delete pass so newly
// archived chats aren't eligible for deletion this tick.
if chatAutoArchiveDays > 0 {
archiveCutoff := start.Add(-time.Duration(chatAutoArchiveDays) * 24 * time.Hour)
archivedChats, err = tx.AutoArchiveInactiveChats(ctx, database.AutoArchiveInactiveChatsParams{
ArchiveCutoff: archiveCutoff,
LimitCount: i.chatAutoArchiveBatchSize,
})
if err != nil {
return xerrors.Errorf("failed to auto-archive inactive chats: %w", err)
return xerrors.Errorf("failed to purge chats: %w", err)
}
}
@@ -351,15 +344,24 @@ func (i *instance) purgeTick(ctx context.Context, db database.Store, start time.
return err
}
// Dispatch audits post-commit on a detached context so ticker
// cancellation doesn't interrupt the loop. No timeout: every root
// must be audited to avoid gaps in the trail. Children inherit
// their root's archival decision and are not audited individually,
// matching the manual archive path (patchChat audits the root only).
// Surface the deferred chat-config error so doTick records
// the failed iteration metric.
if chatConfigErr != nil {
return xerrors.Errorf("chat config read failed this tick: %w", chatConfigErr)
}
// Dispatch audits and digests post-commit. Detached context for audit
// so that ticker cancellation cannot truncate the audit trail.
// Notification enqueue uses the cancellable parent context to avoid
// stalling shutdown.
// Owners with more eligible chats than batch size will get a
// notification per tick until their backlog drains.
// If this is deemed too noisy, users can disable the
// "Chats Auto-Archived" template from their notification preferences.
if len(archivedChats) > 0 {
i.chatAutoArchiveRecords.Add(float64(len(archivedChats)))
dispatchCtx := context.WithoutCancel(ctx)
i.dispatchChatAutoArchive(dispatchCtx, archivedChats)
auditCtx := context.WithoutCancel(ctx)
i.dispatchChatAutoArchive(auditCtx, ctx, start, chatAutoArchiveDays, chatRetentionDays, archivedChats)
}
return nil
@@ -372,6 +374,7 @@ type instance struct {
vals *codersdk.DeploymentValues
clk quartz.Clock
auditor *atomic.Pointer[audit.Auditor]
enqueuer notifications.Enqueuer
iterationDuration *prometheus.HistogramVec
recordsPurged *prometheus.CounterVec
chatAutoArchiveRecords prometheus.Counter
@@ -428,20 +431,69 @@ func chatFromAutoArchiveRow(logger slog.Logger, r database.AutoArchiveInactiveCh
}
}
// dispatchChatAutoArchive audits every archived root chat. Children
// inherit their root's archival decision and are skipped, matching
// the manual archive path (patchChat audits the root only). Runs on
// a detached context so ticker cancellation cannot truncate the trail.
func (i *instance) dispatchChatAutoArchive(ctx context.Context, archived []database.AutoArchiveInactiveChatsRow) {
auditor := *i.auditor.Load()
for _, row := range archived {
if row.ParentChatID.Valid {
continue // Children inherit root's archival; audit roots only.
// purgeChatsInTx MUST BE CALLED WITH A TRANSACTION
func (i *instance) purgeChatsInTx(ctx context.Context, tx database.Store, start time.Time, chatRetentionDays, chatAutoArchiveDays int32) (purgedChats, purgedChatFiles int64, archivedChats []database.AutoArchiveInactiveChatsRow, err error) {
// Delete old archived chats first, then orphaned files
// (cascade clears chat_file_links but not chat_files).
if chatRetentionDays > 0 {
deleteChatsBefore := start.Add(-time.Duration(chatRetentionDays) * 24 * time.Hour)
purgedChats, err = tx.DeleteOldChats(ctx, database.DeleteOldChatsParams{
BeforeTime: deleteChatsBefore,
LimitCount: chatsBatchSize,
})
if err != nil {
return 0, 0, nil, xerrors.Errorf("failed to delete old chats: %w", err)
}
purgedChatFiles, err = tx.DeleteOldChatFiles(ctx, database.DeleteOldChatFilesParams{
BeforeTime: deleteChatsBefore,
LimitCount: chatFilesBatchSize,
})
if err != nil {
return 0, 0, nil, xerrors.Errorf("failed to delete old chat files: %w", err)
}
}
// Auto-archive runs after the delete pass so newly
// archived chats aren't eligible for deletion this tick.
if chatAutoArchiveDays > 0 {
archiveCutoff := start.Add(-time.Duration(chatAutoArchiveDays) * 24 * time.Hour)
archivedChats, err = tx.AutoArchiveInactiveChats(ctx, database.AutoArchiveInactiveChatsParams{
ArchiveCutoff: archiveCutoff,
LimitCount: i.chatAutoArchiveBatchSize,
})
if err != nil {
return 0, 0, nil, xerrors.Errorf("failed to auto-archive inactive chats: %w", err)
}
}
return purgedChats, purgedChatFiles, archivedChats, nil
}
// dispatchChatAutoArchive audits every archived root chat and enqueues one
// notification per owner covering the roots archived in this tick. Children
// inherit their root's archival decision and are skipped for audit, matching
// the manual archive path (patchChat audits the root only). Enqueue is
// per-tick: owners whose backlog spans multiple ticks receive multiple
// notifications; notification_messages dedupe does not collapse them because
// each tick's payload differs.
//
// auditCtx is detached from the ticker so audits always complete. enqueueCtx
// is the cancellable parent: on shutdown we abandon any remaining digests
// rather than blocking Close.
func (i *instance) dispatchChatAutoArchive(auditCtx, enqueueCtx context.Context, tickStart time.Time, autoArchiveDays, retentionDays int32, archived []database.AutoArchiveInactiveChatsRow) {
// Children inherit their root's archival decision and are skipped
// for both audit and digest. Partition once so the two loops
// cannot drift apart if the cascade shape ever changes.
roots := slice.Filter(archived, func(r database.AutoArchiveInactiveChatsRow) bool {
return !r.ParentChatID.Valid
})
auditor := *i.auditor.Load()
for _, row := range roots {
after := chatFromAutoArchiveRow(i.logger, row)
before := after
before.Archived = false
audit.BackgroundAudit(ctx, &audit.BackgroundAuditParams[database.Chat]{
audit.BackgroundAudit(auditCtx, &audit.BackgroundAuditParams[database.Chat]{
Audit: auditor,
Log: i.logger,
UserID: row.OwnerID,
@@ -450,7 +502,94 @@ func (i *instance) dispatchChatAutoArchive(ctx context.Context, archived []datab
Old: before,
New: after,
Status: http.StatusOK,
AdditionalFields: audit.BackgroundTaskFieldsBytes(ctx, i.logger, audit.BackgroundSubsystemChatAutoArchive),
AdditionalFields: audit.BackgroundTaskFieldsBytes(auditCtx, i.logger, audit.BackgroundSubsystemChatAutoArchive),
})
}
// Group archived roots by owner. Inline because this is the
// only call site and the loop body is self-explanatory.
rootsByOwner := make(map[uuid.UUID][]database.AutoArchiveInactiveChatsRow, len(roots))
for _, row := range roots {
rootsByOwner[row.OwnerID] = append(rootsByOwner[row.OwnerID], row)
}
// Sort owner IDs so shutdown abandons a deterministic tail of the dispatch list.
ownerIDs := make([]uuid.UUID, 0, len(rootsByOwner))
for id := range rootsByOwner {
ownerIDs = append(ownerIDs, id)
}
slices.SortFunc(ownerIDs, func(a, b uuid.UUID) int {
return cmp.Compare(a.String(), b.String())
})
dispatched := 0
for _, ownerID := range ownerIDs {
// Check between iterations so shutdown unblocks promptly. A
// hung in-flight enqueue is unblocked by enqueueCtx propagating
// cancellation into the DB call. Skipped owners are not
// re-notified on the next tick because AutoArchiveInactiveChats
// only returns rows with archived = false; we accept that
// tradeoff over hanging shutdown.
if err := enqueueCtx.Err(); err != nil {
i.logger.Warn(enqueueCtx, "chat auto-archive digest dispatch canceled",
slog.F("remaining_owners", len(ownerIDs)-dispatched),
slog.Error(err))
return
}
dispatched++
ownerRoots := rootsByOwner[ownerID]
data := buildDigestData(ownerRoots, autoArchiveDays, retentionDays, tickStart)
// nolint:gocritic // Background digest runs as the notifier subject.
if _, err := i.enqueuer.EnqueueWithData(
dbauthz.AsNotifier(enqueueCtx),
ownerID,
notifications.TemplateChatAutoArchiveDigest,
map[string]string{},
data,
string(audit.BackgroundSubsystemChatAutoArchive),
); err != nil {
i.logger.Warn(enqueueCtx, "failed to enqueue chat auto-archive digest",
slog.F("owner_id", ownerID),
slog.Error(err))
}
}
}
// buildDigestData builds the notification payload; shape mirrors the
// golden fixtures in coderd/notifications/testdata. Truncation keeps
// the oldest archived roots (created_at ASC from the query) to
// preserve index-driven ordering; revisit if the digest becomes the
// primary surface for reviewing archived chats.
func buildDigestData(rows []database.AutoArchiveInactiveChatsRow, autoArchiveDays, retentionDays int32, tickStart time.Time) map[string]any {
// Cap titles; overflow surfaces as "...and N more" via the template.
overflow := 0
if len(rows) > chatAutoArchiveDigestMaxChats {
overflow = len(rows) - chatAutoArchiveDigestMaxChats
rows = rows[:chatAutoArchiveDigestMaxChats]
}
chats := make([]map[string]any, 0, len(rows))
for _, r := range rows {
chats = append(chats, map[string]any{
"title": r.Title,
"last_activity_humanized": humanize.RelTime(r.LastActivityAt, tickStart, "ago", "from now"),
})
}
// Stringify the int32 config values: the template's
// {{if eq .Data.retention_days "0"}} branch requires both
// operands to share a type, and Go templates do not coerce
// numeric ↔ string. Storing a raw int here would silently
// take the deletion-warning branch on every notification.
data := map[string]any{
"auto_archive_days": strconv.Itoa(int(autoArchiveDays)),
"retention_days": strconv.Itoa(int(retentionDays)),
"archived_chats": chats,
}
if overflow > 0 {
data["additional_archived_count"] = strconv.Itoa(overflow)
}
return data
}
+361 -26
View File
@@ -32,6 +32,9 @@ import (
"github.com/coder/coder/v2/coderd/database/dbrollup"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/notifications"
"github.com/coder/coder/v2/coderd/notifications/notificationsmock"
"github.com/coder/coder/v2/coderd/notifications/notificationstest"
"github.com/coder/coder/v2/coderd/provisionerdserver"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/provisionerd/proto"
@@ -177,6 +180,92 @@ func TestMetrics(t *testing.T) {
})
require.Nil(t, successHist, "should not have success=true metric on failure")
})
// A failed retention read must not block unrelated purges,
// but must skip the chat passes and surface as a failed
// iteration via the metric.
t.Run("FailedChatRetentionRead", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
reg := prometheus.NewRegistry()
clk := quartz.NewMock(t)
now := clk.Now()
clk.Set(now).MustWait(ctx)
ctrl := gomock.NewController(t)
mDB := dbmock.NewMockStore(ctrl)
mDB.EXPECT().GetChatRetentionDays(gomock.Any()).
Return(int32(0), xerrors.New("simulated retention read error")).
MinTimes(1)
// Both reads happen before the bail; InTx still runs
// so unrelated purges commit best-effort.
mDB.EXPECT().GetChatAutoArchiveDays(gomock.Any(), codersdk.DefaultChatAutoArchiveDays).
Return(int32(0), nil).AnyTimes()
mDB.EXPECT().InTx(gomock.Any(), database.DefaultTXOptions().WithID("db_purge")).
Return(nil).MinTimes(1)
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
done := awaitDoTick(ctx, t, clk)
closer := dbpurge.New(ctx, logger, mDB, &codersdk.DeploymentValues{}, reg, nopAuditorPtr(t), dbpurge.WithClock(clk))
defer closer.Close()
testutil.TryReceive(ctx, t, done)
hist := promhelp.HistogramValue(t, reg, "coderd_dbpurge_iteration_duration_seconds", prometheus.Labels{
"success": "false",
})
require.NotNil(t, hist)
require.Greater(t, hist.GetSampleCount(), uint64(0),
"failed retention read must record a failed iteration")
successHist := promhelp.MetricValue(t, reg, "coderd_dbpurge_iteration_duration_seconds", prometheus.Labels{
"success": "true",
})
require.Nil(t, successHist, "should not have success=true metric on retention read failure")
})
// Same contract as FailedChatRetentionRead, but the
// auto-archive read is the half that fails.
t.Run("FailedChatAutoArchiveRead", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
reg := prometheus.NewRegistry()
clk := quartz.NewMock(t)
now := clk.Now()
clk.Set(now).MustWait(ctx)
ctrl := gomock.NewController(t)
mDB := dbmock.NewMockStore(ctrl)
mDB.EXPECT().GetChatRetentionDays(gomock.Any()).Return(int32(30), nil).AnyTimes()
mDB.EXPECT().GetChatAutoArchiveDays(gomock.Any(), codersdk.DefaultChatAutoArchiveDays).
Return(int32(0), xerrors.New("simulated auto-archive read error")).
MinTimes(1)
// InTx still runs so unrelated purges commit; chat
// passes inside the tx are skipped.
mDB.EXPECT().InTx(gomock.Any(), database.DefaultTXOptions().WithID("db_purge")).
Return(nil).MinTimes(1)
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
done := awaitDoTick(ctx, t, clk)
closer := dbpurge.New(ctx, logger, mDB, &codersdk.DeploymentValues{}, reg, nopAuditorPtr(t), dbpurge.WithClock(clk))
defer closer.Close()
testutil.TryReceive(ctx, t, done)
hist := promhelp.HistogramValue(t, reg, "coderd_dbpurge_iteration_duration_seconds", prometheus.Labels{
"success": "false",
})
require.NotNil(t, hist)
require.Greater(t, hist.GetSampleCount(), uint64(0),
"failed auto-archive read must record a failed iteration")
successHist := promhelp.MetricValue(t, reg, "coderd_dbpurge_iteration_duration_seconds", prometheus.Labels{
"success": "true",
})
require.Nil(t, successHist, "should not have success=true metric on auto-archive read failure")
})
}
//nolint:paralleltest // It uses LockIDDBPurge.
@@ -2348,15 +2437,19 @@ func TestAutoArchiveInactiveChats(t *testing.T) {
auditor := audit.NewMock()
auditorPtr := mockAuditorPtr(auditor)
enqueuer := notificationstest.NewFakeEnqueuer()
done := awaitDoTick(ctx, t, clk)
closer := dbpurge.New(ctx, logger, db, &codersdk.DeploymentValues{}, prometheus.NewRegistry(), auditorPtr, dbpurge.WithClock(clk))
closer := dbpurge.New(ctx, logger, db, &codersdk.DeploymentValues{}, prometheus.NewRegistry(), auditorPtr, dbpurge.WithNotificationsEnqueuer(enqueuer), dbpurge.WithClock(clk))
defer closer.Close()
testutil.TryReceive(ctx, t, done)
// Not archived, no audits, no digests.
refreshed, err := db.GetChatByID(ctx, staleChat.ID)
require.NoError(t, err)
require.False(t, refreshed.Archived, "chat should stay active when auto-archive is disabled")
require.Empty(t, auditor.AuditLogs(), "no audit log entries expected")
require.Empty(t, enqueuer.Sent(), "no digest notifications expected")
},
},
{
@@ -2365,7 +2458,10 @@ func TestAutoArchiveInactiveChats(t *testing.T) {
h := newArchiveHarness(t, now)
ctx, clk, db, rawDB, logger, deps := h.ctx, h.clk, h.db, h.rawDB, h.logger, h.deps
// Regression guard: ensure that both auto-archive and retention
// are both set to a distinct non-zero value.
require.NoError(t, db.UpsertChatAutoArchiveDays(ctx, int32(90)))
require.NoError(t, db.UpsertChatRetentionDays(ctx, int32(30)))
// Inactive root: newest message 100 days old.
staleChat := createArchiveChat(ctx, t, db, rawDB, deps, "stale-chat", now.Add(-120*24*time.Hour))
@@ -2377,8 +2473,9 @@ func TestAutoArchiveInactiveChats(t *testing.T) {
auditor := audit.NewMock()
auditorPtr := mockAuditorPtr(auditor)
enqueuer := notificationstest.NewFakeEnqueuer()
done := awaitDoTick(ctx, t, clk)
closer := dbpurge.New(ctx, logger, db, &codersdk.DeploymentValues{}, prometheus.NewRegistry(), auditorPtr, dbpurge.WithClock(clk))
closer := dbpurge.New(ctx, logger, db, &codersdk.DeploymentValues{}, prometheus.NewRegistry(), auditorPtr, dbpurge.WithNotificationsEnqueuer(enqueuer), dbpurge.WithClock(clk))
defer closer.Close()
testutil.TryReceive(ctx, t, done)
@@ -2390,6 +2487,7 @@ func TestAutoArchiveInactiveChats(t *testing.T) {
require.NoError(t, err)
require.False(t, refreshedActive.Archived, "active chat should stay live")
// Exactly one audit entry, for the stale root.
logs := auditor.AuditLogs()
require.Len(t, logs, 1, "expected one audit entry")
require.Equal(t, staleChat.ID, logs[0].ResourceID)
@@ -2397,6 +2495,15 @@ func TestAutoArchiveInactiveChats(t *testing.T) {
require.Equal(t, database.AuditActionWrite, logs[0].Action)
require.Contains(t, string(logs[0].AdditionalFields), "chat_auto_archive",
"audit entry must carry the auto-archive subsystem tag")
// Exactly one digest, addressed to the owner.
sent := enqueuer.Sent()
require.Len(t, sent, 1, "expected one digest notification")
require.Equal(t, notifications.TemplateChatAutoArchiveDigest, sent[0].TemplateID)
require.Equal(t, deps.user.ID, sent[0].UserID)
// Ensure that config-derived fields flow through to payload.
require.Equal(t, "90", sent[0].Data["auto_archive_days"])
require.Equal(t, "30", sent[0].Data["retention_days"])
},
},
{
@@ -2498,8 +2605,9 @@ func TestAutoArchiveInactiveChats(t *testing.T) {
auditor := audit.NewMock()
auditorPtr := mockAuditorPtr(auditor)
enqueuer := notificationstest.NewFakeEnqueuer()
done := awaitDoTick(ctx, t, clk)
closer := dbpurge.New(ctx, logger, db, &codersdk.DeploymentValues{}, prometheus.NewRegistry(), auditorPtr, dbpurge.WithClock(clk))
closer := dbpurge.New(ctx, logger, db, &codersdk.DeploymentValues{}, prometheus.NewRegistry(), auditorPtr, dbpurge.WithNotificationsEnqueuer(enqueuer), dbpurge.WithClock(clk))
defer closer.Close()
testutil.TryReceive(ctx, t, done)
@@ -2512,6 +2620,7 @@ func TestAutoArchiveInactiveChats(t *testing.T) {
require.False(t, refreshedChild.Archived, "child must stay active")
require.Empty(t, auditor.AuditLogs(), "no chats should be archived")
require.Empty(t, enqueuer.Sent(), "no notifications should be sent")
},
},
{
@@ -2552,8 +2661,9 @@ func TestAutoArchiveInactiveChats(t *testing.T) {
auditor := audit.NewMock()
auditorPtr := mockAuditorPtr(auditor)
enqueuer := notificationstest.NewFakeEnqueuer()
done := awaitDoTick(ctx, t, clk)
closer := dbpurge.New(ctx, logger, db, &codersdk.DeploymentValues{}, prometheus.NewRegistry(), auditorPtr, dbpurge.WithClock(clk))
closer := dbpurge.New(ctx, logger, db, &codersdk.DeploymentValues{}, prometheus.NewRegistry(), auditorPtr, dbpurge.WithNotificationsEnqueuer(enqueuer), dbpurge.WithClock(clk))
defer closer.Close()
testutil.TryReceive(ctx, t, done)
@@ -2580,6 +2690,12 @@ func TestAutoArchiveInactiveChats(t *testing.T) {
logs := auditor.AuditLogs()
require.Len(t, logs, 1, "only the completed chat should produce an audit entry")
require.Equal(t, completedChat.ID, logs[0].ResourceID)
// Assert number of sent notifications to catch dispatch regressions.
sent := enqueuer.Sent()
require.Len(t, sent, 1, "expected one digest notification for the completed chat")
require.Equal(t, notifications.TemplateChatAutoArchiveDigest, sent[0].TemplateID)
require.Equal(t, deps.user.ID, sent[0].UserID)
},
},
{
@@ -2608,8 +2724,9 @@ func TestAutoArchiveInactiveChats(t *testing.T) {
auditor := audit.NewMock()
auditorPtr := mockAuditorPtr(auditor)
enqueuer := notificationstest.NewFakeEnqueuer()
done := awaitDoTick(ctx, t, clk)
closer := dbpurge.New(ctx, logger, db, &codersdk.DeploymentValues{}, prometheus.NewRegistry(), auditorPtr, dbpurge.WithClock(clk))
closer := dbpurge.New(ctx, logger, db, &codersdk.DeploymentValues{}, prometheus.NewRegistry(), auditorPtr, dbpurge.WithNotificationsEnqueuer(enqueuer), dbpurge.WithClock(clk))
defer closer.Close()
testutil.TryReceive(ctx, t, done)
@@ -2628,6 +2745,62 @@ func TestAutoArchiveInactiveChats(t *testing.T) {
// One audit entry for the root; the cascaded child is
// not audited individually.
require.Len(t, auditor.AuditLogs(), 1)
// Digest should list only the root (one row).
sent := enqueuer.Sent()
require.Len(t, sent, 1)
data := sent[0].Data
require.NotNil(t, data)
chats, ok := data["archived_chats"].([]map[string]any)
require.True(t, ok, "archived_chats should be []map[string]any")
require.Len(t, chats, 1, "digest should only list the root")
require.Equal(t, "root-chat", chats[0]["title"])
},
},
{
name: "DigestOverflowCap",
run: func(t *testing.T) {
// 27 inactive roots exceed chatAutoArchiveDigestMaxChats
// (25). All 27 should archive, but the digest payload
// lists at most 25 titles and surfaces the rest via
// additional_archived_count so the template can render
// "...and N more".
h := newArchiveHarness(t, now)
ctx, clk, db, rawDB, logger, deps := h.ctx, h.clk, h.db, h.rawDB, h.logger, h.deps
require.NoError(t, db.UpsertChatAutoArchiveDays(ctx, int32(30)))
const total = 27
for i := range total {
createArchiveChat(ctx, t, db, rawDB, deps,
fmt.Sprintf("stale-%02d", i),
now.Add(-60*24*time.Hour))
}
auditor := audit.NewMock()
auditorPtr := mockAuditorPtr(auditor)
enqueuer := notificationstest.NewFakeEnqueuer()
done := awaitDoTick(ctx, t, clk)
closer := dbpurge.New(ctx, logger, db, &codersdk.DeploymentValues{}, prometheus.NewRegistry(), auditorPtr, dbpurge.WithNotificationsEnqueuer(enqueuer), dbpurge.WithClock(clk))
defer closer.Close()
testutil.TryReceive(ctx, t, done)
// All 27 roots archived (one audit each).
require.Len(t, auditor.AuditLogs(), total)
sent := enqueuer.Sent()
require.Len(t, sent, 1, "one digest per owner")
chats, ok := sent[0].Data["archived_chats"].([]map[string]any)
require.True(t, ok, "archived_chats should be []map[string]any")
require.Len(t, chats, 25, "digest caps titles at 25")
require.Equal(t, "2", sent[0].Data["additional_archived_count"],
"overflow count is total - cap")
// Humanized timestamp is computed from LastActivityAt
// and the tick-start time, not a static fixture, so we
// only assert the suffix the humanizer emits.
humanized, _ := chats[0]["last_activity_humanized"].(string)
require.Contains(t, humanized, "ago",
"last_activity_humanized should be a past relative time")
},
},
{
@@ -2640,8 +2813,8 @@ func TestAutoArchiveInactiveChats(t *testing.T) {
require.NoError(t, db.UpsertChatAutoArchiveDays(ctx, int32(30)))
// Two stale roots per owner, backdated well past the
// 30-day cutoff.
// Two stale roots per owner, backdated well past
// the 30-day cutoff.
u1Deps := deps
u2Deps := chatAutoArchiveDeps{user: user2, org: deps.org, modelConfig: deps.modelConfig}
createArchiveChat(ctx, t, db, rawDB, u1Deps, "u1-a", now.Add(-60*24*time.Hour))
@@ -2651,22 +2824,45 @@ func TestAutoArchiveInactiveChats(t *testing.T) {
auditor := audit.NewMock()
auditorPtr := mockAuditorPtr(auditor)
enqueuer := notificationstest.NewFakeEnqueuer()
done := awaitDoTick(ctx, t, clk)
closer := dbpurge.New(ctx, logger, db, &codersdk.DeploymentValues{}, prometheus.NewRegistry(), auditorPtr, dbpurge.WithClock(clk))
closer := dbpurge.New(ctx, logger, db, &codersdk.DeploymentValues{}, prometheus.NewRegistry(), auditorPtr, dbpurge.WithNotificationsEnqueuer(enqueuer), dbpurge.WithClock(clk))
defer closer.Close()
testutil.TryReceive(ctx, t, done)
// Four audit rows, one per archived root. Each entry
// carries the owning UserID so downstream consumers can
// Four audit rows, one per archived root, attributed
// to the owning user so downstream consumers can
// correlate per-owner activity.
logs := auditor.AuditLogs()
require.Len(t, logs, 4)
byUser := map[uuid.UUID]int{}
auditsByUser := map[uuid.UUID]int{}
for _, l := range logs {
byUser[l.UserID]++
auditsByUser[l.UserID]++
}
require.Equal(t, 2, byUser[deps.user.ID])
require.Equal(t, 2, byUser[user2.ID])
require.Equal(t, 2, auditsByUser[deps.user.ID])
require.Equal(t, 2, auditsByUser[user2.ID])
// One digest per owner, each listing only that owner's
// two chats.
sent := enqueuer.Sent()
require.Len(t, sent, 2, "expected one digest per owner")
byUser := map[uuid.UUID][]string{}
for _, s := range sent {
require.Equal(t, notifications.TemplateChatAutoArchiveDigest, s.TemplateID)
chats, ok := s.Data["archived_chats"].([]map[string]any)
require.True(t, ok, "archived_chats should be []map[string]any")
for _, c := range chats {
title, _ := c["title"].(string)
byUser[s.UserID] = append(byUser[s.UserID], title)
}
}
require.Contains(t, byUser, deps.user.ID)
require.Contains(t, byUser, user2.ID)
slices.Sort(byUser[deps.user.ID])
slices.Sort(byUser[user2.ID])
require.Equal(t, []string{"u1-a", "u1-b"}, byUser[deps.user.ID])
require.Equal(t, []string{"u2-a", "u2-b"}, byUser[user2.ID])
},
},
{
@@ -2683,8 +2879,9 @@ func TestAutoArchiveInactiveChats(t *testing.T) {
auditor := audit.NewMock()
auditorPtr := mockAuditorPtr(auditor)
enqueuer := notificationstest.NewFakeEnqueuer()
driver := newTickDriver(t, clk)
closer := dbpurge.New(ctx, logger, db, &codersdk.DeploymentValues{}, prometheus.NewRegistry(), auditorPtr, dbpurge.WithClock(clk))
closer := dbpurge.New(ctx, logger, db, &codersdk.DeploymentValues{}, prometheus.NewRegistry(), auditorPtr, dbpurge.WithNotificationsEnqueuer(enqueuer), dbpurge.WithClock(clk))
// Defer driver.close() after closer.Close(): defers
// run LIFO, so this frees shutdown's ticker.Stop()
// before the dbpurge goroutine blocks on it.
@@ -2692,8 +2889,9 @@ func TestAutoArchiveInactiveChats(t *testing.T) {
defer driver.close()
driver.awaitInitial(ctx, t)
// Tick 1: both archived.
// Tick 1: both archived, one digest.
require.Len(t, auditor.AuditLogs(), 2, "tick 1 audits")
require.Len(t, enqueuer.Sent(), 1, "tick 1 digests")
// Seed a third stale root between ticks so tick 2 has
// genuine work and we can distinguish "ignored already
@@ -2702,11 +2900,17 @@ func TestAutoArchiveInactiveChats(t *testing.T) {
driver.awaitNext(ctx, t)
// Tick 2: exactly one new audit for the third chat;
// tick 1's rows must not be re-archived.
// Tick 2: exactly one new audit + one new digest for
// the third chat; tick 1's rows must not be re-archived.
require.Len(t, auditor.AuditLogs(), 3, "tick 2 cumulative audits")
sent := enqueuer.Sent()
require.Len(t, sent, 2, "tick 2 cumulative digests")
chats, ok := sent[1].Data["archived_chats"].([]map[string]any)
require.True(t, ok, "archived_chats should be []map[string]any")
require.Len(t, chats, 1, "tick 2 digest lists only the new chat")
require.Equal(t, "second-c", chats[0]["title"])
// All three chats should remain archived.
// First-tick chats stayed archived.
for _, id := range []uuid.UUID{firstA.ID, firstB.ID, third.ID} {
refreshed, err := db.GetChatByID(ctx, id)
require.NoError(t, err)
@@ -2718,10 +2922,18 @@ func TestAutoArchiveInactiveChats(t *testing.T) {
name: "BatchSizePagination",
run: func(t *testing.T) {
// With 27 stale roots and batch size 20, tick 1
// archives 20, tick 2 archives the remaining 7, tick 3
// archives none. We assert the audit dispatch follows
// the same pattern: no dispatch runs when rows == 0,
// so tick 3 emits no new audits.
// archives 20, tick 2 archives the remaining 7, and
// tick 3 archives none. We assert the dispatch side
// effects (audits, digests) follow the same pattern:
// dispatch only runs when rows > 0, so tick 3 emits
// no new audits or digests.
//
// The two-digest count asserted here is a consequence
// of the per-tick enqueue model, not a product
// invariant. notification_messages dedupe does not
// collapse these because each tick's payload differs.
// If enqueue is ever restructured to one notification
// per owner per day, this assertion changes with it.
h := newArchiveHarness(t, now)
ctx, clk, db, rawDB, logger, deps := h.ctx, h.clk, h.db, h.rawDB, h.logger, h.deps
@@ -2736,8 +2948,9 @@ func TestAutoArchiveInactiveChats(t *testing.T) {
auditor := audit.NewMock()
auditorPtr := mockAuditorPtr(auditor)
enqueuer := notificationstest.NewFakeEnqueuer()
driver := newTickDriver(t, clk)
closer := dbpurge.New(ctx, logger, db, &codersdk.DeploymentValues{}, prometheus.NewRegistry(), auditorPtr, dbpurge.WithClock(clk), dbpurge.WithChatAutoArchiveBatchSize(20))
closer := dbpurge.New(ctx, logger, db, &codersdk.DeploymentValues{}, prometheus.NewRegistry(), auditorPtr, dbpurge.WithNotificationsEnqueuer(enqueuer), dbpurge.WithClock(clk), dbpurge.WithChatAutoArchiveBatchSize(20))
// Defer driver.close() after closer.Close() so trap
// cleanup frees shutdown's ticker.Stop() before the
// dbpurge goroutine blocks on it.
@@ -2745,15 +2958,137 @@ func TestAutoArchiveInactiveChats(t *testing.T) {
defer driver.close()
driver.awaitInitial(ctx, t)
// Tick 1: first batch (20) archived.
require.Len(t, auditor.AuditLogs(), 20, "tick 1 audits")
sent := enqueuer.Sent()
require.Len(t, sent, 1, "tick 1 digests")
chats1, ok := sent[0].Data["archived_chats"].([]map[string]any)
require.True(t, ok, "archived_chats should be []map[string]any")
require.Len(t, chats1, 20, "tick 1 digest lists all 20 titles")
require.NotContains(t, sent[0].Data, "additional_archived_count",
"no overflow when batch <= digest cap; 20 <= 25")
driver.awaitNext(ctx, t)
// Tick 2: remaining 7 archived.
require.Len(t, auditor.AuditLogs(), 27, "tick 2 cumulative audits")
sent = enqueuer.Sent()
require.Len(t, sent, 2, "tick 2 cumulative digests")
chats2, ok := sent[1].Data["archived_chats"].([]map[string]any)
require.True(t, ok, "archived_chats should be []map[string]any")
require.Len(t, chats2, 7, "tick 2 digest lists remaining 7")
driver.awaitNext(ctx, t)
// Tick 3: nothing left to archive; dispatch is gated
// on len(archivedChats) > 0 so no new audits.
// Tick 3: nothing left to archive. The dispatch is
// gated on len(archivedChats) > 0, so no new audits
// or digests are produced. If that gate is ever
// removed, update this assertion intentionally.
require.Len(t, auditor.AuditLogs(), 27, "tick 3 cumulative audits unchanged")
require.Len(t, enqueuer.Sent(), 2, "tick 3 cumulative digests unchanged")
},
},
{
name: "ShutdownCancelsDigestDispatch",
run: func(t *testing.T) {
// Two owners with one stale root each. The first
// EnqueueWithData call blocks until ctx is canceled.
// Closing the purger must propagate cancellation
// into the in-flight call and short-circuit the
// rest of the loop, so Close returns promptly
// instead of hanging on dispatch.
h := newArchiveHarness(t, now)
ctx, clk, db, rawDB, logger, deps := h.ctx, h.clk, h.db, h.rawDB, h.logger, h.deps
user2 := dbgen.User(t, db, database.User{})
_ = dbgen.OrganizationMember(t, db, database.OrganizationMember{UserID: user2.ID, OrganizationID: deps.org.ID})
require.NoError(t, db.UpsertChatAutoArchiveDays(ctx, int32(30)))
u1Deps := deps
u2Deps := chatAutoArchiveDeps{user: user2, org: deps.org, modelConfig: deps.modelConfig}
createArchiveChat(ctx, t, db, rawDB, u1Deps, "u1-stale", now.Add(-60*24*time.Hour))
createArchiveChat(ctx, t, db, rawDB, u2Deps, "u2-stale", now.Add(-60*24*time.Hour))
// Dispatch iterates owner IDs in ascending UUID order (convention).
expectedFirst := deps.user.ID
if user2.ID.String() < deps.user.ID.String() {
expectedFirst = user2.ID
}
ctrl := gomock.NewController(t)
mockEnq := notificationsmock.NewMockEnqueuer(ctrl)
started := make(chan struct{})
mockEnq.EXPECT().EnqueueWithData(gomock.Any(), gomock.Eq(expectedFirst), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
DoAndReturn(func(ctx context.Context, _, _ uuid.UUID, _ map[string]string, _ map[string]any, _ string, _ ...uuid.UUID) ([]uuid.UUID, error) {
close(started)
<-ctx.Done()
return nil, ctx.Err()
}).Times(1)
closer := dbpurge.New(ctx, logger, db, &codersdk.DeploymentValues{}, prometheus.NewRegistry(), nopAuditorPtr(t), dbpurge.WithNotificationsEnqueuer(mockEnq), dbpurge.WithClock(clk))
// Wait for the forced initial tick to reach the first
// enqueue, which then blocks on ctx.Done().
testutil.TryReceive(ctx, t, started)
// Blocked enqueue receives ctx cancellation via the parent context.
// Loop-head check abandons the remaining owner instead of trying to enqueue.
done := make(chan error)
go func() { done <- closer.Close() }()
testutil.RequireReceive(ctx, t, done)
},
},
{
// A transient enqueue failure for one owner must not abort the dispatch loop.
name: "TransientEnqueueFailureDoesNotAbortLoop",
run: func(t *testing.T) {
h := newArchiveHarness(t, now)
ctx, clk, db, rawDB, logger, deps := h.ctx, h.clk, h.db, h.rawDB, h.logger, h.deps
user2 := dbgen.User(t, db, database.User{})
_ = dbgen.OrganizationMember(t, db, database.OrganizationMember{UserID: user2.ID, OrganizationID: deps.org.ID})
require.NoError(t, db.UpsertChatAutoArchiveDays(ctx, int32(30)))
u1Deps := deps
u2Deps := chatAutoArchiveDeps{user: user2, org: deps.org, modelConfig: deps.modelConfig}
createArchiveChat(ctx, t, db, rawDB, u1Deps, "u1-stale", now.Add(-60*24*time.Hour))
createArchiveChat(ctx, t, db, rawDB, u2Deps, "u2-stale", now.Add(-60*24*time.Hour))
auditor := audit.NewMock()
auditorPtr := mockAuditorPtr(auditor)
ctrl := gomock.NewController(t)
mockEnq := notificationsmock.NewMockEnqueuer(ctrl)
var calls atomic.Int32
var successUserID uuid.UUID
mockEnq.EXPECT().EnqueueWithData(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
DoAndReturn(func(_ context.Context, userID, _ uuid.UUID, _ map[string]string, _ map[string]any, _ string, _ ...uuid.UUID) ([]uuid.UUID, error) {
if calls.Add(1) == 1 {
return nil, xerrors.New("simulated transient enqueue failure")
}
successUserID = userID
return nil, nil
}).Times(2)
done := awaitDoTick(ctx, t, clk)
closer := dbpurge.New(ctx, logger, db, &codersdk.DeploymentValues{}, prometheus.NewRegistry(), auditorPtr, dbpurge.WithNotificationsEnqueuer(mockEnq), dbpurge.WithClock(clk))
defer closer.Close()
testutil.TryReceive(ctx, t, done)
// Both owners must have been audited regardless of
// digest enqueue outcomes; the audit and digest
// paths are independent.
require.Len(t, auditor.AuditLogs(), 2, "both archived roots must be audited")
// gomock's .Times(2) already enforces both calls
// happened; this assertion makes the contract
// explicit at the test site.
require.Equal(t, int32(2), calls.Load(),
"loop must attempt every owner even when one fails")
// The second attempt succeeded for one of the two owners.
require.Contains(t, []uuid.UUID{deps.user.ID, user2.ID}, successUserID,
"successful digest must belong to one of the two owners")
},
},
}
@@ -0,0 +1 @@
DELETE FROM notification_templates WHERE id = '764031be-4863-4220-867b-6ce1a1b7a5f5';
@@ -0,0 +1,34 @@
-- Template for the per-owner chat auto-archive notification. Enqueue is
-- per-tick (see dbpurge.dispatchChatAutoArchive): owners whose backlog
-- spans multiple ticks receive multiple notifications, and
-- notification_messages dedupe does not collapse them because each
-- tick's payload differs. Users who find this noisy can disable the
-- template from their notification preferences. The SMTP/webhook
-- wrappers prepend "Hi {{.UserName}},", so body_template must not.
INSERT INTO notification_templates (
id,
name,
title_template,
body_template,
actions,
"group",
method,
kind,
enabled_by_default
)
VALUES (
'764031be-4863-4220-867b-6ce1a1b7a5f5',
'Chats Auto-Archived',
E'Chats auto-archived after {{.Data.auto_archive_days}} days of inactivity',
E'The following chats were automatically archived:\n\n{{range .Data.archived_chats}}* "{{.title}}" (last active {{.last_activity_humanized}})\n{{end}}{{with .Data.additional_archived_count}}\n...and {{.}} more.\n\n{{end}}\n{{if eq .Data.retention_days "0"}}You can restore any of them from the Agents page; archived chats are kept indefinitely.{{else}}You can restore any of them from the Agents page within {{.Data.retention_days}} days, after which they will be permanently deleted.{{end}}',
'[
{
"label": "View chats",
"url": "{{base_url}}/agents?archived=archived"
}
]'::jsonb,
'Chat Events',
NULL,
'system'::notification_template_kind,
true
);
+2
View File
@@ -64,6 +64,8 @@ type sqlcQuerier interface {
// Archives inactive root chats (pinned and already-archived chats skipped),
// cascading to children via root_chat_id. Limits apply to roots, not total
// rows. Used by dbpurge.
// created_at ASC flows through to dbpurge's digest truncation; see
// buildDigestData in dbpurge.go for the tradeoff rationale.
AutoArchiveInactiveChats(ctx context.Context, arg AutoArchiveInactiveChatsParams) ([]AutoArchiveInactiveChatsRow, error)
BackoffChatDiffStatus(ctx context.Context, arg BackoffChatDiffStatusParams) error
BatchUpdateWorkspaceAgentMetadata(ctx context.Context, arg BatchUpdateWorkspaceAgentMetadataParams) error
+2
View File
@@ -5419,6 +5419,8 @@ type AutoArchiveInactiveChatsRow struct {
// Archives inactive root chats (pinned and already-archived chats skipped),
// cascading to children via root_chat_id. Limits apply to roots, not total
// rows. Used by dbpurge.
// created_at ASC flows through to dbpurge's digest truncation; see
// buildDigestData in dbpurge.go for the tradeoff rationale.
func (q *sqlQuerier) AutoArchiveInactiveChats(ctx context.Context, arg AutoArchiveInactiveChatsParams) ([]AutoArchiveInactiveChatsRow, error) {
rows, err := q.db.QueryContext(ctx, autoArchiveInactiveChats, arg.ArchiveCutoff, arg.LimitCount)
if err != nil {
+2
View File
@@ -1482,4 +1482,6 @@ SELECT
)::timestamptz AS last_activity_at
FROM archived a
LEFT JOIN to_archive t ON t.id = a.id
-- created_at ASC flows through to dbpurge's digest truncation; see
-- buildDigestData in dbpurge.go for the tradeoff rationale.
ORDER BY (a.root_chat_id IS NULL) DESC, a.owner_id ASC, a.created_at ASC, a.id ASC;
+3
View File
@@ -54,6 +54,9 @@ var fallbackIcons = map[uuid.UUID]string{
notifications.TemplateTemplateDeleted: codersdk.InboxNotificationFallbackIconTemplate,
notifications.TemplateTemplateDeprecated: codersdk.InboxNotificationFallbackIconTemplate,
notifications.TemplateWorkspaceBuildsFailedReport: codersdk.InboxNotificationFallbackIconTemplate,
// chat related notifications
notifications.TemplateChatAutoArchiveDigest: codersdk.InboxNotificationFallbackIconOther,
}
func ensureNotificationIcon(notif codersdk.InboxNotification) codersdk.InboxNotification {
+5
View File
@@ -62,3 +62,8 @@ var (
TemplateTaskPaused = uuid.MustParse("2a74f3d3-ab09-4123-a4a5-ca238f4f65a1")
TemplateTaskResumed = uuid.MustParse("843ee9c3-a8fb-4846-afa9-977bec578649")
)
// Chat-related events.
var (
TemplateChatAutoArchiveDigest = uuid.MustParse("764031be-4863-4220-867b-6ce1a1b7a5f5")
)
@@ -1332,6 +1332,89 @@ func TestNotificationTemplates_Golden(t *testing.T) {
Data: map[string]any{},
},
},
{
// Default branch: multiple visible chats, retention enabled,
// no overflow. Body phrasing is number-neutral so this also
// covers the n>1 grammar shape without a dedicated branch in
// the template.
name: "TemplateChatAutoArchiveDigest",
id: notifications.TemplateChatAutoArchiveDigest,
payload: types.MessagePayload{
UserName: "Bobby",
UserEmail: "bobby@coder.com",
UserUsername: "bobby",
Labels: map[string]string{},
Data: map[string]any{
"auto_archive_days": "90",
"retention_days": "30",
"archived_chats": []map[string]any{
{"title": "Onboarding kickoff", "last_activity_humanized": "3 months ago"},
{"title": "Quarterly planning draft", "last_activity_humanized": "4 months ago"},
},
},
},
},
{
// Pins the n=1 rendering so future edits to the body cannot
// reintroduce a count-conditional that breaks the singular
// case. The list-introduction sentence and retention sentence
// both use plural-form pronouns ("them", "they") that read
// naturally for a single item.
name: "TemplateChatAutoArchiveDigestSingular",
id: notifications.TemplateChatAutoArchiveDigest,
payload: types.MessagePayload{
UserName: "Bobby",
UserEmail: "bobby@coder.com",
UserUsername: "bobby",
Labels: map[string]string{},
Data: map[string]any{
"auto_archive_days": "90",
"retention_days": "30",
"archived_chats": []map[string]any{
{"title": "Onboarding kickoff", "last_activity_humanized": "3 months ago"},
},
},
},
},
{
// Covers the retention_days="0" indefinite-retention branch.
name: "TemplateChatAutoArchiveDigestRetentionZero",
id: notifications.TemplateChatAutoArchiveDigest,
payload: types.MessagePayload{
UserName: "Bobby",
UserEmail: "bobby@coder.com",
UserUsername: "bobby",
Labels: map[string]string{},
Data: map[string]any{
"auto_archive_days": "90",
"retention_days": "0",
"archived_chats": []map[string]any{
{"title": "Onboarding kickoff", "last_activity_humanized": "3 months ago"},
{"title": "Quarterly planning draft", "last_activity_humanized": "4 months ago"},
},
},
},
},
{
// Covers the additional_archived_count overflow sentence.
name: "TemplateChatAutoArchiveDigestOverflow",
id: notifications.TemplateChatAutoArchiveDigest,
payload: types.MessagePayload{
UserName: "Bobby",
UserEmail: "bobby@coder.com",
UserUsername: "bobby",
Labels: map[string]string{},
Data: map[string]any{
"auto_archive_days": "90",
"retention_days": "30",
"archived_chats": []map[string]any{
{"title": "Onboarding kickoff", "last_activity_humanized": "3 months ago"},
{"title": "Quarterly planning draft", "last_activity_humanized": "4 months ago"},
},
"additional_archived_count": "6",
},
},
},
}
// We must have a test case for every notification_template. This is enforced below:
@@ -0,0 +1,5 @@
// Package notificationsmock contains a mocked implementation of the
// notifications.Enqueuer interface for use in tests.
package notificationsmock
//go:generate mockgen -destination ./notificationsmock.go -package notificationsmock github.com/coder/coder/v2/coderd/notifications Enqueuer
@@ -0,0 +1,82 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: github.com/coder/coder/v2/coderd/notifications (interfaces: Enqueuer)
//
// Generated by this command:
//
// mockgen -destination ./notificationsmock.go -package notificationsmock github.com/coder/coder/v2/coderd/notifications Enqueuer
//
// Package notificationsmock is a generated GoMock package.
package notificationsmock
import (
context "context"
reflect "reflect"
uuid "github.com/google/uuid"
gomock "go.uber.org/mock/gomock"
)
// MockEnqueuer is a mock of Enqueuer interface.
type MockEnqueuer struct {
ctrl *gomock.Controller
recorder *MockEnqueuerMockRecorder
isgomock struct{}
}
// MockEnqueuerMockRecorder is the mock recorder for MockEnqueuer.
type MockEnqueuerMockRecorder struct {
mock *MockEnqueuer
}
// NewMockEnqueuer creates a new mock instance.
func NewMockEnqueuer(ctrl *gomock.Controller) *MockEnqueuer {
mock := &MockEnqueuer{ctrl: ctrl}
mock.recorder = &MockEnqueuerMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockEnqueuer) EXPECT() *MockEnqueuerMockRecorder {
return m.recorder
}
// Enqueue mocks base method.
func (m *MockEnqueuer) Enqueue(ctx context.Context, userID, templateID uuid.UUID, labels map[string]string, createdBy string, targets ...uuid.UUID) ([]uuid.UUID, error) {
m.ctrl.T.Helper()
varargs := []any{ctx, userID, templateID, labels, createdBy}
for _, a := range targets {
varargs = append(varargs, a)
}
ret := m.ctrl.Call(m, "Enqueue", varargs...)
ret0, _ := ret[0].([]uuid.UUID)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// Enqueue indicates an expected call of Enqueue.
func (mr *MockEnqueuerMockRecorder) Enqueue(ctx, userID, templateID, labels, createdBy any, targets ...any) *gomock.Call {
mr.mock.ctrl.T.Helper()
varargs := append([]any{ctx, userID, templateID, labels, createdBy}, targets...)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Enqueue", reflect.TypeOf((*MockEnqueuer)(nil).Enqueue), varargs...)
}
// EnqueueWithData mocks base method.
func (m *MockEnqueuer) EnqueueWithData(ctx context.Context, userID, templateID uuid.UUID, labels map[string]string, data map[string]any, createdBy string, targets ...uuid.UUID) ([]uuid.UUID, error) {
m.ctrl.T.Helper()
varargs := []any{ctx, userID, templateID, labels, data, createdBy}
for _, a := range targets {
varargs = append(varargs, a)
}
ret := m.ctrl.Call(m, "EnqueueWithData", varargs...)
ret0, _ := ret[0].([]uuid.UUID)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// EnqueueWithData indicates an expected call of EnqueueWithData.
func (mr *MockEnqueuerMockRecorder) EnqueueWithData(ctx, userID, templateID, labels, data, createdBy any, targets ...any) *gomock.Call {
mr.mock.ctrl.T.Helper()
varargs := append([]any{ctx, userID, templateID, labels, data, createdBy}, targets...)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EnqueueWithData", reflect.TypeOf((*MockEnqueuer)(nil).EnqueueWithData), varargs...)
}
@@ -0,0 +1,92 @@
From: system@coder.com
To: bobby@coder.com
Subject: Chats auto-archived after 90 days of inactivity
Message-Id: 02ee4935-73be-4fa1-a290-ff9999026b13@blush-whale-48
Date: Fri, 11 Oct 2024 09:03:06 +0000
Content-Type: multipart/alternative; boundary=bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4
MIME-Version: 1.0
--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4
Content-Transfer-Encoding: quoted-printable
Content-Type: text/plain; charset=UTF-8
Hi Bobby,
The following chats were automatically archived:
"Onboarding kickoff" (last active 3 months ago)
"Quarterly planning draft" (last active 4 months ago)
You can restore any of them from the Agents page within 30 days, after whic=
h they will be permanently deleted.
View chats: http://test.com/agents?archived=3Darchived
--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4
Content-Transfer-Encoding: quoted-printable
Content-Type: text/html; charset=UTF-8
<!doctype html>
<html lang=3D"en">
<head>
<meta charset=3D"UTF-8" />
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
=3D1.0" />
<title>Chats auto-archived after 90 days of inactivity</title>
</head>
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
l', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; color: #020617=
; background: #f8fafc;">
<div style=3D"max-width: 600px; margin: 20px auto; padding: 60px; borde=
r: 1px solid #e2e8f0; border-radius: 8px; background-color: #fff; text-alig=
n: left; font-size: 14px; line-height: 1.5;">
<div style=3D"text-align: center;">
<img src=3D"https://coder.com/coder-logo-horizontal.png" alt=3D"Cod=
er Logo" style=3D"height: 40px;" />
</div>
<h1 style=3D"text-align: center; font-size: 24px; font-weight: 400; m=
argin: 8px 0 32px; line-height: 1.5;">
Chats auto-archived after 90 days of inactivity
</h1>
<div style=3D"line-height: 1.5;">
<p>Hi Bobby,</p>
<p>The following chats were automatically archived:</p>
<ul>
<li>&ldquo;Onboarding kickoff&rdquo; (last active 3 months ago)<br>
</li>
<li>&ldquo;Quarterly planning draft&rdquo; (last active 4 months ago)<br>
</li>
</ul>
<p>You can restore any of them from the Agents page within 30 days, after w=
hich they will be permanently deleted.</p>
</div>
<div style=3D"text-align: center; margin-top: 32px;">
=20
<a href=3D"http://test.com/agents?archived=3Darchived" style=3D"dis=
play: inline-block; padding: 13px 24px; background-color: #020617; color: #=
f8fafc; text-decoration: none; border-radius: 8px; margin: 0 4px;">
View chats
</a>
=20
</div>
<div style=3D"border-top: 1px solid #e2e8f0; color: #475569; font-siz=
e: 12px; margin-top: 64px; padding-top: 24px; line-height: 1.6;">
<p>&copy;&nbsp;2024&nbsp;Coder. All rights reserved&nbsp;-&nbsp;<a =
href=3D"http://test.com" style=3D"color: #2563eb; text-decoration: none;">h=
ttp://test.com</a></p>
<p><a href=3D"http://test.com/settings/notifications" style=3D"colo=
r: #2563eb; text-decoration: none;">Click here to manage your notification =
settings</a></p>
<p><a href=3D"http://test.com/settings/notifications?disabled=3D764=
031be-4863-4220-867b-6ce1a1b7a5f5" style=3D"color: #2563eb; text-decoration=
: none;">Stop receiving emails like this</a></p>
</div>
</div>
</body>
</html>
--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4--
@@ -0,0 +1,96 @@
From: system@coder.com
To: bobby@coder.com
Subject: Chats auto-archived after 90 days of inactivity
Message-Id: 02ee4935-73be-4fa1-a290-ff9999026b13@blush-whale-48
Date: Fri, 11 Oct 2024 09:03:06 +0000
Content-Type: multipart/alternative; boundary=bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4
MIME-Version: 1.0
--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4
Content-Transfer-Encoding: quoted-printable
Content-Type: text/plain; charset=UTF-8
Hi Bobby,
The following chats were automatically archived:
"Onboarding kickoff" (last active 3 months ago)
"Quarterly planning draft" (last active 4 months ago)
...and 6 more.
You can restore any of them from the Agents page within 30 days, after whic=
h they will be permanently deleted.
View chats: http://test.com/agents?archived=3Darchived
--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4
Content-Transfer-Encoding: quoted-printable
Content-Type: text/html; charset=UTF-8
<!doctype html>
<html lang=3D"en">
<head>
<meta charset=3D"UTF-8" />
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
=3D1.0" />
<title>Chats auto-archived after 90 days of inactivity</title>
</head>
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
l', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; color: #020617=
; background: #f8fafc;">
<div style=3D"max-width: 600px; margin: 20px auto; padding: 60px; borde=
r: 1px solid #e2e8f0; border-radius: 8px; background-color: #fff; text-alig=
n: left; font-size: 14px; line-height: 1.5;">
<div style=3D"text-align: center;">
<img src=3D"https://coder.com/coder-logo-horizontal.png" alt=3D"Cod=
er Logo" style=3D"height: 40px;" />
</div>
<h1 style=3D"text-align: center; font-size: 24px; font-weight: 400; m=
argin: 8px 0 32px; line-height: 1.5;">
Chats auto-archived after 90 days of inactivity
</h1>
<div style=3D"line-height: 1.5;">
<p>Hi Bobby,</p>
<p>The following chats were automatically archived:</p>
<ul>
<li>&ldquo;Onboarding kickoff&rdquo; (last active 3 months ago)<br>
</li>
<li>&ldquo;Quarterly planning draft&rdquo; (last active 4 months ago)<br>
</li>
</ul>
<p>&hellip;and 6 more.</p>
<p>You can restore any of them from the Agents page within 30 days, after w=
hich they will be permanently deleted.</p>
</div>
<div style=3D"text-align: center; margin-top: 32px;">
=20
<a href=3D"http://test.com/agents?archived=3Darchived" style=3D"dis=
play: inline-block; padding: 13px 24px; background-color: #020617; color: #=
f8fafc; text-decoration: none; border-radius: 8px; margin: 0 4px;">
View chats
</a>
=20
</div>
<div style=3D"border-top: 1px solid #e2e8f0; color: #475569; font-siz=
e: 12px; margin-top: 64px; padding-top: 24px; line-height: 1.6;">
<p>&copy;&nbsp;2024&nbsp;Coder. All rights reserved&nbsp;-&nbsp;<a =
href=3D"http://test.com" style=3D"color: #2563eb; text-decoration: none;">h=
ttp://test.com</a></p>
<p><a href=3D"http://test.com/settings/notifications" style=3D"colo=
r: #2563eb; text-decoration: none;">Click here to manage your notification =
settings</a></p>
<p><a href=3D"http://test.com/settings/notifications?disabled=3D764=
031be-4863-4220-867b-6ce1a1b7a5f5" style=3D"color: #2563eb; text-decoration=
: none;">Stop receiving emails like this</a></p>
</div>
</div>
</body>
</html>
--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4--
@@ -0,0 +1,92 @@
From: system@coder.com
To: bobby@coder.com
Subject: Chats auto-archived after 90 days of inactivity
Message-Id: 02ee4935-73be-4fa1-a290-ff9999026b13@blush-whale-48
Date: Fri, 11 Oct 2024 09:03:06 +0000
Content-Type: multipart/alternative; boundary=bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4
MIME-Version: 1.0
--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4
Content-Transfer-Encoding: quoted-printable
Content-Type: text/plain; charset=UTF-8
Hi Bobby,
The following chats were automatically archived:
"Onboarding kickoff" (last active 3 months ago)
"Quarterly planning draft" (last active 4 months ago)
You can restore any of them from the Agents page; archived chats are kept i=
ndefinitely.
View chats: http://test.com/agents?archived=3Darchived
--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4
Content-Transfer-Encoding: quoted-printable
Content-Type: text/html; charset=UTF-8
<!doctype html>
<html lang=3D"en">
<head>
<meta charset=3D"UTF-8" />
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
=3D1.0" />
<title>Chats auto-archived after 90 days of inactivity</title>
</head>
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
l', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; color: #020617=
; background: #f8fafc;">
<div style=3D"max-width: 600px; margin: 20px auto; padding: 60px; borde=
r: 1px solid #e2e8f0; border-radius: 8px; background-color: #fff; text-alig=
n: left; font-size: 14px; line-height: 1.5;">
<div style=3D"text-align: center;">
<img src=3D"https://coder.com/coder-logo-horizontal.png" alt=3D"Cod=
er Logo" style=3D"height: 40px;" />
</div>
<h1 style=3D"text-align: center; font-size: 24px; font-weight: 400; m=
argin: 8px 0 32px; line-height: 1.5;">
Chats auto-archived after 90 days of inactivity
</h1>
<div style=3D"line-height: 1.5;">
<p>Hi Bobby,</p>
<p>The following chats were automatically archived:</p>
<ul>
<li>&ldquo;Onboarding kickoff&rdquo; (last active 3 months ago)<br>
</li>
<li>&ldquo;Quarterly planning draft&rdquo; (last active 4 months ago)<br>
</li>
</ul>
<p>You can restore any of them from the Agents page; archived chats are kep=
t indefinitely.</p>
</div>
<div style=3D"text-align: center; margin-top: 32px;">
=20
<a href=3D"http://test.com/agents?archived=3Darchived" style=3D"dis=
play: inline-block; padding: 13px 24px; background-color: #020617; color: #=
f8fafc; text-decoration: none; border-radius: 8px; margin: 0 4px;">
View chats
</a>
=20
</div>
<div style=3D"border-top: 1px solid #e2e8f0; color: #475569; font-siz=
e: 12px; margin-top: 64px; padding-top: 24px; line-height: 1.6;">
<p>&copy;&nbsp;2024&nbsp;Coder. All rights reserved&nbsp;-&nbsp;<a =
href=3D"http://test.com" style=3D"color: #2563eb; text-decoration: none;">h=
ttp://test.com</a></p>
<p><a href=3D"http://test.com/settings/notifications" style=3D"colo=
r: #2563eb; text-decoration: none;">Click here to manage your notification =
settings</a></p>
<p><a href=3D"http://test.com/settings/notifications?disabled=3D764=
031be-4863-4220-867b-6ce1a1b7a5f5" style=3D"color: #2563eb; text-decoration=
: none;">Stop receiving emails like this</a></p>
</div>
</div>
</body>
</html>
--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4--
@@ -0,0 +1,89 @@
From: system@coder.com
To: bobby@coder.com
Subject: Chats auto-archived after 90 days of inactivity
Message-Id: 02ee4935-73be-4fa1-a290-ff9999026b13@blush-whale-48
Date: Fri, 11 Oct 2024 09:03:06 +0000
Content-Type: multipart/alternative; boundary=bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4
MIME-Version: 1.0
--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4
Content-Transfer-Encoding: quoted-printable
Content-Type: text/plain; charset=UTF-8
Hi Bobby,
The following chats were automatically archived:
"Onboarding kickoff" (last active 3 months ago)
You can restore any of them from the Agents page within 30 days, after whic=
h they will be permanently deleted.
View chats: http://test.com/agents?archived=3Darchived
--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4
Content-Transfer-Encoding: quoted-printable
Content-Type: text/html; charset=UTF-8
<!doctype html>
<html lang=3D"en">
<head>
<meta charset=3D"UTF-8" />
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
=3D1.0" />
<title>Chats auto-archived after 90 days of inactivity</title>
</head>
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
l', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; color: #020617=
; background: #f8fafc;">
<div style=3D"max-width: 600px; margin: 20px auto; padding: 60px; borde=
r: 1px solid #e2e8f0; border-radius: 8px; background-color: #fff; text-alig=
n: left; font-size: 14px; line-height: 1.5;">
<div style=3D"text-align: center;">
<img src=3D"https://coder.com/coder-logo-horizontal.png" alt=3D"Cod=
er Logo" style=3D"height: 40px;" />
</div>
<h1 style=3D"text-align: center; font-size: 24px; font-weight: 400; m=
argin: 8px 0 32px; line-height: 1.5;">
Chats auto-archived after 90 days of inactivity
</h1>
<div style=3D"line-height: 1.5;">
<p>Hi Bobby,</p>
<p>The following chats were automatically archived:</p>
<ul>
<li>&ldquo;Onboarding kickoff&rdquo; (last active 3 months ago)<br>
</li>
</ul>
<p>You can restore any of them from the Agents page within 30 days, after w=
hich they will be permanently deleted.</p>
</div>
<div style=3D"text-align: center; margin-top: 32px;">
=20
<a href=3D"http://test.com/agents?archived=3Darchived" style=3D"dis=
play: inline-block; padding: 13px 24px; background-color: #020617; color: #=
f8fafc; text-decoration: none; border-radius: 8px; margin: 0 4px;">
View chats
</a>
=20
</div>
<div style=3D"border-top: 1px solid #e2e8f0; color: #475569; font-siz=
e: 12px; margin-top: 64px; padding-top: 24px; line-height: 1.6;">
<p>&copy;&nbsp;2024&nbsp;Coder. All rights reserved&nbsp;-&nbsp;<a =
href=3D"http://test.com" style=3D"color: #2563eb; text-decoration: none;">h=
ttp://test.com</a></p>
<p><a href=3D"http://test.com/settings/notifications" style=3D"colo=
r: #2563eb; text-decoration: none;">Click here to manage your notification =
settings</a></p>
<p><a href=3D"http://test.com/settings/notifications?disabled=3D764=
031be-4863-4220-867b-6ce1a1b7a5f5" style=3D"color: #2563eb; text-decoration=
: none;">Stop receiving emails like this</a></p>
</div>
</div>
</body>
</html>
--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4--
@@ -0,0 +1,39 @@
{
"_version": "1.1",
"msg_id": "00000000-0000-0000-0000-000000000000",
"payload": {
"_version": "1.2",
"notification_name": "Chats Auto-Archived",
"notification_template_id": "00000000-0000-0000-0000-000000000000",
"user_id": "00000000-0000-0000-0000-000000000000",
"user_email": "bobby@coder.com",
"user_name": "Bobby",
"user_username": "bobby",
"actions": [
{
"label": "View chats",
"url": "http://test.com/agents?archived=archived"
}
],
"labels": {},
"data": {
"archived_chats": [
{
"last_activity_humanized": "3 months ago",
"title": "Onboarding kickoff"
},
{
"last_activity_humanized": "4 months ago",
"title": "Quarterly planning draft"
}
],
"auto_archive_days": "90",
"retention_days": "30"
},
"targets": null
},
"title": "Chats auto-archived after 90 days of inactivity",
"title_markdown": "Chats auto-archived after 90 days of inactivity",
"body": "The following chats were automatically archived:\n\n\"Onboarding kickoff\" (last active 3 months ago)\n\"Quarterly planning draft\" (last active 4 months ago)\n\nYou can restore any of them from the Agents page within 30 days, after which they will be permanently deleted.",
"body_markdown": "The following chats were automatically archived:\n\n* \"Onboarding kickoff\" (last active 3 months ago)\n* \"Quarterly planning draft\" (last active 4 months ago)\n\nYou can restore any of them from the Agents page within 30 days, after which they will be permanently deleted."
}
@@ -0,0 +1,40 @@
{
"_version": "1.1",
"msg_id": "00000000-0000-0000-0000-000000000000",
"payload": {
"_version": "1.2",
"notification_name": "Chats Auto-Archived",
"notification_template_id": "00000000-0000-0000-0000-000000000000",
"user_id": "00000000-0000-0000-0000-000000000000",
"user_email": "bobby@coder.com",
"user_name": "Bobby",
"user_username": "bobby",
"actions": [
{
"label": "View chats",
"url": "http://test.com/agents?archived=archived"
}
],
"labels": {},
"data": {
"additional_archived_count": "6",
"archived_chats": [
{
"last_activity_humanized": "3 months ago",
"title": "Onboarding kickoff"
},
{
"last_activity_humanized": "4 months ago",
"title": "Quarterly planning draft"
}
],
"auto_archive_days": "90",
"retention_days": "30"
},
"targets": null
},
"title": "Chats auto-archived after 90 days of inactivity",
"title_markdown": "Chats auto-archived after 90 days of inactivity",
"body": "The following chats were automatically archived:\n\n\"Onboarding kickoff\" (last active 3 months ago)\n\"Quarterly planning draft\" (last active 4 months ago)\n\n...and 6 more.\n\nYou can restore any of them from the Agents page within 30 days, after which they will be permanently deleted.",
"body_markdown": "The following chats were automatically archived:\n\n* \"Onboarding kickoff\" (last active 3 months ago)\n* \"Quarterly planning draft\" (last active 4 months ago)\n\n...and 6 more.\n\n\nYou can restore any of them from the Agents page within 30 days, after which they will be permanently deleted."
}
@@ -0,0 +1,39 @@
{
"_version": "1.1",
"msg_id": "00000000-0000-0000-0000-000000000000",
"payload": {
"_version": "1.2",
"notification_name": "Chats Auto-Archived",
"notification_template_id": "00000000-0000-0000-0000-000000000000",
"user_id": "00000000-0000-0000-0000-000000000000",
"user_email": "bobby@coder.com",
"user_name": "Bobby",
"user_username": "bobby",
"actions": [
{
"label": "View chats",
"url": "http://test.com/agents?archived=archived"
}
],
"labels": {},
"data": {
"archived_chats": [
{
"last_activity_humanized": "3 months ago",
"title": "Onboarding kickoff"
},
{
"last_activity_humanized": "4 months ago",
"title": "Quarterly planning draft"
}
],
"auto_archive_days": "90",
"retention_days": "0"
},
"targets": null
},
"title": "Chats auto-archived after 90 days of inactivity",
"title_markdown": "Chats auto-archived after 90 days of inactivity",
"body": "The following chats were automatically archived:\n\n\"Onboarding kickoff\" (last active 3 months ago)\n\"Quarterly planning draft\" (last active 4 months ago)\n\nYou can restore any of them from the Agents page; archived chats are kept indefinitely.",
"body_markdown": "The following chats were automatically archived:\n\n* \"Onboarding kickoff\" (last active 3 months ago)\n* \"Quarterly planning draft\" (last active 4 months ago)\n\nYou can restore any of them from the Agents page; archived chats are kept indefinitely."
}
@@ -0,0 +1,35 @@
{
"_version": "1.1",
"msg_id": "00000000-0000-0000-0000-000000000000",
"payload": {
"_version": "1.2",
"notification_name": "Chats Auto-Archived",
"notification_template_id": "00000000-0000-0000-0000-000000000000",
"user_id": "00000000-0000-0000-0000-000000000000",
"user_email": "bobby@coder.com",
"user_name": "Bobby",
"user_username": "bobby",
"actions": [
{
"label": "View chats",
"url": "http://test.com/agents?archived=archived"
}
],
"labels": {},
"data": {
"archived_chats": [
{
"last_activity_humanized": "3 months ago",
"title": "Onboarding kickoff"
}
],
"auto_archive_days": "90",
"retention_days": "30"
},
"targets": null
},
"title": "Chats auto-archived after 90 days of inactivity",
"title_markdown": "Chats auto-archived after 90 days of inactivity",
"body": "The following chats were automatically archived:\n\n\"Onboarding kickoff\" (last active 3 months ago)\n\nYou can restore any of them from the Agents page within 30 days, after which they will be permanently deleted.",
"body_markdown": "The following chats were automatically archived:\n\n* \"Onboarding kickoff\" (last active 3 months ago)\n\nYou can restore any of them from the Agents page within 30 days, after which they will be permanently deleted."
}
@@ -26,6 +26,15 @@ root regardless of individual pin status. Admins and users who want
to retain a conversation long after its last message should pin the
root.
## Notifications
When your chats are auto-archived, you receive a digest notification
listing the titles of the archived conversations and the
auto-archive window currently configured.
If you find the digest noisy, you can disable the "Chats
Auto-Archived" notification entirely from your notification preferences.
## Interaction with retention
Auto-archive and deletion are two independent controls:
@@ -79,6 +88,4 @@ set `auto_archive_days` back to `0`.
Each auto-archived root chat produces an audit log entry with the
background subsystem tag `chat_auto_archive`. Cascaded children are
not audited individually. The audit entry records the chat ID, owner
ID, and organization ID, and the diff shows `archived` flipping from
`false` to `true`.
not audited individually.
@@ -60,6 +60,7 @@ export const Default: Story = {
await Promise.all([
// System notification templates
canvas.findByRole("switch", { name: "Chat Events" }),
canvas.findByRole("switch", { name: "Task Events" }),
canvas.findByRole("switch", { name: "Template Events" }),
canvas.findByRole("switch", { name: "User Events" }),
@@ -278,6 +278,7 @@ function canSeeNotificationGroup(
return permissions.createUser;
case "Workspace Events":
case "Task Events":
case "Chat Events":
case "Custom Events":
return true;
default:
+14
View File
@@ -4804,6 +4804,20 @@ export const MockSystemNotificationTemplates: TypesGen.NotificationTemplate[] =
kind: "system",
enabled_by_default: false,
},
{
id: "764031be-4863-4220-867b-6ce1a1b7a5f5",
name: "Chats Auto-Archived",
title_template:
"Chats auto-archived after {{.Data.auto_archive_days}} days of inactivity",
body_template:
'The following chats were automatically archived:\n\n{{range .Data.archived_chats}}* "{{.title}}" (last active {{.last_activity_humanized}})\n{{end}}',
actions:
'[{"label": "View chats", "url": "{{base_url}}/agents?archived=archived"}]',
group: "Chat Events",
method: "",
kind: "system",
enabled_by_default: true,
},
];
export const MockCustomNotificationTemplates: TypesGen.NotificationTemplate[] =