mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
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:
+1
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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...)
|
||||
}
|
||||
Vendored
+92
@@ -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>“Onboarding kickoff” (last active 3 months ago)<br>
|
||||
</li>
|
||||
<li>“Quarterly planning draft” (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>© 2024 Coder. All rights reserved - <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--
|
||||
+96
@@ -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>“Onboarding kickoff” (last active 3 months ago)<br>
|
||||
</li>
|
||||
<li>“Quarterly planning draft” (last active 4 months ago)<br>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p>…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>© 2024 Coder. All rights reserved - <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--
|
||||
+92
@@ -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>“Onboarding kickoff” (last active 3 months ago)<br>
|
||||
</li>
|
||||
<li>“Quarterly planning draft” (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>© 2024 Coder. All rights reserved - <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--
|
||||
+89
@@ -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>“Onboarding kickoff” (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>© 2024 Coder. All rights reserved - <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--
|
||||
Vendored
+39
@@ -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."
|
||||
}
|
||||
+40
@@ -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."
|
||||
}
|
||||
+39
@@ -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."
|
||||
}
|
||||
+35
@@ -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:
|
||||
|
||||
@@ -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[] =
|
||||
|
||||
Reference in New Issue
Block a user