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_params strips 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

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.

Examples:

Clear the description, leave the join link and highlight alone

.schedules.edit_entry(entry_id: 123) do |entry|
  entry.summary = "🚨 #{entry.summary}"
  entry.description = "" # clearing = setting empty on a full object
end

Address a carve-out

.schedules.edit_entry(entry_id: 123) do |entry|
  entry.url = "" if entry.url.start_with?("https://meet.example.com/")
end

Parameters:

  • entry_id (Integer)

    entry id

Yield Parameters:

Returns:

  • (Hash)

    the updated schedule entry

Raises:

  • (ArgumentError)

    if no block is given



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.

Parameters:

  • entry_id (Integer)

    entry id

  • summary (String, nil) (defaults to: nil)

    new summary (nil = keep current)

  • starts_at (String, nil) (defaults to: nil)

    new start, a date or timestamp (nil = keep current)

  • ends_at (String, nil) (defaults to: nil)

    new end, a date or timestamp (nil = keep current)

  • description (String, nil) (defaults to: nil)

    new description (nil = keep current, "" clears)

  • all_day (Boolean, nil) (defaults to: nil)

    new all-day flag (nil = keep current)

  • participant_ids (Array<Integer>, nil) (defaults to: nil)

    replaces participants (nil = leave to BC3, [] clears)

  • url (String, nil) (defaults to: nil)

    new join link (nil = leave to BC3, "" clears)

  • highlighted (Boolean, nil) (defaults to: nil)

    new highlight (nil = leave to BC3, false removes)

  • notify (Boolean, nil) (defaults to: nil)

    notify participants (nil = do not address)

Returns:

  • (Hash)

    the updated schedule entry



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