diff --git a/apps/api/cmd/api/main.go b/apps/api/cmd/api/main.go index b56bd44e..4f1d3680 100644 --- a/apps/api/cmd/api/main.go +++ b/apps/api/cmd/api/main.go @@ -18,6 +18,7 @@ import ( "github.com/Devlaner/devlane/api/internal/rabbitmq" "github.com/Devlaner/devlane/api/internal/redis" "github.com/Devlaner/devlane/api/internal/router" + "github.com/Devlaner/devlane/api/internal/service" "github.com/Devlaner/devlane/api/internal/store" ) @@ -124,6 +125,26 @@ func main() { } } + // Periodically auto-archive settled work items for projects that opt in + // (archive_in > 0). Runs in-process; stops on shutdown via consumerCtx. + automationSvc := service.NewAutomationService(store.NewProjectStore(db), store.NewIssueStore(db)) + go func() { + ticker := time.NewTicker(6 * time.Hour) + defer ticker.Stop() + for { + select { + case <-consumerCtx.Done(): + return + case <-ticker.C: + if n, err := automationSvc.RunAutoArchive(consumerCtx); err != nil { + log.Warn("auto-archive", "error", err) + } else if n > 0 { + log.Info("auto-archive", "archived", n) + } + } + } + }() + addr := ":" + cfg.ServerPort srv := &http.Server{ Addr: addr, diff --git a/apps/api/internal/handler/project.go b/apps/api/internal/handler/project.go index ee595333..a912182c 100644 --- a/apps/api/internal/handler/project.go +++ b/apps/api/internal/handler/project.go @@ -223,6 +223,7 @@ func (h *ProjectHandler) Create(c *gin.Context) { body.PageView, body.IntakeView, body.IsTimeTrackingEnabled, + nil, // archive_in: set via project settings, not on create ) if err != nil { if err == service.ErrInvalidNetwork { @@ -271,6 +272,7 @@ func (h *ProjectHandler) Update(c *gin.Context) { PageView *bool `json:"page_view"` IntakeView *bool `json:"intake_view"` IsTimeTrackingEnabled *bool `json:"is_time_tracking_enabled"` + ArchiveIn *int `json:"archive_in"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "detail": err.Error()}) @@ -328,13 +330,13 @@ func (h *ProjectHandler) Update(c *gin.Context) { defaultAssigneeIDPtr = &id } } - p, err := h.Project.Update(c.Request.Context(), slug, projectID, user.ID, name, identifier, description, timezone, coverImage, body.Emoji, iconProp, body.ProjectLeadID != nil, projectLeadIDPtr, body.DefaultAssigneeID != nil, defaultAssigneeIDPtr, body.GuestViewAllFeatures, body.Network, body.ModuleView, body.CycleView, body.IssueViewsView, body.PageView, body.IntakeView, body.IsTimeTrackingEnabled) + p, err := h.Project.Update(c.Request.Context(), slug, projectID, user.ID, name, identifier, description, timezone, coverImage, body.Emoji, iconProp, body.ProjectLeadID != nil, projectLeadIDPtr, body.DefaultAssigneeID != nil, defaultAssigneeIDPtr, body.GuestViewAllFeatures, body.Network, body.ModuleView, body.CycleView, body.IssueViewsView, body.PageView, body.IntakeView, body.IsTimeTrackingEnabled, body.ArchiveIn) if err != nil { if err == service.ErrProjectNotFound || err == service.ErrProjectForbidden { c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"}) return } - if err == service.ErrProjectIdentifierTooLong || err == service.ErrInvalidNetwork { + if err == service.ErrProjectIdentifierTooLong || err == service.ErrInvalidNetwork || err == service.ErrInvalidArchiveIn { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } diff --git a/apps/api/internal/model/project.go b/apps/api/internal/model/project.go index b7f32842..cb48512d 100644 --- a/apps/api/internal/model/project.go +++ b/apps/api/internal/model/project.go @@ -76,6 +76,9 @@ type Project struct { GuestViewAllFeatures bool `gorm:"column:guest_view_all_features;default:false" json:"guest_view_all_features"` CoverImage string `gorm:"column:cover_image;type:text" json:"cover_image,omitempty"` Timezone string `gorm:"default:UTC" json:"timezone"` + // ArchiveIn is the number of months after which settled (completed/cancelled) + // work items are auto-archived. 0 disables the automation. + ArchiveIn int `gorm:"column:archive_in;default:0" json:"archive_in"` } func (Project) TableName() string { return "projects" } diff --git a/apps/api/internal/service/automation.go b/apps/api/internal/service/automation.go new file mode 100644 index 00000000..49a86d0a --- /dev/null +++ b/apps/api/internal/service/automation.go @@ -0,0 +1,43 @@ +package service + +import ( + "context" + "time" + + "github.com/Devlaner/devlane/api/internal/store" +) + +// AutomationService runs project-level background automations. +type AutomationService struct { + projects *store.ProjectStore + issues *store.IssueStore +} + +func NewAutomationService(projects *store.ProjectStore, issues *store.IssueStore) *AutomationService { + return &AutomationService{projects: projects, issues: issues} +} + +// RunAutoArchive archives settled (completed/cancelled) work items in every +// project that has auto-archive enabled, once they have been untouched for the +// project's configured number of months. Returns the total archived. Safe to run +// repeatedly: already-archived items are skipped. +func (s *AutomationService) RunAutoArchive(ctx context.Context) (int64, error) { + projects, err := s.projects.ListWithAutoArchive(ctx) + if err != nil { + return 0, err + } + var total int64 + for i := range projects { + p := &projects[i] + if p.ArchiveIn <= 0 { + continue + } + cutoff := time.Now().AddDate(0, -p.ArchiveIn, 0) + n, err := s.issues.ArchiveSettledBefore(ctx, p.ID, cutoff) + if err != nil { + return total, err + } + total += n + } + return total, nil +} diff --git a/apps/api/internal/service/automation_test.go b/apps/api/internal/service/automation_test.go new file mode 100644 index 00000000..73901292 --- /dev/null +++ b/apps/api/internal/service/automation_test.go @@ -0,0 +1,89 @@ +package service_test + +import ( + "context" + "testing" + "time" + + "github.com/Devlaner/devlane/api/internal/model" + "github.com/Devlaner/devlane/api/internal/service" + "github.com/Devlaner/devlane/api/internal/store" + "github.com/Devlaner/devlane/api/internal/testutil" + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +func isArchived(t *testing.T, db *gorm.DB, id uuid.UUID) bool { + t.Helper() + var iss model.Issue + require.NoError(t, db.First(&iss, "id = ?", id).Error) + return iss.ArchivedAt != nil +} + +// Auto-archive only touches work items that are both settled (completed/cancelled +// state) and untouched for longer than the project's archive_in months; active or +// recently-settled items are left alone. Covers #194. +func TestAutoArchive_ArchivesOnlySettledStaleIssues(t *testing.T) { + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) + + // Enable auto-archive after 1 month. + require.NoError(t, ts.DB.Model(&model.Project{}).Where("id = ?", w.Project.ID). + UpdateColumn("archive_in", 1).Error) + + doneState := testutil.CreateState(t, ts.DB, w.Project.ID, w.Workspace.ID) + require.NoError(t, ts.DB.Model(doneState).Updates(map[string]any{"group": "completed"}).Error) + + old := time.Now().AddDate(0, -2, 0) // two months ago + now := time.Now() + + // settled + stale -> should archive. + settledOld := testutil.CreateIssue(t, ts.DB, w.Project.ID, w.Workspace.ID, w.User.ID) + require.NoError(t, ts.DB.Model(&model.Issue{}).Where("id = ?", settledOld.ID). + UpdateColumns(map[string]any{"state_id": doneState.ID, "updated_at": old}).Error) + + // settled + recent -> should not archive. + settledRecent := testutil.CreateIssue(t, ts.DB, w.Project.ID, w.Workspace.ID, w.User.ID) + require.NoError(t, ts.DB.Model(&model.Issue{}).Where("id = ?", settledRecent.ID). + UpdateColumns(map[string]any{"state_id": doneState.ID, "updated_at": now}).Error) + + // active (no state -> backlog) + stale -> should not archive. + activeOld := testutil.CreateIssue(t, ts.DB, w.Project.ID, w.Workspace.ID, w.User.ID) + require.NoError(t, ts.DB.Model(&model.Issue{}).Where("id = ?", activeOld.ID). + UpdateColumn("updated_at", old).Error) + + svc := service.NewAutomationService(store.NewProjectStore(ts.DB), store.NewIssueStore(ts.DB)) + n, err := svc.RunAutoArchive(context.Background()) + require.NoError(t, err) + require.EqualValues(t, 1, n, "only the stale settled issue should be archived") + + require.True(t, isArchived(t, ts.DB, settledOld.ID)) + require.False(t, isArchived(t, ts.DB, settledRecent.ID)) + require.False(t, isArchived(t, ts.DB, activeOld.ID)) + + // Running again is a no-op (already archived). + n2, err := svc.RunAutoArchive(context.Background()) + require.NoError(t, err) + require.EqualValues(t, 0, n2) +} + +// A project with auto-archive disabled (archive_in = 0) is never touched. +func TestAutoArchive_DisabledProjectUntouched(t *testing.T) { + ts := testutil.NewTestServer(t) + w := testutil.SeedWorld(t, ts.DB) // archive_in defaults to 0 + + doneState := testutil.CreateState(t, ts.DB, w.Project.ID, w.Workspace.ID) + require.NoError(t, ts.DB.Model(doneState).Updates(map[string]any{"group": "completed"}).Error) + + old := time.Now().AddDate(0, -12, 0) + iss := testutil.CreateIssue(t, ts.DB, w.Project.ID, w.Workspace.ID, w.User.ID) + require.NoError(t, ts.DB.Model(&model.Issue{}).Where("id = ?", iss.ID). + UpdateColumns(map[string]any{"state_id": doneState.ID, "updated_at": old}).Error) + + svc := service.NewAutomationService(store.NewProjectStore(ts.DB), store.NewIssueStore(ts.DB)) + n, err := svc.RunAutoArchive(context.Background()) + require.NoError(t, err) + require.EqualValues(t, 0, n) + require.False(t, isArchived(t, ts.DB, iss.ID)) +} diff --git a/apps/api/internal/service/project.go b/apps/api/internal/service/project.go index fd162959..4fb16056 100644 --- a/apps/api/internal/service/project.go +++ b/apps/api/internal/service/project.go @@ -19,6 +19,7 @@ var ( ErrProjectForbidden = errors.New("no access to this project") ErrProjectIdentifierTooLong = errors.New("project identifier must be at most 7 characters") ErrInvalidNetwork = errors.New("network must be public or secret") + ErrInvalidArchiveIn = errors.New("archive_in must be zero or a positive number of months") ) // ProjectService handles project business logic. @@ -134,7 +135,7 @@ func (s *ProjectService) Create(ctx context.Context, workspaceSlug, name, identi return p, nil } -func (s *ProjectService) Update(ctx context.Context, workspaceSlug string, projectID uuid.UUID, userID uuid.UUID, name, identifier, description, timezone, coverImage *string, emoji *string, iconProp *model.JSONMap, projectLeadIDSet bool, projectLeadID *uuid.UUID, defaultAssigneeIDSet bool, defaultAssigneeID *uuid.UUID, guestViewAllFeatures *bool, network *int16, moduleView, cycleView, issueViewsView, pageView, intakeView, isTimeTrackingEnabled *bool) (*model.Project, error) { +func (s *ProjectService) Update(ctx context.Context, workspaceSlug string, projectID uuid.UUID, userID uuid.UUID, name, identifier, description, timezone, coverImage *string, emoji *string, iconProp *model.JSONMap, projectLeadIDSet bool, projectLeadID *uuid.UUID, defaultAssigneeIDSet bool, defaultAssigneeID *uuid.UUID, guestViewAllFeatures *bool, network *int16, moduleView, cycleView, issueViewsView, pageView, intakeView, isTimeTrackingEnabled *bool, archiveIn *int) (*model.Project, error) { p, err := s.GetByID(ctx, workspaceSlug, projectID, userID) if err != nil { return nil, err @@ -202,6 +203,12 @@ func (s *ProjectService) Update(ctx context.Context, workspaceSlug string, proje if isTimeTrackingEnabled != nil { p.IsTimeTrackingEnabled = *isTimeTrackingEnabled } + if archiveIn != nil { + if *archiveIn < 0 { + return nil, ErrInvalidArchiveIn + } + p.ArchiveIn = *archiveIn + } if err := s.ps.Update(ctx, p); err != nil { return nil, err } diff --git a/apps/api/internal/store/issue.go b/apps/api/internal/store/issue.go index 2c68765f..63307db6 100644 --- a/apps/api/internal/store/issue.go +++ b/apps/api/internal/store/issue.go @@ -137,6 +137,23 @@ func (s *IssueStore) SetArchived(ctx context.Context, id uuid.UUID, archived boo Updates(updates).Error } +// ArchiveSettledBefore archives (sets archived_at) the project's non-archived, +// non-draft work items that sit in a completed/cancelled state and were last +// touched before cutoff. Returns how many were archived. Used by auto-archive. +func (s *IssueStore) ArchiveSettledBefore(ctx context.Context, projectID uuid.UUID, cutoff time.Time) (int64, error) { + res := s.db.WithContext(ctx). + Model(&model.Issue{}). + Where(`project_id = ? AND deleted_at IS NULL AND archived_at IS NULL AND is_draft IS NOT TRUE + AND updated_at < ? + AND state_id IN (SELECT id FROM states WHERE project_id = ? AND "group" IN ('completed','cancelled'))`, + projectID, cutoff, projectID). + Update("archived_at", time.Now()) + if res.Error != nil { + return 0, res.Error + } + return res.RowsAffected, nil +} + func (s *IssueStore) ListDraftsByWorkspaceID(ctx context.Context, workspaceID uuid.UUID, limit, offset int) ([]model.Issue, error) { var list []model.Issue q := s.db.WithContext(ctx).Where( diff --git a/apps/api/internal/store/project.go b/apps/api/internal/store/project.go index 0d9b4fa4..804e8f6a 100644 --- a/apps/api/internal/store/project.go +++ b/apps/api/internal/store/project.go @@ -92,6 +92,16 @@ func (s *ProjectStore) GetByWorkspaceAndIdentifier(ctx context.Context, workspac } // IsInWorkspace checks that the project belongs to the workspace. +// ListWithAutoArchive returns projects that have auto-archive enabled (archive_in > 0). +func (s *ProjectStore) ListWithAutoArchive(ctx context.Context) ([]model.Project, error) { + var list []model.Project + err := s.db.WithContext(ctx).Where("archive_in > 0 AND deleted_at IS NULL").Find(&list).Error + if err != nil { + return nil, err + } + return list, nil +} + func (s *ProjectStore) IsInWorkspace(ctx context.Context, projectID, workspaceID uuid.UUID) (bool, error) { var count int64 err := s.db.WithContext(ctx).Model(&model.Project{}). diff --git a/apps/api/migrations/000007_project_archive_in.down.sql b/apps/api/migrations/000007_project_archive_in.down.sql new file mode 100644 index 00000000..77ddda6a --- /dev/null +++ b/apps/api/migrations/000007_project_archive_in.down.sql @@ -0,0 +1 @@ +ALTER TABLE projects DROP COLUMN IF EXISTS archive_in; diff --git a/apps/api/migrations/000007_project_archive_in.up.sql b/apps/api/migrations/000007_project_archive_in.up.sql new file mode 100644 index 00000000..e5f6b4ee --- /dev/null +++ b/apps/api/migrations/000007_project_archive_in.up.sql @@ -0,0 +1,3 @@ +-- Auto-archive: number of months after which settled (completed/cancelled) work +-- items are archived. 0 disables the automation for the project. +ALTER TABLE projects ADD COLUMN IF NOT EXISTS archive_in INT NOT NULL DEFAULT 0; diff --git a/apps/web/src/api/types.ts b/apps/web/src/api/types.ts index 66817133..ad378df3 100644 --- a/apps/web/src/api/types.ts +++ b/apps/web/src/api/types.ts @@ -106,6 +106,8 @@ export interface ProjectApiResponse { page_view?: boolean; intake_view?: boolean; is_time_tracking_enabled?: boolean; + /** Auto-archive: months of inactivity after which settled items archive (0 = off). */ + archive_in?: number; created_at?: string; updated_at?: string; } diff --git a/apps/web/src/pages/SettingsPage.tsx b/apps/web/src/pages/SettingsPage.tsx index d508e70c..ab4bdfc0 100644 --- a/apps/web/src/pages/SettingsPage.tsx +++ b/apps/web/src/pages/SettingsPage.tsx @@ -232,6 +232,9 @@ export function SettingsPage() { setFeaturePages(selectedProject.page_view ?? true); setFeatureIntake(selectedProject.intake_view ?? false); setFeatureTimeTracking(selectedProject.is_time_tracking_enabled ?? false); + const months = selectedProject.archive_in ?? 0; + setAutoArchive(months > 0); + if (months > 0) setAutoArchiveMonths(months); } }, [ selectedProject, @@ -249,8 +252,31 @@ export function SettingsPage() { selectedProject?.page_view, selectedProject?.intake_view, selectedProject?.is_time_tracking_enabled, + selectedProject?.archive_in, ]); + // Persist the auto-archive automation: archive_in is the number of months (0 + // disables it). Optimistically flips the toggle, then saves. + const persistAutoArchive = async (enabled: boolean, months: number) => { + if (!workspaceSlug || !selectedProjectId) return; + setAutoArchive(enabled); + setAutoArchiveMonths(months); + setAutoArchiveSaving(true); + try { + const updated = await projectService.update(workspaceSlug, selectedProjectId, { + archive_in: enabled ? months : 0, + }); + setProjects((prev) => prev.map((p) => (p.id === updated.id ? updated : p))); + } catch { + // Revert the toggle to the persisted value on failure. + const persisted = selectedProject?.archive_in ?? 0; + setAutoArchive(persisted > 0); + if (persisted > 0) setAutoArchiveMonths(persisted); + } finally { + setAutoArchiveSaving(false); + } + }; + const [workspaceName, setWorkspaceName] = useState(''); const [companySize, setCompanySize] = useState('51-200'); const [generalUpdateLoading, setGeneralUpdateLoading] = useState(false); @@ -358,7 +384,9 @@ export function SettingsPage() { const [featurePages, setFeaturePages] = useState(true); const [featureIntake, setFeatureIntake] = useState(false); const [featureTimeTracking, setFeatureTimeTracking] = useState(false); - const [autoArchive, setAutoArchive] = useState(true); + const [autoArchive, setAutoArchive] = useState(false); + const [autoArchiveMonths, setAutoArchiveMonths] = useState(3); + const [autoArchiveSaving, setAutoArchiveSaving] = useState(false); const [autoClose, setAutoClose] = useState(true); const [pendingInvitesExpanded, setPendingInvitesExpanded] = useState(true); const [pendingInviteMenuId, setPendingInviteMenuId] = useState(null); @@ -2578,6 +2606,23 @@ export function SettingsPage() {

Devlane will auto archive work items that have been completed or canceled.

+ {autoArchive && ( + + )}