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()
|
defer shutdownConns()
|
||||||
|
|
||||||
// Ensures that old database entries are cleaned up over time!
|
// 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()
|
defer purger.Close()
|
||||||
|
|
||||||
// Updates workspace usage
|
// Updates workspace usage
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
package dbpurge
|
package dbpurge
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"cmp"
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/dustin/go-humanize"
|
||||||
|
"github.com/google/uuid"
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
"golang.org/x/xerrors"
|
"golang.org/x/xerrors"
|
||||||
|
|
||||||
@@ -15,7 +21,9 @@ import (
|
|||||||
"github.com/coder/coder/v2/coderd/database"
|
"github.com/coder/coder/v2/coderd/database"
|
||||||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
"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/pproflabel"
|
||||||
|
"github.com/coder/coder/v2/coderd/util/slice"
|
||||||
"github.com/coder/coder/v2/codersdk"
|
"github.com/coder/coder/v2/codersdk"
|
||||||
"github.com/coder/quartz"
|
"github.com/coder/quartz"
|
||||||
)
|
)
|
||||||
@@ -41,6 +49,12 @@ const (
|
|||||||
// chat_files rows carry bytea blobs.
|
// chat_files rows carry bytea blobs.
|
||||||
chatsBatchSize = 1000
|
chatsBatchSize = 1000
|
||||||
chatFilesBatchSize = 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
|
// defaultChatAutoArchiveBatchSize bounds how many root chats one
|
||||||
@@ -62,12 +76,26 @@ func WithChatAutoArchiveBatchSize(n int32) Option {
|
|||||||
return func(i *instance) { i.chatAutoArchiveBatchSize = n }
|
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.
|
// New creates a new periodically purging database instance.
|
||||||
// Callers must Close the returned instance.
|
// Callers must Close the returned instance.
|
||||||
//
|
//
|
||||||
// The auditor pointer is loaded on each dispatch tick so runtime
|
// The auditor pointer is loaded on each dispatch tick so runtime
|
||||||
// entitlement changes (e.g. toggling the audit-log feature) take
|
// 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 {
|
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{})
|
closed := make(chan struct{})
|
||||||
|
|
||||||
@@ -107,6 +135,7 @@ func New(ctx context.Context, logger slog.Logger, db database.Store, vals *coder
|
|||||||
vals: vals,
|
vals: vals,
|
||||||
clk: quartz.NewReal(),
|
clk: quartz.NewReal(),
|
||||||
auditor: auditor,
|
auditor: auditor,
|
||||||
|
enqueuer: notifications.NewNoopEnqueuer(),
|
||||||
iterationDuration: iterationDuration,
|
iterationDuration: iterationDuration,
|
||||||
recordsPurged: recordsPurged,
|
recordsPurged: recordsPurged,
|
||||||
chatAutoArchiveRecords: chatAutoArchiveRecords,
|
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
|
// purgeTick performs a single purge iteration. It returns an error if the
|
||||||
// purge fails.
|
// purge fails.
|
||||||
func (i *instance) purgeTick(ctx context.Context, db database.Store, start time.Time) error {
|
func (i *instance) purgeTick(ctx context.Context, db database.Store, start time.Time) error {
|
||||||
// Read chat retention config outside the transaction to
|
// Read chat configs outside the tx so a corrupt value can't
|
||||||
// avoid poisoning the tx if the stored value is corrupt.
|
// poison subsequent queries. On error we log and stash, then
|
||||||
// A SQL-level cast error (e.g. non-numeric text) puts PG
|
// run unrelated purges best-effort and skip only chat work;
|
||||||
// into error state, failing all subsequent queries in the
|
// purgeTick returns chatConfigErr after the tx so the failed
|
||||||
// same transaction.
|
// iteration is operator-visible via metric and logs.
|
||||||
chatRetentionDays, err := db.GetChatRetentionDays(ctx)
|
chatRetentionDays, chatRetentionErr := db.GetChatRetentionDays(ctx)
|
||||||
if err != nil {
|
if chatRetentionErr != nil {
|
||||||
i.logger.Warn(ctx, "failed to read chat retention config, skipping chat purge", slog.Error(err))
|
i.logger.Error(ctx, "failed to read chat retention config: skipping chat purge and auto-archive this tick", slog.Error(chatRetentionErr))
|
||||||
chatRetentionDays = 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Same rationale as chat_retention_days: read outside the tx.
|
chatAutoArchiveDays, chatAutoArchiveErr := db.GetChatAutoArchiveDays(ctx, codersdk.DefaultChatAutoArchiveDays)
|
||||||
chatAutoArchiveDays, err := db.GetChatAutoArchiveDays(ctx, codersdk.DefaultChatAutoArchiveDays)
|
if chatAutoArchiveErr != nil {
|
||||||
if err != nil {
|
i.logger.Error(ctx, "failed to read chat auto-archive config: skipping chat purge and auto-archive this tick", slog.Error(chatAutoArchiveErr))
|
||||||
i.logger.Warn(ctx, "failed to read chat auto-archive config, skipping auto-archive", slog.Error(err))
|
|
||||||
chatAutoArchiveDays = 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
chatConfigErr := errors.Join(chatRetentionErr, chatAutoArchiveErr)
|
||||||
|
|
||||||
// Populated inside the tx; dispatched post-commit.
|
// Populated inside the tx; dispatched post-commit.
|
||||||
var archivedChats []database.AutoArchiveInactiveChatsRow
|
var archivedChats []database.AutoArchiveInactiveChatsRow
|
||||||
|
|
||||||
// Start a transaction to grab advisory lock, we don't want to run
|
// Start a transaction to grab advisory lock, we don't want to run
|
||||||
// multiple purges at the same time (multiple replicas).
|
// 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
|
// Acquire a lock to ensure that only one instance of the
|
||||||
// purge is running at a time.
|
// purge is running at a time.
|
||||||
ok, err := tx.TryAcquireLock(ctx, database.LockIDDBPurge)
|
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
|
var purgedChats, purgedChatFiles int64
|
||||||
// enabled, old archived chats are deleted first, then
|
if chatConfigErr == nil {
|
||||||
// orphaned chat files. Deleting a chat cascades to
|
purgedChats, purgedChatFiles, archivedChats, err = i.purgeChatsInTx(ctx, tx, start, chatRetentionDays, chatAutoArchiveDays)
|
||||||
// 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,
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return xerrors.Errorf("failed to delete old chats: %w", err)
|
return xerrors.Errorf("failed to purge 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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,15 +344,24 @@ func (i *instance) purgeTick(ctx context.Context, db database.Store, start time.
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dispatch audits post-commit on a detached context so ticker
|
// Surface the deferred chat-config error so doTick records
|
||||||
// cancellation doesn't interrupt the loop. No timeout: every root
|
// the failed iteration metric.
|
||||||
// must be audited to avoid gaps in the trail. Children inherit
|
if chatConfigErr != nil {
|
||||||
// their root's archival decision and are not audited individually,
|
return xerrors.Errorf("chat config read failed this tick: %w", chatConfigErr)
|
||||||
// matching the manual archive path (patchChat audits the root only).
|
}
|
||||||
|
|
||||||
|
// 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 {
|
if len(archivedChats) > 0 {
|
||||||
i.chatAutoArchiveRecords.Add(float64(len(archivedChats)))
|
i.chatAutoArchiveRecords.Add(float64(len(archivedChats)))
|
||||||
dispatchCtx := context.WithoutCancel(ctx)
|
auditCtx := context.WithoutCancel(ctx)
|
||||||
i.dispatchChatAutoArchive(dispatchCtx, archivedChats)
|
i.dispatchChatAutoArchive(auditCtx, ctx, start, chatAutoArchiveDays, chatRetentionDays, archivedChats)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -372,6 +374,7 @@ type instance struct {
|
|||||||
vals *codersdk.DeploymentValues
|
vals *codersdk.DeploymentValues
|
||||||
clk quartz.Clock
|
clk quartz.Clock
|
||||||
auditor *atomic.Pointer[audit.Auditor]
|
auditor *atomic.Pointer[audit.Auditor]
|
||||||
|
enqueuer notifications.Enqueuer
|
||||||
iterationDuration *prometheus.HistogramVec
|
iterationDuration *prometheus.HistogramVec
|
||||||
recordsPurged *prometheus.CounterVec
|
recordsPurged *prometheus.CounterVec
|
||||||
chatAutoArchiveRecords prometheus.Counter
|
chatAutoArchiveRecords prometheus.Counter
|
||||||
@@ -428,20 +431,69 @@ func chatFromAutoArchiveRow(logger slog.Logger, r database.AutoArchiveInactiveCh
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// dispatchChatAutoArchive audits every archived root chat. Children
|
// purgeChatsInTx MUST BE CALLED WITH A TRANSACTION
|
||||||
// inherit their root's archival decision and are skipped, matching
|
func (i *instance) purgeChatsInTx(ctx context.Context, tx database.Store, start time.Time, chatRetentionDays, chatAutoArchiveDays int32) (purgedChats, purgedChatFiles int64, archivedChats []database.AutoArchiveInactiveChatsRow, err error) {
|
||||||
// the manual archive path (patchChat audits the root only). Runs on
|
// Delete old archived chats first, then orphaned files
|
||||||
// a detached context so ticker cancellation cannot truncate the trail.
|
// (cascade clears chat_file_links but not chat_files).
|
||||||
func (i *instance) dispatchChatAutoArchive(ctx context.Context, archived []database.AutoArchiveInactiveChatsRow) {
|
if chatRetentionDays > 0 {
|
||||||
auditor := *i.auditor.Load()
|
deleteChatsBefore := start.Add(-time.Duration(chatRetentionDays) * 24 * time.Hour)
|
||||||
for _, row := range archived {
|
purgedChats, err = tx.DeleteOldChats(ctx, database.DeleteOldChatsParams{
|
||||||
if row.ParentChatID.Valid {
|
BeforeTime: deleteChatsBefore,
|
||||||
continue // Children inherit root's archival; audit roots only.
|
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)
|
after := chatFromAutoArchiveRow(i.logger, row)
|
||||||
before := after
|
before := after
|
||||||
before.Archived = false
|
before.Archived = false
|
||||||
audit.BackgroundAudit(ctx, &audit.BackgroundAuditParams[database.Chat]{
|
audit.BackgroundAudit(auditCtx, &audit.BackgroundAuditParams[database.Chat]{
|
||||||
Audit: auditor,
|
Audit: auditor,
|
||||||
Log: i.logger,
|
Log: i.logger,
|
||||||
UserID: row.OwnerID,
|
UserID: row.OwnerID,
|
||||||
@@ -450,7 +502,94 @@ func (i *instance) dispatchChatAutoArchive(ctx context.Context, archived []datab
|
|||||||
Old: before,
|
Old: before,
|
||||||
New: after,
|
New: after,
|
||||||
Status: http.StatusOK,
|
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/dbrollup"
|
||||||
"github.com/coder/coder/v2/coderd/database/dbtestutil"
|
"github.com/coder/coder/v2/coderd/database/dbtestutil"
|
||||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
"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/coderd/provisionerdserver"
|
||||||
"github.com/coder/coder/v2/codersdk"
|
"github.com/coder/coder/v2/codersdk"
|
||||||
"github.com/coder/coder/v2/provisionerd/proto"
|
"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")
|
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.
|
//nolint:paralleltest // It uses LockIDDBPurge.
|
||||||
@@ -2348,15 +2437,19 @@ func TestAutoArchiveInactiveChats(t *testing.T) {
|
|||||||
|
|
||||||
auditor := audit.NewMock()
|
auditor := audit.NewMock()
|
||||||
auditorPtr := mockAuditorPtr(auditor)
|
auditorPtr := mockAuditorPtr(auditor)
|
||||||
|
enqueuer := notificationstest.NewFakeEnqueuer()
|
||||||
done := awaitDoTick(ctx, t, clk)
|
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()
|
defer closer.Close()
|
||||||
testutil.TryReceive(ctx, t, done)
|
testutil.TryReceive(ctx, t, done)
|
||||||
|
|
||||||
|
// Not archived, no audits, no digests.
|
||||||
refreshed, err := db.GetChatByID(ctx, staleChat.ID)
|
refreshed, err := db.GetChatByID(ctx, staleChat.ID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.False(t, refreshed.Archived, "chat should stay active when auto-archive is disabled")
|
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, 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)
|
h := newArchiveHarness(t, now)
|
||||||
ctx, clk, db, rawDB, logger, deps := h.ctx, h.clk, h.db, h.rawDB, h.logger, h.deps
|
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.UpsertChatAutoArchiveDays(ctx, int32(90)))
|
||||||
|
require.NoError(t, db.UpsertChatRetentionDays(ctx, int32(30)))
|
||||||
|
|
||||||
// Inactive root: newest message 100 days old.
|
// Inactive root: newest message 100 days old.
|
||||||
staleChat := createArchiveChat(ctx, t, db, rawDB, deps, "stale-chat", now.Add(-120*24*time.Hour))
|
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()
|
auditor := audit.NewMock()
|
||||||
auditorPtr := mockAuditorPtr(auditor)
|
auditorPtr := mockAuditorPtr(auditor)
|
||||||
|
enqueuer := notificationstest.NewFakeEnqueuer()
|
||||||
done := awaitDoTick(ctx, t, clk)
|
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()
|
defer closer.Close()
|
||||||
testutil.TryReceive(ctx, t, done)
|
testutil.TryReceive(ctx, t, done)
|
||||||
|
|
||||||
@@ -2390,6 +2487,7 @@ func TestAutoArchiveInactiveChats(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.False(t, refreshedActive.Archived, "active chat should stay live")
|
require.False(t, refreshedActive.Archived, "active chat should stay live")
|
||||||
|
|
||||||
|
// Exactly one audit entry, for the stale root.
|
||||||
logs := auditor.AuditLogs()
|
logs := auditor.AuditLogs()
|
||||||
require.Len(t, logs, 1, "expected one audit entry")
|
require.Len(t, logs, 1, "expected one audit entry")
|
||||||
require.Equal(t, staleChat.ID, logs[0].ResourceID)
|
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.Equal(t, database.AuditActionWrite, logs[0].Action)
|
||||||
require.Contains(t, string(logs[0].AdditionalFields), "chat_auto_archive",
|
require.Contains(t, string(logs[0].AdditionalFields), "chat_auto_archive",
|
||||||
"audit entry must carry the auto-archive subsystem tag")
|
"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()
|
auditor := audit.NewMock()
|
||||||
auditorPtr := mockAuditorPtr(auditor)
|
auditorPtr := mockAuditorPtr(auditor)
|
||||||
|
enqueuer := notificationstest.NewFakeEnqueuer()
|
||||||
done := awaitDoTick(ctx, t, clk)
|
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()
|
defer closer.Close()
|
||||||
testutil.TryReceive(ctx, t, done)
|
testutil.TryReceive(ctx, t, done)
|
||||||
|
|
||||||
@@ -2512,6 +2620,7 @@ func TestAutoArchiveInactiveChats(t *testing.T) {
|
|||||||
require.False(t, refreshedChild.Archived, "child must stay active")
|
require.False(t, refreshedChild.Archived, "child must stay active")
|
||||||
|
|
||||||
require.Empty(t, auditor.AuditLogs(), "no chats should be archived")
|
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()
|
auditor := audit.NewMock()
|
||||||
auditorPtr := mockAuditorPtr(auditor)
|
auditorPtr := mockAuditorPtr(auditor)
|
||||||
|
enqueuer := notificationstest.NewFakeEnqueuer()
|
||||||
done := awaitDoTick(ctx, t, clk)
|
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()
|
defer closer.Close()
|
||||||
testutil.TryReceive(ctx, t, done)
|
testutil.TryReceive(ctx, t, done)
|
||||||
|
|
||||||
@@ -2580,6 +2690,12 @@ func TestAutoArchiveInactiveChats(t *testing.T) {
|
|||||||
logs := auditor.AuditLogs()
|
logs := auditor.AuditLogs()
|
||||||
require.Len(t, logs, 1, "only the completed chat should produce an audit entry")
|
require.Len(t, logs, 1, "only the completed chat should produce an audit entry")
|
||||||
require.Equal(t, completedChat.ID, logs[0].ResourceID)
|
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()
|
auditor := audit.NewMock()
|
||||||
auditorPtr := mockAuditorPtr(auditor)
|
auditorPtr := mockAuditorPtr(auditor)
|
||||||
|
enqueuer := notificationstest.NewFakeEnqueuer()
|
||||||
done := awaitDoTick(ctx, t, clk)
|
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()
|
defer closer.Close()
|
||||||
testutil.TryReceive(ctx, t, done)
|
testutil.TryReceive(ctx, t, done)
|
||||||
|
|
||||||
@@ -2628,6 +2745,62 @@ func TestAutoArchiveInactiveChats(t *testing.T) {
|
|||||||
// One audit entry for the root; the cascaded child is
|
// One audit entry for the root; the cascaded child is
|
||||||
// not audited individually.
|
// not audited individually.
|
||||||
require.Len(t, auditor.AuditLogs(), 1)
|
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)))
|
require.NoError(t, db.UpsertChatAutoArchiveDays(ctx, int32(30)))
|
||||||
|
|
||||||
// Two stale roots per owner, backdated well past the
|
// Two stale roots per owner, backdated well past
|
||||||
// 30-day cutoff.
|
// the 30-day cutoff.
|
||||||
u1Deps := deps
|
u1Deps := deps
|
||||||
u2Deps := chatAutoArchiveDeps{user: user2, org: deps.org, modelConfig: deps.modelConfig}
|
u2Deps := chatAutoArchiveDeps{user: user2, org: deps.org, modelConfig: deps.modelConfig}
|
||||||
createArchiveChat(ctx, t, db, rawDB, u1Deps, "u1-a", now.Add(-60*24*time.Hour))
|
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()
|
auditor := audit.NewMock()
|
||||||
auditorPtr := mockAuditorPtr(auditor)
|
auditorPtr := mockAuditorPtr(auditor)
|
||||||
|
enqueuer := notificationstest.NewFakeEnqueuer()
|
||||||
done := awaitDoTick(ctx, t, clk)
|
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()
|
defer closer.Close()
|
||||||
testutil.TryReceive(ctx, t, done)
|
testutil.TryReceive(ctx, t, done)
|
||||||
|
|
||||||
// Four audit rows, one per archived root. Each entry
|
// Four audit rows, one per archived root, attributed
|
||||||
// carries the owning UserID so downstream consumers can
|
// to the owning user so downstream consumers can
|
||||||
// correlate per-owner activity.
|
// correlate per-owner activity.
|
||||||
logs := auditor.AuditLogs()
|
logs := auditor.AuditLogs()
|
||||||
require.Len(t, logs, 4)
|
require.Len(t, logs, 4)
|
||||||
byUser := map[uuid.UUID]int{}
|
auditsByUser := map[uuid.UUID]int{}
|
||||||
for _, l := range logs {
|
for _, l := range logs {
|
||||||
byUser[l.UserID]++
|
auditsByUser[l.UserID]++
|
||||||
}
|
}
|
||||||
require.Equal(t, 2, byUser[deps.user.ID])
|
require.Equal(t, 2, auditsByUser[deps.user.ID])
|
||||||
require.Equal(t, 2, byUser[user2.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()
|
auditor := audit.NewMock()
|
||||||
auditorPtr := mockAuditorPtr(auditor)
|
auditorPtr := mockAuditorPtr(auditor)
|
||||||
|
enqueuer := notificationstest.NewFakeEnqueuer()
|
||||||
driver := newTickDriver(t, clk)
|
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
|
// Defer driver.close() after closer.Close(): defers
|
||||||
// run LIFO, so this frees shutdown's ticker.Stop()
|
// run LIFO, so this frees shutdown's ticker.Stop()
|
||||||
// before the dbpurge goroutine blocks on it.
|
// before the dbpurge goroutine blocks on it.
|
||||||
@@ -2692,8 +2889,9 @@ func TestAutoArchiveInactiveChats(t *testing.T) {
|
|||||||
defer driver.close()
|
defer driver.close()
|
||||||
driver.awaitInitial(ctx, t)
|
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, 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
|
// Seed a third stale root between ticks so tick 2 has
|
||||||
// genuine work and we can distinguish "ignored already
|
// genuine work and we can distinguish "ignored already
|
||||||
@@ -2702,11 +2900,17 @@ func TestAutoArchiveInactiveChats(t *testing.T) {
|
|||||||
|
|
||||||
driver.awaitNext(ctx, t)
|
driver.awaitNext(ctx, t)
|
||||||
|
|
||||||
// Tick 2: exactly one new audit for the third chat;
|
// Tick 2: exactly one new audit + one new digest for
|
||||||
// tick 1's rows must not be re-archived.
|
// the third chat; tick 1's rows must not be re-archived.
|
||||||
require.Len(t, auditor.AuditLogs(), 3, "tick 2 cumulative audits")
|
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} {
|
for _, id := range []uuid.UUID{firstA.ID, firstB.ID, third.ID} {
|
||||||
refreshed, err := db.GetChatByID(ctx, id)
|
refreshed, err := db.GetChatByID(ctx, id)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -2718,10 +2922,18 @@ func TestAutoArchiveInactiveChats(t *testing.T) {
|
|||||||
name: "BatchSizePagination",
|
name: "BatchSizePagination",
|
||||||
run: func(t *testing.T) {
|
run: func(t *testing.T) {
|
||||||
// With 27 stale roots and batch size 20, tick 1
|
// With 27 stale roots and batch size 20, tick 1
|
||||||
// archives 20, tick 2 archives the remaining 7, tick 3
|
// archives 20, tick 2 archives the remaining 7, and
|
||||||
// archives none. We assert the audit dispatch follows
|
// tick 3 archives none. We assert the dispatch side
|
||||||
// the same pattern: no dispatch runs when rows == 0,
|
// effects (audits, digests) follow the same pattern:
|
||||||
// so tick 3 emits no new audits.
|
// 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)
|
h := newArchiveHarness(t, now)
|
||||||
ctx, clk, db, rawDB, logger, deps := h.ctx, h.clk, h.db, h.rawDB, h.logger, h.deps
|
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()
|
auditor := audit.NewMock()
|
||||||
auditorPtr := mockAuditorPtr(auditor)
|
auditorPtr := mockAuditorPtr(auditor)
|
||||||
|
enqueuer := notificationstest.NewFakeEnqueuer()
|
||||||
driver := newTickDriver(t, clk)
|
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
|
// Defer driver.close() after closer.Close() so trap
|
||||||
// cleanup frees shutdown's ticker.Stop() before the
|
// cleanup frees shutdown's ticker.Stop() before the
|
||||||
// dbpurge goroutine blocks on it.
|
// dbpurge goroutine blocks on it.
|
||||||
@@ -2745,15 +2958,137 @@ func TestAutoArchiveInactiveChats(t *testing.T) {
|
|||||||
defer driver.close()
|
defer driver.close()
|
||||||
driver.awaitInitial(ctx, t)
|
driver.awaitInitial(ctx, t)
|
||||||
|
|
||||||
|
// Tick 1: first batch (20) archived.
|
||||||
require.Len(t, auditor.AuditLogs(), 20, "tick 1 audits")
|
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)
|
driver.awaitNext(ctx, t)
|
||||||
|
|
||||||
|
// Tick 2: remaining 7 archived.
|
||||||
require.Len(t, auditor.AuditLogs(), 27, "tick 2 cumulative audits")
|
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)
|
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, 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),
|
// Archives inactive root chats (pinned and already-archived chats skipped),
|
||||||
// cascading to children via root_chat_id. Limits apply to roots, not total
|
// cascading to children via root_chat_id. Limits apply to roots, not total
|
||||||
// rows. Used by dbpurge.
|
// 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)
|
AutoArchiveInactiveChats(ctx context.Context, arg AutoArchiveInactiveChatsParams) ([]AutoArchiveInactiveChatsRow, error)
|
||||||
BackoffChatDiffStatus(ctx context.Context, arg BackoffChatDiffStatusParams) error
|
BackoffChatDiffStatus(ctx context.Context, arg BackoffChatDiffStatusParams) error
|
||||||
BatchUpdateWorkspaceAgentMetadata(ctx context.Context, arg BatchUpdateWorkspaceAgentMetadataParams) 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),
|
// Archives inactive root chats (pinned and already-archived chats skipped),
|
||||||
// cascading to children via root_chat_id. Limits apply to roots, not total
|
// cascading to children via root_chat_id. Limits apply to roots, not total
|
||||||
// rows. Used by dbpurge.
|
// 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) {
|
func (q *sqlQuerier) AutoArchiveInactiveChats(ctx context.Context, arg AutoArchiveInactiveChatsParams) ([]AutoArchiveInactiveChatsRow, error) {
|
||||||
rows, err := q.db.QueryContext(ctx, autoArchiveInactiveChats, arg.ArchiveCutoff, arg.LimitCount)
|
rows, err := q.db.QueryContext(ctx, autoArchiveInactiveChats, arg.ArchiveCutoff, arg.LimitCount)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1482,4 +1482,6 @@ SELECT
|
|||||||
)::timestamptz AS last_activity_at
|
)::timestamptz AS last_activity_at
|
||||||
FROM archived a
|
FROM archived a
|
||||||
LEFT JOIN to_archive t ON t.id = a.id
|
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;
|
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.TemplateTemplateDeleted: codersdk.InboxNotificationFallbackIconTemplate,
|
||||||
notifications.TemplateTemplateDeprecated: codersdk.InboxNotificationFallbackIconTemplate,
|
notifications.TemplateTemplateDeprecated: codersdk.InboxNotificationFallbackIconTemplate,
|
||||||
notifications.TemplateWorkspaceBuildsFailedReport: codersdk.InboxNotificationFallbackIconTemplate,
|
notifications.TemplateWorkspaceBuildsFailedReport: codersdk.InboxNotificationFallbackIconTemplate,
|
||||||
|
|
||||||
|
// chat related notifications
|
||||||
|
notifications.TemplateChatAutoArchiveDigest: codersdk.InboxNotificationFallbackIconOther,
|
||||||
}
|
}
|
||||||
|
|
||||||
func ensureNotificationIcon(notif codersdk.InboxNotification) codersdk.InboxNotification {
|
func ensureNotificationIcon(notif codersdk.InboxNotification) codersdk.InboxNotification {
|
||||||
|
|||||||
@@ -62,3 +62,8 @@ var (
|
|||||||
TemplateTaskPaused = uuid.MustParse("2a74f3d3-ab09-4123-a4a5-ca238f4f65a1")
|
TemplateTaskPaused = uuid.MustParse("2a74f3d3-ab09-4123-a4a5-ca238f4f65a1")
|
||||||
TemplateTaskResumed = uuid.MustParse("843ee9c3-a8fb-4846-afa9-977bec578649")
|
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{},
|
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:
|
// 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
|
to retain a conversation long after its last message should pin the
|
||||||
root.
|
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
|
## Interaction with retention
|
||||||
|
|
||||||
Auto-archive and deletion are two independent controls:
|
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
|
Each auto-archived root chat produces an audit log entry with the
|
||||||
background subsystem tag `chat_auto_archive`. Cascaded children are
|
background subsystem tag `chat_auto_archive`. Cascaded children are
|
||||||
not audited individually. The audit entry records the chat ID, owner
|
not audited individually.
|
||||||
ID, and organization ID, and the diff shows `archived` flipping from
|
|
||||||
`false` to `true`.
|
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export const Default: Story = {
|
|||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
// System notification templates
|
// System notification templates
|
||||||
|
canvas.findByRole("switch", { name: "Chat Events" }),
|
||||||
canvas.findByRole("switch", { name: "Task Events" }),
|
canvas.findByRole("switch", { name: "Task Events" }),
|
||||||
canvas.findByRole("switch", { name: "Template Events" }),
|
canvas.findByRole("switch", { name: "Template Events" }),
|
||||||
canvas.findByRole("switch", { name: "User Events" }),
|
canvas.findByRole("switch", { name: "User Events" }),
|
||||||
|
|||||||
@@ -278,6 +278,7 @@ function canSeeNotificationGroup(
|
|||||||
return permissions.createUser;
|
return permissions.createUser;
|
||||||
case "Workspace Events":
|
case "Workspace Events":
|
||||||
case "Task Events":
|
case "Task Events":
|
||||||
|
case "Chat Events":
|
||||||
case "Custom Events":
|
case "Custom Events":
|
||||||
return true;
|
return true;
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -4804,6 +4804,20 @@ export const MockSystemNotificationTemplates: TypesGen.NotificationTemplate[] =
|
|||||||
kind: "system",
|
kind: "system",
|
||||||
enabled_by_default: false,
|
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[] =
|
export const MockCustomNotificationTemplates: TypesGen.NotificationTemplate[] =
|
||||||
|
|||||||
Reference in New Issue
Block a user