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 }