| 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( |
| 88 | ctx, |
| 89 | `SELECT EXISTS(SELECT 1 FROM principals WHERE slug = $1)`, |
| 90 | row.Slug, |
| 91 | ).Scan(&taken); err != nil { |
| 92 | return err |
| 93 | } |
| 94 | if taken { |
| 95 | return ErrSlugTaken |
| 96 | } |
| 97 | if err := q.RestoreOrg(ctx, deps.Pool, orgID); err != nil { |
| 98 | return fmt.Errorf("restore: %w", err) |
| 99 | } |
| 100 | _ = actorUserID // audit entry deferred |
| 101 | return nil |
| 102 | } |
| 103 | |
| 104 | // HardDelete is the cascade run by the past-grace sweep. For each |
| 105 | // org-owned repo (including already-soft-deleted ones) it runs the |
| 106 | // existing lifecycle.HardDelete tear-down (bare-repo on disk + DB |
| 107 | // rows). Then drops org_invitations + org_members + the orgs row. |
| 108 | // The principals trigger removes the slug entry as part of the |
| 109 | // orgs DELETE. |
| 110 | // |
| 111 | // Best-effort: a per-repo failure is logged but doesn't stop the |
| 112 | // sweep — leaving zombie repos in the DB is preferable to leaving |
| 113 | // the org row in a half-deleted state where its slug is permanently |
| 114 | // pinned. Operators can retry via the manual sweep job. |
| 115 | func HardDelete(ctx context.Context, hd HardDeleteDeps, orgID int64) error { |
| 116 | q := orgsdb.New() |
| 117 | repoIDs, err := q.ListOrgRepoIDs(ctx, hd.Pool, pgInt8(orgID)) |
| 118 | if err != nil { |
| 119 | return fmt.Errorf("list org repos: %w", err) |
| 120 | } |
| 121 | ldeps := lifecycle.Deps{ |
| 122 | Pool: hd.Pool, RepoFS: hd.RepoFS, Audit: hd.Audit, Logger: hd.Logger, |
| 123 | } |
| 124 | for _, rid := range repoIDs { |
| 125 | if err := lifecycle.HardDelete(ctx, ldeps, 0, rid); err != nil && hd.Logger != nil { |
| 126 | hd.Logger.WarnContext(ctx, "orgs: cascade repo hard-delete failed", |
| 127 | "org_id", orgID, "repo_id", rid, "error", err) |
| 128 | } |
| 129 | } |
| 130 | // org_members + org_invitations cascade via FK ON DELETE CASCADE |
| 131 | // already; the principals trigger drops the slug row inside the |
| 132 | // same tx. Audit row goes in before the row vanishes so the log |
| 133 | // retains the slug. |
| 134 | row, _ := q.GetOrgByID(ctx, hd.Pool, orgID) |
| 135 | if hd.Audit != nil { |
| 136 | _ = hd.Audit.Record(ctx, hd.Pool, 0, |
| 137 | audit.ActionRepoHardDeleted, |
| 138 | audit.TargetUser, orgID, |
| 139 | map[string]any{"slug": string(row.Slug), "kind": "org"}) |
| 140 | } |
| 141 | if err := q.HardDeleteOrgRow(ctx, hd.Pool, orgID); err != nil { |
| 142 | return fmt.Errorf("hard delete org row: %w", err) |
| 143 | } |
| 144 | return nil |
| 145 | } |
| 146 | |
| 147 | // ListPastGraceOrgIDs is a thin wrapper exposing the sqlc query so |
| 148 | // the worker bundle doesn't need to import orgsdb directly. |
| 149 | func ListPastGraceOrgIDs(ctx context.Context, deps Deps) ([]int64, error) { |
| 150 | return orgsdb.New().ListOrgIDsPastSoftDeleteGrace(ctx, deps.Pool) |
| 151 | } |
| 152 |