Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions internal/database/database.go
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
14 changes: 7 additions & 7 deletions internal/database/gifts.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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)
Expand All @@ -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
}

Expand Down
22 changes: 21 additions & 1 deletion internal/handlers/gift.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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"})
}
Expand All @@ -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
}
1 change: 1 addition & 0 deletions internal/models/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
45 changes: 45 additions & 0 deletions templates/dashboard.templ
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -1293,6 +1326,9 @@ templ Dashboard(settings models.Settings, content SettingsContent, guests []mode
<th>{ t("th.donor") }</th>
<th class="amount-cell">{ t("th.amount") }</th>
<th>{ t("th.registry_item") }</th>
if giftsHaveInvitationIDs(pagedGifts) {
<th>{ t("th.invite_guests") }</th>
}
<th>{ t("th.confirmed") }</th>
<th>{ t("th.date") }</th>
<th>{ t("th.actions") }</th>
Expand All @@ -1304,6 +1340,15 @@ templ Dashboard(settings models.Settings, content SettingsContent, guests []mode
<td data-label={ t("th.donor") }>{ g.Donor }</td>
<td class="amount-cell" data-label={ t("th.amount") }>{ formatGiftAmount(g.Amount) }</td>
<td data-label={ t("th.registry_item") }>{ registryItemName(registryItems, g.RegistryItemID) }</td>
if giftsHaveInvitationIDs(pagedGifts) {
<td data-label={ t("th.invite_guests") }>
if giftInvitationGuestFirstNames(invitations, g.InvitationID) != "" {
{ giftInvitationGuestFirstNames(invitations, g.InvitationID) }
} else {
—
}
</td>
}
<td data-label={ t("th.confirmed") }>
if g.Confirmed {
{ t("status.confirmed") }
Expand Down
37 changes: 37 additions & 0 deletions templates/dashboard_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
3 changes: 3 additions & 0 deletions templates/home.templ
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
Loading