labimotion 2.3.0 → 2.4.0.rc11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -1
  3. data/lib/labimotion/apis/generic_dataset_api.rb +93 -3
  4. data/lib/labimotion/apis/generic_element_api.rb +198 -8
  5. data/lib/labimotion/apis/generic_klass_api.rb +74 -8
  6. data/lib/labimotion/apis/klass_share_api.rb +648 -0
  7. data/lib/labimotion/apis/labimotion_ai_api.rb +252 -0
  8. data/lib/labimotion/apis/labimotion_api.rb +4 -0
  9. data/lib/labimotion/apis/labimotion_doi_api.rb +24 -10
  10. data/lib/labimotion/apis/labimotion_template_browse_api.rb +13 -1
  11. data/lib/labimotion/apis/ontology_root_api.rb +74 -0
  12. data/lib/labimotion/apis/segment_api.rb +77 -10
  13. data/lib/labimotion/apis/user_klass_settings_api.rb +93 -0
  14. data/lib/labimotion/conf.rb +5 -0
  15. data/lib/labimotion/constants.rb +64 -0
  16. data/lib/labimotion/entities/application_entity.rb +8 -0
  17. data/lib/labimotion/entities/eln_element_entity.rb +6 -0
  18. data/lib/labimotion/entities/generic_klass_entity.rb +126 -0
  19. data/lib/labimotion/entities/klass_share_entity.rb +48 -0
  20. data/lib/labimotion/entities/properties_entity.rb +78 -2
  21. data/lib/labimotion/entities/segment_entity.rb +8 -0
  22. data/lib/labimotion/entities/user_klass_setting_entity.rb +10 -0
  23. data/lib/labimotion/helpers/cover_image_helpers.rb +181 -0
  24. data/lib/labimotion/helpers/dataset_helpers.rb +187 -1
  25. data/lib/labimotion/helpers/element_helpers.rb +282 -6
  26. data/lib/labimotion/helpers/exporter_helpers.rb +17 -2
  27. data/lib/labimotion/helpers/generic_helpers.rb +293 -4
  28. data/lib/labimotion/helpers/param_helpers.rb +105 -0
  29. data/lib/labimotion/helpers/sample_association_helpers.rb +7 -0
  30. data/lib/labimotion/helpers/segment_helpers.rb +102 -4
  31. data/lib/labimotion/libs/ai_egress_guard.rb +99 -0
  32. data/lib/labimotion/libs/ai_klass_queue.rb +88 -0
  33. data/lib/labimotion/libs/ai_klass_validator.rb +74 -0
  34. data/lib/labimotion/libs/ai_models.rb +201 -0
  35. data/lib/labimotion/libs/ai_template.rb +2285 -0
  36. data/lib/labimotion/libs/converter.rb +5 -43
  37. data/lib/labimotion/libs/data/datacite/labimotion_template.html.erb +67 -0
  38. data/lib/labimotion/libs/export_element.rb +128 -13
  39. data/lib/labimotion/libs/file_extractor.rb +210 -0
  40. data/lib/labimotion/libs/linked_element.rb +313 -0
  41. data/lib/labimotion/libs/ontology_store.rb +226 -0
  42. data/lib/labimotion/libs/ontology_terms.rb +227 -0
  43. data/lib/labimotion/libs/owner_resolver.rb +50 -0
  44. data/lib/labimotion/libs/ownership_audit.rb +73 -0
  45. data/lib/labimotion/libs/sample_association.rb +52 -1
  46. data/lib/labimotion/libs/share_notifier.rb +114 -0
  47. data/lib/labimotion/libs/share_resolver.rb +373 -0
  48. data/lib/labimotion/libs/user_ai_settings.rb +129 -0
  49. data/lib/labimotion/models/cellline.rb +47 -0
  50. data/lib/labimotion/models/concerns/datasetable.rb +3 -0
  51. data/lib/labimotion/models/concerns/matrice_labimotion.rb +124 -0
  52. data/lib/labimotion/models/concerns/segmentable.rb +2 -0
  53. data/lib/labimotion/models/concerns/template_doi.rb +133 -0
  54. data/lib/labimotion/models/dataset_klass.rb +1 -1
  55. data/lib/labimotion/models/element_klass.rb +1 -1
  56. data/lib/labimotion/models/klass_share.rb +129 -0
  57. data/lib/labimotion/models/segment_klass.rb +1 -1
  58. data/lib/labimotion/models/user_klass_setting.rb +58 -0
  59. data/lib/labimotion/models/user_setting.rb +60 -0
  60. data/lib/labimotion/usecases/build_template_doi_xml.rb +69 -23
  61. data/lib/labimotion/usecases/release_template_doi.rb +42 -17
  62. data/lib/labimotion/usecases/template_doi_helpers.rb +28 -6
  63. data/lib/labimotion/usecases/update_template_publication_metadata.rb +71 -2
  64. data/lib/labimotion/utils/export_utils.rb +1 -0
  65. data/lib/labimotion/utils/import_utils.rb +20 -3
  66. data/lib/labimotion/utils/serializer.rb +27 -0
  67. data/lib/labimotion/utils/units.rb +32 -67
  68. data/lib/labimotion/version.rb +1 -1
  69. data/lib/labimotion.rb +27 -0
  70. metadata +45 -3
