Campaign owner is always a person
This commit is contained in:
parent
89900c81c8
commit
cf37ccc649
|
|
@ -19,8 +19,8 @@ func (s *Server) adminListCampaigns(c *gin.Context) {
|
|||
c.JSON(http.StatusOK, rows)
|
||||
}
|
||||
|
||||
func (s *Server) adminOrganizations(c *gin.Context) {
|
||||
orgs, err := s.store.ListOrganizations(c.Request.Context())
|
||||
func (s *Server) adminOwners(c *gin.Context) {
|
||||
orgs, err := s.store.ListOwners(c.Request.Context())
|
||||
if err != nil {
|
||||
s.fail(c, err)
|
||||
return
|
||||
|
|
@ -62,7 +62,7 @@ func (s *Server) adminSaveCampaign(c *gin.Context) {
|
|||
in.Slug = hu.Slugify(firstNonEmpty(in.Slug, in.Title))
|
||||
|
||||
if in.Title == "" || in.Slug == "" || in.GoalAmount <= 0 ||
|
||||
in.EndsAt == "" || in.OrganizationID == "" {
|
||||
in.EndsAt == "" || in.OwnerID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "hianyzo"})
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ func (s *Server) Handler() http.Handler {
|
|||
{
|
||||
admin.GET("/campaigns", s.adminListCampaigns)
|
||||
admin.GET("/campaigns/:slug", s.adminGetCampaign)
|
||||
admin.GET("/organizations", s.adminOrganizations)
|
||||
admin.GET("/owners", s.adminOwners)
|
||||
admin.PUT("/campaigns", s.sameOrigin, s.adminSaveCampaign)
|
||||
admin.DELETE("/campaigns/:slug", s.sameOrigin, s.adminDeleteCampaign)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,18 +13,23 @@ import (
|
|||
// 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"`
|
||||
Organization string `json:"organization"`
|
||||
PledgeCount int `json:"pledgeCount"`
|
||||
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"`
|
||||
}
|
||||
|
||||
type Organization struct {
|
||||
// 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 {
|
||||
|
|
@ -43,18 +48,18 @@ type AdminTier struct {
|
|||
}
|
||||
|
||||
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"`
|
||||
OrganizationID string `json:"organizationId"`
|
||||
Story []StorySection `json:"story"`
|
||||
Tiers []AdminTier `json:"tiers"`
|
||||
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
|
||||
|
|
@ -69,7 +74,7 @@ func (s *Store) ListAdminCampaigns(ctx context.Context) ([]AdminCampaignRow, err
|
|||
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 "Organization" o ON o."id" = c."organizationId"
|
||||
JOIN "Owner" o ON o."id" = c."ownerId"
|
||||
ORDER BY c."createdAt" DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -80,7 +85,7 @@ func (s *Store) ListAdminCampaigns(ctx context.Context) ([]AdminCampaignRow, err
|
|||
for rows.Next() {
|
||||
var r AdminCampaignRow
|
||||
if err := rows.Scan(&r.ID, &r.Slug, &r.Title, &r.Status, &r.GoalAmount,
|
||||
&r.Organization, &r.PledgeCount); err != nil {
|
||||
&r.Owner, &r.PledgeCount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, r)
|
||||
|
|
@ -88,18 +93,18 @@ func (s *Store) ListAdminCampaigns(ctx context.Context) ([]AdminCampaignRow, err
|
|||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) ListOrganizations(ctx context.Context) ([]Organization, error) {
|
||||
func (s *Store) ListOwners(ctx context.Context) ([]Owner, error) {
|
||||
rows, err := s.pool.Query(ctx,
|
||||
`SELECT "id", "name" FROM "Organization" ORDER BY "name" ASC`)
|
||||
`SELECT "id", "name", COALESCE("avatarUrl", '') FROM "Owner" ORDER BY "name" ASC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []Organization{}
|
||||
out := []Owner{}
|
||||
for rows.Next() {
|
||||
var o Organization
|
||||
if err := rows.Scan(&o.ID, &o.Name); err != nil {
|
||||
var o Owner
|
||||
if err := rows.Scan(&o.ID, &o.Name, &o.AvatarURL); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, o)
|
||||
|
|
@ -113,10 +118,10 @@ func (s *Store) GetAdminCampaign(ctx context.Context, slug string) (*AdminCampai
|
|||
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT "id", "slug", "title", "category", "summary", "art", "goalAmount",
|
||||
"endsAt", "status"::text, "organizationId"
|
||||
"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.OrganizationID)
|
||||
&endsAt, &c.Status, &c.OwnerID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
|
@ -171,18 +176,18 @@ type TierInput struct {
|
|||
}
|
||||
|
||||
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"`
|
||||
OrganizationID string `json:"organizationId"`
|
||||
Story []StorySection `json:"story"`
|
||||
Tiers []TierInput `json:"tiers"`
|
||||
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 {
|
||||
|
|
@ -218,10 +223,10 @@ func (s *Store) SaveCampaign(ctx context.Context, in CampaignInput) (*SaveResult
|
|||
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",
|
||||
"organizationId"=$10, "updatedAt"=now()
|
||||
"ownerId"=$10, "updatedAt"=now()
|
||||
WHERE "id"=$1`,
|
||||
campaignID, in.Slug, in.Title, in.Category, in.Summary, in.Art,
|
||||
in.GoalAmount, endsAt, in.Status, in.OrganizationID)
|
||||
in.GoalAmount, endsAt, in.Status, in.OwnerID)
|
||||
if err != nil {
|
||||
if isUniqueViolation(err, "Campaign_slug_key") {
|
||||
return nil, ErrSlugTaken
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@ type Creator struct {
|
|||
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 {
|
||||
|
|
@ -98,11 +101,11 @@ func outTierType(s string) string {
|
|||
|
||||
const listSQL = `
|
||||
SELECT c."slug", c."title", c."category", c."summary", c."art",
|
||||
o."name", o."initials", o."location", o."blurb",
|
||||
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 "Organization" o ON o."id" = c."organizationId"
|
||||
JOIN "Owner" o ON o."id" = c."ownerId"
|
||||
LEFT JOIN (
|
||||
SELECT "campaignId", SUM("amount") AS "total", COUNT(*) AS "backers"
|
||||
FROM "Pledge"
|
||||
|
|
@ -127,6 +130,7 @@ func (s *Store) ListCampaigns(ctx context.Context) ([]CampaignSummary, error) {
|
|||
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
|
||||
|
|
@ -140,13 +144,13 @@ func (s *Store) ListCampaigns(ctx context.Context) ([]CampaignSummary, error) {
|
|||
|
||||
const campaignSQL = `
|
||||
SELECT c."id", c."slug", c."title", c."category", c."summary", c."art",
|
||||
o."name", o."initials", o."location", o."blurb",
|
||||
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 "Organization" o ON o."id" = c."organizationId"
|
||||
JOIN "Owner" o ON o."id" = c."ownerId"
|
||||
LEFT JOIN (
|
||||
SELECT "campaignId", SUM("amount") AS "total", COUNT(*) AS "backers"
|
||||
FROM "Pledge"
|
||||
|
|
@ -179,6 +183,7 @@ func (s *Store) GetCampaignBySlug(ctx context.Context, slug string) (*Campaign,
|
|||
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) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
-- An owner's photo. Nullable: an organisation without one falls back to its
|
||||
-- initials, which is what every card and campaign page already renders.
|
||||
--
|
||||
-- A URL rather than bytes: there is no upload pipeline yet, and when there is,
|
||||
-- it can write the location it stored the file at into this same column without
|
||||
-- the read path changing.
|
||||
ALTER TABLE "Organization" ADD COLUMN "avatarUrl" TEXT;
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
-- A campaign's owner is always a person, never an organisation. The table was
|
||||
-- named for the wrong idea, and every admin screen built on it would have
|
||||
-- repeated the mistake, so the name goes rather than being worked around.
|
||||
--
|
||||
-- Postgres keeps the old names for indexes and constraints through a rename;
|
||||
-- they are renamed here too so nothing in the schema still says Organization.
|
||||
ALTER TABLE "Organization" RENAME TO "Owner";
|
||||
ALTER TABLE "Campaign" RENAME COLUMN "organizationId" TO "ownerId";
|
||||
|
||||
ALTER INDEX "Organization_slug_key" RENAME TO "Owner_slug_key";
|
||||
ALTER INDEX "Organization_stripeAccountId_key" RENAME TO "Owner_stripeAccountId_key";
|
||||
|
|
@ -10,10 +10,10 @@ import (
|
|||
)
|
||||
|
||||
// reset clears the campaign side of the database. Relations cascade, so
|
||||
// campaigns and organisations are enough; users go too because the seed's
|
||||
// campaigns and owners are enough; users go too because the seed's
|
||||
// pledges are anonymous and any account left behind would be an orphan.
|
||||
func (s *seeder) reset(ctx context.Context) error {
|
||||
for _, table := range []string{"Pledge", "Campaign", "Organization", "User"} {
|
||||
for _, table := range []string{"Pledge", "Campaign", "Owner", "User"} {
|
||||
if _, err := s.pool.Exec(ctx, fmt.Sprintf(`DELETE FROM %q`, table)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -35,24 +35,25 @@ func (s *seeder) campaign(ctx context.Context, c seeddata.Campaign) (int, int, e
|
|||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var orgID string
|
||||
var ownerID string
|
||||
if err := tx.QueryRow(ctx, `
|
||||
INSERT INTO "Organization" ("id","slug","name","initials","location","blurb","approved")
|
||||
VALUES ($1,$2,$3,$4,$5,$6,true)
|
||||
INSERT INTO "Owner" ("id","slug","name","initials","location","blurb","avatarUrl","approved")
|
||||
VALUES ($1,$2,$3,$4,$5,$6,NULLIF($7,''),true)
|
||||
ON CONFLICT ("slug") DO UPDATE SET "name" = EXCLUDED."name"
|
||||
RETURNING "id"`,
|
||||
store.NewID(), c.Org.Slug, c.Org.Name, c.Org.Initials, c.Org.Location, c.Org.Blurb,
|
||||
).Scan(&orgID); err != nil {
|
||||
store.NewID(), c.Owner.Slug, c.Owner.Name, c.Owner.Initials, c.Owner.Location, c.Owner.Blurb,
|
||||
c.Owner.AvatarURL,
|
||||
).Scan(&ownerID); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
campaignID := store.NewID()
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO "Campaign" ("id","slug","title","category","summary","art","goalAmount",
|
||||
"endsAt","status","organizationId")
|
||||
"endsAt","status","ownerId")
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9::"CampaignStatus",$10)`,
|
||||
campaignID, c.Slug, c.Title, c.Category, c.Summary, c.Art, c.GoalAmount,
|
||||
endsAt, c.Status, orgID); err != nil {
|
||||
endsAt, c.Status, ownerID); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,12 +8,13 @@
|
|||
"goalAmount": 1200000,
|
||||
"endsAt": "2026-08-31",
|
||||
"status": "AKTIV",
|
||||
"org": {
|
||||
"slug": "zold-jozsefvaros",
|
||||
"name": "Zöld Józsefváros Egyesület",
|
||||
"initials": "ZJ",
|
||||
"location": "Budapest VIII.",
|
||||
"blurb": "2019 óta dolgozunk józsefvárosi zöldfelületekért. Eddig három kertet hoztunk létre, mindhárom ma is működik."
|
||||
"owner": {
|
||||
"slug": "gelencser-ferenc",
|
||||
"name": "Gelencsér Ferenc",
|
||||
"initials": "GF",
|
||||
"location": "Budapest",
|
||||
"blurb": "Momentumos politikus, a Rákóczi tér környékén nőtt fel. A kert ötlete egy lakossági fórumon született.",
|
||||
"avatarUrl": "https://momentum.hu/wp-content/uploads/2022/10/Gelencser-Ferenc-portre-490.webp"
|
||||
},
|
||||
"story": [
|
||||
{
|
||||
|
|
@ -117,12 +118,12 @@
|
|||
"goalAmount": 2400000,
|
||||
"endsAt": "2026-09-15",
|
||||
"status": "AKTIV",
|
||||
"org": {
|
||||
"slug": "hangolo",
|
||||
"name": "Hangoló Közhasznú Egyesület",
|
||||
"initials": "HK",
|
||||
"owner": {
|
||||
"slug": "horvath-eszter",
|
||||
"name": "Horváth Eszter",
|
||||
"initials": "HE",
|
||||
"location": "Szeged",
|
||||
"blurb": "Látássérült fiatalok tanulását segítjük 2016 óta. Eddig 62 hangoskönyvet készítettünk el."
|
||||
"blurb": "Gyógypedagógus, 2016 óta készít hangoskönyveket látássérült diákoknak. Eddig 62 könyv készült el."
|
||||
},
|
||||
"story": [
|
||||
{
|
||||
|
|
@ -197,12 +198,13 @@
|
|||
"goalAmount": 3500000,
|
||||
"endsAt": "2026-10-04",
|
||||
"status": "AKTIV",
|
||||
"org": {
|
||||
"slug": "kerekagy",
|
||||
"name": "Kerékagy Egyesület",
|
||||
"initials": "KE",
|
||||
"owner": {
|
||||
"slug": "szarvas-koppany-bendeguz",
|
||||
"name": "Szarvas Koppány Bendegúz",
|
||||
"initials": "SZKB",
|
||||
"location": "Budapest VIII.",
|
||||
"blurb": "Kerékpáros oktatást tartunk általános iskolákban. Tavaly 900 gyerek ült nálunk először nyeregbe."
|
||||
"blurb": "Józsefvárosi önkormányzati képviselő volt. A tanpályát a Baross utcai iskolákkal együtt tervezi.",
|
||||
"avatarUrl": "https://momentum.hu/wp-content/uploads/2024/02/Szarvas-Koppany-Bendeguz.jpg"
|
||||
},
|
||||
"story": [
|
||||
{
|
||||
|
|
@ -279,12 +281,12 @@
|
|||
"goalAmount": 1800000,
|
||||
"endsAt": "2026-07-31",
|
||||
"status": "LEZART",
|
||||
"org": {
|
||||
"slug": "koborlo",
|
||||
"name": "Kóborló Társulat",
|
||||
"initials": "KT",
|
||||
"owner": {
|
||||
"slug": "kovacs-marton",
|
||||
"name": "Kovács Márton",
|
||||
"initials": "KM",
|
||||
"location": "Debrecen",
|
||||
"blurb": "Független társulat 2021 óta. Ott játszunk, ahol nincs kőszínház — eddig 38 településen."
|
||||
"blurb": "Rendező. Ott játszik, ahol nincs kőszínház — eddig 38 településen fordult meg a társulattal."
|
||||
},
|
||||
"story": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -14,12 +14,15 @@ import (
|
|||
//go:embed seed.json
|
||||
var raw []byte
|
||||
|
||||
type Org struct {
|
||||
type Owner struct {
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Initials string `json:"initials"`
|
||||
Location string `json:"location"`
|
||||
Blurb string `json:"blurb"`
|
||||
// Optional. Left out of the fixture because there is nowhere to host the
|
||||
// files yet; an organisation without one shows its initials.
|
||||
AvatarURL string `json:"avatarUrl,omitempty"`
|
||||
}
|
||||
|
||||
type Story struct {
|
||||
|
|
@ -60,7 +63,7 @@ type Campaign struct {
|
|||
GoalAmount int `json:"goalAmount"`
|
||||
EndsAt string `json:"endsAt"`
|
||||
Status string `json:"status"`
|
||||
Org Org `json:"org"`
|
||||
Owner Owner `json:"owner"`
|
||||
Story []Story `json:"story"`
|
||||
Tiers []Tier `json:"tiers"`
|
||||
Updates []Update `json:"updates"`
|
||||
|
|
|
|||
|
|
@ -417,7 +417,7 @@ check_creation_refused() {
|
|||
-b /tmp/ci-admin.jar \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Origin: http://localhost:${APP_PORT}" \
|
||||
-d '{"title":"Uj kampany","slug":"uj-kampany","goalAmount":100000,"endsAt":"2027-01-01","organizationId":"x","status":"AKTIV"}' \
|
||||
-d '{"title":"Uj kampany","slug":"uj-kampany","goalAmount":100000,"endsAt":"2027-01-01","ownerId":"x","status":"AKTIV"}' \
|
||||
"http://localhost:${APP_PORT}/api/admin/campaigns")
|
||||
[ "$code" = "404" ] || { echo "a save with no id returned $code, expected 404"; return 1; }
|
||||
# And it must not have landed anyway.
|
||||
|
|
@ -428,6 +428,41 @@ check_creation_refused() {
|
|||
}
|
||||
step "campaigns cannot be created through the API" check_creation_refused
|
||||
|
||||
# Every campaign has an owner, and the owner may have a photo. The column is
|
||||
# nullable and the client falls back to the initials chip, so both states have
|
||||
# to survive the trip: set, and cleared again.
|
||||
check_owner_photo() {
|
||||
docker exec "$DB_CONTAINER" psql -U "$DB_USER" -d "$CI_DB" -q \
|
||||
-c "UPDATE \"Owner\" SET \"avatarUrl\" = 'https://example.invalid/o.jpg'" >/dev/null
|
||||
|
||||
curl -s "http://localhost:${APP_PORT}/api/campaigns" | python3 -c '
|
||||
import json, sys
|
||||
campaigns = json.load(sys.stdin)
|
||||
assert campaigns, "no campaigns to check"
|
||||
for c in campaigns:
|
||||
assert c["creator"]["avatarUrl"] == "https://example.invalid/o.jpg", c["creator"]
|
||||
' || return 1
|
||||
|
||||
# A future owner editor loads the photo from here.
|
||||
curl -s -b /tmp/ci-admin.jar "http://localhost:${APP_PORT}/api/admin/owners" | python3 -c '
|
||||
import json, sys
|
||||
owners = json.load(sys.stdin)
|
||||
assert owners, "no owners"
|
||||
for o in owners:
|
||||
assert o.get("avatarUrl"), o
|
||||
' || return 1
|
||||
|
||||
docker exec "$DB_CONTAINER" psql -U "$DB_USER" -d "$CI_DB" -q \
|
||||
-c "UPDATE \"Owner\" SET \"avatarUrl\" = NULL" >/dev/null
|
||||
|
||||
curl -s "http://localhost:${APP_PORT}/api/campaigns" | python3 -c '
|
||||
import json, sys
|
||||
for c in json.load(sys.stdin):
|
||||
assert "avatarUrl" not in c["creator"], "a missing photo must be omitted, not sent as an empty string"
|
||||
'
|
||||
}
|
||||
step "owner photo round-trips, and is omitted when unset" check_owner_photo
|
||||
|
||||
check_404() {
|
||||
local code
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:${APP_PORT}/kampanyok/nincs-ilyen")
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { Link } from "react-router";
|
||||
import { CampaignArt } from "./CampaignArt";
|
||||
import { CreatorBadge } from "./CreatorBadge";
|
||||
import { FundingLane } from "./FundingLane";
|
||||
import { daysLeft, formatHuf, formatNumber, progressPct } from "@/lib/format";
|
||||
import type { CampaignSummary } from "@/lib/types";
|
||||
|
|
@ -23,7 +24,9 @@ export function CampaignCard({ campaign }: { campaign: CampaignSummary }) {
|
|||
|
||||
<div className="flex flex-1 flex-col p-5">
|
||||
<h3 className="font-display text-xl">{campaign.title}</h3>
|
||||
<p className="mt-1 text-xs text-muted">{campaign.creator.name}</p>
|
||||
<div className="mt-2">
|
||||
<CreatorBadge creator={campaign.creator} />
|
||||
</div>
|
||||
<p className="mt-2.5 line-clamp-3 text-[13.5px] leading-relaxed text-muted">
|
||||
{campaign.summary}
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState } from "react";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import { ApiError, api, type AdminCampaign, type AdminTier, type Organization } from "@/lib/api";
|
||||
import { ApiError, api, type AdminCampaign, type AdminTier, type Owner } from "@/lib/api";
|
||||
import {
|
||||
ART_KEYS, CATEGORIES, STATUSES, TIER_TYPES, readCampaignForm, serializeStory,
|
||||
} from "@/lib/campaign-form";
|
||||
|
|
@ -18,10 +18,10 @@ const ERRORS: Record<string, string> = {
|
|||
*/
|
||||
export function CampaignForm({
|
||||
campaign,
|
||||
organizations,
|
||||
owners,
|
||||
}: {
|
||||
campaign: AdminCampaign;
|
||||
organizations: Organization[];
|
||||
owners: Owner[];
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const [notice, setNotice] = useState<string>();
|
||||
|
|
@ -88,13 +88,13 @@ export function CampaignForm({
|
|||
{CATEGORIES.map((c) => <option key={c}>{c}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Szervezet">
|
||||
<Field label="Gazda">
|
||||
<select
|
||||
name="organizationId"
|
||||
defaultValue={campaign.organizationId}
|
||||
name="ownerId"
|
||||
defaultValue={campaign.ownerId}
|
||||
className={input}
|
||||
>
|
||||
{organizations.map((o) => <option key={o.id} value={o.id}>{o.name}</option>)}
|
||||
{owners.map((o) => <option key={o.id} value={o.id}>{o.name}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Cél (Ft)">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
import type { Creator } from "@/lib/types";
|
||||
|
||||
/**
|
||||
* The campaign owner: photo if the organisation has one, its initials if not.
|
||||
*
|
||||
* Both the card and the campaign page render the owner, and they used to do it
|
||||
* with separate markup — the page had the avatar, the card had a bare line of
|
||||
* muted text. One component so a photo appears in both places at once.
|
||||
*/
|
||||
export function CreatorBadge({
|
||||
creator,
|
||||
size = "sm",
|
||||
children,
|
||||
}: {
|
||||
creator: Creator;
|
||||
size?: "sm" | "md";
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const box = size === "md" ? "size-14" : "size-11";
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-3 text-muted">
|
||||
{creator.avatarUrl ? (
|
||||
<img
|
||||
src={creator.avatarUrl}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className={`${box} shrink-0 rounded-full object-cover`}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
aria-hidden
|
||||
className={`${box} grid shrink-0 place-items-center rounded-full bg-lila font-semibold text-white ${
|
||||
size === "md" ? "text-base" : "text-sm"
|
||||
}`}
|
||||
>
|
||||
{creator.initials}
|
||||
</span>
|
||||
)}
|
||||
<span className={`truncate ${size === "md" ? "text-sm" : "text-xs"}`}>
|
||||
{creator.name}
|
||||
{children}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -67,7 +67,7 @@ export const api = {
|
|||
campaigns: () => request<AdminCampaignRow[]>("/admin/campaigns"),
|
||||
campaign: (slug: string) =>
|
||||
request<AdminCampaign>(`/admin/campaigns/${encodeURIComponent(slug)}`),
|
||||
organizations: () => request<Organization[]>("/admin/organizations"),
|
||||
owners: () => request<Owner[]>("/admin/owners"),
|
||||
save: (input: CampaignInput) =>
|
||||
request<SaveResult>("/admin/campaigns", {
|
||||
method: "PUT",
|
||||
|
|
@ -86,11 +86,11 @@ export interface AdminCampaignRow {
|
|||
title: string;
|
||||
status: string;
|
||||
goalAmount: number;
|
||||
organization: string;
|
||||
owner: string;
|
||||
pledgeCount: number;
|
||||
}
|
||||
|
||||
export interface Organization {
|
||||
export interface Owner {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
|
@ -118,7 +118,7 @@ export interface AdminCampaign {
|
|||
goalAmount: number;
|
||||
endsAt: string;
|
||||
status: string;
|
||||
organizationId: string;
|
||||
ownerId: string;
|
||||
story: { heading: string; body: string[] }[];
|
||||
tiers: AdminTier[];
|
||||
}
|
||||
|
|
@ -145,7 +145,7 @@ export interface CampaignInput {
|
|||
goalAmount: number;
|
||||
endsAt: string;
|
||||
status: string;
|
||||
organizationId: string;
|
||||
ownerId: string;
|
||||
story: { heading: string; body: string[] }[];
|
||||
tiers: TierInput[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -149,7 +149,7 @@ export function readCampaignForm(form: FormData, rowCount: number): CampaignInpu
|
|||
goalAmount: parseAmount(form.get("goalAmount")) ?? 0,
|
||||
endsAt: String(form.get("endsAt") ?? ""),
|
||||
status: String(form.get("status") ?? "DRAFT"),
|
||||
organizationId: String(form.get("organizationId") ?? ""),
|
||||
ownerId: String(form.get("ownerId") ?? ""),
|
||||
story: parseStory(String(form.get("story") ?? "")),
|
||||
tiers: readTierRows(form, rowCount),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ export interface Tier {
|
|||
|
||||
export interface Creator {
|
||||
name: string;
|
||||
/** Absent when the owner has no photo; the initials chip stands in. */
|
||||
avatarUrl?: string;
|
||||
initials: string;
|
||||
location: string;
|
||||
blurb: string;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { createBrowserRouter, redirect } from "react-router";
|
||||
import { ApiError, api, type AdminCampaign, type Organization } from "./lib/api";
|
||||
import { ApiError, api, type AdminCampaign, type Owner } from "./lib/api";
|
||||
import { takeBootstrap } from "./lib/bootstrap";
|
||||
import type { Campaign, CampaignSummary } from "./lib/types";
|
||||
import type { PlatformStats } from "./lib/api";
|
||||
|
|
@ -68,11 +68,11 @@ export const router = createBrowserRouter([
|
|||
path: "kampanyok/:slug",
|
||||
element: <EditCampaign />,
|
||||
loader: async ({ params }): Promise<EditData> => {
|
||||
const [campaign, organizations] = await Promise.all([
|
||||
const [campaign, owners] = await Promise.all([
|
||||
guard(api.admin.campaign(params.slug!)),
|
||||
guard(api.admin.organizations()),
|
||||
guard(api.admin.owners()),
|
||||
]);
|
||||
return { campaign, organizations };
|
||||
return { campaign, owners };
|
||||
},
|
||||
},
|
||||
],
|
||||
|
|
@ -84,7 +84,7 @@ export const router = createBrowserRouter([
|
|||
|
||||
export interface EditData {
|
||||
campaign: AdminCampaign;
|
||||
organizations: Organization[];
|
||||
owners: Owner[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { Link, useLoaderData } from "react-router";
|
||||
import { CampaignArt } from "@/components/CampaignArt";
|
||||
import { CreatorBadge } from "@/components/CreatorBadge";
|
||||
import { FundingLane } from "@/components/FundingLane";
|
||||
import { TierCard } from "@/components/TierCard";
|
||||
import { daysLeft, formatDate, formatHuf, formatNumber, progressPct } from "@/lib/format";
|
||||
|
|
@ -24,11 +25,10 @@ export default function CampaignPage() {
|
|||
{c.category}
|
||||
</span>
|
||||
<h1 className="mt-3 max-w-[19ch] font-display text-4xl sm:text-5xl">{c.title}</h1>
|
||||
<div className="mt-4 flex items-center gap-2.5 text-sm text-muted">
|
||||
<span className="grid size-8 shrink-0 place-items-center rounded-full bg-lila text-xs font-semibold text-white">
|
||||
{c.creator.initials}
|
||||
</span>
|
||||
{c.creator.name} · {c.creator.location}
|
||||
<div className="mt-4">
|
||||
<CreatorBadge creator={c.creator} size="md">
|
||||
{` · ${c.creator.location}`}
|
||||
</CreatorBadge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ export default function AdminHome() {
|
|||
<tr key={c.id} className="border-b border-line">
|
||||
<td className="py-3 pr-4">
|
||||
<span className="font-semibold">{c.title}</span>
|
||||
<span className="block text-xs text-muted">{c.organization}</span>
|
||||
<span className="block text-xs text-muted">{c.owner}</span>
|
||||
</td>
|
||||
<td className="py-3 pr-4 text-muted">{c.status}</td>
|
||||
<td className="num py-3 pr-4 text-muted">{formatHuf(c.goalAmount)}</td>
|
||||
|
|
|
|||
|
|
@ -5,14 +5,14 @@ import type { EditData } from "@/router";
|
|||
|
||||
/** Campaigns are created directly in the database; this only edits one. */
|
||||
export default function EditCampaign() {
|
||||
const { campaign, organizations } = useLoaderData() as EditData;
|
||||
const { campaign, owners } = useLoaderData() as EditData;
|
||||
useTitle(campaign.title);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="mt-8 font-display text-3xl">{campaign.title}</h1>
|
||||
|
||||
<CampaignForm campaign={campaign} organizations={organizations} />
|
||||
<CampaignForm campaign={campaign} owners={owners} />
|
||||
|
||||
<div className="mt-10 border-t border-line pt-5">
|
||||
<DeleteCampaignForm slug={campaign.slug} />
|
||||
|
|
|
|||
Loading…
Reference in New Issue