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) }