feat(admin): add async chat moderation cleanup (#445)

## Summary
- add an asynchronous admin chat moderation job pipeline for message,
reaction, and ban cleanup
- expose the moderation operation through HTTP/gRPC admin APIs and
persist resumable worker progress
- remove the obsolete version parameter from the async moderation path
while retaining CAS for synchronous chat edits/deletes

## Verification
- cargo check --workspace --all-targets
- cargo test -p synctv-core --lib (1350 passed)
- cargo test -p synctv-api-common --lib (655 passed)
- PostgreSQL moderation race regression passed
dependabot/github_actions/actions/cache-6
zijiren 4 weeks ago committed by GitHub
parent ad3a8b3930
commit 7fb7f6c64f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,213 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id AS \"id!\",\n room_id AS \"room_id!: RoomId\",\n user_id AS \"user_id?: UserId\",\n client_message_id,\n content AS \"content!\",\n message_type AS \"message_type!: ChatMessageType\",\n status AS \"status!: ChatMessageStatus\",\n version AS \"version!\",\n reply_to_message_id,\n reply_to_message_created_at,\n metadata AS \"metadata?: ChatMetadata\",\n edited_at,\n deleted_at,\n deleted_by AS \"deleted_by?: UserId\",\n delete_reason,\n created_at AS \"created_at!\"\n FROM chat_messages\n WHERE room_id = $1\n AND user_id = $2\n AND status <> $3\n AND deletion_source IS DISTINCT FROM $4\n AND created_at <= $5\n ORDER BY created_at ASC, id ASC\n LIMIT $6\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id!",
"type_info": "Int8",
"origin": {
"Table": {
"table": "chat_messages",
"name": "id"
}
}
},
{
"ordinal": 1,
"name": "room_id!: RoomId",
"type_info": "Int8",
"origin": {
"Table": {
"table": "chat_messages",
"name": "room_id"
}
}
},
{
"ordinal": 2,
"name": "user_id?: UserId",
"type_info": "Int8",
"origin": {
"Table": {
"table": "chat_messages",
"name": "user_id"
}
}
},
{
"ordinal": 3,
"name": "client_message_id",
"type_info": "Text",
"origin": {
"Table": {
"table": "chat_messages",
"name": "client_message_id"
}
}
},
{
"ordinal": 4,
"name": "content!",
"type_info": "Text",
"origin": {
"Table": {
"table": "chat_messages",
"name": "content"
}
}
},
{
"ordinal": 5,
"name": "message_type!: ChatMessageType",
"type_info": "Int2",
"origin": {
"Table": {
"table": "chat_messages",
"name": "message_type"
}
}
},
{
"ordinal": 6,
"name": "status!: ChatMessageStatus",
"type_info": "Int2",
"origin": {
"Table": {
"table": "chat_messages",
"name": "status"
}
}
},
{
"ordinal": 7,
"name": "version!",
"type_info": "Int8",
"origin": {
"Table": {
"table": "chat_messages",
"name": "version"
}
}
},
{
"ordinal": 8,
"name": "reply_to_message_id",
"type_info": "Int8",
"origin": {
"Table": {
"table": "chat_messages",
"name": "reply_to_message_id"
}
}
},
{
"ordinal": 9,
"name": "reply_to_message_created_at",
"type_info": "Timestamptz",
"origin": {
"Table": {
"table": "chat_messages",
"name": "reply_to_message_created_at"
}
}
},
{
"ordinal": 10,
"name": "metadata?: ChatMetadata",
"type_info": "Jsonb",
"origin": {
"Table": {
"table": "chat_messages",
"name": "metadata"
}
}
},
{
"ordinal": 11,
"name": "edited_at",
"type_info": "Timestamptz",
"origin": {
"Table": {
"table": "chat_messages",
"name": "edited_at"
}
}
},
{
"ordinal": 12,
"name": "deleted_at",
"type_info": "Timestamptz",
"origin": {
"Table": {
"table": "chat_messages",
"name": "deleted_at"
}
}
},
{
"ordinal": 13,
"name": "deleted_by?: UserId",
"type_info": "Int8",
"origin": {
"Table": {
"table": "chat_messages",
"name": "deleted_by"
}
}
},
{
"ordinal": 14,
"name": "delete_reason",
"type_info": "Text",
"origin": {
"Table": {
"table": "chat_messages",
"name": "delete_reason"
}
}
},
{
"ordinal": 15,
"name": "created_at!",
"type_info": "Timestamptz",
"origin": {
"Table": {
"table": "chat_messages",
"name": "created_at"
}
}
}
],
"parameters": {
"Left": [
"Int8",
"Int8",
"Int2",
"Int2",
"Timestamptz",
"Int8"
]
},
"nullable": [
false,
false,
true,
true,
false,
false,
false,
false,
true,
true,
true,
true,
true,
true,
true,
false
]
},
"hash": "0b1164612a648a8afc16d2482b53e988390d3a005f5ecb1c7652ce4a52488235"
}

@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE chat_moderation_jobs\n SET deleted_messages = deleted_messages + $4,\n deleted_reactions = deleted_reactions + $5,\n locked_at = NOW(),\n updated_at = NOW()\n WHERE id = $1\n AND locked_by = $2\n AND lock_version = $3\n AND status = 2\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Int8",
"Int8",
"Int8"
]
},
"nullable": []
},
"hash": "111bd2fa647923737ec9f2592f2d118499b8bc60ef5bd98c187d5f468164947d"
}

@ -203,9 +203,11 @@
]
},
"nullable": [
true,
true,
false,
false,
null,
false,
false,
true,
true,
true,
@ -214,10 +216,8 @@
true,
true,
true,
true,
true,
true,
true,
false,
false,
true,
true,
true

@ -0,0 +1,60 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT r.message_id AS \"message_id!\",\n r.message_created_at AS \"message_created_at!\",\n r.reaction_key AS \"reaction_key!\"\n FROM chat_message_reactions r\n JOIN chat_messages m\n ON m.room_id = r.room_id\n AND m.id = r.message_id\n AND m.created_at = r.message_created_at\n WHERE r.room_id = $1\n AND r.user_id = $2\n AND (m.status = $3 OR m.deletion_source = $4)\n AND m.created_at <= $5\n AND r.created_at <= $5\n AND (m.created_at, r.message_id, r.reaction_key) > ($6, $7, $8)\n ORDER BY m.created_at ASC, r.message_id ASC, r.reaction_key ASC\n LIMIT $9\n FOR UPDATE OF r\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "message_id!",
"type_info": "Int8",
"origin": {
"Table": {
"table": "chat_message_reactions",
"name": "message_id"
}
}
},
{
"ordinal": 1,
"name": "message_created_at!",
"type_info": "Timestamptz",
"origin": {
"Table": {
"table": "chat_message_reactions",
"name": "message_created_at"
}
}
},
{
"ordinal": 2,
"name": "reaction_key!",
"type_info": "Text",
"origin": {
"Table": {
"table": "chat_message_reactions",
"name": "reaction_key"
}
}
}
],
"parameters": {
"Left": [
"Int8",
"Int8",
"Int2",
"Int2",
"Timestamptz",
"Timestamptz",
"Int8",
"Text",
"Int8"
]
},
"nullable": [
false,
false,
false
]
},
"hash": "1d0d97935c21fbed5e00693946e11f10198e8e260d844dd62aeffb88514e81dd"
}

@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE chat_moderation_jobs\n SET status = CASE WHEN attempts + 1 >= 10 THEN 4 ELSE 1 END,\n attempts = attempts + 1,\n locked_by = NULL,\n locked_at = NULL,\n next_attempt_at = CASE\n WHEN attempts + 1 >= 10 THEN next_attempt_at\n ELSE NOW()\n END,\n last_error = 'Worker lease expired',\n updated_at = NOW()\n WHERE status = 2\n AND locked_at < NOW() - ($1::DOUBLE PRECISION * INTERVAL '1 second')\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Float8"
]
},
"nullable": []
},
"hash": "25373c5ec9fdd1c82ae58d30648488bb3ce874043b8f1ab84699979109d69e27"
}

@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "\n DELETE FROM chat_message_reactions AS r\n WHERE r.room_id = $1\n AND r.user_id = $2\n AND r.created_at <= $5\n AND EXISTS (\n SELECT 1\n FROM unnest($3::bigint[], $4::timestamptz[])\n AS selected(message_id, message_created_at)\n WHERE selected.message_id = r.message_id\n AND selected.message_created_at = r.message_created_at\n )\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8",
"Int8",
"Int8Array",
"TimestamptzArray",
"Timestamptz"
]
},
"nullable": []
},
"hash": "2bab87831f3d592b2590c05c5dfc3975ad74982e2c05f2693c9c1e84152ba6e0"
}

@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE chat_moderation_jobs\n SET locked_at = NOW() - INTERVAL '1 hour'\n WHERE id = $1\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "2e987e54f812f11348bf769e30059d52402685fa51ac5eb66d7e835bf304aaed"
}

@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "\n DELETE FROM chat_message_reactions AS r\n USING unnest($3::bigint[], $4::timestamptz[], $5::text[])\n AS selected(message_id, message_created_at, reaction_key)\n WHERE r.room_id = $1\n AND r.user_id = $2\n AND r.message_id = selected.message_id\n AND r.message_created_at = selected.message_created_at\n AND r.reaction_key = selected.reaction_key\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8",
"Int8",
"Int8Array",
"TimestamptzArray",
"TextArray"
]
},
"nullable": []
},
"hash": "2f77b78d7c18981223a11f8bc707c08893387d6a4277b7200eaa2b2f9c4dd07e"
}

@ -0,0 +1,57 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT r.message_id AS \"message_id!\",\n r.message_created_at AS \"message_created_at!\",\n r.reaction_key AS \"reaction_key!\"\n FROM chat_message_reactions r\n JOIN chat_messages m\n ON m.room_id = r.room_id\n AND m.id = r.message_id\n AND m.created_at = r.message_created_at\n WHERE r.room_id = $1\n AND r.user_id = $2\n AND (m.status = $3 OR m.deletion_source = $4)\n AND m.created_at <= $5\n AND r.created_at <= $5\n ORDER BY m.created_at ASC, r.message_id ASC, r.reaction_key ASC\n LIMIT $6\n FOR UPDATE OF r\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "message_id!",
"type_info": "Int8",
"origin": {
"Table": {
"table": "chat_message_reactions",
"name": "message_id"
}
}
},
{
"ordinal": 1,
"name": "message_created_at!",
"type_info": "Timestamptz",
"origin": {
"Table": {
"table": "chat_message_reactions",
"name": "message_created_at"
}
}
},
{
"ordinal": 2,
"name": "reaction_key!",
"type_info": "Text",
"origin": {
"Table": {
"table": "chat_message_reactions",
"name": "reaction_key"
}
}
}
],
"parameters": {
"Left": [
"Int8",
"Int8",
"Int2",
"Int2",
"Timestamptz",
"Int8"
]
},
"nullable": [
false,
false,
false
]
},
"hash": "334355b5a4e29e1a82c14e6b4f769e2c99ff87fa833386cb26dc3bdc3f19ef98"
}

@ -0,0 +1,216 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT m.id AS \"id!\",\n m.room_id AS \"room_id!: RoomId\",\n m.user_id AS \"user_id?: UserId\",\n m.client_message_id,\n m.content AS \"content!\",\n m.message_type AS \"message_type!: ChatMessageType\",\n m.status AS \"status!: ChatMessageStatus\",\n m.version AS \"version!\",\n m.reply_to_message_id,\n m.reply_to_message_created_at,\n m.metadata AS \"metadata?: ChatMetadata\",\n m.edited_at,\n m.deleted_at,\n m.deleted_by AS \"deleted_by?: UserId\",\n m.delete_reason,\n m.created_at AS \"created_at!\"\n FROM chat_messages m\n WHERE m.room_id = $1\n AND m.status <> $3\n AND m.deletion_source IS DISTINCT FROM $4\n AND m.created_at <= $5\n AND EXISTS (\n SELECT 1 FROM chat_message_reactions r\n WHERE r.room_id = m.room_id\n AND r.message_id = m.id\n AND r.message_created_at = m.created_at\n AND r.user_id = $2\n AND r.created_at <= $6\n )\n AND (m.created_at, m.id) > ($7, $8)\n ORDER BY m.created_at ASC, m.id ASC\n LIMIT $9\n FOR UPDATE\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id!",
"type_info": "Int8",
"origin": {
"Table": {
"table": "chat_messages",
"name": "id"
}
}
},
{
"ordinal": 1,
"name": "room_id!: RoomId",
"type_info": "Int8",
"origin": {
"Table": {
"table": "chat_messages",
"name": "room_id"
}
}
},
{
"ordinal": 2,
"name": "user_id?: UserId",
"type_info": "Int8",
"origin": {
"Table": {
"table": "chat_messages",
"name": "user_id"
}
}
},
{
"ordinal": 3,
"name": "client_message_id",
"type_info": "Text",
"origin": {
"Table": {
"table": "chat_messages",
"name": "client_message_id"
}
}
},
{
"ordinal": 4,
"name": "content!",
"type_info": "Text",
"origin": {
"Table": {
"table": "chat_messages",
"name": "content"
}
}
},
{
"ordinal": 5,
"name": "message_type!: ChatMessageType",
"type_info": "Int2",
"origin": {
"Table": {
"table": "chat_messages",
"name": "message_type"
}
}
},
{
"ordinal": 6,
"name": "status!: ChatMessageStatus",
"type_info": "Int2",
"origin": {
"Table": {
"table": "chat_messages",
"name": "status"
}
}
},
{
"ordinal": 7,
"name": "version!",
"type_info": "Int8",
"origin": {
"Table": {
"table": "chat_messages",
"name": "version"
}
}
},
{
"ordinal": 8,
"name": "reply_to_message_id",
"type_info": "Int8",
"origin": {
"Table": {
"table": "chat_messages",
"name": "reply_to_message_id"
}
}
},
{
"ordinal": 9,
"name": "reply_to_message_created_at",
"type_info": "Timestamptz",
"origin": {
"Table": {
"table": "chat_messages",
"name": "reply_to_message_created_at"
}
}
},
{
"ordinal": 10,
"name": "metadata?: ChatMetadata",
"type_info": "Jsonb",
"origin": {
"Table": {
"table": "chat_messages",
"name": "metadata"
}
}
},
{
"ordinal": 11,
"name": "edited_at",
"type_info": "Timestamptz",
"origin": {
"Table": {
"table": "chat_messages",
"name": "edited_at"
}
}
},
{
"ordinal": 12,
"name": "deleted_at",
"type_info": "Timestamptz",
"origin": {
"Table": {
"table": "chat_messages",
"name": "deleted_at"
}
}
},
{
"ordinal": 13,
"name": "deleted_by?: UserId",
"type_info": "Int8",
"origin": {
"Table": {
"table": "chat_messages",
"name": "deleted_by"
}
}
},
{
"ordinal": 14,
"name": "delete_reason",
"type_info": "Text",
"origin": {
"Table": {
"table": "chat_messages",
"name": "delete_reason"
}
}
},
{
"ordinal": 15,
"name": "created_at!",
"type_info": "Timestamptz",
"origin": {
"Table": {
"table": "chat_messages",
"name": "created_at"
}
}
}
],
"parameters": {
"Left": [
"Int8",
"Int8",
"Int2",
"Int2",
"Timestamptz",
"Timestamptz",
"Timestamptz",
"Int8",
"Int8"
]
},
"nullable": [
false,
false,
true,
true,
false,
false,
false,
false,
true,
true,
true,
true,
true,
true,
true,
false
]
},
"hash": "4c34ab531605e910efa2d554a55305f92b33215f3bcfe0b751122099a4afd009"
}

@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE chat_moderation_jobs\n SET phase = 3,\n status = 3,\n deleted_messages = $4,\n deleted_reactions = $5,\n locked_by = NULL,\n locked_at = NULL,\n last_error = NULL,\n completed_at = NOW(),\n updated_at = NOW()\n WHERE id = $1 AND locked_by = $2 AND lock_version = $3 AND status = 2\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Int8",
"Int8",
"Int8"
]
},
"nullable": []
},
"hash": "55b14d8ecd8726d53dbe87161d6b3224d3d02425cbb8877ec109a457dd1a66ab"
}

@ -0,0 +1,21 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO chat_message_reactions (\n room_id, message_id, message_created_at, user_id, reaction_key, created_at\n )\n VALUES ($1, $2, $3, $4, $5, $6),\n ($1, $2, $3, $4, $7, $8)\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8",
"Int8",
"Timestamptz",
"Int8",
"Text",
"Timestamptz",
"Text",
"Timestamptz"
]
},
"nullable": []
},
"hash": "6514b3b63863e4f8bd9bff7314979b4473800b33901bec2f145fc467baf81515"
}

@ -0,0 +1,30 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT status AS \"status!\"\n FROM chat_messages\n WHERE room_id = $1 AND id = $2 AND created_at = $3\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "status!",
"type_info": "Int2",
"origin": {
"Table": {
"table": "chat_messages",
"name": "status"
}
}
}
],
"parameters": {
"Left": [
"Int8",
"Int8",
"Timestamptz"
]
},
"nullable": [
false
]
},
"hash": "6e3b2249e1c1cb7dceaad8b25e611df1ec9a1e732597ee0b42b4b83812bd7608"
}

@ -0,0 +1,215 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id AS \"id!\",\n room_id AS \"room_id!: RoomId\",\n user_id AS \"user_id?: UserId\",\n client_message_id,\n content AS \"content!\",\n message_type AS \"message_type!: ChatMessageType\",\n status AS \"status!: ChatMessageStatus\",\n version AS \"version!\",\n reply_to_message_id,\n reply_to_message_created_at,\n metadata AS \"metadata?: ChatMetadata\",\n edited_at,\n deleted_at,\n deleted_by AS \"deleted_by?: UserId\",\n delete_reason,\n created_at AS \"created_at!\"\n FROM chat_messages\n WHERE room_id = $1\n AND user_id = $2\n AND status <> $3\n AND deletion_source IS DISTINCT FROM $4\n AND created_at <= $5\n AND (created_at, id) > ($6, $7)\n ORDER BY created_at ASC, id ASC\n LIMIT $8\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id!",
"type_info": "Int8",
"origin": {
"Table": {
"table": "chat_messages",
"name": "id"
}
}
},
{
"ordinal": 1,
"name": "room_id!: RoomId",
"type_info": "Int8",
"origin": {
"Table": {
"table": "chat_messages",
"name": "room_id"
}
}
},
{
"ordinal": 2,
"name": "user_id?: UserId",
"type_info": "Int8",
"origin": {
"Table": {
"table": "chat_messages",
"name": "user_id"
}
}
},
{
"ordinal": 3,
"name": "client_message_id",
"type_info": "Text",
"origin": {
"Table": {
"table": "chat_messages",
"name": "client_message_id"
}
}
},
{
"ordinal": 4,
"name": "content!",
"type_info": "Text",
"origin": {
"Table": {
"table": "chat_messages",
"name": "content"
}
}
},
{
"ordinal": 5,
"name": "message_type!: ChatMessageType",
"type_info": "Int2",
"origin": {
"Table": {
"table": "chat_messages",
"name": "message_type"
}
}
},
{
"ordinal": 6,
"name": "status!: ChatMessageStatus",
"type_info": "Int2",
"origin": {
"Table": {
"table": "chat_messages",
"name": "status"
}
}
},
{
"ordinal": 7,
"name": "version!",
"type_info": "Int8",
"origin": {
"Table": {
"table": "chat_messages",
"name": "version"
}
}
},
{
"ordinal": 8,
"name": "reply_to_message_id",
"type_info": "Int8",
"origin": {
"Table": {
"table": "chat_messages",
"name": "reply_to_message_id"
}
}
},
{
"ordinal": 9,
"name": "reply_to_message_created_at",
"type_info": "Timestamptz",
"origin": {
"Table": {
"table": "chat_messages",
"name": "reply_to_message_created_at"
}
}
},
{
"ordinal": 10,
"name": "metadata?: ChatMetadata",
"type_info": "Jsonb",
"origin": {
"Table": {
"table": "chat_messages",
"name": "metadata"
}
}
},
{
"ordinal": 11,
"name": "edited_at",
"type_info": "Timestamptz",
"origin": {
"Table": {
"table": "chat_messages",
"name": "edited_at"
}
}
},
{
"ordinal": 12,
"name": "deleted_at",
"type_info": "Timestamptz",
"origin": {
"Table": {
"table": "chat_messages",
"name": "deleted_at"
}
}
},
{
"ordinal": 13,
"name": "deleted_by?: UserId",
"type_info": "Int8",
"origin": {
"Table": {
"table": "chat_messages",
"name": "deleted_by"
}
}
},
{
"ordinal": 14,
"name": "delete_reason",
"type_info": "Text",
"origin": {
"Table": {
"table": "chat_messages",
"name": "delete_reason"
}
}
},
{
"ordinal": 15,
"name": "created_at!",
"type_info": "Timestamptz",
"origin": {
"Table": {
"table": "chat_messages",
"name": "created_at"
}
}
}
],
"parameters": {
"Left": [
"Int8",
"Int8",
"Int2",
"Int2",
"Timestamptz",
"Timestamptz",
"Int8",
"Int8"
]
},
"nullable": [
false,
false,
true,
true,
false,
false,
false,
false,
true,
true,
true,
true,
true,
true,
true,
false
]
},
"hash": "7d4a794a8dedaa27f9ba4465d2d329a34ba36b46fc31699cdf0f900d7d99b8a7"
}

@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE chat_moderation_jobs\n SET status = $4::SMALLINT,\n attempts = $5,\n next_attempt_at = CASE\n WHEN $4::SMALLINT = 1 THEN NOW() + ($7::DOUBLE PRECISION * INTERVAL '1 second')\n ELSE next_attempt_at\n END,\n locked_by = NULL,\n locked_at = NULL,\n last_error = $6,\n updated_at = NOW()\n WHERE id = $1 AND locked_by = $2 AND lock_version = $3 AND status = 2\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Int8",
"Int2",
"Int4",
"Text",
"Float8"
]
},
"nullable": []
},
"hash": "801515f083750d0801cfb0147c60401e65d6ff67798ae15236b2ae9cba6f3f31"
}

