From 7865e5d1c8bf48c67c4648554069bc7d5acf9557 Mon Sep 17 00:00:00 2001 From: streambinder Date: Sat, 26 Sep 2026 06:05:56 +0000 Subject: [PATCH] feat(gifts): link gifts to invitations when available Tie each claimed gift to the invitation the claimer arrived through. --- internal/database/database.go | 1 + internal/database/gifts.go | 14 +++++------ internal/handlers/gift.go | 22 ++++++++++++++++- internal/models/models.go | 1 + templates/dashboard.templ | 45 +++++++++++++++++++++++++++++++++++ templates/dashboard_test.go | 37 ++++++++++++++++++++++++++++ templates/home.templ | 3 +++ 7 files changed, 115 insertions(+), 8 deletions(-) create mode 100644 templates/dashboard_test.go diff --git a/internal/database/database.go b/internal/database/database.go index d15f53d..80c0a5a 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -160,6 +160,7 @@ func migrate() { amount INTEGER NOT NULL, donor TEXT NOT NULL DEFAULT '', registry_item_id INTEGER REFERENCES registry_items(id), + invitation_id INTEGER REFERENCES invitations(id), confirmed INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL DEFAULT (unixepoch()) ) STRICT`, diff --git a/internal/database/gifts.go b/internal/database/gifts.go index 8e43916..d31450c 100644 --- a/internal/database/gifts.go +++ b/internal/database/gifts.go @@ -2,16 +2,16 @@ package database import "github.com/streambinder/foedus/internal/models" -func CreateGift(amount int, donor string, registryItemID *int) error { +func CreateGift(amount int, donor string, registryItemID, invitationID *int) error { _, err := DB.Exec( - `INSERT INTO gifts (amount, donor, registry_item_id, confirmed) VALUES (?, ?, ?, 0)`, - amount, donor, registryItemID, + `INSERT INTO gifts (amount, donor, registry_item_id, invitation_id, confirmed) VALUES (?, ?, ?, ?, 0)`, + amount, donor, registryItemID, invitationID, ) return err } func GetAllGifts() ([]models.Gift, error) { - rows, err := DB.Query(`SELECT id, amount, donor, registry_item_id, confirmed, created_at FROM gifts ORDER BY confirmed ASC, created_at DESC`) + rows, err := DB.Query(`SELECT id, amount, donor, registry_item_id, invitation_id, confirmed, created_at FROM gifts ORDER BY confirmed ASC, created_at DESC`) if err != nil { return nil, err } @@ -20,7 +20,7 @@ func GetAllGifts() ([]models.Gift, error) { var gifts []models.Gift for rows.Next() { var g models.Gift - if err := rows.Scan(&g.ID, &g.Amount, &g.Donor, &g.RegistryItemID, &g.Confirmed, &g.CreatedAt); err != nil { + if err := rows.Scan(&g.ID, &g.Amount, &g.Donor, &g.RegistryItemID, &g.InvitationID, &g.Confirmed, &g.CreatedAt); err != nil { return nil, err } gifts = append(gifts, g) @@ -31,9 +31,9 @@ func GetAllGifts() ([]models.Gift, error) { func GetGift(id int) (models.Gift, error) { var g models.Gift err := DB.QueryRow( - `SELECT id, amount, donor, registry_item_id, confirmed, created_at FROM gifts WHERE id = ?`, + `SELECT id, amount, donor, registry_item_id, invitation_id, confirmed, created_at FROM gifts WHERE id = ?`, id, - ).Scan(&g.ID, &g.Amount, &g.Donor, &g.RegistryItemID, &g.Confirmed, &g.CreatedAt) + ).Scan(&g.ID, &g.Amount, &g.Donor, &g.RegistryItemID, &g.InvitationID, &g.Confirmed, &g.CreatedAt) return g, err } diff --git a/internal/handlers/gift.go b/internal/handlers/gift.go index 0c7d4c2..5f386a6 100644 --- a/internal/handlers/gift.go +++ b/internal/handlers/gift.go @@ -12,6 +12,7 @@ type claimRequest struct { RegistryItemID *int `json:"registry_item_id"` Amount int `json:"amount"` // whole currency units (e.g. euros) Donor string `json:"donor"` + InviteCode string `json:"invite_code"` // invitation the claimer arrived through, if any } func ClaimGift(c *fiber.Ctx) error { @@ -63,7 +64,8 @@ func ClaimGift(c *fiber.Ctx) error { } } - if err := database.CreateGift(req.Amount, strings.TrimSpace(req.Donor), req.RegistryItemID); err != nil { + invitationID := resolveInvitationID(c, req) + if err := database.CreateGift(req.Amount, strings.TrimSpace(req.Donor), req.RegistryItemID, invitationID); err != nil { logger.Error("gift claim save failed", "amount", req.Amount, "donor", observability.Redact(req.Donor), "error", err.Error()) return c.Status(500).JSON(fiber.Map{"error": "failed to save gift"}) } @@ -72,6 +74,24 @@ func ClaimGift(c *fiber.Ctx) error { "amount", req.Amount, "donor", observability.Redact(req.Donor), "registry_item_id", req.RegistryItemID, + "invitation_id", invitationID, ) return c.JSON(fiber.Map{"ok": true}) } + +// resolveInvitationID resolves the invite code the claimer arrived through to +// its invitation row, so the gift is tied to the invitation with a FK. +// Returns nil when no code was supplied or it doesn't match an invitation — +// the claim is recorded anyway, just without the link. +func resolveInvitationID(c *fiber.Ctx, req claimRequest) *int { + code := strings.TrimSpace(req.InviteCode) + if code == "" { + return nil + } + inv, err := database.GetInvitationByCode(code) + if err != nil { + handlerLogger(c).Warn("gift claim unknown invite code", "invitation_code", observability.Redact(code)) + return nil + } + return &inv.ID +} diff --git a/internal/models/models.go b/internal/models/models.go index c4eead6..5003606 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -157,6 +157,7 @@ type Gift struct { Amount int // whole currency units (e.g. euros), no cents Donor string // who sent the gift RegistryItemID *int // fk to registry_items, nil for generic gifts + InvitationID *int // fk to invitations, nil when the claim wasn't tied to an invitation Confirmed bool CreatedAt Timestamp } diff --git a/templates/dashboard.templ b/templates/dashboard.templ index e8c7229..7b4fbee 100644 --- a/templates/dashboard.templ +++ b/templates/dashboard.templ @@ -151,6 +151,39 @@ func soundtrackInviteGuestFirstNames(invitations []models.Invitation, inviteID s return "" } +// giftsHaveInvitationIDs reports whether any of the given gifts is linked to +// an invitation, so the dashboard can skip the Guests column when unused. +func giftsHaveInvitationIDs(gifts []models.Gift) bool { + for _, g := range gifts { + if g.InvitationID != nil { + return true + } + } + return false +} + +// giftInvitationGuestFirstNames joins the first names of the guests on the +// invitation a gift was claimed through, or "" when the gift has none. +func giftInvitationGuestFirstNames(invitations []models.Invitation, invitationID *int) string { + if invitationID == nil { + return "" + } + for _, inv := range invitations { + if inv.ID != *invitationID { + continue + } + var names []string + for _, guest := range inv.Guests { + firstName := strings.TrimSpace(guest.FirstName) + if firstName != "" { + names = append(names, firstName) + } + } + return strings.Join(names, ", ") + } + return "" +} + func invitationActioned(inv models.Invitation) bool { for _, g := range inv.Guests { if g.ConfirmedCeremony != nil || g.ConfirmedReception != nil { @@ -1293,6 +1326,9 @@ templ Dashboard(settings models.Settings, content SettingsContent, guests []mode { t("th.donor") } { t("th.amount") } { t("th.registry_item") } + if giftsHaveInvitationIDs(pagedGifts) { + { t("th.invite_guests") } + } { t("th.confirmed") } { t("th.date") } { t("th.actions") } @@ -1304,6 +1340,15 @@ templ Dashboard(settings models.Settings, content SettingsContent, guests []mode { g.Donor } { formatGiftAmount(g.Amount) } { registryItemName(registryItems, g.RegistryItemID) } + if giftsHaveInvitationIDs(pagedGifts) { + + if giftInvitationGuestFirstNames(invitations, g.InvitationID) != "" { + { giftInvitationGuestFirstNames(invitations, g.InvitationID) } + } else { + — + } + + } if g.Confirmed { { t("status.confirmed") } diff --git a/templates/dashboard_test.go b/templates/dashboard_test.go new file mode 100644 index 0000000..b161e1e --- /dev/null +++ b/templates/dashboard_test.go @@ -0,0 +1,37 @@ +package templates + +import ( + "testing" + + "github.com/streambinder/foedus/internal/models" +) + +func TestGiftsHaveInvitationIDs(t *testing.T) { + if giftsHaveInvitationIDs(nil) { + t.Fatal("expected false for no gifts") + } + if giftsHaveInvitationIDs([]models.Gift{{Donor: "x"}}) { + t.Fatal("expected false when no gift has an invitation") + } + id := 3 + if !giftsHaveInvitationIDs([]models.Gift{{Donor: "x", InvitationID: &id}}) { + t.Fatal("expected true when a gift has an invitation") + } +} + +func TestGiftInvitationGuestFirstNames(t *testing.T) { + invitations := []models.Invitation{ + {ID: 3, Guests: []models.Guest{{FirstName: "Agnese"}, {FirstName: "Davide"}}}, + } + if got := giftInvitationGuestFirstNames(invitations, nil); got != "" { + t.Fatalf("expected empty for nil invitation id, got %q", got) + } + id := 3 + if got := giftInvitationGuestFirstNames(invitations, &id); got != "Agnese, Davide" { + t.Fatalf("expected joined first names, got %q", got) + } + unknown := 99 + if got := giftInvitationGuestFirstNames(invitations, &unknown); got != "" { + t.Fatalf("expected empty for unknown invitation id, got %q", got) + } +} diff --git a/templates/home.templ b/templates/home.templ index d98d0e7..c30f35e 100644 --- a/templates/home.templ +++ b/templates/home.templ @@ -1123,6 +1123,9 @@ templ Home(settings models.Settings, content HomeContent, registryItems []models donor: document.getElementById("modal-donor").value, }; if (currentItemId !== null) body.registry_item_id = currentItemId; + var claimInviteCode = + new URLSearchParams(window.location.search).get("invite") || ""; + if (claimInviteCode) body.invite_code = claimInviteCode; fetch("/gift/claim", { method: "POST", headers: {