Go · 5323 bytes Raw Blame History
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