Class: UserAction

Inherits:
ActiveRecord::Base
  • Object
show all
Defined in:
app/models/user_action.rb

Constant Summary collapse

LIKE =
1
WAS_LIKED =
2
NEW_TOPIC =
4
REPLY =
5
RESPONSE =
6
MENTION =
7
QUOTE =
9
EDIT =
11
NEW_PRIVATE_MESSAGE =
12
GOT_PRIVATE_MESSAGE =
13
SOLVED =
15
ASSIGNED =
16
ORDER =
Hash[
*[
  GOT_PRIVATE_MESSAGE,
  NEW_PRIVATE_MESSAGE,
  NEW_TOPIC,
  REPLY,
  RESPONSE,
  LIKE,
  WAS_LIKED,
  MENTION,
  QUOTE,
  EDIT,
  SOLVED,
  ASSIGNED,
]
USER_ACTED_TYPES =
[LIKE, NEW_TOPIC, REPLY, NEW_PRIVATE_MESSAGE]
NULL_QUEUED_STREAM_COLS =
%i[
  cooked
  uploaded_avatar_id
  acting_name
  acting_username
  acting_user_id
  target_name
  target_username
  target_user_id
  post_number
  post_id
  deleted
  hidden
  post_type
  action_type
  action_code
  action_code_who
  action_code_path
  topic_closed
  topic_id
  topic_archived
].map! { |s| "NULL as #{s}" }.join(", ")

Class Method Summary collapse

Class Method Details

.apply_common_filters(builder, user_id, guardian, ignore_private_messages = false) ⇒ Object



392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
# File 'app/models/user_action.rb', line 392

def self.apply_common_filters(builder, user_id, guardian, ignore_private_messages = false)
  # We never return deleted topics in activity
  builder.where("t.deleted_at is null")

  # We will return deleted posts though if the user can see it
  unless guardian.can_see_deleted_posts?
    builder.where("p.deleted_at is null and p2.deleted_at is null")

    current_user_id = -2
    current_user_id = guardian.user.id if guardian.user
    builder.where(
      "NOT COALESCE(p.hidden, false) OR p.user_id = :current_user_id",
      current_user_id: current_user_id,
    )
  end

  visible_post_types = Topic.visible_post_types(guardian.user)
  builder.where(
    "COALESCE(p.post_type, p2.post_type) IN (:visible_post_types)",
    visible_post_types: visible_post_types,
  )

  builder.where("t.visible") if guardian.user&.id != user_id && !guardian.is_staff?

  filter_private_messages(builder, user_id, guardian, ignore_private_messages)
  filter_categories(builder, guardian)
end

.count_daily_engaged_users(start_date = nil, end_date = nil) ⇒ Object



137
138
139
140
141
142
143
144
145
146
147
# File 'app/models/user_action.rb', line 137

def self.count_daily_engaged_users(start_date = nil, end_date = nil)
  result = select(:user_id).distinct.where(action_type: USER_ACTED_TYPES)

  if start_date && end_date
    result = result.group("date(created_at)")
    result = result.where("created_at > ? AND created_at < ?", start_date, end_date)
    result = result.order("date(created_at)")
  end

  result.count
end

.ensure_consistency!(limit = nil) ⇒ Object



380
381
382
# File 'app/models/user_action.rb', line 380

def self.ensure_consistency!(limit = nil)
  self.synchronize_target_topic_ids(nil, limit: limit)
end

.filter_categories(builder, guardian) ⇒ Object



447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
# File 'app/models/user_action.rb', line 447

def self.filter_categories(builder, guardian)
  unless guardian.is_admin?
    allowed = guardian.secure_category_ids
    if allowed.present?
      builder.where(
        "( c.read_restricted IS NULL OR
                       NOT c.read_restricted OR
                      (c.read_restricted and c.id in (:cats)) )",
        cats: guardian.secure_category_ids,
      )
    else
      builder.where("(c.read_restricted IS NULL OR NOT c.read_restricted)")
    end
  end
  builder
end

.filter_private_messages(builder, user_id, guardian, ignore_private_messages = false) ⇒ Object



420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
# File 'app/models/user_action.rb', line 420

