diff --git a/internal/payload/outgoing.go b/internal/payload/outgoing.go index 5b812a2a8..ef7627041 100644 --- a/internal/payload/outgoing.go +++ b/internal/payload/outgoing.go @@ -29,8 +29,11 @@ func Message(ctx context.Context, msg *samTypes.Message) *outgoing.Message { ReplyTo: msg.ReplyTo, Replies: msg.Replies, - User: User(msg.User), - Attachment: Attachment(msg.Attachment), + User: User(msg.User), + Attachment: Attachment(msg.Attachment), + Reactions: MessageReactions(msg.Flags), + IsPinned: msg.Flags.IsPinned(), + IsBookmarked: msg.Flags.IsBookmarked(currentUserID), CanReply: canReply, CanEdit: canEdit, @@ -51,6 +54,33 @@ func Messages(ctx context.Context, msg samTypes.MessageSet) *outgoing.MessageSet return &retval } +func MessageReactions(flags samTypes.MessageFlagSet) outgoing.ReactionSet { + var ( + rr = make([]*outgoing.Reaction, 0) + rIndex = map[string]int{} + has bool + i int + ) + + _ = flags.Walk(func(flag *samTypes.MessageFlag) error { + if flag.IsReaction() { + r := &outgoing.Reaction{Reaction: flag.Flag, UserIDs: []string{}, Count: 0} + + if i, has = rIndex[flag.Flag]; !has { + i, rIndex[flag.Flag] = len(rr), len(rr) + rr = append(rr, r) + } + + rr[i].UserIDs = append(rr[i].UserIDs, Uint64toa(flag.UserID)) + rr[i].Count++ + } + + return nil + }) + + return rr +} + func Channel(ch *samTypes.Channel) *outgoing.Channel { return &outgoing.Channel{ ID: Uint64toa(ch.ID), diff --git a/internal/payload/outgoing/message.go b/internal/payload/outgoing/message.go index 211ff1d20..eaaafc43f 100644 --- a/internal/payload/outgoing/message.go +++ b/internal/payload/outgoing/message.go @@ -14,8 +14,11 @@ type ( ReplyTo uint64 `json:"replyTo,omitempty,string"` Replies uint `json:"replies,omitempty"` - User *User `json:"user"` - Attachment *Attachment `json:"att,omitempty"` + User *User `json:"user"` + Attachment *Attachment `json:"att,omitempty"` + Reactions ReactionSet `json:"reactions,omitempty"` + IsBookmarked bool `json:"isBookmarked"` + IsPinned bool `json:"isPinned"` CanReply bool `json:"canReply"` CanEdit bool `json:"canEdit"` @@ -27,6 +30,14 @@ type ( } MessageSet []*Message + + Reaction struct { + UserIDs []string `json:"userIDs"` + Reaction string `json:"reaction"` + Count uint `json:"count"` + } + + ReactionSet []*Reaction ) func (p *Message) EncodeMessage() ([]byte, error) { diff --git a/sam/db/mysql/statik.go b/sam/db/mysql/statik.go index 40c85f9d9..913fba10e 100644 --- a/sam/db/mysql/statik.go +++ b/sam/db/mysql/statik.go @@ -8,6 +8,6 @@ import ( ) func init() { - data := "PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x1a\x00 \x0020180704080000.base.up.sqlUT\x05\x00\x01\x80Cm8-- all known organisations (crust instances) and our relation towards them\nCREATE TABLE organisations (\n id BIGINT UNSIGNED NOT NULL,\n fqn TEXT NOT NULL, -- fully qualified name of the organisation\n name TEXT NOT NULL, -- display name of the organisation\n\n created_at DATETIME NOT NULL DEFAULT NOW(),\n updated_at DATETIME NULL,\n archived_at DATETIME NULL,\n deleted_at DATETIME NULL, -- organisation soft delete\n\n PRIMARY KEY (id)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\n-- Keeps all known teams\nCREATE TABLE teams (\n id BIGINT UNSIGNED NOT NULL,\n name TEXT NOT NULL, -- display name of the team\n handle TEXT NOT NULL, -- team handle string\n\n created_at DATETIME NOT NULL DEFAULT NOW(),\n updated_at DATETIME NULL,\n archived_at DATETIME NULL,\n deleted_at DATETIME NULL, -- team soft delete\n\n PRIMARY KEY (id)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\n-- Keeps all known channels\nCREATE TABLE channels (\n id BIGINT UNSIGNED NOT NULL,\n name TEXT NOT NULL, -- display name of the channel\n topic TEXT NOT NULL,\n meta JSON NOT NULL,\n\n type ENUM ('private', 'public', 'group') NOT NULL DEFAULT 'public',\n\n rel_organisation BIGINT UNSIGNED NOT NULL REFERENCES organisation(id),\n rel_creator BIGINT UNSIGNED NOT NULL,\n\n created_at DATETIME NOT NULL DEFAULT NOW(),\n updated_at DATETIME NULL,\n archived_at DATETIME NULL,\n deleted_at DATETIME NULL, -- channel soft delete\n\n rel_last_message BIGINT UNSIGNED NOT NULL DEFAULT 0,\n\n PRIMARY KEY (id)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\n-- Keeps team memberships\nCREATE TABLE team_members (\n rel_team BIGINT UNSIGNED NOT NULL REFERENCES organisation(id),\n rel_user BIGINT UNSIGNED NOT NULL,\n\n PRIMARY KEY (rel_team, rel_user)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\n-- handles channel membership\nCREATE TABLE channel_members (\n rel_channel BIGINT UNSIGNED NOT NULL REFERENCES channels(id),\n rel_user BIGINT UNSIGNED NOT NULL,\n\n type ENUM ('owner', 'member', 'invitee') NOT NULL DEFAULT 'member',\n\n created_at DATETIME NOT NULL DEFAULT NOW(),\n updated_at DATETIME NULL,\n\n PRIMARY KEY (rel_channel, rel_user)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\nCREATE TABLE channel_views (\n rel_channel BIGINT UNSIGNED NOT NULL REFERENCES channels(id),\n rel_user BIGINT UNSIGNED NOT NULL,\n\n -- timestamp of last view, should be enough to find out which messaghr\n viewed_at DATETIME NOT NULL DEFAULT NOW(),\n\n -- new messages count since last view\n new_since INT UNSIGNED NOT NULL DEFAULT 0,\n\n PRIMARY KEY (rel_user, rel_channel)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\nCREATE TABLE channel_pins (\n rel_channel BIGINT UNSIGNED NOT NULL REFERENCES channels(id),\n rel_message BIGINT UNSIGNED NOT NULL REFERENCES messages(id),\n rel_user BIGINT UNSIGNED NOT NULL,\n\n created_at DATETIME NOT NULL DEFAULT NOW(),\n\n PRIMARY KEY (rel_channel, rel_message)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\nCREATE TABLE messages (\n id BIGINT UNSIGNED NOT NULL,\n type TEXT,\n message TEXT NOT NULL,\n meta JSON,\n rel_user BIGINT UNSIGNED NOT NULL,\n rel_channel BIGINT UNSIGNED NOT NULL REFERENCES channels(id),\n reply_to BIGINT UNSIGNED NULL REFERENCES messages(id),\n\n created_at DATETIME NOT NULL DEFAULT NOW(),\n updated_at DATETIME NULL,\n deleted_at DATETIME NULL,\n\n PRIMARY KEY (id)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\nCREATE TABLE reactions (\n id BIGINT UNSIGNED NOT NULL,\n rel_user BIGINT UNSIGNED NOT NULL,\n rel_message BIGINT UNSIGNED NOT NULL REFERENCES messages(id),\n rel_channel BIGINT UNSIGNED NOT NULL REFERENCES channels(id),\n reaction TEXT NOT NULL,\n\n created_at DATETIME NOT NULL DEFAULT NOW(),\n\n PRIMARY KEY (id)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\nCREATE TABLE attachments (\n id BIGINT UNSIGNED NOT NULL,\n rel_user BIGINT UNSIGNED NOT NULL,\n\n url VARCHAR(512),\n preview_url VARCHAR(512),\n\n size INT UNSIGNED,\n mimetype VARCHAR(255),\n name TEXT,\n\n meta JSON,\n\n created_at DATETIME NOT NULL DEFAULT NOW(),\n updated_at DATETIME NULL,\n deleted_at DATETIME NULL,\n\n PRIMARY KEY (id)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\nCREATE TABLE message_attachment (\n rel_message BIGINT UNSIGNED NOT NULL REFERENCES messages(id),\n rel_attachment BIGINT UNSIGNED NOT NULL REFERENCES attachment(id),\n\n PRIMARY KEY (rel_message)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\nCREATE TABLE event_queue (\n id BIGINT UNSIGNED NOT NULL,\n origin BIGINT UNSIGNED NOT NULL,\n subscriber TEXT,\n payload JSON,\n\n PRIMARY KEY (id)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\nCREATE TABLE event_queue_synced (\n origin BIGINT UNSIGNED NOT NULL,\n rel_last BIGINT UNSIGNED NOT NULL,\n\n PRIMARY KEY (origin)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\nPK\x07\x08\xd2g\xcd\xce\x9f\x15\x00\x00\x9f\x15\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00$\x00 \x0020181009080000.altering_types.up.sqlUT\x05\x00\x01\x80Cm8update channels set type = 'group' where type = 'direct';\nalter table channels CHANGE type type enum('private', 'public', 'group');\nalter table channel_members CHANGE type type enum('owner', 'member', 'invitee');\nPK\x07\x08E1\xf5\xa4\xd7\x00\x00\x00\xd7\x00\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00#\x00 \x0020181013080000.channel_views.up.sqlUT\x05\x00\x01\x80Cm8ALTER TABLE channel_views DROP viewed_at;\nALTER TABLE channel_views ADD rel_last_message_id BIGINT UNSIGNED;\nALTER TABLE channel_views CHANGE new_since new_messages_count INT UNSIGNED;\n\n-- Table structure after these changes:\n-- +---------------------+---------------------+------+-----+---------+-------+\n-- | Field | Type | Null | Key | Default | Extra |\n-- +---------------------+---------------------+------+-----+---------+-------+\n-- | rel_channel | bigint(20) unsigned | NO | PRI | NULL | |\n-- | rel_user | bigint(20) unsigned | NO | PRI | NULL | |\n-- | rel_last_message_id | bigint(20) unsigned | YES | | NULL | |\n-- | new_messages_count | int(10) unsigned | NO | | 0 | |\n-- +---------------------+---------------------+------+-----+---------+-------+\n\n-- Prefill with data\nINSERT INTO channel_views (rel_channel, rel_user, rel_last_message_id)\n SELECT cm.rel_channel, cm.rel_user, max(m.ID)\n FROM channel_members AS cm INNER JOIN messages AS m ON (m.rel_channel = cm.rel_channel)\n GROUP BY cm.rel_channel, cm.rel_user;\n\nPK\x07\x08`\xcbP\xf9t\x04\x00\x00t\x04\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x1d\x00 \x0020181013080000.replies.up.sqlUT\x05\x00\x01\x80Cm8ALTER TABLE messages CHANGE reply_to reply_to BIGINT UNSIGNED NOT NULL DEFAULT 0;\nALTER TABLE messages ADD replies INT UNSIGNED NOT NULL DEFAULT 0;\nPK\x07\x08m\xedWA\x94\x00\x00\x00\x94\x00\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0e\x00 \x00migrations.sqlUT\x05\x00\x01\x80Cm8CREATE TABLE IF NOT EXISTS `migrations` (\n `project` varchar(16) NOT NULL COMMENT 'sam, crm, ...',\n `filename` varchar(255) NOT NULL COMMENT 'yyyymmddHHMMSS.sql',\n `statement_index` int(11) NOT NULL COMMENT 'Statement number from SQL file',\n `status` TEXT NOT NULL COMMENT 'ok or full error message',\n PRIMARY KEY (`project`,`filename`)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\nPK\x07\x08\x0d\xa5T2x\x01\x00\x00x\x01\x00\x00PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(\xd2g\xcd\xce\x9f\x15\x00\x00\x9f\x15\x00\x00\x1a\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\x00\x00\x00\x0020180704080000.base.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(E1\xf5\xa4\xd7\x00\x00\x00\xd7\x00\x00\x00$\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\xf0\x15\x00\x0020181009080000.altering_types.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(`\xcbP\xf9t\x04\x00\x00t\x04\x00\x00#\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\"\x17\x00\x0020181013080000.channel_views.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(m\xedWA\x94\x00\x00\x00\x94\x00\x00\x00\x1d\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\xf0\x1b\x00\x0020181013080000.replies.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(\x0d\xa5T2x\x01\x00\x00x\x01\x00\x00\x0e\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\xd8\x1c\x00\x00migrations.sqlUT\x05\x00\x01\x80Cm8PK\x05\x06\x00\x00\x00\x00\x05\x00\x05\x00\x9f\x01\x00\x00\x95\x1e\x00\x00\x00\x00" + data := "PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x1a\x00 \x0020180704080000.base.up.sqlUT\x05\x00\x01\x80Cm8-- all known organisations (crust instances) and our relation towards them\nCREATE TABLE organisations (\n id BIGINT UNSIGNED NOT NULL,\n fqn TEXT NOT NULL, -- fully qualified name of the organisation\n name TEXT NOT NULL, -- display name of the organisation\n\n created_at DATETIME NOT NULL DEFAULT NOW(),\n updated_at DATETIME NULL,\n archived_at DATETIME NULL,\n deleted_at DATETIME NULL, -- organisation soft delete\n\n PRIMARY KEY (id)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\n-- Keeps all known teams\nCREATE TABLE teams (\n id BIGINT UNSIGNED NOT NULL,\n name TEXT NOT NULL, -- display name of the team\n handle TEXT NOT NULL, -- team handle string\n\n created_at DATETIME NOT NULL DEFAULT NOW(),\n updated_at DATETIME NULL,\n archived_at DATETIME NULL,\n deleted_at DATETIME NULL, -- team soft delete\n\n PRIMARY KEY (id)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\n-- Keeps all known channels\nCREATE TABLE channels (\n id BIGINT UNSIGNED NOT NULL,\n name TEXT NOT NULL, -- display name of the channel\n topic TEXT NOT NULL,\n meta JSON NOT NULL,\n\n type ENUM ('private', 'public', 'group') NOT NULL DEFAULT 'public',\n\n rel_organisation BIGINT UNSIGNED NOT NULL REFERENCES organisation(id),\n rel_creator BIGINT UNSIGNED NOT NULL,\n\n created_at DATETIME NOT NULL DEFAULT NOW(),\n updated_at DATETIME NULL,\n archived_at DATETIME NULL,\n deleted_at DATETIME NULL, -- channel soft delete\n\n rel_last_message BIGINT UNSIGNED NOT NULL DEFAULT 0,\n\n PRIMARY KEY (id)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\n-- Keeps team memberships\nCREATE TABLE team_members (\n rel_team BIGINT UNSIGNED NOT NULL REFERENCES organisation(id),\n rel_user BIGINT UNSIGNED NOT NULL,\n\n PRIMARY KEY (rel_team, rel_user)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\n-- handles channel membership\nCREATE TABLE channel_members (\n rel_channel BIGINT UNSIGNED NOT NULL REFERENCES channels(id),\n rel_user BIGINT UNSIGNED NOT NULL,\n\n type ENUM ('owner', 'member', 'invitee') NOT NULL DEFAULT 'member',\n\n created_at DATETIME NOT NULL DEFAULT NOW(),\n updated_at DATETIME NULL,\n\n PRIMARY KEY (rel_channel, rel_user)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\nCREATE TABLE channel_views (\n rel_channel BIGINT UNSIGNED NOT NULL REFERENCES channels(id),\n rel_user BIGINT UNSIGNED NOT NULL,\n\n -- timestamp of last view, should be enough to find out which messaghr\n viewed_at DATETIME NOT NULL DEFAULT NOW(),\n\n -- new messages count since last view\n new_since INT UNSIGNED NOT NULL DEFAULT 0,\n\n PRIMARY KEY (rel_user, rel_channel)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\nCREATE TABLE channel_pins (\n rel_channel BIGINT UNSIGNED NOT NULL REFERENCES channels(id),\n rel_message BIGINT UNSIGNED NOT NULL REFERENCES messages(id),\n rel_user BIGINT UNSIGNED NOT NULL,\n\n created_at DATETIME NOT NULL DEFAULT NOW(),\n\n PRIMARY KEY (rel_channel, rel_message)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\nCREATE TABLE messages (\n id BIGINT UNSIGNED NOT NULL,\n type TEXT,\n message TEXT NOT NULL,\n meta JSON,\n rel_user BIGINT UNSIGNED NOT NULL,\n rel_channel BIGINT UNSIGNED NOT NULL REFERENCES channels(id),\n reply_to BIGINT UNSIGNED NULL REFERENCES messages(id),\n\n created_at DATETIME NOT NULL DEFAULT NOW(),\n updated_at DATETIME NULL,\n deleted_at DATETIME NULL,\n\n PRIMARY KEY (id)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\nCREATE TABLE reactions (\n id BIGINT UNSIGNED NOT NULL,\n rel_user BIGINT UNSIGNED NOT NULL,\n rel_message BIGINT UNSIGNED NOT NULL REFERENCES messages(id),\n rel_channel BIGINT UNSIGNED NOT NULL REFERENCES channels(id),\n reaction TEXT NOT NULL,\n\n created_at DATETIME NOT NULL DEFAULT NOW(),\n\n PRIMARY KEY (id)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\nCREATE TABLE attachments (\n id BIGINT UNSIGNED NOT NULL,\n rel_user BIGINT UNSIGNED NOT NULL,\n\n url VARCHAR(512),\n preview_url VARCHAR(512),\n\n size INT UNSIGNED,\n mimetype VARCHAR(255),\n name TEXT,\n\n meta JSON,\n\n created_at DATETIME NOT NULL DEFAULT NOW(),\n updated_at DATETIME NULL,\n deleted_at DATETIME NULL,\n\n PRIMARY KEY (id)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\nCREATE TABLE message_attachment (\n rel_message BIGINT UNSIGNED NOT NULL REFERENCES messages(id),\n rel_attachment BIGINT UNSIGNED NOT NULL REFERENCES attachment(id),\n\n PRIMARY KEY (rel_message)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\nCREATE TABLE event_queue (\n id BIGINT UNSIGNED NOT NULL,\n origin BIGINT UNSIGNED NOT NULL,\n subscriber TEXT,\n payload JSON,\n\n PRIMARY KEY (id)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\nCREATE TABLE event_queue_synced (\n origin BIGINT UNSIGNED NOT NULL,\n rel_last BIGINT UNSIGNED NOT NULL,\n\n PRIMARY KEY (origin)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\nPK\x07\x08\xd2g\xcd\xce\x9f\x15\x00\x00\x9f\x15\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00$\x00 \x0020181009080000.altering_types.up.sqlUT\x05\x00\x01\x80Cm8update channels set type = 'group' where type = 'direct';\nalter table channels CHANGE type type enum('private', 'public', 'group');\nalter table channel_members CHANGE type type enum('owner', 'member', 'invitee');\nPK\x07\x08E1\xf5\xa4\xd7\x00\x00\x00\xd7\x00\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00#\x00 \x0020181013080000.channel_views.up.sqlUT\x05\x00\x01\x80Cm8ALTER TABLE channel_views DROP viewed_at;\nALTER TABLE channel_views ADD rel_last_message_id BIGINT UNSIGNED;\nALTER TABLE channel_views CHANGE new_since new_messages_count INT UNSIGNED;\n\n-- Table structure after these changes:\n-- +---------------------+---------------------+------+-----+---------+-------+\n-- | Field | Type | Null | Key | Default | Extra |\n-- +---------------------+---------------------+------+-----+---------+-------+\n-- | rel_channel | bigint(20) unsigned | NO | PRI | NULL | |\n-- | rel_user | bigint(20) unsigned | NO | PRI | NULL | |\n-- | rel_last_message_id | bigint(20) unsigned | YES | | NULL | |\n-- | new_messages_count | int(10) unsigned | NO | | 0 | |\n-- +---------------------+---------------------+------+-----+---------+-------+\n\n-- Prefill with data\nINSERT INTO channel_views (rel_channel, rel_user, rel_last_message_id)\n SELECT cm.rel_channel, cm.rel_user, max(m.ID)\n FROM channel_members AS cm INNER JOIN messages AS m ON (m.rel_channel = cm.rel_channel)\n GROUP BY cm.rel_channel, cm.rel_user;\n\nPK\x07\x08`\xcbP\xf9t\x04\x00\x00t\x04\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x1d\x00 \x0020181013080000.replies.up.sqlUT\x05\x00\x01\x80Cm8ALTER TABLE messages CHANGE reply_to reply_to BIGINT UNSIGNED NOT NULL DEFAULT 0;\nALTER TABLE messages ADD replies INT UNSIGNED NOT NULL DEFAULT 0;\nPK\x07\x08m\xedWA\x94\x00\x00\x00\x94\x00\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00(\x00 \x0020181101080000.pins_and_reactions.up.sqlUT\x05\x00\x01\x80Cm8DROP TABLE channel_pins;\nDROP TABLE reactions;\n\nCREATE TABLE message_flags (\n id BIGINT UNSIGNED NOT NULL,\n rel_channel BIGINT UNSIGNED NOT NULL,\n rel_message BIGINT UNSIGNED NOT NULL,\n rel_user BIGINT UNSIGNED NOT NULL,\n flag TEXT,\n\n created_at DATETIME NOT NULL DEFAULT NOW(),\n\n PRIMARY KEY (id)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\nPK\x07\x08eA\x1eo\x90\x01\x00\x00\x90\x01\x00\x00PK\x03\x04\x14\x00\x08\x00\x00\x00\x00\x00!(\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0e\x00 \x00migrations.sqlUT\x05\x00\x01\x80Cm8CREATE TABLE IF NOT EXISTS `migrations` (\n `project` varchar(16) NOT NULL COMMENT 'sam, crm, ...',\n `filename` varchar(255) NOT NULL COMMENT 'yyyymmddHHMMSS.sql',\n `statement_index` int(11) NOT NULL COMMENT 'Statement number from SQL file',\n `status` TEXT NOT NULL COMMENT 'ok or full error message',\n PRIMARY KEY (`project`,`filename`)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8;\n\nPK\x07\x08\x0d\xa5T2x\x01\x00\x00x\x01\x00\x00PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(\xd2g\xcd\xce\x9f\x15\x00\x00\x9f\x15\x00\x00\x1a\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\x00\x00\x00\x0020180704080000.base.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(E1\xf5\xa4\xd7\x00\x00\x00\xd7\x00\x00\x00$\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\xf0\x15\x00\x0020181009080000.altering_types.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(`\xcbP\xf9t\x04\x00\x00t\x04\x00\x00#\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\"\x17\x00\x0020181013080000.channel_views.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(m\xedWA\x94\x00\x00\x00\x94\x00\x00\x00\x1d\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\xf0\x1b\x00\x0020181013080000.replies.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(eA\x1eo\x90\x01\x00\x00\x90\x01\x00\x00(\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\xd8\x1c\x00\x0020181101080000.pins_and_reactions.up.sqlUT\x05\x00\x01\x80Cm8PK\x01\x02\x14\x03\x14\x00\x08\x00\x00\x00\x00\x00!(\x0d\xa5T2x\x01\x00\x00x\x01\x00\x00\x0e\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\xc7\x1e\x00\x00migrations.sqlUT\x05\x00\x01\x80Cm8PK\x05\x06\x00\x00\x00\x00\x06\x00\x06\x00\xfe\x01\x00\x00\x84 \x00\x00\x00\x00" fs.Register(data) } diff --git a/sam/db/schema/mysql/20181101080000.pins_and_reactions.up.sql b/sam/db/schema/mysql/20181101080000.pins_and_reactions.up.sql new file mode 100644 index 000000000..1d1a92531 --- /dev/null +++ b/sam/db/schema/mysql/20181101080000.pins_and_reactions.up.sql @@ -0,0 +1,14 @@ +DROP TABLE channel_pins; +DROP TABLE reactions; + +CREATE TABLE message_flags ( + id BIGINT UNSIGNED NOT NULL, + rel_channel BIGINT UNSIGNED NOT NULL, + rel_message BIGINT UNSIGNED NOT NULL, + rel_user BIGINT UNSIGNED NOT NULL, + flag TEXT, + + created_at DATETIME NOT NULL DEFAULT NOW(), + + PRIMARY KEY (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; diff --git a/sam/docs/README.md b/sam/docs/README.md index 8dab3d3cb..87848133c 100644 --- a/sam/docs/README.md +++ b/sam/docs/README.md @@ -453,20 +453,6 @@ The following event types may be sent with a message event: | --------- | ---- | ------ | ----------- | ------- | --------- | | messageID | uint64 | PATH | Message ID | N/A | YES | -## Pin message to channel (public bookmark) - -#### Method - -| URI | Protocol | Method | Authentication | -| --- | -------- | ------ | -------------- | -| `/channels/{channelID}/messages/{messageID}/pin` | HTTP/S | POST | Client ID, Session ID | - -#### Request parameters - -| Parameter | Type | Method | Description | Default | Required? | -| --------- | ---- | ------ | ----------- | ------- | --------- | -| messageID | uint64 | PATH | Message ID | N/A | YES | - ## Returns all replies to a message #### Method @@ -500,6 +486,20 @@ The following event types may be sent with a message event: #### Method +| URI | Protocol | Method | Authentication | +| --- | -------- | ------ | -------------- | +| `/channels/{channelID}/messages/{messageID}/pin` | HTTP/S | POST | Client ID, Session ID | + +#### Request parameters + +| Parameter | Type | Method | Description | Default | Required? | +| --------- | ---- | ------ | ----------- | ------- | --------- | +| messageID | uint64 | PATH | Message ID | N/A | YES | + +## Pin message to channel (public bookmark) + +#### Method + | URI | Protocol | Method | Authentication | | --- | -------- | ------ | -------------- | | `/channels/{channelID}/messages/{messageID}/pin` | HTTP/S | DELETE | Client ID, Session ID | @@ -510,13 +510,13 @@ The following event types may be sent with a message event: | --------- | ---- | ------ | ----------- | ------- | --------- | | messageID | uint64 | PATH | Message ID | N/A | YES | -## Flag a message (private bookmark) +## Bookmark a message (private bookmark) #### Method | URI | Protocol | Method | Authentication | | --- | -------- | ------ | -------------- | -| `/channels/{channelID}/messages/{messageID}/flag` | HTTP/S | POST | Client ID, Session ID | +| `/channels/{channelID}/messages/{messageID}/bookmark` | HTTP/S | POST | Client ID, Session ID | #### Request parameters @@ -524,13 +524,13 @@ The following event types may be sent with a message event: | --------- | ---- | ------ | ----------- | ------- | --------- | | messageID | uint64 | PATH | Message ID | N/A | YES | -## Remove flag from message (private bookmark) +## Remove boomark from message (private bookmark) #### Method | URI | Protocol | Method | Authentication | | --- | -------- | ------ | -------------- | -| `/channels/{channelID}/messages/{messageID}/flag` | HTTP/S | DELETE | Client ID, Session ID | +| `/channels/{channelID}/messages/{messageID}/bookmark` | HTTP/S | DELETE | Client ID, Session ID | #### Request parameters @@ -544,7 +544,7 @@ The following event types may be sent with a message event: | URI | Protocol | Method | Authentication | | --- | -------- | ------ | -------------- | -| `/channels/{channelID}/messages/{messageID}/reaction/{reaction}` | HTTP/S | PUT | Client ID, Session ID | +| `/channels/{channelID}/messages/{messageID}/reaction/{reaction}` | HTTP/S | POST | Client ID, Session ID | #### Request parameters @@ -559,7 +559,7 @@ The following event types may be sent with a message event: | URI | Protocol | Method | Authentication | | --- | -------- | ------ | -------------- | -| `/channels/{channelID}/messages/{messageID}/react/{reaction}` | HTTP/S | DELETE | Client ID, Session ID | +| `/channels/{channelID}/messages/{messageID}/reaction/{reaction}` | HTTP/S | DELETE | Client ID, Session ID | #### Request parameters diff --git a/sam/docs/src/spec.json b/sam/docs/src/spec.json index 476143c9a..a83fbcfad 100644 --- a/sam/docs/src/spec.json +++ b/sam/docs/src/spec.json @@ -387,17 +387,6 @@ ] } }, - { - "name": "pin", - "path": "/{messageID}/pin", - "method": "POST", - "title": "Pin message to channel (public bookmark)", - "parameters": { - "path": [ - { "name": "messageID", "type": "uint64", "required": true, "title": "Message ID" } - ] - } - }, { "name": "getReplies", "path": "/{messageID}/replies", @@ -424,7 +413,18 @@ } }, { - "name": "unpin", + "name": "pin", + "path": "/{messageID}/pin", + "method": "POST", + "title": "Pin message to channel (public bookmark)", + "parameters": { + "path": [ + { "name": "messageID", "type": "uint64", "required": true, "title": "Message ID" } + ] + } + }, + { + "name": "removePin", "path": "/{messageID}/pin", "method": "DELETE", "title": "Pin message to channel (public bookmark)", @@ -435,10 +435,10 @@ } }, { - "name": "flag", - "path": "/{messageID}/flag", + "name": "bookmark", + "path": "/{messageID}/bookmark", "method": "POST", - "title": "Flag a message (private bookmark)", + "title": "Bookmark a message (private bookmark)", "parameters": { "path": [ { "name": "messageID", "type": "uint64", "required": true, "title": "Message ID" } @@ -446,10 +446,10 @@ } }, { - "name": "unflag", - "path": "/{messageID}/flag", + "name": "removeBookmark", + "path": "/{messageID}/bookmark", "method": "DELETE", - "title": "Remove flag from message (private bookmark)", + "title": "Remove boomark from message (private bookmark)", "parameters": { "path": [ { "name": "messageID", "type": "uint64", "required": true, "title": "Message ID" } @@ -459,7 +459,7 @@ { "name": "react", "path": "/{messageID}/reaction/{reaction}", - "method": "PUT", + "method": "POST", "title": "React to a message", "parameters": { "path": [ @@ -469,8 +469,8 @@ } }, { - "name": "unreact", - "path": "/{messageID}/react/{reaction}", + "name": "removeReaction", + "path": "/{messageID}/reaction/{reaction}", "method": "DELETE", "title": "Delete reaction from a message", "parameters": { diff --git a/sam/docs/src/spec/message.json b/sam/docs/src/spec/message.json index d8cb4bc43..66d4ec6f3 100644 --- a/sam/docs/src/spec/message.json +++ b/sam/docs/src/spec/message.json @@ -92,22 +92,6 @@ ] } }, - { - "Name": "pin", - "Method": "POST", - "Title": "Pin message to channel (public bookmark)", - "Path": "/{messageID}/pin", - "Parameters": { - "path": [ - { - "name": "messageID", - "required": true, - "title": "Message ID", - "type": "uint64" - } - ] - } - }, { "Name": "getReplies", "Method": "GET", @@ -149,7 +133,23 @@ } }, { - "Name": "unpin", + "Name": "pin", + "Method": "POST", + "Title": "Pin message to channel (public bookmark)", + "Path": "/{messageID}/pin", + "Parameters": { + "path": [ + { + "name": "messageID", + "required": true, + "title": "Message ID", + "type": "uint64" + } + ] + } + }, + { + "Name": "removePin", "Method": "DELETE", "Title": "Pin message to channel (public bookmark)", "Path": "/{messageID}/pin", @@ -165,10 +165,10 @@ } }, { - "Name": "flag", + "Name": "bookmark", "Method": "POST", - "Title": "Flag a message (private bookmark)", - "Path": "/{messageID}/flag", + "Title": "Bookmark a message (private bookmark)", + "Path": "/{messageID}/bookmark", "Parameters": { "path": [ { @@ -181,10 +181,10 @@ } }, { - "Name": "unflag", + "Name": "removeBookmark", "Method": "DELETE", - "Title": "Remove flag from message (private bookmark)", - "Path": "/{messageID}/flag", + "Title": "Remove boomark from message (private bookmark)", + "Path": "/{messageID}/bookmark", "Parameters": { "path": [ { @@ -198,7 +198,7 @@ }, { "Name": "react", - "Method": "PUT", + "Method": "POST", "Title": "React to a message", "Path": "/{messageID}/reaction/{reaction}", "Parameters": { @@ -219,10 +219,10 @@ } }, { - "Name": "unreact", + "Name": "removeReaction", "Method": "DELETE", "Title": "Delete reaction from a message", - "Path": "/{messageID}/react/{reaction}", + "Path": "/{messageID}/reaction/{reaction}", "Parameters": { "path": [ { diff --git a/sam/repository/message_flag.go b/sam/repository/message_flag.go new file mode 100644 index 000000000..00186299a --- /dev/null +++ b/sam/repository/message_flag.go @@ -0,0 +1,83 @@ +package repository + +import ( + "context" + "time" + + "github.com/jmoiron/sqlx" + "github.com/titpetric/factory" + + "github.com/crusttech/crust/sam/types" +) + +type ( + MessageFlagRepository interface { + With(ctx context.Context, db *factory.DB) MessageFlagRepository + + FindByID(ID uint64) (*types.MessageFlag, error) + FindByMessageIDs(IDs ...uint64) ([]*types.MessageFlag, error) + FindByFlag(messageID, userID uint64, flag string) (*types.MessageFlag, error) + Create(mod *types.MessageFlag) (*types.MessageFlag, error) + DeleteByID(ID uint64) error + } + + reaction struct { + *repository + } +) + +const ( + ErrMessageFlagNotFound = repositoryError("MessageFlagNotFound") +) + +func MessageFlag(ctx context.Context, db *factory.DB) MessageFlagRepository { + return (&reaction{}).With(ctx, db) +} + +func (r *reaction) With(ctx context.Context, db *factory.DB) MessageFlagRepository { + return &reaction{ + repository: r.repository.With(ctx, db), + } +} + +func (r *reaction) FindByID(ID uint64) (*types.MessageFlag, error) { + sql := "SELECT * FROM message_flags WHERE id = ?" + mod := &types.MessageFlag{} + return mod, isFound(r.db().Get(mod, sql, ID), mod.ID > 0, ErrMessageFlagNotFound) +} + +func (r *reaction) FindByFlag(messageID, userID uint64, flag string) (*types.MessageFlag, error) { + args := []interface{}{messageID, flag} + sql := "SELECT * FROM message_flags WHERE rel_message = ? AND flag = ? " + + if userID > 0 { + sql += "AND rel_user = ? " + args = append(args, userID) + } + + mod := &types.MessageFlag{} + return mod, isFound(r.db().Get(mod, sql, args...), mod.ID > 0, ErrMessageFlagNotFound) +} + +// FindByMessageRange returns all flags by message id range +func (r *reaction) FindByMessageIDs(IDs ...uint64) ([]*types.MessageFlag, error) { + rval := make([]*types.MessageFlag, 0) + + sql := `SELECT * FROM message_flags WHERE rel_message IN (?)` + + if sql, args, err := sqlx.In(sql, IDs); err != nil { + return nil, err + } else { + return rval, r.db().Select(&rval, sql, args...) + } +} + +func (r *reaction) Create(mod *types.MessageFlag) (*types.MessageFlag, error) { + mod.ID = factory.Sonyflake.NextID() + mod.CreatedAt = time.Now() + return mod, r.db().Insert("message_flags", mod) +} + +func (r *reaction) DeleteByID(ID uint64) error { + return exec(r.db().Exec("DELETE FROM message_flags WHERE id = ?", ID)) +} diff --git a/sam/repository/message_flag_test.go b/sam/repository/message_flag_test.go new file mode 100644 index 000000000..2dd1a621a --- /dev/null +++ b/sam/repository/message_flag_test.go @@ -0,0 +1,50 @@ +package repository + +import ( + "context" + + "github.com/titpetric/factory" + + "testing" + + "github.com/crusttech/crust/sam/types" +) + +func TestReaction(t *testing.T) { + var err error + + if testing.Short() { + t.Skip("skipping test in short mode.") + return + } + + rpo := MessageFlag(context.Background(), factory.Database.MustGet()) + + tx(t, func() error { + var chID = factory.Sonyflake.NextID() + var msgID = factory.Sonyflake.NextID() + var f *types.MessageFlag + var ff types.MessageFlagSet + f, err = rpo.Create(&types.MessageFlag{ + ChannelID: chID, + MessageID: msgID, + UserID: 3, + Flag: "success", + }) + + assert(t, err == nil, "Should create message flag without an error, got: %v", err) + + f, err = rpo.FindByID(f.ID) + assert(t, err == nil, "Should fetch message flag without an error, got: %v", err) + assert(t, f != nil && f.ChannelID == chID, "fetch should return valid type struct") + + ff, err = rpo.FindByMessageIDs(msgID) + assert(t, err == nil, "Should fetch message flag by range without an error, got: %v", err) + assert(t, len(ff) == 1, "fetch by range should return 1 message") + + err = rpo.DeleteByID(f.ID) + assert(t, err == nil, "Should delete message flag without an error, got: %v", err) + + return nil + }) +} diff --git a/sam/repository/reaction.go b/sam/repository/reaction.go deleted file mode 100644 index be2933957..000000000 --- a/sam/repository/reaction.go +++ /dev/null @@ -1,61 +0,0 @@ -package repository - -import ( - "context" - "time" - - "github.com/titpetric/factory" - - "github.com/crusttech/crust/sam/types" -) - -type ( - ReactionRepository interface { - With(ctx context.Context, db *factory.DB) ReactionRepository - - FindReactionByID(id uint64) (*types.Reaction, error) - FindReactionsByRange(channelID, fromReactionID, toReactionID uint64) ([]*types.Reaction, error) - CreateReaction(mod *types.Reaction) (*types.Reaction, error) - DeleteReactionByID(id uint64) error - } - - reaction struct { - *repository - } -) - -const ( - ErrReactionNotFound = repositoryError("ReactionNotFound") -) - -func Reaction(ctx context.Context, db *factory.DB) ReactionRepository { - return (&reaction{}).With(ctx, db) -} - -func (r *reaction) With(ctx context.Context, db *factory.DB) ReactionRepository { - return &reaction{ - repository: r.repository.With(ctx, db), - } -} - -func (r *reaction) FindReactionByID(id uint64) (*types.Reaction, error) { - sql := "SELECT * FROM reactions WHERE id=?" - mod := &types.Reaction{} - return mod, isFound(r.db().Get(mod, sql, id), mod.ID > 0, ErrReactionNotFound) -} - -func (r *reaction) FindReactionsByRange(channelID, fromReactionID, toReactionID uint64) ([]*types.Reaction, error) { - rval := make([]*types.Reaction, 0) - sql := `SELECT * FROM reactions WHERE rel_reaction BETWEEN ? AND ? AND rel_channel=?` - return rval, r.db().Select(&rval, sql, fromReactionID, toReactionID, channelID) -} - -func (r *reaction) CreateReaction(mod *types.Reaction) (*types.Reaction, error) { - mod.ID = factory.Sonyflake.NextID() - mod.CreatedAt = time.Now() - return mod, r.db().Insert("reactions", mod) -} - -func (r *reaction) DeleteReactionByID(id uint64) error { - return exec(r.db().Exec("DELETE FROM reactions WHERE id=?", id)) -} diff --git a/sam/repository/reaction_test.go b/sam/repository/reaction_test.go deleted file mode 100644 index c9a0717ee..000000000 --- a/sam/repository/reaction_test.go +++ /dev/null @@ -1,43 +0,0 @@ -package repository - -import ( - "context" - - "github.com/titpetric/factory" - - "testing" - - "github.com/crusttech/crust/sam/types" -) - -func TestReaction(t *testing.T) { - var err error - - if testing.Short() { - t.Skip("skipping test in short mode.") - return - } - - rpo := Reaction(context.Background(), factory.Database.MustGet()) - react := &types.Reaction{} - - var reaction = ":laugh:" - - { - react.Reaction = reaction - react, err = rpo.CreateReaction(react) - assert(t, err == nil, "CreateReaction error: %v", err) - assert(t, react.Reaction == reaction, "Changes were not stored") - - { - react, err = rpo.FindReactionByID(react.ID) - assert(t, err == nil, "FindReactionByID error: %v", err) - assert(t, react.Reaction == reaction, "Changes were not stored") - } - - { - err = rpo.DeleteReactionByID(react.ID) - assert(t, err == nil, "DeleteReactionByID error: %v", err) - } - } -} diff --git a/sam/rest/handlers/message.go b/sam/rest/handlers/message.go index 79daa2ad3..a33cf355d 100644 --- a/sam/rest/handlers/message.go +++ b/sam/rest/handlers/message.go @@ -31,30 +31,30 @@ type MessageAPI interface { History(context.Context, *request.MessageHistory) (interface{}, error) Edit(context.Context, *request.MessageEdit) (interface{}, error) Delete(context.Context, *request.MessageDelete) (interface{}, error) - Pin(context.Context, *request.MessagePin) (interface{}, error) GetReplies(context.Context, *request.MessageGetReplies) (interface{}, error) CreateReply(context.Context, *request.MessageCreateReply) (interface{}, error) - Unpin(context.Context, *request.MessageUnpin) (interface{}, error) - Flag(context.Context, *request.MessageFlag) (interface{}, error) - Unflag(context.Context, *request.MessageUnflag) (interface{}, error) + Pin(context.Context, *request.MessagePin) (interface{}, error) + RemovePin(context.Context, *request.MessageRemovePin) (interface{}, error) + Bookmark(context.Context, *request.MessageBookmark) (interface{}, error) + RemoveBookmark(context.Context, *request.MessageRemoveBookmark) (interface{}, error) React(context.Context, *request.MessageReact) (interface{}, error) - Unreact(context.Context, *request.MessageUnreact) (interface{}, error) + RemoveReaction(context.Context, *request.MessageRemoveReaction) (interface{}, error) } // HTTP API interface type Message struct { - Create func(http.ResponseWriter, *http.Request) - History func(http.ResponseWriter, *http.Request) - Edit func(http.ResponseWriter, *http.Request) - Delete func(http.ResponseWriter, *http.Request) - Pin func(http.ResponseWriter, *http.Request) - GetReplies func(http.ResponseWriter, *http.Request) - CreateReply func(http.ResponseWriter, *http.Request) - Unpin func(http.ResponseWriter, *http.Request) - Flag func(http.ResponseWriter, *http.Request) - Unflag func(http.ResponseWriter, *http.Request) - React func(http.ResponseWriter, *http.Request) - Unreact func(http.ResponseWriter, *http.Request) + Create func(http.ResponseWriter, *http.Request) + History func(http.ResponseWriter, *http.Request) + Edit func(http.ResponseWriter, *http.Request) + Delete func(http.ResponseWriter, *http.Request) + GetReplies func(http.ResponseWriter, *http.Request) + CreateReply func(http.ResponseWriter, *http.Request) + Pin func(http.ResponseWriter, *http.Request) + RemovePin func(http.ResponseWriter, *http.Request) + Bookmark func(http.ResponseWriter, *http.Request) + RemoveBookmark func(http.ResponseWriter, *http.Request) + React func(http.ResponseWriter, *http.Request) + RemoveReaction func(http.ResponseWriter, *http.Request) } func NewMessage(mh MessageAPI) *Message { @@ -87,13 +87,6 @@ func NewMessage(mh MessageAPI) *Message { return mh.Delete(r.Context(), params) }) }, - Pin: func(w http.ResponseWriter, r *http.Request) { - defer r.Body.Close() - params := request.NewMessagePin() - resputil.JSON(w, params.Fill(r), func() (interface{}, error) { - return mh.Pin(r.Context(), params) - }) - }, GetReplies: func(w http.ResponseWriter, r *http.Request) { defer r.Body.Close() params := request.NewMessageGetReplies() @@ -108,25 +101,32 @@ func NewMessage(mh MessageAPI) *Message { return mh.CreateReply(r.Context(), params) }) }, - Unpin: func(w http.ResponseWriter, r *http.Request) { + Pin: func(w http.ResponseWriter, r *http.Request) { defer r.Body.Close() - params := request.NewMessageUnpin() + params := request.NewMessagePin() resputil.JSON(w, params.Fill(r), func() (interface{}, error) { - return mh.Unpin(r.Context(), params) + return mh.Pin(r.Context(), params) }) }, - Flag: func(w http.ResponseWriter, r *http.Request) { + RemovePin: func(w http.ResponseWriter, r *http.Request) { defer r.Body.Close() - params := request.NewMessageFlag() + params := request.NewMessageRemovePin() resputil.JSON(w, params.Fill(r), func() (interface{}, error) { - return mh.Flag(r.Context(), params) + return mh.RemovePin(r.Context(), params) }) }, - Unflag: func(w http.ResponseWriter, r *http.Request) { + Bookmark: func(w http.ResponseWriter, r *http.Request) { defer r.Body.Close() - params := request.NewMessageUnflag() + params := request.NewMessageBookmark() resputil.JSON(w, params.Fill(r), func() (interface{}, error) { - return mh.Unflag(r.Context(), params) + return mh.Bookmark(r.Context(), params) + }) + }, + RemoveBookmark: func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := request.NewMessageRemoveBookmark() + resputil.JSON(w, params.Fill(r), func() (interface{}, error) { + return mh.RemoveBookmark(r.Context(), params) }) }, React: func(w http.ResponseWriter, r *http.Request) { @@ -136,11 +136,11 @@ func NewMessage(mh MessageAPI) *Message { return mh.React(r.Context(), params) }) }, - Unreact: func(w http.ResponseWriter, r *http.Request) { + RemoveReaction: func(w http.ResponseWriter, r *http.Request) { defer r.Body.Close() - params := request.NewMessageUnreact() + params := request.NewMessageRemoveReaction() resputil.JSON(w, params.Fill(r), func() (interface{}, error) { - return mh.Unreact(r.Context(), params) + return mh.RemoveReaction(r.Context(), params) }) }, } @@ -154,14 +154,14 @@ func (mh *Message) MountRoutes(r chi.Router, middlewares ...func(http.Handler) h r.Get("/", mh.History) r.Put("/{messageID}", mh.Edit) r.Delete("/{messageID}", mh.Delete) - r.Post("/{messageID}/pin", mh.Pin) r.Get("/{messageID}/replies", mh.GetReplies) r.Post("/{messageID}/replies", mh.CreateReply) - r.Delete("/{messageID}/pin", mh.Unpin) - r.Post("/{messageID}/flag", mh.Flag) - r.Delete("/{messageID}/flag", mh.Unflag) - r.Put("/{messageID}/reaction/{reaction}", mh.React) - r.Delete("/{messageID}/react/{reaction}", mh.Unreact) + r.Post("/{messageID}/pin", mh.Pin) + r.Delete("/{messageID}/pin", mh.RemovePin) + r.Post("/{messageID}/bookmark", mh.Bookmark) + r.Delete("/{messageID}/bookmark", mh.RemoveBookmark) + r.Post("/{messageID}/reaction/{reaction}", mh.React) + r.Delete("/{messageID}/reaction/{reaction}", mh.RemoveReaction) }) }) } diff --git a/sam/rest/message.go b/sam/rest/message.go index 3687eb866..210722743 100644 --- a/sam/rest/message.go +++ b/sam/rest/message.go @@ -72,24 +72,24 @@ func (ctrl *Message) Pin(ctx context.Context, r *request.MessagePin) (interface{ return nil, ctrl.svc.msg.With(ctx).Pin(r.MessageID) } -func (ctrl *Message) Unpin(ctx context.Context, r *request.MessageUnpin) (interface{}, error) { - return nil, ctrl.svc.msg.With(ctx).Unpin(r.MessageID) +func (ctrl *Message) RemovePin(ctx context.Context, r *request.MessageRemovePin) (interface{}, error) { + return nil, ctrl.svc.msg.With(ctx).RemovePin(r.MessageID) } -func (ctrl *Message) Flag(ctx context.Context, r *request.MessageFlag) (interface{}, error) { - return nil, ctrl.svc.msg.With(ctx).Flag(r.MessageID) +func (ctrl *Message) Bookmark(ctx context.Context, r *request.MessageBookmark) (interface{}, error) { + return nil, ctrl.svc.msg.With(ctx).Bookmark(r.MessageID) } -func (ctrl *Message) Unflag(ctx context.Context, r *request.MessageUnflag) (interface{}, error) { - return nil, ctrl.svc.msg.With(ctx).Unflag(r.MessageID) +func (ctrl *Message) RemoveBookmark(ctx context.Context, r *request.MessageRemoveBookmark) (interface{}, error) { + return nil, ctrl.svc.msg.With(ctx).RemoveBookmark(r.MessageID) } func (ctrl *Message) React(ctx context.Context, r *request.MessageReact) (interface{}, error) { return nil, ctrl.svc.msg.With(ctx).React(r.MessageID, r.Reaction) } -func (ctrl *Message) Unreact(ctx context.Context, r *request.MessageUnreact) (interface{}, error) { - return nil, ctrl.svc.msg.With(ctx).Unreact(r.MessageID, r.Reaction) +func (ctrl *Message) RemoveReaction(ctx context.Context, r *request.MessageRemoveReaction) (interface{}, error) { + return nil, ctrl.svc.msg.With(ctx).RemoveReaction(r.MessageID, r.Reaction) } func (ctrl *Message) wrap(ctx context.Context) func(m *types.Message, err error) (*outgoing.Message, error) { return func(m *types.Message, err error) (*outgoing.Message, error) { diff --git a/sam/rest/request/message.go b/sam/rest/request/message.go index f713bbe58..3ee1c8acb 100644 --- a/sam/rest/request/message.go +++ b/sam/rest/request/message.go @@ -221,51 +221,6 @@ func (m *MessageDelete) Fill(r *http.Request) (err error) { var _ RequestFiller = NewMessageDelete() -// Message pin request parameters -type MessagePin struct { - MessageID uint64 `json:",string"` - ChannelID uint64 `json:",string"` -} - -func NewMessagePin() *MessagePin { - return &MessagePin{} -} - -func (m *MessagePin) Fill(r *http.Request) (err error) { - if strings.ToLower(r.Header.Get("content-type")) == "application/json" { - err = json.NewDecoder(r.Body).Decode(m) - - switch { - case err == io.EOF: - err = nil - case err != nil: - return errors.Wrap(err, "error parsing http request body") - } - } - - if err = r.ParseForm(); err != nil { - return err - } - - get := map[string]string{} - post := map[string]string{} - urlQuery := r.URL.Query() - for name, param := range urlQuery { - get[name] = string(param[0]) - } - postVars := r.Form - for name, param := range postVars { - post[name] = string(param[0]) - } - - m.MessageID = parseUInt64(chi.URLParam(r, "messageID")) - m.ChannelID = parseUInt64(chi.URLParam(r, "channelID")) - - return err -} - -var _ RequestFiller = NewMessagePin() - // Message getReplies request parameters type MessageGetReplies struct { MessageID uint64 `json:",string"` @@ -361,17 +316,17 @@ func (m *MessageCreateReply) Fill(r *http.Request) (err error) { var _ RequestFiller = NewMessageCreateReply() -// Message unpin request parameters -type MessageUnpin struct { +// Message pin request parameters +type MessagePin struct { MessageID uint64 `json:",string"` ChannelID uint64 `json:",string"` } -func NewMessageUnpin() *MessageUnpin { - return &MessageUnpin{} +func NewMessagePin() *MessagePin { + return &MessagePin{} } -func (m *MessageUnpin) Fill(r *http.Request) (err error) { +func (m *MessagePin) Fill(r *http.Request) (err error) { if strings.ToLower(r.Header.Get("content-type")) == "application/json" { err = json.NewDecoder(r.Body).Decode(m) @@ -404,19 +359,19 @@ func (m *MessageUnpin) Fill(r *http.Request) (err error) { return err } -var _ RequestFiller = NewMessageUnpin() +var _ RequestFiller = NewMessagePin() -// Message flag request parameters -type MessageFlag struct { +// Message removePin request parameters +type MessageRemovePin struct { MessageID uint64 `json:",string"` ChannelID uint64 `json:",string"` } -func NewMessageFlag() *MessageFlag { - return &MessageFlag{} +func NewMessageRemovePin() *MessageRemovePin { + return &MessageRemovePin{} } -func (m *MessageFlag) Fill(r *http.Request) (err error) { +func (m *MessageRemovePin) Fill(r *http.Request) (err error) { if strings.ToLower(r.Header.Get("content-type")) == "application/json" { err = json.NewDecoder(r.Body).Decode(m) @@ -449,19 +404,19 @@ func (m *MessageFlag) Fill(r *http.Request) (err error) { return err } -var _ RequestFiller = NewMessageFlag() +var _ RequestFiller = NewMessageRemovePin() -// Message unflag request parameters -type MessageUnflag struct { +// Message bookmark request parameters +type MessageBookmark struct { MessageID uint64 `json:",string"` ChannelID uint64 `json:",string"` } -func NewMessageUnflag() *MessageUnflag { - return &MessageUnflag{} +func NewMessageBookmark() *MessageBookmark { + return &MessageBookmark{} } -func (m *MessageUnflag) Fill(r *http.Request) (err error) { +func (m *MessageBookmark) Fill(r *http.Request) (err error) { if strings.ToLower(r.Header.Get("content-type")) == "application/json" { err = json.NewDecoder(r.Body).Decode(m) @@ -494,7 +449,52 @@ func (m *MessageUnflag) Fill(r *http.Request) (err error) { return err } -var _ RequestFiller = NewMessageUnflag() +var _ RequestFiller = NewMessageBookmark() + +// Message removeBookmark request parameters +type MessageRemoveBookmark struct { + MessageID uint64 `json:",string"` + ChannelID uint64 `json:",string"` +} + +func NewMessageRemoveBookmark() *MessageRemoveBookmark { + return &MessageRemoveBookmark{} +} + +func (m *MessageRemoveBookmark) Fill(r *http.Request) (err error) { + if strings.ToLower(r.Header.Get("content-type")) == "application/json" { + err = json.NewDecoder(r.Body).Decode(m) + + switch { + case err == io.EOF: + err = nil + case err != nil: + return errors.Wrap(err, "error parsing http request body") + } + } + + if err = r.ParseForm(); err != nil { + return err + } + + get := map[string]string{} + post := map[string]string{} + urlQuery := r.URL.Query() + for name, param := range urlQuery { + get[name] = string(param[0]) + } + postVars := r.Form + for name, param := range postVars { + post[name] = string(param[0]) + } + + m.MessageID = parseUInt64(chi.URLParam(r, "messageID")) + m.ChannelID = parseUInt64(chi.URLParam(r, "channelID")) + + return err +} + +var _ RequestFiller = NewMessageRemoveBookmark() // Message react request parameters type MessageReact struct { @@ -543,18 +543,18 @@ func (m *MessageReact) Fill(r *http.Request) (err error) { var _ RequestFiller = NewMessageReact() -// Message unreact request parameters -type MessageUnreact struct { +// Message removeReaction request parameters +type MessageRemoveReaction struct { MessageID uint64 `json:",string"` Reaction string ChannelID uint64 `json:",string"` } -func NewMessageUnreact() *MessageUnreact { - return &MessageUnreact{} +func NewMessageRemoveReaction() *MessageRemoveReaction { + return &MessageRemoveReaction{} } -func (m *MessageUnreact) Fill(r *http.Request) (err error) { +func (m *MessageRemoveReaction) Fill(r *http.Request) (err error) { if strings.ToLower(r.Header.Get("content-type")) == "application/json" { err = json.NewDecoder(r.Body).Decode(m) @@ -588,4 +588,4 @@ func (m *MessageUnreact) Fill(r *http.Request) (err error) { return err } -var _ RequestFiller = NewMessageUnreact() +var _ RequestFiller = NewMessageRemoveReaction() diff --git a/sam/service/message.go b/sam/service/message.go index 1ee0941a8..cfe5185e4 100644 --- a/sam/service/message.go +++ b/sam/service/message.go @@ -23,7 +23,7 @@ type ( cmember repository.ChannelMemberRepository cview repository.ChannelViewRepository message repository.MessageRepository - reaction repository.ReactionRepository + mflag repository.MessageFlagRepository usr authService.UserService evl EventService @@ -39,13 +39,13 @@ type ( Update(messages *types.Message) (*types.Message, error) React(messageID uint64, reaction string) error - Unreact(messageID uint64, reaction string) error + RemoveReaction(messageID uint64, reaction string) error Pin(messageID uint64) error - Unpin(messageID uint64) error + RemovePin(messageID uint64) error - Flag(messageID uint64) error - Unflag(messageID uint64) error + Bookmark(messageID uint64) error + RemoveBookmark(messageID uint64) error Delete(ID uint64) error } @@ -76,7 +76,7 @@ func (svc *message) With(ctx context.Context) MessageService { cmember: repository.ChannelMember(ctx, db), cview: repository.ChannelView(ctx, db), message: repository.Message(ctx, db), - reaction: repository.Reaction(ctx, db), + mflag: repository.MessageFlag(ctx, db), } } @@ -92,9 +92,7 @@ func (svc *message) Find(filter *types.MessageFilter) (mm types.MessageSet, err return nil, err } - svc.preloadUsers(mm) - - return mm, svc.preloadAttachments(mm) + return mm, svc.preload(mm) } func (svc *message) FindThreads(filter *types.MessageFilter) (mm types.MessageSet, err error) { @@ -109,9 +107,7 @@ func (svc *message) FindThreads(filter *types.MessageFilter) (mm types.MessageSe return nil, err } - svc.preloadUsers(mm) - - return mm, svc.preloadAttachments(mm) + return mm, svc.preload(mm) } func (svc *message) Create(in *types.Message) (message *types.Message, err error) { @@ -290,80 +286,114 @@ func (svc *message) Delete(ID uint64) error { }) } +// React on a message with an emoji func (svc *message) React(messageID uint64, reaction string) error { - // @todo get user from context - var currentUserID uint64 = repository.Identity(svc.ctx) - - // @todo verify if current user can access & write to this channel - var m *types.Message - - // @todo validate reaction - - r := &types.Reaction{ - UserID: currentUserID, - MessageID: messageID, - ChannelID: m.ChannelID, - Reaction: reaction, - } - - if _, err := svc.reaction.CreateReaction(r); err != nil { - return err - } - - return nil + return svc.flag(messageID, reaction, false) } -func (svc *message) Unreact(messageID uint64, reaction string) error { - // @todo get user from context - var currentUserID uint64 = repository.Identity(svc.ctx) - - // @todo verify if current user can access & write to this channel - _ = currentUserID - - // @todo load reaction and verify ownership - var r *types.Reaction - - return svc.reaction.DeleteReactionByID(r.ID) +// Remove reaction on a message +func (svc *message) RemoveReaction(messageID uint64, reaction string) error { + return svc.flag(messageID, reaction, true) } +// Pin message to the channel func (svc *message) Pin(messageID uint64) error { - // @todo get user from context - var currentUserID uint64 = repository.Identity(svc.ctx) - - // @todo verify if current user can access & write to this channel - _ = currentUserID - - return nil + return svc.flag(messageID, types.MessageFlagPinnedToChannel, false) } -func (svc *message) Unpin(messageID uint64) error { - // @todo get user from context - var currentUserID uint64 = repository.Identity(svc.ctx) - - // @todo verify if current user can access & write to this channel - _ = currentUserID - - return nil +// Remove pin from message +func (svc *message) RemovePin(messageID uint64) error { + return svc.flag(messageID, types.MessageFlagPinnedToChannel, true) } -func (svc *message) Flag(messageID uint64) error { - // @todo get user from context - var currentUserID uint64 = repository.Identity(svc.ctx) - - // @todo verify if current user can access & write to this channel - _ = currentUserID - - return nil +// Bookmark message (private) +func (svc *message) Bookmark(messageID uint64) error { + return svc.flag(messageID, types.MessageFlagBookmarkedMessage, false) } -func (svc *message) Unflag(messageID uint64) error { +// Remove bookmark message (private) +func (svc *message) RemoveBookmark(messageID uint64) error { + return svc.flag(messageID, types.MessageFlagBookmarkedMessage, true) +} + +// React on a message with an emoji +func (svc *message) flag(messageID uint64, flag string, remove bool) error { // @todo get user from context var currentUserID uint64 = repository.Identity(svc.ctx) // @todo verify if current user can access & write to this channel _ = currentUserID - return nil + if strings.TrimSpace(flag) == "" { + // Sanitize + flag = types.MessageFlagPinnedToChannel + } + + // @todo validate flags beyond empty string + + err := svc.db.Transaction(func() (err error) { + var flagOwnerId = currentUserID + var f *types.MessageFlag + + // @todo [SECURITY] verify if current user can access & write to this channel + + if flag == types.MessageFlagPinnedToChannel { + // It does not matter how is the owner of the pin, + flagOwnerId = 0 + } + + f, err = svc.mflag.FindByFlag(messageID, flagOwnerId, flag) + if f.ID == 0 && remove { + // Skip removing, flag does not exists + return nil + } else if f.ID > 0 && !remove { + // Skip adding, flag already exists + return nil + } else if err != nil && err != repository.ErrMessageFlagNotFound { + // Other errors, exit + return + } + + // Check message + var msg *types.Message + msg, err = svc.message.FindMessageByID(messageID) + if err != nil { + return + } + + if remove { + err = svc.mflag.DeleteByID(f.ID) + } else { + _, err = svc.mflag.Create(&types.MessageFlag{ + UserID: currentUserID, + ChannelID: msg.ChannelID, + MessageID: msg.ID, + Flag: flag, + }) + } + + svc.sendEvent(msg) + + return + }) + + return errors.Wrap(err, "Can not flag/un-flag message") +} + +func (svc *message) preload(mm types.MessageSet) (err error) { + if err = svc.preloadUsers(mm); err != nil { + return + } + + if err = svc.preloadAttachments(mm); err != nil { + return + } + + if err = svc.preloadFlags(mm); err != nil { + return + } + + return } // Preload for all messages @@ -388,6 +418,21 @@ func (svc *message) preloadUsers(mm types.MessageSet) (err error) { return } +// Preload for all messages +func (svc *message) preloadFlags(mm types.MessageSet) (err error) { + var ff types.MessageFlagSet + + ff, err = svc.mflag.FindByMessageIDs(mm.IDs()...) + if err != nil { + return + } + + return ff.Walk(func(flag *types.MessageFlag) error { + mm.FindById(flag.MessageID).Flags = append(mm.FindById(flag.MessageID).Flags, flag) + return nil + }) +} + func (svc *message) preloadAttachments(mm types.MessageSet) (err error) { var ( ids []uint64 @@ -422,8 +467,9 @@ func (svc *message) preloadAttachments(mm types.MessageSet) (err error) { // Sends message to event loop func (svc *message) sendEvent(mm ...*types.Message) (err error) { - svc.preloadAttachments(mm) - svc.preloadUsers(mm) + if err = svc.preload(mm); err != nil { + return + } for _, msg := range mm { if msg.User == nil { diff --git a/sam/types/message.go b/sam/types/message.go index 08e039149..d3d53dbb5 100644 --- a/sam/types/message.go +++ b/sam/types/message.go @@ -21,6 +21,7 @@ type ( DeletedAt *time.Time `json:"deletedAt,omitempty" db:"deleted_at"` Attachment *Attachment `json:"attachment,omitempty"` User *authTypes.User `json:"user,omitempty"` + Flags MessageFlagSet `json:"flags,omitempty"` } MessageSet []*Message @@ -85,6 +86,16 @@ func (mm MessageSet) FindById(ID uint64) *Message { return nil } +func (mm MessageSet) IDs() (IDs []uint64) { + IDs = make([]uint64, len(mm)) + + for i := range mm { + IDs[i] = mm[i].ID + } + + return +} + func (mtype MessageType) String() string { return string(mtype) } diff --git a/sam/types/message_flag.go b/sam/types/message_flag.go new file mode 100644 index 000000000..2b8ea5577 --- /dev/null +++ b/sam/types/message_flag.go @@ -0,0 +1,75 @@ +package types + +import ( + "time" +) + +type ( + MessageFlag struct { + ID uint64 `json:"id" db:"id"` + UserID uint64 `json:"userId" db:"rel_user"` + MessageID uint64 `json:"messageId" db:"rel_message"` + ChannelID uint64 `json:"channelId" db:"rel_channel"` + Flag string `json:"flag" db:"flag"` + CreatedAt time.Time `json:"createdAt,omitempty" db:"created_at"` + } + + MessageFlagSet []*MessageFlag +) + +const ( + MessageFlagPinnedToChannel string = "pin" + MessageFlagBookmarkedMessage string = "bookmark" +) + +func (ff MessageFlagSet) Walk(w func(*MessageFlag) error) (err error) { + for i := range ff { + if err = w(ff[i]); err != nil { + return + } + } + + return +} + +func (ff MessageFlagSet) FindById(ID uint64) *MessageFlag { + for i := range ff { + if ff[i].ID == ID { + return ff[i] + } + } + + return nil +} + +func (ff MessageFlagSet) IsBookmarked(UserID uint64) bool { + for i := range ff { + if ff[i].UserID == UserID && ff[i].IsBookmark() { + return true + } + } + + return false +} + +func (ff MessageFlagSet) IsPinned() bool { + for i := range ff { + if ff[i].IsPin() { + return true + } + } + + return false +} + +func (f *MessageFlag) IsReaction() bool { + return f.Flag != MessageFlagPinnedToChannel && f.Flag != MessageFlagBookmarkedMessage +} + +func (f *MessageFlag) IsPin() bool { + return f.Flag == MessageFlagPinnedToChannel +} + +func (f *MessageFlag) IsBookmark() bool { + return f.Flag == MessageFlagBookmarkedMessage +} diff --git a/sam/types/reaction.go b/sam/types/reaction.go deleted file mode 100644 index f9009d21c..000000000 --- a/sam/types/reaction.go +++ /dev/null @@ -1,16 +0,0 @@ -package types - -import ( - "time" -) - -type ( - Reaction struct { - ID uint64 `json:"id" db:"id"` - UserID uint64 `json:"userId" db:"rel_user"` - MessageID uint64 `json:"messageId" db:"rel_message"` - ChannelID uint64 `json:"channelId" db:"rel_channel"` - Reaction string `json:"reaction" db:"reaction"` - CreatedAt time.Time `json:"createdAt,omitempty" db:"created_at"` - } -)