From 4f13e8304f62ca99324416506d7ebe15a8c7891d Mon Sep 17 00:00:00 2001 From: Denis Arh Date: Fri, 19 Oct 2018 15:07:09 +0200 Subject: [PATCH] Implement message threads --- auth/repository/user.go | 5 +- internal/payload/incoming/messages.go | 15 +-- internal/payload/outgoing.go | 5 +- internal/payload/outgoing/message.go | 5 +- sam/db/mysql/statik.go | 2 +- .../mysql/20181013080000.replies.up.sql | 2 + sam/docs/README.md | 29 ++++++ sam/docs/src/spec.json | 25 +++++ sam/docs/src/spec/message.json | 40 ++++++++ sam/repository/attachment_test.go | 23 +++-- sam/repository/channel_test.go | 9 +- sam/repository/message.go | 58 +++++++++--- sam/repository/message_test.go | 79 +++++++++++++++- sam/repository/organisation.go | 2 +- sam/repository/organisation_test.go | 9 +- sam/repository/reaction_test.go | 9 +- sam/repository/repository_test.go | 19 +++- sam/repository/team.go | 2 +- sam/repository/team_test.go | 9 +- sam/rest/handlers/message.go | 42 ++++++--- sam/rest/message.go | 20 +++- sam/rest/request/message.go | 93 +++++++++++++++++++ sam/service/channel_mock_test.go | 93 ++++++++++++++++--- sam/service/message.go | 45 ++++++++- sam/service/message_mock_test.go | 13 --- sam/types/message.go | 21 ++++- sam/websocket/session_incoming_command.go | 2 +- sam/websocket/session_incoming_message.go | 16 +++- 28 files changed, 583 insertions(+), 109 deletions(-) create mode 100644 sam/db/schema/mysql/20181013080000.replies.up.sql diff --git a/auth/repository/user.go b/auth/repository/user.go index d7d08c6f6..c7d98f963 100644 --- a/auth/repository/user.go +++ b/auth/repository/user.go @@ -2,9 +2,10 @@ package repository import ( "context" + "time" + "github.com/crusttech/crust/auth/types" "github.com/titpetric/factory" - "time" ) type ( @@ -114,5 +115,5 @@ func (r *user) UnsuspendUserByID(id uint64) error { } func (r *user) DeleteUserByID(id uint64) error { - return r.updateColumnByID("users", "deleted_at", nil, id) + return r.updateColumnByID("users", "deleted_at", time.Now(), id) } diff --git a/internal/payload/incoming/messages.go b/internal/payload/incoming/messages.go index da88e8cd5..1a7e8edb5 100644 --- a/internal/payload/incoming/messages.go +++ b/internal/payload/incoming/messages.go @@ -2,23 +2,24 @@ package incoming type ( MessageCreate struct { - ChannelID string `json:"channelId"` + ChannelID string `json:"channelID"` + ReplyTo uint64 `json:"replyTo,omitempty,string"` Message string `json:"message"` } MessageUpdate struct { - ID string `json:"id"` + ID string `json:"messageID"` Message string `json:"message"` } MessageDelete struct { - ChannelID string `json:"channelId"` - ID string `json:"id"` + ID string `json:"messageID"` } Messages struct { - ChannelID string `json:"channelId"` - FromID string `json:"fromId,omitempty"` - UntilID string `json:"untilId,omitempty"` + ChannelID uint64 `json:"channelId,string"` + FirstID uint64 `json:"firstID,string"` + LastID uint64 `json:"lastID,string"` + RepliesTo uint64 `json:"repliesTo,string"` } ) diff --git a/internal/payload/outgoing.go b/internal/payload/outgoing.go index 2cd145e55..317c58a0a 100644 --- a/internal/payload/outgoing.go +++ b/internal/payload/outgoing.go @@ -16,11 +16,12 @@ const ( func Message(msg *sam.Message) *outgoing.Message { return &outgoing.Message{ - ID: Uint64toa(msg.ID), + ID: msg.ID, ChannelID: Uint64toa(msg.ChannelID), Message: msg.Message, Type: string(msg.Type), - ReplyTo: Uint64toa(msg.ReplyTo), + ReplyTo: msg.ReplyTo, + Replies: msg.Replies, User: User(msg.User), Attachment: Attachment(msg.Attachment), diff --git a/internal/payload/outgoing/message.go b/internal/payload/outgoing/message.go index 99ed72a01..fb23f58de 100644 --- a/internal/payload/outgoing/message.go +++ b/internal/payload/outgoing/message.go @@ -7,11 +7,12 @@ import ( type ( Message struct { - ID string `json:"ID"` + ID uint64 `json:"ID,string"` Type string `json:"type"` Message string `json:"message"` ChannelID string `json:"channelID"` - ReplyTo string `json:"replyID"` + ReplyTo uint64 `json:"replyTo,omitempty,string"` + Replies uint `json:"replies,omitempty"` User *User `json:"user"` Attachment *Attachment `json:"att,omitempty"` diff --git a/sam/db/mysql/statik.go b/sam/db/mysql/statik.go index e95a23d13..40c85f9d9 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\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!(\x0d\xa5T2x\x01\x00\x00x\x01\x00\x00\x0e\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\xa4\x81\xf0\x1b\x00\x00migrations.sqlUT\x05\x00\x01\x80Cm8PK\x05\x06\x00\x00\x00\x00\x04\x00\x04\x00K\x01\x00\x00\xad\x1d\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\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" fs.Register(data) } diff --git a/sam/db/schema/mysql/20181013080000.replies.up.sql b/sam/db/schema/mysql/20181013080000.replies.up.sql new file mode 100644 index 000000000..958d00ffe --- /dev/null +++ b/sam/db/schema/mysql/20181013080000.replies.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE messages CHANGE reply_to reply_to BIGINT UNSIGNED NOT NULL DEFAULT 0; +ALTER TABLE messages ADD replies INT UNSIGNED NOT NULL DEFAULT 0; diff --git a/sam/docs/README.md b/sam/docs/README.md index 1b667f506..905c836db 100644 --- a/sam/docs/README.md +++ b/sam/docs/README.md @@ -481,6 +481,35 @@ The following event types may be sent with a message event: | --------- | ---- | ------ | ----------- | ------- | --------- | | messageID | uint64 | PATH | Message ID | N/A | YES | +## Returns all replies to a message + +#### Method + +| URI | Protocol | Method | Authentication | +| --- | -------- | ------ | -------------- | +| `/channels/{channelID}/messages/{messageID}/replies` | HTTP/S | GET | Client ID, Session ID | + +#### Request parameters + +| Parameter | Type | Method | Description | Default | Required? | +| --------- | ---- | ------ | ----------- | ------- | --------- | +| messageID | uint64 | PATH | Message ID | N/A | YES | + +## Reply to a message + +#### Method + +| URI | Protocol | Method | Authentication | +| --- | -------- | ------ | -------------- | +| `/channels/{channelID}/messages/{messageID}/replies` | HTTP/S | POST | Client ID, Session ID | + +#### Request parameters + +| Parameter | Type | Method | Description | Default | Required? | +| --------- | ---- | ------ | ----------- | ------- | --------- | +| messageID | uint64 | PATH | Message ID | N/A | YES | +| message | string | POST | Message contents (markdown) | N/A | YES | + ## Pin message to channel (public bookmark) #### Method diff --git a/sam/docs/src/spec.json b/sam/docs/src/spec.json index e4ac2919f..1f382f41d 100644 --- a/sam/docs/src/spec.json +++ b/sam/docs/src/spec.json @@ -413,6 +413,31 @@ ] } }, + { + "name": "getReplies", + "path": "/{messageID}/replies", + "method": "GET", + "title": "Returns all replies to a message", + "parameters": { + "path": [ + { "name": "messageID", "type": "uint64", "required": true, "title": "Message ID" } + ] + } + }, + { + "name": "createReply", + "path": "/{messageID}/replies", + "method": "POST", + "title": "Reply to a message", + "parameters": { + "path": [ + { "name": "messageID", "type": "uint64", "required": true, "title": "Message ID" } + ], + "post": [ + { "type": "string", "name": "message", "required": true, "title": "Message contents (markdown)" } + ] + } + }, { "name": "unpin", "path": "/{messageID}/pin", diff --git a/sam/docs/src/spec/message.json b/sam/docs/src/spec/message.json index d3592debc..3a8c10e40 100644 --- a/sam/docs/src/spec/message.json +++ b/sam/docs/src/spec/message.json @@ -136,6 +136,46 @@ ] } }, + { + "Name": "getReplies", + "Method": "GET", + "Title": "Returns all replies to a message", + "Path": "/{messageID}/replies", + "Parameters": { + "path": [ + { + "name": "messageID", + "required": true, + "title": "Message ID", + "type": "uint64" + } + ] + } + }, + { + "Name": "createReply", + "Method": "POST", + "Title": "Reply to a message", + "Path": "/{messageID}/replies", + "Parameters": { + "path": [ + { + "name": "messageID", + "required": true, + "title": "Message ID", + "type": "uint64" + } + ], + "post": [ + { + "name": "message", + "required": true, + "title": "Message contents (markdown)", + "type": "string" + } + ] + } + }, { "Name": "unpin", "Method": "DELETE", diff --git a/sam/repository/attachment_test.go b/sam/repository/attachment_test.go index 89f9dadc1..5c5790414 100644 --- a/sam/repository/attachment_test.go +++ b/sam/repository/attachment_test.go @@ -1,8 +1,13 @@ package repository import ( - "github.com/crusttech/crust/sam/types" + "context" + + "github.com/titpetric/factory" + "testing" + + "github.com/crusttech/crust/sam/types" ) func TestAttachment(t *testing.T) { @@ -13,28 +18,26 @@ func TestAttachment(t *testing.T) { return } - rpo := New() + rpo := Attachment(context.Background(), factory.Database.MustGet()) att := &types.Attachment{} - var aa []*types.Attachment - - att.ChannelID = 1 + att.UserID = 1 { att, err = rpo.CreateAttachment(att) assert(t, err == nil, "CreateAttachment error: %v", err) - assert(t, att.ChannelID == 1, "Changes were not stored") + assert(t, att.UserID == 1, "Changes were not stored") { att, err = rpo.FindAttachmentByID(att.ID) assert(t, err == nil, "FindAttachmentByID error: %v", err) - assert(t, att.ChannelID == 2, "Changes were not stored") + assert(t, att.UserID == 1, "Changes were not stored") } { - aa, err = rpo.FindAttachmentByRange(2, 0, att.ID) - assert(t, err == nil, "FindAttachmentByRange error: %v", err) - assert(t, len(aa) > 0, "No results found") + att, err = rpo.FindAttachmentByID(att.ID) + assert(t, err == nil, "FindAttachmentByMessageID error: %v", err) + assert(t, att != nil, "No results found") } { diff --git a/sam/repository/channel_test.go b/sam/repository/channel_test.go index 2e6cd5098..80f05d3ce 100644 --- a/sam/repository/channel_test.go +++ b/sam/repository/channel_test.go @@ -1,8 +1,13 @@ package repository import ( - "github.com/crusttech/crust/sam/types" + "context" + + "github.com/titpetric/factory" + "testing" + + "github.com/crusttech/crust/sam/types" ) func TestChannel(t *testing.T) { @@ -13,7 +18,7 @@ func TestChannel(t *testing.T) { return } - rpo := New() + rpo := Channel(context.Background(), factory.Database.MustGet()) chn := &types.Channel{} var name1, name2 = "Test channel v1", "Test channel v2" diff --git a/sam/repository/message.go b/sam/repository/message.go index e17920bc9..b20e96725 100644 --- a/sam/repository/message.go +++ b/sam/repository/message.go @@ -17,7 +17,9 @@ type ( FindMessages(filter *types.MessageFilter) (types.MessageSet, error) CreateMessage(mod *types.Message) (*types.Message, error) UpdateMessage(mod *types.Message) (*types.Message, error) - DeleteMessageByID(id uint64) error + DeleteMessageByID(ID uint64) error + IncReplyCount(ID uint64) error + DecReplyCount(ID uint64) error } message struct { @@ -26,6 +28,8 @@ type ( ) const ( + MESSAGES_MAX_LIMIT = 100 + sqlMessageScope = "deleted_at IS NULL" sqlMessagesSelect = `SELECT id, @@ -33,13 +37,17 @@ const ( message, rel_user, rel_channel, - COALESCE(reply_to, 0) AS reply_to, + reply_to, + replies, created_at, updated_at, deleted_at FROM messages WHERE ` + sqlMessageScope + sqlMessageRepliesIncCount = `UPDATE messages SET replies = replies + 1 WHERE id = ? AND reply_to = 0` + sqlMessageRepliesDecCount = `UPDATE messages SET replies = replies - 1 WHERE id = ? AND reply_to = 0` + ErrMessageNotFound = repositoryError("MessageNotFound") ) @@ -78,23 +86,35 @@ func (r *message) FindMessages(filter *types.MessageFilter) (types.MessageSet, e params = append(params, filter.ChannelID) } - if filter.FromMessageID > 0 { - sql += " AND id > ? " - params = append(params, filter.FromMessageID) + if filter.RepliesTo > 0 { + sql += " AND reply_to = ? " + params = append(params, filter.RepliesTo) + } else { + sql += " AND reply_to = 0 " } - if filter.UntilMessageID > 0 { - sql += " AND id < ? " - params = append(params, filter.UntilMessageID) + if filter.FirstID > 0 || filter.LastID > 0 { + // Fetching (exclusively) range of messages, without reply + if filter.FirstID > 0 { + sql += " AND id > ? " + params = append(params, filter.FirstID) + } + + if filter.LastID > 0 { + sql += " AND id < ? " + params = append(params, filter.LastID) + } } sql += " ORDER BY id DESC" - if filter.Limit > 0 { - // @todo implement some kind of protection - sql += " LIMIT ? " - params = append(params, filter.Limit) + if filter.Limit == 0 || filter.Limit > MESSAGES_MAX_LIMIT { + filter.Limit = MESSAGES_MAX_LIMIT } + + sql += " LIMIT ? " + params = append(params, filter.Limit) + return rval, r.db().Select(&rval, sql, params...) } @@ -111,6 +131,16 @@ func (r *message) UpdateMessage(mod *types.Message) (*types.Message, error) { return mod, r.db().Replace("messages", mod) } -func (r *message) DeleteMessageByID(id uint64) error { - return r.updateColumnByID("messages", "deleted_at", nil, id) +func (r *message) DeleteMessageByID(ID uint64) error { + return r.updateColumnByID("messages", "deleted_at", time.Now(), ID) +} + +func (r *message) IncReplyCount(ID uint64) error { + _, err := r.db().Exec(sqlMessageRepliesIncCount, ID) + return err +} + +func (r *message) DecReplyCount(ID uint64) error { + _, err := r.db().Exec(sqlMessageRepliesDecCount, ID) + return err } diff --git a/sam/repository/message_test.go b/sam/repository/message_test.go index b5c8c80ca..234e3c92c 100644 --- a/sam/repository/message_test.go +++ b/sam/repository/message_test.go @@ -1,8 +1,13 @@ package repository import ( - "github.com/crusttech/crust/sam/types" + "context" + + "github.com/titpetric/factory" + "testing" + + "github.com/crusttech/crust/sam/types" ) func TestMessage(t *testing.T) { @@ -13,7 +18,7 @@ func TestMessage(t *testing.T) { return } - rpo := New() + rpo := Message(context.Background(), factory.Database.MustGet()) msg := &types.Message{} var msg1, msg2 = "Test message v1", "Test message v2" @@ -35,7 +40,7 @@ func TestMessage(t *testing.T) { { msg, err = rpo.FindMessageByID(msg.ID) - assert(t, err == nil, "FFindMessageByID error: %v", err) + assert(t, err == nil, "FindMessageByID error: %v", err) assert(t, msg.Message == msg2, "Changes were not stored") } @@ -51,3 +56,71 @@ func TestMessage(t *testing.T) { } } } + +func TestReplies(t *testing.T) { + var err error + + if testing.Short() { + t.Skip("skipping test in short mode.") + return + } + + chID := factory.Sonyflake.NextID() + + rpo := Message(context.Background(), factory.Database.MustGet()) + msg := &types.Message{ChannelID: chID} + rpl := &types.Message{ChannelID: chID} + + var mm types.MessageSet + + tx(t, func() error { + msg, err = rpo.CreateMessage(msg) + assert(t, err == nil, "CreateMessage error: %v", err) + assert(t, msg.ID > 0, "Message did not get its ID") + + rpl.ReplyTo = msg.ID + rpl, err = rpo.CreateMessage(rpl) + assert(t, err == nil, "CreateMessage error: %v", err) + assert(t, rpl.ID > 0, "Reply did not get its ID") + + { + mm, err = rpo.FindMessages(&types.MessageFilter{ + RepliesTo: msg.ID, + ChannelID: chID, + }) + + assert(t, err == nil, "FindMessages error: %v", err) + assert(t, len(mm) == 1, "Failed to fetch only reply") + assert(t, mm[0].ID == rpl.ID, "Reply ID does not match") + } + + { + mm, err = rpo.FindMessages(&types.MessageFilter{ + ChannelID: chID, + }) + + assert(t, err == nil, "FindMessages error: %v", err) + assert(t, len(mm) == 1, "Failed to fetch only original message") + assert(t, mm[0].ID == msg.ID, "Reply ID does not match") + } + + { + rpo.IncReplyCount(msg.ID) + rpo.IncReplyCount(msg.ID) + rpo.IncReplyCount(msg.ID) + + msg, err = rpo.FindMessageByID(msg.ID) + assert(t, err == nil, "FindMessageByID error: %v", err) + assert(t, msg.Replies == 3, "Reply counter check failed, expecting 3, got %v", msg.Replies) + + rpo.DecReplyCount(msg.ID) + rpo.DecReplyCount(msg.ID) + + msg, err = rpo.FindMessageByID(msg.ID) + assert(t, err == nil, "FindMessageByID error: %v", err) + assert(t, msg.Replies == 1, "Reply counter check failed, expecting 1, got %v", msg.Replies) + } + + return nil + }) +} diff --git a/sam/repository/organisation.go b/sam/repository/organisation.go index d7f2a0af8..c2b99a819 100644 --- a/sam/repository/organisation.go +++ b/sam/repository/organisation.go @@ -89,5 +89,5 @@ func (r *organisation) UnarchiveOrganisationByID(id uint64) error { } func (r *organisation) DeleteOrganisationByID(id uint64) error { - return r.updateColumnByID("organisations", "deleted_at", nil, id) + return r.updateColumnByID("organisations", "deleted_at", time.Now(), id) } diff --git a/sam/repository/organisation_test.go b/sam/repository/organisation_test.go index a7ebf7f59..142e242c3 100644 --- a/sam/repository/organisation_test.go +++ b/sam/repository/organisation_test.go @@ -1,8 +1,13 @@ package repository import ( - "github.com/crusttech/crust/sam/types" + "context" + + "github.com/titpetric/factory" + "testing" + + "github.com/crusttech/crust/sam/types" ) func TestOrganisation(t *testing.T) { @@ -13,7 +18,7 @@ func TestOrganisation(t *testing.T) { return } - rpo := New() + rpo := Organisation(context.Background(), factory.Database.MustGet()) org := &types.Organisation{} var name1, name2 = "Test organisation v1", "Test organisation v2" diff --git a/sam/repository/reaction_test.go b/sam/repository/reaction_test.go index 6c5aad126..c9a0717ee 100644 --- a/sam/repository/reaction_test.go +++ b/sam/repository/reaction_test.go @@ -1,8 +1,13 @@ package repository import ( - "github.com/crusttech/crust/sam/types" + "context" + + "github.com/titpetric/factory" + "testing" + + "github.com/crusttech/crust/sam/types" ) func TestReaction(t *testing.T) { @@ -13,7 +18,7 @@ func TestReaction(t *testing.T) { return } - rpo := New() + rpo := Reaction(context.Background(), factory.Database.MustGet()) react := &types.Reaction{} var reaction = ":laugh:" diff --git a/sam/repository/repository_test.go b/sam/repository/repository_test.go index 8db675bf1..956b13b52 100644 --- a/sam/repository/repository_test.go +++ b/sam/repository/repository_test.go @@ -2,10 +2,23 @@ package repository import ( "context" + "testing" ) -func TestEvents(t *testing.T) { - repo := &repository{} - repo.With(context.Background(), nil) +func tx(t *testing.T, f func() error) { + db := DB(context.Background()) + + if err := db.Begin(); err != nil { + t.Errorf("Could not begin transaction: %v", err) + + } + + if err := f(); err != nil { + t.Errorf("Test transaction resulted in an error: %v", err) + } + + if err := db.Rollback(); err != nil { + t.Errorf("Could not rollback transaction: %v", err) + } } diff --git a/sam/repository/team.go b/sam/repository/team.go index d36c45f63..889568dcd 100644 --- a/sam/repository/team.go +++ b/sam/repository/team.go @@ -92,7 +92,7 @@ func (r *team) UnarchiveTeamByID(id uint64) error { } func (r *team) DeleteTeamByID(id uint64) error { - return r.updateColumnByID("teams", "deleted_at", nil, id) + return r.updateColumnByID("teams", "deleted_at", time.Now(), id) } func (r *team) MergeTeamByID(id, targetTeamID uint64) error { diff --git a/sam/repository/team_test.go b/sam/repository/team_test.go index 4e6affd09..bcaa22981 100644 --- a/sam/repository/team_test.go +++ b/sam/repository/team_test.go @@ -1,8 +1,13 @@ package repository import ( - "github.com/crusttech/crust/sam/types" + "context" + + "github.com/titpetric/factory" + "testing" + + "github.com/crusttech/crust/sam/types" ) func TestTeam(t *testing.T) { @@ -13,7 +18,7 @@ func TestTeam(t *testing.T) { return } - rpo := New() + rpo := Team(context.Background(), factory.Database.MustGet()) team := &types.Team{} var name1, name2 = "Test team v1", "Test team v2" diff --git a/sam/rest/handlers/message.go b/sam/rest/handlers/message.go index a925e0ac9..bf24c46be 100644 --- a/sam/rest/handlers/message.go +++ b/sam/rest/handlers/message.go @@ -33,6 +33,8 @@ type MessageAPI interface { Delete(context.Context, *request.MessageDelete) (interface{}, error) Search(context.Context, *request.MessageSearch) (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) @@ -42,17 +44,19 @@ type MessageAPI interface { // 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) - Search func(http.ResponseWriter, *http.Request) - Pin 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) + Search 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) } func NewMessage(mh MessageAPI) *Message { @@ -99,6 +103,20 @@ func NewMessage(mh MessageAPI) *Message { return mh.Pin(r.Context(), params) }) }, + GetReplies: func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := request.NewMessageGetReplies() + resputil.JSON(w, params.Fill(r), func() (interface{}, error) { + return mh.GetReplies(r.Context(), params) + }) + }, + CreateReply: func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := request.NewMessageCreateReply() + resputil.JSON(w, params.Fill(r), func() (interface{}, error) { + return mh.CreateReply(r.Context(), params) + }) + }, Unpin: func(w http.ResponseWriter, r *http.Request) { defer r.Body.Close() params := request.NewMessageUnpin() @@ -147,6 +165,8 @@ func (mh *Message) MountRoutes(r chi.Router, middlewares ...func(http.Handler) h r.Delete("/{messageID}", mh.Delete) r.Get("/search", mh.Search) 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) diff --git a/sam/rest/message.go b/sam/rest/message.go index 98cf673db..692558353 100644 --- a/sam/rest/message.go +++ b/sam/rest/message.go @@ -34,10 +34,25 @@ func (ctrl *Message) Create(ctx context.Context, r *request.MessageCreate) (inte })) } +func (ctrl *Message) CreateReply(ctx context.Context, r *request.MessageCreateReply) (interface{}, error) { + return ctrl.wrap(ctrl.svc.msg.With(ctx).Create(&types.Message{ + ChannelID: r.ChannelID, + ReplyTo: r.MessageID, + Message: r.Message, + })) +} + +func (ctrl *Message) GetReplies(ctx context.Context, r *request.MessageGetReplies) (interface{}, error) { + return ctrl.wrapSet(ctrl.svc.msg.With(ctx).Find(&types.MessageFilter{ + ChannelID: r.ChannelID, + RepliesTo: r.MessageID, + })) +} + func (ctrl *Message) History(ctx context.Context, r *request.MessageHistory) (interface{}, error) { return ctrl.wrapSet(ctrl.svc.msg.With(ctx).Find(&types.MessageFilter{ - ChannelID: r.ChannelID, - FromMessageID: r.LastMessageID, + ChannelID: r.ChannelID, + FirstID: r.LastMessageID, })) } @@ -83,7 +98,6 @@ func (ctrl *Message) React(ctx context.Context, r *request.MessageReact) (interf 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) wrap(m *types.Message, err error) (*outgoing.Message, error) { if err != nil { return nil, err diff --git a/sam/rest/request/message.go b/sam/rest/request/message.go index 0b4f4e351..3580d8cc1 100644 --- a/sam/rest/request/message.go +++ b/sam/rest/request/message.go @@ -313,6 +313,99 @@ func (m *MessagePin) Fill(r *http.Request) error { var _ RequestFiller = NewMessagePin() +// Message getReplies request parameters +type MessageGetReplies struct { + MessageID uint64 `json:",string"` + ChannelID uint64 `json:",string"` +} + +func NewMessageGetReplies() *MessageGetReplies { + return &MessageGetReplies{} +} + +func (m *MessageGetReplies) Fill(r *http.Request) error { + var 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") + } + } + + r.ParseForm() + 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 = NewMessageGetReplies() + +// Message createReply request parameters +type MessageCreateReply struct { + MessageID uint64 `json:",string"` + ChannelID uint64 `json:",string"` + Message string +} + +func NewMessageCreateReply() *MessageCreateReply { + return &MessageCreateReply{} +} + +func (m *MessageCreateReply) Fill(r *http.Request) error { + var 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") + } + } + + r.ParseForm() + 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")) + if val, ok := post["message"]; ok { + + m.Message = val + } + + return err +} + +var _ RequestFiller = NewMessageCreateReply() + // Message unpin request parameters type MessageUnpin struct { MessageID uint64 `json:",string"` diff --git a/sam/service/channel_mock_test.go b/sam/service/channel_mock_test.go index 11df8cffb..4791c431f 100644 --- a/sam/service/channel_mock_test.go +++ b/sam/service/channel_mock_test.go @@ -72,6 +72,32 @@ func (mr *MockChannelServiceMockRecorder) Find(filter interface{}) *gomock.Call return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Find", reflect.TypeOf((*MockChannelService)(nil).Find), filter) } +// Create mocks base method +func (m *MockChannelService) Create(channel *types.Channel) (*types.Channel, error) { + ret := m.ctrl.Call(m, "Create", channel) + ret0, _ := ret[0].(*types.Channel) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Create indicates an expected call of Create +func (mr *MockChannelServiceMockRecorder) Create(channel interface{}) *gomock.Call { + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockChannelService)(nil).Create), channel) +} + +// Update mocks base method +func (m *MockChannelService) Update(channel *types.Channel) (*types.Channel, error) { + ret := m.ctrl.Call(m, "Update", channel) + ret0, _ := ret[0].(*types.Channel) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Update indicates an expected call of Update +func (mr *MockChannelServiceMockRecorder) Update(channel interface{}) *gomock.Call { + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockChannelService)(nil).Update), channel) +} + // FindByMembership mocks base method func (m *MockChannelService) FindByMembership() ([]*types.Channel, error) { ret := m.ctrl.Call(m, "FindByMembership") @@ -98,30 +124,57 @@ func (mr *MockChannelServiceMockRecorder) FindMembers(channelID interface{}) *go return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindMembers", reflect.TypeOf((*MockChannelService)(nil).FindMembers), channelID) } -// Create mocks base method -func (m *MockChannelService) Create(channel *types.Channel) (*types.Channel, error) { - ret := m.ctrl.Call(m, "Create", channel) - ret0, _ := ret[0].(*types.Channel) +// InviteUser mocks base method +func (m *MockChannelService) InviteUser(channelID uint64, memberIDs ...uint64) (types.ChannelMemberSet, error) { + varargs := []interface{}{channelID} + for _, a := range memberIDs { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "InviteUser", varargs...) + ret0, _ := ret[0].(types.ChannelMemberSet) ret1, _ := ret[1].(error) return ret0, ret1 } -// Create indicates an expected call of Create -func (mr *MockChannelServiceMockRecorder) Create(channel interface{}) *gomock.Call { - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockChannelService)(nil).Create), channel) +// InviteUser indicates an expected call of InviteUser +func (mr *MockChannelServiceMockRecorder) InviteUser(channelID interface{}, memberIDs ...interface{}) *gomock.Call { + varargs := append([]interface{}{channelID}, memberIDs...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InviteUser", reflect.TypeOf((*MockChannelService)(nil).InviteUser), varargs...) } -// Update mocks base method -func (m *MockChannelService) Update(channel *types.Channel) (*types.Channel, error) { - ret := m.ctrl.Call(m, "Update", channel) - ret0, _ := ret[0].(*types.Channel) +// AddMember mocks base method +func (m *MockChannelService) AddMember(channelID uint64, memberIDs ...uint64) (types.ChannelMemberSet, error) { + varargs := []interface{}{channelID} + for _, a := range memberIDs { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "AddMember", varargs...) + ret0, _ := ret[0].(types.ChannelMemberSet) ret1, _ := ret[1].(error) return ret0, ret1 } -// Update indicates an expected call of Update -func (mr *MockChannelServiceMockRecorder) Update(channel interface{}) *gomock.Call { - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockChannelService)(nil).Update), channel) +// AddMember indicates an expected call of AddMember +func (mr *MockChannelServiceMockRecorder) AddMember(channelID interface{}, memberIDs ...interface{}) *gomock.Call { + varargs := append([]interface{}{channelID}, memberIDs...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddMember", reflect.TypeOf((*MockChannelService)(nil).AddMember), varargs...) +} + +// DeleteMember mocks base method +func (m *MockChannelService) DeleteMember(channelID uint64, memberIDs ...uint64) error { + varargs := []interface{}{channelID} + for _, a := range memberIDs { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "DeleteMember", varargs...) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteMember indicates an expected call of DeleteMember +func (mr *MockChannelServiceMockRecorder) DeleteMember(channelID interface{}, memberIDs ...interface{}) *gomock.Call { + varargs := append([]interface{}{channelID}, memberIDs...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteMember", reflect.TypeOf((*MockChannelService)(nil).DeleteMember), varargs...) } // Archive mocks base method @@ -159,3 +212,15 @@ func (m *MockChannelService) Delete(ID uint64) error { func (mr *MockChannelServiceMockRecorder) Delete(ID interface{}) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockChannelService)(nil).Delete), ID) } + +// RecordView mocks base method +func (m *MockChannelService) RecordView(channelID, userID, lastMessageID uint64) error { + ret := m.ctrl.Call(m, "RecordView", channelID, userID, lastMessageID) + ret0, _ := ret[0].(error) + return ret0 +} + +// RecordView indicates an expected call of RecordView +func (mr *MockChannelServiceMockRecorder) RecordView(channelID, userID, lastMessageID interface{}) *gomock.Call { + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RecordView", reflect.TypeOf((*MockChannelService)(nil).RecordView), channelID, userID, lastMessageID) +} diff --git a/sam/service/message.go b/sam/service/message.go index fa1407a3b..2b37c442c 100644 --- a/sam/service/message.go +++ b/sam/service/message.go @@ -3,6 +3,7 @@ package service import ( "context" + "github.com/pkg/errors" "github.com/titpetric/factory" authService "github.com/crusttech/crust/auth/service" @@ -122,11 +123,34 @@ func (svc *message) Create(mod *types.Message) (message *types.Message, err erro // @todo get user from context var currentUserID uint64 = repository.Identity(svc.ctx) - // @todo verify if current user can access & write to this channel - mod.UserID = currentUserID return message, svc.db.Transaction(func() (err error) { + if mod.ReplyTo > 0 { + original, err := svc.message.FindMessageByID(mod.ReplyTo) + if err != nil { + return err + } + + if original.ReplyTo > 0 { + // We do not want to have multi-level threads + // Take original's reply-to and use it + mod.ReplyTo = original.ReplyTo + } + + mod.ChannelID = original.ChannelID + + if err = svc.message.IncReplyCount(original.ID); err != nil { + return err + } + } + + if mod.ChannelID == 0 { + return errors.New("ChannelID missing") + } + + // @todo [SECURITY] verify if current user can access & write to this channel + if message, err = svc.message.CreateMessage(mod); err != nil { return err } @@ -159,7 +183,7 @@ func (svc *message) Update(mod *types.Message) (*types.Message, error) { return message, svc.sendEvent(message) } -func (svc *message) Delete(id uint64) error { +func (svc *message) Delete(ID uint64) error { // @todo get user from context var currentUserID uint64 = repository.Identity(svc.ctx) @@ -170,7 +194,20 @@ func (svc *message) Delete(id uint64) error { // @todo verify ownership return svc.db.Transaction(func() (err error) { - if err = svc.message.DeleteMessageByID(id); err != nil { + msg, err := svc.message.FindMessageByID(ID) + if err != nil { + return err + } + + if msg.ReplyTo > 0 { + // This is a reply to another message, + // decrease + if err = svc.message.DecReplyCount(msg.ReplyTo); err != nil { + return + } + } + + if err = svc.message.DeleteMessageByID(ID); err != nil { return } diff --git a/sam/service/message_mock_test.go b/sam/service/message_mock_test.go index a1e544c4c..48fb6e87e 100644 --- a/sam/service/message_mock_test.go +++ b/sam/service/message_mock_test.go @@ -157,19 +157,6 @@ func (mr *MockMessageServiceMockRecorder) Unflag(messageID interface{}) *gomock. return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Unflag", reflect.TypeOf((*MockMessageService)(nil).Unflag), messageID) } -// Direct mocks base method -func (m *MockMessageService) Direct(recipientID uint64, in *types.Message) (*types.Message, error) { - ret := m.ctrl.Call(m, "Direct", recipientID, in) - ret0, _ := ret[0].(*types.Message) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// Direct indicates an expected call of Direct -func (mr *MockMessageServiceMockRecorder) Direct(recipientID, in interface{}) *gomock.Call { - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Direct", reflect.TypeOf((*MockMessageService)(nil).Direct), recipientID, in) -} - // Delete mocks base method func (m *MockMessageService) Delete(ID uint64) error { ret := m.ctrl.Call(m, "Delete", ID) diff --git a/sam/types/message.go b/sam/types/message.go index 131a62a3d..b6bd45163 100644 --- a/sam/types/message.go +++ b/sam/types/message.go @@ -15,6 +15,7 @@ type ( UserID uint64 `json:"userId" db:"rel_user"` ChannelID uint64 `json:"channelId" db:"rel_channel"` ReplyTo uint64 `json:"replyTo" db:"reply_to"` + Replies uint `json:"replies" db:"replies"` CreatedAt time.Time `json:"createdAt,omitempty" db:"created_at"` UpdatedAt *time.Time `json:"updatedAt,omitempty" db:"updated_at"` DeletedAt *time.Time `json:"deletedAt,omitempty" db:"deleted_at"` @@ -25,11 +26,21 @@ type ( MessageSet []*Message MessageFilter struct { - Query string - ChannelID uint64 - FromMessageID uint64 - UntilMessageID uint64 - Limit uint + Query string + + // All messages that belong to a channel + ChannelID uint64 + + // Return all replies to a single message + RepliesTo uint64 + + // (FirstID...LastID), for paging + // + // Include all messsages which IDs range from "first" to "last" (exclusive!) + FirstID uint64 + LastID uint64 + + Limit uint } MessageType string diff --git a/sam/websocket/session_incoming_command.go b/sam/websocket/session_incoming_command.go index d5a22e914..24b487af0 100644 --- a/sam/websocket/session_incoming_command.go +++ b/sam/websocket/session_incoming_command.go @@ -27,7 +27,7 @@ func (s *Session) execCommand(ctx context.Context, c *incoming.ExecCommand) erro } return s.sendReply(&outgoing.Message{ - ID: payload.Uint64toa(factory.Sonyflake.NextID()), + ID: factory.Sonyflake.NextID(), User: payload.User(user), CreatedAt: time.Now(), Type: "hallucination", diff --git a/sam/websocket/session_incoming_message.go b/sam/websocket/session_incoming_message.go index 879edb532..8957c7cb0 100644 --- a/sam/websocket/session_incoming_message.go +++ b/sam/websocket/session_incoming_message.go @@ -11,6 +11,7 @@ import ( func (s *Session) messageCreate(ctx context.Context, p *incoming.MessageCreate) error { _, err := s.svc.msg.With(ctx).Create(&types.Message{ ChannelID: payload.ParseUInt64(p.ChannelID), + ReplyTo: p.ReplyTo, Message: p.Message, }) @@ -33,9 +34,11 @@ func (s *Session) messageDelete(ctx context.Context, p *incoming.MessageDelete) func (s *Session) messageHistory(ctx context.Context, p *incoming.Messages) error { var ( filter = &types.MessageFilter{ - ChannelID: payload.ParseUInt64(p.ChannelID), - FromMessageID: payload.ParseUInt64(p.FromID), - UntilMessageID: payload.ParseUInt64(p.UntilID), + ChannelID: p.ChannelID, + FirstID: p.FirstID, + LastID: p.LastID, + + RepliesTo: p.RepliesTo, // Max no. of messages we will return Limit: 50, @@ -47,5 +50,10 @@ func (s *Session) messageHistory(ctx context.Context, p *incoming.Messages) erro return err } - return s.sendReply(payload.Messages(messages)) + err = s.sendReply(payload.Messages(messages)) + if err != nil { + return err + } + + return nil }