def self.filter_private_messages(builder, user_id, guardian, ignore_private_messages = false)
  if !guardian.can_see_private_messages?(user_id) || ignore_private_messages || !guardian.user
    builder.where("t.archetype <> :private_message", private_message: Archetype.private_message)
  else
    unless guardian.is_admin?
      sql = <<~SQL
      t.archetype <> :private_message OR
      EXISTS (
        SELECT 1 FROM topic_allowed_users tu WHERE tu.topic_id = t.id AND tu.user_id = :current_user_id
      ) OR
      EXISTS (
        SELECT 1 FROM topic_allowed_groups tg WHERE tg.topic_id = t.id AND tg.group_id IN (
          SELECT group_id FROM group_users gu WHERE gu.user_id = :current_user_id
        )
      )
      SQL

      builder.where(
        sql,
        private_message: Archetype.private_message,
        current_user_id: guardian.user.id,
      )
    end
  end
  builder
end

.last_action_in_topic(user_id, topic_id) ⇒ Object



68
69
70
71
72
73
# File 'app/models/user_action.rb', line 68

def self.last_action_in_topic(user_id, topic_id)
  UserAction
    .where(user_id: user_id, target_topic_id: topic_id, action_type: [RESPONSE, MENTION, QUOTE])
    .order("created_at DESC")
    .pick(:target_post_id)
end

.log_action!(hash) ⇒ Object



263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
# File 'app/models/user_action.rb', line 263

def self.log_action!(hash)
  required_parameters = %i[action_type user_id acting_user_id]

  required_parameters << :target_post_id
  required_parameters << :target_topic_id

  require_parameters(hash, *required_parameters)

  transaction(requires_new: true) do
    begin
      # TODO there are conditions when this is called and user_id was already rolled back and is invalid.

      # protect against dupes, for some reason this is failing in some cases
      action = self.find_by(hash.select { |k, _| required_parameters.include?(k) })
      return action if action

      action = self.new(hash)

      action.created_at = hash[:created_at] if hash[:created_at]
      action.save!

      user_id = hash[:user_id]

      topic = Topic.includes(:category).find_by(id: hash[:target_topic_id])

      update_like_count(user_id, hash[:action_type], 1) if topic && !topic.private_message?

      group_ids = nil
      if topic && topic.category && topic.category.read_restricted
        group_ids = [Group::AUTO_GROUPS[:admins]]
        group_ids.concat(topic.category.groups.pluck("groups.id"))
      end

      if action.user
        MessageBus.publish(
          "/u/#{action.user.username_lower}",
          action.id,
          user_ids: [user_id],
          group_ids: group_ids,
        )
      end

      action
    rescue ActiveRecord::RecordNotUnique
      # can happen, don't care already logged
      raise ActiveRecord::Rollback
    end
  end
end

.private_messages_stats(user_id, guardian) ⇒ Object



98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
# File 'app/models/user_action.rb', line 98

def self.private_messages_stats(user_id, guardian)
  return unless guardian.can_see_private_messages?(user_id)

  # list the stats for: all/mine/unread/groups (topic-based)

  sql = <<-SQL
    SELECT COUNT(*) "all"
         , SUM(CASE WHEN t.user_id = :user_id THEN 1 ELSE 0 END) "mine"
         , SUM(CASE WHEN tu.last_read_post_number IS NULL OR tu.last_read_post_number < t.highest_post_number THEN 1 ELSE 0 END) "unread"
      FROM topics t
 LEFT JOIN topic_users tu ON t.id = tu.topic_id AND tu.user_id = :user_id
     WHERE t.deleted_at IS NULL
       AND t.archetype = 'private_message'
       AND t.id IN (SELECT topic_id FROM topic_allowed_users WHERE user_id = :user_id)
  SQL

  # map is there due to count returning nil
  all, mine, unread = DB.query_single(sql, user_id: user_id).map(&:to_i)

  sql = <<-SQL
    SELECT  g.name, COUNT(*) "count"
      FROM topics t
      JOIN topic_allowed_groups tg ON topic_id = t.id
      JOIN group_users gu ON gu.user_id = :user_id AND gu.group_id = tg.group_id
      JOIN groups g ON g.id = gu.group_id
     WHERE deleted_at IS NULL
       AND archetype = 'private_message'
     GROUP BY g.name
  SQL

  result = { all: all, mine: mine, unread: unread }

  DB
    .query(sql, user_id: user_id)
    .each { |row| (result[:groups] ||= []) << { name: row.name, count: row.count.to_i } }

  result
