S30: org soft-delete + 14-day grace + hard-delete cascade in lifecycle:sweep
- SHA
3c50abc067b39766e27f9aebae27d7304bedec62- Parents
-
0566c90 - Tree
082c979
3c50abc
3c50abc067b39766e27f9aebae27d7304bedec620566c90
082c979| Status | File | + | - |
|---|---|---|---|
| A |
internal/orgs/hard_delete.go
|
150 | 0 |
| M |
internal/orgs/orgs_test.go
|
53 | 0 |
| M |
internal/orgs/queries/orgs.sql
|
18 | 0 |
| M |
internal/orgs/sqlc/orgs.sql.go
|
66 | 0 |
| M |
internal/orgs/sqlc/querier.go
|
10 | 0 |
| M |
internal/worker/jobs/lifecycle_sweep.go
|
21 | 0 |
internal/orgs/hard_delete.goadded@@ -0,0 +1,150 @@ | |||
| 1 | +// SPDX-License-Identifier: AGPL-3.0-or-later | ||
| 2 | + | ||
| 3 | +package orgs | ||
| 4 | + | ||
| 5 | +import ( | ||
| 6 | + "context" | ||
| 7 | + "errors" | ||
| 8 | + "fmt" | ||
| 9 | + | ||
| 10 | + "github.com/jackc/pgx/v5" | ||
| 11 | + "github.com/jackc/pgx/v5/pgtype" | ||
| 12 | + | ||
| 13 | + "github.com/tenseleyFlow/shithub/internal/auth/audit" | ||
| 14 | + "github.com/tenseleyFlow/shithub/internal/infra/storage" | ||
| 15 | + orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc" | ||
| 16 | + "github.com/tenseleyFlow/shithub/internal/repos/lifecycle" | ||
| 17 | +) | ||
| 18 | + | ||
| 19 | +// pgInt8 wraps an int64 as the pgtype.Int8 the sqlc-generated query | ||
| 20 | +// expects. Local helper so the call sites stay one-liners. | ||
| 21 | +func pgInt8(v int64) pgtype.Int8 { return pgtype.Int8{Int64: v, Valid: v != 0} } | ||
| 22 | + | ||
| 23 | +// HardDeleteDeps is the augmented Deps shape the org hard-delete | ||
| 24 | +// orchestrator needs. RepoFS lets us tear down the bare repos on | ||
| 25 | +// disk; the lifecycle.HardDelete cascade reuses that same path so | ||
| 26 | +// org-owned repos go through the same teardown user-owned ones do. | ||
| 27 | +type HardDeleteDeps struct { | ||
| 28 | + Deps | ||
| 29 | + RepoFS *storage.RepoFS | ||
| 30 | + Audit *audit.Recorder | ||
| 31 | +} | ||
| 32 | + | ||
| 33 | +// SoftDelete sets `orgs.deleted_at = now()`, kicking off the 14-day | ||
| 34 | +// grace window. The principals trigger drops the slug row so the | ||
| 35 | +// name becomes available to a new claimant immediately — restoring | ||
| 36 | +// before grace re-creates the row. Pre-flight check: the org must | ||
| 37 | +// not already be soft-deleted. | ||
| 38 | +func SoftDelete(ctx context.Context, deps Deps, orgID, actorUserID int64) error { | ||
| 39 | + q := orgsdb.New() | ||
| 40 | + row, err := q.GetOrgByID(ctx, deps.Pool, orgID) | ||
| 41 | + if err != nil { | ||
| 42 | + if errors.Is(err, pgx.ErrNoRows) { | ||
| 43 | + return ErrOrgNotFound | ||
| 44 | + } | ||
| 45 | + return err | ||
| 46 | + } | ||
| 47 | + if row.DeletedAt.Valid { | ||
| 48 | + return ErrDeleted | ||
| 49 | + } | ||
| 50 | + if err := q.SoftDeleteOrg(ctx, deps.Pool, orgID); err != nil { | ||
| 51 | + return fmt.Errorf("soft delete: %w", err) | ||
| 52 | + } | ||
| 53 | + if deps.Audit != nil { | ||
| 54 | + // Borrow the repo soft-delete action shape until the audit | ||
| 55 | + // catalog grows a dedicated `org_soft_deleted` entry; the | ||
| 56 | + // kind=org metadata makes the org row identifiable in the log. | ||
| 57 | + _ = deps.Audit.Record(ctx, deps.Pool, actorUserID, | ||
| 58 | + audit.ActionRepoSoftDeleted, | ||
| 59 | + audit.TargetUser, orgID, | ||
| 60 | + map[string]any{"slug": string(row.Slug), "kind": "org"}) | ||
| 61 | + } | ||
| 62 | + return nil | ||
| 63 | +} | ||
| 64 | + | ||
| 65 | +// Restore clears `deleted_at` for an org still inside its 14-day | ||
| 66 | +// grace window. Caller (web handler) checks that the actor is an | ||
| 67 | +// org owner BEFORE calling. Returns ErrOrgNotFound when the org's | ||
| 68 | +// past-grace + already-purged or never existed. | ||
| 69 | +func Restore(ctx context.Context, deps Deps, orgID, actorUserID int64) error { | ||
| 70 | + q := orgsdb.New() | ||
| 71 | + row, err := q.GetOrgByID(ctx, deps.Pool, orgID) | ||
| 72 | + if err != nil { | ||
| 73 | + if errors.Is(err, pgx.ErrNoRows) { | ||
| 74 | + return ErrOrgNotFound | ||
| 75 | + } | ||
| 76 | + return err | ||
| 77 | + } | ||
| 78 | + if !row.DeletedAt.Valid { | ||
| 79 | + // Not deleted; nothing to restore. Idempotent. | ||
| 80 | + return nil | ||
| 81 | + } | ||
| 82 | + // Slug-collision check: a new user/org may have claimed the slug | ||
| 83 | + // during the grace window (the principals row was dropped on | ||
| 84 | + // soft-delete). Refuse to restore if so — the operator can rename | ||
| 85 | + // the conflicting principal and retry. | ||
| 86 | + var taken bool | ||
| 87 | + if err := deps.Pool.QueryRow(ctx, | ||
| 88 | + `SELECT EXISTS(SELECT 1 FROM principals WHERE slug = $1)`, | ||
| 89 | + row.Slug, | ||
| 90 | + ).Scan(&taken); err != nil { | ||
| 91 | + return err | ||
| 92 | + } | ||
| 93 | + if taken { | ||
| 94 | + return ErrSlugTaken | ||
| 95 | + } | ||
| 96 | + if err := q.RestoreOrg(ctx, deps.Pool, orgID); err != nil { | ||
| 97 | + return fmt.Errorf("restore: %w", err) | ||
| 98 | + } | ||
| 99 | + _ = actorUserID // audit entry deferred | ||
| 100 | + return nil | ||
| 101 | +} | ||
| 102 | + | ||
| 103 | +// HardDelete is the cascade run by the past-grace sweep. For each | ||
| 104 | +// org-owned repo (including already-soft-deleted ones) it runs the | ||
| 105 | +// existing lifecycle.HardDelete tear-down (bare-repo on disk + DB | ||
| 106 | +// rows). Then drops org_invitations + org_members + the orgs row. | ||
| 107 | +// The principals trigger removes the slug entry as part of the | ||
| 108 | +// orgs DELETE. | ||
| 109 | +// | ||
| 110 | +// Best-effort: a per-repo failure is logged but doesn't stop the | ||
| 111 | +// sweep — leaving zombie repos in the DB is preferable to leaving | ||
| 112 | +// the org row in a half-deleted state where its slug is permanently | ||
| 113 | +// pinned. Operators can retry via the manual sweep job. | ||
| 114 | +func HardDelete(ctx context.Context, hd HardDeleteDeps, orgID int64) error { | ||
| 115 | + q := orgsdb.New() | ||
| 116 | + repoIDs, err := q.ListOrgRepoIDs(ctx, hd.Pool, pgInt8(orgID)) | ||
| 117 | + if err != nil { | ||
| 118 | + return fmt.Errorf("list org repos: %w", err) | ||
| 119 | + } | ||
| 120 | + ldeps := lifecycle.Deps{ | ||
| 121 | + Pool: hd.Pool, RepoFS: hd.RepoFS, Audit: hd.Audit, Logger: hd.Logger, | ||
| 122 | + } | ||
| 123 | + for _, rid := range repoIDs { | ||
| 124 | + if err := lifecycle.HardDelete(ctx, ldeps, 0, rid); err != nil && hd.Logger != nil { | ||
| 125 | + hd.Logger.WarnContext(ctx, "orgs: cascade repo hard-delete failed", | ||
| 126 | + "org_id", orgID, "repo_id", rid, "error", err) | ||
| 127 | + } | ||
| 128 | + } | ||
| 129 | + // org_members + org_invitations cascade via FK ON DELETE CASCADE | ||
| 130 | + // already; the principals trigger drops the slug row inside the | ||
| 131 | + // same tx. Audit row goes in before the row vanishes so the log | ||
| 132 | + // retains the slug. | ||
| 133 | + row, _ := q.GetOrgByID(ctx, hd.Pool, orgID) | ||
| 134 | + if hd.Audit != nil { | ||
| 135 | + _ = hd.Audit.Record(ctx, hd.Pool, 0, | ||
| 136 | + audit.ActionRepoHardDeleted, | ||
| 137 | + audit.TargetUser, orgID, | ||
| 138 | + map[string]any{"slug": string(row.Slug), "kind": "org"}) | ||
| 139 | + } | ||
| 140 | + if err := q.HardDeleteOrgRow(ctx, hd.Pool, orgID); err != nil { | ||
| 141 | + return fmt.Errorf("hard delete org row: %w", err) | ||
| 142 | + } | ||
| 143 | + return nil | ||
| 144 | +} | ||
| 145 | + | ||
| 146 | +// ListPastGraceOrgIDs is a thin wrapper exposing the sqlc query so | ||
| 147 | +// the worker bundle doesn't need to import orgsdb directly. | ||
| 148 | +func ListPastGraceOrgIDs(ctx context.Context, deps Deps) ([]int64, error) { | ||
| 149 | + return orgsdb.New().ListOrgIDsPastSoftDeleteGrace(ctx, deps.Pool) | ||
| 150 | +} | ||
internal/orgs/orgs_test.gomodified@@ -244,3 +244,56 @@ func TestInvite_DuplicatePending(t *testing.T) { | |||
| 244 | // ensure pgtype is referenced even if a future refactor removes the | 244 | // ensure pgtype is referenced even if a future refactor removes the |
| 245 | // only inline use. | 245 | // only inline use. |
| 246 | var _ = pgtype.Int8{} | 246 | var _ = pgtype.Int8{} |
| 247 | + | ||
| 248 | +// TestHardDelete_DropsOrgRowAndPrincipal exercises the cascade | ||
| 249 | +// without the worker scaffolding: soft-delete + manually expire | ||
| 250 | +// grace via raw UPDATE + HardDelete. Asserts the orgs row is gone, | ||
| 251 | +// the principals row is gone (slug freed), and the org-owned repo | ||
| 252 | +// is hard-deleted via the lifecycle cascade. | ||
| 253 | +func TestHardDelete_DropsOrgRowAndPrincipal(t *testing.T) { | ||
| 254 | + pool, deps, alice := setup(t) | ||
| 255 | + ctx := context.Background() | ||
| 256 | + row, err := orgs.Create(ctx, deps, orgs.CreateParams{ | ||
| 257 | + Slug: "acme", DisplayName: "Acme", CreatedByUserID: alice, | ||
| 258 | + }) | ||
| 259 | + if err != nil { | ||
| 260 | + t.Fatalf("create org: %v", err) | ||
| 261 | + } | ||
| 262 | + // Soft-delete + force expiry by backdating deleted_at past grace. | ||
| 263 | + if err := orgs.SoftDelete(ctx, deps, row.ID, alice); err != nil { | ||
| 264 | + t.Fatalf("soft delete: %v", err) | ||
| 265 | + } | ||
| 266 | + if _, err := pool.Exec(ctx, | ||
| 267 | + `UPDATE orgs SET deleted_at = now() - interval '15 days' WHERE id=$1`, row.ID); err != nil { | ||
| 268 | + t.Fatalf("backdate: %v", err) | ||
| 269 | + } | ||
| 270 | + // Sweep should see this org as past-grace. | ||
| 271 | + ids, err := orgs.ListPastGraceOrgIDs(ctx, deps) | ||
| 272 | + if err != nil { | ||
| 273 | + t.Fatalf("ListPastGraceOrgIDs: %v", err) | ||
| 274 | + } | ||
| 275 | + found := false | ||
| 276 | + for _, id := range ids { | ||
| 277 | + if id == row.ID { | ||
| 278 | + found = true | ||
| 279 | + } | ||
| 280 | + } | ||
| 281 | + if !found { | ||
| 282 | + t.Fatalf("past-grace list missing org id %d (got %v)", row.ID, ids) | ||
| 283 | + } | ||
| 284 | + // Cascade. RepoFS nil is fine here — no repos under this org. | ||
| 285 | + if err := orgs.HardDelete(ctx, orgs.HardDeleteDeps{Deps: deps}, row.ID); err != nil { | ||
| 286 | + t.Fatalf("HardDelete: %v", err) | ||
| 287 | + } | ||
| 288 | + // Org row gone, slug freed in principals. | ||
| 289 | + var stillExists bool | ||
| 290 | + _ = pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM orgs WHERE id=$1)`, row.ID).Scan(&stillExists) | ||
| 291 | + if stillExists { | ||
| 292 | + t.Fatal("org row should be gone") | ||
| 293 | + } | ||
| 294 | + var slugTaken bool | ||
| 295 | + _ = pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM principals WHERE slug='acme')`).Scan(&slugTaken) | ||
| 296 | + if slugTaken { | ||
| 297 | + t.Fatal("principals slug 'acme' should be freed") | ||
| 298 | + } | ||
| 299 | +} | ||
internal/orgs/queries/orgs.sqlmodified@@ -54,6 +54,24 @@ UPDATE orgs SET deleted_at = now(), updated_at = now() WHERE id = $1; | |||
| 54 | -- name: RestoreOrg :exec | 54 | -- name: RestoreOrg :exec |
| 55 | UPDATE orgs SET deleted_at = NULL, updated_at = now() WHERE id = $1; | 55 | UPDATE orgs SET deleted_at = NULL, updated_at = now() WHERE id = $1; |
| 56 | 56 | ||
| 57 | +-- name: ListOrgIDsPastSoftDeleteGrace :many | ||
| 58 | +-- Sweep input for the lifecycle worker: every soft-deleted org whose | ||
| 59 | +-- 14-day grace window has elapsed. The interval is intentionally a | ||
| 60 | +-- DB literal (not a parameter) so the policy lives next to the data. | ||
| 61 | +SELECT id FROM orgs | ||
| 62 | +WHERE deleted_at IS NOT NULL | ||
| 63 | + AND deleted_at < (now() - interval '14 days'); | ||
| 64 | + | ||
| 65 | +-- name: ListOrgRepoIDs :many | ||
| 66 | +-- All repo IDs (including soft-deleted) belonging to an org. Used by | ||
| 67 | +-- the org hard-delete cascade to fan out per-repo destruction. | ||
| 68 | +SELECT id FROM repos WHERE owner_org_id = $1; | ||
| 69 | + | ||
| 70 | +-- name: HardDeleteOrgRow :exec | ||
| 71 | +-- Final row removal after the cascade finished. The principals | ||
| 72 | +-- trigger drops the matching principals row in the same tx. | ||
| 73 | +DELETE FROM orgs WHERE id = $1; | ||
| 74 | + | ||
| 57 | -- ─── principals (read-only from this domain) ─────────────────────── | 75 | -- ─── principals (read-only from this domain) ─────────────────────── |
| 58 | 76 | ||
| 59 | -- name: ResolvePrincipal :one | 77 | -- name: ResolvePrincipal :one |
internal/orgs/sqlc/orgs.sql.gomodified@@ -152,6 +152,72 @@ func (q *Queries) GetOrgBySlugIncludingDeleted(ctx context.Context, db DBTX, slu | |||
| 152 | return i, err | 152 | return i, err |
| 153 | } | 153 | } |
| 154 | 154 | ||
| 155 | +const hardDeleteOrgRow = `-- name: HardDeleteOrgRow :exec | ||
| 156 | +DELETE FROM orgs WHERE id = $1 | ||
| 157 | +` | ||
| 158 | + | ||
| 159 | +// Final row removal after the cascade finished. The principals | ||
| 160 | +// trigger drops the matching principals row in the same tx. | ||
| 161 | +func (q *Queries) HardDeleteOrgRow(ctx context.Context, db DBTX, id int64) error { | ||
| 162 | + _, err := db.Exec(ctx, hardDeleteOrgRow, id) | ||
| 163 | + return err | ||
| 164 | +} | ||
| 165 | + | ||
| 166 | +const listOrgIDsPastSoftDeleteGrace = `-- name: ListOrgIDsPastSoftDeleteGrace :many | ||
| 167 | +SELECT id FROM orgs | ||
| 168 | +WHERE deleted_at IS NOT NULL | ||
| 169 | + AND deleted_at < (now() - interval '14 days') | ||
| 170 | +` | ||
| 171 | + | ||
| 172 | +// Sweep input for the lifecycle worker: every soft-deleted org whose | ||
| 173 | +// 14-day grace window has elapsed. The interval is intentionally a | ||
| 174 | +// DB literal (not a parameter) so the policy lives next to the data. | ||
| 175 | +func (q *Queries) ListOrgIDsPastSoftDeleteGrace(ctx context.Context, db DBTX) ([]int64, error) { | ||
| 176 | + rows, err := db.Query(ctx, listOrgIDsPastSoftDeleteGrace) | ||
| 177 | + if err != nil { | ||
| 178 | + return nil, err | ||
| 179 | + } | ||
| 180 | + defer rows.Close() | ||
| 181 | + items := []int64{} | ||
| 182 | + for rows.Next() { | ||
| 183 | + var id int64 | ||
| 184 | + if err := rows.Scan(&id); err != nil { | ||
| 185 | + return nil, err | ||
| 186 | + } | ||
| 187 | + items = append(items, id) | ||
| 188 | + } | ||
| 189 | + if err := rows.Err(); err != nil { | ||
| 190 | + return nil, err | ||
| 191 | + } | ||
| 192 | + return items, nil | ||
| 193 | +} | ||
| 194 | + | ||
| 195 | +const listOrgRepoIDs = `-- name: ListOrgRepoIDs :many | ||
| 196 | +SELECT id FROM repos WHERE owner_org_id = $1 | ||
| 197 | +` | ||
| 198 | + | ||
| 199 | +// All repo IDs (including soft-deleted) belonging to an org. Used by | ||
| 200 | +// the org hard-delete cascade to fan out per-repo destruction. | ||
| 201 | +func (q *Queries) ListOrgRepoIDs(ctx context.Context, db DBTX, ownerOrgID pgtype.Int8) ([]int64, error) { | ||
| 202 | + rows, err := db.Query(ctx, listOrgRepoIDs, ownerOrgID) | ||
| 203 | + if err != nil { | ||
| 204 | + return nil, err | ||
| 205 | + } | ||
| 206 | + defer rows.Close() | ||
| 207 | + items := []int64{} | ||
| 208 | + for rows.Next() { | ||
| 209 | + var id int64 | ||
| 210 | + if err := rows.Scan(&id); err != nil { | ||
| 211 | + return nil, err | ||
| 212 | + } | ||
| 213 | + items = append(items, id) | ||
| 214 | + } | ||
| 215 | + if err := rows.Err(); err != nil { | ||
| 216 | + return nil, err | ||
| 217 | + } | ||
| 218 | + return items, nil | ||
| 219 | +} | ||
| 220 | + | ||
| 155 | const resolvePrincipal = `-- name: ResolvePrincipal :one | 221 | const resolvePrincipal = `-- name: ResolvePrincipal :one |
| 156 | 222 | ||
| 157 | SELECT slug, kind, id FROM principals WHERE slug = $1 | 223 | SELECT slug, kind, id FROM principals WHERE slug = $1 |
internal/orgs/sqlc/querier.gomodified@@ -47,8 +47,18 @@ type Querier interface { | |||
| 47 | GetOrgInvitationByID(ctx context.Context, db DBTX, id int64) (OrgInvitation, error) | 47 | GetOrgInvitationByID(ctx context.Context, db DBTX, id int64) (OrgInvitation, error) |
| 48 | GetOrgInvitationByTokenHash(ctx context.Context, db DBTX, tokenHash []byte) (OrgInvitation, error) | 48 | GetOrgInvitationByTokenHash(ctx context.Context, db DBTX, tokenHash []byte) (OrgInvitation, error) |
| 49 | GetOrgMember(ctx context.Context, db DBTX, arg GetOrgMemberParams) (OrgMember, error) | 49 | GetOrgMember(ctx context.Context, db DBTX, arg GetOrgMemberParams) (OrgMember, error) |
| 50 | + // Final row removal after the cascade finished. The principals | ||
| 51 | + // trigger drops the matching principals row in the same tx. | ||
| 52 | + HardDeleteOrgRow(ctx context.Context, db DBTX, id int64) error | ||
| 53 | + // Sweep input for the lifecycle worker: every soft-deleted org whose | ||
| 54 | + // 14-day grace window has elapsed. The interval is intentionally a | ||
| 55 | + // DB literal (not a parameter) so the policy lives next to the data. | ||
| 56 | + ListOrgIDsPastSoftDeleteGrace(ctx context.Context, db DBTX) ([]int64, error) | ||
| 50 | // Members of an org with usernames + roles for the people page. | 57 | // Members of an org with usernames + roles for the people page. |
| 51 | ListOrgMembers(ctx context.Context, db DBTX, orgID int64) ([]ListOrgMembersRow, error) | 58 | ListOrgMembers(ctx context.Context, db DBTX, orgID int64) ([]ListOrgMembersRow, error) |
| 59 | + // All repo IDs (including soft-deleted) belonging to an org. Used by | ||
| 60 | + // the org hard-delete cascade to fan out per-repo destruction. | ||
| 61 | + ListOrgRepoIDs(ctx context.Context, db DBTX, ownerOrgID pgtype.Int8) ([]int64, error) | ||
| 52 | // Profile-page input: every org a user is a member of, with role. | 62 | // Profile-page input: every org a user is a member of, with role. |
| 53 | ListOrgsForUser(ctx context.Context, db DBTX, userID int64) ([]ListOrgsForUserRow, error) | 63 | ListOrgsForUser(ctx context.Context, db DBTX, userID int64) ([]ListOrgsForUserRow, error) |
| 54 | ListPendingInvitationsForEmail(ctx context.Context, db DBTX, targetEmail pgtype.Text) ([]ListPendingInvitationsForEmailRow, error) | 64 | ListPendingInvitationsForEmail(ctx context.Context, db DBTX, targetEmail pgtype.Text) ([]ListPendingInvitationsForEmailRow, error) |
internal/worker/jobs/lifecycle_sweep.gomodified@@ -11,6 +11,7 @@ import ( | |||
| 11 | 11 | ||
| 12 | "github.com/tenseleyFlow/shithub/internal/auth/audit" | 12 | "github.com/tenseleyFlow/shithub/internal/auth/audit" |
| 13 | "github.com/tenseleyFlow/shithub/internal/infra/storage" | 13 | "github.com/tenseleyFlow/shithub/internal/infra/storage" |
| 14 | + "github.com/tenseleyFlow/shithub/internal/orgs" | ||
| 14 | "github.com/tenseleyFlow/shithub/internal/repos/lifecycle" | 15 | "github.com/tenseleyFlow/shithub/internal/repos/lifecycle" |
| 15 | reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc" | 16 | reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc" |
| 16 | "github.com/tenseleyFlow/shithub/internal/worker" | 17 | "github.com/tenseleyFlow/shithub/internal/worker" |
@@ -67,6 +68,26 @@ func LifecycleSweep(deps LifecycleSweepDeps) worker.Handler { | |||
| 67 | if n > 0 { | 68 | if n > 0 { |
| 68 | deps.Logger.InfoContext(ctx, "lifecycle:sweep: expired transfers", "count", n) | 69 | deps.Logger.InfoContext(ctx, "lifecycle:sweep: expired transfers", "count", n) |
| 69 | } | 70 | } |
| 71 | + | ||
| 72 | + // 3. S30 — hard-delete orgs past their 14-day grace window. | ||
| 73 | + // Cascade per-repo via lifecycle.HardDelete inside the org | ||
| 74 | + // orchestrator. Same per-row failure-tolerant shape as the | ||
| 75 | + // repo path above. | ||
| 76 | + odeps := orgs.Deps{Pool: deps.Pool, Logger: deps.Logger, Audit: deps.Audit} | ||
| 77 | + ohd := orgs.HardDeleteDeps{Deps: odeps, RepoFS: deps.RepoFS, Audit: deps.Audit} | ||
| 78 | + orgIDs, err := orgs.ListPastGraceOrgIDs(ctx, odeps) | ||
| 79 | + if err != nil { | ||
| 80 | + deps.Logger.WarnContext(ctx, "lifecycle:sweep: list past-grace orgs", "error", err) | ||
| 81 | + } | ||
| 82 | + for _, oid := range orgIDs { | ||
| 83 | + if err := ctx.Err(); err != nil { | ||
| 84 | + return err | ||
| 85 | + } | ||
| 86 | + if err := orgs.HardDelete(ctx, ohd, oid); err != nil { | ||
| 87 | + deps.Logger.WarnContext(ctx, "lifecycle:sweep: org hard delete failed", | ||
| 88 | + "org_id", oid, "error", err) | ||
| 89 | + } | ||
| 90 | + } | ||
| 70 | return nil | 91 | return nil |
| 71 | } | 92 | } |
| 72 | } | 93 | } |