labimotion 2.4.0.rc2 → 2.4.0.rc4

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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +9 -0
  3. data/lib/labimotion/apis/generic_dataset_api.rb +41 -3
  4. data/lib/labimotion/apis/generic_element_api.rb +60 -8
  5. data/lib/labimotion/apis/generic_klass_api.rb +50 -6
  6. data/lib/labimotion/apis/klass_share_api.rb +646 -0
  7. data/lib/labimotion/apis/labimotion_api.rb +1 -0
  8. data/lib/labimotion/apis/labimotion_doi_api.rb +24 -10
  9. data/lib/labimotion/apis/labimotion_template_browse_api.rb +13 -1
  10. data/lib/labimotion/apis/segment_api.rb +61 -10
  11. data/lib/labimotion/constants.rb +14 -0
  12. data/lib/labimotion/entities/application_entity.rb +13 -1
  13. data/lib/labimotion/entities/generic_klass_entity.rb +125 -0
  14. data/lib/labimotion/entities/klass_share_entity.rb +48 -0
  15. data/lib/labimotion/helpers/dataset_helpers.rb +123 -1
  16. data/lib/labimotion/helpers/element_helpers.rb +206 -4
  17. data/lib/labimotion/helpers/generic_helpers.rb +276 -4
  18. data/lib/labimotion/helpers/param_helpers.rb +68 -0
  19. data/lib/labimotion/helpers/segment_helpers.rb +87 -4
  20. data/lib/labimotion/libs/ai_egress_guard.rb +84 -0
  21. data/lib/labimotion/libs/ai_template.rb +1482 -0
  22. data/lib/labimotion/libs/data/datacite/labimotion_template.html.erb +67 -0
  23. data/lib/labimotion/libs/file_extractor.rb +210 -0
  24. data/lib/labimotion/libs/owner_resolver.rb +50 -0
  25. data/lib/labimotion/libs/ownership_audit.rb +73 -0
  26. data/lib/labimotion/libs/share_notifier.rb +114 -0
  27. data/lib/labimotion/libs/share_resolver.rb +369 -0
  28. data/lib/labimotion/models/concerns/template_doi.rb +133 -0
  29. data/lib/labimotion/models/dataset_klass.rb +1 -1
  30. data/lib/labimotion/models/element_klass.rb +1 -1
  31. data/lib/labimotion/models/klass_share.rb +126 -0
  32. data/lib/labimotion/models/segment_klass.rb +1 -1
  33. data/lib/labimotion/usecases/build_template_doi_xml.rb +69 -23
  34. data/lib/labimotion/usecases/release_template_doi.rb +42 -17
  35. data/lib/labimotion/usecases/template_doi_helpers.rb +28 -6
  36. data/lib/labimotion/usecases/update_template_publication_metadata.rb +71 -2
  37. data/lib/labimotion/utils/prop.rb +1 -0
  38. data/lib/labimotion/version.rb +1 -1
  39. data/lib/labimotion.rb +11 -0
  40. metadata +28 -2
