szabadpalya/api/internal/store/admin.go

410 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"`
Owner string `json:"owner"`
PledgeCount int `json:"pledgeCount"`
}
// Owner is the person a campaign belongs to. Always a person — the table was
// called Organization until migration 0003.
type Owner struct {
ID string `json:"id"`
Name string `json:"name"`
// Carried so an owner editor can show and change the photo. Empty means the
// organisation has none and its initials are used instead.
AvatarURL string `json:"avatarUrl,omitempty"`
}
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"`
OwnerID string `json:"ownerId"`
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 "Owner" o ON o."id" = c."ownerId"
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.Owner, &r.PledgeCount); err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
func (s *Store) ListOwners(ctx context.Context) ([]Owner, error) {
rows, err := s.pool.Query(ctx,
`SELECT "id", "name", COALESCE("avatarUrl", '') FROM "Owner" ORDER BY "name" ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Owner{}
for rows.Next() {
var o Owner
if err := rows.Scan(&o.ID, &o.Name, &o.AvatarURL); 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, "ownerId"
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.OwnerID)
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"`
OwnerID string `json:"ownerId"`
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",
"ownerId"=$10, "updatedAt"=now()
WHERE "id"=$1`,
campaignID, in.Slug, in.Title, in.Category, in.Summary, in.Art,
in.GoalAmount, endsAt, in.Status, in.OwnerID)
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)
}