admin: edit the contact's own card, not what a group lends it
The form served the household's address as if you owned the deed,
and a blank note row would've stamped the gate code on the member.
Now the rows come from the stored card, the lent ones left alone,
and a save that names a borrowed line matches nothing and writes none.
Assisted-by: Claude Opus 5 via Claude Code
diff --git a/lib/pro_tacts/admin/contacts_edit.rb b/lib/pro_tacts/admin/contacts_edit.rb
index 176d50e..a6642d2 100644
--- a/lib/pro_tacts/admin/contacts_edit.rb
+++ b/lib/pro_tacts/admin/contacts_edit.rb
@@ -78,6 +78,7 @@ module ProTacts
private_constant :ADDRESS_FIELDS
# @rbs @contact: Contact
+ # @rbs @own: Contact
# @rbs @notice: String?
# @rbs @first: String?
# @rbs @last: String?
@@ -85,6 +86,13 @@ module ProTacts
#: (contact: Contact, ?notice: String?) -> void
def initialize(contact:, notice: nil)
@contact = contact
+ # The rows are the contact's own, never what a group lends it
+ # (Contact#own): a row here is a row the save may rewrite or
+ # remove, and neither is this screen's to do to a value the
+ # household owns — the details page is where an inherited row
+ # is read (Admin::ContactsShow). The etag below is still the
+ # composed contact's, the one a save is guarded against.
+ @own = contact.own
@notice = notice
# N's first two components (RFC 2426 section 3.1.2: family;
# given) — the two fields the create dialog also asks for. The
@@ -157,7 +165,7 @@ module ProTacts
# the last value of a duplicated name — editing one
# of a pair of byte-identical rows means blanking
# one, saving, then editing the other.
- @contact.phones.each do |phone|
+ @own.phones.each do |phone|
label(class: "field", data: {blank_removes: true}) do
span { phone.type || "phone" }
input(type: "tel", name: "phone[#{phone.line.digest}]",
@@ -165,19 +173,19 @@ module ProTacts
end
end
# An email row, the phone row's own shape over EMAIL.
- @contact.emails.each do |email|
+ @own.emails.each do |email|
label(class: "field", data: {blank_removes: true}) do
span { email.type || "email" }
input(type: "email", name: "email[#{email.line.digest}]",
value: email.value, placeholder: "removed on save")
end
end
- @contact.addresses.each { address_row(it) }
+ @own.addresses.each { address_row(it) }
birthday_row
added_rows
# The first NOTE the card carries: this row edits one,
# and a save writes one (Web#edited_card's replace).
- note = @contact.notes.first&.value
+ note = @own.notes.first&.value
label(class: "field", data: {blank_removes: !!note}) do
span { "Note" }
textarea(name: "note", rows: 4,
diff --git a/lib/pro_tacts/contact.rb b/lib/pro_tacts/contact.rb
index 0a84a5b..54ce75b 100644
--- a/lib/pro_tacts/contact.rb
+++ b/lib/pro_tacts/contact.rb
@@ -168,6 +168,23 @@ module ProTacts
@vcard = line ? card.insert([line]) : card
end
+ # The same contact with nothing its groups lend it: the stored
+ # card and the birthday, no inheritance. The editor's subject —
+ # a form rendered from the composed contact offers rows the member
+ # does not own, and saving one writes the group's value into the
+ # member's own card (Web#edited_card), which is the
+ # materialization the composition exists to avoid
+ # (docs/plans/2026-08-24-vcard-storage-and-groups.md). The etag is
+ # not this contact's: a screen guarding a save still carries the
+ # composed one, which is what a client downloads and what the
+ # store's change log records.
+ #: () -> Contact
+ def own
+ return @own if defined?(@own)
+
+ @own = self.class.for(id: @id, stored: @stored, birthday: @birthday, inherited: [])
+ end
+
# The etag over #vcard's bytes, derived here on first ask and
# memoized with the card it hashes.
#: () -> String
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index 06027a7..3cfa0f4 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -752,14 +752,21 @@ module ProTacts
nickname = params["nickname"].to_s.strip
note = params["note"].to_s.strip
+ # The rows are read off the contact's own card, the one the form
+ # rendered (Admin::ContactsEdit) and the one this save splices:
+ # a line a group lends is on neither, so no digest here can name
+ # one and no save can copy a group's value onto its member
+ # (Contact#own).
+ own = contact.own
+
card = contact.stored
card = card.replace("N", [n_line(card, first, last)])
card = edited_fn(contact, card, first, last)
card = card.replace("NICKNAME", text_lines("NICKNAME", nickname))
card = card.replace("NOTE", text_lines("NOTE", note))
- card = edited_phones(contact, card, params)
- card = edited_emails(contact, card, params)
- edited_addresses(contact, card, params)
+ card = edited_phones(own, card, params)
+ card = edited_emails(own, card, params)
+ edited_addresses(own, card, params)
end
# The phones' half of the surgical save: each row names its line
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index 8990829..7a3e4dc 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -686,6 +686,59 @@ class AdminContactsPagesTest < Minitest::Test
end
end
+ # The editor renders the contact's own rows and none a group lends:
+ # a row here is a row the save may rewrite or remove, and neither is
+ # this screen's to do to a value the household owns. The details
+ # page is where an inherited row is read.
+ def test_the_edit_screen_omits_the_rows_a_group_lends
+ with_contacts({"ada" => ADA}) do |store|
+ add_group(store, name: "Boole household", members: ["ada"], lines: HOUSEHOLD)
+
+ get "/contacts/ada/edit"
+
+ assert_includes last_response.body, "12 Analytical Way"
+ refute_includes last_response.body, "7 Calculus Close"
+ end
+ end
+
+ # The note row is the collision's own case: a member with no note of
+ # its own inherits the household's, and a textarea prefilled from it
+ # would write the group's words onto the member the first time
+ # anyone saved.
+ def test_the_note_row_is_blank_over_a_note_only_the_group_lends
+ noteless = ADA.sub("NOTE:Countess of Lovelace.\r\n", "")
+
+ with_contacts({"ada" => noteless}) do |store|
+ add_group(store, name: "Boole household", members: ["ada"], lines: HOUSEHOLD)
+
+ get "/contacts/ada/edit"
+
+ assert_includes last_response.body, '<textarea name="note" rows="4"></textarea>'
+ refute_includes last_response.body, "Gate code 1854."
+ end
+ end
+
+ # A save reaches the contact's own lines and no others, so a request
+ # naming a lent line — the screen renders no such row, but a request
+ # is not the screen — matches nothing and writes nothing.
+ def test_a_save_naming_a_lent_line_touches_nothing
+ with_contacts({"ada" => ADA}) do |store|
+ add_group(store, name: "Boole household", members: ["ada"], lines: HOUSEHOLD)
+ contact = store.contact("ada")
+ lent = contact.addresses.find { contact.group_of(it.line) }
+
+ post "/contacts/ada", first: "Ada", last: "Lovelace", note: "Countess of Lovelace.",
+ etag: contact.etag,
+ address: {lent.line.digest => {"street" => "9 Elsewhere Lane"}}
+
+ assert_equal 303, last_response.status
+ after = store.contact("ada")
+ refute_includes after.stored.to_s, "9 Elsewhere Lane"
+ refute_includes after.stored.to_s, "7 Calculus Close"
+ assert_includes after.vcard.to_s, "7 Calculus Close"
+ end
+ end
+
def test_the_edit_screen_of_an_unknown_contact_is_404
with_contacts({}) { get "/contacts/nope/edit" }