szabadpalya/api/internal/store/admin.go

417 lines
11 KiB
Go

package store
import (
"context"
"errors"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/szabadpalya/api/internal/store/dbgen"
)
// 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.q.ListAdminCampaigns(ctx)
if err != nil {
return nil, err
}
out := make([]AdminCampaignRow, 0, len(rows))
for _, r := range rows {
out = append(out, AdminCampaignRow{
ID: r.ID,
Slug: r.Slug,
Title: r.Title,
Status: r.Status,
GoalAmount: r.GoalAmount,
Owner: r.Name,
PledgeCount: r.PledgeCount,
})
}
return out, nil
}
func (s *Store) ListOwners(ctx context.Context) ([]Owner, error) {
rows, err := s.q.ListOwners(ctx)
if err != nil {
return nil, err
}
out := make([]Owner, 0, len(rows))
for _, r := range rows {
out = append(out, Owner{ID: r.ID, Name: r.Name, AvatarURL: r.AvatarUrl})
}
return out, nil
}
func (s *Store) GetAdminCampaign(ctx context.Context, slug string) (*AdminCampaign, error) {
r, err := s.q.GetAdminCampaign(ctx, slug)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
c := &AdminCampaign{
ID: r.ID,
Slug: r.Slug,
Title: r.Title,
Category: r.Category,
Summary: r.Summary,
Art: r.Art,
GoalAmount: r.GoalAmount,
EndsAt: s.Day(r.EndsAt),
Status: r.Status,
OwnerID: r.OwnerID,
}
if c.Story, err = s.storySections(ctx, c.ID); err != nil {
return nil, err
}
tiers, err := s.q.ListAdminTiers(ctx, c.ID)
if err != nil {
return nil, err
}
c.Tiers = make([]AdminTier, 0, len(tiers))
for _, t := range tiers {
tier := AdminTier{
ID: t.ID,
Type: t.Type,
Price: t.Price,
ListPrice: t.ListPrice,
Title: t.Title,
Includes: t.Includes,
InventoryLimit: t.InventoryLimit,
Featured: t.Featured,
PledgeCount: t.PledgeCount,
}
if t.EstimatedDelivery != nil {
tier.EstimatedDelivery = s.Day(*t.EstimatedDelivery)
}
c.Tiers = append(c.Tiers, tier)
}
return c, nil
}
// 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)
// Same generated queries, bound to the transaction instead of the pool.
q := s.q.WithTx(tx)
campaignID := in.ID
affected, err := q.UpdateCampaign(ctx, dbgen.UpdateCampaignParams{
Slug: in.Slug,
Title: in.Title,
Category: in.Category,
Summary: in.Summary,
Art: in.Art,
GoalAmount: in.GoalAmount,
EndsAt: endsAt,
Status: in.Status,
OwnerID: in.OwnerID,
ID: campaignID,
})
if err != nil {
if isUniqueViolation(err, "campaigns_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 affected == 0 {
return nil, ErrNotFound
}
// Story sections carry no foreign keys, so replacing them wholesale is safe.
if err := q.DeleteStorySections(ctx, campaignID); err != nil {
return nil, err
}
for i, sec := range in.Story {
if err := q.InsertStorySection(ctx, dbgen.InsertStorySectionParams{
ID: NewID(),
CampaignID: campaignID,
Heading: sec.Heading,
Body: sec.Body,
Position: i,
}); err != nil {
return nil, err
}
}
result := &SaveResult{Slug: in.Slug}
// Which existing tiers survive this save, and which have pledges.
counts, err := q.ListTierPledgeCounts(ctx, campaignID)
if err != nil {
return nil, err
}
pledged := map[string]int{}
order := make([]string, 0, len(counts))
for _, c := range counts {
pledged[c.ID] = c.PledgeCount
order = append(order, c.ID)
}
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 := q.ParkTierPosition(ctx, dbgen.ParkTierPositionParams{
Position: -1 - i,
ID: id,
}); 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 pledged[id] > 0 {
result.KeptInUseTier = true
t, err := s.tierByID(ctx, q, id)
if err != nil {
return nil, err
}
final = append(final, *t)
continue
}
if err := q.DeleteTier(ctx, 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 = q.UpdateTier(ctx, dbgen.UpdateTierParams{
Type: t.Type,
Price: t.Price,
ListPrice: t.ListPrice,
Title: t.Title,
Includes: t.Includes,
InventoryLimit: t.InventoryLimit,
EstimatedDelivery: delivery,
Featured: t.Featured,
Position: i,
ID: t.ID,
})
} else {
err = q.InsertTier(ctx, dbgen.InsertTierParams{
ID: NewID(),
CampaignID: campaignID,
Type: t.Type,
Price: t.Price,
ListPrice: t.ListPrice,
Title: t.Title,
Includes: t.Includes,
InventoryLimit: t.InventoryLimit,
EstimatedDelivery: delivery,
Featured: t.Featured,
Position: i,
})
}
if err != nil {
return nil, err
}
}
return result, tx.Commit(ctx)
}
// tierByID reads a tier back through whichever Queries it is handed, so the
// caller decides whether that is the pool or an open transaction.
func (s *Store) tierByID(ctx context.Context, q *dbgen.Queries, id string) (*TierInput, error) {
r, err := q.GetTier(ctx, id)
if err != nil {
return nil, err
}
t := &TierInput{
ID: r.ID,
Type: r.Type,
Price: r.Price,
ListPrice: r.ListPrice,
Title: r.Title,
Includes: r.Includes,
InventoryLimit: r.InventoryLimit,
Featured: r.Featured,
}
if r.EstimatedDelivery != nil {
t.EstimatedDelivery = s.Day(*r.EstimatedDelivery)
}
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 {
pledges, err := s.q.CountCampaignPledges(ctx, id)
if err != nil {
return err
}
if pledges > 0 {
return ErrHasPledges
}
affected, err := s.q.DeleteCampaign(ctx, id)
if err != nil {
return err
}
if affected == 0 {
return ErrNotFound
}
return nil
}
func (s *Store) CampaignSlugByID(ctx context.Context, id string) (string, error) {
slug, err := s.q.CampaignSlugByID(ctx, id)
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)
}