Skip to main content

max / makenotwork

MT audit remediation: typed enums, batch inserts, indexes, session logging - Add sqlx::Type derives to CommunityRole, BanType, ModAction for typed query returns - Replace COUNT(*) > 0 with EXISTS in ban/mute/endorsement checks - Batch INSERT for set_thread_tags and insert_mentions (eliminates N+1) - Wrap toggle_endorsement in transaction to prevent race conditions - Add 5 performance indexes (migration 023) - Add LIMIT to unbounded queries (list_all_communities, list_community_members) - Log session operation failures in auth.rs instead of silently discarding - Complete .env.example with S3 and INTERNAL_SHARED_SECRET vars - Fix test create_post calls to include is_reply parameter Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-04-16 05:31 UTC
Commit: 58e52d58f45b430f109bb9c86517dfd76952958f
Parent: 1180376
18 files changed, +178 insertions, -82 deletions
@@ -12,3 +12,13 @@ RUST_LOG=info
12 12
13 13 # Platform admin (UUID of the MNW account that can access /_admin)
14 14 # PLATFORM_ADMIN_ID=00000000-0000-0000-0000-000000000000
15 +
16 + # S3 storage (optional — required for image uploads)
17 + # S3_ENDPOINT=https://s3.us-east-1.amazonaws.com
18 + # S3_BUCKET=mt-uploads
19 + # S3_ACCESS_KEY=your-access-key
20 + # S3_SECRET_KEY=your-secret-key
21 + # S3_REGION=us-east-1
22 +
23 + # Internal API shared secret (HMAC-SHA256 auth for MNW → MT calls)
24 + # INTERNAL_SHARED_SECRET=your-shared-secret
@@ -2074,6 +2074,8 @@ name = "mt-core"
2074 2074 version = "0.3.3"
2075 2075 dependencies = [
2076 2076 "chrono",
2077 + "serde",
2078 + "sqlx",
2077 2079 ]
2078 2080
2079 2081 [[package]]
@@ -5,3 +5,5 @@ edition.workspace = true
5 5
6 6 [dependencies]
7 7 chrono = { workspace = true }
8 + serde = { workspace = true }
9 + sqlx = { workspace = true }
@@ -1,5 +1,6 @@
1 1 //! Typed enums replacing raw string values across the codebase.
2 2
3 + use serde::Serialize;
3 4 use std::fmt;
4 5
5 6 // ============================================================================
@@ -7,13 +8,20 @@ use std::fmt;
7 8 // ============================================================================
8 9
9 10 /// Role a user holds within a community.
10 - #[derive(Debug, Clone, Copy, PartialEq, Eq)]
11 + #[derive(Debug, Clone, Copy, PartialEq, Eq, sqlx::Type)]
12 + #[sqlx(type_name = "TEXT", rename_all = "lowercase")]
11 13 pub enum CommunityRole {
12 14 Owner,
13 15 Moderator,
14 16 Member,
15 17 }
16 18
19 + impl Serialize for CommunityRole {
20 + fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
21 + serializer.serialize_str(self.as_str())
22 + }
23 + }
24 +
17 25 impl CommunityRole {
18 26 /// Parse from the string stored in the database. Returns `None` for unrecognised values.
19 27 pub fn from_db(s: &str) -> Option<Self> {
@@ -54,7 +62,8 @@ impl fmt::Display for CommunityRole {
54 62 // ============================================================================
55 63
56 64 /// Type of community restriction.
57 - #[derive(Debug, Clone, Copy, PartialEq, Eq)]
65 + #[derive(Debug, Clone, Copy, PartialEq, Eq, sqlx::Type)]
66 + #[sqlx(type_name = "TEXT", rename_all = "lowercase")]
58 67 pub enum BanType {
59 68 Ban,
60 69 Mute,
@@ -80,7 +89,8 @@ impl fmt::Display for BanType {
80 89 // ============================================================================
81 90
82 91 /// Action recorded in the moderation log.
83 - #[derive(Debug, Clone, Copy, PartialEq, Eq)]
92 + #[derive(Debug, Clone, Copy, PartialEq, Eq, sqlx::Type)]
93 + #[sqlx(type_name = "TEXT", rename_all = "snake_case")]
84 94 pub enum ModAction {
85 95 PinThread,
86 96 UnpinThread,
@@ -671,7 +671,7 @@ pub async fn delete_tag(pool: &PgPool, tag_id: Uuid) -> Result<(), sqlx::Error>
671 671 Ok(())
672 672 }
673 673
674 - /// Set the tags for a thread (delete existing + insert new, in a transaction).
674 + /// Set the tags for a thread (delete existing + batch insert new, in a transaction).
675 675 #[tracing::instrument(skip_all)]
676 676 pub async fn set_thread_tags(
677 677 pool: &PgPool,
@@ -683,12 +683,21 @@ pub async fn set_thread_tags(
683 683 .bind(thread_id)
684 684 .execute(&mut *tx)
685 685 .await?;
686 - for tag_id in tag_ids {
687 - sqlx::query("INSERT INTO thread_tags (thread_id, tag_id) VALUES ($1, $2)")
688 - .bind(thread_id)
689 - .bind(tag_id)
690 - .execute(&mut *tx)
691 - .await?;
686 + if !tag_ids.is_empty() {
687 + // Build multi-row INSERT: VALUES ($1, $2), ($1, $3), ...
688 + let mut sql = String::from("INSERT INTO thread_tags (thread_id, tag_id) VALUES ");
689 + for (i, _) in tag_ids.iter().enumerate() {
690 + if i > 0 {
691 + sql.push_str(", ");
692 + }
693 + // $1 = thread_id, $2.. = tag_ids
694 + sql.push_str(&format!("($1, ${})", i + 2));
695 + }
696 + let mut query = sqlx::query(&sql).bind(thread_id);
697 + for tag_id in tag_ids {
698 + query = query.bind(tag_id);
699 + }
700 + query.execute(&mut *tx).await?;
692 701 }
693 702 tx.commit().await?;
694 703 Ok(())
@@ -792,22 +801,30 @@ pub async fn insert_link_preview(
792 801 // Mention mutations
793 802 // ============================================================================
794 803
795 - /// Insert mention rows for a post. Ignores duplicates.
804 + /// Insert mention rows for a post (batch insert). Ignores duplicates.
796 805 #[tracing::instrument(skip_all)]
797 806 pub async fn insert_mentions(
798 807 pool: &PgPool,
799 808 post_id: Uuid,
800 809 user_ids: &[Uuid],
801 810 ) -> Result<(), sqlx::Error> {
811 + if user_ids.is_empty() {
812 + return Ok(());
813 + }
814 + // Build multi-row INSERT: VALUES ($1, $2), ($1, $3), ... ON CONFLICT DO NOTHING
815 + let mut sql = String::from("INSERT INTO post_mentions (post_id, mentioned_user_id) VALUES ");
816 + for (i, _) in user_ids.iter().enumerate() {
817 + if i > 0 {
818 + sql.push_str(", ");
819 + }
820 + sql.push_str(&format!("($1, ${})", i + 2));
821 + }
822 + sql.push_str(" ON CONFLICT DO NOTHING");
823 + let mut query = sqlx::query(&sql).bind(post_id);
802 824 for user_id in user_ids {
803 - sqlx::query(
804 - "INSERT INTO post_mentions (post_id, mentioned_user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING",
805 - )
806 - .bind(post_id)
807 - .bind(user_id)
808 - .execute(pool)
809 - .await?;
825 + query = query.bind(user_id);
810 826 }
827 + query.execute(pool).await?;
811 828 Ok(())
812 829 }
813 830
@@ -816,18 +833,21 @@ pub async fn insert_mentions(
816 833 // ============================================================================
817 834
818 835 /// Toggle endorsement: insert if missing, delete if exists. Returns true if now endorsed.
836 + /// Uses a transaction to prevent race conditions between concurrent toggle requests.
819 837 #[tracing::instrument(skip_all)]
820 838 pub async fn toggle_endorsement(
821 839 pool: &PgPool,
822 840 post_id: Uuid,
823 841 endorser_id: Uuid,
824 842 ) -> Result<bool, sqlx::Error> {
843 + let mut tx = pool.begin().await?;
844 +
825 845 let result = sqlx::query(
826 846 "INSERT INTO post_endorsements (post_id, endorser_id) VALUES ($1, $2) ON CONFLICT DO NOTHING",
827 847 )
828 848 .bind(post_id)
829 849 .bind(endorser_id)
830 - .execute(pool)
850 + .execute(&mut *tx)
831 851 .await?;
832 852
833 853 if result.rows_affected() == 0 {
@@ -835,10 +855,12 @@ pub async fn toggle_endorsement(
835 855 sqlx::query("DELETE FROM post_endorsements WHERE post_id = $1 AND endorser_id = $2")
836 856 .bind(post_id)
837 857 .bind(endorser_id)
838 - .execute(pool)
858 + .execute(&mut *tx)
839 859 .await?;
860 + tx.commit().await?;
840 861 Ok(false)
841 862 } else {
863 + tx.commit().await?;
842 864 Ok(true)
843 865 }
844 866 }
@@ -1,6 +1,7 @@
1 1 //! Database read queries — projection structs and SQL.
2 2
3 3 use chrono::{DateTime, Utc};
4 + use mt_core::types::{BanType, CommunityRole, ModAction};
4 5 use sqlx::PgPool;
5 6 use uuid::Uuid;
6 7
@@ -464,8 +465,8 @@ pub async fn get_user_role(
464 465 pool: &PgPool,
465 466 user_id: Uuid,
466 467 community_id: Uuid,
467 - ) -> Result<Option<String>, sqlx::Error> {
468 - let row: Option<(String,)> = sqlx::query_as(
468 + ) -> Result<Option<CommunityRole>, sqlx::Error> {
469 + let row: Option<(CommunityRole,)> = sqlx::query_as(
469 470 "SELECT role FROM memberships WHERE user_id = $1 AND community_id = $2",
470 471 )
471 472 .bind(user_id)
@@ -504,7 +505,7 @@ pub async fn list_categories_for_settings(
504 505 pub struct MemberRow {
505 506 pub username: String,
506 507 pub display_name: Option<String>,
507 - pub role: String,
508 + pub role: CommunityRole,
508 509 pub joined_at: DateTime<Utc>,
509 510 }
510 511
@@ -512,6 +513,8 @@ pub struct MemberRow {
512 513 pub async fn list_community_members(
513 514 pool: &PgPool,
514 515 community_id: Uuid,
516 + limit: i64,
517 + offset: i64,
515 518 ) -> Result<Vec<MemberRow>, sqlx::Error> {
516 519 sqlx::query_as::<_, MemberRow>(
517 520 "SELECT u.username,
@@ -528,13 +531,28 @@ pub async fn list_community_members(
528 531 WHEN 'member' THEN 2
529 532 ELSE 3
530 533 END,
531 - m.joined_at",
534 + m.joined_at
535 + LIMIT $2 OFFSET $3",
532 536 )
533 537 .bind(community_id)
538 + .bind(limit)
539 + .bind(offset)
534 540 .fetch_all(pool)
535 541 .await
536 542 }
537 543
544 + /// Count members in a community.
545 + #[tracing::instrument(skip_all)]
546 + pub async fn count_community_members(
547 + pool: &PgPool,
548 + community_id: Uuid,
549 + ) -> Result<i64, sqlx::Error> {
550 + sqlx::query_scalar("SELECT COUNT(*) FROM memberships WHERE community_id = $1")
551 + .bind(community_id)
552 + .fetch_one(pool)
553 + .await
554 + }
555 +
538 556 #[tracing::instrument(skip_all)]
539 557 pub async fn get_category_by_id(
540 558 pool: &PgPool,
@@ -612,13 +630,12 @@ pub async fn is_user_suspended(
612 630 pool: &PgPool,
613 631 user_id: Uuid,
614 632 ) -> Result<bool, sqlx::Error> {
615 - let count: i64 = sqlx::query_scalar(
616 - "SELECT COUNT(*) FROM users WHERE mnw_account_id = $1 AND suspended_at IS NOT NULL",
633 + sqlx::query_scalar(
634 + "SELECT EXISTS(SELECT 1 FROM users WHERE mnw_account_id = $1 AND suspended_at IS NOT NULL)",
617 635 )
618 636 .bind(user_id)
619 637 .fetch_one(pool)
620 - .await?;
621 - Ok(count > 0)
638 + .await
622 639 }
623 640
624 641 /// Check if user has an active ban in a community.
@@ -628,16 +645,15 @@ pub async fn is_user_banned(
628 645 community_id: Uuid,
629 646 user_id: Uuid,
630 647 ) -> Result<bool, sqlx::Error> {
631 - let count: i64 = sqlx::query_scalar(
632 - "SELECT COUNT(*) FROM community_bans
648 + sqlx::query_scalar(
649 + "SELECT EXISTS(SELECT 1 FROM community_bans
633 650 WHERE community_id = $1 AND user_id = $2 AND ban_type = 'ban'
634 - AND (expires_at IS NULL OR expires_at > now())",
651 + AND (expires_at IS NULL OR expires_at > now()))",
635 652 )
636 653 .bind(community_id)
637 654 .bind(user_id)
638 655 .fetch_one(pool)
639 - .await?;
640 - Ok(count > 0)
656 + .await
641 657 }
642 658
643 659 /// Check if user has an active mute in a community.
@@ -647,16 +663,15 @@ pub async fn is_user_muted(
647 663 community_id: Uuid,
648 664 user_id: Uuid,
649 665 ) -> Result<bool, sqlx::Error> {
650 - let count: i64 = sqlx::query_scalar(
651 - "SELECT COUNT(*) FROM community_bans
666 + sqlx::query_scalar(
667 + "SELECT EXISTS(SELECT 1 FROM community_bans
652 668 WHERE community_id = $1 AND user_id = $2 AND ban_type = 'mute'
653 - AND (expires_at IS NULL OR expires_at > now())",
669 + AND (expires_at IS NULL OR expires_at > now()))",
654 670 )
655 671 .bind(community_id)
656 672 .bind(user_id)
657 673 .fetch_one(pool)
658 - .await?;
659 - Ok(count > 0)
674 + .await
660 675 }
661 676
662 677 #[derive(sqlx::FromRow)]
@@ -665,7 +680,7 @@ pub struct CommunityBanRow {
665 680 pub user_id: Uuid,
666 681 pub username: String,
667 682 pub display_name: Option<String>,
668 - pub ban_type: String,
683 + pub ban_type: BanType,
669 684 pub reason: Option<String>,
670 685 pub expires_at: Option<DateTime<Utc>>,
671 686 pub created_at: DateTime<Utc>,
@@ -699,7 +714,7 @@ pub async fn list_community_bans(
699 714 pub struct ModLogEntry {
700 715 pub id: Uuid,
701 716 pub actor_username: String,
702 - pub action: String,
717 + pub action: ModAction,
703 718 pub target_username: Option<String>,
704 719 pub reason: Option<String>,
705 720 pub created_at: DateTime<Utc>,
@@ -773,7 +788,7 @@ pub struct UserProfileRow {
773 788 pub username: String,
774 789 pub display_name: Option<String>,
775 790 pub avatar_url: Option<String>,
776 - pub role: String,
791 + pub role: CommunityRole,
777 792 pub joined_at: DateTime<Utc>,
778 793 pub post_count: i64,
779 794 pub endorsement_count: i64,
@@ -866,7 +881,7 @@ pub async fn get_user_activity_in_community(
866 881 pub struct UserMembershipSummary {
867 882 pub community_name: String,
868 883 pub community_slug: String,
869 - pub role: String,
884 + pub role: CommunityRole,
870 885 pub joined_at: DateTime<Utc>,
871 886 pub post_count: i64,
872 887 }
@@ -913,14 +928,14 @@ pub struct AdminCommunityRow {
913 928 pub suspension_reason: Option<String>,
914 929 }
915 930
916 - /// List all communities for the admin dashboard.
931 + /// List all communities for the admin dashboard (capped at 500).
917 932 #[tracing::instrument(skip_all)]
918 933 pub async fn list_all_communities(
919 934 pool: &PgPool,
920 935 ) -> Result<Vec<AdminCommunityRow>, sqlx::Error> {
921 936 sqlx::query_as::<_, AdminCommunityRow>(
922 937 "SELECT id, name, slug, suspended_at, suspension_reason
923 - FROM communities ORDER BY name",
938 + FROM communities ORDER BY name LIMIT 500",
924 939 )
925 940 .fetch_all(pool)
926 941 .await
@@ -1057,14 +1072,13 @@ pub async fn is_thread_tracked(
1057 1072 user_id: Uuid,
1058 1073 thread_id: Uuid,
1059 1074 ) -> Result<bool, sqlx::Error> {
1060 - let count: i64 = sqlx::query_scalar(
1061 - "SELECT COUNT(*) FROM tracked_threads WHERE user_id = $1 AND thread_id = $2",
1075 + sqlx::query_scalar(
1076 + "SELECT EXISTS(SELECT 1 FROM tracked_threads WHERE user_id = $1 AND thread_id = $2)",
1062 1077 )
1063 1078 .bind(user_id)
1064 1079 .bind(thread_id)
1065 1080 .fetch_one(pool)
1066 - .await?;
1067 - Ok(count > 0)
1081 + .await
1068 1082 }
1069 1083
1070 1084 #[derive(sqlx::FromRow)]
@@ -1296,14 +1310,13 @@ pub async fn has_user_flagged_post(
1296 1310 post_id: Uuid,
1297 1311 flagger_id: Uuid,
1298 1312 ) -> Result<bool, sqlx::Error> {
1299 - let count: i64 = sqlx::query_scalar(
1300 - "SELECT COUNT(*) FROM post_flags WHERE post_id = $1 AND flagger_id = $2",
1313 + sqlx::query_scalar(
1314 + "SELECT EXISTS(SELECT 1 FROM post_flags WHERE post_id = $1 AND flagger_id = $2)",
1301 1315 )
1302 1316 .bind(post_id)
1303 1317 .bind(flagger_id)
1304 1318 .fetch_one(pool)
1305 - .await?;
1306 - Ok(count > 0)
1319 + .await
1307 1320 }
1308 1321
1309 1322
@@ -1466,13 +1479,12 @@ pub async fn thread_exists(
1466 1479 pool: &PgPool,
1467 1480 thread_id: Uuid,
1468 1481 ) -> Result<bool, sqlx::Error> {
1469 - let count: i64 = sqlx::query_scalar(
1470 - "SELECT COUNT(*) FROM threads WHERE id = $1",
1482 + sqlx::query_scalar(
1483 + "SELECT EXISTS(SELECT 1 FROM threads WHERE id = $1)",
1471 1484 )
1472 1485 .bind(thread_id)
1473 1486 .fetch_one(pool)
1474 - .await?;
1475 - Ok(count > 0)
1487 + .await
1476 1488 }
1477 1489
1478 1490 /// Count images uploaded by a user in the last N seconds (rate limiting).
@@ -0,0 +1,20 @@
1 + -- Performance indexes for hot query paths identified in Run 15 audit.
2 +
3 + -- Suspension check (called on every authenticated write request)
4 + CREATE INDEX IF NOT EXISTS idx_users_suspended
5 + ON users(suspended_at) WHERE suspended_at IS NOT NULL;
6 +
7 + -- Per-user rate limiting (called on every post/footnote creation)
8 + CREATE INDEX IF NOT EXISTS idx_posts_author_created
9 + ON posts(author_id, created_at);
10 +
11 + CREATE INDEX IF NOT EXISTS idx_post_footnotes_author_created
12 + ON post_footnotes(author_id, created_at);
13 +
14 + -- Image upload rate limiting
15 + CREATE INDEX IF NOT EXISTS idx_images_uploader_created
16 + ON images(uploader_id, created_at);
17 +
18 + -- Ban/mute type filtering (existing index lacks ban_type column)
19 + CREATE INDEX IF NOT EXISTS idx_community_bans_type_lookup
20 + ON community_bans(community_id, user_id, ban_type);
@@ -64,11 +64,15 @@ impl SessionUser {
64 64 }
65 65
66 66 async fn save_to_session(&self, session: &Session) {
67 - let _ = session.insert(SESSION_USER_ID, self.user_id).await;
68 - let _ = session.insert(SESSION_USERNAME, &self.username).await;
69 - let _ = session
70 - .insert(SESSION_DISPLAY_NAME, &self.display_name)
71 - .await;
67 + if let Err(e) = session.insert(SESSION_USER_ID, self.user_id).await {
68 + tracing::error!(error = %e, "failed to save user_id to session");
69 + }
70 + if let Err(e) = session.insert(SESSION_USERNAME, &self.username).await {
71 + tracing::error!(error = %e, "failed to save username to session");
72 + }
73 + if let Err(e) = session.insert(SESSION_DISPLAY_NAME, &self.display_name).await {
74 + tracing::error!(error = %e, "failed to save display_name to session");
75 + }
72 76 }
73 77 }
74 78
@@ -149,8 +153,12 @@ pub async fn login(
149 153 let challenge = pkce_challenge(&verifier);
150 154 let oauth_state = generate_state_nonce();
151 155
152 - let _ = session.insert(SESSION_PKCE_VERIFIER, &verifier).await;
153 - let _ = session.insert(SESSION_OAUTH_STATE, &oauth_state).await;
156 + if let Err(e) = session.insert(SESSION_PKCE_VERIFIER, &verifier).await {
157 + tracing::error!(error = %e, "failed to save PKCE verifier to session");
158 + }
159 + if let Err(e) = session.insert(SESSION_OAUTH_STATE, &oauth_state).await {
160 + tracing::error!(error = %e, "failed to save OAuth state to session");
161 + }
154 162
155 163 let url = format!(
156 164 "{}/oauth/authorize?response_type=code&client_id={}&redirect_uri={}&state={}&code_challenge={}&code_challenge_method=S256",
@@ -189,8 +197,12 @@ pub async fn callback(
189 197 };
190 198
191 199 // Clean up OAuth session data
192 - let _ = session.remove::<String>(SESSION_OAUTH_STATE).await;
193 - let _ = session.remove::<String>(SESSION_PKCE_VERIFIER).await;
200 + if let Err(e) = session.remove::<String>(SESSION_OAUTH_STATE).await {
201 + tracing::warn!(error = %e, "failed to remove OAuth state from session");
202 + }
203 + if let Err(e) = session.remove::<String>(SESSION_PKCE_VERIFIER).await {
204 + tracing::warn!(error = %e, "failed to remove PKCE verifier from session");
205 + }
194 206
195 207 // Exchange code for token (retry up to 2 attempts on network/5xx errors)
196 208 let token_url = format!("{}/oauth/token", state.config.mnw_base_url);
@@ -374,6 +386,8 @@ pub async fn callback(
374 386 /// `POST /auth/logout` — flush session, redirect home.
375 387 #[tracing::instrument(skip_all)]
376 388 pub async fn logout(session: Session) -> impl IntoResponse {
377 - let _ = session.flush().await;
389 + if let Err(e) = session.flush().await {
390 + tracing::warn!(error = %e, "failed to flush session on logout");
391 + }
378 392 Redirect::to("/")
379 393 }
@@ -128,7 +128,7 @@ pub(in crate::routes) async fn community_members(
128 128
129 129 check_community_access(&state.db, &community, session_user.as_ref().map(|u| u.user_id)).await?;
130 130
131 - let db_members = mt_db::queries::list_community_members(&state.db, community.id)
131 + let db_members = mt_db::queries::list_community_members(&state.db, community.id, 500, 0)
132 132 .await
133 133 .map_err(|e| {
134 134 tracing::error!(error = ?e, "db error listing members");
@@ -140,7 +140,7 @@ pub(in crate::routes) async fn community_members(
140 140 .map(|m| MemberListRow {
141 141 display_name: m.display_name.unwrap_or_else(|| m.username.clone()),
142 142 username: m.username,
143 - role: m.role,
143 + role: m.role.to_string(),
144 144 joined: mt_core::time_format::relative_timestamp(m.joined_at),
145 145 })
146 146 .collect();
@@ -389,7 +389,7 @@ pub(in crate::routes) async fn user_profile(
389 389 display_name: profile.display_name.unwrap_or_else(|| profile.username.clone()),
390 390 username: profile.username,
391 391 avatar_url: profile.avatar_url,
392 - role: profile.role,
392 + role: profile.role.to_string(),
393 393 joined: mt_core::time_format::relative_timestamp(profile.joined_at),
394 394 post_count: profile.post_count,
395 395 endorsement_count: profile.endorsement_count,
@@ -89,13 +89,12 @@ pub(crate) async fn get_role(
89 89 user_id: Uuid,
90 90 community_id: Uuid,
91 91 ) -> Result<Option<CommunityRole>, Response> {
92 - let role_str = mt_db::queries::get_user_role(db, user_id, community_id)
92 + mt_db::queries::get_user_role(db, user_id, community_id)
93 93 .await
94 94 .map_err(|e| {
95 95 tracing::error!(error = ?e, "db error fetching role");
96 96 (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error").into_response()
97 - })?;
98 - Ok(role_str.and_then(|s| CommunityRole::from_db(&s)))
97 + })
99 98 }
100 99
101 100 /// Look up a user by username, returning 422 if not found.
@@ -176,7 +176,7 @@ pub(super) async fn moderation_page(
176 176 .map(|b| BanListRow {
177 177 username: b.username,
178 178 display_name: b.display_name,
179 - ban_type: b.ban_type,
179 + ban_type: b.ban_type.to_string(),
180 180 reason: b.reason,
181 181 expires: b.expires_at.map(mt_core::time_format::relative_timestamp),
182 182 created: mt_core::time_format::relative_timestamp(b.created_at),
@@ -427,7 +427,7 @@ pub(super) async fn mod_log_page(
427 427 .into_iter()
428 428 .map(|e| ModLogRow {
429 429 actor: e.actor_username,
430 - action: e.action,
430 + action: e.action.to_string(),
431 431 target: e.target_username,
432 432 reason: e.reason,
433 433 timestamp: mt_core::time_format::relative_timestamp(e.created_at),
@@ -89,7 +89,7 @@ pub(super) async fn upload_image_handler(
89 89 // Upload to S3
90 90 s3.upload(&s3_key, validated_ct, data).await.map_err(|e| {
91 91 tracing::error!(error = %e, "S3 upload failed");
92 - (StatusCode::INTERNAL_SERVER_ERROR, "Upload failed.").into_response()
92 + StatusCode::INTERNAL_SERVER_ERROR.into_response()
93 93 })?;
94 94
95 95 // Record in DB
@@ -250,6 +250,7 @@ impl TestHarness {
250 250 author_id,
251 251 body,
252 252 &format!("<p>{}</p>", body),
253 + false,
253 254 )
254 255 .await
255 256 .expect("Failed to create post");
@@ -169,6 +169,7 @@ async fn test_membership_summary_post_count() {
169 169 user_id,
170 170 "Second post",
171 171 "<p>Second post</p>",
172 + true,
172 173 )
173 174 .await
174 175 .unwrap();
@@ -179,6 +180,7 @@ async fn test_membership_summary_post_count() {
179 180 user_id,
180 181 "Third post",
181 182 "<p>Third post</p>",
183 + true,
182 184 )
183 185 .await
184 186 .unwrap();
@@ -190,7 +192,7 @@ async fn test_membership_summary_post_count() {
190 192 assert_eq!(summaries.len(), 1, "Should have one membership");
191 193 assert_eq!(summaries[0].community_name, "Writers Guild");
192 194 assert_eq!(summaries[0].community_slug, "writers");
193 - assert_eq!(summaries[0].role, "member");
195 + assert_eq!(summaries[0].role, mt_core::types::CommunityRole::Member);
194 196 assert_eq!(
195 197 summaries[0].post_count, 3,
196 198 "Should count all 3 posts (initial + 2 replies)"
@@ -400,7 +400,7 @@ async fn create_post_bumps_thread_activity() {
400 400 .unwrap();
401 401
402 402 // Create post should bump last_activity_at
403 - mt_db::mutations::create_post(&h.db, thread_id, author, "test", "<p>test</p>")
403 + mt_db::mutations::create_post(&h.db, thread_id, author, "test", "<p>test</p>", true)
404 404 .await
405 405 .unwrap();
406 406
@@ -439,7 +439,7 @@ async fn toggle_endorsement_db_roundtrip() {
439 439 .await
440 440 .unwrap();
441 441 let post_id = mt_db::mutations::create_post(
442 - &h.db, thread_id, author, "content", "<p>content</p>",
442 + &h.db, thread_id, author, "content", "<p>content</p>", true,
443 443 )
444 444 .await
445 445 .unwrap();
@@ -488,7 +488,7 @@ async fn insert_flag_idempotent_per_user() {
488 488 .await
489 489 .unwrap();
490 490 let post_id = mt_db::mutations::create_post(
491 - &h.db, thread_id, author, "content", "<p>content</p>",
491 + &h.db, thread_id, author, "content", "<p>content</p>", true,
492 492 )
493 493 .await
494 494 .unwrap();
@@ -631,7 +631,7 @@ async fn insert_link_preview_dedup() {
631 631 .await
632 632 .unwrap();
633 633 let post_id = mt_db::mutations::create_post(
634 - &h.db, thread_id, author, "content", "<p>content</p>",
634 + &h.db, thread_id, author, "content", "<p>content</p>", true,
635 635 )
636 636 .await
637 637 .unwrap();
@@ -684,7 +684,7 @@ async fn insert_mentions_dedup() {
684 684 .await
685 685 .unwrap();
686 686 let post_id = mt_db::mutations::create_post(
687 - &h.db, thread_id, author, "content", "<p>content</p>",
687 + &h.db, thread_id, author, "content", "<p>content</p>", true,
688 688 )
689 689 .await
690 690 .unwrap();
@@ -122,6 +122,7 @@ async fn sort_by_replies_works() {
122 122 user_id,
123 123 &format!("reply {i}"),
124 124 &format!("<p>reply {i}</p>"),
125 + true,
125 126 )
126 127 .await
127 128 .unwrap();
@@ -38,6 +38,7 @@ async fn profile_page_shows_activity() {
38 38 user_id,
39 39 "A reply",
40 40 "<p>A reply</p>",
41 + true,
41 42 )
42 43 .await
43 44 .unwrap();
@@ -110,7 +110,7 @@ async fn unread_count_tracking() {
110 110 .execute(&h.db)
111 111 .await
112 112 .unwrap();
113 - mt_db::mutations::create_post(&h.db, thread_id, other_id, "New reply", "<p>New reply</p>")
113 + mt_db::mutations::create_post(&h.db, thread_id, other_id, "New reply", "<p>New reply</p>", true)
114 114 .await
115 115 .unwrap();
116 116