end

.private_typesObject



64
65
66
# File 'app/models/user_action.rb', line 64

def self.private_types
  @private_types ||= [WAS_LIKED, RESPONSE, MENTION, QUOTE, EDIT]
end

.remove_action!(hash) ⇒ Object



313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
# File 'app/models/user_action.rb', line 313

def self.remove_action!(hash)
  require_parameters(
    hash,
    :action_type,
    :user_id,
    :acting_user_id,
    :target_topic_id,
    :target_post_id,
  )
  if action = UserAction.find_by(hash.except(:created_at))
    action.destroy
    MessageBus.publish("/user/#{hash[:user_id]}", user_action_id: action.id, remove: true)
  end

  if !Topic.where(id: hash[:target_topic_id], archetype: Archetype.private_message).exists?
    update_like_count(hash[:user_id], hash[:action_type], -1)
  end
end

.require_parameters(data, *params) ⇒ Object



464
465
466
# File 'app/models/user_action.rb', line 464

def self.require_parameters(data, *params)
  params.each { |p| raise Discourse::InvalidParameters.new(p) if data[p].nil? }
end

.stats(user_id, guardian) ⇒ Object



75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
# File 'app/models/user_action.rb', line 75

def self.stats(user_id, guardian)
  # Sam: I tried this in AR and it got complex
  builder = DB.build <<~SQL

    SELECT action_type, COUNT(*) count
    FROM user_actions a
    LEFT JOIN topics t ON t.id = a.target_topic_id
    LEFT JOIN posts p on p.id = a.target_post_id
    LEFT JOIN posts p2 on p2.topic_id = a.target_topic_id and p2.post_number = 1
    LEFT JOIN categories c ON c.id = t.category_id
    /*where*/
    GROUP BY action_type
  SQL

  builder.where("a.user_id = :user_id", user_id: user_id)

  apply_common_filters(builder, user_id, guardian)

  results = builder.query
  results.sort! { |a, b| ORDER[a.action_type] <=> ORDER[b.action_type] }
  results
end

.stream(opts = nil) ⇒ Object



177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
# File 'app/models/user_action.rb', line 177

def self.stream(opts = nil)
  opts ||= {}

  action_types = opts[:action_types]
  user_id = opts[:user_id]
  action_id = opts[:action_id]
  guardian = opts[:guardian]
  ignore_private_messages = opts[:ignore_private_messages]
  offset = opts[:offset] || 0
  limit = opts[:limit] || 60
  acting_username = opts[:acting_username]

  # Acting user columns. Can be extended by plugins to include custom avatar
  # columns
  acting_cols = ["u.id AS acting_user_id", "u.name AS acting_name"]

  UserLookup.lookup_columns.each do |c|
    next if c == :id || c["."]
    acting_cols << "u.#{c} AS acting_#{c}"
  end

  # The weird thing is that target_post_id can be null, so it makes everything
  #  ever so more complex. Should we allow this, not sure.
  builder = DB.build <<~SQL
    SELECT
      a.id,
      t.title, a.action_type, a.created_at, t.id topic_id,
      t.closed AS topic_closed, t.archived AS topic_archived,
      a.user_id AS target_user_id, au.name AS target_name, au.username AS target_username,
      coalesce(p.post_number, 1) post_number, p.id as post_id,
      p.reply_to_post_number,
      pu.username, pu.name, pu.id user_id,
      pu.uploaded_avatar_id,
      #{acting_cols.join(", ")},
      coalesce(p.cooked, p2.cooked) cooked,
      CASE WHEN coalesce(p.deleted_at, p2.deleted_at, t.deleted_at) IS NULL THEN false ELSE true END deleted,
      p.hidden,
      p.post_type,
      p.action_code,
      pc.value AS action_code_who,
      pc2.value AS action_code_path,
      p.edit_reason,
      t.category_id
    FROM user_actions as a
    JOIN topics t on t.id = a.target_topic_id
    LEFT JOIN posts p on p.id = a.target_post_id
    JOIN posts p2 on p2.topic_id = a.target_topic_id and p2.post_number = 1
    JOIN users u on u.id = a.acting_user_id
    JOIN users pu on pu.id = COALESCE(p.user_id, t.user_id)
    JOIN users au on au.id = a.user_id
    LEFT JOIN categories c on c.id = t.category_id
    LEFT JOIN post_custom_fields pc ON pc.post_id = a.target_post_id AND pc.name = 'action_code_who'
    LEFT JOIN post_custom_fields pc2 ON pc2.post_id = a.target_post_id AND pc2.name = 'action_code_path'
    /*where*/
    /*order_by*/
    /*offset*/
    /*limit*/
  SQL

  apply_common_filters(builder, user_id, guardian, ignore_private_messages)

  if action_id
    builder.where("a.id = :id", id: action_id.to_i)
  else
    builder.where("a.user_id = :user_id", user_id: user_id.to_i)
    if action_types && action_types.length > 0
      builder.where("a.action_type in (:action_types)", action_types: action_types)
    end

    if acting_username
      builder.where(
        "u.username_lower = :acting_username",
        acting_username: acting_username.downcase,
      )
    end

    unless SiteSetting.enable_mentions?
      builder.where("a.action_type <> :mention_type", mention_type: UserAction::MENTION)
    end

    builder.order_by("a.created_at desc").offset(offset.to_i).limit(limit.to_i)
  end

  builder.query
