1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
require "pro_tacts/admin/card_form"
require "pro_tacts/admin/groups_edit"
require "pro_tacts/admin/groups_index"
require "pro_tacts/admin/groups_members"
require "pro_tacts/admin/groups_show"
module ProTacts
class Web < Roda
# The largest group photo an upload may carry: a phone's
# full-size JPEG fits, and the page shows the bytes as they came.
GROUP_PHOTO_LIMIT = 10_000_000 #: Integer
# The group screens, the contacts' own shape: the collection's
# GET and create, then a record's GET, its editor, the POST that
# applies it, and the members screen beside both — membership is
# a relationship rather than a line the group holds, so it is
# edited from the card's own screen, not the editor
# (Admin::GroupsShow's reason). What the writes rest on is
# docs/plans/2026-09-09-group-edits-propagate.md. A create lands
# on the editor, a new group being nothing until something is
# added to it.
hash_branch("groups") do |r|
r.is do
r.get do
response["Content-Type"] = "text/html; charset=utf-8"
Admin::GroupsIndex.call(groups: store.all_groups, login: @login)
end
r.post do
name = typecast_params.str("name", "").strip
id = store.create_group(name: name.empty? ? nil : name)
r.redirect "/groups/#{id}/edit", 303
rescue Sequel::UniqueConstraintViolation
# A taken name (db/migrations/008_group_names.rb).
response["Content-Type"] = "text/html; charset=utf-8"
Admin::GroupsIndex.call(groups: store.all_groups, login: @login,
notice: "Another group is already named #{name}.")
end
end
r.on String do |id|
r.get "edit" do
group = store.group(id)
group_edit_screen(group) if group
end
r.get "members" do
group = store.group(id)
members_screen(group) if group
end
# The group's photo (docs/plans/2026-10-12-group-photos.md):
# served for its page's <img>, and set or removed from its
# editor. Above the editor's POST, the members route's reason.
r.is "photo" do
r.get do
photo = store.group_photo(id)
# The page never points at a photo the group lacks, so one
# with none is the 404 case.
serve_photo(photo) if photo
end
r.post do
apply_photo(r, id)
end
end
# Membership from the group's side, the members screen's save
# (Admin::GroupsMembers). Above the editor's POST, whose bare
# verb block would match this path too.
r.post "members" do
apply_members(r, id)
end
r.post do
apply_group_edit(r, id)
end
r.get do
group = store.group(id)
if group
response["Content-Type"] = "text/html; charset=utf-8"
changes = store.group_changes_of(id)
Admin::GroupsShow.call(
group:,
login: @login,
contacts: store.listed_contacts((group.members + Admin::GroupsShow.moved(changes)).uniq),
changes:,
)
end
end
end
end
private
# The group editor's POST, #apply_edit's shape over a group: the
# snapshot guard, then the lines the form splices
# (Admin::CardForm.group_lines), and one store write for the lot.
#: (untyped r, String id) -> String?
def apply_group_edit(r, id)
group = store.group(id)
return if group.nil?
if typecast_params.str!("version") != group.version
return group_edit_screen(group, notice: "This group changed since the page loaded; nothing was saved.")
end
# No name at all keeps the one it has: a book's group shows its
# name as text (Admin::GroupsEdit), where a blank one is a
# request to go nameless. Membership is the members screen's
# (#apply_members); the editor's form carries none.
name = typecast_params.str("name", group.name)
begin
store.edit_group(id, name:, lines: Admin::CardForm.group_lines(group, card_form_params))
rescue Sequel::UniqueConstraintViolation
# A taken name (db/migrations/008_group_names.rb). The save is
# one transaction, so nothing of it landed.
return group_edit_screen(group, notice: "Another group is already named #{name}; nothing was saved.")
end
r.redirect "/groups/#{id}", 303
end
# The members screen's save, #apply_groups' shape over a group:
# the diff of what came back against what the page loaded, so a
# membership moved elsewhere since stays as it is, and a stale
# page has nothing to revert and no snapshot to refuse. Only ids
# that name a card: a membership row is a foreign key, and a
# doctored id is ordinary bad input rather than a 500.
#: (untyped r, String id) -> String?
def apply_members(r, id)
group = store.group(id)
return if group.nil?
known = store.contact_ids
checked = ids_in("members") & known
was = ids_in("was") & known
store.edit_members(id, join: checked - was, leave: was - checked)
r.redirect "/groups/#{id}", 303
end
# The editor's photo form: a removal, or an upload judged by its
# magic bytes and its size before anything is written.
#: (untyped r, String id) -> String?
def apply_photo(r, id)
group = store.group(id)
return if group.nil?
if r.params.key?("remove")
store.remove_group_photo(id)
return r.redirect("/groups/#{id}", 303)
end
upload = file_in("photo")
return group_edit_screen(group, notice: "Choose a photo to upload.") if upload.nil?
tempfile = upload.fetch(:tempfile)
if tempfile.size > GROUP_PHOTO_LIMIT
return group_edit_screen(group, notice: "That photo is over #{GROUP_PHOTO_LIMIT / 1_000_000} MB; nothing was saved.")
end
photo = Contact.photo_of(tempfile.read.b)
return group_edit_screen(group, notice: "That file is not a JPEG, PNG, or GIF; nothing was saved.") if photo.nil?
store.set_group_photo(id, photo)
r.redirect "/groups/#{id}", 303
end
#: (Group group, ?notice: String) -> String
def group_edit_screen(group, notice: nil)
response["Content-Type"] = "text/html; charset=utf-8"
Admin::GroupsEdit.call(group:, login: @login, notice:)
end
#: (Group group) -> String
def members_screen(group)
response["Content-Type"] = "text/html; charset=utf-8"
Admin::GroupsMembers.call(group:, contacts: store.listed_contacts, login: @login)
end
end
end