@ -0,0 +1,30 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE chat_moderation_jobs\n SET phase = $4,\n status = CASE\n WHEN $16 THEN 2::SMALLINT\n ELSE 1::SMALLINT\n END,\n message_cursor_created_at = $5,\n message_cursor_id = $6,\n reaction_cursor_created_at = $7,\n reaction_cursor_id = $8,\n hidden_reaction_cursor_created_at = $9,\n hidden_reaction_cursor_id = $10,\n hidden_reaction_cursor_key = $11,\n deleted_messages = $12,\n deleted_reactions = $13,\n explicit_message_done = $14,\n ban_done = $15,\n snapshot_at = $17,\n locked_by = CASE WHEN $16 THEN locked_by ELSE NULL END,\n locked_at = CASE WHEN $16 THEN NOW() ELSE NULL END,\n updated_at = NOW(),\n last_error = NULL,\n lock_version = lock_version + 1\n WHERE id = $1 AND locked_by = $2 AND lock_version = $3 AND status = 2\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Int8",
"Int2",
"Timestamptz",
"Int8",
"Timestamptz",
"Int8",
"Timestamptz",
"Int8",
"Text",
"Int8",
"Int8",
"Bool",
"Bool",
"Bool",
"Timestamptz"
]
},
"nullable": []
},
"hash": "8b64a5fa8a5b92c7f7d0da86132febda77d4312bf83f9b08ac5f6d610e235ff3"
}

@ -0,0 +1,26 @@
{
"db_name": "PostgreSQL",
"query": "\n WITH deleted AS (\n DELETE FROM chat_message_reactions\n WHERE room_id = $1 AND message_id = $2 AND message_created_at = $3\n RETURNING user_id\n )\n SELECT COUNT(*) FILTER (WHERE user_id = $4) AS \"count!\"\n FROM deleted\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count!",
"type_info": "Int8",
"origin": "Expression"
}
],
"parameters": {
"Left": [
"Int8",
"Int8",
"Timestamptz",
"Int8"
]
},
"nullable": [
null
]
},
"hash": "8c2444eda6e020e59498fb67ba77c0d370531cad605419b9ba662e861b8859f4"
}

@ -0,0 +1,25 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT COUNT(*) AS \"count!\"\n FROM chat_message_reactions\n WHERE room_id = $1 AND message_id = $2 AND message_created_at = $3\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count!",
"type_info": "Int8",
"origin": "Expression"
}
],
"parameters": {
"Left": [
"Int8",
"Int8",
"Timestamptz"
]
},
"nullable": [
null
]
},
"hash": "8dfe15260820fe8a64d2297a66273c9fa10cce23bc9919b4809b7457a791fae0"
}

@ -267,12 +267,12 @@
]
},
"nullable": [
true,
true,
false,
false,
null,
true,
null,
true,
false,
true,
null,
true,
@ -284,16 +284,16 @@
true,
true,
null,
false,
false,
true,
true,
true,
true,
false,
true,
null,
true,
true,
true,
true
false,
false,
false
]
},
"hash": "95b523f81c3f448e9a7722c301cf633d87984a1674fa28c7eb649851a482f4ab"

@ -103,7 +103,7 @@
false,
false,
false,
false,
true,
false,
false,
false

@ -0,0 +1,352 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id,\n room_id AS \"room_id!: RoomId\",\n target_user_id AS \"target_user_id!: UserId\",\n actor_user_id AS \"actor_user_id!: UserId\",\n actor_username,\n actor_role,\n message_id,\n explicit_message_done,\n ban_user,\n ban_done,\n delete_all_messages,\n delete_all_reactions,\n reason,\n phase AS \"phase!\",\n status AS \"status!\",\n snapshot_at,\n message_cursor_created_at,\n message_cursor_id,\n reaction_cursor_created_at,\n reaction_cursor_id,\n hidden_reaction_cursor_created_at,\n hidden_reaction_cursor_id,\n hidden_reaction_cursor_key,\n deleted_messages,\n deleted_reactions,\n attempts,\n lock_version,\n last_error\n FROM chat_moderation_jobs\n WHERE id = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Text",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "id"
}
}
},
{
"ordinal": 1,
"name": "room_id!: RoomId",
"type_info": "Int8",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "room_id"
}
}
},
{
"ordinal": 2,
"name": "target_user_id!: UserId",
"type_info": "Int8",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "target_user_id"
}
}
},
{
"ordinal": 3,
"name": "actor_user_id!: UserId",
"type_info": "Int8",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "actor_user_id"
}
}
},
{
"ordinal": 4,
"name": "actor_username",
"type_info": "Text",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "actor_username"
}
}
},
{
"ordinal": 5,
"name": "actor_role",
"type_info": "Int2",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "actor_role"
}
}
},
{
"ordinal": 6,
"name": "message_id",
"type_info": "Int8",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "message_id"
}
}
},
{
"ordinal": 7,
"name": "explicit_message_done",
"type_info": "Bool",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "explicit_message_done"
}
}
},
{
"ordinal": 8,
"name": "ban_user",
"type_info": "Bool",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "ban_user"
}
}
},
{
"ordinal": 9,
"name": "ban_done",
"type_info": "Bool",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "ban_done"
}
}
},
{
"ordinal": 10,
"name": "delete_all_messages",
"type_info": "Bool",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "delete_all_messages"
}
}
},
{
"ordinal": 11,
"name": "delete_all_reactions",
"type_info": "Bool",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "delete_all_reactions"
}
}
},
{
"ordinal": 12,
"name": "reason",
"type_info": "Text",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "reason"
}
}
},
{
"ordinal": 13,
"name": "phase!",
"type_info": "Int2",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "phase"
}
}
},
{
"ordinal": 14,
"name": "status!",
"type_info": "Int2",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "status"
}
}
},
{
"ordinal": 15,
"name": "snapshot_at",
"type_info": "Timestamptz",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "snapshot_at"
}
}
},
{
"ordinal": 16,
"name": "message_cursor_created_at",
"type_info": "Timestamptz",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "message_cursor_created_at"
}
}
},
{
"ordinal": 17,
"name": "message_cursor_id",
"type_info": "Int8",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "message_cursor_id"
}
}
},
{
"ordinal": 18,
"name": "reaction_cursor_created_at",
"type_info": "Timestamptz",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "reaction_cursor_created_at"
}
}
},
{
"ordinal": 19,
"name": "reaction_cursor_id",
"type_info": "Int8",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "reaction_cursor_id"
}
}
},
{
"ordinal": 20,
"name": "hidden_reaction_cursor_created_at",
"type_info": "Timestamptz",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "hidden_reaction_cursor_created_at"
}
}
},
{
"ordinal": 21,
"name": "hidden_reaction_cursor_id",
"type_info": "Int8",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "hidden_reaction_cursor_id"
}
}
},
{
"ordinal": 22,
"name": "hidden_reaction_cursor_key",
"type_info": "Text",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "hidden_reaction_cursor_key"
}
}
},
{
"ordinal": 23,
"name": "deleted_messages",
"type_info": "Int8",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "deleted_messages"
}
}
},
{
"ordinal": 24,
"name": "deleted_reactions",
"type_info": "Int8",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "deleted_reactions"
}
}
},
{
"ordinal": 25,
"name": "attempts",
"type_info": "Int4",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "attempts"
}
}
},
{
"ordinal": 26,
"name": "lock_version",
"type_info": "Int8",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "lock_version"
}
}
},
{
"ordinal": 27,
"name": "last_error",
"type_info": "Text",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "last_error"
}
}
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
false,
false,
false,
false,
true,
false,
false,
false,
false,
false,
true,
false,
false,
false,
true,
true,
true,
true,
true,
true,
true,
false,
false,
false,
false,
true
]
},
"hash": "9c54a48457eebe70ffe09089c64f223fb14bbb8d4295f4eec45f760811610731"
}

@ -240,14 +240,14 @@
false,
false,
true,
true,
true,
true,
true,
true,
true,
true,
true,
false,
false,
false,
false,
false,
false,
false,
false,
false,
true,
false,

@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "\n DELETE FROM chat_moderation_jobs\n WHERE status IN (3, 4)\n AND updated_at < NOW() - ($1::DOUBLE PRECISION * INTERVAL '1 second')\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Float8"
]
},
"nullable": []
},
"hash": "b4bd97bc4c76b8cd14bfe268a21dea2032a6f7eaf31b2cca2e8198d089392846"
}

@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO chat_message_reactions (\n room_id, message_id, message_created_at, user_id, reaction_key\n )\n VALUES ($1, $2, $3, $4, $5)\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8",
"Int8",
"Timestamptz",
"Int8",
"Text"
]
},
"nullable": []
},
"hash": "b59e037849b43734a6dfbc17752992b837913d0e81a37b5542cb94deaa47e85b"
}

@ -270,12 +270,12 @@
]
},
"nullable": [
true,
true,
false,
false,
null,
true,
null,
true,
false,
true,
null,
true,
@ -287,16 +287,16 @@
true,
true,
null,
false,
false,
true,
true,
true,
true,
false,
true,
null,
true,
true,
true,
true
false,
false,
false
]
},
"hash": "be3fd3bd0a5f39b4872b7202f604275f89292e31f3cdc9eac7fb4d434e4a401f"

@ -117,14 +117,14 @@
]
},
"nullable": [
true,
true,
false,
false,
null,
true,
false,
null,
null,
true,
true,
false,
false,
true,
true,
true

@ -0,0 +1,214 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT m.id AS \"id!\",\n m.room_id AS \"room_id!: RoomId\",\n m.user_id AS \"user_id?: UserId\",\n m.client_message_id,\n m.content AS \"content!\",\n m.message_type AS \"message_type!: ChatMessageType\",\n m.status AS \"status!: ChatMessageStatus\",\n m.version AS \"version!\",\n m.reply_to_message_id,\n m.reply_to_message_created_at,\n m.metadata AS \"metadata?: ChatMetadata\",\n m.edited_at,\n m.deleted_at,\n m.deleted_by AS \"deleted_by?: UserId\",\n m.delete_reason,\n m.created_at AS \"created_at!\"\n FROM chat_messages m\n WHERE m.room_id = $1\n AND m.status <> $3\n AND m.deletion_source IS DISTINCT FROM $4\n AND m.created_at <= $5\n AND EXISTS (\n SELECT 1 FROM chat_message_reactions r\n WHERE r.room_id = m.room_id\n AND r.message_id = m.id\n AND r.message_created_at = m.created_at\n AND r.user_id = $2\n AND r.created_at <= $6\n )\n ORDER BY m.created_at ASC, m.id ASC\n LIMIT $7\n FOR UPDATE\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id!",
"type_info": "Int8",
"origin": {
"Table": {
"table": "chat_messages",
"name": "id"
}
}
},
{
"ordinal": 1,
"name": "room_id!: RoomId",
"type_info": "Int8",
"origin": {
"Table": {
"table": "chat_messages",
"name": "room_id"
}
}
},
{
"ordinal": 2,
"name": "user_id?: UserId",
"type_info": "Int8",
"origin": {
"Table": {
"table": "chat_messages",
"name": "user_id"
}
}
},
{
"ordinal": 3,
"name": "client_message_id",
"type_info": "Text",
"origin": {
"Table": {
"table": "chat_messages",
"name": "client_message_id"
}
}
},
{
"ordinal": 4,
"name": "content!",
"type_info": "Text",
"origin": {
"Table": {
"table": "chat_messages",
"name": "content"
}
}
},
{
"ordinal": 5,
"name": "message_type!: ChatMessageType",
"type_info": "Int2",
"origin": {
"Table": {
"table": "chat_messages",
"name": "message_type"
}
}
},
{
"ordinal": 6,
"name": "status!: ChatMessageStatus",
"type_info": "Int2",
"origin": {
"Table": {
"table": "chat_messages",
"name": "status"
}
}
},
{
"ordinal": 7,
"name": "version!",
"type_info": "Int8",
"origin": {
"Table": {
"table": "chat_messages",
"name": "version"
}
}
},
{
"ordinal": 8,
"name": "reply_to_message_id",
"type_info": "Int8",
"origin": {
"Table": {
"table": "chat_messages",
"name": "reply_to_message_id"
}
}
},
{
"ordinal": 9,
"name": "reply_to_message_created_at",
"type_info": "Timestamptz",
"origin": {
"Table": {
"table": "chat_messages",
"name": "reply_to_message_created_at"
}
}
},
{
"ordinal": 10,
"name": "metadata?: ChatMetadata",
"type_info": "Jsonb",
"origin": {
"Table": {
"table": "chat_messages",
"name": "metadata"
}
}
},
{
"ordinal": 11,
"name": "edited_at",
"type_info": "Timestamptz",
"origin": {
"Table": {
"table": "chat_messages",
"name": "edited_at"
}
}
},
{
"ordinal": 12,
"name": "deleted_at",
"type_info": "Timestamptz",
"origin": {
"Table": {
"table": "chat_messages",
"name": "deleted_at"
}
}
},
{
"ordinal": 13,
"name": "deleted_by?: UserId",
"type_info": "Int8",
"origin": {
"Table": {
"table": "chat_messages",
"name": "deleted_by"
}
}
},
{
"ordinal": 14,
"name": "delete_reason",
"type_info": "Text",
"origin": {
"Table": {
"table": "chat_messages",
"name": "delete_reason"
}
}
},
{
"ordinal": 15,
"name": "created_at!",
"type_info": "Timestamptz",
"origin": {
"Table": {
"table": "chat_messages",
"name": "created_at"
}
}
}
],
"parameters": {
"Left": [
"Int8",
"Int8",
"Int2",
"Int2",
"Timestamptz",
"Timestamptz",
"Int8"
]
},
"nullable": [
false,
false,
true,
true,
false,
false,
false,
false,
true,
true,
true,
true,
true,
true,
true,
false
]
},
"hash": "d4c622073d58ce0154a8cf8ccd65aa4198b8ccb09166ae220fc884868f6d4aaa"
}

@ -89,13 +89,13 @@
]
},
"nullable": [
false,
false,
false,
true,
true,
true,
true,
false,
true,
true
false
]
},
"hash": "daa54cfb30fff13ce3305b05b4bca4c4ad9f37796c5320e45cdaa24440951d4b"

@ -0,0 +1,353 @@
{
"db_name": "PostgreSQL",
"query": "\n WITH picked AS (\n SELECT id\n FROM chat_moderation_jobs\n WHERE status = 1\n AND next_attempt_at <= NOW()\n ORDER BY updated_at, id\n LIMIT $1\n FOR UPDATE SKIP LOCKED\n )\n UPDATE chat_moderation_jobs AS job\n SET status = 2,\n locked_by = $2,\n locked_at = NOW(),\n lock_version = lock_version + 1,\n updated_at = NOW()\n FROM picked\n WHERE job.id = picked.id\n RETURNING job.id,\n job.room_id AS \"room_id!: RoomId\",\n job.target_user_id AS \"target_user_id!: UserId\",\n job.actor_user_id AS \"actor_user_id!: UserId\",\n job.actor_username,\n job.actor_role,\n job.message_id,\n job.explicit_message_done,\n job.ban_user,\n job.ban_done,\n job.delete_all_messages,\n job.delete_all_reactions,\n job.reason,\n job.phase AS \"phase!\",\n job.status AS \"status!\",\n job.snapshot_at,\n job.message_cursor_created_at,\n job.message_cursor_id,\n job.reaction_cursor_created_at,\n job.reaction_cursor_id,\n job.hidden_reaction_cursor_created_at,\n job.hidden_reaction_cursor_id,\n job.hidden_reaction_cursor_key,\n job.deleted_messages,\n job.deleted_reactions,\n job.attempts,\n job.lock_version,\n job.last_error\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Text",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "id"
}
}
},
{
"ordinal": 1,
"name": "room_id!: RoomId",
"type_info": "Int8",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "room_id"
}
}
},
{
"ordinal": 2,
"name": "target_user_id!: UserId",
"type_info": "Int8",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "target_user_id"
}
}
},
{
"ordinal": 3,
"name": "actor_user_id!: UserId",
"type_info": "Int8",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "actor_user_id"
}
}
},
{
"ordinal": 4,
"name": "actor_username",
"type_info": "Text",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "actor_username"
}
}
},
{
"ordinal": 5,
"name": "actor_role",
"type_info": "Int2",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "actor_role"
}
}
},
{
"ordinal": 6,
"name": "message_id",
"type_info": "Int8",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "message_id"
}
}
},
{
"ordinal": 7,
"name": "explicit_message_done",
"type_info": "Bool",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "explicit_message_done"
}
}
},
{
"ordinal": 8,
"name": "ban_user",
"type_info": "Bool",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "ban_user"
}
}
},
{
"ordinal": 9,
"name": "ban_done",
"type_info": "Bool",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "ban_done"
}
}
},
{
"ordinal": 10,
"name": "delete_all_messages",
"type_info": "Bool",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "delete_all_messages"
}
}
},
{
"ordinal": 11,
"name": "delete_all_reactions",
"type_info": "Bool",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "delete_all_reactions"
}
}
},
{
"ordinal": 12,
"name": "reason",
"type_info": "Text",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "reason"
}
}
},
{
"ordinal": 13,
"name": "phase!",
"type_info": "Int2",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "phase"
}
}
},
{
"ordinal": 14,
"name": "status!",
"type_info": "Int2",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "status"
}
}
},
{
"ordinal": 15,
"name": "snapshot_at",
"type_info": "Timestamptz",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "snapshot_at"
}
}
},
{
"ordinal": 16,
"name": "message_cursor_created_at",
"type_info": "Timestamptz",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "message_cursor_created_at"
}
}
},
{
"ordinal": 17,
"name": "message_cursor_id",
"type_info": "Int8",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "message_cursor_id"
}
}
},
{
"ordinal": 18,
"name": "reaction_cursor_created_at",
"type_info": "Timestamptz",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "reaction_cursor_created_at"
}
}
},
{
"ordinal": 19,
"name": "reaction_cursor_id",
"type_info": "Int8",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "reaction_cursor_id"
}
}
},
{
"ordinal": 20,
"name": "hidden_reaction_cursor_created_at",
"type_info": "Timestamptz",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "hidden_reaction_cursor_created_at"
}
}
},
{
"ordinal": 21,
"name": "hidden_reaction_cursor_id",
"type_info": "Int8",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "hidden_reaction_cursor_id"
}
}
},
{
"ordinal": 22,
"name": "hidden_reaction_cursor_key",
"type_info": "Text",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "hidden_reaction_cursor_key"
}
}
},
{
"ordinal": 23,
"name": "deleted_messages",
"type_info": "Int8",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "deleted_messages"
}
}
},
{
"ordinal": 24,
"name": "deleted_reactions",
"type_info": "Int8",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "deleted_reactions"
}
}
},
{
"ordinal": 25,
"name": "attempts",
"type_info": "Int4",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "attempts"
}
}
},
{
"ordinal": 26,
"name": "lock_version",
"type_info": "Int8",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "lock_version"
}
}
},
{
"ordinal": 27,
"name": "last_error",
"type_info": "Text",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "last_error"
}
}
}
],
"parameters": {
"Left": [
"Int8",
"Text"
]
},
"nullable": [
false,
false,
false,
false,
false,
false,
true,
false,
false,
false,
false,
false,
true,
false,
false,
false,
true,
true,
true,
true,
true,
true,
true,
false,
false,
false,
false,
true
]
},
"hash": "dcccb3d76e9aee418e37eff9c5bdc85823a04fdbcda7f2e6823fc96a872904b4"
}

@ -116,14 +116,14 @@
]
},
"nullable": [
true,
true,
false,
false,
null,
true,
false,
null,
null,
true,
true,
false,
false,
true,
true,
true

@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE chat_moderation_jobs\n SET status = 3,\n phase = 3,\n updated_at = CASE\n WHEN id = 'expired-terminal-job' THEN NOW() - INTERVAL '2 hours'\n ELSE NOW()\n END,\n completed_at = NOW()\n WHERE id IN ('expired-terminal-job', 'recent-terminal-job')\n ",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "f4bb9bdfe5663577c9d73b85102b135332d4a6efecb51347dbd290372b85a1c7"
}

