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