@@ -0,0 +1,369 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'labimotion/constants'
4
+
5
+ module Labimotion
6
+ ## ShareResolver
7
+ # The one place share rows are turned into an answer, for both shapes of question:
8
+ # `authorize` for a single record on a mutating endpoint, `context_for` for a whole grid —
9
+ # a hundred-template list must cost a fixed number of queries, not a lookup per row
10
+ # (template-sharing.md §7).
11
+ class ShareResolver
12
+ # The authorization ladder (§6). Six names even where thresholds coincide: the call site
13
+ # documents intent, and the collapse stays visible in one constant instead of drifting
14
+ # apart across the codebase. Values are KlassShare::LEVELS (10 viewer / 20 editor /
15
+ # 30 owner) as literals — dereferencing the ActiveRecord model in this class body would
16
+ # force it to load in any process that touches the resolver, and the numbers are already
17
+ # frozen into the one-owner partial index anyway.
18
+ REQUIRED_LEVEL = {
19
+ read: 10, write: 20, release: 30, deactivate: 30, destroy: 30, manage: 30
20
+ }.freeze
21
+
22
+ # The requester's inbox predicate (§7), verbatim from the design, as one SQL fragment
23
+ # because its three conjuncts are one idea: "a grant somebody else made me, which I have
24
+ # not seen in its current state".
25
+ #
26
+ # permission_level >= 10
27
+ # A level that actually confers access. A level-0 row is the caller's own standing ask,
28
+ # and it belongs to the *owner's* queue, not to theirs.
29
+ #
30
+ # created_by <> shared_with_id
31
+ # Load-bearing, and it looks removable — it is not. Every owner row is a self-grant
32
+ # written by KlassShare.seed_owner! on the create path (and by the seed migration for
33
+ # everything older), so without this conjunct every designer would be told "you were
34
+ # granted owner on…" for every template they ever made. A real grant re-stamps
35
+ # created_by to the granter (KlassShareAPI's POST /shares), and transfer_ownership does
36
+ # the same, so both still announce themselves.
37
+ #
38
+ # acked_at IS NULL OR acked_at < updated_at
39
+ # The whole ack mechanism — see KlassShare's comment on the column. There is no reset
40
+ # logic: a later upgrade or transfer moves updated_at past acked_at and re-announces by
41
+ # itself, and a revocation is a hard delete, so the entry disappears instead.
42
+ #
43
+ # Levels as literals for the reason REQUIRED_LEVEL gives above.
44
+ UNACKNOWLEDGED_GRANTS = 'permission_level >= 10 AND created_by <> shared_with_id ' \
45
+ 'AND (acked_at IS NULL OR acked_at < updated_at)'
46
+
47
+ # A refusal carries who to ask — naming the owner is what keeps a 403 from reading as an
48
+ # authorisation failure between equally authorised peers (§3).
49
+ Refusal = Struct.new(:owner_id)
50
+
51
+ # An owner entry in the context: the share row's user id survives even when the user
52
+ # record cannot be resolved, so a departed-and-purged owner still shows as "user #id
53
+ # (unknown)" instead of losing the id the transfer flow needs.
54
+ OwnerRef = Struct.new(:id, :user)
55
+
56
+ class << self
57
+ def key_for(klass)
58
+ Labimotion::KlassShare.key_of(klass)
59
+ end
60
+
61
+ # :ok — the action may proceed.
62
+ # :legacy — no owner row exists (or the host has not migrated): fall back to the
63
+ # designer-wide authenticate_admin! gate. Permanent, not transitional — an
64
+ # un-backfilled template degrades to "as before", never to "locked out".
65
+ # Refusal — the template is owned and the user's level is insufficient.
66
+ #
67
+ # Resolution order: administrator (STI type 'Admin' — owner level on everything,
68
+ # implicit, no rows of its own) → the user's own share, a SegmentKlass taking the
69
+ # higher of its own level and its parent ElementKlass's → legacy fallback when no
70
+ # owner row exists. Errors propagate: an owned template must never fail open just
71
+ # because a query failed.
72
+ #
73
+ # A departed owner's row still counts as "has an owner" — ignoring it would drop the
74
+ # klass into the legacy gate, which is exactly the fail-open the design forbids; the
75
+ # row waits for the super owner's transfer instead. The requesting user's own rows
76
+ # need no departed filter: a deleted or deactivated user cannot be logged in.
77
+ def authorize(klass, user, action)
78
+ required = REQUIRED_LEVEL.fetch(action)
79
+ return :legacy unless shares_enabled?
80
+ return :ok if admin?(user)
81
+
82
+ level = level_for(klass, user)
83
+ return :ok if level && level >= required
84
+
85
+ owner_row = Labimotion::KlassShare.owner_row_for(klass)
86
+ return :legacy if owner_row.nil?
87
+
88
+ Refusal.new(owner_row.shared_with_id)
89
+ end
90
+
91
+ # Per-request preload for the grids: nil when the host has not migrated (entities then
92
+ # keep their pre-share behaviour), else the owner and the requesting user's level per
93
+ # klass key, in two share queries plus one user query however long the list is.
94
+ # Departed users stay in the owners map on purpose — the grid badge and the
95
+ # awaiting-transfer list are how anyone finds out.
96
+ def context_for(klasses, user)
97
+ return nil unless shares_enabled?
98
+
99
+ rows = Array(klasses).compact
100
+ shares = shares_for(rows)
101
+
102
+ {
103
+ admin: admin?(user),
104
+ has_owner: owned_keys(shares),
105
+ owners: owners_map(shares),
106
+ levels: levels_map(rows, shares, user),
107
+ # The requesting user's legacy designer rights per klass type: what an owner-less
108
+ # template falls back to. The ungated list endpoints serve non-designers too, so
109
+ # capability booleans must not assume everyone reading the grid holds them.
110
+ legacy: legacy_rights(user),
111
+ pending_requests: pending_requests_map(shares, user)
112
+ }
113
+ rescue StandardError => e
114
+ # The grid must render even if share resolution breaks; entities fall back to the
115
+ # pre-share display. Display only — `authorize` above deliberately has no such net.
116
+ Labimotion.log_exception(e)
117
+ nil
118
+ end
119
+
120
+ # The Designer's share poller (§7), over the share rows the requesting user is involved
121
+ # in: the rows anybody holds on the templates they own — every collaborator's, every
122
+ # requester's — plus the rows granting them access on other people's templates.
123
+ #
124
+ # Three values, two questions. `count` and `latest` answer "did anything about my sharing
125
+ # move at all", the cheap early-out a client can use alone. `digests` answers "which
126
+ # templates", one entry per involved klass key, each the same pair taken over that klass's
127
+ # rows alone — so the client re-reads the rows whose digest changed, appeared or vanished
128
+ # instead of reloading the whole grid. That whole-grid reload is what destroyed a
129
+ # Designer's unsaved edits, since re-seeding the list re-seeds the open Work Area.
130
+ #
131
+ # Opaque change tokens, not clocks. The client compares them for inequality in *either*
132
+ # direction, and none of this must ever grow "newer than" semantics: a revocation is a
133
+ # hard delete (KlassShare is deliberately not acts_as_paranoid), so a key's `latest` moves
134
+ # backwards while its `count` drops, or the key disappears from the map outright —
135
+ # precisely the events a timestamp cursor would silently stop detecting.
136
+ #
137
+ # Aggregates, never the rows: this is polled about once a minute per open Designer page,
138
+ # and the owner of a busy template is involved in every row on it. The per-key map is a
139
+ # GROUP BY over the same scope, so it costs the same two round trips the bare pair did —
140
+ # and the pair is then derived from the groups rather than queried again, which also makes
141
+ # `count` the sum of the digests' counts by construction.
142
+ #
143
+ # The administrator is an implicit owner everywhere but holds rows only where they really
144
+ # own something, so their token moves for their own templates alone. Same stance as
145
+ # pending_requests_map: it is the owner's signal, not a global one.
146
+ def activity_for(user)
147
+ return { count: 0, latest: nil, digests: {} } unless shares_enabled?
148
+
149
+ scope = involving(user).group(:klass_type, :klass_id)
150
+ # Two round trips over one scope rather than a hand-written SELECT COUNT(*), MAX(...):
151
+ # a write landing between them yields either a token that differs from the client's (a
152
+ # spurious refresh) or one that matches it and is superseded by the next poll a minute
153
+ # later. Neither loses an event, and the query stays ActiveRecord's.
154
+ counts = scope.count
155
+ latests = scope.maximum(:updated_at)
156
+ { count: counts.values.sum,
157
+ latest: latests.values.compact.max,
158
+ digests: digests_map(counts, latests) }
159
+ end
160
+
161
+ # The Designer's share inbox (§7), and the last hop of the request loop finally landing
162
+ # where somebody is looking: the owner's queue of standing requests, and the requester's
163
+ # queue of grants they have not marked seen.
164
+ #
165
+ # Both are queries over klass_shares. There is no event table and no message log by
166
+ # decision, so nothing can hold ack state that disagrees with the rows themselves —
167
+ # a second store would immediately have its own retention question and its own idea of
168
+ # what is outstanding.
169
+ #
170
+ # `requests` — the owner's queue: the level-0 rows on the templates the caller holds the
171
+ # owner row on. No column gates it and none should. A grant promotes the row off level 0
172
+ # and a reject hard-deletes it, so the queue empties itself; that *is* the ack, and there
173
+ # is deliberately no dismiss-without-acting for a column to record. It is also the same
174
+ # predicate pending_requests_map counts for the grid's ShareBtn badge, over the same
175
+ # rows — so the badge and the queue cannot disagree about what is waiting.
176
+ #
177
+ # `grants` — the requester's queue: see UNACKNOWLEDGED_GRANTS.
178
+ #
179
+ # Rows rather than aggregates, unlike activity_for on the same poll tick, and bounded by
180
+ # what a human can act on rather than by the size of the account: a level-0 row lasts only
181
+ # until somebody answers it, and a grant row only until its recipient marks it seen.
182
+ #
183
+ # At most three queries however large the account — the owned keys, then one per queue,
184
+ # with the requests query skipped outright for a caller who owns nothing. The caller
185
+ # resolves klass labels and user names on top of that, in bulk.
186
+ def inbox_for(user)
187
+ return { requests: [], grants: [] } unless shares_enabled?
188
+
189
+ { requests: pending_requests_for(user), grants: unacknowledged_grants_for(user) }
190
+ end
191
+
192
+ def admin?(user)
193
+ user.respond_to?(:type) && user.type == 'Admin'
194
+ end
195
+
196
+ private
197
+
198
+ # The owned keys grouped by klass_type into one exact `klass_type = ? AND klass_id IN (…)`
199
+ # branch each, exactly as `involving` does it — a user owns at most the three types, so the
200
+ # OR stays short and every branch stays indexed. The level filter lands outside the OR:
201
+ # `(A OR B) AND permission_level = 0`.
202
+ def pending_requests_for(user)
203
+ scope = owned_scope(owned_keys_for(user))
204
+ return [] if scope.nil?
205
+
206
+ scope.where(permission_level: Labimotion::KlassShare::LEVELS[:requested]).to_a
207
+ end
208
+
209
+ def owned_scope(keys)
210
+ keys.group_by(&:first).reduce(nil) do |scope, (type, group)|
211
+ branch = Labimotion::KlassShare.where(klass_type: type,
212
+ klass_id: group.map(&:last).uniq)
213
+ scope.nil? ? branch : scope.or(branch)
214
+ end
215
+ end
216
+
217
+ # One query, on the shared_with_id index.
218
+ def unacknowledged_grants_for(user)
219
+ Labimotion::KlassShare.where(shared_with_id: user.id)
220
+ .where(UNACKNOWLEDGED_GRANTS)
221
+ .to_a
222
+ end
223
+
224
+ # The grouped aggregates, re-keyed into the `"KlassType:id"` strings KlassShare.key_of
225
+ # produces — the same format the batch row read takes as its `keys[]`, so an entry the
226
+ # client saw move is directly the argument that re-reads it.
227
+ #
228
+ # The values stay a Ruby Integer and Time: turning the pair into its wire string belongs
229
+ # next to where `latest` is formatted, in the API, so the millisecond precision the whole
230
+ # token depends on is decided in exactly one place.
231
+ def digests_map(counts, latests)
232
+ counts.each_with_object({}) do |(pair, count), map|
233
+ map["#{pair.first}:#{pair.last}"] = { count: count, latest: latests[pair] }
234
+ end
235
+ end
236
+
237
+ # Every share row the user is involved in, as one scope: the rows naming them ORed with
238
+ # the rows on the templates they own. The OR is also what de-duplicates the overlap —
239
+ # their own owner row belongs to both halves — so nothing has to be counted twice and
240
+ # subtracted back.
241
+ #
242
+ # Same tuple-IN problem as rows_matching, solved the other way round. That one queries the
243
+ # cross-product of types and ids and narrows back to the exact keys *in Ruby*, which needs
244
+ # the rows in hand; an aggregate has none. Grouping the owned keys by klass_type instead
245
+ # makes every branch exact in SQL — `klass_type = ? AND klass_id IN (…)` — and a user owns
246
+ # at most the three types, so the OR stays short. Both halves are indexed: shared_with_id
247
+ # has an index of its own, and (klass_type, klass_id) is the leading pair of the unique
248
+ # klass-and-user index.
249
+ def involving(user)
250
+ mine = Labimotion::KlassShare.where(shared_with_id: user.id)
251
+ owned_keys_for(user).group_by(&:first).reduce(mine) do |scope, (type, keys)|
252
+ scope.or(Labimotion::KlassShare.where(klass_type: type,
253
+ klass_id: keys.map(&:last).uniq))
254
+ end
255
+ end
256
+
257
+ # The (klass_type, klass_id) of every template the user holds the owner row on — the one
258
+ # thing here that is read rather than counted, and unavoidably so: these keys *are* the
259
+ # predicate the aggregate is taken over. Two columns, no model instances, one lookup on
260
+ # the shared_with_id index.
261
+ def owned_keys_for(user)
262
+ Labimotion::KlassShare
263
+ .where(shared_with_id: user.id,
264
+ permission_level: Labimotion::KlassShare::LEVELS[:owner])
265
+ .pluck(:klass_type, :klass_id)
266
+ end
267
+
268
+ def shares_enabled?
269
+ Labimotion::KlassShare.table_exists?
270
+ rescue StandardError
271
+ false
272
+ end
273
+
274
+ # A SegmentKlass inherits from its parent ElementKlass (§4): you cannot own an element
275
+ # template yet be locked out of its own segments. Datasets have no parent.
276
+ def parent_key(klass)
277
+ return nil unless klass.respond_to?(:element_klass_id) && klass.element_klass_id.present?
278
+
279
+ "ElementKlass:#{klass.element_klass_id}"
280
+ end
281
+
282
+ def key_of_row(row)
283
+ "#{row.klass_type}:#{row.klass_id}"
284
+ end
285
+
286
+ def level_for(klass, user)
287
+ keys = [key_for(klass), parent_key(klass)].compact
288
+ rows_matching(keys, Labimotion::KlassShare.where(shared_with_id: user.id))
289
+ .map(&:level).max
290
+ end
291
+
292
+ # One query over the cross-product of types and ids, narrowed back to the exact keys in
293
+ # Ruby — an IN-list per column instead of a tuple IN, which ActiveRecord 6.1 cannot
294
+ # express portably.
295
+ def shares_for(klasses)
296
+ keys = (klasses.map { |klz| key_for(klz) } +
297
+ klasses.filter_map { |klz| parent_key(klz) }).uniq
298
+ rows_matching(keys, Labimotion::KlassShare.all)
299
+ end
300
+
301
+ def rows_matching(keys, scope)
302
+ pairs = keys.uniq.map { |key| key.split(':') }
303
+ return [] if pairs.empty?
304
+
305
+ scope.where(klass_type: pairs.map(&:first).uniq,
306
+ klass_id: pairs.map { |pair| pair.last.to_i }.uniq)
307
+ .to_a
308
+ .select { |row| keys.include?(key_of_row(row)) }
309
+ end
310
+
311
+ def owner_rows(shares)
312
+ shares.select { |row| row.level == Labimotion::KlassShare::LEVELS[:owner] }
313
+ end
314
+
315
+ def owned_keys(shares)
316
+ owner_rows(shares).map { |row| key_of_row(row) }.uniq
317
+ end
318
+
319
+ def owners_map(shares)
320
+ rows = owner_rows(shares)
321
+ users = Labimotion::OwnerResolver.map_for_ids(rows.map(&:shared_with_id))
322
+ rows.to_h do |row|
323
+ [key_of_row(row), OwnerRef.new(row.shared_with_id, users[row.shared_with_id])]
324
+ end
325
+ end
326
+
327
+ # Which legacy designer right in `users.generic_admin` answers for an owner-less klass
328
+ # of each type.
329
+ def legacy_rights(user)
330
+ grants = user.respond_to?(:generic_admin) && user.generic_admin.is_a?(Hash) ? user.generic_admin : {}
331
+ Labimotion::Constants::Family::FAMILY_OF.transform_values { |right| grants[right] == true }
332
+ end
333
+
334
+ # How many people are waiting on the requesting user, per template they own — what
335
+ # colours the Share button in the grid. A derivation over rows already in hand, not a
336
+ # query of its own: `shares` is every share row for the listed klasses, level-0 rows
337
+ # included, so the count is a select and a tally.
338
+ #
339
+ # Scoped to the user's own owner rows, which is the whole design of it. A count over
340
+ # every listed template would ship "somebody asked" to every viewer and editor in the
341
+ # payload — a signal only the person who can grant it can act on. Sparse on purpose:
342
+ # keys with nothing pending are absent and the entity reads a missing key as 0, so a
343
+ # grid of a hundred templates carries entries only for the ones actually waiting.
344
+ #
345
+ # The administrator is an implicit owner everywhere but holds no rows, so they see
346
+ # counts on the templates they really own and none on other people's. Correct: it is
347
+ # the owner's queue, not a global inbox.
348
+ def pending_requests_map(shares, user)
349
+ owned = owner_rows(shares).select { |row| row.shared_with_id == user.id }
350
+ .map { |row| key_of_row(row) }
351
+ return {} if owned.empty?
352
+
353
+ shares.select { |row| row.level == Labimotion::KlassShare::LEVELS[:requested] }
354
+ .map { |row| key_of_row(row) }
355
+ .select { |key| owned.include?(key) }
356
+ .tally
357
+ end
358
+
359
+ def levels_map(klasses, shares, user)
360
+ by_key = shares.select { |row| row.shared_with_id == user.id }
361
+ .group_by { |row| key_of_row(row) }
362
+ klasses.to_h do |klz|
363
+ levels = (by_key[key_for(klz)] || []) + (by_key[parent_key(klz)] || [])
364
+ [key_for(klz), levels.map(&:level).max]
365
+ end
366
+ end
367
+ end
368
+ end
369
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ # TemplateDoi concern
4
+ #
5
+ # LabIMotion template DOI logic for the host's Doi model. The host owns the DOI
6
+ # record and the generic DataCite plumbing (build_suffix/align_suffix, the MDS
7
+ # client, the DataCitePublisher concern); this adds only the LabIMotion-specific
8
+ # class methods that the gem's template DOI usecases and APIs call on ::Doi.
9
+ #
10
+ # The host wires it up with a single line:
11
+ # class Doi < ApplicationRecord
12
+ # include Labimotion::TemplateDoi
13
+ # end
14
+ #
15
+ # Assumes the host `dois` table has a jsonb `metadata` column (the DOI version
16
+ # is stamped there) and that the deterministic suffix survives Doi#align_suffix
17
+ # unchanged (reserve sets inchikey == suffix and version_count: 0 for that).
18
+ module Labimotion
19
+ module TemplateDoi
20
+ extend ActiveSupport::Concern
21
+
22
+ # Literal namespace shared by every LabIMotion template DOI suffix.
23
+ LABIMOTION_DOI_NAMESPACE = 'labimotion'
24
+
25
+ # ActiveSupport::Concern extends this into the including class, so every
26
+ # method below becomes a class method on the host's Doi.
27
+ module ClassMethods
28
+ # Deterministic DOI suffix for a LabIMotion template klass. The same suffix
29
+ # is used at reserve and at release time, so both share one DOI:
30
+ # element -> labimotion/element/<name>/<identifier>/<doi_version>
31
+ # segment -> labimotion/segment/<element>/<identifier>/<doi_version>
32
+ # dataset -> labimotion/dataset/<ols_term_id>/<identifier>/<doi_version>
33
+ # `<identifier>` is the first UUID group (8 hex chars) of the template's
34
+ # identifier/uuid, falling back to the record id. `<doi_version>` is the DOI
35
+ # version (see .labimotion_version_segment). Callers may pass an explicit
36
+ # template version; otherwise the record's current version is used.
37
+ def build_labimotion_suffix(record, version = nil)
38
+ parts = labimotion_suffix_parts(record)
39
+ raise ArgumentError, "unsupported template: #{record.class}" if parts.nil?
40
+
41
+ segment = labimotion_version_segment(record, version)
42
+ return nil if segment.blank?
43
+
44
+ segments = parts.map { |part| sanitize_doi_part(part) }.reject(&:empty?)
45
+ "#{segments.join('/')}/#{segment}"
46
+ end
47
+
48
+ # The DOI version segment for a template version. A DOI is published per major
49
+ # release: the .0 release opens that version (X.0 -> "X") and later minor
50
+ # revisions roll into the NEXT DOI (X.y, y>=1 -> "X+1"). Sub-1.0 and
51
+ # unversioned/blank templates map to the first DOI (v1).
52
+ # 0.x -> "1" 1.0 -> "1" 1.1..2.0 -> "2" 2.1..3.0 -> "3"
53
+ def labimotion_version_segment(record, version = nil)
54
+ raw = (version.presence || record.try(:version)).to_s.strip
55
+ parts = raw.split('.')
56
+ major = parts[0].to_i
57
+ minor = parts[1].to_i
58
+ return '1' if major.zero?
59
+ return major.to_s if minor.zero?
60
+
61
+ (major + 1).to_s
62
+ end
63
+
64
+ # The DOIs reserved for a LabIMotion template, in reservation order.
65
+ def labimotion_dois(record)
66
+ where(doiable_id: record.id, doiable_type: record.class.name).order(:id).to_a
67
+ end
68
+
69
+ # The current (most recently reserved) DOI for a template, or nil.
70
+ def labimotion_latest_doi(record)
71
+ labimotion_dois(record).last
72
+ end
73
+
74
+ # The DOI version (next-major integer) recorded on a reserved template DOI.
75
+ def labimotion_doi_version(doi)
76
+ doi.metadata.to_h.dig('labimotion', 'doi_version')
77
+ end
78
+
79
+ # Metadata stamped on a reserved template DOI so the version it represents
80
+ # survives on the record (used to gate reserving the next version).
81
+ def labimotion_doi_metadata(record, version = nil)
82
+ { 'labimotion' => { 'doi_version' => labimotion_version_segment(record, version) } }
83
+ end
84
+
85
+ # The publication metadata a released template DOI was published with.
86
+ # Nil while the DOI is still reserved, and for DOIs released before
87
+ # snapshots were kept — callers fall back to the template's current
88
+ # publication in both cases.
89
+ def labimotion_doi_publication(doi)
90
+ doi.metadata.to_h.dig('labimotion', 'publication')
91
+ end
92
+
93
+ # The DOI's metadata with the publication snapshotted into it, keeping
94
+ # the DOI version already stamped there. Taken at release: the template's
95
+ # publication keeps being edited for the next version, and this version
96
+ # must stop tracking those edits.
97
+ def labimotion_metadata_with_publication(doi, publication)
98
+ metadata = doi.metadata.to_h
99
+ # Deep-duped: the snapshot must not alias the template's publication,
100
+ # which goes on being edited for the next version.
101
+ labimotion = (metadata['labimotion'] || {}).merge('publication' => publication.deep_dup)
102
+ metadata.merge('labimotion' => labimotion)
103
+ end
104
+
105
+ def labimotion_suffix_parts(record)
106
+ # identifier/uuid may both be blank for a draft, which would collapse the
107
+ # scope (colliding across templates). Fall back to the stable record id so
108
+ # every template gets a unique, deterministic suffix. Resolved lazily so an
109
+ # unsupported record (no #id) still falls through to the ArgumentError in the
110
+ # caller. The identifier is shortened to its first UUID group (8 hex chars).
111
+ # A segment's <element> is the name of the ElementKlass it belongs to.
112
+ identifier = lambda do
113
+ raw = record.try(:identifier).presence || record.try(:uuid).presence || record.id
114
+ raw.to_s.split('-').first
115
+ end
116
+ case record
117
+ when ::Labimotion::ElementKlass
118
+ [LABIMOTION_DOI_NAMESPACE, 'element', record.name, identifier.call]
119
+ when ::Labimotion::SegmentKlass
120
+ [LABIMOTION_DOI_NAMESPACE, 'segment', record.element_klass&.name, identifier.call]
121
+ when ::Labimotion::DatasetKlass
122
+ [LABIMOTION_DOI_NAMESPACE, 'dataset', record.ols_term_id, identifier.call]
123
+ end
124
+ end
125
+
126
+ # Keeps a suffix segment DOI-safe: trims, collapses whitespace to hyphens and
127
+ # drops anything outside [A-Za-z0-9._-] (colons stripped, as elsewhere here).
128
+ def sanitize_doi_part(value)
129
+ value.to_s.strip.gsub(/\s+/, '-').gsub(/[^A-Za-z0-9._-]/, '')
130
+ end
131
+ end
132
+ end
133
+ end
@@ -18,7 +18,7 @@ module Labimotion
18
18
  # Scope for displayed_in_list - select only necessary columns for list view