end

.stream_item(action_id, guardian) ⇒ Object



149
150
151
# File 'app/models/user_action.rb', line 149

def self.stream_item(action_id, guardian)
  stream(action_id: action_id, guardian: guardian).first
end

.synchronize_target_topic_ids(post_ids = nil, limit: nil) ⇒ Object



332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
# File 'app/models/user_action.rb', line 332

def self.synchronize_target_topic_ids(post_ids = nil, limit: nil)
  # nuke all dupes, using magic
  builder = DB.build <<~SQL
    DELETE FROM user_actions USING user_actions ua2
    /*where*/
  SQL

  builder.where <<~SQL
    user_actions.action_type = ua2.action_type AND
    user_actions.user_id = ua2.user_id AND
    user_actions.acting_user_id = ua2.acting_user_id AND
    user_actions.target_post_id = ua2.target_post_id AND
    user_actions.target_post_id > 0 AND
    user_actions.id > ua2.id
  SQL

  builder.where(<<~SQL, limit: limit) if limit
      user_actions.target_post_id IN (
        SELECT target_post_id
        FROM user_actions
        WHERE created_at > :limit
      )
    SQL

  builder.where("user_actions.target_post_id in (:post_ids)", post_ids: post_ids) if post_ids

  builder.exec

  builder = DB.build <<~SQL
    UPDATE user_actions
    SET target_topic_id = (select topic_id from posts where posts.id = target_post_id)
    /*where*/
  SQL

  builder.where("target_topic_id <> (select topic_id from posts where posts.id = target_post_id)")
  builder.where("target_post_id in (:post_ids)", post_ids: post_ids) if post_ids

  builder.where(<<~SQL, limit: limit) if limit
      target_post_id IN (
        SELECT target_post_id
        FROM user_actions
        WHERE created_at > :limit
      )
    SQL

  builder.exec
end

.typesObject



44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
# File 'app/models/user_action.rb', line 44

def self.types
  @types ||=
    Enum.new(
      like: 1,
      was_liked: 2,
      # NOTE: Previously type 3 was bookmark but this was removed when we
      # changed to using the Bookmark model.
      new_topic: 4,
      reply: 5,
      response: 6,
      mention: 7,
      quote: 9,
      edit: 11,
      new_private_message: 12,
      got_private_message: 13,
      solved: 15,
      assigned: 16,
    )
end

.update_like_count(user_id, action_type, delta) ⇒ Object



384
385
386
387
388
389
390
# File 'app/models/user_action.rb', line 384

def self.update_like_count(user_id, action_type, delta)
  if action_type == LIKE
    UserStat.where(user_id: user_id).update_all("likes_given = likes_given + #{delta.to_i}")
  elsif action_type == WAS_LIKED
    UserStat.where(user_id: user_id).update_all("likes_received = likes_received + #{delta.to_i}")
  end
end