405 lines
12 KiB
Go
405 lines
12 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgconn"
|
|
)
|
|
|
|
// Admin shapes carry the raw column values, unlike the public ones: the editor
|
|
// has to round-trip DRAFT and ELUTASITVA, which never reach a visitor.
|
|
|
|
type AdminCampaignRow struct {
|
|
ID string `json:"id"`
|
|
Slug string `json:"slug"`
|
|
Title string `json:"title"`
|
|
Status string `json:"status"`
|
|
GoalAmount int `json:"goalAmount"`
|
|
Organization string `json:"organization"`
|
|
PledgeCount int `json:"pledgeCount"`
|
|
}
|
|
|
|
type Organization struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
type AdminTier struct {
|
|
ID string `json:"id"`
|
|
Type string `json:"type"`
|
|
Price int `json:"price"`
|
|
ListPrice *int `json:"listPrice"`
|
|
Title string `json:"title"`
|
|
Includes []string `json:"includes"`
|
|
InventoryLimit *int `json:"inventoryLimit"`
|
|
EstimatedDelivery string `json:"estimatedDelivery"`
|
|
Featured bool `json:"featured"`
|
|
// PledgeCount is why a tier may refuse to be deleted, so the editor needs
|
|
// it to explain itself before the user tries.
|
|
PledgeCount int `json:"pledgeCount"`
|
|
}
|
|
|
|
type AdminCampaign struct {
|
|
ID string `json:"id"`
|
|
Slug string `json:"slug"`
|
|
Title string `json:"title"`
|
|
Category string `json:"category"`
|
|
Summary string `json:"summary"`
|
|
Art string `json:"art"`
|
|
GoalAmount int `json:"goalAmount"`
|
|
EndsAt string `json:"endsAt"`
|
|
Status string `json:"status"`
|
|
OrganizationID string `json:"organizationId"`
|
|
Story []StorySection `json:"story"`
|
|
Tiers []AdminTier `json:"tiers"`
|
|
}
|
|
|
|
// ErrSlugTaken and ErrHasPledges are the two failures the editor has to
|
|
// explain in Hungarian rather than as a 500.
|
|
var (
|
|
ErrSlugTaken = errors.New("slug already taken")
|
|
ErrHasPledges = errors.New("campaign has pledges")
|
|
)
|
|
|
|
func (s *Store) ListAdminCampaigns(ctx context.Context) ([]AdminCampaignRow, error) {
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT c."id", c."slug", c."title", c."status"::text, c."goalAmount", o."name",
|
|
(SELECT COUNT(*) FROM "Pledge" p WHERE p."campaignId" = c."id")::int
|
|
FROM "Campaign" c
|
|
JOIN "Organization" o ON o."id" = c."organizationId"
|
|
ORDER BY c."createdAt" DESC`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := []AdminCampaignRow{}
|
|
for rows.Next() {
|
|
var r AdminCampaignRow
|
|
if err := rows.Scan(&r.ID, &r.Slug, &r.Title, &r.Status, &r.GoalAmount,
|
|
&r.Organization, &r.PledgeCount); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, r)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *Store) ListOrganizations(ctx context.Context) ([]Organization, error) {
|
|
rows, err := s.pool.Query(ctx,
|
|
`SELECT "id", "name" FROM "Organization" ORDER BY "name" ASC`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := []Organization{}
|
|
for rows.Next() {
|
|
var o Organization
|
|
if err := rows.Scan(&o.ID, &o.Name); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, o)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *Store) GetAdminCampaign(ctx context.Context, slug string) (*AdminCampaign, error) {
|
|
var c AdminCampaign
|
|
var endsAt time.Time
|
|
|
|
err := s.pool.QueryRow(ctx, `
|
|
SELECT "id", "slug", "title", "category", "summary", "art", "goalAmount",
|
|
"endsAt", "status"::text, "organizationId"
|
|
FROM "Campaign" WHERE "slug" = $1`, slug).Scan(
|
|
&c.ID, &c.Slug, &c.Title, &c.Category, &c.Summary, &c.Art, &c.GoalAmount,
|
|
&endsAt, &c.Status, &c.OrganizationID)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
c.EndsAt = s.Day(endsAt)
|
|
|
|
if c.Story, err = s.storySections(ctx, c.ID); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT t."id", t."type"::text, t."price", t."listPrice", t."title", t."includes",
|
|
t."inventoryLimit", t."estimatedDelivery", t."featured",
|
|
(SELECT COUNT(*) FROM "Pledge" p WHERE p."tierId" = t."id")::int
|
|
FROM "RewardTier" t WHERE t."campaignId" = $1 ORDER BY t."position" ASC`, c.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
c.Tiers = []AdminTier{}
|
|
for rows.Next() {
|
|
var t AdminTier
|
|
var delivery *time.Time
|
|
if err := rows.Scan(&t.ID, &t.Type, &t.Price, &t.ListPrice, &t.Title, &t.Includes,
|
|
&t.InventoryLimit, &delivery, &t.Featured, &t.PledgeCount); err != nil {
|
|
return nil, err
|
|
}
|
|
if delivery != nil {
|
|
t.EstimatedDelivery = s.Day(*delivery)
|
|
}
|
|
c.Tiers = append(c.Tiers, t)
|
|
}
|
|
return &c, rows.Err()
|
|
}
|
|
|
|
// TierInput is one row of the tier editor. A row with an ID updates that tier,
|
|
// a row without one creates it, and a tier missing from the slice entirely is
|
|
// deleted — unless pledges reference it.
|
|
type TierInput struct {
|
|
ID string `json:"id"`
|
|
Type string `json:"type"`
|
|
Price int `json:"price"`
|
|
ListPrice *int `json:"listPrice"`
|
|
Title string `json:"title"`
|
|
Includes []string `json:"includes"`
|
|
InventoryLimit *int `json:"inventoryLimit"`
|
|
EstimatedDelivery string `json:"estimatedDelivery"`
|
|
Featured bool `json:"featured"`
|
|
}
|
|
|
|
type CampaignInput struct {
|
|
ID string `json:"id"`
|
|
Slug string `json:"slug"`
|
|
Title string `json:"title"`
|
|
Category string `json:"category"`
|
|
Summary string `json:"summary"`
|
|
Art string `json:"art"`
|
|
GoalAmount int `json:"goalAmount"`
|
|
EndsAt string `json:"endsAt"`
|
|
Status string `json:"status"`
|
|
OrganizationID string `json:"organizationId"`
|
|
Story []StorySection `json:"story"`
|
|
Tiers []TierInput `json:"tiers"`
|
|
}
|
|
|
|
type SaveResult struct {
|
|
Slug string `json:"slug"`
|
|
// KeptInUseTier is true when a tier the caller wanted gone was retained
|
|
// because pledges point at it. The save still succeeded.
|
|
KeptInUseTier bool `json:"keptInUseTier"`
|
|
}
|
|
|
|
// SaveCampaign updates an existing campaign together with its story and tiers,
|
|
// in one transaction, so a half-saved campaign cannot be observed.
|
|
//
|
|
// It cannot create one. Campaigns are set up by hand, directly in the database,
|
|
// which is why an input without an id — or with one that matches no row — is
|
|
// ErrNotFound rather than an insert.
|
|
func (s *Store) SaveCampaign(ctx context.Context, in CampaignInput) (*SaveResult, error) {
|
|
if in.ID == "" {
|
|
return nil, ErrNotFound
|
|
}
|
|
|
|
endsAt, err := s.EndOfDay(in.EndsAt)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
tx, err := s.pool.Begin(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
campaignID := in.ID
|
|
tag, err := tx.Exec(ctx, `
|
|
UPDATE "Campaign" SET "slug"=$2, "title"=$3, "category"=$4, "summary"=$5,
|
|
"art"=$6, "goalAmount"=$7, "endsAt"=$8, "status"=$9::"CampaignStatus",
|
|
"organizationId"=$10, "updatedAt"=now()
|
|
WHERE "id"=$1`,
|
|
campaignID, in.Slug, in.Title, in.Category, in.Summary, in.Art,
|
|
in.GoalAmount, endsAt, in.Status, in.OrganizationID)
|
|
if err != nil {
|
|
if isUniqueViolation(err, "Campaign_slug_key") {
|
|
return nil, ErrSlugTaken
|
|
}
|
|
return nil, err
|
|
}
|
|
// Without the insert branch an unknown id would otherwise update nothing and
|
|
// then write story rows against a campaign that does not exist.
|
|
if tag.RowsAffected() == 0 {
|
|
return nil, ErrNotFound
|
|
}
|
|
|
|
// Story sections carry no foreign keys, so replacing them wholesale is safe.
|
|
if _, err := tx.Exec(ctx,
|
|
`DELETE FROM "StorySection" WHERE "campaignId" = $1`, campaignID); err != nil {
|
|
return nil, err
|
|
}
|
|
for i, sec := range in.Story {
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO "StorySection" ("id","campaignId","heading","body","position")
|
|
VALUES ($1,$2,$3,$4,$5)`,
|
|
NewID(), campaignID, sec.Heading, sec.Body, i); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
result := &SaveResult{Slug: in.Slug}
|
|
|
|
// Which existing tiers survive this save, and which have pledges.
|
|
type existing struct{ pledges int }
|
|
current := map[string]existing{}
|
|
rows, err := tx.Query(ctx, `
|
|
SELECT t."id", (SELECT COUNT(*) FROM "Pledge" p WHERE p."tierId" = t."id")::int
|
|
FROM "RewardTier" t WHERE t."campaignId" = $1`, campaignID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var order []string
|
|
for rows.Next() {
|
|
var id string
|
|
var n int
|
|
if err := rows.Scan(&id, &n); err != nil {
|
|
rows.Close()
|
|
return nil, err
|
|
}
|
|
current[id] = existing{pledges: n}
|
|
order = append(order, id)
|
|
}
|
|
rows.Close()
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
kept := map[string]bool{}
|
|
for _, t := range in.Tiers {
|
|
if t.ID != "" {
|
|
kept[t.ID] = true
|
|
}
|
|
}
|
|
|
|
// Positions are unique per campaign, so park every existing row out of the
|
|
// way before assigning final values — otherwise a reorder collides midway.
|
|
for i, id := range order {
|
|
if _, err := tx.Exec(ctx,
|
|
`UPDATE "RewardTier" SET "position" = $2 WHERE "id" = $1`, id, -1-i); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
// A tier the caller dropped is deleted, unless pledges reference it: money
|
|
// already promised against a reward outranks an edit to the reward list.
|
|
final := append([]TierInput(nil), in.Tiers...)
|
|
for _, id := range order {
|
|
if kept[id] {
|
|
continue
|
|
}
|
|
if current[id].pledges > 0 {
|
|
result.KeptInUseTier = true
|
|
t, err := s.tierByID(ctx, tx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
final = append(final, *t)
|
|
continue
|
|
}
|
|
if _, err := tx.Exec(ctx, `DELETE FROM "RewardTier" WHERE "id" = $1`, id); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
for i, t := range final {
|
|
var delivery *time.Time
|
|
if t.EstimatedDelivery != "" {
|
|
d, err := s.EndOfDay(t.EstimatedDelivery)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
delivery = &d
|
|
}
|
|
if t.Includes == nil {
|
|
t.Includes = []string{}
|
|
}
|
|
if t.ID != "" {
|
|
_, err = tx.Exec(ctx, `
|
|
UPDATE "RewardTier" SET "type"=$2::"TierType", "price"=$3, "listPrice"=$4,
|
|
"title"=$5, "includes"=$6, "inventoryLimit"=$7,
|
|
"estimatedDelivery"=$8, "featured"=$9, "position"=$10
|
|
WHERE "id"=$1`,
|
|
t.ID, t.Type, t.Price, t.ListPrice, t.Title, t.Includes,
|
|
t.InventoryLimit, delivery, t.Featured, i)
|
|
} else {
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO "RewardTier" ("id","campaignId","type","price","listPrice","title",
|
|
"includes","inventoryLimit","estimatedDelivery",
|
|
"featured","position")
|
|
VALUES ($1,$2,$3::"TierType",$4,$5,$6,$7,$8,$9,$10,$11)`,
|
|
NewID(), campaignID, t.Type, t.Price, t.ListPrice, t.Title, t.Includes,
|
|
t.InventoryLimit, delivery, t.Featured, i)
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
return result, tx.Commit(ctx)
|
|
}
|
|
|
|
func (s *Store) tierByID(ctx context.Context, tx pgx.Tx, id string) (*TierInput, error) {
|
|
var t TierInput
|
|
var delivery *time.Time
|
|
err := tx.QueryRow(ctx, `
|
|
SELECT "id", "type"::text, "price", "listPrice", "title", "includes",
|
|
"inventoryLimit", "estimatedDelivery", "featured"
|
|
FROM "RewardTier" WHERE "id" = $1`, id).Scan(
|
|
&t.ID, &t.Type, &t.Price, &t.ListPrice, &t.Title, &t.Includes,
|
|
&t.InventoryLimit, &delivery, &t.Featured)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if delivery != nil {
|
|
t.EstimatedDelivery = s.Day(*delivery)
|
|
}
|
|
return &t, nil
|
|
}
|
|
|
|
// DeleteCampaign refuses once any money has been promised: the pledge rows are
|
|
// the record of who gave what, and losing them to a mis-click is unacceptable.
|
|
func (s *Store) DeleteCampaign(ctx context.Context, id string) error {
|
|
var pledges int
|
|
if err := s.pool.QueryRow(ctx,
|
|
`SELECT COUNT(*)::int FROM "Pledge" WHERE "campaignId" = $1`, id).Scan(&pledges); err != nil {
|
|
return err
|
|
}
|
|
if pledges > 0 {
|
|
return ErrHasPledges
|
|
}
|
|
tag, err := s.pool.Exec(ctx, `DELETE FROM "Campaign" WHERE "id" = $1`, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) CampaignSlugByID(ctx context.Context, id string) (string, error) {
|
|
var slug string
|
|
err := s.pool.QueryRow(ctx, `SELECT "slug" FROM "Campaign" WHERE "id" = $1`, id).Scan(&slug)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return "", ErrNotFound
|
|
}
|
|
return slug, err
|
|
}
|
|
|
|
func isUniqueViolation(err error, constraint string) bool {
|
|
var pgErr *pgconn.PgError
|
|
return errors.As(err, &pgErr) && pgErr.Code == "23505" &&
|
|
(constraint == "" || pgErr.ConstraintName == constraint)
|
|
}
|