@ -0,0 +1,363 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO chat_moderation_jobs (\n id, room_id, target_user_id, actor_user_id,\n actor_username,\n actor_role, message_id, explicit_message_done,\n ban_user, ban_done,\n delete_all_messages, delete_all_reactions, reason, snapshot_at,\n next_attempt_at\n )\n VALUES ($1, $2, $3, $4, $5, $6, $7, FALSE, $8, FALSE, $9, $10, $11, $12, NOW())\n RETURNING id,\n room_id AS \"room_id!: RoomId\",\n target_user_id AS \"target_user_id!: UserId\",\n actor_user_id AS \"actor_user_id!: UserId\",\n actor_username,\n actor_role,\n message_id,\n explicit_message_done,\n ban_user,\n ban_done,\n delete_all_messages,\n delete_all_reactions,\n reason,\n phase AS \"phase!\",\n status AS \"status!\",\n snapshot_at,\n message_cursor_created_at,\n message_cursor_id,\n reaction_cursor_created_at,\n reaction_cursor_id,\n hidden_reaction_cursor_created_at,\n hidden_reaction_cursor_id,\n hidden_reaction_cursor_key,\n deleted_messages,\n deleted_reactions,\n attempts,\n lock_version,\n last_error\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Text",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "id"
}
}
},
{
"ordinal": 1,
"name": "room_id!: RoomId",
"type_info": "Int8",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "room_id"
}
}
},
{
"ordinal": 2,
"name": "target_user_id!: UserId",
"type_info": "Int8",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "target_user_id"
}
}
},
{
"ordinal": 3,
"name": "actor_user_id!: UserId",
"type_info": "Int8",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "actor_user_id"
}
}
},
{
"ordinal": 4,
"name": "actor_username",
"type_info": "Text",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "actor_username"
}
}
},
{
"ordinal": 5,
"name": "actor_role",
"type_info": "Int2",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "actor_role"
}
}
},
{
"ordinal": 6,
"name": "message_id",
"type_info": "Int8",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "message_id"
}
}
},
{
"ordinal": 7,
"name": "explicit_message_done",
"type_info": "Bool",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "explicit_message_done"
}
}
},
{
"ordinal": 8,
"name": "ban_user",
"type_info": "Bool",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "ban_user"
}
}
},
{
"ordinal": 9,
"name": "ban_done",
"type_info": "Bool",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "ban_done"
}
}
},
{
"ordinal": 10,
"name": "delete_all_messages",
"type_info": "Bool",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "delete_all_messages"
}
}
},
{
"ordinal": 11,
"name": "delete_all_reactions",
"type_info": "Bool",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "delete_all_reactions"
}
}
},
{
"ordinal": 12,
"name": "reason",
"type_info": "Text",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "reason"
}
}
},
{
"ordinal": 13,
"name": "phase!",
"type_info": "Int2",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "phase"
}
}
},
{
"ordinal": 14,
"name": "status!",
"type_info": "Int2",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "status"
}
}
},
{
"ordinal": 15,
"name": "snapshot_at",
"type_info": "Timestamptz",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "snapshot_at"
}
}
},
{
"ordinal": 16,
"name": "message_cursor_created_at",
"type_info": "Timestamptz",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "message_cursor_created_at"
}
}
},
{
"ordinal": 17,
"name": "message_cursor_id",
"type_info": "Int8",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "message_cursor_id"
}
}
},
{
"ordinal": 18,
"name": "reaction_cursor_created_at",
"type_info": "Timestamptz",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "reaction_cursor_created_at"
}
}
},
{
"ordinal": 19,
"name": "reaction_cursor_id",
"type_info": "Int8",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "reaction_cursor_id"
}
}
},
{
"ordinal": 20,
"name": "hidden_reaction_cursor_created_at",
"type_info": "Timestamptz",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "hidden_reaction_cursor_created_at"
}
}
},
{
"ordinal": 21,
"name": "hidden_reaction_cursor_id",
"type_info": "Int8",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "hidden_reaction_cursor_id"
}
}
},
{
"ordinal": 22,
"name": "hidden_reaction_cursor_key",
"type_info": "Text",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "hidden_reaction_cursor_key"
}
}
},
{
"ordinal": 23,
"name": "deleted_messages",
"type_info": "Int8",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "deleted_messages"
}
}
},
{
"ordinal": 24,
"name": "deleted_reactions",
"type_info": "Int8",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "deleted_reactions"
}
}
},
{
"ordinal": 25,
"name": "attempts",
"type_info": "Int4",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "attempts"
}
}
},
{
"ordinal": 26,
"name": "lock_version",
"type_info": "Int8",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "lock_version"
}
}
},
{
"ordinal": 27,
"name": "last_error",
"type_info": "Text",
"origin": {
"Table": {
"table": "chat_moderation_jobs",
"name": "last_error"
}
}
}
],
"parameters": {
"Left": [
"Text",
"Int8",
"Int8",
"Int8",
"Text",
"Int2",
"Int8",
"Bool",
"Bool",
"Bool",
"Text",
"Timestamptz"
]
},
"nullable": [
false,
false,
false,
false,
false,
false,
true,
false,
false,
false,
false,
false,
true,
false,
false,
false,
true,
true,
true,
true,
true,
true,
true,
false,
false,
false,
false,
true
]
},
"hash": "f868fa65c267238611e77b024c7d872ca631267a6b7e976da06a19410ccb5d93"
}

@ -0,0 +1,31 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT reaction_key\n FROM chat_message_reactions\n WHERE room_id = $1\n AND message_id = $2\n AND message_created_at = $3\n AND user_id = $4\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "reaction_key",
"type_info": "Text",
"origin": {
"Table": {
"table": "chat_message_reactions",
"name": "reaction_key"
}
}
}
],
"parameters": {
"Left": [
"Int8",
"Int8",
"Timestamptz",
"Int8"
]
},
"nullable": [
false
]
},
"hash": "ff3d40d7e27aee6f9dd6edd766be86b78aa37ce875006e84caa98343d3de8df5"
}