19
19
  scope :for_list_display, lambda {
20
20
  select(:id, :uuid, :label, :desc, :is_active, :version, :place, :released_at,
21
- :identifier, :sync_time, :created_at, :updated_at, :ols_term_id)
21
+ :identifier, :sync_time, :created_at, :updated_at, :ols_term_id, :created_by)
22
22
  }
23
23
 
24
24
  def self.init_seeds
@@ -24,7 +24,7 @@ module Labimotion
24
24
  scope :for_list_display, lambda {
25
25
  select(:id, :uuid, :label, :desc, :is_active, :version, :place, :released_at,
26
26
  :identifier, :sync_time, :created_at, :updated_at, :name, :icon_name,
27
- :klass_prefix, :is_generic)
27
+ :klass_prefix, :is_generic, :created_by)
28
28
  }
29
29
 
30
30
  def self.gen_klasses_json
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Labimotion
4
+ # One user's right on one template (template-sharing.md §5). The owner row
5
+ # (permission_level 30) is the transferable right; the klass's `created_by` column stays
6
+ # immutable provenance. Deliberately NOT acts_as_paranoid, and the klass models carry no
7
+ # `dependent:` towards it: deleting a template must leave its shares alone, so a restored
8
+ # template comes back owned. Wiping the rows on delete would make restore itself a
9
+ # privilege escalation — the template would return owner-less and fall open to the legacy
10
+ # designer-wide gate.
11
+ class KlassShare < ApplicationRecord
12
+ self.table_name = :klass_shares
13
+
14
+ # `acked_at` — when the user this row *names* last acknowledged the row's current state.
15
+ # Written only by that user (POST /generic_klass/shares/:share_id/ack), never by the owner
16
+ # and never by the system: it says "seen", not "happened".
17
+ #
18
+ # Unacknowledged is `acked_at IS NULL OR acked_at < updated_at`, and that comparison is the
19
+ # whole mechanism (ShareResolver::UNACKNOWLEDGED_GRANTS). Nothing resets the column: any
20
+ # later change to the row — an upgrade to editor, an ownership transfer — moves `updated_at`
21
+ # past `acked_at` and re-announces itself, and a revocation is a hard delete, so the entry
22
+ # disappears rather than needing to be un-acked. Which is also why the *owner's* queue needs
23
+ # no column at all: a grant promotes the level-0 row off level 0 and a reject deletes it
24
+ # outright, so that queue empties itself.
25
+ #
26
+ # The corollary, and the one way to break it: the ack write must not touch `updated_at`, or
27
+ # the row re-announces itself for ever. The ack route writes the column directly for that
28
+ # reason — never through save/update.
29
+ #
30
+ # Deliberately independent of ELN's own `NoticeButton` read marker: that acknowledges a host
31
+ # message in the main ELN, this acknowledges a share row in the Designer, and the two answer
32
+ # different questions in different places.
33
+
34
+ # Strictly nested ladder (requested ⊂ viewer ⊂ editor ⊂ owner), which is what makes `>=`
35
+ # threshold checks valid. Spaced by ten so a future level is additive; never renumber —
36
+ # the one-owner-per-klass partial index freezes `permission_level = 30` into the schema.
37
+ #
38
+ # `requested` is the odd one: it grants nothing. It is how an access request is stored —
39
+ # a row rather than a message, because the last hop of the request loop lands on the
40
+ # Designer, which polls no message list, so a message nobody sees is no notification at
41
+ # all. As a row it survives acknowledgement, granting and rejecting become ordinary
42
+ # state changes on it, and the unique (klass_type, klass_id, shared_with_id) index
43
+ # dedupes repeat asks for free. `0 >= 10` is false, so every threshold check refuses it
44
+ # without a special case.
45
+ LEVELS = { requested: 0, viewer: 10, editor: 20, owner: 30 }.freeze
46
+
47
+ # Which account types may hold a share row. `Admin` is in the list because both seeding
48
+ # paths have to reach the same verdict: the seed migration is raw SQL over `created_by`
49
+ # and does write an owner row naming an administrator, while this validation used to
50
+ # refuse the runtime self-grant for the same actor and leave the template owner-less —
51
+ # the worse of the two, since owner-less falls back to the permanent legacy gate where
52
+ # any designer of the family may edit and delete it. Not hypothetical: the
53
+ # `backfill_klass_created_columns` migration fills a NULL `created_by` with the first
54
+ # system administrator, so a real population of templates is admin-created.
55
+ # `Group` and `DeviceDeprecated` stay out: a group is not an individual, and ELN reserves
56
+ # `Group` for device management.
57
+ SHAREABLE_USER_TYPES = %w[Person Admin].freeze
58
+
59
+ # Hash form, NOT the Rails 7 positional form: the gemspec admits ActiveRecord 6.1, where
60
+ # the positional form raises ArgumentError in the class body and the host fails to boot.
61
+ enum permission_level: LEVELS # rubocop:disable Rails/EnumSyntax
62
+
63
+ validates :klass_type, inclusion: { in: Labimotion::Constants::Klass::ALL }
64
+ validate :shared_with_individual
65
+
66
+ # The integer, whatever the reader: the AR enum getter answers the label string, a bare
67
+ # test double answers the raw integer.
68
+ def level
69
+ raw = permission_level
70
+ raw.is_a?(Integer) ? raw : LEVELS.fetch(raw.to_sym)
71
+ end
72
+
73
+ scope :for_klass, lambda { |klass_type, klass_id|
74
+ where(klass_type: klass_type, klass_id: klass_id)
75
+ }
76
+
77
+ def self.key_of(klass)
78
+ "#{klass.class.name.split('::').last}:#{klass.id}"
79
+ end
80
+
81
+ def self.owner_row_for(klass)
82
+ for_klass(klass.class.name.split('::').last, klass.id)
83
+ .find_by(permission_level: LEVELS[:owner])
84
+ end
85
+
86
+ # Writes the owner self-grant a fresh klass gets on every create path — bookkeeping, not
87
+ # enforcement, so it is unconditional but must never break the create it rides on:
88
+ # a host that has not migrated yet simply skips it (log only), and a concurrent duplicate
89
+ # means the owner row already exists, which is the goal state.
90
+ def self.seed_owner!(klass, user_id)
91
+ return unless table_exists?
92
+ return if owner_row_for(klass).present?
93
+
94
+ create!(klass_type: klass.class.name.split('::').last, klass_id: klass.id,
95
+ shared_with_id: user_id, created_by: user_id,
96
+ permission_level: LEVELS[:owner])
97
+ rescue ActiveRecord::RecordNotUnique
98
+ nil
99
+ rescue ActiveRecord::RecordInvalid => e
100
+ # An Admin creator no longer lands here — SHAREABLE_USER_TYPES admits it, which is the
101
+ # point of the allowlist. What still can: a `created_by` naming an excluded type or an
102
+ # account that no longer exists at all. The klass then stays owner-less on the legacy
103
+ # gate, logged, rather than taking the create down with it.
104
+ Labimotion.log_exception(e)
105
+ nil
106
+ end
107
+
108
+ private
109
+
110
+ # A share targets an individual account (SHAREABLE_USER_TYPES), never a collective one.
111
+ #
112
+ # `User.persons` survives only as the probe that this host's User *is* the ELN's STI
113
+ # model: it is host-owned, and its absence (an older host, a bare test double) must not
114
+ # turn into "reject every write". It can no longer be the query — `persons` is
115
+ # `where(type: 'Person')`, which is exactly the row the allowlist now has to let through.
116
+ # So: `respond_to?` asks whether the type column is there to be trusted, `exists?` asks
117
+ # the allowlist question directly, the same shape ELN's own `request_access` uses.
118
+ def shared_with_individual
119
+ return if shared_with_id.blank?
120
+ return unless ::User.respond_to?(:persons)
121
+ return if ::User.exists?(type: SHAREABLE_USER_TYPES, id: shared_with_id)
122
+
123
+ errors.add(:shared_with_id, 'must be an individual user account')
124
+ end
125
+ end
126
+ end
@@ -22,7 +22,7 @@ module Labimotion
22
22
  # Scope for displayed_in_list - select only necessary columns for list view
23
23
  scope :for_list_display, lambda {
24
24
  select(:id, :uuid, :label, :desc, :is_active, :version, :place, :released_at,
25
- :identifier, :sync_time, :created_at, :updated_at, :element_klass_id)
25
+ :identifier, :sync_time, :created_at, :updated_at, :element_klass_id, :created_by)
26
26
  }
27
27
 
28
28
  def self.gen_klasses_json