Module: Basecamp::Services::SchedulesExtensions
- Defined in:
- lib/basecamp/services/schedules_extensions.rb
Overview
Merge-safe update_entry and read-modify-write edit_entry for schedule
entries, prepended onto the generated SchedulesService (see the
on_load hook in basecamp.rb).
BC3's Schedules::EntriesController#update rebuilds the recordable from
the submitted params, so PUT /schedule_entries/{id} is a full
replace: a body that omits description ERASES it, and one that omits
summary erases that too — the entry then reads back as
"Untitled", because Schedule::Entry#summary is
super.presence || "Untitled". The sparse PUT — the natural thing
to write — is therefore destructive on the raw endpoint, which stays
available as replace_entry.
Two classes of writable field
Unlike documents and todolists, this record's writable set does not have one uniform rule. It splits in two:
[full state] summary, starts_at, ends_at, description, all_day.
Always resent, empties included: "" is how a clear is
expressed on a full-replace endpoint, never JSON null and
never an omission.
[addressed-only] participant_ids, url, highlighted, notify. Sent
only when the caller addressed them, and never seeded
onto the wire from the read-back.
The first three of the addressed-only set are the operation's
preservedOnOmission carve-out: BC3 seeds them from the existing
recordable when the request does not address them, so resending them is
redundant at best and wrong if the GET raced a concurrent change. Echoing
the response's url would be worse than redundant — that key is the
entry's own Basecamp API URL, written by recordings/_recording before
the entry partial renders, so BC3 emits the join link under the
non-colliding join_url. Writing url back would store the API URL as
the join link. notify is addressed-only for a different reason: it is a
directive, not state — sending it makes BC3 recompute a drafted entry's
subscriber list — and the read-back carries nothing to seed it from.
An explicitly empty value in that second class is an address, not an absence: participant_ids: [] clears participants, url: "" clears the join link, highlighted: false removes the highlight. All three survive body compaction, which strips only nil (SPEC section 18).
Recurring entries
ensure_non_recurring_event 302-redirects both show and update for a
recurring entry, so this route serves non-recurring entries only. The SDK
does not follow redirects on a PUT, and the GET's redirect lands on a body
this composite refuses rather than reads. Recurrence itself
(+recurrence_schedule+, recurs_until, time_zone_name) is unmodelled
here and stays unmodelled: BC3 forces all three to nil for a non-recurring
entry.
Both methods compose the public get_entry and replace_entry, so hooks
observe the two wire operations (+get_entry+ then replace_entry), not a
synthetic composite.
Neither is atomic: there is no conditional-update signal on this endpoint,
so a concurrent write between the GET and PUT is overwritten — last write
wins for the whole representation. The window is one round-trip. Use
replace_entry to overwrite deliberately.
Defined Under Namespace
Classes: ScheduleEntryFields
Constant Summary collapse
- ESCAPE_HATCH =
The deliberate-overwrite escape hatch named in every malformed-response hint raised out of this composite.
"replace_entry"- RECORD =
The record name interpolated into MergeSafe's messages.
"Schedule entry"- CARVE_OUTS =
The writable members BC3 preserves when the request does not address them, plus
notify, which is a directive rather than state. Sent only on an explicit address; never seeded from the read-back. %i[participant_ids url highlighted notify].freeze
- CLEARED =
How each writable member spells "cleared" on the wire.
compact_paramsstrips nil (SPEC section 18), so a nil that reached the request would silently become an omission — an address turned back into an absence, which on a full-replace endpoint is exactly the defect this composite exists to prevent. Every member whose type has an empty value is normalised to it here. The three booleans (+all_day+,highlighted,notify) have none: a boolean is true or false, so a nil assigned to one is caller error and is refused rather than dropped. { summary: "", starts_at: "", ends_at: "", description: "", participant_ids: [].freeze, url: "" }.freeze
Instance Method Summary collapse
-
#edit_entry(entry_id:) {|fields| ... } ⇒ Hash
Applies a read-modify-write block to a schedule entry: GETs the current entry, yields its writable state (ScheduleEntryFields), and PUTs it back.
-
#update_entry(entry_id:, summary: nil, starts_at: nil, ends_at: nil, description: nil, all_day: nil, participant_ids: nil, url: nil, highlighted: nil, notify: nil) ⇒ Hash
Sets the given fields on a schedule entry and preserves everything else: GETs the current entry, overlays the explicitly-passed keyword arguments, and PUTs the full representation back.
Instance Method Details
#edit_entry(entry_id:) {|fields| ... } ⇒ Hash
Applies a read-modify-write block to a schedule entry: GETs the current entry, yields its writable state (ScheduleEntryFields), and PUTs it back. The full-state fields are resent whether or not the block touches them; a carve-out is sent only if the block assigns it, even when it assigns the value the read already returned. If the block raises, the edit aborts and nothing is written.
Not atomic — see the module docs for the GET→PUT race, and for the recurring-entry redirect.
231 232 233 234 235 236 237 |
# File 'lib/basecamp/services/schedules_extensions.rb', line 231 def edit_entry(entry_id:) raise ArgumentError, "edit_entry requires a block" unless block_given? fields = fields_from_entry(get_entry(entry_id: entry_id)) yield fields put_entry_fields(entry_id, fields) end |
#update_entry(entry_id:, summary: nil, starts_at: nil, ends_at: nil, description: nil, all_day: nil, participant_ids: nil, url: nil, highlighted: nil, notify: nil) ⇒ Hash
Sets the given fields on a schedule entry and preserves everything else: GETs the current entry, overlays the explicitly-passed keyword arguments, and PUTs the full representation back.
An omitted (+nil+) argument is untouched, guaranteed. For the full-state
fields that means the read-back value is resent; for the addressed-only
fields it means the key never reaches the wire, leaving BC3 to seed it
from the record it already holds. An explicitly-passed "",
[] or false is an address and is sent.
nil is an unambiguous "not addressed" for every one of these
arguments: none of them has a JSON null wire spelling — a clear is
"", [] or false — and compact_params strips nil
before serialization anyway, so no sentinel is needed to tell "passed
nil" from "not passed".
Not atomic — see the module docs for the GET→PUT race, and for the
recurring-entry redirect. Use replace_entry to overwrite deliberately.
187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 |
# File 'lib/basecamp/services/schedules_extensions.rb', line 187 def update_entry(entry_id:, summary: nil, starts_at: nil, ends_at: nil, description: nil, all_day: nil, participant_ids: nil, url: nil, highlighted: nil, notify: nil) # Delegating to edit_entry is not a shortcut: it makes "the caller # addressed this" one rule with one implementation. A non-nil argument # invokes the same writer a block would, so the carve-outs are recorded # by exactly the mechanism edit_entry documents. edit_entry(entry_id: entry_id) do |entry| entry.summary = summary unless summary.nil? entry.starts_at = starts_at unless starts_at.nil? entry.ends_at = ends_at unless ends_at.nil? entry.description = description unless description.nil? entry.all_day = all_day unless all_day.nil? entry.participant_ids = participant_ids unless participant_ids.nil? entry.url = url unless url.nil? entry.highlighted = highlighted unless highlighted.nil? entry.notify = notify unless notify.nil? end end |