@ -0,0 +1,53 @@
CREATE TABLE IF NOT EXISTS chat_moderation_jobs (
id TEXT PRIMARY KEY,
room_id BIGINT NOT NULL,
target_user_id BIGINT NOT NULL,
actor_user_id BIGINT NOT NULL,
actor_username TEXT NOT NULL,
actor_role SMALLINT NOT NULL,
message_id BIGINT,
explicit_message_done BOOLEAN NOT NULL DEFAULT FALSE,
ban_user BOOLEAN NOT NULL DEFAULT FALSE,
ban_done BOOLEAN NOT NULL DEFAULT FALSE,
delete_all_messages BOOLEAN NOT NULL DEFAULT FALSE,
delete_all_reactions BOOLEAN NOT NULL DEFAULT FALSE,
reason TEXT,
phase SMALLINT NOT NULL DEFAULT 1,
status SMALLINT NOT NULL DEFAULT 1,
snapshot_at TIMESTAMPTZ NOT NULL,
message_cursor_created_at TIMESTAMPTZ,
message_cursor_id BIGINT,
reaction_cursor_created_at TIMESTAMPTZ,
reaction_cursor_id BIGINT,
hidden_reaction_cursor_created_at TIMESTAMPTZ,
hidden_reaction_cursor_id BIGINT,
hidden_reaction_cursor_key TEXT,
deleted_messages BIGINT NOT NULL DEFAULT 0,
deleted_reactions BIGINT NOT NULL DEFAULT 0,
attempts INTEGER NOT NULL DEFAULT 0,
next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
locked_by TEXT,
locked_at TIMESTAMPTZ,
lock_version BIGINT NOT NULL DEFAULT 1,
last_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMPTZ,
FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE,
FOREIGN KEY (target_user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_chat_moderation_jobs_pending
ON chat_moderation_jobs (status, next_attempt_at, updated_at, id)
WHERE status = 1;
CREATE INDEX IF NOT EXISTS idx_chat_moderation_jobs_processing_lease
ON chat_moderation_jobs (locked_at)
WHERE status = 2 AND locked_at IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_chat_moderation_jobs_target
ON chat_moderation_jobs (room_id, target_user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_chat_moderation_jobs_terminal_updated
ON chat_moderation_jobs (updated_at)
WHERE status IN (3, 4);

@ -0,0 +1,8 @@
CREATE INDEX IF NOT EXISTS idx_chat_message_reactions_room_user_cleanup
ON chat_message_reactions (
room_id,
user_id,
message_created_at,
message_id,
reaction_key
);

@ -0,0 +1,2 @@
CREATE INDEX IF NOT EXISTS idx_chat_messages_room_user_created
ON chat_messages (room_id, user_id, created_at ASC, id ASC);

@ -2,9 +2,9 @@
use std::sync::Arc;
use synctv_core::service::{
AuditService, BanRecordService, ContentReportService, EmailService, RemoteProviderManager,
ReviewService, RoomService, RuntimeSettingsStore, SettingsService, SystemStatsService,
UserService,
AuditService, BanRecordService, ChatService, ContentReportService, EmailService,
RemoteProviderManager, ReviewService, RoomService, RuntimeSettingsStore, SettingsService,
SystemStatsService, UserService,
};
use synctv_livestream::LiveStreamingInfrastructure;
use synctv_realtime::fanout::RealtimeFanoutService;
@ -20,6 +20,7 @@ use super::client::convert::{
};
use super::client::user_notification_preferences_to_proto;
use super::ApiError;
use crate::chat_event_dispatcher::ChatEventDispatcher;
use crate::fanout::{default_room_settings_fanout_service, RoomSettingsFanoutService};
use crate::impls::client::media::{
prepare_delete_entries_outbox_fanout, PrepareDeleteEntriesOutboxFanout,
@ -51,6 +52,7 @@ mod audit;
mod auth;
mod bans;
mod batch;
mod chat_moderation;
mod common;
mod lifecycle;
mod livestream;
@ -89,6 +91,7 @@ pub use synctv_core::models::LOCAL_MANAGEMENT_ACTOR_USER_ID;
#[derive(Clone)]
pub struct AdminApiOptions {
pub room_service: Arc<RoomService>,
pub chat_service: Option<Arc<ChatService>>,
pub user_service: Arc<UserService>,
pub read_services: AdminReadServices,
pub settings_service: Arc<SettingsService>,
@ -153,6 +156,7 @@ impl AdminApiRuntime {
pub struct AdminApiImpl {
pub clock: Arc<dyn synctv_core::Clock>,
pub room_service: Arc<RoomService>,
pub chat_service: Option<Arc<ChatService>>,
pub user_service: Arc<UserService>,
pub system_stats_service: Arc<synctv_core::service::SystemStatsService>,
pub review_service: Arc<ReviewService>,
@ -177,6 +181,7 @@ pub struct AdminApiImpl {
pub realtime_lifecycle: Arc<dyn RealtimeLifecycleService>,
pub room_lifecycle_fanout: Arc<dyn RoomLifecycleFanoutService>,
pub realtime_event_service: Arc<dyn RealtimeEventService>,
pub chat_event_dispatcher: Arc<dyn ChatEventDispatcher>,
pub audit_service: Arc<AuditService>,
pub provider_stores: Arc<dyn synctv_core::provider::ProviderStoreResolver>,
pub provider_access_service: Arc<dyn synctv_core::provider::ProviderAccessService>,
@ -191,6 +196,7 @@ impl AdminApiImpl {
pub fn new_with_runtime(options: AdminApiOptions, runtime: AdminApiRuntime) -> Self {
let AdminApiOptions {
room_service,
chat_service,
user_service,
read_services,
settings_service,
@ -214,6 +220,9 @@ impl AdminApiImpl {
let clock = runtime.clock;
let realtime_fanout = runtime.realtime_fanout;
let realtime_event_service = runtime.realtime_event_service;
let chat_event_dispatcher = crate::chat_event_dispatcher::default_chat_event_dispatcher(
realtime_event_service.clone(),
);
let room_settings_fanout = default_room_settings_fanout_service(realtime_fanout.clone());
let membership_event_fanout = default_membership_event_fanout_service(
realtime_fanout.clone(),
@ -235,6 +244,7 @@ impl AdminApiImpl {
Self {
clock,
room_service,
chat_service,
user_service,
system_stats_service,
review_service,
@ -259,6 +269,7 @@ impl AdminApiImpl {
realtime_lifecycle,
room_lifecycle_fanout,
realtime_event_service,
chat_event_dispatcher,
audit_service,
provider_stores: runtime.provider_stores,
provider_access_service: runtime.provider_access_service,

@ -0,0 +1,511 @@
use synctv_core::models::{AuditDetails, RoomId, UserId, UserRole};
use synctv_core::repository::{
ChatModerationJob, ChatModerationJobPhase, ChatModerationProgress, NewChatModerationJob,
};
use super::{AdminApiImpl, ApiError};
impl AdminApiImpl {
async fn admin_chat_messages_to_proto(
&self,
messages: Vec<synctv_core::models::ChatMessageWithAttachments>,
) -> Result<Vec<synctv_proto::client::ChatMessageReceive>, ApiError> {
let user_ids: Vec<synctv_core::models::UserId> = messages
.iter()
.filter_map(|message| message.message.user_id)
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect();
let usernames = self
.user_service
.get_usernames(&user_ids)
.await
.map_err(ApiError::from)?;
messages
.iter()
.map(|message| {
let username = message
.message
.user_id
.and_then(|user_id| usernames.get(&user_id).cloned());
crate::impls::messaging::chat_message_receive_to_proto(
message,
&self.public_id_codec,
username,
)
.map_err(ApiError::Internal)
})
.collect()
}
pub async fn get_room_chat_history(
&self,
room_id_raw: &str,
req: synctv_proto::client::GetChatHistoryRequest,
) -> Result<synctv_proto::client::GetChatHistoryResponse, ApiError> {
crate::impls::validate_proto_request(&req)?;
let room_id = crate::impls::proto_validated_room_id(room_id_raw, &self.public_id_codec)?;
if !self
.room_service
.room_exists(&room_id)
.await
.map_err(ApiError::from)?
{
return Err(ApiError::NotFound("Room not found".to_string()));
}
let (limit, cursor, selection) =
crate::impls::client::build_get_chat_history_request(&req)?;
let cursor = cursor
.map(|(created_at, id)| synctv_core::models::ChatHistoryCursor { created_at, id });
let chat_service = self.chat_service.as_ref().ok_or_else(|| {
ApiError::ServiceUnavailable("Chat service is unavailable".to_string())
})?;
let page = chat_service
.get_history_page_with_attachments_for_viewer(
&room_id, cursor, limit, true, None, &selection,
)
.await
.map_err(ApiError::from)?;
let messages = self.admin_chat_messages_to_proto(page.messages).await?;
let next_cursor = page.next_cursor.map(|cursor| {
format!(
"{}|{}",
synctv_common::time::format_datetime_rfc3339(cursor.created_at),
cursor.id
)
});
Ok(synctv_proto::client::GetChatHistoryResponse {
messages,
next_cursor: next_cursor.unwrap_or_default(),
event_cursor: Some(synctv_proto::client::EventCursor {
event_id: page.event_cursor.event_id,
sequence: page.event_cursor.sequence,
}),
})
}
pub async fn get_room_chat_message_context(
&self,
room_id_raw: &str,
req: synctv_proto::client::GetChatMessageContextRequest,
) -> Result<synctv_proto::client::GetChatMessageContextResponse, ApiError> {
crate::impls::validate_proto_request(&req)?;
let room_id = crate::impls::proto_validated_room_id(room_id_raw, &self.public_id_codec)?;
if !self
.room_service
.room_exists(&room_id)
.await
.map_err(ApiError::from)?
{
return Err(ApiError::NotFound("Room not found".to_string()));
}
let message_id = req.message_id.parse::<i64>().map_err(|_| {
ApiError::InvalidInput("message_id must be a numeric chat message ID".to_string())
})?;
let chat_service = self.chat_service.as_ref().ok_or_else(|| {
ApiError::ServiceUnavailable("Chat service is unavailable".to_string())
})?;
let limit = |value: i32| if value <= 0 { 20 } else { value.min(50) };
let context = chat_service
.get_message_context_for_viewer(
&room_id,
message_id,
limit(req.before_limit),
limit(req.after_limit),
req.include_deleted,
None,
)
.await
.map_err(ApiError::from)?;
let before_len = context.before.len();
let after_len = context.after.len();
let mut messages = context.before;
messages.push(context.anchor);
messages.extend(context.after);
let messages = self.admin_chat_messages_to_proto(messages).await?;
let mut messages = messages.into_iter();
let before = messages.by_ref().take(before_len).collect();
let message = messages
.next()
.ok_or_else(|| ApiError::Internal("Chat context anchor is missing".to_string()))?;
let after = messages.take(after_len).collect();
Ok(synctv_proto::client::GetChatMessageContextResponse {
before,
message: Some(message),
after,
})
}
pub async fn moderate_room_chat_user(
&self,
req: synctv_proto::admin::ModerateRoomChatUserRequest,
admin_user_id: &UserId,
caller_role: UserRole,
) -> Result<synctv_proto::admin::ModerateRoomChatUserResponse, ApiError> {
crate::impls::validate_proto_request(&req)?;
if !req.delete_all_messages
&& !req.delete_all_reactions
&& !req.ban_user
&& req.message_id.trim().is_empty()
{
return Err(ApiError::InvalidInput(
"At least one moderation action is required".to_string(),
));
}
let room_id: RoomId =
crate::impls::proto_validated_room_id(req.room_id, &self.public_id_codec)?;
let target_user_id: UserId =
crate::impls::proto_validated_user_id(req.user_id, &self.public_id_codec)?;
if !self
.room_service
.room_exists(&room_id)
.await
.map_err(ApiError::from)?
{
return Err(ApiError::NotFound("Room not found".to_string()));
}
let reason = (!req.reason.trim().is_empty()).then(|| req.reason.trim().to_string());
let message_id = if req.message_id.trim().is_empty() {
None
} else {
let message_id = req.message_id.parse::<i64>().map_err(|_| {
ApiError::InvalidInput("message_id must be a numeric chat message ID".to_string())
})?;
if message_id <= 0 {
return Err(ApiError::InvalidInput(
"message_id must be positive".to_string(),
));
}
Some(message_id)
};
let chat_service = self.chat_service.as_ref().ok_or_else(|| {
ApiError::ServiceUnavailable("Chat service is unavailable".to_string())
})?;
let admin_actor = self.require_authorized_admin_actor(admin_user_id).await?;
self.validate_user_action_target(&target_user_id, caller_role, "moderate chat for")
.await?;
if let Some(message_id) = message_id {
chat_service
.validate_moderation_message_anchor(&room_id, message_id, &target_user_id)
.await
.map_err(ApiError::from)?;
}
let operation_id = synctv_common::snanoid!(16);
chat_service
.moderation_job_repository()
.insert(&NewChatModerationJob {
id: operation_id.clone(),
room_id,
target_user_id,
actor_user_id: *admin_user_id,
actor_username: admin_actor.username().to_string(),
actor_role: caller_role,
message_id,
ban_user: req.ban_user,
delete_all_messages: req.delete_all_messages,
delete_all_reactions: req.delete_all_reactions,
reason: reason.or_else(|| Some("admin_bulk_deleted".to_string())),
snapshot_at: self.clock.now(),
})
.await
.map_err(ApiError::from)?;
tracing::info!(
job_id = %operation_id,
room_id = %room_id,
target_user_id = %target_user_id,
"Chat moderation job queued"
);
Ok(synctv_proto::admin::ModerateRoomChatUserResponse {})
}
pub async fn process_chat_moderation_jobs(
&self,
worker_id: &str,
limit: i64,
) -> Result<bool, ApiError> {
let chat_service = self.chat_service.as_ref().ok_or_else(|| {
ApiError::ServiceUnavailable("Chat service is unavailable".to_string())
})?;
let jobs = chat_service
.moderation_job_repository()
.claim_batch(worker_id, limit)
.await
.map_err(ApiError::from)?;
if jobs.is_empty() {
return Ok(false);
}
for job in jobs {
if let Err(error) = self
.process_chat_moderation_job(chat_service, &job, worker_id)
.await
{
let message = error.to_string();
match chat_service
.moderation_job_repository()
.mark_failed(&job, worker_id, &message)
.await
{
Ok(true) if job.attempts.saturating_add(1) >= 10 => {
tracing::error!(
error = %message,
job_id = %job.id,
attempts = job.attempts.saturating_add(1),
"Chat moderation job permanently failed"
);
}
Ok(true) => {
tracing::warn!(
error = %message,
job_id = %job.id,
attempts = job.attempts.saturating_add(1),
"Chat moderation job failed and will be retried"
);
}
Ok(false) => {
tracing::warn!(
job_id = %job.id,
"Chat moderation job failure was not persisted because its lease changed"
);
}
Err(mark_error) => {
tracing::error!(error = %mark_error, job_id = %job.id, "Failed to persist chat moderation job error");
}
}
}
}
Ok(true)
}
async fn process_chat_moderation_job(
&self,
chat_service: &synctv_core::service::ChatService,
job: &ChatModerationJob,
worker_id: &str,
) -> Result<(), ApiError> {
let actor = synctv_core::service::AuthorizedAdminActor::for_persisted_job(
job.actor_user_id,
job.actor_username.clone(),
);
let mut next = job.clone();
let dispatcher = self.chat_event_dispatcher.clone();
let mut phase = job.phase;
let mut deleted_messages = job.deleted_messages;
let mut deleted_reactions = job.deleted_reactions;
let mut explicit_message_done = job.explicit_message_done;
let mut ban_done = job.ban_done;
let mut snapshot_at = job.snapshot_at;
let (message_cursor, reaction_cursor, hidden_reaction_cursor) = (
job.message_cursor,
job.reaction_cursor,
job.hidden_reaction_cursor.clone(),
);
let mut message_cursor = message_cursor;
let mut reaction_cursor = reaction_cursor;
let mut hidden_reaction_cursor = hidden_reaction_cursor;
let moderation_progress = ChatModerationProgress {
job_id: &job.id,
worker_id,
lock_version: job.lock_version,
};
if let Some(message_id) = job.message_id.filter(|_| !explicit_message_done) {
let outcome = chat_service
.delete_moderation_message_event_outcome_as_admin_with_progress(
&job.room_id,
message_id,
&job.target_user_id,
&actor,
job.reason.as_deref().or(Some("admin_deleted")),
Some(moderation_progress),
)
.await
.map_err(ApiError::from)?;
if let Some(outcome) = outcome.filter(|outcome| outcome.inserted) {
deleted_messages = deleted_messages.checked_add(1).ok_or_else(|| {
ApiError::Internal("Deleted message count exceeds i64::MAX".to_string())
})?;
deleted_reactions = deleted_reactions
.checked_add(i64::try_from(outcome.deleted_reactions).map_err(|_| {
ApiError::Internal("Deleted reaction count exceeds i64::MAX".to_string())
})?)
.ok_or_else(|| {
ApiError::Internal("Deleted reaction count exceeds i64::MAX".to_string())
})?;
dispatcher.dispatch(&outcome.event);
if let Some(pin_event) = &outcome.pin_event {
dispatcher.dispatch_pin(pin_event);
}
}
explicit_message_done = true;
}
if job.ban_user && !ban_done {
let newly_banned = self
.ensure_persisted_user_banned_with_cleanup(
&job.target_user_id,
&job.actor_user_id,
job.actor_role,
job.reason
.clone()
.or_else(|| Some("chat_moderation".to_string())),
)
.await?;
if newly_banned {
if let Err(error) = self
.audit_service
.log(synctv_core::service::AuditEventParams {
actor_id: job.actor_user_id.to_string(),
actor_username: job.actor_username.clone(),
action: synctv_core::models::AuditAction::UserBanned,
target_type: synctv_core::models::AuditTargetType::User,
target_id: Some(job.target_user_id.to_string()),
details: AuditDetails {
target_user_id: Some(job.target_user_id.to_string()),
caller_role: Some(format!("{:?}", job.actor_role)),
..Default::default()
},
ip_address: None,
user_agent: None,
})
.await
{
tracing::error!(error = %error, job_id = %job.id, "Failed to write async chat moderation ban audit log");
}
}
ban_done = true;
snapshot_at = self.clock.now();
}
match phase {
ChatModerationJobPhase::Messages if job.delete_all_messages => {
let page = chat_service
.moderate_user_messages_page(
synctv_core::service::ChatUserModerationPageRequest {
room_id: &job.room_id,
target_user_id: &job.target_user_id,
actor: &actor,
reason: job.reason.as_deref(),
created_before: snapshot_at,
cursor: message_cursor,
moderation_progress: Some(moderation_progress),
},
move |event, pin_event| {
dispatcher.dispatch(event);
if let Some(pin_event) = pin_event {
dispatcher.dispatch_pin(pin_event);
}
},
)
.await
.map_err(ApiError::from)?;
deleted_messages = deleted_messages
.checked_add(i64::try_from(page.outcome.deleted_messages).map_err(|_| {
ApiError::Internal("Deleted message count exceeds i64::MAX".to_string())
})?)
.ok_or_else(|| {
ApiError::Internal("Deleted message count exceeds i64::MAX".to_string())
})?;
deleted_reactions = deleted_reactions
.checked_add(i64::try_from(page.outcome.deleted_reactions).map_err(|_| {
ApiError::Internal("Deleted reaction count exceeds i64::MAX".to_string())
})?)
.ok_or_else(|| {
ApiError::Internal("Deleted reaction count exceeds i64::MAX".to_string())
})?;
message_cursor = page.next_cursor;
phase = if page.done {
if job.delete_all_reactions {
ChatModerationJobPhase::Reactions
} else {
ChatModerationJobPhase::Done
}
} else {
ChatModerationJobPhase::Messages
};
}
ChatModerationJobPhase::Messages => {
phase = if job.delete_all_reactions {
ChatModerationJobPhase::Reactions
} else {
ChatModerationJobPhase::Done
};
}
ChatModerationJobPhase::Reactions if job.delete_all_reactions => {
let dispatcher = self.chat_event_dispatcher.clone();
let page = chat_service
.remove_user_reactions_page(
synctv_core::service::ChatUserReactionModerationPageRequest {
room_id: &job.room_id,
target_user_id: &job.target_user_id,
actor_user_id: actor.user_id(),
created_before: snapshot_at,
cursor: reaction_cursor,
hidden_cursor: hidden_reaction_cursor,
moderation_progress: Some(moderation_progress),
},
move |event, pin_event| {
dispatcher.dispatch(event);
if let Some(pin_event) = pin_event {
dispatcher.dispatch_pin(pin_event);
}
},
)
.await
.map_err(ApiError::from)?;
deleted_reactions = deleted_reactions
.checked_add(i64::try_from(page.deleted_reactions).map_err(|_| {
ApiError::Internal("Deleted reaction count exceeds i64::MAX".to_string())
})?)
.ok_or_else(|| {
ApiError::Internal("Deleted reaction count exceeds i64::MAX".to_string())
})?;
reaction_cursor = page.next_cursor;
hidden_reaction_cursor = page.hidden_next_cursor;
phase = if page.hidden_done {
ChatModerationJobPhase::Done
} else {
ChatModerationJobPhase::Reactions
};
}
ChatModerationJobPhase::Reactions => phase = ChatModerationJobPhase::Done,
ChatModerationJobPhase::Done => {}
}
next.phase = phase;
next.message_cursor = message_cursor;
next.reaction_cursor = reaction_cursor;
next.hidden_reaction_cursor = hidden_reaction_cursor.clone();
next.deleted_messages = deleted_messages;
next.deleted_reactions = deleted_reactions;
next.snapshot_at = snapshot_at;
if phase == ChatModerationJobPhase::Done {
let updated = chat_service
.moderation_job_repository()
.update_progress(&next, worker_id)
.await
.map_err(ApiError::from)?;
if !updated {
return Ok(());
}
next.lock_version += 1;
let completed = chat_service
.moderation_job_repository()
.mark_completed(&next, worker_id, deleted_messages, deleted_reactions)
.await
.map_err(ApiError::from)?;
if !completed {
return Ok(());
}
} else {
let updated = chat_service
.moderation_job_repository()
.update_progress(&next, worker_id)
.await
.map_err(ApiError::from)?;
if !updated {
return Ok(());
}
}
Ok(())
}
}

@ -54,16 +54,65 @@ impl AdminApiImpl {
caller_role: UserRole,
reason: Option<String>,
) -> Result<synctv_core::models::User, ApiError> {
let user = self
.user_service
.get_user(target_user_id)
.await
.map_err(ApiError::from)?;
self.ban_user_with_cleanup_inner(
target_user_id,
admin_user_id,
caller_role,
reason,
false,
true,
)
.await?
.ok_or_else(|| ApiError::InvalidInput("User is already banned".to_string()))
}
check_role_hierarchy(caller_role, user.role, "ban")?;
/// Completes a moderation command that was authorized before it was queued.
/// The target role is intentionally read without re-checking hierarchy so a
/// later role change cannot leave an already-authorized command half-done.
pub(in crate::impls::admin) async fn ensure_persisted_user_banned_with_cleanup(
&self,
target_user_id: &UserId,
admin_user_id: &UserId,
caller_role: UserRole,
reason: Option<String>,
) -> Result<bool, ApiError> {
Ok(self
.ban_user_with_cleanup_inner(
target_user_id,
admin_user_id,
caller_role,
reason,
true,
false,
)
.await?
.is_some())
}
if user.is_banned {
return Err(ApiError::InvalidInput("User is already banned".to_string()));
async fn ban_user_with_cleanup_inner(
&self,
target_user_id: &UserId,
admin_user_id: &UserId,
caller_role: UserRole,
reason: Option<String>,
allow_already_banned: bool,
validate_target_role: bool,
) -> Result<Option<synctv_core::models::User>, ApiError> {
let target_user = if validate_target_role {
self.validate_user_action_target(target_user_id, caller_role, "ban")
.await?
} else {
self.user_service
.get_user(target_user_id)
.await
.map_err(ApiError::from)?
};
if target_user.is_banned {
return if allow_already_banned {
Ok(None)
} else {
Err(ApiError::InvalidInput("User is already banned".to_string()))
};
}
let (affected_room_ids, owned_room_ids) = tokio::try_join!(
@ -130,7 +179,7 @@ impl AdminApiImpl {
}) as synctv_core::service::RealtimeOutboxOwnedMediaKickEventFactory
};
let updated = self
let updated = match self
.room_service
.ban_user_and_reset_owned_playback_with_outbox_and_media_kicks(
target_user_id,
@ -141,7 +190,20 @@ impl AdminApiImpl {
Some(owned_media_kick_events_factory),
)
.await
.map_err(ApiError::from)?;
{
Ok(updated) => updated,
Err(_error)
if allow_already_banned
&& self
.user_service
.is_user_banned(target_user_id)
.await
.map_err(ApiError::from)? =>
{
return Ok(None);
}
Err(error) => return Err(ApiError::from(error)),
};
prepared_playback_reset.publish_after_outbox_commit();
@ -179,8 +241,24 @@ impl AdminApiImpl {
.disconnect_user(target_user_id, "user_banned")
.await;
Ok(updated)
Ok(Some(updated))
}
pub(in crate::impls::admin) async fn validate_user_action_target(
&self,
target_user_id: &UserId,
caller_role: UserRole,
action: &str,
) -> Result<synctv_core::models::User, ApiError> {
let user = self
.user_service
.get_user(target_user_id)
.await
.map_err(ApiError::from)?;
check_role_hierarchy(caller_role, user.role, action)?;
Ok(user)
}
pub(in crate::impls::admin) async fn list_owned_room_ids(
&self,
user_id: &UserId,

@ -722,6 +722,7 @@ async fn make_admin_api_for_delete_user_test_with_runtime(
AdminApiImpl::new_with_runtime(
AdminApiOptions {
room_service,
chat_service: None,
read_services: crate::test_support::admin_read_services(user_service.as_ref()),
user_service,
settings_service,
@ -837,6 +838,7 @@ async fn make_admin_api_with_livestream_for_test(
AdminApiImpl::new_with_runtime(
AdminApiOptions {
room_service,
chat_service: None,
read_services: crate::test_support::admin_read_services(user_service.as_ref()),
user_service,
settings_service,
@ -3291,6 +3293,7 @@ async fn test_update_settings_persists_when_global_cache_invalidation_fanout_fai
let admin_api = AdminApiImpl::new_with_runtime(
AdminApiOptions {
room_service: admin_api.room_service.clone(),
chat_service: admin_api.chat_service.clone(),
user_service: admin_api.user_service.clone(),
read_services: AdminReadServices {
system_stats_service: admin_api.system_stats_service.clone(),

@ -25,6 +25,7 @@ pub mod playback_lifecycle;
pub mod playlist;
mod report;
mod room;
pub(crate) use room::build_get_chat_history_request;
pub use room::{
build_search_chat_messages_query, parse_optional_room_category_id,
parse_proto_chat_attachments, parse_room_label_ids,

@ -27,6 +27,7 @@ use super::media::{
use super::{ClientApiImpl, RoomActor};
mod support;
pub(crate) use support::build_get_chat_history_request;
use support::*;
pub use support::{
build_search_chat_messages_query, parse_optional_room_category_id,

@ -712,7 +712,7 @@ pub fn build_create_websocket_ticket_request(
crate::impls::proto_validated_room_id(req.room_id.clone(), public_id_codec)
}
pub(super) type ChatHistoryCursor = (chrono::DateTime<chrono::Utc>, i64);
pub(crate) type ChatHistoryCursor = (chrono::DateTime<chrono::Utc>, i64);
pub(super) type ChatReactionUsersCursor = (chrono::DateTime<chrono::Utc>, UserId);
fn parse_chat_history_cursor(cursor: &str) -> Result<Option<ChatHistoryCursor>, ApiError> {
@ -730,7 +730,7 @@ fn parse_chat_history_cursor(cursor: &str) -> Result<Option<ChatHistoryCursor>,
Ok(Some((ts, id)))
}
pub(super) fn build_get_chat_history_request(
pub(crate) fn build_get_chat_history_request(
req: &synctv_proto::client::GetChatHistoryRequest,
) -> Result<(i32, Option<ChatHistoryCursor>, ChatMessageSelection), ApiError> {
crate::impls::validate_proto_request(req)?;

@ -30,13 +30,13 @@ use synctv_proto::admin::{
ListRoomCreationReviewsRequest, ListRoomCreationReviewsResponse, ListRoomJoinReviewsRequest,
ListRoomJoinReviewsResponse, ListRoomLabelsRequest, ListRoomLabelsResponse, ListRoomsRequest,
ListRoomsResponse, ListUserRegistrationReviewsRequest, ListUserRegistrationReviewsResponse,
ListUsersRequest, ListUsersResponse, PurgeSliceCacheRequest, PurgeSliceCacheResponse,
RejectRoomCreationReviewRequest, RejectRoomJoinReviewRequest,
RejectUserRegistrationReviewRequest, RemoveAdminRequest, RemoveAdminResponse,
ResetRoomSettingsRequest, RestoreUserRequest, RestoreUserResponse, Room, RoomCreationReview,
RoomJoinReview, RuntimeSettingsSnapshot, SendTestEmailRequest, SendTestEmailResponse,
SetUserPasswordRequest, SetUserPasswordResponse, UnbanRoomRequest, UnbanUserRequest,
UpdateContentReportStatusRequest, UpdateContentReportStatusResponse,
ListUsersRequest, ListUsersResponse, ModerateRoomChatUserRequest, ModerateRoomChatUserResponse,
PurgeSliceCacheRequest, PurgeSliceCacheResponse, RejectRoomCreationReviewRequest,
RejectRoomJoinReviewRequest, RejectUserRegistrationReviewRequest, RemoveAdminRequest,
RemoveAdminResponse, ResetRoomSettingsRequest, RestoreUserRequest, RestoreUserResponse, Room,
RoomCreationReview, RoomJoinReview, RuntimeSettingsSnapshot, SendTestEmailRequest,
SendTestEmailResponse, SetUserPasswordRequest, SetUserPasswordResponse, UnbanRoomRequest,
UnbanUserRequest, UpdateContentReportStatusRequest, UpdateContentReportStatusResponse,
UpdateMemberDisplayTagRequest, UpdateMemberPermissionsRequest, UpdateMemberRemarkNameRequest,
UpdateRoomPasswordRequest, UpdateRoomPasswordResponse, UpdateRoomSettingsRequest,
UpdateRoomTaxonomyRequest, UpdateSettingsRequest, UpdateUserPreferencesRequest,
@ -424,6 +424,18 @@ impl AdminService for AdminServiceImpl {
.await
}
async fn moderate_room_chat_user(
&self,
request: Request<ModerateRoomChatUserRequest>,
) -> Result<Response<ModerateRoomChatUserResponse>, Status> {
self.execute_admin_rpc(request, move |api, validated, _, req| async move {
synctv_api_common::impls::validate_proto_request(&req)?;
api.moderate_room_chat_user(req, &validated.user_id, validated.role)
.await
})
.await
}
async fn get_user_rooms(
&self,
request: Request<GetUserRoomsRequest>,

@ -3074,6 +3074,7 @@ mod tests {
let admin_api = Arc::new(AdminApiImpl::new_with_runtime(
AdminApiOptions {
room_service: context.room_service.clone(),
chat_service: None,
user_service: context.user_service.clone(),
read_services: synctv_api_common::test_support::admin_read_services(
context.user_service.as_ref(),

@ -36,6 +36,19 @@ pub(crate) struct RoomMemberTargetPath {
user_id: String,
}
#[derive(Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct AdminRoomPath {
room_id: String,
}
#[derive(Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct AdminChatMessagePath {
room_id: String,
message_id: String,
}
#[derive(Debug, Default, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "openapi", derive(utoipa::IntoParams))]
@ -221,6 +234,15 @@ pub(crate) fn create_admin_router() -> Router<AppState> {
)
.route("/rooms/{roomId}/ban", post(ban_room))
.route("/rooms/{roomId}/unban", post(unban_room))
.route(
"/rooms/{roomId}/chat/users/{userId}/moderate",
post(moderate_room_chat_user),
)
.route("/rooms/{roomId}/chat/history", get(get_room_chat_history))
.route(
"/rooms/{roomId}/chat/messages/{messageId}/context",
get(get_room_chat_message_context),
)
.route(
"/rooms/{roomId}/settings",
get(get_room_settings).post(set_room_settings),
@ -1432,6 +1454,127 @@ pub(crate) async fn ban_user(
Ok(Json(resp))
}
#[cfg_attr(
feature = "openapi",
utoipa::path(
post,
path = "/api/admin/rooms/{roomId}/chat/users/{userId}/moderate",
tag = "Admin",
params(
("roomId" = String, Path, description = "Room ID"),
("userId" = String, Path, description = "User ID")
),
request_body = admin::ModerateRoomChatUserRequest,
responses(
(status = 202, description = "Chat user moderation accepted", body = admin::ModerateRoomChatUserResponse),
(status = 400, description = "Invalid request", body = crate::openapi::GoogleRpcStatusSchema),
(status = 401, description = "Admin authentication required", body = crate::openapi::GoogleRpcStatusSchema)
),
security(("bearer_auth" = []))
)
)]
pub(crate) async fn moderate_room_chat_user(
request_meta: RequestMetadata,
State(state): State<AppState>,
Path(path): Path<RoomMemberTargetPath>,
Json(mut req): Json<admin::ModerateRoomChatUserRequest>,
) -> AppResult<(
axum::http::StatusCode,
Json<admin::ModerateRoomChatUserResponse>,
)> {
req.room_id = path.room_id;
req.user_id = path.user_id;
synctv_api_common::impls::validate_proto_request(&req).map_err(super::error::map_api_error)?;
let resp = execute_admin_endpoint(
&state,
request_meta,
require_admin_api,
move |api, validated, _| async move {
api.moderate_room_chat_user(req, &validated.user_id, validated.role)
.await
},
)
.await?;
Ok((axum::http::StatusCode::ACCEPTED, Json(resp)))
}
#[cfg_attr(
feature = "openapi",
utoipa::path(
get,
path = "/api/admin/rooms/{roomId}/chat/history",
tag = "Admin",
params(
("roomId" = String, Path, description = "Room ID"),
crate::http::room::GetChatHistoryQuery
),
responses(
(status = 200, description = "Chat history", body = client::GetChatHistoryResponse),
(status = 400, description = "Invalid request", body = crate::openapi::GoogleRpcStatusSchema),
(status = 401, description = "Admin authentication required", body = crate::openapi::GoogleRpcStatusSchema),
(status = 404, description = "Room not found", body = crate::openapi::GoogleRpcStatusSchema)
),
security(("bearer_auth" = []))
)
)]
pub(crate) async fn get_room_chat_history(
request_meta: RequestMetadata,
State(state): State<AppState>,
Path(path): Path<AdminRoomPath>,
ProtoQuery(query): ProtoQuery<crate::http::room::GetChatHistoryQuery>,
) -> AppResult<Json<client::GetChatHistoryResponse>> {
let req = query.into_request()?;
let room_id = path.room_id;
let resp = execute_admin_endpoint(
&state,
request_meta,
require_admin_api,
move |api, _validated, _ctx| async move { api.get_room_chat_history(&room_id, req).await },
)
.await?;
Ok(Json(resp))
}
#[cfg_attr(
feature = "openapi",
utoipa::path(
get,
path = "/api/admin/rooms/{roomId}/chat/messages/{messageId}/context",
tag = "Admin",
params(
("roomId" = String, Path, description = "Room ID"),
("messageId" = String, Path, description = "Chat message ID"),
crate::http::room::GetChatMessageContextQuery
),
responses(
(status = 200, description = "Chat message context", body = client::GetChatMessageContextResponse),
(status = 400, description = "Invalid request", body = crate::openapi::GoogleRpcStatusSchema),
(status = 401, description = "Admin authentication required", body = crate::openapi::GoogleRpcStatusSchema),
(status = 404, description = "Message or room not found", body = crate::openapi::GoogleRpcStatusSchema)
),
security(("bearer_auth" = []))
)
)]
pub(crate) async fn get_room_chat_message_context(
request_meta: RequestMetadata,
State(state): State<AppState>,
Path(path): Path<AdminChatMessagePath>,
ProtoQuery(query): ProtoQuery<crate::http::room::GetChatMessageContextQuery>,
) -> AppResult<Json<client::GetChatMessageContextResponse>> {
let req = query.into_request(path.message_id);
let room_id = path.room_id;
let resp = execute_admin_endpoint(
&state,
request_meta,
require_admin_api,
move |api, _validated, _ctx| async move {
api.get_room_chat_message_context(&room_id, req).await
},
)
.await?;
Ok(Json(resp))
}
#[cfg_attr(
feature = "openapi",
utoipa::path(

@ -30,6 +30,7 @@ pub(crate) use chat::{
list_pinned_chat_messages, mark_chat_read, pin_chat_message, search_chat_messages,
send_chat_message, set_chat_reaction, unpin_chat_message,
};
pub(crate) use chat::{GetChatHistoryQuery, GetChatMessageContextQuery};
pub(crate) use execute::execute_room_actor_endpoint;
use execute::request_metadata;
#[cfg(feature = "openapi")]

@ -34,7 +34,7 @@ pub struct GetChatHistoryQuery {
}
impl GetChatHistoryQuery {
pub(super) fn into_request(self) -> AppResult<GetChatHistoryRequest> {
pub(crate) fn into_request(self) -> AppResult<GetChatHistoryRequest> {
Ok(GetChatHistoryRequest {
limit: self.limit,
cursor: self.cursor,
@ -77,7 +77,7 @@ pub struct GetChatMessageContextQuery {
}
impl GetChatMessageContextQuery {
fn into_request(self, message_id: String) -> GetChatMessageContextRequest {
pub(crate) fn into_request(self, message_id: String) -> GetChatMessageContextRequest {
GetChatMessageContextRequest {
message_id,
before_limit: self.before_limit,

@ -200,6 +200,7 @@ fn test_core_api_impls(
synctv_api_common::impls::AdminApiImpl::new_with_runtime(
synctv_api_common::impls::AdminApiOptions {
room_service,
chat_service: None,
user_service: user_service.clone(),
read_services: synctv_api_common::test_support::admin_read_services(
user_service.as_ref(),

@ -420,6 +420,9 @@ pub struct GoogleRpcStatusSchema {
admin::set_user_password,
admin::set_user_username,
admin::ban_user,
admin::get_room_chat_history,
admin::get_room_chat_message_context,
admin::moderate_room_chat_user,
admin::unban_user,
admin::get_user_rooms,
admin::batch_ban_users,

@ -203,6 +203,7 @@ async fn make_admin_api(pool: sqlx::PgPool) -> AdminApiImpl {
AdminApiImpl::new_with_runtime(
AdminApiOptions {
room_service: Arc::new(room_service),
chat_service: None,
read_services: support::admin_read_services(user_service.as_ref()),
user_service,
settings_service,

@ -148,6 +148,7 @@ async fn make_admin_api(pool: sqlx::PgPool) -> AdminApiImpl {
AdminApiImpl::new_with_runtime(
AdminApiOptions {
room_service: Arc::new(room_service),
chat_service: None,
read_services: support::admin_read_services(user_service.as_ref()),
user_service,
settings_service,

@ -39,6 +39,28 @@ pub struct InsertChatMessageEvent<'a> {
pub occurred_at: DateTime<Utc>,
}
/// Lease identity used when a moderation worker mutates chat data.
///
/// The progress update is performed in the same transaction as the data
/// mutation, so an expired worker lease rolls back the mutation as well.
#[derive(Clone, Copy)]
pub struct ChatModerationProgress<'a> {
pub job_id: &'a str,
pub worker_id: &'a str,
pub lock_version: i64,
}
pub struct DeleteChatReactionsPageRequest<'a> {
pub room_id: &'a RoomId,
pub user_id: &'a UserId,
pub actor_user_id: &'a UserId,
pub occurred_at: DateTime<Utc>,
pub created_before: DateTime<Utc>,
pub cursor: Option<(DateTime<Utc>, i64)>,
pub limit: i64,
pub moderation_progress: Option<ChatModerationProgress<'a>>,
}
struct ChatHistoryCursorRequest<'a> {
room_id: &'a RoomId,
cursor: Option<ChatHistoryCursor>,
@ -80,6 +102,13 @@ struct ChatMessageRow {
created_at: DateTime<Utc>,
}
#[derive(sqlx::FromRow)]
struct HiddenReactionCleanupRow {
message_id: i64,
message_created_at: DateTime<Utc>,
reaction_key: String,
}
impl TryFrom<ChatMessageRow> for ChatMessage {
type Error = crate::Error;
@ -381,6 +410,7 @@ impl ChatRepository {
event,
inserted: false,
pin_event: None,
deleted_reactions: 0,
});
}
}
@ -433,6 +463,7 @@ impl ChatRepository {
event: logged,
inserted: true,
pin_event: None,
deleted_reactions: 0,
})
}
@ -960,6 +991,7 @@ impl ChatRepository {
event: logged,
inserted: true,
pin_event,
deleted_reactions: 0,
})
}
@ -2196,6 +2228,449 @@ impl ChatRepository {
})
}
/// Lists one bounded page of active messages authored by a user for
/// server-side moderation. The keyset cursor remains valid while earlier
/// rows are deleted by the moderation loop.
pub async fn list_active_messages_for_user_page(
&self,
room_id: &RoomId,
user_id: &UserId,
cursor: Option<(DateTime<Utc>, i64)>,
created_before: DateTime<Utc>,
limit: i64,
) -> Result<Vec<ChatMessage>> {
let limit = limit.clamp(1, 100);
let rows = if let Some((created_at, id)) = cursor {
sqlx::query_as!(
ChatMessageRow,
r#"
SELECT id AS "id!",
room_id AS "room_id!: RoomId",
user_id AS "user_id?: UserId",
client_message_id,
content AS "content!",
message_type AS "message_type!: ChatMessageType",
status AS "status!: ChatMessageStatus",
version AS "version!",
reply_to_message_id,
reply_to_message_created_at,
metadata AS "metadata?: ChatMetadata",
edited_at,
deleted_at,
deleted_by AS "deleted_by?: UserId",
delete_reason,
created_at AS "created_at!"
FROM chat_messages
WHERE room_id = $1
AND user_id = $2
AND status <> $3
AND deletion_source IS DISTINCT FROM $4
AND created_at <= $5
AND (created_at, id) > ($6, $7)
ORDER BY created_at ASC, id ASC
LIMIT $8
"#,
room_id.as_i64(),
user_id.as_i64(),
i16::from(ChatMessageStatus::Deleted),
DeletionSource::Account as DeletionSource,
created_before,
created_at,
id,
limit
)
.fetch_all(self.pool())
.await?
} else {
sqlx::query_as!(
ChatMessageRow,
r#"
SELECT id AS "id!",
room_id AS "room_id!: RoomId",
user_id AS "user_id?: UserId",
client_message_id,
content AS "content!",
message_type AS "message_type!: ChatMessageType",
status AS "status!: ChatMessageStatus",
version AS "version!",
reply_to_message_id,
reply_to_message_created_at,
metadata AS "metadata?: ChatMetadata",
edited_at,
deleted_at,
deleted_by AS "deleted_by?: UserId",
delete_reason,
created_at AS "created_at!"
FROM chat_messages
WHERE room_id = $1
AND user_id = $2
AND status <> $3
AND deletion_source IS DISTINCT FROM $4
AND created_at <= $5
ORDER BY created_at ASC, id ASC
LIMIT $6
"#,
room_id.as_i64(),
user_id.as_i64(),
i16::from(ChatMessageStatus::Deleted),
DeletionSource::Account as DeletionSource,
created_before,
limit
)
.fetch_all(self.pool())
.await?
};
chat_messages_from_rows(rows)
}
pub async fn delete_reactions_by_user_with_events_page(
&self,
request: DeleteChatReactionsPageRequest<'_>,
) -> Result<(
u64,
Vec<ChatMessageEvent>,
Vec<ChatPinEvent>,
Option<(DateTime<Utc>, i64)>,
)> {
let DeleteChatReactionsPageRequest {
room_id,
user_id,
actor_user_id,
occurred_at,
created_before,
cursor,
limit,
moderation_progress,
} = request;
let limit = limit.clamp(1, 100);
let mut tx = self.pool().begin().await?;
let rows = if let Some((created_at, id)) = cursor {
sqlx::query_as!(
ChatMessageRow,
r#"
SELECT m.id AS "id!",
m.room_id AS "room_id!: RoomId",
m.user_id AS "user_id?: UserId",
m.client_message_id,
m.content AS "content!",
m.message_type AS "message_type!: ChatMessageType",
m.status AS "status!: ChatMessageStatus",
m.version AS "version!",
m.reply_to_message_id,
m.reply_to_message_created_at,
m.metadata AS "metadata?: ChatMetadata",
m.edited_at,
m.deleted_at,
m.deleted_by AS "deleted_by?: UserId",
m.delete_reason,
m.created_at AS "created_at!"
FROM chat_messages m
WHERE m.room_id = $1
AND m.status <> $3
AND m.deletion_source IS DISTINCT FROM $4
AND m.created_at <= $5
AND EXISTS (
SELECT 1 FROM chat_message_reactions r
WHERE r.room_id = m.room_id
AND r.message_id = m.id
AND r.message_created_at = m.created_at
AND r.user_id = $2
AND r.created_at <= $6
)
AND (m.created_at, m.id) > ($7, $8)
ORDER BY m.created_at ASC, m.id ASC
LIMIT $9
FOR UPDATE
"#,
room_id.as_i64(),
user_id.as_i64(),
i16::from(ChatMessageStatus::Deleted),
DeletionSource::Account as DeletionSource,
created_before,
created_before,
created_at,
id,
limit
)
.fetch_all(&mut *tx)
.await?
} else {
sqlx::query_as!(
ChatMessageRow,
r#"
SELECT m.id AS "id!",
m.room_id AS "room_id!: RoomId",
m.user_id AS "user_id?: UserId",
m.client_message_id,
m.content AS "content!",
m.message_type AS "message_type!: ChatMessageType",
m.status AS "status!: ChatMessageStatus",
m.version AS "version!",
m.reply_to_message_id,
m.reply_to_message_created_at,
m.metadata AS "metadata?: ChatMetadata",
m.edited_at,
m.deleted_at,
m.deleted_by AS "deleted_by?: UserId",
m.delete_reason,
m.created_at AS "created_at!"
FROM chat_messages m
WHERE m.room_id = $1
AND m.status <> $3
AND m.deletion_source IS DISTINCT FROM $4
AND m.created_at <= $5
AND EXISTS (
SELECT 1 FROM chat_message_reactions r
WHERE r.room_id = m.room_id
AND r.message_id = m.id
AND r.message_created_at = m.created_at
AND r.user_id = $2
AND r.created_at <= $6
)
ORDER BY m.created_at ASC, m.id ASC
LIMIT $7
FOR UPDATE
"#,
room_id.as_i64(),
user_id.as_i64(),
i16::from(ChatMessageStatus::Deleted),
DeletionSource::Account as DeletionSource,
created_before,
created_before,
limit
)
.fetch_all(&mut *tx)
.await?
};
let messages = chat_messages_from_rows(rows)?;
if messages.is_empty() {
tx.commit().await?;
return Ok((0, Vec::new(), Vec::new(), None));
}
let message_ids: Vec<i64> = messages.iter().map(|message| message.id).collect();
let message_created_ats: Vec<DateTime<Utc>> =
messages.iter().map(|message| message.created_at).collect();
let result = sqlx::query!(
r#"
DELETE FROM chat_message_reactions AS r
WHERE r.room_id = $1
AND r.user_id = $2
AND r.created_at <= $5
AND EXISTS (
SELECT 1
FROM unnest($3::bigint[], $4::timestamptz[])
AS selected(message_id, message_created_at)
WHERE selected.message_id = r.message_id
AND selected.message_created_at = r.message_created_at
)
"#,
room_id.as_i64(),
user_id.as_i64(),
&message_ids,
&message_created_ats,
created_before,
)
.execute(&mut *tx)
.await?;
let mut events = Vec::with_capacity(messages.len());
let mut pin_events = Vec::new();
for message in &messages {
let payload = self
.message_event_payload_in_tx(&mut tx, room_id, message, Some(actor_user_id))
.await?;
let pin = payload.pin.clone();
let event = ChatMessageEvent {
event_id: synctv_common::snanoid!(16),
sequence: 0,
room_id: *room_id,
actor_user_id: *actor_user_id,
kind: ChatEventKind::ReactionsChanged,
message: payload,
occurred_at,
};
let logged = self.insert_event_in_tx(&mut tx, &event).await?;
events.push(logged.event.clone());
if let Some(pin) = pin {
pin_events.push(
self.insert_pin_event_in_tx(
&mut tx,
&ChatPinEvent {
event_id: synctv_common::snanoid!(16),
sequence: 0,
room_id: *room_id,
actor_user_id: *actor_user_id,
kind: ChatPinEventKind::MessageUpdated,
message: logged.event.message,
pin: Some(pin),
occurred_at,
},
)
.await?
.event,
);
}
}
if let Some(progress) = moderation_progress {
self.update_moderation_progress_in_tx(
&mut tx,
progress,
0,
i64::try_from(result.rows_affected()).map_err(|_| {
Error::Internal("Deleted reaction count exceeds i64::MAX".to_string())
})?,
)
.await?;
}
tx.commit().await?;
let next_cursor = messages
.last()
.map(|message| (message.created_at, message.id));
Ok((result.rows_affected(), events, pin_events, next_cursor))
}
/// Removes a bounded page of reactions on messages that are already hidden
/// from normal chat history. These rows do not emit message events because
/// their messages are no longer visible to chat clients.
pub async fn delete_hidden_reactions_by_user_page(
&self,
room_id: &RoomId,
user_id: &UserId,
created_before: DateTime<Utc>,
cursor: Option<(DateTime<Utc>, i64, String)>,
limit: i64,
moderation_progress: Option<ChatModerationProgress<'_>>,
) -> Result<(
u64,
Vec<(i64, DateTime<Utc>)>,
Option<(DateTime<Utc>, i64, String)>,
)> {
let limit = limit.clamp(1, 100);
let mut tx = self.pool().begin().await?;
let rows = if let Some((created_at, message_id, reaction_key)) = cursor {
sqlx::query_as!(
HiddenReactionCleanupRow,
r#"
SELECT r.message_id AS "message_id!",
r.message_created_at AS "message_created_at!",
r.reaction_key AS "reaction_key!"
FROM chat_message_reactions r
JOIN chat_messages m
ON m.room_id = r.room_id
AND m.id = r.message_id
AND m.created_at = r.message_created_at
WHERE r.room_id = $1
AND r.user_id = $2
AND (m.status = $3 OR m.deletion_source = $4)
AND m.created_at <= $5
AND r.created_at <= $5
AND (m.created_at, r.message_id, r.reaction_key) > ($6, $7, $8)
ORDER BY m.created_at ASC, r.message_id ASC, r.reaction_key ASC
LIMIT $9
FOR UPDATE OF r
"#,
room_id.as_i64(),
user_id.as_i64(),
i16::from(ChatMessageStatus::Deleted),
DeletionSource::Account as DeletionSource,
created_before,
created_at,
message_id,
reaction_key,
limit
)
.fetch_all(&mut *tx)
.await?
} else {
sqlx::query_as!(
HiddenReactionCleanupRow,
r#"
SELECT r.message_id AS "message_id!",
r.message_created_at AS "message_created_at!",
r.reaction_key AS "reaction_key!"
FROM chat_message_reactions r
JOIN chat_messages m
ON m.room_id = r.room_id
AND m.id = r.message_id
AND m.created_at = r.message_created_at
WHERE r.room_id = $1
AND r.user_id = $2
AND (m.status = $3 OR m.deletion_source = $4)
AND m.created_at <= $5
AND r.created_at <= $5
ORDER BY m.created_at ASC, r.message_id ASC, r.reaction_key ASC
LIMIT $6
FOR UPDATE OF r
"#,
room_id.as_i64(),
user_id.as_i64(),
i16::from(ChatMessageStatus::Deleted),
DeletionSource::Account as DeletionSource,
created_before,
limit
)
.fetch_all(&mut *tx)
.await?
};
if rows.is_empty() {
tx.commit().await?;
return Ok((0, Vec::new(), None));
}
let message_ids: Vec<i64> = rows.iter().map(|row| row.message_id).collect();
let message_created_ats: Vec<DateTime<Utc>> =
rows.iter().map(|row| row.message_created_at).collect();
let reaction_keys: Vec<String> = rows.iter().map(|row| row.reaction_key.clone()).collect();
let result = sqlx::query!(
r#"
DELETE FROM chat_message_reactions AS r
USING unnest($3::bigint[], $4::timestamptz[], $5::text[])
AS selected(message_id, message_created_at, reaction_key)
WHERE r.room_id = $1
AND r.user_id = $2
AND r.message_id = selected.message_id
AND r.message_created_at = selected.message_created_at
AND r.reaction_key = selected.reaction_key
"#,
room_id.as_i64(),
user_id.as_i64(),
&message_ids,
&message_created_ats,
&reaction_keys
)
.execute(&mut *tx)
.await?;
if let Some(progress) = moderation_progress {
self.update_moderation_progress_in_tx(
&mut tx,
progress,
0,
i64::try_from(result.rows_affected()).map_err(|_| {
Error::Internal("Deleted reaction count exceeds i64::MAX".to_string())
})?,
)
.await?;
}
let mut message_keys: Vec<(i64, DateTime<Utc>)> = rows
.iter()
.map(|row| (row.message_id, row.message_created_at))
.collect();
message_keys.sort_unstable();
message_keys.dedup();
let next_cursor = rows.last().map(|row| {
(
row.message_created_at,
row.message_id,
row.reaction_key.clone(),
)
});
tx.commit().await?;
Ok((result.rows_affected(), message_keys, next_cursor))
}
pub async fn search_messages_for_viewer(
&self,
query: &ChatSearchMessagesQuery,
@ -2821,6 +3296,7 @@ impl ChatRepository {
event,
inserted: false,
pin_event: None,
deleted_reactions: 0,
}));
}
}
@ -2929,6 +3405,7 @@ impl ChatRepository {
event: logged,
inserted: true,
pin_event,
deleted_reactions: 0,
}))
}
@ -2952,6 +3429,7 @@ impl ChatRepository {
event,
inserted: false,
pin_event: None,
deleted_reactions: 0,
}));
}
}
@ -2962,7 +3440,7 @@ impl ChatRepository {
);
builder.push_bind(i16::from(ChatMessageStatus::Deleted));
builder.push(", version = version + 1, deleted_at = NOW(), deletion_source = ");
builder.push_bind(DeletionSource::User);
builder.push_bind(request.deletion_source);
builder.push(", deleted_by = ");
builder.push_bind(request.deleted_by.as_i64());
builder.push(", delete_reason = ");
@ -3004,6 +3482,26 @@ impl ChatRepository {
tx.commit().await?;
return Ok(None);
};
// Reactions belong to the message lifecycle. Once a message is deleted,
// remove the reaction rows as well so moderation does not retain stale data.
let deleted_reactions = sqlx::query_scalar!(
r#"
WITH deleted AS (
DELETE FROM chat_message_reactions
WHERE room_id = $1 AND message_id = $2 AND message_created_at = $3
RETURNING user_id
)
SELECT COUNT(*) FILTER (WHERE user_id = $4) AS "count!"
FROM deleted
"#,
request.room_id.as_i64(),
message.id,
message.created_at,
request.reaction_user_id.map(UserId::as_i64),
)
.fetch_one(&mut *tx)
.await?;
let pin = self
.pin_for_message_in_tx(&mut tx, request.room_id, message.id, message.created_at)
.await?;
@ -3057,14 +3555,54 @@ impl ChatRepository {
)
.await?;
}
if let Some(progress) = request.moderation_progress {
self.update_moderation_progress_in_tx(&mut tx, progress, 1, deleted_reactions)
.await?;
}
tx.commit().await?;
Ok(Some(IdempotentChatEventInsert {
event: logged,
inserted: true,
pin_event,
deleted_reactions: deleted_reactions.cast_unsigned(),
}))
}
async fn update_moderation_progress_in_tx(
&self,
tx: &mut Transaction<'_, Postgres>,
progress: ChatModerationProgress<'_>,
deleted_messages: i64,
deleted_reactions: i64,
) -> Result<()> {
let result = sqlx::query!(
r#"
UPDATE chat_moderation_jobs
SET deleted_messages = deleted_messages + $4,
deleted_reactions = deleted_reactions + $5,
locked_at = NOW(),
updated_at = NOW()
WHERE id = $1
AND locked_by = $2
AND lock_version = $3
AND status = 2
"#,
progress.job_id,
progress.worker_id,
progress.lock_version,
deleted_messages,
deleted_reactions,
)
.execute(&mut **tx)
.await?;
if result.rows_affected() != 1 {
return Err(Error::LockConflict(
"Chat moderation worker lease is no longer valid".to_string(),
));
}
Ok(())
}
pub async fn delete(&self, message_id: i64, created_at: DateTime<Utc>) -> Result<bool> {
let result = sqlx::query!(
r"
@ -4591,6 +5129,11 @@ pub struct DeleteChatMessageEventRequest<'a> {
pub event_id: &'a str,
pub occurred_at: DateTime<Utc>,
pub operation: Option<&'a ChatMessageOperationIdempotency<'a>>,
/// Optional user whose reactions should be counted as part of message deletion.
pub reaction_user_id: Option<&'a UserId>,
/// Optional moderation job progress to update in the deletion transaction.
pub moderation_progress: Option<ChatModerationProgress<'a>>,
pub deletion_source: DeletionSource,
}
pub struct PinChatMessageEventRequest<'a> {
@ -4626,6 +5169,7 @@ pub struct IdempotentChatEventInsert {
pub event: ChatMessageEventLog,
pub inserted: bool,
pub pin_event: Option<ChatPinEventLog>,
pub deleted_reactions: u64,
}
pub struct IdempotentChatPinEventInsert {

@ -0,0 +1,582 @@
use chrono::{DateTime, Utc};
use sqlx::PgPool;
use crate::{
models::{RoomId, UserId, UserRole},
Error, Result,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(i16)]
pub enum ChatModerationJobStatus {
Pending = 1,
Processing = 2,
Completed = 3,
Failed = 4,
}
impl TryFrom<i16> for ChatModerationJobStatus {
type Error = Error;
fn try_from(value: i16) -> Result<Self> {
match value {
1 => Ok(Self::Pending),
2 => Ok(Self::Processing),
3 => Ok(Self::Completed),
4 => Ok(Self::Failed),
_ => Err(Error::Internal(format!(
"Unknown chat moderation job status: {value}"
))),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(i16)]
pub enum ChatModerationJobPhase {
Messages = 1,
Reactions = 2,
Done = 3,
}
impl TryFrom<i16> for ChatModerationJobPhase {
type Error = Error;
fn try_from(value: i16) -> Result<Self> {
match value {
1 => Ok(Self::Messages),
2 => Ok(Self::Reactions),
3 => Ok(Self::Done),
_ => Err(Error::Internal(format!(
"Unknown chat moderation job phase: {value}"
))),
}
}
}
#[derive(Debug, Clone)]
pub struct NewChatModerationJob {
pub id: String,
pub room_id: RoomId,
pub target_user_id: UserId,
pub actor_user_id: UserId,
pub actor_username: String,
pub actor_role: UserRole,
pub message_id: Option<i64>,
pub ban_user: bool,
pub delete_all_messages: bool,
pub delete_all_reactions: bool,
pub reason: Option<String>,
pub snapshot_at: DateTime<Utc>,
}
#[derive(Debug, Clone)]
pub struct ChatModerationJob {
pub id: String,
pub room_id: RoomId,
pub target_user_id: UserId,
pub actor_user_id: UserId,
pub actor_username: String,
pub actor_role: UserRole,
pub message_id: Option<i64>,
pub explicit_message_done: bool,
pub ban_user: bool,
pub ban_done: bool,
pub delete_all_messages: bool,
pub delete_all_reactions: bool,
pub reason: Option<String>,
pub phase: ChatModerationJobPhase,
pub status: ChatModerationJobStatus,
pub snapshot_at: DateTime<Utc>,
pub message_cursor: Option<(DateTime<Utc>, i64)>,
pub reaction_cursor: Option<(DateTime<Utc>, i64)>,
pub hidden_reaction_cursor: Option<(DateTime<Utc>, i64, String)>,
pub deleted_messages: i64,
pub deleted_reactions: i64,
pub attempts: i32,
pub lock_version: i64,
pub last_error: Option<String>,
}
#[derive(sqlx::FromRow)]
struct ChatModerationJobRow {
id: String,
room_id: RoomId,
target_user_id: UserId,
actor_user_id: UserId,
actor_username: String,
actor_role: i16,
message_id: Option<i64>,
explicit_message_done: bool,
ban_user: bool,
ban_done: bool,
delete_all_messages: bool,
delete_all_reactions: bool,
reason: Option<String>,
phase: i16,
status: i16,
snapshot_at: DateTime<Utc>,
message_cursor_created_at: Option<DateTime<Utc>>,
message_cursor_id: Option<i64>,
reaction_cursor_created_at: Option<DateTime<Utc>>,
reaction_cursor_id: Option<i64>,
hidden_reaction_cursor_created_at: Option<DateTime<Utc>>,
hidden_reaction_cursor_id: Option<i64>,
hidden_reaction_cursor_key: Option<String>,
deleted_messages: i64,
deleted_reactions: i64,
attempts: i32,
lock_version: i64,
last_error: Option<String>,
}
impl TryFrom<ChatModerationJobRow> for ChatModerationJob {
type Error = Error;
fn try_from(row: ChatModerationJobRow) -> Result<Self> {
let message_cursor = row.message_cursor_created_at.zip(row.message_cursor_id);
let reaction_cursor = row.reaction_cursor_created_at.zip(row.reaction_cursor_id);
let hidden_reaction_cursor = row
.hidden_reaction_cursor_created_at
.zip(row.hidden_reaction_cursor_id)
.zip(row.hidden_reaction_cursor_key)
.map(|((created_at, id), key)| (created_at, id, key));
Ok(Self {
id: row.id,
room_id: row.room_id,
target_user_id: row.target_user_id,
actor_user_id: row.actor_user_id,
actor_username: row.actor_username,
actor_role: UserRole::try_from(row.actor_role).map_err(|error| {
Error::Internal(format!("Invalid moderation actor role: {error}"))
})?,
message_id: row.message_id,
explicit_message_done: row.explicit_message_done,
ban_user: row.ban_user,
ban_done: row.ban_done,
delete_all_messages: row.delete_all_messages,
delete_all_reactions: row.delete_all_reactions,
reason: row.reason,
phase: ChatModerationJobPhase::try_from(row.phase)?,
status: ChatModerationJobStatus::try_from(row.status)?,
snapshot_at: row.snapshot_at,
message_cursor,
reaction_cursor,
hidden_reaction_cursor,
deleted_messages: row.deleted_messages,
deleted_reactions: row.deleted_reactions,
attempts: row.attempts,
lock_version: row.lock_version,
last_error: row.last_error,
})
}
}
#[derive(Clone)]
pub struct ChatModerationJobRepository {
pool: PgPool,
}
impl ChatModerationJobRepository {
#[must_use]
pub const fn new(pool: PgPool) -> Self {
Self { pool }
}
#[must_use]
pub const fn pool(&self) -> &PgPool {
&self.pool
}
pub async fn insert(&self, job: &NewChatModerationJob) -> Result<ChatModerationJob> {
validate_new_chat_moderation_job(job)?;
let row = sqlx::query_as!(
ChatModerationJobRow,
r#"
INSERT INTO chat_moderation_jobs (
id, room_id, target_user_id, actor_user_id,
actor_username,
actor_role, message_id, explicit_message_done,
ban_user, ban_done,
delete_all_messages, delete_all_reactions, reason, snapshot_at,
next_attempt_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, FALSE, $8, FALSE, $9, $10, $11, $12, NOW())
RETURNING id,
room_id AS "room_id!: RoomId",
target_user_id AS "target_user_id!: UserId",
actor_user_id AS "actor_user_id!: UserId",
actor_username,
actor_role,
message_id,
explicit_message_done,
ban_user,
ban_done,
delete_all_messages,
delete_all_reactions,
reason,
phase AS "phase!",
status AS "status!",
snapshot_at,
message_cursor_created_at,
message_cursor_id,
reaction_cursor_created_at,
reaction_cursor_id,
hidden_reaction_cursor_created_at,
hidden_reaction_cursor_id,
hidden_reaction_cursor_key,
deleted_messages,
deleted_reactions,
attempts,
lock_version,
last_error
"#,
&job.id,
job.room_id.as_i64(),
job.target_user_id.as_i64(),
job.actor_user_id.as_i64(),
&job.actor_username,
i16::from(job.actor_role),
job.message_id,
job.ban_user,
job.delete_all_messages,
job.delete_all_reactions,
job.reason.as_deref(),
job.snapshot_at,
)
.fetch_one(&self.pool)
.await?;
row.try_into()
}
pub async fn get(&self, id: &str) -> Result<Option<ChatModerationJob>> {
let row = sqlx::query_as!(
ChatModerationJobRow,
r#"
SELECT id,
room_id AS "room_id!: RoomId",
target_user_id AS "target_user_id!: UserId",
actor_user_id AS "actor_user_id!: UserId",
actor_username,
actor_role,
message_id,
explicit_message_done,
ban_user,
ban_done,
delete_all_messages,
delete_all_reactions,
reason,
phase AS "phase!",
status AS "status!",
snapshot_at,
message_cursor_created_at,
message_cursor_id,
reaction_cursor_created_at,
reaction_cursor_id,
hidden_reaction_cursor_created_at,
hidden_reaction_cursor_id,
hidden_reaction_cursor_key,
deleted_messages,
deleted_reactions,
attempts,
lock_version,
last_error
FROM chat_moderation_jobs
WHERE id = $1
"#,
id,
)
.fetch_optional(&self.pool)
.await?;
row.map(TryInto::try_into).transpose()
}
pub async fn claim_batch(&self, worker_id: &str, limit: i64) -> Result<Vec<ChatModerationJob>> {
let rows = sqlx::query_as!(
ChatModerationJobRow,
r#"
WITH picked AS (
SELECT id
FROM chat_moderation_jobs
WHERE status = 1
AND next_attempt_at <= NOW()
ORDER BY updated_at, id
LIMIT $1
FOR UPDATE SKIP LOCKED
)
UPDATE chat_moderation_jobs AS job
SET status = 2,
locked_by = $2,
locked_at = NOW(),
lock_version = lock_version + 1,
updated_at = NOW()
FROM picked
WHERE job.id = picked.id
RETURNING job.id,
job.room_id AS "room_id!: RoomId",
job.target_user_id AS "target_user_id!: UserId",
job.actor_user_id AS "actor_user_id!: UserId",
job.actor_username,
job.actor_role,
job.message_id,
job.explicit_message_done,
job.ban_user,
job.ban_done,
job.delete_all_messages,
job.delete_all_reactions,
job.reason,
job.phase AS "phase!",
job.status AS "status!",
job.snapshot_at,
job.message_cursor_created_at,
job.message_cursor_id,
job.reaction_cursor_created_at,
job.reaction_cursor_id,
job.hidden_reaction_cursor_created_at,
job.hidden_reaction_cursor_id,
job.hidden_reaction_cursor_key,
job.deleted_messages,
job.deleted_reactions,
job.attempts,
job.lock_version,
job.last_error
"#,
limit,
worker_id,
)
.fetch_all(&self.pool)
.await?;
rows.into_iter().map(TryInto::try_into).collect()
}
pub async fn update_progress(&self, job: &ChatModerationJob, worker_id: &str) -> Result<bool> {
let result = sqlx::query!(
r#"
UPDATE chat_moderation_jobs
SET phase = $4,
status = CASE
WHEN $16 THEN 2::SMALLINT
ELSE 1::SMALLINT
END,
message_cursor_created_at = $5,
message_cursor_id = $6,
reaction_cursor_created_at = $7,
reaction_cursor_id = $8,
hidden_reaction_cursor_created_at = $9,
hidden_reaction_cursor_id = $10,
hidden_reaction_cursor_key = $11,
deleted_messages = $12,
deleted_reactions = $13,
explicit_message_done = $14,
ban_done = $15,
snapshot_at = $17,
locked_by = CASE WHEN $16 THEN locked_by ELSE NULL END,
locked_at = CASE WHEN $16 THEN NOW() ELSE NULL END,
updated_at = NOW(),
last_error = NULL,
lock_version = lock_version + 1
WHERE id = $1 AND locked_by = $2 AND lock_version = $3 AND status = 2
"#,
&job.id,
worker_id,
job.lock_version,
job.phase as i16,
job.message_cursor.map(|(at, _)| at),
job.message_cursor.map(|(_, id)| id),
job.reaction_cursor.map(|(at, _)| at),
job.reaction_cursor.map(|(_, id)| id),
job.hidden_reaction_cursor.as_ref().map(|(at, _, _)| *at),
job.hidden_reaction_cursor.as_ref().map(|(_, id, _)| *id),
job.hidden_reaction_cursor
.as_ref()
.map(|(_, _, key)| key.as_str()),
job.deleted_messages,
job.deleted_reactions,
job.explicit_message_done,
job.ban_done,
job.phase == ChatModerationJobPhase::Done,
job.snapshot_at,
)
.execute(&self.pool)
.await?;
Ok(result.rows_affected() == 1)
}
pub async fn mark_completed(
&self,
job: &ChatModerationJob,
worker_id: &str,
deleted_messages: i64,
deleted_reactions: i64,
) -> Result<bool> {
let result = sqlx::query!(
r#"
UPDATE chat_moderation_jobs
SET phase = 3,
status = 3,
deleted_messages = $4,
deleted_reactions = $5,
locked_by = NULL,
locked_at = NULL,
last_error = NULL,
completed_at = NOW(),
updated_at = NOW()
WHERE id = $1 AND locked_by = $2 AND lock_version = $3 AND status = 2
"#,
&job.id,
worker_id,
job.lock_version,
deleted_messages,
deleted_reactions,
)
.execute(&self.pool)
.await?;
Ok(result.rows_affected() == 1)
}
pub async fn mark_failed(
&self,
job: &ChatModerationJob,
worker_id: &str,
error: &str,
) -> Result<bool> {
let attempts = job.attempts.saturating_add(1);
let status = if attempts >= 10 { 4 } else { 1 };
let retry_delay_seconds = f64::from(
2_i32
.saturating_pow(attempts.min(10).cast_unsigned())
.min(3600),
);
let result = sqlx::query!(
r#"
UPDATE chat_moderation_jobs
SET status = $4::SMALLINT,
attempts = $5,
next_attempt_at = CASE
WHEN $4::SMALLINT = 1 THEN NOW() + ($7::DOUBLE PRECISION * INTERVAL '1 second')
ELSE next_attempt_at
END,
locked_by = NULL,
locked_at = NULL,
last_error = $6,
updated_at = NOW()
WHERE id = $1 AND locked_by = $2 AND lock_version = $3 AND status = 2
"#,
&job.id,
worker_id,
job.lock_version,
status,
attempts,
error,
retry_delay_seconds,
)
.execute(&self.pool)
.await?;
Ok(result.rows_affected() == 1)
}
#[allow(clippy::cast_precision_loss)]
pub async fn requeue_stale_processing(&self, stale_after_seconds: i64) -> Result<u64> {
let result = sqlx::query!(
r#"
UPDATE chat_moderation_jobs
SET status = CASE WHEN attempts + 1 >= 10 THEN 4 ELSE 1 END,
attempts = attempts + 1,
locked_by = NULL,
locked_at = NULL,
next_attempt_at = CASE
WHEN attempts + 1 >= 10 THEN next_attempt_at
ELSE NOW()
END,
last_error = 'Worker lease expired',
updated_at = NOW()
WHERE status = 2
AND locked_at < NOW() - ($1::DOUBLE PRECISION * INTERVAL '1 second')
"#,
stale_after_seconds as f64,
)
.execute(&self.pool)
.await?;
Ok(result.rows_affected())
}
#[allow(clippy::cast_precision_loss)]
pub async fn delete_terminal_before(&self, retention_seconds: i64) -> Result<u64> {
let result = sqlx::query!(
r#"
DELETE FROM chat_moderation_jobs
WHERE status IN (3, 4)
AND updated_at < NOW() - ($1::DOUBLE PRECISION * INTERVAL '1 second')
"#,
retention_seconds as f64,
)
.execute(&self.pool)
.await?;
Ok(result.rows_affected())
}
}
fn validate_new_chat_moderation_job(job: &NewChatModerationJob) -> Result<()> {
if !job.actor_role.is_admin_or_above() {
return Err(Error::Authorization(
"Admin role is required for chat moderation".to_string(),
));
}
if job.message_id.is_none()
&& !job.delete_all_messages
&& !job.delete_all_reactions
&& !job.ban_user
{
return Err(Error::InvalidInput(
"At least one chat moderation action is required".to_string(),
));
}
if job.message_id.is_some_and(|id| id <= 0) {
return Err(Error::InvalidInput(
"Chat moderation message ID must be positive".to_string(),
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn job() -> NewChatModerationJob {
NewChatModerationJob {
id: "test".to_string(),
room_id: RoomId::new(),
target_user_id: UserId::new(),
actor_user_id: UserId::new(),
actor_username: "admin".to_string(),
actor_role: UserRole::Admin,
message_id: Some(1),
ban_user: false,
delete_all_messages: false,
delete_all_reactions: false,
reason: None,
snapshot_at: Utc::now(),
}
}
#[test]
fn new_job_requires_an_action() {
let mut job = job();
job.message_id = None;
assert!(matches!(
validate_new_chat_moderation_job(&job),
Err(Error::InvalidInput(_))
));
}
#[test]
fn new_job_requires_an_admin_actor() {
let mut job = job();
job.actor_role = UserRole::User;
assert!(matches!(
validate_new_chat_moderation_job(&job),
Err(Error::Authorization(_))
));
}
}

@ -1,6 +1,7 @@
pub(crate) mod audit;
pub(crate) mod ban;
pub mod chat;
pub mod chat_moderation;
pub(crate) mod content_report;
pub(crate) mod email_bind;
pub mod email_outbox;
@ -45,8 +46,13 @@ pub use ban::{
BanRecordListQuery, BanRecordPage, BanRecordRepository, BanRecordRow, BanRecordTargetType,
};
pub use chat::{
ChatMessageOperationIdempotency, ChatRepository, DeleteChatMessageEventRequest,
EditChatMessageEventRequest, PinChatMessageEventRequest, UnpinChatMessageEventRequest,
ChatMessageOperationIdempotency, ChatModerationProgress, ChatRepository,
DeleteChatMessageEventRequest, DeleteChatReactionsPageRequest, EditChatMessageEventRequest,
PinChatMessageEventRequest, UnpinChatMessageEventRequest,
};
pub use chat_moderation::{
ChatModerationJob, ChatModerationJobPhase, ChatModerationJobRepository,
ChatModerationJobStatus, NewChatModerationJob,
};
pub use content_report::{
ContentReportListQuery, ContentReportListScope, ContentReportPage, ContentReportRepository,

@ -20,21 +20,22 @@ use crate::{
ChatPinEvent, ChatPinnedMessage, ChatPlaybackMessagesQuery, ChatReactionUsersCursor,
ChatReactionUsersPage, ChatReadStateWithUnread, ChatSearchMessagesPage,
ChatSearchMessagesQuery, CompleteFileUploadSession, CompleteFileUploadSessionResult,
CreateChatAttachmentUploadSession, DeleteChatMessage, EditChatMessage, FileObjectDownload,
FileRangeRequest, FileUploadRange, FileUploadSessionCreateResult, GetFileObject,
MarkChatRead, PinChatMessage, RoomId, SendChatMessage, SetChatReaction, StoreFileUpload,
StoreFileUploadResult, SubmittedFileReference, SubmittedFileReferenceKind,
CreateChatAttachmentUploadSession, DeleteChatMessage, DeletionSource, EditChatMessage,
FileObjectDownload, FileRangeRequest, FileUploadRange, FileUploadSessionCreateResult,
GetFileObject, MarkChatRead, PinChatMessage, RoomId, SendChatMessage, SetChatReaction,
StoreFileUpload, StoreFileUploadResult, SubmittedFileReference, SubmittedFileReferenceKind,
UnpinChatMessage, UserId, CHAT_PIN_NOTE_MAX_CHARS,
},
repository::{
ChatMessageOperationIdempotency, ChatRepository, DeleteChatMessageEventRequest,
ChatMessageOperationIdempotency, ChatModerationJobRepository, ChatModerationProgress,
ChatRepository, DeleteChatMessageEventRequest, DeleteChatReactionsPageRequest,
EditChatMessageEventRequest, PinChatMessageEventRequest, UnpinChatMessageEventRequest,
},
service::{
audit::{AuditEventParams, AuditService},
notification::NotificationService,
ContentFilter, PermissionService, RateLimitConfig, RequestRateLimiterService,
RoomSettingsService, UserService,
AuthorizedAdminActor, ContentFilter, PermissionService, RateLimitConfig,
RequestRateLimiterService, RoomSettingsService, UserService,
},
Clock, Error, Result,
};
@ -50,6 +51,8 @@ pub const MAX_CHAT_MESSAGE_CHARS: usize = 500;
pub const MAX_CHAT_ATTACHMENTS_PER_MESSAGE: usize = 10;
const CHAT_REACTION_DETAIL_CACHE_TTL_SECS: u64 = 5;
const CHAT_REACTION_DETAIL_CACHE_CAPACITY: u64 = 1024;
const CHAT_MODERATION_PAGE_SIZE: i64 = 100;
const CHAT_REACTION_CLEANUP_PAGE_SIZE: i64 = 100;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct ChatReactionDetailCacheKey {
@ -78,6 +81,7 @@ pub struct ChatService {
/// Local room event bus for chat/domain notifications
notification_service: NotificationService,
runtime_settings_store: Option<Arc<crate::service::RuntimeSettingsStore>>,
moderation_job_repository: ChatModerationJobRepository,
reaction_detail_cache: AsyncCache<ChatReactionDetailCacheKey, ChatReactionUsersPage>,
}
@ -86,6 +90,49 @@ pub struct ChatMessageEventOutcome {
pub event: ChatMessageEvent,
pub inserted: bool,
pub pin_event: Option<ChatPinEvent>,
pub deleted_reactions: u64,
}
#[derive(Debug, Clone)]
pub struct ChatUserModerationOutcome {
pub deleted_messages: u64,
pub deleted_reactions: u64,
}
#[derive(Debug, Clone)]
pub struct ChatUserModerationPageOutcome {
pub outcome: ChatUserModerationOutcome,
pub next_cursor: Option<(DateTime<Utc>, i64)>,
pub done: bool,
}
#[derive(Debug, Clone)]
pub struct ChatReactionModerationPageOutcome {
pub deleted_reactions: u64,
pub next_cursor: Option<(DateTime<Utc>, i64)>,
pub hidden_next_cursor: Option<(DateTime<Utc>, i64, String)>,
pub visible_done: bool,
pub hidden_done: bool,
}
pub struct ChatUserModerationPageRequest<'a> {
pub room_id: &'a RoomId,
pub target_user_id: &'a UserId,
pub actor: &'a AuthorizedAdminActor,
pub reason: Option<&'a str>,
pub created_before: DateTime<Utc>,
pub cursor: Option<(DateTime<Utc>, i64)>,
pub moderation_progress: Option<ChatModerationProgress<'a>>,
}
pub struct ChatUserReactionModerationPageRequest<'a> {
pub room_id: &'a RoomId,
pub target_user_id: &'a UserId,
pub actor_user_id: &'a UserId,
pub created_before: DateTime<Utc>,
pub cursor: Option<(DateTime<Utc>, i64)>,
pub hidden_cursor: Option<(DateTime<Utc>, i64, String)>,
pub moderation_progress: Option<ChatModerationProgress<'a>>,
}
#[derive(Debug, Clone)]
@ -155,6 +202,7 @@ impl std::fmt::Debug for ChatService {
impl ChatService {
/// Create a new chat service
#[must_use]
#[allow(clippy::needless_pass_by_value)]
pub fn new(
chat_repository: Arc<ChatRepository>,
runtime: ChatRuntime,
@ -182,7 +230,7 @@ impl ChatService {
chat_repository.pool().clone(),
file_storage_service.clone(),
),
chat_repository,
chat_repository: chat_repository.clone(),
rate_limiter,
rate_limit_config,
content_filter,
@ -193,6 +241,9 @@ impl ChatService {
audit_service,
notification_service,
runtime_settings_store,
moderation_job_repository: ChatModerationJobRepository::new(
chat_repository.pool().clone(),
),
reaction_detail_cache: AsyncCache::builder()
.max_capacity(CHAT_REACTION_DETAIL_CACHE_CAPACITY)
.time_to_live(Duration::from_secs(CHAT_REACTION_DETAIL_CACHE_TTL_SECS))
@ -206,6 +257,11 @@ impl ChatService {
self.file_storage_service.clone()
}
#[must_use]
pub const fn moderation_job_repository(&self) -> &ChatModerationJobRepository {
&self.moderation_job_repository
}
pub async fn create_attachment_upload_session(
&self,
request: CreateChatAttachmentUploadSession,
@ -377,6 +433,7 @@ impl ChatService {
event,
inserted: false,
pin_event: None,
deleted_reactions: 0,
});
}
}
@ -491,6 +548,7 @@ impl ChatService {
event,
inserted: created.inserted,
pin_event: created.pin_event.map(|event| event.event),
deleted_reactions: created.deleted_reactions,
})
}
@ -883,6 +941,7 @@ impl ChatService {
event: event.event,
inserted: false,
pin_event: None,
deleted_reactions: 0,
});
}
}
@ -926,6 +985,7 @@ impl ChatService {
event,
inserted: false,
pin_event: None,
deleted_reactions: 0,
});
}
return Err(Error::OptimisticLockConflict);
@ -979,6 +1039,7 @@ impl ChatService {
event,
inserted: false,
pin_event: None,
deleted_reactions: 0,
});
}
}
@ -989,6 +1050,7 @@ impl ChatService {
event: updated.event.event,
inserted: updated.inserted,
pin_event: updated.pin_event.map(|event| event.event),
deleted_reactions: updated.deleted_reactions,
})
}
@ -999,9 +1061,348 @@ impl ChatService {
Ok(self.delete_message_event_outcome(request).await?.event)
}
pub async fn validate_moderation_message_anchor(
&self,
room_id: &RoomId,
message_id: i64,
expected_author_id: &UserId,
) -> Result<()> {
let message = self
.chat_repository
.get_by_room_and_id_from_primary(room_id, message_id)
.await?
.ok_or_else(|| Error::NotFound("Message not found".to_string()))?;
if message.user_id.as_ref() != Some(expected_author_id) {
return Err(Error::InvalidInput(
"Message does not belong to the moderated user".to_string(),
));
}
Ok(())
}
pub async fn moderate_user_messages_page<F>(
&self,
request: ChatUserModerationPageRequest<'_>,
mut on_event: F,
) -> Result<ChatUserModerationPageOutcome>
where
F: FnMut(&ChatMessageEvent, Option<&ChatPinEvent>) + Send,
{
let ChatUserModerationPageRequest {
room_id,
target_user_id,
actor,
reason,
created_before,
cursor,
moderation_progress,
} = request;
let messages = self
.chat_repository
.list_active_messages_for_user_page(
room_id,
target_user_id,
cursor,
created_before,
CHAT_MODERATION_PAGE_SIZE,
)
.await?;
let page_len = messages.len();
let mut next_cursor = None;
let mut outcome = ChatUserModerationOutcome {
deleted_messages: 0,
deleted_reactions: 0,
};
for message in messages {
next_cursor = Some((message.created_at, message.id));
let deleted = self
.delete_message_event_outcome_with_reaction_user_and_permission(
DeleteChatMessage {
room_id: *room_id,
message_id: message.id,
user_id: *actor.user_id(),
client_operation_id: None,
reason: reason.map(str::to_owned),
expected_version: None,
},
Some(target_user_id),
false,
DeletionSource::Admin,
Some(actor.username()),
moderation_progress,
)
.await?;
if deleted.inserted {
outcome.deleted_messages = outcome
.deleted_messages
.checked_add(1)
.ok_or_else(|| Error::Internal("Deleted message count overflow".to_string()))?;
outcome.deleted_reactions = outcome
.deleted_reactions
.checked_add(deleted.deleted_reactions)
.ok_or_else(|| {
Error::Internal("Deleted reaction count overflow".to_string())
})?;
on_event(&deleted.event, deleted.pin_event.as_ref());
}
}
Ok(ChatUserModerationPageOutcome {
outcome,
next_cursor,
done: page_len < usize::try_from(CHAT_MODERATION_PAGE_SIZE).unwrap_or(usize::MAX),
})
}
pub async fn remove_user_reactions_page<F>(
&self,
request: ChatUserReactionModerationPageRequest<'_>,
mut on_event: F,
) -> Result<ChatReactionModerationPageOutcome>
where
F: FnMut(&ChatMessageEvent, Option<&ChatPinEvent>) + Send,
{
let ChatUserReactionModerationPageRequest {
room_id,
target_user_id,
actor_user_id,
created_before,
cursor,
hidden_cursor,
moderation_progress,
} = request;
let (deleted, events, pin_events, next_cursor) = if hidden_cursor.is_none() {
self.chat_repository
.delete_reactions_by_user_with_events_page(DeleteChatReactionsPageRequest {
room_id,
user_id: target_user_id,
actor_user_id,
occurred_at: self.clock.now(),
created_before,
cursor,
limit: CHAT_REACTION_CLEANUP_PAGE_SIZE,
moderation_progress,
})
.await?
} else {
(0, Vec::new(), Vec::new(), cursor)
};
for event in &events {
let pin_event = pin_events
.iter()
.find(|pin| pin.message.message.id == event.message.message.id);
self.invalidate_reaction_detail_cache_for_message(room_id, event.message.message.id)
.await;
on_event(event, pin_event);
}
let visible_done = events.is_empty();
if !visible_done {
return Ok(ChatReactionModerationPageOutcome {
deleted_reactions: deleted,
next_cursor,
hidden_next_cursor: hidden_cursor,
visible_done: false,
hidden_done: false,
});
}
let (hidden_deleted, message_keys, hidden_next_cursor) = self
.chat_repository
.delete_hidden_reactions_by_user_page(
room_id,
target_user_id,
created_before,
hidden_cursor,
CHAT_REACTION_CLEANUP_PAGE_SIZE,
moderation_progress,
)
.await?;
for (message_id, _) in message_keys {
self.invalidate_reaction_detail_cache_for_message(room_id, message_id)
.await;
}
Ok(ChatReactionModerationPageOutcome {
deleted_reactions: deleted
.checked_add(hidden_deleted)
.ok_or_else(|| Error::Internal("Deleted reaction count overflow".to_string()))?,
next_cursor,
hidden_done: hidden_deleted == 0,
hidden_next_cursor,
visible_done: true,
})
}
pub async fn delete_message_event_outcome_for_author(
&self,
request: DeleteChatMessage,
expected_author_id: &UserId,
) -> Result<ChatMessageEventOutcome> {
self.delete_message_event_outcome_for_author_with_permission(
request,
expected_author_id,
true,
DeletionSource::User,
None,
None,
)
.await
}
pub async fn delete_message_event_outcome_for_author_as_admin(
&self,
request: DeleteChatMessage,
expected_author_id: &UserId,
actor: &AuthorizedAdminActor,
) -> Result<ChatMessageEventOutcome> {
self.delete_message_event_outcome_for_author_as_admin_with_progress(
request,
expected_author_id,
actor,
None,
)
.await
}
pub async fn delete_message_event_outcome_for_author_as_admin_with_progress(
&self,
request: DeleteChatMessage,
expected_author_id: &UserId,
actor: &AuthorizedAdminActor,
moderation_progress: Option<ChatModerationProgress<'_>>,
) -> Result<ChatMessageEventOutcome> {
if request.user_id != *actor.user_id() {
return Err(Error::Authorization(
"Delete actor does not match authorized admin actor".to_string(),
));
}
self.delete_message_event_outcome_for_author_with_permission(
request,
expected_author_id,
false,
DeletionSource::Admin,
Some(actor.username()),
moderation_progress,
)
.await
}
/// Deletes the message that anchored an accepted moderation command.
///
/// Once accepted, later edits do not invalidate the command and a
/// concurrently deleted anchor is considered complete so the remaining
/// actions can run.
pub async fn delete_moderation_message_event_outcome_as_admin_with_progress(
&self,
room_id: &RoomId,
message_id: i64,
expected_author_id: &UserId,
actor: &AuthorizedAdminActor,
reason: Option<&str>,
moderation_progress: Option<ChatModerationProgress<'_>>,
) -> Result<Option<ChatMessageEventOutcome>> {
let Some(current) = self
.chat_repository
.get_by_room_and_id_from_primary(room_id, message_id)
.await?
else {
return Ok(None);
};
if current.user_id.as_ref() != Some(expected_author_id) {
return Err(Error::InvalidInput(
"Message does not belong to the moderated user".to_string(),
));
}
if current.status == ChatMessageStatus::Deleted {
return Ok(None);
}
let request = DeleteChatMessage {
room_id: *room_id,
message_id,
user_id: *actor.user_id(),
client_operation_id: None,
reason: reason.map(str::to_owned),
expected_version: None,
};
match self
.delete_message_event_outcome_for_author_with_permission(
request.clone(),
expected_author_id,
false,
DeletionSource::Admin,
Some(actor.username()),
moderation_progress,
)
.await
{
Ok(outcome) => Ok(Some(outcome)),
Err(error @ (Error::Conflict(_) | Error::OptimisticLockConflict)) => {
let current = self
.chat_repository
.get_by_room_and_id_from_primary(room_id, message_id)
.await?;
if current.is_none_or(|message| message.status == ChatMessageStatus::Deleted) {
Ok(None)
} else {
Err(error)
}
}
Err(error) => Err(error),
}
}
async fn delete_message_event_outcome_for_author_with_permission(
&self,
request: DeleteChatMessage,
expected_author_id: &UserId,
check_permission: bool,
deletion_source: DeletionSource,
actor_username: Option<&str>,
moderation_progress: Option<ChatModerationProgress<'_>>,
) -> Result<ChatMessageEventOutcome> {
let message = self
.chat_repository
.get_by_room_and_id_from_primary(&request.room_id, request.message_id)
.await?
.ok_or_else(|| Error::NotFound("Message not found".to_string()))?;
if message.user_id.as_ref() != Some(expected_author_id) {
return Err(Error::InvalidInput(
"Message does not belong to the moderated user".to_string(),
));
}
self.delete_message_event_outcome_with_reaction_user_and_permission(
request,
Some(expected_author_id),
check_permission,
deletion_source,
actor_username,
moderation_progress,
)
.await
}
pub async fn delete_message_event_outcome(
&self,
request: DeleteChatMessage,
) -> Result<ChatMessageEventOutcome> {
self.delete_message_event_outcome_with_reaction_user_and_permission(
request,
None,
true,
DeletionSource::User,
None,
None,
)
.await
}
async fn delete_message_event_outcome_with_reaction_user_and_permission(
&self,
request: DeleteChatMessage,
reaction_user_id: Option<&UserId>,
check_permission: bool,
deletion_source: DeletionSource,
actor_username: Option<&str>,
moderation_progress: Option<ChatModerationProgress<'_>>,
) -> Result<ChatMessageEventOutcome> {
let current_with_attachments = self
.chat_repository
@ -1011,7 +1412,12 @@ impl ChatService {
let current = &current_with_attachments.message;
let is_sender = current.user_id.as_ref() == Some(&request.user_id);
if !is_sender {
let deletion_source = if !is_sender && check_permission {
DeletionSource::Admin
} else {
deletion_source
};
if !is_sender && check_permission {
self.permission_service
.check_permission(
&request.room_id,
@ -1038,6 +1444,7 @@ impl ChatService {
event: event.event,
inserted: false,
pin_event: None,
deleted_reactions: 0,
});
}
}
@ -1047,6 +1454,7 @@ impl ChatService {
event,
inserted: false,
pin_event: None,
deleted_reactions: 0,
});
}
return Err(Error::Conflict(
@ -1082,6 +1490,9 @@ impl ChatService {
event_id: &synctv_common::snanoid!(16),
occurred_at: self.clock.now(),
operation: operation.as_ref(),
reaction_user_id,
moderation_progress,
deletion_source,
})
.await?;
let Some(deleted) = deleted else {
@ -1095,6 +1506,7 @@ impl ChatService {
event,
inserted: false,
pin_event: None,
deleted_reactions: 0,
});
}
if current.status == ChatMessageStatus::Deleted {
@ -1106,9 +1518,16 @@ impl ChatService {
};
if deleted.inserted {
self.invalidate_reaction_detail_cache_for_message(&request.room_id, request.message_id)
.await;
if !is_sender {
self.audit_chat_management_delete(&request, current, &deleted.event.event)
.await;
self.audit_chat_management_delete(
&request,
current,
&deleted.event.event,
actor_username,
)
.await;
}
let attachment_file_references = current_with_attachments
@ -1137,6 +1556,7 @@ impl ChatService {
event: deleted.event.event,
inserted: deleted.inserted,
pin_event: deleted.pin_event.map(|event| event.event),
deleted_reactions: deleted.deleted_reactions,
})
}
@ -1350,6 +1770,7 @@ impl ChatService {
event: inserted.event.event,
inserted: inserted.inserted,
pin_event: inserted.pin_event.map(|event| event.event),
deleted_reactions: inserted.deleted_reactions,
})
}
@ -1422,6 +1843,23 @@ impl ChatService {
self.reaction_detail_cache.run_pending_tasks().await;
}
async fn invalidate_reaction_detail_cache_for_message(
&self,
room_id: &RoomId,
message_id: i64,
) {
let room_id = *room_id;
if let Err(error) = self
.reaction_detail_cache
.invalidate_entries_if(move |key, _| {
key.room_id == room_id && key.message_id == message_id
})
{
debug!(%error, "Failed to invalidate chat reaction detail cache");
}
self.reaction_detail_cache.run_pending_tasks().await;
}
/// Cleanup old chat messages for all rooms using global settings
///
/// This method uses a single optimized SQL query with window functions to delete
@ -1621,31 +2059,39 @@ impl ChatService {
request: &DeleteChatMessage,
original: &ChatMessage,
event: &ChatMessageEvent,
actor_username_snapshot: Option<&str>,
) {
let Some(audit) = &self.audit_service else {
return;
};
let actor_username = match self.user_service.get_username(&request.user_id).await {
Ok(Some(username)) => username,
Ok(None) => {
warn!(
room_id = %request.room_id,
message_id = %request.message_id,
actor_user_id = %request.user_id,
"skipped chat delete audit because actor user was not found"
);
return;
}
Err(error) => {
warn!(
room_id = %request.room_id,
message_id = %request.message_id,
actor_user_id = %request.user_id,
error = %error,
"skipped chat delete audit because actor username lookup failed"
);
return;
let actor_username = if let Some(username) = actor_username_snapshot
.map(str::trim)
.filter(|username| !username.is_empty())
{
username.to_string()
} else {
match self.user_service.get_username(&request.user_id).await {
Ok(Some(username)) => username,
Ok(None) => {
warn!(
room_id = %request.room_id,
message_id = %request.message_id,
actor_user_id = %request.user_id,
"skipped chat delete audit because actor user was not found"
);
return;
}
Err(error) => {
warn!(
room_id = %request.room_id,
message_id = %request.message_id,
actor_user_id = %request.user_id,
error = %error,
"skipped chat delete audit because actor username lookup failed"
);
return;
}
}
};
let target_id = format!("{}:{}", request.room_id, request.message_id);

@ -73,7 +73,11 @@ pub use auth::{
pub use ban_record::{
BanRecordListQuery, BanRecordPage, BanRecordRow, BanRecordService, BanRecordTargetType,
};
pub use chat::{ChatDependencies, ChatRuntime, ChatService};
pub use chat::{
ChatDependencies, ChatReactionModerationPageOutcome, ChatRuntime, ChatService,
ChatUserModerationOutcome, ChatUserModerationPageOutcome, ChatUserModerationPageRequest,
ChatUserReactionModerationPageRequest,
};
pub use cleanup::{CleanupConfig, CleanupResult, CleanupService, CleanupServiceOptions};
pub use content_filter::{ContentFilter, ContentFilterError};
pub use content_report::{

@ -12,6 +12,16 @@ pub struct AuthorizedAdminActor {
}
impl AuthorizedAdminActor {
/// Builds the internal actor used to finish an already-authorized job.
///
/// Persisted jobs must remain executable when the admin account is later
/// deleted or demoted. The job row is created only after the request-level
/// authorization check, so the worker does not re-authorize it here.
#[must_use]
pub fn for_persisted_job(user_id: UserId, username: String) -> Self {
Self { user_id, username }
}
pub fn new(user_id: UserId, username: String, role: UserRole) -> Result<Self> {
if !role.is_admin_or_above() {
return Err(Error::Authorization(

@ -0,0 +1,532 @@
//! Chat moderation job repository integration tests.
//!
//! These tests require Docker-backed PostgreSQL and are ignored by default,
//! matching the other database integration tests in this package.
use chrono::Utc;
use synctv_core::{
models::{ChatMessage, ChatMessageStatus, DeletionSource, Room, SignupMethod, User},
repository::{
ChatModerationJobPhase, ChatModerationJobRepository, ChatModerationJobStatus,
ChatModerationProgress, ChatRepository, DeleteChatMessageEventRequest,
DeleteChatReactionsPageRequest, NewChatModerationJob, RoomRepository, UserRepository,
},
};
use synctv_core_testing::create_test_pool;
#[tokio::test]
#[ignore = "Requires Docker-backed PostgreSQL"]
async fn moderation_job_releases_each_incomplete_page_and_keeps_actor_snapshot() {
let (_container, pool) = create_test_pool().await;
let user_repository = UserRepository::new(pool.clone());
let room_repository = RoomRepository::new(pool.clone());
let actor = user_repository
.create(&User::new(
"moderation_actor".to_string(),
SignupMethod::Email,
))
.await
.expect("actor should be created");
let room = room_repository
.create(&Room::new("moderation_room".to_string(), actor.id))
.await
.expect("room should be created");
let repository = ChatModerationJobRepository::new(pool);
let job = repository
.insert(&NewChatModerationJob {
id: "moderation-state-machine".to_string(),
room_id: room.id,
target_user_id: actor.id,
actor_user_id: actor.id,
actor_username: "actor-before-account-removal".to_string(),
actor_role: synctv_core::models::UserRole::Admin,
message_id: Some(42),
ban_user: true,
delete_all_messages: true,
delete_all_reactions: true,
reason: Some("test".to_string()),
snapshot_at: Utc::now(),
})
.await
.expect("job should be inserted");
assert_eq!(job.actor_username, "actor-before-account-removal");
assert_eq!(job.message_id, Some(42));
assert!(job.ban_user);
let claimed = repository
.claim_batch("worker-1", 1)
.await
.expect("job should be claimed");
assert_eq!(claimed.len(), 1);
let claimed = &claimed[0];
assert_eq!(claimed.status, ChatModerationJobStatus::Processing);
let cursor = Some((Utc::now(), 1_i64));
let mut progress = claimed.clone();
progress.message_cursor = cursor;
progress.deleted_messages = 100;
progress.deleted_reactions = 100;
assert!(repository
.update_progress(&progress, "worker-1")
.await
.expect("incomplete progress should be persisted"));
let pending = repository
.get("moderation-state-machine")
.await
.expect("job should be readable")
.expect("job should still exist");
assert_eq!(pending.status, ChatModerationJobStatus::Pending);
assert_eq!(pending.actor_username, "actor-before-account-removal");
let reclaimed = repository
.claim_batch("worker-2", 1)
.await
.expect("next worker should claim the next page");
assert_eq!(reclaimed.len(), 1);
let reclaimed = &reclaimed[0];
assert_eq!(
reclaimed.message_cursor.map(|(_, id)| id),
cursor.map(|(_, id)| id)
);
assert_eq!(
reclaimed
.message_cursor
.map(|(at, _)| at.timestamp_micros()),
cursor.map(|(at, _)| at.timestamp_micros())
);
let mut completed_progress = reclaimed.clone();
completed_progress.phase = ChatModerationJobPhase::Done;
completed_progress.snapshot_at = reclaimed.snapshot_at + chrono::Duration::seconds(1);
completed_progress.explicit_message_done = true;
completed_progress.ban_done = true;
assert!(repository
.update_progress(&completed_progress, "worker-2")
.await
.expect("completion progress should be persisted"));
let persisted = repository
.get("moderation-state-machine")
.await
.expect("job should be readable")
.expect("job should exist");
assert_eq!(persisted.snapshot_at, completed_progress.snapshot_at);
completed_progress.lock_version += 1;
assert!(repository
.mark_completed(&completed_progress, "worker-2", 100, 100)
.await
.expect("job should complete"));
}
#[tokio::test]
#[ignore = "Requires Docker-backed PostgreSQL"]
async fn moderation_delete_persists_job_counts_in_the_same_transaction() {
let (_container, pool) = create_test_pool().await;
let user_repository = UserRepository::new(pool.clone());
let room_repository = RoomRepository::new(pool.clone());
let actor = user_repository
.create(&User::new(
"moderation_atomic_actor".to_string(),
SignupMethod::Email,
))
.await
.expect("actor should be created");
let room = room_repository
.create(&Room::new("moderation_atomic_room".to_string(), actor.id))
.await
.expect("room should be created");
let chat_repository = ChatRepository::new(pool.clone());
let message = chat_repository
.create(&ChatMessage::new(
room.id,
actor.id,
"message to moderate".to_string(),
))
.await
.expect("message should be created");
sqlx::query!(
r#"
INSERT INTO chat_message_reactions (
room_id, message_id, message_created_at, user_id, reaction_key
)
VALUES ($1, $2, $3, $4, $5)
"#,
room.id.as_i64(),
message.id,
message.created_at,
actor.id.as_i64(),
"heart",
)
.execute(&pool)
.await
.expect("reaction should be created");
let job_repository = ChatModerationJobRepository::new(pool.clone());
job_repository
.insert(&NewChatModerationJob {
id: "moderation-atomic-delete".to_string(),
room_id: room.id,
target_user_id: actor.id,
actor_user_id: actor.id,
actor_username: actor.username.clone(),
actor_role: synctv_core::models::UserRole::Admin,
message_id: None,
ban_user: false,
delete_all_messages: true,
delete_all_reactions: false,
reason: Some("test".to_string()),
snapshot_at: Utc::now(),
})
.await
.expect("job should be inserted");
let claimed = job_repository
.claim_batch("worker-atomic", 1)
.await
.expect("job should be claimed")
.pop()
.expect("one job should be claimed");
let progress = ChatModerationProgress {
job_id: &claimed.id,
worker_id: "worker-atomic",
lock_version: claimed.lock_version,
};
let outcome = chat_repository
.soft_delete_with_event(DeleteChatMessageEventRequest {
room_id: &room.id,
message_id: message.id,
message_created_at: message.created_at,
deleted_by: &actor.id,
reason: Some("test"),
expected_version: None,
event_id: "moderation-atomic-event",
occurred_at: Utc::now(),
operation: None,
reaction_user_id: Some(&actor.id),
moderation_progress: Some(progress),
deletion_source: DeletionSource::Admin,
})
.await
.expect("message deletion should commit")
.expect("message should be inserted as deleted");
assert_eq!(outcome.deleted_reactions, 1);
let stored_job = job_repository
.get(&claimed.id)
.await
.expect("job should be readable")
.expect("job should exist");
assert_eq!(stored_job.deleted_messages, 1);
assert_eq!(stored_job.deleted_reactions, 1);
let reaction_count = sqlx::query_scalar!(
r#"
SELECT COUNT(*) AS "count!"
FROM chat_message_reactions
WHERE room_id = $1 AND message_id = $2 AND message_created_at = $3
"#,
room.id.as_i64(),
message.id,
message.created_at,
)
.fetch_one(&pool)
.await
.expect("reaction count should be readable");
assert_eq!(reaction_count, 0);
}
#[tokio::test]
#[ignore = "Requires Docker-backed PostgreSQL"]
async fn moderation_delete_rolls_back_when_worker_lease_is_invalid() {
let (_container, pool) = create_test_pool().await;
let user_repository = UserRepository::new(pool.clone());
let room_repository = RoomRepository::new(pool.clone());
let actor = user_repository
.create(&User::new(
"moderation_lease_actor".to_string(),
SignupMethod::Email,
))
.await
.expect("actor should be created");
let room = room_repository
.create(&Room::new("moderation_lease_room".to_string(), actor.id))
.await
.expect("room should be created");
let chat_repository = ChatRepository::new(pool.clone());
let message = chat_repository
.create(&ChatMessage::new(
room.id,
actor.id,
"message with expired lease".to_string(),
))
.await
.expect("message should be created");
sqlx::query!(
r#"
INSERT INTO chat_message_reactions (
room_id, message_id, message_created_at, user_id, reaction_key
)
VALUES ($1, $2, $3, $4, $5)
"#,
room.id.as_i64(),
message.id,
message.created_at,
actor.id.as_i64(),
"heart",
)
.execute(&pool)
.await
.expect("reaction should be created");
let job_repository = ChatModerationJobRepository::new(pool.clone());
job_repository
.insert(&NewChatModerationJob {
id: "moderation-expired-lease".to_string(),
room_id: room.id,
target_user_id: actor.id,
actor_user_id: actor.id,
actor_username: actor.username.clone(),
actor_role: synctv_core::models::UserRole::Admin,
message_id: None,
ban_user: false,
delete_all_messages: true,
delete_all_reactions: false,
reason: Some("test".to_string()),
snapshot_at: Utc::now(),
})
.await
.expect("job should be inserted");
let claimed = job_repository
.claim_batch("worker-expired", 1)
.await
.expect("job should be claimed")
.pop()
.expect("one job should be claimed");
sqlx::query!(
r#"
UPDATE chat_moderation_jobs
SET locked_at = NOW() - INTERVAL '1 hour'
WHERE id = $1
"#,
&claimed.id,
)
.execute(&pool)
.await
.expect("lease should be expired");
assert_eq!(
job_repository
.requeue_stale_processing(1)
.await
.expect("stale job should be requeued"),
1
);
let Err(error) = chat_repository
.soft_delete_with_event(DeleteChatMessageEventRequest {
room_id: &room.id,
message_id: message.id,
message_created_at: message.created_at,
deleted_by: &actor.id,
reason: Some("test"),
expected_version: None,
event_id: "moderation-expired-event",
occurred_at: Utc::now(),
operation: None,
reaction_user_id: Some(&actor.id),
moderation_progress: Some(ChatModerationProgress {
job_id: &claimed.id,
worker_id: "worker-expired",
lock_version: claimed.lock_version,
}),
deletion_source: DeletionSource::Admin,
})
.await
else {
panic!("expired worker must not delete chat data");
};
assert!(matches!(error, synctv_core::Error::LockConflict(_)));
let message_status = sqlx::query_scalar!(
r#"
SELECT status AS "status!"
FROM chat_messages
WHERE room_id = $1 AND id = $2 AND created_at = $3
"#,
room.id.as_i64(),
message.id,
message.created_at,
)
.fetch_one(&pool)
.await
.expect("message status should be readable");
assert_eq!(message_status, i16::from(ChatMessageStatus::Active));
let reaction_count = sqlx::query_scalar!(
r#"
SELECT COUNT(*) AS "count!"
FROM chat_message_reactions
WHERE room_id = $1 AND message_id = $2 AND message_created_at = $3
"#,
room.id.as_i64(),
message.id,
message.created_at,
)
.fetch_one(&pool)
.await
.expect("reaction count should be readable");
assert_eq!(reaction_count, 1);
}
#[tokio::test]
#[ignore = "Requires Docker-backed PostgreSQL"]
async fn reaction_cleanup_preserves_reactions_created_after_the_snapshot() {
let (_container, pool) = create_test_pool().await;
let user_repository = UserRepository::new(pool.clone());
let room_repository = RoomRepository::new(pool.clone());
let user = user_repository
.create(&User::new(
"reaction_snapshot_user".to_string(),
SignupMethod::Email,
))
.await
.expect("user should be created");
let room = room_repository
.create(&Room::new("reaction_snapshot_room".to_string(), user.id))
.await
.expect("room should be created");
let chat_repository = ChatRepository::new(pool.clone());
let message = chat_repository
.create(&ChatMessage::new(
room.id,
user.id,
"reaction snapshot message".to_string(),
))
.await
.expect("message should be created");
let snapshot_at = Utc::now();
sqlx::query!(
r#"
INSERT INTO chat_message_reactions (
room_id, message_id, message_created_at, user_id, reaction_key, created_at
)
VALUES ($1, $2, $3, $4, $5, $6),
($1, $2, $3, $4, $7, $8)
"#,
room.id.as_i64(),
message.id,
message.created_at,
user.id.as_i64(),
"heart",
snapshot_at - chrono::Duration::seconds(1),
"fire",
snapshot_at + chrono::Duration::seconds(1),
)
.execute(&pool)
.await
.expect("reactions should be created");
let (deleted, _, _, _) = chat_repository
.delete_reactions_by_user_with_events_page(DeleteChatReactionsPageRequest {
room_id: &room.id,
user_id: &user.id,
actor_user_id: &user.id,
occurred_at: Utc::now(),
created_before: snapshot_at,
cursor: None,
limit: 100,
moderation_progress: None,
})
.await
.expect("reaction cleanup should succeed");
assert_eq!(deleted, 1);
let remaining = sqlx::query_scalar!(
r#"
SELECT reaction_key
FROM chat_message_reactions
WHERE room_id = $1
AND message_id = $2
AND message_created_at = $3
AND user_id = $4
"#,
room.id.as_i64(),
message.id,
message.created_at,
user.id.as_i64(),
)
.fetch_all(&pool)
.await
.expect("remaining reactions should be readable");
assert_eq!(remaining, vec!["fire".to_string()]);
}
#[tokio::test]
#[ignore = "Requires Docker-backed PostgreSQL"]
async fn terminal_job_cleanup_preserves_recent_jobs() {
let (_container, pool) = create_test_pool().await;
let user_repository = UserRepository::new(pool.clone());
let room_repository = RoomRepository::new(pool.clone());
let actor = user_repository
.create(&User::new(
"moderation_cleanup_actor".to_string(),
SignupMethod::Email,
))
.await
.expect("actor should be created");
let room = room_repository
.create(&Room::new("moderation_cleanup_room".to_string(), actor.id))
.await
.expect("room should be created");
let repository = ChatModerationJobRepository::new(pool.clone());
for id in ["expired-terminal-job", "recent-terminal-job"] {
repository
.insert(&NewChatModerationJob {
id: id.to_string(),
room_id: room.id,
target_user_id: actor.id,
actor_user_id: actor.id,
actor_username: actor.username.clone(),
actor_role: synctv_core::models::UserRole::Admin,
message_id: None,
ban_user: false,
delete_all_messages: true,
delete_all_reactions: false,
reason: Some("test".to_string()),
snapshot_at: Utc::now(),
})
.await
.expect("job should be inserted");
}
sqlx::query!(
r#"
UPDATE chat_moderation_jobs
SET status = 3,
phase = 3,
updated_at = CASE
WHEN id = 'expired-terminal-job' THEN NOW() - INTERVAL '2 hours'
ELSE NOW()
END,
completed_at = NOW()
WHERE id IN ('expired-terminal-job', 'recent-terminal-job')
"#,
)
.execute(&pool)
.await
.expect("jobs should be completed");
assert_eq!(
repository
.delete_terminal_before(60 * 60)
.await
.expect("expired jobs should be deleted"),
1
);
assert!(repository
.get("expired-terminal-job")
.await
.expect("expired job lookup should succeed")
.is_none());
assert!(repository
.get("recent-terminal-job")
.await
.expect("recent job lookup should succeed")
.is_some());
}

@ -21,15 +21,16 @@ use synctv_core::{
SendChatMessage, SubmittedFileReference, User, UserId, UserRole, UserStatus,
},
repository::{
ChatRepository, FileStorageRepository, RoomMemberRepository, RoomRepository,
RoomSettingsRepository, UpsertFileBlob, UpsertFileObject, UserRepository,
ChatModerationJobRepository, ChatModerationProgress, ChatRepository, FileStorageRepository,
NewChatModerationJob, RoomMemberRepository, RoomRepository, RoomSettingsRepository,
UpsertFileBlob, UpsertFileObject, UserRepository,
},
service::{
AuditService, BruteForceProtection, ChatDependencies, ChatRuntime, ChatService,
ContentFilter, DisabledFileStorageService, FileStorageCleanupOrigin, FileStorageService,
InMemoryTokenBlacklistStore, JwtService, NotificationService, PermissionService,
RateLimitConfig, RateLimiter, RequestRateLimiterService, RoomService, RoomSettingsService,
UserService,
AuditService, AuthorizedAdminActor, BruteForceProtection, ChatDependencies, ChatRuntime,
ChatService, ContentFilter, DisabledFileStorageService, FileStorageCleanupOrigin,
FileStorageService, InMemoryTokenBlacklistStore, JwtService, NotificationService,
PermissionService, RateLimitConfig, RateLimiter, RequestRateLimiterService, RoomService,
RoomSettingsService, UserService,
},
Error,
};
@ -2227,6 +2228,234 @@ async fn test_delete_message_event_soft_deletes_and_checks_expected_version() {
assert!(matches!(stale, Error::Conflict(_)));
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_admin_moderation_delete_rejects_expired_worker_lease() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let (chat_service, username_cache) = make_chat_service(&pool);
let target = user_repo
.create(&make_user("moderation_lease_target"))
.await
.checked("target should be created");
let mut actor = make_user("moderation_lease_admin");
actor.role = UserRole::Admin;
let actor = user_repo
.create(&actor)
.await
.checked("actor should be created");
username_cache
.set(&target.id, &target.username)
.await
.checked("target username should be cached");
let room = room_service
.create_room(
"Moderation Lease Room".to_string(),
String::new(),
target.id,
None,
None,
)
.await
.checked("room should be created");
let message = chat_service
.send_message(room.id, target.id, "keep after stale lease".to_string())
.await
.checked("message should be created");
let job_repository = ChatModerationJobRepository::new(pool.clone());
job_repository
.insert(&NewChatModerationJob {
id: "service-expired-moderation-lease".to_string(),
room_id: room.id,
target_user_id: target.id,
actor_user_id: actor.id,
actor_username: actor.username.clone(),
actor_role: actor.role,
message_id: Some(message.id),
ban_user: false,
delete_all_messages: false,
delete_all_reactions: false,
reason: Some("test".to_string()),
snapshot_at: Utc::now(),
})
.await
.checked("job should be inserted");
let claimed = job_repository
.claim_batch("expired-service-worker", 1)
.await
.checked("job should be claimed")
.pop()
.checked("one job should be claimed");
sqlx::query!(
r#"
UPDATE chat_moderation_jobs
SET locked_at = NOW() - INTERVAL '1 hour'
WHERE id = $1
"#,
&claimed.id,
)
.execute(&pool)
.await
.checked("lease should be expired");
assert_eq!(
job_repository
.requeue_stale_processing(1)
.await
.checked("stale job should be requeued"),
1
);
let actor = AuthorizedAdminActor::for_persisted_job(actor.id, actor.username);
let error = chat_service
.delete_message_event_outcome_for_author_as_admin_with_progress(
DeleteChatMessage {
room_id: room.id,
message_id: message.id,
user_id: *actor.user_id(),
client_operation_id: None,
reason: Some("test".to_string()),
expected_version: Some(message.version),
},
&target.id,
&actor,
Some(ChatModerationProgress {
job_id: &claimed.id,
worker_id: "expired-service-worker",
lock_version: claimed.lock_version,
}),
)
.await
.failed("expired worker must not delete the message");
assert!(matches!(error, Error::LockConflict(_)));
let stored = ChatRepository::new(pool)
.get_by_room_and_id_from_primary(&room.id, message.id)
.await
.checked("message should be readable")
.checked("message should exist");
assert_eq!(stored.status, ChatMessageStatus::Active);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_admin_moderation_anchor_remains_idempotent_after_enqueue() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_service = make_room_service(pool.clone());
let (chat_service, username_cache) = make_chat_service(&pool);
let target = user_repo
.create(&make_user("moderation_anchor_target"))
.await
.checked("target should be created");
let mut first_admin = make_user("moderation_anchor_admin_one");
first_admin.role = UserRole::Admin;
let first_admin = user_repo
.create(&first_admin)
.await
.checked("first admin should be created");
let mut second_admin = make_user("moderation_anchor_admin_two");
second_admin.role = UserRole::Admin;
let second_admin = user_repo
.create(&second_admin)
.await
.checked("second admin should be created");
username_cache
.set(&target.id, &target.username)
.await
.checked("target username should be cached");
let room = room_service
.create_room(
"Moderation Anchor Room".to_string(),
String::new(),
target.id,
None,
None,
)
.await
.checked("room should be created");
let edited_after_enqueue = chat_service
.send_message(room.id, target.id, "edited after enqueue".to_string())
.await
.checked("message should be created");
chat_service
.validate_moderation_message_anchor(&room.id, edited_after_enqueue.id, &target.id)
.await
.checked("anchor should be valid when the command is accepted");
sqlx::query(
r"
UPDATE chat_messages
SET version = version + 1,
content = 'edited while moderation was queued'
WHERE room_id = $1 AND id = $2 AND created_at = $3
",
)
.bind(room.id.as_i64())
.bind(edited_after_enqueue.id)
.bind(edited_after_enqueue.created_at)
.execute(&pool)
.await
.checked("message should be edited");
chat_service
.validate_moderation_message_anchor(&room.id, edited_after_enqueue.id, &target.id)
.await
.checked("editing the anchor must not prevent moderation");
let first_actor =
AuthorizedAdminActor::for_persisted_job(first_admin.id, first_admin.username.clone());
let deleted = chat_service
.delete_moderation_message_event_outcome_as_admin_with_progress(
&room.id,
edited_after_enqueue.id,
&target.id,
&first_actor,
Some("queued moderation"),
None,
)
.await
.checked("an edit after enqueue must not cancel the accepted command")
.checked("the active anchor should be deleted");
assert!(deleted.inserted);
let deleted_by_another_admin = chat_service
.send_message(room.id, target.id, "deleted by another admin".to_string())
.await
.checked("second message should be created");
let second_actor =
AuthorizedAdminActor::for_persisted_job(second_admin.id, second_admin.username.clone());
chat_service
.delete_message_event_outcome_for_author_as_admin(
DeleteChatMessage {
room_id: room.id,
message_id: deleted_by_another_admin.id,
user_id: second_admin.id,
client_operation_id: None,
reason: Some("other moderation".to_string()),
expected_version: None,
},
&target.id,
&second_actor,
)
.await
.checked("second admin should delete the anchor");
let already_deleted = chat_service
.delete_moderation_message_event_outcome_as_admin_with_progress(
&room.id,
deleted_by_another_admin.id,
&target.id,
&first_actor,
Some("queued moderation"),
None,
)
.await
.checked("an already deleted anchor should be treated as complete");
assert!(already_deleted.is_none());
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_delete_message_client_operation_id_replays_without_expected_version() {

@ -40,6 +40,7 @@ service AdminService {
rpc UpdateUserRole(UpdateUserRoleRequest) returns (AdminUser);
rpc BanUser(BanUserRequest) returns (AdminUser);
rpc UnbanUser(UnbanUserRequest) returns (AdminUser);
rpc ModerateRoomChatUser(ModerateRoomChatUserRequest) returns (ModerateRoomChatUserResponse);
rpc GetUserRooms(GetUserRoomsRequest) returns (GetUserRoomsResponse);
// =========================
@ -825,6 +826,26 @@ message UnbanUserRequest {
}];
}
message ModerateRoomChatUserRequest {
string room_id = 1 [(buf.validate.field).string = {
min_len: 1
max_len: 64
pattern: "^room_[A-Za-z0-9]+$"
}];
string user_id = 2 [(buf.validate.field).string = {
min_len: 1
max_len: 64
pattern: "^usr_[A-Za-z0-9]+$"
}];
bool delete_all_messages = 3;
bool ban_user = 4;
string reason = 5 [(buf.validate.field).string.max_len = 500];
bool delete_all_reactions = 6;
string message_id = 7;
}
message ModerateRoomChatUserResponse {}
message GetUserRoomsRequest {
string user_id = 1 [(buf.validate.field).string = {
min_len: 1

@ -0,0 +1,82 @@
use std::{sync::Arc, time::Duration};
use synctv_api::AdminApiImpl;
use tokio_util::sync::CancellationToken;
use tracing::{error, info, warn};
const CLAIM_BATCH_SIZE: i64 = 1;
const IDLE_POLL_INTERVAL: Duration = Duration::from_secs(1);
const STALE_REQUEUE_INTERVAL: Duration = Duration::from_mins(1);
const PROCESSING_STALE_AFTER_SECS: i64 = 600;
const TERMINAL_CLEANUP_INTERVAL: Duration = Duration::from_hours(1);
const TERMINAL_RETENTION_SECS: i64 = 7 * 24 * 60 * 60;
pub fn start_chat_moderation_dispatcher(
admin_api: Arc<AdminApiImpl>,
worker_id: String,
cancel: CancellationToken,
) -> tokio::task::JoinHandle<()> {
synctv_core::spawn::spawn_monitored("chat_moderation_dispatcher", async move {
run_dispatcher(admin_api, worker_id, cancel).await;
})
}
async fn run_dispatcher(
admin_api: Arc<AdminApiImpl>,
worker_id: String,
cancel: CancellationToken,
) {
let mut next_stale_requeue = tokio::time::Instant::now();
let mut next_terminal_cleanup = tokio::time::Instant::now();
info!(worker_id = %worker_id, "Chat moderation dispatcher started");
loop {
if cancel.is_cancelled() {
info!(worker_id = %worker_id, "Chat moderation dispatcher stopping");
return;
}
if tokio::time::Instant::now() >= next_stale_requeue {
next_stale_requeue = tokio::time::Instant::now() + STALE_REQUEUE_INTERVAL;
if let Some(chat_service) = admin_api.chat_service.as_ref() {
match chat_service
.moderation_job_repository()
.requeue_stale_processing(PROCESSING_STALE_AFTER_SECS)
.await
{
Ok(0) => {}
Ok(count) => warn!(count, "Recovered stale chat moderation jobs"),
Err(error) => {
warn!(error = %error, "Failed to requeue stale chat moderation jobs");
}
}
}
}
if tokio::time::Instant::now() >= next_terminal_cleanup {
next_terminal_cleanup = tokio::time::Instant::now() + TERMINAL_CLEANUP_INTERVAL;
if let Some(chat_service) = admin_api.chat_service.as_ref() {
match chat_service
.moderation_job_repository()
.delete_terminal_before(TERMINAL_RETENTION_SECS)
.await
{
Ok(0) => {}
Ok(count) => info!(count, "Deleted expired chat moderation jobs"),
Err(error) => {
warn!(error = %error, "Failed to delete expired chat moderation jobs");
}
}
}
}
match admin_api
.process_chat_moderation_jobs(&worker_id, CLAIM_BATCH_SIZE)
.await
{
Ok(true) => continue,
Ok(false) => {}
Err(error) => error!(error = %error, "Failed to process chat moderation jobs"),
}
tokio::select! {
() = cancel.cancelled() => return,
() = tokio::time::sleep(IDLE_POLL_INTERVAL) => {}
}
}
}

@ -7,6 +7,7 @@ pub(crate) mod admin_client;
mod app;
pub mod app_config;
mod bootstrap;
mod chat_moderation_dispatcher;
pub mod cli;
mod config_env;
mod config_loader;

@ -43,6 +43,7 @@ use synctv_realtime::sync::RealtimeEvent;
use crate::app_config::{AppConfig as Config, MetricsTlsConfig};
use crate::bootstrap::cluster::ClusterNodeActivator;
use crate::bootstrap::DatabasePools;
use crate::chat_moderation_dispatcher::start_chat_moderation_dispatcher;
use crate::management_runtime::{
ManagementAcfunRuntime, ManagementAdminRuntime, ManagementAlistRuntime,
ManagementBilibiliRuntime, ManagementCctvRuntime, ManagementCloudreveRuntime,
@ -365,7 +366,7 @@ fn build_core_api_impls(
live_streaming_infrastructure: live_streaming_infrastructure.clone(),
runtime_settings_store: runtime_settings_store.clone(),
public_id_codec: public_id_codec.clone(),
chat_service,
chat_service: chat_service.clone(),
provider_stores: provider_stores.clone(),
email_api: email.clone(),
passkey_service,
@ -394,6 +395,7 @@ fn build_core_api_impls(
Some(Arc::new(synctv_api::AdminApiImpl::new_with_runtime(
synctv_api::AdminApiOptions {
room_service,
chat_service: chat_service.clone(),
user_service: user_service.clone(),
read_services: build_admin_read_services(user_service.as_ref(), read_pool),
settings_service,
@ -1824,6 +1826,20 @@ impl SyncTvServer {
synctv_core::service::ServerStateService::refresh_interval().as_secs()
);
if let Some(admin_api) = shared_http_app_state.shared_api_runtime.admin_api.clone() {
let cancel = coordinator.register_token("chat_moderation_dispatcher");
let worker_id = format!(
"{}:{}",
self.services.realtime_event_service.node_id(),
synctv_common::snanoid!(8)
);
coordinator.register_task(
"chat_moderation_dispatcher",
start_chat_moderation_dispatcher(admin_api, worker_id, cancel),
);
info!("Chat moderation dispatcher started");
}
// Start unified API server (single listener for REST + gRPC)
let api_handle = match self
.start_api_server(
@ -2296,6 +2312,7 @@ impl SyncTvServer {
coordinator.cancel_tokens(&[
"realtime_outbox_dispatcher",
"email_outbox_dispatcher",
"chat_moderation_dispatcher",
"room_notification_bridge",
"playback_background_tasks",
]);
@ -2304,6 +2321,7 @@ impl SyncTvServer {
&[
"realtime_outbox_dispatcher",
"email_outbox_dispatcher",
"chat_moderation_dispatcher",
"room_notification_bridge",
"playback_auto_advance",
"playback_duration_probe",

Loading…
Cancel
Save