From b8502ee84473785ce0e9df7c334fc6e94fd48623 Mon Sep 17 00:00:00 2001 From: Guido X Jansen Date: Thu, 5 Mar 2026 11:29:43 +0100 Subject: [PATCH] feat(topics): AT Protocol-style lookup endpoints (#139) Add author-scoped endpoints for topics and replies to support AT Protocol-style URLs (/{handle}/{rkey}) and fix the latent rkey collision bug in the existing by-rkey endpoint. - Add GET /api/topics/by-author-rkey/:handle/:rkey - Add GET /api/replies/by-author-rkey/:handle/:rkey - Add shared resolveHandleToDid utility (local DB + Bluesky fallback) - Add authorHandle to POST /api/topics 201 response - Enrich GET /api/notifications with actorHandle, subjectTitle, subjectAuthorDid, subjectAuthorHandle, and message fields - Update cross-post URL builder to AT Protocol-style format - Add composite indexes on (author_did, rkey) for topics and replies - Keep existing by-rkey endpoint for backwards compatibility --- drizzle/0008_add_author_rkey_indexes.sql | 2 + drizzle/meta/0008_snapshot.json | 4113 ++++++++++++++++++++++ drizzle/meta/_journal.json | 7 + src/db/schema/replies.ts | 1 + src/db/schema/topics.ts | 1 + src/lib/resolve-handle-to-did.ts | 41 + src/routes/notifications.ts | 149 +- src/routes/replies.ts | 57 +- src/routes/topics.ts | 79 + src/services/cross-post.ts | 11 +- tests/unit/routes/notifications.test.ts | 25 + tests/unit/routes/replies.test.ts | 67 + tests/unit/routes/topics.test.ts | 94 +- tests/unit/services/cross-post.test.ts | 19 +- 14 files changed, 4652 insertions(+), 14 deletions(-) create mode 100644 drizzle/0008_add_author_rkey_indexes.sql create mode 100644 drizzle/meta/0008_snapshot.json create mode 100644 src/lib/resolve-handle-to-did.ts diff --git a/drizzle/0008_add_author_rkey_indexes.sql b/drizzle/0008_add_author_rkey_indexes.sql new file mode 100644 index 0000000..891ffec --- /dev/null +++ b/drizzle/0008_add_author_rkey_indexes.sql @@ -0,0 +1,2 @@ +CREATE INDEX "topics_author_did_rkey_idx" ON "topics" USING btree ("author_did","rkey");--> statement-breakpoint +CREATE INDEX "replies_author_did_rkey_idx" ON "replies" USING btree ("author_did","rkey"); \ No newline at end of file diff --git a/drizzle/meta/0008_snapshot.json b/drizzle/meta/0008_snapshot.json new file mode 100644 index 0000000..33211d9 --- /dev/null +++ b/drizzle/meta/0008_snapshot.json @@ -0,0 +1,4113 @@ +{ + "id": "d168c26e-76b0-40aa-b1b3-81eeba8a99c8", + "prevId": "30af658d-1dce-41a5-8c85-ed9d81fa33dd", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.users": { + "name": "users", + "schema": "", + "columns": { + "did": { + "name": "did", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banner_url": { + "name": "banner_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "is_banned": { + "name": "is_banned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "reputation_score": { + "name": "reputation_score", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_active_at": { + "name": "last_active_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "declared_age": { + "name": "declared_age", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "maturity_pref": { + "name": "maturity_pref", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'safe'" + }, + "account_created_at": { + "name": "account_created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "followers_count": { + "name": "followers_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "follows_count": { + "name": "follows_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "atproto_posts_count": { + "name": "atproto_posts_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "has_bluesky_profile": { + "name": "has_bluesky_profile", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "atproto_labels": { + "name": "atproto_labels", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "users_role_elevated_idx": { + "name": "users_role_elevated_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "role IN ('moderator', 'admin')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_handle_idx": { + "name": "users_handle_idx", + "columns": [ + { + "expression": "handle", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_account_created_at_idx": { + "name": "users_account_created_at_idx", + "columns": [ + { + "expression": "account_created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.firehose_cursor": { + "name": "firehose_cursor", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "default": "'default'" + }, + "cursor": { + "name": "cursor", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.topics": { + "name": "topics", + "schema": "", + "columns": { + "uri": { + "name": "uri", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "rkey": { + "name": "rkey", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_did": { + "name": "author_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_format": { + "name": "content_format", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "community_did": { + "name": "community_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cid": { + "name": "cid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "labels": { + "name": "labels", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "reply_count": { + "name": "reply_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reaction_count": { + "name": "reaction_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "vote_count": { + "name": "vote_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "indexed_at": { + "name": "indexed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_locked": { + "name": "is_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_pinned": { + "name": "is_pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_mod_deleted": { + "name": "is_mod_deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_author_deleted": { + "name": "is_author_deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "moderation_status": { + "name": "moderation_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'approved'" + }, + "trust_status": { + "name": "trust_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'trusted'" + } + }, + "indexes": { + "topics_author_did_idx": { + "name": "topics_author_did_idx", + "columns": [ + { + "expression": "author_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "topics_category_idx": { + "name": "topics_category_idx", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "topics_created_at_idx": { + "name": "topics_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "topics_last_activity_at_idx": { + "name": "topics_last_activity_at_idx", + "columns": [ + { + "expression": "last_activity_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "topics_community_did_idx": { + "name": "topics_community_did_idx", + "columns": [ + { + "expression": "community_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "topics_moderation_status_idx": { + "name": "topics_moderation_status_idx", + "columns": [ + { + "expression": "moderation_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "topics_trust_status_idx": { + "name": "topics_trust_status_idx", + "columns": [ + { + "expression": "trust_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "topics_community_category_activity_idx": { + "name": "topics_community_category_activity_idx", + "columns": [ + { + "expression": "community_did", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_activity_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "topics_author_did_rkey_idx": { + "name": "topics_author_did_rkey_idx", + "columns": [ + { + "expression": "author_did", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "rkey", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "tenant_isolation": { + "name": "tenant_isolation", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "barazo_app" + ], + "using": "community_did = current_setting('app.current_community_did', true)", + "withCheck": "community_did = current_setting('app.current_community_did', true)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.replies": { + "name": "replies", + "schema": "", + "columns": { + "uri": { + "name": "uri", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "rkey": { + "name": "rkey", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_did": { + "name": "author_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_format": { + "name": "content_format", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "root_uri": { + "name": "root_uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "root_cid": { + "name": "root_cid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_uri": { + "name": "parent_uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_cid": { + "name": "parent_cid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "community_did": { + "name": "community_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cid": { + "name": "cid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "labels": { + "name": "labels", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "reaction_count": { + "name": "reaction_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "vote_count": { + "name": "vote_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "depth": { + "name": "depth", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "indexed_at": { + "name": "indexed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_author_deleted": { + "name": "is_author_deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_mod_deleted": { + "name": "is_mod_deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "moderation_status": { + "name": "moderation_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'approved'" + }, + "trust_status": { + "name": "trust_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'trusted'" + } + }, + "indexes": { + "replies_author_did_idx": { + "name": "replies_author_did_idx", + "columns": [ + { + "expression": "author_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "replies_root_uri_idx": { + "name": "replies_root_uri_idx", + "columns": [ + { + "expression": "root_uri", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "replies_parent_uri_idx": { + "name": "replies_parent_uri_idx", + "columns": [ + { + "expression": "parent_uri", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "replies_created_at_idx": { + "name": "replies_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "replies_community_did_idx": { + "name": "replies_community_did_idx", + "columns": [ + { + "expression": "community_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "replies_moderation_status_idx": { + "name": "replies_moderation_status_idx", + "columns": [ + { + "expression": "moderation_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "replies_trust_status_idx": { + "name": "replies_trust_status_idx", + "columns": [ + { + "expression": "trust_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "replies_root_uri_created_at_idx": { + "name": "replies_root_uri_created_at_idx", + "columns": [ + { + "expression": "root_uri", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "replies_root_uri_depth_idx": { + "name": "replies_root_uri_depth_idx", + "columns": [ + { + "expression": "root_uri", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "depth", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "replies_author_did_rkey_idx": { + "name": "replies_author_did_rkey_idx", + "columns": [ + { + "expression": "author_did", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "rkey", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "tenant_isolation": { + "name": "tenant_isolation", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "barazo_app" + ], + "using": "community_did = current_setting('app.current_community_did', true)", + "withCheck": "community_did = current_setting('app.current_community_did', true)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.reactions": { + "name": "reactions", + "schema": "", + "columns": { + "uri": { + "name": "uri", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "rkey": { + "name": "rkey", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_did": { + "name": "author_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_uri": { + "name": "subject_uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_cid": { + "name": "subject_cid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "community_did": { + "name": "community_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cid": { + "name": "cid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "indexed_at": { + "name": "indexed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "reactions_author_did_idx": { + "name": "reactions_author_did_idx", + "columns": [ + { + "expression": "author_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reactions_subject_uri_idx": { + "name": "reactions_subject_uri_idx", + "columns": [ + { + "expression": "subject_uri", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reactions_community_did_idx": { + "name": "reactions_community_did_idx", + "columns": [ + { + "expression": "community_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reactions_subject_uri_type_idx": { + "name": "reactions_subject_uri_type_idx", + "columns": [ + { + "expression": "subject_uri", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "reactions_author_subject_type_uniq": { + "name": "reactions_author_subject_type_uniq", + "nullsNotDistinct": false, + "columns": [ + "author_did", + "subject_uri", + "type" + ] + } + }, + "policies": { + "tenant_isolation": { + "name": "tenant_isolation", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "barazo_app" + ], + "using": "community_did = current_setting('app.current_community_did', true)", + "withCheck": "community_did = current_setting('app.current_community_did', true)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.votes": { + "name": "votes", + "schema": "", + "columns": { + "uri": { + "name": "uri", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "rkey": { + "name": "rkey", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_did": { + "name": "author_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_uri": { + "name": "subject_uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_cid": { + "name": "subject_cid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "community_did": { + "name": "community_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cid": { + "name": "cid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "indexed_at": { + "name": "indexed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "votes_author_did_idx": { + "name": "votes_author_did_idx", + "columns": [ + { + "expression": "author_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "votes_subject_uri_idx": { + "name": "votes_subject_uri_idx", + "columns": [ + { + "expression": "subject_uri", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "votes_community_did_idx": { + "name": "votes_community_did_idx", + "columns": [ + { + "expression": "community_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "votes_author_subject_uniq": { + "name": "votes_author_subject_uniq", + "nullsNotDistinct": false, + "columns": [ + "author_did", + "subject_uri" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tracked_repos": { + "name": "tracked_repos", + "schema": "", + "columns": { + "did": { + "name": "did", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tracked_at": { + "name": "tracked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.community_settings": { + "name": "community_settings", + "schema": "", + "columns": { + "community_did": { + "name": "community_did", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "domains": { + "name": "domains", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "initialized": { + "name": "initialized", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "admin_did": { + "name": "admin_did", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "community_name": { + "name": "community_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Barazo Community'" + }, + "maturity_rating": { + "name": "maturity_rating", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'safe'" + }, + "reaction_set": { + "name": "reaction_set", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[\"like\"]'::jsonb" + }, + "moderation_thresholds": { + "name": "moderation_thresholds", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"autoBlockReportCount\":5,\"warnThreshold\":3,\"firstPostQueueCount\":0,\"newAccountDays\":7,\"newAccountWriteRatePerMin\":3,\"establishedWriteRatePerMin\":10,\"linkHoldEnabled\":false,\"topicCreationDelayEnabled\":false,\"burstPostCount\":5,\"burstWindowMinutes\":10,\"trustedPostThreshold\":10}'::jsonb" + }, + "word_filter": { + "name": "word_filter", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "jurisdiction_country": { + "name": "jurisdiction_country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "age_threshold": { + "name": "age_threshold", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 16 + }, + "max_reply_depth": { + "name": "max_reply_depth", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 9999 + }, + "require_login_for_mature": { + "name": "require_login_for_mature", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "community_description": { + "name": "community_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "service_endpoint": { + "name": "service_endpoint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "signing_key": { + "name": "signing_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rotation_key": { + "name": "rotation_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "community_logo_url": { + "name": "community_logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "header_logo_url": { + "name": "header_logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "show_community_name": { + "name": "show_community_name", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "primary_color": { + "name": "primary_color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "accent_color": { + "name": "accent_color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "tenant_isolation": { + "name": "tenant_isolation", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "barazo_app" + ], + "using": "community_did = current_setting('app.current_community_did', true)", + "withCheck": "community_did = current_setting('app.current_community_did', true)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.categories": { + "name": "categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "community_did": { + "name": "community_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "maturity_rating": { + "name": "maturity_rating", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'safe'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "categories_slug_community_did_idx": { + "name": "categories_slug_community_did_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "community_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "categories_parent_id_idx": { + "name": "categories_parent_id_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "categories_community_did_idx": { + "name": "categories_community_did_idx", + "columns": [ + { + "expression": "community_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "categories_maturity_rating_idx": { + "name": "categories_maturity_rating_idx", + "columns": [ + { + "expression": "maturity_rating", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "categories_parent_id_fk": { + "name": "categories_parent_id_fk", + "tableFrom": "categories", + "tableTo": "categories", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "tenant_isolation": { + "name": "tenant_isolation", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "barazo_app" + ], + "using": "community_did = current_setting('app.current_community_did', true)", + "withCheck": "community_did = current_setting('app.current_community_did', true)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.moderation_actions": { + "name": "moderation_actions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_uri": { + "name": "target_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_did": { + "name": "target_did", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "moderator_did": { + "name": "moderator_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "community_did": { + "name": "community_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mod_actions_moderator_did_idx": { + "name": "mod_actions_moderator_did_idx", + "columns": [ + { + "expression": "moderator_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mod_actions_community_did_idx": { + "name": "mod_actions_community_did_idx", + "columns": [ + { + "expression": "community_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mod_actions_created_at_idx": { + "name": "mod_actions_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mod_actions_target_uri_idx": { + "name": "mod_actions_target_uri_idx", + "columns": [ + { + "expression": "target_uri", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mod_actions_target_did_idx": { + "name": "mod_actions_target_did_idx", + "columns": [ + { + "expression": "target_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "tenant_isolation": { + "name": "tenant_isolation", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "barazo_app" + ], + "using": "community_did = current_setting('app.current_community_did', true)", + "withCheck": "community_did = current_setting('app.current_community_did', true)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.reports": { + "name": "reports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "reporter_did": { + "name": "reporter_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_uri": { + "name": "target_uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_did": { + "name": "target_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason_type": { + "name": "reason_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "community_did": { + "name": "community_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "resolution_type": { + "name": "resolution_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resolved_by": { + "name": "resolved_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "appeal_reason": { + "name": "appeal_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "appealed_at": { + "name": "appealed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "appeal_status": { + "name": "appeal_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "reports_reporter_did_idx": { + "name": "reports_reporter_did_idx", + "columns": [ + { + "expression": "reporter_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reports_target_uri_idx": { + "name": "reports_target_uri_idx", + "columns": [ + { + "expression": "target_uri", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reports_target_did_idx": { + "name": "reports_target_did_idx", + "columns": [ + { + "expression": "target_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reports_community_did_idx": { + "name": "reports_community_did_idx", + "columns": [ + { + "expression": "community_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reports_status_idx": { + "name": "reports_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reports_created_at_idx": { + "name": "reports_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reports_unique_reporter_target_idx": { + "name": "reports_unique_reporter_target_idx", + "columns": [ + { + "expression": "reporter_did", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_uri", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "community_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "tenant_isolation": { + "name": "tenant_isolation", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "barazo_app" + ], + "using": "community_did = current_setting('app.current_community_did', true)", + "withCheck": "community_did = current_setting('app.current_community_did', true)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.notifications": { + "name": "notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "recipient_did": { + "name": "recipient_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_uri": { + "name": "subject_uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_did": { + "name": "actor_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "community_did": { + "name": "community_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "read": { + "name": "read", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notifications_recipient_did_idx": { + "name": "notifications_recipient_did_idx", + "columns": [ + { + "expression": "recipient_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_recipient_read_idx": { + "name": "notifications_recipient_read_idx", + "columns": [ + { + "expression": "recipient_did", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "read", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_created_at_idx": { + "name": "notifications_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "tenant_isolation": { + "name": "tenant_isolation", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "barazo_app" + ], + "using": "community_did = current_setting('app.current_community_did', true)", + "withCheck": "community_did = current_setting('app.current_community_did', true)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.user_community_preferences": { + "name": "user_community_preferences", + "schema": "", + "columns": { + "did": { + "name": "did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "community_did": { + "name": "community_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "maturity_override": { + "name": "maturity_override", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "muted_words": { + "name": "muted_words", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "blocked_dids": { + "name": "blocked_dids", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "muted_dids": { + "name": "muted_dids", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "notification_prefs": { + "name": "notification_prefs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_community_prefs_did_idx": { + "name": "user_community_prefs_did_idx", + "columns": [ + { + "expression": "did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_community_prefs_community_idx": { + "name": "user_community_prefs_community_idx", + "columns": [ + { + "expression": "community_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "user_community_preferences_did_community_did_pk": { + "name": "user_community_preferences_did_community_did_pk", + "columns": [ + "did", + "community_did" + ] + } + }, + "uniqueConstraints": {}, + "policies": { + "tenant_isolation": { + "name": "tenant_isolation", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "barazo_app" + ], + "using": "community_did = current_setting('app.current_community_did', true)", + "withCheck": "community_did = current_setting('app.current_community_did', true)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.user_preferences": { + "name": "user_preferences", + "schema": "", + "columns": { + "did": { + "name": "did", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "maturity_level": { + "name": "maturity_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'sfw'" + }, + "declared_age": { + "name": "declared_age", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "muted_words": { + "name": "muted_words", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "blocked_dids": { + "name": "blocked_dids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "muted_dids": { + "name": "muted_dids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "cross_post_bluesky": { + "name": "cross_post_bluesky", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cross_post_frontpage": { + "name": "cross_post_frontpage", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cross_post_scopes_granted": { + "name": "cross_post_scopes_granted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cross_posts": { + "name": "cross_posts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "topic_uri": { + "name": "topic_uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "service": { + "name": "service", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cross_post_uri": { + "name": "cross_post_uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cross_post_cid": { + "name": "cross_post_cid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_did": { + "name": "author_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "cross_posts_topic_uri_idx": { + "name": "cross_posts_topic_uri_idx", + "columns": [ + { + "expression": "topic_uri", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cross_posts_author_did_idx": { + "name": "cross_posts_author_did_idx", + "columns": [ + { + "expression": "author_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.community_onboarding_fields": { + "name": "community_onboarding_fields", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "community_did": { + "name": "community_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_mandatory": { + "name": "is_mandatory", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'admin'" + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "onboarding_fields_community_idx": { + "name": "onboarding_fields_community_idx", + "columns": [ + { + "expression": "community_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "tenant_isolation": { + "name": "tenant_isolation", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "barazo_app" + ], + "using": "community_did = current_setting('app.current_community_did', true)", + "withCheck": "community_did = current_setting('app.current_community_did', true)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.user_onboarding_responses": { + "name": "user_onboarding_responses", + "schema": "", + "columns": { + "did": { + "name": "did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "community_did": { + "name": "community_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field_id": { + "name": "field_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "response": { + "name": "response", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "onboarding_responses_did_community_idx": { + "name": "onboarding_responses_did_community_idx", + "columns": [ + { + "expression": "did", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "community_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "user_onboarding_responses_did_community_did_field_id_pk": { + "name": "user_onboarding_responses_did_community_did_field_id_pk", + "columns": [ + "did", + "community_did", + "field_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": { + "tenant_isolation": { + "name": "tenant_isolation", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "barazo_app" + ], + "using": "community_did = current_setting('app.current_community_did', true)", + "withCheck": "community_did = current_setting('app.current_community_did', true)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.moderation_queue": { + "name": "moderation_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "content_uri": { + "name": "content_uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_did": { + "name": "author_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "community_did": { + "name": "community_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "queue_reason": { + "name": "queue_reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "matched_words": { + "name": "matched_words", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "reviewed_by": { + "name": "reviewed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "mod_queue_author_did_idx": { + "name": "mod_queue_author_did_idx", + "columns": [ + { + "expression": "author_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mod_queue_community_did_idx": { + "name": "mod_queue_community_did_idx", + "columns": [ + { + "expression": "community_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mod_queue_status_idx": { + "name": "mod_queue_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mod_queue_created_at_idx": { + "name": "mod_queue_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mod_queue_content_uri_idx": { + "name": "mod_queue_content_uri_idx", + "columns": [ + { + "expression": "content_uri", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "tenant_isolation": { + "name": "tenant_isolation", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "barazo_app" + ], + "using": "community_did = current_setting('app.current_community_did', true)", + "withCheck": "community_did = current_setting('app.current_community_did', true)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.account_trust": { + "name": "account_trust", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "did": { + "name": "did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "community_did": { + "name": "community_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "approved_post_count": { + "name": "approved_post_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_trusted": { + "name": "is_trusted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trusted_at": { + "name": "trusted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "account_trust_did_community_idx": { + "name": "account_trust_did_community_idx", + "columns": [ + { + "expression": "did", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "community_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_trust_did_idx": { + "name": "account_trust_did_idx", + "columns": [ + { + "expression": "did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "tenant_isolation": { + "name": "tenant_isolation", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "barazo_app" + ], + "using": "community_did = current_setting('app.current_community_did', true)", + "withCheck": "community_did = current_setting('app.current_community_did', true)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.community_filters": { + "name": "community_filters", + "schema": "", + "columns": { + "community_did": { + "name": "community_did", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "admin_did": { + "name": "admin_did", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "report_count": { + "name": "report_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_reviewed_at": { + "name": "last_reviewed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "filtered_by": { + "name": "filtered_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "community_filters_status_idx": { + "name": "community_filters_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "community_filters_admin_did_idx": { + "name": "community_filters_admin_did_idx", + "columns": [ + { + "expression": "admin_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "community_filters_updated_at_idx": { + "name": "community_filters_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "tenant_isolation": { + "name": "tenant_isolation", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "barazo_app" + ], + "using": "community_did = current_setting('app.current_community_did', true)", + "withCheck": "community_did = current_setting('app.current_community_did', true)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.account_filters": { + "name": "account_filters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "did": { + "name": "did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "community_did": { + "name": "community_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "report_count": { + "name": "report_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "ban_count": { + "name": "ban_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_reviewed_at": { + "name": "last_reviewed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "filtered_by": { + "name": "filtered_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "account_filters_did_community_idx": { + "name": "account_filters_did_community_idx", + "columns": [ + { + "expression": "did", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "community_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_filters_did_idx": { + "name": "account_filters_did_idx", + "columns": [ + { + "expression": "did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_filters_community_did_idx": { + "name": "account_filters_community_did_idx", + "columns": [ + { + "expression": "community_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_filters_status_idx": { + "name": "account_filters_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_filters_updated_at_idx": { + "name": "account_filters_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "tenant_isolation": { + "name": "tenant_isolation", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "barazo_app" + ], + "using": "community_did = current_setting('app.current_community_did', true)", + "withCheck": "community_did = current_setting('app.current_community_did', true)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.ozone_labels": { + "name": "ozone_labels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "src": { + "name": "src", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "val": { + "name": "val", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "neg": { + "name": "neg", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cts": { + "name": "cts", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "exp": { + "name": "exp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "indexed_at": { + "name": "indexed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ozone_labels_src_uri_val_idx": { + "name": "ozone_labels_src_uri_val_idx", + "columns": [ + { + "expression": "src", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "uri", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "val", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ozone_labels_uri_idx": { + "name": "ozone_labels_uri_idx", + "columns": [ + { + "expression": "uri", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ozone_labels_val_idx": { + "name": "ozone_labels_val_idx", + "columns": [ + { + "expression": "val", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ozone_labels_indexed_at_idx": { + "name": "ozone_labels_indexed_at_idx", + "columns": [ + { + "expression": "indexed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.community_profiles": { + "name": "community_profiles", + "schema": "", + "columns": { + "did": { + "name": "did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "community_did": { + "name": "community_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banner_url": { + "name": "banner_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "community_profiles_did_idx": { + "name": "community_profiles_did_idx", + "columns": [ + { + "expression": "did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "community_profiles_community_idx": { + "name": "community_profiles_community_idx", + "columns": [ + { + "expression": "community_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "community_profiles_did_community_did_pk": { + "name": "community_profiles_did_community_did_pk", + "columns": [ + "did", + "community_did" + ] + } + }, + "uniqueConstraints": {}, + "policies": { + "tenant_isolation": { + "name": "tenant_isolation", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "barazo_app" + ], + "using": "community_did = current_setting('app.current_community_did', true)", + "withCheck": "community_did = current_setting('app.current_community_did', true)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.interaction_graph": { + "name": "interaction_graph", + "schema": "", + "columns": { + "source_did": { + "name": "source_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_did": { + "name": "target_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "community_id": { + "name": "community_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "interaction_type": { + "name": "interaction_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "weight": { + "name": "weight", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "first_interaction_at": { + "name": "first_interaction_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_interaction_at": { + "name": "last_interaction_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "interaction_graph_source_target_community_idx": { + "name": "interaction_graph_source_target_community_idx", + "columns": [ + { + "expression": "source_did", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_did", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "community_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "interaction_graph_source_did_target_did_community_id_interaction_type_pk": { + "name": "interaction_graph_source_did_target_did_community_id_interaction_type_pk", + "columns": [ + "source_did", + "target_did", + "community_id", + "interaction_type" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.trust_seeds": { + "name": "trust_seeds", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "did": { + "name": "did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "community_id": { + "name": "community_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "trust_seeds_did_community_idx": { + "name": "trust_seeds_did_community_idx", + "columns": [ + { + "expression": "did", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "community_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.trust_scores": { + "name": "trust_scores", + "schema": "", + "columns": { + "did": { + "name": "did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "community_id": { + "name": "community_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "score": { + "name": "score", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "computed_at": { + "name": "computed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "trust_scores_did_community_idx": { + "name": "trust_scores_did_community_idx", + "columns": [ + { + "expression": "did", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "community_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "trust_scores_did_community_id_pk": { + "name": "trust_scores_did_community_id_pk", + "columns": [ + "did", + "community_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sybil_clusters": { + "name": "sybil_clusters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "cluster_hash": { + "name": "cluster_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_edge_count": { + "name": "internal_edge_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "external_edge_count": { + "name": "external_edge_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "member_count": { + "name": "member_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'flagged'" + }, + "reviewed_by": { + "name": "reviewed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "detected_at": { + "name": "detected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sybil_clusters_hash_idx": { + "name": "sybil_clusters_hash_idx", + "columns": [ + { + "expression": "cluster_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sybil_cluster_members": { + "name": "sybil_cluster_members", + "schema": "", + "columns": { + "cluster_id": { + "name": "cluster_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "did": { + "name": "did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_in_cluster": { + "name": "role_in_cluster", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sybil_cluster_members_cluster_id_sybil_clusters_id_fk": { + "name": "sybil_cluster_members_cluster_id_sybil_clusters_id_fk", + "tableFrom": "sybil_cluster_members", + "tableTo": "sybil_clusters", + "columnsFrom": [ + "cluster_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sybil_cluster_members_cluster_id_did_pk": { + "name": "sybil_cluster_members_cluster_id_did_pk", + "columns": [ + "cluster_id", + "did" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.behavioral_flags": { + "name": "behavioral_flags", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "flag_type": { + "name": "flag_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "affected_dids": { + "name": "affected_dids", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "details": { + "name": "details", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "community_did": { + "name": "community_did", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "detected_at": { + "name": "detected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "behavioral_flags_flag_type_idx": { + "name": "behavioral_flags_flag_type_idx", + "columns": [ + { + "expression": "flag_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "behavioral_flags_status_idx": { + "name": "behavioral_flags_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "behavioral_flags_detected_at_idx": { + "name": "behavioral_flags_detected_at_idx", + "columns": [ + { + "expression": "detected_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pds_trust_factors": { + "name": "pds_trust_factors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pds_host": { + "name": "pds_host", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "trust_factor": { + "name": "trust_factor", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pds_trust_factors_pds_host_idx": { + "name": "pds_trust_factors_pds_host_idx", + "columns": [ + { + "expression": "pds_host", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pages": { + "name": "pages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "meta_description": { + "name": "meta_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "community_did": { + "name": "community_did", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pages_slug_community_did_idx": { + "name": "pages_slug_community_did_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "community_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pages_community_did_idx": { + "name": "pages_community_did_idx", + "columns": [ + { + "expression": "community_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pages_parent_id_idx": { + "name": "pages_parent_id_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pages_status_community_did_idx": { + "name": "pages_status_community_did_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "community_did", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pages_parent_id_fk": { + "name": "pages_parent_id_fk", + "tableFrom": "pages", + "tableTo": "pages", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "tenant_isolation": { + "name": "tenant_isolation", + "as": "PERMISSIVE", + "for": "ALL", + "to": [ + "barazo_app" + ], + "using": "community_did = current_setting('app.current_community_did', true)", + "withCheck": "community_did = current_setting('app.current_community_did', true)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": { + "barazo_app": { + "name": "barazo_app", + "createDb": false, + "createRole": false, + "inherit": true + } + }, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 0421768..435491d 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -57,6 +57,13 @@ "when": 1772636063890, "tag": "0007_ordinary_mulholland_black", "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1772706380033, + "tag": "0008_add_author_rkey_indexes", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/schema/replies.ts b/src/db/schema/replies.ts index 0ab55f2..02baddb 100644 --- a/src/db/schema/replies.ts +++ b/src/db/schema/replies.ts @@ -59,6 +59,7 @@ export const replies = pgTable( index('replies_trust_status_idx').on(table.trustStatus), index('replies_root_uri_created_at_idx').on(table.rootUri, table.createdAt), index('replies_root_uri_depth_idx').on(table.rootUri, table.depth), + index('replies_author_did_rkey_idx').on(table.authorDid, table.rkey), pgPolicy('tenant_isolation', { as: 'permissive', to: appRole, diff --git a/src/db/schema/topics.ts b/src/db/schema/topics.ts index 5ceb80b..4b9b602 100644 --- a/src/db/schema/topics.ts +++ b/src/db/schema/topics.ts @@ -55,6 +55,7 @@ export const topics = pgTable( table.category, table.lastActivityAt ), + index('topics_author_did_rkey_idx').on(table.authorDid, table.rkey), pgPolicy('tenant_isolation', { as: 'permissive', to: appRole, diff --git a/src/lib/resolve-handle-to-did.ts b/src/lib/resolve-handle-to-did.ts new file mode 100644 index 0000000..d47a440 --- /dev/null +++ b/src/lib/resolve-handle-to-did.ts @@ -0,0 +1,41 @@ +import { eq } from 'drizzle-orm' +import type { Database } from '../db/index.js' +import type { Logger } from './logger.js' +import { users } from '../db/schema/users.js' + +/** + * Resolve an AT Protocol handle to a DID. + * + * 1. Check the local users table first (fast path). + * 2. Fall back to the public Bluesky AppView XRPC endpoint. + * 3. Return `null` if resolution fails. + */ +export async function resolveHandleToDid( + handle: string, + db: Database, + logger: Logger +): Promise { + const localRows = await db + .select({ did: users.did }) + .from(users) + .where(eq(users.handle, handle)) + + if (localRows[0]) { + return localRows[0].did + } + + try { + const url = `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}` + const res = await fetch(url, { signal: AbortSignal.timeout(5000) }) + if (res.ok) { + const data = (await res.json()) as { did?: string } + if (data.did) { + return data.did + } + } + } catch { + logger.warn({ handle }, 'Failed to resolve handle via Bluesky AppView') + } + + return null +} diff --git a/src/routes/notifications.ts b/src/routes/notifications.ts index ed06cad..c1e456d 100644 --- a/src/routes/notifications.ts +++ b/src/routes/notifications.ts @@ -1,8 +1,12 @@ -import { eq, and, sql, desc } from 'drizzle-orm' +import { eq, and, sql, desc, inArray } from 'drizzle-orm' import type { FastifyPluginCallback } from 'fastify' import { badRequest, errorResponseSchema } from '../lib/api-errors.js' import { notificationQuerySchema, markReadSchema } from '../validation/notifications.js' import { notifications } from '../db/schema/notifications.js' +import { users } from '../db/schema/users.js' +import { topics } from '../db/schema/topics.js' +import { replies } from '../db/schema/replies.js' +import { getCollectionFromUri } from '../lib/at-uri.js' // --------------------------------------------------------------------------- // OpenAPI JSON Schema definitions @@ -15,6 +19,11 @@ const notificationJsonSchema = { type: { type: 'string' as const }, subjectUri: { type: 'string' as const }, actorDid: { type: 'string' as const }, + actorHandle: { type: ['string', 'null'] as const }, + subjectTitle: { type: ['string', 'null'] as const }, + subjectAuthorDid: { type: ['string', 'null'] as const }, + subjectAuthorHandle: { type: ['string', 'null'] as const }, + message: { type: ['string', 'null'] as const }, communityDid: { type: 'string' as const }, read: { type: 'boolean' as const }, createdAt: { type: 'string' as const, format: 'date-time' as const }, @@ -41,6 +50,35 @@ function serializeNotification(row: typeof notifications.$inferSelect) { } } +/** + * Build a human-readable notification message. + */ +function buildNotificationMessage( + type: string, + actorHandle: string | null, + subjectTitle: string | null +): string | null { + const actor = actorHandle ?? 'Someone' + const subject = subjectTitle ? `"${subjectTitle}"` : 'your content' + + switch (type) { + case 'reply': + return `${actor} replied to ${subject}` + case 'reaction': + return `${actor} reacted to ${subject}` + case 'mention': + return `${actor} mentioned you in ${subject}` + case 'mod_action': + return `A moderator took action on ${subject}` + case 'cross_post_failed': + return `Cross-post failed for ${subject}` + case 'cross_post_revoked': + return `Cross-post authorization was revoked` + default: + return null + } +} + /** * Encode a pagination cursor from createdAt + id. */ @@ -165,6 +203,113 @@ export function notificationRoutes(): FastifyPluginCallback { const resultRows = hasMore ? rows.slice(0, limit) : rows const serialized = resultRows.map(serializeNotification) + // Batch-resolve actor handles + const actorDids = [...new Set(serialized.map((n) => n.actorDid))] + const actorHandleMap = new Map() + if (actorDids.length > 0) { + const actorRows = await db + .select({ did: users.did, handle: users.handle }) + .from(users) + .where(inArray(users.did, actorDids)) + for (const row of actorRows) { + actorHandleMap.set(row.did, row.handle) + } + } + + // Batch-resolve subject titles and authors from topics and replies + const subjectUris = [...new Set(serialized.map((n) => n.subjectUri))] + const topicUris = subjectUris.filter( + (uri) => getCollectionFromUri(uri) === 'forum.barazo.topic.post' + ) + const replyUris = subjectUris.filter( + (uri) => getCollectionFromUri(uri) === 'forum.barazo.topic.reply' + ) + + const subjectMap = new Map< + string, + { title: string | null; authorDid: string; authorHandle: string | null } + >() + + if (topicUris.length > 0) { + const topicRows = await db + .select({ + uri: topics.uri, + title: topics.title, + authorDid: topics.authorDid, + }) + .from(topics) + .where(inArray(topics.uri, topicUris)) + for (const row of topicRows) { + subjectMap.set(row.uri, { + title: row.title, + authorDid: row.authorDid, + authorHandle: null, + }) + } + } + + if (replyUris.length > 0) { + // For replies, look up the root topic title + const replyRows = await db + .select({ + uri: replies.uri, + authorDid: replies.authorDid, + rootUri: replies.rootUri, + }) + .from(replies) + .where(inArray(replies.uri, replyUris)) + + // Get root topic titles for replies + const rootUris = [...new Set(replyRows.map((r) => r.rootUri))] + const rootTitleMap = new Map() + if (rootUris.length > 0) { + const rootRows = await db + .select({ uri: topics.uri, title: topics.title }) + .from(topics) + .where(inArray(topics.uri, rootUris)) + for (const row of rootRows) { + rootTitleMap.set(row.uri, row.title) + } + } + + for (const row of replyRows) { + subjectMap.set(row.uri, { + title: rootTitleMap.get(row.rootUri) ?? null, + authorDid: row.authorDid, + authorHandle: null, + }) + } + } + + // Resolve author handles for subjects + const subjectAuthorDids = [ + ...new Set([...subjectMap.values()].map((s) => s.authorDid)), + ] + if (subjectAuthorDids.length > 0) { + const authorRows = await db + .select({ did: users.did, handle: users.handle }) + .from(users) + .where(inArray(users.did, subjectAuthorDids)) + const handleMap = new Map(authorRows.map((r) => [r.did, r.handle])) + for (const [, subject] of subjectMap) { + subject.authorHandle = handleMap.get(subject.authorDid) ?? null + } + } + + // Enrich notifications + const enriched = serialized.map((n) => { + const actorHandle = actorHandleMap.get(n.actorDid) ?? null + const subject = subjectMap.get(n.subjectUri) + return { + ...n, + actorHandle, + subjectTitle: subject?.title ?? null, + subjectAuthorDid: subject?.authorDid ?? null, + subjectAuthorHandle: subject?.authorHandle ?? null, + message: buildNotificationMessage(n.type, actorHandle, subject?.title ?? null), + } + }) + // Get total count for the user const countResult = await db .select({ count: sql`count(*)::int` }) @@ -182,7 +327,7 @@ export function notificationRoutes(): FastifyPluginCallback { } return reply.status(200).send({ - notifications: serialized, + notifications: enriched, cursor: nextCursor, total, }) diff --git a/src/routes/replies.ts b/src/routes/replies.ts index 6c3a2f0..c49e156 100644 --- a/src/routes/replies.ts +++ b/src/routes/replies.ts @@ -32,6 +32,7 @@ import { communitySettings } from '../db/schema/community-settings.js' import { checkOnboardingComplete } from '../lib/onboarding-gate.js' import { createNotificationService } from '../services/notification.js' import { extractRkey } from '../lib/at-uri.js' +import { resolveHandleToDid } from '../lib/resolve-handle-to-did.js' // --------------------------------------------------------------------------- // Constants @@ -163,10 +164,11 @@ function decodeCursor(cursor: string): { createdAt: string; uri: string } | null /** * Reply routes for the Barazo forum. * - * - POST /api/topics/:topicUri/replies -- Create a reply - * - GET /api/topics/:topicUri/replies -- List replies for a topic - * - PUT /api/replies/:uri -- Update a reply - * - DELETE /api/replies/:uri -- Delete a reply + * - POST /api/topics/:topicUri/replies -- Create a reply + * - GET /api/topics/:topicUri/replies -- List replies for a topic + * - GET /api/replies/by-author-rkey/:handle/:rkey -- Get a reply by author handle and rkey + * - PUT /api/replies/:uri -- Update a reply + * - DELETE /api/replies/:uri -- Delete a reply */ export function replyRoutes(): FastifyPluginCallback { return (app, _opts, done) => { @@ -696,6 +698,53 @@ export function replyRoutes(): FastifyPluginCallback { } ) + // ------------------------------------------------------------------- + // GET /api/replies/by-author-rkey/:handle/:rkey (public, optionalAuth) + // ------------------------------------------------------------------- + + app.get( + '/api/replies/by-author-rkey/:handle/:rkey', + { + preHandler: [authMiddleware.optionalAuth], + schema: { + tags: ['Replies'], + summary: 'Get a single reply by author handle and rkey', + params: { + type: 'object', + required: ['handle', 'rkey'], + properties: { + handle: { type: 'string' }, + rkey: { type: 'string' }, + }, + }, + response: { + 200: replyJsonSchema, + 404: errorResponseSchema, + }, + }, + }, + async (request, reply) => { + const { handle, rkey } = request.params as { handle: string; rkey: string } + + const did = await resolveHandleToDid(handle, db, app.log) + if (!did) { + throw notFound('User not found') + } + + const rows = await db + .select() + .from(replies) + .where(and(eq(replies.authorDid, did), eq(replies.rkey, rkey))) + + const row = rows[0] + if (!row) { + throw notFound('Reply not found') + } + + return reply.status(200).send(serializeReply(row)) + } + ) + // ------------------------------------------------------------------- // PUT /api/replies/:uri (auth required, author only) // ------------------------------------------------------------------- diff --git a/src/routes/topics.ts b/src/routes/topics.ts index a536233..219b814 100644 --- a/src/routes/topics.ts +++ b/src/routes/topics.ts @@ -33,6 +33,7 @@ import { communitySettings } from '../db/schema/community-settings.js' import { checkOnboardingComplete } from '../lib/onboarding-gate.js' import { createNotificationService } from '../services/notification.js' import { extractRkey } from '../lib/at-uri.js' +import { resolveHandleToDid } from '../lib/resolve-handle-to-did.js' // --------------------------------------------------------------------------- // Constants @@ -233,6 +234,7 @@ export function topicRoutes(): FastifyPluginCallback { rkey: { type: 'string' }, title: { type: 'string' }, category: { type: 'string' }, + authorHandle: { type: 'string' }, moderationStatus: { type: 'string', enum: ['approved', 'held', 'rejected'] }, createdAt: { type: 'string', format: 'date-time' }, }, @@ -448,6 +450,7 @@ export function topicRoutes(): FastifyPluginCallback { crossPostService .crossPostTopic({ did: user.did, + handle: user.handle, topicUri: pdsResult.uri, title, content, @@ -479,6 +482,7 @@ export function topicRoutes(): FastifyPluginCallback { rkey, title, category, + authorHandle: user.handle, moderationStatus: contentModerationStatus, createdAt: now, }) @@ -846,6 +850,81 @@ export function topicRoutes(): FastifyPluginCallback { } ) + // ------------------------------------------------------------------- + // GET /api/topics/by-author-rkey/:handle/:rkey (public, optionalAuth) + // ------------------------------------------------------------------- + + app.get( + '/api/topics/by-author-rkey/:handle/:rkey', + { + preHandler: [authMiddleware.optionalAuth], + schema: { + tags: ['Topics'], + summary: 'Get a single topic by author handle and rkey', + params: { + type: 'object', + required: ['handle', 'rkey'], + properties: { + handle: { type: 'string' }, + rkey: { type: 'string' }, + }, + }, + response: { + 200: topicJsonSchema, + 403: errorResponseSchema, + 404: errorResponseSchema, + }, + }, + }, + async (request, reply) => { + const { handle, rkey } = request.params as { handle: string; rkey: string } + + const did = await resolveHandleToDid(handle, db, app.log) + if (!did) { + throw notFound('User not found') + } + + const rows = await db + .select() + .from(topics) + .where(and(eq(topics.authorDid, did), eq(topics.rkey, rkey))) + + const row = rows[0] + if (!row) { + throw notFound('Topic not found') + } + + const communityDid = requireCommunityDid(request) + const catRows = await db + .select({ maturityRating: categories.maturityRating }) + .from(categories) + .where(and(eq(categories.slug, row.category), eq(categories.communityDid, communityDid))) + const categoryRating = catRows[0]?.maturityRating ?? 'safe' + + let userProfile: MaturityUser | undefined + if (request.user) { + const userRows = await db + .select({ declaredAge: users.declaredAge, maturityPref: users.maturityPref }) + .from(users) + .where(eq(users.did, request.user.did)) + userProfile = userRows[0] ?? undefined + } + + const authorRkeySettingsRows = await db + .select({ ageThreshold: communitySettings.ageThreshold }) + .from(communitySettings) + .where(eq(communitySettings.communityDid, communityDid)) + const authorRkeyAgeThreshold = authorRkeySettingsRows[0]?.ageThreshold ?? 16 + + const maxMaturity = resolveMaxMaturity(userProfile, authorRkeyAgeThreshold) + if (!maturityAllows(maxMaturity, categoryRating)) { + throw forbidden('Content restricted by maturity settings') + } + + return reply.status(200).send(serializeTopic(row, categoryRating)) + } + ) + // ------------------------------------------------------------------- // GET /api/topics/:uri (public, optionalAuth) // ------------------------------------------------------------------- diff --git a/src/services/cross-post.ts b/src/services/cross-post.ts index 7929c77..9d77b97 100644 --- a/src/services/cross-post.ts +++ b/src/services/cross-post.ts @@ -30,6 +30,7 @@ const FRONTPAGE_COLLECTION = 'fyi.frontpage.post' export interface CrossPostParams { did: string + handle: string topicUri: string title: string content: string @@ -79,11 +80,11 @@ function buildBlueskyPostText(title: string, content: string): string { } /** - * Build the public URL for a topic from its AT URI. + * Build the public URL for a topic using AT Protocol-style format. */ -function buildTopicUrl(publicUrl: string, topicUri: string): string { +function buildTopicUrl(publicUrl: string, handle: string, topicUri: string): string { const rkey = extractRkey(topicUri) - return `${publicUrl}/topics/${rkey}` + return `${publicUrl}/${handle}/${rkey}` } // --------------------------------------------------------------------------- @@ -136,7 +137,7 @@ export function createCrossPostService( * to the forum topic and a branded OG image thumbnail. */ async function crossPostToBluesky(params: CrossPostParams, thumb: unknown): Promise { - const topicUrl = buildTopicUrl(config.publicUrl, params.topicUri) + const topicUrl = buildTopicUrl(config.publicUrl, params.handle, params.topicUri) const postText = buildBlueskyPostText(params.title, params.content) const external: Record = { @@ -189,7 +190,7 @@ export function createCrossPostService( * (link submission pointing back to the forum topic). */ async function crossPostToFrontpage(params: CrossPostParams): Promise { - const topicUrl = buildTopicUrl(config.publicUrl, params.topicUri) + const topicUrl = buildTopicUrl(config.publicUrl, params.handle, params.topicUri) const record: Record = { title: params.title, diff --git a/tests/unit/routes/notifications.test.ts b/tests/unit/routes/notifications.test.ts index a3dc3c7..44d6d94 100644 --- a/tests/unit/routes/notifications.test.ts +++ b/tests/unit/routes/notifications.test.ts @@ -68,6 +68,18 @@ function resetAllDbMocks(): void { }) } +/** + * Mock the enrichment queries that run after fetching notification rows. + * These resolve actor handles and subject titles. Since mocked topic queries + * return empty arrays, no subject author handle query is triggered. + * Call this after `selectChain.limit.mockResolvedValueOnce(rows)` when + * `rows` is non-empty (i.e., there are notifications to enrich). + */ +function mockEnrichmentQueries(): void { + selectChain.where.mockResolvedValueOnce([]) // actor handles (users table) + selectChain.where.mockResolvedValueOnce([]) // topic subjects (topics table) +} + // --------------------------------------------------------------------------- // Auth middleware mocks // --------------------------------------------------------------------------- @@ -261,6 +273,7 @@ describe('notification routes', () => { } selectChain.where.mockReturnValueOnce(chainableThenable) selectChain.limit.mockResolvedValueOnce([unreadNotification, readNotification]) + mockEnrichmentQueries() selectChain.where.mockResolvedValueOnce([{ count: 2 }]) const response = await app.inject({ @@ -301,6 +314,7 @@ describe('notification routes', () => { } selectChain.where.mockReturnValueOnce(chainableThenable) selectChain.limit.mockResolvedValueOnce(rows) + mockEnrichmentQueries() selectChain.where.mockResolvedValueOnce([{ count: 50 }]) const response = await app.inject({ @@ -332,6 +346,7 @@ describe('notification routes', () => { } selectChain.where.mockReturnValueOnce(chainableThenable) selectChain.limit.mockResolvedValueOnce(rows) + mockEnrichmentQueries() selectChain.where.mockResolvedValueOnce([{ count: 1 }]) const response = await app.inject({ @@ -360,6 +375,7 @@ describe('notification routes', () => { } selectChain.where.mockReturnValueOnce(chainableThenable) selectChain.limit.mockResolvedValueOnce([sampleNotificationRow()]) + mockEnrichmentQueries() selectChain.where.mockResolvedValueOnce([{ count: 1 }]) const response = await app.inject({ @@ -423,6 +439,7 @@ describe('notification routes', () => { } selectChain.where.mockReturnValueOnce(chainableThenable) selectChain.limit.mockResolvedValueOnce([unreadRow]) + mockEnrichmentQueries() selectChain.where.mockResolvedValueOnce([{ count: 1 }]) const response = await app.inject({ @@ -454,6 +471,7 @@ describe('notification routes', () => { } selectChain.where.mockReturnValueOnce(chainableThenable) selectChain.limit.mockResolvedValueOnce([unreadRow, readRow]) + mockEnrichmentQueries() selectChain.where.mockResolvedValueOnce([{ count: 2 }]) const response = await app.inject({ @@ -484,6 +502,7 @@ describe('notification routes', () => { } selectChain.where.mockReturnValueOnce(chainableThenable) selectChain.limit.mockResolvedValueOnce([unreadRow, readRow]) + mockEnrichmentQueries() selectChain.where.mockResolvedValueOnce([{ count: 2 }]) const response = await app.inject({ @@ -522,6 +541,7 @@ describe('notification routes', () => { } selectChain.where.mockReturnValueOnce(chainableThenable) selectChain.limit.mockResolvedValueOnce([row]) + mockEnrichmentQueries() selectChain.where.mockResolvedValueOnce([{ count: 10 }]) const response = await app.inject({ @@ -559,6 +579,7 @@ describe('notification routes', () => { } selectChain.where.mockReturnValueOnce(chainableThenable) selectChain.limit.mockResolvedValueOnce([row]) + mockEnrichmentQueries() selectChain.where.mockResolvedValueOnce([{ count: 5 }]) const response = await app.inject({ @@ -776,6 +797,7 @@ describe('notification routes', () => { } selectChain.where.mockReturnValueOnce(chainableThenable) selectChain.limit.mockResolvedValueOnce(rows) + mockEnrichmentQueries() selectChain.where.mockResolvedValueOnce([{ count: 25 }]) const response = await app.inject({ @@ -811,6 +833,7 @@ describe('notification routes', () => { } selectChain.where.mockReturnValueOnce(chainableThenable) selectChain.limit.mockResolvedValueOnce(rows) + mockEnrichmentQueries() selectChain.where.mockResolvedValueOnce([{ count: 5 }]) const response = await app.inject({ @@ -847,6 +870,7 @@ describe('notification routes', () => { } selectChain.where.mockReturnValueOnce(chainableThenable) selectChain.limit.mockResolvedValueOnce([row]) + mockEnrichmentQueries() selectChain.where.mockResolvedValueOnce([{ count: 5 }]) const response = await app.inject({ @@ -891,6 +915,7 @@ describe('notification routes', () => { } selectChain.where.mockReturnValueOnce(chainableThenable) selectChain.limit.mockResolvedValueOnce(rows) + mockEnrichmentQueries() selectChain.where.mockResolvedValueOnce([{ count: 100 }]) const response = await app.inject({ diff --git a/tests/unit/routes/replies.test.ts b/tests/unit/routes/replies.test.ts index a9d3e69..4e44135 100644 --- a/tests/unit/routes/replies.test.ts +++ b/tests/unit/routes/replies.test.ts @@ -72,6 +72,12 @@ vi.mock('../../../src/lib/onboarding-gate.js', () => ({ checkOnboardingComplete: (...args: unknown[]) => checkOnboardingCompleteFn(...args) as unknown, })) +// Mock handle-to-DID resolver +const resolveHandleToDidFn = vi.fn<(handle: string) => Promise>() +vi.mock('../../../src/lib/resolve-handle-to-did.js', () => ({ + resolveHandleToDid: (...args: unknown[]) => resolveHandleToDidFn(args[0] as string), +})) + // Import routes AFTER mocking import { replyRoutes } from '../../../src/routes/replies.js' @@ -2562,4 +2568,65 @@ describe('reply routes', () => { expect(body.error).toBe('Authentication required') }) }) + + // ========================================================================= + // GET /api/replies/by-author-rkey/:handle/:rkey + // ========================================================================= + + describe('GET /api/replies/by-author-rkey/:handle/:rkey', () => { + let app: FastifyInstance + + beforeAll(async () => { + app = await buildTestApp(testUser()) + }) + + afterAll(async () => { + await app.close() + }) + + beforeEach(() => { + vi.clearAllMocks() + resetAllDbMocks() + }) + + it('returns a reply by author handle and rkey', async () => { + resolveHandleToDidFn.mockResolvedValueOnce(TEST_DID) + const row = sampleReplyRow() + selectChain.where.mockResolvedValueOnce([row]) + + const response = await app.inject({ + method: 'GET', + url: `/api/replies/by-author-rkey/${TEST_HANDLE}/${TEST_REPLY_RKEY}`, + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ uri: string; rkey: string; content: string }>() + expect(body.uri).toBe(TEST_REPLY_URI) + expect(body.rkey).toBe(TEST_REPLY_RKEY) + expect(body.content).toBe('This is a test reply') + }) + + it('returns 404 when handle cannot be resolved', async () => { + resolveHandleToDidFn.mockResolvedValueOnce(null) + + const response = await app.inject({ + method: 'GET', + url: '/api/replies/by-author-rkey/unknown.handle/reply001', + }) + + expect(response.statusCode).toBe(404) + }) + + it('returns 404 when reply not found for author', async () => { + resolveHandleToDidFn.mockResolvedValueOnce(TEST_DID) + selectChain.where.mockResolvedValueOnce([]) + + const response = await app.inject({ + method: 'GET', + url: `/api/replies/by-author-rkey/${TEST_HANDLE}/nonexistent`, + }) + + expect(response.statusCode).toBe(404) + }) + }) }) diff --git a/tests/unit/routes/topics.test.ts b/tests/unit/routes/topics.test.ts index dda18a4..d327296 100644 --- a/tests/unit/routes/topics.test.ts +++ b/tests/unit/routes/topics.test.ts @@ -62,6 +62,12 @@ vi.mock('../../../src/services/notification.js', () => ({ }), })) +// Mock handle-to-DID resolver +const resolveHandleToDidFn = vi.fn<(handle: string) => Promise>() +vi.mock('../../../src/lib/resolve-handle-to-did.js', () => ({ + resolveHandleToDid: (...args: unknown[]) => resolveHandleToDidFn(args[0] as string), +})) + // Mock anti-spam module (tested separately in anti-spam.test.ts) const loadAntiSpamSettingsFn = vi.fn().mockResolvedValue({ wordFilter: [], @@ -322,9 +328,10 @@ describe('topic routes', () => { }) expect(response.statusCode).toBe(201) - const body = response.json<{ uri: string; cid: string }>() + const body = response.json<{ uri: string; cid: string; authorHandle: string }>() expect(body.uri).toBe(TEST_URI) expect(body.cid).toBe(TEST_CID) + expect(body.authorHandle).toBe(TEST_HANDLE) // Should have called PDS createRecord expect(createRecordFn).toHaveBeenCalledOnce() @@ -1207,6 +1214,91 @@ describe('topic routes', () => { }) }) + // ========================================================================= + // GET /api/topics/by-author-rkey/:handle/:rkey + // ========================================================================= + + describe('GET /api/topics/by-author-rkey/:handle/:rkey', () => { + let app: FastifyInstance + + beforeAll(async () => { + app = await buildTestApp(testUser()) + }) + + afterAll(async () => { + await app.close() + }) + + beforeEach(() => { + vi.clearAllMocks() + resetAllDbMocks() + }) + + it('returns a topic by author handle and rkey', async () => { + resolveHandleToDidFn.mockResolvedValueOnce(TEST_DID) + const row = sampleTopicRow() + // 1. select().from(topics).where(authorDid, rkey) -> find topic + selectChain.where.mockResolvedValueOnce([row]) + // 2. select(maturityRating).from(categories).where() -> category lookup + selectChain.where.mockResolvedValueOnce([{ maturityRating: 'safe' }]) + // 3. select(declaredAge, maturityPref).from(users).where() -> user profile + selectChain.where.mockResolvedValueOnce([{ declaredAge: null, maturityPref: 'safe' }]) + // 4. select(ageThreshold).from(communitySettings).where() -> age threshold + selectChain.where.mockResolvedValueOnce([{ ageThreshold: 16 }]) + + const response = await app.inject({ + method: 'GET', + url: `/api/topics/by-author-rkey/${TEST_HANDLE}/${TEST_RKEY}`, + }) + + expect(response.statusCode).toBe(200) + const body = response.json<{ uri: string; title: string }>() + expect(body.uri).toBe(TEST_URI) + expect(body.title).toBe('Test Topic Title') + }) + + it('returns 404 when handle cannot be resolved', async () => { + resolveHandleToDidFn.mockResolvedValueOnce(null) + + const response = await app.inject({ + method: 'GET', + url: '/api/topics/by-author-rkey/unknown.handle/abc123', + }) + + expect(response.statusCode).toBe(404) + }) + + it('returns 404 when topic not found for author', async () => { + resolveHandleToDidFn.mockResolvedValueOnce(TEST_DID) + selectChain.where.mockResolvedValueOnce([]) // no topic found + + const response = await app.inject({ + method: 'GET', + url: `/api/topics/by-author-rkey/${TEST_HANDLE}/nonexistent`, + }) + + expect(response.statusCode).toBe(404) + }) + + it('returns 403 when maturity blocks access', async () => { + const noAuthApp = await buildTestApp(undefined) + + resolveHandleToDidFn.mockResolvedValueOnce(TEST_DID) + const row = sampleTopicRow({ category: 'mature-cat' }) + selectChain.where.mockResolvedValueOnce([row]) + selectChain.where.mockResolvedValueOnce([{ maturityRating: 'mature' }]) + selectChain.where.mockResolvedValueOnce([{ ageThreshold: 16 }]) + + const response = await noAuthApp.inject({ + method: 'GET', + url: `/api/topics/by-author-rkey/${TEST_HANDLE}/${TEST_RKEY}`, + }) + + expect(response.statusCode).toBe(403) + await noAuthApp.close() + }) + }) + // ========================================================================= // PUT /api/topics/:uri // ========================================================================= diff --git a/tests/unit/services/cross-post.test.ts b/tests/unit/services/cross-post.test.ts index 307ceee..7095c8c 100644 --- a/tests/unit/services/cross-post.test.ts +++ b/tests/unit/services/cross-post.test.ts @@ -167,6 +167,7 @@ describe('cross-post service', () => { await service.crossPostTopic({ did: TEST_DID, + handle: 'test.handle', topicUri: TEST_TOPIC_URI, title: 'My Topic', content: 'Topic content here.', @@ -218,6 +219,7 @@ describe('cross-post service', () => { await service.crossPostTopic({ did: TEST_DID, + handle: 'test.handle', topicUri: TEST_TOPIC_URI, title: 'OG Image Topic', content: 'Testing OG image.', @@ -269,6 +271,7 @@ describe('cross-post service', () => { await service.crossPostTopic({ did: TEST_DID, + handle: 'test.handle', topicUri: TEST_TOPIC_URI, title: 'No Thumb Topic', content: 'OG image will fail.', @@ -318,6 +321,7 @@ describe('cross-post service', () => { await service.crossPostTopic({ did: TEST_DID, + handle: 'test.handle', topicUri: TEST_TOPIC_URI, title: 'Upload Fail Topic', content: 'Blob upload will fail.', @@ -359,6 +363,7 @@ describe('cross-post service', () => { await service.crossPostTopic({ did: TEST_DID, + handle: 'test.handle', topicUri: TEST_TOPIC_URI, title: 'Frontpage Topic', content: 'Content for Frontpage.', @@ -375,7 +380,7 @@ describe('cross-post service', () => { expect(did).toBe(TEST_DID) expect(collection).toBe('fyi.frontpage.post') expect(record.title).toBe('Frontpage Topic') - expect(record.url).toBe(`${TEST_PUBLIC_URL}/topics/abc123`) + expect(record.url).toBe(`${TEST_PUBLIC_URL}/test.handle/abc123`) expect(mockDb.insert).toHaveBeenCalledOnce() }) @@ -406,6 +411,7 @@ describe('cross-post service', () => { await service.crossPostTopic({ did: TEST_DID, + handle: 'test.handle', topicUri: TEST_TOPIC_URI, title: 'Dual Post', content: 'Content for both.', @@ -437,6 +443,7 @@ describe('cross-post service', () => { await service.crossPostTopic({ did: TEST_DID, + handle: 'test.handle', topicUri: TEST_TOPIC_URI, title: 'No Cross-Post', content: 'Should not go anywhere.', @@ -469,6 +476,7 @@ describe('cross-post service', () => { await service.crossPostTopic({ did: TEST_DID, + handle: 'test.handle', topicUri: TEST_TOPIC_URI, title: 'No Scopes', content: 'User has not authorized.', @@ -501,6 +509,7 @@ describe('cross-post service', () => { await service.crossPostTopic({ did: TEST_DID, + handle: 'test.handle', topicUri: TEST_TOPIC_URI, title: 'Will Fail', content: 'Bluesky is down.', @@ -547,6 +556,7 @@ describe('cross-post service', () => { await service.crossPostTopic({ did: TEST_DID, + handle: 'test.handle', topicUri: TEST_TOPIC_URI, title: 'FP Fail', content: 'Frontpage is down.', @@ -580,6 +590,7 @@ describe('cross-post service', () => { await service.crossPostTopic({ did: TEST_DID, + handle: 'test.handle', topicUri: TEST_TOPIC_URI, title: 'Both Fail', content: 'Everything is broken.', @@ -620,6 +631,7 @@ describe('cross-post service', () => { await service.crossPostTopic({ did: TEST_DID, + handle: 'test.handle', topicUri: TEST_TOPIC_URI, title: 'Partial Success', content: 'One succeeds, one fails.', @@ -674,6 +686,7 @@ describe('cross-post service', () => { await service.crossPostTopic({ did: TEST_DID, + handle: 'test.handle', topicUri: TEST_TOPIC_URI, title: 'Short Title', content: longContent, @@ -713,6 +726,7 @@ describe('cross-post service', () => { await service.crossPostTopic({ did: TEST_DID, + handle: 'test.handle', topicUri: TEST_TOPIC_URI, title: 'URL Test', content: 'Content.', @@ -727,7 +741,7 @@ describe('cross-post service', () => { ] const embed = record.embed as Record const external = embed.external as Record - expect(external.uri).toBe(`${TEST_PUBLIC_URL}/topics/abc123`) + expect(external.uri).toBe(`${TEST_PUBLIC_URL}/test.handle/abc123`) }) it('does not generate OG image for Frontpage-only cross-posts', async () => { @@ -751,6 +765,7 @@ describe('cross-post service', () => { await service.crossPostTopic({ did: TEST_DID, + handle: 'test.handle', topicUri: TEST_TOPIC_URI, title: 'FP Only', content: 'No OG needed.', -- 2.51.2