260 lines
7.6 KiB
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
|
|
}
|