mirror of https://github.com/synctv-org/synctv
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 passeddependabot/github_actions/actions/cache-6
parent
ad3a8b3930
commit
7fb7f6c64f
@ -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"
|
||||||
|
}
|
||||||
@ -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"
|
||||||
|
}
|
||||||
@ -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"
|
||||||
|
}
|
||||||
@ -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"
|
||||||
|
}
|
||||||
@ -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"
|
||||||
|
}
|
||||||
@ -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"
|
||||||
|
}
|
||||||
@ -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);
|
||||||
@ -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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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());
|
||||||
|
}
|
||||||
@ -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) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue