szabadpalya/api/internal/store/campaigns.go

260 lines
7.6 KiB
Go

package store
import (
"context"
"errors"
"time"
"github.com/jackc/pgx/v5"
)
// The wire shapes below are what the frontend already expected from the
// previous stack, so the view layer never learned about the schema and does
// not have to now.
type Creator struct {
Name string `json:"name"`
Initials string `json:"initials"`
Location string `json:"location"`
Blurb string `json:"blurb"`
// Empty when the owner has no photo. The client falls back to Initials,
// so this is omitted from the payload rather than sent as "".
AvatarURL string `json:"avatarUrl,omitempty"`
}
type StorySection struct {
Heading string `json:"heading"`
Body []string `json:"body"`
}
type Tier struct {
ID string `json:"id"`
Type string `json:"type"`
Price int `json:"price"`
ListPrice *int `json:"listPrice,omitempty"`
Title string `json:"title"`
Includes []string `json:"includes"`
Backers int `json:"backers"`
InventoryLimit *int `json:"inventoryLimit,omitempty"`
EstimatedDelivery string `json:"estimatedDelivery,omitempty"`
Featured bool `json:"featured"`
}
type CampaignSummary struct {
Slug string `json:"slug"`
Title string `json:"title"`
Category string `json:"category"`
Summary string `json:"summary"`
Art string `json:"art"`
Creator Creator `json:"creator"`
Goal int `json:"goal"`
Raised int `json:"raised"`
Backers int `json:"backers"`
EndsAt string `json:"endsAt"`
Status string `json:"status"`
}
type Campaign struct {
CampaignSummary
Story []StorySection `json:"story"`
Tiers []Tier `json:"tiers"`
Updates int `json:"updates"`
Comments int `json:"comments"`
}
var ErrNotFound = errors.New("not found")
// Only these statuses are visible without an admin session. A draft campaign
// must 404 for the public exactly as a non-existent one does.
var publicStatuses = []string{"AKTIV", "FINANSZIROZOTT", "LEZART"}
// A pledge counts towards the total once the card is authorized; released and
// refunded pledges drop back out. The raised amount and the backer count are
// ALWAYS derived from these rows and stored nowhere.
var countedPledges = []string{"AUTHORIZED", "CAPTURED"}
var statusOut = map[string]string{
"AKTIV": "aktiv",
"FINANSZIROZOTT": "finanszirozott",
"LEZART": "lezart",
}
var tierTypeOut = map[string]string{
"DONATION": "donation",
"DIGITAL": "digital",
"PHYSICAL": "physical",
}
func outStatus(s string) string {
if v, ok := statusOut[s]; ok {
return v
}
return "aktiv"
}
func outTierType(s string) string {
if v, ok := tierTypeOut[s]; ok {
return v
}
return "donation"
}
const listSQL = `
SELECT c."slug", c."title", c."category", c."summary", c."art",
o."name", o."initials", o."location", o."blurb", COALESCE(o."avatarUrl", ''),
c."goalAmount", c."endsAt", c."status"::text,
COALESCE(p."total", 0)::int, COALESCE(p."backers", 0)::int
FROM "Campaign" c
JOIN "Owner" o ON o."id" = c."ownerId"
LEFT JOIN (
SELECT "campaignId", SUM("amount") AS "total", COUNT(*) AS "backers"
FROM "Pledge"
WHERE "status" = ANY($2::"PledgeStatus"[])
GROUP BY "campaignId"
) p ON p."campaignId" = c."id"
WHERE c."status" = ANY($1::"CampaignStatus"[])
ORDER BY c."status" ASC, c."endsAt" ASC`
func (s *Store) ListCampaigns(ctx context.Context) ([]CampaignSummary, error) {
rows, err := s.pool.Query(ctx, listSQL, publicStatuses, countedPledges)
if err != nil {
return nil, err
}
defer rows.Close()
out := []CampaignSummary{}
for rows.Next() {
var c CampaignSummary
var endsAt time.Time
var status string
if err := rows.Scan(
&c.Slug, &c.Title, &c.Category, &c.Summary, &c.Art,
&c.Creator.Name, &c.Creator.Initials, &c.Creator.Location, &c.Creator.Blurb,
&c.Creator.AvatarURL,
&c.Goal, &endsAt, &status, &c.Raised, &c.Backers,
); err != nil {
return nil, err
}
c.EndsAt = s.Day(endsAt)
c.Status = outStatus(status)
out = append(out, c)
}
return out, rows.Err()
}
const campaignSQL = `
SELECT c."id", c."slug", c."title", c."category", c."summary", c."art",
o."name", o."initials", o."location", o."blurb", COALESCE(o."avatarUrl", ''),
c."goalAmount", c."endsAt", c."status"::text,
COALESCE(p."total", 0)::int, COALESCE(p."backers", 0)::int,
(SELECT COUNT(*) FROM "CampaignUpdate" u WHERE u."campaignId" = c."id")::int,
(SELECT COUNT(*) FROM "Comment" m WHERE m."campaignId" = c."id")::int
FROM "Campaign" c
JOIN "Owner" o ON o."id" = c."ownerId"
LEFT JOIN (
SELECT "campaignId", SUM("amount") AS "total", COUNT(*) AS "backers"
FROM "Pledge"
WHERE "status" = ANY($3::"PledgeStatus"[])
GROUP BY "campaignId"
) p ON p."campaignId" = c."id"
WHERE c."slug" = $1 AND c."status" = ANY($2::"CampaignStatus"[])`
const tiersSQL = `
SELECT t."id", t."type"::text, t."price", t."listPrice", t."title", t."includes",
t."inventoryLimit", t."estimatedDelivery", t."featured",
COALESCE(p."backers", 0)::int
FROM "RewardTier" t
LEFT JOIN (
SELECT "tierId", COUNT(*) AS "backers"
FROM "Pledge"
WHERE "campaignId" = $1 AND "status" = ANY($2::"PledgeStatus"[])
GROUP BY "tierId"
) p ON p."tierId" = t."id"
WHERE t."campaignId" = $1
ORDER BY t."position" ASC`
// GetCampaignBySlug returns a campaign the public may see, or ErrNotFound.
func (s *Store) GetCampaignBySlug(ctx context.Context, slug string) (*Campaign, error) {
var c Campaign
var id string
var endsAt time.Time
var status string
err := s.pool.QueryRow(ctx, campaignSQL, slug, publicStatuses, countedPledges).Scan(
&id, &c.Slug, &c.Title, &c.Category, &c.Summary, &c.Art,
&c.Creator.Name, &c.Creator.Initials, &c.Creator.Location, &c.Creator.Blurb,
&c.Creator.AvatarURL,
&c.Goal, &endsAt, &status, &c.Raised, &c.Backers, &c.Updates, &c.Comments,
)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
c.EndsAt = s.Day(endsAt)
c.Status = outStatus(status)
c.Story, err = s.storySections(ctx, id)
if err != nil {
return nil, err
}
rows, err := s.pool.Query(ctx, tiersSQL, id, countedPledges)
if err != nil {
return nil, err
}
defer rows.Close()
c.Tiers = []Tier{}
for rows.Next() {
var t Tier
var kind string
var delivery *time.Time
if err := rows.Scan(&t.ID, &kind, &t.Price, &t.ListPrice, &t.Title, &t.Includes,
&t.InventoryLimit, &delivery, &t.Featured, &t.Backers); err != nil {
return nil, err
}
t.Type = outTierType(kind)
if delivery != nil {
t.EstimatedDelivery = s.Day(*delivery)
}
c.Tiers = append(c.Tiers, t)
}
return &c, rows.Err()
}
func (s *Store) storySections(ctx context.Context, campaignID string) ([]StorySection, error) {
rows, err := s.pool.Query(ctx,
`SELECT "heading", "body" FROM "StorySection" WHERE "campaignId" = $1 ORDER BY "position" ASC`,
campaignID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []StorySection{}
for rows.Next() {
var sec StorySection
if err := rows.Scan(&sec.Heading, &sec.Body); err != nil {
return nil, err
}
out = append(out, sec)
}
return out, rows.Err()
}
type PlatformStats struct {
ActiveCount int `json:"activeCount"`
Backers int `json:"backers"`
}
func (s *Store) PlatformStats(ctx context.Context) (PlatformStats, error) {
var st PlatformStats
err := s.pool.QueryRow(ctx, `
SELECT (SELECT COUNT(*) FROM "Campaign" WHERE "status" = 'AKTIV')::int,
(SELECT COUNT(*) FROM "Pledge" WHERE "status" = ANY($1::"PledgeStatus"[]))::int`,
countedPledges).Scan(&st.ActiveCount, &st.Backers)
return st, err
}