@@ -0,0 +1,648 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time' # Time#iso8601, for the share_activity token below
4
+ require 'labimotion/version'
5
+
6
+ module Labimotion
7
+ # Share management for generic templates (template-sharing.md §7). Its own API class, not
8
+ # more namespaces in GenericKlassAPI, so the spec harness can load and mount it alone.
9
+ #
10
+ # Grant and level-change accept viewer/editor/maintainer only: the owner level never moves through
11
+ # these routes. It moves through transfer_ownership, an update of the single owner row, so
12
+ # the one-owner-per-klass invariant holds at every instant and the handover is auditable.
13
+ #
14
+ # request_access is the one route a non-holder may call, and it writes the one level the
15
+ # others refuse: 0, which grants nothing and only records the ask.
16
+ #
17
+ # Metrics/ClassLength is disabled for the same reason Metrics/BlockLength is on the helpers
18
+ # below: almost none of this is logic. It is Grape route DSL — a `desc`, a `params` block and
19
+ # a body per endpoint — and the cop counts every declaration and rescue clause as a class
20
+ # line. Splitting the routes across classes to satisfy it would scatter one coherent surface
21
+ # (`/generic_klass/shares…`) over several files.
22
+ class KlassShareAPI < Grape::API # rubocop:disable Metrics/ClassLength
23
+ helpers Labimotion::GenericHelpers
24
+
25
+ # How many rows one batch read may ask for. The poller is the caller, so the bound is about
26
+ # the payload rather than the client's patience: fifty klass entities carry fifty
27
+ # properties_templates, which is already a large response, and a Designer whose diff names
28
+ # more keys than that has effectively had its whole grid change — it can fall back to the
29
+ # list endpoint it was trying to avoid. Refused rather than truncated: a silently short
30
+ # answer would leave the client believing the missing keys were deleted.
31
+ MAX_ROW_KEYS = 50
32
+
33
+ # rubocop:disable Metrics/BlockLength
34
+ helpers do
35
+ def find_share!(share_id)
36
+ row = Labimotion::KlassShare.find_by(id: share_id)
37
+ error!('Share is invalid. Please refresh.', 404) if row.nil?
38
+ row
39
+ end
40
+
41
+ def share_klass!(row)
42
+ fetch_klass(row.klass_type, row.klass_id)
43
+ end
44
+
45
+ # The owner row is undeletable and undowngradeable by construction: removing it leaves
46
+ # the klass owner-less, which falls open to the legacy designer-wide gate. 409 pointing
47
+ # at transfer_ownership, mirroring Docs — an owner transfers before stepping off.
48
+ def guard_owner_row!(row)
49
+ return unless row.level == Labimotion::KlassShare::LEVELS[:owner]
50
+
51
+ error!({ mc: Labimotion::GenericHelpers::OWNER_ROW_MC,
52
+ msg: 'The owner cannot be removed or downgraded. ' \
53
+ 'Transfer ownership first (transfer_ownership).' }, 409)
54
+ end
55
+
56
+ # Person-only, deliberately narrower than the model's allowlist
57
+ # (KlassShare::SHAREABLE_USER_TYPES, which also admits Admin). The model has to accept
58
+ # the admin owner rows the seed paths write; nothing should mint a *new* one here. A
59
+ # grant comes from the Share modal's Person picker, and transfer_ownership is the route
60
+ # that moves a template *off* an admin — it checks the target, never the current owner,
61
+ # so an admin-owned template still transfers away under this rule.
62
+ #
63
+ # Validated here as well as on the model, so the refusal is a clean 404 naming the id
64
+ # rather than a validation error.
65
+ def person!(user_id)
66
+ scope = ::User.respond_to?(:persons) ? ::User.persons : ::User
67
+ person = scope.find_by(id: user_id)
68
+ error!("User #{user_id} is not a valid share target.", 404) if person.nil?
69
+ person
70
+ end
71
+
72
+ # A caller who already holds viewer/editor/owner has nothing to request. 409 rather
73
+ # than a silent success: the only way to get here is a grid that has not seen the grant
74
+ # yet, and the client needs to know to refresh rather than to show "asked".
75
+ def guard_access_held!(row)
76
+ return if row.level < Labimotion::KlassShare::LEVELS[:viewer]
77
+
78
+ error!({ mc: Labimotion::GenericHelpers::ACCESS_ALREADY_HELD_MC,
79
+ msg: 'You already have access to this template. Reload the list.' }, 409)
80
+ end
81
+
82
+ # Two lists, not one. A level-0 row is somebody *waiting* for access, and listing them
83
+ # under "People with access" says the opposite of what they are. `shares` keeps its
84
+ # contents and owner-first ordering for everything that confers access, the owner row
85
+ # included — the modal filters that one out itself.
86
+ #
87
+ # Both lists go to anyone who may `:read` the template, so viewers and editors see
88
+ # other people's pending requests. Intended: the request is a fact about the template,
89
+ # and hiding it would need a second authorization pass over a list this small.
90
+ def represent_shares(klz)
91
+ rows = Labimotion::KlassShare.for_klass(klz.class.name.split('::').last, klz.id).to_a
92
+ users = Labimotion::OwnerResolver.map_for_ids(rows.map(&:shared_with_id))
93
+ owner_row = rows.find { |row| row.level == Labimotion::KlassShare::LEVELS[:owner] }
94
+ granted, requested = rows.partition do |row|
95
+ row.level >= Labimotion::KlassShare::LEVELS[:viewer]
96
+ end
97
+ {
98
+ owner: owner_row && Labimotion::KlassShareEntity.represent(owner_row, users: users),
99
+ shares: Labimotion::KlassShareEntity.represent(
100
+ granted.sort_by { |row| -row.level }, users: users
101
+ ),
102
+ # Oldest ask first: the modal is a queue, and whoever waited longest reads first.
103
+ requests: Labimotion::KlassShareEntity.represent(
104
+ requested.sort_by(&:id), users: users
105
+ )
106
+ }
107
+ end
108
+
109
+ # The two inbox queues in wire form. Two shapes rather than one entity, because the two
110
+ # queues answer different questions: a request names the person *waiting* (shared_with_id)
111
+ # and when they asked, a grant names the person who *granted* it (created_by), what they
112
+ # granted, and when it last moved. One shared shape would have meant a `user` field whose
113
+ # meaning flipped with the list it happened to be in.
114
+ #
115
+ # Both bulk resolutions happen here, once over both queues together: the klass labels
116
+ # (grouped by type) and the user names (one query), never a lookup per row — this route
117
+ # sits on the same once-a-minute poll tick as share_activity.
118
+ def represent_inbox(inbox)
119
+ labels = klass_labels(inbox[:requests] + inbox[:grants])
120
+ users = Labimotion::OwnerResolver.map_for_ids(
121
+ inbox[:requests].map(&:shared_with_id) + inbox[:grants].map(&:created_by)
122
+ )
123
+ { requests: request_entries(inbox[:requests], labels, users),
124
+ grants: grant_entries(inbox[:grants], labels, users) }
125
+ end
126
+
127
+ # Oldest ask first: the owner's queue is a queue, and whoever waited longest reads first —
128
+ # the same ordering the Share modal's `requests` list uses.
129
+ def request_entries(rows, labels, users)
130
+ inbox_entries(rows.sort_by(&:id), labels) do |row, entry|
131
+ entry.merge(user: inbox_user(row.shared_with_id, users),
132
+ requested_at: row.created_at&.iso8601)
133
+ end
134
+ end
135
+
136
+ # Newest change first: this half is an announcement list, so the freshest news reads first.
137
+ # `changed_at` is `updated_at`, not `created_at`, because updated_at is what `acked_at` is
138
+ # compared against — the entry describes the row's *current* state, which an upgrade or a
139
+ # transfer moves long after the row was first written. Ties broken by id so the order is
140
+ # deterministic when a batch grant stamps several rows in the same instant.
141
+ def grant_entries(rows, labels, users)
142
+ ordered = rows.sort_by { |row| [row.updated_at, row.id] }.reverse
143
+ inbox_entries(ordered, labels) do |row, entry|
144
+ entry.merge(permission: level_label(row), user: inbox_user(row.created_by, users),
145
+ changed_at: row.updated_at&.iso8601)
146
+ end
147
+ end
148
+
149
+ # A row whose template cannot be resolved is dropped rather than sent with a null label:
150
+ # every entry exists to be clicked through to a grid row, and a deleted template has none
151
+ # (acts_as_paranoid puts a soft-deleted one outside the default scope by itself). Nothing
152
+ # is lost by dropping it — the share row survives the delete, since KlassShare carries no
153
+ # `dependent:` so a restored template comes back owned, and neither created_at nor
154
+ # updated_at moved, so the entry reappears exactly as it was.
155
+ def inbox_entries(rows, labels)
156
+ rows.filter_map do |row|
157
+ key = "#{row.klass_type}:#{row.klass_id}"
158
+ next if labels[key].nil?
159
+
160
+ yield(row, { share_id: row.id, klass_type: row.klass_type, klass_id: row.klass_id,
161
+ klass_label: labels[key] })
162
+ end
163
+ end
164
+
165
+ # One query per klass type present in the two queues, never one per row. `pluck` rather
166
+ # than loading records: an ElementKlass carries its whole properties_template, and all the
167
+ # inbox wants is a label.
168
+ def klass_labels(rows)
169
+ rows.group_by(&:klass_type).each_with_object({}) do |(type, group), map|
170
+ "Labimotion::#{type}".constantize
171
+ .where(id: group.map(&:klass_id).uniq)
172
+ .pluck(:id, :label)
173
+ .each { |id, label| map["#{type}:#{id}"] = label }
174
+ end
175
+ end
176
+
177
+ # No `state` field, unlike KlassShareEntity's user block: that one renders a live
178
+ # collaborator list, where a departed account has to grey out. The inbox names the other
179
+ # party of something that already happened, and their leaving since does not change it.
180
+ def inbox_user(user_id, users)
181
+ resolved = users[user_id]
182
+ { id: user_id, name: resolved&.name, name_abbreviation: resolved&.name_abbreviation }
183
+ end
184
+
185
+ def level_label(row)
186
+ value = row.permission_level
187
+ value.is_a?(Integer) ? Labimotion::KlassShare::LEVELS.key(value)&.to_s : value.to_s
188
+ end
189
+
190
+ # Wire form of one key's change token: `"<row count>|<newest updated_at>"`, in the same
191
+ # millisecond precision `latest` uses, because two changes inside one second have to be
192
+ # two different digests. Opaque to the client, which compares the whole string for
193
+ # inequality and never parses it — so the separator and the field order are free to change
194
+ # as long as they change for every key at once.
195
+ def share_digests(digests)
196
+ (digests || {}).each_with_object({}) do |(key, digest), map|
197
+ map[key] = "#{digest[:count]}|#{digest[:latest]&.iso8601(3)}"
198
+ end
199
+ end
200
+
201
+ # `"KlassType:id"` — the format KlassShare.key_of produces and share_activity's digest map
202
+ # is keyed by, so a key the client saw move is directly the argument that re-reads it.
203
+ # Malformed keys are refused rather than guessed at or dropped: a key nobody meant is a
204
+ # client bug, and skipping it silently would surface much later as a grid row that never
205
+ # refreshes. The cap is checked on what was *asked for*, before de-duplication, so a poll
206
+ # cannot buy itself a bigger payload by repeating keys.
207
+ def parse_row_keys!(keys)
208
+ if keys.size > Labimotion::KlassShareAPI::MAX_ROW_KEYS
209
+ error!({ mc: 'se00',
210
+ msg: "At most #{Labimotion::KlassShareAPI::MAX_ROW_KEYS} keys per request." }, 400)
211
+ end
212
+
213
+ keys.uniq.map do |key|
214
+ type, id = key.to_s.split(':', 2)
215
+ unless Labimotion::Constants::Klass::ALL.include?(type) && id.to_s.match?(/\A[1-9]\d*\z/)
216
+ error!({ mc: 'se00', msg: "Malformed key: #{key}" }, 400)
217
+ end
218
+
219
+ [type, id.to_i]
220
+ end
221
+ end
222
+
223
+ # One query per klass type, never one per key — the diff the poller hands over can name
224
+ # all three types and fifty ids, and this route sits on the same once-a-minute path as
225
+ # share_activity.
226
+ #
227
+ # A key whose record is gone is skipped rather than raised on: a template deleted between
228
+ # the poll and the read is an ordinary race (and acts_as_paranoid puts a soft-deleted one
229
+ # outside the default scope by itself), and the client's own row goes away on its next
230
+ # natural refresh.
231
+ def batch_rows(pairs)
232
+ pairs.group_by(&:first).each_with_object({ keys: [], data: [] }) do |(type, group), acc|
233
+ records = row_records(type, group.map(&:last))
234
+ next if records.empty?
235
+
236
+ acc[:keys].concat(records.map { |record| "#{type}:#{record.id}" })
237
+ acc[:data].concat(Array(represent_rows(type, records)))
238
+ end
239
+ end
240
+
241
+ def row_records(type, ids)
242
+ scope = "Labimotion::#{type}".constantize.where(id: ids)
243
+ # SegmentKlassEntity nests ElementKlassEntity, so the parent is read either way; ask for
244
+ # it in this query rather than one per row, exactly as list_segment_klass does.
245
+ scope = scope.preload(:element_klass) if type == Labimotion::Constants::Klass::SEGMENT
246
+ scope.to_a
247
+ end
248
+
249
+ # The options list_element_klass / list_segment_klass / list_dataset_klass pass, and no
250
+ # others: "same shape as the list" *is* the contract here. The client drops one of these
251
+ # into a grid row, and `owner`, `can_write` … `can_manage`, `current_user_permission` and
252
+ # `pending_request_count` all have to keep meaning what they meant in the list — the
253
+ # library reads an absent capability field as permissive, so a row missing them would come
254
+ # back looking editable to a viewer.
255
+ #
256
+ # `displayed_in_list: false` is what the three Designer grids get. Note the direction:
257
+ # DISPLAYED_IN_LIST_CONDITION is `unless:`, so false is the *full* payload,
258
+ # properties_template included — a row refresh that dropped the template body would be no
259
+ # use to the Work Area this route exists to keep alive.
260
+ #
261
+ # For segments the parent element klasses join the owners map and the share context, the
262
+ # same way list_segment_klass does it: Grape hands serialization options down into nested
263
+ # entities, so the nested ElementKlassEntity resolves an owner too, and without them every
264
+ # nested row falls back to a user query of its own.
265
+ def represent_rows(type, records)
266
+ related = records
267
+ related += records.map(&:element_klass) if type == Labimotion::Constants::Klass::SEGMENT
268
+ related = related.compact
269
+ "Labimotion::#{type}Entity".constantize.represent(
270
+ records,
271
+ displayed_in_list: false, with_ownership: true,
272
+ owners: Labimotion::OwnerResolver.map_for(related),
273
+ share_context: Labimotion::ShareResolver.context_for(related, current_user)
274
+ )
275
+ end
276
+ end
277
+ # rubocop:enable Metrics/BlockLength
278
+
279
+ resource :generic_klass do
280
+ namespace :shares do
281
+ desc 'list the shares of one template'
282
+ params do
283
+ requires :klass, type: String, desc: 'Klass', values: Labimotion::Constants::Klass::ALL
284
+ requires :id, type: Integer, desc: 'Klass ID'
285
+ end
286
+ get do
287
+ klz = fetch_klass(params[:klass], params[:id])
288
+ authorize_klass!(klz, :read)
289
+ { mc: 'ss00' }.merge(represent_shares(klz))
290
+ rescue StandardError => e
291
+ Labimotion.log_exception(e, current_user)
292
+ { mc: 'se00', msg: e.message }
293
+ end
294
+
295
+ desc 'grant viewer/editor/maintainer access to users (upsert)'
296
+ params do
297
+ requires :klass, type: String, desc: 'Klass', values: Labimotion::Constants::Klass::ALL
298
+ requires :id, type: Integer, desc: 'Klass ID'
299
+ # Grape's array-of-type coercion syntax, not a redundant constructor.
300
+ # rubocop:disable Style/RedundantArrayConstructor
301
+ requires :user_ids, type: Array[Integer], desc: 'users to share with (Persons only)'
302
+ # rubocop:enable Style/RedundantArrayConstructor
303
+ # Literal 10/20/25 (viewer/editor/maintainer), not KlassShare::LEVELS: params blocks
304
+ # run while the routes are built, and referencing the ActiveRecord model there would
305
+ # force it to load in any process that only mounts the API. Owner (30) is absent on
306
+ # purpose.
307
+ requires :permission_level, type: Integer, desc: 'viewer, editor or maintainer', values: [10, 20, 25]
308
+ end
309
+ post do
310
+ klz = fetch_klass(params[:klass], params[:id])
311
+ authorize_klass!(klz, :manage)
312
+ # Resolve and guard every target before writing anything: one bad id in the batch
313
+ # must refuse the whole grant, not leave the first half written.
314
+ rows = params[:user_ids].uniq.map do |user_id|
315
+ person!(user_id)
316
+ row = Labimotion::KlassShare.find_or_initialize_by(
317
+ klass_type: params[:klass], klass_id: klz.id, shared_with_id: user_id
318
+ )
319
+ # Re-granting over the owner's own row must not silently demote the owner.
320
+ guard_owner_row!(row) unless row.new_record?
321
+ row
322
+ end
323
+ Labimotion::KlassShare.transaction do
324
+ rows.each do |row|
325
+ row.created_by = current_user.id
326
+ row.permission_level = params[:permission_level]
327
+ row.save!
328
+ end
329
+ end
330
+ # After the commit, and fire-and-forget: ShareNotifier rescues
331
+ # internally, so an undeliverable toast can never turn a written
332
+ # grant into an se00 (labimotion#255 — moved in from the host).
333
+ Labimotion::ShareNotifier.grant(
334
+ klass: klz, actor: current_user, user_ids: params[:user_ids].uniq,
335
+ level: { 10 => 'viewer', 20 => 'editor', 25 => 'maintainer' }.fetch(params[:permission_level])
336
+ )
337
+ { mc: 'ss00' }.merge(represent_shares(klz))
338
+ rescue StandardError => e
339
+ Labimotion.log_exception(e, current_user)
340
+ { mc: 'se00', msg: e.message }
341
+ end
342
+
343
+ desc 'change the level of one share (viewer/editor/maintainer only)'
344
+ params do
345
+ requires :share_id, type: Integer, desc: 'Share ID'
346
+ # Literal 10/20/25 (viewer/editor/maintainer), not KlassShare::LEVELS: params blocks
347
+ # run while the routes are built, and referencing the ActiveRecord model there would
348
+ # force it to load in any process that only mounts the API. Owner (30) is absent on
349
+ # purpose.
350
+ requires :permission_level, type: Integer, desc: 'viewer, editor or maintainer', values: [10, 20, 25]
351
+ end
352
+ put ':share_id' do
353
+ row = find_share!(params[:share_id])
354
+ klz = share_klass!(row)
355
+ authorize_klass!(klz, :manage)
356
+ guard_owner_row!(row)
357
+ row.update!(permission_level: params[:permission_level], created_by: current_user.id)
358
+ # No notification here, deliberately: the Share modal's level changes
359
+ # ride the POST upsert above (which announces them), and this route
360
+ # has no library caller — matching the host layer it replaced, which
361
+ # never notified for it either.
362
+ { mc: 'ss00' }.merge(represent_shares(klz))
363
+ rescue StandardError => e
364
+ Labimotion.log_exception(e, current_user)
365
+ { mc: 'se00', msg: e.message }
366
+ end
367
+
368
+ desc 'revoke one share'
369
+ params do
370
+ requires :share_id, type: Integer, desc: 'Share ID'
371
+ end
372
+ delete ':share_id' do
373
+ row = find_share!(params[:share_id])
374
+ klz = share_klass!(row)
375
+ authorize_klass!(klz, :manage)
376
+ guard_owner_row!(row)
377
+ # Revoking on an ElementKlass leaves explicit SegmentKlass shares standing —
378
+ # inheritance is highest-wins, not a substitute — and revocations do not notify.
379
+ row.destroy!
380
+ { mc: 'ss00' }.merge(represent_shares(klz))
381
+ rescue StandardError => e
382
+ Labimotion.log_exception(e, current_user)
383
+ { mc: 'se00', msg: e.message }
384
+ end
385
+
386
+ # The requester's half of the inbox, and the only write in this class that is not an
387
+ # owner's: marking one grant seen. Idempotent — re-acking simply re-stamps the column,
388
+ # and the row stays hidden either way.
389
+ desc 'mark one share row seen (self only)'
390
+ params do
391
+ requires :share_id, type: Integer, desc: 'Share ID'
392
+ end
393
+ post ':share_id/ack' do
394
+ row = find_share!(params[:share_id])
395
+ # Self only. Acking is the person the row *names* saying "seen", so nobody may say it
396
+ # on their behalf — not the owner, not an administrator, and authorize_klass! is
397
+ # therefore the wrong question entirely: holding :manage on the template does not make
398
+ # somebody else's grant yours to acknowledge.
399
+ #
400
+ # 404 with find_share!'s own message rather than 403: the inbox is the only place a
401
+ # share id reaches this user, so a row that does not name them is indistinguishable
402
+ # from one that does not exist, and a 403 would confirm that somebody else's share
403
+ # sits at that id.
404
+ error!('Share is invalid. Please refresh.', 404) unless row.shared_with_id == current_user.id
405
+ # update_column, NOT update! — deliberate, and the whole reason this is not a one-liner.
406
+ # `updated_at` is the other half of the unacknowledged comparison
407
+ # (acked_at IS NULL OR acked_at < updated_at), so an ack that bumped it would land
408
+ # acked_at and updated_at on the same write and leave the row announcing itself for
409
+ # ever. update_column writes the one column and skips the timestamp entirely, which
410
+ # also keeps the row out of share_activity's change token: marking a message read is
411
+ # not a change to anybody's sharing, and it must not make every other Designer session
412
+ # re-read the grid row.
413
+ #
414
+ # `Time.now.utc`, not `Time.current`: this is a machine timestamp whose only job is to
415
+ # be compared against `updated_at`, and that is exactly the clock and zone ActiveRecord
416
+ # itself writes `updated_at` from (default_timezone :utc). It also keeps the file free
417
+ # of ActiveSupport's zone machinery, which the gem's own spec process does not boot.
418
+ row.update_column(:acked_at, Time.now.utc) # rubocop:disable Rails/SkipsModelValidations
419
+ # 200, not Grape's default 201 on a POST: nothing was created.
420
+ status 200
421
+ { mc: 'ss00' }
422
+ rescue StandardError => e
423
+ Labimotion.log_exception(e, current_user)
424
+ { mc: 'se00', msg: e.message }
425
+ end
426
+ end
427
+
428
+ # What the Designer polls to learn that its share state moved (§7), and the whole of what
429
+ # it needs: two aggregates over the share rows involving the caller. It used to ask the
430
+ # *host* instead — a poll of `/api/v1/messages/list?is_ack=0` — which could not answer
431
+ # this question: that list carries every kind of notification and the library cannot name
432
+ # the host's channels to filter it, so an unrelated spectra message refreshed the grid; it
433
+ # is a host route no embedder is obliged to mount, and the library's only reach outside
434
+ # gem-owned API surface; and reading it acknowledges message rows as a side effect. The
435
+ # question is about klass_shares, so it is answered here, from data the gem already holds.
436
+ #
437
+ # Authentication only, and deliberately no further gate. authorize_klass! cannot apply —
438
+ # there is no single klass in the question — and neither can authenticate_admin!, which
439
+ # needs a family. What makes that safe is the payload: a row count and a timestamp over
440
+ # the caller's *own* involvement, naming no template, no user and no level, and disclosing
441
+ # nothing about anybody else's sharing. A gate here would refuse the very callers the
442
+ # route exists for — a grantee holds no designer right and owns nothing to authorize on.
443
+ namespace :share_activity do
444
+ desc 'change token for the share rows involving the current user'
445
+ get do
446
+ activity = Labimotion::ShareResolver.activity_for(current_user)
447
+ # `latest` may move *backwards*: a revocation is a hard delete, so the newest
448
+ # surviving row can be older than the one that just went away. The client therefore
449
+ # compares the pair for inequality in either direction and never as "newer than" —
450
+ # a timestamp cursor would stop detecting revocations. Milliseconds, so that two
451
+ # changes within the same second are still two different tokens.
452
+ #
453
+ # `digests` is the same token taken per klass key, and it is what lets the client
454
+ # stop reloading its whole grid: it diffs the map and re-reads only the keys whose
455
+ # digest changed, appeared or vanished, through `GET /generic_klass/rows` below. Same
456
+ # inequality-in-either-direction rule, and it matters more here — a revoked template's
457
+ # key drops out of the map entirely, and a key's `count`/`latest` both move down.
458
+ # `count` and `latest` stay: they are the cheap early-out, and a client that only
459
+ # wants "did anything change" need not walk the map at all.
460
+ { mc: 'ss00', count: activity[:count], latest: activity[:latest]&.iso8601(3),
461
+ digests: share_digests(activity[:digests]) }
462
+ rescue StandardError => e
463
+ Labimotion.log_exception(e, current_user)
464
+ { mc: 'se00', msg: e.message }
465
+ end
466
+ end
467
+
468
+ # The Designer's share inbox (§7): the two queues that make the request loop's last hop
469
+ # visible to somebody. The owner gets what is waiting on their templates; the requester
470
+ # gets the grants nobody has told them about — until now the grant landed as a host
471
+ # message on a page the Designer does not watch, so the person who asked was never told.
472
+ #
473
+ # Polled on the same tick as share_activity above: one timer, two endpoints. Which rows go
474
+ # into each queue is ShareResolver's question and its spec's; this route's job is the wire
475
+ # shape, and the two bulk resolutions (klass labels, user names) that turn rows into lines
476
+ # somebody can read.
477
+ #
478
+ # Authentication only, and deliberately no further gate — the same reasoning as
479
+ # share_activity above, so nobody should "fix" it here either. authorize_klass! has no
480
+ # single klass to take and authenticate_admin! no family, and what makes it safe is that
481
+ # every row returned either names the caller or sits on a template the caller holds the
482
+ # owner row on. A gate would refuse the very callers the route exists for: a grantee holds
483
+ # no designer right and owns nothing to authorize on.
484
+ namespace :share_inbox do
485
+ desc "the current user's two share queues: requests to answer, grants to acknowledge"
486
+ get do
487
+ inbox = Labimotion::ShareResolver.inbox_for(current_user)
488
+ { mc: 'ss00' }.merge(represent_inbox(inbox))
489
+ rescue StandardError => e
490
+ Labimotion.log_exception(e, current_user)
491
+ { mc: 'se00', msg: e.message, requests: [], grants: [] }
492
+ end
493
+ end
494
+
495
+ # The other half of the poller's contract, and the reason it exists: knowing *which*
496
+ # templates moved is only useful if a client can re-read those rows alone. Every entry is
497
+ # what the Designer list endpoints return for the same record — same entity, the same
498
+ # `with_ownership`, `owners:` and `share_context:` options — so a row can be dropped
499
+ # straight into the grid and every capability field still means what it meant in the list.
500
+ #
501
+ # It lives here rather than beside `GET /generic_klass/fetch`, which it superficially
502
+ # resembles, for the reason this whole class exists: `fetch` represents with neither
503
+ # `share_context` nor `with_ownership`, so a row read through it carries no `owner`, no
504
+ # `can_*` and no `current_user_permission` — and the library reads absent capability
505
+ # fields as permissive (that is how an un-upgraded gem stays usable), so patching a grid
506
+ # row from `fetch` would quietly turn a viewer's row editable. Widening `fetch` instead
507
+ # would change a payload other callers already depend on.
508
+ #
509
+ # Authorization: deliberately none, matching the list endpoints this mirrors. Those are
510
+ # ungated by decision — every designer lists every template, and it is the *actions* that
511
+ # gate — and a row read has to answer to exactly the same callers, including the grantee
512
+ # who holds no designer right at all. What constrains the caller is inside the payload:
513
+ # the capability booleans and `current_user_permission` are resolved for *this* user, and
514
+ # every mutating route re-checks with authorize_klass! regardless. So do not "fix" the
515
+ # missing gate here: it would break the poller for the users sharing exists for, without
516
+ # protecting anything the list endpoints do not already disclose.
517
+ namespace :rows do
518
+ desc 'batch-read grid rows for the keys share_activity flagged'
519
+ params do
520
+ # `?keys[]=ElementKlass:12&keys[]=SegmentKlass:3` — Grape's array-of-type coercion
521
+ # syntax, not a redundant constructor.
522
+ # rubocop:disable Style/RedundantArrayConstructor
523
+ requires :keys, type: Array[String], desc: '"KlassType:id" keys, at most 50'
524
+ # rubocop:enable Style/RedundantArrayConstructor
525
+ end
526
+ get do
527
+ rows = batch_rows(parse_row_keys!(params[:keys]))
528
+ # `keys` runs parallel to `data`, and is not decoration: the klass entities carry no
529
+ # type field, so an id alone cannot say whether a row is ElementKlass:12 or
530
+ # DatasetKlass:12. It is also how the client learns which requested keys were skipped
531
+ # — set-difference the two — without having to guess from the array's length.
532
+ { mc: 'ss00', data: rows[:data], keys: rows[:keys] }
533
+ rescue StandardError => e
534
+ Labimotion.log_exception(e, current_user)
535
+ { mc: 'se00', msg: e.message, data: [], keys: [] }
536
+ end
537
+ end
538
+
539
+ namespace :transfer_ownership do
540
+ desc 'hand the owner row to another user'
541
+ params do
542
+ requires :klass, type: String, desc: 'Klass', values: Labimotion::Constants::Klass::ALL
543
+ requires :id, type: Integer, desc: 'Klass ID'
544
+ requires :user_id, type: Integer, desc: 'the new owner (a Person)'
545
+ end
546
+ post do
547
+ klz = fetch_klass(params[:klass], params[:id])
548
+ # :manage admits the owner and the administrator (implicit super owner) — the
549
+ # latter is how a template whose owner departed gets a new one. On an owner-less
550
+ # template :manage falls back to the legacy designer gate, which makes this route
551
+ # double as adoption: assigning a first owner creates the row.
552
+ authorize_klass!(klz, :manage)
553
+ person!(params[:user_id])
554
+ owner_row = Labimotion::KlassShare.owner_row_for(klz)
555
+ Labimotion::KlassShare.transaction do
556
+ # The new owner may already hold a viewer/editor row; the unique (klass, user)
557
+ # index would collide with the handover, so that share is absorbed into the
558
+ # ownership first.
559
+ existing = Labimotion::KlassShare.for_klass(params[:klass], klz.id).to_a.find do |row|
560
+ row.shared_with_id == params[:user_id] &&
561
+ row.level != Labimotion::KlassShare::LEVELS[:owner]
562
+ end
563
+ existing&.destroy!
564
+ if owner_row.nil?
565
+ Labimotion::KlassShare.create!(
566
+ klass_type: params[:klass], klass_id: klz.id,
567
+ shared_with_id: params[:user_id], created_by: current_user.id,
568
+ permission_level: Labimotion::KlassShare::LEVELS[:owner]
569
+ )
570
+ else
571
+ # An UPDATE, not delete-and-insert: the one-owner partial index stays satisfied
572
+ # throughout, and created_by records who performed the handover.
573
+ owner_row.update!(shared_with_id: params[:user_id], created_by: current_user.id)
574
+ end
575
+ end
576
+ # After the commit; rescues internally, never fails the transfer, and
577
+ # drops the actor — a self-transfer announces nothing (#255).
578
+ Labimotion::ShareNotifier.transfer(klass: klz, actor: current_user,
579
+ user_id: params[:user_id])
580
+ { mc: 'ss00' }.merge(represent_shares(klz))
581
+ rescue StandardError => e
582
+ Labimotion.log_exception(e, current_user)
583
+ { mc: 'se00', msg: e.message }
584
+ end
585
+ end
586
+
587
+ # The requester's own route, and the only one that writes a level-0 row. `POST /shares`
588
+ # cannot serve them: it is gated `:manage`, which only the owner and the administrator
589
+ # hold, and the entire premise of a request is that the caller holds nothing.
590
+ #
591
+ # There is no matching "grant" or "reject" route, on purpose. Granting is `POST /shares`
592
+ # unchanged — its find_or_initialize_by lands on this very row and lifts it to 10/20,
593
+ # and guard_owner_row! only protects level 30 — and rejecting is `DELETE /shares/:id`,
594
+ # a hard delete because KlassShare is deliberately not acts_as_paranoid. So "no" does
595
+ # not stick: the row's disappearance makes the requester askable again, and there is no
596
+ # "stop asking". Accepted — a tombstone would contradict the hard delete.
597
+ namespace :request_access do
598
+ desc 'ask the owner for access to one template (self, level 0, idempotent)'
599
+ params do
600
+ requires :klass, type: String, desc: 'Klass', values: Labimotion::Constants::Klass::ALL
601
+ requires :id, type: Integer, desc: 'Klass ID'
602
+ end
603
+ post do
604
+ klz = fetch_klass(params[:klass], params[:id])
605
+ # Deliberately NOT authorize_klass!(klz, :read): `:read` demands level >= 10 and a
606
+ # requester has no level at all, so the share gate would refuse every caller this
607
+ # route exists for. The legacy family designer right gates it instead — the grid
608
+ # the request is made from is behind that same right, so everyone who can see the
609
+ # template well enough to ask about it passes here, and nobody else does.
610
+ authenticate_admin!(klass_family(klz))
611
+ # Self only, level 0 only. Neither the target user nor the level is a parameter, so
612
+ # no shape of this request escalates anybody: the worst a caller can do is ask.
613
+ row = Labimotion::KlassShare.find_or_initialize_by(
614
+ klass_type: params[:klass], klass_id: klz.id, shared_with_id: current_user.id
615
+ )
616
+ created = row.new_record?
617
+ guard_access_held!(row) unless created
618
+ if created
619
+ row.created_by = current_user.id
620
+ row.permission_level = Labimotion::KlassShare::LEVELS[:requested]
621
+ row.save!
622
+ end
623
+ # `created` is the notify switch, and the server owns it outright now
624
+ # (labimotion#255): the unique index dedupes rows, not messages, so
625
+ # only the ask that wrote the row pings the owner — ten asks are one
626
+ # toast. ShareNotifier rescues internally; an undeliverable ping
627
+ # never fails the request, whose success is the row.
628
+ Labimotion::ShareNotifier.request(klass: klz, actor: current_user) if created
629
+ # 200 either way, not Grape's default 201 on the create: the ping
630
+ # decision is `created` in the body, and a client that read it off
631
+ # the status code would be reading the wrong thing. One status keeps
632
+ # that unambiguous.
633
+ status 200
634
+ { mc: 'ss00', created: created }
635
+ rescue ActiveRecord::RecordNotUnique
636
+ # Two concurrent asks collide on the unique (klass, user) index. The row exists
637
+ # either way, which is the goal state, and the insert that won already earned the
638
+ # ping — so this one answers created: false rather than 500.
639
+ status 200
640
+ { mc: 'ss00', created: false }
641
+ rescue StandardError => e
642
+ Labimotion.log_exception(e, current_user)
643
+ { mc: 'se00', msg: e.message }
644
+ end
645
+ end
646
+ end
647
+ end
648
+ end