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