Skip to content

Commit 36936d5

Browse files
authored
fix: add unique constraint to reactions to avoid race conditions (The-Commit-Company#1702)
* fix: add unique constraint to reactions to avoid race conditions * fix: label for reactions tooltip
1 parent e27e625 commit 36936d5

File tree

5 files changed

+115
-30
lines changed

5 files changed

+115
-30
lines changed

frontend/src/utils/operations.ts

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -81,25 +81,33 @@ export const getUsers = (usersList: string[], count: number, currentUser: string
8181

8282
if (currentUserInList) {
8383
const otherUsers = usersList.filter((user, index) => index !== currentUserIndex)
84-
85-
// Show all users upto 50
86-
const userString = otherUsers.slice(0, 50).map((user) => userArray.find((u) => u.name == user)?.full_name).join(', ')
87-
8884
const remainingUsers = otherUsers.length - 50
8985
if (remainingUsers > 0) {
86+
// Show all users upto 50
87+
const userString = otherUsers.slice(0, 50).map((user) => userArray.find((u) => u.name == user)?.full_name).join(', ')
9088
return `You, ${userString} and ${remainingUsers} others`
9189
} else {
92-
return `You and ${userString}`
90+
// The user string will need to have an ", and" just before the last user
91+
// For example, You, John, Jane, and Henry
92+
const numberOfUsers = otherUsers.length
93+
const userString = otherUsers.slice(0, numberOfUsers - 1).map((user) => userArray.find((u) => u.name == user)?.full_name).join(', ')
94+
const lastUser = userArray.find((u) => u.name == otherUsers[numberOfUsers - 1])?.full_name
95+
return `You, ${userString}, and ${lastUser}`
9396
}
9497
}
9598
else {
96-
const userString = usersList.slice(0, 50).map((user) => userArray.find((u) => u.name == user)?.full_name).join(', ')
97-
const remainingUsers = usersList.length - 50
99+
const numberOfUsers = usersList.length
98100

99-
if (remainingUsers > 0) {
101+
if (numberOfUsers > 50) {
102+
const userString = usersList.slice(0, 50).map((user) => userArray.find((u) => u.name == user)?.full_name).join(', ')
103+
const remainingUsers = usersList.length - 50
100104
return `${userString} and ${remainingUsers} others`
101105
} else {
102-
return userString
106+
// The user string will need to have an ", and" just before the last user
107+
// For example, John, Jane, and Henry
108+
const userString = usersList.slice(0, numberOfUsers - 1).map((user) => userArray.find((u) => u.name == user)?.full_name).join(', ')
109+
const lastUser = userArray.find((u) => u.name == usersList[numberOfUsers - 1])?.full_name
110+
return `${userString} and ${lastUser}`
103111
}
104112
}
105113
}

raven/api/reactions.py

Lines changed: 26 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,6 @@ def react(message_id: str, reaction: str, is_custom: bool = False, emoji_name: s
1515
If yes, then unreacts (deletes), else reacts (creates).
1616
"""
1717

18-
# PERF: No need for permission checks here.
19-
# The permission checks are done in the controller method for the doctype
20-
2118
channel_id = frappe.get_cached_value("Raven Message", message_id, "channel_id")
2219
channel_type = frappe.get_cached_value("Raven Channel", channel_id, "type")
2320

@@ -32,18 +29,9 @@ def react(message_id: str, reaction: str, is_custom: bool = False, emoji_name: s
3229
else:
3330
reaction_escaped = reaction.encode("unicode-escape").decode("utf-8").replace("\\u", "")
3431
user = frappe.session.user
35-
existing_reaction = frappe.db.exists(
36-
"Raven Message Reaction",
37-
{"message": message_id, "owner": user, "reaction_escaped": reaction_escaped},
38-
)
3932

40-
if existing_reaction:
41-
# Why not use frappe.db.delete?
42-
# Because frappe won't run the controller method for 'after_delete' if we do so,
43-
# and we need to calculate the new count of reactions for our message
44-
frappe.get_doc("Raven Message Reaction", existing_reaction).delete(delete_permanently=True)
45-
46-
else:
33+
try:
34+
# Try to insert the reaction first
4735
frappe.get_doc(
4836
{
4937
"doctype": "Raven Message Reaction",
@@ -55,10 +43,24 @@ def react(message_id: str, reaction: str, is_custom: bool = False, emoji_name: s
5543
"reaction_escaped": reaction_escaped,
5644
}
5745
).insert(ignore_permissions=True)
58-
return "Ok"
5946

47+
calculate_message_reaction(message_id, channel_id)
48+
return "Ok"
6049

61-
def calculate_message_reaction(message_id, channel_id: str = None):
50+
except frappe.exceptions.UniqueValidationError:
51+
# If the reaction already exists, delete it
52+
frappe.db.delete(
53+
"Raven Message Reaction",
54+
filters={"message": message_id, "owner": user, "reaction_escaped": reaction_escaped},
55+
)
56+
57+
calculate_message_reaction(message_id, channel_id)
58+
return "Ok"
59+
except Exception as e:
60+
frappe.throw(_("Error reacting to message {0}").format(str(e)))
61+
62+
63+
def calculate_message_reaction(message_id, channel_id: str = None, do_not_publish: bool = False):
6264

6365
reactions = frappe.get_all(
6466
"Raven Message Reaction",
@@ -73,11 +75,11 @@ def calculate_message_reaction(message_id, channel_id: str = None):
7375
item_key = reaction_item.reaction_escaped if reaction_item.is_custom else reaction_item.reaction
7476
if item_key in total_reactions:
7577
existing_reaction = total_reactions[item_key]
76-
new_users = existing_reaction.get("users")
77-
new_users.append(reaction_item.owner)
78+
new_users = set(existing_reaction.get("users"))
79+
new_users.add(reaction_item.owner)
7880
total_reactions[item_key] = {
79-
"count": existing_reaction.get("count") + 1,
80-
"users": new_users,
81+
"count": len(new_users),
82+
"users": list(new_users),
8183
"reaction": reaction_item.reaction,
8284
"is_custom": reaction_item.is_custom,
8385
}
@@ -96,6 +98,10 @@ def calculate_message_reaction(message_id, channel_id: str = None):
9698
json.dumps(total_reactions, indent=4),
9799
update_modified=False,
98100
)
101+
102+
if do_not_publish:
103+
return
104+
99105
frappe.publish_realtime(
100106
"message_reacted",
101107
{

raven/patches.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,5 @@ raven.patches.v1_6.create_raven_channel_member_index
99
raven.patches.v1_6.migrate_older_raven_users #2
1010
raven.patches.v2_0.migrate_existing_dm_threads
1111
raven.patches.v2_0.create_default_workspace
12-
raven.patches.v2_0.create_default_company_workspace_mapping
12+
raven.patches.v2_0.create_default_company_workspace_mapping
13+
raven.patches.v2_4.add_unique_constraint_on_reactions #2
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import frappe
2+
3+
from raven.api.reactions import calculate_message_reaction
4+
from raven.raven_messaging.doctype.raven_message_reaction.raven_message_reaction import (
5+
on_doctype_update,
6+
)
7+
8+
9+
def execute():
10+
"""
11+
Add a unique constraint on Raven Message Reaction on the message, owner and reaction_escaped fields
12+
"""
13+
14+
# Before adding the constraint, we need to delete any existing reactions where the message, owner and reaction_escaped fields are the same
15+
16+
# For this, we can count the number of reactions where the message, owner and reaction_escaped fields are the same
17+
duplicate_reactions = frappe.db.sql(
18+
"""
19+
SELECT
20+
message,
21+
owner,
22+
reaction_escaped,
23+
COUNT(*) AS count
24+
FROM
25+
`tabRaven Message Reaction`
26+
GROUP BY
27+
message,
28+
owner,
29+
reaction_escaped
30+
HAVING
31+
COUNT(*) > 1
32+
""",
33+
as_dict=True,
34+
)
35+
messages_to_be_updated = set()
36+
37+
for reaction in duplicate_reactions:
38+
if reaction.count == 1:
39+
continue
40+
reaction_ids = frappe.db.get_all(
41+
"Raven Message Reaction",
42+
filters={
43+
"message": reaction.message,
44+
"owner": reaction.owner,
45+
"reaction_escaped": reaction.reaction_escaped,
46+
},
47+
fields=["name"],
48+
)
49+
# Delete all but the first one
50+
for reaction_id in reaction_ids[1:]:
51+
frappe.delete_doc("Raven Message Reaction", reaction_id.name, delete_permanently=True)
52+
53+
# Update the count for the reaction
54+
messages_to_be_updated.add(reaction.message)
55+
56+
# Update the count for the reactions
57+
for message in messages_to_be_updated:
58+
calculate_message_reaction(message_id=message, do_not_publish=True)
59+
60+
# Add the unique constraint
61+
on_doctype_update()

raven/raven_messaging/doctype/raven_message_reaction/raven_message_reaction.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import json
55

66
import frappe
7+
from frappe import _
78
from frappe.model.document import Document
89

910
from raven.api.reactions import calculate_message_reaction
@@ -32,3 +33,11 @@ def after_insert(self):
3233
def after_delete(self):
3334
# Update the count for the current reaction
3435
calculate_message_reaction(self.message, self.channel_id)
36+
37+
38+
def on_doctype_update():
39+
frappe.db.add_unique(
40+
"Raven Message Reaction",
41+
fields=["message", "owner", "reaction_escaped"],
42+
constraint_name="unique_reaction",
43+
)

0 commit comments

Comments
 (0)