417 lines
11 KiB
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)
|
|
}
|