carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3import (
4 "database/sql"
5 "encoding/json"
6 "errors"
7 "time"
8
9 "github.com/carlosframework/platform/internal/plans"
10)
11
12// The sources an account's plan row can have. "default" rows are the ones
13// EnsureDefaultPlans inserts for accounts nobody has assigned.
14const (
15 PlanSourceDefault = "default"
16 PlanSourceOperator = "operator"
17 PlanSourceSponsored = "sponsored"
18 PlanSourceBilling = "billing"
19)
20
21// ErrAccountGone refuses a plan write for an account that does not exist or
22// has been deleted.
23var ErrAccountGone = errors.New("account deleted")
24
25// planDefaultActor is who a default row names as its writer.
26const planDefaultActor = "plans"
27
28// AccountPlanRow is one account's plan assignment.
29type AccountPlanRow struct {
30 AccountID int64
31 Sqid string
32 Plan string
33 Packs map[string]int
34 Source string
35 Sponsored *plans.Limits
36 Reason string
37 Revision int64
38 PublishedRevision int64
39 PublishedCatalog string
40 UpdatedBy string
41 UpdatedAt time.Time
42}
43
44const accountPlanColumns = `p.account_id, a.sqid, p.plan, p.packs, p.source, p.sponsored, p.reason,
45 p.revision, p.published_revision, p.published_catalog, p.updated_by, p.updated_at`
46
47func scanAccountPlan(sc interface{ Scan(...any) error }) (*AccountPlanRow, error) {
48 var r AccountPlanRow
49 var packs, sponsored string
50 var updated int64
51 if err := sc.Scan(&r.AccountID, &r.Sqid, &r.Plan, &packs, &r.Source, &sponsored, &r.Reason,
52 &r.Revision, &r.PublishedRevision, &r.PublishedCatalog, &r.UpdatedBy, &updated); err != nil {
53 return nil, err
54 }
55 if err := json.Unmarshal([]byte(packs), &r.Packs); err != nil {
56 return nil, err
57 }
58 if sponsored != "" {
59 r.Sponsored = &plans.Limits{}
60 if err := json.Unmarshal([]byte(sponsored), r.Sponsored); err != nil {
61 return nil, err
62 }
63 }
64 r.UpdatedAt = time.Unix(updated, 0)
65 return &r, nil
66}
67
68// AccountPlan is the account's plan row, or nil when it has none.
69func (s *Store) AccountPlan(accountID int64) (*AccountPlanRow, error) {
70 r, err := scanAccountPlan(s.db.QueryRow(`SELECT `+accountPlanColumns+`
71 FROM account_plans p JOIN accounts a ON a.id = p.account_id WHERE p.account_id = ?`, accountID))
72 if errors.Is(err, sql.ErrNoRows) {
73 return nil, nil
74 }
75 return r, err
76}
77
78// SetAccountPlan writes the account's plan and returns its new revision.
79// It validates nothing: callers check the plan against the catalog first.
80func (s *Store) SetAccountPlan(accountID int64, plan string, packs map[string]int, source string,
81 sponsored *plans.Limits, reason, by string, now time.Time) (int64, error) {
82 if packs == nil {
83 packs = map[string]int{}
84 }
85 pj, err := json.Marshal(packs)
86 if err != nil {
87 return 0, err
88 }
89 sj := ""
90 if sponsored != nil {
91 b, err := json.Marshal(sponsored)
92 if err != nil {
93 return 0, err
94 }
95 sj = string(b)
96 }
97 var rev int64
98 err = s.db.QueryRow(`INSERT INTO account_plans
99 (account_id, plan, packs, source, sponsored, reason, revision, updated_by, updated_at)
100 SELECT ?, ?, ?, ?, ?, ?, 1, ?, ? WHERE EXISTS (SELECT 1 FROM accounts WHERE id = ? AND deleted_at = 0)
101 ON CONFLICT(account_id) DO UPDATE SET plan = excluded.plan, packs = excluded.packs,
102 source = excluded.source, sponsored = excluded.sponsored, reason = excluded.reason,
103 updated_by = excluded.updated_by, updated_at = excluded.updated_at, revision = revision + 1
104 RETURNING revision`,
105 accountID, plan, string(pj), source, sj, reason, by, now.Unix(), accountID).Scan(&rev)
106 if errors.Is(err, sql.ErrNoRows) {
107 return 0, ErrAccountGone
108 }
109 return rev, err
110}
111
112// EnsureDefaultPlans gives every live account without a plan row the
113// default plan, and reports how many rows it inserted.
114func (s *Store) EnsureDefaultPlans(defaultPlan string, now time.Time) (int, error) {
115 res, err := s.db.Exec(`INSERT INTO account_plans
116 (account_id, plan, packs, source, revision, updated_by, updated_at)
117 SELECT id, ?, '{}', ?, 1, ?, ? FROM accounts
118 WHERE deleted_at = 0 AND id NOT IN (SELECT account_id FROM account_plans)`,
119 defaultPlan, PlanSourceDefault, planDefaultActor, now.Unix())
120 if err != nil {
121 return 0, err
122 }
123 n, err := res.RowsAffected()
124 return int(n), err
125}
126
127// PlansToPublish lists live accounts whose bucket record is behind their
128// row (an unpublished revision, or a record from another catalog), in
129// account order after afterAccountID, so a caller can page past rows it
130// cannot publish.
131func (s *Store) PlansToPublish(catalogID string, afterAccountID int64, limit int) ([]AccountPlanRow, error) {
132 rows, err := s.db.Query(`SELECT `+accountPlanColumns+`
133 FROM account_plans p JOIN accounts a ON a.id = p.account_id
134 WHERE a.deleted_at = 0 AND p.account_id > ? AND (p.published_revision < p.revision OR p.published_catalog != ?)
135 ORDER BY p.account_id LIMIT ?`, afterAccountID, catalogID, limit)
136 if err != nil {
137 return nil, err
138 }
139 defer rows.Close()
140 var out []AccountPlanRow
141 for rows.Next() {
142 r, err := scanAccountPlan(rows)
143 if err != nil {
144 return nil, err
145 }
146 out = append(out, *r)
147 }
148 return out, rows.Err()
149}
150
151// MarkPlanPublished records that the bucket carries revision under
152// catalogID. A revision that is no longer current marks nothing: the newer
153// one still needs publishing.
154func (s *Store) MarkPlanPublished(accountID, revision int64, catalogID string) error {
155 _, err := s.db.Exec(`UPDATE account_plans SET published_revision = ?, published_catalog = ?
156 WHERE account_id = ? AND revision = ?`, revision, catalogID, accountID, revision)
157 return err
158}
159
160// LiveAppCount counts the account's running apps for the creation caps:
161// live rows and rows being restored (temporary apps included).
162func (s *Store) LiveAppCount(accountID int64) (int, error) {
163 var n int
164 err := s.db.QueryRow(`SELECT COUNT(*) FROM apps WHERE account_id = ? AND (deleted_at = 0 OR restoring = 1)`, accountID).Scan(&n)
165 return n, err
166}
167
168// RestoringAppsIn names the account's apps that are mid-restore.
169func (s *Store) RestoringAppsIn(accountID int64) ([]string, error) {
170 rows, err := s.db.Query(`SELECT app FROM apps WHERE account_id = ? AND deleted_at != 0 AND restoring = 1 ORDER BY app`, accountID)
171 if err != nil {
172 return nil, err
173 }
174 defer rows.Close()
175 var out []string
176 for rows.Next() {
177 var a string
178 if err := rows.Scan(&a); err != nil {
179 return nil, err
180 }
181 out = append(out, a)
182 }
183 return out, rows.Err()
184}
185